From 7c7d23d13aefe34bf825cd19bda344ff8c25cbbf Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 15:46:58 +0200 Subject: [PATCH] fix(cli): short-circuit workspace-list when --workspace is set; clearer 403 hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When agentworkforce login is invoked with --workspace, listWorkspacesForLogin should not be called — but the previous flow always listed first and only short-circuited the picker. That meant users hitting 403 on /api/v1/workspaces could not log in at all, even with the workspace id in hand. This PR: - Short-circuits listWorkspacesForLogin when opts.workspace is provided - Surfaces a clearer error when the list 403s, pointing at --workspace - Surfaces a clearer error when the list is empty (no workspaces yet) - Adds tests covering all three paths Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/deploy-command.test.ts | 138 ++++++++++++++++++++++++ packages/cli/src/deploy-command.ts | 25 ++++- 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/deploy-command.test.ts b/packages/cli/src/deploy-command.test.ts index 2596a8ce..d39c75c7 100644 --- a/packages/cli/src/deploy-command.test.ts +++ b/packages/cli/src/deploy-command.test.ts @@ -110,6 +110,144 @@ test('runLogin uses cloud SDK auth, mints a workspace token, and stores it', asy } }); +test('runLogin with --workspace skips the workspace list call and uses the provided workspace', async () => { + const calls: string[] = []; + const writes: unknown[] = []; + const restoreDeps = configureDeployCommandForTest({ + createTerminalIO: () => createBufferedIO(), + ensureAuthenticated: async (apiUrl: string) => { + calls.push(`ensure:${apiUrl}`); + return { + apiUrl, + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }; + }, + createCloudApiClient() { + calls.push('createCloudApiClient'); + return { + async fetch(pathname: string) { + calls.push(`fetch:${pathname}`); + return new Response('should not be called', { status: 500 }); + } + }; + }, + issueWorkspaceToken: async (workspace: string, options: { apiUrl?: string; name?: string } = {}) => { + calls.push(`issue:${workspace}:${options.apiUrl}:${options.name}`); + return { key: 'tok-ws', workspaceToken: { workspaceId: 'ws-explicit', kind: 'workspace_token' } }; + }, + writeStoredWorkspaceToken: async (login: unknown) => { + writes.push(login); + } + }); + const trap = trapExit(false); + try { + await runLogin([ + '--cloud-url', + 'https://cloud.example.test/', + '--workspace', + '50587328-441d-4acb-b8f3-dbe1b3c5de99' + ]); + assert.deepEqual(trap.exits, [0]); + assert.ok( + !calls.some((c) => c === 'createCloudApiClient' || c.startsWith('fetch:')), + `expected workspace-list to be skipped, got calls: ${JSON.stringify(calls)}` + ); + assert.deepEqual(calls, [ + 'ensure:https://cloud.example.test', + 'issue:50587328-441d-4acb-b8f3-dbe1b3c5de99:https://cloud.example.test:agentworkforce-cli' + ]); + assert.deepEqual(writes, [{ + workspace: '50587328-441d-4acb-b8f3-dbe1b3c5de99', + workspaceSlug: '50587328-441d-4acb-b8f3-dbe1b3c5de99', + workspaceId: 'ws-explicit', + token: 'tok-ws', + cloudUrl: 'https://cloud.example.test' + }]); + assert.match(trap.stdout, /logged in: 50587328-441d-4acb-b8f3-dbe1b3c5de99/); + } finally { + trap.restore(); + restoreDeps(); + } +}); + +test('runLogin without --workspace surfaces a --workspace hint when the workspaces list returns 403', async () => { + const restoreDeps = configureDeployCommandForTest({ + createTerminalIO: () => createBufferedIO(), + ensureAuthenticated: async (apiUrl: string) => ({ + apiUrl, + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }), + createCloudApiClient() { + return { + async fetch(_pathname: string) { + return new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, + headers: { 'content-type': 'application/json' } + }); + } + }; + }, + issueWorkspaceToken: async () => { + throw new Error('issueWorkspaceToken should not be called when listing fails'); + }, + writeStoredWorkspaceToken: async () => { + throw new Error('writeStoredWorkspaceToken should not be called when listing fails'); + } + }); + const trap = trapExit(false); + try { + await runLogin(['--cloud-url', 'https://cloud.example.test/']); + assert.deepEqual(trap.exits, [1]); + assert.match(trap.stderr, /workspace list returned 403 Forbidden/); + assert.match(trap.stderr, /Pass --workspace to skip listing/); + } finally { + trap.restore(); + restoreDeps(); + } +}); + +test('runLogin without --workspace surfaces a no-workspaces message when the list comes back empty', async () => { + const restoreDeps = configureDeployCommandForTest({ + createTerminalIO: () => createBufferedIO(), + ensureAuthenticated: async (apiUrl: string) => ({ + apiUrl, + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }), + createCloudApiClient() { + return { + async fetch(_pathname: string) { + return new Response(JSON.stringify({ workspaces: [] }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + }; + }, + issueWorkspaceToken: async () => { + throw new Error('issueWorkspaceToken should not be called when no workspaces'); + }, + writeStoredWorkspaceToken: async () => { + throw new Error('writeStoredWorkspaceToken should not be called when no workspaces'); + } + }); + const trap = trapExit(false); + try { + await runLogin(['--cloud-url', 'https://cloud.example.test/']); + assert.deepEqual(trap.exits, [1]); + assert.match(trap.stderr, /no workspaces are accessible from this account/); + assert.match(trap.stderr, /pass --workspace /); + } finally { + trap.restore(); + restoreDeps(); + } +}); + test('runLogout preserves shared cloud auth and clears only the workspace token by default', async () => { const calls: string[] = []; const restoreDeps = configureDeployCommandForTest({ diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index bb390c27..71e0bef3 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -134,9 +134,20 @@ export async function runLogin(args: readonly string[]): Promise { try { const auth = await deployCommandDeps.ensureAuthenticated(cloudUrl); const apiUrl = normalizeCloudUrl(auth.apiUrl || cloudUrl); - const workspaces = await listWorkspacesForLogin(auth, apiUrl); - const chosen = opts.workspace - ?? await pickWorkspaceInteractive(workspaces, io); + let workspaces: LoginWorkspace[] = []; + let chosen: string; + if (opts.workspace) { + chosen = opts.workspace; + } else { + workspaces = await listWorkspacesForLogin(auth, apiUrl); + if (workspaces.length === 0) { + throw new Error( + 'no workspaces are accessible from this account. Create one at https://agentrelay.cloud, ' + + 'or pass --workspace if you already know the workspace identifier.' + ); + } + chosen = await pickWorkspaceInteractive(workspaces, io); + } const tokenResp = await deployCommandDeps.issueWorkspaceToken(chosen, { apiUrl, name: 'agentworkforce-cli' @@ -210,6 +221,8 @@ legacy ~/.agentworkforce/login.json-style fallback instead. Flags: --workspace Workforce workspace; defaults to WORKFORCE_WORKSPACE_ID or prompt --cloud-url Override the workforce cloud base URL + When --workspace is set, the CLI skips listing workspaces — useful when your + account hits 403 on /api/v1/workspaces but you already know the workspace id. -h, --help Print this message `; @@ -428,6 +441,12 @@ async function listWorkspacesForLogin(auth: StoredAuth, apiUrl: string): Promise if (res.ok) { return parseWorkspaceList(await res.json().catch(() => null)); } + if (res.status === 403) { + throw new Error( + 'workspace list returned 403 Forbidden. Pass --workspace to skip listing, ' + + 'or check that your account has access to a workspace at https://agentrelay.cloud.' + ); + } if (res.status !== 404 && res.status !== 405) { throw new Error(`workspace list failed: ${res.status} ${await res.text().catch(() => '')}`.trim()); }