From 4dd059eec1e57354407c1da7530f04027012068b Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 7 Oct 2026 22:36:27 -0700 Subject: [PATCH 1/6] fix(cli): add non-rotating agent token --current; make agent register create-only (#1920) When the desktop socket is unavailable, an agent had no safe way to use its own identity: `agent register` was documented as create-or-rotate. - `agent token --current` uses the token the session already holds (--from-file, --token, or RELAY_AGENT_TOKEN), verifies it read-only with agents.me(), and never prints, mints, or rotates it. --out writes a new 0600 file; `--from-file -- ` runs the next command with RELAY_AGENT_TOKEN set. - `agent register` is create-only; an existing name fails with guidance and keeps its token. --rotate is the explicit opt-in. --strict is a hidden no-op alias. - `agent-relay mcp` no longer exits before the MCP handshake when startup registration fails (existing name on a create-only server, unreachable Relaycast). That exit is Claude Code's "Connection closed". - Agent-scoped message commands without a token explain the safe options instead of surfacing the internal SDK error. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 13 + packages/cli/README.md | 42 +- .../src/cli/agent-relay-mcp.startup.test.ts | 54 +++ packages/cli/src/cli/agent-relay-mcp.ts | 50 ++- packages/cli/src/cli/bootstrap.test.ts | 1 + packages/cli/src/cli/commands/agent.test.ts | 362 +++++++++++++++++- packages/cli/src/cli/commands/agent.ts | 236 +++++++++++- .../src/cli/commands/relaycast-groups.test.ts | 26 +- packages/cli/src/cli/lib/agent-token-file.ts | 80 ++++ packages/cli/src/cli/lib/sdk-command.ts | 20 +- packages/cli/src/cli/mcp/types.ts | 6 + 11 files changed, 857 insertions(+), 33 deletions(-) create mode 100644 packages/cli/src/cli/lib/agent-token-file.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ba93110501..1cade4965d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `agent-relay agent token --current` lets a session use the agent identity it already holds without printing, minting, or rotating its token. It checks the token with a read-only lookup. `--out ` writes the token to a new owner-only (0600) file, and `--from-file -- ` runs the next command with `RELAY_AGENT_TOKEN` set. This is a safe fallback when the desktop session socket is unavailable. + +### Changed + +- `agent-relay agent register` is now create-only. For a name that already exists it fails, leaves that identity's token unchanged, and points to `agent token --current`. Pass `--rotate` to replace the existing token deliberately. `--strict` is still accepted but now does nothing. + +### Fixed + +- The `agent-relay mcp` stdio server no longer exits before the MCP handshake when its startup registration fails, for example when the agent name already exists or Relaycast is unreachable. Before this fix, Claude Code reported "Connection closed". The server now starts without an agent identity, writes the reason to stderr, and returns that reason from identity-scoped tools until `register_agent` succeeds. +- Agent-scoped `agent-relay message` commands run without an agent token now explain the non-rotating options instead of failing with an internal SDK error. + ## [13.1.5] - 2026-10-08 ### Fixed diff --git a/packages/cli/README.md b/packages/cli/README.md index c6cf792650..b58c7b5cbb 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,45 @@ 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, but it does nothing now that +create-only is the default. + +To act as an identity a session already holds, for example a broker-spawned +worker with `RELAY_AGENT_TOKEN` set, or a fallback when the desktop session +socket is unavailable, use `agent token --current`. It never mints or rotates +a token, and it never writes the token to stdout or stderr: + +```bash +# Confirm which identity this shell already has (read-only lookup). +agent-relay agent token --current + +# Hand it to a later command without printing it: write a new 0600 file... +agent-relay agent token --current --out ~/.config/agent-relay/me.token + +# ...and run a command with RELAY_AGENT_TOKEN set from that file. +agent-relay agent token --current --from-file ~/.config/agent-relay/me.token -- \ + agent-relay message dm send reviewer "Ready for review" +``` + +If the session has no token, the command fails without contacting the +service. Don't re-register your own name to get one. Send through the desktop +session socket or the Agent Relay MCP tools instead, or register a new, unused +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, 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 96d615bc59..616a316b60 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,60 @@ 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/ + ); + } 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 ecd4ac37be..d0bd7f936f 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -1662,6 +1662,11 @@ export function createAgentRelayMcpServer(options: AgentRelayMcpServerOptions): } if (!session.agentToken) { + if (options.startupRegistrationError) { + throw new Error( + `Not registered: startup registration failed (${options.startupRegistrationError}). Call the "register_agent" tool to retry.` + ); + } throw new Error('Not registered. Call the "register_agent" tool first.'); } @@ -1799,9 +1804,52 @@ export async function resolveStdioBootstrapOptions( }; } +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'; +} + +/** + * 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" + : safeRelayErrorMessage(error); + 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/bootstrap.test.ts b/packages/cli/src/cli/bootstrap.test.ts index f07483fdba..1a2ef1c93e 100644 --- a/packages/cli/src/cli/bootstrap.test.ts +++ b/packages/cli/src/cli/bootstrap.test.ts @@ -95,6 +95,7 @@ const expectedLeafCommands = [ // workspace agents 'agent register', 'agent rotate', + 'agent token', 'agent list', 'agent add', 'agent remove', diff --git a/packages/cli/src/cli/commands/agent.test.ts b/packages/cli/src/cli/commands/agent.test.ts index 775d576da8..7d988eaa02 100644 --- a/packages/cli/src/cli/commands/agent.test.ts +++ b/packages/cli/src/cli/commands/agent.test.ts @@ -1,9 +1,13 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + import { Command } from 'commander'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, 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' })), @@ -27,11 +31,14 @@ function createHarness() { const createWorkspaceRelay = vi.fn(() => workspaceRelay); const log = vi.fn(); const error = vi.fn(); + const runWithEnv = vi.fn(async (_command: string, _args: string[], _env: NodeJS.ProcessEnv) => 0); const program = new Command(); program.exitOverride(); registerAgentCommands(program, { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: createWorkspaceRelay as never, + env, + runWithEnv, log, error, exit: ((code: number) => { @@ -46,9 +53,31 @@ function createHarness() { createWorkspaceRelay, log, error, + runWithEnv, }; } +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 +109,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 +122,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 +134,122 @@ 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('agent token --current'); + 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('agent token --current'); + }); + + 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"); + }); + + 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 +269,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 path', () => { + const { program } = createHarness(); + const help = helpFor(program, 'agent', 'register'); + + expect(help).toContain('Create-only'); + expect(help).toContain('--rotate'); + expect(help).toContain('agent token --current'); + 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(() => {})); @@ -312,3 +486,181 @@ describe('agent identity lifecycle commands', () => { expect(rendered).not.toContain('214015171589668864'); }); }); + +describe('agent token --current', () => { + const tempDirs: string[] = []; + const TOKEN = 'at_live_current_session_secret'; + + function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-token-')); + tempDirs.push(dir); + return dir; + } + + afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('verifies the existing identity read-only and never prints the token', async () => { + const harness = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); + + await harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current']); + + expect(harness.createAgentRelay).toHaveBeenCalledWith({ token: TOKEN, baseUrl: undefined }); + expect(harness.agentRelay.agents.me).toHaveBeenCalledTimes(1); + expect(harness.createWorkspaceRelay).not.toHaveBeenCalled(); + expect(harness.workspaceRelay.workspace.register).not.toHaveBeenCalled(); + expect(JSON.parse(String(harness.log.mock.calls[0]?.[0]))).toEqual({ + id: 'agent_1', + name: 'room-human', + source: 'env', + }); + expect(everythingPrinted(harness)).not.toContain(TOKEN); + }); + + it('requires --current', async () => { + const harness = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); + + await expect(harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token'])).rejects.toThrow( + 'exit:1' + ); + expect(harness.error.mock.calls.flat().join('\n')).toContain('Pass --current'); + expect(harness.agentRelay.agents.me).not.toHaveBeenCalled(); + }); + + it('fails without minting anything when the session has no token', async () => { + const harness = createHarness({ RELAY_AGENT_NAME: 'chief' }); + + await expect( + harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current']) + ).rejects.toThrow('exit:1'); + + const rendered = harness.error.mock.calls.flat().join('\n'); + expect(rendered).toContain('no agent token'); + expect(rendered).toContain('Nothing was minted or rotated'); + expect(harness.createAgentRelay).not.toHaveBeenCalled(); + expect(harness.createWorkspaceRelay).not.toHaveBeenCalled(); + }); + + it('reports a rejected token without echoing it', async () => { + const harness = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); + harness.agentRelay.agents.me.mockRejectedValueOnce( + new Error(`agent_token_invalid: token ${TOKEN} is not valid`) + ); + + await expect( + harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current']) + ).rejects.toThrow('exit:1'); + + const rendered = everythingPrinted(harness); + expect(rendered).toContain('was rejected'); + expect(rendered).not.toContain(TOKEN); + }); + + it('writes the token to a new 0600 file and prints only the path', async () => { + const harness = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); + const file = path.join(tempDir(), 'me.token'); + + await harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current', '--out', file]); + + expect(fs.readFileSync(file, 'utf8').trim()).toBe(TOKEN); + if (process.platform !== 'win32') expect(fs.statSync(file).mode & 0o777).toBe(0o600); + expect(JSON.parse(String(harness.log.mock.calls[0]?.[0]))).toMatchObject({ tokenFile: file }); + expect(everythingPrinted(harness)).not.toContain(TOKEN); + }); + + it('refuses to overwrite an existing file unless --force is passed', async () => { + const harness = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); + const file = path.join(tempDir(), 'me.token'); + fs.writeFileSync(file, 'keep me'); + + await expect( + harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current', '--out', file]) + ).rejects.toThrow('exit:1'); + expect(fs.readFileSync(file, 'utf8')).toBe('keep me'); + + const forced = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); + await forced.program.parseAsync([ + 'node', + 'agent-relay', + 'agent', + 'token', + '--current', + '--out', + file, + '--force', + ]); + expect(fs.readFileSync(file, 'utf8').trim()).toBe(TOKEN); + }); + + it('runs the next command with the token from a file in its environment, without printing it', async () => { + const file = path.join(tempDir(), 'me.token'); + fs.writeFileSync(file, `${TOKEN}\n`, { mode: 0o600 }); + const harness = createHarness({ PATH: '/usr/bin' }); + + await harness.program.parseAsync([ + 'node', + 'agent-relay', + 'agent', + 'token', + '--current', + '--from-file', + file, + '--', + 'agent-relay', + 'message', + 'dm', + 'send', + 'reviewer', + 'ready', + '--mode', + 'steer', + ]); + + expect(harness.createAgentRelay).toHaveBeenCalledWith({ token: TOKEN, baseUrl: undefined }); + expect(harness.runWithEnv).toHaveBeenCalledTimes(1); + const [command, args, env] = harness.runWithEnv.mock.calls[0]!; + expect(command).toBe('agent-relay'); + expect(args).toEqual(['message', 'dm', 'send', 'reviewer', 'ready', '--mode', 'steer']); + expect(env).toMatchObject({ PATH: '/usr/bin', RELAY_AGENT_TOKEN: TOKEN, RELAY_AGENT_NAME: 'room-human' }); + expect(harness.log).not.toHaveBeenCalled(); + expect(everythingPrinted(harness)).not.toContain(TOKEN); + }); + + it('propagates a non-zero exit code from the wrapped command', async () => { + const harness = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); + harness.runWithEnv.mockResolvedValueOnce(3); + + await expect( + harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current', '--', 'false']) + ).rejects.toThrow('exit:3'); + }); + + it('refuses a token file that other users can read', async () => { + if (process.platform === 'win32') return; + const file = path.join(tempDir(), 'me.token'); + fs.writeFileSync(file, TOKEN); + fs.chmodSync(file, 0o644); + const harness = createHarness(); + + await expect( + harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current', '--from-file', file]) + ).rejects.toThrow('exit:1'); + + expect(harness.error.mock.calls.flat().join('\n')).toContain('chmod 600'); + expect(harness.agentRelay.agents.me).not.toHaveBeenCalled(); + expect(everythingPrinted(harness)).not.toContain(TOKEN); + }); + + it('help says it never prints, mints, or rotates the token', () => { + const { program } = createHarness(); + const help = helpFor(program, 'agent', 'token'); + + expect(help).toContain('--current'); + expect(help).toContain('--out '); + expect(help).toContain('--from-file '); + expect(help).toContain('never written to stdout or stderr'); + expect(help).toMatch(/without\s+printing,\s+minting,\s+or\s+rotating/); + expect(help).not.toContain('--workspace-key'); + }); +}); diff --git a/packages/cli/src/cli/commands/agent.ts b/packages/cli/src/cli/commands/agent.ts index bd7aff33fb..3c4922951b 100644 --- a/packages/cli/src/cli/commands/agent.ts +++ b/packages/cli/src/cli/commands/agent.ts @@ -1,4 +1,6 @@ -import type { Command } from 'commander'; +import { spawn } from 'node:child_process'; + +import { Option, type Command } from 'commander'; import { addSdkOptions, @@ -8,10 +10,12 @@ 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 { readAgentTokenFile, writeAgentTokenFile } from '../lib/agent-token-file.js'; import { attributableReleaseReason } from '../lib/release-reason.js'; +import { resolveAgentToken } from '../lib/sdk-client.js'; function isNotFoundError(error: unknown): boolean { if (error instanceof RelayError) return error.code === 'not_found' || error.statusCode === 404; @@ -22,15 +26,88 @@ 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(); +} + +function safeErrorDetail(error: unknown): string { + // Never echo a credential that an upstream error might quote back. + return safeRelayErrorMessage(error).replace(/\b(at|rk)_live_[A-Za-z0-9_-]+/g, ''); +} + +function isNameConflictError(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'; +} + +/** Run `command` with `env` and resolve with its exit code. */ +export type RunWithEnv = (command: string, args: string[], env: NodeJS.ProcessEnv) => Promise; + +export interface AgentCommandDependencies extends SdkCommandDeps { + /** Environment used to find the current identity (`RELAY_AGENT_NAME`, `RELAY_AGENT_TOKEN`). */ + env: NodeJS.ProcessEnv; + runWithEnv: RunWithEnv; +} + +const defaultRunWithEnv: RunWithEnv = (command, args, env) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { env, stdio: 'inherit' }); + child.once('error', reject); + child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); + }); function withAgentDefaults(overrides: Partial = {}): AgentCommandDependencies { return { + env: process.env, + runWithEnv: defaultRunWithEnv, ...withSdkDefaults(overrides), ...overrides, }; } +const CURRENT_IDENTITY_HINT = + 'To act as an identity you already hold, run "agent-relay agent token --current" ' + + '(it reads the token this session already has and never mints or rotates one).'; + +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}` + ); +} + +function resolveCurrentToken( + opts: Record, + env: NodeJS.ProcessEnv +): { token: string | undefined; source: 'file' | 'flag' | 'env' } { + const fromFile = typeof opts.fromFile === 'string' ? opts.fromFile : undefined; + const flagToken = typeof opts.token === 'string' && opts.token.trim() ? opts.token : undefined; + if (fromFile && flagToken) { + throw new Error('Pass either --from-file or --token, not both.'); + } + if (fromFile) return { token: readAgentTokenFile(fromFile), source: 'file' }; + return { token: resolveAgentToken({ token: flagToken, env }), source: flagToken ? 'flag' : 'env' }; +} + +function noCurrentTokenError(): Error { + return new Error( + 'This session has no agent token: neither --from-file, --token, nor RELAY_AGENT_TOKEN is set. ' + + 'Nothing was minted or rotated. Do not re-register your own name to get one. Instead: send through ' + + 'the Agent Relay desktop session socket, use the Agent Relay MCP tools (for example send_dm) if they ' + + 'are loaded, or register a new, unused name with "agent-relay agent register ".' + ); +} + export function registerAgentCommands( program: Command, overrides: Partial = {} @@ -41,26 +118,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, use "agent-relay agent token --current" ' + + 'instead; it never mints or rotates a token.' + ) ).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 (!isNameConflictError(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 isNameConflictError(rotateError) ? rotationRefusedError(name) : rotateError; + }); + } printJson(deps, { id: registration.id, name: registration.name, token: registration.token }); }); }); @@ -68,7 +177,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,11 +210,97 @@ export function registerAgentCommands( const registration = await withAgentRegistrationDeadline( () => relay.workspace.register({ name }), name - ); + ).catch((error: unknown) => { + throw isNameConflictError(error) ? rotationRefusedError(name) : error; + }); printJson(deps, { id: registration.id, name: registration.name, token: registration.token }); }); }); + group + .command('token') + .description( + "Use this session's existing agent identity without printing, minting, or rotating its token" + ) + .option('--current', "Required: act on this session's existing identity") + .option('--from-file ', 'Read the token from an owner-only (0600) file written by --out') + .option('--out ', 'Write the token to a new owner-only (0600) file and print only its path') + .option('--force', 'With --out, replace an existing regular file') + .option('--token ', 'Agent token (defaults to RELAY_AGENT_TOKEN); prefer --from-file') + .option('--base-url ', 'Override the API base URL (defaults to RELAY_BASE_URL)') + .argument('[command...]', 'After "--": run this command with RELAY_AGENT_TOKEN set to the token') + .addHelpText( + 'after', + [ + '', + 'The token comes from --from-file, --token, or RELAY_AGENT_TOKEN, and is verified with a read-only', + 'identity lookup. It is never written to stdout or stderr.', + '', + 'Examples:', + ' agent-relay agent token --current', + ' agent-relay agent token --current --out ~/.config/agent-relay/me.token', + ' agent-relay agent token --current --from-file ~/.config/agent-relay/me.token -- \\', + ' agent-relay message dm send reviewer "Ready for review"', + ].join('\n') + ) + .action(async (command: string[], opts: Record) => { + let childExitCode = 0; + await runSdk(deps, async () => { + if (opts.current !== true) { + throw new Error( + 'Pass --current. "agent token" only uses the identity this session already holds; ' + + 'it never mints or rotates a token.' + ); + } + const { token, source } = resolveCurrentToken(opts, deps.env); + if (!token) throw noCurrentTokenError(); + + const relay = deps.createAgentRelay({ token, baseUrl: opts.baseUrl as string | undefined }); + const me = await withDeadline( + () => relay.agents.me(), + (effectiveTimeoutMs) => + new Error(`Verifying the current agent identity did not complete within ${effectiveTimeoutMs}ms.`) + ).catch((error: unknown) => { + if (error instanceof Error && error.message.startsWith('Verifying the current agent identity')) { + throw error; + } + throw new Error( + `The current agent token was rejected (${safeErrorDetail(error)}). It may have been rotated or ` + + 'revoked. Nothing was minted or rotated.' + ); + }); + + const expectedName = deps.env.RELAY_AGENT_NAME?.trim(); + if (expectedName && me.name && me.name !== expectedName) { + deps.error( + `Warning: the token belongs to "${me.name}", but RELAY_AGENT_NAME is "${expectedName}".` + ); + } + + const tokenFile = + typeof opts.out === 'string' + ? writeAgentTokenFile(opts.out, token, { force: opts.force === true }) + : undefined; + + if (command.length > 0) { + const [executable, ...args] = command; + const env: NodeJS.ProcessEnv = { ...deps.env, RELAY_AGENT_TOKEN: token }; + if (me.name) env.RELAY_AGENT_NAME = me.name; + childExitCode = await deps.runWithEnv(executable, args, env); + return; + } + + printJson(deps, { + id: me.id, + name: me.name, + source, + ...(tokenFile ? { tokenFile } : {}), + }); + }); + // Outside runSdk so the wrapped command's own exit code is preserved. + if (childExitCode !== 0) deps.exit(childExitCode); + }); + addSdkOptions( group.command('list').description('List agents').option('--status ', 'Filter by status') ).action(async (opts: Record) => { diff --git a/packages/cli/src/cli/commands/relaycast-groups.test.ts b/packages/cli/src/cli/commands/relaycast-groups.test.ts index 633bcb3c17..6ef3aab887 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(); }); @@ -195,6 +193,22 @@ describe('SDK-backed CLI groups', () => { // MUST-FIRE: retaining the created message and receipt must not make an // unresolved recipient look like CLI success. + it('message dm send without an agent token points at the non-rotating paths, not re-registering', async () => { + const { program, relay, error, exit } = harness(registerMessageCommands); + relay.messages.direct.mockRejectedValueOnce( + new Error('RelaycastMessagingClient.messages.direct requires agentToken or agentClient.') + ); + + await program.parseAsync(['message', 'dm', 'send', 'lead', 'hi'], { from: 'user' }); + + const rendered = error.mock.calls.flat().join('\n'); + expect(rendered).toContain('needs an agent token'); + expect(rendered).toContain('agent token --current --from-file'); + expect(rendered).toContain('Do not re-register your own name'); + expect(rendered).not.toContain('RelaycastMessagingClient'); + expect(exit).toHaveBeenCalledWith(1); + }); + it('message dm send exits non-zero after printing an unresolved-recipient receipt', async () => { const { program, relay, workspaceRelay, log, error, exit } = harness(registerMessageCommands); workspaceRelay.agents.list.mockResolvedValueOnce([]); diff --git a/packages/cli/src/cli/lib/agent-token-file.ts b/packages/cli/src/cli/lib/agent-token-file.ts new file mode 100644 index 0000000000..2be50f47e2 --- /dev/null +++ b/packages/cli/src/cli/lib/agent-token-file.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Owner-only token files for handing an existing agent identity to another + * command without printing it. The token never appears in stdout, stderr, or + * process arguments; only the path does. + */ + +const OWNER_ONLY_MODE = 0o600; + +export interface WriteAgentTokenFileOptions { + /** Replace an existing regular file. Symlinks and non-files are always refused. */ + force?: boolean; +} + +/** Write `token` to a new `0600` file. Returns the absolute path written. */ +export function writeAgentTokenFile( + filePath: string, + token: string, + options: WriteAgentTokenFileOptions = {} +): string { + const target = path.resolve(filePath); + let existing: fs.Stats | undefined; + try { + existing = fs.lstatSync(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if (existing) { + if (!existing.isFile()) { + throw new Error(`Refusing to write the agent token to ${target}: it exists and is not a regular file.`); + } + if (!options.force) { + throw new Error( + `Refusing to overwrite ${target}: it already exists. Pass --force to replace it, or choose a new path.` + ); + } + fs.unlinkSync(target); + } + + // `wx` fails if something re-created the path after the unlink above, so a + // raced symlink can never redirect the write. + const fd = fs.openSync(target, 'wx', OWNER_ONLY_MODE); + try { + fs.writeSync(fd, `${token}\n`); + // The create mode is filtered by the umask; set it explicitly as well. + if (process.platform !== 'win32') fs.fchmodSync(fd, OWNER_ONLY_MODE); + } finally { + fs.closeSync(fd); + } + return target; +} + +/** Read a token written by {@link writeAgentTokenFile}, refusing group/world-readable files. */ +export function readAgentTokenFile(filePath: string): string { + const target = path.resolve(filePath); + let stats: fs.Stats; + try { + stats = fs.statSync(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Agent token file ${target} does not exist.`, { cause: error }); + } + throw error; + } + if (!stats.isFile()) { + throw new Error(`Agent token file ${target} is not a regular file.`); + } + if (process.platform !== 'win32' && (stats.mode & 0o077) !== 0) { + throw new Error( + `Agent token file ${target} is readable by other users; run "chmod 600" on it before using it.` + ); + } + const token = fs.readFileSync(target, 'utf8').trim(); + if (!token) { + throw new Error(`Agent token file ${target} is empty.`); + } + return token; +} diff --git a/packages/cli/src/cli/lib/sdk-command.ts b/packages/cli/src/cli/lib/sdk-command.ts index cdf84799dd..f2409787b3 100644 --- a/packages/cli/src/cli/lib/sdk-command.ts +++ b/packages/cli/src/cli/lib/sdk-command.ts @@ -71,12 +71,30 @@ export function printJson(deps: SdkCommandDeps, value: unknown): void { deps.log(JSON.stringify(value, null, 2)); } +/** + * Guidance for an agent-scoped command run without an agent token. The SDK's + * own error names an internal client; point at the non-rotating paths instead + * of leaving `agent register` (which cannot hand back an existing identity) as + * the only apparent way to get a token. + */ +export const MISSING_AGENT_TOKEN_MESSAGE = + 'This command needs an agent token: pass --token or set RELAY_AGENT_TOKEN. Do not re-register your ' + + 'own name to get one. If you already hold a token in a file, run it through ' + + '"agent-relay agent token --current --from-file -- ". Otherwise send through the ' + + 'Agent Relay desktop session socket, use the Agent Relay MCP tools if they are loaded, or register a ' + + 'new, unused name with "agent-relay agent register ".'; + +function missingAgentToken(message: string): boolean { + return /requires agentToken or agentClient/.test(message); +} + /** Run an SDK command body, formatting errors consistently and exiting non-zero. */ export async function runSdk(deps: SdkCommandDeps, fn: () => Promise): Promise { try { await fn(); } catch (err) { - const message = safeRelayErrorMessage(err); + const rawMessage = safeRelayErrorMessage(err); + const message = missingAgentToken(rawMessage) ? MISSING_AGENT_TOKEN_MESSAGE : rawMessage; if (err instanceof RelayPlacementError) { const structured = { error: { diff --git a/packages/cli/src/cli/mcp/types.ts b/packages/cli/src/cli/mcp/types.ts index 0ea0fecf97..8cb456a676 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. */ From 141c2edac5d2182fb3349ecda4d9fcf6e9266b49 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 7 Oct 2026 22:47:32 -0700 Subject: [PATCH 2/6] refactor(cli): narrow #1920 to the agent register rotation guard Per the narrowed scope: #1920's root cause was desktop socket latency (relay-desktop#333), and the documented fallback is the same socket with a longer timeout, so the CLI current-identity token path is not needed. - Drop `agent token --current`, its token-file helper, and the message command missing-token guidance that pointed at it. - Keep `agent register` create-only with an explicit `--rotate`; its guidance now points at the existing token, socket, or MCP tools. - Keep the MCP startup-degrade fix: published 13.1.5 still exits before `initialize` on an existing name or unreachable Relaycast. - Redact tokens, workspace keys, JWTs, bearer values, and URL credentials from the MCP startup reason, and stop reporting it once register_agent succeeds. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 11 +- packages/cli/README.md | 28 +-- .../src/cli/agent-relay-mcp.startup.test.ts | 45 ++++ packages/cli/src/cli/agent-relay-mcp.ts | 12 +- packages/cli/src/cli/bootstrap.test.ts | 1 - packages/cli/src/cli/commands/agent.test.ts | 196 +----------------- packages/cli/src/cli/commands/agent.ts | 137 +----------- .../src/cli/commands/relaycast-groups.test.ts | 16 -- packages/cli/src/cli/lib/agent-token-file.ts | 80 ------- .../cli/src/cli/lib/redact-credentials.ts | 32 +++ packages/cli/src/cli/lib/sdk-command.ts | 20 +- 11 files changed, 106 insertions(+), 472 deletions(-) delete mode 100644 packages/cli/src/cli/lib/agent-token-file.ts create mode 100644 packages/cli/src/cli/lib/redact-credentials.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cade4965d..91354cb608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +5,15 @@ 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] - -### Added - -- `agent-relay agent token --current` lets a session use the agent identity it already holds without printing, minting, or rotating its token. It checks the token with a read-only lookup. `--out ` writes the token to a new owner-only (0600) file, and `--from-file -- ` runs the next command with `RELAY_AGENT_TOKEN` set. This is a safe fallback when the desktop session socket is unavailable. +## [Unreleased - Minor] ### Changed -- `agent-relay agent register` is now create-only. For a name that already exists it fails, leaves that identity's token unchanged, and points to `agent token --current`. Pass `--rotate` to replace the existing token deliberately. `--strict` is still accepted but now does nothing. +- `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 but now does nothing. ### Fixed -- The `agent-relay mcp` stdio server no longer exits before the MCP handshake when its startup registration fails, for example when the agent name already exists or Relaycast is unreachable. Before this fix, Claude Code reported "Connection closed". The server now starts without an agent identity, writes the reason to stderr, and returns that reason from identity-scoped tools until `register_agent` succeeds. -- Agent-scoped `agent-relay message` commands run without an agent token now explain the non-rotating options instead of failing with an internal SDK error. +- The `agent-relay mcp` stdio server no longer exits before the MCP handshake when its startup registration fails, for example when 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, logs the reason to stderr with credentials redacted, and identity-scoped tools return that reason until `register_agent` succeeds. ## [13.1.5] - 2026-10-08 diff --git a/packages/cli/README.md b/packages/cli/README.md index b58c7b5cbb..fc209592ee 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -519,33 +519,15 @@ Relay servers that enforce create-only registration refuse the rotation. must already exist. `--strict` is still accepted, but it does nothing now that create-only is the default. -To act as an identity a session already holds, for example a broker-spawned -worker with `RELAY_AGENT_TOKEN` set, or a fallback when the desktop session -socket is unavailable, use `agent token --current`. It never mints or rotates -a token, and it never writes the token to stdout or stderr: - -```bash -# Confirm which identity this shell already has (read-only lookup). -agent-relay agent token --current - -# Hand it to a later command without printing it: write a new 0600 file... -agent-relay agent token --current --out ~/.config/agent-relay/me.token - -# ...and run a command with RELAY_AGENT_TOKEN set from that file. -agent-relay agent token --current --from-file ~/.config/agent-relay/me.token -- \ - agent-relay message dm send reviewer "Ready for review" -``` - -If the session has no token, the command fails without contacting the -service. Don't re-register your own name to get one. Send through the desktop -session socket or the Agent Relay MCP tools instead, or register a new, unused -name. +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, and identity-scoped tools return that reason until -`register_agent` succeeds. +reason to stderr with credentials redacted, and identity-scoped tools return +that reason until `register_agent` succeeds. ## Cloud multiplayer rooms 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 616a316b60..63b6a6ec43 100644 --- a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts +++ b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts @@ -2151,6 +2151,51 @@ describe('startAgentRelayMcpStdio', () => { 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' + ); + }); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await mod.startAgentRelayMcpStdio({ + apiKey: 'rk_live_workspace_secret', + 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', + ]) { + expect(written).not.toContain(secret); + } + expect(written).toContain('Bearer '); + expect(written).toContain('https://@cast.example/v1'); } finally { stderr.mockRestore(); } diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index d0bd7f936f..5f2c20f137 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -63,6 +63,7 @@ import { resolveWorkspaceSessionKey, validateWorkspaceSessionName, } from './lib/workspace-session.js'; +import { redactCredentials } from './lib/redact-credentials.js'; import type { AgentClientLike, AgentRelayMcpServerOptions, @@ -1590,7 +1591,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,9 +1668,9 @@ export function createAgentRelayMcpServer(options: AgentRelayMcpServerOptions): } if (!session.agentToken) { - if (options.startupRegistrationError) { + if (startupRegistrationError) { throw new Error( - `Not registered: startup registration failed (${options.startupRegistrationError}). Call the "register_agent" tool to retry.` + `Not registered: startup registration failed (${startupRegistrationError}). Call the "register_agent" tool to retry.` ); } throw new Error('Not registered. Call the "register_agent" tool first.'); @@ -1833,7 +1839,7 @@ export async function resolveStdioBootstrapOptionsOrDegrade( 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" - : safeRelayErrorMessage(error); + : redactCredentials(safeRelayErrorMessage(error), [options.apiKey, options.agentToken]); const who = options.agentName ? ` as "${options.agentName}"` : ''; writeStderr( `[agent-relay mcp] Startup registration${who} failed: ${reason}. ` + diff --git a/packages/cli/src/cli/bootstrap.test.ts b/packages/cli/src/cli/bootstrap.test.ts index 1a2ef1c93e..f07483fdba 100644 --- a/packages/cli/src/cli/bootstrap.test.ts +++ b/packages/cli/src/cli/bootstrap.test.ts @@ -95,7 +95,6 @@ const expectedLeafCommands = [ // workspace agents 'agent register', 'agent rotate', - 'agent token', 'agent list', 'agent add', 'agent remove', diff --git a/packages/cli/src/cli/commands/agent.test.ts b/packages/cli/src/cli/commands/agent.test.ts index 7d988eaa02..783d0e04f4 100644 --- a/packages/cli/src/cli/commands/agent.test.ts +++ b/packages/cli/src/cli/commands/agent.test.ts @@ -1,9 +1,5 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - import { Command } from 'commander'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { registerAgentCommands } from './agent.js'; @@ -31,14 +27,12 @@ function createHarness(env: NodeJS.ProcessEnv = {}) { const createWorkspaceRelay = vi.fn(() => workspaceRelay); const log = vi.fn(); const error = vi.fn(); - const runWithEnv = vi.fn(async (_command: string, _args: string[], _env: NodeJS.ProcessEnv) => 0); const program = new Command(); program.exitOverride(); registerAgentCommands(program, { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: createWorkspaceRelay as never, env, - runWithEnv, log, error, exit: ((code: number) => { @@ -53,7 +47,6 @@ function createHarness(env: NodeJS.ProcessEnv = {}) { createWorkspaceRelay, log, error, - runWithEnv, }; } @@ -158,7 +151,8 @@ describe('agent identity lifecycle commands', () => { const rendered = everythingPrinted(harness); expect(rendered).toContain('already exists'); expect(rendered).toContain('left its token unchanged'); - expect(rendered).toContain('agent token --current'); + expect(rendered).toContain('RELAY_AGENT_TOKEN'); + expect(rendered).toContain('desktop session socket'); expect(rendered).toContain('--rotate'); expect(rendered).not.toContain('at_live_'); }); @@ -180,7 +174,7 @@ describe('agent identity lifecycle commands', () => { ).rejects.toThrow('exit:1'); expect(harness.workspaceRelay.workspace.register).toHaveBeenCalledTimes(1); - expect(everythingPrinted(harness)).toContain('agent token --current'); + expect(everythingPrinted(harness)).toContain('do not re-register its name'); }); it('register --rotate rotates an existing name only when asked', async () => { @@ -288,13 +282,13 @@ describe('agent identity lifecycle commands', () => { expect(harness.workspaceRelay.workspace.register).not.toHaveBeenCalled(); }); - it('register help states create-only, the explicit --rotate flag, and the non-rotating path', () => { + 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('agent token --current'); + expect(help).toContain('never re-register its name'); expect(help).not.toContain('--strict'); }); @@ -486,181 +480,3 @@ describe('agent identity lifecycle commands', () => { expect(rendered).not.toContain('214015171589668864'); }); }); - -describe('agent token --current', () => { - const tempDirs: string[] = []; - const TOKEN = 'at_live_current_session_secret'; - - function tempDir(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-token-')); - tempDirs.push(dir); - return dir; - } - - afterEach(() => { - for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); - }); - - it('verifies the existing identity read-only and never prints the token', async () => { - const harness = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); - - await harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current']); - - expect(harness.createAgentRelay).toHaveBeenCalledWith({ token: TOKEN, baseUrl: undefined }); - expect(harness.agentRelay.agents.me).toHaveBeenCalledTimes(1); - expect(harness.createWorkspaceRelay).not.toHaveBeenCalled(); - expect(harness.workspaceRelay.workspace.register).not.toHaveBeenCalled(); - expect(JSON.parse(String(harness.log.mock.calls[0]?.[0]))).toEqual({ - id: 'agent_1', - name: 'room-human', - source: 'env', - }); - expect(everythingPrinted(harness)).not.toContain(TOKEN); - }); - - it('requires --current', async () => { - const harness = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); - - await expect(harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token'])).rejects.toThrow( - 'exit:1' - ); - expect(harness.error.mock.calls.flat().join('\n')).toContain('Pass --current'); - expect(harness.agentRelay.agents.me).not.toHaveBeenCalled(); - }); - - it('fails without minting anything when the session has no token', async () => { - const harness = createHarness({ RELAY_AGENT_NAME: 'chief' }); - - await expect( - harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current']) - ).rejects.toThrow('exit:1'); - - const rendered = harness.error.mock.calls.flat().join('\n'); - expect(rendered).toContain('no agent token'); - expect(rendered).toContain('Nothing was minted or rotated'); - expect(harness.createAgentRelay).not.toHaveBeenCalled(); - expect(harness.createWorkspaceRelay).not.toHaveBeenCalled(); - }); - - it('reports a rejected token without echoing it', async () => { - const harness = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); - harness.agentRelay.agents.me.mockRejectedValueOnce( - new Error(`agent_token_invalid: token ${TOKEN} is not valid`) - ); - - await expect( - harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current']) - ).rejects.toThrow('exit:1'); - - const rendered = everythingPrinted(harness); - expect(rendered).toContain('was rejected'); - expect(rendered).not.toContain(TOKEN); - }); - - it('writes the token to a new 0600 file and prints only the path', async () => { - const harness = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); - const file = path.join(tempDir(), 'me.token'); - - await harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current', '--out', file]); - - expect(fs.readFileSync(file, 'utf8').trim()).toBe(TOKEN); - if (process.platform !== 'win32') expect(fs.statSync(file).mode & 0o777).toBe(0o600); - expect(JSON.parse(String(harness.log.mock.calls[0]?.[0]))).toMatchObject({ tokenFile: file }); - expect(everythingPrinted(harness)).not.toContain(TOKEN); - }); - - it('refuses to overwrite an existing file unless --force is passed', async () => { - const harness = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); - const file = path.join(tempDir(), 'me.token'); - fs.writeFileSync(file, 'keep me'); - - await expect( - harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current', '--out', file]) - ).rejects.toThrow('exit:1'); - expect(fs.readFileSync(file, 'utf8')).toBe('keep me'); - - const forced = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); - await forced.program.parseAsync([ - 'node', - 'agent-relay', - 'agent', - 'token', - '--current', - '--out', - file, - '--force', - ]); - expect(fs.readFileSync(file, 'utf8').trim()).toBe(TOKEN); - }); - - it('runs the next command with the token from a file in its environment, without printing it', async () => { - const file = path.join(tempDir(), 'me.token'); - fs.writeFileSync(file, `${TOKEN}\n`, { mode: 0o600 }); - const harness = createHarness({ PATH: '/usr/bin' }); - - await harness.program.parseAsync([ - 'node', - 'agent-relay', - 'agent', - 'token', - '--current', - '--from-file', - file, - '--', - 'agent-relay', - 'message', - 'dm', - 'send', - 'reviewer', - 'ready', - '--mode', - 'steer', - ]); - - expect(harness.createAgentRelay).toHaveBeenCalledWith({ token: TOKEN, baseUrl: undefined }); - expect(harness.runWithEnv).toHaveBeenCalledTimes(1); - const [command, args, env] = harness.runWithEnv.mock.calls[0]!; - expect(command).toBe('agent-relay'); - expect(args).toEqual(['message', 'dm', 'send', 'reviewer', 'ready', '--mode', 'steer']); - expect(env).toMatchObject({ PATH: '/usr/bin', RELAY_AGENT_TOKEN: TOKEN, RELAY_AGENT_NAME: 'room-human' }); - expect(harness.log).not.toHaveBeenCalled(); - expect(everythingPrinted(harness)).not.toContain(TOKEN); - }); - - it('propagates a non-zero exit code from the wrapped command', async () => { - const harness = createHarness({ RELAY_AGENT_TOKEN: TOKEN }); - harness.runWithEnv.mockResolvedValueOnce(3); - - await expect( - harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current', '--', 'false']) - ).rejects.toThrow('exit:3'); - }); - - it('refuses a token file that other users can read', async () => { - if (process.platform === 'win32') return; - const file = path.join(tempDir(), 'me.token'); - fs.writeFileSync(file, TOKEN); - fs.chmodSync(file, 0o644); - const harness = createHarness(); - - await expect( - harness.program.parseAsync(['node', 'agent-relay', 'agent', 'token', '--current', '--from-file', file]) - ).rejects.toThrow('exit:1'); - - expect(harness.error.mock.calls.flat().join('\n')).toContain('chmod 600'); - expect(harness.agentRelay.agents.me).not.toHaveBeenCalled(); - expect(everythingPrinted(harness)).not.toContain(TOKEN); - }); - - it('help says it never prints, mints, or rotates the token', () => { - const { program } = createHarness(); - const help = helpFor(program, 'agent', 'token'); - - expect(help).toContain('--current'); - expect(help).toContain('--out '); - expect(help).toContain('--from-file '); - expect(help).toContain('never written to stdout or stderr'); - expect(help).toMatch(/without\s+printing,\s+minting,\s+or\s+rotating/); - expect(help).not.toContain('--workspace-key'); - }); -}); diff --git a/packages/cli/src/cli/commands/agent.ts b/packages/cli/src/cli/commands/agent.ts index 3c4922951b..c9f07ee951 100644 --- a/packages/cli/src/cli/commands/agent.ts +++ b/packages/cli/src/cli/commands/agent.ts @@ -1,5 +1,3 @@ -import { spawn } from 'node:child_process'; - import { Option, type Command } from 'commander'; import { @@ -13,9 +11,7 @@ import { import { RelayError, safeRelayErrorMessage } from '@agent-relay/sdk'; import { withAgentRegistrationDeadline, withDeadline } from '../lib/agent-registration.js'; -import { readAgentTokenFile, writeAgentTokenFile } from '../lib/agent-token-file.js'; import { attributableReleaseReason } from '../lib/release-reason.js'; -import { resolveAgentToken } from '../lib/sdk-client.js'; function isNotFoundError(error: unknown): boolean { if (error instanceof RelayError) return error.code === 'not_found' || error.statusCode === 404; @@ -31,45 +27,28 @@ function isCurrentIdentityName(env: NodeJS.ProcessEnv, name: string): boolean { return Boolean(current) && current!.toLowerCase() === name.trim().replace(/^@/, '').toLowerCase(); } -function safeErrorDetail(error: unknown): string { - // Never echo a credential that an upstream error might quote back. - return safeRelayErrorMessage(error).replace(/\b(at|rk)_live_[A-Za-z0-9_-]+/g, ''); -} - function isNameConflictError(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'; } -/** Run `command` with `env` and resolve with its exit code. */ -export type RunWithEnv = (command: string, args: string[], env: NodeJS.ProcessEnv) => Promise; - export interface AgentCommandDependencies extends SdkCommandDeps { - /** Environment used to find the current identity (`RELAY_AGENT_NAME`, `RELAY_AGENT_TOKEN`). */ + /** Environment used to recognise this session's own identity (`RELAY_AGENT_NAME`). */ env: NodeJS.ProcessEnv; - runWithEnv: RunWithEnv; } -const defaultRunWithEnv: RunWithEnv = (command, args, env) => - new Promise((resolve, reject) => { - const child = spawn(command, args, { env, stdio: 'inherit' }); - child.once('error', reject); - child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); - }); - function withAgentDefaults(overrides: Partial = {}): AgentCommandDependencies { return { env: process.env, - runWithEnv: defaultRunWithEnv, ...withSdkDefaults(overrides), ...overrides, }; } const CURRENT_IDENTITY_HINT = - 'To act as an identity you already hold, run "agent-relay agent token --current" ' + - '(it reads the token this session already has and never mints or rotates one).'; + '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( @@ -86,28 +65,6 @@ function rotationRefusedError(name: string): Error { ); } -function resolveCurrentToken( - opts: Record, - env: NodeJS.ProcessEnv -): { token: string | undefined; source: 'file' | 'flag' | 'env' } { - const fromFile = typeof opts.fromFile === 'string' ? opts.fromFile : undefined; - const flagToken = typeof opts.token === 'string' && opts.token.trim() ? opts.token : undefined; - if (fromFile && flagToken) { - throw new Error('Pass either --from-file or --token, not both.'); - } - if (fromFile) return { token: readAgentTokenFile(fromFile), source: 'file' }; - return { token: resolveAgentToken({ token: flagToken, env }), source: flagToken ? 'flag' : 'env' }; -} - -function noCurrentTokenError(): Error { - return new Error( - 'This session has no agent token: neither --from-file, --token, nor RELAY_AGENT_TOKEN is set. ' + - 'Nothing was minted or rotated. Do not re-register your own name to get one. Instead: send through ' + - 'the Agent Relay desktop session socket, use the Agent Relay MCP tools (for example send_dm) if they ' + - 'are loaded, or register a new, unused name with "agent-relay agent register ".' - ); -} - export function registerAgentCommands( program: Command, overrides: Partial = {} @@ -133,8 +90,8 @@ export function registerAgentCommands( .addOption(new Option('--strict', 'Deprecated: registration is create-only by default').hideHelp()) .addHelpText( 'after', - '\nTo act as an identity this session already holds, use "agent-relay agent token --current" ' + - 'instead; it never mints or rotates a token.' + '\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 () => { @@ -217,90 +174,6 @@ export function registerAgentCommands( }); }); - group - .command('token') - .description( - "Use this session's existing agent identity without printing, minting, or rotating its token" - ) - .option('--current', "Required: act on this session's existing identity") - .option('--from-file ', 'Read the token from an owner-only (0600) file written by --out') - .option('--out ', 'Write the token to a new owner-only (0600) file and print only its path') - .option('--force', 'With --out, replace an existing regular file') - .option('--token ', 'Agent token (defaults to RELAY_AGENT_TOKEN); prefer --from-file') - .option('--base-url ', 'Override the API base URL (defaults to RELAY_BASE_URL)') - .argument('[command...]', 'After "--": run this command with RELAY_AGENT_TOKEN set to the token') - .addHelpText( - 'after', - [ - '', - 'The token comes from --from-file, --token, or RELAY_AGENT_TOKEN, and is verified with a read-only', - 'identity lookup. It is never written to stdout or stderr.', - '', - 'Examples:', - ' agent-relay agent token --current', - ' agent-relay agent token --current --out ~/.config/agent-relay/me.token', - ' agent-relay agent token --current --from-file ~/.config/agent-relay/me.token -- \\', - ' agent-relay message dm send reviewer "Ready for review"', - ].join('\n') - ) - .action(async (command: string[], opts: Record) => { - let childExitCode = 0; - await runSdk(deps, async () => { - if (opts.current !== true) { - throw new Error( - 'Pass --current. "agent token" only uses the identity this session already holds; ' + - 'it never mints or rotates a token.' - ); - } - const { token, source } = resolveCurrentToken(opts, deps.env); - if (!token) throw noCurrentTokenError(); - - const relay = deps.createAgentRelay({ token, baseUrl: opts.baseUrl as string | undefined }); - const me = await withDeadline( - () => relay.agents.me(), - (effectiveTimeoutMs) => - new Error(`Verifying the current agent identity did not complete within ${effectiveTimeoutMs}ms.`) - ).catch((error: unknown) => { - if (error instanceof Error && error.message.startsWith('Verifying the current agent identity')) { - throw error; - } - throw new Error( - `The current agent token was rejected (${safeErrorDetail(error)}). It may have been rotated or ` + - 'revoked. Nothing was minted or rotated.' - ); - }); - - const expectedName = deps.env.RELAY_AGENT_NAME?.trim(); - if (expectedName && me.name && me.name !== expectedName) { - deps.error( - `Warning: the token belongs to "${me.name}", but RELAY_AGENT_NAME is "${expectedName}".` - ); - } - - const tokenFile = - typeof opts.out === 'string' - ? writeAgentTokenFile(opts.out, token, { force: opts.force === true }) - : undefined; - - if (command.length > 0) { - const [executable, ...args] = command; - const env: NodeJS.ProcessEnv = { ...deps.env, RELAY_AGENT_TOKEN: token }; - if (me.name) env.RELAY_AGENT_NAME = me.name; - childExitCode = await deps.runWithEnv(executable, args, env); - return; - } - - printJson(deps, { - id: me.id, - name: me.name, - source, - ...(tokenFile ? { tokenFile } : {}), - }); - }); - // Outside runSdk so the wrapped command's own exit code is preserved. - if (childExitCode !== 0) deps.exit(childExitCode); - }); - addSdkOptions( group.command('list').description('List agents').option('--status ', 'Filter by status') ).action(async (opts: Record) => { diff --git a/packages/cli/src/cli/commands/relaycast-groups.test.ts b/packages/cli/src/cli/commands/relaycast-groups.test.ts index 6ef3aab887..7d2eb54c8f 100644 --- a/packages/cli/src/cli/commands/relaycast-groups.test.ts +++ b/packages/cli/src/cli/commands/relaycast-groups.test.ts @@ -193,22 +193,6 @@ describe('SDK-backed CLI groups', () => { // MUST-FIRE: retaining the created message and receipt must not make an // unresolved recipient look like CLI success. - it('message dm send without an agent token points at the non-rotating paths, not re-registering', async () => { - const { program, relay, error, exit } = harness(registerMessageCommands); - relay.messages.direct.mockRejectedValueOnce( - new Error('RelaycastMessagingClient.messages.direct requires agentToken or agentClient.') - ); - - await program.parseAsync(['message', 'dm', 'send', 'lead', 'hi'], { from: 'user' }); - - const rendered = error.mock.calls.flat().join('\n'); - expect(rendered).toContain('needs an agent token'); - expect(rendered).toContain('agent token --current --from-file'); - expect(rendered).toContain('Do not re-register your own name'); - expect(rendered).not.toContain('RelaycastMessagingClient'); - expect(exit).toHaveBeenCalledWith(1); - }); - it('message dm send exits non-zero after printing an unresolved-recipient receipt', async () => { const { program, relay, workspaceRelay, log, error, exit } = harness(registerMessageCommands); workspaceRelay.agents.list.mockResolvedValueOnce([]); diff --git a/packages/cli/src/cli/lib/agent-token-file.ts b/packages/cli/src/cli/lib/agent-token-file.ts deleted file mode 100644 index 2be50f47e2..0000000000 --- a/packages/cli/src/cli/lib/agent-token-file.ts +++ /dev/null @@ -1,80 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -/** - * Owner-only token files for handing an existing agent identity to another - * command without printing it. The token never appears in stdout, stderr, or - * process arguments; only the path does. - */ - -const OWNER_ONLY_MODE = 0o600; - -export interface WriteAgentTokenFileOptions { - /** Replace an existing regular file. Symlinks and non-files are always refused. */ - force?: boolean; -} - -/** Write `token` to a new `0600` file. Returns the absolute path written. */ -export function writeAgentTokenFile( - filePath: string, - token: string, - options: WriteAgentTokenFileOptions = {} -): string { - const target = path.resolve(filePath); - let existing: fs.Stats | undefined; - try { - existing = fs.lstatSync(target); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - if (existing) { - if (!existing.isFile()) { - throw new Error(`Refusing to write the agent token to ${target}: it exists and is not a regular file.`); - } - if (!options.force) { - throw new Error( - `Refusing to overwrite ${target}: it already exists. Pass --force to replace it, or choose a new path.` - ); - } - fs.unlinkSync(target); - } - - // `wx` fails if something re-created the path after the unlink above, so a - // raced symlink can never redirect the write. - const fd = fs.openSync(target, 'wx', OWNER_ONLY_MODE); - try { - fs.writeSync(fd, `${token}\n`); - // The create mode is filtered by the umask; set it explicitly as well. - if (process.platform !== 'win32') fs.fchmodSync(fd, OWNER_ONLY_MODE); - } finally { - fs.closeSync(fd); - } - return target; -} - -/** Read a token written by {@link writeAgentTokenFile}, refusing group/world-readable files. */ -export function readAgentTokenFile(filePath: string): string { - const target = path.resolve(filePath); - let stats: fs.Stats; - try { - stats = fs.statSync(target); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - throw new Error(`Agent token file ${target} does not exist.`, { cause: error }); - } - throw error; - } - if (!stats.isFile()) { - throw new Error(`Agent token file ${target} is not a regular file.`); - } - if (process.platform !== 'win32' && (stats.mode & 0o077) !== 0) { - throw new Error( - `Agent token file ${target} is readable by other users; run "chmod 600" on it before using it.` - ); - } - const token = fs.readFileSync(target, 'utf8').trim(); - if (!token) { - throw new Error(`Agent token file ${target} is empty.`); - } - return token; -} 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 0000000000..047505fb49 --- /dev/null +++ b/packages/cli/src/cli/lib/redact-credentials.ts @@ -0,0 +1,32 @@ +/** + * 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. + */ +const CREDENTIAL_PATTERNS: RegExp[] = [ + // Relaycast agent tokens and workspace keys (live and test). + /\b(?:at|rk)_(?:live|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, + // Credentials embedded in a URL, e.g. https://user:secret@host. + /(?<=[a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+(?::[^\s/@]*)?@/gi, + // Authorization header values. + /\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, +]; + +export function redactCredentials(text: string, known: Array = []): string { + let out = text; + for (const secret of known) { + if (secret && secret.length >= 8) out = out.split(secret).join(''); + } + for (const pattern of CREDENTIAL_PATTERNS) { + out = out.replace(pattern, (match, prefix?: string) => + typeof prefix === 'string' && /^bearer$/i.test(prefix) + ? `${prefix} ` + : match.endsWith('@') + ? '@' + : '' + ); + } + return out; +} diff --git a/packages/cli/src/cli/lib/sdk-command.ts b/packages/cli/src/cli/lib/sdk-command.ts index f2409787b3..cdf84799dd 100644 --- a/packages/cli/src/cli/lib/sdk-command.ts +++ b/packages/cli/src/cli/lib/sdk-command.ts @@ -71,30 +71,12 @@ export function printJson(deps: SdkCommandDeps, value: unknown): void { deps.log(JSON.stringify(value, null, 2)); } -/** - * Guidance for an agent-scoped command run without an agent token. The SDK's - * own error names an internal client; point at the non-rotating paths instead - * of leaving `agent register` (which cannot hand back an existing identity) as - * the only apparent way to get a token. - */ -export const MISSING_AGENT_TOKEN_MESSAGE = - 'This command needs an agent token: pass --token or set RELAY_AGENT_TOKEN. Do not re-register your ' + - 'own name to get one. If you already hold a token in a file, run it through ' + - '"agent-relay agent token --current --from-file -- ". Otherwise send through the ' + - 'Agent Relay desktop session socket, use the Agent Relay MCP tools if they are loaded, or register a ' + - 'new, unused name with "agent-relay agent register ".'; - -function missingAgentToken(message: string): boolean { - return /requires agentToken or agentClient/.test(message); -} - /** Run an SDK command body, formatting errors consistently and exiting non-zero. */ export async function runSdk(deps: SdkCommandDeps, fn: () => Promise): Promise { try { await fn(); } catch (err) { - const rawMessage = safeRelayErrorMessage(err); - const message = missingAgentToken(rawMessage) ? MISSING_AGENT_TOKEN_MESSAGE : rawMessage; + const message = safeRelayErrorMessage(err); if (err instanceof RelayPlacementError) { const structured = { error: { From f7a53150915bd2c1ba944879a5a36324ef74e212 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 7 Oct 2026 22:49:17 -0700 Subject: [PATCH 3/6] docs(cli): clarify --strict cannot be combined with --rotate Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- packages/cli/README.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91354cb608..3cfbf4bea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 but now does nothing. +- `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 diff --git a/packages/cli/README.md b/packages/cli/README.md index fc209592ee..997753e366 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -516,8 +516,9 @@ 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, but it does nothing now that -create-only is the default. +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 From 7ee38d21cbaa157fc896e0274cdc6186aa7f5b2b Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 7 Oct 2026 23:01:02 -0700 Subject: [PATCH 4/6] fix(cli): widen MCP startup redaction; mark pending release Major Reuse the shared cloud redactor for every live-credential prefix, and also redact test-mode tokens, JWTs, bearer values, URL userinfo, credential query parameters, and every declared key (including workspaceKey and short values). Split the MCP changelog bullet. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 ++- .../src/cli/agent-relay-mcp.startup.test.ts | 10 ++++- packages/cli/src/cli/agent-relay-mcp.ts | 6 ++- .../cli/src/cli/lib/redact-credentials.ts | 41 +++++++++++-------- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cfbf4bea7..10a02cef11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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 - Minor] +## [Unreleased - Major] ### Changed @@ -13,7 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- The `agent-relay mcp` stdio server no longer exits before the MCP handshake when its startup registration fails, for example when 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, logs the reason to stderr with credentials redacted, and identity-scoped tools return that reason until `register_agent` succeeds. +- `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/src/cli/agent-relay-mcp.startup.test.ts b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts index 63b6a6ec43..95cb521cce 100644 --- a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts +++ b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts @@ -2171,13 +2171,17 @@ describe('startAgentRelayMcpStdio', () => { 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' + '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: [], }); @@ -2191,11 +2195,15 @@ describe('startAgentRelayMcpStdio', () => { '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(); } diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index 5f2c20f137..d4139f9a2e 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -1839,7 +1839,11 @@ export async function resolveStdioBootstrapOptionsOrDegrade( 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.apiKey, options.agentToken]); + : 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}. ` + diff --git a/packages/cli/src/cli/lib/redact-credentials.ts b/packages/cli/src/cli/lib/redact-credentials.ts index 047505fb49..4b14f6c3e3 100644 --- a/packages/cli/src/cli/lib/redact-credentials.ts +++ b/packages/cli/src/cli/lib/redact-credentials.ts @@ -1,32 +1,39 @@ +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 CREDENTIAL_PATTERNS: RegExp[] = [ - // Relaycast agent tokens and workspace keys (live and test). - /\b(?:at|rk)_(?:live|test)_[A-Za-z0-9_-]+/g, +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, - // Credentials embedded in a URL, e.g. https://user:secret@host. - /(?<=[a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+(?::[^\s/@]*)?@/gi, + [/\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g, ''], // Authorization header values. - /\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, + [/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 '], + // 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 { +export function redactCredentials(text: string, known: Array = []): string { let out = text; for (const secret of known) { - if (secret && secret.length >= 8) out = out.split(secret).join(''); + const value = secret?.trim(); + if (value) out = out.split(value).join(''); } - for (const pattern of CREDENTIAL_PATTERNS) { - out = out.replace(pattern, (match, prefix?: string) => - typeof prefix === 'string' && /^bearer$/i.test(prefix) - ? `${prefix} ` - : match.endsWith('@') - ? '@' - : '' - ); + out = redactCredentialValues(out); + for (const [pattern, replacement] of EXTRA_PATTERNS) { + out = out.replace(pattern, replacement); } return out; } From 45d25827202d729dc626a18ad61af30e4ccd8bf4 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 7 Oct 2026 23:03:16 -0700 Subject: [PATCH 5/6] fix(cli): redact credential ranges before declared values; share name-conflict predicate Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cli/src/cli/agent-relay-mcp.ts | 7 +------ packages/cli/src/cli/commands/agent.test.ts | 4 ++++ packages/cli/src/cli/commands/agent.ts | 13 ++++--------- .../cli/src/cli/lib/agent-name-conflict.ts | 10 ++++++++++ .../cli/src/cli/lib/redact-credentials.test.ts | 18 ++++++++++++++++++ packages/cli/src/cli/lib/redact-credentials.ts | 12 +++++++----- 6 files changed, 44 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/cli/lib/agent-name-conflict.ts create mode 100644 packages/cli/src/cli/lib/redact-credentials.test.ts diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index d4139f9a2e..c84378b4f4 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -63,6 +63,7 @@ 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, @@ -1810,12 +1811,6 @@ export async function resolveStdioBootstrapOptions( }; } -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'; -} - /** * Run the startup registration, but never let its failure kill the stdio * server before the MCP `initialize` handshake. An exit here is what MCP diff --git a/packages/cli/src/cli/commands/agent.test.ts b/packages/cli/src/cli/commands/agent.test.ts index 783d0e04f4..e5684f6ab1 100644 --- a/packages/cli/src/cli/commands/agent.test.ts +++ b/packages/cli/src/cli/commands/agent.test.ts @@ -217,6 +217,10 @@ describe('agent identity lifecycle commands', () => { ]); 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 () => { diff --git a/packages/cli/src/cli/commands/agent.ts b/packages/cli/src/cli/commands/agent.ts index c9f07ee951..17669cd06c 100644 --- a/packages/cli/src/cli/commands/agent.ts +++ b/packages/cli/src/cli/commands/agent.ts @@ -11,6 +11,7 @@ import { 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 { @@ -27,12 +28,6 @@ function isCurrentIdentityName(env: NodeJS.ProcessEnv, name: string): boolean { return Boolean(current) && current!.toLowerCase() === name.trim().replace(/^@/, '').toLowerCase(); } -function isNameConflictError(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'; -} - export interface AgentCommandDependencies extends SdkCommandDeps { /** Environment used to recognise this session's own identity (`RELAY_AGENT_NAME`). */ env: NodeJS.ProcessEnv; @@ -112,7 +107,7 @@ export function registerAgentCommands( name ); } catch (error) { - if (!isNameConflictError(error)) throw error; + if (!isAgentNameConflict(error)) throw error; if (!rotate) throw existingNameError(name); if (isCurrentIdentityName(deps.env, name)) { deps.error( @@ -124,7 +119,7 @@ export function registerAgentCommands( () => relay.workspace.register(input, { strict: false }), name ).catch((rotateError: unknown) => { - throw isNameConflictError(rotateError) ? rotationRefusedError(name) : rotateError; + throw isAgentNameConflict(rotateError) ? rotationRefusedError(name) : rotateError; }); } printJson(deps, { id: registration.id, name: registration.name, token: registration.token }); @@ -168,7 +163,7 @@ export function registerAgentCommands( () => relay.workspace.register({ name }), name ).catch((error: unknown) => { - throw isNameConflictError(error) ? rotationRefusedError(name) : error; + throw isAgentNameConflict(error) ? rotationRefusedError(name) : error; }); printJson(deps, { id: registration.id, name: registration.name, token: registration.token }); }); 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 0000000000..301bacb247 --- /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 0000000000..0ff6c4773b --- /dev/null +++ b/packages/cli/src/cli/lib/redact-credentials.test.ts @@ -0,0 +1,18 @@ +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'); + }); +}); diff --git a/packages/cli/src/cli/lib/redact-credentials.ts b/packages/cli/src/cli/lib/redact-credentials.ts index 4b14f6c3e3..51070631d6 100644 --- a/packages/cli/src/cli/lib/redact-credentials.ts +++ b/packages/cli/src/cli/lib/redact-credentials.ts @@ -26,14 +26,16 @@ const EXTRA_PATTERNS: Array<[RegExp, string]> = [ ]; export function redactCredentials(text: string, known: Array = []): string { - let out = text; + // 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(''); } - out = redactCredentialValues(out); - for (const [pattern, replacement] of EXTRA_PATTERNS) { - out = out.replace(pattern, replacement); - } return out; } From 28f414eb255928e6c8b6a5ff16766e85b56e8b0c Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 7 Oct 2026 23:24:48 -0700 Subject: [PATCH 6/6] fix(cli): redact Basic auth values and quoted JSON credential fields Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cli/src/cli/lib/redact-credentials.test.ts | 10 ++++++++++ packages/cli/src/cli/lib/redact-credentials.ts | 8 +++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli/lib/redact-credentials.test.ts b/packages/cli/src/cli/lib/redact-credentials.test.ts index 0ff6c4773b..66d3facc93 100644 --- a/packages/cli/src/cli/lib/redact-credentials.test.ts +++ b/packages/cli/src/cli/lib/redact-credentials.test.ts @@ -15,4 +15,14 @@ describe('redactCredentials', () => { 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 index 51070631d6..e496c02967 100644 --- a/packages/cli/src/cli/lib/redact-credentials.ts +++ b/packages/cli/src/cli/lib/redact-credentials.ts @@ -15,7 +15,13 @@ const EXTRA_PATTERNS: Array<[RegExp, string]> = [ // JWTs, e.g. RelayAuth tokens. [/\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g, ''], // Authorization header values. - [/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 '], + [/\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=...