From eeac84ca92498ac18b6832754d01aef1d3c5f654 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 2 Oct 2026 17:43:21 +0200 Subject: [PATCH] fix(ai): fail Sign in with ChatGPT when the callback port is taken When port 1455 was held by another pending login (another pi session or the Codex CLI), login fell back to a paste prompt, but the browser callback reached the other listener, which rejected it with "OAuth state mismatch". Fail before opening the browser with a port-in-use error instead. fixes #10265 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/auth/oauth/openai-chatgpt.ts | 23 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) 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(); } }