1189 lines
44 KiB
Python
1189 lines
44 KiB
Python
import abc
|
|
import io
|
|
import shlex
|
|
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
from pathlib import Path, PurePath
|
|
from typing import Literal, TypeVar
|
|
|
|
from typing_extensions import Self
|
|
|
|
from ...editor import ApplyPatchOperation
|
|
from ...run_config import (
|
|
DEFAULT_MAX_LOCAL_DIR_FILE_CONCURRENCY,
|
|
DEFAULT_MAX_MANIFEST_ENTRY_CONCURRENCY,
|
|
SandboxArchiveLimits,
|
|
SandboxConcurrencyLimits,
|
|
)
|
|
from ..apply_patch import PatchFormat, WorkspaceEditor
|
|
from ..entries import BaseEntry
|
|
from ..errors import (
|
|
ExecNonZeroError,
|
|
ExecTransportError,
|
|
ExposedPortUnavailableError,
|
|
InvalidManifestPathError,
|
|
MountConfigError,
|
|
PtySessionNotFoundError,
|
|
WorkspaceArchiveWriteError,
|
|
WorkspaceReadNotFoundError,
|
|
)
|
|
from ..files import FileEntry
|
|
from ..manifest import Manifest
|
|
from ..materialization import MaterializationResult, MaterializedFile
|
|
from ..types import ExecResult, ExposedPortEndpoint, User
|
|
from ..util.parse_utils import parse_ls_la
|
|
from ..workspace_paths import (
|
|
WorkspacePathPolicy,
|
|
coerce_posix_path,
|
|
posix_path_as_path,
|
|
posix_path_for_error,
|
|
sandbox_path_str,
|
|
)
|
|
from . import archive_ops, manifest_ops, snapshot_lifecycle
|
|
from .dependencies import Dependencies
|
|
from .pty_types import PtyExecUpdate
|
|
from .runtime_helpers import (
|
|
RESOLVE_WORKSPACE_PATH_HELPER,
|
|
RuntimeHelperScript,
|
|
)
|
|
from .sandbox_session_state import SandboxSessionState
|
|
|
|
_PtyEntryT = TypeVar("_PtyEntryT")
|
|
_RUNTIME_HELPER_CACHE_KEY_UNSET = object()
|
|
_WORKSPACE_ROOT_PROBE_TIMEOUT_S = 10.0
|
|
_WRITE_ACCESS_CHECK_SCRIPT = (
|
|
'target="$1"\n'
|
|
'if [ -e "$target" ]; then\n'
|
|
' [ -f "$target" ] && [ -w "$target" ]\n'
|
|
" exit $?\n"
|
|
"fi\n"
|
|
'parent=$(dirname "$target")\n'
|
|
'while [ ! -e "$parent" ]; do\n'
|
|
' next=$(dirname "$parent")\n'
|
|
' if [ "$next" = "$parent" ]; then\n'
|
|
" exit 1\n"
|
|
" fi\n"
|
|
' parent="$next"\n'
|
|
"done\n"
|
|
'[ -d "$parent" ] && [ -w "$parent" ] && [ -x "$parent" ]\n'
|
|
)
|
|
_MKDIR_ACCESS_CHECK_SCRIPT = (
|
|
'target="$1"\n'
|
|
'parents="$2"\n'
|
|
'if [ -e "$target" ] || [ -L "$target" ]; then\n'
|
|
' [ -d "$target" ] && [ -x "$target" ]\n'
|
|
" exit $?\n"
|
|
"fi\n"
|
|
'parent=$(dirname "$target")\n'
|
|
'if [ "$parents" = "1" ]; then\n'
|
|
' while [ ! -e "$parent" ]; do\n'
|
|
' next=$(dirname "$parent")\n'
|
|
' if [ "$next" = "$parent" ]; then\n'
|
|
" exit 1\n"
|
|
" fi\n"
|
|
' parent="$next"\n'
|
|
" done\n"
|
|
"fi\n"
|
|
'[ -d "$parent" ] && [ -w "$parent" ] && [ -x "$parent" ]\n'
|
|
)
|
|
_RM_ACCESS_CHECK_SCRIPT = (
|
|
'target="$1"\n'
|
|
'recursive="$2"\n'
|
|
'if [ ! -e "$target" ] && [ ! -L "$target" ]; then\n'
|
|
' [ "$recursive" = "1" ]\n'
|
|
" exit $?\n"
|
|
"fi\n"
|
|
'parent=$(dirname "$target")\n'
|
|
'[ -d "$parent" ] && [ -w "$parent" ] && [ -x "$parent" ]\n'
|
|
)
|
|
|
|
|
|
class BaseSandboxSession(abc.ABC):
|
|
state: SandboxSessionState
|
|
_dependencies: Dependencies | None = None
|
|
_dependencies_closed: bool = False
|
|
_runtime_persist_workspace_skip_relpaths: set[Path] | None = None
|
|
_pre_stop_hooks: list[Callable[[], Awaitable[None]]] | None = None
|
|
_pre_stop_hooks_ran: bool = False
|
|
_runtime_helpers_installed: set[PurePath] | None = None
|
|
_runtime_helper_cache_key: object = _RUNTIME_HELPER_CACHE_KEY_UNSET
|
|
_workspace_path_policy_cache: (
|
|
tuple[str, tuple[tuple[str, bool], ...], WorkspacePathPolicy] | None
|
|
) = None
|
|
# True when start() is reusing a backend whose workspace files may still be present.
|
|
# This controls whether start() can avoid a full manifest apply for non-snapshot resumes.
|
|
_start_workspace_state_preserved: bool = False
|
|
# True when start() is reusing a backend whose OS users and groups may still be present.
|
|
# This controls whether snapshot restore needs to reprovision manifest-managed accounts.
|
|
_start_system_state_preserved: bool = False
|
|
# Snapshot of serialized workspace readiness after backend startup/reconnect.
|
|
# Providers may set this to True during start only after a preserved-backend probe succeeds.
|
|
_start_workspace_root_ready: bool | None = None
|
|
_max_manifest_entry_concurrency: int | None = DEFAULT_MAX_MANIFEST_ENTRY_CONCURRENCY
|
|
_max_local_dir_file_concurrency: int | None = DEFAULT_MAX_LOCAL_DIR_FILE_CONCURRENCY
|
|
_archive_limits: SandboxArchiveLimits | None = None
|
|
|
|
async def start(self) -> None:
|
|
try:
|
|
await self._ensure_backend_started()
|
|
self._start_workspace_root_ready = self.state.workspace_root_ready
|
|
await self._probe_workspace_root_for_preserved_resume()
|
|
await self._prepare_backend_workspace()
|
|
await self._ensure_runtime_helpers()
|
|
await self._start_workspace()
|
|
except Exception as e:
|
|
await self._after_start_failed()
|
|
wrapped = self._wrap_start_error(e)
|
|
if wrapped is e:
|
|
raise
|
|
raise wrapped from e
|
|
await self._after_start()
|
|
self.state.workspace_root_ready = True
|
|
|
|
def _set_concurrency_limits(self, limits: SandboxConcurrencyLimits) -> None:
|
|
limits.validate()
|
|
self._max_manifest_entry_concurrency = limits.manifest_entries
|
|
self._max_local_dir_file_concurrency = limits.local_dir_files
|
|
|
|
def _set_archive_limits(self, limits: SandboxArchiveLimits | None) -> None:
|
|
if limits is not None:
|
|
limits.validate()
|
|
self._archive_limits = limits
|
|
|
|
async def _ensure_backend_started(self) -> None:
|
|
"""Start, reconnect, or recreate the backend before workspace setup runs."""
|
|
|
|
return
|
|
|
|
async def _prepare_backend_workspace(self) -> None:
|
|
"""Prepare provider-specific workspace prerequisites before manifest or snapshot work."""
|
|
|
|
return
|
|
|
|
async def _probe_workspace_root_for_preserved_resume(self) -> bool:
|
|
"""Probe whether a preserved backend already has a usable workspace root."""
|
|
|
|
if not self._workspace_state_preserved_on_start() or self._start_workspace_root_ready:
|
|
return self._can_reuse_preserved_workspace_on_resume()
|
|
|
|
try:
|
|
result = await self.exec(
|
|
"test",
|
|
"-d",
|
|
self.state.manifest.root,
|
|
timeout=_WORKSPACE_ROOT_PROBE_TIMEOUT_S,
|
|
shell=False,
|
|
)
|
|
except Exception:
|
|
return False
|
|
|
|
if not result.ok():
|
|
return False
|
|
|
|
self._mark_workspace_root_ready_from_probe()
|
|
return True
|
|
|
|
def _mark_workspace_root_ready_from_probe(self) -> None:
|
|
"""Record that the preserved-backend workspace root was proven ready."""
|
|
|
|
self.state.workspace_root_ready = True
|
|
self._start_workspace_root_ready = True
|
|
|
|
def _set_start_state_preserved(self, workspace: bool, *, system: bool | None = None) -> None:
|
|
"""Record whether this start begins with preserved backend state."""
|
|
|
|
self._start_workspace_state_preserved = workspace
|
|
self._start_system_state_preserved = workspace if system is None else system
|
|
|
|
def _workspace_state_preserved_on_start(self) -> bool:
|
|
"""Return whether start begins with previously persisted workspace state."""
|
|
|
|
return self._start_workspace_state_preserved
|
|
|
|
def _system_state_preserved_on_start(self) -> bool:
|
|
"""Return whether start begins with previously provisioned OS/user state."""
|
|
|
|
return self._start_system_state_preserved
|
|
|
|
async def _start_workspace(self) -> None:
|
|
"""Restore snapshot or apply manifest state after backend startup is complete."""
|
|
|
|
if await self.state.snapshot.restorable(dependencies=self.dependencies):
|
|
can_reuse_workspace = await self._can_reuse_restorable_snapshot_workspace()
|
|
if can_reuse_workspace:
|
|
# The preserved workspace already matches the snapshot, so only rebuild ephemeral
|
|
# manifest state that intentionally was not persisted.
|
|
await self._reapply_ephemeral_manifest_on_resume()
|
|
else:
|
|
# Fresh workspaces and drifted preserved workspaces both need the durable snapshot
|
|
# restored before ephemeral state is rebuilt.
|
|
await self._restore_snapshot_into_workspace_on_resume()
|
|
if self.should_provision_manifest_accounts_on_resume():
|
|
await self.provision_manifest_accounts()
|
|
await self._reapply_ephemeral_manifest_on_resume()
|
|
elif self._can_reuse_preserved_workspace_on_resume():
|
|
# There is no durable snapshot to restore, but a reconnected backend may still need
|
|
# ephemeral mounts/files refreshed without reapplying the full manifest.
|
|
await self._reapply_ephemeral_manifest_on_resume()
|
|
else:
|
|
# A fresh backend without a restorable snapshot needs the full manifest materialized.
|
|
await self._apply_manifest(
|
|
provision_accounts=self.should_provision_manifest_accounts_on_resume()
|
|
)
|
|
|
|
async def _can_reuse_restorable_snapshot_workspace(self) -> bool:
|
|
"""Return whether a restorable snapshot can be skipped for this start."""
|
|
|
|
if not self._can_reuse_preserved_workspace_on_resume():
|
|
return False
|
|
is_running = await self.running()
|
|
return await self._can_skip_snapshot_restore_on_resume(is_running=is_running)
|
|
|
|
def _can_reuse_preserved_workspace_on_resume(self) -> bool:
|
|
"""Return whether preserved workspace state is proven safe to reuse."""
|
|
|
|
workspace_root_ready = self._start_workspace_root_ready
|
|
if workspace_root_ready is None:
|
|
workspace_root_ready = self.state.workspace_root_ready
|
|
return self._workspace_state_preserved_on_start() and workspace_root_ready
|
|
|
|
async def _after_start(self) -> None:
|
|
"""Run provider bookkeeping after workspace setup succeeds."""
|
|
|
|
return
|
|
|
|
async def _after_start_failed(self) -> None:
|
|
"""Run provider bookkeeping after workspace setup fails."""
|
|
|
|
return
|
|
|
|
def _wrap_start_error(self, error: Exception) -> Exception:
|
|
"""Return a provider-specific start error, or the original error."""
|
|
|
|
return error
|
|
|
|
async def stop(self) -> None:
|
|
"""
|
|
Persist/snapshot the workspace.
|
|
|
|
Note: `stop()` is intentionally persistence-only. Sandboxes that need to tear down
|
|
sandbox resources (Docker containers, remote sessions, etc.) should implement
|
|
`shutdown()` instead.
|
|
"""
|
|
try:
|
|
try:
|
|
await self._before_stop()
|
|
await self._persist_snapshot()
|
|
except Exception as e:
|
|
wrapped = self._wrap_stop_error(e)
|
|
if wrapped is e:
|
|
raise
|
|
raise wrapped from e
|
|
finally:
|
|
await self._after_stop()
|
|
|
|
async def _before_stop(self) -> None:
|
|
"""Run transient process cleanup before snapshot persistence."""
|
|
|
|
await self.pty_terminate_all()
|
|
|
|
async def _persist_snapshot(self) -> None:
|
|
"""Persist/snapshot the workspace."""
|
|
|
|
await snapshot_lifecycle.persist_snapshot(self)
|
|
|
|
def _wrap_stop_error(self, error: Exception) -> Exception:
|
|
"""Return a provider-specific stop error, or the original error."""
|
|
|
|
return error
|
|
|
|
async def _after_stop(self) -> None:
|
|
"""Run provider bookkeeping after stop finishes or fails."""
|
|
|
|
return
|
|
|
|
def supports_docker_volume_mounts(self) -> bool:
|
|
"""Return whether this backend attaches Docker volume mounts before manifest apply."""
|
|
|
|
return False
|
|
|
|
def supports_pty(self) -> bool:
|
|
return False
|
|
|
|
async def shutdown(self) -> None:
|
|
"""
|
|
Tear down sandbox resources (best-effort).
|
|
|
|
Default is a no-op. Sandbox-specific sessions (e.g. Docker) should override.
|
|
"""
|
|
await self._before_shutdown()
|
|
await self._shutdown_backend()
|
|
await self._after_shutdown()
|
|
|
|
async def _before_shutdown(self) -> None:
|
|
"""Run transient process cleanup before backend shutdown."""
|
|
|
|
await self.pty_terminate_all()
|
|
|
|
async def _shutdown_backend(self) -> None:
|
|
"""Tear down provider-specific backend resources."""
|
|
|
|
return
|
|
|
|
async def _after_shutdown(self) -> None:
|
|
"""Run provider bookkeeping after backend shutdown."""
|
|
|
|
return
|
|
|
|
async def __aenter__(self) -> Self:
|
|
await self.start()
|
|
return self
|
|
|
|
async def aclose(self) -> None:
|
|
"""Run the session cleanup lifecycle outside of ``async with``.
|
|
|
|
This performs the same session-owned cleanup as ``__aexit__()``: persist/snapshot the
|
|
workspace via ``stop()``, tear down session resources via ``shutdown()``, and close
|
|
session-scoped dependencies. If the session came from a sandbox client, call the client's
|
|
``delete()`` separately for backend-specific deletion such as removing a Docker container
|
|
or deleting a temporary host workspace.
|
|
"""
|
|
try:
|
|
await self.run_pre_stop_hooks()
|
|
await self.stop()
|
|
await self.shutdown()
|
|
finally:
|
|
await self._aclose_dependencies()
|
|
|
|
async def __aexit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc: BaseException | None,
|
|
tb: object | None,
|
|
) -> None:
|
|
await self.aclose()
|
|
|
|
@property
|
|
def dependencies(self) -> Dependencies:
|
|
dependencies = self._dependencies
|
|
if dependencies is None:
|
|
dependencies = Dependencies()
|
|
self._dependencies = dependencies
|
|
self._dependencies_closed = False
|
|
return dependencies
|
|
|
|
def set_dependencies(self, dependencies: Dependencies | None) -> None:
|
|
if dependencies is None:
|
|
return
|
|
self._dependencies = dependencies
|
|
self._dependencies_closed = False
|
|
|
|
def register_pre_stop_hook(self, hook: Callable[[], Awaitable[None]]) -> None:
|
|
"""Register an async hook to run once before the session workspace is persisted."""
|
|
|
|
hooks = self._pre_stop_hooks
|
|
if hooks is None:
|
|
hooks = []
|
|
self._pre_stop_hooks = hooks
|
|
hooks.append(hook)
|
|
self._pre_stop_hooks_ran = False
|
|
|
|
async def run_pre_stop_hooks(self) -> None:
|
|
"""Run registered pre-stop hooks once before workspace persistence."""
|
|
|
|
hooks = self._pre_stop_hooks
|
|
if hooks is None or self._pre_stop_hooks_ran:
|
|
return
|
|
self._pre_stop_hooks_ran = True
|
|
cleanup_error: BaseException | None = None
|
|
for hook in hooks:
|
|
try:
|
|
await hook()
|
|
except BaseException as exc:
|
|
if cleanup_error is None:
|
|
cleanup_error = exc
|
|
if cleanup_error is not None:
|
|
raise cleanup_error
|
|
|
|
async def _run_pre_stop_hooks(self) -> None:
|
|
await self.run_pre_stop_hooks()
|
|
|
|
async def _aclose_dependencies(self) -> None:
|
|
dependencies = self._dependencies
|
|
if dependencies is None or self._dependencies_closed:
|
|
return
|
|
self._dependencies_closed = True
|
|
await dependencies.aclose()
|
|
|
|
@staticmethod
|
|
def _workspace_relpaths_overlap(lhs: Path, rhs: Path) -> bool:
|
|
return lhs == rhs or lhs in rhs.parents or rhs in lhs.parents
|
|
|
|
def _mount_relpaths_within_workspace(self) -> set[Path]:
|
|
root = self._workspace_root_path()
|
|
mount_relpaths: set[Path] = set()
|
|
for _mount_entry, mount_path in self.state.manifest.mount_targets():
|
|
try:
|
|
mount_relpaths.add(mount_path.relative_to(root))
|
|
except ValueError:
|
|
continue
|
|
return mount_relpaths
|
|
|
|
def _overlapping_mount_relpaths(self, rel_path: Path) -> set[Path]:
|
|
return {
|
|
mount_relpath
|
|
for mount_relpath in self._mount_relpaths_within_workspace()
|
|
if self._workspace_relpaths_overlap(rel_path, mount_relpath)
|
|
}
|
|
|
|
def _native_snapshot_requires_tar_fallback(self) -> bool:
|
|
for mount_entry, _mount_path in self.state.manifest.mount_targets():
|
|
if not mount_entry.mount_strategy.supports_native_snapshot_detach(mount_entry):
|
|
return True
|
|
return False
|
|
|
|
def register_persist_workspace_skip_path(self, path: Path | str) -> Path:
|
|
"""Exclude a runtime-created workspace path from future workspace snapshots.
|
|
|
|
Use this for session side effects that are not part of durable workspace state, such as
|
|
generated mount config or ephemeral sink output.
|
|
"""
|
|
|
|
rel_path = Manifest._coerce_rel_path(path)
|
|
Manifest._validate_rel_path(rel_path)
|
|
if rel_path in (Path(""), Path(".")):
|
|
raise ValueError("Persist workspace skip paths must target a concrete relative path.")
|
|
overlapping_mounts = self._overlapping_mount_relpaths(rel_path)
|
|
if overlapping_mounts:
|
|
overlapping_mount = min(overlapping_mounts, key=lambda p: (len(p.parts), p.as_posix()))
|
|
raise MountConfigError(
|
|
message="persist workspace skip path must not overlap mount path",
|
|
context={
|
|
"skip_path": rel_path.as_posix(),
|
|
"mount_path": overlapping_mount.as_posix(),
|
|
},
|
|
)
|
|
|
|
if self._runtime_persist_workspace_skip_relpaths is None:
|
|
self._runtime_persist_workspace_skip_relpaths = set()
|
|
self._runtime_persist_workspace_skip_relpaths.add(rel_path)
|
|
return rel_path
|
|
|
|
def _persist_workspace_skip_relpaths(self) -> set[Path]:
|
|
skip_paths = set(self.state.manifest.ephemeral_persistence_paths())
|
|
if self._runtime_persist_workspace_skip_relpaths:
|
|
skip_paths.update(self._runtime_persist_workspace_skip_relpaths)
|
|
return skip_paths
|
|
|
|
async def exec(
|
|
self,
|
|
*command: str | Path,
|
|
timeout: float | None = None,
|
|
shell: bool | list[str] = True,
|
|
user: str | User | None = None,
|
|
) -> ExecResult:
|
|
"""Execute a command inside the session.
|
|
|
|
:param command: Command and args (will be stringified).
|
|
:param timeout: Optional wall-clock timeout in seconds.
|
|
:param shell: Whether to run this command in a shell. If ``True`` is provided,
|
|
the command will be run prefixed by ``sh -lc``. A custom shell prefix may be used
|
|
by providing a list.
|
|
|
|
:returns: An ``ExecResult`` containing stdout/stderr and exit code.
|
|
|
|
:raises TimeoutError: If the sandbox cannot complete within `timeout`.
|
|
"""
|
|
|
|
sanitized_command = self._prepare_exec_command(*command, shell=shell, user=user)
|
|
return await self._exec_internal(*sanitized_command, timeout=timeout)
|
|
|
|
async def resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
|
|
self._assert_exposed_port_configured(port)
|
|
return await self._resolve_exposed_port(port)
|
|
|
|
def _assert_exposed_port_configured(self, port: int) -> None:
|
|
if port not in self.state.exposed_ports:
|
|
raise ExposedPortUnavailableError(
|
|
port=port,
|
|
exposed_ports=self.state.exposed_ports,
|
|
reason="not_configured",
|
|
)
|
|
|
|
def _prepare_exec_command(
|
|
self,
|
|
*command: str | Path,
|
|
shell: bool | list[str],
|
|
user: str | User | None,
|
|
) -> list[str]:
|
|
sanitized_command = [str(c) for c in command]
|
|
|
|
if shell:
|
|
joined = (
|
|
sanitized_command[0]
|
|
if len(sanitized_command) == 1
|
|
else shlex.join(sanitized_command)
|
|
)
|
|
if isinstance(shell, list):
|
|
sanitized_command = shell + [joined]
|
|
else:
|
|
sanitized_command = ["sh", "-lc", joined]
|
|
|
|
if user:
|
|
if isinstance(user, User):
|
|
user = user.name
|
|
|
|
assert isinstance(user, str)
|
|
|
|
sanitized_command = ["sudo", "-u", user, "--"] + sanitized_command
|
|
|
|
return sanitized_command
|
|
|
|
def _resolve_pty_session_entry(
|
|
self, *, pty_processes: Mapping[int, _PtyEntryT], session_id: int
|
|
) -> _PtyEntryT:
|
|
entry = pty_processes.get(session_id)
|
|
if entry is None:
|
|
raise PtySessionNotFoundError(session_id=session_id)
|
|
return entry
|
|
|
|
async def pty_exec_start(
|
|
self,
|
|
*command: str | Path,
|
|
timeout: float | None = None,
|
|
shell: bool | list[str] = True,
|
|
user: str | User | None = None,
|
|
tty: bool = False,
|
|
yield_time_s: float | None = None,
|
|
max_output_tokens: int | None = None,
|
|
) -> PtyExecUpdate:
|
|
_ = (command, timeout, shell, user, tty, yield_time_s, max_output_tokens)
|
|
raise NotImplementedError("PTY execution is not supported by this sandbox session")
|
|
|
|
async def pty_write_stdin(
|
|
self,
|
|
*,
|
|
session_id: int,
|
|
chars: str,
|
|
yield_time_s: float | None = None,
|
|
max_output_tokens: int | None = None,
|
|
) -> PtyExecUpdate:
|
|
_ = (session_id, chars, yield_time_s, max_output_tokens)
|
|
raise NotImplementedError("PTY execution is not supported by this sandbox session")
|
|
|
|
async def pty_terminate_all(self) -> None:
|
|
return
|
|
|
|
@abc.abstractmethod
|
|
async def _exec_internal(
|
|
self,
|
|
*command: str | Path,
|
|
timeout: float | None = None,
|
|
) -> ExecResult: ...
|
|
|
|
async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
|
|
raise ExposedPortUnavailableError(
|
|
port=port,
|
|
exposed_ports=self.state.exposed_ports,
|
|
reason="backend_unavailable",
|
|
context={"backend": type(self).__name__},
|
|
)
|
|
|
|
def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
|
|
return ()
|
|
|
|
def _current_runtime_helper_cache_key(self) -> object | None:
|
|
return None
|
|
|
|
def _sync_runtime_helper_install_cache(self) -> None:
|
|
current_key = self._current_runtime_helper_cache_key()
|
|
cached_key = self._runtime_helper_cache_key
|
|
if cached_key is _RUNTIME_HELPER_CACHE_KEY_UNSET:
|
|
self._runtime_helper_cache_key = current_key
|
|
return
|
|
if cached_key != current_key:
|
|
self._runtime_helpers_installed = None
|
|
self._runtime_helper_cache_key = current_key
|
|
|
|
async def _ensure_runtime_helper_installed(self, helper: RuntimeHelperScript) -> PurePath:
|
|
self._sync_runtime_helper_install_cache()
|
|
installed = self._runtime_helpers_installed
|
|
if installed is None:
|
|
installed = set()
|
|
self._runtime_helpers_installed = installed
|
|
|
|
install_path = helper.install_path
|
|
if install_path in installed:
|
|
probe = await self.exec(*helper.present_command(), shell=False)
|
|
if probe.ok():
|
|
return install_path
|
|
self._sync_runtime_helper_install_cache()
|
|
installed = self._runtime_helpers_installed
|
|
if installed is None:
|
|
installed = set()
|
|
self._runtime_helpers_installed = installed
|
|
installed.discard(install_path)
|
|
|
|
result = await self.exec(*helper.install_command(), shell=False)
|
|
if not result.ok():
|
|
raise ExecNonZeroError(
|
|
result,
|
|
command=("install_runtime_helper", str(install_path)),
|
|
)
|
|
|
|
self._sync_runtime_helper_install_cache()
|
|
installed = self._runtime_helpers_installed
|
|
if installed is None:
|
|
installed = set()
|
|
self._runtime_helpers_installed = installed
|
|
installed.add(install_path)
|
|
return install_path
|
|
|
|
async def _ensure_runtime_helpers(self) -> None:
|
|
for helper in self._runtime_helpers():
|
|
await self._ensure_runtime_helper_installed(helper)
|
|
|
|
def _workspace_path_policy(self) -> WorkspacePathPolicy:
|
|
root = self.state.manifest.root
|
|
grants_key = tuple(
|
|
(grant.path, grant.read_only) for grant in self.state.manifest.extra_path_grants
|
|
)
|
|
cached = self._workspace_path_policy_cache
|
|
if cached is not None and cached[0] == root and cached[1] == grants_key:
|
|
return cached[2]
|
|
|
|
policy = WorkspacePathPolicy(
|
|
root=root,
|
|
extra_path_grants=self.state.manifest.extra_path_grants,
|
|
)
|
|
self._workspace_path_policy_cache = (root, grants_key, policy)
|
|
return policy
|
|
|
|
def _workspace_root_path(self) -> Path:
|
|
return posix_path_as_path(self._workspace_path_policy().sandbox_root())
|
|
|
|
async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
|
|
return self.normalize_path(path, for_write=for_write)
|
|
|
|
async def _validate_remote_path_access(
|
|
self,
|
|
path: Path | str,
|
|
*,
|
|
for_write: bool = False,
|
|
) -> Path:
|
|
"""Validate an SDK file path against the remote sandbox filesystem before IO.
|
|
|
|
The returned path is the normalized workspace path, not the resolved realpath. This keeps
|
|
safe leaf symlink operations working normally, such as removing a symlink instead of its
|
|
target, while still rejecting paths whose resolved remote target escapes all allowed roots.
|
|
"""
|
|
|
|
path_policy = self._workspace_path_policy()
|
|
root = path_policy.sandbox_root()
|
|
workspace_path = path_policy.normalize_sandbox_path(path, for_write=for_write)
|
|
original_path = coerce_posix_path(path)
|
|
helper_path = await self._ensure_runtime_helper_installed(RESOLVE_WORKSPACE_PATH_HELPER)
|
|
extra_grant_args = tuple(
|
|
arg
|
|
for root, read_only in path_policy.extra_path_grant_rules()
|
|
for arg in (root.as_posix(), "1" if read_only else "0")
|
|
)
|
|
command = (
|
|
str(helper_path),
|
|
root.as_posix(),
|
|
workspace_path.as_posix(),
|
|
"1" if for_write else "0",
|
|
*extra_grant_args,
|
|
)
|
|
result = await self.exec(*command, shell=False)
|
|
if result.ok():
|
|
resolved = result.stdout.decode("utf-8", errors="replace").strip()
|
|
if resolved:
|
|
# Preserve the requested workspace path so leaf symlinks keep their normal
|
|
# semantics while the remote realpath check still enforces path confinement.
|
|
return posix_path_as_path(workspace_path)
|
|
raise ExecTransportError(
|
|
command=(
|
|
"resolve_workspace_path",
|
|
root.as_posix(),
|
|
workspace_path.as_posix(),
|
|
"1" if for_write else "0",
|
|
*extra_grant_args,
|
|
),
|
|
context={
|
|
"reason": "empty_stdout",
|
|
"exit_code": result.exit_code,
|
|
"stdout": "",
|
|
"stderr": result.stderr.decode("utf-8", errors="replace"),
|
|
},
|
|
)
|
|
|
|
reason: Literal["absolute", "escape_root"] = (
|
|
"absolute" if original_path.is_absolute() else "escape_root"
|
|
)
|
|
if result.exit_code == 111:
|
|
raise InvalidManifestPathError(
|
|
rel=original_path.as_posix(),
|
|
reason=reason,
|
|
context={
|
|
"resolved_path": result.stderr.decode("utf-8", errors="replace").strip(),
|
|
},
|
|
)
|
|
if result.exit_code == 113:
|
|
raise ValueError(result.stderr.decode("utf-8", errors="replace").strip())
|
|
if result.exit_code == 114:
|
|
stderr = result.stderr.decode("utf-8", errors="replace")
|
|
context: dict[str, object] = {"reason": "read_only_extra_path_grant"}
|
|
for line in stderr.splitlines():
|
|
if line.startswith("read-only extra path grant: "):
|
|
context["grant_path"] = line.removeprefix("read-only extra path grant: ")
|
|
elif line.startswith("resolved path: "):
|
|
context["resolved_path"] = line.removeprefix("resolved path: ")
|
|
raise WorkspaceArchiveWriteError(
|
|
path=posix_path_for_error(workspace_path), context=context
|
|
)
|
|
raise ExecNonZeroError(
|
|
result,
|
|
command=(
|
|
"resolve_workspace_path",
|
|
root.as_posix(),
|
|
workspace_path.as_posix(),
|
|
"1" if for_write else "0",
|
|
*extra_grant_args,
|
|
),
|
|
)
|
|
|
|
@abc.abstractmethod
|
|
async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase:
|
|
"""Read a file from the session's workspace.
|
|
|
|
:param path: Absolute path in the container or path relative to the
|
|
workspace root.
|
|
:param user: Optional sandbox user to perform the read as.
|
|
:returns: A readable file-like object.
|
|
:raises: FileNotFoundError: If the path does not exist.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
async def write(
|
|
self,
|
|
path: Path,
|
|
data: io.IOBase,
|
|
*,
|
|
user: str | User | None = None,
|
|
) -> None:
|
|
"""Write a file into the session's workspace.
|
|
|
|
:param path: Absolute path in the container or path relative to the
|
|
workspace root.
|
|
:param data: A file-like object positioned at the start of the payload.
|
|
:param user: Optional sandbox user to perform the write as.
|
|
"""
|
|
|
|
async def _check_read_with_exec(
|
|
self, path: Path | str, *, user: str | User | None = None
|
|
) -> Path:
|
|
workspace_path = await self._validate_path_access(path)
|
|
path_arg = sandbox_path_str(workspace_path)
|
|
cmd = ("sh", "-lc", '[ -r "$1" ]', "sh", path_arg)
|
|
result = await self.exec(*cmd, shell=False, user=user)
|
|
if not result.ok():
|
|
raise WorkspaceReadNotFoundError(
|
|
path=posix_path_as_path(coerce_posix_path(path)),
|
|
context={
|
|
"command": ["sh", "-lc", "<read_access_check>", path_arg],
|
|
"stdout": result.stdout.decode("utf-8", errors="replace"),
|
|
"stderr": result.stderr.decode("utf-8", errors="replace"),
|
|
},
|
|
)
|
|
return workspace_path
|
|
|
|
async def _check_write_with_exec(
|
|
self, path: Path | str, *, user: str | User | None = None
|
|
) -> Path:
|
|
workspace_path = await self._validate_path_access(path, for_write=True)
|
|
path_arg = sandbox_path_str(workspace_path)
|
|
cmd = ("sh", "-lc", _WRITE_ACCESS_CHECK_SCRIPT, "sh", path_arg)
|
|
result = await self.exec(*cmd, shell=False, user=user)
|
|
if not result.ok():
|
|
raise WorkspaceArchiveWriteError(
|
|
path=workspace_path,
|
|
context={
|
|
"command": ["sh", "-lc", "<write_access_check>", path_arg],
|
|
"stdout": result.stdout.decode("utf-8", errors="replace"),
|
|
"stderr": result.stderr.decode("utf-8", errors="replace"),
|
|
},
|
|
)
|
|
return workspace_path
|
|
|
|
async def _check_mkdir_with_exec(
|
|
self,
|
|
path: Path | str,
|
|
*,
|
|
parents: bool = False,
|
|
user: str | User | None = None,
|
|
) -> Path:
|
|
workspace_path = await self._validate_path_access(path, for_write=True)
|
|
parents_flag = "1" if parents else "0"
|
|
path_arg = sandbox_path_str(workspace_path)
|
|
cmd = ("sh", "-lc", _MKDIR_ACCESS_CHECK_SCRIPT, "sh", path_arg, parents_flag)
|
|
result = await self.exec(*cmd, shell=False, user=user)
|
|
if not result.ok():
|
|
raise WorkspaceArchiveWriteError(
|
|
path=workspace_path,
|
|
context={
|
|
"command": [
|
|
"sh",
|
|
"-lc",
|
|
"<mkdir_access_check>",
|
|
path_arg,
|
|
parents_flag,
|
|
],
|
|
"stdout": result.stdout.decode("utf-8", errors="replace"),
|
|
"stderr": result.stderr.decode("utf-8", errors="replace"),
|
|
},
|
|
)
|
|
return workspace_path
|
|
|
|
async def _check_rm_with_exec(
|
|
self,
|
|
path: Path | str,
|
|
*,
|
|
recursive: bool = False,
|
|
user: str | User | None = None,
|
|
) -> Path:
|
|
workspace_path = await self._validate_path_access(path, for_write=True)
|
|
recursive_flag = "1" if recursive else "0"
|
|
path_arg = sandbox_path_str(workspace_path)
|
|
cmd = ("sh", "-lc", _RM_ACCESS_CHECK_SCRIPT, "sh", path_arg, recursive_flag)
|
|
result = await self.exec(*cmd, shell=False, user=user)
|
|
if not result.ok():
|
|
raise WorkspaceArchiveWriteError(
|
|
path=workspace_path,
|
|
context={
|
|
"command": [
|
|
"sh",
|
|
"-lc",
|
|
"<rm_access_check>",
|
|
path_arg,
|
|
recursive_flag,
|
|
],
|
|
"stdout": result.stdout.decode("utf-8", errors="replace"),
|
|
"stderr": result.stderr.decode("utf-8", errors="replace"),
|
|
},
|
|
)
|
|
return workspace_path
|
|
|
|
@abc.abstractmethod
|
|
async def running(self) -> bool:
|
|
"""
|
|
:returns: whether the underlying sandbox is currently running.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
async def persist_workspace(self) -> io.IOBase:
|
|
"""Serialize the session's workspace into a byte stream.
|
|
|
|
:returns: A readable byte stream representing the workspace contents.
|
|
Portable tar streams must use workspace-relative member paths rather than
|
|
embedding the source backend's workspace root directory.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
|
"""Populate the session's workspace from a serialized byte stream.
|
|
|
|
:param data: A readable byte stream as produced by `persist_workspace`.
|
|
Portable tar streams are extracted underneath this session's workspace root.
|
|
"""
|
|
|
|
async def ls(
|
|
self,
|
|
path: Path | str,
|
|
*,
|
|
user: str | User | None = None,
|
|
) -> list[FileEntry]:
|
|
"""List directory contents.
|
|
|
|
:param path: Path to list.
|
|
:param user: Optional sandbox user to list as.
|
|
:returns: A list of `FileEntry` objects.
|
|
"""
|
|
path = await self._validate_path_access(path)
|
|
|
|
path_arg = sandbox_path_str(path)
|
|
cmd = ("ls", "-la", "--", path_arg)
|
|
result = await self.exec(*cmd, shell=False, user=user)
|
|
if not result.ok():
|
|
raise ExecNonZeroError(result, command=cmd)
|
|
|
|
return parse_ls_la(result.stdout.decode("utf-8", errors="replace"), base=path_arg)
|
|
|
|
async def rm(
|
|
self,
|
|
path: Path | str,
|
|
*,
|
|
recursive: bool = False,
|
|
user: str | User | None = None,
|
|
) -> None:
|
|
"""Remove a file or directory.
|
|
|
|
:param path: Path to remove.
|
|
:param recursive: If true, remove directories recursively.
|
|
:param user: Optional sandbox user to remove as.
|
|
"""
|
|
path = await self._validate_path_access(path, for_write=True)
|
|
|
|
cmd: list[str] = ["rm"]
|
|
if recursive:
|
|
cmd.append("-rf")
|
|
cmd.extend(["--", sandbox_path_str(path)])
|
|
|
|
result = await self.exec(*cmd, shell=False, user=user)
|
|
if not result.ok():
|
|
raise ExecNonZeroError(result, command=cmd)
|
|
|
|
async def mkdir(
|
|
self,
|
|
path: Path | str,
|
|
*,
|
|
parents: bool = False,
|
|
user: str | User | None = None,
|
|
) -> None:
|
|
"""Create a directory.
|
|
|
|
:param path: Directory to create on the remote.
|
|
:param parents: If true, create missing parents.
|
|
:param user: Optional sandbox user to create the directory as.
|
|
"""
|
|
path = await self._validate_path_access(path, for_write=True)
|
|
|
|
cmd: list[str] = ["mkdir"]
|
|
if parents:
|
|
cmd.append("-p")
|
|
cmd.append(sandbox_path_str(path))
|
|
|
|
result = await self.exec(*cmd, shell=False, user=user)
|
|
if not result.ok():
|
|
raise ExecNonZeroError(result, command=cmd)
|
|
|
|
async def extract(
|
|
self,
|
|
path: Path | str,
|
|
data: io.IOBase,
|
|
*,
|
|
compression_scheme: Literal["tar", "zip"] | None = None,
|
|
archive_limits: SandboxArchiveLimits | None = None,
|
|
) -> None:
|
|
"""
|
|
Write a compressed archive to a destination on the remote.
|
|
Optionally extract the archive once written.
|
|
|
|
:param path: Path on the host machine to extract to
|
|
:param data: a file-like io stream.
|
|
:param compression_scheme: either "tar" or "zip". If not provided,
|
|
it will try to infer from the path.
|
|
:param archive_limits: optional per-call archive resource limits. If omitted,
|
|
the session default is used.
|
|
"""
|
|
if archive_limits is not None:
|
|
archive_limits.validate()
|
|
effective_archive_limits = (
|
|
archive_limits if archive_limits is not None else self._archive_limits
|
|
)
|
|
|
|
await archive_ops.extract_archive(
|
|
self,
|
|
path,
|
|
data,
|
|
compression_scheme=compression_scheme,
|
|
archive_limits=effective_archive_limits,
|
|
)
|
|
|
|
async def apply_patch(
|
|
self,
|
|
operations: ApplyPatchOperation
|
|
| dict[str, object]
|
|
| list[ApplyPatchOperation | dict[str, object]],
|
|
*,
|
|
patch_format: PatchFormat | Literal["v4a"] = "v4a",
|
|
) -> str:
|
|
return await WorkspaceEditor(self).apply_patch(operations, patch_format=patch_format)
|
|
|
|
def normalize_path(self, path: Path | str, *, for_write: bool = False) -> Path:
|
|
policy = self._workspace_path_policy()
|
|
return policy.normalize_path(path, for_write=for_write)
|
|
|
|
def describe(self) -> str:
|
|
return self.state.manifest.describe()
|
|
|
|
async def _extract_tar_archive(
|
|
self,
|
|
*,
|
|
archive_path: Path,
|
|
destination_root: Path,
|
|
data: io.IOBase,
|
|
archive_limits: SandboxArchiveLimits | None = None,
|
|
) -> None:
|
|
await archive_ops.extract_tar_archive(
|
|
self,
|
|
archive_path=archive_path,
|
|
destination_root=destination_root,
|
|
data=data,
|
|
archive_limits=archive_limits,
|
|
)
|
|
|
|
async def _extract_zip_archive(
|
|
self,
|
|
*,
|
|
archive_path: Path,
|
|
destination_root: Path,
|
|
data: io.IOBase,
|
|
archive_limits: SandboxArchiveLimits | None = None,
|
|
) -> None:
|
|
await archive_ops.extract_zip_archive(
|
|
self,
|
|
archive_path=archive_path,
|
|
destination_root=destination_root,
|
|
data=data,
|
|
archive_limits=archive_limits,
|
|
)
|
|
|
|
@staticmethod
|
|
def _safe_zip_member_rel_path(member) -> Path | None:
|
|
return archive_ops.safe_zip_member_rel_path(member)
|
|
|
|
async def _apply_manifest(
|
|
self,
|
|
*,
|
|
only_ephemeral: bool = False,
|
|
provision_accounts: bool = True,
|
|
) -> MaterializationResult:
|
|
return await manifest_ops.apply_manifest(
|
|
self,
|
|
only_ephemeral=only_ephemeral,
|
|
provision_accounts=provision_accounts,
|
|
)
|
|
|
|
async def apply_manifest(self, *, only_ephemeral: bool = False) -> MaterializationResult:
|
|
return await self._apply_manifest(
|
|
only_ephemeral=only_ephemeral,
|
|
provision_accounts=not only_ephemeral,
|
|
)
|
|
|
|
async def provision_manifest_accounts(self) -> None:
|
|
await manifest_ops.provision_manifest_accounts(self)
|
|
|
|
def should_provision_manifest_accounts_on_resume(self) -> bool:
|
|
"""Return whether resume should reprovision manifest-managed users and groups."""
|
|
|
|
return not self._system_state_preserved_on_start()
|
|
|
|
async def _reapply_ephemeral_manifest_on_resume(self) -> None:
|
|
"""Rebuild ephemeral manifest state without touching persisted workspace files."""
|
|
|
|
await self.apply_manifest(only_ephemeral=True)
|
|
|
|
async def _restore_snapshot_into_workspace_on_resume(self) -> None:
|
|
"""Clear the live workspace contents and repopulate them from the persisted snapshot."""
|
|
|
|
await snapshot_lifecycle.restore_snapshot_into_workspace_on_resume(self)
|
|
|
|
async def _live_workspace_matches_snapshot_on_resume(self) -> bool:
|
|
"""Return whether the running sandbox workspace definitely matches the stored snapshot."""
|
|
|
|
return await snapshot_lifecycle.live_workspace_matches_snapshot_on_resume(self)
|
|
|
|
async def _can_skip_snapshot_restore_on_resume(self, *, is_running: bool) -> bool:
|
|
"""Return whether resume can safely reuse the running workspace without restore."""
|
|
|
|
return await snapshot_lifecycle.can_skip_snapshot_restore_on_resume(
|
|
self,
|
|
is_running=is_running,
|
|
)
|
|
|
|
def _snapshot_fingerprint_cache_path(self) -> Path:
|
|
"""Return the runtime-owned path for this session's cached snapshot fingerprint."""
|
|
|
|
return snapshot_lifecycle.snapshot_fingerprint_cache_path(self)
|
|
|
|
def _workspace_fingerprint_skip_relpaths(self) -> set[Path]:
|
|
"""Return workspace paths that should be omitted from snapshot fingerprinting."""
|
|
|
|
return snapshot_lifecycle.workspace_fingerprint_skip_relpaths(self)
|
|
|
|
async def _compute_and_cache_snapshot_fingerprint(self) -> dict[str, str]:
|
|
"""Compute the current workspace fingerprint in-container and atomically cache it."""
|
|
|
|
return await snapshot_lifecycle.compute_and_cache_snapshot_fingerprint(self)
|
|
|
|
async def _read_cached_snapshot_fingerprint(self) -> dict[str, str]:
|
|
"""Read the cached snapshot fingerprint record from the running sandbox."""
|
|
|
|
return await snapshot_lifecycle.read_cached_snapshot_fingerprint(self)
|
|
|
|
def _parse_snapshot_fingerprint_record(
|
|
self, payload: bytes | bytearray | str
|
|
) -> dict[str, str]:
|
|
"""Validate and normalize a cached snapshot fingerprint JSON payload."""
|
|
|
|
return snapshot_lifecycle.parse_snapshot_fingerprint_record(payload)
|
|
|
|
async def _delete_cached_snapshot_fingerprint_best_effort(self) -> None:
|
|
"""Remove the cached snapshot fingerprint file without raising on cleanup failure."""
|
|
|
|
await snapshot_lifecycle.delete_cached_snapshot_fingerprint_best_effort(self)
|
|
|
|
def _snapshot_fingerprint_version(self) -> str:
|
|
"""Return the version tag for the current snapshot fingerprint algorithm."""
|
|
|
|
return snapshot_lifecycle.snapshot_fingerprint_version()
|
|
|
|
def _resume_manifest_digest(self) -> str:
|
|
"""Return a stable digest of the manifest state that affects resume correctness."""
|
|
|
|
return snapshot_lifecycle.resume_manifest_digest(self)
|
|
|
|
async def _apply_entry_batch(
|
|
self,
|
|
entries: Sequence[tuple[Path, BaseEntry]],
|
|
*,
|
|
base_dir: Path,
|
|
) -> list[MaterializedFile]:
|
|
return await manifest_ops.apply_entry_batch(self, entries, base_dir=base_dir)
|
|
|
|
def _manifest_base_dir(self) -> Path:
|
|
return Path.cwd()
|
|
|
|
async def _exec_checked_nonzero(self, *command: str | Path) -> ExecResult:
|
|
result = await self.exec(*command, shell=False)
|
|
if not result.ok():
|
|
raise ExecNonZeroError(result, command=command)
|
|
return result
|
|
|
|
async def _clear_workspace_root_on_resume(self) -> None:
|
|
"""
|
|
Best-effort cleanup step for snapshot resume.
|
|
|
|
We intentionally clear *contents* of the workspace root rather than deleting the root
|
|
directory itself. Some sandboxes configure their process working directory to the workspace
|
|
root (e.g. Modal sandboxes), and deleting the directory can make subsequent exec() calls
|
|
fail with "failed to find initial working directory".
|
|
"""
|
|
|
|
await snapshot_lifecycle.clear_workspace_root_on_resume(self)
|
|
|
|
def _workspace_resume_mount_skip_relpaths(self) -> set[Path]:
|
|
return snapshot_lifecycle.workspace_resume_mount_skip_relpaths(self)
|
|
|
|
async def _clear_workspace_dir_on_resume_pruned(
|
|
self,
|
|
*,
|
|
current_dir: Path,
|
|
skip_rel_paths: set[Path],
|
|
) -> None:
|
|
await snapshot_lifecycle.clear_workspace_dir_on_resume_pruned(
|
|
self,
|
|
current_dir=current_dir,
|
|
skip_rel_paths=skip_rel_paths,
|
|
)
|