Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 105 additions & 2 deletions packages/deploy/src/modes/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,14 +432,35 @@ 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: {
WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test',
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' });
Expand All @@ -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');
});
Expand Down Expand Up @@ -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/
);
});
95 changes: 84 additions & 11 deletions packages/deploy/src/modes/cloud/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

if (args.noPrompt) {
throw new Error(
Expand All @@ -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
Expand All @@ -388,9 +410,9 @@ async function resolveHarnessSource(args: {
async function isHarnessOauthConnected(args: {
cloudUrl: string;
persona: PersonaSpec;
}): Promise<boolean> {
}): Promise<boolean | null> {
const body = await fetchCloudAgents(args.cloudUrl);
if (!body) return false;
if (!body) return null;
return hasConnectedHarness(body, deriveModelProvider(args.persona));
}

Expand Down Expand Up @@ -439,6 +461,21 @@ async function resolveOauthCredentialSelections(args: {
}): Promise<Record<string, string>> {
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
Expand All @@ -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; ` +
Expand All @@ -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.`
Expand Down Expand Up @@ -509,13 +546,28 @@ async function ensureHarnessOauth(args: {
// redeploy can never refresh a dead harness credential. `--reconnect
// <provider>` 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) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// 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.
Comment on lines +553 to +556

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve a credential ID before proceeding with OAuth

When a headless deploy has no stored CLI login, this early return is followed by resolveOauthCredentialSelections(), which performs the same unavailable fetchCloudAgents() lookup and therefore returns {}. The deployment request consequently contains empty credential selections even when the workspace has a valid connected Anthropic credential; this file documents that an unstamped OAuth deployment leaves ctx.llm using its throwing stub. The deploy can thus report success while every invocation needing ctx.llm fails, so this path must either obtain/stamp the credential through a deploy-token-authorized or server-side mechanism, or avoid reporting a successful usable deployment.

Useful? React with 👍 / 👎.

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.`
);
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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 <provider>` 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.'
Expand Down Expand Up @@ -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`);
Expand Down
Loading