Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased - Major]

### Changed

- `agent-relay agent register` no longer rotates an existing agent's token by default. For a name that already exists it now fails and leaves that identity's token unchanged. Pass the new `--rotate` flag to replace the token deliberately. `--strict` is still accepted; it has no effect on its own and can't be combined with `--rotate`.

### Fixed

- `agent-relay mcp` now completes its stdio handshake when startup registration fails (for example, the agent name already exists or Relaycast is unreachable). Claude Code used to report this as "Connection closed". The server now starts without an agent identity and writes the reason to stderr with credentials redacted.
- Identity-scoped `agent-relay mcp` tools return that startup failure until `register_agent` succeeds.

## [13.1.5] - 2026-10-08

Expand Down
25 changes: 24 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <lead-name>`. `fleet spawn --sandbox` needs a Cloud
`agent-relay agent register <lead-name>` (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.

Expand Down Expand Up @@ -507,6 +508,28 @@ record restores the stored token on the next `agent-relay node up`.

`agent-relay cloud whoami` also prints the current organization and workspace IDs.

## Agent identity tokens

`agent-relay agent register <name>` 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 <name>` is the same explicit rotation for a name that
must already exist. `--strict` is still accepted for older scripts. On its own
it changes nothing, because create-only is the default. It can't be combined
with `--rotate`.

To act as an identity a session already holds, keep using its existing token
(`RELAY_AGENT_TOKEN`), the Agent Relay desktop session socket, or the Agent
Comment thread
khaliqgant marked this conversation as resolved.
Relay MCP tools. Don't re-register its name.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

The `agent-relay mcp` stdio server also keeps running when its startup
registration fails, for example when the name already exists or Relaycast is
unreachable. It answers the MCP handshake without an agent identity, writes the
reason to stderr with credentials redacted, and identity-scoped tools return
that reason until `register_agent` succeeds.

## Cloud multiplayer rooms

Cloud room membership is scoped to one Relay workspace. Every v1 invite creates
Expand Down
107 changes: 107 additions & 0 deletions packages/cli/src/cli/agent-relay-mcp.startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2121,6 +2121,113 @@ describe('startAgentRelayMcpStdio', () => {
);
});

it('completes the stdio handshake when startup registration hits an existing name', async () => {
// relay#1920: a create-only server returns agent_already_exists for a
// name an earlier session registered. Exiting here is what Claude Code
// reports as "Connection closed"; the server must start degraded instead.
const { mod, mocks } = await loadAgentRelayMcpModule();
mocks.behavior.registerImpl = vi.fn(async () => {
throw Object.assign(new Error('Agent "WorkerA" already exists in this workspace'), {
code: 'name_conflict',
});
});
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
await mod.startAgentRelayMcpStdio({
apiKey: 'rk_live_workspace',
agentName: 'WorkerA',
agentToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig',
sharedSessionTools: [],
});

const server = mocks.serverInstances[0];
expect(server.connect).toHaveBeenCalledTimes(1);
const warning = stderr.mock.calls.map((call) => String(call[0])).join('');
expect(warning).toContain('Startup registration as "WorkerA" failed');
expect(warning).toContain('already exists');
expect(warning).not.toContain('rk_live_workspace');
expect(warning).not.toContain('eyJhbGciOiJSUzI1NiJ9');

await expect(server.tools.get('send_dm')!.handler({ to: 'peer', text: 'hi' })).rejects.toThrow(
/startup registration failed .*already exists.*register_agent/
);

// Once register_agent succeeds the startup failure is no longer reported.
mocks.behavior.registerImpl = vi.fn(async () => ({
id: 'agent_b',
name: 'WorkerB',
token: 'at_live_workerb',
}));
await server.tools.get('register_agent')!.handler({ name: 'WorkerB' });
await server.tools.get('send_dm')!.handler({ to: 'peer', text: 'hi' });
} finally {
stderr.mockRestore();
}
});

it('redacts credentials an upstream startup error quotes back on stderr', async () => {
const { mod, mocks } = await loadAgentRelayMcpModule();
mocks.behavior.registerImpl = vi.fn(async () => {
throw new Error(
'POST /v1/agents failed: Authorization: Bearer abc.def.ghi key=rk_live_workspace_secret ' +
'token=at_live_agent_secret jwt=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.sig ' +
'url=https://relay-user:url-password-secret@cast.example/v1 ' +
'query=https://cast.example/v1/agents?api_key=opaque-query-secret&x=1 ' +
'node=nt_live_node_credential_secret short=k9z'
);
});
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
await mod.startAgentRelayMcpStdio({
apiKey: 'rk_live_workspace_secret',
// A short, unprefixed declared key is still removed.
workspaceKey: 'k9z',
agentName: 'WorkerA',
sharedSessionTools: [],
});
expect(mocks.serverInstances[0].connect).toHaveBeenCalledTimes(1);
const written = stderr.mock.calls.map((call) => String(call[0])).join('');
expect(written).toContain('Startup registration as "WorkerA" failed');
for (const secret of [
'rk_live_workspace_secret',
'at_live_agent_secret',
'eyJhbGciOiJSUzI1NiJ9',
'abc.def.ghi',
'url-password-secret',
'relay-user',
'opaque-query-secret',
'node_credential_secret',
'k9z',
]) {
expect(written).not.toContain(secret);
}
expect(written).toContain('Bearer <redacted>');
expect(written).toContain('https://<redacted>@cast.example/v1');
expect(written).toContain('?api_key=<redacted>&x=1');
} finally {
stderr.mockRestore();
}
});

