同一个运行时,在另一台主机上
文件系统、子进程和沙箱整体搬到远端,Harness 与会话留在本机:SSH 执行环境。一条 OpenSSH 控制主控承载管理请求,每个进程的字节流各自经独立转发通道回本机,远端 helper 校验摘要后才会把执行世界打开。
本章以中文撰写
第 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 的摘要才是它自己的身份。
本节源码依据(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 = 128 | ordinary 类共享;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 转发:
process.prepare在远端为每个通道建一个私有监听端点(Unix socket path +capability = randomBytes(32)十六进制),经管理面把{ path, capability }发回客户端——“Allocate private stream listeners; no target executes until start()”;- 客户端对每个端点
controlCommand(['-O', 'forward', '-o', 'ExitOnForwardFailure=yes', '-L', 'local:remote'])起本地转发,createConnection({ path: local })连上本地 socket; - 在 socket 上跑
authenticateStream:TLS-PSK 握手(PSK-AES256-GCM-SHA384、TLSv1.2钉死,无未认证回退),客户端pskCallback用 capability 的 hex 作密钥、identitydsh-stream;helper 端的pskCallback只认identity === 'dsh-stream'的客户端、用同一把能力密钥; 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 与截断。