Move state links, root setup, and ownership into the driver - #54
Open
CrypticSwarm wants to merge 5 commits into
Open
Move state links, root setup, and ownership into the driver#54CrypticSwarm wants to merge 5 commits into
CrypticSwarm wants to merge 5 commits into
Conversation
Two more per-harness hooks on the spec: one for linking the state a harness keeps across runs into its config destination, and one for the container preparation only root can do before privileges drop. Both default to doing nothing, so a harness with no such state and no root work declares nothing. Context gains the working directory the harness process will start in, which is what a root-phase hook reads its start directory from; it is empty for the phases that do not act on it.
Claude's config destination is rebuilt on every run, so the state that has to outlive the container -- projects, sessions, history and the rest of the allowlist -- is linked back in from the persistent home. That linking moves out of the entrypoint and into claude's own link-state hook, run by the driver after the asset phase, which puts the allowlist beside the harness it belongs to and leaves the entrypoint with no per-harness state handling. The port keeps the shell's semantics: a stale destination entry is cleared before the link is made, a link is unlinked rather than followed so the tree it points at survives, a state directory is created in the shared home first because claude's own mkdir fails on a link to a missing directory, and a file whose name is already taken there is linked as it stands. A link that cannot be made stops the container, as it did under set -eu. The tests that read the allowlist out of the entrypoint are replaced by behavior tests that run the phase over a staged home and read the destination back: the full link tree, state written by an earlier run served through it, every kind of stale entry giving way, and the harnesses without a hook writing nothing.
A workspace that is a linked worktree of a bare repo records the host path of its checkout, which no path inside the container resolves, so claude's /resume matches nothing and reports no conversations to resume. A git wrapper standing ahead of the real git rewrites that one path in `git worktree list --porcelain` output. Writing it moves out of the entrypoint and into claude's own root-setup hook, run by the driver after the state links, which puts the rewrite beside the harness that needs it and leaves the entrypoint with no per-harness shell. The port keeps the shell's semantics: a .git directory, a .git file naming no gitdir, an administrative directory with no reverse pointer, and a worktree already recorded at the container's own path each install nothing, and the wrapper text is byte-for-byte what the heredoc produced. A missing git stops the container, as it did under set -eu. The hook is handed the run's working directory through the context rather than reading a fixed mount: claude runs against the checkout the run mounted, not /workspace. What is left in the entrypoint is harness-neutral and does only what the hook cannot, exporting PATH into the shell that execs the harness. It puts the wrapper directory ahead of the real git when a wrapper is standing there, which is exactly when the shell it replaces exported the same PATH. The guards, the recorded wrapper text and its mode, the working directory reaching the hook, and the harnesses whose default hook writes nothing are covered by tests that run the phase over a staged workspace. The entrypoint's literal and the module constant are checked against each other, since drift there leaves a wrapper installed that nothing ever runs.
Every root phase writes as root, so the last thing the container does before dropping privileges is give what it built to the anvil uid. That handover moves out of the entrypoint and into the driver's final phase, driven per harness by the paths its spec lists outside the home, which leaves the entrypoint with no per-harness path literals of its own. The port keeps the shell's semantics: the home and the workspace change recursively, the listed extras change without symlinks being followed so a state link changes owner rather than its target, and a path that is not there or a file whose owner cannot be changed is passed over in silence, as `2>/dev/null || true` was. Each of the two /run/swarmforge paths exists only in its own harness's runs, so the per-harness lists run the chowns the four unconditional lines ran. Codex's config destination stays root-owned as it was: the session reads the copy published under the home. The uid and gid reach the driver as two more arguments, passed through as the strings the shell interpolated. The tests that read the entrypoint's literals back are replaced by tests that run the phase: the chowns each harness produces, flags and order included, the paths its spec has to list, and a real run over a staged tree that comes through intact and stays quiet over a path that was never built.
The ownership executor now swallows a missing chown binary the way it swallows any other failed chown: resolution fails in the parent rather than in a child whose status was already discarded, and the handover is best-effort either way. The root-setup docstring no longer calls itself the last phase; the ownership handover is. The contracts the docstrings state are now tests: a failed state link and a missing git stop the container, a run without a chown binary stays silent, the entrypoint hands the driver the four words main() reads before the privilege drop, the PATH export names the same directory as its guard, and the credential store's literal is pinned whole as the directory the state links point into. The one driver run() call site that named neither a wrapper directory nor a working directory pins both, keeping every test away from the checkout's own worktree metadata.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three root-phase jobs remained in shell: claude's state-allowlist symlinks, its git worktree wrapper, and the chown pass. The first two become claude hooks run by the driver after assets —
link_staterecreates the allowlisted dirs and files as symlinks into the persistent config home, androot_setupwrites the wrapper that lets/resumework from a linked worktree. The chown pass moves behind the core: home, each spec's extra paths, then the workspace, always last in the root phase, with the same flags per path.The specs' extra chown paths match what the shell chowned — claude's and codex's pinned
/run/swarmforgetrees; grok and opencode need none since their config lives under the home. Recorded comparisons against the pre-change shell cover fresh homes, pre-populated state, stale destination entries (including a symlink whose target must survive), the wrapper's guards and rewrite, and the per-harness chown argv, flags, and order.