Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/references/sandbox-runtime-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Resolve the session source in this order: injected live session, resumable sandb
- Validate local sources at use time, not only when parsing the manifest. Defend against symlinked sources, parent-directory swaps, platform path aliases, and archive members that change meaning between validation and extraction.
- Archive extraction must reject traversal, unsafe links, and unsupported member types before writing, and enforce entry, byte, and expansion limits without materializing an unbounded member list.
- Extra path grants are runtime access, not durable workspace content. Snapshots and `persist_workspace()` include the workspace root, not arbitrary granted paths.
- Credentials for mounts or providers must remain in the owning adapter and must not appear in generated shell commands, model-visible errors, logs, or serialized sandbox state.
- Credentials for mounts or providers must remain in the owning adapter and must not appear in generated shell commands, model-visible errors, logs, or serialized sandbox state. Reject explicit credentials before side effects when a mount strategy runs helpers inside the model-controlled sandbox. Credential-bearing mounts must use an external or provider-controlled strategy unless trusted application code explicitly acknowledges exposure for a specific path; manifest dictionaries and persisted state cannot carry that acknowledgement.

## Provider and Error Boundary

Expand Down
2 changes: 1 addition & 1 deletion examples/sandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Most examples call a model through `Runner`, so set `OPENAI_API_KEY` in the repo
| [`sandbox_agents_as_tools.py`](./sandbox_agents_as_tools.py) | `uv run python examples/sandbox/sandbox_agents_as_tools.py` | Exposes sandbox agents as tools for another agent. |
| [`sandbox_agent_with_remote_snapshot.py`](./sandbox_agent_with_remote_snapshot.py) | `uv run python examples/sandbox/sandbox_agent_with_remote_snapshot.py` | Starts from a remote sandbox snapshot. |
| [`memory.py`](./memory.py) | `uv run python examples/sandbox/memory.py` | Runs one sandbox agent twice across a snapshot resume so it can read and write its own memory. |
| [`memory_s3.py`](./memory_s3.py) | `source ~/.s3.env && uv run python examples/sandbox/memory_s3.py` | Runs sandbox memory across two fresh Docker sandboxes with S3-backed memory storage. |
| [`memory_s3.py`](./memory_s3.py) | `source ~/.s3.env && uv run python examples/sandbox/memory_s3.py` | Runs sandbox memory across two fresh Docker sandboxes with S3-backed memory storage attached by an external Docker volume driver. |
| [`memory_multi_agent_multiturn.py`](./memory_multi_agent_multiturn.py) | `uv run python examples/sandbox/memory_multi_agent_multiturn.py` | Shows separate memory layouts for two agents sharing one sandbox workspace. |
| [`unix_local_pty.py`](./unix_local_pty.py) | `uv run python examples/sandbox/unix_local_pty.py` | Exercises an interactive pseudo-terminal in a Unix-local sandbox. |
| [`unix_local_runner.py`](./unix_local_runner.py) | `uv run python examples/sandbox/unix_local_runner.py` | Runs against the Unix-local sandbox backend directly. |
Expand Down
29 changes: 0 additions & 29 deletions examples/sandbox/docker/mounts/azure_mount_read_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@
from agents.sandbox.entries import (
AzureBlobMount,
DockerVolumeMountStrategy,
FuseMountPattern,
InContainerMountStrategy,
RcloneMountPattern,
)
from examples.sandbox.docker.mounts.mount_smoke import (
MountSmokeCase,
Expand Down Expand Up @@ -43,32 +40,6 @@ def _mount_cases() -> list[MountSmokeCase]:
read_only=False,
),
),
MountSmokeCase(
name="in_container/rclone",
mount_dir="azure-in-container-rclone",
mount=AzureBlobMount(
account=account,
container=container,
endpoint=endpoint,
identity_client_id=identity_client_id,
account_key=account_key,
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
read_only=False,
),
),
MountSmokeCase(
name="in_container/fuse",
mount_dir="azure-in-container-fuse",
mount=AzureBlobMount(
account=account,
container=container,
endpoint=endpoint,
identity_client_id=identity_client_id,
account_key=account_key,
mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()),
read_only=False,
),
),
]


