From 4f4d156de3fc61957333e9aab496174ad06b1c32 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Thu, 4 Jun 2026 16:20:22 +0200 Subject: [PATCH 1/4] =?UTF-8?q?fix(deploy):=20stamp=20credentialSelections?= =?UTF-8?q?=20on=20oauth=20harness=20legs=20=E2=80=94=20ctx.llm=20no=20lon?= =?UTF-8?q?ger=20stubs=20on=20CLI=20oauth=20deploys=20(#196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both oauth legs (ensureHarnessReady and ensureCloudSubscriptionReady) returned without credentialSelections, so every CLI oauth deploy produced a deployment with empty selections and ctx.llm stubbed on each fire — only the web wizard stamped OAuth selections. The lookup needs no new cloud endpoint: /api/v1/cloud-agents selects straight from provider_credentials and returns the row id. The new resolveOauthCredentialSelections stamps { anthropic: } from the most-recent connected row; openai (and other) OAuth credentials are deliberately NOT stamped — cloud rejects ChatGPT/codex OAuth tokens for runtime env, so stamping would turn the stub into a failed delivery — the CLI prints the byok/anthropic alternatives instead. Matching is alias-aware (claude/codex/gemini/opencode vs the model-provider string) because cloud's OAuth completion route writes the harness alias into provider_credentials.harness while other write paths store the provider string. The connection probe shares the same matcher via connectedHarnessEntries; a connected row missing its id still counts as connected and the stamp degrades to today's unstamped behavior. Tests: anthropic stamp (alias row), openai no-stamp + harness-only message, no-id fallback. 172/172. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/deploy/src/modes/cloud.test.ts | 163 +++++++++++++++++++++++ packages/deploy/src/modes/cloud/index.ts | 150 +++++++++++++++++---- 2 files changed, 288 insertions(+), 25 deletions(-) diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index 49b4cb9d..66e04516 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -1021,3 +1021,166 @@ test('findExistingAgent: malformed array entries (null/empty) are skipped withou function callsForUrl(calls: FetchCall[], suffix: string): number { return calls.filter((call) => call.url.endsWith(suffix)).length; } + +test('cloud oauth deploy stamps anthropic credentialSelections from the connected row', async () => { + // workforce#196: the byok/plan legs stamp the credential they create, but + // the oauth leg deployed with empty selections, so ctx.llm stubbed on + // every fire. The connected row id comes back through + // /api/v1/cloud-agents (cloud selects it straight from + // provider_credentials). Note the row's harness field holds the + // OAuth-completion alias 'claude', not the model-provider string + // 'anthropic' — the lookup must match both spellings. + 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: [ + { id: 'pc-anthropic-1', harness: 'claude', status: 'connected' } + ] + }); + } + }; + } + }); + try { + const { handle, io } = await launch({ + persona: persona({ harness: 'claude', model: 'claude-sonnet-4-6' }), + defaultPlanCredential: 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-1' }); + assert.deepEqual(body.credential_selections, { anthropic: 'pc-anthropic-1' }); + return okJson({ agentId: 'agent-oauth-anthropic', deploymentId: 'dep-1', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }); + assert.equal(handle.id, 'agent-oauth-anthropic'); + assert.ok( + io.messages.some((entry) => entry.message.includes('selected connected anthropic credential')), + 'expected the selected-credential info line' + ); + } finally { + restoreDeps(); + } +}); + +test('cloud oauth deploy does NOT stamp openai selections and prints the harness-only message', async () => { + // ChatGPT/codex OAuth tokens are harness-only: cloud's runtime credential + // resolution rejects them for env injection, so stamping one would turn + // the ctx.llm stub into a failed delivery. The deploy must stay + // unstamped and tell the user the working alternatives. + 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: [ + { id: 'pc-openai-1', harness: 'openai', status: 'connected' } + ] + }); + } + }; + } + }); + try { + const { io } = await launch({ + defaultPlanCredential: 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, {}); + assert.deepEqual(body.credential_selections, {}); + return okJson({ agentId: 'agent-oauth-openai', deploymentId: 'dep-1', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }); + assert.ok( + io.messages.some((entry) => entry.message.includes('harness-only')), + 'expected the harness-only guidance line' + ); + } finally { + restoreDeps(); + } +}); + +test('cloud oauth deploy falls back to unstamped when the connected row has no id', async () => { + // Defensive split between the probe and the stamp: a connected entry + // missing its id must still count as connected (no reconnect prompt) + // while the selection stamp degrades to today's unstamped behavior with + // an explanatory line instead of failing the 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(pathname: string) { + assert.equal(pathname, '/api/v1/cloud-agents'); + return okJson({ + agents: [{ harness: 'claude', status: 'connected' }] + }); + } + }; + } + }); + try { + const { handle, io } = await launch({ + persona: persona({ harness: 'claude', model: 'claude-sonnet-4-6' }), + defaultPlanCredential: 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, {}); + return okJson({ agentId: 'agent-oauth-noid', deploymentId: 'dep-1', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }); + assert.equal(handle.id, 'agent-oauth-noid'); + assert.ok( + io.messages.some((entry) => entry.message.includes('no connected anthropic credential row')), + 'expected the unstamped-fallback info line' + ); + } finally { + restoreDeps(); + } +}); diff --git a/packages/deploy/src/modes/cloud/index.ts b/packages/deploy/src/modes/cloud/index.ts index 0e63b4cb..f2c3dc52 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -324,7 +324,7 @@ async function ensureHarnessReady(args: { } await ensureHarnessOauth(args); - return {}; + return resolveOauthCredentialSelections(args); } async function resolveHarnessSource(args: { @@ -375,22 +375,70 @@ async function isHarnessOauthConnected(args: { cloudUrl: string; persona: PersonaSpec; }): Promise { - const auth = await readUsableCloudAuth(args.cloudUrl); - if (!auth) return false; - const client = cloudCredentialDeps.createCloudApiClient(auth, args.cloudUrl); + const body = await fetchCloudAgents(args.cloudUrl); + if (!body) return false; + 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). + */ +async function fetchCloudAgents(cloudUrl: string): Promise { + const auth = await readUsableCloudAuth(cloudUrl); + if (!auth) return null; + const client = cloudCredentialDeps.createCloudApiClient(auth, cloudUrl); const res = await client.fetch('/api/v1/cloud-agents', { method: 'GET', headers: { 'user-agent': USER_AGENT } }); - if (res.status === 404 || res.status === 405) return false; + if (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.'); } if (!res.ok) { throw new Error(`cloud harness check failed: ${res.status} ${await responseExcerpt(res)}`); } - const body = (await res.json()) as CloudAgentsListResponse; - return hasConnectedHarness(body, deriveModelProvider(args.persona)); + return (await res.json()) as CloudAgentsListResponse; +} + +/** + * Stamp `credentialSelections` for the oauth harness source so cloud's + * runtime can configure `ctx.llm` from the deployment. The byok/plan legs + * already stamp the credential they create; an oauth deploy without a + * selection leaves the deployment with empty selections and every fire + * stubs ctx.llm — workforce#196. + * + * Provider split is deliberate: + * - anthropic: the OAuth completion route upserts a `provider_credentials` + * row, and cloud resolves it to a runtime env credential — stamp it. + * - openai (and anything else): ChatGPT/codex OAuth tokens are harness-only; + * cloud rejects them for runtime env, so stamping would turn the ctx.llm + * stub into a failed delivery. Print the actionable alternative instead. + */ +async function resolveOauthCredentialSelections(args: { + cloudUrl: string; + persona: PersonaSpec; + io: ModeLaunchInput['io']; +}): Promise> { + const provider = deriveModelProvider(args.persona); + if (provider !== 'anthropic') { + args.io.info( + `cloud: ${provider} subscriptions are harness-only and cannot back ctx.llm; ` + + 'use --harness-source byok with a platform API key, or connect an anthropic credential.' + ); + return {}; + } + const body = await fetchCloudAgents(args.cloudUrl); + const credentialId = body ? findConnectedHarnessCredentialId(body, provider) : null; + if (!credentialId) { + args.io.info( + `cloud: no connected ${provider} credential row found; deploying without a ctx.llm credential selection.` + ); + return {}; + } + args.io.info(`cloud: selected connected ${provider} credential for ctx.llm`); + return { [provider]: credentialId }; } async function resolveByokKey(args: { @@ -493,7 +541,10 @@ export async function ensureCloudSubscriptionReady(args: { } await ensureSubscriptionOauth(args); - return { provider }; + const credentialSelections = await resolveOauthCredentialSelections(args); + return Object.keys(credentialSelections).length > 0 + ? { provider, credentialSelections } + : { provider }; } function resolveSubscriptionHarnessSource(args: { @@ -869,26 +920,75 @@ function readCredentialId(body: Record): string { } /** - * Walk the `/api/v1/cloud-agents` response and decide whether any entry - * represents a usable, connected credential for the given harness/provider. + * Walk the `/api/v1/cloud-agents` response and return the credential row id + * of the first entry that represents a usable, connected credential for the + * given model provider, or `null` when none matches. * - * "Usable" means: the cloud_agents row exists, its `harness` field - * matches the persona's derived model provider (case-insensitive), and - * its `status` is `connected`. The S3-backed credential write happens - * before the row is marked connected, so this single check is enough — - * no second probe required. + * "Usable" means: the row exists, its `harness` field matches the persona's + * derived model provider (case-insensitive), and its `status` is + * `connected`. The S3-backed credential write happens before the row is + * marked connected, so this single check is enough — no second probe + * required. + * + * The response rows come straight from cloud's `provider_credentials` + * table, where `harness` may hold either the model-provider string + * ("anthropic") or the harness alias cloud's OAuth completion route stamps + * ("claude"), depending on which write path created the row — so match + * against both. Cloud lists rows most-recently-updated first, making the + * first match the active-credential heuristic the web wizard uses. */ -function hasConnectedHarness(body: CloudAgentsListResponse, expectedHarness: string): boolean { - if (!body || !Array.isArray(body.agents)) return false; - const target = expectedHarness.trim().toLowerCase(); - if (!target) return false; - return body.agents.some((value): boolean => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; +function connectedHarnessEntries( + body: CloudAgentsListResponse, + expectedProvider: string +): CloudAgentEntry[] { + if (!body || !Array.isArray(body.agents)) return []; + const provider = expectedProvider.trim().toLowerCase(); + if (!provider) return []; + const targets = new Set([provider, harnessAliasForModelProvider(provider)]); + const entries: CloudAgentEntry[] = []; + for (const value of body.agents) { + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; const entry = value as CloudAgentEntry; - if (typeof entry.harness !== 'string') return false; - if (entry.harness.trim().toLowerCase() !== target) return false; - return entry.status === 'connected'; - }); + if (typeof entry.harness !== 'string') continue; + if (!targets.has(entry.harness.trim().toLowerCase())) continue; + if (entry.status !== 'connected') continue; + entries.push(entry); + } + return entries; +} + +function hasConnectedHarness(body: CloudAgentsListResponse, expectedProvider: string): boolean { + return connectedHarnessEntries(body, expectedProvider).length > 0; +} + +function findConnectedHarnessCredentialId( + body: CloudAgentsListResponse, + expectedProvider: string +): string | null { + for (const entry of connectedHarnessEntries(body, expectedProvider)) { + if (typeof entry.id === 'string' && entry.id.trim()) return entry.id.trim(); + } + return null; +} + +/** + * Mirror of cloud's `harnessForModelProvider` (lib/billing/house-keys.ts): + * the harness name its OAuth completion route writes into + * `provider_credentials.harness` for each model provider. + */ +function harnessAliasForModelProvider(modelProvider: string): string { + switch (modelProvider) { + case 'anthropic': + return 'claude'; + case 'openai': + return 'codex'; + case 'google': + return 'gemini'; + case 'openrouter': + return 'opencode'; + default: + return modelProvider; + } } function expectHarnessSource(value: string): HarnessSource { From ca2e3921be91b42909cbd464a4c0802787fd7686 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Thu, 4 Jun 2026 16:25:20 +0200 Subject: [PATCH 2/4] fix(deploy): prefer the isActive credential row when stamping oauth selections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web wizard selects on is_active (cloud keeps one active row per provider via a partial unique index), not recency — and cloud's /api/v1/cloud-agents response orders by updatedAt desc, so a newer-but-inactive row precedes the active one. Prefer flagged rows, fall back to list order for older clouds / pre-backfill rows that don't carry the flag. Pins: active-but-older row wins over newer-inactive. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 140 ----------------------- packages/deploy/src/modes/cloud.test.ts | 51 +++++++++ packages/deploy/src/modes/cloud/index.ts | 15 ++- 3 files changed, 63 insertions(+), 143 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 1e3dbf9b..00000000 --- a/package-lock.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "name": "workforce", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "workforce", - "version": "0.1.0", - "license": "Apache-2.0", - "devDependencies": { - "@types/node": "^22.18.0", - "agent-trajectories": "^0.5.3", - "typescript": "^5.9.2" - } - }, - "node_modules/@clack/core": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.5.tgz", - "integrity": "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "picocolors": "^1.0.0", - "sisteransi": "^1.0.5" - } - }, - "node_modules/@clack/prompts": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.7.0.tgz", - "integrity": "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==", - "bundleDependencies": [ - "is-unicode-supported" - ], - "dev": true, - "license": "MIT", - "dependencies": { - "@clack/core": "^0.3.3", - "is-unicode-supported": "*", - "picocolors": "^1.0.0", - "sisteransi": "^1.0.5" - } - }, - "node_modules/@clack/prompts/node_modules/is-unicode-supported": { - "version": "1.3.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@types/node": { - "version": "22.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", - "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/agent-trajectories": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.5.6.tgz", - "integrity": "sha512-KVZTW1ThJZ5wYnf8Rah7oUfOD87GO/UBaWe/gxWX8kRRlRzPRChPMdS3hnKMtqx3VRbqo7IWXYtUl+WwTpSeGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@clack/prompts": "^0.7.0", - "commander": "^12.0.0", - "zod": "^3.23.0" - }, - "bin": { - "trail": "dist/cli/index.js" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index 66e04516..8f33a5dc 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -1184,3 +1184,54 @@ test('cloud oauth deploy falls back to unstamped when the connected row has no i restoreDeps(); } }); + +test('cloud oauth deploy stamps the ACTIVE anthropic row over a newer inactive one', async () => { + // The web wizard selects on is_active (cloud keeps one active row per + // provider), not recency — cloud lists rows most-recently-updated first, + // so a newer-but-inactive row precedes the active one in the response. + // The stamp must prefer the flagged row and only fall back to list order + // when no row carries the flag. + 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: [ + { id: 'pc-newer-inactive', harness: 'claude', status: 'connected', isActive: false }, + { id: 'pc-older-active', harness: 'claude', status: 'connected', isActive: true } + ] + }); + } + }; + } + }); + try { + await launch({ + persona: persona({ harness: 'claude', model: 'claude-sonnet-4-6' }), + defaultPlanCredential: 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-older-active' }); + return okJson({ agentId: 'agent-oauth-active', deploymentId: 'dep-1', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }); + } finally { + restoreDeps(); + } +}); diff --git a/packages/deploy/src/modes/cloud/index.ts b/packages/deploy/src/modes/cloud/index.ts index f2c3dc52..e7d7f86f 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -69,6 +69,7 @@ interface CloudAgentEntry { status?: unknown; credentialStoredAt?: unknown; id?: unknown; + isActive?: unknown; } interface ExistingAgentResponse { @@ -934,8 +935,7 @@ function readCredentialId(body: Record): string { * table, where `harness` may hold either the model-provider string * ("anthropic") or the harness alias cloud's OAuth completion route stamps * ("claude"), depending on which write path created the row — so match - * against both. Cloud lists rows most-recently-updated first, making the - * first match the active-credential heuristic the web wizard uses. + * against both. Cloud lists rows most-recently-updated first. */ function connectedHarnessEntries( body: CloudAgentsListResponse, @@ -965,7 +965,16 @@ function findConnectedHarnessCredentialId( body: CloudAgentsListResponse, expectedProvider: string ): string | null { - for (const entry of connectedHarnessEntries(body, expectedProvider)) { + const entries = connectedHarnessEntries(body, expectedProvider); + // The web wizard selects on is_active (one active row per provider — + // cloud's partial unique index), not recency, so prefer the flagged row. + // Older clouds / pre-backfill rows may not carry the flag; fall back to + // the list order (cloud returns most-recently-updated first). + const candidates = [ + ...entries.filter((entry) => entry.isActive === true), + ...entries.filter((entry) => entry.isActive !== true) + ]; + for (const entry of candidates) { if (typeof entry.id === 'string' && entry.id.trim()) return entry.id.trim(); } return null; From 61885a8446c09b662e3d18d184446f86dc5f6746 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Thu, 4 Jun 2026 16:28:09 +0200 Subject: [PATCH 3/4] feat(deploy): cross-stamp a connected anthropic credential for non-anthropic oauth personas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the persona's model family derives a provider whose OAuth credential cannot back ctx.llm (codex/ChatGPT tokens are harness-only), stamp a connected anthropic credential instead of deploying selection-less. This encodes at deploy time exactly what the runtime does at run time: cloud-llm selectCredential prefers the persona family but falls back to whichever provider env is present, and resolveModel swaps in that family's default model. One extra /cloud-agents GET per oauth deploy (poll-count pin updated). Pins: openai-family persona + connected anthropic row → {anthropic: id} + adapted message; only-openai row still deploys unstamped with guidance. 174/174. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/deploy/src/modes/cloud.test.ts | 60 +++++++++++++++++++++++- packages/deploy/src/modes/cloud/index.ts | 23 +++++++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index 8f33a5dc..e63d15af 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -587,7 +587,9 @@ test('cloud harness OAuth starts auth and polls /cloud-agents until the harness await cleanup(); } - assert.equal(credentialChecks, 3); + // 3 connection polls + 1 post-connect selections lookup (the oauth leg + // re-reads /cloud-agents to stamp credentialSelections — workforce#196). + assert.equal(credentialChecks, 4); assert.deepEqual(connected, ['openai']); }); @@ -1235,3 +1237,59 @@ test('cloud oauth deploy stamps the ACTIVE anthropic row over a newer inactive o restoreDeps(); } }); + +test('cloud oauth deploy cross-stamps a connected anthropic credential for an openai-family persona', async () => { + // The codex/ChatGPT OAuth credential cannot back ctx.llm, but the runtime + // already falls back to whichever provider env IS present and swaps in + // that family's default model — so the deploy stamps the connected + // anthropic credential instead of leaving the deployment selection-less. + 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: [ + { id: 'pc-openai-oauth', harness: 'openai', status: 'connected' }, + { id: 'pc-anthropic-1', harness: 'claude', status: 'connected', isActive: true } + ] + }); + } + }; + } + }); + try { + const { io } = await launch({ + defaultPlanCredential: 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-1' }); + assert.deepEqual(body.credential_selections, { anthropic: 'pc-anthropic-1' }); + return okJson({ agentId: 'agent-cross-stamp', deploymentId: 'dep-1', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }); + assert.ok( + io.messages.some((entry) => + entry.message.includes('stamping your connected anthropic credential instead') + ), + 'expected the cross-stamp info line' + ); + } finally { + restoreDeps(); + } +}); diff --git a/packages/deploy/src/modes/cloud/index.ts b/packages/deploy/src/modes/cloud/index.ts index e7d7f86f..b047fa81 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -414,8 +414,10 @@ async function fetchCloudAgents(cloudUrl: string): Promise> { const provider = deriveModelProvider(args.persona); + const body = await fetchCloudAgents(args.cloudUrl); if (provider !== 'anthropic') { + // Cross-provider fallback: the runtime's credential pick already + // prefers the persona's model family but falls back to whatever + // provider env IS present, swapping in that family's default model + // (cloud-llm selectCredential/resolveModel). So when the persona + // 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; + if (anthropicId) { + args.io.info( + `cloud: ${provider} subscriptions are harness-only and cannot back ctx.llm; ` + + 'stamping your connected anthropic credential instead (the runtime adapts the model family).' + ); + return { anthropic: anthropicId }; + } args.io.info( `cloud: ${provider} subscriptions are harness-only and cannot back ctx.llm; ` + 'use --harness-source byok with a platform API key, or connect an anthropic credential.' ); return {}; } - const body = await fetchCloudAgents(args.cloudUrl); const credentialId = body ? findConnectedHarnessCredentialId(body, provider) : null; if (!credentialId) { args.io.info( From c1181a6d342faae2d480a3eeb47fa5bea6b78abd Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Thu, 4 Jun 2026 14:34:47 +0000 Subject: [PATCH 4/4] chore: apply pr-reviewer fixes for #197 --- packages/deploy/src/integrations-list.test.ts | 1 + packages/deploy/src/modes/cloud.test.ts | 2 +- packages/deploy/src/modes/cloud/index.ts | 9 +++++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/deploy/src/integrations-list.test.ts b/packages/deploy/src/integrations-list.test.ts index 3b521ace..8e9d39c7 100644 --- a/packages/deploy/src/integrations-list.test.ts +++ b/packages/deploy/src/integrations-list.test.ts @@ -88,6 +88,7 @@ test('listIntegrations merges cloud catalog, trigger catalog aliases, and connec test('listIntegrations returns partial trigger-catalog document when unauthenticated and cloud catalog is unavailable', async () => { const document = await listIntegrations({ activeWorkspace: null, + env: {}, async resolveWorkspaceToken() { throw new Error('missing login'); }, diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index e63d15af..ffe0bb6f 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -1207,7 +1207,7 @@ test('cloud oauth deploy stamps the ACTIVE anthropic row over a newer inactive o return okJson({ agents: [ { id: 'pc-newer-inactive', harness: 'claude', status: 'connected', isActive: false }, - { id: 'pc-older-active', harness: 'claude', status: 'connected', isActive: true } + { id: 'pc-older-active', harness: 'claude', status: 'connected', is_active: true } ] }); } diff --git a/packages/deploy/src/modes/cloud/index.ts b/packages/deploy/src/modes/cloud/index.ts index b047fa81..e95accd6 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -70,6 +70,7 @@ interface CloudAgentEntry { credentialStoredAt?: unknown; id?: unknown; isActive?: unknown; + is_active?: unknown; } interface ExistingAgentResponse { @@ -988,8 +989,8 @@ function findConnectedHarnessCredentialId( // Older clouds / pre-backfill rows may not carry the flag; fall back to // the list order (cloud returns most-recently-updated first). const candidates = [ - ...entries.filter((entry) => entry.isActive === true), - ...entries.filter((entry) => entry.isActive !== true) + ...entries.filter(isActiveCloudAgentEntry), + ...entries.filter((entry) => !isActiveCloudAgentEntry(entry)) ]; for (const entry of candidates) { if (typeof entry.id === 'string' && entry.id.trim()) return entry.id.trim(); @@ -997,6 +998,10 @@ function findConnectedHarnessCredentialId( return null; } +function isActiveCloudAgentEntry(entry: CloudAgentEntry): boolean { + return entry.isActive === true || entry.is_active === true; +} + /** * Mirror of cloud's `harnessForModelProvider` (lib/billing/house-keys.ts): * the harness name its OAuth completion route writes into