Files
Agent-System-Backend/internal/tools/exec_workspace_test.go
T
weichen bd4b3bca94
ci / vet-and-test (pull_request) Successful in 40s
Remove the empty -ws shell directory after each apply
Why: relaxAndRemove cleared the per-exec id directory but left the -ws
parent behind, so every conversation accumulated an empty husk - the
live probe caught it in the workspace listing.

Evidence: go test ./... green; TestOverlayCleansShellDir pins the
behavior with a real bwrap roundtrip.

Impact: One extra os.Remove of the (now empty) parent; failures are
best-effort as before.

Refs: Dev task feature/agent-s0-workspace-overlay (fix-up of #135).
2026-10-11 02:18:01 +00:00

256 lines
8.0 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 }
// -ws 壳目录在空时移除(不随会话累积)。
func TestOverlayCleansShellDir(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("bwrap not available")
}
old := workspaceOverlay
workspaceOverlay = true
defer func() { workspaceOverlay = old }()
dir := t.TempDir()
spec := ExecSpec{Bin: "/bin/bash", Args: []string{"-c", "echo hi"}, Dir: dir, Env: []string{"PATH=/usr/bin:/bin"}}
proc, err := sandboxBackend{}.Start(context.Background(), spec, &strings.Builder{})
if err != nil {
t.Fatal(err)
}
if err := proc.Wait(); err != nil {
t.Fatal(err)
}
if _, serr := os.Stat(dir + "-ws"); !os.IsNotExist(serr) {
t.Fatalf("-ws shell dir must be removed when empty: %v", serr)
}
}