shell 接缝
packages/shell/shell 是仓库中最典型的能力接缝(capability seam)。一个接缝把某项能力拆分为三种角色(见 docs/capability-seams.md):
- 服务定义(Service Definition)——抽象类与词汇表,由接缝包持有,并通过 Cordis 的
declare module增强注册为ctx.<key>服务。 - Provider——以插件加载、实现抽象方法的具体子类。每个上下文恰好挂载一个;再挂一个会抛出标准的 Cordis「重复服务」错误。
- 消费者(Consumer)——依赖该服务、与具体 Provider 无关的工具插件(以及 hooks 桥)。
shell 接缝就是参考范例:ShellExecutor 定义 ctx.shell,四个 Provider 实现它,tool-bash/tool-pwsh 消费它。docs/capability-seams.md 记录的直接消费者有 tool-bash、tool-pwsh、hooks-claude-code、hooks-codex。
tool-bash ──┐ tool-pwsh ──┐
hooks-claude-code ─┤ hooks-codex ────┤ (消费者)
▼ ▼
┌──────────────────────────────┐
│ 服务定义:ctx.shell │
│ ShellExecutor(抽象类) │ @deepseek-ai/dsh-shell
└──────────────────────────────┘
▲
┌──────┬──────────┴──────┬─────────────┐
▼ ▼ ▼ ▼
bash-local │ bash-sandbox pwsh-local ── pwsh-sandbox (Provider)
(LocalBashExecutor) │ (SandboxBashExecutor)
└──┬───┘ │
│ 经 ctx.subprocess 生成进程(subprocess 接缝)ctx.shell 只负责进程句柄。后台任务的 id、会话、所有权、轮询与通知归属 ctx.jobs(@deepseek-ai/dsh-jobs),因此执行器与会话解耦。
各包版本
该组内所有 workspace 包共享被钉住的根版本:
| 包 | 角色 |
|---|---|
@deepseek-ai/dsh-shell | 服务定义(ShellExecutor、ctx.shell) |
@deepseek-ai/dsh-bash-local | Provider:本地 bash -c,基于 ctx.subprocess |
@deepseek-ai/dsh-bash-sandbox | Provider:经 ctx.sandbox 包裹的 bash |
@deepseek-ai/dsh-pwsh-local | Provider:本地 PowerShell,基于 ctx.subprocess |
@deepseek-ai/dsh-pwsh-sandbox | Provider:经 ctx.sandbox 包裹的 PowerShell |
@deepseek-ai/dsh-shell-env | Core:ctx.shellEnv,受管理的 DSH_* 事实 |
@deepseek-ai/dsh-tool-bash | 消费者:面向模型的 bash 工具 |
@deepseek-ai/dsh-tool-bash-persistent | 消费者:基于 PTY 接缝的持久 bash 工具 |
@deepseek-ai/dsh-tool-pwsh | 消费者:面向模型的 PowerShell 工具 |
执行契约
ShellExecutor(packages/shell/shell/src/index.ts)暴露三个抽象方法外加一个 sandboxMode getter:
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
}流程是请求 → resolve → run/start。调用方传入原始 ShellExecRequest(command 以及可选的 workdir/timeoutMs/stdoutMaxBytes/signal/stdin/env/dshEnv/sandboxPolicy);resolve() 填充默认值并给超时加上限。工具层总是在 run/start 之前调用 resolve,因此后面两个方法读到的都是显式取值。
前台:一次 shell 执行返回什么
ShellRunResult(packages/shell/shell/src/types.ts)是前台一次运行 resolve 出的结构:
export interface ShellRunResult {
exitCode: number | null // 被信号杀死时为 null
signal: NodeJS.Signals | null
timedOut: boolean // 执行器自己的超时是最先触发的因
aborted: boolean // 调用方 AbortSignal 是最先触发的因
timeoutMs: number // 取默认值/上限后的有效超时
stdout: CollectedOutput
stderr: CollectedOutput
sandbox?: ShellSandboxInfo // 未沙箱化执行器时为缺省
}请注意这里的错误纪律(见 docs/defensive-patterns.md):run 只针对基础设施类故障 reject。非零退出、超时杀死、中止杀死都会resolve 出一个描述性的 ShellRunResult。timedOut 与 aborted 互斥——由一个融合的截止时间(@deepseek-ai/dsh-timeout)驱动二者,最先触发的因胜出。
输出捕获与保留
CollectedOutput 从 subprocess 接缝再导出,而保留(retention)策略来自共享库 @deepseek-ai/dsh-output-retention(packages/util/output-retention)。它明确不是 Cordis 服务或插件——不接收 ctx、不注册任何东西、不发任何事件。它提供两个有状态的保留器:
ItemRetainer<T>——为逻辑单元流(路径、grep 命中、搜索来源)设置上限。v1 只支持head策略(maxItems)。TextRetainer——为面向字节的文本流设置上限,策略为head/tail/headTail,在内存中滚动保留有界后缀,并在每次截断处修剪不完整的 UTF-8 码点,使返回的文本不会因截断本身引入替换字符。
两者都上报精确的 Omitted 计数;formatRetentionNotice 把它拼成标准页脚。工具专属的「恢复提示」(收窄模式、读取 spill 文件)留在工具里——库只负责机制。
另外,bash-local 的配置带有每条流的 spill 上限(maxSpillBytes,默认 64 MiB):内存中最多保留 maxOutputBytes(默认 64_000)字节,超出 spill 上限后只保留内存尾部并上报截断。
后台进程
start(spec) 同步返回一个 ShellProcess 句柄:
export interface ShellProcess {
status: ShellProcessStatus // 'running' | 'completed' | 'killed'
exitCode: number | null
signal: NodeJS.Signals | null
readonly done: Promise<void> // 永不 reject;spawn 失败以 'killed' 收场
sandbox?: ShellSandboxInfo
readOutput(): ShellProcessRead // 增量、消耗性;lossy 读取会标记 spill 路径
kill(): boolean
}后台运行忽略 timeoutMs(执行器不设超时);调用方通过 kill() 或 spec 的 signal 停止。readOutput 增量且消耗性——连续读取不会重复投递输出;一次丢失了未读字节的读取会置 lossy 并指向完整流的 spill 文件。仍在运行的后台进程会在其所属 composition 拆除时被杀死并等待。
Provider:本地 vs 沙箱化
LocalBashExecutor(packages/shell/bash-local/src/index.ts)经 ctx.subprocess 生成 bash -c <command>,并负责命令默认值、截止时间、对模型友好的终端环境,以及 stdout/stderr 合并。要点:
- 环境覆盖(
ENV_OVERRIDES):NO_COLOR=1、TERM=dumb、PAGER=cat、GIT_PAGER=cat——禁用会让工具输出变乱的分页/颜色。 - 配置(
static Config):cwd、timeoutMs(默认120_000)、maxTimeoutMs(600_000)、maxOutputBytes(64_000)、maxSpillBytes(64 MiB)、graceMs(3_000,SIGTERM→SIGKILL 宽限期)。 - 环境分层:spawn 合并
{...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv},所以受信任的受管理DSH_*快照既压过调用方 env 也压过终端覆盖。subprocess 服务会先应用它自己的环境凭据清理。 LocalBashExecutor.runArgv/startArgv标记为protected,便于子类在执行边界替换公开的bash -cargv。
SandboxBashExecutor(@deepseek-ai/dsh-bash-sandbox)继承 LocalBashExecutor,并经 ctx.sandbox 包裹精确 argv。它原样继承本地配置;沙箱策略(默认模式 + workspace 根)位于 ctx.sandboxPolicy,而 runner 的选择是 ctx.sandbox Provider 的配置。resolve() 盖上完整的逐调用策略;run()/start() 上报 result.sandbox = { mode, denied, enforcement?, runnerFailed? }。runner 启动失败会让前台调用抛出 SANDBOX_UNAVAILABLE,而后台进程则携带 runnerFailed。pwsh-local/pwsh-sandbox 这对在 Windows 层镜像 bash 版,替换掉 POSIX 行。
三种沙箱模式(packages/sandbox/sandbox/src/index.ts):read-only、workspace-write、danger-full-access。强制执行的机制(本地 Landlock/Seatbelt/bwrap runner,以及 E2B 远端底座)见 沙箱架构。
面向模型的工具
@deepseek-ai/dsh-tool-bash 通过 defineTool 注册 bash 工具。其解析后的 BashToolArgs:
interface BashToolArgs {
command: string
description: string
timeoutMs?: number
workdir?: string
run_in_background?: boolean
sandbox_permissions?: string // 仅在存在会约束的执行器时对外公布
justification?: string
}执行语义(编码在模型看到的工具描述里):
- 每次调用都在全新 shell 中运行——
cwd/变量/函数均不保留;请传workdir而不是用cd。 - 非零退出渲染为
[exit code: N];正常退出无标记。 run_in_background: true立即返回一个 job id;用job_output/job_kill经ctx.jobs管理。- 沙箱拒绝渲染为
[sandbox: file access denied under <mode> mode];升级(escalation)会用sandbox_permissions加justification把同一条命令重跑一次,经ctx.approval的approveEscalation走审批。 - 长输出截断到尾部,完整输出保存到文件并上报路径(
[output truncated; full output: <spill>])。
渲染器会标记超时或信号:先是 [timed out after <ms>ms],然后是非零时 [killed by signal: X] 或 [exit code: N]。renderResult/renderProcessRead 位于 packages/shell/tool-bash/src/render.ts。退出标记契约是共享的:parseExitStatus(在 @deepseek-ai/dsh-shell/render)从渲染字符串中恢复 { exitCode } 或 { signal },因此终端展示可以把回放的运行结果渲染成真正的退出标记胶囊。
@deepseek-ai/dsh-tool-pwsh 为 PowerShell 镜像 bash 工具。
shell-env:受管理的 DSH_* 事实
@deepseek-ai/dsh-shell-env 持有 ctx.shellEnv——一个由 shell 工具消费的受信任、按执行生效的 DSH_* 变量注册表。内置事实(如 DSH_HOME,经 DSH_HOME 或 ~/.dsh 解析)由注册表自身持有;插件可注册带 effect 作用域清理的额外可枚举事实。每个 shell 工具在每次执行时收集一个可信快照,执行器再重建命名空间(DSH_ENV_PREFIX 位于 subprocess 接缝)。subprocess 服务在合并显式层之前,会先清理凭据形态的环境名(SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i)以及环境里残留的 DSH_* 名。
终端接缝(持久化 PTY)
packages/terminal/terminal 定义 ctx.terminals——一个按所有者划分的持久 PTY 注册表。与一次性的 bash 工具不同,PTY 会话在多次调用之间保留。后端负责终端机制;注册表负责 id、发布、授权(每个会话都绑定到它的 owner: Agent)以及等待清理。
ctx.terminals接缝的所有者是terminal,Provider 是terminal-bash,消费者是tool-terminal(据docs/capability-seams.md)。- 后端接口:
TerminalBackend+TerminalBackendSession。后端经ctx.subprocess.spawnTerminal生成,并执行共享沙箱围栏。 - 模型表面上允许的信号:
SIGINT | SIGTERM | SIGKILL | SIGTSTP | SIGHUP。 - 错误分类:
TerminalErrorCode——DUPLICATE_BACKEND、DUPLICATE_NAME、FOREIGN_SESSION、NO_BACKEND、NO_SESSION、OWNER_NOT_LIVE、SEND_ACTIVE、SERVICE_DISPOSING。
tool-terminal(@deepseek-ai/dsh-tool-terminal)暴露四个按所有者划分的工具:terminal_open、terminal_send、terminal_read、terminal_signal(外加一种后台发送形态)。发送是面向行的:TerminalSendRequest { text, submit, signal? };一次 settle 返回 viewport 外加 waitReason(stdin_read | inferred_idle | timeout | session_exit)。
@deepseek-ai/dsh-tool-bash-persistent 是 PTY 接缝的另一个模型侧消费者:它注册一个 bash 工具,用回显标记(__DSH_PERSISTENT_BASH_START_<nonce>__ … __DSH_PERSISTENT_BASH_END_<nonce>:<status>)和提示符(__DSH_PERSISTENT_BASH_PROMPT__)包裹每条命令,因此对于一个 agent,状态(cwd、导出的变量)在多次调用间保留,而输出被定界并截断到 maxOutputChars。
terminal 对比持久 bash
tool-bash(一次性) | tool-terminal(PTY) | bash 持久版 | |
|---|---|---|---|
| 跨调用状态 | 无 | 完整 PTY 画面 | cwd + 导出 env |
| 底座 | ctx.shell(执行器接缝) | ctx.terminals(PTY 注册表) | ctx.terminals |
| 模型工具 | bash | terminal_open/send/read/signal | bash(持久) |