diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index cb40dada2..f082646b4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -19,6 +19,7 @@ - Cloud Code Assist Gemini 3.6/3.7 Flash requests at `minimal` now send `thinkingLevel: LOW` on the aliased `-low` SKU instead of `MINIMAL`, which the API rejects with HTTP 400. - Answer Cursor `interaction_query` permission gates (hosted web search, Exa, unnamed field-9 WebFetch) so the Run RPC continues instead of sitting silent until the 300s idle watchdog. - Fixed provider tool calls arriving with flattened array argument paths (e.g. Gemini's `questions[0].id`) being stripped and rejected by argument validation; well-formed flattened paths are now rebuilt into the nested arrays the tool schema expects ([#8886](https://github.com/can1357/oh-my-pi/issues/8886)). +- Fixed OAuth login (Codex `localhost:1455`, and any `localhost` callback flow) failing on hosts with IPv6 disabled at the kernel (`ipv6.disable=1`). The `::1` companion listener added in #8081 fails there with Bun's generic "Is port X in use?" message (oven-sh/bun#7187), which the in-use check misread as a real collision — tearing down the healthy IPv4 listener and surfacing a bogus "port 1455 is in use" error. The dual-bind path now detects the missing IPv6 loopback up front and serves IPv4 alone ([#8814](https://github.com/can1357/oh-my-pi/issues/8814)). ## [17.3.7] - 2026-08-17 diff --git a/packages/ai/src/registry/oauth/callback-server.ts b/packages/ai/src/registry/oauth/callback-server.ts index cbb45e563..d3e5a399a 100644 --- a/packages/ai/src/registry/oauth/callback-server.ts +++ b/packages/ai/src/registry/oauth/callback-server.ts @@ -10,6 +10,7 @@ * - generateAuthUrl(): Build provider-specific authorization URL * - exchangeToken(): Exchange authorization code for tokens */ +import * as os from "node:os"; import * as AIError from "../../error"; import templateHtml from "./oauth.html" with { type: "text" }; import type { OAuthController, OAuthCredentials } from "./types"; @@ -58,6 +59,27 @@ function isAddressInUse(error: unknown): boolean { return error instanceof Error && /EADDRINUSE|in use/i.test(error.message); } +/** + * Whether this host exposes an IPv6 loopback (`::1`) the companion listener can + * bind. A kernel with IPv6 disabled (`ipv6.disable=1`) lists no internal IPv6 + * address, and the `::1` companion bind there fails with a generic Bun error + * {@link isAddressInUse} cannot distinguish from a real collision — Bun reuses + * its "Is port X in use?" message for every listen failure (oven-sh/bun#7187) — + * so the dual-bind path must be skipped up front rather than misread as a + * conflict (issue #8814). + */ +function ipv6LoopbackAvailable(): boolean { + const interfaces = os.networkInterfaces(); + for (const name in interfaces) { + const addresses = interfaces[name]; + if (!addresses) continue; + for (const address of addresses) { + if (address.internal && address.family === "IPv6") return true; + } + } + return false; +} + export interface OAuthCallbackFlowOptions { preferredPort: number; callbackPath?: string; @@ -335,12 +357,21 @@ export abstract class OAuthCallbackFlow { if (this.callbackHostname !== DEFAULT_HOSTNAME) { return this.#serve(this.callbackHostname, port, expectedState); } + // A host with IPv6 disabled at the kernel exposes no `::1`, so the + // companion bind cannot succeed there. Bun reports that failure with the + // same generic "Is port X in use?" message it uses for a real collision + // (oven-sh/bun#7187), which the catch below would misread — tearing down + // the healthy IPv4 listener and, for a pinned port, throwing a bogus + // "port in use" ConfigurationError. Detecting the missing stack up front + // lets the IPv4 listener serve alone (issue #8814). + const dualStack = ipv6LoopbackAvailable(); for (let attempt = 0; ; attempt++) { const primary = this.#serve(IPV4_LOOPBACK, port, expectedState); const boundPort = primary.port; // A non-TCP endpoint has no port for the companion to target; // #resolveServerPort reports that case precisely. if (typeof boundPort !== "number") return primary; + if (!dualStack) return primary; let companion: Bun.Server; try { companion = this.#serve(IPV6_LOOPBACK, boundPort, expectedState); diff --git a/packages/ai/test/callback-server-dual-stack.test.ts b/packages/ai/test/callback-server-dual-stack.test.ts index ae0084c38..55c5a4d89 100644 --- a/packages/ai/test/callback-server-dual-stack.test.ts +++ b/packages/ai/test/callback-server-dual-stack.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as os from "node:os"; import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/registry/oauth/callback-server"; import type { OAuthCredentials } from "@oh-my-pi/pi-ai/registry/oauth/types"; @@ -117,4 +118,54 @@ describe("OAuthCallbackFlow loopback address families", () => { expect(flow.lastRedirectUri).toBe(`http://localhost:${port}/callback`); expect(progress.some(msg => msg.includes("unavailable"))).toBe(false); }); + + it("keeps a pinned port when IPv6 is disabled and the companion bind reports a misleading in-use error", async () => { + // Reproduce a host with IPv6 disabled at the kernel (ipv6.disable=1): the + // loopback interface exposes only 127.0.0.1, no ::1 (issue #8814). + vi.spyOn(os, "networkInterfaces").mockReturnValue({ + lo: [ + { + address: "127.0.0.1", + netmask: "255.0.0.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: true, + cidr: "127.0.0.1/8", + }, + ], + }); + // Bun surfaces the IPv6-unavailable companion failure with the same + // generic "Is port X in use?" message it uses for a real collision + // (oven-sh/bun#7187), so the in-use message regex cannot tell them apart. + const realServe = Bun.serve.bind(Bun) as typeof Bun.serve; + vi.spyOn(Bun, "serve").mockImplementation(((options: { hostname?: string; port?: number }) => { + if (options.hostname === "::1") { + throw Object.assign(new Error(`Failed to start server. Is port ${options.port} in use?`), { + code: "EADDRINUSE", + }); + } + return realServe(options as Parameters[0]); + }) as typeof Bun.serve); + + const port = freeLoopbackPort(); + const progress: string[] = []; + const cancel = new AbortController(); + const flow = new TestCallbackFlow( + { + onAuth: () => cancel.abort("advertised"), + onProgress: msg => progress.push(msg), + signal: cancel.signal, + }, + // Pinned redirect URI reproduces the Codex flow: a misclassified + // companion failure would abort with a bogus port-in-use error here. + { preferredPort: port, redirectUri: `http://localhost:${port}/callback` }, + ); + + await expect(flow.login()).rejects.toThrow(); + // The IPv4 listener is the only reachable endpoint, so the flow must serve + // it and advertise the pinned URI rather than misread the companion + // failure as a collision and throw a ConfigurationError before onAuth. + expect(flow.lastRedirectUri).toBe(`http://localhost:${port}/callback`); + expect(progress.some(msg => msg.includes("unavailable"))).toBe(false); + }); });