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/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 49b4cb9d..ffe0bb6f 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']); }); @@ -1021,3 +1023,273 @@ 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(); + } +}); + +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', is_active: 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(); + } +}); + +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 0e63b4cb..e95accd6 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -69,6 +69,8 @@ interface CloudAgentEntry { status?: unknown; credentialStoredAt?: unknown; id?: unknown; + isActive?: unknown; + is_active?: unknown; } interface ExistingAgentResponse { @@ -324,7 +326,7 @@ async function ensureHarnessReady(args: { } await ensureHarnessOauth(args); - return {}; + return resolveOauthCredentialSelections(args); } async function resolveHarnessSource(args: { @@ -375,22 +377,87 @@ 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 the persona-family + * credential would turn the ctx.llm stub into a failed delivery. Stamp a + * connected anthropic credential instead when one exists (the runtime + * adapts the model family), else print the actionable alternative. + */ +async function resolveOauthCredentialSelections(args: { + cloudUrl: string; + persona: PersonaSpec; + io: ModeLaunchInput['io']; +}): 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 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 +560,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 +939,87 @@ 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 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 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. + * 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. */ -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 { + 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(isActiveCloudAgentEntry), + ...entries.filter((entry) => !isActiveCloudAgentEntry(entry)) + ]; + for (const entry of candidates) { + if (typeof entry.id === 'string' && entry.id.trim()) return entry.id.trim(); + } + 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 + * `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 {