Describe the bug
A UnixLocal sandbox session cannot resume from its own workspace snapshot when the workspace contains hardlinked files. The resume path clears the workspace root before it hydrates, so the session ends up with an empty workspace and the archive is never restored.
UnixLocalSandboxSession.persist_workspace archives the workspace with tarfile.add(root, arcname="."). CPython's tarfile emits a LNKTYPE member for any path whose inode it has already recorded during the same archive, so two workspace paths that share an inode produce a snapshot containing a hardlink member. hydrate_workspace extracts through safe_extract_tarfile, which rejects every hardlink member (hardlink member not allowed).
Persisting itself succeeds, so nothing reports the problem until a later resume has already discarded the live workspace.
This is easy to hit in ordinary use. UnixLocal sets HOME to the workspace root, so a uv cache lives inside the workspace, and uv hardlinks from that cache into the environment it installs. A package store, cp -al, or a local clone inside the workspace produces the same layout.
One more detail that matters for a fix: tarfile.gettarinfo records an inode's first path before the filter callback runs. If that first path is one the persist skip list excludes, the second path is written as a link to a member that is not present in the archive at all.
Debug information
- Agents SDK version: v0.22.3 (also reproduces on
main)
- Python version: 3.14.7 (the underlying
tarfile behavior is the same on 3.10)
- Operating system: macOS 26.5.2
- Model and model provider: not applicable, no model call is involved
- Does the issue reproduce with the latest Agents SDK release? Yes
- Does the issue occur consistently or intermittently? Consistently, whenever two archived workspace paths share an inode
Traceback (most recent call last):
...
File ".../agents/sandbox/util/tar_utils.py", line 96, in safe_tar_member_rel_path
raise UnsafeTarMemberError(member=member.name, reason="hardlink member not allowed")
agents.sandbox.errors.WorkspaceArchiveWriteError: failed to write archive for path: /tmp/.../workspace
Repro steps
import asyncio
import tempfile
from pathlib import Path
from agents.sandbox import LocalSnapshotSpec, Manifest
from agents.sandbox.sandboxes import UnixLocalSandboxClient
async def main() -> None:
workspace = Path(tempfile.mkdtemp()) / "workspace"
snapshots = Path(tempfile.mkdtemp())
client = UnixLocalSandboxClient()
session = await client.create(
manifest=Manifest(root=str(workspace)),
snapshot=LocalSnapshotSpec(base_path=snapshots),
)
await session.start()
# Any two workspace paths that share an inode, such as a uv cache entry and the
# copy uv hardlinks into the environment it installs.
await session.exec(
"mkdir -p cache venv && echo 'VALUE = 1' > cache/mod.py && ln cache/mod.py venv/mod.py"
)
await session.stop()
resumed = await client.resume(session.state)
await resumed.start() # raises WorkspaceArchiveWriteError
print("resumed", (workspace / "venv" / "mod.py").read_text())
asyncio.run(main())
Replacing ln with cp in that script makes the same run succeed.
Expected behavior
A workspace that a session persisted should hydrate back into that session. Preserving hardlink identity does not seem necessary here: restoring each path as an independent regular file would match the Docker backend, which stages with cp -R and therefore never produces hardlink members.
Whatever the chosen semantics, the failure should not be able to surface only after the resume path has already cleared the live workspace.
Additional context
The rejection on the extraction side looks deliberate and is pinned by tests in tests/sandbox/test_tar_utils.py, tests/extensions/sandbox/test_modal.py, and tests/extensions/sandbox/test_blaxel.py, so the mismatch appears to be on the producing side.
Backends that build the archive with tar inside the sandbox, such as blaxel, daytona, e2b, modal, vercel, and runloop, look like they can emit hardlink members the same way, since GNU tar preserves hardlinks by default and --hard-dereference is not available in the BusyBox tar that some sandbox images ship. I have not run those backends, so that part is only from reading the code.
Happy to share a small patch for the UnixLocal producer if that would be useful.
Describe the bug
A
UnixLocalsandbox session cannot resume from its own workspace snapshot when the workspace contains hardlinked files. The resume path clears the workspace root before it hydrates, so the session ends up with an empty workspace and the archive is never restored.UnixLocalSandboxSession.persist_workspacearchives the workspace withtarfile.add(root, arcname="."). CPython'starfileemits aLNKTYPEmember for any path whose inode it has already recorded during the same archive, so two workspace paths that share an inode produce a snapshot containing a hardlink member.hydrate_workspaceextracts throughsafe_extract_tarfile, which rejects every hardlink member (hardlink member not allowed).Persisting itself succeeds, so nothing reports the problem until a later resume has already discarded the live workspace.
This is easy to hit in ordinary use.
UnixLocalsetsHOMEto the workspace root, so a uv cache lives inside the workspace, and uv hardlinks from that cache into the environment it installs. A package store,cp -al, or a local clone inside the workspace produces the same layout.One more detail that matters for a fix:
tarfile.gettarinforecords an inode's first path before thefiltercallback runs. If that first path is one the persist skip list excludes, the second path is written as a link to a member that is not present in the archive at all.Debug information
main)tarfilebehavior is the same on 3.10)Repro steps
Replacing
lnwithcpin that script makes the same run succeed.Expected behavior
A workspace that a session persisted should hydrate back into that session. Preserving hardlink identity does not seem necessary here: restoring each path as an independent regular file would match the Docker backend, which stages with
cp -Rand therefore never produces hardlink members.Whatever the chosen semantics, the failure should not be able to surface only after the resume path has already cleared the live workspace.
Additional context
The rejection on the extraction side looks deliberate and is pinned by tests in
tests/sandbox/test_tar_utils.py,tests/extensions/sandbox/test_modal.py, andtests/extensions/sandbox/test_blaxel.py, so the mismatch appears to be on the producing side.Backends that build the archive with
tarinside the sandbox, such asblaxel,daytona,e2b,modal,vercel, andrunloop, look like they can emit hardlink members the same way, since GNUtarpreserves hardlinks by default and--hard-dereferenceis not available in the BusyBoxtarthat some sandbox images ship. I have not run those backends, so that part is only from reading the code.Happy to share a small patch for the
UnixLocalproducer if that would be useful.