diff --git a/CHANGELOG.md b/CHANGELOG.md index da433bd49..91620d906 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` 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/cli/src/cli/commands/cloud.test.ts b/packages/cli/src/cli/commands/cloud.test.ts index 7b4f6c969..c705b61be 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,13 @@ 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 { track } from '../telemetry/index.js'; import { buildCloudSyncPatchExcludeArgs, registerCloudCommands, type CloudDependencies } from './cloud.js'; import { createDefaultAssignmentRunner } from './cloud-worker.js'; @@ -149,6 +157,113 @@ 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('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', () => { 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..b3c1e1df9 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,23 @@ 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. Left undefined + // until a login actually runs — a no-op invocation that short-circuits + // on a live session performed no flow, and attributing one to it would + // overcount whichever method the host happens to prefer. + let method: 'browser' | 'device' | undefined; try { const apiUrl = options.apiUrl || defaultApiUrl(); @@ -622,7 +633,8 @@ export function registerCloudCommands(program: Command, overrides: Partial ({ readFile: vi.fn(), @@ -66,9 +66,26 @@ 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(); + 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 +382,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({ @@ -749,6 +834,187 @@ 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('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. + 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 fbf4925da..6a052f73b 100644 --- a/packages/cloud/src/auth.ts +++ b/packages/cloud/src/auth.ts @@ -15,6 +15,7 @@ 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 +627,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 +646,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 +714,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 +742,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 }); } } @@ -754,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); @@ -778,7 +840,22 @@ export async function authorizedApiFetch( throw error; } - activeAuth = await loginWithBrowser(activeAuth.apiUrl); + // 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 + // 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); diff --git a/packages/cloud/src/device-auth.test.ts b/packages/cloud/src/device-auth.test.ts new file mode 100644 index 000000000..5e69e3580 --- /dev/null +++ b/packages/cloud/src/device-auth.test.ts @@ -0,0 +1,620 @@ +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, + }, + }; +} + +/** + * 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 () => + 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/); + }); + + 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('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(); + + 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', () => { + 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); + }); + + // 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('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('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 })]); + + 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('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); + 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..d4f8293dd --- /dev/null +++ b/packages/cloud/src/device-auth.ts @@ -0,0 +1,408 @@ +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; +/** + * 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; +/** 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; + 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; + /** Per-request abort budget. Defaults to {@link DEFAULT_REQUEST_TIMEOUT_MS}. */ + requestTimeoutMs?: number; +}; + +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); +} + +/** + * `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 { + if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) { + return DEFAULT_REQUEST_TIMEOUT_MS; + } + return clampTimerDelay(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; + } + 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; requestTimeoutMs?: number } = {} +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const clientName = options.clientName ?? os.hostname(); + const timeoutMs = normalizeTimeout(options.requestTimeoutMs); + + 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 }), + // 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, + }); + } + + 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()); + const log = hooks.log ?? ((message: string) => console.log(message)); + const requestTimeoutMs = normalizeTimeout(hooks.requestTimeoutMs); + + 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`. 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( + 'Device login expired before it was approved. Run the command again to get a new code.' + ); + } + + // 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 = clampTimerDelay(Math.min(requestTimeoutMs, deadline - now())); + + 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, + }), + 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`, + { 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; + } + + // 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 + // 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 } : {}), + ...(options.requestTimeoutMs !== undefined ? { requestTimeoutMs: options.requestTimeoutMs } : {}), + }); + + 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; };