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
123 changes: 117 additions & 6 deletions packages/deploy/src/modes/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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: [] });
}
};
}
Expand All @@ -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({
Expand All @@ -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}`);
}
Expand Down
85 changes: 53 additions & 32 deletions packages/deploy/src/modes/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -333,29 +339,40 @@ 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;
}): Promise<boolean> {
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: {
Expand Down Expand Up @@ -626,22 +643,26 @@ function readCredentialId(body: Record<string, unknown>): 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<string, unknown>;
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';
});
}

Expand Down
Loading