ci / vet-and-test (pull_request) Successful in 43s
Why: The sandbox tier wrote the session directory directly, so a buggy or runaway script could leave the workspace half-mutated and nothing bounded what a timeout had already written. The plan's first landing shape (section 4.4) swaps the session bind for a frozen-base overlay: each exec freezes the session dir (hardlink clone + sha256 baseline), runs on a private upper, and only a clean exit promotes the change set back via CAS (baseline hash match -> atomic replace/remove; externally changed targets double-store as *.conflict-* instead of clobbering). Timeouts and kills discard the upper entirely - half-written state never lands. Evidence: go test ./... green including bwrap integration roundtrips (TestSandboxWorkspaceOverlayRoundtrip: add/modify/delete through a real overlayfs upper with whiteout handling and ws cleanup; TestSandboxWorkspaceOverlayTimeoutDiscards: partial writes discarded on timeout) and unit anchors for freeze manifest (.agentx excluded), CAS add/modify, conflict double-store, and the change summary. Impact: Opt-in via --exec-workspace overlay / AGENT_SYSTEM_EXEC_WORKSPACE (only with --exec-backend sandbox; fail-closed on unknown modes). The model sees a one-line change summary appended to the tool output, so discards and conflict files are visible. Default behavior is unchanged. Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md section 4.4; Dev task feature/agent-s0-workspace-overlay.
395 lines
13 KiB
Go
395 lines
13 KiB
Go
// 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 }
|