Files
HomeAgent/internal/agent/io/inject_payload_test.go
JianFeeeee 70f7bea4b9 feat(io): InjectOptions.Payload 业务字段扩展,透传到本轮 stage
## 为什么需要

QQ 的「对方正在输入中」必须只在**自己引起的** before_output 里发,且要
知道发给谁。但两条路都断了:

	on_input 的 stageCtx  是局部变量(stageCtxFromInput),runStage 完就丢
	before_output 的 stageCtx 是内核**新建**的(emitResponse)
	⇒ 两者**不共享 Extra**

## 第一版是错的(用户指出)

我先加了 `InjectOptions.Meta` —— 那是把同一件事做了两遍。用户直接问:
「为什么又额外定义了 injectMeta,而不是包含在 inputch 的请求体中?」

查证后的真实边界:`InputEvent.Payload` **本就是通道送入内核的请求体**,
身份信息本该在那里。但 `IOInjector` 接口全是
`(source, channel, text, opts)`,插件无法给 payload 加字段 —— 所以
payload 需要一个**扩展口**,而不是一个平行的新概念。

改名为 `Payload` 还有一个实际好处:所有注入路径(HTTP/CLI/ACP)共用
同一套机制,不是注入专属。

## 内核怎么处理

	注入点 opts.Payload
	  → 与真 payload 合并(**内核保留键不可覆盖**)
	  → emitResponse 把非保留键原样透传到 stageCtx.Extra

保留键清单 `reservedPayloadKeys` 覆盖注入路径自身写入的键
(no_memory/context_policy/recall_policy/scene_policy/cleaner_name/
priority/type/content)与内核读取的语义键。允许覆盖意味着插件能伪造
"这条不记记忆"或"这条要立即打断"——那是权限提升,不是便利。

拦截方式是**跳过**而非拒绝整次注入:插件可能只想带一个业务字段,
因顺手多写一个保留键就让整条注入失败,代价太大。

## 判据(4 条,变异 2/2 咬)

 · 插件字段原样进 payload
 · 保留键不可覆盖(逐个验证 11 个键)
 · 空 Payload 不改变旧行为
 · 保留字清单必须覆盖注入路径自己写入的键(否则新键=新提权面)

## 附带修掉的编译破坏

`internal/plugin/lua_surface_test.go` 用 `z != sdk.InjectOptions{}` 比较
零值 —— 结构体含 map 就**不可比较**,加了 Payload 后直接编译失败。
改为逐字段验证。
2026-10-18 17:40:07 +08:00

92 lines
3.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 io
// 判据:InjectOptions.Payload 是插件的业务字段扩展口,内核只透传、不解释。
//
// ## 为什么要这条通道(2026-10-18)
//
// QQ 的「对方正在输入中」只在**自己引起的** before_output 里发,且要知道
// 发给谁。但 `on_input` 的 stageCtx 是局部变量,`before_output` 的是内核
// 新建的(emitResponse)—— 两者不共享 Extra。
//
// `InputEvent.Payload` 本就是通道送入内核的请求体,身份本该在那里,
// 但 `IOInjector` 全是 `(source, channel, text, opts)`,插件无法给 payload
// 加字段。所以给 InjectOptions 一个扩展口。
//
// (第一版我加了 InjectOptions.Meta —— 那等于把同一件事做两遍,
// 且只服务注入路径。改为 Payload 后所有注入路径共用。)
//
// ## 判据要点
//
// · 插件字段原样进 payload
// · **内核保留键不可被覆盖**(否则插件能伪造 context_policy/priority = 提权)
// · 空 Payload 不改变旧行为
import "testing"
func TestInjectPayloadMerged(t *testing.T) {
payload := map[string]interface{}{"type": "text", "content": "hi"}
applyInjectOpts(payload, InjectOptions{
Payload: map[string]interface{}{"qq_uid": float64(1456128421)},
})
if got, ok := payload["qq_uid"].(float64); !ok || got != 1456128421 {
t.Fatalf("★ 插件 payload 字段没进事件 payload:%v", payload["qq_uid"])
}
// 原有字段不能被破坏
if payload["content"] != "hi" || payload["type"] != "text" {
t.Fatalf("★ 合并插件字段时破坏了内核字段:%v", payload)
}
}
// ★★★ 保留键不可覆盖。
//
// 允许覆盖意味着一个插件能伪造"这条注入不记记忆"(no_memory)、
// "这条要立即打断"(priority)、"这条正文是别的"(content)——
// 那是权限提升,不是便利。
func TestInjectPayloadCannotOverrideReserved(t *testing.T) {
reserved := []string{
"no_memory", "context_policy", "recall_policy", "scene_policy",
"cleaner_name", "priority", "type", "content",
"interrupt", "stop", "command",
}
for _, k := range reserved {
payload := map[string]interface{}{"type": "text", "content": "orig"}
applyInjectOpts(payload, InjectOptions{
NoMemory: true,
Payload: map[string]interface{}{k: "HACKED"},
})
if k == "no_memory" {
// no_memory 是 bool,注入 true;插件塞字符串必须被忽略
if v, ok := payload[k].(bool); !ok || !v {
t.Fatalf("★ 保留键 %q 被插件改写成 %v", k, payload[k])
}
continue
}
if payload[k] == "HACKED" {
t.Fatalf("★ 插件改写了内核保留键 %q —— 这是权限提升(能伪造裁剪/优先级/正文)", k)
}
}
}
// 空 Payload 不改变旧行为(零值必须与历史 payload 一致)。
func TestInjectPayloadEmptyNoChange(t *testing.T) {
payload := map[string]interface{}{"type": "text", "content": "hi"}
before := len(payload)
applyInjectOpts(payload, InjectOptions{})
if len(payload) != before {
t.Fatalf("★ 空 Payload 也改了 payload:%v", payload)
}
}
// 保留字清单必须覆盖注入路径自己写入的键 —— 否则新键就是新的提权面。
func TestReservedKeysCoverInjectionOwnKeys(t *testing.T) {
for _, k := range []string{
"no_memory", "context_policy", "recall_policy", "scene_policy",
"cleaner_name", "priority",
} {
if !IsReservedPayloadKey(k) {
t.Fatalf("★ 注入路径自己写入的键 %q 不在保留字清单里 —— "+
"插件可以覆盖它", k)
}
}
}