From 978bc53e80cccdc23d42324b18c4d20cd4749315 Mon Sep 17 00:00:00 2001 From: saram ali <140950904+SARAMALI15792@users.noreply.github.com> Date: Tue, 5 May 2026 06:45:01 +0500 Subject: [PATCH] fix(gateway): skip IPv6 loopback binding on Windows (#69701) Bind the default loopback gateway listener only to `127.0.0.1` on Windows so libuv dual-stack `::1` behavior cannot wedge localhost HTTP requests. Also keeps non-Windows dual-loopback behavior covered, replaces the redundant Windows passthrough test with guard coverage, and adds the required changelog entry. Fixes #69674. Tests: - pnpm exec oxfmt --check --threads=1 CHANGELOG.md src/gateway/net.ts src/gateway/net.test.ts - pnpm test src/gateway/net.test.ts - pnpm check:changed - GitHub required checks: green Thanks @SARAMALI15792. Co-authored-by: saram ali <140950904+SARAMALI15792@users.noreply.github.com> Co-authored-by: Brad Groux <3053586+BradGroux@users.noreply.github.com> --- CHANGELOG.md | 1 + src/gateway/net.test.ts | 21 +++++++++++++++++++++ src/gateway/net.ts | 6 ++++++ 3 files changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f4f7ff6edd..6074b698071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Gateway/Windows: bind the default loopback gateway listener only to `127.0.0.1` on Windows so libuv's dual-stack `::1` behavior cannot wedge localhost HTTP requests. (#69701, fixes #69674) Thanks @SARAMALI15792. - Plugins/migration: emit catalog-backed install hints when `plugins.entries` or `plugins.allow` references an official external plugin that is not installed, so upgraded configs point operators to `openclaw plugins install ` instead of telling them to remove valid plugin config. (#77483) Thanks @hclsys. - OpenAI/Codex media: advertise Codex audio transcription in runtime and manifest metadata and route active Codex chat models to the OpenAI transcription default instead of sending chat model ids to audio transcription. Thanks @vincentkoc. - Dependencies: refresh runtime and provider packages including Pi 0.73.0, ACPX adapters, OpenAI, Anthropic, Slack, and TypeScript native preview, while keeping the Bedrock runtime installer override pinned below the Windows ARM Node 24 npm resolver failure. diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 5122be8f2b3..dcc36205dbe 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -290,6 +290,10 @@ describe("resolveClientIp", () => { }); describe("resolveGatewayListenHosts", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it.each([ { name: "non-loopback host passthrough", @@ -312,11 +316,28 @@ describe("resolveGatewayListenHosts", () => { expected: ["127.0.0.1"], }, ] as const)("resolves listen hosts: $name", async ({ host, canBindToHost, expected }) => { + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); const hosts = await resolveGatewayListenHosts(host, { canBindToHost, }); expect(hosts).toEqual(expected); }); + + it("skips ::1 on Windows even when IPv6 is bindable", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const canBindToHost = vi.fn().mockResolvedValue(true); + const hosts = await resolveGatewayListenHosts("127.0.0.1", { canBindToHost }); + expect(hosts).toEqual(["127.0.0.1"]); + expect(canBindToHost).not.toHaveBeenCalled(); + }); + + it("still includes ::1 on non-Windows when IPv6 is bindable", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const canBindToHost = vi.fn().mockResolvedValue(true); + const hosts = await resolveGatewayListenHosts("127.0.0.1", { canBindToHost }); + expect(hosts).toEqual(["127.0.0.1", "::1"]); + expect(canBindToHost).toHaveBeenCalledWith("::1"); + }); }); describe("pickPrimaryLanIPv4", () => { diff --git a/src/gateway/net.ts b/src/gateway/net.ts index fd0348c6038..f3eeb29b483 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -330,6 +330,12 @@ export async function resolveGatewayListenHosts( if (bindHost !== "127.0.0.1") { return [bindHost]; } + // Windows: uv_tcp_bind6 creates a dual-stack socket (no UV_TCP_IPV6ONLY), which + // also accepts ::ffff:127.0.0.1 connections. Binding both ::1 and 127.0.0.1 on + // the same port causes non-deterministic TCP routing → HTTP requests hang silently. + if (process.platform === "win32") { + return [bindHost]; + } const canBind = opts?.canBindToHost ?? canBindToHost; if (await canBind("::1")) { return [bindHost, "::1"];