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
- 01Connect to CDP with connect.sh
- 02Inspect and reuse existing tabs
- 03Run scripts and monitor network traffic via CDP
- 04Manage proxies per Chrome profile
- 05Set approval rules for visible and account-changing actions
Use cases
- 01Open pages available only in an authenticated browser
- 02Handle challenges that block a headless browser
- 03Inspect page scripts and network requests
- 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-browserAfter installing, type/windows-host-browserin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
cdp.pyconnect.shmk-shortcut.ps1Details
From the project READMEWindows 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 wslreaches 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, forTarget.*calls such ascreateTarget.
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:
- A blank tab you opened yourself, or one Iliyas left blank, exists
(
about:blankorchrome://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. - 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/newdoes this, it targets an existing window. - 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):
-
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" } } -
Repoint the task to that port. Use PowerShell
Set-ScheduledTask, notschtasks /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 $aQuoting through
ssh wsl 'ssh windows "..."'is brittle; run PowerShell via-EncodedCommand <base64-UTF16LE>to avoid it. -
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 -
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:
-
schtasks /query /tn chromedebug /xml— in<Settings>,StopIfGoingOnBatteries/DisallowStartIfOnBatteriesmust befalseandExecutionTimeLimitPT0S(unlimited). If they aren't, that's the bug — go straight to the fix. -
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, MessageEvent 327 with "switching to battery power" = this gotcha. No 32x stop-event at all = Chrome itself crashed — add
--enable-logging --v=1to the task action, reproduce, readC:\chrome-debug\chrome_debug.log, then revert the flags. -
Battery reality check:
Get-CimInstance Win32_Battery(a result means it's a laptop) and[System.Windows.Forms.SystemInformation]::PowerStatus(afterAdd-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 viassh 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 thessh -L ... windowstunnel from Step 0.dev-remote=100.105.176.17. Its~/.ssh/confighasHost wsl→100.93.231.101, and its root key is in the WSL box'sauthorized_keys, sossh wslworks 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 statusshowsiliyasoneoffline. Nothing to fix from dev-remote. - Chrome not running but
ssh wslworks: 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):
pingworks butssh wslhangs atexpecting 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 andiliyasone. 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, butconnect.shre-applies it on every non-fast-path run; checkip link show tailscale0if 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.