Repository navigation
registerOrRotate hands over any agent's identity on a name collision, unverified — while the CLI's register refuses #311
Description
Activity
Provenance note. Source references in this issue were read from
mainat08ddec7. The deployed engine is not this checkout — it demonstrably carries code no local tree has: storedstatus = 'active'serializes as"unknown"through a mapping that exists nowhere in this source (#312). Treat everyfile:linehere as provisional until checked against the deployed build; if you cannot find a reference, suspect a build difference before assuming the reference is wrong.Measurements taken against the live workspace — record shapes, status counts, staleness ages, and CLI probe results — do not depend on this and stand on their own.
- added a commit that references this issue
on Aug 7, 2026 A third path reaches the same outcome, and here is the takeover demonstrated rather than described
This issue and #310 each read as "this one call is unsafe". Working through #306 turned up a third, independent path with the same outcome, which changes the shape of the claim: the missing invariant is at the identity layer, not at three call sites.
The three are genuinely different code:
path mechanism #310 PATCH /v1/agents/:nameplantsmetadata.identity_key, then satisfies relay#1438's gate with the caller's own proofthis issue SDK registerOrRotatecatches the 409 and callsagents.get+agents.rotateTokennew engine registerAgentViaNode— the node-controlagent.registerframe — reclaims viaonConflictDoUpdatewith atoken_hashoverwriteThe takeover, demonstrated
Run against the engine with two enrolled broker nodes. Same workspace, same agent name, nothing but the name presented.
A — incumbent's row stored
active:node_alpharegisterscontested.node_betasendsagent.registerforcontested.locationNodeId after: node_alpha (unchanged) tokenHash changed: falseRefused.
B — same row, flipped to
offline: identical setup, one column changed first.reply to node_beta: {"ok":true,"data":{"agent_id":"2114640403890913 28","name":"contested","token":"at_live_<redacted>"}} locationNodeId after: node_beta tokenHash changed: true same row id: truenode_betais handed a working bearer token for the incumbent's existing row, and the incumbent's token stops resolving. Not a pointer move, not a duplicate record — a credential handover on the same identity, on name alone.This matters for how the issues here get prioritised: the takeover in this class is now demonstrated, not inferred.
The standing hole this issue owns
While verifying #306 I found the guard's third disjunct is already satisfied for most agents, today, with no deploy:
// packages/engine/src/engine/node.ts — registerAgentViaNode setWhere and( eq(agents.locationType, 'via_node'), or( eq(agents.locationNodeId, nodeId), sql`${agents.locationNodeId} = 'node_direct_' || ${agents.id}`, // <-- this one ), )
Agents created through the ordinary
registerAgentpath are given exactly that value at registration:// packages/engine/src/engine/agent.ts locationType: 'via_node', locationNodeId: directNodeId, // = `node_direct_${agentId}`
So for every such agent the disjunct is true from any node, and the node-identity condition does no work at all. Measured on
relaycast-cloud, 2026-08-07 (20,107 agent rows):- 14,074 rows sit on their implicit direct node and are reclaimable by any node right now
- 1,578 were additionally protected only by the
statuscolumn
#306 closes the second group by gating reclaim on observed silence (
last_seen) instead ofstatus, so a roster read can no longer widen who may claim an identity. It deliberately does not touch the first group — narrowing or removing thenode_direct_disjunct is a design decision that belongs here, alongsideregisterOrRotate, not smuggled into a presence fix.That 14,074 is the number I would put on this issue's priority. It is not a latent risk waiting on a deploy; it is the current state.
Note on scope
The engine-side reclaim path is now gated on silence, but it is still name-based reclaim with a
token_hashoverwrite once the grace window expires — it verifies elapsed time, not who is asking. The verification this issue asks for is still missing on all three paths. #306 removed an accidental trigger; it did not add proof of identity.- added a commit that references this issue
on Aug 7, 2026 Production evidence, 2026-08-18 — this cost us a working agent today
Filed 11 days ago as a reasoned defect. Here is what it looks like in production, measured directly against
relaycast-cloudD1.A live, healthy lane was silently disconnected by it.
relaycast-ws-lifecycle-0818— spawned 46 minutes earlier, mid-task on relaycast#338 — was found sitting at its prompt having failed with:agent_token_invalid: The selected Relaycast agent token is no longer valid. The stale token was cleared from this MCP session. Call the "register_agent" tool with the configured agent name to obtain a fresh token, then retry.It had also drifted onto unrelated work after a context compaction. Every observable field said it was fine: process alive, node healthy, uptime 46 minutes. Nothing surfaced that it had been evicted — the operator only found out by attaching to its terminal and reading the screen. Two steering messages sent to it in that window were accepted with a
queuedreceipt and never delivered, because a holder with a rotated token cannot receive.The collision rate is not marginal:
agents registered under the name 'relay' 2,206 distinct names re-registered in last 24h 13Registrations seconds apart, from the last four hours alone:
10:18:35 relay-cli-test-ZARhdW 10:18:54 relay-cli-test-ZARhdW <- same name, 19s later 10:10:21 relay-cli-test-YlCk1N 10:10:37 relay-cli-test-YlCk1N <- same name, 16s later 10:08 / 10:12 / 10:12 / 10:15 / 10:16 / 10:20 / 10:24 all named 'relay'relayis the CLI's default agent name when--nameis omitted, so every bare invocation that registers claims it and evicts the incumbent. An agent on that name has a token lifetime measured in minutes. That turns this from "a takeover is possible" into "a takeover happens routinely, by accident, to ourselves."What makes it expensive rather than merely wrong
The eviction is silent on both sides. The new registrant gets a working token and no indication it displaced anyone. The incumbent gets no notification at all — it discovers the loss only on its next call, which for a long-running agent can be many minutes later, and by then it has no way to distinguish this from a network fault.
And the failure mode it produces is the worst kind for an operator: an agent that is running, healthy by every status field, and deaf. See relay#1563 for the adjacent observability problem — between them, an agent can be alive-and-unreachable in at least three distinct ways with no way to tell them apart without attaching to its terminal.
Scope note
This is the same class as the workspace-name collisions found today while investigating relay#1562:
relay-6bf605d7appears 3 times andrelay-4a214c1a4 times in theworkspacestable, different creation dates, different contents. Both the agent and workspace namespaces treat a non-unique string as an identity. Worth fixing with one principle rather than twice with two patches.Suggested direction, unchanged from the original filing but now with a priority argument
Registration should refuse or namespace a collision rather than evict. And the CLI should not default to a shared name — a per-process default that is unique by construction removes the accidental case entirely, which is the overwhelming majority of the 2,206.
Decision proposal: separate registration, rotation, and recovery
I re-checked this against current
main(relaycast4e62ef4,relayfdbf8c4) before proposing a policy. The central finding is still correct, with one important update.Current reachability and evidence update
Yes: the MCP
register_agenttool still reachesregisterOrRotate. In the currentagent-relayv11.7.1 source, the tool handler callsregisterAgentWithRebind(packages/cli/src/cli/agent-relay-mcp.ts:918); its cold/recovery path callsrelay.agents.registerOrRotate(:537-548). Strict worker identity only replaces the caller-supplied name with the configured name. It does not prove possession before the SDK'sget(name) -> rotateToken(name)collision branch.The cached fast path reduces how often this happens, but does not change the authorization rule: after
agent_token_invalid, the MCP session drops the stale identity and the documented recovery is to callregister_agent, which falls through toregisterOrRotate. Recovery and takeover are therefore still the same code path.One fact in the issue is now stale after #332: the engine no longer has exactly one usable token slot. It keeps
token_hashplus aprevious_token_hashaccepted for 60 seconds. A rotation therefore does not evict the incumbent immediately. It still hands the caller the incumbent's id and a new credential on the name alone, and the old holder loses access when the grace expires. A third/chained rotation can retire an older token sooner because the design retains only two slots. This fixes the race that handed a caller an already-dead token; it does not authorize the takeover.What AgentWorkforce/relay#1546 covers—and what it does not
The broker guard present in v11.7.1 is useful but is not a fix for this issue.
It adds caller intent inside the Rust broker. On an impersonation cache miss,
registered_agent_client_asprobes the target: a live target is refused;offline/released/inactivemay be rotated; probe failure, timeout, or an unknown status fails closed. This covers broker impersonation callers such as sends, mark-read, hashing, and channel join/leave. The broker's own identity bypasses that impersonation check.It deliberately does not cover:
- the TypeScript MCP
register_agent -> registerAgentWithRebind -> registerOrRotatepath; - spawn/restart paths using
RegisterIntent::SpawnNew, which retain rotate-on-collision semantics; - the engine's workspace-key-only
POST /agents/:name/rotate-tokenprimitive; - the node-control reclaim path; or
- the check-then-rotate race after a presence probe.
Most importantly, presence is not identity. The guard can prevent an accidental eviction of a visibly live worker, but an offline result proves only silence, not that the claimant owns the name.
Options for Khaliq's decision
Option Collision policy Legitimate recovery after a crash Consequence / boundary 1. Require the existing agent token Register refuses; rotate requires the current agent credential. Automatic if the token is persisted outside the crashed process; otherwise an explicit operator recovery is required. Smallest proof model, but not sufficient as a final security boundary while relay#1570 leaves every agent token and the shared workspace key readable in process argv. Those exposures must be removed and the leaked credentials rotated. 2. Require a stable work-unit or node recovery proof Register refuses; recovery succeeds only with a server-verifiable capability bound to the original agent/work unit. Broker/node restart can recover automatically if its durable proof survives; loss of both process and durable state falls back to operator recovery. Stronger separation, but requires lifecycle plumbing and a migration. The verifier must live in server-owned state, not caller-writable metadata.identity_key(see #310), and a generic workspace key is not identity proof.3. Keep takeover, but make it explicit, privileged, and audited Ordinary registration refuses. A separately named admin operation can displace the incumbent. Recovery remains available even when all agent-specific proof is lost, but it is an operator decision rather than a silent SDK fallback. If the authority is merely the currently shared workspace key, this remains name-based takeover with better observability. A meaningful version needs an owner/admin or signed node authority, an expected agent id, actor/reason/session/node in a durable audit event, and notification to the incumbent. 4. Remove registerOrRotateand make callers choose recover-vs-refuseregisteralways returns 409;recoverAgentandtakeOverAgentare explicit APIs with the proof selected above. Silent suffix registration is removed or renamed as an explicit “create unique sibling” operation.Depends on the recovery authority selected in options 1-3; the important change is that a normal registration can no longer accidentally become recovery. Clearest API contract and preserves the already-correct agents.register/strict: truebehavior, but requires call-site migration across MCP, SDKs, broker fallback, and docs.These are composable: option 4 is the API shape; options 1-3 decide which authorities are accepted by the explicit recovery operation. A layered policy could accept either a valid current token or a durable work-unit proof for self-service recovery, while reserving proof-less takeover for an audited owner operation.
Migration without invalidating live agents
Any chosen policy can be introduced without rotating the fleet's current tokens:
- Add server-owned recovery state and explicit recover/takeover endpoints first. Do not change token validation or rotate existing rows during the migration.
- Enroll new agents at creation. Backfill existing agents only through a trusted path that already proves control—for example the current agent token plus a node-bound proof, or a signed node-control registration. Do not let the workspace-key
PATCH metadataroute write the verifier. - Let legacy live agents continue using their current tokens. A legacy row with no recovery proof may require explicit owner recovery after a total crash; that is an operational limitation, but it avoids a migration that kills every live credential.
- Move MCP and other callers from implicit
registerOrRotatetoregisterplus the selected explicit recovery call. Instrument conflicts and recoveries during the rollout. - After adoption, remove/deprecate the implicit rotate and silent suffix paths. Keep the 60-second previous-token slot for concurrent authorized rollover if desired; define a separate immediate-revoke operation for compromise response rather than overloading “rotate.”
Decisions needed before code
- Which authorities may recover an identity: current agent token, stable node/work-unit proof, owner/admin credential, or a combination?
- Is proof-less takeover allowed at all, and if so must it be owner-only and auditable?
- What is the temporary recovery experience for legacy rows that have no proof, so honest crashed callers are not permanently locked out?
- Should silent suffix registration be removed, or retained only under an explicit method name?
No auth change proposed here should ship before those are answered. The invariant I would carry into any implementation is: a name and presence state identify a target; neither authorizes the claimant.
- the TypeScript MCP
Decision — Khaliq, 2026-08-20
Answering the four questions from the proposal above, in his words relayed by Chief. Adopt option 4 as the API shape, with options 1–3 supplying the authorities.
1. Which authorities may recover an identity?
Self-service recovery accepts either the current agent token or a stable node/work-unit proof. The owner/admin credential is the last resort, not the everyday path.2. Is proof-less takeover allowed?
Yes — because things genuinely crash — but it must be owner-only, explicit, loud and audited. It must never again be a silent SDK fallback reachable from an ordinaryregistercall. A durable audit event carries actor, reason, session, node and the expected agent id, and the incumbent is notified.3. Legacy rows with no proof.
They need an explicit escape hatch. Every agent alive today predates this mechanism, and an honest crash must not become a permanent lockout. Legacy rows keep working on their current tokens; recovery for them is the explicit owner operation until they are enrolled through a trusted path.4. Silent suffix registration.
Removed as an implicit behaviour. A caller that wants a sibling asks for one explicitly. Being quietly handedname-2is how you end up addressing a stranger who has your name with a number after it.Constraints carried into implementation
- No live token is invalidated by the migration. Follow the staged plan in the proposal: server-owned recovery state and explicit endpoints first, enrol new agents at creation, backfill only through a path that already proves control, and never let the workspace-key
PATCH metadataroute write the verifier (see PATCH /v1/agents/:name lets any workspace-key holder write identity_key onto any agent, satisfying relay's admission gate with a planted proof #310). - The invariant to hold throughout: a name and presence state identify a target; neither authorizes the claimant.
relay#1546's broker guard is complementary and not a substitute — it does not cover the MCPregister_agentpath,RegisterIntent::SpawnNew, the workspace-key-onlyrotate-tokenprimitive, or node-control reclaim.- This is authentication for every agent in the workspace. A written implementation plan and a PR come back for review before anything merges. Khaliq owns the merge.
Why this is urgent rather than tidy
It is actively costing us. Chief's own seat is evicted every time the relay MCP connection reconnects, because each reconnect re-registers
chiefand today recovery and takeover are the same code path. Chief has spent much of the last day mute or deaf as a result.- No live token is invalidated by the migration. Follow the staged plan in the proposal: server-owned recovery state and explicit endpoints first, enrol new agents at creation, backfill only through a path that already proves control, and never let the workspace-key
Summary
The SDK offers four ways to register an agent. On a name collision they do four different things, ranging from a clean refusal to silently handing the caller the incumbent's identity. Callers pick between them by method name, with nothing in the naming to signal that the choice is a security decision.
All four in
packages/sdk-typescript/src/relay.ts:agents.registeragent_already_exists(409)registerAgent({ strict: true })(:473)agents.registerregisterAgent({ strict: false })(:482)registerWithLegacySuffix(:448) — retries undername-<suffix>, up to 5 attemptsregisterOrRotate(:485)agents.get(name)thenagents.rotateToken(name)registerOrRotateverifies nothing. The name string alone is sufficient to take over any agent record in the workspace and evict whoever was using it.Both of these are the defect relay#1438 was written to remove
relay's v11.4.2 spawn-admission gate closed exactly these two behaviours on the Rust broker's own registration path. Its doc comment (
relay crates/broker/src/relaycast/auth.rs:934-947) names them:That reasoning was applied to the Rust path. Both behaviours are still live here, unchanged, and the gate does not cover this code.
Reachable surface
registerOrRotateis what relay's MCPregister_agenttool calls (relay packages/cli/src/cli/agent-relay-mcp.ts:381, viaregisterAgentWithRebind). The caller-suppliednameis pinned to the session identity only when strict worker identity is on:With strict worker identity off, or with no configured name,
namepasses through as given — so an agent calling the MCP tool can register under another agent's name and receive that agent's credentials.It is also the Rust broker's documented HTTP fallback for worker pre-registration when node-control
agent.registeris unavailable (relay crates/broker/src/runtime/relaycast_events.rs:493-510, viaws.rs::register_agent_token). That call site already carries a warning in its own doc comment (ws.rs:163-170) that passing an unvalidated name "risks silently disconnecting an unrelated, already-registered agent that happens to share the name" — the hazard is known at the call site but unfixed at the source.The CLI refuses, and that inconsistency is the point
A probe on 2026-08-07 ran
agent-relay agent register <throwaway>twice. The second call returnedAgent "…" already exists in this workspace, exited with no token, and left the incumbent untouched.That is consistent with everything above, and it is worth stating why: the CLI reaches a different method. Both
agent registerandagent addcall plainagents.register:So the surface an operator would naturally use to probe this behaviour is the one surface that is safe, while the programmatic surfaces are not. Anyone testing the takeover by hand will conclude the system is fine. That makes the inconsistency worse than a uniformly permissive API would be — it hides itself from exactly the check most likely to be run.
Suggested direction
The specific fix is a judgement call for the owners, but the shape:
registerOrRotate's reclaim needs the same thing relay#1438 requires: proof the caller is the same work unit, not just the same name.registerAgentViaNode(packages/engine/src/engine/node.ts:1118) already demonstrates a server-enforced version keyed on node identity — that proof cannot be forged by a caller, unlike anything held in caller-writable metadata (see PATCH /v1/agents/:name lets any workspace-key holder write identity_key onto any agent, satisfying relay's admission gate with a planted proof #310).registerWithLegacySuffixshould be considered for removal outright. It was judged the wrong behaviour on the Rust side for reasons that apply identically here, and a caller that silently gets a differently-named agent than it asked for has no way to notice.Verification
SDK, engine and CLI line references read from
relaycastmainat08ddec7; relay references fromorigin/main. The CLI double-register probe was run against a live workspace with a throwaway name. TheregisterOrRotatetakeover was not executed against a live workspace — it would evict a running agent — and is derived from the source above.Related
PATCH /v1/agents/:namelets a workspace-key holder plant the proof relay#1438 reads. More urgent than this one.Context
Filed from an incident investigation on 2026-08-07. Khaliq owns the merge gate — no agent merges.