Skip to content

agent_token_invalid: worker tokens superseded mid-session by an out-of-process re-registration (n=5, trigger unattributed for 4) #1333

Description

@khaliqgant

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:63tokenHash: 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 43role=='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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions