dsh-gov-workbench
本插件已完成真机挂载验证。仓库内 138 项自动化测试基于 mock ctx,覆盖协议信封、SSE 帧、AbortSignal 回归、来源校验与令牌、Cordis Proxy 语义、inject 的 !!js 陷阱与前端接线。
真机侧(dsh 0.2.0-rc.2 / node v24.18.1 / Windows,desktop profile)确认:插件挂载成功,3091 正常监听并返回页面;/plugin/status 报 host: "typertGateway"、hostAvailable: true;session.list 返回 result.ok = true 与 85 条真实会话;agentPreset.list 返回 standard, ptc, minimal, cordis, computer-use, redteam;settings.describe 报 24 个宿主命名空间,系统配置页全部渲染且可写;政务门户完整渲染,六栏目可切换。
session.prompt 的完整对话回合已在真机验证:提交后 session.projections 的 asOfSeq 从 5 增长到 21,宿主自动生成了会话标题。尚未覆盖审批/提问弹窗的 respond 回环与 sessionStats 统计口径的逐项核对,详见 §8 已知限制。
1. 它是什么
综合政务智能工作台:一网通办 · 智能协同 · 全程留痕
一个可独立安装运行的 dsh(DeepSeek Harness)Cordis 插件。它在插件自己的端口(默认 3091)拉起政务门户风格的 WebUI,并把浏览器的每一个请求直接交给宿主进程内的 API 网关。页面不做任何业务预设,事项、模型、权限档位、统计口径全部实时取自宿主。
- 包名:
dsh-gov-workbench|入口:lib/index.js(type: module,engines.node >= 22.19) - 装配层:
cordis.patch.yml(package.json的dsh.bundle.patch指向它) - 默认地址:
http://127.0.0.1:3091/ - 页面六栏目:工作台首页 / 事项办理 / 卷宗档案 / 运行轨迹 / 系统配置 / 规章制度
界面
工作台首页 事项受理概览、常用通道、通知公告与平台运行数据

事项办理 工单编号条、交互回执窗口、实时统计行与参数行

卷宗档案 会话档案索引、检索、调阅与 JSONL 导出

运行轨迹 逐条事件流水,可按字级输出过滤

系统配置 由 settings.describe 的 schema 动态生成的表单

规章制度 平台运行说明

