第三部分 · 能力接缝
第 7 章:能力接缝——三件套与可换后端
「接口 + 实现」升级为「定义 + 提供 + 消费」三角,换一个 provider 就换掉整个产品的一个横切面
为什么「接口 + 实现」不够
大多数系统把「可替换能力」实现为「接口 + 实现」:核心定义接口,实现类注册进去。dsh 认为两个角色不够——它把每个可替换能力做成三个角色的完整接缝:
| 角色 | 是什么 | 约定 |
|---|---|---|
| Service Definition | 声明接口的extends Service 抽象类(不是 TS interface!),定义词汇与错误码 | 通过 declare module 声明 ctx.<key>;构造器 super(ctx, 'key') 注册 |
| Service Provider | 实现接口的插件,子类化 Definition 并作为 ctx.<key> 装载 | 同一 context 只允许一个实现,重复装载 fail loud |
| Consumer | 消费服务的插件,通常是模型工具 | inject: ['tools', 'shell', ...] 注入服务,在 execute 里调用 |
为什么 Service Definition 必须是 Service 子类而不是 interface?docs/glossary.md 给了答案:Cordis 的依赖注入、生命周期(dispose)、重复注册 fail-loud 都挂在 Service 上,纯 interface 拿不到这些语义。packages/AGENTS.md 还有一条反向约束:「Design Service Definitions for all current Consumers」——工具 schema、Loader、UI、传输、provider 专属行为放在 Consumer 或 provider 里,绝不让一个 Consumer 绑架服务契约。
The seam is the complete capability, never one role.(
docs/glossary.md)
fs 接缝:请求模型 + 策略事件门
文件系统接缝是理解「策略与实现正交」的最佳样本。packages/fs/fs 定义 FileSystem 抽象类(ctx.fs),词汇包括 FsTargetKey/FsVersion(branded 不透明 id)、FsObservation(present/absent)、FsWriteIntent(createIfAbsent / replaceIfVersion 两种 CAS 意图)、FsError 的 13 个稳定码(FS_STALE_VERSION、FS_NOT_OBSERVED、FS_SANDBOX_DENIED…)。方法集是 resolve/readText/writeText/editText/listDir/... 一整套。
关键设计是策略通过事件附加,而不是写死在 provider 里:
// 本插件不注册任何服务;只挂三个 fs/* 事件
export function apply(ctx: Context): void {
const gate = new ObservedStateGate()
ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor)))
ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))
ctx.on('fs/observed', (target, observation, actor) => {
gate.observe(target, observation, actor)
})
}
fs/write-intent 与 fs/edit-intent 是 waterfall 单槽决策(第一个返回的监听者拥有决策);fs/observed 是同步记录器。装上 dsh-fs-observation-policy,「写前必读、版本 CAS」就生效;拆掉它,工具就是裸 provider 的无条件写——安全策略是可拆卸的事件监听器,默认最宽松,按部署叠加。local provider(fs-local)的实现要点:resolve 走 realpath 得 targetKey(别名共享同一身份)、每 key FIFO 尾链锁把「读→守卫→写」窗口串行化(并发写一个赢、其余以 FS_STALE_VERSION 拒绝)、writeText 校验 intent 后原子写并返回 before/after diff 基。
subprocess 接缝:显式 spec 与进程树
子进程接缝(packages/subprocess/subprocess)是 dsh「显式优于隐式」原则的样板——SubprocessSpawnSpec 的 JSDoc 自称是仓库模板:
export interface SubprocessSpawnSpec {
argv: readonly string[]
cwd: string
stdio: SubprocessStdio
graceMs: number
signal?: AbortSignal | undefined
env?: NodeJS.ProcessEnv | undefined
}
// JSDoc: "This seam applies no defaults: every disposition, limit, and directory
// is explicit, so the caller's own config — not a hidden subprocess-service
// default — decides them (the `dsh-shell` request/spec split is the owning template)."
服务只有三个方法:resolveExecutable、spawn、spawnTerminal。local provider(subprocess-local)的进程管理是 agent 安全性的关键:
- kill 永远打整棵树:POSIX 用
process.kill(-pid, sig)打进程组(detached: true自成组);Windows 用taskkill /PID <pid> /T /F。 - SIGTERM → graceMs → SIGKILL 升级:
terminate()是唯一终止动词,先温和后强杀;守卫的是树存活而非直接子进程结算(treeAlive()用process.kill(-pid, 0)+ /proc 探测排除只剩僵尸的组)。 - PID 复用防护:
ProcessIdentity{pid, started}记录启动时间,process-inspector.ts读 /proc 或ps建父子表——「助手进程不能比 handle 活得久」是硬约束。 - 输出保尾 + spill:
OutputCollector保留尾部、溢出写 spill 文件(0700 私有目录、随机名防 symlink 种植),readFrom(byteOffset)提供不消费彼此的光标式增量读。
shell 接缝:request/spec 分离的出处
shell 接缝(packages/shell/shell)是 request/spec 分离的「拥有模板」:
export abstract class ShellExecutor extends Service {
constructor(ctx: Context) {
super(ctx, 'shell')
}
get sandboxMode(): SandboxMode | undefined { return undefined }
/** Apply implementation-owned defaults and caps to a request before execution. */
abstract resolve(request: ShellExecRequest): ShellExecSpec
/** Run a command in the foreground; resolves when it finishes. */
abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
/** Start a background process and return its handle immediately. */
abstract start(spec: ShellExecSpec): ShellProcess
}
Consumer 给「愿望」(ShellExecRequest:command + 可选 workdir/timeoutMs/stdin/env/sandboxPolicy),Provider 的 resolve() 显式填默认与封顶(clampTimeout),run/start 只收全量 spec——「默认值从不在 run() 里悄悄 ?? default」。这让 spec 可序列化、可审计,也消灭了「调用的实际参数由实现暗中决定」这类 bug。
local provider(bash-local)不自己 spawn——它 inject: ['subprocess'],把 shell 的预算翻译成 SubprocessCollect,经 ctx.subprocess.spawn(['bash', '-c', command]) 派生:
async run(spec: ShellExecSpec): Promise<ShellRunResult> {
return this.runArgv(spec, ['bash', '-c', spec.command])
}
protected async runArgv(spec: ShellExecSpec, argv: readonly string[]): Promise<ShellRunResult> {
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, spec.stdoutMaxBytes, d.signal))
const outcome = await handle.done
const collected = LocalBashExecutor.collected(handle)
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
const aborted = d.signal.aborted && !timedOut
return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs,
stdout: finalOutput(collected.stdout), stderr: finalOutput(collected.stderr) }
}
注意 timedOut 与 aborted 的互斥首因分类——超时与取消被熔进同一个 deadline(deadline(signal, timeoutMs, 'BASH_TIMEOUT')),但结果能区分「谁先到」。还有 pwsh-local:逐调用镜像 bash-local(源码里 jscpd:ignore 注释明说这是刻意对称),argv 换成 pwsh -NoLogo -NoProfile -NonInteractive -Command 并钉住 UTF-8——Windows 组合层把 POSIX 行换成 pwsh 行,Consumer 一行不改。
bash 工具(tool-bash,Consumer)的执行顺序展示了接缝消费端的长链条:校验参数 → 解析沙箱策略(ctx.sandboxPolicy.resolve(session))→ 沙箱升级审批(ctx.approval waterfall)→ 解析工作目录 → 收集 ctx.shellEnv 快照 → 组装 request;后台执行走 ctx.jobs.start 返回 jobId,前台走 ctx.shell.run。
terminal 接缝:有状态的另一端
与 bash「每次调用全新、无状态」相对,terminal 接缝提供跨工具调用存活的有状态 PTY。TerminalSessionService(packages/terminal/terminal)是一个具体注册表而非抽象类:registerBackend(backend)(后端声明唯一 type)、spawn(owner, request)——owner 是精确的 Agent 对象,expectOwned 抛 FOREIGN_SESSION;agent 析构时 owner.ctx.effect 自动 disposeOwned。系统提示词对两者的分工写得很直白:「Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer shell/read/write/edit for bounded one-shot operations」——过程生命周期与会话生命周期是两条正交的 seam。
lsp 接缝一句话带过:ctx.lsp 是 provider 注册表 + 按扩展名路由,标准化恰好四种查询(goToDefinition/findReferences/goToImplementation/hover),没有协议逃生口——后端必须把 JSON-RPC 翻译成规范化请求,模型侧永远只见四种操作。
换 provider 就是换产品
三件套的直接回报:一个 provider 的替换能迁移整个横切面。看 fs 的三个实现并排:
dsh-fs-local:本机文件系统;dsh-fs-sandbox:继承 local,只给 write/edit 加每调用 policy 围栏(checkedTarget按SandboxExecutionPolicy拒绝或包含性检查后返回 fresh target 防 TOCTOU);dsh-fs-e2b:E2BFileSystem extends FileSystem,targetKey 是远程 id,所有操作翻译成sandbox.files.*。
而工具层只读 ctx.fs.sandboxMode 能力事实来决定是否在 schema 里宣传 sandbox_permissions 字段——schema 随 provider 自动变。subprocess 同样:E2BSubprocessRuntime extends SubprocessRuntime,spawn 落到共享的 E2B SDK。两兄弟共享 ctx.e2b 句柄,住在同一个远程 Linux 世界——于是「把 Bash、PTY、LSP 整体搬进远端沙箱」只换了两个 provider 实现(第 8 章展开 e2b)。
ctx.shell 调用,Provider(bash-local / bash-sandbox / pwsh-local / e2b 系)是同一接缝的可换实现;resolve(request) → Spec 的显式默认化是接缝模板。把 seam 想成插座标准:Service Definition 是插孔标准(连「插孔必须是 Service 子类」这种反直觉条款都规定好了),Provider 是插头,Consumer 是电器。标准的存在让「电器不知道插头是谁」成为可能——bash 工具不 import 任何 provider,它只认识 ShellExecutor。所以换插头(bash-local → pwsh-local,win32 换壳;→ bash-sandbox,加限制)时,电器(工具)与房间布线(prompt、审批)全部无感。
实践应用
- 三件套比「接口+实现」多一个自由度的原因:定义/提供/消费各自独立成包、独立演化——Consumer 换需求不需要动 Provider,Provider 换实现不需要动 Consumer,定义者可以按消费需求演进契约。
- request/spec 显式解析:Consumer 给愿望、Provider 的
resolve()显式填默认与封顶——「默认值从不在 run() 里悄悄 ?? default」是可审计执行参数的可迁移模式。 - 进程树是资产:kill 打整棵树、SIGTERM→SIGKILL 升级、PID 复用防护、等树退出——「助手进程不能比 handle 活得久」对任何 agent 系统都是硬约束(孤儿进程 = 泄漏的算力与凭据)。
- 安全策略做成可拆卸事件监听器:fs 的「写前必读/版本 CAS」是事件插件叠出来的而非 provider 内建——默认最宽松、策略按部署叠加,同一 provider 可被不同策略装饰。
总结
这一章定义了 capability seam 三件套,并用 fs、subprocess、shell、terminal、lsp 五个实例展示了「换 provider 就是换产品」:显式 spec、进程树管理、策略事件门、能力事实驱动 schema——每一件都是为了让「可替换」从口号变成配置。但还有一个接缝没讲完:沙箱。它把「进程跑在哪、能碰什么」做成了一等接缝,甚至动用了一个 native addon。下一章进入沙箱与代码执行:Landlock、平台链与 e2b。