Skip to content

Move state links, root setup, and ownership into the driver - #54

Open
CrypticSwarm wants to merge 5 commits into
harness-container-assetsfrom
harness-container-state
Open

Move state links, root setup, and ownership into the driver#54
CrypticSwarm wants to merge 5 commits into
harness-container-assetsfrom
harness-container-state

Conversation

@CrypticSwarm

Copy link
Copy Markdown
Owner

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_state recreates the allowlisted dirs and files as symlinks into the persistent config home, and root_setup writes the wrapper that lets /resume work 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/swarmforge trees; 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.

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

1 participant