Browser: normalize localhost absolute-form CDP hosts (#59236)

* Browser: normalize localhost absolute-form CDP hosts

* CHANGELOG: note localhost absolute-form CDP fix

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
This commit is contained in:
mappel-nv
2026-04-02 08:34:55 -04:00
committed by GitHub
parent e48ee8ae9e
commit 9c22d63669
4 changed files with 22 additions and 2 deletions

View File

@@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai
- ACP/gateway reconnects: reject stale pre-ack ACP prompts after reconnect grace expiry so callers fail cleanly instead of hanging indefinitely when the gateway never confirms the run.
- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. Thanks @vincentkoc.
- Gateway: prune empty `node-pending-work` state entries after explicit acknowledgments and natural expiry so the per-node state map no longer grows indefinitely. (#58179) Thanks @gavyngong.
- Browser/CDP: normalize trailing-dot localhost absolute-form hosts before loopback checks so remote CDP websocket URLs like `ws://localhost.:...` rewrite back to the configured remote host. (#59236) Thanks @mappel-nv.
## 2026.4.1-beta.1

View File

@@ -320,6 +320,14 @@ describe("cdp", () => {
expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc");
});
it("rewrites localhost absolute-form websocket URLs for remote CDP hosts", () => {
const normalized = normalizeCdpWsUrl(
"ws://localhost.:9222/devtools/browser/ABC",
"https://user:pass@example.com?token=abc",
);
expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc");
});
it("rewrites 0.0.0.0 wildcard bind address to remote CDP host", () => {
const normalized = normalizeCdpWsUrl(
"ws://0.0.0.0:3000/devtools/browser/ABC",

View File

@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { makeNetworkInterfacesSnapshot } from "../test-helpers/network-interfaces.js";
import {
isLocalishHost,
isLoopbackHost,
isPrivateOrLoopbackAddress,
isPrivateOrLoopbackHost,
isSecureWebSocketUrl,
@@ -28,6 +29,7 @@ describe("isLocalishHost", () => {
it("accepts loopback and tailscale serve/funnel host headers", () => {
const accepted = [
"localhost",
"localhost.:18789",
"127.0.0.1:18789",
"[::1]:18789",
"[::ffff:127.0.0.1]:18789",
@@ -46,6 +48,13 @@ describe("isLocalishHost", () => {
});
});
describe("isLoopbackHost", () => {
it("accepts localhost absolute-form hostnames", () => {
expect(isLoopbackHost("localhost.")).toBe(true);
expect(isLoopbackHost("LOCALHOST...")).toBe(true);
});
});
describe("isTrustedProxyAddress", () => {
it.each([
{
@@ -394,6 +403,7 @@ describe("isPrivateOrLoopbackAddress", () => {
describe("isPrivateOrLoopbackHost", () => {
it("accepts localhost", () => {
expect(isPrivateOrLoopbackHost("localhost")).toBe(true);
expect(isPrivateOrLoopbackHost("localhost.")).toBe(true);
});
it("accepts loopback addresses", () => {

View File

@@ -400,8 +400,9 @@ function parseHostForAddressChecks(
return null;
}
const normalizedHost = host.trim().toLowerCase();
if (normalizedHost === "localhost") {
return { isLocalhost: true, unbracketedHost: normalizedHost };
const canonicalHost = normalizedHost.replace(/\.+$/, "");
if (canonicalHost === "localhost") {
return { isLocalhost: true, unbracketedHost: canonicalHost };
}
return {
isLocalhost: false,