From fa4ce25a7d2a286b2c2acc71fddbc110b1a70446 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 20:54:44 +0200 Subject: [PATCH] fix(deploy): point harness probe at /api/v1/cloud-agents (M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness-credentials check called `GET /api/v1/users/me/provider_credentials?model_provider=`, which doesn't exist on cloud at all — the route was never built. Every `agentworkforce deploy --harness-source oauth --no-prompt` therefore failed with "credentials are not connected" even when they were, and the auto-detect path (no --harness-source) always fell through to the interactive prompt for the same reason. Cloud actually exposes harness connection state via `/api/v1/cloud-agents`, which returns one row per (user, workspace, harness). When the OAuth completion route (`/api/v1/cli/auth/complete`) stores a credential in S3 it marks that row `status: "connected"`. That's the single source of truth — no second probe needed. Changes: * Replace `ProviderCredentialsResponse`+`providerCredentialsReady` with `CloudAgentsListResponse`+`hasConnectedHarness`. * Switch `isHarnessOauthConnected` to call `/api/v1/cloud-agents` and match by harness (case-insensitive) + `status === 'connected'`. * Rewrite the two existing harness tests for the new endpoint, plus add two new cases: (1) a matching connected entry → probe returns true and no connect-provider call fires; (2) entries with wrong harness or wrong status are correctly ignored. Plan/byok save paths still call cloud routes that don't exist either — those need cloud-side implementation, deferred to a separate PR. Users who already authed via `agent-relay cloud connect ` (the default flow today) are unblocked immediately by this change. Smoke verified against `agentrelay.com/cloud` with the user's actual Anthropic-connected workspace: cloud: claude credentials already connected (deploy proceeds to bundle/upload; the next failure is a separate 403 on `/workspaces/{ws}/agents` — cloud auth scope bug, not this PR.) Co-Authored-By: Claude Opus 4.7 --- packages/deploy/src/modes/cloud.test.ts | 123 ++++++++++++++++++++++-- packages/deploy/src/modes/cloud.ts | 85 ++++++++++------ 2 files changed, 170 insertions(+), 38 deletions(-) diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index c4cc44d..1f3512e 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -289,7 +289,12 @@ test('cloud BYOK provider detection avoids substring false positives', async () }); }); -test('cloud harness OAuth uses provider_credentials readiness and honors no-prompt failure', async () => { +test('cloud harness OAuth probe hits /api/v1/cloud-agents and honors no-prompt failure', async () => { + // Cloud surfaces "is the harness connected?" via the cloud-agents list, + // not the (never-built) /users/me/provider_credentials route. When the + // list is empty for the persona's provider, --no-prompt must surface a + // clear actionable error rather than reaching the prompt path. + let probeCalls = 0; const restoreDeps = configureCloudCredentialDepsForTest({ readStoredAuth: async () => ({ apiUrl: 'https://cloud.example.test', @@ -300,9 +305,10 @@ test('cloud harness OAuth uses provider_credentials readiness and honors no-prom createCloudApiClient() { return { async fetch(pathname: string, init?: RequestInit) { - assert.equal(pathname, '/api/v1/users/me/provider_credentials?model_provider=openai'); + probeCalls += 1; + assert.equal(pathname, '/api/v1/cloud-agents'); assert.equal(init?.method, 'GET'); - return okJson({}); + return okJson({ agents: [] }); } }; } @@ -321,9 +327,110 @@ test('cloud harness OAuth uses provider_credentials readiness and honors no-prom }), /OAuth credentials are not connected/ ).finally(restoreDeps); + assert.ok(probeCalls >= 1); }); -test('cloud harness OAuth starts auth and polls until provider credentials are connected', async () => { +test('cloud harness OAuth probe treats a matching connected entry as ready (skips prompt)', async () => { + // Regression for the user-facing M3 bug: an Anthropic-connected user + // hit "credentials are not connected" because the probe pointed at a + // phantom route. With the probe fixed and a connected entry present, + // the harness check resolves silently and the deploy proceeds. + 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: 'cloud-agent-1', + harness: 'openai', // matches persona's derived provider + status: 'connected', + credentialStoredAt: '2026-05-13T12:00:00.000Z' + } + ] + }); + } + }; + } + }); + + const { calls, handle } = await launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_NO_PROMPT: '1' + }, + input: { harnessSource: 'oauth' }, + fetch(url) { + if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + return okJson( + { agentId: 'agent-oauth-connected', deploymentId: 'dep-1', status: 'active' }, + 201 + ); + } + throw new Error(`unexpected URL ${url}`); + } + }).finally(restoreDeps); + + assert.equal(handle.id, 'agent-oauth-connected'); + // No connect-provider call should have fired because the probe already + // returned a connected entry. + assert.ok(!calls.some((c) => c.url.includes('/cli/auth'))); +}); + +test('cloud harness OAuth probe ignores entries with the wrong harness', async () => { + // If the user has openai connected but the persona's provider is + // anthropic, the probe must NOT treat that as readiness — otherwise + // the deploy would proceed with cloud expecting an anthropic key it + // never received. + 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: [ + { harness: 'openai', status: 'connected' }, + { harness: 'anthropic', status: 'pending' }, // wrong status + { harness: 'google', status: 'connected' } // wrong harness + ] + }); + } + }; + } + }); + + // Override the persona to claude/anthropic so the expected provider mismatches. + await assert.rejects( + launch({ + defaultPlanCredential: false, + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_NO_PROMPT: '1' + }, + input: { harnessSource: 'oauth' }, + persona: persona({ harness: 'claude', model: 'claude-sonnet-4-6' }), + fetch(url) { + throw new Error(`unexpected URL ${url}`); + } + }), + /OAuth credentials are not connected/ + ).finally(restoreDeps); +}); + +test('cloud harness OAuth starts auth and polls /cloud-agents until the harness is connected', async () => { let credentialChecks = 0; const connected: string[] = []; const restoreDeps = configureCloudCredentialDepsForTest({ @@ -340,10 +447,14 @@ test('cloud harness OAuth starts auth and polls until provider credentials are c createCloudApiClient() { return { async fetch(pathname: string, init?: RequestInit) { - if (pathname.endsWith('/provider_credentials?model_provider=openai')) { + if (pathname === '/api/v1/cloud-agents') { credentialChecks += 1; assert.equal(init?.method, 'GET'); - return okJson(credentialChecks < 3 ? {} : { id: 'cred-oauth', status: 'connected' }); + // First two polls: harness not yet connected (empty list). + // Third poll: openai entry appears with status connected. + return okJson(credentialChecks < 3 + ? { agents: [] } + : { agents: [{ id: 'cloud-agent-openai', harness: 'openai', status: 'connected' }] }); } throw new Error(`unexpected path ${pathname}`); } diff --git a/packages/deploy/src/modes/cloud.ts b/packages/deploy/src/modes/cloud.ts index 13f5dad..6a84c1b 100644 --- a/packages/deploy/src/modes/cloud.ts +++ b/packages/deploy/src/modes/cloud.ts @@ -47,17 +47,23 @@ interface CloudAgentStatusResponse { status?: unknown; } -interface ProviderCredentialsResponse { - credentials?: unknown; - providerCredentials?: unknown; - credential?: unknown; - id?: unknown; - authType?: unknown; - auth_type?: unknown; +/** + * Shape of `GET /api/v1/cloud-agents` on the cloud side. + * + * Each entry represents one (user, workspace, harness) row in the + * `cloud_agents` table; `status === 'connected'` means the harness OAuth + * completion stored a usable credential in S3 (see cloud's + * `cli/auth/complete` route handler). + */ +interface CloudAgentsListResponse { + agents?: unknown; +} + +interface CloudAgentEntry { + harness?: unknown; status?: unknown; - connected?: unknown; credentialStoredAt?: unknown; - createdAt?: unknown; + id?: unknown; } interface ExistingAgentResponse { @@ -333,6 +339,20 @@ async function resolveHarnessSource(args: { return expectHarnessSource(answer); } +/** + * Check whether the user already has a connected harness credential in + * cloud for this persona's model provider. + * + * 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 + * OAuth completion route successfully stored a credential. + * + * We previously called `/api/v1/users/me/provider_credentials?model_provider=...`, + * which doesn't exist on cloud at all (the route was never built). That + * 404 made every deploy with `--no-prompt` fail with "credentials are + * not connected" even when they were — see workforce#118 follow-up. + */ async function isHarnessOauthConnected(args: { cloudUrl: string; persona: PersonaSpec; @@ -340,22 +360,19 @@ async function isHarnessOauthConnected(args: { const auth = await readUsableCloudAuth(args.cloudUrl); if (!auth) return false; const client = cloudCredentialDeps.createCloudApiClient(auth, args.cloudUrl); - const path = `/api/v1/users/me/provider_credentials?model_provider=${encodeURIComponent( - deriveModelProvider(args.persona) - )}`; - const res = await client.fetch(path, { + 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 === 401) { - throw new Error('cloud harness check failed: unauthorized. Run `workforce login` and retry.'); + 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 ProviderCredentialsResponse; - return providerCredentialsReady(body); + const body = (await res.json()) as CloudAgentsListResponse; + return hasConnectedHarness(body, deriveModelProvider(args.persona)); } async function resolveByokKey(args: { @@ -626,22 +643,26 @@ function readCredentialId(body: Record): string { throw new Error('cloud provider credentials response missing credential id'); } -function providerCredentialsReady(body: ProviderCredentialsResponse): boolean { - const candidates = [ - body.credential, - ...(Array.isArray(body.credentials) ? body.credentials : []), - ...(Array.isArray(body.providerCredentials) ? body.providerCredentials : []), - body - ]; - return candidates.some((candidate) => { - if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return false; - const record = candidate as Record; - return record.connected === true - || record.status === 'connected' - || record.status === 'active' - || Boolean(record.credentialStoredAt) - || Boolean(record.createdAt) - || typeof record.id === 'string'; +/** + * Walk the `/api/v1/cloud-agents` response and decide whether any entry + * represents a usable, connected credential for the given harness/provider. + * + * "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. + */ +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; + const entry = value as CloudAgentEntry; + if (typeof entry.harness !== 'string') return false; + if (entry.harness.trim().toLowerCase() !== target) return false; + return entry.status === 'connected'; }); }