From 8deba4e598d4ba9d43f29b565734c220d2176359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E8=8F=93?= Date: Thu, 13 Aug 2026 19:11:07 +0800 Subject: [PATCH 1/2] feat(integrations): add OpenSandbox environment --- constraints-3.10.txt | 8 + constraints-3.11.txt | 8 + constraints-3.12.txt | 8 + constraints-3.13.txt | 8 + constraints-3.14.txt | 8 + .../opensandbox_environment/README.md | 79 ++ .../opensandbox_environment/__init__.py | 15 + .../opensandbox_environment/agent.py | 47 + docs/guides/README.md | 3 + .../opensandbox_environment/index.md | 211 ++++ .../integrations/opensandbox/verification.md | 28 + pyproject.toml | 5 + src/google/adk/features/_feature_registry.py | 4 + .../adk/integrations/opensandbox/__init__.py | 25 + .../opensandbox/_opensandbox_environment.py | 579 +++++++++++ .../test_opensandbox_environment.py | 910 ++++++++++++++++++ 16 files changed, 1946 insertions(+) create mode 100644 contributing/samples/environment_and_skills/opensandbox_environment/README.md create mode 100644 contributing/samples/environment_and_skills/opensandbox_environment/__init__.py create mode 100644 contributing/samples/environment_and_skills/opensandbox_environment/agent.py create mode 100644 docs/guides/integrations/opensandbox/opensandbox_environment/index.md create mode 100644 docs/guides/integrations/opensandbox/verification.md create mode 100644 src/google/adk/integrations/opensandbox/__init__.py create mode 100644 src/google/adk/integrations/opensandbox/_opensandbox_environment.py create mode 100644 tests/unittests/integrations/opensandbox/test_opensandbox_environment.py diff --git a/constraints-3.10.txt b/constraints-3.10.txt index 25cb0463fdb..36c7c0790de 100644 --- a/constraints-3.10.txt +++ b/constraints-3.10.txt @@ -111,6 +111,7 @@ attrs==26.1.0 # aiohttp # e2b # jsonschema + # opensandbox # referencing authlib==1.7.2 # via @@ -723,6 +724,7 @@ httpx==0.28.1 # llama-index-core # mcp # openai + # opensandbox httpx-sse==0.4.3 # via # -c constraints-3.10.txt.stable.tmp @@ -1032,6 +1034,10 @@ openpyxl==3.1.5 # via # -c constraints-3.10.txt.stable.tmp # google-adk (pyproject.toml) +opensandbox==0.1.15 + # via + # -c constraints-3.10.txt.stable.tmp + # google-adk (pyproject.toml) opentelemetry-api==1.42.1 # via # -c constraints-3.10.txt.stable.tmp @@ -1340,6 +1346,7 @@ pydantic==2.13.4 # llama-index-workflows # mcp # openai + # opensandbox # pydantic-settings # toolbox-core pydantic-core==2.46.4 @@ -1431,6 +1438,7 @@ python-dateutil==2.9.0.post0 # google-cloud-bigquery # kubernetes # oci + # opensandbox # pandas python-discovery==1.4.4 # via diff --git a/constraints-3.11.txt b/constraints-3.11.txt index 14358a25741..4a27711841e 100644 --- a/constraints-3.11.txt +++ b/constraints-3.11.txt @@ -120,6 +120,7 @@ attrs==26.1.0 # aiohttp # e2b # jsonschema + # opensandbox # referencing authlib==1.7.2 # via @@ -794,6 +795,7 @@ httpx==0.28.1 # llama-index-core # mcp # openai + # opensandbox httpx-sse==0.4.3 # via # -c constraints-3.11.txt.stable.tmp @@ -1170,6 +1172,10 @@ openpyxl==3.1.5 # -c constraints-3.11.txt.stable.tmp # google-adk (pyproject.toml) # crewai +opensandbox==0.1.15 + # via + # -c constraints-3.11.txt.stable.tmp + # google-adk (pyproject.toml) opentelemetry-api==1.42.1 # via # -c constraints-3.11.txt.stable.tmp @@ -1542,6 +1548,7 @@ pydantic==2.12.5 # llama-index-workflows # mcp # openai + # opensandbox # pydantic-settings # toolbox-core pydantic-core==2.41.5 @@ -1654,6 +1661,7 @@ python-dateutil==2.9.0.post0 # kubernetes # lance-namespace-urllib3-client # oci + # opensandbox # pandas # pendulum # posthog diff --git a/constraints-3.12.txt b/constraints-3.12.txt index d0a37eb47f9..83ac5d3c618 100644 --- a/constraints-3.12.txt +++ b/constraints-3.12.txt @@ -105,6 +105,7 @@ attrs==26.1.0 # aiohttp # e2b # jsonschema + # opensandbox # referencing authlib==1.7.2 # via @@ -707,6 +708,7 @@ httpx==0.28.1 # llama-index-core # mcp # openai + # opensandbox httpx-sse==0.4.3 # via # -c constraints-3.12.txt.stable.tmp @@ -1020,6 +1022,10 @@ openpyxl==3.1.5 # via # -c constraints-3.12.txt.stable.tmp # google-adk (pyproject.toml) +opensandbox==0.1.15 + # via + # -c constraints-3.12.txt.stable.tmp + # google-adk (pyproject.toml) opentelemetry-api==1.42.1 # via # -c constraints-3.12.txt.stable.tmp @@ -1328,6 +1334,7 @@ pydantic==2.13.4 # llama-index-workflows # mcp # openai + # opensandbox # pydantic-settings # toolbox-core pydantic-core==2.46.4 @@ -1419,6 +1426,7 @@ python-dateutil==2.9.0.post0 # google-cloud-bigquery # kubernetes # oci + # opensandbox # pandas python-discovery==1.4.4 # via diff --git a/constraints-3.13.txt b/constraints-3.13.txt index 5dcb92471f7..f960b803cb8 100644 --- a/constraints-3.13.txt +++ b/constraints-3.13.txt @@ -101,6 +101,7 @@ attrs==26.1.0 # aiohttp # e2b # jsonschema + # opensandbox # referencing authlib==1.7.2 # via @@ -699,6 +700,7 @@ httpx==0.28.1 # llama-index-core # mcp # openai + # opensandbox httpx-sse==0.4.3 # via # -c constraints-3.13.txt.stable.tmp @@ -1012,6 +1014,10 @@ openpyxl==3.1.5 # via # -c constraints-3.13.txt.stable.tmp # google-adk (pyproject.toml) +opensandbox==0.1.15 + # via + # -c constraints-3.13.txt.stable.tmp + # google-adk (pyproject.toml) opentelemetry-api==1.42.1 # via # -c constraints-3.13.txt.stable.tmp @@ -1320,6 +1326,7 @@ pydantic==2.13.4 # llama-index-workflows # mcp # openai + # opensandbox # pydantic-settings # toolbox-core pydantic-core==2.46.4 @@ -1411,6 +1418,7 @@ python-dateutil==2.9.0.post0 # google-cloud-bigquery # kubernetes # oci + # opensandbox # pandas python-discovery==1.4.4 # via diff --git a/constraints-3.14.txt b/constraints-3.14.txt index 2f6b1b5e8f9..b835c1dc054 100644 --- a/constraints-3.14.txt +++ b/constraints-3.14.txt @@ -101,6 +101,7 @@ attrs==26.1.0 # aiohttp # e2b # jsonschema + # opensandbox # referencing authlib==1.7.2 # via @@ -699,6 +700,7 @@ httpx==0.28.1 # llama-index-core # mcp # openai + # opensandbox httpx-sse==0.4.3 # via # -c constraints-3.14.txt.stable.tmp @@ -1012,6 +1014,10 @@ openpyxl==3.1.5 # via # -c constraints-3.14.txt.stable.tmp # google-adk (pyproject.toml) +opensandbox==0.1.15 + # via + # -c constraints-3.14.txt.stable.tmp + # google-adk (pyproject.toml) opentelemetry-api==1.42.1 # via # -c constraints-3.14.txt.stable.tmp @@ -1320,6 +1326,7 @@ pydantic==2.13.4 # llama-index-workflows # mcp # openai + # opensandbox # pydantic-settings # toolbox-core pydantic-core==2.46.4 @@ -1411,6 +1418,7 @@ python-dateutil==2.9.0.post0 # google-cloud-bigquery # kubernetes # oci + # opensandbox # pandas python-discovery==1.4.4 # via diff --git a/contributing/samples/environment_and_skills/opensandbox_environment/README.md b/contributing/samples/environment_and_skills/opensandbox_environment/README.md new file mode 100644 index 00000000000..6469ddeda40 --- /dev/null +++ b/contributing/samples/environment_and_skills/opensandbox_environment/README.md @@ -0,0 +1,79 @@ +# OpenSandbox Environment Sample + +## Overview + +This sample uses `OpenSandboxEnvironment` with `EnvironmentToolset` so an ADK +agent can execute commands and edit files in an isolated, persistent remote +workspace. The default environment creates a `python:3.11` sandbox, keeps its +five-minute lifetime active while the agent uses it, and destroys it when the +toolset closes. + +OpenSandbox can run locally on Docker or behind a remote lifecycle service. See +the [OpenSandbox documentation](https://open-sandbox.ai) for server setup. + +## Prerequisites + +1. Install the OpenSandbox extra: + + ```bash + pip install google-adk[opensandbox] + ``` + +1. Start an OpenSandbox server. A local server uses `localhost:8080` by + default. For a remote service, configure its domain and API key: + + ```bash + export OPEN_SANDBOX_DOMAIN="https://sandbox.example.com" + export OPEN_SANDBOX_API_KEY="your-api-key" + ``` + +1. Configure the model credentials required by your ADK setup. + +## Sample Inputs + +- `Write a Python script that prints the first 20 Fibonacci numbers, run it, and report the output.` +- `Create a binary file containing bytes 0 through 255, then verify its size and SHA-256 digest.` +- `Create a small CSV of five products and write a Python script that reports the most expensive one.` + +## Graph + +```mermaid +graph TD + Agent[opensandbox_coding_agent] -->|calls| Toolset[EnvironmentToolset] + Toolset --> Execute[Execute] + Toolset --> ReadFile[ReadFile] + Toolset --> WriteFile[WriteFile] + Toolset --> EditFile[EditFile] + Toolset -->|runs in| Environment[OpenSandboxEnvironment] +``` + +## How To + +The agent is connected to a normal `EnvironmentToolset`: + +```python +from google.adk.integrations.opensandbox import OpenSandboxEnvironment +from google.adk.tools.environment import EnvironmentToolset + +toolset = EnvironmentToolset( + environment=OpenSandboxEnvironment( + image="python:3.11", + timeout=300, + ) +) +``` + +The environment maps ADK's lifecycle, command, and byte-oriented file APIs to +the OpenSandbox async SDK. Relative paths resolve below `/workspace`. + +To reuse an existing sandbox, pass `sandbox_id`. The environment treats an +attached sandbox as caller-owned, so closing the toolset releases the SDK client +without destroying the remote sandbox: + +```python +environment = OpenSandboxEnvironment(sandbox_id="existing-sandbox-id") +``` + +## Related Guides + +- [OpenSandboxEnvironment guide](../../../../docs/guides/integrations/opensandbox/opensandbox_environment/index.md) - Configure lifecycle, execution, file operations, and remote connections. diff --git a/contributing/samples/environment_and_skills/opensandbox_environment/__init__.py b/contributing/samples/environment_and_skills/opensandbox_environment/__init__.py new file mode 100644 index 00000000000..4015e47d6e4 --- /dev/null +++ b/contributing/samples/environment_and_skills/opensandbox_environment/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent diff --git a/contributing/samples/environment_and_skills/opensandbox_environment/agent.py b/contributing/samples/environment_and_skills/opensandbox_environment/agent.py new file mode 100644 index 00000000000..94eefcd1a6d --- /dev/null +++ b/contributing/samples/environment_and_skills/opensandbox_environment/agent.py @@ -0,0 +1,47 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""An agent that runs generated code in an OpenSandbox workspace.""" + +from __future__ import annotations + +from google.adk import Agent +from google.adk.integrations.opensandbox import OpenSandboxEnvironment +from google.adk.tools.environment import EnvironmentToolset + +root_agent = Agent( + name="opensandbox_coding_agent", + description=( + "A coding agent that executes commands and edits files in an isolated " + "OpenSandbox workspace." + ), + instruction="""\ +You are a coding assistant. Work only through the environment tools, which run +inside an isolated OpenSandbox workspace rather than on the user's machine. + +For each request: +1. Inspect relevant files before changing them. +2. Write the smallest script or data file needed to solve the request. +3. Execute the script and use its actual output in your answer. +4. If a command fails, read stderr, fix the problem, and retry. +""", + tools=[ + EnvironmentToolset( + environment=OpenSandboxEnvironment( + image="python:3.11", + timeout=300, + ) + ) + ], +) diff --git a/docs/guides/README.md b/docs/guides/README.md index 51bc7cd9df9..682b75fa580 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -23,6 +23,9 @@ This directory contains specific developer guides for the ADK Python implementat * [Event and NodeInfo](events/event/index.md) - Understanding Event and NodeInfo in workflows. * [RequestInput](events/request_input/index.md) - How to use RequestInput for human-in-the-loop interactions. +### Integrations +* [OpenSandboxEnvironment](integrations/opensandbox/opensandbox_environment/index.md) - Run ADK environment tools in an isolated OpenSandbox workspace. + ### Memory * [BaseMemoryService](memory/memory_service/index.md) - Storing finished sessions and recalling them from later conversations. diff --git a/docs/guides/integrations/opensandbox/opensandbox_environment/index.md b/docs/guides/integrations/opensandbox/opensandbox_environment/index.md new file mode 100644 index 00000000000..f378f3f1743 --- /dev/null +++ b/docs/guides/integrations/opensandbox/opensandbox_environment/index.md @@ -0,0 +1,211 @@ +# OpenSandboxEnvironment + +`OpenSandboxEnvironment` implements ADK's `BaseEnvironment` contract with an +isolated OpenSandbox workspace. It gives `EnvironmentToolset` a persistent +remote shell and byte-oriented filesystem without running model-generated code +on the ADK host. + +## Introduction + +ADK environment tools need four capabilities from a runtime: lifecycle +management, a working directory, command execution, and file I/O. +`OpenSandboxEnvironment` maps those operations to the asynchronous OpenSandbox +SDK. A single sandbox persists across tool calls, so commands can reuse files +and installed packages. + +The environment can either create a sandbox or attach to an existing one. +Created sandboxes are owned by the environment and destroyed during `close()`. +Attached sandboxes remain caller-owned; closing the environment only releases +its local SDK resources. + +The integration depends on: + +- `BaseEnvironment` and `ExecutionResult` for the ADK runtime contract; +- `EnvironmentToolset` as its primary ADK consumer; +- the optional `opensandbox` package for lifecycle, command, and file APIs. + +## Get started + +Install the optional dependency and start an OpenSandbox lifecycle server: + +```bash +pip install google-adk[opensandbox] +export OPEN_SANDBOX_DOMAIN="localhost:8080" +``` + +Then pass the environment to the standard toolset: + +```python +from google.adk import Agent +from google.adk.integrations.opensandbox import OpenSandboxEnvironment +from google.adk.tools.environment import EnvironmentToolset + +environment = OpenSandboxEnvironment( + image="python:3.11", + timeout=300, +) + +root_agent = Agent( + name="sandboxed_coding_agent", + instruction=( + "Use the environment tools to inspect files, write code, and run it." + ), + tools=[EnvironmentToolset(environment=environment)], +) +``` + +`EnvironmentToolset` initializes the sandbox before returning tools and closes +it when the toolset closes. Relative command and file paths resolve below +`/workspace` by default. + +## How it works + +Initialization is serialized with an async lock, so concurrent callers cannot +create duplicate sandboxes. For a new environment, initialization performs +these steps: + +1. Build an OpenSandbox `ConnectionConfig` from explicit options or the + `OPEN_SANDBOX_API_KEY` and `OPEN_SANDBOX_DOMAIN` environment variables. +1. Call `Sandbox.create()` with an image or snapshot. +1. Create the configured working directory through the filesystem API. +1. Mark the ADK environment initialized only after all setup succeeds. + +If working-directory setup fails, the environment destroys a sandbox it +created. When attaching by `sandbox_id`, the same failure closes only the local +SDK client. + +Before each operation on an owned sandbox, the environment renews its configured +lifetime. A finite command deadline extends that renewal past the deadline, and +a command without a deadline uses periodic renewal heartbeats. It does not renew +an attached sandbox because the caller owns that resource's expiration policy. + +Operations hold a shared lifecycle lease. They can run concurrently, while +`close()` waits for in-flight operations before destroying or disconnecting the +sandbox. Once closing starts, new operations fail fast and a concurrent +`initialize()` waits until cleanup finishes before creating a new sandbox. +Concurrent `close()` calls share the same cleanup operation. If destroying an +owned sandbox fails, a later `close()` reconnects by ID and retries cleanup +before the environment can be initialized again. + +Owned operations serialize renewal requests and remember the expiration time +returned by the service. A shorter concurrent operation cannot overwrite a +longer command's expiration. Operations that can run indefinitely also renew +periodically until they finish. + +Commands use `RunCommandOpts` to set the working directory, command deadline, +and environment variables. OpenSandbox stdout, stderr, and exit status become +an ADK `ExecutionResult`. Nonzero exit status is returned normally rather than +raised as an exception. + +File operations resolve relative paths below the working directory. Reads +return raw bytes, HTTP 404 becomes `FileNotFoundError`, and writes explicitly +use mode `644` rather than the OpenSandbox SDK's executable default. + +## Configuration options + +| Parameter | Type | Default | Description | +| ------------------- | --------------------------------- | ------------------------------- | -------------------------------------------------------------------- | +| `image` | `str \| SandboxImageSpec \| None` | `python:3.11` | Container image for a newly created sandbox. | +| `snapshot_id` | `str \| None` | `None` | Snapshot startup source used instead of an image. | +| `sandbox_id` | `str \| None` | `None` | Existing caller-owned sandbox to attach to. | +| `timeout` | `int \| None` | `300` | Owned sandbox lifetime, at least 60 seconds; `None` disables expiry. | +| `ready_timeout` | `float` | `30` | Maximum wait for create or connect readiness. | +| `working_dir` | `str \| os.PathLike` | `/workspace` | Absolute POSIX path used for commands and relative files. | +| `env_vars` | `dict[str, str] \| None` | `None` | Variables applied during create and command execution. | +| `metadata` | `dict[str, str] \| None` | `None` | Lifecycle metadata for a newly created sandbox. | +| `connection_config` | `ConnectionConfig \| None` | `None` | Complete SDK connection configuration. | +| `api_key` | `str \| None` | environment | OpenSandbox lifecycle API key. | +| `domain` | `str \| None` | environment or `localhost:8080` | Lifecycle API domain. | +| `protocol` | `str \| None` | `http` | Scheme used when the domain has no scheme. | +| `request_timeout` | `float \| None` | `30` | HTTP request timeout in seconds. | +| `use_server_proxy` | `bool \| None` | `False` | Route sandbox service requests through the lifecycle server. | + +`image` and `snapshot_id` are mutually exclusive. Neither can be supplied with +`sandbox_id`. A complete `connection_config` also cannot be mixed with the +individual connection settings. + +### Use a remote server + +Basic connection options can be supplied explicitly: + +```python +environment = OpenSandboxEnvironment( + image="python:3.11", + domain="sandbox.example.com", + protocol="https", + api_key="...", + use_server_proxy=True, +) +``` + +Prefer environment-based secret injection in deployed applications rather than +putting an API key in source code. + +## Advanced applications + +### Attach without taking ownership + +Use an existing workspace when another component manages its lifecycle: + +```python +environment = OpenSandboxEnvironment( + sandbox_id="existing-sandbox-id", + env_vars={"TASK_ID": "analysis-42"}, +) + +await environment.initialize() +result = await environment.execute("printf '%s' \"$TASK_ID\"") +await environment.close() # The remote sandbox remains running. +``` + +Command-level `env_vars` still apply to attached sandboxes. Their remote +lifetime is not renewed or destroyed by this environment. + +### Start from a snapshot + +Pass a snapshot when the workspace needs preinstalled dependencies: + +```python +environment = OpenSandboxEnvironment( + snapshot_id="python-data-tools", + timeout=900, +) +``` + +Snapshots and images are alternative startup sources, so do not pass both. + +## Limitations + +- OpenSandbox SDK 0.1.x does not expose a dedicated command-timeout flag. The + adapter marks a result timed out only when a deadline was supplied, execution + lasted at least that long, and the SDK returned exit code `-1`. A process + terminated by another SIGKILL near the deadline can be indistinguishable. +- SDK 0.1.15 parses command output as line-oriented SSE. Unicode separators + U+0085, U+2028, and U+2029 can be split by the underlying HTTP parser and + dropped. This is an SDK transport limitation, not an ADK file-I/O limitation. +- Command stream events do not preserve whether every original chunk ended in + a newline. The adapter preserves text and ordering but reconstructs chunks + with `\n` separators. +- Like the existing E2B and Daytona remote environments, absolute file paths + can address locations outside the working directory. Treat the entire + sandbox filesystem as the security boundary. +- `BaseEnvironment` currently has no endpoint discovery, snapshot creation, + pause, or resume methods. Those OpenSandbox capabilities are outside this + integration's public ADK contract. +- A `timeout` of `None` requires explicit cleanup and is runtime-dependent. + Some Kubernetes workload providers can reject non-expiring sandboxes. +- If an owned sandbox expires while idle, the next operation surfaces the + OpenSandbox error instead of silently creating an empty replacement. Call + `close()` and then `initialize()` to create a fresh workspace. +- A command without a deadline can also delay `close()` until the command + returns or its caller cancels it. This preserves in-flight operation safety; + callers remain responsible for cancelling work that should not finish. + +## Related samples + +See +[`contributing/samples/environment_and_skills/opensandbox_environment`](../../../../../contributing/samples/environment_and_skills/opensandbox_environment/README.md) +for a runnable coding-agent configuration. + +The [verification record](../verification.md) lists the automated checks and +the live-test boundary used for this integration. diff --git a/docs/guides/integrations/opensandbox/verification.md b/docs/guides/integrations/opensandbox/verification.md new file mode 100644 index 00000000000..c10be6e7135 --- /dev/null +++ b/docs/guides/integrations/opensandbox/verification.md @@ -0,0 +1,28 @@ +# OpenSandbox environment verification + +Verification recorded on 2026-08-13 against upstream commit +`2e878ed4120b1009080ec4bd189cf8b436d03ccf`. + +## Automated checks + +```bash +uv run pytest \ + tests/unittests/integrations/opensandbox/test_opensandbox_environment.py -q +./scripts/update_constraints.sh --check +``` + +Result: 52 tests passed, and all Python 3.10-3.14 constraint files were +up to date. + +## Live boundary + +The environment was exercised end to end against a local OpenSandbox server +using ADK's default `python:3.11` image. Agent-driven command execution and +workspace file operations passed. + +The same agent flow was exercised against a private HTTPS OpenSandbox +deployment using server-proxy mode. Its endpoint, credential, and private image +are intentionally not recorded here. + +These live checks require an OpenSandbox server and model credentials, so they +are not part of the default unit-test suite. diff --git a/pyproject.toml b/pyproject.toml index 46edcbc6949..9754cf781b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,7 @@ optional-dependencies.all = [ "oci>=2.126", "openai>=2.20,<3", "openpyxl>=3.1.5,<4", + "opensandbox>=0.1.15,<0.2", "opentelemetry-exporter-gcp-logging>=1.9.0a0,<=1.12.0a0", "opentelemetry-exporter-gcp-monitoring>=1.9.0a0,<2", "opentelemetry-exporter-gcp-trace>=1.9,<2", @@ -250,6 +251,9 @@ optional-dependencies.mcp = [ optional-dependencies.oci = [ "oci>=2.126", # OCI Generative AI native SDK (OCIGenAILlm) ] +optional-dependencies.opensandbox = [ + "opensandbox>=0.1.15,<0.2", # For OpenSandboxEnvironment remote sandbox. +] optional-dependencies.otel-gcp = [ "opentelemetry-instrumentation-google-genai>=0.7b1,<1", "opentelemetry-instrumentation-grpc>=0.43b0,<1", @@ -301,6 +305,7 @@ optional-dependencies.test = [ "nltk!=3.10.1", # Transitive via rouge-score and llama-index-core; 3.10.1's import hook breaks any venv living inside the working directory (reverted upstream in nltk/nltk#3732). "openai>=2.20,<3", "openpyxl>=3.1.5,<4", + "opensandbox>=0.1.15,<0.2", "opentelemetry-exporter-gcp-logging>=1.9.0a0,<=1.12.0a0", "opentelemetry-exporter-gcp-monitoring>=1.9.0a0,<2", "opentelemetry-exporter-gcp-trace>=1.9,<2", diff --git a/src/google/adk/features/_feature_registry.py b/src/google/adk/features/_feature_registry.py index ab0ecc99809..2af6f8ffe14 100644 --- a/src/google/adk/features/_feature_registry.py +++ b/src/google/adk/features/_feature_registry.py @@ -40,6 +40,7 @@ class FeatureName(str, Enum): DYNAMIC_INSTRUCTION_ROUTING = "DYNAMIC_INSTRUCTION_ROUTING" DAYTONA_ENVIRONMENT = "DAYTONA_ENVIRONMENT" E2B_ENVIRONMENT = "E2B_ENVIRONMENT" + OPENSANDBOX_ENVIRONMENT = "OPENSANDBOX_ENVIRONMENT" ENVIRONMENT_SIMULATION = "ENVIRONMENT_SIMULATION" EVENTARC_TOOL_CONFIG = "EVENTARC_TOOL_CONFIG" EVENTARC_TOOLSET = "EVENTARC_TOOLSET" @@ -143,6 +144,9 @@ class FeatureConfig: FeatureName.E2B_ENVIRONMENT: FeatureConfig( FeatureStage.EXPERIMENTAL, default_on=True ), + FeatureName.OPENSANDBOX_ENVIRONMENT: FeatureConfig( + FeatureStage.EXPERIMENTAL, default_on=True + ), FeatureName.ENVIRONMENT_SIMULATION: FeatureConfig( FeatureStage.EXPERIMENTAL, default_on=True ), diff --git a/src/google/adk/integrations/opensandbox/__init__.py b/src/google/adk/integrations/opensandbox/__init__.py new file mode 100644 index 00000000000..9eb4fe7fc69 --- /dev/null +++ b/src/google/adk/integrations/opensandbox/__init__.py @@ -0,0 +1,25 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OpenSandbox integration for persistent remote agent workspaces. + +Requires the ``opensandbox`` extra: +``pip install google-adk[opensandbox]``. +""" + +from ._opensandbox_environment import OpenSandboxEnvironment + +__all__ = [ + "OpenSandboxEnvironment", +] diff --git a/src/google/adk/integrations/opensandbox/_opensandbox_environment.py b/src/google/adk/integrations/opensandbox/_opensandbox_environment.py new file mode 100644 index 00000000000..6880fe62dea --- /dev/null +++ b/src/google/adk/integrations/opensandbox/_opensandbox_environment.py @@ -0,0 +1,579 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OpenSandbox remote code execution environment.""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from datetime import datetime +from datetime import timedelta +from datetime import timezone +import logging +import math +import os +from pathlib import Path +from pathlib import PurePosixPath +from time import monotonic +from typing import TYPE_CHECKING +from urllib.parse import urlsplit + +from typing_extensions import override + +from ...environment._base_environment import BaseEnvironment +from ...environment._base_environment import ExecutionResult +from ...features import experimental +from ...features import FeatureName + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from opensandbox import Sandbox + from opensandbox.config import ConnectionConfig + from opensandbox.models.execd import Execution + from opensandbox.models.sandboxes import SandboxImageSpec + +logger = logging.getLogger("google_adk." + __name__) + +_DEFAULT_IMAGE = "python:3.11" +_DEFAULT_SANDBOX_TIMEOUT = 300 +_DEFAULT_READY_TIMEOUT = 30 +_DEFAULT_REQUEST_TIMEOUT = 30 +_MIN_SANDBOX_TIMEOUT = 60 +_COMMAND_TTL_GRACE = timedelta(seconds=30) +_MAX_RENEW_INTERVAL_SECONDS = 60 +_SANDBOX_HOME = "/workspace" +# OpenSandbox's WriteEntry schema uses decimal digits for Unix modes (for +# example, its SDK default is 755), rather than Python's raw octal bit value. +_FILE_MODE = 644 +_HTTP_NOT_FOUND = 404 +_TIMEOUT_EXIT_CODE = -1 + + +@experimental(FeatureName.OPENSANDBOX_ENVIRONMENT) +class OpenSandboxEnvironment(BaseEnvironment): + """A persistent remote workspace backed by OpenSandbox. + + By default, ``initialize()`` creates a sandbox and ``close()`` destroys it. + When ``sandbox_id`` is supplied, the environment attaches to that sandbox; + ``close()`` then releases only local SDK resources and leaves the caller-owned + remote sandbox running. + + Requires the ``opensandbox`` extra: + ``pip install google-adk[opensandbox]``. + """ + + def __init__( # pylint: disable=too-many-arguments + self, + *, + image: str | SandboxImageSpec | None = None, + snapshot_id: str | None = None, + sandbox_id: str | None = None, + timeout: int | None = _DEFAULT_SANDBOX_TIMEOUT, + ready_timeout: float = _DEFAULT_READY_TIMEOUT, + working_dir: str | os.PathLike[str] = _SANDBOX_HOME, + env_vars: dict[str, str] | None = None, + metadata: dict[str, str] | None = None, + connection_config: ConnectionConfig | None = None, + api_key: str | None = None, + domain: str | None = None, + protocol: str | None = None, + request_timeout: float | None = None, + use_server_proxy: bool | None = None, + ): + """Create an OpenSandbox environment. + + Args: + image: Container image for a newly created sandbox. Defaults to + ``python:3.11``. Mutually exclusive with ``snapshot_id``. + snapshot_id: Snapshot used instead of an image for a new sandbox. + sandbox_id: Existing sandbox to attach to instead of creating one. + timeout: Remote sandbox lifetime in seconds. Active, owned environments + renew this lifetime before each operation. Must be at least 60 seconds; + ``None`` disables expiry when supported by the runtime. + ready_timeout: Maximum seconds to wait for create or connect readiness. + working_dir: Absolute POSIX path used for commands and relative files. + env_vars: Environment variables applied to created sandboxes and commands. + metadata: Additional metadata for a newly created sandbox. + connection_config: Complete OpenSandbox connection configuration. + api_key: OpenSandbox API key. Falls back to ``OPEN_SANDBOX_API_KEY``. + domain: OpenSandbox service domain. Falls back to + ``OPEN_SANDBOX_DOMAIN``. + protocol: Protocol used when ``domain`` has no URL scheme. + request_timeout: HTTP request timeout in seconds. + use_server_proxy: Route sandbox requests through the lifecycle server. + + Raises: + TypeError: If ``connection_config`` is mixed with basic connection fields. + ValueError: If configuration values are invalid or mutually exclusive. + """ + if image is not None and snapshot_id is not None: + raise ValueError("image and snapshot_id are mutually exclusive") + if sandbox_id is not None and ( + image is not None or snapshot_id is not None + ): + raise ValueError( + "image and snapshot_id cannot be used when attaching by sandbox_id" + ) + if timeout is not None and timeout < _MIN_SANDBOX_TIMEOUT: + raise ValueError("timeout must be at least 60 seconds or None") + if ready_timeout <= 0: + raise ValueError("ready_timeout must be positive") + if request_timeout is not None and request_timeout <= 0: + raise ValueError("request_timeout must be positive") + + pure_working_dir = PurePosixPath(os.fspath(working_dir)) + if not pure_working_dir.is_absolute(): + raise ValueError("working_dir must be an absolute POSIX path") + + basic_connection_fields = ( + api_key, + domain, + protocol, + request_timeout, + use_server_proxy, + ) + if connection_config is not None and any( + value is not None for value in basic_connection_fields + ): + raise TypeError( + "connection_config cannot be combined with basic connection fields" + ) + + self._image = image + self._snapshot_id = snapshot_id + self._requested_sandbox_id = sandbox_id + self._sandbox_timeout = ( + timedelta(seconds=timeout) if timeout is not None else None + ) + self._ready_timeout = timedelta(seconds=ready_timeout) + self._working_dir = pure_working_dir + self._env_vars = dict(env_vars) if env_vars is not None else None + self._metadata = dict(metadata) if metadata is not None else None + self._connection_config = connection_config + self._api_key = api_key + self._domain = domain + self._protocol = protocol + self._request_timeout = request_timeout + self._use_server_proxy = use_server_proxy + self._sandbox: Sandbox | None = None + self._owns_remote = False + self._pending_destroy_id: str | None = None + self._known_expiration: datetime | None = None + self._renew_lock = asyncio.Lock() + self._active_operations = 0 + self._closing = False + self._close_task: asyncio.Task[None] | None = None + self._lifecycle_condition = asyncio.Condition() + + @property + @override + def working_dir(self) -> Path: + if self._sandbox is None: + raise RuntimeError("Sandbox is not started. Call initialize() first.") + return Path(str(self._working_dir)) + + @override + async def initialize(self) -> None: + async with self._lifecycle_condition: + await self._lifecycle_condition.wait_for(lambda: not self._closing) + if self._sandbox is not None: + return + await self._retry_pending_destroy() + + sandbox, owns_remote = await self._open_sandbox() + try: + await self._prepare_working_directory(sandbox) + except BaseException: + cleanup_task = asyncio.create_task( + self._cleanup_sandbox(sandbox, owns_remote=owns_remote) + ) + try: + await asyncio.shield(cleanup_task) + except asyncio.CancelledError: + try: + await cleanup_task + except BaseException: # pylint: disable=broad-exception-caught + if owns_remote: + self._pending_destroy_id = sandbox.id + logger.warning( + "Failed to clean up OpenSandbox after initialization was " + "cancelled", + exc_info=True, + ) + raise + except BaseException: # pylint: disable=broad-exception-caught + if owns_remote: + self._pending_destroy_id = sandbox.id + logger.warning( + "Failed to clean up OpenSandbox after initialization failure", + exc_info=True, + ) + raise + + self._sandbox = sandbox + self._owns_remote = owns_remote + self._known_expiration = None + self._is_initialized = True + + @override + async def close(self) -> None: + async with self._lifecycle_condition: + close_task = self._close_task + if close_task is None: + self._closing = True + close_task = asyncio.create_task(self._close_once()) + close_task.add_done_callback(self._log_close_task_failure) + self._close_task = close_task + + await asyncio.wait({close_task}) + close_task.result() + + @staticmethod + def _log_close_task_failure(close_task: asyncio.Task[None]) -> None: + if close_task.cancelled(): + return + error = close_task.exception() + if error is not None: + logger.warning( + "OpenSandbox cleanup task failed", + exc_info=(type(error), error, error.__traceback__), + ) + + @override + async def execute( + self, + command: str, + *, + timeout: float | None = None, + ) -> ExecutionResult: + if timeout is not None and timeout < 0: + raise ValueError("timeout must be non-negative or None") + + from opensandbox.models.execd import RunCommandOpts + + sdk_timeout = self._command_timeout(timeout) + renewal_timeout = self._command_renewal_timeout(timeout) + async with self._sandbox_operation( + renewal_timeout=renewal_timeout + ) as sandbox: + started_at = monotonic() + opts = RunCommandOpts( + working_directory=str(self._working_dir), + timeout=sdk_timeout, + envs=self._env_vars, + ) + execution = await sandbox.commands.run(command, opts=opts) + elapsed = monotonic() - started_at + + if execution.exit_code is None: + error = execution.error + detail = ( + f"{error.name}: {error.value}" + if error is not None + else "no completion or error event" + ) + raise RuntimeError( + "OpenSandbox command completed without an exit code: " + detail + ) + + return ExecutionResult( + exit_code=execution.exit_code, + stdout=self._stdout(execution), + stderr=self._stderr(execution), + timed_out=( + timeout is not None + and execution.exit_code == _TIMEOUT_EXIT_CODE + and elapsed >= timeout + ), + ) + + @override + async def read_file(self, path: str | os.PathLike[str]) -> bytes: + from opensandbox.exceptions import SandboxApiException + + resolved = self._resolve_path(path) + async with self._sandbox_operation() as sandbox: + try: + return await sandbox.files.read_bytes(resolved) + except SandboxApiException as e: + if e.status_code == _HTTP_NOT_FOUND: + raise FileNotFoundError(resolved) from e + raise + + @override + async def write_file( + self, + path: str | os.PathLike[str], + content: str | bytes, + ) -> None: + async with self._sandbox_operation() as sandbox: + await sandbox.files.write_file( + self._resolve_path(path), content, mode=_FILE_MODE + ) + + async def _open_sandbox(self) -> tuple[Sandbox, bool]: + try: + from opensandbox import Sandbox + except ImportError as e: + raise ImportError( + "The opensandbox package is required to use OpenSandboxEnvironment. " + "Install it with `pip install google-adk[opensandbox]`." + ) from e + + connection_config = self._build_connection_config() + if self._requested_sandbox_id is not None: + sandbox = await Sandbox.connect( + self._requested_sandbox_id, + connection_config=connection_config, + connect_timeout=self._ready_timeout, + ) + return sandbox, False + + metadata = { + "framework": "google-adk", + "integration": "google-adk-opensandbox", + } + metadata.update(self._metadata or {}) + image = self._image + if image is None and self._snapshot_id is None: + image = _DEFAULT_IMAGE + sandbox = await Sandbox.create( + image, + snapshot_id=self._snapshot_id, + timeout=self._sandbox_timeout, + ready_timeout=self._ready_timeout, + env=self._env_vars, + metadata=metadata, + connection_config=connection_config, + ) + return sandbox, True + + async def _close_once(self) -> None: + current_task = asyncio.current_task() + try: + async with self._lifecycle_condition: + await self._lifecycle_condition.wait_for( + lambda: self._active_operations == 0 + ) + sandbox = self._sandbox + if sandbox is None: + await self._retry_pending_destroy() + return + + owns_remote = self._owns_remote + sandbox_id = sandbox.id + self._sandbox = None + self._owns_remote = False + self._known_expiration = None + self._is_initialized = False + + try: + await self._cleanup_sandbox(sandbox, owns_remote=owns_remote) + except BaseException: # pylint: disable=broad-exception-caught + if owns_remote: + self._pending_destroy_id = sandbox_id + raise + finally: + async with self._lifecycle_condition: + if self._close_task is current_task: + self._close_task = None + self._closing = False + self._lifecycle_condition.notify_all() + + def _build_connection_config(self) -> ConnectionConfig: + if self._connection_config is not None: + return self._connection_config + + from opensandbox.config import ConnectionConfig + + return ConnectionConfig( + api_key=self._api_key, + domain=self._domain, + protocol=self._resolve_protocol(), + request_timeout=timedelta( + seconds=self._request_timeout or _DEFAULT_REQUEST_TIMEOUT + ), + use_server_proxy=self._use_server_proxy or False, + ) + + async def _prepare_working_directory(self, sandbox: Sandbox) -> None: + from opensandbox.models.filesystem import WriteEntry + + await sandbox.files.create_directories( + [WriteEntry(path=str(self._working_dir), mode=755)] + ) + + @staticmethod + async def _cleanup_sandbox( + sandbox: Sandbox, + *, + owns_remote: bool, + ) -> None: + if not owns_remote: + await sandbox.close() + return + + from opensandbox.exceptions import SandboxApiException + + try: + await sandbox.destroy() + except SandboxApiException as e: + if e.status_code != _HTTP_NOT_FOUND: + raise + + @asynccontextmanager + async def _sandbox_operation( + self, + *, + renewal_timeout: timedelta | None = None, + ) -> AsyncIterator[Sandbox]: + async with self._lifecycle_condition: + if self._closing: + raise RuntimeError("Sandbox is closing. Wait for close() to finish.") + sandbox = self._sandbox + if sandbox is None: + raise RuntimeError("Sandbox is not started. Call initialize() first.") + owns_remote = self._owns_remote + if renewal_timeout is None: + renewal_timeout = self._sandbox_timeout + self._active_operations += 1 + + operation_failed = False + stop: asyncio.Event | None = None + heartbeat: asyncio.Task[None] | None = None + try: + if owns_remote and renewal_timeout is not None: + await self._renew_sandbox(sandbox, renewal_timeout) + stop = asyncio.Event() + heartbeat = asyncio.create_task( + self._renew_until_stopped(sandbox, stop, renewal_timeout) + ) + yield sandbox + except BaseException: + operation_failed = True + raise + finally: + try: + if stop is not None and heartbeat is not None: + stop.set() + try: + await heartbeat + except Exception: # pylint: disable=broad-exception-caught + if not operation_failed: + raise + logger.warning( + "OpenSandbox renewal failed while an operation was exiting", + exc_info=True, + ) + finally: + async with self._lifecycle_condition: + self._active_operations -= 1 + if self._active_operations == 0: + self._lifecycle_condition.notify_all() + + async def _renew_sandbox( + self, + sandbox: Sandbox, + renewal_timeout: timedelta, + ) -> None: + async with self._renew_lock: + requested_expiration = datetime.now(timezone.utc) + renewal_timeout + if ( + self._known_expiration is not None + and requested_expiration <= self._known_expiration + ): + return + response = await sandbox.renew(renewal_timeout) + self._known_expiration = response.expires_at + + async def _renew_until_stopped( + self, + sandbox: Sandbox, + stop: asyncio.Event, + renewal_timeout: timedelta, + ) -> None: + interval = min( + renewal_timeout.total_seconds() / 2, + _MAX_RENEW_INTERVAL_SECONDS, + ) + while True: + try: + await asyncio.wait_for(stop.wait(), timeout=interval) + return + except asyncio.TimeoutError: + await self._renew_sandbox(sandbox, renewal_timeout) + + async def _retry_pending_destroy(self) -> None: + sandbox_id = self._pending_destroy_id + if sandbox_id is None: + return + + from opensandbox import Sandbox + from opensandbox.exceptions import SandboxApiException + + try: + sandbox = await Sandbox.connect( + sandbox_id, + connection_config=self._build_connection_config(), + skip_health_check=True, + ) + await self._cleanup_sandbox(sandbox, owns_remote=True) + except SandboxApiException as e: + if e.status_code != _HTTP_NOT_FOUND: + raise + self._pending_destroy_id = None + + def _resolve_protocol(self) -> str: + domain = self._domain or os.getenv("OPEN_SANDBOX_DOMAIN") + if domain is not None: + scheme = urlsplit(domain).scheme.lower() + if scheme in {"http", "https"}: + return scheme + return self._protocol or "http" + + def _command_renewal_timeout(self, timeout: float | None) -> timedelta | None: + sandbox_timeout = self._sandbox_timeout + if sandbox_timeout is None or timeout is None: + return sandbox_timeout + command_lifetime = timedelta(seconds=timeout) + _COMMAND_TTL_GRACE + return max(sandbox_timeout, command_lifetime) + + def _resolve_path(self, path: str | os.PathLike[str]) -> str: + pure = PurePosixPath(os.fspath(path)) + if pure.is_absolute(): + return str(pure) + return str(self._working_dir / pure) + + @staticmethod + def _command_timeout(timeout: float | None) -> timedelta | None: + if timeout is None: + return None + timeout_ms = max(1, math.ceil(timeout * 1000)) + return timedelta(milliseconds=timeout_ms) + + @staticmethod + def _stdout(execution: Execution) -> str: + chunks = [message.text for message in execution.logs.stdout] + chunks.extend( + result.text for result in execution.result if result.text is not None + ) + return "\n".join(chunk.rstrip("\r\n") for chunk in chunks) + + @staticmethod + def _stderr(execution: Execution) -> str: + return "\n".join( + message.text.rstrip("\r\n") for message in execution.logs.stderr + ) diff --git a/tests/unittests/integrations/opensandbox/test_opensandbox_environment.py b/tests/unittests/integrations/opensandbox/test_opensandbox_environment.py new file mode 100644 index 00000000000..f20d217cf37 --- /dev/null +++ b/tests/unittests/integrations/opensandbox/test_opensandbox_environment.py @@ -0,0 +1,910 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for OpenSandboxEnvironment.""" + +import asyncio +from datetime import datetime +from datetime import timedelta +from datetime import timezone +import gc +from pathlib import Path +from time import monotonic +from types import SimpleNamespace +from unittest import mock + +from google.adk.integrations.opensandbox import OpenSandboxEnvironment +from opensandbox.config import ConnectionConfig +from opensandbox.exceptions import SandboxApiException +import pytest + + +def _renew_response(timeout: timedelta) -> SimpleNamespace: + return SimpleNamespace(expires_at=datetime.now(timezone.utc) + timeout) + + +def _make_sandbox(*, sandbox_id: str = "sandbox-1") -> mock.MagicMock: + """Build a mock async OpenSandbox client.""" + + async def _renew(timeout): + return _renew_response(timeout) + + sandbox = mock.MagicMock(name="Sandbox") + sandbox.id = sandbox_id + sandbox.destroy = mock.AsyncMock() + sandbox.close = mock.AsyncMock() + sandbox.renew = mock.AsyncMock(side_effect=_renew) + sandbox.commands.run = mock.AsyncMock() + sandbox.files.create_directories = mock.AsyncMock() + sandbox.files.read_bytes = mock.AsyncMock() + sandbox.files.write_file = mock.AsyncMock() + return sandbox + + +def _execution( + *, + exit_code: int | None = 0, + stdout: tuple[str, ...] = (), + stderr: tuple[str, ...] = (), + results: tuple[str, ...] = (), + error: object | None = None, +) -> SimpleNamespace: + """Build the observable result returned by the OpenSandbox SDK boundary.""" + return SimpleNamespace( + exit_code=exit_code, + error=error, + result=[SimpleNamespace(text=value) for value in results], + logs=SimpleNamespace( + stdout=[SimpleNamespace(text=value) for value in stdout], + stderr=[SimpleNamespace(text=value) for value in stderr], + ), + ) + + +@pytest.fixture(name="sandbox") +def _sandbox() -> mock.MagicMock: + return _make_sandbox() + + +@pytest.fixture(name="create_patch") +def _create_patch(sandbox: mock.MagicMock): + """Patch Sandbox.create to return the mock sandbox.""" + with mock.patch( + "opensandbox.Sandbox.create", new=mock.AsyncMock(return_value=sandbox) + ) as create: + yield create + + +@pytest.fixture(name="connect_patch") +def _connect_patch(sandbox: mock.MagicMock): + """Patch Sandbox.connect to return the mock sandbox.""" + with mock.patch( + "opensandbox.Sandbox.connect", new=mock.AsyncMock(return_value=sandbox) + ) as connect: + yield connect + + +@pytest.mark.asyncio +async def test_initialize_creates_sandbox_with_configuration( + create_patch: mock.AsyncMock, sandbox: mock.MagicMock +): + """Initialization forwards lifecycle and connection settings.""" + env = OpenSandboxEnvironment( + image="custom:latest", + timeout=120, + ready_timeout=12.5, + working_dir="/work", + env_vars={"A": "1"}, + metadata={"team": "adk"}, + api_key="key", + domain="sandbox.example:8080", + protocol="https", + request_timeout=8, + use_server_proxy=True, + ) + + await env.initialize() + + assert env.is_initialized is True + assert env.working_dir == Path("/work") + args, kwargs = create_patch.call_args + assert args == ("custom:latest",) + assert kwargs["timeout"] == timedelta(seconds=120) + assert kwargs["ready_timeout"] == timedelta(seconds=12.5) + assert kwargs["env"] == {"A": "1"} + assert kwargs["metadata"] == { + "framework": "google-adk", + "integration": "google-adk-opensandbox", + "team": "adk", + } + config = kwargs["connection_config"] + assert config.api_key == "key" + assert config.domain == "sandbox.example:8080" + assert config.protocol == "https" + assert config.request_timeout == timedelta(seconds=8) + assert config.use_server_proxy is True + directory = sandbox.files.create_directories.await_args.args[0][0] + assert directory.path == "/work" + assert directory.mode == 755 + + +@pytest.mark.asyncio +async def test_initialize_uses_snapshot_without_default_image(create_patch): + """A snapshot startup does not also send the default image.""" + env = OpenSandboxEnvironment(snapshot_id="snapshot-1") + + await env.initialize() + + args, kwargs = create_patch.call_args + assert args == (None,) + assert kwargs["snapshot_id"] == "snapshot-1" + + +@pytest.mark.asyncio +async def test_initialize_uses_complete_connection_config(create_patch): + """A caller-supplied ConnectionConfig is passed through unchanged.""" + config = ConnectionConfig(domain="sandbox.example:8080") + env = OpenSandboxEnvironment(connection_config=config) + + await env.initialize() + + assert create_patch.await_args.kwargs["connection_config"] is config + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "domain", + ["https://sandbox.example", "https://sandbox.example:8443"], +) +async def test_initialize_derives_https_protocol_from_domain( + create_patch, domain +): + """A URL scheme also configures HTTPS for sandbox service endpoints.""" + env = OpenSandboxEnvironment(domain=domain) + + await env.initialize() + + config = create_patch.await_args.kwargs["connection_config"] + assert config.protocol == "https" + + +@pytest.mark.asyncio +async def test_initialize_derives_protocol_from_environment( + create_patch, monkeypatch +): + """The environment domain scheme applies to lifecycle and sandbox calls.""" + monkeypatch.setenv("OPEN_SANDBOX_DOMAIN", "https://sandbox.example") + env = OpenSandboxEnvironment() + + await env.initialize() + + config = create_patch.await_args.kwargs["connection_config"] + assert config.protocol == "https" + + +@pytest.mark.asyncio +async def test_initialize_is_idempotent(create_patch): + """Repeated initialization creates one remote sandbox.""" + env = OpenSandboxEnvironment() + + await env.initialize() + await env.initialize() + + create_patch.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_concurrent_initialize_creates_one_sandbox(create_patch): + """Concurrent initialization is serialized without leaking a sandbox.""" + env = OpenSandboxEnvironment() + + await asyncio.gather(env.initialize(), env.initialize(), env.initialize()) + + create_patch.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_close_destroys_owned_sandbox_and_is_idempotent( + create_patch, sandbox +): + """Closing a created environment destroys the remote resource once.""" + env = OpenSandboxEnvironment() + await env.initialize() + + await env.close() + await env.close() + + sandbox.destroy.assert_awaited_once() + sandbox.close.assert_not_awaited() + assert env.is_initialized is False + + +@pytest.mark.asyncio +async def test_close_accepts_already_expired_owned_sandbox( + create_patch, sandbox +): + """An already absent remote resource still counts as successfully closed.""" + sandbox.destroy.side_effect = SandboxApiException(status_code=404) + env = OpenSandboxEnvironment() + await env.initialize() + + await env.close() + + with mock.patch( + "opensandbox.Sandbox.connect", new=mock.AsyncMock() + ) as connect: + await env.close() + + connect.assert_not_awaited() + assert env.is_initialized is False + + +@pytest.mark.asyncio +async def test_close_attached_sandbox_releases_only_local_resources( + connect_patch, sandbox +): + """Closing an attached environment leaves the caller-owned sandbox alive.""" + env = OpenSandboxEnvironment(sandbox_id="existing-1") + await env.initialize() + + await env.close() + + connect_patch.assert_awaited_once() + assert connect_patch.await_args.args == ("existing-1",) + sandbox.close.assert_awaited_once() + sandbox.destroy.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_close_waits_for_active_operation(create_patch, sandbox): + """Closing never destroys a sandbox while a command is using it.""" + renew_started = asyncio.Event() + allow_renew = asyncio.Event() + + async def _renew(timeout): + renew_started.set() + await allow_renew.wait() + return _renew_response(timeout) + + sandbox.renew.side_effect = _renew + sandbox.commands.run.return_value = _execution(stdout=("done",)) + env = OpenSandboxEnvironment() + await env.initialize() + + execute_task = asyncio.create_task(env.execute("printf done")) + await renew_started.wait() + close_task = asyncio.create_task(env.close()) + await asyncio.sleep(0) + + assert close_task.done() is False + sandbox.destroy.assert_not_awaited() + + allow_renew.set() + result, _ = await asyncio.gather(execute_task, close_task) + + assert result.stdout == "done" + sandbox.destroy.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_close_rejects_operations_while_draining(create_patch, sandbox): + """A close in progress prevents new operations from delaying cleanup.""" + renew_started = asyncio.Event() + allow_renew = asyncio.Event() + + async def _renew(timeout): + renew_started.set() + await allow_renew.wait() + return _renew_response(timeout) + + sandbox.renew.side_effect = _renew + sandbox.commands.run.return_value = _execution(stdout=("done",)) + env = OpenSandboxEnvironment() + await env.initialize() + + execute_task = asyncio.create_task(env.execute("printf done")) + await renew_started.wait() + close_task = asyncio.create_task(env.close()) + await asyncio.sleep(0) + + with pytest.raises(RuntimeError, match="closing"): + await env.read_file("late.txt") + + allow_renew.set() + result, _ = await asyncio.gather(execute_task, close_task) + + assert result.stdout == "done" + sandbox.destroy.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_initialize_waits_for_close_then_creates_new_sandbox( + create_patch, sandbox +): + """Initialization concurrent with close returns a new live sandbox.""" + renew_started = asyncio.Event() + allow_renew = asyncio.Event() + + async def _renew(timeout): + renew_started.set() + await allow_renew.wait() + return _renew_response(timeout) + + sandbox.renew.side_effect = _renew + sandbox.commands.run.return_value = _execution(stdout=("done",)) + env = OpenSandboxEnvironment() + await env.initialize() + + execute_task = asyncio.create_task(env.execute("printf done")) + await renew_started.wait() + close_task = asyncio.create_task(env.close()) + await asyncio.sleep(0) + initialize_task = asyncio.create_task(env.initialize()) + await asyncio.sleep(0) + + assert initialize_task.done() is False + assert create_patch.await_count == 1 + + allow_renew.set() + await asyncio.gather(execute_task, close_task, initialize_task) + + assert env.is_initialized is True + assert create_patch.await_count == 2 + sandbox.destroy.assert_awaited_once() + + await env.close() + + +@pytest.mark.asyncio +async def test_concurrent_close_does_not_destroy_reinitialized_sandbox(): + """Concurrent close calls coalesce instead of closing the next generation.""" + old_sandbox = _make_sandbox(sandbox_id="old-sandbox") + new_sandbox = _make_sandbox(sandbox_id="new-sandbox") + destroy_started = asyncio.Event() + allow_destroy = asyncio.Event() + + async def _destroy_old(): + destroy_started.set() + await allow_destroy.wait() + + old_sandbox.destroy.side_effect = _destroy_old + create = mock.AsyncMock(side_effect=[old_sandbox, new_sandbox]) + env = OpenSandboxEnvironment() + + with mock.patch("opensandbox.Sandbox.create", new=create): + await env.initialize() + first_close = asyncio.create_task(env.close()) + await destroy_started.wait() + initialize = asyncio.create_task(env.initialize()) + second_close = asyncio.create_task(env.close()) + await asyncio.sleep(0) + + allow_destroy.set() + await asyncio.gather(first_close, initialize, second_close) + + assert env.is_initialized is True + assert create.await_count == 2 + old_sandbox.destroy.assert_awaited_once() + new_sandbox.destroy.assert_not_awaited() + new_sandbox.files.read_bytes.return_value = b"live" + assert await env.read_file("live.txt") == b"live" + + await env.close() + + +@pytest.mark.asyncio +async def test_cancelled_close_does_not_cancel_concurrent_close( + create_patch, sandbox +): + """Cancelling one close waiter does not abandon shared cleanup.""" + renew_started = asyncio.Event() + allow_renew = asyncio.Event() + + async def _renew(timeout): + renew_started.set() + await allow_renew.wait() + return _renew_response(timeout) + + sandbox.renew.side_effect = _renew + sandbox.commands.run.return_value = _execution(stdout=("done",)) + env = OpenSandboxEnvironment() + await env.initialize() + + execute = asyncio.create_task(env.execute("printf done")) + await renew_started.wait() + first_close = asyncio.create_task(env.close()) + second_close = asyncio.create_task(env.close()) + await asyncio.sleep(0) + first_close.cancel() + + with pytest.raises(asyncio.CancelledError): + await first_close + assert second_close.done() is False + + allow_renew.set() + result, _ = await asyncio.gather(execute, second_close) + + assert result.stdout == "done" + assert env.is_initialized is False + sandbox.destroy.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_cancelled_only_close_waiter_observes_cleanup_failure( + create_patch, sandbox +): + """A background cleanup failure is retrieved after its waiter is cancelled.""" + destroy_started = asyncio.Event() + allow_destroy = asyncio.Event() + loop_errors = [] + + async def _destroy(): + destroy_started.set() + await allow_destroy.wait() + raise RuntimeError("delete failed") + + sandbox.destroy.side_effect = _destroy + env = OpenSandboxEnvironment() + await env.initialize() + loop = asyncio.get_running_loop() + old_exception_handler = loop.get_exception_handler() + loop.set_exception_handler(lambda _loop, context: loop_errors.append(context)) + try: + close_waiter = asyncio.create_task(env.close()) + await destroy_started.wait() + close_waiter.cancel() + + with pytest.raises(asyncio.CancelledError): + await close_waiter + + allow_destroy.set() + deadline = monotonic() + 5.0 + while env._close_task is not None: + assert monotonic() < deadline, "close task did not finish" + await asyncio.sleep(0.001) + gc.collect() + await asyncio.sleep(0) + + retry_sandbox = _make_sandbox(sandbox_id="sandbox-1") + with mock.patch( + "opensandbox.Sandbox.connect", + new=mock.AsyncMock(return_value=retry_sandbox), + ): + await env.close() + + assert loop_errors == [] + retry_sandbox.destroy.assert_awaited_once() + finally: + loop.set_exception_handler(old_exception_handler) + + +@pytest.mark.asyncio +async def test_close_retries_failed_owned_cleanup(create_patch, sandbox): + """A second close reconnects to retry a failed remote destroy.""" + sandbox.destroy.side_effect = RuntimeError("delete failed") + env = OpenSandboxEnvironment() + await env.initialize() + + with pytest.raises(RuntimeError, match="delete failed"): + await env.close() + + retry_sandbox = _make_sandbox(sandbox_id="sandbox-1") + with mock.patch( + "opensandbox.Sandbox.connect", + new=mock.AsyncMock(return_value=retry_sandbox), + ) as connect: + await env.close() + + connect.assert_awaited_once() + assert connect.await_args.args == ("sandbox-1",) + assert connect.await_args.kwargs["skip_health_check"] is True + retry_sandbox.destroy.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_initialize_failure_destroys_owned_sandbox(create_patch, sandbox): + """A working-directory failure does not leak a newly created sandbox.""" + sandbox.files.create_directories.side_effect = RuntimeError("setup failed") + env = OpenSandboxEnvironment() + + with pytest.raises(RuntimeError, match="setup failed"): + await env.initialize() + + sandbox.destroy.assert_awaited_once() + assert env.is_initialized is False + + +@pytest.mark.asyncio +async def test_initialize_failure_does_not_destroy_attached_sandbox( + connect_patch, sandbox +): + """A failed attached setup closes its client without killing the sandbox.""" + sandbox.files.create_directories.side_effect = RuntimeError("setup failed") + env = OpenSandboxEnvironment(sandbox_id="existing-1") + + with pytest.raises(RuntimeError, match="setup failed"): + await env.initialize() + + sandbox.close.assert_awaited_once() + sandbox.destroy.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cleanup_failure_preserves_initialize_error_and_retries( + create_patch, sandbox +): + """Cleanup errors preserve setup failure and leave remote cleanup retryable.""" + sandbox.files.create_directories.side_effect = RuntimeError("setup failed") + sandbox.destroy.side_effect = RuntimeError("cleanup failed") + env = OpenSandboxEnvironment() + + with pytest.raises(RuntimeError, match="setup failed"): + await env.initialize() + + retry_sandbox = _make_sandbox(sandbox_id="sandbox-1") + with mock.patch( + "opensandbox.Sandbox.connect", + new=mock.AsyncMock(return_value=retry_sandbox), + ) as connect: + await env.close() + + connect.assert_awaited_once() + assert connect.await_args.kwargs["skip_health_check"] is True + retry_sandbox.destroy.assert_awaited_once() + + +def test_working_dir_requires_initialize(): + """The working directory is unavailable before initialization.""" + env = OpenSandboxEnvironment() + + with pytest.raises(RuntimeError, match="initialize"): + _ = env.working_dir + + +@pytest.mark.asyncio +async def test_execute_requires_initialize(): + """Commands cannot run before initialization.""" + env = OpenSandboxEnvironment() + + with pytest.raises(RuntimeError, match="initialize"): + await env.execute("echo hi") + + +@pytest.mark.asyncio +async def test_read_requires_initialize(): + """Files cannot be read before initialization.""" + env = OpenSandboxEnvironment() + + with pytest.raises(RuntimeError, match="initialize"): + await env.read_file("a.txt") + + +@pytest.mark.asyncio +async def test_write_requires_initialize(): + """Files cannot be written before initialization.""" + env = OpenSandboxEnvironment() + + with pytest.raises(RuntimeError, match="initialize"): + await env.write_file("a.txt", "data") + + +@pytest.mark.asyncio +async def test_execute_returns_separate_streams_and_result_text( + create_patch, sandbox +): + """Command output keeps stdout and stderr separate.""" + sandbox.commands.run.return_value = _execution( + stdout=("first\n", "second"), + stderr=("warning\n",), + results=("result\n",), + ) + env = OpenSandboxEnvironment(env_vars={"A": "1"}) + await env.initialize() + + result = await env.execute("printf test") + + assert result.exit_code == 0 + assert result.stdout == "first\nsecond\nresult" + assert result.stderr == "warning" + assert result.timed_out is False + opts = sandbox.commands.run.await_args.kwargs["opts"] + assert opts.working_directory == "/workspace" + assert opts.timeout is None + assert opts.envs == {"A": "1"} + sandbox.renew.assert_awaited_once_with(timedelta(seconds=300)) + + +@pytest.mark.asyncio +async def test_execute_preserves_nonzero_exit(create_patch, sandbox): + """A nonzero process exit is a normal execution result.""" + sandbox.commands.run.return_value = _execution( + exit_code=7, stderr=("failed",) + ) + env = OpenSandboxEnvironment() + await env.initialize() + + result = await env.execute("exit 7") + + assert result.exit_code == 7 + assert result.stderr == "failed" + + +@pytest.mark.asyncio +async def test_execute_renews_past_command_deadline(create_patch, sandbox): + """A finite command deadline cannot outlive the owned sandbox TTL.""" + sandbox.commands.run.return_value = _execution(stdout=("done",)) + env = OpenSandboxEnvironment(timeout=60) + await env.initialize() + + await env.execute("sleep 120", timeout=120) + + sandbox.renew.assert_awaited_once_with(timedelta(seconds=150)) + + +@pytest.mark.asyncio +async def test_execute_without_deadline_renews_while_running( + create_patch, sandbox, monkeypatch +): + """An unlimited command receives heartbeat renewals until it completes.""" + + async def _run(*_args, **_kwargs): + await asyncio.sleep(0.13) + return _execution(stdout=("done",)) + + monkeypatch.setattr( + "google.adk.integrations.opensandbox._opensandbox_environment." + "_MAX_RENEW_INTERVAL_SECONDS", + 0.05, + ) + sandbox.commands.run.side_effect = _run + env = OpenSandboxEnvironment(timeout=60) + await env.initialize() + + result = await env.execute("sleep 0.13") + + assert result.stdout == "done" + assert sandbox.renew.await_count >= 2 + sandbox.renew.assert_awaited_with(timedelta(seconds=60)) + + +@pytest.mark.asyncio +async def test_short_operation_does_not_shorten_long_command_ttl( + create_patch, sandbox +): + """Concurrent operations serialize renewals without reducing expiration.""" + command_started = asyncio.Event() + allow_command = asyncio.Event() + + async def _run(*_args, **_kwargs): + command_started.set() + await allow_command.wait() + return _execution(stdout=("done",)) + + sandbox.commands.run.side_effect = _run + sandbox.files.read_bytes.return_value = b"data" + env = OpenSandboxEnvironment(timeout=60) + await env.initialize() + + execute = asyncio.create_task(env.execute("sleep 600", timeout=600)) + await command_started.wait() + assert await env.read_file("data.bin") == b"data" + + sandbox.renew.assert_awaited_once_with(timedelta(seconds=630)) + allow_command.set() + result = await execute + + assert result.stdout == "done" + + +@pytest.mark.asyncio +async def test_execute_marks_server_deadline_as_timed_out( + create_patch, sandbox +): + """A deadline-length exit minus one is reported as a best-effort timeout.""" + sandbox.commands.run.return_value = _execution(exit_code=-1) + env = OpenSandboxEnvironment() + await env.initialize() + + with mock.patch( + "google.adk.integrations.opensandbox._opensandbox_environment.monotonic", + side_effect=[10.0, 11.1], + ): + result = await env.execute("sleep 30", timeout=1) + + assert result.timed_out is True + opts = sandbox.commands.run.await_args.kwargs["opts"] + assert opts.timeout == timedelta(seconds=1) + + +@pytest.mark.asyncio +async def test_execute_does_not_mislabel_early_sigkill(create_patch, sandbox): + """An immediate exit minus one is not called a timeout before its deadline.""" + sandbox.commands.run.return_value = _execution(exit_code=-1) + env = OpenSandboxEnvironment() + await env.initialize() + + with mock.patch( + "google.adk.integrations.opensandbox._opensandbox_environment.monotonic", + side_effect=[10.0, 10.1], + ): + result = await env.execute("kill -9 $$", timeout=5) + + assert result.timed_out is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("timeout", [0, 0.0001]) +async def test_execute_uses_minimum_one_millisecond_timeout( + create_patch, sandbox, timeout +): + """Zero and sub-millisecond deadlines never become unlimited commands.""" + sandbox.commands.run.return_value = _execution(exit_code=-1) + env = OpenSandboxEnvironment() + await env.initialize() + + await env.execute("sleep 30", timeout=timeout) + + opts = sandbox.commands.run.await_args.kwargs["opts"] + assert opts.timeout == timedelta(milliseconds=1) + + +@pytest.mark.asyncio +async def test_execute_rejects_negative_timeout(create_patch): + """Negative command deadlines fail before reaching the SDK.""" + env = OpenSandboxEnvironment() + await env.initialize() + + with pytest.raises(ValueError, match="non-negative"): + await env.execute("echo hi", timeout=-1) + + +@pytest.mark.asyncio +async def test_execute_rejects_missing_exit_code(create_patch, sandbox): + """An incomplete SDK execution cannot be reported as success.""" + error = SimpleNamespace(name="ProtocolError", value="stream ended") + sandbox.commands.run.return_value = _execution(exit_code=None, error=error) + env = OpenSandboxEnvironment() + await env.initialize() + + with pytest.raises(RuntimeError, match="ProtocolError: stream ended"): + await env.execute("echo hi") + + +@pytest.mark.asyncio +async def test_read_file_resolves_relative_path(create_patch, sandbox): + """Relative file reads use the configured working directory.""" + sandbox.files.read_bytes.return_value = b"data" + env = OpenSandboxEnvironment(working_dir="/work") + await env.initialize() + + content = await env.read_file(Path("nested/data.bin")) + + assert content == b"data" + sandbox.files.read_bytes.assert_awaited_once_with("/work/nested/data.bin") + + +@pytest.mark.asyncio +async def test_read_file_preserves_absolute_path(create_patch, sandbox): + """Absolute remote paths pass through unchanged.""" + sandbox.files.read_bytes.return_value = b"host" + env = OpenSandboxEnvironment() + await env.initialize() + + await env.read_file("/etc/hostname") + + sandbox.files.read_bytes.assert_awaited_once_with("/etc/hostname") + + +@pytest.mark.asyncio +async def test_read_file_maps_not_found(create_patch, sandbox): + """OpenSandbox HTTP 404 errors become FileNotFoundError.""" + sandbox.files.read_bytes.side_effect = SandboxApiException(status_code=404) + env = OpenSandboxEnvironment() + await env.initialize() + + with pytest.raises(FileNotFoundError, match="missing.txt"): + await env.read_file("missing.txt") + + +@pytest.mark.asyncio +async def test_read_file_preserves_other_api_errors(create_patch, sandbox): + """Non-404 API failures retain their OpenSandbox error type.""" + error = SandboxApiException(status_code=403) + sandbox.files.read_bytes.side_effect = error + env = OpenSandboxEnvironment() + await env.initialize() + + with pytest.raises(SandboxApiException) as caught: + await env.read_file("private.txt") + + assert caught.value is error + + +@pytest.mark.asyncio +async def test_write_file_resolves_path_and_uses_regular_file_mode( + create_patch, sandbox +): + """Writes create regular non-executable files below the workspace.""" + env = OpenSandboxEnvironment() + await env.initialize() + + await env.write_file("nested/out.txt", "hello") + + sandbox.files.write_file.assert_awaited_once_with( + "/workspace/nested/out.txt", "hello", mode=644 + ) + + +@pytest.mark.asyncio +async def test_write_file_preserves_binary_content(create_patch, sandbox): + """Binary file content reaches the SDK without text conversion.""" + env = OpenSandboxEnvironment() + await env.initialize() + content = b"\x00\xff" + + await env.write_file("/tmp/data.bin", content) + + sandbox.files.write_file.assert_awaited_once_with( + "/tmp/data.bin", content, mode=644 + ) + + +@pytest.mark.asyncio +async def test_attached_operations_do_not_renew_caller_ttl( + connect_patch, sandbox +): + """Using an attached sandbox does not modify its owner's expiration policy.""" + sandbox.files.read_bytes.return_value = b"data" + env = OpenSandboxEnvironment(sandbox_id="existing-1") + await env.initialize() + + await env.read_file("data.bin") + + sandbox.renew.assert_not_awaited() + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"image": "python:3.11", "snapshot_id": "snap"}, "mutually"), + ({"sandbox_id": "id", "image": "python:3.11"}, "attaching"), + ({"timeout": 0}, "timeout"), + ({"timeout": 59}, "at least 60"), + ({"ready_timeout": 0}, "ready_timeout"), + ({"request_timeout": 0}, "request_timeout"), + ({"working_dir": "relative"}, "absolute"), + ], +) +def test_constructor_rejects_invalid_configuration(kwargs, message): + """Invalid lifecycle and path combinations fail during construction.""" + with pytest.raises(ValueError, match=message): + OpenSandboxEnvironment(**kwargs) + + +def test_constructor_rejects_mixed_connection_configuration(): + """Complete and individual connection settings cannot be mixed.""" + config = ConnectionConfig() + + with pytest.raises(TypeError, match="cannot be combined"): + OpenSandboxEnvironment(connection_config=config, domain="localhost:8080") + + +@pytest.mark.asyncio +async def test_initialize_explains_missing_optional_dependency(): + """Missing OpenSandbox installs produce an actionable extra hint.""" + env = OpenSandboxEnvironment() + + with mock.patch.dict("sys.modules", {"opensandbox": None}): + with pytest.raises(ImportError, match=r"google-adk\[opensandbox\]"): + await env.initialize() From 381a5ee3f4183166c8fb3718263b39cd5883110f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E8=8F=93?= Date: Mon, 17 Aug 2026 18:50:49 +0800 Subject: [PATCH 2/2] refactor(integrations): simplify OpenSandbox environment --- .../opensandbox_environment/README.md | 10 +- docs/guides/README.md | 3 - .../opensandbox_environment/index.md | 211 ------ .../integrations/opensandbox/verification.md | 28 - .../opensandbox/_opensandbox_environment.py | 443 +++--------- .../test_opensandbox_environment.py | 674 +++--------------- 6 files changed, 200 insertions(+), 1169 deletions(-) delete mode 100644 docs/guides/integrations/opensandbox/opensandbox_environment/index.md delete mode 100644 docs/guides/integrations/opensandbox/verification.md diff --git a/contributing/samples/environment_and_skills/opensandbox_environment/README.md b/contributing/samples/environment_and_skills/opensandbox_environment/README.md index 6469ddeda40..03c1454d2e9 100644 --- a/contributing/samples/environment_and_skills/opensandbox_environment/README.md +++ b/contributing/samples/environment_and_skills/opensandbox_environment/README.md @@ -8,8 +8,8 @@ workspace. The default environment creates a `python:3.11` sandbox, keeps its five-minute lifetime active while the agent uses it, and destroys it when the toolset closes. -OpenSandbox can run locally on Docker or behind a remote lifecycle service. See -the [OpenSandbox documentation](https://open-sandbox.ai) for server setup. +OpenSandbox can run locally on Docker or as a self-hosted remote deployment. +See the [OpenSandbox documentation](https://open-sandbox.ai) for server setup. ## Prerequisites @@ -20,7 +20,7 @@ the [OpenSandbox documentation](https://open-sandbox.ai) for server setup. ``` 1. Start an OpenSandbox server. A local server uses `localhost:8080` by - default. For a remote service, configure its domain and API key: + default. For a remote deployment, configure its domain and API key: ```bash export OPEN_SANDBOX_DOMAIN="https://sandbox.example.com" @@ -73,7 +73,3 @@ without destroying the remote sandbox: ```python environment = OpenSandboxEnvironment(sandbox_id="existing-sandbox-id") ``` - -## Related Guides - -- [OpenSandboxEnvironment guide](../../../../docs/guides/integrations/opensandbox/opensandbox_environment/index.md) - Configure lifecycle, execution, file operations, and remote connections. diff --git a/docs/guides/README.md b/docs/guides/README.md index 682b75fa580..51bc7cd9df9 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -23,9 +23,6 @@ This directory contains specific developer guides for the ADK Python implementat * [Event and NodeInfo](events/event/index.md) - Understanding Event and NodeInfo in workflows. * [RequestInput](events/request_input/index.md) - How to use RequestInput for human-in-the-loop interactions. -### Integrations -* [OpenSandboxEnvironment](integrations/opensandbox/opensandbox_environment/index.md) - Run ADK environment tools in an isolated OpenSandbox workspace. - ### Memory * [BaseMemoryService](memory/memory_service/index.md) - Storing finished sessions and recalling them from later conversations. diff --git a/docs/guides/integrations/opensandbox/opensandbox_environment/index.md b/docs/guides/integrations/opensandbox/opensandbox_environment/index.md deleted file mode 100644 index f378f3f1743..00000000000 --- a/docs/guides/integrations/opensandbox/opensandbox_environment/index.md +++ /dev/null @@ -1,211 +0,0 @@ -# OpenSandboxEnvironment - -`OpenSandboxEnvironment` implements ADK's `BaseEnvironment` contract with an -isolated OpenSandbox workspace. It gives `EnvironmentToolset` a persistent -remote shell and byte-oriented filesystem without running model-generated code -on the ADK host. - -## Introduction - -ADK environment tools need four capabilities from a runtime: lifecycle -management, a working directory, command execution, and file I/O. -`OpenSandboxEnvironment` maps those operations to the asynchronous OpenSandbox -SDK. A single sandbox persists across tool calls, so commands can reuse files -and installed packages. - -The environment can either create a sandbox or attach to an existing one. -Created sandboxes are owned by the environment and destroyed during `close()`. -Attached sandboxes remain caller-owned; closing the environment only releases -its local SDK resources. - -The integration depends on: - -- `BaseEnvironment` and `ExecutionResult` for the ADK runtime contract; -- `EnvironmentToolset` as its primary ADK consumer; -- the optional `opensandbox` package for lifecycle, command, and file APIs. - -## Get started - -Install the optional dependency and start an OpenSandbox lifecycle server: - -```bash -pip install google-adk[opensandbox] -export OPEN_SANDBOX_DOMAIN="localhost:8080" -``` - -Then pass the environment to the standard toolset: - -```python -from google.adk import Agent -from google.adk.integrations.opensandbox import OpenSandboxEnvironment -from google.adk.tools.environment import EnvironmentToolset - -environment = OpenSandboxEnvironment( - image="python:3.11", - timeout=300, -) - -root_agent = Agent( - name="sandboxed_coding_agent", - instruction=( - "Use the environment tools to inspect files, write code, and run it." - ), - tools=[EnvironmentToolset(environment=environment)], -) -``` - -`EnvironmentToolset` initializes the sandbox before returning tools and closes -it when the toolset closes. Relative command and file paths resolve below -`/workspace` by default. - -## How it works - -Initialization is serialized with an async lock, so concurrent callers cannot -create duplicate sandboxes. For a new environment, initialization performs -these steps: - -1. Build an OpenSandbox `ConnectionConfig` from explicit options or the - `OPEN_SANDBOX_API_KEY` and `OPEN_SANDBOX_DOMAIN` environment variables. -1. Call `Sandbox.create()` with an image or snapshot. -1. Create the configured working directory through the filesystem API. -1. Mark the ADK environment initialized only after all setup succeeds. - -If working-directory setup fails, the environment destroys a sandbox it -created. When attaching by `sandbox_id`, the same failure closes only the local -SDK client. - -Before each operation on an owned sandbox, the environment renews its configured -lifetime. A finite command deadline extends that renewal past the deadline, and -a command without a deadline uses periodic renewal heartbeats. It does not renew -an attached sandbox because the caller owns that resource's expiration policy. - -Operations hold a shared lifecycle lease. They can run concurrently, while -`close()` waits for in-flight operations before destroying or disconnecting the -sandbox. Once closing starts, new operations fail fast and a concurrent -`initialize()` waits until cleanup finishes before creating a new sandbox. -Concurrent `close()` calls share the same cleanup operation. If destroying an -owned sandbox fails, a later `close()` reconnects by ID and retries cleanup -before the environment can be initialized again. - -Owned operations serialize renewal requests and remember the expiration time -returned by the service. A shorter concurrent operation cannot overwrite a -longer command's expiration. Operations that can run indefinitely also renew -periodically until they finish. - -Commands use `RunCommandOpts` to set the working directory, command deadline, -and environment variables. OpenSandbox stdout, stderr, and exit status become -an ADK `ExecutionResult`. Nonzero exit status is returned normally rather than -raised as an exception. - -File operations resolve relative paths below the working directory. Reads -return raw bytes, HTTP 404 becomes `FileNotFoundError`, and writes explicitly -use mode `644` rather than the OpenSandbox SDK's executable default. - -## Configuration options - -| Parameter | Type | Default | Description | -| ------------------- | --------------------------------- | ------------------------------- | -------------------------------------------------------------------- | -| `image` | `str \| SandboxImageSpec \| None` | `python:3.11` | Container image for a newly created sandbox. | -| `snapshot_id` | `str \| None` | `None` | Snapshot startup source used instead of an image. | -| `sandbox_id` | `str \| None` | `None` | Existing caller-owned sandbox to attach to. | -| `timeout` | `int \| None` | `300` | Owned sandbox lifetime, at least 60 seconds; `None` disables expiry. | -| `ready_timeout` | `float` | `30` | Maximum wait for create or connect readiness. | -| `working_dir` | `str \| os.PathLike` | `/workspace` | Absolute POSIX path used for commands and relative files. | -| `env_vars` | `dict[str, str] \| None` | `None` | Variables applied during create and command execution. | -| `metadata` | `dict[str, str] \| None` | `None` | Lifecycle metadata for a newly created sandbox. | -| `connection_config` | `ConnectionConfig \| None` | `None` | Complete SDK connection configuration. | -| `api_key` | `str \| None` | environment | OpenSandbox lifecycle API key. | -| `domain` | `str \| None` | environment or `localhost:8080` | Lifecycle API domain. | -| `protocol` | `str \| None` | `http` | Scheme used when the domain has no scheme. | -| `request_timeout` | `float \| None` | `30` | HTTP request timeout in seconds. | -| `use_server_proxy` | `bool \| None` | `False` | Route sandbox service requests through the lifecycle server. | - -`image` and `snapshot_id` are mutually exclusive. Neither can be supplied with -`sandbox_id`. A complete `connection_config` also cannot be mixed with the -individual connection settings. - -### Use a remote server - -Basic connection options can be supplied explicitly: - -```python -environment = OpenSandboxEnvironment( - image="python:3.11", - domain="sandbox.example.com", - protocol="https", - api_key="...", - use_server_proxy=True, -) -``` - -Prefer environment-based secret injection in deployed applications rather than -putting an API key in source code. - -## Advanced applications - -### Attach without taking ownership - -Use an existing workspace when another component manages its lifecycle: - -```python -environment = OpenSandboxEnvironment( - sandbox_id="existing-sandbox-id", - env_vars={"TASK_ID": "analysis-42"}, -) - -await environment.initialize() -result = await environment.execute("printf '%s' \"$TASK_ID\"") -await environment.close() # The remote sandbox remains running. -``` - -Command-level `env_vars` still apply to attached sandboxes. Their remote -lifetime is not renewed or destroyed by this environment. - -### Start from a snapshot - -Pass a snapshot when the workspace needs preinstalled dependencies: - -```python -environment = OpenSandboxEnvironment( - snapshot_id="python-data-tools", - timeout=900, -) -``` - -Snapshots and images are alternative startup sources, so do not pass both. - -## Limitations - -- OpenSandbox SDK 0.1.x does not expose a dedicated command-timeout flag. The - adapter marks a result timed out only when a deadline was supplied, execution - lasted at least that long, and the SDK returned exit code `-1`. A process - terminated by another SIGKILL near the deadline can be indistinguishable. -- SDK 0.1.15 parses command output as line-oriented SSE. Unicode separators - U+0085, U+2028, and U+2029 can be split by the underlying HTTP parser and - dropped. This is an SDK transport limitation, not an ADK file-I/O limitation. -- Command stream events do not preserve whether every original chunk ended in - a newline. The adapter preserves text and ordering but reconstructs chunks - with `\n` separators. -- Like the existing E2B and Daytona remote environments, absolute file paths - can address locations outside the working directory. Treat the entire - sandbox filesystem as the security boundary. -- `BaseEnvironment` currently has no endpoint discovery, snapshot creation, - pause, or resume methods. Those OpenSandbox capabilities are outside this - integration's public ADK contract. -- A `timeout` of `None` requires explicit cleanup and is runtime-dependent. - Some Kubernetes workload providers can reject non-expiring sandboxes. -- If an owned sandbox expires while idle, the next operation surfaces the - OpenSandbox error instead of silently creating an empty replacement. Call - `close()` and then `initialize()` to create a fresh workspace. -- A command without a deadline can also delay `close()` until the command - returns or its caller cancels it. This preserves in-flight operation safety; - callers remain responsible for cancelling work that should not finish. - -## Related samples - -See -[`contributing/samples/environment_and_skills/opensandbox_environment`](../../../../../contributing/samples/environment_and_skills/opensandbox_environment/README.md) -for a runnable coding-agent configuration. - -The [verification record](../verification.md) lists the automated checks and -the live-test boundary used for this integration. diff --git a/docs/guides/integrations/opensandbox/verification.md b/docs/guides/integrations/opensandbox/verification.md deleted file mode 100644 index c10be6e7135..00000000000 --- a/docs/guides/integrations/opensandbox/verification.md +++ /dev/null @@ -1,28 +0,0 @@ -# OpenSandbox environment verification - -Verification recorded on 2026-08-13 against upstream commit -`2e878ed4120b1009080ec4bd189cf8b436d03ccf`. - -## Automated checks - -```bash -uv run pytest \ - tests/unittests/integrations/opensandbox/test_opensandbox_environment.py -q -./scripts/update_constraints.sh --check -``` - -Result: 52 tests passed, and all Python 3.10-3.14 constraint files were -up to date. - -## Live boundary - -The environment was exercised end to end against a local OpenSandbox server -using ADK's default `python:3.11` image. Agent-driven command execution and -workspace file operations passed. - -The same agent flow was exercised against a private HTTPS OpenSandbox -deployment using server-proxy mode. Its endpoint, credential, and private image -are intentionally not recorded here. - -These live checks require an OpenSandbox server and model credentials, so they -are not part of the default unit-test suite. diff --git a/src/google/adk/integrations/opensandbox/_opensandbox_environment.py b/src/google/adk/integrations/opensandbox/_opensandbox_environment.py index 6880fe62dea..38c3fca4b31 100644 --- a/src/google/adk/integrations/opensandbox/_opensandbox_environment.py +++ b/src/google/adk/integrations/opensandbox/_opensandbox_environment.py @@ -16,11 +16,7 @@ from __future__ import annotations -import asyncio -from contextlib import asynccontextmanager -from datetime import datetime from datetime import timedelta -from datetime import timezone import logging import math import os @@ -28,7 +24,6 @@ from pathlib import PurePosixPath from time import monotonic from typing import TYPE_CHECKING -from urllib.parse import urlsplit from typing_extensions import override @@ -38,8 +33,6 @@ from ...features import FeatureName if TYPE_CHECKING: - from collections.abc import AsyncIterator - from opensandbox import Sandbox from opensandbox.config import ConnectionConfig from opensandbox.models.execd import Execution @@ -48,15 +41,13 @@ logger = logging.getLogger("google_adk." + __name__) _DEFAULT_IMAGE = "python:3.11" -_DEFAULT_SANDBOX_TIMEOUT = 300 +_DEFAULT_TIMEOUT = 300 _DEFAULT_READY_TIMEOUT = 30 -_DEFAULT_REQUEST_TIMEOUT = 30 -_MIN_SANDBOX_TIMEOUT = 60 -_COMMAND_TTL_GRACE = timedelta(seconds=30) -_MAX_RENEW_INTERVAL_SECONDS = 60 +_MIN_TIMEOUT = 60 +_COMMAND_TTL_GRACE = 30 _SANDBOX_HOME = "/workspace" -# OpenSandbox's WriteEntry schema uses decimal digits for Unix modes (for -# example, its SDK default is 755), rather than Python's raw octal bit value. +# OpenSandbox modes use decimal digits, not Python's octal literal form. +_DIRECTORY_MODE = 755 _FILE_MODE = 644 _HTTP_NOT_FOUND = 404 _TIMEOUT_EXIT_CODE = -1 @@ -68,30 +59,27 @@ class OpenSandboxEnvironment(BaseEnvironment): By default, ``initialize()`` creates a sandbox and ``close()`` destroys it. When ``sandbox_id`` is supplied, the environment attaches to that sandbox; - ``close()`` then releases only local SDK resources and leaves the caller-owned - remote sandbox running. + ``close()`` then releases only local SDK resources and leaves the remote + sandbox running. + + Commands without an explicit timeout renew only the configured sandbox + lifetime. Pass a timeout when a command may run longer than that lifetime. Requires the ``opensandbox`` extra: ``pip install google-adk[opensandbox]``. """ - def __init__( # pylint: disable=too-many-arguments + def __init__( self, *, image: str | SandboxImageSpec | None = None, snapshot_id: str | None = None, sandbox_id: str | None = None, - timeout: int | None = _DEFAULT_SANDBOX_TIMEOUT, + timeout: int = _DEFAULT_TIMEOUT, ready_timeout: float = _DEFAULT_READY_TIMEOUT, - working_dir: str | os.PathLike[str] = _SANDBOX_HOME, env_vars: dict[str, str] | None = None, metadata: dict[str, str] | None = None, connection_config: ConnectionConfig | None = None, - api_key: str | None = None, - domain: str | None = None, - protocol: str | None = None, - request_timeout: float | None = None, - use_server_proxy: bool | None = None, ): """Create an OpenSandbox environment. @@ -100,23 +88,16 @@ def __init__( # pylint: disable=too-many-arguments ``python:3.11``. Mutually exclusive with ``snapshot_id``. snapshot_id: Snapshot used instead of an image for a new sandbox. sandbox_id: Existing sandbox to attach to instead of creating one. - timeout: Remote sandbox lifetime in seconds. Active, owned environments - renew this lifetime before each operation. Must be at least 60 seconds; - ``None`` disables expiry when supported by the runtime. + timeout: Owned sandbox lifetime in seconds. Must be at least 60 seconds. + The lifetime is renewed before each operation. ready_timeout: Maximum seconds to wait for create or connect readiness. - working_dir: Absolute POSIX path used for commands and relative files. - env_vars: Environment variables applied to created sandboxes and commands. + env_vars: Environment variables applied to new sandboxes and commands. metadata: Additional metadata for a newly created sandbox. - connection_config: Complete OpenSandbox connection configuration. - api_key: OpenSandbox API key. Falls back to ``OPEN_SANDBOX_API_KEY``. - domain: OpenSandbox service domain. Falls back to - ``OPEN_SANDBOX_DOMAIN``. - protocol: Protocol used when ``domain`` has no URL scheme. - request_timeout: HTTP request timeout in seconds. - use_server_proxy: Route sandbox requests through the lifecycle server. + connection_config: OpenSandbox SDK connection configuration. When + omitted, the SDK reads ``OPEN_SANDBOX_DOMAIN`` and + ``OPEN_SANDBOX_API_KEY``. Raises: - TypeError: If ``connection_config`` is mixed with basic connection fields. ValueError: If configuration values are invalid or mutually exclusive. """ if image is not None and snapshot_id is not None: @@ -127,130 +108,61 @@ def __init__( # pylint: disable=too-many-arguments raise ValueError( "image and snapshot_id cannot be used when attaching by sandbox_id" ) - if timeout is not None and timeout < _MIN_SANDBOX_TIMEOUT: - raise ValueError("timeout must be at least 60 seconds or None") + if timeout < _MIN_TIMEOUT: + raise ValueError("timeout must be at least 60 seconds") if ready_timeout <= 0: raise ValueError("ready_timeout must be positive") - if request_timeout is not None and request_timeout <= 0: - raise ValueError("request_timeout must be positive") - - pure_working_dir = PurePosixPath(os.fspath(working_dir)) - if not pure_working_dir.is_absolute(): - raise ValueError("working_dir must be an absolute POSIX path") - - basic_connection_fields = ( - api_key, - domain, - protocol, - request_timeout, - use_server_proxy, - ) - if connection_config is not None and any( - value is not None for value in basic_connection_fields - ): - raise TypeError( - "connection_config cannot be combined with basic connection fields" - ) self._image = image self._snapshot_id = snapshot_id self._requested_sandbox_id = sandbox_id - self._sandbox_timeout = ( - timedelta(seconds=timeout) if timeout is not None else None - ) + self._timeout = timedelta(seconds=timeout) self._ready_timeout = timedelta(seconds=ready_timeout) - self._working_dir = pure_working_dir self._env_vars = dict(env_vars) if env_vars is not None else None self._metadata = dict(metadata) if metadata is not None else None self._connection_config = connection_config - self._api_key = api_key - self._domain = domain - self._protocol = protocol - self._request_timeout = request_timeout - self._use_server_proxy = use_server_proxy self._sandbox: Sandbox | None = None - self._owns_remote = False - self._pending_destroy_id: str | None = None - self._known_expiration: datetime | None = None - self._renew_lock = asyncio.Lock() - self._active_operations = 0 - self._closing = False - self._close_task: asyncio.Task[None] | None = None - self._lifecycle_condition = asyncio.Condition() + self._owns_sandbox = False @property @override def working_dir(self) -> Path: if self._sandbox is None: raise RuntimeError("Sandbox is not started. Call initialize() first.") - return Path(str(self._working_dir)) + return Path(_SANDBOX_HOME) @override async def initialize(self) -> None: - async with self._lifecycle_condition: - await self._lifecycle_condition.wait_for(lambda: not self._closing) - if self._sandbox is not None: - return - await self._retry_pending_destroy() + if self._sandbox is not None: + return - sandbox, owns_remote = await self._open_sandbox() + sandbox, owns_sandbox = await self._open_sandbox() + try: + await self._prepare_working_directory(sandbox) + except BaseException: try: - await self._prepare_working_directory(sandbox) - except BaseException: - cleanup_task = asyncio.create_task( - self._cleanup_sandbox(sandbox, owns_remote=owns_remote) + await self._cleanup_sandbox(sandbox, owns_sandbox=owns_sandbox) + except Exception: # pylint: disable=broad-exception-caught + logger.warning( + "Failed to clean up OpenSandbox after initialization failure", + exc_info=True, ) - try: - await asyncio.shield(cleanup_task) - except asyncio.CancelledError: - try: - await cleanup_task - except BaseException: # pylint: disable=broad-exception-caught - if owns_remote: - self._pending_destroy_id = sandbox.id - logger.warning( - "Failed to clean up OpenSandbox after initialization was " - "cancelled", - exc_info=True, - ) - raise - except BaseException: # pylint: disable=broad-exception-caught - if owns_remote: - self._pending_destroy_id = sandbox.id - logger.warning( - "Failed to clean up OpenSandbox after initialization failure", - exc_info=True, - ) - raise + raise - self._sandbox = sandbox - self._owns_remote = owns_remote - self._known_expiration = None - self._is_initialized = True + self._sandbox = sandbox + self._owns_sandbox = owns_sandbox + self._is_initialized = True @override async def close(self) -> None: - async with self._lifecycle_condition: - close_task = self._close_task - if close_task is None: - self._closing = True - close_task = asyncio.create_task(self._close_once()) - close_task.add_done_callback(self._log_close_task_failure) - self._close_task = close_task - - await asyncio.wait({close_task}) - close_task.result() - - @staticmethod - def _log_close_task_failure(close_task: asyncio.Task[None]) -> None: - if close_task.cancelled(): + sandbox = self._sandbox + if sandbox is None: return - error = close_task.exception() - if error is not None: - logger.warning( - "OpenSandbox cleanup task failed", - exc_info=(type(error), error, error.__traceback__), - ) + + await self._cleanup_sandbox(sandbox, owns_sandbox=self._owns_sandbox) + self._sandbox = None + self._owns_sandbox = False + self._is_initialized = False @override async def execute( @@ -264,19 +176,19 @@ async def execute( from opensandbox.models.execd import RunCommandOpts - sdk_timeout = self._command_timeout(timeout) - renewal_timeout = self._command_renewal_timeout(timeout) - async with self._sandbox_operation( - renewal_timeout=renewal_timeout - ) as sandbox: - started_at = monotonic() - opts = RunCommandOpts( - working_directory=str(self._working_dir), - timeout=sdk_timeout, - envs=self._env_vars, - ) - execution = await sandbox.commands.run(command, opts=opts) - elapsed = monotonic() - started_at + sandbox = await self._get_sandbox( + renewal_timeout=self._command_renewal_timeout(timeout) + ) + started_at = monotonic() + execution = await sandbox.commands.run( + command, + opts=RunCommandOpts( + working_directory=_SANDBOX_HOME, + timeout=self._command_timeout(timeout), + envs=self._env_vars, + ), + ) + elapsed = monotonic() - started_at if execution.exit_code is None: error = execution.error @@ -304,25 +216,23 @@ async def execute( async def read_file(self, path: str | os.PathLike[str]) -> bytes: from opensandbox.exceptions import SandboxApiException + sandbox = await self._get_sandbox() resolved = self._resolve_path(path) - async with self._sandbox_operation() as sandbox: - try: - return await sandbox.files.read_bytes(resolved) - except SandboxApiException as e: - if e.status_code == _HTTP_NOT_FOUND: - raise FileNotFoundError(resolved) from e - raise + try: + return await sandbox.files.read_bytes(resolved) + except SandboxApiException as e: + if e.status_code == _HTTP_NOT_FOUND: + raise FileNotFoundError(resolved) from e + raise @override async def write_file( - self, - path: str | os.PathLike[str], - content: str | bytes, + self, path: str | os.PathLike[str], content: str | bytes ) -> None: - async with self._sandbox_operation() as sandbox: - await sandbox.files.write_file( - self._resolve_path(path), content, mode=_FILE_MODE - ) + sandbox = await self._get_sandbox() + await sandbox.files.write_file( + self._resolve_path(path), content, mode=_FILE_MODE + ) async def _open_sandbox(self) -> tuple[Sandbox, bool]: try: @@ -333,11 +243,10 @@ async def _open_sandbox(self) -> tuple[Sandbox, bool]: "Install it with `pip install google-adk[opensandbox]`." ) from e - connection_config = self._build_connection_config() if self._requested_sandbox_id is not None: sandbox = await Sandbox.connect( self._requested_sandbox_id, - connection_config=connection_config, + connection_config=self._connection_config, connect_timeout=self._ready_timeout, ) return sandbox, False @@ -353,76 +262,27 @@ async def _open_sandbox(self) -> tuple[Sandbox, bool]: sandbox = await Sandbox.create( image, snapshot_id=self._snapshot_id, - timeout=self._sandbox_timeout, + timeout=self._timeout, ready_timeout=self._ready_timeout, env=self._env_vars, metadata=metadata, - connection_config=connection_config, + connection_config=self._connection_config, ) return sandbox, True - async def _close_once(self) -> None: - current_task = asyncio.current_task() - try: - async with self._lifecycle_condition: - await self._lifecycle_condition.wait_for( - lambda: self._active_operations == 0 - ) - sandbox = self._sandbox - if sandbox is None: - await self._retry_pending_destroy() - return - - owns_remote = self._owns_remote - sandbox_id = sandbox.id - self._sandbox = None - self._owns_remote = False - self._known_expiration = None - self._is_initialized = False - - try: - await self._cleanup_sandbox(sandbox, owns_remote=owns_remote) - except BaseException: # pylint: disable=broad-exception-caught - if owns_remote: - self._pending_destroy_id = sandbox_id - raise - finally: - async with self._lifecycle_condition: - if self._close_task is current_task: - self._close_task = None - self._closing = False - self._lifecycle_condition.notify_all() - - def _build_connection_config(self) -> ConnectionConfig: - if self._connection_config is not None: - return self._connection_config - - from opensandbox.config import ConnectionConfig - - return ConnectionConfig( - api_key=self._api_key, - domain=self._domain, - protocol=self._resolve_protocol(), - request_timeout=timedelta( - seconds=self._request_timeout or _DEFAULT_REQUEST_TIMEOUT - ), - use_server_proxy=self._use_server_proxy or False, - ) - - async def _prepare_working_directory(self, sandbox: Sandbox) -> None: - from opensandbox.models.filesystem import WriteEntry - - await sandbox.files.create_directories( - [WriteEntry(path=str(self._working_dir), mode=755)] - ) + async def _get_sandbox( + self, *, renewal_timeout: timedelta | None = None + ) -> Sandbox: + sandbox = self._sandbox + if sandbox is None: + raise RuntimeError("Sandbox is not started. Call initialize() first.") + if self._owns_sandbox: + await sandbox.renew(renewal_timeout or self._timeout) + return sandbox @staticmethod - async def _cleanup_sandbox( - sandbox: Sandbox, - *, - owns_remote: bool, - ) -> None: - if not owns_remote: + async def _cleanup_sandbox(sandbox: Sandbox, *, owns_sandbox: bool) -> None: + if not owns_sandbox: await sandbox.close() return @@ -434,135 +294,34 @@ async def _cleanup_sandbox( if e.status_code != _HTTP_NOT_FOUND: raise - @asynccontextmanager - async def _sandbox_operation( - self, - *, - renewal_timeout: timedelta | None = None, - ) -> AsyncIterator[Sandbox]: - async with self._lifecycle_condition: - if self._closing: - raise RuntimeError("Sandbox is closing. Wait for close() to finish.") - sandbox = self._sandbox - if sandbox is None: - raise RuntimeError("Sandbox is not started. Call initialize() first.") - owns_remote = self._owns_remote - if renewal_timeout is None: - renewal_timeout = self._sandbox_timeout - self._active_operations += 1 - - operation_failed = False - stop: asyncio.Event | None = None - heartbeat: asyncio.Task[None] | None = None - try: - if owns_remote and renewal_timeout is not None: - await self._renew_sandbox(sandbox, renewal_timeout) - stop = asyncio.Event() - heartbeat = asyncio.create_task( - self._renew_until_stopped(sandbox, stop, renewal_timeout) - ) - yield sandbox - except BaseException: - operation_failed = True - raise - finally: - try: - if stop is not None and heartbeat is not None: - stop.set() - try: - await heartbeat - except Exception: # pylint: disable=broad-exception-caught - if not operation_failed: - raise - logger.warning( - "OpenSandbox renewal failed while an operation was exiting", - exc_info=True, - ) - finally: - async with self._lifecycle_condition: - self._active_operations -= 1 - if self._active_operations == 0: - self._lifecycle_condition.notify_all() - - async def _renew_sandbox( - self, - sandbox: Sandbox, - renewal_timeout: timedelta, - ) -> None: - async with self._renew_lock: - requested_expiration = datetime.now(timezone.utc) + renewal_timeout - if ( - self._known_expiration is not None - and requested_expiration <= self._known_expiration - ): - return - response = await sandbox.renew(renewal_timeout) - self._known_expiration = response.expires_at - - async def _renew_until_stopped( - self, - sandbox: Sandbox, - stop: asyncio.Event, - renewal_timeout: timedelta, - ) -> None: - interval = min( - renewal_timeout.total_seconds() / 2, - _MAX_RENEW_INTERVAL_SECONDS, + @staticmethod + async def _prepare_working_directory(sandbox: Sandbox) -> None: + from opensandbox.models.filesystem import WriteEntry + + await sandbox.files.create_directories( + [WriteEntry(path=_SANDBOX_HOME, mode=_DIRECTORY_MODE)] ) - while True: - try: - await asyncio.wait_for(stop.wait(), timeout=interval) - return - except asyncio.TimeoutError: - await self._renew_sandbox(sandbox, renewal_timeout) - - async def _retry_pending_destroy(self) -> None: - sandbox_id = self._pending_destroy_id - if sandbox_id is None: - return - from opensandbox import Sandbox - from opensandbox.exceptions import SandboxApiException + def _command_renewal_timeout(self, timeout: float | None) -> timedelta: + if timeout is None: + return self._timeout + return max( + self._timeout, + timedelta(seconds=timeout + _COMMAND_TTL_GRACE), + ) - try: - sandbox = await Sandbox.connect( - sandbox_id, - connection_config=self._build_connection_config(), - skip_health_check=True, - ) - await self._cleanup_sandbox(sandbox, owns_remote=True) - except SandboxApiException as e: - if e.status_code != _HTTP_NOT_FOUND: - raise - self._pending_destroy_id = None - - def _resolve_protocol(self) -> str: - domain = self._domain or os.getenv("OPEN_SANDBOX_DOMAIN") - if domain is not None: - scheme = urlsplit(domain).scheme.lower() - if scheme in {"http", "https"}: - return scheme - return self._protocol or "http" - - def _command_renewal_timeout(self, timeout: float | None) -> timedelta | None: - sandbox_timeout = self._sandbox_timeout - if sandbox_timeout is None or timeout is None: - return sandbox_timeout - command_lifetime = timedelta(seconds=timeout) + _COMMAND_TTL_GRACE - return max(sandbox_timeout, command_lifetime) - - def _resolve_path(self, path: str | os.PathLike[str]) -> str: + @staticmethod + def _resolve_path(path: str | os.PathLike[str]) -> str: pure = PurePosixPath(os.fspath(path)) if pure.is_absolute(): return str(pure) - return str(self._working_dir / pure) + return str(PurePosixPath(_SANDBOX_HOME) / pure) @staticmethod def _command_timeout(timeout: float | None) -> timedelta | None: if timeout is None: return None - timeout_ms = max(1, math.ceil(timeout * 1000)) - return timedelta(milliseconds=timeout_ms) + return timedelta(milliseconds=max(1, math.ceil(timeout * 1000))) @staticmethod def _stdout(execution: Execution) -> str: diff --git a/tests/unittests/integrations/opensandbox/test_opensandbox_environment.py b/tests/unittests/integrations/opensandbox/test_opensandbox_environment.py index f20d217cf37..1678600dcbc 100644 --- a/tests/unittests/integrations/opensandbox/test_opensandbox_environment.py +++ b/tests/unittests/integrations/opensandbox/test_opensandbox_environment.py @@ -14,13 +14,10 @@ """Tests for OpenSandboxEnvironment.""" -import asyncio -from datetime import datetime +from __future__ import annotations + from datetime import timedelta -from datetime import timezone -import gc from pathlib import Path -from time import monotonic from types import SimpleNamespace from unittest import mock @@ -30,21 +27,11 @@ import pytest -def _renew_response(timeout: timedelta) -> SimpleNamespace: - return SimpleNamespace(expires_at=datetime.now(timezone.utc) + timeout) - - -def _make_sandbox(*, sandbox_id: str = "sandbox-1") -> mock.MagicMock: - """Build a mock async OpenSandbox client.""" - - async def _renew(timeout): - return _renew_response(timeout) - +def _make_sandbox() -> mock.MagicMock: sandbox = mock.MagicMock(name="Sandbox") - sandbox.id = sandbox_id sandbox.destroy = mock.AsyncMock() sandbox.close = mock.AsyncMock() - sandbox.renew = mock.AsyncMock(side_effect=_renew) + sandbox.renew = mock.AsyncMock() sandbox.commands.run = mock.AsyncMock() sandbox.files.create_directories = mock.AsyncMock() sandbox.files.read_bytes = mock.AsyncMock() @@ -60,7 +47,6 @@ def _execution( results: tuple[str, ...] = (), error: object | None = None, ) -> SimpleNamespace: - """Build the observable result returned by the OpenSandbox SDK boundary.""" return SimpleNamespace( exit_code=exit_code, error=error, @@ -79,7 +65,6 @@ def _sandbox() -> mock.MagicMock: @pytest.fixture(name="create_patch") def _create_patch(sandbox: mock.MagicMock): - """Patch Sandbox.create to return the mock sandbox.""" with mock.patch( "opensandbox.Sandbox.create", new=mock.AsyncMock(return_value=sandbox) ) as create: @@ -88,7 +73,6 @@ def _create_patch(sandbox: mock.MagicMock): @pytest.fixture(name="connect_patch") def _connect_patch(sandbox: mock.MagicMock): - """Patch Sandbox.connect to return the mock sandbox.""" with mock.patch( "opensandbox.Sandbox.connect", new=mock.AsyncMock(return_value=sandbox) ) as connect: @@ -99,103 +83,67 @@ def _connect_patch(sandbox: mock.MagicMock): async def test_initialize_creates_sandbox_with_configuration( create_patch: mock.AsyncMock, sandbox: mock.MagicMock ): - """Initialization forwards lifecycle and connection settings.""" + config = ConnectionConfig(domain="sandbox.example:8080") env = OpenSandboxEnvironment( image="custom:latest", timeout=120, ready_timeout=12.5, - working_dir="/work", env_vars={"A": "1"}, metadata={"team": "adk"}, - api_key="key", - domain="sandbox.example:8080", - protocol="https", - request_timeout=8, - use_server_proxy=True, + connection_config=config, ) await env.initialize() - assert env.is_initialized is True - assert env.working_dir == Path("/work") args, kwargs = create_patch.call_args assert args == ("custom:latest",) - assert kwargs["timeout"] == timedelta(seconds=120) - assert kwargs["ready_timeout"] == timedelta(seconds=12.5) - assert kwargs["env"] == {"A": "1"} - assert kwargs["metadata"] == { - "framework": "google-adk", - "integration": "google-adk-opensandbox", - "team": "adk", + assert kwargs == { + "snapshot_id": None, + "timeout": timedelta(seconds=120), + "ready_timeout": timedelta(seconds=12.5), + "env": {"A": "1"}, + "metadata": { + "framework": "google-adk", + "integration": "google-adk-opensandbox", + "team": "adk", + }, + "connection_config": config, } - config = kwargs["connection_config"] - assert config.api_key == "key" - assert config.domain == "sandbox.example:8080" - assert config.protocol == "https" - assert config.request_timeout == timedelta(seconds=8) - assert config.use_server_proxy is True directory = sandbox.files.create_directories.await_args.args[0][0] - assert directory.path == "/work" + assert directory.path == "/workspace" assert directory.mode == 755 + assert env.working_dir == Path("/workspace") + assert env.is_initialized is True @pytest.mark.asyncio async def test_initialize_uses_snapshot_without_default_image(create_patch): - """A snapshot startup does not also send the default image.""" env = OpenSandboxEnvironment(snapshot_id="snapshot-1") await env.initialize() - args, kwargs = create_patch.call_args - assert args == (None,) - assert kwargs["snapshot_id"] == "snapshot-1" + assert create_patch.await_args.args == (None,) + assert create_patch.await_args.kwargs["snapshot_id"] == "snapshot-1" @pytest.mark.asyncio -async def test_initialize_uses_complete_connection_config(create_patch): - """A caller-supplied ConnectionConfig is passed through unchanged.""" +async def test_initialize_attaches_to_existing_sandbox(connect_patch): config = ConnectionConfig(domain="sandbox.example:8080") - env = OpenSandboxEnvironment(connection_config=config) - - await env.initialize() - - assert create_patch.await_args.kwargs["connection_config"] is config - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "domain", - ["https://sandbox.example", "https://sandbox.example:8443"], -) -async def test_initialize_derives_https_protocol_from_domain( - create_patch, domain -): - """A URL scheme also configures HTTPS for sandbox service endpoints.""" - env = OpenSandboxEnvironment(domain=domain) - - await env.initialize() - - config = create_patch.await_args.kwargs["connection_config"] - assert config.protocol == "https" - - -@pytest.mark.asyncio -async def test_initialize_derives_protocol_from_environment( - create_patch, monkeypatch -): - """The environment domain scheme applies to lifecycle and sandbox calls.""" - monkeypatch.setenv("OPEN_SANDBOX_DOMAIN", "https://sandbox.example") - env = OpenSandboxEnvironment() + env = OpenSandboxEnvironment( + sandbox_id="existing-1", connection_config=config + ) await env.initialize() - config = create_patch.await_args.kwargs["connection_config"] - assert config.protocol == "https" + connect_patch.assert_awaited_once_with( + "existing-1", + connection_config=config, + connect_timeout=timedelta(seconds=30), + ) @pytest.mark.asyncio async def test_initialize_is_idempotent(create_patch): - """Repeated initialization creates one remote sandbox.""" env = OpenSandboxEnvironment() await env.initialize() @@ -205,367 +153,89 @@ async def test_initialize_is_idempotent(create_patch): @pytest.mark.asyncio -async def test_concurrent_initialize_creates_one_sandbox(create_patch): - """Concurrent initialization is serialized without leaking a sandbox.""" - env = OpenSandboxEnvironment() - - await asyncio.gather(env.initialize(), env.initialize(), env.initialize()) - - create_patch.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_close_destroys_owned_sandbox_and_is_idempotent( +async def test_initialize_failure_cleans_up_owned_sandbox( create_patch, sandbox ): - """Closing a created environment destroys the remote resource once.""" + sandbox.files.create_directories.side_effect = RuntimeError("setup failed") env = OpenSandboxEnvironment() - await env.initialize() - await env.close() - await env.close() + with pytest.raises(RuntimeError, match="setup failed"): + await env.initialize() sandbox.destroy.assert_awaited_once() - sandbox.close.assert_not_awaited() assert env.is_initialized is False @pytest.mark.asyncio -async def test_close_accepts_already_expired_owned_sandbox( - create_patch, sandbox -): - """An already absent remote resource still counts as successfully closed.""" - sandbox.destroy.side_effect = SandboxApiException(status_code=404) - env = OpenSandboxEnvironment() - await env.initialize() - - await env.close() - - with mock.patch( - "opensandbox.Sandbox.connect", new=mock.AsyncMock() - ) as connect: - await env.close() - - connect.assert_not_awaited() - assert env.is_initialized is False - - -@pytest.mark.asyncio -async def test_close_attached_sandbox_releases_only_local_resources( +async def test_initialize_failure_closes_attached_sandbox( connect_patch, sandbox ): - """Closing an attached environment leaves the caller-owned sandbox alive.""" + sandbox.files.create_directories.side_effect = RuntimeError("setup failed") env = OpenSandboxEnvironment(sandbox_id="existing-1") - await env.initialize() - await env.close() + with pytest.raises(RuntimeError, match="setup failed"): + await env.initialize() - connect_patch.assert_awaited_once() - assert connect_patch.await_args.args == ("existing-1",) sandbox.close.assert_awaited_once() sandbox.destroy.assert_not_awaited() @pytest.mark.asyncio -async def test_close_waits_for_active_operation(create_patch, sandbox): - """Closing never destroys a sandbox while a command is using it.""" - renew_started = asyncio.Event() - allow_renew = asyncio.Event() - - async def _renew(timeout): - renew_started.set() - await allow_renew.wait() - return _renew_response(timeout) - - sandbox.renew.side_effect = _renew - sandbox.commands.run.return_value = _execution(stdout=("done",)) - env = OpenSandboxEnvironment() - await env.initialize() - - execute_task = asyncio.create_task(env.execute("printf done")) - await renew_started.wait() - close_task = asyncio.create_task(env.close()) - await asyncio.sleep(0) - - assert close_task.done() is False - sandbox.destroy.assert_not_awaited() - - allow_renew.set() - result, _ = await asyncio.gather(execute_task, close_task) - - assert result.stdout == "done" - sandbox.destroy.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_close_rejects_operations_while_draining(create_patch, sandbox): - """A close in progress prevents new operations from delaying cleanup.""" - renew_started = asyncio.Event() - allow_renew = asyncio.Event() - - async def _renew(timeout): - renew_started.set() - await allow_renew.wait() - return _renew_response(timeout) - - sandbox.renew.side_effect = _renew - sandbox.commands.run.return_value = _execution(stdout=("done",)) - env = OpenSandboxEnvironment() - await env.initialize() - - execute_task = asyncio.create_task(env.execute("printf done")) - await renew_started.wait() - close_task = asyncio.create_task(env.close()) - await asyncio.sleep(0) - - with pytest.raises(RuntimeError, match="closing"): - await env.read_file("late.txt") - - allow_renew.set() - result, _ = await asyncio.gather(execute_task, close_task) - - assert result.stdout == "done" - sandbox.destroy.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_initialize_waits_for_close_then_creates_new_sandbox( +async def test_close_destroys_owned_sandbox_and_is_idempotent( create_patch, sandbox ): - """Initialization concurrent with close returns a new live sandbox.""" - renew_started = asyncio.Event() - allow_renew = asyncio.Event() - - async def _renew(timeout): - renew_started.set() - await allow_renew.wait() - return _renew_response(timeout) - - sandbox.renew.side_effect = _renew - sandbox.commands.run.return_value = _execution(stdout=("done",)) env = OpenSandboxEnvironment() await env.initialize() - execute_task = asyncio.create_task(env.execute("printf done")) - await renew_started.wait() - close_task = asyncio.create_task(env.close()) - await asyncio.sleep(0) - initialize_task = asyncio.create_task(env.initialize()) - await asyncio.sleep(0) - - assert initialize_task.done() is False - assert create_patch.await_count == 1 - - allow_renew.set() - await asyncio.gather(execute_task, close_task, initialize_task) - - assert env.is_initialized is True - assert create_patch.await_count == 2 - sandbox.destroy.assert_awaited_once() - await env.close() - - -@pytest.mark.asyncio -async def test_concurrent_close_does_not_destroy_reinitialized_sandbox(): - """Concurrent close calls coalesce instead of closing the next generation.""" - old_sandbox = _make_sandbox(sandbox_id="old-sandbox") - new_sandbox = _make_sandbox(sandbox_id="new-sandbox") - destroy_started = asyncio.Event() - allow_destroy = asyncio.Event() - - async def _destroy_old(): - destroy_started.set() - await allow_destroy.wait() - - old_sandbox.destroy.side_effect = _destroy_old - create = mock.AsyncMock(side_effect=[old_sandbox, new_sandbox]) - env = OpenSandboxEnvironment() - - with mock.patch("opensandbox.Sandbox.create", new=create): - await env.initialize() - first_close = asyncio.create_task(env.close()) - await destroy_started.wait() - initialize = asyncio.create_task(env.initialize()) - second_close = asyncio.create_task(env.close()) - await asyncio.sleep(0) - - allow_destroy.set() - await asyncio.gather(first_close, initialize, second_close) - - assert env.is_initialized is True - assert create.await_count == 2 - old_sandbox.destroy.assert_awaited_once() - new_sandbox.destroy.assert_not_awaited() - new_sandbox.files.read_bytes.return_value = b"live" - assert await env.read_file("live.txt") == b"live" - await env.close() + sandbox.destroy.assert_awaited_once() + sandbox.close.assert_not_awaited() + assert env.is_initialized is False + @pytest.mark.asyncio -async def test_cancelled_close_does_not_cancel_concurrent_close( +async def test_close_accepts_already_absent_owned_sandbox( create_patch, sandbox ): - """Cancelling one close waiter does not abandon shared cleanup.""" - renew_started = asyncio.Event() - allow_renew = asyncio.Event() - - async def _renew(timeout): - renew_started.set() - await allow_renew.wait() - return _renew_response(timeout) - - sandbox.renew.side_effect = _renew - sandbox.commands.run.return_value = _execution(stdout=("done",)) + sandbox.destroy.side_effect = SandboxApiException(status_code=404) env = OpenSandboxEnvironment() await env.initialize() - execute = asyncio.create_task(env.execute("printf done")) - await renew_started.wait() - first_close = asyncio.create_task(env.close()) - second_close = asyncio.create_task(env.close()) - await asyncio.sleep(0) - first_close.cancel() - - with pytest.raises(asyncio.CancelledError): - await first_close - assert second_close.done() is False - - allow_renew.set() - result, _ = await asyncio.gather(execute, second_close) + await env.close() - assert result.stdout == "done" assert env.is_initialized is False - sandbox.destroy.assert_awaited_once() @pytest.mark.asyncio -async def test_cancelled_only_close_waiter_observes_cleanup_failure( - create_patch, sandbox -): - """A background cleanup failure is retrieved after its waiter is cancelled.""" - destroy_started = asyncio.Event() - allow_destroy = asyncio.Event() - loop_errors = [] - - async def _destroy(): - destroy_started.set() - await allow_destroy.wait() - raise RuntimeError("delete failed") - - sandbox.destroy.side_effect = _destroy - env = OpenSandboxEnvironment() - await env.initialize() - loop = asyncio.get_running_loop() - old_exception_handler = loop.get_exception_handler() - loop.set_exception_handler(lambda _loop, context: loop_errors.append(context)) - try: - close_waiter = asyncio.create_task(env.close()) - await destroy_started.wait() - close_waiter.cancel() - - with pytest.raises(asyncio.CancelledError): - await close_waiter - - allow_destroy.set() - deadline = monotonic() + 5.0 - while env._close_task is not None: - assert monotonic() < deadline, "close task did not finish" - await asyncio.sleep(0.001) - gc.collect() - await asyncio.sleep(0) - - retry_sandbox = _make_sandbox(sandbox_id="sandbox-1") - with mock.patch( - "opensandbox.Sandbox.connect", - new=mock.AsyncMock(return_value=retry_sandbox), - ): - await env.close() - - assert loop_errors == [] - retry_sandbox.destroy.assert_awaited_once() - finally: - loop.set_exception_handler(old_exception_handler) - - -@pytest.mark.asyncio -async def test_close_retries_failed_owned_cleanup(create_patch, sandbox): - """A second close reconnects to retry a failed remote destroy.""" - sandbox.destroy.side_effect = RuntimeError("delete failed") +async def test_close_can_retry_failed_cleanup(create_patch, sandbox): + sandbox.destroy.side_effect = [RuntimeError("delete failed"), None] env = OpenSandboxEnvironment() await env.initialize() with pytest.raises(RuntimeError, match="delete failed"): await env.close() + await env.close() - retry_sandbox = _make_sandbox(sandbox_id="sandbox-1") - with mock.patch( - "opensandbox.Sandbox.connect", - new=mock.AsyncMock(return_value=retry_sandbox), - ) as connect: - await env.close() - - connect.assert_awaited_once() - assert connect.await_args.args == ("sandbox-1",) - assert connect.await_args.kwargs["skip_health_check"] is True - retry_sandbox.destroy.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_initialize_failure_destroys_owned_sandbox(create_patch, sandbox): - """A working-directory failure does not leak a newly created sandbox.""" - sandbox.files.create_directories.side_effect = RuntimeError("setup failed") - env = OpenSandboxEnvironment() - - with pytest.raises(RuntimeError, match="setup failed"): - await env.initialize() - - sandbox.destroy.assert_awaited_once() + assert sandbox.destroy.await_count == 2 assert env.is_initialized is False @pytest.mark.asyncio -async def test_initialize_failure_does_not_destroy_attached_sandbox( +async def test_close_attached_sandbox_releases_only_local_resources( connect_patch, sandbox ): - """A failed attached setup closes its client without killing the sandbox.""" - sandbox.files.create_directories.side_effect = RuntimeError("setup failed") env = OpenSandboxEnvironment(sandbox_id="existing-1") + await env.initialize() - with pytest.raises(RuntimeError, match="setup failed"): - await env.initialize() + await env.close() sandbox.close.assert_awaited_once() sandbox.destroy.assert_not_awaited() -@pytest.mark.asyncio -async def test_cleanup_failure_preserves_initialize_error_and_retries( - create_patch, sandbox -): - """Cleanup errors preserve setup failure and leave remote cleanup retryable.""" - sandbox.files.create_directories.side_effect = RuntimeError("setup failed") - sandbox.destroy.side_effect = RuntimeError("cleanup failed") - env = OpenSandboxEnvironment() - - with pytest.raises(RuntimeError, match="setup failed"): - await env.initialize() - - retry_sandbox = _make_sandbox(sandbox_id="sandbox-1") - with mock.patch( - "opensandbox.Sandbox.connect", - new=mock.AsyncMock(return_value=retry_sandbox), - ) as connect: - await env.close() - - connect.assert_awaited_once() - assert connect.await_args.kwargs["skip_health_check"] is True - retry_sandbox.destroy.assert_awaited_once() - - def test_working_dir_requires_initialize(): - """The working directory is unavailable before initialization.""" env = OpenSandboxEnvironment() with pytest.raises(RuntimeError, match="initialize"): @@ -574,144 +244,51 @@ def test_working_dir_requires_initialize(): @pytest.mark.asyncio async def test_execute_requires_initialize(): - """Commands cannot run before initialization.""" - env = OpenSandboxEnvironment() - - with pytest.raises(RuntimeError, match="initialize"): - await env.execute("echo hi") - - -@pytest.mark.asyncio -async def test_read_requires_initialize(): - """Files cannot be read before initialization.""" - env = OpenSandboxEnvironment() - - with pytest.raises(RuntimeError, match="initialize"): - await env.read_file("a.txt") - - -@pytest.mark.asyncio -async def test_write_requires_initialize(): - """Files cannot be written before initialization.""" env = OpenSandboxEnvironment() with pytest.raises(RuntimeError, match="initialize"): - await env.write_file("a.txt", "data") + await env.execute("pwd") @pytest.mark.asyncio -async def test_execute_returns_separate_streams_and_result_text( - create_patch, sandbox -): - """Command output keeps stdout and stderr separate.""" +async def test_execute_maps_command_options_and_result(create_patch, sandbox): sandbox.commands.run.return_value = _execution( - stdout=("first\n", "second"), - stderr=("warning\n",), - results=("result\n",), + exit_code=7, + stdout=("out-1\n",), + stderr=("err\n",), + results=("out-2",), ) env = OpenSandboxEnvironment(env_vars={"A": "1"}) await env.initialize() - result = await env.execute("printf test") + result = await env.execute("run-me", timeout=1.25) - assert result.exit_code == 0 - assert result.stdout == "first\nsecond\nresult" - assert result.stderr == "warning" + assert result.exit_code == 7 + assert result.stdout == "out-1\nout-2" + assert result.stderr == "err" assert result.timed_out is False opts = sandbox.commands.run.await_args.kwargs["opts"] assert opts.working_directory == "/workspace" - assert opts.timeout is None + assert opts.timeout == timedelta(seconds=1.25) assert opts.envs == {"A": "1"} sandbox.renew.assert_awaited_once_with(timedelta(seconds=300)) @pytest.mark.asyncio -async def test_execute_preserves_nonzero_exit(create_patch, sandbox): - """A nonzero process exit is a normal execution result.""" - sandbox.commands.run.return_value = _execution( - exit_code=7, stderr=("failed",) - ) - env = OpenSandboxEnvironment() - await env.initialize() - - result = await env.execute("exit 7") - - assert result.exit_code == 7 - assert result.stderr == "failed" - - -@pytest.mark.asyncio -async def test_execute_renews_past_command_deadline(create_patch, sandbox): - """A finite command deadline cannot outlive the owned sandbox TTL.""" - sandbox.commands.run.return_value = _execution(stdout=("done",)) +async def test_execute_renews_past_long_command_deadline(create_patch, sandbox): + sandbox.commands.run.return_value = _execution() env = OpenSandboxEnvironment(timeout=60) await env.initialize() - await env.execute("sleep 120", timeout=120) + await env.execute("long-command", timeout=120) sandbox.renew.assert_awaited_once_with(timedelta(seconds=150)) -@pytest.mark.asyncio -async def test_execute_without_deadline_renews_while_running( - create_patch, sandbox, monkeypatch -): - """An unlimited command receives heartbeat renewals until it completes.""" - - async def _run(*_args, **_kwargs): - await asyncio.sleep(0.13) - return _execution(stdout=("done",)) - - monkeypatch.setattr( - "google.adk.integrations.opensandbox._opensandbox_environment." - "_MAX_RENEW_INTERVAL_SECONDS", - 0.05, - ) - sandbox.commands.run.side_effect = _run - env = OpenSandboxEnvironment(timeout=60) - await env.initialize() - - result = await env.execute("sleep 0.13") - - assert result.stdout == "done" - assert sandbox.renew.await_count >= 2 - sandbox.renew.assert_awaited_with(timedelta(seconds=60)) - - -@pytest.mark.asyncio -async def test_short_operation_does_not_shorten_long_command_ttl( - create_patch, sandbox -): - """Concurrent operations serialize renewals without reducing expiration.""" - command_started = asyncio.Event() - allow_command = asyncio.Event() - - async def _run(*_args, **_kwargs): - command_started.set() - await allow_command.wait() - return _execution(stdout=("done",)) - - sandbox.commands.run.side_effect = _run - sandbox.files.read_bytes.return_value = b"data" - env = OpenSandboxEnvironment(timeout=60) - await env.initialize() - - execute = asyncio.create_task(env.execute("sleep 600", timeout=600)) - await command_started.wait() - assert await env.read_file("data.bin") == b"data" - - sandbox.renew.assert_awaited_once_with(timedelta(seconds=630)) - allow_command.set() - result = await execute - - assert result.stdout == "done" - - @pytest.mark.asyncio async def test_execute_marks_server_deadline_as_timed_out( create_patch, sandbox ): - """A deadline-length exit minus one is reported as a best-effort timeout.""" sandbox.commands.run.return_value = _execution(exit_code=-1) env = OpenSandboxEnvironment() await env.initialize() @@ -723,13 +300,10 @@ async def test_execute_marks_server_deadline_as_timed_out( result = await env.execute("sleep 30", timeout=1) assert result.timed_out is True - opts = sandbox.commands.run.await_args.kwargs["opts"] - assert opts.timeout == timedelta(seconds=1) @pytest.mark.asyncio async def test_execute_does_not_mislabel_early_sigkill(create_patch, sandbox): - """An immediate exit minus one is not called a timeout before its deadline.""" sandbox.commands.run.return_value = _execution(exit_code=-1) env = OpenSandboxEnvironment() await env.initialize() @@ -744,16 +318,14 @@ async def test_execute_does_not_mislabel_early_sigkill(create_patch, sandbox): @pytest.mark.asyncio -@pytest.mark.parametrize("timeout", [0, 0.0001]) async def test_execute_uses_minimum_one_millisecond_timeout( - create_patch, sandbox, timeout + create_patch, sandbox ): - """Zero and sub-millisecond deadlines never become unlimited commands.""" - sandbox.commands.run.return_value = _execution(exit_code=-1) + sandbox.commands.run.return_value = _execution() env = OpenSandboxEnvironment() await env.initialize() - await env.execute("sleep 30", timeout=timeout) + await env.execute("true", timeout=0) opts = sandbox.commands.run.await_args.kwargs["opts"] assert opts.timeout == timedelta(milliseconds=1) @@ -761,54 +333,43 @@ async def test_execute_uses_minimum_one_millisecond_timeout( @pytest.mark.asyncio async def test_execute_rejects_negative_timeout(create_patch): - """Negative command deadlines fail before reaching the SDK.""" env = OpenSandboxEnvironment() await env.initialize() with pytest.raises(ValueError, match="non-negative"): - await env.execute("echo hi", timeout=-1) + await env.execute("true", timeout=-1) @pytest.mark.asyncio async def test_execute_rejects_missing_exit_code(create_patch, sandbox): - """An incomplete SDK execution cannot be reported as success.""" - error = SimpleNamespace(name="ProtocolError", value="stream ended") - sandbox.commands.run.return_value = _execution(exit_code=None, error=error) + sandbox.commands.run.return_value = _execution(exit_code=None) env = OpenSandboxEnvironment() await env.initialize() - with pytest.raises(RuntimeError, match="ProtocolError: stream ended"): - await env.execute("echo hi") + with pytest.raises(RuntimeError, match="without an exit code"): + await env.execute("broken") @pytest.mark.asyncio -async def test_read_file_resolves_relative_path(create_patch, sandbox): - """Relative file reads use the configured working directory.""" - sandbox.files.read_bytes.return_value = b"data" - env = OpenSandboxEnvironment(working_dir="/work") - await env.initialize() - - content = await env.read_file(Path("nested/data.bin")) - - assert content == b"data" - sandbox.files.read_bytes.assert_awaited_once_with("/work/nested/data.bin") - - -@pytest.mark.asyncio -async def test_read_file_preserves_absolute_path(create_patch, sandbox): - """Absolute remote paths pass through unchanged.""" - sandbox.files.read_bytes.return_value = b"host" +async def test_read_and_write_files(create_patch, sandbox): + sandbox.files.read_bytes.return_value = b"\x00data" env = OpenSandboxEnvironment() await env.initialize() - await env.read_file("/etc/hostname") + content = await env.read_file("nested/input.bin") + await env.write_file(Path("/tmp/output.bin"), b"\xffdata") - sandbox.files.read_bytes.assert_awaited_once_with("/etc/hostname") + assert content == b"\x00data" + sandbox.files.read_bytes.assert_awaited_once_with( + "/workspace/nested/input.bin" + ) + sandbox.files.write_file.assert_awaited_once_with( + "/tmp/output.bin", b"\xffdata", mode=644 + ) @pytest.mark.asyncio async def test_read_file_maps_not_found(create_patch, sandbox): - """OpenSandbox HTTP 404 errors become FileNotFoundError.""" sandbox.files.read_bytes.side_effect = SandboxApiException(status_code=404) env = OpenSandboxEnvironment() await env.initialize() @@ -819,57 +380,27 @@ async def test_read_file_maps_not_found(create_patch, sandbox): @pytest.mark.asyncio async def test_read_file_preserves_other_api_errors(create_patch, sandbox): - """Non-404 API failures retain their OpenSandbox error type.""" - error = SandboxApiException(status_code=403) + error = SandboxApiException(status_code=500) sandbox.files.read_bytes.side_effect = error env = OpenSandboxEnvironment() await env.initialize() - with pytest.raises(SandboxApiException) as caught: - await env.read_file("private.txt") - - assert caught.value is error - - -@pytest.mark.asyncio -async def test_write_file_resolves_path_and_uses_regular_file_mode( - create_patch, sandbox -): - """Writes create regular non-executable files below the workspace.""" - env = OpenSandboxEnvironment() - await env.initialize() - - await env.write_file("nested/out.txt", "hello") + with pytest.raises(SandboxApiException) as exc_info: + await env.read_file("unavailable.txt") - sandbox.files.write_file.assert_awaited_once_with( - "/workspace/nested/out.txt", "hello", mode=644 - ) - - -@pytest.mark.asyncio -async def test_write_file_preserves_binary_content(create_patch, sandbox): - """Binary file content reaches the SDK without text conversion.""" - env = OpenSandboxEnvironment() - await env.initialize() - content = b"\x00\xff" - - await env.write_file("/tmp/data.bin", content) - - sandbox.files.write_file.assert_awaited_once_with( - "/tmp/data.bin", content, mode=644 - ) + assert exc_info.value is error @pytest.mark.asyncio async def test_attached_operations_do_not_renew_caller_ttl( connect_patch, sandbox ): - """Using an attached sandbox does not modify its owner's expiration policy.""" - sandbox.files.read_bytes.return_value = b"data" + sandbox.commands.run.return_value = _execution() env = OpenSandboxEnvironment(sandbox_id="existing-1") await env.initialize() - await env.read_file("data.bin") + await env.execute("true") + await env.write_file("output.txt", "done") sandbox.renew.assert_not_awaited() @@ -877,32 +408,19 @@ async def test_attached_operations_do_not_renew_caller_ttl( @pytest.mark.parametrize( ("kwargs", "message"), [ - ({"image": "python:3.11", "snapshot_id": "snap"}, "mutually"), - ({"sandbox_id": "id", "image": "python:3.11"}, "attaching"), - ({"timeout": 0}, "timeout"), + ({"image": "python", "snapshot_id": "snapshot"}, "mutually"), + ({"sandbox_id": "existing", "image": "python"}, "attaching"), ({"timeout": 59}, "at least 60"), - ({"ready_timeout": 0}, "ready_timeout"), - ({"request_timeout": 0}, "request_timeout"), - ({"working_dir": "relative"}, "absolute"), + ({"ready_timeout": 0}, "positive"), ], ) def test_constructor_rejects_invalid_configuration(kwargs, message): - """Invalid lifecycle and path combinations fail during construction.""" with pytest.raises(ValueError, match=message): OpenSandboxEnvironment(**kwargs) -def test_constructor_rejects_mixed_connection_configuration(): - """Complete and individual connection settings cannot be mixed.""" - config = ConnectionConfig() - - with pytest.raises(TypeError, match="cannot be combined"): - OpenSandboxEnvironment(connection_config=config, domain="localhost:8080") - - @pytest.mark.asyncio async def test_initialize_explains_missing_optional_dependency(): - """Missing OpenSandbox installs produce an actionable extra hint.""" env = OpenSandboxEnvironment() with mock.patch.dict("sys.modules", {"opensandbox": None}):