spud 8f08df9b48
fix(sandbox): fence ambiguous AIO session creation ownership (#5711)
* fix(sandbox): quarantine ambiguous shell session creation

* fix(sandbox): quarantine ambiguous bash session creation

* fix(sandbox): recycle sandboxes with ambiguous session creates

* fix(sandbox): keep quarantined release teardown non-raising

* docs(sandbox): keep guidance within instruction budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-22 20:48:22 +08:00

1266 lines
58 KiB
Python

import base64
import errno
import logging
import math
import threading
import uuid
from dataclasses import dataclass, field
import httpx
from agent_sandbox import Sandbox as AioSandboxClient
from agent_sandbox.core.api_error import ApiError
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.sandbox.remote_list_dir import parse_remote_list_dir_output, remote_list_dir_command
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line
from .backend import sandbox_http_trust_env
logger = logging.getLogger(__name__)
_MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100 MB
_ERROR_OBSERVATION_SIGNATURE = "'ErrorObservation' object has no attribute 'exit_code'"
# Env-bearing commands require the bash.exec API (POST /v1/bash/exec), which the
# all-in-one-sandbox image only ships since 1.9.x. Older images (including any
# ``latest`` tag frozen on the 1.0.0.x line) answer 404 for the whole /v1/bash/*
# namespace. That raw 404 is useless to the model (it just retries), so the
# sandbox fails fast with this operator-facing message instead (#3921).
_BASH_EXEC_UNSUPPORTED_ERROR = (
"Error: this sandbox image does not support per-command environment injection "
"(POST /v1/bash/exec returned 404), which is required to run skills that declare "
"required-secrets. This is a deployment issue that retrying cannot fix: upgrade the "
"sandbox image to all-in-one-sandbox >= 1.9.3 (set `sandbox.image` in config.yaml, "
"e.g. pin the tag `1.11.0`) and recreate the sandbox container, then try again."
)
@dataclass
class _ScopedShellSession:
"""One server-side shell session serialized within an agent execution."""
lock: threading.Lock = field(default_factory=threading.Lock)
session_id: str | None = None
@dataclass
class _SessionCreationState:
"""Process-local ownership state for one server-side session plane."""
pending: set[str] = field(default_factory=set)
ambiguous: set[str] = field(default_factory=set)
class AioSandbox(Sandbox):
"""Sandbox implementation using the agent-infra/sandbox Docker container.
This sandbox connects to a running AIO sandbox container via HTTP API.
Lead/direct calls retain the legacy serialized shell. Delegated executions
receive separate server-side sessions, with commands serialized only inside
the same execution scope, so parallel subagents cannot corrupt one another's
shell state (see #1433 and #5128).
"""
#: The legacy exec path reuses one persistent shell session across calls,
#: so shell state (exports, cwd, functions) carries from one command into
#: the next — recorded bash evidence cannot prove a clean environment.
persistent_shell_sessions = True
def __init__(
self,
id: str,
base_url: str,
home_dir: str | None = None,
request_headers: dict[str, str] | None = None,
default_command_timeout: float | None = None,
):
"""Initialize the AIO sandbox.
Args:
id: Unique identifier for this sandbox instance.
base_url: URL of the sandbox API (e.g., http://localhost:8080).
home_dir: Home directory inside the sandbox. If None, will be fetched from the sandbox.
request_headers: Trusted control-plane headers required by a local
relay. These are never injected into sandbox commands.
default_command_timeout: Provider-configured command deadline used
when a command does not provide an explicit timeout.
"""
super().__init__(id)
if default_command_timeout is None:
self._default_command_timeout = self._DEFAULT_HARD_TIMEOUT
else:
try:
resolved_default_timeout = float(default_command_timeout)
except (TypeError, ValueError) as exc:
raise ValueError("default_command_timeout must be positive") from exc
if not math.isfinite(resolved_default_timeout) or resolved_default_timeout <= 0:
raise ValueError("default_command_timeout must be positive")
self._default_command_timeout = resolved_default_timeout
self._base_url = base_url
client_kwargs = {
"base_url": base_url,
"timeout": 600,
}
if request_headers:
client_kwargs["headers"] = dict(request_headers)
if sandbox_http_trust_env(base_url):
self._client = AioSandboxClient(**client_kwargs)
else:
direct_client = httpx.Client(timeout=600, follow_redirects=True, trust_env=False)
self._client = AioSandboxClient(**client_kwargs, httpx_client=direct_client)
self._home_dir = home_dir
self._lock = threading.Lock()
self._scope_registry_lock = threading.Lock()
self._scoped_shell_sessions: dict[str, _ScopedShellSession] = {}
self._recovery_session_id: str | None = None
self._default_shell_corrupted = False
self._closed = False
# Set to True after bash.exec answers 404 (image predates /v1/bash/*),
# so later env-bearing calls fail fast instead of re-hitting HTTP (#3921).
self._bash_exec_unsupported = False
self._session_creation_state_lock = threading.Lock()
self._shell_session_creation_state = _SessionCreationState()
self._bash_session_creation_state = _SessionCreationState()
@property
def base_url(self) -> str:
return self._base_url
def close(self) -> None:
"""Best-effort close of the host-side HTTP client owned by this sandbox.
The agent_sandbox SDK is Fern-generated and exposes no ``close()`` /
``__exit__``, so we reach the socket-owning ``httpx.Client`` explicitly
through its attribute chain::
Sandbox._client_wrapper -> SyncClientWrapper
.httpx_client -> Fern HttpClient (a wrapper, NOT httpx.Client)
.httpx_client -> httpx.Client <- the real socket owner
Closing it releases pooled sockets so long-running provider lifecycles
do not accumulate unreclaimed host-side resources (#2872).
Resolution is most-specific-first with graceful degradation: if a future
SDK adds a top-level ``Sandbox.close()`` it is picked up automatically
without changing this code. Idempotent, thread-safe, and non-fatal:
failures during teardown are logged and swallowed so provider/backend
cleanup is never blocked.
"""
# Close admission for scoped commands before draining their sessions.
# A scoped call that already registered itself remains in this snapshot
# and is joined through its per-scope lock below; later calls fail
# without creating an orphaned registry entry.
with self._scope_registry_lock:
if self._closed:
return
self._closed = True
scoped_sessions = list(self._scoped_shell_sessions.items())
self._scoped_shell_sessions.clear()
for scope_id, scoped in scoped_sessions:
with scoped.lock:
if scoped.session_id is not None and self._client is not None:
self._cleanup_session_best_effort(
self._client,
scoped.session_id,
context=f"execution scope {scope_id}",
request_options=self._bounded_cleanup_request_options(),
)
scoped.session_id = None
with self._lock:
if self._recovery_session_id is not None and self._client is not None:
self._cleanup_session_best_effort(
self._client,
self._recovery_session_id,
context="default recovery session",
request_options=self._bounded_cleanup_request_options(),
)
self._recovery_session_id = None
client = self._client
if client is not None:
shell_tombstones, bash_tombstones = self._ambiguous_session_creation_snapshot()
for session_id in shell_tombstones:
self._cleanup_session_best_effort(
client,
session_id,
context="ambiguous shell session creation during close",
request_options=self._bounded_cleanup_request_options(),
)
for session_id in bash_tombstones:
self._cleanup_bash_session_best_effort(
client,
session_id,
)
# Drop the reference under the lock for use-after-close safety: any
# later command on this instance fails loudly instead of reusing a
# half-closed client.
self._client = None
if client is None:
return
# Walk from the real httpx.Client up to the top-level client, picking the
# first object that actually exposes close().
wrapper = getattr(client, "_client_wrapper", None)
fern_http = getattr(wrapper, "httpx_client", None)
real_httpx = getattr(fern_http, "httpx_client", None)
target = next(
(c for c in (real_httpx, fern_http, client) if c is not None and hasattr(c, "close")),
None,
)
if target is None:
logger.debug("AioSandbox %s: no closable client found, nothing to release", self.id)
return
try:
target.close()
except Exception as e:
logger.warning(f"Error closing AioSandbox client for {self.id}: {e}")
@staticmethod
def _cleanup_session_best_effort(
client,
session_id: str,
*,
context: str,
request_options: dict[str, int] | None = None,
) -> None:
try:
client.shell.cleanup_session(
session_id,
**({"request_options": request_options} if request_options is not None else {}),
)
except Exception as cleanup_error:
logger.warning(
"Failed to release shell session %s (%s): %s",
session_id,
context,
cleanup_error,
)
@staticmethod
def _format_shell_result(result) -> tuple[str, int | None, str | None]:
data = result.data if result else None
output = data.output if data else ""
exit_code = getattr(data, "exit_code", None) if data else None
status = getattr(data, "status", None) if data else None
return output, exit_code, status
@staticmethod
def _is_missing_shell_session_error(error: ApiError) -> bool:
body = error.body
if error.status_code != 404 or not isinstance(body, dict):
return False
message = body.get("message")
return isinstance(message, str) and "session not found" in message.casefold()
def _cleanup_bash_session_best_effort(self, client, session_id: str) -> None:
try:
client.bash.close_session(
session_id,
request_options=self._bounded_cleanup_request_options(),
)
except Exception as cleanup_error:
logger.warning(
"Failed to release transient bash session %s: %s",
session_id,
cleanup_error,
)
@staticmethod
def _is_definite_session_creation_failure(error: Exception) -> bool:
if isinstance(error, httpx.ConnectError):
return True
if isinstance(error, ApiError):
return 400 <= error.status_code < 500
return False
def _session_creation_state(self, plane: str) -> _SessionCreationState:
if plane == "shell":
return self._shell_session_creation_state
if plane == "bash":
return self._bash_session_creation_state
raise ValueError(f"unknown session creation plane: {plane}")
def _begin_session_creation(self, plane: str, session_id: str) -> None:
with self._session_creation_state_lock:
state = self._session_creation_state(plane)
if state.ambiguous:
raise RuntimeError(f"AIO {plane} session creation is quarantined after an earlier ambiguous create outcome; recycle the sandbox before creating another session")
state.pending.add(session_id)
def _resolve_session_creation(self, plane: str, session_id: str) -> None:
with self._session_creation_state_lock:
self._session_creation_state(plane).pending.discard(session_id)
def _mark_session_creation_ambiguous(
self,
plane: str,
session_id: str,
) -> None:
with self._session_creation_state_lock:
state = self._session_creation_state(plane)
state.pending.discard(session_id)
state.ambiguous.add(session_id)
def _ambiguous_session_creation_snapshot(
self,
) -> tuple[tuple[str, ...], tuple[str, ...]]:
with self._session_creation_state_lock:
return (
tuple(self._shell_session_creation_state.ambiguous),
tuple(self._bash_session_creation_state.ambiguous),
)
@property
def requires_container_recycle(self) -> bool:
with self._session_creation_state_lock:
shell = self._shell_session_creation_state
bash = self._bash_session_creation_state
return bool(shell.pending or shell.ambiguous or bash.pending or bash.ambiguous)
def _create_shell_session(self, client) -> str:
session_id = str(uuid.uuid4())
self._begin_session_creation("shell", session_id)
try:
client.shell.create_session(
id=session_id,
request_options=self._session_create_request_options(),
)
except Exception as error:
if self._is_definite_session_creation_failure(error):
self._resolve_session_creation("shell", session_id)
raise
self._mark_session_creation_ambiguous("shell", session_id)
# Best effort only. A successful cleanup does NOT clear the tombstone:
# the original create may still commit after this cleanup returns.
self._cleanup_session_best_effort(
client,
session_id,
context="ambiguous shell session creation",
request_options=self._bounded_cleanup_request_options(),
)
raise RuntimeError("AIO shell session creation outcome is unknown; the sandbox is quarantined for recycle") from error
self._resolve_session_creation("shell", session_id)
return session_id
def _create_bash_session(self, client) -> str:
session_id = str(uuid.uuid4())
self._begin_session_creation("bash", session_id)
try:
client.bash.create_session(
session_id=session_id,
request_options=self._session_create_request_options(),
)
except Exception as error:
if self._is_definite_session_creation_failure(error):
self._resolve_session_creation("bash", session_id)
raise
self._mark_session_creation_ambiguous("bash", session_id)
# Same rule as shell: compensation is bounded but cannot prove that
# the original create will not commit later.
self._cleanup_bash_session_best_effort(
client,
session_id,
)
raise RuntimeError("AIO bash session creation outcome is unknown; the sandbox is quarantined for recycle") from error
self._resolve_session_creation("bash", session_id)
return session_id
def _ensure_default_shell_session_id(self, client) -> str | None:
"""Return the session id that may safely receive default-shell work.
A healthy implicit shell is represented by ``None``. Once that generation
is fenced, all later shell-backed operations must target an explicit
recovery session instead of re-entering the implicit shell.
Caller must hold ``self._lock``.
"""
if self._default_shell_corrupted and self._recovery_session_id is None:
self._recovery_session_id = self._create_shell_session(client)
return self._recovery_session_id
def _exec_shell(
self,
client,
command: str,
*,
session_id: str | None,
timeout: float,
) -> tuple[str, int | None, str | None]:
kwargs = {
"command": command,
"no_change_timeout": self._effective_no_change_timeout(timeout),
"hard_timeout": timeout,
"request_options": self._command_request_options(timeout),
}
if session_id is not None:
kwargs["id"] = session_id
return self._format_shell_result(client.shell.exec_command(**kwargs))
def _rotate_and_retry_shell(
self,
client,
command: str,
*,
corrupted_session_id: str | None,
context: str,
timeout: float,
) -> tuple[str, int | None, str | None, str | None]:
cleanup_options = self._bounded_cleanup_request_options()
if corrupted_session_id is not None:
self._cleanup_session_best_effort(
client,
corrupted_session_id,
context=f"corrupted {context}",
request_options=cleanup_options,
)
replacement_id = self._create_shell_session(client)
try:
output, exit_code, status = self._exec_shell(
client,
command,
session_id=replacement_id,
timeout=timeout,
)
except BaseException:
self._cleanup_session_best_effort(
client,
replacement_id,
context=f"abandoned replacement for {context}",
request_options=cleanup_options,
)
raise
if self._is_session_invalidating_shell_status(status):
if status == "terminated":
cleanup_context = f"terminated replacement for {context}"
elif status == "no_change_timeout":
cleanup_context = f"ambiguous replacement for {context}"
else:
cleanup_context = f"unexpected replacement status for {context}"
self._cleanup_session_best_effort(
client,
replacement_id,
context=cleanup_context,
request_options=cleanup_options,
)
return output, exit_code, status, None
if status in (None, "completed") and output and _ERROR_OBSERVATION_SIGNATURE in output:
self._cleanup_session_best_effort(
client,
replacement_id,
context=f"failed replacement for {context}",
request_options=cleanup_options,
)
return output, exit_code, status, None
return output, exit_code, status, replacement_id
def execute_command_in_scope(
self,
command: str,
env: dict[str, str] | None = None,
timeout: float | None = None,
*,
scope_id: str | None = None,
) -> str:
"""Run no-env commands in one persistent session per subagent run.
Commands within a scope remain serialized, while independent subagents
use distinct server-side sessions and can execute concurrently. Secret-
bearing commands keep the existing fresh ``bash.exec`` behavior.
"""
if env or scope_id is None:
return self.execute_command(command, env=env, timeout=timeout)
_validate_extra_env(env)
try:
with self._scope_registry_lock:
if self._closed:
raise RuntimeError("sandbox client is closed")
scoped = self._scoped_shell_sessions.setdefault(
scope_id,
_ScopedShellSession(),
)
with scoped.lock:
# Registration and command execution are separated by the
# per-scope wait. Revalidate identity after that wait so a
# release/close which removed this exact scope is a hard
# lifecycle fence: queued callers cannot resurrect a session
# on an orphaned registry entry.
with self._scope_registry_lock:
if self._closed or self._scoped_shell_sessions.get(scope_id) is not scoped:
raise RuntimeError("sandbox command scope is no longer active")
client = self._client
if client is None:
raise RuntimeError("sandbox client is closed")
if scoped.session_id is None:
scoped.session_id = self._create_shell_session(client)
try:
effective_timeout = self._effective_command_timeout(timeout)
output, exit_code, status = self._exec_shell(
client,
command,
session_id=scoped.session_id,
timeout=effective_timeout,
)
except httpx.TransportError as exc:
session_id = scoped.session_id
scoped.session_id = None
if session_id is not None:
self._cleanup_session_best_effort(
client,
session_id,
context="execution scope after transport failure",
request_options=self._bounded_cleanup_request_options(),
)
return self._transport_failure_error(exc, effective_timeout)
except ApiError as error:
if not self._is_missing_shell_session_error(error):
raise
logger.warning("Execution-scoped sandbox shell session is missing; recreating it once")
scoped.session_id = None
try:
output, exit_code, status, scoped.session_id = self._rotate_and_retry_shell(
client,
command,
corrupted_session_id=None,
context="execution scope after missing session",
timeout=effective_timeout,
)
except httpx.TransportError as exc:
return self._transport_failure_error(exc, effective_timeout)
if self._is_session_invalidating_shell_status(status):
session_id = scoped.session_id
scoped.session_id = None
if session_id is not None:
if status == "terminated":
cleanup_context = "execution scope after terminal session loss"
elif status == "no_change_timeout":
cleanup_context = "execution scope after ambiguous no-change timeout"
else:
cleanup_context = "execution scope after unexpected status"
self._cleanup_session_best_effort(
client,
session_id,
context=cleanup_context,
request_options=self._bounded_cleanup_request_options(),
)
if scoped.session_id is not None and status in (None, "completed") and output and _ERROR_OBSERVATION_SIGNATURE in output:
logger.warning("ErrorObservation detected in sandbox output for execution scope; rotating session")
corrupted_session_id = scoped.session_id
scoped.session_id = None
try:
output, exit_code, status, scoped.session_id = self._rotate_and_retry_shell(
client,
command,
corrupted_session_id=corrupted_session_id,
context="execution scope",
timeout=effective_timeout,
)
except httpx.TransportError as exc:
return self._transport_failure_error(exc, effective_timeout)
return self._render_shell_output(
output,
exit_code,
status=status,
timeout=effective_timeout,
)
except Exception as e:
logger.error(f"Failed to execute command in sandbox: {e}")
return f"Error: {e}"
def release_command_scope(self, scope_id: str) -> None:
"""Clean up one subagent's explicit server-side shell session."""
with self._scope_registry_lock:
scoped = self._scoped_shell_sessions.pop(scope_id, None)
if scoped is None:
return
with scoped.lock:
if scoped.session_id is None or self._client is None:
return
self._cleanup_session_best_effort(
self._client,
scoped.session_id,
context=f"execution scope {scope_id}",
request_options=self._bounded_cleanup_request_options(),
)
scoped.session_id = None
@staticmethod
def _format_timeout_duration(timeout: float) -> str:
return f"{timeout:g}"
@classmethod
def _format_timeout_notice(cls, timeout: float) -> str:
return f"Command timed out after {cls._format_timeout_duration(timeout)} seconds and was terminated."
@classmethod
def _bounded_cleanup_request_options(cls) -> dict[str, int]:
return {
"timeout_in_seconds": cls._CLEANUP_REQUEST_TIMEOUT_SECONDS,
"max_retries": 0,
}
@classmethod
def _session_create_request_options(cls) -> dict[str, int]:
return {
"timeout_in_seconds": cls._SESSION_CREATE_REQUEST_TIMEOUT_SECONDS,
"max_retries": 0,
}
@classmethod
def _transport_timeout_error(cls, timeout: float) -> str:
request_timeout = cls._command_request_options(timeout)["timeout_in_seconds"]
return f"Error: Sandbox command response timed out after {request_timeout} seconds; command outcome is unknown and the command was not retried."
@classmethod
def _transport_failure_error(cls, error: httpx.TransportError, timeout: float) -> str:
if isinstance(error, httpx.TimeoutException):
return cls._transport_timeout_error(timeout)
return "Error: Sandbox command transport failed; command outcome is unknown and the command was not retried."
@staticmethod
def _is_unexpected_shell_status(status: str | None) -> bool:
return status not in (None, "completed", "hard_timeout", "no_change_timeout", "terminated")
@classmethod
def _is_session_invalidating_shell_status(cls, status: str | None) -> bool:
return status in ("terminated", "no_change_timeout") or cls._is_unexpected_shell_status(status)
@classmethod
def _unexpected_status_notice(cls, status: str) -> str:
return f"Error: Sandbox command returned an unexpected status '{status}'; command outcome is unknown and the command was not retried."
@classmethod
def _render_shell_output(
cls,
output: str,
exit_code: int | None,
*,
status: str | None,
timeout: float,
) -> str:
if status == "hard_timeout":
notice = cls._format_timeout_notice(timeout)
output = f"{output}\n{notice}" if output else notice
return f"{output}\nExit Code: 124"
if status == "no_change_timeout":
effective_no_change_timeout = cls._effective_no_change_timeout(timeout)
notice = f"Command produced no output change for {effective_no_change_timeout} seconds; it may still be running. Command outcome is unknown and was not retried."
return f"{output}\n{notice}" if output else notice
if status == "terminated":
notice = "Command was terminated because its shell session ended."
return f"{output}\n{notice}" if output else notice
if cls._is_unexpected_shell_status(status):
notice = cls._unexpected_status_notice(status)
return f"{output}\n{notice}" if output else notice
if exit_code not in (0, None):
output = f"{output}\nExit Code: {exit_code}" if output else f"Command exited with code {exit_code}"
return output if output else "(no output)"
@property
def home_dir(self) -> str:
"""Get the home directory inside the sandbox."""
if self._home_dir is None:
context = self._client.sandbox.get_context()
self._home_dir = context.home_dir
return self._home_dir
# Default no_change_timeout base idle guard for exec_command (seconds).
# The per-command effective value is max(600, ceil(T + 5)); at the default
# T=600, the value sent to the sandbox is therefore 605, not 600.
_DEFAULT_NO_CHANGE_TIMEOUT = 600
# Fallback command hard timeout for both the legacy shell path and
# env-bearing commands routed through bash.exec when no provider default is set.
# The bash.exec API exposes no idle/no-change timeout (unlike
# shell.exec_command's ``no_change_timeout`` on the legacy path), so
# env-bearing commands are bounded by total elapsed wall-clock time, not
# time-since-last-output. Kept at the same numeric value as the legacy idle
# budget so the two paths broadly agree on how long a single command may
# run; a future SDK that exposes an idle timeout on bash.exec should switch
# this call site to it.
_DEFAULT_HARD_TIMEOUT = 600.0
_REQUEST_TIMEOUT_GRACE_SECONDS = 5.0
_CLEANUP_REQUEST_TIMEOUT_SECONDS = 5
_SESSION_CREATE_REQUEST_TIMEOUT_SECONDS = 5
# Directory-operation deadline for ``list_dir`` (#5644). ``list_dir`` is an
# independent operation, not a shell command: it must not inherit
# ``bash_command_timeout`` (600s default), or a wedged ``find`` holds
# ``self._lock`` for the full SDK budget. 60s is far above a real
# ``max_depth=2`` traversal and far below the SDK's 600s, and stays a
# private constant rather than new operator config.
_LIST_DIR_TIMEOUT_SECONDS = 60.0
def _effective_command_timeout(self, timeout: float | None) -> float:
return timeout if timeout is not None else (getattr(self, "_default_command_timeout", None) or self._DEFAULT_HARD_TIMEOUT)
@classmethod
def _effective_no_change_timeout(cls, timeout: float) -> int:
return max(
cls._DEFAULT_NO_CHANGE_TIMEOUT,
math.ceil(timeout + cls._REQUEST_TIMEOUT_GRACE_SECONDS),
)
@classmethod
def _command_request_options(cls, timeout: float) -> dict[str, int]:
return {
"timeout_in_seconds": max(
1,
math.ceil(timeout + cls._REQUEST_TIMEOUT_GRACE_SECONDS),
),
"max_retries": 0,
}
def execute_command(
self,
command: str,
env: dict[str, str] | None = None,
timeout: float | None = None,
) -> str:
"""Execute a shell command in the sandbox.
Uses a lock to serialize unscoped requests. The AIO sandbox container's
implicit persistent shell corrupts when hit with concurrent
``exec_command`` calls (returning ``ErrorObservation`` instead of real
output). If corruption is detected despite the lock (e.g. multiple
processes sharing a sandbox), the replacement session is promoted for
subsequent calls rather than returning to the corrupted implicit one.
Args:
command: The command to execute.
env: Optional per-call environment variables (request-scoped secrets,
issue #3861). When provided, the command runs via the ``bash.exec``
API (which supports per-command env) on a fresh explicitly released
session, so the secrets are scoped to this single command and never
persist; secret values travel in the structured ``env`` field, never
in the command string. When ``None``, the legacy persistent-shell
path still uses the provider-wide command timeout via
``hard_timeout``, bounded request options, and returned-status
interpretation.
timeout: Optional per-call command timeout. The legacy shell path
enforces it server-side and gives the request a small additional
response-path grace period.
Returns:
The output of the command.
"""
# Validate ``env`` keys before forwarding them to the ``bash.exec`` API.
# The public ``Sandbox.execute_command`` contract accepts arbitrary dict
# keys; enforcing the POSIX env-var name rule keeps the contract
# consistent with the local and e2b sandboxes and catches unsafe keys
# early. ``_validate_extra_env`` is a no-op when ``env`` is None or empty.
_validate_extra_env(env)
effective_timeout = self._effective_command_timeout(timeout)
if env:
return self._execute_with_env(command, env, effective_timeout)
with self._lock:
try:
client = self._client
if getattr(self, "_closed", False) or client is None:
raise RuntimeError("sandbox client is closed")
session_id = self._ensure_default_shell_session_id(client)
recovered_missing_session = False
try:
output, exit_code, status = self._exec_shell(
client,
command,
session_id=session_id,
timeout=effective_timeout,
)
except httpx.TransportError as exc:
session_id = self._recovery_session_id
self._recovery_session_id = None
self._default_shell_corrupted = True
if session_id is not None:
self._cleanup_session_best_effort(
client,
session_id,
context="default shell after transport failure",
request_options=self._bounded_cleanup_request_options(),
)
return self._transport_failure_error(exc, effective_timeout)
except ApiError as error:
if not self._is_missing_shell_session_error(error):
raise
logger.warning("Default sandbox shell session is missing; recreating it once")
self._default_shell_corrupted = True
self._recovery_session_id = None
recovered_missing_session = True
try:
output, exit_code, status, self._recovery_session_id = self._rotate_and_retry_shell(
client,
command,
corrupted_session_id=None,
context="default shell after missing session",
timeout=effective_timeout,
)
except httpx.TransportError as exc:
return self._transport_failure_error(exc, effective_timeout)
if not recovered_missing_session and status in (None, "completed") and output and _ERROR_OBSERVATION_SIGNATURE in output:
self._default_shell_corrupted = True
logger.warning("ErrorObservation detected in sandbox output, retrying on a fresh session")
corrupted_session_id = self._recovery_session_id
self._recovery_session_id = None
try:
output, exit_code, status, self._recovery_session_id = self._rotate_and_retry_shell(
client,
command,
corrupted_session_id=corrupted_session_id,
context="default shell",
timeout=effective_timeout,
)
except httpx.TransportError as exc:
return self._transport_failure_error(exc, effective_timeout)
if self._is_session_invalidating_shell_status(status):
session_id = self._recovery_session_id
self._recovery_session_id = None
self._default_shell_corrupted = True
if session_id is not None:
if status == "terminated":
cleanup_context = "default shell after terminal session loss"
elif status == "no_change_timeout":
cleanup_context = "default shell after ambiguous no-change timeout"
else:
cleanup_context = "default shell after unexpected status"
self._cleanup_session_best_effort(
client,
session_id,
context=cleanup_context,
request_options=self._bounded_cleanup_request_options(),
)
return self._render_shell_output(
output,
exit_code,
status=status,
timeout=effective_timeout,
)
except Exception as e:
logger.error(f"Failed to execute command in sandbox: {e}")
return f"Error: {e}"
def _execute_with_env(
self,
command: str,
env: dict[str, str],
timeout: float,
) -> str:
"""Execute a command with per-call environment variables injected.
The persistent-shell ``shell.exec_command`` API has no env parameter, so
injected commands use the ``bash.exec`` API which accepts per-command env.
Each call creates an explicit transient session and closes it after the
command, so injected request-scoped secrets are scoped to this command,
never persist across calls, and do not consume the server's session
capacity after completion. Secret values travel in the structured
``env`` field, never in the command string.
Trade-off of the fresh-session choice: consecutive env-bearing bash calls
within the same skill do not share session state (cwd, sourced venv,
exported variables). This mirrors the LocalSandbox model (each call is a
fresh subprocess) and is intentional — a shared session_id would let
request-scoped secrets ride the session env into later commands, which the
SDK does not contractually forbid. Skills that need setup must fold it into
a single command (e.g. ``cd /mnt/user-data/workspace && source .venv/bin/activate && python run.py``).
The ``_ERROR_OBSERVATION_SIGNATURE`` recovery contract is shared with the
legacy persistent-shell path: if the (unlikely, since each call is a fresh
session) corruption marker shows up, the call is retried on another fresh
session rather than returned verbatim.
Images older than all-in-one-sandbox 1.9.x have no ``/v1/bash/*`` routes;
there is no fallback on the legacy shell path that would keep the secret
values out of the command string, so the only safe behaviour is to fail
fast with an actionable error (#3921).
"""
if self._bash_exec_unsupported:
return _BASH_EXEC_UNSUPPORTED_ERROR
output, status = self._run_bash_exec(command, env, timeout)
if status in (None, "completed") and output and _ERROR_OBSERVATION_SIGNATURE in output:
logger.warning("ErrorObservation detected in bash.exec output, retrying on a fresh session")
retried, retry_status = self._run_bash_exec(command, env, timeout)
if retry_status not in (None, "completed"):
return retried
if retried and _ERROR_OBSERVATION_SIGNATURE not in retried:
return retried
return output
def _run_bash_exec(
self,
command: str,
env: dict[str, str],
timeout: float,
) -> tuple[str, str | None]:
"""Single bash.exec invocation in an explicitly released fresh session."""
with self._lock:
for attempt in range(2):
session_id: str | None = None
try:
session_id = self._create_bash_session(self._client)
result = self._client.bash.exec(
command=command,
session_id=session_id,
env=env,
hard_timeout=timeout,
request_options=self._command_request_options(timeout),
)
data = result.data if result else None
stdout = (data.stdout or "") if data else ""
stderr = (data.stderr or "") if data else ""
exit_code = getattr(data, "exit_code", None) if data else None
status = getattr(data, "status", None) if data else None
output = stdout
if stderr:
output += f"\nStd Error:\n{stderr}" if output else stderr
if status == "timed_out":
notice = self._format_timeout_notice(timeout)
output = f"{output}\n{notice}" if output else notice
return f"{output}\nExit Code: 124", status
if status == "killed":
notice = "Command was killed before completion."
output = f"{output}\n{notice}" if output else notice
return output, status
if status == "running":
notice = "Error: Sandbox command returned a non-terminal running status; command outcome is unknown and was not retried."
output = f"{output}\n{notice}" if output else notice
return output, status
if status not in (None, "completed"):
notice = self._unexpected_status_notice(status)
output = f"{output}\n{notice}" if output else notice
return output, status
if exit_code not in (0, None):
# Mirror LocalSandbox: keep the actual shell status in the
# output text (acceptance-checklist evidence).
output = f"{output}\nExit Code: {exit_code}" if output else f"Command exited with code {exit_code}"
return output if output else "(no output)", status
except httpx.TimeoutException:
return self._transport_timeout_error(timeout), "transport_timeout"
except ApiError as e:
if self._is_missing_shell_session_error(e):
if attempt == 0:
logger.warning("Transient bash.exec session disappeared; retrying once")
continue
logger.error("Failed to execute command with injected env: bash.exec session disappeared after retry")
return "Error: bash.exec session disappeared after retry", None
if e.status_code == 404:
self._bash_exec_unsupported = True
logger.error("Sandbox %s does not support bash.exec (/v1/bash/exec returned 404); env-bearing commands are unavailable until the sandbox image is upgraded to all-in-one-sandbox >= 1.9.3", self.id)
return _BASH_EXEC_UNSUPPORTED_ERROR, None
logger.error(f"Failed to execute command with injected env in sandbox: {e}")
return f"Error: {e}", None
except Exception as e:
logger.error(f"Failed to execute command with injected env in sandbox: {e}")
return f"Error: {e}", None
finally:
if session_id is not None:
self._cleanup_bash_session_best_effort(self._client, session_id)
return "Error: bash.exec session disappeared after retry", None
def read_file(
self,
path: str,
start_line: int | None = None,
end_line: int | None = None,
) -> str:
"""Read the content of a file in the sandbox.
Args:
path: The absolute path of the file to read.
Returns:
The content of the file.
"""
try:
kwargs = {}
if start_line is not None:
kwargs["start_line"] = max(start_line - 1, 0)
if end_line is not None:
kwargs["end_line"] = max(end_line, 0)
result = self._client.file.read_file(file=path, **kwargs)
return result.data.content if result.data else ""
except Exception as e:
logger.error(f"Failed to read file in sandbox: {e}")
return f"Error: {e}"
def download_file(self, path: str) -> bytes:
"""Download file bytes from the sandbox.
Raises:
PermissionError: If the path contains '..' traversal segments or is
outside ``VIRTUAL_PATH_PREFIX``.
OSError: If the file cannot be retrieved from the sandbox.
"""
# Reject path traversal before sending to the container API.
# LocalSandbox gets this implicitly via _resolve_path;
# here the path is forwarded verbatim so we must check explicitly.
normalised = path.replace("\\", "/")
for segment in normalised.split("/"):
if segment == "..":
logger.error(f"Refused download due to path traversal: {path}")
raise PermissionError(f"Access denied: path traversal detected in '{path}'")
stripped_path = normalised.lstrip("/")
allowed_prefix = VIRTUAL_PATH_PREFIX.lstrip("/")
if stripped_path != allowed_prefix and not stripped_path.startswith(f"{allowed_prefix}/"):
logger.error("Refused download outside allowed directory: path=%s, allowed_prefix=%s", path, VIRTUAL_PATH_PREFIX)
raise PermissionError(f"Access denied: path must be under '{VIRTUAL_PATH_PREFIX}': '{path}'")
with self._lock:
try:
chunks: list[bytes] = []
total = 0
for chunk in self._client.file.download_file(path=path):
total += len(chunk)
if total > _MAX_DOWNLOAD_SIZE:
raise OSError(
errno.EFBIG,
f"File exceeds maximum download size of {_MAX_DOWNLOAD_SIZE} bytes",
path,
)
chunks.append(chunk)
return b"".join(chunks)
except OSError:
raise
except Exception as e:
logger.error(f"Failed to download file in sandbox: {e}")
raise OSError(f"Failed to download file '{path}' from sandbox: {e}") from e
def list_dir(self, path: str, max_depth: int = 2) -> list[str]:
"""List the contents of a directory in the sandbox.
Args:
path: The absolute path of the directory to list.
max_depth: The maximum depth to traverse. Default is 2.
Returns:
The contents of the directory.
"""
resolved = path
timeout = self._LIST_DIR_TIMEOUT_SECONDS
with self._lock:
client = self._client
session_id: str | None = None
try:
session_id = self._ensure_default_shell_session_id(client)
kwargs = {
"command": remote_list_dir_command(resolved, max_depth),
"no_change_timeout": self._effective_no_change_timeout(timeout),
"hard_timeout": timeout,
"request_options": self._command_request_options(timeout),
}
if session_id is not None:
kwargs["id"] = session_id
result = client.shell.exec_command(**kwargs)
except httpx.TransportError as exc:
# The response never arrived, so we cannot tell whether ``find``
# is still running on the targeted shell generation. Fence that
# generation and replay nothing. Local recovery ownership is
# dropped before the cleanup attempt, so a failed cleanup can
# never leave the ambiguous session reusable.
self._default_shell_corrupted = True
if session_id is not None:
self._recovery_session_id = None
self._cleanup_session_best_effort(
client,
session_id,
context="list_dir after transport failure",
request_options=self._bounded_cleanup_request_options(),
)
logger.error(f"Failed to list directory in sandbox: {exc}")
reason = "request timed out" if isinstance(exc, httpx.TimeoutException) else "transport failed"
raise OSError(f"Failed to list directory '{resolved}': {reason}; directory result is unknown") from exc
except ApiError as exc:
if self._is_missing_shell_session_error(exc):
# The server has lost this generation. Forget it without replaying
# the listing; the next call creates a fresh recovery session.
self._default_shell_corrupted = True
self._recovery_session_id = None
logger.error(f"Failed to list directory in sandbox: {exc}")
raise OSError(f"Failed to list directory '{resolved}' in sandbox: {exc}") from exc
except Exception as e:
logger.error(f"Failed to list directory in sandbox: {e}")
raise OSError(f"Failed to list directory '{resolved}' in sandbox: {e}") from e
data = result.data if result else None
if data is None:
raise OSError(f"Failed to list directory '{resolved}' in sandbox: empty response")
# Only a completed listing is a listing. ``list_dir`` returns
# ``list[str]`` or raises; it never surfaces a partial ``find`` as
# the directory's contents.
status = getattr(data, "status", None)
if status == "hard_timeout":
raise TimeoutError(f"Failed to list directory '{resolved}': find timed out after {timeout:g} seconds; directory result may be incomplete")
if self._is_session_invalidating_shell_status(status):
# Same contract as an ambiguous transport timeout: fence the
# generation that actually executed this listing, dropping local
# recovery ownership before the bounded cleanup attempt.
self._default_shell_corrupted = True
if session_id is not None:
self._recovery_session_id = None
self._cleanup_session_best_effort(
client,
session_id,
context=f"list_dir after ambiguous status {status}",
request_options=self._bounded_cleanup_request_options(),
)
raise OSError(f"Failed to list directory '{resolved}' in sandbox: command status '{status}'; directory result is unknown")
return parse_remote_list_dir_output(
data.output or "",
resolved,
pipeline_exit_code=getattr(data, "exit_code", None),
)
def write_file(self, path: str, content: str, append: bool = False) -> None:
"""Write content to a file in the sandbox.
Args:
path: The absolute path of the file to write to.
content: The text content to write to the file.
append: Whether to append the content to the file.
"""
with self._lock:
try:
if append:
self._client.file.write_file(file=path, content=content, append=True)
else:
self._client.file.write_file(file=path, content=content)
except Exception as e:
logger.error(f"Failed to write file in sandbox: {e}")
raise
def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]:
if not include_dirs:
result = self._client.file.find_files(path=path, glob=pattern)
files = result.data.files if result.data and result.data.files else []
filtered = [file_path for file_path in files if not should_ignore_path(file_path)]
truncated = len(filtered) > max_results
return filtered[:max_results], truncated
result = self._client.file.list_path(path=path, recursive=True, show_hidden=False)
entries = result.data.files if result.data and result.data.files else []
matches: list[str] = []
root_path = path.rstrip("/") or "/"
root_prefix = root_path if root_path == "/" else f"{root_path}/"
for entry in entries:
if entry.path != root_path and not entry.path.startswith(root_prefix):
continue
if should_ignore_path(entry.path):
continue
rel_path = entry.path[len(root_path) :].lstrip("/")
if path_matches(pattern, rel_path):
matches.append(entry.path)
# Look one match past the cap before deciding. Returning on
# the max-th match cannot tell a listing that held exactly
# ``max_results`` from one that held more, so an exhausted
# listing was reported as truncated; it also returned a match
# for ``max_results=0``. The ``include_dirs=False`` branch
# below and the shared ``parse_remote_search_output`` path
# decide the same way.
if len(matches) > max_results:
return matches[:max_results], True
return matches, False
def grep(
self,
path: str,
pattern: str,
*,
glob: str | None = None,
literal: bool = False,
case_sensitive: bool = False,
max_results: int = 100,
) -> tuple[list[GrepMatch], bool]:
import re as _re
regex_source = _re.escape(pattern) if literal else pattern
# Validate the pattern locally so an invalid regex raises re.error
# (caught by grep_tool's except re.error handler) rather than a
# generic remote API error.
_re.compile(regex_source, 0 if case_sensitive else _re.IGNORECASE)
total_cap = max(max_results * 4, max_results + 50)
result = self._client.file.grep_files(
path=path,
pattern=pattern,
case_insensitive=not case_sensitive,
fixed_strings=literal,
max_results=total_cap,
max_file_size="1M",
recursive=True,
)
data = result.data
provider_matches = data.matches if data and data.matches else []
root = path.rstrip("/") or "/"
root_prefix = root if root == "/" else f"{root}/"
matches: list[GrepMatch] = []
truncated = bool(data and data.truncated)
for match in provider_matches:
file_path = match.file
if should_ignore_path(file_path):
continue
if file_path == root:
rel_path = file_path.rsplit("/", 1)[-1]
elif file_path.startswith(root_prefix):
rel_path = file_path[len(root_prefix) :]
else:
continue
if glob is not None and not path_matches(glob, rel_path):
continue
matches.append(
GrepMatch(
path=file_path,
line_number=match.line_number,
line=truncate_line(match.line_content),
)
)
# Look one match past the cap before deciding, as ``glob`` above
# does. Returning on the ``max_results``-th match cannot tell a
# search that held exactly that many from one that held more, so an
# exhausted search was reported as truncated.
if len(matches) > max_results:
return matches[:max_results], True
return matches, truncated
def update_file(self, path: str, content: bytes) -> None:
"""Update a file with binary content in the sandbox.
Args:
path: The absolute path of the file to update.
content: The binary content to write to the file.
"""
with self._lock:
try:
base64_content = base64.b64encode(content).decode("utf-8")
self._client.file.write_file(file=path, content=base64_content, encoding="base64")
except Exception as e:
logger.error(f"Failed to update file in sandbox: {e}")
raise