From 94e2e0e1e09ca28ea0e912942ff4a76a50ab5011 Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Mon, 12 Oct 2026 21:55:24 +0800 Subject: [PATCH 1/2] fix(provider-auth): send the installation device ID on ChatGPT subscription login OpenAI registers a ChatGPT subscription login against an agent host ID and rejects it with "Sign in with ChatGPT requires a device ID (UUID) for this installation" when the app supplies none. Pace never passed `LoginOptions.getDeviceId`, so the OpenAI card's login failed before it could open a browser; the legacy `openai-codex` flow, which does not read a device ID, is why this went unnoticed. Read and create the ID through Pi's settings manager, where the Pi TUI keeps it, so both clients register as one installation instead of two. --- .../src/workspace/provider-auth.test.ts | 78 +++++++++++++++++++ .../backend/src/workspace/provider-auth.ts | 59 +++++++++----- 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/packages/backend/src/workspace/provider-auth.test.ts b/packages/backend/src/workspace/provider-auth.test.ts index 112ef5dd..71bba99d 100644 --- a/packages/backend/src/workspace/provider-auth.test.ts +++ b/packages/backend/src/workspace/provider-auth.test.ts @@ -23,6 +23,54 @@ function realRuntimeService(agentDir: string) { }); } +/** Stub runtime that records the LoginOptions Pace hands to an OAuth login. */ +function deviceIdCapturingRuntime( + onDeviceId: (value: string | undefined) => void, +): ProviderAuthRuntime { + return { + getProviderAuthStatus: () => ({ configured: false }), + getProviders: () => [{ id: "openai", name: "OpenAI", auth: { oauth: {} } }], + async login(_providerId, _type, _interaction, options) { + onDeviceId(options?.getDeviceId?.()); + 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 storedDeviceId(agentDir: string) { + try { + const raw = JSON.parse( + await readFile(join(agentDir, "settings.json"), "utf8"), + ) as { deviceId?: string }; + return raw.deviceId; + } catch { + return undefined; + } +} + +async function deviceIdFromLogin(agentDir: string) { + let deviceId: string | undefined; + const service = createProviderAuthService({ + agentDir, + runtime: async () => + deviceIdCapturingRuntime((value) => { + deviceId = value; + }), + }); + + await service.loginOAuth("openai"); + return deviceId; +} + // 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 +232,36 @@ 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).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + // Pi writes settings asynchronously; until it lands, a later login would + // register a second agent host with OpenAI. + await vi.waitFor(async () => { + expect(await storedDeviceId(agentDir)).toBe(first); + }); + await expect(deviceIdFromLogin(agentDir)).resolves.toBe(first); + }); + + 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.toBe( + "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..94921458 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,12 +249,30 @@ 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. + */ +function createDeviceIdReader(agentDir: string): () => string { + let deviceId: string | undefined; + + return () => { + deviceId ??= SettingsManager.create(agentDir, agentDir, { + projectTrusted: false, + }).getOrCreateDeviceId(); + return deviceId; + }; +} + export function createProviderAuthService( options: ProviderAuthServiceOptions, ): ProviderAuthService { const authPath = join(options.agentDir, "auth.json"); const openExternal = options.openExternalUrl ?? openUrlInBrowser; const getRuntime = options.runtime; + const getDeviceId = createDeviceIdReader(options.agentDir); const listStatus = async (): Promise => { const runtime = await getRuntime(); @@ -328,26 +346,31 @@ 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); + 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 }, + ); return listStatus(); }, From 104555bc3add83015c7e2a90e297e82130092d0a Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Mon, 12 Oct 2026 23:31:20 +0800 Subject: [PATCH 2/2] fix(provider-auth): save the installation ID before the login starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi's settings setters only queue their write, so the first ChatGPT login handed the OAuth flow an ID that was not on disk yet, and a failed save went unnoticed: `save()` returns early when the settings file cannot be loaded, and `flush()` folds write errors into `drainErrors()`. Resolve the ID before `runtime.login` and flush it, so the Pi TUI — which reads the same file from another process — sees the ID OpenAI was told. Surface a save that fails instead of churning the agent host on every later login. --- .../src/workspace/provider-auth.test.ts | 76 ++++++++++++------- .../backend/src/workspace/provider-auth.ts | 30 +++++--- 2 files changed, 68 insertions(+), 38 deletions(-) diff --git a/packages/backend/src/workspace/provider-auth.test.ts b/packages/backend/src/workspace/provider-auth.test.ts index 71bba99d..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,15 +24,30 @@ function realRuntimeService(agentDir: string) { }); } -/** Stub runtime that records the LoginOptions Pace hands to an OAuth login. */ +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( - onDeviceId: (value: string | undefined) => void, + 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) { - onDeviceId(options?.getDeviceId?.()); + onLogin(options?.getDeviceId?.(), readSettingsFile(agentDir)); return { type: "oauth", access: "a", refresh: "r", expires: 0 }; }, getAvailableSnapshot: () => [], @@ -46,29 +62,18 @@ function deviceIdCapturingRuntime( }; } -async function storedDeviceId(agentDir: string) { - try { - const raw = JSON.parse( - await readFile(join(agentDir, "settings.json"), "utf8"), - ) as { deviceId?: string }; - return raw.deviceId; - } catch { - return undefined; - } -} - async function deviceIdFromLogin(agentDir: string) { - let deviceId: string | undefined; + let seen: { deviceId?: string; onDisk?: { deviceId?: string } } = {}; const service = createProviderAuthService({ agentDir, runtime: async () => - deviceIdCapturingRuntime((value) => { - deviceId = value; + deviceIdCapturingRuntime(agentDir, (deviceId, onDisk) => { + seen = { deviceId, onDisk }; }), }); await service.loginOAuth("openai"); - return deviceId; + return seen; } // Pi counts a provider as configured when the ambient environment carries its @@ -240,13 +245,29 @@ describe("provider auth service", () => { const first = await deviceIdFromLogin(agentDir); - expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); - // Pi writes settings asynchronously; until it lands, a later login would - // register a second agent host with OpenAI. - await vi.waitFor(async () => { - expect(await storedDeviceId(agentDir)).toBe(first); + 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 }, }); - await expect(deviceIdFromLogin(agentDir)).resolves.toBe(first); + }); + + 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 () => { @@ -257,9 +278,10 @@ describe("provider auth service", () => { "utf8", ); - await expect(deviceIdFromLogin(agentDir)).resolves.toBe( - "3f2a6c1e-0b47-4d9a-8e11-5c6d7f8a9b0c", - ); + 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 () => { diff --git a/packages/backend/src/workspace/provider-auth.ts b/packages/backend/src/workspace/provider-auth.ts index 94921458..e0730bf2 100644 --- a/packages/backend/src/workspace/provider-auth.ts +++ b/packages/backend/src/workspace/provider-auth.ts @@ -254,16 +254,24 @@ function settleOrAbort(work: Promise, signal: AbortSignal): Promise { * 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. */ -function createDeviceIdReader(agentDir: string): () => string { - let deviceId: string | undefined; - - return () => { - deviceId ??= SettingsManager.create(agentDir, agentDir, { - projectTrusted: false, - }).getOrCreateDeviceId(); - return deviceId; - }; +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( @@ -272,7 +280,6 @@ export function createProviderAuthService( const authPath = join(options.agentDir, "auth.json"); const openExternal = options.openExternalUrl ?? openUrlInBrowser; const getRuntime = options.runtime; - const getDeviceId = createDeviceIdReader(options.agentDir); const listStatus = async (): Promise => { const runtime = await getRuntime(); @@ -346,6 +353,7 @@ export function createProviderAuthService( if (!provider.auth.oauth) { throw new Error(`Provider "${providerId}" does not support subscription login.`); } + const deviceId = await resolveDeviceId(options.agentDir); await runtime.login( providerId, "oauth", @@ -369,7 +377,7 @@ export function createProviderAuthService( } }, }, - { getDeviceId }, + { getDeviceId: () => deviceId }, ); return listStatus();