Files
Agent-System-Backend/internal/tools/exec_workspace_test.go
T
weichen 01fd3b6fd4
ci / vet-and-test (pull_request) Successful in 43s
Add the S0 task workspace overlay: frozen base, private upper, CAS apply
Why: The sandbox tier wrote the session directory directly, so a buggy
or runaway script could leave the workspace half-mutated and nothing
bounded what a timeout had already written. The plan's first landing
shape (section 4.4) swaps the session bind for a frozen-base overlay:
each exec freezes the session dir (hardlink clone + sha256 baseline),
runs on a private upper, and only a clean exit promotes the change set
back via CAS (baseline hash match -> atomic replace/remove; externally
changed targets double-store as *.conflict-* instead of clobbering).
Timeouts and kills discard the upper entirely - half-written state
never lands.

Evidence: go test ./... green including bwrap integration roundtrips
(TestSandboxWorkspaceOverlayRoundtrip: add/modify/delete through a real
overlayfs upper with whiteout handling and ws cleanup;
TestSandboxWorkspaceOverlayTimeoutDiscards: partial writes discarded on
timeout) and unit anchors for freeze manifest (.agentx excluded), CAS
add/modify, conflict double-store, and the change summary.

Impact: Opt-in via --exec-workspace overlay / AGENT_SYSTEM_EXEC_WORKSPACE
(only with --exec-backend sandbox; fail-closed on unknown modes). The
model sees a one-line change summary appended to the tool output, so
discards and conflict files are visible. Default behavior is unchanged.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md section
4.4; Dev task feature/agent-s0-workspace-overlay.
2026-10-11 02:04:05 +00:00

