From 4af07d08fea58a92639084d56840c979064df489 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 27 Oct 2026 11:49:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory,llm):=20L2=20=E9=A2=84=E5=A1=AB?= =?UTF-8?q?=E5=85=85=E5=B9=B6=E5=85=A5=E5=8F=AC=E5=9B=9E=E4=B8=BB=E8=B7=AF?= =?UTF-8?q?=20+=20=E5=9B=9E=E9=80=80=E6=8D=A2=20provider=20=E6=97=B6?= =?UTF-8?q?=E6=8D=A2=20model=EF=BC=88=E8=A7=86=E8=A7=89=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E7=9C=9F=E5=9B=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. L2(冷上下文)预填充此前只走「注入文本」,等于没有 设计:prune 与 recall 是一对 —— 工具声明 ContextPolicy=prune + RecallPolicy=auto 时,工具输出既触发裁剪、也触发预填充(memoryPass → recallTextFor)。 但 L2 那一路只写在 buildSystemPrompt 里,query 用的是**注入文本**; QQ 注入的是「来自「X」的私聊消息(message_id=…),先用 qq_get_message 取正文」, 与用户真正说的话毫无相似度 ⇒ 冷上下文预填充恒为空。 (现场:头天晚上讲的冷笑话,第二天早上完全唤不回;14 小时里 memory recall (input…) 只有 5 次、且 query 是元信息。) 改法:把 L2 检索并入 buildMemoryContext —— 输入路用输入文本、工具路用工具正文, 两条路共用同一次召回与预算;buildSystemPrompt 里那份重复注入删掉。 中断消息仍然 NoMemory + RecallPolicy=none(不裁剪、不召回、不进记忆), 这是设计要求,未改动。 ## 2. 回退换 provider 时必须换 model(视觉失效的真因) 现场日志: deepseek 402 Insufficient Balance → 回退 llmsproxy,但 req 仍带 model=「deepseek-flash」 → llmsproxy 403 model_not_allowed → llmsproxy 被整源标记 unavailable(冷却) → 视觉回退 resolveModalFallback 跳过它 ⇒「配置了视觉却说没有视觉」 两处修正: · 每个 provider 用自己的 model 发请求(model 是 provider 的属性) · 403 区分「模型不被该 key 允许」与「凭证失效」:前者只否一个 model, provider 仍可用于其他 model 与视觉回退 判据:internal/agent/core/provider_model_fallback_test.go --- .../core/provider_model_fallback_test.go | 42 +++++ internal/agent/core/task.go | 54 ++++++- internal/agent/core/tooldefs.go | 30 ++-- internal/sdk/events.go | 113 ++++++++------ internal/sdk/events_test.go | 3 +- internal/security/eventpolicy.go | 147 ++++++++++++++++++ internal/security/eventpolicy_test.go | 102 ++++++++++++ internal/security/runtime.go | 34 ++-- third_party/homeagent-sdk/sdk/events.go | 66 ++++++++ 9 files changed, 520 insertions(+), 71 deletions(-) create mode 100644 internal/agent/core/provider_model_fallback_test.go create mode 100644 internal/security/eventpolicy.go create mode 100644 internal/security/eventpolicy_test.go create mode 100644 third_party/homeagent-sdk/sdk/events.go diff --git a/internal/agent/core/provider_model_fallback_test.go b/internal/agent/core/provider_model_fallback_test.go new file mode 100644 index 00000000..177a8df7 --- /dev/null +++ b/internal/agent/core/provider_model_fallback_test.go @@ -0,0 +1,42 @@ +package core + +// provider 回退时**model 必须跟着 provider 走** + 403 分两类处置。 +// +// 真机现场(2026-10-27): +// deepseek 402 余额不足 +// → 回退 llmsproxy,但 req 里仍带 model="deepseek-flash" +// → llmsproxy 报 403 model_not_allowed +// → llmsproxy 被整源标记 unavailable(冷却) +// → 视觉回退 resolveModalFallback 跳过它 +// → 用户看到"配置了视觉能力却说没有视觉" +// +// 这两条判据分别钉住:换 provider 要换 model;model 级错误不能把整源判死。 + +import ( + "errors" + "testing" +) + +func TestIsModelNotAllowedRecognizesGatewayPhrases(t *testing.T) { + cases := []struct { + msg string + want bool + }{ + {`api error 403: {"error":{"message":"model \"deepseek-flash\" is not allowed for this key","type":"model_not_allowed"}}`, true}, + {`api error 403: {"error":{"type":"model_not_allowed"}}`, true}, + {`api error 404: {"error":{"message":"model_not_found"}}`, true}, + {`api error 400: The model ` + "`gpt-9`" + ` does not exist or you do not have access to it`, true}, + {`api error 401: invalid api key`, false}, // 凭证问题:整源不可用 + {`api error 403: forbidden`, false}, // 权限问题:整源不可用 + {``, false}, + } + for _, c := range cases { + var err error + if c.msg != "" { + err = errors.New(c.msg) + } + if got := isModelNotAllowed(err); got != c.want { + t.Errorf("isModelNotAllowed(%q) = %v, want %v", c.msg, got, c.want) + } + } +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index a56727c5..adf6b392 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -1353,6 +1353,24 @@ func (a *Agent) callLLMWithFallback(req *agentAPI.CompletionRequest, providers [ fbProvider.Name(), pi, len(providers)-1) } + // ★ 每个 provider 必须用**自己的** model 发请求(2026-10-27 真机)。 + // + // 现场:默认源 deepseek(model=deepseek-flash) 402 → 回退 llmsproxy(model=AUTO) + // 时复用了同一个 req ⇒ llmsproxy 收到 model="deepseek-flash" ⇒ 403 + // model_not_allowed ⇒ 两个源全挂,且 llmsproxy 被整源标记不可用(冷却) + // ⇒ 视觉回退跳过它 ⇒ 用户看到"配置了视觉却说没有视觉"。 + // + // model 是 **provider 的属性**,不是请求的共享字段。 + if rp, ok := fbProvider.(agentAPI.RoutableProvider); ok { + if m := rp.Model(); m != "" && m != req.Model { + if pi > 0 { + log.Printf("[agent] fallback provider %q uses its own model %q (was %q)", + fbProvider.Name(), m, req.Model) + } + req.Model = m + } + } + // 同源瞬时错误重试:网关瞬断(502/503/504/429/网络抖动)通常秒级恢复, // 直接跳下一个 provider(或直接报错)会丢掉本可成功的请求。 // 凭证错误(401/403)与用户中断不重试。 @@ -1415,8 +1433,17 @@ func (a *Agent) callLLMWithFallback(req *agentAPI.CompletionRequest, providers [ } var pe *agentAPI.ProviderError if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) { - a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode) - log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode) + // 403 分两类:凭证/权限问题 ⇒ 整源不可用;**模型名不被该 key 允许** + // (model_not_allowed)⇒ 只是这个 model 不行,provider 仍可用于 + // 其他 model,也包括视觉回退。混为一谈会让一次模型名不匹配把整源判死。 + if isModelNotAllowed(llmErr) { + log.Printf("[agent] provider %q rejected model %q (HTTP %d) — "+ + "model-level issue, provider stays available: %v", + fbProvider.Name(), req.Model, pe.StatusCode, llmErr) + } else { + a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode) + log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode) + } } else { a.providerManager.MarkUnavailable(fbProvider.Name()) } @@ -1485,3 +1512,26 @@ func stageHint(ctx *sdk.StageContext, key string) string { v, _ := ctx.Extra[key].(string) return strings.TrimSpace(v) } + +// isModelNotAllowed 判断错误是不是"这个模型名不被该 key 允许"。 +// +// 与"凭证失效"处置相反:前者只否掉一个 model(provider 仍可服务其他 model, +// 也仍可用于视觉回退),后者才该把整源标记不可用。 +func isModelNotAllowed(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + for _, m := range []string{ + "model_not_allowed", + "is not allowed for this key", + "model not allowed", + "model_not_found", + "does not exist or you do not have access", + } { + if strings.Contains(msg, m) { + return true + } + } + return false +} diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index d7f17d8b..784008ab 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -37,6 +37,25 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string) s += "[Related media]\n" + mc } + // ★ L2(冷上下文)相似度预填充:**必须在这里做**,不能只在上层 buildSystemPrompt。 + // + // 设计里 prune 与 recall 是一对:工具声明 ContextPolicy=prune + RecallPolicy=auto + // 时,工具输出既触发裁剪也触发预填充(memoryPass → recallTextFor → 本函数)。 + // 而此前 L2 那一路只写在 buildSystemPrompt 里、且 query 是**注入文本** + // (QQ 是"来自「X」的私聊消息(message_id=…),先用 qq_get_message 取正文"), + // 跟用户真实说的话毫不相似 ⇒ 冷上下文预填充等于没有 + //(2026-10-27 现场:头天晚上讲的冷笑话第二天早上完全唤不回)。 + // + // 放进本函数后:输入路用输入文本、工具路用工具正文,两条路都能召回 L2 摘要。 + if a.docStore != nil { + if md := a.crossModalMarkdown(a.retrieveCrossModal(input, 3, a.fusionCfg)); md != "" { + if s != "" { + s += "\n" + } + s += md + } + } + if maxTokens > 0 { s = TruncateByTokens(s, maxTokens) } @@ -200,15 +219,8 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string { //(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。 prompt = appendPromptBlock(prompt, "[Memory maintenance] To tidy or clear memory, call the memory_* tools - a described plan is not the work. Report the result in one sentence.") - // 跨模态召回:文本路(fastText/TF-IDF 文档层,媒体描述文本已随记忆进入) - // + 视觉路(多模态文本编码 → 媒体库坐标)两路归一化融合。 - // 未配置多模态空间时视觉路为空,等价旧的 docStore.Query。 - if a.docStore != nil { - hits := a.retrieveCrossModal(userInput, 3, a.fusionCfg) - if md := a.crossModalMarkdown(hits); md != "" { - prompt = appendPromptBlock(prompt, md) - } - } + // L2(冷上下文)预填充已并入 buildMemoryContext(输入路/工具路共用一次), + // 这里不再重复注入,否则同一批摘要会出现两遍。 prompt = appendPromptBlock(prompt, "[Interrupt] During long tasks a system message prefixed [Interrupt] may arrive (new message, terminal output, timer). It is not user speech, but handle it: pause, respond, or consciously continue.") diff --git a/internal/sdk/events.go b/internal/sdk/events.go index a8f33d37..fa964820 100644 --- a/internal/sdk/events.go +++ b/internal/sdk/events.go @@ -2,7 +2,6 @@ package sdk import ( "fmt" - "strings" "github.com/JianFeeeee/HologramAgent/internal/events" "github.com/JianFeeeee/HologramAgent/internal/security" @@ -40,76 +39,75 @@ const ( // ───────────────────── 受限事件总线(插件可见) ───────────────────── // -// 边界由三件事决定,缺一不可: -// 1. 类型白名单 + 插件自己的命名空间(`plugin..*`) -// 2. 载荷出向经 security 审查(明文凭据换成 @{别名})+ 键数上限 -// 3. 订阅只开只读事件(tools_ready / security_ready / device_ready) +// **本层只做适配,不做策略**(用户 2026-10-27 的指正): // -// 为什么不直接把 *events.Bus 交给插件:Bus.Publish 能发 EventAll 通配类型、 -// 能伪造任意 Source、载荷不做任何审查。那不是"多给一个便利",而是把内核 -// 内部同步面变成没有边界的公共写口。 - -const ( - // eventPayloadMaxKeys 限制载荷顶层键数,防止"把一个对象当事件"塞进来。 - // 事件是**通知**,不是数据通道:大块数据该走插件自己的通道或工具返回值。 - eventPayloadMaxKeys = 64 -) +// internal/security 谁可以发/订阅什么事件(EventPolicy)+ 载荷过闸 +// internal/sdk(本文件) 查策略 → 发/订 → 过闸 → 落到 *events.Bus +// sdk(公开) 只给接口与事件名 +// +// 为什么策略必须住在 security:事件的边界就是**授权**。能发 skill_detected +// 等于能驱动 skillmgr 接管;能发 update_request 等于能触发内核升级流程。 +// 这类判断与工具黑白名单是同一类,必须与它们同处一处,否则策略会有两份真相, +// 换个实现就换口径。 type eventsAPI struct { pluginName string bus *events.Bus - sec *security.Runtime // 可 nil(安全模块未装配时零成本跳过) + sec *security.Runtime // 可 nil:未装配时按**默认策略**(最小集合)判定 unsubs []func() + // unavailable 非空表示宿主没接总线:Publish/Subscribe 一律返回该原因。 + unavailable string } -// newEventsAPI 构造插件的受限事件面(bus 为 nil 时返回 nil,调用方按 nil 处理)。 +// newEventsAPI 构造插件的受限事件面。 +// +// ★ bus 为 nil 时返回**不可用实现**而不是 nil: +// 返回 nil 会让 `s.Events().Publish(...)` 在未接总线的宿主里直接 panic +// (实测:internal/plugins 的集成测试在 loadOne 阶段崩在 clawhubadapter 上)。 +// 插件不该为"宿主没接总线"写 nil 判断——那是一个该报错、而不是该崩溃的状态。 func newEventsAPI(pluginName string, bus *events.Bus) pubsdk.EventsAPI { + api := &eventsAPI{pluginName: pluginName, bus: bus, sec: security.RuntimeGlobal()} if bus == nil { - return nil + api.unavailable = "event bus is not wired in this host" } - return &eventsAPI{pluginName: pluginName, bus: bus, sec: security.RuntimeGlobal()} + return api } func (e *eventsAPI) AllowedPublish() []string { - out := pubsdk.PluginPublishableEvents() + pol := e.EventPolicy() + out := make([]string, len(pol.Publishable)) + copy(out, pol.Publishable) return append(out, "plugin."+e.pluginName+".*") } -func (e *eventsAPI) AllowedSubscribe() []string { return pubsdk.PluginSubscribableEvents() } - -// publishAllowed:白名单精确匹配,或本插件命名空间前缀(plugin..<后缀>)。 -func (e *eventsAPI) publishAllowed(t string) bool { - for _, a := range pubsdk.PluginPublishableEvents() { - if t == a { - return true - } - } - prefix := "plugin." + e.pluginName + "." - return strings.HasPrefix(t, prefix) && len(t) > len(prefix) +func (e *eventsAPI) AllowedSubscribe() []string { + pol := e.EventPolicy() + out := make([]string, len(pol.Subscribable)) + copy(out, pol.Subscribable) + return out } -func (e *eventsAPI) subscribeAllowed(t string) bool { - for _, a := range pubsdk.PluginSubscribableEvents() { - if t == a { - return true - } +// EventPolicy 取当前策略:优先用 security Runtime 的(可热改), +// Runtime 为 nil 时退回 security 包的默认策略(同样是最小集合,失败方向收紧)。 +func (e *eventsAPI) EventPolicy() security.EventPolicy { + if e.sec != nil { + return e.sec.EventPolicy() } - return false + return security.DefaultEventPolicy() } func (e *eventsAPI) Publish(eventType string, payload map[string]interface{}) error { - if eventType == "" { - return fmt.Errorf("events: event type must not be empty") + if e.unavailable != "" { + return fmt.Errorf("events: %s", e.unavailable) } - if !e.publishAllowed(eventType) { - return fmt.Errorf("events: plugin %q may not publish %q (allowed: %v)", - e.pluginName, eventType, e.AllowedPublish()) + if d := e.checkPublish(eventType); !d.Allowed { + return fmt.Errorf("%s", d.Reason) } if payload == nil { payload = map[string]interface{}{} } - if len(payload) > eventPayloadMaxKeys { - return fmt.Errorf("events: payload has %d keys, limit is %d", len(payload), eventPayloadMaxKeys) + if d := e.checkSize(len(payload)); !d.Allowed { + return fmt.Errorf("%s", d.Reason) } // ★ 载荷出向审查:事件载荷会被其他订阅者(含 WebUI/状态面)读到, @@ -122,6 +120,27 @@ func (e *eventsAPI) Publish(eventType string, payload map[string]interface{}) er return nil } +func (e *eventsAPI) checkPublish(t string) security.Decision { + if e.sec != nil { + return e.sec.CheckEventPublish(e.pluginName, t) + } + return security.DefaultEventPolicy().CheckEventPublish(e.pluginName, t) +} + +func (e *eventsAPI) checkSubscribe(t string) security.Decision { + if e.sec != nil { + return e.sec.CheckEventSubscribe(e.pluginName, t) + } + return security.DefaultEventPolicy().CheckEventSubscribe(e.pluginName, t) +} + +func (e *eventsAPI) checkSize(keys int) security.Decision { + if e.sec != nil { + return e.sec.CheckEventPayloadSize(keys) + } + return security.DefaultEventPolicy().CheckEventPayloadSize(keys) +} + // scrubOut 对载荷做一次出向脱敏(安全模块未装配时原样返回)。 func (e *eventsAPI) scrubOut(eventType string, payload map[string]interface{}) map[string]interface{} { if e.sec == nil { @@ -145,9 +164,11 @@ func (e *eventsAPI) scrubOut(eventType string, payload map[string]interface{}) m } func (e *eventsAPI) Subscribe(eventType string, handler func(map[string]interface{})) (func(), error) { - if !e.subscribeAllowed(eventType) { - return nil, fmt.Errorf("events: plugin %q may not subscribe to %q (allowed: %v)", - e.pluginName, eventType, e.AllowedSubscribe()) + if e.unavailable != "" { + return nil, fmt.Errorf("events: %s", e.unavailable) + } + if d := e.checkSubscribe(eventType); !d.Allowed { + return nil, fmt.Errorf("%s", d.Reason) } if handler == nil { return nil, fmt.Errorf("events: handler must not be nil") diff --git a/internal/sdk/events_test.go b/internal/sdk/events_test.go index fccda6ec..6bdf58d7 100644 --- a/internal/sdk/events_test.go +++ b/internal/sdk/events_test.go @@ -127,8 +127,9 @@ func TestEventsPayloadIsScrubbed(t *testing.T) { // ⑥ 键数上限:事件是通知,不是数据通道。 func TestEventsPayloadKeyLimit(t *testing.T) { e, _ := newTestEvents(t, "big") + limit := security.DefaultEventPolicy().MaxPayloadKeys p := map[string]interface{}{} - for i := 0; i < eventPayloadMaxKeys+1; i++ { + for i := 0; i < limit+1; i++ { p[string(rune('a'+i%26))+strings.Repeat("x", i)] = i } if err := e.Publish("skill_detected", p); err == nil { diff --git a/internal/security/eventpolicy.go b/internal/security/eventpolicy.go new file mode 100644 index 00000000..2066cfd2 --- /dev/null +++ b/internal/security/eventpolicy.go @@ -0,0 +1,147 @@ +package security + +// 事件总线策略:**谁能发/订阅什么事件**。 +// +// ## 为什么它属于 security 模块(用户 2026-10-27) +// +// 事件的边界不是"总线的一个参数",而是**授权**:一个插件能发 skill_detected +// 就等于能驱动 skillmgr 接管;能发 update_request 就等于能触发内核升级流程。 +// 这类判断和工具黑白名单是同一类东西,理应放在同一处(security),而不是 +// 散在 SDK 实现里 —— 否则策略会出现两份真相,且换实现就换口径。 +// +// 分工: +// +// internal/security 策略(本文件)+ 载荷过闸(PrepareOutbound/ScrubInbound) +// internal/sdk 总线适配:查策略 → 发/订 → 过闸 +// sdk(公开) 只给接口与事件名,不给策略 +// +// ## 为什么"关闭 security"不影响本策略 +// +// `core.security.enabled=false` 关掉的是**凭据替换**(需要主密钥), +// 而"插件不许发 raw_input"这类授权与密钥无关。所以 EventPolicy 挂在 +// Runtime 上,Disabled Runtime 同样持有默认策略 —— 关安全不等于开权限。 + +import "strings" + +// EventPolicy 是事件总线的授权策略。 +type EventPolicy struct { + // Publishable 是插件可发布的事件类型白名单。 + // + // ★ 加一项前先问:这个事件会不会让插件**间接驱动内核行为**? + // 会的话就不能加——事件是通知,不是未授权的执行入口。 + Publishable []string + // Subscribable 是插件可订阅的**只读**事件白名单。 + Subscribable []string + // MaxPayloadKeys 是载荷顶层键数上限(事件是通知,不是数据通道)。 + MaxPayloadKeys int +} + +// DefaultEventPolicy 是当前的默认授权表。 +func DefaultEventPolicy() EventPolicy { + return EventPolicy{ + Publishable: []string{ + // 兼容层(openclaw/clawhub 目录扫描)把发现的纯 SKILL 条目交给 + // skillmgr 接管:这是**职责链交接**,不是执行指令。 + "skill_detected", + }, + Subscribable: []string{ + // tools_ready:全部插件加载完、工具注册齐("需要工具才能做的事"的起点) + "tools_ready", + // security_ready:安全闸门装配完成(含"是否真的在生效") + "security_ready", + // device_ready:工具/设备数据面就绪 + "device_ready", + }, + MaxPayloadKeys: 64, + } +} + +// pluginNamespacePrefix 返回某个插件**自己的**事件命名空间前缀。 +// +// 插件的私有事件必须在这个前缀下(`plugin..<后缀>`), +// 这样"两个插件协作"不需要新增白名单,也不可能冒用别人的名字。 +func pluginNamespacePrefix(plugin string) string { + return "plugin." + plugin + "." +} + +// CheckEventPublish 判定插件能否发布某个事件。 +func (p EventPolicy) CheckEventPublish(plugin, eventType string) Decision { + if plugin == "" { + return deny("events: publisher name is empty") + } + if eventType == "" { + return deny("events: event type must not be empty") + } + for _, a := range p.Publishable { + if eventType == a { + return allowed() + } + } + prefix := pluginNamespacePrefix(plugin) + if strings.HasPrefix(eventType, prefix) && len(eventType) > len(prefix) { + return allowed() + } + return deny("events: plugin %q may not publish %q (allowlist: %v, own namespace: %s*)", + plugin, eventType, p.Publishable, prefix) +} + +// CheckEventSubscribe 判定插件能否订阅某个事件。 +func (p EventPolicy) CheckEventSubscribe(plugin, eventType string) Decision { + if plugin == "" { + return deny("events: subscriber name is empty") + } + for _, a := range p.Subscribable { + if eventType == a { + return allowed() + } + } + return deny("events: plugin %q may not subscribe to %q (allowlist: %v)", + plugin, eventType, p.Subscribable) +} + +// CheckEventPayloadSize 判定载荷规模(键数)是否在允许范围。 +func (p EventPolicy) CheckEventPayloadSize(keys int) Decision { + max := p.MaxPayloadKeys + if max <= 0 { + max = 64 + } + if keys > max { + return deny("events: payload has %d keys, limit is %d", keys, max) + } + return allowed() +} + +// ───────────────────── Runtime 上的对外入口 ───────────────────── + +// EventPolicy 返回当前事件策略(拷贝,调用方可安全读取)。 +func (r *Runtime) EventPolicy() EventPolicy { + if r == nil { + return DefaultEventPolicy() + } + return r.eventPolicy +} + +// SetEventPolicy 替换事件策略(配置重载时调用)。 +func (r *Runtime) SetEventPolicy(p EventPolicy) { + if r == nil { + return + } + r.eventPolicy = p +} + +// CheckEventPublish / CheckEventSubscribe / CheckEventPayloadSize 是内核侧 +// (internal/sdk 的事件适配层)唯一该调的入口。 +// +// nil Runtime 时退回默认策略 —— **失败方向是收紧**(默认表就是最小集合), +// 不是放行。 +func (r *Runtime) CheckEventPublish(plugin, eventType string) Decision { + return r.EventPolicy().CheckEventPublish(plugin, eventType) +} + +func (r *Runtime) CheckEventSubscribe(plugin, eventType string) Decision { + return r.EventPolicy().CheckEventSubscribe(plugin, eventType) +} + +func (r *Runtime) CheckEventPayloadSize(keys int) Decision { + return r.EventPolicy().CheckEventPayloadSize(keys) +} diff --git a/internal/security/eventpolicy_test.go b/internal/security/eventpolicy_test.go new file mode 100644 index 00000000..a3c3e4c9 --- /dev/null +++ b/internal/security/eventpolicy_test.go @@ -0,0 +1,102 @@ +package security + +// 事件总线**授权策略**的判据。 +// +// 用户 2026-10-27 的指正:白名单放行 / 越界拒绝属于**策略**,必须在 security +// 模块里,而不是散在 SDK 适配层。所以这些判据钉在 security 包上 —— +// 换实现(谁的适配层)都不能改变口径。 + +import "testing" + +// ① 白名单内的事件可发布。 +func TestEventPolicyAllowsListedPublish(t *testing.T) { + p := DefaultEventPolicy() + if d := p.CheckEventPublish("clawhubadapter", "skill_detected"); !d.Allowed { + t.Fatalf("白名单事件应放行: %s", d.Reason) + } +} + +// ② 越界类型必须拒绝,且理由里带白名单(否则调用方只能猜)。 +func TestEventPolicyRejectsUnlistedPublish(t *testing.T) { + p := DefaultEventPolicy() + for _, bad := range []string{ + "raw_input", // 用户输入 + "agent_output", // 对外输出 + "update_request", // 能驱动升级流程 + "stage", // 阶段管道内部 + "system", // 系统事件 + "", // 空 + } { + d := p.CheckEventPublish("any", bad) + if d.Allowed { + t.Errorf("发布 %q 应被拒绝", bad) + } + if d.Reason == "" { + t.Errorf("拒绝 %q 时必须给出理由", bad) + } + } +} + +// ③ 自己的命名空间允许(插件间协作),且必须有后缀、不能冒用别人。 +func TestEventPolicyOwnNamespace(t *testing.T) { + p := DefaultEventPolicy() + cases := []struct { + plugin, event string + want bool + }{ + {"seq", "plugin.seq.done", true}, + {"seq", "plugin.seq.", false}, // 只有前缀没有后缀 + {"seq", "plugin.seq", false}, // 没有点 + {"seq", "plugin.other.done", false}, // 冒用别人 + {"", "plugin.x.done", false}, // 没有发布者名 + } + for _, c := range cases { + if got := p.CheckEventPublish(c.plugin, c.event).Allowed; got != c.want { + t.Errorf("plugin=%q event=%q 期望 %v 实际 %v", c.plugin, c.event, c.want, got) + } + } +} + +// ④ 订阅只开只读事件。 +func TestEventPolicySubscribeReadOnlyOnly(t *testing.T) { + p := DefaultEventPolicy() + for _, ok := range []string{"tools_ready", "security_ready", "device_ready"} { + if d := p.CheckEventSubscribe("p1", ok); !d.Allowed { + t.Errorf("%q 应可订阅: %s", ok, d.Reason) + } + } + for _, bad := range []string{"raw_input", "agent_output", "update_request"} { + if d := p.CheckEventSubscribe("p1", bad); d.Allowed { + t.Errorf("订阅 %q 应被拒绝", bad) + } + } +} + +// ⑤ 载荷键数上限(事件是通知,不是数据通道)。 +func TestEventPolicyPayloadKeyLimit(t *testing.T) { + p := DefaultEventPolicy() + if d := p.CheckEventPayloadSize(p.MaxPayloadKeys); !d.Allowed { + t.Fatalf("达到上限应放行: %s", d.Reason) + } + if d := p.CheckEventPayloadSize(p.MaxPayloadKeys + 1); d.Allowed { + t.Fatal("超过上限应拒绝") + } +} + +// ⑥ Runtime 委托:策略挂在 Runtime 上,可替换;Disabled Runtime 也带默认策略 +// +// —— 关掉凭据替换不等于放开事件授权。 +func TestRuntimeEventPolicyDelegation(t *testing.T) { + r := NewDisabledRuntime() + if d := r.CheckEventPublish("p", "raw_input"); d.Allowed { + t.Fatal("Disabled Runtime 仍应拒绝越界事件(关安全≠开权限)") + } + custom := EventPolicy{Publishable: []string{"my_event"}, MaxPayloadKeys: 1} + r.SetEventPolicy(custom) + if d := r.CheckEventPublish("p", "my_event"); !d.Allowed { + t.Fatalf("替换策略后应放行: %s", d.Reason) + } + if d := r.CheckEventPayloadSize(2); d.Allowed { + t.Fatal("替换后的键数上限应生效") + } +} diff --git a/internal/security/runtime.go b/internal/security/runtime.go index a8b3e37f..beb3f695 100644 --- a/internal/security/runtime.go +++ b/internal/security/runtime.go @@ -57,6 +57,12 @@ type Runtime struct { profiles *profileRegistry // active 记录**当前活跃档位**(由 inputch 输入的 SecurityProfile 选择)。 active *profileActivator + // eventPolicy 是**事件总线授权表**(谁能发/订阅什么事件)。 + // + // ★ 它与凭据闸门无关:`core.security.enabled=false` 关掉的是凭据替换, + // 而"插件不许发 raw_input"这类授权始终生效 —— 所以 Disabled Runtime + // 同样持有默认策略(见 NewDisabledRuntime)。 + eventPolicy EventPolicy // principals 是**按主体**的能力策略(插件声明,内核执行)。 // // ★ 它替代的正是"插件自己在 stage 里拦工具"那种实现 @@ -71,13 +77,14 @@ func NewRuntime(store *Store) *Runtime { return NewDisabledRuntime() } return &Runtime{ - gate: NewGate(store), - enabled: true, - policy: DefaultPolicy(), - scopes: NewScopeRegistry(), - principals: newPrincipalRegistry(), - profiles: newProfileRegistry(), - active: &profileActivator{}, + gate: NewGate(store), + enabled: true, + policy: DefaultPolicy(), + eventPolicy: DefaultEventPolicy(), + scopes: NewScopeRegistry(), + principals: newPrincipalRegistry(), + profiles: newProfileRegistry(), + active: &profileActivator{}, } } @@ -86,12 +93,13 @@ func NewDisabledRuntime() *Runtime { // 即使禁用也装一个策略表:否则"关闭凭据防火墙"会连带让工具策略失效, // 而这是两件不同的事(用户可以只要策略、不要凭据替换)。 return &Runtime{ - enabled: false, - policy: DefaultPolicy(), - scopes: NewScopeRegistry(), - principals: newPrincipalRegistry(), - profiles: newProfileRegistry(), - active: &profileActivator{}, + enabled: false, + policy: DefaultPolicy(), + eventPolicy: DefaultEventPolicy(), + scopes: NewScopeRegistry(), + principals: newPrincipalRegistry(), + profiles: newProfileRegistry(), + active: &profileActivator{}, } } diff --git a/third_party/homeagent-sdk/sdk/events.go b/third_party/homeagent-sdk/sdk/events.go new file mode 100644 index 00000000..b1e29cf4 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/events.go @@ -0,0 +1,66 @@ +package sdk + +// 受限事件总线(EventsAPI)。 +// +// ## 为什么现在才敢暴露 +// +// 事件总线是内核内部机制:它连接内核模块、状态面、WebUI 与插件。 +// 早先不给插件的理由是**没有边界**——一给就是"任何插件能发任何事件、能订阅任何事件", +// 等于把内核内部的同步面变成公共 API,且没有任何审查。 +// +// 2026-10 之后内核侧有了 security 模块(凭据闸门 + 内容审查 + 审计), +// 于是可以只开**受限的一条缝**: +// +// · 发布:只能用白名单事件类型,且只能在自己的命名空间下扩展 +// · 载荷:出向经 security 审查(明文凭据会被换成 @{别名}),并限制大小 +// · 订阅:只能订阅白名单里的只读事件 +// +// ## 插件能做什么 +// +// Publish("skill_detected", {...}) // 交接类事件(兼容层 → skillmgr) +// Publish("plugin.<自己名字>.<任意>", {...}) // 自己的命名空间,供插件间协作 +// Subscribe("tools_ready", handler) // 等内核就绪再做事的入口 +// +// 越界会**报错**而不是静默丢弃:静默丢弃会让插件以为发成功了。 + +// EventsAPI 是插件可见的事件总线。 +// +// 它**不是** *events.Bus 的投影:这是一个有边界、有审查的接口。 +type EventsAPI interface { + // Publish 发布一个事件。 + // + // eventType 必须在白名单内(见 AllowedPublish),或形如 + // "plugin.<本插件名>.<后缀>"。载荷大小与键数有上限。 + Publish(eventType string, payload map[string]interface{}) error + + // Subscribe 订阅一个白名单内的事件(只读面)。 + // + // 返回的 cancel 在插件 Stop 时调用可解除订阅;宿主卸载插件时也会自动解除。 + // handler 的 panic 会被总线隔离,不影响其他订阅者。 + Subscribe(eventType string, handler func(payload map[string]interface{})) (cancel func(), err error) + + // AllowedPublish 返回当前可发布的事件类型(白名单 + 自己的命名空间前缀)。 + AllowedPublish() []string + + // AllowedSubscribe 返回当前可订阅的事件类型。 + AllowedSubscribe() []string +} + +// 常用事件名(**只是名字,不是授权表**)。 +// +// 授权表由内核 security 模块持有(`security.EventPolicy`)并可随配置调整; +// SDK 侧只声明"这些名字存在",避免插件作者拼错字符串。 +// +// 是否允许你用某个名字,以运行时 `Events().Publish/Subscribe` 的返回为准: +// 越界会**报错**,不会静默丢弃。 +const ( + // EventNameSkillDetected:兼容层把发现的纯 SKILL 条目交给 skillmgr 接管。 + // (职责链交接,不是执行指令) + EventNameSkillDetected = "skill_detected" + // EventNameToolsReady:全部插件加载完、工具注册齐(只读订阅)。 + EventNameToolsReady = "tools_ready" + // EventNameSecurityReady:安全闸门装配完成(只读订阅)。 + EventNameSecurityReady = "security_ready" + // EventNameDeviceReady:工具/设备数据面就绪(只读订阅)。 + EventNameDeviceReady = "device_ready" +)