Merge pull request 'Add the S0 task workspace overlay: frozen base, private upper, CAS apply' (#135) from feature/agent-s0-workspace-overlay into main
ci / vet-and-test (push) Successful in 41s
ci / vet-and-test (push) Successful in 41s
This commit was merged in pull request #135.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user