diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 4615999ae..0b63359a8 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -15,6 +15,7 @@ - Fixed "Selected model is at capacity" provider errors ending the turn instead of being retried ([#10278](https://github.com/earendil-works/pi/issues/10278)) - Fixed Cloudflare AI Gateway Claude models failing with a 404 by using dashed model IDs (`claude-opus-5-5` instead of `claude-opus-5.5`), which Anthropic requires +- Fixed Sign in with ChatGPT continuing when its callback port is taken by another login, which made the browser show "OAuth state mismatch"; it now fails with a port-in-use error ([#10265](https://github.com/earendil-works/pi/issues/10265)) ## [1.0.0] - 2026-10-01 diff --git a/packages/ai/src/auth/oauth/openai-chatgpt.ts b/packages/ai/src/auth/oauth/openai-chatgpt.ts index 0dbe5a7c8..c6b049139 100644 --- a/packages/ai/src/auth/oauth/openai-chatgpt.ts +++ b/packages/ai/src/auth/oauth/openai-chatgpt.ts @@ -238,15 +238,14 @@ async function loginOpenAIChatGPT( const { verifier, challenge } = await generatePKCE(); const state = randomValue(); const nonce = randomValue(); - let callback: CallbackServer | undefined; - try { - callback = await startCallbackServer(state); - } catch (error) { - interaction.notify({ - type: "info", - message: `Could not listen on ${REDIRECT_URI}; paste the final redirect URL to continue. ${error instanceof Error ? error.message : String(error)}`, - }); - } + // Without this server, the browser's callback would reach whatever else holds the port (another + // pending login or the Codex CLI), which rejects it as a state mismatch. Fail with a clear error instead. + const callback = await startCallbackServer(state).catch((error: unknown) => { + if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) throw error; + throw new Error( + `Port ${CALLBACK_PORT} is in use, probably by an unfinished login in another pi session or by the Codex CLI. Cancel that login and try again.`, + ); + }); const authorizationUrl = new URL(AUTHORIZE_URL); authorizationUrl.search = new URLSearchParams({ @@ -280,7 +279,7 @@ async function loginOpenAIChatGPT( .then((input) => authorizationResultFromManualInput(input, state)); try { - const result = await (callback ? Promise.race([callback.result, manualCode]) : manualCode); + const result = await Promise.race([callback.result, manualCode]); interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." }); return await exchangeAuthorizationCode(result.code, verifier, result.clientId, interaction.signal); } catch (error) { @@ -288,13 +287,13 @@ async function loginOpenAIChatGPT( throw error; } finally { manualAbort.abort(); - callback?.server.close(); + callback.server.close(); // close() only stops accepting new connections. Browsers open spare connections ahead of // time, and one that has not sent a request yet stays open and attached to this server. // A later login in the same process starts a new server with a new state, but the browser // may send that login's callback over the spare connection. This server would then handle // it and reject it with "OAuth state mismatch", and the new login would never see it. - callback?.server.closeAllConnections(); + callback.server.closeAllConnections(); } }