Skip to content

registerOrRotate hands over any agent's identity on a name collision, unverified — while the CLI's register refuses #311

Description

@khaliqgant

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:

call on collision outcome
agents.register throws agent_already_exists (409) incumbent untouched — correct
registerAgent({ strict: true }) (:473) delegates to agents.register same as above
registerAgent({ strict: false }) (:482) registerWithLegacySuffix (:448) — retries under name-<suffix>, up to 5 attempts a second agent under a near-identical name
registerOrRotate (:485) agents.get(name) then agents.rotateToken(name) returns the incumbent's id and a fresh token; the incumbent's token is invalidated
// packages/sdk-typescript/src/relay.ts:485
async registerOrRotate(data: RegisterOrRotateInput): Promise<CreateAgentResponse> {
  try {
    return await this.registerAgent({ ...data, strict: true });
  } catch (err) {
    if (isNameConflictError(err)) {
      const agent = await this.agents.get(data.name);
      const { token } = await this.agents.rotateToken(agent.name);
      ...

registerOrRotate verifies 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:

formerly split between a strict branch that always reclaimed a name-collision via register_or_get_agent — handing the caller the incumbent's id, name, AND bearer token — and a non-strict branch that silently minted a -{uuid8} sibling name once before failing. Both were the same defect: a spawn-admission gate that doesn't verify who is asking… A silent suffix would have produced a second agent doing duplicate work under a near-identical name — exactly the AR-448 duplicate-agent class this gate exists to stop.

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

registerOrRotate is what relay's MCP register_agent tool calls (relay packages/cli/src/cli/agent-relay-mcp.ts:381, via registerAgentWithRebind). The caller-supplied name is pinned to the session identity only when strict worker identity is on:

// relay packages/cli/src/cli/agent-relay-mcp.ts:346
const effectiveName = strictAgentName && configuredName ? configuredName : name;

With strict worker identity off, or with no configured name, name passes 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.register is unavailable (relay crates/broker/src/runtime/relaycast_events.rs:493-510, via ws.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 returned Agent "…" 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 register and agent add call plain agents.register:

// relay packages/cli/src/cli/commands/agent.ts:38 (register) and :85 (add)
const registration = await relay.agents.register({ name, type, persona });

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).
  • registerWithLegacySuffix should 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.
  • Whatever is decided, the four methods should not silently differ. If the collision policy is genuinely a caller choice, the method names should say which one is being made.

Verification

SDK, engine and CLI line references read from relaycast main at 08ddec7; relay references from origin/main. The CLI double-register probe was run against a live workspace with a throwaway name. The registerOrRotate takeover was not executed against a live workspace — it would evict a running agent — and is derived from the source above.

Related

Context

Filed from an incident investigation on 2026-08-07. Khaliq owns the merge gate — no agent merges.

Activity

  1. khaliqgant commented on Aug 7, 2026

    @khaliqgant
    MemberAuthor

    Provenance note. Source references in this issue were read from main at 08ddec7. The deployed engine is not this checkout — it demonstrably carries code no local tree has: stored status = 'active' serializes as "unknown" through a mapping that exists nowhere in this source (#312). Treat every file:line here 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.

  2. khaliqgant commented on Aug 7, 2026

    @khaliqgant
    MemberAuthor

    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/:name plants metadata.identity_key, then satisfies relay#1438's gate with the caller's own proof
    this issue SDK registerOrRotate catches the 409 and calls agents.get + agents.rotateToken
    new engine registerAgentViaNode — the node-control agent.register frame — reclaims via onConflictDoUpdate with a token_hash overwrite

    The 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_alpha registers contested. node_beta sends agent.register for contested.

    locationNodeId after: node_alpha     (unchanged)
    tokenHash changed:    false
    

    Refused.

    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:          true
    

    node_beta is 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 registerAgent path 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 status column

    #306 closes the second group by gating reclaim on observed silence (last_seen) instead of status, so a roster read can no longer widen who may claim an identity. It deliberately does not touch the first group — narrowing or removing the node_direct_ disjunct is a design decision that belongs here, alongside registerOrRotate, 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_hash overwrite 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.

  3. khaliqgant commented on Aug 18, 2026

    @khaliqgant
    MemberAuthor

    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-cloud D1.

    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 queued receipt 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           13
    

    Registrations 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'
    

    relay is the CLI's default agent name when --name is 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-6bf605d7 appears 3 times and relay-4a214c1a 4 times in the workspaces table, 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.

  4. khaliqgant commented on Aug 18, 2026

    @khaliqgant
    MemberAuthor

    Decision proposal: separate registration, rotation, and recovery

    I re-checked this against current main (relaycast 4e62ef4, relay fdbf8c4) before proposing a policy. The central finding is still correct, with one important update.

    Current reachability and evidence update

    Yes: the MCP register_agent tool still reaches registerOrRotate. In the current agent-relay v11.7.1 source, the tool handler calls registerAgentWithRebind (packages/cli/src/cli/agent-relay-mcp.ts:918); its cold/recovery path calls relay.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's get(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 call register_agent, which falls through to registerOrRotate. 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_hash plus a previous_token_hash accepted 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_as probes the target: a live target is refused; offline/released/inactive may 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 -> registerOrRotate path;
    • spawn/restart paths using RegisterIntent::SpawnNew, which retain rotate-on-collision semantics;
    • the engine's workspace-key-only POST /agents/:name/rotate-token primitive;
    • 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 registerOrRotate and make callers choose recover-vs-refuse register always returns 409; recoverAgent and takeOverAgent are 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: true behavior, 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:

    1. Add server-owned recovery state and explicit recover/takeover endpoints first. Do not change token validation or rotate existing rows during the migration.
    2. 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 metadata route write the verifier.
    3. 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.
    4. Move MCP and other callers from implicit registerOrRotate to register plus the selected explicit recovery call. Instrument conflicts and recoveries during the rollout.
    5. 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

    1. Which authorities may recover an identity: current agent token, stable node/work-unit proof, owner/admin credential, or a combination?
    2. Is proof-less takeover allowed at all, and if so must it be owner-only and auditable?
    3. What is the temporary recovery experience for legacy rows that have no proof, so honest crashed callers are not permanently locked out?
    4. 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.

  5. khaliqgant commented on Aug 20, 2026

    @khaliqgant
    MemberAuthor

    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 ordinary register call. 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 handed name-2 is 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 metadata route 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 MCP register_agent path, RegisterIntent::SpawnNew, the workspace-key-only rotate-token primitive, 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 chief and today recovery and takeover are the same code path. Chief has spent much of the last day mute or deaf as a result.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions