Why: the kernel had no system-tool capability, so deliverable generation via CLI/Python (the settled product direction) could not run inside agent-native executions; squad's BashPermission gate had nothing to guard. Evidence: go vet ./... clean; go test ./... all ok. Cases cover echo and non-zero exit reporting, env whitelist isolation (host secrets do not leak into children), timeout and context-cancel killing the whole process group promptly, blocklist rejection (sudo/destructive patterns), 64 KiB head+tail output truncation, python script persistence under .agent/scripts/, and fail-closed registration (no interpreter configured = no tool). Adapter tests verify Options.Exec wiring, per-request Env merge, allowlist stripping, and absence without Options.Exec. Impact: native.Options gains Exec *tools.ExecConfig; exec.Request.Env now flows into child processes; workspace profile gains up to 2 tools (only when configured). NOT an OS sandbox - isolation stays a host responsibility, documented in tool descriptions. Refs: parity-matrix #3/#4; settled product decision (deliverables via CLI/Python + skills, see archived card feature-exec-interpreters).
167 lines
5.3 KiB
Go
167 lines
5.3 KiB
Go
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 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 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)
|
|
}
|
|
}
|