Skip to content

fix(server): clear the sandbox review blockers - #53

Merged
awtprod merged 34 commits into
mainfrom
fix/sandbox-review-blockers
Aug 23, 2026
Merged

fix(server): clear the sandbox review blockers#53
awtprod merged 34 commits into
mainfrom
fix/sandbox-review-blockers

Conversation

@awtprod

@awtprod awtprod commented Aug 22, 2026

Copy link
Copy Markdown
Owner

A code review of the merged range 88ea72a8c..20bee7554 returned BLOCK with 8 P1 and 4 P2 findings. This fixes all twelve. Sandboxes are not enabled in production, so none of these were live — but several destroy user work or strand resources the moment they are.

Two of the P1s were regressions from the sandbox work in that range, both introduced with passing tests. They are called out below rather than buried.

P1

  1. A spawned worker provisioned twice, and reported ready from the wrong state. The worker dispatch omitted provisionsInline, so the decider emitted sandbox.provision-requested — the inline sandbox.provision.ready was then rejected, and the request queued a second provisioning attempt. Regression from fix(server): harden sandbox lifecycle against restarts, deletion, and provision failures #51, which added the flag to two of the three inline call sites. All three are now consistent.

  2. Automatic settle silently destroyed uncommitted work. The backend computed a --binary patch and the manager persisted only the bundle and HEAD, so restore checked out the commit and everything dirty or untracked was gone. Export now pins the working tree as a snapshot commit inside the bundle, so the digest-verified artifact carries it; restore unpacks it back as dirty files.

  3. Restart adoption left threads "ready" but unusable. Reconcile recognised label-verified survivors without rebuilding execution records, so the projection said ready while every exec threw "sandbox is not ready". Survivors are now reported as unresumable rather than active, preserving the deliberate fail-closed intent.

  4. Deleting a thread mid-provision could leave its container running. Stop observed no container yet and completed as a no-op; provisioning then finished into a terminal lifecycle.

  5. Deleting a thread under a takeover lease never stopped the sandbox. The decider refuses sandbox.stop while a human holds the desktop — correct in general, impossible for deletion, since nothing will ever release that lease and reconcile still counts the thread as expected. Deletion now sends a forced stop. force is server-only, absent from ClientSandboxStopCommand, so a remote caller still cannot pull the desktop out from under someone.

  6. Deletion could resurrect exported transcripts. An export already in flight renamed fresh artifacts into place after removal had run. Serialised per thread, plus a deletion tombstone the export checks immediately before its renames.

  7. Remote podman had no configuration that was both working and bounded. One flag gated volume o=size quotas and container --storage-opt; enabling it broke provisioning over --remote, disabling it also discarded the volume limits that do hold. Now two controls: volume quotas default on (the real per-thread bound), container quota opt-in. A host that cannot administer project quotas at all gets a message naming the opt-out instead of podman's bare "Filesystem does not support Project Quota" — found by running the canary on exactly such a host.

  8. A failed runtime upgrade could leave the podman socket down. The socket was stopped before the binary swap but only restored at the end, so any failure in between exited under set -e with the host's socket off. Restoration is now an EXIT trap armed before anything stops.

P2

  1. A stale resume cursor survived a failed restore. The cursor was kept whenever a storeSha256 was recorded, but the artifact can be swept and the extraction is best-effort — leaving a clean container and a cursor naming a conversation that is not in it. Provisioning now reports what it actually restored.

  2. Provider execution could target a different daemon than provisioning. The provider spawn env was PATH+HOME while the command executor forwards a wider non-secret allowlist that rootless/remote podman needs. Related to the executor fix in fix(server): harden sandbox lifecycle against restarts, deletion, and provision failures #51, which widened one path and not the other. Both now share one allowlist; credential stripping unchanged.

  3. The projection recorded docker while podman provisioned. Server-side inline provisions now carry the resolved runtime; the decider stays pure.

  4. A same-version rerun could not repair a partial install. Installation was skipped on a version match alone, so a missing helper or absent manifest stayed broken. The manifest is now validated entry by entry.

Verification

  • Full server suite: 3295 passed. The 4 failures are pre-existing and unrelated — 3 CodexAdapter tests that fail from any linked git worktree (reproduced on a branch with none of these commits) and one ProviderRegistry test that passes 43/43 in isolation and only fails under suite parallelism.
  • vp check 0 errors, tsgo 0 errors, bash -n clean on the bootstrap script.
  • Every new test revert-checked: the production change is reverted alone and the test must fail.
  • Canary against real containers, 10/10, exit 0 (apps/server/integration/sandboxCanary.integration.ts): provision → settle without wedging → re-provision from export → delete with artifact removal. Run once with the new quota defaults, which surfaced finding 7's messaging gap, then again with the documented opt-out.
  • Shell logic for 8 and 12 verified against spliced harnesses with stubbed systemctl; the bootstrap script was never executed against this host.

Round 2: eight findings in the fixes above

A second review pass found eight more defects in these fixes. All are fixed in the later commits on this branch. Three deserve calling out:

Upgrade break (the most serious). Splitting the quota flag left volumeStorageQuotaEnabled reading only the new variable — so a host configured with the previously-documented T3_SANDBOX_CONTAINER_STORAGE_QUOTA=disabled would silently get volume quotas re-enabled at upgrade and fail every provision. An explicit new-variable value now wins; an unset one honours the legacy value with a one-shot deprecation warning. Mirrored in the bootstrap script and all four config docs.

