package builtin // exec_tool.go 实现通过子进程执行的外部工具. // // ExecTool 将外部程序包装成 Agent 可调用的工具. // 调用协议: // - 通过 os/exec 启动 Exec 配置的子进程 // - 将工具调用输入(JSON)写入子进程 stdin // - 读取子进程 stdout 作为工具输出 // - 子进程 exit code != 0 → Result.IsError=true // // 这为 Agent 提供了一种"无需修改引擎源码"的工具扩展路径: // - 写一个读 stdin,写 stdout 的脚本/程序 // - 在 JSON 定义文件中声明它 // - 通过 --tools-schema 传入引擎 // // 跨行业扩展(ELEVATED): // - 仓储场景:exec 一个 Python 脚本查询 WMS 库存系统 // - 医疗场景:exec 一个 Go 二进制调用院内 HIS 接口 // - 任何有 CLI 接口的外部系统都可以通过此机制接入 // // 安全注记(三层防御): // - 指令层:子进程命令在 JSON 定义文件中固定,不接受运行时覆盖 // - 参数层:输入作为 stdin 传递,不拼接到命令行(防注入) // - 兜底层:超时强制终止(默认 30 秒),防止子进程无限挂起 import ( "bytes" "context" "encoding/json" "fmt" "io" "os" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" "git.flytoex.net/yuanwei/flyto-agent/pkg/permission" "git.flytoex.net/yuanwei/flyto-agent/pkg/tools" ) // ExecToolDef 是外部工具的 JSON 定义格式. // 通过 JSON 文件描述一个外部可执行程序工具,支持数组形式批量定义. // // 示例 JSON: // // { // "name": "query_inventory", // "description": "查询仓库库存系统", // "input_schema": {"type":"object","properties":{"sku":{"type":"string"}},"required":["sku"]}, // "exec": ["python3", "/opt/tools/query_wms.py"], // "timeout_seconds": 10 // } type ExecToolDef struct { Name string `json:"name"` Description string `json:"description"` InputSchema json.RawMessage `json:"input_schema"` Exec []string `json:"exec"` // 命令 + 参数,至少一个元素 TimeoutSeconds int `json:"timeout_seconds"` // 默认 30 } // ExecTool 实现 tools.Tool 接口,通过子进程执行外部程序. // // 精妙之处(CLEVER): 命令固定在定义文件中,输入通过 stdin 传递-- // 不可能通过工具调用注入额外命令行参数,消除了命令注入的攻击面. // 替代方案:<把参数拼到 exec 数组尾部> - 否决:任意字符串参数可触发 shell 解析或 // 被恶意程序解释为子命令(如 git 的 --exec 系列参数). type ExecTool struct { def ExecToolDef executor execenv.Executor } // NewExecTool 创建 ExecTool 实例. // def.Exec 必须非空(至少包含可执行文件路径). // // executor 必填, nil 即 panic (M1 方案 β 严格 DI, 无 fallback). // 本地 CLI 传 execenv.DefaultExecutor{}, 云端 SaaS 由 platform 层传 // sandbox.Backend. ClassUserHook 告诉 backend "这是用户静态声明的 exec // tool, 信任度同 UserHook / settings.json hook". func NewExecTool(def ExecToolDef, executor execenv.Executor) (*ExecTool, error) { if def.Name == "" { return nil, fmt.Errorf("exec_tool: name is required") } if len(def.Exec) == 0 { return nil, fmt.Errorf("exec_tool %q: exec command is required", def.Name) } if executor == nil { panic("builtin.NewExecTool: executor is required (M1 strict DI, no fallback)") } // 默认超时 30 秒 if def.TimeoutSeconds <= 0 { def.TimeoutSeconds = 30 } return &ExecTool{def: def, executor: executor}, nil } // Name 返回工具名称. func (t *ExecTool) Name() string { return t.def.Name } // Description 返回工具描述. func (t *ExecTool) Description(_ context.Context) string { return t.def.Description } // InputSchema 返回工具的 JSON Schema 输入定义. func (t *ExecTool) InputSchema() json.RawMessage { if len(t.def.InputSchema) > 0 { return t.def.InputSchema } // 兜底:空 object schema(防御点:返回有效 schema 而非 nil,避免 API 序列化失败) return json.RawMessage(`{"type":"object","properties":{}}`) } // Metadata 返回工具元数据. // 外部工具保守默认:不可并发,非只读,非破坏性(具体行为未知). func (t *ExecTool) Metadata() tools.Metadata { return tools.Metadata{ ConcurrencySafe: false, ReadOnly: false, Destructive: false, PermissionClass: permission.PermClassGeneric, AuditOperation: "execute", } } // Execute 启动子进程执行工具逻辑. // // 执行流程: // 1. 创建带超时的 context // 2. 启动 Exec 子进程,stdin 连接管道 // 3. 将 input JSON 写入 stdin,关闭 stdin(EOF 通知子进程输入结束) // 4. 读取 stdout 作为输出 // 5. 等待子进程退出,exit code != 0 → IsError=true // // 升华改进(ELEVATED): stdin/stdout 协议天然支持流式大输入-- // 通过 io.Pipe 可以零内存拷贝地将大型 JSON 输入传入子进程, // 比 HTTP 接口的请求体上传更轻量. // 替代方案:<命令行参数传递 JSON> - 否决:有 ARG_MAX 限制(Linux 通常 2MB), // 大型输入(文件内容嵌入 JSON)会失败. func (t *ExecTool) Execute(ctx context.Context, input json.RawMessage, _ tools.ProgressFunc) (*tools.Result, error) { // 构建带超时的子 context timeout := time.Duration(t.def.TimeoutSeconds) * time.Second execCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() // 构建命令:第一个元素为可执行文件,其余为固定参数 // 精妙之处(CLEVER): 走 Executor.Command(execCtx, Spec{...})-- // execCtx 带超时, 实现层 (DefaultExecutor / sandbox backend) 负责 // 在 ctx 过期时终止子进程, 本地路径等价原先 exec.CommandContext 的 SIGKILL 行为. // 替代方案:<手动管理超时 + SIGTERM/SIGKILL> - 对外部工具来说 SIGKILL 直接终止更合适, // 外部工具通常是短暂的辅助进程,不需要优雅清理(不像 BashTool 那样有进程组管理需求). // // Env 走 FullInheritMap (ClassUserHook 信任模型: 用户对自己机器负责, 和 Git // hooks / systemd Exec= / npm pre-scripts 默认语义一致). 早期方案 exec.CommandContext // 默认 cmd.Env=nil 即继承 os.Environ, 迁移后 bit-identical. // // Stdout/Stderr 走 Writer 模式 (Spec.Stdout/Stderr 字段), 不走 StdoutPipe-- // 收集到 buffer 的姿势和早期方案一致, 只是管道建立由 Executor 负责. var stdoutBuf, stderrBuf bytes.Buffer proc := t.executor.Command(execCtx, execenv.Spec{ Class: execenv.ClassUserHook, Path: t.def.Exec[0], Args: t.def.Exec[1:], Env: execenv.FullInheritMap(nil), Stdout: &stdoutBuf, Stderr: &stderrBuf, }) // 连接 stdin 管道(输入 JSON) stdinPipe, err := proc.StdinPipe() if err != nil { return nil, fmt.Errorf("exec_tool %q: create stdin pipe: %w", t.def.Name, err) } // 启动子进程 if err := proc.Start(); err != nil { return &tools.Result{ Output: fmt.Sprintf("error: failed to start %q: %v", t.def.Exec[0], err), IsError: true, }, nil } // 写入输入 JSON 到 stdin,写完后关闭(EOF 通知子进程) // 精妙之处(CLEVER): 写入和关闭 stdin 放在 goroutine 里,避免子进程的 stdout 缓冲区 // 塞满导致死锁(子进程等我们读 stdout,我们等子进程读完 stdin → 死锁). // 替代方案:<直接写然后关闭 stdinPipe> - 仅在 stdout 输出量很小时安全, // 外部工具输出量不可控,goroutine 写 stdin 是正确姿势. stdinErr := make(chan error, 1) go func() { var writeErr error if len(input) > 0 { _, writeErr = io.WriteString(stdinPipe, string(input)) } closeErr := stdinPipe.Close() if writeErr != nil { stdinErr <- writeErr } else { stdinErr <- closeErr } }() // 等待子进程结束 waitErr := proc.Wait() // 检查 stdin 写入错误(非阻塞,已经有结果了) select { case se := <-stdinErr: if se != nil && waitErr == nil { // stdin 写入失败但进程已正常退出(罕见,记录但不覆盖主错误) _ = se } default: } // 处理超时(context deadline exceeded) if execCtx.Err() == context.DeadlineExceeded { return &tools.Result{ Output: fmt.Sprintf("error: exec_tool %q timed out after %v", t.def.Name, timeout), IsError: true, }, nil } // 获取退出码. // 精妙之处(CLEVER): 用 duck typing `interface{ ExitCode() int }` 提取退出码, // 不绑定 *exec.ExitError 具体类型. 这样云端 backend 未来可以返回自己的 // ExitError 类型 (只需实现 ExitCode() int 方法), caller 零感知. // 替代方案: - 锁死 POSIX os/exec 实现, // 违反 Executor/Process 门框红线 (不暴露 *exec.Cmd 及其衍生类型). exitCode := 0 if waitErr != nil { if ec, ok := waitErr.(interface{ ExitCode() int }); ok { exitCode = ec.ExitCode() } else { // 进程启动后未能 Wait(极罕见,如 OS 级别错误) return &tools.Result{ Output: fmt.Sprintf("error: exec_tool %q wait failed: %v", t.def.Name, waitErr), IsError: true, }, nil } } output := stdoutBuf.String() if output == "" && exitCode != 0 { // 子进程没有 stdout 输出时,用 stderr 提供诊断信息 stderr := stderrBuf.String() if stderr != "" { output = fmt.Sprintf("exit code %d\n[stderr] %s", exitCode, stderr) } else { output = fmt.Sprintf("exit code %d (no output)", exitCode) } } return &tools.Result{ Output: output, IsError: exitCode != 0, }, nil } // LoadExecToolsFromFile 从 JSON 文件加载外部工具定义列表. // // JSON 格式支持两种形式: // - 单个工具对象:{...} // - 工具对象数组:[{...}, {...}] // // 升华改进(ELEVATED): 同时支持单对象和数组格式,方便不同的生成工具写出的文件. // Kubernetes ConfigMap 倾向于单个对象,脚本批量生成倾向于数组. // 替代方案:<只支持数组> - 否决:单工具场景需要额外包一层 `[]`,容易遗漏出错. // // executor 透传给每个 NewExecTool, nil 即 panic (M1 方案 β 严格 DI). func LoadExecToolsFromFile(path string, executor execenv.Executor) ([]*ExecTool, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("exec_tool: load %q: %w", path, err) } // 去掉首尾空白,判断是对象还是数组 trimmed := bytes.TrimSpace(data) if len(trimmed) == 0 { return nil, fmt.Errorf("exec_tool: load %q: empty file", path) } var defs []ExecToolDef if trimmed[0] == '[' { // 数组格式 if err := json.Unmarshal(data, &defs); err != nil { return nil, fmt.Errorf("exec_tool: parse %q as array: %w", path, err) } } else { // 单对象格式 var single ExecToolDef if err := json.Unmarshal(data, &single); err != nil { return nil, fmt.Errorf("exec_tool: parse %q as object: %w", path, err) } defs = []ExecToolDef{single} } result := make([]*ExecTool, 0, len(defs)) for i, def := range defs { t, err := NewExecTool(def, executor) if err != nil { return nil, fmt.Errorf("exec_tool: load %q[%d]: %w", path, i, err) } result = append(result, t) } return result, nil }