Skip to content

Commit 00e2aa4

Browse files
fix(sandbox): preserve symlink targets during snapshot restore (#5208)
1 parent 92a2c60 commit 00e2aa4

4 files changed

Lines changed: 59 additions & 3 deletions

File tree

‎src/agents/sandbox/sandboxes/unix_local.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,16 @@ async def rm(
10371037
except OSError as e:
10381038
raise WorkspaceArchiveWriteError(path=normalized, cause=e) from e
10391039

1040+
async def _remove_workspace_entry_on_resume(self, path: Path) -> None:
1041+
# Snapshot cleanup owns the entry, not the target of a stale symlink.
1042+
target = self.normalize_path(path.parent, for_write=True) / path.name
1043+
try:
1044+
await run_blocking_workspace_io(partial(self._files.rm, target, recursive=True))
1045+
except FileNotFoundError:
1046+
return
1047+
except OSError as e:
1048+
raise WorkspaceArchiveWriteError(path=target, cause=e) from e
1049+
10401050
async def _read_bounded(self, path: Path, *, max_bytes: int) -> bytes:
10411051
return self._files.read_bounded(self.normalize_path(path), max_bytes)
10421052

‎src/agents/sandbox/session/base_sandbox_session.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1473,6 +1473,10 @@ async def _clear_workspace_root_on_resume(self) -> None:
14731473

14741474
await snapshot_lifecycle.clear_workspace_root_on_resume(self)
14751475

1476+
async def _remove_workspace_entry_on_resume(self, path: Path) -> None:
1477+
"""Remove a stale workspace entry before restoring a snapshot."""
1478+
await self.rm(path, recursive=True)
1479+
14761480
def _workspace_resume_mount_skip_relpaths(self) -> set[Path]:
14771481
return snapshot_lifecycle.workspace_resume_mount_skip_relpaths(self)
14781482

‎src/agents/sandbox/session/snapshot_lifecycle.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ async def clear_workspace_dir_on_resume_pruned(
224224
try:
225225
child_rel = child.relative_to(root)
226226
except ValueError:
227-
await session.rm(child, recursive=True)
227+
await session._remove_workspace_entry_on_resume(child)
228228
continue
229229

230230
if child_rel in skip_rel_paths:
@@ -236,10 +236,10 @@ async def clear_workspace_dir_on_resume_pruned(
236236
skip_rel_paths=skip_rel_paths,
237237
)
238238
else:
239-
await session.rm(child, recursive=True)
239+
await session._remove_workspace_entry_on_resume(child)
240240
continue
241241
# `parse_ls_la` filters "." and ".." already; remove everything else recursively.
242-
await session.rm(child, recursive=True)
242+
await session._remove_workspace_entry_on_resume(child)
243243

244244

245245
def _close_best_effort(stream: io.IOBase) -> None:

‎tests/sandbox/test_unix_local.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,48 @@ async def test_unix_local_snapshot_round_trips_hardlinks(
112112
await session.shutdown()
113113

114114

115+
@pytest.mark.asyncio
116+
@pytest.mark.parametrize("target_kind", ["directory", "file", "missing"])
117+
async def test_unix_local_snapshot_resume_removes_stale_link_without_following_target(
118+
tmp_path: Path, target_kind: str
119+
) -> None:
120+
workspace = tmp_path / "workspace"
121+
external = tmp_path / "external"
122+
external.mkdir()
123+
sentinel = external / "keep.txt"
124+
sentinel.write_bytes(b"external data")
125+
target = external if target_kind == "directory" else external / target_kind
126+
if target_kind == "file":
127+
target.write_bytes(b"external file")
128+
client = UnixLocalSandboxClient(inherit_host_environment=False)
129+
session = await client.create(
130+
manifest=Manifest(
131+
root=str(workspace), extra_path_grants=(SandboxPathGrant(path=str(external)),)
132+
),
133+
snapshot=LocalSnapshotSpec(base_path=tmp_path / "snapshots"),
134+
)
135+
await session.start()
136+
(workspace / "original.txt").write_bytes(b"snapshot content")
137+
await session.stop()
138+
(workspace / "original.txt").write_bytes(b"changed after snapshot")
139+
stale_link = workspace / "stale-link"
140+
stale_link.symlink_to(target, target_is_directory=target_kind == "directory")
141+
resumed = await client.resume(session.state)
142+
try:
143+
await resumed.start()
144+
assert (workspace / "original.txt").read_bytes() == b"snapshot content"
145+
assert sentinel.read_bytes() == b"external data"
146+
if target_kind == "file":
147+
assert target.read_bytes() == b"external file"
148+
elif target_kind == "missing":
149+
assert not target.exists()
150+
assert not stale_link.is_symlink()
151+
assert not stale_link.exists()
152+
finally:
153+
await resumed.shutdown()
154+
await session.shutdown()
155+
156+
115157
@pytest.mark.asyncio
116158
@pytest.mark.parametrize("invalid_kind", ["hardlink", "external_symlink", "invalid_tar"])
117159
async def test_unix_local_resume_rejects_invalid_snapshot_before_clearing_workspace(

0 commit comments

Comments
 (0)