Files
HomeAgent/internal/plugins/remotedevice/outputch_test.go
JianFeeeee d17c18665c fix(remotedevice): 设备通道名改用 - 分隔并派生合规名(v1.3.0 部署后 agent 完全不应答的根因)
## 事故

v1.3.0 部署到生产后,**整个 agent 不应答**:任何对话都返回
`all 3 providers failed, last error: api error 403: model "claude-opus-5" is not allowed for this key`。
回滚到 1.2.2 立即恢复(部署前 403=0/成功对话=10,部署后 403=5/成功对话=0)。

## 根因(网关日志给出的原文)

```
tier 3 gozen/deepseek-v4.1-flash: api error 400: [invalid_request_error]
  Invalid 'tools[299].function.name': string does not match pattern '^[a-zA...
```

设备的每设备输出通道名叫 `device/<id>`,内核按 `output_send__<通道名>` 生成工具 ⇒
`output_send__device/<id>` 里的 `/` 违反上游函数名规范 `^[a-zA-Z0-9_-]{1,64}$`。
上游不是"拒掉这一个工具",而是**整条请求 400** ⇒ 网关 auto tier 全链条失败
(400/429/503 混在一起)⇒ 内核只能报"所有 provider 都失败"。
两台真实设备(waiter-fnnas / waiter-mainnas)一上线就登记了这种通道,于是必然触发。

## 修法(改插件,不改内核)

初版我在内核里加了"通道名净化 + 反向解析"层。用户否掉了这个方向,理由对:
**通道名是插件自己的声明,不合契约就该改插件**,不该让内核替插件擦屁股。
内核侧改动已全部回退(HEAD 干净)。

插件侧两处:
1. 分隔符 `device/<id>` → `device-<id>`(源码与来源标签统一,不留两套名字)。
2. 设备 id 是**外部输入**(设备自己声明),可能含空格/非 ASCII/超长 ⇒
   `deviceChannelName()` 把它派生为**合规且唯一**的通道名:
   保留 `[A-Za-z0-9_-]`、其它折成 `-`、主体截断到 32 字符(预算 64 = 13+7+32+7+…)、
   发生截断或撞名时追加 id 的 6 位短哈希。同一 id 恒定同名;真名仍用于路由与日志。

核心契约写进了插件注释与 SDK 文档(见 SDK 仓同批提交):名字若来自外部输入,
**在插件侧派生合规名**,内核不会替你净化。

## 验证

- 新增 `TestDeviceChannelNameIsLLMFunctionNameSafe`:恶意 id(空格/符号/非 ASCII/超长/
  会折成同名的两个 id)都必须派生出**合法且互不重复**的通道名与工具名。
  反向验证:把分隔符改回 `/` 即 FAIL。
- 生产两台设备派生结果:`device-waiter-fnnas`、`device-waiter-mainnas`
  ⇒工具名 `output_send__device-waiter-fnnas`(37 字符,合规)。
- 全量 `go test ./...` = 37 包 ok / 0 FAIL;`-race`(remotedevice + core)无 DATA RACE。
2026-09-13 13:07:24 +08:00

