Files
HomeAgent/cmd/homed/guard_safety_test.go
JianFeeeee a7f89a1b45 fix(guard)!: L2 自杀防护 + 配置零值兜底 + 补齐零覆盖的测试
部署 guard 前的审查发现两个**致命**缺陷。它们此前都零测试覆盖 ——
正是「没测过才敢发现」。

## ① L2 自杀循环(启用 guard 就会触发)

doLastResort(restart_app) 执行 `systemctl restart <unit>`,而启用 guard 的
标准做法正是让 systemd 管理 `homed --role=guard` —— <unit> 就是它自己:

    guard: doLastResort → systemctl restart homeagent
           → 杀掉自己 → systemd 按 Restart=always 拉起新 guard
           → 新 guard 再 spawn 失败 → 再自杀 …… 无限循环

每轮白跑一遍完整启动。与 2026-09-05 那次 systemd 审计记录的事故同类
(坏配置导致 836 次重启、79 分钟)。

### 修法:自指时直接退出,而不是换个单元名

「换个别的服务」语义完全不对 —— L2 的本意就是重启自己。而
`Restart=always` 已经提供了这件事:**guard 退出 == systemd 重新拉起 ==
重启整个单元**。所以自指时 os.Exit(41) 与 restart_app 完全等价,且无自杀窗口。

- `doLastResortGuarded(cfg, unit)`:判自指 → 退出 41;否则原逻辑
- `detectOwnSystemdUnit()`:读 /proc/self/cgroup(cgroup v2),兜底
  $INVOCATION_ID → /run/systemd/invocation/<id>。**拿不到就返回空字符串**
  让调用方跳过检测 —— 不猜单元名(猜错 = 拦错/杀错服务)
- `exitGuardRestartRequested = 41`:刻意避开 worker 的 42-44 区间
- 两处 doLastResort 调用点全部改走防护版

## ② 配置损坏 → agent 装上就崩

defaultGuardConfig **不解析时长字符串**(只给 "30s" 这类字面量),
parseDur 在 loadGuardConfig 里、且在 yaml.Unmarshal **失败时提前 return
之后**——那段根本不执行。

于是 guard.yaml 一旦写坏(缩进错、半截内容,手改文本极常见):

| 字段 | 实际值 | 后果 |
|---|---|---|
| heartbeatTimeout | **0** | `time.Since(mtime) > 0` 恒真 → **每轮杀 worker** → 无限重启 |
| heartbeatInterval | 0 | `time.NewTicker(0)` panic |
| attemptTimeout | 0 | 恢复 probe 立即超时 → L1 梯子必败 |
| Recovery.Plugins | 空 | 受限启动什么都不加载 |

这不是「配置没生效」,是「装上就崩」。修法:把 parseDur 与插件集缺省
移进 defaultGuardConfig,任何路径都有正值兜底。

## ③ 测试:0 → 25 条(全可杀变异)

| 文件 | 覆盖 |
|---|---|
| `guard_config_test.go` | 配置默认值/YAML/损坏/越界 + 心跳三态 |
| `guard_lifecycle_test.go` | worker 生命周期:6 种退出码分类、信号转发、坏二进制、卡死压力测试、L2 防护行为、单元探测 |
| `guard_safety_test.go` | 破坏性路径:回滚不可逆、任务协议往返、failback 插件集 |
| `rollback_irreversible_test.go` | 不可逆性证据 + 二次回滚 no-op + 新建→删除 |

**7 个变异逐个实测被杀**:parseDur 移回原处 / 心跳漏判 / 心跳误判 /
插件集留空 / exitRecovered→crash / exitRecoveryFailed→recovered /
rollback 不删 changeset / L2 防护失效 / 单元探测返回假名。

### 判据自身被变异测试逼着改三次

1. `isSelfRestartCommand` 的测试全绿,但把 `doLastResortGuarded` 改成
   直接调 `doLastResort`(防护完全不生效)时**仍然全绿** ⇒ 补了
   **行为测试**:真子进程 + 假 systemctl(PATH 前置),验「自指时
   restart_command 绝不被执行」
2. 单元探测只断言「非空+格式合法」,被 `return "wrong.service"` 骗过
   ⇒ 补了与**独立第二实现**(测试里重写一遍 cgroup 解析)的交叉核对
3. 信号转发测试用 `sleep 300` 会泄漏孤儿进程(sh fork 子进程,信号只到 sh)
   ⇒ 改 `exec sleep 300`,并加断言检测泄漏(60s → 0.52s)

### 踩到的两个坑(都记在判据注释里)

- **POSIX 环境变量不能含 NUL**:`\x00` 分隔符让 exec 直接报
  "environment variable contains NUL",改用 `\x1f`
- **测试函数里 os.Exit 会终止整个测试二进制** ⇒ 子进程入口必须用 TestMain

全仓 `go test ./... -count=1` → 48 包 ok / 0 FAIL。
2026-10-11 10:09:15 +08:00

172 lines
6.8 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.

//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 写 <data>/agentfs/heartbeat 而 guard 读 <data>/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)
}
}
}