The attention feature has been dead since it merged: the gate never allowlisted /api/agent/events #133

Merged
lz merged 2 commits from fix/agent-status into main 2026-09-05 20:59:06 +02:00
Owner

Every session attention report has been rejected with 401 {"error":"locked"} since #125 merged. AGENT_CALLBACKS in hooks.server.ts named three routes; there are four on disk.

Reproduced from inside a live session, against the running instance:

POST /api/agent/previews  {}   → 400 invalid body   ← route ran
POST /api/agent/events    {}   → 401 locked         ← gate ate it, route never ran

Because the gate rejects it, the route's own bridge-IP check never runs either.

Why nobody noticed

Three independent silences line up:

  • nexus-report.sh ends its curl with >/dev/null 2>&1 || true and always exits 0. It has to — a non-zero exit on a Stop hook wedges the turn, and stdout on SessionStart is injected into the agent's context.
  • The gate's 401 at hooks.server.ts was unlogged, and Nexus's own stdout has no UI surface (/api/workers/[id]/logs serves worker containers).
  • The UI is correct and therefore silent: attention ?? 'running' renders a never-reported session exactly like a healthy pre-feature one. Deliberate, pinned by a parity suite — and precisely what made a dead pipe invisible.

#123 replaced the /api/agent/ prefix with this named allowlist and merged 48 minutes before #125 added the route. Different files, no textual conflict, both merged clean. git merge-base --is-ancestor 7b6e5bc 647e70a → true, so the route was born 401'd on main.

Worth noting: AgentAuthScope in agent/authorize.ts has read "The four callback routes" — listing 'agent events' — since #125. The contradiction sat on disk, greppable, tied to nothing.

The fix

One allowlist entry. The rest is guards, since the same seam produced this twice in one week.

  • The routes directory is walked both ways: a route the allowlist does not name, and an entry whose route is gone. A stale entry lies dormant until a route reappears under that name and inherits passthrough nobody asked for.
  • +server.js counts. Matching .ts alone left a route invisible to the guard while SvelteKit still served it.
  • A route that must stay authenticated is declared in GATED_AGENT_ROUTES, which asserts it 401s. Without a declaration site the failure reads expected 401 to be 200 and pushes the next author toward granting passthrough — the wrong fix in a security test.
  • Every /api/agent/ URL in worker/**/*.sh is driven through the gate. All four producers hand-write their URL and swallow the response.
  • Runtime detector: the gate warns on a 401 whose routeId is under /api/agent/. Structurally unreachable except by this bug — a path with no route gets a null routeId and 404s, an allowlisted one returns at step 1 — so it costs no false positives and fires on the first hook after a bad merge.

Verification

Every guard was proven to fail by breaking what it protects:

broken failure
allowlist entry removed 3 tests, incl. the worker-producer scan
callback added as +server.js names /api/agent/jsroute
route file at /api/agent/ names /api/agent (was /api/agent/.)
allowlisted route declared GATED names /api/agent/quota
warn removed / made unconditional fails in both directions
stale entry /api/agent/renamed-away caught
walk filter regressed to one route expected 1 to be >= 4

End to end over real HTTP against a migrated database: {} → 400, bad state → 400, unknown session → 403. All three were 401 before.

Typecheck 4941 files / 0 errors. Suite 1508 passed.

Reviewed by four specialist agents (security/code, tests, silent-failure, comments); 12 findings, all applied or declined in the thread.

Not in this PR

  • A liveness detector. The plugin loads unconditionally, so SessionStart must produce an idle within seconds of every launch; probe-healthy plus attention_state IS NULL past that is a broken pipe. It catches the rest of the class (plugin not loading, NEXUS_URL unset, a matcher renamed by a claude-code bump, a 403 from authorize). It needs a launch-timestamp column — convergeSessions calls clearAttention against a created_at hours old — so it is past "smallest" and belongs on its own.
  • worker/notify-preview.sh:58-59 builds its JSON with printf and raw $LABEL interpolation, while notify-artifact.sh:24-25 correctly uses jq -n --arg. A label containing " or \ produces invalid JSON and a 400. Visible rather than silent, and unrelated to this branch.

This does not reach the UI until the agent-nexus image is rebuilt and redeployed.

