ci / vet-and-test (pull_request) Successful in 40s
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).
256 lines
8.0 KiB
Go
256 lines
8.0 KiB
Go
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)
|
||
}
|
||
}
|