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);
  }
}

如何使用

  1. 检测TTY需求:检查命令是否为依赖TTY的CLI(编码Agent、交互式工具),并设置usePty: true
  2. 处理PTY启动失败:将PTY进程启动逻辑包裹在try-catch代码块中,启动失败时回退至直接执行(exec)方式,并输出相应警告信息。
  3. 配置安全模式:设置默认安全级别(denyallowlistfull)及审批行为(offon-missalways)。
  4. 注册后台进程:将会话添加至注册表,用于进程的跟踪、轮询与清理。
  5. 正确传递信号:先发送SIGTERM信号、再发送SIGKILL信号以实现优雅终止,并处理不同平台下分离进程(detached process)的特有行为。
  6. 聚合输出内容:将标准输出(stdout)与标准错误(stderr)收集至aggregated变量中,同时维护tail变量用于推送用户通知。

需规避的陷阱

  • PTY模块缺失node-pty库可能无法在所有环境中生效;务必提供回退方案。
  • 信号处理差异:macOS系统中的分离进程无法接收信号;需使用进程组或替代信号传递机制。
  • 僵尸进程残留:务必处理"close"事件,并清理会话注册表中的对应条目。
  • 输出截断问题:大体积输出可能耗尽内存;需强制执行maxOutput限制,并对输出的中间部分进行截断。

权衡

优点

  • TTY 支持:PTY 模式可启用原本无法运行的、依赖TTY的工具。
  • 优雅降级:当PTY不可用时,会回退到直接执行模式。
  • 安全层级:多种模式(拒绝、白名单、完全开放)提供灵活的安全策略。
  • 后台追踪:进程注册表支持长时间运行任务的管理与清理。
  • 平台适配性:针对macOS/Linux的信号传递差异做了适配处理。

缺点/注意事项

  • PTY 依赖:需要node-pty原生模块,该模块在部分环境中可能编译失败。
  • 复杂度提升:多模式执行增加了代码复杂度和测试覆盖范围。
  • 输出缓冲限制:将所有输出聚合在内存中,可能导致长时间运行、输出量大的进程耗尽内存。
  • 信号传递限制:macOS上的分离进程无法接收信号,需采用变通方案。

参考文献

关键词

涉及Clawdbot项目中Bash工具相关的三类核心代码文件,分别覆盖执行模式、进程管理、后台注册表功能,同时关联用于远程执行模式的虚拟机操作员代理方案。

直译

来源摘要

正在获取来源并生成中文摘要…

来源: https://github.com/clawdbot/clawdbot/blob/main/src/agents/bash-tools.exec.ts

← 返回社区