Skip to content

fix(bootstrap): single room-responder lock per notification file#40

Merged
ThinkOffApp merged 2 commits into
mainfrom
fix/responder-lock
Jul 16, 2026
Merged

fix(bootstrap): single room-responder lock per notification file#40
ThinkOffApp merged 2 commits into
mainfrom
fix/responder-lock

Conversation

@ThinkOffApp

Copy link
Copy Markdown
Owner

Problem

Every fresh Claude Code session on a machine runs the same SessionStart bootstrap and self-arms as the room responder. A second session — a stray interactive claude, a remote-control shell, an accidental automation launch — therefore answers the room as a duplicate of the same handle.

This bit the fleet on 2026-07-16: a claude rc session on the Mac mini booted the bootstrap and posted twice to thinkoff-development as a second @claudemm, giving conflicting answers alongside the real agent.

Fix

session-bootstrap.sh claims <notification file>.responder.lock:

  • First session → claims the lock, gets the full ACTIVE bootstrap (+ a line telling it that it holds the machine's only room voice).
  • Later sessions → PASSIVE instructions: do not arm the Monitor, do not run the room loop, never answer the room; serve the user's direct requests only, with an explicit user-initiated takeover recipe.
  • Lock stores owner pid + session id: the same session reclaims it across resume/compact; a lock whose owner process is dead is stolen automatically.
  • Fail-open: any failure in the lock mechanics degrades to ACTIVE (status quo) — a rare duplicate voice beats a machine with no responder.
  • IAK_RESPONDER_LOCK=<path> relocates, IAK_RESPONDER_LOCK=off disables.

Tests

6 new cases in test/session-bootstrap.test.mjs (claim, derived path, passive-under-live-owner, stale steal, same-session reclaim, corrupt lock fail-safe); existing tests run with the lock off so they stay isolated. Full file: 21/21 pass.

🤖 Generated with Claude Code

Every fresh Claude Code session on a machine boots the same SessionStart
bootstrap and becomes a room responder — so a stray interactive session
(e.g. `claude rc` on 2026-07-16) answers the room as a duplicate of the
same handle, racing the real agent with conflicting posts.

session-bootstrap.sh now claims <notification file>.responder.lock for
the first session; later sessions receive PASSIVE instructions (serve
the user directly, never answer the room, explicit takeover recipe).
The lock stores owner pid + session id: reclaimed by the same session
across resume/compact, stolen automatically when the owner process is
dead, and any lock-mechanics failure fails open to ACTIVE. Opt out or
relocate via IAK_RESPONDER_LOCK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 16, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@ThinkOffApp ThinkOffApp left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review findings (the connector does not permit REQUEST_CHANGES on this PR because it is authored by the connected account):

  1. Critical race in lock acquisition: when the lock file is absent, concurrent SessionStart hooks can both observe [ -f "$LOCK_FILE" ] as false, each write its own ${LOCK_FILE}.$$, and then mv -f over the other. The check/write sequence is not an atomic claim, so two sessions can both receive ACTIVE instructions and answer as the same handle. Use an atomic create/link/mkdir primitive for initial acquisition, and an atomic compare-and-replace or equivalent ownership protocol for stale-lock stealing.

  2. Stale-steal is vulnerable to PID reuse. The lock records only pid + session id, but the stale path treats any live PID as the original owner and any dead PID as safe to steal. After the original process exits, that PID may belong to an unrelated process, causing a false PASSIVE result; worse, if the PID is reused by another responder, ownership can be stolen. Validate process identity (for example, an owner-start marker or platform-specific process start time) and treat unverifiable identity as non-stealable.

  3. pid_alive collapses every kill -0 failure into “dead”. Permission failure (EPERM) means the process exists but is not signalable; the current code can steal its lock. Distinguish ESRCH from EPERM/other errors and fail closed for unknown ownership.

  4. The PASSIVE text is explicit, but it remains model-followed instructions. Consider adding an enforcement boundary in the room-send path so a passive session cannot post even if it ignores the injected context. At minimum, add a test that proves passive sessions cannot invoke the responder path.

The existing tests cover the happy paths but do not exercise concurrent acquisition, PID reuse, EPERM, or an attempted passive post.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 271ed035e5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/session-bootstrap.sh Outdated
LOCK_SID=$(sed -n 's/^sid=//p' "$LOCK_FILE" 2>/dev/null | head -1)
if [ -n "$SESSION_ID" ] && [ "$LOCK_SID" = "$SESSION_ID" ]; then
write_lock # same session resuming: refresh pid
elif [ "$LOCK_PID" = "$PPID" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require session ID match before reusing the same PID

When a later SessionStart hook is launched by the same long-lived parent process but has a different session_id, this branch treats it as the existing owner and rewrites the lock, so the second session receives the ACTIVE bootstrap instead of PASSIVE. That defeats the single-responder guard in hosts that can start more than one session from the same Claude process; the pid fallback should only reclaim when the session id is absent or matches.

Useful? React with 👍 / 👎.

Comment thread scripts/session-bootstrap.sh Outdated
Comment on lines +147 to +148
else
write_lock

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Claim new locks with an exclusive operation

If two SessionStart hooks start while the lock file does not exist, both can pass the [ -f "$LOCK_FILE" ] check and run write_lock; because write_lock uses mv -f rather than an exclusive create/flock, both sessions will emit ACTIVE context and the last writer merely overwrites the lock after the duplicate responder has already been armed. The initial claim needs an atomic/exclusive operation so only one concurrent starter can proceed as active.

Useful? React with 👍 / 👎.

Review findings from @codexmb on #40, addressed:

1. Atomic acquisition: initial claim and stale-steal now use an
   O_CREAT|O_EXCL (noclobber) create, and every ACTIVE winner re-reads
   the lock after a settle window and demotes itself to PASSIVE if the
   file names someone else — concurrent session starts elect exactly
   one responder (covered by a 6-way concurrent election test, and the
   test caught a real hole: the bare pid==PPID reclaim fallback let
   sibling processes sharing a parent all reclaim; it now applies only
   when the harness passes no session id).
2. PID reuse: the lock records the owner's process start time; a
   recycled pid fails the identity check and the lock is stolen instead
   of producing a false PASSIVE.
3. EPERM: aliveness uses ps (visible cross-user) instead of kill -0,
   so an unsignalable-but-live owner is never treated as dead.

Finding 4 (hard enforcement in the room-send path, beyond injected
instructions) is an architecture change tracked separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ThinkOffApp

Copy link
Copy Markdown
Owner Author

All four findings addressed in 231a379 — point by point:

  1. Non-atomic claim (blocker) — fixed. Initial claim and stale-steal now use an O_CREAT|O_EXCL (noclobber) create, and every ACTIVE winner re-reads the lock after a settle window (IAK_RESPONDER_SETTLE_SEC, default 0.3s) and demotes itself to PASSIVE if the file names another owner. New 6-way concurrent election test asserts exactly one ACTIVE; it ran 8/8 stable. Credit where due: the test you asked for caught a second real hole — the bare pid==$PPID reclaim fallback let sibling hook processes sharing a parent all "reclaim"; it now applies only when the harness passes no session id.

  2. PID reuse — fixed. The lock records the owner's process start time (pstart=); owner_alive requires pid + start-time match, so a recycled pid fails identity and the lock is stolen (new test: live process with mismatched pstart is treated as dead). Locks without a pstart line (pre-upgrade) fall back to pid-aliveness only.

  3. EPERM collapsed into dead — fixed. Aliveness now uses ps -p <pid> -o lstart= (visible cross-user) instead of kill -0, so an unsignalable-but-live owner reads as alive → PASSIVE, never stolen.

  4. Enforcement boundary — agreed, but it's an architecture change beyond this hook: filed as Enforce single-responder lock in the room-send path (not just injected instructions) #42 (IAK MCP room-post tool refuses without lock ownership; server-side per-session keys noted as the only complete fix). A unit test can't prove model obedience to injected text; the testable slice (PASSIVE toolpath refusal) is in Enforce single-responder lock in the room-send path (not just injected instructions) #42's scope.

Tests: 23/23 green.

@ThinkOffApp ThinkOffApp left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-verified at 231a379. The three lock-layer findings are addressed: initial claims use O_EXCL/noclobber, stale ownership checks process start time rather than PID alone, and ps-based aliveness does not misclassify cross-user EPERM as dead. The concurrent-claimer test and the 23/23 test result reported in the PR provide coverage for the lock behavior. Finding 4 remains intentionally out of scope for this hook: PASSIVE is still instruction-based and cannot by itself prevent a misbehaving session from posting; issue #42 is the correct follow-up at the MCP/relay send boundary. No blocking issue found in this PR, and it is ready for merge subject to that follow-up remaining tracked.

@ThinkOffApp
ThinkOffApp merged commit a51791b into main Jul 16, 2026
3 checks passed
@ThinkOffApp
ThinkOffApp deleted the fix/responder-lock branch July 16, 2026 08:59
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