diff --git a/packages/backend/src/workspace/provider-auth.test.ts b/packages/backend/src/workspace/provider-auth.test.ts index 112ef5dd..4e385328 100644 --- a/packages/backend/src/workspace/provider-auth.test.ts +++ b/packages/backend/src/workspace/provider-auth.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ModelRuntime } from "@earendil-works/pi-coding-agent"; @@ -23,6 +24,58 @@ function realRuntimeService(agentDir: string) { }); } +function readSettingsFile(agentDir: string) { + try { + return JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf8")) as { + deviceId?: string; + }; + } catch { + return undefined; + } +} + +/** + * Stub runtime that records the LoginOptions Pace hands to an OAuth login, + * together with the settings file as another process would read it at that + * moment. + */ +function deviceIdCapturingRuntime( + agentDir: string, + onLogin: (deviceId: string | undefined, onDisk: { deviceId?: string } | undefined) => void, +): ProviderAuthRuntime { + return { + getProviderAuthStatus: () => ({ configured: false }), + getProviders: () => [{ id: "openai", name: "OpenAI", auth: { oauth: {} } }], + async login(_providerId, _type, _interaction, options) { + onLogin(options?.getDeviceId?.(), readSettingsFile(agentDir)); + return { type: "oauth", access: "a", refresh: "r", expires: 0 }; + }, + getAvailableSnapshot: () => [], + getModel: () => undefined, + async completeSimple() { + throw new Error("not used"); + }, + async logout() {}, + async refresh() { + return { aborted: false, errors: new Map() }; + }, + }; +} + +async function deviceIdFromLogin(agentDir: string) { + let seen: { deviceId?: string; onDisk?: { deviceId?: string } } = {}; + const service = createProviderAuthService({ + agentDir, + runtime: async () => + deviceIdCapturingRuntime(agentDir, (deviceId, onDisk) => { + seen = { deviceId, onDisk }; + }), + }); + + await service.loginOAuth("openai"); + return seen; +} + // Pi counts a provider as configured when the ambient environment carries its // credentials (API-key env vars, the AWS chain, gcloud ADC under $HOME), and it // reads process.env live. Strip the host environment so these tests only see @@ -184,6 +237,53 @@ describe("provider auth service", () => { expect(selected).toBe("browser"); }); + // OpenAI registers a ChatGPT subscription login against this installation's + // agent host ID and rejects the login when it is missing, so every login must + // send one and every later login must send the same one. + it("keeps one ChatGPT device ID for the installation", async () => { + const agentDir = await tempAgentDir(); + + const first = await deviceIdFromLogin(agentDir); + + expect(first.deviceId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + // The login asks for the ID before it opens a browser, and the Pi TUI reads + // the same file from another process: it has to be saved by then. + expect(first.onDisk?.deviceId).toBe(first.deviceId); + await expect(deviceIdFromLogin(agentDir)).resolves.toEqual({ + deviceId: first.deviceId, + onDisk: { deviceId: first.deviceId }, + }); + }); + + it("refuses the login when the installation ID cannot be saved", async () => { + const agentDir = await tempAgentDir(); + await writeFile(join(agentDir, "settings.json"), "{ not json", "utf8"); + const login = vi.fn(deviceIdCapturingRuntime(agentDir, () => {}).login); + const service = createProviderAuthService({ + agentDir, + runtime: async () => ({ ...deviceIdCapturingRuntime(agentDir, () => {}), login }), + }); + + await expect(service.loginOAuth("openai")).rejects.toThrow(/settings\.json/); + expect(login).not.toHaveBeenCalled(); + }); + + it("reuses the device ID the Pi TUI stored for this installation", async () => { + const agentDir = await tempAgentDir(); + await writeFile( + join(agentDir, "settings.json"), + JSON.stringify({ deviceId: "3f2a6c1e-0b47-4d9a-8e11-5c6d7f8a9b0c" }), + "utf8", + ); + + await expect(deviceIdFromLogin(agentDir)).resolves.toEqual({ + deviceId: "3f2a6c1e-0b47-4d9a-8e11-5c6d7f8a9b0c", + onDisk: { deviceId: "3f2a6c1e-0b47-4d9a-8e11-5c6d7f8a9b0c" }, + }); + }); + it("reuses xai OAuth already stored in Pi auth.json", async () => { const agentDir = await tempAgentDir(); await writeFile( diff --git a/packages/backend/src/workspace/provider-auth.ts b/packages/backend/src/workspace/provider-auth.ts index f071810a..e0730bf2 100644 --- a/packages/backend/src/workspace/provider-auth.ts +++ b/packages/backend/src/workspace/provider-auth.ts @@ -4,7 +4,7 @@ // persist to auth.json and the runtime snapshot stays in sync. import { join } from "node:path"; -import { ModelRuntime, readStoredCredential } from "@earendil-works/pi-coding-agent"; +import { ModelRuntime, SettingsManager, readStoredCredential } from "@earendil-works/pi-coding-agent"; import { openUrlInBrowser } from "./open-url"; import { readSettingsPreferredModel } from "./pi-settings"; import { @@ -249,6 +249,31 @@ function settleOrAbort(work: Promise, signal: AbortSignal): Promise { }); } +/** + * Stable ID of this installation. OpenAI registers a ChatGPT subscription login + * against it and rejects the login without one. Pi keeps it in the global + * settings file, so reading it through the same settings manager registers the + * Pi TUI and Pace as one installation rather than two. + * + * The ID is saved before the login starts, not when the flow asks for it: Pi's + * setters only queue their write, and the Pi TUI reads that file from another + * process. A save that fails would hand OpenAI an ID no later process knows. + */ +async function resolveDeviceId(agentDir: string): Promise { + const settings = SettingsManager.create(agentDir, agentDir, { projectTrusted: false }); + const deviceId = settings.getOrCreateDeviceId(); + await settings.flush(); + const [failure] = settings.drainErrors(); + + if (failure) { + throw new Error( + `Could not save this installation's ID to ${failure.path ?? failure.scope}: ${failure.error.message}`, + ); + } + + return deviceId; +} + export function createProviderAuthService( options: ProviderAuthServiceOptions, ): ProviderAuthService { @@ -328,26 +353,32 @@ export function createProviderAuthService( if (!provider.auth.oauth) { throw new Error(`Provider "${providerId}" does not support subscription login.`); } - await runtime.login(providerId, "oauth", { - prompt: (prompt: AuthPrompt) => { - // Codex asks browser vs device-code; GUI always takes the browser flow. - if (prompt.type === "select") { - const browser = prompt.options.find((option) => option.id === "browser"); - if (browser) { - return Promise.resolve(browser.id); + const deviceId = await resolveDeviceId(options.agentDir); + await runtime.login( + providerId, + "oauth", + { + prompt: (prompt: AuthPrompt) => { + // Codex asks browser vs device-code; GUI always takes the browser flow. + if (prompt.type === "select") { + const browser = prompt.options.find((option) => option.id === "browser"); + if (browser) { + return Promise.resolve(browser.id); + } } - } - return pendPrompt(); - }, - notify: (event: AuthEvent) => { - if (event.type === "auth_url") { - void openExternal(event.url); - } else if (event.type === "device_code") { - void openExternal(event.verificationUri); - } + return pendPrompt(); + }, + notify: (event: AuthEvent) => { + if (event.type === "auth_url") { + void openExternal(event.url); + } else if (event.type === "device_code") { + void openExternal(event.verificationUri); + } + }, }, - }); + { getDeviceId: () => deviceId }, + ); return listStatus(); },