diff --git a/internal/app/app.go b/internal/app/app.go index 930956b..0d7454e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -81,6 +81,9 @@ func Run(opt Options) error { if err := tools.RegisterExecTools(reg); err != nil { return fmt.Errorf("exec tools: %w", err) } + if err := tools.RegisterInterpreterTools(reg); err != nil { + return fmt.Errorf("interpreter tools: %w", err) + } setSvc := settings.NewService(st) setSvc.SetSystemOverride(map[string]any{"storage": map[string]any{"dataDir": opt.DataDir}}) // 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。 diff --git a/internal/runmgr/exec_tool_artifact_test.go b/internal/runmgr/exec_tool_artifact_test.go index b2ed6e4..c6dfb77 100644 --- a/internal/runmgr/exec_tool_artifact_test.go +++ b/internal/runmgr/exec_tool_artifact_test.go @@ -6,7 +6,9 @@ package runmgr import ( "context" "encoding/json" + "os" "path/filepath" + "strings" "testing" "agent-system-backend/internal/bus" @@ -63,6 +65,46 @@ func TestExecuteToolBackfillsArtifact(t *testing.T) { } } +// run_python 生成文件后 register_artifact 必须把工件回填到 ToolCallResult.Artifact。 +func TestRunPythonRegisterArtifactBackfill(t *testing.T) { + c, _ := newExecEnv(t) + root := t.TempDir() + c.mgr.SetWorkspaceRoot(root) + if err := tools.RegisterExecTools(c.mgr.registry); err != nil { + t.Fatal(err) + } + if err := tools.RegisterInterpreterTools(c.mgr.registry); err != nil { + t.Fatal(err) + } + py := c.ExecuteTool(core.ToolCallRequest{ + CallID: "py1", Name: "run_python", + Arguments: json.RawMessage(`{"script":"open('note.docx','wb').write(b'PK\\x03\\x04hello')\n"}`), + }) + if py.Status != "ok" || !strings.Contains(py.Output, "exit=0") { + t.Fatalf("run_python: %s %s", py.Status, py.Output) + } + res := c.ExecuteTool(core.ToolCallRequest{ + CallID: "reg1", Name: "register_artifact", + Arguments: json.RawMessage(`{"path":"note.docx"}`), + }) + if res.Status != "ok" { + t.Fatalf("register_artifact: %s", res.Output) + } + if res.Artifact == nil || res.Artifact.Name != "note.docx" { + t.Fatalf("ToolCallResult.Artifact 未回填: %+v", res.Artifact) + } + if res.Artifact.MIME != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" { + t.Errorf("docx mime: %s", res.Artifact.MIME) + } + b, err := os.ReadFile(filepath.Join(root, "alice", c.run.ConversationID, "note.docx")) + if err != nil { + t.Fatal(err) + } + if string(b[:4]) != "PK\x03\x04" { + t.Fatalf("stored bytes: %q", b) + } +} + // 空手工具(如 time_now)不得凭空挂 Artifact。 func TestExecuteToolNoArtifactByDefault(t *testing.T) { c, _ := newExecEnv(t) diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 2490904..8c32e32 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -468,6 +468,7 @@ func (t *toolCtx) WriteArtifact(name, kind, mime string, content []byte) (core.A } return art, err } +func (t *toolCtx) Context() context.Context { return t.rc.Context() } func (t *toolCtx) OwnerID() string { return t.rc.run.Owner } func (t *toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot } func (t *toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) } diff --git a/internal/tools/exec.go b/internal/tools/exec.go index 32a3bb1..2d9248d 100644 --- a/internal/tools/exec.go +++ b/internal/tools/exec.go @@ -47,10 +47,9 @@ func execSandbox(rc ToolExecCtx) (string, error) { return sandbox, nil } -// RegisterExecTools 注册执行原语族。当前批次:register_artifact(本文件)。 -// run_shell / run_python(解释器执行器)实现方案已定(脚本落盘+白名单解释器), -// 因安全门禁分批落盘——补齐后在 app.go 追加 RegisterInterpreterTools 并同批 -// 注册(契约见 base.deliverables 提示词)。 +// RegisterExecTools 注册执行原语族的登记桥(register_artifact)。 +// run_shell / run_python 在 exec_interpreter.go,由 RegisterInterpreterTools 同批注册 +// (契约见 base.deliverables 提示词)。 func RegisterExecTools(r *Registry) error { return r.Register(core.ToolDesc{ Name: "register_artifact", Source: core.SourceBuiltin, diff --git a/internal/tools/exec_interpreter.go b/internal/tools/exec_interpreter.go new file mode 100644 index 0000000..433a838 --- /dev/null +++ b/internal/tools/exec_interpreter.go @@ -0,0 +1,234 @@ +// run_shell / run_python 解释器执行器(与 register_artifact 同族)。 +// +// 语义:模型提交的脚本原样落盘到会话工作区 .agent/ 下(字节精确、执行后保留以便审计), +// 再以该文件为唯一参数启动白名单解释器(bash / python3 的绝对路径)——模型输入不进入 +// 进程参数。工作目录锁定会话沙箱;超时取请求 context 与 timeout_sec(默认 60s、上限 300s) +// 的先到者,超时杀掉整个进程组;输出 64KB 头尾截断。非零退出、超时与取消作为文本反馈返回 +// (模型据此自纠),仅启动失败算工具错误。 +package tools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + core "agent-system-core" +) + +// RegisterInterpreterTools 注册解释器执行原语(run_shell / run_python)。 +func RegisterInterpreterTools(r *Registry) error { + if err := r.Register(core.ToolDesc{ + Name: "run_shell", Source: core.SourceBuiltin, + Description: "在会话工作区目录内执行 bash 脚本:检查环境、安装依赖(pip install --user …)、处理文件等。脚本原样落盘执行;返回退出码与合并输出(stdout+stderr,超限头尾截断)。以当前用户身份运行。", + Parameters: json.RawMessage(`{"type":"object","properties":{` + + `"script":{"type":"string","description":"bash 脚本(可多行)"},` + + `"timeout_sec":{"type":"integer","description":"超时秒数(默认 60,上限 300)"}}` + + `,"required":["script"]}`), + }, execShell); err != nil { + return err + } + return r.Register(core.ToolDesc{ + Name: "run_python", Source: core.SourceBuiltin, + Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。python-docx/openpyxl/python-pptx 缺失时可先 pip install --user 安装。", + Parameters: json.RawMessage(`{"type":"object","properties":{` + + `"script":{"type":"string","description":"完整 Python 脚本(utf-8)"},` + + `"timeout_sec":{"type":"integer","description":"超时秒数(默认 60,上限 300)"}}` + + `,"required":["script"]}`), + }, execPython) +} + +// execEnv 最小环境:PATH/HOME/LANG/TZ/TERM 继承(pip --user、字体、中文 locale), +// 其余变量不带入子进程。 +func execEnv() []string { + var out []string + for _, kv := range os.Environ() { + for _, keep := range []string{"PATH=", "HOME=", "LANG=", "LC_", "TZ=", "TERM="} { + if strings.HasPrefix(kv, keep) { + out = append(out, kv) + break + } + } + } + return append(out, "PYTHONDONTWRITEBYTECODE=1") +} + +// 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 := 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 := 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 把白名单解释器名钉成绝对路径,避免 PATH 在子进程里被换掉。 +func resolveInterpreter(name string) (string, error) { + switch name { + case "bash", "python3": + 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) + } + return p, nil +} + +// runInterpreter 启动白名单解释器执行落盘脚本(脚本路径为唯一参数)。 +// 非零退出、超时与请求取消都是正常反馈,以文本返回;仅启动失败算错误。 +func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, timeoutS int) (string, error) { + if parent == nil { + parent = context.Background() + } + if timeoutS <= 0 { + timeoutS = execDefaultTimeoutS + } + if timeoutS > execMaxTimeoutS { + timeoutS = execMaxTimeoutS + } + 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() + cmd := exec.CommandContext(ctx, bin, scriptPath) + cmd.Dir = dir + cmd.Env = execEnv() + 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 + } + out, err := cmd.CombinedOutput() + text := truncateOutput(out) + 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, firstPos(a.TimeoutS, a.TimeoutSec)) +} + +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, firstPos(a.TimeoutS, a.TimeoutSec)) +} + +func firstPos(vals ...int) int { + for _, v := range vals { + if v > 0 { + return v + } + } + return 0 +} diff --git a/internal/tools/exec_interpreter_test.go b/internal/tools/exec_interpreter_test.go new file mode 100644 index 0000000..71ea4c3 --- /dev/null +++ b/internal/tools/exec_interpreter_test.go @@ -0,0 +1,140 @@ +package tools + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + core "agent-system-core" +) + +type execFake struct { + root string + owner string + conv string + execOn bool + settings map[string]bool + ctx context.Context +} + +func (c *execFake) ProjectID() string { return "p1" } +func (c *execFake) ConversationID() string { return c.conv } +func (c *execFake) OwnerID() string { return c.owner } +func (c *execFake) WorkspaceRoot() string { return c.root } +func (c *execFake) Context() context.Context { + if c.ctx != nil { + return c.ctx + } + return context.Background() +} +func (c *execFake) SettingBool(key string, def bool) bool { + if c.settings != nil { + if v, ok := c.settings[key]; ok { + return v + } + } + if key == execSetting { + return c.execOn + } + return def +} +func (c *execFake) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil } +func (c *execFake) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { + return core.Artifact{ID: "art_" + name, Name: name, Kind: kind, MIME: mime, Size: int64(len(content))}, nil +} +func (c *execFake) PeerConversations() ([]core.Conversation, error) { return nil, nil } +func (c *execFake) PeerConversationMessages(string, int) ([]core.Message, error) { + return nil, nil +} + +func newInterpRegistry(t *testing.T) *Registry { + t.Helper() + r := NewRegistry() + if err := RegisterInterpreterTools(r); err != nil { + t.Fatal(err) + } + return r +} + +func interpCall(t *testing.T, r *Registry, rc *execFake, name, args string) core.ToolCallResult { + t.Helper() + return r.Execute(rc, core.ToolCallRequest{CallID: "c1", Name: name, Arguments: json.RawMessage(args)}) +} + +func TestRunShellEchoKeepsScript(t *testing.T) { + r := newInterpRegistry(t) + root := t.TempDir() + rc := &execFake{root: root, owner: "o1", conv: "c1", execOn: true} + const script = "echo hello-from-shell\n" + res := interpCall(t, r, rc, "run_shell", `{"script":"echo hello-from-shell\n"}`) + if res.Status != "ok" || !strings.Contains(res.Output, "exit=0") || !strings.Contains(res.Output, "hello-from-shell") { + t.Fatalf("echo script: %s %s", res.Status, res.Output) + } + dir := filepath.Join(root, "o1", "c1", execScriptDir) + ents, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(ents) != 1 { + t.Fatalf("script should be retained for audit, got %d files", len(ents)) + } + b, err := os.ReadFile(filepath.Join(dir, ents[0].Name())) + if err != nil { + t.Fatal(err) + } + if string(b) != script { + t.Fatalf("script bytes mismatch: %q", b) + } +} + +func TestRunShellCommandAlias(t *testing.T) { + r := newInterpRegistry(t) + rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true} + res := interpCall(t, r, rc, "run_shell", `{"command":"echo aliased"}`) + if res.Status != "ok" || !strings.Contains(res.Output, "aliased") { + t.Fatalf("command alias: %s %s", res.Status, res.Output) + } +} + +func TestRunShellEmptyAndGate(t *testing.T) { + r := newInterpRegistry(t) + rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true} + res := interpCall(t, r, rc, "run_shell", `{"script":" "}`) + if res.Status != "error" || !strings.Contains(res.Output, "script required") { + t.Fatalf("empty script: %s %s", res.Status, res.Output) + } + rc.execOn = false + res = interpCall(t, r, rc, "run_shell", `{"script":"echo no"}`) + if res.Status != "error" || !strings.Contains(res.Output, "runtime.exec") { + t.Fatalf("gate off: %s %s", res.Status, res.Output) + } +} + +func TestRunShellTimeoutKillsProcessGroup(t *testing.T) { + r := newInterpRegistry(t) + rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true} + res := interpCall(t, r, rc, "run_shell", `{"timeout_sec":1,"script":"sleep 30 &\nwait\n"}`) + if res.Status != "ok" || !strings.Contains(res.Output, "exit=timeout(1s)") { + t.Fatalf("timeout: %s %s", res.Status, res.Output) + } +} + +func TestRunPythonWritesZipMagic(t *testing.T) { + r := newInterpRegistry(t) + root := t.TempDir() + rc := &execFake{root: root, owner: "o1", conv: "c1", execOn: true} + res := interpCall(t, r, rc, "run_python", `{"script":"open('note.docx','wb').write(b'PK\\x03\\x04hello')\n"}`) + if res.Status != "ok" || !strings.Contains(res.Output, "exit=0") { + t.Fatalf("python: %s %s", res.Status, res.Output) + } + b, err := os.ReadFile(filepath.Join(root, "o1", "c1", "note.docx")) + if err != nil { + t.Fatal(err) + } + if len(b) < 4 || string(b[:4]) != "PK\x03\x04" { + t.Fatalf("docx magic: %q", b) + } +} diff --git a/internal/tools/fs.go b/internal/tools/fs.go index 4f4b57d..9addf8f 100644 --- a/internal/tools/fs.go +++ b/internal/tools/fs.go @@ -202,6 +202,18 @@ func mimeOfExt(name string) string { return "text/csv" case ".html": return "text/html" + case ".docx": + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + case ".xlsx": + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + case ".pptx": + return "application/vnd.openxmlformats-officedocument.presentationml.presentation" + case ".pdf": + return "application/pdf" + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" default: return "text/plain; charset=utf-8" }