mcpField
W
windows-host-browser

windows-host-browser

github.com/iliyasoneAgent workflows068 installsUpdated 6d ago
Repository

About windows-host-browser

windows-host-browser is a skill for operating the long-lived Chrome instance on Iliyas's Windows PC, for agents that need access to that browser session. It connects through CDP and tunnels, with rules for tab handling, proxy changes, and account-sensitive actions.

Key features

  1. 01Connect to CDP with connect.sh
  2. 02Inspect and reuse existing tabs
  3. 03Run scripts and monitor network traffic via CDP
  4. 04Manage proxies per Chrome profile
  5. 05Set approval rules for visible and account-changing actions

Use cases

  1. 01Open pages available only in an authenticated browser
  2. 02Handle challenges that block a headless browser
  3. 03Inspect page scripts and network requests
  4. 04Set and restore a proxy for a chosen profile

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/iliyasone/skills --skill windows-host-browser

After installing, type/windows-host-browserin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

cdp.py
connect.sh
mk-shortcut.ps1

Details

From the project README

Windows host browser

The target is a long-lived Chrome on Iliyas's Windows PC, started with --remote-debugging-port=<PORT> --user-data-dir="C:\chrome-debug". It is not a headless sandbox: it is a real browser Iliyas also uses, with his live sessions (Vercel, Google Cloud, and more), rendered on his physical screen.

Two consequences drive everything below:

  • Power — anything behind his logins is reachable without asking for credentials, and a page can be debugged exactly as he sees it: open tabs, run JS in them, watch network traffic, route through a chosen proxy.
  • Restraint — he sees every window you open and shares every setting you change. Tell him when you open something visible, close tabs you opened unless he wants them kept, and undo browser-wide changes (proxy!) when done. For data you could equally get via an authed CLI or a headless browser, prefer those.

When to use it without being asked

Iliyas would rather you use this browser than stop and wait for him. Use it on your own when the cheaper routes failed:

  • a headless browser is stopped by a Cloudflare challenge or a captcha;
  • the page needs one of his logins and no CLI or token covers it;
  • something only happens in his browser or session, and headless can't reproduce it.

Unasked, open your page as a background tab, so his active tab keeps the focus: Target.createTarget with background: true on the browser websocket (GET /json/version → webSocketDebuggerUrl; connect without an Origin header, as cdp.py does), not PUT /json/new. Bring it forward with Target.activateTarget only when he has to act on it (a captcha). If Chrome has no window open, a new window appears anyway; say so. The tab lands in the default profile: if the login you need lives in another profile, ask him. You may read and navigate a handful of pages the task needs, at human pace, in a tab you opened yourself (a blank tab of his you took over under "Reuse existing windows" counts as yours, to navigate and to close). Write in the chat which page you opened and why. A Cloudflare challenge usually clears by itself in this browser; ticking the Turnstile checkbox is fine; any other captcha, ask him to solve it in the open tab.

Ask first before anything that changes state on any of his accounts or costs money: submitting forms, posting, buying, account settings, link clicks that confirm, accept, authorize or log out, OAuth consent, and any non-GET request you send from page JS. Also ask before copying cookies or tokens out of the browser, and before pasting credentials a page shows (API keys, tokens, passwords, env var values) anywhere, the chat included. Other private content may go to him in the chat, not into issues, PRs or other people's chats. Loops, polling and bulk fetching through his browser need his OK: they run on his home IP and his accounts. Ask before changing the proxy of any profile. These rules hold also when he asked you to use the browser, unless his request names the action.

If connect.sh fails while you are acting unasked, stop and tell him. The repairs below (killing Chrome, rewriting the chromedebug task, net stop winnat) close his windows and other agents' tabs; they need his OK.

Another agent may be using the browser at the same time: work in your own tab and never close or navigate tabs that aren't yours (see the blank-tab rule below).

Step 0 — connect: run connect.sh, use the fixed endpoint

On dev-remote the endpoint is fixed: http://127.0.0.1:18800, no matter which port Chrome uses on the Windows side. One command sets everything up:

~/.agents/skills/windows-host-browser/connect.sh
# on success prints:  CDP_HTTP=http://127.0.0.1:18800
export CDP_HTTP="http://127.0.0.1:18800"

