diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7124c8582..adcf2b773 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -24,6 +24,7 @@ - Reduced context-limit request failures by estimating input at 3.5 characters per token instead of 4 when calculating output limits ([#10497](https://github.com/earendil-works/pi/issues/10497)) - Fixed Radius models disabled by an organization owner still being listed: a fetched or cached Radius catalog now replaces the shipped default catalog instead of being merged into it - Fixed OpenAI provider type-checking with cached catalogs that contain no classifier models +- Fixed Anthropic browser login failing with "localhost refused to connect" when port 53692 is reserved or in use, for example by Hyper-V/WSL port exclusions on Windows: login now falls back to a free loopback port ([#10571](https://github.com/earendil-works/pi/issues/10571)) ## [1.0.4] - 2026-10-05 diff --git a/packages/ai/src/auth/oauth/anthropic.ts b/packages/ai/src/auth/oauth/anthropic.ts index 352150613..b3d8499b2 100644 --- a/packages/ai/src/auth/oauth/anthropic.ts +++ b/packages/ai/src/auth/oauth/anthropic.ts @@ -15,6 +15,8 @@ const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1"; +// Preferred so the port can be forwarded into containers or over SSH. Anthropic accepts any loopback port, +// so login falls back to a free port when this one cannot be bound (#10571). const CALLBACK_PORT = 53692; const CALLBACK_PATH = "/callback"; const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; @@ -137,22 +139,29 @@ async function exchangeAuthorizationCode( async function loginAnthropic(interaction: ProviderAuthInteraction): Promise { const { verifier, challenge } = await generatePKCE(); - const callback = await startOAuthCallbackServer({ - providerName: "Anthropic", - host: CALLBACK_HOST, - port: CALLBACK_PORT, - path: CALLBACK_PATH, - state: verifier, - complete: async (code) => code, - signal: interaction.signal, - }).catch(() => undefined); + const startCallbackServer = (port: number) => + startOAuthCallbackServer({ + providerName: "Anthropic", + host: CALLBACK_HOST, + port, + path: CALLBACK_PATH, + redirectHost: "localhost", + state: verifier, + complete: async (code) => code, + signal: interaction.signal, + }); + // Without a callback server, login continues with the pasted redirect URL. + const callback = await startCallbackServer(CALLBACK_PORT) + .catch(() => startCallbackServer(0)) + .catch(() => undefined); + const redirectUri = callback?.redirectUri ?? REDIRECT_URI; try { const authParams = new URLSearchParams({ code: "true", client_id: CLIENT_ID, response_type: "code", - redirect_uri: REDIRECT_URI, + redirect_uri: redirectUri, scope: SCOPES, code_challenge: challenge, code_challenge_method: "S256", @@ -167,7 +176,7 @@ async function loginAnthropic(interaction: ProviderAuthInteraction): Promise { }); it("completes login through the browser callback and shows the sign-in page", async () => { - let exchangedCode: string | undefined; - vi.stubGlobal( - "fetch", - vi.fn(async (input: unknown, init?: RequestInit): Promise => { - if (getUrl(input) !== "https://platform.claude.com/v1/oauth/token") - return nativeFetch(input as string, init); - exchangedCode = getJsonBody(init).code; - return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 }); - }), - ); + const login = await loginThroughBrowserCallback(); - let callbackPage: Promise | undefined; - const credential = await anthropicOAuth.login({ - signal: neverAbortedSignal, - notify: (event) => { - if (event.type !== "auth_url") return; - const state = new URL(event.url).searchParams.get("state") ?? ""; - callbackPage = nativeFetch(`http://127.0.0.1:53692/callback?code=browser-code&state=${state}`); - }, - prompt: (prompt) => - prompt.type === "select" - ? Promise.resolve("browser") - : new Promise((_, reject) => { - prompt.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); - }), - }); + expect(login.credential.access).toBe("access"); + expect(login.exchangedCode).toBe("browser-code"); + expect(login.exchangedRedirectUri).toBe(login.redirectUri); + expect(login.pageStatus).toBe(200); + expect(login.pageText).toContain("Signed in to Anthropic."); + }); - expect(credential.access).toBe("access"); - expect(exchangedCode).toBe("browser-code"); - const response = await callbackPage; - expect(response?.status).toBe(200); - expect(await response?.text()).toContain("Signed in to Anthropic."); + // #10571 + it("falls back to a free callback port when the preferred port cannot be bound", async () => { + const blocker = createServer(); + await listen(blocker, 53692); + try { + const login = await loginThroughBrowserCallback(); + const redirectUri = new URL(login.redirectUri); + + expect(redirectUri.hostname).toBe("localhost"); + expect(redirectUri.pathname).toBe("/callback"); + expect(redirectUri.port).not.toBe("53692"); + expect(login.credential.access).toBe("access"); + expect(login.exchangedRedirectUri).toBe(login.redirectUri); + expect(login.pageStatus).toBe(200); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } }); }); + +function listen(server: Server, port: number): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +async function loginThroughBrowserCallback() { + let exchangedCode: string | undefined; + let exchangedRedirectUri: string | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + if (getUrl(input) !== "https://platform.claude.com/v1/oauth/token") return nativeFetch(input as string, init); + const body = getJsonBody(init); + exchangedCode = body.code; + exchangedRedirectUri = body.redirect_uri; + return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 }); + }), + ); + + let redirectUri = ""; + let callbackPage: Promise | undefined; + const credential = await anthropicOAuth.login({ + signal: neverAbortedSignal, + notify: (event) => { + if (event.type !== "auth_url") return; + const params = new URL(event.url).searchParams; + redirectUri = params.get("redirect_uri") ?? ""; + const callbackUrl = new URL(redirectUri); + callbackUrl.hostname = "127.0.0.1"; + callbackUrl.search = new URLSearchParams({ + code: "browser-code", + state: params.get("state") ?? "", + }).toString(); + callbackPage = nativeFetch(callbackUrl); + }, + prompt: (prompt) => + prompt.type === "select" + ? Promise.resolve("browser") + : new Promise((_, reject) => { + prompt.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }), + }); + const page = await callbackPage; + return { + credential, + redirectUri, + exchangedCode, + exchangedRedirectUri, + pageStatus: page?.status, + pageText: await page?.text(), + }; +}