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. Use this browser only on his explicit request; when he names the
browser in the request, that settles it. 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 by
asking him or via an authed CLI, prefer that.
Step 0 — connect: run , use the fixed endpoint
On dev-remote the endpoint is
fixed:
, no matter
which port Chrome uses on the Windows side. One command sets everything up:
bash
~/.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
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
(a visible window on Iliyas's screen — say so), 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
prints
, 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
) and
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:
bash
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 : bring the host port onto WSL's loopback:
bash
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 :
reaches the WSL shell over the tailnet
(
). Chain a second forward on top of the WSL→host one above:
bash
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:
bash
curl -s --max-time 4 "http://127.0.0.1:$PORT/json/version" || echo NO_CDP
A Chrome version → go to Step 1.
→ 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
(
— if
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
, so
open CDP websockets with
no Origin header or the connection is rejected.
From there it is plain CDP against
:
- — tabs and their websocket URLs; attach with any CDP
client to evaluate JS, capture screenshots, or watch network events
( +
Network.requestWillBeSent
/ ).
- — open a page (a visible tab on Iliyas's screen —
say so when you do it).
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,
and reuse
what's there, in this order:
- A blank tab exists ( or ): navigate it
(attach to its websocket, ) instead of creating a target.
- 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 —
does 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:
the tabs you opened; never close tabs
you didn't open.
Proxy control — (per profile)
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.
(next to this file) drives it. It
defaults to the fixed endpoint
(run
first);
overrides that only when running somewhere else.
bash
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
/
takes an index from
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
(CDP refuses real
profiles' browserContextIds).
wakes it by
ing a
throwaway tab from one of that profile's own pages, CDP-navigating it to the
extension popup, then closing it.
calls
chrome.proxy.settings.set
for that profile (exactly what the
extension popup does) and, when a username is given, writes the extension's
/
storage and re-arms its
handler so authenticated proxies don't pop a dialog.
Always 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
table (Postgres, Heroku app
): columns
scheme, server, port, username, password, country_code
.
HTTP proxies with user/pass auth, selected by
(ISO-3166
alpha-2). Query it rather than hardcoding credentials:
bash
# 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
but
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:
bash
# 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"
This opens a visible window on Iliyas's screen — say so when you do it. 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
task (a plain
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
(next to this file) on the host via the
transport. It builds the
in
and moves it to the
desktop —
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
refuses the connection and no DevTools server exists.
Launched with
,
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
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:
powershell
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
,
not
— the latter wrongly prompts for a Windows password even
though the task stores none:
powershell
$a = New-ScheduledTaskAction -Execute 'C:\Program Files\Google\Chrome\Application\chrome.exe' `
-Argument '--remote-debugging-port=<PORT> --user-data-dir=C:\chrome-debug'
Set-ScheduledTask -TaskName 'chromedebug' -Action $a
Quoting through
ssh wsl 'ssh windows "..."'
is brittle; run PowerShell via
-EncodedCommand <base64-UTF16LE>
to avoid it.
-
Kill the old instances, relaunch, verify:
powershell
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
.
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
with
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
task already carries the fixed settings, but any recreation of
the task with
/
default settings
silently reintroduces the killers — so re-check the settings whenever this
symptom returns.
Diagnose:
-
schtasks /query /tn chromedebug /xml
— in
,
/
DisallowStartIfOnBatteries
must be
and
(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:
powershell
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
to the task action, reproduce, read
C:\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
(after
Add-Type -AssemblyName System.Windows.Forms
) for the current AC state.
Durable fix — replace the task's settings, keeping the action and the
principal (
never touches parts you
don't pass). Put this in a
and run it through the
transport rather than quoting it inline across the two SSH hops:
powershell
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew
Set-ScheduledTask -TaskName 'chromedebug' -Settings $s
bash
# 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'"
(
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:
- = — the WSL box on the home PC. Tailscale runs
inside WSL, not on Windows; the Windows host is reached through WSL via
(→ , key ). The Windows
loopback is not shared into WSL, so reaching the debug port from WSL
needs the tunnel from Step 0.
- = . Its has →
, and its root key is in the WSL box's ,
so works over the tailnet.
Tailnet addresses survive home-IP changes, so nothing needs reconfiguring
when Iliyas's network moves. When
fails (or a manual check says
):
- Far end offline (PC asleep / WSL not up): shows
offline. Nothing to fix from dev-remote.
- Chrome not running but works: see "Launching".
- Port stolen by WinNAT: see "Port gotcha".
- Chrome dies again right after relaunch: see "Death gotcha".
- Tunnel not up: re-run (it rebuilds both hops).
- MTU blackhole on the tailnet path (seen 2026-08-15): works but
hangs at
expecting SSH2_MSG_KEX_ECDH_REPLY
, or small CDP calls
() work while big ones () return nothing — large
packets are being dropped between dev-remote and . Two-part fix:
force a small classic KEX on every ssh to wsl
(-o KexAlgorithms=curve25519-sha256@libssh.org,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 re-applies it on every non-fast-path
run; check 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 (
). If Iliyas later
works from a different machine, it joins as a separate node with its own
name/IP — repoint
(or add
) at it. Do not invent
non-tailnet routes.