# dsh-browser-tool

DSH（DeepSeek Harness）浏览器工具包：在 Agent 会话中驱动 Chromium 打开标签页、执行脚本、观察页面，并把截图/文本结果直接回传给模型。核心逻辑从 [oh-my-pi](https://github.com/omp) 的 browser 工具迁移而来，保持「open / run / close」三段式语义与 OMP 风格的 browser-relay。

## 能力

- **open** — 开一个命名标签页，四种浏览器来源：
  - **headless**：进程内启动无头 Chromium（`puppeteer-core` + `@puppeteer/browsers` 自动下载，支持 stealth 注入）；
  - **spawned**：detached 启动指定可执行文件（桌面应用/自研 App），通过空闲 CDP 端口 attach，关闭时随进程回收；
  - **connected**：连接已有 CDP 端点（`DSH_BROWSER_CDP_URL`）；不拥有浏览器，释放时只 disconnect；
  - **relay**：通过本地 Browser Relay（`dsh-browser-relay serve`）接管用户自己正在用的 Chrome，带标签页分组排队、debugger ban 恢复等 OMP 语义。
- **run** — 在标签页里运行 JS 单元（async 函数体 + 尾表达式自动返回值）：
  - 作用域：`page`、`browser`、`tab`（完整 Tab API：goto/observe/ariaSnapshot/screenshot/extract/click/type/fill/press/scroll/drag/waitFor*/evaluate/select/uploadFile/ref…）、`display`、`print`、`console`、`assert`、`wait`、`sleep`；
  - 输出：文本流 + 图片内容块（截图自动 resize 至模型像素预算）+ JSON 返回值；
  - `read/write/env/tree/tool/agent/parallel/pipeline/phase/log/budget` 等 omp 专属 helper 明确报错；
  - op 级超时低于 cell 预算（`OP_DEADLINE_SLACK_MS=1000`），超时走 tab worker recycle 或强制回收；
  - **run 作用域隔离**：同一个 tab 的所有 run cell 共享一个长生命周期 worker 和一个 `page`，但 cell 里注册的 `page.on/once/addListener/…` 只活到本次 run 结束——结束时被精确摘除，回调同步抛错或返回 rejected promise 都只记 warn 并写进 run 输出，不会逃逸成进程级 uncaughtException；需要跨 run 自动处理 JS 对话框（如 `beforeunload`）时用 `open` 的 `dialogs:"accept"|"dismiss"`，不要依赖上一条 cell 残留的监听器。
- **close** — 关闭标签页，可 `kill` 一并结束自己拥有的浏览器。
  - 自己拥有（headless/spawned）的浏览器在最后一个标签页关闭后自动回收进程；connected/relay 只 disconnect 不关闭（`kill` 对它们也无效，绝不杀用户自己的浏览器）。
  - **收尾时最后关闭应带 `kill: true`**：工具描述与 `kill` 参数说明都会要求 agent 在结束浏览器工作时这么做，且 `close` 的返回消息会明确告诉 agent 浏览器是否仍存活（其他标签页还持有）或已随本标签终止，形成即时提示，避免遗留 headless Chromium 进程。
  - 会话被销毁（`session/disposed`）时释放该会话创建的标签页，插件卸载时释放全部标签页。
  - **回合结束回灌提示**：agent 某回合结束时仍留有未关闭的标签页，插件会经 `agent.inject()` 给模型注入一条一次性 user-role 提示（"仍有 N 个标签未关，收尾请用 kill:true"），不唤醒 driver，只在下一回合被模型看到；同一批未关标签只提示一次，全部关闭后复位。可用 `DSH_BROWSER_REMIND_AGENT=0` 关闭，同时会打 warn 日志记录泄漏的标签。
- **relay CLI**（`dsh-browser-relay`）：`serve`（HTTP+WS，伪装 Chrome CDP discovery `/json/version`、`/json/list`、`/cdp`）、`install`（把扩展装入 `~/.dsh/browser-relay/extension`）、`status`。扩展资产提交在 `src/assets/relay/extension-assets`。

## 结构

```
src/
  index.ts               工具注册（单工具 `browser`：open/run/close）
  config.ts              DSH_BROWSER_* 环境变量合并
  browsers/              浏览器核心
    registry.ts            四种 kind 的获取/持有/释放（引用计数）
    launch.ts              headless 启动、stealth、UA 覆盖、Chromium 下载
    tab-supervisor.ts      标签生命周期与 tab worker 编排
    tab-worker.ts          Tab API 实现（页面侧）
    runtime.ts             单元运行时（尾表达式返回、display/print）
    run-listeners.ts       run 作用域监听器隔离 + dialog 幂等收尾（handled 即跳过）
    aria/aria-snapshot.ts  aria 快照与 ref 选择器
    final-expr.ts          Babel 末表达式注入（移植 omp returnFinalExpression）
  relay/                  Browser Relay
    server.ts               HTTP+WS 服务（/ext /cdp /json/*）
    bridge.ts              CDP 伪装：Target 域、minted tab/page 会话、OMP.claimTarget
    daemon.ts              空闲探测 + detached 拉起
    cli.ts                 serve|install|status
```

## 安装

作为 DSH profile 的 bundle 挂载。**先停掉正在运行的 dsh，再装** —— 在服务运行期间改它的 `node_modules` 会让运行中的进程读到残缺的依赖树。

```bash
cd ~/.dsh/profiles/<你的 profile>          # 例如 web
dsh plugin --profile <你的 profile> add dsh-browser-tool
```

从 npm 安装，`lib/` 已随包发布，装完即可用，无需本地构建。

然后在该 profile 的 `package.json` 里把它加进 `dsh.profile.bundles`（顺序放最后即可）：

```json
{
  "dsh": {
    "profile": {
      "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-browser-tool"]
    }
  }
}
```

截图依赖 `sharp` 的原生模块，pnpm 默认会跳过它的构建脚本，需在 profile 的 `pnpm-workspace.yaml` 里放行：

```yaml
allowBuilds:
  sharp: true
```

重启 dsh 后 `browser` 工具即出现在工具列表中。验证：

```bash
dsh --profile <你的 profile> --dump-config | grep dsh-browser-tool
```

### 关于依赖

`@deepseek-ai/dsh-tools`、`@deepseek-ai/schemastery` 声明为 **peerDependencies 而非 dependencies**，这一点很关键：DSH 通过 `~/.dsh/profiles/node_modules/@deepseek-ai/` 的符号链接农场把宿主运行时暴露给插件，插件不声明就会向上查找命中宿主那一份。

若把它们写进 `dependencies`，pnpm 会在 profile 里装第二份实体拷贝；`nodeLinker: hoisted` 下插件解析到近的那份、宿主用自己那份，两边模块实例不同。`dsh-tools` 的调度器挂在模块内的 `Symbol()` 上，跨实例读取得到 `undefined`，结果是**所有**工具调用都死在 `scheduler.prepare()`。

### 工具形态（LLM 视角）

```
browser action:"open"    name:"wiki" url:"https://en.wikipedia.org" headless:false
browser action:"run"     name:"wiki" code:"await tab.observe(); await tab.click('text/Log in');"
browser action:"close"   name:"wiki"
```

### 环境变量（均可选）

| 变量 | 作用 |
| --- | --- |
| `DSH_BROWSER_ENABLED` | 0 禁用工具 |
| `DSH_BROWSER_HEADLESS` | 1 默认 headless |
| `DSH_BROWSER_RELAY` / `DSH_BROWSER_RELAY_URL` | 1 时走 relay（默认 `http://127.0.0.1:9224`） |
| `DSH_BROWSER_CDP_URL` | 默认 connected 目标 |
| `DSH_BROWSER_SCREENSHOT_DIR` | 截图落盘目录 |
| `DSH_BROWSER_NO_WEBP` | 1 时模型副本用 PNG |
| `DSH_BROWSER_NODE` | 跑 `sharp` 的普通 Node 可执行文件；缺省按 `DSH_PRIMARY_RUNTIME` + `dependencies/node/bin/node` 推导 |
| `DSH_BROWSER_INSTALL_CHROME` | 0 不自动下载 Chromium |
| `DSH_BROWSER_REMIND_AGENT` | 0 关闭回合结束的"仍有标签未关"回灌提示 |
| `DSH_BROWSER_DONOR_CACHE` | 额外的 Chromium 复用来源（`@puppeteer/browsers` 缓存目录） |
| `PUPPETEER_EXECUTABLE_PATH` / `DSH_BROWSER_EXECUTABLE` | 指定 Chromium 可执行文件 |
| `PUPPETEER_PROXY_*` | 下载/Chrome 代理（透传 puppeteer） |

### Chromium 复用

需要 headless Chromium 时按以下顺序解析，只有全部落空才会下载：

1. `PUPPETEER_EXECUTABLE_PATH` / `DSH_BROWSER_EXECUTABLE`；
2. 系统已安装的 Chrome/Chromium（macOS 除外，那里优先用受管版本）；
3. 本包缓存里已有的 Chrome for Testing；
4. **其他工具缓存里已下载的版本 —— 直接复制，不重新下载**：`DSH_BROWSER_DONOR_CACHE`、`~/.omp/puppeteer`（oh-my-pi）、`PUPPETEER_CACHE_DIR`、`~/.cache/puppeteer`；
5. 以上都没有，才从 Chrome for Testing 下载。

第 4 步只认 `@puppeteer/browsers` 的缓存布局（`chrome/<platform>-<buildId>/`）。构建号完全一致时直接采用，否则取该缓存里最新的一版（仍然省掉一次下载）。复制的是整个构建目录并保留可执行位，落到本包缓存后即与来源解耦——oh-my-pi 清理自己的缓存不会影响这里。复制失败时退化为直接启动来源里的二进制。

Playwright 的缓存（`chromium-<revision>`）不在支持范围：它的 revision 编号无法映射到 Chrome for Testing 的 buildId。

## Electron（桌面壳）下为什么把 sharp 放子进程

Sharp 的 Linux 预编译 libvips 静态链接了一份 GLib，而 Electron 的 Linux 二进制链接系统 GLib 并把符号泄漏进进程空间，两者撞名后进程内第一次碰图片就 SIGABRT（[electron#46323](https://github.com/electron/electron/issues/46323)）。本包因此在 `process.versions.electron` 存在且平台为 linux 时不在自身进程加载 sharp，改为用普通 Node 起一个 worker 子进程跑整条编码阶梯（帧协议见 `src/browsers/image-worker-protocol.ts`）；其它运行时——包括 macOS / Windows 的 Electron——仍在进程内编码，不多起进程。

顺带说明：**升级 sharp 替代不了这个子进程**。本机实测（Electron 44.7.0 / 43.3.0 Linux）：0.33.5 的图片操作 28/28 次直接 SIGABRT；0.34.5 的编码路径在 `ELECTRON_RUN_AS_NODE=1` 下能跑通，但**同一版本同一份 glib，`metadata()` 在该形态下 8/8 次 SIGSEGV**（GUI 形态反而通过，即结果依赖宿主 loader 状态），0.35.5 也仍然崩，并且 sharp 自己会打印 `[SharpElectronLinux] Warning: Binaries provided by Electron for use on Linux may be incompatible with sharp`。上游 [electron#46323](https://github.com/electron/electron/issues/46323) 至今 open，sharp 官方安装文档明写 Electron Linux 不兼容。所以这里不碰 sharp 版本，只把进程切开；本包的编码路径也从不调用 `metadata()`（尺寸靠纯 JS 头部探测），子进程里只做编码。

worker 用的 Node 按顺序取第一个真实存在的可执行文件；一个都找不到时只警告一次并原图透传，截图本身不会失败：

1. `DSH_BROWSER_NODE`；
2. `$DSH_PRIMARY_RUNTIME/dependencies/node/bin/node`（桌面壳会设置它）；
3. `$DSH_BUNDLED_PRIMARY_RUNTIME/dependencies/node/bin/node`。

回归检查：普通 Node 下验证进程内路径不回归，Electron 下验证已切到子进程（修复前这一步必崩，退出码 134）：

```bash
npm run check:image-worker
ELECTRON_RUN_AS_NODE=1 DSH_PRIMARY_RUNTIME=/path/to/primary-runtime /path/to/electron scripts/image-worker-check.mjs
```

注意 `smoke` 的截图（1280x960、约 10KB）会命中 `image.ts` 的快速路径，**并不经过编码**，所以它不能当作编码链路的证据；编码链路的证据由上面的 `check:image-worker` 提供，它直接在 Electron 进程里跑一次真实编码并断言宿主进程没有映射 libvips。

## 开发

```bash
npm run check      # tsc --noEmit
npm run build      # tsc + 资产拷贝（src/assets → lib/assets）
bun test           # 单元测试（util/kind/aria/run-output/runtime/bridge/relay-server）
npm run smoke      # 构建后用真实 headless Chromium 全链路冒烟
npm run check:image-worker   # 构建后验证截图编码路径（Electron 下验证已切子进程）
npm run relay -- serve --port 9224
```

## 与 omp 的差异

- 以独立包交付，作为 profile bundle 挂载（宿主 Builtin 无法 import npm 包）；
- 去掉 omp 专属 helper（read/write/env/…），保留浏览器面，调用即报清晰错误；
- stealth 仅保留无侵入部分（`evaluateOnNewDocument` 脚本 + UA 覆盖），不套 omp 反检测 patch；
- 截图模型副本经 `sharp` 实现（Bun.Image 移除），Electron Linux 下改由普通 Node 子进程执行（见上文）；
- final-expression 注入用 `@babel/parser`（无 AST 不引入 babel 全家桶）。

## 许可

[MIT](./LICENSE) © 画野 (mtaech)

浏览器核心与 Browser Relay 的逻辑移植自 oh-my-pi 的 browser 工具。
