diff --git a/internal/plugin/proc/extra_bag_test.go b/internal/plugin/proc/extra_bag_test.go new file mode 100644 index 00000000..08c26dbf --- /dev/null +++ b/internal/plugin/proc/extra_bag_test.go @@ -0,0 +1,331 @@ +package proc + +// Extra 通用透传的判据(2026-10-12,fix/proc-extra-frame-passthrough)。 +// +// 背景:shm 桥此前只透传 4 个具名 Extra 键(media_blocks/media_type/ +// input_source/output_channel),插件自建的键(如 qq 的 qq_auth 绑帧身份) +// 在 stage 之间静默丢失 —— 权限门绑帧机制在 proc 插件下整体失效。 +// 修复:4 键之外的 Extra 键装入 fExtraBag(JSON),跨 stage 与跨进程往返。 +// +// 判据即契约: +// 1. 具名 4 键走原字段、原语义(含 nil/未设置区分),bag 不碰它们。 +// 2. 任意 JSON 可序列化的插件键经 WriteAll→ReadInto 往返后值等价 +// (map/bool/string/数字/嵌套)。 +// 3. 不可序列化的键被跳过并返回错误——宁可显式失败也不静默丢键 +// (丢键对 qq 是权限失效,静默等于把安全退化成运气)。 +// 4. WriteDirty:bag 变了才写;没变零写入(lost update 防线不破)。 +// 5. 插件写回的 bag 在内核 ctx.Extra 里以 map 形态存活,下一个 stage +// WriteAll 原样下发(帧态跨 stage 存活)。 + +import ( + "encoding/json" + "testing" + + pubsdk "github.com/JianFeeeee/homeagentsdk/sdk" +) + +func newTestSeg(t *testing.T) *Segment { + t.Helper() + buf := make([]byte, 1<<20) + seg, err := NewSegment(buf) + if err != nil { + t.Fatalf("NewSegment: %v", err) + } + return seg +} + +// 判据 2:插件自建键(qq_auth 形态:bool+int64 混合 map)完整往返。 +func TestExtraBag_PluginKeyRoundTrip(t *testing.T) { + seg := newTestSeg(t) + src := &pubsdk.StageContext{ + RawMessage: "hello", + Extra: map[string]interface{}{ + "input_source": "qq", // 具名键,语义不变 + "qq_auth": map[string]interface{}{ + "active": true, + "owner": true, + "message_id": int64(1125391573), + "user_id": int64(2198972886), + "group_id": int64(0), + "is_group": false, + }, + }, + } + if err := seg.WriteAll(src); err != nil { + t.Fatalf("WriteAll: %v", err) + } + var dst pubsdk.StageContext + if err := seg.ReadInto(&dst); err != nil { + t.Fatalf("ReadInto: %v", err) + } + if dst.Extra["input_source"] != "qq" { + t.Fatalf("具名键 input_source 应原样往返,got %v", dst.Extra["input_source"]) + } + auth, ok := dst.Extra["qq_auth"].(map[string]interface{}) + if !ok { + t.Fatalf("qq_auth 应以 map 形态往返,got %T", dst.Extra["qq_auth"]) + } + if auth["active"] != true || auth["owner"] != true { + t.Fatalf("qq_auth bool 字段失真: %+v", auth) + } + // JSON 数字统一 float64 —— 插件侧按此解码 + if id, ok := auth["user_id"].(float64); !ok || int64(id) != 2198972886 { + t.Fatalf("qq_auth user_id 失真: %v (%T)", auth["user_id"], auth["user_id"]) + } + if id, ok := auth["message_id"].(float64); !ok || int64(id) != 1125391573 { + t.Fatalf("qq_auth message_id 失真: %v", auth["message_id"]) + } + if auth["is_group"] != false { + t.Fatalf("qq_auth is_group 失真(false 不应丢失): %v", auth["is_group"]) + } +} + +// 判据 3:不可序列化的键显式报错,不静默丢。 +func TestExtraBag_UnserializableKeyFails(t *testing.T) { + seg := newTestSeg(t) + src := &pubsdk.StageContext{ + Extra: map[string]interface{}{ + "func_val": func() {}, // 函数不可 JSON + }, + } + if err := seg.WriteAll(src); err == nil { + t.Fatal("不可序列化的 Extra 值应使 WriteAll 报错,而不是静默丢键") + } +} + +// 判据 1:具名 4 键的 nil/空 语义不受 bag 影响(回归既有行为)。 +func TestExtraBag_NamedKeysUnaffected(t *testing.T) { + seg := newTestSeg(t) + // 无 Extra:全部未设置 + if err := seg.WriteAll(&pubsdk.StageContext{RawMessage: "x"}); err != nil { + t.Fatalf("WriteAll: %v", err) + } + var dst pubsdk.StageContext + if err := seg.ReadInto(&dst); err != nil { + t.Fatalf("ReadInto: %v", err) + } + if dst.Extra != nil { + t.Fatalf("无 Extra 时应保持 nil,got %v", dst.Extra) + } + // 只有具名键:与旧行为一致 + if err := seg.WriteAll(&pubsdk.StageContext{ + Extra: map[string]interface{}{"output_channel": "qq"}, + }); err != nil { + t.Fatalf("WriteAll: %v", err) + } + dst = pubsdk.StageContext{} + if err := seg.ReadInto(&dst); err != nil { + t.Fatalf("ReadInto: %v", err) + } + if dst.Extra["output_channel"] != "qq" || len(dst.Extra) != 1 { + t.Fatalf("具名键行为变化: %v", dst.Extra) + } +} + +// 判据 4:bag 未变时 WriteDirty 零写入;变了才写。 +func TestExtraBag_WriteDirty(t *testing.T) { + seg := newTestSeg(t) + base := &pubsdk.StageContext{ + Extra: map[string]interface{}{"qq_auth": map[string]interface{}{"active": true, "owner": true}}, + } + if err := seg.WriteAll(base); err != nil { + t.Fatalf("WriteAll: %v", err) + } + var local pubsdk.StageContext + if err := seg.ReadInto(&local); err != nil { + t.Fatalf("ReadInto: %v", err) + } + snap := TakeSnapshot(&local) + + // 只读插件:bag 没动 → 零写入 + n, err := seg.WriteDirty(&local, snap) + if err != nil { + t.Fatalf("WriteDirty: %v", err) + } + if n != 0 { + t.Fatalf("只读插件应零写入,got %d 字段", n) + } + + // 改了身份(降权):bag 变 → 恰好 1 个字段 + local.Lock() + local.Extra["qq_auth"] = map[string]interface{}{"active": true, "owner": false} + local.Unlock() + n, err = seg.WriteDirty(&local, snap) + if err != nil { + t.Fatalf("WriteDirty: %v", err) + } + if n != 1 { + t.Fatalf("bag 变更应恰好写回 1 个字段,got %d", n) + } + var dst pubsdk.StageContext + if err := seg.ReadInto(&dst); err != nil { + t.Fatalf("ReadInto: %v", err) + } + auth, ok := dst.Extra["qq_auth"].(map[string]interface{}) + if !ok || auth["owner"] != false { + t.Fatalf("降权应往返成功,got %v", dst.Extra["qq_auth"]) + } +} + +// 判据 5:帧态跨 stage 存活 —— 内核从共享段读回后,下一次 WriteAll +// 把插件写的 bag 原样下发(这是绑帧能成立的必要条件)。 +func TestExtraBag_FrameSurvivesAcrossStages(t *testing.T) { + seg := newTestSeg(t) + // stage1: 内核只有 input_source;插件 on_input 绑了 qq_auth + if err := seg.WriteAll(&pubsdk.StageContext{ + Extra: map[string]interface{}{"input_source": "qq"}, + }); err != nil { + t.Fatalf("WriteAll: %v", err) + } + var pluginCtx pubsdk.StageContext + if err := seg.ReadInto(&pluginCtx); err != nil { + t.Fatalf("ReadInto: %v", err) + } + // 快照在 handler 执行前抓(真实 stage 顺序);之后插件 on_input 绑身份。 + snap := TakeSnapshot(&pluginCtx) + pluginCtx.Lock() + if pluginCtx.Extra == nil { + pluginCtx.Extra = map[string]interface{}{} + } + pluginCtx.Extra["qq_auth"] = map[string]interface{}{"active": true, "owner": true, "user_id": int64(2198972886)} + pluginCtx.Unlock() + if n, err := seg.WriteDirty(&pluginCtx, snap); err != nil || n == 0 { + t.Fatalf("插件写回 qq_auth: n=%d err=%v", n, err) + } + + // 内核回读到自己的 ctx(coordinator.finish 语义) + var kernelCtx pubsdk.StageContext + if err := seg.ReadInto(&kernelCtx); err != nil { + t.Fatalf("内核回读: %v", err) + } + if _, ok := kernelCtx.Extra["qq_auth"]; !ok { + t.Fatal("内核回读后 qq_auth 必须存活(否则绑帧失效)") + } + + // stage2: 内核把同一 ctx 写下去(新段模拟下一 stage 的 WriteAll) + if err := seg.WriteAll(&kernelCtx); err != nil { + t.Fatalf("stage2 WriteAll: %v", err) + } + var pluginCtx2 pubsdk.StageContext + if err := seg.ReadInto(&pluginCtx2); err != nil { + t.Fatalf("stage2 ReadInto: %v", err) + } + auth, ok := pluginCtx2.Extra["qq_auth"].(map[string]interface{}) + if !ok || auth["owner"] != true { + t.Fatalf("下一 stage 插件必须还能读到帧身份(绑帧核心判据),got %v", pluginCtx2.Extra["qq_auth"]) + } + if pluginCtx2.Extra["input_source"] != "qq" { + t.Fatalf("具名键跨 stage 也应存活: %v", pluginCtx2.Extra) + } +} + +// bag 的 JSON 编解码等价性(float64 数字约定)——qq 插件解码侧依赖它。 +func TestExtraBag_JSONNumberConvention(t *testing.T) { + raw, err := json.Marshal(map[string]interface{}{"user_id": int64(2198972886)}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out map[string]interface{} + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if f, ok := out["user_id"].(float64); !ok || int64(f) != 2198972886 { + t.Fatalf("int64 经 JSON 应变 float64 且无损: %v %T", out["user_id"], out["user_id"]) + } +} + +// 判据 1b:bag 里混入具名键时,具名字段必须赢(ReadInto 的覆盖顺序契约)。 +// 直接往共享段写原始 JSON 构造冲突值,绕过 WriteAll 的过滤 —— 模拟 +// "写侧 bug / 旧版本内核把具名键也装袋" 的现实场景。 +func TestExtraBag_NamedKeyConflict_BagLoses(t *testing.T) { + seg := newTestSeg(t) + if err := seg.WriteAll(&pubsdk.StageContext{ + Extra: map[string]interface{}{"input_source": "qq"}, + }); err != nil { + t.Fatalf("WriteAll: %v", err) + } + // 绕过 captureLocal,手工把 bag 描述符指向伪造的 JSON(含同名异值具名键)。 + bagJSON, _ := json.Marshal(map[string]interface{}{ + "input_source": "bogus-from-bag", // 具名键污染 + "qq_auth": map[string]interface{}{"active": true}, + }) + sl, err := seg.write(bagJSON) + if err != nil { + t.Fatalf("write: %v", err) + } + seg.setDesc(fExtraBag, sl) + + var dst pubsdk.StageContext + if err := seg.ReadInto(&dst); err != nil { + t.Fatalf("ReadInto: %v", err) + } + if dst.Extra["input_source"] != "qq" { + t.Fatalf("具名键必须覆盖 bag 污染值: got %v", dst.Extra["input_source"]) + } + if _, ok := dst.Extra["qq_auth"]; !ok { + t.Fatal("bag 的正常键(qq_auth)应存活") + } +} + +// 判据 4b:WriteAll 后 bag 描述符里不得出现具名键(写侧过滤契约, +// 用 raw JSON 检查——语义判据测不到"键从未进袋")。 +func TestExtraBag_WriteAll_NeverPutsNamedKeysInBag(t *testing.T) { + seg := newTestSeg(t) + src := &pubsdk.StageContext{ + Extra: map[string]interface{}{ + "input_source": "qq", + "media_blocks": "x", + "qq_auth": map[string]interface{}{"active": true}, + "custom": 123, + }, + } + if err := seg.WriteAll(src); err != nil { + t.Fatalf("WriteAll: %v", err) + } + desc := seg.getDesc(fExtraBag) + if desc.IsUnset() { + t.Fatal("有自建键时 bag 描述符不应为空") + } + raw, err := seg.read(desc) + if err != nil { + t.Fatalf("read bag: %v", err) + } + var bag map[string]interface{} + if err := json.Unmarshal(raw, &bag); err != nil { + t.Fatalf("bag 不是合法 JSON: %v (%s)", err, raw) + } + for k := range bag { + if namedExtraKeys[k] { + t.Fatalf("具名键 %s 混进了 bag(写侧过滤失效)", k) + } + } + if bag["qq_auth"] == nil || bag["custom"] == nil { + t.Fatalf("自建键应全在 bag: %v", bag) + } +} + +// 变异哨兵:TakeSnapshot/WirteDirty 对 bag 的脏检测(bag 变了必须计 1 字段)。 +func TestExtraBag_SnapshotDetectsBagChange(t *testing.T) { + seg := newTestSeg(t) + if err := seg.WriteAll(&pubsdk.StageContext{ + Extra: map[string]interface{}{"qq_auth": map[string]interface{}{"active": true, "owner": false}}, + }); err != nil { + t.Fatalf("WriteAll: %v", err) + } + var local pubsdk.StageContext + if err := seg.ReadInto(&local); err != nil { + t.Fatalf("ReadInto: %v", err) + } + snap := TakeSnapshot(&local) + local.Lock() + // 模拟 qq on_input 绑帧/合并降权:owner 翻转 + local.Extra["qq_auth"] = map[string]interface{}{"active": true, "owner": true} + local.Unlock() + n, err := seg.WriteDirty(&local, snap) + if err != nil { + t.Fatalf("WriteDirty: %v", err) + } + if n != 1 { + t.Fatalf("bag 身份翻转应恰好脏 1 字段(WriteDirty 漏检=绑帧写不回), got %d", n) + } +} diff --git a/internal/plugin/proc/shm.go b/internal/plugin/proc/shm.go index 3718b82e..f75986eb 100644 --- a/internal/plugin/proc/shm.go +++ b/internal/plugin/proc/shm.go @@ -18,9 +18,13 @@ import ( ) // 共享段魔数与版本,用于挂载时校验对端布局一致。 +// +// v2(2026-10-12):新增 fExtraBag —— Extra 具名 4 键之外的插件自建键 +// (如 qq 的 qq_auth 绑帧身份)跨进程/跨 stage 透传。加字段必升版: +// AttachSegment 与插件握手对版本失败即拒,旧插件不会静默错读新布局。 const ( shmMagic uint32 = 0x48415348 // "HASH" — HomeAgent SHared - shmVersion uint32 = 1 + shmVersion uint32 = 2 ) // 段布局(所有偏移均相对**段起始**,arena 内偏移相对 arenaBase): @@ -84,10 +88,22 @@ const ( fMemory // JSON fTokenUsage // JSON fErrors // JSON - fExtraMediaBlocks // JSON —— Extra 的 4 个键提升为具名字段(§3.3 已核实使用点) + fExtraMediaBlocks // JSON —— Extra 的 4 个键提升为具名字段 fExtraMediaType fExtraInputSource fExtraOutputChannel + // fExtraBag 承载 Extra 里**具名 4 键之外的全部插件自建键**(JSON)。 + // + // 背景(2026-10-12 权限失效修复):qq 插件靠 ctx.Extra["qq_auth"] 把 + // 身份绑到推理帧上(中断抢占/并发合并场景下全局身份不可靠,见该插件 + // README「身份必须绑帧」)。但此前的桥只透传 4 个具名键,插件自建键 + // 在 stage 之间静默丢失 → before_toolcall 永远回落到可变全局身份 → + // 绑帧(以及建立在它之上的权限门)在 proc 插件下整体失效。 + // + // bag 把 Extra 恢复成「任意键跨进程/跨 stage 存活」的原语义。值以 JSON + // 往返:插件侧拿到 map[string]interface{}(数字统一 float64),不能 + // 类型断言回私有 struct —— 插件侧解码约定见 sdk example/qq。 + fExtraBag stageFieldCount ) diff --git a/internal/plugin/proc/shmcodec.go b/internal/plugin/proc/shmcodec.go index 5f88fc28..41086dad 100644 --- a/internal/plugin/proc/shmcodec.go +++ b/internal/plugin/proc/shmcodec.go @@ -66,6 +66,36 @@ type localCtx struct { ExtraMediaType interface{} ExtraInputSource interface{} ExtraOutputChan interface{} + // ExtraBag 是 Extra 里具名 4 键之外的全部键(插件自建,如 qq_auth)。 + // nil 表示无额外键;非 nil 时整体 JSON 进 fExtraBag。 + ExtraBag map[string]interface{} +} + +// namedExtraKeys 是被提升为具名字段的 4 个 Extra 键。bag 必须排除它们, +// 否则同一键会被具名字段与 bag 各带一份,回读时语义冲突。 +var namedExtraKeys = map[string]bool{ + ExtraKeyMediaBlocks: true, + ExtraKeyMediaType: true, + ExtraKeyInputSource: true, + ExtraKeyOutputChannel: true, +} + +// bagFromExtra 把 Extra 里非具名键收集成 bag(无额外键时返回 nil)。 +func bagFromExtra(extra map[string]interface{}) map[string]interface{} { + if len(extra) == 0 { + return nil + } + var bag map[string]interface{} + for k, v := range extra { + if namedExtraKeys[k] { + continue + } + if bag == nil { + bag = make(map[string]interface{}) + } + bag[k] = v + } + return bag } func captureLocal(sc *pubsdk.StageContext) *localCtx { @@ -91,6 +121,7 @@ func captureLocal(sc *pubsdk.StageContext) *localCtx { l.ExtraMediaType = sc.Extra[ExtraKeyMediaType] l.ExtraInputSource = sc.Extra[ExtraKeyInputSource] l.ExtraOutputChan = sc.Extra[ExtraKeyOutputChannel] + l.ExtraBag = bagFromExtra(sc.Extra) } return l } @@ -166,6 +197,9 @@ func (s *Segment) writeLocal(l *localCtx) error { {fExtraMediaType, l.ExtraMediaType}, {fExtraInputSource, l.ExtraInputSource}, {fExtraOutputChannel, l.ExtraOutputChan}, + // bag 空时写成未设置(sliceOrNil);非空时整体 marshal。 + // 不可序列化的值在这里让 putJSON 显式报错(宁失败不静默丢键)。 + {fExtraBag, sliceOrNil(len(l.ExtraBag), l.ExtraBag)}, } for _, step := range jsonFields { if err := putJSON(step.f, step.v); err != nil { @@ -263,6 +297,16 @@ func (s *Segment) ReadInto(sc *pubsdk.StageContext) error { } extra := map[string]interface{}{} + // bag 先打平,具名键随后覆盖 → 具名键永远赢。 + // 顺序是防御:即便 bag 里混入具名键(写侧 bug/旧数据), + // 也不会用 bag 的陈旧值顶掉具名字段的新值。 + var bag map[string]interface{} + if err := getJSON(fExtraBag, &bag); err != nil { + return err + } + for k, v := range bag { + extra[k] = v + } for _, pair := range []struct { f stageField key string @@ -281,6 +325,7 @@ func (s *Segment) ReadInto(sc *pubsdk.StageContext) error { } } + var respPtr *string if s.getFlag(flagResponseSet) { r, err := getStr(fResponse) @@ -461,6 +506,7 @@ func newSnapshotFromLocal(l *localCtx) *Snapshot { sn.jsons[fExtraMediaType] = marshal(l.ExtraMediaType) sn.jsons[fExtraInputSource] = marshal(l.ExtraInputSource) sn.jsons[fExtraOutputChannel] = marshal(l.ExtraOutputChan) + sn.jsons[fExtraBag] = marshal(sliceOrNil(len(l.ExtraBag), l.ExtraBag)) if l.Response != nil { sn.response = *l.Response @@ -509,6 +555,8 @@ func (f stageField) String() string { return "extra." + ExtraKeyInputSource case fExtraOutputChannel: return "extra." + ExtraKeyOutputChannel + case fExtraBag: + return "extra.bag" } return fmt.Sprintf("field(%d)", int(f)) }