DeepSeek Harness 的 Bash 执行器:一条命令从下发到回显的完整链路
模型决定执行一条 ls,随后会话里出现一段输出。中间这一路到底经过了几层、超时是谁定的、输出被截断了谁负责告诉模型——这些问题在 DeepSeek Harness 里都能在 packages/shell/ 下找到白纸黑字的答案。这篇就顺着一条前台命令走一遍,把途中每个能查到的坐标标出来。
先说一句限定,后面的默认值都建立在这句话上:该项目 README 自述处于开发者预览阶段,并明确写着「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」(会有破坏兼容性的变更)。下面提到的字段名、默认值、错误文案,都对应我们核对的这份仓库快照,随时可能变。
这条链路被切成了三段,不是一坨
docs/subsystems/shell.md 开篇就把 bash 执行这条 seam 明确切成三段:Service Definition 是 packages/shell/shell(挂在 ctx.shell 上),Service Provider 是 packages/shell/bash-local 与 packages/shell/bash-sandbox,Consumer 是 packages/shell/tool-bash——也就是模型看到的那个 bash 工具。packages/shell/ 下我们数出 9 个包,除了上面四个,还有 shell-env、tool-bash-persistent,以及 Windows 侧对应的 pwsh-local、pwsh-sandbox、tool-pwsh。
这个切法的直接后果是:默认值不在工具层。工具层只管从模型给的参数拼一个请求,真正的超时、输出上限、工作目录兜底全在执行器那边。想改行为的时候,找错层就会白忙一场。
第一站:tool-bash 的 execute 做了什么
入口是 packages/shell/tool-bash/src/index.ts 里 defineTool 注册的 bash 工具。它对模型暴露的参数只有这几个:command、description(都是必填)、timeoutMs、workdir,以及条件出现的 run_in_background、sandbox_permissions、justification。
validateBashArgs 先做值域校验,错误文案是固定的:空命令报 invalid command: expected a non-empty string,timeoutMs 不是正数报 invalid timeoutMs: expected a positive number, got <值>。这几句在排查日志时是可以直接拿去搜的锚点。
然后是 resolveWorkdir 这个函数,它解决的是「N 个会话共用一个执行器」的问题。它优先用已解析的 sandbox 策略里的 workspaceRoot,其次用调用方 agent 的 session.header.cwd;模型给的 workdir 如果是相对路径,就以这个会话身份为基准解析。也就是说,会话级 cwd 是在工具层补的,不是在执行器补的——packages/shell/tool-bash/README.md 把这一点专门写了出来:只有在拿不到会话 cwd 时,才轮到执行器用自己的 config.cwd 或 process.cwd() 兜底。
顺带一提工具描述里那句给模型的硬约束:每次调用都是全新的 shell,cwd、变量、函数都不跨调用保留,要换目录得传 workdir 而不是写 cd。packages/shell/bash-local/src/index.ts 里还留着一条 XXX(stateful-shell) 注释,写的是「evaluate persistent cwd or PTY sessions when workflows require shell state」——这是一条 XXX 待评估项,不是已有能力,别当成「以后一定会做」。真需要持久 shell 的场景,仓库里另有 packages/shell/tool-bash-persistent 这个独立的包,走 ctx.terminals,那是另一条路。
第二站:resolve()——默认值全在这一步落地
工具层拼好请求后调 ctx.shell.resolve(request)。这个 request / spec 的拆分是这条 seam 里最值得注意的设计:ShellExecRequest 的 workdir、timeoutMs、stdoutMaxBytes 都是可选的,ShellExecSpec 里全是必填。文档自述这是仓库「包边界处显式优于隐式」规则的体现,run() 和 start() 拿到的一定是补全过的值,绝不会二次兜底。
packages/shell/bash-local/src/index.ts 里的 LocalBashExecutor.Config 是查默认值的唯一位置,逐条抄下来:
| 配置项 | 默认值 | 作用 |
|---|---|---|
timeoutMs | 120_000 | 前台默认超时(毫秒) |
maxTimeoutMs | 600_000 | 单次 timeoutMs 覆盖的上限 |
maxOutputBytes | 64_000 | 每条流的内存输出上限,溢出写 spill 文件 |
maxSpillBytes | 64 * 1024 * 1024 | 每条流的 spill 文件上限 |
graceMs | 3_000 | SIGTERM→SIGKILL 的宽限期 |
cwd | 无默认 | 缺省时回落到 process.cwd() |
超时的合并逻辑在 clampTimeout(packages/util/timeout/src/index.ts),它的 JSDoc 写得很直白:返回 min(requested ?? def, max)。所以模型传一个特别大的 timeoutMs 并不会真的生效,会被 maxTimeoutMs 削平;传 0 或负数则直接抛错,注释里明说零不是「关闭超时」的哨兵值。
这里有个很容易踩的坑:上面这张表是代码里的默认值,不等于你实际跑到的值。packages/bundle/base/cordis.patch.yml 里 bash-sandbox 这一行带了 config: timeoutMs: 60000,也就是随包发出去的组合里,前台默认超时是 60 秒而不是 120 秒。要判断自己那套组合到底用的哪个数,得回去看实际挂载的那一行 config,而不是看类上的 Config。另外 packages/shell/shell/src/index.ts 导出了 SHELL_SETTINGS_NAMESPACE = settingsNamespace('shell'),bash-local 在构造时把这套 config 装成了一个 settings section,并且注释写明每个字段都在每条命令执行时通过 getter 读取——意味着这些值是可以在运行期被改的。
第三站:真正落到 bash -c
run() 的实现只有一行:return this.runArgv(spec, ['bash', '-c', spec.command])。也就是说命令是整串交给 bash -c 的,工具层不做任何 shell 解析。
runArgv 里用 deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') 把「执行器自己的超时」和「上游取消」熔成同一个 deadline,然后靠 timeoutOf(d.signal, 'BASH_TIMEOUT') 反查是谁先触发的。结果里的 timedOut 和 aborted 因此是互斥的:只有本执行器自己的超时才算 timedOut,外层 deadline 算 abort。而 exitCode、signal、timedOut、aborted 四个字段各自独立上报——docs/subsystems/shell.md 给的理由是,一个进程完全可能捕获信号后以退出码 0 结束,同时它确实超时了,字段拆开调用方才不会把被打断的运行读成正常成功。
环境变量的合并顺序在 spawnSpec 里,一眼就能看清:env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }。ENV_OVERRIDES 是导出的常量,内容是 NO_COLOR=1、TERM=dumb、PAGER=cat、GIT_PAGER=cat,注释自述目的是关掉颜色和分页器这些会把工具输出弄乱的终端特性。三层里 dshEnv 最后合并,所以受管的 DSH_* 变量压得住前两层。DSH_ENV_PREFIX 的定义在 packages/subprocess/subprocess/src/types.ts,值就是 'DSH_';内建的几个键在 packages/shell/shell-env/src/index.ts,README 列的是 DSH_HOME、DSH_SHELL=1、DSH_SESSION_ID,以及持久化后端能定位到 JSONL 时的 DSH_SESSION_JSONL。执行器会先丢掉继承来的 DSH_* 再合并当前快照——文档给的理由是嵌套 harness 与并发的父子 agent 不能互相漏出旧身份。
还有两个字段是模型碰不到的:stdin 和 env 只留给进程内的可信插件(文档举的例子是 hooks 桥接把 JSON 负载写进 stdin),stdoutMaxBytes 同理。模型要用 stdin 就只能在命令里写 heredoc 或管道。
回程:标记契约
命令跑完,packages/shell/tool-bash/src/render.ts 的 renderResult 把结果拼成模型看到的文本:stdout,然后是带 [stderr] 标头的 stderr 段,最后才是各种标记。标记的文本是逐字固定的:
[output truncated; full output: <路径或 (unavailable)>][sandbox: file access denied under <mode> mode][timed out after <timeoutMs>ms][killed by signal: <signal>][exit code: <exitCode>]
顺序有讲究,退出标记必须排在最后一行。原因写在 packages/shell/shell/src/render.ts 的 parseExitStatus 里:它用 /\n\[exit code: (\d+)\]$/ 这种锚定字符串末尾的正则把退出状态从正文里切出来,交给终端卡片当独立的 pill 显示,否则退出码会渲染两遍。packages/shell/tool-bash/README.md 的「已知残留」一节里也老实写了这条契约的代价:如果命令本身的输出最后一行恰好长得跟标记一模一样,回放时就会解析出错误的 pill,并且那行会从正文里消失。
另外注意,非零退出不是错误。renderResult 的注释和 README 都写明,只有 spawn 失败、abort 这类基础设施故障才产生 isError;命令返回 1 只是一个结果,交给模型自己判断。packages/shell/tool-bash/src/index.ts 里还注册了一段 order 105 的 system prompt:Check the [exit code: N] marker on every bash result; investigate failures before moving on.
后台那条分叉
传 run_in_background: true 走的是另一条路:ctx.shell.start() 返回一个不带 id 的 ShellProcess 句柄,工具层把它注册进 ctx.jobs,id、归属、生命周期都归通用任务运行时管。这条路上有几条硬约定:后台进程没有执行器超时(startArgv 的注释直说 background runs ignore timeoutMs),要停只能靠 job_kill 或所有者/服务释放;readOutput() 是增量消费式的,连续两次读不会重复吐同样的内容;读到丢数据时标 lossy 并给出 spill 文件路径。
packages/shell/tool-bash/src/background.ts 里挂着一条 TODO(background-infrastructure-outcome),写的是后台 spawn 失败目前会跟「没有信号的 kill」混在一起,需要给 ShellProcess 加一个显式的基础设施失败结果。这是仓库自己标出来的未完成项,照实记着就行。
Windows 上这套根本不挂
本站读者用 Windows 的多,这一段不能省。packages/bundle/base/cordis.patch.yml 里,tool-bash 那一行写着 disabled: !!js process.platform === 'win32',tool-pwsh 那一行写的是 disabled: !!js process.platform !== 'win32';执行器侧 bash-sandbox 与 pwsh-sandbox 也是同样一对互斥的门。所以在 Windows 上模型拿到的不是 bash 工具,而是 packages/shell/tool-pwsh 提供的那一个,执行器换成 pwsh-local / pwsh-sandbox。上面讲的 bash -c、ENV_OVERRIDES 这些属于 bash 侧的细节,不要直接搬到 Windows 侧去推断——pwsh 那几个包我们本篇没有展开核对。
两处文档与源码对不上的地方
翻这个仓库时遇到两处口径不一致,按事实记录,不作评价:
其一,packages/shell/tool-bash/README.md 写这个插件的注入是 inject: ['tools', 'bash', 'systemPrompt', 'bashEnv'],而 packages/shell/tool-bash/src/index.ts 里导出的是 export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']。两者不一致,以源码为准。
其二,同一份 README 在「稳定错误文案」清单里列了 background execution is disabled for this bash tool,而源码里在 enableRunInBackground: false 时抛的是 run_in_background is disabled for this deployment (enableRunInBackground: false)。要拿错误文案去搜日志或写断言的话,以源码那句为准。
最后:沙箱那一层不要读成保险
packages/shell/bash-sandbox/src/index.ts 里的 SandboxBashExecutor 继承自 LocalBashExecutor,把同一串 argv 交给 ctx.sandbox.confine(['bash', '-c', command], policy) 包一层,然后把 mode、denied、enforcement、runnerFailed 这些事实回填进结果。值得注意的是 mode === 'danger-full-access' 时它直接走父类的 run(),只在结果上标一个 denied: false——这一档本身就不做限制。升级请求的目标模式是封闭词表,packages/sandbox/sandbox/src/escalation.ts 里 ESCALATION_TARGETS 的值是 ['workspace-write', 'danger-full-access']。
需要说清楚的是:这套机制是在本机上真实起子进程执行命令的,沙箱只管辖文件效果(docs/subsystems/shell.md 原文即如此界定),tool-bash/src/index.ts 顶部还挂着一条 TODO(permissions),写明部署级策略应当归 tools/pre-execute 与沙箱执行器。存在沙箱不等于装上就安全,具体到你的部署边界在哪,得回去看你实际挂载的 ctx.sandbox 提供方与 sandbox-policy 那几行配置。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。