跳到主内容
创建时间:最后更新:作者:白川

命令执行:工作目录、超时与退出状态

把命令执行接入智能体并保留真实执行结果,明确工作目录、超时、退出码与输出裁剪的宿主边界。

命令执行:工作目录、超时与退出状态

真实仓库任务躲不开一件事:跑测试、跑构建、跑 git。这类需求只能落到命令执行上。

命令执行是整条链路上副作用等级最高的工具。它能改任意文件、能装包、能连网络、能读环境变量里的凭据。也正因为如此,这一篇的重点不是"怎么把命令跑起来"——spawn 几行就够——而是跑起来之后宿主拿得住什么:工作目录钉在哪、超时怎么算、退出码怎么变成模型能理解的信息、输出怎么裁剪不让它撑爆上下文、取消时进程是不是真的被杀掉了。

读它需要能读 TypeScript,并且知道"子进程"和"Promise"的基本概念。读完你能自己实现一个受控的命令工具,并解释清楚退出码、超时、取消三者在模型眼里分别长什么样。

Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。

内置 bash 已经定义好的契约

先看清内置实现做了什么,因为自定义工具要跟它保持一致,否则模型会在两套语义之间来回猜。

bash 的参数只有两个:

typescript
// 内置 bash 的参数只有两个:命令和超时秒数
const bashSchema = Type.Object({
	command: Type.String({ description: "Shell command to execute" }),
	// timeout 是可选的,而且不传就没有默认超时:宿主必须自己补上上限
	timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});

注意 timeout 的单位是秒,而且不传就没有默认超时。这个"没有默认超时"是宿主必须补上的第一件事——一个可以永远挂着的命令会占住整个会话。

内置实现里有四个值得学的处理:

超时值本身要校验。 resolveTimeoutMs 会拒绝非有限数和负数,报 Invalid timeout: must be a finite number of seconds,并设上限 MAX_TIMEOUT_MS = 2_147_483_647(约 68 年,实际上等于不限)。传给模型的参数不可信,超时值同样不可信。

超时和取消都要杀掉整棵进程树。 源码里子进程用 detached: process.platform !== "win32" 启动,然后 killProcessTree(child.pid) 发信号。Unix 上是 process.kill(-pid, "SIGKILL")——负的 pid 表示进程组,只杀直接子进程的话,sh -c "npm test" 起的孙进程会活下来继续占资源。

退出码非 0 不是异常,是错误结果。 源码里 exitCode !== 0 时返回的是带 isError: true 的正常结果,内容末尾追加 Command exited with code N;只有 exitCode === null(被信号杀掉且没拿到信号码)才抛 Command terminated without an exit code。这个区分很重要:命令跑失败是正常业务结果,不是程序错误。

顺带一个容易忽略的细节:被信号杀掉的进程,本地执行后端会把退出码折算成 128 + 信号号(SIGKILL 是 137),所以这种情况通常表现为非 0 退出码,而不是抛错。

输出裁剪保留尾部并落临时文件。 bash 用的是 truncateTail 而不是 truncateHead——命令的报错通常在最后。裁剪后会把完整输出的临时文件路径告诉模型。

裁剪阈值来自截断工具,是全局常量:

常量值含义
DEFAULT_MAX_LINES
2000
行数上限
DEFAULT_MAX_BYTES
50KB
字节上限
GREP_MAX_LINE_LENGTH
500
单行截断长度(grep 专用)

两个上限先到先算。bash 还会额外把完整输出写到临时文件(pi-bash 前缀),并把路径放进 details.fullOutputPath 和 structuredContent.full_output_path。

自己实现一个受控的命令工具

先看内置实现里可以复用的部分。BashOperations 接口是一个干净的注入点:

typescript
// 执行后端的抽象:换掉这一层就能加策略,不用重写整个工具
export interface BashOperations {
	exec: (
		command: string,
		// 工作目录由调用方给出,但自定义实现可以忽略它并强制成固定目录
		cwd: string,
		options: {
			// 输出增量回调,内置实现用它做节流上报
			onData: (data: Buffer) => void;
			// 取消信号:超时和用户取消走同一条杀掉进程树的路径
			signal?: AbortSignal;
			timeout?: number;
			// 环境变量:默认继承全部 process.env 意味着模型能读到凭据
			env?: NodeJS.ProcessEnv;
		},
		// exitCode 为 null 表示进程被信号杀掉,没拿到退出码
	) => Promise<{ exitCode: number | null }>;
}

createLocalBashOperations() 给出本地实现,createBashTool(cwd, { operations }) 接受自定义实现。如果你只是想要"更严的策略",替换 BashOperations 比重写整个工具划算得多。

但如果要做成"智能体可调用的命令工具",得自己写 execute。下面这份实现把前面五个要点都落到了代码上。

