dsh 源码解析
第三段 · 能力与治理 · 第 08 章

Capability Seam:Definition / Provider / Consumer 三角

34 个可替换能力;换两个 provider 把执行世界搬到远程

本章基准:dsh 0.1.1-rc.2,commit b150a551b8d465e31e418e1b2eaf5e79bbb7d28e

主要源码:packages/shell/packages/subprocess/packages/fs/packages/sandbox/packages/e2b/,以及 docs/capability-seams.md(生成物,34 个 seam)

前七章讲的都是"骨架":Cordis、装配、循环、日志、提示词、工具。这一章讲 dsh 怎么让骨架上的能力可替换。

先看一个具体问题。模型要跑一条 bash 命令。这条命令可能:

  • 在本机直接跑;
  • 在本机跑但被 Landlock / Seatbelt / bwrap 限制;
  • 在一台 E2B 远程 Linux 沙箱里跑;
  • 在 Windows 上根本不是 bash,而是 pwsh。

tool-bash 这个模型可见的工具,在这四种情况下是同一个包、同一段代码、同一份 schema

这一章讲实现这件事的模式,以及它在 dsh 里被用了 34 次的原因。

全章图示:assets/ch08-capability-seam.svg

Capability Seam 三角:Definition / Provider / Consumer


1. 三个角色,一个完整的能力

docs/glossary.md:9 给的定义很精确:

a 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.

The seam is the complete capability, never one role.

packages/shell 是标准样板:

角色 干什么
Service Definition dsh-shell 拥有 ctx.shell,定义 ShellExecutor 抽象类和 request/spec/result 词汇
Provider dsh-bash-local 走本机 ctx.subprocess
Provider dsh-bash-sandbox 先套 ctx.sandbox 再本机执行
Provider dsh-pwsh-local Windows 上的 PowerShell
Consumer dsh-tool-bash 给模型的 bash 工具
Consumer dsh-hooks-claude-code Claude Code hook 桥

两个词值得单独强调。

"never a TypeScript interface"。 Service Definition 必须是 Cordis Service(抽象类或具体注册表),不能是纯 interface。因为它承担的不只是类型:它拥有 ctx.shell 这个键(重复注册会抛错,这是 Cordis 的语义)、它拥有生命周期(第 2 章的 Fiber)、它拥有共享常量。一个 interface 只能给类型检查用,运行时什么都不占。

"the seam is the complete capability, never one role"。 这条是词汇纪律。dsh-shell 不叫 seam,它是 seam 的 Service Definition;dsh-bash-local 不叫 seam,它是一个 Provider。这不是咬文嚼字——把某一个角色叫成 seam,下一个人就会以为"换 provider 要改 Definition"。

三个角色通常分包,但一个概念可以占多个角色dsh-llm 同时是 Definition 和 Consumer)。分包的标准是"是否独立演化"。


2. Service Definition 拥有什么

ShellExecutorpackages/shell/shell/src/index.ts:65):

export abstract class ShellExecutor extends Service {
  constructor(ctx: Context) { super(ctx, 'shell') }

  get sandboxMode(): SandboxMode | undefined { return undefined }

  abstract resolve(request: ShellExecRequest): ShellExecSpec
  abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
  abstract start(spec: ShellExecSpec): ShellProcess
}

看起来很小,但它拥有四样东西。

2.1 语义契约,写在 JSDoc 里

类的 JSDoc 列了实现必须遵守的语义:

  • run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a ShellRunResult.
  • start returns immediately; no timeout applies to background processes. done settles at process close and never rejects; spawn failures settle as killed with the error on stderr.
  • readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.
  • A still-running background process is stopped and awaited when its owning composition tears down.

第一条是最值钱的:非零退出不是异常。模型跑 grep foo file 没找到,退出码 1——这是正常结果,不是失败。如果一个 provider 在这里 reject,tool-bash 就要给每个 provider 写一套异常处理。把它写进 Definition 的契约,所有 Consumer 只需要处理一种形状。

第四条点出了一个隐蔽的所有权问题:后台进程的清理边界在哪个 Fiber。注释说得很具体:

With the subprocess seam that boundary is ctx.subprocess disposal, so a background process survives an executor-only reload.

热更新 bash-local(改个配置)不会杀掉正在跑的后台进程,因为进程属于 ctx.subprocess。这就是第 2 章那套 Fiber 生命周期在能力分层上的直接后果。

2.2 request / spec 分离

这是 AGENTS.md: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).

两个类型(shell/src/types.ts:38:86)差别就在可选性:

export interface ShellExecRequest {
  command: string
  workdir?: string | undefined
  timeoutMs?: number | undefined
  stdoutMaxBytes?: number | undefined

}

export interface ShellExecSpec {
  command: string
  workdir: string        // 必填
  timeoutMs: number      // 必填
  stdoutMaxBytes: number // 必填

  sandboxPolicy: SandboxExecutionPolicy | undefined
}

run()start()@param 都写着 a resolved spec from resolve(), never a raw request

为什么值得多一个类型和一次调用?

默认值属于实现,不属于契约。 bash-local 的默认超时和 E2B 的默认超时可以不同,而 Consumer 不该知道这件事。

默认值变成可观察的。 resolve() 的结果可以被日志、被测试、被断言。藏在 run() 里的 ?? 120_000 只有读源码才能发现。

Consumer 可以先 resolve 再决定。 拿到 spec 之后可以看清最终 workdir 和 policy,然后再决定要不要真的执行。

注意 sandboxPolicy 在 spec 上是必写但可为 undefined,注释:ignored by executors that do not confine。词汇属于 seam,字段永远在;给不给值由 Consumer 决定,用不用由 provider 决定。

2.3 一个能力问答方法

get sandboxMode(): SandboxMode | undefined { return undefined }

默认返回 undefined(不沙箱),bash-sandbox 覆盖它。tool-bash 靠它决定要不要给模型的 schema 加上升级参数——bash-sandbox 的 README 写明:

no alternate tool plugin is needed; dsh-tool-bash detects the executor's sandboxMode capability and adds the escalation fields.

同一个 Consumer,两种模型可见 schema。 这是 seam 设计里最实用的一手:不用给沙箱版再写一个 tool-bash-sandbox,也不用让 tool-bashimport 沙箱包。一个能力问答方法就够了,而且默认值让不沙箱的 provider 什么都不用写。

2.4 一个 settings namespace

export const SHELL_SETTINGS_NAMESPACE = settingsNamespace('shell')

注释解释了为什么这个常量归 Definition 而不是归任一 provider:

it names the capability, not an implementation: a host composes exactly one provider of ctx.shell (the win32 layer swaps the POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate service registration), so the providers share one namespace without ever registering it twice, and a settings document carried between platforms keeps resolving on both.

最后半句是关键:一份 settings 文档在 Linux 和 Windows 上都能解析。如果 namespace 叫 bashpwsh,同一份用户配置跨平台就失效了。namespace 命名的是能力,所以它归 Definition。


3. Provider:三种替换方式

ctx.shell 有三个 provider,替换方式各不相同,正好覆盖了三种典型形态。

3.1 继承:fs-sandbox extends fs-local

SandboxedFileSystem 直接继承 LocalFileSystem,README 说得很干脆:

It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on writeText/editText. Reads always pass through — every mode permits reading.

Loading it INSTEAD OF dsh-fs-local, together with a ctx.sandboxPolicy, is the whole swap; the model-facing tools (dsh-tool-fs) are untouched.

"整个替换就是换一行 cordis.yml + 加一个 policy 插件"。配置类型也保持不变(Its plugin config is the local backend config unchanged),所以已有配置不用改。

这种形态适合"加一层检查"。代价是继承耦合:fs-local 的内部结构变了,fs-sandbox 要跟着改。

3.2 组合:bash-sandbox 消费 ctx.sandbox

bash-sandbox 继承了 bash-local 的进程机制,但真正的沙箱能力来自另一个 seam

Every command is confined by handing the provider the exact ['bash', '-c', command] argv this executor is about to spawn and spawning the returned argv directly.

ctx.sandbox 的 Service Definition 注释(sandbox/src/index.ts:1)划了范围:

