Skip to content

fix(sandbox): make Docker persist_workspace archives restorable by hydrate_workspace - #4834

Open
coderdailyone wants to merge 2 commits into
openai:mainfrom
coderdailyone:fix/docker-persist-restorable-archive
Open

fix(sandbox): make Docker persist_workspace archives restorable by hydrate_workspace#4834
coderdailyone wants to merge 2 commits into
openai:mainfrom
coderdailyone:fix/docker-persist-restorable-archive

Conversation

@coderdailyone

@coderdailyone coderdailyone commented Sep 2, 2026

Copy link
Copy Markdown

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 leading workspace/ prefix in Python via strip_tar_member_prefix(). That function iterates the members with safe_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:

workspace content persist_workspace() on main
a.txt + ln a.txt b.txt (what uv / pnpm do for every installed package) UnsafeTarMemberError: hardlink member not allowed — persist fails
a FIFO left behind by a dev server UnsafeTarMemberError: unsupported member type — persist fails
ln -s /workspace/a.txt link (absolute link to a file inside the workspace) persist succeeds; hydrate refuses it with absolute symlink target not allowed

This 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:

  • hardlink members become regular file members carrying the target's payload; the source stays a single-pass stream and the payload is read back from the rewritten archive being written, so peak temporary usage stays at one archive;
  • FIFO and character/block device members are dropped;
  • a new optional 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.txt from sub/abs_up becomes ../a.txt). Docker passes its workspace root. Without the argument, symlink targets are untouched, so other callers see no change;
  • absolute targets outside the workspace are left unchanged for hydrate's existing policy (fix: reject external symlink targets during hydrate #3094).

The function still validates its output with validate_tarfile() and its existing prefix and pax-header behavior is unchanged.

Evidence

  • New tests in tests/sandbox/test_tar_utils.py build a workspace/… 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 through validate_tarfile(..., allow_external_symlink_targets=False) and safe_extract_tarfile(...) into a temporary root. All three fail on main.
  • tests/sandbox/test_tar_utils.py, test_extract.py, test_docker.py (195 passed), ruff, mypy, and pyright on 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 existing test_docker.py suite plus the pure-Python rewrite tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BN4v25msJgrjNgac97g1Az

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 sylvesterkaczmarek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@coderdailyone

Copy link
Copy Markdown
Author

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 (r|*), and a hardlink member's payload is read back from the rewritten archive that is being written (the regular members' data offsets are recorded by original member name as they are emitted; a small reader seeks to the payload and restores the writer's position around each read). Peak temp usage is back to one archive, the same as before this PR; hardlink expansion only grows the output by the duplicated payloads, which is inherent to storing them as regular files. A hardlink whose target is not a regular file already in the archive is rejected with a clear UnsafeTarMemberError. Existing and new strip_tar_member_prefix tests pass unchanged.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +183 to 187
rewritten.linkname = _relative_symlink_target(
rewritten.linkname,
link_name=stripped_name,
root=symlink_root,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants