第 7 章
capability seam 解剖:shell 三件套
场景还原
「把 Bash 换掉」,听起来像一次查找替换。但如果你的代码库里「bash 工具」是一个八百行的文件,工具描述、权限检查、进程拉起、结果渲染全在里面,这一次替换要回答一串问题:工具描述要改吗?提示词里「命令运行在沙箱里」要改吗?超时策略升级,是改一处还是改三处?
这些问题的共同根源:执行引擎的演化速度,和模型可见契约的演化速度,被绑在同一个文件里。两周前你们把默认超时从 30 秒改成 120 秒,三个调用方各改各的,因为没人知道「实际生效的超时」由谁决定。现在要换引擎,你希望改动面有多小?
dsh 的答案是:先别急着换,先把「bash 能力」解剖成三个角色。Service Definition 定义契约,Service Provider 提供实现,Consumer 面向模型。换实现只换一个插件,模型侧的工具 schema、审批、结果渲染都不动。这一章拆开 packages/shell 这个家族,看三个角色各司何职,以及「把 Bash 换掉」动到的到底是什么。
逐行精读
先认家族成员
packages/shell 下的包目录有 10 个:bash-local、bash-sandbox、pwsh-local、pwsh-sandbox、shell、shell-env、tool-bash、tool-bash-persistent、tool-pwsh、tool-pwsh-persistent。组 README 用一张表说明角色分工:
7| Package | Role | ctx key |8|---|---|---|9| [`shell/`](shell/README.md) | Defines the executor contract shared by Service Providers and Consumers. | `ctx.shell` |10| [`bash-local/`](bash-local/README.md) | Executes commands through the local [`subprocess`](../subprocess/README.md) service. | (registers `ctx.shell`) |11| [`bash-sandbox/`](bash-sandbox/README.md) | Applies the configured [`sandbox`](../sandbox/README.md) backend before local execution. | (registers `ctx.shell`) |12| [`pwsh-local/`](pwsh-local/README.md) | Executes PowerShell commands with Windows-specific process behavior. | (registers `ctx.shell`) |13| [`shell-env/`](shell-env/README.md) | Provides the managed `DSH_*` environment shared by shell tools. | `ctx.shellEnv` |14| [`tool-bash/`](tool-bash/README.md) | Exposes Bash execution and background-job integration to the model. | (registers on `ctx.tools`) |15| [`tool-pwsh/`](tool-pwsh/README.md) | Exposes PowerShell execution to the model. | (registers on `ctx.tools`) |表格列了主要 7 个包。ctx key 一列泄露了秘密:shell、bash-local、bash-sandbox 三行都写 ctx.shell。契约包定义 key,两个 provider 注册同一个 key,只能二选一挂载,这是「一实现 per context」的布局级预告。四个工具共享同样的 provider。角色和包的多对多关系,就是 seam 要管理的结构。
Service Definition:契约包 dsh-shell
先看模块头注释,它自称什么、拒绝什么:
1/**2 * Service Definition for the `ctx.shell` capability seam, covering foreground commands and background process3 * handles. Job ids, ownership, polling, and notices belong to4 * `@deepseek-ai/dsh-jobs`, keeping executors independent of sessions.5 * @module @deepseek-ai/dsh-shell6 */注释第一句自报家门:本包是 ctx.shell 这个 seam 的 Service Definition。第二句是边界声明:job 的 id、所有权、轮询、通知都属于 dsh-jobs,本 seam 只出进程句柄。契约在包一级就裁剪好了,不混入别的 seam 的职责。
ctx.shell 这个 key 怎么进 cordis 的 Context?靠声明合并:
40declare module '@deepseek-ai/cordis' {41 interface Context {42 shell: ShellExecutor43 }44}declare module 把 shell 字段合并进 cordis 的 Context 接口。之后任何插件都能写 ctx.shell,编译器知道类型是 ShellExecutor。Consumer 不用 import provider 的实现类,只用契约包里的类型,靠的就是这个机制。
然后是核心:这个类长什么样:
46/**47 * Abstract bash execution service. Subclass, implement the abstract methods,48 * and load the subclass as a plugin — it registers as `ctx.shell` (one49 * implementation per context; loading a second throws, which is cordis'50 * standard duplicate-service behavior).51 *52 * Implementations must honor these semantics:53 * - {@link run} rejects only for infrastructure failures. Nonzero exits,54 * timeout kills, and abort kills resolve with a {@link ShellRunResult}.55 * - {@link start} returns immediately; no timeout applies to background56 * processes. `done` settles at process close and never rejects; spawn57 * failures settle as `killed` with the error on stderr.58 * - {@link ShellProcess.readOutput} is incremental: consecutive reads never59 * repeat output. Lossy reads report truncation and available spill files.60 * - A still-running background process is stopped and awaited when its61 * owning composition tears down. With the subprocess seam that62 * boundary is `ctx.subprocess` disposal, so a background process survives63 * an executor-only reload.64 */65export abstract class ShellExecutor extends Service {66 constructor(ctx: Context) {67 super(ctx, 'shell')68 }extends Service 加 super(ctx, 'shell'),构造器本身完成注册动作。抽象方法留给 provider 填,注册和生命周期由基类承担。注释里四条语义(run 只因基础设施失败而 reject;start 立即返回、done 永不 reject;readOutput 增量;背景进程跨 reload 存活)是消费者可以依赖的承诺,「一实现 per context,加载第二个会抛错」是热切换的排他前提:槽位只有一个,换 provider 必须先卸再装。
契约的核心是三个抽象方法:
79 /**80 * Apply implementation-owned defaults and caps to a request before execution.81 * @param request - the caller's request; omitted fields get this82 * implementation's defaults, capped fields are clamped.83 * @returns the fully-specified spec to hand to {@link run}/{@link start}.84 */85 abstract resolve(request: ShellExecRequest): ShellExecSpec8687 /**88 * Run a command in the foreground; resolves when it finishes.89 * @param spec - a resolved spec from {@link resolve}, never a raw request.90 * @returns the outcome; nonzero exits, timeout kills, and abort kills91 * resolve with a descriptive result rather than reject.92 */93 abstract run(spec: ShellExecSpec): Promise<ShellRunResult>9495 /**96 * Start a background process and return its handle immediately.97 * @param spec - a resolved spec from {@link resolve}, never a raw request.98 * @returns the live process handle (reads, kill, quiescence promise).99 */100 abstract start(spec: ShellExecSpec): ShellProcess101}resolve 负责「默认值显式化」,run 和 start 的注释都写着同一个要求:收 resolve 出来的 spec,绝不收原始 request。这条纪律在方法签名层就写死了,下面两个 provider 的差异也全在这三个方法的实现里。这张图把装配关系画清楚:
词汇表:request 与 spec
契约的词汇分两层。请求是调用方表达的意图:
38export interface ShellExecRequest {39 command: string40 /** Working directory override (default: implementation-configured). */41 workdir?: string | undefined42 /** Timeout override in milliseconds (implementations cap it). */43 timeoutMs?: number | undefined44 /**45 * Foreground stdout capture budget in bytes. Absent uses the executor's46 * default output cap. Trusted in-process consumers use this when they must47 * parse complete stdout up to their own bounded limit; the model-facing bash48 * tool does not expose it as a parameter.49 */50 stdoutMaxBytes?: number | undefined51 /** Abort signal — implementations kill the command when it fires. */52 signal?: AbortSignal | undefined53 /**54 * Bytes to write to the command's stdin, then close it. Absent leaves stdin55 * closed/empty (the default for model-driven tool calls). Set by in-process56 * plugins (e.g. the hooks bridges, which write a hook command's JSON payload57 * to its stdin); the model-facing bash tool does not expose it as a parameter58 * (a model that needs stdin uses shell syntax like a heredoc or a pipe).59 */60 stdin?: string | undefined61 /**62 * Ordinary environment entries for the command, merged after the credential63 * scrub. Managed facts belong in {@link dshEnv}, which merges after this64 * map, so an entry here can never displace one. Set by in-process plugins65 * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the66 * model-facing bash tool does not expose it as a parameter.67 */68 env?: Record<string, string> | undefined69 /**70 * Harness-owned `DSH_*` variables for this execution (typed to managed71 * keys). Executors discard ambient `DSH_*` entries before merging this72 * snapshot last, so an unavailable current fact cannot inherit a stale73 * value from the harness process and a caller {@link env} entry cannot74 * displace a managed one.75 */76 dshEnv?: DshEnvironment | undefined77 /** Fully resolved per-call sandbox policy; sandboxing executors default it. */78 sandboxPolicy?: SandboxExecutionPolicy | undefined79}八个可选字段,每个的注释都在讲「默认归实现管」:workdir 是 implementation-configured,timeoutMs 会被 implementations cap,sandboxPolicy 由 sandboxing executor 去 default。调用方只表达意图:我想在这个目录跑、给我 50 秒。目录不存在怎么办、50 秒超没超上限、要不要沙箱,调用方不负责。
对应的规格把所有默认值落定:
86export interface ShellExecSpec {87 command: string88 workdir: string89 timeoutMs: number90 /**91 * Resolved foreground stdout capture budget in bytes. `run()` uses it for92 * stdout; background jobs and stderr keep the executor's own output cap.93 */94 stdoutMaxBytes: number95 /** Abort signal — implementations kill the command when it fires. */96 signal?: AbortSignal | undefined97 /** Bytes to write to stdin before closing it; absent means no stdin. */98 stdin?: string | undefined99 /**100 * Ordinary environment entries carried through from101 * {@link ShellExecRequest.env}; {@link dshEnv} still merges after them.102 * OPTIONAL on the spec for the same reason as `stdin`: absent means no103 * ordinary extra environment.104 */105 env?: Record<string, string> | undefined106 /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */107 dshEnv?: DshEnvironment | undefined108 /** Resolved sandbox policy; ignored by executors that do not confine. */109 sandboxPolicy: SandboxExecutionPolicy | undefined110}五个必填:command、workdir、timeoutMs、stdoutMaxBytes、sandboxPolicy。可选的只剩 signal、stdin、env、dshEnv 四个透传项。必填与可选的边界,就是「执行器必须知道」与「可以省略」的边界。注意 sandboxPolicy:类型必填,值可以是 undefined,local 传 undefined,sandbox 传完整策略,两个 provider 在同一个字段里表达差异。
结果类型把「一次运行」拆成四路独立事实:
112/** The outcome of one completed (or killed) foreground run. */113export interface ShellRunResult {114 /** Exit code; null when the process died from a signal. */115 exitCode: number | null116 /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */117 signal: NodeJS.Signals | null118 /**119 * True when the executor's own timeout was the FIRST cause to cut the command120 * short. Mutually exclusive with {@link aborted}: one fused deadline drives121 * both the timeout and the caller's cancellation, so a timeout and an abort122 * racing before process close report the single first-abort cause, not both123 * (see the [timeout-library Agent Note](../../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).124 */125 timedOut: boolean126 /**127 * True when the caller's `AbortSignal` was the FIRST cause to kill the command128 * (and it was not the executor's own timeout). Mutually exclusive with129 * {@link timedOut} — see there for the first-cause classification.130 */131 aborted: boolean132 /** The effective timeout applied to this run (after defaulting/capping). */133 timeoutMs: number134 stdout: CollectedOutput135 stderr: CollectedOutput136 /** Sandbox execution facts, absent for an unsandboxed executor. */137 sandbox?: ShellSandboxInfo138}exitCode、signal、timedOut、aborted 各记各的。一个命令可以同时超时并退出 0(它在信号处理器里清了场),所以不能只读 exitCode。timeoutMs 带的是「实际生效值」,请求值、生效值、结果值三方能对账。sandbox 字段标注「absent for an unsandboxed executor」,local 的结果里没有它,sandbox 的结果里有它。
Provider 一:dsh-bash-local
模块头注释是职责分配表:
1/**2 * Local Service Provider for the bash capability seam over the subprocess3 * capability seam. Public commands run as `bash -c` in a managed process group spawned4 * through `ctx.subprocess`; subclasses may reuse the same mechanics with an5 * explicit argv. This executor owns command defaulting, deadlines and cause6 * classification, the model-friendly terminal environment, and the model-facing7 * stdout/stderr merge for background reads. Execution policy belongs in8 * `tools/pre-execute` or a sandboxing executor.9 * @module @deepseek-ai/dsh-bash-local10 */注释第一句点出它坐落在另一个 seam 上:ctx.subprocess。bash provider 自己不写进程管理,进程组、输出收集、溢出文件、凭据擦除都归 subprocess seam。「owns command defaulting, deadlines and cause classification」划出本包边界:默认值、deadline、原因分类归它;执行策略(审批)归 tools/pre-execute 或沙箱执行器。
默认值显式化的现场就在 resolve 里:
139 /**140 * Resolve a request into a fully-specified spec: fill `workdir` from141 * `config.cwd` (else `process.cwd()`), and `timeoutMs` from142 * `config.timeoutMs`, capped at `config.maxTimeoutMs`. The tool layer calls143 * this before {@link run}/{@link start}, so those methods receive explicit144 * values and never re-default.145 */146 resolve(request: ShellExecRequest): ShellExecSpec {147 const timeoutMs = clampTimeout(148 request.timeoutMs,149 this.config.timeoutMs,150 this.config.maxTimeoutMs,151 'bash-local: request.timeoutMs',152 )153 const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes154 assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)155 return {156 command: request.command,157 workdir: request.workdir ?? this.config.cwd ?? process.cwd(),158 timeoutMs,159 stdoutMaxBytes,⋯ ...request.signal ? { signal: request.signal } : {},· // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,· // no config default. The subprocess service owns the scrub and merge order.⋯ ...request.stdin !== undefined ? { stdin: request.stdin } : {},⋯ ...request.env !== undefined ? { env: request.env } : {},⋯ ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},166 // Carry a sandbox policy through verbatim: this executor never167 // confines, so the field is inert here (the seam contract) — a168 // sandboxing subclass overrides resolve() to stamp its default instead.169 sandboxPolicy: request.sandboxPolicy,170 }171 }三处默认值一次落定:workdir 三连 request.workdir ?? this.config.cwd ?? process.cwd()(157 行);timeoutMs 走 clampTimeout,请求值被默认和上限夹住(147-152 行),默认 120 秒、上限 600 秒来自静态 Config(107-108 行);stdoutMaxBytes 落定(153-154 行)。166-169 行注释是第二个 provider 的伏笔:local 从不沙箱,sandboxPolicy 在此是惰性透传。
run 只剩一行:
211 async run(spec: ShellExecSpec): Promise<ShellRunResult> {212 return this.runArgv(spec, ['bash', '-c', spec.command])213 }把 spec 换成 argv,交给 runArgv。真正的复杂度在 223-240 行:一个 deadline 融合超时与取消(225 行)、spawn 进 ctx.subprocess(226 行)、超时与取消按第一因分类(229-231 行,timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined,aborted = d.signal.aborted && !timedOut)。
Provider 二:dsh-bash-sandbox
第二个 provider 换实现的方式,是本章的核心观察。模块头注释:
1/**2 * Sandbox-consuming bash executor. It wraps the exact local bash argv through3 * `ctx.sandbox`, inherits local process mechanics, and reports the selected4 * mode, enforcement, and denial facts. Positive runner-launch evidence means5 * the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while6 * background processes carry `runnerFailed`; other spawn rejections retain7 * local-executor semantics. The tool owns approval and passes a complete per-call policy.8 * @module @deepseek-ai/dsh-bash-sandbox9 */三件事:用 ctx.sandbox 包装同一个 bash argv;继承 local 的全部进程机械;报告模式、执行、拒绝三类事实。跑不起来的 runner 证据走 SANDBOX_UNAVAILABLE,后台进程走 runnerFailed。注意「The tool owns approval」,审批仍然在工具层,provider 只负责执行和报告。
类的声明证明它换实现靠的是继承:
37/**38 * Registers as `ctx.shell` in place of the local executor and requires a39 * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is40 * unchanged. Tool calls pass the calling session's resolved policy; direct41 * calls fall back to deployment policy. `result.sandbox` reports the mode and42 * enforcement actually used.43 */44export class SandboxBashExecutor extends LocalBashExecutor {45 static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']4647 // No own Config: the sandbox default (mode + workspaceRoot) is owned by48 // ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config49 // verbatim (the config catalog walks the inherited static).extends LocalBashExecutor,inject 扩成 ['subprocess', 'sandbox', 'sandboxPolicy'],没有自己的 Config:沙箱默认值(模式 + 工作区根目录)归 ctx.sandboxPolicy。「the tool layer is unchanged」是继承的收益声明。
resolve 的覆写只有一行:
84 override resolve(request: ShellExecRequest): ShellExecSpec {85 return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }86 }先 super.resolve 完成全部默认值落定,再补一个沙箱策略:调用方带了策略就用调用方的,没带就从部署策略解析。默认值注入点因此比 local 多一层,而 local 完全不知道这层存在。
run 的覆写是沙箱语义的全貌:
88 override async run(spec: ShellExecSpec): Promise<ShellRunResult> {89 const policy = spec.sandboxPolicy as SandboxExecutionPolicy90 const { mode } = policy91 if (mode === 'danger-full-access') {92 const result = await super.run(spec)93 return { ...result, sandbox: { mode, denied: false } }94 }95 const confined = this.confine(spec.command, { ...policy, mode })96 let result: ShellRunResult97 try {98 result = await this.runArgv(spec, confined.argv)99 } catch (error) {100 // An upstream abort remains cancellation even when it prevents spawn.101 if (spec.signal?.aborted === true) spec.signal.throwIfAborted()102 if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {103 throw new SandboxUnavailableError(mode, String(error))104 }105 throw error106 }107 // Runner failure outranks denial because the command did not run. Carry108 // the matched fatal line, not an informational line that preceded it.109 const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)110 if (runnerFailure !== undefined) {111 throw new SandboxUnavailableError(mode, runnerFailure.detail)112 }113 return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }114 }三条路:danger-full-access 直通 local 并附一条 denied: false 的沙箱事实(91-94 行);其他模式用 confine 把 ['bash', '-c', command] 换成包装 argv 再走 runArgv(95-98 行),confine 在 177-179 行就是一行 this.ctx.sandbox.confine(['bash', '-c', command], policy);失败路径按证据分类:spawn 失败若是指向 runner 的 ENOENT/EACCES(helpers.ts:39-53 只认 argv[0] 的错误路径且 workdir 可用),抛 SandboxUnavailableError(102-104 行),运行后 runner 失败优先于拒绝(109-112 行),正常路径附 mode、denied、enforcement 三件套(113 行)。命令被沙箱拒绝,模型看到的是一句「file access denied」标记,代码层面的 denied 布尔由 classifyDenial(helpers.ts:67-69)按拒绝方言签名匹配出来。
Consumer:dsh-tool-bash
Consumer 的注入声明是「无感知」的代码级证据:
30export const name = 'tool-bash'31export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']注入的只有 seam 的 key:shell,外加 tools、systemPrompt、shellEnv。没有任何 provider 类型。工具不知道今天挂的是 local 还是 sandbox。执行路径固定两拍:
380 const result = await ctx.shell.run(ctx.shell.resolve({⋯ ...request,382 signal: exec.signal,383 }))384 if (result.aborted) {385 const error = new HarnessError('tool call aborted', TOOL_ABORTED)386 error.name = 'AbortError'387 throw error388 }389 return { kind: 'foreground' as const, ...canonicalBashResult(result) }先 ctx.shell.resolve(request) 拿 spec,再 ctx.shell.run(spec)。aborted 结果映射成 TOOL_ABORTED 抛给上层(384-388 行);成功路径走 canonicalBashResult 拆成可序列化 DTO(389 行),只在 result.sandbox !== undefined 时带出沙箱事实(173-180 行)。后台分支在 365-378 行,姿势一样:ctx.shell.start(ctx.shell.resolve(request))(370 行),再挂进 ctx.jobs。
Consumer 的「无感知」有一个精确边界。provider 通过 sandboxMode getter(75-77 行,默认 undefined)向工具层宣告能力事实;tool-bash 读它(192 行),决定 schema 是否亮出 sandbox_permissions、justification 两个升级参数(259-269 行)以及描述是否附升级纪律段落(81-92 行)。挂沙箱时多出这两个可选字段,换回 local 它们消失。契约形状不变,能力事实让展示层自动适配,这才是「无感知」的确切含义。
子系统文档把这条链路命名为仓库的「explicit > implicit」规则:
15The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`/`stdoutMaxBytes`, filled from config or request policy) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.shell.resolve(request)` between them (the repo's "explicit > implicit at package boundaries" rule); a `ShellExecSpec` carries resolved values.一次前台 bash 调用穿过所有角色的完整时序:
设计决策分析
决策一:Service Definition 是 class,不是 interface
glossary 里 seam 的定义有一句看起来像规定的话:
9- **seam** — a *swappable capability* with three roles: a **Service Definition** (the Cordis `Service` that owns its `ctx.<key>` and vocabulary types — an abstract class such as `ShellExecutor`, or a concrete registry such as `WebRuntime`, never a TypeScript `interface`), one or more **Service Providers**, and one or more **Consumers** that inject the service. `packages/shell` is the canonical example: `dsh-shell` (Service Definition), `dsh-bash-local` / `dsh-bash-sandbox` (providers), and `dsh-tool-bash` (Consumer). Roles normally occupy separate packages when they evolve independently, but a package may own multiple roles when they are one concern (`dsh-llm` owns its Service Definition and Consumer). The seam is the complete capability, never one role; reserve the term for that meaning and name a constituent by its role, class, service, contract, or extension point.Agent Note 的决策节重复了同一句(第 17 行:「it is never a TypeScript interface」)。这不是修辞,从代码结构能推出三个理由:
- 注册是运行时动作。
super(ctx, 'shell')(67 行)把实例写进 cordis 的服务槽,interface 没有运行时存在,注册动作无处安放。 - 默认行为要能被继承。
sandboxModegetter 返回 undefined(75-77 行),interface 约束不了「默认返回 undefined」。 - provider 之间的继承链靠 class 存在。
SandboxBashExecutor extends LocalBashExecutor(44 行)是运行时继承,接口之间没有实现继承。
不这样做会出什么事:每个 provider 各自重复注册样板;「一实现 per context」的排他语义只能靠文档约定;sandbox 要么 fork local 的全部代码,要么自己发明一套组合机制。glossary 把 class 写进 seam 定义,是把这三个后果固化成规定。
决策二:默认值在 resolve 显式化
仓库规范有一条条款直接管辖这个 seam:
112- **Explicit > implicit at package boundaries**: defaulting is an explicit `resolve(request): Spec` step in the owning implementation, never a hidden `?? default` inside `run()` (the `dsh-shell` request/spec split is the template).「默认化是归属实现里显式的 resolve 步骤,永远不是 run() 里藏着的 ?? default」,并点名 dsh-shell 的 request/spec 分离是模板。配套条款在 110 行定义了角色三角,两条一起构成 seam 的宪法:
110- **A capability seam comprises Service Definition / Service Provider / Consumer roles.** It is complete, never one role; split only when roles evolve independently ([glossary](docs/glossary.md#capability-seam)).为什么这样做:resolve 是 provider 语义的唯一入口,也是 consumer 必须经过的前置站(tool-bash 380 行固定先 resolve 后 run)。显式化的直接收益是「实际生效值」成为可观察事实:结果里的 timeoutMs(types.ts:133 行)就是 spec 的值,请求值、生效值、结果值三方对账。请求里 50 毫秒的意图、spec 里 120 秒的生效值、结果里 120 秒的记录,一条链下来没有黑箱。
不这样做会出什么事:默认值藏在 run() 里,调用方无法预知实际生效的 spec。模型传 timeoutMs=50,不知道会被 cap 到多少;沙箱 provider 找不到 stamp 策略的组合点,只能自己在 run 里再判断一次,而那时请求上下文已经丢了。装配错误也一样:挂载 confining executor 而缺 sandboxPolicy 服务时,load 阶段直接抛错(195-197 行),错误在第一次跑命令之前就暴露。
决策三:provider 无 fork 换实现
换实现的方式是继承加覆写,证据链完整:sandbox extends local(44 行),模块注释写明「inherits local process mechanics」(3 行),confine 只换 argv(177-179 行)。叶子组合里,换 provider 就是换一行插件名:
39- id: bash40 name: '@deepseek-ai/dsh-bash-sandbox'而换掉的底座是共享的。同文件 163-169 行的注释写着「The filesystem stack rides the SAME sandbox policy as bash」:fs 工具和 bash 工具共享同一个沙箱策略。沙箱不是一个 bash 的局部改造,是整个执行世界的底座;换 provider 换的是底座和命令之间的适配层,模型可见面不动。
不这样做会出什么事:fork 一份 local 改两行,从此 subprocess 每修一个 bug 要同步两份;更糟的是 provider 和 consumer 同包,工具 schema 的演进被实现节奏绑架。Agent Note 的后果节写明了这个选择的代价:
38Separating roles adds packages and boilerplate (`package.json`, `tsconfig`, README, and injection wiring). In return, Service Providers and Consumers ship and version independently, and a new backend never risks the model-facing contract. [AGENTS.md](../../../../AGENTS.md) and [architecture.md](../../../../docs/architecture.md) carry the rule; the bash trio is the reference template. This Agent Note records why independently changing roles normally split while genuinely shared concerns may remain folded.多出的包和样板,换来独立版本化和契约零风险。角色定义原文在 Agent Note 的决策节:
15A swappable capability has **three roles**:16171. **Service Definition** — the Cordis `Service` and vocabulary types owning `ctx.<key>` and depending only on the vocabulary the contract needs (e.g. `dsh-shell`: `ShellExecutor`, `ShellRunResult`, `ShellProcess`). A definition may be an abstract class or a concrete registry service; it is never a TypeScript `interface`.182. **Service Provider** — a plugin that supplies or registers an implementation (e.g. `dsh-bash-local`: subprocesses, process-group kills, spill-file truncation). Sandboxed and remote providers are sibling packages implementing or registering against the same Service Definition.193. **Consumer** — what the model and plugins program against (e.g. `dsh-tool-bash`: the `bash` schema, with background handles registered into the generic job runtime). Consumers inject the service key and never import provider-specific types.第 3 条「Consumers inject the service key and never import provider-specific types」和 tool-bash 的 inject 声明(30-31 行)逐字对应。第 25 行还有一条边界:「Don't split preemptively」,一个能力只有一种实现和一种 Consumer 时,保持单包,等第二个出现再切。
边界条件剖析
如果同一个 context 挂两个 provider
会抛错,组合加载失败。类注释(48-50 行):「one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior」。两个实现不会悄悄抢同一个 key,槽位排他让「换 provider」成为原子操作:先卸再装。SHELL_SETTINGS_NAMESPACE(22 行)也按这个逻辑按能力命名:win32 组合把 POSIX 行换成 pwsh 行(16-17 行),两个 provider 共享一个命名空间但永不重复注册。跨平台差异在这里交代:三个平台共享同一份 Service Definition,Windows 组合换 pwsh provider(shell/README.md:12),bash 与 pwsh 工具各自渲染各自的退出码标记。
如果超时与用户取消同时发生
只有一个第一因被记为 true。ShellRunResult 的注释(118-123 行)说 timedOut 与 aborted 互斥:一个融合的 deadline 同时驱动超时和取消,竞速先到者记为第一因。实现侧(bash-local:229-231 行):timedOut 只在「本执行器的 BASH_TIMEOUT 触发了 deadline」时为 true,aborted 是 deadline 被 abort 且没超时。Consumer 侧只查 aborted(tool-bash:384-388 行),超时的语义已经包在结果里。
如果沙箱 runner 没能把命令跑起来
前台抛 SANDBOX_UNAVAILABLE(bash-sandbox:102-104、109-112 行),后台进程在 facts 里带 runnerFailed(150-165 行,onProcessDone 在 done 结算前 stamp 事实)。判定逻辑 isRunnerSpawnFailure(helpers.ts:39-53 行)只认 argv[0] 的 ENOENT/EACCES 且 workdir 可用(44-52 行),排除「调用方自己把 cwd 写错」的误判。区分的意义写在 107-108 行注释里:runner 失败说明命令根本没运行,所以它压过 denial。模型看到的是基础设施错误,不是命令失败,不会去「修复」一条没跑过的命令。
如果命令自己给自己发信号
任何信号终止都记为 killed,包括命令自杀。bash-local:276-279 行注释:「Any signal termination is killed, including a command signaling itself」。同时 done 永不 reject(types.ts:168-169 行),spawn 失败以 killed 结算,错误走 stderr 和 readOutput 通道。背景进程的状态机只有三个态:
横向对比
双侧一:同一请求,两个 provider 的两种答案
同一个问题「如何执行 bash」,dsh-bash-local 和 dsh-bash-sandbox 给出两种答案,两个实现都在源码里,可以并排对照:
| 环节 | dsh-bash-local | dsh-bash-sandbox |
|---|---|---|
| resolve | 默认值落定(146-171 行) | super.resolve 之后 stamp 沙箱策略(84-86 行) |
| run | 裸跑 ['bash', '-c'](211-213 行) |
confine 换 argv(95-98 行),分类 runner 失败与拒绝(102-113 行) |
| 结果 | 无 sandbox 字段 | 附 mode、denied、enforcement(113 行) |
各自的代价:sandbox 多依赖两个服务(ctx.sandbox、ctx.sandboxPolicy),组合缺一个就加载失败(tool-bash:195-197 行),换来执行语义零侵入,local 完全不知道沙箱存在(bash-local:166-169 行「the field is inert here」)。local 的代价是要沙箱就得整体换 provider,正因如此 bash-local 全文件 334 行里没有任何沙箱 import。两种答案的取舍点:差异收敛到 resolve 和 run 两个覆写点,代价是继承链(sandbox 永远共享 local 的进程生命周期),收益是 subprocess 层每次修复两个 provider 同时受益。
双侧二:claude-code 的 BashTool 单体
未找到对应实现,检索关键词为 BashTool、tool-bash、toolBash、BashSandbox、spawn bash -c、bash -c。上游公开仓库(commit 45bdfa96)全仓仅 5 个 .ts 文件,且都在 examples/ 与 plugins/ 下,无主产品源码,Bash 工具的产品实现不在公开仓库里。
用行为记录补足。CHANGELOG 是主产品变更的投影,检索含 Bash 的条目共 40 条,分布在 64 到 1248 行。两条与本章主题直接相关:
408- Fixed sandbox violation details never appearing in Bash tool results; Claude now sees which file or network access was denied and why719- Fixed SIGTERM during a running Bash tool orphaning the command's process tree in print/SDK mode; the CLI now aborts the turn, kills the tree, and exits 143对照点「换实现要不要 fork」:上游每次行为修复都进 CHANGELOG,随产品发布周期走。408 行那条(沙箱违规详情进工具结果)在 dsh 里对应 ShellRunResult.sandbox 可选字段(types.ts:136-137 行);719 行那条(SIGTERM 杀进程树)对应 ShellProcess.kill 的进程组语义(types.ts:178-182 行);663 行(超 10K 字符的命令强制审批)对应 dsh 把审批放在 tools/pre-execute(bash-local:7-8 行)。同类语义,上游修在单体里走产品发布,dsh 拆进各 seam 的 provider、独立版本化。dsh 付的代价是包数量:10 个包目录,每个都要 package.json、tsconfig、README(Agent Note:38 行)。推断:上游内部是否也有三角色结构,公开仓库无源码无从考证。
互动演示设计
形态: 模拟器(provider 热切换)。
一句话结论: 换 provider 只改一行插件装配,模型侧的工具 schema、审批、结果渲染全部无感知。
舞台: 中央一个「执行工位」。右侧是模型对话框,持续调用 bash 工具,字幕显示同一段命令;中间是 ctx.shell 插座,插着当前 provider 的转换头;左侧三块插头卡片:local(亮)、sandbox(亮)、远程(灰,角标「未实现」);底部是逻辑轨迹面板,随动画步进高亮。
比喻: 电器插头。Consumer 是电器,只认插座上的三孔形状(Service Definition),换转换头不改变电器插脚;插头内部怎么变压(执行方式)电器不知道。sandbox 插头多出来的一根针(sandbox_permissions 参数),是能力事实的展示,插脚形状没变。
分步与字幕:
- 初始装配 local。模型调用 bash 跑
ls,输出正常。字幕:「同一个 bash 调用,现在走本机子进程」。 - 拖动 sandbox 插头插入工位。工位闪一下,轨迹面板新增一行。字幕:「换 provider,工具描述、参数 schema、结果渲染一帧都没变」。
- 同一命令再跑一次,这次命令写沙箱外的文件。结果出现
[sandbox: file access denied]标记。字幕:「模型看到的是策略拒绝,带模式名,不带执行细节」。 - 展开轨迹面板,resolve 那一步高亮,显示 sandboxPolicy 被 stamp。字幕:「两个 provider 的差异,全部发生在 resolve 和 run 两个覆写点」。
- 点击远程插头,灰色并弹出「未实现」。轨迹面板显示同一组签名。字幕:「远程执行器要做的,还是实现 resolve、run、start 三个抽象方法」。
- 拨回 local,重跑第一步的命令,输出逐字节相同。字幕:「从第一步到第六步,Consumer 无感知」。「无感知」的精确边界:挂 sandbox 时 schema 多出的两个升级参数消失,结果里 sandbox 字段消失。
读者操作: 拖拽插头切换 provider;拖动 timeoutMs 滑条(50ms 到 300s),观察 resolve 面板里 clampTimeout 高亮;点击轨迹行,右侧浮层显示对应源码摘录。
逻辑轨迹面板(伪代码行,右侧标真实行号,随步进高亮):
execute 收到 bash 调用,校验参数 tool-bash/src/index.ts:331
policy = sandboxPolicy.resolve tool-bash/src/index.ts:333
spec = ctx.shell.resolve request tool-bash/src/index.ts:380
local 分支:workdir 与 timeout 落定 bash-local/src/index.ts:146
sandbox 分支:stamp sandboxPolicy bash-sandbox/src/index.ts:85
run:裸 argv 或 confine 后 argv bash-local/src/index.ts:212
结果附沙箱事实 bash-sandbox/src/index.ts:113
canonicalBashResult 拆 DTO,渲染终端卡片 tool-bash/src/index.ts:389可迁移结论
- 三件套切法值得抄,不依赖 TypeScript。 把「能力」切成三个目录:契约(接口或抽象类)、实现(出现第二个时再切)、调用方(只 import 契约)。换实现改一处注册。Python 的最小形态:一个 abc 抽象类加两个实现模块加一个使用模块,组合点在一个函数里。抄的条件:已经有第二个实现或明确的计划,只有一个实现时别切(Agent Note:25 行的「Don't split preemptively」)。
- 请求与规格分离值得抄。 最小形态:入口第一行做 normalize(options),把可选字段全部落定成必填的内部结构再传给执行函数;执行函数里禁止出现默认值。这条纪律不挑语言,一个函数就能落地,收益是「实际生效值」永远可对账。
- 继承作为 provider 复用机械的手艺。 新实现 extends 旧实现,把差异收敛到入口语义(resolve)和执行结果(run 的返回)。抄的条件:两个实现共享大部分生命周期。如果新实现的生命周期完全不同(比如远程执行器要管理连接和传输),继承可能反成约束,这时只实现抽象方法更干净(推断)。
- 这个体量下的过度设计清单。 槽位排他、settings namespace 跨平台复用、双注册失败即抛,这些是 10 个包级别的纪律。你的产品如果只有一次换引擎需求,一个 Config 开关加两行分支就够;出现第二个实现再切 seam。
思考题
- 动手验证。 打开
examples/acp-agent/cordis.yml,把第 40 行的插件名@deepseek-ai/dsh-bash-sandbox改成@deepseek-ai/dsh-bash-local,跑一个会话执行「向工作区写一个文件」的命令。预期观察三点:工具描述的基础段不变,但 escalation 纪律段落消失(来自 bashDescription 的 81-92 行,条件escalationModes.length > 0);schema 里sandbox_permissions、justification两个参数消失(259-269 行);结果里 sandbox 字段消失(canonicalBashResult 只在result.sandbox !== undefined时带出,173-180 行)。此时第 65 行系统提示词里「Your bash tool runs under a file sandbox」成了空话,把它也删掉再跑一次,对比模型行为。验证方法:起该示例的会话,同一任务跑两次,对比工具结果 JSON 的字段差异。 - 给 seam 增加第三个 provider(远程执行器),代码层面至少要动哪几处?答案:实现 resolve、run、start 三个抽象方法(shell/src/index.ts:85、93、100 行),可选覆写
sandboxModegetter(75-77 行);如果远程也有沙箱模式,参考 bash-sandbox 的 resolve 覆写(84-86 行)决定如何 stamp 策略;模型侧和 tool-bash 零改动。追问:远程结果的沙箱事实如何表示?看 types.ts:136-137 行的可选字段。 - 为什么 spec 里 timeoutMs 是必填(types.ts:89 行),request 里却是可选(43 行)?答案:显式化纪律(AGENTS.md:112 行),run 只收全字段的 spec。如果默认值藏在 run 里,模型传 50ms 时你无法预知实际生效值,结果里的 timeoutMs(133 行)也无从对账。
- 后台进程的
done为什么永不 reject(types.ts:168-169 行)?答案:spawn 失败以 killed 结算,错误经 stderr 和 readOutput 通道送达,调用方不需要对同一条失败路径写两套处理;kill 语义已经覆盖了「没跑起来」这种情况。