Service Definition for the same-world process-confinement capability seam: wrap exact subprocess argv under a host-path file policy. Containers, microVMs, and remote execution replace the surrounding capability seam instead; this service shares the host kernel and filesystem.

"same-world"。这个 seam 只管"同一个内核里的文件效果限制"。要换成容器或远程执行,不是给 ctx.sandbox 加一个 provider,而是替换外层 seam(换 ctx.subprocessctx.fs)。

一个 seam 明确声明自己覆盖什么,和声明覆盖什么一样重要。否则 ctx.sandbox 迟早会长出 mode: 'docker',然后 SandboxMode 这个只有三个值的词汇就崩了。

SandboxPolicy 上还有一条设计说明(sandbox/src/index.ts:69):

What one confined execution is allowed to touch — carried PER CALL, not fixed on the provider: two consumers may confine under different policies at the same instant (bash under read-only while a confined child agent needs its state directory writable), and an approved escalated retry is a new call with a wider policy.

策略按调用携带,不固定在 provider 上。 同一时刻两个消费者可以用不同策略。而"批准的升级重试"就是一次带更宽策略的新调用——不是修改现有状态。这让升级审批天然地不可累积:批准一次就是一次。

3.3 换掉整个执行世界:E2B

packages/e2b 是三种形态里最激进的:

The existing dsh-bash-local, dsh-terminal-bash, and dsh-lsp-stdio need no E2B-specific forks. They delegate every execution-world operation to ctx.fs and ctx.subprocess, so mounting the two E2B adapters places their mutable work in the same sandbox.

装两个 provider(fs-e2b + subprocess-e2b),bash 执行器、PTY 后端、LSP 宿主全都跑到远程去了,三个包一行没改。

这是 seam 分层的复利:bash-local 不认识 E2B,它只认识 ctx.subprocess。谁提供 ctx.subprocess 是组合时的事。

而边界写得同样清楚:

This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, higher-level protocol state, or E2B SDK buffers.

移动的只是"执行世界"——文件和进程。会话状态、模型调用、插件树都还在本地。E2B 三个包里 ctx.e2b 的存在也有讲究:它是"共享生命周期拥有者",两个 adapter 注入它、等同一个 SDK 句柄,所以它们必然在同一个远程工作树里。如果两个 adapter 各自建 sandbox,fs 写的文件 subprocess 就看不见了。


4. Consumer:为什么 Definition 不能被单个 Consumer 定形

packages/AGENTS.md:10 有一条针对 Definition 设计的规则:

Design Service Definitions for all current Consumers. Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract. Inverse smell: a public service method with one internal caller — pass a private capability closure instead.

两条相反方向的约束,值得一起看。

不要让一个 Consumer 定义契约。 ctx.shell 有四个 Consumer(bash 工具、pwsh 工具、两个 hook 桥)。如果 Definition 按 tool-bash 的需要设计,那些"模型可见的 schema 字段""升级提示文本"就会漏进 Definition,hook 桥就得吞下一堆和它无关的概念。

bash-sandbox 那句 Deny-only at the seam: a denial is a reported fact, and this executor never negotiates permissions itself — the approval question lives in the tool layer 就是这条规则的实践:seam 只报告事实,不做交互。审批是 UI 概念,属于 Consumer。

反向的坏味道:一个只有一个内部调用者的公共服务方法。 这种情况该传一个私有能力闭包,而不是在服务上开个公共方法。它的破坏性同样大:一个公共方法就是一份长期契约,而它实际上只服务一个调用点。

ctx.fs 那三个 waterfall 事件(fs/src/index.ts:58 起)是"用扩展点代替公共方法"的例子:

'fs/write-intent'(target, actor, next): Promise<FsWriteIntent | undefined>
'fs/edit-intent'(target, actor, next): Promise<{ version: FsVersion } | undefined>
'fs/observed'(target, observation, actor): void

注意这两个 waterfall 的 JSDoc 都说 Single-slot decision … the first listener that returns an intent owns the decision rather than composing with peers——单槽决策,第一个返回的赢。这和第 7 章工具流水线里那些"可组合"的 waterfall 不同:这里是"要不要要求版本号一致",两个答案不能合并。

