From 8d96e21061cc15e62d906fc39251126db2af5acb Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 23 Jul 2026 22:34:34 +0200 Subject: [PATCH 01/21] feat(cli): add Cloud multiplayer room commands --- CHANGELOG.md | 7 +- packages/cli/README.md | 45 + packages/cli/src/cli/commands/agent.test.ts | 57 ++ packages/cli/src/cli/commands/agent.ts | 18 + .../cli/src/cli/commands/cloud-room.test.ts | 793 +++++++++++++++++ packages/cli/src/cli/commands/cloud-room.ts | 814 ++++++++++++++++++ packages/cli/src/cli/commands/cloud.test.ts | 1 + packages/cli/src/cli/commands/cloud.ts | 5 +- packages/cli/src/cli/lib/sdk-client.test.ts | 15 + packages/cli/src/cli/lib/sdk-client.ts | 7 +- 10 files changed, 1758 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/cli/commands/agent.test.ts create mode 100644 packages/cli/src/cli/commands/cloud-room.test.ts create mode 100644 packages/cli/src/cli/commands/cloud-room.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cc4b000bb..ef2a4d40b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,12 @@ 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 - Minor] + +### Added + +- `agent-relay cloud room` can invite workspace-scoped participants through explicit secret sinks, manage members, and establish per-device multiplayer sessions without sharing a Relay workspace key. +- `agent-relay agent me|presence` use scoped agent credentials for room-safe identity and presence checks. ## [11.1.1] - 2026-07-23 diff --git a/packages/cli/README.md b/packages/cli/README.md index 08d643015..eac0d0d4f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -85,6 +85,51 @@ agent-relay cloud enroll --token ocl_node_enr_... agent-relay node up ``` +## Cloud multiplayer rooms + +Cloud room membership is scoped to one Relay workspace. It does not expose the +workspace key, grant Fleet control, or grant integration write access. + +```bash +# Owner: invite and manage people in this workspace. +agent-relay cloud room invite \ + --workspace rw_7ccfea89 \ + --email teammate@example.com \ + --role participant \ + --token-file ./teammate.room-invite +agent-relay cloud room invites --workspace rw_7ccfea89 +agent-relay cloud room members --workspace rw_7ccfea89 + +# Share the owner-only token file over a secure channel. The invitee keeps the +# single-use token out of shell history and process arguments. +read -rs ROOM_INVITATION_TOKEN +printf '%s' "$ROOM_INVITATION_TOKEN" | + agent-relay cloud room accept --token-stdin +unset ROOM_INVITATION_TOKEN + +# Trusted clients such as Herdr establish one stable session per device. +# --json intentionally includes the scoped participant or observer credential; +# capture it in memory and do not log or persist it. +agent-relay cloud room session \ + --workspace rw_7ccfea89 \ + --device-id herdr-macbook \ + --json + +# Explicitly ending or replacing the device session revokes the old scoped token. +agent-relay cloud room revoke-session \ + --workspace rw_7ccfea89 \ + --device-id herdr-macbook + +# Participants use their scoped token for presence and chat; an ambient owner +# workspace key is never consulted when --token is present. +agent-relay agent presence \ + --token at_live_... \ + --base-url https://cast.agentrelay.com + +# Owner: revoke access and active room sessions. +agent-relay cloud room remove-member --workspace rw_7ccfea89 +``` + `local` remains as a deprecated hidden alias of `node` (it prints a one-time warning). Node workflow runs use Relayflows for YAML, TypeScript, and Python workflow files. diff --git a/packages/cli/src/cli/commands/agent.test.ts b/packages/cli/src/cli/commands/agent.test.ts new file mode 100644 index 000000000..fdffd946a --- /dev/null +++ b/packages/cli/src/cli/commands/agent.test.ts @@ -0,0 +1,57 @@ +import { Command } from 'commander'; +import { describe, expect, it, vi } from 'vitest'; + +import { registerAgentCommands } from './agent.js'; + +function createHarness() { + const agentRelay = { + agents: { + me: vi.fn(async () => ({ id: 'agent_1', name: 'room-human' })), + presence: vi.fn(async () => [{ agent: 'room-human', status: 'online' }]), + }, + }; + const createAgentRelay = vi.fn(() => agentRelay); + const createWorkspaceRelay = vi.fn(); + const program = new Command(); + program.exitOverride(); + registerAgentCommands(program, { + createAgentRelay: createAgentRelay as never, + createWorkspaceRelay: createWorkspaceRelay as never, + log: vi.fn(), + error: vi.fn(), + exit: ((code: number) => { + throw new Error(`exit:${code}`); + }) as never, + }); + return { program, agentRelay, createAgentRelay, createWorkspaceRelay }; +} + +describe('agent-scoped identity commands', () => { + it.each([ + ['me', 'me'], + ['presence', 'presence'], + ] as const)('uses the agent credential for agent %s', async (command, method) => { + const { program, agentRelay, createAgentRelay, createWorkspaceRelay } = createHarness(); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'agent', + command, + '--token', + 'at_live_room_human', + '--workspace-key', + 'rk_live_owner_must_not_win', + '--base-url', + 'https://cast.agentrelay.test', + ]); + + expect(createAgentRelay).toHaveBeenCalledWith({ + token: 'at_live_room_human', + workspaceKey: 'rk_live_owner_must_not_win', + baseUrl: 'https://cast.agentrelay.test', + }); + expect(createWorkspaceRelay).not.toHaveBeenCalled(); + expect(agentRelay.agents[method]).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/cli/commands/agent.ts b/packages/cli/src/cli/commands/agent.ts index a7d906a49..324691ed1 100644 --- a/packages/cli/src/cli/commands/agent.ts +++ b/packages/cli/src/cli/commands/agent.ts @@ -53,6 +53,24 @@ export function registerAgentCommands( }); }); + addSdkOptions(group.command('me').description('Show the current agent identity')).action( + async (opts: Record) => { + await runSdk(deps, async () => { + const relay = deps.createAgentRelay(sdkOptionsFromOpts(opts)); + printJson(deps, await relay.agents.me()); + }); + } + ); + + addSdkOptions(group.command('presence').description('List visible agent presence')).action( + async (opts: Record) => { + await runSdk(deps, async () => { + const relay = deps.createAgentRelay(sdkOptionsFromOpts(opts)); + printJson(deps, await relay.agents.presence()); + }); + } + ); + addSdkOptions( group .command('add') diff --git a/packages/cli/src/cli/commands/cloud-room.test.ts b/packages/cli/src/cli/commands/cloud-room.test.ts new file mode 100644 index 000000000..fd015d78d --- /dev/null +++ b/packages/cli/src/cli/commands/cloud-room.test.ts @@ -0,0 +1,793 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Command } from 'commander'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerCloudRoomCommands } from './cloud-room.js'; +import type { CloudDependencies } from './cloud.js'; + +vi.mock('@agent-relay/cloud', () => ({ + defaultApiUrl: () => 'https://cloud.test', +})); + +type RoomDeps = Pick< + CloudDependencies, + 'log' | 'error' | 'exit' | 'ensureCloudSession' | 'authorizedApiFetch' +>; + +const auth = { + apiUrl: 'https://cloud.test', + accessToken: 'access-secret', + refreshToken: 'refresh-secret', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', +}; + +function jsonResponse(body: unknown, status = 200, headers?: HeadersInit): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +function createHarness(roomIo?: Parameters[2]) { + const exit = vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }) as unknown as RoomDeps['exit']; + const deps: RoomDeps = { + log: vi.fn(), + error: vi.fn(), + exit, + ensureCloudSession: vi.fn(async () => ({ auth, client: {} as never })) as RoomDeps['ensureCloudSession'], + authorizedApiFetch: vi.fn(async () => ({ + response: jsonResponse({}), + auth, + })) as RoomDeps['authorizedApiFetch'], + }; + const program = new Command(); + program.exitOverride(); + const cloud = program.command('cloud'); + registerCloudRoomCommands(cloud, deps, roomIo); + return { program, deps, room: cloud.commands[0] }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('registerCloudRoomCommands', () => { + it('registers the complete room lifecycle', () => { + const { room } = createHarness(); + + expect(room.name()).toBe('room'); + expect(room.commands.map((command) => command.name())).toEqual([ + 'invite', + 'invites', + 'revoke-invite', + 'members', + 'remove-member', + 'accept', + 'revoke-session', + 'session', + ]); + }); + + it('creates an email-bound invite with a finite role and lifetime', async () => { + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ + invite: { + id: 'invite_1', + email: 'person@example.com', + role: 'viewer', + token: 'herdr_inv_single_use_secret', + expiresAt: '2026-07-30T00:00:00.000Z', + createdAt: '2026-07-23T00:00:00.000Z', + }, + }), + auth, + }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'invite', + '--workspace', + 'rw_7ccfea89', + '--email', + 'Person@Example.com', + '--role', + 'viewer', + '--expires-in', + '600', + '--token-stdout', + ]); + + expect(deps.ensureCloudSession).toHaveBeenCalledWith({ + apiUrl: 'https://cloud.test', + interactive: false, + }); + expect(deps.authorizedApiFetch).toHaveBeenCalledWith( + auth, + '/api/v1/workspaces/rw_7ccfea89/room/invites', + { + method: 'POST', + body: JSON.stringify({ + email: 'person@example.com', + role: 'viewer', + expiresInSeconds: 600, + }), + }, + { interactive: false } + ); + expect(deps.log).toHaveBeenCalledWith('herdr_inv_single_use_secret'); + }); + + it('requires one explicit invitation-token output sink before authenticating', async () => { + const { program, deps } = createHarness(); + + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'invite', + '--workspace', + 'rw_7ccfea89', + '--email', + 'person@example.com', + ]) + ).rejects.toThrow('exit:1'); + + expect(deps.ensureCloudSession).not.toHaveBeenCalled(); + expect(deps.error).toHaveBeenCalledWith( + 'Use exactly one invitation-token sink: --token-stdout, --token-file, or --json.' + ); + }); + + it('writes an invitation token only to a new owner-only file', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-room-invite-output-')); + const tokenFile = path.join(directory, 'invite-token'); + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ + invite: { + id: 'invite_1', + email: 'person@example.com', + role: 'participant', + token: 'herdr_inv_file_secret', + expiresAt: '2026-07-30T00:00:00.000Z', + createdAt: '2026-07-23T00:00:00.000Z', + }, + }), + auth, + }); + + try { + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'invite', + '--workspace', + 'rw_7ccfea89', + '--email', + 'person@example.com', + '--token-file', + tokenFile, + ]); + expect(fs.readFileSync(tokenFile, 'utf8')).toBe('herdr_inv_file_secret\n'); + if (process.platform !== 'win32') { + expect(fs.statSync(tokenFile).mode & 0o077).toBe(0); + } + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + expect(vi.mocked(deps.log).mock.calls.flat().join('\n')).not.toContain( + 'herdr_inv_file_secret' + ); + }); + + it('revokes a newly created invite when its token file cannot be created', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-room-invite-output-')); + const tokenFile = path.join(directory, 'existing'); + fs.writeFileSync(tokenFile, 'do-not-overwrite', { mode: 0o600 }); + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch) + .mockResolvedValueOnce({ + response: jsonResponse({ + invite: { + id: 'invite_1', + email: 'person@example.com', + role: 'participant', + token: 'herdr_inv_lost_secret', + expiresAt: '2026-07-30T00:00:00.000Z', + createdAt: '2026-07-23T00:00:00.000Z', + }, + }), + auth, + }) + .mockResolvedValueOnce({ + response: new Response(null, { status: 204 }), + auth, + }); + + try { + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'invite', + '--workspace', + 'rw_7ccfea89', + '--email', + 'person@example.com', + '--token-file', + tokenFile, + ]) + ).rejects.toThrow('exit:1'); + expect(fs.readFileSync(tokenFile, 'utf8')).toBe('do-not-overwrite'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + + expect(deps.authorizedApiFetch).toHaveBeenNthCalledWith( + 2, + auth, + '/api/v1/workspaces/rw_7ccfea89/room/invites/invite_1', + { method: 'DELETE' }, + { interactive: false } + ); + expect( + [...vi.mocked(deps.log).mock.calls, ...vi.mocked(deps.error).mock.calls] + .flat() + .join('\n') + ).not.toContain('herdr_inv_lost_secret'); + }); + + it.each([ + { + args: ['invites', '--workspace', 'rw_7ccfea89', '--json'], + path: '/api/v1/workspaces/rw_7ccfea89/room/invites', + method: 'GET', + response: { invites: [] }, + }, + { + args: ['revoke-invite', 'invite_1', '--workspace', 'rw_7ccfea89', '--json'], + path: '/api/v1/workspaces/rw_7ccfea89/room/invites/invite_1', + method: 'DELETE', + response: null, + }, + { + args: ['members', '--workspace', 'rw_7ccfea89', '--json'], + path: '/api/v1/workspaces/rw_7ccfea89/room/members', + method: 'GET', + response: { members: [] }, + }, + { + args: ['remove-member', 'member_1', '--workspace', 'rw_7ccfea89', '--json'], + path: '/api/v1/workspaces/rw_7ccfea89/room/members/member_1', + method: 'DELETE', + response: null, + }, + { + args: [ + 'revoke-session', + '--workspace', + 'rw_7ccfea89', + '--device-id', + 'herdr-desktop-1', + '--json', + ], + path: '/api/v1/workspaces/rw_7ccfea89/room/session', + method: 'DELETE', + body: JSON.stringify({ deviceId: 'herdr-desktop-1' }), + response: null, + }, + ])('routes $args.0 through the scoped workspace API', async ({ args, path, method, body, response }) => { + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: response === null ? new Response(null, { status: 204 }) : jsonResponse(response), + auth, + }); + + await program.parseAsync(['node', 'agent-relay', 'cloud', 'room', ...args]); + + expect(deps.authorizedApiFetch).toHaveBeenCalledWith( + auth, + path, + body ? { method, body } : { method }, + { interactive: false } + ); + }); + + it('accepts an invitation without echoing its token', async () => { + const token = 'herdr_inv_room_secret'; + const { program, deps } = createHarness({ + readStdin: vi.fn(async () => token), + }); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ + membership: { + id: 'member_1', + workspaceId: 'rw_7ccfea89', + role: 'participant', + }, + }), + auth, + }); + + await program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'accept', '--token-stdin']); + + expect(deps.authorizedApiFetch).toHaveBeenCalledWith( + auth, + '/api/v1/room/invites/accept', + { + method: 'POST', + body: JSON.stringify({ token }), + }, + { interactive: false } + ); + expect( + [...vi.mocked(deps.log).mock.calls, ...vi.mocked(deps.error).mock.calls].flat().join('\n') + ).not.toContain(token); + }); + + it('requires exactly one private invitation-token input', async () => { + const { program, deps } = createHarness(); + + await expect(program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'accept'])).rejects.toThrow( + 'exit:1' + ); + + expect(deps.error).toHaveBeenCalledWith('Use exactly one of --token-stdin or --token-file.'); + expect(deps.ensureCloudSession).not.toHaveBeenCalled(); + }); + + it('reads an invitation token from an owner-only regular file', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-room-token-')); + const tokenFile = path.join(directory, 'invite'); + fs.writeFileSync(tokenFile, 'herdr_inv_single_use_secret\n', { mode: 0o600 }); + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ + membership: { + id: 'member_1', + workspaceId: 'rw_7ccfea89', + role: 'viewer', + }, + }), + auth, + }); + + try { + await program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'accept', '--token-file', tokenFile]); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + + expect(deps.authorizedApiFetch).toHaveBeenCalledWith( + auth, + '/api/v1/room/invites/accept', + { + method: 'POST', + body: JSON.stringify({ token: 'herdr_inv_single_use_secret' }), + }, + { interactive: false } + ); + }); + + it('rejects a symlink invitation-token file before authenticating', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-room-token-')); + const tokenFile = path.join(directory, 'invite'); + const tokenLink = path.join(directory, 'invite-link'); + fs.writeFileSync(tokenFile, 'herdr_inv_single_use_secret\n', { mode: 0o600 }); + fs.symlinkSync(tokenFile, tokenLink); + const { program, deps } = createHarness(); + + try { + await expect( + program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'accept', '--token-file', tokenLink]) + ).rejects.toThrow('exit:1'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + + expect(deps.ensureCloudSession).not.toHaveBeenCalled(); + }); + + it('rejects a malformed invitation token before authenticating', async () => { + const { program, deps } = createHarness({ + readStdin: vi.fn(async () => 'not-a-room-invitation'), + }); + + await expect( + program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'accept', '--token-stdin']) + ).rejects.toThrow('exit:1'); + + expect(deps.ensureCloudSession).not.toHaveBeenCalled(); + expect(deps.error).toHaveBeenCalledWith('Invalid room invitation token.'); + }); + + it('hides the scoped room credential unless JSON was explicitly requested', async () => { + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ + role: 'participant', + relaycastBaseUrl: 'https://relay.test', + agentName: 'human-device-1', + agentToken: 'at_live_scoped_secret', + }), + auth, + }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'session', + '--workspace', + 'rw_7ccfea89', + '--device-id', + 'herdr-desktop-1', + ]); + + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain('Room session ready with role participant.'); + expect(output).not.toContain('at_live_scoped_secret'); + }); + + it('supports an explicit Cloud API URL only when the stored login matches it', async () => { + const { program, deps } = createHarness(); + const localAuth = { ...auth, apiUrl: 'http://127.0.0.1:8787' }; + vi.mocked(deps.ensureCloudSession).mockResolvedValueOnce({ + auth: localAuth, + client: {} as never, + }); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ members: [] }), + auth: localAuth, + }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'members', + '--workspace', + 'rw_7ccfea89', + '--api-url', + 'http://127.0.0.1:8787', + ]); + + expect(deps.ensureCloudSession).toHaveBeenCalledWith({ + apiUrl: 'http://127.0.0.1:8787', + interactive: false, + }); + expect(deps.authorizedApiFetch).toHaveBeenCalledWith( + localAuth, + '/api/v1/workspaces/rw_7ccfea89/room/members', + { method: 'GET' }, + { interactive: false } + ); + }); + + it('fails closed when an explicit API URL differs from the stored login host', async () => { + const { program, deps } = createHarness(); + + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'members', + '--workspace', + 'rw_7ccfea89', + '--api-url', + 'http://127.0.0.1:8787', + ]) + ).rejects.toThrow('exit:1'); + + expect(deps.authorizedApiFetch).not.toHaveBeenCalled(); + expect(deps.error).toHaveBeenCalledWith( + expect.stringContaining('cloud login --api-url http://127.0.0.1:8787 --force') + ); + }); + + it('emits the scoped room credential for an explicitly requested machine-readable session', async () => { + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ + role: 'viewer', + relaycastBaseUrl: 'https://relay.test', + observerToken: 'ot_live_scoped_secret', + }), + auth, + }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'session', + '--workspace', + 'rw_7ccfea89', + '--device-id', + 'herdr-desktop-1', + '--json', + ]); + + expect(vi.mocked(deps.log).mock.calls.flat().join('\n')).toContain('ot_live_scoped_secret'); + }); + + it.each([ + { + role: 'participant', + relaycastBaseUrl: 'https://relay.test', + agentName: 'human-device-1', + agentToken: 'workspace-owner-key', + }, + { + role: 'viewer', + relaycastBaseUrl: 'https://relay.test', + observerToken: 'workspace-owner-key', + }, + { + role: 'participant', + relaycastBaseUrl: 'http://relay.example.com', + agentName: 'human-device-1', + agentToken: 'at_live_scoped_secret', + }, + { + role: 'viewer', + relaycastBaseUrl: 'https://user:password@relay.example.com', + observerToken: 'ot_live_scoped_secret', + }, + ])('rejects unsafe or incorrectly scoped session material', async (session) => { + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse(session), + auth, + }); + + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'session', + '--workspace', + 'rw_7ccfea89', + '--device-id', + 'herdr-desktop-1', + '--json', + ]) + ).rejects.toThrow('exit:1'); + + expect(deps.error).toHaveBeenCalledWith(expect.stringContaining('invalid')); + }); + + it('rejects unsafe workspace selectors before authenticating', async () => { + const { program, deps } = createHarness(); + + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'members', + '--workspace', + 'rk_live_workspace_secret', + ]) + ).rejects.toThrow('exit:1'); + + expect(deps.error).toHaveBeenCalledWith( + 'Unsupported Cloud workspace identifier. Use a Cloud workspace UUID or unified rw_ workspace ID.' + ); + expect(deps.ensureCloudSession).not.toHaveBeenCalled(); + expect(vi.mocked(deps.error).mock.calls.flat().join('\n')).not.toContain('rk_live_workspace_secret'); + }); + + it('does not reflect server response bodies that may contain credentials', async () => { + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ error: 'bad session at_live_should_not_leak' }, 400), + auth, + }); + + await expect( + program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'members', '--workspace', 'rw_7ccfea89']) + ).rejects.toThrow('exit:1'); + + expect(deps.error).toHaveBeenCalledWith('Cloud rejected the room request (400).'); + expect(vi.mocked(deps.error).mock.calls.flat().join('\n')).not.toContain('at_live_should_not_leak'); + }); + + it('sanitizes terminal controls and bidi overrides in human-readable room lists', async () => { + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ + members: [ + { + id: 'member_1', + userId: 'user_1', + email: '\u001b[31mmallory@example.com\n\u202e', + name: 'Mallory\nAdmin\u202e', + role: 'participant', + status: 'active', + joinedAt: '2026-07-23T00:00:00.000Z', + }, + ], + }), + auth, + }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'members', + '--workspace', + 'rw_7ccfea89', + ]); + + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain('mallory@example.com��'); + expect(output).not.toContain('\u001b'); + expect(output).not.toContain('\u202e'); + }); + + it('treats server resource IDs as opaque while encoding them into URL paths', async () => { + const { program, deps } = createHarness(); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'revoke-invite', + 'future:id/with+safe?shape', + '--workspace', + 'rw_7ccfea89', + ]); + + expect(deps.authorizedApiFetch).toHaveBeenCalledWith( + auth, + '/api/v1/workspaces/rw_7ccfea89/room/invites/future%3Aid%2Fwith%2Bsafe%3Fshape', + { method: 'DELETE' }, + { interactive: false } + ); + }); + + it.each(['12s', '1.5', '59', '2592001'])( + 'rejects an invalid invitation lifetime %s before authenticating', + async (expiresIn) => { + const { program, deps } = createHarness(); + + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'invite', + '--workspace', + 'rw_7ccfea89', + '--email', + 'person@example.com', + '--expires-in', + expiresIn, + ]) + ).rejects.toThrow(); + + expect(deps.ensureCloudSession).not.toHaveBeenCalled(); + } + ); + + it.each([ + [ + 'invitation', + ['invite', '--workspace', 'rw_7ccfea89', '--email', 'p@example.com', '--token-stdout'], + ], + ['invitation list', ['invites', '--workspace', 'rw_7ccfea89']], + ['member list', ['members', '--workspace', 'rw_7ccfea89']], + ['membership', ['accept', '--token-stdin']], + ['session', ['session', '--workspace', 'rw_7ccfea89', '--device-id', 'herdr-1']], + ])('rejects a malformed successful %s response', async (_label, args) => { + const { program, deps } = createHarness({ + readStdin: vi.fn(async () => 'herdr_inv_single_use_secret'), + }); + + await expect(program.parseAsync(['node', 'agent-relay', 'cloud', 'room', ...args])).rejects.toThrow( + 'exit:1' + ); + + expect(deps.error).toHaveBeenCalledWith(expect.stringContaining('invalid')); + }); + + it('rejects forbidden workspace credentials even in a successful response', async () => { + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ + members: [], + workspaceKey: 'rk_live_must_not_escape', + }), + auth, + }); + + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'members', + '--workspace', + 'rw_7ccfea89', + '--json', + ]) + ).rejects.toThrow('exit:1'); + + const output = [...vi.mocked(deps.log).mock.calls, ...vi.mocked(deps.error).mock.calls].flat().join('\n'); + expect(output).not.toContain('rk_live_must_not_escape'); + expect(deps.error).toHaveBeenCalledWith( + 'Cloud room returned a forbidden workspace or integration credential.' + ); + }); + + it.each([ + { + role: 'viewer', + relaycastBaseUrl: 'https://relay.test', + agentToken: 'at_wrong_role', + }, + { + role: 'participant', + relaycastBaseUrl: 'https://relay.test', + agentName: 'human-device-1', + observerToken: 'ot_wrong_role', + }, + { + role: 'participant', + relaycastBaseUrl: 'https://relay.test', + agentToken: 'at_missing_name', + }, + ])('rejects a mismatched session credential matrix', async (response) => { + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse(response), + auth, + }); + + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'session', + '--workspace', + 'rw_7ccfea89', + '--device-id', + 'herdr-1', + ]) + ).rejects.toThrow('exit:1'); + + expect(deps.error).toHaveBeenCalledWith( + expect.stringMatching(/invalid (viewer|participant) session response/) + ); + }); +}); diff --git a/packages/cli/src/cli/commands/cloud-room.ts b/packages/cli/src/cli/commands/cloud-room.ts new file mode 100644 index 000000000..e78bdb170 --- /dev/null +++ b/packages/cli/src/cli/commands/cloud-room.ts @@ -0,0 +1,814 @@ +import fs from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import process from 'node:process'; +import { Command, InvalidArgumentError } from 'commander'; + +import { defaultApiUrl } from '@agent-relay/cloud'; +import { stripAnsiFast } from '@agent-relay/utils'; + +import type { CloudDependencies } from './cloud.js'; + +type CloudRoomDependencies = Pick< + CloudDependencies, + 'log' | 'error' | 'exit' | 'ensureCloudSession' | 'authorizedApiFetch' +>; + +type RoomRole = 'viewer' | 'participant'; + +const CLOUD_WORKSPACE_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const UNIFIED_WORKSPACE_ID_PATTERN = /^rw_[a-z0-9]{8}$/; +const ROOM_RESOURCE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const DEFAULT_INVITATION_LIFETIME_SECONDS = 7 * 24 * 60 * 60; +const MIN_INVITATION_LIFETIME_SECONDS = 60; +const MAX_INVITATION_LIFETIME_SECONDS = 30 * 24 * 60 * 60; +const MAX_ROOM_SECRET_LENGTH = 2_048; + +interface CloudRoomIo { + readStdin: () => Promise; + readSecretFile: (filePath: string) => Promise; + writeSecretFile: (filePath: string, value: string) => Promise; +} + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function requireObject(value: unknown, label: string): Record { + if (!isObject(value)) { + throw new Error(`Cloud room returned an invalid ${label} response.`); + } + return value; +} + +function requireStringField(record: Record, key: string, label: string): string { + const value = record[key]; + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`Cloud room returned an invalid ${label} response.`); + } + return value.trim(); +} + +function requireNullableStringField( + record: Record, + key: string, + label: string +): string | null { + const value = record[key]; + if (value === null) return null; + if (typeof value !== 'string') { + throw new Error(`Cloud room returned an invalid ${label} response.`); + } + return value; +} + +function requireIsoDateField(record: Record, key: string, label: string): string { + const value = requireStringField(record, key, label); + if (!Number.isFinite(Date.parse(value))) { + throw new Error(`Cloud room returned an invalid ${label} response.`); + } + return value; +} + +function requireResponseRole(record: Record, label: string): RoomRole { + const role = record.role; + if (role !== 'viewer' && role !== 'participant') { + throw new Error(`Cloud room returned an invalid ${label} response.`); + } + return role; +} + +function containsForbiddenCredentialField(value: unknown): boolean { + if (Array.isArray(value)) return value.some(containsForbiddenCredentialField); + if (!isObject(value)) return false; + return Object.entries(value).some( + ([key, nested]) => + /^(?:workspaceKey|relaycastApiKey|relayfileToken|relayfileCredentials|accessToken|refreshToken|authorization|apiKey)$/i.test( + key + ) || containsForbiddenCredentialField(nested) + ); +} + +type RoomInvite = { + id: string; + email: string; + role: RoomRole; + expiresAt: string; + createdAt: string; + token?: string; +}; + +function normalizeInvite(value: unknown, requireToken: boolean): RoomInvite { + const invite = requireObject(value, 'invitation'); + const normalized: RoomInvite = { + id: requireStringField(invite, 'id', 'invitation'), + email: requireStringField(invite, 'email', 'invitation'), + role: requireResponseRole(invite, 'invitation'), + expiresAt: requireIsoDateField(invite, 'expiresAt', 'invitation'), + createdAt: requireIsoDateField(invite, 'createdAt', 'invitation'), + }; + if (requireToken) { + normalized.token = requireInvitationToken(requireStringField(invite, 'token', 'invitation')); + } + return normalized; +} + +function normalizeInviteCreate(payload: unknown): { invite: RoomInvite & { token: string } } { + const response = requireObject(payload, 'invitation'); + return { + invite: normalizeInvite(response.invite, true) as RoomInvite & { token: string }, + }; +} + +function normalizeInviteList(payload: unknown): { invites: RoomInvite[] } { + const response = requireObject(payload, 'invitation list'); + if (!Array.isArray(response.invites)) { + throw new Error('Cloud room returned an invalid invitation list response.'); + } + return { invites: response.invites.map((invite) => normalizeInvite(invite, false)) }; +} + +type RoomMember = { + id: string; + userId: string; + email: string | null; + name: string | null; + role: RoomRole; + status: 'active' | 'revoking'; + joinedAt: string; +}; + +function normalizeMemberList(payload: unknown): { members: RoomMember[] } { + const response = requireObject(payload, 'member list'); + if (!Array.isArray(response.members)) { + throw new Error('Cloud room returned an invalid member list response.'); + } + return { + members: response.members.map((value) => { + const member = requireObject(value, 'member list'); + const status = requireStringField(member, 'status', 'member list'); + if (status !== 'active' && status !== 'revoking') { + throw new Error('Cloud room returned an invalid member list response.'); + } + return { + id: requireStringField(member, 'id', 'member list'), + userId: requireStringField(member, 'userId', 'member list'), + email: requireNullableStringField(member, 'email', 'member list'), + name: requireNullableStringField(member, 'name', 'member list'), + role: requireResponseRole(member, 'member list'), + status, + joinedAt: requireIsoDateField(member, 'joinedAt', 'member list'), + }; + }), + }; +} + +function normalizeMembership(payload: unknown): { + membership: { id: string; workspaceId: string; role: RoomRole }; +} { + const response = requireObject(payload, 'membership'); + const membership = requireObject(response.membership, 'membership'); + return { + membership: { + id: requireStringField(membership, 'id', 'membership'), + workspaceId: requireStringField(membership, 'workspaceId', 'membership'), + role: requireResponseRole(membership, 'membership'), + }, + }; +} + +type RoomSession = + | { role: 'viewer'; relaycastBaseUrl: string; observerToken: string } + | { + role: 'participant'; + relaycastBaseUrl: string; + agentName: string; + agentToken: string; + }; + +function normalizeRoomSession(payload: unknown): RoomSession { + const response = requireObject(payload, 'session'); + const role = requireResponseRole(response, 'session'); + const relaycastBaseUrl = requireRelaycastBaseUrl( + requireStringField(response, 'relaycastBaseUrl', 'session') + ); + if (role === 'viewer') { + if ( + typeof response.observerToken !== 'string' || + !response.observerToken.trim().startsWith('ot_live_') || + response.agentToken !== undefined + ) { + throw new Error('Cloud room returned an invalid viewer session response.'); + } + return { role, relaycastBaseUrl, observerToken: response.observerToken.trim() }; + } + if ( + typeof response.agentToken !== 'string' || + !response.agentToken.trim().startsWith('at_live_') || + response.observerToken !== undefined + ) { + throw new Error('Cloud room returned an invalid participant session response.'); + } + return { + role, + relaycastBaseUrl, + agentName: requireStringField(response, 'agentName', 'participant session'), + agentToken: response.agentToken.trim(), + }; +} + +function parsePositiveInteger(value: string): number { + if (!/^[1-9][0-9]*$/.test(value)) { + throw new InvalidArgumentError('Expected a positive whole number.'); + } + const parsed = Number(value); + if ( + !Number.isSafeInteger(parsed) || + parsed < MIN_INVITATION_LIFETIME_SECONDS || + parsed > MAX_INVITATION_LIFETIME_SECONDS + ) { + throw new InvalidArgumentError( + `Expected between ${MIN_INVITATION_LIFETIME_SECONDS} and ${MAX_INVITATION_LIFETIME_SECONDS} seconds.` + ); + } + return parsed; +} + +function parseRoomRole(value: string): RoomRole { + if (value === 'viewer' || value === 'participant') { + return value; + } + throw new InvalidArgumentError('Expected role to be one of: viewer, participant'); +} + +function requireWorkspaceId(value: string): string { + const workspaceId = value.trim(); + if (!CLOUD_WORKSPACE_UUID_PATTERN.test(workspaceId) && !UNIFIED_WORKSPACE_ID_PATTERN.test(workspaceId)) { + throw new Error( + 'Unsupported Cloud workspace identifier. Use a Cloud workspace UUID or unified rw_ workspace ID.' + ); + } + return workspaceId; +} + +function requireResourceId(value: string, label: string): string { + const resourceId = value.trim(); + // Cloud owns these opaque identifiers. Only reject values that cannot be + // safely carried in a URL path or terminal; encodeURIComponent handles the + // remaining printable characters without coupling the CLI to an ID format. + if ( + !resourceId || + resourceId.length > 512 || + // eslint-disable-next-line no-control-regex + /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(resourceId) + ) { + throw new Error(`Invalid ${label}.`); + } + return resourceId; +} + +function requireEmail(value: string): string { + const email = value.trim().toLowerCase(); + if (email.length > 320 || /[\r\n]/.test(email) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new Error('A valid email address is required.'); + } + return email; +} + +function requireDeviceId(value: string): string { + const deviceId = value.trim(); + if (!ROOM_RESOURCE_ID_PATTERN.test(deviceId)) { + throw new Error('Invalid device ID. Use 1-128 letters, numbers, underscores, or hyphens.'); + } + return deviceId; +} + +/** Keep Cloud/user-provided text from controlling or escaping the terminal. */ +function sanitizeTerminalCell(value: string): string { + // eslint-disable-next-line no-control-regex + return stripAnsiFast(value).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '�'); +} + +async function defaultReadStdin(): Promise { + let input = ''; + for await (const chunk of process.stdin) { + input += String(chunk); + if (input.length > MAX_ROOM_SECRET_LENGTH + 1) { + throw new Error('Invalid room invitation token.'); + } + } + return input; +} + +async function defaultReadSecretFile(filePath: string): Promise { + const noFollow = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW; + const handle = await fs.open(filePath, fsConstants.O_RDONLY | noFollow); + try { + const metadata = await handle.stat(); + if (!metadata.isFile()) { + throw new Error('Room invitation token file must be a regular file.'); + } + if (process.platform !== 'win32' && (metadata.mode & 0o077) !== 0) { + throw new Error('Room invitation token file must have owner-only permissions (0600).'); + } + if (metadata.size > MAX_ROOM_SECRET_LENGTH + 1) { + throw new Error('Invalid room invitation token.'); + } + return handle.readFile('utf8'); + } finally { + await handle.close(); + } +} + +async function defaultWriteSecretFile(filePath: string, value: string): Promise { + const noFollow = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW; + const handle = await fs.open( + filePath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, + 0o600 + ); + try { + await handle.writeFile(`${value}\n`, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } +} + +function requireInvitationToken(value: string): string { + const token = value.trim(); + if ( + !token.startsWith('herdr_inv_') || + token.length > MAX_ROOM_SECRET_LENGTH || + // eslint-disable-next-line no-control-regex + /[\u0000-\u001f\u007f-\u009f]/.test(token) + ) { + throw new Error('Invalid room invitation token.'); + } + return token; +} + +function requireRelaycastBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('Cloud room returned an invalid session response.'); + } + const loopback = + url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'; + if ( + (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error('Cloud room returned an invalid session response.'); + } + return url.toString().replace(/\/+$/, ''); +} + +function canonicalApiBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('Invalid Cloud API URL.'); + } + const loopback = + url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'; + if ( + (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error('Invalid Cloud API URL.'); + } + return url.toString().replace(/\/+$/, ''); +} + +function cloudRoomError(response: Response): Error { + if (response.status === 401) { + return new Error('Cloud login required. Run `agent-relay cloud login` and retry.'); + } + if (response.status === 403) { + return new Error('You do not have permission to perform that room operation.'); + } + if (response.status === 404) { + return new Error('The room resource was not found or is no longer available.'); + } + if (response.status === 409) { + return new Error('The room operation conflicts with the current membership state.'); + } + if (response.status === 410) { + return new Error('The room invitation has expired or was already used.'); + } + if (response.status === 429) { + const retryAfter = response.headers.get('retry-after')?.trim(); + return new Error( + `Cloud room rate limit exceeded.${ + retryAfter ? ` Retry-After: ${retryAfter} seconds.` : ' Wait and retry.' + }` + ); + } + if (response.status >= 400 && response.status < 500) { + return new Error(`Cloud rejected the room request (${response.status}).`); + } + return new Error(`Cloud room request failed (${response.status}).`); +} + +async function requestRoom( + deps: CloudRoomDependencies, + path: string, + init: RequestInit, + apiUrl?: string +): Promise { + const requestedApiUrl = apiUrl ?? defaultApiUrl(); + const session = await deps.ensureCloudSession({ + apiUrl: requestedApiUrl, + interactive: false, + }); + if ( + apiUrl && + canonicalApiBaseUrl(session.auth.apiUrl) !== canonicalApiBaseUrl(requestedApiUrl) + ) { + throw new Error( + `Cloud login is bound to ${canonicalApiBaseUrl( + session.auth.apiUrl + )}. Run \`agent-relay cloud login --api-url ${canonicalApiBaseUrl( + requestedApiUrl + )} --force\` before using this host.` + ); + } + const { response } = await deps.authorizedApiFetch(session.auth, path, init, { + interactive: false, + }); + const payload = (await response.json().catch(() => null)) as unknown; + if (!response.ok) { + throw cloudRoomError(response); + } + if (containsForbiddenCredentialField(payload)) { + throw new Error('Cloud room returned a forbidden workspace or integration credential.'); + } + return payload; +} + +async function runRoomAction(deps: CloudRoomDependencies, action: () => Promise): Promise { + try { + await action(); + } catch (error) { + deps.error(error instanceof Error ? error.message : String(error)); + deps.exit(1); + } +} + +function logJson(deps: CloudRoomDependencies, payload: unknown): void { + deps.log(JSON.stringify(payload, null, 2)); +} + +function textField(record: object, key: string): string | undefined { + const value = (record as Record)[key]; + return typeof value === 'string' && value.trim() ? sanitizeTerminalCell(value.trim()) : undefined; +} + +function renderInvites(payload: { invites: RoomInvite[] }, deps: CloudRoomDependencies): void { + const { invites } = payload; + if (invites.length === 0) { + deps.log('No active room invitations.'); + return; + } + for (const invite of invites) { + const id = textField(invite, 'id') ?? 'unknown'; + const email = textField(invite, 'email') ?? 'unknown'; + const role = textField(invite, 'role') ?? 'unknown'; + const expiresAt = textField(invite, 'expiresAt'); + deps.log([id, email, role, expiresAt ? `expires ${expiresAt}` : undefined].filter(Boolean).join(' ')); + } +} + +function renderMembers(payload: { members: RoomMember[] }, deps: CloudRoomDependencies): void { + const { members } = payload; + if (members.length === 0) { + deps.log('No room members.'); + return; + } + for (const member of members) { + const id = textField(member, 'id') ?? 'unknown'; + const email = textField(member, 'email') ?? textField(member, 'name') ?? 'unknown'; + const role = textField(member, 'role') ?? 'unknown'; + const status = textField(member, 'status'); + deps.log([id, email, role, status].filter(Boolean).join(' ')); + } +} + +export function registerCloudRoomCommands( + cloudCommand: Command, + deps: CloudRoomDependencies, + ioOverrides: Partial = {} +): void { + const io: CloudRoomIo = { + readStdin: defaultReadStdin, + readSecretFile: defaultReadSecretFile, + writeSecretFile: defaultWriteSecretFile, + ...ioOverrides, + }; + const room = cloudCommand.command('room').description('Manage workspace-scoped multiplayer rooms'); + + room + .command('invite') + .description('Invite an email address to a workspace room') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .requiredOption('--email ', 'Email address bound to the invitation') + .option('--api-url ', 'Cloud API base URL') + .option('--role ', 'Room role: viewer or participant', parseRoomRole, 'participant') + .option( + '--expires-in ', + 'Invitation lifetime in seconds', + parsePositiveInteger, + DEFAULT_INVITATION_LIFETIME_SECONDS + ) + .option('--token-stdout', 'Print only the one-time invitation token') + .option('--token-file ', 'Write the token to a new owner-only 0600 file') + .option('--json', 'Output the full invitation, including its one-time token, as JSON') + .action( + async (options: { + workspace: string; + email: string; + role: RoomRole; + expiresIn: number; + apiUrl?: string; + tokenStdout?: boolean; + tokenFile?: string; + json?: boolean; + }) => { + await runRoomAction(deps, async () => { + const outputCount = [ + options.tokenStdout, + Boolean(options.tokenFile), + options.json, + ].filter(Boolean).length; + if (outputCount !== 1) { + throw new Error( + 'Use exactly one invitation-token sink: --token-stdout, --token-file, or --json.' + ); + } + const workspaceId = requireWorkspaceId(options.workspace); + const email = requireEmail(options.email); + const payload = normalizeInviteCreate( + await requestRoom( + deps, + `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/room/invites`, + { + method: 'POST', + body: JSON.stringify({ + email, + role: options.role, + expiresInSeconds: options.expiresIn, + }), + }, + options.apiUrl + ) + ); + if (options.json) { + logJson(deps, payload); + return; + } + if (options.tokenStdout) { + deps.log(payload.invite.token); + return; + } + try { + await io.writeSecretFile(options.tokenFile ?? '', payload.invite.token); + } catch (writeError) { + try { + await requestRoom( + deps, + `/api/v1/workspaces/${encodeURIComponent( + workspaceId + )}/room/invites/${encodeURIComponent(payload.invite.id)}`, + { method: 'DELETE' }, + options.apiUrl + ); + } catch { + throw new Error( + `Could not write the invitation token. Revocation could not be confirmed; revoke invitation ${sanitizeTerminalCell( + payload.invite.id + )} before retrying.` + ); + } + throw writeError; + } + deps.log(`Created ${options.role} room invitation for ${email}.`); + deps.log(`Wrote the one-time invitation token to ${sanitizeTerminalCell(options.tokenFile ?? '')}.`); + }); + } + ); + + room + .command('invites') + .description('List workspace room invitations') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output invitations as JSON') + .action(async (options: { workspace: string; apiUrl?: string; json?: boolean }) => { + await runRoomAction(deps, async () => { + const workspaceId = requireWorkspaceId(options.workspace); + const payload = normalizeInviteList( + await requestRoom( + deps, + `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/room/invites`, + { method: 'GET' }, + options.apiUrl + ) + ); + if (options.json) { + logJson(deps, payload); + return; + } + renderInvites(payload, deps); + }); + }); + + room + .command('revoke-invite') + .description('Revoke an unused workspace room invitation') + .argument('', 'Invitation ID') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output the revocation response as JSON') + .action( + async (inviteIdInput: string, options: { workspace: string; apiUrl?: string; json?: boolean }) => { + await runRoomAction(deps, async () => { + const workspaceId = requireWorkspaceId(options.workspace); + const inviteId = requireResourceId(inviteIdInput, 'invitation ID'); + await requestRoom( + deps, + `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/room/invites/${encodeURIComponent(inviteId)}`, + { method: 'DELETE' }, + options.apiUrl + ); + if (options.json) { + logJson(deps, { ok: true }); + return; + } + deps.log(`Revoked room invitation ${inviteId}.`); + }); + } + ); + + room + .command('members') + .description('List workspace room members') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output members as JSON') + .action(async (options: { workspace: string; apiUrl?: string; json?: boolean }) => { + await runRoomAction(deps, async () => { + const workspaceId = requireWorkspaceId(options.workspace); + const payload = normalizeMemberList( + await requestRoom( + deps, + `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/room/members`, + { method: 'GET' }, + options.apiUrl + ) + ); + if (options.json) { + logJson(deps, payload); + return; + } + renderMembers(payload, deps); + }); + }); + + room + .command('remove-member') + .description('Remove a member and revoke their live room access') + .argument('', 'Workspace membership ID') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output the removal response as JSON') + .action( + async (memberIdInput: string, options: { workspace: string; apiUrl?: string; json?: boolean }) => { + await runRoomAction(deps, async () => { + const workspaceId = requireWorkspaceId(options.workspace); + const memberId = requireResourceId(memberIdInput, 'membership ID'); + await requestRoom( + deps, + `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/room/members/${encodeURIComponent(memberId)}`, + { method: 'DELETE' }, + options.apiUrl + ); + if (options.json) { + logJson(deps, { ok: true }); + return; + } + deps.log(`Removed room member ${memberId} and revoked their room sessions.`); + }); + } + ); + + room + .command('accept') + .description('Accept an email-bound room invitation') + .option('--token-stdin', 'Read the single-use invitation token from stdin') + .option('--token-file ', 'Read the invitation token from an owner-only 0600 file') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output the accepted membership as JSON') + .action( + async (options: { tokenStdin?: boolean; tokenFile?: string; apiUrl?: string; json?: boolean }) => { + await runRoomAction(deps, async () => { + if (Boolean(options.tokenStdin) === Boolean(options.tokenFile)) { + throw new Error('Use exactly one of --token-stdin or --token-file.'); + } + const token = requireInvitationToken( + options.tokenStdin ? await io.readStdin() : await io.readSecretFile(options.tokenFile ?? '') + ); + const payload = normalizeMembership( + await requestRoom( + deps, + '/api/v1/room/invites/accept', + { + method: 'POST', + body: JSON.stringify({ token }), + }, + options.apiUrl + ) + ); + if (options.json) { + logJson(deps, payload); + return; + } + deps.log('Room invitation accepted.'); + }); + } + ); + + room + .command('revoke-session') + .description('Revoke this member’s scoped session for one device') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .requiredOption('--device-id ', 'Stable non-secret identifier for this client') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output the revocation response as JSON') + .action( + async (options: { + workspace: string; + deviceId: string; + apiUrl?: string; + json?: boolean; + }) => { + await runRoomAction(deps, async () => { + const workspaceId = requireWorkspaceId(options.workspace); + const deviceId = requireDeviceId(options.deviceId); + await requestRoom( + deps, + `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/room/session`, + { + method: 'DELETE', + body: JSON.stringify({ deviceId }), + }, + options.apiUrl + ); + if (options.json) { + logJson(deps, { ok: true }); + return; + } + deps.log(`Revoked room session for device ${sanitizeTerminalCell(deviceId)}.`); + }); + } + ); + + room + .command('session') + .description('Create or resume this device’s scoped room session') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .requiredOption('--device-id ', 'Stable non-secret identifier for this client') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output the session, including its scoped participant or observer credential') + .action(async (options: { workspace: string; deviceId: string; apiUrl?: string; json?: boolean }) => { + await runRoomAction(deps, async () => { + const workspaceId = requireWorkspaceId(options.workspace); + const deviceId = requireDeviceId(options.deviceId); + const payload = normalizeRoomSession( + await requestRoom( + deps, + `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/room/session`, + { + method: 'POST', + body: JSON.stringify({ deviceId }), + }, + options.apiUrl + ) + ); + if (options.json) { + logJson(deps, payload); + return; + } + deps.log(`Room session ready with role ${payload.role}.`); + deps.log('Scoped credentials are hidden. Trusted clients may request them explicitly with --json.'); + }); + }); +} diff --git a/packages/cli/src/cli/commands/cloud.test.ts b/packages/cli/src/cli/commands/cloud.test.ts index b12c45e35..72b71bae5 100644 --- a/packages/cli/src/cli/commands/cloud.test.ts +++ b/packages/cli/src/cli/commands/cloud.test.ts @@ -115,6 +115,7 @@ describe('registerCloudCommands', () => { expect(cloud).toBeDefined(); expect(cloud?.commands.map((command) => command.name())).toEqual([ 'worker', + 'room', 'login', 'logout', 'session', diff --git a/packages/cli/src/cli/commands/cloud.ts b/packages/cli/src/cli/commands/cloud.ts index 191f64552..75decba8f 100644 --- a/packages/cli/src/cli/commands/cloud.ts +++ b/packages/cli/src/cli/commands/cloud.ts @@ -32,6 +32,7 @@ import { import { defaultExit } from '../lib/exit.js'; import { errorClassName } from '../lib/telemetry-helpers.js'; import { track } from '../telemetry/index.js'; +import { registerCloudRoomCommands } from './cloud-room.js'; import { registerCloudWorkerCommands } from './cloud-worker.js'; const CLOUD_SYNC_PATCH_EXCLUDES = [ @@ -416,6 +417,7 @@ export function registerCloudCommands(program: Command, overrides: Partial null)) as - | (WhoAmIResponse & { error?: string }) - | null; + (WhoAmIResponse & { error?: string }) | null; if (!response.ok || !payload?.authenticated) { throw new Error(payload?.error || 'Failed to resolve auth status'); diff --git a/packages/cli/src/cli/lib/sdk-client.test.ts b/packages/cli/src/cli/lib/sdk-client.test.ts index 439bcd301..b57ee0dd9 100644 --- a/packages/cli/src/cli/lib/sdk-client.test.ts +++ b/packages/cli/src/cli/lib/sdk-client.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + createAgentRelay, resolveAgentToken, resolveBaseUrl, resolveWorkspaceKey, @@ -105,4 +106,18 @@ describe('sdk client option resolution', () => { expect(resolveAgentToken({ token: ' at_123 ' })).toBe('at_123'); expect(resolveAgentToken({ token: ' ', env: { RELAY_AGENT_TOKEN: ' at_env ' } })).toBe('at_env'); }); + + it('uses an agent token as the transport credential instead of an ambient owner workspace key', () => { + setWorkspaceKey('ops', 'rk_live_owner_secret'); + writeProjectWorkspaceKey(projectDataDir(), 'rk_live_project_owner_secret'); + + const relay = createAgentRelay({ + env: { + AGENT_RELAY_HOME: dir, + RELAY_AGENT_TOKEN: 'at_live_participant_scoped', + }, + }) as { workspaceKey?: string }; + + expect(relay.workspaceKey).toBe('at_live_participant_scoped'); + }); }); diff --git a/packages/cli/src/cli/lib/sdk-client.ts b/packages/cli/src/cli/lib/sdk-client.ts index 3082928d5..8779f5b0b 100644 --- a/packages/cli/src/cli/lib/sdk-client.ts +++ b/packages/cli/src/cli/lib/sdk-client.ts @@ -69,8 +69,13 @@ export function createWorkspaceRelay(options: SdkClientOptions = {}): AgentRelay */ export function createAgentRelay(options: SdkClientOptions = {}): AgentRelayAgent { const token = resolveAgentToken(options); + // Agent tokens are valid Relaycast transport credentials and already bind + // the caller to exactly one workspace. Prefer the scoped token itself over + // every ambient workspace-key source so invited humans cannot accidentally + // inherit the local owner's rk_live credential from this project or machine. + const transportCredential = token ?? resolveWorkspaceKey(options); return new AgentRelay({ - workspaceKey: resolveWorkspaceKey(options), + workspaceKey: transportCredential, baseUrl: resolveBaseUrl(options), ...(token ? { agentToken: token } : {}), }); From 11df645277752b65b250a13028de77b69ec50960 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 23 Jul 2026 23:44:36 +0200 Subject: [PATCH 02/21] feat(cli): add cloud integration access --- CHANGELOG.md | 1 + packages/cli/README.md | 38 +- packages/cli/src/cli/bootstrap.test.ts | 19 + .../cli/commands/cloud-integration.test.ts | 408 ++++++++++ .../cli/src/cli/commands/cloud-integration.ts | 725 ++++++++++++++++++ .../cli/src/cli/commands/cloud-room.test.ts | 106 ++- packages/cli/src/cli/commands/cloud-room.ts | 134 ++-- packages/cli/src/cli/commands/cloud.test.ts | 1 + packages/cli/src/cli/commands/cloud.ts | 2 + 9 files changed, 1347 insertions(+), 87 deletions(-) create mode 100644 packages/cli/src/cli/commands/cloud-integration.test.ts create mode 100644 packages/cli/src/cli/commands/cloud-integration.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ef2a4d40b..21740e724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `agent-relay cloud room` can invite workspace-scoped participants through explicit secret sinks, manage members, and establish per-device multiplayer sessions without sharing a Relay workspace key. +- `agent-relay cloud integration` can discover truthful provider capabilities, connect providers, manage room-member path grants, and mint or revoke device-scoped Relayfile credential leases. - `agent-relay agent me|presence` use scoped agent credentials for room-safe identity and presence checks. ## [11.1.1] - 2026-07-23 diff --git a/packages/cli/README.md b/packages/cli/README.md index eac0d0d4f..b4364c359 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -96,12 +96,16 @@ agent-relay cloud room invite \ --workspace rw_7ccfea89 \ --email teammate@example.com \ --role participant \ - --token-file ./teammate.room-invite + --email-delivery agent-relay cloud room invites --workspace rw_7ccfea89 agent-relay cloud room members --workspace rw_7ccfea89 -# Share the owner-only token file over a secure channel. The invitee keeps the -# single-use token out of shell history and process arguments. +# Manual fallback: create an owner-only token file and share it over a secure +# channel. The invitee keeps the token out of shell history and process arguments. +agent-relay cloud room invite \ + --workspace rw_7ccfea89 \ + --email teammate@example.com \ + --token-file ./teammate.room-invite read -rs ROOM_INVITATION_TOKEN printf '%s' "$ROOM_INVITATION_TOKEN" | agent-relay cloud room accept --token-stdin @@ -130,6 +134,34 @@ agent-relay agent presence \ agent-relay cloud room remove-member --workspace rw_7ccfea89 ``` +Room membership grants chat only. Integration access is separately connected, +granted to one room member, and issued as a short-lived, revocable lease: + +```bash +# Owner: inspect capability truth, connect a provider, then grant exact paths. +agent-relay cloud integration catalog --workspace rw_7ccfea89 +agent-relay cloud integration connect linear --workspace rw_7ccfea89 +agent-relay cloud integration grant \ + --workspace rw_7ccfea89 \ + --member \ + --provider linear \ + --path '/linear/issues/**' \ + --access write + +# Member or Herdr: capture the delegated bundle explicitly. Caller identity is +# derived from Cloud auth; it is never accepted from command flags. +agent-relay cloud integration credential \ + --workspace rw_7ccfea89 \ + --device-id herdr-room-device \ + --access write \ + --output-file ./relayfile-credential.json + +# Herdr uses the JSON form only for in-process capture, keeps the lease ID (not +# its token) as durable cleanup state, and revokes it on close or session reset. +agent-relay cloud integration revoke-credential \ + --workspace rw_7ccfea89 +``` + `local` remains as a deprecated hidden alias of `node` (it prints a one-time warning). Node workflow runs use Relayflows for YAML, TypeScript, and Python workflow files. diff --git a/packages/cli/src/cli/bootstrap.test.ts b/packages/cli/src/cli/bootstrap.test.ts index a3289c46a..2523b5764 100644 --- a/packages/cli/src/cli/bootstrap.test.ts +++ b/packages/cli/src/cli/bootstrap.test.ts @@ -62,6 +62,23 @@ const expectedLeafCommands = [ 'cloud worker start', 'cloud worker status', 'cloud worker logs', + 'cloud room invite', + 'cloud room invites', + 'cloud room revoke-invite', + 'cloud room members', + 'cloud room remove-member', + 'cloud room session', + 'cloud room revoke-session', + 'cloud room accept', + 'cloud integration catalog', + 'cloud integration connections', + 'cloud integration connect', + 'cloud integration disconnect', + 'cloud integration grants', + 'cloud integration grant', + 'cloud integration revoke-grant', + 'cloud integration credential', + 'cloud integration revoke-credential', // workspace 'workspace create', 'workspace active', @@ -74,6 +91,8 @@ const expectedLeafCommands = [ 'agent list', 'agent add', 'agent remove', + 'agent me', + 'agent presence', // channel 'channel create', 'channel list', diff --git a/packages/cli/src/cli/commands/cloud-integration.test.ts b/packages/cli/src/cli/commands/cloud-integration.test.ts new file mode 100644 index 000000000..c0a194aaf --- /dev/null +++ b/packages/cli/src/cli/commands/cloud-integration.test.ts @@ -0,0 +1,408 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Command } from 'commander'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerCloudIntegrationCommands } from './cloud-integration.js'; +import type { CloudDependencies } from './cloud.js'; + +vi.mock('@agent-relay/cloud', () => ({ + defaultApiUrl: () => 'https://cloud.test', +})); + +type Deps = Pick; + +const auth = { + apiUrl: 'https://cloud.test', + accessToken: 'access-secret', + refreshToken: 'refresh-secret', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', +}; + +function response(value: unknown, status = 200): Response { + return new Response(value === null ? null : JSON.stringify(value), { + status, + headers: value === null ? undefined : { 'content-type': 'application/json' }, + }); +} + +function harness() { + const exit = vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }) as unknown as Deps['exit']; + const deps: Deps = { + log: vi.fn(), + error: vi.fn(), + exit, + ensureCloudSession: vi.fn(async () => ({ auth, client: {} as never })) as Deps['ensureCloudSession'], + authorizedApiFetch: vi.fn(async () => ({ + response: response({}), + auth, + })) as Deps['authorizedApiFetch'], + }; + const program = new Command(); + program.exitOverride(); + const cloud = program.command('cloud'); + registerCloudIntegrationCommands(cloud, deps); + return { program, deps, integration: cloud.commands[0] }; +} + +function credential() { + return { + leaseId: 'lease_1', + relayfileUrl: 'https://relayfile.test', + relayauthUrl: 'https://relayauth.test', + refreshUrl: 'https://relayauth.test/v1/tokens/refresh', + relayfileWorkspaceId: 'rw_7ccfea89', + relayfileToken: 'relay_pa_private', + relayfileTokenExpiresAt: '2026-07-23T22:00:00.000Z', + relayfileRefreshToken: 'relay_pr_private', + relayfileRefreshTokenExpiresAt: '2026-07-24T21:00:00.000Z', + relayfileScopes: ['relayfile:fs:read:*', 'relayfile:fs:write:*'], + delegationNotAfter: '2026-07-24T21:00:00.000Z', + relayfileMountPaths: ['/linear/**'], + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('registerCloudIntegrationCommands', () => { + it('registers the complete Cloud integration lifecycle', () => { + const { integration } = harness(); + expect(integration.commands.map((command) => command.name())).toEqual([ + 'catalog', + 'connections', + 'connect', + 'disconnect', + 'grants', + 'grant', + 'revoke-grant', + 'credential', + 'revoke-credential', + ]); + }); + + it('discovers dynamic providers with truthful capabilities', async () => { + const { program, deps } = harness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: response({ + providers: [ + { + id: 'dropbox', + vfsRoot: '/dropbox', + capabilities: { connect: true, read: true, writeback: false }, + }, + ], + version: 'abcdef123456', + }), + auth, + }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'integration', + 'catalog', + '--workspace', + 'rw_7ccfea89', + '--json', + ]); + + expect(deps.authorizedApiFetch).toHaveBeenCalledWith( + auth, + '/api/v1/integrations/catalog?dynamic=true', + { method: 'GET' }, + { interactive: false } + ); + expect(vi.mocked(deps.log).mock.calls.flat().join('\n')).toContain('"writeback": false'); + }); + + it('creates a bounded write grant for a room member', async () => { + const { program, deps } = harness(); + const grant = { + id: 'grant_1', + workspaceId: '00000000-0000-4000-8000-000000000020', + memberId: 'member_1', + userId: '00000000-0000-4000-8000-000000000001', + provider: 'linear', + allowedPaths: ['/linear/issues/**'], + canRead: true, + canWrite: true, + createdAt: '2026-07-23T21:00:00.000Z', + updatedAt: '2026-07-23T21:00:00.000Z', + }; + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: response({ grant }, 201), + auth, + }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'integration', + 'grant', + '--workspace', + 'rw_7ccfea89', + '--member', + 'member_1', + '--provider', + 'linear', + '--path', + '/linear/issues/**', + '--access', + 'write', + '--json', + ]); + + expect(deps.authorizedApiFetch).toHaveBeenCalledWith( + auth, + '/api/v1/workspaces/rw_7ccfea89/room/integration-grants', + { + method: 'POST', + body: JSON.stringify({ + memberId: 'member_1', + provider: 'linear', + allowedPaths: ['/linear/issues/**'], + canRead: true, + canWrite: true, + }), + }, + { interactive: false } + ); + }); + + it('requires an explicit delegated-credential sink before authentication', async () => { + const { program, deps } = harness(); + + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'integration', + 'credential', + '--workspace', + 'rw_7ccfea89', + '--device-id', + 'herdr-room-device', + '--access', + 'write', + ]) + ).rejects.toThrow('exit:1'); + + expect(deps.ensureCloudSession).not.toHaveBeenCalled(); + expect(deps.error).toHaveBeenCalledWith('Use exactly one credential sink: --output-file or --json.'); + }); + + it('mints a grant-intersected write credential without caller identity fields', async () => { + const { program, deps } = harness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: response(credential()), + auth, + }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'integration', + 'credential', + '--workspace', + 'rw_7ccfea89', + '--device-id', + 'herdr-room-device', + '--access', + 'write', + '--path', + '/linear/**', + '--json', + ]); + + expect(deps.authorizedApiFetch).toHaveBeenCalledWith( + auth, + '/api/v1/workspaces/rw_7ccfea89/relayfile/delegated-token', + { + method: 'POST', + body: JSON.stringify({ + deviceId: 'herdr-room-device', + scopes: ['fs:read', 'fs:write'], + relayfileMountPaths: ['/linear/**'], + ttlSeconds: 3600, + delegationTtlSeconds: 86400, + }), + }, + { interactive: false } + ); + expect(vi.mocked(deps.log).mock.calls.flat().join('\n')).toContain('relay_pa_private'); + }); + + it('writes a delegated credential only to a new owner-only file', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-integration-credential-')); + const target = path.join(directory, 'credential.json'); + const { program, deps } = harness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: response(credential()), + auth, + }); + try { + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'integration', + 'credential', + '--workspace', + 'rw_7ccfea89', + '--device-id', + 'herdr-room-device', + '--access', + 'read', + '--output-file', + target, + ]); + expect(JSON.parse(fs.readFileSync(target, 'utf8'))).toMatchObject({ + leaseId: 'lease_1', + relayfileToken: 'relay_pa_private', + }); + if (process.platform !== 'win32') { + expect(fs.statSync(target).mode & 0o077).toBe(0); + } + const requestBody = JSON.parse( + String(vi.mocked(deps.authorizedApiFetch).mock.calls[0]?.[2]?.body) + ) as Record; + expect(requestBody).not.toHaveProperty('relayfileMountPaths'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it('revokes a newly minted lease when the output file cannot be created', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-integration-credential-')); + const target = path.join(directory, 'existing'); + fs.writeFileSync(target, 'preserve', { mode: 0o600 }); + const { program, deps } = harness(); + vi.mocked(deps.authorizedApiFetch) + .mockResolvedValueOnce({ response: response(credential()), auth }) + .mockResolvedValueOnce({ response: new Response(null, { status: 204 }), auth }); + try { + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'integration', + 'credential', + '--workspace', + 'rw_7ccfea89', + '--device-id', + 'herdr-room-device', + '--access', + 'write', + '--output-file', + target, + ]) + ).rejects.toThrow('exit:1'); + expect(fs.readFileSync(target, 'utf8')).toBe('preserve'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + expect(deps.authorizedApiFetch).toHaveBeenNthCalledWith( + 2, + auth, + '/api/v1/workspaces/rw_7ccfea89/relayfile/delegated-token/lease_1', + { method: 'DELETE' }, + { interactive: false } + ); + }); + + it('revokes a device-scoped credential lease without putting secrets in argv', async () => { + const { program, deps } = harness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: new Response(null, { status: 204 }), + auth, + }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'integration', + 'revoke-credential', + 'lease_1', + '--workspace', + 'rw_7ccfea89', + '--json', + ]); + + expect(deps.authorizedApiFetch).toHaveBeenCalledWith( + auth, + '/api/v1/workspaces/rw_7ccfea89/relayfile/delegated-token/lease_1', + { method: 'DELETE' }, + { interactive: false } + ); + }); + + it('strips server credential fields from a connection session response', async () => { + const { program, deps } = harness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: response({ + connectLink: 'https://connect.test/session', + workspaceId: 'app_workspace', + relayWorkspaceId: 'rw_7ccfea89', + backend: 'nango', + providers: [ + { + id: 'linear', + displayName: 'Linear', + backendMetadata: { sessionToken: 'nested-must-not-print' }, + }, + ], + token: 'must-not-print', + sessionToken: 'must-not-print', + }), + auth, + }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'integration', + 'connect', + 'linear', + '--workspace', + 'rw_7ccfea89', + '--json', + ]); + + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain('https://connect.test/session'); + expect(output).toContain('"linear"'); + expect(output).not.toContain('must-not-print'); + }); + + it('fails closed before forwarding Cloud auth to a mismatched API host', async () => { + const { program, deps } = harness(); + + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'integration', + 'catalog', + '--workspace', + 'rw_7ccfea89', + '--api-url', + 'http://127.0.0.1:4310', + ]) + ).rejects.toThrow('exit:1'); + + expect(deps.authorizedApiFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/cli/commands/cloud-integration.ts b/packages/cli/src/cli/commands/cloud-integration.ts new file mode 100644 index 000000000..2f6637445 --- /dev/null +++ b/packages/cli/src/cli/commands/cloud-integration.ts @@ -0,0 +1,725 @@ +import fs from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { Command, InvalidArgumentError } from 'commander'; + +import { defaultApiUrl } from '@agent-relay/cloud'; +import { stripAnsiFast } from '@agent-relay/utils'; + +import type { CloudDependencies } from './cloud.js'; + +type Dependencies = Pick< + CloudDependencies, + 'log' | 'error' | 'exit' | 'ensureCloudSession' | 'authorizedApiFetch' +>; + +const WORKSPACE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const RELAY_WORKSPACE = /^rw_[a-z0-9]{8}$/; +const RESOURCE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const PROVIDER_ID = /^[a-z0-9][a-z0-9_-]{0,127}$/; +const MAX_SECRET_BYTES = 512 * 1024; +const DEFAULT_CREDENTIAL_TTL = 3_600; +const DEFAULT_DELEGATION_TTL = 86_400; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function object(value: unknown, label: string): Record { + if (!isObject(value)) throw new Error(`Cloud returned an invalid ${label} response.`); + return value; +} + +function string(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`Cloud returned an invalid ${label} response.`); + } + return value.trim(); +} + +function nullableString(value: unknown, label: string): string | null { + if (value === null) return null; + return string(value, label); +} + +function stringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) { + throw new Error(`Cloud returned an invalid ${label} response.`); + } + return value.map((entry) => string(entry, label)); +} + +function connectionProviderIds(value: unknown): string[] { + if (!Array.isArray(value)) { + throw new Error('Cloud returned an invalid integration connection response.'); + } + return value.map((entry) => { + if (typeof entry === 'string') return string(entry, 'integration connection'); + return string(object(entry, 'integration connection provider').id, 'integration connection'); + }); +} + +function workspaceId(value: string): string { + const normalized = value.trim(); + if (!WORKSPACE_UUID.test(normalized) && !RELAY_WORKSPACE.test(normalized)) { + throw new Error( + 'Unsupported Cloud workspace identifier. Use a Cloud workspace UUID or unified rw_ workspace ID.' + ); + } + return normalized; +} + +function resourceId(value: string, label: string): string { + const normalized = value.trim(); + if (!RESOURCE_ID.test(normalized)) throw new Error(`Invalid ${label}.`); + return normalized; +} + +function providerId(value: string): string { + const normalized = value.trim().toLowerCase(); + if (!PROVIDER_ID.test(normalized)) throw new Error('Invalid integration provider ID.'); + return normalized; +} + +function deviceId(value: string): string { + const normalized = value.trim(); + if ( + !normalized || + normalized.length > 255 || + // eslint-disable-next-line no-control-regex + /[\u0000-\u001f\u007f-\u009f]/.test(normalized) + ) { + throw new InvalidArgumentError( + 'Expected a non-empty device ID of at most 255 characters without control characters.' + ); + } + return normalized; +} + +function canonicalPath(value: string): string { + const normalized = value.trim(); + if ( + !normalized.startsWith('/') || + normalized.length > 2_048 || + normalized.includes('\0') || + normalized.includes('\\') || + normalized.split('/').some((part) => part === '.' || part === '..') + ) { + throw new Error('Integration paths must be canonical absolute Relayfile paths.'); + } + return normalized; +} + +function positiveInt(value: string): number { + if (!/^[1-9][0-9]*$/.test(value)) { + throw new InvalidArgumentError('Expected a positive whole number.'); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) throw new InvalidArgumentError('Expected a safe whole number.'); + return parsed; +} + +function pathList(value: string, previous: string[] = []): string[] { + return [...previous, canonicalPath(value)]; +} + +function access(value: string): 'read' | 'write' { + if (value === 'read' || value === 'write') return value; + throw new InvalidArgumentError('Expected access to be one of: read, write'); +} + +function backend(value: string): 'nango' | 'composio' { + if (value === 'nango' || value === 'composio') return value; + throw new InvalidArgumentError('Expected backend to be one of: nango, composio'); +} + +function canonicalApiUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('Invalid Cloud API URL.'); + } + const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'; + if ( + (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error('Invalid Cloud API URL.'); + } + return url.toString().replace(/\/+$/, ''); +} + +function cloudError(response: Response): Error { + if (response.status === 401) { + return new Error('Cloud login required. Run `agent-relay cloud login` and retry.'); + } + if (response.status === 403) { + return new Error('You do not have permission to perform that integration operation.'); + } + if (response.status === 404) { + return new Error('The integration resource was not found or is no longer available.'); + } + if (response.status === 409) { + return new Error('The integration operation conflicts with its current lifecycle state.'); + } + if (response.status === 429) { + return new Error('Cloud integration rate limit exceeded. Wait and retry.'); + } + return new Error(`Cloud integration request failed (${response.status}).`); +} + +async function request( + deps: Dependencies, + path: string, + init: RequestInit, + apiUrl?: string +): Promise { + const requested = apiUrl ?? defaultApiUrl(); + const session = await deps.ensureCloudSession({ apiUrl: requested, interactive: false }); + if (apiUrl && canonicalApiUrl(session.auth.apiUrl) !== canonicalApiUrl(requested)) { + throw new Error( + `Cloud login is bound to ${canonicalApiUrl( + session.auth.apiUrl + )}. Run \`agent-relay cloud login --api-url ${canonicalApiUrl( + requested + )} --force\` before using this host.` + ); + } + const { response } = await deps.authorizedApiFetch(session.auth, path, init, { + interactive: false, + }); + const payload = (await response.json().catch(() => null)) as unknown; + if (!response.ok) throw cloudError(response); + return payload; +} + +async function action(deps: Dependencies, fn: () => Promise): Promise { + try { + await fn(); + } catch (error) { + deps.error(error instanceof Error ? error.message : String(error)); + deps.exit(1); + } +} + +function terminal(value: string): string { + return ( + stripAnsiFast(value) + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '�') + .trim() + ); +} + +function secretField(key: string): boolean { + return /(?:token|secret|password|authorization|credential|api[_-]?key)/i.test(key); +} + +function sanitize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sanitize); + if (!isObject(value)) return typeof value === 'string' ? terminal(value) : value; + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => !secretField(key)) + .map(([key, nested]) => [key, sanitize(nested)]) + ); +} + +function json(deps: Dependencies, payload: unknown): void { + deps.log(JSON.stringify(payload, null, 2)); +} + +function normalizeCatalog(payload: unknown): { + providers: Array>; + version: string; +} { + const response = object(payload, 'integration catalog'); + if (!Array.isArray(response.providers)) { + throw new Error('Cloud returned an invalid integration catalog response.'); + } + return { + providers: response.providers.map((entry) => { + const provider = object(entry, 'integration provider'); + const capabilities = object(provider.capabilities, 'integration capabilities'); + if ( + typeof capabilities.connect !== 'boolean' || + typeof capabilities.read !== 'boolean' || + typeof capabilities.writeback !== 'boolean' + ) { + throw new Error('Cloud returned invalid integration capabilities.'); + } + return sanitize(provider) as Record; + }), + version: string(response.version, 'integration catalog'), + }; +} + +function normalizeGrant(value: unknown): Record { + const grant = object(value, 'integration grant'); + const normalized = { + id: string(grant.id, 'integration grant'), + workspaceId: string(grant.workspaceId, 'integration grant'), + memberId: string(grant.memberId, 'integration grant'), + userId: string(grant.userId, 'integration grant'), + provider: string(grant.provider, 'integration grant'), + allowedPaths: stringArray(grant.allowedPaths, 'integration grant'), + canRead: grant.canRead, + canWrite: grant.canWrite, + createdAt: string(grant.createdAt, 'integration grant'), + updatedAt: string(grant.updatedAt, 'integration grant'), + }; + if (typeof normalized.canRead !== 'boolean' || typeof normalized.canWrite !== 'boolean') { + throw new Error('Cloud returned an invalid integration grant response.'); + } + return normalized; +} + +function normalizeGrants(payload: unknown): { grants: Array> } { + const response = object(payload, 'integration grants'); + if (!Array.isArray(response.grants)) { + throw new Error('Cloud returned an invalid integration grants response.'); + } + return { grants: response.grants.map(normalizeGrant) }; +} + +function normalizeGrantCreate(payload: unknown): { grant: Record } { + return { grant: normalizeGrant(object(payload, 'integration grant').grant) }; +} + +type DelegatedCredential = { + leaseId: string; + relayfileUrl: string; + relayauthUrl: string; + refreshUrl: string; + relayfileWorkspaceId: string; + relayfileToken: string | null; + relayfileTokenExpiresAt: string | null; + relayfileRefreshToken: string | null; + relayfileRefreshTokenExpiresAt: string | null; + relayfileScopes: string[]; + delegationNotAfter: string | null; + relayfileMountPaths: string[]; +}; + +function serviceUrl(value: unknown, label: string): string { + const raw = string(value, label); + const canonical = canonicalApiUrl(raw); + return canonical; +} + +function normalizeCredential(payload: unknown): DelegatedCredential { + const candidate = object( + isObject(payload) && payload.credential !== undefined ? payload.credential : payload, + 'delegated credential' + ); + return { + leaseId: resourceId(string(candidate.leaseId, 'delegated credential'), 'credential lease ID'), + relayfileUrl: serviceUrl(candidate.relayfileUrl, 'delegated credential'), + relayauthUrl: serviceUrl(candidate.relayauthUrl, 'delegated credential'), + refreshUrl: serviceUrl(candidate.refreshUrl, 'delegated credential'), + relayfileWorkspaceId: string(candidate.relayfileWorkspaceId, 'delegated credential'), + relayfileToken: nullableString(candidate.relayfileToken, 'delegated credential'), + relayfileTokenExpiresAt: nullableString(candidate.relayfileTokenExpiresAt, 'delegated credential'), + relayfileRefreshToken: nullableString(candidate.relayfileRefreshToken, 'delegated credential'), + relayfileRefreshTokenExpiresAt: nullableString( + candidate.relayfileRefreshTokenExpiresAt, + 'delegated credential' + ), + relayfileScopes: stringArray(candidate.relayfileScopes, 'delegated credential'), + delegationNotAfter: nullableString(candidate.delegationNotAfter, 'delegated credential'), + relayfileMountPaths: stringArray(candidate.relayfileMountPaths, 'delegated credential'), + }; +} + +async function writeCredentialFile(path: string, credential: DelegatedCredential): Promise { + const payload = `${JSON.stringify(credential, null, 2)}\n`; + if (Buffer.byteLength(payload) > MAX_SECRET_BYTES) { + throw new Error('Delegated credential response exceeded the safe output limit.'); + } + const noFollow = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW; + const handle = await fs.open( + path, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, + 0o600 + ); + try { + await handle.writeFile(payload, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } +} + +function renderProviders(catalog: ReturnType, deps: Dependencies): void { + for (const provider of catalog.providers) { + const capabilities = provider.capabilities as Record; + deps.log( + [ + terminal(String(provider.id ?? 'unknown')), + capabilities.read ? 'read' : 'no-read', + capabilities.writeback ? 'write' : 'no-write', + capabilities.connect ? 'connect' : 'no-connect', + ].join(' ') + ); + } +} + +export function registerCloudIntegrationCommands(cloudCommand: Command, deps: Dependencies): void { + const integration = cloudCommand + .command('integration') + .description('Manage Cloud integrations and delegated Relayfile access'); + + integration + .command('catalog') + .description('Discover integrations and their truthful capabilities') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .option('--api-url ', 'Cloud API base URL') + .option('--static', 'Exclude dynamic Nango and Composio catalog entries') + .option('--search ', 'Filter providers by ID or display name') + .option('--backend ', 'Filter providers by nango or composio', backend) + .option('--json', 'Output the integration catalog as JSON') + .action( + async (options: { + workspace: string; + apiUrl?: string; + static?: boolean; + search?: string; + backend?: 'nango' | 'composio'; + json?: boolean; + }) => { + await action(deps, async () => { + workspaceId(options.workspace); + const catalog = normalizeCatalog( + await request( + deps, + `/api/v1/integrations/catalog?dynamic=${options.static ? 'false' : 'true'}`, + { method: 'GET' }, + options.apiUrl + ) + ); + const query = options.search?.trim().toLowerCase(); + const payload = { + ...catalog, + providers: catalog.providers.filter((provider) => { + const haystack = `${String(provider.id ?? '')} ${String( + provider.displayName ?? '' + )}`.toLowerCase(); + const backends = Array.isArray(provider.backends) + ? provider.backends + : [provider.backend].filter(Boolean); + return ( + (!query || haystack.includes(query)) && + (!options.backend || backends.includes(options.backend)) + ); + }), + }; + if (options.json) json(deps, payload); + else renderProviders(payload, deps); + }); + } + ); + + integration + .command('connections') + .description('List connected workspace integrations') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output connections as JSON') + .action(async (options: { workspace: string; apiUrl?: string; json?: boolean }) => { + await action(deps, async () => { + const id = workspaceId(options.workspace); + const payload = sanitize( + await request( + deps, + `/api/v1/workspaces/${encodeURIComponent(id)}/integrations`, + { method: 'GET' }, + options.apiUrl + ) + ); + if (options.json) json(deps, payload); + else json(deps, payload); + }); + }); + + integration + .command('connect') + .description('Create a Cloud connection session for a provider') + .argument('', 'Provider ID from the catalog') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .option('--backend ', 'Connection backend: nango or composio', backend) + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output the safe connection-session details as JSON') + .action( + async ( + providerInput: string, + options: { + workspace: string; + backend?: 'nango' | 'composio'; + apiUrl?: string; + json?: boolean; + } + ) => { + await action(deps, async () => { + const id = workspaceId(options.workspace); + const provider = providerId(providerInput); + const response = object( + await request( + deps, + `/api/v1/workspaces/${encodeURIComponent(id)}/integrations/connect-session`, + { + method: 'POST', + body: JSON.stringify({ + allowedIntegrations: [provider], + ...(options.backend ? { requestedBackend: options.backend } : {}), + }), + }, + options.apiUrl + ), + 'integration connection' + ); + const payload = { + connectLink: string(response.connectLink, 'integration connection'), + workspaceId: string(response.workspaceId, 'integration connection'), + relayWorkspaceId: string(response.relayWorkspaceId, 'integration connection'), + backend: string(response.backend, 'integration connection'), + providers: connectionProviderIds(response.providers), + ...(typeof response.expiresAt === 'string' ? { expiresAt: response.expiresAt } : {}), + }; + if (options.json) json(deps, payload); + else deps.log(payload.connectLink); + }); + } + ); + + integration + .command('disconnect') + .description('Disconnect a provider from the workspace') + .argument('', 'Provider ID') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output the disconnection result as JSON') + .action( + async (providerInput: string, options: { workspace: string; apiUrl?: string; json?: boolean }) => { + await action(deps, async () => { + const id = workspaceId(options.workspace); + const provider = providerId(providerInput); + await request( + deps, + `/api/v1/workspaces/${encodeURIComponent(id)}/integrations/${encodeURIComponent( + provider + )}/status`, + { method: 'DELETE' }, + options.apiUrl + ); + if (options.json) json(deps, { success: true }); + else deps.log(`Disconnected ${terminal(provider)}.`); + }); + } + ); + + integration + .command('grants') + .description('List room integration grants') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output grants as JSON') + .action(async (options: { workspace: string; apiUrl?: string; json?: boolean }) => { + await action(deps, async () => { + const id = workspaceId(options.workspace); + const payload = normalizeGrants( + await request( + deps, + `/api/v1/workspaces/${encodeURIComponent(id)}/room/integration-grants`, + { method: 'GET' }, + options.apiUrl + ) + ); + if (options.json) json(deps, payload); + else json(deps, payload); + }); + }); + + integration + .command('grant') + .description('Grant a room member bounded integration access') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .requiredOption('--member ', 'Room membership ID') + .requiredOption('--provider ', 'Provider ID from the catalog') + .requiredOption('--path ', 'Canonical allowed path; repeat for more paths', pathList, []) + .requiredOption('--access ', 'Grant read or write access', access) + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output the grant as JSON') + .action( + async (options: { + workspace: string; + member: string; + provider: string; + path: string[]; + access: 'read' | 'write'; + apiUrl?: string; + json?: boolean; + }) => { + await action(deps, async () => { + const id = workspaceId(options.workspace); + const payload = normalizeGrantCreate( + await request( + deps, + `/api/v1/workspaces/${encodeURIComponent(id)}/room/integration-grants`, + { + method: 'POST', + body: JSON.stringify({ + memberId: resourceId(options.member, 'membership ID'), + provider: providerId(options.provider), + allowedPaths: options.path, + canRead: true, + canWrite: options.access === 'write', + }), + }, + options.apiUrl + ) + ); + if (options.json) json(deps, payload); + else deps.log(`Integration grant ${terminal(String(payload.grant.id))} is active.`); + }); + } + ); + + integration + .command('revoke-grant') + .description('Revoke a room integration grant and its delegated credentials') + .argument('', 'Integration grant ID') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output the revocation result as JSON') + .action(async (grantInput: string, options: { workspace: string; apiUrl?: string; json?: boolean }) => { + await action(deps, async () => { + const id = workspaceId(options.workspace); + const grant = resourceId(grantInput, 'grant ID'); + await request( + deps, + `/api/v1/workspaces/${encodeURIComponent(id)}/room/integration-grants/${encodeURIComponent(grant)}`, + { method: 'DELETE' }, + options.apiUrl + ); + if (options.json) json(deps, { success: true }); + else deps.log(`Revoked integration grant ${terminal(grant)}.`); + }); + }); + + integration + .command('credential') + .description('Mint a grant-bounded delegated Relayfile credential lease') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .requiredOption( + '--device-id ', + 'Stable non-secret ID for this Herdr/Relayfile device', + deviceId + ) + .requiredOption('--access ', 'Request read or write access', access) + .option('--path ', 'Request a granted path; repeat for more paths', pathList, []) + .option('--ttl ', 'Access-token lifetime', positiveInt, DEFAULT_CREDENTIAL_TTL) + .option( + '--delegation-ttl ', + 'Maximum refresh/delegation lifetime', + positiveInt, + DEFAULT_DELEGATION_TTL + ) + .option('--api-url ', 'Cloud API base URL') + .option('--output-file ', 'Write the credential to a new owner-only 0600 file') + .option('--json', 'Output the delegated credential, including its secrets, as JSON') + .action( + async (options: { + workspace: string; + deviceId: string; + access: 'read' | 'write'; + path: string[]; + ttl: number; + delegationTtl: number; + apiUrl?: string; + outputFile?: string; + json?: boolean; + }) => { + await action(deps, async () => { + if (Boolean(options.outputFile) === Boolean(options.json)) { + throw new Error('Use exactly one credential sink: --output-file or --json.'); + } + if (options.ttl > 3_600 || options.delegationTtl > 86_400) { + throw new Error('Credential TTL exceeds the Cloud maximum.'); + } + const id = workspaceId(options.workspace); + const credential = normalizeCredential( + await request( + deps, + `/api/v1/workspaces/${encodeURIComponent(id)}/relayfile/delegated-token`, + { + method: 'POST', + body: JSON.stringify({ + deviceId: options.deviceId, + scopes: options.access === 'write' ? ['fs:read', 'fs:write'] : ['fs:read'], + ...(options.path.length > 0 + ? { relayfileMountPaths: options.path } + : {}), + ttlSeconds: options.ttl, + delegationTtlSeconds: options.delegationTtl, + }), + }, + options.apiUrl + ) + ); + if (options.json) { + json(deps, credential); + return; + } + try { + await writeCredentialFile(options.outputFile ?? '', credential); + } catch (writeError) { + try { + await request( + deps, + `/api/v1/workspaces/${encodeURIComponent( + id + )}/relayfile/delegated-token/${encodeURIComponent(credential.leaseId)}`, + { method: 'DELETE' }, + options.apiUrl + ); + } catch { + throw new Error( + `Could not write the delegated credential. Revocation could not be confirmed; revoke lease ${terminal( + credential.leaseId + )} before retrying.` + ); + } + throw writeError; + } + deps.log(`Wrote delegated Relayfile credential lease ${terminal(credential.leaseId)}.`); + }); + } + ); + + integration + .command('revoke-credential') + .description('Revoke this member’s delegated Relayfile credential lease') + .argument('', 'Credential lease ID') + .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Output the revocation result as JSON') + .action(async (leaseInput: string, options: { workspace: string; apiUrl?: string; json?: boolean }) => { + await action(deps, async () => { + const id = workspaceId(options.workspace); + const lease = resourceId(leaseInput, 'credential lease ID'); + await request( + deps, + `/api/v1/workspaces/${encodeURIComponent( + id + )}/relayfile/delegated-token/${encodeURIComponent(lease)}`, + { method: 'DELETE' }, + options.apiUrl + ); + if (options.json) json(deps, { success: true }); + else deps.log(`Revoked delegated Relayfile credential lease ${terminal(lease)}.`); + }); + }); +} diff --git a/packages/cli/src/cli/commands/cloud-room.test.ts b/packages/cli/src/cli/commands/cloud-room.test.ts index fd015d78d..cced02d57 100644 --- a/packages/cli/src/cli/commands/cloud-room.test.ts +++ b/packages/cli/src/cli/commands/cloud-room.test.ts @@ -125,7 +125,56 @@ describe('registerCloudRoomCommands', () => { expect(deps.log).toHaveBeenCalledWith('herdr_inv_single_use_secret'); }); - it('requires one explicit invitation-token output sink before authenticating', async () => { + it('sends an invitation by email without returning its one-time token', async () => { + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ + invite: { + id: 'invite_1', + email: 'person@example.com', + role: 'participant', + expiresAt: '2026-07-30T00:00:00.000Z', + createdAt: '2026-07-23T00:00:00.000Z', + }, + delivery: { mode: 'email', status: 'sent' }, + }), + auth, + }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'invite', + '--workspace', + 'rw_7ccfea89', + '--email', + 'person@example.com', + '--email-delivery', + '--json', + ]); + + expect(deps.authorizedApiFetch).toHaveBeenCalledWith( + auth, + '/api/v1/workspaces/rw_7ccfea89/room/invites', + { + method: 'POST', + body: JSON.stringify({ + email: 'person@example.com', + role: 'participant', + expiresInSeconds: 604800, + delivery: 'email', + }), + }, + { interactive: false } + ); + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain('"status": "sent"'); + expect(output).not.toContain('herdr_inv_'); + }); + + it('requires explicit email delivery or one token sink before authenticating', async () => { const { program, deps } = createHarness(); await expect( @@ -144,10 +193,32 @@ describe('registerCloudRoomCommands', () => { expect(deps.ensureCloudSession).not.toHaveBeenCalled(); expect(deps.error).toHaveBeenCalledWith( - 'Use exactly one invitation-token sink: --token-stdout, --token-file, or --json.' + 'Use --email-delivery (optionally with --json), or exactly one manual token sink: --token-stdout, --token-file, or --json.' ); }); + it('rejects combining email delivery with a manual token sink before authenticating', async () => { + const { program, deps } = createHarness(); + + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'invite', + '--workspace', + 'rw_7ccfea89', + '--email', + 'person@example.com', + '--email-delivery', + '--token-stdout', + ]) + ).rejects.toThrow('exit:1'); + + expect(deps.ensureCloudSession).not.toHaveBeenCalled(); + }); + it('writes an invitation token only to a new owner-only file', async () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-room-invite-output-')); const tokenFile = path.join(directory, 'invite-token'); @@ -187,9 +258,7 @@ describe('registerCloudRoomCommands', () => { } finally { fs.rmSync(directory, { recursive: true, force: true }); } - expect(vi.mocked(deps.log).mock.calls.flat().join('\n')).not.toContain( - 'herdr_inv_file_secret' - ); + expect(vi.mocked(deps.log).mock.calls.flat().join('\n')).not.toContain('herdr_inv_file_secret'); }); it('revokes a newly created invite when its token file cannot be created', async () => { @@ -245,9 +314,7 @@ describe('registerCloudRoomCommands', () => { { interactive: false } ); expect( - [...vi.mocked(deps.log).mock.calls, ...vi.mocked(deps.error).mock.calls] - .flat() - .join('\n') + [...vi.mocked(deps.log).mock.calls, ...vi.mocked(deps.error).mock.calls].flat().join('\n') ).not.toContain('herdr_inv_lost_secret'); }); @@ -277,14 +344,7 @@ describe('registerCloudRoomCommands', () => { response: null, }, { - args: [ - 'revoke-session', - '--workspace', - 'rw_7ccfea89', - '--device-id', - 'herdr-desktop-1', - '--json', - ], + args: ['revoke-session', '--workspace', 'rw_7ccfea89', '--device-id', 'herdr-desktop-1', '--json'], path: '/api/v1/workspaces/rw_7ccfea89/room/session', method: 'DELETE', body: JSON.stringify({ deviceId: 'herdr-desktop-1' }), @@ -299,12 +359,9 @@ describe('registerCloudRoomCommands', () => { await program.parseAsync(['node', 'agent-relay', 'cloud', 'room', ...args]); - expect(deps.authorizedApiFetch).toHaveBeenCalledWith( - auth, - path, - body ? { method, body } : { method }, - { interactive: false } - ); + expect(deps.authorizedApiFetch).toHaveBeenCalledWith(auth, path, body ? { method, body } : { method }, { + interactive: false, + }); }); it('accepts an invitation without echoing its token', async () => { @@ -698,10 +755,7 @@ describe('registerCloudRoomCommands', () => { ); it.each([ - [ - 'invitation', - ['invite', '--workspace', 'rw_7ccfea89', '--email', 'p@example.com', '--token-stdout'], - ], + ['invitation', ['invite', '--workspace', 'rw_7ccfea89', '--email', 'p@example.com', '--token-stdout']], ['invitation list', ['invites', '--workspace', 'rw_7ccfea89']], ['member list', ['members', '--workspace', 'rw_7ccfea89']], ['membership', ['accept', '--token-stdin']], diff --git a/packages/cli/src/cli/commands/cloud-room.ts b/packages/cli/src/cli/commands/cloud-room.ts index e78bdb170..dcd26d2a9 100644 --- a/packages/cli/src/cli/commands/cloud-room.ts +++ b/packages/cli/src/cli/commands/cloud-room.ts @@ -119,6 +119,22 @@ function normalizeInviteCreate(payload: unknown): { invite: RoomInvite & { token }; } +function normalizeEmailInviteCreate(payload: unknown): { + invite: RoomInvite; + delivery: { mode: 'email'; status: 'sent' }; +} { + const response = requireObject(payload, 'email invitation'); + const delivery = requireObject(response.delivery, 'email invitation delivery'); + if (delivery.mode !== 'email' || delivery.status !== 'sent') { + throw new Error('Cloud room returned an invalid email invitation response.'); + } + const invite = normalizeInvite(response.invite, false); + if (containsForbiddenCredentialField(response) || 'token' in requireObject(response.invite, 'invitation')) { + throw new Error('Cloud room returned a forbidden invitation credential.'); + } + return { invite, delivery: { mode: 'email', status: 'sent' } }; +} + function normalizeInviteList(payload: unknown): { invites: RoomInvite[] } { const response = requireObject(payload, 'invitation list'); if (!Array.isArray(response.invites)) { @@ -354,8 +370,7 @@ function requireRelaycastBaseUrl(value: string): string { } catch { throw new Error('Cloud room returned an invalid session response.'); } - const loopback = - url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'; + const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'; if ( (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || @@ -375,8 +390,7 @@ function canonicalApiBaseUrl(value: string): string { } catch { throw new Error('Invalid Cloud API URL.'); } - const loopback = - url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'; + const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'; if ( (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || @@ -430,10 +444,7 @@ async function requestRoom( apiUrl: requestedApiUrl, interactive: false, }); - if ( - apiUrl && - canonicalApiBaseUrl(session.auth.apiUrl) !== canonicalApiBaseUrl(requestedApiUrl) - ) { + if (apiUrl && canonicalApiBaseUrl(session.auth.apiUrl) !== canonicalApiBaseUrl(requestedApiUrl)) { throw new Error( `Cloud login is bound to ${canonicalApiBaseUrl( session.auth.apiUrl @@ -529,9 +540,10 @@ export function registerCloudRoomCommands( parsePositiveInteger, DEFAULT_INVITATION_LIFETIME_SECONDS ) + .option('--email-delivery', 'Send the invitation through Agent Relay Cloud email') .option('--token-stdout', 'Print only the one-time invitation token') .option('--token-file ', 'Write the token to a new owner-only 0600 file') - .option('--json', 'Output the full invitation, including its one-time token, as JSON') + .option('--json', 'Output the invitation as JSON; manual delivery includes its one-time token') .action( async (options: { workspace: string; @@ -539,38 +551,49 @@ export function registerCloudRoomCommands( role: RoomRole; expiresIn: number; apiUrl?: string; + emailDelivery?: boolean; tokenStdout?: boolean; tokenFile?: string; json?: boolean; }) => { await runRoomAction(deps, async () => { - const outputCount = [ - options.tokenStdout, - Boolean(options.tokenFile), - options.json, - ].filter(Boolean).length; - if (outputCount !== 1) { + const manualSinkCount = [options.tokenStdout, Boolean(options.tokenFile), options.json].filter( + Boolean + ).length; + if ( + (options.emailDelivery && (options.tokenStdout || options.tokenFile)) || + (!options.emailDelivery && manualSinkCount !== 1) + ) { throw new Error( - 'Use exactly one invitation-token sink: --token-stdout, --token-file, or --json.' + 'Use --email-delivery (optionally with --json), or exactly one manual token sink: --token-stdout, --token-file, or --json.' ); } const workspaceId = requireWorkspaceId(options.workspace); const email = requireEmail(options.email); - const payload = normalizeInviteCreate( - await requestRoom( - deps, - `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/room/invites`, - { - method: 'POST', - body: JSON.stringify({ - email, - role: options.role, - expiresInSeconds: options.expiresIn, - }), - }, - options.apiUrl - ) + const response = await requestRoom( + deps, + `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/room/invites`, + { + method: 'POST', + body: JSON.stringify({ + email, + role: options.role, + expiresInSeconds: options.expiresIn, + ...(options.emailDelivery ? { delivery: 'email' } : {}), + }), + }, + options.apiUrl ); + if (options.emailDelivery) { + const payload = normalizeEmailInviteCreate(response); + if (options.json) { + logJson(deps, payload); + return; + } + deps.log(`Sent ${options.role} room invitation to ${email}.`); + return; + } + const payload = normalizeInviteCreate(response); if (options.json) { logJson(deps, payload); return; @@ -601,7 +624,9 @@ export function registerCloudRoomCommands( throw writeError; } deps.log(`Created ${options.role} room invitation for ${email}.`); - deps.log(`Wrote the one-time invitation token to ${sanitizeTerminalCell(options.tokenFile ?? '')}.`); + deps.log( + `Wrote the one-time invitation token to ${sanitizeTerminalCell(options.tokenFile ?? '')}.` + ); }); } ); @@ -753,33 +778,26 @@ export function registerCloudRoomCommands( .requiredOption('--device-id ', 'Stable non-secret identifier for this client') .option('--api-url ', 'Cloud API base URL') .option('--json', 'Output the revocation response as JSON') - .action( - async (options: { - workspace: string; - deviceId: string; - apiUrl?: string; - json?: boolean; - }) => { - await runRoomAction(deps, async () => { - const workspaceId = requireWorkspaceId(options.workspace); - const deviceId = requireDeviceId(options.deviceId); - await requestRoom( - deps, - `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/room/session`, - { - method: 'DELETE', - body: JSON.stringify({ deviceId }), - }, - options.apiUrl - ); - if (options.json) { - logJson(deps, { ok: true }); - return; - } - deps.log(`Revoked room session for device ${sanitizeTerminalCell(deviceId)}.`); - }); - } - ); + .action(async (options: { workspace: string; deviceId: string; apiUrl?: string; json?: boolean }) => { + await runRoomAction(deps, async () => { + const workspaceId = requireWorkspaceId(options.workspace); + const deviceId = requireDeviceId(options.deviceId); + await requestRoom( + deps, + `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/room/session`, + { + method: 'DELETE', + body: JSON.stringify({ deviceId }), + }, + options.apiUrl + ); + if (options.json) { + logJson(deps, { ok: true }); + return; + } + deps.log(`Revoked room session for device ${sanitizeTerminalCell(deviceId)}.`); + }); + }); room .command('session') diff --git a/packages/cli/src/cli/commands/cloud.test.ts b/packages/cli/src/cli/commands/cloud.test.ts index 72b71bae5..d121343ae 100644 --- a/packages/cli/src/cli/commands/cloud.test.ts +++ b/packages/cli/src/cli/commands/cloud.test.ts @@ -116,6 +116,7 @@ describe('registerCloudCommands', () => { expect(cloud?.commands.map((command) => command.name())).toEqual([ 'worker', 'room', + 'integration', 'login', 'logout', 'session', diff --git a/packages/cli/src/cli/commands/cloud.ts b/packages/cli/src/cli/commands/cloud.ts index 75decba8f..807355ad4 100644 --- a/packages/cli/src/cli/commands/cloud.ts +++ b/packages/cli/src/cli/commands/cloud.ts @@ -33,6 +33,7 @@ import { defaultExit } from '../lib/exit.js'; import { errorClassName } from '../lib/telemetry-helpers.js'; import { track } from '../telemetry/index.js'; import { registerCloudRoomCommands } from './cloud-room.js'; +import { registerCloudIntegrationCommands } from './cloud-integration.js'; import { registerCloudWorkerCommands } from './cloud-worker.js'; const CLOUD_SYNC_PATCH_EXCLUDES = [ @@ -418,6 +419,7 @@ export function registerCloudCommands(program: Command, overrides: Partial Date: Thu, 23 Jul 2026 21:45:55 +0000 Subject: [PATCH 03/21] style: auto-format with Prettier --- packages/cli/src/cli/commands/cloud-integration.ts | 4 +--- packages/cli/src/cli/commands/cloud.ts | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/cli/commands/cloud-integration.ts b/packages/cli/src/cli/commands/cloud-integration.ts index 2f6637445..c843944f4 100644 --- a/packages/cli/src/cli/commands/cloud-integration.ts +++ b/packages/cli/src/cli/commands/cloud-integration.ts @@ -659,9 +659,7 @@ export function registerCloudIntegrationCommands(cloudCommand: Command, deps: De body: JSON.stringify({ deviceId: options.deviceId, scopes: options.access === 'write' ? ['fs:read', 'fs:write'] : ['fs:read'], - ...(options.path.length > 0 - ? { relayfileMountPaths: options.path } - : {}), + ...(options.path.length > 0 ? { relayfileMountPaths: options.path } : {}), ttlSeconds: options.ttl, delegationTtlSeconds: options.delegationTtl, }), diff --git a/packages/cli/src/cli/commands/cloud.ts b/packages/cli/src/cli/commands/cloud.ts index 807355ad4..191b38852 100644 --- a/packages/cli/src/cli/commands/cloud.ts +++ b/packages/cli/src/cli/commands/cloud.ts @@ -571,7 +571,8 @@ export function registerCloudCommands(program: Command, overrides: Partial null)) as - (WhoAmIResponse & { error?: string }) | null; + | (WhoAmIResponse & { error?: string }) + | null; if (!response.ok || !payload?.authenticated) { throw new Error(payload?.error || 'Failed to resolve auth status'); From 48572b3b003d784b5cb38f3904c5cff7f1ece16e Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 23 Jul 2026 23:55:04 +0200 Subject: [PATCH 04/21] fix(cli): preserve refreshed auth for cleanup --- .../cli/commands/cloud-integration.test.ts | 12 +++- .../cli/src/cli/commands/cloud-integration.ts | 67 +++++++++++-------- .../cli/src/cli/commands/cloud-room.test.ts | 12 +++- packages/cli/src/cli/commands/cloud-room.ts | 54 +++++++++------ 4 files changed, 92 insertions(+), 53 deletions(-) diff --git a/packages/cli/src/cli/commands/cloud-integration.test.ts b/packages/cli/src/cli/commands/cloud-integration.test.ts index c0a194aaf..4b20f7373 100644 --- a/packages/cli/src/cli/commands/cloud-integration.test.ts +++ b/packages/cli/src/cli/commands/cloud-integration.test.ts @@ -19,6 +19,11 @@ const auth = { refreshToken: 'refresh-secret', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', }; +const refreshedAuth = { + ...auth, + accessToken: 'refreshed-access-secret', + refreshToken: 'rotated-refresh-secret', +}; function response(value: unknown, status = 200): Response { return new Response(value === null ? null : JSON.stringify(value), { @@ -287,8 +292,8 @@ describe('registerCloudIntegrationCommands', () => { fs.writeFileSync(target, 'preserve', { mode: 0o600 }); const { program, deps } = harness(); vi.mocked(deps.authorizedApiFetch) - .mockResolvedValueOnce({ response: response(credential()), auth }) - .mockResolvedValueOnce({ response: new Response(null, { status: 204 }), auth }); + .mockResolvedValueOnce({ response: response(credential()), auth: refreshedAuth }) + .mockResolvedValueOnce({ response: new Response(null, { status: 204 }), auth: refreshedAuth }); try { await expect( program.parseAsync([ @@ -313,11 +318,12 @@ describe('registerCloudIntegrationCommands', () => { } expect(deps.authorizedApiFetch).toHaveBeenNthCalledWith( 2, - auth, + refreshedAuth, '/api/v1/workspaces/rw_7ccfea89/relayfile/delegated-token/lease_1', { method: 'DELETE' }, { interactive: false } ); + expect(deps.ensureCloudSession).toHaveBeenCalledTimes(1); }); it('revokes a device-scoped credential lease without putting secrets in argv', async () => { diff --git a/packages/cli/src/cli/commands/cloud-integration.ts b/packages/cli/src/cli/commands/cloud-integration.ts index c843944f4..c636974ad 100644 --- a/packages/cli/src/cli/commands/cloud-integration.ts +++ b/packages/cli/src/cli/commands/cloud-integration.ts @@ -11,6 +11,7 @@ type Dependencies = Pick< CloudDependencies, 'log' | 'error' | 'exit' | 'ensureCloudSession' | 'authorizedApiFetch' >; +type CloudAuth = Awaited>['auth']; const WORKSPACE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const RELAY_WORKSPACE = /^rw_[a-z0-9]{8}$/; @@ -171,29 +172,39 @@ function cloudError(response: Response): Error { return new Error(`Cloud integration request failed (${response.status}).`); } -async function request( +async function requestWithAuth( deps: Dependencies, path: string, init: RequestInit, - apiUrl?: string -): Promise { + apiUrl?: string, + priorAuth?: CloudAuth +): Promise<{ payload: unknown; auth: CloudAuth }> { const requested = apiUrl ?? defaultApiUrl(); - const session = await deps.ensureCloudSession({ apiUrl: requested, interactive: false }); - if (apiUrl && canonicalApiUrl(session.auth.apiUrl) !== canonicalApiUrl(requested)) { + const auth = priorAuth ?? (await deps.ensureCloudSession({ apiUrl: requested, interactive: false })).auth; + if (apiUrl && canonicalApiUrl(auth.apiUrl) !== canonicalApiUrl(requested)) { throw new Error( `Cloud login is bound to ${canonicalApiUrl( - session.auth.apiUrl + auth.apiUrl )}. Run \`agent-relay cloud login --api-url ${canonicalApiUrl( requested )} --force\` before using this host.` ); } - const { response } = await deps.authorizedApiFetch(session.auth, path, init, { + const result = await deps.authorizedApiFetch(auth, path, init, { interactive: false, }); - const payload = (await response.json().catch(() => null)) as unknown; - if (!response.ok) throw cloudError(response); - return payload; + const payload = (await result.response.json().catch(() => null)) as unknown; + if (!result.response.ok) throw cloudError(result.response); + return { payload, auth: result.auth }; +} + +async function request( + deps: Dependencies, + path: string, + init: RequestInit, + apiUrl?: string +): Promise { + return (await requestWithAuth(deps, path, init, apiUrl)).payload; } async function action(deps: Dependencies, fn: () => Promise): Promise { @@ -650,23 +661,22 @@ export function registerCloudIntegrationCommands(cloudCommand: Command, deps: De throw new Error('Credential TTL exceeds the Cloud maximum.'); } const id = workspaceId(options.workspace); - const credential = normalizeCredential( - await request( - deps, - `/api/v1/workspaces/${encodeURIComponent(id)}/relayfile/delegated-token`, - { - method: 'POST', - body: JSON.stringify({ - deviceId: options.deviceId, - scopes: options.access === 'write' ? ['fs:read', 'fs:write'] : ['fs:read'], - ...(options.path.length > 0 ? { relayfileMountPaths: options.path } : {}), - ttlSeconds: options.ttl, - delegationTtlSeconds: options.delegationTtl, - }), - }, - options.apiUrl - ) + const minted = await requestWithAuth( + deps, + `/api/v1/workspaces/${encodeURIComponent(id)}/relayfile/delegated-token`, + { + method: 'POST', + body: JSON.stringify({ + deviceId: options.deviceId, + scopes: options.access === 'write' ? ['fs:read', 'fs:write'] : ['fs:read'], + ...(options.path.length > 0 ? { relayfileMountPaths: options.path } : {}), + ttlSeconds: options.ttl, + delegationTtlSeconds: options.delegationTtl, + }), + }, + options.apiUrl ); + const credential = normalizeCredential(minted.payload); if (options.json) { json(deps, credential); return; @@ -675,13 +685,14 @@ export function registerCloudIntegrationCommands(cloudCommand: Command, deps: De await writeCredentialFile(options.outputFile ?? '', credential); } catch (writeError) { try { - await request( + await requestWithAuth( deps, `/api/v1/workspaces/${encodeURIComponent( id )}/relayfile/delegated-token/${encodeURIComponent(credential.leaseId)}`, { method: 'DELETE' }, - options.apiUrl + options.apiUrl, + minted.auth ); } catch { throw new Error( diff --git a/packages/cli/src/cli/commands/cloud-room.test.ts b/packages/cli/src/cli/commands/cloud-room.test.ts index cced02d57..9c867da90 100644 --- a/packages/cli/src/cli/commands/cloud-room.test.ts +++ b/packages/cli/src/cli/commands/cloud-room.test.ts @@ -22,6 +22,11 @@ const auth = { refreshToken: 'refresh-secret', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', }; +const refreshedAuth = { + ...auth, + accessToken: 'refreshed-access-secret', + refreshToken: 'rotated-refresh-secret', +}; function jsonResponse(body: unknown, status = 200, headers?: HeadersInit): Response { return new Response(JSON.stringify(body), { @@ -278,11 +283,11 @@ describe('registerCloudRoomCommands', () => { createdAt: '2026-07-23T00:00:00.000Z', }, }), - auth, + auth: refreshedAuth, }) .mockResolvedValueOnce({ response: new Response(null, { status: 204 }), - auth, + auth: refreshedAuth, }); try { @@ -308,11 +313,12 @@ describe('registerCloudRoomCommands', () => { expect(deps.authorizedApiFetch).toHaveBeenNthCalledWith( 2, - auth, + refreshedAuth, '/api/v1/workspaces/rw_7ccfea89/room/invites/invite_1', { method: 'DELETE' }, { interactive: false } ); + expect(deps.ensureCloudSession).toHaveBeenCalledTimes(1); expect( [...vi.mocked(deps.log).mock.calls, ...vi.mocked(deps.error).mock.calls].flat().join('\n') ).not.toContain('herdr_inv_lost_secret'); diff --git a/packages/cli/src/cli/commands/cloud-room.ts b/packages/cli/src/cli/commands/cloud-room.ts index dcd26d2a9..21068ed86 100644 --- a/packages/cli/src/cli/commands/cloud-room.ts +++ b/packages/cli/src/cli/commands/cloud-room.ts @@ -12,6 +12,7 @@ type CloudRoomDependencies = Pick< CloudDependencies, 'log' | 'error' | 'exit' | 'ensureCloudSession' | 'authorizedApiFetch' >; +type CloudAuth = Awaited>['auth']; type RoomRole = 'viewer' | 'participant'; @@ -433,37 +434,51 @@ function cloudRoomError(response: Response): Error { return new Error(`Cloud room request failed (${response.status}).`); } -async function requestRoom( +async function requestRoomWithAuth( deps: CloudRoomDependencies, path: string, init: RequestInit, - apiUrl?: string -): Promise { + apiUrl?: string, + priorAuth?: CloudAuth +): Promise<{ payload: unknown; auth: CloudAuth }> { const requestedApiUrl = apiUrl ?? defaultApiUrl(); - const session = await deps.ensureCloudSession({ - apiUrl: requestedApiUrl, - interactive: false, - }); - if (apiUrl && canonicalApiBaseUrl(session.auth.apiUrl) !== canonicalApiBaseUrl(requestedApiUrl)) { + const auth = + priorAuth ?? + ( + await deps.ensureCloudSession({ + apiUrl: requestedApiUrl, + interactive: false, + }) + ).auth; + if (apiUrl && canonicalApiBaseUrl(auth.apiUrl) !== canonicalApiBaseUrl(requestedApiUrl)) { throw new Error( `Cloud login is bound to ${canonicalApiBaseUrl( - session.auth.apiUrl + auth.apiUrl )}. Run \`agent-relay cloud login --api-url ${canonicalApiBaseUrl( requestedApiUrl )} --force\` before using this host.` ); } - const { response } = await deps.authorizedApiFetch(session.auth, path, init, { + const result = await deps.authorizedApiFetch(auth, path, init, { interactive: false, }); - const payload = (await response.json().catch(() => null)) as unknown; - if (!response.ok) { - throw cloudRoomError(response); + const payload = (await result.response.json().catch(() => null)) as unknown; + if (!result.response.ok) { + throw cloudRoomError(result.response); } if (containsForbiddenCredentialField(payload)) { throw new Error('Cloud room returned a forbidden workspace or integration credential.'); } - return payload; + return { payload, auth: result.auth }; +} + +async function requestRoom( + deps: CloudRoomDependencies, + path: string, + init: RequestInit, + apiUrl?: string +): Promise { + return (await requestRoomWithAuth(deps, path, init, apiUrl)).payload; } async function runRoomAction(deps: CloudRoomDependencies, action: () => Promise): Promise { @@ -570,7 +585,7 @@ export function registerCloudRoomCommands( } const workspaceId = requireWorkspaceId(options.workspace); const email = requireEmail(options.email); - const response = await requestRoom( + const created = await requestRoomWithAuth( deps, `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/room/invites`, { @@ -585,7 +600,7 @@ export function registerCloudRoomCommands( options.apiUrl ); if (options.emailDelivery) { - const payload = normalizeEmailInviteCreate(response); + const payload = normalizeEmailInviteCreate(created.payload); if (options.json) { logJson(deps, payload); return; @@ -593,7 +608,7 @@ export function registerCloudRoomCommands( deps.log(`Sent ${options.role} room invitation to ${email}.`); return; } - const payload = normalizeInviteCreate(response); + const payload = normalizeInviteCreate(created.payload); if (options.json) { logJson(deps, payload); return; @@ -606,13 +621,14 @@ export function registerCloudRoomCommands( await io.writeSecretFile(options.tokenFile ?? '', payload.invite.token); } catch (writeError) { try { - await requestRoom( + await requestRoomWithAuth( deps, `/api/v1/workspaces/${encodeURIComponent( workspaceId )}/room/invites/${encodeURIComponent(payload.invite.id)}`, { method: 'DELETE' }, - options.apiUrl + options.apiUrl, + created.auth ); } catch { throw new Error( From f9f7451df34c9bb7bfa3c0a699c09b41fb2149ba Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 00:03:35 +0200 Subject: [PATCH 05/21] fix(cli): expose integration catalog globally --- packages/cli/README.md | 2 +- .../cli/src/cli/commands/cloud-integration.test.ts | 11 +---------- packages/cli/src/cli/commands/cloud-integration.ts | 6 +++--- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index b4364c359..18874a474 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -139,7 +139,7 @@ granted to one room member, and issued as a short-lived, revocable lease: ```bash # Owner: inspect capability truth, connect a provider, then grant exact paths. -agent-relay cloud integration catalog --workspace rw_7ccfea89 +agent-relay cloud integration catalog agent-relay cloud integration connect linear --workspace rw_7ccfea89 agent-relay cloud integration grant \ --workspace rw_7ccfea89 \ diff --git a/packages/cli/src/cli/commands/cloud-integration.test.ts b/packages/cli/src/cli/commands/cloud-integration.test.ts index 4b20f7373..9ba39f5e1 100644 --- a/packages/cli/src/cli/commands/cloud-integration.test.ts +++ b/packages/cli/src/cli/commands/cloud-integration.test.ts @@ -106,16 +106,7 @@ describe('registerCloudIntegrationCommands', () => { auth, }); - await program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'integration', - 'catalog', - '--workspace', - 'rw_7ccfea89', - '--json', - ]); + await program.parseAsync(['node', 'agent-relay', 'cloud', 'integration', 'catalog', '--json']); expect(deps.authorizedApiFetch).toHaveBeenCalledWith( auth, diff --git a/packages/cli/src/cli/commands/cloud-integration.ts b/packages/cli/src/cli/commands/cloud-integration.ts index c636974ad..f2c28839d 100644 --- a/packages/cli/src/cli/commands/cloud-integration.ts +++ b/packages/cli/src/cli/commands/cloud-integration.ts @@ -386,7 +386,7 @@ export function registerCloudIntegrationCommands(cloudCommand: Command, deps: De integration .command('catalog') .description('Discover integrations and their truthful capabilities') - .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') + .option('--workspace ', 'Optional Cloud UUID or unified rw_ workspace context') .option('--api-url ', 'Cloud API base URL') .option('--static', 'Exclude dynamic Nango and Composio catalog entries') .option('--search ', 'Filter providers by ID or display name') @@ -394,7 +394,7 @@ export function registerCloudIntegrationCommands(cloudCommand: Command, deps: De .option('--json', 'Output the integration catalog as JSON') .action( async (options: { - workspace: string; + workspace?: string; apiUrl?: string; static?: boolean; search?: string; @@ -402,7 +402,7 @@ export function registerCloudIntegrationCommands(cloudCommand: Command, deps: De json?: boolean; }) => { await action(deps, async () => { - workspaceId(options.workspace); + if (options.workspace) workspaceId(options.workspace); const catalog = normalizeCatalog( await request( deps, From 255554d999e2d546aea618175ee179fc78a23223 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 00:28:16 +0200 Subject: [PATCH 06/21] test(fleet): reserve the broker API port --- tests/e2e/fleet/fleet-e2e.test.ts | 9 +++++---- tests/e2e/fleet/harness.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/e2e/fleet/fleet-e2e.test.ts b/tests/e2e/fleet/fleet-e2e.test.ts index f295a9709..b195b0160 100644 --- a/tests/e2e/fleet/fleet-e2e.test.ts +++ b/tests/e2e/fleet/fleet-e2e.test.ts @@ -8,6 +8,7 @@ import { delay, enrollNode, FleetNode, + getFreeBrokerBasePort, getFreePort, getInvocation, getNodes, @@ -79,7 +80,7 @@ describe.skipIf(!pre.ok)('Cloud-enrolled node startup', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - brokerPort: await getFreePort(), + brokerPort: await getFreeBrokerBasePort(), capacityHarnesses: 'claude', usePersistedEnrollment: true, }); @@ -176,7 +177,7 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - brokerPort: await getFreePort(), + brokerPort: await getFreeBrokerBasePort(), // Pin capacity so the node advertises a distinct harness (`claude`) plus the // shared `pool`. A `spawn:` shadow delegates to the broker's native // capacity for that harness, so every shadow the node defines (spawn:claude, @@ -193,7 +194,7 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - brokerPort: await getFreePort(), + brokerPort: await getFreeBrokerBasePort(), // Distinct `codex` plus the shared `pool` (see node-a's note). capacityHarnesses: 'codex,pool', }); @@ -269,7 +270,7 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - brokerPort: await getFreePort(), + brokerPort: await getFreeBrokerBasePort(), }); badNode.start(); try { diff --git a/tests/e2e/fleet/harness.ts b/tests/e2e/fleet/harness.ts index 52b8f0888..b0e7add2c 100644 --- a/tests/e2e/fleet/harness.ts +++ b/tests/e2e/fleet/harness.ts @@ -117,6 +117,19 @@ export function getFreePort(): Promise { }); } +/** + * `AGENT_RELAY_BROKER_PORT` is a base port; the broker HTTP API starts probing + * at base + 1. Reserve that actual candidate in the harness instead of proving + * only that the unused base itself is free. + */ +export async function getFreeBrokerBasePort(): Promise { + let apiPort = await getFreePort(); + while (apiPort <= 1) { + apiPort = await getFreePort(); + } + return apiPort - 1; +} + export async function waitFor( fn: () => Promise, opts: { timeoutMs?: number; intervalMs?: number; label?: string } = {} From 13c86858c0e22f03ce8805c0553407f7df21d930 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 01:22:00 +0200 Subject: [PATCH 07/21] fix(cli): scope credentials and fleet ports --- CHANGELOG.md | 4 ++ crates/broker/src/cli/mod.rs | 2 +- .../cli/commands/cloud-integration.test.ts | 35 ++++++++++++++ .../cli/src/cli/commands/cloud-integration.ts | 8 ++-- .../cli/src/cli/commands/cloud-room.test.ts | 47 +++++++++++++++++++ packages/cli/src/cli/commands/cloud-room.ts | 8 ++-- packages/cli/src/cli/commands/core.test.ts | 31 ++++++++++++ packages/cli/src/cli/commands/core.ts | 15 ++++-- packages/cli/src/cli/lib/broker-lifecycle.ts | 19 ++++++-- packages/cli/src/cli/lib/sdk-client.test.ts | 7 ++- packages/cli/src/cli/lib/sdk-client.ts | 10 ++-- packages/harness-driver/src/spawn-config.ts | 2 +- .../sdk/src/__tests__/agent-relay.test.ts | 39 +++++++++++++++ packages/sdk/src/agent-relay.ts | 20 ++++++++ .../sdk/src/messaging/relaycast-client.ts | 10 ++-- tests/e2e/fleet/fleet-e2e.test.ts | 9 ++-- tests/e2e/fleet/harness.ts | 15 +----- 17 files changed, 238 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21740e724..c87ebe734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `agent-relay cloud integration` can discover truthful provider capabilities, connect providers, manage room-member path grants, and mint or revoke device-scoped Relayfile credential leases. - `agent-relay agent me|presence` use scoped agent credentials for room-safe identity and presence checks. +### Fixed + +- `AGENT_RELAY_BROKER_PORT=0` now lets `agent-relay node up` bind an OS-assigned API port atomically, preventing concurrent Fleet nodes from racing over a probed port. + ## [11.1.1] - 2026-07-23 ### Added diff --git a/crates/broker/src/cli/mod.rs b/crates/broker/src/cli/mod.rs index cc6ba4c0f..1f1f119cf 100644 --- a/crates/broker/src/cli/mod.rs +++ b/crates/broker/src/cli/mod.rs @@ -243,7 +243,7 @@ pub(crate) struct InitCommand { #[arg(long, default_value = "general")] pub(crate) channels: String, - /// Optional HTTP API port for dashboard proxy (0 = disabled) + /// Optional HTTP API port for dashboard proxy (0 = atomically OS-assigned). #[arg(long, default_value = "0")] pub(crate) api_port: u16, diff --git a/packages/cli/src/cli/commands/cloud-integration.test.ts b/packages/cli/src/cli/commands/cloud-integration.test.ts index 9ba39f5e1..6a1bcbdb7 100644 --- a/packages/cli/src/cli/commands/cloud-integration.test.ts +++ b/packages/cli/src/cli/commands/cloud-integration.test.ts @@ -317,6 +317,41 @@ describe('registerCloudIntegrationCommands', () => { expect(deps.ensureCloudSession).toHaveBeenCalledTimes(1); }); + it('reports write and rollback failure without printing either error detail', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-integration-credential-')); + const target = path.join(directory, 'existing'); + fs.writeFileSync(target, 'preserve', { mode: 0o600 }); + const { program, deps } = harness(); + vi.mocked(deps.authorizedApiFetch) + .mockResolvedValueOnce({ response: response(credential()), auth: refreshedAuth }) + .mockRejectedValueOnce(new Error('cleanup-secret-marker')); + try { + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'integration', + 'credential', + '--workspace', + 'rw_7ccfea89', + '--device-id', + 'herdr-room-device', + '--access', + 'write', + '--output-file', + target, + ]) + ).rejects.toThrow('exit:1'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + const output = vi.mocked(deps.error).mock.calls.flat().join('\n'); + expect(output).toContain('Revocation could not be confirmed; revoke lease lease_1'); + expect(output).not.toContain('cleanup-secret-marker'); + expect(output).not.toContain('EEXIST'); + }); + it('revokes a device-scoped credential lease without putting secrets in argv', async () => { const { program, deps } = harness(); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ diff --git a/packages/cli/src/cli/commands/cloud-integration.ts b/packages/cli/src/cli/commands/cloud-integration.ts index f2c28839d..b11e15592 100644 --- a/packages/cli/src/cli/commands/cloud-integration.ts +++ b/packages/cli/src/cli/commands/cloud-integration.ts @@ -694,11 +694,13 @@ export function registerCloudIntegrationCommands(cloudCommand: Command, deps: De options.apiUrl, minted.auth ); - } catch { - throw new Error( + } catch (cleanupError) { + throw new AggregateError( + [writeError, cleanupError], `Could not write the delegated credential. Revocation could not be confirmed; revoke lease ${terminal( credential.leaseId - )} before retrying.` + )} before retrying.`, + { cause: writeError } ); } throw writeError; diff --git a/packages/cli/src/cli/commands/cloud-room.test.ts b/packages/cli/src/cli/commands/cloud-room.test.ts index 9c867da90..b6491b1f4 100644 --- a/packages/cli/src/cli/commands/cloud-room.test.ts +++ b/packages/cli/src/cli/commands/cloud-room.test.ts @@ -324,6 +324,53 @@ describe('registerCloudRoomCommands', () => { ).not.toContain('herdr_inv_lost_secret'); }); + it('reports invite write and rollback failure without printing either error detail', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-room-token-')); + const tokenFile = path.join(directory, 'existing'); + fs.writeFileSync(tokenFile, 'do-not-overwrite', { mode: 0o600 }); + const { program, deps } = createHarness(); + vi.mocked(deps.authorizedApiFetch) + .mockResolvedValueOnce({ + response: jsonResponse({ + invite: { + id: 'invite_1', + email: 'person@example.com', + role: 'viewer', + token: 'herdr_inv_lost_secret', + expiresAt: '2026-07-30T00:00:00.000Z', + createdAt: '2026-07-23T00:00:00.000Z', + }, + }), + auth: refreshedAuth, + }) + .mockRejectedValueOnce(new Error('cleanup-secret-marker')); + try { + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'invite', + '--workspace', + 'rw_7ccfea89', + '--email', + 'person@example.com', + '--token-file', + tokenFile, + ]) + ).rejects.toThrow('exit:1'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + + const output = vi.mocked(deps.error).mock.calls.flat().join('\n'); + expect(output).toContain('Revocation could not be confirmed; revoke invitation invite_1'); + expect(output).not.toContain('cleanup-secret-marker'); + expect(output).not.toContain('EEXIST'); + expect(output).not.toContain('herdr_inv_lost_secret'); + }); + it.each([ { args: ['invites', '--workspace', 'rw_7ccfea89', '--json'], diff --git a/packages/cli/src/cli/commands/cloud-room.ts b/packages/cli/src/cli/commands/cloud-room.ts index 21068ed86..77c20a26b 100644 --- a/packages/cli/src/cli/commands/cloud-room.ts +++ b/packages/cli/src/cli/commands/cloud-room.ts @@ -630,11 +630,13 @@ export function registerCloudRoomCommands( options.apiUrl, created.auth ); - } catch { - throw new Error( + } catch (cleanupError) { + throw new AggregateError( + [writeError, cleanupError], `Could not write the invitation token. Revocation could not be confirmed; revoke invitation ${sanitizeTerminalCell( payload.invite.id - )} before retrying.` + )} before retrying.`, + { cause: writeError } ); } throw writeError; diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index c9e54732d..1f5c790d2 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -394,6 +394,37 @@ describe('registerCoreCommands', () => { expect(relay.getStatus).toHaveBeenCalledTimes(1); }); + it('up lets the broker atomically bind an OS-assigned API port when configured with port zero', async () => { + const relay = createRelayMock({ apiPort: 43123 }); + const { program, deps } = createHarness({ + relay, + env: { AGENT_RELAY_BROKER_PORT: '0' }, + }); + + const exitCode = await runCommand(program, ['up']); + + expect(exitCode).toBeUndefined(); + expect(deps.isPortInUse).not.toHaveBeenCalled(); + expect(deps.createRelay).toHaveBeenCalledWith('/tmp/project', 0, undefined, undefined); + expect(deps.log).toHaveBeenCalledWith('Relay API: http://localhost:43123'); + }); + + it('up shuts down a port-zero broker that does not report its assigned API port', async () => { + const relay = createRelayMock({ apiPort: undefined }); + const { program, deps } = createHarness({ + relay, + env: { AGENT_RELAY_BROKER_PORT: '0' }, + }); + + const exitCode = await runCommand(program, ['up']); + + expect(exitCode).toBe(1); + expect(relay.shutdown).toHaveBeenCalledTimes(1); + expect(deps.error).toHaveBeenCalledWith( + 'Failed to start broker: Broker started without reporting its OS-assigned API port.' + ); + }); + it('up enables the local broker API', async () => { const relay = createRelayMock(); const { program, deps } = createHarness({ relay }); diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index 19aab00f3..8ce9234f7 100644 --- a/packages/cli/src/cli/commands/core.ts +++ b/packages/cli/src/cli/commands/core.ts @@ -61,6 +61,8 @@ export interface CoreRelay { workspaceKey?: string; /** PID of the underlying broker process, when available. */ brokerPid?: number; + /** Actual HTTP API port bound by the broker, including OS-assigned ports. */ + apiPort?: number; } export interface CoreFileSystem { @@ -148,11 +150,10 @@ async function createDefaultRelay( brokerName?: string, verbose = false ): Promise { - const binaryArgs: BrokerInitArgs = {}; - if (apiPort > 0) { - binaryArgs.persist = true; - binaryArgs.apiPort = apiPort; - } + const binaryArgs: BrokerInitArgs = { + persist: true, + apiPort, + }; const stateDir = process.env.AGENT_RELAY_STATE_DIR; if (stateDir) { binaryArgs.stateDir = stateDir; @@ -186,6 +187,10 @@ async function createDefaultRelay( get brokerPid() { return client.brokerPid; }, + get apiPort() { + const port = Number.parseInt(new URL(client.baseUrl).port, 10); + return Number.isInteger(port) && port > 0 ? port : undefined; + }, }; return relay; } diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index d05266c39..c52ae59e6 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -317,12 +317,13 @@ async function resolveApiPortWithFallback( /** * The broker base port. `AGENT_RELAY_BROKER_PORT` overrides the default so - * multiple brokers can run side by side (e.g. in tests); the broker HTTP API - * binds near `basePort + 1` with fallback scanning. + * multiple brokers can run side by side. A value of `0` asks the OS to assign + * the API port atomically during broker bind, which avoids probe-then-bind + * races in concurrent test stacks. */ export function resolveBrokerBasePort(deps: Pick): number { const raw = Number.parseInt(deps.env.AGENT_RELAY_BROKER_PORT ?? '', 10); - return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_BROKER_BASE_PORT; + return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_BROKER_BASE_PORT; } export async function startBrokerWithPortFallback( @@ -332,6 +333,18 @@ export async function startBrokerWithPortFallback( brokerName?: string, verbose?: boolean ): Promise<{ relay: CoreRelay; apiPort: number }> { + if (basePort === 0) { + vlog(deps, verbose, 'Asking the OS to assign the broker API port...'); + const candidate = await deps.createRelay(paths.projectRoot, 0, brokerName, verbose); + await candidate.getStatus(); + if (!candidate.apiPort) { + await candidate.shutdown().catch(() => undefined); + throw new Error('Broker started without reporting its OS-assigned API port.'); + } + vlog(deps, verbose, `API port assigned: ${candidate.apiPort}`); + return { relay: candidate, apiPort: candidate.apiPort }; + } + // Resolve a free API port BEFORE spawning the broker. This avoids // spawning (and flocking) multiple --persist brokers during retry, // which caused stale-flock "already running" errors. diff --git a/packages/cli/src/cli/lib/sdk-client.test.ts b/packages/cli/src/cli/lib/sdk-client.test.ts index b57ee0dd9..65e947453 100644 --- a/packages/cli/src/cli/lib/sdk-client.test.ts +++ b/packages/cli/src/cli/lib/sdk-client.test.ts @@ -116,8 +116,11 @@ describe('sdk client option resolution', () => { AGENT_RELAY_HOME: dir, RELAY_AGENT_TOKEN: 'at_live_participant_scoped', }, - }) as { workspaceKey?: string }; + }) as { workspaceKey?: string; toJSON(): unknown }; - expect(relay.workspaceKey).toBe('at_live_participant_scoped'); + expect(relay.workspaceKey).toBeUndefined(); + expect(JSON.stringify(relay)).not.toContain('rk_live_owner_secret'); + expect(JSON.stringify(relay)).not.toContain('rk_live_project_owner_secret'); + expect(JSON.stringify(relay)).not.toContain('at_live_participant_scoped'); }); }); diff --git a/packages/cli/src/cli/lib/sdk-client.ts b/packages/cli/src/cli/lib/sdk-client.ts index 8779f5b0b..ae1e2b216 100644 --- a/packages/cli/src/cli/lib/sdk-client.ts +++ b/packages/cli/src/cli/lib/sdk-client.ts @@ -73,10 +73,14 @@ export function createAgentRelay(options: SdkClientOptions = {}): AgentRelayAgen // the caller to exactly one workspace. Prefer the scoped token itself over // every ambient workspace-key source so invited humans cannot accidentally // inherit the local owner's rk_live credential from this project or machine. - const transportCredential = token ?? resolveWorkspaceKey(options); + if (token) { + return new AgentRelay({ + agentToken: token, + baseUrl: resolveBaseUrl(options), + }); + } return new AgentRelay({ - workspaceKey: transportCredential, + workspaceKey: resolveWorkspaceKey(options), baseUrl: resolveBaseUrl(options), - ...(token ? { agentToken: token } : {}), }); } diff --git a/packages/harness-driver/src/spawn-config.ts b/packages/harness-driver/src/spawn-config.ts index aa65d76ff..f3c95b235 100644 --- a/packages/harness-driver/src/spawn-config.ts +++ b/packages/harness-driver/src/spawn-config.ts @@ -4,7 +4,7 @@ import type { EventBus } from './event-bus.js'; import type { HarnessDriverEvents } from './lifecycle-hooks.js'; export interface BrokerInitArgs { - /** Optional HTTP API port for the broker (0 = disabled). */ + /** Optional HTTP API port for the broker (0 = atomically OS-assigned). */ apiPort?: number; /** Bind address for the HTTP API. Defaults to 127.0.0.1 in the broker. */ apiBind?: string; diff --git a/packages/sdk/src/__tests__/agent-relay.test.ts b/packages/sdk/src/__tests__/agent-relay.test.ts index 6c48e35cf..be3c883cd 100644 --- a/packages/sdk/src/__tests__/agent-relay.test.ts +++ b/packages/sdk/src/__tests__/agent-relay.test.ts @@ -1,3 +1,4 @@ +import { inspect } from 'node:util'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const relaycastMocks = vi.hoisted(() => { @@ -98,6 +99,44 @@ describe('AgentRelay workspace setup', () => { }); }); + it('uses an agent token as the only Relaycast transport credential', () => { + const relay = new AgentRelay({ + agentToken: 'at_live_participant_scoped', + baseUrl: 'https://api.example.test', + }); + + expect(relay.workspaceKey).toBeUndefined(); + expect(relaycastMocks.relayCast).toHaveBeenCalledWith({ + apiKey: 'at_live_participant_scoped', + baseUrl: 'https://api.example.test', + }); + }); + + it('redacts credentials from JSON, object spread, and Node inspection', () => { + const relay = new AgentRelay({ + workspaceKey: 'rk_live_owner_marker', + agentToken: 'at_live_agent_marker', + observerToken: 'ot_live_observer_marker', + baseUrl: 'https://user:password@example.test', + }); + + const rendered = [ + JSON.stringify(relay), + JSON.stringify({ ...relay }), + inspect(relay), + inspect({ ...relay }), + ].join('\n'); + + expect(rendered).not.toContain('rk_live_owner_marker'); + expect(rendered).not.toContain('at_live_agent_marker'); + expect(rendered).not.toContain('ot_live_observer_marker'); + expect(rendered).not.toContain('password'); + expect(JSON.parse(JSON.stringify(relay))).toEqual({ + type: 'AgentRelay', + authenticated: true, + }); + }); + it('passes explicit Relaycast telemetry through existing workspace clients', () => { const relay = new AgentRelay({ workspaceKey: 'rk_live_existing', diff --git a/packages/sdk/src/agent-relay.ts b/packages/sdk/src/agent-relay.ts index d7d38ed4d..b8967b49a 100644 --- a/packages/sdk/src/agent-relay.ts +++ b/packages/sdk/src/agent-relay.ts @@ -190,6 +190,26 @@ export class AgentRelay implements AgentRelayAgent { if (onError) { this.errorHooks.add(onError); } + // Credentials can live in several implementation objects (the workspace + // key, observer token, messaging options, and agent-client map). Keep every + // own field non-enumerable so object spread and generic serializers cannot + // accidentally copy those values into logs. + for (const property of Object.keys(this)) { + Object.defineProperty(this, property, { enumerable: false }); + } + } + + /** Safe JSON/log representation. Deliberately excludes URLs and credentials. */ + toJSON(): { type: 'AgentRelay'; authenticated: boolean } { + return { + type: 'AgentRelay', + authenticated: Boolean(this.workspaceKey || this.observerToken || this.messagingOptions.agentToken), + }; + } + + /** Node's util.inspect hook follows the same credential-free contract. */ + [Symbol.for('nodejs.util.inspect.custom')](): ReturnType { + return this.toJSON(); } static async createWorkspace(input: string | AgentRelayCreateWorkspaceInput): Promise { diff --git a/packages/sdk/src/messaging/relaycast-client.ts b/packages/sdk/src/messaging/relaycast-client.ts index 80a99198a..8782d98ac 100644 --- a/packages/sdk/src/messaging/relaycast-client.ts +++ b/packages/sdk/src/messaging/relaycast-client.ts @@ -218,14 +218,16 @@ export interface RelaycastMessagingOptions extends RelaycastTelemetryOptions { export function createRelaycastClient(options: RelaycastMessagingOptions): RelaycastWorkspaceLike { if (options.relaycast) return options.relaycast; - const workspaceKey = options.workspaceKey ?? options.apiKey; - if (!workspaceKey) { - throw new Error('RelaycastMessagingClient requires workspaceKey when relaycast is not provided.'); + const credential = options.workspaceKey ?? options.apiKey ?? options.agentToken; + if (!credential) { + throw new Error( + 'RelaycastMessagingClient requires workspaceKey or agentToken when relaycast is not provided.' + ); } return new RelayCast( definedOptions({ - apiKey: workspaceKey, + apiKey: credential, baseUrl: options.baseUrl, retryPolicy: options.retryPolicy, ...relaycastTelemetryOptions({ diff --git a/tests/e2e/fleet/fleet-e2e.test.ts b/tests/e2e/fleet/fleet-e2e.test.ts index b195b0160..9bc9647c5 100644 --- a/tests/e2e/fleet/fleet-e2e.test.ts +++ b/tests/e2e/fleet/fleet-e2e.test.ts @@ -8,7 +8,6 @@ import { delay, enrollNode, FleetNode, - getFreeBrokerBasePort, getFreePort, getInvocation, getNodes, @@ -80,7 +79,7 @@ describe.skipIf(!pre.ok)('Cloud-enrolled node startup', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - brokerPort: await getFreeBrokerBasePort(), + brokerPort: 0, capacityHarnesses: 'claude', usePersistedEnrollment: true, }); @@ -177,7 +176,7 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - brokerPort: await getFreeBrokerBasePort(), + brokerPort: 0, // Pin capacity so the node advertises a distinct harness (`claude`) plus the // shared `pool`. A `spawn:` shadow delegates to the broker's native // capacity for that harness, so every shadow the node defines (spawn:claude, @@ -194,7 +193,7 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - brokerPort: await getFreeBrokerBasePort(), + brokerPort: 0, // Distinct `codex` plus the shared `pool` (see node-a's note). capacityHarnesses: 'codex,pool', }); @@ -270,7 +269,7 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - brokerPort: await getFreeBrokerBasePort(), + brokerPort: 0, }); badNode.start(); try { diff --git a/tests/e2e/fleet/harness.ts b/tests/e2e/fleet/harness.ts index b0e7add2c..bc8bbd712 100644 --- a/tests/e2e/fleet/harness.ts +++ b/tests/e2e/fleet/harness.ts @@ -117,19 +117,6 @@ export function getFreePort(): Promise { }); } -/** - * `AGENT_RELAY_BROKER_PORT` is a base port; the broker HTTP API starts probing - * at base + 1. Reserve that actual candidate in the harness instead of proving - * only that the unused base itself is free. - */ -export async function getFreeBrokerBasePort(): Promise { - let apiPort = await getFreePort(); - while (apiPort <= 1) { - apiPort = await getFreePort(); - } - return apiPort - 1; -} - export async function waitFor( fn: () => Promise, opts: { timeoutMs?: number; intervalMs?: number; label?: string } = {} @@ -384,6 +371,8 @@ export class FleetNode { engineBaseUrl: string; brokerBinary: string; tmpRoot: string; + /** Use 0 in concurrent test stacks so the broker atomically binds an + * OS-assigned API port instead of racing a probe-and-release helper. */ brokerPort: number; /** Pins the broker's `spawn:` capacity set (AGENT_RELAY_NODE_HARNESSES) * so two nodes on one host advertise distinct capabilities. */ From ce8520c156739dadb34a62b20ed031203a49f3c5 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 01:33:20 +0200 Subject: [PATCH 08/21] fix(cli): clean up failed broker startups --- packages/cli/src/cli/commands/core.test.ts | 34 +++++++++++++++++++ packages/cli/src/cli/commands/core.ts | 3 ++ packages/cli/src/cli/lib/broker-lifecycle.ts | 35 +++++++++++++++++--- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index 1f5c790d2..d0025f334 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -425,6 +425,40 @@ describe('registerCoreCommands', () => { ); }); + it('up shuts down a port-zero broker when startup status validation rejects', async () => { + const relay = createRelayMock({ + apiPort: 43123, + getStatus: vi.fn(async () => { + throw new Error('startup status unavailable'); + }), + }); + const { program, deps } = createHarness({ + relay, + env: { AGENT_RELAY_BROKER_PORT: '0' }, + }); + + const exitCode = await runCommand(program, ['up']); + + expect(exitCode).toBe(1); + expect(relay.shutdown).toHaveBeenCalledTimes(1); + expect(deps.error).toHaveBeenCalledWith('Failed to start broker: startup status unavailable'); + }); + + it('up shuts down a fixed-port broker when startup status validation rejects', async () => { + const relay = createRelayMock({ + getStatus: vi.fn(async () => { + throw new Error('startup status unavailable'); + }), + }); + const { program, deps } = createHarness({ relay }); + + const exitCode = await runCommand(program, ['up']); + + expect(exitCode).toBe(1); + expect(relay.shutdown).toHaveBeenCalledTimes(1); + expect(deps.error).toHaveBeenCalledWith('Failed to start broker: startup status unavailable'); + }); + it('up enables the local broker API', async () => { const relay = createRelayMock(); const { program, deps } = createHarness({ relay }); diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index 8ce9234f7..5b5008160 100644 --- a/packages/cli/src/cli/commands/core.ts +++ b/packages/cli/src/cli/commands/core.ts @@ -150,6 +150,9 @@ async function createDefaultRelay( brokerName?: string, verbose = false ): Promise { + // This is the `up` command's broker factory. `up` is persistent even when + // port 0 delegates atomic port selection to the OS; the connection file is + // how later `status`, `down`, and enrolled-node recovery find that broker. const binaryArgs: BrokerInitArgs = { persist: true, apiPort, diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index c52ae59e6..f07163594 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -336,10 +336,22 @@ export async function startBrokerWithPortFallback( if (basePort === 0) { vlog(deps, verbose, 'Asking the OS to assign the broker API port...'); const candidate = await deps.createRelay(paths.projectRoot, 0, brokerName, verbose); - await candidate.getStatus(); - if (!candidate.apiPort) { - await candidate.shutdown().catch(() => undefined); - throw new Error('Broker started without reporting its OS-assigned API port.'); + try { + await candidate.getStatus(); + if (!candidate.apiPort) { + throw new Error('Broker started without reporting its OS-assigned API port.'); + } + } catch (startupError) { + try { + await candidate.shutdown(); + } catch (cleanupError) { + throw new AggregateError( + [startupError, cleanupError], + 'Broker startup validation failed and cleanup also failed.', + { cause: startupError } + ); + } + throw startupError; } vlog(deps, verbose, `API port assigned: ${candidate.apiPort}`); return { relay: candidate, apiPort: candidate.apiPort }; @@ -357,7 +369,20 @@ export async function startBrokerWithPortFallback( const candidate = await deps.createRelay(paths.projectRoot, apiPort, brokerName, verbose); vlog(deps, verbose, 'Broker client created. Checking broker status...'); - await candidate.getStatus(); + try { + await candidate.getStatus(); + } catch (startupError) { + try { + await candidate.shutdown(); + } catch (cleanupError) { + throw new AggregateError( + [startupError, cleanupError], + 'Broker startup validation failed and cleanup also failed.', + { cause: startupError } + ); + } + throw startupError; + } vlog(deps, verbose, 'Broker status check passed.'); return { relay: candidate, apiPort }; } From 71b38f7b0af7641e2a241f1619a84bec16fbdb02 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 02:03:08 +0200 Subject: [PATCH 09/21] fix(node): allocate fleet ports atomically --- CHANGELOG.md | 2 +- packages/cli/src/cli/commands/node.test.ts | 20 ++++++++++++++++++++ packages/cli/src/cli/commands/node.ts | 4 ++++ tests/e2e/fleet/fleet-e2e.test.ts | 4 ---- tests/e2e/fleet/harness.ts | 8 ++++---- 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c87ebe734..5eba540d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `AGENT_RELAY_BROKER_PORT=0` now lets `agent-relay node up` bind an OS-assigned API port atomically, preventing concurrent Fleet nodes from racing over a probed port. +- `agent-relay node up` now binds an OS-assigned API port atomically by default, preventing concurrent Fleet nodes from racing over a probed port; `AGENT_RELAY_BROKER_PORT` remains an explicit stable-port override. ## [11.1.1] - 2026-07-23 diff --git a/packages/cli/src/cli/commands/node.test.ts b/packages/cli/src/cli/commands/node.test.ts index 298a0c2fd..572481e2d 100644 --- a/packages/cli/src/cli/commands/node.test.ts +++ b/packages/cli/src/cli/commands/node.test.ts @@ -105,6 +105,26 @@ describe('registerNodeCommands', () => { expect(up.options.map((option) => option.long)).toContain('--config'); }); + it('defaults node startup to an atomically OS-assigned broker API port', async () => { + const { program, env } = createNodeHarness(); + + await program.parseAsync(['node', 'up'], { from: 'user' }); + + expect(env.AGENT_RELAY_BROKER_PORT).toBe('0'); + expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); + }); + + it('preserves an explicit broker base port for node startup', async () => { + const { program, env } = createNodeHarness({ + env: { AGENT_RELAY_BROKER_PORT: '4100' }, + }); + + await program.parseAsync(['node', 'up'], { from: 'user' }); + + expect(env.AGENT_RELAY_BROKER_PORT).toBe('4100'); + expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); + }); + it('picks up a persisted enrollment and wires its creds into the env', async () => { const resolveEnrollment = vi.fn( () => enrollmentRecord diff --git a/packages/cli/src/cli/commands/node.ts b/packages/cli/src/cli/commands/node.ts index a8bf1bc13..36711aa53 100644 --- a/packages/cli/src/cli/commands/node.ts +++ b/packages/cli/src/cli/commands/node.ts @@ -159,6 +159,10 @@ function applyResolvedNodeSession( */ async function runNodeUp(options: UpCommandOptions, deps: NodeCommandDependencies): Promise { const env = deps.core.env; + // Fleet nodes may be started concurrently on one machine. Let the broker + // bind an ephemeral API port atomically unless the operator explicitly + // selected a stable broker base port. + env.AGENT_RELAY_BROKER_PORT ??= '0'; // An explicit workspace key (flag or env) is a direct workspace choice; the // enrollment store records workspace ids, not keys, so a stored enrollment // cannot be matched against it — skip pickup entirely rather than risk diff --git a/tests/e2e/fleet/fleet-e2e.test.ts b/tests/e2e/fleet/fleet-e2e.test.ts index 9bc9647c5..d92dcbeec 100644 --- a/tests/e2e/fleet/fleet-e2e.test.ts +++ b/tests/e2e/fleet/fleet-e2e.test.ts @@ -79,7 +79,6 @@ describe.skipIf(!pre.ok)('Cloud-enrolled node startup', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - brokerPort: 0, capacityHarnesses: 'claude', usePersistedEnrollment: true, }); @@ -176,7 +175,6 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - brokerPort: 0, // Pin capacity so the node advertises a distinct harness (`claude`) plus the // shared `pool`. A `spawn:` shadow delegates to the broker's native // capacity for that harness, so every shadow the node defines (spawn:claude, @@ -193,7 +191,6 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - brokerPort: 0, // Distinct `codex` plus the shared `pool` (see node-a's note). capacityHarnesses: 'codex,pool', }); @@ -269,7 +266,6 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - brokerPort: 0, }); badNode.start(); try { diff --git a/tests/e2e/fleet/harness.ts b/tests/e2e/fleet/harness.ts index bc8bbd712..cae3909e1 100644 --- a/tests/e2e/fleet/harness.ts +++ b/tests/e2e/fleet/harness.ts @@ -371,9 +371,9 @@ export class FleetNode { engineBaseUrl: string; brokerBinary: string; tmpRoot: string; - /** Use 0 in concurrent test stacks so the broker atomically binds an - * OS-assigned API port instead of racing a probe-and-release helper. */ - brokerPort: number; + /** Optional explicit broker base port; omission exercises the production + * `node up` default of an atomically OS-assigned API port. */ + brokerPort?: number; /** Pins the broker's `spawn:` capacity set (AGENT_RELAY_NODE_HARNESSES) * so two nodes on one host advertise distinct capabilities. */ capacityHarnesses?: string; @@ -500,7 +500,7 @@ export class FleetNode { }), AGENT_RELAY_PROJECT: this.projectDir, AGENT_RELAY_STATE_DIR: stateDir, - AGENT_RELAY_BROKER_PORT: String(o.brokerPort), + ...(o.brokerPort === undefined ? {} : { AGENT_RELAY_BROKER_PORT: String(o.brokerPort) }), ...(o.capacityHarnesses ? { AGENT_RELAY_NODE_HARNESSES: o.capacityHarnesses } : {}), }), stdio: ['ignore', 'pipe', 'pipe'], From 66fb0bd687f6b088417d23898fa288ce90c8720e Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 09:09:11 +0200 Subject: [PATCH 10/21] refactor(cloud): simplify rooms to trusted participants --- CHANGELOG.md | 4 +- packages/cli/README.md | 61 +- packages/cli/src/cli/bootstrap.test.ts | 5 - .../cli/commands/cloud-integration.test.ts | 376 +++------ .../cli/src/cli/commands/cloud-integration.ts | 431 +---------- .../cli/src/cli/commands/cloud-room.test.ts | 717 +++--------------- packages/cli/src/cli/commands/cloud-room.ts | 86 +-- 7 files changed, 306 insertions(+), 1374 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eba540d0..31af20902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `agent-relay cloud room` can invite workspace-scoped participants through explicit secret sinks, manage members, and establish per-device multiplayer sessions without sharing a Relay workspace key. -- `agent-relay cloud integration` can discover truthful provider capabilities, connect providers, manage room-member path grants, and mint or revoke device-scoped Relayfile credential leases. +- `agent-relay cloud room` can invite trusted full workspace participants through explicit secret sinks, manage members, and establish revocable per-device Relaycast sessions without sharing the workspace key. +- `agent-relay cloud integration` exposes the existing Cloud integration catalog, connection, and disconnection lifecycle from the CLI; connected providers remain available through Relayfile's normal setup, mount, and writeback flow. - `agent-relay agent me|presence` use scoped agent credentials for room-safe identity and presence checks. ### Fixed diff --git a/packages/cli/README.md b/packages/cli/README.md index 18874a474..51f6785be 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -87,33 +87,31 @@ agent-relay node up ## Cloud multiplayer rooms -Cloud room membership is scoped to one Relay workspace. It does not expose the -workspace key, grant Fleet control, or grant integration write access. +Cloud room membership is scoped to one Relay workspace. Every v1 invite creates +a trusted full workspace participant: they receive their own revocable Relaycast +human credential and may use all workspace actions. The workspace key itself is +never shared, and membership does not grant Agent Relay Cloud organization +administration. ```bash # Owner: invite and manage people in this workspace. agent-relay cloud room invite \ --workspace rw_7ccfea89 \ --email teammate@example.com \ - --role participant \ - --email-delivery + --token-file ./teammate.room-invite agent-relay cloud room invites --workspace rw_7ccfea89 agent-relay cloud room members --workspace rw_7ccfea89 -# Manual fallback: create an owner-only token file and share it over a secure -# channel. The invitee keeps the token out of shell history and process arguments. -agent-relay cloud room invite \ - --workspace rw_7ccfea89 \ - --email teammate@example.com \ - --token-file ./teammate.room-invite +# Share the owner-only token file over a secure channel. The invitee keeps the +# token out of shell history and process arguments. read -rs ROOM_INVITATION_TOKEN printf '%s' "$ROOM_INVITATION_TOKEN" | agent-relay cloud room accept --token-stdin unset ROOM_INVITATION_TOKEN # Trusted clients such as Herdr establish one stable session per device. -# --json intentionally includes the scoped participant or observer credential; -# capture it in memory and do not log or persist it. +# --json intentionally includes the participant credential; capture it in +# memory and do not log or persist it. agent-relay cloud room session \ --workspace rw_7ccfea89 \ --device-id herdr-macbook \ @@ -124,8 +122,8 @@ agent-relay cloud room revoke-session \ --workspace rw_7ccfea89 \ --device-id herdr-macbook -# Participants use their scoped token for presence and chat; an ambient owner -# workspace key is never consulted when --token is present. +# Participants use their scoped token for all Relaycast workspace operations; an +# ambient owner workspace key is never consulted when --token is present. agent-relay agent presence \ --token at_live_... \ --base-url https://cast.agentrelay.com @@ -134,32 +132,23 @@ agent-relay agent presence \ agent-relay cloud room remove-member --workspace rw_7ccfea89 ``` -Room membership grants chat only. Integration access is separately connected, -granted to one room member, and issued as a short-lived, revocable lease: +There is no room-specific integration grant or credential service. Connect the +workspace provider through the existing Cloud integration API, then use the +normal Relayfile workflow for setup, mounts, reads, and writebacks: ```bash -# Owner: inspect capability truth, connect a provider, then grant exact paths. +# Owner: discover or connect a provider through Cloud. agent-relay cloud integration catalog agent-relay cloud integration connect linear --workspace rw_7ccfea89 -agent-relay cloud integration grant \ - --workspace rw_7ccfea89 \ - --member \ - --provider linear \ - --path '/linear/issues/**' \ - --access write - -# Member or Herdr: capture the delegated bundle explicitly. Caller identity is -# derived from Cloud auth; it is never accepted from command flags. -agent-relay cloud integration credential \ - --workspace rw_7ccfea89 \ - --device-id herdr-room-device \ - --access write \ - --output-file ./relayfile-credential.json - -# Herdr uses the JSON form only for in-process capture, keeps the lease ID (not -# its token) as durable cleanup state, and revokes it on close or session reset. -agent-relay cloud integration revoke-credential \ - --workspace rw_7ccfea89 +agent-relay cloud integration connections --workspace rw_7ccfea89 + +# Member or Herdr: use Relayfile directly, including its OAuth/backend selection +# and durable writeback queue. +relayfile integration available +relayfile integration connect linear +RELAYFILE_LOCAL_DIR="$PWD/.integrations" relayfile setup +RELAYFILE_LOCAL_DIR="$PWD/.integrations" relayfile status +RELAYFILE_LOCAL_DIR="$PWD/.integrations" relayfile writeback status ``` `local` remains as a deprecated hidden alias of `node` (it prints a one-time warning). diff --git a/packages/cli/src/cli/bootstrap.test.ts b/packages/cli/src/cli/bootstrap.test.ts index 2523b5764..e28920d6c 100644 --- a/packages/cli/src/cli/bootstrap.test.ts +++ b/packages/cli/src/cli/bootstrap.test.ts @@ -74,11 +74,6 @@ const expectedLeafCommands = [ 'cloud integration connections', 'cloud integration connect', 'cloud integration disconnect', - 'cloud integration grants', - 'cloud integration grant', - 'cloud integration revoke-grant', - 'cloud integration credential', - 'cloud integration revoke-credential', // workspace 'workspace create', 'workspace active', diff --git a/packages/cli/src/cli/commands/cloud-integration.test.ts b/packages/cli/src/cli/commands/cloud-integration.test.ts index 6a1bcbdb7..da896f1cf 100644 --- a/packages/cli/src/cli/commands/cloud-integration.test.ts +++ b/packages/cli/src/cli/commands/cloud-integration.test.ts @@ -1,6 +1,3 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import { Command } from 'commander'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -19,11 +16,6 @@ const auth = { refreshToken: 'refresh-secret', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', }; -const refreshedAuth = { - ...auth, - accessToken: 'refreshed-access-secret', - refreshToken: 'rotated-refresh-secret', -}; function response(value: unknown, status = 200): Response { return new Response(value === null ? null : JSON.stringify(value), { @@ -53,52 +45,31 @@ function harness() { return { program, deps, integration: cloud.commands[0] }; } -function credential() { - return { - leaseId: 'lease_1', - relayfileUrl: 'https://relayfile.test', - relayauthUrl: 'https://relayauth.test', - refreshUrl: 'https://relayauth.test/v1/tokens/refresh', - relayfileWorkspaceId: 'rw_7ccfea89', - relayfileToken: 'relay_pa_private', - relayfileTokenExpiresAt: '2026-07-23T22:00:00.000Z', - relayfileRefreshToken: 'relay_pr_private', - relayfileRefreshTokenExpiresAt: '2026-07-24T21:00:00.000Z', - relayfileScopes: ['relayfile:fs:read:*', 'relayfile:fs:write:*'], - delegationNotAfter: '2026-07-24T21:00:00.000Z', - relayfileMountPaths: ['/linear/**'], - }; -} - beforeEach(() => { vi.clearAllMocks(); }); describe('registerCloudIntegrationCommands', () => { - it('registers the complete Cloud integration lifecycle', () => { + it('exposes only the existing Cloud connection lifecycle', () => { const { integration } = harness(); expect(integration.commands.map((command) => command.name())).toEqual([ 'catalog', 'connections', 'connect', 'disconnect', - 'grants', - 'grant', - 'revoke-grant', - 'credential', - 'revoke-credential', ]); }); - it('discovers dynamic providers with truthful capabilities', async () => { + it('lists dynamic providers without requiring room-specific capabilities', async () => { const { program, deps } = harness(); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ response: response({ providers: [ { - id: 'dropbox', - vfsRoot: '/dropbox', - capabilities: { connect: true, read: true, writeback: false }, + id: 'linear', + displayName: 'Linear', + backends: ['nango'], + apiKey: 'must-not-print', }, ], version: 'abcdef123456', @@ -114,25 +85,21 @@ describe('registerCloudIntegrationCommands', () => { { method: 'GET' }, { interactive: false } ); - expect(vi.mocked(deps.log).mock.calls.flat().join('\n')).toContain('"writeback": false'); + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain('"id": "linear"'); + expect(output).not.toContain('must-not-print'); }); - it('creates a bounded write grant for a room member', async () => { + it('filters the catalog locally by backend and search text', async () => { const { program, deps } = harness(); - const grant = { - id: 'grant_1', - workspaceId: '00000000-0000-4000-8000-000000000020', - memberId: 'member_1', - userId: '00000000-0000-4000-8000-000000000001', - provider: 'linear', - allowedPaths: ['/linear/issues/**'], - canRead: true, - canWrite: true, - createdAt: '2026-07-23T21:00:00.000Z', - updatedAt: '2026-07-23T21:00:00.000Z', - }; vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: response({ grant }, 201), + response: response({ + providers: [ + { id: 'linear', displayName: 'Linear', backends: ['nango'] }, + { id: 'github', displayName: 'GitHub', backends: ['composio'] }, + ], + version: '1', + }), auth, }); @@ -141,64 +108,23 @@ describe('registerCloudIntegrationCommands', () => { 'agent-relay', 'cloud', 'integration', - 'grant', - '--workspace', - 'rw_7ccfea89', - '--member', - 'member_1', - '--provider', - 'linear', - '--path', - '/linear/issues/**', - '--access', - 'write', + 'catalog', + '--search', + 'git', + '--backend', + 'composio', '--json', ]); - expect(deps.authorizedApiFetch).toHaveBeenCalledWith( - auth, - '/api/v1/workspaces/rw_7ccfea89/room/integration-grants', - { - method: 'POST', - body: JSON.stringify({ - memberId: 'member_1', - provider: 'linear', - allowedPaths: ['/linear/issues/**'], - canRead: true, - canWrite: true, - }), - }, - { interactive: false } - ); - }); - - it('requires an explicit delegated-credential sink before authentication', async () => { - const { program, deps } = harness(); - - await expect( - program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'integration', - 'credential', - '--workspace', - 'rw_7ccfea89', - '--device-id', - 'herdr-room-device', - '--access', - 'write', - ]) - ).rejects.toThrow('exit:1'); - - expect(deps.ensureCloudSession).not.toHaveBeenCalled(); - expect(deps.error).toHaveBeenCalledWith('Use exactly one credential sink: --output-file or --json.'); + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain('"id": "github"'); + expect(output).not.toContain('"id": "linear"'); }); - it('mints a grant-intersected write credential without caller identity fields', async () => { + it('lists workspace connections using the existing endpoint', async () => { const { program, deps } = harness(); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: response(credential()), + response: response([{ id: 'linear', status: 'connected', token: 'hidden' }]), auth, }); @@ -207,155 +133,52 @@ describe('registerCloudIntegrationCommands', () => { 'agent-relay', 'cloud', 'integration', - 'credential', + 'connections', '--workspace', 'rw_7ccfea89', - '--device-id', - 'herdr-room-device', - '--access', - 'write', - '--path', - '/linear/**', '--json', ]); expect(deps.authorizedApiFetch).toHaveBeenCalledWith( auth, - '/api/v1/workspaces/rw_7ccfea89/relayfile/delegated-token', - { - method: 'POST', - body: JSON.stringify({ - deviceId: 'herdr-room-device', - scopes: ['fs:read', 'fs:write'], - relayfileMountPaths: ['/linear/**'], - ttlSeconds: 3600, - delegationTtlSeconds: 86400, - }), - }, + '/api/v1/workspaces/rw_7ccfea89/integrations', + { method: 'GET' }, { interactive: false } ); - expect(vi.mocked(deps.log).mock.calls.flat().join('\n')).toContain('relay_pa_private'); + expect(vi.mocked(deps.log).mock.calls.flat().join('\n')).not.toContain('hidden'); }); - it('writes a delegated credential only to a new owner-only file', async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-integration-credential-')); - const target = path.join(directory, 'credential.json'); + it('renders connected providers for humans by default', async () => { const { program, deps } = harness(); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: response(credential()), + response: response([{ provider: 'linear', status: 'connected' }]), auth, }); - try { - await program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'integration', - 'credential', - '--workspace', - 'rw_7ccfea89', - '--device-id', - 'herdr-room-device', - '--access', - 'read', - '--output-file', - target, - ]); - expect(JSON.parse(fs.readFileSync(target, 'utf8'))).toMatchObject({ - leaseId: 'lease_1', - relayfileToken: 'relay_pa_private', - }); - if (process.platform !== 'win32') { - expect(fs.statSync(target).mode & 0o077).toBe(0); - } - const requestBody = JSON.parse( - String(vi.mocked(deps.authorizedApiFetch).mock.calls[0]?.[2]?.body) - ) as Record; - expect(requestBody).not.toHaveProperty('relayfileMountPaths'); - } finally { - fs.rmSync(directory, { recursive: true, force: true }); - } - }); - it('revokes a newly minted lease when the output file cannot be created', async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-integration-credential-')); - const target = path.join(directory, 'existing'); - fs.writeFileSync(target, 'preserve', { mode: 0o600 }); - const { program, deps } = harness(); - vi.mocked(deps.authorizedApiFetch) - .mockResolvedValueOnce({ response: response(credential()), auth: refreshedAuth }) - .mockResolvedValueOnce({ response: new Response(null, { status: 204 }), auth: refreshedAuth }); - try { - await expect( - program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'integration', - 'credential', - '--workspace', - 'rw_7ccfea89', - '--device-id', - 'herdr-room-device', - '--access', - 'write', - '--output-file', - target, - ]) - ).rejects.toThrow('exit:1'); - expect(fs.readFileSync(target, 'utf8')).toBe('preserve'); - } finally { - fs.rmSync(directory, { recursive: true, force: true }); - } - expect(deps.authorizedApiFetch).toHaveBeenNthCalledWith( - 2, - refreshedAuth, - '/api/v1/workspaces/rw_7ccfea89/relayfile/delegated-token/lease_1', - { method: 'DELETE' }, - { interactive: false } - ); - expect(deps.ensureCloudSession).toHaveBeenCalledTimes(1); - }); + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'integration', + 'connections', + '--workspace', + 'rw_7ccfea89', + ]); - it('reports write and rollback failure without printing either error detail', async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-integration-credential-')); - const target = path.join(directory, 'existing'); - fs.writeFileSync(target, 'preserve', { mode: 0o600 }); - const { program, deps } = harness(); - vi.mocked(deps.authorizedApiFetch) - .mockResolvedValueOnce({ response: response(credential()), auth: refreshedAuth }) - .mockRejectedValueOnce(new Error('cleanup-secret-marker')); - try { - await expect( - program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'integration', - 'credential', - '--workspace', - 'rw_7ccfea89', - '--device-id', - 'herdr-room-device', - '--access', - 'write', - '--output-file', - target, - ]) - ).rejects.toThrow('exit:1'); - } finally { - fs.rmSync(directory, { recursive: true, force: true }); - } - const output = vi.mocked(deps.error).mock.calls.flat().join('\n'); - expect(output).toContain('Revocation could not be confirmed; revoke lease lease_1'); - expect(output).not.toContain('cleanup-secret-marker'); - expect(output).not.toContain('EEXIST'); + expect(deps.log).toHaveBeenCalledWith('linear connected'); }); - it('revokes a device-scoped credential lease without putting secrets in argv', async () => { + it('creates a provider connection session', async () => { const { program, deps } = harness(); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: new Response(null, { status: 204 }), + response: response({ + connectLink: 'https://cloud.test/connect/opaque', + workspaceId: '00000000-0000-4000-8000-000000000020', + relayWorkspaceId: 'rw_7ccfea89', + backend: 'nango', + providers: [{ id: 'linear' }], + expiresAt: '2026-07-30T00:00:00.000Z', + }), auth, }); @@ -364,61 +187,53 @@ describe('registerCloudIntegrationCommands', () => { 'agent-relay', 'cloud', 'integration', - 'revoke-credential', - 'lease_1', + 'connect', + 'linear', '--workspace', 'rw_7ccfea89', - '--json', + '--backend', + 'nango', ]); expect(deps.authorizedApiFetch).toHaveBeenCalledWith( auth, - '/api/v1/workspaces/rw_7ccfea89/relayfile/delegated-token/lease_1', - { method: 'DELETE' }, + '/api/v1/workspaces/rw_7ccfea89/integrations/connect-session', + { + method: 'POST', + body: JSON.stringify({ + allowedIntegrations: ['linear'], + requestedBackend: 'nango', + }), + }, { interactive: false } ); + expect(deps.log).toHaveBeenCalledWith('https://cloud.test/connect/opaque'); }); - it('strips server credential fields from a connection session response', async () => { + it('disconnects a provider through the existing status endpoint', async () => { const { program, deps } = harness(); - vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: response({ - connectLink: 'https://connect.test/session', - workspaceId: 'app_workspace', - relayWorkspaceId: 'rw_7ccfea89', - backend: 'nango', - providers: [ - { - id: 'linear', - displayName: 'Linear', - backendMetadata: { sessionToken: 'nested-must-not-print' }, - }, - ], - token: 'must-not-print', - sessionToken: 'must-not-print', - }), - auth, - }); await program.parseAsync([ 'node', 'agent-relay', 'cloud', 'integration', - 'connect', + 'disconnect', 'linear', '--workspace', 'rw_7ccfea89', - '--json', ]); - const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); - expect(output).toContain('https://connect.test/session'); - expect(output).toContain('"linear"'); - expect(output).not.toContain('must-not-print'); + expect(deps.authorizedApiFetch).toHaveBeenCalledWith( + auth, + '/api/v1/workspaces/rw_7ccfea89/integrations/linear/status', + { method: 'DELETE' }, + { interactive: false } + ); + expect(deps.log).toHaveBeenCalledWith('Disconnected linear.'); }); - it('fails closed before forwarding Cloud auth to a mismatched API host', async () => { + it('rejects invalid workspace and provider IDs before authenticating', async () => { const { program, deps } = harness(); await expect( @@ -427,14 +242,49 @@ describe('registerCloudIntegrationCommands', () => { 'agent-relay', 'cloud', 'integration', - 'catalog', + 'connect', + '../linear', '--workspace', - 'rw_7ccfea89', + 'not-a-workspace', + ]) + ).rejects.toThrow('exit:1'); + + expect(deps.ensureCloudSession).not.toHaveBeenCalled(); + }); + + it('does not reuse a login bound to a different explicit API host', async () => { + const { program, deps } = harness(); + + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'integration', + 'catalog', '--api-url', - 'http://127.0.0.1:4310', + 'https://other.test', ]) ).rejects.toThrow('exit:1'); expect(deps.authorizedApiFetch).not.toHaveBeenCalled(); + expect(deps.error).toHaveBeenCalledWith(expect.stringContaining('Cloud login is bound to')); + }); + + it('maps authorization failures to a stable error without reflecting response bodies', async () => { + const { program, deps } = harness(); + vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ + response: response({ error: 'private server detail' }, 403), + auth, + }); + + await expect( + program.parseAsync(['node', 'agent-relay', 'cloud', 'integration', 'catalog']) + ).rejects.toThrow('exit:1'); + + expect(deps.error).toHaveBeenCalledWith( + 'You do not have permission to perform that integration operation.' + ); + expect(vi.mocked(deps.error).mock.calls.flat().join('\n')).not.toContain('private server detail'); }); }); diff --git a/packages/cli/src/cli/commands/cloud-integration.ts b/packages/cli/src/cli/commands/cloud-integration.ts index b11e15592..ea5d3e80c 100644 --- a/packages/cli/src/cli/commands/cloud-integration.ts +++ b/packages/cli/src/cli/commands/cloud-integration.ts @@ -1,5 +1,3 @@ -import fs from 'node:fs/promises'; -import { constants as fsConstants } from 'node:fs'; import { Command, InvalidArgumentError } from 'commander'; import { defaultApiUrl } from '@agent-relay/cloud'; @@ -15,11 +13,7 @@ type CloudAuth = Awaited>['a const WORKSPACE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const RELAY_WORKSPACE = /^rw_[a-z0-9]{8}$/; -const RESOURCE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; const PROVIDER_ID = /^[a-z0-9][a-z0-9_-]{0,127}$/; -const MAX_SECRET_BYTES = 512 * 1024; -const DEFAULT_CREDENTIAL_TTL = 3_600; -const DEFAULT_DELEGATION_TTL = 86_400; function isObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); @@ -37,18 +31,6 @@ function string(value: unknown, label: string): string { return value.trim(); } -function nullableString(value: unknown, label: string): string | null { - if (value === null) return null; - return string(value, label); -} - -function stringArray(value: unknown, label: string): string[] { - if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) { - throw new Error(`Cloud returned an invalid ${label} response.`); - } - return value.map((entry) => string(entry, label)); -} - function connectionProviderIds(value: unknown): string[] { if (!Array.isArray(value)) { throw new Error('Cloud returned an invalid integration connection response.'); @@ -69,65 +51,12 @@ function workspaceId(value: string): string { return normalized; } -function resourceId(value: string, label: string): string { - const normalized = value.trim(); - if (!RESOURCE_ID.test(normalized)) throw new Error(`Invalid ${label}.`); - return normalized; -} - function providerId(value: string): string { const normalized = value.trim().toLowerCase(); if (!PROVIDER_ID.test(normalized)) throw new Error('Invalid integration provider ID.'); return normalized; } -function deviceId(value: string): string { - const normalized = value.trim(); - if ( - !normalized || - normalized.length > 255 || - // eslint-disable-next-line no-control-regex - /[\u0000-\u001f\u007f-\u009f]/.test(normalized) - ) { - throw new InvalidArgumentError( - 'Expected a non-empty device ID of at most 255 characters without control characters.' - ); - } - return normalized; -} - -function canonicalPath(value: string): string { - const normalized = value.trim(); - if ( - !normalized.startsWith('/') || - normalized.length > 2_048 || - normalized.includes('\0') || - normalized.includes('\\') || - normalized.split('/').some((part) => part === '.' || part === '..') - ) { - throw new Error('Integration paths must be canonical absolute Relayfile paths.'); - } - return normalized; -} - -function positiveInt(value: string): number { - if (!/^[1-9][0-9]*$/.test(value)) { - throw new InvalidArgumentError('Expected a positive whole number.'); - } - const parsed = Number(value); - if (!Number.isSafeInteger(parsed)) throw new InvalidArgumentError('Expected a safe whole number.'); - return parsed; -} - -function pathList(value: string, previous: string[] = []): string[] { - return [...previous, canonicalPath(value)]; -} - -function access(value: string): 'read' | 'write' { - if (value === 'read' || value === 'write') return value; - throw new InvalidArgumentError('Expected access to be one of: read, write'); -} - function backend(value: string): 'nango' | 'composio' { if (value === 'nango' || value === 'composio') return value; throw new InvalidArgumentError('Expected backend to be one of: nango, composio'); @@ -180,7 +109,14 @@ async function requestWithAuth( priorAuth?: CloudAuth ): Promise<{ payload: unknown; auth: CloudAuth }> { const requested = apiUrl ?? defaultApiUrl(); - const auth = priorAuth ?? (await deps.ensureCloudSession({ apiUrl: requested, interactive: false })).auth; + const auth = + priorAuth ?? + ( + await deps.ensureCloudSession({ + apiUrl: requested, + interactive: false, + }) + ).auth; if (apiUrl && canonicalApiUrl(auth.apiUrl) !== canonicalApiUrl(requested)) { throw new Error( `Cloud login is bound to ${canonicalApiUrl( @@ -254,139 +190,59 @@ function normalizeCatalog(payload: unknown): { return { providers: response.providers.map((entry) => { const provider = object(entry, 'integration provider'); - const capabilities = object(provider.capabilities, 'integration capabilities'); - if ( - typeof capabilities.connect !== 'boolean' || - typeof capabilities.read !== 'boolean' || - typeof capabilities.writeback !== 'boolean' - ) { - throw new Error('Cloud returned invalid integration capabilities.'); - } + string(provider.id, 'integration provider'); return sanitize(provider) as Record; }), version: string(response.version, 'integration catalog'), }; } -function normalizeGrant(value: unknown): Record { - const grant = object(value, 'integration grant'); - const normalized = { - id: string(grant.id, 'integration grant'), - workspaceId: string(grant.workspaceId, 'integration grant'), - memberId: string(grant.memberId, 'integration grant'), - userId: string(grant.userId, 'integration grant'), - provider: string(grant.provider, 'integration grant'), - allowedPaths: stringArray(grant.allowedPaths, 'integration grant'), - canRead: grant.canRead, - canWrite: grant.canWrite, - createdAt: string(grant.createdAt, 'integration grant'), - updatedAt: string(grant.updatedAt, 'integration grant'), - }; - if (typeof normalized.canRead !== 'boolean' || typeof normalized.canWrite !== 'boolean') { - throw new Error('Cloud returned an invalid integration grant response.'); - } - return normalized; -} - -function normalizeGrants(payload: unknown): { grants: Array> } { - const response = object(payload, 'integration grants'); - if (!Array.isArray(response.grants)) { - throw new Error('Cloud returned an invalid integration grants response.'); - } - return { grants: response.grants.map(normalizeGrant) }; -} - -function normalizeGrantCreate(payload: unknown): { grant: Record } { - return { grant: normalizeGrant(object(payload, 'integration grant').grant) }; -} - -type DelegatedCredential = { - leaseId: string; - relayfileUrl: string; - relayauthUrl: string; - refreshUrl: string; - relayfileWorkspaceId: string; - relayfileToken: string | null; - relayfileTokenExpiresAt: string | null; - relayfileRefreshToken: string | null; - relayfileRefreshTokenExpiresAt: string | null; - relayfileScopes: string[]; - delegationNotAfter: string | null; - relayfileMountPaths: string[]; -}; - -function serviceUrl(value: unknown, label: string): string { - const raw = string(value, label); - const canonical = canonicalApiUrl(raw); - return canonical; -} - -function normalizeCredential(payload: unknown): DelegatedCredential { - const candidate = object( - isObject(payload) && payload.credential !== undefined ? payload.credential : payload, - 'delegated credential' - ); - return { - leaseId: resourceId(string(candidate.leaseId, 'delegated credential'), 'credential lease ID'), - relayfileUrl: serviceUrl(candidate.relayfileUrl, 'delegated credential'), - relayauthUrl: serviceUrl(candidate.relayauthUrl, 'delegated credential'), - refreshUrl: serviceUrl(candidate.refreshUrl, 'delegated credential'), - relayfileWorkspaceId: string(candidate.relayfileWorkspaceId, 'delegated credential'), - relayfileToken: nullableString(candidate.relayfileToken, 'delegated credential'), - relayfileTokenExpiresAt: nullableString(candidate.relayfileTokenExpiresAt, 'delegated credential'), - relayfileRefreshToken: nullableString(candidate.relayfileRefreshToken, 'delegated credential'), - relayfileRefreshTokenExpiresAt: nullableString( - candidate.relayfileRefreshTokenExpiresAt, - 'delegated credential' - ), - relayfileScopes: stringArray(candidate.relayfileScopes, 'delegated credential'), - delegationNotAfter: nullableString(candidate.delegationNotAfter, 'delegated credential'), - relayfileMountPaths: stringArray(candidate.relayfileMountPaths, 'delegated credential'), - }; -} - -async function writeCredentialFile(path: string, credential: DelegatedCredential): Promise { - const payload = `${JSON.stringify(credential, null, 2)}\n`; - if (Buffer.byteLength(payload) > MAX_SECRET_BYTES) { - throw new Error('Delegated credential response exceeded the safe output limit.'); - } - const noFollow = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW; - const handle = await fs.open( - path, - fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, - 0o600 - ); - try { - await handle.writeFile(payload, 'utf8'); - await handle.sync(); - } finally { - await handle.close(); - } -} - function renderProviders(catalog: ReturnType, deps: Dependencies): void { for (const provider of catalog.providers) { - const capabilities = provider.capabilities as Record; + const backends = Array.isArray(provider.backends) + ? provider.backends.map(String) + : [provider.backend].filter(Boolean).map(String); deps.log( [ terminal(String(provider.id ?? 'unknown')), - capabilities.read ? 'read' : 'no-read', - capabilities.writeback ? 'write' : 'no-write', - capabilities.connect ? 'connect' : 'no-connect', + backends.length > 0 ? backends.join(',') : 'backend-unspecified', ].join(' ') ); } } +function renderConnections(payload: unknown, deps: Dependencies): void { + if (!Array.isArray(payload)) { + throw new Error('Cloud returned an invalid integration connection list.'); + } + if (payload.length === 0) { + deps.log('No connected workspace integrations.'); + return; + } + for (const entry of payload) { + const connection = object(entry, 'integration connection'); + const id = [connection.id, connection.provider, connection.providerId].find( + (value) => typeof value === 'string' && value.trim() + ); + if (typeof id !== 'string') { + throw new Error('Cloud returned an invalid integration connection list.'); + } + const status = + typeof connection.status === 'string' && connection.status.trim() + ? terminal(connection.status) + : undefined; + deps.log([terminal(id), status].filter(Boolean).join(' ')); + } +} + export function registerCloudIntegrationCommands(cloudCommand: Command, deps: Dependencies): void { const integration = cloudCommand .command('integration') - .description('Manage Cloud integrations and delegated Relayfile access'); + .description('Manage Agent Relay Cloud integration connections'); integration .command('catalog') - .description('Discover integrations and their truthful capabilities') - .option('--workspace ', 'Optional Cloud UUID or unified rw_ workspace context') + .description('Discover static and dynamic Cloud integrations') .option('--api-url ', 'Cloud API base URL') .option('--static', 'Exclude dynamic Nango and Composio catalog entries') .option('--search ', 'Filter providers by ID or display name') @@ -394,7 +250,6 @@ export function registerCloudIntegrationCommands(cloudCommand: Command, deps: De .option('--json', 'Output the integration catalog as JSON') .action( async (options: { - workspace?: string; apiUrl?: string; static?: boolean; search?: string; @@ -402,7 +257,6 @@ export function registerCloudIntegrationCommands(cloudCommand: Command, deps: De json?: boolean; }) => { await action(deps, async () => { - if (options.workspace) workspaceId(options.workspace); const catalog = normalizeCatalog( await request( deps, @@ -451,7 +305,7 @@ export function registerCloudIntegrationCommands(cloudCommand: Command, deps: De ) ); if (options.json) json(deps, payload); - else json(deps, payload); + else renderConnections(payload, deps); }); }); @@ -462,7 +316,7 @@ export function registerCloudIntegrationCommands(cloudCommand: Command, deps: De .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') .option('--backend ', 'Connection backend: nango or composio', backend) .option('--api-url ', 'Cloud API base URL') - .option('--json', 'Output the safe connection-session details as JSON') + .option('--json', 'Output safe connection-session details as JSON') .action( async ( providerInput: string, @@ -530,207 +384,4 @@ export function registerCloudIntegrationCommands(cloudCommand: Command, deps: De }); } ); - - integration - .command('grants') - .description('List room integration grants') - .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') - .option('--api-url ', 'Cloud API base URL') - .option('--json', 'Output grants as JSON') - .action(async (options: { workspace: string; apiUrl?: string; json?: boolean }) => { - await action(deps, async () => { - const id = workspaceId(options.workspace); - const payload = normalizeGrants( - await request( - deps, - `/api/v1/workspaces/${encodeURIComponent(id)}/room/integration-grants`, - { method: 'GET' }, - options.apiUrl - ) - ); - if (options.json) json(deps, payload); - else json(deps, payload); - }); - }); - - integration - .command('grant') - .description('Grant a room member bounded integration access') - .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') - .requiredOption('--member ', 'Room membership ID') - .requiredOption('--provider ', 'Provider ID from the catalog') - .requiredOption('--path ', 'Canonical allowed path; repeat for more paths', pathList, []) - .requiredOption('--access ', 'Grant read or write access', access) - .option('--api-url ', 'Cloud API base URL') - .option('--json', 'Output the grant as JSON') - .action( - async (options: { - workspace: string; - member: string; - provider: string; - path: string[]; - access: 'read' | 'write'; - apiUrl?: string; - json?: boolean; - }) => { - await action(deps, async () => { - const id = workspaceId(options.workspace); - const payload = normalizeGrantCreate( - await request( - deps, - `/api/v1/workspaces/${encodeURIComponent(id)}/room/integration-grants`, - { - method: 'POST', - body: JSON.stringify({ - memberId: resourceId(options.member, 'membership ID'), - provider: providerId(options.provider), - allowedPaths: options.path, - canRead: true, - canWrite: options.access === 'write', - }), - }, - options.apiUrl - ) - ); - if (options.json) json(deps, payload); - else deps.log(`Integration grant ${terminal(String(payload.grant.id))} is active.`); - }); - } - ); - - integration - .command('revoke-grant') - .description('Revoke a room integration grant and its delegated credentials') - .argument('', 'Integration grant ID') - .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') - .option('--api-url ', 'Cloud API base URL') - .option('--json', 'Output the revocation result as JSON') - .action(async (grantInput: string, options: { workspace: string; apiUrl?: string; json?: boolean }) => { - await action(deps, async () => { - const id = workspaceId(options.workspace); - const grant = resourceId(grantInput, 'grant ID'); - await request( - deps, - `/api/v1/workspaces/${encodeURIComponent(id)}/room/integration-grants/${encodeURIComponent(grant)}`, - { method: 'DELETE' }, - options.apiUrl - ); - if (options.json) json(deps, { success: true }); - else deps.log(`Revoked integration grant ${terminal(grant)}.`); - }); - }); - - integration - .command('credential') - .description('Mint a grant-bounded delegated Relayfile credential lease') - .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') - .requiredOption( - '--device-id ', - 'Stable non-secret ID for this Herdr/Relayfile device', - deviceId - ) - .requiredOption('--access ', 'Request read or write access', access) - .option('--path ', 'Request a granted path; repeat for more paths', pathList, []) - .option('--ttl ', 'Access-token lifetime', positiveInt, DEFAULT_CREDENTIAL_TTL) - .option( - '--delegation-ttl ', - 'Maximum refresh/delegation lifetime', - positiveInt, - DEFAULT_DELEGATION_TTL - ) - .option('--api-url ', 'Cloud API base URL') - .option('--output-file ', 'Write the credential to a new owner-only 0600 file') - .option('--json', 'Output the delegated credential, including its secrets, as JSON') - .action( - async (options: { - workspace: string; - deviceId: string; - access: 'read' | 'write'; - path: string[]; - ttl: number; - delegationTtl: number; - apiUrl?: string; - outputFile?: string; - json?: boolean; - }) => { - await action(deps, async () => { - if (Boolean(options.outputFile) === Boolean(options.json)) { - throw new Error('Use exactly one credential sink: --output-file or --json.'); - } - if (options.ttl > 3_600 || options.delegationTtl > 86_400) { - throw new Error('Credential TTL exceeds the Cloud maximum.'); - } - const id = workspaceId(options.workspace); - const minted = await requestWithAuth( - deps, - `/api/v1/workspaces/${encodeURIComponent(id)}/relayfile/delegated-token`, - { - method: 'POST', - body: JSON.stringify({ - deviceId: options.deviceId, - scopes: options.access === 'write' ? ['fs:read', 'fs:write'] : ['fs:read'], - ...(options.path.length > 0 ? { relayfileMountPaths: options.path } : {}), - ttlSeconds: options.ttl, - delegationTtlSeconds: options.delegationTtl, - }), - }, - options.apiUrl - ); - const credential = normalizeCredential(minted.payload); - if (options.json) { - json(deps, credential); - return; - } - try { - await writeCredentialFile(options.outputFile ?? '', credential); - } catch (writeError) { - try { - await requestWithAuth( - deps, - `/api/v1/workspaces/${encodeURIComponent( - id - )}/relayfile/delegated-token/${encodeURIComponent(credential.leaseId)}`, - { method: 'DELETE' }, - options.apiUrl, - minted.auth - ); - } catch (cleanupError) { - throw new AggregateError( - [writeError, cleanupError], - `Could not write the delegated credential. Revocation could not be confirmed; revoke lease ${terminal( - credential.leaseId - )} before retrying.`, - { cause: writeError } - ); - } - throw writeError; - } - deps.log(`Wrote delegated Relayfile credential lease ${terminal(credential.leaseId)}.`); - }); - } - ); - - integration - .command('revoke-credential') - .description('Revoke this member’s delegated Relayfile credential lease') - .argument('', 'Credential lease ID') - .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') - .option('--api-url ', 'Cloud API base URL') - .option('--json', 'Output the revocation result as JSON') - .action(async (leaseInput: string, options: { workspace: string; apiUrl?: string; json?: boolean }) => { - await action(deps, async () => { - const id = workspaceId(options.workspace); - const lease = resourceId(leaseInput, 'credential lease ID'); - await request( - deps, - `/api/v1/workspaces/${encodeURIComponent( - id - )}/relayfile/delegated-token/${encodeURIComponent(lease)}`, - { method: 'DELETE' }, - options.apiUrl - ); - if (options.json) json(deps, { success: true }); - else deps.log(`Revoked delegated Relayfile credential lease ${terminal(lease)}.`); - }); - }); } diff --git a/packages/cli/src/cli/commands/cloud-room.test.ts b/packages/cli/src/cli/commands/cloud-room.test.ts index b6491b1f4..081338412 100644 --- a/packages/cli/src/cli/commands/cloud-room.test.ts +++ b/packages/cli/src/cli/commands/cloud-room.test.ts @@ -22,11 +22,6 @@ const auth = { refreshToken: 'refresh-secret', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', }; -const refreshedAuth = { - ...auth, - accessToken: 'refreshed-access-secret', - refreshToken: 'rotated-refresh-secret', -}; function jsonResponse(body: unknown, status = 200, headers?: HeadersInit): Response { return new Response(JSON.stringify(body), { @@ -35,6 +30,19 @@ function jsonResponse(body: unknown, status = 200, headers?: HeadersInit): Respo }); } +function invite(token = 'herdr_inv_single_use_secret') { + return { + invite: { + id: 'invite_1', + email: 'person@example.com', + role: 'participant', + token, + expiresAt: '2026-07-30T00:00:00.000Z', + createdAt: '2026-07-23T00:00:00.000Z', + }, + }; +} + function createHarness(roomIo?: Parameters[2]) { const exit = vi.fn((code: number) => { throw new Error(`exit:${code}`); @@ -61,10 +69,8 @@ beforeEach(() => { }); describe('registerCloudRoomCommands', () => { - it('registers the complete room lifecycle', () => { + it('registers the complete trusted-participant lifecycle', () => { const { room } = createHarness(); - - expect(room.name()).toBe('room'); expect(room.commands.map((command) => command.name())).toEqual([ 'invite', 'invites', @@ -77,19 +83,10 @@ describe('registerCloudRoomCommands', () => { ]); }); - it('creates an email-bound invite with a finite role and lifetime', async () => { + it('creates only participant invitations', async () => { const { program, deps } = createHarness(); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: jsonResponse({ - invite: { - id: 'invite_1', - email: 'person@example.com', - role: 'viewer', - token: 'herdr_inv_single_use_secret', - expiresAt: '2026-07-30T00:00:00.000Z', - createdAt: '2026-07-23T00:00:00.000Z', - }, - }), + response: jsonResponse(invite()), auth, }); @@ -103,17 +100,11 @@ describe('registerCloudRoomCommands', () => { 'rw_7ccfea89', '--email', 'Person@Example.com', - '--role', - 'viewer', '--expires-in', '600', '--token-stdout', ]); - expect(deps.ensureCloudSession).toHaveBeenCalledWith({ - apiUrl: 'https://cloud.test', - interactive: false, - }); expect(deps.authorizedApiFetch).toHaveBeenCalledWith( auth, '/api/v1/workspaces/rw_7ccfea89/room/invites', @@ -121,7 +112,7 @@ describe('registerCloudRoomCommands', () => { method: 'POST', body: JSON.stringify({ email: 'person@example.com', - role: 'viewer', + role: 'participant', expiresInSeconds: 600, }), }, @@ -130,56 +121,14 @@ describe('registerCloudRoomCommands', () => { expect(deps.log).toHaveBeenCalledWith('herdr_inv_single_use_secret'); }); - it('sends an invitation by email without returning its one-time token', async () => { - const { program, deps } = createHarness(); - vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: jsonResponse({ - invite: { - id: 'invite_1', - email: 'person@example.com', - role: 'participant', - expiresAt: '2026-07-30T00:00:00.000Z', - createdAt: '2026-07-23T00:00:00.000Z', - }, - delivery: { mode: 'email', status: 'sent' }, - }), - auth, - }); - - await program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'room', - 'invite', - '--workspace', - 'rw_7ccfea89', - '--email', - 'person@example.com', - '--email-delivery', - '--json', - ]); - - expect(deps.authorizedApiFetch).toHaveBeenCalledWith( - auth, - '/api/v1/workspaces/rw_7ccfea89/room/invites', - { - method: 'POST', - body: JSON.stringify({ - email: 'person@example.com', - role: 'participant', - expiresInSeconds: 604800, - delivery: 'email', - }), - }, - { interactive: false } - ); - const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); - expect(output).toContain('"status": "sent"'); - expect(output).not.toContain('herdr_inv_'); + it('does not expose viewer or email-delivery invite options', () => { + const { room } = createHarness(); + const inviteCommand = room.commands.find((command) => command.name() === 'invite'); + expect(inviteCommand?.options.map((option) => option.long)).not.toContain('--role'); + expect(inviteCommand?.options.map((option) => option.long)).not.toContain('--email-delivery'); }); - it('requires explicit email delivery or one token sink before authenticating', async () => { + it('requires exactly one invitation token sink before authenticating', async () => { const { program, deps } = createHarness(); await expect( @@ -198,47 +147,16 @@ describe('registerCloudRoomCommands', () => { expect(deps.ensureCloudSession).not.toHaveBeenCalled(); expect(deps.error).toHaveBeenCalledWith( - 'Use --email-delivery (optionally with --json), or exactly one manual token sink: --token-stdout, --token-file, or --json.' + 'Use exactly one invitation token sink: --token-stdout, --token-file, or --json.' ); }); - it('rejects combining email delivery with a manual token sink before authenticating', async () => { - const { program, deps } = createHarness(); - - await expect( - program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'room', - 'invite', - '--workspace', - 'rw_7ccfea89', - '--email', - 'person@example.com', - '--email-delivery', - '--token-stdout', - ]) - ).rejects.toThrow('exit:1'); - - expect(deps.ensureCloudSession).not.toHaveBeenCalled(); - }); - - it('writes an invitation token only to a new owner-only file', async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-room-invite-output-')); - const tokenFile = path.join(directory, 'invite-token'); + it('writes a token only to a new owner-only file', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-room-invite-')); + const tokenFile = path.join(directory, 'token'); const { program, deps } = createHarness(); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: jsonResponse({ - invite: { - id: 'invite_1', - email: 'person@example.com', - role: 'participant', - token: 'herdr_inv_file_secret', - expiresAt: '2026-07-30T00:00:00.000Z', - createdAt: '2026-07-23T00:00:00.000Z', - }, - }), + response: jsonResponse(invite('herdr_inv_file_secret')), auth, }); @@ -258,169 +176,51 @@ describe('registerCloudRoomCommands', () => { ]); expect(fs.readFileSync(tokenFile, 'utf8')).toBe('herdr_inv_file_secret\n'); if (process.platform !== 'win32') { - expect(fs.statSync(tokenFile).mode & 0o077).toBe(0); + expect(fs.statSync(tokenFile).mode & 0o777).toBe(0o600); } } finally { fs.rmSync(directory, { recursive: true, force: true }); } - expect(vi.mocked(deps.log).mock.calls.flat().join('\n')).not.toContain('herdr_inv_file_secret'); }); - it('revokes a newly created invite when its token file cannot be created', async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-room-invite-output-')); - const tokenFile = path.join(directory, 'existing'); - fs.writeFileSync(tokenFile, 'do-not-overwrite', { mode: 0o600 }); - const { program, deps } = createHarness(); + it('revokes a newly-created invite when its token cannot be written', async () => { + const { program, deps } = createHarness({ + writeSecretFile: vi.fn(async () => { + throw new Error('disk full'); + }), + }); vi.mocked(deps.authorizedApiFetch) - .mockResolvedValueOnce({ - response: jsonResponse({ - invite: { - id: 'invite_1', - email: 'person@example.com', - role: 'participant', - token: 'herdr_inv_lost_secret', - expiresAt: '2026-07-30T00:00:00.000Z', - createdAt: '2026-07-23T00:00:00.000Z', - }, - }), - auth: refreshedAuth, - }) - .mockResolvedValueOnce({ - response: new Response(null, { status: 204 }), - auth: refreshedAuth, - }); + .mockResolvedValueOnce({ response: jsonResponse(invite()), auth }) + .mockResolvedValueOnce({ response: jsonResponse({ ok: true }), auth }); - try { - await expect( - program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'room', - 'invite', - '--workspace', - 'rw_7ccfea89', - '--email', - 'person@example.com', - '--token-file', - tokenFile, - ]) - ).rejects.toThrow('exit:1'); - expect(fs.readFileSync(tokenFile, 'utf8')).toBe('do-not-overwrite'); - } finally { - fs.rmSync(directory, { recursive: true, force: true }); - } + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'invite', + '--workspace', + 'rw_7ccfea89', + '--email', + 'person@example.com', + '--token-file', + '/unused', + ]) + ).rejects.toThrow('exit:1'); expect(deps.authorizedApiFetch).toHaveBeenNthCalledWith( 2, - refreshedAuth, + auth, '/api/v1/workspaces/rw_7ccfea89/room/invites/invite_1', { method: 'DELETE' }, { interactive: false } ); - expect(deps.ensureCloudSession).toHaveBeenCalledTimes(1); - expect( - [...vi.mocked(deps.log).mock.calls, ...vi.mocked(deps.error).mock.calls].flat().join('\n') - ).not.toContain('herdr_inv_lost_secret'); - }); - - it('reports invite write and rollback failure without printing either error detail', async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-room-token-')); - const tokenFile = path.join(directory, 'existing'); - fs.writeFileSync(tokenFile, 'do-not-overwrite', { mode: 0o600 }); - const { program, deps } = createHarness(); - vi.mocked(deps.authorizedApiFetch) - .mockResolvedValueOnce({ - response: jsonResponse({ - invite: { - id: 'invite_1', - email: 'person@example.com', - role: 'viewer', - token: 'herdr_inv_lost_secret', - expiresAt: '2026-07-30T00:00:00.000Z', - createdAt: '2026-07-23T00:00:00.000Z', - }, - }), - auth: refreshedAuth, - }) - .mockRejectedValueOnce(new Error('cleanup-secret-marker')); - try { - await expect( - program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'room', - 'invite', - '--workspace', - 'rw_7ccfea89', - '--email', - 'person@example.com', - '--token-file', - tokenFile, - ]) - ).rejects.toThrow('exit:1'); - } finally { - fs.rmSync(directory, { recursive: true, force: true }); - } - - const output = vi.mocked(deps.error).mock.calls.flat().join('\n'); - expect(output).toContain('Revocation could not be confirmed; revoke invitation invite_1'); - expect(output).not.toContain('cleanup-secret-marker'); - expect(output).not.toContain('EEXIST'); - expect(output).not.toContain('herdr_inv_lost_secret'); - }); - - it.each([ - { - args: ['invites', '--workspace', 'rw_7ccfea89', '--json'], - path: '/api/v1/workspaces/rw_7ccfea89/room/invites', - method: 'GET', - response: { invites: [] }, - }, - { - args: ['revoke-invite', 'invite_1', '--workspace', 'rw_7ccfea89', '--json'], - path: '/api/v1/workspaces/rw_7ccfea89/room/invites/invite_1', - method: 'DELETE', - response: null, - }, - { - args: ['members', '--workspace', 'rw_7ccfea89', '--json'], - path: '/api/v1/workspaces/rw_7ccfea89/room/members', - method: 'GET', - response: { members: [] }, - }, - { - args: ['remove-member', 'member_1', '--workspace', 'rw_7ccfea89', '--json'], - path: '/api/v1/workspaces/rw_7ccfea89/room/members/member_1', - method: 'DELETE', - response: null, - }, - { - args: ['revoke-session', '--workspace', 'rw_7ccfea89', '--device-id', 'herdr-desktop-1', '--json'], - path: '/api/v1/workspaces/rw_7ccfea89/room/session', - method: 'DELETE', - body: JSON.stringify({ deviceId: 'herdr-desktop-1' }), - response: null, - }, - ])('routes $args.0 through the scoped workspace API', async ({ args, path, method, body, response }) => { - const { program, deps } = createHarness(); - vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: response === null ? new Response(null, { status: 204 }) : jsonResponse(response), - auth, - }); - - await program.parseAsync(['node', 'agent-relay', 'cloud', 'room', ...args]); - - expect(deps.authorizedApiFetch).toHaveBeenCalledWith(auth, path, body ? { method, body } : { method }, { - interactive: false, - }); }); - it('accepts an invitation without echoing its token', async () => { - const token = 'herdr_inv_room_secret'; + it('accepts an invitation only from an explicit secret source', async () => { const { program, deps } = createHarness({ - readStdin: vi.fn(async () => token), + readStdin: vi.fn(async () => 'herdr_inv_accept_secret\n'), }); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ response: jsonResponse({ @@ -433,106 +233,52 @@ describe('registerCloudRoomCommands', () => { auth, }); - await program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'accept', '--token-stdin']); + await program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'accept', '--token-stdin', '--json']); expect(deps.authorizedApiFetch).toHaveBeenCalledWith( auth, '/api/v1/room/invites/accept', { method: 'POST', - body: JSON.stringify({ token }), + body: JSON.stringify({ token: 'herdr_inv_accept_secret' }), }, { interactive: false } ); - expect( - [...vi.mocked(deps.log).mock.calls, ...vi.mocked(deps.error).mock.calls].flat().join('\n') - ).not.toContain(token); }); - it('requires exactly one private invitation-token input', async () => { - const { program, deps } = createHarness(); - - await expect(program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'accept'])).rejects.toThrow( - 'exit:1' - ); - - expect(deps.error).toHaveBeenCalledWith('Use exactly one of --token-stdin or --token-file.'); - expect(deps.ensureCloudSession).not.toHaveBeenCalled(); - }); - - it('reads an invitation token from an owner-only regular file', async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-room-token-')); - const tokenFile = path.join(directory, 'invite'); - fs.writeFileSync(tokenFile, 'herdr_inv_single_use_secret\n', { mode: 0o600 }); + it('rejects non-participant member responses', async () => { const { program, deps } = createHarness(); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ response: jsonResponse({ - membership: { - id: 'member_1', - workspaceId: 'rw_7ccfea89', - role: 'viewer', - }, + members: [ + { + id: 'member_1', + userId: 'user_1', + email: 'person@example.com', + name: null, + role: 'viewer', + status: 'active', + joinedAt: '2026-07-23T00:00:00.000Z', + }, + ], }), auth, }); - try { - await program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'accept', '--token-file', tokenFile]); - } finally { - fs.rmSync(directory, { recursive: true, force: true }); - } - - expect(deps.authorizedApiFetch).toHaveBeenCalledWith( - auth, - '/api/v1/room/invites/accept', - { - method: 'POST', - body: JSON.stringify({ token: 'herdr_inv_single_use_secret' }), - }, - { interactive: false } - ); - }); - - it('rejects a symlink invitation-token file before authenticating', async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-room-token-')); - const tokenFile = path.join(directory, 'invite'); - const tokenLink = path.join(directory, 'invite-link'); - fs.writeFileSync(tokenFile, 'herdr_inv_single_use_secret\n', { mode: 0o600 }); - fs.symlinkSync(tokenFile, tokenLink); - const { program, deps } = createHarness(); - - try { - await expect( - program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'accept', '--token-file', tokenLink]) - ).rejects.toThrow('exit:1'); - } finally { - fs.rmSync(directory, { recursive: true, force: true }); - } - - expect(deps.ensureCloudSession).not.toHaveBeenCalled(); - }); - - it('rejects a malformed invitation token before authenticating', async () => { - const { program, deps } = createHarness({ - readStdin: vi.fn(async () => 'not-a-room-invitation'), - }); - await expect( - program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'accept', '--token-stdin']) + program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'members', '--workspace', 'rw_7ccfea89']) ).rejects.toThrow('exit:1'); - - expect(deps.ensureCloudSession).not.toHaveBeenCalled(); - expect(deps.error).toHaveBeenCalledWith('Invalid room invitation token.'); + expect(deps.error).toHaveBeenCalledWith('Cloud room returned an invalid member list response.'); }); - it('hides the scoped room credential unless JSON was explicitly requested', async () => { + it('creates a human Relaycast session for a participant device', async () => { const { program, deps } = createHarness(); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ response: jsonResponse({ role: 'participant', - relaycastBaseUrl: 'https://relay.test', - agentName: 'human-device-1', - agentToken: 'at_live_scoped_secret', + relaycastBaseUrl: 'https://relay.example.com', + agentName: 'human-person-device', + agentToken: 'at_live_device_secret', }), auth, }); @@ -546,130 +292,35 @@ describe('registerCloudRoomCommands', () => { '--workspace', 'rw_7ccfea89', '--device-id', - 'herdr-desktop-1', - ]); - - const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); - expect(output).toContain('Room session ready with role participant.'); - expect(output).not.toContain('at_live_scoped_secret'); - }); - - it('supports an explicit Cloud API URL only when the stored login matches it', async () => { - const { program, deps } = createHarness(); - const localAuth = { ...auth, apiUrl: 'http://127.0.0.1:8787' }; - vi.mocked(deps.ensureCloudSession).mockResolvedValueOnce({ - auth: localAuth, - client: {} as never, - }); - vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: jsonResponse({ members: [] }), - auth: localAuth, - }); - - await program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'room', - 'members', - '--workspace', - 'rw_7ccfea89', - '--api-url', - 'http://127.0.0.1:8787', + 'herdr-device', + '--json', ]); - expect(deps.ensureCloudSession).toHaveBeenCalledWith({ - apiUrl: 'http://127.0.0.1:8787', - interactive: false, - }); expect(deps.authorizedApiFetch).toHaveBeenCalledWith( - localAuth, - '/api/v1/workspaces/rw_7ccfea89/room/members', - { method: 'GET' }, + auth, + '/api/v1/workspaces/rw_7ccfea89/room/session', + { + method: 'POST', + body: JSON.stringify({ deviceId: 'herdr-device' }), + }, { interactive: false } ); + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain('"role": "participant"'); + expect(output).toContain('at_live_device_secret'); }); - it('fails closed when an explicit API URL differs from the stored login host', async () => { - const { program, deps } = createHarness(); - - await expect( - program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'room', - 'members', - '--workspace', - 'rw_7ccfea89', - '--api-url', - 'http://127.0.0.1:8787', - ]) - ).rejects.toThrow('exit:1'); - - expect(deps.authorizedApiFetch).not.toHaveBeenCalled(); - expect(deps.error).toHaveBeenCalledWith( - expect.stringContaining('cloud login --api-url http://127.0.0.1:8787 --force') - ); - }); - - it('emits the scoped room credential for an explicitly requested machine-readable session', async () => { + it('rejects observer sessions from Cloud', async () => { const { program, deps } = createHarness(); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ response: jsonResponse({ role: 'viewer', - relaycastBaseUrl: 'https://relay.test', - observerToken: 'ot_live_scoped_secret', + relaycastBaseUrl: 'https://relay.example.com', + observerToken: 'ot_live_observer', }), auth, }); - await program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'room', - 'session', - '--workspace', - 'rw_7ccfea89', - '--device-id', - 'herdr-desktop-1', - '--json', - ]); - - expect(vi.mocked(deps.log).mock.calls.flat().join('\n')).toContain('ot_live_scoped_secret'); - }); - - it.each([ - { - role: 'participant', - relaycastBaseUrl: 'https://relay.test', - agentName: 'human-device-1', - agentToken: 'workspace-owner-key', - }, - { - role: 'viewer', - relaycastBaseUrl: 'https://relay.test', - observerToken: 'workspace-owner-key', - }, - { - role: 'participant', - relaycastBaseUrl: 'http://relay.example.com', - agentName: 'human-device-1', - agentToken: 'at_live_scoped_secret', - }, - { - role: 'viewer', - relaycastBaseUrl: 'https://user:password@relay.example.com', - observerToken: 'ot_live_scoped_secret', - }, - ])('rejects unsafe or incorrectly scoped session material', async (session) => { - const { program, deps } = createHarness(); - vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: jsonResponse(session), - auth, - }); - await expect( program.parseAsync([ 'node', @@ -680,87 +331,12 @@ describe('registerCloudRoomCommands', () => { '--workspace', 'rw_7ccfea89', '--device-id', - 'herdr-desktop-1', - '--json', + 'herdr-device', ]) ).rejects.toThrow('exit:1'); - - expect(deps.error).toHaveBeenCalledWith(expect.stringContaining('invalid')); }); - it('rejects unsafe workspace selectors before authenticating', async () => { - const { program, deps } = createHarness(); - - await expect( - program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'room', - 'members', - '--workspace', - 'rk_live_workspace_secret', - ]) - ).rejects.toThrow('exit:1'); - - expect(deps.error).toHaveBeenCalledWith( - 'Unsupported Cloud workspace identifier. Use a Cloud workspace UUID or unified rw_ workspace ID.' - ); - expect(deps.ensureCloudSession).not.toHaveBeenCalled(); - expect(vi.mocked(deps.error).mock.calls.flat().join('\n')).not.toContain('rk_live_workspace_secret'); - }); - - it('does not reflect server response bodies that may contain credentials', async () => { - const { program, deps } = createHarness(); - vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: jsonResponse({ error: 'bad session at_live_should_not_leak' }, 400), - auth, - }); - - await expect( - program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'members', '--workspace', 'rw_7ccfea89']) - ).rejects.toThrow('exit:1'); - - expect(deps.error).toHaveBeenCalledWith('Cloud rejected the room request (400).'); - expect(vi.mocked(deps.error).mock.calls.flat().join('\n')).not.toContain('at_live_should_not_leak'); - }); - - it('sanitizes terminal controls and bidi overrides in human-readable room lists', async () => { - const { program, deps } = createHarness(); - vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: jsonResponse({ - members: [ - { - id: 'member_1', - userId: 'user_1', - email: '\u001b[31mmallory@example.com\n\u202e', - name: 'Mallory\nAdmin\u202e', - role: 'participant', - status: 'active', - joinedAt: '2026-07-23T00:00:00.000Z', - }, - ], - }), - auth, - }); - - await program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'room', - 'members', - '--workspace', - 'rw_7ccfea89', - ]); - - const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); - expect(output).toContain('mallory@example.com��'); - expect(output).not.toContain('\u001b'); - expect(output).not.toContain('\u202e'); - }); - - it('treats server resource IDs as opaque while encoding them into URL paths', async () => { + it('revokes the current device session', async () => { const { program, deps } = createHarness(); await program.parseAsync([ @@ -768,72 +344,26 @@ describe('registerCloudRoomCommands', () => { 'agent-relay', 'cloud', 'room', - 'revoke-invite', - 'future:id/with+safe?shape', + 'revoke-session', '--workspace', 'rw_7ccfea89', + '--device-id', + 'herdr-device', ]); expect(deps.authorizedApiFetch).toHaveBeenCalledWith( auth, - '/api/v1/workspaces/rw_7ccfea89/room/invites/future%3Aid%2Fwith%2Bsafe%3Fshape', - { method: 'DELETE' }, + '/api/v1/workspaces/rw_7ccfea89/room/session', + { + method: 'DELETE', + body: JSON.stringify({ deviceId: 'herdr-device' }), + }, { interactive: false } ); }); - it.each(['12s', '1.5', '59', '2592001'])( - 'rejects an invalid invitation lifetime %s before authenticating', - async (expiresIn) => { - const { program, deps } = createHarness(); - - await expect( - program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'room', - 'invite', - '--workspace', - 'rw_7ccfea89', - '--email', - 'person@example.com', - '--expires-in', - expiresIn, - ]) - ).rejects.toThrow(); - - expect(deps.ensureCloudSession).not.toHaveBeenCalled(); - } - ); - - it.each([ - ['invitation', ['invite', '--workspace', 'rw_7ccfea89', '--email', 'p@example.com', '--token-stdout']], - ['invitation list', ['invites', '--workspace', 'rw_7ccfea89']], - ['member list', ['members', '--workspace', 'rw_7ccfea89']], - ['membership', ['accept', '--token-stdin']], - ['session', ['session', '--workspace', 'rw_7ccfea89', '--device-id', 'herdr-1']], - ])('rejects a malformed successful %s response', async (_label, args) => { - const { program, deps } = createHarness({ - readStdin: vi.fn(async () => 'herdr_inv_single_use_secret'), - }); - - await expect(program.parseAsync(['node', 'agent-relay', 'cloud', 'room', ...args])).rejects.toThrow( - 'exit:1' - ); - - expect(deps.error).toHaveBeenCalledWith(expect.stringContaining('invalid')); - }); - - it('rejects forbidden workspace credentials even in a successful response', async () => { + it('does not reuse a login bound to another explicit API host', async () => { const { program, deps } = createHarness(); - vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: jsonResponse({ - members: [], - workspaceKey: 'rk_live_must_not_escape', - }), - auth, - }); await expect( program.parseAsync([ @@ -844,57 +374,26 @@ describe('registerCloudRoomCommands', () => { 'members', '--workspace', 'rw_7ccfea89', - '--json', + '--api-url', + 'https://other.test', ]) ).rejects.toThrow('exit:1'); - const output = [...vi.mocked(deps.log).mock.calls, ...vi.mocked(deps.error).mock.calls].flat().join('\n'); - expect(output).not.toContain('rk_live_must_not_escape'); - expect(deps.error).toHaveBeenCalledWith( - 'Cloud room returned a forbidden workspace or integration credential.' - ); + expect(deps.authorizedApiFetch).not.toHaveBeenCalled(); }); - it.each([ - { - role: 'viewer', - relaycastBaseUrl: 'https://relay.test', - agentToken: 'at_wrong_role', - }, - { - role: 'participant', - relaycastBaseUrl: 'https://relay.test', - agentName: 'human-device-1', - observerToken: 'ot_wrong_role', - }, - { - role: 'participant', - relaycastBaseUrl: 'https://relay.test', - agentToken: 'at_missing_name', - }, - ])('rejects a mismatched session credential matrix', async (response) => { + it('maps rate limits without reflecting response bodies', async () => { const { program, deps } = createHarness(); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: jsonResponse(response), + response: jsonResponse({ error: 'private detail' }, 429, { 'retry-after': '5' }), auth, }); await expect( - program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'room', - 'session', - '--workspace', - 'rw_7ccfea89', - '--device-id', - 'herdr-1', - ]) + program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'members', '--workspace', 'rw_7ccfea89']) ).rejects.toThrow('exit:1'); - expect(deps.error).toHaveBeenCalledWith( - expect.stringMatching(/invalid (viewer|participant) session response/) - ); + expect(deps.error).toHaveBeenCalledWith('Cloud room rate limit exceeded. Retry-After: 5 seconds.'); + expect(vi.mocked(deps.error).mock.calls.flat().join('\n')).not.toContain('private detail'); }); }); diff --git a/packages/cli/src/cli/commands/cloud-room.ts b/packages/cli/src/cli/commands/cloud-room.ts index 77c20a26b..91ee9761e 100644 --- a/packages/cli/src/cli/commands/cloud-room.ts +++ b/packages/cli/src/cli/commands/cloud-room.ts @@ -14,7 +14,7 @@ type CloudRoomDependencies = Pick< >; type CloudAuth = Awaited>['auth']; -type RoomRole = 'viewer' | 'participant'; +type RoomRole = 'participant'; const CLOUD_WORKSPACE_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const UNIFIED_WORKSPACE_ID_PATTERN = /^rw_[a-z0-9]{8}$/; @@ -72,7 +72,7 @@ function requireIsoDateField(record: Record, key: string, label function requireResponseRole(record: Record, label: string): RoomRole { const role = record.role; - if (role !== 'viewer' && role !== 'participant') { + if (role !== 'participant') { throw new Error(`Cloud room returned an invalid ${label} response.`); } return role; @@ -120,22 +120,6 @@ function normalizeInviteCreate(payload: unknown): { invite: RoomInvite & { token }; } -function normalizeEmailInviteCreate(payload: unknown): { - invite: RoomInvite; - delivery: { mode: 'email'; status: 'sent' }; -} { - const response = requireObject(payload, 'email invitation'); - const delivery = requireObject(response.delivery, 'email invitation delivery'); - if (delivery.mode !== 'email' || delivery.status !== 'sent') { - throw new Error('Cloud room returned an invalid email invitation response.'); - } - const invite = normalizeInvite(response.invite, false); - if (containsForbiddenCredentialField(response) || 'token' in requireObject(response.invite, 'invitation')) { - throw new Error('Cloud room returned a forbidden invitation credential.'); - } - return { invite, delivery: { mode: 'email', status: 'sent' } }; -} - function normalizeInviteList(payload: unknown): { invites: RoomInvite[] } { const response = requireObject(payload, 'invitation list'); if (!Array.isArray(response.invites)) { @@ -193,14 +177,12 @@ function normalizeMembership(payload: unknown): { }; } -type RoomSession = - | { role: 'viewer'; relaycastBaseUrl: string; observerToken: string } - | { - role: 'participant'; - relaycastBaseUrl: string; - agentName: string; - agentToken: string; - }; +type RoomSession = { + role: 'participant'; + relaycastBaseUrl: string; + agentName: string; + agentToken: string; +}; function normalizeRoomSession(payload: unknown): RoomSession { const response = requireObject(payload, 'session'); @@ -208,16 +190,6 @@ function normalizeRoomSession(payload: unknown): RoomSession { const relaycastBaseUrl = requireRelaycastBaseUrl( requireStringField(response, 'relaycastBaseUrl', 'session') ); - if (role === 'viewer') { - if ( - typeof response.observerToken !== 'string' || - !response.observerToken.trim().startsWith('ot_live_') || - response.agentToken !== undefined - ) { - throw new Error('Cloud room returned an invalid viewer session response.'); - } - return { role, relaycastBaseUrl, observerToken: response.observerToken.trim() }; - } if ( typeof response.agentToken !== 'string' || !response.agentToken.trim().startsWith('at_live_') || @@ -250,13 +222,6 @@ function parsePositiveInteger(value: string): number { return parsed; } -function parseRoomRole(value: string): RoomRole { - if (value === 'viewer' || value === 'participant') { - return value; - } - throw new InvalidArgumentError('Expected role to be one of: viewer, participant'); -} - function requireWorkspaceId(value: string): string { const workspaceId = value.trim(); if (!CLOUD_WORKSPACE_UUID_PATTERN.test(workspaceId) && !UNIFIED_WORKSPACE_ID_PATTERN.test(workspaceId)) { @@ -544,29 +509,25 @@ export function registerCloudRoomCommands( room .command('invite') - .description('Invite an email address to a workspace room') + .description('Invite a full participant to a workspace room') .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') .requiredOption('--email ', 'Email address bound to the invitation') .option('--api-url ', 'Cloud API base URL') - .option('--role ', 'Room role: viewer or participant', parseRoomRole, 'participant') .option( '--expires-in ', 'Invitation lifetime in seconds', parsePositiveInteger, DEFAULT_INVITATION_LIFETIME_SECONDS ) - .option('--email-delivery', 'Send the invitation through Agent Relay Cloud email') .option('--token-stdout', 'Print only the one-time invitation token') .option('--token-file ', 'Write the token to a new owner-only 0600 file') - .option('--json', 'Output the invitation as JSON; manual delivery includes its one-time token') + .option('--json', 'Output the invitation and its one-time token as JSON') .action( async (options: { workspace: string; email: string; - role: RoomRole; expiresIn: number; apiUrl?: string; - emailDelivery?: boolean; tokenStdout?: boolean; tokenFile?: string; json?: boolean; @@ -575,12 +536,9 @@ export function registerCloudRoomCommands( const manualSinkCount = [options.tokenStdout, Boolean(options.tokenFile), options.json].filter( Boolean ).length; - if ( - (options.emailDelivery && (options.tokenStdout || options.tokenFile)) || - (!options.emailDelivery && manualSinkCount !== 1) - ) { + if (manualSinkCount !== 1) { throw new Error( - 'Use --email-delivery (optionally with --json), or exactly one manual token sink: --token-stdout, --token-file, or --json.' + 'Use exactly one invitation token sink: --token-stdout, --token-file, or --json.' ); } const workspaceId = requireWorkspaceId(options.workspace); @@ -592,22 +550,12 @@ export function registerCloudRoomCommands( method: 'POST', body: JSON.stringify({ email, - role: options.role, + role: 'participant', expiresInSeconds: options.expiresIn, - ...(options.emailDelivery ? { delivery: 'email' } : {}), }), }, options.apiUrl ); - if (options.emailDelivery) { - const payload = normalizeEmailInviteCreate(created.payload); - if (options.json) { - logJson(deps, payload); - return; - } - deps.log(`Sent ${options.role} room invitation to ${email}.`); - return; - } const payload = normalizeInviteCreate(created.payload); if (options.json) { logJson(deps, payload); @@ -641,7 +589,7 @@ export function registerCloudRoomCommands( } throw writeError; } - deps.log(`Created ${options.role} room invitation for ${email}.`); + deps.log(`Created full-participant room invitation for ${email}.`); deps.log( `Wrote the one-time invitation token to ${sanitizeTerminalCell(options.tokenFile ?? '')}.` ); @@ -819,11 +767,11 @@ export function registerCloudRoomCommands( room .command('session') - .description('Create or resume this device’s scoped room session') + .description('Create or resume this device’s full-participant room session') .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') .requiredOption('--device-id ', 'Stable non-secret identifier for this client') .option('--api-url ', 'Cloud API base URL') - .option('--json', 'Output the session, including its scoped participant or observer credential') + .option('--json', 'Output the session, including its participant credential') .action(async (options: { workspace: string; deviceId: string; apiUrl?: string; json?: boolean }) => { await runRoomAction(deps, async () => { const workspaceId = requireWorkspaceId(options.workspace); @@ -843,7 +791,7 @@ export function registerCloudRoomCommands( logJson(deps, payload); return; } - deps.log(`Room session ready with role ${payload.role}.`); + deps.log('Full-participant room session ready.'); deps.log('Scoped credentials are hidden. Trusted clients may request them explicitly with --json.'); }); }); From b95c855ed0adc1ef6ec809f450e41230b96f43f0 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 09:34:47 +0200 Subject: [PATCH 11/21] fix(fleet): resume requested CLI sessions --- CHANGELOG.md | 3 +- crates/broker/src/worker.rs | 187 ++++++++++++++++++-- packages/cli/README.md | 22 ++- packages/cli/src/cli/commands/cloud-room.ts | 4 +- 4 files changed, 198 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31af20902..3de6bc18e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `agent-relay cloud room` can invite trusted full workspace participants through explicit secret sinks, manage members, and establish revocable per-device Relaycast sessions without sharing the workspace key. +- `agent-relay cloud room` can invite trusted full room participants through explicit secret sinks, manage members, and establish revocable per-device Relaycast sessions without sharing the workspace key. - `agent-relay cloud integration` exposes the existing Cloud integration catalog, connection, and disconnection lifecycle from the CLI; connected providers remain available through Relayfile's normal setup, mount, and writeback flow. - `agent-relay agent me|presence` use scoped agent credentials for room-safe identity and presence checks. ### Fixed - `agent-relay node up` now binds an OS-assigned API port atomically by default, preventing concurrent Fleet nodes from racing over a probed port; `AGENT_RELAY_BROKER_PORT` remains an explicit stable-port override. +- `agent-relay fleet spawn --session-ref` now passes the requested session to Claude and Codex as a real resume operation instead of recording resume metadata while launching a fresh CLI session. ## [11.1.1] - 2026-07-23 diff --git a/crates/broker/src/worker.rs b/crates/broker/src/worker.rs index a77febbe2..1aa154587 100644 --- a/crates/broker/src/worker.rs +++ b/crates/broker/src/worker.rs @@ -350,15 +350,22 @@ impl WorkerRegistry { spec.model = Some(model); } let mut harness_session_args = Vec::new(); - if spec.session_id.is_none() { + if let Some(session_id) = spec.session_id.as_deref() { + apply_requested_session_reference( + &cli_lower, + session_id, + &mut effective_args, + &mut harness_session_args, + )?; + } else { if is_claude { spec.session_id = prepare_claude_session_args(&mut effective_args); } else if is_codex { match codex_session_reference(&effective_args) { - CodexSessionReference::Known(thread_id) => { + CodexSessionReference::Resume(thread_id) => { spec.session_id = Some(thread_id); } - CodexSessionReference::Unknown => {} + CodexSessionReference::Fork(_) | CodexSessionReference::Unknown => {} CodexSessionReference::None => { if codex_has_positional_arg(&effective_args) { tracing::debug!( @@ -582,15 +589,23 @@ impl WorkerRegistry { spec.model = Some(model); } let mut harness_session_args = Vec::new(); - if spec.session_id.is_none() { + if let Some(session_id) = spec.session_id.as_deref() { + apply_requested_session_reference( + &cli_lower, + session_id, + &mut effective_args, + &mut harness_session_args, + )?; + } else { if is_claude { spec.session_id = prepare_claude_session_args(&mut effective_args); } else if is_codex { match codex_session_reference(&effective_args) { - CodexSessionReference::Known(thread_id) => { + CodexSessionReference::Resume(thread_id) => { spec.session_id = Some(thread_id); } - CodexSessionReference::Unknown => {} + CodexSessionReference::Fork(_) | CodexSessionReference::Unknown => { + } CodexSessionReference::None => { if codex_has_positional_arg(&effective_args) { tracing::debug!( @@ -1242,7 +1257,8 @@ fn is_loopback_endpoint_host(endpoint: &reqwest::Url) -> bool { #[derive(Debug, Clone, PartialEq, Eq)] enum CodexSessionReference { - Known(String), + Resume(String), + Fork(String), Unknown, None, } @@ -1269,6 +1285,64 @@ fn prepare_claude_session_args(args: &mut Vec) -> Option { Some(session_id) } +fn apply_requested_session_reference( + cli_lower: &str, + session_id: &str, + args: &mut Vec, + harness_session_args: &mut Vec, +) -> Result<()> { + let session_id = session_id.trim(); + if session_id.is_empty() { + anyhow::bail!("session_ref must not be empty"); + } + + if cli_lower == "claude" || cli_lower.starts_with("claude:") { + if let Some(existing) = + cli_flag_value(args, "--resume").or_else(|| cli_flag_value(args, "-r")) + { + if existing != session_id { + anyhow::bail!( + "session_ref conflicts with the Claude session argument already configured" + ); + } + return Ok(()); + } + if cli_flag_present( + args, + &["--session-id", "--resume", "-r", "--continue", "-c"], + ) { + anyhow::bail!("session_ref requires an explicit Claude session id"); + } + args.push("--resume".to_string()); + args.push(session_id.to_string()); + return Ok(()); + } + + if cli_lower == "codex" { + match codex_session_reference(args) { + CodexSessionReference::Resume(existing) if existing == session_id => return Ok(()), + CodexSessionReference::Resume(_) => { + anyhow::bail!( + "session_ref conflicts with the Codex session argument already configured" + ); + } + CodexSessionReference::Fork(_) => { + anyhow::bail!("session_ref cannot be combined with a Codex fork"); + } + CodexSessionReference::Unknown => { + anyhow::bail!("session_ref requires an explicit Codex session id"); + } + CodexSessionReference::None => { + harness_session_args.push("resume".to_string()); + harness_session_args.push(session_id.to_string()); + return Ok(()); + } + } + } + + anyhow::bail!("session_ref resume is supported only for Claude and Codex PTY harnesses"); +} + fn codex_session_reference(args: &[String]) -> CodexSessionReference { let mut index = 0; let mut skip_next = false; @@ -1297,7 +1371,11 @@ fn codex_session_reference(args: &[String]) -> CodexSessionReference { if next == "--last" || next.starts_with('-') { return CodexSessionReference::Unknown; } - return CodexSessionReference::Known(next.to_string()); + return if arg == "resume" { + CodexSessionReference::Resume(next.to_string()) + } else { + CodexSessionReference::Fork(next.to_string()) + }; } index += 1; } @@ -2023,6 +2101,95 @@ mod tests { assert_eq!(args, vec!["--resume=session-2".to_string()]); } + #[test] + fn requested_session_reference_adds_claude_resume_args() { + let mut args = vec!["--model".to_string(), "claude-opus-4-1".to_string()]; + let mut harness_session_args = Vec::new(); + + apply_requested_session_reference( + "claude", + "session-claude-1", + &mut args, + &mut harness_session_args, + ) + .expect("Claude session resume"); + + assert_eq!( + args, + vec![ + "--model".to_string(), + "claude-opus-4-1".to_string(), + "--resume".to_string(), + "session-claude-1".to_string(), + ] + ); + assert!(harness_session_args.is_empty()); + } + + #[test] + fn requested_session_reference_adds_codex_resume_args() { + let mut args = vec!["--profile".to_string(), "work".to_string()]; + let mut harness_session_args = Vec::new(); + + apply_requested_session_reference( + "codex", + "thread-codex-1", + &mut args, + &mut harness_session_args, + ) + .expect("Codex session resume"); + + assert_eq!(args, vec!["--profile".to_string(), "work".to_string()]); + assert_eq!( + harness_session_args, + vec!["resume".to_string(), "thread-codex-1".to_string()] + ); + } + + #[test] + fn requested_session_reference_rejects_conflicting_cli_session() { + let mut args = vec!["resume".to_string(), "thread-other".to_string()]; + let mut harness_session_args = Vec::new(); + + let error = apply_requested_session_reference( + "codex", + "thread-requested", + &mut args, + &mut harness_session_args, + ) + .expect_err("conflicting resume must fail closed"); + + assert!(error.to_string().contains("conflicts")); + assert!(harness_session_args.is_empty()); + } + + #[test] + fn requested_session_reference_rejects_new_or_forked_sessions() { + let mut claude_args = vec!["--session-id".to_string(), "session-requested".to_string()]; + let mut claude_harness_args = Vec::new(); + let claude_error = apply_requested_session_reference( + "claude", + "session-requested", + &mut claude_args, + &mut claude_harness_args, + ) + .expect_err("a requested session must resume instead of starting"); + assert!(claude_error + .to_string() + .contains("explicit Claude session id")); + + let mut codex_args = vec!["fork".to_string(), "thread-requested".to_string()]; + let mut codex_harness_args = Vec::new(); + let codex_error = apply_requested_session_reference( + "codex", + "thread-requested", + &mut codex_args, + &mut codex_harness_args, + ) + .expect_err("a requested session must resume instead of forking"); + assert!(codex_error.to_string().contains("Codex fork")); + } + #[test] fn codex_session_reference_detects_resume_and_fork_ids() { assert_eq!( @@ -2032,11 +2199,11 @@ mod tests { "resume".into(), "thread-1".into() ]), - CodexSessionReference::Known("thread-1".to_string()) + CodexSessionReference::Resume("thread-1".to_string()) ); assert_eq!( codex_session_reference(&["fork".into(), "thread-2".into()]), - CodexSessionReference::Known("thread-2".to_string()) + CodexSessionReference::Fork("thread-2".to_string()) ); assert_eq!( codex_session_reference(&["resume".into(), "--last".into()]), diff --git a/packages/cli/README.md b/packages/cli/README.md index 51f6785be..ca5d31bdf 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -62,6 +62,13 @@ agent-relay fleet spawn codex \ --task "Use https://agentrelay.com/skill, ACK over Relay, then wait for details." \ --node sf-mini +# Resume a known Claude/Codex CLI session on its origin node. +agent-relay fleet spawn codex \ + --name api-worker \ + --task "Resume over Relay and continue the prior task." \ + --node sf-mini \ + --session-ref + # Omit --node for automatic eligible-node placement. agent-relay fleet spawn codex --name api-worker --task "Review the current diff." @@ -78,6 +85,11 @@ set `RELAY_AGENT_TOKEN` to the token returned by `agent-relay agent register `. Automatic placement and release need only the workspace key. +`--session-ref` is a real CLI resume, not a logical collaboration label. Pass +the actual Claude session ID or Codex thread ID and target its origin node. +Omit it to start a new CLI session. The project’s Agent Relay workspace remains +pinned independently until you explicitly create or select another workspace. + To run as a Cloud-managed node, first redeem a one-time enrollment token, then start the node: ```bash @@ -88,10 +100,10 @@ agent-relay node up ## Cloud multiplayer rooms Cloud room membership is scoped to one Relay workspace. Every v1 invite creates -a trusted full workspace participant: they receive their own revocable Relaycast -human credential and may use all workspace actions. The workspace key itself is -never shared, and membership does not grant Agent Relay Cloud organization -administration. +a trusted full room participant: they receive their own revocable Relaycast +human credential and may use all ordinary agent-level collaboration actions. +The workspace key itself is never shared, so owner-key administration and Agent +Relay Cloud organization administration remain owner-only. ```bash # Owner: invite and manage people in this workspace. @@ -122,7 +134,7 @@ agent-relay cloud room revoke-session \ --workspace rw_7ccfea89 \ --device-id herdr-macbook -# Participants use their scoped token for all Relaycast workspace operations; an +# Participants use their scoped token for agent-level Relaycast operations; an # ambient owner workspace key is never consulted when --token is present. agent-relay agent presence \ --token at_live_... \ diff --git a/packages/cli/src/cli/commands/cloud-room.ts b/packages/cli/src/cli/commands/cloud-room.ts index 91ee9761e..86c0418f3 100644 --- a/packages/cli/src/cli/commands/cloud-room.ts +++ b/packages/cli/src/cli/commands/cloud-room.ts @@ -589,7 +589,7 @@ export function registerCloudRoomCommands( } throw writeError; } - deps.log(`Created full-participant room invitation for ${email}.`); + deps.log(`Created full room-participant invitation for ${email}.`); deps.log( `Wrote the one-time invitation token to ${sanitizeTerminalCell(options.tokenFile ?? '')}.` ); @@ -767,7 +767,7 @@ export function registerCloudRoomCommands( room .command('session') - .description('Create or resume this device’s full-participant room session') + .description('Create or resume this device’s full room-participant session') .requiredOption('--workspace ', 'Cloud UUID or unified rw_ workspace ID') .requiredOption('--device-id ', 'Stable non-secret identifier for this client') .option('--api-url ', 'Cloud API base URL') From 3fa9b600ae09b037bc3f5bc3409257274f29698d Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 09:49:22 +0200 Subject: [PATCH 12/21] fix(fleet): harden Codex resume parsing --- crates/broker/src/worker.rs | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/broker/src/worker.rs b/crates/broker/src/worker.rs index 1aa154587..7ff0873a9 100644 --- a/crates/broker/src/worker.rs +++ b/crates/broker/src/worker.rs @@ -1364,6 +1364,15 @@ fn codex_session_reference(args: &[String]) -> CodexSessionReference { index += 1; continue; } + if arg.starts_with('-') { + if arg.contains('=') || codex_flag_without_value(arg) { + index += 1; + continue; + } + // An unknown option may consume the following token. Fail closed + // instead of mistaking that value for a resume/fork subcommand. + return CodexSessionReference::Unknown; + } if arg == "resume" || arg == "fork" { let Some(next) = args.get(index + 1).map(String::as_str) else { return CodexSessionReference::Unknown; @@ -1413,14 +1422,42 @@ fn codex_flag_consumes_next_arg(arg: &str) -> bool { "--model" | "-m" | "--profile" + | "-p" | "--config" | "-c" + | "--enable" + | "--disable" + | "--remote" + | "--remote-auth-token-env" + | "--image" + | "-i" | "--sandbox" | "-s" + | "--local-provider" | "--ask-for-approval" + | "-a" | "--approval-policy" | "--cd" + | "-C" | "--cwd" + | "--add-dir" + ) +} + +fn codex_flag_without_value(arg: &str) -> bool { + matches!( + arg, + "--strict-config" + | "--oss" + | "--dangerously-bypass-approvals-and-sandbox" + | "--dangerously-bypass-hook-trust" + | "--full-auto" + | "--search" + | "--no-alt-screen" + | "--help" + | "-h" + | "--version" + | "-V" ) } @@ -2146,6 +2183,45 @@ mod tests { ); } + #[test] + fn requested_session_reference_does_not_treat_flag_value_as_codex_resume() { + let mut args = vec!["--enable".to_string(), "resume".to_string()]; + let mut harness_session_args = Vec::new(); + + apply_requested_session_reference( + "codex", + "thread-codex-1", + &mut args, + &mut harness_session_args, + ) + .expect("Codex session resume"); + + assert_eq!(args, vec!["--enable".to_string(), "resume".to_string()]); + assert_eq!( + harness_session_args, + vec!["resume".to_string(), "thread-codex-1".to_string()] + ); + } + + #[test] + fn requested_session_reference_rejects_ambiguous_codex_option() { + let mut args = vec!["--future-option".to_string(), "resume".to_string()]; + let mut harness_session_args = Vec::new(); + + let error = apply_requested_session_reference( + "codex", + "thread-codex-1", + &mut args, + &mut harness_session_args, + ) + .expect_err("unknown Codex option arity must fail closed"); + + assert!(error + .to_string() + .contains("requires an explicit Codex session id")); + assert!(harness_session_args.is_empty()); + } + #[test] fn requested_session_reference_rejects_conflicting_cli_session() { let mut args = vec!["resume".to_string(), "thread-other".to_string()]; From f3aeb29ef58737411771aaed4760add37859df40 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 09:57:30 +0200 Subject: [PATCH 13/21] fix(fleet): order Codex resume before variadic options --- crates/broker/src/worker.rs | 175 +++++++++++++++++++++++++++--------- 1 file changed, 133 insertions(+), 42 deletions(-) diff --git a/crates/broker/src/worker.rs b/crates/broker/src/worker.rs index 7ff0873a9..a17b98eb7 100644 --- a/crates/broker/src/worker.rs +++ b/crates/broker/src/worker.rs @@ -461,27 +461,16 @@ impl WorkerRegistry { spec.model = Some(model.clone()); } - let has_extra = bypass_flag.is_some() - || model_flag.is_some() - || !effective_args.is_empty() - || !mcp_args.is_empty() - || !harness_session_args.is_empty(); - if has_extra { + let pty_cli_args = ordered_pty_cli_args( + bypass_flag, + model_flag.as_deref(), + &mcp_args, + &effective_args, + &harness_session_args, + ); + if !pty_cli_args.is_empty() { command.arg("--"); - if let Some(flag) = bypass_flag { - command.arg(flag); - } - if let Some(ref model) = model_flag { - command.arg("--model"); - command.arg(model); - } - for arg in &mcp_args { - command.arg(arg); - } - for arg in &effective_args { - command.arg(arg); - } - for arg in &harness_session_args { + for arg in &pty_cli_args { command.arg(arg); } } @@ -703,27 +692,16 @@ impl WorkerRegistry { spec.model = Some(model.clone()); } - let has_extra = bypass_flag.is_some() - || model_flag.is_some() - || !effective_args.is_empty() - || !mcp_args.is_empty() - || !harness_session_args.is_empty(); - if has_extra { + let pty_cli_args = ordered_pty_cli_args( + bypass_flag, + model_flag.as_deref(), + &mcp_args, + &effective_args, + &harness_session_args, + ); + if !pty_cli_args.is_empty() { command.arg("--"); - if let Some(flag) = bypass_flag { - command.arg(flag); - } - if let Some(ref model) = model_flag { - command.arg("--model"); - command.arg(model); - } - for arg in &mcp_args { - command.arg(arg); - } - for arg in &effective_args { - command.arg(arg); - } - for arg in &harness_session_args { + for arg in &pty_cli_args { command.arg(arg); } } @@ -1285,6 +1263,30 @@ fn prepare_claude_session_args(args: &mut Vec) -> Option { Some(session_id) } +fn ordered_pty_cli_args( + bypass_flag: Option<&str>, + model: Option<&str>, + mcp_args: &[String], + effective_args: &[String], + harness_session_args: &[String], +) -> Vec { + let mut args = Vec::new(); + if let Some(flag) = bypass_flag { + args.push(flag.to_string()); + } + if let Some(model) = model { + args.push("--model".to_string()); + args.push(model.to_string()); + } + args.extend_from_slice(mcp_args); + // Codex options such as --image are variadic and can consume an appended + // `resume `. Put the broker-owned subcommand before user options; + // Codex accepts its resume options after the session positional. + args.extend_from_slice(harness_session_args); + args.extend_from_slice(effective_args); + args +} + fn apply_requested_session_reference( cli_lower: &str, session_id: &str, @@ -1319,6 +1321,16 @@ fn apply_requested_session_reference( } if cli_lower == "codex" { + if codex_has_variadic_image_arg(args) { + if args.iter().any(|arg| arg == "resume" || arg == "fork") { + anyhow::bail!( + "session_ref cannot safely disambiguate Codex resume/fork values after --image" + ); + } + harness_session_args.push("resume".to_string()); + harness_session_args.push(session_id.to_string()); + return Ok(()); + } match codex_session_reference(args) { CodexSessionReference::Resume(existing) if existing == session_id => return Ok(()), CodexSessionReference::Resume(_) => { @@ -1356,6 +1368,9 @@ fn codex_session_reference(args: &[String]) -> CodexSessionReference { if arg == "--" { return CodexSessionReference::None; } + if codex_is_variadic_image_arg(arg) { + return CodexSessionReference::Unknown; + } if codex_flag_consumes_next_arg(arg) { if args.get(index + 1).is_none() { return CodexSessionReference::Unknown; @@ -1429,8 +1444,6 @@ fn codex_flag_consumes_next_arg(arg: &str) -> bool { | "--disable" | "--remote" | "--remote-auth-token-env" - | "--image" - | "-i" | "--sandbox" | "-s" | "--local-provider" @@ -1444,6 +1457,15 @@ fn codex_flag_consumes_next_arg(arg: &str) -> bool { ) } +fn codex_has_variadic_image_arg(args: &[String]) -> bool { + args.iter() + .any(|arg| codex_is_variadic_image_arg(arg.as_str())) +} + +fn codex_is_variadic_image_arg(arg: &str) -> bool { + arg == "--image" || arg == "-i" || arg.starts_with("--image=") || arg.starts_with("-i=") +} + fn codex_flag_without_value(arg: &str) -> bool { matches!( arg, @@ -2203,6 +2225,66 @@ mod tests { ); } + #[test] + fn requested_session_reference_precedes_variadic_codex_image_args() { + let mut args = vec!["--image".to_string(), "/tmp/review.png".to_string()]; + let mut harness_session_args = Vec::new(); + + apply_requested_session_reference( + "codex", + "thread-codex-1", + &mut args, + &mut harness_session_args, + ) + .expect("Codex session resume"); + + let ordered = ordered_pty_cli_args( + Some("--dangerously-bypass-approvals-and-sandbox"), + Some("gpt-5.4"), + &[ + "-c".to_string(), + "mcp_servers.agent-relay.enabled=true".to_string(), + ], + &args, + &harness_session_args, + ); + assert_eq!( + ordered, + vec![ + "--dangerously-bypass-approvals-and-sandbox", + "--model", + "gpt-5.4", + "-c", + "mcp_servers.agent-relay.enabled=true", + "resume", + "thread-codex-1", + "--image", + "/tmp/review.png", + ] + ); + } + + #[test] + fn requested_session_reference_rejects_ambiguous_variadic_codex_image_values() { + let mut args = vec![ + "--image".to_string(), + "/tmp/review.png".to_string(), + "resume".to_string(), + ]; + let mut harness_session_args = Vec::new(); + + let error = apply_requested_session_reference( + "codex", + "thread-codex-1", + &mut args, + &mut harness_session_args, + ) + .expect_err("ambiguous variadic values must fail closed"); + + assert!(error.to_string().contains("after --image")); + assert!(harness_session_args.is_empty()); + } + #[test] fn requested_session_reference_rejects_ambiguous_codex_option() { let mut args = vec!["--future-option".to_string(), "resume".to_string()]; @@ -2291,6 +2373,15 @@ mod tests { codex_session_reference(&["--profile".into()]), CodexSessionReference::Unknown ); + assert_eq!( + codex_session_reference(&[ + "--image".into(), + "/tmp/review.png".into(), + "resume".into(), + "thread-3".into(), + ]), + CodexSessionReference::Unknown + ); } #[test] From 4ffc1038c0044d67528ea5f0f7c3dc662107fdd0 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 10:02:35 +0200 Subject: [PATCH 14/21] fix(fleet): distinguish Codex image resume forms --- crates/broker/src/worker.rs | 93 ++++++++++++++++++++++++++++--------- 1 file changed, 70 insertions(+), 23 deletions(-) diff --git a/crates/broker/src/worker.rs b/crates/broker/src/worker.rs index a17b98eb7..8b6786824 100644 --- a/crates/broker/src/worker.rs +++ b/crates/broker/src/worker.rs @@ -365,8 +365,10 @@ impl WorkerRegistry { CodexSessionReference::Resume(thread_id) => { spec.session_id = Some(thread_id); } - CodexSessionReference::Fork(_) | CodexSessionReference::Unknown => {} - CodexSessionReference::None => { + CodexSessionReference::Fork(_) + | CodexSessionReference::AmbiguousVariadicImage + | CodexSessionReference::Unknown => {} + CodexSessionReference::None | CodexSessionReference::VariadicImage => { if codex_has_positional_arg(&effective_args) { tracing::debug!( worker = %spec.name, @@ -593,9 +595,11 @@ impl WorkerRegistry { CodexSessionReference::Resume(thread_id) => { spec.session_id = Some(thread_id); } - CodexSessionReference::Fork(_) | CodexSessionReference::Unknown => { - } - CodexSessionReference::None => { + CodexSessionReference::Fork(_) + | CodexSessionReference::AmbiguousVariadicImage + | CodexSessionReference::Unknown => {} + CodexSessionReference::None + | CodexSessionReference::VariadicImage => { if codex_has_positional_arg(&effective_args) { tracing::debug!( worker = %spec.name, @@ -1237,6 +1241,8 @@ fn is_loopback_endpoint_host(endpoint: &reqwest::Url) -> bool { enum CodexSessionReference { Resume(String), Fork(String), + VariadicImage, + AmbiguousVariadicImage, Unknown, None, } @@ -1321,16 +1327,6 @@ fn apply_requested_session_reference( } if cli_lower == "codex" { - if codex_has_variadic_image_arg(args) { - if args.iter().any(|arg| arg == "resume" || arg == "fork") { - anyhow::bail!( - "session_ref cannot safely disambiguate Codex resume/fork values after --image" - ); - } - harness_session_args.push("resume".to_string()); - harness_session_args.push(session_id.to_string()); - return Ok(()); - } match codex_session_reference(args) { CodexSessionReference::Resume(existing) if existing == session_id => return Ok(()), CodexSessionReference::Resume(_) => { @@ -1341,10 +1337,15 @@ fn apply_requested_session_reference( CodexSessionReference::Fork(_) => { anyhow::bail!("session_ref cannot be combined with a Codex fork"); } + CodexSessionReference::AmbiguousVariadicImage => { + anyhow::bail!( + "session_ref cannot safely disambiguate Codex resume/fork values after --image" + ); + } CodexSessionReference::Unknown => { anyhow::bail!("session_ref requires an explicit Codex session id"); } - CodexSessionReference::None => { + CodexSessionReference::None | CodexSessionReference::VariadicImage => { harness_session_args.push("resume".to_string()); harness_session_args.push(session_id.to_string()); return Ok(()); @@ -1369,7 +1370,14 @@ fn codex_session_reference(args: &[String]) -> CodexSessionReference { return CodexSessionReference::None; } if codex_is_variadic_image_arg(arg) { - return CodexSessionReference::Unknown; + return if args[index + 1..] + .iter() + .any(|value| value == "resume" || value == "fork") + { + CodexSessionReference::AmbiguousVariadicImage + } else { + CodexSessionReference::VariadicImage + }; } if codex_flag_consumes_next_arg(arg) { if args.get(index + 1).is_none() { @@ -1416,6 +1424,12 @@ fn codex_has_positional_arg(args: &[String]) -> bool { if arg == "--" { return true; } + if codex_is_variadic_image_arg(arg) { + // At the root command, --image consumes subsequent positional + // values. With a broker-owned resume prefix those same options are + // safely interpreted by the resume subcommand. + return false; + } if codex_flag_consumes_next_arg(arg) { skip_next = true; continue; @@ -1457,11 +1471,6 @@ fn codex_flag_consumes_next_arg(arg: &str) -> bool { ) } -fn codex_has_variadic_image_arg(args: &[String]) -> bool { - args.iter() - .any(|arg| codex_is_variadic_image_arg(arg.as_str())) -} - fn codex_is_variadic_image_arg(arg: &str) -> bool { arg == "--image" || arg == "-i" || arg.starts_with("--image=") || arg.starts_with("-i=") } @@ -2264,6 +2273,27 @@ mod tests { ); } + #[test] + fn requested_session_reference_accepts_matching_resume_before_codex_images() { + let mut args = vec![ + "resume".to_string(), + "thread-codex-1".to_string(), + "--image".to_string(), + "/tmp/review.png".to_string(), + ]; + let mut harness_session_args = Vec::new(); + + apply_requested_session_reference( + "codex", + "thread-codex-1", + &mut args, + &mut harness_session_args, + ) + .expect("matching explicit Codex resume"); + + assert!(harness_session_args.is_empty()); + } + #[test] fn requested_session_reference_rejects_ambiguous_variadic_codex_image_values() { let mut args = vec![ @@ -2380,7 +2410,20 @@ mod tests { "resume".into(), "thread-3".into(), ]), - CodexSessionReference::Unknown + CodexSessionReference::AmbiguousVariadicImage + ); + assert_eq!( + codex_session_reference(&["--image".into(), "/tmp/review.png".into()]), + CodexSessionReference::VariadicImage + ); + assert_eq!( + codex_session_reference(&[ + "resume".into(), + "thread-4".into(), + "--image".into(), + "/tmp/review.png".into(), + ]), + CodexSessionReference::Resume("thread-4".to_string()) ); } @@ -2398,6 +2441,10 @@ mod tests { "Fix the bug".into(), ])); assert!(codex_has_positional_arg(&["exec".into()])); + assert!(!codex_has_positional_arg(&[ + "--image".into(), + "/tmp/review.png".into(), + ])); } #[test] From 458cf1c49f177356cb018187f4adc381b87f7148 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 11:23:31 +0200 Subject: [PATCH 15/21] fix(fleet): advertise handlers before first spawn --- CHANGELOG.md | 1 + crates/broker/src/node_control.rs | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3de6bc18e..c640ce869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `agent-relay node up` now binds an OS-assigned API port atomically by default, preventing concurrent Fleet nodes from racing over a probed port; `AGENT_RELAY_BROKER_PORT` remains an explicit stable-port override. +- Newly connected Fleet brokers now advertise their spawn/release handlers immediately, so the first remote spawn is dispatched instead of remaining queued until load changes. - `agent-relay fleet spawn --session-ref` now passes the requested session to Claude and Codex as a real resume operation instead of recording resume metadata while launching a fresh CLI session. ## [11.1.1] - 2026-07-23 diff --git a/crates/broker/src/node_control.rs b/crates/broker/src/node_control.rs index ee7477139..edc50a974 100644 --- a/crates/broker/src/node_control.rs +++ b/crates/broker/src/node_control.rs @@ -1216,6 +1216,12 @@ fn handle_disconnected_command( resume_cursor, }) => { load.max_agents = manifest.max_agents.unwrap_or(load.max_agents); + // This control client is the broker provider, which owns the + // node's spawn/release capacity as soon as its socket connects. + // A fresh node has no workers yet, so no load transition would + // otherwise publish the first `handlers_live=true` snapshot and + // the engine would queue the very first spawn indefinitely. + load.handlers_live = true; *registration = Some(build_node_register( &manifest, &config.node_id, @@ -1543,6 +1549,7 @@ async fn run_connected_once( match command { Some(FleetControlCommand::RegisterNode { manifest, resume_cursor }) => { load.max_agents = manifest.max_agents.unwrap_or(load.max_agents); + load.handlers_live = true; let mut next = build_node_register(&manifest, &config.node_id, &config.node_name, &config.broker_version, resume_cursor); next.provider = Some(provider.clone()); node_register = next.clone(); @@ -2830,7 +2837,15 @@ mod tests { let register = next_node_to_server(&mut ws).await; assert!(matches!(register, BrokerToRelaycast::NodeRegister(_))); let heartbeat = next_node_to_server(&mut ws).await; - assert!(matches!(heartbeat, BrokerToRelaycast::NodeHeartbeat(_))); + match heartbeat { + BrokerToRelaycast::NodeHeartbeat(heartbeat) => { + assert!( + heartbeat.handlers_live, + "the broker provider must advertise capacity before the first spawn" + ); + } + other => panic!("expected initial node heartbeat, got {other:?}"), + } ws.send(Message::Text( serde_json::to_string(&RelaycastToBroker::Deliver(Deliver { From a75d541e2591291187e0f7594ecf479dbb6725f4 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 11:39:25 +0200 Subject: [PATCH 16/21] test(fleet): await action provider registration --- tests/e2e/fleet/fleet-e2e.test.ts | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/tests/e2e/fleet/fleet-e2e.test.ts b/tests/e2e/fleet/fleet-e2e.test.ts index d92dcbeec..2a2700abc 100644 --- a/tests/e2e/fleet/fleet-e2e.test.ts +++ b/tests/e2e/fleet/fleet-e2e.test.ts @@ -101,9 +101,17 @@ describe.skipIf(!pre.ok)('Cloud-enrolled node startup', () => { async () => { const nodes = await getNodes(engine, workspaceKey, { name: 'cloud-enrolled' }); const match = nodes.find((node) => node.id === 'node_cloud_enrolled'); - return match?.live && match.handlers_live ? match : null; + // The broker provider is independently ready for spawn/release before + // the config-backed action provider finishes registering. + return match?.live && + match.handlers_live && + match.capabilities.some((capability) => capability.name === 'cloud:ping') && + match.tags?.includes('cloud-enrolled') && + match.tags.includes('e2e') + ? match + : null; }, - { timeoutMs: 30_000, label: 'Cloud-enrolled node online with live handlers' } + { timeoutMs: 30_000, label: 'Cloud-enrolled node online with broker and action handlers' } ); expect(enrolled.name).toBe('cloud-enrolled'); @@ -204,9 +212,22 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { const nodes = await getNodes(engine, workspaceKey); const a = node(nodes, 'node-a'); const b = node(nodes, 'node-b'); - return a?.live && a.handlers_live && b?.live && b.handlers_live ? nodes : null; + const aCapabilities = new Set(a?.capabilities.map((capability) => capability.name)); + const bCapabilities = new Set(b?.capabilities.map((capability) => capability.name)); + // handlers_live covers the broker provider too, so wait for the + // separately connected action providers before asserting their union. + return a?.live && + a.handlers_live && + aCapabilities.has('echo') && + aCapabilities.has('work') && + b?.live && + b.handlers_live && + bCapabilities.has('ping') && + bCapabilities.has('work') + ? nodes + : null; }, - { timeoutMs: 45_000, label: 'both nodes online+handlers_live' } + { timeoutMs: 45_000, label: 'both nodes online with broker and action handlers' } ); }, 60_000); From 1d8e493fcf4d3eafbfa842ce05431b6549758a35 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 12:16:54 +0200 Subject: [PATCH 17/21] fix(fleet): carry resume session into worker --- crates/broker/src/runtime/fleet.rs | 91 +++++++++++++++++-- crates/broker/src/runtime/relaycast_events.rs | 67 ++++++++++++-- 2 files changed, 144 insertions(+), 14 deletions(-) diff --git a/crates/broker/src/runtime/fleet.rs b/crates/broker/src/runtime/fleet.rs index a4414b3b8..9a60384cf 100644 --- a/crates/broker/src/runtime/fleet.rs +++ b/crates/broker/src/runtime/fleet.rs @@ -1425,17 +1425,16 @@ mod tests { #[tokio::test] async fn action_invoke_spawn_seeds_authoritative_cursor_before_resumed_delivery() { - // An `action.invoke` spawn carrying `harnessConfig.session_id` must - // forward a non-None session_ref (and the invocation id) into the node - // `agent.register` it emits, so the spawn resumes the session and the - // invocation is correlated to the agent (Bug 2). Previously both were - // hardcoded to None on this path. + // The Fleet CLI sends `session_ref` at the top level. It must be + // forwarded with the invocation id into the node `agent.register`, so + // the spawn resumes the session and the invocation is correlated to + // the agent. let ws_value = json!({ + "session_ref": "sess-resume-7", "agent": { "harnessConfig": { "runtime": "pty", "command": "codex", - "sessionId": "sess-resume-7", } } }); @@ -1443,7 +1442,7 @@ mod tests { assert_eq!( session_ref.as_deref(), Some("sess-resume-7"), - "session ref must be derived from harnessConfig.session_id" + "session ref must be derived from the action input" ); // Drive the exact registration step the spawn path uses and capture the @@ -1659,6 +1658,84 @@ mod tests { None ); } + + #[test] + fn relaycast_spawn_session_ref_supports_action_and_harness_shapes() { + let explicit = json!({ + "session_ref": " session-explicit ", + "agent": { + "harnessConfig": { + "runtime": "pty", + "command": "codex", + "sessionId": "session-harness", + } + } + }); + assert_eq!( + super::super::relaycast_events::relaycast_spawn_session_ref(&explicit).as_deref(), + Some("session-explicit"), + "the Fleet action field must take precedence over its compatibility fallback" + ); + + let nested_camel = json!({"agent": {"sessionRef": "session-nested"}}); + assert_eq!( + super::super::relaycast_events::relaycast_spawn_session_ref(&nested_camel).as_deref(), + Some("session-nested") + ); + + let harness_only = json!({ + "agent": { + "harnessConfig": { + "runtime": "pty", + "command": "codex", + "sessionId": "session-harness", + } + } + }); + assert_eq!( + super::super::relaycast_events::relaycast_spawn_session_ref(&harness_only).as_deref(), + Some("session-harness") + ); + } + + #[test] + fn relaycast_spawn_spec_session_id_prefers_requested_resume() { + assert_eq!( + super::super::relaycast_events::relaycast_spawn_spec_session_id( + "codex", + Some(" requested-session "), + Some("harness-session"), + ) + .as_deref(), + Some("requested-session") + ); + assert_eq!( + super::super::relaycast_events::relaycast_spawn_spec_session_id( + "claude", + None, + Some(" harness-session "), + ) + .as_deref(), + Some("harness-session") + ); + assert_eq!( + super::super::relaycast_events::relaycast_spawn_spec_session_id( + "codex", + Some(" "), + None, + ), + None + ); + assert_eq!( + super::super::relaycast_events::relaycast_spawn_spec_session_id( + "pool", + Some("metadata-only-session"), + None, + ), + None, + "custom capacity harnesses retain session_ref metadata without receiving Codex/Claude argv" + ); + } #[tokio::test] async fn prune_fleet_inventory_entry_publishes_without_removed_agent() { let (tx, mut rx) = mpsc::channel(4); diff --git a/crates/broker/src/runtime/relaycast_events.rs b/crates/broker/src/runtime/relaycast_events.rs index d1660d048..294bd61b8 100644 --- a/crates/broker/src/runtime/relaycast_events.rs +++ b/crates/broker/src/runtime/relaycast_events.rs @@ -25,13 +25,37 @@ impl BrokerRuntime { } } -/// Derive the initial session ref for a spawn request from its `ws_value`, -/// mirroring `spawn_worker_from_request`'s own `session_id` derivation (the -/// harness config's `session_id`). Returns `None` when the harness config is -/// absent or invalid, or carries no session id. Used by the node `action.invoke` -/// spawn path to forward a resumable session ref into `agent.register`, matching -/// the sidecar's `fleet_initial_session_ref(&spec)`. +/// Derive the initial session ref for a spawn request from its `ws_value`. +/// +/// Fleet CLI/API callers send `session_ref` as a top-level action input, while +/// older firehose-style payloads may carry it under `agent` or in +/// `harnessConfig.session_id`. Prefer the explicit action field and retain the +/// harness fallback so both shapes resume the worker and register the same +/// session with the node control plane. pub(super) fn relaycast_spawn_session_ref(ws_value: &Value) -> Option { + let explicit = ["session_ref", "sessionRef"] + .iter() + .find_map(|key| { + ws_value + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + .or_else(|| { + let agent = ws_value.get("agent")?; + ["session_ref", "sessionRef"].iter().find_map(|key| { + agent + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + }); + if let Some(session_ref) = explicit { + return Some(session_ref.to_string()); + } + relaycast_harness_config(ws_value) .ok() .flatten() @@ -40,6 +64,30 @@ pub(super) fn relaycast_spawn_session_ref(ws_value: &Value) -> Option { .map(ToOwned::to_owned) } +pub(super) fn relaycast_spawn_spec_session_id( + cli: &str, + session_ref: Option<&str>, + harness_session_id: Option<&str>, +) -> Option { + let normalized_cli = crate::cli::command_parse::normalize_cli_name(cli); + let supports_resume = normalized_cli == "codex" + || normalized_cli == "claude" + || normalized_cli.starts_with("claude:"); + supports_resume + .then_some(session_ref) + .flatten() + .and_then(|value| { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) + }) + .or_else(|| { + harness_session_id.and_then(|value| { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) + }) + }) +} + fn relaycast_harness_config(value: &Value) -> Result, String> { let agent = value.get("agent"); let harness_id = agent @@ -355,10 +403,15 @@ pub(super) async fn spawn_worker_from_request( .as_ref() .map(ResolvedHarnessConfig::runtime) .unwrap_or(AgentRuntime::Pty); - let session_id = harness_config + let harness_session_id = harness_config .as_ref() .and_then(ResolvedHarnessConfig::session_id) .map(ToOwned::to_owned); + let session_id = relaycast_spawn_spec_session_id( + &cli, + session_ref.as_deref(), + harness_session_id.as_deref(), + ); tracing::info!(name = %name, cli = %cli, task = ?task, channel = ?channel, "handling spawn request from relaycast WS"); let channels = channel From 232a278a8de8f02b1b1eab341fa122d7d44d3636 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 12:38:26 +0200 Subject: [PATCH 18/21] fix(fleet): allow immediate agent respawn --- CHANGELOG.md | 2 +- crates/broker/src/runtime/fleet.rs | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c640ce869..3879084cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `agent-relay node up` now binds an OS-assigned API port atomically by default, preventing concurrent Fleet nodes from racing over a probed port; `AGENT_RELAY_BROKER_PORT` remains an explicit stable-port override. - Newly connected Fleet brokers now advertise their spawn/release handlers immediately, so the first remote spawn is dispatched instead of remaining queued until load changes. -- `agent-relay fleet spawn --session-ref` now passes the requested session to Claude and Codex as a real resume operation instead of recording resume metadata while launching a fresh CLI session. +- `agent-relay fleet spawn --session-ref` now passes the requested session to Claude and Codex as a real resume operation, and a released agent name can be reused immediately instead of being suppressed as a duplicate spawn. ## [11.1.1] - 2026-07-23 diff --git a/crates/broker/src/runtime/fleet.rs b/crates/broker/src/runtime/fleet.rs index 9a60384cf..be2daaf1e 100644 --- a/crates/broker/src/runtime/fleet.rs +++ b/crates/broker/src/runtime/fleet.rs @@ -415,6 +415,13 @@ impl BrokerRuntime { // not correlated to the agent and a resumable `spawn:` (when // `harnessConfig.session_id` is set) silently becomes a fresh spawn. let session_ref = super::relaycast_events::relaycast_spawn_session_ref(&ws_value); + // `action.invoke` is the authoritative control request, not a + // workspace-firehose echo of a local spawn. Mark it with the same + // control key the echo guard derives so a later release + respawn of + // the same agent name is not suppressed by the five-minute + // name-scoped echo cache. + let action_control_dedup_key = + relaycast_spawn_control_dedup_key(workspace_id.as_str(), name.as_str()); super::relaycast_events::spawn_worker_from_request( name.clone(), @@ -425,7 +432,7 @@ impl BrokerRuntime { exit_after_task, &ws_value, &workspace_id, - None, + Some(&action_control_dedup_key), &workspace_state, &mut self.workers, &mut self.state, @@ -1408,6 +1415,21 @@ mod tests { .expect("valid explicit flag")); } + #[test] + fn action_invoke_spawn_control_key_allows_immediate_name_reuse() { + let local_key = relaycast_spawn_control_dedup_key("ws_1", "worker-a"); + + // Each node action is already correlated by its invocation id. Passing + // the matching control key tells the legacy firehose echo guard not to + // consume or reject the reusable worker name. + for _ in 0..2 { + assert!(!relaycast_ws_should_apply_local_spawn_echo_dedup( + Some(local_key.as_str()), + &local_key, + )); + } + } + #[test] fn fleet_initial_session_ref_prefers_explicit_spec_session() { let spec = test_agent_spec(Some("session-spec"), Some("session-harness")); From aa4cac0dca20babbbfed58501e728efeece59cce Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 21:20:35 +0200 Subject: [PATCH 19/21] refactor(cli): use generic Relay Room invite tokens --- packages/cli/README.md | 8 ++-- .../cli/src/cli/commands/cloud-room.test.ts | 44 ++++++++++++++++--- packages/cli/src/cli/commands/cloud-room.ts | 8 +--- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index ca5d31bdf..beda041ab 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -121,18 +121,18 @@ printf '%s' "$ROOM_INVITATION_TOKEN" | agent-relay cloud room accept --token-stdin unset ROOM_INVITATION_TOKEN -# Trusted clients such as Herdr establish one stable session per device. +# Trusted clients establish one stable session per device. # --json intentionally includes the participant credential; capture it in # memory and do not log or persist it. agent-relay cloud room session \ --workspace rw_7ccfea89 \ - --device-id herdr-macbook \ + --device-id client-macbook \ --json # Explicitly ending or replacing the device session revokes the old scoped token. agent-relay cloud room revoke-session \ --workspace rw_7ccfea89 \ - --device-id herdr-macbook + --device-id client-macbook # Participants use their scoped token for agent-level Relaycast operations; an # ambient owner workspace key is never consulted when --token is present. @@ -154,7 +154,7 @@ agent-relay cloud integration catalog agent-relay cloud integration connect linear --workspace rw_7ccfea89 agent-relay cloud integration connections --workspace rw_7ccfea89 -# Member or Herdr: use Relayfile directly, including its OAuth/backend selection +# Member clients use Relayfile directly, including its OAuth/backend selection # and durable writeback queue. relayfile integration available relayfile integration connect linear diff --git a/packages/cli/src/cli/commands/cloud-room.test.ts b/packages/cli/src/cli/commands/cloud-room.test.ts index 081338412..c3fa10587 100644 --- a/packages/cli/src/cli/commands/cloud-room.test.ts +++ b/packages/cli/src/cli/commands/cloud-room.test.ts @@ -30,7 +30,11 @@ function jsonResponse(body: unknown, status = 200, headers?: HeadersInit): Respo }); } -function invite(token = 'herdr_inv_single_use_secret') { +function roomInvitationToken(character = 'A') { + return `relay_room_inv_${character.repeat(43)}`; +} + +function invite(token = roomInvitationToken()) { return { invite: { id: 'invite_1', @@ -118,7 +122,7 @@ describe('registerCloudRoomCommands', () => { }, { interactive: false } ); - expect(deps.log).toHaveBeenCalledWith('herdr_inv_single_use_secret'); + expect(deps.log).toHaveBeenCalledWith(roomInvitationToken()); }); it('does not expose viewer or email-delivery invite options', () => { @@ -156,7 +160,7 @@ describe('registerCloudRoomCommands', () => { const tokenFile = path.join(directory, 'token'); const { program, deps } = createHarness(); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ - response: jsonResponse(invite('herdr_inv_file_secret')), + response: jsonResponse(invite(roomInvitationToken('B'))), auth, }); @@ -174,7 +178,7 @@ describe('registerCloudRoomCommands', () => { '--token-file', tokenFile, ]); - expect(fs.readFileSync(tokenFile, 'utf8')).toBe('herdr_inv_file_secret\n'); + expect(fs.readFileSync(tokenFile, 'utf8')).toBe(`${roomInvitationToken('B')}\n`); if (process.platform !== 'win32') { expect(fs.statSync(tokenFile).mode & 0o777).toBe(0o600); } @@ -219,8 +223,9 @@ describe('registerCloudRoomCommands', () => { }); it('accepts an invitation only from an explicit secret source', async () => { + const token = roomInvitationToken('C'); const { program, deps } = createHarness({ - readStdin: vi.fn(async () => 'herdr_inv_accept_secret\n'), + readStdin: vi.fn(async () => `${token}\n`), }); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ response: jsonResponse({ @@ -240,12 +245,39 @@ describe('registerCloudRoomCommands', () => { '/api/v1/room/invites/accept', { method: 'POST', - body: JSON.stringify({ token: 'herdr_inv_accept_secret' }), + body: JSON.stringify({ token }), }, { interactive: false } ); }); + it('rejects invitation tokens outside the Relay Room wire contract', async () => { + for (const token of [ + `product_inv_${'A'.repeat(43)}`, + `relay_room_inv_${'A'.repeat(42)}`, + `relay_room_inv_${'A'.repeat(44)}`, + `relay_room_inv_${'A'.repeat(42)}!`, + ]) { + const { program, deps } = createHarness({ + readStdin: vi.fn(async () => `${token}\n`), + }); + + await expect( + program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'room', + 'accept', + '--token-stdin', + ]) + ).rejects.toThrow('exit:1'); + expect(deps.error).toHaveBeenCalledWith('Invalid room invitation token.'); + expect(deps.ensureCloudSession).not.toHaveBeenCalled(); + expect(deps.authorizedApiFetch).not.toHaveBeenCalled(); + } + }); + it('rejects non-participant member responses', async () => { const { program, deps } = createHarness(); vi.mocked(deps.authorizedApiFetch).mockResolvedValueOnce({ diff --git a/packages/cli/src/cli/commands/cloud-room.ts b/packages/cli/src/cli/commands/cloud-room.ts index 86c0418f3..677e3f715 100644 --- a/packages/cli/src/cli/commands/cloud-room.ts +++ b/packages/cli/src/cli/commands/cloud-room.ts @@ -23,6 +23,7 @@ const DEFAULT_INVITATION_LIFETIME_SECONDS = 7 * 24 * 60 * 60; const MIN_INVITATION_LIFETIME_SECONDS = 60; const MAX_INVITATION_LIFETIME_SECONDS = 30 * 24 * 60 * 60; const MAX_ROOM_SECRET_LENGTH = 2_048; +const ROOM_INVITATION_TOKEN_PATTERN = /^relay_room_inv_[A-Za-z0-9_-]{43}$/; interface CloudRoomIo { readStdin: () => Promise; @@ -318,12 +319,7 @@ async function defaultWriteSecretFile(filePath: string, value: string): Promise< function requireInvitationToken(value: string): string { const token = value.trim(); - if ( - !token.startsWith('herdr_inv_') || - token.length > MAX_ROOM_SECRET_LENGTH || - // eslint-disable-next-line no-control-regex - /[\u0000-\u001f\u007f-\u009f]/.test(token) - ) { + if (!ROOM_INVITATION_TOKEN_PATTERN.test(token)) { throw new Error('Invalid room invitation token.'); } return token; From 4ccd206fce9327b9eb48a1778910226d138518d7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Jul 2026 19:21:36 +0000 Subject: [PATCH 20/21] style: auto-format with Prettier --- packages/cli/src/cli/commands/cloud-room.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/cli/src/cli/commands/cloud-room.test.ts b/packages/cli/src/cli/commands/cloud-room.test.ts index c3fa10587..bd9ab3a3b 100644 --- a/packages/cli/src/cli/commands/cloud-room.test.ts +++ b/packages/cli/src/cli/commands/cloud-room.test.ts @@ -263,14 +263,7 @@ describe('registerCloudRoomCommands', () => { }); await expect( - program.parseAsync([ - 'node', - 'agent-relay', - 'cloud', - 'room', - 'accept', - '--token-stdin', - ]) + program.parseAsync(['node', 'agent-relay', 'cloud', 'room', 'accept', '--token-stdin']) ).rejects.toThrow('exit:1'); expect(deps.error).toHaveBeenCalledWith('Invalid room invitation token.'); expect(deps.ensureCloudSession).not.toHaveBeenCalled(); From 9689e64766ed3c4088002e2c1016e06e0384fcfe Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 24 Jul 2026 21:33:00 +0200 Subject: [PATCH 21/21] docs(cli): document Relay Room invite tokens --- packages/cli/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/README.md b/packages/cli/README.md index beda041ab..4d12f1550 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -116,6 +116,8 @@ agent-relay cloud room members --workspace rw_7ccfea89 # Share the owner-only token file over a secure channel. The invitee keeps the # token out of shell history and process arguments. +# Tokens use the consumer-neutral relay_room_inv_ prefix followed by exactly +# 43 URL-safe characters. read -rs ROOM_INVITATION_TOKEN printf '%s' "$ROOM_INVITATION_TOKEN" | agent-relay cloud room accept --token-stdin