
字节笔记本
2026年10月6日 · 约 6 分钟读完
拆解 DeepSeek Harness 的 Bash 执行层
模型会写代码、会改文件,但真正让它在你的机器上干活的,是那条能跑命令的通道。在开源 Agent 框架 DeepSeek Harness 里,这条通道被单独抽成了一条 bash 执行层接缝:模型每次调用 bash 工具,背后都有一整套关于默认值、环境变量、超时、沙箱和后台进程的设计在兜底。这篇文章把这条接缝逐段拆开,看每一环做了什么决定。

一条接缝,四个包
执行层的分工写在四个包里。dsh-shell 只定义服务:ctx.shell 这条抽象接缝,接口是 ShellExecutor 的 resolve、run、start 三个方法外加一个 sandboxMode 能力事实,本身不带任何实现。dsh-bash-local 与 dsh-bash-sandbox 是两套服务提供者,前者跑本机默认实现,后者在受限模式里执行。dsh-tool-bash 是消费方,把 ctx.shell 适配成模型可见的 bash 工具。
上下游也被明确划走:后台任务的编号、归属和通用控制不归这层管,start() 返回的进程句柄没有身份,由 dsh-tool-bash 适配进通用的 job 运行时;进程组管理、有界输出收集、溢出文件、凭证清洗这些底层机制,则压在更底下的 subprocess 接缝里。每块职责单独成包,替换和测试都只动一处。
请求与规格:resolve() 两段式
模型和插件提交的是 ShellExecRequest:workdir、timeoutMs、stdoutMaxBytes 都是可选字段。执行之前,工具层先调用 ctx.shell.resolve(request),由实现方填默认值、加封顶,产出一个字段齐全的 ShellExecSpec,执行器只认规格、不认原始请求。这个两段式对应框架的一条边界规则:包边界上显式优于隐式。默认值归实现所有,调用方不必猜,执行器也不必防御缺字段。
有三个字段只对可信的进程内插件开放,模型侧的 bash 工具一概不暴露。stdin 允许插件向命令写入内容后关闭标准输入,模型想喂输入就用 heredoc 或管道;env 用于注入普通环境变量,比如钩子桥接器要放进去的项目目录、插件根目录;stdoutMaxBytes 让前台消费者在有限预算内拿到完整输出,同时不影响模型侧工具的常规输出上限。
环境变量:三段合并,托管事实最后写
环境变量走三段合并。第一步清洗继承环境:执行器丢弃环境里所有继承来的 DSH_* 变量,并洗掉凭证;第二步合并调用方显式给出的普通 env;第三步才合并本次执行的 DSH_* 托管快照。顺序即优先级:托管事实最后写入,调用方的 env 永远顶不掉它,框架进程里过期的 DSH_* 也绝不会被子进程继承。
这些托管事实由 ctx.shellEnv 注册表收集。每次模型 shell 调用都重建一份快照,内置事实归注册表所有,插件也能注册自己的事实,注册随插件生命周期自动注销。
前台结果:结局正交,不给假成功
前台一次运行的结果是 ShellRunResult,它的核心决定是把互不相关的结局拆成独立字段:exitCode、signal、timedOut、aborted 各说各话。一个进程完全可能既被超时击杀、又因为自己捕获了信号而以退出码 0 收场,调用方若只看退出码,就会把一次被砍断的运行当成干净成功。超时与中止共用同一个熔断时钟,谁先到点谁是第一原因,两个字段互斥,不会同时报真。run 只在基础设施故障时 reject,非零退出、超时击杀、中止击杀都正常返回结果对象。
输出侧同样有安排:stdout 与 stderr 各自是有界收集,超限时文本保留尾部,完整流写进私有溢出文件,需要时可以追回全文。
工具渲染端还有一个共享契约:bash 与 pwsh 两个工具都会在输出尾部附加 [exit code: N] 或 [killed by signal: X] 标记,dsh-shell 导出的 parseExitStatus 负责把标记反解回结构化状态,终端卡片据此拆出输出主体和退出状态徽标。

沙箱:事实独立于退出码
沙箱执行器每次运行都附带一份 ShellSandboxInfo:实际生效的 mode,有没有文件操作被拒(denied),所选 runner 把该模式执行到什么程度(enforcement),以及 runner 是否在命令开跑前就失败(runnerFailed)。这些事实独立于进程退出状态上报,调用方才分得清命令自己失败与策略拦截。
失败封闭是硬规则:受限模式找不到可用后端时,前台直接抛 SANDBOX_UNAVAILABLE,绝不悄悄放行;后台任务没有前台可抛,就把 runnerFailed 记进事实通道。模型收到拒绝事实后,可以通过 sandbox_permissions 附带理由申请一次性放宽,但这要 ctx.approval 先批准那一次具体调用,批准之前什么都不会执行。
后台进程:无身份句柄与增量读取
start() 立即返回 ShellProcess 句柄。done promise 在进程关闭时兑现且永不 reject,spawn 失败也按 killed 结算、把错误写在 stderr;沙箱事实在 done 兑现前就盖好章。readOutput() 是增量读取,连续读不会重复吐旧输出,真丢了数据会置 lossy 标记并给出溢出文件路径。kill() 杀整个进程组,幂等,进程已结束就返回 false。
销毁语义也有讲究:组合销毁时会停掉所有还在跑的后台进程并等它们退出,但只重载执行器的话进程继续活着,这条边界画在 subprocess 服务的处置上。
这些保守决定,正是可靠性所在
回看整条接缝,主线是分层与隔离:不可信的模型调用和可信的插件调用共享同一个执行核,但暴露面不同;结局正交上报,超时、中止、策略拒绝、runner 故障都不与退出码混在一起;托管事实最后合并,策略永远比调用方强势;后台句柄不认身份,生命周期归通用运行时。对一个要长时间自主跑命令的 Agent 框架来说,这类保守决定不是繁文缛节,而是它敢让模型开终端的底气。


