fix(hooks): never pin a non-durable plugin root into global settings.json#438
Merged
Conversation
…json
The SessionStart statusline self-heal rewrote the user's GLOBAL
~/.claude/settings.json to the CURRENT plugin root on every session. A
session started inside a git worktree (subagents run in
<repo>/.claude/worktrees/<id>/) resolves that root to the worktree, so the
heal pinned the global config at a directory whose entire purpose is to be
pruned:
"...\.claude\worktrees\wf_58ee3fa6-de1-8\plugins\ca\hooks\statusline.py"
Found in-session on 2026-07-25 after it broke the maintainer's statusline
three times in one day (wf_58ee3fa6-de1-8, wf_b7160646-041-2,
wf_04e3c2f6-f27-1). Not agent-specific: any user running codeArbiter from a
worktree hits it.
New core/pysrc/_durabilitylib.py answers one question — may this path be
pinned into a long-lived config — from two independent signals, with no
subprocess (it sits on the SessionStart hot path, before the dormant gate,
in every repo):
* lexical: a `worktrees` / `*-worktrees` path SEGMENT, zero I/O; covers the
confirmed .claude/worktrees/<id> shape and the skill's
.codearbiter-worktrees layout.
* git: a LINKED worktree's `.git` is a FILE reading
`gitdir: <common>/.git/worktrees/<name>`, read off disk; catches a
worktree parked anywhere under any name. A submodule's `.git` file points
at modules/, not worktrees/, and stays durable.
Anything unreadable, unparseable or absent reads as DURABLE: reading
uncertainty as ephemeral would silently disable the self-heal for users whose
installs are fine.
Guard placement, both sides of the seam sharing the one predicate:
* wire-statusline.refresh_if_stale (producer, at the mutation) — the heal
calls it DIRECTLY and never goes through cmd_refresh, so a guard one level
up would have left the confirmed path open. Silent no-op: automatic path.
* wire-statusline.cmd_install — LOUD SystemExit. install is explicit (a human
ran /ca:statusline, or passed --plugin-root <worktree>); a silent no-op
would leave them believing it worked.
* session-start.heal_statusline_wiring (caller) — deliberately redundant:
_load_wire_statusline loads the producer OUT OF `plugin` itself, so a
worktree cut from a pre-fix branch supplies a pre-fix, unguarded producer
while this file may have been loaded elsewhere (main() honours
$CLAUDE_PLUGIN_ROOT independently).
status and uninstall are never blocked — reporting and removal are safe from
anywhere, and a user stuck with a dead pin must be able to clear it.
NOT a kill-switch: a genuinely stale pin from a real plugin-cache update still
heals, proven both directions at both layers. Clearing instead of skipping was
rejected — a user whose only session that day is a worktree session would lose
a working statusline over a condition that resolves itself next start.
Tests (+39): test_durabilitylib.py for both signals and the no-subprocess hot-
path contract; TestNonDurableRootIsNeverPinned (producer) and
TestHealRefusesNonDurableRoot (caller), each pinning byte-identical settings on
a worktree root, a still-healing stale-but-durable pin, and the untouched
third-party line. Fixtures driving the REAL plugin root moved to
_helpers.durable_plugin_copy — codeArbiter's own checkout is often a worktree,
which would otherwise make those tests pass or fail on where they were run.
Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
The Pi payload gate requires any change under plugins/ca-pi to ship a strictly advancing version with regenerated root metadata and a new changelog heading. The previous commit vendored _durabilitylib.py and the guarded session-start / wire-statusline into plugins/ca-pi/hooks, so ca-pi 0.1.3 -> 0.1.4 with the matching root package.json and CHANGELOG entry. Caught by CI, not locally: build-host-packages --release-guard-base diffs base...HEAD (commits), so running it against an uncommitted working tree reports "no Pi payload change" and proves nothing. Verified here against the merge-base AFTER committing. Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
PR #430 (run-scoped farm receipts) landed on main and claimed ca-pi 0.1.4, the version this branch had also taken. Resolved plugins/ca-pi/CHANGELOG.md by keeping BOTH entries and moving this branch's durability-guard entry up to 0.1.5 (2026-07-25) above main's 0.1.4; plugins/ca-pi/package.json and the regenerated root package.json follow to 0.1.5, so the Pi payload gate still sees a strictly advancing version against the new merge base. No code conflict: #430 is farm/TypeScript plus prose, this branch is python hooks. Merged rather than rebased (rebasing a pushed PR branch is prohibited). H-10b fired on this merge and was cleared honestly, not bypassed: the 14 flagged lines are all `FARM_API_KEY: "test-key"` in plugins/ca/tools/ farm.test.ts and farm.unit.test.ts, already present 40x on origin/main from #430 — this branch's own commits touch no file under plugins/ca/tools. Product code reads the value from the approved source (`process.env.FARM_API_KEY`, farm.ts:102); the literal "test-key" is a non-credential placeholder that grants nothing and reaches only a spawned test subprocess's env in a temp dir. Reviewed, PASS with no findings, recorded via hooks/security-pass.py. Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
PR #426 (isolated Pi child / ADR-0017 credential-blind config projection) landed on main and claimed ca-pi 0.1.5 — the version this branch had taken in the previous merge. Same collision as #430 before it: several ca-pi PRs are racing for the next patch number. Resolved plugins/ca-pi/CHANGELOG.md by splitting the shared heading and keeping BOTH sections — this branch's durability-guard entry moves up to 0.1.6, main's credential-projection Security section keeps 0.1.5. package.json and the regenerated root metadata follow to 0.1.6, so the Pi payload gate sees a strictly advancing version against the new merge base. No code conflict: #426 is ca-pi TypeScript under tools/ and extensions/, this branch is python hooks. Merged rather than rebased. H-10b fired again and was cleared honestly: the 13 flagged lines all sit in plugins/ca-pi/tools/** and plugins/ca-pi/extensions/codearbiter.js, already on origin/main from #426, and this branch touches none of those paths. They are synthetic NEGATIVE-test fixtures for #426's own credential-blind projection — "sk-literal-operator-secret", "sk-foreign-literal", "dummy-*-value" exist so the tests can assert a literal apiKey FAILS the launch closed; the "$OPENAI_API_KEY" forms are whole-value environment references, the shape that feature permits precisely because it carries no secret. Reviewed, PASS with no findings, recorded via hooks/security-pass.py. Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
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.
The defect
heal_statusline_wiring()incore/pysrc/session-start.pyruns on every SessionStart and refreshes a ca-OWNEDstatusLinepin to the current renderer path. The purpose is legitimate: a plugin cannot own astatusLineand${CLAUDE_PLUGIN_ROOT}is not expanded insidesettings.json, so an absolute, version-pinned path must be written — and nothing re-ran it after a plugin update, so an updated install kept invoking the old version'sstatusline.pyuntil that cache dir was pruned.What it had no notion of is a non-durable root. A session started inside a git worktree (subagents run in
<repo>/.claude/worktrees/<id>/) resolves the plugin root to that worktree, and the heal rewrote the user's global~/.claude/settings.jsonto point there. The worktree is then pruned — its entire purpose — and the statusline renders nothing.Found in-session on 2026-07-25, after it broke the maintainer's statusline three times in one day:
Not agent-specific: any user running codeArbiter from a worktree hits it. There is no issue number — this was found and fixed in the same session.
How a healthy pin degraded into a doomed one
Worth spelling out, because the entry point is not obvious.
is_ours()recognises a legitimate cache pin via_owned_command(). So from a perfectly healthy…/plugins/cache/codearbiter/ca/<ver>/hooks/statusline.py, a worktree session took the heal branch, rewrote the command to the worktree path, and stamped_codearbiterStatuslineOwnerwith it. From that point the worktree pin is "ours" by owner-key match, so every later session kept re-pointing it at whatever worktree it happened to be in. The bug fed itself.Required behaviour, and what this implements
When the current root is non-durable: leave the existing pin alone. Do not heal, do not clear, do not error — degrade silently, as the surrounding code already does.
The feature is not disabled. A genuinely stale pin from a real plugin-cache update still heals, because that root is durable. Both directions are proven at both layers (see Tests).
Clearing instead of skipping was considered and rejected: a user whose only session that day is a worktree session would lose a working statusline over a condition that resolves itself the next time they start from a real install.
Placement — argued
The task asked whether the guard belongs in the caller, the producer, or both. Both, sharing one predicate, and the reasoning differs per site:
wire-statusline.refresh_if_stalereturn Falseheal_statusline_wiringcalls this function directly and never goes throughcmd_refresh, so a guard one level up (incmd_refresh) would have left the confirmed corruption path wide open. Silence matches the automatic/degrade-silently contract of the SessionStart path.wire-statusline.cmd_installSystemExit/ca:statusline, or passed--plugin-root <worktree>by hand. A silent no-op here would leave that human believing the statusline was wired. This is the reachable-by-a-human path the task flagged.session-start.heal_statusline_wiringreturn False_load_wire_statuslineloads the producer out ofpluginitself. A worktree cut from a pre-fix branch therefore supplies a pre-fix, unguarded producer — whilesession-start.pymay have been loaded from somewhere else entirely, sincemain()honours$CLAUDE_PLUGIN_ROOTindependently of where the file came from (exactly the split the pre-existingTestMainHealsBeforeDormantGateharness already exercises). The highest-consequence write on the machine gets to refuse on its own account rather than on the good behaviour of whatever version it happened to load.cmd_status,cmd_uninstallBoth call sites invoke the same
is_ephemeral_path(), so there is one policy and nothing to drift.Salvage: what was reused, what was rejected
A prior agent (killed mid-task by a session limit) left an uncommitted
_durabilitylib.pyplus test drafts. Its branch existed but carried zero commits — nothing to rebase onto. I verified rather than trusted; the judgement calls:Kept. The two-signal design, the fail-durable direction, the "false positive costs one
/ca:statuslinerun, false negative corrupts global config" asymmetry argument, the loud-install/silent-refresh split, and the observation that fixtures driving the real plugin root become checkout-dependent once the guard exists. All sound, all retained (largely rewritten in wording, same substance).Rejected — the
git rev-parsesubprocess. The salvagedin_linked_git_worktree()shelled out togit rev-parse --git-dir --git-common-dirwith a 2s timeout and an injectable runner. Its own module header conceded the probe "fails there every single time" for the ordinary plugin-cache install — which is not a git repo at all. But the lexical check short-circuits only on a positive, so the common, negative, everybody-every-session case fell straight through to the subprocess. That is a process spawn on every SessionStart, before the dormant gate, in every repo on the machine, to learn nothing. Plus a timeout hazard and a dependency on git being installed.Git already writes the answer to disk: a linked worktree's
.gitis a file readinggitdir: <common>/.git/worktrees/<name>; a normal checkout's.gitis a directory. Walking up to the first.gitand reading a bounded prefix gives the identical answer from a handful ofstat()calls, with no git dependency, no timeout, and no injected-runner seam in the tests. It is also more precise: a submodule also uses a.gitfile, but its target ismodules/, notworktrees/— so submodules stay durable, which the subprocess version got right only incidentally.test_module_does_not_import_subprocesspins the constraint so it cannot creep back.Judged, per the task's prompt — is a whole new module over-architecture? I kept it, but only after stripping it to ~2 public predicates. The deciding factor is house style, not speculation:
core/pysrc/already carries small single-concept libs,_gitexec.pyamong them at 45 lines. "Is this path durable enough to pin?" is a topology question, not a statusline question, and folding it into the 1200-line_hooklib.pyjunk drawer or inlining it inwire-statusline.pywould hide it from the next caller. (One is visible already and deliberately out of scope here:_githooks.install(root)installs git hooks pointing at the current plugin path, and linked worktrees share$GIT_COMMON_DIR/hooks— the same bug class, unreported, not touched by this PR.)Tests — TDD, red first
Red was captured before any implementation existed, and reproduced the production corruption verbatim in the failure message:
plus
ModuleNotFoundError: No module named '_durabilitylib'for the new unit suite. +39 tests, all three required directions pinned at both layers:test_worktree_root_leaves_settings_byte_identical(caller),test_refresh_from_worktree_root_leaves_settings_byte_identical(producer), plustest_worktree_root_pin_never_appears_in_settingsasserting the literal stringworktreesis absent from the file afterward.test_durable_stale_pin_still_heals,test_refresh_from_durable_root_still_heals,test_install_from_durable_root_still_wires.Beyond the three:
test_worktree_root_does_not_repair_an_already_worktree_pin(the maintainer's observed corrupt state, re-entered from another worktree — leave it, don't swap one doomed pin for another);test_durable_root_repairs_a_worktree_pin_left_by_the_bug(the forward fix — the corruption already on disk self-heals next time a session starts from a real install);test_caller_refuses_even_an_unguarded_producer(injects a pre-fix producer stub and asserts the heal bails before reaching it, which is the whole reason the caller guards);test_refresh_declines_a_linked_worktree_with_no_worktrees_segment(the git signal earning its place — a worktree the lexical rule cannot see);test_uninstall_from_worktree_root_is_never_blockedandtest_status_from_worktree_root_still_reports_and_writes_nothing.Every test passes an explicit temp
--settings/settings_path. Nothing touches real home settings.Fixture change worth reviewing
TestHealStatuslineWiring,TestMainHealsBeforeDormantGate,TestMainSkipsHealUnderNoStatuslineHostandHealReloadBeforeSaveTestdrove the real plugin root. codeArbiter's own checkout is very often a worktree, so once the guard exists those tests would heal or decline depending on where the suite was run — green in CI, red on a maintainer's machine, for reasons unrelated to what they test. They now use_helpers.durable_plugin_copy(), which copies the top-levelhooks/*.pyinto a temp dir, making "durable" a property of the fixture. (Placed in the existing shared_helpers.pyrather than triplicated across three test modules as the salvage draft had it.)Live-fire verification
Predicate evaluated against the maintainer's real paths, and the CLI driven end-to-end against a temp settings file:
Both signals fire on the real defect vector; both the maintainer's main checkout and their actual
2.8.13-era cache install read durable, so the self-heal they depend on is intact.Gates
python -m unittest discover -s plugins/ca/hooks/testspython tools/sync-core.py --checkpython tools/build-surface.py --checkpython tools/build-host-packages.py --check --release-guard-base <merge-base>Pi payload, version, changelog, and root metadata advanced together: 0.1.5 -> 0.1.6python .github/scripts/check-plugin-refs.py capython .github/scripts/check_docs_contract.pygit diff --check origin/main HEADcaunpublished2.9.1(no tag) andca-codexunpublished0.3.0— not required.ca-pibumped to0.1.6— see below.One correction, caught by CI rather than by me
My first push failed
[GATE ] | [PI ] | Payload version. The Pi guard is stricter than ca's: any change underplugins/ca-pimust ship a strictly advancing version plus regenerated rootpackage.jsonplus a new exact## [X.Y.Z]changelog heading. Vendoring_durabilitylib.pyintoplugins/ca-pi/hooksis such a change.I had run that exact command locally and it printed
no Pi payload change - version bump not required. The reason it lied:pi_release_guarddiffsbase...HEAD— commits, not the working tree — and I ran it before committing, whenHEADwas still the base. Worth knowing for anyone verifying this gate locally: run it after committing, or it proves nothing.Fixed in
f03d08d: ca-pi0.1.3 -> 0.1.4, root metadata regenerated viapython tools/build-host-packages.py, changelog entry added.That number then had to move twice more. #430 landed on main taking
0.1.4, and #426 landed taking0.1.5— several ca-pi PRs are racing for the next patch. Each time I mergedorigin/main(never rebased), split the shared changelog heading so both entries survive, and moved this branch up:0.1.4->0.1.5->0.1.6. No code ever conflicted — #430 is farm TypeScript, #426 isplugins/ca-pi/tools/**, this branch is python hooks — only the version line and its changelog heading. Final verification against the current merge base:CI status
34 checks pass, 0 fail; PR is CLEAN / MERGEABLE. One flake on the way:
[CHECK] | [PI ] | Adapter contract <windows-latest · Pi 0.80.5>hitError: Test timed out in 5000msatplugins/ca-pi/tools/test/background-jobs.test.ts:513. That file is not in this diff — the branch contains zero TypeScript — and the same job passed on the preceding commit against an identical TS tree. It passed on re-run (6m18s; that matrix cell is the slowest in the run). Not investigated further as unrelated to this change.Pre-existing, unrelated:
.github/scriptshas 4 failures intest_pi_benchmark.py, allRuntimeError: Pi benchmark requires the installed pinned esbuild— an environment prerequisite (nonode_modulesin this fresh worktree), untouched by this change. The other 1061 tests in that suite pass.farm.js/sandbox.js/codearbiter*.jswere not rebuilt: this diff contains no TypeScript.Files
core/pysrc/_durabilitylib.py— the predicate (vendored toca,ca-codex,ca-piviasync-core)core/pysrc/wire-statusline.py— guard inrefresh_if_stale(silent) andcmd_install(loud)core/pysrc/session-start.py— guard inheal_statusline_wiringcore/surface/commands/statusline.md— documents the refusal and thatuninstallis never blocked (built toplugins/ca/commands/)plugins/ca/hooks/tests/test_durabilitylib.py;test_wire_statusline.py,test_session_start.py,test_atomic_writes.py,_helpers.pyA note on the two merge commits
Both merges tripped the local
H-10bsecret gate, and both were cleared by actually reviewing rather than bypassing:FARM_API_KEY: "test-key"inplugins/ca/tools/farm.test.ts/farm.unit.test.ts— already present 40x onorigin/mainfrom fix(farm): isolate and atomically publish run receipts, fail the run when they cannot land #430. Product code reads the real value from the approved source (process.env.FARM_API_KEY,farm.ts:102).plugins/ca-pi/tools/**andplugins/ca-pi/extensions/codearbiter.jsfrom feat(ca-pi): unbreak the isolated Pi child — package root, and ADR-0017 credential-blind selected-provider config projection #426 — synthetic negative-test fixtures (sk-literal-operator-secret,dummy-*-value) that exist so the tests can assert a literalapiKeyfails the launch closed; the$OPENAI_API_KEYforms are whole-value environment references, the shape that feature permits precisely because they carry no secret.This branch's own commits touch no file under
plugins/ca/tools/orplugins/ca-pi/tools/. The gate fires only because a merge commit's staged diff re-presents the other branch's added lines. Both reviewed PASS with no findings and recorded throughhooks/security-pass.py; the rationale is written into each merge commit message.https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L