Two signals that were added but never consumed. unresumableThreadIds (restart survivors) and the provider-store boolean both existed only in tests. Survivors now get exported then stopped before their thread is failed — the export is what saves the user's work, so the ordering is load-bearing and is asserted directly. The boolean became an explicit preserved | restored | unavailable tri-state routed through one shared cursor helper, because the boolean also cleared a valid cursor whenever a container survived and no restore was needed.

The rest: snapshot refs are now uniquely named and SHA-validated so a stale one cannot be restored over newer state, and a dirty tree that cannot snapshot fails the export loudly instead of silently dropping work; stop() awaits in-flight provisioning and unwinds anything created after a stop tombstone; the provider archive no longer strips Codex's sessions transcripts (every credential pattern retained and verified against real tar); tombstones are not evicted while an export is queued; and the bootstrap completeness check verifies digests rather than mere file existence.

Verification (round 2)

  • Canary 10/10, exit 0, run with only the legacy quota variable set — deliberately exercising the upgrade path the earlier run masked. The deprecation warning appears once in the log and provisioning succeeds.
  • Whole-repo vp check 0 errors, tsgo 0 errors, vp run -r typecheck clean across 17 packages, bash -n clean.
  • Server suite 3311 passed; the only failures are the 3 known CodexAdapter tests that fail from any linked git worktree. Contracts 282 passed; web + packages 4146 passed.
  • Every new test revert-checked. Bootstrap logic exercised against an isolated fake install root covering intact / truncated / missing / de-symlinked / repointed / legacy / empty manifests. The bootstrap script was never executed against this host.

Two notes for the reviewer

  • The manifest format change means the first bootstrap re-run on an already-provisioned host reinstalls the bundle once to rewrite its manifest with digests, rather than skipping. Intentional — an unverifiable manifest is treated as damage — but it is a one-time extra download.
  • apps/web/src/terminal/ghostty/runtimeAbi.test.ts fails to load in this worktree on a missing .wasm build artifact. Pre-existing environment issue, unrelated to this branch, but it means the web suite's count excludes that file.

Round 3: four more, two of them in the round-2 fixes

  • A recovery export was erased right after being written. The round-2 unresumable drain exported a thread's work, then built sandbox.reconcile.result from the pre-export snapshot — overwriting the fresh lastExport after the volume was already destroyed, so the next provision seeded from the base commit. Now re-reads projected state, but only for threads that actually exported (a deleted thread is invisible to the detail query, so its recorded sandbox stays the source).
  • stop() only synchronized against part of provisioning. It awaited the backend's ensureReady, but the manager keeps starting services, preview, credential, and desktop sidecars afterwards — so a concurrent stop could tear down, return, and let later sidecars survive. The lock moved to SandboxRuntimeManager, where the whole lifecycle is visible; the backend tombstone stays as the inner guard.
  • Later bundles resurrected deleted files. Round 2 gave each dirty export a unique persistent snapshot ref, but git bundle create --all swept every ref into every later bundle, so a file deleted after an earlier export stayed recoverable from a newer artifact. Bundles are now scoped to the thread branch plus the current snapshot namespace, and stale snapshot refs are pruned before the dirty check.
  • Claude stores always failed the restore probe. The probe matched only Codex's depth-2 sessions directory; Claude nests transcripts under .claude/projects (see UsageService.ts:235). Since the probe feeds the round-2 providerStore tri-state, every valid Claude restore was classified unavailable and its resume cursor cleared — silently losing the conversation on each re-provision, for one of the two providers that can run sandboxed at all. The probe now accepts both layouts.

Verification (round 3)

  • Canary 10/10, exit 0 against real containers, exercising the export → settle → re-provision round-trip these changes touch.
  • Whole-repo vp check 0 errors, tsgo 0 errors, clean tree, 26 commits.
  • Server suite 3316 passed (baseline 3310 + 6 new tests); the only failures are the known 3 CodexAdapter linked-worktree tests and the occasional ProviderRegistry parallelism flake.
  • Every new test revert-checked. Two worth noting: the concurrency test initially passed without the fix because the stop fiber never got scheduled — it was reworked until it genuinely reproduced the orphaned sidecar; and the retention test asserts against the bundle bytes rather than a restored checkout, because the restore-side SHA check would otherwise mask deleted content still present in the artifact.

Open judgement call

The Claude/Codex store probe is a documented union of both layouts rather than provider-driven: provider identity is not available at that call site, and threading it through means adding a provider field to SandboxBootstrap and deciding what happens when a thread's model selection changes mid-life. The union errs permissive deliberately — a missed layout clears the cursor and loses a conversation, an over-eager match costs one turn a resume error.


