diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 6e9cf18..664143f 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -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) diff --git a/internal/app/app.go b/internal/app/app.go index 9579cde..227b058 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) } diff --git a/internal/config/config.go b/internal/config/config.go index 4901cfb..0e8a146 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } } diff --git a/internal/tools/exec_backend.go b/internal/tools/exec_backend.go index 3b6de8e..4d11ac6 100644 --- a/internal/tools/exec_backend.go +++ b/internal/tools/exec_backend.go @@ -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) diff --git a/internal/tools/exec_interpreter.go b/internal/tools/exec_interpreter.go index 7c877a3..55538b1 100644 --- a/internal/tools/exec_interpreter.go +++ b/internal/tools/exec_interpreter.go @@ -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 } diff --git a/internal/tools/exec_workspace.go b/internal/tools/exec_workspace.go new file mode 100644 index 0000000..c996cbe --- /dev/null +++ b/internal/tools/exec_workspace.go @@ -0,0 +1,394 @@ +// S0 首个落地形态(提案 2026-10-10 §4.4):任务工作区 overlay——sandbox 档 +// 执行时不再直写会话目录,而是「冻结基底 + 私有 upper」: +// +// - freezeBase:执行前对会话目录做硬链接克隆(cp -al 语义;跨设备/受限 +// 场景退化为复制)并生成 sha256 基线清单——基底是不可变视图,执行中 +// 他人的变更不进入本次视野(原地写穿透除外,见提案 §4.1 的实测边界)。 +// - 执行:bwrap --overlay-src --overlay <会话目录>, +// 模型代码的所有写入只落 upper。 +// - applyUpper(进程正常退出后):按变更集 CAS 写回——目标哈希 == 基线 +// 哈希才原子替换/删除;目标已被外部改动则双存(*.conflict-)不覆盖。 +// 超时/被杀: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/(与会话同文件系统, +// 硬链接可达);目录惰性创建。放在 .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 为真表示目标 +// 被外部改动,新内容已双存为 .conflict-)。 +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 } diff --git a/internal/tools/exec_workspace_test.go b/internal/tools/exec_workspace_test.go new file mode 100644 index 0000000..26a2f9a --- /dev/null +++ b/internal/tools/exec_workspace_test.go @@ -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 }