The script is idempotent and cheap when everything is already up. When it isn't, it rediscovers the current Windows-side port from the chromedebug scheduled task (the source of truth — the port moves, see "Port gotcha"; never hardcode it), relaunches Chrome via the task if the process is dead (windowless — see "Launching"), and rebuilds both SSH hops (dev-remote → wsl → windows) onto local port 18800.

A systemd timer on dev-remote re-runs it every 2 minutes, so the endpoint is normally already alive: systemctl status cdp-tunnel.timer, logs in journalctl -u cdp-tunnel.service.

If connect.sh prints FAIL, its message names the layer that broke; the sections below are the manual troubleshooting path. As of 2026-09-01 the Windows-side port is 9555 (9222, 9223, 9250, 9333 and 9444 got WinNAT-blocked in turn).

Manual path (what connect.sh automates)

The debug port lives only on the Windows host's own loopback. The home machine (the WSL box iliyasone) and dev-remote are both on Iliyas's Tailscale tailnet, the stable path between them. Reaching the port is a two-hop tunnel — the WSL box cannot see the Windows loopback directly, so it must tunnel to the host too. Discover the port first:

PORT=$(ssh wsl 'ssh windows "schtasks /query /tn chromedebug /xml"' \
        | grep -aoE 'remote-debugging-port=[0-9]+' | grep -oE '[0-9]+')
  • On the WSL box iliyasone: bring the host port onto WSL's loopback:

    pgrep -f "ssh .*-L $PORT:127.0.0.1:$PORT windows" >/dev/null \
      || ssh -f -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 \
             -L "$PORT:127.0.0.1:$PORT" windows
    

    (ssh windows "curl 127.0.0.1:$PORT/json/version" also works without the tunnel, for one-off checks.)

  • On dev-remote: ssh wsl reaches the WSL shell over the tailnet (100.93.231.101). Chain a second forward on top of the WSL→host one above:

    pgrep -f "ssh -N .*-L $PORT:127.0.0.1:$PORT wsl" >/dev/null \
      || setsid ssh -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 \
               -L "$PORT:127.0.0.1:$PORT" wsl >/dev/null 2>&1 < /dev/null &
    

Then run the check:

curl -s --max-time 4 "http://127.0.0.1:$PORT/json/version" || echo NO_CDP

A Chrome version → go to Step 1. NO_CDP → causes, cheapest first: a tunnel isn't up (re-run the forwards above); the port was stolen by WinNAT even though Chrome is running (see "Port gotcha"); the home PC / WSL box is offline (tailscale status — if iliyasone is offline, the machine is off, nothing to fix from here); or the debug Chrome isn't running (see "Launching"). Do not fabricate a path.

Step 1 — connect

One gotcha applies to every websocket connection, not just the helper: Chrome was started without --remote-allow-origins, so open CDP websockets with no Origin header or the connection is rejected.

