Files
Agent-System-Backend/internal/tools/exec_interpreter_test.go
weichen 68c1a7272a
ci / vet-and-test (pull_request) Successful in 43s
Keep tracked-change and hyperlink text in DOCX and add an exec hard switch
Why: The DOCX parser only read w:r runs that were direct children of w:p, so
text inside w:ins, w:hyperlink and w:fldSimple was dropped with no warning,
violating the no-silent-degradation rule. Separately, runtime.exec is a
user-writable setting, so multi-user deployments could not reliably turn the
exec primitives off.
Evidence: SELF - red tests TestParseDocxTrackedChangesAndHyperlinks and
TestExecHardDisabledOverridesSettings failed before and pass after; backend
go test ./... (32 packages) passes; a mock-mode binary started with
--disable-exec-tools hides run_shell/run_python/register_artifact from
/api/tools even after the user sets runtime.exec=true.
Impact: DOCX paragraphs are decoded from the token stream at any depth;
deleted/moved-from text, field codes and mc:Fallback are excluded and
revised documents carry an explicit warning. New Options.DisableExecTools
(SurfaceVersion 2.5.0-experimental), CLI --disable-exec-tools, config key
disableExecTools and env AGENT_SYSTEM_DISABLE_EXEC_TOOLS; zero value keeps
current behavior.
Refs: Agent-System-RDM docs/proposals/2026-10-09-V22-review-and-implementation-plan.md (G0-7, B-5)
2026-10-09 10:36:02 +00:00

236 lines
8.0 KiB
Go

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
output []string
}
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) EmitToolOutput(stream, text string) { c.output = append(c.output, stream+":"+text) }
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)
}
}
// 启动级硬关闭:用户设置 runtime.exec=true 也无法打开执行原语,
// 且执行原语从模型可见清单中消失、内核执行配置为空(fail-closed)。
func TestExecHardDisabledOverridesSettings(t *testing.T) {
t.Cleanup(func() { SetExecDisabled(false) })
SetExecDisabled(true)
r := newInterpRegistry(t)
if err := RegisterExecTools(r); err != nil {
t.Fatal(err)
}
rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true}
for _, name := range []string{"run_shell", "run_python"} {
res := interpCall(t, r, rc, name, `{"script":"echo should-not-run"}`)
if res.Status != "error" || !strings.Contains(res.Output, "启动参数禁用") {
t.Fatalf("%s must be refused under hard disable: %s %s", name, res.Status, res.Output)
}
}
res := interpCall(t, r, rc, "register_artifact", `{"path":"x.docx"}`)
if res.Status != "error" || !strings.Contains(res.Output, "启动参数禁用") {
t.Fatalf("register_artifact must be refused under hard disable: %s %s", res.Status, res.Output)
}
if InterpreterExecConfig() != nil {
t.Fatal("kernel exec config must be nil under hard disable")
}
for _, d := range FilterExecDisabled(r.List()) {
if IsExecTool(d.Name) {
t.Fatalf("exec tool %s must be hidden under hard disable", d.Name)
}
}
SetExecDisabled(false)
if res := interpCall(t, r, rc, "run_shell", `{"script":"echo back"}`); res.Status != "ok" {
t.Fatalf("exec must work again when not hard-disabled: %s %s", res.Status, res.Output)
}
if len(FilterExecDisabled(r.List())) != len(r.List()) {
t.Fatal("filter must be a no-op when not hard-disabled")
}
}
func TestRunShellStreamsOutput(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":"echo streamed-line"}`)
if res.Status != "ok" || !strings.Contains(res.Output, "streamed-line") {
t.Fatalf("result: %s %s", res.Status, res.Output)
}
joined := strings.Join(rc.output, "")
if !strings.Contains(joined, "stdout:") || !strings.Contains(joined, "streamed-line") {
t.Fatalf("streamed chunks: %q", rc.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)
}
}
func TestExecEnvIncludesSkillsRoot(t *testing.T) {
prev := skillsRoot
t.Cleanup(func() { skillsRoot = prev })
SetSkillsRoot(filepath.Join(t.TempDir(), "skills"))
var dir, pythonpath string
for _, kv := range execEnv() {
if strings.HasPrefix(kv, "AGENT_SKILLS_DIR=") {
dir = strings.TrimPrefix(kv, "AGENT_SKILLS_DIR=")
}
if strings.HasPrefix(kv, "PYTHONPATH=") {
pythonpath = strings.TrimPrefix(kv, "PYTHONPATH=")
}
}
if dir != skillsRoot {
t.Fatalf("AGENT_SKILLS_DIR = %q", dir)
}
if pythonpath != filepath.Join(skillsRoot, "house-style") {
t.Fatalf("PYTHONPATH = %q", pythonpath)
}
}
func TestInterpreterExecConfigForKernel(t *testing.T) {
cfg := InterpreterExecConfig()
if cfg == nil {
t.Fatal("host has bash/python3; kernel exec config must resolve")
}
if cfg.Bash == "" && cfg.Python == "" {
t.Fatal("at least one interpreter path must be set")
}
if !filepath.IsAbs(cfg.Bash) && cfg.Bash != "" {
t.Fatalf("bash path must be absolute, got %q", cfg.Bash)
}
for _, kv := range cfg.Env {
if strings.HasPrefix(kv, "PATH=") || strings.HasPrefix(kv, "HOME=") || strings.HasPrefix(kv, "LANG=") ||
strings.HasPrefix(kv, "LC_") || strings.HasPrefix(kv, "TZ=") || strings.HasPrefix(kv, "TERM=") ||
kv == "PYTHONDONTWRITEBYTECODE=1" || strings.HasPrefix(kv, "AGENT_SKILLS_DIR=") || strings.HasPrefix(kv, "PYTHONPATH=") {
continue
}
t.Fatalf("env whitelist violated: %q", kv)
}
}