diff --git a/constraints-3.10.txt b/constraints-3.10.txt index 25cb0463fd..36c7c0790d 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 14358a2574..4a27711841 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 d0a37eb47f..83ac5d3c61 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 5dcb92471f..f960b803cb 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 2f6b1b5e8f..b835c1dc05 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 0000000000..03c1454d2e --- /dev/null +++ b/contributing/samples/environment_and_skills/opensandbox_environment/README.md @@ -0,0 +1,75 @@ +# 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 as a self-hosted remote deployment. +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 deployment, 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") +``` 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 0000000000..4015e47d6e --- /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 0000000000..94eefcd1a6 --- /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/pyproject.toml b/pyproject.toml index 46edcbc694..9754cf781b 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 ab0ecc9980..2af6f8ffe1 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 0000000000..9eb4fe7fc6 --- /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 0000000000..38c3fca4b3 --- /dev/null +++ b/src/google/adk/integrations/opensandbox/_opensandbox_environment.py @@ -0,0 +1,338 @@ +# 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 + +from datetime import timedelta +import logging +import math +import os +from pathlib import Path +from pathlib import PurePosixPath +from time import monotonic +from typing import TYPE_CHECKING + +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 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_TIMEOUT = 300 +_DEFAULT_READY_TIMEOUT = 30 +_MIN_TIMEOUT = 60 +_COMMAND_TTL_GRACE = 30 +_SANDBOX_HOME = "/workspace" +# 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 + + +@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 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__( + self, + *, + image: str | SandboxImageSpec | None = None, + snapshot_id: str | None = None, + sandbox_id: str | None = None, + timeout: int = _DEFAULT_TIMEOUT, + ready_timeout: float = _DEFAULT_READY_TIMEOUT, + env_vars: dict[str, str] | None = None, + metadata: dict[str, str] | None = None, + connection_config: ConnectionConfig | 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: 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. + env_vars: Environment variables applied to new sandboxes and commands. + metadata: Additional metadata for a newly created sandbox. + connection_config: OpenSandbox SDK connection configuration. When + omitted, the SDK reads ``OPEN_SANDBOX_DOMAIN`` and + ``OPEN_SANDBOX_API_KEY``. + + Raises: + 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 < _MIN_TIMEOUT: + raise ValueError("timeout must be at least 60 seconds") + if ready_timeout <= 0: + raise ValueError("ready_timeout must be positive") + + self._image = image + self._snapshot_id = snapshot_id + self._requested_sandbox_id = sandbox_id + self._timeout = timedelta(seconds=timeout) + self._ready_timeout = timedelta(seconds=ready_timeout) + 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._sandbox: Sandbox | None = None + 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(_SANDBOX_HOME) + + @override + async def initialize(self) -> None: + if self._sandbox is not None: + return + + sandbox, owns_sandbox = await self._open_sandbox() + try: + await self._prepare_working_directory(sandbox) + except BaseException: + try: + 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, + ) + raise + + self._sandbox = sandbox + self._owns_sandbox = owns_sandbox + self._is_initialized = True + + @override + async def close(self) -> None: + sandbox = self._sandbox + if sandbox is None: + return + + 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( + 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 + + 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 + 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 + + sandbox = await self._get_sandbox() + resolved = self._resolve_path(path) + 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: + 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: + 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 + + if self._requested_sandbox_id is not None: + sandbox = await Sandbox.connect( + self._requested_sandbox_id, + connection_config=self._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._timeout, + ready_timeout=self._ready_timeout, + env=self._env_vars, + metadata=metadata, + connection_config=self._connection_config, + ) + return sandbox, True + + 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_sandbox: bool) -> None: + if not owns_sandbox: + 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 + + @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)] + ) + + 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), + ) + + @staticmethod + def _resolve_path(path: str | os.PathLike[str]) -> str: + pure = PurePosixPath(os.fspath(path)) + if pure.is_absolute(): + return str(pure) + return str(PurePosixPath(_SANDBOX_HOME) / pure) + + @staticmethod + def _command_timeout(timeout: float | None) -> timedelta | None: + if timeout is None: + return None + return timedelta(milliseconds=max(1, math.ceil(timeout * 1000))) + + @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 0000000000..1678600dcb --- /dev/null +++ b/tests/unittests/integrations/opensandbox/test_opensandbox_environment.py @@ -0,0 +1,428 @@ +# 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.""" + +from __future__ import annotations + +from datetime import timedelta +from pathlib import Path +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 _make_sandbox() -> mock.MagicMock: + sandbox = mock.MagicMock(name="Sandbox") + sandbox.destroy = mock.AsyncMock() + sandbox.close = mock.AsyncMock() + sandbox.renew = mock.AsyncMock() + 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: + 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): + 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): + 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 +): + config = ConnectionConfig(domain="sandbox.example:8080") + env = OpenSandboxEnvironment( + image="custom:latest", + timeout=120, + ready_timeout=12.5, + env_vars={"A": "1"}, + metadata={"team": "adk"}, + connection_config=config, + ) + + await env.initialize() + + args, kwargs = create_patch.call_args + assert args == ("custom:latest",) + 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, + } + directory = sandbox.files.create_directories.await_args.args[0][0] + 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): + env = OpenSandboxEnvironment(snapshot_id="snapshot-1") + + await env.initialize() + + assert create_patch.await_args.args == (None,) + assert create_patch.await_args.kwargs["snapshot_id"] == "snapshot-1" + + +@pytest.mark.asyncio +async def test_initialize_attaches_to_existing_sandbox(connect_patch): + config = ConnectionConfig(domain="sandbox.example:8080") + env = OpenSandboxEnvironment( + sandbox_id="existing-1", connection_config=config + ) + + await env.initialize() + + 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): + env = OpenSandboxEnvironment() + + await env.initialize() + await env.initialize() + + create_patch.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_initialize_failure_cleans_up_owned_sandbox( + create_patch, 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_closes_attached_sandbox( + connect_patch, 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_close_destroys_owned_sandbox_and_is_idempotent( + create_patch, sandbox +): + 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_absent_owned_sandbox( + create_patch, sandbox +): + sandbox.destroy.side_effect = SandboxApiException(status_code=404) + env = OpenSandboxEnvironment() + await env.initialize() + + await env.close() + + assert env.is_initialized is False + + +@pytest.mark.asyncio +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() + + assert sandbox.destroy.await_count == 2 + assert env.is_initialized is False + + +@pytest.mark.asyncio +async def test_close_attached_sandbox_releases_only_local_resources( + connect_patch, sandbox +): + env = OpenSandboxEnvironment(sandbox_id="existing-1") + await env.initialize() + + await env.close() + + sandbox.close.assert_awaited_once() + sandbox.destroy.assert_not_awaited() + + +def test_working_dir_requires_initialize(): + env = OpenSandboxEnvironment() + + with pytest.raises(RuntimeError, match="initialize"): + _ = env.working_dir + + +@pytest.mark.asyncio +async def test_execute_requires_initialize(): + env = OpenSandboxEnvironment() + + with pytest.raises(RuntimeError, match="initialize"): + await env.execute("pwd") + + +@pytest.mark.asyncio +async def test_execute_maps_command_options_and_result(create_patch, sandbox): + sandbox.commands.run.return_value = _execution( + 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("run-me", timeout=1.25) + + 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 == 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_renews_past_long_command_deadline(create_patch, sandbox): + sandbox.commands.run.return_value = _execution() + env = OpenSandboxEnvironment(timeout=60) + await env.initialize() + + await env.execute("long-command", timeout=120) + + sandbox.renew.assert_awaited_once_with(timedelta(seconds=150)) + + +@pytest.mark.asyncio +async def test_execute_marks_server_deadline_as_timed_out( + create_patch, sandbox +): + 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 + + +@pytest.mark.asyncio +async def test_execute_does_not_mislabel_early_sigkill(create_patch, sandbox): + 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 +async def test_execute_uses_minimum_one_millisecond_timeout( + create_patch, sandbox +): + sandbox.commands.run.return_value = _execution() + env = OpenSandboxEnvironment() + await env.initialize() + + await env.execute("true", timeout=0) + + 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): + env = OpenSandboxEnvironment() + await env.initialize() + + with pytest.raises(ValueError, match="non-negative"): + await env.execute("true", timeout=-1) + + +@pytest.mark.asyncio +async def test_execute_rejects_missing_exit_code(create_patch, sandbox): + sandbox.commands.run.return_value = _execution(exit_code=None) + env = OpenSandboxEnvironment() + await env.initialize() + + with pytest.raises(RuntimeError, match="without an exit code"): + await env.execute("broken") + + +@pytest.mark.asyncio +async def test_read_and_write_files(create_patch, sandbox): + sandbox.files.read_bytes.return_value = b"\x00data" + env = OpenSandboxEnvironment() + await env.initialize() + + content = await env.read_file("nested/input.bin") + await env.write_file(Path("/tmp/output.bin"), b"\xffdata") + + 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): + 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): + error = SandboxApiException(status_code=500) + sandbox.files.read_bytes.side_effect = error + env = OpenSandboxEnvironment() + await env.initialize() + + with pytest.raises(SandboxApiException) as exc_info: + await env.read_file("unavailable.txt") + + assert exc_info.value is error + + +@pytest.mark.asyncio +async def test_attached_operations_do_not_renew_caller_ttl( + connect_patch, sandbox +): + sandbox.commands.run.return_value = _execution() + env = OpenSandboxEnvironment(sandbox_id="existing-1") + await env.initialize() + + await env.execute("true") + await env.write_file("output.txt", "done") + + sandbox.renew.assert_not_awaited() + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"image": "python", "snapshot_id": "snapshot"}, "mutually"), + ({"sandbox_id": "existing", "image": "python"}, "attaching"), + ({"timeout": 59}, "at least 60"), + ({"ready_timeout": 0}, "positive"), + ], +) +def test_constructor_rejects_invalid_configuration(kwargs, message): + with pytest.raises(ValueError, match=message): + OpenSandboxEnvironment(**kwargs) + + +@pytest.mark.asyncio +async def test_initialize_explains_missing_optional_dependency(): + env = OpenSandboxEnvironment() + + with mock.patch.dict("sys.modules", {"opensandbox": None}): + with pytest.raises(ImportError, match=r"google-adk\[opensandbox\]"): + await env.initialize()