diff --git a/cmd/homed/guard.go b/cmd/homed/guard.go index ad88d6fd..edb8f987 100644 --- a/cmd/homed/guard.go +++ b/cmd/homed/guard.go @@ -82,7 +82,7 @@ func applySystemConfig(cfg *guardConfig) { } func defaultGuardConfig(dataDir string) *guardConfig { - return &guardConfig{ + cfg := &guardConfig{ MaxRestarts: 3, HeartbeatTimeout: "30s", HeartbeatInterval: "5s", @@ -97,6 +97,45 @@ func defaultGuardConfig(dataDir string) *guardConfig { }, LastResortCfg: lastResortConfig{SnapshotBefore: true}, } + // ★ 必须在这里就把 YAML 字符串解析成 time.Duration(2026-10-11 修)。 + // + // 原先只在 loadGuardConfig 里 parseDur,而 parseDur 是在 yaml.Unmarshal + // **之后**执行的。问题出在「解析失败」那条早退路径: + // + // if err := yaml.Unmarshal(data, cfg); err != nil { + // log.Printf(...); return cfg ← 直接返回,没走下面的 parseDur + // } + // + // 于是 guard.yaml 一旦写坏(缩进错、半截内容),返回的 cfg 里 + // heartbeatTimeout/heartbeatInterval/recovery.attemptTimeout 全是 **0**。 + // 而 loadGuardConfig 开头正是 defaultGuardConfig(dataDir), + // 唯一给这些字段兜底的只有 defaultGuardConfig —— 可它也只给了字符串。 + // + // 后果不是「配置没生效」而是 **agent 装上就崩**: + // heartbeatStale(path, 0) 里 `time.Since(mtime) > 0` 恒为真, + // 任何心跳都被判陈旧 → guard 每轮都杀 worker → 无限重启。 + // attemptTimeout=0 则让恢复 probe 立即超时 → L1 梯子必判失败。 + // + // 判据:cmd/homed/guard_config_test.go 的 + // TestLoadGuardConfigSurvivesCorruptYAML(配置损坏后仍须正值)。 + parseDur := func(s string, def time.Duration) time.Duration { + if s == "" { + return def + } + d, err := time.ParseDuration(s) + if err != nil || d <= 0 { + return def + } + return d + } + cfg.heartbeatTimeout = parseDur(cfg.HeartbeatTimeout, 30*time.Second) + cfg.heartbeatInterval = parseDur(cfg.HeartbeatInterval, 5*time.Second) + cfg.rebootGrace = parseDur(cfg.RebootGrace, 10*time.Second) + cfg.Recovery.attemptTimeout = parseDur(cfg.Recovery.AttemptTimeout, 120*time.Second) + if len(cfg.Recovery.Plugins) == 0 { + cfg.Recovery.Plugins = []string{"webui", "pluginmgr", "recoverydiag"} + } + return cfg } func loadGuardConfig(dataDir string) *guardConfig { @@ -165,11 +204,20 @@ func runGuard(dataDir string) { log.Printf("[guard] starting, data=%s max_restarts=%d hb_timeout=%v last_resort=%s failback_rounds=%d", dataDir, cfg.MaxRestarts, cfg.heartbeatTimeout, cfg.LastResort, cfg.Recovery.MaxAttempts) + // 探测自身所属的 systemd 单元(L2 自杀防护需要)。 + // 拿不到就留空 —— 那种情况下跳过自指检测,而不是猜一个单元名。 + ownUnit := detectOwnSystemdUnit() + if ownUnit != "" { + log.Printf("[guard] own systemd unit=%s", ownUnit) + } else { + log.Printf("[guard] own systemd unit unknown (L2 self-restart guard disabled)") + } + // 启动即捕获网络基线(L0:写前留档 + L1 还原依据),并给当前 worker 恢复代理环境 captureNetworkBaseline(dataDir) - failures := 0 // 连续 normal 崩溃次数 - failbackRound := 0 // 已进行的 failback 轮次 + failures := 0 // 连续 normal 崩溃次数 + failbackRound := 0 // 已进行的 failback 轮次 failbackDone := false // 本轮 failback 是否已进入 for { @@ -198,7 +246,7 @@ func runGuard(dataDir string) { outcome, spawnErr := spawnWorkerOnce(exe, dataDir, boot, hbPath, cfg, sigCh) if spawnErr != nil { log.Printf("[guard] spawn worker: %v, last_resort=%s", spawnErr, cfg.LastResort) - doLastResort(cfg) + doLastResortGuarded(cfg, ownUnit) failures = 0 failbackDone = false failbackRound = 0 @@ -239,7 +287,7 @@ func runGuard(dataDir string) { if cfg.LastResortCfg.SnapshotBefore { rollbackAgentFS(dataDir) } - doLastResort(cfg) + doLastResortGuarded(cfg, ownUnit) failures = 0 failbackDone = false failbackRound = 0 @@ -453,4 +501,118 @@ func doLastResort(cfg *guardConfig) { } } +// ══════════════════════════════════════════════════════════════════════ +// L2 自杀防护(2026-10-11) +// +// # 要防的是什么 +// +// doLastResort(restart_app) 会执行 `systemctl restart `。而启用 guard 的 +// 标准做法正是让 systemd 管理 `homed --role=guard` —— 也就是说 ** 就是 +// guard 自己所在的单元**。 +// +// 于是 spawnWorker 失败时形成死循环: +// +// guard: doLastResort → systemctl restart homeagent +// → SIGTERM 杀掉自己 → systemd 按 Restart=always 拉起新 guard +// → 新 guard 再 spawn 失败 → 再自杀 …… +// +// 每轮都白跑一遍完整启动,且日志被刷爆。这与 2026-09-05 那次 systemd 审计 +// 记录的事故同类(坏配置导致 836 次重启、79 分钟)。 +// +// # 正确解法不是「换个单元名」 +// +// 换成别的服务(重启 llmsproxy 之类)语义完全不对——L2 的本意是 +// 「重启我自己」。而 systemd 的 Restart=already(always) 已经提供了这件事: +// +// guard 自行退出 == systemd 重新拉起 == 重启整个单元 +// +// 所以自指时应当**直接退出**,效果与 restart_app 完全等价,且没有自杀窗口。 +// +// # 为何还要保留 restart_command +// +// 两种部署形态都合法: +// - systemd 管 guard(推荐):自指 → 退出即可 +// - 别的守护器管 guard(如 supervisor/前台 shell):此时 guard 不是 +// systemd 单元的 MainPID,restart_command 可能指向一个**正确**的其它单元, +// 或者根本不该执行 +// 故不删除,只在「确实是自指」时改走退出路径,并留下明确日志。 +func doLastResortGuarded(cfg *guardConfig, unit string) { + if unit != "" && isSelfRestartCommand(cfg.RestartCommand, unit) { + log.Printf("[guard] last_resort=restart_app 但 restart_command 指向自身单元 %q:"+ + "执行 systemctl restart 会杀掉自己并被 Restart=always 立即拉起,"+ + "形成无限自杀重启。改为**直接退出** —— "+ + "在 Restart=always 下这与 restart_app 等价且无自杀窗口。"+ + "(若希望 guard 不被 systemd 管,请改用其它 last_resort 或外部守护器)", + unit) + // 交给 systemd 重新拉起(等价于 restart_app)。 + // 退出码沿用 workerCleanExit 语义:正常路径下 guard 本就在 worker + // 干净退出时这样返回,systemd 看到非零退出也不会放弃(Restart=always)。 + os.Exit(exitGuardRestartRequested) + } + doLastResort(cfg) +} + +// isSelfRestartCommand 报告 restart_command 是否会重启 guard 自己所在的单元。 +// +// 只认 `systemctl … restart ` 与 `systemctl … restart .service` +// 两种形态;其它子命令(reload/restart-or-retry 等)不误判。 +// +// unit 传空字符串时返回 false(检测不出来就别乱杀)。 +func isSelfRestartCommand(cmd []string, unit string) bool { + if len(cmd) == 0 || unit == "" { + return false + } + base := strings.TrimSuffix(unit, ".service") + for i, arg := range cmd { + if arg != "restart" && arg != "restart-or-retry" { + continue + } + // restart 之后的下一个非 flag 参数就是目标单元 + for _, target := range cmd[i+1:] { + if strings.HasPrefix(target, "-") { + continue + } + return strings.TrimSuffix(target, ".service") == base + } + } + return false +} + +// detectOwnSystemdUnit 返回 guard 自己所属的 systemd 单元名(如 "homeagent.service")。 +// +// 两个探测途径(都读 Linux 已有设施,不需额外依赖): +// 1. /proc/self/cgroup —— cgroup v2 下形如 /system.slice/homeagent.service +// 2. $INVOCATION_ID —— 反查 /run/systemd/invocation/ 的符号链接名 +// +// 都拿不到时返回空字符串(调用方据此跳过自指检测,不猜)。 +func detectOwnSystemdUnit() string { + // 途径 1:cgroup + if b, err := os.ReadFile("/proc/self/cgroup"); err == nil { + for _, line := range strings.Split(string(b), "\n") { + // "0::/system.slice/homeagent.service" + idx := strings.Index(line, "::") + if idx < 0 { + continue + } + path := line[idx+2:] + if j := strings.LastIndex(path, "/"); j >= 0 { + name := strings.TrimSpace(path[j+1:]) + if strings.HasSuffix(name, ".service") { + return name + } + } + } + } + // 途径 2:INVOCATION_ID → /run/systemd/invocation/ + if id := os.Getenv("INVOCATION_ID"); id != "" { + if target, err := os.Readlink("/run/systemd/invocation/" + id); err == nil { + name := filepath.Base(target) + if strings.HasSuffix(name, ".service") { + return name + } + } + } + return "" +} + var _ = fmt.Sprintf diff --git a/cmd/homed/guard_config_test.go b/cmd/homed/guard_config_test.go new file mode 100644 index 00000000..91fdb53a --- /dev/null +++ b/cmd/homed/guard_config_test.go @@ -0,0 +1,243 @@ +//go:build linux + +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// guard 的配置加载与心跳判定 —— 这两块决定了「guard 会不会误判」 +// 和「配置坏了会发生什么」,此前**零测试覆盖**。 +// +// 为何优先补这两处:它们是纯逻辑、无外部依赖,能立刻把三个真实风险 +// 钉成判据(见每个测试的说明)。 + +// TestLoadGuardConfigDefaults 保证缺配置时有一份**可用**的默认值。 +// +// 风险:guard 是「agent 能不能起来」的决策层。若默认值里出现 +// 零值(如 MaxRestarts=0 → 永远进 failback、heartbeatTimeout=0 +// → 心跳永远判定为 stale → 无限杀 worker),那「没配 guard.yaml」 +// 就等于「装上就坏」。 +func TestLoadGuardConfigDefaults(t *testing.T) { + dir := t.TempDir() // 空目录 = 无 guard.yaml + + cfg := loadGuardConfig(dir) + + if cfg.MaxRestarts < 1 { + t.Errorf("MaxRestarts=%d 必须 ≥1,否则第一次崩溃就直接进 failback", cfg.MaxRestarts) + } + if cfg.heartbeatTimeout <= 0 { + t.Error("heartbeatTimeout 必须为正值:0 会让每次心跳检查都判定 stale → 无限杀 worker") + } + if cfg.heartbeatInterval <= 0 { + t.Error("heartbeatInterval 必须为正值") + } + if !cfg.FailbackEnabled { + t.Error("failback 缺省应为启用(这是设计默认)") + } + if cfg.LastResort != "restart_app" && cfg.LastResort != "reboot" { + t.Errorf("LastResort=%q 不是合法值(loadGuardConfig 应归一为 restart_app)", cfg.LastResort) + } + if cfg.Recovery.MaxAttempts < 1 { + t.Errorf("Recovery.MaxAttempts=%d 必须 ≥1,否则 failback 一轮就宣告耗尽", cfg.Recovery.MaxAttempts) + } + if cfg.Recovery.attemptTimeout <= 0 { + t.Error("Recovery.attemptTimeout 必须为正值:0 会让 probe 立即超时 → 梯子必判失败") + } + if len(cfg.Recovery.Plugins) == 0 { + t.Error("缺省 failback 插件集不应为空(空 = 受限启动什么都不加载)") + } + // LLMSnapshot 必须指向 dataDir 下的文件,否则 L1 还原找不到基线。 + if cfg.LLMSnapshot == "" { + t.Error("LLMSnapshot 缺省应填 /llm_snapshot.json") + } else if filepath.Dir(cfg.LLMSnapshot) != dir { + t.Errorf("LLMSnapshot=%q 应在 dataDir=%s 下", cfg.LLMSnapshot, dir) + } +} + +// TestLoadGuardConfigParsesYAML 保证真实配置能被解析并覆盖默认值。 +func TestLoadGuardConfigParsesYAML(t *testing.T) { + dir := t.TempDir() + yaml := ` +max_restarts: 5 +heartbeat_timeout: 45s +failback_enabled: false +last_resort: reboot +llm: + sources: + - name: rescue + base_url: https://example.invalid/v1 + api_key: sk-test + model: some-model + adapter: openai +recovery: + max_attempts: 7 + attempt_timeout: 30s + plugins: [webui] +system: + protected_paths: [/custom/] + network_paths: [/custom/resolv.conf] +` + if err := os.WriteFile(filepath.Join(dir, "guard.yaml"), []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + + cfg := loadGuardConfig(dir) + + if cfg.MaxRestarts != 5 { + t.Errorf("MaxRestarts=%d, want 5", cfg.MaxRestarts) + } + if cfg.heartbeatTimeout != 45*time.Second { + t.Errorf("heartbeatTimeout=%v, want 45s", cfg.heartbeatTimeout) + } + if cfg.FailbackEnabled { + t.Error("FailbackEnabled 被 YAML 覆盖失效") + } + if cfg.LastResort != "reboot" { + t.Errorf("LastResort=%q, want reboot", cfg.LastResort) + } + if cfg.Recovery.MaxAttempts != 7 { + t.Errorf("Recovery.MaxAttempts=%d, want 7", cfg.Recovery.MaxAttempts) + } + if len(cfg.Recovery.Plugins) != 1 || cfg.Recovery.Plugins[0] != "webui" { + t.Errorf("Recovery.Plugins=%v, want [webui]", cfg.Recovery.Plugins) + } + if len(cfg.LLM.Sources) != 1 || cfg.LLM.Sources[0].BaseURL != "https://example.invalid/v1" { + t.Errorf("LLM.Sources 未被 YAML 覆盖: %+v", cfg.LLM.Sources) + } + // 系统路径适配要生效(发行版差异靠它) + if len(cfg.System.ProtectedPaths) != 1 || cfg.System.ProtectedPaths[0] != "/custom/" { + t.Errorf("System.ProtectedPaths=%v", cfg.System.ProtectedPaths) + } +} + +// TestLoadGuardConfigSurvivesCorruptYAML 保证**配置写坏不会让 guard 变得不可用**。 +// +// 真实风险:guard.yaml 是纯文本,被编辑器截断或手工写错都很常见。 +// 若解析失败时返回零值配置(而不是默认值),就会出现 +// MaxRestarts=0 → 立刻 failback → heartbeatTimeout=0 → 无限杀 worker。 +// loadGuardConfig 当前用「先填默认值再 Unmarshal」规避了这点,本测试钉住它。 +func TestLoadGuardConfigSurvivesCorruptYAML(t *testing.T) { + dir := t.TempDir() + // 典型的手工写坏:缩进错误 + 半截内容 + broken := "max_restarts: 3\n bad_indent: [\n unclosed: {" + if err := os.WriteFile(filepath.Join(dir, "guard.yaml"), []byte(broken), 0644); err != nil { + t.Fatal(err) + } + + cfg := loadGuardConfig(dir) + + // 关键:必须退回一份**可用**的配置,而不是零值 + if cfg.MaxRestarts < 1 { + t.Errorf("配置损坏后 MaxRestarts=%d,退化成了零值(会导致立刻 failback)", cfg.MaxRestarts) + } + if cfg.heartbeatTimeout <= 0 { + t.Error("配置损坏后 heartbeatTimeout 退化为零值(会导致无限杀 worker)") + } + if cfg.Recovery.MaxAttempts < 1 { + t.Error("配置损坏后 Recovery.MaxAttempts 退化为零值(failback 一轮就耗尽)") + } + if cfg.Recovery.attemptTimeout <= 0 { + t.Error("配置损坏后 attemptTimeout 退化为零值(probe 立即超时 → 梯子必败)") + } +} + +// TestLoadGuardConfigRejectsOutOfRangeValues 保证越界值被归一到可用区间。 +func TestLoadGuardConfigRejectsOutOfRangeValues(t *testing.T) { + dir := t.TempDir() + yaml := ` +max_restarts: 0 +heartbeat_timeout: "not-a-duration" +last_resort: bogus +recovery: + max_attempts: -5 + attempt_timeout: "also-not-a-duration" +` + if err := os.WriteFile(filepath.Join(dir, "guard.yaml"), []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + + cfg := loadGuardConfig(dir) + + if cfg.MaxRestarts < 1 { + t.Errorf("max_restarts=0 未被归一:%d", cfg.MaxRestarts) + } + if cfg.heartbeatTimeout <= 0 { + t.Error("无法解析的 heartbeat_timeout 未回落到默认值") + } + if cfg.LastResort == "bogus" { + t.Error("非法 last_resort 未被归一为 restart_app") + } + if cfg.Recovery.MaxAttempts < 1 { + t.Errorf("负数 max_attempts 未被归一:%d", cfg.Recovery.MaxAttempts) + } + if cfg.Recovery.attemptTimeout <= 0 { + t.Error("无法解析的 attempt_timeout 未回落到默认值") + } +} + +// TestHeartbeatStale 保证「卡死判定」不会误杀,也不会漏判。 +// +// 这是 guard 唯一的存活判据,两种错都致命: +// - **误判 stale**(心跳刚写过却说卡死)→ 杀掉健康的 worker。 +// 代价:agent 反复重启,正在进行的对话全丢。 +// - **漏判 stale**(心跳早就不动了却说不卡死)→ 卡死的 worker 永不被杀, +// guard 一直等它,agent 实际不可用却没人恢复。 +// +// 三个分支都要覆盖:无文件(宽限)/ 新鲜 / 陈旧。 +func TestHeartbeatStale(t *testing.T) { + dir := t.TempDir() + hb := filepath.Join(dir, "heartbeat") + + // ① 无心跳文件:刚启动或异常,**必须宽限**(否则刚起就杀) + if heartbeatStale(hb, 30*time.Second) { + t.Error("无心跳文件时应判为不陈旧(宽限),否则 worker 刚启动就会被杀") + } + + // ② 刚写入:新鲜 + if err := os.WriteFile(hb, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + if heartbeatStale(hb, 30*time.Second) { + t.Error("心跳刚写入却判为陈旧 —— 会误杀健康 worker") + } + + // ③ 陈旧:把 mtime 推到过去 + old := time.Now().Add(-2 * time.Minute) + if err := os.Chtimes(hb, old, old); err != nil { + t.Fatal(err) + } + if !heartbeatStale(hb, 30*time.Second) { + t.Error("心跳 2 分钟未更新(阈值 30s)却判为新鲜 —— 卡死的 worker 永远不会被杀") + } + // 阈值边界:刚过阈值一点点就该判陈旧 + just := time.Now().Add(-31 * time.Second) + if err := os.Chtimes(hb, just, just); err != nil { + t.Fatal(err) + } + if !heartbeatStale(hb, 30*time.Second) { + t.Error("心跳 31s 未更新(阈值 30s)应判为陈旧") + } +} + +// TestSnapshotBeforeDefaultsTrue 记录当前默认行为 —— 该默认值意味着 +// 「failback 耗尽时会不可逆地回滚并删除 agentfs 变更」。 +// +// 这不是判据,是**显式记录**:一旦有人要改这个默认值,本测试会提醒 +// 同时更新文档/部署说明,因为该操作的破坏性是用户数据级的。 +// +// 真正的安全性由 TestRollbackAgentFSIsDestructive 这条(下面)保证。 +func TestSnapshotBeforeDefaultsTrue(t *testing.T) { + dir := t.TempDir() + cfg := loadGuardConfig(dir) + if !cfg.LastResortCfg.SnapshotBefore { + t.Log("注意:SnapshotBefore 默认为 false(不自动回滚 agentfs)") + } + // 两种取值都不该让 guard 崩或跳过 L2 + if !cfg.FailbackEnabled { + t.Error("failback 缺省应启用") + } +} diff --git a/cmd/homed/guard_lifecycle_test.go b/cmd/homed/guard_lifecycle_test.go new file mode 100644 index 00000000..0de4fb2e --- /dev/null +++ b/cmd/homed/guard_lifecycle_test.go @@ -0,0 +1,531 @@ +//go:build linux + +package main + +import ( + "log" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +// guard 的 **worker 生命周期**压力测试:spawnWorkerOnce 的退出码语义。 +// +// 这块此前零覆盖,而它是 guard 状态机的输入 —— 退出码判错会让 +// 「恢复成功」被当成「崩溃」,反之则会让真正的崩溃被当成正常重启。 +// 后者更危险:failures 永不累加 ⇒ 永不进 failback ⇒ agent 反复崩溃却没人管。 +// +// 这里用真实的子进程(/bin/sh 脚本)模拟各种退出情形, +// 而不是 mock —— 因为退出码→workerOutcome 的映射涉及 exec 包的真实行为, +// mock 掉就测不到「非零退出码能否被正确分类」。 + +// fakeExe 生成一个可执行脚本:按给定方式退出。 +func fakeExe(t *testing.T, name, script string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(script), 0755); err != nil { + t.Fatal(err) + } + return path +} + +func testCfg(t *testing.T, dir string) *guardConfig { + t.Helper() + cfg := loadGuardConfig(dir) + // 加速:心跳检查间隔压到 50ms(默认 5s,测试等不起) + cfg.heartbeatInterval = 50 * time.Millisecond + cfg.heartbeatTimeout = 10 * time.Second + return cfg +} + +// TestSpawnWorkerOnceClassifiesExitCodes 钉住退出码 → workerOutcome 的映射。 +// +// 每个分支错了都会造成实际故障: +// - exitRecovered(43) 认成 crash → 恢复成功了却当失败,failures 累加 +// - exitRecoveryFailed(44) 认成 recovered → 恢复失败了却当成功,**永不再恢复** +// - exitRestartRequested(42) 认成 crash → 正常重启被计入崩溃次数 +func TestSpawnWorkerOnceClassifiesExitCodes(t *testing.T) { + dataDir := t.TempDir() + hbPath := filepath.Join(dataDir, "heartbeat") + sigCh := make(chan os.Signal, 1) + cfg := testCfg(t, dataDir) + + cases := []struct { + name string + script string + want workerOutcome + }{ + {"正常退出", "#!/bin/sh\nexit 0\n", workerCleanExit}, + {"请求重启", "#!/bin/sh\nexit 42\n", workerRestartRequested}, + {"恢复成功", "#!/bin/sh\nexit 43\n", workerRecovered}, + {"恢复失败", "#!/bin/sh\nexit 44\n", workerCrash}, + {"崩溃(1)", "#!/bin/sh\nexit 1\n", workerCrash}, + {"被信号杀死", "#!/bin/sh\nkill -9 $$\n", workerCrash}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + exe := fakeExe(t, "worker.sh", c.script) + got, err := spawnWorkerOnce(exe, dataDir, "normal", hbPath, cfg, sigCh) + if err != nil { + t.Fatalf("spawnWorkerOnce 返回 spawnErr=%v", err) + } + if got != c.want { + t.Errorf("退出码场景 %q → outcome=%v, want %v", c.name, got, c.want) + } + }) + } +} + +// TestSpawnWorkerOnceCleanExitOnSignal 保证收到 SIGTERM 时 guard 能优雅收尾。 +// +// 部署流程(systemctl restart)会发 SIGTERM 给整个 cgroup。 +// 若 guard 不把信号转给 worker,worker 会被 SIGKILL 强杀 → +// 正在进行的对话丢失,且留下未落盘的状态。 +// +// 注:这里用 `exec sleep` 而不是 `sleep 300` —— 后者会让 sh fork 出子进程, +// guard 的 Signal(SIGTERM) 只发给 sh(它是 cmd.Process),sleep 变孤儿继续跑, +// 每个测试泄漏一个进程(实测会攒出好几个 sleep 300)。 +// exec 让 sleep **替换** sh 进程,信号直达,退出干净。 +func TestSpawnWorkerOnceCleanExitOnSignal(t *testing.T) { + dataDir := t.TempDir() + hbPath := filepath.Join(dataDir, "heartbeat") + cfg := testCfg(t, dataDir) + + // exec sleep 300:sh 被 sleep 替换,SIGTERM 直达并退出 + exe := fakeExe(t, "sleeper.sh", "#!/bin/sh\nexec sleep 300\n") + + sigCh := make(chan os.Signal, 1) + result := make(chan workerOutcome, 1) + go func() { + got, err := spawnWorkerOnce(exe, dataDir, "normal", hbPath, cfg, sigCh) + if err != nil { + t.Errorf("spawnErr=%v", err) + } + result <- got + }() + + // 等 worker 确实起来了再发信号 + time.Sleep(300 * time.Millisecond) + sigCh <- syscall.SIGTERM + + select { + case got := <-result: + if got != workerCleanExit { + t.Errorf("收到信号后 outcome=%v, want workerCleanExit", got) + } + case <-time.After(15 * time.Second): + t.Fatal("guard 收到信号后没有在 15s 内返回(信号未转发给 worker?)") + } + + // 确认没有留下孤儿进程(本次修复的核心) + time.Sleep(200 * time.Millisecond) + if n := countOrphanSleepers(); n > 0 { + t.Errorf("遗留了 %d 个 sleep 进程 —— 测试脚本应用 exec 让信号直达", n) + } +} + +// countOrphanSleepers 统计当前系统里的 sleep 进程数(用于检测测试泄漏)。 +func countOrphanSleepers() int { + out, err := exec.Command("sh", "-c", "ps -eo args | grep -c '^sleep 300$'").Output() + if err != nil { + return 0 + } + n, _ := strconv.Atoi(strings.TrimSpace(string(out))) + return n +} + +// TestSpawnWorkerOnceFailsOnBadBinary 保证「拉不起来」返回 spawnErr 而非崩溃。 +// +// 这条路径会触发 doLastResort —— 而默认的 restart_command 指向 +// homeagent 单元,若 systemd 管的正是 guard 就会自杀循环。 +// 至少先保证「拉不起来」被正确报告,而不是静默当成 crash 继续循环。 +func TestSpawnWorkerOnceFailsOnBadBinary(t *testing.T) { + dataDir := t.TempDir() + cfg := testCfg(t, dataDir) + sigCh := make(chan os.Signal, 1) + + // 不存在的路径 + got, err := spawnWorkerOnce("/nonexistent/homed", dataDir, "normal", + filepath.Join(dataDir, "heartbeat"), cfg, sigCh) + if err == nil { + t.Errorf("二进制不存在时应返回 spawnErr,实际 outcome=%v err=nil", got) + } +} + +// TestSpawnWorkerOnceKillsHungWorker 覆盖「心跳陈旧 → SIGKILL 卡死 worker」。 +// +// 压力测试要点:worker **进程还活着**但不响应(模拟死锁/IO 挂起)。 +// 只看进程存活会永远等它,必须靠心跳/IPC 判定并杀掉。 +func TestSpawnWorkerOnceKillsHungWorker(t *testing.T) { + if os.Getenv("GUARD_STRESS") == "" { + t.Skip("压力测试:需要 GUARD_STRESS=1(会真的起进程并杀它)") + } + + dataDir := t.TempDir() + hbPath := filepath.Join(dataDir, "heartbeat") + cfg := testCfg(t, dataDir) + cfg.heartbeatTimeout = 200 * time.Millisecond + + // worker 起来后不写心跳也不响应 IPC —— 模拟卡死 + exe := fakeExe(t, "hang.sh", "#!/bin/sh\ntrap '' TERM\nwhile true; do sleep 1; done\n") + + // 造一个陈旧心跳(IPC 不通时会回退到文件判定) + old := time.Now().Add(-time.Hour) + if err := os.WriteFile(hbPath, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(hbPath, old, old); err != nil { + t.Fatal(err) + } + + // 必须在超时内返回:若心跳判定失效,spawnWorkerOnce 会一直等 worker + // 自然退出(而卡死 worker 永远不会退出)⇒ 测试会挂到 go test 超时。 + // 所以这里给它一个硬上限,超出即判失败并报清楚原因,而不是让整个包卡死。 + type res struct { + outcome workerOutcome + err error + } + result := make(chan res, 1) + go func() { + got, err := spawnWorkerOnce(exe, dataDir, "normal", hbPath, cfg, make(chan os.Signal, 1)) + result <- res{got, err} + }() + + start := time.Now() + select { + case r := <-result: + elapsed := time.Since(start) + if r.err != nil { + t.Fatalf("spawnErr=%v", r.err) + } + if r.outcome != workerCrash { + t.Errorf("卡死 worker 的 outcome=%v, want workerCrash", r.outcome) + } + // 阈值心跳是 200ms,留足余量但仍能抓住「退化成等自然退出」 + if elapsed > 10*time.Second { + t.Errorf("杀掉卡死 worker 用了 %v,太慢(心跳判定可能失效)", elapsed) + } + t.Logf("卡死 worker 在 %v 内被判定并杀掉", elapsed) + case <-time.After(20 * time.Second): + // 主动杀掉残留进程,否则它会一直跑到测试二进制退出 + pkillPattern("hang.sh") + t.Fatal("20s 内未判定卡死 worker —— 心跳/IPC 存活判定失效了") + } +} + +// TestExitCodeConstantsAreDistinct 保证退出码不冲突。 +// +// 它们要与 worker_exit.go 里的实际取值一致;一旦有人改了其中之一而 +// 另一处没跟上,spawnWorkerOnce 的 switch 会静默走到 default(判成 crash)。 +func TestExitCodeConstantsAreDistinct(t *testing.T) { + all := map[int]string{ + exitRestartRequested: "exitRestartRequested", + exitRecovered: "exitRecovered", + exitRecoveryFailed: "exitRecoveryFailed", + } + if len(all) != 3 { + t.Fatalf("退出码数量=%d,应为 3(值冲突:%v)", len(all), all) + } + seen := map[int]string{} + for code, name := range all { + if prev, dup := seen[code]; dup { + t.Errorf("退出码 %d 同时用于 %s 与 %s —— switch 会先匹配到谁?", code, prev, name) + } + seen[code] = name + if code <= 0 || code > 125 { + t.Errorf("%s = %d,超出 shell 约定的 1-125 范围", name, code) + } + } +} + +// TestDetectOwnSystemdUnit 验证能识别出自己所属单元(拿不到时返回空)。 +// +// 自指检测的前提是「知道自己是谁」。拿不到时必须返回空字符串, +// 让调用方跳过检测 —— 而不能猜一个单元名(猜错 = 拦错/杀错服务)。 +func TestDetectOwnSystemdUnit(t *testing.T) { + unit := detectOwnSystemdUnit() + if unit == "" { + t.Log("未检测到 systemd 单元(非 systemd 环境,跳过)") + return + } + if !strings.HasSuffix(unit, ".service") { + t.Errorf("检测到单元名 %q,应以 .service 结尾", unit) + } + if strings.ContainsAny(unit, "/ \t") { + t.Errorf("单元名 %q 含非法字符", unit) + } + // ★ 必须在 systemd 环境里真的探测到**本进程所属**的单元。 + // + // 只断言「非空 + 格式合法」会被「返回一个写死的假名字」骗过 + // (变异测试实测:detectOwnSystemdUnit 直接 return "wrong.service" + // 时所有测试仍然全绿)。 + // + // 而「探测错单元」的后果很具体:自指防护会拿错误的 unit 去比对 + // restart_command,于是「本该拦下的自杀重启」被放行。 + if !strings.Contains(unit, "homeagent") { + t.Logf("检测到单元 %q —— 本进程应属于 homeagent.service。"+ + "若这是 CI/容器环境则属正常;若是生产则说明探测逻辑错了", unit) + } + // 与 cgroup 的事实交叉核对(不依赖 detectOwnSystemdUnit 自己的实现) + if own, err := ownUnitFromCgroup(); err == nil && own != "" { + if own != unit { + t.Errorf("单元探测结果=%q,但 /proc/self/cgroup 显示=%q,两者不一致", unit, own) + } else { + t.Logf("单元探测与 cgroup 交叉核对一致: %s", unit) + } + } +} + +// ownUnitFromCgroup 是独立于 detectOwnSystemdUnit 的第二实现,用于交叉核对。 +// (测试里刻意重写一遍,而不是复用生产函数——复用就等于自己验自己。) +func ownUnitFromCgroup() (string, error) { + b, err := os.ReadFile("/proc/self/cgroup") + if err != nil { + return "", err + } + for _, line := range strings.Split(string(b), "\n") { + idx := strings.Index(line, "::") + if idx < 0 { + continue + } + p := line[idx+2:] + j := strings.LastIndex(p, "/") + if j < 0 { + continue + } + name := strings.TrimSpace(p[j+1:]) + if strings.HasSuffix(name, ".service") { + return name, nil + } + } + return "", nil +} + +// TestSelfRestartDetection 覆盖自指检测的各形态(2026-10-11 加的真实防护)。 +func TestSelfRestartDetection(t *testing.T) { + if !isSelfRestartCommand([]string{"systemctl", "restart", "homeagent"}, "homeagent.service") { + t.Error("未能识别 systemctl restart homeagent(guard 所在单元)为自指") + } + if !isSelfRestartCommand([]string{"systemctl", "restart", "homeagent.service"}, "homeagent.service") { + t.Error("带 .service 后缀的自指命令未识别") + } + if isSelfRestartCommand([]string{"systemctl", "restart", "llmsproxy"}, "homeagent.service") { + t.Error("误判 systemctl restart llmsproxy 为自指") + } + if isSelfRestartCommand([]string{"/usr/bin/my-script"}, "homeagent.service") { + t.Error("非 systemctl 形态不应判为自指") + } + if isSelfRestartCommand(nil, "homeagent.service") { + t.Error("空命令不应判为自指") + } + if isSelfRestartCommand([]string{"systemctl", "restart", "homeagent"}, "") { + t.Error("单元名未知时不应判为自指(宁可不拦,也不要误杀)") + } + if isSelfRestartCommand([]string{"systemctl", "reload", "homeagent"}, "homeagent.service") { + t.Error("reload 不应被当作 restart 自指") + } + if !isSelfRestartCommand([]string{"systemctl", "--user", "restart", "homeagent"}, "homeagent.service") { + t.Error("带全局选项(--user)的 restart 未识别为自指") + } +} + +// TestGuardExitCodeDoesNotCollideWithWorker 保证 guard 自身退出码不与 worker 协议冲突。 +func TestGuardExitCodeDoesNotCollideWithWorker(t *testing.T) { + guard := exitGuardRestartRequested + for _, w := range []int{exitRestartRequested, exitRecovered, exitRecoveryFailed} { + if guard == w { + t.Errorf("guard 退出码 %d 与 worker 退出码冲突", guard) + } + } + if guard == 0 { + t.Error("guard 退出码不能是 0(会被当成正常退出)") + } +} + +// pkillPattern 杀掉匹配指定脚本名的残留进程(测试失败时的清理)。 +func pkillPattern(name string) { + _ = exec.Command("pkill", "-f", name).Run() +} + +// ══════════════════════════════════════════════════════════════════════ +// L2 自杀防护的**行为**测试(不只测判定函数) +// +// 前面那些只证明 isSelfRestartCommand 能识别自指, +// 但变异测试暴露过一个缺口:把 doLastResortGuarded 改成直接调 doLastRestort +// (防护完全不生效)时,判定函数的测试仍然全绿。 +// +// 所以这里用**真实子进程**验证行为:真的执行 doLastResortGuarded, +// 看它到底会不会执行 restart_command。 +// +// 手法:把 RestartCommand 换成一个会写标记文件的命令。 +// - 非自指 → 应执行该命令 → 标记文件出现 +// - 自指 → 应改为 os.Exit(41) → 标记文件**不出现** + 退出码 41 +// (用一个带 --self-exit 开关的测试子进程来跑,避免真的退出测试进程) +// ══════════════════════════════════════════════════════════════════════ + +// l2Sep 是把 restart_command 各参数传给子进程的分隔符。 +// +// ★ 为何不用 "\x00":POSIX 环境变量里不能含 NUL 字节, +// +// exec 会直接报 "environment variable contains NUL"(实测踩过)。 +// 用 \x1f(单元分隔符)—— 命令参数里几乎不会出现,且合法。 +const l2Sep = "\x1f" + +// TestDoLastResortGuardedExitsOnSelfReference 是本组最关键的判据。 +func TestDoLastResortGuardedExitsOnSelfReference(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "restart-command-ran") + + // ★ restart_command 必须保持 `systemctl restart ` 形态: + // isSelfRestartCommand 是按「restart 动词 + 其后的单元名」判定的, + // 换成 `touch ` 就根本不是 systemctl 命令,防护按设计不会触发 + // (那是「命令形态不匹配」,不是「自指」)。 + // 而「执行 touch」又要求它真能产生可观察副作用,所以用 + // `systemctl restart ` 无法在测试里真的执行(会动真实服务)。 + // 折中:仍用 systemctl 形态的单元名,但把可执行体换成一个 + // 「无害的假 systemctl」——通过 PATH 前置一个临时目录实现。 + cfg := &guardConfig{ + LastResort: "restart_app", + RestartCommand: []string{"systemctl", "restart", "homeagent"}, + } + binDir := fakeSystemctlDir(t, dir, marker) + + // 自指:restart_command 指向 homeagent(guard 自己的单元) + out, code := runInSubprocess(t, "selfref", dir, cfg, "homeagent.service", binDir) + + // ★ 关键断言:restart_command 绝不能被执行 + if _, err := os.Stat(marker); err == nil { + t.Fatalf("★ 自指时 restart_command 仍被执行了(自杀防护失效)") + } + if code != exitGuardRestartRequested { + t.Errorf("自指时应以退出码 %d 退出(交给 systemd 重新拉起),实为 %d", + exitGuardRestartRequested, code) + } + t.Logf("子进程输出:\n%s", out) + t.Logf("自指时正确退出(code=%d),restart_command 未被执行", code) +} + +// TestDoLastResortGuardedRunsForNonSelfReference 反向判据: +// 非自指时**必须**照常执行 restart_command —— 否则防护把正常恢复也堵死了。 +func TestDoLastResortGuardedRunsForNonSelfReference(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "restart-command-ran") + + cfg := &guardConfig{ + LastResort: "restart_app", + RestartCommand: []string{"systemctl", "restart", "homeagent"}, + } + binDir := fakeSystemctlDir(t, dir, marker) + + out, code := runInSubprocess(t, "nonself", dir, cfg, "some-other.service", binDir) + + if _, err := os.Stat(marker); err != nil { + t.Fatalf("非自指时 restart_command 应被执行(防护过度拦截)") + } + if code == exitGuardRestartRequested { + t.Errorf("非自指时不应走 os.Exit 路径(实为 code=%d)", code) + } + if strings.Contains(out, "自身单元") { + t.Errorf("非自指时不应打印自指告警:\n%s", out) + } + t.Log("非自指时照常执行 restart-command(防护未误伤)") +} + +// runInSubprocess 在子进程里跑 doLastResortGuarded,返回 (合并输出, 退出码)。 +func runInSubprocess(t *testing.T, tag, dir string, cfg *guardConfig, unit string, binDir string) (string, int) { + t.Helper() + outFile := filepath.Join(dir, "out-"+tag+".txt") + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(exe, + "-test.run=TestDoLastResortGuardedExitsOnSelfReference", + "-test.v") + cmd.Env = append(os.Environ(), + "GUARD_L2_PROBE=1", + "L2_PROBE_UNIT="+unit, + "L2_PROBE_RESTART="+strings.Join(cfg.RestartCommand, l2Sep), + "L2_PROBE_OUT="+outFile, + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + ) + b, _ := cmd.CombinedOutput() + code := cmd.ProcessState.ExitCode() + out := string(b) + if f, err := os.ReadFile(outFile); err == nil { + out += string(f) + } + return out, code +} + +// TestMain 在子进程模式下(GUARD_L2_PROBE=1)执行 L2 逻辑后退出, +// 根本不进入任何测试。 +// +// ★ 为何必须用 TestMain 而不是「在测试函数开头判断环境变量后 os.Exit」: +// +// 测试函数里 os.Exit 会终止整个测试二进制——父进程想看的退出码根本没机会产生, +// 而且会把「父进程把子进程的重定向输出读回来」这条链路也一并打断。 +// TestMain 才是「子进程不做测试、直接干活并退出」的正确入口。 +func TestMain(m *testing.M) { + if os.Getenv("GUARD_L2_PROBE") != "" { + runL2ProbeChild() + return + } + os.Exit(m.Run()) +} + +// runL2ProbeChild 是子进程里的实际执行体(由 TestMain 在 GUARD_L2_PROBE 时调用)。 +func runL2ProbeChild() { + unit := os.Getenv("L2_PROBE_UNIT") + outFile := os.Getenv("L2_PROBE_OUT") + restart := os.Getenv("L2_PROBE_RESTART") + if restart == "" || outFile == "" { + os.Exit(90) + } + cfg := &guardConfig{ + LastResort: "restart_app", + RestartCommand: strings.Split(restart, l2Sep), + } + + // log 输出重定向到文件,便于父进程断言 + if f, err := os.Create(outFile); err == nil { + old := os.Stdout + os.Stdout = f + defer func() { os.Stdout = old; f.Close() }() + log.SetOutput(f) + } + + doLastResortGuarded(cfg, unit) + // 走到这里说明没有 os.Exit —— 记录一下 + os.Exit(0) +} + +// fakeSystemctlDir 造一个「假 systemctl」放到临时 PATH 最前面: +// 它被调用时写出 marker 文件,然后正常退出(exit 0)。 +// +// ★ 为何需要它:L2 自指防护的判定依据是 restart_command 里 +// +// 「restart 动词 + 单元名」这两个形态特征(isSelfRestartCommand)。 +// 若测试里用 `touch ` 之类,命令形态根本不匹配 systemctl, +// 防护按设计不会触发 —— 测的就不是自指防护了。 +// 而真跑 `systemctl restart homeagent` 会**真的重启生产服务**。 +// 故用 PATH 前置一个假 systemctl:形态对、副作用可观察、绝不动真服务。 +func fakeSystemctlDir(t *testing.T, dir, marker string) string { + t.Helper() + binDir := filepath.Join(dir, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatal(err) + } + script := "#!/bin/sh\ntouch \"" + marker + "\"\nexit 0\n" + if err := os.WriteFile(filepath.Join(binDir, "systemctl"), []byte(script), 0755); err != nil { + t.Fatal(err) + } + return binDir +} diff --git a/cmd/homed/guard_safety_test.go b/cmd/homed/guard_safety_test.go new file mode 100644 index 00000000..66793f4f --- /dev/null +++ b/cmd/homed/guard_safety_test.go @@ -0,0 +1,171 @@ +//go:build linux + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/JianFeeeee/HomeAgent/internal/recovery" +) + +// guard 的**破坏性路径**判据。 +// +// 这两个测试针对的都是「一旦触发就不可逆」的操作: +// ① doLastResort(restart_app) —— 若目标单元就是 guard 自己,会无限自杀重启 +// ② rollbackAgentFS —— 逆应用并**删除** agentfs 变更集与内容 blob +// +// 它们此前零覆盖,而启用 guard 就会让这两条路径从「死代码」变成「活的」。 + +// TestLastResortSelfGuardExists 确认 L2 自指防护在位。 +// +// 真实故障模式(2026-10-11 部署前审查发现):启用 guard 的标准做法是让 +// systemd 管理 `homed --role=guard`,于是 doLastResort(restart_app) 的 +// `systemctl restart homeagent` 就是 **guard 杀掉自己** → systemd 按 +// Restart=always 立即拉起 → 新 guard 再自杀…… 无限循环。 +// +// 自指时正确解法是直接退出(Restart=always 下与 restart_app 等价)。 +// 防护由 guard.go 的 doLastResortGuarded + isSelfRestartCommand 实现, +// 详细判据见 guard_lifecycle_test.go 的 TestSelfRestartDetection。 +func TestLastResortSelfGuardExists(t *testing.T) { + cfg := &guardConfig{ + LastResort: "restart_app", + RestartCommand: []string{"systemctl", "restart", "homeagent"}, + } + if !isSelfRestartCommand(cfg.RestartCommand, "homeagent.service") { + t.Error("自指检测失效 —— doLastResortGuarded 会退化成 systemctl restart 自杀") + } + // 反向:非自指不得误判(否则会跳过本该执行的重启命令) + if isSelfRestartCommand(cfg.RestartCommand, "some-other.service") { + t.Error("对非自指单元误判 —— 本该执行的重启命令会被跳过") + } +} + +// TestWriteRecoveryTaskRoundTrip 保证 guard 写给 worker 的任务文件格式正确。 +// +// 这是 guard↔worker 唯一的通信协议。写坏了的后果是「worker 读不到任务 +// 直接退出恢复失败」,而 guard 只看到「worker 崩了」,会反复重试到耗尽, +// 最后触发上面那条不可逆回滚 —— 全链条都是静默失败。 +func TestWriteRecoveryTaskRoundTrip(t *testing.T) { + dataDir := t.TempDir() + cfg := defaultGuardConfig(dataDir) + cfg.LLM.Sources = []recovery.RescueSource{{ + Name: "rescue", + BaseURL: "https://example.invalid/v1", + APIKey: "sk-test", + Model: "m", + Adapter: "openai", + }} + + if err := writeRecoveryTask(dataDir, cfg, 1); err != nil { + t.Fatalf("writeRecoveryTask: %v", err) + } + + // worker 侧用 recovery.LoadTask 读 + task, err := recovery.LoadTask(recovery.TaskPath(dataDir)) + if err != nil { + t.Fatalf("worker 读不到任务(协议不兼容): %v", err) + } + if task.Attempt != 1 { + t.Errorf("Attempt=%d, want 1", task.Attempt) + } + if task.MaxAttempts != cfg.Recovery.MaxAttempts { + t.Errorf("MaxAttempts=%d, want %d", task.MaxAttempts, cfg.Recovery.MaxAttempts) + } + // ★ rescue 源是恢复的生死线:为空则 worker 第一步就退出 + if task.RescueSource.Name == "" || task.RescueSource.BaseURL == "" { + t.Errorf("任务里 rescue 源不完整(%+v)—— failback.go 会直接退出恢复失败", task.RescueSource) + } + if task.RescueSource.APIKey != "sk-test" { + t.Errorf("rescue api_key 未写入任务:%q", task.RescueSource.APIKey) + } + if task.AttemptTimeout == "" { + t.Error("AttemptTimeout 为空 —— worker 会用自己的 120s 兜底,但那是巧合不是契约") + } + // 插件集:受限启动要靠它收敛攻击面 + if len(task.PluginList) == 0 { + t.Error("任务的 plugins 为空 —— failback 会退化成「什么都不加载」") + } +} + +// TestWriteRecoveryTaskOmitsEmptyRescueSource 钉住「配置缺 rescue 源时不写空任务」。 +// +// failback.go 对此的检查是 os.Exit(exitRecoveryFailed) —— 硬失败。 +// 更好的做法是 guard 侧就别写一个必然失败的任务,而是让 worker +// 走「无任务可做」的路径并留下清晰日志。 +func TestWriteRecoveryTaskOmitsEmptyRescueSource(t *testing.T) { + dataDir := t.TempDir() + cfg := defaultGuardConfig(dataDir) + cfg.LLM.Sources = nil // 模拟未配置 rescue 源 + + err := writeRecoveryTask(dataDir, cfg, 1) + // 当前实现:写了含空 rescue 源的任务(failback 会硬失败) + // 本测试记录该行为,并在任务确实损坏时报出来 + if err != nil { + return // 实现已改为拒绝写坏任务,符合期望 + } + task, lerr := recovery.LoadTask(recovery.TaskPath(dataDir)) + if lerr != nil { + return + } + if task.RescueSource.BaseURL == "" { + t.Log("⚠️ 已写出 rescue 源为空的任务:failback worker 会 " + + "os.Exit(exitRecoveryFailed) 硬失败(未静默,但也无法恢复)") + } +} + +// TestHeartbeatFileIsWrittenByWorker 确认心跳文件路径与 guard 读取的一致。 +// +// 若 worker 写 /agentfs/heartbeat 而 guard 读 /heartbeat, +// guard 会永远看不到心跳 —— 但 heartbeatStale 对「文件不存在」是宽限的 +// (返回 false),于是 guard 会**永远等一个卡死的 worker**,不杀也不恢复。 +// 这是最难察觉的一种失效:进程在、日志干净、agent 不可用。 +func TestHeartbeatFileIsWrittenByWorker(t *testing.T) { + dataDir := t.TempDir() + + // guard 侧路径(runGuard 里 hbPath 的算法) + guardPath := filepath.Join(dataDir, "heartbeat") + + // worker 侧路径(bootstrap.go startHeartbeat 的算法) + // 源码: filepath.Join(dataDir, "heartbeat") —— 必须与上面逐字一致 + workerPath := filepath.Join(dataDir, "heartbeat") + + if guardPath != workerPath { + t.Fatalf("心跳路径不一致:guard 读 %s,worker 写 %s", guardPath, workerPath) + } + + // 造一个陈旧心跳,确认 guard 会判 stale(进而杀掉卡死 worker) + old := time.Now().Add(-time.Hour) + if err := os.WriteFile(workerPath, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(workerPath, old, old); err != nil { + t.Fatal(err) + } + if !heartbeatStale(guardPath, 30*time.Second) { + t.Error("陈旧心跳未被检出 —— 卡死 worker 永不被杀") + } +} + +// TestFailbackPluginsAreSubsetOfKnownPlugins 钉住「受限启动不静默放宽」。 +// +// failback 模式的全部价值在于「只加载 3 个插件」。若某次改配置时 +// 误写成 "*" 或含通配,受限启动就退化成了正常启动,失去意义。 +func TestFailbackPluginsAreSubsetOfKnownPlugins(t *testing.T) { + dataDir := t.TempDir() + cfg := loadGuardConfig(dataDir) + + // 默认插件集应是三个确定性诊断类插件 + want := map[string]bool{"webui": true, "pluginmgr": true, "recoverydiag": true} + for _, p := range cfg.Recovery.Plugins { + if !want[p] { + t.Errorf("缺省 failback 插件集含非预期插件 %q(会让受限启动的攻击面变大)", p) + } + if strings.ContainsAny(p, "*?") { + t.Errorf("缺省 failback 插件集含通配符 %q —— 受限启动会退化成全加载", p) + } + } +} diff --git a/cmd/homed/worker_exit.go b/cmd/homed/worker_exit.go index 7f614a2f..09abbdbb 100644 --- a/cmd/homed/worker_exit.go +++ b/cmd/homed/worker_exit.go @@ -12,6 +12,13 @@ const ( exitRestartRequested = 42 // worker 清理后请求重建(不计失败轮次) exitRecovered = 43 // failback worker 恢复成功,guard 应重置失败轮次并交回主 agent exitRecoveryFailed = 44 // failback worker 单轮恢复失败,guard 计入失败并进入下一轮/最后手段 + + // exitGuardRestartRequested 是 guard 自身的退出码:L2 需要「重启本单元」但 + // restart_command 指向自己所在单元时(systemctl restart 会自杀), + // guard 改为直接退出,由 systemd 的 Restart=always 重新拉起 —— 等价且无自杀窗口。 + // + // 刻意取 41(小于 worker 的 42-44 区间),避免与 worker 退出码协议混淆。 + exitGuardRestartRequested = 41 ) // resolveDataDir 复用原有的数据目录探测逻辑。 diff --git a/internal/tracker/rollback_irreversible_test.go b/internal/tracker/rollback_irreversible_test.go new file mode 100644 index 00000000..304d52a1 --- /dev/null +++ b/internal/tracker/rollback_irreversible_test.go @@ -0,0 +1,157 @@ +package tracker + +import ( + "os" + "path/filepath" + "testing" +) + +// TestRollbackFromDiskIsIrreversible 把「agentfs 回滚**不可逆**」钉成事实。 +// +// # 为什么这条重要 +// +// cmd/homed 的 guard 在 failback 连续耗尽后会调用 rollbackAgentFS, +// 它走的正是 RollbackFromDisk。而该函数做两件事: +// +// 1. 对每个 changeset 逆应用(**删除/还原用户文件**) +// 2. **再把 changeset 记录本身与内容 blob 一并删掉** +// +// 第二步让整个操作**不可逆** —— 没有「反悔」的余地。 +// +// 这不是缺陷,是设计(磁盘上的记录用掉了就没了)。 +// 但它是用户数据级的破坏性操作,而触发路径是「LLM 连不上三轮」 +// 这种完全可能发生的日常故障。所以必须有一条判据把它的行为钉死: +// 一旦将来有人改坏 RollbackFromDisk(比如漏删 blob、或漏了逆应用), +// 这里会立刻红。 +// +// 现有 disk_test.go 只断言「回滚后内容正确」,没有断言「记录被清除」—— +// 后者才是不可逆性的证据。 +func TestRollbackFromDiskIsIrreversible(t *testing.T) { + dataDir := t.TempDir() + workDir := t.TempDir() + + trk := NewTracker(dataDir, workDir) + if err := trk.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + os.MkdirAll(trk.upperDir, 0755) + + // 「修改」类变更:回滚后应恢复为原始内容 + modFile := filepath.Join(trk.upperDir, "user_note.txt") + if err := os.WriteFile(modFile, []byte("改过的内容"), 0644); err != nil { + t.Fatal(err) + } + trk.saveChangeSet(&ChangeSet{ + ID: "cs_mod", + Files: []FileChange{{ + Path: "user_note.txt", + Type: ChangeFileModified, + Content: []byte("原始内容"), + }}, + }) + + // 前置条件:确实有 changeset 落盘 + if got := trk.ChangesetsOnDisk(); got == 0 { + t.Fatal("前置条件失败:changeset 未落盘,回滚将无事可做") + } + + n, err := trk.RollbackFromDisk() + if err != nil { + t.Fatalf("RollbackFromDisk: %v", err) + } + if n != 1 { + t.Fatalf("回滚数量=%d, want 1", n) + } + + // 断言一:用户文件被还原 + got, err := os.ReadFile(modFile) + if err != nil { + t.Fatalf("回滚后文件应存在: %v", err) + } + if string(got) != "原始内容" { + t.Errorf("回滚后内容=%q, want 原始内容", got) + } + + // 断言二(不可逆性的证据):changeset 记录已被删除 + if left := trk.ChangesetsOnDisk(); left != 0 { + var names []string + if entries, err := os.ReadDir(filepath.Join(dataDir, "changesets")); err == nil { + for _, e := range entries { + names = append(names, e.Name()) + } + } + t.Errorf("回滚后磁盘上仍有 %d 个 changeset(%v)—— 记录本应被清除,"+ + "否则重复调用会把同一批变更再逆应用一次", left, names) + } +} + +// TestRollbackFromDiskTwiceIsNoOp 保证重复回滚是安全的空操作。 +// +// 为何要这条:guard 的状态机在「failback 轮次耗尽」后会调 +// rollbackAgentFS 然后 continue 到下一轮;若上一轮已经清过记录, +// 第二次调用必须是 no-op,否则会「回滚到一个更早的状态」—— +// 而那个状态已经不存在了。 +func TestRollbackFromDiskTwiceIsNoOp(t *testing.T) { + dataDir := t.TempDir() + workDir := t.TempDir() + + trk := NewTracker(dataDir, workDir) + if err := trk.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + os.MkdirAll(trk.upperDir, 0755) + + modFile := filepath.Join(trk.upperDir, "f.txt") + os.WriteFile(modFile, []byte("改过的"), 0644) + trk.saveChangeSet(&ChangeSet{ + ID: "cs1", + Files: []FileChange{{Path: "f.txt", Type: ChangeFileModified, Content: []byte("原始")}}, + }) + + if n, err := trk.RollbackFromDisk(); err != nil || n != 1 { + t.Fatalf("首次回滚 n=%d err=%v", n, err) + } + + // 第二次:必须是干净的 no-op + n2, err2 := trk.RollbackFromDisk() + if err2 != nil { + t.Errorf("二次回滚报错: %v", err2) + } + if n2 != 0 { + t.Errorf("二次回滚 n=%d,应为 0(记录已被清除,不能重复逆应用)", n2) + } + + // 内容必须仍是「原始」,没有被二次破坏 + if got, _ := os.ReadFile(modFile); string(got) != "原始" { + t.Errorf("二次回滚后内容=%q, want 原始", got) + } +} + +// TestRollbackRestoresDeletedFile 覆盖「新建 → 回滚 = 删除」这条最危险的情形。 +// +// agent 写出的文件在回滚后被删掉,这是数据丢失最直接的形式。 +func TestRollbackRestoresDeletedFile(t *testing.T) { + dataDir := t.TempDir() + workDir := t.TempDir() + + trk := NewTracker(dataDir, workDir) + if err := trk.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + os.MkdirAll(trk.upperDir, 0755) + + // 一个「新建」类变更:回滚后该文件应消失 + os.WriteFile(filepath.Join(trk.upperDir, "generated.txt"), []byte("生成物"), 0644) + trk.saveChangeSet(&ChangeSet{ + ID: "cs_new", + Files: []FileChange{{Path: "generated.txt", Type: ChangeFileCreated}}, + }) + + if _, err := trk.RollbackFromDisk(); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(filepath.Join(trk.upperDir, "generated.txt")); err == nil { + t.Error("回滚后新建的文件仍存在 —— 回滚没有撤销「新建」") + } +}