完整实现

typescript
import { spawn } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Type } from "@earendil-works/pi-ai";
import { defineTool, truncateTail } from "@earendil-works/pi-coding-agent";

/** 宿主侧的硬性上限:模型给的 timeout 不能超过这个值。 */
const MAX_TIMEOUT_SECONDS = 120;
const DEFAULT_TIMEOUT_SECONDS = 30;

export interface RunCommandOptions {
	/** 命令的工作目录。所有相对路径都以此为基准。 */
	workspaceRoot: string;
	/** 允许执行的可执行文件名白名单。空数组表示不限制。 */
	allowedBinaries?: string[];
}

export function createRunCommandTool(options: RunCommandOptions) {
	const { workspaceRoot } = options;
	const allowed = options.allowedBinaries;

	return defineTool({
		name: "run_command",
		label: "执行命令",
		description:
			"在工作区内执行一条命令,返回退出码与输出。输出过长时会截断并给出完整输出的临时文件路径。",
		parameters: Type.Object({
			command: Type.String({ description: "要执行的命令" }),
			timeout: Type.Optional(Type.Number({ description: "超时秒数,上限 120" })),
		}),

		async execute(_toolCallId, params, signal, onUpdate) {
			// 1) 超时值先校验,再套上宿主上限
			const requested = params.timeout ?? DEFAULT_TIMEOUT_SECONDS;
			if (!Number.isFinite(requested) || requested <= 0) {
				throw new Error("timeout 必须是大于 0 的有限数值。");
			}
			const timeoutSeconds = Math.min(requested, MAX_TIMEOUT_SECONDS);

			// 2) 命令白名单:只放行可执行文件名,不解析整条命令行
			if (allowed && allowed.length > 0) {
				const binary = params.command.trim().split(/\s+/)[0] ?? "";
				const basenameOnly = binary.split("/").pop() ?? "";
				if (!allowed.includes(basenameOnly)) {
					throw new Error(
						`命令 ${basenameOnly} 不在允许列表内。允许的命令:${allowed.join("、")}。`,
					);
				}
			}

			if (signal?.aborted) throw new Error("操作已取消");

			// 3) 启动子进程:独立进程组,超时或取消时能整组杀掉
			const child = spawn("/bin/sh", ["-c", params.command], {
				cwd: workspaceRoot,
				detached: process.platform !== "win32",
				stdio: ["ignore", "pipe", "pipe"],
				env: { PATH: process.env.PATH ?? "", HOME: workspaceRoot },
			});

			let stdout = "";
			let stderr = "";
			let timedOut = false;
			let aborted = false;

			const collect = (chunk: Buffer, into: "out" | "err") => {
				const text = chunk.toString("utf-8");
				if (into === "out") stdout += text;
				else stderr += text;
			};
			child.stdout?.on("data", (c: Buffer) => collect(c, "out"));
			child.stderr?.on("data", (c: Buffer) => collect(c, "err"));

			// 4) 超时与取消共用一条杀掉进程树的路径
			const killTree = () => {
				if (!child.pid) return;
				try {
					if (process.platform === "win32") {
						child.kill();
					} else {
						process.kill(-child.pid, "SIGKILL");
					}
				} catch {
					child.kill("SIGKILL");
				}
			};

			const timer = setTimeout(() => {
				timedOut = true;
				killTree();
			}, timeoutSeconds * 1000);

			const onAbort = () => {
				aborted = true;
				killTree();
			};
			signal?.addEventListener("abort", onAbort, { once: true });

			// 5) 等进程真正退出,再做清理
			const exitCode = await new Promise<number | null>((resolve) => {
				child.on("error", () => resolve(null));
				child.on("close", (code) => resolve(code));
			});

			clearTimeout(timer);
			signal?.removeEventListener("abort", onAbort);

			if (aborted) throw new Error("命令已取消");
			if (timedOut) throw new Error(`命令超过 ${timeoutSeconds} 秒被终止`);

			// 6) 输出裁剪:保留尾部,完整内容落临时文件
			const combined = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n");
			const truncation = truncateTail(combined);

			let fullOutputPath: string | undefined;
			if (truncation.truncated) {
				const dir = await mkdtemp(join(tmpdir(), "pi-cmd-"));
				fullOutputPath = join(dir, "output.txt");
				await writeFile(fullOutputPath, combined, "utf-8");
			}

			const summary = truncation.truncated
				? `${truncation.content}\n\n[输出已截断,显示最后 ${truncation.outputLines} 行。完整输出:${fullOutputPath}]`
				: combined || "(无输出)";

			// 7) 上报进度,让界面知道命令跑完了
			onUpdate?.({
				content: [{ type: "text", text: summary }],
				details: { exitCode, truncated: truncation.truncated },
			});

			// 8) 退出码非 0 返回错误结果,不是抛异常
			if (exitCode !== 0) {
				return {
					content: [{ type: "text", text: `${summary}\n\n命令以退出码 ${exitCode} 结束` }],
					details: { exitCode, truncated: truncation.truncated, fullOutputPath },
					isError: true,
				};
			}

			return {
				content: [{ type: "text", text: summary }],
				details: { exitCode, truncated: truncation.truncated, fullOutputPath },
			};
		},
	});
}

