我给 AI Agent 造了一个「隐藏桌面」,让它替我管十几个平台的账号

0 阅读8分钟

我给 AI Agent 造了一个「隐藏桌面」,让它替我管十几个平台的账号

项目:Moonlighting · 开源地址在文末 关键词:Codex / Claude / MCP / Playwright / Windows 桌面 / 凭据隔离

起因:让 AI 操作浏览器,我连续踩了三个坑

我有一堆平台账号要维护:邮箱、GitHub、几个后台、几个社交号。想让 AI 帮我干,试过三条路,全部翻车:

  1. 截图 + 让它看坐标。页面差 11px 就点空,弹出个错误提示条,后面所有坐标全错位。
  2. 每次开个新 profile。跑完关掉,下次登录态归零,每天重新登录十几次。
  3. 把密码写进 prompt。这个最要命——密码进了模型上下文,就等于进了别人的服务器日志。

后来我换了个思路:别让 AI 去"看"浏览器,让它像人一样坐在一台自己的电脑前。于是有了 Moonlighting:一个桌面 = 一个平台 = 一个账号。


00-landing.png

一、先给它一张自己的桌面

Windows 有个很少人用的 API:CreateDesktopW。它能在你的会话里创建一个独立的桌面对象,上面跑的程序对你的主桌面完全不可见。

# 简化版
hdesk = user32.CreateDesktopW("HermesDesk", None, None, 0,
                              GENERIC_ALL, None)   # 新建一个桌面
​
si = STARTUPINFO()
si.lpDesktop = "HermesDesk"                      # 子进程挂到这张桌面上
si.dwFlags |= STARTF_USESHOWWINDOW
​
CreateProcessW(exe, cmdline, None, None, False,
               CREATE_NEW_CONSOLE, None, cwd, byref(si), byref(pi))

于是 Chrome 在一张"看不见的桌面"上跑起来了。你看不到窗口,但它是一个真的 Chrome:真的渲染、真的 cookie、真的扩展。

这里有个坑值得记:隐藏桌面不会被 DWM 合成,所以 PrintWindow 截出来永远是旧帧。想看到画面只能走 CDP 的 Page.captureScreenshot。我一开始用 PrintWindow 调试了半小时,一直以为程序卡死了。

画面还是要给你看的。所以加了个推流服务:CDP 抓帧 → JPEG → 本地 HTTP,页面上直接能点、能打字,输入会反向送到隐藏桌面。你随时可以接管,AI 卡住的时候你点一下就行。


二、别算坐标,用无障碍树里的 ref

这是效率提升最大的一步。

@playwright/mcp 会把页面做成一份无障碍树快照,每个可交互元素带一个 [ref=e42] 这样的句柄:

- generic [ref=e2]:
  - main [ref=e9]:
    - heading "Sign in to GitHub" [ref=e20]
    - textbox "Username or email address" [ref=e25]
    - textbox "Password" [ref=e28]
    - button "Sign in" [ref=e33]

点 e25 就是点那个输入框,跟坐标没有任何关系。页面重排、窗口缩放、弹窗插入,都不影响。

实测对比(同一台机器):

方式找元素点击
截图 + 视觉估坐标300~800ms,还会错经常偏
无障碍树 ref83~250ms39ms(稳定命中)

ref 有个坑:它是一次性句柄。页面一刷新,旧的 ref 全废,继续点会点到别的东西。所以每次动作后重取快照,失败时按可访问名回退重定位。


三、把密码从模型里拿走

这是我最在意的部分。做法是中间插一个本地凭据代理:

用户 ──掩码弹窗输入──► DPAPI 加密存储 ──handle──► agent
                                                    │
                                    cred_fill(handle)
                                                    │  ①校验 origin
                                                    │  ②按选择器填入
                                                    │  ③读回长度校验
                                                    ▼
                                                 页面

模型拿到的返回值长这样:

{"ok": true, "verified": true, "submitted": true, "id_len": 18, "pwd_len": 12}

只有长度,没有值。它永远不知道密码是什么,但能确认填对了。

三个细节是踩坑踩出来的:

① 必须绑定 origin。 给 https://github.com 存的凭据,在别的域名上直接拒绝。否则一个钓鱼页就能骗走它。

② 必须读回校验。 早期我缓存坐标,结果错误提示条一出现,表单整体下移,账号和密码全被塞进第一个框——这就是我上一篇文章里说的"账号密码显示在第一行"。改成按选择器取实时 rect,填完再读一遍长度。

③ 输入前先 Ctrl+A。 否则第二次注入会追加到旧值后面,密码变成 abc123abc123。


四、一个桌面一个号,但机器上不止一个号

跑通单个之后,问题来了:我要同时管好几个号,每个号的 cookie 不能串。

export HERMES_BROWSER_HOME="E:\browser\junjunni"   # 端口表/token/凭据库,每套必须独立
export HERMES_PROFILES="D:\Hermes浏览器"            # profile 根,可共用
export HERMES_DESK=junjunni                        # 桌面名 = 账号的边界
export HERMES_STREAM_PORT=8766 HERMES_BROKER_PORT=8900

