Skip to content

fix(clawgate): resolve the OPENCODE session id first, so a nested run stops claiming its parent's tasks - #1365

Open
ZacxDev wants to merge 4 commits into
mainfrom
fix/clawgate-handoff-opencode-session-id
Open

fix(clawgate): resolve the OPENCODE session id first, so a nested run stops claiming its parent's tasks#1365
ZacxDev wants to merge 4 commits into
mainfrom
fix/clawgate-handoff-opencode-session-id

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Sep 7, 2026

Copy link
Copy Markdown
Member

fix(clawgate): resolve the OPENCODE session id first, so a nested run 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

Verification

Red at base, green at HEAD. 5 of the 8 new tests fail at 969f0581. The
load-bearing one fails with the bug in the URL it asked for:

urls=['http://clawgate.invalid:1/api/sessions/claude-parent-111/tasks']
assert ['claude-parent-111'] == ['oc-sess-777']

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, and
an_empty_opencode_var_falls_through_to_claude, which passes at base
vacuously (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):

  • dev-host gate.sh --tier both: pytest RESULT: PASS 22011 collected /
    22008 passed / 0 failed; node RESULT: PASS 1449/1449
  • sandbox nix build .#checks.x86_64-linux.{pytests,nodetests}, built ONE AT A
    TIME: both RESULT: PASS (exit=0), same totals

Not 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

ZacxDev and others added 2 commits September 7, 2026 15:00
… 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
@ZacxDev

ZacxDev commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Round 1 audit complete; fixes pushed as 80034d64. Claims block for the next round.

1. 🟡1 FIXED — a fail-closed refusal (exit 3) was added for the case `$OPENCODE` is set while `OPENCODE_SESSION_ID` is absent-or-empty, so an INHERITED `CLAUDE_CODE_SESSION_ID` is no longer used to query the board.
2. 🟡1b FIXED — the test that pinned that fallthrough as intended was replaced; the empty-var fallthrough is now asserted only OUTSIDE opencode, and both empty and absent spellings are driven inside it.
3. 🟡2 NOT FIXED, RECORDED — the writer (`clawgatectl` `sessionIDEnvNames`) has no opencode tier, so opencode sessions resolve exit 5 rather than their own tasks. Stated in the lib header with its measurement date and repo sha; claimed to buy correctness, not capability.
4. 🟡3 FIXED — a mirror assertion was added so a refusal on the CLAUDE tier must name `CLAUDE_CODE_SESSION_ID` and not `OPENCODE_SESSION_ID`; the previously-surviving hardcode mutant is claimed to be killed.
5. 🟡4 FIXED — `claude/skills/handoff/SKILL.md` was corrected to name both variables and the refusal, and two entries were added to `HANDOFF_PINS` to keep it from drifting back.
6. 🟢5 FIXED — `shell_fn_body` was widened to accept `name() {` and the seam test's duplicate extractor was deleted.
7. 🟢6 NOT FIXED — the three resolvers still differ on how they degrade for an INVALID id (hard refusal / no validation / trim-and-fall-through); only ordering is pinned.
8. Gating claim — all four tier runs green on the MERGED tree `d682f926` (base `40ca50da`): dev-host pytest 22008/22005/0 + node 1449/1449; sandbox pytests 22008/22005/0 + nodetests 1449/1449.

… 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
@ZacxDev

ZacxDev commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Round 2 audit complete; fixes pushed as a69bbe44. Claims block for round 3's auditor.

round 2 · payload lines changed THIS round: 39 · round 3 · payload lines changed THIS round: 25 (SKILL.md 2 + lib 23; the 84 test lines are scaffolding). Gate has not fired — no zero-payload round yet.

Gating: all four tiers green on MERGED tree fbde4059 (base e9e9075f) — dev-host pytest 22047/22044/0 + node 1449/1449; sandbox pytests 22047/22044/0 + nodetests 1449/1449.

1. F1 FIXED — `_base_env` now pops the `OPENCODE` marker, so the suite is no longer sensitive to an ambient variable opencode sets (was 86 failed under `OPENCODE=1`; claimed 246 passed now).
2. F1b FIXED — the harness guard for that pop was VACUOUS (asserting a name is absent passes for free when the shell never had it; deleting the pop SURVIVED 246 tests). It now INJECTS each variable with a positive control, then asserts `_base_env` removes it.
3. F2 FIXED — the header's universal ("the only id in reach IS inherited") is retracted to "may be", the MIRROR nesting (Claude Code launched FROM opencode, exporting its own id) is named as the counter-case, and the fail-closed trade is stated. The same overclaim in `claude/skills/handoff/SKILL.md` is qualified too.
4. F3 FIXED — the `HANDOFF_PINS` entry no longer pins the bare `$OPENCODE` prefix; it pins a phrase containing the verb REFUSES.
5. F4 FIXED — the seam test now pins that each resolver consults the `OPENCODE` marker, not only the tier ordering.
6. F4b FIXED — F4's first draft asserted the marker INSIDE the resolving function and failed against `browser`, which was never defective; the two implementations place the arm at different scopes, so the marker assertion is file-scoped while ordering stays body-scoped.
7. Mutation claim — ME (drop the OPENCODE pop) KILLED 1 where it previously SURVIVED; MD (delete the marker arm) KILLED 3 including the seam guard. Control 246 before and after each.
8. SKILL.md byte ceiling — the headroom test caught a breach (839 of 900 required); reclaimed by trimming this change's OWN wording, not by moving the ceiling.

… 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
@ZacxDev

ZacxDev commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Round 3 audit complete; fixes pushed as 66e6d791.

🔴 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 (INHERITED|inherited|ancestor across all three files, positive control 11 hits in the lib; one further loose phrase found and fixed that nobody reported).

Deliberately NOT fixed — open, not absent:

  1. 🟡 clawgatectl (homelab-talos) has no opencode tier, so this PR buys CORRECTNESS, not capability: opencode sessions resolve exit 5 rather than their own tasks. Recorded in the lib header with its measurement date and repo sha. Closing it is a Go change in another repo.
  2. 🟢 The three resolvers still differ on how they degrade for an INVALID id (hard refusal / no validation / trim-and-fall-through). Only ordering and the marker consult are pinned.
  3. ⚠ No live opencode run was ever exercised end-to-end. Evidence is the suite plus the mutation matrix.

Correction to my own round-2 commit message: it cited a headroom breach of "839 of 900". At 80034d64 headroom was 902 — already passing — so that number describes no committed tree; the breach existed only in an uncommitted draft. The trim was real; shipped state has 934 free.

Gating, merged tree 341aa654 (base bac41175), all four tiers: dev-host pytest RESULT: PASS 20914/20912/0 + node RESULT: PASS 1449/1449; sandbox pytests RESULT: PASS 20914/20912/0 + nodetests RESULT: PASS 1449/1449. (Collected fell 22047→20914 because main dropped two targets; my coverage was confirmed intact — 246 collected, the new class 12 passed, in the merged tree.)

1. F-A FIXED — the runtime refusal message 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.
2. F-A-sweep FIXED — the whole class was swept across all three files rather than the reported line, with a positive control; one further loose phrase ("an ancestor's tasks" -> "ANOTHER SESSION'S tasks") was found and fixed by that sweep.
3. F-B FIXED — the seam guard now requires `${OPENCODE:-}` in the CODE half of a line, closing the inline-comment walk; re-running the auditor's exact walk now FAILS on the seam guard where it previously left 246 passed.
4. Ladder stopped on the stated prose criterion, with the rationale and the three unfixed items recorded above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant