Merge PR #8982: fix(ai): serve IPv4-only OAuth callback when IPv6 is disabled (@roboomp)

This commit is contained in:
can1357
2026-08-19 11:52:54 +02:00
3 changed files with 83 additions and 0 deletions
+1
View File
@@ -21,6 +21,7 @@
- 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 opencode-go (Console Go) rejecting Responses turns with `400 No tool output found for tool call …` (naming a random call of the batch on each retry) when a model streamed a trailing text/thinking block after its tool calls: `buildResponsesInput` emitted that block as an assistant `message` item wedged between the `function_call` batch and its `function_call_output` items. Such interleaved messages are now hoisted ahead of their call batch (canonical `message(s) → calls → outputs`), which the strict gateway validator accepts; content is unchanged ([#8789](https://github.com/can1357/oh-my-pi/issues/8789)).
- Fixed the OpenAI-wire transport sleeping on a LiteLLM concurrency-admission 429 (`rate_limit_type: max_parallel_requests`, `Retry-After: 60`) and retrying it up to 6 times (~300s) before session recovery saw the error. Because a 60s hint equals the transport's `maxDelayMs` cap, `fetchWithRetry` kept sleeping and retrying; the request now surfaces on the first attempt so `TurnRecovery`'s concurrency backoff/model fallback runs promptly. Genuine RPM/quota 429s (no such marker) still honor `Retry-After` ([#8854](https://github.com/can1357/oh-my-pi/issues/8854)).
- 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);
});
});