Round 4: three more, one a hole in the round-3 fix

  • A deletion that beat the provision leaked its containers. The round-3 lifecycle lock only serialized provisions that had already entered the manager, so a stop could win the lock in the window after sandbox.provision was dispatched but before manager.provision() began, find no record, and return "nothing to do" — after which the queued provision built containers for a now-terminal thread. Compounding it, the sandbox.provision.ready dispatch had no error handling anywhere, so the decider's rejection was swallowed and the new containers leaked permanently. Both halves fixed: a stop tombstone re-read under the lock (cleared only once the decider has authorized a fresh provision), and every one of the three production provision.ready sites now tears the sandbox down if the transition is refused.
  • An interrupted manifest write looked complete. write_podman_manifest truncated in place, so a partial write left a well-formed prefix that podman_install_is_complete accepted — a same-version rerun would then skip repairing every helper the manifest never got around to listing. Now built in a temp file, synced, atomically renamed, and closed with a version/count trailer that completeness verifies.
  • A failed service restart was warn-only with no socket to recover it. When podman.service was active but podman.socket was not, a failed restart was tolerated on the reasoning that the socket would re-activate the daemon — which is false in exactly that state, so bootstrap exited 0 with the daemon down. Fatal now when the socket was inactive; still warn-only from the EXIT trap so it cannot mask an original error.

Verification (round 4)

  • Canary 10/10, exit 0 against real containers.
  • Whole-repo vp check 0 errors, tsgo 0 errors, bash -n clean, 29 commits, clean tree.
  • Server suite 3320 passed; only the 3 known CodexAdapter linked-worktree failures.
  • Every new test revert-checked individually. The concurrency case is structural rather than schedule-dependent — the stop runs to completion before the provision is issued, so there is no competing fiber that can fail to be scheduled, and the revert-check still shows it failing without the fix.
  • Bootstrap changes exercised against an isolated fake install root (13/13 assertions) and a stubbed systemctl across all 9 unit-state × failure × severity combinations (9/9). The real script was never executed against this host.

Round 5: two more, both regressions from the rounds above

  • Restore silently discarded a snapshot its own export recorded. #resolveExportSnapshot collapsed two different situations into one return undefined: "the export recorded no snapshot" (fine — clean tree) and "the export recorded one but it cannot be resolved" (not fine — provisioning proceeded from the branch head and dropped the dirty and untracked work the event log says exists). The failed-for-each-ref path had the same defect. Now, when a snapshot was recorded and cannot be resolved and verified, provisioning fails naming the expected commit and distinguishing the two causes; the stale-ref sweep stays on the no-snapshot path only, so a failure does not destroy the evidence. The throw reaches provision's existing cleanup, so the container is torn down rather than left half-seeded.
  • A nested RETURN trap leaked the ~45MB podman install directory. install_podman_static and write_podman_manifest each installed one, and bash RETURN traps do not stack — the callee's replaced the caller's, reintroducing the very leak this branch's ancestor fixed, by a different mechanism. One function now owns the trap and cleans both temporaries; both carry a comment saying traps do not stack, since this has bitten twice. trap -p RETURN save/restore was tried and ruled out empirically: it reads back empty without set -T, and double-fires with it.

Verification (round 5)

  • Canary 10/10, exit 0 against real containers, exercising the export → settle → re-provision round-trip the snapshot fix guards.
  • Whole-repo vp check 0 errors, tsgo 0 errors, bash -n clean, 31 commits, clean tree.
  • Server suite 3322 passed; only the 3 known CodexAdapter linked-worktree failures.
  • Both new tests revert-checked: with the fix reverted, provisioning succeeded where it should have failed — the exact silent-degradation signature. The trap fix was verified with a probe that extracts the real functions, stubs every host-touching command, and asserts both temporaries are gone; it printed LEAKED install dir: … against the pre-fix script.

Two notes for the reviewer

  • One commit is --no-verify, disclosed in its message. The pre-commit hook's only step is vp fmt, which has no shell handler and aborts a shell-only commit outright; earlier bootstrap commits passed only because they also touched a .md file. scripts/public-leak-scan.ts was run by hand instead (clean, 145 files + 43 revisions). The hook gap is worth fixing separately so shell-only commits are not forced through --no-verify.
  • Pre-existing and out of scope: install_podman_static's die paths still leak their temp dir, because bash RETURN traps do not fire on exit. Unchanged by this branch and a different defect from the one reviewed.

awtprod and others added 14 commits August 22, 2026 03:19
The forwarded-env allowlist and its builder were file-private, so other
sandbox modules that need to spawn the container CLI with the same
environment had no way to reuse them. Export both symbols.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The worker-spawn handler dispatched `sandbox.provision` for the child
thread with no branch and no `provisionsInline`, then provisioned inline
on the next line. The decider reads a command without that flag as a
request and emits `sandbox.provision-requested` -- the event this very
reactor consumes -- so the worker got a second container, while the
inline `sandbox.provision.ready` that followed was rejected because the
sandbox had never entered `provisioning`.

Dispatch the child's resolved branch and `provisionsInline: true`, the
same shape the manual provision path in this file and the one in
ProviderCommandReactor already use, and await the projection before
provisioning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The decider refuses `sandbox.stop` while a human holds the desktop
takeover lease, and thread deletion dispatched a plain stop. Deleting a
thread mid-takeover therefore left the container, its network, and its
volumes running forever: nothing ever resumes a deleted thread to
release the lease, and reconcile still counts the deleted thread as
expected, so orphan removal skips it too.

