From 0ffd00e8c2a7c1fd42b4067dd675e78d0516472a Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Sat, 13 Jun 2026 13:10:45 +0200 Subject: [PATCH 1/3] Unify workforce cloud auth session Co-Authored-By: OpenAI --- packages/cli/package.json | 2 +- packages/cli/src/deploy-command.test.ts | 180 +++++--- packages/cli/src/deploy-command.ts | 95 +++-- packages/cli/src/destroy-command.test.ts | 32 +- packages/cli/src/destroy-command.ts | 7 +- packages/deploy/package.json | 2 +- packages/deploy/src/deploy.test.ts | 7 +- packages/deploy/src/deploy.ts | 12 +- packages/deploy/src/login.test.ts | 422 +++---------------- packages/deploy/src/login.ts | 506 ++++++----------------- pnpm-lock.yaml | 22 +- 11 files changed, 390 insertions(+), 897 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 911fc1d3..4692fd3b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,7 +10,7 @@ "package.json" ], "dependencies": { - "@agent-relay/cloud": "^6.0.17", + "@agent-relay/cloud": "^8.7.0", "@agentworkforce/deploy": "workspace:*", "@agentworkforce/persona-kit": "workspace:*", "@agentworkforce/runtime": "workspace:*", diff --git a/packages/cli/src/deploy-command.test.ts b/packages/cli/src/deploy-command.test.ts index f270fc31..ebf36a40 100644 --- a/packages/cli/src/deploy-command.test.ts +++ b/packages/cli/src/deploy-command.test.ts @@ -54,24 +54,42 @@ function trapExit(throwOnExit = true): ExitTrap { return trap; } -test('runLogin uses cloud SDK auth, picks a workspace, and writes the active pointer (no token mint)', async () => { +test('runLogin uses cloud SDK auth, picks a workspace, and pins the canonical relay workspace key', async () => { const calls: string[] = []; - const writes: unknown[] = []; + const pinned: unknown[] = []; const restoreDeps = configureDeployCommandForTest({ createTerminalIO: () => createBufferedIO(), - ensureAuthenticated: async (apiUrl: string) => { + ensureCloudSession: async (options?: { apiUrl?: string }) => { + const apiUrl = options?.apiUrl ?? 'https://cloud.example.test'; calls.push(`ensure:${apiUrl}`); return { - apiUrl, - accessToken: 'access', - refreshToken: 'refresh', - accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + auth: { + apiUrl, + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }, + client: { fetch: async () => new Response(null, { status: 500 }) } }; }, createCloudApiClient() { return { async fetch(pathname: string) { calls.push(`fetch:${pathname}`); + if (pathname === '/api/v1/workspaces/ws-1/resolve') { + return new Response(JSON.stringify({ + key: 'rk_live_acme', + workspaceId: 'rw_1234abcd', + relaycastWorkspaceId: 'rw_1234abcd', + relayfileWorkspaceId: 'rf_acme', + relayauthWorkspaceId: 'ra_acme', + slug: 'acme', + name: 'Acme' + }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } return new Response(JSON.stringify({ workspaces: [{ id: 'ws-1', slug: 'acme' }] }), { status: 200, headers: { 'content-type': 'application/json' } @@ -79,8 +97,8 @@ test('runLogin uses cloud SDK auth, picks a workspace, and writes the active poi } }; }, - writeActiveWorkspace: async (pointer: unknown) => { - writes.push(pointer); + setWorkspaceKey: (name: string, key: string) => { + pinned.push({ name, key }); } }); const trap = trapExit(false); @@ -89,33 +107,33 @@ test('runLogin uses cloud SDK auth, picks a workspace, and writes the active poi assert.deepEqual(trap.exits, [0]); assert.deepEqual(calls, [ 'ensure:https://cloud.example.test', - 'fetch:/api/v1/workspaces' + 'fetch:/api/v1/workspaces', + 'fetch:/api/v1/workspaces/ws-1/resolve' ]); - assert.deepEqual(writes, [{ - workspace: 'acme', - workspaceSlug: 'acme', - workspaceId: 'ws-1', - cloudUrl: 'https://cloud.example.test' - }]); - assert.match(trap.stdout, /logged in: acme/); + assert.deepEqual(pinned, [{ name: 'Acme', key: 'rk_live_acme' }]); + assert.match(trap.stdout, /logged in: Acme/); } finally { trap.restore(); restoreDeps(); } }); -test('runLogin with --workspace skips the workspaces list, skips token mint, writes active pointer', async () => { +test('runLogin with --workspace skips the workspaces list and pins the resolved relay workspace key', async () => { const calls: string[] = []; - const writes: unknown[] = []; + const pinned: unknown[] = []; const restoreDeps = configureDeployCommandForTest({ createTerminalIO: () => createBufferedIO(), - ensureAuthenticated: async (apiUrl: string) => { + ensureCloudSession: async (options?: { apiUrl?: string }) => { + const apiUrl = options?.apiUrl ?? 'https://cloud.example.test'; calls.push(`ensure:${apiUrl}`); return { - apiUrl, - accessToken: 'access', - refreshToken: 'refresh', - accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + auth: { + apiUrl, + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }, + client: { fetch: async () => new Response(null, { status: 500 }) } }; }, createCloudApiClient() { @@ -123,12 +141,21 @@ test('runLogin with --workspace skips the workspaces list, skips token mint, wri return { async fetch(pathname: string) { calls.push(`fetch:${pathname}`); - return new Response('should not be called', { status: 500 }); + return new Response(JSON.stringify({ + key: 'rk_live_direct', + workspaceId: 'rw_5678abcd', + relaycastWorkspaceId: 'rw_5678abcd', + relayfileWorkspaceId: 'rf_direct', + relayauthWorkspaceId: 'ra_direct' + }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); } }; }, - writeActiveWorkspace: async (pointer: unknown) => { - writes.push(pointer); + setWorkspaceKey: (name: string, key: string) => { + pinned.push({ name, key }); } }); const trap = trapExit(false); @@ -140,14 +167,14 @@ test('runLogin with --workspace skips the workspaces list, skips token mint, wri '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']); - assert.deepEqual(writes, [{ - workspace: '50587328-441d-4acb-b8f3-dbe1b3c5de99', - cloudUrl: 'https://cloud.example.test' + assert.deepEqual(calls, [ + 'ensure:https://cloud.example.test', + 'createCloudApiClient', + 'fetch:/api/v1/workspaces/50587328-441d-4acb-b8f3-dbe1b3c5de99/resolve' + ]); + assert.deepEqual(pinned, [{ + name: '50587328-441d-4acb-b8f3-dbe1b3c5de99', + key: 'rk_live_direct' }]); assert.match(trap.stdout, /logged in: 50587328-441d-4acb-b8f3-dbe1b3c5de99/); } finally { @@ -159,11 +186,14 @@ test('runLogin with --workspace skips the workspaces list, skips token mint, wri 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' + ensureCloudSession: async (options?: { apiUrl?: string }) => ({ + auth: { + apiUrl: options?.apiUrl ?? 'https://cloud.example.test', + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }, + client: { fetch: async () => new Response(null, { status: 500 }) } }), createCloudApiClient() { return { @@ -175,8 +205,8 @@ test('runLogin without --workspace surfaces a --workspace hint when the workspac } }; }, - writeActiveWorkspace: async () => { - throw new Error('writeActiveWorkspace should not be called when listing fails'); + setWorkspaceKey: () => { + throw new Error('setWorkspaceKey should not be called when listing fails'); } }); const trap = trapExit(false); @@ -194,11 +224,14 @@ test('runLogin without --workspace surfaces a --workspace hint when the workspac 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' + ensureCloudSession: async (options?: { apiUrl?: string }) => ({ + auth: { + apiUrl: options?.apiUrl ?? 'https://cloud.example.test', + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }, + client: { fetch: async () => new Response(null, { status: 500 }) } }), createCloudApiClient() { return { @@ -210,8 +243,8 @@ test('runLogin without --workspace surfaces a no-workspaces message when the lis } }; }, - writeActiveWorkspace: async () => { - throw new Error('writeActiveWorkspace should not be called when no workspaces'); + setWorkspaceKey: () => { + throw new Error('setWorkspaceKey should not be called when no workspaces'); } }); const trap = trapExit(false); @@ -226,7 +259,7 @@ test('runLogin without --workspace surfaces a no-workspaces message when the lis } }); -test('runLogout preserves shared cloud auth and clears the active pointer + legacy keychain token by default', async () => { +test('runLogout preserves shared cloud auth and clears compatibility workspace state by default', async () => { const calls: string[] = []; const restoreDeps = configureDeployCommandForTest({ clearStoredAuth: async () => { @@ -347,23 +380,36 @@ test('parseDeployArgs: malformed --input exits with clean error', () => { } }); -test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before persisting active.json', async () => { - // ensureAuthenticated occasionally returns auth.apiUrl pointing at the - // SST origin-bypass hostname. If we persist that, every subsequent API - // call 401s because session cookies don't cross subdomains. The CLI - // must canonicalize before writing. - const writes: Array<{ cloudUrl?: string }> = []; +test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before resolving the workspace', async () => { + const calls: string[] = []; const restoreDeps = configureDeployCommandForTest({ createTerminalIO: () => createBufferedIO(), - ensureAuthenticated: async () => ({ - apiUrl: 'https://origin.agentrelay.cloud', - accessToken: 'access', - refreshToken: 'refresh', - accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + ensureCloudSession: async () => ({ + auth: { + apiUrl: 'https://origin.agentrelay.cloud', + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }, + client: { fetch: async () => new Response(null, { status: 500 }) } }), - createCloudApiClient() { + createCloudApiClient(_auth, apiUrl) { + calls.push(`client:${apiUrl}`); return { - async fetch(_pathname: string) { + async fetch(pathname: string) { + calls.push(`fetch:${pathname}`); + if (pathname === '/api/v1/workspaces/ws-1/resolve') { + return new Response(JSON.stringify({ + key: 'rk_live_acme', + workspaceId: 'rw_1234abcd', + relaycastWorkspaceId: 'rw_1234abcd', + relayfileWorkspaceId: 'rf_acme', + relayauthWorkspaceId: 'ra_acme' + }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } return new Response(JSON.stringify({ workspaces: [{ id: 'ws-1', slug: 'acme' }] }), { status: 200, headers: { 'content-type': 'application/json' } @@ -371,16 +417,14 @@ test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before persisting ac } }; }, - writeActiveWorkspace: async (pointer: { cloudUrl?: string }) => { - writes.push(pointer); - } + setWorkspaceKey: () => {} }); const trap = trapExit(false); try { await runLogin(['--cloud-url', 'https://agentrelay.com/cloud']); assert.deepEqual(trap.exits, [0]); - assert.equal(writes.length, 1); - assert.equal(writes[0].cloudUrl, 'https://agentrelay.com/cloud'); + assert.ok(calls.every((call) => !call.includes('origin.agentrelay.cloud')), calls.join('\n')); + assert.ok(calls.includes('client:https://agentrelay.com/cloud')); } finally { trap.restore(); restoreDeps(); diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index 34632160..e64f8b75 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -3,7 +3,8 @@ import { CloudApiClient, clearStoredAuth, defaultApiUrl, - ensureAuthenticated, + ensureCloudSession, + setWorkspaceKey, type StoredAuth } from '@agent-relay/cloud'; import { @@ -12,7 +13,6 @@ import { clearStoredWorkspaceToken, createTerminalIO, deploy, - writeActiveWorkspace, type CloudAuthRecoveryResolver, type DeployMode, type DeployOptions, @@ -23,21 +23,25 @@ import { type LoginApiClient = Pick; type DeployCommandDeps = { - ensureAuthenticated: typeof ensureAuthenticated; + ensureCloudSession(options?: { + apiUrl?: string; + force?: boolean; + interactive?: boolean; + }): Promise<{ auth: StoredAuth }>; clearStoredAuth: typeof clearStoredAuth; clearStoredWorkspaceToken: typeof clearStoredWorkspaceToken; clearActiveWorkspace: typeof clearActiveWorkspace; - writeActiveWorkspace: typeof writeActiveWorkspace; + setWorkspaceKey(name: string, key: string): unknown; createTerminalIO: typeof createTerminalIO; createCloudApiClient(auth: StoredAuth, apiUrl: string): LoginApiClient; }; const defaultDeployCommandDeps: DeployCommandDeps = { - ensureAuthenticated, + ensureCloudSession, clearStoredAuth, clearStoredWorkspaceToken, clearActiveWorkspace, - writeActiveWorkspace, + setWorkspaceKey, createTerminalIO, createCloudApiClient(auth, apiUrl) { return new CloudApiClient({ @@ -117,16 +121,15 @@ function createDeployAuthRecovery(opts: DeployOptions): CloudAuthRecoveryResolve if (!ok) return false; io.info(`cloud: starting login because integration auth failed (${reason})`); - const auth = await deployCommandDeps.ensureAuthenticated(cloudUrl, { force: true }); - const apiUrl = normalizeCloudUrl(auth.apiUrl || cloudUrl); - const activeWorkspace = opts.workspace ?? workspace; - await deployCommandDeps.writeActiveWorkspace({ - workspace: activeWorkspace, - cloudUrl: apiUrl + const session = await deployCommandDeps.ensureCloudSession({ + apiUrl: cloudUrl, + force: true, + interactive: true }); + const activeWorkspace = opts.workspace ?? workspace; io.info(`cloud: logged in for workspace ${activeWorkspace}; retrying integration check`); return { - token: auth.accessToken + token: session.auth.accessToken }; } }; @@ -153,12 +156,8 @@ export async function runLogin(args: readonly string[]): Promise { )); try { - const auth = await deployCommandDeps.ensureAuthenticated(cloudUrl); - // Canonicalize what ensureAuthenticated handed back — when the auth - // request happens to route through cloud's edge-bypass hostname, - // auth.apiUrl can be `https://origin.agentrelay.cloud` even though - // the user's session cookies are scoped to `agentrelay.com`. Storing - // that URL is what causes every subsequent API call to 401. + const session = await deployCommandDeps.ensureCloudSession({ apiUrl: cloudUrl }); + const auth = session.auth; const apiUrl = canonicalizeCloudUrl(normalizeCloudUrl(auth.apiUrl || cloudUrl)); let workspaces: LoginWorkspace[] = []; let chosen: string; @@ -174,19 +173,11 @@ export async function runLogin(args: readonly string[]): Promise { } chosen = await pickWorkspaceInteractive(workspaces, io); } - // 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, - ...(match?.slug ? { workspaceSlug: match.slug } : {}), - ...(match?.id ? { workspaceId: match.id } : {}), - cloudUrl: apiUrl - }); - process.stdout.write(`\nlogged in: ${chosen}\n`); + const descriptor = await resolveWorkspaceForLogin(auth, apiUrl, match?.id ?? chosen); + const workspaceName = descriptor.name ?? descriptor.slug ?? match?.slug ?? match?.name ?? chosen; + deployCommandDeps.setWorkspaceKey(workspaceName, descriptor.key); + process.stdout.write(`\nlogged in: ${workspaceName}\n`); process.exit(0); } catch (err) { process.stderr.write( @@ -245,9 +236,8 @@ Flags: const LOGIN_USAGE = `usage: agentworkforce login [flags] -Connect this machine to a workforce workspace. Opens the browser to sign in -to the workforce cloud and stores a small pointer at -\`~/.agentworkforce/active.json\` recording which workspace this machine targets. +Connect this machine to a workforce workspace through the canonical +\`agent-relay\` cloud session and active workspace store. Flags: --workspace Workforce workspace; defaults to WORKFORCE_WORKSPACE_ID or prompt @@ -480,6 +470,13 @@ type LoginWorkspace = { name?: string; }; +type LoginWorkspaceDescriptor = { + key: string; + relaycastWorkspaceId: string; + name?: string; + slug?: string; +}; + async function listWorkspacesForLogin(auth: StoredAuth, apiUrl: string): Promise { const client = deployCommandDeps.createCloudApiClient(auth, apiUrl); const res = await client.fetch('/api/v1/workspaces'); @@ -501,6 +498,36 @@ async function listWorkspacesForLogin(auth: StoredAuth, apiUrl: string): Promise return parseWorkspaceList(await who.json().catch(() => null)); } +async function resolveWorkspaceForLogin( + auth: StoredAuth, + apiUrl: string, + workspace: string +): Promise { + const client = deployCommandDeps.createCloudApiClient(auth, apiUrl); + const res = await client.fetch(`/api/v1/workspaces/${encodeURIComponent(workspace)}/resolve`); + if (!res.ok) { + throw new Error(`workspace resolve failed: ${res.status} ${await res.text().catch(() => '')}`.trim()); + } + const payload = await res.json().catch(() => null); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('workspace resolve returned an invalid descriptor'); + } + const record = payload as Record; + const key = readString(record, 'key') ?? readString(record, 'relaycastApiKey') ?? ''; + const relaycastWorkspaceId = readString(record, 'relaycastWorkspaceId') + ?? readString(record, 'workspaceId') + ?? ''; + if (!key || !relaycastWorkspaceId) { + throw new Error('workspace resolve returned an incomplete descriptor'); + } + return { + key, + relaycastWorkspaceId, + ...(readString(record, 'name') ? { name: readString(record, 'name') } : {}), + ...(readString(record, 'slug') ? { slug: readString(record, 'slug') } : {}) + }; +} + async function pickWorkspaceInteractive( workspaces: readonly LoginWorkspace[], io: ReturnType diff --git a/packages/cli/src/destroy-command.test.ts b/packages/cli/src/destroy-command.test.ts index baf45efe..7b4f1828 100644 --- a/packages/cli/src/destroy-command.test.ts +++ b/packages/cli/src/destroy-command.test.ts @@ -98,9 +98,7 @@ function withTokenEnv(token: string, workspace: string): () => void { /** * Pin every filesystem-backed auth source to definitely-missing/disabled * paths so the destroy CLI tests don't accidentally pick up the host - * developer's `~/.agentworkforce/active.json` or `~/.agent-relay/cloud-auth.json`. - * Tests that intentionally exercise the active.json fallback override - * `WORKFORCE_ACTIVE_WORKSPACE_FILE` after this runs. + * developer's legacy `~/.agentworkforce/active.json` or cloud-auth state. */ function isolateAuthFiles(): () => void { const prevActive = process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE; @@ -273,10 +271,8 @@ test('runDestroy: missing workspace exits 1', async () => { try { await assert.rejects(runDestroy([AGENT_UUID, '--no-prompt']), /__exit_trap__:1/); assert.deepEqual(trap.exits, [1]); - // Accept either the orchestrator-level message ("no workspace resolved") - // or the auth-resolver message ("no workspace credentials resolved") - // — both are valid pre-network failures. - assert.match(trap.stderr, /no workspace (credentials )?resolved/); + assert.match(trap.stderr, /No active Agent Relay workspace found/); + assert.match(trap.stderr, /agent-relay workspace/); assert.equal(fetchTrap.calls.length, 0); } finally { trap.restore(); @@ -478,11 +474,9 @@ test('runDestroy: HTML 404 body is replaced with a hint, not dumped verbatim', a } }); -test('runDestroy: reads active.json cloudUrl when no flag and no env is set', async () => { - // The destroy command must consult `~/.agentworkforce/active.json` for - // the cloud URL just like the deploy orchestrator does. Without this, - // a user who ran `agentworkforce login` (which writes active.json with - // the canonical cloud URL) would still hit the legacy default. +test('runDestroy: ignores legacy active.json cloudUrl when no flag and no env is set', async () => { + // The canonical Agent Relay session owns the active workspace now. + // Legacy active.json state must not choose the cloud URL. const restoreIsolate = isolateAuthFiles(); const prevToken = process.env.WORKFORCE_WORKSPACE_TOKEN; const prevWs = process.env.WORKFORCE_WORKSPACE_ID; @@ -493,10 +487,10 @@ test('runDestroy: reads active.json cloudUrl when no flag and no env is set', as delete process.env.WORKFORCE_DEPLOY_CLOUD_URL; delete process.env.WORKFORCE_CLOUD_URL; - const tmp = await mkdtemp(path.join(os.tmpdir(), 'aw-destroy-active-')); - const activeFile = path.join(tmp, 'active.json'); + const legacyDir = await mkdtemp(path.join(os.tmpdir(), 'aw-destroy-active-')); + process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = path.join(legacyDir, 'active.json'); await writeFile( - activeFile, + process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE, JSON.stringify({ workspace: WORKSPACE, workspaceId: WORKSPACE, @@ -505,7 +499,6 @@ test('runDestroy: reads active.json cloudUrl when no flag and no env is set', as }), 'utf8' ); - process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = activeFile; const fetchTrap = trapFetch( async () => @@ -521,12 +514,13 @@ test('runDestroy: reads active.json cloudUrl when no flag and no env is set', as ); const trap = trapIO(); try { - // No `--cloud-url` flag. The command must derive the URL from active.json. + // No `--cloud-url` flag. The command must use the canonical default, + // not the stale active.json value. await assert.rejects(runDestroy([AGENT_UUID]), /__exit_trap__:0/); assert.equal(fetchTrap.calls.length, 1); assert.equal( fetchTrap.calls[0].url, - `https://active.example.test/cloud/api/v1/workspaces/${WORKSPACE}/deployments/${AGENT_UUID}` + `https://agentrelay.com/cloud/api/v1/workspaces/${WORKSPACE}/deployments/${AGENT_UUID}` ); } finally { trap.restore(); @@ -538,6 +532,6 @@ test('runDestroy: reads active.json cloudUrl when no flag and no env is set', as if (prevCloudA !== undefined) process.env.WORKFORCE_DEPLOY_CLOUD_URL = prevCloudA; if (prevCloudB !== undefined) process.env.WORKFORCE_CLOUD_URL = prevCloudB; restoreIsolate(); - await rm(tmp, { recursive: true, force: true }); + await rm(legacyDir, { recursive: true, force: true }); } }); diff --git a/packages/cli/src/destroy-command.ts b/packages/cli/src/destroy-command.ts index 6668deaa..f87932f7 100644 --- a/packages/cli/src/destroy-command.ts +++ b/packages/cli/src/destroy-command.ts @@ -3,7 +3,6 @@ import path from 'node:path'; import { createTerminalIO, formatHttpErrorBody, - readActiveWorkspace, resolveCloudUrl, resolveWorkspaceToken } from '@agentworkforce/deploy'; @@ -17,7 +16,7 @@ export interface DestroyOptions { target: string; /** Workforce workspace id. Falls back to WORKFORCE_WORKSPACE_ID. */ workspace?: string; - /** Override cloud base URL. Falls back to env, then active.json, then the canonical default. */ + /** Override cloud base URL. Falls back to env, then the canonical default. */ cloudUrl?: string; /** Fail instead of opening the browser to log in. */ noPrompt?: boolean; @@ -82,10 +81,8 @@ export async function runDestroy(args: readonly string[]): Promise { } async function executeDestroy(opts: DestroyOptions): Promise { - const active = await readActiveWorkspace().catch(() => null); const cloudUrl = resolveCloudUrl({ - ...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}), - active + ...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}) }); const io = createTerminalIO(); diff --git a/packages/deploy/package.json b/packages/deploy/package.json index b770341c..a4baa25f 100644 --- a/packages/deploy/package.json +++ b/packages/deploy/package.json @@ -32,7 +32,7 @@ "lint": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@agent-relay/cloud": "^6.0.17", + "@agent-relay/cloud": "^8.7.0", "@agentworkforce/persona-kit": "workspace:*", "@agentworkforce/runtime": "workspace:*", "@daytonaio/sdk": "^0.185.0", diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index 425a6530..e8b44225 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -1711,8 +1711,9 @@ test('deploy: default auth resolver honors env credentials without a workspaceAu test('deploy: clear error when nothing resolves and noPrompt is set', async () => { // Without env or an explicit resolver, the orchestrator must surface // an actionable error rather than wedging in a prompt loop. Setting - // `noPrompt` forces `resolveWorkspaceToken` to throw at Tier 3 instead - // of opening a browser, so we get a deterministic error path to assert. + // `noPrompt` forces `resolveWorkspaceToken` to use the non-interactive + // canonical agent-relay session path, so a missing active workspace + // produces deterministic SDK guidance instead of a prompt loop. const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ integrations: {} }) ); @@ -1732,7 +1733,7 @@ test('deploy: clear error when nothing resolves and noPrompt is set', async () = { personaPath, mode: 'dev', noConnect: true, noPrompt: true, io: createBufferedIO() }, { bundle: successfulBundleStager(), modes: { dev: successfulDevLauncher() } } ), - /no workspace credentials resolved|workspace is required for deploy/ + /No active Agent Relay workspace found|workspace is required for deploy/ ); } finally { if (previousActiveFile === undefined) { diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index 32fd4af8..fdfcb686 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -185,14 +185,10 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { }); // Auth resolution: an explicit `resolvers.workspaceAuth` (used by tests - // and bespoke harnesses) wins. Otherwise consult the shared resolver - // that walks env → cloud-auth.json → active.json → legacy keychain, - // which is the same path `list`/`destroy` and the cloud launcher use. - // The orchestrator historically called `envWorkspaceAuth()` directly, - // which only honoured WORKFORCE_WORKSPACE_TOKEN + a long-dead keychain — - // a user who freshly ran `agentworkforce login` would hit "no workspace - // resolved" because that flow only writes the shared accessToken and - // active.json pointer. + // and bespoke harnesses) wins. Otherwise use the canonical agent-relay + // cloud session and active workspace descriptor. The only non-SDK + // credential path left here is the complete WORKFORCE_WORKSPACE_ID + + // WORKFORCE_WORKSPACE_TOKEN env override for CI. const resolvedAuth = resolvers.workspaceAuth ? await resolvers.workspaceAuth.resolveWorkspace({ override: opts.workspace, io }) : await resolveWorkspaceToken({ diff --git a/packages/deploy/src/login.test.ts b/packages/deploy/src/login.test.ts index 7ebf35ee..5c35a79e 100644 --- a/packages/deploy/src/login.test.ts +++ b/packages/deploy/src/login.test.ts @@ -1,223 +1,41 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; import { clearActiveWorkspace, clearStoredWorkspaceToken, + loadActiveWorkspaceToken, loadWorkspaceToken, readActiveWorkspace, resolveWorkspaceToken, + storeWorkspaceToken, writeActiveWorkspace, writeStoredWorkspaceToken } from './login.js'; import { createBufferedIO } from './io.js'; -async function withLoginEnv( +async function withWorkspaceEnv( env: { - loginFile?: string; workspaceId?: string; workspaceToken?: string; }, fn: () => Promise ): Promise { const previous = { - WORKFORCE_LOGIN_FILE: process.env.WORKFORCE_LOGIN_FILE, - WORKFORCE_DISABLE_KEYCHAIN: process.env.WORKFORCE_DISABLE_KEYCHAIN, WORKFORCE_WORKSPACE_ID: process.env.WORKFORCE_WORKSPACE_ID, - WORKFORCE_WORKSPACE_TOKEN: process.env.WORKFORCE_WORKSPACE_TOKEN + WORKFORCE_WORKSPACE_TOKEN: process.env.WORKFORCE_WORKSPACE_TOKEN, + 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, + CLOUD_API_URL: process.env.CLOUD_API_URL }; - process.env.WORKFORCE_DISABLE_KEYCHAIN = '1'; - if (env.loginFile === undefined) delete process.env.WORKFORCE_LOGIN_FILE; - else process.env.WORKFORCE_LOGIN_FILE = env.loginFile; if (env.workspaceId === undefined) delete process.env.WORKFORCE_WORKSPACE_ID; else process.env.WORKFORCE_WORKSPACE_ID = env.workspaceId; if (env.workspaceToken === undefined) delete process.env.WORKFORCE_WORKSPACE_TOKEN; else process.env.WORKFORCE_WORKSPACE_TOKEN = env.workspaceToken; - - 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; - } - } - } -} - -test('workspace token store writes and reads the active workspace token', async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-login-store-')); - const loginFile = path.join(dir, 'login.json'); - try { - await withLoginEnv({ loginFile }, async () => { - await writeStoredWorkspaceToken({ - workspaceSlug: 'acme', - workspaceId: 'ws-123', - token: 'tok-stored', - cloudUrl: 'https://cloud.example.test' - }); - const raw = JSON.parse(await readFile(loginFile, 'utf8')) as Record; - assert.equal(raw.workspace, 'acme'); - assert.equal(raw.workspaceId, 'ws-123'); - assert.equal(raw.token, 'tok-stored'); - assert.equal((await loadWorkspaceToken('acme'))?.token, 'tok-stored'); - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test('resolveWorkspaceToken prefers env token before stored login', async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-login-precedence-')); - const loginFile = path.join(dir, 'login.json'); - try { - await withLoginEnv({ loginFile }, async () => { - await writeStoredWorkspaceToken({ workspace: 'stored', token: 'tok-stored' }); - }); - await withLoginEnv({ - loginFile, - workspaceId: 'env-ws', - workspaceToken: 'tok-env' - }, async () => { - assert.deepEqual( - await resolveWorkspaceToken({ - cloudUrl: 'https://cloud.example.test', - io: createBufferedIO() - }), - { token: 'tok-env', workspace: 'env-ws' } - ); - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -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' }); - 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 } - ); - } + delete process.env.CLOUD_API_URL; try { return await fn(); @@ -229,188 +47,64 @@ async function withActiveWorkspaceEnv( 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 - }); - assert.equal(resolved.token, 'shared-access'); - assert.equal(resolved.workspace, 'acme'); - }); - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } +test('resolveWorkspaceToken preserves complete WORKFORCE env credentials for CI', async () => { + await withWorkspaceEnv({ + workspaceId: 'rw_1234abcd', + workspaceToken: 'ci-token' + }, async () => { + assert.deepEqual( + await resolveWorkspaceToken({ + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }), + { token: 'ci-token', workspace: 'rw_1234abcd' } + ); + }); }); -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 - }); - assert.equal(resolved.token, 'legacy-token'); - assert.equal(resolved.workspace, 'legacy-ws'); - }); - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } +test('resolveWorkspaceToken lets --workspace pair with WORKFORCE_WORKSPACE_TOKEN', async () => { + await withWorkspaceEnv({ workspaceToken: 'ci-token' }, async () => { + assert.deepEqual( + await resolveWorkspaceToken({ + workspace: 'rw_5678abcd', + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }), + { token: 'ci-token', workspace: 'rw_5678abcd' } + ); + }); }); -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('legacy active workspace pointer functions are inert', async () => { + await writeActiveWorkspace({ + workspace: 'stale', + workspaceId: 'stale-id', + workspaceSlug: 'stale', + cloudUrl: 'https://cloud.example.test' + }); + assert.equal(await readActiveWorkspace(), null); + await clearActiveWorkspace(); + assert.equal(await readActiveWorkspace(), null); }); -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 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('legacy workspace token reads never return shadow credentials', async () => { + assert.equal(await loadWorkspaceToken('stale'), null); + assert.equal(await loadActiveWorkspaceToken(), null); }); -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 }); - } +test('legacy workspace token writes are rejected and clears are inert', async () => { + await assert.rejects( + storeWorkspaceToken({ workspace: 'stale', token: 'tok' }), + /canonical agent-relay cloud session/ + ); + await assert.rejects( + writeStoredWorkspaceToken({ workspace: 'stale', token: 'tok' }), + /canonical agent-relay cloud session/ + ); + await clearStoredWorkspaceToken('stale'); }); diff --git a/packages/deploy/src/login.ts b/packages/deploy/src/login.ts index dd7f7e8f..b0c8cf91 100644 --- a/packages/deploy/src/login.ts +++ b/packages/deploy/src/login.ts @@ -1,11 +1,8 @@ -import { readFile, mkdir, rm, writeFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import path from 'node:path'; import { - readStoredAuth, - refreshStoredAuth, - writeStoredAuth, - type StoredAuth + CloudAuthError, + ensureCloudSession, + resolveActiveWorkspace, + type ActiveWorkspaceDescriptor } from '@agent-relay/cloud'; import { canonicalizeCloudUrl } from './cloud-url.js'; import type { DeployIO } from './types.js'; @@ -26,6 +23,9 @@ export interface WorkspaceAuth { export interface WorkspaceAuthToken { token: string; + workspace?: string; + relayfileWorkspaceId?: string; + workspaceDescriptor?: ActiveWorkspaceDescriptor; } export interface StoredWorkspaceLogin { @@ -38,139 +38,55 @@ export interface StoredWorkspaceLogin { cloudUrl?: string; } -type WorkforceStoredAuth = StoredAuth & { - workforce?: { - activeWorkspace?: string; - workspaceTokens?: Record; - }; - workspace?: string; - workspaceSlug?: string; - workspaceId?: string; - workspaceToken?: string; - workforceWorkspaceToken?: StoredWorkspaceLogin; -}; - -function loginFile(): string { - return process.env.WORKFORCE_LOGIN_FILE?.trim() - || 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. + * Deprecated compatibility type. Workspace selection now lives in + * @agent-relay/cloud's canonical workspace store. */ 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; - } + return null; } export async function writeActiveWorkspace( - input: Omit + _input: Omit ): Promise { - const file = activeWorkspaceFile(); - await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); - // Canonicalize at write time so we never persist an edge / origin-bypass - // hostname (e.g. origin.agentrelay.cloud) into active.json. Downstream - // readers can trust the stored value and skip canonicalization. - const cloudUrl = input.cloudUrl ? canonicalizeCloudUrl(input.cloudUrl) : undefined; - const payload: ActiveWorkspacePointer = { - workspace: input.workspace, - ...(input.workspaceSlug ? { workspaceSlug: input.workspaceSlug } : {}), - ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), - ...(cloudUrl ? { cloudUrl } : {}), - setAt: new Date().toISOString() - }; - await writeFile(file, `${JSON.stringify(payload, null, 2)}\n`, { - encoding: 'utf8', - mode: 0o600 - }); + // No-op: agentworkforce no longer owns a separate active.json pin. } export async function clearActiveWorkspace(): Promise { - await rm(activeWorkspaceFile(), { force: true }); + // No-op: agentworkforce no longer owns a separate active.json pin. } /** * Environment-backed fallback resolver: reads `WORKFORCE_WORKSPACE_ID` * and `WORKFORCE_WORKSPACE_TOKEN` from `process.env`. Useful in CI and as - * a sane default before the CLI wires up the OAuth flow. + * an explicit override before falling back to the canonical relay session. */ export function envWorkspaceAuth(): WorkspaceAuth { return { async resolveWorkspace({ override, io }) { - // Normalize whitespace-only values to "missing" — a token of `" "` - // is no more usable than an empty string, and silently passing one - // through produces a confusing 401 later instead of a clear setup - // error here. const workspace = (override ?? process.env.WORKFORCE_WORKSPACE_ID ?? '').trim(); const token = (process.env.WORKFORCE_WORKSPACE_TOKEN ?? '').trim(); if (workspace && token) { return { workspace, token }; } - const stored = await loadWorkspaceToken(workspace || undefined); - if (stored) { - const storedWorkspace = storedWorkspaceName(stored); - if (storedWorkspace) return { workspace: storedWorkspace, token: stored.token }; - } - if (!workspace) { io.error( - 'no workspace resolved: pass --workspace, set WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN, or run `agentworkforce login`' + 'no workspace resolved: pass --workspace, set WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN, or run `agent-relay workspace switch `' ); throw new Error('workspace is required for deploy'); } io.error( - `no workspace token resolved for ${workspace}: set WORKFORCE_WORKSPACE_TOKEN, or run \`agentworkforce login\`` + `no workspace token resolved for ${workspace}: set WORKFORCE_WORKSPACE_TOKEN, or use the canonical agent-relay session` ); throw new Error('workspace token is required for deploy'); } @@ -178,16 +94,15 @@ export function envWorkspaceAuth(): WorkspaceAuth { } /** - * Resolve the workspace token for mode launchers that need to call cloud - * APIs directly. The deploy orchestrator resolves workspace identity before - * launch; this helper keeps the token lookup in the auth module until the - * browser/keychain login flow replaces the env fallback. + * Resolve the cloud bearer and canonical active relay workspace. CI may still + * provide WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN explicitly; all + * interactive/user-machine auth flows go through @agent-relay/cloud. */ export function resolveWorkspaceTokenFromEnv(workspace: string): WorkspaceAuthToken { const token = (process.env.WORKFORCE_WORKSPACE_TOKEN ?? '').trim(); if (!token) { throw new Error( - `no workspace token resolved for ${workspace}: run \`workforce login\` or set WORKFORCE_WORKSPACE_TOKEN` + `no workspace token resolved for ${workspace}: run \`agent-relay login\` or set WORKFORCE_WORKSPACE_TOKEN` ); } return { token }; @@ -199,133 +114,121 @@ export async function resolveWorkspaceToken(args: { io: DeployIO; noPrompt?: boolean; }): Promise { - // Defensively canonicalize the incoming cloud URL so any per-call - // matching (e.g. cloudUrlMatches in loadWorkspaceToken) compares against - // the public canonical host rather than an origin-bypass hostname. const cloudUrl = canonicalizeCloudUrl(args.cloudUrl); const envWorkspace = (process.env.WORKFORCE_WORKSPACE_ID ?? '').trim(); - const fromEnv = (process.env.WORKFORCE_WORKSPACE_TOKEN ?? '').trim(); + const envToken = (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)) { + if (envToken && (requestedWorkspace || envWorkspace)) { return { - token: fromEnv, + token: envToken, workspace: requestedWorkspace || envWorkspace }; } - // 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 }; - } - } + const session = await ensureCloudSession({ + apiUrl: cloudUrl, + interactive: false + }).catch((error) => { + throw workspaceAuthError(error); + }); + const descriptor = await resolveWorkspaceDescriptor({ + requestedWorkspace: requestedWorkspace || envWorkspace, + apiUrl: session.auth.apiUrl || cloudUrl + }); - // 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, cloudUrl); - if (stored) { - return { - token: stored.token, - ...(storedWorkspaceName(stored) ? { workspace: storedWorkspaceName(stored) } : {}) - }; - } + return { + token: session.auth.accessToken, + workspace: descriptor.relaycastWorkspaceId, + relayfileWorkspaceId: descriptor.relayfileWorkspaceId, + workspaceDescriptor: descriptor + }; +} - if (args.noPrompt) { - throw new Error( - `no workspace credentials resolved${requestedWorkspace ? ` for ${requestedWorkspace}` : ''}: run \`agentworkforce login\` or set WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN` - ); +async function resolveWorkspaceDescriptor(args: { + requestedWorkspace?: string; + apiUrl: string; +}): Promise { + const workspace = args.requestedWorkspace?.trim(); + if (!workspace) { + return resolveActiveWorkspace({ + apiUrl: args.apiUrl, + interactive: false + }); } - args.io.info('cloud: no workspace credentials found; run `agentworkforce login` to connect this machine'); - throw new Error( - `no workspace credentials resolved${requestedWorkspace ? ` for ${requestedWorkspace}` : ''}: run \`agentworkforce login\` or set WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN` + const session = await ensureCloudSession({ + apiUrl: args.apiUrl, + interactive: false + }); + const response = await session.client.fetch( + `/api/v1/workspaces/${encodeURIComponent(workspace)}/resolve`, + { method: 'GET' } ); -} - -/** - * 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. - * - * Set `WORKFORCE_DISABLE_SHARED_AUTH=1` (or any truthy value) to skip - * the shared-auth read entirely. Primary use cases: - * - Hermetic tests that must not pick up the host machine's - * `~/.agent-relay/cloud-auth.json`. - * - Users who want the CLI to behave as if they had never run - * `agent-relay cloud login` (e.g. to force env-only operation in CI). - */ -async function readSharedAuthForBearer(): Promise { - if (isTruthyEnv(process.env.WORKFORCE_DISABLE_SHARED_AUTH)) return null; - 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; + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`workspace resolve failed for ${workspace}: ${response.status} ${text}`.trim()); } + return normalizeWorkspaceDescriptor(await response.json(), session.auth.apiUrl || args.apiUrl); } -function isTruthyEnv(value: string | undefined): boolean { - if (!value) return false; - const v = value.trim().toLowerCase(); - return v === '1' || v === 'true' || v === 'yes' || v === 'on'; +function normalizeWorkspaceDescriptor(payload: unknown, apiUrl: string): ActiveWorkspaceDescriptor { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('workspace resolve returned an invalid descriptor'); + } + const record = payload as Record; + const urls = record.urls && typeof record.urls === 'object' && !Array.isArray(record.urls) + ? record.urls as Record + : {}; + const key = readString(record, 'key') ?? readString(record, 'relaycastApiKey') ?? ''; + const relaycastWorkspaceId = readString(record, 'relaycastWorkspaceId') + ?? readString(record, 'workspaceId') + ?? ''; + const relayfileWorkspaceId = readString(record, 'relayfileWorkspaceId') ?? ''; + const relayauthWorkspaceId = readString(record, 'relayauthWorkspaceId') ?? ''; + if (!relaycastWorkspaceId || !relayfileWorkspaceId || !relayauthWorkspaceId) { + throw new Error('workspace resolve returned an incomplete descriptor'); + } + return { + key, + cloudWorkspaceId: readString(record, 'cloudWorkspaceId') ?? relaycastWorkspaceId, + relaycastWorkspaceId, + ...(readString(record, 'relaycastApiKey') + ? { relaycastApiKey: readString(record, 'relaycastApiKey') } + : {}), + relayfileWorkspaceId, + relayauthWorkspaceId, + ...(readString(record, 'organizationId') + ? { organizationId: readString(record, 'organizationId') } + : {}), + ...(readString(record, 'slug') ? { slug: readString(record, 'slug') } : {}), + ...(readString(record, 'name') ? { name: readString(record, 'name') } : {}), + urls: { + relaycastUrl: readString(urls, 'relaycastUrl') ?? '', + relayfileUrl: readString(urls, 'relayfileUrl') ?? '', + relayauthUrl: readString(urls, 'relayauthUrl') ?? '' + }, + apiUrl, + ...(typeof record.provisioned === 'boolean' ? { provisioned: record.provisioned } : {}) + }; } export async function loadWorkspaceToken( - workspace?: string, - cloudUrl?: string + _workspace?: string, + _cloudUrl?: string ): Promise { - const fromCloudAuth = await readWorkspaceTokenFromCloudAuth(workspace, cloudUrl); - if (fromCloudAuth && !isExpired(fromCloudAuth.expiresAt)) { - return fromCloudAuth; - } - - const fromFile = await readLoginFile(); - if ( - fromFile - && workspaceMatches(fromFile, workspace) - && cloudUrlMatches(fromFile, cloudUrl) - && !isExpired(fromFile.expiresAt) - ) { - return fromFile; - } - return null; } export async function loadActiveWorkspaceToken(): Promise { - return loadWorkspaceToken(undefined); + return null; } -export async function storeWorkspaceToken(login: StoredWorkspaceLogin): Promise { - if (await writeWorkspaceTokenToCloudAuth(login)) return; - - const file = loginFile(); - await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); - await writeFile(file, `${JSON.stringify(login, null, 2)}\n`, { - encoding: 'utf8', - mode: 0o600 - }); +export async function storeWorkspaceToken(_login: StoredWorkspaceLogin): Promise { + throw new Error('workspace token storage has moved to the canonical agent-relay cloud session'); } -export async function writeStoredWorkspaceToken(login: { +export async function writeStoredWorkspaceToken(_login: { workspaceSlug?: string; workspaceId?: string; workspace?: string; @@ -333,192 +236,29 @@ export async function writeStoredWorkspaceToken(login: { cloudUrl?: string; expiresAt?: string; }): Promise { - const workspace = login.workspace ?? login.workspaceSlug ?? login.workspaceId; - await storeWorkspaceToken({ - token: login.token, - ...(workspace ? { workspace } : {}), - ...(login.workspaceSlug ? { workspaceSlug: login.workspaceSlug } : {}), - ...(login.workspaceId ? { workspaceId: login.workspaceId } : {}), - ...(login.cloudUrl ? { cloudUrl: login.cloudUrl } : {}), - ...(login.expiresAt ? { expiresAt: login.expiresAt } : {}) - }); + throw new Error('workspace token storage has moved to the canonical agent-relay cloud session'); } -export async function clearStoredWorkspaceToken(workspace?: string): Promise { - await clearWorkspaceTokenFromCloudAuth(workspace); - await rm(loginFile(), { force: true }); +export async function clearStoredWorkspaceToken(_workspace?: string): Promise { + // No-op: old login.json and cloud-auth workforce.workspaceTokens are no + // longer written by this package. } -async function readLoginFile(): Promise { - const raw = await readFile(loginFile(), 'utf8').catch(() => ''); - if (!raw.trim()) return null; - return parseStoredLogin(raw); -} - -async function readWorkspaceTokenFromCloudAuth( - workspace?: string, - cloudUrl?: string -): Promise { - if (usesWorkspaceLoginFileOverride()) return null; - if (isTruthyEnv(process.env.WORKFORCE_DISABLE_SHARED_AUTH)) return null; - let auth = await readStoredAuth().catch(() => null); - if (!auth) return null; - - if (isExpired(auth.accessTokenExpiresAt)) { - try { - auth = await refreshStoredAuth(auth); - } catch { - return null; +function workspaceAuthError(error: unknown): Error { + if (error instanceof CloudAuthError) { + switch (error.code) { + case 'AUTH_REFRESH_TIMEOUT': + return new Error(`cloud auth refresh timed out: ${error.message}`); + case 'AUTH_REFRESH_EXPIRED': + case 'AUTH_BROWSER_REQUIRED': + case 'AUTH_ENV_REPROVISION_REQUIRED': + return new Error(`${error.message}. Run \`agent-relay login\` and retry.`); } } - - const stored = auth as WorkforceStoredAuth; - const tokens: Record = stored.workforce?.workspaceTokens ?? {}; - const active = stored.workforce?.activeWorkspace; - const candidates = workspace - ? [tokens[workspace], ...Object.values(tokens).filter((login) => workspaceMatches(login, workspace))] - : [active ? tokens[active] : undefined, stored.workforceWorkspaceToken, legacyWorkspaceToken(stored)]; - - return candidates.find((login) => Boolean(login) - && cloudUrlMatches(login as StoredWorkspaceLogin, cloudUrl) - && !isExpired((login as StoredWorkspaceLogin).expiresAt)) ?? null; -} - -async function writeWorkspaceTokenToCloudAuth(login: StoredWorkspaceLogin): Promise { - if (usesWorkspaceLoginFileOverride()) return false; - const auth = await readStoredAuth().catch(() => null); - if (!auth) return false; - - const current = auth as WorkforceStoredAuth; - const tokens: Record = { ...(current.workforce?.workspaceTokens ?? {}) }; - const workspace = storedWorkspaceName(login); - if (!workspace) return false; - - const nextLogin = { ...login, workspace }; - for (const key of [workspace, login.workspaceSlug, login.workspaceId]) { - if (key) tokens[key] = nextLogin; - } - - await writeStoredAuth({ - ...current, - workforce: { - ...(current.workforce ?? {}), - activeWorkspace: workspace, - workspaceTokens: tokens - }, - workforceWorkspaceToken: nextLogin - } as StoredAuth); - return true; -} - -async function clearWorkspaceTokenFromCloudAuth(workspace?: string): Promise { - if (usesWorkspaceLoginFileOverride()) return; - const auth = await readStoredAuth().catch(() => null); - if (!auth) return; - - const current = auth as WorkforceStoredAuth; - let tokens: Record = { ...(current.workforce?.workspaceTokens ?? {}) }; - const target = workspace?.trim(); - if (target) { - tokens = Object.fromEntries( - Object.entries(tokens).filter(([key, login]) => key !== target && !workspaceMatches(login, target)) - ); - } else { - tokens = {}; - } - - const next: WorkforceStoredAuth = { - ...current, - workforce: { - ...(current.workforce ?? {}), - workspaceTokens: tokens - } - }; - if (!target || current.workforce?.activeWorkspace === target) { - delete next.workforce?.activeWorkspace; - delete next.workforceWorkspaceToken; - } - await writeStoredAuth(next as StoredAuth); -} - -function parseStoredLogin(raw: string): StoredWorkspaceLogin | null { - const trimmed = raw.trim(); - if (!trimmed) return null; - if (!trimmed.startsWith('{')) { - return { token: trimmed }; - } - try { - const parsed = JSON.parse(trimmed) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; - const record = parsed as Record; - const token = typeof record.token === 'string' - ? record.token - : typeof record.accessToken === 'string' - ? record.accessToken - : ''; - if (!token.trim()) return null; - const workspace = typeof record.workspace === 'string' - ? record.workspace - : typeof record.workspaceSlug === 'string' - ? record.workspaceSlug - : typeof record.workspaceId === 'string' - ? record.workspaceId - : undefined; - const expiresAt = typeof record.expiresAt === 'string' - ? record.expiresAt - : typeof record.accessTokenExpiresAt === 'string' - ? record.accessTokenExpiresAt - : undefined; - return { - token: token.trim(), - ...(workspace ? { workspace } : {}), - ...(typeof record.workspaceSlug === 'string' ? { workspaceSlug: record.workspaceSlug } : {}), - ...(typeof record.workspaceId === 'string' ? { workspaceId: record.workspaceId } : {}), - ...(typeof record.refreshToken === 'string' ? { refreshToken: record.refreshToken } : {}), - ...(expiresAt ? { expiresAt } : {}), - ...(typeof record.cloudUrl === 'string' ? { cloudUrl: record.cloudUrl } : {}) - }; - } catch { - return null; - } -} - -function legacyWorkspaceToken(auth: WorkforceStoredAuth): StoredWorkspaceLogin | undefined { - const token = auth.workspaceToken; - if (!token) return undefined; - return { - token, - ...(auth.workspace ? { workspace: auth.workspace } : {}), - ...(auth.workspaceSlug ? { workspaceSlug: auth.workspaceSlug } : {}), - ...(auth.workspaceId ? { workspaceId: auth.workspaceId } : {}), - cloudUrl: auth.apiUrl - }; -} - -function workspaceMatches(login: StoredWorkspaceLogin, workspace: string | undefined): boolean { - if (!workspace) return true; - return [login.workspace, login.workspaceSlug, login.workspaceId].some((value) => value === workspace); -} - -function cloudUrlMatches(login: StoredWorkspaceLogin, cloudUrl: string | undefined): boolean { - if (!cloudUrl || !login.cloudUrl) return true; - return normalizeUrl(login.cloudUrl) === normalizeUrl(cloudUrl); -} - -function storedWorkspaceName(login: StoredWorkspaceLogin): string | undefined { - return login.workspaceSlug ?? login.workspace ?? login.workspaceId; -} - -function isExpired(expiresAt: string | undefined): boolean { - if (!expiresAt) return false; - const millis = Date.parse(expiresAt); - return Number.isNaN(millis) ? false : millis <= Date.now(); -} - -function normalizeUrl(url: string): string { - return url.trim().replace(/\/+$/, ''); + return error instanceof Error ? error : new Error(String(error)); } -function usesWorkspaceLoginFileOverride(): boolean { - return Boolean(process.env.WORKFORCE_LOGIN_FILE?.trim()); +function readString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29558b5f..bb4e2cc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,8 +38,8 @@ importers: packages/cli: dependencies: '@agent-relay/cloud': - specifier: ^6.0.17 - version: 6.0.19 + specifier: ^8.7.0 + version: 8.7.0 '@agentworkforce/deploy': specifier: workspace:* version: link:../deploy @@ -71,8 +71,8 @@ importers: packages/deploy: dependencies: '@agent-relay/cloud': - specifier: ^6.0.17 - version: 6.0.19 + specifier: ^8.7.0 + version: 8.7.0 '@agentworkforce/persona-kit': specifier: workspace:* version: link:../persona-kit @@ -169,11 +169,11 @@ packages: '@agent-assistant/surfaces@0.4.32': resolution: {integrity: sha512-VI1UpwDD/RznfngRKatqL3zkZ6Bo9MszIGwZ5/H68r+6yghsFeTSq42eyoQM90DuQniX6Z/QfPmA890U1ZA2MQ==} - '@agent-relay/cloud@6.0.19': - resolution: {integrity: sha512-MCehAQwZBgoVh5ddFXUwDIxsuEzj6RfmyM3g5AExTYne2FZJmsPCcv/B916WVa7rpu2IxujP04CKEUcS4wiS5Q==} + '@agent-relay/cloud@8.7.0': + resolution: {integrity: sha512-7cXD1ziMOuAOy3IbVk0P6qDVipTYxbVFBWmxn9aME7hTHyy4AzfjLOj51AL7JLpDKj4I5OiXJjw12B29Wg/Krw==} - '@agent-relay/config@6.0.19': - resolution: {integrity: sha512-vARUMQm5066xyqSJgG1IgpS/4DcogOz/4Qjf5cgo/rs+F/f0R1D5YgvuBNKtmkqIQ9FZ3m6hwsK2ZM0kQyrQMQ==} + '@agent-relay/config@8.7.0': + resolution: {integrity: sha512-hCH0jPSbeBYoZs/Jx8NGDqbPe93xEXlF3q0E6HCO8+niLAuYZU3+hDWsS2dvDCg6S7BH5cTQbVUyMxV8T48U8Q==} '@agent-relay/events@6.3.6': resolution: {integrity: sha512-3el7/iImhQ/L+TKetELF8SY6MEebmpxI8hbJ8qX1GWNyy46aXhuM/SwCMSSKch+Wmk6w9kitmx8csCGuF+4iNA==} @@ -2124,9 +2124,9 @@ snapshots: '@agent-assistant/surfaces@0.4.32': {} - '@agent-relay/cloud@6.0.19': + '@agent-relay/cloud@8.7.0': dependencies: - '@agent-relay/config': 6.0.19 + '@agent-relay/config': 8.7.0 '@aws-sdk/client-s3': 3.1020.0 ignore: 7.0.5 tar: 7.5.15 @@ -2135,7 +2135,7 @@ snapshots: transitivePeerDependencies: - aws-crt - '@agent-relay/config@6.0.19': + '@agent-relay/config@8.7.0': dependencies: zod: 3.25.76 zod-to-json-schema: 3.25.2(zod@3.25.76) From ec66aec663c76b658d4db0ebed076de9be079b17 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Sat, 13 Jun 2026 13:15:01 +0200 Subject: [PATCH 2/3] Address workforce auth CI feedback Co-Authored-By: OpenAI --- packages/cli/src/deploy-command.ts | 4 ++-- packages/deploy/src/deploy.test.ts | 2 +- packages/deploy/src/login.ts | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index e64f8b75..b2d200c6 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -31,7 +31,7 @@ type DeployCommandDeps = { clearStoredAuth: typeof clearStoredAuth; clearStoredWorkspaceToken: typeof clearStoredWorkspaceToken; clearActiveWorkspace: typeof clearActiveWorkspace; - setWorkspaceKey(name: string, key: string): unknown; + setWorkspaceKey(name: string, key: string): unknown | Promise; createTerminalIO: typeof createTerminalIO; createCloudApiClient(auth: StoredAuth, apiUrl: string): LoginApiClient; }; @@ -176,7 +176,7 @@ export async function runLogin(args: readonly string[]): Promise { const match = findWorkspace(workspaces, chosen); const descriptor = await resolveWorkspaceForLogin(auth, apiUrl, match?.id ?? chosen); const workspaceName = descriptor.name ?? descriptor.slug ?? match?.slug ?? match?.name ?? chosen; - deployCommandDeps.setWorkspaceKey(workspaceName, descriptor.key); + await deployCommandDeps.setWorkspaceKey(workspaceName, descriptor.key); process.stdout.write(`\nlogged in: ${workspaceName}\n`); process.exit(0); } catch (err) { diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index e8b44225..68780271 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -1733,7 +1733,7 @@ test('deploy: clear error when nothing resolves and noPrompt is set', async () = { personaPath, mode: 'dev', noConnect: true, noPrompt: true, io: createBufferedIO() }, { bundle: successfulBundleStager(), modes: { dev: successfulDevLauncher() } } ), - /No active Agent Relay workspace found|workspace is required for deploy/ + /No active Agent Relay workspace found|Cloud login required|workspace is required for deploy/ ); } finally { if (previousActiveFile === undefined) { diff --git a/packages/deploy/src/login.ts b/packages/deploy/src/login.ts index b0c8cf91..9bce7830 100644 --- a/packages/deploy/src/login.ts +++ b/packages/deploy/src/login.ts @@ -186,7 +186,7 @@ function normalizeWorkspaceDescriptor(payload: unknown, apiUrl: string): ActiveW ?? ''; const relayfileWorkspaceId = readString(record, 'relayfileWorkspaceId') ?? ''; const relayauthWorkspaceId = readString(record, 'relayauthWorkspaceId') ?? ''; - if (!relaycastWorkspaceId || !relayfileWorkspaceId || !relayauthWorkspaceId) { + if (!key || !relaycastWorkspaceId || !relayfileWorkspaceId || !relayauthWorkspaceId) { throw new Error('workspace resolve returned an incomplete descriptor'); } return { @@ -252,6 +252,9 @@ function workspaceAuthError(error: unknown): Error { case 'AUTH_REFRESH_EXPIRED': case 'AUTH_BROWSER_REQUIRED': case 'AUTH_ENV_REPROVISION_REQUIRED': + if (/agent-relay login/i.test(error.message)) { + return new Error(error.message); + } return new Error(`${error.message}. Run \`agent-relay login\` and retry.`); } } From 908bf23d95578a71b7bcd50b51ffb4059945218c Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Sat, 13 Jun 2026 13:29:04 +0200 Subject: [PATCH 3/3] Stabilize workforce auth resolver tests Co-Authored-By: OpenAI --- packages/cli/src/deploy-command.ts | 1 + packages/cli/src/destroy-command.test.ts | 77 +++++------- packages/cli/src/list-command.ts | 9 +- packages/deploy/src/cloud-url.test.ts | 10 +- packages/deploy/src/cloud-url.ts | 5 +- packages/deploy/src/deploy.test.ts | 143 +++++++++++++++++------ packages/deploy/src/deploy.ts | 15 ++- packages/deploy/src/integrations-list.ts | 7 +- packages/deploy/src/login.test.ts | 105 +++++++++++++++++ packages/deploy/src/login.ts | 4 +- 10 files changed, 271 insertions(+), 105 deletions(-) diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index b2d200c6..35dcd6a2 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -114,6 +114,7 @@ export async function runDeploy(args: readonly string[]): Promise { function createDeployAuthRecovery(opts: DeployOptions): CloudAuthRecoveryResolver { return { async recover({ workspace, cloudUrl, io, reason }) { + if (opts.noPrompt) return false; const ok = await io.confirm( 'Cloud login is required before deploy can check integrations. Log in now? (opens browser)', { defaultValue: true } diff --git a/packages/cli/src/destroy-command.test.ts b/packages/cli/src/destroy-command.test.ts index 7b4f1828..f0a75dd1 100644 --- a/packages/cli/src/destroy-command.test.ts +++ b/packages/cli/src/destroy-command.test.ts @@ -73,7 +73,7 @@ function trapFetch(handler: (call: FetchCall) => Response | Promise): } function withTokenEnv(token: string, workspace: string): () => void { - const restoreIsolate = isolateAuthFiles(); + const restoreIsolate = isolateAgentRelayWorkspace(); const prevToken = process.env.WORKFORCE_WORKSPACE_TOKEN; const prevWs = process.env.WORKFORCE_WORKSPACE_ID; const prevCloudA = process.env.WORKFORCE_DEPLOY_CLOUD_URL; @@ -95,25 +95,15 @@ function withTokenEnv(token: string, workspace: string): () => void { }; } -/** - * Pin every filesystem-backed auth source to definitely-missing/disabled - * paths so the destroy CLI tests don't accidentally pick up the host - * developer's legacy `~/.agentworkforce/active.json` or cloud-auth state. - */ -function isolateAuthFiles(): () => void { - const prevActive = process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE; - const prevLogin = process.env.WORKFORCE_LOGIN_FILE; - const prevDisable = process.env.WORKFORCE_DISABLE_SHARED_AUTH; - process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = path.join(os.tmpdir(), 'wf-destroy-test-active-MISSING.json'); - process.env.WORKFORCE_LOGIN_FILE = path.join(os.tmpdir(), 'wf-destroy-test-login-MISSING.json'); - process.env.WORKFORCE_DISABLE_SHARED_AUTH = '1'; +function isolateAgentRelayWorkspace(): () => void { + const prevHome = process.env.AGENT_RELAY_HOME; + process.env.AGENT_RELAY_HOME = path.join( + os.tmpdir(), + `wf-destroy-agent-relay-${process.pid}-${Date.now()}-${Math.random()}` + ); return () => { - if (prevActive === undefined) delete process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE; - else process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = prevActive; - if (prevLogin === undefined) delete process.env.WORKFORCE_LOGIN_FILE; - else process.env.WORKFORCE_LOGIN_FILE = prevLogin; - if (prevDisable === undefined) delete process.env.WORKFORCE_DISABLE_SHARED_AUTH; - else process.env.WORKFORCE_DISABLE_SHARED_AUTH = prevDisable; + if (prevHome === undefined) delete process.env.AGENT_RELAY_HOME; + else process.env.AGENT_RELAY_HOME = prevHome; }; } @@ -253,17 +243,21 @@ test('runDestroy: 401 maps to exit 1 with a login hint', async () => { }); test('runDestroy: missing workspace exits 1', async () => { - // No WORKFORCE_WORKSPACE_ID, no --workspace, and no on-disk auth state - // — destroy should fail fast with an actionable error and never reach - // the network. We isolate the filesystem sources because the new code - // path also consults `~/.agentworkforce/active.json` and the shared - // cloud-auth file, which would otherwise leak from the host machine - // running the test. - const restoreIsolate = isolateAuthFiles(); + // A token without a workspace plus an empty canonical Agent Relay workspace + // store must fail before any network call. + const restoreIsolate = isolateAgentRelayWorkspace(); const prevToken = process.env.WORKFORCE_WORKSPACE_TOKEN; const prevWs = process.env.WORKFORCE_WORKSPACE_ID; + const prevCloudUrl = process.env.CLOUD_API_URL; + const prevCloudAccess = process.env.CLOUD_API_ACCESS_TOKEN; + const prevCloudRefresh = process.env.CLOUD_API_REFRESH_TOKEN; + const prevCloudExpires = process.env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT; process.env.WORKFORCE_WORKSPACE_TOKEN = 'tok-1'; delete process.env.WORKFORCE_WORKSPACE_ID; + process.env.CLOUD_API_URL = CLOUD; + process.env.CLOUD_API_ACCESS_TOKEN = 'cloud-access'; + process.env.CLOUD_API_REFRESH_TOKEN = 'cloud-refresh'; + process.env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT = '2999-01-01T00:00:00.000Z'; const fetchTrap = trapFetch(async () => { throw new Error('fetch must not be called when workspace is missing'); }); @@ -280,6 +274,14 @@ test('runDestroy: missing workspace exits 1', async () => { if (prevToken === undefined) delete process.env.WORKFORCE_WORKSPACE_TOKEN; else process.env.WORKFORCE_WORKSPACE_TOKEN = prevToken; if (prevWs !== undefined) process.env.WORKFORCE_WORKSPACE_ID = prevWs; + if (prevCloudUrl === undefined) delete process.env.CLOUD_API_URL; + else process.env.CLOUD_API_URL = prevCloudUrl; + if (prevCloudAccess === undefined) delete process.env.CLOUD_API_ACCESS_TOKEN; + else process.env.CLOUD_API_ACCESS_TOKEN = prevCloudAccess; + if (prevCloudRefresh === undefined) delete process.env.CLOUD_API_REFRESH_TOKEN; + else process.env.CLOUD_API_REFRESH_TOKEN = prevCloudRefresh; + if (prevCloudExpires === undefined) delete process.env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT; + else process.env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT = prevCloudExpires; restoreIsolate(); } }); @@ -474,10 +476,8 @@ test('runDestroy: HTML 404 body is replaced with a hint, not dumped verbatim', a } }); -test('runDestroy: ignores legacy active.json cloudUrl when no flag and no env is set', async () => { - // The canonical Agent Relay session owns the active workspace now. - // Legacy active.json state must not choose the cloud URL. - const restoreIsolate = isolateAuthFiles(); +test('runDestroy: uses the canonical default cloud URL when no flag or env is set', async () => { + const restoreIsolate = isolateAgentRelayWorkspace(); const prevToken = process.env.WORKFORCE_WORKSPACE_TOKEN; const prevWs = process.env.WORKFORCE_WORKSPACE_ID; const prevCloudA = process.env.WORKFORCE_DEPLOY_CLOUD_URL; @@ -487,19 +487,6 @@ test('runDestroy: ignores legacy active.json cloudUrl when no flag and no env is delete process.env.WORKFORCE_DEPLOY_CLOUD_URL; delete process.env.WORKFORCE_CLOUD_URL; - const legacyDir = await mkdtemp(path.join(os.tmpdir(), 'aw-destroy-active-')); - process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = path.join(legacyDir, 'active.json'); - await writeFile( - process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE, - JSON.stringify({ - workspace: WORKSPACE, - workspaceId: WORKSPACE, - cloudUrl: 'https://active.example.test/cloud', - setAt: new Date().toISOString() - }), - 'utf8' - ); - const fetchTrap = trapFetch( async () => new Response( @@ -514,8 +501,7 @@ test('runDestroy: ignores legacy active.json cloudUrl when no flag and no env is ); const trap = trapIO(); try { - // No `--cloud-url` flag. The command must use the canonical default, - // not the stale active.json value. + // No `--cloud-url` flag and no env override: use the canonical default. await assert.rejects(runDestroy([AGENT_UUID]), /__exit_trap__:0/); assert.equal(fetchTrap.calls.length, 1); assert.equal( @@ -532,6 +518,5 @@ test('runDestroy: ignores legacy active.json cloudUrl when no flag and no env is if (prevCloudA !== undefined) process.env.WORKFORCE_DEPLOY_CLOUD_URL = prevCloudA; if (prevCloudB !== undefined) process.env.WORKFORCE_CLOUD_URL = prevCloudB; restoreIsolate(); - await rm(legacyDir, { recursive: true, force: true }); } }); diff --git a/packages/cli/src/list-command.ts b/packages/cli/src/list-command.ts index e43cf785..64ee1c34 100644 --- a/packages/cli/src/list-command.ts +++ b/packages/cli/src/list-command.ts @@ -1,7 +1,6 @@ import { createTerminalIO, formatHttpErrorBody, - readActiveWorkspace, resolveCloudUrl, resolveWorkspaceToken } from '@agentworkforce/deploy'; @@ -68,10 +67,8 @@ export async function runDeploymentList(args: readonly string[]): Promise try { const opts = parseDeploymentListArgs(args); const io = createTerminalIO(); - const active = await readActiveWorkspace().catch(() => null); const cloudUrl = resolveCloudUrl({ - ...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}), - active + ...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}) }); const auth = await resolveWorkspaceToken({ ...(opts.workspace ? { workspace: opts.workspace } : {}), @@ -350,10 +347,8 @@ export async function resolveDeploymentRequestContext(opts: { noPrompt?: boolean; }): Promise<{ cloudUrl: string; workspace: string; token: string }> { const io = createTerminalIO(); - const active = await readActiveWorkspace().catch(() => null); const cloudUrl = resolveCloudUrl({ - ...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}), - active + ...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}) }); const auth = await resolveWorkspaceToken({ ...(opts.workspace ? { workspace: opts.workspace } : {}), diff --git a/packages/deploy/src/cloud-url.test.ts b/packages/deploy/src/cloud-url.test.ts index 7f2f6cc2..591162b0 100644 --- a/packages/deploy/src/cloud-url.test.ts +++ b/packages/deploy/src/cloud-url.test.ts @@ -89,7 +89,7 @@ test('canonicalizeCloudUrl: apex with a non-root path is left untouched', () => ); }); -test('resolveCloudUrl: flag wins over env wins over active.json wins over default', () => { +test('resolveCloudUrl: flag wins over env wins over compatibility active pointer wins over default', () => { // Flag wins. assert.equal( resolveCloudUrl({ @@ -124,7 +124,7 @@ test('resolveCloudUrl: flag wins over env wins over active.json wins over defaul 'https://legacy-env.example.test' ); - // No env → active.json wins. + // No env → compatibility active pointer wins. assert.equal( resolveCloudUrl({ env: {}, @@ -141,9 +141,7 @@ test('resolveCloudUrl: nothing set → canonical default', () => { ); }); -test('resolveCloudUrl: active.json with bypass hostname is canonicalized', () => { - // The active.json file may still carry an origin.* hostname written by - // an older login flow. resolveCloudUrl repairs that on read. +test('resolveCloudUrl: compatibility active pointer with bypass hostname is canonicalized', () => { assert.equal( resolveCloudUrl({ env: {}, @@ -153,7 +151,7 @@ test('resolveCloudUrl: active.json with bypass hostname is canonicalized', () => ); }); -test('resolveCloudUrl: active.json with bare apex is canonicalized', () => { +test('resolveCloudUrl: compatibility active pointer with bare apex is canonicalized', () => { assert.equal( resolveCloudUrl({ env: {}, diff --git a/packages/deploy/src/cloud-url.ts b/packages/deploy/src/cloud-url.ts index c9c4d1c4..93e3408b 100644 --- a/packages/deploy/src/cloud-url.ts +++ b/packages/deploy/src/cloud-url.ts @@ -63,8 +63,7 @@ export function canonicalizeCloudUrl(input: string): string { * 1. Explicit `--cloud-url` flag. * 2. `WORKFORCE_DEPLOY_CLOUD_URL` (preferred env override). * 3. `WORKFORCE_CLOUD_URL` (legacy env override). - * 4. `cloudUrl` recorded in `~/.agentworkforce/active.json` by - * `agentworkforce login`. + * 4. Deprecated compatibility active pointer supplied by a caller. * 5. `defaultApiUrl()` from `@agent-relay/cloud` (the public canonical * URL — currently `https://agentrelay.com/cloud`). * @@ -89,7 +88,7 @@ export interface CloudUrlContext { flag?: string | undefined; /** Process env override; defaults to `process.env`. Pass `{}` to ignore env. */ env?: NodeJS.ProcessEnv; - /** Active workspace pointer read from `~/.agentworkforce/active.json`. */ + /** Deprecated compatibility pointer. New callers should not pass this. */ active?: Pick | null | undefined; } diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index 68780271..d875a163 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -147,6 +147,46 @@ async function withWorkspaceEnv( } } +async function withCloudSessionEnv(fn: () => Promise): Promise { + const previous = { + 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 + }; + process.env.CLOUD_API_URL = 'https://cloud.example.test'; + process.env.CLOUD_API_ACCESS_TOKEN = 'cloud-access'; + process.env.CLOUD_API_REFRESH_TOKEN = 'cloud-refresh'; + process.env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT = '2999-01-01T00:00:00.000Z'; + 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; + } + } + } +} + +async function withAgentRelayHome(fn: () => Promise): Promise { + const previous = process.env.AGENT_RELAY_HOME; + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-agent-relay-home-')); + process.env.AGENT_RELAY_HOME = dir; + try { + return await fn(); + } finally { + if (previous === undefined) { + delete process.env.AGENT_RELAY_HOME; + } else { + process.env.AGENT_RELAY_HOME = previous; + } + await rm(dir, { recursive: true, force: true }); + } +} + function successfulBundleStager(): BundleStager { return { async stage(input) { @@ -1092,6 +1132,67 @@ test('deploy dev mode rejects malformed runtime credential tokens before launch' } }); +test('deploy dev mode rejects mismatched relayfile workspace ids before launch', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ + integrations: { + github: {} + } + }) + ); + const io = createBufferedIO(); + const originalFetch = globalThis.fetch; + let launched = false; + + globalThis.fetch = (async (input) => { + const url = String(input); + if (url.includes('/api/v1/workspaces/rw-test/integrations/github/status')) { + return jsonResponse({ provider: 'github', status: 'ready' }); + } + if (url.includes('/api/v1/workspaces/rw-test/runtime-credentials')) { + return jsonResponse({ + relayfileUrl: 'https://relayfile.test', + relayfileWorkspaceId: 'rf-stale', + relayfileToken: 'relay_pa_scoped', + relayfileMountPaths: ['/github/repos/**/**/pulls/**'] + }); + } + throw new Error(`unexpected URL ${url}`); + }) as typeof fetch; + + try { + await assert.rejects( + deploy( + { + personaPath, + mode: 'dev', + noPrompt: true, + cloudUrl: 'https://cloud.example.test', + io + }, + { + workspaceAuth: { + async resolveWorkspace() { + return { + workspace: 'rw-test', + relayfileWorkspaceId: 'rf-canonical', + token: 'relay_ws_workspace' + }; + } + }, + bundle: successfulBundleStager(), + modes: { dev: successfulDevLauncher(() => { launched = true; }) } + } + ), + /runtime-credentials returned relayfile workspace rf-stale, expected rf-canonical/ + ); + assert.equal(launched, false); + } finally { + globalThis.fetch = originalFetch; + await cleanup(); + } +}); + test('deploy dev mode rejects runtime credential tokens without mount paths before launch', async () => { const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ @@ -1683,8 +1784,8 @@ test('deploy: default auth resolver honors env credentials without a workspaceAu // previous default (`envWorkspaceAuth()`) only consulted env vars and a // long-dead keychain; the new default delegates to `resolveWorkspaceToken`, // which still honors WORKFORCE_WORKSPACE_TOKEN + WORKFORCE_WORKSPACE_ID - // as Tier 1 but additionally falls through to the shared cloud-auth + - // active.json pointer. This test exercises the Tier 1 path end-to-end + // as Tier 1 but additionally falls through to the shared cloud session and + // canonical Agent Relay workspace store. This test exercises the Tier 1 path end-to-end // through `deploy()` with no resolver injection — proving the wiring is // intact for CI users while the filesystem-fallback paths stay covered // by `login.test.ts`. @@ -1710,48 +1811,24 @@ test('deploy: default auth resolver honors env credentials without a workspaceAu test('deploy: clear error when nothing resolves and noPrompt is set', async () => { // Without env or an explicit resolver, the orchestrator must surface - // an actionable error rather than wedging in a prompt loop. Setting - // `noPrompt` forces `resolveWorkspaceToken` to use the non-interactive - // canonical agent-relay session path, so a missing active workspace - // produces deterministic SDK guidance instead of a prompt loop. + // an actionable error rather than wedging in a prompt loop. The temp + // AGENT_RELAY_HOME gives the SDK an empty canonical workspace store. const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ integrations: {} }) ); await withWorkspaceEnv({ workspace: undefined, token: undefined }, async () => { - // Point filesystem-backed auth at definitely-missing/disabled paths so the - // test doesn't accidentally pick up host credentials. - const previousActiveFile = process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE; - const previousLoginFile = process.env.WORKFORCE_LOGIN_FILE; - const previousDisableShared = process.env.WORKFORCE_DISABLE_SHARED_AUTH; - process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = path.join(os.tmpdir(), 'wf-deploy-test-missing-active.json'); - process.env.WORKFORCE_LOGIN_FILE = path.join(os.tmpdir(), 'wf-deploy-test-missing-login.json'); - process.env.WORKFORCE_DISABLE_SHARED_AUTH = '1'; - try { + await withCloudSessionEnv(async () => { + await withAgentRelayHome(async () => { await assert.rejects( deploy( { personaPath, mode: 'dev', noConnect: true, noPrompt: true, io: createBufferedIO() }, { bundle: successfulBundleStager(), modes: { dev: successfulDevLauncher() } } ), - /No active Agent Relay workspace found|Cloud login required|workspace is required for deploy/ + /No active Agent Relay workspace found/ ); - } finally { - if (previousActiveFile === undefined) { - delete process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE; - } else { - process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = previousActiveFile; - } - if (previousLoginFile === undefined) { - delete process.env.WORKFORCE_LOGIN_FILE; - } else { - process.env.WORKFORCE_LOGIN_FILE = previousLoginFile; - } - if (previousDisableShared === undefined) { - delete process.env.WORKFORCE_DISABLE_SHARED_AUTH; - } else { - process.env.WORKFORCE_DISABLE_SHARED_AUTH = previousDisableShared; - } - } + }); + }); }); await cleanup(); diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index fdfcb686..769457aa 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -25,7 +25,6 @@ import { } from './connect.js'; import { createTerminalIO } from './io.js'; import { - readActiveWorkspace, resolveWorkspaceToken, type WorkspaceAuth } from './login.js'; @@ -178,10 +177,8 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { }; } - const active = await readActiveWorkspace().catch(() => null); const cloudUrl = resolveCloudUrl({ - ...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}), - active + ...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}) }); // Auth resolution: an explicit `resolvers.workspaceAuth` (used by tests @@ -306,6 +303,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { agent: preflight.agent, workspace, workspaceToken: activeToken, + ...(resolvedAuth.relayfileWorkspaceId ? { relayfileWorkspaceId: resolvedAuth.relayfileWorkspaceId } : {}), cloudUrl, byoSandbox: opts.byoSandbox === true, enabled: resolvers.integrations === undefined, @@ -426,6 +424,7 @@ async function resolveRuntimeCredentialEnv(args: { agent: AgentSpec; workspace: string; workspaceToken: string; + relayfileWorkspaceId?: string; cloudUrl?: string; byoSandbox: boolean; enabled: boolean; @@ -475,6 +474,14 @@ async function resolveRuntimeCredentialEnv(args: { if (credentials.relayfileToken !== null && !credentials.relayfileToken.startsWith('relay_pa_')) { throw new Error('runtime-credentials returned a token without expected relay_pa_ prefix'); } + if ( + args.relayfileWorkspaceId + && credentials.relayfileWorkspaceId !== args.relayfileWorkspaceId + ) { + throw new Error( + `runtime-credentials returned relayfile workspace ${credentials.relayfileWorkspaceId}, expected ${args.relayfileWorkspaceId}` + ); + } if (credentials.relayfileToken !== null && credentials.relayfileMountPaths.length === 0) { throw new Error('runtime-credentials returned a token without relayfile mount paths'); } diff --git a/packages/deploy/src/integrations-list.ts b/packages/deploy/src/integrations-list.ts index 4e4a73df..46f2fa96 100644 --- a/packages/deploy/src/integrations-list.ts +++ b/packages/deploy/src/integrations-list.ts @@ -5,7 +5,7 @@ import { } from '@agentworkforce/persona-kit'; import { resolveCloudUrl } from './cloud-url.js'; import { createBufferedIO } from './io.js'; -import { readActiveWorkspace, resolveWorkspaceToken, type ActiveWorkspacePointer } from './login.js'; +import { resolveWorkspaceToken, type ActiveWorkspacePointer } from './login.js'; export type AuthState = 'authenticated' | 'unauthenticated'; export type TriggerSource = 'catalog' | 'none'; @@ -47,7 +47,6 @@ export interface ListIntegrationsOptions { fetch?: typeof fetch; env?: NodeJS.ProcessEnv; activeWorkspace?: ActiveWorkspacePointer | null; - readActiveWorkspace?: typeof readActiveWorkspace; resolveWorkspaceToken?: typeof resolveWorkspaceToken; provider?: string; includeTriggers?: boolean; @@ -89,9 +88,7 @@ export async function listIntegrations( options: ListIntegrationsOptions = {} ): Promise { const env = options.env ?? process.env; - const active = options.activeWorkspace !== undefined - ? options.activeWorkspace - : await (options.readActiveWorkspace ?? readActiveWorkspace)().catch(() => null); + const active = options.activeWorkspace ?? null; const cloudUrl = resolveCloudUrl({ env, active, ...(options.cloudUrl ? { flag: options.cloudUrl } : {}) }); const auth = await resolveAuth(options, cloudUrl, active, env); diff --git a/packages/deploy/src/login.test.ts b/packages/deploy/src/login.test.ts index 5c35a79e..b3bb5c83 100644 --- a/packages/deploy/src/login.test.ts +++ b/packages/deploy/src/login.test.ts @@ -1,5 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { clearActiveWorkspace, clearStoredWorkspaceToken, @@ -50,6 +54,42 @@ async function withWorkspaceEnv( } } +async function withCloudSessionEnv(fn: () => Promise): Promise { + const previous = { + WORKFORCE_WORKSPACE_ID: process.env.WORKFORCE_WORKSPACE_ID, + WORKFORCE_WORKSPACE_TOKEN: process.env.WORKFORCE_WORKSPACE_TOKEN, + 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, + CLOUD_API_URL: process.env.CLOUD_API_URL + }; + delete process.env.WORKFORCE_WORKSPACE_ID; + delete process.env.WORKFORCE_WORKSPACE_TOKEN; + process.env.CLOUD_API_URL = 'https://cloud.example.test'; + process.env.CLOUD_API_ACCESS_TOKEN = 'cloud-access'; + process.env.CLOUD_API_REFRESH_TOKEN = 'cloud-refresh'; + process.env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT = '2999-01-01T00:00:00.000Z'; + 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; + } + } + } +} + +function withTrappedFetch(handler: typeof fetch): () => void { + const previous = globalThis.fetch; + globalThis.fetch = handler; + return () => { + globalThis.fetch = previous; + }; +} + test('resolveWorkspaceToken preserves complete WORKFORCE env credentials for CI', async () => { await withWorkspaceEnv({ workspaceId: 'rw_1234abcd', @@ -80,6 +120,71 @@ test('resolveWorkspaceToken lets --workspace pair with WORKFORCE_WORKSPACE_TOKEN }); }); +test('resolveWorkspaceToken fails clearly when workspace resolve returns non-JSON', async () => { + await withCloudSessionEnv(async () => { + const restoreFetch = withTrappedFetch(async () => + new Response('', { status: 200, headers: { 'content-type': 'text/plain' } }) + ); + try { + await assert.rejects( + resolveWorkspaceToken({ + workspace: 'rw_badjson', + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }), + /workspace resolve returned an invalid descriptor/ + ); + } finally { + restoreFetch(); + } + }); +}); + +test('resolveWorkspaceToken without a cloud session fails with login guidance', () => { + const home = mkdtempSync(path.join(os.tmpdir(), 'wf-no-cloud-session-')); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: home, + AGENT_RELAY_HOME: path.join(home, '.agentworkforce', 'relay') + }; + delete env.WORKFORCE_WORKSPACE_ID; + delete env.WORKFORCE_WORKSPACE_TOKEN; + delete env.CLOUD_API_URL; + delete env.CLOUD_API_ACCESS_TOKEN; + delete env.CLOUD_API_REFRESH_TOKEN; + delete env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT; + try { + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + const { resolveWorkspaceToken } = await import('./dist/login.js'); + const { createBufferedIO } = await import('./dist/io.js'); + try { + await resolveWorkspaceToken({ + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }); + process.exit(2); + } catch (error) { + process.stderr.write(error instanceof Error ? error.message : String(error)); + } + ` + ], + { cwd: process.cwd(), env, encoding: 'utf8' } + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stderr, /Cloud login required/); + assert.match(result.stderr, /agent-relay login/); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + test('legacy active workspace pointer functions are inert', async () => { await writeActiveWorkspace({ workspace: 'stale', diff --git a/packages/deploy/src/login.ts b/packages/deploy/src/login.ts index 9bce7830..a4a6815e 100644 --- a/packages/deploy/src/login.ts +++ b/packages/deploy/src/login.ts @@ -16,6 +16,7 @@ export interface WorkspaceAuth { /** Resolve the active workspace, prompting the user to pick one if needed. */ resolveWorkspace(args: { override?: string; io: DeployIO }): Promise<{ workspace: string; + relayfileWorkspaceId?: string; /** Workspace-scoped token usable for gateway + cloud API calls. */ token: string; }>; @@ -169,7 +170,8 @@ async function resolveWorkspaceDescriptor(args: { const text = await response.text().catch(() => ''); throw new Error(`workspace resolve failed for ${workspace}: ${response.status} ${text}`.trim()); } - return normalizeWorkspaceDescriptor(await response.json(), session.auth.apiUrl || args.apiUrl); + const payload = await response.json().catch(() => null); + return normalizeWorkspaceDescriptor(payload, session.auth.apiUrl || args.apiUrl); } function normalizeWorkspaceDescriptor(payload: unknown, apiUrl: string): ActiveWorkspaceDescriptor {