diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index edadc6e43..e969a382c 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -126,6 +126,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 ) @@ -1452,6 +1453,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: @@ -1750,6 +1755,35 @@ 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. + + 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(): + 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 4cc4ab711..9116b1040 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -457,6 +457,11 @@ 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. + # 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 # 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 2d55ef52d..f8870a888 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -423,6 +423,76 @@ 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 == {} + + +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()