几个设计取舍要讲清楚。

为什么用 sh -c 而不是直接 spawn(command)。 直接 spawn 一个字符串会按空格切分,无法处理管道和重定向。sh -c 把整条命令交给 shell,语义和用户预期一致。代价是命令必须完全可信——这正是白名单存在的原因。

为什么环境变量要收窄。 上面只传 PATH 和 HOME。默认继承整个 process.env 意味着模型可以 env 读到你的 API key、token 和 CI 凭据。这是宿主该做的第一件事,不是模型的自觉。

为什么超时和取消走同一个 killTree。 两条路径的语义一样:进程必须真的死掉。如果只 child.kill(),孙进程会变孤儿继续跑。

为什么裁剪用 truncateTail。 命令的错误信息在末尾。用 truncateHead 会把真正的报错截掉,模型只能看到一堆无害的开头。

为什么 onUpdate 在最后调一次就够了。 内置 bash 是按节流周期上报的,中间输出会频繁触发事件。命令通常在秒级完成,一次性上报能显著降低事件量。如果你需要实时流式输出,就该在 collect 里按时间节流调用 onUpdate。

把它接进会话

typescript
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";

const workspaceRoot = process.cwd();

const { session } = await createAgentSession({
	cwd: workspaceRoot,
	// 用白名单:只保留只读工具和这一个命令工具
	tools: ["read", "grep", "find", "ls", "run_command"],
	// 白名单会连自定义工具一起过滤,所以 run_command 必须写进上面那个名单
	customTools: [createRunCommandTool({ workspaceRoot, allowedBinaries: ["npm", "node", "git"] })],
	sessionManager: SessionManager.inMemory(workspaceRoot),
});

try {
	await session.prompt("运行 npm test 并告诉我失败原因");
	console.log(session.getLastAssistantText());
} finally {
	// 必须释放:否则子进程和监听器会留在进程里
	session.dispose();
}

注意 run_command 必须写进 tools 白名单——tools 白名单会连自定义工具一起过滤,白名单没写上的名字会被直接剔除。

复用内置 bash:更严策略的注入点

自己重写一套不是唯一选择。内置 bash 的实现把执行后端抽象成了 BashOperations,你可以只替换这一层,保留它已经处理好的裁剪、临时文件和退出码逻辑。

typescript
import {
	createBashTool,
	createLocalBashOperations,
	type BashOperations,
} from "@earendil-works/pi-coding-agent";

/**
 * 在内置 bash 的基础上加两道限制:
 * 1) 工作目录强制为工作区,忽略调用方传入的任何目录
 * 2) 超时封顶,模型给再大也有效
 */
function createRestrictedBash(workspaceRoot: string, maxTimeoutSeconds: number): BashOperations {
	const base = createLocalBashOperations();

	return {
		exec: async (command, _cwd, options) => {
			const timeout = options.timeout
				? Math.min(options.timeout, maxTimeoutSeconds)
				: maxTimeoutSeconds;

			return base.exec(command, workspaceRoot, {
				...options,
				timeout,
				// 环境变量在这里收窄:默认继承全部 process.env 意味着模型能读到凭据
				env: { PATH: process.env.PATH ?? "", HOME: workspaceRoot },
			});
		},
	};
}

// 把它包装成工具,模型看到的参数 schema 仍是内置 bash 的
const restrictedBash = createBashTool(workspaceRoot, {
	operations: createRestrictedBash(workspaceRoot, 120),
});

这个注入点的好处是不用重新实现难写的部分。内置 bash 已经处理了:输出累积与内存上限、截断后写临时文件、非 0 退出码转成 isError 结果、structuredContent 里带上 exit_code 和 wall_time_seconds。这些逻辑重写一遍很容易漏。

源码里还有两个相关的注入点,按需要选:

注入点作用适用场景
operations
替换整个执行后端
要加白名单、强制工作目录、换沙箱执行
spawnHook
在启动前改写 command / cwd / env
要加环境变量、统一前缀命令
commandPrefix
给每条命令加固定前缀
统一设置 shell 选项、加载 profile

spawnHook 的形态是一个纯函数,官方示例 bash-spawn-hook.ts 就是这么用的:

