diff --git a/packages/deploy/src/modes/cloud-subscription.test.ts b/packages/deploy/src/modes/cloud-subscription.test.ts index 470d53e0..80edb0b2 100644 --- a/packages/deploy/src/modes/cloud-subscription.test.ts +++ b/packages/deploy/src/modes/cloud-subscription.test.ts @@ -169,7 +169,7 @@ test('ensureCloudSubscriptionReady oauth leg throws "credentials are not connect const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -308,7 +308,7 @@ test('ensureCloudSubscriptionReady oauth leg resolves without connectProvider wh const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -356,6 +356,106 @@ test('ensureCloudSubscriptionReady oauth leg resolves without connectProvider wh } }); +test('ensureCloudSubscriptionReady reconnect fails fast when its deploy credential cannot verify completion', async () => { + let connectCalled = false; + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => ({ + apiUrl: 'https://cloud.example.test', + accessToken: 'stored-user-login', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }), + connectProvider: async () => { + connectCalled = true; + return { provider: 'anthropic', success: true }; + }, + createCloudApiClient() { + throw new Error('an unrelated stored login must not run the deploy probe'); + } + }); + + const io = createBufferedIO(); + try { + await assert.rejects( + ensureCloudSubscriptionReady(subscriptionArgs(io, { + persona: persona({ harness: 'claude', model: 'claude-sonnet-4-6' }), + harnessSource: 'oauth', + noPrompt: false, + reconnectProviders: ['anthropic'] + })), + /--reconnect anthropic cannot verify completion.*deploy credential cannot authoritatively list/s + ); + assert.equal(connectCalled, false, 'must fail before starting a connection it can never verify'); + } finally { + restoreDeps(); + } +}); + +test('ensureCloudSubscriptionReady keeps polling through a matching stored-token refresh', async () => { + let storedAuth = { + apiUrl: 'https://cloud.example.test', + accessToken: 'tok', + refreshToken: 'refresh', + accessTokenExpiresAt: '2000-01-01T00:00:00.000Z' + }; + let storedAuthReads = 0; + let refreshes = 0; + let connected = false; + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => { + storedAuthReads += 1; + return storedAuth; + }, + refreshStoredAuth: async (auth) => { + refreshes += 1; + assert.equal(auth.accessToken, 'tok'); + storedAuth = { + ...storedAuth, + accessToken: 'rotated-token', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }; + return storedAuth; + }, + connectProvider: async () => { + connected = true; + return { provider: 'anthropic', success: true }; + }, + createCloudApiClient(auth) { + assert.equal(auth.accessToken, 'rotated-token'); + return { + async fetch() { + return okJson({ + agents: connected + ? [{ id: 'pc-anthropic-rotated', harness: 'claude', status: 'connected' }] + : [] + }); + } + }; + } + }); + + const io = createBufferedIO(); + io.scriptConfirmations([true]); + try { + const result = await withEnv({ + WORKFORCE_DEPLOY_POLL_INTERVAL_MS: '1', + WORKFORCE_DEPLOY_POLL_TIMEOUT_MS: '5000' + }, () => + ensureCloudSubscriptionReady(subscriptionArgs(io, { + persona: persona({ harness: 'claude', model: 'claude-sonnet-4-6' }), + harnessSource: 'oauth', + noPrompt: false + })) + ); + assert.equal(connected, true); + assert.equal(refreshes, 1); + assert.equal(storedAuthReads, 1, 'polling reuses the identity-verified probe client'); + assert.deepEqual(result.credentialSelections, { anthropic: 'pc-anthropic-rotated' }); + } finally { + restoreDeps(); + } +}); + test('ensureCloudSubscriptionReady oauth leg connects, polls until connected, then returns selections', async () => { let connectCalled = false; let pollCount = 0; @@ -363,7 +463,7 @@ test('ensureCloudSubscriptionReady oauth leg connects, polls until connected, th const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -423,7 +523,7 @@ test('ensureCloudSubscriptionReady oauth leg returns { provider } without creden const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index 27e59530..b83eb2be 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -440,7 +440,7 @@ test('cloud harness prompt default chooses managed provider credentials', async const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -530,7 +530,7 @@ test('cloud harness OAuth probe hits /api/v1/cloud-agents and honors no-prompt f const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -570,7 +570,7 @@ test('cloud harness OAuth probe treats a matching connected entry as ready (skip const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -617,6 +617,128 @@ test('cloud harness OAuth probe treats a matching connected entry as ready (skip assert.ok(!calls.some((c) => c.url.includes('/cli/auth'))); }); +test('cloud harness OAuth probe retains authority when its stored token refreshes', async () => { + let storedAuth = { + apiUrl: 'https://cloud.example.test', + accessToken: 'tok', + refreshToken: 'refresh', + accessTokenExpiresAt: '2000-01-01T00:00:00.000Z' + }; + let storedAuthReads = 0; + let refreshes = 0; + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => { + storedAuthReads += 1; + return storedAuth; + }, + refreshStoredAuth: async (auth) => { + refreshes += 1; + assert.equal(auth.accessToken, 'tok'); + storedAuth = { + ...storedAuth, + accessToken: 'rotated-token', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }; + return storedAuth; + }, + createCloudApiClient(auth) { + assert.equal(auth.accessToken, 'rotated-token'); + return { + async fetch(pathname: string) { + assert.equal(pathname, '/api/v1/cloud-agents'); + return okJson({ + agents: [{ id: 'pc-anthropic-rotated', harness: 'claude', status: 'connected' }] + }); + } + }; + } + }); + + try { + const { handle } = await launch({ + persona: persona({ harness: 'claude', model: 'claude-sonnet-4-6' }), + defaultManagedCredential: false, + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_HARNESS_SOURCE: 'oauth', + WORKFORCE_DEPLOY_NO_PROMPT: '1' + }, + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + const body = JSON.parse(String(init?.body)) as Record; + assert.deepEqual(body.credentialSelections, { anthropic: 'pc-anthropic-rotated' }); + return okJson({ agentId: 'agent-refreshed-probe', deploymentId: 'dep-1', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }); + assert.equal(handle.id, 'agent-refreshed-probe'); + assert.equal(refreshes, 1); + assert.equal(storedAuthReads, 1, 'the verified probe client is reused after token rotation'); + } finally { + restoreDeps(); + } +}); + +test('cloud harness source resolves a connected Claude setup-token without an operator override', async () => { + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => ({ + apiUrl: 'https://cloud.example.test', + accessToken: 'tok', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }), + createCloudApiClient() { + return { + async fetch(pathname: string) { + assert.equal(pathname, '/api/v1/cloud-agents'); + return okJson({ + agents: [ + { + id: 'pc-claude-setup-token', + harness: 'claude', + modelProvider: 'anthropic', + authType: 'oauth_token', + status: 'connected', + isActive: true, + credentialExpiresAt: null, + lastError: null + } + ] + }); + } + }; + } + }); + + const { handle } = await launch({ + persona: persona({ harness: 'claude', model: 'claude-sonnet-4-6' }), + defaultManagedCredential: false, + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_HARNESS_SOURCE: undefined, + WORKFORCE_DEPLOY_NO_PROMPT: '1' + }, + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + const body = JSON.parse(String(init?.body)) as Record; + assert.deepEqual(body.credentialSelections, { + anthropic: 'pc-claude-setup-token' + }); + return okJson( + { agentId: 'agent-claude-setup-token', deploymentId: 'dep-1', status: 'active' }, + 201 + ); + } + throw new Error(`unexpected URL ${url}`); + } + }).finally(restoreDeps); + + assert.equal(handle.id, 'agent-claude-setup-token'); +}); + test('cloud harness OAuth probe maps a grok persona to the connected xai credential', async () => { // Regression: a grok persona declares `model: "grok-build"`, but the // connected credential `relay cloud connect xai` stores is keyed @@ -629,7 +751,7 @@ test('cloud harness OAuth probe maps a grok persona to the connected xai credent const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -682,7 +804,7 @@ test('cloud harness OAuth probe ignores entries with the wrong harness', async ( const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -725,7 +847,7 @@ test('cloud harness OAuth starts auth and polls /cloud-agents until the harness const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -797,7 +919,7 @@ test('cloud --reconnect forces a fresh harness connect even when already connect const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -854,11 +976,44 @@ test('cloud --reconnect forces a fresh harness connect even when already connect assert.deepEqual(connected, ['openai']); }); +test('cloud --reconnect fails fast when the deploy credential cannot verify completion', async () => { + let connectCalled = false; + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => ({ + apiUrl: 'https://cloud.example.test', + accessToken: 'stored-user-login', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }), + connectProvider: async () => { + connectCalled = true; + return { provider: 'openai', success: true }; + }, + createCloudApiClient() { + throw new Error('an unrelated stored login must not run the deploy probe'); + } + }); + + await assert.rejects( + launch({ + defaultManagedCredential: false, + env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' }, + input: { harnessSource: 'oauth', reconnectProviders: ['openai'] }, + fetch(url) { + throw new Error(`unexpected URL ${url}`); + } + }), + /--reconnect openai cannot verify completion.*deploy credential cannot authoritatively list/s + ).finally(restoreDeps); + + assert.equal(connectCalled, false, 'must fail before starting a connection it can never verify'); +}); + test('cloud --reconnect with --no-prompt fails with actionable guidance', async () => { const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -1511,7 +1666,7 @@ test('cloud oauth deploy stamps anthropic credentialSelections from the connecte const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -1566,7 +1721,7 @@ test('cloud oauth deploy does NOT stamp openai selections and prints the harness const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -1619,7 +1774,7 @@ test('cloud oauth deploy falls back to unstamped when the connected row has no i const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -1672,7 +1827,7 @@ test('cloud oauth deploy stamps the ACTIVE anthropic row over a newer inactive o const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -1722,7 +1877,7 @@ test('cloud oauth deploy cross-stamps a connected anthropic credential for an op const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), @@ -1816,14 +1971,100 @@ test('a headless deploy proceeds when the harness probe cannot run at all', asyn ); }); +test('a workspace deploy token does not trust a stored user login credential listing', async () => { + // Regression: the deployment uses WORKFORCE_WORKSPACE_TOKEN (`tok`), while + // fetchCloudAgents used an unrelated stored user login. Cloud scopes the + // successful response to that login's (user, current workspace), so an empty + // list was not evidence about the deployment workspace and must be null, not + // false. Do not even issue the misleading request with the other identity. + let storedLoginRequests = 0; + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => ({ + apiUrl: 'https://cloud.example.test', + accessToken: 'stored-user-login', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }), + createCloudApiClient() { + return { + async fetch() { + storedLoginRequests += 1; + return okJson({ agents: [] }); + } + }; + } + }); + + const { handle, io } = await launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_HARNESS_SOURCE: undefined, + WORKFORCE_DEPLOY_NO_PROMPT: '1' + }, + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + return okJson({ agentId: 'agent-ci-token', deploymentId: 'dep-1', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }).finally(restoreDeps); + + assert.equal(handle.id, 'agent-ci-token'); + assert.equal(storedLoginRequests, 0); + assert.ok( + io.messages.some((entry) => /credential check is unavailable here/.test(entry.message)), + 'the CLI reports an indeterminate probe instead of declaring the credential missing' + ); +}); + +test('a deployment credential forbidden from listing user credentials is undeterminable', async () => { + let probeCalls = 0; + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => ({ + apiUrl: 'https://cloud.example.test', + accessToken: 'tok', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }), + createCloudApiClient() { + return { + async fetch() { + probeCalls += 1; + return okJson({ error: 'Forbidden' }, 403); + } + }; + } + }); + + const { handle } = await launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_HARNESS_SOURCE: undefined, + WORKFORCE_DEPLOY_NO_PROMPT: '1' + }, + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + return okJson({ agentId: 'agent-ci-forbidden', deploymentId: 'dep-1', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }).finally(restoreDeps); + + assert.equal(handle.id, 'agent-ci-forbidden'); + assert.ok(probeCalls >= 1, 'the deployment credential reached the cloud route'); +}); + test('a probe that CAN run and reports nothing connected still fails closed', async () => { // The complement of the test above: "cannot check" must not become a blanket - // bypass. With a stored login the probe runs, and an empty list is a real - // negative that must still stop a --no-prompt deploy. + // bypass. When the stored login IS the credential used for this deploy, the + // probe is authoritative and an empty list is a real negative that must still + // stop a --no-prompt deploy. const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', - accessToken: 'access', + accessToken: 'tok', refreshToken: 'refresh', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' }), diff --git a/packages/deploy/src/modes/cloud/index.ts b/packages/deploy/src/modes/cloud/index.ts index d891fae0..6b172113 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -87,6 +87,10 @@ interface ExistingAgent { type CloudApiClientLike = Pick; +interface CloudAgentsProbe { + getClient(): Promise; +} + type CloudCredentialDeps = { readStoredAuth: typeof readStoredAuth; refreshStoredAuth: typeof refreshStoredAuth; @@ -309,7 +313,11 @@ async function ensureHarnessReady(args: { args.io.info('cloud: persona declares no harness; skipping harness credential setup'); return {}; } - const source = await resolveHarnessSource(args); + const probeArgs = { + ...args, + cloudAgentsProbe: createCloudAgentsProbe(args) + }; + const source = await resolveHarnessSource(probeArgs); const modelProvider = deriveModelProvider(args.persona); if (source === 'managed') { const credentialId = await saveProviderCredential({ @@ -337,8 +345,8 @@ async function ensureHarnessReady(args: { return { [modelProvider]: credentialId }; } - await ensureHarnessOauth(args); - return resolveOauthCredentialSelections(args); + await ensureHarnessOauth(probeArgs); + return resolveOauthCredentialSelections(probeArgs); } async function resolveHarnessSource(args: { @@ -350,6 +358,7 @@ async function resolveHarnessSource(args: { noPrompt: boolean; harnessSource?: HarnessSource; byokKey?: string; + cloudAgentsProbe: CloudAgentsProbe; }): Promise { if (args.harnessSource) return normalizeHarnessSource(args.harnessSource); const fromEnv = process.env.WORKFORCE_DEPLOY_HARNESS_SOURCE?.trim(); @@ -366,7 +375,7 @@ async function resolveHarnessSource(args: { // connected" from its own missing login. args.io.info( `cloud: the ${args.persona.harness} credential check is unavailable here ` + - '(no stored CLI login, or this cloud does not serve the route); ' + + '(the deploy credential cannot authoritatively list it, or this cloud does not serve the route); ' + 'assuming oauth — cloud will reject the deploy if it is genuinely not connected.' ); return 'oauth'; @@ -391,10 +400,11 @@ async function resolveHarnessSource(args: { * * Tri-state on purpose: `null` means WE COULD NOT CHECK — not "not connected". * The check is unavailable whenever `fetchCloudAgents` yields nothing: no - * stored CLI login (every headless/CI deploy authenticates with a workspace - * deploy token instead), or a cloud without the route (404/405). Collapsing - * that into `false` made every `--no-prompt` deploy fail with "credentials are - * not connected" even when they were connected — the same false negative the + * stored CLI login, a deploy credential different from that login, a deploy + * credential without permission to list user credentials (403), or a cloud + * without the route (404/405). Collapsing that into `false` made headless + * `--no-prompt` deploys fail with "credentials are not connected" even when + * they were connected — the same false negative the * `/users/me/provider_credentials` 404 caused before it, described below. * * Cloud surfaces this via `GET /api/v1/cloud-agents`, which returns one @@ -409,26 +419,42 @@ async function resolveHarnessSource(args: { */ async function isHarnessOauthConnected(args: { cloudUrl: string; + token: string; persona: PersonaSpec; + cloudAgentsProbe: CloudAgentsProbe; }): Promise { - const body = await fetchCloudAgents(args.cloudUrl); + const body = await fetchCloudAgents(args); if (!body) return null; return hasConnectedHarness(body, deriveModelProvider(args.persona)); } /** * Fetch the `/api/v1/cloud-agents` list, or `null` when there is no usable - * stored auth or the route doesn't exist on the target cloud (404/405). + * authoritative auth for this deploy or the route cannot serve it. + * + * The route is scoped to the authenticated `(userId, workspaceId)`, not the + * workspace in the deployment URL. The probe verifies that the stored login + * starts with the deploy token before allowing that refresh-capable session to + * answer. Its client is retained for the whole credential flow, so a refresh + * can rotate the token without making the already-verified identity look like + * an unrelated login. CI deploy tokens that never matched the stored login + * remain undeterminable. */ -async function fetchCloudAgents(cloudUrl: string): Promise { - const auth = await readUsableCloudAuth(cloudUrl); - if (!auth) return null; - const client = cloudCredentialDeps.createCloudApiClient(auth, cloudUrl); +async function fetchCloudAgents(args: { + cloudUrl: string; + token: string; + cloudAgentsProbe: CloudAgentsProbe; +}): Promise { + const client = await args.cloudAgentsProbe.getClient(); + if (!client) return null; const res = await client.fetch('/api/v1/cloud-agents', { method: 'GET', headers: { 'user-agent': USER_AGENT } }); - if (res.status === 404 || res.status === 405) return null; + // Workspace deployment tokens are intentionally not granted `cli:auth`, so + // cloud returns 403 for this user-credential listing. That is "cannot see", + // not evidence that the workspace has no connected credential. + if (res.status === 403 || res.status === 404 || res.status === 405) return null; if (res.status === 401) { throw new Error('cloud harness check failed: unauthorized. Run `agentworkforce login` and retry.'); } @@ -456,11 +482,13 @@ async function fetchCloudAgents(cloudUrl: string): Promise> { const provider = deriveModelProvider(args.persona); - const body = await fetchCloudAgents(args.cloudUrl); + const body = await fetchCloudAgents(args); if (!body) { // The stamping lookup reads the SAME route as the probe, so when that route // is unavailable a headless oauth deploy cannot resolve a credential id @@ -538,6 +566,7 @@ async function ensureHarnessOauth(args: { io: ModeLaunchInput['io']; noPrompt: boolean; reconnectProviders?: readonly string[]; + cloudAgentsProbe: CloudAgentsProbe; }): Promise { const reconnect = harnessReconnectRequested(args.reconnectProviders, args.persona); const connected = await isHarnessOauthConnected(args); @@ -556,7 +585,7 @@ async function ensureHarnessOauth(args: { // headless deploy regardless of what the workspace actually has connected. args.io.info( `cloud: the ${args.persona.harness} credential check is unavailable here ` + - '(no stored CLI login, or this cloud does not serve the route); ' + + '(the deploy credential cannot authoritatively list it, or this cloud does not serve the route); ' + 'proceeding — cloud will reject the deploy if it is genuinely not connected.' ); return; @@ -572,6 +601,13 @@ async function ensureHarnessOauth(args: { : `cloud: ${args.persona.harness} OAuth credentials are not connected. Run without --no-prompt or choose --harness-source managed/byok.` ); } + if (connected === null && reconnect) { + throw new Error( + `cloud: --reconnect ${deriveModelProvider(args.persona)} cannot verify completion because ` + + 'this deploy credential cannot authoritatively list connected credentials. ' + + 'Reconnect with the same stored cloud login as the deploy, or reconnect separately and rerun without --reconnect.' + ); + } if (connected) { args.io.info( `cloud: reconnect requested; opening a fresh ${args.persona.harness} connection flow (replaces the stored credential)` @@ -644,7 +680,11 @@ export async function ensureCloudSubscriptionReady(args: { byokKey?: string; reconnectProviders?: readonly string[]; }): Promise { - const source = resolveSubscriptionHarnessSource(args); + const probeArgs = { + ...args, + cloudAgentsProbe: createCloudAgentsProbe(args) + }; + const source = resolveSubscriptionHarnessSource(probeArgs); const provider = deriveModelProvider(args.persona); if (source === 'byok') { @@ -664,8 +704,8 @@ export async function ensureCloudSubscriptionReady(args: { }; } - await ensureSubscriptionOauth(args); - const credentialSelections = await resolveOauthCredentialSelections(args); + await ensureSubscriptionOauth(probeArgs); + const credentialSelections = await resolveOauthCredentialSelections(probeArgs); return Object.keys(credentialSelections).length > 0 ? { provider, credentialSelections } : { provider }; @@ -694,6 +734,7 @@ async function ensureSubscriptionOauth(args: { io: ModeLaunchInput['io']; noPrompt: boolean; reconnectProviders?: readonly string[]; + cloudAgentsProbe: CloudAgentsProbe; }): Promise { const provider = deriveModelProvider(args.persona); const reconnect = harnessReconnectRequested(args.reconnectProviders, args.persona); @@ -710,7 +751,7 @@ async function ensureSubscriptionOauth(args: { // headless deploy of a useSubscription persona on a check that cannot run. args.io.info( `subscription: the ${provider} credential check is unavailable here ` + - '(no stored CLI login, or this cloud does not serve the route); ' + + '(the deploy credential cannot authoritatively list it, or this cloud does not serve the route); ' + 'proceeding — cloud will reject the deploy if it is genuinely not connected.' ); return; @@ -727,6 +768,13 @@ async function ensureSubscriptionOauth(args: { 'Run without --no-prompt to connect them, pass --harness-source byok with --byok-key, or remove useSubscription to use workforce-billed inference.' ); } + if (connected === null && reconnect) { + throw new Error( + `cloud: --reconnect ${provider} cannot verify completion because this deploy credential ` + + 'cannot authoritatively list connected credentials. Reconnect with the same stored cloud login ' + + 'as the deploy, or reconnect separately and rerun without --reconnect.' + ); + } if (connected) { args.io.info( `subscription: reconnect requested; opening a fresh ${provider} connection flow (replaces the stored credential)` @@ -1260,9 +1308,38 @@ function normalizeCloudUrl(url: string): string { return trimmed.replace(/\/+$/, ''); } -async function readUsableCloudAuth(apiUrl: string): Promise { +function createCloudAgentsProbe(args: { + cloudUrl: string; + token: string; +}): CloudAgentsProbe { + let clientPromise: Promise | undefined; + return { + getClient() { + clientPromise ??= resolveCloudAgentsClient(args); + return clientPromise; + } + }; +} + +async function resolveCloudAgentsClient(args: { + cloudUrl: string; + token: string; +}): Promise { + const auth = await readUsableCloudAuth(args.cloudUrl, args.token); + return auth + ? cloudCredentialDeps.createCloudApiClient(auth, args.cloudUrl) + : null; +} + +async function readUsableCloudAuth( + apiUrl: string, + expectedAccessToken: string +): Promise { let auth = await cloudCredentialDeps.readStoredAuth().catch(() => null); if (!auth) return null; + // Establish identity continuity before refresh rotates either token. Once + // matched, the caller retains the resulting client for the full probe flow. + if (auth.accessToken !== expectedAccessToken) return null; if (isAuthExpired(auth.accessTokenExpiresAt)) { auth = await cloudCredentialDeps.refreshStoredAuth(auth).catch((err) => { console.warn(`cloud: stored auth refresh failed: ${formatErrorMessage(err)}`);