The contract probe armed yesterday failed on its first real run with
CANNOT-OBSERVE. It was right to fail loudly, and it was right about the cause: on a
GitHub runner none of the ~/.local/share/claude paths exist. Same shape as the
worktree bug in #3410, one layer down: a tool holding a belief about its own environment that nothing
had checked against the environment.
## Binary layout which: /opt/hostedtoolcache/node/22.23.1/x64/bin/claude /home/runner/.local/share/claude/versions: ABSENT /home/runner/.local/share/claude: ABSENT /home/runner/.claude: ABSENT derive-cc-output-keys exit code: 2
That diagnostic step was written to print unconditionally, before the check, precisely so a CANNOT-OBSERVE would name the layout instead of leaving the next person to repeat the experiment. It did exactly that, and this fix is a direct read off its output.
| installer | path |
|---|---|
| native | ~/.local/share/claude/versions/<x.y.z> |
| npm | <prefix>/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe |
The npm package ships no bundled
JS. install.cjs:210 writes its payload to
join(__dirname, 'bin', 'claude.exe') inside the package directory, delivered through
per-platform optionalDependencies. It is the same artifact, not a re-packaging:
279,661,952 bytes in both layouts for 2.1.226.
My first comment on the issue assumed npm shipped a bundled cli.js and warned that the
derived key set might differ, asking whoever implemented this to diff the two. Installing the package to
a throwaway prefix showed there is no cli.js at all. Since both layouts resolve to the same
bytes, the sets cannot differ, and the measurement below confirms it rather than assuming it.
The comment this fix replaces said
"command -v claude may be a wrapper", which is a real hazard and the reason PATH was avoided.
A shim would grep to zero events, and because the drift check is missing-only
(binaryEvents.filter(e => !allowed.has(e))) an empty set has an empty difference:
a vacuous pass, which is the exact failure this gate exists to prevent.
It is already handled, one layer up, and the fix leans on
that rather than duplicating it: the caller requires a hookSpecificOutput literal in the
extracted strings and reports CANNOT-OBSERVE without it. Case C below is the proof.
| case | exit | result |
|---|---|---|
| A. native layout | 0 | PostToolBatch, Stop, SubagentStop |
B. npm layout, no ~/.local/share | 0 | byte-identical output to A |
| C. wrapper shim on PATH | 2 | CANNOT OBSERVE: no hookSpecificOutput literal |
| D. neither layout present | 2 | CANNOT OBSERVE, naming both layouts |
--check, native layout OK: every event the binary names is in the allow-list --check, npm layout OK: every event the binary names is in the allow-list
First run of the test above: exit=127 for cases B, C and D. Cause: narrowing PATH to reach the npm bin also removed `node` and `strings`, which the script needs. That is the HARNESS failing, not the fix. Read as a result it would have looked like the fallback was broken.
What this unblocks. EPIC B's whole point is that CI had been validating
OrchestKit against a snapshot no process refreshed. The probe closes that loop, but until now it closed
it onto a path that does not exist on the only machine scheduled to run it, so the nightly would have
gone red every day at 07:00 UTC while verifying nothing. PATH is scanned directly rather than via
command -v (a shell builtin, not an executable) or which (not guaranteed on a
minimal image), and directories named claude are skipped rather than returned.