From 9e8ee09632e3f968e2286b042cdd7eb5d1226598 Mon Sep 17 00:00:00 2001 From: maplexu Date: Thu, 13 Aug 2026 13:06:49 -0400 Subject: [PATCH] feat(sandbox): add OsEnvValue for reading an environment value from the process environment Environment can only hold literals today: StrEnvValue is the only concrete EnvValue, so anything a sandbox needs in its environment has to be written into the manifest itself. That is awkward wherever the manifest is persisted or handed across a boundary, because the value travels with it. OsEnvValue names a variable instead. The backend clients pick it up where they already call manifest.environment.resolve(), at create, exec and resume, so the value is read on the machine actually building the sandbox. An unset variable resolves to the empty string, matching shell expansion rather than introducing a new failure mode into sandbox creation. --- src/agents/sandbox/manifest.py | 14 +++++ tests/sandbox/test_compatibility_guards.py | 3 +- tests/sandbox/test_manifest.py | 65 +++++++++++++++++++++- 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/agents/sandbox/manifest.py b/src/agents/sandbox/manifest.py index 97a3677442..4ea0d4652f 100644 --- a/src/agents/sandbox/manifest.py +++ b/src/agents/sandbox/manifest.py @@ -1,5 +1,6 @@ import abc import inspect +import os from collections.abc import Iterator, Mapping from dataclasses import dataclass from pathlib import Path, PurePath, PurePosixPath @@ -147,6 +148,19 @@ async def resolve(self) -> str: return self.value +class OsEnvValue(EnvValue): + """Reads the value from the environment of the process creating the sandbox. + + An unset variable resolves to the empty string. + """ + + type: Literal["os_env"] = "os_env" + name: str + + async def resolve(self) -> str: + return os.environ.get(self.name, "") + + def _serialize_env_value_with_type(value: EnvValue, serialized: object) -> dict[str, Any]: if EnvValue._subclass_registry.get(value.type) is not type(value): raise PydanticSerializationError( diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index 0e3a2bcea4..3c4ca3b2e8 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -40,7 +40,7 @@ RcloneMountPattern, S3FilesMountPattern, ) -from agents.sandbox.manifest import EnvValue, StrEnvValue +from agents.sandbox.manifest import EnvValue, OsEnvValue, StrEnvValue from agents.sandbox.session.sandbox_client import BaseSandboxClientOptions from agents.sandbox.session.sandbox_session_state import SandboxSessionState from agents.sandbox.snapshot import LocalSnapshot, NoopSnapshot, RemoteSnapshot, SnapshotBase @@ -894,6 +894,7 @@ def test_core_discriminator_type_strings_are_stable() -> None: InContainerMountStrategy: "in_container", DockerVolumeMountStrategy: "docker_volume", StrEnvValue: "str", + OsEnvValue: "os_env", } for cls, expected_type in expected_types.items(): diff --git a/tests/sandbox/test_manifest.py b/tests/sandbox/test_manifest.py index 0f5eef6bc1..f8fb0ddb31 100644 --- a/tests/sandbox/test_manifest.py +++ b/tests/sandbox/test_manifest.py @@ -16,7 +16,14 @@ MountpointMountPattern, ) from agents.sandbox.errors import InvalidManifestPathError -from agents.sandbox.manifest import EnvEntry, Environment, EnvValue, Manifest, StrEnvValue +from agents.sandbox.manifest import ( + EnvEntry, + Environment, + EnvValue, + Manifest, + OsEnvValue, + StrEnvValue, +) from agents.sandbox.manifest_render import _truncate_manifest_description @@ -340,6 +347,62 @@ def test_manifest_round_trips_str_env_value() -> None: } +@pytest.mark.asyncio +async def test_os_env_value_resolves_from_the_process_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SANDBOX_TEST_OS_ENV_VALUE", "from-host") + + assert await OsEnvValue(name="SANDBOX_TEST_OS_ENV_VALUE").resolve() == "from-host" + + +@pytest.mark.asyncio +async def test_os_env_value_resolves_unset_and_empty_variables_to_an_empty_string( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("SANDBOX_TEST_OS_ENV_VALUE", raising=False) + + assert await OsEnvValue(name="SANDBOX_TEST_OS_ENV_VALUE").resolve() == "" + + monkeypatch.setenv("SANDBOX_TEST_OS_ENV_VALUE", "") + + assert await OsEnvValue(name="SANDBOX_TEST_OS_ENV_VALUE").resolve() == "" + + +def test_manifest_round_trips_os_env_value() -> None: + manifest = Manifest( + environment=Environment(value={"TOKEN": OsEnvValue(name="SANDBOX_TEST_OS_ENV_VALUE")}) + ) + + payload = manifest.model_dump(mode="json") + restored = Manifest.model_validate(payload) + + assert payload["environment"] == { + "value": {"TOKEN": {"type": "os_env", "name": "SANDBOX_TEST_OS_ENV_VALUE"}} + } + assert type(restored.environment.value["TOKEN"]) is OsEnvValue + + +@pytest.mark.asyncio +async def test_environment_resolves_os_env_values_alongside_literals( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SANDBOX_TEST_OS_ENV_VALUE", "from-host") + environment = Environment( + value={ + "PLAIN": "literal", + "DIRECT": OsEnvValue(name="SANDBOX_TEST_OS_ENV_VALUE"), + "ENTRY": EnvEntry(value=OsEnvValue(name="SANDBOX_TEST_OS_ENV_VALUE")), + } + ) + + assert await environment.resolve() == { + "PLAIN": "literal", + "DIRECT": "from-host", + "ENTRY": "from-host", + } + + def test_manifest_reads_legacy_discriminator_free_str_env_values() -> None: payload = { "environment": {