typescript
// spawnHook 是纯函数:在真正启动前改写命令、工作目录和环境变量
const bashTool = createBashTool(cwd, {
	spawnHook: ({ command, cwd, env }) => ({
		command: `source ~/.profile\n${command}`,
		cwd,
		env: { ...env, PI_SPAWN_HOOK: "1" },
	}),
});

两条路怎么选: 需要模型自己决定跑什么命令、并且要严格控制超时和输出,就自己写工具;要保留内置 bash 的全部行为、只想加几道限制,就用 operations 注入。自己写工具时,本文上面的八步实现是一个可用的模板。

宿主负责什么

命令执行这条链路上,宿主的责任比工具实现本身更重:

职责由谁承担具体表现
工作目录
宿主
写死在 spawn 的 cwd 里,模型无法改
超时上限
宿主
MAX_TIMEOUT_SECONDS 封顶,模型给再大也无效
命令白名单
宿主
可执行文件名级别,拒绝任意程序
环境变量
宿主
只传必要变量,凭据不进 process.env
进程隔离
宿主
容器或沙箱
输出裁剪
宿主 + 工具
阈值 2000 行 / 50KB,完整输出落临时文件
资源上限
宿主
内存、磁盘、进程数,容器层控制
退出码解释
工具
非 0 → isError: true,附上退出码数值

官方安全文档的原话值得引用:Pi 没有内置权限系统,"Enabled tools use the operating-system permissions of the Pi process"。你的白名单是工具层的第一道防线,真正的隔离要靠容器。

同一份文档还列了三种运行方式,越往后隔离越强:直接以宿主用户权限运行 → 整个跑在容器/虚拟机/沙箱里 → 只有内置工具跑在隔离环境内。对不可信或无人值守的任务,第二种是通常最强也最实际的选择。

三种场景的可观察结果

场景触发条件模型看到什么宿主观察到什么
成功
退出码 0
输出文本,isError 为 false
tool_execution_end 的 result.details.exitCode 为 0,durationMs 有值
失败
退出码非 0
输出 + 命令以退出码 N 结束,isError 为 true
result.details.exitCode 为非 0,isError 为 true
取消
用户取消或超时
抛错,模型看到 命令已取消 或 命令超过 N 秒被终止
tool_execution_end 的 isError 为 true,进程组已被 SIGKILL

三种场景在事件层的区分方式很朴素:看 isError 和 details.exitCode。exitCode 为 null 说明进程是被信号杀掉的(取消或超时),是一个数字说明命令自己跑完了。

这个区分之所以重要,是因为它决定了模型的下一步动作:

  • 退出码非 0 → 命令本身执行了但结果不对,模型应该改代码再跑。
  • 被信号杀掉 → 命令没跑完,模型应该缩短命令或减少输出。

如果把两者混成一句"命令失败了",模型会去改代码,而真正的问题是输出太多。

你现在能做什么

  • 用 createBashTool(cwd, { operations }) 写一个自己的 BashOperations,把工作目录强制成某个子目录,然后跑一条会打印 pwd 的命令,验证输出确实是那个目录。
  • 跑一条 sleep 30 的命令并把 timeout 设成 2 秒,确认它返回的是失败结果而不是挂住;同时用 ps 看有没有残留的 sleep 进程——这条验证的是进程组是否被整组杀掉。
  • 把 allowedBinaries 设成 ["npm"],然后让模型尝试执行 cat /etc/passwd,确认它拿到的是白名单拒绝的错误文本。
  • 写一个断言:命令返回非 0 退出码时,result.isError === true 且 details.exitCode 等于那个非 0 数字。这条断言能防止你之后不小心把失败当成成功回填给模型。

排障速查

现象检查点处理
命令挂住不返回
是否设置了 timeout
timeout 不传就是没有默认超时,宿主必须设上限
超时后子进程还在跑
是否杀了进程组
Unix 上要 process.kill(-pid, "SIGKILL"),只杀直接子进程会留孙进程
模型读到了你的密钥
env 是否整个继承
只传必要变量,别把 process.env 直接交出去
模型看到的是输出开头而不是报错
裁剪方向
命令输出用 truncateTail,read 文件才用 truncateHead
上下文被输出撑爆
裁剪阈值
2000 行 / 50KB,超出部分落临时文件并把路径给模型
退出码非 0 但模型当成成功
结果构造
非 0 必须返回 isError: true,不能只把退出码写进文本
Windows 上杀不掉进程
平台分支
detached 和进程组杀法在 Windows 上不同,需要走 taskkill /F /T
交互式命令卡住
是否需要 TTY
工具跑的是非交互 shell,命令不该等待 stdin
run_command 没被模型看到
tools 白名单
白名单会过滤自定义工具,要把工具名写进去

参考资料