Intelligent Bash Tool Execution
Clawdbot Contributors· validated-in-production
问题
通过Agent实现安全、可靠的命令执行是一项复杂且易出错的工作:
- PTY需求:依赖TTY的CLI(如编码Agent、终端UI)在直接执行时会运行失败
- 平台差异:Linux与macOS在脱离进程、信号处理的表现上存在差异
- 安全考量:任意命令执行需要配套审批流程与提权模式检测机制
- 后台管理:长时间运行的进程需要进行追踪、输出聚合与清理操作
Agent需采用多模式执行策略,在适配命令需求的同时,保障安全性与可靠性。
方案
带自适应回退的多模式执行:直接执行 → PTY
基于命令需求和运行时能力自动选择执行模式。系统可优雅处理PTY启动失败场景,管理后台进程,并提供安全感知的审批工作流。
核心概念:
- 需TTY的命令优先使用PTY:检测到命令需要伪终端(如编码Agent、交互式CLI)时,通过
node-pty启动进程。 - PTY优雅回退:若PTY启动失败(模块缺失、平台不兼容),则在记录警告后回退至直接执行模式。
- 平台专属处理:macOS要求使用分离进程以确保信号正确传播;Linux同时支持两种执行模式。
- 安全感知模式:检测高权限执行需求,提供三类审批流程(拒绝、白名单、完全允许)。
- 后台进程注册表:通过会话ID跟踪长期运行的进程,支持输出尾行查看和退出通知功能。
- 正确的信号传播:超时或主动终止时,将SIGTERM/SIGKILL信号正确传递给子进程。
实现草图:
async function runExecProcess(opts: {
command: string;
workdir: string;
env: Record<string, string>;
usePty: boolean;
timeoutSec: number;
}): Promise<ExecProcessHandle> {
let child: ChildProcess | null = null;
let pty: PtyHandle | null = null;
const warnings: string[] = [];
if (opts.usePty) {
try {
// 动态导入PTY模块并启动PTY进程
const { spawn } = await import("@lydell/node-pty");
pty = spawn(shell, [opts.command], {
cwd: opts.workdir,
env: opts.env,
cols: 120, // 终端列数
rows: 30 // 终端行数
});
} catch (err) {
// PTY不可用,回退至直接执行
warnings.push(`PTY启动失败(${err});将不使用PTY重试。`);
const { child: spawned } = await spawnWithFallback({
argv: [shell, opts.command],
options: { cwd: opts.workdir, env: opts.env },
fallbacks: [{ label: "no-detach", options: { detached: false } }],
});
child = spawned;
}
} else {
// 不使用PTY,直接执行命令
const { child: spawned } = await spawnWithFallback({
argv: [shell, opts.command],
options: { cwd: opts.workdir, env: opts.env, detached: platform !== "win32" },
fallbacks: [{ label: "no-detach", options: { detached: false } }],
});
child = spawned;
}
// 注册会话以跟踪进程状态
const session = {
id: createSessionSlug(),
command: opts.command,
pid: child?.pid ?? pty?.pid,
aggregated: "", // 聚合输出
tail: "", // 最新输出尾行
exited: false // 进程是否已退出
};
addSession(session);
// 设置超时处理:超时后终止进程
if (opts.timeoutSec > 0) {
setTimeout(() => {
if (!session.exited) {
killSession(session); // 先发送SIGTERM,超时未退出则发送SIGKILL
}
}, opts.timeoutSec * 1000);
}
return { session, promise /* 进程退出时resolve */ };
}
针对平台差异的启动回退:
async function spawnWithFallback(params: {
argv: string[];
options: ChildProcess.SpawnOptions;
fallbacks: Array<{ label: string; options: Partial<ChildProcess.SpawnOptions> }>;
}): Promise<{ child: ChildProcess }> {
try {
// 尝试使用初始参数启动进程
return { child: spawn(...params.argv, params.options) };
} catch (err) {
// 初始启动失败,依次尝试回退选项
for (const fallback of params.fallbacks) {
try {
const mergedOptions = { ...params.options, ...fallback.options };
const child = spawn(...params.argv, mergedOptions);
// 记录回退警告
logWarn(`进程启动失败(${err});将使用${fallback.label}选项重试。`);
return { child };
} catch {
continue;
}
}
// 所有回退选项均失败,抛出原始错误
throw err;
}
}
带审批的安全感知执行:
async function executeWithApproval(params: {
command: string;
security: "deny" | "allowlist" | "full";
ask: "off" | "on-miss" | "always";
agentId: string;
}): Promise<ExecResult> {
// 解析当前Agent的执行审批规则
const approvals = resolveExecApprovals(params.agentId, {
security: params.security,
ask: params.ask,
});
// 评估命令是否符合白名单规则
const allowlistEval = evaluateShellAllowlist({
command: params.command,
allowlist: approvals.allowlist,
safeBins: approvals.safeBins,
});
// 判断是否需要发起用户审批
const requiresAsk = requiresExecApproval({
ask: params.ask,
security: params.security,
analysisOk: allowlistEval.analysisOk,
allowlistSatisfied: allowlistEval.allowlistSatisfied,
});
if (requiresAsk) {
const approvalId = crypto.randomUUID();
// 通过网关发起审批请求,等待用户决策
const decision = await requestApproval(approvalId, params.command);
if (decision === "deny") {
throw new Error("执行被拒绝:用户已否决");
}
}
// 执行命令
return runExecProcess(params);
}
后台进程管理:
// 进程会话类型定义
type ProcessSession = {
id: string; // 会话ID
command: string; // 执行的命令
pid?: number; // 进程ID
aggregated: string; // 聚合输出内容
tail: string; // 最新输出尾行
exited: boolean; // 进程是否已退出
exitCode?: number | null; // 退出码
exitSignal?: NodeJS.Signals | null; // 终止进程的信号
backgrounded: boolean; // 是否为后台进程
};
// 会话存储Map
const sessions = new Map<string, ProcessSession>();
// 添加新会话至注册表
function addSession(session: ProcessSession) {
sessions.set(session.id, session);
}
// 将会话标记为后台进程
function markBackgrounded(session: ProcessSession) {
session.backgrounded = true;
}
// 终止会话对应的进程
function killSession(session: ProcessSession) {
if (session.child) {
// 先发送SIGTERM尝试优雅终止
session.child.kill("SIGTERM");
// 1秒后若进程仍未退出,发送SIGKILL强制终止
setTimeout(() => {
if (!session.exited) {
session.child?.kill("SIGKILL");
markExited(session, null, "SIGKILL", "failed");
}
}, 1000);
}
}
如何使用
- 检测TTY需求:检查命令是否为依赖TTY的CLI(编码Agent、交互式工具),并设置
usePty: true。 - 处理PTY启动失败:将PTY进程启动逻辑包裹在try-catch代码块中,启动失败时回退至直接执行(exec)方式,并输出相应警告信息。
- 配置安全模式:设置默认安全级别(
deny、allowlist、full)及审批行为(off、on-miss、always)。 - 注册后台进程:将会话添加至注册表,用于进程的跟踪、轮询与清理。
- 正确传递信号:先发送SIGTERM信号、再发送SIGKILL信号以实现优雅终止,并处理不同平台下分离进程(detached process)的特有行为。
- 聚合输出内容:将标准输出(stdout)与标准错误(stderr)收集至
aggregated变量中,同时维护tail变量用于推送用户通知。
需规避的陷阱:
- PTY模块缺失:
node-pty库可能无法在所有环境中生效;务必提供回退方案。 - 信号处理差异:macOS系统中的分离进程无法接收信号;需使用进程组或替代信号传递机制。
- 僵尸进程残留:务必处理
"close"事件,并清理会话注册表中的对应条目。 - 输出截断问题:大体积输出可能耗尽内存;需强制执行
maxOutput限制,并对输出的中间部分进行截断。
权衡
优点:
- TTY 支持:PTY 模式可启用原本无法运行的、依赖TTY的工具。
- 优雅降级:当PTY不可用时,会回退到直接执行模式。
- 安全层级:多种模式(拒绝、白名单、完全开放)提供灵活的安全策略。
- 后台追踪:进程注册表支持长时间运行任务的管理与清理。
- 平台适配性:针对macOS/Linux的信号传递差异做了适配处理。
缺点/注意事项:
- PTY 依赖:需要
node-pty原生模块,该模块在部分环境中可能编译失败。 - 复杂度提升:多模式执行增加了代码复杂度和测试覆盖范围。
- 输出缓冲限制:将所有输出聚合在内存中,可能导致长时间运行、输出量大的进程耗尽内存。
- 信号传递限制:macOS上的分离进程无法接收信号,需采用变通方案。
参考文献
关键词:
涉及Clawdbot项目中Bash工具相关的三类核心代码文件,分别覆盖执行模式、进程管理、后台注册表功能,同时关联用于远程执行模式的虚拟机操作员代理方案。
直译:
- Clawdbot bash-tools.exec.ts - 执行模式
- Clawdbot bash-tools.process.ts - 进程管理
- Clawdbot bash-process-registry.ts - 后台注册表
- 相关内容:虚拟机操作员代理,适用于远程执行模式场景
来源摘要
正在获取来源并生成中文摘要…
来源: https://github.com/clawdbot/clawdbot/blob/main/src/agents/bash-tools.exec.ts