工具跑在远程沙盒
前九集里,Harness、Session、Storage 和工具都在同一台机器上。模型要读文件,工具就读本地文件;模型要跑命令,就在本地 shell 里跑。
这一集换一个场景:Durable 还跑在你的笔记本上,记录本也在本地,但工具要在一台远程机器上动手。那台机器可能有 GPU、有数据集、有生产环境,你的笔记本没有。
这一集只回答一个问题:工具调用怎么从本地搬到远程,而对话、任务和存档仍然留在本地?
先把边界画出来
Section titled “先把边界画出来”Durable 把“运行时”和“工具执行环境”分开了。
- 本地:Harness、Session、Storage、任务图、checkpoint、提交项、模型请求、凭据。
- 远程:工具看到的文件系统、shell、命令输出。
- 中间:
ExecutionEnv。
模型说“读 notes.txt”,工具任务仍然在本地生成、落 checkpoint、写 pi.tool 任务;真正打开 notes.txt 的,是 ExecutionEnv。模型说“跑 pytest”,命令也在 ExecutionEnv 里跑;输出回到本地,变成工具结果,再进记录本。
所以远程沙盒不是把整个 agent 搬过去,而是把工具的手伸过去。
图 10-1 · Durable 的记账留在本地,工具的文件和命令走远端
ExecutionEnv 是什么
Section titled “ExecutionEnv 是什么”Durable 给工具用的环境接口很小,只有文件系统和 shell:
源码对照 · packages/durable/src/env/index.ts:377
export interface ExecutionEnv extends FileSystem, Shell {}文件系统负责读、写、改、列目录;shell 负责执行命令和清理:
源码对照 · packages/durable/src/env/index.ts:362
export interface Shell { exec( command: string | readonly string[], options: ShellExecOptions | undefined, context: Context, ): Promise<Result<ShellExecResult, ExecutionError>>; cleanup(context: Context): Promise<void>;}还有一个容易被忽略的字段:id。它是文件命名空间。同一个 id、同一个路径,edit 和 write 会串行;不同 id 就是不同沙盒,写操作互不串行。一个对话一个容器时,通常一个对话一个 id。
图 10-2 · ExecutionEnv 把文件系统和 shell 合成一个接口,id 决定文件命名空间
Harness 怎么把环境接进来
Section titled “Harness 怎么把环境接进来”HarnessOptions 里有一个 env,它在每次工具调用时构造环境:
源码对照 · packages/durable/src/harness/types.ts:703
readonly env?: ( target: EnvTarget, context: Context,) => ExecutionEnv | undefined | Promise<ExecutionEnv | undefined>;target 里有三样东西:对话 ID、这个对话的 cwd,以及一个只读已提交文档的 read:
源码对照 · packages/durable/src/harness/types.ts:677
export type EnvTarget = { readonly conversationId: ConversationId; readonly cwd?: string; readonly read: DocumentReader;};Harness 在构造环境时,会先读 pi.agent 里的 cwd,再把它交给 env:
源码对照 · packages/durable/src/harness/harness.ts:291
async buildEnv(id: ConversationId, context: Context): Promise<ExecutionEnv | undefined> { const build = this.#options.env; if (build === undefined) return undefined; const cwd = (await this.snapshot(AgentDoc, id, context))?.cwd; return build({ conversationId: id, ...(cwd === undefined ? {} : { cwd }), read: this }, context);}工具任务在真正执行工具之前,会先拿到这次调用的环境:
源码对照 · packages/durable/src/harness/tool.ts:556
env = await runtime.env(context);const startedAt = performance.now();try { result = await tool.execute(args, api, context);} finally { durationMs = Math.round(performance.now() - startedAt);}如果 env 抛错,这次工具调用会得到一个普通的错误结果;如果没有配置 env,内置工具会直接报“没有执行环境”:
源码对照 · packages/durable/src/tools/env.ts:5
export function requireEnv(api: ToolExecutionApi): ExecutionEnv { if (api.env === undefined) throw new Error("No execution environment is configured"); return api.env;}注意,env 是每次调用都构造一次,不是 Harness 打开时构造一次。这样它才能按对话、按 cwd、按已提交的文档,决定这次工具到底去哪里跑。
图 10-3 · env(target) 每次按对话、cwd 和已提交文档选环境
一个最小的远程接法
Section titled “一个最小的远程接法”远程实现不用自己写。@earendil-works/pi-env 已经提供了一个 Durable 的 ExecutionEnv:
源码对照 · packages/env/src/remote-env.ts:354
export class RemoteExecutionEnv implements ExecutionEnv { readonly id: string; cwd: string; readonly connection: Connection; // …}它通过一个 Connection 和远端 daemon 说话。Connection 可以用 sshConnection(packages/env/src/ssh.ts:519)惰性创建,也可以用 connectSsh(:532)立即连接。信任主机用 scanHostKey(:182)和 acceptHostKey(:258)。
先看一段完整的接线:
import { acceptHostKey, connectSsh, RemoteExecutionEnv, scanHostKey,} from "@earendil-works/pi-env";
const target = { host: "gpu-box", knownHostsFile: "/data/ssh/known_hosts", hostKeyAlias: "pi-env-gpu",};
// 第一次连接前:让 owner 核对远端主机指纹,再接受const { lines, fingerprints } = await scanHostKey(target);console.log("remote fingerprint:", fingerprints);await acceptHostKey(target, lines);
// 检测远端系统,部署 daemon,返回连接const { connection } = await connectSsh(target);
const env = new RemoteExecutionEnv({ connection, id: "pi-env:gpu", cwd: "/home/me/project",});然后把 env 交给 Harness:
const harness = await Harness.open(storage, { models, registry, env: () => env,}, context);这样,模型下一次说“读文件”或“跑命令”,工具任务仍然在本地,但文件操作和命令会通过这个 env 到 gpu-box 上执行。这里为了简短,所有对话复用同一个 env;按对话分配环境见下一节。
如果不想在启动时就连,可以用惰性版本:
import { sshConnection, RemoteExecutionEnv } from "@earendil-works/pi-env";
const { connection } = sshConnection(target);const env = new RemoteExecutionEnv({ connection, id: "pi-env:gpu", cwd: "/home/me/project",});sshConnection 第一次真正发请求时才检测系统、部署 daemon、建立 SSH。失败就是那一次操作的错误;下一次操作会再试一次。
远端 daemon 做什么
Section titled “远端 daemon 做什么”pi-env 在远端跑一个很小的 Rust daemon。它用 stdin/stdout 上的分帧协议说话,替本地做文件操作和命令执行。
pi-env 建立连接时会:
- 检测远端系统(Linux、macOS、Android/Termux、Windows,x86-64 或 arm64);
- 按当前系统选择随包发布的 daemon;
- 用内容的 SHA-256 命名,并在每次启动前校验;
- 通过 SSH 启动 daemon。
SSH 这一层也收得很紧:BatchMode、严格 host key 检查、固定的 host key alias、不做任何转发、不共享连接、不读取远端配置里的命令、不转发本地 locale。host key 变了不会自动接受;要换新 key,先用 forgetHostKey 删掉旧的。
这些细节不是安全装饰。远程执行意味着工具的命令真的在另一台机器上跑,host key 和 daemon 校验是把“另一台机器”认对的第一步。
图 10-4 · 先信任 host key,再部署并校验 daemon,最后才执行工具
每个对话一个沙盒
Section titled “每个对话一个沙盒”env 每次调用都会拿到 conversationId 和已提交的文档,所以可以让每个对话用自己的沙盒。
包里第 28 个示例就是这么做的:它定义一个 app.sandbox 文档,里面存沙盒路径;env 读这个文档,有路径就返回一个环境,没有就返回 undefined,让工具干净地失败。
源码对照 · packages/durable/test/examples/28-sandbox-per-conversation.ts:49
env: async ({ conversationId, read }, envContext) => { const sandbox = await read.snapshot(Sandbox, conversationId, envContext); return sandbox?.path === undefined ? undefined : new NodeExecutionEnv({ cwd: sandbox.path });},这个例子里,沙盒是一个本地目录;换成远程,只要把 NodeExecutionEnv 换成 RemoteExecutionEnv:
env: async ({ conversationId, read }, envContext) => { const sandbox = await read.snapshot(Sandbox, conversationId, envContext); return sandbox?.path === undefined ? undefined : new RemoteExecutionEnv({ connection, id: `pi-env:${conversationId}`, cwd: sandbox.path, });},这里有两个选择:
- 如果所有对话共享同一个远端工作区,用同一个
id,让文件写操作按同一个命名空间串行。 - 如果每个对话一个独立目录或容器,用每个对话自己的
id,让它们互不干扰。
示例 28 在创建对话的同一个 commit 里写沙盒路径:
源码对照 · packages/durable/test/examples/28-sandbox-per-conversation.ts:61
const conversation = await harness.createConversation( { ownership: { kind: "ownerless" }, agent: { model }, init: async (tx, id) => { (await tx.doc(Sandbox, id)).path = path; }, }, context,);这就是“每个对话一个沙盒”的接线点:沙盒地址是对话自己的文档,env 每次按这个地址去取环境。
图 10-5 · app.sandbox 文档记住地址,env 按对话返回各自的远程环境
远程失败以后,Durable 怎么记账
Section titled “远程失败以后,Durable 怎么记账”远程执行不会改变 Durable 的记账方式。
工具任务的 checkpoint 仍然在本地:call 阶段记下要调用哪个工具、参数是什么;execute 阶段记下要执行、参数和 replay 策略。远程命令跑没跑完,本地都有这条 checkpoint。
恢复时仍然按原来的规则:
- 存着的
replay和当前工具的replay都是safe,才重跑; - 标成
unsafe,不重跑,写一条interrupted结果; - 连续被切断到
settings.restartLimit次,写restart_limit结果,任务结束。
远程这一侧不会因为本地回滚而回滚。命令已经改过的文件、已经发出的请求,仍然留在远端。所以有外部副作用的工具,仍然要老老实实标 replay: "unsafe",或者在工具内部做幂等。
图 10-6 · 回滚只覆盖本地记录;远端已经发生的副作用不会跟着回滚
远程沙盒不是 pi-env 创建的
Section titled “远程沙盒不是 pi-env 创建的”这一点要说清楚:pi-env 负责把工具调用送过去,不负责创建那台机器。
沙盒由你的平台准备:一台虚拟机、一个容器、一台 GPU 机器,或者一个已经配好 SSH 的远程主机。pi-env 只要求 SSH 能到、远端系统和架构受支持、daemon 能部署。
也因此,pi-env 本身不是安全边界。它做的是传输和校验;真正的隔离来自远端那台机器或容器的用户、文件系统和网络限制。需要更强隔离时,把 daemon 跑在受限用户、容器或虚拟机里,而不是直接跑在有敏感数据的宿主机上。
一个可选的目录列表
Section titled “一个可选的目录列表”远程环境还实现了一个可选能力:listFiles()。它像 fd --hidden --exclude .git 一样列出目录树,跳过 git ignore,支持条数、字节数和超时上限,返回 truncated 标记。
NodeExecutionEnv 没有这个方法;RemoteExecutionEnv 和 LocalExecutionEnv 有。调用方可以用可选链,缺这个方法时退回自己的遍历:
源码对照 · packages/durable/src/env/index.ts:254
const listing = await env.listFiles?.(".", { exclude: ["node_modules"], maxEntries: 50_000 }, context);这不是远程执行的必需项,但做文件浏览、上下文选择时很好用。
这一集记住三句话:
- 工具的手可以伸到远程,记录本仍在本地。 Harness、Session、Storage、checkpoint 和凭据都不动。
ExecutionEnv是那条手臂。 文件系统和 shell 都从它走;env回调决定每个对话用哪只手。pi-env是现成的远程实现。 它通过 SSH 把文件和命令送到远端 daemon,host key 要信任,daemon 要校验;沙盒本身由你的平台准备。
前九集讲的是 Durable 怎么把一次 agent 运行记下来;这一集讲的是,工具到底在哪里动手。把这两件事分开,远程沙盒就只是换一个 ExecutionEnv,而不是换一套 agent。