Skip to content
Closed
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
37 changes: 31 additions & 6 deletions src/agents/extensions/sandbox/modal/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
from ....sandbox.util.tar_utils import UnsafeTarMemberError, validate_tar_bytes
from ....sandbox.workspace_paths import (
coerce_posix_path,
normalize_posix_path,
posix_path_as_path,
posix_path_for_error,
sandbox_path_str,
Expand Down Expand Up @@ -1421,7 +1422,7 @@ async def _persist_workspace_via_snapshot_directory(self) -> io.IOBase:
assert self._sandbox is not None
if not hasattr(self._sandbox, "snapshot_directory"):
return await self._persist_workspace_via_tar()
if self._native_snapshot_requires_tar_fallback():
if self._native_snapshot_requires_tar_fallback(snapshot_root=root):
return await self._persist_workspace_via_tar()
plain_skip = self._modal_snapshot_plain_skip_relpaths(root)
skip_abs = [root / rel for rel in sorted(plain_skip, key=lambda p: p.as_posix())]
Expand Down Expand Up @@ -1509,7 +1510,9 @@ async def restore_ephemeral_paths_or_raise() -> None:
},
)

for mount_entry, mount_path in self._snapshot_directory_mount_targets_to_restore(root):
for mount_entry, mount_path in self._snapshot_directory_mount_targets_to_restore(
root, include_ephemeral=True
):
transition_error, transition_cancelled = await _settle_mount_transition(
self,
mount_entry.mount_strategy.teardown_for_snapshot(
Expand Down Expand Up @@ -1938,14 +1941,19 @@ async def _run_restore() -> None:
cause=e,
) from e

def _snapshot_directory_mount_targets_to_restore(self, root: Path) -> list[tuple[Mount, Path]]:
def _snapshot_directory_mount_targets_to_restore(
self,
root: Path,
*,
include_ephemeral: bool = False,
) -> list[tuple[Mount, Path]]:
mount_targets: list[tuple[Mount, Path]] = []
for mount_entry, mount_path in self.state.manifest.mount_targets():
if mount_entry.ephemeral:
if mount_entry.ephemeral and not include_ephemeral:
continue
if isinstance(mount_entry.mount_strategy, ModalCloudBucketMountStrategy):
continue
if mount_path != root and root not in mount_path.parents:
if not self._sandbox_paths_overlap(root, mount_path):
continue
mount_targets.append((mount_entry, mount_path))
return mount_targets
Expand Down Expand Up @@ -2063,10 +2071,11 @@ def _validate_manifest_for_workspace_persistence(
if workspace_persistence != _WORKSPACE_PERSISTENCE_SNAPSHOT_DIRECTORY:
return

root = posix_path_as_path(coerce_posix_path(manifest.root))
root = posix_path_as_path(normalize_posix_path(manifest.root))
for mount_entry, mount_path in manifest.mount_targets():
if not isinstance(mount_entry.mount_strategy, ModalCloudBucketMountStrategy):
continue
mount_path = posix_path_as_path(normalize_posix_path(mount_path))
if mount_path == root or root in mount_path.parents:
raise MountConfigError(
message=(
Expand All @@ -2079,6 +2088,18 @@ def _validate_manifest_for_workspace_persistence(
"workspace_persistence": workspace_persistence,
},
)
if mount_path in root.parents:
raise MountConfigError(
message=(
"snapshot_directory is not supported when the workspace root "
"lives inside a Modal cloud bucket mount"
),
context={
"workspace_root": root.as_posix(),
"mount_path": mount_path.as_posix(),
"workspace_persistence": workspace_persistence,
},
)

@redact_mount_error_data
async def create(
Expand Down Expand Up @@ -2302,6 +2323,10 @@ async def resume(
)
_mark_mount_validation_error(error)
raise error
self._validate_manifest_for_workspace_persistence(
manifest=state.manifest,
workspace_persistence=state.workspace_persistence,
)
if state.mount_authority_rebound:
state.sandbox_id = None
state.session_id = uuid.uuid4()
Expand Down
27 changes: 22 additions & 5 deletions src/agents/sandbox/session/base_sandbox_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from ..workspace_paths import (
WorkspacePathPolicy,
coerce_posix_path,
normalize_posix_path,
posix_path_as_path,
posix_path_for_error,
sandbox_path_str,
Expand Down Expand Up @@ -578,8 +579,14 @@ async def _aclose_dependencies(self) -> None:
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 _sandbox_paths_overlap(lhs: PurePath, rhs: PurePath) -> bool:
lhs_posix = normalize_posix_path(lhs)
rhs_posix = normalize_posix_path(rhs)
return (
lhs_posix == rhs_posix
or lhs_posix in rhs_posix.parents
or rhs_posix in lhs_posix.parents
)

def _mount_relpaths_within_workspace(self) -> set[Path]:
root = self._workspace_root_path()
Expand All @@ -595,11 +602,21 @@ 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)
if self._sandbox_paths_overlap(rel_path, mount_relpath)
}

def _native_snapshot_requires_tar_fallback(self) -> bool:
for mount_entry, _mount_path in self.state.manifest.mount_targets():
def _native_snapshot_requires_tar_fallback(self, *, snapshot_root: Path | None = None) -> bool:
"""Return whether mounts prevent a safe native snapshot.

Filesystem-wide native snapshots pass no root and conservatively inspect every mount.
Directory snapshots may ignore mounts that cannot overlap the resolved snapshot root.
"""

for mount_entry, mount_path in self.state.manifest.mount_targets():
if snapshot_root is not None and not self._sandbox_paths_overlap(
snapshot_root, mount_path
):
continue
if not mount_entry.mount_strategy.supports_native_snapshot_detach(mount_entry):
return True
return False
Expand Down
13 changes: 11 additions & 2 deletions src/agents/sandbox/workspace_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ def coerce_posix_path(path: str | PurePath) -> PurePosixPath:
return PurePosixPath(path)


def normalize_posix_path(path: str | PurePath) -> PurePosixPath:
"""Return a normalized POSIX path for sandbox filesystem comparisons."""

normalized = posixpath.normpath(coerce_posix_path(path).as_posix())
if normalized.startswith("//"):
normalized = f"/{normalized.lstrip('/')}"
return PurePosixPath(normalized)


def windows_absolute_path(path: str | PurePath) -> PureWindowsPath | None:
"""Return a Windows absolute path when the input uses Windows absolute syntax."""

Expand Down Expand Up @@ -498,10 +507,10 @@ def _absolute_workspace_posix_path(self, path: PurePosixPath) -> PurePosixPath:
def _absolute_posix_path(self, path: PurePosixPath) -> PurePosixPath:
root = self._normalized_root()
raw_candidate = path.as_posix() if path.is_absolute() else str(root / path.as_posix())
return PurePosixPath(posixpath.normpath(str(raw_candidate)))
return normalize_posix_path(str(raw_candidate))

def _normalized_root(self) -> PurePosixPath:
return PurePosixPath(posixpath.normpath(self._sandbox_root.as_posix()))
return normalize_posix_path(self._sandbox_root)

@staticmethod
def _path_exists(path: Path) -> bool:
Expand Down
Loading