三层回退恢复机制(L0写前留档/L1恢复梯子/L2离线回滚)+ guard 父守护

- L0: files 插件写受保护系统路径(/etc 等)前自动留档,AbstractBeforeWrite 到 data/file_baseline
- L1: failback 受限 worker 执行恢复梯子 probe→还原DNS/proxy→还原LLM配置+ReloadFromConfig→probe,N轮有界
- L2: tracker changeset 持久化原文 blob,guard 离线 RollbackFromDisk 回滚 agentfs;SystemSnapshot 支撑
- guard 父守护: 心跳 IPC(PING/ACK unix socket, 文件心跳回退)、失败计数、退出码协议(42/43/44)、最后手段
- 发行版路径适配: system.protected_paths/network_paths 可注入,默认面向主流 Linux
- Windows 兼容: guard.go/failback.go 加 //go:build linux, guard_windows.go 提供 no-op 桩
- 修复: guard.yaml last_resort 键冲突、changeset Content 不落盘导致离线回滚丢原文

Build 全绿, vet 干净, system/recovery/ipc/tracker 单元测试全过
This commit is contained in:
root
2026-08-05 16:00:08 +08:00
parent fc614477ed
commit d08ed3312e
28 changed files with 3675 additions and 167 deletions

146
internal/system/guard.go Normal file
View File

@ -0,0 +1,146 @@
package system
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// ProtectedPaths L0 写前留档保护的系统路径前缀。主 agent 通过 files 插件写
// 这些路径前,先自动把原文存档到 <dataDir>/file_baseline/,供 guard 还原。
//
// 该集合强依赖当前 Linux 发行版布局(/etc、/home)。默认值仅作兜底;
// 生产环境应由 homed/main 或 guard.yaml 显式注入(SetProtectedPaths),
// 以适配具体发行版/部署路径,避免硬编码失效。
var protectedPrefixes = []string{
"/etc/",
}
// SetProtectedPaths 覆盖受保护路径前缀集合(发行版/部署路径适配入口)。
// 幂等地保留 / 前缀兜底;传入 nil/空则恢复默认。
func SetProtectedPaths(prefixes []string) {
if len(prefixes) == 0 {
protectedPrefixes = []string{"/etc/"}
return
}
ns := make([]string, 0, len(prefixes))
for _, p := range prefixes {
if p = strings.TrimSpace(p); p != "" {
ns = append(ns, p)
}
}
protectedPrefixes = ns
}
// ProtectedPaths 返回当前受保护路径前缀(副本)。
func ProtectedPaths() []string {
out := make([]string, len(protectedPrefixes))
copy(out, protectedPrefixes)
return out
}
// IsProtectedPath 判断路径是否属于受保护的写前留档范围。
func IsProtectedPath(path string) bool {
abs, err := filepath.Abs(path)
if err != nil {
return false
}
for _, p := range protectedPrefixes {
if strings.HasPrefix(abs, p) {
return true
}
}
return false
}
// IsProtectedPathExplicit 同 IsProtectedPath,但仅匹配传入的显式前缀集
// (不改全局状态时的判断入口,供测试/工具使用)。
func IsProtectedPathExplicit(prefixes []string, path string) bool {
abs, err := filepath.Abs(path)
if err != nil {
return false
}
for _, p := range prefixes {
if strings.HasPrefix(abs, p) {
return true
}
}
return false
}
// ArchiveBeforeWrite 在覆盖某个受保护路径前调用:若目标为已存在的普通文件,
// 则把原文复制到 <dataDir>/file_baseline/<相对路径>(幂等:已存在同 hash 则跳过),
// 返回是否发生了留档。用于 L0 写 /etc 前的自动留档。
//
// archive base 为 <dataDir>/file_baseline。path 为绝对路径。
func ArchiveBeforeWrite(baseDir, path string) (archived bool, err error) {
if !IsProtectedPath(path) {
return false, nil
}
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil // 新建文件无需留档
}
return false, err
}
if info.IsDir() {
return false, nil
}
rel := strings.TrimPrefix(path, "/")
dst := filepath.Join(filepath.Join(baseDir, "file_baseline"), rel)
if _, err := os.Stat(dst); err == nil {
// 已留档(内容未再变化时避免重复)
if same, _ := sameContent(path, dst); same {
return false, nil
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return false, err
}
data, err := os.ReadFile(path)
if err != nil {
return false, err
}
if err := os.WriteFile(dst, data, 0600); err != nil {
return false, err
}
return true, nil
}
func sameContent(a, b string) (bool, error) {
da, ea := os.ReadFile(a)
if ea != nil {
return false, ea
}
db, eb := os.ReadFile(b)
if eb != nil {
return false, eb
}
return string(da) == string(db), nil
}
// FileBaselineDir 返回写前留档根目录。
func FileBaselineDir(dataDir string) string {
return filepath.Join(dataDir, "file_baseline")
}
// RestoreFileFromBaseline 从写前留档恢复一个受保护文件(guard 使用)。
// 若留档存在则写回并返回 true 与恢复路径;否则 false。
func RestoreFileFromBaseline(dataDir, path string) (bool, error) {
if !IsProtectedPath(path) {
return false, nil
}
rel := strings.TrimPrefix(path, "/")
dst := filepath.Join(FileBaselineDir(dataDir), rel)
data, err := os.ReadFile(dst)
if err != nil {
return false, nil
}
if err := os.WriteFile(path, data, 0644); err != nil {
return false, fmt.Errorf("restore from baseline %s: %w", dst, err)
}
return true, nil
}