fix(proc)!: shm 桥 v2 —— fExtraBag 透传插件自建 Extra 键,修复绑桁权限门失效

根因(2026-10-12 生产实证):shm 桥此前只透传 4 个具名 Extra 键
(media_blocks/media_type/input_source/output_channel),插件自建键
(qq 的 qq_auth 绑帧身份)在 WriteDirty/captureLocal 处被静默丢弃,
下一 stage ReadInto 重建的 Extra 永远没有它 → authOnFrame 恒 miss →
before_toolcall 回落到可变全局身份。owner 轮被并发的非 owner 中断
合并降权后,自己的 memory_commit 被硬私名单误拒(日志 22:20:58
「改写了 1 个字段」);反向时序则可能把非 owner 误放为 owner。

- shm.go:stageField 加 fExtraBag(19 字段),shmVersion 1→2
  (AttachSegment 与插件握手对旧版失败即拒,不静默错读新布局)
- shmcodec.go:captureLocal/WriteAll/ReadInto/newSnapshotFromLocal
  全链接入 bag;写侧过滤具名键(bag 不偷具名键槽位);读侧 bag 先
  打平、具名键后覆盖(具名键永远赢);不可序列化值显式报错——
  宁失败不静默丢键(丢键对 qq 是权限失效)
- extra_bag_test.go:判据 8 条(往返无损/具名键语义不变/不可序列化
  报错/WriteDirty 零写与脏写/跨 stage 帧存活/袋污染具名键必输/
  raw-JSON 写侧过滤/快照脏检),变异 M1-M3 全杀

配套:SDK 仓 777c3a0(模板 v2 同步 + qq 身份可序列化 + 插件侧判据)。
部署注意:shmVersion 2 后全部 23 个 proc 插件须用新 hmapdev 重编。
This commit is contained in:
JianFeeeee
2026-10-12 08:30:14 +08:00
parent b15994d964
commit 6abf03bd78
3 changed files with 397 additions and 2 deletions

View File

@ -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)
}
}

View File

@ -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
)

View File

@ -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))
}