截图由 node test/screenshot.mjs 生成,脚本用演示数据替换真实会话,图里的标题与路径都不是本机内容。
2. 架构
三层结构:
┌─────────────────────────────────────────────────────────────────────┐
│ dsh 主进程(宿主) │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ dsh-gov-workbench 插件(lib/,跑在宿主进程内) │ │
│ │ index.js Cordis 插件主体 { name, inject, apply, Config } │ │
│ │ host.js 宿主网关绑定 + 能力探测 + 参数投影 │ │
│ │ bridge.js /api/* 四象限 RPC 信封派发、卷宗导出 │ │
│ │ security.js 来源 / 令牌 / Content-Type 准入 │ │
│ │ transport.js HTTP 信封、SSE 编码、请求级 AbortSignal │ │
│ │ sse.js MuxController:会话事件 + 投影 + 审批 / 提问 │ │
│ │ static.js public/ 静态托管(含目录穿越防护) │ │
│ │ config.js 配置读写、归一化、令牌生成 │ │
│ │ │ │
│ │ node:http 服务 —— 独立端口 3091(默认绑回环地址) │ │
│ └───────────────────────────┬───────────────────────────────────┘ │
│ │ 同进程直调,不走网络 │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 宿主 API 网关 │ │
│ │ · 0.2.0+:ctx.typertGateway + 各域 controller │ │
│ │ (sessionController / settingsController / │ │
│ │ workspaceController / agentPresets / llm / ...) │ │
│ │ · 0.1.x :ctx.apiProxy │ │
│ │ 另有 ctx.sessionPersistence / ctx.sessionProjections │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
▲ HTTP / SSE → http://127.0.0.1:3091/
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 浏览器页面(public/) │
│ index.html 六栏目政务版式 js/api.js 四象限 RPC 客户端 │
│ css/gov.css 政务视觉令牌 js/store.js localStorage 偏好 │
│ js/app.js 页面装配与业务编排(只调 wire 端点,无业务硬编码) │
└─────────────────────────────────────────────────────────────────────┘
为什么走同进程直调而不是网络转发。插件跑在 dsh 主进程内,lib/host.js 拿到的 ctx.typertGateway(或老形态的 ctx.apiProxy)就是宿主自己的对象引用。因此:
- 不走网络:没有中间 HTTP 跳数,也没有「插件端口 → 主 GUI 端口」的第二跳。
- 没有 CORS 问题:浏览器只与
127.0.0.1:3091同源通信,不跨源访问 dsh 主 GUI。 - 没有鉴权围栏问题:不需要把宿主令牌交给浏览器,插件在宿主侧自行完成准入判定(见 §7)。
- 不做业务硬编码:端点是否存在由宿主自己的注册表回答(
ctx.typert.local),参数按宿主描述符动态投影(buildArgs),插件不维护方法表。
页面侧一律使用「单数域名.方法」的 wire 写法(如 session.list),由 lib/host.js 的别名表映射到真实 namespace(session/list)。
3. 在测试机上安装与验证
本工程未在本机安装。开发机只负责产出代码,所有安装动作都在独立测试机上做。本节给出完整的搬运、安装、验证、回滚与排错流程。
3.0 前置要求
| 项 | 要求 | 说明 |
|---|---|---|
| dsh 版本 | 0.2.0-rc.2(对齐开发机) | 其它版本也能跑(lib/host.js 做能力探测),但集成面以 0.2.0-rc.2 为准,见 docs/harness-integration.md |
| Node | ≥ 22.19 | dsh 自身运行时随发行版附带 node 24.21.0。本插件的测试套件已在 v22.22.2 / v24.18.0 / v24.21.0 三个版本上全部跑通;更低版本未验证 |
| 包管理器 | pnpm(dsh 自带即可) | dsh plugin 会转发给 profile 目录下的 pnpm |
| 端口 | 3091 空闲 | 刻意避开 3080(dsh 主 GUI)与 3081(原版 gov-portal) |
| 平台 | Windows / macOS / Linux | 代码里没有平台特定分支 |
先确认测试机环境:
dsh --version # 期望 0.2.0-rc.2
node --version # 期望 v22.19.0 或更高
关于 profile 名:dsh CLI 拒绝 --profile desktop,会直接报
error: profile "desktop" is managed exclusively by the Electron application。
desktop profile 由 Electron 应用独占管理。所以测试机上请用非 desktop 的 profile
(如 web,或自建一个),下文的 <PROFILE> 即指它。
# 查看已有 profile
ls "$DSH_HOME/profiles" # Windows: dir %DSH_HOME%\profiles
3.1 传输:把工程拷到测试机
工程是纯源码、没有运行时依赖、也不需要构建,三种方式任选。
方式 A:打包 zip(推荐,最省事)
在开发机(工程根目录的上一级)执行:
# Windows PowerShell
Compress-Archive -Path dsh-gov-workbench -DestinationPath dsh-gov-workbench.zip -Force
# macOS / Linux
zip -r dsh-gov-workbench.zip dsh-gov-workbench -x '*/node_modules/*'
拷到测试机后解压到任意目录,例如:
# Windows PowerShell
Expand-Archive -Path dsh-gov-workbench.zip -DestinationPath C:\plugins\
# macOS / Linux
unzip dsh-gov-workbench.zip -d ~/plugins/
解压后应得到 C:\plugins\dsh-gov-workbench\package.json(或 ~/plugins/dsh-gov-workbench/package.json)。
下面把这个绝对路径记作 <PLUGIN_DIR>。
方式 B:git
# 开发机:把工程推到一个测试机能访问的仓库
cd dsh-gov-workbench
git init && git add -A && git commit -m "dsh-gov-workbench"
git remote add origin <REPO_URL> && git push -u origin main
# 测试机
git clone <REPO_URL> <PLUGIN_DIR>
方式 C:直接共享目录(同一局域网 / 挂载盘)
直接把 dsh-gov-workbench/ 整个目录拷过去即可。不要拷贝 node_modules(本工程没有依赖,不需要它)。
3.2 安装
前置条件:github: 源要求 git 在 PATH 里。pnpm 解析 github: 规格时会 shell 调用 git ls-remote 去问远端的分支与 tag。目标机没装 git 时,安装会在这一步失败,报错形如:
[ERROR] Command failed with exit code 1: git ls-remote "https://github.com/<owner>/<repo>.git"
'git' 不是内部或外部命令,也不是可运行的程序或批处理文件。
两条路:
装上 git 再重试:Windows 用
winget install Git.Git或到 git-scm.com 下载; 装完重开终端让 PATH 生效,再执行上面的add命令。绕开 git:把本工程目录(或解压后的 zip)直接拷到目标机,用本地路径安装。 本包没有
dependencies,link:安装不需要联网下载任何东西:dsh plugin --profile <PROFILE> add "link:<解压后的目录>"界面里的「添加插件」对话框同样接受本地目录路径,效果一致。
下面的命令只应在测试机上执行。执行前先确认 echo $DSH_HOME 指向测试机的 dsh home。
推荐用 link: 协议(软链到源码目录,改代码后重启即生效,适合联调):
dsh plugin --profile <PROFILE> add "link:<PLUGIN_DIR>"
例(Windows):
dsh plugin --profile web add "link:C:\plugins\dsh-gov-workbench"
例(macOS / Linux):
dsh plugin --profile web add "link:/home/user/plugins/dsh-gov-workbench"
link: 换成其它写法:
| 写法 | 命令 | 适用场景 |
|---|---|---|
link: |
dsh plugin --profile web add "link:<PLUGIN_DIR>" |
联调;软链,改源码立即反映 |
file: |
dsh plugin --profile web add "file:<PLUGIN_DIR>" |
拷贝一份到 profile 的 store,与源码解耦 |
| 相对路径 | dsh plugin --profile web add "../dsh-gov-workbench" |
在 profile 目录附近时;会被锚定到当前工作目录 |
| npm 包名 | dsh plugin --profile web add dsh-gov-workbench |
已发布到 registry 时。本包 package.json 未设 private,可直接 npm publish;但注意 registry 默认是 npmmirror 镜像,发布要走官方源 |
命令行为(@deepseek-ai/dsh-plugin-manager):
- 在
<DSH_HOME>/profiles/<PROFILE>/目录下把剩余参数转发给 pnpm(profile 不存在时会先按模板初始化); - 安装完成后 reconcile
dsh.profile.bundles:遍历 profile 的dependencies,凡是声明了dsh.bundle.patch的包就被追加进 bundle 栈; - 没声明
dsh.bundle的包只会得到一行警告declares no dsh.bundle — installed as a plain dependency, not a profile layer,不会成为插件层。
本包在 package.json 里声明了 dsh.bundle.patch: ./cordis.patch.yml,所以会自动进 bundle 栈。
手动等效做法(CLI 不可用或想完全掌控时):
编辑
<DSH_HOME>/profiles/<PROFILE>/package.json,在dependencies加一行,并在dsh.profile.bundles追加本包:{ "dependencies": { "dsh-gov-workbench": "link:C:\\plugins\\dsh-gov-workbench" }, "dsh": { "profile": { "bundles": [ "@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-gov-workbench" ] } } }在 profile 目录执行
pnpm install。
两种做法等价。走 CLI 的好处是 reconcile 会自动算 bundle 栈,不用手写 bundles。
3.3 首次启动:确认插件挂上了
bundle 栈的变化只有重启 dsh 才会被读取,所以装完必须重启。
生效边界:
安装 / 卸载 / 改端口或 host → 必须重启 dsh 才挂载
只改 public/ 下的页面资源 → 刷新浏览器即生效(静态资源每次请求都读盘)
只改 $DSH_HOME/gov-workbench.json 内容 → 立即生效
确认点 1:启动日志里 3091 的 ready 行。重启后应出现三行:
[gov-workbench] 综合政务智能工作台已上线:http://127.0.0.1:3091/
[gov-workbench] 宿主 API 网关:typertGateway(已接入,1:1 能力)
[gov-workbench] 配对令牌校验:开启;配置文件:<DSH_HOME>/gov-workbench.json
第二行是关键:必须是 typertGateway(已接入,1:1 能力)。若显示
unavailable(不可用),说明网关探测失败,见 §3.6 排错。
确认点 2:/plugin/status 端点自检。
curl -i http://127.0.0.1:3091/plugin/status
期望 200 且含 plugin / host / hostAvailable: true / port / requireToken 等字段,
不含 token 字段(令牌永不回显)。浏览器直接打开 http://127.0.0.1:3091/ 也能看到政务门户页面。
确认点 3:--dump-config 查合成树里有没有插入行。
dsh --profile <PROFILE> --dump-config | grep -A 8 'gov-workbench'
(Windows 无 grep 时用 dsh --profile <PROFILE> --dump-config > dump.txt 再搜 gov-workbench。)
应能看到 id: gov-workbench / name: dsh-gov-workbench 以及 config 里的 port: 3091。
--dump-config 打印的是合成后的 profile 树,因此这一条同时验证了「bundle 栈已包含本包」与
「cordis.patch.yml 的 - insert: 已合并」。
相关开关(三者互斥):
dsh --profile <PROFILE> --dump-config # 含用户层与 --patch 覆盖的完整树
dsh --profile <PROFILE> --dump-default-config # 不含用户层,用于对比差异
dsh --profile <PROFILE> --dump-config-schema # 只打印 profile 条目的 JSON Schema
3.4 冒烟测试(在测试机上跑)
工程自带 6 个零依赖测试套件,不需要真实 API 额度,也不会占用 3091:
cd <PLUGIN_DIR>
node test/syntax-check.mjs # 语法 + UTF-8 无 BOM + 分层约束 + package.json 路径 + patch inject 守卫
node test/patch-inject.mjs # patch 不得用 !!js 写 inject(真 cordis 可用时做端到端验证)
node test/cordis-proxy.mjs # cordis ctx Proxy 语义(未 inject 的 service 访问会抛错)
node test/host-descriptor.mjs # 用宿主真实 descriptor 校验端点表与 buildArgs 投影
node test/server-smoke.mjs # 静态托管 / 四象限信封 / SSE / respond / 导出 / 跨源拒绝
node test/plugin-boot.mjs # 真 apply() → 真 http → 真 SSE → 惰性接网关 → 关停
node test/frontend-wiring.mjs # DOM id / 类名 / 栏目 / 模块顺序 / settings 签名 / 宿主必填字段
界面截图由 node test/screenshot.mjs 生成(需要 3091 在线与无头 Edge,不属于回归套件):
node test/screenshot.mjs --out shots
它用 CDP 注入一层演示数据,把会话标题、工作目录、模型目录、配置项换成虚构内容, 所以示例图不会夹带本机的会话与路径。
全部应以 exit=0 结束,并打印 全部通过(N 项)。
test/host-descriptor.mjs 会去读 dsh 发行版里的 app.asar,把 26 个宿主包的
lib/typert.host.js 里的 invocations[](140 个端点)全部解出来,然后断言:
别名表里每个 namespace 都真的注册过、前端调用的每个端点都存在、buildArgs 对
acceptsUndefined 的 wire 会整个省略、单 wire / 多 wire / scope 三种形状投影正确。
找不到含 dsh 描述符的 app.asar 时(测试机没装桌面版)该组打印 SKIP 并以 0
退出,第 1 组的静态检查永远执行。机器上若同时跑着别的 Electron 应用,定位器会
逐个候选验证「真的含 dsh 描述符」再采纳,不会把别的 app.asar 误当 dsh。
另有 4 个真机联调脚本,需要本机 3091 正在运行,且 $DSH_HOME/gov-workbench.json
里有配对令牌。它们不属于回归套件,只在有真机时手动跑:
node test/_live-frontend.mjs # 把改后的前端在 VM 里跑起来,捕获它真发的 wire body,原样打到真机
node test/_live-verify.mjs # 提交一条真消息,验证 updatedAt 与 asOfSeq 都增长
node test/_live-regression.mjs # 17 个已知可用端点回归,确认没改坏
node test/_live-degrade.mjs # 工作目录降级路径与 session.search 的真实错误码
这 4 个脚本会真的往宿主里写数据(建会话、发消息、改名),跑完会在 dsh 里留下 若干测试会话,这是真机验证的必要代价。
test/patch-inject.mjs 的第 2 组需要真 @deepseek-ai/cordis / cordis-plugin-loader /
cordis-plugin-include / js-yaml。解析不到时该组打印 SKIP 并以 0 退出(不算失败),
第 1 组的静态检查永远执行,所以测试机上没装这些依赖也不会误报。
关于 test/ 是否随包分发:package.json 的 files 字段包含 test。
这样无论用 link:(软链到源码目录)还是 file:(拷贝到 profile store)安装,
都能直接在安装目录里跑上面这些命令。测试本身零依赖、不占用 3091、不碰真实配置,
带上没有副作用。若你只想发最小包,把 files 里的 "test" 删掉即可,
那时测试需要从仓库源码跑。
plugin-boot.mjs 会真的起一个 http 服务,但它先让内核分配空闲端口再写进配置,
用临时 DSH_HOME,不会占用 3091,也不碰测试机的真实配置。
也可以只跑最关键的一个:
node test/server-smoke.mjs
3.5 回滚 / 卸载
方式 A:CLI 卸载(推荐)
dsh plugin --profile <PROFILE> remove dsh-gov-workbench
该命令同样转发给 pnpm,并在结束后 reconcile:本包已不在 dependencies 里,
于是也会从 dsh.profile.bundles 中移除。之后同样需要重启 dsh。
方式 B:手改回退(CLI 不可用时)
- 编辑
<DSH_HOME>/profiles/<PROFILE>/package.json:- 从
dependencies删掉dsh-gov-workbench那一行; - 从
dsh.profile.bundles删掉"dsh-gov-workbench"。
- 从
- 在 profile 目录执行
pnpm install(清理软链/拷贝)。 - 重启 dsh。
方式 C:只停用不卸载
在 <DSH_HOME>/profiles/<PROFILE>/cordis.patch.yml 里追加一行按 id 覆盖(用户层最后应用,覆盖 bundle 层):
- id: gov-workbench
disabled: true
这样包还在、页面不再挂载;把这段删掉再重启即可恢复。
清理运行时残留(可选):
rm "$DSH_HOME/gov-workbench.json" # 端口/令牌/访问计数
浏览器侧清 localStorage 的 dsh.govWorkbench.v1(UI 偏好)。
3.6 排错
① 端口被占。启动日志出现 EADDRINUSE 或 监听失败:address already in use
# Windows
netstat -ano | findstr :3091
# macOS / Linux
lsof -i :3091
判断:若占用者是上一次未退出的 dsh,杀掉它再重启;若是别的程序,改本插件端口,
编辑 <DSH_HOME>/gov-workbench.json 的 port,或改 profile 的 cordis.patch.yml 里
config.port,然后重启 dsh(端口改动不热生效)。
页面里「系统配置 → 插件自有端点」改端口只会落盘并返回 needsRestart: true,同样要重启。
② 令牌不匹配。浏览器拿到 401 / {"error":"配对令牌无效或缺失。"}
原因通常是 cookie 没种上或过期。依次尝试:
确认是首次访问页面(
GET /)而不是直接打/api/*,令牌 Cookie 在首次静态访问时种下;清掉浏览器里
dsh_gov_workbench_token这个 Cookie,重新打开http://127.0.0.1:3091/;检查
<DSH_HOME>/gov-workbench.json的token与requireToken是否与预期一致; 开发期想省事可临时设requireToken: false(不推荐在共享机器上这么做);用脚本调试时,把令牌放进
x-gov-token头即可,不必依赖 Cookie:curl -X POST http://127.0.0.1:3091/api/workbench.status \ -H 'content-type: application/json' \ -H "x-gov-token: $(node -e "console.log(require(process.env.DSH_HOME+'/gov-workbench.json').token)")" \ -d '{"type":"client-request","rpcId":"1","method":"workbench.status","payload":{}}'
③ apiProxy / 网关未接入。启动日志显示 宿主 API 网关:unavailable(不可用),
或 /plugin/status 的 hostAvailable 为 false
这说明 lib/host.js 的能力探测两种形态都没找到(apiProxy 与 typertGateway 都不在 ctx 上)。
判断顺序:
- dsh 版本是否匹配:
dsh --version。0.2.0-rc.2 上apiProxy不存在, 正常形态应是typertGateway;若日志里反而是apiProxy,说明测试机是 0.1.x 老形态 (也能用,但集成面不同)。 - profile 是否真的加载了本插件:回到 §3.3 确认点 3,用
--dump-config查插入行。 插入行存在但网关不可用,是宿主侧问题;插入行不存在,是安装问题。 - 确认 profile 里挂了 API 网关层:
typertGateway由@deepseek-ai/dsh-api-gateway提供, 它随@deepseek-ai/dsh-base的typert-gateway行装入。用--dump-config搜typert-gateway, 确认它没被disabled: true掉。 inject表达式是否生效:本包用inject: !!js "ctx.get('apiProxy', false) ? ['apiProxy'] : []", 0.2.0+ 上求值为[](立即激活)。若插件卡在启动日志的 「Plugins waiting for services」,说明注入表达式被改坏,用--dump-config核对该行。
降级行为:网关不可用时插件不会崩,页面照常打开,/plugin/status 报告
hostAvailable: false,所有业务接口返回 gateway/service-unavailable 失败信封。
所以「页面能开但提交申办报错」通常就是这一条。
3.7 版本兼容性门禁
dsh 在加载插件时会检查 peerDependencies 里所有 @deepseek-ai/dsh / @deepseek-ai/dsh-*
的版本范围是否满足运行时版本(预发布版本参与比较,workspace:^ / ~ / * 视为当前运行时)。
不匹配会作为 issue 上报,并可通过「版本豁免」(按 插件名@版本 → 精确运行时版本)放行。
本包刻意不声明任何 peerDependencies。它不 import 任何 @deepseek-ai/* 包,
只通过宿主 ctx 上的 service 通信(见 lib/host.js),因此不会触发版本门禁。
这也是它能在 0.1.x 与 0.2.x 两种宿主形态上都跑起来的原因。
4. 验证
全部命令在 dsh-gov-workbench/ 目录下执行。
逐文件语法检查,覆盖单文件 V8 解析期,任何一项非零退出即为语法错误:
node --check lib/index.js
node --check lib/host.js
node --check lib/bridge.js
node --check lib/security.js
node --check lib/transport.js
node --check lib/sse.js
node --check lib/static.js
node --check lib/config.js
node --check public/js/api.js
node --check public/js/app.js
工程自检:覆盖全部 .js / .mjs 的 node --check、UTF-8 无 BOM 校验、分层约束
(lib/ 下不得出现 window. / document. / localStorage.,不得 import 网关的浏览器产物 client.js,
不得把 req.signal 当实参传给宿主方法;public/ 脚本不得用 ES module 语法)、
index.html 前端模块引用顺序、cordis.patch.yml 的 - insert: 与默认端口 3091、
package.json 的 dsh.bundle.patch / type: module / exports 声明:
node test/syntax-check.mjs
宿主插件冒烟测试(无需真实 API 额度):覆盖静态托管与目录穿越防护、/api/* 四象限信封分发
(unary → mock 网关 → server-response)、域名别名映射(session→session、agentPreset→agentPresets、
host→directoryPicker)、buildArgs 按宿主描述符裁剪参数、SSE 帧格式(\n\n 分隔 + data: <json> +
server-request 信封)、/api/respond 的 waterfall rpcId 还原、卷宗导出 JSONL、来源校验拒绝跨源(403)、
Content-Type 校验(text/plain → 415)、配对令牌校验(无令牌 → 401)、AbortSignal 回归
(确认传下去的是自建 controller.signal,且客户端断开时立即 abort、正常结束不 abort)、
网关不可用时的失败信封降级:
node test/server-smoke.mjs
端到端装配测试:真的调用插件的 apply(ctx, config),依次读配置、探测宿主、起真 http 服务、
静态托管、/api/* 桥、/plugin/* 端点、关停。断言插件形状与 Config 容错、上线日志、
未探测到网关时给出告警但仍可开页面、网关晚于插件 provide 时自动接上(惰性解析 + kind getter)、
网关缺席时 hostEvents 等待而不是立即结束、typertGateway 与老形态 apiProxy 的优先级、
首次访问种令牌 Cookie、令牌落盘可复用、真 SSE + 投影转发(session/projection 帧)、
/plugin/status 不回显令牌、PUT /plugin/config 落盘与 needsRestart 提示、跨源 /plugin/* 403、
ctx.effect 关停路径真的释放端口(含「不得使用 ctx.on('dispose')」的源码守卫)。
该测试先让内核分配空闲端口再写进配置,不占用 3091,使用临时 DSH_HOME,不碰真实配置:
node test/plugin-boot.mjs
前端接线自检(静态分析,无需浏览器):前端是零构建的经典脚本,没有编译期检查,
最容易出的错是「JS 里 getElementById 的 id 在 HTML 里不存在」导致运行时报 null。
该测试把这条静态化:app.js / panels.js 引用的每个 DOM id 都必须在 index.html 中存在、
用到的类选择器必须在 CSS 或 HTML 中有定义、每个 data-page / data-page-link 都指向真实栏目、
模块加载顺序与全局对象导出一致、util.js 的对外 API 覆盖实际调用:
node test/frontend-wiring.mjs
cordis Proxy 语义回归(无需真 cordis 依赖):用一个复刻 cordis 语义的 Proxy ctx
(读未 inject 的 service 属性会抛错),确认网关探测、MuxController 订阅路径都不崩,
并守卫源码里不出现裸的 ctx.<service> 属性访问:
node test/cordis-proxy.mjs
patch 装配守卫(致命 bug 回归):确认 cordis.patch.yml 的插件行没有 inject: 字段
(尤其没有 !!js 形式)。第 1 组是纯静态检查,永远执行;第 2 组在真 cordis + 真 loader +
真 YAML 方言下端到端验证 Inject.resolve 的结果里不含 __jsExpr,并含一个对照组
(故意构造坏 patch,证明检测手段不是空转)。依赖不可解析时第 2 组 SKIP 并以 0 退出:
node test/patch-inject.mjs
手动确认:应返回 200 与运行信息(plugin / host / hostAvailable / port / requireToken /
visits / marquee / node / pid / uptimeSeconds / startedAt),且不含 token 字段:
curl -i http://127.0.0.1:3091/plugin/status
5. 配置
5.1 服务端配置:$DSH_HOME/gov-workbench.json
路径由 configPath() 决定:DSH_HOME 存在时用它,否则回落到 ~/.dsh。写入是原子的(先写临时文件再 rename)。合并优先级(递增):内置默认值 → 配置文件 → cordis.patch.yml 该行的 config。
| 字段 | 默认值 | 含义 |
|---|---|---|
port |
3091 |
插件监听端口。刻意避开 3080(dsh 主 GUI)与 3081。取值必须落在 1–65535,非法值静默回落默认值。改动需重启 dsh。 |
host |
'127.0.0.1' |
监听地址。只接受非空字符串。改成 0.0.0.0 会把工作台暴露到局域网,请自行评估。改动需重启 dsh。 |
token |
''(启动时自动生成) |
配对令牌,32 字节 base64url。首次启动自动生成并写回配置文件;已有值不会被覆盖。 |
requireToken |
true |
是否强制校验配对令牌。默认开启,可在配置文件里置 false 关闭。 |
allowNoOrigin |
true |
是否放行无 Origin 头的请求(同源导航、curl、本地脚本)。置 false 则无 Origin 一律拒绝。 |
requireJsonContentType |
true |
是否要求写请求的 Content-Type 必须是 application/json。用于堵掉 text/plain 简单请求绕过预检的路径。 |
visits |
0 |
访问次数统计,随 workbench.visits / /plugin/visits 递增并落盘。 |
marquee |
三条内置通知 | 首页「重要通知」跑马灯内容,字符串数组。空数组会回落默认值。 |
sealOnComplete |
true |
是否在办结时显示「准予办结」盖章动画。 |
floatEnabled |
true |
便民提示浮窗(飘窗)开关。 |
5.2 前端偏好:localStorage
键名固定为 dsh.govWorkbench.v1,只放界面偏好,不放服务端配置。包含:page(当前栏目)、sessionId(当前事项)、workspace / permission / preset / provider / model / reasoningEffort(参数行选择)、autoScrollTrace / traceChunkFilter(轨迹视图)、sealOnComplete / floatEnabled(界面开关)、largeFont / highContrast(无障碍)、marquee。读写失败一律静默回落默认值。
5.3 插件自有端点
| 端点 | 方法 | 说明 |
|---|---|---|
/plugin/status |
GET | 运行信息,不回显令牌 |
/plugin/config |
GET / PUT | 读写配置。PUT 只改传入字段;端口 / host 变动时回 needsRestart: true |
/plugin/token |
GET | 回显令牌(能走到这里的请求已通过令牌校验) |
/plugin/visits |
GET / PUT | 读取(自增)/ 清零访问计数 |
/plugin/marquee |
GET / PUT | 读写跑马灯内容 |
6. 与宿主能力对照表
页面调用的 wire 端点(「单数域名.方法」写法)与宿主侧的对应关系。域名 → namespace 的映射由 lib/host.js 的别名表驱动。
完整的 140 个宿主端点目录(含每个端点的 wire 名与 acceptsUndefined 标记)见
docs/host-endpoints.md。
| 栏目 / 功能 | 页面调用的 wire 端点 | 宿主 service / 说明 |
|---|---|---|
| 工作目录 | directoryPicker.list、directoryPicker.pick、directoryPicker.createDirectory |
namespace directoryPicker;list 返回 path / home / crumbs / entries / truncated,需要 browse capability,缺失时宿主回 directory-picker/unavailable。list 的 path 声明了 acceptsUndefined,所以空值必须整个省略该 wire(buildArgs 已处理)。pick 无参数,弹原生对话框。降级:list 不可用时界面切成「可手动编辑的输入框 + 浏览…按钮」,并在提示里写明当前模式。 |
| 权限档位(读取) | permission.catalog |
namespace permissionPresets;返回 options / defaultOptions / defaultPreset。 |
| 权限档位(写入) | settings.update('permission', { defaultPreset }) |
settingsController。0.2.0-rc.2 的 Remote 面只暴露 permissionPresets.catalog,写入走 settings 的 permission.defaultPreset(与官方 UI 同路)。注意:settings/update 的宿主签名是位置参数 (ns, patch, expectedRevision),public/js/api.js 已把它组装成键名匹配的 payload 对象。 |
| 办理模式 | agentPreset.list、agentPreset.read、agentPreset.select |
namespace agentPresets;返回 presets[](含 id / name / isDefault)。read 传裸 {agentPreset};select 是两个独立 wire {agentId, agentPreset},agentId 是 scope 身份(即会话身份),传 sessionId 会得到 gateway/arguments-invalid: missing "agentId"。 |
| 模型与推理强度 | session.modelCatalog、session.selectModel |
sessionController。modelCatalog 返回 default / routableProviders / groups / failures;selectModel 的必填字段是 sessionId、provider、model,reasoningEffort 可选。provider 必须取自 groups[].id(不是模型名的一部分)。推理强度会回落到该模型自己的 reasoning.defaultEffort。另有 llm.listProviders / llm.listConfigurableProviders。 |
| 提交申办 | session.create → session.prompt |
sessionController。首次提交自动受理(create,可带 cwd / agentPreset),随后 prompt。session/prompt 的必填字段是 requestId、sessionId、mode、content,requestId 每次提交唯一,漏传会得到 gateway/input-invalid: wire field "request" failed boundary validation。事项编号由宿主分配(session-*)。 |
| 取消办理 | session.cancel |
sessionController,带 sessionId。 |
| 历史分页 | session.page、session.list |
sessionController。page 入参 address: { kind:'session', sessionId } / throughSeq / maxMessages,返回 records / hasMore。throughSeq 必须落在宿主自己的游标内,超过会得到 gateway/bad-request: session page through seq N is past cursor M;界面取自 session.list 的 projections.asOfSeq。 |
| 卷宗检索 | session.search |
sessionController,带 query。留空显示全部。宿主若把 session-query 索引配成 openAt "never"(本机如此),会回 gateway/internal: session search is disabled,界面显示成「宿主未启用会话检索」。 |
| 技能目录 | skills.list |
namespace 是复数 skills(不是 skill),必带 sessionId。 |
| 配置读写 | settings.describe、settings.update、settings.replace、settings.mutate |
settingsController。表单由 describe 返回的 schema 动态生成,覆盖全部命名空间;update 带修订号做乐观并发控制;敏感项只显示是否已设置。 |
| 统计取值 | session.projections + events.mux 的 session/projection 帧 |
sessionProjections.onChanged。轮次 / 步数 / 模型耗时 / 工具耗时 / 首 token / 解码耗时与 token 来自 sessionStats 投影;输入 / 输出 / 缓存 token 来自 tokenUsage 投影,嵌套在 totals 下({totals:{uncachedInputTokens,outputTokens,cacheReadTokens,cacheWriteTokens}}),读平铺字段会永远拿到 0。平台不估算、不编造任何统计值。 |
| 宿主诊断 | pluginInventory.list |
namespace pluginInventory;返回宿主已加载的插件条目与各预设的组合,用于诊断「插件到底挂上没有」。 |
| 审批应答 | POST /api/respond |
网关 $events 的 waterfall 帧(approval/request)经 MuxController 翻译为 approval/requested,应答走 $events/result + { clientId, eventId, outcome }。 |
| 提问应答 | POST /api/respond |
同上路径,user-questions/request → question/requested,应答携带 { answers }。 |
已移除的幽灵端点(0.2.0-rc.2 上不存在,调了必然失败,因此从 api.js 与别名表里删掉):
| 曾经的调用 | 真实情况 |
|---|---|
host.describe / host.listDirectory |
0.1.x apiProxy 时代的端点。别名表曾把域名 host 改写成 directoryPicker,产生 directoryPicker/describe、directoryPicker/listDirectory 两个必然 gateway/invocation-unavailable 的端点,该映射已删除。 |
skill.list |
真实端点是 skills/list(复数 namespace),且必带 sessionId。 |
subagent.list |
subagents namespace 只有 prompt 与 interruptByParent。 |
workspace.list |
不存在;会话/工作区列表走 session.list 的投影。 |
| 卷宗导出 | GET /api/session.export?sessionId= |
| 事件流 | GET /api/events.mux |
| 宿主原始事件流 | GET /api/events.host |
| 运行信息 | workbench.status、workbench.visits |
7. 安全
准入判定集中在 lib/security.js 的 admit(),顺序为来源 → 令牌 → Content-Type,任一失败即拒绝且不执行任何副作用(被拒绝的请求绝不会到达宿主网关)。/api/* 与 /plugin/* 都过完整准入;静态资源只做来源校验,否则首次访问会因为拿不到页面而无法种下令牌 Cookie。
| 机制 | 行为 |
|---|---|
| 来源校验 | 有 Origin 头时,new URL(origin).host 必须等于 req.headers.host,否则 403(含跨端口,如 127.0.0.1:3080)。缺 Host 头直接拒绝(HTTP/1.1 必有 Host,缺失说明请求被构造过)。 |
| 跨站拒绝 | sec-fetch-site: cross-site 一律 403,不依赖 Origin 是否存在。 |
| 无 Origin 请求 | 由 allowNoOrigin 决定(默认放行,覆盖同源导航 / curl / 本地脚本)。置 false 则拒绝。 |
| Content-Type 校验 | 写请求(非 GET/HEAD/OPTIONS)的 Content-Type 必须是 application/json(允许带 ; charset=...),否则 415。这条堵掉 text/plain 简单请求不触发预检就打到 /api/session.prompt 的路径。 |
| 配对令牌 | 默认开启。先查 x-gov-token 头,再查 Cookie dsh_gov_workbench_token,任一正确即通过;否则 401。比较用常量时间实现(timingSafeEqualString),避免逐字符试探。首次访问静态资源时由服务端种下 Cookie(Path=/; SameSite=Strict; Max-Age=1年,非 HttpOnly 以便页面 JS 读取)。可在配置里置 requireToken: false 关闭。 |
| 目录穿越防护 | resolveStaticPath() 先 decodeURIComponent,再 normalize 并 resolve 到 public/,最后校验前缀必须落在 public/ 之内;越界返回 403(/%2e%2e%2f%2e%2e%2fpackage.json 之类的编码绕过同样被拒)。 |
| 绑定回环地址 | 默认 host: '127.0.0.1',仅本机可访问。改成 0.0.0.0 会暴露到局域网,需自行评估。 |
| 令牌不回显 | /plugin/status 与 GET /plugin/config 都不返回 token 字段,只返回 tokenSet 布尔;/plugin/token 能走到就已通过令牌校验。 |
| 响应不可缓存 | 所有 JSON 响应带 cache-control: no-store。 |
| 请求体限长 | 单条 JSON 请求体上限 32 MiB(/api/respond 与 /plugin/* 更小),超限抛错而非静默截断。 |
| 无 CSRF 驱动点 | 准入层为每个请求自建 AbortController(而非依赖 req.signal),客户端断开时立即 abort,且区分「断开」与「正常结束」,正常 res.end() 不会误触发 abort。 |
req.signal 不可依赖 |
Node 22 及以下 http.IncomingMessage 没有这个属性(恒为 undefined);Node 24 上它存在,但 v24.18.0 只在响应关闭后才 abort、v24.21.0 正常结束根本不 abort。三个版本行为互不一致,都必须自建 AbortController,见 §8 第 8 条与 docs/harness-integration.md 第 7 节。 |
8. 已知限制
3091 端口已在真实 dsh 进程中挂载验证(0.2.0-rc.2 / Windows),插件随宿主进程启动后正常监听并返回页面。回归测试
node test/plugin-boot.mjs用 mock ctx 与假网关覆盖装配路径,它先让内核分配一个空闲端口再写进配置,因此不会占用 3091(port: 0会被mergeConfig当作非法值回落到默认 3091,所以测试不能用它)。dsh 0.2.0-rc.2 上
ctx.apiProxy不存在。@deepseek-ai/dsh-host-apiproxy已从发行版移除,API 网关被重构为ctx.typertGateway(@deepseek-ai/dsh-api-gateway的TypertGatewayService)+ 各域 controller(sessionController/settingsController/workspaceController/agentPresets/permissionPresets/llm/userQuestions/approval等);端点清单由@deepseek-ai/dsh-typert-registry在运行时从各包的typert.host.js注册进ctx.typert.local。因此lib/index.js的inject留空(原因见下面第 4 条),挂载由lib/host.js把两种宿主形态归一成同一内部接口(invoke/stream/hostEvents/resolveEventResult/describeEndpoint),同时兼容 0.2.0+ 的typertGateway与 0.1.x 的老形态apiProxy(后者优先级更高)。两者都不存在时插件照常开页面,并在状态接口里报告hostAvailable: false,而不是崩掉整个 dsh。配套的惰性解析设计:因为插件是「立即激活」的,网关可能在
apply()之后才 provide。所以lib/host.js不在构造时绑定一次,而是:kind是 getter,每次读取都重新探测,实时反映可用性;invoke/stream/resolveEventResult/describeEndpoint在每次调用时重新ctx.get('typertGateway', false);hostEvents(signal)是等待式生成器:网关未就绪时先等(响应式ctx.inject([...], cb)+ 轮询兜底),出现后再开始转发;流自然结束后若网关仍在且未 abort 会重新接续,因此MuxController不需要任何重启逻辑。
回归测试:
test/plugin-boot.mjs的「网关晚于插件 provide 时自动接上」与「网关缺席时 hostEvents 等待而不是立即结束」两项。会话事件不在 0.2.0-rc.2 的网关转发白名单里。
@deepseek-ai/dsh-api-remotes只把一份白名单事件转发给浏览器(approval/request、user-questions/request以及各类配置变更),session 事件不在其中;0.1.x 的apiProxy.events.mux()也已不存在。所以lib/sse.js的MuxController改为在宿主进程内直接订阅ctx.on('session/event', ..., { global: true })(并订阅session/created/session/disposed),与dsh-session-controller自己 follow 会话时的做法一致;{ global: true }拿全局可见性,老版本自动退回两参数形式。实时投影(统计 / 标题 / 待办)另经sessionProjections.onChanged获取。若宿主未挂该投影服务,插件告警但继续工作,统计与待办只随会话事件更新。cordis.patch.yml的插件行不能写inject字段,尤其不能写!!js。 写错会让插件永不激活,启动日志停在「Plugins waiting for services」,3091 根本不监听。正确做法是整行删掉inject:(不是写inject: [],空数组同样会覆盖插件导出的值),让lib/index.js的export const inject = []生效。inject: [apiProxy]这种普通数组写法在 0.2.0-rc.2 上同样会让插件永不激活,因为该版本已无apiProxyservice。两种写法都不能用,原因不同。完整的五步机制、复现输出与回归守卫(test/patch-inject.mjs8 项,含对照组)见docs/harness-integration.md第 14 节;test/syntax-check.mjs另有一条静态守卫。test/syntax-check.mjs在受限沙箱下会自动降级。该脚本用node --check逐文件校验;若沙箱拒绝子进程的管道 stdio(EPERM),脚本会自动退回stdio: 'inherit'模式仅取退出码,并在输出里注明降级(校验方式仍是同一个node --check)。本机 danger-full-access 模式下不会触发降级。端口 / host 改动不热生效。
PUT /plugin/config会落盘并返回needsRestart: true与提示文案,但当前监听保持不变,需重启 dsh。public/资源不缓存但每次读盘。好处是改完刷新即生效、无需重启;代价是每个静态请求都有一次磁盘读。适合本机单用户场景。req.signal的版本差异(三版本对照)。参考实现把req.signal传给宿主方法,这在任何 Node 上都不可靠:Node 22 及以下没有.signal,宿主帧队列的signal.addEventListener(...)没有 undefined 防御,会抛TypeError,前端重连后再次抛错,形成死循环;v24.18.0 会 abort,但总在res.close之后;v24.21.0 正常结束根本不 abort。三者都不能用于流中途取消:v22.22.2 连信号都没有,v24.18.0 太晚,v24.21.0 连结束都不反映。因此lib/transport.js的trackAbort()一律自建AbortController,在res.on('close')里 abort,并用res.writableEnded区分「客户端断开」与「正常结束」。test/server-smoke.mjs第 5 组是可复现探针,只断言三版本都成立的部分(进入处理器时尚未 abort),不对「结束后一定 abort」做断言,否则换 Node 版本 CI 会红。逐版本对照表与探针脚本见docs/harness-integration.md第 7 节。全部 5 个测试套件已在 v22.22.2 / v24.18.0 / v24.21.0 三个 Node 上跑通。关停必须用
ctx.effect,不能用ctx.on('dispose')。cordis 4.x 上ctx.on('dispose', fn)的fn永远不会执行,该事件名从不被派发。若用它做清理,插件卸载后 http 服务会继续占着 3091 端口。cordis 的插件级清理语义是ctx.effect(() => () => cleanup),effect 回调返回的函数在该 fiber 销毁时调用(@deepseek-ai/cordis-plugin-timer等官方插件都是这个写法)。lib/index.js已改用ctx.effect,并有两条回归断言守着(行为断言 + 源码模式断言,且忽略注释)。cordis 的
ctx是 Proxy:读未声明 service 的属性会抛错,不是返回undefined。ctx.apiProxy在 mock ctx 上完全正常,在真 cordis 下直接抛cannot get property "apiProxy" without inject,插件启动即崩。因此lib/host.js/lib/sse.js/lib/bridge.js的 service 探测一律走ctx.get(key, false)(false= 不要求已注入),并整段包try/catch。test/cordis-proxy.mjs用一个复刻该抛错语义的 Proxy ctx 把这条钉死,另有源码守卫禁止裸的ctx.<service>属性访问。settings写入端点是位置参数,不是单 payload。宿主签名是settings/update(ns, patch, expectedRevision)(replace/mutate同理)。wire 上的 payload 必须是与描述符 wire 名一致的对象,因此public/js/api.js在客户端就把位置参数组装成{ ns, patch, expectedRevision? }。若写成update: (p, s) => unary('settings.update', p, s),传进来的ns字符串会被当成 payload,服务端buildArgs会把它丢掉,宿主以gateway/arguments-invalid拒绝,表现为「提交配置后静默无效」。test/frontend-wiring.mjs有两条断言守着(api.js的签名形状 +app.js的调用形状)。
MIT
No comments yet. Be the first to write one.