windows-host-browser

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Windows host browser

Windows主机浏览器

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.
目标是Iliyas的Windows电脑上长期运行的Chrome浏览器,启动命令为
--remote-debugging-port=<PORT> --user-data-dir="C:\chrome-debug"
。它并非无头沙箱环境:这是Iliyas日常使用的真实浏览器,包含他的活跃会话(Vercel、Google Cloud等),会在他的物理屏幕上渲染内容。
以下两点决定了后续所有操作的规则:
  • 权限 —— 所有需登录才能访问的内容都可直接获取,无需索要凭据;页面可完全按照他看到的样子进行调试:打开标签页、在其中运行JS、监控网络流量、通过指定代理路由请求。
  • 约束 —— 你打开的每个窗口、更改的每个设置都会被他看到。仅在他明确要求时才可使用此浏览器;当他在请求中指定该浏览器时,才可执行操作。打开可见内容时需告知他,关闭你打开的标签页(除非他要求保留),操作完成后恢复浏览器全局设置(如代理!)。对于可通过询问他或经认证的CLI获取的数据,优先选择这些方式。

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

步骤0 —— 连接:运行
connect.sh
,使用固定端点

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:
bash
~/.agents/skills/windows-host-browser/connect.sh
在dev-remote上,端点是固定的
http://127.0.0.1:18800
,无论Windows端Chrome使用哪个端口。只需一条命令即可完成所有设置:
bash
~/.agents/skills/windows-host-browser/connect.sh

on success prints: CDP_HTTP=http://127.0.0.1:18800

