fix(clawgate): resolve the OPENCODE session id first, so a nested run stops claiming its parent's tasks - #1365
fix(clawgate): resolve the OPENCODE session id first, so a nested run stops claiming its parent's tasks#1365ZacxDev wants to merge 4 commits into
Conversation
… stops claiming its parent's tasks
`clawgate_resolve` read only `CLAUDE_CODE_SESSION_ID`. opencode INHERITS that
variable from the outer Claude Code session and hands it to its tool shells
verbatim, which gave two failures of different severity:
* DETACHED opencode — no claude var at all, so resolve returned exit 3
forever and the board was never asked. Loud.
* NESTED opencode — the claude var IS set, inherited, so resolve returned
exit 0 carrying ANOTHER SESSION'S TASKS. Silent, and indistinguishable
from a clean resolution.
The fix is the ORDER, not merely reading a second variable: OPENCODE_SESSION_ID
is tier 0, CLAUDE_CODE_SESSION_ID tier 1. That precedence is not invented here —
it is the same order, for the same stated reason, as `derive_session_id` in
scripts/browser-bridge/browser: opencode's id is the only one rewritten on every
tool call, while the claude vars can be stale values inherited from an ancestor.
Refusal messages now name the variable the id actually CAME FROM. A message
hardcoding one name while the code reads another is the same inert-feature class
the function's own header warns about, one layer out.
Tests (8 new, 177 -> 185 defs; file 240 collected, 0 failed):
- detached opencode resolves instead of exiting 3
- nested opencode asks about the OPENCODE id and the parent's id never
reaches the wire (asserted in BOTH directions — an absence alone would be
satisfied by a subject that asked about neither)
- claude-only unchanged; set-but-EMPTY opencode falls through
- neither set -> exit 3 naming BOTH variables, and nothing asked
- a bad opencode id is reported against OPENCODE_SESSION_ID, not the claude one
- _base_env now pops OPENCODE_SESSION_ID: it is TIER 0, so an inherited value
would not merely answer a question the subject should fail, it would
OUTRANK the variable each test sets and silently rewrite every claude-tier
assertion
- a SEAM guard pinning that this resolver and browser's read opencode first,
since a divergence misattributes runs while both stay internally consistent
Every assertion is on the URL actually requested, not on stdout wording: stdout
cannot distinguish "picked the right id" from "picked the wrong one and
described the right one".
The seam guard's first draft was a whole-file substring search, which measured
where the two names are first MENTIONED — in browser that is header prose 26 KB
above the code — and failed against an implementation already correct. It now
extracts the function body and decomments it, so it reads branches rather than
words.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwUQmUw99gqMug4T3ruFNC
Claude-Session-Id: 747b8a7d-0f55-4b5e-b364-625c1f0d6425
…us half open
Round-1 audit findings. The first commit copied `derive_session_id`'s precedence
and called it the fix; the precedence is only HALF of that function's design.
🟡1 (behaviour) — `browser` pairs the ordering with a second arm
(`browser:769`): when the id came from the claude tier while `$OPENCODE` is set,
it tags the id `opencode-inherited` and `server.py` refuses it as a session key.
Only the ordering was ported, so whenever `OPENCODE_SESSION_ID` was
absent-or-empty the old code fell through to the INHERITED parent id and
returned exit 0 with another session's tasks — verbatim the failure the PR
claimed to remove.
Not theoretical: `scripts/opencode/plugin/session-env.js` sets
`OPENCODE_SESSION_ID=""` deliberately on the PTY path, and its comment asserts
this "degrades to the same fail-closed path as no plugin at all" — TRUE of
`browser`, FALSE here until now. Worse, the first commit PINNED that gap as
intended (`test_an_empty_opencode_var_falls_through_to_claude`).
Now: `$OPENCODE` set + no usable opencode id => exit 3, board never asked. The
marker is reliable — opencode's CLI sets it in a yargs TOP-LEVEL `.middleware()`,
verified in the pinned bundle and by a live env dump.
🟡2 (behaviour, NOT fixed here — recorded) — the WRITER has no opencode tier.
MEASURED 2026-09-07 against homelab-talos `b3ad3d8cb`: `grep -rl OPENCODE
containers/` returns ZERO files against FIVE for `CLAUDE_CODE_SESSION_ID`, so
links are always keyed by a claude id and an opencode session asking about its
own id gets exit 5. This tier therefore buys CORRECTNESS, not capability —
honest silence replacing a confident wrong answer. Stated in the header with its
measurement date, because it is a claim about another repo on a given day.
🟡3 (guard) — a SURVIVING mutant the audit found: hardcoding `$OPENCODE_SESSION_ID`
into the tier-1 refusal passed all 240 tests, because the only assertion on which
variable a refusal names was on tier 0. A one-directional pair cannot see a
message that always names tier 0. Mirror test added; that mutant now dies.
🟡4 (behaviour) — `claude/skills/handoff/SKILL.md` still told every executor the
id comes from `CLAUDE_CODE_SESSION_ID`. Corrected, and both the new tier and the
refusal are added to `HANDOFF_PINS` so the doc cannot drift back silently.
🟢5 (guard) — the seam test open-coded a SECOND shell-function-body extractor.
`shell_fn_body` widened to accept `name() {` and the copy deleted: one concept,
one implementation. A cosmetic `clawgate_resolve() {` would otherwise have
broken the helper's five call sites while the copy passed.
Mutation matrix (control 12/12 before and after each, isolated `cp -a` copy with
`.git` removed, PYTHONDONTWRITEBYTECODE=1):
MA delete the arm (under-refuse) -> KILLED 2, both parametrized cases
MC hoist above tier 0 (over-refuse) -> KILLED 1
MB hardcode tier 0 (the survivor) -> KILLED 1
Both directions guarded, so the arm is reachable and not merely breakable.
An existing guard caught a regression mid-round: quoting clawgatectl's Go list
verbatim reintroduced the bare `CLAUDE_SESSION_ID` spelling that
test_the_wrong_variable_name_appears_in_neither_skill_nor_the_code exists to
keep out. Reworded rather than exempted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwUQmUw99gqMug4T3ruFNC
Claude-Session-Id: 747b8a7d-0f55-4b5e-b364-625c1f0d6425
|
Round 1 audit complete; fixes pushed as |
… its own prose overclaimed
Round-2 delta audit. It confirmed every round-1 claim (reproducing the mutation
matrix independently) and found four things, three of them introduced by the
round-2 fix itself.
🟡F1 (guard) — adding the `$OPENCODE` refusal made this suite sensitive to an
ambient variable and did not teach the harness to drop it. MEASURED on the
round-2 commit: 246 passed clean, **86 failed** with `OPENCODE=1` exported; 240
passed at the pre-refusal commit under the same variable, so the sensitivity is
created by the feature. This repo ships `/handoff` as an opencode command and
opencode hands its tool shells `{...process.env}`, so an agent running the gate
from an opencode shell got 86 failures belonging to no diff. `_base_env` now
pops the marker.
🔴 AND MY FIRST FIX FOR IT WAS VACUOUS — caught by mutation, not by review.
`test_the_harness_carries_no_session_variable_at_all` read `_base_env()` and
asserted each name absent, which is TRUE FOR FREE when the ambient shell never
had that name: deleting the new `env.pop` SURVIVED all 246 tests. The test now
INJECTS each variable (with a positive control that the injection landed) and
asserts the pop removes it. Same lesson this file already records for the
session ids, one variable over.
🟡F2 (behaviour/prose) — the round-2 header asserted a universal: "the only id
in reach IS a CLAUDE_CODE_SESSION_ID INHERITED from an ancestor". False. The
MIRROR nesting exists — a Claude Code session launched FROM an opencode tool
exports its OWN id while `OPENCODE=1` and an empty `OPENCODE_SESSION_ID` ride
down — and `browser` records exactly that residual, stating no environment
variable separates the two directions. The BEHAVIOUR is still right (fail-closed:
a missing field beats a wrong one) but the CLAIM was not, and the same overclaim
had been copied into the operator-facing SKILL.md sentence. Both qualified to
"may be", with the trade named. The retracted draft is recorded so nobody
re-derives it.
🟢F3 (guard) — the `HANDOFF_PINS` entry was the bare `$OPENCODE`, a PREFIX of
`$OPENCODE_SESSION_ID`: one ordinary edit elsewhere in the doc would satisfy it
with the refusal sentence deleted, and a reword to "...it USES the claude id"
would satisfy it while contradicting the code. Pins the verb now.
🟢F4 (guard) — the seam test pinned ORDERING only, which is what let the round-1
defect through: both resolvers "agreed" while one still used an inherited id. It
now also pins that each consults the `OPENCODE` marker.
🔴 F4's first draft was itself wrong and FAILED against `browser`, which was
never defective: it asserted the marker INSIDE the resolving function, but the
two implementations place the arm differently — this lib refuses inside
`clawgate_resolve`, `browser` tags the returned id at file scope and lets
server.py refuse it. Ordering is a within-function property; consulting the
marker is not. The first draft measured a layout difference and called it a
defect; it is file-scoped now, with that recorded.
Mutation matrix (control 246 before and after each, isolated `cp -a`, no `.git`,
PYTHONDONTWRITEBYTECODE=1):
ME drop the OPENCODE pop -> KILLED 1 (was SURVIVING before the vacuity fix)
MD delete the marker arm -> KILLED 3, incl. the seam guard that missed it before
SKILL.md stayed under its byte ceiling by trimming this change's own wording
("and prints one verdict", and a shortened qualification) rather than touching
the ceiling: the headroom test caught the breach at 839 of 900 required.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwUQmUw99gqMug4T3ruFNC
Claude-Session-Id: 747b8a7d-0f55-4b5e-b364-625c1f0d6425
|
Round 2 audit complete; fixes pushed as
Gating: all four tiers green on MERGED tree |
… the message an operator reads
Round-3 delta audit. It reproduced every round-2 claim independently — including
re-deriving the three docstring measurements (246 clean / 86 under OPENCODE=1 /
240 pre-refusal) and reconstructing the vacuous first draft to watch it SURVIVE
246 — and found two things.
🟡F-A (behaviour, payload) — a THIRD copy of the universal round 2 retracted,
in the runtime refusal MESSAGE itself. The sweep reached the comment and
`claude/skills/handoff/SKILL.md` and stopped one line short of the only surface
an operator actually reads — and `SKILL.md`'s step copies that text into a
durable handoff doc the next `/resume` treats as fact. In the mirror nesting
(Claude Code launched FROM an opencode tool) both its sentences are false: the
id is the session's OWN, and asking would return its own tasks. Now carries the
same "MAY be … nothing here can tell the two apart" qualification as the comment
above it. The companion copy in the test docstring is corrected too.
🟢F-B (guard, scaffolding) — the round-2 marker assertion was walkable by an
INLINE comment: `_decommented` blanks WHOLE-LINE comments only (deliberately —
these files carry `#` inside quoted strings), so deleting browser's arm and
leaving `# was gated on OPENCODE` left the file 246 passed. MEASURED, then
closed: it now requires `${OPENCODE:-}` — the form both files actually write —
in the code half of some line. Re-running the auditor's exact walk against the
fix now FAILS on the seam guard.
🔴 THE SHAPE IS NOW SWEPT AT EVERY SITE, NOT AT THE ONE REPORTED. Three rounds
running, the finding was the same false sentence in a new location, so this
commit sweeps all three files for the whole class (`INHERITED|inherited|
ancestor`, positive control 11 hits in the lib) rather than fixing the reported
line. Result: every unconditional assertion is qualified; the survivors are
either explicitly scoped to the NESTED case (where inheritance is true by
construction) or are test-fixture-local messages the fixture makes true. One
further loose phrase found by that sweep and fixed here — "an ancestor's tasks"
-> "ANOTHER SESSION'S tasks" in the writer-gap block.
⚠ One claim in the round-2 commit message is NOT verifiable and is corrected
here: it cited a headroom breach of "839 of 900". At `80034d64` headroom was
902 — already passing — so that breach existed only in an uncommitted
intermediate draft. The trim was real and the shipped state has 934 free; the
number simply describes no committed tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwUQmUw99gqMug4T3ruFNC
Claude-Session-Id: 747b8a7d-0f55-4b5e-b364-625c1f0d6425
|
Round 3 audit complete; fixes pushed as 🔴 I am stopping the ladder here on the STATED CRITERION, not on a clean round. Those are different things and a reader must be able to tell them apart. Round 3 returned findings that needed fixing, so the findings-keyed rule would owe a round 4. Why the rounds will not stop on their own: the payload here is prose, so the attribution gate is structurally unable to fire — "fixed a defect" and "reworded a warning" are the same edit. Three consecutive rounds found the identical shape (a false sentence asserting the claude id is inherited) at a new site each time: the header comment (r1→r2), the SKILL.md sentence (r2), the runtime refusal message (r3). Criterion met: no 🔴 in any round · the behaviour has been correct since round 1's fix and every round since changed only explanation · blast radius confined to a false explanatory sentence · and the recurring SHAPE is now swept at EVERY site rather than at the one reported ( Deliberately NOT fixed — open, not absent:
Correction to my own round-2 commit message: it cited a headroom breach of "839 of 900". At Gating, merged tree |
fix(clawgate): resolve the OPENCODE session id first, so a nested run stops claiming its parent's tasks
clawgate_resolveread onlyCLAUDE_CODE_SESSION_ID. opencode INHERITS thatvariable from the outer Claude Code session and hands it to its tool shells
verbatim, which gave two failures of different severity:
forever and the board was never asked. Loud.
exit 0 carrying ANOTHER SESSION'S TASKS. Silent, and indistinguishable
from a clean resolution.
The fix is the ORDER, not merely reading a second variable: OPENCODE_SESSION_ID
is tier 0, CLAUDE_CODE_SESSION_ID tier 1. That precedence is not invented here —
it is the same order, for the same stated reason, as
derive_session_idinscripts/browser-bridge/browser: opencode's id is the only one rewritten on every
tool call, while the claude vars can be stale values inherited from an ancestor.
Refusal messages now name the variable the id actually CAME FROM. A message
hardcoding one name while the code reads another is the same inert-feature class
the function's own header warns about, one layer out.
Tests (8 new, 177 -> 185 defs; file 240 collected, 0 failed):
reaches the wire (asserted in BOTH directions — an absence alone would be
satisfied by a subject that asked about neither)
would not merely answer a question the subject should fail, it would
OUTRANK the variable each test sets and silently rewrite every claude-tier
assertion
since a divergence misattributes runs while both stay internally consistent
Every assertion is on the URL actually requested, not on stdout wording: stdout
cannot distinguish "picked the right id" from "picked the wrong one and
described the right one".
The seam guard's first draft was a whole-file substring search, which measured
where the two names are first MENTIONED — in browser that is header prose 26 KB
above the code — and failed against an implementation already correct. It now
extracts the function body and decomments it, so it reads branches rather than
words.
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01AwUQmUw99gqMug4T3ruFNC
Claude-Session-Id: 747b8a7d-0f55-4b5e-b364-625c1f0d6425
Verification
Red at base, green at HEAD. 5 of the 8 new tests fail at
969f0581. Theload-bearing one fails with the bug in the URL it asked for:
i.e. unfixed, a nested opencode run asked the board about its PARENT's session.
3 pass at base and are NOT counted as regression coverage — labelled as
invariant guards:
claude_only_is_unchanged,the_harness_carries_no_session_variable_at_all, andan_empty_opencode_var_falls_through_to_claude, which passes at basevacuously (base ignores the variable entirely) and only becomes meaningful
after the fix.
Mutation-tested. Control 8/8 green; an order-swap mutant (claude branch
first — a pure reordering, the narrowest expression that can be wrong) KILLED 2,
each with its own specific error: the behavioural nested-opencode test and the
structural seam test. The behavioural kill is the one that matters — a
structural check alone type-checks past a wrong argument.
Both tiers, on the merged tree (base
969f0581, main unmoved at push):gate.sh --tier both: pytestRESULT: PASS22011 collected /22008 passed / 0 failed; node
RESULT: PASS1449/1449nix build .#checks.x86_64-linux.{pytests,nodetests}, built ONE AT ATIME: both
RESULT: PASS (exit=0), same totalsNot claimed: this was not exercised against a live opencode run — the evidence
is the suite plus the mutation matrix.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AwUQmUw99gqMug4T3ruFNC