Skip to main content
Glama
README.md
# codex-mcp-dsh

让 **Codex CLI** / **Claude Code** 这类 MCP 客户端把任务交给 **DeepSeek Harness(dsh)** 执行的本地桥。
支持 **原生 Web(推荐,进度可见)** 和 **ACP(默认,向后兼容)**。Web 提供 9 个工具,包含模型选择和持久化长任务;ACP 保留原执行工具,模型工具会明确返回不支持。

```
Codex / Claude Code --MCP stdio--> codex-mcp-dsh --+-- 原生 Web(DSH_BRIDGE_TRANSPORT=web)
                                                    |     HTTP + /api/remote.mux WebSocket
                                                    |     自动拉起/复用自己的 dsh web 服务
                                                    |
                                                    +-- ACP stdio(默认)
                                                          dsh --profile acp
```

两种传输共用 `dsh_start` / `dsh_workspace` / `dsh_run` / `dsh_session` / `dsh_status`。
Web 还支持 `dsh_model`,以及 `dsh_submit` / `dsh_job` / `dsh_cancel`。
切换只改**一个环境变量**:`DSH_BRIDGE_TRANSPORT=web`。

---

## 快速开始(推荐:原生 Web,进度能在原生页面里看)

**不需要**手工运行 `dsh web`,**不需要**找 token,**不需要**复制任何凭据。

```jsonc
// Codex 配置:见下方 “### Codex” 一节
[mcp_servers.dsh.env]
DSH_BRIDGE_TRANSPORT = "web"
```

然后照常调用:

```jsonc
// 1. 启动/复用桥自己管理的本地 Web 服务,并自动在默认浏览器打开原生 Web UI
dsh_start({})
// -> { "ok": true, "running": true, "transport": "web",
//      "web_url": "http://127.0.0.1:53421/",       // 干净 URL,绝不含 token
//      "browser_opened": true,
//      "web": { "owned": true, "pid": 12345, "connected": true } }

// 2. 建立/复用一个真实工作区(目录 + 原生 Workspace + 挂在其上的原生会话)
dsh_workspace({ "path": "D:/PROJECT/my-new-project" })
// -> { "ok": true, "created": true, "cwd": "D:\\PROJECT\\my-new-project",
//      "session_id": "session-...", "workspace_id": "...", "web_url": "http://127.0.0.1:53421/" }

// 3. 短任务可同步执行。在 web_url 中展开工作区,并选中返回的会话,观察实时工具调用与文本流
dsh_run({ "session_id": "上一步返回的 session_id", "task": "在这个工作区里做……" })
```

耗时不确定的修改任务优先用 `dsh_submit`,拿到 `job_id` 后用 `dsh_job` 读取进度与结果。每次读取最多等待 20 秒,MCP 断开不影响独立 worker;同一 `request_id` 同参数只执行一次。需要停止时调用 `dsh_cancel`,以原生终态确认结果。

任务本身仍有执行期限,默认取 `DSH_BRIDGE_GATEWAY_JOB_TIMEOUT_MS` 或 `DSH_BRIDGE_TIMEOUT_MS`(未配置时为 30 分钟);显式 `timeout_ms` 会覆盖默认值。持久化任务解决客户端同步等待被截断的问题,并不绕过原生权限或无限延长期限。

`server-web.mjs` 默认将长任务范围限制在 `DSH_WORKSPACE`(未配置则启动目录),多工作区可显式配置 `DSH_BRIDGE_ALLOWED_ROOTS`。其它入口需显式配置允许范围。模型选择示例与完整流程见 [模型、进度与长任务](docs/dsh-model-and-approval.md)。新增工具在 MCP 重新连接、重新读取工具列表后可用。

这一条链路是**通用**的:任何用户只要装上 `dsh` 并设置 `DSH_BRIDGE_TRANSPORT=web` 就能用,
token 由桥在内部捕获、换成 HttpOnly cookie 并保存到私有文件,用户永远不需要看到它。

### 管理服务的所有权与清理(web)

- 桥只管理**自己启动的**服务:**默认端口 0**(由操作系统选空闲端口),所以永远不会和用户已有的服务
  (例如 3080 上的)冲突,也**绝不会停止或替换**别人的服务。
- 桥启动的服务记录在私有连接状态文件里(默认 `$DSH_HOME/bridge-web.json`,权限 0600):
  干净 URL + 浏览器 cookie + token 化的 launch URL + pid。
- **MCP 断开时,桥自己启动的服务会继续运行**,这样用户还能继续在同一个页面看历史;
  只有显式设置 `DSH_BRIDGE_WEB_STOP_ON_EXIT=1` 才会在断开时停止它。
- 这条「继续运行」是**真的独立进程**:桥用 `detached` 方式启动 `dsh web`,并把它的 stdout/stderr 重定向到
  一个私有的 0600 捕获文件(读到 launch URL 后立即 unlink),**不使用 MCP 父进程的管道**。
  Windows 上非 detached 的子进程会绑定在父进程的 console/job 上,父进程一退出就被一起杀掉
  (`child.unref()` 只影响事件循环,不足以让子进程独立);用管道还会在父进程关闭读端后让服务写入失败。
  桥只在启动失败/超时时清理这个半启动的子进程。
- 状态文件里的服务如果已经不在监听了,下一次 `dsh_start` 会**自动重启一个新的管理服务**,不会要求用户手工启动。
- **会话映射按「服务 authority + 目录」记录**:桥只复用**自己记录过的**那个原生会话。原生 registry 是跨进程共享的,
  里面可能有同目录、`running:false` 看起来空闲、但写持有者属于**别人**的会话(用户另一个 Web 服务、正在跑的 ACP 委派);
  桥**绝不**自动挂接这种会话,而是直接在**已存在的原生 Workspace** 上新建一个原生会话,其他会话原样保留。
- 桥**自己管理**的服务如果死掉并被替换,只有它自己记录过、且属于那个已死 authority 的会话会在新服务上重新挂接
  (旧写持有者已经消失);若原生服务拒绝挂接,则退化为新建会话。**别的 authority 的映射不会被隐式复用。**
- 用户显式传 `session_id` 时,若该会话正被别的进程持有,会**如实报错**(不会静默换一个会话)。
- `dsh_session close` 只是**桥侧解绑**:原生历史保留、共享服务不动、不会停止原生任务。

### 浏览器打开行为

- `dsh_start` 在 web 模式下默认打开发送一次(每个服务最多一次,不会反复抢焦点);
  用 `dsh_start({ "open_browser": false })` 关闭,或 `{ "open_browser": true }` 显式再开一次。
- **并发**的默认 `dsh_start` 共享同一次打开动作(authority 在 await 之前就被占位),所以默认一次启动流程最多只开一个窗口。
- 打开用的是操作系统默认浏览器(与 `dsh web` 相同的原生 handoff),打开的是**原生 DSH Web UI**,
  不是任何自定义面板。
- 只有**桥自己管理的**服务会自动打开;用 `DSH_WEB_URL` 显式挂接别人的服务时不会抢焦点(可显式传 `open_browser:true`)。

### 权限与确认(web)

Web 传输下**权限保持原生**:桥不会替你自动放行任何权限请求,原生 Web UI 会自己弹出确认,
需要人点确认时任务就会等在那里。这与 ACP 传输的 `DSH_BRIDGE_PERMISSION` 策略不同,是刻意的设计。

---

## 快速开始(默认:ACP,向后兼容)

不设置 `DSH_BRIDGE_TRANSPORT` 时行为与以前完全一致(`dsh --profile acp`),**dsh 子进程延迟启动**:
MCP 连接和 `tools/list` 不依赖 dsh,`dsh_start`(或第一次 `dsh_run` / `dsh_workspace`)才会启动并完成
`initialize` 握手。子进程生命周期绑定在 MCP 连接上,同一个工作区的多轮对话复用同一个 DSH 会话。

```jsonc
dsh_start({})                                      // -> running:true, state, version
dsh_workspace({ "path": "D:/PROJECT/my-new-project"})  // 目录不存在会连同父目录创建;已存在则安全复用
dsh_run({ "session_id": "...", "task": "在这个工作区里做……" })
```

要点:

- `dsh_start` 无必需参数,连续或并发调用只会启动一个 dsh 进程;启动失败返回明确错误,MCP 仍然可用,
  再次调用即为干净重试。
- `dsh_workspace` 的 `path` 必须是**绝对目录路径**;空/相对/文件/磁盘根目录会被拒绝。重复调用同一目录
  复用同一个会话。
- `dsh_run` 直接调用(不带 `session_id`)也会自动确保 dsh 已启动,旧用法不受影响;`dsh_run`/`dsh_workspace`
  创建的会话以该目录为 cwd 实际执行。

---

## 安装

```sh
cd D:/PROJECT/codex-mcp-dsh
npm install
```

要求:Node >= 22.19(本机 v24.19 已验证)、`dsh` 在 PATH、`~/.dsh` 已配置好模型凭据。

## 工具

| 工具 | 说明 |
|---|---|
| `dsh_start` | 显式启动/连接 DSH 并完成握手(幂等:连续或并发调用只启动一个)。`transport=acp`(默认)启动 `dsh --profile acp`;`transport=web` 复用自己的管理服务,或新起一个 `dsh web`(默认 `--port 0`,隐藏窗口,私有捕获 launch URL),并默认在默认浏览器打开原生 Web UI(`open_browser:false` 可关)。返回 `running`/`state`/`transport`,web 下另返回干净 `web_url` 与 `web` 连接信息。失败返回明确错误且桥保持可调用,可重试。 |
| `dsh_workspace` | 用 `path`(必需,绝对目录)建立/复用工作区:缺失目录连同父目录创建,已有目录安全复用、绝不覆盖文件;必要时自动启动/连接 DSH。web 下还会创建**真实的原生 Workspace**(`workspace_id`)并把**原生会话**挂到它上面,重复/并发调用复用同一个 `session_id`;没有本桥自己的映射时**新建**一个原生会话(绝不抢占 registry 里同目录但属于别人的空闲会话)。返回 `created` / `cwd` / `session_id`(web 另含 `workspace_id` / `web_url`)。 |
| `dsh_run` | **原生工具卡片标题 `Dsh run`**。执行一个任务并返回最终答复。若未启动会自动启动;默认复用当前 cwd 的会话。它是一个**同步的 `tools/call`**:保持 pending 直到原生回合给出最终结果(或超时/取消),客户端应等待这一个请求,而不是轮询/重复提交。仅当客户端提供 `_meta.progressToken` 时才发送 progress 通知(起始 → connecting/executing → 空闲心跳 waiting,见下);web 下进度来自原生事件流(工具调用 + 文本增量),最终答复只取**一次**持久化文本(不会把流式和落盘文本拼在一起),推理内容不会进入答复或普通进度。 |
| `dsh_session` | `action=list/resume/close/status`,管理会话。`list`/`status` 未启动时也能答(缓存视图),并可用 `cwd`/`session_id` **收窄结果**:只筛选已经存在(running 或缓存)的会话行,**不创建目录、不隐式启动/恢复**,无匹配返回空数组;不带过滤参数时仍是完整视图(含原生错误行)。`cwd` 按规范化绝对路径比较(Windows 忽略大小写)。`resume`/`close` 需要正在运行。web 的 `close` 是桥侧解绑(保留原生历史、不动共享服务);**有回合在跑(含结果未知)时拒绝解绑**。 |
| `dsh_model` | 原生模型目录与选择(**仅 web transport**;ACP 明确返回 `UNSUPPORTED_TRANSPORT`,不会假成功)。`action=list` 读公开目录;`action=status` 按 `cwd`/`session_id` 读该会话 `modelSelection` projection 的 `next`/`lastUsed`,并以目录 `default` 兜底(`current = next ?? default`,与原生 Web UI 一致);`action=select` 先按实时目录校验 `provider`/`model`/`reasoning_effort` 再调用一次 `session/selectModel`,随后回读并给出 `verified`。该原生 API 同时作用于**本会话下一次请求**并**尝试保存全局默认**(保存失败只由原生记日志),因此**不是 session-only**。本工具不创建/恢复/启动会话、不修改 `settings.yaml`、不以模型自述作为身份依据。详见 [`docs/dsh-model-and-approval.md`](docs/dsh-model-and-approval.md)。 |
| `dsh_status` | **只读**,不会隐式启动/连接任何东西。`running`/`state` 区分 `stopped`/`starting`/`running`/`error`;`ok=true` 表示健康。并保留 dsh 路径、版本、活会话数、会话能力、配置来源;web 下另含 `web_url`(干净)或 `web_target`,以及 `permissions` 说明。传输真的断了(mux 关闭 / HTTP 不可达 / 子进程退出)会如实变为 `error`,不会永远报健康。 |

`dsh_run` 入参:`task`(必填,不超过 16000 字符)、`cwd`、`session_id`、`new_session`、`timeout_ms`。
`dsh_workspace` 只有 `path` 一个参数;`dsh_start` 只有可选 `open_browser`;`dsh_model` 只有 `action` 与
`session_id`/`cwd`/`provider`/`model`/`reasoning_effort`。不引入任意命令/凭据类配置。

**看网页就用 `dsh_start` 返回的 `web_url`**:那是本桥正在驱动的那个原生 Web 服务。不要假设端口(例如用户机器上的
`3080` 可能是另一个**本桥没有连接**的服务);请在返回的 URL 里选择同一个 workspace/session,才能看到同一段对话、
工具活动和权限提示。

## 双入口:WebCodex 网关(异步)与 Web-GPT 咨询

除了上面的 6 个工具,本仓库还有两个**独立**入口,详细集成说明见 [`docs/dual-entry.md`](docs/dual-entry.md):

| 入口 | 启动 | 工具 |
|---|---|---|
| B. WebCodex 网关(ChatGPT 网页 → 本机 DSH) | `src/server-webcodex.mjs` | `dsh_submit` / `dsh_job` / `dsh_cancel` / `dsh_status` / `dsh_lock`(**只读**) |
| C. Web-GPT 咨询(只出方案/评审,不执行) | `src/server-planner.mjs` | `planner_status` / `planner_models` / `planner_consult` |

两条**真实用法**:

1. **Codex → planner → Codex → DSH**:Codex 调 `planner_consult` 拿网页 6 Pro 的方案/评审文本,自己决策后
   把要执行的步骤交给原生桥的 `dsh_run`(入口 A,同步执行);planner 只出文本、**不读文件也不执行**,
   因此不需要共享 cwd 锁。
2. **网页 6 Pro → WebCodex → gateway → DSH**:ChatGPT 网页的 WebCodex Runner 通过 `dsh` Provider 驱动
   `dsh_submit`/`dsh_job`(入口 B,异步执行,worker 断线存活)。配置见
   [`examples/webcodex-runner.toml`](examples/webcodex-runner.toml)(**Runner TOML**,不是 `mcpServers` JSON)。

**实测验收(外部 SDK → 本地 WebCodex Runner → DSH)**:短任务 **3.572 秒**、长任务 **143.249 秒**(断线后 worker
存活并写出终态)、首次 `dsh_submit` **13 毫秒**返回;真实取消 `requested → confirmed`(job 终态 `cancelled`,锁已释放);
运行中同 `request_id` 重试返回**原 job**。**未实测**:ChatGPT 账号登录 / 6 Pro 选择 / 远端连接,planner 入口目前
未登录、未发送过真实咨询——所以本文档不宣称「ChatGPT 侧端到端全部可用」。

入口 B 的核心约束(为什么长这样):

- **没有同步 `dsh_run`**:WebCodex 0.4.1 的单次 `tools/call` 硬等待约 **125 秒**且不转发 progress,
  所以 `dsh_submit` 只做有界校验 + 落盘 + 启动 **detached worker**,立即返回;
  `dsh_job` 最多等 **20 秒**(`waited_ms` 是**真实**已等待时间),长回合跑在 worker 里,断开后仍存活;
- **`request_id` 原子幂等**:`requests/<hash>.json` 用独占创建做「一个 request_id 一个 claim」。
  同 key 同参数(即使正在运行或结果未知)返回**同一个 job**,绝不抛 `WORKSPACE_BUSY`、绝不重放;
  同 key 不同参数原子拒绝(`REQUEST_ID_CONFLICT`);claim 成功但 record 缺失
  (claim 后崩溃)→ `JOB_RECORD_MISSING`,**不会**启动第二个 worker;
- **`dsh_lock` 只读**:MCP 侧没有任何解锁写操作;手工解锁只能由本机操作者运行
  `node scripts/unlock-workspace.mjs --cwd <dir> --confirm-native-terminal --evidence "<你核对到了什么>"`
  (持有者进程仍存活时拒绝,需要 `--force` 才能越过;也可以 `npm run unlock -- --cwd ...`);
- **allowed roots 是路由范围,不是 OS 沙箱**:`readJob`/`request_id` 反查/`dsh_cancel`/`dsh_job`/列表
  在读取或 reconcile 之前,都用**真实路径**验证记录 cwd 位于当前 `DSH_BRIDGE_ALLOWED_ROOTS` 内;
  收窄配置后历史越界任务不可查看/取消/泄露(列表**先过滤再 limit**),配置不可用时 fail closed。
  它**不能**替代 DSH 原生权限策略:原生确认/权限配置仍然完全适用;该变量只由 gateway(入口 B)用来守范围,
  原生入口 A 用 `DSH_WORKSPACE` 指定默认工作区;
- **终态真相**:只有 `completed` 才返回 `answer`;连接丢失/超时/未确认取消/已 admission 的未知错误
  一律 `unknown` 并保留围栏,明确预提交失败才是 `failed`;取消在 admission 前到达(`not-sent`)
  判定为 `cancelled` 并释放围栏(原生根本没收到)。

入口 C 的规则:模型必须来自 live UI 的精确枚举;写入后再读 UI,**必须读到非空且与 requested 完全一致的
current model** 才允许发送——`null`/空(读不到,点击成功也只证明点到了菜单)与矛盾现值一律拒绝,零 write 零 submit;
请求档位本身必须是 Pro(配置成 Thinking 也不会通过),观察档位要用第三方**真模块**的 `isProTier`;
非空 `response` ≠ 完成,只有 `submission.completed===true && sent===true` 才返回答案,否则 `unknown`。

### 等待体验与 MCP 协议(Codex 原生工具卡片)

- 客户端做一次 `tools/call dsh_run`,界面上就是**一个 `Dsh run` 原生工具卡片**;桥让这个请求一直 pending,
  直到 DSH 产生权威的终止结果(或超时/取消)才返回最终答复。就是标准 MCP 请求/响应:**没有**异步邮箱、
  任务队列、自定义面板或额外协议。
- `initialize` 会带上 `instructions`(≤2000 字符、自包含):说明这是用户授权的 DSH 委派、`dsh_run` 保持 pending、
  应当等待而不是轮询或起 shell 包装、尊重用户给定的路径与范围;并明确「错误或结果未知不构成重新提交的授权」。
  桥**不要求**客户端绕过权限/配额,也不承诺不消耗 token。
- **progress 通知**只在客户端提供 `_meta.progressToken` 时发送(数字 `0` 也是合法 token)。同一次 `dsh_run`
  只有**一个严格递增的计数器**,真实事件(助手文本、工具状态)与空闲心跳共用;并发请求各自绑定自己的 token,
  绝不串号。顺序为:立即 `starting` → `connecting` → `executing` → 空闲心跳(默认 10s,
  `DSH_BRIDGE_PROGRESS_INTERVAL_MS` 可调、`0` 关闭)。心跳只说明「已等待多久 / 多久没有原生事件」,
  **不编造百分比、总数、模型思考或完成状态**;调用一旦返回、报错或被取消就立刻停表,之后不再发送任何通知。
- 心跳是**活动信息**:不会改变客户端自身的超时/配额/用量限制,也不能让 Codex 的额度提示消失。工具卡片、
  配额/余额提示以及断开后的中断都由**客户端**渲染与决定;桥无法模拟它们,也不保证被中断后还能继续跑完。

## 配置

环境变量(都可选):

### 通用

| 变量 | 默认 | 含义 |
|---|---|---|
| `DSH_BRIDGE_TRANSPORT` | `acp` | `acp`(默认,向后兼容)或 `web`(原生 Web,推荐) |
| `DSH_BIN` | `dsh` | 可执行文件;Windows 上会自动把 npm 的 `dsh.cmd` 垫片解析成它包裹的 Node 入口,避免 `cmd.exe` 与参数插值 |
| `DSH_HOME` | 用户目录下的 `.dsh` | dsh 配置与凭据目录 |
| `DSH_PROFILE` | `acp` | ACP 传输使用的 profile |
| `DSH_WORKSPACE` | 启动目录 | 默认工作区 |
| `DSH_MODEL` | 空(用 dsh settings) | 会话级 model 覆盖 |
| `DSH_BRIDGE_TIMEOUT_MS` | `1800000` | 单轮超时;客户端的 `tool_timeout_sec` 要比它多留启动握手 + 取消宽限的余量 |
| `DSH_BRIDGE_CANCEL_GRACE_MS` | `5000` | 发出取消后等待「权威终止事件」确认的宽限;到期即返回 unknown 且会话仍 busy |
| `DSH_BRIDGE_PROMPT_RESPONSE_GRACE_MS` | `5000` | ACP `session/prompt` 请求本身的额外预算 |
| `DSH_BRIDGE_STARTUP_TIMEOUT_MS` | `60000` | 启动握手 / `session/new`、`session/resume` 预算 |
| `DSH_BRIDGE_CLOSE_TIMEOUT_MS` | `60000` | 单次 `session/close` 预算 |
| `DSH_BRIDGE_CLOSE_ALL_BUDGET_MS` | `120000` | 退出时关闭全部会话的总预算 |
| `DSH_BRIDGE_SHUTDOWN_WAIT_MS` | `5000` | MCP 断开时若还在启动,等待/中止握手的上限(避免留下晚启动的子进程) |
| `DSH_BRIDGE_MAX_OUTPUT_CHARS` | `60000` | 返回给客户端的答复上限(超出截断) |
| `DSH_BRIDGE_PROGRESS_INTERVAL_MS` | `10000` | `dsh_run` 空闲心跳间隔;`0` 关闭周期性心跳(起始/阶段通知仍然发送)。**仅**在客户端提供 `progressToken` 时生效 |
| `DSH_BRIDGE_PERMISSION` | `allow` | **仅 ACP**:权限请求自动应答 `allow`/`deny`;其它取值按 `deny` 处理 |
| `DSH_BRIDGE_LOG` | `info` | `debug` 会打印 dsh stderr 与每条事件;`silent` 全关 |
| `DSH_BRIDGE_SESSIONS_FILE` | `$DSH_HOME/bridge-sessions.json` | ACP 会话缓存(跨重启 resume 用) |

### Web 传输

| 变量 | 默认 | 含义 |
|---|---|---|
| `DSH_WEB_URL` | 空 | **高级挂接模式**:显式指定已有服务(可以是带 `token=` 的 URL)。设置后桥**不会**启动/替换服务;若该服务不可达或拒绝鉴权,返回可操作的错误。 |
| `DSH_WEB_URL_FILE` | `$DSH_HOME/bridge-web.json` | 私有连接状态 / launch URL 文件(0600,**不要提交到版本库**)。支持桥自己写的 JSON,也支持纯文本的一行 URL。 |
| `DSH_BRIDGE_WEB_HOST` | `127.0.0.1` | 仅允许回环;非回环/非 http 会被拒绝 |
| `DSH_BRIDGE_WEB_PORT` | `0` | 管理服务的端口;`0` 由系统选空闲端口(默认,绝不与用户服务冲突) |
| `DSH_BRIDGE_WEB_OPEN_BROWSER` | `true` | 是否在 `dsh_start` 时打开默认浏览器展示原生 Web UI |
| `DSH_BRIDGE_WEB_LAUNCH_TIMEOUT_MS` | `60000` | 等待 `dsh web` 打印 launch URL 的预算;超时会终止这个半启动的子进程 |
| `DSH_BRIDGE_WEB_REQUEST_TIMEOUT_MS` | `20000` | 单次 HTTP 请求 / root token 换 cookie 的预算 |
| `DSH_BRIDGE_WEB_PREFLIGHT_TIMEOUT_MS` | `15000` | 发 prompt 前的预检(follow 快照 + 原生注册表)预算,避免无上限等待 |
| `DSH_BRIDGE_WEB_BUSY_RECHECK_MS` | `1500` | 原生 `session/list` 报 running 时,重复确认多久才判定为「外部正在跑」并拒绝 |
| `DSH_BRIDGE_WEB_STOP_ON_EXIT` | `0` | 是否在 MCP 断开时停止**自己启动的**服务(默认不停,留给用户继续看历史) |
| `DSH_BRIDGE_WEB_STOP_TIMEOUT_MS` | `10000` | 停止管理服务的等待预算 |
| `DSH_BRIDGE_WEB_SESSIONS_FILE` | `$DSH_HOME/bridge-web-sessions.json` | Web 会话缓存(与 ACP 缓存分开,互不覆盖) |

### WebCodex 网关(入口 B)

| 变量 | 默认 | 含义 |
|---|---|---|
| `DSH_BRIDGE_ALLOWED_ROOTS` | 空(**未配置=拒绝一切提交**) | JSON 数组,本桥允许提交/续跑/读取/取消/列出的工作区根。按**真实路径**判断;收窄后历史越界任务不可查看/取消/泄露,配置不可用时 fail closed。这是**路由/会话范围,不是操作系统文件沙箱**:原生 DSH 权限策略仍然适用 |
| `DSH_BRIDGE_GATEWAY_DIR` | `$DSH_HOME/bridge-gateway` | 私有 job 状态目录(record/spec/cancel/worker 日志/request claim) |
| `DSH_BRIDGE_WORKSPACE_LOCK_DIR` | 空(围栏关闭) | 跨进程工作区围栏目录;**入口 A/B/C 用同一个值**才能互斥 |
| `DSH_BRIDGE_GATEWAY_WAIT_MAX_MS` | `20000` | `dsh_job` 单次等待上限(代码再取 `min(该值, 20000)`;网关硬等待 125s) |
| `DSH_BRIDGE_GATEWAY_JOB_TIMEOUT_MS` | `0`(用 `DSH_BRIDGE_TIMEOUT_MS`) | 单个 worker 拥有的原生回合超时 |
| `DSH_BRIDGE_GATEWAY_CANCEL_POLL_MS` | `500` | worker 轮询取消请求文件的间隔 |
| `DSH_BRIDGE_GATEWAY_START_GRACE_MS` | `30000` | 仍为 `queued` 且没有活 worker 多久后如实报 `unknown` |
| `DSH_BRIDGE_GATEWAY_MAX_STAGES` / `_MAX_LISTED` / `_PROGRESS_WRITE_MS` | `12` / `50` / `1000` | 进度条目数、列表条数、进度写盘节流 |
| `DSH_BRIDGE_GATEWAY_OPEN_BROWSER` | `true` | 每个 authority 只打开一次原生 Web UI(真实进度看那里) |
| `DSH_BRIDGE_ENTRY` | 空 | 只用于记录/诊断(`codex`/`webcodex`/…),**不用于授权** |

WebCodex 0.4.1 的 Provider 配置是 **Runner TOML**(`[mcp]` / `[[mcp.providers]]`),不是 `mcpServers` JSON:
完整可合并示例见 [`examples/webcodex-runner.toml`](examples/webcodex-runner.toml)。
`env_from_env` 是 **TOML 内联表**,只把**父 Runner 进程已有的同名变量**透传给 Provider 子进程,
所以要在**启动 Runner 之前**在父进程环境里设置 `DSH_BRIDGE_ALLOWED_ROOTS`(必填)、
`DSH_BRIDGE_WORKSPACE_LOCK_DIR`(用户私有,与入口 A 同值)、`DSH_BRIDGE_WEB_SESSIONS_FILE`(用户私有单独文件)、
`DSH_BRIDGE_GATEWAY_OPEN_BROWSER=false`,以及 `SYSTEMROOT`/`COMSPEC`/`PATH`/`TEMP` 等基础变量。

`WEBCODEX_MCP_COMPACT_SCHEMAS=true` 与 `WEBCODEX_MCP_TEXT_JSON_COMPAT=true` 写在
**WebCodex Server 自身的 `server.env`**(不在 Provider 的 env 里):0.4.1 内置
`cargo_fmt`/`cargo_check`/`cargo_test`/`go_test` 的输出 schema 里有 `enum: []`,标准 SDK 解析会失败;
这与本桥的 `dsh_status` 空 inputSchema 无关。

### Planner(入口 C)

| 变量 | 默认 | 含义 |
|---|---|---|
| `CHATGPT_WEB_MCP_ROOT` | 空 | 第三方 `chatgpt-web-mcp` 的 checkout;只读动态 import `<root>/src/browser.js`,不复制/不修改/不新增依赖 |
| `DSH_BRIDGE_PLANNER_TIMEOUT_MS` | `900000` | 单次咨询上限(上游 Pro 路径会一直等,适配器自带 AbortController) |
| `DSH_BRIDGE_PLANNER_PROBE_TIMEOUT_MS` | `60000` | status/models 探测上限 |
| `DSH_BRIDGE_PLANNER_ANSWER_TIER` | `Pro` | **必须是 Pro**:非 Pro 取值会在发送前被拒绝(不会因为配置成 Thinking 而放行) |
| `DSH_BRIDGE_PLANNER_MAX_CHARS` | `16000` | task/context 上限 |


**Web 传输的连接与鉴权规则(实现约束)**

- 鉴权只走「root token → 服务签名的 HttpOnly cookie」:**不支持 Bearer**,也不合成 cookie、不读 `.credentials.yaml`。
- 只允许**回环 http**;`DSH_WEB_URL` 指向远程地址会被拒绝(cookie 是 host-only 且非 Secure)。
- 端口 0 的管理服务:launch URL(含 token)只进私有状态文件,**普通日志、工具返回、错误信息里都不会出现 token/cookie**。
- 已有 cookie 未过期时优先复用 cookie;否则才用 token 换一次。状态文件里的 launch URL 会在重连时保留,不会被写空。
- 缓存 cookie 被服务拒绝(过期/轮换)时,桥会用**同一 authority 的私有 launch URL**里的 token 走一次正常的 root 换 cookie
  并再次探测,用户**不需要手工输入 token**;显式挂接(`DSH_WEB_URL`)的被拒 token 仍然如实失败。
- 复用旧 cookie **不会**延长记录里的过期时间(服务端绝对过期时间不变);只有真的换到新 cookie 才会刷新。
- 会话的「结果未知」围栏(见下)会**持久化**:桥断开/重连后,只有拿到该回合持久的 `turn/end` 才会解除,
  不会因为 registry 暂时显示空闲(排队回合还没开始跑)就把已入队但结果未知的 prompt 再放一次。

权限语义(仅 ACP):`allow` 时优先选 `allow_once`,其次 `allow_always`;`deny` 时优先 `reject_always`,其次
`reject_once`。选项的 `kind` 是语义来源:显式但未知或与策略矛盾的 `kind` 不会被 `optionId` 字面值“救回”;
没有任何可接受选项时回 ACP 的 `cancelled`(fail closed)。

### Codex

ACP(默认):

```toml
[mcp_servers.dsh]
command = "node"
args = ["D:/PROJECT/codex-mcp-dsh/src/server.mjs"]
startup_timeout_sec = 60
# 不能写成与 DSH_BRIDGE_TIMEOUT_MS/1000 相等:桥在单轮超时后还要等 cancelGrace 才能给出 timeout
# 结果,客户端若先到点结束,就看不到这个结果。至少预留启动握手 + 取消宽限的余量。
tool_timeout_sec = 1900
enabled_tools = ["dsh_start", "dsh_workspace", "dsh_run", "dsh_session", "dsh_status"]

[mcp_servers.dsh.env]
DSH_HOME = "C:/Users/<you>/.dsh"
```

原生 Web(推荐;一行环境变量即可):

```toml
[mcp_servers.dsh]
command = "node"
args = ["D:/PROJECT/codex-mcp-dsh/src/server.mjs"]
startup_timeout_sec = 60
# 桥的后端单轮预算是 DSH_BRIDGE_TIMEOUT_MS=1800000ms(1800s),另外还要等启动握手(≤60s)
# 和取消宽限(≤5s);1900s 给客户端留出余量。设成刚好 1800s 会让客户端先到点,看不到桥的 timeout/unknown 结果。
tool_timeout_sec = 1900
enabled_tools = ["dsh_start", "dsh_workspace", "dsh_run", "dsh_session", "dsh_status"]

[mcp_servers.dsh.env]
DSH_BRIDGE_TRANSPORT = "web"
# 可选:DSH_BRIDGE_WEB_OPEN_BROWSER = "false"(脚本/无人值守时)
# 可选:DSH_WEB_URL = "http://127.0.0.1:3080/?token=..."(高级:挂接已有服务,不替换它)
```

等价的专用入口(强制 web 传输,注册时可替代上面的 `src/server.mjs` + 环境变量):

```toml
[mcp_servers.dsh]
command = "node"
args = ["D:/PROJECT/codex-mcp-dsh/src/server-web.mjs"]
startup_timeout_sec = 60
tool_timeout_sec = 1900
```

也可以用便捷入口(等价于强制 web 传输):

```sh
node src/server-web.mjs                 # = DSH_BRIDGE_TRANSPORT=web
node src/server-web.mjs --acp           # 临时切回 ACP
node src/server-web.mjs --web-url "http://127.0.0.1:3080/?token=..."
npm run start:web
```

### Claude Code

```bash
claude mcp add dsh --transport stdio -- node "D:/PROJECT/codex-mcp-dsh/src/server.mjs"
# 原生 Web:
claude mcp add dsh-web --transport stdio --env DSH_BRIDGE_TRANSPORT=web -- node "D:/PROJECT/codex-mcp-dsh/src/server-web.mjs"
```

## 验证

```sh
npm test              # 离线回归全套 + 真实 MCP 握手冒烟(不调模型)
npm run test:acp      # 只验证 ACP 握手与建会话,不调模型
npm run test:sessions    # 回合结算 / 取消 / cwd / 缓存(fake ACP,不调模型)
npm run test:permissions # 权限策略 fail-closed(不调模型)
npm run test:crash       # 真实子进程:超时不再打死桥(不调模型)
npm run test:mcp-cancel  # 真实 MCP 内存传输:取消穿透到 ACP(不调模型)
npm run test:mcp-progress # 真实 MCP 内存传输 + fake ACP:initialize.instructions / Dsh run 标题 /
                         # pending 期间的单调 progress(起始/阶段/心跳,含数字 0 token)、并发隔离、
                         # 结束/报错/中断后停表且中断仍穿透到 ACP(不调模型)
npm run test:lifecycle   # 延迟启动 / 幂等并发启动 / 失败重试 / 退出重启 / 启动中关闭
npm run test:workspace   # dsh_workspace:新建/复用/非法路径/并发/失败诚实报告 + run 按新目录执行
npm run test:web         # 原生 Web:鉴权/凭据脱敏/原生 workspace/相关性结算/实时进度/重复文本抑制/
                         # 取消与网络未知/外部占用拒绝/回执丢失/管理服务重启与复用/浏览器一次性打开/
                         # 自有服务在真实 MCP 父进程退出后仍存活(含 stopOnExit 回收)
npm run test:workspace-lock # 跨进程工作区围栏:互斥 / 假前缀 / 死持有者不自动解锁 / not-sent 释放 / root 过滤
npm run test:planner     # 入口 C:精确模型 + 矛盾现值拒绝 / 只认 Pro(真模块 isProTier)/ 非终态=unknown / 不重放
npm run test:gateway     # 入口 B:真实 MCP + 真实 detached worker(fake DSH Web):只读 dsh_lock、
                         # root 过滤、request_id 幂等与冲突、unknown 不伪装完成、waited_ms 真实值、断开存活
npm run test:dual-entry  # 审查修复回归:allowed-roots 读取门禁 / request_id 原子 claim(两个真实子进程)/
                         # classifyError 真相 / waited_ms(worker 指向不存在的 dsh,不启动服务、不调模型)
npm run test:gate     # 端到端 7 项(**会调 2 次模型**,需本机凭据)
```

`npm test` 只跑离线部分(**所有项目测试都用隔离的临时会话缓存**,不碰真实用户缓存,也不碰用户已有服务:
web 测试用端口 0 或自带 fake 服务)。真实模型验收只有显式跑 `npm run test:gate` / `acp-smoke --prompt` 才会发生,
本项目不会声称未运行过的模型验收已通过。

> 注意:`test/mcp-smoke.mjs` 会用**管道 stdio** 拉起 `src/server.mjs`。在禁止创建管道(`spawn EPERM`)的
> 受限沙箱里这一步会失败,这是环境限制而非桥的缺陷;`test/web.test.mjs` 用文件描述符实现了等价的 stdio
> 冒烟(ACP 与 web 两个入口都覆盖),所以在那种沙箱里 `npm test` 的最后一步仍可能报 EPERM。

## 本机实测结论(dsh 0.1.5-rc.1 + Node v24.19)

- `dsh --profile acp` 直接可用,ACP `initialize` 通告 `sessionCapabilities: {close, list, resume}`、`mcpCapabilities.http: true`、`authMethods: []`;
- **ACP 的 `session/prompt` 响应就是回合结果**(等模型跑完才返回,带 `stopReason`),不是仅入队回执——它是桥唯一承认的回合结束信号;`session/update.turn_ended` 与 `session/status` 只是信息,不能提前释放 busy,也不能改写 stop_reason;
- **Web 的 `session/prompt` 只是入队回执**(`{accepted:true}`):桥先开 `session/follow` 拿快照,再用
  `requestId` 与持久 `user/message.source.rpcId` 对齐回合,**只有该回合的持久 `turn/end` 才算结束**;
  历史事件、别人的回合、工具结果、HTTP 回执都不会让调用提前返回;
- Web 的 `session/follow` 实时帧里:`start` 带 `turn`,**`chunk`/`end` 只有 `attemptId`**(没有 `turn`),
  所以必须自己把 `attemptId → turn` 映射起来,否则实时文本会丢;
- 图片提示词不支持(`promptCapabilities.image=false`),桥只接受文本;
- 会话跨进程可恢复(ACP 实测用 `bridge-sessions.json` 里的 id `session/resume` 成功后继续跑)。

## 设计取舍

- **不用 `@agentclientprotocol/sdk`**:ACP 就是换行分帧的 JSON-RPC,字段已实测,手写客户端少一个依赖面;
- **Web 传输独立成 `web-client` / `web-sessions` / `web-runtime`**,不把 Web 语义塞进 ACP 契约:ACP 的
  `prompt` 是「等结果」,Web 的 `prompt` 是「等回执 + 靠事件流结算」,两者只在 `app.mjs` 的 5 个工具后面统一;
- **不暴露 DSH 内部工具**:只给 `dsh_run` 这类粗粒度入口;
- **stdout 只有 MCP 帧**,所有日志走 stderr;
- **默认端口 0 的管理服务**:绝不占用/覆盖用户已有服务;launch token 私有捕获、私有换 cookie;
  重连优先复用 cookie 与已记录的服务,用户不需要任何 token 操作;服务以 **detached + 私有文件捕获**启动,
  因此父 MCP 进程退出后它仍然真实存活(`DSH_BRIDGE_WEB_STOP_ON_EXIT=1` 时才主动回收);
- **Windows 启动器安全解析**:`dsh.cmd` 垫片会被解析成它包裹的 Node 入口(`node <bin.js> ...`),
  直接用 `process.execPath` 启动——既不需要 `cmd.exe`,也**不会把 agent 名/任务/cwd 之类的内容插值进命令行**;
  ACP 与 Web 共用这套解析;垫片指向已删除的 `bin.js` 时会立即返回可操作的安装错误,不再等待握手超时;
  清理半启动子进程时先直接杀这个真实子进程(受限环境里 `taskkill` 可能被拒绝,树杀只是兜底);
- **dsh 延迟启动,由 `runtime` 模块统一管理**:MCP 连接不依赖 dsh;`dsh_start`/`dsh_run`/`dsh_workspace`
  通过同一个 startup promise 启动,连续/并发调用只起一个进程;`dsh_status` 只读。传输真的断了(子进程退出、
  mux 关闭、HTTP 不可达)时状态如实变为 `error`,但 MCP 服务继续可用,`dsh_start` 可重新连接;
- **`dsh_workspace` 只做目录 + 原生 workspace/会话**:只用 fs API(不拼 shell),创建缺失父目录、安全复用已有目录,
  不做 Git 初始化/模板/注册等副作用;会话创建失败时保留已创建目录并如实报告 `created`,不回滚删除;
- **超时/取消语义(两种传输一致)**:单轮超时或调用方 abort 都只发一次取消(并清掉另一个触发计时器);
  本地超时触发的 `stop_reason` 始终是 `timeout`。ACP 用 prompt 响应确认;Web 用对应回合的 `turn/end` 确认,
  `session/cancel` 的 `{accepted:true}` 只是「已请求」。宽限到期仍未确认则返回 `cancel_state: unknown`,
  **会话保持 busy**、拒绝新回合;网络中断、回执丢失同样保持 busy 且**绝不自动重放** prompt;
- **`dsh_session close`(web)只解绑**:原生历史保留、共享服务不动、`native_task_stopped:false`;
  回合在跑时拒绝解绑,避免把「结果未知」的证据抹掉;
- **会话映射按 authority 作用域保留**:桥只重新采纳**自己记录过**的同一个原生会话;别的 authority/别人的会话
  绝不被隐式复用,没有自己的映射时就在已有 Workspace 上新建一个;
- **「结果未知」跨重连围栏**:回执丢失、取消未确认、连接断开导致的 `cancel_state: unknown` 会把该会话标记 fence;
  重连/换 runtime 后仍然保守拒绝新回合,直到从持久事件流拿到我们那条 `user/message` 对应的 `turn/end` 才解除。
  需要立刻绕开时用 `dsh_run({ new_session: true })`;这**不会**削弱任何安全性:宁可拒绝,也不重放;
- **权限默认 allow(仅 ACP),Web 权限交给原生 UI**:Web 不自动绕过任何确认;
- **`dsh_run` 的进度是每请求独立计数器**:绑定客户端给的 `progressToken`(无 token 则完全不发),
  真实事件与空闲心跳共用一个严格递增的 `progress`,不发百分比/总数,返回/报错/取消即停表;
  心跳只是活动信息,不改变客户端超时/配额,也不能替代或模拟客户端自己的额度提示。

## 已知限制

- 单会话串行:同一会话有回合在跑时,新的 `dsh_run` 直接报错,不会排队;
- 取消未确认(`cancel_state: unknown`)后会话保持 busy,直到对应回合的终止事件到达;期间拒绝新回合。
  ACP 下连接退出可以释放;Web 下共享服务不会因为桥断开而结束,所以需要原生 `turn/end`,或重连后由原生注册表再次确认
  (此时会以「外部正在运行」拒绝,而不是重放)。曾经入队但结果未知的回合会留下持久 fence,重连后仍保守拒绝,
  直到持久 `turn/end` 出现;期间可用 `new_session: true` 开一个新会话继续;
- Web 的 `session/list` 参数名以安装版本的生成描述符为准(当前为 `_request`),桥带 `request` 兜底;
- Web 传输下 `turns` 只统计正常结束(`end_turn`/`max_tokens`/`blocked`)的回合;插件扩展的未知终止原因
  会以 `unknown:<kind>` 如实上报且不计入;缺失终止原因会被当作失败而不是成功;
- 不支持图片入参;
- MCP 声明透传(把 Codex 的 MCP server 转发进原生会话)尚未实现,`mcpServers` 目前固定传空数组。
- WebCodex 网关(入口 B):`DSH_BRIDGE_ALLOWED_ROOTS` 只是**路由/会话范围**,不是 OS 文件沙箱,
  也不能替代 DSH 原生权限策略;围栏(`DSH_BRIDGE_WORKSPACE_LOCK_DIR`)没有过期时间、不会按 PID 自动解锁,
  结果未知时故意保留,只能由本机操作者用 `scripts/unlock-workspace.mjs` 带证据解锁(MCP 的 `dsh_lock` 只读);
  收窄 allowed roots 会让越界历史任务不可读/不可取消/不可列出(fail closed,这是有意为之)。
- Planner(入口 C):需要本机已登录的 ChatGPT 网页;模型名必须来自 `planner_models` 的实时枚举,
  没有默认值也不会替换;非 Pro 档位在发送前就被拒绝。

TDQS

A4.1/5.0

Scored across 6 tools

Disambiguation4/5

Each tool maps to a distinct lifecycle area (start, run, session, status, workspace, model), and the descriptions are detailed enough to resolve most ambiguity. However, dsh_session's list/status view overlaps conceptually with dsh_status, and dsh_run's auto-start behavior blurs the boundary with dsh_start.

Naming Consistency4/5

All tools share a clear dsh_ prefix and consistent snake_case, making the family easy to recognize. The pattern is slightly mixed, though: start/run are verbs while session/status/workspace/model are nouns, so it is not a uniform verb_noun convention.

Tool Count5/5

Six tools is a well-scoped set for this domain. Each tool covers a distinct concern with no obvious redundancy, and the count falls comfortably within the ideal range.

Completeness3/5

The core surface covers starting, running, session management, status, workspace setup, and model selection. Notable gaps exist, however: there is no explicit stop/shutdown operation, and dsh_run's guidance to prefer dsh_submit + dsh_job for long tasks references async tooling that is absent from this server.

Maintenance

ActivityMaintained
ResponsivenessNo issues