288 lines
10 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 remotedevice
// 设备输出通道outputch.go的测试
// - caps 映射词表
// - 上下线 → 通道登记/注销 + push 真能落到设备(走真 WS 帧)
// - 聚合通道 devicectl 的出站寻址
import (
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"sync"
"testing"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func TestDeviceOutputCapsMapping(t *testing.T) {
full := agentIO.CapText | agentIO.CapFile | agentIO.CapImage | agentIO.CapAudio | agentIO.CapStructured
cases := []struct {
name string
caps []string
kind string
want agentIO.OutputCapability
}{
{"未声明已知能力 ⇒ 全能力(旧设备兼容)", nil, "", full},
{"未知 caps ⇒ 全能力", []string{"whatever"}, "", full},
{"cmd ⇒ 历史全能力", []string{"cmd"}, "", full},
{"speaker ⇒ 文本+音频", []string{"speaker"}, "", agentIO.CapText | agentIO.CapAudio},
{"screen ⇒ 文本+图+文件", []string{"screen"}, "", agentIO.CapText | agentIO.CapImage | agentIO.CapFile},
{"clipboard ⇒ 文本+文件", []string{"clipboard"}, "", agentIO.CapText | agentIO.CapFile},
{"kind=computer 兜底(未声明 caps", nil, "computer", agentIO.CapText | agentIO.CapImage | agentIO.CapFile | agentIO.CapStructured},
{"kind=speaker 兜底", nil, "speaker", agentIO.CapText | agentIO.CapAudio},
}
for _, c := range cases {
if got := deviceOutputCaps(c.caps, c.kind); got != c.want {
t.Errorf("%s: deviceOutputCaps(%v,%q)=%s期望 %s", c.name, c.caps, c.kind, got, c.want)
}
}
}
// channelRecorder 记录通道注册/注销。
//
// 必须加锁:注册/注销发生在设备 WS 的处理 goroutine上下线回调
// 而测试在主线读 —— 裸 map/slice 会被 -race 抓住(第一版就是这么被抓住的)。
type channelRecorder struct {
mu sync.Mutex
registered map[string]int
unregistered []string
}
func (r *channelRecorder) caps(name string) (int, bool) {
r.mu.Lock()
defer r.mu.Unlock()
c, ok := r.registered[name]
return c, ok
}
func (r *channelRecorder) unregList() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.unregistered...)
}
// recordingSDK 造一个能记账的 PluginSDK通道注册/注销都落到 recorder 里。
func recordingSDK(t *testing.T, iom *agentIO.IOManager, rec *channelRecorder) *sdk.PluginSDK {
t.Helper()
return sdk.New("remotedevice", sdk.SDKConfig{
IOManager: iom,
RegOutput: func(name string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error {
rec.mu.Lock()
rec.registered[name] = caps
rec.mu.Unlock()
// 通道 handler 也要真的可调用 —— 记进 iom 才能从外面触发。
return iom.RegisterDevice(&recordingDevice{name: name, handler: handler})
},
RegOutputUnreg: func(name string) error {
rec.mu.Lock()
rec.unregistered = append(rec.unregistered, name)
rec.mu.Unlock()
iom.UnregisterDevice(name)
return nil
},
RegInput: func(name string, def sdk.ChannelDef) error {
iom.RegisterInputChannel(name, agentIO.ChannelDef(def))
return nil
},
})
}
// recordingDevice 把插件注册的输出通道在 io 层落地,便于用 Execute("output") 触发。
type recordingDevice struct {
name string
handler sdk.ToolHandler
}
func (d *recordingDevice) Name() string { return d.name }
func (d *recordingDevice) Type() agentIO.DeviceType { return agentIO.DeviceIO }
func (d *recordingDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
func (d *recordingDevice) Description() string { return "recording device" }
func (d *recordingDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *recordingDevice) Start() error { return nil }
func (d *recordingDevice) Stop() error { return nil }
func (d *recordingDevice) Tools() []agentIO.ToolDef { return nil }
func (d *recordingDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
return d.handler(args)
}
func TestDeviceChannelLifecycleAndPush(t *testing.T) {
reg := NewRegistry()
token := "tk"
reg.SetAcceptToken(func(p string) bool { return p == token })
iom := agentIO.NewIOManager()
rec := &channelRecorder{registered: map[string]int{}}
p := &Plugin{registry: reg}
p.sdk = recordingSDK(t, iom, rec)
p.wireDeviceChannels() // 走 Start 的同一条接线
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
// 设备上线caps=speaker ⇒ 通道能力应为 文本+音频)
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"spk-1","name":"音箱","kind":"speaker","caps":["speaker"]}}`))
cli.readHelloAckAndBind(t, token)
ch := p.deviceChannelName("spk-1")
deadline := time.Now().Add(3 * time.Second)
caps, ok := rec.caps(ch)
for !ok && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
caps, ok = rec.caps(ch)
}
if !ok {
t.Fatalf("设备上线后应注册输出通道 %s实际: %v", ch, rec.unregList())
}
if want := int(agentIO.CapText | agentIO.CapAudio); caps != want {
t.Fatalf("通道能力位应为 文本+音频(%d),实际 %d", want, caps)
}
// 入站 inputch 同名登记(父 agent 才能把"这台设备"划给驻留子)
if _, ok := iom.LookupInputChannel(ch); !ok {
t.Fatalf("设备上线后应同时登记同名 inputch %s", ch)
}
// 触发一次出站:走 io 层的 output 分发(与 output_send__<通道> 同一条路)
dev := iom.GetDevice(ch)
if dev == nil {
t.Fatalf("输出通道 %s 未在 io 层注册", ch)
}
if _, err := dev.Execute("output", map[string]interface{}{"payload": "你好,设备", "type": "text"}); err != nil {
t.Fatalf("向设备发送失败: %v", err)
}
// 设备侧应收到 op=push 的帧
got := make(chan map[string]interface{}, 1)
go func() {
_, payload, err := cli.readMsg()
if err != nil {
return
}
var m map[string]interface{}
if json.Unmarshal(payload, &m) == nil {
got <- m
}
}()
select {
case m := <-got:
if m["op"] != "push" {
t.Fatalf("设备应收到 op=push实际 %v", m)
}
if m["payload"] != "你好,设备" || m["type"] != "text" {
t.Fatalf("push 帧内容不符: %v", m)
}
case <-time.After(3 * time.Second):
t.Fatal("设备未收到 push 帧")
}
// 设备下线 ⇒ 注销通道(不留死通道)
cli.close()
deadline = time.Now().Add(3 * time.Second)
for len(rec.unregList()) == 0 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if got := rec.unregList(); len(got) == 0 || got[0] != ch {
t.Fatalf("设备下线后应注销 %s实际 %v", ch, got)
}
if iom.GetDevice(ch) != nil {
t.Fatalf("注销后 io 层不应还有 %s", ch)
}
}
func TestDevicectlAggregateOutputAddressing(t *testing.T) {
reg := NewRegistry()
token := "tk2"
reg.SetAcceptToken(func(p string) bool { return p == token })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
dev := &devicectlDevice{reg: reg}
// ① 没指定设备 ⇒ 报错要**可执行**(列出在线设备),而不是含糊失败
if _, err := dev.Execute("output", map[string]interface{}{"payload": "x", "type": "text"}); err == nil {
t.Fatal("无 device_id 时应报错")
}
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"pc-1","name":"PC","kind":"computer","caps":["cmd"]}}`))
cli.readHelloAckAndBind(t, token)
deadline := time.Now().Add(3 * time.Second)
for !reg.Online("pc-1") && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if !reg.Online("pc-1") {
t.Fatal("设备未上线")
}
// ② meta 是 JSON 且含 device_id ⇒ 投递到该设备
if _, err := dev.Execute("output", map[string]interface{}{
"payload": "hi", "type": "text", "meta": `{"device_id":"pc-1"}`,
}); err != nil {
t.Fatalf("按 meta.device_id 投递失败: %v", err)
}
m := make(chan map[string]interface{}, 1)
go func() {
_, payload, err := cli.readMsg()
if err != nil {
return
}
var got map[string]interface{}
if json.Unmarshal(payload, &got) == nil {
m <- got
}
}()
select {
case got := <-m:
if got["op"] != "push" {
t.Fatalf("应为 push 帧,实际 %v", got)
}
case <-time.After(3 * time.Second):
t.Fatal("聚合通道未投递到设备")
}
// ③ 设备在线但指定了不存在的设备 ⇒ 报错online 列表里有 pc-1
if _, err := dev.Execute("output", map[string]interface{}{
"payload": "hi", "type": "text", "device_id": "ghost",
}); err == nil {
t.Fatal("不存在的设备应报错")
}
}
// 通道名合规性:设备通道名会被内核拼进 LLM **函数名**output_send__<通道名>
// 而上游函数名规范是 ^[a-zA-Z0-9_-]{1,64}$ —— 违规会让**整条请求**被 400 拒绝
// 实测把生产打挂device/<id> 里的 `/` 触发 Invalid 'tools[299].function.name'
// 网关 auto tier 全链条失败,整个 agent 不说话了)。
//
// 通道名是**插件自己的声明**,所以这条判据钉在插件侧。
func TestDeviceChannelNameIsLLMFunctionNameSafe(t *testing.T) {
re := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
// 含**恶意/异常** id空格、符号、非 ASCII、超长、以及会折成同一个名字的两个 id
ids := []string{"waiter-fnnas", "1", "a b!c", "中文设备", strings.Repeat("x", 120), "a b", "a-b"}
p := &Plugin{}
seen := map[string]string{}
for _, id := range ids {
ch := p.deviceChannelName(id)
if prev, dup := seen[ch]; dup {
t.Errorf("不同设备 id%q 与 %q派生出同一个通道名 %q", prev, id, ch)
}
seen[ch] = id
if !re.MatchString(ch) {
t.Errorf("设备通道名 %q 违反上游函数名规范 %s", ch, re)
}
toolName := "output_send__" + ch
if !re.MatchString(toolName) {
t.Errorf("派生出的工具名 %q 违反上游函数名规范 %s", toolName, re)
}
}
}