From 680049319a50dc7b6cf6b346af9e1de0ef25a83a Mon Sep 17 00:00:00 2001 From: Nikarhub <46433701+Nikarhub@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:47:27 +0800 Subject: [PATCH 1/2] feat(box): inject box.local.env into every exec payload --- src/langbot/pkg/box/service.py | 27 +++++++++++++ src/langbot/templates/config.yaml | 3 ++ tests/unit_tests/box/test_box_service.py | 50 ++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index bf626090b..7c5dbaadb 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -124,6 +124,7 @@ class BoxService: self.profile = self._load_profile() self.custom_image = self._load_custom_image() self.workspace_quota_mb = self._load_workspace_quota_mb() + self.default_env = self._load_default_env() self._admission_policy = ( require_cloud_admission_policy(_get_box_config(ap).get('admission')) if self._cloud_managed else None ) @@ -1394,6 +1395,10 @@ class BoxService: def build_spec(self, spec_payload: dict, skip_host_mount_validation: bool = False) -> BoxSpec: spec_payload = dict(spec_payload) spec_payload.setdefault('env', {}) + if self.default_env: + merged_env = dict(self.default_env) + merged_env.update(spec_payload.get('env') or {}) + spec_payload['env'] = merged_env if spec_payload.get('host_path') in (None, '') and self.default_workspace is not None: spec_payload['host_path'] = self.default_workspace if spec_payload.get('workspace_quota_mb') in (None, '') and self.workspace_quota_mb is not None: @@ -1831,6 +1836,28 @@ class BoxService: raise BoxValidationError('workspace_quota_mb must be greater than or equal to 0') return value + def _load_default_env(self) -> dict[str, str]: + """Return ``box.local.env`` as operator-provided sandbox defaults. + + These values are merged into every Box exec payload so sandbox commands + can read settings such as a service base URL. A per-call ``env`` supplied + by the agent or caller overrides the matching defaults. Non-dict configs + and non-string keys or values are ignored. + """ + raw_env = self._local_config().get('env') + if not isinstance(raw_env, dict): + return {} + + env: dict[str, str] = {} + for key, value in raw_env.items(): + if not isinstance(key, str) or not isinstance(value, str): + continue + normalized_key = key.strip() + if not normalized_key: + continue + env[normalized_key] = value + return env + def _ensure_default_workspace(self): if self.default_workspace is None: return diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 6b3c716fa..bd4973598 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -383,6 +383,9 @@ box: - './data/box' - '/tmp' workspace_quota_mb: null # Optional disk quota override (>= 0). null = profile default. + # Extra environment variables injected into every Box exec payload. + # The agent-supplied per-call env overrides matching keys. Values must be strings. + env: {} # Default nsjail cgroup memory limit for each MCP stdio server process, in MB. # Node.js MCP servers (npx/bunx) need more memory than Python ones because V8 # and WebAssembly modules (e.g. undici llhttp) reserve large virtual address diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index ce6e7bc5f..68f680ec4 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -416,6 +416,56 @@ def test_separated_box_runtime_allows_box_owned_missing_host_path(tmp_path): assert not (host_root / 'default').exists() +def test_box_build_spec_injects_local_default_env(): + logger = Mock() + app = make_app(logger) + app.instance_config.data['box']['local']['env'] = {'JENKINS_BASE_URL': 'https://jenkins.example.com'} + service = BoxService(app, client=Mock()) + + spec = service.build_spec({'cmd': 'echo hi', 'session_id': 'default-env'}) + + assert spec.env == {'JENKINS_BASE_URL': 'https://jenkins.example.com'} + + +def test_box_build_spec_per_call_env_overrides_local_default(): + logger = Mock() + app = make_app(logger) + app.instance_config.data['box']['local']['env'] = { + 'JENKINS_BASE_URL': 'https://default.example.com', + 'KEEP_ME': 'yes', + } + service = BoxService(app, client=Mock()) + + spec = service.build_spec( + {'cmd': 'echo hi', 'session_id': 'override-env', 'env': {'JENKINS_BASE_URL': 'https://override.example.com'}} + ) + + assert spec.env == {'JENKINS_BASE_URL': 'https://override.example.com', 'KEEP_ME': 'yes'} + + +def test_box_build_spec_ignores_invalid_local_env_entries(): + logger = Mock() + app = make_app(logger) + app.instance_config.data['box']['local']['env'] = { + 'OK': 'value', + '': 'empty-key', + ' ': 'blank-key', + 'BAD_VALUE': 123, + 3: 'non-string-key', + } + service = BoxService(app, client=Mock()) + + spec = service.build_spec({'cmd': 'echo hi', 'session_id': 'invalid-env'}) + + assert spec.env == {'OK': 'value'} + + app.instance_config.data['box']['local']['env'] = 'not-a-dict' + service = BoxService(app, client=Mock()) + spec = service.build_spec({'cmd': 'echo hi', 'session_id': 'non-dict-env'}) + + assert spec.env == {} + + @pytest.mark.asyncio async def test_box_service_get_sessions_delegates_to_client(): client = Mock() From 49afd9f3205e7df02cacce443bfb4a2a6d38d67f Mon Sep 17 00:00:00 2001 From: RockChinQ <45992437+RockChinQ@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:06:00 +0000 Subject: [PATCH 2/2] fix(box): ignore box.local.env in multi-workspace deployments Co-Authored-By: Claude Opus 5.5 Co-authored-by: multica-agent --- src/langbot/pkg/box/service.py | 7 +++++++ src/langbot/templates/config.yaml | 2 ++ tests/unit_tests/box/test_box_service.py | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index 7c5dbaadb..55d4f39ab 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -1843,10 +1843,17 @@ class BoxService: can read settings such as a service base URL. A per-call ``env`` supplied by the agent or caller overrides the matching defaults. Non-dict configs and non-string keys or values are ignored. + + The setting is instance-wide, so it is disabled in multi-workspace + deployments to keep operator values from reaching every tenant's sandbox. """ raw_env = self._local_config().get('env') if not isinstance(raw_env, dict): return {} + if self._cloud_managed: + if raw_env: + self.ap.logger.warning('box.local.env is ignored in multi-workspace deployments.') + return {} env: dict[str, str] = {} for key, value in raw_env.items(): diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 098cdcb9f..cd43709db 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -391,6 +391,8 @@ box: workspace_quota_mb: null # Optional disk quota override (>= 0). null = profile default. # Extra environment variables injected into every Box exec payload. # The agent-supplied per-call env overrides matching keys. Values must be strings. + # Every sandbox command, including untrusted agent commands, can read them; + # do not put secrets here. Loaded at startup; ignored in multi-workspace deployments. env: {} # Default nsjail cgroup memory limit for each MCP stdio server process, in MB. # Node.js MCP servers (npx/bunx) need more memory than Python ones because V8 diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index 68f680ec4..dd849316f 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -466,6 +466,26 @@ def test_box_build_spec_ignores_invalid_local_env_entries(): assert spec.env == {} +def test_box_build_spec_ignores_local_env_in_multi_workspace_deployment(): + logger = Mock() + app = make_app(logger) + app.deployment = SimpleNamespace(multi_workspace_enabled=True) + app.instance_config.data['box'].update( + { + 'backend': 'nsjail', + 'admission': {'required': True, 'workspace_quota_mb': 32}, + } + ) + app.instance_config.data['box']['local']['env'] = {'JENKINS_BASE_URL': 'https://jenkins.example.com'} + service = BoxService(app, client=Mock()) + + spec = service.build_spec({'cmd': 'echo hi', 'session_id': 'cloud-env', 'env': {'PER_CALL': '1'}}) + + assert service.default_env == {} + assert spec.env == {'PER_CALL': '1'} + logger.warning.assert_any_call('box.local.env is ignored in multi-workspace deployments.') + + @pytest.mark.asyncio async def test_box_service_get_sessions_delegates_to_client(): client = Mock()