深入浅出 DeepSeek Harness 第三部:工具执行——不是调一个函数那么简单 Chapter 20

同一个运行时,在另一台主机上

文件系统、子进程和沙箱整体搬到远端,Harness 与会话留在本机:SSH 执行环境。一条 OpenSSH 控制主控承载管理请求,每个进程的字节流各自经独立转发通道回本机,远端 helper 校验摘要后才会把执行世界打开。

Written in Chinese

Source version
dsh-v0.1.7-rc.2
Verified on
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,一个要跑在远端机器上的会话发起第一次 bash。客户端(SshConnection)在 /tmp/dsh-ssh-* 下建控制目录,spawn('ssh', ['-T', '-M', '-S', controlPath, ...]) 拉起一条 OpenSSH 控制主控,远程命令是 node --disable-sigusr1 <helper>。helper 起一个 SshRpcPeer,等客户端发 hello:协议版本、workspace、租约时长。客户端收到回包,先看 hello.hash 是不是等于配置的 helperHash、hello.bootstrapHash 是不是等于 bootstrapHash——任一不符,连接当场拒绝。握手过了,心跳按租约的节奏开始跳。这条 SSH 连接只承载管理请求;每个进程的输出字节,走的是各自独立的转发通道,各自带一把只有这条连接和 helper 知道的钥匙。

这一章接着第 18 章的最后一节:ctx.subprocess 有一个走 SSH 的实现。把文件系统、子进程、沙箱整体搬到另一台主机,本机只留 Harness 与会话——执行世界的边界从”本机沙箱”变成”一条已验证的 SSH 连接”。

一条控制主控,承载管理面

连接只建一次,用 OpenSSH 的控制主控(-M -S):第一次 ssh 拉起主控进程,之后的 ssh -S controlPath ... 命令都走这条已认证的通道。启动旗标都是写死的:

ssh -T -M -S <controlPath>
  -o ControlPersist=no          # 主控不驻留,会话结束即退
  -o BatchMode=yes              # 不提示密码,靠部署的密钥
  -o StrictHostKeyChecking=yes  # 已知主机校验,不接受新指纹
  -o ForwardAgent=no            # 不转发 agent,凭据不越界
  -o ClearAllForwardings=yes    # 清掉历史转发,只留我们建的
  -o ServerAliveInterval=10 -o ServerAliveCountMax=3   # TCP 层存活探测
  <host>  'node --disable-sigusr1 <helper>'

这些旗标是一个决定接着一个决定的:BatchMode 把认证锁死在部署配置的密钥上(模型没有任何参数能选择登录身份——Config 注释原话:“no model argument selects these values”);ForwardAgent=no 是因为控制主控一旦起在远端,转发的 agent 就等于把本机的 SSH 私钥借给了远端进程;ClearAllForwardings=yes 是防止历史转发残留把数据面开错口;ControlPersist=no 是不让主控在 helper 退出后继续驻留远端。

控制主控在 start() 里配好,SshRpcPeer 直接接在 child.stdout/child.stdin 上——管理面就是 OpenSSH 的 exec 通道:helper 的 runSshHelper 把 RPC 输入接到 transport 的 input/output(“Authenticated request bytes from OpenSSH”)。认证是 SSH 层做的(密钥 + 已知主机),管理面不需要再自证身份;helper 的摘要才是它自己的身份。

SSH 执行环境:客户端一条控制主控(ssh -T -M -S,BatchMode/StrictHostKeyChecking/ForwardAgent=no/ServerAlive)跑远程 node --disable-sigusr1 helper;控制主控承载管理面 SshRpcPeer(4 字节长度前缀帧、64 MiB 帧、pending 128、写尾串行),hello 握手双摘要校验(hash/helperHash、bootstrapHash),心跳 leaseMs/3、超时 leaseMs/2。数据面每进程每通道独立:ssh -O forward -L + TLS-PSK(PSK-AES256-GCM-SHA384、TLSv1.2),每流 randomBytes(32) 能力密钥;stdout/stderr 再套 RPC 传输出快照,stdin/control 原始 duplex。远端 helper 用同一套本地实现,管理类请求保留配额;进程四步 prepare→start→done→terminate,句柄与文本流各 128。

本节源码依据(6 处)

版本化 JSON-RPC:帧、配额与取消

管理面是 SshRpcPeer——一个有界的、版本化的请求通道(SSH_PROTOCOL_VERSION = 1)。帧格式简单:4 字节大端长度前缀 + JSON 体。四种帧:request(id/method/params)、result、error(name/message/code)、cancel。约束写死在构造与收发路径上:

约束值说明
帧上限maxFrameBytes = 64 MiB单帧超限即拒;读侧 `size === 0
写队列上限maxFrameBytes * 2排队字节 + 新帧超限即拒,防止慢读拖爆内存
普通请求配额maxPending = 128ordinary 类共享;heartbeat/close 各 1、process.terminate/fs.streamClose 各 128 是保留配额
写序写尾串行化每帧按序入队,drain 等待,失败即 close
校验zod schema结果/错误帧 parse 通过才交调用方,错误保留 typed code

两个语义值得单独说。取消不重放:abort 时本地 reject 请求并送 cancel 帧,但配额要等远端回包才释放——“Keep the credit until the remote handler replies; cancellation does not mean its resource cleanup has finished”,因为取消不代表远端清理完了。提示语也把边界写死:SSH operation cancelled; a completed remote mutation is not rolled back。断开即失败:close() 用一句话概括全部在飞操作的结局——SSH connection lost; remote operation outcome and cleanup are unknown;pending 全 reject、active 全 abort、流销毁。不做重连:单次会话,丢了一切作废。

request() 还有一层超时策略:管理请求默认 30 秒超时(requestTimeoutMs),wait: true 的进程观察请求不受管理期限约束(进程可以比管理调用活得久)。

本节源码依据(6 处)

数据面:每进程每通道独立转发

管理面只有请求,没有字节流。一个远程进程的 stdin/stdout/stderr/control/terminal 五条通道,每一条都是独立的 SSH 转发:

  1. process.prepare 在远端为每个通道建一个私有监听端点(Unix socket path + capability = randomBytes(32) 十六进制),经管理面把 { path, capability } 发回客户端——“Allocate private stream listeners; no target executes until start()”;
  2. 客户端对每个端点 controlCommand(['-O', 'forward', '-o', 'ExitOnForwardFailure=yes', '-L', 'local:remote']) 起本地转发,createConnection({ path: local }) 连上本地 socket;
  3. 在 socket 上跑 authenticateStream:TLS-PSK 握手(PSK-AES256-GCM-SHA384、TLSv1.2 钉死,无未认证回退),客户端 pskCallback 用 capability 的 hex 作密钥、identity dsh-stream;helper 端的 pskCallback 只认 identity === 'dsh-stream' 的客户端、用同一把能力密钥;
  4. secureConnect 后 disableRenegotiation() + pause()——“the key is never transmitted as data”。

为什么是 TLS-PSK 而不是证书?PSK 证明对端身份不需要 X.509 或主机名(注释原话),而这里本来就有比证书更强的信任来源:capability 只在已认证的管理面里传过,只有这条连接的客户端和 helper 知道——证书体系解决的是”公钥该信谁”,这里双方的身份已经由 SSH 层 + 摘要定死了。为什么每流一把随机密钥?泄漏边界:一个通道的 key 泄露,只波及这一个通道、这一个进程,不会连累管理面或其他通道。为什么 ExitOnForwardFailure=yes?转发建不起来要立刻失败,而不是让进程在”无人接听”的通道上跑起来。

认证之后,两条输出通道还要再套一层:stdout/stderr 上各跑一个只处理输出快照的 SshRpcPeer(outputSnapshotFrameLimit(maxBytes) 把快照帧限制在收集预算的 2 倍 + 1024、封顶 64 MiB)——输出由 OutputCollector 按预算收集、按帧快照交给客户端,客户端再按需读;stdin/control 则是原始 duplex(this.inbound.pipe(socket))。为什么输出要再套 RPC?因为本地实现里输出收集是”有界尾部 + spill 文件”(第 21 章),远端要把同样的语义搬过去:快照帧带上 tail(base64)与 totalBytes,客户端能按字节定位、能知道丢了什么。

本节源码依据(7 处)

远端 helper:同一套实现,不同的边界

helper 是部署侧装到远端的一个 Node 程序(runSshHelper),它的 services() 把本地面那套实现原样搬过去:SandboxedFileSystem(cwd 是 helper 的工作目录)、LocalSubprocessRuntime、LocalSandboxProvider、SandboxPolicyService(mode read-only)。注释说得很直白:“filesystem and process effects use the installed local providers”——远端不是”模拟”本地,就是本地实现的同一套代码,只是跑在另一台主机的进程里。

管理面的操作方法因此是一份完整的本地能力清单:process.prepare/start/done/wait/terminate、terminal.*、executable(解析可执行文件,SUBPROCESS_EXECUTABLE_NOT_FOUND 保留 typed code)、sandbox(danger-full-access 不需要包装直接拒绝)、fs.resolve/lstat/stat/list/readText/stream/next/streamClose/readBytes/readRange/write/edit。每个方法都过 zod strict schema——远端把每个请求当不可信输入校验。文本传输有上限:整文本 8 MiB(MAX_TEXT_BYTES,超了报 FS_TOO_LARGE 并提示用 streaming),流式迭代器上限 128(SSH_MAX_TEXT_STREAMS)。

管理类请求有保留配额,这是配额表里最值得读的一行:heartbeat 和 close 各占 1 个槽位、process.terminate 和 fs.streamClose 各占 128——普通请求挤满 maxPending 时,心跳和终止永远进得来。为什么?因为管理操作是生死攸关的:pending 满通常是 helper 侧出了问题,这时恰恰最需要 heartbeat 判断死活、需要 terminate 止损;把管理类挤出队列,等于让一个卡死的 helper 永远卡着。

helper 侧的租约是另一道保险:touchLease 在每次 heartbeat 刷新,超时 peer.close('SSH helper client lease expired')——客户端心跳停了(网络黑洞、客户端死了),helper 不再无限等,自己关掉并清理。这就是”断开 = 结果与清理未知”的远端半边:客户端不知道远端结果,远端也不知道客户端是否还活着,租约是双方都能接受的兜底。

本节源码依据(6 处)

进程四步:先发端点,全认证后才 start

一个远程进程走四步,RemoteProcesses 每一步都有防错语义:

步骤做什么防错
prepare解析 spawnSchema(argv/cwd/env/graceMs/stdio/terminal,ordinary 与 terminal 二选一),为每条 pipe 通道建私有端点并发布 {path, capability}目标代码不执行;duplicate starts refuse 之后才拦
start等所有端点 connected(“Start once all data channels are authenticated”),再 spawnTerminal 或 spawn重复 start 抛错;start 失败把 done 钉成 failed,供迟到请求读到原始失败
done交退出码/信号、stdout/stderr 快照(outputSnapshotSchema:tail base64 + totalBytes)、可选 spill 路径原始拒绝保留给迟到的 done 请求(settled promises preserve)
terminate终止进程并清理,与传输丢失走同一清理路径走管理配额 128,pending 满也进得来

spawnSchema 里 stdio 与 terminal 二选一(.refine(...):ordinary 或 terminal 执行,不能都要);terminal 请求带 terminalType/rows/cols/shellActivity。process.done 之后进程收尾:done 先于清理(done.then(rememberCompleted, finishFailed))。

handle 的记账和第 18 章本地面一样:进程/终端都只活在 helper 进程里,helper 退出(client lease expired、transport 断开、close 请求)时 processes.close() 收掉全部。重开会话,远端不保留任何东西。

本节源码依据(5 处)

断开 = 结果与清理未知

SshConnection 的类注释只有一句:“One non-reconnecting SSH session; loss invalidates all active operations.”。断开的处理在两端各有各的”不知道”:

  • 客户端:fail() 记下 failure、abort lifetime、关 rpc、逐 socket destroy、SIGTERM 子进程;在飞操作拿到的全是 SSH connection lost; remote operation outcome and cleanup are unknown——不重连(重连只会看到更坏的状态:远端可能已跑了一半)、不回滚(远端已经完成的变更不能撤销);
  • helper 端:transport 断开或 close 请求触发 close()——先 abort lifetime、收迭代器、processes.close()、关服务、删临时目录;租约过期(客户端心跳停了)也走同一路径。

dispose() 的顺序也是写死的:先经管理面 request('close')(能通就礼貌地让 helper 自己收),再关 TLS 包装(“TLS wrappers release their reads before their underlying sockets close”),SIGTERM → 超时 SIGKILL → 等 childClosed → 等全部 operations → 删控制目录。这个顺序的每一格都是在给”未知”收尾:能确定的先确定,确定不了的用信号和超时兜底。

把这一章和本地面并起来看:bash 的”每次调用一个新的 bash -c”、job 的”记在 Agent 名下”、常驻 shell 的”按 Agent 排队”(第 18 章),到了 SSH 实现里一个都没少——它们都只是 ctx.subprocess 的两种实现共享的语义。换的只是执行世界的边界:本机沙箱换成一条已验证的 SSH 连接,进程换到远端,输出经独立转发的通道回来,身份由 helper 摘要钉死。执行世界在哪里,不是运行时决定的,是部署决定的。

下一章看输出回来之后的事:第 21 章讲输出超过预算时怎样 spill 与截断。

本节源码依据(3 处)