Expand Down
37 changes: 0 additions & 37 deletions examples/sandbox/docker/mounts/gcs_mount_read_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@
from agents.sandbox.entries import (
DockerVolumeMountStrategy,
GCSMount,
InContainerMountStrategy,
MountpointMountPattern,
RcloneMountPattern,
)
from examples.sandbox.docker.mounts.mount_smoke import (
MountSmokeCase,
Expand Down Expand Up @@ -51,40 +48,6 @@ def _mount_cases() -> list[MountSmokeCase]:
read_only=False,
),
),
MountSmokeCase(
name="in_container/rclone",
mount_dir="gcs-in-container-rclone",
mount=GCSMount(
bucket=bucket,
access_id=access_id,
secret_access_key=secret_access_key,
prefix=prefix,
region=region,
endpoint_url=endpoint_url,
service_account_file=service_account_file,
service_account_credentials=service_account_credentials,
access_token=access_token,
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
read_only=False,
),
),
MountSmokeCase(
name="in_container/mountpoint",
mount_dir="gcs-in-container-mountpoint",
mount=GCSMount(
bucket=bucket,
access_id=access_id,
secret_access_key=secret_access_key,
prefix=prefix,
region=region,
endpoint_url=endpoint_url,
service_account_file=service_account_file,
service_account_credentials=service_account_credentials,
access_token=access_token,
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
read_only=False,
),
),
]


Expand Down
33 changes: 0 additions & 33 deletions examples/sandbox/docker/mounts/s3_mount_read_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@

from agents.sandbox.entries import (
DockerVolumeMountStrategy,
InContainerMountStrategy,
MountpointMountPattern,
RcloneMountPattern,
S3Mount,
)
from examples.sandbox.docker.mounts.mount_smoke import (
Expand Down Expand Up @@ -40,36 +37,6 @@ def _mount_cases() -> list[MountSmokeCase]:
read_only=False,
),
),
MountSmokeCase(
name="in_container/rclone",
mount_dir="s3-in-container-rclone",
mount=S3Mount(
bucket=bucket,
access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
session_token=os.getenv("AWS_SESSION_TOKEN"),
prefix=os.getenv("S3_MOUNT_PREFIX"),
region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"),
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
read_only=False,
),
),
MountSmokeCase(
name="in_container/mountpoint",
mount_dir="s3-in-container-mountpoint",
mount=S3Mount(
bucket=bucket,
access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
session_token=os.getenv("AWS_SESSION_TOKEN"),
prefix=os.getenv("S3_MOUNT_PREFIX"),
region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"),
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
read_only=False,
),
),
]


Expand Down
6 changes: 5 additions & 1 deletion examples/sandbox/extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ They intentionally keep the flow simple:
2. Create a `SandboxAgent` that inspects that workspace through one shell tool.
3. Run the agent against E2B, Modal, Daytona, Cloudflare, Runloop, Blaxel, or Vercel.

For cloud storage, prefer provider-native mounts or mounts established outside the sandbox. Hosted strategies that execute `rclone`, `s3fs`, `gcsfuse`, or similar helpers inside the model-controlled sandbox accept credentialless configurations, but explicit mount credentials are rejected by default. If a backend has no external or provider-native alternative, credential-bearing mounts are unsupported.

All of these examples require `OPENAI_API_KEY`, because they call the model through the normal `Runner` path. Each cloud backend also needs its own provider credentials.

