<!-- Source: https://deepseek-browser-use.io.nexus/37 -->

# dsb 客户端：Go 版本、多目标与后端服务管理

[03-1](<https://deepseek-browser-use.io.nexus/03_client>) 介绍当前命令行参数、结构化输出与留档。本章介绍 **Go 实现的单文件二进制 `dsb`** 的安装、多目标与服务管理；源码在仓库 `dsb/` 目录。

这一章讲三件事：为什么换、怎么装、以及新版本多出来的两块能力 —— **连别的主机/多个端口** 与 **自己管后端服务**。

## 1. 为什么换成 Go

原来的 Python 客户端只有一个文件、只用标准库，本身没问题。换掉它有三个具体理由：

| 原因 | 具体表现 |
| --- | --- |
| 少一层运行时依赖 | 原来要先有 Python 3，再由 `dsb`（macOS/Linux）或 `dsb.cmd`（Windows）两层包装去挑解释器、转发参数与退出码；现在是一个静态二进制，装进 `PATH` 就能在任何目录直接敲 |
| 服务没起时不用手工处理 | 原来必须记得先跑 `scripts/run/start-server.cmd`；现在客户端发现连不上会自己把后端拉起来 |
| 兼顾可复核性与 Cookie 保护 | 普通结构化响应保留原字段，便于后续处理；自动留档定向遮蔽 Cookie 值，显式授权导出仍可保留原值 |

还有一条是排查上的：Go 版本每次调用都会在 stderr 说清「连的是哪台机器、用的是哪份 jar」，这在多主机、多实例的场景里是刚需。

## 2. 安装

```bash
cd dsb
go build -o "$(go env GOPATH)/bin/dsb.exe" .     # macOS/Linux 去掉 .exe
```

装完在**任意目录**验证：

```bash
dsb --version
dsb --help
```

目录结构（源码在仓库 `dsb/`）：

```
dsb/
├── main.go        入口：解析命令行 → 拼客户端 → 派发子命令 → 退出码
├── args.go        命令行解析
├── endpoint.go    目标解析（多主机/多端口、具名目标）+ ~/.dsb/config.json 读写
├── client.go      HTTP 层、响应信封、留档
├── commands.go    health / run / batch / state / js 等子命令
├── server.go      dsb server 子命令族（后端服务管理与目标登记）
├── backend.go     仓库定位、jar 候选与查找、构建、拉起/停止后端
├── process_windows.go / process_unix.go   脱离父进程启动、结束进程树
├── input.go       参数文件、标准输入、批量命令来源
├── jsonval.go     保序 JSON 对象与可控的编解码
├── output.go      输出策略（摘要、JSON、--out、--grep）
├── selftest.go    端到端自检
└── errors.go      用法错（3）与传输错（1）
```

## 3. 命令行入口与子命令

常用参数与结构化留档见 [03-1](<https://deepseek-browser-use.io.nexus/03_client>)。子命令清单：

| 子命令 | 作用 |
| --- | --- |
| `health` / `config` / `tasks` / `methods [过滤词]` | 运维自省：健康检查、生效配置、活着的任务、命令清单 |
| `start [--browser chrome] [--headless] [--profile 名称]` | 起任务，默认显示窗口；也可用 `--user-data-dir` / `--profile-directory` 显式选目录 |
| `profiles` / `profile list` | 调用 `list_profiles`，列出服务端可发现的 profile |
| `profile clone --name 名称 --source-user-data-dir 路径 --source-profile-directory Default` | 调用 `clone_profile`，离线克隆为命名 profile；见 [39](<https://deepseek-browser-use.io.nexus/39>) |
| `close [--close-browser]` / `shutdown` | 普通结束保留最后的数据页；显式关闭浏览器 / 关闭全部任务与浏览器 |
| `run <method> [-p k=v] [--params @文件] [--wait-until STAGE]` | 发一条命令；导航等待阶段见 [首个任务](<https://deepseek-browser-use.io.nexus/03>) |
| `batch [文件\|-] [--async] [--wait]` | 批量命令 |
| `job <jobId> [--wait]` | 查/等异步批次 |
| `recipes [--run 名字]` | 列配方 / 跑配方 |
| `state [--full] [--text-only] [--highlight] [--screenshot]` | 索引正常生成；高亮与截图默认关闭，需要时分别启用 |
| `js <脚本\|@脚本.js\|-> [--var k=v]` | 执行 JS |
| `upload` / `uploads [--delete 名字]` | 文件送到服务端暂存区 |
| `last` | 重放本会话最近一次的响应 |
| `server <动作>` | 后端服务与目标管理（本章第 5、6 节） |
| `selftest [--browser chrome]` | 端到端自检 |

`dsb --help` 与 `dsb <子命令> --help` 都有说明。退出码沿用原约定：**0 成功 / 1 传输或协议错 / 2 业务失败 / 3 用法错**。

### 单路径选择与防重复执行

`--select` 接受一个点分隔路径，例如 `data.text`。严格模式会在创建客户端、发送请求或启动服务之前，拒绝形如 `data.title,data.url` 的逗号分隔多路径，退出码为 3，并明确提示没有发送请求。检查规则是每个逗号分段均包含点；它不是所有 JSON 键的通用语法验证。重复提供选项时，前面的此类错误值也会被检查。

兼容性：`data.a,b` 仍可选择字面键 `a,b`；如果字面键形成看似多路径的表达式，显式使用 `--select-lenient` 保留原解释。此开关还会在路径缺失时回退输出完整响应，不用于真正的多字段选择。优先选择父对象（如 `--select data`），或用 `--out` 保存后本地投影。

只有语法前置拒绝才能明确断言“请求未发送”。合法路径在回执中不存在时，错误发生在响应之后，提示会提醒动作可能已完成。使用相同 session 与留档目录读取 `dsb last` 或原始记录，不重发动作。`--out` 的完整落盘优先级不变。

`--id` 或 `DSB_TASK_ID` 必须是数字；命名标签用 `--session`，标签不替代浏览器任务 ID。`dsb methods` 只列出方法，方法调用使用 `dsb run METHOD`。

## 4. 留档复核与 Cookie 保护

自动请求/响应留档会将结构化 `cookies` 数组及 `set_cookie` 参数中的 Cookie 值替换为 `[REDACTED]`，不修改实时请求、stdout 或显式 `--out` 导出。任意脚本、页面文本、错误消息、HTTP 头、服务端 trace 与历史记录不在此功能覆盖范围，不能把它当成通用脱敏。`requestId`、`jobId` 等后续处理所需字段保持原样。日志不用于恢复被遮蔽的 Cookie；显式导出与终端输出需单独保护。

留档格式不变：

```
logs/agent/<会话>/001.req.json    请求结构（Cookie 值定向遮蔽）
logs/agent/<会话>/001.res.json    响应结构（Cookie 值定向遮蔽）
logs/agent/<会话>/steps.log       一行一次调用
```

`dsb last` 不发送业务请求。不带显式 ID 时读取目录最新响应；显式 `--id` 按配对请求筛选该任务，无匹配报错。目录选择与完整示例见 [留档与复核](<https://deepseek-browser-use.io.nexus/03_client#5-留档与复核>)。

## 5. 连别的主机、连同一台机器上的多个实例

后端可能在别的机器上（团队共用一台跑浏览器），本机也可能同时开着好几个实例 —— CLI 托管目录默认按端口隔离：10049 使用 `.dsb-backend/profile`，其他端口使用 `.dsb-backend/profile-<port>`。显式配置目录仍需避免共享占用。两种都用同一组开关。

```shell
dsb --host 10.0.0.5 --port 10049 health          # 直接给主机与端口
dsb --base-url http://10.0.0.5:10049 health      # 等价，写全了更直观（IPv6 用这个）
dsb --port 10050 start --headful                 # 本机第二个实例
```

地址的决定顺序：

| 优先级 | 来源 |
| --- | --- |
| 1 | `--base-url` |
| 2 | `--use <名字>`（具名目标） |
| 3 | `--host` / `--port` |
| 4 | 环境变量 `DSB_BASE_URL` / `DSB_HOST` / `DSB_PORT` |
| 5 | `~/.dsb/config.json` 里的 `host` / `port` |
| 6 | 内置默认 `localhost:10049` |

`dsb server status` 会把最终地址**以及它的来源**一并打出来，避免「我以为连 A，其实连的是 B」：

```
服务地址:http://localhost:10050(健康,本机;来源:命令行 --port)
```

### 具名目标

每次都敲 IP 和端口太啰嗦，可以给「主机 + 端口」起个名字：

```shell
dsb server target add lab --host 10.0.0.5 --port 10049 --note "实验机"
dsb server target add local2 --host 127.0.0.1 --port 10050
dsb server target list            # 列出全部（--json 给脚本用）
dsb server target remove lab

dsb --use lab methods             # 之后就不用敲 IP 了
```

`target list` 会标出每条是本机还是远端：

```
已登记的目标(<用户主目录>/.dsb/config.json):
  lab          10.0.0.5:10049(远端)  # 实验机
  local2       127.0.0.1:10050(本机)
用法:dsb --use <名字> <子命令>
```

### 远端目标不自动拉起

只有**明确的本地形态**（`localhost`、`127.0.0.0/8`、`::1`、`0.0.0.0`、本机主机名）才算本机；其余一律当远端。
远端服务必须在那台机器上自己起 —— 客户端没有理由去启动别人的进程。硬要拉起只会把「地址写错了」
变成「在本机起了个没用的服务，然后照样失败」。所以远端目标连不上时得到的是明确提示：

```
服务地址:http://10.0.0.5:10049(没起,远端;来源:具名目标 lab)
远端目标:进程、jar、日志都在那台机器上,这里看不到
连不上:确认那台机器上服务起了、端口开着、防火墙放行(远端不能自动拉起)
```

## 6. 后端服务管理

```shell
dsb server init --repo-dir <仓库根>   # 记下仓库在哪（一次性）
dsb server build                      # 构建当前代码的后端 jar
dsb server start                      # 起服务（已在跑就复用）
dsb server status                     # 地址、健康、pid、仓库、全部可用 jar、日志
dsb server logs --lines 80            # 看日志尾部（排查启动失败最常用）
dsb server stop                       # 停服务；有活动任务时拒绝执行
dsb server restart                    # 重启；有活动任务时拒绝执行
dsb server restart --when-idle        # 等任务都结束再重启，等不到就放弃（--idle-timeout 默认 600 秒）
dsb server restart --force            # 中断活动任务并重启
dsb server wipe-profile --yes         # 服务停止后，删除这个端口的 profile 和登录状态
```

`start`、`stop`、`status` 都认 `--port`，所以同一台机器上的多个实例可以逐个管理：

```shell
dsb --port 10050 server stop
dsb --port 10050 server status
```

### 配置文件

`~/.dsb/config.json` 同时管「仓库在哪」与「默认连哪台主机」：

```json
{
  "repoDir": "<仓库根目录>",
  "host": "127.0.0.1",
  "port": 10049,
  "targets": {
    "lab":    { "host": "10.0.0.5", "port": 10049, "note": "实验机" },
    "local2": { "host": "127.0.0.1", "port": 10050, "repoDir": "…" }
  }
}
```

为什么要有这个文件：`dsb` 装在 `PATH` 上，离仓库十万八千里，推不出仓库在哪。装到别的机器、换了仓库目录、
多了一台跑浏览器的机器，都只改这一个文件。旧版本只有 `repoDir` 一个字段的配置照常能读。

仓库位置的决定顺序：`--repo-dir` > `DSB_REPO_DIR` 环境变量 > 配置文件 > 从当前目录向上探测。
仓库位置只对**本机**目标有意义 —— 远端主机上的 jar 在远端。

### jar 从哪来

按优先级：

| 优先级 | 位置 | 说明 |
| --- | --- | --- |
| 1 | 配置文件里的 `jar` | 显式指定，最优先 |
| 2 | `<repo>/.dsb-backend/releases/<commit>/backend.jar` | 按 commit 归档，多个时取最新的 |
| 3 | `<repo>/playwright-server/target/playwright-server-*.jar` | 本地 `mvn package` 的直接产物 |
| 4 | `<repo>/dist/*-windows-x64.jar` | 发行包，通常是几周前的快照 |

一个都没有时报用法错并提示跑 `dsb server build`。

`dsb server status` 会把**全部候选**列出来（带来源、commit / playwright 版本、时间、大小），
用 `★` 标出启动时会选的那份：

```
后端 jar(按优先级,★ = 启动时会用的那份):
★ …\.dsb-backend\releases\879906f4…\backend.jar(releases, 879906f4, 2026-10-07 12:32, 51.6 MB)
  …\.dsb-backend\releases\87a5e4d7…\backend.jar(releases, 87a5e4d7, 2026-10-06 10:52, 51.6 MB)
  …\playwright-server\target\playwright-server-1.0.0.jar(target, 2026-10-07 12:32, 51.6 MB)
  …\dist\deepseek-browser-use-1.0.0-windows-x64.jar(dist, 2026-09-25 20:01, 199.5 MB)
```

**为什么 `releases` 排在 `target` 前面**：前者是「按 commit 存档、可复现」的产物，后者是随便哪次本地构建
留下的（可能来自半路中断的构建，也可能改了代码没提交）。想用最新代码跑，先 `dsb server build` —— 它会把
当前 commit 的产物归进 `releases/<commit>/`，自然就成了最新的那份；`target` 与 `dist` 是兜底，平时不会用到。

### 构建

```shell
dsb server build
```

等价于在仓库根执行：

```shell
mvn -B -ntp -Pproduction -pl playwright-server -am clean package -DskipTests -Ddriver.platform=<当前平台>
```

产物拷进 `.dsb-backend/releases/<当前 commit>/backend.jar`。

`-Ddriver.platform` 不能省：Playwright 的 driver-bundle 里有 5 个平台的 node（合计约 194MB），
一个发行版只需要自己那个平台的；打包脚本与插件的 `backend.ps1` 都传这个属性，传了 jar 才只有 51 MB 上下。
客户端按当前系统自动映射（`windows/amd64` → `win32_x64`、`darwin/arm64` → `mac11-arm64` 等）。

构建完要**重启**才会用上：

```shell
dsb server restart
```

### 启动参数

`dsb server start` 拉起的命令行与 `plugins/deepseek-browser-use/scripts/backend.ps1` 对齐：

```
java -Dserver.port=10049
     -Djdk.net.unixdomain.tmpdir=<仓库>/logs/server/run-10049
     -Dbrowser.profileDir=<仓库>/.dsb-backend/profile
     -Dbrowser.chrome.cdpProfileDir=<仓库>/.dsb-backend/profile
     -Dbrowser.chrome.useUserProfile=false
     -jar <jar>
```

两点值得记：

- **`-Djdk.net.unixdomain.tmpdir` 必须给。** 不给时 JDK 21 建 `Selector` 会去连默认位置的 unix domain socket，
  在 Windows 上直接 `Invalid argument: connect` / `Unable to establish loopback connection`，服务起不来。
- **工作目录是仓库根**，所以 `data/`、`logs/` 都落在仓库里，与既有脚本一致。
- 进程脱离父进程启动（Windows 用 `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`，类 Unix 用 `setsid`），
  `dsb` 退出不会把后端带走；pid 记在 `logs/server/server-<端口>.pid`，stdout/stderr 进同目录的 `.out.log` / `.err.log`。

### 自动拉起

默认**开**：普通命令（`health`、`run`、`state` …）连不上**本机**服务时，客户端按上面的配置把后端拉起来，
然后重试一次。触发时 stderr 会打一行说明：

```
服务没起,正在按配置文件拉起后端(仓库:…,端口:10049)…
```

关掉它：

```shell
dsb --no-auto-start health     # 单次
DSB_AUTO_START=0 dsb health    # 环境变量
```

只在「本机 + 连不上（连接被拒）」时触发。**服务超时、业务失败都不触发** —— 超时说明服务在跑，只是慢，
这时再起一个只会更糟。重试只做一次，避免「起不来 → 无限重试」。

启动失败时不会干等满超时：进程中途退出就立刻放弃，并把日志尾部打印出来。

## 7. 为什么自己实现 JSON 值层

`dsb/jsonval.go` 没有直接用 `encoding/json`，而是自己写了「保序对象 + 编解码」。这不是因为 Java 后端返回的
JSON 不标准 —— 服务端回的是完全标准的 JSON。原因是 `encoding/json` 面向 `map` 的设计会丢掉三样东西：

| 问题 | 后果 | 本项目的处理 |
| --- | --- | --- |
| `map[string]any` 按键名排序 | 回执与留档的字段顺序被重排，和「第几步开始不对」的排查对不上；日志与原始响应不再逐字一致 | 用 `Obj` 保留插入顺序 |
| 数字默认解成 `float64` | `jobId` 是雪花号（`1790232350369123456`，19 位），过一遍 float64 就丢精度，回填给 `get_job` 直接失败 | 用 `json.Number` 保留字面量 |
| 缩进与分隔符不可控 | 留档文件、`--compact` 的单行输出都有人和脚本在依赖 | 自己控制缩进与分隔符 |

换句话说：**协议是标准的，问题出在 Go 标准库的默认解码目标上**。换用 `json.Decoder` + `UseNumber` 能解决
数字精度，但解决不了顺序；而顺序恰恰是排查时的第一手线索。代价是一个约 300 行的文件，换来的是留档与
服务端原文一致。

## 8. 自检与本地测试

对当前服务跑一遍端到端自检（用自己的任务 ID `990001`，不会撞上业务任务，自检页面是本地生成的 HTML、不依赖外网）：

```shell
dsb --port 10049 selftest --browser chrome
```

它依次验证：健康检查、命令清单里新命令是否都在、`start` 的 `engineHonored`、打开自检页、`wait_for_count`、
`get_modals`/`close_modal`、点击降级到真实鼠标、`expect` 断言能报出不一致、异步批次与 `get_job`、
任务清单/配置/配方可读、`cleanup` 默认只预演。

客户端自身的测试不连服务：

```bash
cd dsb && go test ./...
```

覆盖参数解析、JSON 保序与编码、留档编号接续、输出策略、信封级字段、jar 候选优先级、仓库定位、
目标解析优先级，以及「远端目标不自动拉起」这条约束。

## 9. 一次完整的多实例演练

```bash
# 1. 记下仓库位置(一次性)
dsb server init --repo-dir "<仓库根目录>"

# 2. 起两个服务；若要同时启动浏览器，须先为它们配置互不重叠的用户数据目录
dsb --port 10049 server start
dsb --port 10050 server start

# 3. 给第二个实例起个名字,之后用名字连
dsb server target add local2 --host 127.0.0.1 --port 10050
dsb --use local2 health
dsb --use local2 --id 2002 start --browser chrome --headful

# 4. 登记一台远端机器(需要那台机器上自己把服务起好)
dsb server target add lab --host 10.0.0.5 --port 10049 --note "实验机"
dsb --use lab server status      # 会明确说「远端」,连不上也不会在本机乱起服务

# 5. 收尾
dsb --use local2 --id 2002 close
dsb --port 10050 server stop
dsb --port 10049 server stop
```

## 下一步

- 想了解协议本身（一个端点、JSON 进 JSON 出），看 [统一命令接口](<https://deepseek-browser-use.io.nexus/04>)。
- 想了解当前 dsb 客户端的参数与结构化留档，看 [03-1](<https://deepseek-browser-use.io.nexus/03_client>)。
- 想看命令清单与每个命令的参数，看 [命令清单](<https://deepseek-browser-use.io.nexus/13>)。
