Skip to content

Commit 6e44ff4

Browse files
khaliqgantRicky Schema Cascadeclaude
authored
fix(deploy): point harness probe at /api/v1/cloud-agents (M3) (#119)
The harness-credentials check called `GET /api/v1/users/me/provider_credentials?model_provider=<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 <provider>` (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: Ricky Schema Cascade <ricky@agent-relay.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a88cc80 commit 6e44ff4

2 files changed

Lines changed: 170 additions & 38 deletions

File tree

packages/deploy/src/modes/cloud.test.ts

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,12 @@ test('cloud BYOK provider detection avoids substring false positives', async ()
289289
});
290290
});
291291

292-
test('cloud harness OAuth uses provider_credentials readiness and honors no-prompt failure', async () => {
292+
test('cloud harness OAuth probe hits /api/v1/cloud-agents and honors no-prompt failure', async () => {
293+
// Cloud surfaces "is the harness connected?" via the cloud-agents list,
294+
// not the (never-built) /users/me/provider_credentials route. When the
295+
// list is empty for the persona's provider, --no-prompt must surface a
296+
// clear actionable error rather than reaching the prompt path.
297+
let probeCalls = 0;
293298
const restoreDeps = configureCloudCredentialDepsForTest({
294299
readStoredAuth: async () => ({
295300
apiUrl: 'https://cloud.example.test',
@@ -300,9 +305,10 @@ test('cloud harness OAuth uses provider_credentials readiness and honors no-prom
300305
createCloudApiClient() {
301306
return {
302307
async fetch(pathname: string, init?: RequestInit) {
303-
assert.equal(pathname, '/api/v1/users/me/provider_credentials?model_provider=openai');
308+
probeCalls += 1;
309+
assert.equal(pathname, '/api/v1/cloud-agents');
304310
assert.equal(init?.method, 'GET');
305-
return okJson({});
311+
return okJson({ agents: [] });
306312
}
307313
};
308314
}
@@ -321,9 +327,110 @@ test('cloud harness OAuth uses provider_credentials readiness and honors no-prom
321327
}),
322328
/OAuth credentials are not connected/
323329
).finally(restoreDeps);
330+
assert.ok(probeCalls >= 1);
324331
});
325332

326-
test('cloud harness OAuth starts auth and polls until provider credentials are connected', async () => {
333+
test('cloud harness OAuth probe treats a matching connected entry as ready (skips prompt)', async () => {
334+
// Regression for the user-facing M3 bug: an Anthropic-connected user
335+
// hit "credentials are not connected" because the probe pointed at a
336+
// phantom route. With the probe fixed and a connected entry present,
337+
// the harness check resolves silently and the deploy proceeds.
338+
const restoreDeps = configureCloudCredentialDepsForTest({
339+
readStoredAuth: async () => ({
340+
apiUrl: 'https://cloud.example.test',
341+
accessToken: 'access',
342+
refreshToken: 'refresh',
343+
accessTokenExpiresAt: '2999-01-01T00:00:00.000Z'
344+
}),
345+
createCloudApiClient() {
346+
return {
347+
async fetch(pathname: string) {
348+
assert.equal(pathname, '/api/v1/cloud-agents');
349+
return okJson({
350+
agents: [
351+
{
352+
id: 'cloud-agent-1',
353+
harness: 'openai', // matches persona's derived provider
354+
status: 'connected',
355+
credentialStoredAt: '2026-05-13T12:00:00.000Z'
356+
}
357+
]
358+
});
359+
}
360+
};
361+
}
362+
});
363+
364+
const { calls, handle } = await launch({
365+
env: {
366+
WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test',
367+
WORKFORCE_DEPLOY_NO_PROMPT: '1'
368+
},
369+
input: { harnessSource: 'oauth' },
370+
fetch(url) {
371+
if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] });
372+
if (url.endsWith('/deployments')) {
373+
return okJson(
374+
{ agentId: 'agent-oauth-connected', deploymentId: 'dep-1', status: 'active' },
375+
201
376+
);
377+
}
378+
throw new Error(`unexpected URL ${url}`);
379+
}
380+
}).finally(restoreDeps);
381+
382+
assert.equal(handle.id, 'agent-oauth-connected');
383+
// No connect-provider call should have fired because the probe already
384+
// returned a connected entry.
385+
assert.ok(!calls.some((c) => c.url.includes('/cli/auth')));
386+
});
387+
388+
test('cloud harness OAuth probe ignores entries with the wrong harness', async () => {
389+
// If the user has openai connected but the persona's provider is
390+
// anthropic, the probe must NOT treat that as readiness — otherwise
391+
// the deploy would proceed with cloud expecting an anthropic key it
392+
// never received.
393+
const restoreDeps = configureCloudCredentialDepsForTest({
394+
readStoredAuth: async () => ({
395+
apiUrl: 'https://cloud.example.test',
396+
accessToken: 'access',
397+
refreshToken: 'refresh',
398+
accessTokenExpiresAt: '2999-01-01T00:00:00.000Z'
399+
}),
400+
createCloudApiClient() {
401+
return {
402+
async fetch() {
403+
return okJson({
404+
agents: [
405+
{ harness: 'openai', status: 'connected' },
406+
{ harness: 'anthropic', status: 'pending' }, // wrong status
407+
{ harness: 'google', status: 'connected' } // wrong harness
408+
]
409+
});
410+
}
411+
};
412+
}
413+
});
414+
415+
// Override the persona to claude/anthropic so the expected provider mismatches.
416+
await assert.rejects(
417+
launch({
418+
defaultPlanCredential: false,
419+
env: {
420+
WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test',
421+
WORKFORCE_DEPLOY_NO_PROMPT: '1'
422+
},
423+
input: { harnessSource: 'oauth' },
424+
persona: persona({ harness: 'claude', model: 'claude-sonnet-4-6' }),
425+
fetch(url) {
426+
throw new Error(`unexpected URL ${url}`);
427+
}
428+
}),
429+
/OAuth credentials are not connected/
430+
).finally(restoreDeps);
431+
});
432+
433+
test('cloud harness OAuth starts auth and polls /cloud-agents until the harness is connected', async () => {
327434
let credentialChecks = 0;
328435
const connected: string[] = [];
329436
const restoreDeps = configureCloudCredentialDepsForTest({
@@ -340,10 +447,14 @@ test('cloud harness OAuth starts auth and polls until provider credentials are c
340447
createCloudApiClient() {
341448
return {
342449
async fetch(pathname: string, init?: RequestInit) {
343-
if (pathname.endsWith('/provider_credentials?model_provider=openai')) {
450+
if (pathname === '/api/v1/cloud-agents') {
344451
credentialChecks += 1;
345452
assert.equal(init?.method, 'GET');
346-
return okJson(credentialChecks < 3 ? {} : { id: 'cred-oauth', status: 'connected' });
453+
// First two polls: harness not yet connected (empty list).
454+
// Third poll: openai entry appears with status connected.
455+
return okJson(credentialChecks < 3
456+
? { agents: [] }
457+
: { agents: [{ id: 'cloud-agent-openai', harness: 'openai', status: 'connected' }] });
347458
}
348459
throw new Error(`unexpected path ${pathname}`);
349460
}

packages/deploy/src/modes/cloud.ts

Lines changed: 53 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,23 @@ interface CloudAgentStatusResponse {
4747
status?: unknown;
4848
}
4949

50-
interface ProviderCredentialsResponse {
51-
credentials?: unknown;
52-
providerCredentials?: unknown;
53-
credential?: unknown;
54-
id?: unknown;
55-
authType?: unknown;
56-
auth_type?: unknown;
50+
/**
51+
* Shape of `GET /api/v1/cloud-agents` on the cloud side.
52+
*
53+
* Each entry represents one (user, workspace, harness) row in the
54+
* `cloud_agents` table; `status === 'connected'` means the harness OAuth
55+
* completion stored a usable credential in S3 (see cloud's
56+
* `cli/auth/complete` route handler).
57+
*/
58+
interface CloudAgentsListResponse {
59+
agents?: unknown;
60+
}
61+
62+
interface CloudAgentEntry {
63+
harness?: unknown;
5764
status?: unknown;
58-
connected?: unknown;
5965
credentialStoredAt?: unknown;
60-
createdAt?: unknown;
66+
id?: unknown;
6167
}
6268

6369
interface ExistingAgentResponse {
@@ -333,29 +339,40 @@ async function resolveHarnessSource(args: {
333339
return expectHarnessSource(answer);
334340
}
335341

342+
/**
343+
* Check whether the user already has a connected harness credential in
344+
* cloud for this persona's model provider.
345+
*
346+
* Cloud surfaces this via `GET /api/v1/cloud-agents`, which returns one
347+
* row per (user, workspace, harness) — `harness` is the provider key
348+
* ("anthropic", "openai", …) and `status === 'connected'` means the
349+
* OAuth completion route successfully stored a credential.
350+
*
351+
* We previously called `/api/v1/users/me/provider_credentials?model_provider=...`,
352+
* which doesn't exist on cloud at all (the route was never built). That
353+
* 404 made every deploy with `--no-prompt` fail with "credentials are
354+
* not connected" even when they were — see workforce#118 follow-up.
355+
*/
336356
async function isHarnessOauthConnected(args: {
337357
cloudUrl: string;
338358
persona: PersonaSpec;
339359
}): Promise<boolean> {
340360
const auth = await readUsableCloudAuth(args.cloudUrl);
341361
if (!auth) return false;
342362
const client = cloudCredentialDeps.createCloudApiClient(auth, args.cloudUrl);
343-
const path = `/api/v1/users/me/provider_credentials?model_provider=${encodeURIComponent(
344-
deriveModelProvider(args.persona)
345-
)}`;
346-
const res = await client.fetch(path, {
363+
const res = await client.fetch('/api/v1/cloud-agents', {
347364
method: 'GET',
348365
headers: { 'user-agent': USER_AGENT }
349366
});
350367
if (res.status === 404 || res.status === 405) return false;
351368
if (res.status === 401) {
352-
throw new Error('cloud harness check failed: unauthorized. Run `workforce login` and retry.');
369+
throw new Error('cloud harness check failed: unauthorized. Run `agentworkforce login` and retry.');
353370
}
354371
if (!res.ok) {
355372
throw new Error(`cloud harness check failed: ${res.status} ${await responseExcerpt(res)}`);
356373
}
357-
const body = (await res.json()) as ProviderCredentialsResponse;
358-
return providerCredentialsReady(body);
374+
const body = (await res.json()) as CloudAgentsListResponse;
375+
return hasConnectedHarness(body, deriveModelProvider(args.persona));
359376
}
360377

361378
async function resolveByokKey(args: {
@@ -626,22 +643,26 @@ function readCredentialId(body: Record<string, unknown>): string {
626643
throw new Error('cloud provider credentials response missing credential id');
627644
}
628645

629-
function providerCredentialsReady(body: ProviderCredentialsResponse): boolean {
630-
const candidates = [
631-
body.credential,
632-
...(Array.isArray(body.credentials) ? body.credentials : []),
633-
...(Array.isArray(body.providerCredentials) ? body.providerCredentials : []),
634-
body
635-
];
636-
return candidates.some((candidate) => {
637-
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return false;
638-
const record = candidate as Record<string, unknown>;
639-
return record.connected === true
640-
|| record.status === 'connected'
641-
|| record.status === 'active'
642-
|| Boolean(record.credentialStoredAt)
643-
|| Boolean(record.createdAt)
644-
|| typeof record.id === 'string';
646+
/**
647+
* Walk the `/api/v1/cloud-agents` response and decide whether any entry
648+
* represents a usable, connected credential for the given harness/provider.
649+
*
650+
* "Usable" means: the cloud_agents row exists, its `harness` field
651+
* matches the persona's derived model provider (case-insensitive), and
652+
* its `status` is `connected`. The S3-backed credential write happens
653+
* before the row is marked connected, so this single check is enough —
654+
* no second probe required.
655+
*/
656+
function hasConnectedHarness(body: CloudAgentsListResponse, expectedHarness: string): boolean {
657+
if (!body || !Array.isArray(body.agents)) return false;
658+
const target = expectedHarness.trim().toLowerCase();
659+
if (!target) return false;
660+
return body.agents.some((value): boolean => {
661+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
662+
const entry = value as CloudAgentEntry;
663+
if (typeof entry.harness !== 'string') return false;
664+
if (entry.harness.trim().toLowerCase() !== target) return false;
665+
return entry.status === 'connected';
645666
});
646667
}
647668

0 commit comments

Comments
 (0)