隔离这件事,我踩了三个隐蔽的坑:

坑 1:WMI 起的进程不继承环境变量。 服务需要用 WMI 启动(为了活过 agent 收尾时的进程树清理),但 WMI 创建进程用的是 WmiPrvSE 自己的环境,我 export 的变量全丢了。表现极其阴险:服务显示 UP、页面能打开,数据却写到了默认目录。解法是把隔离变量写进一个 .cmd 包装脚本,让 cmd 先 set 再起 python。

坑 2:两套实例互相认领端口。 判断"这个端口上的服务是不是我"时,我最初只看它能不能回 /health——同一台机器上两套都能回,于是第二套认领了第一套的端口,直接连到别人的浏览器上。现在服务会在 /health 里报 home + desk,身份对不上就当别人的跳过。

坑 3:端口表被覆盖。 ports.json 是按 home 命名的单文件,两套实例共用一个 home 会互相覆盖。所以 home 必须每套独立,profile 根可以共用(每个桌面一个子目录)。


五、还有一层:任务闭环

能登录只是"手"。要真的托管,还得有"脑子"和"闭环"。我写了很小的一层:

obs.py     快照 → 压缩到 ≤6KB(只留可操作节点)
verify.py  断言 DSL:url / text / eval / count
act.py     11 个原子动作,硬白名单
run.py     每步 3 次重试、连败截图、milestone 回滚
mem.py     站点记忆:host+path → 成功动作序列

两个设计决定值得说:

第一个:正文不进上下文。 GitHub 仓库页原始快照 56KB ≈ 1.4 万 token。压完 6KB,因为只保留 link / button / textbox / heading 这些可操作节点,正文要用的时候用 read 单独取。20 步任务从 28 万 token 降到 3 万。

第二个:没有断言的步骤直接拒跑。

for i, s in enumerate(task["steps"]):
    if "check" not in s and not s.get("expect_fail"):
        raise SystemExit(f"第 {i+1} 步没有 check:不允许无校验的动作")

这条规则听起来很严,但它根治了一个最讨厌的失败模式:点了没反应,AI 以为成了,继续往下走,最后错得莫名其妙。现在每步都必须有断言,完成判定只认断言,不认 AI 说"我完成了"。

顺带说,AI 谎报完成这件事是真会发生的。我自己就遇到过:模型说"已提交",页面其实还停在表单上,错误提示条都没消失。


六、实测数字

项数字
快照压缩GitHub 仓库页 56.2KB → 6.0KB(-89%)
单步耗时快照 83250ms、click 39ms、goto 5861797ms
空点击检出构造的必失败用例全部被抓到(点不存在的元素 → 动作 FAIL + 断言 FAIL)
失败恢复重试 3 次 → 第 2 次自动截图 → 断言失败 → 回滚 milestone
隔离两套实例并存:8765/8899/8897 与 8766/8900/8898,/health 各报各的
内核Chrome 155 与 Edge 148 都跑通,不打包、不下载内核

七、说实话:现在做不到什么

写技术文章最没意思的就是只写成功。这几件事我做不了,也不想假装能做:

1. 人机验证绕不过去。 我实测注册 GitHub 时直接吃到 HTTP 403 · 访问暂时受限 · 为什么需要经过「我不是机器人」的验证。不是我技术不够,是平台一看到自动化特征就上验证,根本不给你填表的机会。这条路我明确不碰——停下来交给用户点一下。

2. 短信验证码没戏。 手机在用户手里。

3. 同 IP 批量注册会被封设备。 第二个号大概率就要手机号了。

4. 只有 Windows。 CreateDesktopW、DPAPI、WMI 全是 Win32 原语,mac / Linux 要重写这几处。这是取舍,不是偷懒。

5. 还没有 planner。 现在的任务是显式步骤列表,等于人当 planner。真正的自主决策(给个目标自己拆步骤)还没做,这是下一步。


八、怎么用

pip install moonlighting
moonlighting-setup --home "D:\moonlighting"
moonlighting

接进任意 MCP 客户端只要一行,没有绝对路径:

{ "mcpServers": { "browser": { "command": "moonlighting-mcp" } } }

暴露 27 个工具(25 个 browser_* + cred_list / cred_fill),Claude Desktop、Claude Code、Codex CLI、Hermes 都能直接接。

需要 Windows 10/11、Python 3.10+、Node 18+,以及 Chrome 或 Edge(系统自带 Edge 就行,不下载内核)。


最后

这个项目最初的动机很朴素:我不想把密码交给一个我不认识的服务器,但又确实想让 AI 帮我干重复活。所以我把"手"留在本地,"脑子"用你的模型——密码永远不出这台机器。

现在它能做到:登录、填表、切标签页、收验证码、盯登录态、危险动作停下来问你。做不到的我也写在 README 里了。

如果它帮你省下了来回切换平台、复制验证码的功夫,欢迎点个 star;有问题直接提 issue


项目用 MIT 协议。底层无障碍树能力来自 @playwright/mcp(Apache-2.0),特此说明。