Skip to content
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
117 changes: 116 additions & 1 deletion packages/cli/src/cli/commands/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand All @@ -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(),
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>];
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');
Expand Down
19 changes: 16 additions & 3 deletions packages/cli/src/cli/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
ensureAuthenticated,
ensureCloudSession,
authorizedApiFetch,
isHeadlessEnvironment,
readStoredAuth,
clearStoredAuth,
defaultApiUrl,
Expand Down Expand Up @@ -440,7 +441,7 @@
};
} catch (error) {
if (isCloudLoginError(error)) {
throw new Error('Cloud login required. Run `agent-relay cloud login` and retry.');

Check warning on line 444 in packages/cli/src/cli/commands/cloud.ts

View workflow job for this annotation

GitHub Actions / lint

There is no `cause` attached to the symptom error being thrown
}
throw error;
}
Expand Down Expand Up @@ -600,13 +601,23 @@

cloudCommand
.command('login')
.description('Authenticate with Agent Relay Cloud via browser (alias of `relay login`)')
.description('Authenticate with Agent Relay Cloud (alias of `relay login`)')
.option('--api-url <url>', '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();

Expand All @@ -622,14 +633,16 @@
}
}

await ensureAuthenticated(apiUrl, { force: options.force });
method = options.device === true || isHeadlessEnvironment() ? 'device' : 'browser';
await ensureAuthenticated(apiUrl, { force: options.force, device: options.device });
success = true;
} catch (err) {
errorClass = errorClassName(err);
throw err;
} finally {
track('cloud_auth', {
action: 'login',
...(method ? { method } : {}),
success,
duration_ms: Date.now() - started,
...(errorClass ? { error_class: errorClass } : {}),
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ export interface CloudAuthEvent {
duration_ms: number;
/** Provider id for `connect` flows (e.g., 'anthropic', 'openai') */
provider?: string;
/** Which login style ran; only present for `login` */
method?: 'browser' | 'device';
/** Error constructor name on failure */
error_class?: string;
}
Expand Down
Loading
Loading