Shell 执行能力缝(ctx.shell)
一句话版:
ctx.shell是 DSH 的 bash 能力层服务缝:它定义「运行一条前台命令、启动一个后台进程」要做什么,但把怎么实现留给下层 provider。run()只因基础设施失败而 reject、其余情况都以描述性ShellRunResult求解;start()立即返回ShellProcess句柄、不套用前台超时;readOutput()增量读取、丢失标lossy并指向 spill 文件。子进程(怎么跑)与 bash(跑 bash 的语义)由此分居两层。
bash 工具、hooks 桥接、沙箱执行器都建立在这个能力缝上。这一篇讲清「DSH 怎么跑 bash」。
与 子进程 的关系:
ctx.subprocess是进程原语——可执行查找、受管 spawn、收集 stdio、树级终止;ctx.shell是bash 能力层缝——命令默认语义、deadline、因果分类、模型友好输出叠加。bash 建在 subprocess 之上,二者不互相替代。
一、是什么
ctx.shell 是 ShellExecutor 服务定义缝:抽象类只声明 run()、start()、resolve() 与能力 getter sandboxMode,自己不碰进程实现。job id、所有权、收集、取消与通知归通用的 ctx.jobs 运行时。
| 包 | ctx key | 角色 |
|---|---|---|
shell | ctx.shell | 服务定义:抽象 ShellExecutor + 词汇类型(ShellExecRequest / ShellExecSpec / ShellRunResult / ShellProcess),导出 SHELL_SETTINGS_NAMESPACE 与 parseExitStatus |
bash-local | —(ctx.shell 本地 provider) | 本地子进程执行器:bash -c、命令默认、deadline、因果分类、模型友好环境 |
bash-sandbox | —(ctx.shell 沙箱 provider) | 沿用 bash-local 机制,但把 argv 经 ctx.sandbox 包进 confine,并把拒绝/runner 失败报成结果事实 |
tool-bash | —(模型面工具 bash) | 基于 ctx.shell、面向模型的工具 schema 与渲染约定 |
bash-sandbox是与bash-local落在同一服务缝之后的沙箱执行器;tool-bash检测它的sandboxMode能力来公告升权字段,无需导入提供方。这是标准的能力缝(capability seam)拆法,容器化/远程执行器可以同样接入。
二、服务 API(ctx.shell)
| 成员 | 语义 |
|---|---|
resolve(request) | 由请求填充/封顶为完全指定的 ShellExecSpec(workdir、timeoutMs、stdoutMaxBytes…),run/start 只收到已解析 spec |
run(spec) | 前台执行,完成时 resolve。只因基础设施失败而 reject(工作目录不可用、shell 缺失、signal 调用前已中止);非零退出、超时终止、中止都以描述性 ShellRunResult 求解 |
start(spec) | 后台执行,立即返回 ShellProcess 句柄;不应用超时。调用方可用 kill()/readOutput()/done 把它适配到 ctx.jobs |
get sandboxMode() | 能力事实:沙箱执行器的默认模式(基类返回 undefined=不使用沙箱);tool-bash 据此仅在支持升权时公布 sandbox_permissions/justification 字段 |
ShellProcess.kill() | 终止进程组;进程已结束则返回 false |
ShellProcess.readOutput() | 增量读取:连续读取绝不重复交付;因缓冲区容量丢数据的读取标 lossy,并指向完整流 spill 文件 |
实现继承 ShellExecutor 并实现抽象方法;dispose 必须终止并等待仍在运行的进程。
前台超时始终由执行器负责,
start()明确忽略timeoutMs:后台进程只经kill()或 spec 的AbortSignal停止。
三、run 的求解语义(不 reject)
// packages/shell/shell/src/index.ts
/**
* Run a command in the foreground; resolves when it finishes.
* @returns the outcome; nonzero exits, timeout kills, and abort kills
* resolve with a descriptive result rather than reject.
*/
abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
ShellRunResult 把退出事实与首个中止原因合并成一个字段对:
// packages/shell/shell/src/types.ts
export interface ShellRunResult {
exitCode: number | null // 进程被信号杀死时为 null
signal: NodeJS.Signals | null
timedOut: boolean // 执行器自身超时是首个中断原因
aborted: boolean // 调用方 AbortSignal 是首个中断原因
timeoutMs: number // 本跑实际生效的超时(默认/封顶后)
stdout: CollectedOutput
stderr: CollectedOutput
sandbox?: ShellSandboxInfo // 仅沙箱执行器会出现
}
timedOut 与 aborted 互斥:单一 deadline 同时驱动超时与取消,结束时只报 first-abort 原因。bash-local 里裁判逻辑为:
// packages/shell/bash-local/src/index.ts
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 timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
const aborted = d.signal.aborted && !timedOut
四、start:后台句柄
start() 是 run() 的后台对偶:立即返回 ShellProcess,done 在进程关闭时 settle 且从不 reject(spawn 失败 settle 为 killed,并把错误放在 stderr)。后台进程在宿主组合 teardown 时被终止并 join,因此即使执行器热重载,进程仍受 subprocess 缝管理。
readOutput() 每次返回自上次读取以来的增量(stderr 用 [stderr] 段标记),并把 stdout/stderr 的尾截断合并成一个 lossy 标志,stdoutSpillPath/stderrSpillPath 指向完整流 spill 文件:
// packages/shell/shell/src/types.ts
export interface ShellProcessRead {
delta: string // 自上次读取以来新增的输出
lossy: boolean // 截断丢弃了未被 delta 覆盖的字节
stdoutSpillPath?: string
stderrSpillPath?: string
}
五、工具层能力公告(sandboxMode)
sandboxMode 抽象 getter 是组合能力事实。基类默认返回 undefined(=此执行器不使用沙箱);bash-sandbox 覆盖它并返回 ctx.sandboxPolicy.defaultMode:
// packages/shell/bash-sandbox/src/index.ts
override get sandboxMode(): SandboxMode {
return this.mode // 构造时取 ctx.sandboxPolicy.defaultMode
}
tool-bash 在注册时读取它:
// packages/shell/tool-bash/src/index.ts
const defaultMode = ctx.shell.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
const sandboxPolicy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
defaultMode === undefined→ 不公布sandbox_permissions/justification参数(本地无沙箱执行器);defaultMode !== undefined→ 公布升权字段 =ESCALATION_TARGETS,并要求ctx.sandboxPolicy已挂载(缺则加载时抛错);- 升权走
approveEscalation,通过ctx.approval在任何执行前完成 fail-closed 序列。
六、设置命名空间与环境 overlay
SHELL_SETTINGS_NAMESPACE(bash) 由服务定义导出,而非某个 provider——因为它命名的是能力而非实现。一个宿主只组装一个 ctx.shell provider:
// packages/shell/shell/src/index.ts
export const SHELL_SETTINGS_NAMESPACE = settingsNamespace('shell')
win32 层把 POSIX 行换成 pwsh 行(
pwsh-local以pwsh -NoLogo -NoProfile -NonInteractive -Command …调用),同时挂载两者会因服务重复注册而在加载期快速失败;所以各平台 provider 用同一 schema 注册这同一个命名空间,永不相撞,跨平台携带的settings.yaml在两边都能继续解析。
受类型限制的信任环境 overlay。执行环境有三条输入通道(两条普通 + 一条受管):
| 通道 | 来源 | 语义 |
|---|---|---|
普通 env | hooks 桥接、原生插件(CLAUDE_PROJECT_DIR、CLAUDE_PLUGIN_ROOT) | 在凭据清除之后 merge;模型面工具不暴露为参数 |
dshEnv | ctx.shellEnv.collect() 收集的受管快照 | 类型为 DshEnvironmentKey=`${'DSH_'}${string}`;在 env 之后 merge,受管 DSH_* 不可被顶掉 |
ENV_OVERRIDES | bash-local 常量 | 模型友好条目 NO_COLOR=1、TERM=dumb、PAGER=cat 等,最先 merge,信任调用方自身条目仍胜出 |
最终 spawn 的显式 env 是分层合并的,随后才由 subprocess 服务再做一次凭据清除:
// packages/shell/bash-local/src/index.ts
env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv },
因为受管 key 在最后 merge,所以省略的当前事实不会回退到陈旧环境值,env 条目也无法顶掉 DSH_*。dshEnv 在已解析 spec 上仍可选;缺失表示没有 overlay。
七、parseExitStatus:marker 逆解析
parseExitStatus(连同 ParsedExitStatus)是 shell 工具共享渲染约定的另一半:dsh-tool-bash 的 renderResult 与 dsh-tool-pwsh 的 renderPwshResult 都追加 [exit code: N]/[killed by signal: X] marker。把解析放进服务定义,两个工具就永远不会在 marker 约定上漂移。
// packages/shell/shell/src/render.ts
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
return { body: text, exitCode: 0 }
规则:带 [killed by signal: X] → 返回 signal;非零 [exit code: N] → 返回 exitCode;两者皆无 → 干净退出 0。已消费的 marker 从 body 移除,因为终端卡把退出状态渲染成独立 pill,留在正文会重复。要求前导换行 + 字符串结尾,避免普通输出恰好以 marker 样文本结尾被误匹配。
八、已知限制与暂缓事项
- 没有交互式输入词汇:
stdin只会在 spawn 时写入一次并关闭;缝不提供对运行中进程继续输入的通道,也没有 PTY 会话概念。 - 前台超时始终由执行器负责:缝上由调用方负责 deadline 的模式已由工具调用超时策略 Agent Note 明确暂缓。
九、验证
# 看 bash 执行器/工具是否装载
dsh web --dump-config | grep -iE "shell|tool-bash"
# 会话里看 bash 子进程(透传)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -E '"bash' | head
# 在会话里跑一条命令、观察 exit marker 与 job 语义
# 前台:非零退出以描述性结果返回,不 reject
bash -c "exit 3" # → [exit code: 3]
# 后台:立即返回 job id,不应用前台超时
bash -c "sleep 30" # run_in_background: true → 得到 jobId,job_output / job_kill