From there it is plain CDP against $CDP_HTTP:

  • GET /json/list — tabs and their websocket URLs; attach with any CDP client to evaluate JS, capture screenshots, or watch network events (Network.enable + Network.requestWillBeSent / responseReceived).
  • PUT /json/new?url=… — open a page (a visible tab on Iliyas's screen — say so when you do it).
  • GET /json/version → webSocketDebuggerUrl — the browser websocket, for Target.* calls such as createTarget.
Reuse existing windows — don't multiply them

Every window and tab is on Iliyas's screen, and he has asked agents to stop spawning new windows. Before opening anything, GET /json/list and reuse what's there, in this order:

  1. A blank tab you opened yourself, or one Iliyas left blank, exists (about:blank or chrome://newtab/): navigate it (attach to its websocket, Page.navigate) instead of creating a target. A fresh blank tab may be another agent's, about to navigate; if you can't tell, treat it as not yours and go to rule 2.
  2. The open tabs are your own or clearly idle (not something Iliyas is actively working in): open your page as a tab in that same window — PUT /json/new does this, it targets an existing window.
  3. A new window only when you deliberately want one — e.g. the existing window is full of Iliyas's unrelated active work, or you need isolation (different window size, a flow he should watch separately). Say why.

Same on cleanup: /json/close/<id> the tabs you opened; never close tabs you didn't open.

Proxy control — cdp.py (per profile)

Ask Iliyas before set or direct on any profile, unless he asked for the proxy change. Reverting your own approved set with direct needs no second ask; a proxy you found already set is his to decide.

Chrome carries the Proxy Switcher extension (iejkjpdckomcjdhmkemlfdapjodcpgih), which owns the proxy setting. The proxy is per Chrome profile, not per window: the browser can run several profiles at once (separate windows, cookies, extension copies), and each can sit behind a different proxy. cdp.py (next to this file) drives it. It defaults to the fixed endpoint http://127.0.0.1:18800 (run connect.sh first); $CDP_HTTP overrides that only when running somewhere else.

python3 cdp.py profiles             # list running profiles by their open tabs
python3 cdp.py get -p 2             # proxy setting of profile 2 from that list
python3 cdp.py set 82.38.65.142 41196 http proxyuser 'PASSWORD' -p proton
python3 cdp.py egress -p proton     # prove the exit IP/country through it
python3 cdp.py direct -p proton     # revert that profile to direct

--profile / -p takes an index from profiles or a substring of a URL/title of a tab open in that profile. With a single profile running it can be omitted; with several it is required — the tool refuses to guess, because setting a proxy on the wrong profile changes what Iliyas is browsing through.

Two gotchas the tool hides: the extension is MV3, so its per-profile service worker sleeps and drops out of the target list — and a sleeping worker in a specific profile can't be woken via Target.createTarget (CDP refuses real profiles' browserContextIds). cdp.py wakes it by window.opening a throwaway tab from one of that profile's own pages, CDP-navigating it to the extension popup, then closing it.

set calls chrome.proxy.settings.set for that profile (exactly what the extension popup does) and, when a username is given, writes the extension's auth-username/auth-password storage and re-arms its onAuthRequired handler so authenticated proxies don't pop a dialog.

Always direct when done on the profile Iliyas browses in himself — a proxy left there changes his own browsing. A dedicated proxy profile can keep its proxy.

Adding the extension to a new profile is a manual step: open https://chromewebstore.google.com/detail/iejkjpdckomcjdhmkemlfdapjodcpgih in that profile's window and have Iliyas click "Add to Chrome" — the install confirmation is native UI, unreachable over CDP.

Where proxies come from

The reverse-api project owns a proxy table (Postgres, Heroku app pinc000): columns scheme, server, port, username, password, country_code. HTTP proxies with user/pass auth, selected by country_code (ISO-3166 alpha-2). Query it rather than hardcoding credentials:

# on dev-remote, heroku CLI is authed to app pinc000
heroku pg:psql -a pinc000 -c \
  "select scheme,server,port,username,password,country_code from proxy where country_code='nl';"

There is no rotation — a proxy is sticky per account — so for browser use pick any row for the country you want.

Launching the debug Chrome

If Step 0 says NO_CDP but ssh wsl works, start Chrome via the scheduled task — never over plain SSH, which lands Chrome in the invisible session 0 where it exits without ever binding the port:

# from the WSL box:
ssh windows 'schtasks /run /tn chromedebug'
# from dev-remote (hop through WSL):
ssh wsl 'ssh windows "schtasks /run /tn chromedebug"'
# then poll: curl -s "$CDP_HTTP/json/version"

The task starts Chrome with --no-startup-window, so a launch is invisible: Chrome runs in the background with CDP up and no window until something opens a page (PUT /json/new then creates a window on his screen — say so). This is what keeps the 2-minute timer from popping Chrome in front of Iliyas after every logon; keep the flag whenever the task action is rewritten. The "Agent Chrome" shortcut opens the window itself after starting the task. The task runs as LogonType=InteractiveToken (General tab: "Run only when user is logged on"), so it launches into his visible session and stores no password.

If the relaunch works but Chrome is dead again minutes later, don't keep relaunching — see "Death gotcha".

Iliyas can open it himself — the desktop shortcut

"Agent Chrome" on the Windows desktop opens this browser by hand — e.g. to sign in to a service so agents can then use the session. It runs C:\chrome-debug\open-debug-chrome.ps1: if the debug Chrome is already running it opens a new window in it; otherwise it starts the chromedebug task, waits for the debug port to answer, and then opens the window (a plain schtasks /run would be silently ignored while the task instance is still running, because the task uses MultipleInstances IgnoreNew — that's why the script checks first). So when he needs to log in somewhere, point him at the shortcut instead of opening tabs for him.

If the shortcut or opener script is missing, recreate both by running mk-shortcut.ps1 (next to this file) on the host via the -EncodedCommand transport. It builds the .lnk in C:\chrome-debug and moves it to the desktop — WScript.Shell fails to save directly into the desktop folder because its name is Cyrillic (Рабочий стол).

Port gotcha — WinNAT can steal the debug port

Symptom: the debug Chrome is running and browses fine, but $CDP_HTTP/json/version refuses the connection and no DevTools server exists. Launched with --enable-logging --v=1, C:\chrome-debug\chrome_debug.log shows bind() ... Only one usage of each socket address ... (0x2740) then Cannot start http server for devtools.

Cause: Hyper-V/WSL2 WinNAT/HNS reserves blocks of TCP ports; after a WSL or host restart a block can include the debug port. It becomes unbindable even though netstat shows nothing on it AND it is absent from netsh int ipv4 show excludedportrange. "Worked yesterday, broken today" = the reserved block moved onto the port. Custom user-data-dir, policies, and session 0 are red herrings here — confirm with the log line above.

Recovery (no admin, no service restart):

  1. Find a free port — try to bind candidates and pick the first that succeeds:

    foreach ($p in 9223,9250,9333,9555,18222) {
      try { $l=[System.Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,$p)
            $l.Start(); "OK $p"; $l.Stop() } catch { "FAIL $p" } }
    
  2. Repoint the task to that port. Use PowerShell Set-ScheduledTask, not schtasks /change — the latter wrongly prompts for a Windows password even though the task stores none:

    $a = New-ScheduledTaskAction -Execute 'C:\Program Files\Google\Chrome\Application\chrome.exe' `
         -Argument '--remote-debugging-port=<PORT> --user-data-dir=C:\chrome-debug --no-startup-window'
    Set-ScheduledTask -TaskName 'chromedebug' -Action $a
    

    Quoting through ssh wsl 'ssh windows "..."' is brittle; run PowerShell via -EncodedCommand <base64-UTF16LE> to avoid it.

  3. Kill the old instances, relaunch, verify:

    Get-CimInstance Win32_Process -Filter "name='chrome.exe'" |
      ? { $_.CommandLine -like '*chrome-debug*' } | % { Stop-Process $_.ProcessId -Force }
    schtasks /run /tn chromedebug
    (Invoke-WebRequest -UseBasicParsing http://127.0.0.1:<PORT>/json/version).Content
    
  4. Re-run the Step 0 tunnels with the new $PORT.

Durable fix (optional, needs admin): reserve the port so WinNAT won't grab it — net stop winnat; netsh int ipv4 add excludedportrange protocol=tcp startport=<PORT> numberofports=1 store=persistent; net start winnat. Stopping winnat briefly drops WSL2/Hyper-V NAT, which can blip an SSH path that runs through it — do it only when a short interruption is safe.

Death gotcha — Task Scheduler kills Chrome on battery flap

Symptom: the opposite of the Port gotcha — the debug Chrome process dies minutes after every launch (0 chrome.exe with chrome-debug in the command line), while the debug port is free and bindable. schtasks /run /tn chromedebug brings CDP back, then it's dead again within ~2 minutes.

Cause: the PC is a laptop, and a task created with default settings gets StopIfGoingOnBatteries=true / DisallowStartIfOnBatteries=true. Windows' AC/battery status can flap every couple of minutes even with the charger plugged in (battery-care charge limiting, loose connector), and on every flap Task Scheduler terminates the task's Chrome. The proof is Task Scheduler operational-log event 327: Task Scheduler stopped instance ... of task "\chromedebug" because the computer is switching to battery power. The chromedebug task already carries the fixed settings, but any recreation of the task with New-ScheduledTask/Register-ScheduledTask default settings silently reintroduces the killers — so re-check the settings whenever this symptom returns.

Diagnose:

  1. schtasks /query /tn chromedebug /xml — in <Settings>, StopIfGoingOnBatteries / DisallowStartIfOnBatteries must be false and ExecutionTimeLimit PT0S (unlimited). If they aren't, that's the bug — go straight to the fix.

  2. The Task Scheduler operational log is disabled by default; enable it and catch the next death in the act:

    wevtutil sl Microsoft-Windows-TaskScheduler/Operational /e:true
    # after a death:
    Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 100 |
      ? { $_.Message -match 'chromedebug' } | select TimeCreated, Id, Message
    

    Event 327 with "switching to battery power" = this gotcha. No 32x stop-event at all = Chrome itself crashed — add --enable-logging --v=1 to the task action, reproduce, read C:\chrome-debug\chrome_debug.log, then revert the flags.

  3. Battery reality check: Get-CimInstance Win32_Battery (a result means it's a laptop) and [System.Windows.Forms.SystemInformation]::PowerStatus (after Add-Type -AssemblyName System.Windows.Forms) for the current AC state.

Durable fix — replace the task's settings, keeping the action and the InteractiveToken principal (Set-ScheduledTask never touches parts you don't pass). Put this in a .ps1 and run it through the -EncodedCommand transport rather than quoting it inline across the two SSH hops:

$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
     -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew
Set-ScheduledTask -TaskName 'chromedebug' -Settings $s
# from dev-remote: encode UTF-16LE+base64, run via the double hop
B64=$(iconv -f UTF-8 -t UTF-16LE /tmp/fix.ps1 | base64 -w0)
ssh wsl "ssh windows 'powershell -NoProfile -EncodedCommand $B64'"

(-ExecutionTimeLimit zero matters: New-ScheduledTaskSettingsSet otherwise defaults it to 72 h, which would kill Chrome three days in.) Then relaunch and — the real test — confirm the process count is still non-zero 4–5 minutes later, past the flap interval, not just that CDP answered once.

How the access is wired (and repairing it)

Both machines are nodes on Iliyas's Tailscale tailnet:

  • iliyasone = 100.93.231.101 — the WSL box on the home PC. Tailscale runs inside WSL, not on Windows; the Windows host is reached through WSL via ssh windows (→ 127.0.0.1:2222, key ~/.ssh/id_win). The Windows loopback is not shared into WSL, so reaching the debug port from WSL needs the ssh -L ... windows tunnel from Step 0.
  • dev-remote = 100.105.176.17. Its ~/.ssh/config has Host wsl → 100.93.231.101, and its root key is in the WSL box's authorized_keys, so ssh wsl works over the tailnet.

Tailnet addresses survive home-IP changes, so nothing needs reconfiguring when Iliyas's network moves. When connect.sh fails (or a manual check says NO_CDP):

  • Far end offline (PC asleep / WSL not up): tailscale status shows iliyasone offline. Nothing to fix from dev-remote.
  • Chrome not running but ssh wsl works: see "Launching".
  • Port stolen by WinNAT: see "Port gotcha".
  • Chrome dies again right after relaunch: see "Death gotcha".
  • Tunnel not up: re-run connect.sh (it rebuilds both hops).
  • MTU blackhole on the tailnet path (seen 2026-08-15): ping works but ssh wsl hangs at expecting SSH2_MSG_KEX_ECDH_REPLY, or small CDP calls (/json/version) work while big ones (/json/list) return nothing — large packets are being dropped between dev-remote and iliyasone. Two-part fix: force a small classic KEX on every ssh to wsl (-o [email protected],curve25519-sha256 — the default post-quantum sntrup761 KEX sends oversized packets), and lower the interface MTU on dev-remote: ip link set dev tailscale0 mtu 1200 (default 1280 exceeds the real path MTU). The MTU setting does not survive a dev-remote reboot, but connect.sh re-applies it on every non-fast-path run; check ip link show tailscale0 if big transfers stall anyway. Tunnels opened before the MTU fix keep their broken MSS — restart them after changing the MTU.

Only one home node is on the tailnet today (iliyasone). If Iliyas later works from a different machine, it joins as a separate node with its own name/IP — repoint Host wsl (or add Host wsl-<name>) at it. Do not invent non-tailnet routes.

Comments

Markdown supported