// run_shell / run_python 解释器执行器(与 register_artifact 同族)。 // // 语义:模型提交的脚本原样落盘到会话工作区 .agent/ 下(字节精确、执行后保留以便审计), // 再以该文件为唯一参数启动白名单解释器(bash / python3 的绝对路径)——模型输入不进入 // 进程参数。工作目录锁定会话沙箱;超时取请求 context 与 timeout_sec(默认 600s,无硬上限) // 的先到者,超时杀掉整个进程组;输出 64KB 头尾截断。执行中的输出经可选回调流出。 // 非零退出、超时与取消作为文本反馈返回 // (模型据此自纠),仅启动失败算工具错误。 package tools import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "syscall" "time" core "agent-system-core" agenttools "agent-native/runtime/tools" ) // RegisterInterpreterTools 注册解释器执行原语(run_shell / run_python)。 func RegisterInterpreterTools(r *Registry) error { if err := r.Register(core.ToolDesc{ Name: "run_shell", Source: core.SourceBuiltin, Risk: "medium", Description: "在会话工作区目录内执行 bash 脚本:检查环境、处理文件、运行 Excel/PPT 的 housestyle lint。普通 Word 使用 render_document。脚本原样落盘执行;返回退出码与合并输出(stdout+stderr,超限头尾截断)。沙箱断网且不继承宿主家目录,不要用 pip/apt 安装;缺的 Python 包用 python_env 向系统申请。", Parameters: json.RawMessage(`{"type":"object","properties":{` + `"script":{"type":"string","description":"bash 脚本(可多行)"},` + `"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` + `,"required":["script"]}`), }, execShell); err != nil { return err } if err := registerPythonEnvTool(r); err != nil { return err } return r.Register(core.ToolDesc{ Name: "run_python", Source: core.SourceBuiltin, Risk: "medium", Description: "执行 Python 脚本(python3,工作目录为会话工作区):用于数据处理以及 Excel、PowerPoint、图片生成。普通 Word 必须使用 render_document;register_artifact 会拒绝 Python 或其他任意来源生成的 DOC/DOCX。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。基础层提供 openpyxl、python-pptx 等;缺的包用 python_env 申请,不要 pip install。", Parameters: json.RawMessage(`{"type":"object","properties":{` + `"script":{"type":"string","description":"完整 Python 脚本(utf-8)"},` + `"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` + `,"required":["script"]}`), }, execPython) } // skillsRoot 由组合根在启动时注入(/skills)。空 = 不注入技能路径。 var skillsRoot string // SetSkillsRoot 让 run_python / run_shell 能 import housestyle,并看到技能目录。 func SetSkillsRoot(dir string) { skillsRoot = dir } // execDepsDir 是 sandbox 档的只读基础依赖层(site-packages);组合根启动时注入。 var execDepsDir string // SetExecDepsDir 配置 sandbox 档基础依赖层目录(空 = 不挂载,现状)。 func SetExecDepsDir(dir string) { execDepsDir = dir } // 托管解释器与 pip 垫片。空表示尚未配置,python3 回落到 /usr/bin/python3, // 不再使用 PATH 里可能指向 /home 的解释器(沙箱会把 /home 遮掉)。 var ( execPythonBin string execPythonPrefix string execShimDir string execLayers func(conversationID string) (extra []string, isolated string) ) // SetExecPython 配置托管解释器。prefix 是需要在沙箱里重新挂载的目录。 func SetExecPython(bin, prefix string) { execPythonBin = bin execPythonPrefix = prefix } // SetExecShimDir 把 pip 垫片目录放到 PATH 最前。 func SetExecShimDir(dir string) { execShimDir = dir } // SetExecLayers 按会话返回扩展层或隔离层的 site-packages 目录。 func SetExecLayers(fn func(conversationID string) (extra []string, isolated string)) { execLayers = fn } // execEnv 最小环境。PATH 固定,不继承宿主 PATH:宿主 PATH 里的 linuxbrew // 位于 /home 下,沙箱 tmpfs 会把它遮掉,解析到那里的 python3 无法启动。 func execEnv() []string { return execEnvFor("") } func execEnvFor(conv string) []string { var out []string for _, kv := range os.Environ() { for _, keep := range []string{"HOME=", "LANG=", "LC_", "TZ=", "TERM="} { if strings.HasPrefix(kv, keep) { out = append(out, kv) break } } } out = append(out, "PATH="+execPATH(), "PYTHONDONTWRITEBYTECODE=1", "PIP_NO_INDEX=1", ) dirs, _ := pythonPathFor(conv) if skillsRoot != "" { out = append(out, "AGENT_SKILLS_DIR="+skillsRoot) dirs = append(dirs, filepath.Join(skillsRoot, "house-style")) } if len(dirs) > 0 { out = append(out, "PYTHONPATH="+strings.Join(dirs, string(os.PathListSeparator))) } return out } func execPATH() string { var parts []string if execPythonBin != "" { parts = append(parts, filepath.Dir(execPythonBin)) } if execShimDir != "" { parts = append(parts, execShimDir) } parts = append(parts, "/usr/local/bin", "/usr/bin", "/bin") return strings.Join(parts, ":") } // pythonPathFor 返回要放进 PYTHONPATH、并且在沙箱里只读挂载的目录。 // 隔离层替换基础层,不与基础层叠放。 func pythonPathFor(conv string) (dirs []string, isolated bool) { var extra []string var iso string if execLayers != nil && conv != "" { extra, iso = execLayers(conv) } if iso != "" { return []string{iso}, true } if execDepsDir != "" { dirs = append(dirs, execDepsDir) } dirs = append(dirs, extra...) return dirs, false } // truncateOutput 头 2/3 + 尾 1/3 保留,中部标记省略。 func truncateOutput(b []byte) string { if len(b) <= execMaxOutput { return string(b) } head := execMaxOutput * 2 / 3 tail := execMaxOutput / 3 return string(b[:head]) + fmt.Sprintf("\n…(输出超限,省略 %d 字节)…\n", len(b)-head-tail) + string(b[len(b)-tail:]) } // writeScript 把模型脚本落盘到工作区 .agent/ 下(字节精确,执行后保留),返回绝对路径。 func writeScript(rc ToolExecCtx, sandbox, prefix, ext, content string) (string, error) { dir := filepath.Join(sandbox, execScriptDir) if err := checkToolExternalEffect(rc); err != nil { return "", err } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } p := filepath.Join(dir, fmt.Sprintf("%s-%s-%d%s", prefix, rc.ConversationID(), time.Now().UnixNano(), ext)) if err := checkToolExternalEffect(rc); err != nil { return "", err } if err := os.WriteFile(p, []byte(content), 0o600); err != nil { return "", err } return p, nil } type execContexter interface { Context() context.Context } func execParentCtx(rc ToolExecCtx) context.Context { if c, ok := rc.(execContexter); ok { if ctx := c.Context(); ctx != nil { return ctx } } return context.Background() } // resolveInterpreter 把白名单解释器名钉成绝对路径。 // python3 优先用托管解释器,其次 /usr/bin/python3。不用 LookPath: // 服务进程的 PATH 常把 /home 下的 linuxbrew 放在前面,那个路径在沙箱里不存在。 func resolveInterpreter(name string) (string, error) { switch name { case "bash": for _, p := range []string{"/bin/bash", "/usr/bin/bash"} { if st, err := os.Stat(p); err == nil && !st.IsDir() { return p, nil } } case "python3": if execPythonBin != "" { if st, err := os.Stat(execPythonBin); err == nil && !st.IsDir() { // current/bin/python3 是符号链接。沙箱用 tmpfs 盖住 /home 后只挂真实前缀, // 执行未解析的 current 路径会找不到文件。 resolved, err := filepath.EvalSymlinks(execPythonBin) if err != nil { return "", err } return resolved, nil } return "", fmt.Errorf("python3: configured interpreter missing: %s", execPythonBin) } if st, err := os.Stat("/usr/bin/python3"); err == nil && !st.IsDir() { return "/usr/bin/python3", nil } default: return "", fmt.Errorf("interpreter %s is not allowlisted", name) } p, err := exec.LookPath(name) if err != nil { return "", err } if !filepath.IsAbs(p) { return "", fmt.Errorf("%s: resolved path is not absolute: %s", name, p) } if strings.HasPrefix(p, "/home/") || strings.HasPrefix(p, "/root/") { return "", fmt.Errorf("%s: refusing %s because the sandbox hides that path", name, p) } return p, nil } // runInterpreter 启动白名单解释器执行落盘脚本(脚本路径为唯一参数)。 // 进程启动经 EXT-16 执行后端(direct=现状直启 / sandbox=bwrap 隔离档)。 // 非零退出、超时与请求取消都是正常反馈,以文本返回;仅启动失败算错误。 func runInterpreter(parent context.Context, interpreter, scriptPath, dir, convID string, timeoutS int, sink func(stream, text string), checks ...func() error) (string, error) { if parent == nil { parent = context.Background() } if timeoutS <= 0 { timeoutS = execDefaultTimeoutS } bin, err := resolveInterpreter(interpreter) if err != nil { return "", fmt.Errorf("%s: %w", interpreter, err) } ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutS)*time.Second) defer cancel() var check func() error if len(checks) > 0 { check = checks[0] } spec := ExecSpec{ Bin: bin, Args: []string{scriptPath}, Dir: dir, Env: execEnvFor(convID), SkillsDir: skillsRoot, DepsDir: execDepsDir, Timeout: time.Duration(timeoutS) * time.Second, PythonPrefix: execPythonPrefix, ShimDir: execShimDir, CheckExternalEffect: check, } if dirs, isolated := pythonPathFor(convID); isolated { spec.DepsDir = "" spec.LayerDirs = dirs } else if len(dirs) > 1 || (len(dirs) == 1 && dirs[0] != execDepsDir) { spec.LayerDirs = dirs spec.DepsDir = "" } pr, pw := io.Pipe() var buf bytes.Buffer readDone := make(chan struct{}) go func() { defer close(readDone) tmp := make([]byte, 4096) for { n, rerr := pr.Read(tmp) if n > 0 { if buf.Len() < 1<<20 { buf.Write(tmp[:n]) } if sink != nil { sink("stdout", string(tmp[:n])) } } if rerr != nil { return } } }() proc, err := currentExecBackend().Start(ctx, spec, pw) if err != nil { _ = pw.Close() <-readDone return "", fmt.Errorf("%s: %w", interpreter, err) } err = proc.Wait() _ = pw.Close() <-readDone text := truncateOutput(buf.Bytes()) // S0 工作区 overlay:回收摘要(写回/丢弃/冲突)追加到工具输出—— // 模型需要知道改动是否落地、冲突文件在哪。 if ws, ok := proc.(interface{ Summary() string }); ok { if s := ws.Summary(); s != "" { text += s } } if errors.Is(ctx.Err(), context.DeadlineExceeded) { return fmt.Sprintf("exit=timeout(%ds)\n%s", timeoutS, text), nil } if errors.Is(parent.Err(), context.Canceled) { return fmt.Sprintf("exit=canceled\n%s", text), nil } if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { return fmt.Sprintf("exit=%d\n%s", ee.ExitCode(), text), nil } return "", fmt.Errorf("%s: %v:%s", interpreter, err, text) } return "exit=0\n" + text, nil } func execShell(rc ToolExecCtx, args json.RawMessage) (string, error) { if err := execGate(rc); err != nil { return "", err } var a struct { Script string `json:"script"` Command string `json:"command"` TimeoutS int `json:"timeout_sec"` TimeoutSec int `json:"timeoutSec"` } if len(args) > 0 { if err := json.Unmarshal(args, &a); err != nil { return "", fmt.Errorf("run_shell: bad args: %w", err) } } script := a.Script if strings.TrimSpace(script) == "" { script = a.Command } if strings.TrimSpace(script) == "" { return "", fmt.Errorf("run_shell: script required") } sandbox, err := execSandbox(rc) if err != nil { return "", err } p, err := writeScript(rc, sandbox, "sh", ".sh", script) if err != nil { return "", err } return runInterpreter(execParentCtx(rc), "bash", p, sandbox, rc.ConversationID(), firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc), func() error { return checkToolExternalEffect(rc) }) } // ScriptCommand 准备一条以当前用户身份、工作目录为会话沙箱的 python3 命令。 // 调用方在 Start 之前设置 ExtraFiles、Stdout 和 Stderr。取消 ctx 会杀掉整个进程组。 func ScriptCommand(ctx context.Context, sandbox, path string) (*exec.Cmd, error) { if ctx == nil { ctx = context.Background() } bin, err := resolveInterpreter("python3") if err != nil { return nil, err } cmd := exec.CommandContext(ctx, bin, path) cmd.Dir = sandbox cmd.Env = execEnvFor("") cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.WaitDelay = 2 * time.Second cmd.Cancel = func() error { if cmd.Process == nil { return os.ErrProcessDone } err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) if errors.Is(err, syscall.ESRCH) { return nil } return err } return cmd, nil } func execPython(rc ToolExecCtx, args json.RawMessage) (string, error) { if err := execGate(rc); err != nil { return "", err } var a struct { Script string `json:"script"` TimeoutS int `json:"timeout_sec"` TimeoutSec int `json:"timeoutSec"` } if len(args) > 0 { if err := json.Unmarshal(args, &a); err != nil { return "", fmt.Errorf("run_python: bad args: %w", err) } } if strings.TrimSpace(a.Script) == "" { return "", fmt.Errorf("run_python: script required") } sandbox, err := execSandbox(rc) if err != nil { return "", err } p, err := writeScript(rc, sandbox, "py", ".py", a.Script) if err != nil { return "", err } return runInterpreter(execParentCtx(rc), "python3", p, sandbox, rc.ConversationID(), firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc), func() error { return checkToolExternalEffect(rc) }) } // toolOutputSink 在执行上下文实现了 EmitToolOutput 时,把 stdout 增量交出去。 func toolOutputSink(rc ToolExecCtx) func(stream, text string) { type emitter interface { EmitToolOutput(stream, text string) } e, ok := rc.(emitter) if !ok { return nil } return e.EmitToolOutput } func firstPos(vals ...int) int { for _, v := range vals { if v > 0 { return v } } return 0 } // InterpreterExecConfig 把产品解释器白名单换算为 agent-native 内核的 // ExecConfig(squad 角色经 agent-native 后端执行时复用同一套约束与环境)。 // 两个解释器都缺席或启动级硬关闭时返回 nil(内核侧不注册执行工具,fail-closed)。 func InterpreterExecConfig() *agenttools.ExecConfig { if ExecDisabled() { return nil } cfg := &agenttools.ExecConfig{Env: execEnvFor("")} if p, err := exec.LookPath("bash"); err == nil { cfg.Bash = p } if p, err := exec.LookPath("python3"); err == nil { cfg.Python = p } if cfg.Bash == "" && cfg.Python == "" { return nil } return cfg }