Repository navigation
fix(provider-auth): send the installation device ID on ChatGPT subscription login - #462
Conversation
…iption 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.
| deviceId ??= SettingsManager.create(agentDir, agentDir, { | ||
| projectTrusted: false, | ||
| }).getOrCreateDeviceId(); | ||
| return deviceId; |
There was a problem hiding this comment.
🔴 Installation ID can change between logins
On the first login, getDeviceId returns before getOrCreateDeviceId persists the ID. Another process can read the old settings and register a second agent host.
Learn more
Pi's settings manager queues writes rather than making them durable in its setters; withPackages waits for flush() and checks drainErrors() before reporting success. Here, the first login gives the new ID to the OAuth flow before the queued settings write finishes. A separate backend or Pi process can create another ID during that gap; a failed write can leave every future process without the original ID. The new test explicitly waits for persistence before it starts the second login, so it does not exercise this gap.
Example: Pace creates ID A for its first ChatGPT login. Before its settings write completes, Pi starts a login, sees no device ID on disk, and creates ID B. The two clients register different agent hosts for one installation.
Recommended fix: Persist the newly generated ID and check SettingsManager.drainErrors() before handing it to the OAuth callback, or initialize it asynchronously before calling runtime.login. Serialize concurrent initializations and test immediate cross-instance logins and write failures.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Both halves check out, and the gap was real: at the moment the flow asked for the ID, <agentDir>/settings.json had no deviceId yet (expected undefined to be '33117e80-…'), and with a corrupt settings file the login resolved instead of rejecting. save() returns early when the file fails to load, and flush() only awaits the queue — errors land in drainErrors().
Fixed in 104555b: the ID is resolved, flushed and checked before runtime.login, matching the convention resource-management.ts already uses for Pi's settings writes.
if (!provider.auth.oauth) { … }
+ const deviceId = await resolveDeviceId(options.agentDir);
await runtime.login(providerId, "oauth", interaction, { getDeviceId: () => deviceId });async function resolveDeviceId(agentDir: string): Promise<string> {
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}: …`);
return deviceId;
}The test no longer waits for persistence — the stub reads the settings file synchronously inside login, exactly as another process would, and asserts it already holds the ID handed to the flow. A second test covers the write failure (corrupt settings file → login rejected before the flow runs, login never called).
One part I did not take: serializing concurrent first-time creations. getOrCreateDeviceId() decides from the globalSettings snapshot loaded at construction, outside the storage lock — persistScopedSettings only merges modified fields into the freshly-read file after the decision. So a client that already loaded settings (a running Pi TUI) generates its own ID no matter how early we flush; two clients that both start before either writes will still disagree. That is Pi's settings-manager design and the TUI has the same property, so it needs an upstream fix (a lock-protected read-or-create) rather than one here. Eager + flushed persistence is what Pace can do: it guarantees the ID exists for every client that starts later, which is the case that matters in practice.
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.
Summary
OpenAI rejects a ChatGPT subscription login that carries no agent host ID, so the
openaicard's Login with subscription button failed before it could open a browser. Pace never passedLoginOptions.getDeviceId:await runtime.login(providerId, "oauth", { prompt: …, notify: …, - }); + }, { getDeviceId });The ID comes from Pi's settings manager — the same store the Pi TUI uses — so a machine registers as one installation rather than one per client:
Only the OAuth login path changes; the API-key path never reads a device ID, and the legacy
openai-codexflow (which does not require one) is untouched.Evidence
Sign in with ChatGPT requires a device ID (UUID) for this installation(pi-ai/dist/auth/oauth/openai-chatgpt.js:186), thrown before the authorize URL was built.getDeviceIdwasundefined):keeps one ChatGPT device ID for the installation— creates a UUID and persists it tosettings.jsonbefore a later login reads it back.reuses the device ID the Pi TUI stored for this installation— a pre-writtendeviceIdis what the login sends.bun run typecheck,bun run lint,bun run test(2122 passed) andbun run buildare green.Merge Danger
Door: two-way. Reverting restores the previous behavior (login rejected for the OpenAI subscription card); no data migration, and the
deviceIdfield Pi already writes is unaffected.Blast Radius: one provider card. A
deviceIdis now written to~/.pi/agent/settings.jsonon the first OpenAI subscription login — the same field the Pi TUI writes, shared with it. Users signed in through the legacyopenai-codexcard keep working exactly as before.