Files
ARTEX/agent/tools_nil_store_test.go
T
Autumn-27 5963281582 fix(agent): 探索图工具在无任务上下文时拒绝而非 nil 崩溃
buildDomainReg 构造的 server 级 ToolSet 恒持 nil ExplorationStore(taskID=0),
tools 表可把其中任一工具绑给不在任务内运行的 agent(auto/pentest/reporter/
自定义/旁路提问)。node_detail 等探索图工具直接解引用 t.ts,被这类 agent 调用即
nil deref;panic 发生在 harness 自己的 goroutine 上,server 侧任何 recover 都
够不到,整进程退出,重启后调度器又拉起同一 agent 再崩,形成崩溃循环。

- 新增 readExpTool/writeExpTool + needExploration:t.ts==nil 时返回可读的工具
  错误(提示改用带 task_id 的跨任务读取工具),把 17 个探索图工具切过去
- anchorOwner 补判空
- AugmentTools 出口给每个工具套 guardPanic:handler panic 降级成工具错误并打
  stack,覆盖域工具/SDK/MCP/skill,进程不再陪葬
- 注明 buildDomainReg 的 nil-store 契约
2026-09-14 13:56:56 -04:00

69 lines
2.4 KiB
Go

package agent
import (
"context"
"encoding/json"
"strings"
"testing"
actool "github.com/Autumn-27/norma/tool"
)
// The server-level ToolSet behind buildDomainReg carries a nil ExplorationStore,
// and the tools table can bind any of its tools to any agent — including agents
// that never run inside a task. Every domain tool must therefore survive being
// called with nil stores: a nil deref here runs on the harness's own goroutine,
// out of reach of every recover() in the server, and kills the whole process.
func TestDomainToolsSurviveNilStores(t *testing.T) {
inputs := []string{
`{}`,
`{"id":379,"asset_id":1,"goal_id":1,"evidence_id":1,"intent_id":1,"node_id":1,"work_id":1,` +
`"summary":"x","reason":"x","name":"x","severity":"low","vulnclass":"x","text":"x","q":"x"}`,
}
for _, tool := range NewToolSet(nil, "").AllDomainTools() {
for _, in := range inputs {
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("%s panicked with nil stores on %s: %v", tool.Name(), in, r)
}
}()
if _, err := tool.Call(context.Background(), json.RawMessage(in), nil); err != nil {
t.Fatalf("%s returned a transport error: %v", tool.Name(), err)
}
}()
}
}
}
// node_detail is the one that took the process down; assert it now answers with a
// usable refusal rather than dying.
func TestExplorationToolRefusesWithoutTask(t *testing.T) {
ts := NewToolSet(nil, "")
res, err := ts.NodeDetailTool().Call(context.Background(), json.RawMessage(`{"id":379}`), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.IsError || !strings.Contains(res.Flatten(), "任务上下文") {
t.Fatalf("want an explanatory tool error, got IsError=%v %q", res.IsError, res.Flatten())
}
}
// A panicking tool must degrade to a tool error: the run continues and the process
// survives, instead of the supervisor restarting into the same crash on replay.
func TestGuardPanicConvertsPanicToToolError(t *testing.T) {
boom := actool.Build(actool.Spec{
Name: "boom",
Run: func(context.Context, json.RawMessage, *actool.ToolContext) (actool.Result, error) {
panic("nil map write")
},
})
res, err := guardPanic(boom).Call(context.Background(), json.RawMessage(`{}`), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.IsError || !strings.Contains(res.Flatten(), "nil map write") {
t.Fatalf("want the panic reported as a tool error, got IsError=%v %q", res.IsError, res.Flatten())
}
}