feat(instance): per-run instance HOME with credential copy/wipe #209

Merged
dominik.polakovics merged 3 commits from afk/202 into main 2026-07-23 01:14:35 +02:00

Closes #202

Every run now gets a private HOME at <state>/instances/<runID>/home, spawned host-side with HOME= in the session env — the isolation seam the container runner (#205) will mount later, de-risked without podman.

What shipped

Lifecycle (internal/instancehome) — mirrors the vault credential materializer: Materialize at launch, idempotent Wipe at stop/rollback, SweepAll keep-set GC (only run_<hex>-shaped dirs, 5-minute min-age guard protecting in-flight launches) at boot (StartupReconcile) and on the throttled runtime sweep.

Provider seamAgentProvider.InjectCredentials(instanceHome) (env, err) is the per-provider credentialInjector: copies the master store's model credentials into the instance HOME and returns the env pins the spawn must carry. Exactly one call site (the launch path), so a server-side proxy can later replace the copy wholesale. SeedOpts.Home + a home argument on LocateTranscript/Commands/CaptureDeepLink (ADR-0038 explicit-strings style). Empty home ⇒ miss/skip, never a master-store fallback — isolation by construction, and pre-upgrade active runs degrade gracefully (locate miss keeps the stored transcript path; deep-link miss falls back loudly as designed).

claude — injector copies .credentials.json (0600) and mirrors oauthAccount into the instance .claude.json; trust seeding (onboarding + folder trust) writes only under SeedOpts.Home; registry/projects/user-commands resolve under <home>/.claude. No env pin — HOME alone points the CLI (a service-wide CLAUDE_CONFIG_DIR was never supported by lab's readers).

codex — injector copies auth.json (0600; parent dir forced 0700 regardless of seed/inject order — found and regression-tested during review) and always returns CODEX_HOME=<home>/.codex: lab supports $CODEX_HOME for the master store, and a tmux-inherited value would otherwise point the instance back at it. Trust config.toml, the AGENTS.md bridge, and the rollout tree resolve under <home>/.codex.

Core wiring — launch materializes the home after the worktree add, seeds + injects into it, and spawns with HOME=<home> + injector env; the rollback closure wipes it. Manual Stop, StopAFK, and the reaper wipe beside their credential cleanup. Boot + runtime sweeps GC orphans against the live-run keep-set. Chat locate, the composer command catalog, and deep-link capture all read per-run paths. docs/ops.md documents instances/ as ephemeral/no-backup.

Conformance (Tier-1, hermetic, every adapter) — empty-home locate miss; empty-home injector error; missing-master tolerance with zero writes into the master tree; seed-home containment.

Acceptance criteria

  • New runs get a private HOME; master ~/.claude* / ~/.codex no longer read or written by instance processes (live-verified, below)
  • Chat tailing, dialog-capture spool, and deep-link capture work against per-run paths (spool was already runtime-dir-keyed; locate/capture now home-keyed; full chat/tailer suite green)
  • Credential copy wiped on stop and launch rollback; boot sweep removes orphans (tests: TestStart_spawnFailureWipesInstanceHome, TestStop_wipesInstanceHome, TestStopAFK_wipesInstanceHome, TestReap_wipesInstanceHome, TestStartupReconcile_instanceHomeKeepSet)
  • Trust seeding writes only into the instance HOME (adapter tests + seed-home-containment conformance)
  • Existing chat/tailer tests pass; new tests cover per-run path resolution and wipe-on-stop

Verification

  • go build ./..., full go test ./..., and golangci-lint 2.12.2 (CI's pin): all green; gofmt clean.
  • Live end-to-end against the real claude CLI 2.1.198 in tmux: a session spawned with an injected+seeded instance HOME authenticated (assistant answered from the copied OAuth creds), skipped onboarding/trust wizards, wrote its transcript at <home>/.claude/projects/<slug>/<sessionId>.jsonl where LocateTranscript resolved it via the per-run session registry, and left the master store byte-untouched (mtimes + project-slug set compared). Codex could not be live-driven in this sandbox; its behavior is pinned by unit tests + compat + conformance.

Notes for the maintainer

  • Ended-run chat history: wiping the HOME at stop removes the transcript, so an ended run's chat degrades to "transcript no longer available" (the documented degradation). If history-after-stop should survive, that's a follow-up (copy-out before wipe).
  • User-level slash commands (~/.claude/commands) no longer reach instances — the catalog truthfully reports none (per-run home is fresh). Seeding them per-run would be a small follow-up if wanted.
  • Machine-managed MCP is not HOME-isolated (live-spike finding): /etc/claude-code/managed-mcp.json loads into every run regardless of HOME — a shared surface worth remembering for #205's container mount inventory.
  • Master ~/.claude/settings.json (operator prefs) similarly no longer reaches instances; per-run behavior is now default-config plus lab's own --settings hooks file.

🤖 Generated with Claude Code

Closes #202 Every run now gets a private HOME at `<state>/instances/<runID>/home`, spawned host-side with `HOME=` in the session env — the isolation seam the container runner (#205) will mount later, de-risked without podman. ## What shipped **Lifecycle (`internal/instancehome`)** — mirrors the vault credential materializer: `Materialize` at launch, idempotent `Wipe` at stop/rollback, `SweepAll` keep-set GC (only `run_<hex>`-shaped dirs, 5-minute min-age guard protecting in-flight launches) at boot (`StartupReconcile`) and on the throttled runtime sweep. **Provider seam** — `AgentProvider.InjectCredentials(instanceHome) (env, err)` is the per-provider credentialInjector: copies the master store's model credentials into the instance HOME and returns the env pins the spawn must carry. Exactly one call site (the launch path), so a server-side proxy can later replace the copy wholesale. `SeedOpts.Home` + a `home` argument on `LocateTranscript`/`Commands`/`CaptureDeepLink` (ADR-0038 explicit-strings style). Empty home ⇒ miss/skip, never a master-store fallback — isolation by construction, and pre-upgrade active runs degrade gracefully (locate miss keeps the stored transcript path; deep-link miss falls back loudly as designed). **claude** — injector copies `.credentials.json` (0600) and mirrors `oauthAccount` into the instance `.claude.json`; trust seeding (onboarding + folder trust) writes only under `SeedOpts.Home`; registry/projects/user-commands resolve under `<home>/.claude`. No env pin — HOME alone points the CLI (a service-wide `CLAUDE_CONFIG_DIR` was never supported by lab's readers). **codex** — injector copies `auth.json` (0600; parent dir forced 0700 regardless of seed/inject order — found and regression-tested during review) and **always** returns `CODEX_HOME=<home>/.codex`: lab supports `$CODEX_HOME` for the master store, and a tmux-inherited value would otherwise point the instance back at it. Trust `config.toml`, the `AGENTS.md` bridge, and the rollout tree resolve under `<home>/.codex`. **Core wiring** — launch materializes the home after the worktree add, seeds + injects into it, and spawns with `HOME=<home>` + injector env; the rollback closure wipes it. Manual Stop, `StopAFK`, and the reaper wipe beside their credential cleanup. Boot + runtime sweeps GC orphans against the live-run keep-set. Chat locate, the composer command catalog, and deep-link capture all read per-run paths. `docs/ops.md` documents `instances/` as ephemeral/no-backup. **Conformance (Tier-1, hermetic, every adapter)** — empty-home locate miss; empty-home injector error; missing-master tolerance with zero writes into the master tree; seed-home containment. ## Acceptance criteria - ✅ New runs get a private HOME; master `~/.claude*` / `~/.codex` no longer read or written by instance processes (live-verified, below) - ✅ Chat tailing, dialog-capture spool, and deep-link capture work against per-run paths (spool was already runtime-dir-keyed; locate/capture now home-keyed; full chat/tailer suite green) - ✅ Credential copy wiped on stop and launch rollback; boot sweep removes orphans (tests: `TestStart_spawnFailureWipesInstanceHome`, `TestStop_wipesInstanceHome`, `TestStopAFK_wipesInstanceHome`, `TestReap_wipesInstanceHome`, `TestStartupReconcile_instanceHomeKeepSet`) - ✅ Trust seeding writes only into the instance HOME (adapter tests + `seed-home-containment` conformance) - ✅ Existing chat/tailer tests pass; new tests cover per-run path resolution and wipe-on-stop ## Verification - `go build ./...`, full `go test ./...`, and golangci-lint 2.12.2 (CI's pin): all green; `gofmt` clean. - **Live end-to-end** against the real claude CLI 2.1.198 in tmux: a session spawned with an injected+seeded instance HOME authenticated (assistant answered from the copied OAuth creds), skipped onboarding/trust wizards, wrote its transcript at `<home>/.claude/projects/<slug>/<sessionId>.jsonl` where `LocateTranscript` resolved it via the per-run session registry, and left the master store byte-untouched (mtimes + project-slug set compared). Codex could not be live-driven in this sandbox; its behavior is pinned by unit tests + compat + conformance. ## Notes for the maintainer - **Ended-run chat history**: wiping the HOME at stop removes the transcript, so an ended run's chat degrades to "transcript no longer available" (the documented degradation). If history-after-stop should survive, that's a follow-up (copy-out before wipe). - **User-level slash commands** (`~/.claude/commands`) no longer reach instances — the catalog truthfully reports none (per-run home is fresh). Seeding them per-run would be a small follow-up if wanted. - **Machine-managed MCP is not HOME-isolated** (live-spike finding): `/etc/claude-code/managed-mcp.json` loads into every run regardless of HOME — a shared surface worth remembering for #205's container mount inventory. - **Master `~/.claude/settings.json`** (operator prefs) similarly no longer reaches instances; per-run behavior is now default-config plus lab's own `--settings` hooks file. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(instance): per-run instance HOME with credential copy/wipe (#202)
All checks were successful
ci / native (pull_request) Successful in 8m44s
44fd84bf0f
Every run now gets a private HOME at <state>/instances/<runID>/home —
the isolation seam the container runner (#205) will later mount, landed
host-mode first:

- internal/instancehome: the lifecycle package mirroring the vault
  materializer — Materialize at launch, idempotent Wipe at
  stop/rollback, keep-set SweepAll (run-ID-shaped dirs only, 5-minute
  min-age guard) at boot and on the throttled runtime sweep.
- Provider seam (ADR-0038 style): AgentProvider gains
  InjectCredentials(instanceHome) (env, err) — the per-provider
  credentialInjector with exactly one call site (the launch path) so a
  server-side proxy can replace the copy later; SeedOpts gains Home;
  LocateTranscript/Commands/CaptureDeepLink gain a home argument. An
  empty home is a miss/skip by contract — never a master-store
  fallback, which also degrades pre-upgrade runs gracefully (a locate
  miss keeps the stored transcript path).
- claude: injector copies master .credentials.json (0600) and mirrors
  oauthAccount into the instance .claude.json; trust seeding
  (onboarding + folder trust) writes only under SeedOpts.Home;
  registry/projects/user-commands resolve under <home>/.claude. No env
  pin needed — HOME alone points the CLI.
- codex: injector copies master auth.json (0600, parent forced 0700
  regardless of seed/inject order) and always returns the
  CODEX_HOME=<home>/.codex pin, since lab supports $CODEX_HOME for the
  master store and a tmux-inherited value would otherwise point the
  instance back at it; trust config.toml + AGENTS.md bridge and the
  rollout tree resolve under <home>/.codex.
- Launch spawns sessions with HOME=<home> plus the injector env; the
  rollback closure wipes the instance tree. Manual Stop, StopAFK, and
  the reaper wipe it beside their credential cleanup; boot and runtime
  sweeps GC orphans against the live-run keep-set.
- Tier-1 conformance pins the new contracts hermetically: empty-home
  locate miss, empty-home injector error, missing-master tolerance
  with zero master writes, and seed-home containment.
- docs/ops.md documents <state>/instances/ as ephemeral, excluded from
  backup.

Live-verified against the real claude CLI 2.1.198 in tmux: a session
spawned with an injected+seeded instance HOME authenticates, skips
onboarding, writes its transcript under the per-run path where
LocateTranscript resolves it, and leaves the master store untouched.

Closes #202

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

[autoland] verdict: pass

PASS with CONCERNS (non-blocking). Signal relied on: CI 'ci / native' = success (8m44s, run 229). Clean fast-forward from origin/main; conventional title + 'Closes #202' present; diff scoped to #202. Build + go vet clean locally.

CONCERN (non-blocking, conditional) — claude credential-isolation asymmetry vs codex:

  • internal/provider/claudecode/inject.go:46 returns nil env, so the spawn pins only HOME. But the claude CLI (and lab's own credentialsPath(), internal/provider/claudecode/logout.go:88) resolve credentials from CLAUDE_CONFIG_DIR over HOME. tmux inherits the lab server env (tmuxx.newSessionArgs only ADDS -e K=V), so if the deployment exports CLAUDE_CONFIG_DIR, an instance's claude process reads/writes the MASTER credential store — violating this PR's headline AC ('master ~/.claude* no longer read or written by instance processes') for the one file that matters most.
  • codex/inject.go:42 defends the identical vector by UNCONDITIONALLY returning CODEX_HOME=/.codex; claude ships no equivalent CLAUDE_CONFIG_DIR pin. The inject.go:29-31 comment ('lab's readers never supported a service-wide CLAUDE_CONFIG_DIR') concerns lab's own readers, not the CLI's resolution.
  • NOT a regression (main had no isolation) and conditional: grep shows nothing in-repo sets CLAUDE_CONFIG_DIR, but the deploy systemd unit (Cloonar/nixos) is out of tree and should be checked. Fix is one line, symmetric with codex: have claude InjectCredentials return CLAUDE_CONFIG_DIR=/.claude.

Approving to record the finding; NOT auto-merging (a clean PASS is required to auto-merge). Human to decide whether to pin CLAUDE_CONFIG_DIR before merge.

[autoland] verdict: pass PASS with CONCERNS (non-blocking). Signal relied on: CI 'ci / native' = success (8m44s, run 229). Clean fast-forward from origin/main; conventional title + 'Closes #202' present; diff scoped to #202. Build + go vet clean locally. CONCERN (non-blocking, conditional) — claude credential-isolation asymmetry vs codex: - internal/provider/claudecode/inject.go:46 returns nil env, so the spawn pins only HOME. But the claude CLI (and lab's own credentialsPath(), internal/provider/claudecode/logout.go:88) resolve credentials from CLAUDE_CONFIG_DIR *over* HOME. tmux inherits the lab server env (tmuxx.newSessionArgs only ADDS -e K=V), so if the deployment exports CLAUDE_CONFIG_DIR, an instance's claude process reads/writes the MASTER credential store — violating this PR's headline AC ('master ~/.claude* no longer read or written by instance processes') for the one file that matters most. - codex/inject.go:42 defends the identical vector by UNCONDITIONALLY returning CODEX_HOME=<home>/.codex; claude ships no equivalent CLAUDE_CONFIG_DIR pin. The inject.go:29-31 comment ('lab's readers never supported a service-wide CLAUDE_CONFIG_DIR') concerns lab's own readers, not the CLI's resolution. - NOT a regression (main had no isolation) and conditional: grep shows nothing in-repo sets CLAUDE_CONFIG_DIR, but the deploy systemd unit (Cloonar/nixos) is out of tree and should be checked. Fix is one line, symmetric with codex: have claude InjectCredentials return CLAUDE_CONFIG_DIR=<home>/.claude. Approving to record the finding; NOT auto-merging (a clean PASS is required to auto-merge). Human to decide whether to pin CLAUDE_CONFIG_DIR before merge.
Merge branch 'main' into afk/202
All checks were successful
ci-nix / flake-check (pull_request) Successful in 22m17s
ci / native (pull_request) Successful in 28m30s
b5dbc9e6f2
fix(claudecode): pin CLAUDE_CONFIG_DIR= (empty) in the instance spawn env
All checks were successful
ci / native (pull_request) Successful in 7m58s
24ab1b0331
The claude CLI resolves CLAUDE_CONFIG_DIR *over* HOME for all of its
state (credentials, .claude.json, projects/), and the instance spawn
inherits the lab server's environment through tmux — so a service-wide
CLAUDE_CONFIG_DIR (which lab itself honors for the master store via
credentialsPath) would silently point every instance's claude back at
the master credential store and transcript tree, defeating the #202
isolation seam. codex already defends this vector with its
unconditional CODEX_HOME pin; claude shipped without an equivalent.

Pin the variable to EMPTY rather than to <home>/.claude: the CLI treats
an empty value as unset and falls back to HOME resolution (live-probed
on 2.1.214), which preserves the exact HOME-only layout the adapter
seeds — a non-empty pin would relocate .claude.json under the config
dir, away from the <home>/.claude.json the trust seed and oauthAccount
mirror write.

Both load-bearing CLI facts (precedence and empty-as-unset) are pinned
in compat.md §3a with an opt-in live probe
(TestCompat_Live_configDirResolution, LAB_COMPAT_LIVE=1), and tmux
delivery of the empty-valued -e entry was probe-verified.

Addresses the autoland validator's concern on PR #209.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!209
No description provided.