摘要:替换两个底层 Provider,文件、Bash、PTY 和 LSP 一起迁到远端。
本文更换了版本基线。前几篇基于
0.1.1-rc.2,当前npx @deepseek-ai/dsh获取的是0.1.2-rc.1(2026-09-03 发布),对应仓库标签dsh-v0.1.2-rc.1,commita66e470204。文中的能力关系来自仓库生成的docs/capability-seams.md与docs/event-producer-consumer.md,可以通过pnpm run gen-doc-graphs重新生成核对。dsh 仍处于 developer preview。
前面几篇的 dsh 拆解内容是从纵向结构拆解:怎么装配插件树、主循环怎么推进、如何记录状态。这一篇换个方向,看一个横向的问题,如果把一个 Provider 换成新的实现,会影响哪些上层能力。
假设一个 Agent 原本运行在本机,现在要把它的执行环境搬到远程沙箱。如果 Bash、文件系统、终端和 LSP 各自持有一套本地的执行逻辑,这次迁移就需要在多个模块里分别修改。Bash 要换进程启动方式,文件工具要换读写后端,终端要换 PTY 的创建方式,语言服务器也要重新处理进程启动问题。
这种结构的问题不只在于修改点多。同一类执行能力的实现细节分布在多个上层模块里,每增加一种执行环境,都需要重新检查相关的调用链。
dsh 会先为一项可替换的能力定义统一契约,再让不同 Provider 按这份契约提供实现,上层 Consumer 也只依赖这份契约。因此,判断一次替换的影响,需要同时看两个范围:
依赖关系
→ 变化会传到哪些模块
契约边界
→ 这些模块能否继续使用新的实现
seam 的三个角色
dsh 把这种可替换能力称为 seam。

