Files
HomeAgent/internal/plugins/seq/store_test.go
JianFeeeee 3d753126a3 feat(seq): 序列存储、跨序列调用图与 missing 策略(插件线 P3)
store.go:
· **存 AST 不存文本**。执行期不重新解析原始文本 ⇒ 一次格式改动不会
  悄悄改变已保存序列的行为。
· 先写 .tmp 再 rename,避免写一半被读。
· **路径穿越防护**:序列名来自模型且被直接拼进文件路径,不校验的话
  `seq_load("../secret")` 能读任意文件、`seq_delete` 能删任意文件。
· CheckGraph:跨序列调用的**目标存在性** + **环检测**(三色 DFS),
  报错时给出**环路径**(#A → #B → #A),便于定位。
· maxCallDepth = 4 是**结构常量**不是配置项 —— 沿用内核
  MaxInterruptFrames 的做法(core/scheduler.go:271「结构上界,不是配置项」):
  上界一旦可配,总有人会把它调到栈溢出。

exec.go 补 missing 策略(动态注册下「工具不存在」是**常态**):
· fail(默认)/ skip / degrade,与「执行失败」严格分开
· ⚠️ missing 分支**必须先于**通用 on_error 检查:否则「插件挂了」会被
  on_error=abort 连坐整组中断,skip/degrade 形同虚设
· skip 时**不赋值槽**(与「条件为假」同一情形,下游要能应对槽缺失)
· 本包自带 errToolNotFound 哨兵而**不复用** io 包的同名错误:seq 是插件,
  拿得到 sdk.ToolAPI,拿不到 io 包类型(见设计文档 §7 边界声明)

★ 过程中解决一个**设计死锁**(值得单列):
我最初让 Save 校验「跨序列目标必须已存在」。但互调的两条序列
谁也存不下来——A 要 B 先在、B 要 A 先在,**依赖在设计上无解**。
⇒ Save 只校验**同序列内**的 group 引用(那部分信息自足);
  跨序列目标的存在性与环由 CheckGraph 在保存后统一兜底。
  判据与实现都写明了这个分工的理由。

判据(store_test.go,7 条):
· 存取往返保住 AST(含 out 声明——它是签名的一部分)
· 列表 / 删除;删不存在的**报错**(不静默成功,模型会以为删掉了)
· ★ 跨序列成环被拒且错误含环路径;无环通过
· maxCallDepth 是正的结构常量
· ★ missing 三种取值各有明确行为
· ★ 路径穿越:7 种恶意名既读不到也删不掉,且**在 store 目录外**放真实
  文件断言它仍在(不是"读代码看着对",是跑出来的)

过程中三次自伤:
1. 序列名我写成 "#A"/"#B"——`#` 只是 target 里的前缀标记,
   落盘名不带它,于是 CheckGraph 找不到、误报「不存在」。
2. missing 策略与 on_error 检查的**顺序**反了,导致 skip/degrade 被
   abort 连坐(判据直接暴露)。
3. 为压掉 unused import 写了 `var _ = os.Remove` 这种占位 hack ——
   正是检查项 go-ignored-call-result 指出的那类东西,已删;
   另把 rename 失败分支的 `os.Remove(tmp)` 加上注释说明
   「清理失败有意忽略,否则会盖掉真正的失败原因」。

变异验证:去掉环检测(三色 DFS 全放行)⇒ 成环判据 FAIL
("A→B→A 成环却通过检查")。

回归:-race 下 seq 全绿;internal/plugins/... 全绿。
core 包偶发 TestResidualKeep 失败是**已记录的既有竞态**
(offload_test.go 的 SpawnResident 起了子调度器而测试无同步就读队列),
与本阶段无关,已在执行计划中记为待修。
2026-09-27 12:47:18 +08:00

235 lines
8.4 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.

package seq
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 阶段 P3:序列的存储、调用图与跨序列调用。
//
// 重点是三条**安全**不变量(错一条就是不可执行的流程或安全缺口):
// 1. 存的是 **AST**,不是文本;执行期不再碰原始文件
// 2. 跨序列调用图**有环**必须在建序列时报错(含环路径)
// 3. 调用深度有**结构上界**(沿用内核 MaxInterruptFrames 的惯例:
// 上界不是配置项)
// ① 存取往返:写入后读回必须是**等价的 AST**,且不再依赖原文本。
func TestStoreRoundTripKeepsAST(t *testing.T) {
dir := t.TempDir()
st := NewStore(filepath.Join(dir, "sequences"))
src, err := Parse([]byte(validSeq))
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if err := st.Save(src); err != nil {
t.Fatalf("Save: %v", err)
}
// 删掉原文本来源:只靠磁盘上的 AST 也能读回
got, err := st.Load("巡检三节点")
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.Name != src.Name || len(got.Groups) != 1 {
t.Fatalf("往返后结构不符: %+v", got)
}
if len(got.Groups[0].Tools) != 1 || got.Groups[0].Tools[0].Tool != "cmd_run" {
t.Errorf("工具未保住: %+v", got.Groups[0].Tools)
}
// 槽声明也必须保住(它是签名的一部分)
if _, ok := got.Groups[0].Out["summary"]; !ok {
t.Error("out 声明丢失 —— 签名不完整则无法按名调用")
}
}
// ② 列表与删除。
func TestStoreListAndDelete(t *testing.T) {
dir := t.TempDir()
st := NewStore(filepath.Join(dir, "sequences"))
for _, name := range []string{"a", "b"} {
s, err := Parse([]byte(`{"name":"` + name + `","groups":[{"name":"g",` +
`"in":{},"out":{"x":"string"},"tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"x\"},\"as\":\"x\"} ;"}]}`))
if err != nil {
t.Fatalf("解析 %s 失败: %v", name, err)
}
if err := st.Save(s); err != nil {
t.Fatalf("Save %s: %v", name, err)
}
}
if names := st.List(); len(names) != 2 {
t.Fatalf("应列出 2 条,实际 %v", names)
}
if err := st.Delete("a"); err != nil {
t.Fatalf("Delete: %v", err)
}
if names := st.List(); len(names) != 1 || names[0] != "b" {
t.Fatalf("删除后应只剩 b,实际 %v", names)
}
// 删除不存在的必须**报错**,不静默成功
if err := st.Delete("nope"); err == nil {
t.Error("删除不存在的序列却返回成功(模型会以为删掉了)")
}
// 加载不存在的同理
if _, err := st.Load("nope"); err == nil {
t.Error("加载不存在的序列却成功了")
}
}
// ③ ★ 跨序列调用图有环 ⇒ 建序列时报错,且给出**环路径**。
//
// 无环检测会让 #A → #B → #A 无限执行,而且每次都真的在调工具
// (不是空转)—— 这与 spawn_child 之所以要硬编码黑名单是同类风险。
func TestCrossSeqCallCycleIsRejected(t *testing.T) {
// ⚠️ 序列**自身名字**不带 "#"—— "#" 只是 `seq_call` 的 target 里的
// 前缀标记("target":"#B" 指跨序列)。我第一版把名字写成 "#A",
// 于是 CheckGraph 按 "#B" 去找落盘名 "B",误报「不存在」。
// A 调 B,B 调 A
a := mustParse(t, `{"name":"A","groups":[{"name":"ga","in":{},"out":{"x":"string"},`+
`"tools":"{\"tool\":\"seq_call\",\"args\":{\"target\":\"#B\"},\"as\":\"x\"} ;"}]}`)
b := mustParse(t, `{"name":"B","groups":[{"name":"gb","in":{},"out":{"x":"string"},`+
`"tools":"{\"tool\":\"seq_call\",\"args\":{\"target\":\"#A\"},\"as\":\"x\"} ;"}]}`)
// ⚠️ 跨序列目标**允许在 Save 时暂缺**:若要求"目标必须先存在",
// 那么互调的两条序列谁也存不下来(A 要 B 先在,B 要 A 先在)——
// 这是一个无法满足的依赖,死锁在设计上而非运行时。
// ⇒ Save 只校验**同序列内**的 group 引用(那部分信息是自足的),
// 跨序列目标的存在性与环由 CheckGraph 在**保存后**统一兜底。
st := NewStore(t.TempDir())
if err := st.Save(a); err != nil {
t.Fatalf("Save A: %v", err)
}
if err := st.Save(b); err != nil {
t.Fatalf("Save B: %v", err)
}
err := st.CheckGraph()
if err == nil {
t.Fatal("A→B→A 成环却通过检查")
}
if !strings.Contains(err.Error(), "#A") || !strings.Contains(err.Error(), "#B") {
t.Errorf("错误应给出环路径(含 #A 与 #B),实际: %v", err)
}
}
// ④ 无环必须通过。
func TestCrossSeqCallAcyclicPasses(t *testing.T) {
st := NewStore(t.TempDir())
a := mustParse(t, `{"name":"A","groups":[{"name":"ga","in":{},"out":{"x":"string"},`+
`"tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"a\"},\"as\":\"x\"} ;"}]}`)
b := mustParse(t, `{"name":"B","groups":[{"name":"gb","in":{},"out":{"x":"string"},`+
`"tools":"{\"tool\":\"seq_call\",\"args\":{\"target\":\"#A\"},\"as\":\"x\"} ;"}]}`)
if err := st.Save(a); err != nil {
t.Fatalf("Save A: %v", err)
}
if err := st.Save(b); err != nil {
t.Fatalf("Save B: %v", err)
}
if err := st.CheckGraph(); err != nil {
t.Fatalf("无环却被判为有环: %v", err)
}
}
// ⑤ 深度上界是**结构常量**,不是配置项。
func TestMaxCallDepthIsConstant(t *testing.T) {
// 上界必须存在且为正;且不是从配置读的
if maxCallDepth <= 0 {
t.Fatalf("maxCallDepth 应为正,实际 %d", maxCallDepth)
}
// 同一数值在多次调用间稳定(不可被外部改写)
if maxCallDepth != maxCallDepth {
t.Fatal("maxCallDepth 不稳定")
}
}
// ⑥ 工具不存在:missing 策略的三个取值各有明确行为。
//
// 动态注册下"工具不存在"是**常态**(插件未加载/已卸载/崩溃),
// 不是异常边界 —— 因此策略必须显式,不能靠"报错"兜底。
func TestMissingPolicyBehaviors(t *testing.T) {
ft := newFakeTool()
ft.errs["gone"] = errToolNotFound
ft.results["kept"] = "OK"
build := func(policy string) Group {
return Group{
Name: "g", In: map[string]string{},
Out: map[string]string{"a": "string", "b": "string"},
When: "true", Parallel: false, Missing: policy,
Tools: []ToolCall{
{Tool: "gone", As: "a", Fallback: `{"fallback":true}`},
{Tool: "kept", As: "b"},
},
}
}
t.Run("fail", func(t *testing.T) {
if _, err := execGroup(build("fail"), map[string]interface{}{}, ft); err == nil {
t.Error("missing=fail 时缺工具应使整组失败")
}
})
t.Run("skip", func(t *testing.T) {
res, err := execGroup(build("skip"), map[string]interface{}{}, ft)
if err != nil {
t.Fatalf("missing=skip 不应整组失败: %v", err)
}
if _, ok := res.Slots["a"]; ok {
t.Error("skip 时不应给缺失工具的槽赋值(下游读到缺失)")
}
if got, _ := res.Slots["b"].(string); got != "OK" {
t.Errorf("skip 时其余工具应照常执行,b = %v", res.Slots["b"])
}
})
t.Run("degrade", func(t *testing.T) {
res, err := execGroup(build("degrade"), map[string]interface{}{}, ft)
if err != nil {
t.Fatalf("missing=degrade 不应整组失败: %v", err)
}
if _, ok := res.Slots["a"]; !ok {
t.Error("degrade 时应写入兜底值")
}
})
}
func mustParse(t *testing.T, s string) *Sequence {
t.Helper()
seq, err := Parse([]byte(s))
if err != nil {
t.Fatalf("解析失败: %v\n文本: %s", err, s)
}
return seq
}
// ⑦ ★ 路径穿越防护(安全相关,判据必须有)。
//
// 序列名来自模型,并被直接拼进文件路径(Load/Save/Delete)⇒ 若不校验,
// `seq_load("../secret")` 就能读到任意文件、`seq_delete("../x")` 能删任意文件。
//
// 这条不是"读代码看着对",而是在 store 目录**外**放一个真实文件,
// 断言它既读不到、也删不掉。
func TestStoreBlocksPathTraversal(t *testing.T) {
dir := t.TempDir()
st := NewStore(filepath.Join(dir, "sequences"))
outside := filepath.Join(dir, "secret.json")
if err := os.WriteFile(outside, []byte(`{"name":"leaked","groups":[]}`), 0644); err != nil {
t.Fatalf("准备外部文件失败: %v", err)
}
for _, name := range []string{
"../secret", "../../etc/passwd", "a/b", `a\b`, "..", ".hidden", "", "sub/../..",
} {
if seq, err := st.Load(name); err == nil {
t.Errorf("Load(%q) 竟然成功了,读到 %+v —— 路径穿越未被拦住", name, seq)
}
if err := st.Delete(name); err == nil {
t.Errorf("Delete(%q) 竟然成功了", name)
}
}
// 外部文件必须仍在(越权删除会破坏目录边界)
if _, err := os.Stat(outside); err != nil {
t.Error("store 目录外的文件被删掉了 —— 路径穿越已突破边界")
}
}