Skip to content

fix(security): require auth on /mcp — closes an unauthenticated RCE (ADR-054) - #281

Merged
aterrylu merged 3 commits into
mainfrom
terry/security-mcp-auth-bind
Jul 18, 2026
Merged

fix(security): require auth on /mcp — closes an unauthenticated RCE (ADR-054)#281
aterrylu merged 3 commits into
mainfrom
terry/security-mcp-auth-bind

Conversation

@aterrylu

Copy link
Copy Markdown
Owner

Problem

Two defects composed into unauthenticated remote code execution. Both verified live against the local server and forge — not inferred from reading code.

1. /mcp had no auth at all. requireAuth was mounted on /api/* and /ws/* only. /mcp — the Streamable-HTTP transport exposing create_agent, kill_agent, set_manager — matched neither prefix and had no internal check.

2. The server bound every interface. serve() was called with no hostname, so Node bound 0.0.0.0/::. Confirmed live: node … TCP *:3100 (LISTEN).

POST /api/agents      → 401                          (auth worked)
POST /mcp initialize  → 200 + mcp-session-id         (NO auth)
POST /mcp tools/list  → create_agent, kill_agent, create_schedule, set_manager, …

Together: any host on the same network — café, office, dorm — could open an MCP session with a single curl and create_agent with permissionMode: "bypass", an arbitrary workingDirectory (runtime.ts checks existence only) and an arbitrary prompt. That is arbitrary code execution as the server's user, no credential, no skill. CVSS ≈ 9.8. The repo is public with tagged releases (latest v0.4.0), so this shipped.

The chain was proven and stopped at tools/list; create_agent was never called.

flowchart LR
    A[Any host on the network] -->|no credential| B{"bind *:3100"}
    B --> C["POST /api/agents"] --> D["401 ✅"]
    B --> E["POST /mcp"] --> F["no middleware<br/>no internal check"]
    F --> G["initialize → 200 + session"]
    G --> H["create_agent<br/>bypass + any cwd + any prompt"]
    H --> I["RCE as the server's user 💥"]
Loading

Solution

Auth is the boundary; the bind is defense-in-depth. That ordering is Terry's "always require auth" ruling, and it's what let this delete the localhost-trust category instead of gating it — no peer-address check, no exemption to get subtly wrong. It costs nothing: auth.ts has always generated and persisted a random 256-bit token on first start, so there is no tokenless state to design around.

  • app.use("/mcp", requireAuth) — checked at the transport boundary, so an unauthenticated caller cannot complete initialize and never obtains the session id later calls depend on. Asserting only that tools/call 401s would have missed a regression leaving session creation open.
  • Bind 127.0.0.1 by default, with --host / AUTONOMOS_HOST as explicit opt-in, threaded through install-serviceinstall-prod-service.shMakefile (BIND_HOST) so a deliberately-exposed box keeps its bind across make deploy.
  • An exposed bind warns at startup — and names the routes that are still open rather than claiming blanket coverage.

Known residual — deliberately NOT closed here

POST /api/hooks/* and GET /api/host remain unauthenticated on every bind. They cannot spawn or control agents, but an unauthenticated caller can forge agent status and inject dashboard notifications (including the proactive: true push path).

Deferred on purpose: authenticating the relay changes how the token travels (TokenSecurityAudit@autonomOS's scope) and risks a fleet-wide status blackout if the token fails to reach the hooks. That needs its own PR with a real-spawn /qa, not an emergency patch. Until then every operator-facing string names the gap. See ADR-054.

⚠️ Breaking change

A server reached over the network goes loopback-only on upgrade. Set in that box's .env (not rsynced, so it persists per box):

AUTONOMOS_HOST=0.0.0.0

AUTONOMOS_HOST is recommended over BIND_HOST: the service wrapper runs tsx --env-file=<repo>/.env, so it survives install-service --force, whereas a service-file-baked --host is dropped by a reinstall that omits it. One-off alternative: make deploy BIND_HOST=0.0.0.0.

Testing

Live, against a real server on an isolated port + config dir (never touching the running deployment):

check before after
bind *:3100 127.0.0.1:39131
unauth /mcp initialize 200 + session id 401
unauth /mcp GET / DELETE reachable 401
legit client w/ token 200 200 + session (not broken)
--host=0.0.0.0 n/a binds *, warns, still 401s unauth
  • make check green — 632 server/CLI + 234 dashboard tests
  • AUTONOMOS_INTEGRATION=1 mcp-auth suite green locally (4/4)
  • Real-spawn suites left to CI — the harness warns they're unsafe on a box with a live deployment
  • New tests: mcp-auth.test.ts (real HTTP, real server), bind-host.test.ts, cli-args.test.ts, install-service-host.test.ts (asserts the actual written service file, including the absence case)

Risks

  • Breaking for network-reached servers (above). Mitigated by README + changeset + ADR, but it does need one manual step before upgrading such a box.
  • --host=<specific IP> (not 0.0.0.0) makes the post-install health check and the ADR-029 running-server guard — both of which probe localhost — report a false failure. Documented in --help and the README; not fixed here to keep the patch small.
  • The /mcp change would break any external HTTP MCP client (Claude Desktop, CI). No in-repo client is affected — agents use the stdio channel-server, not HTTP /mcp — and the "legit client still works" test covers the path.

What /polish caught (worth reading)

Three real bugs, all found before this PR existed:

  1. The PR didn't achieve its own goal. forge would still have rebound to loopback on the next deploy: its .env has no BIND_HOST (new + rsync-excluded), so $(if $(BIND_HOST),…) forwarded nothing and install-service --force rewrote the unit without --host. Fixed by recommending AUTONOMOS_HOST in .env (survives reinstalls) and documenting the pre-upgrade step.
  2. BIND_HOST=0.0.0.0 # comment in .env made make prod a silent no-op — the # opened a shell comment that swallowed the installer, and the recipe is @-prefixed so there was no output. A green deploy that changed nothing. Fixed by stripping the comment + quoting; the strip itself then needed \# because make comments the line otherwise. Verified across four .env shapes.
  3. My own warning text lied — it said "Every route requires the auth token" while /api/hooks/* and /api/host were exempt. That string is the last thing an operator reads before exposing a box. Now it names them.

Correction to the record

The audit first reported isLoopbackBind (zero callers) as "designed and never wired up." Wrong, and git log -S says so: #221 added it with its caller and a locking test; #264 deleted the caller and the test together, orphaning the helper.

The corrected story is worse. ADR-041 already recorded that unconditional auth-exemption is a HIGH-severity credential-injection vector on non-loopback binds — found by /polish, fixed pre-ship for one endpoint. /api/hooks/* is the "existing localhost-trust model" ADR-041 says it was mirroring but chose to be stricter than, and it was left exempt on every bind. The lesson was written down, applied once, and never applied to the neighbour it was copied from — then the only test encoding it was deleted as collateral.

A deleted test is how a security invariant dies quietly. Removing the endpoint made removing its test look like hygiene. Nothing failed. The helper just sat there looking like a guard. The new tests attach to the bind logic itself, not to any endpoint's lifetime.

Two candidate findings were also retracted as false positives after empirical checks: a slash-normalisation auth bypass (//api/agents → 200 is the SPA static fallback; handler-vs-router 404 shape disproved it) and CSWSH from a third-party origin (SameSite=Lax withholds the cookie on a subresource WS handshake).

Alternatives considered

  • Revive isLoopbackBind to gate the exemptions — rejected. Preserves the "trusted context" idea that failed here in the first place.
  • Peer-address gate on /api/hooks/* (hook traffic is always loopback — shared.ts hardcodes AUTONOMOS_SERVER=localhost) — designed, then dropped for Terry's always-auth rule. Kept as fallback. It carried three landmines, all empirically probed: ::ffff:127.0.0.1 must PASS (a ::1-only check 401s every hook → fleet blackout); startsWith("::ffff:") is a total bypass (it prefixes any IPv4); and loopback is all of 127/8127.0.0.53 is loopback on Linux but not macOS, so === "127.0.0.1" passes QA on a Mac and is wrong on forge.
  • Bind forge to its tailnet IP — smaller surface, but EADDRNOTAVAIL if tailscaled isn't up yet (forge is systemd), and with auth universal the bind isn't the boundary.
  • Hotpatch + firewall — unnecessary once the real fix was small enough to ship directly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SeVpLvCDMBZNVnrbkHzAgN

@nox-0x nox-0x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving — clean security fix that closes an unauthenticated RCE via /mcp + all-interfaces bind, verified live per the PR body.

Reviewed the diff carefully. The important things check out:

  • The auth middleware is mounted before the /mcp route handlers (app.use("/mcp", requireAuth) at run.ts:286, handlers at 310/317/323), and the integration test explicitly verifies GET/POST/DELETE all 401 without a token, plus that no mcp-session-id is issued on the reject path. That is the correct place to gate — an unauthenticated caller cannot obtain the session id later tools/call depends on.
  • resolveBindHost defaults to 127.0.0.1 and serve() now passes hostname: bindHost. Test in bind-host.test.ts locks the default. The empty-string / whitespace cases in both parseCliArgs and resolveBindHost correctly fall back to loopback rather than expanding to all interfaces (which is what listen("") would silently do).
  • --host threads through install-service into the written service file; install-service-host.test.ts asserts both presence and absence, and the Makefile forwards BIND_HOST only when set locally so a deploy with no override doesn't blank the remote's own value.
  • Empty --host is rejected at three layers (server parseHost, CLI parseFlags, and asserted in tests), so a service file carrying --host= can't silently expose the port on every boot.
  • The startup warning names the two still-exempt routes (POST /api/hooks/*, GET /api/host) rather than claiming blanket coverage — matches the deferred-residual documented in ADR-054 and the changeset, and won't lie to an operator at the moment they decide to expose the port.

Known residual (/api/hooks/* + /api/host unauthenticated on every bind) is deferred with clear rationale and follow-up scope. Not blocking here.

Minor observations, none blocking:

  • isLoopbackBind only recognises localhost/127.0.0.1/::1, so a bind to another 127/8 address (127.0.0.2) would trigger the network-exposure warning — false-positive direction, safe.
  • The Makefile .env parser doesn't handle quoted values (BIND_HOST="0.0.0.0"); a quoted value would reach Node's listen() with quotes and fail loudly — not a silent-widen. Fine to defer.
  • install-prod-service.sh's "[prod] Binding to HOST=$HOST (reachable from the network)" line would be inaccurate if HOST=127.0.0.1, but that combination has no legitimate use case.

aterrylu and others added 2 commits July 17, 2026 20:39
Two defects composed into unauthenticated remote code execution, verified
live against both the local server and forge rather than inferred:

  requireAuth was mounted on /api/* and /ws/* only, so /mcp — the transport
  exposing create_agent/kill_agent/set_manager — matched neither prefix and
  had no check of its own. Unauthenticated POST /mcp initialize returned 200
  + a session id; tools/list then enumerated the full toolset.

  serve() was called with no hostname, so Node bound 0.0.0.0/::. Confirmed
  live: node ... TCP *:3100 (LISTEN).

Together: any host on the same network could open an MCP session with one
curl and create_agent with permissionMode "bypass", an arbitrary
workingDirectory and an arbitrary prompt — arbitrary code execution as the
server's user, no credential. The repo is public with tagged releases, so
this shipped to users whose laptops sit on untrusted networks.

/mcp now requires the same token as everything else, checked at the transport
boundary so an unauthenticated caller cannot complete initialize and never
obtains the session id later calls need. The server binds 127.0.0.1 unless
--host / AUTONOMOS_HOST says otherwise, threaded through install-service,
install-prod-service.sh and the Makefile so an exposed box keeps its bind.

Auth is the boundary; the bind is defense-in-depth. That ordering (Terry's
"always require auth" ruling) is what let this delete the localhost-trust
category outright rather than gate it — no peer-address check, no exemption
to get subtly wrong. It costs nothing: auth.ts has always generated and
persisted a random 256-bit token on first start.

Known residual, deliberately deferred: POST /api/hooks/* and GET /api/host
stay unauthenticated on every bind. They can't spawn or control agents, but
can forge agent status and inject dashboard notifications. Authenticating the
relay changes how the token travels and risks a fleet-wide status blackout if
it fails to arrive — that needs its own PR with a real-spawn QA, not an
emergency patch. Every operator-facing string names the gap instead of
claiming blanket coverage.

BREAKING: a server reached over the network goes loopback-only on upgrade
unless AUTONOMOS_HOST=0.0.0.0 is set in that box's .env (not rsynced, so it
persists per box) or BIND_HOST=0.0.0.0 is passed to make deploy.

Tests are attached to the bind logic, not to an endpoint's lifetime: #221
shipped isLoopbackBind with a locking test, #264 deleted the caller and the
test together, and the helper sat inert with zero callers. A deleted test is
how a security invariant dies quietly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeVpLvCDMBZNVnrbkHzAgN
Review follow-up. `tsx --env-file` (the prod wrapper) and hand-quoted
service-file args pass `AUTONOMOS_HOST="0.0.0.0"` through WITH the quote
characters, and a hostname carrying quotes fails serve() with ENOTFOUND — a
crash-loop on the exact deploy where someone is enabling network exposure,
and AUTONOMOS_HOST-in-.env is the path the docs recommend.

resolveBindHost now peels one matched surrounding quote pair before use. A
hostname/IP never legitimately contains a surrounding quote pair, so this is
safe; a mismatched or lone quote is left intact and still reaches serve() to
fail loudly rather than being silently rewritten into something that binds.

Verified: booting via `tsx --env-file` with AUTONOMOS_HOST="0.0.0.0" now
binds *:PORT instead of crashing. The nox-0x review flagged the BIND_HOST
Makefile path as fail-loud/defer; this is the same class on the recommended
env path, fixed at the single chokepoint before serve().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeVpLvCDMBZNVnrbkHzAgN
@aterrylu
aterrylu force-pushed the terry/security-mcp-auth-bind branch from 0a13c18 to 16cddb9 Compare July 18, 2026 03:39
…opback

Corrects the bind half of this PR after learning how the server is actually
deployed. Users reach it on a remote box over the network — Tailscale
`dev-server:3100`, GCP IAP port-forward, or SSH to the public IP. The first two
need a real network interface; a loopback default (an earlier revision of this
PR) would have silently broken them on upgrade.

So the bind default is unchanged: the server still listens on all interfaces,
exactly as before. resolveBindHost returns `undefined` when unset and passes
that to serve() — Node's `::` dual-stack default, byte-identical to the old
no-hostname behavior (deliberately not the string "0.0.0.0", which is IPv4-only
and would drop an IPv6 client). `--host` / `AUTONOMOS_HOST` is now an opt-in to
RESTRICT to loopback (`--host=127.0.0.1`) for a box reached only via SSH tunnel.

The RCE is closed by auth on /mcp, not by the bind — that is unchanged and is
the actual fix. This makes the whole PR non-breaking: nobody's network-reached
dashboard goes dark on upgrade.

The startup line is now informational (not an alarm) and fires only on a
non-loopback bind, naming the still-unauthenticated /api/hooks + /api/host.
isLoopbackBind no longer treats undefined as loopback (undefined = all
interfaces = exposed). Docs, ADR-054, and the changeset updated: no longer a
breaking change; Phase 2 (loopback-only internal listener) named as the
follow-up that removes the network exposure of /mcp + /ws/gateway + /api/hooks.

Verified live: default binds *:PORT (dual-stack) and unauth /mcp 401s;
--host=127.0.0.1 binds loopback only and the info line stays silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeVpLvCDMBZNVnrbkHzAgN
@aterrylu aterrylu changed the title fix(security): require auth on /mcp + bind loopback by default (ADR-054) fix(security): require auth on /mcp — closes an unauthenticated RCE (ADR-054) Jul 18, 2026
@aterrylu
aterrylu merged commit df6806e into main Jul 18, 2026
5 checks passed
@aterrylu
aterrylu deleted the terry/security-mcp-auth-bind branch July 18, 2026 05:48
aterrylu added a commit that referenced this pull request Jul 24, 2026
…d queue warning

Two policies established by this PR, recorded per CLAUDE.md's append-only
decision-record convention:

1. The prompt-delivery receipt applies only to providers that emit hook events
   (`hooks.eventCount > 0`), never a provider-name check — so a provider that
   doesn't exist yet is classified by what it declares rather than by an
   allowlist someone has to remember to update.

2. Queued Codex inbound warns the operator once per stall episode at 5 minutes,
   replacing a ~45-minute silence. The 15-minute tolerance for long turns is
   unchanged; waiting was always correct, not saying so was the bug.

Records the consequence explicitly rather than leaving it to be rediscovered:
Codex spawn-with-prompt now has NO delivery detector, and a lost prompt is
indistinguishable from a finished agent because the daemon reports the thread
idle either way. A Codex-native detector via thread/status is a scoped
follow-up, deliberately not this ADR.

The ADR number appears in exactly two places (the entry and one code comment)
and in no log strings, so a renumber stays a two-minute change — a past
collision touched ~18 references across 10+ files.

Number assigned by TeamLead@autonomOS (055 reserved for #284, 056 merged
with #283). Rides with this PR per the #281/#283 precedent so the code and its
rationale stay co-located in git log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP
aterrylu added a commit that referenced this pull request Jul 24, 2026
…d queue warning

Two policies established by this PR, recorded per CLAUDE.md's append-only
decision-record convention:

1. The prompt-delivery receipt applies only to providers that emit hook events
   (`hooks.eventCount > 0`), never a provider-name check — so a provider that
   doesn't exist yet is classified by what it declares rather than by an
   allowlist someone has to remember to update.

2. Queued Codex inbound warns the operator once per stall episode at 5 minutes,
   replacing a ~45-minute silence. The 15-minute tolerance for long turns is
   unchanged; waiting was always correct, not saying so was the bug.

Records the consequence explicitly rather than leaving it to be rediscovered:
Codex spawn-with-prompt now has NO delivery detector, and a lost prompt is
indistinguishable from a finished agent because the daemon reports the thread
idle either way. A Codex-native detector via thread/status is a scoped
follow-up, deliberately not this ADR.

The ADR number appears in exactly two places (the entry and one code comment)
and in no log strings, so a renumber stays a two-minute change — a past
collision touched ~18 references across 10+ files.

Number assigned by TeamLead@autonomOS (055 reserved for #284, 056 merged
with #283). Rides with this PR per the #281/#283 precedent so the code and its
rationale stay co-located in git log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP
aterrylu added a commit that referenced this pull request Jul 26, 2026
…-delivery false alarm (#287)

* fix(codex): stop losing inbound messages in silence + kill the prompt-delivery false alarm

A Codex agent appeared to drop an inbound message: send() returned success,
the gateway logged the connection, and no `[codex-inbound] injected` line ever
appeared. Investigation found the message was never dropped — it was correctly
QUEUED behind an unfinished turn, in complete silence. From outside, a working
queue and a lost message produced byte-identical logs.

Root causes fixed:

1. Provider-blind prompt-delivery receipt. `trackPromptDelivery` reads the hook
   relay, but was gated only on `if (params.prompt)`. Codex emits zero hook
   events, so its SessionStart never arrived and EVERY prompted Codex agent
   logged "may have failed to boot" and pushed a dashboard SystemWarning — on
   agents that had already run their prompt correctly. That false alarm actively
   misdirected the original diagnosis. Now gated on the CAPABILITY
   (`hooks.eventCount > 0`) so it stays correct if Codex ever ships hooks.
   Nothing is lost: the re-delivery fallback needs a SessionStart to become
   reachable, so for Codex it never was. Gemini (11 events) is unaffected.

2. The inbound queue said nothing. Delivery is idle-gated by design (a
   `turn/start` mid-turn corrupts the thread), but the wait emitted no output:
   no enqueue log, a 15-minute silent poll, and an operator notification only
   after 3 consecutive failures — ~45 minutes. Now logs enqueue, logs every
   expired attempt, distinguishes "thread still active" from "status
   unreadable" (naming the cause), and notifies the operator once per stall
   episode after 5 minutes. The 15-minute tolerance for long turns is kept —
   waiting is correct, not SAYING you're waiting was the bug.

3. Silent drops on the delivery paths:
   - `dispose()` cleared the queue with no log — the module's only true message
     drop, reachable on kill, delete, PTY exit and resume-failure respawn.
   - The broadcast fan-out skipped endpoint-less Codex agents bare, and
     broadcast has no per-recipient ack.
   - Unicast fell through to the channel-server WS for a non-running Codex
     agent, which "succeeds" into a socket whose reader ignores inbound.
   - `broadcastToAllAgents` had no `.catch()`; one throw took out every
     recipient after the sender was already ack'd.

Also fixed: `statusLoop`'s escalation could never fire, because `queryIdle`
swallows read failures so the catch never ran and `statusFailures` reset every
cycle — a daemon that accepts the socket but never answers `thread/read` would
freeze the dashboard silently. And RPC timeouts were never cleared on success,
leaking a 30s timer per call (test suite exit: 31s -> 1.8s).

NOT fixed, deliberately: the original report's other symptom — a Codex thread
that goes active at spawn and never finishes — did not reproduce locally with
an identical spawn and is Codex-side, past our submission path. This change
makes that failure VISIBLE rather than claiming to fix it.

Tests: 9 delivery-observability + capability-gate cases against a fake
app-server daemon (swaps the global WebSocket, so production framing, id
matching and timeout logic all stay under test). Verified live against a real
`codex` agent on an isolated dev server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP

* docs: add changeset for the Codex inbound observability fix

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP

* docs: record ADR-057 — capability-gated prompt receipt + 5-min inbound queue warning

Two policies established by this PR, recorded per CLAUDE.md's append-only
decision-record convention:

1. The prompt-delivery receipt applies only to providers that emit hook events
   (`hooks.eventCount > 0`), never a provider-name check — so a provider that
   doesn't exist yet is classified by what it declares rather than by an
   allowlist someone has to remember to update.

2. Queued Codex inbound warns the operator once per stall episode at 5 minutes,
   replacing a ~45-minute silence. The 15-minute tolerance for long turns is
   unchanged; waiting was always correct, not saying so was the bug.

Records the consequence explicitly rather than leaving it to be rediscovered:
Codex spawn-with-prompt now has NO delivery detector, and a lost prompt is
indistinguishable from a finished agent because the daemon reports the thread
idle either way. A Codex-native detector via thread/status is a scoped
follow-up, deliberately not this ADR.

The ADR number appears in exactly two places (the entry and one code comment)
and in no log strings, so a renumber stays a two-minute change — a past
collision touched ~18 references across 10+ files.

Number assigned by TeamLead@autonomOS (055 reserved for #284, 056 merged
with #283). Rides with this PR per the #281/#283 precedent so the code and its
rationale stay co-located in git log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP

* test(gateway): cover the non-running-Codex delivery guard

The PR's one behavior change had zero test coverage — gateway-router.test.ts
never exercised the Codex branches at all. Adds both directions against a real
isolated agent store:

- an EXITED Codex agent whose channel-server socket is still open now gets a
  visible error and NO socket write (previously: the write "succeeded", the
  sender was told null/success, and the recipient discarded the bytes)
- a running Claude Code agent still delivers over that same socket — the guard
  must stay Codex-specific, since Claude Code genuinely reads inbound there

Found by asking whether the change was actually verified rather than assuming
the suite covered it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP

* test(codex): cover the two escalation paths that were unreachable or one-shot

Both are behavior, not logging — a notification that can never fire looks
exactly like a healthy system, which is the failure mode this whole PR is about.

- statusLoop: a daemon that accepts the WebSocket but never answers thread/read
  now warns. Previously queryIdle swallowed the failure, so the catch never ran,
  statusFailures reset every cycle, and the warning was unreachable for the
  likeliest daemon failure there is.
- noteFailure: re-notifies on a doubling backoff instead of exactly once per
  controller lifetime.

Moves ensureThread's hardcoded 1s poll into `timings` as threadPollMs — it was
the last wait in the module a test couldn't shrink, which is what made the
second case unreachable in under 6 seconds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP

* docs(adr-057): state the idle-gate premise as an untested assumption

ADR-057's Context asserted that injecting a turn/start mid-turn "interleaves
and corrupts the thread" and called the resulting idle gate right. Subsequent
testing disproved it: 8 injections across 5 thread states (blocking MCP call,
apply_patch mid-write, wait_agent block, reasoning, backgrounded shell, plus a
control) were all accepted with the original work completing intact.

The claim is reworded, not removed. Deleting it would erase the "we believed X,
then tested it" trail; leaving it would enter a known-false statement into an
append-only record, where a future reader could believe it and never find the
reversal — the exact misleading-signal failure this PR exists to fix. Stating
it as an assumption untested AT THE TIME is simply accurate about that moment.

Consistency is what decides it: the forthcoming reversal ADR is required to
label its one surviving safeguard "untested conservatism, not a measured
requirement." We don't get to hold an hours-old ADR of our own to a lower bar
than the one we're writing today.

Docs only — no behavior change. Nothing has entered the record yet; #287 is
unmerged, so this is not a rewrite of history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP

* fix(codex): address review — remove dead `warned` field, give statusLoop the same backoff

Two findings from nox-0x's review of #287:

1. `QueuedInbound.warned` was dead state — set to false in enqueue() and never
   read again, a leftover from moving warn-once tracking to the controller-
   scoped `longWaitWarned`. Its doc comment described per-message semantics the
   code deliberately rejects. Removed the field and corrected the comment.

2. `statusLoop`'s escalation was still `=== FAILURES_BEFORE_WARN` — the exact
   one-shot-per-lifetime flaw this PR fixes in `noteFailure`, and its own comment
   claimed to "mirror the delivery-path warning" after that mirror had broken.
   Now escalates on the same doubling backoff (nextStatusWarnAt, re-armed on a
   successful reconcile), so a daemon still unreachable an hour later keeps
   surfacing instead of going quiet after the first warning. New test pins it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants