codex-mcp-dsh
# 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
Scored across 6 tools
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.
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.
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.
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.