Files
HomeAgent/cmd/homed/guard_config_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

244 lines
8.7 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"
"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 缺省应填 <dataDir>/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 缺省应启用")
}
}