package tools import ( "context" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) func lookInterpreter(t *testing.T, name string) string { t.Helper() p, err := exec.LookPath(name) if err != nil { t.Fatalf("%s interpreter not available on host: %v", name, err) } return p } func TestRunCommandEchoAndExitCode(t *testing.T) { bash := lookInterpreter(t, "bash") root := t.TempDir() reg := NewRegistry(root) RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}}) res := runTool(t, reg, "run_command", `{"command":"echo hello"}`) if res.IsError || !strings.Contains(res.Content, "hello") || !strings.Contains(res.Content, "[exit code 0]") { t.Fatalf("echo failed: %+v", res) } res = runTool(t, reg, "run_command", `{"command":"echo boom >&2; exit 3"}`) if !res.IsError || !strings.Contains(res.Content, "boom") || !strings.Contains(res.Content, "[exit code 3]") { t.Fatalf("non-zero exit must be reported as text: %+v", res) } } func TestRunCommandEnvWhitelist(t *testing.T) { bash := lookInterpreter(t, "bash") t.Setenv("KERNEL_SECRET_LEAK", "must-not-leak") root := t.TempDir() reg := NewRegistry(root) RegisterExecTools(reg, root, ExecConfig{ Bash: bash, Env: []string{"PATH=/usr/bin:/bin", "ALLOWED_FLAG=yes"}, }) res := runTool(t, reg, "run_command", `{"command":"echo allowed=$ALLOWED_FLAG secret=${KERNEL_SECRET_LEAK:-absent}"}`) if res.IsError { t.Fatal(res.Content) } if !strings.Contains(res.Content, "allowed=yes") || !strings.Contains(res.Content, "secret=absent") { t.Fatalf("env whitelist violated, got:\n%s", res.Content) } } func TestRunCommandNilEnvIsEmptyWhitelist(t *testing.T) { bash := lookInterpreter(t, "bash") t.Setenv("KERNEL_SECRET_LEAK", "must-not-leak") root := t.TempDir() reg := NewRegistry(root) // Env left nil: must normalize to empty whitelist (not inherit host). RegisterExecTools(reg, root, ExecConfig{Bash: bash}) res := runTool(t, reg, "run_command", `{"command":"echo secret=${KERNEL_SECRET_LEAK:-absent}"}`) if res.IsError { t.Fatal(res.Content) } if !strings.Contains(res.Content, "secret=absent") { t.Fatalf("nil Env must not inherit host secrets, got:\n%s", res.Content) } } func TestRunCommandExplicitEmptyEnvStaysEmpty(t *testing.T) { bash := lookInterpreter(t, "bash") t.Setenv("KERNEL_SECRET_LEAK", "must-not-leak") root := t.TempDir() reg := NewRegistry(root) RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{}}) res := runTool(t, reg, "run_command", `{"command":"echo secret=${KERNEL_SECRET_LEAK:-absent}"}`) if res.IsError { t.Fatal(res.Content) } if !strings.Contains(res.Content, "secret=absent") { t.Fatalf("explicit empty Env must stay empty, got:\n%s", res.Content) } } func TestRunCommandTimeoutKillsProcessGroup(t *testing.T) { bash := lookInterpreter(t, "bash") root := t.TempDir() reg := NewRegistry(root) RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}}) start := time.Now() res := runTool(t, reg, "run_command", `{"command":"sleep 30 & sleep 30","timeout_seconds":1}`) elapsed := time.Since(start) if !res.IsError || !strings.Contains(res.Content, "[timeout: process group killed]") { t.Fatalf("want timeout kill, got %+v", res) } if elapsed > 10*time.Second { t.Fatalf("process group must die promptly, took %s", elapsed) } } func TestRunCommandContextCancelKillsGroup(t *testing.T) { bash := lookInterpreter(t, "bash") root := t.TempDir() reg := NewRegistry(root) RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}}) ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(200 * time.Millisecond) cancel() }() start := time.Now() res := reg.Execute(ctx, "run_command", []byte(`{"command":"sleep 30","timeout_seconds":60}`)) elapsed := time.Since(start) if elapsed > 10*time.Second { t.Fatalf("cancel must kill promptly, took %s", elapsed) } if !res.IsError { t.Fatalf("canceled command must be an error result, got %+v", res) } } func TestRunCommandBlocklist(t *testing.T) { bash := lookInterpreter(t, "bash") root := t.TempDir() reg := NewRegistry(root) RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}}) for _, cmd := range []string{ `{"command":"sudo rm x"}`, `{"command":"rm -rf /"}`, `{"command":"shutdown now"}`, } { res := runTool(t, reg, "run_command", cmd) if !res.IsError || !strings.Contains(res.Content, "blocklist") { t.Fatalf("blocklist must reject %s, got %+v", cmd, res) } } } func TestRunCommandOutputTruncation(t *testing.T) { bash := lookInterpreter(t, "bash") root := t.TempDir() reg := NewRegistry(root) RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}}) res := runTool(t, reg, "run_command", `{"command":"head -c 200000 /dev/zero | tr '\\0' 'x'"}`) if res.IsError { t.Fatal(res.Content) } if len(res.Content) > execMaxOutput+512 { t.Fatalf("output must be truncated to ~%d bytes, got %d", execMaxOutput, len(res.Content)) } if !strings.Contains(res.Content, "truncated") { t.Fatalf("truncation marker missing, len=%d", len(res.Content)) } } func TestHeadTailBufferBoundsMemoryAndPreservesSmall(t *testing.T) { buf := newHeadTailBuffer(64) if _, err := buf.Write([]byte("hello")); err != nil { t.Fatal(err) } out, total := buf.Snapshot() if total != 5 || string(out) != "hello" { t.Fatalf("small output must be unchanged, got %q total=%d", out, total) } buf = newHeadTailBuffer(64) var payload strings.Builder for i := 0; i < 200; i++ { payload.WriteByte(byte('A' + (i % 26))) } if _, err := buf.Write([]byte(payload.String())); err != nil { t.Fatal(err) } out, total = buf.Snapshot() if total != 200 { t.Fatalf("total must count all bytes, got %d", total) } if len(out) != 64 { t.Fatalf("retained buffer must be capped at 64, got %d", len(out)) } if string(out[:32]) != payload.String()[:32] { t.Fatalf("head mismatch: %q", out[:32]) } if string(out[32:]) != payload.String()[200-32:] { t.Fatalf("tail mismatch: %q", out[32:]) } // Internal retention stays O(max), not O(total). if cap(buf.head)+cap(buf.tail) > 64+8 { t.Fatalf("buffer capacity must stay near max, head=%d tail=%d", cap(buf.head), cap(buf.tail)) } } func TestRunCommandChattyOutputCancelDrains(t *testing.T) { bash := lookInterpreter(t, "bash") root := t.TempDir() reg := NewRegistry(root) RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}}) ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(150 * time.Millisecond) cancel() }() start := time.Now() // Chatty child: continuous stdout until killed; capture must stay bounded and Wait must reap. res := reg.Execute(ctx, "run_command", []byte( `{"command":"while true; do head -c 8192 /dev/zero | tr '\\0' 'y'; done","timeout_seconds":60}`, )) elapsed := time.Since(start) if elapsed > 10*time.Second { t.Fatalf("cancel must drain/reap promptly, took %s", elapsed) } if !res.IsError { t.Fatalf("canceled chatty command must be an error result, got %+v", res) } if len(res.Content) > execMaxOutput+512 { t.Fatalf("chatty cancel output must stay capped, got %d", len(res.Content)) } } func TestRunPythonPersistsScriptAndRuns(t *testing.T) { python := lookInterpreter(t, "python3") root := t.TempDir() reg := NewRegistry(root) RegisterExecTools(reg, root, ExecConfig{Python: python, Env: []string{"PATH=/usr/bin:/bin"}}) res := runTool(t, reg, "run_python", `{"code":"print(2 + 3)\n"}`) if res.IsError || !strings.Contains(res.Content, "5") { t.Fatalf("python run failed: %+v", res) } if !strings.Contains(res.Content, "script: .agent/scripts/") { t.Fatalf("script path must be reported, got:\n%s", res.Content) } matches, _ := filepath.Glob(filepath.Join(root, ".agent", "scripts", "*.py")) if len(matches) != 1 { t.Fatalf("script must be persisted exactly once, got %v", matches) } // 脚本内容落盘可审计。 data, _ := os.ReadFile(matches[0]) if !strings.Contains(string(data), "print(2 + 3)") { t.Fatalf("persisted script content wrong: %q", data) } } func TestRegisterExecToolsSkipsMissingInterpreters(t *testing.T) { reg := NewRegistry(t.TempDir()) RegisterExecTools(reg, t.TempDir(), ExecConfig{}) // 无解释器:两个工具都不注册 names := make(map[string]bool) for _, d := range reg.Decls() { names[d.Name] = true } if names["run_command"] || names["run_python"] { t.Fatalf("no interpreter configured, exec tools must not register, got %v", names) } }