Files
Agent-Native/runtime/tools/exec_tools_test.go
T
weichen afbb567301 Bind session scope identity, enforce write sets, repair torn JSONL
Why: F02/F04/F15 - contract requests shared one transcript across
session scopes, Writable was dropped, and a torn JSONL tail poisoned
the next append; pure-text contract results lost their text.
Evidence: adapters/native, runtime/session, runtime/tools and
conformance tests pass (verify-u2.json); scope-isolation, write-set and
torn-tail regression cases added.
Impact: empty scope keeps legacy one-shot semantics; identity-less old
transcripts are never auto-adopted by a new scope.
Refs: feature/agent-stack-audit-remediation-b1 U2
2026-10-21 16:03:04 +00:00

263 lines
8.5 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 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)
}
}