diff --git a/CHANGELOG.md b/CHANGELOG.md index ba9311050..10a02cef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,16 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Major] + +### Changed + +- `agent-relay agent register` no longer rotates an existing agent's token by default. For a name that already exists it now fails and leaves that identity's token unchanged. Pass the new `--rotate` flag to replace the token deliberately. `--strict` is still accepted; it has no effect on its own and can't be combined with `--rotate`. + +### Fixed + +- `agent-relay mcp` now completes its stdio handshake when startup registration fails (for example, the agent name already exists or Relaycast is unreachable). Claude Code used to report this as "Connection closed". The server now starts without an agent identity and writes the reason to stderr with credentials redacted. +- Identity-scoped `agent-relay mcp` tools return that startup failure until `register_agent` succeeds. ## [13.1.5] - 2026-10-08 diff --git a/packages/cli/README.md b/packages/cli/README.md index c6cf79265..997753e36 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -308,7 +308,8 @@ agent-relay fleet release api-worker --reason "Work accepted" Commands use the workspace session pinned to the current project. Exact-node spawn and messaging operations also need an agent identity: pass `--token` or set `RELAY_AGENT_TOKEN` to the token returned by -`agent-relay agent register `. `fleet spawn --sandbox` needs a Cloud +`agent-relay agent register ` (see +[Agent identity tokens](#agent-identity-tokens)). `fleet spawn --sandbox` needs a Cloud login (`agent-relay cloud login`) but does not need an agent token: when one is absent, it creates and removes a short-lived launcher identity automatically. @@ -507,6 +508,28 @@ record restores the stored token on the next `agent-relay node up`. `agent-relay cloud whoami` also prints the current organization and workspace IDs. +## Agent identity tokens + +`agent-relay agent register ` is create-only. It prints the new +identity's token once. For a name that already exists it fails and leaves that +identity's token alone. Pass `--rotate` only when you mean to replace the +existing token: any session still using the old token is disconnected, and +Relay servers that enforce create-only registration refuse the rotation. +`agent-relay agent rotate ` is the same explicit rotation for a name that +must already exist. `--strict` is still accepted for older scripts. On its own +it changes nothing, because create-only is the default. It can't be combined +with `--rotate`. + +To act as an identity a session already holds, keep using its existing token +(`RELAY_AGENT_TOKEN`), the Agent Relay desktop session socket, or the Agent +Relay MCP tools. Don't re-register its name. + +The `agent-relay mcp` stdio server also keeps running when its startup +registration fails, for example when the name already exists or Relaycast is +unreachable. It answers the MCP handshake without an agent identity, writes the +reason to stderr with credentials redacted, and identity-scoped tools return +that reason until `register_agent` succeeds. + ## Cloud multiplayer rooms Cloud room membership is scoped to one Relay workspace. Every v1 invite creates diff --git a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts index 96d615bc5..95cb521cc 100644 --- a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts +++ b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts @@ -2121,6 +2121,113 @@ describe('startAgentRelayMcpStdio', () => { ); }); + it('completes the stdio handshake when startup registration hits an existing name', async () => { + // relay#1920: a create-only server returns agent_already_exists for a + // name an earlier session registered. Exiting here is what Claude Code + // reports as "Connection closed"; the server must start degraded instead. + const { mod, mocks } = await loadAgentRelayMcpModule(); + mocks.behavior.registerImpl = vi.fn(async () => { + throw Object.assign(new Error('Agent "WorkerA" already exists in this workspace'), { + code: 'name_conflict', + }); + }); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await mod.startAgentRelayMcpStdio({ + apiKey: 'rk_live_workspace', + agentName: 'WorkerA', + agentToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig', + sharedSessionTools: [], + }); + + const server = mocks.serverInstances[0]; + expect(server.connect).toHaveBeenCalledTimes(1); + const warning = stderr.mock.calls.map((call) => String(call[0])).join(''); + expect(warning).toContain('Startup registration as "WorkerA" failed'); + expect(warning).toContain('already exists'); + expect(warning).not.toContain('rk_live_workspace'); + expect(warning).not.toContain('eyJhbGciOiJSUzI1NiJ9'); + + await expect(server.tools.get('send_dm')!.handler({ to: 'peer', text: 'hi' })).rejects.toThrow( + /startup registration failed .*already exists.*register_agent/ + ); + + // Once register_agent succeeds the startup failure is no longer reported. + mocks.behavior.registerImpl = vi.fn(async () => ({ + id: 'agent_b', + name: 'WorkerB', + token: 'at_live_workerb', + })); + await server.tools.get('register_agent')!.handler({ name: 'WorkerB' }); + await server.tools.get('send_dm')!.handler({ to: 'peer', text: 'hi' }); + } finally { + stderr.mockRestore(); + } + }); + + it('redacts credentials an upstream startup error quotes back on stderr', async () => { + const { mod, mocks } = await loadAgentRelayMcpModule(); + mocks.behavior.registerImpl = vi.fn(async () => { + throw new Error( + 'POST /v1/agents failed: Authorization: Bearer abc.def.ghi key=rk_live_workspace_secret ' + + 'token=at_live_agent_secret jwt=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.sig ' + + 'url=https://relay-user:url-password-secret@cast.example/v1 ' + + 'query=https://cast.example/v1/agents?api_key=opaque-query-secret&x=1 ' + + 'node=nt_live_node_credential_secret short=k9z' + ); + }); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await mod.startAgentRelayMcpStdio({ + apiKey: 'rk_live_workspace_secret', + // A short, unprefixed declared key is still removed. + workspaceKey: 'k9z', + agentName: 'WorkerA', + sharedSessionTools: [], + }); + expect(mocks.serverInstances[0].connect).toHaveBeenCalledTimes(1); + const written = stderr.mock.calls.map((call) => String(call[0])).join(''); + expect(written).toContain('Startup registration as "WorkerA" failed'); + for (const secret of [ + 'rk_live_workspace_secret', + 'at_live_agent_secret', + 'eyJhbGciOiJSUzI1NiJ9', + 'abc.def.ghi', + 'url-password-secret', + 'relay-user', + 'opaque-query-secret', + 'node_credential_secret', + 'k9z', + ]) { + expect(written).not.toContain(secret); + } + expect(written).toContain('Bearer '); + expect(written).toContain('https://@cast.example/v1'); + expect(written).toContain('?api_key=&x=1'); + } finally { + stderr.mockRestore(); + } + }); + + it('completes the stdio handshake when Relaycast is unreachable at startup', async () => { + const { mod, mocks } = await loadAgentRelayMcpModule(); + mocks.behavior.registerImpl = vi.fn(async () => { + throw new Error('Network request failed: fetch failed'); + }); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await mod.startAgentRelayMcpStdio({ + apiKey: 'rk_live_workspace', + agentName: 'WorkerA', + sharedSessionTools: [], + }); + expect(mocks.serverInstances[0].connect).toHaveBeenCalledTimes(1); + expect(stderr.mock.calls.map((call) => String(call[0])).join('')).toContain('Network request failed'); + } finally { + stderr.mockRestore(); + } + }); + it('starts sessions-only MCP without Relaycast registration or messaging tools', async () => { const { mod, mocks } = await loadAgentRelayMcpModule(); diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index ecd4ac37b..c84378b4f 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -63,6 +63,8 @@ import { resolveWorkspaceSessionKey, validateWorkspaceSessionName, } from './lib/workspace-session.js'; +import { isAgentNameConflict } from './lib/agent-name-conflict.js'; +import { redactCredentials } from './lib/redact-credentials.js'; import type { AgentClientLike, AgentRelayMcpServerOptions, @@ -1590,7 +1592,12 @@ export function createAgentRelayMcpServer(options: AgentRelayMcpServerOptions): } }; + // Reported only until this session first gets an identity; after that the + // startup failure is history and normal recovery guidance applies. + let startupRegistrationError = options.startupRegistrationError; + const setSession: SessionSetter = (partial) => { + if (partial.agentToken) startupRegistrationError = undefined; const switchingWorkspace = partial.workspaceKey !== undefined && partial.workspaceKey !== session.workspaceKey; const changingToken = partial.agentToken !== undefined && partial.agentToken !== session.agentToken; @@ -1662,6 +1669,11 @@ export function createAgentRelayMcpServer(options: AgentRelayMcpServerOptions): } if (!session.agentToken) { + if (startupRegistrationError) { + throw new Error( + `Not registered: startup registration failed (${startupRegistrationError}). Call the "register_agent" tool to retry.` + ); + } throw new Error('Not registered. Call the "register_agent" tool first.'); } @@ -1799,9 +1811,50 @@ export async function resolveStdioBootstrapOptions( }; } +/** + * Run the startup registration, but never let its failure kill the stdio + * server before the MCP `initialize` handshake. An exit here is what MCP + * clients such as Claude Code report as "Connection closed" (an unreachable + * Relaycast, a stale workspace key, or a registration timeout all used to + * end the process). The server starts without an agent identity instead, and + * identity-scoped tools report the redacted reason until `register_agent` + * succeeds. + */ +export async function resolveStdioBootstrapOptionsOrDegrade( + options: AgentRelayMcpServerOptions, + writeStderr: (line: string) => void = (line) => { + process.stderr.write(line); + } +): Promise { + try { + return await resolveStdioBootstrapOptions(options); + } catch (error) { + if (options.sessionsOnly) throw error; + const conflict = isAgentNameConflict(error); + const reason = conflict + ? `agent "${options.agentName}" already exists and registration is create-only; ` + + "set RELAY_AGENT_TOKEN to that identity's existing token or register a different name" + : redactCredentials(safeRelayErrorMessage(error), [ + options.workspaceKey, + options.apiKey, + options.agentToken, + ]); + const who = options.agentName ? ` as "${options.agentName}"` : ''; + writeStderr( + `[agent-relay mcp] Startup registration${who} failed: ${reason}. ` + + 'Starting without an agent identity; call register_agent to retry.\n' + ); + // Never fall back to a non-Relaycast token (for example a RelayAuth JWT): + // bootstrap exists to replace it, so keeping it would only fail later. + const { agentToken: _discarded, ...rest } = options; + void _discarded; + return { ...rest, startupRegistrationError: reason }; + } +} + export async function startAgentRelayMcpStdio(options: AgentRelayMcpServerOptions): Promise { initMcpTelemetry(); - const bootstrappedOptions = await resolveStdioBootstrapOptions(options); + const bootstrappedOptions = await resolveStdioBootstrapOptionsOrDegrade(options); const sharedSessionsClient = bootstrappedOptions.sharedSessionsClient ?? new SharedSessionsMcpClient(); let sharedSessionTools = bootstrappedOptions.sharedSessionTools; if (!sharedSessionTools) { diff --git a/packages/cli/src/cli/commands/agent.test.ts b/packages/cli/src/cli/commands/agent.test.ts index 775d576da..e5684f6ab 100644 --- a/packages/cli/src/cli/commands/agent.test.ts +++ b/packages/cli/src/cli/commands/agent.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { registerAgentCommands } from './agent.js'; -function createHarness() { +function createHarness(env: NodeJS.ProcessEnv = {}) { const agentRelay = { agents: { me: vi.fn(async () => ({ id: 'agent_1', name: 'room-human' })), @@ -32,6 +32,7 @@ function createHarness() { registerAgentCommands(program, { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: createWorkspaceRelay as never, + env, log, error, exit: ((code: number) => { @@ -49,6 +50,27 @@ function createHarness() { }; } +function nameConflict(name: string): Error { + return Object.assign(new Error(`Agent "${name}" already exists in this workspace`), { + code: 'name_conflict', + statusCode: 409, + }); +} + +function helpFor(program: Command, ...names: string[]): string { + let command: Command | undefined = program; + for (const name of names) command = command?.commands.find((candidate) => candidate.name() === name); + if (!command) throw new Error(`no command ${names.join(' ')}`); + let rendered = ''; + command.configureOutput({ writeOut: (text) => (rendered += text) }); + command.outputHelp(); + return rendered; +} + +function everythingPrinted(harness: { log: ReturnType; error: ReturnType }) { + return [...harness.log.mock.calls, ...harness.error.mock.calls].flat().map(String).join('\n'); +} + describe('agent-scoped identity commands', () => { it.each([ ['me', 'me'], @@ -80,7 +102,7 @@ describe('agent-scoped identity commands', () => { }); describe('agent identity lifecycle commands', () => { - it('register adopts an existing name by rotating its token', async () => { + it('register is create-only: a new name is registered without the rotation path', async () => { const { program, workspaceRelay, log } = createHarness(); await program.parseAsync([ @@ -93,9 +115,10 @@ describe('agent identity lifecycle commands', () => { 'rk_live_test', ]); + expect(workspaceRelay.workspace.register).toHaveBeenCalledTimes(1); expect(workspaceRelay.workspace.register).toHaveBeenCalledWith( { name: 'chief', type: undefined, persona: undefined }, - { strict: false } + { strict: true } ); expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ id: 'agent_rotated', @@ -104,7 +127,127 @@ describe('agent identity lifecycle commands', () => { }); }); - it('register --strict fails on a name conflict instead of rotating', async () => { + it('register refuses to rotate an existing name without --rotate and explains why', async () => { + const harness = createHarness(); + harness.workspaceRelay.workspace.register.mockRejectedValueOnce(nameConflict('chief')); + + await expect( + harness.program.parseAsync([ + 'node', + 'agent-relay', + 'agent', + 'register', + 'chief', + '--workspace-key', + 'rk_live_test', + ]) + ).rejects.toThrow('exit:1'); + + // Exactly one create-only attempt; the rotating path is never reached. + expect(harness.workspaceRelay.workspace.register).toHaveBeenCalledTimes(1); + expect(harness.workspaceRelay.workspace.register).toHaveBeenCalledWith(expect.anything(), { + strict: true, + }); + const rendered = everythingPrinted(harness); + expect(rendered).toContain('already exists'); + expect(rendered).toContain('left its token unchanged'); + expect(rendered).toContain('RELAY_AGENT_TOKEN'); + expect(rendered).toContain('desktop session socket'); + expect(rendered).toContain('--rotate'); + expect(rendered).not.toContain('at_live_'); + }); + + it("register refuses to rotate this session's own identity without --rotate", async () => { + const harness = createHarness({ RELAY_AGENT_NAME: 'Chief' }); + harness.workspaceRelay.workspace.register.mockRejectedValueOnce(nameConflict('chief')); + + await expect( + harness.program.parseAsync([ + 'node', + 'agent-relay', + 'agent', + 'register', + 'chief', + '--wk', + 'rk_live_test', + ]) + ).rejects.toThrow('exit:1'); + + expect(harness.workspaceRelay.workspace.register).toHaveBeenCalledTimes(1); + expect(everythingPrinted(harness)).toContain('do not re-register its name'); + }); + + it('register --rotate rotates an existing name only when asked', async () => { + const harness = createHarness(); + harness.workspaceRelay.workspace.register.mockRejectedValueOnce(nameConflict('chief')); + + await harness.program.parseAsync([ + 'node', + 'agent-relay', + 'agent', + 'register', + 'chief', + '--rotate', + '--workspace-key', + 'rk_live_test', + ]); + + expect(harness.workspaceRelay.workspace.register).toHaveBeenNthCalledWith(1, expect.anything(), { + strict: true, + }); + expect(harness.workspaceRelay.workspace.register).toHaveBeenNthCalledWith(2, expect.anything(), { + strict: false, + }); + expect(JSON.parse(String(harness.log.mock.calls[0]?.[0]))).toMatchObject({ name: 'chief' }); + }); + + it("register --rotate warns before rotating this session's own identity", async () => { + const harness = createHarness({ RELAY_AGENT_NAME: 'chief' }); + harness.workspaceRelay.workspace.register.mockRejectedValueOnce(nameConflict('chief')); + + await harness.program.parseAsync([ + 'node', + 'agent-relay', + 'agent', + 'register', + 'chief', + '--rotate', + '--workspace-key', + 'rk_live_test', + ]); + + expect(harness.error.mock.calls.flat().join('\n')).toContain("this session's own identity"); + // The warning must come before the destructive rotating call. + expect(harness.error.mock.invocationCallOrder[0]).toBeLessThan( + harness.workspaceRelay.workspace.register.mock.invocationCallOrder[1]! + ); + }); + + it('register --rotate reports a create-only server refusal without claiming a rotation', async () => { + const harness = createHarness(); + harness.workspaceRelay.workspace.register + .mockRejectedValueOnce(nameConflict('chief')) + .mockRejectedValueOnce(nameConflict('chief')); + + await expect( + harness.program.parseAsync([ + 'node', + 'agent-relay', + 'agent', + 'register', + 'chief', + '--rotate', + '--workspace-key', + 'rk_live_test', + ]) + ).rejects.toThrow('exit:1'); + + const rendered = everythingPrinted(harness); + expect(rendered).toContain('refused to rotate'); + expect(rendered).toContain('left unchanged'); + }); + + it('register --strict stays accepted as a deprecated create-only alias', async () => { const { program, workspaceRelay } = createHarness(); await program.parseAsync([ @@ -124,6 +267,35 @@ describe('agent identity lifecycle commands', () => { ); }); + it('register rejects --strict combined with --rotate', async () => { + const harness = createHarness(); + + await expect( + harness.program.parseAsync([ + 'node', + 'agent-relay', + 'agent', + 'register', + 'chief', + '--strict', + '--rotate', + '--workspace-key', + 'rk_live_test', + ]) + ).rejects.toThrow('exit:1'); + expect(harness.workspaceRelay.workspace.register).not.toHaveBeenCalled(); + }); + + it('register help states create-only, the explicit --rotate flag, and the non-rotating alternatives', () => { + const { program } = createHarness(); + const help = helpFor(program, 'agent', 'register'); + + expect(help).toContain('Create-only'); + expect(help).toContain('--rotate'); + expect(help).toContain('never re-register its name'); + expect(help).not.toContain('--strict'); + }); + it('register surfaces the bounded-registration timeout instead of hanging on a broken existing name', async () => { const { program, workspaceRelay, error } = createHarness(); workspaceRelay.workspace.register.mockReturnValueOnce(new Promise(() => {})); diff --git a/packages/cli/src/cli/commands/agent.ts b/packages/cli/src/cli/commands/agent.ts index bd7aff33f..17669cd06 100644 --- a/packages/cli/src/cli/commands/agent.ts +++ b/packages/cli/src/cli/commands/agent.ts @@ -1,4 +1,4 @@ -import type { Command } from 'commander'; +import { Option, type Command } from 'commander'; import { addSdkOptions, @@ -8,9 +8,10 @@ import { withSdkDefaults, type SdkCommandDeps, } from '../lib/sdk-command.js'; -import { RelayError } from '@agent-relay/sdk'; +import { RelayError, safeRelayErrorMessage } from '@agent-relay/sdk'; import { withAgentRegistrationDeadline, withDeadline } from '../lib/agent-registration.js'; +import { isAgentNameConflict } from '../lib/agent-name-conflict.js'; import { attributableReleaseReason } from '../lib/release-reason.js'; function isNotFoundError(error: unknown): boolean { @@ -22,15 +23,43 @@ function isNotFoundError(error: unknown): boolean { return Number(statusCode) === 404; } -export type AgentCommandDependencies = SdkCommandDeps; +function isCurrentIdentityName(env: NodeJS.ProcessEnv, name: string): boolean { + const current = env.RELAY_AGENT_NAME?.trim().replace(/^@/, ''); + return Boolean(current) && current!.toLowerCase() === name.trim().replace(/^@/, '').toLowerCase(); +} + +export interface AgentCommandDependencies extends SdkCommandDeps { + /** Environment used to recognise this session's own identity (`RELAY_AGENT_NAME`). */ + env: NodeJS.ProcessEnv; +} function withAgentDefaults(overrides: Partial = {}): AgentCommandDependencies { return { + env: process.env, ...withSdkDefaults(overrides), ...overrides, }; } +const CURRENT_IDENTITY_HINT = + 'To act as an identity you already hold, keep using its existing token (RELAY_AGENT_TOKEN), the ' + + 'Agent Relay desktop session socket, or the Agent Relay MCP tools; do not re-register its name.'; + +function existingNameError(name: string): Error { + return new Error( + `Agent "${name}" already exists. "agent register" is create-only and left its token unchanged. ` + + `${CURRENT_IDENTITY_HINT} To register a separate identity, choose a new name. ` + + `Pass --rotate only if you mean to replace "${name}"'s token and disconnect any session still using it.` + ); +} + +function rotationRefusedError(name: string): Error { + return new Error( + `The Relay service refused to rotate "${name}": it no longer lets a workspace key rotate an ` + + `existing agent's token, and its current token was left unchanged. ${CURRENT_IDENTITY_HINT}` + ); +} + export function registerAgentCommands( program: Command, overrides: Partial = {} @@ -41,26 +70,58 @@ export function registerAgentCommands( addSdkOptions( group .command('register') - .description('Register an agent, rotating an existing identity token when needed') + .description( + 'Register a new agent identity and print its token. Create-only: an existing name fails ' + + 'and keeps its token unless --rotate is passed' + ) .argument('', 'Agent name') .option('--type ', 'Agent type (agent | human | system)') .option('--persona ', 'Persona string') - .option('--strict', 'Fail instead of rotating the token when the name already exists') + .option( + '--rotate', + 'If the name already exists, replace its token instead of failing. Any session still using the ' + + 'old token is disconnected; servers that enforce create-only registration refuse this' + ) + .addOption(new Option('--strict', 'Deprecated: registration is create-only by default').hideHelp()) + .addHelpText( + 'after', + '\nTo act as an identity this session already holds, keep using its existing token ' + + '(RELAY_AGENT_TOKEN), the desktop session socket, or the MCP tools; never re-register its name.' + ) ).action(async (name: string, opts: Record) => { await runSdk(deps, async () => { + const rotate = opts.rotate === true; + if (rotate && opts.strict === true) { + throw new Error('--rotate and --strict cannot be combined.'); + } const relay = deps.createWorkspaceRelay(sdkOptionsFromOpts(opts)); - const registration = await withAgentRegistrationDeadline( - () => - relay.workspace.register( - { - name, - type: opts.type as 'agent' | 'human' | 'system' | undefined, - persona: opts.persona as string | undefined, - }, - { strict: opts.strict === true } - ), - name - ); + const input = { + name, + type: opts.type as 'agent' | 'human' | 'system' | undefined, + persona: opts.persona as string | undefined, + }; + let registration; + try { + registration = await withAgentRegistrationDeadline( + () => relay.workspace.register(input, { strict: true }), + name + ); + } catch (error) { + if (!isAgentNameConflict(error)) throw error; + if (!rotate) throw existingNameError(name); + if (isCurrentIdentityName(deps.env, name)) { + deps.error( + `Warning: rotating "${name}", this session's own identity (RELAY_AGENT_NAME). ` + + 'The token this session is using stops working.' + ); + } + registration = await withAgentRegistrationDeadline( + () => relay.workspace.register(input, { strict: false }), + name + ).catch((rotateError: unknown) => { + throw isAgentNameConflict(rotateError) ? rotationRefusedError(name) : rotateError; + }); + } printJson(deps, { id: registration.id, name: registration.name, token: registration.token }); }); }); @@ -68,7 +129,10 @@ export function registerAgentCommands( addSdkOptions( group .command('rotate') - .description('Rotate the token for an existing agent name') + .description( + 'Explicitly rotate the token for an existing agent name. Any session still using the old token ' + + 'is disconnected; servers that enforce create-only registration refuse this' + ) .argument('', 'Agent name') ).action(async (name: string, opts: Record) => { await runSdk(deps, async () => { @@ -98,7 +162,9 @@ export function registerAgentCommands( const registration = await withAgentRegistrationDeadline( () => relay.workspace.register({ name }), name - ); + ).catch((error: unknown) => { + throw isAgentNameConflict(error) ? rotationRefusedError(name) : error; + }); printJson(deps, { id: registration.id, name: registration.name, token: registration.token }); }); }); diff --git a/packages/cli/src/cli/commands/relaycast-groups.test.ts b/packages/cli/src/cli/commands/relaycast-groups.test.ts index 633bcb3c1..7d2eb54c8 100644 --- a/packages/cli/src/cli/commands/relaycast-groups.test.ts +++ b/packages/cli/src/cli/commands/relaycast-groups.test.ts @@ -152,14 +152,12 @@ describe('SDK-backed CLI groups', () => { it('agent register calls workspace.register and prints the registration', async () => { const { program, workspaceRelay, log } = harness(registerAgentCommands); await program.parseAsync(['agent', 'register', 'reviewer', '--type', 'agent'], { from: 'user' }); - // `agent register` resolves its client through createWorkspaceRelay (hence - // workspaceRelay, from main) and calls workspace.register with an explicit - // strict flag (hence this shape, from #1527). Taking either side of the - // merge alone asserts against a method or an object the implementation no - // longer uses. + // `agent register` resolves its client through createWorkspaceRelay and is + // create-only: it calls workspace.register strictly so an existing name is + // never rotated without `--rotate` (relay#1920). expect(workspaceRelay.workspace.register).toHaveBeenCalledWith( expect.objectContaining({ name: 'reviewer', type: 'agent' }), - { strict: false } + { strict: true } ); expect(log).toHaveBeenCalled(); }); diff --git a/packages/cli/src/cli/lib/agent-name-conflict.ts b/packages/cli/src/cli/lib/agent-name-conflict.ts new file mode 100644 index 000000000..301bacb24 --- /dev/null +++ b/packages/cli/src/cli/lib/agent-name-conflict.ts @@ -0,0 +1,10 @@ +/** + * True when a registration failed because the agent name already exists: + * the SDK's normalised `name_conflict`, or Relaycast's raw + * `agent_already_exists` (create-only registration, relaycast#349). + */ +export function isAgentNameConflict(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const { code, rawCode } = error as { code?: unknown; rawCode?: unknown }; + return code === 'name_conflict' || code === 'agent_already_exists' || rawCode === 'agent_already_exists'; +} diff --git a/packages/cli/src/cli/lib/redact-credentials.test.ts b/packages/cli/src/cli/lib/redact-credentials.test.ts new file mode 100644 index 000000000..66d3facc9 --- /dev/null +++ b/packages/cli/src/cli/lib/redact-credentials.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { redactCredentials } from './redact-credentials.js'; + +describe('redactCredentials', () => { + it('masks a whole credential even when a declared value is its prefix', () => { + const long = 'rk_live_declaredprefix_and_more_secret_tail'; + const out = redactCredentials(`key=${long}`, ['rk_live_declaredprefix']); + expect(out).not.toContain('declaredprefix'); + expect(out).not.toContain('and_more_secret'); + }); + + it('removes declared values of any length, and query and URL credentials', () => { + const out = redactCredentials('a=k9z https://u:p4ss@h.example/x?token=t0k&y=1', ['k9z']); + expect(out).not.toMatch(/k9z|p4ss|t0k/); + expect(out).toContain('?token=&y=1'); + }); + + it('removes Basic authorization values and quoted JSON credential fields', () => { + const out = redactCredentials( + 'Authorization: Basic dXNlcjpwYXNz body={"authToken":"opaque-secret","apiKey":"k-1","name":"ok"}' + ); + expect(out).not.toMatch(/dXNlcjpwYXNz|opaque-secret|k-1/); + expect(out).toContain('Basic '); + expect(out).toContain('"authToken":""'); + expect(out).toContain('"name":"ok"'); + }); +}); diff --git a/packages/cli/src/cli/lib/redact-credentials.ts b/packages/cli/src/cli/lib/redact-credentials.ts new file mode 100644 index 000000000..e496c0296 --- /dev/null +++ b/packages/cli/src/cli/lib/redact-credentials.ts @@ -0,0 +1,47 @@ +import { redactCredentialValues } from '@agent-relay/cloud/redact'; + +/** + * Strip Relay credentials from text that may reach a log, stderr, or a tool + * result. Upstream errors sometimes quote the request that failed, so any + * message built from one goes through here first. + * + * Live-credential prefixes (`rk_live_`, `at_live_`, `nt_live_`, `rjt_live_`, + * `ocl_node_enr_`, ...) are masked by the shared cloud redactor; the patterns + * below cover the shapes it does not. + */ +const EXTRA_PATTERNS: Array<[RegExp, string]> = [ + // Relaycast test-mode tokens and keys. + [/\b(?:at|rk)_test_[A-Za-z0-9_-]+/g, ''], + // JWTs, e.g. RelayAuth tokens. + [/\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g, ''], + // Authorization header values. + [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 '], + // Quoted credential fields in echoed JSON, e.g. {"authToken":"..."}: any key + // ending in token, key, secret, password, or signature. + [ + /("[A-Za-z0-9_-]*(?:token|key|secret|password|passwd|signature)"\s*:\s*")(?:[^"\\]|\\.)*(")/gi, + '$1$2', + ], + // Credentials embedded in a URL, e.g. https://user:secret@host. + [/(?<=[a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+(?::[^\s/@]*)?@/gi, '@'], + // Credential-bearing query or form parameters, e.g. ?api_key=... or &token=... + [ + /([?&;]|\b)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|token|secret|password|passwd|key|workspace[_-]?key|client[_-]?secret|sig|signature)=)[^\s&#;"']+/gi, + '$1$2', + ], +]; + +export function redactCredentials(text: string, known: Array = []): string { + // Mask whole credential ranges first: replacing a declared value first could + // cut a longer credential that merely starts with it, so the remainder would + // no longer match a pattern and would survive. + let out = redactCredentialValues(text); + for (const [pattern, replacement] of EXTRA_PATTERNS) { + out = out.replace(pattern, replacement); + } + for (const secret of known) { + const value = secret?.trim(); + if (value) out = out.split(value).join(''); + } + return out; +} diff --git a/packages/cli/src/cli/mcp/types.ts b/packages/cli/src/cli/mcp/types.ts index 0ea0fecf9..8cb456a67 100644 --- a/packages/cli/src/cli/mcp/types.ts +++ b/packages/cli/src/cli/mcp/types.ts @@ -20,6 +20,12 @@ export interface AgentRelayMcpServerOptions { strictAgentName?: boolean; telemetryTransport?: 'stdio' | 'http'; skipBootstrap?: boolean; + /** + * Redacted reason the stdio startup registration failed. When set, the + * server still completes the MCP handshake and identity-scoped tools report + * this reason instead of the process exiting before `initialize`. + */ + startupRegistrationError?: string; /** Expose only hosted shared-session tools; used by the installable plugin. */ sessionsOnly?: boolean; /** Test/embedding seam for the hosted MCP client. */