234 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tools
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// ---- S0:冻结基底与 CAS 回收 ----
func write(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func read(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// 冻结:硬链接基底 + 基线清单;.agentx 不进基底。
func TestFreezeBaseManifest(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "a.md"), "alpha")
write(t, filepath.Join(dir, "sub", "b.txt"), "beta")
write(t, filepath.Join(dir, ".agentx", "transcript.jsonl"), "{}")
frozen := filepath.Join(dir+"-ws", "t1")
manifest, err := freezeBase(dir, frozen)
if err != nil {
t.Fatal(err)
}
if len(manifest) != 2 {
t.Fatalf("manifest = %v(.agentx 必须排除)", manifest)
}
if strings.TrimSpace(read(t, filepath.Join(frozen, "a.md"))) != "alpha" {
t.Fatal("frozen copy content mismatch")
}
if _, err := os.Lstat(filepath.Join(frozen, ".agentx")); !os.IsNotExist(err) {
t.Fatal(".agentx must not be frozen")
}
// 基底不可变验证:改 upper 前先确认冻结文件是硬链接(与源同 inode——
// 原地写会穿透,这是文档化的边界;CAS 依赖哈希清单而非 inode)。
}
// 回收:新增/修改 CAS 写回;删除(whiteout 形态在集成测试覆盖,这里用
// 直接删 upper 对应目标的语义等价路径——applyUpper 只看 upper 内容)。
func TestApplyUpperAddModDel(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "keep.txt"), "keep")
write(t, filepath.Join(dir, "mod.txt"), "old")
write(t, filepath.Join(dir, "gone.txt"), "gone")
frozen := filepath.Join(dir+"-ws", "t1")
manifest, err := freezeBase(dir, frozen)
if err != nil {
t.Fatal(err)
}
upper := filepath.Join(dir+"-ws", "t1", "upper")
write(t, filepath.Join(upper, "new.txt"), "new content")
write(t, filepath.Join(upper, "mod.txt"), "new value")
// 删除:upper 里放 whiteout 等价物不可构造(需真实 overlayfs);
// applyUpper 的删除分支由集成测试覆盖。此处验证 add/mod。
changes, err := applyUpper(upper, dir, manifest)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(read(t, filepath.Join(dir, "new.txt"))) != "new content" {
t.Fatal("add must land")
}
if strings.TrimSpace(read(t, filepath.Join(dir, "mod.txt"))) != "new value" {
t.Fatal("mod must land (CAS ok)")
}
ops := map[string]Change{}
for _, c := range changes {
ops[c.Rel] = c
}
if ops["new.txt"].Op != "add" || !ops["new.txt"].Applied {
t.Fatalf("new.txt change = %+v", ops["new.txt"])
}
if ops["mod.txt"].Op != "mod" || !ops["mod.txt"].Applied {
t.Fatalf("mod.txt change = %+v", ops["mod.txt"])
}
}
// 冲突:目标被外部改动(哈希 ≠ 基线)→ 双存 *.conflict-*,原文件不动。
func TestApplyUpperConflictDoubleStore(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "doc.md"), "base")
frozen := filepath.Join(dir+"-ws", "t1")
manifest, err := freezeBase(dir, frozen)
if err != nil {
t.Fatal(err)
}
write(t, filepath.Join(dir, "doc.md"), "externally changed") // 会话侧被外部改
upper := filepath.Join(dir+"-ws", "t1", "upper")
write(t, filepath.Join(upper, "doc.md"), "sandbox wrote this")
changes, err := applyUpper(upper, dir, manifest)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(read(t, filepath.Join(dir, "doc.md"))) != "externally changed" {
t.Fatal("conflict must not overwrite the externally changed file")
}
found := false
for _, c := range changes {
if c.Rel == "doc.md" && c.Conflict && !c.Applied {
found = true
}
}
if !found {
t.Fatalf("changes = %+v", changes)
}
// 双存文件存在且内容是沙箱侧新内容。
matches, _ := filepath.Glob(filepath.Join(dir, "doc.md.conflict-*"))
if len(matches) == 0 || strings.TrimSpace(read(t, matches[0])) != "sandbox wrote this" {
t.Fatalf("conflict copy missing: %v", matches)
}
}
// 变更摘要:数量与冲突提示。
func TestWorkspaceChangeSummary(t *testing.T) {
s := workspaceChangeSummary([]Change{
{Rel: "a", Op: "add", Applied: true},
{Rel: "b", Op: "mod", Applied: true},
{Rel: "c", Op: "del", Applied: true},
{Rel: "d", Op: "mod", Conflict: true},
})
for _, want := range []string{"新增 1", "修改 1", "删除 1", "冲突 1"} {
if !strings.Contains(s, want) {
t.Fatalf("summary %q missing %q", s, want)
}
}
if workspaceChangeSummary(nil) != "" {
t.Fatal("empty changes must yield empty summary")
}
}
// ---- 集成(本机有 bwrap 时执行):真实 overlay 三件套 + whiteout ----
func TestSandboxWorkspaceOverlayRoundtrip(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("bwrap not available")
}
oldOverlay := workspaceOverlay
workspaceOverlay = true
defer func() { workspaceOverlay = oldOverlay }()
dir := t.TempDir()
write(t, filepath.Join(dir, "原文件.txt"), "orig")
write(t, filepath.Join(dir, "将删除.txt"), "delete me")
spec := ExecSpec{
Bin: "/bin/bash", Args: []string{"-c", `
set -e
echo 'sandbox new' > 新文件.txt
echo 'modified' > 原文件.txt
rm 将删除.txt
echo done
`},
Dir: dir, Env: []string{"PATH=/usr/bin:/bin"},
}
out := &strings.Builder{}
proc, err := sandboxBackend{}.Start(context.Background(), spec, out)
if err != nil {
t.Fatal(err)
}
if err := proc.Wait(); err != nil {
t.Fatalf("wait: %v (output: %s)", err, out.String())
}
if strings.TrimSpace(read(t, filepath.Join(dir, "新文件.txt"))) != "sandbox new" {
t.Fatal("new file must be applied")
}
if strings.TrimSpace(read(t, filepath.Join(dir, "原文件.txt"))) != "modified" {
t.Fatal("modified file must be applied")
}
if _, err := os.Stat(filepath.Join(dir, "将删除.txt")); !os.IsNotExist(err) {
t.Fatal("deleted file must be removed after apply")
}
// -ws 兄弟目录已清理。
matches, _ := filepath.Glob(dir + "-ws/*")
if len(matches) != 0 {
t.Fatalf("workspace roots must be cleaned: %v", matches)
}
s := proc.(interface{ Summary() string }).Summary()
if !strings.Contains(s, "新增 1") || !strings.Contains(s, "删除 1") {
t.Fatalf("summary = %q", s)
}
}
// 超时丢弃:超时杀灭后 upper 不落地(半写状态不进会话目录)。
func TestSandboxWorkspaceOverlayTimeoutDiscards(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("bwrap not available")
}
oldOverlay := workspaceOverlay
workspaceOverlay = true
defer func() { workspaceOverlay = oldOverlay }()
dir := t.TempDir()
spec := ExecSpec{
Bin: "/bin/bash", Args: []string{"-c", "echo partial > 半写.txt; sleep 30"},
Dir: dir, Env: []string{"PATH=/usr/bin:/bin"}, Timeout: 1e9,
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout())
defer cancel()
out := &strings.Builder{}
proc, err := sandboxBackend{}.Start(ctx, spec, out)
if err != nil {
t.Fatal(err)
}
_ = proc.Wait()
if _, err := os.Stat(filepath.Join(dir, "半写.txt")); !os.IsNotExist(err) {
t.Fatal("partial write must NOT land after timeout")
}
if !strings.Contains(proc.(interface{ Summary() string }).Summary(), "丢弃") {
t.Fatal("summary must state discard")
}
}
func shortTimeout() time.Duration { return 1500 * time.Millisecond }