fs-observation-policy 就是靠这三个事件挂进去的(它在 seam 表里被标为 Companion plugins,既不是 provider 也不是普通 Consumer)。ctx.fs 因此不需要一个 setObservationPolicy() 公共方法。


5. seam 之间怎么共享事实

一个跑起来的 dsh 里有 34 个 seam。它们之间必然有共享的事实,dsh 的处理方式有两条清晰的模式。

5.1 共享一个策略拥有者

ctx.sandboxPolicy 在生成表里被标为 core 而不是 seam,注释:

The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service … Both enforcing families read it so bash and fs cannot confine to different roots.

bash 和 fs 是两个独立的 seam,但它们必须限制在同一个根下。 做法是让它们读同一个策略服务,而不是各自配一个 workspaceRoot

fs-sandbox 的 README 把这条落到了实现层:

the workspace root plus the platform temp areas (/tmp, os.tmpdir()), the SAME set the Seatbelt profile grants, derived from the one writableRoots function so the fs fence and the bash runner cannot drift.

不只共享配置,还共享计算可写根的那个函数。因为"同一个策略"如果由两段代码各自解释,迟早会解释出两个答案——而这个分歧就是一个沙箱逃逸。

5.2 明确区分"策略围栏"和"内核边界"

fs-sandbox 的 README 有一节标题就叫 Threat model: a policy fence, not a kernel boundary

The fence is a check in TRUSTED code over a MODEL-CONTROLLED path — the operations are the seam's own (open, rename), only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface … Kernel-grade isolation of untrusted CODE stays ctx.shell's job.

两个 seam 的威胁模型不同,而且写清楚了为什么:

  • ctx.fs 的操作是自己的代码,只有路径来自模型 → 规范化后做包含检查就够了;
  • ctx.shell 跑的是模型写的任意代码 → 需要内核级隔离(Landlock / Seatbelt / bwrap)。

残留风险也写明了:

The residual TOCTOU (an ancestor symlink swapped between the containment re-check and the syscall) is narrowed by re-canonicalizing immediately before the write and is accepted for this threat model; a kernel-tight boundary needs openat2-class primitives not worth their portability cost here.

已知的、被接受的、写下理由的残留风险。 这比一句"我们做了沙箱"诚实得多,也让下一个人知道边界在哪。

5.3 结构化失败 vs 文本推断

同一个"拒绝",两个 seam 表达方式不同:

fs-sandbox

