mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
fix(proc,qq)!: shm 模板 v2 —— fExtraBag 透传插件自建 Extra 键;qq 身份可序列化绑帧
内核 shm 桥 v2 前(见主仓同步提交)只透传 4 个具名 Extra 键,插件自建键 (qq 的 qq_auth 绑帧身份)在 stage 之间静默丢失 → before_toolcall 永远回落 可变全局身份 → 权限门绑帧在 proc 插件下整体失效(owner 被误拒/非 owner 可能被误放,2026-10-12 生产实证)。 模板(proc_main.go.tmpl): - shmStageFieldCount 18→19、shmVersion 1→2(与内核镜像同步) - fExtraBag:具名 4 键之外的自建键整体 JSON 装袋,read/snapshot/dirty 全链接入 - ReadInto:bag 先打平、具名键后覆盖(具名键永远赢,防御袋污染) qq 插件: - qqAuthContext 字段导出 + json tag:非导出字段过 JSON 桥会静默丢空 (原根因链一环) - authOnFrame 双形态:值形态(进程内/单测)+ map 形态(proc 桥往返, 数字 float64);半截身份拒绝 —— 宁回落全局也不猜(owner 误判即提权) - authFromMap 按 json tag 重建,逐字段校验类型 判据(plugin_test.go):往返无损/降权保持/半截拒绝(逐键)/帧身份优先于 全局(含 owner 被误拒的原始故障场景反向)。变异 Q1-Q4 全杀。
This commit is contained in:
@ -85,14 +85,58 @@ type DownloadTask struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// qqAuthContext 是绑帧身份。字段导出 + json tag:proc 插件下身份要经
|
||||||
|
// 共享段 JSON 往返(内核 shm 桥 v2 的 fExtraBag),非导出字段会在
|
||||||
|
// marshal 时静默丢失 —— 那正是 2026-10-12 权限门失效的根因链一环。
|
||||||
type qqAuthContext struct {
|
type qqAuthContext struct {
|
||||||
active bool
|
Active bool `json:"active"`
|
||||||
owner bool
|
Owner bool `json:"owner"`
|
||||||
messageID int64
|
MessageID int64 `json:"message_id"`
|
||||||
userID int64
|
UserID int64 `json:"user_id"`
|
||||||
groupID int64
|
GroupID int64 `json:"group_id"`
|
||||||
isGroup bool
|
IsGroup bool `json:"is_group"`
|
||||||
generation uint64
|
Generation uint64 `json:"generation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// authFromMap 按 JSON 往返约定重建身份(数字统一 float64)。
|
||||||
|
// 解不出完整身份(键缺失/类型错)返回 false —— 宁回落全局也不猜:
|
||||||
|
// 半截的身份比没有更危险(owner 误判即提权)。
|
||||||
|
func authFromMap(m map[string]interface{}) (qqAuthContext, bool) {
|
||||||
|
var a qqAuthContext
|
||||||
|
getBool := func(k string) (bool, bool) {
|
||||||
|
v, ok := m[k].(bool)
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
getInt := func(k string) (int64, bool) {
|
||||||
|
f, ok := m[k].(float64)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return int64(f), true
|
||||||
|
}
|
||||||
|
var ok bool
|
||||||
|
if a.Active, ok = getBool("active"); !ok {
|
||||||
|
return a, false
|
||||||
|
}
|
||||||
|
if a.Owner, ok = getBool("owner"); !ok {
|
||||||
|
return a, false
|
||||||
|
}
|
||||||
|
if a.MessageID, ok = getInt("message_id"); !ok {
|
||||||
|
return a, false
|
||||||
|
}
|
||||||
|
if a.UserID, ok = getInt("user_id"); !ok {
|
||||||
|
return a, false
|
||||||
|
}
|
||||||
|
if a.GroupID, ok = getInt("group_id"); !ok {
|
||||||
|
return a, false
|
||||||
|
}
|
||||||
|
if a.IsGroup, ok = getBool("is_group"); !ok {
|
||||||
|
return a, false
|
||||||
|
}
|
||||||
|
if g, present := m["generation"].(float64); present {
|
||||||
|
a.Generation = uint64(g)
|
||||||
|
}
|
||||||
|
return a, true
|
||||||
}
|
}
|
||||||
|
|
||||||
var qqMessageIDRe = regexp.MustCompile(`message_id=(-?\d+)`)
|
var qqMessageIDRe = regexp.MustCompile(`message_id=(-?\d+)`)
|
||||||
@ -982,7 +1026,7 @@ func argInt64(args map[string]interface{}, key string) (int64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}, auth qqAuthContext) (bool, string) {
|
func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}, auth qqAuthContext) (bool, string) {
|
||||||
if !auth.active || auth.owner {
|
if !auth.Active || auth.Owner {
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
// 输出工具**不受"当前会话"身份限制**(先于身份判据返回)。
|
// 输出工具**不受"当前会话"身份限制**(先于身份判据返回)。
|
||||||
@ -998,9 +1042,9 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
|
|||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPeer := auth.userID
|
currentPeer := auth.UserID
|
||||||
if auth.isGroup {
|
if auth.IsGroup {
|
||||||
currentPeer = auth.groupID
|
currentPeer = auth.GroupID
|
||||||
}
|
}
|
||||||
if currentPeer == 0 {
|
if currentPeer == 0 {
|
||||||
return false, "可信 QQ 会话身份不完整"
|
return false, "可信 QQ 会话身份不完整"
|
||||||
@ -1008,10 +1052,10 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
|
|||||||
matchCurrentPeer := func() bool {
|
matchCurrentPeer := func() bool {
|
||||||
groupID, hasGroup := argInt64(args, "group_id")
|
groupID, hasGroup := argInt64(args, "group_id")
|
||||||
userID, hasUser := argInt64(args, "user_id")
|
userID, hasUser := argInt64(args, "user_id")
|
||||||
if auth.isGroup {
|
if auth.IsGroup {
|
||||||
return hasGroup && groupID == auth.groupID && !hasUser
|
return hasGroup && groupID == auth.GroupID && !hasUser
|
||||||
}
|
}
|
||||||
return hasUser && userID == auth.userID && !hasGroup
|
return hasUser && userID == auth.UserID && !hasGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
switch name {
|
switch name {
|
||||||
@ -1024,16 +1068,16 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false, "缺少有效 message_id"
|
return false, "缺少有效 message_id"
|
||||||
}
|
}
|
||||||
if messageID == auth.messageID {
|
if messageID == auth.MessageID {
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
peerID, isGroup, _, found := p.lookupMsgRef(messageID)
|
peerID, isGroup, _, found := p.lookupMsgRef(messageID)
|
||||||
if !found || isGroup != auth.isGroup || peerID != currentPeer {
|
if !found || isGroup != auth.IsGroup || peerID != currentPeer {
|
||||||
return false, "message_id 不属于当前 QQ 会话"
|
return false, "message_id 不属于当前 QQ 会话"
|
||||||
}
|
}
|
||||||
case p.name + "_get_group_member_info", p.name + "_get_group_files":
|
case p.name + "_get_group_member_info", p.name + "_get_group_files":
|
||||||
groupID, ok := argInt64(args, "group_id")
|
groupID, ok := argInt64(args, "group_id")
|
||||||
if !auth.isGroup || !ok || groupID != auth.groupID {
|
if !auth.IsGroup || !ok || groupID != auth.GroupID {
|
||||||
return false, "只能访问当前 QQ 群的数据"
|
return false, "只能访问当前 QQ 群的数据"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1046,7 +1090,7 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
|
|||||||
// - 中断会抢占当前轮并把现场压栈(scheduler 的 suspendStack),中断轮收尾时
|
// - 中断会抢占当前轮并把现场压栈(scheduler 的 suspendStack),中断轮收尾时
|
||||||
// afterOutput 把插件全局身份清空;随后外层被恢复(resumeTask 复用同一帧、
|
// afterOutput 把插件全局身份清空;随后外层被恢复(resumeTask 复用同一帧、
|
||||||
// 不重跑 StageOnInput),若身份只存全局,恢复后的外层就是"无身份"——
|
// 不重跑 StageOnInput),若身份只存全局,恢复后的外层就是"无身份"——
|
||||||
// beforeToolcall 会在 !auth.active 处直接返回,权限门整体失效。
|
// beforeToolcall 会在 !auth.Active 处直接返回,权限门整体失效。
|
||||||
// - 运行中到达的新消息会调 activateAuthContext 改写全局身份,把**正在跑的那一轮**
|
// - 运行中到达的新消息会调 activateAuthContext 改写全局身份,把**正在跑的那一轮**
|
||||||
// 换成另一方的身份(换高=越权,换低=误拒)。
|
// 换成另一方的身份(换高=越权,换低=误拒)。
|
||||||
//
|
//
|
||||||
@ -1054,13 +1098,22 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
|
|||||||
const qqAuthExtraKey = "qq_auth"
|
const qqAuthExtraKey = "qq_auth"
|
||||||
|
|
||||||
// authOnFrame 读取本帧绑定的身份;ok=false 表示本帧未绑定过 QQ 身份。
|
// authOnFrame 读取本帧绑定的身份;ok=false 表示本帧未绑定过 QQ 身份。
|
||||||
// 调用方需持有 ctx 的读(或写)锁。
|
//
|
||||||
|
// 双形态兼容(proc 桥 JSON 往返约定):
|
||||||
|
// - 值形态(进程内 stage / 单测):类型断言直接命中;
|
||||||
|
// - map 形态(proc 插件:身份经共享段 JSON 往返):按 json tag 重建。
|
||||||
func authOnFrame(ctx *sdk.StageContext) (qqAuthContext, bool) {
|
func authOnFrame(ctx *sdk.StageContext) (qqAuthContext, bool) {
|
||||||
if ctx == nil || ctx.Extra == nil {
|
if ctx == nil || ctx.Extra == nil {
|
||||||
return qqAuthContext{}, false
|
return qqAuthContext{}, false
|
||||||
}
|
}
|
||||||
auth, ok := ctx.Extra[qqAuthExtraKey].(qqAuthContext)
|
switch v := ctx.Extra[qqAuthExtraKey].(type) {
|
||||||
return auth, ok
|
case qqAuthContext:
|
||||||
|
return v, true
|
||||||
|
case map[string]interface{}:
|
||||||
|
return authFromMap(v)
|
||||||
|
default:
|
||||||
|
return qqAuthContext{}, false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bindAuthOnFrame 把身份绑到本帧上。调用方需持有 ctx 的写锁。
|
// bindAuthOnFrame 把身份绑到本帧上。调用方需持有 ctx 的写锁。
|
||||||
@ -1085,43 +1138,43 @@ func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup b
|
|||||||
}
|
}
|
||||||
p.authGeneration++
|
p.authGeneration++
|
||||||
next := qqAuthContext{
|
next := qqAuthContext{
|
||||||
active: true, owner: p.isOwner(userID), messageID: messageID, userID: userID,
|
Active: true, Owner: p.isOwner(userID), MessageID: messageID, UserID: userID,
|
||||||
groupID: groupID, isGroup: isGroup, generation: p.authGeneration,
|
GroupID: groupID, IsGroup: isGroup, Generation: p.authGeneration,
|
||||||
}
|
}
|
||||||
if messageID != 0 {
|
if messageID != 0 {
|
||||||
p.authByMessageID[messageID] = next
|
p.authByMessageID[messageID] = next
|
||||||
if len(p.authByMessageID) > 2048 {
|
if len(p.authByMessageID) > 2048 {
|
||||||
cutoff := p.authGeneration - 1024
|
cutoff := p.authGeneration - 1024
|
||||||
for id, auth := range p.authByMessageID {
|
for id, auth := range p.authByMessageID {
|
||||||
if auth.generation < cutoff {
|
if auth.Generation < cutoff {
|
||||||
delete(p.authByMessageID, id)
|
delete(p.authByMessageID, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !p.auth.active {
|
if !p.auth.Active {
|
||||||
p.auth = next
|
p.auth = next
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if p.auth.userID == userID && p.auth.groupID == groupID && p.auth.isGroup == isGroup {
|
if p.auth.UserID == userID && p.auth.GroupID == groupID && p.auth.IsGroup == isGroup {
|
||||||
p.auth.owner = p.auth.owner && next.owner
|
p.auth.Owner = p.auth.Owner && next.Owner
|
||||||
p.auth.messageID = next.messageID
|
p.auth.MessageID = next.MessageID
|
||||||
p.auth.generation = next.generation
|
p.auth.Generation = next.Generation
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 不同可信来源被内核合并到同一推理时,只在双方都是所有者时保留完整权限。
|
// 不同可信来源被内核合并到同一推理时,只在双方都是所有者时保留完整权限。
|
||||||
bothOwners := p.auth.owner && next.owner
|
bothOwners := p.auth.Owner && next.Owner
|
||||||
sameGroup := p.auth.isGroup && next.isGroup && p.auth.groupID == groupID
|
sameGroup := p.auth.IsGroup && next.IsGroup && p.auth.GroupID == groupID
|
||||||
p.auth.owner = bothOwners
|
p.auth.Owner = bothOwners
|
||||||
p.auth.messageID = 0
|
p.auth.MessageID = 0
|
||||||
p.auth.userID = 0
|
p.auth.UserID = 0
|
||||||
p.auth.isGroup = sameGroup
|
p.auth.IsGroup = sameGroup
|
||||||
if sameGroup {
|
if sameGroup {
|
||||||
p.auth.groupID = groupID
|
p.auth.GroupID = groupID
|
||||||
} else {
|
} else {
|
||||||
p.auth.groupID = 0
|
p.auth.GroupID = 0
|
||||||
}
|
}
|
||||||
p.auth.generation = next.generation
|
p.auth.Generation = next.Generation
|
||||||
}
|
}
|
||||||
|
|
||||||
var qqMessageIDsRe = regexp.MustCompile(`message_id=(-?\d+(?:,-?\d+)*)`)
|
var qqMessageIDsRe = regexp.MustCompile(`message_id=(-?\d+(?:,-?\d+)*)`)
|
||||||
@ -1156,7 +1209,7 @@ func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
|
|||||||
|
|
||||||
p.authMu.Lock()
|
p.authMu.Lock()
|
||||||
// 默认降权:QQ 来源却对不上可信事件时绝不复用上一条消息的身份。
|
// 默认降权:QQ 来源却对不上可信事件时绝不复用上一条消息的身份。
|
||||||
next := qqAuthContext{active: source == p.name}
|
next := qqAuthContext{Active: source == p.name}
|
||||||
ids := messageIDsFromInput(raw)
|
ids := messageIDsFromInput(raw)
|
||||||
if source == p.name && len(ids) > 0 {
|
if source == p.name && len(ids) > 0 {
|
||||||
if auth, ok := p.authByMessageID[ids[0]]; ok {
|
if auth, ok := p.authByMessageID[ids[0]]; ok {
|
||||||
@ -1201,9 +1254,9 @@ func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, q
|
|||||||
auth = p.auth
|
auth = p.auth
|
||||||
}
|
}
|
||||||
var patterns []string
|
var patterns []string
|
||||||
if auth.active && !auth.owner {
|
if auth.Active && !auth.Owner {
|
||||||
if auth.isGroup {
|
if auth.IsGroup {
|
||||||
patterns, _ = p.groupToolAllowlists[auth.groupID]
|
patterns, _ = p.groupToolAllowlists[auth.GroupID]
|
||||||
if patterns == nil {
|
if patterns == nil {
|
||||||
patterns = p.groupToolAllowlists[0]
|
patterns = p.groupToolAllowlists[0]
|
||||||
}
|
}
|
||||||
@ -1212,7 +1265,7 @@ func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, q
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.authMu.RUnlock()
|
p.authMu.RUnlock()
|
||||||
if !auth.active || auth.owner {
|
if !auth.Active || auth.Owner {
|
||||||
return true, auth
|
return true, auth
|
||||||
}
|
}
|
||||||
return matchesToolAllowlist(name, patterns), auth
|
return matchesToolAllowlist(name, patterns), auth
|
||||||
@ -1309,7 +1362,7 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
|
|||||||
}
|
}
|
||||||
tc := &ctx.ToolCalls[0]
|
tc := &ctx.ToolCalls[0]
|
||||||
allowed, auth := p.currentToolAllowed(ctx, tc.Name)
|
allowed, auth := p.currentToolAllowed(ctx, tc.Name)
|
||||||
if !auth.active {
|
if !auth.Active {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1352,7 +1405,7 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
|
|||||||
p.setDenial(ctx, msg)
|
p.setDenial(ctx, msg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !auth.owner && isHardPrivateTool(tc.Name) {
|
if !auth.Owner && isHardPrivateTool(tc.Name) {
|
||||||
p.authMu.Lock()
|
p.authMu.Lock()
|
||||||
p.denialLocked = true
|
p.denialLocked = true
|
||||||
p.authMu.Unlock()
|
p.authMu.Unlock()
|
||||||
@ -1362,8 +1415,8 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
|
|||||||
}
|
}
|
||||||
if !allowed {
|
if !allowed {
|
||||||
scope := "非所有者私聊"
|
scope := "非所有者私聊"
|
||||||
if auth.isGroup {
|
if auth.IsGroup {
|
||||||
scope = fmt.Sprintf("群聊 %d", auth.groupID)
|
scope = fmt.Sprintf("群聊 %d", auth.GroupID)
|
||||||
}
|
}
|
||||||
msg := fmt.Sprintf("QQ 权限策略拒绝工具 %s:%s 的工具白名单未包含该工具;请不要重试,改为直接说明权限限制", tc.Name, scope)
|
msg := fmt.Sprintf("QQ 权限策略拒绝工具 %s:%s 的工具白名单未包含该工具;请不要重试,改为直接说明权限限制", tc.Name, scope)
|
||||||
p.setDenial(ctx, msg)
|
p.setDenial(ctx, msg)
|
||||||
|
|||||||
@ -27,7 +27,7 @@ func toolCallContext(name string, args map[string]interface{}) *sdk.StageContext
|
|||||||
|
|
||||||
func TestOwnerBypassesQQPermissionBoundary(t *testing.T) {
|
func TestOwnerBypassesQQPermissionBoundary(t *testing.T) {
|
||||||
p := newPermissionTestPlugin(t)
|
p := newPermissionTestPlugin(t)
|
||||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886}
|
||||||
ctx := toolCallContext("calendar_list", nil)
|
ctx := toolCallContext("calendar_list", nil)
|
||||||
if err := p.beforeToolcall(ctx); err != nil {
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@ -40,7 +40,7 @@ func TestOwnerBypassesQQPermissionBoundary(t *testing.T) {
|
|||||||
func TestPrivateResourceCannotBeAllowlisted(t *testing.T) {
|
func TestPrivateResourceCannotBeAllowlisted(t *testing.T) {
|
||||||
p := newPermissionTestPlugin(t)
|
p := newPermissionTestPlugin(t)
|
||||||
p.privateToolAllowlist = append(p.privateToolAllowlist, "calendar_*")
|
p.privateToolAllowlist = append(p.privateToolAllowlist, "calendar_*")
|
||||||
p.auth = qqAuthContext{active: true, userID: 10001}
|
p.auth = qqAuthContext{Active: true, UserID: 10001}
|
||||||
ctx := toolCallContext("calendar_list", nil)
|
ctx := toolCallContext("calendar_list", nil)
|
||||||
if err := p.beforeToolcall(ctx); err != nil {
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@ -52,7 +52,7 @@ func TestPrivateResourceCannotBeAllowlisted(t *testing.T) {
|
|||||||
|
|
||||||
func TestNonOwnerQQHistoryIsScopedToCurrentGroup(t *testing.T) {
|
func TestNonOwnerQQHistoryIsScopedToCurrentGroup(t *testing.T) {
|
||||||
p := newPermissionTestPlugin(t)
|
p := newPermissionTestPlugin(t)
|
||||||
p.auth = qqAuthContext{active: true, messageID: 88, userID: 10001, groupID: 20002, isGroup: true}
|
p.auth = qqAuthContext{Active: true, MessageID: 88, UserID: 10001, GroupID: 20002, IsGroup: true}
|
||||||
|
|
||||||
ctx := toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20003)})
|
ctx := toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20003)})
|
||||||
if err := p.beforeToolcall(ctx); err != nil {
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
@ -73,7 +73,7 @@ func TestNonOwnerQQHistoryIsScopedToCurrentGroup(t *testing.T) {
|
|||||||
|
|
||||||
func TestUnmatchedQQInputIsDowngraded(t *testing.T) {
|
func TestUnmatchedQQInputIsDowngraded(t *testing.T) {
|
||||||
p := newPermissionTestPlugin(t)
|
p := newPermissionTestPlugin(t)
|
||||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886}
|
||||||
ctx := &sdk.StageContext{
|
ctx := &sdk.StageContext{
|
||||||
RawMessage: "来自未知事件(message_id=404)",
|
RawMessage: "来自未知事件(message_id=404)",
|
||||||
Extra: map[string]interface{}{"input_source": "qq"},
|
Extra: map[string]interface{}{"input_source": "qq"},
|
||||||
@ -81,7 +81,7 @@ func TestUnmatchedQQInputIsDowngraded(t *testing.T) {
|
|||||||
if err := p.onInputAuthContext(ctx); err != nil {
|
if err := p.onInputAuthContext(ctx); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !p.auth.active || p.auth.owner || p.auth.userID != 0 {
|
if !p.auth.Active || p.auth.Owner || p.auth.UserID != 0 {
|
||||||
t.Fatalf("unmatched input reused prior privilege: %+v", p.auth)
|
t.Fatalf("unmatched input reused prior privilege: %+v", p.auth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -89,7 +89,7 @@ func TestUnmatchedQQInputIsDowngraded(t *testing.T) {
|
|||||||
func TestDuplicateQQOutputIsStopped(t *testing.T) {
|
func TestDuplicateQQOutputIsStopped(t *testing.T) {
|
||||||
p := newPermissionTestPlugin(t)
|
p := newPermissionTestPlugin(t)
|
||||||
p.maxDuplicateSend = 1
|
p.maxDuplicateSend = 1
|
||||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886}
|
||||||
args := map[string]interface{}{"payload": "same", "type": "text", "meta": `{"user_id":123}`}
|
args := map[string]interface{}{"payload": "same", "type": "text", "meta": `{"user_id":123}`}
|
||||||
|
|
||||||
ctx := toolCallContext("output_send__qq", args)
|
ctx := toolCallContext("output_send__qq", args)
|
||||||
@ -151,7 +151,7 @@ func TestGroupAndUserRouteAddsLeadingMention(t *testing.T) {
|
|||||||
// 现在只拦参数完全相同的重复调用。
|
// 现在只拦参数完全相同的重复调用。
|
||||||
func TestDistinctQQOutputsAreNotTreatedAsDuplicates(t *testing.T) {
|
func TestDistinctQQOutputsAreNotTreatedAsDuplicates(t *testing.T) {
|
||||||
p := newPermissionTestPlugin(t)
|
p := newPermissionTestPlugin(t)
|
||||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886}
|
||||||
// maxDuplicateSend 默认 1:同一条消息重复才会被拦,不同消息必须全部放行。
|
// maxDuplicateSend 默认 1:同一条消息重复才会被拦,不同消息必须全部放行。
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
ctx := toolCallContext("output_send__qq", map[string]interface{}{
|
ctx := toolCallContext("output_send__qq", map[string]interface{}{
|
||||||
@ -170,7 +170,7 @@ func TestDistinctQQOutputsAreNotTreatedAsDuplicates(t *testing.T) {
|
|||||||
|
|
||||||
func TestDistinctNecessaryToolCallsAreNotBlocked(t *testing.T) {
|
func TestDistinctNecessaryToolCallsAreNotBlocked(t *testing.T) {
|
||||||
p := newPermissionTestPlugin(t)
|
p := newPermissionTestPlugin(t)
|
||||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886}
|
||||||
// 旧实现 maxQQToolCalls=32 会在第 33 个不同参数的必需调用处误拦。
|
// 旧实现 maxQQToolCalls=32 会在第 33 个不同参数的必需调用处误拦。
|
||||||
for i := 0; i < 50; i++ {
|
for i := 0; i < 50; i++ {
|
||||||
ctx := toolCallContext("cmd_run", map[string]interface{}{"command": fmt.Sprintf("cmd-%d", i)})
|
ctx := toolCallContext("cmd_run", map[string]interface{}{"command": fmt.Sprintf("cmd-%d", i)})
|
||||||
@ -188,7 +188,7 @@ func TestZeroLimitsMeanUnlimited(t *testing.T) {
|
|||||||
p.maxQQOutputCalls = 0
|
p.maxQQOutputCalls = 0
|
||||||
p.maxDuplicateSend = 0
|
p.maxDuplicateSend = 0
|
||||||
p.maxQQToolCalls = 0
|
p.maxQQToolCalls = 0
|
||||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886}
|
||||||
for i := 0; i < 30; i++ {
|
for i := 0; i < 30; i++ {
|
||||||
ctx := toolCallContext("output_send__qq", map[string]interface{}{
|
ctx := toolCallContext("output_send__qq", map[string]interface{}{
|
||||||
"payload": "same-content",
|
"payload": "same-content",
|
||||||
@ -204,7 +204,7 @@ func TestZeroLimitsMeanUnlimited(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={active:true}、无 peer、非 owner)时,
|
// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={Active:true}、无 peer、非 owner)时,
|
||||||
// **输出仍必须放行**:发到哪个会话由 agent 自己给的 meta 决定,
|
// **输出仍必须放行**:发到哪个会话由 agent 自己给的 meta 决定,
|
||||||
// 不该被「当前会话身份」挡住。现场:被子的中断唤醒的一轮里,父带齐 meta 也发不出去
|
// 不该被「当前会话身份」挡住。现场:被子的中断唤醒的一轮里,父带齐 meta 也发不出去
|
||||||
// (报「可信 QQ 会话身份不完整」)。
|
// (报「可信 QQ 会话身份不完整」)。
|
||||||
@ -212,7 +212,7 @@ func TestZeroLimitsMeanUnlimited(t *testing.T) {
|
|||||||
// 反之,**读取类**工具在降权时仍受当前会话限制 —— 那才是真的不能跨会话读。
|
// 反之,**读取类**工具在降权时仍受当前会话限制 —— 那才是真的不能跨会话读。
|
||||||
func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) {
|
func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) {
|
||||||
p := newPermissionTestPlugin(t)
|
p := newPermissionTestPlugin(t)
|
||||||
p.auth = qqAuthContext{active: true}
|
p.auth = qqAuthContext{Active: true}
|
||||||
p.privateToolAllowlist = []string{"output_send__qq", "qq_get_history"}
|
p.privateToolAllowlist = []string{"output_send__qq", "qq_get_history"}
|
||||||
p.groupToolAllowlists = map[int64][]string{0: {"output_send__qq", "qq_get_history"}}
|
p.groupToolAllowlists = map[int64][]string{0: {"output_send__qq", "qq_get_history"}}
|
||||||
|
|
||||||
@ -342,18 +342,18 @@ func TestAuthSurvivesInterruptPreemptionOfAnotherTurn(t *testing.T) {
|
|||||||
|
|
||||||
// 中断轮(Bot 所有者)跑完:afterOutput 会清掉插件全局身份。
|
// 中断轮(Bot 所有者)跑完:afterOutput 会清掉插件全局身份。
|
||||||
inner := &sdk.StageContext{Extra: map[string]interface{}{
|
inner := &sdk.StageContext{Extra: map[string]interface{}{
|
||||||
qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886},
|
qqAuthExtraKey: qqAuthContext{Active: true, Owner: true, UserID: 2198972886},
|
||||||
}}
|
}}
|
||||||
if err := p.afterOutputAuthContext(inner); err != nil {
|
if err := p.afterOutputAuthContext(inner); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if p.auth.active {
|
if p.auth.Active {
|
||||||
t.Fatal("收尾后插件全局身份应为空(复现恢复前状态)")
|
t.Fatal("收尾后插件全局身份应为空(复现恢复前状态)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 外层轮(非所有者群成员)恢复后继续调工具:仍须按非所有者拦下私人资源工具。
|
// 外层轮(非所有者群成员)恢复后继续调工具:仍须按非所有者拦下私人资源工具。
|
||||||
frame := &sdk.StageContext{
|
frame := &sdk.StageContext{
|
||||||
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, userID: 10001, groupID: 20002, isGroup: true}},
|
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{Active: true, UserID: 10001, GroupID: 20002, IsGroup: true}},
|
||||||
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
|
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
|
||||||
}
|
}
|
||||||
if err := p.beforeToolcall(frame); err != nil {
|
if err := p.beforeToolcall(frame); err != nil {
|
||||||
@ -369,12 +369,12 @@ func TestMidTurnMessageDoesNotChangeRunningTurnAuth(t *testing.T) {
|
|||||||
p := newPermissionTestPlugin(t)
|
p := newPermissionTestPlugin(t)
|
||||||
|
|
||||||
frame := &sdk.StageContext{
|
frame := &sdk.StageContext{
|
||||||
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886}},
|
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{Active: true, Owner: true, UserID: 2198972886}},
|
||||||
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
|
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
|
||||||
}
|
}
|
||||||
// 路人的群消息在所有者轮运行中到达。
|
// 路人的群消息在所有者轮运行中到达。
|
||||||
p.activateAuthContext(4242, 10001, 20002, true)
|
p.activateAuthContext(4242, 10001, 20002, true)
|
||||||
if p.auth.owner {
|
if p.auth.Owner {
|
||||||
t.Fatal("到达事件应改写全局身份(复现场景)")
|
t.Fatal("到达事件应改写全局身份(复现场景)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -390,8 +390,8 @@ func TestMidTurnMessageDoesNotChangeRunningTurnAuth(t *testing.T) {
|
|||||||
func TestBatchInterruptConsumesAllMessageIDs(t *testing.T) {
|
func TestBatchInterruptConsumesAllMessageIDs(t *testing.T) {
|
||||||
p := newPermissionTestPlugin(t)
|
p := newPermissionTestPlugin(t)
|
||||||
p.authByMessageID = map[int64]qqAuthContext{
|
p.authByMessageID = map[int64]qqAuthContext{
|
||||||
100: {active: true, owner: true, userID: 2198972886},
|
100: {Active: true, Owner: true, UserID: 2198972886},
|
||||||
101: {active: true, owner: true, userID: 2198972886},
|
101: {Active: true, Owner: true, UserID: 2198972886},
|
||||||
}
|
}
|
||||||
ctx := &sdk.StageContext{
|
ctx := &sdk.StageContext{
|
||||||
RawMessage: "来自「老板」的私聊短时间内连续发来 2 条消息(message_id=100,101, user_id=2198972886)。",
|
RawMessage: "来自「老板」的私聊短时间内连续发来 2 条消息(message_id=100,101, user_id=2198972886)。",
|
||||||
@ -400,13 +400,13 @@ func TestBatchInterruptConsumesAllMessageIDs(t *testing.T) {
|
|||||||
if err := p.onInputAuthContext(ctx); err != nil {
|
if err := p.onInputAuthContext(ctx); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !p.auth.owner {
|
if !p.auth.Owner {
|
||||||
t.Fatalf("合并中断未恢复所有者身份: %+v", p.auth)
|
t.Fatalf("合并中断未恢复所有者身份: %+v", p.auth)
|
||||||
}
|
}
|
||||||
if len(p.authByMessageID) != 0 {
|
if len(p.authByMessageID) != 0 {
|
||||||
t.Fatalf("同批 message_id 未全部清理: %v", p.authByMessageID)
|
t.Fatalf("同批 message_id 未全部清理: %v", p.authByMessageID)
|
||||||
}
|
}
|
||||||
if auth, ok := authOnFrame(ctx); !ok || !auth.owner {
|
if auth, ok := authOnFrame(ctx); !ok || !auth.Owner {
|
||||||
t.Fatalf("身份未绑定到帧上: %+v ok=%v", auth, ok)
|
t.Fatalf("身份未绑定到帧上: %+v ok=%v", auth, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -414,7 +414,7 @@ func TestBatchInterruptConsumesAllMessageIDs(t *testing.T) {
|
|||||||
// 非 QQ 来源(webui/timer 等)的帧上绑空身份:权限门对这些轮整体关闭。
|
// 非 QQ 来源(webui/timer 等)的帧上绑空身份:权限门对这些轮整体关闭。
|
||||||
func TestNonQQFrameBindsInactiveAuth(t *testing.T) {
|
func TestNonQQFrameBindsInactiveAuth(t *testing.T) {
|
||||||
p := newPermissionTestPlugin(t)
|
p := newPermissionTestPlugin(t)
|
||||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886}
|
||||||
|
|
||||||
ctx := &sdk.StageContext{
|
ctx := &sdk.StageContext{
|
||||||
RawMessage: "webui 里的提问",
|
RawMessage: "webui 里的提问",
|
||||||
@ -431,3 +431,113 @@ func TestNonQQFrameBindsInactiveAuth(t *testing.T) {
|
|||||||
t.Fatalf("非 QQ 轮不应被 QQ 权限门拦: %s", *ctx.Response)
|
t.Fatalf("非 QQ 轮不应被 QQ 权限门拦: %s", *ctx.Response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 绑帧跨 proc 桥的判据(2026-10-12 shm v2 fExtraBag)=====
|
||||||
|
//
|
||||||
|
// 背景:内核 shm 桥 v2 之前只透传 4 个具名 Extra 键,插件自建键(qq_auth)
|
||||||
|
// 在 stage 之间静默丢失 → before_toolcall 永远回落可变全局身份 → 绑帧失效。
|
||||||
|
// v2 起自建键装 fExtraBag 以 JSON 往返;本组判据锁定插件侧解码契约:
|
||||||
|
// 数字统一 float64、解不出完整身份按未绑定处理(宁回落全局也不猜)。
|
||||||
|
|
||||||
|
// 判据 1:身份 struct → JSON → map(模拟 proc 桥往返)→ authOnFrame 重建无损。
|
||||||
|
func TestAuthOnFrameMapRoundTripFullFidelity(t *testing.T) {
|
||||||
|
src := qqAuthContext{
|
||||||
|
Active: true, Owner: true, MessageID: 1125391573, UserID: 2198972886,
|
||||||
|
GroupID: 0, IsGroup: false, Generation: 42,
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var m map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
ctx := &sdk.StageContext{Extra: map[string]interface{}{qqAuthExtraKey: m}}
|
||||||
|
got, ok := authOnFrame(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("map 形态身份应能重建(proc 桥语义),got !ok")
|
||||||
|
}
|
||||||
|
if got != src {
|
||||||
|
t.Fatalf("往返失真:\n got %+v\nwant %+v", got, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 2:降权身份往返后必须保持降权(防“往返即提权”)。
|
||||||
|
func TestAuthOnFrameMapRoundTripPreservesDemotion(t *testing.T) {
|
||||||
|
src := qqAuthContext{Active: true, Owner: false, UserID: 3162251442, IsGroup: true, GroupID: 258962824}
|
||||||
|
raw, _ := json.Marshal(src)
|
||||||
|
var m map[string]interface{}
|
||||||
|
_ = json.Unmarshal(raw, &m)
|
||||||
|
ctx := &sdk.StageContext{Extra: map[string]interface{}{qqAuthExtraKey: m}}
|
||||||
|
got, ok := authOnFrame(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("降权身份应能重建")
|
||||||
|
}
|
||||||
|
if got.Owner {
|
||||||
|
t.Fatal("owner=false 经往返绝不能变 true(提权=安全漏洞)")
|
||||||
|
}
|
||||||
|
if !got.IsGroup || got.GroupID != 258962824 {
|
||||||
|
t.Fatalf("群身份字段失真: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 3:半截身份(缺键)按未绑定处理 —— 宁回落全局也不猜。
|
||||||
|
// 每个必填键都单独缺失一次:任何一个被放过,降权/身份锚定就有一条缝。
|
||||||
|
func TestAuthOnFramePartialMapIsRejected(t *testing.T) {
|
||||||
|
full := map[string]interface{}{
|
||||||
|
"active": true, "owner": true, "message_id": float64(1),
|
||||||
|
"user_id": float64(1), "group_id": float64(0), "is_group": false,
|
||||||
|
}
|
||||||
|
for _, missing := range []string{"active", "owner", "message_id", "user_id", "group_id", "is_group"} {
|
||||||
|
m := map[string]interface{}{}
|
||||||
|
for k, v := range full {
|
||||||
|
if k != missing {
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx := &sdk.StageContext{Extra: map[string]interface{}{qqAuthExtraKey: m}}
|
||||||
|
if _, ok := authOnFrame(ctx); ok {
|
||||||
|
t.Fatalf("缺 %s: 半截身份应按未绑定处理(回落全局),却解析成功", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 类型错同样拒绝(float64 约定被破坏 = 旧桥/脏数据)。
|
||||||
|
bad := map[string]interface{}{
|
||||||
|
"active": true, "owner": true, "message_id": float64(1),
|
||||||
|
"user_id": "10001", "group_id": float64(0), "is_group": false,
|
||||||
|
}
|
||||||
|
ctx := &sdk.StageContext{Extra: map[string]interface{}{qqAuthExtraKey: bad}}
|
||||||
|
if _, ok := authOnFrame(ctx); ok {
|
||||||
|
t.Fatal("user_id 类型错应拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 4:帧上身份优先于全局身份 —— 绑帧存在时全局只作兜底。
|
||||||
|
func TestFrameIdentityBeatsGlobalIdentity(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886}
|
||||||
|
ctx := &sdk.StageContext{
|
||||||
|
Extra: map[string]interface{}{
|
||||||
|
qqAuthExtraKey: qqAuthContext{Active: true, Owner: false, UserID: 3162251442, IsGroup: true, GroupID: 258962824},
|
||||||
|
},
|
||||||
|
ToolCalls: []sdk.ToolCall{{Name: "memory_commit"}},
|
||||||
|
}
|
||||||
|
p.beforeToolcall(ctx)
|
||||||
|
if ctx.Response == nil || !strings.Contains(*ctx.Response, "拒绝") {
|
||||||
|
t.Fatalf("帧上非 owner 调硬私工具应被拒(全局 owner 不得放行): %v", ctx.Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反向:全局非 owner,帧上 owner → 放行(owner 被误拒的原始故障)。
|
||||||
|
p2 := newPermissionTestPlugin(t)
|
||||||
|
p2.auth = qqAuthContext{Active: true, Owner: false, UserID: 3162251442}
|
||||||
|
ctx2 := &sdk.StageContext{
|
||||||
|
Extra: map[string]interface{}{
|
||||||
|
qqAuthExtraKey: qqAuthContext{Active: true, Owner: true, UserID: 2198972886},
|
||||||
|
},
|
||||||
|
ToolCalls: []sdk.ToolCall{{Name: "memory_commit"}},
|
||||||
|
}
|
||||||
|
p2.beforeToolcall(ctx2)
|
||||||
|
if ctx2.Response != nil {
|
||||||
|
t.Fatalf("帧上 owner 调硬私工具应放行(2026-10-12 owner 被误拒的故障场景): %s", *ctx2.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -56,7 +56,7 @@ const (
|
|||||||
// ---- StageContext 段内部布局(与内核 internal/plugin/proc/shm.go 一致)----
|
// ---- StageContext 段内部布局(与内核 internal/plugin/proc/shm.go 一致)----
|
||||||
|
|
||||||
const (
|
const (
|
||||||
shmStageFieldCount = 18
|
shmStageFieldCount = 19
|
||||||
shmSliceSize = 8
|
shmSliceSize = 8
|
||||||
|
|
||||||
shmOffMagic = 0
|
shmOffMagic = 0
|
||||||
@ -68,7 +68,7 @@ const (
|
|||||||
shmOffSeq = 24
|
shmOffSeq = 24
|
||||||
|
|
||||||
shmMagic = 0x48415348
|
shmMagic = 0x48415348
|
||||||
shmVersion = 1
|
shmVersion = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
// 字段索引(顺序须与内核 stageField 枚举一致)
|
// 字段索引(顺序须与内核 stageField 枚举一致)
|
||||||
@ -91,8 +91,35 @@ const (
|
|||||||
fExtraMediaType
|
fExtraMediaType
|
||||||
fExtraInputSource
|
fExtraInputSource
|
||||||
fExtraOutputChannel
|
fExtraOutputChannel
|
||||||
|
// fExtraBag:Extra 里具名 4 键之外的全部插件自建键(整体 JSON)。
|
||||||
|
// 插件可写:绑定帧态(如 qq_auth)就存这里;跨 stage 经内核回读存活。
|
||||||
|
fExtraBag
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// shmNamedExtraKeys 是被提升为具名字段的 Extra 键;bag 打平时必须排除,
|
||||||
|
// 否则同一键会被具名字段与 bag 各带一份。
|
||||||
|
var shmNamedExtraKeys = map[string]bool{
|
||||||
|
"media_blocks": true, "media_type": true, "input_source": true, "output_channel": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// shmBagFromExtra 收集 Extra 里非具名键;无额外键返回 nil。
|
||||||
|
func shmBagFromExtra(extra map[string]interface{}) map[string]interface{} {
|
||||||
|
if len(extra) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var bag map[string]interface{}
|
||||||
|
for k, v := range extra {
|
||||||
|
if shmNamedExtraKeys[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bag == nil {
|
||||||
|
bag = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
bag[k] = v
|
||||||
|
}
|
||||||
|
return bag
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
flagNoMemory = 0
|
flagNoMemory = 0
|
||||||
flagResponseSet = 1
|
flagResponseSet = 1
|
||||||
@ -518,6 +545,15 @@ func readStageContext() (*sdk.StageContext, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extra := map[string]interface{}{}
|
extra := map[string]interface{}{}
|
||||||
|
// bag 先打平,具名键随后覆盖 → 具名键永远赢(与内核 ReadInto 同构;
|
||||||
|
// 防御 bag 里混入具名键时用陈旧值顶掉具名字段)。
|
||||||
|
var bag map[string]interface{}
|
||||||
|
if err := unmarshalField(fExtraBag, &bag); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for k, v := range bag {
|
||||||
|
extra[k] = v
|
||||||
|
}
|
||||||
for _, pair := range []struct {
|
for _, pair := range []struct {
|
||||||
field int
|
field int
|
||||||
key string
|
key string
|
||||||
@ -580,6 +616,8 @@ func takeStageSnapshot(sc *sdk.StageContext) *stageSnapshot {
|
|||||||
sn.jsons[fMemory] = marshal(sc.Memory, len(sc.Memory))
|
sn.jsons[fMemory] = marshal(sc.Memory, len(sc.Memory))
|
||||||
sn.jsons[fTokenUsage] = marshal(sc.TokenUsage, len(sc.TokenUsage))
|
sn.jsons[fTokenUsage] = marshal(sc.TokenUsage, len(sc.TokenUsage))
|
||||||
sn.jsons[fErrors] = marshal(sc.Errors, len(sc.Errors))
|
sn.jsons[fErrors] = marshal(sc.Errors, len(sc.Errors))
|
||||||
|
bag := shmBagFromExtra(sc.Extra)
|
||||||
|
sn.jsons[fExtraBag] = marshal(bag, len(bag))
|
||||||
|
|
||||||
if sc.Response != nil {
|
if sc.Response != nil {
|
||||||
sn.response = *sc.Response
|
sn.response = *sc.Response
|
||||||
|
|||||||
Reference in New Issue
Block a user