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.

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