成功时会输出: 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
(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 `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).
export CDP_HTTP="http://127.0.0.1:18800"

这个脚本是幂等的,当所有连接已建立时执行成本很低。若未建立连接,它会从`chromedebug`计划任务中重新发现Windows端当前使用的端口(这是权威来源——端口会变动,详见“端口陷阱”;**切勿硬编码端口**);若Chrome进程已终止,则通过该任务重新启动Chrome(会在Iliyas的屏幕上显示一个可见窗口——需告知他);并重建两条SSH隧道(dev-remote → wsl → windows),映射到本地端口18800。

dev-remote上的systemd定时器每2分钟重新运行一次该脚本,因此端点通常已处于活跃状态:可通过`systemctl status cdp-tunnel.timer`查看状态,日志位于`journalctl -u cdp-tunnel.service`。

如果`connect.sh`输出`FAIL`,其消息会指出故障所在的层级;以下章节是手动排查步骤。截至2026年9月1日,Windows端端口为**9555**(9222、9223、9250、9333和9444依次被WinNAT阻止)。

Manual path (what connect.sh automates)

手动流程(connect.sh自动化的操作)

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:
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
    iliyasone
    : 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
    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:
    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.
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.
调试端口仅存在于Windows主机自身的回环接口上。家用机器(WSL主机
iliyasone
)和
dev-remote
都位于Iliyas的Tailscale网络中,这是二者之间的稳定连接路径。要访问该端口需建立两层隧道——WSL主机无法直接访问Windows回环接口,因此也需建立到Windows主机的隧道。首先需发现端口:
bash
PORT=$(ssh wsl 'ssh windows "schtasks /query /tn chromedebug /xml"' \
        | grep -aoE 'remote-debugging-port=[0-9]+' | grep -oE '[0-9]+')
  • 在WSL主机
    iliyasone
    :将Windows主机端口映射到WSL的回环接口:
    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"
    无需隧道也可工作,适用于一次性检查。)
  • dev-remote
    ssh wsl
    通过Tailscale网络(
    100.93.231.101
    )连接到WSL shell。在上述WSL→主机的隧道基础上,再建立一层转发:
    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 &
然后运行检查:
bash
curl -s --max-time 4 "http://127.0.0.1:$PORT/json/version" || echo NO_CDP
返回Chrome版本信息 → 进入步骤1。返回
NO_CDP
→ 原因(从易到难):隧道未建立(重新运行上述转发命令);端口被WinNAT占用但Chrome仍在运行(详见“端口陷阱”);家用PC/WSL主机离线(
tailscale status
——若
iliyasone
离线,则机器已关机,无法从dev-remote修复);或调试Chrome未运行(详见“启动浏览器”)。请勿自行编造解决方案。

Step 1 — connect

步骤1 —— 建立连接

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).
每个WebSocket连接都存在一个陷阱:Chrome启动时未添加
--remote-allow-origins
参数,因此打开CDP WebSocket时不要携带Origin头,否则连接会被拒绝。
接下来,只需针对
$CDP_HTTP
执行标准CDP操作即可:
  • GET /json/list
    —— 获取标签页及其WebSocket URL;可通过任意CDP客户端附加到标签页,执行JS、捕获截图或监控网络事件(
    Network.enable
    +
    Network.requestWillBeSent
    /
    responseReceived
    )。
  • PUT /json/new?url=…
    —— 打开页面(会在Iliyas的屏幕上显示一个可见标签页——操作时需告知他)。

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 exists (
    about:blank
    or
    chrome://newtab/
    ): navigate it (attach to its websocket,
    Page.navigate
    ) instead of creating a target.
  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.
每个窗口和标签页都会显示在Iliyas的屏幕上,他已要求代理停止生成新窗口。打开任何内容之前,先调用
GET /json/list
并按以下顺序复用现有资源:
  1. 存在空白标签页
    about:blank
    chrome://newtab/
    ):导航该标签页(附加到其WebSocket,调用
    Page.navigate
    ),而非创建新目标。
  2. 已打开的标签页属于你或明显处于空闲状态(不是Iliyas正在处理的内容):在同一窗口中打开你的页面——
    PUT /json/new
    会执行此操作,它会针对现有窗口。
  3. 仅在刻意需要时才打开新窗口——例如,现有窗口中充满了Iliyas无关的活跃工作内容,或者你需要隔离环境(不同窗口尺寸、他需要单独查看的流程)。需说明原因。
清理时同理:调用
/json/close/<id>
关闭你打开的标签页;切勿关闭你未打开的标签页。

Proxy control —
cdp.py
(per profile)

代理控制 ——
cdp.py
(按配置文件)

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.
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
--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.open
ing 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.
Chrome安装了Proxy Switcher扩展(
iejkjpdckomcjdhmkemlfdapjodcpgih
),该扩展负责代理设置。代理是按Chrome配置文件设置的,而非按窗口:浏览器可同时运行多个配置文件(独立窗口、Cookie、扩展副本),每个配置文件可使用不同的代理。
cdp.py
(与本文档同目录)用于控制该扩展。它默认使用固定端点
http://127.0.0.1:18800
(需先运行
connect.sh
);仅在其他环境运行时,
$CDP_HTTP
才会覆盖该端点。
bash
python3 cdp.py profiles             # 通过打开的标签页列出运行中的配置文件
python3 cdp.py get -p 2             # 获取该列表中配置文件2的代理设置
python3 cdp.py set 82.38.65.142 41196 http proxyuser 'PASSWORD' -p proton
python3 cdp.py egress -p proton     # 验证该配置文件的出口IP/地区
python3 cdp.py direct -p proton     # 将该配置文件恢复为直接连接
--profile
/
-p
参数可接受
profiles
命令返回的索引,或该配置文件中打开的标签页的URL/标题的子字符串。仅运行单个配置文件时可省略该参数;运行多个配置文件时必须指定——工具不会自动猜测,因为错误的配置文件设置代理会影响Iliyas的浏览。
工具隐藏了两个陷阱:该扩展是MV3版本,因此其按配置文件的服务工作线程会休眠并从目标列表中消失——且特定配置文件中休眠的工作线程无法通过
Target.createTarget
唤醒(CDP拒绝真实配置文件的browserContextIds)。
cdp.py
通过从该配置文件的某个页面
window.open
一个临时标签页,通过CDP导航到扩展弹窗,然后关闭该标签页来唤醒工作线程。
set
命令会针对该配置文件调用
chrome.proxy.settings.set
(与扩展弹窗的操作完全一致);当提供用户名时,会写入扩展的
auth-username
/
auth-password
存储,并重新激活其
onAuthRequired
处理程序,以便需要认证的代理不会弹出对话框。
操作完成后务必将Iliyas日常使用的配置文件恢复为直接连接——残留的代理设置会影响他自己的浏览。专用代理配置文件可保留其代理设置。
将该扩展添加到新配置文件需手动操作:在该配置文件的窗口中打开
https://chromewebstore.google.com/detail/iejkjpdckomcjdhmkemlfdapjodcpgih
,并让Iliyas点击“添加至Chrome”——安装确认是原生UI,无法通过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:
bash
undefined
reverse-api项目维护着一个
proxy
表(Postgres,Heroku应用
pinc000
):列包括
scheme, server, port, username, password, country_code
。带用户名/密码认证的HTTP代理,可按
country_code
(ISO-3166 alpha-2)筛选。请查询该表,而非硬编码凭据:
bash
undefined

on dev-remote, heroku CLI is authed to app pinc000

在dev-remote上,Heroku CLI已认证到应用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.
heroku pg:psql -a pinc000 -c
"select scheme,server,port,username,password,country_code from proxy where country_code='nl';"

代理无需轮换——每个账户对应固定代理——因此浏览器使用时只需选择目标国家的任意一行记录即可。

Launching the debug Chrome

启动调试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:
bash
undefined
如果步骤0显示
NO_CDP
ssh wsl
可正常工作,则通过计划任务启动Chrome——切勿通过普通SSH启动,否则Chrome会进入不可见的会话0,无法绑定端口就会退出:
bash
undefined

from the WSL box:

从WSL主机执行:

ssh windows 'schtasks /run /tn chromedebug'
ssh windows 'schtasks /run /tn chromedebug'

from dev-remote (hop through WSL):

从dev-remote执行(通过WSL跳转):

ssh wsl 'ssh windows "schtasks /run /tn chromedebug"'
ssh wsl 'ssh windows "schtasks /run /tn chromedebug"'

then poll: curl -s "$CDP_HTTP/json/version"

然后轮询: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的屏幕上打开一个可见窗口——操作时需告知他。该任务的`LogonType=InteractiveToken`(常规选项卡:“仅当用户登录时运行”),因此会启动到他的可见会话中,且不存储密码。

如果重新启动成功但Chrome几分钟后再次终止,则不要持续重启——详见“进程终止陷阱”。

Iliyas can open it himself — the desktop shortcut

Iliyas可手动打开——桌面快捷方式

"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 (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 (
Рабочий стол
).
Windows桌面上的**"Agent Chrome"**快捷方式可手动打开此浏览器——例如,登录某个服务以便代理后续使用该会话。它会运行
C:\chrome-debug\open-debug-chrome.ps1
:如果调试Chrome已在运行,则在其中打开一个新窗口;否则启动
chromedebug
任务(若任务实例仍在运行,直接执行
schtasks /run
会被静默忽略,因为任务设置为
MultipleInstances IgnoreNew
——这就是脚本先检查状态的原因)。因此当他需要登录某个服务时,让他点击该快捷方式,而非为他打开标签页。
如果快捷方式或启动脚本丢失,可通过
-EncodedCommand
传输方式在主机上运行
mk-shortcut.ps1
(与本文档同目录)重新创建。它会在
C:\chrome-debug
中生成
.lnk
文件,然后移动到桌面——
WScript.Shell
无法直接保存到桌面文件夹,因为桌面名称是西里尔文(
Рабочий стол
)。

Port gotcha — WinNAT can steal the debug port

端口陷阱——WinNAT可能抢占调试端口

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:
    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" } }
  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:
    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.
  3. 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
  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.
症状:调试Chrome正在运行且可正常浏览,但
$CDP_HTTP/json/version
拒绝连接,且不存在DevTools服务器。启动时添加
--enable-logging --v=1
参数,
C:\chrome-debug\chrome_debug.log
会显示
bind() ... Only one usage of each socket address ... (0x2740)
,然后显示
Cannot start http server for devtools
原因:Hyper-V/WSL2的WinNAT/HNS会保留TCP端口块;WSL或主机重启后,某个端口块可能包含调试端口。即使
netstat
显示该端口无占用,且
netsh int ipv4 show excludedportrange
中也不存在该端口,它仍无法被绑定。“昨天正常,今天故障”意味着保留的端口块覆盖了该调试端口。自定义用户数据目录、策略和会话0都是无关因素——以上述日志行作为判断依据。
恢复方法(无需管理员权限,无需重启服务)
  1. 找到可用端口——尝试绑定候选端口,选择第一个成功的:
    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" } }
  2. 将计划任务指向该新端口。使用PowerShell的
    Set-ScheduledTask
    不要使用
    schtasks /change
    ——后者会错误地提示输入Windows密码,尽管任务并未存储密码:
    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
    通过
    ssh wsl 'ssh windows "..."
    传递引号参数容易出错;建议通过
    -EncodedCommand <base64-UTF16LE>
    运行PowerShell以避免此问题。
  3. 终止旧实例,重新启动并验证:
    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
  4. 使用新的
    $PORT
    重新运行步骤0的隧道。
持久修复(可选,需管理员权限):保留该端口,避免WinNAT抢占——执行
net stop winnat; netsh int ipv4 add excludedportrange protocol=tcp startport=<PORT> numberofports=1 store=persistent; net start winnat
。停止winnat会短暂中断WSL2/Hyper-V NAT,可能导致通过该路径的SSH连接短暂中断——仅在允许短时间中断时执行此操作。

Death gotcha — Task Scheduler kills Chrome on battery flap

进程终止陷阱——计划任务在电源切换时终止Chrome

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:
    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
    --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:
powershell
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
     -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew
Set-ScheduledTask -TaskName 'chromedebug' -Settings $s
bash
undefined
症状:与端口陷阱相反——调试Chrome进程每次启动后几分钟就会终止(命令行中包含
chrome-debug
chrome.exe
进程数为0),但调试端口可用且可绑定。执行
schtasks /run /tn chromedebug
可恢复CDP连接,但几分钟后又会终止。
原因:该电脑是笔记本电脑,默认设置创建的任务会启用
StopIfGoingOnBatteries=true
/
DisallowStartIfOnBatteries=true
。即使充电器已插入,Windows的AC/电池状态也可能每隔几分钟切换一次(电池养护充电限制、连接器松动),每次切换时计划任务都会终止Chrome。证据是计划任务操作日志中的事件327
Task Scheduler stopped instance ... of task "\chromedebug" because the computer is switching to battery power.
chromedebug
任务已配置为固定设置,但使用
New-ScheduledTask
/
Register-ScheduledTask
默认设置重新创建任务时,会重新引入这些终止设置——因此出现此症状时需重新检查设置。
诊断方法
  1. schtasks /query /tn chromedebug /xml
    —— 在
    <Settings>
    中,
    StopIfGoingOnBatteries
    /
    DisallowStartIfOnBatteries
    必须为
    false
    ExecutionTimeLimit
    必须为
    PT0S
    (无限制)。若不是,则这就是故障原因——直接执行修复步骤。
  2. 计划任务操作日志默认是禁用的;启用日志并捕获下次终止事件:
    powershell
    wevtutil sl Microsoft-Windows-TaskScheduler/Operational /e:true
    # 终止事件发生后:
    Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 100 |
      ? { $_.Message -match 'chromedebug' } | select TimeCreated, Id, Message
    事件327且包含“switching to battery power” = 此陷阱。没有32x终止事件 = Chrome自身崩溃——在任务操作中添加
    --enable-logging --v=1
    参数,重现故障,读取
    C:\chrome-debug\chrome_debug.log
    ,然后恢复参数。
  3. 电池状态检查:
    Get-CimInstance Win32_Battery
    (有结果表示是笔记本电脑),以及
    [System.Windows.Forms.SystemInformation]::PowerStatus
    (需先执行
    Add-Type -AssemblyName System.Windows.Forms
    )查看当前AC状态。
持久修复——替换任务的设置,保留操作和
InteractiveToken
主体(
Set-ScheduledTask
不会修改未传递的部分)。将以下内容保存为
.ps1
脚本,通过
-EncodedCommand
传输方式执行,避免跨两层SSH跳转时的引号问题:
powershell
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
     -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew
Set-ScheduledTask -TaskName 'chromedebug' -Settings $s
bash
undefined

from dev-remote: encode UTF-16LE+base64, run via the double hop

从dev-remote执行:编码为UTF-16LE+base64,通过双层跳转运行

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.
B64=$(iconv -f UTF-8 -t UTF-16LE /tmp/fix.ps1 | base64 -w0) ssh wsl "ssh windows 'powershell -NoProfile -EncodedCommand $B64'"

(`-ExecutionTimeLimit`设为0很重要:否则`New-ScheduledTaskSettingsSet`默认会设置为72小时,三天后会终止Chrome。)然后重新启动,并进行真正的测试——确认4-5分钟后进程数仍不为零(超过电源切换间隔),而不仅仅是CDP一次性响应。

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 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
    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.
两台机器都是Iliyas的Tailscale网络中的节点:
  • iliyasone
    =
    100.93.231.101
    —— 家用PC上的WSL主机。Tailscale运行在WSL内部,而非Windows上;Windows主机通过WSL的
    ssh windows
    访问(→
    127.0.0.1:2222
    ,密钥为
    ~/.ssh/id_win
    )。Windows回环接口共享到WSL中,因此从WSL访问调试端口需要步骤0中的
    ssh -L ... windows
    隧道。
  • dev-remote
    =
    100.105.176.17
    。其
    ~/.ssh/config
    中设置了
    Host wsl
    100.93.231.101
    ,且其根密钥已添加到WSL主机的
    authorized_keys
    中,因此
    ssh wsl
    可通过Tailscale网络正常工作。
Tailscale网络地址不受家用IP变化影响,因此Iliyas更换网络时无需重新配置。当
connect.sh
失败(或手动检查显示
NO_CDP
)时:
  • 远端离线(PC休眠/WSL未启动):
    tailscale status
    显示
    iliyasone
    离线。无法从dev-remote修复。
  • Chrome未运行
    ssh wsl
    正常工作:详见“启动浏览器”。
  • 端口被WinNAT抢占:详见“端口陷阱”。
  • Chrome重启后立即终止:详见“进程终止陷阱”。
  • 隧道未建立:重新运行
    connect.sh
    (它会重建两层隧道)。
  • Tailscale链路MTU黑洞(2026年8月15日出现):
    ping
    正常但
    ssh wsl
    卡在
    expecting SSH2_MSG_KEX_ECDH_REPLY
    ,或小CDP调用(
    /json/version
    )正常但大调用(
    /json/list
    )无响应——dev-remote与
    iliyasone
    之间的大包被丢弃。修复分为两部分:强制所有到wsl的ssh连接使用小型经典KEX算法(
    -o KexAlgorithms=curve25519-sha256@libssh.org,curve25519-sha256
    ——默认的后量子sntrup761 KEX会发送超大包);降低dev-remote上的接口MTU:
    ip link set dev tailscale0 mtu 1200
    (默认1280超过实际链路MTU)。MTU设置不会在dev-remote重启后保留,但
    connect.sh
    会在每次非快速路径运行时重新应用;若大传输仍停滞,可检查
    ip link show tailscale0
    。MTU修复前建立的隧道仍会使用错误的MSS——更改MTU后需重启隧道。
目前只有一个家用节点(
iliyasone
)在Tailscale网络中。若Iliyas后续更换机器工作,新机器会作为单独节点加入,拥有自己的名称/IP——将
Host wsl
指向它(或添加
Host wsl-<name>
)。请勿创建非Tailscale路由。