Five agents on one box hit agent_token_invalid on 2026-07-18, each recovering via register_agent under the same name (same agent id, new token). The mechanism is proven from source; the trigger is traced for only one of the five, and every in-process candidate has been eliminated. Filing so the remaining question isn't re-derived from scratch.
Mechanism — proven, not inferred
Relaycast stores exactly one token_hash per agent row, and authentication is a lookup on that column:
relaycast/packages/engine/src/db/schema.ts:63 — tokenHash: text('token_hash').notNull().unique()
relaycast/packages/engine/src/auth/index.ts:51-53 — no matching row → unauthorized('Invalid agent token', 'agent_token_invalid')
Exactly two writers exist:
packages/engine/src/engine/agent.ts:41-60 createAgent — plain INSERT; on an existing (workspace_id, name) it 409s, it does not overwrite.
packages/engine/src/engine/tokenRotate.ts:22-25 rotateAgentToken — the sole overwriter, unconditional .set({ tokenHash }), request-scoped: no cron, no sweeper, no timer.
Therefore registerOrRotate = register → 409 → rotate, and any second registration of a live name silently kills the incumbent token. There is no expiry check anywhere in the auth path — no expiresAt, no timestamp comparison. A token dies only when something re-registers its name.
Corollary for anyone debugging this: do not chase expiry, scope, or workspace mismatch (the workspace lookup happens later, at auth/index.ts:54, with its own distinct message). Ask "what re-registered this name?"
Eliminated candidates
Every in-process path was excluded, by source, timing, or ps:
| Candidate |
Verdict |
Basis |
resolveStdioBootstrapOptions bootstrap rotate |
eliminated |
The isRelaycastAgentToken(token) || skipBootstrap short-circuit is present in v9.1.0, v9.1.6, v9.2.2, v9.2.3, v9.2.4 and 10.6.3 (identical code/lines). Two victims also have a verified successful call after MCP process start, proving their tokens survived bootstrap. |
| npx serving an older major without the guard |
eliminated |
Same source read — the guard exists in every candidate version, so version skew cannot explain it. |
| Reconnect re-registering |
eliminated |
resolveStdioBootstrapOptions has exactly one caller (agent-relay-mcp.ts:931, at startup); the file has no reconnect handling. |
| Local process restart |
eliminated |
ps: victims' PTYs ran continuously from ~1s after their agent record was created; MCP servers are their children at the same instant. (Limit: a process that started and exited leaves no ps trace.) |
| Spawn/respawn churn |
eliminated |
One victim's spawns postdate their token death by 37–40 min; another's respawns were of different names, which under the storage model touch different rows. |
agent-relay-mcp.ts:346-360 cache-miss fall-through |
eliminated |
Lives in registerAgentWithRebind, whose sole call site is :488, the register_agent tool handler — it never runs on send_dm/post_message/etc. Every victim failed on a message send without having called register_agent. Reinforcing: createInitialSession (:284-295) pre-seeds the identity map when a session starts with both agentToken and agentName (the broker-pre-registered case), so even an explicit call short-circuits. |
What remains
One trigger is traced: an MCP create_workspace call re-registering its own session (n=1 of 5, first-hand).
Four are unattributed, and since every in-process path is excluded, the rotations originated outside the victims' own processes — some other actor invoking register/registerOrRotate with those exact names.
The query that would resolve it (needs broker-side logs, no live surface): registration calls by agent name and timestamp, specifically around 12:32:55.896Z and 10:36:34.312Z for the two verified cases.
Two methodology notes for whoever picks this up
1. Do not fit the intervals. Observed times-from-mint (~2/7/9/29 min) are time-to-discovery, not time-to-death — a dead token is silent until used. One "29 minute" case had a 25-minute window with zero relay calls. The data bounds "dead by T" and never "alive until T−ε" except where a successful call provides a hard lower bound.
2. Transcript-grepping for this error over-counts. A successful post_message echoes your own text back as a tool_result, so anyone who has discussed the error has planted the string inside passing tool results. Observed on one session: naive string count 43 → role=='user' + tool_result filter 8 → correct count 1. The discriminator is is_error: True on the block, not block type. The error rate of the naive filter grows as the investigation proceeds.
Related
Pear had an independent instance of the same supersession class — concurrent callers double-registering one deterministic identity — fixed in AgentWorkforce/pear#420 by single-flighting the registration. Any client caching a registration keyed on a deterministic name is exposed to this and should single-flight it.
A separate credential-exposure issue on the same box is being filed by @lead-2 and is higher priority than this one.
🤖 Generated with Claude Code
Five agents on one box hit
agent_token_invalidon 2026-07-18, each recovering viaregister_agentunder the same name (same agent id, new token). The mechanism is proven from source; the trigger is traced for only one of the five, and every in-process candidate has been eliminated. Filing so the remaining question isn't re-derived from scratch.Mechanism — proven, not inferred
Relaycast stores exactly one
token_hashper agent row, and authentication is a lookup on that column:relaycast/packages/engine/src/db/schema.ts:63—tokenHash: text('token_hash').notNull().unique()relaycast/packages/engine/src/auth/index.ts:51-53— no matching row →unauthorized('Invalid agent token', 'agent_token_invalid')Exactly two writers exist:
packages/engine/src/engine/agent.ts:41-60createAgent— plain INSERT; on an existing(workspace_id, name)it 409s, it does not overwrite.packages/engine/src/engine/tokenRotate.ts:22-25rotateAgentToken— the sole overwriter, unconditional.set({ tokenHash }), request-scoped: no cron, no sweeper, no timer.Therefore
registerOrRotate= register → 409 → rotate, and any second registration of a live name silently kills the incumbent token. There is no expiry check anywhere in the auth path — noexpiresAt, no timestamp comparison. A token dies only when something re-registers its name.Corollary for anyone debugging this: do not chase expiry, scope, or workspace mismatch (the workspace lookup happens later, at
auth/index.ts:54, with its own distinct message). Ask "what re-registered this name?"Eliminated candidates
Every in-process path was excluded, by source, timing, or
ps:resolveStdioBootstrapOptionsbootstrap rotateisRelaycastAgentToken(token) || skipBootstrapshort-circuit is present in v9.1.0, v9.1.6, v9.2.2, v9.2.3, v9.2.4 and 10.6.3 (identical code/lines). Two victims also have a verified successful call after MCP process start, proving their tokens survived bootstrap.resolveStdioBootstrapOptionshas exactly one caller (agent-relay-mcp.ts:931, at startup); the file has no reconnect handling.ps: victims' PTYs ran continuously from ~1s after their agent record was created; MCP servers are their children at the same instant. (Limit: a process that started and exited leaves nopstrace.)agent-relay-mcp.ts:346-360cache-miss fall-throughregisterAgentWithRebind, whose sole call site is:488, theregister_agenttool handler — it never runs onsend_dm/post_message/etc. Every victim failed on a message send without having calledregister_agent. Reinforcing:createInitialSession(:284-295) pre-seeds the identity map when a session starts with bothagentTokenandagentName(the broker-pre-registered case), so even an explicit call short-circuits.What remains
One trigger is traced: an MCP
create_workspacecall re-registering its own session (n=1 of 5, first-hand).Four are unattributed, and since every in-process path is excluded, the rotations originated outside the victims' own processes — some other actor invoking
register/registerOrRotatewith those exact names.The query that would resolve it (needs broker-side logs, no live surface): registration calls by agent name and timestamp, specifically around
12:32:55.896Zand10:36:34.312Zfor the two verified cases.Two methodology notes for whoever picks this up
1. Do not fit the intervals. Observed times-from-mint (~2/7/9/29 min) are time-to-discovery, not time-to-death — a dead token is silent until used. One "29 minute" case had a 25-minute window with zero relay calls. The data bounds "dead by T" and never "alive until T−ε" except where a successful call provides a hard lower bound.
2. Transcript-grepping for this error over-counts. A successful
post_messageechoes your own text back as atool_result, so anyone who has discussed the error has planted the string inside passing tool results. Observed on one session: naive string count 43 →role=='user' + tool_resultfilter 8 → correct count 1. The discriminator isis_error: Trueon the block, not block type. The error rate of the naive filter grows as the investigation proceeds.Related
Pear had an independent instance of the same supersession class — concurrent callers double-registering one deterministic identity — fixed in AgentWorkforce/pear#420 by single-flighting the registration. Any client caching a registration keyed on a deterministic name is exposed to this and should single-flight it.
A separate credential-exposure issue on the same box is being filed by @lead-2 and is higher priority than this one.
🤖 Generated with Claude Code