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 6 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".
… target A symlink target longer than the ustar field is carried in a PAX "linkpath" record. Rewriting only TarInfo.linkname left that record pointing at the original absolute target, and addfile() emitted it, so the rewritten archive still held the absolute link and strict hydrate refused it. Remove the record; tobuf() re-derives it from the new linkname when needed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dce44cfdb8
ℹ️ 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".
… a double slash posixpath.normpath keeps two leading slashes, so //workspace/a.txt was not recognized as under the workspace root and stayed absolute; Linux resolves // as /, so collapse it before the containment check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
seratch
left a comment
There was a problem hiding this comment.
The FIFO and internal absolute-symlink cases warrant a focused fix, but Docker stages the workspace with cp -R, which already copies hardlinked files independently on the normal GNU coreutils path. Please remove the hardlink expansion machinery and narrow the patch to the remaining snapshot failures.
The symlink rewrite also needs to preserve path semantics. With alias -> sub/deep, /workspace/alias/../data.txt refers to sub/data.txt, but the current normalization rewrites it to the workspace-root data.txt. Please rebase internal targets without collapsing their components, and add Docker-session persist/hydrate coverage that exercises staging and verifies restored contents, including this symlink case.
…rkspace symlinks Address review: Docker stages the workspace with `cp -R`, which already copies hardlinked files independently, so drop the hardlink expansion (payload read-back, spooling) and keep only what the staged copy really carries and the strict hydrate extractor refuses: FIFO/device members are dropped, and absolute symlink targets under the workspace root are rebased onto the link's directory with their components kept verbatim. Only the root prefix is replaced, never normalized: with `alias -> sub/deep`, `/workspace/alias/../data.txt` names `sub/data.txt` and must stay `alias/../data.txt`. Add a Docker-session persist/hydrate test that goes through staging and reads the restored links. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
|
Thanks for the review. Both points addressed in
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fa4a43a6e5
ℹ️ 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".
| elif target.startswith(prefix + "/"): | ||
| rest = target[len(prefix) + 1 :] |
There was a problem hiding this comment.
Keep duplicate separators relative after removing the root
When a root-level symlink targets /workspace//a.txt, Linux resolves it inside the workspace, but this slice leaves rest as /a.txt; with an empty climb, the rewritten target remains absolute. persist_workspace() therefore returns the archive, while the strict hydrate_workspace() rejects it with absolute symlink target not allowed. Remove all separators immediately after the matched root boundary, while continuing to preserve subsequent path components, so this valid in-workspace link remains restorable.
AGENTS.md reference: AGENTS.md:L205-L205
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 266eab83: the whole separator run after the matched root is consumed, so /workspace//a.txt rebases to a.txt (with an empty climb it is no longer left absolute) while later components such as .. stay untouched. The fixture gained a double_sep member and the strict-hydrate round trip covers it.
…t when rebasing symlinks Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
Summary
DockerSandboxSession.persist_workspace()stages a copy of the workspace withcp -R, has the daemon archive it, and normalizes the member prefix in Python (strip_tar_member_prefix()).hydrate_workspace()then extracts with the strict policy that refuses FIFO/device members and absolute symlink targets. A staged Docker workspace legitimately carries both, so the snapshot was produced and then could never be restored:mainpersist_workspace()fails:UnsafeTarMemberError: unsupported member typeln -s /workspace/sub/data.txt link(absolute link to a file inside the workspace)hydrate_workspace()refuses the archive withabsolute symlink target not allowedstrip_tar_member_prefix()now drops FIFO and character/block device members, and, when the caller passes the workspace root (relativize_symlinks_under=; Docker passes its root), rebases an absolute symlink target under that root onto the link's own directory. Only the root prefix is replaced and the remaining components are kept verbatim: withalias -> sub/deep,/workspace/alias/../data.txtnamessub/data.txt(the kernel resolves the..against the alias target), so the restored link isalias/../data.txt, never a lexically collapseddata.txt. A long target's PAXlinkpathrecord is dropped so the rewrittenlinknameis what gets written. Absolute targets outside the workspace are left unchanged for hydrate's existing policy (#3094); without the argument, symlink targets are untouched.Hardlink handling from earlier revisions is removed:
cp -Rcopies hardlinked files independently, so hardlink members do not reach this path.Test plan
tests/sandbox/test_docker.py::test_docker_persist_and_hydrate_keep_absolute_workspace_symlinks_resolving: a host-backed Docker session persists a workspace withalias -> sub/deep,abs_alias -> /workspace/alias/../data.txt(wheredata.txt= "wrong" andsub/data.txt= "right") andsub/abs_up -> /workspace/sub/data.txtthrough the real staging + normalization path (the fakecp -Rnow preserves symlinks like the real one), hydrates into a second session (the faketar -xextracts on the host), and asserts the restored links arealias/../data.txt/../sub/data.txtand both read "right". Onmainhydrate refuses the archive.tests/sandbox/test_tar_utils.py: theworkspace/…fixture covers the FIFO,sub/abs_up, a//workspace/...target, thealias/..case, a >100-byte PAXlinkpathtarget and an external target; the rewrite test asserts each member, the strict-hydrate test extracts and readsabs_aliasas "right", andtest_strip_tar_member_prefix_still_rejects_hardlink_memberspins that hardlink members are still rejected.tests/sandbox/test_tar_utils.py,test_extract.py,test_docker.py: 196 + 110 passed.ruff format/ruff checkclean,mypyandpyrightclean on the changed files (Linux, Python 3.10). The repository-widemypy srcreports pre-existing Python 3.10 errors inarchive_ops.py/run_loop.pyunrelated to this change, so the full-stack checkbox is left unchecked.Issue number
None (Docker counterpart of #4831).
Checks
.agents/skills/code-change-verificationindividually (format, lint, typecheck on changed files, tests)/reviewbefore submitting this PR🤖 Generated with Claude Code
https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6