跳转至

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 先画清三种状态分别在哪里

flowchart LR subgraph Local[应用服务器] Model[模型凭据与请求] Harness[Durable Harness] Storage[Session 与任务存储] Env[RemoteExecutionEnv] Conn[Connection] Harness --> Model Harness --> Storage Harness --> Env --> Conn end subgraph Remote[执行机器] Daemon[pi-env Rust daemon] Files[文件系统] Process[命令进程组] Daemon --> Files Daemon --> Process end Conn <-->|SSH 的 stdin / stdout 帧| Daemon

这里的远程对象是文件操作和进程执行,不是 LLM 请求的转发代理。把 RemoteExecutionEnv 交给消费 ExecutionEnv 的 Durable 工具,可以改变执行位置;模型凭据、存储以及应用自身权限仍由宿主安排。远端命令能看到 daemon 进程所拥有的环境和 OS 权限,因此不要把“模型凭据留在本地”误读为“远端环境中绝无任何敏感变量”。

对照接口而不是猜测方法名:env 导出、RemoteExecutionEnv。

组成 管理什么 不负责什么
Durable Harness 对话、任务、工具调用、恢复策略 SSH 主机密钥信任
RemoteExecutionEnv 路径、接口结果、取消检查、输出事件映射 给任意命令自动配置沙箱
Connection 子进程连接、帧、request id、重连、存活检查 重放已经发出的业务请求
Rust daemon 文件系统调用、命令进程组、watch、终端 判断模型提出的命令是否符合用户业务意图
应用 身份、允许操作、部署、凭据、资源关闭 不能把这些责任藏在 prompt 中

21.2 第一个实验不需要 SSH:同一协议跑本机子进程

先在下载的 examples 目录运行:

npm ci --ignore-scripts
npm run check
npm run checkpoint:env

完整文件是 src/env-checkpoint.ts,也收录在实验源码附录。它使用包内的真实 Rust daemon,建立真实管道与 RemoteExecutionEnv;没有 mock 文件系统,也没有连接你的 SSH 服务器。

执行前,预测下列结果:

  1. 写入两份笔记,再读取第一份,会得到 current evidence。
  2. listFiles 的 maxEntries: 1 会返回一项并标记 truncated: true;这不意味着第二项不存在。
  3. argv 命令输出 daemon-ok,退出码为 0。
  4. 一个持续运行的 Node 子进程在 0.1 秒限制后返回 timeout。
  5. 已取消的 context 返回 aborted,该命令不应启动。
  6. 对实验自己创建的 ../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:一次请求与一次连接不是同一生命周期

sequenceDiagram participant Tool as 工具调用 participant E as RemoteExecutionEnv participant C as Connection participant D as daemon Tool->>E: read / exec / listFiles E->>C: request(op, json, options) opt 尚无可用连接 C->>D: 启动 serve + token D-->>C: 同步标记与 hello end C->>D: 带 request id 的 frame D-->>C: 零个或多个 event alt 操作正常结束 D-->>C: result C-->>E: Reply else 操作出错 D-->>C: error C-->>E: RemoteError else 连接丢失 C-->>E: unknown / lost:true,当前请求失败 Note over C,D: 后续新请求可重新启动 daemon;不自动重放本次操作 end E-->>Tool: 接口对应的 Result

#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 仍由业务宿主验证本次读取收据和原句。远端只改变“数据从哪里取得”,不取消引用验收。

请完成三个练习,并写出预期后再运行:

  1. 将列表上限改为 0,检查 truncated 与空列表的关系。业务响应应该说“未枚举”还是“没有文件”?
  2. 在自己的临时目录中修改笔记后重读,比较旧收据为何不应继续有效。远端连接没有自动赋予你的业务版本号。
  3. 设计一次断线后的写文件重试:给操作加入业务 id、内容哈希和原子发布步骤,明确哪些状态能安全重试,哪些必须人工或业务逻辑确认。

本章选择执行层实现,而第 22 章的 Chord 解决跨宿主应用对象、身份与可观察状态。二者都可以服务 Durable,但并非同一个网络协议,也不自动提供同一种恢复保证。