Merge PR #8982: fix(ai): serve IPv4-only OAuth callback when IPv6 is disabled (@roboomp)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user