Add a server-only `force` flag to the stop command -- absent from
`ClientSandboxStopCommand`, the same boundary `canceledThroughSequence`
and `provisionsInline` already use -- which revokes the lease and takes
the ordinary `stopping` transition. Deletion sends the forced variant; a
client that sends `force` loses it at the wire schema and is still
refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Provisioning spawns the container CLI through NodeSandboxCommandExecutor,
which forwards a non-secret allowlist -- XDG_RUNTIME_DIR, CONTAINER_HOST,
DOCKER_HOST, CONTAINERS_CONF, CONTAINERS_STORAGE_CONF -- because rootless
podman needs the first to find its user socket and a remote daemon needs
the others to be reachable at all. Provider exec built its spawn env from
PATH and HOME only, so a deployment could provision a container
successfully and then exec against a different daemon, or none.

Reuse the executor's `runtimeEnvironment()` for the exec spawn so both
paths locate the same runtime. Credential stripping is untouched: the
allowlist names only runtime-locating variables and carries no secret.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The decider is pure, so an inline provision with no `config.runtime`
falls back to docker, while SandboxRuntimeManager honours
`T3_SANDBOX_RUNTIME`. On a podman deployment the projection claimed
docker for the entire provisioning window, and a stop or delete landing
in that window addressed a backend that was never used.

The two server callers already resolve the runtime -- ProviderCommandReactor
to validate it, SandboxLifecycleReactor now via a small helper -- so they
put the resolved value into `config.runtime` when dispatching. The decider
stays pure and keeps its docker fallback for callers that send nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An export committed nothing and computed a patch of the working tree, but
only the branch bundle and head commit reached the manifest -- the patch
was dropped. Restore then checked the recorded commit out with nothing
applied, so every dirty tracked file and every untracked file a user had
in the sandbox was silently gone after a settle, an expiry, or a stop.

The export now pins the working tree (dirty AND untracked) as a commit
under refs/t3/export-snapshot before the bundle is written, so the
digest-verified bundle itself carries the work; restore fetches that ref
when the bundle names one, unpacks it over the checked-out head, and
deletes the ref. Nothing uncommitted means no snapshot and no behaviour
change, and a snapshot that cannot be written never fails the export --
losing the branch would be worse than losing the working tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing can use

Reconcile verified a container that outlived a server restart by its label
signature and reported the thread active, but never rebuilt the in-memory
record -- and exec, runtimeRef, and checkpointing all throw "sandbox for
thread X is not ready" without one. The lifecycle reactor left the
projection saying ready, so the thread looked usable and every operation
on it failed.

Rebuilding a trusted record is not safe here: the declarations that re-arm
credentials, preview routes, and automation targets died with the restart,
which is exactly what the fail-closed adoption refusal protects. So the
survivor is reported as missing instead -- the thread fails and
re-provisions -- and additionally listed in a new unresumableThreadIds,
since the container is still intact and hint-addressable: a caller that
reads it can stop the sandbox, which exports the user's work first, rather
than abandoning a container holding their commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er deletion

Thread deletion removed the exported artifact set immediately after
enqueuing the sandbox stop, but an export already in flight then renamed
its fresh bundle, manifest, and provider store into the canonical names
behind it. The deleted thread's transcripts and commits stayed on disk
with nothing left that would ever remove them: reconcile no longer knows
the thread, and the age sweep only reclaims sets long past their cap.

Export and artifact removal now run under a per-thread lock, so the two
can no longer interleave their filesystem work, and deletion leaves a
tombstone that the export checks immediately before its renames -- an
export that wins the lock discards its temporaries instead of publishing
files for a thread that no longer exists. The export still returns the
digests it computed; its caller's event describes a container that is
being torn down either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…orking and bounded

One flag gated two unrelated things: the container writable layer's
--storage-opt size= and the workspace/desktop volumes' --opt o=size= XFS
project quotas. Over podman --remote the container flag is rejected, so
leaving quotas on failed every provision -- and turning them off also
discarded the volume quotas, which do work over the socket and are the
real per-thread disk bound. No setting was both working and bounded.

They are separate controls now. T3_SANDBOX_CONTAINER_STORAGE_QUOTA keeps
its name and governs only --storage-opt, defaulting OFF since every socket
deployment is remote; the new T3_SANDBOX_VOLUME_STORAGE_QUOTA governs the
volume quotas and defaults ON, with the old opt-out kept for hosts where
rootless podman cannot administer project quotas at all. Step 8 of the
host bootstrap reads both with the same defaults, so its verification
still mirrors what the server issues, and both are documented in the
runtime and host runbooks, the drop-in template, and .env.example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…et down

Step 2 stops podman.socket and podman.service before replacing the
binaries, but only restored them at the very end of the step. Everything
in between runs under set -e: the binary copy, the manifest write, the
containers.conf drop-in, the AppArmor profile load. Any one of them
failing exited with the host's podman socket still stopped, which takes
the running server's sandboxes with it.

The restore is now a function armed as an EXIT trap before anything can
stop the socket, and cleared once the step finishes normally, so it cannot
contend with step 8's own trap. It clears its flags as it runs, so the
success-path call and the trap can never both act. On the success path a
socket that will not come back is still fatal; from the trap it warns
instead, since an exit is already in progress and dying there would mask
the error that caused it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r store

Callers decide whether to keep a thread's provider resume cursor from the
recorded storeSha256 alone, but that digest only says an export once
captured a store. The artifact may have been swept since, and the
extraction into the container is best-effort and swallows its own
failures -- so a fresh container with no conversation could be paired with
a cursor pointing into it, and every following turn failed to resume.

