Add the S0 task workspace overlay: frozen base, private upper, CAS apply #135

Merged
weichen merged 1 commits from feature/agent-s0-workspace-overlay into main 2026-10-11 02:14:36 +00:00
7 changed files with 684 additions and 9 deletions
+5 -2
View File
@@ -47,6 +47,7 @@ func main() {
noExec := fs.Bool("disable-exec-tools", false, "硬关闭执行原语 run_shell/run_python/register_artifact(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)")
execBackend := fs.String("exec-backend", "", "执行后端:direct(默认,现状直启)| sandbox(bwrap 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)")
execDepsDir := fs.String("exec-deps-dir", "", "sandbox 档只读依赖层目录(site-packages 等;空=不挂载)")
execWorkspace := fs.String("exec-workspace", "", "sandbox 档工作区模式:空=直写会话目录 | overlay=冻结基底+私有 upper+CAS 回收(S0)")
fs.Parse(os.Args[1:])
cliSet := visited(fs)
@@ -54,7 +55,7 @@ func main() {
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
}, cliSet)
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
@@ -65,6 +66,7 @@ func main() {
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
ExecWorkspace: resolved.ExecWorkspace,
}); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
@@ -110,12 +112,13 @@ func runConfig(args []string) int {
noExec := fs.Bool("disable-exec-tools", false, "")
execBackend := fs.String("exec-backend", "", "")
execDepsDir := fs.String("exec-deps-dir", "", "")
execWorkspace := fs.String("exec-workspace", "", "")
fs.Parse(args[1:])
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
}, visited(fs))
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
+13
View File
@@ -82,6 +82,9 @@ type Options struct {
// ExecDepsDir sandbox 档只读依赖层(site-packages 等,空=不挂):修复
// --tmpfs /home 遮蔽 ~/.local 导致交付物生成链路不可用(F3)。
ExecDepsDir string
// ExecWorkspace = "overlay" 时 sandbox 档启用 S0 工作区(冻结基底+私有
// upper+CAS 回收);空 = EXT-16 现状直写会话目录。
ExecWorkspace string
}
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
@@ -145,6 +148,16 @@ func Run(opt Options) error {
tools.SetExecDepsDir(opt.ExecDepsDir)
log.Printf("exec tools: sandbox deps layer=%s", opt.ExecDepsDir)
}
if opt.ExecWorkspace == "overlay" {
tools.SetWorkspaceOverlay(true)
if tools.ExecBackendName() == "sandbox" {
log.Printf("exec tools: workspace=overlay (frozen base + private upper + CAS apply)")
} else {
log.Printf("exec tools: workspace=overlay ignored (backend is not sandbox)")
}
} else if opt.ExecWorkspace != "" {
return fmt.Errorf("exec workspace: unknown mode %q (overlay)", opt.ExecWorkspace)
}
if err := tools.RegisterExecTools(reg); err != nil {
return fmt.Errorf("exec tools: %w", err)
}
+9
View File
@@ -33,6 +33,8 @@ type Config struct {
ExecBackend string `json:"execBackend"`
// ExecDepsDir sandbox 档只读依赖层目录(空 = 不挂载)。
ExecDepsDir string `json:"execDepsDir"`
// ExecWorkspace sandbox 档工作区模式:空=直写;overlay=冻结基底+upper。
ExecWorkspace string `json:"execWorkspace"`
}
var defaults = Config{
@@ -54,6 +56,7 @@ var envKey = map[string]string{
"instance": "AGENT_SYSTEM_INSTANCE",
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
"execDepsDir": "AGENT_SYSTEM_EXEC_DEPS_DIR",
"execWorkspace": "AGENT_SYSTEM_EXEC_WORKSPACE",
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
@@ -181,6 +184,8 @@ func loadFile(path string) (Config, map[string]bool, error) {
dst = &c.ExecBackend
case "execDepsDir":
dst = &c.ExecDepsDir
case "execWorkspace":
dst = &c.ExecWorkspace
default:
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
}
@@ -214,6 +219,8 @@ func stringField(c Config, f string) string {
return c.ExecBackend
case "execDepsDir":
return c.ExecDepsDir
case "execWorkspace":
return c.ExecWorkspace
}
return ""
}
@@ -266,6 +273,8 @@ func setString(c *Config, f, v string) {
c.ExecBackend = v
case "execDepsDir":
c.ExecDepsDir = v
case "execWorkspace":
c.ExecWorkspace = v
}
}
+23 -7
View File
@@ -25,16 +25,21 @@ const execSandboxMaxProcs = 256
// ExecSpec 一次解释器执行的启动规格(后端无关)。
type ExecSpec struct {
Bin string // 白名单解释器绝对路径
Args []string // 落盘脚本路径为唯一参数
Dir string // 会话沙箱工作目录(sandbox 档唯一可写挂载)
Env []string // 最小环境(execEnv 口径)
SkillsDir string // 技能根(sandbox 档只读挂载;空 = 不挂)
Bin string // 白名单解释器绝对路径
Args []string // 落盘脚本路径为唯一参数
Dir string // 会话沙箱工作目录(sandbox 档唯一可写挂载)
Env []string // 最小环境(execEnv 口径)
SkillsDir string // 技能根(sandbox 档只读挂载;空 = 不挂)
// DepsDir 依赖层(sandbox 档只读挂载;空 = 不挂):python-docx 等装在
// ~/.local 下会被 --tmpfs /home 遮蔽,只读挂回其原路径并并入 PYTHONPATH,
// 交付物生成链路(F3)在隔离档下才可用。
DepsDir string
Timeout time.Duration // 硬超时(调用方 ctx.WithTimeout 之前传入,仅记录用)
// S0 工作区 overlay(sandbox 档;三者须同批非空):冻结基底(--overlay-src)、
// 私有 upper(--overlay RWSRC)、overlayfs work 目录。空 = EXT-16 现状直写。
WorkspaceFrozen string
WorkspaceUpper string
WorkspaceWork string
Timeout time.Duration // 硬超时(调用方 ctx.WithTimeout 之前传入,仅记录用)
}
// ExecProcess 后端启动的执行体句柄:Wait 等待退出;Kill 杀整个执行体
@@ -133,6 +138,9 @@ func (sandboxBackend) Start(ctx context.Context, spec ExecSpec, out io.Writer) (
if err != nil {
return nil, fmt.Errorf("sandbox exec backend requires bwrap: %w", err)
}
if workspaceOverlay {
return startOverlaySandbox(ctx, spec, bin, out)
}
argv := sandboxArgv(spec)
cmd := exec.CommandContext(ctx, bin, argv...)
cmd.Env = []string{"PATH=" + os.Getenv("PATH")}
@@ -167,7 +175,15 @@ func sandboxArgv(spec ExecSpec) []string {
"--tmpfs", "/tmp", // 可写临时区(pip/tempfile),不含宿主内容
"--proc", "/proc",
"--dev", "/dev",
"--bind", spec.Dir, spec.Dir, // 会话沙箱:唯一可写挂载
}
if spec.WorkspaceUpper != "" {
// S0:会话目录不再直写——冻结基底 overlay,写入只落私有 upper
// (正常退出后 CAS 回收;超时/中止整体丢弃,半写状态不落地)。
argv = append(argv,
"--overlay-src", spec.WorkspaceFrozen,
"--overlay", spec.WorkspaceUpper, spec.WorkspaceWork, spec.Dir)
} else {
argv = append(argv, "--bind", spec.Dir, spec.Dir) // 会话沙箱:唯一可写挂载
}
if spec.SkillsDir != "" {
argv = append(argv, "--ro-bind", spec.SkillsDir, spec.SkillsDir)
+7
View File
@@ -192,6 +192,13 @@ func runInterpreter(parent context.Context, interpreter, scriptPath, dir string,
_ = pw.Close()
<-readDone
text := truncateOutput(buf.Bytes())
// S0 工作区 overlay:回收摘要(写回/丢弃/冲突)追加到工具输出——
// 模型需要知道改动是否落地、冲突文件在哪。
if ws, ok := proc.(interface{ Summary() string }); ok {
if s := ws.Summary(); s != "" {
text += s
}
}
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Sprintf("exit=timeout(%ds)\n%s", timeoutS, text), nil
}
+394
View File
@@ -0,0 +1,394 @@
// S0 首个落地形态(提案 2026-10-10 §4.4):任务工作区 overlay——sandbox 档
// 执行时不再直写会话目录,而是「冻结基底 + 私有 upper」:
//
// - freezeBase:执行前对会话目录做硬链接克隆(cp -al 语义;跨设备/受限
// 场景退化为复制)并生成 sha256 基线清单——基底是不可变视图,执行中
// 他人的变更不进入本次视野(原地写穿透除外,见提案 §4.1 的实测边界)。
// - 执行:bwrap --overlay-src <frozen> --overlay <upper> <work> <会话目录>,
// 模型代码的所有写入只落 upper。
// - applyUpper(进程正常退出后):按变更集 CAS 写回——目标哈希 == 基线
// 哈希才原子替换/删除;目标已被外部改动则双存(*.conflict-<ts>)不覆盖。
// 超时/被杀:upper 整体丢弃——半写状态永不落地(崩溃安全)。
//
// 明确不做(提案 §4.3/§4.5):不做快照语义声明(overlay 下层变更未定义,
// 一致性来自冻结基底);不做三方合并(冲突双存 + 报告,人工兜底);
// opaque 目录白出(罕见路径)与 reflink(ext4 无)不在范围。
package tools
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
)
// workspaceOverlay 开关(组合根注入;缺省 false=EXT-16 现状直写)。
var workspaceOverlay bool
// SetWorkspaceOverlay 配置 sandbox 档是否走「冻结基底+upper」工作区模式。
func SetWorkspaceOverlay(on bool) { workspaceOverlay = on }
// freezeDir 返回 <会话目录>/.agentx/ws-freeze/<id>(与会话同文件系统,
// 硬链接可达);目录惰性创建。放在 .agentx 下:既有转录/检查点同区,
// 清理规则一致(随会话目录走)。
func freezeDir(sessionDir, id string) string {
return filepath.Join(sessionDir, ".agentx", "ws-freeze", id)
}
func upperDir(sessionDir, id string) string {
return filepath.Join(sessionDir, ".agentx", "ws-upper", id)
}
// freezeBase 硬链接克隆 dir 到 frozen(已存在则报错——每个 id 只冻结一次),
// 返回基线清单 {相对路径: sha256}。符号链接原样保留(链接本身少见内容变更)。
func freezeBase(dir, frozen string) (map[string]string, error) {
if _, err := os.Lstat(frozen); err == nil {
return nil, fmt.Errorf("freeze id reused: %s", frozen)
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err := os.MkdirAll(frozen, 0o755); err != nil {
return nil, err
}
manifest := map[string]string{}
err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, rerr := filepath.Rel(dir, p)
if rerr != nil {
return rerr
}
if rel == "." {
return nil
}
// 冻结区与 upper 区自身不进基底(避免自嵌套)。
if rel == ".agentx" {
return filepath.SkipDir
}
target := filepath.Join(frozen, rel)
info, ierr := d.Info()
if ierr != nil {
return ierr
}
switch {
case d.IsDir():
return os.Mkdir(target, info.Mode().Perm())
case info.Mode()&os.ModeSymlink != 0:
link, lerr := os.Readlink(p)
if lerr != nil {
return lerr
}
return os.Symlink(link, target)
case info.Mode().IsRegular():
sum, herr := fileSHA256(p)
if herr != nil {
return herr
}
manifest[rel] = sum
if err := os.Link(p, target); err == nil {
return nil // 硬链接成功(常态:同 fs)
} else if !errors.Is(err, syscall.EXDEV) && !errors.Is(err, os.ErrPermission) {
return err
}
return copyFile(p, target, info.Mode().Perm()) // 受限场景退化复制
default:
return nil // 套接字/设备等:会话工作区不出现,跳过
}
})
if err != nil {
return nil, err
}
return manifest, nil
}
// Change 描述一次回收的变更(applied 为真是已写回;conflict 为真表示目标
// 被外部改动,新内容已双存为 <name>.conflict-<ts>)。
type Change struct {
Rel string
Op string // add | mod | del
Applied bool
Conflict bool
}
// applyUpper 把 upper 的变更集 CAS 写回 dir。基线 manifest 来自 freezeBase;
// whiteout(overlayfs 删除标记:字符设备 0/0)解释为删除。
// 返回变更清单(按路径排序)与错误(目录级失败即停——宁可少改不乱改)。
func applyUpper(upper, dir string, manifest map[string]string) ([]Change, error) {
var changes []Change
err := filepath.WalkDir(upper, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, rerr := filepath.Rel(upper, p)
if rerr != nil {
return rerr
}
if rel == "." {
return nil
}
target := filepath.Join(dir, rel)
info, ierr := d.Info()
if ierr != nil {
return ierr
}
switch {
case d.IsDir():
// 目录本身不构成变更;缺失时补建(rename 文件需要父目录在位)。
if _, serr := os.Lstat(target); serr != nil {
return os.MkdirAll(target, info.Mode().Perm())
}
return nil
case isWhiteout(info):
baseSum, hadBase := manifest[rel]
curSum, curErr := fileSHA256(target)
switch {
case !hadBase:
changes = append(changes, Change{Rel: rel, Op: "del", Applied: false, Conflict: true})
return nil // 基底没有却要删:外部新增,不动
case curErr != nil && errors.Is(curErr, os.ErrNotExist):
// 已被外部删除:与意图一致,记为已应用
changes = append(changes, Change{Rel: rel, Op: "del", Applied: true})
return nil
case curErr != nil:
return curErr
case curSum != baseSum:
changes = append(changes, Change{Rel: rel, Op: "del", Applied: false, Conflict: true})
return nil // 被外部改过:不删,报告冲突
default:
if rmErr := os.Remove(target); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
return rmErr
}
changes = append(changes, Change{Rel: rel, Op: "del", Applied: true})
return nil
}
case info.Mode().IsRegular():
newSum, herr := fileSHA256(p)
if herr != nil {
return herr
}
baseSum, hadBase := manifest[rel]
curSum, curErr := fileSHA256(target)
targetExists := curErr == nil
if curErr != nil && !errors.Is(curErr, os.ErrNotExist) {
return curErr
}
conflict := targetExists && curSum != baseSum // 外部已改(或基底没有却被外部新建)
if conflict {
if cpErr := copyFile(p, target+".conflict-"+time.Now().Format("0102-150405"), info.Mode().Perm()); cpErr != nil {
return cpErr
}
changes = append(changes, Change{Rel: rel, Op: changeOp(hadBase, targetExists), Applied: false, Conflict: true})
return nil
}
if mvErr := os.Rename(p, target); mvErr != nil { // 同 fs 原子替换
if cpErr := copyFile(p, target, info.Mode().Perm()); cpErr != nil { // 跨设备兜底
return cpErr
}
}
_ = newSum
changes = append(changes, Change{Rel: rel, Op: changeOp(hadBase, targetExists), Applied: true})
return nil
default:
return nil // 链接等:复制语义保持简单,跳过
}
})
if err != nil {
return changes, err
}
sort.Slice(changes, func(i, j int) bool { return changes[i].Rel < changes[j].Rel })
return changes, nil
}
func changeOp(hadBase, targetExists bool) string {
switch {
case hadBase && targetExists:
return "mod"
case hadBase && !targetExists:
return "mod" // 基底有、目标被外部删:恢复性写入,按修改计
default:
return "add"
}
}
// isWhiteout 识别 overlayfs upper 的删除标记:字符设备 0/0。
func isWhiteout(info os.FileInfo) bool {
if info.Mode()&os.ModeCharDevice == 0 {
return false
}
st, ok := info.Sys().(*syscall.Stat_t)
return ok && st.Rdev == 0
}
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func copyFile(src, dst string, perm os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := os.OpenFile(dst+".tmp-s0", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(dst + ".tmp-s0")
return err
}
if err := out.Close(); err != nil {
return err
}
return os.Rename(dst+".tmp-s0", dst) // 复制路径也走原子替换
}
// workspaceChangeSummary 把变更清单压成给模型看的一行(输出尾部追加)。
func workspaceChangeSummary(changes []Change) string {
if len(changes) == 0 {
return ""
}
var adds, mods, dels, conflicts int
for _, c := range changes {
switch {
case c.Conflict:
conflicts++
case c.Op == "add":
adds++
case c.Op == "mod":
mods++
case c.Op == "del":
dels++
}
}
parts := []string{}
if adds > 0 {
parts = append(parts, fmt.Sprintf("新增 %d", adds))
}
if mods > 0 {
parts = append(parts, fmt.Sprintf("修改 %d", mods))
}
if dels > 0 {
parts = append(parts, fmt.Sprintf("删除 %d", dels))
}
if conflicts > 0 {
parts = append(parts, fmt.Sprintf("冲突 %d(新内容已存为 *.conflict-*,原文件未覆盖)", conflicts))
}
return "[工作区变更:" + strings.Join(parts, ",") + "]"
}
// startOverlaySandbox(S0 首个落地形态,提案 §4.4):冻结会话目录 → 以
// overlay 三件套启动 bwrap → 包一层 overlayProcess 在退出时回收。任何准备
// 失败都 fail-closed 返回错误(不静默回落直写——那会让调用方误以为有隔离)。
func startOverlaySandbox(ctx context.Context, spec ExecSpec, bin string, out io.Writer) (ExecProcess, error) {
id := fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid())
wsRoot := filepath.Join(strings.TrimSuffix(spec.Dir, "/")+"-ws", id)
frozen := filepath.Join(wsRoot, "frozen")
upper := filepath.Join(wsRoot, "upper")
work := filepath.Join(wsRoot, "work")
for _, d := range []string{upper, work} { // frozen 由 freezeBase 自建(重用检查依赖不存在)
if err := os.MkdirAll(d, 0o755); err != nil {
os.RemoveAll(wsRoot)
return nil, fmt.Errorf("workspace overlay: %w", err)
}
}
manifest, err := freezeBase(spec.Dir, frozen)
if err != nil {
os.RemoveAll(wsRoot)
return nil, fmt.Errorf("workspace freeze: %w", err)
}
ov := spec
ov.WorkspaceFrozen, ov.WorkspaceUpper, ov.WorkspaceWork = frozen, upper, work
cmd := exec.CommandContext(ctx, bin, sandboxArgv(ov)...)
cmd.Env = []string{"PATH=" + os.Getenv("PATH")}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.WaitDelay = 2 * time.Second
cmd.Cancel = func() error {
if cmd.Process == nil {
return os.ErrProcessDone
}
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
cmd.Stdout = out
cmd.Stderr = out
if err := cmd.Start(); err != nil {
os.RemoveAll(wsRoot)
return nil, err
}
return &overlayProcess{inner: cmdProcess{cmd: cmd}, ctx: ctx, wsRoot: wsRoot, dir: spec.Dir, manifest: manifest}, nil
}
// overlayProcess 在进程退出时回收工作区:ctx 已取消(超时/中止)→ 整体丢弃;
// 正常退出(含非零退出码——脚本自己 exit 1 仍是完整结束)→ CAS 写回并清理。
type overlayProcess struct {
inner ExecProcess
ctx context.Context
wsRoot string
dir string
manifest map[string]string
summary string
}
func (p *overlayProcess) Wait() error {
err := p.inner.Wait()
defer relaxAndRemove(p.wsRoot)
if p.ctx.Err() != nil {
p.summary = "\n[工作区变更:已丢弃——执行超时或被中止,本次改动未落地]"
return err
}
changes, aerr := applyUpper(filepath.Join(p.wsRoot, "upper"), p.dir, p.manifest)
if aerr != nil {
log.Printf("[exec] workspace apply partial failure (stopping at first dir error): %v", aerr)
}
if s := workspaceChangeSummary(changes); s != "" {
p.summary = "\n" + s
} else {
p.summary = "\n[工作区变更:无]"
}
return err
}
// relaxAndRemove 放宽 overlayfs work 目录(内核卸载后把权限收到 0000——
// 属正常行为,不是泄漏)再整树删除。
func relaxAndRemove(root string) {
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err == nil && d.IsDir() {
_ = os.Chmod(p, 0o755)
}
return nil // 清理尽力而为:失败留给会话目录清理兜底
})
_ = os.RemoveAll(root)
}
func (p *overlayProcess) Kill() error { return p.inner.Kill() }
// Summary 供 runInterpreter 在 Wait 后追加到工具输出(可选接口,不侵入契约)。
func (p *overlayProcess) Summary() string { return p.summary }
+233
View File
@@ -0,0 +1,233 @@
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 }