fix(ai): serve ipv4-only oauth callback when ipv6 is disabled

The `::1` companion listener added in #8081 cannot bind on hosts with
IPv6 disabled at the kernel (ipv6.disable=1). Bun reports that failure
with its generic "Is port X in use?" message (oven-sh/bun#7187), which
isAddressInUse misread as a real collision, tearing down the healthy
IPv4 listener and throwing a bogus "port 1455 is in use"
ConfigurationError that blocked Codex login.

#createServer now probes os.networkInterfaces() for an internal IPv6
loopback up front and serves IPv4 alone when none exists, instead of
relying on Bun error classification the message ambiguity defeats.

Fixes #8814
This commit is contained in:
roboomp
2026-08-19 09:33:46 +00:00
parent d94bdfa1bb
commit 8ae547091f
3 changed files with 83 additions and 0 deletions
+1
View File
@@ -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
@@ -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<unknown>;
try {
companion = this.#serve(IPV6_LOOPBACK, boundPort, expectedState);
@@ -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<typeof Bun.serve>[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);
});
});