21 · 让工具在另一台机器执行:Pi Env 的契约与边界¶
本章解决一个具体需求:研究 Agent 的模型请求和任务记录留在应用服务器,资料、构建工具或 GPU 在另一台机器。你希望 read、文件搜索与命令执行使用那台机器的文件系统,同时保留一致的错误、取消和输出限制。
Pi 1.3.0 的 @earendil-works/pi-env 为 Durable 的 ExecutionEnv 提供远端实现。它不是旧版 pi-server 的换名,也不是把整个 coding-agent 会话搬到远程。先读第 11 章 Durable了解任务与存储,再用本章决定工具在哪里执行。固定源码为 71023aec1546d6d677a65669c5665c0927ce0dd0。
21.1 先画清三种状态分别在哪里¶
这里的远程对象是文件操作和进程执行,不是 LLM 请求的转发代理。把 RemoteExecutionEnv 交给消费 ExecutionEnv 的 Durable 工具,可以改变执行位置;模型凭据、存储以及应用自身权限仍由宿主安排。远端命令能看到 daemon 进程所拥有的环境和 OS 权限,因此不要把“模型凭据留在本地”误读为“远端环境中绝无任何敏感变量”。
对照接口而不是猜测方法名:env 导出、RemoteExecutionEnv。
| 组成 | 管理什么 | 不负责什么 |
|---|---|---|
| Durable Harness | 对话、任务、工具调用、恢复策略 | SSH 主机密钥信任 |
RemoteExecutionEnv |
路径、接口结果、取消检查、输出事件映射 | 给任意命令自动配置沙箱 |
Connection |
子进程连接、帧、request id、重连、存活检查 | 重放已经发出的业务请求 |
| Rust daemon | 文件系统调用、命令进程组、watch、终端 | 判断模型提出的命令是否符合用户业务意图 |
| 应用 | 身份、允许操作、部署、凭据、资源关闭 | 不能把这些责任藏在 prompt 中 |
21.2 第一个实验不需要 SSH:同一协议跑本机子进程¶
先在下载的 examples 目录运行:
完整文件是 src/env-checkpoint.ts,也收录在实验源码附录。它使用包内的真实 Rust daemon,建立真实管道与 RemoteExecutionEnv;没有 mock 文件系统,也没有连接你的 SSH 服务器。
执行前,预测下列结果:
- 写入两份笔记,再读取第一份,会得到
current evidence。 listFiles的maxEntries: 1会返回一项并标记truncated: true;这不意味着第二项不存在。- argv 命令输出
daemon-ok,退出码为 0。 - 一个持续运行的 Node 子进程在 0.1 秒限制后返回
timeout。 - 已取消的 context 返回
aborted,该命令不应启动。 - 对实验自己创建的
../outside.txt的读取能够成功,证明 cwd 只是路径解析基点,不是访问控制边界。
实验的核心结构如下;实际脚本还有断言和 finally 清理:
const connection = new Connection({ command: [localDaemon()] });
const env = new RemoteExecutionEnv({
connection,
id: 'course-local-daemon',
cwd: projectDirectory,
});
const result = await env.readTextFile('notes/a.txt', BACKGROUND_CONTEXT);
if (!result.ok) throw result.error;
注意它返回 Result,不是所有失败都通过 Promise rejection 表达。若只 await 而不检查 ok,你可能把“调用完成”错当成“文件读取成功”。实验覆盖当前 Linux/Node 环境的本机 daemon 路径;SSH 部署、其他 OS、真实断网恢复没有因为这个实验就通过。
21.3 为什么增加环境层,而不是把工具统一改成 ssh 命令¶
工具希望处理“读取文本、列目录、执行进程、观察变化”,并不希望自己实现每个远端 shell 的路径规则与输出协议。环境层让工具依赖操作语义,使执行位置成为组装选择。
RemoteExecutionEnv 以远端机器的 NodeExecutionEnv 行为为参考。路径解析先读取 daemon 的系统信息:~ 使用远端 home;相对路径使用环境 cwd;Windows 路径使用 Windows 规则,包含盘符相对路径。它并非简单地用宿主的 path.resolve() 拼出一条 Unix 路径。源码:路径解析与文件结果。
另一个容易混淆的类型是 LocalExecutionEnv。它继承 NodeExecutionEnv,只把 listFiles 接到本机 daemon;其他操作仍是本地 Node 实现。它不是让所有本地方法都经过远端协议。源码:LocalExecutionEnv。
适用取舍:若只做本机研究 Agent,直接本地 env 足够;当需要远端文件系统且仍想复用同一套 Durable 工具时,才增加 daemon、部署、连接和进程所有权这几层。
21.4 Connection:一次请求与一次连接不是同一生命周期¶
#connect() 共用进行中的启动 Promise;启动失败后清掉缓存,让下一次请求可以重试。每次 daemon 启动有新的 session id。文件句柄带原 session,不能在断线后拿一个旧句柄去操作新 daemon 中“碰巧同号”的资源。源码:Connection 请求、关闭、启动缓存。
协议不是换行分隔 JSON。每帧包括大端长度、type、request id、JSON 长度、JSON 与可选二进制 payload;文件内容不必包成巨大 JSON 字符串。单帧最大 16 MiB。启动先寻找带随机 token 的 PI-ENV 同步行,避免 shell 启动脚本在 stdout 打印文字破坏帧边界。这个 token 用于同步,不能代替 SSH 认证。协议定义。
daemon 会优先发送控制回复和部分事件,避免大段命令输出堵住所有交互;大 payload 回复也会进入 bulk 队列,不能概括为“所有结果永远优先”。这个调度设计减少控制延迟,但不提供跨请求的业务事务。
21.5 真正连接 SSH 时,信任与部署分别做什么¶
scanHostKey() 收集候选主机密钥与指纹;应用要让拥有者经可靠渠道核对后,才调用 acceptHostKey() 保存信任。不要直接把“扫描成功”当成“已认证服务器”。已有同类型密钥改变会拒绝接受,要在核实之后显式遗忘旧信任并接受新的密钥。源码:主机密钥接受规则。
之后有两种启动策略:
| API | 调用时发生什么 | 首次操作失败在哪里出现 |
|---|---|---|
connectSsh(options) |
立即探测平台、检查/部署 daemon,返回连接 | 此次连接函数可直接拒绝 |
sshConnection(options) |
只构造惰性连接 | 第一次 env 操作启动 SSH 时失败;后续请求可再次尝试 |
惰性模式便于创建配置对象与 UI 列表时不触网;主动模式适合连接设置页马上反馈诊断。daemon 以内容 SHA 命名,每次启动验证,必要时部署包内对应平台的二进制,不能从“文件名有哈希”推断后续不再检查。SSH 组装与每次启动。
默认 SSH 参数显式设置 BatchMode、严格主机密钥检查、应用自己的 known-hosts 文件和 alias,关闭 agent/X11/端口转发、共享连接以及配置中的远程/本地自动命令。它仍然以目标 OS 用户权限执行,因此这套 SSH 连接约束不等于命令权限策略。实际参数构造。
作为自己的 Agent 产品,应把目标主机身份、执行账户、允许路径/操作、模型凭据位置以及连接所有者写入配置,而不是在每次模型调用时任意接受模型提供的 SSH 参数。
21.6 命令、取消与清理的几个不同结局¶
exec() 支持字符串与 argv。字符串经过 shell,argv 直接调用程序;两者的转义与环境展开不同。timeout 的单位是秒,传给 daemon 时转换成毫秒,必须是有效正数。执行输出从 onOutput(text, context, info) 送出,info.stream 指明 stdout/stderr,函数返回的 value 主要是退出状态和可能的 spillPath;不要假设返回对象里自动包含完整 stdout。exec 源码。
| 触发 | 应如何理解 |
|---|---|
| 命令退出 0 | 进程报告成功,业务产物仍需检查 |
| 命令非零退出 | 正常取得退出状态,与启动失败不同 |
| timeout | 达到时间预算,相关进程组被终止 |
| context abort | 返回取消;不撤销已经写入的文件或远端 API 调用 |
cleanup() |
向该 env 跟踪的执行请求发送 kill,不等同 abort;Unix 被 SIGKILL 的状态通常为 137 |
connection.close() |
永久关闭这个 Connection,停止 daemon,不再接受后续重连 |
| 网络丢失 | 当前请求结果可能不确定;不能无条件重试有副作用的命令 |
cleanup() 跟踪的是该环境启动的命令;Connection 可以被多个 env 共用,因此谁创建 Connection,谁应负责最终关闭。示例用 finally 同时清理 env 和连接,读者迁移到长驻宿主时应把这一段放到对应资源的 shutdown,而不是每次工具调用都断开共享连接。
watch 有不同的恢复策略:断线后可以重开 watcher,但会报告 overflow,意思是中间变化可能遗漏。消费者应重新扫描当前事实,不能把“重新连上”当作事件从未丢失。源码:RemoteWatcher。
21.7 有界枚举与终端:能力探测先于使用¶
listFiles 在 daemon 中执行,按 maxEntries/maxBytes/timeoutMs 有界返回相对 / 路径;不跟随符号链接。Git 工作树中可按忽略规则过滤;这与“列出所有被 Git 跟踪的文件”并不等价。结果的 truncated 是任务证据的一部分,Agent 不能把被截断列表当完整仓库证明。
客户端先检查 hello.features 是否包含 listFiles。旧 daemon 不支持时返回 not_supported,由消费者选择升级或使用自己的 fallback;并非 RemoteExecutionEnv 自动补出全量遍历。能力探测与结果解码。
openTerminal() 创建 PTY,支持原始终端字节、输入和 resize,适用于人的交互终端,不应与“调用 bash 工具得到一段受限输出”混为一谈。1.3.0 的 PTY 支持 Unix,Windows 不支持;不要从“env 支持 Windows 文件操作”推导“所有 env 能力在 Windows 都支持”。终端入口。
21.8 改造成你的 Agent¶
现在把第 13 章的证据路径映射到远端资料:search_notes 从指定目录取得有界索引;read_note 通过 env 读完整内容并计算版本;submit_answer 仍由业务宿主验证本次读取收据和原句。远端只改变“数据从哪里取得”,不取消引用验收。
请完成三个练习,并写出预期后再运行:
- 将列表上限改为 0,检查 truncated 与空列表的关系。业务响应应该说“未枚举”还是“没有文件”?
- 在自己的临时目录中修改笔记后重读,比较旧收据为何不应继续有效。远端连接没有自动赋予你的业务版本号。
- 设计一次断线后的写文件重试:给操作加入业务 id、内容哈希和原子发布步骤,明确哪些状态能安全重试,哪些必须人工或业务逻辑确认。
本章选择执行层实现,而第 22 章的 Chord 解决跨宿主应用对象、身份与可观察状态。二者都可以服务 Durable,但并非同一个网络协议,也不自动提供同一种恢复保证。