The provision result now carries providerStoreRestored, set only once the
archive is actually unpacked in the container, so the decision can be made
on what happened rather than on what was recorded. Nothing about the
best-effort behaviour changes: losing the conversation still must not cost
the user their branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 2 skipped installation whenever `podman --version` matched the pin,
but a matching version proves one binary, not a complete bundle. A helper
deleted by hand, an interrupted `cp -r` that copied podman first, or a
manifest that was never written all leave a host whose podman reports the
right version and cannot start a container -- and the documented repair,
re-running the script, said "already installed" and changed nothing.

The skip now also requires the recorded manifest to exist, be non-empty,
and list only files that are still present. Any gap reinstalls the pinned
bundle, which removes what the manifest lists and rewrites it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…is missing

The cursor was kept whenever the teardown had recorded a `storeSha256`. But the
artifact can be swept by retention, and the copy/extract into the new container
is best-effort -- so the recorded digest does not prove the conversation is
actually there. The thread came back to a clean container holding a cursor that
named a session it did not have, and every turn after that died on "No
conversation found with session ID".

Provisioning now reports what it really restored, so the decision uses that
instead of the digest.
…quotas

Volume quotas are on by default, and they are the real per-thread disk bound --
but a host without XFS project-quota support cannot create a quota-bearing
volume at all. Podman's own wording ("Filesystem does not support Project
Quota") says what failed and not what to do, so provisioning died without ever
mentioning the switch that fixes it. Found by running the canary on exactly such
a host.
@awtprod

awtprod commented Aug 22, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@awtprod

awtprod commented Aug 22, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL labels Aug 22, 2026
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.6 KiB 13.6 KiB +18 B (+0.1%) 15.1 KiB
Codex Thread snapshot wire 6.9 KiB 6.9 KiB +2 B (+0.0%) 7.3 KiB
Codex Live turn WebSocket wire 6.7 KiB 6.7 KiB +16 B (+0.2%) 7.8 KiB
Codex Live turn WebSocket decoded 54.2 KiB 54.2 KiB 0 B (0.0%) 66.4 KiB
Codex Live turn messages 17 17 0 (0.0%) 21
Claude Total thread wire 13.7 KiB 13.7 KiB −6 B (−0.0%) 15.1 KiB
Claude Thread snapshot wire 7.0 KiB 7.0 KiB −1 B (−0.0%) 7.3 KiB
Claude Live turn WebSocket wire 6.7 KiB 6.7 KiB −5 B (−0.1%) 7.8 KiB
Claude Live turn WebSocket decoded 55.0 KiB 55.0 KiB 0 B (0.0%) 66.4 KiB
Claude Live turn messages 17 17 0 (0.0%) 21

Baseline: 20bee75 · PR result: 801264f · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 107.8 KiB
  • Claude decoded thread snapshot: 108.6 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

awtprod and others added 8 commits August 22, 2026 16:47
…switch

`T3_SANDBOX_CONTAINER_STORAGE_QUOTA` used to gate both quota controls, and a
host that cannot administer XFS project quotas was documented to set it to
`disabled`. After the split, `volumeStorageQuotaEnabled()` read only the new
`T3_SANDBOX_VOLUME_STORAGE_QUOTA` -- so upgrading such a host silently
re-enabled volume quotas and every provision died on the first quota-bearing
`volume create`.

With the new variable unset, a legacy `disabled` now disables the volume quotas
too, and logs a deprecation warning naming the variable to move to. An explicit
value on the new variable always wins, including an explicit re-enable. The
same fallback is mirrored in bootstrap-sandbox-host.sh step 8, and the
behaviour is documented in the runtime/host runbooks, .env.example, and the
systemd drop-in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tainer

`providerStoreRestored` was a boolean, and "not restored" conflated the two
cases that matter most. A container that SURVIVED and never needed restoring
reported the same `false` as a fresh container whose archive never arrived, so
the provider command reactor threw away a perfectly good cursor on every
re-attach. Meanwhile manual provisioning in the lifecycle reactor ignored the
outcome entirely and kept a stale cursor, failing every following turn with
"No conversation found with session ID".

The result now carries an explicit `providerStore: "preserved" | "restored" |
"unavailable"`, and every provisioning entry point -- the provider command
reactor, manual provisioning, and worker spawn -- routes through one shared
`reconcileProviderStoreCursor` helper. Only `unavailable` clears.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…andoning it

Reconcile ignored the `unresumableThreadIds` the backend reports: containers
that survived a restart and proved their identity, but that this manager
generation cannot exec into. The thread was marked failed and left free to
re-provision while its container kept RUNNING -- burning the host, colliding
with its own surviving sidecars on the next provision, and losing the user's
commits when that provision's unwind deleted a workspace volume nothing had
ever exported.

Reconcile now drains each one before anything marks it failed: export first,
then stop. A failed export is reported and the stop still runs, because a
container nothing can address must not keep running the workload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s newer work

`#writeExportSnapshot` swallowed every failure, and pinned the snapshot under
one fixed ref name. Two ways that lost work. A failed ref DELETION left the
previous export's snapshot in place, and it rode out in the next bundle --
restore then unpacked an OLD working tree over state the user had since
changed, silently. And any other snapshot failure returned `undefined`, so the
export shipped a bundle that looked complete while dropping everything
uncommitted, which the settle that triggered the export then destroyed.

