fix(sandbox): make Docker persist_workspace archives restorable by hydrate_workspace - #4834
fix(sandbox): make Docker persist_workspace archives restorable by hydrate_workspace#4834coderdailyone wants to merge 2 commits into
Conversation
Docker's persist_workspace() stages a copy of the workspace, has the
daemon archive it, and rewrites the member prefix in Python with
strip_tar_member_prefix(). That rewrite raised UnsafeTarMemberError
("hardlink member not allowed", "unsupported member type") as soon as
the archive contained a hardlink member or a FIFO, so snapshotting a
workspace where uv or pnpm had hardlinked installed packages, or a dev
server had left a FIFO behind, failed outright. An absolute symlink
target under the workspace root survived persist but was refused by
the strict hydrate extractor.
Rewrite those members while stripping the prefix: hardlink members are
stored as regular files carrying the target's payload (the source is
spooled to a temporary file so the earlier member can be re-read),
FIFOs and device nodes are dropped, and, when the caller passes the
workspace root, absolute symlink targets under it become relative to
the link's directory. Absolute targets outside the workspace are left
unchanged for hydrate's policy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BN4v25msJgrjNgac97g1Az
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
This changes persist_workspace() from streaming the Docker archive to fully spooling it to a local tempfile before producing another tempfile. For large workspaces, peak host temp usage becomes roughly the input archive plus the rewritten archive (and hardlink expansion can make the output larger), so a valid workspace can now fail solely because /tmp lacks roughly 2x its size. Could hardlink payload lookup be made bounded without buffering the entire archive, or should this enforce an explicit archive/temp-space limit?
Keep reading the source archive as a stream instead of spooling it to a temporary file first. A hardlink member's payload is read back from the rewritten archive being written (recorded by original member name), so peak temporary usage stays at one archive rather than the source plus the output. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCNKceEs9sPdb6aHK3FqPf
|
Good point, thanks — the spool was the easy way to get the earlier member back, but it doubled the temp footprint. Reworked in the latest revision: the source archive is streamed once again ( |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a64ccf615f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| rewritten.linkname = _relative_symlink_target( | ||
| rewritten.linkname, | ||
| link_name=stripped_name, | ||
| root=symlink_root, | ||
| ) |
There was a problem hiding this comment.
Clear PAX linkpath when relativizing a symlink
When an in-workspace absolute symlink target is long enough to be encoded as a PAX linkpath (typically over 100 bytes), assigning rewritten.linkname here leaves rewritten.pax_headers["linkpath"] set to the original absolute target. dst.addfile() emits that stale header, so reopening the snapshot yields the absolute link and Docker's strict hydrate_workspace() rejects the archive, leaving this supported workspace unrestorable. Remove or update linkpath together with linkname and cover a long target in the regression test.
AGENTS.md reference: AGENTS.md:L145-L147
Useful? React with 👍 / 👎.
This pull request fixes
DockerSandboxSession.persist_workspace()so that workspaces containing hardlinks, FIFOs, or absolute in-workspace symlinks can be snapshotted and restored.Bug
Docker's
persist_workspace()stages a copy of the workspace, asks the daemon for a tar of it, and rewrites the leadingworkspace/prefix in Python viastrip_tar_member_prefix(). That function iterates the members withsafe_tar_member_rel_path(member, allow_symlinks=True), which raises for anything that is not a directory, regular file, or symlink. Docker's archiver represents the second path of a hardlinked file as a hardlink member and keeps FIFOs, so:persist_workspace()onmaina.txt+ln a.txt b.txt(whatuv/pnpmdo for every installed package)UnsafeTarMemberError: hardlink member not allowed— persist failsUnsafeTarMemberError: unsupported member type— persist failsln -s /workspace/a.txt link(absolute link to a file inside the workspace)absolute symlink target not allowedThis is the Docker counterpart of #4831 (UnixLocal), where the same three cases produced an archive that
hydrate_workspace()could not restore.Fix
strip_tar_member_prefix()now emits only members the strict hydrate extractor accepts:relativize_symlinks_under=argument names the workspace root; an absolute symlink target under it is rewritten relative to the link's own directory (/workspace/a.txtfromsub/abs_upbecomes../a.txt). Docker passes its workspace root. Without the argument, symlink targets are untouched, so other callers see no change;The function still validates its output with
validate_tarfile()and its existing prefix and pax-header behavior is unchanged.Evidence
tests/sandbox/test_tar_utils.pybuild aworkspace/…archive shaped like Docker's (regular file, hardlink member, FIFO, absolute in-workspace symlink, relative symlink, external symlink), assert the rewritten members, and run the result throughvalidate_tarfile(..., allow_external_symlink_targets=False)andsafe_extract_tarfile(...)into a temporary root. All three fail onmain.tests/sandbox/test_tar_utils.py,test_extract.py,test_docker.py(195 passed),ruff,mypy, andpyrighton the changed files pass locally (Linux, Python 3.10). No Docker daemon was available here, so the end-to-end Docker path is covered by the existingtest_docker.pysuite plus the pure-Python rewrite tests.🤖 Generated with Claude Code
https://claude.ai/code/session_01BN4v25msJgrjNgac97g1Az