一个完整的 seam 包含三个角色:
Service Definition:声明这项能力的契约
Service Provider:提供具体实现
Consumer:使用这项能力
以 shell 为例,可以简化成:
Service Definition
ctx.shell
/ \
/ \
Provider Consumer
bash-local tool-bash
bash-sandbox hooks
pwsh-local
这组关系中,关键点是三者之间的依赖方向。
Consumer 依赖的是 Definition 声明的能力契约,Provider 则负责实现这份契约。只要新的 Provider 继续满足相同的契约,Consumer 就不用感知具体实现发生了什么变化。
这也是 Service Definition 在 dsh 中以运行期 Service 形式存在的原因。稳定的服务入口让 Consumer 可以声明「我需要 shell 能力」,而无需绑定到 bash-local 这样的具体实现。
同模块的两条 seam
seam 之间并非完全彼此独立。比如本地 Bash(bash-local)执行器向上提供 ctx.shell,同时向下消费 ctx.subprocess:
ctx.subprocess
↓
bash-local
↓
ctx.shell
↓
tool-bash
当 ctx.subprocess 的实现发生变化时,bash-local 仍然能提供相同的 shell 能力,只是启动进程的位置从本地换到了新的执行环境。上层的 tool-bash 可以继续使用 ctx.shell,不需要知道更底层的进程实现发生了变化。这样一来,执行环境的变化可以沿着 Service 逐层传递,同时把修改范围控制在更底层的实现层。
相比让每个工具分别处理本地和远程执行,这种结构更容易控制一次环境切换涉及到的修改点。
沿着能力链传导的变化
dsh 生成的能力图里一共记录了 29 个 seam。以 ctx.subprocess 为例,它位于多项执行能力的下层,适合用来观察 Provider 替换后的传导路径。
Bash tools Terminal tool LSP tool
↑ ↑ ↑
ctx.shell ctx.terminals ctx.lsp
↑ ↑ ↑
Bash executor Terminal backend LSP host
└────────────────┼────────────────┘
↑
ctx.subprocess
除了 Bash、Terminal 和 LSP,一些进程外的子 Agent 后端也依赖 ctx.subprocess。上图直观地体现了一次 Provider 替换,会沿着哪些依赖关系继续向上传递。
如果只替换 ctx.subprocess 的 Provider,上图中的变化会先到达 Bash executor、Terminal backend 和 LSP host;这些模块如果同时还向上提供其他 Service,执行行为的变化还会继续传到更上层。
例如:
subprocess Provider 被替换
↓
bash-local 的进程运行位置改变
↓
ctx.shell 的外部行为随之改变
↓
tool-bash 执行的命令进入新的环境
tool-bash 全程只依赖 ctx.shell,不需要知道更底层的 subprocess 运行在本机还是远程环境。这也是 seam 带来的一个核心工程收益:底层实现可以替换,原有依赖关系和上层调用方式仍然保持不变。
共享执行世界
只替换 subprocess 还不够。如果命令已经迁到远端,而文件工具仍然在读写本机目录,两边看到的就会是不同的执行环境:
Bash → 远端 /workspace
FS → 本机 /workspace
所以,dsh 会让文件系统和子进程落在同一个 execution world 里,保证命令执行和文件读写面对的是同一套环境。
dsh 这里以 E2B 作为远程沙箱实现。E2B 提供隔离的 Linux 执行环境,文件系统和子进程分别通过 ctx.fs 和 ctx.subprocess 两个 seam 接入:
ctx.fs → fs-e2b ─────────┐
├→ 共享一个 E2B 沙箱
ctx.subprocess → subprocess-e2b ─┘
两边共用同一个远程工作目录和沙箱生命周期。于是上层看到的是:
文件读写 ───────┐
Bash ──────────┤
Terminal ──────┼→ 同一个远程 Linux 环境
LSP ───────────┘
文件工具通过 ctx.fs 切到远端环境。Bash、Terminal 和 LSP 则沿各自的 Service 间接使用 ctx.subprocess,因此不需要分别增加一套 E2B 专用实现,上层也不用额外维护「本地 / 远程」两套分支。
这样一来,执行环境的差异主要收敛在底层 Provider。切到 E2B 后,进程启动需要经过远程环境的准备过程,延迟会高于本机执行;环境变量也需要显式传入,宿主环境不会自动带过去。这些运行特性由 Provider 这一层处理,上层工具仍然沿用原来的 Service 接口。
契约边界
到这里很容易得出一个过于简单的结论:
接口一样
→ Provider 就可以随便换
dsh 自己恰好提供了一个反例。
运行语义
ctx.subprocess 的能力图里包含多个 Consumer,但这并不代表它们都能无条件使用任意 subprocess Provider。
subprocess-e2b 创建远程进程时存在异步过程,进程 handle 会先返回,真实 PID 稍后才能得到。
Bash、Terminal 和 LSP 不依赖启动后立即获得有效 PID,因此可以沿用原来的调用方式;ACP 子进程后端则需要在启动后立刻取得有效 PID,所以无法原样切到这个 Provider。
于是这里出现了两种范围:
能力图
→ ACP 依赖 ctx.subprocess
运行契约
→ ACP 依赖的即时 PID 语义
subprocess-e2b 当前无法满足
这个例子说明,Provider 的可替换性不只取决于接口形式,还取决于契约里约定的运行语义。除了函数名、参数和返回值,调用时序、生命周期、进程身份等行为也属于契约的一部分。
如果 Consumer 依赖了某个 Provider 特有、但没有写进契约的行为,这些隐含依赖同样会增加后续替换的成本。
错误语义
本地沙箱里也出现过类似问题。不同平台的沙箱后端,需要让上层区分几类结果:
命令自身失败
策略拒绝了操作
沙箱执行器自身异常
这三类情况表面上都可能表现为「命令没有成功」,但对应的原因和处理方式完全不同。
dsh 的一次 Landlock 故障就出在错误归因上:ripgrep 返回「没有匹配结果」时,本来只是一次正常的命令退出,却因为沙箱后端输出的信息,被误判成了沙箱不可用。
修复之后,错误判断从简单的字符串匹配改成了更明确的失败规则,让 Consumer 能进一步区分:
runner failure
→ 沙箱执行器自身出错
denial
→ 沙箱工作正常,但策略阻止了操作
这个案例也说明,Provider 的可替换性还取决于错误语义是否被纳入契约。
如果对同一种失败场景,不同的实现给出不同的错误含义,上层就得识别具体 Provider 并分别处理,替换成本也会随之增加。
策略与执行约束分层
权限和沙箱这部分也采用了类似的分层方式。
dsh 把「策略怎么定义」和「约束怎么执行」拆成两层处理。工作区范围、沙箱模式等策略由统一入口维护,Bash、文件系统等执行能力读取同一套约束,再交给具体的沙箱 Provider 落地。
可以简化成:
权限 / 沙箱策略
↓
统一的执行边界
↓
Bash / FS / Terminal
↓
具体沙箱实现
这样一来,工具只用消费权限结果和执行能力,无需自己维护一套沙箱规则。更换底层沙箱实现时,权限策略也不用随着每个工具重新设计。
seam 之外的扩展点
还有一类需求,不适合通过替换 Provider 来实现。
比如文件修改里的「先读后改」。文件系统 Provider 负责的核心问题是:
read
write
edit
至于「写入前是否读取过文件」「当前文件版本是否仍然匹配」这类要求,更接近文件操作的策略约束。
dsh 没有把这些规则放进文件系统 Provider,而是通过独立的 policy 插件接入文件操作流程。整体关系可以简化成:
文件工具
↓
策略检查
↓
文件系统 Provider
这样设计有两个好处。
第一,文件系统仍然保持完整的基础能力,不安装这层 policy 也可以工作。
第二,策略可以独立增加和调整,不需要为了增加「先读后改」规则再造一套文件系统 Provider。
这也给 seam 的边界提供了一种比较清晰的判断方式:如果变化的是能力本身的实现,适合放在 Provider;如果变化的是这项能力在什么条件下可以使用,更适合交给独立的策略层。
窄接口与替换成本
seam 的接口应该收多窄,是另一个需要考虑的问题。
ctx.lsp 是一个比较典型的例子。它没有把完整的 LSP 协议暴露给 Consumer,只保留四类只读导航能力:
跳转到定义
查找引用
跳到实现
读取 hover 文档
同时,它也没有提供通用的 JSON-RPC 通道,让上层绕过这层抽象。因此,不同的语言服务器都要把自身的能力适配到这几类标准操作里。某个语言服务器提供的额外功能,也不会自动通过这层 seam 暴露给上层。
相应地,这层接口的替换范围也更容易控制。对于 Consumer 来说:
Go LSP
Python LSP
TypeScript LSP
↓
统一的导航能力
↓
模型工具
Provider 发生变化时,上层工具仍然可以沿用相同的请求方式和结果结构。
如果以后要增加新的导航能力,就需要扩展 Definition,并重新检查现有 Provider 和 Consumer 是否都能适配。这也体现了 seam 设计中的一个基本取舍:
接口更宽
→ Provider 可以表达更多差异
→ 共同维护的契约面更大
接口更窄
→ 替换成本更容易控制
→ 一部分实现特有能力无法暴露
所以 seam 应该收多窄,最终取决于这项能力准备容纳多大的实现差异。
替换范围的判断方法
回到开头的问题:换掉一个 Provider,影响范围有多大?
沿着 dsh 的能力关系,可以从三个层面判断。第一步,看依赖图:
Provider
↓
哪些 Consumer 依赖这项能力
这决定变化可能传到哪里。第二步,看 Consumer 同时提供了什么能力:
底层 Provider
↓
Consumer
↓
上层 Service
↓
更多 Consumer
这决定行为还会不会继续向上传导。第三步,看契约里的运行语义:
方法签名
调用时序
生命周期
错误语义
进程身份
……
这决定了新的 Provider 能否继续适配这些 Consumer。
因此,想降低 Provider 的替换成本,需要满足两个条件:Consumer 依赖稳定的 Definition,同时不依赖契约之外、某个实现特有的行为。
E2B 的例子正好把这两层关系串了起来。替换文件系统和 subprocess 两个底层 Provider 后,文件、Bash、Terminal 和 LSP 可以沿原有能力链迁到同一个远程环境;ACP 由于依赖即时 PID,则停在了当前 Provider 的兼容边界之外。
能力接缝的作用也体现在这里:一次底层实现发生变化后,可以沿着明确的依赖关系判断变化会传到哪里,再结合契约判断哪些 Consumer 能继续工作,从而把替换范围控制在可预期的边界内。
下一篇是这个系列的最后一篇,我们会继续看这套设计带来了哪些额外复杂度、适合哪些场景,以及其中哪些设计方法可以脱离 Cordis 单独使用。
参考源码
以下路径以 dsh-v0.1.2-rc.1 为基线:
docs/capability-seams.md
docs/event-producer-consumer.md
docs/architecture.md
docs/glossary.md
docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md
packages/shell/
packages/subprocess/
packages/fs/
packages/e2b/
packages/sandbox/
packages/interaction/
packages/lsp/
packages/code-runtime/