diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index b77eee6a..2fbca8e8 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -389,6 +389,36 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string, turnUsage Phase: sdk.StageBeforeOutput, Extra: map[string]interface{}{"output_channel": ch}, } + // ★ 把输入事件的 payload **原样透传**给 before_output / after_output。 + // + // ## 为什么必须在这里透传(2026-10-18) + // + // 本函数**新建**了 stageCtx,而 `on_input` 的 stageCtx 是局部变量 + // (stageCtxFromInput)—— 两者**不共享 Extra**。 + // + // 后果:插件在 before_output 里只能读全局状态猜“这轮是谁引起的”, + // 并发时会串号(QQ 的「对方正在输入中」就踩到这个:A 的指示打到 B 身上)。 + // + // ## 为什么是“透传 payload”而不是新造一个字段 + // + // `InputEvent.Payload` 本就是通道送入内核的请求体,身份信息本就该在那里。 + // 我第一版加了 `InjectOptions.Meta` 专用的透传键 —— 那是把同一件事做了两遍, + // 而且只服务注入路径,HTTP/CLI/ACP 无法共用。 + // + // 现在:插件用 `InjectOptions.Payload` 声明业务字段(内核不解释), + // 这里把它连同 `input_source` 一起带到 stageCtx.Extra。 + // 任何注入路径都能用同一套机制。 + if evt != nil { + stageCtx.Extra["input_source"] = evt.Source + for k, v := range evt.Payload { + // 内核语义键不往 Extra 里塞:它们的归宿是内核自己的处理逻辑, + // 不是插件可见的上下文(塞了反而干扰插件读 Extra)。 + if agentIO.IsReservedPayloadKey(k) { + continue + } + stageCtx.Extra[k] = v + } + } a.runStage(sdk.StageBeforeOutput, stageCtx) response = stageCtx.FinalText diff --git a/internal/agent/core/offload.go b/internal/agent/core/offload.go index 0997880b..c977e591 100644 --- a/internal/agent/core/offload.go +++ b/internal/agent/core/offload.go @@ -339,6 +339,19 @@ func offloadTaskPrompt() string { - **必须把回复发到用户原本的通道**。面向 qq、wechat 等异步通道时, 纯文本返回会被丢弃 —— 必须显式调用 output_send__{通道名},否则用户收不到, 而你会以为已经回过了。 + +- ★ **只能发给“这条消息的发送者本人”,必须用私聊。绝对不要发到群聊。** + + 这是硬限制(2026-10-18 现场事故):一个临时助手把本该私聊给用户的 + 进度播报发到了群里。在群里发言是**公开可见**的,而你只是一个临时分诊 + 助手 —— 你看不到群里的上下文、分不清每句话是回给谁的。 + + 具体做法:调 output_send__{通道} 时,meta 里用 user_id(那个人的号), + **不要用 group_id**。即使消息来自群聊,你的回复也走私聊。 + + (如果你认为这件事必须在群里回,那就走【报忙碌】:让 主 agent 自己发。 + 它有完整上下文和用户身份判定。) + - 不要向用户暴露"我是被临时拉起的助手"这类内部细节,用主 agent 的口吻回复。 - 拿不准属于哪一类时,选【报忙碌】。宁可让用户稍后得到准确答复, 也不要给出错误的直接回答。` diff --git a/internal/agent/io/channel.go b/internal/agent/io/channel.go index f672973d..9c27ab09 100644 --- a/internal/agent/io/channel.go +++ b/internal/agent/io/channel.go @@ -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) diff --git a/internal/agent/io/inject_payload_test.go b/internal/agent/io/inject_payload_test.go new file mode 100644 index 00000000..e03e98f8 --- /dev/null +++ b/internal/agent/io/inject_payload_test.go @@ -0,0 +1,91 @@ +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) + } + } +} diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go index 1158c745..00856178 100644 --- a/third_party/homeagent-sdk/sdk/plugin.go +++ b/third_party/homeagent-sdk/sdk/plugin.go @@ -150,6 +150,36 @@ type InjectOptions struct { // 排队注入(InjectText*/InjectInputSync)没有级别:它们本就是“不需及时处理” // 的那一类,可被任何中断打断。 Priority string + + // Payload 是本次注入要额外并入事件 payload 的字段(**不透明**)。 + // + // ## 为什么需要它(2026-10-18) + // + // QQ 插件的「对方正在输入中」必须只在**自己引起的** before_output 里发, + // 而且要知道发给谁。但两条路都断了: + // + // · `on_input` 的 stageCtx 是局部变量(runStage 完就丢) + // · `before_output` 的 stageCtx 是内核**新建**的(emitResponse) + // ⇒ 两者**不共享 Extra**,插件拿不到“这轮是谁引起的” + // + // ## 为什么挂在 InjectOptions 而不是新造一个 Meta 字段 + // + // 第一版我加了 `InjectOptions.Meta` —— 错的。它把同一件事做了两遍: + // `InputEvent.Payload` **本来就是通道送入内核的请求体**,身份信息本该 + // 就在那里。但 `IOInjector` 接口全是 `(source, channel, text, opts)`, + // 插件无法直接给 payload 加字段 —— 所以 payload 需要一个扩展口, + // 而不是一个平行的新概念。 + // + // 叫 Payload 而不是 Meta 还有一个好处:经内核实测确认, + // 它能让 HTTP/CLI/ACP 等**所有注入路径**共用同一套机制。 + // + // ## 内核怎么处理 + // + // 与真 payload 合并(同名键以本字段为准,但**内核保留字不允许覆盖**, + // 否则插件能伪造 `context_policy`/`content` 等内核语义), + // 然后在 emitResponse 里把**非内核保留键**原样透传到 `stageCtx.Extra`。 + // 内核不解释其中任何业务字段。 + Payload map[string]interface{} } // 中断优先级取值。