Every session attention report has been rejected with `401 {"error":"locked"}` since #125 merged. `AGENT_CALLBACKS` in `hooks.server.ts` named three routes; there are four on disk. Reproduced from inside a live session, against the running instance: ``` POST /api/agent/previews {} → 400 invalid body ← route ran POST /api/agent/events {} → 401 locked ← gate ate it, route never ran ``` Because the gate rejects it, the route's own bridge-IP check never runs either. ## Why nobody noticed Three independent silences line up: - `nexus-report.sh` ends its curl with `>/dev/null 2>&1 || true` and always exits 0. It has to — a non-zero exit on a `Stop` hook wedges the turn, and stdout on `SessionStart` is injected into the agent's context. - The gate's 401 at `hooks.server.ts` was unlogged, and Nexus's own stdout has no UI surface (`/api/workers/[id]/logs` serves *worker* containers). - The UI is correct and therefore silent: `attention ?? 'running'` renders a never-reported session exactly like a healthy pre-feature one. Deliberate, pinned by a parity suite — and precisely what made a dead pipe invisible. #123 replaced the `/api/agent/` **prefix** with this named allowlist and merged 48 minutes **before** #125 added the route. Different files, no textual conflict, both merged clean. `git merge-base --is-ancestor 7b6e5bc 647e70a` → true, so the route was born 401'd on main. Worth noting: `AgentAuthScope` in `agent/authorize.ts` has read *"The four callback routes"* — listing `'agent events'` — since #125. The contradiction sat on disk, greppable, tied to nothing. ## The fix One allowlist entry. The rest is guards, since the same seam produced this twice in one week. - The routes directory is walked **both ways**: a route the allowlist does not name, and an entry whose route is gone. A stale entry lies dormant until a route reappears under that name and inherits passthrough nobody asked for. - `+server.js` counts. Matching `.ts` alone left a route invisible to the guard while SvelteKit still served it. - A route that must **stay** authenticated is declared in `GATED_AGENT_ROUTES`, which asserts it 401s. Without a declaration site the failure reads `expected 401 to be 200` and pushes the next author toward granting passthrough — the wrong fix in a security test. - Every `/api/agent/` URL in `worker/**/*.sh` is driven through the gate. All four producers hand-write their URL and swallow the response. - Runtime detector: the gate warns on a 401 whose `routeId` is under `/api/agent/`. Structurally unreachable except by this bug — a path with no route gets a null `routeId` and 404s, an allowlisted one returns at step 1 — so it costs no false positives and fires on the first hook after a bad merge. ## Verification Every guard was proven to fail by breaking what it protects: | broken | failure | |---|---| | allowlist entry removed | 3 tests, incl. the worker-producer scan | | callback added as `+server.js` | names `/api/agent/jsroute` | | route file at `/api/agent/` | names `/api/agent` (was `/api/agent/.`) | | allowlisted route declared `GATED` | names `/api/agent/quota` | | warn removed / made unconditional | fails in both directions | | stale entry `/api/agent/renamed-away` | caught | | walk filter regressed to one route | `expected 1 to be >= 4` | End to end over real HTTP against a migrated database: `{}` → 400, bad state → 400, unknown session → 403. All three were 401 before. Typecheck 4941 files / 0 errors. Suite 1508 passed. Reviewed by four specialist agents (security/code, tests, silent-failure, comments); 12 findings, all applied or declined in the thread. ## Not in this PR - **A liveness detector.** The plugin loads unconditionally, so `SessionStart` must produce an `idle` within seconds of every launch; probe-healthy plus `attention_state IS NULL` past that is a broken pipe. It catches the rest of the class (plugin not loading, `NEXUS_URL` unset, a matcher renamed by a claude-code bump, a 403 from authorize). It needs a launch-timestamp column — `convergeSessions` calls `clearAttention` against a `created_at` hours old — so it is past "smallest" and belongs on its own. - **`worker/notify-preview.sh:58-59`** builds its JSON with `printf` and raw `$LABEL` interpolation, while `notify-artifact.sh:24-25` correctly uses `jq -n --arg`. A label containing `"` or `\` produces invalid JSON and a 400. Visible rather than silent, and unrelated to this branch. **This does not reach the UI until the `agent-nexus` image is rebuilt and redeployed.**
lz added 2 commits 2026-09-05 20:55:48 +02:00
AGENT_CALLBACKS named three routes; there are four on disk. Every session
attention report has 401d at the gate since the feature merged — the route
never ran, so its own bridge-IP check never ran either.

Nothing said so at either end. nexus-report.sh discards curl's status by
design (a non-zero exit on a Stop hook wedges the turn), the gate's 401 was
unlogged, and the UI renders a never-reported session exactly like a healthy
pre-feature one. #123 replaced the /api/agent/ prefix with this allowlist and
merged 48 minutes before #125 added the route: different files, no textual
conflict, both clean.

Guards, each proven to fail by breaking what it protects:

- the routes directory is walked BOTH ways — a route the allowlist does not
  name, and an entry whose route is gone (dormant until a route reappears
  under that name and inherits passthrough nobody asked for)
- +server.js counts as a route; matching .ts alone left one invisible while
  SvelteKit still served it
- a route that must STAY authenticated is declared in GATED_AGENT_ROUTES,
  which asserts it 401s. Without a declaration site the failure text reads
  "expected 401 to be 200" and pushes the next author toward granting
  passthrough — the wrong fix in a security test
- every /api/agent/ URL in worker/**/*.sh is driven through the gate; all
  four producers hand-write their URL and swallow the response
- the gate now warns on a 401 whose routeId is under /api/agent/, which is
  structurally unreachable except by this bug: a path with no route gets a
  null routeId and 404s, an allowlisted one returns at step 1

AgentAuthScope in agent/authorize.ts has read "The four callback routes",
listing 'agent events', since #125 — greppable, and tied to nothing.
docs(ui): the hook-reported states are fact #26, not #23
All checks were successful
ci / nexus (pull_request) Successful in 12m42s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 12s
ci / images (pull_request) Successful in 9m40s
d3eaa6a566
Fact #23 is the auth gate. Pre-existing, and this branch edits both facts.
lz merged commit f500863aa1 into main 2026-09-05 20:59:06 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!133
No description provided.