From 06002c53ba765d56b4e3420105b1296532928087 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Mon, 17 Aug 2026 22:59:28 +0200 Subject: [PATCH 1/3] fix(deploy): don't treat an unrunnable harness probe as "not connected" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every headless deploy fails with "credentials are not connected" no matter what the workspace has connected. In the internal-agents repo that is every CI deploy since the workflows were introduced — x-reply-radar hits "cloud: grok credentials are not connected", x-tweet-competition hits the useSubscription variant, with xai connected in the workspace the whole time. The probe reads GET /api/v1/cloud-agents, which cloud gates behind requireSessionAuth || requireAuthScope('cli:auth'), and the client is built from readStoredAuth() — the user's stored CLI login on disk. A headless deploy authenticates with a workspace deploy token and has no stored login, so fetchCloudAgents returns null and isHarnessOauthConnected turned that into `false`. "Could not check" and "checked, nothing there" were the same value. isHarnessOauthConnected is now tri-state, with null meaning undeterminable, and all three call sites distinguish it: - resolveHarnessSource: null -> 'oauth' (the default the caller would pick anyway) instead of throwing under --no-prompt - ensureHarnessOauth: null -> proceed - ensureSubscriptionOauth: null -> proceed In every case CLOUD stays the authority. It validates the credential at deploy time and fails with a message about the actual credential, rather than this CLI inferring absence from its own missing login. That is the same failure mode the /users/me/provider_credentials 404 caused before it — the note on fetchCloudAgents already documents that round. The two pollUntil waits after a connect flow now require a definite `true`: the probe IS runnable there, so null means still-unknown and must keep polling rather than count as connected. Tests: a headless deploy (no stored auth) proceeds and explains why it could not check; and — the complement, so this cannot become a blanket bypass — a probe that CAN run and returns an empty list still fails closed under --no-prompt. Co-Authored-By: Claude Opus 5 --- packages/deploy/src/modes/cloud.test.ts | 72 ++++++++++++++++++++++++ packages/deploy/src/modes/cloud/index.ts | 62 +++++++++++++++++--- 2 files changed, 127 insertions(+), 7 deletions(-) diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index d8b29c20..ad9a7d7c 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -1748,3 +1748,75 @@ 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) => /cannot verify .* credentials without a stored CLI login; assuming oauth/.test(m.message)), + 'the deploy says why it could not check, instead of asserting "not connected"' + ); +}); + +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..59ad3a94 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -356,7 +356,19 @@ 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: cannot verify ${args.persona.harness} credentials without a stored CLI login; assuming oauth (cloud will reject if it is genuinely not connected).` + ); + return 'oauth'; + } if (args.noPrompt) { throw new Error( @@ -385,12 +397,24 @@ async function resolveHarnessSource(args: { * 404 made every deploy with `--no-prompt` fail with "credentials are * not connected" even when they were — see workforce#118 follow-up. */ +/** + * Whether a connected harness credential exists for this persona's provider. + * + * Tri-state on purpose. `null` means WE COULD NOT CHECK — not "not connected". + * The check reads `/api/v1/cloud-agents`, which requires the user's stored CLI + * login (session or `cli:auth` scope); a headless deploy authenticates with a + * workspace deploy token instead and has no stored login, so the check is + * simply unavailable there. Collapsing that into `false` is what 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 (see fetchCloudAgents' note). + */ 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)); } @@ -509,10 +533,19 @@ 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: cannot verify ${args.persona.harness} credentials without a stored CLI login; proceeding (cloud will reject if it is genuinely not connected).` + ); + return; + } if (args.noPrompt) { throw new Error( connected @@ -544,7 +577,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,10 +681,19 @@ 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: cannot verify ${provider} credentials without a stored CLI login; proceeding (cloud will reject if it is genuinely not connected).` + ); + return; + } if (args.noPrompt) { throw new Error( connected @@ -680,7 +725,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`); From 08698973cf36ba2fb30272a1413f18c770462571 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Mon, 17 Aug 2026 23:10:05 +0200 Subject: [PATCH 2/3] test(deploy): make the harness-prompt test hermetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught it: 'cloud harness prompt default chooses managed provider credentials' mocked GET /cloud-agents through the global fetch but left readStoredAuth real, so whether the probe ran at all depended on whether the machine happened to have a stored CLI login. Locally it did, so the probe ran, reported nothing connected, and the interactive prompt defaulted to managed — which is what the test asserts. On a CI runner there is no stored login, the probe cannot run, and with this branch's tri-state that now resolves to oauth, so no managed credential was created and credentialSelections came back {}. The test is about what happens when the probe RUNS and finds nothing, so stub the stored auth the way the other fourteen probe tests already do. Verified both ways: full suite green, and green again with HOME pointed at an empty directory to reproduce the CI condition. Co-Authored-By: Claude Opus 5 --- packages/deploy/src/modes/cloud.test.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index ad9a7d7c..1523aac0 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'); }); From 4d796d9c56ad445157085c26cd64110e5c7d8479 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Mon, 17 Aug 2026 23:16:41 +0200 Subject: [PATCH 3/3] fix(deploy): warn when an unverifiable probe leaves ctx.llm unstamped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review catch (Codex P1, cubic P1, same finding independently): the credential STAMPING lookup reads the same /api/v1/cloud-agents route as the probe, so when that route is unavailable resolveOauthCredentialSelections also returns {} — and this file documents that an oauth deploy without a selection leaves every ctx.llm call on the throwing stub (workforce#196). Proceeding past the probe would have shipped a deployment that succeeds and then fails at runtime. It still proceeds — a ctx.harness.run persona (both agents that hit this bug) is unaffected, and blocking them is the thing being fixed — but the unstamped deployment is now a WARN naming the consequence and the two ways out, hoisted above the provider split so the openai/codex leg is covered too, not only anthropic. Asserted in the headless test. Also from review: - CodeRabbit: fetchCloudAgents also returns null on 404/405, so the messages no longer claim "without a stored CLI login"; they say the check is unavailable and name both causes. - cubic: a reconnect requested under --no-prompt now branches on `reconnect` rather than on falsy `connected`, in both the oauth and subscription paths. Reaching there with a reconnect requested means the browser flow is the blocker whether or not the probe ran, and null must never be reported as "not connected" — the exact false claim this change removes. - cubic: merged the duplicate JSDoc so isHarnessOauthConnected has one block. Verified with HOME pointed at an empty directory as well, to keep exercising the no-stored-login path CI runs on. Co-Authored-By: Claude Opus 5 --- packages/deploy/src/modes/cloud.test.ts | 12 ++++- packages/deploy/src/modes/cloud/index.ts | 63 +++++++++++++++++------- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index 1523aac0..27e59530 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -1801,9 +1801,19 @@ test('a headless deploy proceeds when the harness probe cannot run at all', asyn assert.equal(handle.id, 'agent-headless'); assert.ok( - io.messages.some((m) => /cannot verify .* credentials without a stored CLI login; assuming oauth/.test(m.message)), + 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 () => { diff --git a/packages/deploy/src/modes/cloud/index.ts b/packages/deploy/src/modes/cloud/index.ts index 59ad3a94..d891fae0 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -365,7 +365,9 @@ async function resolveHarnessSource(args: { // a message about the actual credential, instead of this CLI guessing "not // connected" from its own missing login. args.io.info( - `cloud: cannot verify ${args.persona.harness} credentials without a stored CLI login; assuming oauth (cloud will reject if it is genuinely not connected).` + `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'; } @@ -387,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 @@ -397,18 +407,6 @@ async function resolveHarnessSource(args: { * 404 made every deploy with `--no-prompt` fail with "credentials are * not connected" even when they were — see workforce#118 follow-up. */ -/** - * Whether a connected harness credential exists for this persona's provider. - * - * Tri-state on purpose. `null` means WE COULD NOT CHECK — not "not connected". - * The check reads `/api/v1/cloud-agents`, which requires the user's stored CLI - * login (session or `cli:auth` scope); a headless deploy authenticates with a - * workspace deploy token instead and has no stored login, so the check is - * simply unavailable there. Collapsing that into `false` is what 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 (see fetchCloudAgents' note). - */ async function isHarnessOauthConnected(args: { cloudUrl: string; persona: PersonaSpec; @@ -463,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 @@ -471,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; ` + @@ -485,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.` @@ -542,13 +555,19 @@ async function ensureHarnessOauth(args: { // cloud reject a genuinely missing credential; blocking here fails every // headless deploy regardless of what the workspace actually has connected. args.io.info( - `cloud: cannot verify ${args.persona.harness} credentials without a stored CLI login; proceeding (cloud will reject if it is genuinely not connected).` + `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.` ); @@ -690,13 +709,19 @@ async function ensureSubscriptionOauth(args: { // 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: cannot verify ${provider} credentials without a stored CLI login; proceeding (cloud will reject if it is genuinely not connected).` + `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.'