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 后直接编译失败。
改为逐字段验证。
This commit is contained in:
JianFeeeee
2026-10-18 17:40:07 +08:00
parent 40de84facc
commit 70f7bea4b9
5 changed files with 218 additions and 0 deletions

View File

@ -401,8 +401,62 @@ func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) {
if opts.Priority != "" {
payload["priority"] = opts.Priority
}
// ★ 合并插件的 payload 扩展,但**不允许覆盖内核保留键**。
//
// 为什么必须拦:这些键是内核语义(`content` 是正文、`context_policy`
// 决定裁剪、`priority` 决定抢占级别…)。允许插件覆盖意味着
// 一个插件能伪造“这条注入不记记忆”或“这条要立即打断”——
// 那是权限提升,不是便利。
//
// 拦的方式是**跳过**而不是拒绝整次注入:插件可能只想带一个业务字段,
// 因顺手多写了一个保留键就让整条注入失败,代价太大。静默跳过 + 不报错
// 会不会让调试变难?会在内核日志里看到,因为这是“插件想改内核语义”,
// 属于应当被看见的事件(见下方 log)。
for k, v := range opts.Payload {
if IsReservedPayloadKey(k) {
log.Printf("[io] 注入 payload 试图覆盖内核保留键 %q(已忽略)——"+
"该键由内核语义决定,插件不可改写", k)
continue
}
payload[k] = v
}
}
// reservedPayloadKeys 是**内核语义键**,插件不得通过 InjectOptions.Payload 覆盖。
//
// 来源:实测 grep 全部 `Payload["..."]` 的读取点(internal/agent、internal/plugins),
// 再加注入路径自身写入的键。新增内核语义键时必须同步这个集合,
// 否则新键就成了新的提权面。
var reservedPayloadKeys = map[string]bool{
// —— 注入路径自身写入的
"no_memory": true,
"context_policy": true,
"recall_policy": true,
"scene_policy": true,
"cleaner_name": true,
"priority": true,
"type": true,
"content": true,
// —— 内核读取的语义键(见 grep 结果)
"interrupt": true,
"interrupt_source": true,
"interrupt_channel": true,
"stop": true,
"command": true,
"scene": true,
"source": true,
"channel": true,
"media_blocks": true,
"media_type": true,
}
// IsReservedPayloadKey 报告 k 是否为内核语义键。
//
// 导出给 core 用:emitResponse 把 payload 透传到 stageCtx.Extra 时,
// 也要用**同一份**保留字清单——否则两处清单会漂移,
// 一边拦一边放,等于没拦。
func IsReservedPayloadKey(k string) bool { return reservedPayloadKeys[k] }
func (m *IOManager) InjectInputOpts(source, eventType string, payload map[string]interface{}, opts InjectOptions) {
applyInjectOpts(payload, opts)
m.InjectInput(source, eventType, payload)