diff --git a/CHANGELOG.md b/CHANGELOG.md index fd17216268a..6a4475eef96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/docs_map.md b/docs/docs_map.md index ef67dae4ffd..9e0d04c1021 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md index 0a7ded270b4..da7415c3bf7 100644 --- a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md +++ b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.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 "` 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..cdpUrl` use that exact WSL2-reachable address? diff --git a/extensions/browser/src/browser/chrome.diagnostics.ts b/extensions/browser/src/browser/chrome.diagnostics.ts index 06e5a205575..68a4e38a58b 100644 --- a/extensions/browser/src/browser/chrome.diagnostics.ts +++ b/extensions/browser/src/browser/chrome.diagnostics.ts @@ -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: { diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index df87133d9b2..6294959f764 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -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((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((resolve) => { + portproxy.close(() => resolve()); + }); + } }); it("probes direct ws:// CDP URLs (with /devtools/ path) via handshake instead of HTTP", async () => {