mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
真机实跑实证(独立实例 /tmp/seqtest,未触碰生产):
seq_run 报「工具 knowledge_list 不存在或未注册」,
而**同一轮模型直接调 knowledge_list 是成功的**。
根因:`memory_*` / `knowledge_*` / `doc_*` / `person_*` 这 20+ 个是
**内核内置**工具,在 core.executeToolCallInner 里按**前缀分派**,
由 buildToolDefs 直接生成 schema,**从不进 StageHost / IOManager**
⇒ ToolAPI(只有插件工具 + IO 设备工具)既查不到也调不了。
后果:序列只能编排插件/设备工具,无法编排记忆/知识/文档/人物
——恰恰是最常用的能力。
方案 B 的实现:
· internal/sdk: 新增 BuiltinProvider(Defs/Exec)与 SetBuiltinProvider。
用**晚绑定注入**而非让 toolImpl 依赖 core,理由:ToolAPI 是**全局单例**
却需要 per-agent 数据(驻留子是轻量内核,memory 为 nil;内置工具可见性
由 `if a.memory != nil` 等门控)。sdk 不能依赖 core(方向反了)。
· internal/sdk/tool_impl.go: ToolDefByName / ExecuteTool 补查内置工具。
⚠️ ExecuteTool 只在「io 确实没有该工具」时才转内置;io 的**执行失败**
必须如实上抛 —— 否则会把「设备离线」误报成「工具不存在」,让调用方
按 missing 策略跳过(与 P3 修过的父 io 吞错误同一族陷阱)。
内置工具**默认不声明 ParallelSafe**(含 SQLite 写与召回)。
· internal/agent/core/builtin_toolapi.go: Agent 侧 provider。
★ Defs **复用 buildToolDefs 的同一批生成逻辑**(筛出不在
StageHost/IOManager 中的那些),保证"模型看得到什么"与"插件看得到什么"
门控完全一致 —— 避免两套语义。
Exec 复用 executeToolCall 完整路径(授权闸 + 异常处理)。
· cmd/homed/bootstrap.go: agent 构造后注入。
★ 不会让模型看到重复工具(已核实):模型侧走 buildToolDefs
(a.io / a.stageHost **直调**),ToolAPI 只经 PluginSDK.Tool() 暴露给插件
—— 两条不重叠的路径。
判据(builtin_toolapi_test.go,6 条):
· 内置工具能从 ToolAPI 查到
· ★ 查到 ≠ 调得通:必须真的能执行
· 门控语义保持:未接 memory/knowledge 时不得声称有那些工具
· ★ 接了 knowledge 时必须可见(这正是要补的缺口)
· ToolAPI 上"不存在"必须是类型化 not-found(供 seq 的 missing 策略用)
· 内置工具默认不声明并发安全
真机复验(同一隔离实例,新二进制):
序列 "smoke2" 执行完毕(1/1 组)— 工具 1 个
变量槽: summary = cangjie/central-repo/agreement/...
⇒ knowledge_list 成功执行并回填具名槽。上一次的「不存在或未注册」已消除。
已知局限(记入待定):ToolAPI 单例而内置工具面是 per-agent,
多 agent 下看到的是"最近一个注入者"的面。本次不解决。
162 lines
6.3 KiB
Go
162 lines
6.3 KiB
Go
package core
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
)
|
||
|
||
// toolAPIOf 造出与插件侧**完全同一个** ToolAPI 实现(PluginSDK.Tool()
|
||
// 内部就是 sdk.NewTool(stageHost, iom))。
|
||
// 刻意不另写一份判据实现——两处会漂移,而漂移本身就是漏洞。
|
||
func toolAPIOf(t *testing.T, a *Agent) sdk.ToolAPI {
|
||
// 注入当前 agent 的授权判据:与 bootstrap 装配时的做法一致。
|
||
// 不注入则 CanUse 对设备放行(那是"尚未接线"的状态,见 sdk 包注释)。
|
||
sdk.SetDeviceAuthQuery(func(deviceID string) bool {
|
||
return a.IsOutputAllowed("device/" + deviceID)
|
||
})
|
||
t.Cleanup(func() { sdk.SetDeviceAuthQuery(nil) })
|
||
// 方案 B:把本 agent 的内置工具面注入(真实路径里由 bootstrap/新建 agent 时做)
|
||
sdk.SetBuiltinProvider(builtinProvider{a: a})
|
||
t.Cleanup(func() { sdk.SetBuiltinProvider(nil) })
|
||
return sdk.NewTool(a.stageHost, a.io)
|
||
}
|
||
|
||
// 阶段 D4:设备授权闸下沉到 ToolAPI 路径。
|
||
//
|
||
// 问题:设备类工具的授权闸只存在于 `executeToolCallInner`
|
||
// (toolcall.go:151-152),即**「agent 收到模型 tool_call」这条路径**。
|
||
// 而 `ToolAPI.ExecuteTool` 是另一条独立的执行入口,**不经那道闸**。
|
||
//
|
||
// 实测范围(不止 seq):`cli` 插件的 /terminal 直接经 ToolAPI 调
|
||
// agentcli 的终端工具(cli/plugin.go:1038 的注释自陈"SDK 的 ToolAPI
|
||
// 已允许跨插件调用工具"),这条路同样不过闸。
|
||
// ⇒ 凡是走 ToolAPI 的调用都能绕过 AllowedOutputs,不只是序列。
|
||
|
||
// ① 收窄授权时,ToolAPI 路径必须**同样**被拦。
|
||
//
|
||
// 这是本阶段的核心断言:同一份 allowedOutputs,两条路径判定必须一致。
|
||
func TestToolAPIPathRespectsDeviceGrant(t *testing.T) {
|
||
a := newPreemptAgent(t, newPreemptProvider())
|
||
a.allowedOutputs = []string{"device/ok-1"}
|
||
registerFakeDevice(t, a, "devicectl", nil)
|
||
|
||
tc := agentAPI.ToolCall{
|
||
ID: "c1", Name: "device_ctl_cmdrun",
|
||
Arguments: map[string]interface{}{"device_id": "other-2", "command": "rm -rf /"},
|
||
}
|
||
|
||
// ① 内核路径(现状已有)
|
||
gotInner := a.executeToolCall(tc, "cli")
|
||
if !strings.Contains(gotInner, "未授权") {
|
||
t.Fatalf("内核路径应拒绝未授权设备,实际: %s", gotInner)
|
||
}
|
||
|
||
// ② ToolAPI 路径(此前无此判定 ⇒ 缺口)
|
||
if toolAPIOf(t, a).CanUse(tc.Name, tc.Arguments) {
|
||
t.Error("ToolAPI 路径对未授权设备返回了 true —— 授权可被绕过(缺口未堵)")
|
||
}
|
||
}
|
||
|
||
// ② 反向:已授权的设备必须放行,否则正常能力被误杀。
|
||
func TestToolAPIPathAllowsGrantedDevice(t *testing.T) {
|
||
a := newPreemptAgent(t, newPreemptProvider())
|
||
a.allowedOutputs = []string{"device/ok-1"}
|
||
registerFakeDevice(t, a, "devicectl", nil)
|
||
|
||
tc := agentAPI.ToolCall{
|
||
ID: "c1", Name: "device_ctl_cmdrun",
|
||
Arguments: map[string]interface{}{"device_id": "ok-1", "command": "ls"},
|
||
}
|
||
if !toolAPIOf(t, a).CanUse(tc.Name, tc.Arguments) {
|
||
t.Error("已授权设备被误拒 —— 授权闸过严会把正常能力杀掉")
|
||
}
|
||
}
|
||
|
||
// ③ 非设备工具不受该闸影响(否则会把所有工具都锁死)。
|
||
func TestToolAPIPathIgnoresNonDeviceTools(t *testing.T) {
|
||
a := newPreemptAgent(t, newPreemptProvider())
|
||
a.allowedOutputs = []string{"device/ok-1"} // 收窄到只给一台设备
|
||
|
||
for _, name := range []string{"cmd_run", "knowledge_search", "memory_recall", "output_list_channels"} {
|
||
if !toolAPIOf(t, a).CanUse(name, map[string]interface{}{}) {
|
||
t.Errorf("非设备工具 %q 被设备授权闸拦了 —— 闸的作用域过宽", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ④ 枚举类工具(无 device_id)不受闸——与内核现有测试
|
||
// TestDeviceToolAuth_EnumerationNotGated 保持同一语义。
|
||
func TestToolAPIPathAllowsEnumerationTools(t *testing.T) {
|
||
a := newPreemptAgent(t, newPreemptProvider())
|
||
a.allowedOutputs = []string{"device/ok-1"}
|
||
registerFakeDevice(t, a, "devicectl", nil)
|
||
|
||
if !toolAPIOf(t, a).CanUse("devicedetect", map[string]interface{}{}) {
|
||
t.Error("枚举类工具不应被设备授权闸拦")
|
||
}
|
||
}
|
||
|
||
// ⑤ 未配置白名单(根 agent 默认)= 完整授权。
|
||
func TestToolAPIPathFullGrantByDefault(t *testing.T) {
|
||
a := newPreemptAgent(t, newPreemptProvider())
|
||
registerFakeDevice(t, a, "devicectl", nil)
|
||
if !toolAPIOf(t, a).CanUse("device_ctl_cmdrun", map[string]interface{}{"device_id": "any-1"}) {
|
||
t.Error("未配置白名单时应为完整授权")
|
||
}
|
||
}
|
||
|
||
// ⑥ 两条路径的判定必须**一致** —— 这是本设计的核心不变式。
|
||
func TestCanUseAgreesWithInnerPath(t *testing.T) {
|
||
cases := []struct {
|
||
deviceID string
|
||
allowed []string
|
||
}{
|
||
{"ok-1", []string{"device/ok-1"}},
|
||
{"other-2", []string{"device/ok-1"}},
|
||
{"ok-1", nil}, // 完整授权
|
||
{"other-2", nil},
|
||
}
|
||
for _, c := range cases {
|
||
a := newPreemptAgent(t, newPreemptProvider())
|
||
a.allowedOutputs = c.allowed
|
||
registerFakeDevice(t, a, "devicectl", nil)
|
||
|
||
tc := agentAPI.ToolCall{
|
||
ID: "c1", Name: "device_ctl_cmdrun",
|
||
Arguments: map[string]interface{}{"device_id": c.deviceID, "command": "ls"},
|
||
}
|
||
innerOK := !strings.Contains(a.executeToolCall(tc, "cli"), "未授权")
|
||
apiOK := toolAPIOf(t, a).CanUse(tc.Name, tc.Arguments)
|
||
if innerOK != apiOK {
|
||
t.Errorf("device=%s allowed=%v:内核路径=%v 而 ToolAPI 路径=%v —— 两条路径判定不一致",
|
||
c.deviceID, c.allowed, innerOK, apiOK)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ⑦ 设备工具但 device_id 缺失:内核现状是 **fail-open**。
|
||
// 本判据把现状钉住,避免无意中改变既有行为(内核有测试
|
||
// TestDeviceToolAuth_* 依赖它);若将来要改成 fail-closed,
|
||
// 必须同时改内核与此处,并更新两边判据。
|
||
func TestCanUseMatchesInnerFailOpenOnMissingDeviceID(t *testing.T) {
|
||
a := newPreemptAgent(t, newPreemptProvider())
|
||
a.allowedOutputs = []string{"device/ok-1"}
|
||
registerFakeDevice(t, a, "devicectl", nil)
|
||
|
||
tc := agentAPI.ToolCall{
|
||
ID: "c1", Name: "device_ctl_cmdrun",
|
||
Arguments: map[string]interface{}{"command": "ls"}, // 无 device_id
|
||
}
|
||
innerOK := !strings.Contains(a.executeToolCall(tc, "cli"), "未授权")
|
||
apiOK := toolAPIOf(t, a).CanUse(tc.Name, tc.Arguments)
|
||
if innerOK != apiOK {
|
||
t.Errorf("缺 device_id 时两条路径不一致:内核=%v ToolAPI=%v", innerOK, apiOK)
|
||
}
|
||
if innerOK {
|
||
t.Log("现状:缺 device_id 时放行(fail-open)。已钉住,若要改须两边同时改。")
|
||
}
|
||
}
|