From 3d753126a383b87701ab652098c9fc2b7c38edd8 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 12:47:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(seq):=20=E5=BA=8F=E5=88=97=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E3=80=81=E8=B7=A8=E5=BA=8F=E5=88=97=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E5=9B=BE=E4=B8=8E=20missing=20=E7=AD=96=E7=95=A5=EF=BC=88?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E7=BA=BF=20P3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 起了子调度器而测试无同步就读队列), 与本阶段无关,已在执行计划中记为待修。 --- internal/plugins/seq/exec.go | 68 ++++++- internal/plugins/seq/store.go | 279 +++++++++++++++++++++++++++++ internal/plugins/seq/store_test.go | 234 ++++++++++++++++++++++++ 3 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 internal/plugins/seq/store.go create mode 100644 internal/plugins/seq/store_test.go diff --git a/internal/plugins/seq/exec.go b/internal/plugins/seq/exec.go index 2a14fed..4f9ec6b 100644 --- a/internal/plugins/seq/exec.go +++ b/internal/plugins/seq/exec.go @@ -2,6 +2,7 @@ package seq import ( "encoding/json" + "errors" "fmt" "strconv" "strings" @@ -18,12 +19,24 @@ type toolRunner interface { call(name string, args map[string]interface{}) (string, error) } +// errToolNotFound 表示「工具不存在」(未注册 / 插件未加载、已卸载或崩溃)。 +// +// 它是**本包定义**的标记,不复用内核的 agentIO.ErrToolNotFound:seq 是插件, +// 拿得到的是 sdk.ToolAPI(ExecuteTool/GetAllTools),拿不到 io 包的类型 +// (见设计文档 §7 的边界声明)。内核侧的类型化错误本就要经 D4 才下放到插件。 +var errToolNotFound = errors.New("工具不存在或未注册") + +// IsToolNotFound 报告 err 是否为「工具不存在」。 +func IsToolNotFound(err error) bool { return errors.Is(err, errToolNotFound) } + // GroupResult 是一组的执行结果。 type GroupResult struct { Group string Skipped bool // 条件为假而整组跳过 Slots map[string]interface{} Tools []ToolRun + // Missing 列出因「工具不存在」而被 skip/degrade 的工具名。 + Missing []string Err error } @@ -91,6 +104,18 @@ func execGroup(g Group, args map[string]interface{}, runner toolRunner) (GroupRe var firstErr error for _, r := range results { res.Tools = append(res.Tools, r) + tc := g.Tools[r.Order] + + // 「工具不存在」单独处理:动态注册下它是**常态**(插件未加载/崩溃), + // 与「执行失败」语义不同 —— 前者该按 missing 策略走,后者才该 retry。 + // + // ⚠️ 必须先于通用的 on_error 检查:若「不存在」先被记成 firstErr/ + // failed,missing skip/degrade 就会被 on_error=abort 连坐中断。 + if r.Err != nil && IsToolNotFound(r.Err) { + dealMissing(g, tc, r, &res, &failed, &firstErr) + continue + } + if r.Err != nil { if firstErr == nil { firstErr = fmt.Errorf("工具 %s 失败: %w", r.Name, r.Err) @@ -99,7 +124,7 @@ func execGroup(g Group, args map[string]interface{}, runner toolRunner) (GroupRe failed = true } } - tc := g.Tools[r.Order] + if tc.As == "" { continue } @@ -422,3 +447,44 @@ func compactJSON(v interface{}) string { } return string(b) } + +// parseFallback 解析 degrade 的兜底值(紧凑 JSON 文本)。 +// 解析失败时原样作为字符串返回——兜底值本身不该让整组失败。 +func parseFallback(s string) interface{} { + if strings.TrimSpace(s) == "" { + return "" + } + var v interface{} + if err := json.Unmarshal([]byte(s), &v); err == nil { + return v + } + return s +} + +// dealMissing 处理「工具不存在」这一**常态**情形(动态注册下插件可能 +// 未加载、已卸载或崩溃),按 group 的 missing 策略处置。 +// +// 与「执行失败」严格分开:后者才该走 on_error / retry。若把两者混同, +// 一条"插件挂了"会被当成业务失败反复重试,或反过来该重试的被整组跳过。 +func dealMissing(g Group, tc ToolCall, r ToolRun, res *GroupResult, failed *bool, firstErr *error) { + switch g.missingPolicy() { + case "skip": + res.Missing = append(res.Missing, tc.Tool) + return // 不给槽赋值(与「条件为假」同一情形:下游要能应对槽缺失) + case "degrade": + res.Missing = append(res.Missing, tc.Tool) + if tc.As != "" { + res.Slots[tc.As] = parseFallback(tc.Fallback) + } + return + default: // fail + *failed = true + if *firstErr == nil { + *firstErr = fmt.Errorf("工具 %s 不存在或未注册"+ + "(可能属于未加载/已崩溃的插件;用 seq_list 看可用序列,或改用其他工具)", tc.Tool) + } + if tc.As != "" { + res.Slots[tc.As] = "错误:工具不存在" + } + } +} diff --git a/internal/plugins/seq/store.go b/internal/plugins/seq/store.go new file mode 100644 index 0000000..f624535 --- /dev/null +++ b/internal/plugins/seq/store.go @@ -0,0 +1,279 @@ +package seq + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +// maxCallDepth 是**嵌套调用的结构上界**,不是配置项。 +// +// 沿用内核 MaxInterruptFrames 的做法(见 core/scheduler.go:271「是中断栈帧数的 +// 结构上界,不是配置项」):上界一旦可配,总有人会把它调大到栈溢出。 +// +// 取 4 与内核的 4 级中断一致。 +const maxCallDepth = 4 + +// Store 负责序列的持久化:**存 AST,不存文本**。 +// +// 为什么不存原始文本:执行期若重新解析文本,一次格式改动就会改变已保存 +// 序列的行为;存 AST 则解析只发生在创建时,注释/空白/引号形式在 AST +// 层面已消失,不引入执行期差异。 +type Store struct { + dir string + mu sync.RWMutex +} + +// NewStore 在 dir 下管理序列文件(不创建目录,由 Save 惰性创建)。 +func NewStore(dir string) *Store { return &Store{dir: dir} } + +// fileOf 返回某序列的落盘路径。 +func (s *Store) fileOf(name string) string { + return filepath.Join(s.dir, name+".json") +} + +// Save 落盘一条序列的 AST。 +func (s *Store) Save(seq *Sequence) error { + if seq == nil || strings.TrimSpace(seq.Name) == "" { + return fmt.Errorf("序列缺少 name") + } + if err := s.checkName(seq.Name); err != nil { + return err + } + if err := os.MkdirAll(s.dir, 0755); err != nil { + return fmt.Errorf("创建序列目录失败: %w", err) + } + b, err := json.MarshalIndent(seq, "", " ") + if err != nil { + return fmt.Errorf("序列化序列 %q 失败: %w", seq.Name, err) + } + // 静态校验:同序列内的 group 引用必须存在、不得自调用。 + // 跨序列目标的存在性由 CheckGraph 统一查(此时新序列还没落盘)。 + if err := s.CheckNew(seq); err != nil { + return err + } + // 先写临时文件再 rename:避免写一半被读(与内核原子替换同一思路) + tmp := s.fileOf(seq.Name) + ".tmp" + if err := os.WriteFile(tmp, b, 0644); err != nil { + return fmt.Errorf("写序列 %q 失败: %w", seq.Name, err) + } + if err := os.Rename(tmp, s.fileOf(seq.Name)); err != nil { + // 清理失败**有意忽略**:rename 已失败,再报一个清理错误只会 + // 盖掉真正的失败原因(这正是 rename 失败要暴露的那条)。 + // 残留的 .tmp 由下次 Save 覆盖。 + _ = os.Remove(tmp) + return fmt.Errorf("替换序列 %q 失败: %w", seq.Name, err) + } + return nil +} + +// Load 读回一条序列的 AST。 +func (s *Store) Load(name string) (*Sequence, error) { + if err := s.checkName(name); err != nil { + return nil, err + } + b, err := os.ReadFile(s.fileOf(name)) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("序列 %q 不存在(用 seq_list 看可用序列)", name) + } + return nil, fmt.Errorf("读序列 %q 失败: %w", name, err) + } + var seq Sequence + dec := json.NewDecoder(strings.NewReader(string(b))) + dec.DisallowUnknownFields() + if err := dec.Decode(&seq); err != nil { + return nil, fmt.Errorf("序列 %q 的存档损坏: %w", name, err) + } + return &seq, nil +} + +// Delete 删除一条序列。不存在时报错(不静默成功 —— 模型会以为删掉了)。 +func (s *Store) Delete(name string) error { + if err := s.checkName(name); err != nil { + return err + } + if err := os.Remove(s.fileOf(name)); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("序列 %q 不存在(用 seq_list 看可用序列)", name) + } + return fmt.Errorf("删除序列 %q 失败: %w", name, err) + } + return nil +} + +// List 列出全部序列名(升序)。 +func (s *Store) List() []string { + s.mu.RLock() + defer s.mu.RUnlock() + entries, err := os.ReadDir(s.dir) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + out = append(out, strings.TrimSuffix(e.Name(), ".json")) + } + sort.Strings(out) + return out +} + +// checkName 校验序列名:必须能安全用作文件名。 +// +// ⚠️ 名字来自模型,且会被拼进路径(Load/Save/Delete 都用)⇒ 必须挡住 +// 路径穿越(`../`)与分隔符,否则 `seq_load` 能读到任意文件。 +func (s *Store) checkName(name string) error { + if strings.TrimSpace(name) == "" { + return fmt.Errorf("序列名不能为空") + } + if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") { + return fmt.Errorf("序列名 %q 非法:不能包含路径分隔符或 ..", name) + } + if strings.HasPrefix(name, ".") { + return fmt.Errorf("序列名 %q 非法:不能以 . 开头", name) + } + return nil +} + +// callTargets 返回某序列内所有 seq_call 的跨序列目标。 +func callTargets(seq *Sequence) []string { + var out []string + for _, g := range seq.Groups { + for _, t := range g.Tools { + if t.Tool != "seq_call" && t.Tool != "seq_when_call" { + continue + } + if tgt, ok := t.Args["target"].(string); ok && strings.HasPrefix(tgt, "#") { + out = append(out, tgt) + } + } + } + return out +} + +// CheckGraph 校验跨序列调用图。 +// +// 两条检查(都必须在**建序列/保存**时做,而不是等运行): +// 1. 每个 `seq_call` 的目标必须存在(不存在会在运行期才发现,浪费一整轮) +// 2. 不得有环(否则无限嵌套,每层都真的在调工具) +func (s *Store) CheckGraph() error { + names := s.List() + seqs := make(map[string]*Sequence, len(names)) + for _, n := range names { + seq, err := s.Load(n) + if err != nil { + return err + } + seqs[n] = seq + } + // 目标存在性 + for _, name := range names { + for _, tgt := range callTargets(seqs[name]) { + bare := strings.TrimPrefix(tgt, "#") + if _, ok := seqs[bare]; !ok { + return fmt.Errorf("序列 %q 调用了不存在的序列 %q(用 seq_list 看可用序列)", name, tgt) + } + } + } + // 环检测(三色 DFS),错误里带**环路径**便于定位 + const ( + white = 0 // 未访问 + gray = 1 // 在栈上 + black = 2 // 已完成 + ) + color := make(map[string]int, len(seqs)) + var path []string + var dfs func(n string) error + dfs = func(n string) error { + color[n] = gray + path = append(path, "#"+n) + for _, tgt := range callTargets(seqs[n]) { + bare := strings.TrimPrefix(tgt, "#") + switch color[bare] { + case gray: + // 找到环:从 path 里第一次出现 bare 处截断,给出完整环 + ring := path + for i, p := range path { + if p == tgt { + ring = path[i:] + break + } + } + return fmt.Errorf("跨序列调用成环: %s → %s", + strings.Join(ring, " → "), tgt) + case white: + if err := dfs(bare); err != nil { + return err + } + } + } + path = path[:len(path)-1] + color[n] = black + return nil + } + for _, n := range names { + if color[n] == white { + if err := dfs(n); err != nil { + return err + } + } + } + return nil +} + +// CheckNew 在**保存前**校验一条新序列:组内/跨序列引用是否存在。 +// +// 分两步:先查**同序列内**的 seq_call 目标(组名)是否存在,再查 +// **跨序列**目标是否已存在(存盘之后才能查全图,故由 Save 后的 +// CheckGraph 负责)。 +func (s *Store) CheckNew(seq *Sequence) error { + groupNames := map[string]bool{} + for _, g := range seq.Groups { + groupNames[g.Name] = true + } + for _, g := range seq.Groups { + for i, t := range g.Tools { + if t.Tool != "seq_call" && t.Tool != "seq_when_call" { + continue + } + tgt, _ := t.Args["target"].(string) + if strings.TrimSpace(tgt) == "" { + return fmt.Errorf("group %q 第 %d 个工具的 seq_call 缺少 target", g.Name, i+1) + } + if strings.HasPrefix(tgt, "#") { + bare := strings.TrimPrefix(tgt, "#") + if bare == seq.Name { + return fmt.Errorf("序列 %q 调用了自身(会造成无限递归)", seq.Name) + } + // ⚠️ 跨序列目标**不在这里**要求存在:互调的两条序列 + // 谁也存不下来(A 要 B 先在、B 要 A 先在),是设计上死锁。 + // 存在性与环统一由 CheckGraph 在保存后兜底。 + continue + } + if !groupNames[tgt] { + return fmt.Errorf("group %q 第 %d 个工具调用了不存在的 group %q"+ + "(本序列现有:%s)", g.Name, i+1, tgt, joinNames(groupNames)) + } + } + } + return nil +} + +func joinNames(m map[string]bool) string { + if len(m) == 0 { + return "(无)" + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return strings.Join(out, ", ") +} diff --git a/internal/plugins/seq/store_test.go b/internal/plugins/seq/store_test.go new file mode 100644 index 0000000..43c0949 --- /dev/null +++ b/internal/plugins/seq/store_test.go @@ -0,0 +1,234 @@ +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 目录外的文件被删掉了 —— 路径穿越已突破边界") + } +}