# dsh-agent-browser · 技术方案 v2 > 状态:**定稿(可实现)**。形态已定:**DSH 插件**(host 半 + client 半设置页 + 转发 CLI skills)。 > 本文记录方式:结论 + **证据(文件/命令/行号)** + 明确标注的未验证项。不写"应该没问题"。 > > **v1 → v2 改了三件事**(都在 §1,有实测证据): > 1. v1 的立身之本「refs 陈旧门控能兜住一切」**实测只有部分成立**(同名元素 + DOM 变动会顺位点到隔壁,§1.1 #8/#9),撤下"靠 CLI 兜住"的假设,改由每步重取快照 + 可选断言兜; > 2. 「不用 MCP」的论证从「token 开销」换成「形态取舍」; > 3. 定位从"薄封装工具面"改成"**一个 delegate 工具 + 插件内部有界循环**"。 > > **v2.1 补充(都已实现并验证)**:内置 skill(§3.2)、安装自检 summary(§4.4)、 > 二进制定位改成**版本无关**(§3.1 第 2 条)。 --- ## 0. 结论先行 | 项 | 定稿 | |---|---| | 形态 | DSH 插件(不是 skills 仓库、不是 MCP 配置) | | 引擎 | **仍是 `agent-browser` CLI**(Rust + CDP),不碰 CDP、不引 Playwright | | 工具面 | 窄核心(4 个常驻)+ 逃生舱 1 个;**决策开**才多注册 1 个 `browser_run` | | 陈旧风险 | **每步重取 snapshot** 消除决策窗口;CLI 的 ref 大部分时候能拒绝陈旧(§1.1 表格 #2/#5/#6),但**同名元素 + DOM 变动时会顺位点到隔壁**(#8/#9),所以另有**可选**新鲜度断言 | | 决策 | **`ctx.judgment`(可选依赖,用途名 `browser.action`)**;缺失/未配置 → **显式报错**,不静默降级 | | 设置页 | `settings.section` 一页:2 行开关 + 进阶项 + 「打开配置文件」;视觉照**通用设置面板**(标题 + 子标题) | | 技能 | **内置** `skills/agent-browser/`(SKILL.md + references),用 `ctx.skills.register()` 注册 | 装插件即得 | | MCP | 不配。理由见 §1.2 | --- ## 1. v1 哪三条是错的(附实测) ### 1.1 ❌「旧 ref 会默默点到别的元素」——**原判有误,复测后修正**(v0.38.2 / v0.39.1 同) DESIGN v1 §2① 把"refs 可跨快照复用 → 旧 ref 指向另一个元素 → 唯一不可逆损害"当作**立身之本**。 第二轮实测(headless、本地页、跑完 `agent-browser close --all`)拿到两组结论,前一组被 v1 采纳, 后一组推翻了它: | # | 操作 | 实测输出 | |---|---|---| | 1 | `snapshot`:Alpha `@e2` / Bravo `@e3` | 按树序分配 | | 2 | 删掉 Alpha 后 `click @e2` | `✗ Unknown ref: e2`(**拒绝**) | | 3 | 整页 `innerHTML` 替换后 `click @e3` | `✗ Could not locate element with role=button name=Bravo` | | 4 | 三行**同名** `Delete` 把 Row3 置顶后 `click @e7` | `✓ Done`,log = `deleted row1` —— 仍指向原元素,不是位置 | | 5 | 同名重复:删掉第一个 `Delete` 后 `click @e2` | `✗ Unknown ref: e2` —— 没有落到幸存的同名按钮 | | 6 | `open` 另一页后 `click @e7` | `✗ Unknown ref: e7` | | 7 | ref 编号 | **单调递增、永不复用**(e1–e3 → e4,e5 → e7–e9 → e10–e12) | | 8 | 唯一 `Pay`:删掉它、**插入一个新的同名 `Pay`**,再 `click @e1`(不重新快照) | `✓ 成功`,log = `NEW` —— **点到了新元素** | | 9 | 三行同名 `Delete`(@e2 指 row2):**删掉 row2** 后 `click @e2` | `✓ 成功`,log = `row3` —— **按同名序号顺位落到了下一个** | **结论(修正版)**:ref 在**元素还在、且没有同名歧义**时确实是身份绑定的(#4、#5、#6); 但**当目标元素消失而页面上还有同名元素时,解析会退回 role+name 的序位匹配,静默点到别的元素**(#8、#9)。 所以「不存在默默点到别的元素这一类」是错的:这一类存在,只是需要"同名元素 + DOM 变动"两个条件同时成立。 v1 当初只测了「删掉后没有任何同名幸存者」(#2、#5)和「排序变化」(#4),恰好绕开了这个组合。 > 因此:`snapshot_id` **强制门控仍不是立身之本**(ref 大多数时候够用),但**"CLI 兜住一切陈旧风险"也不成立**。 > 真正的保险是:**每步重取 snapshot**(§5,决策与执行之间不留窗口)+ `browser_act` 的**可选**新鲜度断言 > `expectUrl` / `expectName`(不匹配 → `stale_page`,执行前拒绝)。批量删除/同名列表这类场景, > 调用方应显式给断言,或改用 `find` + 文本定位。 > 另一处连带修正:CLI 有两种"指不到元素"的措辞(`Unknown ref` / `Could not locate element with role=… name=…`), > 任务循环必须**都**当 stale 处理——只认前者时,元素被删会被误判成动作失败(`src/task/loop.ts` 已修)。 复现命令(无需 DSH 开启任何东西): ```bash # 建一个含 Alpha/Bravo 两按钮的本地页 → open → snapshot → 删 Alpha → click 旧 ref agent-browser eval "document.getElementById('btnA').remove()" && agent-browser click "@e2" # → ✗ Unknown ref agent-browser close --all ``` 复现"静默点到同名元素"(#9): ```bash # 页面上三行同名 Delete → snapshot → 删掉目标那行 → click 旧 ref → 会点到下一行 agent-browser eval "document.querySelectorAll('#list > div')[1].remove()" && agent-browser click "@e2" agent-browser close --all ``` **版本边界**:以上结论在 `agent-browser 0.38.2` 实测,并在 **0.39.1 上复验行为一致**(两种 stale 措辞、同名顺位回退都相同),所以插件要求 CLI **不低于 0.38.2**(`>=0.38.2`),启动时校验;低于下限就明确报错而不是猜。 **为什么不设上限**(初版写的是 `>=0.38 <0.39`,即 0.39.x 会被拒):这个上限在 0.39.1 上把**可用的环境**判成了"环境失败",是纯误报——拒绝一个能正常工作的 CLI,比"少验证一个次版本"代价大得多。放宽前已在 0.39.1(隔离安装)上复验本插件实际用到的全部命令:`--version`、`snapshot -i --json`、`batch --json`、`get title`、`eval -b`、`click @ref`,以及陈旧 ref 仍返回 `Unknown ref`;输出结构与 0.38.2 一致。真出现不兼容,第一次调用就会以 `bad-output` / `cli-error` 如实暴露,而不是靠版本号提前猜。 ### 1.2 ❌「MCP 很吃 token」——方向对,但不是参考项目的结论 事实: - `agent-browser mcp [--tools ]` 确实存在(默认 core,`--tools all` 是那面墙),会以 `mcp____` 全局进 prompt。 - 但**三个参考项目没有一个**是"因为 token 所以拒绝 MCP":[jkudish/jev-browser](https://github.com/jkudish/jev-browser) 反而**以 MCP 为主接口**(首个 commit 就是 MCP),窄工具面的公开理由是"代码拥有循环 / 范围"(CONTRIBUTING.md:3)。 所以 v2 的论证换成**形态**: MCP 的形态是"把 N 个原子动作交给模型,然后等服务端返回"——它**结构上做不到**我们要的:插件内部有界循环、每步新鲜度门控、动作审计、设置页。而参考项目已验证的两个窄形态是:**1 个 delegate 工具**(jkudish:`jev_navigate`)或 **0 工具 + skill**([wy-coliney/jev-browser-use](https://github.com/wy-coliney/jev-browser-use))。我们两样都给:转发 CLI skills + 1 个 `browser_run`。 ### 1.3 ✅「插件内部做自己的决策」——方向正确,且契约已预留位置 [dsh-judgment](../../dsh-judgment/README.md) 的立场是原话:**「它判断,消费者决定」**,且 **loop 必须留在消费者**(README:7-8、:196-197)。 它的契约里已经写好了同一接口的**第二种实现**:`HostModelJudge`("消费者内置,零依赖,把代码枚举好的候选交给宿主 LLM",[contract/index.ts:629-633](../../dsh-judgment/src/contract/index.ts#L629-L633)),配置示例里也**已经预留用途名 `browser.action`**([README.md:92](../../dsh-judgment/README.md#L92))。 **所以本插件的角色 = 消费者 / loop**;`ctx.judgment` = judge。这条与 v1 §2③ 一致,v2 把它从"可选"提为**主路径**。 --- ## 2. 参考项目「怎么接入决策」(全部读过源码) | | [wy-coliney/jev-browser-use](https://github.com/wy-coliney/jev-browser-use) | [jkudish/jev-browser](https://github.com/jkudish/jev-browser) | [forvela/jev-agent-browser](https://github.com/forvela/jev-agent-browser) | |---|---|---|---| | 形态 | Codex **Skill**(无 package.json) | npm + CLI + MCP + library | npm CLI(`jev`) | | 驱动浏览器 | 宿主 CUA 的 tab API(不 spawn 任何东西) | **Playwright**(不是 agent-browser) | **spawn `agent-browser` CLI** ← 唯一同路 | | 决策在哪 | 循环在宿主 `cua_repl` 内,`fetch` 直连 Jev | 代码拥有循环(`navigate.ts`) | 代码拥有循环(`src/loop.js`) | | 决策协议 | 1 个 `choice`(a0…aN + DONE/BLOCKED/WAIT) | `action` choice + `goal_done`/`stuck` noul(**fan-out 三问**) | `operation` choice + `*_target` choice(over refs) + `goal_reached`/`stuck` noul | | 工具面 | **0 个新工具** | **恰好 1 个** `jev_navigate(task, start_url)` | CLI,0 个 MCP 工具 | | 陈旧处理 | 决策后**重读 AX 逐字节比对**,变了就丢弃重决(`bridge.mjs:203-206`) | 每步重打 `data-jev-id` + 动作 4s 超时 + 记 `action_error` | **每步重新 snapshot 再决策**(旧 ref 无机会存在) | | 交回宿主 | 文字输入 / 敏感动作 / 最终校验;`DONE → needs_verification` | `done/goal_achieved/stuck/...` | `input-required` + handoff/continuation/resume | | 设置面板 | 无 | 无 | 无([Ying-Kai-Liao/jev-browser](https://github.com/Ying-Kai-Liao/jev-browser) 只有声明式 `userConfig` 的 `{title, description}`) | **三家共同点(直接决定我们的形态)**: 1. **决策全在插件/技能内部**,宿主只给 goal、候选枚举、授权、最终校验(jev-browser-use `SKILL.md:18`)。**没有一个**去调"宿主提供的判断服务"。 2. **文字输入不进决策面**(wy-coliney 的 `safeKeys` 白名单;jkudish 的 typing 用另一个模型且"Jev never generates text";forvela 的 `input-required` 交回宿主)。 3. **停止门在动作之前判定**,且 `goal_done` / `stuck` 与动作选择是**互不可见的 fan-out 问题**——所以"目标达成"是对动作的真实交叉校验,不是动作的自我合理化(jkudish `questions.ts:10-12`)。 **能直接借的**:① `browser_run` 的 delegate-and-trace 形状(jkudish);② 每步重取快照 + 执行前新鲜度比对(forvela / wy-coliney);③ 文字输入交回宿主;④ 失败分类与 handoff(`needs_verification` 而不是"已完成");⑤ `usage`/耗时进结果;⑥ 网站内容是不可信数据,绝不作为指令。 --- ## 3. 形态 | 层 | 用什么 | 说明 | |---|---|---| | 引擎 | **仍是 CLI** | 薄封装:`spawn` + `--json` 解析 + 版本下限校验;不碰 CDP | | 主面 | `defineTool` + `ctx.tools.register`(`@deepseek-ai/dsh-tools`) | 与 dsh-judgment 相反:那个**零工具**,这个**工具为主** | | 开关 | 配置 gate 注册:`enabled` 为假时**不注册**任何工具 | 注册/dispose 会改变 schema 前缀 → 关掉即 **prompt 零成本**(`dsh-tools` README:173) | | 门控 | **CLI 自己的 `--allowed-domains`**(fail-closed:设了它就关掉 CDP / profile / 状态回放)+ `browser_raw` 的**子命令/参数拒绝表** | `ctx.tools.guard()`(单调 deny)留到 M3,把白名单从"这个工具内部"提到"整条工具流水线" | | 可选 Service | `ctx.browser`(给其它插件用) | 首版可后置 | | 技能 | **内置** `skills/agent-browser/`(SKILL.md + 5 份 references),`ctx.skills.register()` 注册成运行时 skill | 装插件即得;官方全量 skills 需要时仍可用 `agent-browser skills get ` 拉 | ### 工具清单(窄核心 + 逃生舱) | 工具 | 作用 | 备注 | |---|---|---| | `browser_read` | 读:`read ` / text / html / value / attr / find | 只读,不需要新鲜度断言 | | `browser_snapshot` | 精简 a11y 树 + refs;返回 `url` + `digest` | `interactiveOnly` / `depth` / `selector` | | `browser_act` | **唯一变更入口**:click/fill/type/press/select/scroll/hover/upload,一次一个 | 可选 `expectUrl` / `expectName`:不匹配 → `stale_page`,**执行前**拒绝 | | `browser_session` | open / close / list / status / tabs | 命名会话,见 §5.5 | | `browser_screenshot` | 截图(视觉兜底 / 给人看) | path、fullPage | | `browser_raw` | 逃生舱:白名单内的其它子命令 | `args[]`;受 profile 白名单 + `guard` 约束;默认关 | | `browser_run` | **决策开才注册**:一次 `goal` → 插件内部有界循环 → trace + handoff | 见 §5 | **不做 40 个工具**:MCP 的 `--tools all` 是那面墙;DSH 工具面是进 prompt 的,越窄越好。 ### 3.1 「spawn 一个 CLI」到底贵不贵(实测回答) 有人会问:模型本来就能用 bash 直接跑 `agent-browser`,插件再 spawn 一次不是多一层开销? 本机实测(0.38.2,node spawn,30 次均值): | 被测 | 均值 | 说明 | |---|---|---| | `node spawn /bin/echo` | **1.2ms** | spawn 本身的下限 | | `node spawn bash -c "echo"` | **1.4ms** | **bash 路线更贵**:多一层 shell | | `node spawn agent-browser --version` | 57.3ms | 走 PATH 上的 **Node 包装脚本** | | `node spawn bash -c "agent-browser …"` | 59.5ms | bash 路线:同样 +1 层,不会更省 | | `node spawn <原生二进制> --version` | **3.1ms** | 同一版本、同一个二进制,少一层 Node | 同一组命令按子命令看:原生二进制 `get title` 3.7ms / `snapshot -i` 5.1ms / `click @e2` 16.8ms; 走包装层一律 ~57ms。`batch`(一条进程跑多条命令)实测 3 条命令 58.7ms ≈ 1 条的价格。 **结论三条**: 1. **bash 不是零开销**:它是"shell + CLI"两次 spawn(+0.2~0.8ms),插件是"直接 CLI"一次 spawn。 插件这条路**比 bash 更省**,不是更贵。 2. **真正的开销在 CLI 进程本身,而且可以砍掉 11–15 倍**:PATH 上的 `agent-browser` 是 Node 包装脚本 (Homebrew / npx 都如此),它再 spawn 原生二进制。所以 `AgentBrowser` 默认**解析符号链接、 按 CLI 包装层自己的命名规则**找到同目录的 `agent-browser--` 直接 spawn (`resolveCommand` / `nativeBinaryName`,配置里的 `browser.executablePath` 仍可覆盖)。 **规则里没有任何版本号**——它跟着"装的那份 CLI"走;带版本号的是 Homebrew 的 Cellar 路径, 所以 `brew upgrade` 后 `spawn` 报 ENOENT 时**自动重解析并重试一次**(`AgentBrowser.run`), 不让用户为此重启 DSH。 3. **决策循环里 CLI 根本不是瓶颈**:一步 = `snapshot`(5ms) + 一个动作(3–17ms) ≈ **22ms**, 而一次 `ctx.judgment` 判断是 **500ms 量级**(dsh-judgment 实测)。真正的性能论点不在毫秒级: 不做插件时每一步要花**一个宿主模型轮次**(秒级 + token),`browser_run` 把 N 步压进 **1 个轮次**。 > 复现:`pnpm probe` 会打印"实际命令"与每条命令的耗时;包装层 vs 原生二进制可以用 > `command: 'agent-browser'` 对 `command: ''` 各跑一次对比。 ### 3.2 内置 skill(装插件即得,不用自己装) 官方 `agent-browser skills get core --full` / `skills list` 是**全量**参考(含 electron / slack / dogfood 等 9 个专门技能),直接塞进 skill 目录太重、也不适合日常触发。随本包发布的是 **与已装 CLI 版本对齐的核心命令集**: ``` skills/agent-browser/SKILL.md 130 行:核心循环、两个最容易做错的点、定位优先级、 可见性/复用登录态、安全护栏、排障表 skills/agent-browser/references/commands.md 34KB:需要 SKILL.md 未列的命令时才读 snapshot-refs.md / session-management.md / authentication.md / trust-boundaries.md ``` 注册方式(`dsh-skill` 的运行时 skill): ```ts ctx.skills.register({ name, description, whenToUse, content, source: 'dsh-agent-browser', resourceBase: { kind: 'directory', path: <包内 references 目录> }, invocation: { modelInvocable: true, userInvocable: true } }) ``` 三条纪律: 1. **`ctx.skills` 是可选服务**(`ctx.get('skills')`):不在就跳过并记日志,插件其余部分照常工作。 2. **原文逐字使用**:SKILL.md 是同步 CLI 版本的产物,改它会制造第二份真相。插件相关的补充 (工具名、`browser_run`、references 的**绝对路径**)在注册时**追加**在末尾。 3. **为什么不依赖 `.agents/skills`**:DSH 的 filesystem provider 扫的是 user/project 的 `.agents/skills` 根,**插件包里的 `skills/` 不在扫描根里**;而且层级是 project > runtime > user, 所以插件这份会盖过 `~/.agents/skills/agent-browser`(用户手装的那份可以留着给别的 host 用)。 --- ## 4. 设置页(照**通用设置面板**,标题 + 子标题) ### 4.1 视觉事实(来自 `dsh-client-ui-settings-general/lib/client.js`) 通用设置的一行就是「标题 + 子标题 + 右侧开关」: ``` .row { justify-content:space-between; align-items:center; gap:24px; padding:16px 0; border-bottom:.5px solid var(--dsw-alias-border-l2) } .title { font-size:14px; line-height:20px } .description{ color:var(--dsw-alias-label-secondary); margin-top:4px; font-size:12px; line-height:18px } ``` 页面级页头沿用本插件族的既有约定([dsh-judgment/client/styles.css](../../dsh-judgment/client/styles.css) `.jdg-pageTitle` / `.jdg-pageIntro`):**18px/500 标题 + 13px secondary 副标题**。 开关用官方 primitives 的 `Switch`(36×20,`label` 必填,`aria-checked` 驱动视觉)。 ### 4.2 页面结构 ``` 浏览器 ← 18px/500 agent-browser(Rust + CDP)的窄工具面…… ← 13px secondary ┌ ● 浏览器环境就绪 [已安装·灰] ┐ ← summary:绿(两项都过)/ 红 │ │ 可用 → 按钮置灰「已安装」不可点 │ │ 不可用/未检查 → 「检查安装」可点 │ ✓ agent-browser 已安装 0.38.2 │ 检查就**两项**,路径之类的细节不显示 │ ✗ Chrome 没找到 │ 缺 CLI 时右侧多一个 [安装](用户点击才跑) │ 修法:装 Chrome:https://www.google.com/chrome/ │ └────────────────────────────────────────────────────────┘ [浏览器功能 Switch] ← 14px 标题 + 12px 子标题;每行 16px 竖距 + .5px 下边框 [决策 Switch] 工具面 ← 分组标题(12px secondary),保留 [截图工具 Switch] [逃生舱 browser_raw Switch] 进阶 ← 分组标题 [允许域名] ← **上下布局**(title/subtitle 在上,控件占满下面整行) 一行一个(逗号也行),如 a.com、*.b.com ← 子标题 ┌ textarea(4 行,可纵向拉高,整行宽)──────────┐ │ a.com │ │ *.b.com │ └──────────────────────────────────────────────┘ [会话名 input] → --session [有头模式 Switch] → --headed [命令超时 30000 毫秒 ▲▼] → 我们的 spawn 超时;步进器照「通用设置 → 字号」那行 (hover 才浮出箭头),±100ms,**数值可点进去直接输入精确毫秒** [最大输出 0 字符 ⬚] → --max-output;紧凑数值框(**无箭头**),可直接输入 [空闲超时 [30 分钟 ▾]] → --idle-timeout;预设下拉(默认/10m/30m/1h/2h/自定义…) 选「自定义」时右侧才出现毫秒输入框 [内容边界标记 Switch] → --content-boundaries ``` > **「打开配置文件」不在这里**:那是设置外壳的**全局动作**(面板左侧导航底部的那个按钮), > 来自 `settings/openSettingsDocument()`。本插件页面不再重复提供它——之前的实现既重复, > 又因为 `configForms.describe().hasDocument` 与外壳的可用性判断不一致而**一直是灰的**。 **三条行距规则**(第 2、3 条来自真机反馈后的修正): 1. 每一行都是**同一条 16px 竖距 + 同一条 `.5px` 下边框**,只有整页最后一行没有 (General 的 `:last-child{border-bottom:none}`)。 2. **整页第一行不留上内边距**(`.ab-section > .ab-row:first-child{padding-top:0}`)——否则 summary → 第一行会比「副标题 → summary」多出一份 16px,看起来"这里特别大"。 3. **分组标题保留**(工具面 / 进阶 / 配置文件),排版与「向上的间距」**逐字抄 Agent 预设页** (`@deepseek-ai/dsh-client-ui-agent-preset` 的 `.groupHead` + `.group+.group{margin-top:20px}`): `12px / 600 / tertiary / letter-spacing .06em`,组与组之间 `margin-top:20px`。 上一行自带 16px 下内边距 → 视觉上标题上方约 36px、下方 16px,标题明显更靠近它标注的那组。 **行距本身不为了标题破例**(每一行仍是统一的 16px)——两件事分开:组间距用标题的 margin, 行间距用行的 padding。 按钮照抄 skills-mcp 的 `.smp-button`(**`flex:none` 是重点**——少了它,按钮会被左列 `flex:1` 的文本挤成"检/查/安/装"竖排,真机截图里出现过)。 同样来自真机的 CSS 地雷:**别给「控件容器内的所有孩子」统一设宽度**(`.ab-rowControl > * { width:100% }`), 那会把 36px 的 `Switch` 拉成整列宽、开关直接变形。只对 `.ab-input` 设宽即可; `tests/panel.test.mjs` 有一条 CSS 回归测试盯着这条。 **行有两种布局**(不是所有控件都适合挤在右侧): - 默认**左右**:标题/子标题在左,控件在右(开关、短输入)——`flex` 行 + `gap:24px`; - **上下**(`.ab-rowStacked`):文字在上,控件在下面**占满整行**。目前只有「允许域名」用它, 因为它天然是**多行**内容(一行一个域名,`parseDomains` 同时接受换行与逗号),横向输入框会被挤压。 多行控件用 `