diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b845f47..ab1518025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,16 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Minor] + +### Added + +- `agent-relay cloud workspaces` lists the Cloud workspaces the stored login can use, with their IDs (`--json` for scripts). Workspace IDs previously had no CLI discovery path. +- `agent-relay cloud enroll --workspace` accepts a workspace name or slug, not just a Cloud workspace UUID or unified `rw_` ID. The name is matched against the login's workspace listing, so enrolling no longer requires fetching an ID from the web dashboard. + +### Changed + +- `agent-relay cloud whoami` prints the organization and workspace IDs alongside their names. ## [11.3.1] - 2026-07-31 diff --git a/packages/cli/README.md b/packages/cli/README.md index 4d12f1550..adfd75b97 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -97,6 +97,20 @@ agent-relay cloud enroll --token ocl_node_enr_... agent-relay node up ``` +With a stored `agent-relay cloud login` you can mint the token yourself instead. +`cloud workspaces` lists the workspaces the login can use, and `--workspace` +takes a name, a Cloud workspace UUID, or a unified `rw_` ID: + +```bash +agent-relay cloud workspaces +# 50587328-441d-4acb-b8f3-dbe1b3c5de99 chief Chief HQ + +agent-relay cloud enroll --workspace "Chief HQ" +agent-relay node up +``` + +`agent-relay cloud whoami` also prints the current organization and workspace IDs. + ## Cloud multiplayer rooms Cloud room membership is scoped to one Relay workspace. Every v1 invite creates diff --git a/packages/cli/src/cli/bootstrap.test.ts b/packages/cli/src/cli/bootstrap.test.ts index 937feddbd..cb4da75b7 100644 --- a/packages/cli/src/cli/bootstrap.test.ts +++ b/packages/cli/src/cli/bootstrap.test.ts @@ -52,6 +52,7 @@ const expectedLeafCommands = [ 'cloud login', 'cloud logout', 'cloud whoami', + 'cloud workspaces', 'cloud connect', 'cloud enroll', 'cloud run', diff --git a/packages/cli/src/cli/commands/cloud.test.ts b/packages/cli/src/cli/commands/cloud.test.ts index 06c1d8091..22d41a3a8 100644 --- a/packages/cli/src/cli/commands/cloud.test.ts +++ b/packages/cli/src/cli/commands/cloud.test.ts @@ -20,7 +20,7 @@ const cloudMocks = vi.hoisted(() => ({ upsertFleetNodeEnrollment: vi.fn(), })); -vi.mock('@agent-relay/cloud', () => ({ +vi.mock('@agent-relay/cloud', async (importOriginal) => ({ AUTH_FILE_PATH: '/tmp/cloud-auth.json', REFRESH_WINDOW_MS: 5 * 60_000, authorizedApiFetch: vi.fn(), @@ -40,6 +40,10 @@ vi.mock('@agent-relay/cloud', () => ({ cloudMocks.downloadCloudWorkerAssignmentStorage(...args), listWorkflowSchedules: (...args: unknown[]) => cloudMocks.listWorkflowSchedules(...args), readStoredAuth: vi.fn(), + // The real redactor, not a copy: `looksLikeCredential` keys off its exact + // prefix set, so a copy here would let production drift past these tests. + redactCredentialValues: (await importOriginal()) + .redactCredentialValues, registerCloudWorker: (...args: unknown[]) => cloudMocks.registerCloudWorker(...args), resolveCloudWorkerRecord: (...args: unknown[]) => cloudMocks.resolveCloudWorkerRecord(...args), runWorkflow: (...args: unknown[]) => cloudMocks.runWorkflow(...args), @@ -47,6 +51,9 @@ vi.mock('@agent-relay/cloud', () => ({ scheduleWorkflow: (...args: unknown[]) => cloudMocks.scheduleWorkflow(...args), syncWorkflowPatch: (...args: unknown[]) => cloudMocks.syncWorkflowPatch(...args), upsertCloudWorkerRecord: vi.fn(), + IDENTITY_FILE_PATH: '/tmp/cloud-identity.json', + toCloudIdentity: () => null, + writeStoredIdentity: vi.fn(), cloudWorkerStateDir: (env?: NodeJS.ProcessEnv) => env?.AGENT_RELAY_HOME ? path.join(env.AGENT_RELAY_HOME, 'cloud-workers') : '/tmp/cloud-workers', })); @@ -55,7 +62,7 @@ vi.mock('../telemetry/index.js', () => ({ track: vi.fn(), })); -import { authorizedApiFetch, ensureCloudSession } from '@agent-relay/cloud'; +import { authorizedApiFetch, ensureAuthenticated, ensureCloudSession } from '@agent-relay/cloud'; import { buildCloudSyncPatchExcludeArgs, registerCloudCommands, type CloudDependencies } from './cloud.js'; import { createDefaultAssignmentRunner } from './cloud-worker.js'; @@ -89,6 +96,14 @@ function createHarness(overrides?: Partial) { return { program, deps }; } +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + ...init, + }); +} + async function createTarBuffer(entries: Record): Promise { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cloud-worker-archive-')); try { @@ -121,6 +136,7 @@ describe('registerCloudCommands', () => { 'logout', 'session', 'whoami', + 'workspaces', 'connect', 'enroll', 'run', @@ -1009,9 +1025,20 @@ describe('registerCloudCommands', () => { expect(cloudMocks.enrollFleetNode).not.toHaveBeenCalled(); }); - it.each(['204337648549896192', 'rk_live_SECRET'])( - 'cloud enroll --workspace rejects unsupported identifier %s without disclosing it', + it.each(['rk_live_SECRET', 'ocl_node_enr_SECRET'])( + 'cloud enroll --workspace rejects credential %s without transmitting or disclosing it', async (workspaceId) => { + const auth = { + apiUrl: 'https://cloud.test', + accessToken: 'access-secret', + refreshToken: 'refresh-secret', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }; + vi.mocked(ensureCloudSession).mockResolvedValueOnce({ auth, client: {} as never }); + vi.mocked(authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ workspaces: [{ id: 'ws-1', slug: 'chief', name: 'Chief HQ' }] }), + auth, + }); const { program, deps } = createHarness(); await expect( @@ -1019,15 +1046,384 @@ describe('registerCloudCommands', () => { ).rejects.toThrow('exit:1'); expect(deps.error).toHaveBeenCalledWith( - 'Unsupported Cloud workspace identifier. Use a Cloud workspace UUID or unified rw_ workspace ID.' + 'That value looks like a credential, not a workspace. Pass a workspace name, Cloud workspace UUID, ' + + "or unified rw_ workspace ID. Run 'agent-relay cloud workspaces' to list the workspaces this login can use." ); + // The selector is matched locally against the listing, so it must never + // reach an outbound request or the terminal. expect(deps.error.mock.calls.flat().join('\n')).not.toContain(workspaceId); - expect(ensureCloudSession).not.toHaveBeenCalled(); - expect(authorizedApiFetch).not.toHaveBeenCalled(); + expect(vi.mocked(deps.log).mock.calls.flat().join('\n')).not.toContain(workspaceId); + expect(authorizedApiFetch).toHaveBeenCalledTimes(1); + expect(JSON.stringify(vi.mocked(authorizedApiFetch).mock.calls)).not.toContain(workspaceId); expect(cloudMocks.enrollFleetNode).not.toHaveBeenCalled(); } ); + it('cloud enroll --workspace accepts a listed name that looks credential-like', async () => { + const auth = { + apiUrl: 'https://cloud.test', + accessToken: 'access-secret', + refreshToken: 'refresh-secret', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }; + vi.mocked(ensureCloudSession).mockResolvedValueOnce({ auth, client: {} as never }); + vi.mocked(authorizedApiFetch) + .mockResolvedValueOnce({ + // `br_` is one of the redactor's deliberately broad prefixes, so this + // name must resolve on the strength of being in the listing. + response: jsonResponse({ workspaces: [{ id: 'ws-1', slug: 'br-team', name: 'br_team' }] }), + auth, + }) + .mockResolvedValueOnce({ + response: jsonResponse({ + workspaceId: 'rw_7ccfea89', + cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99', + }), + auth, + }) + .mockResolvedValueOnce({ + response: jsonResponse({ + token: 'ocl_node_enr_minted_secret', + enrollmentUrl: 'https://cloud.test/api/v1/fleet/register', + }), + auth, + }); + cloudMocks.enrollFleetNode.mockResolvedValueOnce({ + nodeId: 'node_abc', + nodeName: 'br', + nodeToken: 'nt_secret', + relayWorkspaceId: 'rw_7ccfea89', + }); + cloudMocks.upsertFleetNodeEnrollment.mockReturnValueOnce({ version: 1, active: {}, nodes: {} }); + const { program } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'cloud', 'enroll', '--workspace', 'br_team']); + + expect(authorizedApiFetch).toHaveBeenNthCalledWith( + 2, + auth, + '/api/v1/workspaces/ws-1/resolve', + { method: 'GET' }, + { interactive: false } + ); + expect(cloudMocks.enrollFleetNode).toHaveBeenCalled(); + }); + + it('cloud enroll --workspace resolves a workspace name against the login listing', async () => { + const auth = { + apiUrl: 'https://cloud.test', + accessToken: 'access-secret', + refreshToken: 'refresh-secret', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }; + vi.mocked(ensureCloudSession).mockResolvedValueOnce({ auth, client: {} as never }); + vi.mocked(authorizedApiFetch) + .mockResolvedValueOnce({ + response: jsonResponse({ + workspaces: [ + { id: '50587328-441d-4acb-b8f3-dbe1b3c5de99', slug: 'chief', name: 'Chief HQ' }, + { id: 'a1b2c3d4-0000-4000-8000-000000000000', slug: 'scratch', name: 'Scratch' }, + ], + }), + auth, + }) + .mockResolvedValueOnce({ + response: jsonResponse({ + workspaceId: 'rw_7ccfea89', + cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99', + }), + auth, + }) + .mockResolvedValueOnce({ + response: jsonResponse({ + token: 'ocl_node_enr_minted_secret', + enrollmentUrl: 'https://cloud.test/api/v1/fleet/register', + }), + auth, + }); + cloudMocks.enrollFleetNode.mockResolvedValueOnce({ + nodeId: 'node_abc', + nodeName: 'chief', + nodeToken: 'nt_secret', + relayWorkspaceId: 'rw_7ccfea89', + }); + cloudMocks.upsertFleetNodeEnrollment.mockReturnValueOnce({ version: 1, active: {}, nodes: {} }); + const { program } = createHarness(); + + // Case-insensitive: the listing is the source of truth, not the casing typed. + await program.parseAsync(['node', 'agent-relay', 'cloud', 'enroll', '--workspace', 'chief hq']); + + expect(authorizedApiFetch).toHaveBeenNthCalledWith( + 1, + auth, + '/api/v1/workspaces', + { method: 'GET' }, + { interactive: false } + ); + expect(authorizedApiFetch).toHaveBeenNthCalledWith( + 2, + auth, + '/api/v1/workspaces/50587328-441d-4acb-b8f3-dbe1b3c5de99/resolve', + { method: 'GET' }, + { interactive: false } + ); + expect(cloudMocks.enrollFleetNode).toHaveBeenCalledWith({ + enrollmentToken: 'ocl_node_enr_minted_secret', + enrollmentUrl: 'https://cloud.test/api/v1/fleet/register', + }); + }); + + it('cloud enroll --workspace matches an ID or slug ahead of another workspace name', async () => { + const auth = { + apiUrl: 'https://cloud.test', + accessToken: 'access-secret', + refreshToken: 'refresh-secret', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }; + vi.mocked(ensureCloudSession).mockResolvedValueOnce({ auth, client: {} as never }); + vi.mocked(authorizedApiFetch) + .mockResolvedValueOnce({ + response: jsonResponse({ + workspaces: [ + { id: 'ws-1', slug: 'chief', name: 'Decoy' }, + { id: 'ws-2', slug: 'scratch', name: 'chief' }, + ], + }), + auth, + }) + .mockResolvedValueOnce({ + response: jsonResponse({ + workspaceId: 'rw_7ccfea89', + cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99', + }), + auth, + }) + .mockResolvedValueOnce({ + response: jsonResponse({ + token: 'ocl_node_enr_minted_secret', + enrollmentUrl: 'https://cloud.test/api/v1/fleet/register', + }), + auth, + }); + cloudMocks.enrollFleetNode.mockResolvedValueOnce({ + nodeId: 'node_abc', + nodeName: 'chief', + nodeToken: 'nt_secret', + relayWorkspaceId: 'rw_7ccfea89', + }); + cloudMocks.upsertFleetNodeEnrollment.mockReturnValueOnce({ version: 1, active: {}, nodes: {} }); + const { program } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'cloud', 'enroll', '--workspace', 'chief']); + + expect(authorizedApiFetch).toHaveBeenNthCalledWith( + 2, + auth, + '/api/v1/workspaces/ws-1/resolve', + { method: 'GET' }, + { interactive: false } + ); + }); + + it('cloud enroll --workspace reports an unmatched name without echoing it', async () => { + const auth = { + apiUrl: 'https://cloud.test', + accessToken: 'access-secret', + refreshToken: 'refresh-secret', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }; + vi.mocked(ensureCloudSession).mockResolvedValueOnce({ auth, client: {} as never }); + vi.mocked(authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ workspaces: [{ id: 'ws-1', slug: 'chief', name: 'Chief HQ' }] }), + auth, + }); + const { program, deps } = createHarness(); + + await expect( + program.parseAsync(['node', 'agent-relay', 'cloud', 'enroll', '--workspace', '204337648549896192']) + ).rejects.toThrow('exit:1'); + + expect(deps.error).toHaveBeenCalledWith( + "No Cloud workspace matched that name. Run 'agent-relay cloud workspaces' to list the workspaces this login can use." + ); + expect(deps.error.mock.calls.flat().join('\n')).not.toContain('204337648549896192'); + expect(cloudMocks.enrollFleetNode).not.toHaveBeenCalled(); + }); + + it('cloud enroll --workspace refuses an ambiguous name and names the candidates', async () => { + const auth = { + apiUrl: 'https://cloud.test', + accessToken: 'access-secret', + refreshToken: 'refresh-secret', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }; + vi.mocked(ensureCloudSession).mockResolvedValueOnce({ auth, client: {} as never }); + vi.mocked(authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ + workspaces: [ + { id: 'ws-1', slug: 'chief-a', name: 'Chief' }, + { id: 'ws-2', slug: 'chief-b', name: 'chief' }, + ], + }), + auth, + }); + const { program, deps } = createHarness(); + + await expect( + program.parseAsync(['node', 'agent-relay', 'cloud', 'enroll', '--workspace', 'Chief']) + ).rejects.toThrow('exit:1'); + + expect(deps.error).toHaveBeenCalledWith( + 'That name matches 2 Cloud workspaces (ws-1, ws-2). Pass the workspace ID instead.' + ); + expect(cloudMocks.enrollFleetNode).not.toHaveBeenCalled(); + }); + + it('cloud whoami prints the organization and workspace IDs, not just names', async () => { + const auth = { apiUrl: 'https://cloud.test', accessToken: 'access-secret' }; + vi.mocked(ensureAuthenticated).mockResolvedValueOnce(auth as never); + vi.mocked(authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ + authenticated: true, + source: 'session', + subjectType: 'user', + scopes: [], + user: { id: 'u1', email: 'a@b.test', name: 'A', avatarUrl: null }, + currentOrganization: { id: 'org-1', slug: 'acme', name: 'Acme', role: 'owner', status: 'active' }, + currentWorkspace: { + id: '50587328-441d-4acb-b8f3-dbe1b3c5de99', + organization_id: 'org-1', + slug: 'chief', + name: 'Chief HQ', + }, + }), + auth, + } as never); + const { program, deps } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'cloud', 'whoami']); + + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain('Organization: Acme (org-1)'); + expect(output).toContain('Workspace: Chief HQ (50587328-441d-4acb-b8f3-dbe1b3c5de99)'); + }); + + it('cloud whoami still reports a login with no workspace selected', async () => { + const auth = { apiUrl: 'https://cloud.test', accessToken: 'access-secret' }; + vi.mocked(ensureAuthenticated).mockResolvedValueOnce(auth as never); + vi.mocked(authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ + authenticated: true, + source: 'session', + subjectType: 'user', + scopes: [], + user: { id: 'u1', email: null, name: null, avatarUrl: null }, + currentOrganization: null, + currentWorkspace: null, + workspaceRequired: true, + }), + auth, + } as never); + const { program, deps } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'cloud', 'whoami']); + + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain('Organization: (none)'); + expect(output).toContain('Workspace: (none)'); + }); + + it('cloud workspaces lists every workspace with its ID', async () => { + const auth = { + apiUrl: 'https://cloud.test', + accessToken: 'access-secret', + refreshToken: 'refresh-secret', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }; + vi.mocked(ensureCloudSession).mockResolvedValueOnce({ auth, client: {} as never }); + vi.mocked(authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ + workspaces: [ + { id: '50587328-441d-4acb-b8f3-dbe1b3c5de99', slug: 'chief', name: 'Chief HQ' }, + // Terminal control sequences in Cloud-provided text must not survive, + // in the ID as much as in the name. + { id: `a1b2c3d4\x1b[2J-0000-4000-8000-000000000000`, slug: 'scratch', name: `Scr\x1b[2Jatch` }, + ], + }), + auth, + }); + const { program, deps } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'cloud', 'workspaces']); + + expect(authorizedApiFetch).toHaveBeenCalledWith( + auth, + '/api/v1/workspaces', + { method: 'GET' }, + { interactive: false } + ); + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain('50587328-441d-4acb-b8f3-dbe1b3c5de99'); + expect(output).toContain('Chief HQ'); + expect(output).toContain('Scratch'); + expect(output).toContain('a1b2c3d4-0000-4000-8000-000000000000'); + expect(output).not.toContain('\x1b'); + expect(output).not.toContain('access-secret'); + }); + + it('cloud workspaces prints valid JSON with --json', async () => { + const auth = { + apiUrl: 'https://cloud.test', + accessToken: 'access-secret', + refreshToken: 'refresh-secret', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }; + vi.mocked(ensureCloudSession).mockResolvedValueOnce({ auth, client: {} as never }); + vi.mocked(authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ workspaces: [{ id: 'ws-1', slug: 'chief', name: 'Chief HQ' }] }), + auth, + }); + const { program, deps } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'cloud', 'workspaces', '--json']); + + expect(JSON.parse(vi.mocked(deps.log).mock.calls.flat().join(''))).toEqual({ + workspaces: [{ id: 'ws-1', slug: 'chief', name: 'Chief HQ' }], + }); + }); + + it('cloud workspaces tells logged-out users how to log in', async () => { + vi.mocked(ensureCloudSession).mockRejectedValueOnce( + Object.assign(new Error('Cloud login required'), { code: 'AUTH_BROWSER_REQUIRED' }) + ); + const { program, deps } = createHarness(); + + await expect(program.parseAsync(['node', 'agent-relay', 'cloud', 'workspaces'])).rejects.toThrow( + 'exit:1' + ); + + expect(deps.error).toHaveBeenCalledWith('Cloud login required. Run `agent-relay cloud login` and retry.'); + }); + + it('cloud workspaces says so when the login has no workspaces', async () => { + const auth = { + apiUrl: 'https://cloud.test', + accessToken: 'access-secret', + refreshToken: 'refresh-secret', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }; + vi.mocked(ensureCloudSession).mockResolvedValueOnce({ auth, client: {} as never }); + vi.mocked(authorizedApiFetch).mockResolvedValueOnce({ + response: jsonResponse({ workspaces: [] }), + auth, + }); + const { program, deps } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'cloud', 'workspaces']); + + expect(deps.log).toHaveBeenCalledWith('No Cloud workspaces are available to this login.'); + }); + it('cloud enroll rejects --token and --workspace together before using either credential', async () => { const { program } = createHarness(); diff --git a/packages/cli/src/cli/commands/cloud.ts b/packages/cli/src/cli/commands/cloud.ts index 9eed3c859..da19a7904 100644 --- a/packages/cli/src/cli/commands/cloud.ts +++ b/packages/cli/src/cli/commands/cloud.ts @@ -24,6 +24,7 @@ import { normalizeProvider, enrollFleetNode, upsertFleetNodeEnrollment, + redactCredentialValues, toCloudIdentity, writeStoredIdentity, IDENTITY_FILE_PATH, @@ -33,6 +34,7 @@ import { } from '@agent-relay/cloud'; import { defaultExit } from '../lib/exit.js'; +import { sanitizeForTerminalLine } from '../lib/formatting.js'; import { maskSecret } from '../lib/redact.js'; import { errorClassName } from '../lib/telemetry-helpers.js'; import { track } from '../telemetry/index.js'; @@ -155,9 +157,28 @@ type ResolvedCloudWorkspace = { auth: CloudAuth; }; +/** One entry of `GET /api/v1/workspaces` — the workspaces this login can use. */ +type CloudWorkspaceSummary = { + id: string; + slug: string; + name: string; +}; + 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 WORKSPACE_SELECTOR_HELP = + "Run 'agent-relay cloud workspaces' to list the workspaces this login can use."; + +/** + * True when the value carries a live credential prefix. A pasted secret must + * never be treated as a workspace name: name resolution matches locally + * against the listing, but reporting "no workspace named X" would echo it. + */ +function looksLikeCredential(value: string): boolean { + return redactCredentialValues(value) !== value; +} + function isCloudLoginError(error: unknown): boolean { if (!isObject(error) || typeof error.code !== 'string') { return false; @@ -248,18 +269,132 @@ async function resolveCloudWorkspace( return { cloudWorkspaceId, auth: activeAuth }; } +function workspaceListError(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('This login is not allowed to list Cloud workspaces.'); + } + if (response.status === 429) { + const retryAfter = response.headers.get('retry-after')?.trim(); + return new Error( + `Cloud workspace list rate limit exceeded.${ + retryAfter ? ` Retry-After: ${retryAfter} seconds.` : ' Wait and retry.' + }` + ); + } + + const detail = `${response.status} ${response.statusText}`.trim(); + return new Error(`Failed to list Cloud workspaces: ${detail}`); +} + +function toWorkspaceSummary(entry: unknown): CloudWorkspaceSummary | null { + if (!isObject(entry) || typeof entry.id !== 'string' || !entry.id.trim()) { + return null; + } + const id = entry.id.trim(); + const slug = typeof entry.slug === 'string' && entry.slug.trim() ? entry.slug.trim() : id; + const name = typeof entry.name === 'string' && entry.name.trim() ? entry.name.trim() : slug; + return { id, slug, name }; +} + +/** + * List the Cloud workspaces reachable from the stored login. This is the only + * discovery path the CLI has for workspace IDs, so both `cloud workspaces` and + * name-based `--workspace` resolution go through it. + */ +async function listCloudWorkspaces( + auth: CloudAuth, + deps: Pick +): Promise<{ workspaces: CloudWorkspaceSummary[]; auth: CloudAuth }> { + const { response, auth: activeAuth } = await deps.authorizedApiFetch( + auth, + '/api/v1/workspaces', + { method: 'GET' }, + { interactive: false } + ); + const payload = (await response.json().catch(() => null)) as unknown; + + if (!response.ok) { + throw workspaceListError(response); + } + if (!isObject(payload) || !Array.isArray(payload.workspaces)) { + throw new Error('Cloud workspace list returned an invalid response.'); + } + + const workspaces = payload.workspaces + .map(toWorkspaceSummary) + .filter((entry): entry is CloudWorkspaceSummary => entry !== null); + + return { workspaces, auth: activeAuth }; +} + +/** + * Match a user-supplied selector against the listing. ID beats slug beats + * name so an exact identifier is never shadowed by someone else's display + * name, and each tier is matched case-insensitively. + */ +function matchWorkspaceSelector( + selector: string, + workspaces: CloudWorkspaceSummary[] +): CloudWorkspaceSummary[] { + const needle = selector.trim().toLowerCase(); + for (const field of ['id', 'slug', 'name'] as const) { + const matches = workspaces.filter((workspace) => workspace[field].toLowerCase() === needle); + if (matches.length > 0) { + return matches; + } + } + return []; +} + +/** + * Turn `--workspace` into an identifier the resolver accepts. UUIDs and + * unified `rw_` IDs pass straight through; anything else is looked up by name + * or slug against the login's workspace listing. + */ +async function resolveWorkspaceSelector( + selector: string, + auth: CloudAuth, + deps: Pick +): Promise<{ workspaceId: string; auth: CloudAuth }> { + if (CLOUD_WORKSPACE_UUID_PATTERN.test(selector) || UNIFIED_WORKSPACE_ID_PATTERN.test(selector)) { + return { workspaceId: selector, auth }; + } + + const listed = await listCloudWorkspaces(auth, deps); + const matches = matchWorkspaceSelector(selector, listed.workspaces); + + if (matches.length === 1) { + return { workspaceId: matches[0].id, auth: listed.auth }; + } + if (matches.length > 1) { + throw new Error( + `That name matches ${matches.length} Cloud workspaces (${matches + .map((workspace) => workspace.id) + .join(', ')}). Pass the workspace ID instead.` + ); + } + + // Nothing matched. The credential check only picks the error message — it + // must not gate the lookup, because the redactor's prefixes are deliberately + // broad and a real workspace may legitimately be named `br_team`. Neither + // message echoes the selector: an unmatched value may be a pasted secret. + throw new Error( + looksLikeCredential(selector) + ? `That value looks like a credential, not a workspace. Pass a workspace name, Cloud workspace UUID, or unified rw_ workspace ID. ${WORKSPACE_SELECTOR_HELP}` + : `No Cloud workspace matched that name. ${WORKSPACE_SELECTOR_HELP}` + ); +} + async function mintFleetNodeEnrollment( options: { workspaceId: string; name?: string; maxAgents?: number }, deps: Pick ): Promise { const workspaceId = options.workspaceId.trim(); if (!workspaceId) { - throw new Error('A workspace ID is required for session-based enrollment.'); - } - 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.' - ); + throw new Error('A workspace name or ID is required for session-based enrollment.'); } try { @@ -267,7 +402,8 @@ async function mintFleetNodeEnrollment( apiUrl: defaultApiUrl(), interactive: false, }); - const resolved = await resolveCloudWorkspace(workspaceId, session.auth, deps); + const selected = await resolveWorkspaceSelector(workspaceId, session.auth, deps); + const resolved = await resolveCloudWorkspace(selected.workspaceId, selected.auth, deps); const { response } = await deps.authorizedApiFetch( resolved.auth, '/api/v1/fleet/enrollment-tokens', @@ -284,7 +420,9 @@ async function mintFleetNodeEnrollment( const payload = (await response.json().catch(() => null)) as unknown; if (!response.ok) { - throw enrollmentTokenMintError(response, payload, workspaceId); + // Report the resolved identifier, never the raw selector: by this point + // the selector has matched a real workspace, so the ID is safe to echo. + throw enrollmentTokenMintError(response, payload, selected.workspaceId); } if ( !isObject(payload) || @@ -342,6 +480,39 @@ async function resolveFleetNodeEnrollmentInput( }; } +/** + * Render `name (id)` for whoami, or `(none)` when the login has no selection. + * The ID is Cloud-provided text like the name, so it is sanitized too. + */ +function formatWorkspaceLabel(entry: { id: string; name?: string | null } | null | undefined): string { + if (!entry) { + return '(none)'; + } + const name = entry.name?.trim() ? sanitizeForTerminalLine(entry.name.trim()) : '(no name)'; + return `${name} (${sanitizeForTerminalLine(entry.id)})`; +} + +function renderWorkspaceList(workspaces: CloudWorkspaceSummary[], log: (...args: unknown[]) => void): void { + if (workspaces.length === 0) { + log('No Cloud workspaces are available to this login.'); + return; + } + + // Sanitize before measuring: padding computed from raw text would be wrong + // for any row whose displayed form is shorter than its stored form. + const rows = workspaces.map((workspace) => ({ + id: sanitizeForTerminalLine(workspace.id), + slug: sanitizeForTerminalLine(workspace.slug), + name: sanitizeForTerminalLine(workspace.name), + })); + const idWidth = Math.max(...rows.map((row) => row.id.length)); + const slugWidth = Math.max(...rows.map((row) => row.slug.length)); + for (const row of rows) { + log(`${row.id.padEnd(idWidth)} ${row.slug.padEnd(slugWidth)} ${row.name}`); + } + log("\nPass an ID or a name to 'agent-relay cloud enroll --workspace'."); +} + function renderPatchPushResults(patches: unknown, log: (...args: unknown[]) => void): void { if (!isObject(patches)) { return; @@ -598,8 +769,11 @@ export function registerCloudCommands(program: Command, overrides: Partial` : ''}` ); - deps.log(`Organization: ${payload.currentOrganization?.name ?? '(none)'}`); - deps.log(`Workspace: ${payload.currentWorkspace?.name ?? '(none)'}`); + // Print IDs, not just names: the workspace ID is what `cloud enroll` + // and the other workspace-scoped commands take, and whoami is where + // people look for it first. + deps.log(`Organization: ${formatWorkspaceLabel(payload.currentOrganization)}`); + deps.log(`Workspace: ${formatWorkspaceLabel(payload.currentWorkspace)}`); deps.log(`Scopes: ${payload.scopes.length > 0 ? payload.scopes.join(', ') : '(none)'}`); deps.log(`Token file: ${AUTH_FILE_PATH}`); @@ -624,6 +798,50 @@ export function registerCloudCommands(program: Command, overrides: Partial', 'Cloud API base URL') + .option('--json', 'Print raw JSON response', false) + .action(async (options: { apiUrl?: string; json?: boolean }) => { + const started = Date.now(); + let success = false; + let errorClass: string | undefined; + try { + const session = await deps.ensureCloudSession({ + apiUrl: options.apiUrl || defaultApiUrl(), + interactive: false, + }); + const { workspaces } = await listCloudWorkspaces(session.auth, deps); + + if (options.json) { + deps.log(JSON.stringify({ workspaces }, null, 2)); + } else { + renderWorkspaceList(workspaces, deps.log); + } + success = true; + } catch (err) { + errorClass = errorClassName(err); + deps.error( + isCloudLoginError(err) + ? 'Cloud login required. Run `agent-relay cloud login` and retry.' + : err instanceof Error + ? err.message + : String(err) + ); + deps.exit(1); + } finally { + track('cloud_auth', { + action: 'workspaces', + success, + duration_ms: Date.now() - started, + ...(errorClass ? { error_class: errorClass } : {}), + }); + } + }); + // ── connect ──────────────────────────────────────────────────────────────── cloudCommand @@ -673,8 +891,8 @@ export function registerCloudCommands(program: Command, overrides: Partial', - 'Resolve a Cloud workspace UUID or unified rw_ ID and mint using the stored login' + '--workspace ', + 'Workspace name, Cloud workspace UUID, or unified rw_ ID to mint into using the stored login' ).conflicts('token') ) .option('--enrollment-url ', 'Cloud enrollment endpoint that redeems the token') diff --git a/packages/cli/src/cli/lib/formatting.test.ts b/packages/cli/src/cli/lib/formatting.test.ts index e606e049e..9253a5709 100644 --- a/packages/cli/src/cli/lib/formatting.test.ts +++ b/packages/cli/src/cli/lib/formatting.test.ts @@ -83,6 +83,11 @@ describe('sanitizeForTerminal', () => { expect(sanitizeForTerminal('x\x9bDy')).toBe('xy'); expect(sanitizeForTerminal('p\x1b]0;t\x1b\\q')).toBe('pq'); }); + + it('removes bidirectional overrides that let text reorder itself on the line', () => { + expect(sanitizeForTerminal('safe\u202ename\u202c')).toBe('safename'); + expect(sanitizeForTerminal('a\u2066b\u2069c')).toBe('abc'); + }); }); describe('sanitizeForTerminalLine', () => { diff --git a/packages/cli/src/cli/lib/formatting.ts b/packages/cli/src/cli/lib/formatting.ts index c6ed77617..3c84bc24b 100644 --- a/packages/cli/src/cli/lib/formatting.ts +++ b/packages/cli/src/cli/lib/formatting.ts @@ -58,12 +58,17 @@ export function parseSince(input?: string): number | undefined { export function sanitizeForTerminal(input: string): string { /* eslint-disable no-control-regex -- intentionally matching ANSI/control bytes to strip them */ - return input - .replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '') - .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '') - .replace(/\x9B[0-?]*[ -/]*[@-~]/g, '') - .replace(/\x1B[@-Z\\-_]/g, '') - .replace(/[\x00-\x08\x0B\x0C\x0D\x0E-\x1F\x7F-\x9F]/g, ''); + return ( + input + .replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '') + .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\x9B[0-?]*[ -/]*[@-~]/g, '') + .replace(/\x1B[@-Z\\-_]/g, '') + .replace(/[\x00-\x08\x0B\x0C\x0D\x0E-\x1F\x7F-\x9F]/g, '') + // Bidirectional overrides let server-provided text reorder the line it is + // printed on, so a name can visually impersonate another one. + .replace(/[\u202a-\u202e\u2066-\u2069]/g, '') + ); /* eslint-enable no-control-regex */ } diff --git a/packages/cli/src/cli/telemetry/events.ts b/packages/cli/src/cli/telemetry/events.ts index d12e96f9d..77115038b 100644 --- a/packages/cli/src/cli/telemetry/events.ts +++ b/packages/cli/src/cli/telemetry/events.ts @@ -348,12 +348,13 @@ export interface WorkflowRunEvent { } /** - * cloud_auth - Emitted for cloud account auth flows (login/logout/whoami/connect). + * cloud_auth - Emitted for cloud account auth flows + * (login/logout/whoami/workspaces/connect). * `action` distinguishes the flow; `provider` is only present for `connect`. */ export interface CloudAuthEvent { /** Which cloud-auth flow ran */ - action: 'login' | 'logout' | 'whoami' | 'connect'; + action: 'login' | 'logout' | 'whoami' | 'workspaces' | 'connect'; /** True if the flow completed without throwing */ success: boolean; /** Wall-clock duration in milliseconds */