fix(browser): diagnose empty WSL2 Chrome replies (#100590)

* fix(browser): diagnose Windows CDP listener mismatch

Base the repair on #93481 while matching Chromium's IPv4-first, IPv6-fallback listener contract and keeping CDP exposure loopback-scoped.

Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>

* style(docs): format browser troubleshooting table

* docs: refresh browser troubleshooting map

* fix(browser): diagnose reset CDP replies

* docs: refresh browser troubleshooting map

---------

Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>
This commit is contained in:
Peter Steinberger
2026-07-06 07:42:02 +01:00
committed by GitHub
parent 5713f1bec1
commit 2830b3ef38
5 changed files with 107 additions and 17 deletions
+1
View File
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- **TUI startup status:** show `starting up` during post-connect initialization without overwriting active-run or reconnect state. (#93999) Thanks @ml12580.
- **Control UI restart recovery:** recover stale bundle pages through a bounded whole-document refresh after Gateway updates or restarts. (#99111) Thanks @ZengWen-DT.
- **TUI active Gateway ports:** follow the verified active local Gateway port when no explicit URL, port, or remote target is configured. (#73338, #42461) Thanks @haishmg and @vincentkoc.
- **Windows remote browser diagnostics:** distinguish WSL2 portproxy self-loops from Chromium's IPv6 fallback using host listener checks, document source-accurate `v4tov6` recovery, require a non-default profile for Chrome 136+ command-line debugging, and warn against broad CDP exposure. (#54669) Thanks @Owlock and @ZengWen-DT.
- **Apple chat run recovery:** restore active responses from canonical Gateway history after reconnects, foreground resumes, and event gaps, while preserving gateway user-turn identity across Codex and Copilot transcript mirrors to prevent duplicate rows. (#100277)
- **Claude CLI streamed replies:** preserve assistant text already received from Claude CLI when its terminal result envelope is empty, preventing false empty-response failover after a complete streamed answer. (#90450) Thanks @totobusnello.
- **Phone identity normalization:** canonicalize stray plus signs, preserve non-phone iMessage handles, and reject digit-free Signal identities across shared channel routing. (#100467) Thanks @morluto.
+2
View File
@@ -8967,6 +8967,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Critical rule for the Control UI
- H2: Validate in layers
- H3: Layer 1: verify Chrome is serving CDP on Windows
- H4: Diagnose IPv4 and IPv6 before changing portproxy
- H3: Layer 2: verify WSL2 can reach that Windows endpoint
- H3: Layer 3: configure the correct browser profile
- H3: Layer 4: verify the Control UI layer separately
@@ -9497,6 +9498,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Storage
- H2: Limits
- H2: Troubleshooting
- H3: Tool-policy diagnostic
- H2: Related
## tools/skills-config.md
@@ -63,17 +63,58 @@ different error visible from a layer further down.
### Layer 1: verify Chrome is serving CDP on Windows
```powershell
chrome.exe --remote-debugging-port=9222
chrome.exe --remote-debugging-port=9222 --user-data-dir="$env:LOCALAPPDATA\OpenClaw\ChromeCDP"
```
Chrome 136 and later ignore remote-debugging command-line switches for the
default Chrome data directory. Use a separate, non-default data directory as
shown above. See Chrome's
[remote-debugging security change](https://developer.chrome.com/blog/remote-debugging-port).
This does not make the normal signed-in Chrome profile remotely controllable.
From Windows, verify Chrome itself first:
```powershell
curl http://127.0.0.1:9222/json/version
curl http://127.0.0.1:9222/json/list
curl.exe http://127.0.0.1:9222/json/version
curl.exe http://127.0.0.1:9222/json/list
```
If this fails on Windows, OpenClaw is not the problem yet.
If this fails, diagnose the Windows listeners below. OpenClaw is not the
problem yet.
#### Diagnose IPv4 and IPv6 before changing portproxy
Chromium tries to bind remote debugging to `127.0.0.1` first and falls back to
`[::1]` only if the IPv4 bind fails. A persistent `v4tov4` rule listening on
`127.0.0.1:9222` can occupy that endpoint before Chrome starts. Chrome then
falls back to `[::1]:9222`, while the old rule forwards IPv4 traffic back to
its own listener and returns an empty reply.
Check the actual listeners and proxy rules from Windows instead of inferring
them from the Chrome version:
```powershell
netstat -ano | findstr :9222
netsh interface portproxy show all
curl.exe http://127.0.0.1:9222/json/version
curl.exe http://[::1]:9222/json/version
```
Use `tasklist /fi "PID eq <PID>"` for each PID from `netstat`.
- If `chrome.exe` answers on `127.0.0.1`, remove any portproxy rule that also
listens on `127.0.0.1:9222`. Forward only the WSL2-reachable Windows adapter
address to `127.0.0.1`.
- If `chrome.exe` answers only on `[::1]`, point the WSL2-reachable listener at
`::1` with `v4tov6` instead of forwarding to an unused IPv4 address:
```powershell
netsh interface portproxy add v4tov6 listenaddress=WINDOWS_HOST_OR_IP listenport=9222 connectaddress=::1 connectport=9222
```
Bind the listener to the adapter address WSL2 needs. Do not expose the CDP
port on `0.0.0.0`, a LAN address, or a tailnet address: CDP grants control of
the browser session.
### Layer 2: verify WSL2 can reach that Windows endpoint
@@ -158,6 +199,7 @@ Good result:
| `token_missing` | auth configuration problem |
| `pairing required` | device approval problem |
| `Remote CDP for profile "remote" is not reachable` | WSL2 cannot reach the configured `cdpUrl` |
| empty CDP reply / `other side closed` through a portproxy | Windows listener mismatch or a self-loop; inspect both loopback families and `netsh interface portproxy show all` |
| `Browser attachOnly is enabled and CDP websocket for profile "remote" is not reachable` | the HTTP endpoint answered, but the DevTools WebSocket could not be opened |
| stale viewport / dark-mode / locale / offline overrides after a remote session | run `openclaw browser --browser-profile remote stop` to close the session and release the cached Playwright/CDP connection without restarting the Gateway or the external browser |
| timeout around `remoteCdpTimeoutMs` (default 1500ms) | usually still CDP reachability, or a slow/unreachable remote endpoint |
@@ -166,7 +208,8 @@ Good result:
## Fast triage checklist
1. Windows: does `curl http://127.0.0.1:9222/json/version` work?
1. Windows: which of `127.0.0.1` or `[::1]` answers on `/json/version`, and
does that listener belong to `chrome.exe`?
2. WSL2: does `curl http://WINDOWS_HOST_OR_IP:9222/json/version` work?
3. OpenClaw config: does `browser.profiles.<name>.cdpUrl` use that exact
WSL2-reachable address?
@@ -272,13 +272,25 @@ export function formatChromeCdpDiagnostic(diagnostic: ChromeCdpDiagnostic): stri
const websocket = redactedWsUrl ? `; websocket=${redactedWsUrl}` : "";
const wslPortproxyHint =
diagnostic.code === "http_unreachable" && isLikelyEmptyHttpReply(diagnostic.message)
? " In WSL2-to-Windows Chrome setups, this can be a stale netsh portproxy self-loop where svchost/iphlpsvc owns the CDP port instead of chrome.exe; verify with tasklist /svc and curl /json/version, then remove any 127.0.0.1:9222 -> 127.0.0.1:9222 portproxy rule."
? WSL_EMPTY_REPLY_PORTPROXY_HINT
: "";
return `CDP diagnostic: ${diagnostic.code} after ${diagnostic.elapsedMs}ms; cdp=${redactedCdpUrl}${websocket}; ${diagnostic.message}.${wslPortproxyHint}`;
}
// The WSL-side error cannot identify which Windows loopback Chrome owns.
// Send operators to the host listeners before they change the proxy family.
const WSL_EMPTY_REPLY_PORTPROXY_HINT =
" In WSL2-to-Windows Chrome setups, an empty CDP reply can mean netsh is forwarding to the" +
" wrong loopback address. On Windows, inspect `netstat -ano | findstr :9222` and" +
" `netsh interface portproxy show all`, then curl both 127.0.0.1 and [::1]. Chromium prefers" +
" 127.0.0.1 and falls back to [::1] only when the IPv4 bind fails. If svchost/iphlpsvc owns" +
" 127.0.0.1:9222, remove the 127.0.0.1:9222 -> 127.0.0.1:9222 self-loop; if chrome.exe" +
" listens only on [::1], use v4tov6 with connectaddress=::1 for the WSL2-reachable listener.";
function isLikelyEmptyHttpReply(message: string): boolean {
return /empty reply|other side closed|socket closed|terminated before response/i.test(message);
return /empty reply|other side closed|socket closed|connection reset|econnreset|terminated before response/i.test(
message,
);
}
async function diagnoseCdpWebSocketEndpoint(params: {
+42 -10
View File
@@ -2,6 +2,7 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import { createServer } from "node:http";
import { createServer as createTcpServer } from "node:net";
import type { AddressInfo } from "node:net";
import os from "node:os";
import path from "node:path";
@@ -671,17 +672,48 @@ describe("browser chrome helpers", () => {
expect(formatted).not.toContain("supersecret123");
});
it("adds a WSL2 portproxy hint for empty HTTP CDP replies", () => {
const formatted = formatChromeCdpDiagnostic({
ok: false,
code: "http_unreachable",
cdpUrl: "http://172.30.144.1:9222",
message: "fetch failed: other side closed",
elapsedMs: 12,
});
it.each(["fetch failed: other side closed", "fetch failed: read ECONNRESET"])(
"adds a WSL2 portproxy hint for empty HTTP CDP replies: %s",
(message) => {
const formatted = formatChromeCdpDiagnostic({
ok: false,
code: "http_unreachable",
cdpUrl: "http://172.30.144.1:9222",
message,
elapsedMs: 12,
});
expect(formatted).toContain("svchost/iphlpsvc owns the CDP port");
expect(formatted).toContain("127.0.0.1:9222 -> 127.0.0.1:9222");
expect(formatted).toContain("netsh interface portproxy show all");
expect(formatted).toContain("svchost/iphlpsvc owns");
expect(formatted).toContain("127.0.0.1:9222 -> 127.0.0.1:9222");
expect(formatted).toContain("falls back to [::1] only when the IPv4 bind fails");
expect(formatted).toContain("v4tov6");
},
);
it("surfaces Windows listener checks from a real empty-reply CDP probe", async () => {
// A broken portproxy accepts the WSL-side socket and closes it without an
// HTTP body. The host checks must survive the full probe/format path.
const portproxy = createTcpServer((socket) => socket.destroy());
await new Promise<void>((resolve, reject) => {
portproxy.listen(0, "127.0.0.1", () => resolve());
portproxy.once("error", reject);
});
try {
const addr = portproxy.address() as AddressInfo;
const diagnostic = expectFailedChromeCdpDiagnostic(
await diagnoseChromeCdp(`http://127.0.0.1:${addr.port}`, 500, 50),
);
expect(diagnostic.code).toBe("http_unreachable");
const formatted = formatChromeCdpDiagnostic(diagnostic);
expect(formatted).toContain("netstat -ano");
expect(formatted).toContain("v4tov6");
expect(formatted).not.toContain("Chrome 136");
} finally {
await new Promise<void>((resolve) => {
portproxy.close(() => resolve());
});
}
});
it("probes direct ws:// CDP URLs (with /devtools/ path) via handshake instead of HTTP", async () => {