The ref is now named by the commit it points at, so no earlier export's ref can
be mistaken for this one's, and the commit is recorded on the event log
alongside the bundle digest. Restore unpacks a snapshot only when the bundle's
ref both resolves to the SHA in its own name and is the commit the export
recorded; anything else in the namespace is dropped rather than carried
forward. A dirty tree whose snapshot cannot be written now fails the export
loudly -- a clean tree still writes none, which stays a success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iner exists

`stop()` neither awaited nor cancelled the in-flight `#provisioning` promise.
A stop that landed mid-provision tore down whatever existed at that instant and
returned, while the container, sidecars, network, and volumes the provision
created moments later survived with nothing holding a reference to them --
unreachable, unaccounted for, and never reclaimed. A forced deletion reported
success while the workload was still running.

A stop now marks the thread and waits for the provision to settle before
tearing anything down, and the provision checks that mark before publishing its
record: it unwinds everything it built -- including sibling sidecars this
backend never created -- rather than handing back a sandbox the deletion has
already accounted for as gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PROVIDER_STORE_EXCLUDES` carried a bare `sessions`. It was aimed at
`sessions/*.key`, which the `*.key` pattern already covers -- and it stripped
Codex's `~/.codex/sessions`, which IS the conversation the archive exists to
carry. Extraction still reported success, so the thread came back with a
retained cursor naming a conversation that had never been in the tar, and every
following turn failed to resume.

The exclusions are now credential-targeted, with each pattern documented for
what it protects; every credential pattern is kept. Verified against real tar
that `sessions/*.key`, `auth.json`, and `.credentials.json` are still excluded
while the transcripts survive. A provision also now probes for a provider's own
session directory after extracting, so a tar that unpacks cleanly without a
conversation reports `unavailable` rather than claiming a restore -- tar's exit
code alone never proved the conversation arrived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…drained

Tombstones were evicted FIFO at a fixed 4096 entries. A server that deletes
that many threads while one slow export is still in flight dropped that
thread's tombstone, and the export then republished a deleted thread's
transcripts and commits -- with nothing left to ever remove them.

Eviction now skips any thread that still has an entry in `artifactLocks`, which
is exactly "this thread has artifact work queued". The cap is enforced against
everything else, so the set holds at most a few extra entries while in-flight
exports settle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot a complete one

Step 8's completeness check tested only that each manifest path EXISTS. Both
damage modes a same-version re-run exists to repair -- an interrupted `cp -r`,
a helper truncated by hand -- leave the path present, so the host counted as
complete, the repair was skipped, and podman kept reporting the pinned version
while being unable to start a container.

The manifest now records what each entry is and what it contains: a sha256 for
a regular file, the link target for a symlink. The check reinstalls when an
entry is missing, fails its digest, is no longer a symlink, or points somewhere
new -- and when the manifest predates digest recording, so an upgrade rewrites
it. Verified with `bash -n` and by exercising the check against an isolated
fake install root covering all seven cases; the script was not run against this
host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size:XXL and removed size:XL labels Aug 22, 2026
…ed as missing

The post-extraction probe accepted only a depth-2 directory named `sessions`,
which is Codex's layout. Claude nests its transcripts under `.claude/projects`
(and directly under `projects` when the config directory is overridden), so
every valid Claude restore came back `unavailable` -- and `unavailable` is what
CLEARS the thread's provider resume cursor. Claude is one of only two providers
that can run sandboxed at all, so a Claude thread silently lost its conversation
on every re-provision while its archive sat correctly extracted in the
container.

The probe now accepts both providers' store layouts at depth 1 or 2. The
provision input carries no provider identity -- the same provider home is handed
to whichever CLI the thread's model selection resolves to, and that selection
can change between turns -- so this is a documented union rather than a
per-thread choice. Permissive is the safe direction: a missed layout loses the
conversation, a layout matched too eagerly costs at most one turn a resume
error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
awtprod and others added 11 commits August 22, 2026 20:15
…ust saved the work

Reconcile drains an unresumable thread by exporting its work and then stopping
it, and the export records a fresh `lastExport` on the thread's projected
sandbox. The `missing` result dispatched immediately afterwards was built from
the snapshot captured at the top of the pass -- from before the export -- and
the decider replaces the whole sandbox value, so it wrote the OLD pointer back
over the new one. By then the container and its workspace volume were already
destroyed, so the next provision seeded from the project's base commit and the
user's exported work was unreachable: silent data loss inside the code path
that exists to prevent it.

The result is now built on state re-read after the drain, for exactly the
threads whose export succeeded. Threads that were not drained keep using the
snapshot copy, which for a deleted thread is the only record of its sandbox
there is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ars running

`stop` synchronized against the backend's in-flight `ensureReady`, but that is
only the first step of a provision. The manager keeps going after it returns:
it starts the service stack, the preview proxy, the credential proxy, and the
desktop sidecar, and publishes the thread's gateway state. A stop arriving in
that window destroyed whatever existed at that instant, reported the thread
stopped, and let everything created afterwards survive forever -- unreferenced
containers, live preview routes, and readable credential grants belonging to a
thread nothing would ever stop again. A forced deletion looked complete while
the workload was still up.

The mutual exclusion moves to the manager, where the whole lifecycle is
visible: a per-thread lock spanning provision from its first step through final
publication, and spanning stop end to end. The backend's tombstone stays as the
inner guard. A stop for a thread with nothing in flight is unaffected -- it
takes an uncontended lock and runs its teardown as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the next bundle

Each dirty export pins its working tree under a ref named by its own commit,
and the bundle was written with `git bundle create --all`. Every snapshot ref
an earlier export left in the repository therefore rode out in every LATER
bundle: a secret the user wrote, exported, and then deleted was still
recoverable from the newest artifact -- the one that is supposed to represent
current state, and the one the retention sweep ages out. The restore-side
commit check refused to UNPACK a stale snapshot, so this was invisible from a
restored checkout while the bytes sat in the artifact on disk.

Two changes, each closing the hole on its own. The export drops every snapshot
ref left over from a previous run before writing its own, and the bundle now
names the thread branch and the snapshot namespace instead of every ref the
repository happens to hold -- which also stops the local seeding ref from
travelling. The snapshot SHA is still validated against the event log on
restore; scoping the bundle relaxes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ts containers

The per-thread lifecycle lock only serializes operations that have already
ENTERED the runtime manager, and a provision spends real time getting there:
the reactor dispatches `sandbox.provision`, loads the project file, and derives
the branch first. A deletion issued in that window took the lock uncontended,
found no record, reported "nothing to do", and returned -- and the provision
then ran against a thread that was already terminal, creating a container,
sidecars, a network, and volumes with nothing left holding a reference to them.

The `sandbox.provision.ready` that followed was the only signal that any of
this had happened, and nothing acted on it: every dispatch site sent the command
and ignored the result, so the decider's rejection was swallowed and the
containers survived forever.

Both halves are fixed, because either alone leaves the hole open. A stop now
leaves a tombstone that a provision reads once it acquires the lock, so a
provision that lost the race refuses before creating anything; the tombstone is
cleared by `authorizeProvision`, which the reactors call only once the decider
has accepted their `sandbox.provision`, so coming back to a stopped or expired
thread still provisions normally. And a refused readiness is now treated as a
failed provision at all three dispatch sites -- the provider reactor, the manual
lifecycle path, and the worker spawn -- tearing down the sandbox it would have
published rather than leaving it to nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…omplete one

`write_podman_manifest` truncated the manifest and then appended one line per
installed file. An interruption -- the run killed, the disk filling, the host
rebooting -- left a nonempty, perfectly well-formed PREFIX of the real thing,
and the completeness check validates only the entries it FINDS. A manifest
naming 3 of the bundle's 40 files therefore passed, a same-version re-run
reported "already installed and complete", and the 37 helpers it never recorded
were never repaired -- the exact "reports the pinned version, cannot start a
container" state the check exists to catch.

The manifest now vouches for itself. It is built in a temporary beside the
destination and renamed into place, so the canonical path holds a complete
manifest or the previous complete one and never a prefix of either, and it
closes with an `e<TAB><version><TAB><entry count>` trailer written last. The
check compares that count against the entries it actually read and the version
against the pinned bundle, so a manifest missing any part of itself -- or left
over from another version -- reinstalls instead of being trusted.

Verified with `bash -n` and by exercising both functions against an isolated
fake install root: a truncated manifest, a trailer whose count exceeds the
entries present, a trailer from another version, a killed write leaving the
previous manifest intact, and the seven pre-existing damage cases, 13 assertions
in all. The script was not run against this host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…emon down

The restore path treated a failed `systemctl --user start podman.service` as
non-fatal because "the socket re-spawns it on demand". That is only true when
there IS a socket. `restore_podman_services` records `podman.service` active
WITHOUT `podman.socket` as its own case, and the binary swap stops the service
just the same -- so on such a host a failed restart warned, the step returned,
and bootstrap exited 0 with the previously running daemon down and every
sandbox on the host unreachable.

A failed service restart is now fatal in exactly that state, matching how the
socket path already behaves, and it still degrades to a warning when the socket
was active (the daemon really is socket-activated then) and when called from
the EXIT trap, where an exit is already in progress and a `die` would only mask
the original error.

Verified with `bash -n` and by driving `restore_podman_services` against a
stubbed `systemctl` across all nine combinations of which unit was active,
which restart fails, and which severity it was called with. The script was not
run against this host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s export recorded

`#resolveExportSnapshot` collapsed two different situations into the same
`undefined`: an export that recorded no snapshot because the tree was clean,
and an export that DID record one that the restore then could not find --
the ref missing from the bundle, or `for-each-ref` failing outright. The
second returned `undefined` too, so provisioning continued from the branch
head and the dirty and untracked work the event log says exists was gone,
with nothing logged and nothing failed.

A recorded snapshot that cannot be resolved and verified now fails the
provision, naming the commit the operator can go look for. The stale-ref
cleanup stays on the `expected === undefined` path only: on the failure path
the ref is the only copy of that tree in the container, and deleting it
blind after a listing that just failed destroys the evidence. The provision's
own catch tears the container down, so a retry starts clean either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tall directory

Bash RETURN traps do not stack. `install_podman_static` set one to remove its
~45MB mktemp directory, then called `write_podman_manifest`, which set its own
for the manifest temporary -- REPLACING the caller's. When the callee returned,
the caller's cleanup no longer existed and the extracted bundle leaked on every
install. Save-and-restore is not available to the callee either: `trap -p
RETURN` reads back empty inside a called function without `set -T`, and with
`set -T` the inherited trap fires twice.

So there is now exactly one RETURN trap in the step. `install_podman_static`
owns both temporaries and passes the manifest one in; `write_podman_manifest`
sets no trap at all. Both functions carry a comment saying traps do not stack,
since this leak has now been introduced twice -- the second time purely by
adding the nested trap.

Verified with a self-contained probe that sources both functions verbatim out
of this script, stubs the host-touching commands, and asserts the recorded
mktemp directory and the manifest temporary are both gone afterwards: it fails
on the previous revision and passes on this one.

Committed with --no-verify: the pre-commit formatter has no shell handler and
aborts on a shell-only commit ("Expected at least one target file"). The leak
scan was run by hand instead and passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…authorization

The manager gated provisioning on a per-thread BOOLEAN stop tombstone: `stop`
set it, `authorizeProvision` cleared it. A boolean can say that a stop went
past SOMETHING, never past WHICH provision, and three defects were that one
gap:

- Provision A was stopped; provision B was then authorized, which cleared the
  thread's one tombstone; and A -- still queued, holding no lifecycle lock
  because it had not reached the manager yet -- was admitted under B's
  authorization and built a container for a thread nothing would stop again.
- Readiness was not attempt-scoped either, so A's refused
  `sandbox.provision.ready` tore down the container B had just published and
  had accepted, leaving the projection reporting `ready` over a sandbox that
  no longer existed.
- The tombstone set was capped at 4096 and evicted, sparing only threads
  holding a lifecycle lock -- but a queued provision holds no lock, so the
  entry protecting it was exactly the evictable kind, and the cap put the
  first leak back.

`authorizeProvision` now returns an attempt token instead of clearing a flag.
`provision` carries it and is admitted, under the lifecycle lock, only while
it is still the thread's current authorization, so a stale attempt is refused
however long it queued. `stop` drops the authorization before queueing for the
lock. The token rides back on the provision result -- the channel the caller
already threads from `provision` to its readiness dispatch -- and the new
`stopProvisionAttempt` tears down only the sandbox that attempt published.

The cap goes with it: an entry is dropped when the thread stops, because
refusing anyone who cannot present the current token already refuses the stale
provision. What is left is one entry per live thread rather than one per
thread ever stopped, so there is nothing to bound and nothing to evict.

Covered by three tests over the orderings that were uncovered: a stale attempt
refused after a newer one is authorized and provisions successfully, the same
race interleaved with the stale attempt parked on the lifecycle lock, and a
refused readiness that must leave the live attempt's container standing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o the next export

`#deleteExportSnapshotRefs` enumerates the snapshot namespace with
`for-each-ref` and returned silently when that failed. The export then carried
on, and because the bundle named the namespace with a `--glob`, what it
shipped was whatever the failed cleanup had left there: an earlier export's
snapshot refs, and the files the user had deleted since that export, inside a
new artifact that is supposed to represent current state.

Narrowing `--all` to a namespace glob in an earlier round did not fix this
class, it only made the fix conditional on cleanup succeeding. So the bundle
now names the single snapshot ref this export just wrote -- `exportBranch`
already returns that commit, and it is threaded to `exportBundle` -- and
nothing else can be in the artifact whatever is left in the repository. The
deletions become hygiene for the user's `git log --all`, which is why their
enumeration failing is allowed to stay quiet.

Restore's SHA validation is untouched: it still requires the bundle's snapshot
ref to resolve to exactly the commit the event log recorded, and it now gets a
bundle that cannot contain any other. Naming the ref also fails loudly when
the snapshot has gone missing, where a glob matching nothing was not an error
and shipped a bundle the restore would refuse after the container was gone.

Tested on the failed-listing path the reviewer flagged as uncovered: a
round-trip over real git whose `for-each-ref` always fails, asserting the
deleted file is absent from the restore AND that the stale snapshot commit
does not resolve in the restored repository at all. It fails against the glob.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ition of its first provision

`ensureReady` returned the recorded `ready` object verbatim on a cache hit, so
a re-provision reported whatever the ORIGINAL provision had done to the
provider store. A fresh container records `providerStore: "unavailable"`,
which is correct at the time -- nothing was restored because there was nothing
to restore. The provider then creates a conversation inside that same
container, a later provision hits the cache, `unavailable` comes back out, and
`reconcileProviderStoreCursor` clears a resume cursor that names a live
conversation. The conversation is lost.

A cache hit means the container survived, which is exactly what `preserved`
describes -- the same answer the label-matched reuse path already gives for
the same situation, reached through a container inspect instead of the
in-memory record.

Audited every other field on the cached record for the same staleness class
and found none: `sandboxId` and the container, network, and volume names derive
from the thread and project ids, `runtime` is fixed for the backend, and
`branchName`, `limits`, and the egress proxy names describe the container that
is still running. A re-provision arriving with a different branch or different
limits does not change what is running, so reporting the values it was built
with is correct rather than stale. `providerStore` was the only field
describing what a provision ATTEMPT did rather than what the container is,
which is why it was the only one that could not be replayed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@awtprod
awtprod merged commit 7c4450c into main Aug 23, 2026
14 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant