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>
This commit is contained in:
saram ali
2026-05-05 06:45:01 +05:00
committed by GitHub
parent 30bb88d80e
commit 978bc53e80
3 changed files with 28 additions and 0 deletions

View File

@@ -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 <spec>` 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.

View File

@@ -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", () => {

View File

@@ -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"];