# dsh-wolfram 设计与安全边界 本文件记录设计决策与其证据来源。实现细节以代码与 JSDoc 为准;本文只保留调用方需要依赖的契约、边界与取舍理由。 ## 0.2.1 DSH 0.1.1-rc.2 contract rc.2 保留了本 bundle 消费的两个主机接口:`@deepseek-ai/dsh-mcp-client` 仍是具名导出 `apply`、声明 `inject = ['tools']` 的 namespace plugin; `tools/pre-execute` 仍返回 `allow`、`deny` 或 `ask`。动态导入之后、交给 `ctx.plugin()` 之前会验证前一个接口,使未来不兼容的 DSH 版本在装载点给出 明确错误。`npm run test:host` 对一个已构建的真实 DSH checkout 重放该合同, 而不是靠本项目的结构类型替身推断兼容。 rc.2 在官方 mcp-client 内新增 MCP image block 的 attachment admission、当前模型 能力检查、持久化与 rich-result projection。这些行为需要 DSH 自己的 attachment、 LLM 和 tool-runtime 服务,本项目不复制。supervisor 保持字节透明,所以任何 AgentTools image block 都能原样到达官方 bridge;AgentTools 2.2.0 是否为具体工具 生成 image block 仍由上游决定。 ## 0.2.0 WSL contract WSL 是显式 interop 拓扑,不走自动发现:Linux DSH 通过配置的 `/mnt//.../wolfram.exe` 启动 Windows 内核。DSH state 与 orphan record 保持 POSIX 路径;进程创建时间来自 `/proc`;子进程进入独立 POSIX process group;仅转发文档化的非秘密 interop 环境。`MCP_SERVER_NAME/w` 是必需的 `WSLENV` 标记,否则 Windows 会启动错误的默认 AgentTools server。 supervisor 收尾会显式 unpipe/destroy 自己持有的 stdio,使 POSIX 测试自然退出, 而不是依赖外层 timeout。进程名永远不是终止授权:只有 live child handle,或 同时满足 PID、命令、可执行 basename 与 creation-window 的持久记录,才能触发 回收。 ## 1. 定位:DSH 专用薄集成层 三方已存在,本项目不重写其中任何一方: | 组件 | 归属 | 本项目的关系 | |---|---|---| | `@deepseek-ai/dsh-mcp-client` | DSH 通用 MCP 客户端插件 | 作为子插件挂载并复用,不复制 | | `Wolfram/AgentTools` Paclet | Wolfram 官方 MCP Server | 按官方启动形式拉起,不修改、不替代 | | `dsh-wolfram`(本项目) | 集成层 | 内核发现 + stdio supervisor + DSH 原生权限策略 + bundle | 本项目只补三件 DSH 通用层与 Wolfram 官方层之间缺失的东西:**在哪找内核**、**进程怎么干净地死**、**哪些工具需要人批准**。 ## 2. 组成 一个 npm 包同时承担四个角色,避免为三个角色拆三个包而使 `dsh plugin add` 变成多次安装: | 导出 | 角色 | |---|---| | `package.json` 的 `dsh.bundle.patch` | DSH profile bundle | | `.`(`src/index.js`) | 集成插件:发现内核 → 组装 supervisor 命令行 → `ctx.plugin(mcpClient, …)` | | `./policy`(`src/policy.js`) | DSH 原生权限策略插件(`tools/pre-execute`) | | `./supervisor`(`src/supervisor-main.js`)与 `bin/` | Windows 透明 stdio supervisor(被当作子进程拉起) | `cordis.patch.yml` 只插入两行(`wolfram`、`wolfram-permission`),因此用户可以用 profile 自己的 patch 层按 id 覆盖或 `disabled: true` 单独关掉其中任何一行。子路径插件说明符(`dsh-wolfram/policy`)在 DSH Loader 中可用,本机同目录的 `dsh-tui` bundle 已经这样用。 ### 为什么用 `ctx.plugin()` 挂子插件,而不是在 YAML 里直接写 mcp-client 行 `@deepseek-ai/dsh-mcp-client` 的 `command`/`args` 必须指向本包内的 supervisor 脚本,其绝对路径只有本包自己能通过 `import.meta.url` 正确解析。把路径写进 YAML 就等于把安装位置写死。改为集成插件在 `apply()` 里解析路径、解析内核、组装配置,再 `await ctx.plugin(mcpClient, config)`,可以: - 路径与内核发现失败时抛出可读错误,在插件装载点 fail loud; - 复用 mcp-client 自己的 Schemastery `Config` 校验与默认值; - 保持 HMR:本插件被换掉时子 fiber 一并释放。 ## 3. 零运行时依赖 / 无构建步骤 本包只用 Node 内置模块,源码是带 JSDoc 的 ESM JavaScript,`files` 直接发布 `src/`。 理由:out-of-tree bundle 通过 `dsh plugin --profile add ` 安装,该命令转发给 pnpm;pnpm ≥10 默认阻止依赖的构建脚本(`dsh` 自己的错误提示就在讲这件事)。没有构建步骤意味着 `add` 之后即刻可用,也意味着 peer(cordis、dsh-tools、dsh-mcp-client)全部走 DSH 维护的 `$DSH_HOME/profiles/node_modules` 扁平回退目录解析,与 in-box 插件共用同一份 cordis 实例。 类型仍然被检查:`types/harness.d.ts` 声明本插件实际消费的 DSH 接口的**结构子集**,`npm run typecheck` 以 `checkJs + strict` 校验全部源码。 ## 4. Wolfram 内核发现 官方 Paclet 的 `getWolframCommand`(`Kernel/MCPServerObject.wl`)定义了可执行文件相对安装目录的位置,本项目按同一规则解析: | 平台 | 相对路径 | |---|---| | Windows | `wolfram.exe` | | macOS | `MacOS/wolfram` | | Linux | `Executables/wolfram` | 解析顺序(先命中先用,每一步都会记录 `source` 供排障): 1. `config.command` — 显式可执行文件路径; 2. `DSH_WOLFRAM_COMMAND` 环境变量; 3. `config.installationDirectory`; 4. `WOLFRAM_INSTALLATION_DIRECTORY` 环境变量; 5. 平台自动发现: - Windows:枚举注册表 `HKLM\SOFTWARE\Wolfram Research\Installations\*` 的 `ExecutablePath`,取其目录;再按 `ProductVersion` 降序;然后是标准安装根目录(`%ProgramFiles%\Wolfram Research\{Mathematica,Wolfram,Wolfram Desktop,Wolfram Engine}\<版本>`); - macOS:`/Applications/{Mathematica,Wolfram,Wolfram Desktop,Wolfram Engine}.app/Contents`; - Linux:`/usr/local/Wolfram/{Mathematica,Wolfram,WolframEngine}/<版本>` 与 `$PATH` 上的 `wolfram`; 6. `wolframscript -code $InstallationDirectory` 探测(最后一步,因为要启动内核,有秒级开销); 7. 全部失败 → 抛出列明「试过哪些位置」和「怎么显式覆盖」的错误。 注册表优先于标准目录,是因为 Wolfram 允许装到任意盘符;本机的安装位于 `D:\mathematica\Mathematica 15.0`,只有注册表能找到它。没有任何机器特定路径被写进代码。 启动参数与环境按官方 `makeJSONConfiguration` 复制: ``` -run PacletSymbol["Wolfram/AgentTools","Wolfram`AgentTools`StartMCPServer"][] -noinit -noprompt env: MCP_SERVER_NAME=<服务器名,默认 WolframLanguage> ``` `-run` 后面那一整个表达式是**一个** argv 元素。官方文档里的单引号是 shell 语法,Node 以 `shell: false` spawn,不能把引号带进去。 ## 5. Windows 透明 stdio supervisor ### 要解决的问题 1. `@modelcontextprotocol/sdk` 的 `StdioClientTransport.close()` 先 `stdin.end()`,2 秒后 `kill('SIGTERM')`,再 2 秒后 `kill('SIGKILL')`。Windows 上 `child.kill()` 是对**直接子进程**的 `TerminateProcess`,孙进程不受影响。 2. 实测 `wolfram.exe -run …StartMCPServer[]` 会派生孙进程(`conhost.exe`,以及求值时的 WSTP 转换子进程 `XML.exe`;若用户把内核指向 `wolframscript`,还会有 `WolframKernel.exe`)。只杀直接子进程会留下孤儿。 3. 本机同时存在用户自己的 Mathematica 前端与内核,以及别的 MCP 客户端拉起的 AgentTools server。**任何按进程名/命令行模糊匹配的清理都会误杀它们。** ### 设计 supervisor 是被 mcp-client 当作 `command` 拉起的 Node 进程,夹在 DSH 与 `wolfram.exe` 之间: - **协议透明**:`stdin → child.stdin`、`child.stdout → stdout` 是纯字节转发,不解析、不缓冲成行、不改写。supervisor 自己的诊断只写 stderr 且带 `[dsh-wolfram-supervisor]` 前缀。child 的 stderr 也原样转发到 stderr。 - **只认自己的 PID**:唯一被终止的是 `spawn()` 返回的那个 `child.pid`,以及以它为根的树。终止在 child 句柄仍被持有(尚未收到 `exit`)时进行——此时该 PID 不可能被系统复用,因此不存在误伤。代码里没有任何按进程名匹配的路径。 - **平台终止方式**:Windows 用 `taskkill /PID /T /F`(与 DSH `subprocess-local` provider 相同的做法);POSIX 用 `detached: true` 建立自己的进程组并对 `-pgid` 发信号,失败回退到直接子进程。 - **优雅优先**:AgentTools 2.2.0 的 stdio 读循环把 stdin EOF 当作关机信号并 `Exit[0]`(`Kernel/Server/Local.wl` 的 `stdinShutdownQ`)。所以关机第一步永远是关闭 child 的 stdin,等 `graceMs`(默认 1000ms)再进程树强杀。 - **必须跑赢 SDK 的 SIGTERM**:默认 `graceMs` 取 1000ms,使「关 stdin → 等 1 秒 → taskkill」在 SDK 的 2 秒 SIGTERM 之前完成,从而保证进程树清理逻辑真的会执行,而不是被一发 `TerminateProcess` 打断。该值可配置,但配置校验会拒绝 ≥ 2000ms 的值并说明原因。 - **兜底**:`process.on('exit')` 上挂一个同步收尾,若 child 仍在则同步 `spawnSync taskkill /T /F`。这与 DSH `subprocess-local` 的 "Synchronous host-exit finalization" 是同一模式。 - **触发关机的全部来源**:stdin EOF、stdin error、`SIGINT`/`SIGTERM`/`SIGHUP`/`SIGBREAK`、child spawn 失败、child 提前退出、supervisor 自身未捕获异常。 ### 孤儿回收(orphan reclaim) supervisor 被 `SIGKILL`(或 Windows 上被 `TerminateProcess`)时无法运行 JavaScript,这是 Node 层面无法消除的窗口。为此 supervisor 在 spawn 后写一条记录(`$DSH_HOME/.dsh-wolfram/.json`,含 supervisor pid、child pid、命令、参数、spawn 时刻),child 确认退出后删除。 本机实测发现这个窗口比预期窄:只强杀 supervisor(`taskkill /PID /F`,不带 `/T`)会关掉内核 stdin 的写端,AgentTools 的读循环把 EOF 当关机信号并 `Exit[0]`,内核在约 1 秒内自行退出——**即使当时正在求值中**。所以真正的孤儿只会出现在内核不响应 EOF 的情形(更老的 Paclet、包了一层 `wolframscript` 的内核、自行 reparent 的子进程)。回收因此是安全网,不是主路径;`live/orphan-reclaim.test.js` 同时钉住了这条自愈行为和回收本身。 集成插件在**装载时**与**释放时**各扫一次记录,回收条件是全部满足: 1. 记录里的 supervisor 进程已经不在了(还活着就完全不碰——那是别人正在用的连接); 2. child pid 仍然存活; 3. 身份三重校验通过:映像名等于我们启动的可执行文件名、命令行同时包含该可执行文件路径与 `Wolfram\`AgentTools\`StartMCPServer`、创建时间落在记录的 spawn 时刻附近的窗口内。 任一条不满足就只删记录、不动进程。校验数据来自 `Get-CimInstance Win32_Process -Filter 'ProcessId='`,只在真的出现候选孤儿时才查询(正常路径下不会有额外开销)。创建时间校验是防 PID 复用的关键:光靠命令行匹配会命中别的客户端拉起的 AgentTools server。 POSIX 上身份校验退化为 `ps -o command= -p ` 的命令行比对;该路径未在本机验证,文档中如实标注。 ## 6. 权限策略 Wolfram 官方 MCP server 的 `tools/list` 只在 `annotations` 里给 `title`(见 `Kernel/Server/Shared.wl`),**没有** `readOnlyHint`/`destructiveHint`。因此策略不能依赖协议注解,只能按工具名分级,并且对未知名字 fail-safe。 分级(默认值,可配置): | 决策 | 工具 | 理由 | |---|---|---| | `ask` | `WolframLanguageEvaluator` | 无沙箱的任意 Wolfram 代码求值,可写文件、起进程、访问网络 | | `ask` | `TestReport` | 在新内核里执行 `.wlt` 文件,等价于任意代码执行 | | `ask` | `WriteNotebook` | 写 `.nb` 文件到任意路径 | | `ask` | `ReadNotebook` | 读任意路径的文件 | | `ask` | `CodeInspector` | 可对整个目录递归读取源码 | | `ask` | `WolframAlpha`、Paclet 开发类工具 | 网络外发 / 写 Paclet 与文档 | | 交给后续策略 | `WolframLanguageContext`、`SymbolDefinition`、`WolframAlphaContext` | 只读元数据:文档语义检索与内核内符号定义 | | `ask`(fail-safe) | 任何未列出的 Wolfram 工具 | 新版本 Paclet 可能加入新工具;默认必须先问人 | 工具识别按公开名前缀 `mcp____` 做后缀切分。mcp-client 在名字超长或含非法字符时会做归一化并追加哈希;那种情况下切出来的尾巴匹配不到任何已知工具,于是落到 fail-safe 的 `ask` —— 归一化不会把一个高风险工具悄悄降级。 **升级而非短路**:`ask` 的判定不是直接短路返回,而是先 `await next()` 让后续 `tools/pre-execute` 监听器(hooks、其他策略)跑完;只有当下游返回 `allow` 时才提升为 `ask`,下游的 `deny`/`ask` 原样保留。这保证本策略只会让权限更严,不会绕过任何其它策略。`deny` 是终态,直接返回。 对不属于本服务器命名空间的工具,策略无条件 `next()`。 ## 7. 明确不做的事 - 不注入 system prompt 段落。模型可见文本会进入每一次请求的前缀,属于产品决策,交给使用者用自己的 patch 层加。 - 不桥接 MCP Resources / Prompts。DSH 通用 mcp-client 目前只桥接 Tools,本项目不在其上另开旁路。 - 不实现 `allow-always` / 规则记忆。DSH `ctx.approval` 的结果词汇只有一次性授权,会话级策略只有 `ask` / `never`;本插件不自建第二套授权存储。 - 不做求值沙箱。`WolframLanguageEvaluator` 的隔离由 Wolfram 侧决定,本插件的边界是「执行前必须有人点头」。