// 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) { return freezeBaseChecked(context.Background(), nil, dir, frozen) } func freezeBaseChecked(ctx context.Context, check func() error, 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 := checkExternalEffect(ctx, check); err != nil { 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 } if err := checkExternalEffect(ctx, check); err != nil { return err } 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 copyFileChecked(ctx, check, 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) { return applyUpperChecked(context.Background(), nil, upper, dir, manifest) } func applyUpperChecked(ctx context.Context, check func() error, upper, dir string, manifest map[string]string) ([]Change, error) { var changes []Change if err := checkExternalEffect(ctx, check); err != nil { return changes, err } 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 { if err := checkExternalEffect(ctx, check); err != nil { return err } 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 err := checkExternalEffect(ctx, check); err != nil { return err } 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 := copyFileChecked(ctx, check, 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 err := checkExternalEffect(ctx, check); err != nil { return err } if mvErr := os.Rename(p, target); mvErr != nil { // 同 fs 原子替换 if cpErr := copyFileChecked(ctx, check, 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 { return copyFileChecked(context.Background(), nil, src, dst, perm) } func copyFileChecked(ctx context.Context, check func() error, src, dst string, perm os.FileMode) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() if err := checkExternalEffect(ctx, check); err != nil { return err } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } if err := checkExternalEffect(ctx, check); 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 } if err := checkExternalEffect(ctx, check); err != nil { _ = os.Remove(dst + ".tmp-s0") 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 := checkExternalEffect(ctx, spec.CheckExternalEffect); err != nil { os.RemoveAll(wsRoot) return nil, err } if err := os.MkdirAll(d, 0o755); err != nil { os.RemoveAll(wsRoot) return nil, fmt.Errorf("workspace overlay: %w", err) } } manifest, err := freezeBaseChecked(ctx, spec.CheckExternalEffect, 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 := checkExternalEffect(ctx, spec.CheckExternalEffect); err != nil { os.RemoveAll(wsRoot) return nil, err } if err := cmd.Start(); err != nil { os.RemoveAll(wsRoot) return nil, err } return &overlayProcess{inner: cmdProcess{cmd: cmd}, ctx: ctx, check: spec.CheckExternalEffect, wsRoot: wsRoot, dir: spec.Dir, manifest: manifest}, nil } // overlayProcess 在进程退出时回收工作区:ctx 已取消(超时/中止)→ 整体丢弃; // 正常退出(含非零退出码——脚本自己 exit 1 仍是完整结束)→ CAS 写回并清理。 type overlayProcess struct { inner ExecProcess ctx context.Context check func() error 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 := applyUpperChecked(p.ctx, p.check, 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) return 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) // -ws 根目录在空时顺手移除(每会话一个壳,不清理会随会话累积)。 _ = os.Remove(filepath.Dir(root)) } func (p *overlayProcess) Kill() error { return p.inner.Kill() } // Summary 供 runInterpreter 在 Wait 后追加到工具输出(可选接口,不侵入契约)。 func (p *overlayProcess) Summary() string { return p.summary }