A denial is a structured FsError (FS_SANDBOX_DENIED, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused.

bash-sandbox

Denials are result facts. A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as ShellRunResult.sandbox.denied: true (conservative classification, read from the collected stderr tail).

进程内的围栏知道自己拒了什么,所以给结构化错误。内核拒绝只留下 stderr 文本,所以只能按 provider 自己的方言做保守分类

而且分类规则被提炼成了 Definition 上的一个类型(sandbox/src/index.ts:81):

export interface RunnerFailureRule {
  allowedExitCodes?: readonly number[]
  fatalSignatures: readonly string[]
  informationalLines?: readonly string[]
}

JSDoc 给了确定的判定顺序:A consumer first applies allowedExitCodes when present, removes informationalLines by case-insensitive exact line equality, then matches fatalSignatures case-insensitively within each remaining stderr line. Exit status alone never proves runner failure.

最后一句是重点:光看退出码永远不能证明是 runner 挂了。命令自己也会返回非零。所以判定必须要有 stderr 证据。

两个模型可见的结果最终又被统一了:fs-sandbox 的 README 说它的拒绝被渲染成 [sandbox: file access denied under <mode> mode] 标记加同轮升级提示,exactly as bash's do内部表示按各自最准确的方式,模型看到的表面统一。


6. 34 个 seam 里的几种形态

docs/capability-seams.md 是生成的(scripts/gen-doc-graphs.ts,带完整性守卫)。表里每个服务被标成 seam / core / bundle 三类。挑几个不同形态的:

ctx 形态
ctx.llm 多 provider 注册进一个适配器注册表(deepseek / pi-ai / replay),Definition 自己也是 Consumer
ctx.sessionPersistence 同一份 SessionEvent 词汇,JSONL 和 SQLite 两个后端(第 5 章)
ctx.approval 缺席即 fail-closed:没有 provider 时降级为 unavailable(第 7 章)
ctx.codeRuntime 按"基质 + 语言"区分后端,Consumer 是工具注册表(第 6 章的 Code Mode)
ctx.userQuestions provider 是 UI 前端;tool-ask-user 在一个 provider-neutral 的 ask() promise 上挂起工具调用
ctx.web search 和 fetch 各自多 provider,注册进同一个 seam;tool-web 拥有稳定的模型可见名字
ctx.lsp 刻意留协议逃逸口:the seam offers no protocol escape hatch, so a backend translates into the normalized request and result
ctx.spillStore Definition + provider 存溢出文本,policy 包作为 tools/post-execute Consumer 决定何时溢出
ctx.workflowEngine One engine per context, as in bash, with no named-provider registry

最后两行体现了一个反复出现的判断:什么时候需要"命名 provider 注册表",什么时候"一个上下文一个实现"就够

ctx.llmctx.web 需要注册表——一个部署会同时用多个 LLM provider、多个搜索后端,运行时要按名字选。

ctx.shellctx.workflowEngine 不需要——一个部署只有一个 shell 执行器,装两个是配置错误,而 Cordis 的重复服务注册会自然地 fail loud。

选注册表的成本是:每个调用点都要处理"这个名字没注册"。不需要多实例还上注册表,就是白付这个成本。

ctx.lsp 那句"没有协议逃逸口"是另一类判断。留一个 raw(request) 方法会很方便,但那等于让每个 Consumer 都可能绑死在某个后端的协议细节上——seam 就名存实亡了。四个规范化操作,一个都不多。


7. 设计代价

包数量。 34 个 seam,加上 provider 和 Consumer,是 227 个 workspace package 的主要来源。改一个能力的语义要同时动 Definition、若干 provider、若干 Consumer 和它们的 README。

一次跳转变成两次。 读代码时看到 ctx.shell.run(spec),要先查组合配置才知道跑的是哪个实现。docs/capability-seams.md 这类生成的导航图是对这个代价的补偿,但它是补偿不是消除。

词汇必须一次设计对。 SandboxMode 只有三个值,ctx.lsp 只有四个操作。这种"闭合词汇"是 seam 有价值的前提,但它意味着加一个真正的新语义要动所有 provider。dsh 靠 pre-release 阶段的姿态对冲(AGENTS.md:5 那节 foundation over blast radius:没有外部消费者,宁可重命名重打包也要把地基做对)。

"same-world" 这类边界要靠散文维持。 编译器不会阻止有人给 ctx.sandbox 加一个 mode: 'docker'。守住它的只有 Definition 的模块注释和 review。

跨 seam 的共享事实需要显式的第三方拥有者。 ctx.sandboxPolicy 的存在纯粹是因为两个 seam 必须一致。这类"协调服务"会随着 seam 增加而增加,而它们既不是 Definition 也不是 provider,分类上永远尴尬(表里被标成 core)。

能力问答方法(sandboxMode)是弱类型的协商。 Consumer 靠一个可选返回值判断 provider 有没有某个能力。比"给每种组合写一个 Consumer"好得多,但它是运行时约定,类型系统不会提醒你漏了一种情况。


8. 可迁移的经验

1. 一个可替换能力有三个角色,缺一个就不完整。 Definition(拥有键和词汇)、Provider(实现)、Consumer(使用)。只有 interface 没有 Definition 服务,就没有生命周期和唯一性保证;只有一个 provider 也要按 seam 写,因为第二个 provider 出现时才补太晚了。

2. Service Definition 用抽象类,不用 interface。 它要拥有运行时的键、生命周期和共享常量,这些 interface 都给不了。

3. 把语义契约写进 Definition,而不是让每个 Consumer 各自处理。 "非零退出不是异常"这一句,省掉了每个 Consumer 的一套异常处理,也防止了 provider 之间的行为漂移。

4. request / spec 分离:默认值是实现的显式一步,不是藏在执行里的 ?? default 好处是默认值可观察、可测试、可因 provider 而异,而 Consumer 不需要知道。

5. 用一个能力问答方法代替"给每种组合写一个 Consumer"。 sandboxMode 返回 undefined 就是"我不沙箱",Consumer 据此调整模型可见 schema。默认实现让不关心的 provider 什么都不用写。

6. 声明 seam 覆盖什么。 "same-world 进程限制;容器和远程执行请替换外层 seam"——这句话保住了一个只有三个值的闭合词汇。没有这句,词汇会被逐步扩张到失去意义。

7. 策略按调用携带,不固定在 provider 上。 两个消费者可以同时用不同策略,而"批准的升级"就是一次带更宽策略的新调用,天然不可累积。

8. 两个必须一致的 seam,共享一个策略拥有者和那段计算函数。 只共享配置不够——同一份配置被两段代码各自解释,迟早解释出两个答案,而那个分歧就是漏洞。

9. 写下威胁模型的差异和被接受的残留风险。 "受信代码 + 不受信路径"和"不受信代码"需要的防护级别不同。把这个区别和它的取舍写下来,比一句"我们做了沙箱"有用得多。

10. 内部表示按各自最准确的方式,用户可见表面统一。 进程内围栏给结构化错误,内核拒绝只能做保守的 stderr 分类——但模型看到的是同一个标记和同一种升级提示。

11. 判断"要不要命名 provider 注册表"。 一个部署真的会同时用多个实现(LLM provider、搜索后端)才需要;否则"一个上下文一个实现"更好,重复挂载会自然报错,而且每个调用点少一条"没注册"分支。

12. 别留协议逃逸口。 一个 raw(request) 方法很方便,但它会让 Consumer 绑死在某个后端上,seam 就名存实亡了。


9. 本章源码位置

位置 内容
docs/glossary.md:9 capability-seam 的权威定义:三个角色、"never a TypeScript interface"
docs/capability-seams.md:426 34 个服务的 seam / core / bundle 分类表(生成物)
packages/shell/README.md 一个 seam 家族的完整角色表
packages/shell/shell/src/index.ts:22 SHELL_SETTINGS_NAMESPACE:namespace 命名能力,所以跨平台配置仍可解析
packages/shell/shell/src/index.ts:47 类 JSDoc:run/start/readOutput/teardown 的四条语义契约
packages/shell/shell/src/index.ts:65 ShellExecutor:抽象 Service,三个抽象方法
packages/shell/shell/src/index.ts:75 sandboxMode:能力问答,默认 undefined
packages/shell/shell/src/types.ts:38 ShellExecRequest:字段可选
packages/shell/shell/src/types.ts:86 ShellExecSpec:字段必填;sandboxPolicy 必写可为 undefined
packages/shell/bash-sandbox/README.md 组合式 provider:交出确切 argv、拒绝是结果事实、deny-only
packages/fs/fs-sandbox/README.md 继承式 provider;策略围栏 vs 内核边界;共享 writableRoots
packages/fs/fs/src/index.ts:1 ctx.fs Definition:backend 拥有什么,policy 和读窗口留给别人
packages/fs/fs/src/index.ts:58 fs/write-intent / fs/edit-intent:单槽决策,第一个返回的赢
packages/fs/fs/src/index.ts:76 fs/observed:监听器必须同步,抛错会让工具调用失败
packages/sandbox/sandbox/src/index.ts:1 "same-world":明确声明容器/microVM/远程请替换外层 seam
packages/sandbox/sandbox/src/index.ts:29 SandboxMode:三个值的闭合词汇,网络和进程可见性不在其中
packages/sandbox/sandbox/src/index.ts:69 SandboxPolicy:按调用携带;升级重试是新调用
packages/sandbox/sandbox/src/index.ts:81 RunnerFailureRule:确定的判定顺序;退出码单独不构成证据
packages/subprocess/subprocess/README.md 契约清单:spawn/stdio/tree-kill/PTY/env scrub,以及"teardown ladder 归 Consumer"
packages/e2b/README.md 换掉整个执行世界;三个 Consumer 零改动;边界不含会话状态
packages/e2b/e2b/README.md ctx.e2b 作为共享生命周期拥有者,保证两个 adapter 在同一远程工作树
packages/AGENTS.md:10 "Design Service Definitions for all current Consumers" 与反向坏味道