Skip to content

Commit e9c353f

Browse files
committed
fix(cli): classify cloud failures by HTTP status
1 parent 5a836c0 commit e9c353f

4 files changed

Lines changed: 130 additions & 4 deletions

File tree

‎src/__tests__/cloud-connect-profile.test.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from '@agent-device/remote/remote-connection-state';
1414
import type { AgentDeviceClient } from '@agent-device/command-surface';
1515
import { resolveConnectProviderProfile } from '../cli/connection/connect-provider-adapters.ts';
16-
import { AppError } from '@agent-device/kernel/errors';
16+
import { AppError, normalizeError } from '@agent-device/kernel/errors';
1717
import { verifyLimrunConnection } from '@agent-device/provider-limrun';
1818
import {
1919
verifyAwsDeviceFarmConnection,
@@ -398,6 +398,34 @@ test('connect without remote config reports cloud profile authorization failures
398398
}
399399
});
400400

401+
for (const status of [429, 503]) {
402+
test(`connect without remote config propagates cloud profile ${status} failures`, async () => {
403+
const tempRoot = mkdtempForTestSync(`agent-device-connect-cloud-${status}-`);
404+
const stateDir = path.join(tempRoot, '.state');
405+
mockedResolveCloudAccessForConnect.mockResolvedValue({
406+
accessToken: 'adc_agent_cloud',
407+
cloudBaseUrl: 'https://cloud.example',
408+
});
409+
vi.stubGlobal(
410+
'fetch',
411+
vi.fn(async () => new Response('{"error":"secret"}', { status })),
412+
);
413+
try {
414+
await assert.rejects(connectWithGeneratedCloudProfile(stateDir), (error: unknown) => {
415+
const normalized = normalizeError(error);
416+
assert.equal(normalized.code, 'COMMAND_FAILED');
417+
assert.equal(normalized.details?.status, status);
418+
assert.equal(normalized.retriable, true);
419+
return true;
420+
});
421+
assert.equal(readActiveConnectionState({ stateDir }), null);
422+
assert.equal(fs.existsSync(path.join(stateDir, 'remote-connections')), false);
423+
} finally {
424+
fs.rmSync(tempRoot, { recursive: true, force: true });
425+
}
426+
});
427+
}
428+
401429
test('connect without remote config reports unsupported cloud profile keys', async () => {
402430
const tempRoot = mkdtempForTestSync('agent-device-connect-cloud-invalid-');
403431
const stateDir = path.join(tempRoot, '.state');

‎src/cli/__tests__/auth-session.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,54 @@ test('remote auth refreshes a stored CLI session into an agent token', async ()
169169
fs.rmSync(tempRoot, { recursive: true, force: true });
170170
});
171171

172+
test('remote auth does not fall back to login when session refresh gets 503', async () => {
173+
const tempRoot = mkdtempForTestSync('agent-device-auth-refresh-503-');
174+
const session = {
175+
version: 1 as const,
176+
id: 'session-503',
177+
cloudBaseUrl: 'https://cloud.example',
178+
refreshCredential: 'adc_refresh_503',
179+
createdAt: '2026-01-01T00:00:00.000Z',
180+
};
181+
writeCliSession({ stateDir: tempRoot, session });
182+
let fetchCalls = 0;
183+
let browserOpened = false;
184+
try {
185+
await assert.rejects(
186+
resolveRemoteAuth({
187+
command: 'connect',
188+
flags: { ...baseFlags, daemonBaseUrl: 'https://bridge.agent-device.dev' },
189+
stateDir: tempRoot,
190+
allowInteractiveLogin: true,
191+
env: {},
192+
io: {
193+
stdinIsTTY: true,
194+
stdoutIsTTY: true,
195+
openBrowser: async () => {
196+
browserOpened = true;
197+
},
198+
fetch: async () => {
199+
fetchCalls += 1;
200+
return new Response('{"error":"secret"}', { status: 503 });
201+
},
202+
},
203+
}),
204+
(error: unknown) => {
205+
const normalized = normalizeError(error);
206+
assert.equal(normalized.code, 'COMMAND_FAILED');
207+
assert.equal(normalized.details?.status, 503);
208+
assert.equal(normalized.retriable, true);
209+
return true;
210+
},
211+
);
212+
assert.equal(fetchCalls, 1);
213+
assert.equal(browserOpened, false);
214+
assert.equal(readCliSession({ stateDir: tempRoot })?.id, session.id);
215+
} finally {
216+
fs.rmSync(tempRoot, { recursive: true, force: true });
217+
}
218+
});
219+
172220
test('remote auth fails immediately when stored CLI session is revoked', async () => {
173221
const tempRoot = mkdtempForTestSync('agent-device-auth-revoked-');
174222
writeCliSession({
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { normalizeError } from '@agent-device/kernel/errors';
4+
import { readCloudJsonResponse } from '../cloud-response.ts';
5+
6+
const options = { invalidJsonMessage: 'Invalid JSON', rejectedMessage: 'Cloud request rejected' };
7+
8+
for (const status of [401, 403]) {
9+
test(`cloud response ${status} remains an authentication error`, async () => {
10+
await assert.rejects(
11+
readCloudJsonResponse(new Response('{"token":"secret"}', { status }), options),
12+
(error: unknown) => {
13+
const normalized = normalizeError(error);
14+
assert.equal(normalized.code, 'UNAUTHORIZED');
15+
assert.equal(normalized.details?.status, status);
16+
assert.doesNotMatch(JSON.stringify(normalized), /secret/);
17+
return true;
18+
},
19+
);
20+
});
21+
}
22+
23+
for (const status of [404, 429, 503]) {
24+
test(`cloud response ${status} preserves status without requesting login`, async () => {
25+
await assert.rejects(
26+
readCloudJsonResponse(new Response('{"accessToken":"secret"}', { status }), options),
27+
(error: unknown) => {
28+
const normalized = normalizeError(error);
29+
assert.equal(normalized.code, 'COMMAND_FAILED');
30+
assert.equal(normalized.details?.status, status);
31+
assert.equal(normalized.retriable, status === 429 || status >= 500 ? true : undefined);
32+
assert.doesNotMatch(JSON.stringify(normalized), /secret|accessToken/);
33+
return true;
34+
},
35+
);
36+
});
37+
}
38+
39+
test('cloud response keeps successful JSON and invalid JSON behavior', async () => {
40+
assert.deepEqual(await readCloudJsonResponse(new Response('{"ok":true}'), options), { ok: true });
41+
await assert.rejects(readCloudJsonResponse(new Response('{'), options), (error: unknown) => {
42+
assert.equal(normalizeError(error).code, 'COMMAND_FAILED');
43+
assert.equal(normalizeError(error).details?.status, 200);
44+
return true;
45+
});
46+
});

‎src/cli/cloud-response.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,13 @@ export async function readCloudJsonResponse<T>(
2222
}
2323
}
2424
if (!response.ok) {
25-
throw new AppError('UNAUTHORIZED', options.rejectedMessage, {
26-
status: response.status,
27-
response: parsed,
25+
const status = response.status;
26+
const code = status === 401 || status === 403 ? 'UNAUTHORIZED' : 'COMMAND_FAILED';
27+
throw new AppError(code, options.rejectedMessage, {
28+
status,
29+
...(status === 429 || status >= 500
30+
? { retriable: true, hint: 'Retry the request later. Do not sign in again.' }
31+
: {}),
2832
});
2933
}
3034
return parsed as T;

0 commit comments

Comments
 (0)