From 7c7d23d13aefe34bf825cd19bda344ff8c25cbbf Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 15:46:58 +0200 Subject: [PATCH 1/2] 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 2596a8c..d39c75c 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 bb390c2..71e0bef 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()); } From ee06f8fa15ee1264e0fa1ab58b915073df355e2b Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 16:15:42 +0200 Subject: [PATCH 2/2] fix(cli): reuse @agent-relay/cloud auth as Bearer; drop workspace-token mint agentworkforce login was hitting POST /api/v1/workspaces/{id}/tokens/workspace (and two fallback paths) to mint a workspace-scoped token. Cloud does not implement any of those routes -- all three 404, blocking login for every user who already has a valid agent-relay cloud login. Cloud's resolveRequestAuth already accepts the user's accessToken from ~/.agent-relay/cloud-auth.json as Authorization: Bearer. The mint step exists for CI/service-account use, not interactive CLI use, but currently blocks BOTH because cloud has not shipped the route. This PR: - Drops issueWorkspaceToken from runLogin. - Persists a lightweight ~/.agentworkforce/active.json pointer (workspaceId + workspaceSlug + cloudUrl) at login time. - resolveWorkspaceToken reads active.json + @agent-relay/cloud's shared auth and returns auth.accessToken as the Bearer. Refreshes the accessToken via refreshStoredAuth when expired. - Preserves the WORKFORCE_WORKSPACE_TOKEN env fallback (CI) and the legacy keychain-stored workspace token path (back-compat for users mid-upgrade). - runLogout always clears active.json; --cloud-auth/--all still clears the shared agent-relay auth as before. Layered on top of workforce#112 (--workspace short-circuit) so login works end-to-end with just a known workspace id. Co-Authored-By: Claude Opus 4.7 --- packages/cli/src/deploy-command.test.ts | 67 ++--- packages/cli/src/deploy-command.ts | 69 ++---- packages/deploy/src/index.ts | 6 +- packages/deploy/src/login.test.ts | 316 ++++++++++++++++++++++-- packages/deploy/src/login.ts | 126 +++++++++- 5 files changed, 478 insertions(+), 106 deletions(-) diff --git a/packages/cli/src/deploy-command.test.ts b/packages/cli/src/deploy-command.test.ts index d39c75c..55941e0 100644 --- a/packages/cli/src/deploy-command.test.ts +++ b/packages/cli/src/deploy-command.test.ts @@ -54,7 +54,7 @@ function trapExit(throwOnExit = true): ExitTrap { return trap; } -test('runLogin uses cloud SDK auth, mints a workspace token, and stores it', async () => { +test('runLogin uses cloud SDK auth, picks a workspace, and writes the active pointer (no token mint)', async () => { const calls: string[] = []; const writes: unknown[] = []; const restoreDeps = configureDeployCommandForTest({ @@ -79,12 +79,8 @@ test('runLogin uses cloud SDK auth, mints a workspace token, and stores it', asy } }; }, - issueWorkspaceToken: async (workspace: string, options: { apiUrl?: string; name?: string } = {}) => { - calls.push(`issue:${workspace}:${options.apiUrl}:${options.name}`); - return { key: 'tok-ws', workspaceToken: { workspaceId: 'ws-1', kind: 'workspace_token' } }; - }, - writeStoredWorkspaceToken: async (login: unknown) => { - writes.push(login); + writeActiveWorkspace: async (pointer: unknown) => { + writes.push(pointer); } }); const trap = trapExit(false); @@ -93,14 +89,12 @@ test('runLogin uses cloud SDK auth, mints a workspace token, and stores it', asy assert.deepEqual(trap.exits, [0]); assert.deepEqual(calls, [ 'ensure:https://cloud.example.test', - 'fetch:/api/v1/workspaces', - 'issue:acme:https://cloud.example.test:agentworkforce-cli' + 'fetch:/api/v1/workspaces' ]); assert.deepEqual(writes, [{ workspace: 'acme', workspaceSlug: 'acme', workspaceId: 'ws-1', - token: 'tok-ws', cloudUrl: 'https://cloud.example.test' }]); assert.match(trap.stdout, /logged in: acme/); @@ -110,7 +104,7 @@ 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 () => { +test('runLogin with --workspace skips the workspaces list, skips token mint, writes active pointer', async () => { const calls: string[] = []; const writes: unknown[] = []; const restoreDeps = configureDeployCommandForTest({ @@ -133,12 +127,8 @@ test('runLogin with --workspace skips the workspace list call and uses the provi } }; }, - 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); + writeActiveWorkspace: async (pointer: unknown) => { + writes.push(pointer); } }); const trap = trapExit(false); @@ -154,15 +144,9 @@ test('runLogin with --workspace skips the workspace list call and uses the provi !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(calls, ['ensure:https://cloud.example.test']); 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/); @@ -191,11 +175,8 @@ test('runLogin without --workspace surfaces a --workspace hint when the workspac } }; }, - 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'); + writeActiveWorkspace: async () => { + throw new Error('writeActiveWorkspace should not be called when listing fails'); } }); const trap = trapExit(false); @@ -229,11 +210,8 @@ test('runLogin without --workspace surfaces a no-workspaces message when the lis } }; }, - 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'); + writeActiveWorkspace: async () => { + throw new Error('writeActiveWorkspace should not be called when no workspaces'); } }); const trap = trapExit(false); @@ -248,12 +226,15 @@ test('runLogin without --workspace surfaces a no-workspaces message when the lis } }); -test('runLogout preserves shared cloud auth and clears only the workspace token by default', async () => { +test('runLogout preserves shared cloud auth and clears the active pointer + legacy keychain token by default', async () => { const calls: string[] = []; const restoreDeps = configureDeployCommandForTest({ clearStoredAuth: async () => { calls.push('clear-auth'); }, + clearActiveWorkspace: async () => { + calls.push('clear-active'); + }, clearStoredWorkspaceToken: async (workspace?: string) => { calls.push(`clear-workspace:${workspace ?? ''}`); } @@ -262,7 +243,7 @@ test('runLogout preserves shared cloud auth and clears only the workspace token try { await runLogout(['--workspace', 'acme']); assert.deepEqual(trap.exits, [0]); - assert.deepEqual(calls, ['clear-workspace:acme']); + assert.deepEqual(calls, ['clear-active', 'clear-workspace:acme']); assert.match(trap.stdout, /workspace login cleared/); } finally { trap.restore(); @@ -270,12 +251,15 @@ test('runLogout preserves shared cloud auth and clears only the workspace token } }); -test('runLogout clears shared cloud auth when explicitly requested', async () => { +test('runLogout clears shared cloud auth + active pointer when --cloud-auth is passed', async () => { const calls: string[] = []; const restoreDeps = configureDeployCommandForTest({ clearStoredAuth: async () => { calls.push('clear-auth'); }, + clearActiveWorkspace: async () => { + calls.push('clear-active'); + }, clearStoredWorkspaceToken: async (workspace?: string) => { calls.push(`clear-workspace:${workspace ?? ''}`); } @@ -284,7 +268,7 @@ test('runLogout clears shared cloud auth when explicitly requested', async () => try { await runLogout(['--workspace', 'acme', '--cloud-auth']); assert.deepEqual(trap.exits, [0]); - assert.deepEqual(calls, ['clear-auth', 'clear-workspace:acme']); + assert.deepEqual(calls, ['clear-auth', 'clear-active', 'clear-workspace:acme']); assert.match(trap.stdout, /logged out/); } finally { trap.restore(); @@ -292,12 +276,15 @@ test('runLogout clears shared cloud auth when explicitly requested', async () => } }); -test('runLogout treats --all as an alias for clearing shared cloud auth', async () => { +test('runLogout treats --all as an alias for clearing shared cloud auth + active pointer', async () => { const calls: string[] = []; const restoreDeps = configureDeployCommandForTest({ clearStoredAuth: async () => { calls.push('clear-auth'); }, + clearActiveWorkspace: async () => { + calls.push('clear-active'); + }, clearStoredWorkspaceToken: async (workspace?: string) => { calls.push(`clear-workspace:${workspace ?? ''}`); } @@ -306,7 +293,7 @@ test('runLogout treats --all as an alias for clearing shared cloud auth', async try { await runLogout(['--all']); assert.deepEqual(trap.exits, [0]); - assert.deepEqual(calls, ['clear-auth', 'clear-workspace:']); + assert.deepEqual(calls, ['clear-auth', 'clear-active', 'clear-workspace:']); assert.match(trap.stdout, /logged out/); } finally { trap.restore(); diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index 71e0bef..d815bb2 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -4,14 +4,14 @@ import { clearStoredAuth, defaultApiUrl, ensureAuthenticated, - issueWorkspaceToken, type StoredAuth } from '@agent-relay/cloud'; import { + clearActiveWorkspace, clearStoredWorkspaceToken, createTerminalIO, deploy, - writeStoredWorkspaceToken, + writeActiveWorkspace, type DeployMode, type DeployOptions, type ModeLaunchHandle @@ -22,20 +22,20 @@ type LoginApiClient = Pick; type DeployCommandDeps = { ensureAuthenticated: typeof ensureAuthenticated; - issueWorkspaceToken: typeof issueWorkspaceToken; clearStoredAuth: typeof clearStoredAuth; clearStoredWorkspaceToken: typeof clearStoredWorkspaceToken; - writeStoredWorkspaceToken: typeof writeStoredWorkspaceToken; + clearActiveWorkspace: typeof clearActiveWorkspace; + writeActiveWorkspace: typeof writeActiveWorkspace; createTerminalIO: typeof createTerminalIO; createCloudApiClient(auth: StoredAuth, apiUrl: string): LoginApiClient; }; const defaultDeployCommandDeps: DeployCommandDeps = { ensureAuthenticated, - issueWorkspaceToken, clearStoredAuth, clearStoredWorkspaceToken, - writeStoredWorkspaceToken, + clearActiveWorkspace, + writeActiveWorkspace, createTerminalIO, createCloudApiClient(auth, apiUrl) { return new CloudApiClient({ @@ -148,17 +148,16 @@ export async function runLogin(args: readonly string[]): Promise { } chosen = await pickWorkspaceInteractive(workspaces, io); } - const tokenResp = await deployCommandDeps.issueWorkspaceToken(chosen, { - apiUrl, - name: 'agentworkforce-cli' - }); - const token = readWorkspaceToken(tokenResp); - const workspaceId = readWorkspaceId(tokenResp) ?? findWorkspace(workspaces, chosen)?.id ?? chosen; - await deployCommandDeps.writeStoredWorkspaceToken({ + // No workspace-scoped token mint — cloud's resolveRequestAuth accepts + // the shared @agent-relay/cloud accessToken as Bearer directly. We just + // persist a pointer recording which workspace the user picked so + // resolveWorkspaceToken can pair it with the shared accessToken on + // each subsequent deploy call. + const match = findWorkspace(workspaces, chosen); + await deployCommandDeps.writeActiveWorkspace({ workspace: chosen, - workspaceSlug: findWorkspace(workspaces, chosen)?.slug ?? chosen, - workspaceId, - token, + ...(match?.slug ? { workspaceSlug: match.slug } : {}), + ...(match?.id ? { workspaceId: match.id } : {}), cloudUrl: apiUrl }); process.stdout.write(`\nlogged in: ${chosen}\n`); @@ -181,6 +180,12 @@ export async function runLogout(args: readonly string[]): Promise { if (opts.cloudAuth) { await deployCommandDeps.clearStoredAuth(); } + // Always drop the active-workspace pointer — `agentworkforce logout` + // should detach this machine from any workspace regardless of whether + // the user also wants to nuke the shared cloud login. + await deployCommandDeps.clearActiveWorkspace(); + // Legacy keychain workspace token is also cleared so users mid-upgrade + // don't end up with a stale minted token after logout. await deployCommandDeps.clearStoredWorkspaceToken(opts.workspace); process.stdout.write(opts.cloudAuth ? 'logged out\n' : 'workspace login cleared\n'); process.exit(0); @@ -213,10 +218,12 @@ Flags: const LOGIN_USAGE = `usage: agentworkforce login [flags] -Connect this machine to a workforce workspace using the browser OAuth flow. -If an Agent Relay Cloud login already exists, it is reused and the workforce -workspace token is stored beside it. Set WORKFORCE_LOGIN_FILE to force the -legacy ~/.agentworkforce/login.json-style fallback instead. +Connect this machine to a workforce workspace. Reuses the shared +Agent Relay Cloud login (\`~/.agent-relay/cloud-auth.json\`) for the bearer +credential and stores a small pointer at \`~/.agentworkforce/active.json\` +recording which workspace this machine targets. No separate workspace-scoped +token is minted; cloud accepts the shared accessToken as Authorization: Bearer +for deploy endpoints. Flags: --workspace Workforce workspace; defaults to WORKFORCE_WORKSPACE_ID or prompt @@ -515,28 +522,6 @@ function workspaceKey(workspace: LoginWorkspace): string { return workspace.slug ?? workspace.id; } -function readWorkspaceToken(value: unknown): string { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('workspace token response was not an object'); - } - const record = value as Record; - const token = readString(record, 'token') ?? readString(record, 'key'); - if (!token) { - throw new Error('workspace token response did not include a token'); - } - return token; -} - -function readWorkspaceId(value: unknown): string | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - const record = value as Record; - const direct = readString(record, 'workspaceId'); - if (direct) return direct; - const nested = record.workspaceToken; - if (!nested || typeof nested !== 'object' || Array.isArray(nested)) return undefined; - return readString(nested as Record, 'workspaceId'); -} - function readString(record: Record, key: string): string | undefined { const value = record[key]; return typeof value === 'string' && value.trim() ? value.trim() : undefined; diff --git a/packages/deploy/src/index.ts b/packages/deploy/src/index.ts index b60b8bb..273442f 100644 --- a/packages/deploy/src/index.ts +++ b/packages/deploy/src/index.ts @@ -26,14 +26,18 @@ export { type ProviderSubscriptionResolver } from './connect.js'; export { - envWorkspaceAuth, + clearActiveWorkspace, clearStoredWorkspaceToken, + envWorkspaceAuth, loadActiveWorkspaceToken, loadWorkspaceToken, + readActiveWorkspace, resolveWorkspaceToken, resolveWorkspaceTokenFromEnv, storeWorkspaceToken, + writeActiveWorkspace, writeStoredWorkspaceToken, + type ActiveWorkspacePointer, type StoredWorkspaceLogin, type WorkspaceAuth, type WorkspaceAuthToken diff --git a/packages/deploy/src/login.test.ts b/packages/deploy/src/login.test.ts index 014981b..7ebf35e 100644 --- a/packages/deploy/src/login.test.ts +++ b/packages/deploy/src/login.test.ts @@ -1,12 +1,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { + clearActiveWorkspace, clearStoredWorkspaceToken, loadWorkspaceToken, + readActiveWorkspace, resolveWorkspaceToken, + writeActiveWorkspace, writeStoredWorkspaceToken } from './login.js'; import { createBufferedIO } from './io.js'; @@ -96,43 +99,316 @@ test('resolveWorkspaceToken prefers env token before stored login', async () => test('resolveWorkspaceToken reads stored token and fails clearly with --no-prompt', async () => { const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-login-resolve-')); const loginFile = path.join(dir, 'login.json'); + const activeFile = path.join(dir, 'active.json'); + try { + // Suppress the shared-auth tier (Tier 2) so the legacy keychain path + // (Tier 3) is exercised — otherwise a real ~/.agent-relay login on + // the dev's machine satisfies Tier 2 first and the assertion flips. + await withActiveWorkspaceEnv({ activeFile, sharedAuth: null }, async () => { + await withLoginEnv({ loginFile }, async () => { + await writeStoredWorkspaceToken({ workspace: 'stored', token: 'tok-stored' }); + assert.deepEqual( + await resolveWorkspaceToken({ + workspace: 'stored', + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }), + { token: 'tok-stored', workspace: 'stored' } + ); + }); + await withLoginEnv({ loginFile: path.join(dir, 'missing.json') }, async () => { + await assert.rejects( + resolveWorkspaceToken({ + workspace: 'missing', + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }), + /run `agentworkforce login`/ + ); + }); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('clearStoredWorkspaceToken removes the stored token file', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-login-clear-')); + const loginFile = path.join(dir, 'login.json'); try { await withLoginEnv({ loginFile }, async () => { await writeStoredWorkspaceToken({ workspace: 'stored', token: 'tok-stored' }); - assert.deepEqual( - await resolveWorkspaceToken({ - workspace: 'stored', + await clearStoredWorkspaceToken('stored'); + assert.equal(await loadWorkspaceToken('stored'), null); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// active.json pointer + shared-auth resolution tier +// --------------------------------------------------------------------------- + +/** + * Back up the real ~/.agent-relay/cloud-auth.json (if present) for the + * duration of a test, so we can install a controlled fixture or assert + * "no shared auth" without flaking on machines where the dev has already + * logged into agent-relay. `AUTH_FILE_PATH` in `@agent-relay/cloud` is + * computed once at import time from `os.homedir()`, so there's no env + * knob to redirect it — backing up the actual file is the cleanest hook. + */ +const SHARED_AUTH_FILE = path.join(os.homedir(), '.agent-relay', 'cloud-auth.json'); + +async function withActiveWorkspaceEnv( + env: { + activeFile?: string; + /** + * Shared @agent-relay/cloud auth fixture. When non-null, the test + * writes it to ~/.agent-relay/cloud-auth.json. When null, the test + * makes sure that file is absent. The real user's file (if any) is + * backed up and restored. + */ + sharedAuth?: { + accessToken: string; + refreshToken?: string; + accessTokenExpiresAt?: string; + apiUrl?: string; + } | null; + }, + fn: () => Promise +): Promise { + const previous = { + WORKFORCE_ACTIVE_WORKSPACE_FILE: process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE, + CLOUD_API_URL: process.env.CLOUD_API_URL, + CLOUD_API_ACCESS_TOKEN: process.env.CLOUD_API_ACCESS_TOKEN, + CLOUD_API_REFRESH_TOKEN: process.env.CLOUD_API_REFRESH_TOKEN, + CLOUD_API_ACCESS_TOKEN_EXPIRES_AT: process.env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT + }; + // Clear CLOUD_API_* env vars so readEnvAuth() doesn't shortcut past our + // file-based fixture — every test exercises the on-disk path explicitly. + delete process.env.CLOUD_API_URL; + delete process.env.CLOUD_API_ACCESS_TOKEN; + delete process.env.CLOUD_API_REFRESH_TOKEN; + delete process.env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT; + + if (env.activeFile === undefined) delete process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE; + else process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = env.activeFile; + + // Back up the real shared-auth file so we can swap in a fixture (or + // assert absence) without nuking the dev's actual agent-relay login. + const existingAuth = await readFile(SHARED_AUTH_FILE, 'utf8').catch(() => null); + if (existingAuth !== null) { + await rm(SHARED_AUTH_FILE, { force: true }); + } + + if (env.sharedAuth) { + await mkdir(path.dirname(SHARED_AUTH_FILE), { recursive: true, mode: 0o700 }); + await writeFile( + SHARED_AUTH_FILE, + JSON.stringify({ + apiUrl: env.sharedAuth.apiUrl ?? 'https://cloud.example.test', + accessToken: env.sharedAuth.accessToken, + refreshToken: env.sharedAuth.refreshToken ?? 'refresh-token', + accessTokenExpiresAt: + env.sharedAuth.accessTokenExpiresAt ?? '2999-01-01T00:00:00.000Z' + }, null, 2) + '\n', + { encoding: 'utf8', mode: 0o600 } + ); + } + + try { + return await fn(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key as keyof typeof previous]; + } else { + process.env[key as keyof typeof previous] = value; + } + } + // Always restore the real shared-auth file (or remove our fixture). + if (existingAuth !== null) { + await mkdir(path.dirname(SHARED_AUTH_FILE), { recursive: true, mode: 0o700 }); + await writeFile(SHARED_AUTH_FILE, existingAuth, { encoding: 'utf8', mode: 0o600 }); + } else { + await rm(SHARED_AUTH_FILE, { force: true }); + } + } +} + +test('writeActiveWorkspace + readActiveWorkspace round-trip the pointer file', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-active-rt-')); + const activeFile = path.join(dir, 'active.json'); + try { + await withActiveWorkspaceEnv({ activeFile, sharedAuth: null }, async () => { + assert.equal(await readActiveWorkspace(), null); + await writeActiveWorkspace({ + workspace: 'acme', + workspaceSlug: 'acme', + workspaceId: 'ws-1', + cloudUrl: 'https://cloud.example.test' + }); + const raw = JSON.parse(await readFile(activeFile, 'utf8')) as Record; + assert.equal(raw.workspace, 'acme'); + assert.equal(raw.workspaceId, 'ws-1'); + assert.equal(raw.cloudUrl, 'https://cloud.example.test'); + assert.ok(typeof raw.setAt === 'string' && raw.setAt.length > 0); + const read = await readActiveWorkspace(); + assert.equal(read?.workspace, 'acme'); + assert.equal(read?.workspaceId, 'ws-1'); + assert.equal(read?.cloudUrl, 'https://cloud.example.test'); + await clearActiveWorkspace(); + assert.equal(await readActiveWorkspace(), null); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('resolveWorkspaceToken returns shared accessToken as Bearer when active.json + shared auth are present', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-resolve-shared-')); + const activeFile = path.join(dir, 'active.json'); + const loginFile = path.join(dir, 'login.json'); // empty — no legacy fallback + try { + await withActiveWorkspaceEnv({ + activeFile, + sharedAuth: { + accessToken: 'shared-access', + refreshToken: 'shared-refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + } + }, async () => { + await withLoginEnv({ loginFile }, async () => { + await writeActiveWorkspace({ + workspace: 'acme', + workspaceSlug: 'acme', + workspaceId: 'ws-1', + cloudUrl: 'https://cloud.example.test' + }); + const resolved = await resolveWorkspaceToken({ cloudUrl: 'https://cloud.example.test', io: createBufferedIO(), noPrompt: true - }), - { token: 'tok-stored', workspace: 'stored' } - ); + }); + assert.equal(resolved.token, 'shared-access'); + assert.equal(resolved.workspace, 'acme'); + }); }); - await withLoginEnv({ loginFile: path.join(dir, 'missing.json') }, async () => { - await assert.rejects( - resolveWorkspaceToken({ - workspace: 'missing', + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('resolveWorkspaceToken falls back to the legacy keychain path when active.json is absent', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-resolve-legacy-')); + const activeFile = path.join(dir, 'active.json'); // never written + const loginFile = path.join(dir, 'login.json'); + try { + await withActiveWorkspaceEnv({ activeFile, sharedAuth: null }, async () => { + await withLoginEnv({ loginFile }, async () => { + await writeStoredWorkspaceToken({ + workspace: 'legacy-ws', + token: 'legacy-token' + }); + const resolved = await resolveWorkspaceToken({ + workspace: 'legacy-ws', cloudUrl: 'https://cloud.example.test', io: createBufferedIO(), noPrompt: true - }), - /run `agentworkforce login`/ - ); + }); + assert.equal(resolved.token, 'legacy-token'); + assert.equal(resolved.workspace, 'legacy-ws'); + }); }); } finally { await rm(dir, { recursive: true, force: true }); } }); -test('clearStoredWorkspaceToken removes the stored token file', async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-login-clear-')); +test('resolveWorkspaceToken throws clear-instructions error when nothing is configured', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-resolve-empty-')); + const activeFile = path.join(dir, 'active.json'); // never written + const loginFile = path.join(dir, 'login.json'); // never written + try { + await withActiveWorkspaceEnv({ activeFile, sharedAuth: null }, async () => { + await withLoginEnv({ loginFile }, async () => { + await assert.rejects( + resolveWorkspaceToken({ + workspace: 'nothing', + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }), + /run `agentworkforce login`/ + ); + }); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('resolveWorkspaceToken prefers WORKFORCE_WORKSPACE_TOKEN env over shared-auth tier', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-resolve-env-')); + const activeFile = path.join(dir, 'active.json'); const loginFile = path.join(dir, 'login.json'); try { - await withLoginEnv({ loginFile }, async () => { - await writeStoredWorkspaceToken({ workspace: 'stored', token: 'tok-stored' }); - await clearStoredWorkspaceToken('stored'); - assert.equal(await loadWorkspaceToken('stored'), null); + await withActiveWorkspaceEnv({ + activeFile, + sharedAuth: { + accessToken: 'shared-access', + refreshToken: 'shared-refresh' + } + }, async () => { + await withLoginEnv({ + loginFile, + workspaceId: 'env-ws', + workspaceToken: 'env-token' + }, async () => { + await writeActiveWorkspace({ workspace: 'active-ws', cloudUrl: 'https://cloud.example.test' }); + const resolved = await resolveWorkspaceToken({ + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO() + }); + assert.equal(resolved.token, 'env-token'); + assert.equal(resolved.workspace, 'env-ws'); + }); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('resolveWorkspaceToken uses requested workspace arg even when active.json has a different value', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-resolve-arg-')); + const activeFile = path.join(dir, 'active.json'); + const loginFile = path.join(dir, 'login.json'); + try { + await withActiveWorkspaceEnv({ + activeFile, + sharedAuth: { + accessToken: 'shared-access', + refreshToken: 'shared-refresh' + } + }, async () => { + await withLoginEnv({ loginFile }, async () => { + await writeActiveWorkspace({ + workspace: 'default-ws', + workspaceSlug: 'default-ws', + cloudUrl: 'https://cloud.example.test' + }); + const resolved = await resolveWorkspaceToken({ + workspace: 'override-ws', + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }); + assert.equal(resolved.token, 'shared-access'); + assert.equal(resolved.workspace, 'override-ws'); + }); }); } finally { await rm(dir, { recursive: true, force: true }); diff --git a/packages/deploy/src/login.ts b/packages/deploy/src/login.ts index 4b8e811..d91b035 100644 --- a/packages/deploy/src/login.ts +++ b/packages/deploy/src/login.ts @@ -54,6 +54,85 @@ function loginFile(): string { || path.join(homedir(), '.agentworkforce', 'login.json'); } +/** + * Tiny pointer file recording the workspace + cloud URL the user picked at + * `agentworkforce login`. The access token itself lives in the shared + * `@agent-relay/cloud` auth store (`~/.agent-relay/cloud-auth.json`); this + * file just remembers which workspace to target so `resolveWorkspaceToken` + * can pair "user identity" (shared accessToken) with "which workspace to + * deploy to" without re-prompting on every invocation. + * + * This file is non-secret. The actual bearer credential lives in the + * shared auth file, which `@agent-relay/cloud` manages. + */ +export interface ActiveWorkspacePointer { + /** Whatever the user passed at login time (slug, id, or display name). */ + workspace: string; + /** Canonical slug, if known. */ + workspaceSlug?: string; + /** Canonical workspace id (uuid), if known. */ + workspaceId?: string; + /** Cloud base URL we authed against. */ + cloudUrl?: string; + /** ISO timestamp of the most recent write. */ + setAt: string; +} + +function activeWorkspaceFile(): string { + return process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE?.trim() + || path.join(homedir(), '.agentworkforce', 'active.json'); +} + +export async function readActiveWorkspace(): Promise { + const raw = await readFile(activeWorkspaceFile(), 'utf8').catch(() => ''); + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const record = parsed as Record; + const workspace = typeof record.workspace === 'string' ? record.workspace.trim() : ''; + if (!workspace) return null; + return { + workspace, + setAt: typeof record.setAt === 'string' ? record.setAt : new Date(0).toISOString(), + ...(typeof record.workspaceSlug === 'string' && record.workspaceSlug.trim() + ? { workspaceSlug: record.workspaceSlug.trim() } + : {}), + ...(typeof record.workspaceId === 'string' && record.workspaceId.trim() + ? { workspaceId: record.workspaceId.trim() } + : {}), + ...(typeof record.cloudUrl === 'string' && record.cloudUrl.trim() + ? { cloudUrl: record.cloudUrl.trim() } + : {}) + }; + } catch { + return null; + } +} + +export async function writeActiveWorkspace( + input: Omit +): Promise { + const file = activeWorkspaceFile(); + await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + const payload: ActiveWorkspacePointer = { + workspace: input.workspace, + ...(input.workspaceSlug ? { workspaceSlug: input.workspaceSlug } : {}), + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), + ...(input.cloudUrl ? { cloudUrl: input.cloudUrl } : {}), + setAt: new Date().toISOString() + }; + await writeFile(file, `${JSON.stringify(payload, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600 + }); +} + +export async function clearActiveWorkspace(): Promise { + await rm(activeWorkspaceFile(), { force: true }); +} + /** * Environment-backed fallback resolver: reads `WORKFORCE_WORKSPACE_ID` * and `WORKFORCE_WORKSPACE_TOKEN` from `process.env`. Useful in CI and as @@ -118,6 +197,9 @@ export async function resolveWorkspaceToken(args: { const envWorkspace = (process.env.WORKFORCE_WORKSPACE_ID ?? '').trim(); const fromEnv = (process.env.WORKFORCE_WORKSPACE_TOKEN ?? '').trim(); const requestedWorkspace = (args.workspace ?? '').trim(); + + // Tier 1: explicit env vars (CI / headless). Preserved untouched so + // pipelines that already set WORKFORCE_WORKSPACE_TOKEN keep working. if (fromEnv && (requestedWorkspace || envWorkspace)) { return { token: fromEnv, @@ -125,6 +207,27 @@ export async function resolveWorkspaceToken(args: { }; } + // Tier 2: shared @agent-relay/cloud accessToken + active.json pointer. + // This is the interactive-CLI default after `agentworkforce login`: the + // user already has a valid accessToken in ~/.agent-relay/cloud-auth.json + // and cloud's resolveRequestAuth accepts that token as Bearer for the + // deployment endpoints — no workspace-scoped token mint required. + const sharedAuth = await readSharedAuthForBearer().catch(() => null); + if (sharedAuth?.accessToken) { + const active = await readActiveWorkspace().catch(() => null); + const workspace = requestedWorkspace + || envWorkspace + || active?.workspaceSlug + || active?.workspaceId + || active?.workspace; + if (workspace) { + return { token: sharedAuth.accessToken, workspace }; + } + } + + // Tier 3: legacy keychain / file-stored workspace token. Kept for users + // mid-upgrade who already have a minted workspace token from the old + // login flow. const stored = await loadWorkspaceToken(requestedWorkspace || undefined, args.cloudUrl); if (stored) { return { @@ -135,16 +238,33 @@ export async function resolveWorkspaceToken(args: { if (args.noPrompt) { throw new Error( - `no workspace token resolved${requestedWorkspace ? ` for ${requestedWorkspace}` : ''}: run \`agentworkforce login\` or set WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN` + `no workspace credentials resolved${requestedWorkspace ? ` for ${requestedWorkspace}` : ''}: run \`agentworkforce login\` or set WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN` ); } - args.io.info('cloud: no workspace token found; run `agentworkforce login` to connect this machine'); + args.io.info('cloud: no workspace credentials found; run `agentworkforce login` to connect this machine'); throw new Error( - `no workspace token resolved${requestedWorkspace ? ` for ${requestedWorkspace}` : ''}: run \`agentworkforce login\` or set WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN` + `no workspace credentials resolved${requestedWorkspace ? ` for ${requestedWorkspace}` : ''}: run \`agentworkforce login\` or set WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN` ); } +/** + * Read the shared @agent-relay/cloud auth, refreshing if the accessToken + * is expired and a refreshToken is available. Returns `null` on any + * failure — callers fall through to the next resolution tier. + */ +async function readSharedAuthForBearer(): Promise { + const auth = await readStoredAuth().catch(() => null); + if (!auth || !auth.accessToken) return null; + if (!isExpired(auth.accessTokenExpiresAt)) return auth; + if (!auth.refreshToken) return null; + try { + return await refreshStoredAuth(auth); + } catch { + return null; + } +} + export async function loadWorkspaceToken( workspace?: string, cloudUrl?: string