From 406c31c28406bc89b5b6f5b73167799d0f716665 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 6 Aug 2026 04:26:45 +0200 Subject: [PATCH 1/9] feat(cloud): add headless device-link login (RFC 8628) `agent-relay cloud login` was browser-only, so a machine reachable only over ssh could not be provisioned. This left `barry` as the only fleet node unable to push ai-hist history. Copying `cloud-auth.json` from a logged-in machine is not a workaround: refresh tokens rotate against a single server-side session row, so two machines sharing a file silently log each other out within hours. The device flow gives each machine its own session instead. - `agent-relay cloud login --device` prints a short code to approve in a browser on any other device, then polls until approved. - Login falls back to the device flow automatically over ssh, or on a Unix host with no display server, rather than waiting on a loopback callback that can never arrive there. - The poll honours the RFC 8628 backoff contract: it always sleeps a full interval before each request, widens the interval on `slow_down` and on a 429 `Retry-After`, and never narrows it again. Denied, expired, and replayed grants raise distinct errors instead of hanging. - Sessions are written through the existing persistence path, so `cloud session --json --reveal-token` is unchanged afterwards. Headlessness is deliberately not keyed off `CI`: a CI runner has no browser, but it also has no human to approve the code, so routing it to the device flow would hang for the full grant lifetime instead of failing fast. `auth.test.ts` now pins the browser-availability signal so the browser-flow tests do not inherit whether the runner is a headless Linux box. Requires the cloud device authorization endpoints (AgentWorkforce/cloud#2941). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 +- packages/cli/src/cli/commands/cloud.test.ts | 74 +++- packages/cli/src/cli/commands/cloud.ts | 15 +- packages/cli/src/cli/telemetry/events.ts | 2 + packages/cloud/src/auth.test.ts | 78 +++++ packages/cloud/src/auth.ts | 68 +++- packages/cloud/src/device-auth.test.ts | 368 ++++++++++++++++++++ packages/cloud/src/device-auth.ts | 317 +++++++++++++++++ packages/cloud/src/index.ts | 11 + packages/cloud/src/types.ts | 6 + 10 files changed, 934 insertions(+), 11 deletions(-) create mode 100644 packages/cloud/src/device-auth.test.ts create mode 100644 packages/cloud/src/device-auth.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index da433bd49..e836db95e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased - Patch] +## [Unreleased - Minor] + +### Added + +- `agent-relay cloud login --device` authenticates a machine that has no browser, using the OAuth device flow: the CLI prints a short code, you approve it in a browser on any other device, and the headless machine writes its own `cloud-auth.json`. Login now falls back to this automatically over SSH or on a Unix host with no display server, instead of waiting on a loopback callback that can never arrive. Each machine gets its own cloud session, so copying `cloud-auth.json` between hosts — which silently logs them out of each other as refresh tokens rotate — is no longer necessary. Requires cloud with the device authorization endpoints. ### Fixed diff --git a/packages/cli/src/cli/commands/cloud.test.ts b/packages/cli/src/cli/commands/cloud.test.ts index 7b4f6c969..234f4f02d 100644 --- a/packages/cli/src/cli/commands/cloud.test.ts +++ b/packages/cli/src/cli/commands/cloud.test.ts @@ -18,6 +18,7 @@ const cloudMocks = vi.hoisted(() => ({ runCloudWorkerLoop: vi.fn(), enrollFleetNode: vi.fn(), upsertFleetNodeEnrollment: vi.fn(), + isHeadlessEnvironment: vi.fn(() => false), })); vi.mock('@agent-relay/cloud', async (importOriginal) => ({ @@ -32,6 +33,7 @@ vi.mock('@agent-relay/cloud', async (importOriginal) => ({ upsertFleetNodeEnrollment: (...args: unknown[]) => cloudMocks.upsertFleetNodeEnrollment(...args), ensureAuthenticated: vi.fn(), ensureCloudSession: vi.fn(), + isHeadlessEnvironment: (...args: unknown[]) => cloudMocks.isHeadlessEnvironment(...args), getProviderHelpText: () => 'anthropic (alias: claude), openai (alias: codex), google (alias: gemini), cursor, opencode, droid', getRunLogs: vi.fn(), @@ -62,7 +64,12 @@ vi.mock('../telemetry/index.js', () => ({ track: vi.fn(), })); -import { authorizedApiFetch, ensureAuthenticated, ensureCloudSession } from '@agent-relay/cloud'; +import { + authorizedApiFetch, + ensureAuthenticated, + ensureCloudSession, + readStoredAuth, +} from '@agent-relay/cloud'; import { buildCloudSyncPatchExcludeArgs, registerCloudCommands, type CloudDependencies } from './cloud.js'; import { createDefaultAssignmentRunner } from './cloud-worker.js'; @@ -149,6 +156,71 @@ describe('registerCloudCommands', () => { ]); }); + describe('cloud login', () => { + beforeEach(() => { + vi.mocked(readStoredAuth).mockResolvedValue(null); + vi.mocked(ensureAuthenticated).mockResolvedValue({} as never); + cloudMocks.isHeadlessEnvironment.mockReturnValue(false); + }); + + it('exposes --device for headless hosts', () => { + const { program } = createHarness(); + const login = program.commands + .find((command) => command.name() === 'cloud') + ?.commands.find((command) => command.name() === 'login'); + + expect(login?.options.map((option) => option.long)).toContain('--device'); + }); + + it('requests the device flow when --device is passed', async () => { + const { program } = createHarness(); + await program.parseAsync(['cloud', 'login', '--device'], { from: 'user' }); + + expect(vi.mocked(ensureAuthenticated)).toHaveBeenCalledWith( + 'https://cloud.test', + expect.objectContaining({ device: true }) + ); + }); + + it('leaves the browser flow alone by default', async () => { + const { program } = createHarness(); + await program.parseAsync(['cloud', 'login'], { from: 'user' }); + + expect(vi.mocked(ensureAuthenticated)).toHaveBeenCalledWith( + 'https://cloud.test', + expect.objectContaining({ device: undefined }) + ); + }); + + it('short-circuits when a live session already exists', async () => { + vi.mocked(readStoredAuth).mockResolvedValue({ + apiUrl: 'https://cloud.test', + accessTokenExpiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + }); + + const { program, deps } = createHarness(); + await program.parseAsync(['cloud', 'login', '--device'], { from: 'user' }); + + expect(vi.mocked(ensureAuthenticated)).not.toHaveBeenCalled(); + expect(deps.log).toHaveBeenCalledWith('Already logged in to https://cloud.test'); + }); + + it('re-authenticates on --force even with a live session', async () => { + vi.mocked(readStoredAuth).mockResolvedValue({ + apiUrl: 'https://cloud.test', + accessTokenExpiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + }); + + const { program } = createHarness(); + await program.parseAsync(['cloud', 'login', '--device', '--force'], { from: 'user' }); + + expect(vi.mocked(ensureAuthenticated)).toHaveBeenCalledWith( + 'https://cloud.test', + expect.objectContaining({ device: true, force: true }) + ); + }); + }); + it('registers cloud worker subcommands', () => { const { program } = createHarness(); const cloud = program.commands.find((command) => command.name() === 'cloud'); diff --git a/packages/cli/src/cli/commands/cloud.ts b/packages/cli/src/cli/commands/cloud.ts index da19a7904..9198a4143 100644 --- a/packages/cli/src/cli/commands/cloud.ts +++ b/packages/cli/src/cli/commands/cloud.ts @@ -7,6 +7,7 @@ import { ensureAuthenticated, ensureCloudSession, authorizedApiFetch, + isHeadlessEnvironment, readStoredAuth, clearStoredAuth, defaultApiUrl, @@ -600,13 +601,20 @@ export function registerCloudCommands(program: Command, overrides: Partial', 'Cloud API base URL') .option('--force', 'Force re-authentication even if already logged in') - .action(async (options: { apiUrl?: string; force?: boolean }) => { + .option( + '--device', + 'Authorize from a browser on another machine (for headless/ssh hosts). Chosen automatically when no browser is available.' + ) + .action(async (options: { apiUrl?: string; force?: boolean; device?: boolean }) => { const started = Date.now(); let success = false; let errorClass: string | undefined; + // Recorded so headless adoption is visible in telemetry; the auto + // fallback means `--device` alone would undercount it. + const method = options.device === true || isHeadlessEnvironment() ? 'device' : 'browser'; try { const apiUrl = options.apiUrl || defaultApiUrl(); @@ -622,7 +630,7 @@ export function registerCloudCommands(program: Command, overrides: Partial = {}): NodeJS.ProcessEnv { beforeEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + + // Pin the browser-availability signal. Login now falls back to the device + // flow on a host that cannot open a browser, so tests that exercise the + // browser flow must not inherit whether the runner happens to be a headless + // Linux CI box. + vi.stubEnv('DISPLAY', ':0'); + vi.stubEnv('SSH_CONNECTION', ''); + vi.stubEnv('SSH_TTY', ''); + vi.stubEnv('SSH_CLIENT', ''); fsMocks.readFile.mockReset(); fsMocks.readFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); @@ -365,6 +375,74 @@ describe('ensureAuthenticated', () => { logSpy.mockRestore(); }); + it('falls back to the device flow on a host that cannot open a browser', async () => { + // barry over ssh: no browser here, so the loopback callback the browser + // flow depends on is unreachable and would only hang until it timed out. + vi.stubEnv('SSH_CONNECTION', '10.0.0.2 54321 10.0.0.1 22'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const fetchSpy = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.includes('/api/v1/auth/device/start')) { + return new Response( + JSON.stringify({ + device_code: 'cld_dc_test', + user_code: 'BCDF-GHJK', + verification_uri: 'https://example.com/cloud/device', + expires_in: 600, + // Keep the mandatory pre-poll wait short; pacing itself is + // covered exhaustively in device-auth.test.ts. + interval: 1, + }), + { status: 201, headers: { 'content-type': 'application/json' } } + ); + } + if (url.includes('/api/v1/auth/device/token')) { + return new Response( + JSON.stringify({ + access_token: 'device-access', + refresh_token: 'device-refresh', + access_token_expires_at: '2999-01-01T00:00:00.000Z', + refresh_token_expires_at: '2999-04-01T00:00:00.000Z', + api_url: 'https://example.com/cloud', + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + } + // whoami, used only to record identity; failing it must not fail login. + return new Response('{}', { status: 500 }); + }); + vi.stubGlobal('fetch', fetchSpy); + + const session = await ensureCloudSession({ apiUrl: 'https://example.com/cloud' }); + + expect(session.auth).toMatchObject({ + accessToken: 'device-access', + refreshToken: 'device-refresh', + apiUrl: 'https://example.com/cloud', + }); + // Never tried to launch a browser it does not have. + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + // Persisted through the normal path, so `cloud session --reveal-token` + // works afterwards — that is what ai-hist consumes. + expect(fsMocks.writeFile).toHaveBeenCalled(); + // The user was actually told the code. + expect(logSpy.mock.calls.map((call) => String(call[0])).join('\n')).toContain('BCDF-GHJK'); + + logSpy.mockRestore(); + }); + + it('points a headless host at --device when it cannot prompt', async () => { + vi.stubEnv('SSH_CONNECTION', '10.0.0.2 54321 10.0.0.1 22'); + + await expect( + ensureCloudSession({ apiUrl: 'https://example.com/cloud', interactive: false }) + ).rejects.toMatchObject({ + code: 'AUTH_BROWSER_REQUIRED', + message: 'Cloud login required. Run `agent-relay cloud login --device`.', + }); + }); + it('fails fast without opening a browser when non-interactive auth needs login', async () => { await expect( ensureCloudSession({ diff --git a/packages/cloud/src/auth.ts b/packages/cloud/src/auth.ts index fbf4925da..e62ca059a 100644 --- a/packages/cloud/src/auth.ts +++ b/packages/cloud/src/auth.ts @@ -15,6 +15,11 @@ import { writeStoredIdentity, type CloudIdentity, } from './identity.js'; +import { + isHeadlessEnvironment, + runDeviceAuthorizationFlow, + type DeviceFlowHooks, +} from './device-auth.js'; import { AUTH_FILE_PATH, DEFAULT_REFRESH_TIMEOUT_MS, @@ -626,8 +631,13 @@ async function requestStoredAuthRefresh( return nextAuth; } -async function loginWithBrowser(apiUrl: string): Promise { - const auth = await beginBrowserLogin(apiUrl); +/** + * Persist a freshly obtained session and announce who it belongs to. Shared by + * the browser and device flows so both write `cloud-auth.json` through the same + * path — that is what keeps `cloud session --json --reveal-token` working + * regardless of how the machine logged in. + */ +async function completeLogin(auth: StoredAuth): Promise { await writeStoredAuth(auth); // Record who just logged in so subsequent CLI/broker runs can attribute // telemetry to this user and org. Never blocks the login from succeeding. @@ -640,14 +650,54 @@ async function loginWithBrowser(apiUrl: string): Promise { return auth; } +async function loginWithBrowser(apiUrl: string): Promise { + return completeLogin(await beginBrowserLogin(apiUrl)); +} + +/** + * Device-code login for machines with no browser. Each run mints its own + * session row on the server, so this machine's refresh-token rotation is + * independent of every other machine's — which is the reason copying + * `cloud-auth.json` between hosts is not a valid substitute. + */ +export async function loginWithDevice( + apiUrl: string, + options: { clientName?: string } & DeviceFlowHooks = {} +): Promise { + return completeLogin(await runDeviceAuthorizationFlow(apiUrl, options)); +} + +/** + * Pick a login style. `--device` is explicit; otherwise fall back to the device + * flow when nothing here could open a browser, since the browser flow's + * loopback callback is unreachable from another machine and would only hang + * until it timed out. + */ +async function loginInteractive( + apiUrl: string, + options: { device?: boolean; env?: NodeJS.ProcessEnv } = {} +): Promise { + const env = options.env ?? process.env; + if (options.device === true || isHeadlessEnvironment(env)) { + return loginWithDevice(apiUrl); + } + return loginWithBrowser(apiUrl); +} + export async function ensureAuthenticated( apiUrl: string, - options?: { force?: boolean; interactive?: boolean; refreshTimeoutMs?: number } + options?: { + force?: boolean; + interactive?: boolean; + device?: boolean; + refreshTimeoutMs?: number; + } ): Promise { const session = await ensureCloudSession({ apiUrl, force: options?.force, interactive: options?.interactive, + device: options?.device, refreshTimeoutMs: options?.refreshTimeoutMs, }); return session.auth; @@ -668,9 +718,15 @@ export async function ensureCloudSession(options: CloudSessionOptions = {}): Pro // Only `--force` re-links to a different host. if (!stored) { if (!interactive) { - throw browserRequired('Cloud login required. Run `agent-relay login`.'); + // Point a headless host at the flow that can actually work there, + // rather than at a browser it has no way to open. + throw browserRequired( + isHeadlessEnvironment(env) + ? 'Cloud login required. Run `agent-relay cloud login --device`.' + : 'Cloud login required. Run `agent-relay login`.' + ); } - const auth = await loginWithBrowser(apiUrl); + const auth = await loginInteractive(apiUrl, { device: options.device, env }); return createCloudSession(auth, { refreshTimeoutMs }); } @@ -690,7 +746,7 @@ export async function ensureCloudSession(options: CloudSessionOptions = {}): Pro throw error; } - const auth = await loginWithBrowser(stored.apiUrl); + const auth = await loginInteractive(stored.apiUrl, { device: options.device, env }); return createCloudSession(auth, { refreshTimeoutMs }); } } diff --git a/packages/cloud/src/device-auth.test.ts b/packages/cloud/src/device-auth.test.ts new file mode 100644 index 000000000..415723823 --- /dev/null +++ b/packages/cloud/src/device-auth.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + formatDeviceInstructions, + isHeadlessEnvironment, + pollForDeviceToken, + runDeviceAuthorizationFlow, + startDeviceAuthorization, + type DeviceAuthorization, +} from './device-auth.js'; +import { CloudAuthError } from './types.js'; + +const API_URL = 'https://agentrelay.com'; + +const AUTHORIZATION: DeviceAuthorization = { + deviceCode: 'cld_dc_test', + userCode: 'BCDF-GHJK', + verificationUri: 'https://agentrelay.com/cloud/device', + verificationUriComplete: 'https://agentrelay.com/cloud/device?user_code=BCDF-GHJK', + expiresInSeconds: 600, + intervalSeconds: 5, +}; + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + ...init, + }); +} + +/** + * A poll harness that records every sleep, so a client that busy-loops is + * detectable rather than merely slow. + */ +function harness(responses: Response[]) { + const sleeps: number[] = []; + let clock = 0; + const fetchImpl = vi.fn(async () => { + const next = responses.shift(); + if (!next) throw new Error('unexpected extra poll'); + return next; + }); + + return { + sleeps, + fetchImpl, + hooks: { + fetchImpl: fetchImpl as unknown as typeof fetch, + sleep: async (ms: number) => { + sleeps.push(ms); + clock += ms; + }, + now: () => clock, + }, + }; +} + +describe('device authorization start', () => { + it('sends the hostname so the approval screen can name this machine', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse( + { + device_code: 'cld_dc_test', + user_code: 'BCDF-GHJK', + verification_uri: 'https://agentrelay.com/cloud/device', + verification_uri_complete: 'https://agentrelay.com/cloud/device?user_code=BCDF-GHJK', + expires_in: 600, + interval: 5, + }, + { status: 201 } + ) + ); + + const result = await startDeviceAuthorization(API_URL, { + clientName: 'barry', + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result).toEqual(AUTHORIZATION); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [URL, RequestInit]; + expect(url.toString()).toBe('https://agentrelay.com/api/v1/auth/device/start'); + expect(JSON.parse(String(init.body))).toEqual({ client_name: 'barry' }); + }); + + it('explains that the deployment is too old on a 404', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({}, { status: 404 })); + + await expect( + startDeviceAuthorization(API_URL, { fetchImpl: fetchImpl as unknown as typeof fetch }) + ).rejects.toThrow(/does not support device login/); + }); + + it('rejects a response missing the device code', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ user_code: 'BCDF-GHJK' }, { status: 201 }) + ); + + await expect( + startDeviceAuthorization(API_URL, { fetchImpl: fetchImpl as unknown as typeof fetch }) + ).rejects.toThrow(/missing required fields/); + }); +}); + +describe('device authorization poll', () => { + it('waits the interval between polls instead of spinning', async () => { + const { hooks, sleeps, fetchImpl } = harness([ + jsonResponse({ error: 'authorization_pending', interval: 5 }, { status: 400 }), + jsonResponse({ error: 'authorization_pending', interval: 5 }, { status: 400 }), + jsonResponse({ + access_token: 'cld_at_abc', + refresh_token: 'cld_rt_abc', + access_token_expires_at: '2026-08-07T00:00:00.000Z', + refresh_token_expires_at: '2026-11-04T00:00:00.000Z', + api_url: 'https://agentrelay.com/cloud', + }), + ]); + + const auth = await pollForDeviceToken(API_URL, AUTHORIZATION, hooks); + + expect(auth).toEqual({ + accessToken: 'cld_at_abc', + refreshToken: 'cld_rt_abc', + accessTokenExpiresAt: '2026-08-07T00:00:00.000Z', + refreshTokenExpiresAt: '2026-11-04T00:00:00.000Z', + apiUrl: 'https://agentrelay.com/cloud', + }); + // This is the assertion that fails if the client busy-loops: one full + // interval of sleep before every request, including the first. + expect(sleeps).toEqual([5000, 5000, 5000]); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it('never polls before sleeping, so it cannot spin even once', async () => { + const { hooks, sleeps } = harness([ + jsonResponse({ + access_token: 'cld_at_abc', + refresh_token: 'cld_rt_abc', + access_token_expires_at: '2026-08-07T00:00:00.000Z', + }), + ]); + + await pollForDeviceToken(API_URL, AUTHORIZATION, hooks); + expect(sleeps).toEqual([5000]); + }); + + it('widens the interval on slow_down (RFC 8628 §3.5)', async () => { + const { hooks, sleeps } = harness([ + jsonResponse({ error: 'authorization_pending', interval: 5 }, { status: 400 }), + jsonResponse({ error: 'slow_down', interval: 10 }, { status: 400 }), + jsonResponse({ error: 'slow_down', interval: 15 }, { status: 400 }), + jsonResponse({ + access_token: 'cld_at_abc', + refresh_token: 'cld_rt_abc', + access_token_expires_at: '2026-08-07T00:00:00.000Z', + }), + ]); + + await pollForDeviceToken(API_URL, AUTHORIZATION, hooks); + + // 5s, then +5 each time the server says slow down. The client must never + // go back down to the original interval afterwards. + expect(sleeps).toEqual([5000, 5000, 10_000, 15_000]); + }); + + it('takes the server interval when it exceeds the client step', async () => { + const { hooks, sleeps } = harness([ + jsonResponse({ error: 'slow_down', interval: 30 }, { status: 400 }), + jsonResponse({ + access_token: 'cld_at_abc', + refresh_token: 'cld_rt_abc', + access_token_expires_at: '2026-08-07T00:00:00.000Z', + }), + ]); + + await pollForDeviceToken(API_URL, AUTHORIZATION, hooks); + expect(sleeps).toEqual([5000, 30_000]); + }); + + it('honours Retry-After when rate limited', async () => { + const { hooks, sleeps } = harness([ + jsonResponse({ error: 'slow_down' }, { status: 429, headers: { 'retry-after': '20' } }), + jsonResponse({ + access_token: 'cld_at_abc', + refresh_token: 'cld_rt_abc', + access_token_expires_at: '2026-08-07T00:00:00.000Z', + }), + ]); + + await pollForDeviceToken(API_URL, AUTHORIZATION, hooks); + expect(sleeps).toEqual([5000, 20_000]); + }); + + it('never lets the interval grow without bound', async () => { + const responses = Array.from({ length: 30 }, () => + jsonResponse({ error: 'slow_down' }, { status: 400 }) + ); + responses.push( + jsonResponse({ + access_token: 'cld_at_abc', + refresh_token: 'cld_rt_abc', + access_token_expires_at: '2026-08-07T00:00:00.000Z', + }) + ); + // A long-lived grant so the deadline does not end the loop first. + const { hooks, sleeps } = harness(responses); + + await pollForDeviceToken( + API_URL, + { ...AUTHORIZATION, expiresInSeconds: 60 * 60 * 24 }, + hooks + ); + + expect(Math.max(...sleeps)).toBe(60_000); + }); + + it('reports a denial as a clear error rather than hanging', async () => { + const { hooks } = harness([ + jsonResponse({ error: 'access_denied' }, { status: 400 }), + ]); + + await expect(pollForDeviceToken(API_URL, AUTHORIZATION, hooks)).rejects.toThrow( + /denied\. No credentials were issued/ + ); + }); + + it('reports an expired grant as a clear error rather than hanging', async () => { + const { hooks } = harness([ + jsonResponse({ error: 'expired_token' }, { status: 400 }), + ]); + + await expect(pollForDeviceToken(API_URL, AUTHORIZATION, hooks)).rejects.toThrow( + /expired before it was approved/ + ); + }); + + it('reports a replayed device code as a clear error', async () => { + const { hooks } = harness([ + jsonResponse({ error: 'invalid_grant' }, { status: 400 }), + ]); + + await expect(pollForDeviceToken(API_URL, AUTHORIZATION, hooks)).rejects.toThrow( + /no longer valid/ + ); + }); + + it('gives up locally once the grant lifetime elapses', async () => { + // The server should say expired_token first, but a client that trusted the + // server to end the loop could poll forever if it never did. + const responses = Array.from({ length: 500 }, () => + jsonResponse({ error: 'authorization_pending' }, { status: 400 }) + ); + const { hooks, fetchImpl } = harness(responses); + + await expect( + pollForDeviceToken(API_URL, { ...AUTHORIZATION, expiresInSeconds: 30 }, hooks) + ).rejects.toThrow(/expired before it was approved/); + + // 30s of grant at a 5s interval — six polls, not five hundred. + expect(fetchImpl.mock.calls.length).toBeLessThanOrEqual(6); + }); + + it('surfaces an unexpected error code instead of looping on it', async () => { + const { hooks } = harness([ + jsonResponse({ error: 'invalid_client' }, { status: 400 }), + ]); + + await expect(pollForDeviceToken(API_URL, AUTHORIZATION, hooks)).rejects.toThrow( + /Device login failed: invalid_client/ + ); + }); + + it('reports a CloudAuthError so callers can branch on the code', async () => { + const { hooks } = harness([ + jsonResponse({ error: 'access_denied' }, { status: 400 }), + ]); + + const error = await pollForDeviceToken(API_URL, AUTHORIZATION, hooks).then( + () => { + throw new Error('expected the poll to reject'); + }, + (reason: unknown) => reason + ); + + expect(error).toBeInstanceOf(CloudAuthError); + expect((error as CloudAuthError).code).toBe('AUTH_DEVICE_FLOW_FAILED'); + }); + + it('falls back to the requested api url when the server omits one', async () => { + const { hooks } = harness([ + jsonResponse({ + access_token: 'cld_at_abc', + refresh_token: 'cld_rt_abc', + access_token_expires_at: '2026-08-07T00:00:00.000Z', + }), + ]); + + const auth = await pollForDeviceToken(API_URL, AUTHORIZATION, hooks); + expect(auth.apiUrl).toBe(API_URL); + }); +}); + +describe('headless detection', () => { + it('treats an ssh session as headless', () => { + expect(isHeadlessEnvironment({ SSH_CONNECTION: '10.0.0.1 22' }, 'darwin')).toBe(true); + expect(isHeadlessEnvironment({ SSH_TTY: '/dev/pts/0' }, 'linux')).toBe(true); + }); + + it('does not treat CI alone as headless', () => { + // CI has no browser, but it also has no human to approve the code, so + // routing it to the device flow would hang for the whole grant lifetime + // instead of failing fast with "login required". + expect(isHeadlessEnvironment({ CI: 'true', DISPLAY: ':0' }, 'linux')).toBe(false); + expect(isHeadlessEnvironment({ CI: 'true' }, 'darwin')).toBe(false); + }); + + it('treats a Unix host with no display server as headless', () => { + expect(isHeadlessEnvironment({}, 'linux')).toBe(true); + expect(isHeadlessEnvironment({ DISPLAY: ':0' }, 'linux')).toBe(false); + expect(isHeadlessEnvironment({ WAYLAND_DISPLAY: 'wayland-0' }, 'linux')).toBe(false); + }); + + it('does not treat a local desktop as headless', () => { + // macOS and Windows always have a way to open a browser. + expect(isHeadlessEnvironment({}, 'darwin')).toBe(false); + expect(isHeadlessEnvironment({}, 'win32')).toBe(false); + }); +}); + +describe('instructions', () => { + it('shows the URL and the code the user must type', () => { + const text = formatDeviceInstructions(AUTHORIZATION); + expect(text).toContain('https://agentrelay.com/cloud/device'); + expect(text).toContain('BCDF-GHJK'); + }); + + it('prints the code before waiting, so the user is never left guessing', async () => { + const logs: string[] = []; + const responses = [ + jsonResponse( + { + device_code: 'cld_dc_test', + user_code: 'BCDF-GHJK', + verification_uri: 'https://agentrelay.com/cloud/device', + expires_in: 600, + interval: 5, + }, + { status: 201 } + ), + jsonResponse({ + access_token: 'cld_at_abc', + refresh_token: 'cld_rt_abc', + access_token_expires_at: '2026-08-07T00:00:00.000Z', + }), + ]; + const fetchImpl = vi.fn(async () => responses.shift()!); + + await runDeviceAuthorizationFlow(API_URL, { + clientName: 'barry', + fetchImpl: fetchImpl as unknown as typeof fetch, + sleep: async () => undefined, + log: (message) => logs.push(message), + }); + + expect(logs.join('\n')).toContain('BCDF-GHJK'); + expect(logs.join('\n')).toContain('Waiting for authorization'); + }); +}); diff --git a/packages/cloud/src/device-auth.ts b/packages/cloud/src/device-auth.ts new file mode 100644 index 000000000..cdd576f4b --- /dev/null +++ b/packages/cloud/src/device-auth.ts @@ -0,0 +1,317 @@ +import os from 'node:os'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { buildApiUrl } from './api-client.js'; +import { CloudAuthError, type StoredAuth } from './types.js'; + +/** + * OAuth 2.0 device authorization grant (RFC 8628), client side. + * + * Lets a machine with no browser — a fleet node reached only over ssh — + * obtain its own cloud session. The human approves in a browser on any other + * device, and this machine ends up with its own session row rather than a + * copy of someone else's. That matters because refresh tokens rotate: two + * machines sharing one `cloud-auth.json` share one session row and silently + * log each other out. + */ + +/** RFC 8628 §3.4. */ +const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; +const DEFAULT_INTERVAL_SECONDS = 5; +/** RFC 8628 §3.5: on `slow_down` the client must increase its interval. */ +const SLOW_DOWN_INCREMENT_SECONDS = 5; +const MAX_INTERVAL_SECONDS = 60; +const DEFAULT_EXPIRES_IN_SECONDS = 600; + +export type DeviceAuthorization = { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + expiresInSeconds: number; + intervalSeconds: number; +}; + +export type DeviceFlowHooks = { + /** Injected so tests can assert the client actually waits between polls. */ + sleep?: (ms: number) => Promise; + now?: () => number; + fetchImpl?: typeof fetch; + log?: (message: string) => void; +}; + +type DeviceTokenErrorCode = + | 'authorization_pending' + | 'slow_down' + | 'access_denied' + | 'expired_token' + | 'invalid_grant' + | (string & {}); + +function deviceError(message: string): CloudAuthError { + return new CloudAuthError('AUTH_DEVICE_FLOW_FAILED', message); +} + +function clampInterval(seconds: number): number { + if (!Number.isFinite(seconds) || seconds <= 0) { + return DEFAULT_INTERVAL_SECONDS; + } + return Math.min(MAX_INTERVAL_SECONDS, Math.ceil(seconds)); +} + +/** + * A machine is treated as headless when nothing could plausibly open a + * browser. Used only to pick a default — `--device` always wins, and the + * device flow works fine on a desktop too. + */ +export function isHeadlessEnvironment( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = os.platform() +): boolean { + if (env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT) { + return true; + } + // Deliberately NOT keyed off `CI`. A CI runner has no browser, but it also + // has no human to approve the code, so the device flow would hang for the + // full grant lifetime instead of failing fast. Unattended runs should use + // WORKFORCE_WORKSPACE_TOKEN. + // + // macOS and Windows always have a usable `open`/`start`; only X11/Wayland + // desktops on Unix depend on a display server being present. + if (platform !== 'darwin' && platform !== 'win32') { + return !env.DISPLAY && !env.WAYLAND_DISPLAY; + } + return false; +} + +export async function startDeviceAuthorization( + apiUrl: string, + options: { clientName?: string; fetchImpl?: typeof fetch } = {} +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const clientName = options.clientName ?? os.hostname(); + + let response: Response; + try { + response = await fetchImpl(buildApiUrl(apiUrl, '/api/v1/auth/device/start'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ client_name: clientName }), + }); + } catch (error) { + throw new CloudAuthError( + 'AUTH_DEVICE_FLOW_FAILED', + `Could not reach ${apiUrl} to start device login`, + { cause: error } + ); + } + + const payload = (await response.json().catch(() => null)) as { + device_code?: string; + user_code?: string; + verification_uri?: string; + verification_uri_complete?: string; + expires_in?: number; + interval?: number; + error?: string; + error_description?: string; + } | null; + + if (!response.ok) { + // A server without these routes is the likeliest failure early on; say so + // rather than reporting a bare 404. + if (response.status === 404) { + throw deviceError( + `${apiUrl} does not support device login. Update the cloud deployment, or run \`agent-relay cloud login\` on a machine with a browser.` + ); + } + const detail = payload?.error_description || payload?.error || `HTTP ${response.status}`; + throw deviceError(`Could not start device login: ${detail}`); + } + + if (!payload?.device_code || !payload.user_code || !payload.verification_uri) { + throw deviceError('Device login response was missing required fields'); + } + + return { + deviceCode: payload.device_code, + userCode: payload.user_code, + verificationUri: payload.verification_uri, + ...(payload.verification_uri_complete + ? { verificationUriComplete: payload.verification_uri_complete } + : {}), + expiresInSeconds: payload.expires_in ?? DEFAULT_EXPIRES_IN_SECONDS, + intervalSeconds: clampInterval(payload.interval ?? DEFAULT_INTERVAL_SECONDS), + }; +} + +/** + * Poll until the user approves, denies, or the grant expires. + * + * Honours the RFC 8628 backoff contract: it waits `interval` between polls and + * widens that interval on `slow_down`. It must never spin — a client that + * ignores the interval gets rate limited off the server, so busy-looping is a + * bug, not just impoliteness. + */ +export async function pollForDeviceToken( + apiUrl: string, + authorization: DeviceAuthorization, + hooks: DeviceFlowHooks = {} +): Promise { + const fetchImpl = hooks.fetchImpl ?? fetch; + const sleep = hooks.sleep ?? ((ms: number) => delay(ms)); + const now = hooks.now ?? (() => Date.now()); + + let intervalSeconds = authorization.intervalSeconds; + const deadline = now() + authorization.expiresInSeconds * 1000; + + for (;;) { + // Wait first: the user cannot possibly have approved in the microseconds + // since the grant was minted, and polling immediately only earns a + // `slow_down`. + await sleep(intervalSeconds * 1000); + + if (now() >= deadline) { + throw deviceError( + 'Device login expired before it was approved. Run the command again to get a new code.' + ); + } + + let response: Response; + try { + response = await fetchImpl(buildApiUrl(apiUrl, '/api/v1/auth/device/token'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + grant_type: DEVICE_GRANT_TYPE, + device_code: authorization.deviceCode, + }), + }); + } catch (error) { + throw new CloudAuthError( + 'AUTH_DEVICE_FLOW_FAILED', + `Lost connection to ${apiUrl} while waiting for approval`, + { cause: error } + ); + } + + const payload = (await response.json().catch(() => null)) as { + access_token?: string; + refresh_token?: string; + access_token_expires_at?: string; + refresh_token_expires_at?: string; + api_url?: string; + error?: DeviceTokenErrorCode; + error_description?: string; + interval?: number; + } | null; + + if (response.ok) { + if ( + !payload?.access_token || + !payload.refresh_token || + !payload.access_token_expires_at + ) { + throw deviceError('Device login response was missing required fields'); + } + return { + accessToken: payload.access_token, + refreshToken: payload.refresh_token, + accessTokenExpiresAt: payload.access_token_expires_at, + ...(payload.refresh_token_expires_at + ? { refreshTokenExpiresAt: payload.refresh_token_expires_at } + : {}), + apiUrl: payload.api_url?.trim() || apiUrl, + }; + } + + // A 429 means the server thinks we are polling too fast even before it + // gets to the grant. Back off on its terms. + if (response.status === 429) { + const retryAfter = Number(response.headers.get('retry-after')); + intervalSeconds = clampInterval( + Number.isFinite(retryAfter) && retryAfter > 0 + ? retryAfter + : intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS + ); + continue; + } + + switch (payload?.error) { + case 'authorization_pending': + // Still waiting on the human. The server may also have widened the + // interval; respect it if so. + if (payload.interval) { + intervalSeconds = Math.max(intervalSeconds, clampInterval(payload.interval)); + } + continue; + + case 'slow_down': + // RFC 8628 §3.5. Take whichever is larger: the server's stated + // interval, or our own +5s step. + intervalSeconds = clampInterval( + Math.max( + intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS, + payload.interval ?? 0 + ) + ); + continue; + + case 'access_denied': + throw deviceError('Device login was denied. No credentials were issued.'); + + case 'expired_token': + throw deviceError( + 'Device login expired before it was approved. Run the command again to get a new code.' + ); + + case 'invalid_grant': + throw deviceError( + 'This device code is no longer valid. Run the command again to get a new code.' + ); + + default: { + const detail = + payload?.error_description || payload?.error || `HTTP ${response.status}`; + throw deviceError(`Device login failed: ${detail}`); + } + } + } +} + +export function formatDeviceInstructions(authorization: DeviceAuthorization): string { + return [ + '', + 'To authorize this machine, visit:', + ` ${authorization.verificationUri}`, + '', + 'and enter code:', + ` ${authorization.userCode}`, + '', + ...(authorization.verificationUriComplete + ? [`Or open this link directly:`, ` ${authorization.verificationUriComplete}`, ''] + : []), + ].join('\n'); +} + +/** + * Run the whole device flow. Returns the session without persisting it — the + * caller owns writing `cloud-auth.json` through the normal persistence path. + */ +export async function runDeviceAuthorizationFlow( + apiUrl: string, + options: { clientName?: string } & DeviceFlowHooks = {} +): Promise { + const log = options.log ?? ((message: string) => console.log(message)); + + const authorization = await startDeviceAuthorization(apiUrl, { + ...(options.clientName ? { clientName: options.clientName } : {}), + ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), + }); + + log(formatDeviceInstructions(authorization)); + log('Waiting for authorization...'); + + const auth = await pollForDeviceToken(apiUrl, authorization, options); + return auth; +} diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index bcdd014a5..b11339ea7 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -9,8 +9,19 @@ export { ensureAuthenticated, ensureCloudSession, authorizedApiFetch, + loginWithDevice, } from './auth.js'; +export { + isHeadlessEnvironment, + startDeviceAuthorization, + pollForDeviceToken, + runDeviceAuthorizationFlow, + formatDeviceInstructions, + type DeviceAuthorization, + type DeviceFlowHooks, +} from './device-auth.js'; + export { IDENTITY_ENV_KEYS, IDENTITY_FILE_PATH, diff --git a/packages/cloud/src/types.ts b/packages/cloud/src/types.ts index e0ecb41ce..90c547617 100644 --- a/packages/cloud/src/types.ts +++ b/packages/cloud/src/types.ts @@ -13,6 +13,7 @@ export type CloudAuthErrorCode = | 'AUTH_REFRESH_TIMEOUT' | 'AUTH_REFRESH_EXPIRED' | 'AUTH_BROWSER_REQUIRED' + | 'AUTH_DEVICE_FLOW_FAILED' | 'AUTH_ENV_REPROVISION_REQUIRED'; export class CloudAuthError extends Error { @@ -35,6 +36,11 @@ export type CloudSessionOptions = { apiUrl?: string; force?: boolean; interactive?: boolean; + /** + * Force the RFC 8628 device flow instead of the browser flow. Left unset, + * the device flow is still chosen automatically on a headless host. + */ + device?: boolean; refreshTimeoutMs?: number; env?: NodeJS.ProcessEnv; }; From 9dc3256129840c089ea69b07f89a0a3529b3e832 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 6 Aug 2026 05:00:40 +0200 Subject: [PATCH 2/9] style: auto-format with Prettier --- packages/cloud/src/auth.ts | 6 +--- packages/cloud/src/device-auth.test.ts | 38 ++++++-------------------- packages/cloud/src/device-auth.ts | 26 +++++------------- 3 files changed, 17 insertions(+), 53 deletions(-) diff --git a/packages/cloud/src/auth.ts b/packages/cloud/src/auth.ts index e62ca059a..c863c01ae 100644 --- a/packages/cloud/src/auth.ts +++ b/packages/cloud/src/auth.ts @@ -15,11 +15,7 @@ import { writeStoredIdentity, type CloudIdentity, } from './identity.js'; -import { - isHeadlessEnvironment, - runDeviceAuthorizationFlow, - type DeviceFlowHooks, -} from './device-auth.js'; +import { isHeadlessEnvironment, runDeviceAuthorizationFlow, type DeviceFlowHooks } from './device-auth.js'; import { AUTH_FILE_PATH, DEFAULT_REFRESH_TIMEOUT_MS, diff --git a/packages/cloud/src/device-auth.test.ts b/packages/cloud/src/device-auth.test.ts index 415723823..877af405f 100644 --- a/packages/cloud/src/device-auth.test.ts +++ b/packages/cloud/src/device-auth.test.ts @@ -92,9 +92,7 @@ describe('device authorization start', () => { }); it('rejects a response missing the device code', async () => { - const fetchImpl = vi.fn(async () => - jsonResponse({ user_code: 'BCDF-GHJK' }, { status: 201 }) - ); + const fetchImpl = vi.fn(async () => jsonResponse({ user_code: 'BCDF-GHJK' }, { status: 201 })); await expect( startDeviceAuthorization(API_URL, { fetchImpl: fetchImpl as unknown as typeof fetch }) @@ -192,9 +190,7 @@ describe('device authorization poll', () => { }); it('never lets the interval grow without bound', async () => { - const responses = Array.from({ length: 30 }, () => - jsonResponse({ error: 'slow_down' }, { status: 400 }) - ); + const responses = Array.from({ length: 30 }, () => jsonResponse({ error: 'slow_down' }, { status: 400 })); responses.push( jsonResponse({ access_token: 'cld_at_abc', @@ -205,19 +201,13 @@ describe('device authorization poll', () => { // A long-lived grant so the deadline does not end the loop first. const { hooks, sleeps } = harness(responses); - await pollForDeviceToken( - API_URL, - { ...AUTHORIZATION, expiresInSeconds: 60 * 60 * 24 }, - hooks - ); + await pollForDeviceToken(API_URL, { ...AUTHORIZATION, expiresInSeconds: 60 * 60 * 24 }, hooks); expect(Math.max(...sleeps)).toBe(60_000); }); it('reports a denial as a clear error rather than hanging', async () => { - const { hooks } = harness([ - jsonResponse({ error: 'access_denied' }, { status: 400 }), - ]); + const { hooks } = harness([jsonResponse({ error: 'access_denied' }, { status: 400 })]); await expect(pollForDeviceToken(API_URL, AUTHORIZATION, hooks)).rejects.toThrow( /denied\. No credentials were issued/ @@ -225,9 +215,7 @@ describe('device authorization poll', () => { }); it('reports an expired grant as a clear error rather than hanging', async () => { - const { hooks } = harness([ - jsonResponse({ error: 'expired_token' }, { status: 400 }), - ]); + const { hooks } = harness([jsonResponse({ error: 'expired_token' }, { status: 400 })]); await expect(pollForDeviceToken(API_URL, AUTHORIZATION, hooks)).rejects.toThrow( /expired before it was approved/ @@ -235,13 +223,9 @@ describe('device authorization poll', () => { }); it('reports a replayed device code as a clear error', async () => { - const { hooks } = harness([ - jsonResponse({ error: 'invalid_grant' }, { status: 400 }), - ]); + const { hooks } = harness([jsonResponse({ error: 'invalid_grant' }, { status: 400 })]); - await expect(pollForDeviceToken(API_URL, AUTHORIZATION, hooks)).rejects.toThrow( - /no longer valid/ - ); + await expect(pollForDeviceToken(API_URL, AUTHORIZATION, hooks)).rejects.toThrow(/no longer valid/); }); it('gives up locally once the grant lifetime elapses', async () => { @@ -261,9 +245,7 @@ describe('device authorization poll', () => { }); it('surfaces an unexpected error code instead of looping on it', async () => { - const { hooks } = harness([ - jsonResponse({ error: 'invalid_client' }, { status: 400 }), - ]); + const { hooks } = harness([jsonResponse({ error: 'invalid_client' }, { status: 400 })]); await expect(pollForDeviceToken(API_URL, AUTHORIZATION, hooks)).rejects.toThrow( /Device login failed: invalid_client/ @@ -271,9 +253,7 @@ describe('device authorization poll', () => { }); it('reports a CloudAuthError so callers can branch on the code', async () => { - const { hooks } = harness([ - jsonResponse({ error: 'access_denied' }, { status: 400 }), - ]); + const { hooks } = harness([jsonResponse({ error: 'access_denied' }, { status: 400 })]); const error = await pollForDeviceToken(API_URL, AUTHORIZATION, hooks).then( () => { diff --git a/packages/cloud/src/device-auth.ts b/packages/cloud/src/device-auth.ts index cdd576f4b..9e727fe1c 100644 --- a/packages/cloud/src/device-auth.ts +++ b/packages/cloud/src/device-auth.ts @@ -99,11 +99,9 @@ export async function startDeviceAuthorization( body: JSON.stringify({ client_name: clientName }), }); } catch (error) { - throw new CloudAuthError( - 'AUTH_DEVICE_FLOW_FAILED', - `Could not reach ${apiUrl} to start device login`, - { cause: error } - ); + throw new CloudAuthError('AUTH_DEVICE_FLOW_FAILED', `Could not reach ${apiUrl} to start device login`, { + cause: error, + }); } const payload = (await response.json().catch(() => null)) as { @@ -207,11 +205,7 @@ export async function pollForDeviceToken( } | null; if (response.ok) { - if ( - !payload?.access_token || - !payload.refresh_token || - !payload.access_token_expires_at - ) { + if (!payload?.access_token || !payload.refresh_token || !payload.access_token_expires_at) { throw deviceError('Device login response was missing required fields'); } return { @@ -250,10 +244,7 @@ export async function pollForDeviceToken( // RFC 8628 §3.5. Take whichever is larger: the server's stated // interval, or our own +5s step. intervalSeconds = clampInterval( - Math.max( - intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS, - payload.interval ?? 0 - ) + Math.max(intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS, payload.interval ?? 0) ); continue; @@ -266,13 +257,10 @@ export async function pollForDeviceToken( ); case 'invalid_grant': - throw deviceError( - 'This device code is no longer valid. Run the command again to get a new code.' - ); + throw deviceError('This device code is no longer valid. Run the command again to get a new code.'); default: { - const detail = - payload?.error_description || payload?.error || `HTTP ${response.status}`; + const detail = payload?.error_description || payload?.error || `HTTP ${response.status}`; throw deviceError(`Device login failed: ${detail}`); } } From e84e118444e14e0896abc086ea24dd011601ee20 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 6 Aug 2026 06:00:59 +0200 Subject: [PATCH 3/9] fix(cloud): retry a transient 5xx during device polling instead of aborting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server half of this flow (AgentWorkforce/cloud#2941) wraps claiming the grant and minting the session in one transaction: if issuance fails, the claim rolls back, the grant stays `approved` and claimable, and the endpoint answers 503 `server_error` rather than burning the approval. That exists specifically so the client can poll again and still succeed. This client did not. `server_error` fell through to the default branch and became fatal, so a momentary blip discarded an approval the human had already given and made them redo the whole flow — the exact harm the server-side fix was written to prevent. The two halves did not compose. Any 5xx is now retried with a widened interval, bounded by the grant deadline, so a gateway 502 with an HTML body recovers too while an outage that outlasts the grant still ends the loop. Backing off rather than retrying at pace avoids hammering a server that is already failing. Each of the three new tests was confirmed red against the parent (`Device login failed: server_error`) before the fix. Co-Authored-By: Claude Opus 5 --- packages/cloud/src/device-auth.test.ts | 59 ++++++++++++++++++++++++++ packages/cloud/src/device-auth.ts | 12 ++++++ 2 files changed, 71 insertions(+) diff --git a/packages/cloud/src/device-auth.test.ts b/packages/cloud/src/device-auth.test.ts index 877af405f..d2f510399 100644 --- a/packages/cloud/src/device-auth.test.ts +++ b/packages/cloud/src/device-auth.test.ts @@ -244,6 +244,65 @@ describe('device authorization poll', () => { expect(fetchImpl.mock.calls.length).toBeLessThanOrEqual(6); }); + // The server keeps the grant claimable across an issuance failure precisely + // so the next poll can still succeed (cloud#2941 wraps claim+mint in one + // transaction and answers 503 `server_error`). Aborting here would throw away + // an approval the human already gave and make them redo the whole flow — + // exactly the harm the server-side fix exists to prevent. + it('keeps polling through a transient server_error and still logs in', async () => { + const { hooks, sleeps, fetchImpl } = harness([ + jsonResponse({ error: 'authorization_pending', interval: 5 }, { status: 400 }), + jsonResponse( + { error: 'server_error', error_description: 'Unable to complete device authorization' }, + { status: 503 } + ), + jsonResponse({ + access_token: 'cld_at_abc', + refresh_token: 'cld_rt_abc', + access_token_expires_at: '2026-08-07T00:00:00.000Z', + }), + ]); + + const auth = await pollForDeviceToken(API_URL, AUTHORIZATION, hooks); + + expect(auth.accessToken).toBe('cld_at_abc'); + expect(fetchImpl).toHaveBeenCalledTimes(3); + // Backs off rather than hammering a server that is already struggling. + expect(sleeps).toEqual([5000, 5000, 10_000]); + }); + + it('retries a gateway 502 with no JSON body at all', async () => { + // A proxy blip in front of the app yields HTML, not an OAuth error object. + const { hooks, fetchImpl } = harness([ + new Response('502 Bad Gateway', { + status: 502, + headers: { 'content-type': 'text/html' }, + }), + jsonResponse({ + access_token: 'cld_at_abc', + refresh_token: 'cld_rt_abc', + access_token_expires_at: '2026-08-07T00:00:00.000Z', + }), + ]); + + const auth = await pollForDeviceToken(API_URL, AUTHORIZATION, hooks); + expect(auth.accessToken).toBe('cld_at_abc'); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('still gives up on a server outage that outlasts the grant', async () => { + // Retrying must stay bounded by the grant deadline, not loop forever. + const responses = Array.from({ length: 500 }, () => + jsonResponse({ error: 'server_error' }, { status: 503 }) + ); + const { hooks, fetchImpl } = harness(responses); + + await expect( + pollForDeviceToken(API_URL, { ...AUTHORIZATION, expiresInSeconds: 60 }, hooks) + ).rejects.toThrow(/expired before it was approved/); + expect(fetchImpl.mock.calls.length).toBeLessThanOrEqual(6); + }); + it('surfaces an unexpected error code instead of looping on it', async () => { const { hooks } = harness([jsonResponse({ error: 'invalid_client' }, { status: 400 })]); diff --git a/packages/cloud/src/device-auth.ts b/packages/cloud/src/device-auth.ts index 9e727fe1c..0ffc25c2e 100644 --- a/packages/cloud/src/device-auth.ts +++ b/packages/cloud/src/device-auth.ts @@ -231,6 +231,18 @@ export async function pollForDeviceToken( continue; } + // A 5xx here is transient by construction. The server claims the grant and + // mints the session in one transaction, so a failure rolls the claim back + // and leaves the grant `approved` and claimable — it answers `server_error` + // rather than burning the approval, specifically so this loop can try + // again. Treating it as fatal would discard an approval the human already + // gave and make them redo the whole flow. Bounded by the grant deadline + // above, so an outage that outlasts the grant still ends the loop. + if (response.status >= 500) { + intervalSeconds = clampInterval(intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS); + continue; + } + switch (payload?.error) { case 'authorization_pending': // Still waiting on the human. The server may also have widened the From 04acadfb780bb6e7d8995432e098dedf89be7be8 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 6 Aug 2026 06:59:50 +0200 Subject: [PATCH 4/9] fix(cloud): bound every device-flow request so login cannot hang silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node's `fetch` has no default timeout. Neither the start request nor any poll passed a signal, so a stalled TCP connection never settled and `agent-relay cloud login --device` hung with no output and no error. That is the one failure this feature cannot have. It exists for a headless box reached over ssh, where nobody is watching a terminal — a silent indefinite hang there is strictly worse than a failure, because a failure is visible. And the start request stalls before anything has been printed, so the user sees a bare cursor forever. Both legs now carry an abort budget (30s by default, injectable for tests): - The start request fails with "Timed out after 30s trying to reach ", as a CloudAuthError callers can already branch on. - Each poll is bounded by whichever is sooner, the request budget or the grant deadline, so a stalled socket can never outlive the grant it is waiting on and skip the loop's own expiry check. - A stalled poll is treated like the transient 5xx handled in the parent commit: back off and retry rather than discard an approval the human may already have given, and say so on stdout so a slow network does not look like a hang. Stalls that outlast the grant still end with the expiry error, not an infinite loop. All five new tests were confirmed red against the parent — three of them by hanging until the test timeout, which is the bug itself. Co-Authored-By: Claude Opus 5 --- packages/cloud/src/device-auth.test.ts | 136 +++++++++++++++++++++++++ packages/cloud/src/device-auth.ts | 70 ++++++++++++- 2 files changed, 205 insertions(+), 1 deletion(-) diff --git a/packages/cloud/src/device-auth.test.ts b/packages/cloud/src/device-auth.test.ts index d2f510399..ae8c4dc59 100644 --- a/packages/cloud/src/device-auth.test.ts +++ b/packages/cloud/src/device-auth.test.ts @@ -56,6 +56,55 @@ function harness(responses: Response[]) { }; } +/** + * A request that never answers on its own, so only the abort signal can end + * it. If the client passes no signal the promise never settles — which is + * exactly the hang under test, surfaced here as a test timeout. + */ +function stallingFetch() { + return vi.fn( + (_url: unknown, init: RequestInit = {}) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + }) + ); +} + +/** + * A poll harness whose plan entries may be the literal `'stall'`, standing in + * for a connection that opens and then goes quiet. + */ +function stallHarness(plan: Array) { + const sleeps: number[] = []; + const logs: string[] = []; + let clock = 0; + const fetchImpl = vi.fn((_url: unknown, init: RequestInit = {}) => { + const next = plan.shift(); + if (!next) throw new Error('unexpected extra poll'); + if (next !== 'stall') return Promise.resolve(next); + return new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + }); + }); + + return { + sleeps, + logs, + fetchImpl, + hooks: { + fetchImpl: fetchImpl as unknown as typeof fetch, + // Short enough to keep the suite fast; the production default is 30s. + requestTimeoutMs: 20, + sleep: async (ms: number) => { + sleeps.push(ms); + clock += ms; + }, + now: () => clock, + log: (message: string) => logs.push(message), + }, + }; +} + describe('device authorization start', () => { it('sends the hostname so the approval screen can name this machine', async () => { const fetchImpl = vi.fn(async () => @@ -98,6 +147,40 @@ describe('device authorization start', () => { startDeviceAuthorization(API_URL, { fetchImpl: fetchImpl as unknown as typeof fetch }) ).rejects.toThrow(/missing required fields/); }); + + it('times out a stalled connection instead of hanging with no output', async () => { + // Node's fetch has no default timeout. Nothing has been printed by this + // point in the flow, so an unbounded start request shows an ssh user a + // bare cursor forever — the one failure mode this feature cannot have. + const fetchImpl = stallingFetch(); + + await expect( + startDeviceAuthorization(API_URL, { + fetchImpl: fetchImpl as unknown as typeof fetch, + requestTimeoutMs: 20, + }) + ).rejects.toThrow(/Timed out after 20ms trying to reach https:\/\/agentrelay\.com/); + + const [, init] = fetchImpl.mock.calls[0] as unknown as [URL, RequestInit]; + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it('reports a timeout as a CloudAuthError, not a bare DOMException', async () => { + const fetchImpl = stallingFetch(); + + const error = await startDeviceAuthorization(API_URL, { + fetchImpl: fetchImpl as unknown as typeof fetch, + requestTimeoutMs: 20, + }).then( + () => { + throw new Error('expected the start request to reject'); + }, + (reason: unknown) => reason + ); + + expect(error).toBeInstanceOf(CloudAuthError); + expect((error as CloudAuthError).code).toBe('AUTH_DEVICE_FLOW_FAILED'); + }); }); describe('device authorization poll', () => { @@ -303,6 +386,59 @@ describe('device authorization poll', () => { expect(fetchImpl.mock.calls.length).toBeLessThanOrEqual(6); }); + it('bounds every poll request so a stalled socket cannot hang the wait', async () => { + const { hooks, fetchImpl } = stallHarness([ + 'stall', + jsonResponse({ + access_token: 'cld_at_abc', + refresh_token: 'cld_rt_abc', + access_token_expires_at: '2026-08-07T00:00:00.000Z', + }), + ]); + + await pollForDeviceToken(API_URL, AUTHORIZATION, hooks); + + // Both polls must carry an abort budget, not just the first. + for (const call of fetchImpl.mock.calls) { + const [, init] = call as unknown as [URL, RequestInit]; + expect(init.signal).toBeInstanceOf(AbortSignal); + } + }); + + it('retries a stalled poll rather than discarding an approval', async () => { + // A stall is the same class of failure as the transient 5xx above: the + // request died, but the grant the human may already have approved is + // still claimable. Giving up here would make them redo the whole flow. + const { hooks, sleeps, logs } = stallHarness([ + 'stall', + jsonResponse({ + access_token: 'cld_at_abc', + refresh_token: 'cld_rt_abc', + access_token_expires_at: '2026-08-07T00:00:00.000Z', + }), + ]); + + const auth = await pollForDeviceToken(API_URL, AUTHORIZATION, hooks); + + expect(auth.accessToken).toBe('cld_at_abc'); + // Backed off after the stall rather than retrying at pace. + expect(sleeps).toEqual([5000, 10_000]); + // And said so: a slow network must not look like a hang. + expect(logs).toContainEqual( + expect.stringContaining('No response from https://agentrelay.com within 20ms') + ); + }); + + it('gives up with the expiry error when stalls outlast the grant', async () => { + const { hooks, fetchImpl } = stallHarness(Array.from({ length: 20 }, () => 'stall' as const)); + + await expect( + pollForDeviceToken(API_URL, { ...AUTHORIZATION, expiresInSeconds: 60 }, hooks) + ).rejects.toThrow(/expired before it was approved/); + // Bounded by the grant, not looping forever on a dead host. + expect(fetchImpl.mock.calls.length).toBeLessThanOrEqual(6); + }); + it('surfaces an unexpected error code instead of looping on it', async () => { const { hooks } = harness([jsonResponse({ error: 'invalid_client' }, { status: 400 })]); diff --git a/packages/cloud/src/device-auth.ts b/packages/cloud/src/device-auth.ts index 0ffc25c2e..099c9594d 100644 --- a/packages/cloud/src/device-auth.ts +++ b/packages/cloud/src/device-auth.ts @@ -22,6 +22,14 @@ const DEFAULT_INTERVAL_SECONDS = 5; const SLOW_DOWN_INCREMENT_SECONDS = 5; const MAX_INTERVAL_SECONDS = 60; const DEFAULT_EXPIRES_IN_SECONDS = 600; +/** + * Node's `fetch` has no default timeout, so a stalled TCP connection never + * settles and leaves `cloud login --device` hanging with no output and no + * error. That is the worst failure this feature can have: it exists for a + * headless host reached over ssh, where nobody is watching a terminal, and a + * silent hang there is strictly worse than a visible failure. + */ +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; export type DeviceAuthorization = { deviceCode: string; @@ -38,6 +46,8 @@ export type DeviceFlowHooks = { now?: () => number; fetchImpl?: typeof fetch; log?: (message: string) => void; + /** Per-request abort budget. Defaults to {@link DEFAULT_REQUEST_TIMEOUT_MS}. */ + requestTimeoutMs?: number; }; type DeviceTokenErrorCode = @@ -52,6 +62,30 @@ function deviceError(message: string): CloudAuthError { return new CloudAuthError('AUTH_DEVICE_FLOW_FAILED', message); } +function normalizeTimeout(ms: number | undefined): number { + return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? ms : DEFAULT_REQUEST_TIMEOUT_MS; +} + +function formatDuration(ms: number): string { + return ms >= 1000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms)}ms`; +} + +/** + * `AbortSignal.timeout` rejects with a `TimeoutError`, but fetch stacks differ + * in whether they surface it directly, as an `AbortError`, or wrapped in a + * `cause` chain. Walk the chain rather than trusting one shape. + */ +function isRequestTimeout(error: unknown): boolean { + let current: unknown = error; + for (let depth = 0; current instanceof Error && depth < 4; depth += 1) { + if (current.name === 'TimeoutError' || current.name === 'AbortError') { + return true; + } + current = (current as { cause?: unknown }).cause; + } + return false; +} + function clampInterval(seconds: number): number { if (!Number.isFinite(seconds) || seconds <= 0) { return DEFAULT_INTERVAL_SECONDS; @@ -86,10 +120,11 @@ export function isHeadlessEnvironment( export async function startDeviceAuthorization( apiUrl: string, - options: { clientName?: string; fetchImpl?: typeof fetch } = {} + options: { clientName?: string; fetchImpl?: typeof fetch; requestTimeoutMs?: number } = {} ): Promise { const fetchImpl = options.fetchImpl ?? fetch; const clientName = options.clientName ?? os.hostname(); + const timeoutMs = normalizeTimeout(options.requestTimeoutMs); let response: Response; try { @@ -97,8 +132,18 @@ export async function startDeviceAuthorization( method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ client_name: clientName }), + // Nothing has been printed yet at this point, so a hang here shows the + // user a bare cursor forever. Fail loudly instead. + signal: AbortSignal.timeout(timeoutMs), }); } catch (error) { + if (isRequestTimeout(error)) { + throw new CloudAuthError( + 'AUTH_DEVICE_FLOW_FAILED', + `Timed out after ${formatDuration(timeoutMs)} trying to reach ${apiUrl} to start device login`, + { cause: error } + ); + } throw new CloudAuthError('AUTH_DEVICE_FLOW_FAILED', `Could not reach ${apiUrl} to start device login`, { cause: error, }); @@ -159,6 +204,8 @@ export async function pollForDeviceToken( const fetchImpl = hooks.fetchImpl ?? fetch; const sleep = hooks.sleep ?? ((ms: number) => delay(ms)); const now = hooks.now ?? (() => Date.now()); + const log = hooks.log ?? ((message: string) => console.log(message)); + const requestTimeoutMs = normalizeTimeout(hooks.requestTimeoutMs); let intervalSeconds = authorization.intervalSeconds; const deadline = now() + authorization.expiresInSeconds * 1000; @@ -175,6 +222,11 @@ export async function pollForDeviceToken( ); } + // Bound every poll, and never past the grant deadline: a stalled socket + // must not outlive the grant it is waiting on, or the loop's own expiry + // check never gets to run. + const pollTimeoutMs = Math.max(1, Math.min(requestTimeoutMs, deadline - now())); + let response: Response; try { response = await fetchImpl(buildApiUrl(apiUrl, '/api/v1/auth/device/token'), { @@ -184,8 +236,23 @@ export async function pollForDeviceToken( grant_type: DEVICE_GRANT_TYPE, device_code: authorization.deviceCode, }), + signal: AbortSignal.timeout(pollTimeoutMs), }); } catch (error) { + if (isRequestTimeout(error)) { + if (now() >= deadline) { + throw deviceError( + 'Device login expired before it was approved. Run the command again to get a new code.' + ); + } + // Same class as a 5xx: the request stalled, but the approval the human + // may already have given is still claimable. Back off and try again + // rather than discarding it — the deadline above bounds the loop, and + // saying so out loud keeps a slow network from looking like a hang. + log(`No response from ${apiUrl} within ${formatDuration(pollTimeoutMs)}; retrying...`); + intervalSeconds = clampInterval(intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS); + continue; + } throw new CloudAuthError( 'AUTH_DEVICE_FLOW_FAILED', `Lost connection to ${apiUrl} while waiting for approval`, @@ -307,6 +374,7 @@ export async function runDeviceAuthorizationFlow( const authorization = await startDeviceAuthorization(apiUrl, { ...(options.clientName ? { clientName: options.clientName } : {}), ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), + ...(options.requestTimeoutMs !== undefined ? { requestTimeoutMs: options.requestTimeoutMs } : {}), }); log(formatDeviceInstructions(authorization)); From 1e3f0169b82568cf9cfc98893d09fd7cdc6c7a30 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 6 Aug 2026 07:00:03 +0200 Subject: [PATCH 5/9] fix(cli): stop attributing a login method to runs that logged nobody in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `method` was computed before the already-logged-in short-circuit, and the `finally` block emitted `cloud_auth` with it regardless. A no-op invocation — live session, no `--force`, `ensureAuthenticated` never called — therefore reported `method: 'device'` or `'browser'` as though a flow had run. The whole point of the field is measuring headless adoption, so this inflates exactly the number it exists to produce, on the invocations that are cheapest to repeat. And it is the class of telemetry that later gets trusted. `method` is now assigned immediately before `ensureAuthenticated` and omitted from the event when it is still undefined, so the no-op path records the login attempt without claiming a style for it. Three CLI tests cover it; the omission case was confirmed red against the parent. Co-Authored-By: Claude Opus 5 --- packages/cli/src/cli/commands/cloud.test.ts | 43 +++++++++++++++++++++ packages/cli/src/cli/commands/cloud.ts | 10 +++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/cli/commands/cloud.test.ts b/packages/cli/src/cli/commands/cloud.test.ts index 234f4f02d..c705b61be 100644 --- a/packages/cli/src/cli/commands/cloud.test.ts +++ b/packages/cli/src/cli/commands/cloud.test.ts @@ -70,6 +70,7 @@ import { ensureCloudSession, readStoredAuth, } from '@agent-relay/cloud'; +import { track } from '../telemetry/index.js'; import { buildCloudSyncPatchExcludeArgs, registerCloudCommands, type CloudDependencies } from './cloud.js'; import { createDefaultAssignmentRunner } from './cloud-worker.js'; @@ -219,6 +220,48 @@ describe('registerCloudCommands', () => { expect.objectContaining({ device: true, force: true }) ); }); + + it('records the method that actually ran', async () => { + const { program } = createHarness(); + await program.parseAsync(['cloud', 'login', '--device'], { from: 'user' }); + + expect(vi.mocked(track)).toHaveBeenCalledWith( + 'cloud_auth', + expect.objectContaining({ action: 'login', method: 'device', success: true }) + ); + }); + + it('attributes an auto-selected device login to the device flow', async () => { + // The point of the field is headless adoption, and the auto fallback + // means counting `--device` alone would undercount it. + cloudMocks.isHeadlessEnvironment.mockReturnValue(true); + + const { program } = createHarness(); + await program.parseAsync(['cloud', 'login'], { from: 'user' }); + + expect(vi.mocked(track)).toHaveBeenCalledWith( + 'cloud_auth', + expect.objectContaining({ method: 'device' }) + ); + }); + + it('omits the method when the live-session short-circuit ran no flow', async () => { + // Reporting `method: 'device'` for an invocation that logged nobody in + // inflates exactly the headless-adoption metric the field exists to + // measure — and it is the kind of number that later gets trusted. + vi.mocked(readStoredAuth).mockResolvedValue({ + apiUrl: 'https://cloud.test', + accessTokenExpiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + }); + + const { program } = createHarness(); + await program.parseAsync(['cloud', 'login', '--device'], { from: 'user' }); + + expect(vi.mocked(ensureAuthenticated)).not.toHaveBeenCalled(); + const [, payload] = vi.mocked(track).mock.calls.at(-1) as [string, Record]; + expect(payload).toMatchObject({ action: 'login', success: true }); + expect(payload).not.toHaveProperty('method'); + }); }); it('registers cloud worker subcommands', () => { diff --git a/packages/cli/src/cli/commands/cloud.ts b/packages/cli/src/cli/commands/cloud.ts index 9198a4143..b3c1e1df9 100644 --- a/packages/cli/src/cli/commands/cloud.ts +++ b/packages/cli/src/cli/commands/cloud.ts @@ -613,8 +613,11 @@ export function registerCloudCommands(program: Command, overrides: Partial Date: Thu, 6 Aug 2026 07:00:19 +0200 Subject: [PATCH 6/9] test(cloud): cover the headless branch of headless detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two `CI` assertions each pinned a browser signal — first a `DISPLAY`, then a platform that always has `open`. A typical Linux runner has neither, so `isHeadlessEnvironment({ CI: 'true' }, 'linux')` returns true and selects the device flow, and that branch was the untested one in a headless-login feature. Pin it. `CI` never suppresses headless detection; it simply is not a signal on its own, and the absent display is what decides. The behaviour is right as it stands: unattended runs never reach this code, because a non-interactive `ensureCloudSession` throws "login required" before any flow is chosen, so only an explicit `cloud login` on a runner lands here — and there the device flow is the only one that can work. Co-Authored-By: Claude Opus 5 --- packages/cloud/src/device-auth.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/cloud/src/device-auth.test.ts b/packages/cloud/src/device-auth.test.ts index ae8c4dc59..a9d44b5f5 100644 --- a/packages/cloud/src/device-auth.test.ts +++ b/packages/cloud/src/device-auth.test.ts @@ -489,6 +489,22 @@ describe('headless detection', () => { expect(isHeadlessEnvironment({ CI: 'true' }, 'darwin')).toBe(false); }); + it('still reports headless on a CI host with no display at all', () => { + // Both cases above pin a browser signal — a display, then a platform that + // always has `open`. A typical Linux runner has neither, and this is the + // branch that actually decides the flow on one, so pin it rather than + // leaving the headless path of a headless-login feature untested. + // + // `CI` is not an escape hatch: it never *suppresses* headless detection, + // it simply is not a signal on its own. The absent display is what + // decides here. Unattended runs never reach this code anyway — a + // non-interactive `ensureCloudSession` throws "login required" before any + // flow is chosen — so only an explicit `cloud login` on a runner lands + // here, and there the device flow is the one that can work. + expect(isHeadlessEnvironment({ CI: 'true' }, 'linux')).toBe(true); + expect(isHeadlessEnvironment({ CI: 'true', GITHUB_ACTIONS: 'true' }, 'linux')).toBe(true); + }); + it('treats a Unix host with no display server as headless', () => { expect(isHeadlessEnvironment({}, 'linux')).toBe(true); expect(isHeadlessEnvironment({ DISPLAY: ':0' }, 'linux')).toBe(false); From 8b5f8ce241cab5f9432888f67690295c7f49692c Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 6 Aug 2026 07:11:35 +0200 Subject: [PATCH 7/9] fix(cloud): keep the device-flow timeout budget inside Node's timer limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the two P2s cubic raised against the timeout commit. Both are real, and both turn the timeout budget back into the failure it was added to prevent. `AbortSignal.timeout` throws a RangeError on a fractional delay and silently collapses anything past the 32-bit timer range to 1ms — an immediate abort. The fractional case does not need a careless caller to reach: the poll budget is derived from the grant deadline, which is derived from the server's `expires_in`, so a non-integer `expires_in` is enough. And because the signal is constructed inside the fetch call, the RangeError was caught by the connection handler and misreported as "Could not reach " — a wrong diagnosis of a bug in our own arithmetic. Delays are now floored and clamped to [1, 2^31-1] before they reach a timer. Separately, the poll slept a full widened interval before re-checking the deadline, so a 60s backoff with 5s of grant left sat on the expiry report for most of a minute. Every retry path widens the interval, so this was reachable from `slow_down`, from a transient 5xx, and from the new stall retry. The wait is now capped at the remaining grant lifetime, which makes expiry land on time rather than a backoff step late — the same "looks like a hang" failure the request timeouts address, one level up. Three tests, each confirmed red against b8200a55e. The sleep-cap test needed a 25s grant to discriminate: against 30s the 5/10/15 backoff lands exactly on the deadline and passes either way. Co-Authored-By: Claude Opus 5 --- packages/cloud/src/device-auth.test.ts | 61 ++++++++++++++++++++++++++ packages/cloud/src/device-auth.ts | 31 +++++++++++-- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/packages/cloud/src/device-auth.test.ts b/packages/cloud/src/device-auth.test.ts index a9d44b5f5..5e69e3580 100644 --- a/packages/cloud/src/device-auth.test.ts +++ b/packages/cloud/src/device-auth.test.ts @@ -165,6 +165,48 @@ describe('device authorization start', () => { expect(init.signal).toBeInstanceOf(AbortSignal); }); + it('survives a timeout budget Node timers cannot take literally', async () => { + // `AbortSignal.timeout` throws a RangeError on a fractional delay. Thrown + // from inside the fetch call it would be caught as a connection failure + // and misreported as "Could not reach", so normalize before handing it + // over rather than relying on the caller. + const fetchImpl = stallingFetch(); + + await expect( + startDeviceAuthorization(API_URL, { + fetchImpl: fetchImpl as unknown as typeof fetch, + requestTimeoutMs: 20.7, + }) + ).rejects.toThrow(/Timed out after/); + }); + + it('does not let an oversized budget collapse into an instant abort', async () => { + // Node timers are 32-bit: past ~24.9 days the delay silently becomes 1ms, + // turning "wait a long time" into "fail immediately". + const fetchImpl = vi.fn(async (_url: unknown, init: RequestInit = {}) => { + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(init.signal?.aborted).toBe(false); + return jsonResponse( + { + device_code: 'cld_dc_test', + user_code: 'BCDF-GHJK', + verification_uri: 'https://agentrelay.com/cloud/device', + verification_uri_complete: 'https://agentrelay.com/cloud/device?user_code=BCDF-GHJK', + expires_in: 600, + interval: 5, + }, + { status: 201 } + ); + }); + + const result = await startDeviceAuthorization(API_URL, { + fetchImpl: fetchImpl as unknown as typeof fetch, + requestTimeoutMs: 3e9, + }); + + expect(result.userCode).toBe('BCDF-GHJK'); + }); + it('reports a timeout as a CloudAuthError, not a bare DOMException', async () => { const fetchImpl = stallingFetch(); @@ -439,6 +481,25 @@ describe('device authorization poll', () => { expect(fetchImpl.mock.calls.length).toBeLessThanOrEqual(6); }); + it('never sleeps past the grant, so expiry is reported promptly', async () => { + // Every retry path widens the interval. Without a cap, a widened interval + // outlives the remaining grant and the client sits on the expiry report + // instead of delivering it — the same "looks like a hang" failure the + // request timeouts exist to prevent, one level up. + const { hooks, sleeps } = harness( + Array.from({ length: 10 }, () => jsonResponse({ error: 'slow_down' }, { status: 400 })) + ); + + await expect( + pollForDeviceToken(API_URL, { ...AUTHORIZATION, expiresInSeconds: 25 }, hooks) + ).rejects.toThrow(/expired before it was approved/); + + // Backoff would reach 5 + 10 + 15 = 30s against a 25s grant; the last wait + // is trimmed to the 10s actually left, so expiry lands on time. + expect(sleeps).toEqual([5000, 10_000, 10_000]); + expect(sleeps.reduce((total, ms) => total + ms, 0)).toBe(25_000); + }); + it('surfaces an unexpected error code instead of looping on it', async () => { const { hooks } = harness([jsonResponse({ error: 'invalid_client' }, { status: 400 })]); diff --git a/packages/cloud/src/device-auth.ts b/packages/cloud/src/device-auth.ts index 099c9594d..d4f8293dd 100644 --- a/packages/cloud/src/device-auth.ts +++ b/packages/cloud/src/device-auth.ts @@ -30,6 +30,8 @@ const DEFAULT_EXPIRES_IN_SECONDS = 600; * silent hang there is strictly worse than a visible failure. */ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +/** Node timers are 32-bit; past this a delay silently becomes 1ms. */ +const MAX_TIMER_DELAY_MS = 2_147_483_647; export type DeviceAuthorization = { deviceCode: string; @@ -62,8 +64,27 @@ function deviceError(message: string): CloudAuthError { return new CloudAuthError('AUTH_DEVICE_FLOW_FAILED', message); } +/** + * `AbortSignal.timeout` throws a `RangeError` on a fractional delay, and + * silently collapses anything past the 32-bit timer range to 1ms — an + * immediate abort. Either turns a timeout budget into an instant failure, and + * the `RangeError` would surface from inside the fetch call as the misleading + * "Could not reach ". A fractional value is reachable without a bad + * caller: the poll budget is derived from the deadline, which is derived from + * the server's `expires_in`. + */ +function clampTimerDelay(ms: number): number { + if (!Number.isFinite(ms)) { + return MAX_TIMER_DELAY_MS; + } + return Math.min(MAX_TIMER_DELAY_MS, Math.max(1, Math.floor(ms))); +} + function normalizeTimeout(ms: number | undefined): number { - return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? ms : DEFAULT_REQUEST_TIMEOUT_MS; + if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) { + return DEFAULT_REQUEST_TIMEOUT_MS; + } + return clampTimerDelay(ms); } function formatDuration(ms: number): string { @@ -213,8 +234,10 @@ export async function pollForDeviceToken( for (;;) { // Wait first: the user cannot possibly have approved in the microseconds // since the grant was minted, and polling immediately only earns a - // `slow_down`. - await sleep(intervalSeconds * 1000); + // `slow_down`. Never wait past the grant, though — every retry path here + // widens the interval, and a 60s interval with 5s of grant left would sit + // on the expiry report for most of a minute before delivering it. + await sleep(Math.min(intervalSeconds * 1000, Math.max(0, deadline - now()))); if (now() >= deadline) { throw deviceError( @@ -225,7 +248,7 @@ export async function pollForDeviceToken( // Bound every poll, and never past the grant deadline: a stalled socket // must not outlive the grant it is waiting on, or the loop's own expiry // check never gets to run. - const pollTimeoutMs = Math.max(1, Math.min(requestTimeoutMs, deadline - now())); + const pollTimeoutMs = clampTimerDelay(Math.min(requestTimeoutMs, deadline - now())); let response: Response; try { From 50a37df3945d8321937b1cf207db4d72bd06d790 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 6 Aug 2026 08:25:56 +0200 Subject: [PATCH 8/9] fix(cloud): re-authenticate a headless host through the device flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `authorizedApiFetch` called `loginWithBrowser` directly when a 401 could not be refreshed, bypassing the `loginInteractive` selector that `ensureCloudSession` uses. A headless machine could therefore complete the device flow once and then hang on its first re-auth, waiting on a loopback callback it has no way to complete — the feature failed exactly in the steady state it exists for. Route that fallback through `loginInteractive`. `authorizedApiFetch` gains optional `device` and `env` options; nothing mid-request carries an explicit `--device`, so left unset the selector still auto-detects a headless host, which is the behaviour that matters here. Also condenses the device-login changelog entry per review. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- packages/cloud/src/auth.test.ts | 140 ++++++++++++++++++++++++++++++++ packages/cloud/src/auth.ts | 21 ++++- 3 files changed, 160 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e836db95e..91620d906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `agent-relay cloud login --device` authenticates a machine that has no browser, using the OAuth device flow: the CLI prints a short code, you approve it in a browser on any other device, and the headless machine writes its own `cloud-auth.json`. Login now falls back to this automatically over SSH or on a Unix host with no display server, instead of waiting on a loopback callback that can never arrive. Each machine gets its own cloud session, so copying `cloud-auth.json` between hosts — which silently logs them out of each other as refresh tokens rotate — is no longer necessary. Requires cloud with the device authorization endpoints. +- `agent-relay cloud login --device` logs in a machine with no browser through the OAuth device flow: the CLI prints a code you approve from any other device. Login and re-authentication fall back to it automatically over SSH or on a Unix host with no display server, and each machine gets its own cloud session instead of a copied `cloud-auth.json`. Requires cloud with the device authorization endpoints. ### Fixed diff --git a/packages/cloud/src/auth.test.ts b/packages/cloud/src/auth.test.ts index a3a74c64d..e6b1b67d8 100644 --- a/packages/cloud/src/auth.test.ts +++ b/packages/cloud/src/auth.test.ts @@ -827,6 +827,146 @@ describe('authorizedApiFetch telemetry headers', () => { }); }); +describe('authorizedApiFetch re-login', () => { + it('re-authenticates a headless host through the device flow, not the browser', async () => { + // The steady state this feature exists for: barry logged in once over ssh + // with `--device`, and now a request 401s with a refresh token the server + // will not renew. Sending that host to the browser flow would park it on a + // loopback callback nobody can ever complete. + vi.stubEnv('SSH_CONNECTION', '10.0.0.2 54321 10.0.0.1 22'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + let protectedCalls = 0; + const fetchSpy = vi.fn(async (input: string | URL) => { + const url = String(input); + + if (url.includes('/api/v1/auth/token/refresh')) { + // Refresh token revoked/rotated away — the branch that falls through + // to an interactive login. + return new Response('{}', { status: 401 }); + } + if (url.includes('/api/v1/auth/device/start')) { + return new Response( + JSON.stringify({ + device_code: 'cld_dc_reauth', + user_code: 'MNPQ-RSTV', + verification_uri: 'https://api.example.test/cloud/device', + expires_in: 600, + interval: 1, + }), + { status: 201, headers: { 'content-type': 'application/json' } } + ); + } + if (url.includes('/api/v1/auth/device/token')) { + return new Response( + JSON.stringify({ + access_token: 'reauth-access', + refresh_token: 'reauth-refresh', + access_token_expires_at: '2999-01-01T00:00:00.000Z', + api_url: 'https://api.example.test', + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + } + if (url.includes('/api/v1/auth/whoami')) { + return new Response('{}', { status: 500 }); + } + + protectedCalls += 1; + // First attempt is unauthorized; the retry after re-login succeeds. + return protectedCalls === 1 + ? new Response('{}', { status: 401 }) + : new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchSpy); + + const { response, auth } = await authorizedApiFetch( + { + apiUrl: 'https://api.example.test', + accessToken: 'stale-access', + refreshToken: 'stale-refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }, + '/api/v1/workflows/run', + { method: 'POST' } + ); + + expect(response.status).toBe(200); + expect(auth).toMatchObject({ accessToken: 'reauth-access', refreshToken: 'reauth-refresh' }); + + const requested = fetchSpy.mock.calls.map((call) => String(call[0])); + expect(requested.some((url) => url.includes('/api/v1/auth/device/start'))).toBe(true); + // The browser flow is what this fix routes around: no browser launch, and + // the retried request carries the token the device flow just issued. + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + const retryInit = fetchSpy.mock.calls.at(-1)?.[1] as RequestInit; + expect(new Headers(retryInit.headers).get('authorization')).toBe('Bearer reauth-access'); + + logSpy.mockRestore(); + }); + + it('still uses the browser flow on a host that has one', async () => { + // The selector only changes which flow a headless host gets. Everywhere + // else the browser flow stays the default, and it must still complete. + const realFetch = globalThis.fetch; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + let protectedCalls = 0; + const fetchSpy = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.includes('/api/v1/auth/token/refresh')) { + return new Response('{}', { status: 401 }); + } + if (url.includes('/api/v1/auth/device/')) { + throw new Error('device flow must not run on a host with a browser'); + } + if (url.includes('/api/v1/auth/whoami')) { + return new Response('{}', { status: 500 }); + } + protectedCalls += 1; + return protectedCalls === 1 + ? new Response('{}', { status: 401 }) + : new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchSpy); + + const pending = authorizedApiFetch( + { + apiUrl: 'https://api.example.test', + accessToken: 'stale-access', + refreshToken: 'stale-refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }, + '/api/v1/workflows/run', + { method: 'POST' } + ); + + await vi.waitFor(() => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Opening browser for cloud login: ')); + }); + + // Drive the loopback callback to completion so the login server closes + // instead of outliving the test. + const loginLine = logSpy.mock.calls + .map((call) => String(call[0])) + .find((line) => line.startsWith('Opening browser for cloud login: ')); + const loginUrl = new URL(String(loginLine).slice('Opening browser for cloud login: '.length)); + const callbackUrl = new URL(String(loginUrl.searchParams.get('redirect_uri'))); + callbackUrl.searchParams.set('state', String(loginUrl.searchParams.get('state'))); + callbackUrl.searchParams.set('access_token', 'browser-access'); + callbackUrl.searchParams.set('refresh_token', 'browser-refresh'); + callbackUrl.searchParams.set('access_token_expires_at', '2999-01-01T00:00:00.000Z'); + callbackUrl.searchParams.set('api_url', 'https://api.example.test'); + await realFetch(callbackUrl, { redirect: 'manual' }); + + const { auth } = await pending; + expect(auth).toMatchObject({ accessToken: 'browser-access' }); + expect(childProcessMocks.spawn).toHaveBeenCalled(); + + logSpy.mockRestore(); + }); +}); + describe('cloud identity capture', () => { const WHOAMI_URL = 'https://api.example.test/api/v1/auth/whoami'; diff --git a/packages/cloud/src/auth.ts b/packages/cloud/src/auth.ts index c863c01ae..70000ea19 100644 --- a/packages/cloud/src/auth.ts +++ b/packages/cloud/src/auth.ts @@ -806,7 +806,17 @@ export async function authorizedApiFetch( auth: StoredAuth, requestPath: string, init: RequestInit, - options: { interactive?: boolean; refreshTimeoutMs?: number } = {} + options: { + interactive?: boolean; + refreshTimeoutMs?: number; + /** + * Force the device flow for the re-login below. Callers here are mid-request + * rather than mid-`login`, so nobody passes an explicit `--device`; left + * unset, a headless host still picks the device flow automatically. + */ + device?: boolean; + env?: NodeJS.ProcessEnv; + } = {} ): Promise<{ response: Response; auth: StoredAuth }> { let activeAuth = auth; let response = await apiFetch(activeAuth.apiUrl, activeAuth.accessToken, requestPath, init); @@ -830,7 +840,14 @@ export async function authorizedApiFetch( throw error; } - activeAuth = await loginWithBrowser(activeAuth.apiUrl); + // Must go through the same selector `ensureCloudSession` uses. Calling the + // browser flow directly here stranded exactly the machine this feature + // exists for: a headless host completes the device flow once, then its + // first re-auth sits on a loopback callback it can never reach. + activeAuth = await loginInteractive(activeAuth.apiUrl, { + device: options.device, + env: options.env, + }); } response = await apiFetch(activeAuth.apiUrl, activeAuth.accessToken, requestPath, init); From 1d3c997a2e8f262cc3eedbb603a5544764dabb22 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 6 Aug 2026 09:00:21 +0200 Subject: [PATCH 9/9] fix(cloud): let a cancelled request win over device re-authentication Routing re-auth through the device flow made cancellation matter more than it did under the browser flow: the device grant blocks for its full lifetime, so an aborted request that fell through to a login left a cancelled CLI or workflow waiting on authorization nobody asked for. Check init.signal?.aborted before loginInteractive and surface signal.reason, matching the abort idiom already used for the auth lock. Also restore vi.spyOn spies in an afterEach: clearAllMocks resets call history but leaves spies installed, so a test failing mid-body silenced console.log for every later test in the file - exactly when a failure needs the output. --- packages/cloud/src/auth.test.ts | 50 ++++++++++++++++++++++++++++++++- packages/cloud/src/auth.ts | 8 ++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/cloud/src/auth.test.ts b/packages/cloud/src/auth.test.ts index e6b1b67d8..49773972f 100644 --- a/packages/cloud/src/auth.test.ts +++ b/packages/cloud/src/auth.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const fsMocks = vi.hoisted(() => ({ readFile: vi.fn(), @@ -66,6 +66,13 @@ function createEnvAuth(overrides: Partial = {}): NodeJS.ProcessEnv { }; } +// `clearAllMocks` resets call history but leaves `vi.spyOn` spies installed, so +// a test that failed mid-body used to leave `console.log` silenced for every +// later test in this file — silencing output exactly when a failure needs it. +afterEach(() => { + vi.restoreAllMocks(); +}); + beforeEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); @@ -905,6 +912,47 @@ describe('authorizedApiFetch re-login', () => { logSpy.mockRestore(); }); + it('returns the caller cancellation instead of starting a device login', async () => { + // Routing re-auth through the device flow made cancellation matter more: + // the device grant blocks for minutes, so an aborted request that starts a + // login leaves a cancelled CLI or workflow waiting on authorization nobody + // asked for. The abort must win before any flow begins. + vi.stubEnv('SSH_CONNECTION', '10.0.0.2 54321 10.0.0.1 22'); + const controller = new AbortController(); + + const fetchSpy = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.includes('/api/v1/auth/device/')) { + throw new Error('a cancelled request must not start the device flow'); + } + if (url.includes('/api/v1/auth/token/refresh')) { + // Abort while the refresh is in flight, then fail it: this is the + // exact interleaving where the old code fell through to a login. + controller.abort(); + return new Response('{}', { status: 401 }); + } + return new Response('{}', { status: 401 }); + }); + vi.stubGlobal('fetch', fetchSpy); + + await expect( + authorizedApiFetch( + { + apiUrl: 'https://api.example.test', + accessToken: 'stale-access', + refreshToken: 'stale-refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }, + '/api/v1/workflows/run', + { method: 'POST', signal: controller.signal } + ) + ).rejects.toThrow(); + + const requested = fetchSpy.mock.calls.map((call) => String(call[0])); + expect(requested.some((url) => url.includes('/api/v1/auth/device/start'))).toBe(false); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + it('still uses the browser flow on a host that has one', async () => { // The selector only changes which flow a headless host gets. Everywhere // else the browser flow stays the default, and it must still complete. diff --git a/packages/cloud/src/auth.ts b/packages/cloud/src/auth.ts index 70000ea19..6a052f73b 100644 --- a/packages/cloud/src/auth.ts +++ b/packages/cloud/src/auth.ts @@ -840,6 +840,14 @@ export async function authorizedApiFetch( throw error; } + // A caller that already aborted gets its cancellation back, not a login. + // The device flow blocks for the grant lifetime — minutes — so starting one + // here would leave a cancelled CLI or workflow waiting on authorization it + // never asked for. The browser flow masked this by resolving sooner. + if (init.signal?.aborted) { + throw init.signal.reason ?? new Error('Cloud request aborted before re-authentication'); + } + // Must go through the same selector `ensureCloudSession` uses. Calling the // browser flow directly here stranded exactly the machine this feature // exists for: a headless host completes the device flow once, then its