跳转到内容

工具跑在远程沙盒

前九集里,Harness、Session、Storage 和工具都在同一台机器上。模型要读文件,工具就读本地文件;模型要跑命令,就在本地 shell 里跑。

这一集换一个场景:Durable 还跑在你的笔记本上,记录本也在本地,但工具要在一台远程机器上动手。那台机器可能有 GPU、有数据集、有生产环境,你的笔记本没有。

这一集只回答一个问题:工具调用怎么从本地搬到远程,而对话、任务和存档仍然留在本地?

Durable 把“运行时”和“工具执行环境”分开了。

  • 本地:Harness、Session、Storage、任务图、checkpoint、提交项、模型请求、凭据。
  • 远程:工具看到的文件系统、shell、命令输出。
  • 中间:ExecutionEnv。

模型说“读 notes.txt”,工具任务仍然在本地生成、落 checkpoint、写 pi.tool 任务;真正打开 notes.txt 的,是 ExecutionEnv。模型说“跑 pytest”,命令也在 ExecutionEnv 里跑;输出回到本地,变成工具结果,再进记录本。

所以远程沙盒不是把整个 agent 搬过去,而是把工具的手伸过去。

图 10-1 记录在本地,动手在远端

图 10-1 · Durable 的记账留在本地,工具的文件和命令走远端

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

图 10-2 · ExecutionEnv 把文件系统和 shell 合成一个接口,id 决定文件命名空间

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 每次工具调用,现选环境

图 10-3 · env(target) 每次按对话、cwd 和已提交文档选环境

远程实现不用自己写。@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。失败就是那一次操作的错误;下一次操作会再试一次。

pi-env 在远端跑一个很小的 Rust daemon。它用 stdin/stdout 上的分帧协议说话,替本地做文件操作和命令执行。

pi-env 建立连接时会:

  1. 检测远端系统(Linux、macOS、Android/Termux、Windows,x86-64 或 arm64);
  2. 按当前系统选择随包发布的 daemon;
  3. 用内容的 SHA-256 命名,并在每次启动前校验;
  4. 通过 SSH 启动 daemon。

SSH 这一层也收得很紧:BatchMode、严格 host key 检查、固定的 host key alias、不做任何转发、不共享连接、不读取远端配置里的命令、不转发本地 locale。host key 变了不会自动接受;要换新 key,先用 forgetHostKey 删掉旧的。

这些细节不是安全装饰。远程执行意味着工具的命令真的在另一台机器上跑,host key 和 daemon 校验是把“另一台机器”认对的第一步。

图 10-4 先认机器,再跑工具

图 10-4 · 先信任 host key,再部署并校验 daemon,最后才执行工具

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 每个对话一只沙盒

图 10-5 · app.sandbox 文档记住地址,env 按对话返回各自的远程环境

远程执行不会改变 Durable 的记账方式。

工具任务的 checkpoint 仍然在本地:call 阶段记下要调用哪个工具、参数是什么;execute 阶段记下要执行、参数和 replay 策略。远程命令跑没跑完,本地都有这条 checkpoint。

恢复时仍然按原来的规则:

  • 存着的 replay 和当前工具的 replay 都是 safe,才重跑;
  • 标成 unsafe,不重跑,写一条 interrupted 结果;
  • 连续被切断到 settings.restartLimit 次,写 restart_limit 结果,任务结束。

远程这一侧不会因为本地回滚而回滚。命令已经改过的文件、已经发出的请求,仍然留在远端。所以有外部副作用的工具,仍然要老老实实标 replay: "unsafe",或者在工具内部做幂等。

图 10-6 本地记账,远端副作用

图 10-6 · 回滚只覆盖本地记录;远端已经发生的副作用不会跟着回滚

这一点要说清楚:pi-env 负责把工具调用送过去,不负责创建那台机器。

沙盒由你的平台准备:一台虚拟机、一个容器、一台 GPU 机器,或者一个已经配好 SSH 的远程主机。pi-env 只要求 SSH 能到、远端系统和架构受支持、daemon 能部署。

也因此,pi-env 本身不是安全边界。它做的是传输和校验;真正的隔离来自远端那台机器或容器的用户、文件系统和网络限制。需要更强隔离时,把 daemon 跑在受限用户、容器或虚拟机里,而不是直接跑在有敏感数据的宿主机上。

远程环境还实现了一个可选能力: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);

这不是远程执行的必需项,但做文件浏览、上下文选择时很好用。

这一集记住三句话:

  1. 工具的手可以伸到远程,记录本仍在本地。 Harness、Session、Storage、checkpoint 和凭据都不动。
  2. ExecutionEnv 是那条手臂。 文件系统和 shell 都从它走;env 回调决定每个对话用哪只手。
  3. pi-env 是现成的远程实现。 它通过 SSH 把文件和命令送到远端 daemon,host key 要信任,daemon 要校验;沙盒本身由你的平台准备。

前九集讲的是 Durable 怎么把一次 agent 运行记下来;这一集讲的是,工具到底在哪里动手。把这两件事分开,远程沙盒就只是换一个 ExecutionEnv,而不是换一套 agent。