diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bebab954a..888b55ba6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -349,6 +349,9 @@ jobs: - name: Install dependencies run: node scripts/npm-retry.mjs ci + - name: Build server stack + run: npm run build:server + - name: Install agent CLIs for provider tests run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai diff --git a/package-lock.json b/package-lock.json index 2bba6a576..6357980ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36247,7 +36247,8 @@ "mime-types": "^2.1.35", "tree-kill": "^1.2.2", "ws": "^8.14.2", - "yaml": "^2.8.4" + "yaml": "^2.8.4", + "zod": "^4.4.3" }, "bin": { "paseo": "bin/paseo" diff --git a/packages/cli/package.json b/packages/cli/package.json index bc0333db2..15fba8ca5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,7 +20,8 @@ "build:clean": "npm run clean && npm run build", "prepack": "npm run build:clean", "typecheck": "tsgo --noEmit", - "test": "npm run test:local", + "test": "npm run test:unit && npm run test:local", + "test:unit": "vitest run src", "test:local": "tsx tests/run-all.ts", "test:e2e": "npm run test:local", "test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts" @@ -35,7 +36,8 @@ "mime-types": "^2.1.35", "tree-kill": "^1.2.2", "ws": "^8.14.2", - "yaml": "^2.8.4" + "yaml": "^2.8.4", + "zod": "^4.4.3" }, "devDependencies": { "@types/mime-types": "^3.0.1", diff --git a/packages/cli/src/commands/hub/cloud-device-authorization.test.ts b/packages/cli/src/commands/hub/cloud-device-authorization.test.ts new file mode 100644 index 000000000..4a4145223 --- /dev/null +++ b/packages/cli/src/commands/hub/cloud-device-authorization.test.ts @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { describe, it } from "vitest"; +import { CloudDeviceAuthorizationClient } from "./cloud-device-authorization.js"; + +describe("Cloud device authorization", () => { + it("accepts loopback HTTP activation URLs", async () => { + const cloud = await RegistrationCloud.start("loopback-authorization"); + try { + const authorization = await new CloudDeviceAuthorizationClient().start( + cloud.origin, + "Studio Mac", + ); + + assert.equal(authorization.verificationUri, `${cloud.origin}/activate`); + assert.equal( + authorization.verificationUriComplete, + `${cloud.origin}/activate?code=ABCD-EFGH-JKLMN`, + ); + } finally { + await cloud.stop(); + } + }); + + it("rejects a non-web activation URL at the Cloud boundary", async () => { + const cloud = await RegistrationCloud.start("non-web-authorization"); + try { + await assert.rejects(new CloudDeviceAuthorizationClient().start(cloud.origin, "Studio Mac"), { + name: "ZodError", + }); + assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/"]); + } finally { + await cloud.stop(); + } + }); + + it("fails when start headers arrive but the response body stalls", async () => { + const cloud = await RegistrationCloud.start("stalled-start-body"); + try { + await assert.rejects( + new CloudDeviceAuthorizationClient(100).start(cloud.origin, "Studio Mac"), + { message: "Cloud registration start timed out" }, + ); + assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/"]); + } finally { + await cloud.stop(); + } + }); + + it("retries when poll headers arrive but the response body stalls", async () => { + const cloud = await RegistrationCloud.start("stalled-poll-body"); + try { + const outcome = await new CloudDeviceAuthorizationClient().poll( + cloud.origin, + "device-code-with-more-than-thirty-two-characters", + 100, + ); + + assert.deepEqual(outcome, { status: "retry_later" }); + assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/poll"]); + } finally { + await cloud.stop(); + } + }); + + it("retries when a poll response body resets after headers arrive", async () => { + const cloud = await RegistrationCloud.start("reset-poll-body"); + try { + const outcome = await new CloudDeviceAuthorizationClient().poll( + cloud.origin, + "device-code-with-more-than-thirty-two-characters", + 1_000, + ); + + assert.deepEqual(outcome, { status: "retry_later" }); + assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/poll"]); + } finally { + await cloud.stop(); + } + }); + + it("rejects a completed malformed poll response", async () => { + const cloud = await RegistrationCloud.start("malformed-poll-body"); + try { + await assert.rejects( + new CloudDeviceAuthorizationClient().poll( + cloud.origin, + "device-code-with-more-than-thirty-two-characters", + 1_000, + ), + { name: "SyntaxError" }, + ); + } finally { + await cloud.stop(); + } + }); + + it("rejects a completed poll response with an invalid shape", async () => { + const cloud = await RegistrationCloud.start("invalid-poll-body"); + try { + await assert.rejects( + new CloudDeviceAuthorizationClient().poll( + cloud.origin, + "device-code-with-more-than-thirty-two-characters", + 1_000, + ), + { name: "ZodError" }, + ); + } finally { + await cloud.stop(); + } + }); +}); + +type RegistrationCloudResponse = + | "loopback-authorization" + | "non-web-authorization" + | "stalled-start-body" + | "stalled-poll-body" + | "reset-poll-body" + | "malformed-poll-body" + | "invalid-poll-body"; + +class RegistrationCloud { + readonly receivedPaths: string[] = []; + + private constructor( + readonly origin: string, + private readonly server: Server, + ) {} + + static async start(responseBody: RegistrationCloudResponse): Promise { + let cloud: RegistrationCloud; + const server = createServer((request, response) => { + if (request.url !== undefined) cloud.receivedPaths.push(request.url); + response.writeHead(200, { "content-type": "application/json" }); + if (responseBody === "malformed-poll-body") { + response.end("not-json"); + return; + } + if (responseBody === "invalid-poll-body") { + response.end('{"status":"pending"}'); + return; + } + if (responseBody === "non-web-authorization") { + response.end( + JSON.stringify({ + deviceCode: "device-code-with-more-than-thirty-two-characters", + userCode: "ABCD-EFGH-JKLMN", + verificationUri: "https://cloud.paseo.test/activate", + verificationUriComplete: "file:///tmp/paseo-activate", + expiresAt: "2026-07-18T12:10:00.000Z", + interval: 5, + }), + ); + return; + } + if (responseBody === "loopback-authorization") { + response.end( + JSON.stringify({ + deviceCode: "device-code-with-more-than-thirty-two-characters", + userCode: "ABCD-EFGH-JKLMN", + verificationUri: `${cloud.origin}/activate`, + verificationUriComplete: `${cloud.origin}/activate?code=ABCD-EFGH-JKLMN`, + expiresAt: "2026-07-18T12:10:00.000Z", + interval: 5, + }), + ); + return; + } + if (responseBody === "stalled-start-body") { + response.write('{"deviceCode":"device-code-with-more-than-thirty-two-characters"'); + return; + } + if (responseBody === "reset-poll-body") { + response.flushHeaders(); + response.write('{"status":"pending"'); + setImmediate(() => response.socket?.destroy()); + return; + } + response.write('{"status":"pending","interval":5'); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + cloud = new RegistrationCloud(`http://127.0.0.1:${address.port}`, server); + return cloud; + } + + async stop(): Promise { + this.server.closeAllConnections(); + await new Promise((resolve, reject) => { + this.server.close((error) => { + if (error !== undefined) { + reject(error); + return; + } + resolve(); + }); + }); + } +} diff --git a/packages/cli/src/commands/hub/cloud-device-authorization.ts b/packages/cli/src/commands/hub/cloud-device-authorization.ts new file mode 100644 index 000000000..9bf832619 --- /dev/null +++ b/packages/cli/src/commands/hub/cloud-device-authorization.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; + +const START_TIMEOUT_MS = 15_000; +const activationUrlSchema = z.url({ protocol: /^https?$/u }); + +const authorizationSchema = z.object({ + deviceCode: z.string().min(32), + userCode: z.string().min(1), + verificationUri: activationUrlSchema, + verificationUriComplete: activationUrlSchema, + expiresAt: z.string().datetime(), + interval: z.number().int().min(5), +}); + +const pollSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("pending"), interval: z.number().int().min(5) }), + z.object({ status: z.literal("slow_down"), interval: z.number().int().min(5) }), + z.object({ + status: z.literal("approved"), + interval: z.number().int().min(5), + enrollmentToken: z.string().min(32), + }), + z.object({ status: z.literal("denied"), interval: z.number().int().min(5) }), + z.object({ status: z.literal("expired"), interval: z.number().int().min(5) }), + z.object({ status: z.literal("enrolled"), interval: z.number().int().min(5) }), + z.object({ status: z.literal("retry_later") }), +]); + +export type DeviceAuthorization = z.infer; +export type DeviceAuthorizationPoll = z.infer; + +export interface CloudDeviceAuthorization { + start(hubUrl: string, displayName: string): Promise; + poll( + hubUrl: string, + deviceCode: string, + timeoutMilliseconds: number, + ): Promise; +} + +export class CloudDeviceAuthorizationClient implements CloudDeviceAuthorization { + constructor(private readonly startTimeoutMilliseconds = START_TIMEOUT_MS) {} + + async start(hubUrl: string, displayName: string): Promise { + const signal = AbortSignal.timeout(this.startTimeoutMilliseconds); + try { + const response = await fetch(endpoint(hubUrl, "/api/device-authorizations/"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ displayName }), + signal, + }); + if (!response.ok) throw new Error(`Cloud registration failed (${response.status})`); + return authorizationSchema.parse(await response.json()); + } catch (error) { + if (signal.aborted) { + throw new Error("Cloud registration start timed out", { cause: error }); + } + throw error; + } + } + + async poll( + hubUrl: string, + deviceCode: string, + timeoutMilliseconds: number, + ): Promise { + const signal = AbortSignal.timeout(timeoutMilliseconds); + let response: Response; + try { + response = await fetch(endpoint(hubUrl, "/api/device-authorizations/poll"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ deviceCode }), + signal, + }); + } catch { + return { status: "retry_later" }; + } + if ([408, 425, 429].includes(response.status) || response.status >= 500) { + return { status: "retry_later" }; + } + if (!response.ok) throw new Error(`Cloud registration poll failed (${response.status})`); + let body: unknown; + try { + body = await response.json(); + } catch (error) { + if (signal.aborted || error instanceof TypeError) return { status: "retry_later" }; + throw error; + } + return pollSchema.parse(body); + } +} + +function endpoint(hubUrl: string, pathname: string): string { + const url = new URL(hubUrl); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error("Hub URL must be an HTTP or HTTPS origin without credentials or a query"); + } + url.pathname = `${url.pathname.replace(/\/$/u, "")}${pathname}`; + return url.toString(); +} diff --git a/packages/cli/src/commands/hub/device-authorization.test.ts b/packages/cli/src/commands/hub/device-authorization.test.ts new file mode 100644 index 000000000..a7f1e63a2 --- /dev/null +++ b/packages/cli/src/commands/hub/device-authorization.test.ts @@ -0,0 +1,273 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; +import { DeviceAuthorizationWorkflow, SystemBrowser } from "./device-authorization.js"; +import type { + CloudDeviceAuthorization, + DeviceAuthorizationPoll, +} from "./cloud-device-authorization.js"; +import { createHubCommand } from "./index.js"; + +describe("Hub device authorization", () => { + it("opens activation URLs on Windows without a command shell", async () => { + const launches: Array<{ command: string; args: string[] }> = []; + const browser = new SystemBrowser({ + hostPlatform: "win32", + launch: async (command, args) => void launches.push({ command, args }), + }); + + await browser.open("https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN"); + + assert.deepEqual(launches, [ + { + command: "rundll32.exe", + args: [ + "url.dll,FileProtocolHandler", + "https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN", + ], + }, + ]); + }); + + it("opens the browser, follows Cloud cadence, and returns the approved enrollment token", async () => { + const cloud = new FakeCloud([ + { status: "pending", interval: 5 }, + { status: "slow_down", interval: 10 }, + { status: "approved", interval: 10, enrollmentToken: "approved-enrollment-token-1234567890" }, + ]); + const authorization = new AuthorizationJourney(cloud); + + const token = await authorization.approve("https://cloud.paseo.test", "Studio Mac"); + + assert.equal(token, "approved-enrollment-token-1234567890"); + assert.deepEqual(authorization.observed(), { + starts: [{ hubUrl: "https://cloud.paseo.test", displayName: "Studio Mac" }], + polls: [ + { + hubUrl: "https://cloud.paseo.test", + deviceCode: "device-code-with-more-than-thirty-two-characters", + timeoutMilliseconds: 595_000, + }, + { + hubUrl: "https://cloud.paseo.test", + deviceCode: "device-code-with-more-than-thirty-two-characters", + timeoutMilliseconds: 590_000, + }, + { + hubUrl: "https://cloud.paseo.test", + deviceCode: "device-code-with-more-than-thirty-two-characters", + timeoutMilliseconds: 580_000, + }, + ], + waits: [5_000, 5_000, 10_000], + opened: ["https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN"], + instructions: ["https://cloud.paseo.test/activate ABCD-EFGH-JKLMN"], + }); + }); + + it("stops without an enrollment token when the browser denies the request", async () => { + const authorization = new AuthorizationJourney( + new FakeCloud([{ status: "denied", interval: 5 }]), + ); + + await assert.rejects(authorization.approve("https://cloud.paseo.test", "Studio Mac"), { + message: "Daemon registration was denied", + }); + }); + + it("recovers the approved authority after its first poll response is lost", async () => { + const authorization = new AuthorizationJourney( + new FakeCloud([ + { status: "retry_later" }, + { + status: "approved", + interval: 5, + enrollmentToken: "stable-enrollment-token-after-response-loss", + }, + ]), + ); + + const token = await authorization.approve("https://cloud.paseo.test", "Studio Mac"); + + assert.equal(token, "stable-enrollment-token-after-response-loss"); + assert.deepEqual(authorization.observed().waits, [5_000, 5_000]); + }); + + it("retries timeout failures only while the fixed authorization expiry remains", async () => { + const authorization = new AuthorizationJourney( + new FakeCloud( + [{ status: "retry_later" }, { status: "retry_later" }], + "2026-07-18T12:00:11.000Z", + ), + ); + + await assert.rejects(authorization.approve("https://cloud.paseo.test", "Studio Mac"), { + message: "Daemon registration expired", + }); + assert.deepEqual(authorization.observed().waits, [5_000, 5_000, 1_000]); + assert.deepEqual( + authorization.observed().polls.map(({ timeoutMilliseconds }) => timeoutMilliseconds), + [6_000, 1_000], + ); + }); + + it("stops without an enrollment token when the request expires", async () => { + const authorization = new AuthorizationJourney( + new FakeCloud([{ status: "expired", interval: 5 }]), + ); + + await assert.rejects(authorization.approve("https://cloud.paseo.test", "Studio Mac"), { + message: "Daemon registration expired", + }); + }); + + it("connects the real hub command with a browser-approved token", async () => { + const daemon = new FakeDaemon(); + + await createHubCommand({ + connect: async () => daemon, + authorize: async (url, displayName) => { + assert.equal(url, "https://cloud.paseo.test"); + assert.equal(displayName, "Studio Mac"); + return "approved-enrollment-token-1234567890"; + }, + displayName: () => "Studio Mac", + }).parseAsync(["node", "paseo hub", "connect", "https://cloud.paseo.test", "--json"], { + from: "node", + }); + + assert.deepEqual(daemon.connections, [ + { + url: "https://cloud.paseo.test", + token: "approved-enrollment-token-1234567890", + }, + ]); + assert.equal(daemon.closed, true); + }); + + it("bounds the default daemon name before starting browser authorization", async () => { + const daemon = new FakeDaemon(); + const names: string[] = []; + + await createHubCommand({ + connect: async () => daemon, + authorize: async (_url, displayName) => { + names.push(displayName); + return "approved-enrollment-token-1234567890"; + }, + displayName: () => ` ${"very-long-hostname".repeat(10)} `, + }).parseAsync(["node", "paseo hub", "connect", "https://cloud.paseo.test", "--json"], { + from: "node", + }); + + assert.deepEqual(names, ["very-long-hostname".repeat(10).slice(0, 100)]); + }); +}); + +class AuthorizationJourney { + private now = Date.parse("2026-07-18T12:00:00.000Z"); + private readonly waits: number[] = []; + private readonly opened: string[] = []; + private readonly instructions: string[] = []; + private readonly workflow: DeviceAuthorizationWorkflow; + + constructor(private readonly cloud: FakeCloud) { + this.workflow = new DeviceAuthorizationWorkflow({ + cloud, + waiter: { + wait: async (milliseconds) => { + this.waits.push(milliseconds); + this.now += milliseconds; + }, + now: () => this.now, + }, + browser: { open: async (url) => void this.opened.push(url) }, + reporter: { + instructions: (url, code) => void this.instructions.push(`${url} ${code}`), + }, + }); + } + + approve(hubUrl: string, displayName: string): Promise { + return this.workflow.authorize(hubUrl, displayName); + } + + observed() { + return { + starts: this.cloud.starts, + polls: this.cloud.polls, + waits: this.waits, + opened: this.opened, + instructions: this.instructions, + }; + } +} + +class FakeCloud implements CloudDeviceAuthorization { + readonly starts: Array<{ hubUrl: string; displayName: string }> = []; + readonly polls: Array<{ + hubUrl: string; + deviceCode: string; + timeoutMilliseconds: number; + }> = []; + + constructor( + private readonly outcomes: DeviceAuthorizationPoll[], + private readonly expiresAt = "2026-07-18T12:10:00.000Z", + ) {} + + async start(hubUrl: string, displayName: string) { + this.starts.push({ hubUrl, displayName }); + return { + deviceCode: "device-code-with-more-than-thirty-two-characters", + userCode: "ABCD-EFGH-JKLMN", + verificationUri: "https://cloud.paseo.test/activate", + verificationUriComplete: "https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN", + expiresAt: this.expiresAt, + interval: 5, + }; + } + + async poll( + hubUrl: string, + deviceCode: string, + timeoutMilliseconds: number, + ): Promise { + this.polls.push({ hubUrl, deviceCode, timeoutMilliseconds }); + const outcome = this.outcomes[this.polls.length - 1]; + if (outcome === undefined) throw new Error("No Cloud poll outcome remains"); + return outcome; + } +} + +class FakeDaemon { + readonly connections: Array<{ url: string; token: string }> = []; + closed = false; + + async getHubStatus() { + return { status: hubStatus("not_connected") }; + } + + async connectHub(url: string, token: string) { + this.connections.push({ url, token }); + return { status: hubStatus("connected") }; + } + + async disconnectHub() { + return { status: hubStatus("not_connected") }; + } + + async close() { + this.closed = true; + } +} + +function hubStatus(state: string) { + return { + state, + daemonId: state === "connected" ? "daemon-1" : null, + hubOrigin: state === "connected" ? "https://cloud.paseo.test" : null, + scopes: state === "connected" ? ["hub.execution.*"] : [], + connectedAt: null, + lastError: null, + }; +} diff --git a/packages/cli/src/commands/hub/device-authorization.ts b/packages/cli/src/commands/hub/device-authorization.ts new file mode 100644 index 000000000..22f7702a4 --- /dev/null +++ b/packages/cli/src/commands/hub/device-authorization.ts @@ -0,0 +1,118 @@ +import { spawn } from "node:child_process"; +import { platform } from "node:os"; +import { + CloudDeviceAuthorizationClient, + type CloudDeviceAuthorization, +} from "./cloud-device-authorization.js"; + +export interface AuthorizationWaiter { + wait(milliseconds: number): Promise; + now(): number; +} + +export interface BrowserOpener { + open(url: string): Promise; +} + +type BrowserLaunch = (command: string, args: string[]) => Promise; + +interface SystemBrowserOptions { + hostPlatform?: NodeJS.Platform; + launch?: BrowserLaunch; +} + +export class SystemBrowser implements BrowserOpener { + private readonly hostPlatform: NodeJS.Platform; + private readonly launch: BrowserLaunch; + + constructor(options: SystemBrowserOptions = {}) { + this.hostPlatform = options.hostPlatform ?? platform(); + this.launch = options.launch ?? launchDetached; + } + + async open(url: string): Promise { + if (this.hostPlatform === "win32") { + await this.launch("rundll32.exe", ["url.dll,FileProtocolHandler", url]); + return; + } + + await this.launch(this.hostPlatform === "darwin" ? "open" : "xdg-open", [url]); + } +} + +export interface AuthorizationReporter { + instructions(verificationUri: string, userCode: string): void; +} + +interface DeviceAuthorizationWorkflowOptions { + cloud: CloudDeviceAuthorization; + waiter: AuthorizationWaiter; + browser: BrowserOpener; + reporter: AuthorizationReporter; + openBrowser?: boolean; +} + +export class DeviceAuthorizationWorkflow { + constructor(private readonly options: DeviceAuthorizationWorkflowOptions) {} + + async authorize(hubUrl: string, displayName: string): Promise { + const authorization = await this.options.cloud.start(hubUrl, displayName); + this.options.reporter.instructions(authorization.verificationUri, authorization.userCode); + if (this.options.openBrowser !== false) { + await this.options.browser.open(authorization.verificationUriComplete).catch(() => undefined); + } + + let interval = authorization.interval; + const expiresAt = Date.parse(authorization.expiresAt); + while (true) { + const remaining = expiresAt - this.options.waiter.now(); + if (remaining <= 0) throw new Error("Daemon registration expired"); + await this.options.waiter.wait(Math.min(interval * 1_000, remaining)); + if (this.options.waiter.now() >= expiresAt) throw new Error("Daemon registration expired"); + const pollLifetime = expiresAt - this.options.waiter.now(); + if (pollLifetime <= 0) throw new Error("Daemon registration expired"); + const outcome = await this.options.cloud.poll(hubUrl, authorization.deviceCode, pollLifetime); + if (this.options.waiter.now() >= expiresAt) throw new Error("Daemon registration expired"); + if (outcome.status === "retry_later") continue; + interval = outcome.interval; + if (outcome.status === "approved") return outcome.enrollmentToken; + if (outcome.status === "denied") throw new Error("Daemon registration was denied"); + if (outcome.status === "expired") throw new Error("Daemon registration expired"); + if (outcome.status === "enrolled") { + throw new Error("Daemon registration was already used"); + } + } + } +} + +export function createDeviceAuthorizationWorkflow(): DeviceAuthorizationWorkflow { + return new DeviceAuthorizationWorkflow({ + cloud: new CloudDeviceAuthorizationClient(), + waiter: { + wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + now: Date.now, + }, + browser: new SystemBrowser(), + reporter: { + instructions(verificationUri, userCode) { + process.stderr.write(`Open ${verificationUri} and enter code ${userCode}\n`); + }, + }, + openBrowser: process.stderr.isTTY === true, + }); +} + +async function launchDetached(command: string, args: string[]): Promise { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + detached: true, + shell: false, + stdio: "ignore", + }); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + child.once("error", reject); + }); +} diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts index fce9f1c65..813181b62 100644 --- a/packages/cli/src/commands/hub/index.ts +++ b/packages/cli/src/commands/hub/index.ts @@ -1,7 +1,37 @@ import { Command } from "commander"; +import { hostname } from "node:os"; import { withOutput, type ListResult, type OutputSchema } from "../../output/index.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { connectToDaemon } from "../../utils/client.js"; +import { createDeviceAuthorizationWorkflow } from "./device-authorization.js"; + +interface HubCommandClient { + connectHub(url: string, token: string): Promise<{ status: HubStatus }>; + getHubStatus(): Promise<{ status: HubStatus }>; + disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>; + close(): Promise; +} + +interface HubStatus { + state: string; + daemonId: string | null; + hubOrigin: string | null; + scopes: string[]; + connectedAt: string | null; + lastError: string | null; +} + +interface HubCommandEnvironment { + connect(host: string | undefined): Promise; + authorize(url: string, displayName: string): Promise; + displayName(): string; +} + +const productionEnvironment: HubCommandEnvironment = { + connect: (host) => connectToDaemon({ host }), + authorize: (url, displayName) => createDeviceAuthorizationWorkflow().authorize(url, displayName), + displayName: hostname, +}; interface HubRow { state: string; @@ -55,10 +85,11 @@ function result( } async function withClient( + environment: HubCommandEnvironment, host: string | undefined, - action: (client: Awaited>) => Promise, + action: (client: HubCommandClient) => Promise, ): Promise { - const client = await connectToDaemon({ host }); + const client = await environment.connect(host); try { return await action(client); } finally { @@ -66,23 +97,36 @@ async function withClient( } } -export function createHubCommand(): Command { +export function createHubCommand( + environment: HubCommandEnvironment = productionEnvironment, +): Command { const hub = new Command("hub").description("Manage this daemon's Paseo Hub relationship"); addJsonAndDaemonHostOptions( - hub.command("connect").argument("").requiredOption("--token "), + hub.command("connect").argument("").option("--token "), ).action( withOutput(async (...args) => { const url = args[0] as string; - const options = args.at(-2) as { token: string; host?: string }; - return withClient(options.host, async (client) => - result((await client.connectHub(url, options.token)).status), - ); + const options = args.at(-2) as { token?: string; host?: string }; + return withClient(environment, options.host, async (client) => { + if (options.token !== undefined) { + return result((await client.connectHub(url, options.token)).status); + } + const existing = (await client.getHubStatus()).status; + if (existing.state !== "not_connected" && existing.state !== "revoked") { + throw new Error("This daemon already has a Hub relationship"); + } + const token = await environment.authorize( + url, + suggestedDisplayName(environment.displayName()), + ); + return result((await client.connectHub(url, token)).status); + }); }), ); addJsonAndDaemonHostOptions(hub.command("status")).action( withOutput(async (...args) => { const options = args.at(-2) as { host?: string }; - return withClient(options.host, async (client) => + return withClient(environment, options.host, async (client) => result((await client.getHubStatus()).status), ); }), @@ -94,7 +138,7 @@ export function createHubCommand(): Command { ).action( withOutput(async (...args) => { const options = args.at(-2) as { host?: string; force?: boolean }; - return withClient(options.host, async (client) => { + return withClient(environment, options.host, async (client) => { const response = await client.disconnectHub(options.force ?? false); return result(response.status, response.warning); }); @@ -102,3 +146,7 @@ export function createHubCommand(): Command { ); return hub; } + +function suggestedDisplayName(value: string): string { + return value.trim().slice(0, 100) || "Paseo daemon"; +} diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index 95bbe3791..5052ec935 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -16,6 +16,7 @@ import { mkdtemp, rm, mkdir } from "fs/promises"; import { existsSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import { fileURLToPath } from "url"; import { ChildProcess, spawn } from "child_process"; import { getAvailablePort } from "./network.ts"; @@ -43,6 +44,7 @@ const TEST_DAEMON_ENV_DEFAULTS: Record = { PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0", }; const TEST_DAEMON_HOST = "127.0.0.1"; +const TSX_ENTRY = fileURLToPath(import.meta.resolve("tsx/cli")); const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024; const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt( @@ -233,19 +235,23 @@ export async function startTestDaemon(options?: { const cliSrcPath = join(cliDir, "src", "index.ts"); // Start daemon process using tsx to run TypeScript directly - const daemonProcess = spawn("npx", ["tsx", cliSrcPath, "daemon", "start", "--foreground"], { - env: { - ...process.env, - ...TEST_DAEMON_ENV_DEFAULTS, - PASEO_HOME: paseoHome, - PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, - // Force no TTY to prevent QR code output - CI: "true", - ...options?.env, + const daemonProcess = spawn( + process.execPath, + [TSX_ENTRY, cliSrcPath, "daemon", "start", "--foreground"], + { + env: { + ...process.env, + ...TEST_DAEMON_ENV_DEFAULTS, + PASEO_HOME: paseoHome, + PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, + // Force no TTY to prevent QR code output + CI: "true", + ...options?.env, + }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", }, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", - }); + ); const stdout = createOutputCapture(); const stderr = createOutputCapture(); @@ -345,7 +351,7 @@ export async function runPaseoCli( const cliSrcPath = join(cliDir, "src", "index.ts"); return new Promise((resolve, reject) => { - const proc = spawn("npx", ["tsx", cliSrcPath, ...args], { + const proc = spawn(process.execPath, [TSX_ENTRY, cliSrcPath, ...args], { env: { ...process.env, ...TEST_DAEMON_ENV_DEFAULTS,