Files
Agent-System-Backend/internal/tools/exec_workspace.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

447 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) {
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 为真表示目标
// 被外部改动,新内容已双存为 <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) {
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 }