diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index d8b29c20..27e59530 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -432,6 +432,28 @@ test('cloud harness legacy plan env alias maps to managed provider credentials', }); test('cloud harness prompt default chooses managed provider credentials', async () => { + // The probe must actually RUN for this test to mean anything: it asserts what + // happens when the probe reports nothing connected. Stub the stored login + // rather than leaning on the developer's own — without this the test passes + // on a machine that happens to be logged in and takes a different path in CI, + // where there is no stored auth and the probe is simply unavailable. + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => ({ + apiUrl: 'https://cloud.example.test', + accessToken: 'access', + 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: [] }); + } + }; + } + }); + const prompted = await launch({ defaultManagedCredential: false, env: { @@ -439,7 +461,6 @@ test('cloud harness prompt default chooses managed provider credentials', async WORKFORCE_DEPLOY_HARNESS_SOURCE: undefined }, fetch(url, init) { - if (init?.method === 'GET' && url.endsWith('/cloud-agents')) return okJson({ agents: [] }); if (url.endsWith('/provider-credentials/managed?provider=openai')) { assert.equal(init?.method, 'POST'); return okJson({ providerCredentialId: 'cred-managed-prompt' }); @@ -452,7 +473,7 @@ test('cloud harness prompt default chooses managed provider credentials', async } throw new Error(`unexpected URL ${url}`); } - }); + }).finally(restoreDeps); assert.equal(prompted.handle.id, 'agent-managed-prompt'); }); @@ -1748,3 +1769,85 @@ test('cloud oauth deploy cross-stamps a connected anthropic credential for an op restoreDeps(); } }); + +test('a headless deploy proceeds when the harness probe cannot run at all', async () => { + // Regression for the false negative that blocked EVERY CI deploy: the probe + // reads /api/v1/cloud-agents, which needs the user's stored CLI login + // (session or cli:auth scope). A headless deploy authenticates with a + // workspace deploy token and has no stored login, so the probe cannot run — + // and "cannot check" was being collapsed into "not connected", failing with + // `credentials are not connected` no matter what the workspace actually had. + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => null, + createCloudApiClient() { + throw new Error('the probe must not be attempted without stored auth'); + } + }); + + const { handle, io } = await launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_NO_PROMPT: '1', + WORKFORCE_DEPLOY_HARNESS_SOURCE: undefined + }, + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + return okJson({ agentId: 'agent-headless', deploymentId: 'dep-1', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }).finally(restoreDeps); + + assert.equal(handle.id, 'agent-headless'); + assert.ok( + io.messages.some((m) => /credential check is unavailable here/.test(m.message)), + 'the deploy says why it could not check, instead of asserting "not connected"' + ); + // The stamping lookup reads the same unavailable route, so this deployment + // carries no ctx.llm selection. That is survivable for a ctx.harness.run + // persona and fatal for a ctx.llm one, so it must WARN, not read like the + // ordinary "checked, found nothing" info line. + assert.ok( + io.messages.some( + (m) => m.level === 'warn' && /NO ctx\.llm credential selection/.test(m.message) + ), + 'an unstamped deployment is surfaced as a warning' + ); +}); + +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. + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => ({ + apiUrl: 'https://cloud.example.test', + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }), + createCloudApiClient() { + return { + async fetch() { + return okJson({ agents: [] }); + } + }; + } + }); + + await assert.rejects( + launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_NO_PROMPT: '1', + WORKFORCE_DEPLOY_HARNESS_SOURCE: undefined + }, + fetch(url, init) { + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); + throw new Error(`unexpected URL ${url}`); + } + }).finally(restoreDeps), + /credentials are not connected/ + ); +}); diff --git a/packages/deploy/src/modes/cloud/index.ts b/packages/deploy/src/modes/cloud/index.ts index fb260bf8..d891fae0 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -356,7 +356,21 @@ async function resolveHarnessSource(args: { if (fromEnv) return expectHarnessSource(fromEnv); const available = await isHarnessOauthConnected(args); - if (available) return 'oauth'; + if (available === true) return 'oauth'; + + if (available === null) { + // Undeterminable, not absent — see isHarnessOauthConnected. Assume the + // source the user already defaults to and let CLOUD be the authority on + // whether the credential exists: it validates at deploy time and fails with + // a message about the actual credential, instead of this CLI guessing "not + // 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); ' + + 'assuming oauth — cloud will reject the deploy if it is genuinely not connected.' + ); + return 'oauth'; + } if (args.noPrompt) { throw new Error( @@ -375,6 +389,14 @@ async function resolveHarnessSource(args: { * Check whether the user already has a connected harness credential in * cloud for this persona's model provider. * + * 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 + * `/users/me/provider_credentials` 404 caused before it, described below. + * * Cloud surfaces this via `GET /api/v1/cloud-agents`, which returns one * row per (user, workspace, harness) — `harness` is the provider key * ("anthropic", "openai", …) and `status === 'connected'` means the @@ -388,9 +410,9 @@ async function resolveHarnessSource(args: { async function isHarnessOauthConnected(args: { cloudUrl: string; persona: PersonaSpec; -}): Promise { +}): Promise { const body = await fetchCloudAgents(args.cloudUrl); - if (!body) return false; + if (!body) return null; return hasConnectedHarness(body, deriveModelProvider(args.persona)); } @@ -439,6 +461,21 @@ async function resolveOauthCredentialSelections(args: { }): Promise> { const provider = deriveModelProvider(args.persona); const body = await fetchCloudAgents(args.cloudUrl); + 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 + // even when one is connected. Cloud accepts the deployment and every + // ctx.llm call then hits the throwing stub (workforce#196). A persona that + // only uses ctx.harness.run is unaffected, which is why this warns instead + // of failing — but it must be loud, and distinct from the "looked, found + // nothing" info lines below. + args.io.warn( + 'cloud: could not read connected credentials, so this deployment carries NO ctx.llm credential selection. ' + + 'ctx.harness.run is unaffected; if this persona calls ctx.llm, deploy it once interactively ' + + 'or use --harness-source managed/byok so a credential is stamped.' + ); + return {}; + } if (provider !== 'anthropic') { // Cross-provider fallback: the runtime's credential pick already // prefers the persona's model family but falls back to whatever @@ -447,7 +484,7 @@ async function resolveOauthCredentialSelections(args: { // family can't back ctx.llm (codex/ChatGPT OAuth is harness-only), a // connected anthropic credential is the honest deploy-time encoding // of what the runtime would do anyway. - const anthropicId = body ? findConnectedHarnessCredentialId(body, 'anthropic') : null; + const anthropicId = findConnectedHarnessCredentialId(body, 'anthropic'); if (anthropicId) { args.io.info( `cloud: ${provider} subscriptions are harness-only and cannot back ctx.llm; ` + @@ -461,7 +498,7 @@ async function resolveOauthCredentialSelections(args: { ); return {}; } - const credentialId = body ? findConnectedHarnessCredentialId(body, provider) : null; + const credentialId = findConnectedHarnessCredentialId(body, provider); if (!credentialId) { args.io.info( `cloud: no connected ${provider} credential row found; deploying without a ctx.llm credential selection.` @@ -509,13 +546,28 @@ async function ensureHarnessOauth(args: { // redeploy can never refresh a dead harness credential. `--reconnect // ` forces the connect flow to re-run and overwrite the stored // token — the escape hatch for codex/ChatGPT refresh-token rotation. - if (connected && !reconnect) { + if (connected === true && !reconnect) { args.io.info(`cloud: ${args.persona.harness} credentials already connected`); return; } + if (connected === null && !reconnect) { + // Undeterminable, not absent — see isHarnessOauthConnected. Proceed and let + // cloud reject a genuinely missing credential; blocking here fails every + // 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); ' + + 'proceeding — cloud will reject the deploy if it is genuinely not connected.' + ); + return; + } if (args.noPrompt) { throw new Error( - connected + // Branch on the REQUEST, not on `connected`: reaching here with a + // reconnect requested means the browser flow is the blocker regardless of + // whether the probe could run, and `null` must never be reported as "not + // connected" — that is the false claim this change exists to remove. + reconnect ? `cloud: --reconnect ${deriveModelProvider(args.persona)} opens a browser connect flow; re-run without --no-prompt.` : `cloud: ${args.persona.harness} OAuth credentials are not connected. Run without --no-prompt or choose --harness-source managed/byok.` ); @@ -544,7 +596,10 @@ async function ensureHarnessOauth(args: { } }); await pollUntil( - () => isHarnessOauthConnected(args), + // Only a definite `true` ends the wait: after a connect flow the probe IS + // runnable, so `null` here means still-unknown and should keep polling + // rather than count as connected. + async () => (await isHarnessOauthConnected(args)) === true, `timed out waiting for ${args.persona.harness} OAuth credentials` ); args.io.info(`cloud: ${args.persona.harness} credentials connected`); @@ -645,13 +700,28 @@ async function ensureSubscriptionOauth(args: { const connected = await isHarnessOauthConnected(args); // See ensureHarnessOauth: a `connected` row can hold a revoked token, so // `--reconnect ` forces a fresh connect that overwrites it. - if (connected && !reconnect) { + if (connected === true && !reconnect) { args.io.info(`subscription: ${provider} credentials already connected`); return; } + if (connected === null && !reconnect) { + // Undeterminable, not absent — see isHarnessOauthConnected. Proceed and let + // cloud reject a genuinely missing credential, rather than blocking every + // 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); ' + + 'proceeding — cloud will reject the deploy if it is genuinely not connected.' + ); + return; + } if (args.noPrompt) { throw new Error( - connected + // Branch on the REQUEST, not on `connected`: reaching here with a + // reconnect requested means the browser flow is the blocker regardless of + // whether the probe could run, and `null` must never be reported as "not + // connected" — that is the false claim this change exists to remove. + reconnect ? `cloud: --reconnect ${provider} opens a browser connect flow; re-run without --no-prompt.` : `persona "${args.persona.id}" sets useSubscription:true but ${provider} credentials are not connected. ` + 'Run without --no-prompt to connect them, pass --harness-source byok with --byok-key, or remove useSubscription to use workforce-billed inference.' @@ -680,7 +750,10 @@ async function ensureSubscriptionOauth(args: { } }); await pollUntil( - () => isHarnessOauthConnected(args), + // Only a definite `true` ends the wait: after a connect flow the probe IS + // runnable, so `null` here means still-unknown and should keep polling + // rather than count as connected. + async () => (await isHarnessOauthConnected(args)) === true, `timed out waiting for ${provider} OAuth credentials` ); args.io.info(`subscription: ${provider} credentials connected`);