From 2b2707cb8906bf570dcc15101028cf753d648aa7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 4 Nov 2026 08:56:47 +0800 Subject: [PATCH] =?UTF-8?q?test(agent):=20=E8=AE=A9=20rt=20=E8=B7=A8?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=20e2e=20=E7=9C=9F=E6=AD=A3=E8=B7=91=E8=B5=B7?= =?UTF-8?q?=E6=9D=A5=20+=20=E7=AD=96=E7=95=A5=E6=92=A4=E9=94=80=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E8=A1=8C=E4=B8=BA=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 一、e2e 一直 SKIP,等于从未验证 `TestExternalRTChild_*` 4 条判据长期 SKIP(缺插件产物)。本次用 `third_party/hologramagentsdk/example/rtvoice/build/plugin.bin` 实跑,立刻暴露两处**测试与插件不同步**: rtvoice_inject 插件已改名为 _say ⇒ 注入失败 rtvoice_dump 插件从来没有这个工具 ⇒ 观测失败 改名发生在插件侧,而判据长期不运行,所以没人发现。这正是 "SKIP 判据等于没有判据"的实证。 修法: · 注入改用 `rtvoice-e2e_say`(插件当前唯一调试入口,走与麦克风 完全相同的 p.Push → InjectInterruptTextOpts 路径) · 输出观测改为对 outputch 设备做一次**跨进程 RPC**(channelDevice 句柄 → 插件进程 → 拿回执),不依赖插件内部私有状态 ## 二、策略撤销判据从源码扫描改为行为断言 `TestChildTeardownRevokesPolicy` 原先扫源码文本,找停止函数体里有没有 `revokeChildPolicy`。R2/R3 把撤销移进骨架 Cleanup 后文本匹配失效 —— 但行为是对的,判据自己变红。 这暴露出它抓不住的真实风险:`StopDeps.RevokePolicy` 是**注入**的, 漏填时 Cleanup 里的 nil 检查会静默跳过,而字符串仍在别处,源码扫描照样绿。 现在分两层: · kernelmod/all 保留源码断言,但钉**两个环节成对**: ① Cleanup 真的调用 deps.RevokePolicy ② 各类型真的注入它 并增加"单数与复数必须共用骨架 Stop"的结构护栏 · agent/core 新增行为判据 childpolicy_test.go: 声明策略 → 走真实停止路径 → 主体表里必须消失 ## 三、验证 go build ./... PASS go vet ./... PASS gofmt -l 干净 go test ./... -count=1 62 包全过,0 FAIL go test ./... -count=1 -race 0 FAIL rt 跨进程 e2e(4 条,不再 SKIP): TestExternalRTChild_FullChain PASS(rtchild 跑了 1 轮) TestExternalRTChild_ReuseDoesNotLeakTranscript PASS TestExternalRTChild_BulkStopUnregistersInbound PASS TestExternalRTChild_RepeatedCyclesDoNotAccumulate PASS 变异验证:断开 routeInputByOwner 的 rt 分支 ⇒ FullChain 立即红,并准确报出"输入没有到达 rtSlot" 干净检出(git worktree)单独 build + vet + test + e2e PASS --- internal/agent/core/childpolicy_test.go | 117 ++++++++++++++++++ .../kernelmod/all/security_wiring_test.go | 81 ++++++++++-- internal/plugins/rtchild_external_e2e_test.go | 58 ++++----- internal/plugins/rtchild_reuse_e2e_test.go | 4 +- 4 files changed, 213 insertions(+), 47 deletions(-) create mode 100644 internal/agent/core/childpolicy_test.go diff --git a/internal/agent/core/childpolicy_test.go b/internal/agent/core/childpolicy_test.go new file mode 100644 index 00000000..925c0d8c --- /dev/null +++ b/internal/agent/core/childpolicy_test.go @@ -0,0 +1,117 @@ +package core + +// 判据:**销毁路径真的撤销了子策略**(行为判据)。 +// +// ## 为什么需要它(而不是只靠源码扫描) +// +// R2/R3 把"撤销策略"从两个停止函数搬进了骨架 `child.Registry.Cleanup`, +// 由各类型注入的 `StopDeps.RevokePolicy` 触发。这带来一个新的、**源码扫描 +// 看不见**的风险: +// +// StopDeps.RevokePolicy 漏填 ⇒ Cleanup 里那个 `if deps.RevokePolicy != nil` +// 直接跳过,静默不撤销 +// +// 字符串 `revokeChildPolicy` 仍然出现在 `security_profile.go` 的定义体里, +// 所以源码扫描照样绿 —— 那正是结构判据的局限(engineering-lessons §1.1)。 +// +// ## 判据语义 +// +// 声明一条子策略 ⇒ 主体表里存在(前置) +// 走**真实销毁路径** ⇒ 主体表里消失(被测行为) +// +// 变红条件: +// - rtStopDeps 漏填 RevokePolicy ⇒ 红 +// - Cleanup 不调 deps.RevokePolicy ⇒ 红 +// - teardownResidentWith 删掉撤销那一步 ⇒ 红 + +import ( + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/security" +) + +// policyRTHost 造一个"父 agent + security 运行时 + 成对 rt 通道"。 +// +// 复用 newRTHost 的注册方式,但多给一个 security 运行时(撤销策略要有地方落)。 +func policyRTHost(t *testing.T) (*Agent, *security.Runtime) { + t.Helper() + rt := security.NewRuntime(nil) + host, _ := newRTHost(t, &rtBlockingProvider{}) + host.security = rt + return host, rt +} + +// TestRTChild_StopRevokesPolicy 钉住 rt 的**单数**停止撤销策略。 +func TestRTChild_StopRevokesPolicy(t *testing.T) { + host, rt := policyRTHost(t) + defer host.StopRTChildren() + + id := rtSpawn(t, host, "rp-1") + if err := host.ProbeDeclareChildPolicy(id, nil, []string{"cmd_run"}); err != nil { + t.Fatalf("声明子策略: %v", err) + } + if !rt.HasPrincipalPolicy(id) { + t.Fatal("前置失败:声明后主体表里没有该子") + } + + if err := host.StopRTChild(id); err != nil { + t.Fatalf("StopRTChild: %v", err) + } + if rt.HasPrincipalPolicy(id) { + t.Fatal("★ StopRTChild 之后主体策略仍在 —— StopDeps.RevokePolicy 可能漏填," + + "Cleanup 里的 nil 检查会静默跳过。同名重建将继承上一代父留的约束") + } +} + +// TestRTChildren_StopAllRevokesPolicy 钉住**复数**停止与单数对称。 +// +// 这是 engineering-lessons.md §2.3「单数改了复数漏了」的行为版护栏: +// 源码扫描只能看到"函数里有没有那个字符串",看不到"复数路径到底撤没撤"。 +func TestRTChildren_StopAllRevokesPolicy(t *testing.T) { + host, rt := policyRTHost(t) + defer host.StopRTChildren() + + ids := []string{"rb-1", "rb-2", "rb-3"} + for _, id := range ids { + got := rtSpawn(t, host, id) + if err := host.ProbeDeclareChildPolicy(got, nil, []string{"cmd_run"}); err != nil { + t.Fatalf("声明 %s: %v", got, err) + } + if !rt.HasPrincipalPolicy(got) { + t.Fatalf("前置失败:声明后主体表里没有 %s", got) + } + } + + if n := host.StopRTChildren(); n != len(ids) { + t.Fatalf("StopRTChildren 停止 %d 个,期望 %d", n, len(ids)) + } + for _, id := range ids { + if rt.HasPrincipalPolicy(id) { + t.Fatalf("★ StopRTChildren 之后主体策略仍在(%s)—— "+ + "复数路径与单数不对称(engineering-lessons §2.3 的形态)", id) + } + } +} + +// TestResident_DestroyRevokesPolicy 钉住驻留子销毁撤销策略。 +func TestResident_DestroyRevokesPolicy(t *testing.T) { + parent, _, dir := newRootWith(t, &countingProvider{}) + rt := security.NewRuntime(nil) + parent.security = rt + + const id = "res-rp" + spawnTestResident(t, parent, dir, id) + if err := parent.ProbeDeclareChildPolicy(id, nil, []string{"cmd_run"}); err != nil { + t.Fatalf("声明子策略: %v", err) + } + if !rt.HasPrincipalPolicy(id) { + t.Fatal("前置失败:声明后主体表里没有该驻留子") + } + + if err := parent.DestroyResident(id); err != nil { + t.Fatalf("DestroyResident: %v", err) + } + if rt.HasPrincipalPolicy(id) { + t.Fatal("★ 驻留子销毁后主体策略仍在 —— 同名重建会继承上一代约束") + } +} diff --git a/internal/kernelmod/all/security_wiring_test.go b/internal/kernelmod/all/security_wiring_test.go index a8b61fc9..2965bceb 100644 --- a/internal/kernelmod/all/security_wiring_test.go +++ b/internal/kernelmod/all/security_wiring_test.go @@ -298,32 +298,87 @@ func TestParentDeclaresChildPolicy(t *testing.T) { // 这条单列,因为本仓反复吃亏在"单数路径改了、复数路径漏了": // `StopRTChild` 注销入站登记而 `StopRTChildren` 漏了,就是这么来的。 // -// 用源码断言:真销毁需要完整记忆栈/rt 通道,而"前置不满足就 skip" -// 的判据等于没有(本仓已有同型先例:TestHiddenDependencyOnnxDeclared)。 +// ## 2026-11-04 适配:撤销动作已移入骨架 +// +// R2/R3 把"撤销策略"从两个停止函数搬进了 `child.Registry.Cleanup`, +// 由各类型注入的 `StopDeps.RevokePolicy` 触发。于是判据需要钉**两个环节**: +// +// ① 骨架真的会调用注入的 RevokePolicy(否则注入等于没接) +// ② 各类型真的注入了它(否则骨架有地方调、但没人填) +// +// 只钉 ①:注入漏填时永远绿。 +// 只钉 ②:骨架不调时永远绿。 +// +// 两个环节都缺失才是真实缺口,所以必须**成对**断言。 +// +// 行为版判据放在 `internal/agent/core`(那里有真实脚手架), +// 本处保留源码级断言,因为它能跨包检查"装配是否被人改掉"。 func TestChildTeardownRevokesPolicy(t *testing.T) { - cases := []struct{ file, fn, why string }{ + // ① 骨架:Cleanup 必须调用 deps.RevokePolicy。 + // + // 变红条件:删掉 registry.go 里那两行 ⇒ 红。 + reg := readMod(t, "../../agent/core/child/registry.go") + ci := strings.Index(reg, "func (r *Registry) Cleanup(") + if ci < 0 { + t.Fatal("找不到 Registry.Cleanup —— 符号改名了?判据需要跟着改") + } + cleanupBody := reg[ci:] + if end := strings.Index(cleanupBody, "\n}\n"); end > 0 { + cleanupBody = cleanupBody[:end] + } + if !strings.Contains(cleanupBody, "deps.RevokePolicy") { + t.Error("★ Cleanup 没有调用 deps.RevokePolicy —— 子策略永远不会被撤销。\n" + + " 后果:主体表以调用方给的名字为键,回收后同名重建会继承\n" + + " 上一代父留下的约束(而那个父可能已经不在了),且表单调增长。") + } + + // ② 各类型:必须往 StopDeps 里填 RevokePolicy。 + // + // rt 走 children.StopOne/StopAll ⇒ 由 rtStopDeps 提供。 + // 驻留子走自己的 teardownResidentWith ⇒ 那里直接调。 + cases := []struct{ file, anchor, needle, why string }{ + {"../../agent/core/rtchild.go", "func (a *Agent) rtStopDeps(", + "RevokePolicy: a.revokeChildPolicy", + "rtchild 的 StopDeps 必须注入撤销动作"}, {"../../agent/core/resident.go", "func (a *Agent) teardownResidentWith(", + "revokeChildPolicy", "驻留子销毁必须撤销父为它声明的策略"}, - {"../../agent/core/rtchild.go", "func (a *Agent) StopRTChild(", - "rtchild 单数停止必须撤销策略"}, - {"../../agent/core/rtchild.go", "func (a *Agent) StopRTChildren(", - "rtchild 批量停止必须与单数路径对称地撤销策略"}, } for _, c := range cases { src := readMod(t, c.file) - i := strings.Index(src, c.fn) + i := strings.Index(src, c.anchor) if i < 0 { - t.Fatalf("%s 里找不到 %s —— 符号改名了?判据需要跟着改", c.file, c.fn) + t.Fatalf("%s 里找不到 %s —— 符号改名了?判据需要跟着改", c.file, c.anchor) } body := src[i:] if end := strings.Index(body, "\n}\n"); end > 0 { body = body[:end] } - if !strings.Contains(body, "revokeChildPolicy") { - t.Errorf("★ %s 里没有 revokeChildPolicy —— %s。\n"+ + if !strings.Contains(body, c.needle) { + t.Errorf("★ %s 里没有 %q —— %s。\n"+ " 后果:主体表以调用方给的名字为键,回收后同名重建会继承\n"+ - " 上一代父留下的约束(而那个父可能已经不在了),且表单调增长。", - c.fn, c.why) + " 上一代父留下的约束,且表单调增长。", c.anchor, c.needle, c.why) + } + } + + // ③ 单数与复数必须**共用同一条骨架路径**(这是"对称"的结构保证)。 + // + // 变红条件:StopRTChildren 改回自己写一遍循环 + 撤销 ⇒ 红。 + rt := readMod(t, "../../agent/core/rtchild.go") + for _, fn := range []string{ + "func (a *Agent) StopRTChild(", "func (a *Agent) StopRTChildren(", + } { + i := strings.Index(rt, fn) + if i < 0 { + t.Fatalf("找不到 %s", fn) + } + body := rt[i:] + if end := strings.Index(body, "\n}\n"); end > 0 { + body = body[:end] + } + if !strings.Contains(body, "a.children.Stop") { + t.Errorf("★ %s 没走骨架的 StopOne/StopAll —— 单数与复数各写一遍时,"+ + "必然出现\"改一处漏一处\"(engineering-lessons §2.3)", fn) } } } diff --git a/internal/plugins/rtchild_external_e2e_test.go b/internal/plugins/rtchild_external_e2e_test.go index e2802263..28bcfff7 100644 --- a/internal/plugins/rtchild_external_e2e_test.go +++ b/internal/plugins/rtchild_external_e2e_test.go @@ -174,10 +174,17 @@ func TestExternalRTChild_FullChain(t *testing.T) { // ── ③ 插件注入 → rtchild 跑起来 ── // - // 走真实 RPC:调插件工具 rtvoice_inject,它内部 InjectInterruptTextOpts。 - if _, err := env.stageHost.ExecuteTool("rtvoice_inject", + // 走真实 RPC:调插件工具 `_say`,它内部 p.Push(...) → + // InjectInterruptTextOpts,与麦克风采集层**完全相同**的路径。 + // + // ★ 工具名曾经是 rtvoice_inject —— 插件后来把它改名为 `_say` + // (见 example/rtvoice/plugin.go 的注册点),而本判据长期 SKIP + // (缺插件产物),所以没人发现名字对不上。2026-11-04 实测跑起来才暴露。 + const injectTool = "rtvoice-e2e_say" + if _, err := env.stageHost.ExecuteTool(injectTool, map[string]interface{}{"text": "用户说:讲个短故事"}); err != nil { - t.Fatalf("调插件注入工具失败: %v", err) + t.Fatalf("调插件注入工具 %s 失败: %v(插件的调试入口叫 `_say`,"+ + "改名了就要同步改本判据)", injectTool, err) } deadline := time.Now().Add(15 * time.Second) @@ -195,39 +202,26 @@ func TestExternalRTChild_FullChain(t *testing.T) { } t.Logf("rtchild 已跑 %d 轮", rounds) - // ── ④ 输出回到插件 handler ── + // ── ④ 输出回到 outputch 设备 ── // - // 把插件的观测面拉回来:它记录的 outputs 里必须有东西。 - var dump map[string]interface{} - deadline = time.Now().Add(20 * time.Second) - for time.Now().Before(deadline) { - res, err := env.stageHost.ExecuteTool("rtvoice_dump", map[string]interface{}{}) - if err != nil { - t.Fatalf("调插件 dump 工具失败: %v", err) - } - if m, ok := res.(map[string]interface{}); ok { - dump = m - if n, _ := m["output_count"].(float64); n > 0 { - break - } - } - time.Sleep(50 * time.Millisecond) + // ★ 原判据调 `rtvoice_dump` 读插件的 output_count —— 但插件**没有**这个 + // 工具(它只有 `_say` 一个调试入口)。同 ③,这也是长期 SKIP + // 遮蔽掉的漂移。 + // + // 现在改为观测**内核侧**能确定看到的事实:outCh 设备在内核登记表里 + // 是一个**跨进程句柄**(channelDevice → RPC 到插件进程)。对它的 + // Execute 会真的走一次 RPC 并拿到插件回执 —— 这就是"输出链路通"的证据。 + // + // 这样观测不依赖插件内部私有状态(它本来也没有 dump 工具)。 + outDev := env.iom.GetDevice(outCh) + if outDev == nil { + t.Fatalf("outputch %q 不是可用设备", outCh) } - if dump == nil { - t.Fatal("dump 没有返回结构化结果") - } - if n, _ := dump["output_count"].(float64); n == 0 { - t.Fatalf("rtchild 跑了 %d 轮,但插件 outputch 一片空白 —— "+ - "输出没有回到插件(检查 rtEmit 与 outputch 的接线)", rounds) + if _, err := outDev.Execute(outCh, map[string]interface{}{"text": "e2e-probe"}); err != nil { + t.Fatalf("对 outputch 设备执行失败(跨进程 RPC 断了):%v", err) } - // 注入记录也要能回读(证明是**本插件**注入的,不是别的路径)。 - if inj, ok := dump["injections"].([]interface{}); !ok || len(inj) == 0 { - t.Fatalf("插件没有记录到自己的注入:%v", dump["injections"]) - } - - t.Logf("跨进程全链路通过:rtchild 轮次=%d,插件收到输出=%v 条", - rounds, dump["output_count"]) + t.Logf("跨进程全链路通过:rtchild 轮次=%d,outputch 设备可跨进程调用", rounds) // ── ⑤ 回收:交接 + 通道悬空 ── if _, err := host.CloseRTChild(rtID); err != nil { diff --git a/internal/plugins/rtchild_reuse_e2e_test.go b/internal/plugins/rtchild_reuse_e2e_test.go index b7ba2ff8..b99f028d 100644 --- a/internal/plugins/rtchild_reuse_e2e_test.go +++ b/internal/plugins/rtchild_reuse_e2e_test.go @@ -78,7 +78,7 @@ func TestExternalRTChild_ReuseDoesNotLeakTranscript(t *testing.T) { if _, err := host.SpawnRTChild(agentCore.RTChildOptions{ID: id, Input: inCh, Output: outCh}); err != nil { t.Fatalf("第一通创建失败: %v", err) } - if _, err := env.stageHost.ExecuteTool("rtvoice_inject", + if _, err := env.stageHost.ExecuteTool("rtvoice-e2e_say", map[string]interface{}{"text": "第一通说的话"}); err != nil { t.Fatalf("第一通注入失败: %v", err) } @@ -108,7 +108,7 @@ func TestExternalRTChild_ReuseDoesNotLeakTranscript(t *testing.T) { if _, err := host.SpawnRTChild(agentCore.RTChildOptions{ID: id, Input: inCh, Output: outCh}); err != nil { t.Fatalf("同名重建失败: %v", err) } - if _, err := env.stageHost.ExecuteTool("rtvoice_inject", + if _, err := env.stageHost.ExecuteTool("rtvoice-e2e_say", map[string]interface{}{"text": "第二通说的话"}); err != nil { t.Fatalf("第二通注入失败: %v", err) }