{"slug": "claude-in-chrome-doesn-t-work-on-wsl2-here-s-the-fix-one-logged-in-chrome-two", "title": "Claude in Chrome doesn't work on WSL2. Here's the fix: one logged-in Chrome, two drivers, cheap tokens", "summary": "A developer solved the problem of Claude Code in WSL2 not working with the Claude in Chrome extension by using the Chrome DevTools Protocol (CDP) instead. The solution involves running a single Chrome instance on Windows with remote debugging enabled, then attaching two CDP clients from WSL2: one for cheap accessibility tree snapshots and another for network/console/Lighthouse access. This setup avoids expensive screenshot-based interactions and maintains a persistent logged-in session.", "body_md": "Claude Code can drive Chrome natively through the Claude in Chrome extension, and it will not do it from WSL2. The reason is structural rather than a missing flag. The extension runs inside Windows Chrome, which discovers native messaging hosts through the Windows registry, while Claude Code running in WSL installs its host manifest into the Linux filesystem at `~/.config/google-chrome/NativeMessagingHosts/`\n\n, pointing at a Linux binary. Neither half can see the other. On my machine the Linux manifest is sitting there and the Windows registry key simply doesn't exist, so `/chrome`\n\nreports \"Extension not detected\" and no amount of reinstalling changes that. The docs say WSL is unsupported. There are open issues that suggest this gets fixed eventually, and there's an unofficial Windows-side bridge if you enjoy that kind of thing, but I wanted something that worked now.\n\nSo you fall back to the Chrome DevTools Protocol, which works, and which is where every guide I found stops: you can now take a screenshot. Screenshots are the expensive part. A single page costs a few thousand tokens as an image, and an agent working through a ten step form takes one at every step. The thing it actually needs, which elements exist and which ones it can click, comes to a few hundred tokens if you ask for the accessibility tree instead.\n\nThe other half of the problem is login. A fresh Chrome for Testing has never seen your app's sign-in screen, so if your dev app puts everything behind auth, an agent driving that browser spends its life staring at a login form. You want the browser you already logged into, and you want to drive it with something cheaper than pictures. That's the setup below: one Chrome on 9222, two drivers attached to it, and a rule for which one to use when.\n\n```\nWindows\n  chrome.exe\n    --remote-debugging-port=9222        CDP server, HTTP + WebSocket\n    --user-data-dir=...\\ChromeDebugProfiles\\myapp     isolated, stays logged in\n    bound to 127.0.0.1 only\n\n        localhost:9222   (shared loopback via networkingMode=Mirrored)\n\nWSL2\n  agent-browser          attaches by CDP     cheap a11y snapshots, default driver\n  chrome-devtools-mcp    attaches by CDP     network, console, Lighthouse\n  Claude Code            drives both\n```\n\nOne browser, two clients on it. CDP is a plain HTTP and WebSocket server and it accepts multiple simultaneous clients, so this isn't a trick anyone should feel clever about. The one rule is that each client gets its own tab, since two clients driving the same active page will conflict.\n\nYou need `networkingMode=Mirrored`\n\nin your Windows `.wslconfig`\n\n, which is the thing that makes `localhost:9222`\n\nmean the same address on both sides of the WSL boundary, and you need a Chrome listening on 9222 with an isolated profile. If you don't have that yet, [Nebrass Lamouchi's post](https://blog.nebrass.fr/playing-with-wsl2-and-chrome-devtools-mcp/) covers the setup properly and I'm not going to re-teach it here. One warning from that side: mirrored mode can fight with Docker Desktop, and running Docker Engine natively in WSL2 was easier than trying to make them coexist.\n\nThe minimum launcher, from Windows or from WSL through `/mnt/c`\n\n:\n\n```\n\"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe\" \\\n  --remote-debugging-port=9222 \\\n  --user-data-dir=\"C:\\Users\\youruser\\AppData\\Local\\ChromeDebugProfiles\\myapp\" \\\n  --no-first-run \\\n  --no-default-browser-check \\\n  --remote-allow-origins='*' &\n```\n\nThe `--user-data-dir`\n\nis doing more work than it looks like. It gives you a profile separate from your everyday Chrome, so the debug browser never touches your real cookies or extensions, and it persists across runs, so you sign into your app once inside it and the session is still there tomorrow. That persistence is most of why this is worth setting up at all.\n\nConfirm it's alive before connecting anything:\n\n```\ncurl -s http://127.0.0.1:9222/json/version | python3 -m json.tool\n```\n\nA healthy response has `\"Browser\": \"Chrome/...\"`\n\nand a `webSocketDebuggerUrl`\n\nin it. If you get nothing back, skip ahead to the gotchas, because one of them is probably why.\n\n`vercel-labs/agent-browser`\n\nis a browser automation CLI aimed at AI agents, Apache-2.0, out of Vercel Labs. I looked at provenance before installing because the package ships a native binary: the npm package does map to the GitHub repo, it's published through GitHub Actions with OIDC and SLSA provenance, and it has zero runtime dependencies with a clean audit. The `postinstall`\n\npulls a prebuilt Rust binary from GitHub releases over HTTPS without verifying a checksum, which is the part I'm least comfortable with. It's standard practice for native-binary npm packages and I installed it anyway, so take that for whatever it's worth.\n\n```\nnpm install -g agent-browser\n```\n\nThe docs will point you at `agent-browser install`\n\n, which downloads Chrome for Testing. Skip it. That's the logged-out browser this whole post is trying to avoid.\n\nFirst run under WSL2 fails like this:\n\n```\n✗ Failed to create socket directory: Permission denied\n```\n\nagent-browser runs a session daemon over a Unix socket under `$XDG_RUNTIME_DIR`\n\n, and on WSL2 `/run/user/1000`\n\nusually doesn't exist. Point it at somewhere writable:\n\n```\nexport XDG_RUNTIME_DIR=/tmp/abr-runtime\nmkdir -p \"$XDG_RUNTIME_DIR\" && chmod 700 \"$XDG_RUNTIME_DIR\"\n```\n\nThe export doesn't survive between separate shell invocations, which matters when an agent is calling the CLI one command at a time and each call gets a fresh shell. Putting it in `~/.bashrc`\n\nwith the mkdir clears that up.\n\n```\nagent-browser connect 9222\n```\n\n`connect 9222`\n\nattaches over CDP to the Chrome you already have running, with your login already in it. If you let agent-browser start its own browser instead, you get a fresh Linux Chrome for Testing that's logged out and wants a display or headless mode, which throws away the only property here worth having. There's also `--auto-connect`\n\n, which discovers a running Chrome for you, though I'd rather name the port than trust discovery.\n\nAsk what's on the page, get back a tree of elements with refs, act on the refs.\n\n```\nagent-browser snapshot\n# textbox \"code\" [ref=e7]\n# textbox \"message\" [ref=e17]\n# button \"Send\" [ref=e18]\n\nagent-browser fill @e7 \"ABC-123\"\nagent-browser type @e17 \"hello, can you help with this?\"\nagent-browser click @e18\nagent-browser get text \"main\"\n```\n\n`get text`\n\nreads the result back as plain text for almost nothing. Screenshot when you genuinely need pixels, like a visual regression or a layout bug, rather than to check whether a click landed.\n\nTwo things caught me out. Refs go stale as soon as the DOM changes, so take a fresh snapshot after anything that re-renders instead of reusing e17 from thirty seconds ago. And for single page apps, `pushstate <url>`\n\nis the navigation command you want, not `open`\n\n. I lost a chunk of an evening to that one: `open`\n\nwaits on a load event that an SPA route change never fires, so it reports a timeout on a page that has in fact loaded and is sitting right there in front of you. `pushstate`\n\ndetects the router, Next.js included, and goes through it.\n\nThe rest of the surface is roughly what you'd guess. `find role|text|label|placeholder|testid <value> <action>`\n\nfor locating things, `get url|title|text|html|value`\n\nand `is visible|enabled|checked`\n\nfor state, `console`\n\n/ `errors`\n\n/ `network requests`\n\n/ `vitals`\n\nfor a quick look, `tab new|list|close`\n\nfor tabs. One wrinkle worth knowing before it confuses you: `type`\n\nisn't a find action, so it stays `type <selector> <text>`\n\nat the top level.\n\nA screenshot of a normal app page runs into the low thousands of tokens, as an image, every time you take one. The accessibility snapshot of that same page is a few hundred tokens of text. I haven't benchmarked this properly, so treat those as the order of magnitude I watched my context fill up at rather than measured figures, but the gap is not subtle and over a ten step flow it shows up on the bill.\n\nThe second order effect interests me more. Because the snapshot is small and structured, Sonnet handles this work fine. Browser driving is repetitive tool calling rather than reasoning, and paying Opus rates to click buttons is silly. You can flip the whole session with `/model sonnet`\n\n, or keep Opus as the driver and hand browser tasks to a Sonnet subagent, which reaches the same session-connected MCP tools without any extra wiring.\n\nNone of that means throwing away chrome-devtools-mcp. It sees things agent-browser doesn't: full network request bodies, structured console output, Lighthouse audits, performance traces, heap snapshots. When I'm working out why a request 500s or why a page feels slow, that's the tool I want. So the split I've landed on is agent-browser to drive and chrome-devtools-mcp to investigate, on the same browser, so switching mid-task costs nothing. I'll admit I'm not certain this is the right factoring rather than just where I stopped tuning it. It has held up for a few months of daily use.\n\nPointing the MCP server at the same Chrome is one line of config:\n\n```\n{\n  \"mcpServers\": {\n    \"chrome-devtools\": {\n      \"type\": \"stdio\",\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"chrome-devtools-mcp@latest\", \"-u\", \"http://127.0.0.1:9222\"]\n    }\n  }\n}\n```\n\nThat's Claude Code's `~/.claude.json`\n\n. Gemini CLI takes the same shape in `~/.gemini/settings.json`\n\nand Copilot CLI in `~/.copilot/mcp-config.json`\n\n.\n\nFor Claude Code, add it user-scoped so every session in every directory picks it up, and add it through the CLI rather than hand-editing the file. Claude Code rewrites `~/.claude.json`\n\nwhile it's running and will happily clobber a manual edit:\n\n```\nclaude mcp add --scope user chrome-devtools -- \\\n  npx -y chrome-devtools-mcp@latest -u http://127.0.0.1:9222\n```\n\nThis is my favourite of the three, partly because I couldn't find it documented anywhere and partly because it survives every reset you'd instinctively reach for.\n\nThe symptom: Chrome won't start on 9222, nothing you know about is listening, `curl`\n\non the CDP endpoint returns nothing at all, and `netstat`\n\ninsists the port is taken. `wsl --shutdown`\n\ndoesn't clear it and neither does a reboot, which is what makes it so disorienting.\n\nThe cause is leftover `netsh interface portproxy`\n\nrules from the old pre-Mirrored era of WSL port bridging:\n\n``` php\n0.0.0.0:9222       -> 127.0.0.1:9222\n172.24.160.1:9222  -> 127.0.0.1:9222\n```\n\nThose are hosted by the Windows IP Helper service, `iphlpsvc`\n\n, running inside `svchost.exe`\n\n, which is why netstat blames svchost. They're persistent Windows configuration rather than a WSL artifact, so rebooting was never going to help. Worth noticing the `0.0.0.0`\n\nlisten address too, since that rule had been quietly exposing 9222 to the whole LAN for who knows how long.\n\nFind it:\n\n```\n/mnt/c/Windows/System32/netstat.exe -ano | grep ':9222' | grep -i LISTENING\n/mnt/c/Windows/System32/tasklist.exe /svc /fi \"pid eq <PID>\"   # shows iphlpsvc\npowershell.exe -Command \"netsh interface portproxy show all\"\n```\n\nDelete it from an elevated PowerShell, since portproxy is protected:\n\n```\nnetsh interface portproxy delete v4tov4 listenport=9222 listenaddress=0.0.0.0\nnetsh interface portproxy delete v4tov4 listenport=9222 listenaddress=172.24.160.1\nnetsh interface portproxy show all\n```\n\nThe socket frees immediately, with no reboot and no `wsl --shutdown`\n\nneeded.\n\nIf your setup wraps the MCP server in something that waits for CDP and then degrades to a noop server instead of crashing, a wrong port looks identical to a broken install. I spent a while on this with one session driving the browser perfectly while another insisted nothing was running, and the answer was that their configs pointed at different ports and only one matched the Chrome I'd actually launched.\n\nWhen the tools vanish, suspect the port before you suspect anything else:\n\n```\ngrep -n \"browser-url\\|--browser\" ~/.claude.json\ncurl -s http://127.0.0.1:9222/json/version\n```\n\nThe same trap catches you with a per-repo launcher that auto-picks a free port. Repo A takes 9222, repo B takes 9223, and now there are two Chromes and only one of them has your login. Pin the port explicitly if you want a single shared browser.\n\nMCP servers connect at startup. Start one before Chrome exists and it holds a dead session, after which every call fails with `Protocol error (Target.setDiscoverTargets): Target closed`\n\nwhile `curl`\n\non 9222 looks perfectly healthy. Chrome first, then `/mcp`\n\nand reconnect. It's a small thing but the error message points nowhere near the actual cause, so it's worth having in your head before you meet it at 1am.\n\nAny local process that can reach `localhost:9222`\n\ncan fully drive that browser, which is simply how CDP works, and two details keep that manageable. Chrome has bound `--remote-debugging-port`\n\nto 127.0.0.1 only since version 111, so it isn't reachable from other machines unless you deliberately add `--remote-debugging-address=0.0.0.0`\n\n, which I'd think hard about before doing. And the isolated `--user-data-dir`\n\nmeans the blast radius is whatever you signed into inside the debug profile rather than your entire browsing life, which is a good reason to keep that profile boring: your dev app and nothing else.\n\nWhen something breaks, paste this before you start theorising:\n\n```\n# Is the logged-in Chrome actually up on 9222?\ncurl -s http://127.0.0.1:9222/json/version | python3 -m json.tool\n\n# What pages does it have open?\ncurl -s http://127.0.0.1:9222/json/list\n\n# Which port is each MCP config pointing at? All should say 9222.\ngrep -n \"browser-url\\|--browser\" ~/.claude.json\n\n# Are the MCP servers connected?\nclaude mcp list | grep chrome-devtools\n\n# Is a stale portproxy rule squatting on 9222? Should be empty.\npowershell.exe -Command \"netsh interface portproxy show all\"\n\n# agent-browser socket dir present?\nls -ld \"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}\"\n```\n\nNearly every problem I've had with this setup was one of those lines returning something I didn't expect. The portproxy rule was the exception and it cost me most of an afternoon, so it's in the list now.", "url": "https://wpnews.pro/news/claude-in-chrome-doesn-t-work-on-wsl2-here-s-the-fix-one-logged-in-chrome-two", "canonical_source": "https://dev.to/karthik_rathod_592ae48161/claude-in-chrome-doesnt-work-on-wsl2-heres-the-fix-one-logged-in-chrome-two-drivers-cheap-5ke", "published_at": "2026-07-16 13:23:56+00:00", "updated_at": "2026-07-16 13:40:58.173611+00:00", "lang": "en", "topics": ["developer-tools", "large-language-models", "ai-agents"], "entities": ["Claude Code", "Chrome DevTools Protocol", "WSL2", "Nebrass Lamouchi"], "alternates": {"html": "https://wpnews.pro/news/claude-in-chrome-doesn-t-work-on-wsl2-here-s-the-fix-one-logged-in-chrome-two", "markdown": "https://wpnews.pro/news/claude-in-chrome-doesn-t-work-on-wsl2-here-s-the-fix-one-logged-in-chrome-two.md", "text": "https://wpnews.pro/news/claude-in-chrome-doesn-t-work-on-wsl2-here-s-the-fix-one-logged-in-chrome-two.txt", "jsonld": "https://wpnews.pro/news/claude-in-chrome-doesn-t-work-on-wsl2-here-s-the-fix-one-logged-in-chrome-two.jsonld"}}