## E2B
Expand Down Expand Up @@ -228,6 +230,8 @@ export DAYTONA_API_KEY=...
uv run python examples/sandbox/extensions/daytona/daytona_runner.py --stream
```

The optional Daytona cloud-bucket flags demonstrate a credentialless in-sandbox mount. Do not pass AWS credential fields to `DaytonaCloudBucketMountStrategy`; use a backend with an external or provider-native mount mechanism when explicit credentials are required.

## Runloop

### Setup
Expand Down Expand Up @@ -350,4 +354,4 @@ The runner also includes standalone demos for individual features. Pass
- `pty` -- agent-driven interactive Python session via PTY
- `drive` -- [Blaxel Drive mount](https://docs.blaxel.ai/Agent-drive/Overview) (persistent storage, requires `--drive-name`)

Blaxel sandboxes support cloud bucket mounts (S3, R2, GCS) through `BlaxelCloudBucketMountStrategy` and persistent drive mounts through `BlaxelDriveMountStrategy`. See the [Blaxel Drive docs](https://docs.blaxel.ai/Agent-drive/Overview) for details.
Blaxel sandboxes support credentialless cloud bucket mounts (S3, R2, GCS) through `BlaxelCloudBucketMountStrategy`. Explicit credentials are rejected by default because its FUSE helpers run inside the sandbox. Prefer persistent drive mounts through the provider-controlled `BlaxelDriveMountStrategy`; see the [Blaxel Drive docs](https://docs.blaxel.ai/Agent-drive/Overview) for details.
3 changes: 0 additions & 3 deletions examples/sandbox/extensions/daytona/daytona_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,6 @@ def _build_manifest(

manifest.entries["cloud-bucket"] = S3Mount(
bucket=cloud_bucket_name,
access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
session_token=os.environ.get("AWS_SESSION_TOKEN"),
endpoint_url=cloud_bucket_endpoint_url,
prefix=cloud_bucket_key_prefix,
mount_path=Path(cloud_bucket_mount_path) if cloud_bucket_mount_path is not None else None,
Expand Down
4 changes: 2 additions & 2 deletions examples/sandbox/memory_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
SandboxRunConfig,
)
from agents.sandbox.capabilities import Filesystem, Memory, Shell
from agents.sandbox.entries import File, InContainerMountStrategy, RcloneMountPattern, S3Mount
from agents.sandbox.entries import DockerVolumeMountStrategy, File, S3Mount
from agents.sandbox.sandboxes.docker import (
DockerSandboxClient,
DockerSandboxClientOptions,
Expand Down Expand Up @@ -146,7 +146,7 @@ def _build_manifest(
prefix=config.prefix,
region=config.region,
endpoint_url=config.endpoint_url,
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
mount_strategy=DockerVolumeMountStrategy(driver="rclone"),
read_only=False,
),
}
Expand Down
77 changes: 60 additions & 17 deletions src/agents/extensions/sandbox/blaxel/mounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@

from __future__ import annotations

import io
import logging
import shlex
import uuid
import warnings
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from typing import Any, ClassVar, Literal

from .... import _debug
from ....logger import log_tool_action_warning
Expand Down Expand Up @@ -69,6 +70,7 @@ class BlaxelCloudBucketMountStrategy(MountStrategyBase):
``fusermount`` or ``umount``.
"""

credential_boundary: ClassVar[Literal["inside_sandbox"]] = "inside_sandbox"
type: Literal["blaxel_cloud_bucket"] = "blaxel_cloud_bucket"

def validate_mount(self, mount: Mount) -> None:
Expand Down Expand Up @@ -133,6 +135,7 @@ def build_docker_volume_driver_config(
# ---------------------------------------------------------------------------

_INSTALL_RETRIES = 3
_CREDENTIAL_DIRECTORY = Path(".sandbox-blaxel-mount-credentials")


def _assert_blaxel_session(session: BaseSandboxSession) -> None:
Expand Down Expand Up @@ -281,24 +284,60 @@ async def _ensure_tool(session: BaseSandboxSession, tool: str) -> None:
await _install_tool(session, tool)


async def _write_mount_credential_file(
session: BaseSandboxSession,
*,
name: str,
content: str,
) -> str:
session_id = getattr(session.state, "session_id", None)
if not isinstance(session_id, uuid.UUID):
raise MountConfigError(message="Blaxel mount session is missing session_id")

credential_dir = _CREDENTIAL_DIRECTORY / session_id.hex
session.register_persist_workspace_skip_path(_CREDENTIAL_DIRECTORY)
await session.mkdir(credential_dir, parents=True)
Comment on lines +297 to +299

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve credential exclusions across sandbox resume

If the process terminates after this workspace credential file is written but before its removal is confirmed, the surviving Blaxel sandbox can later be reconnected with _skip_start=True; the exclusion registered here exists only in the old session object’s _runtime_persist_workspace_skip_relpaths and is not serialized or reconstructed. A subsequent stop() in the new process can therefore archive .sandbox-blaxel-mount-credentials into the workspace snapshot. The new rclone config-file exclusion has the same in-memory-only lifecycle, so these secret-path exclusions need to be restored on reconnect or the files must remain outside the persisted workspace.

AGENTS.md reference: AGENTS.md:L137-L137

Useful? React with 👍 / 👎.

credential_path = credential_dir / name
normalized_path = session.normalize_path(credential_path)
credential_path_str = sandbox_path_str(normalized_path)
try:
await session.write(credential_path, io.BytesIO(content.encode("utf-8")))
await session._exec_checked_nonzero("chmod", "0600", credential_path_str)
except BaseException:
await _remove_mount_credential_file(session, credential_path_str)
raise
return credential_path_str


async def _remove_mount_credential_file(
session: BaseSandboxSession,
credential_path: str,
) -> None:
await session._exec_checked_nonzero("rm", "-f", credential_path)


def _redact_sensitive_values(text: str, values: list[str | None]) -> str:
redacted = text
for value in sorted({value for value in values if value}, key=len, reverse=True):
redacted = redacted.replace(value, "REDACTED")
return redacted


async def _mount_s3(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None:
"""Mount an S3 or R2 bucket using s3fs-fuse."""
await _ensure_tool(session, "s3fs")

# Write credentials to a temp file.
cred_path = f"/tmp/s3fs-passwd-{uuid.uuid4().hex[:8]}"
cred_path = ""
if config.access_key_id and config.secret_access_key:
cred_content = f"{config.access_key_id}:{config.secret_access_key}"
if config.session_token:
cred_content += f":{config.session_token}"
await session.exec(
"sh",
"-c",
f"printf %s {shlex.quote(cred_content)} > {cred_path} && chmod 600 {cred_path}",
cred_path = await _write_mount_credential_file(
session,
name=f"s3fs-passwd-{uuid.uuid4().hex[:8]}",
content=cred_content,
)
else:
cred_path = ""

# Build the s3fs command.
bucket = config.bucket
if config.prefix:
Expand Down Expand Up @@ -331,14 +370,18 @@ async def _mount_s3(session: BaseSandboxSession, config: BlaxelCloudBucketMountC
result = await _exec(session, cmd, timeout=60)
if result.exit_code != 0:
stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
stderr = _redact_sensitive_values(
stderr,
[config.access_key_id, config.secret_access_key, config.session_token],
)
raise MountConfigError(
message="s3fs mount failed",
context={"cmd": cmd, "exit_code": result.exit_code, "stderr": stderr},
)
finally:
# Clean up credentials file.
if cred_path:
await _exec(session, f"rm -f {cred_path}")
await _remove_mount_credential_file(session, cred_path)


async def _mount_gcs(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None:
Expand All @@ -351,12 +394,10 @@ async def _mount_gcs(session: BaseSandboxSession, config: BlaxelCloudBucketMount
# Write service account key if provided.
key_path = ""
if config.service_account_key:
key_path = f"/tmp/gcs-creds-{uuid.uuid4().hex[:8]}.json"
await session.exec(
"sh",
"-c",
f"printf %s {shlex.quote(config.service_account_key)} "
f"> {key_path} && chmod 600 {key_path}",
key_path = await _write_mount_credential_file(
session,
name=f"gcs-creds-{uuid.uuid4().hex[:8]}.json",
content=config.service_account_key,
)

opts: list[str] = []
Expand All @@ -379,13 +420,14 @@ async def _mount_gcs(session: BaseSandboxSession, config: BlaxelCloudBucketMount
result = await _exec(session, cmd, timeout=60)
if result.exit_code != 0:
stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
stderr = _redact_sensitive_values(stderr, [config.service_account_key])
raise MountConfigError(
message="gcsfuse mount failed",
context={"cmd": cmd, "exit_code": result.exit_code, "stderr": stderr},
)
finally:
if key_path:
await _exec(session, f"rm -f {key_path}")
await _remove_mount_credential_file(session, key_path)


async def _mount_bucket(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None:
Expand Down Expand Up @@ -541,6 +583,7 @@ class BlaxelDriveMountStrategy(MountStrategyBase):
)
"""

credential_boundary: ClassVar[Literal["outside_sandbox"]] = "outside_sandbox"
type: Literal["blaxel_drive"] = "blaxel_drive"

def validate_mount(self, mount: Mount) -> None:
Expand Down
3 changes: 2 additions & 1 deletion src/agents/extensions/sandbox/blaxel/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,7 @@ async def create(
) -> SandboxSession:
if manifest is None:
manifest = Manifest(root=DEFAULT_BLAXEL_WORKSPACE_ROOT)
self._validate_manifest_mount_credentials(manifest)

timeouts_in = options.timeouts
if isinstance(timeouts_in, BlaxelTimeouts):
Expand Down Expand Up @@ -1145,7 +1146,7 @@ async def resume(
"""
if not isinstance(state, BlaxelSandboxSessionState):
raise TypeError("BlaxelSandboxClient.resume expects a BlaxelSandboxSessionState")
state.assert_path_grants_rebound()
state.assert_trusted_manifest_rebound()

SandboxInstance = _import_blaxel_sdk()
blaxel_sandbox = None
Expand Down
Loading
Loading