dsh-zen-browser
DeepSeek Harness 插件:用浏览器自带的 WebDriver BiDi 驱动
Zen Browser(Firefox 内核)。
不需要 geckodriver、不需要 CDP、不需要任何扩展——zen-browser --remote-debugging-port <port> 会直接开一个
BiDi 端点,插件连上去就能导航、读正文、截图、产生可信输入。
安装
dsh plugin --profile web add dsh-zen-browser # 从 npm 装;或:dsh plugin --profile web add /path/to/dsh-zen-browser
dsh restart # 插件在宿主启动时加载
包内除了插件本体,还带 scripts/(不进 PATH,按需手动装):
P="$(dirname "$(readlink -f ~/.dsh/profiles/web/node_modules/dsh-zen-browser)")/scripts"
"$P/install-zen-agent.sh" # zen-agent → ~/.local/bin + 应用菜单项
"$P/install-daily-entry.sh" # 可选:把日常入口也变成带端口的入口(见下文)
安装后本插件会自带 zen-browser 技能;默认 lazyTools: true,也就是技能被调用后才把 zen_* 工具放开。
在对话里试:
/zen-browser 打开 example.com,把正文读给我,再截个图
工具
| 工具 | action | 说明 |
|---|
zen_session | start / stop / list / attach | start 拉起一个会话;attach 连到你自己带 --remote-debugging-port 启动的 Zen(不新起进程,stop 也不会关闭它) |
zen_page | navigate | 打开 URL 并等待 complete / interactive / none |
zen_inspect | observe / text / html / screenshot / pdf / console / network | observe 产出 @eN 元素引用;screenshot 能内联时直接作为图片回给模型;console/network 是带游标的只读诊断 |
zen_interact | click / hover / fill / type / press / select / scroll-to / wheel / focus / blur | 与页面交互。点击/键入/按键/滚轮走原生输入(isTrusted=true);select/focus/blur/scroll-to 走脚本,结果里显式标注 trusted=false |
zen_tabs | list / create / select / close / containers | 标签页与容器(Zen 的容器/工作区) |
zen_assist | resize / emulate / request-help | 改窗口形态,或把登录/验证码这类步骤交给人类 |
zen_ext | status / install / diagnose / downloads / bookmarks / history / containers / tabs | 伴生扩展桥:补 BiDi 没有的浏览器级能力 |
@eN 元素引用
zen_inspect({action:"observe"}) 会给可交互元素分配 @e1、@e2… 的引用(DOM + ARIA 启发式:角色、可访问名、值、选项、禁用态,外加 cursor:pointer 的普通元素)。
引用在导航后失效,再次 observe 也会整体替换——这时 zen_interact 会返回明确的“引用已失效,请重新 observe”。
配置
装好后编辑 profile 的 cordis.patch.yml(web profile 默认在 ~/.dsh/profiles/web/cordis.patch.yml):
- id: zenbrowser
config:
profileMode: daily
zenPath: zen-browser
headless: false
maxSessions: 3
lazyTools: true
| 选项 | 默认 | 说明 |
|---|
zenPath | zen-browser | Zen 启动器路径(PATH 上的名字或绝对路径) |
profileMode | daily | daily=用你日常的 profile(登录态立即可用);agent=插件专用 profile |
dailyProfilePath | 空 | 显式指定日常 profile 目录;空则从 profiles.ini 解析 |
agentProfileDir | $XDG_DATA_HOME/dsh-zen-browser/profiles | 插件专用 profile 的根目录 |
headless | false | 无窗口启动(那样就没法人工登录了) |
port | 空 | 固定端口;空则每次会话取一个空闲端口 |
launchTimeoutMs | 30000 | 等待浏览器打印 BiDi 端点的时间 |
defaultTimeoutMs | 30000 | BiDi 命令默认超时 |
maxSessions | 3 | 本插件并发会话上限 |
maxTextChars | 40000 | text / html / observe 的截断上限 |
screenshotOrigin | document | 截图范围:document(整页,视口测不出时也能用)或 viewport(仅可见区),另一个会自动回退 |
lazyTools | true | 技能被调用后才注册 zen_* 工具 |
extraArgs | [] | 追加到 Zen 命令行的额外参数 |
关于 profile(重要)
Firefox/Zen 的 profile 是协作式独占的:lock 符号链接指向持有它的进程,第二个实例会被拒绝启动。
所以 profileMode: daily 有个前提:你自己的 Zen 必须先关掉。插件永远不会去结束一个不是它启动的浏览器——
这种情况下 zen_session start 会返回:
{ "status": "profile-locked", "runningPid": 12345, "profile": "…" }
此时让模型把情况告诉你,你关掉 Zen 再重试;或者改用 mode: "agent"(插件专用 profile,可以和你的日常浏览器同时运行,
代价是每个站点要各自登录一次)。
为什么不直接删掉锁文件强行双开?实测过:两个实例共用一个 profile 时,第二个实例的日志会出现
PlacesUtils.sys.mjs … db is undefined 与 UnknownError —— 它打不开 places.sqlite(书签/历史)。
详见仓库里的 计划书.md §5.4。
安全边界
--remote-debugging-port 起开的端口没有鉴权,只绑定 127.0.0.1:本机上任何进程连上去都能控制这个浏览器、读走 Cookie。
- 插件自己拉起的会话用每次随机挑的空闲端口,只随那个实例存活;
- 如果你装了「日常入口」(见上一节),端口就会随你的日常浏览器常驻——那是一个显式的取舍,撤销脚本也写在那一节;
profileMode: daily 时那个实例里装着你的真实身份,因此 agent 默认只操作自己创建的标签,不去动你已有的窗口。
开发
node test/plugin-wiring.mjs # 假 ctx 跑插件接线(技能注册、lazyTools 门控、schema 合法性、所有权边界)
node test/smoke.mjs # 脱离 DSH 的驱动冒烟:拉起 Zen → 导航 → 读正文 → 截图 → 按 pid 回收
node test/smoke.mjs --headed # 带窗口跑
node test/smoke.mjs --profile ~/.config/zen/<你的profile> # 用指定 profile(会先检查锁)
冒烟测试永远只操作它自己启动的那个子进程,并按 pid 结束它。
让 agent 接管你现在用的浏览器(zen-agent)
Firefox 的 Remote Agent 只能在启动那一刻开:命令行 --remote-debugging-port 有效,而写进 user.js 的
remote.enabled / remote.port 一个端口都不开(2026-09-20 用临时 profile 实测,见 计划书.md §15)。
所以「需要时直接控制」的唯一办法是:让日常那个入口带上参数。
./scripts/install-zen-agent.sh # 命令 → ~/.local/bin/zen-agent;外加一个「Zen Browser (可被 DSH 接管)」菜单项
./scripts/install-daily-entry.sh # 把「日常 Zen 入口」也指到 zen-agent(用户级 zen.desktop 覆盖)
install-daily-entry.sh 会在 ~/.local/share/applications/zen.desktop 放一份同名用户级覆盖
(同名即优先于 /usr/share/applications/zen.desktop),只把 Exec 换成 zen-agent,图标 / MimeType / 动作 / 多语言名字全部保留。
于是应用菜单、xdg-open 点链接、默认浏览器全都走带端口的入口。撤销:install-daily-entry.sh --uninstall。
想让 Hyprland 快捷键也走同一个入口,注意 uwsm app -- 继承的是会话 PATH(不含 ~/.local/bin),要写绝对路径:
-- ~/.config/hypr/config/variables.lua
BROWSER = "/home/jianzian/.local/bin/zen-agent"
(hyprctl reload 或重新登录后生效。)
之后怎么用
- 照常从菜单 / 快捷键 / 点链接打开 Zen —— 它本身就是带端口的日常浏览器;
- 让 agent 执行
zen_session({ action: "attach" }) —— 不用给 port,插件会读启动器写的
~/.local/state/dsh-zen-browser/attach.json;
- 浏览器已经在跑时再点链接 / 再按快捷键:
zen-agent 会把请求交接给那个实例(实测 0.16s 返回、
不会产生第二个实例,URL 真的落在已运行的窗口里);如果那个实例没带端口,会弹一次桌面通知提醒你。
随时可以问它现在能不能被接管:
zen-agent --status
# profile : /home/jianzian/.config/zen/wq8es50q.Default (release)
# running pid: 215452
# state port : 60713 pid: 215452
# attachable : yes(zen_session action=attach 可直接接管,不用给 port)
这个实例用的是你日常的 profile:登录态、书签、扩展全在;agent 拿到的是可信输入(真实事件,不是合成事件)。
安全代价(要接受才把它当日常入口):调试端口没有鉴权、只绑 127.0.0.1,但本机任何进程连上就能控制这个浏览器、
读走所有已登录 Cookie;当日常入口 = 这个暴露面一直在,而且界面上没有任何提示。端口随该浏览器进程存活,关掉浏览器即消失。
不接受就撤销:install-daily-entry.sh --uninstall,并把 BROWSER 改回 zen-browser。
zen-agent 支持 --profile <dir>(显式指定时不做交接,锁被占就直接报错退出)/ --port <n>(固定端口,被占用时明确报错而不是静默无端口)/
--status / --dry-run。
多进程共用一条连接(zen-call)
Firefox 的 Remote Agent 一个浏览器只允许一个 BiDi 会话:第二个连接会拿到 Session already started。
所以插件连着的时候,你自己的脚本、另一个 DSH 实例都连不上——除非先把插件的会话 stop 掉。
插件的做法是把连接复用出去:它在 loopback 上开一个小桥(src/rpc.js),把 zen_* 工具面暴露给本机其它进程,
端口与 token 写进 $XDG_STATE_HOME/dsh-zen-browser/rpc.json(0600);scripts/zen-call 是配套客户端:
./scripts/zen-call status # 看桥和当前会话
./scripts/zen-call zen_session '{"action":"attach"}' # reused:true —— 复用插件那条连接
./scripts/zen-call zen_tabs '{"action":"list"}'
./scripts/zen-call zen_page '{"action":"navigate","url":"https://example.com"}'
./scripts/zen-call zen_inspect '{"action":"text","maxChars":500}'
- 不会开第二条 BiDi 会话:
attach 直接返回插件已有的会话(reused: true),实测 zen_session list 始终只有 1 个会话;
- 走的是同一份工具实现,结果与对话里调用完全一致(需要内联图片的能力——比如截图——会退化成「返回文件路径」);
- 鉴权:请求带
x-zen-token 头或 ?token=,不对就 403;
- 关掉:
cordis.patch.yml 里 rpcBridge: false(想固定端口用 rpcPort)。
安全边界:桥只绑 127.0.0.1,但它把「控制这个浏览器」的入口开放给本机任意进程(能读 rpc.json 就能拿到 token)。
和常驻端口属于同一类取舍,区别是这里有 token、而且可以随时关掉。
伴生扩展(zen_ext)
插件自带一个 MV2 扩展,用 zen_ext({action:"install"}) 通过 BiDi 自动安装(未签名也能装,不需要手动 about:debugging)。
扩展通过 loopback 的 HTTP + SSE 桥与插件通信,补上 BiDi 完全没有的能力:下载记录、书签、历史、具名容器。
但它控制不了当前页面,也给不了可信输入——实测(Zen 1.22.1b,有头无头一致):扩展派发的 click 是 isTrusted: false
(Firefox 没有 chrome.debugger 等价物),运行时安装的临时扩展还拿不到初始标签页枚举,
tabs.query 任何过滤条件都返回 0,只有装完之后新建的标签页可见;省略 tabId 走活动标签页又被 activeTab 权限挡住(需要用户手势)。
想控制当前页,要么把扩展签名后永久安装(AMO unlisted),要么给它加个工具栏按钮让用户点一下授权。
已知限制
zen_assist 的 resize 依赖 browser.setWindowBounds,Firefox 的 BiDi 支持不稳定(实测同一台机器上时好时坏);不可用时返回 status: "unsupported" 而不是报错。
emulate(browsingContext.setViewport)在有头窗口上可能挂住不返回(实测),因此只给 6 秒超时并在失败时提示改用截图。
- Zen 不支持通过 BiDi 把标签切到前台:
browsingContext.activate 抛 selectedBrowser is null,前台建标签还抛
TabManager.getBrowserForTab(...) is null。所以 zen_tabs create 建出来的是后台标签(点一下标签栏里的它才看得到),
zen_tabs select 只切「本插件的目标标签」,不会把浏览器可见标签切到前面。
- Zen 不支持通过 BiDi 建窗口(
openWindow() not supported in Zen):zen_tabs create type=window 直接给出可读报错。
- 没有前端可视化面板(P4)。
- 伴生扩展桥能补浏览器级数据,但控制不了当前页面(见上一节的实测),
webRequest 全量抓包仍未接。
测试
node test/plugin-wiring.mjs # 假 ctx:技能注册、lazyTools 门控、schema、所有权边界
node test/smoke.mjs # 驱动冒烟:拉起 Zen → 导航 → 读正文 → 截图 → 按 pid 回收
node test/interact-exec.mjs # observe/@eN + 可信点击/填写/按键/悬停/滚动 + 失效引用契约
node test/p2b-exec.mjs # 标签页、PDF 导出、console/network 游标、assist
node test/zen-agent-exec.mjs # 启动器 e2e:zen-agent 拉起 → 不传 port 直接 attach → stop 后浏览器仍活着
bash test/zen-agent-branches.sh # 启动器分支:start / handoff / attachable 判定 / 固定端口占用 / dry-run 无副作用
node test/json-output.mjs # 工具输出必须是 lossless JSON(undefined / NaN 会被严格调用方判成失败)
node test/rpc-exec.mjs # 连接复用:外部进程经工具桥复用同一条 BiDi 连接(reused / 403 / 未知工具)