#3409 · playground · 2026-08-10

Two install layouts. The gate knew one.

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.

What the run reported, unprompted

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

Where the binary actually goes

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

The premise I got wrong first

I asked for a check that was not needed, based on a guess

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.

Why the fallback cannot be trusted blindly, and is not

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.

Four cases, measured

caseexitresult
A. native layout0PostToolBatch, Stop, SubagentStop
B. npm layout, no ~/.local/share0byte-identical output to A
C. wrapper shim on PATH2CANNOT OBSERVE: no hookSpecificOutput literal
D. neither layout present2CANNOT 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

One harness bug worth recording

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.