it('completes the stdio handshake when Relaycast is unreachable at startup', async () => {
const { mod, mocks } = await loadAgentRelayMcpModule();
mocks.behavior.registerImpl = vi.fn(async () => {
throw new Error('Network request failed: fetch failed');
});
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
await mod.startAgentRelayMcpStdio({
apiKey: 'rk_live_workspace',
agentName: 'WorkerA',
sharedSessionTools: [],
});
expect(mocks.serverInstances[0].connect).toHaveBeenCalledTimes(1);
expect(stderr.mock.calls.map((call) => String(call[0])).join('')).toContain('Network request failed');
} finally {
stderr.mockRestore();
}
});

it('starts sessions-only MCP without Relaycast registration or messaging tools', async () => {
const { mod, mocks } = await loadAgentRelayMcpModule();

Expand Down
55 changes: 54 additions & 1 deletion packages/cli/src/cli/agent-relay-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
resolveWorkspaceSessionKey,
validateWorkspaceSessionName,
} from './lib/workspace-session.js';
import { isAgentNameConflict } from './lib/agent-name-conflict.js';
import { redactCredentials } from './lib/redact-credentials.js';
import type {
AgentClientLike,
AgentRelayMcpServerOptions,
Expand Down Expand Up @@ -305,7 +307,7 @@
return `${VERIFIED_SPAWN_MISSING_READY_MESSAGE} Resolved handler node: ${handler}; invocation: ${invocationId}.`;
}

async function waitForVerifiedSpawn(

Check warning on line 310 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

Async function 'waitForVerifiedSpawn' has a complexity of 21. Maximum allowed is 15
actions: InvocationReader,
ackValue: unknown,
timeoutMs = VERIFIED_SPAWN_TIMEOUT_MS
Expand Down Expand Up @@ -625,7 +627,7 @@
});
} catch (err) {
if ((err as { name?: string }).name === 'AbortError') {
throw new Error(`Agent Relay result submission timed out after ${timeoutMs}ms`);

Check warning on line 630 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

There is no `cause` attached to the symptom error being thrown
}
throw err;
} finally {
Expand Down Expand Up @@ -678,7 +680,7 @@
return { agentName, agentToken };
}

export async function registerAgentWithRebind({

Check warning on line 683 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

Async function 'registerAgentWithRebind' has a complexity of 29. Maximum allowed is 15
session,
setSession,
getRelay,
Expand Down Expand Up @@ -1590,7 +1592,12 @@
}
};

// 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;
Expand Down Expand Up @@ -1662,6 +1669,11 @@
}

if (!session.agentToken) {
if (startupRegistrationError) {
throw new Error(
`Not registered: startup registration failed (${startupRegistrationError}). Call the "register_agent" tool to retry.`
);
Comment thread
khaliqgant marked this conversation as resolved.
}
throw new Error('Not registered. Call the "register_agent" tool first.');
}

Expand Down Expand Up @@ -1799,9 +1811,50 @@
};
}

/**
* 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<AgentRelayMcpServerOptions> {
try {
return await resolveStdioBootstrapOptions(options);
} catch (error) {
if (options.sessionsOnly) throw error;
const conflict = isAgentNameConflict(error);
const reason = conflict
? `agent "${options.agentName}" already exists and registration is create-only; ` +
"set RELAY_AGENT_TOKEN to that identity's existing token or register a different name"
: redactCredentials(safeRelayErrorMessage(error), [
options.workspaceKey,
options.apiKey,
options.agentToken,
]);
const who = options.agentName ? ` as "${options.agentName}"` : '';
writeStderr(
`[agent-relay mcp] Startup registration${who} failed: ${reason}. ` +
'Starting without an agent identity; call register_agent to retry.\n'
Comment thread
khaliqgant marked this conversation as resolved.
);
// 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 };
Comment thread
khaliqgant marked this conversation as resolved.
}
}

export async function startAgentRelayMcpStdio(options: AgentRelayMcpServerOptions): Promise<void> {
initMcpTelemetry();
const bootstrappedOptions = await resolveStdioBootstrapOptions(options);
const bootstrappedOptions = await resolveStdioBootstrapOptionsOrDegrade(options);
const sharedSessionsClient = bootstrappedOptions.sharedSessionsClient ?? new SharedSessionsMcpClient();
let sharedSessionTools = bootstrappedOptions.sharedSessionTools;
if (!sharedSessionTools) {
Expand Down
Loading
Loading