diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 9f30e64a96..93454716de 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -1646,6 +1646,7 @@ sandbox: request_timeout: 30 # management request timeout seconds ready_timeout: 30 # create/readiness timeout seconds use_server_proxy: false # proxy execd/file traffic through server + secure_access: false # per-sandbox endpoint token; Kubernetes + gateway mode only sandbox_timeout: 14400 # remote lifetime; 0 = explicit cleanup bash_command_timeout: 600 # default remote command timeout seconds replicas: 3 # active + warm cap per gateway process @@ -1654,6 +1655,10 @@ sandbox: PYTHONUNBUFFERED: "1" ``` +`use_server_proxy` and `secure_access` accept YAML booleans. Omitted or `null` +values default to `false`. Quoted values such as `"false"` cause a provider +startup error. + Install the optional SDK before selecting this provider: ```bash diff --git a/backend/packages/harness/deerflow/community/opensandbox/AGENTS.md b/backend/packages/harness/deerflow/community/opensandbox/AGENTS.md index 4430e23674..440ad3c5db 100644 --- a/backend/packages/harness/deerflow/community/opensandbox/AGENTS.md +++ b/backend/packages/harness/deerflow/community/opensandbox/AGENTS.md @@ -6,6 +6,10 @@ General sandbox rules are in the [sandbox guide](../../sandbox/AGENTS.md). cd backend && python -m pytest tests -q -k "opensandbox" ``` +Validate `use_server_proxy` and `secure_access` at provider startup. Accept +booleans, default missing or null values to `false`, and reject strings and +other types before creating an SDK connection. + ## Command deadlines - **Only a `CommandExecError` with `context deadline exceeded` and no numeric diff --git a/backend/packages/harness/deerflow/community/opensandbox/README.md b/backend/packages/harness/deerflow/community/opensandbox/README.md index ae75adf09c..c51c83dc3f 100644 --- a/backend/packages/harness/deerflow/community/opensandbox/README.md +++ b/backend/packages/harness/deerflow/community/opensandbox/README.md @@ -22,6 +22,7 @@ sandbox: # request_timeout: 30 # ready_timeout: 30 # use_server_proxy: false + # secure_access: false # sandbox_timeout: 14400 # remote lifetime; 0 means explicit cleanup only # bash_command_timeout: 600 # default command timeout # replicas: 3 # active + warm sandboxes per gateway process @@ -35,6 +36,19 @@ sandbox: reach the OpenSandbox management service but cannot directly reach sandbox `execd` endpoints. +Use unquoted `true` or `false` for `use_server_proxy` and `secure_access`. +Omitted and `null` values default to `false`. Quoted booleans and other +non-boolean values cause a provider startup error. + +`secure_access: true` creates each sandbox with OpenSandbox +[Secure Access](https://github.com/opensandbox-group/OpenSandbox/blob/main/docs/guides/secure-access.md): +the ingress gateway, and the server proxy, then reject endpoint requests that +lack the sandbox's access token. The server issues the token with the endpoint +and the SDK attaches it to every execd and file request, so no token setting is +needed in DeerFlow. The server must run the Kubernetes runtime with +`[ingress] mode = "gateway"`; the Docker runtime and other ingress modes reject +the create with HTTP 400. + Values in `sandbox.environment` that start with `$` are resolved from the Gateway process environment once, when `config.yaml` loads; a missing variable fails the load. The provider injects the resolved values unchanged, so a secret diff --git a/backend/packages/harness/deerflow/community/opensandbox/provider.py b/backend/packages/harness/deerflow/community/opensandbox/provider.py index aef7422329..af169cb425 100644 --- a/backend/packages/harness/deerflow/community/opensandbox/provider.py +++ b/backend/packages/harness/deerflow/community/opensandbox/provider.py @@ -114,8 +114,16 @@ class OpenSandboxProvider(WarmPoolLifecycleMixin[OpenSandboxSandbox], SandboxPro def _load_config(self) -> dict[str, Any]: sandbox_config = get_app_config().sandbox - def option(name: str, default: Any = None) -> Any: - return getattr(sandbox_config, name, default) + def option(name: str) -> Any: + return getattr(sandbox_config, name, None) + + def bool_option(name: str) -> bool: + value = option(name) + if value is None: + return False + if not isinstance(value, bool): + raise ValueError(f"sandbox.{name} must be true or false") + return value api_key = option("api_key") domain = option("domain") @@ -143,7 +151,8 @@ class OpenSandboxProvider(WarmPoolLifecycleMixin[OpenSandboxSandbox], SandboxPro "domain": domain, "protocol": protocol, "request_timeout": self._positive_float("request_timeout", option("request_timeout"), DEFAULT_REQUEST_TIMEOUT), - "use_server_proxy": bool(option("use_server_proxy", False)), + "use_server_proxy": bool_option("use_server_proxy"), + "secure_access": bool_option("secure_access"), "image": option("image") or DEFAULT_IMAGE, "ready_timeout": self._positive_float("ready_timeout", option("ready_timeout"), DEFAULT_READY_TIMEOUT), "sandbox_timeout": None if sandbox_timeout == 0 else sandbox_timeout, @@ -310,6 +319,7 @@ class OpenSandboxProvider(WarmPoolLifecycleMixin[OpenSandboxSandbox], SandboxPro env=self._config["environment"] or None, metadata=metadata, connection_config=connection_config, + secure_access=self._config["secure_access"], ) try: bootstrap = remote.commands.run( diff --git a/backend/packages/harness/deerflow/config/sandbox_config.py b/backend/packages/harness/deerflow/config/sandbox_config.py index 8ba66767b6..b96e58fd58 100644 --- a/backend/packages/harness/deerflow/config/sandbox_config.py +++ b/backend/packages/harness/deerflow/config/sandbox_config.py @@ -225,6 +225,9 @@ class SandboxConfig(BaseModel): OpenSandboxProvider specific options: api_key, domain, protocol, request_timeout, use_server_proxy: OpenSandbox management and execd connection settings. + secure_access: Ask OpenSandbox to require a per-sandbox access token on + sandbox endpoints (default: false). Needs a Kubernetes runtime in + ingress gateway mode; other servers reject the create with 400. ready_timeout: Create/readiness deadline in seconds (default: 30). sandbox_timeout: Remote lifetime in seconds (default: 14400); 0 requires explicit provider cleanup. diff --git a/backend/tests/test_opensandbox_provider.py b/backend/tests/test_opensandbox_provider.py index c387b128bd..412322a4c8 100644 --- a/backend/tests/test_opensandbox_provider.py +++ b/backend/tests/test_opensandbox_provider.py @@ -388,6 +388,42 @@ def test_null_sandbox_timeout_uses_default(monkeypatch: pytest.MonkeyPatch) -> N provider.shutdown() +@pytest.mark.parametrize(("configured", "expected"), [({}, False), ({"secure_access": None}, False), ({"secure_access": False}, False), ({"secure_access": True}, True)]) +def test_create_forwards_secure_access(monkeypatch: pytest.MonkeyPatch, configured: dict[str, Any], expected: bool) -> None: + provider, sdk = _install(monkeypatch, config=configured) + + provider.acquire("thread-1", user_id="user-1") + + assert sdk.create_calls[0]["secure_access"] is expected + provider.shutdown() + + +@pytest.mark.parametrize("value", ["true", "false", 1, 0]) +def test_provider_rejects_non_boolean_secure_access(monkeypatch: pytest.MonkeyPatch, value: Any) -> None: + with pytest.raises(ValueError, match="sandbox.secure_access must be true or false"): + _install(monkeypatch, config={"secure_access": value}) + + +@pytest.mark.parametrize( + ("configured", "expected"), + [({}, False), ({"use_server_proxy": None}, False), ({"use_server_proxy": False}, False), ({"use_server_proxy": True}, True)], +) +def test_create_forwards_use_server_proxy(monkeypatch: pytest.MonkeyPatch, configured: dict[str, Any], expected: bool) -> None: + provider, sdk = _install(monkeypatch, config=configured) + + try: + provider.acquire("thread-1", user_id="user-1") + assert sdk.create_calls[0]["connection_config"].kwargs["use_server_proxy"] is expected + finally: + provider.shutdown() + + +@pytest.mark.parametrize("value", ["true", "false", 1, 0]) +def test_provider_rejects_non_boolean_use_server_proxy(monkeypatch: pytest.MonkeyPatch, value: Any) -> None: + with pytest.raises(ValueError, match=r"sandbox\.use_server_proxy must be true or false"): + _install(monkeypatch, config={"use_server_proxy": value}) + + @pytest.mark.parametrize( ("config_key", "value", "message"), [ diff --git a/config.example.yaml b/config.example.yaml index 2ec5aba56e..331aa46d8d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -2012,6 +2012,7 @@ sandbox: # # request_timeout: 30 # finite positive management API request timeout seconds # # ready_timeout: 30 # finite positive create/readiness timeout seconds # # use_server_proxy: false # proxy execd/file traffic through server +# # secure_access: false # per-sandbox endpoint token; needs Kubernetes + ingress gateway mode # # sandbox_timeout: 14400 # finite non-negative remote lifetime seconds; 0 = explicit cleanup # # bash_command_timeout: 600 # default remote command timeout seconds # # replicas: 3 # active + warm cap per gateway process