feat(security): 插件按工具声明内容策略 + 目录名统一

## 一、插件声明层(用户裁定)

> 「敏感词策略也应该是插件声明啊,每个插件声明自己轮次以及自己工具的
>   敏感词策略」

SecurityProfile 新增两个字段:

  ToolSensitiveWords map[string][]string  // key=工具名模式(尾 *),value=词表
  DetectInjection    bool                 // 开启内核内置注入迹象识别

语义是**追加不覆盖**:整轮 SensitiveWords 仍然生效。
取宽不取窄的理由:当前处置是 B 档(标记+警示,不阻断),
多标一条只是多一句警示,漏标则是安全缺口。

## 二、修掉我自己造的「莲藕式分层」

前一版把 *security.Round 从 runInputTask 一路搬到工具链
(prepareInputTask(evt, round) → TaskFrame.SecRound →
executeToolCallOutcomeWithRound),并在 rtchild 里另补一条。

那是把**分层策略实现成跨层传参**:每多一条工具执行路径就要求调用方
多传一次句柄。profileActivator 的设计注释早就写明了正路:

> 工具调用发生在 LLM 决定之后,此时插件已不在调用栈上。
> 唯一可靠的传递方式是**内核在输入进来时记住**,在该轮的工具判定里使用。

改为 security 自取:

  Runtime.ScanContentForAgent(agentID, isChild, ins, text)
    → ForAgent(agentID, isChild)   // per-agent 视图,天然不串档
    → EffectiveProfile()           // session > longtime
    → 合并 全局词表 + 档位词表 + 工具词表 + DetectInjection

core 只递交**事实**(我是谁 / 哪个工具 / 什么文本),不认识 Round。
与 CheckToolForAgent 同一套身份推断,不引入第二个入口语义。
TaskFrame.SecRound / executeToolCallOutcomeWithRound 全部撤除。

## 三、目录名统一

third_party/homeagent-sdk → third_party/hologramagentsdk

module 与远端仓早已是 hologramagentsdk,只剩物理目录是遗留名。
同步改了 go.mod replace、构建/打包/部署脚本、5 处测试里的分段路径。
注意:这里改的是**主仓 vendored 的 SDK 源码路径**,example/ 由 SDK
独立仓自己跟踪,主仓不涉及。

## 判据

TestToolSensitiveWordsAppendOnlyForThatTool   词表只对目标工具生效 + 追加语义
TestToolSensitiveWordsUseAgentScope           security 按 agent scope 自取生效
(变异验证:删掉 ForAgent 查询 → 红)

## 验证

	go build ./...                 → 0
	go vet ./...                   → 0
	go test ./... -count=1 -race   → 62 ok / 0 FAIL / 0 DATA RACE
	gofmt -l internal/             → 干净
This commit is contained in:
JianFeeeee
2026-11-03 09:51:11 +08:00
parent 9ab19f7493
commit 81a0f5f598
53 changed files with 201 additions and 78 deletions

21
third_party/hologramagentsdk/LICENSE vendored Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 JianFeeeee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
third_party/hologramagentsdk/go.mod vendored Normal file
View File

@ -0,0 +1,3 @@
module github.com/JianFeeeee/hologramagentsdk
go 1.27.1

View File

@ -0,0 +1,131 @@
// Package meta 收集 HologramAgent SDK 的全部元数据。
// 版本号应与核心 meta.Version 保持一致。
package meta
var (
// Version 是 HologramAgent SDK 版本号。
// 通过 `-ldflags="-X github.com/JianFeeeee/homeagentsdk/meta.Version=vX.Y.Z"` 注入。
//
// 版本号语义:**SDK 版本跟随核心的中版本,patch 位恒为 .0**。
// 整条核心 1.1.x 线(1.1.0、1.1.1、1.1.7…)共用 SDK 1.1.0;
// 只有核心进入 1.2.0 这种中版本跃迁时 SDK 才升到 1.2.0。
// 这样插件开发者只需关心「我在为哪个中版本写插件」,
// 不必跟着核心的每个 bugfix 换 SDK 依赖(见 核心仓 docs/git-branching.md §七)。
//
// 1.0.0:插件运行模型从 C ABI 动态库改为子进程 + 共享内存。
// 公开 SDK 接口零改动,但产物形态变了(plugin.so → plugin.bin)。
// 1.1.0:多模态贯通插件边界。**全部是新增,无签名变更**:
// - Triple.SentenceText / Triple.MediaDigests
// - Doc.MediaDigests / Doc.Attachments、MediaAttachment
// - TextEvent.Attachments
// - DocMemoryAPI.InsertWithMedia
// - IOInjector 的 InjectInputMedia / InjectInputMediaSync /
// InjectInterruptMedia;PluginSDK 补上缺失的 SetToolBlocks 包装
// 同版修掉两处并发竞态(sdk/stress_test.go 的 -race 实证,不是理论风险):
// PluginSDK 的 API 字段与 autoRestart 标志此前无锁,而写方
// (内核注入 API、插件 SetAutoRestart)与读方(插件后台 goroutine
// 注入、内核 registry 读 AutoRestart)天然跨 goroutine。
// 存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**,
// 不调就不受影响。想用新字段的插件重编即可。
//
// 1.2.0:注入行为的记忆/裁剪标志位。**全部是新增,无签名变更**:
// - InjectOptions{NoMemory, ContextPolicy}
// - IOInjector 的六个 *Opts 变体(排队/中断/同步/带媒体各一对)
// - ChannelDef.ContextPolicy(顺带给 ChannelDef 补上 JSON tag:
// 它要跨进程传给内核,而 Cleaner 是函数必须忽略;无 tag 时只能
// 手写字段白名单,新增字段会被静默丢掉)
// 语义:零值 InjectOptions 与旧的三参数方法完全等价(记入记忆 +
// 不裁剪),因此存量插件不需要改一行也不需要重编。
// 裁剪(ContextPolicy=prune)必须显式声明——它会归档丢弃低相关事件。
//
// ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的
// release/vX.Y.x 分支与 tag(见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
//
// 现为 1.4.0:1.3.0 已随核心的正式 tag `v1.3.0` 定版并发版(本仓 tag v1.3.0、
// release/v1.3.x 承载它),该号从此归发布线所有,main 遂推进到下一个未发布中版本。
//
// ❗本仓**不发 patch tag**(§七.1):一个中版本只发一次 `vX.Y.0`,核心的 1.3.x
// 后续 patch **不伴随 SDK 发版** —— patch 位恒为 `.0`,带非零 patch 的 SDK tag
// 都是错的。(2026-09-13 曾误发 `v1.3.1`,已撤回;`v1.2.1` 是同一类历史遗留。)
//
Version = "1.4.0"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"
// BuildTime 是构建时间。
BuildTime = "unknown"
// SDKName 是 SDK 名称。
SDKName = "HologramAgent SDK"
// SDKModule 是本 SDK 仓**当前**的 Go module path。
//
// ★ 演进史(三次):`gitcode.com/JianFeeeee/homeagent-sdk`
// → `github.com/JianFeeeee/homeagentsdk`(迁到 GitHub)
// → `github.com/JianFeeeee/hologramagentsdk`(与仓名统一,2026-10)
//
// ★★ 本常量**必须与 go.mod 的 module 行逐字一致**(2026-10-29 实测缺陷):
//
// 它此前停在中间名 `github.com/JianFeeeee/homeagentsdk`,而 go.mod 已
// 推进到 `...hologramagentsdk`。后果不是"显示错",而是**模板工程编不出**:
//
// · hmapdev 用本常量生成插件 go.mod 的 require/replace
// · 而 SDK 内部(如 proc_main 运行时模板)引用的是 go.mod 的真名
// ⇒ 两个模块名并存,`go build` 报
// "no required module provides package .../sdk"
// ⇒ 用户照提示 `go get` 只会**再拉一份** SDK,问题依旧。
//
// 因此凡改 module 名,必须同时改 go.mod 与本常量。判据见
// `meta/meta_module_test.go`(钉住"与 go.mod 一致",仅认改名后的新名)。
//
// ❗存量插件的 go.mod 里仍可能是更早的名字,所以「识别 SDK 模块」的判定
// 必须**所有历史名都接受**(见 hmapdev 的 sdkModuleNames)——
// 只认最新名会让旧插件被当成「不是 SDK 模块」而静默走错分支。
// 注意区分:**识别**要宽容,**生成**要精确(就是本常量)。
SDKModule = "github.com/JianFeeeee/hologramagentsdk"
// LegacySDKModule 是迁移前的 SDK module path,仍需被识别。
LegacySDKModule = "gitcode.com/JianFeeeee/homeagent-sdk"
// CoreModule 是核心仓的 Go module path,供 hmapdev 生成 go.mod 时使用。
//
// ★ 2026-10-08 随主仓托管地迁移而更新:仓库在 GitHub
// (github.com/JianFeeeee/HologramAgent),module 路径同步。
// 注意它与 SDKModule 不是同一个仓:插件只依赖 SDK,**不依赖主仓**,
// 所以改本常量不影响任何已有插件。
CoreModule = "github.com/JianFeeeee/HologramAgent"
// CoreVersion 是此 SDK 所兼容的最低核心版本。
//
// 1.0.0 是硬下限而非建议值:0.9.x 内核只会 dlopen `.so`,
// 本版工具链产出的 `plugin.bin` 在旧内核上根本不会被识别。
//
// ⚠️ 1.1.0 新增的媒体接口需要核心 **1.1.1+**(更早的核心没有
// doc.insertWithMedia / io.injectMedia* 这些 RPC,调用会返回 unknown method)。
// 这里仍写 1.0.0,因为它是「SDK 能在其上运行」的下限;
// 媒体接口是可选能力,不用就不受影响。
//
// ⚠️ 1.2.0 新增的注入标志位同理需要核心 **1.2.0+**:内核在 1.2.0 之前会
// 忽略注入参数里的 no_memory/context_policy 字段(不会报错,但不生效)。
// 想用这些标志位的插件应当要求核心 1.2.0+;不用就不受影响。
CoreVersion = "1.0.0"
)
// FullVersion 返回完整的版本字符串。
func FullVersion() string {
return SDKName + " v" + Version + " (" + Commit + ")"
}
// ---- 协议兼容性 ----
//
// **不自定协议号**:兼容性只认双方版本号的**主版本段**。
// 主版本相同即兼容(1.x 内全是纯追加);跨主版本则是破坏性变更
// (如 1.0.0 从 C ABI 改子进程),握手时显式拒绝并提示重编。
//
// C ABI 时代的 ABIVersion / CABINum / 51 个 Core<Method> 整数 ID 已随
// Part 6.2 删除 internal/plugin/cabi/ 一并退场:
// - 整数 method id 平移为 method 名字符串(proc/protocol.go 的 Method* 常量)
// - 版本协商改为握手帧里的 core_version / sdk_version 主版本比对
//
// 保留那些常量只会让人以为它们还在生效。

View File

@ -0,0 +1,65 @@
package meta
// SDKModule 与 go.mod 的一致性命中判据。
//
// ## 为什么必须有(2026-10-29 实测缺陷,代价是"模板工程开箱编不出")
//
// 事实经过:
//
// go.mod: module github.com/JianFeeeee/hologramagentsdk ← 已改名
// meta.SDKModule = "github.com/JianFeeeee/homeagentsdk" ← 停在中间名
//
// 两者不一致时,`hmapdev init` 生成的工程会**同时**出现两个模块名:
//
// require github.com/JianFeeeee/homeagentsdk v1.4.0 (来自本常量)
// 而 z_proc_gen.go 里 import .../hologramagentsdk/sdk (来自 SDK 真实模块名)
//
// 结果 `hmapdev build` 报
//
// no required module provides package github.com/JianFeeeee/hologramagentsdk/sdk
//
// 而用户照提示执行 `go get` 只会**再拉一份** SDK —— 两个模块名并存,
// 问题原样保留。这类故障的恶劣之处:报错指向 import,真因在 meta.go。
//
// 所以本判据钉死"生成用的模块名 == go.mod 的模块名"。
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSDKModuleMatchesGoMod(t *testing.T) {
// meta/ 的上一级就是 SDK 仓根。
data, err := os.ReadFile(filepath.Join("..", "go.mod"))
if err != nil {
t.Fatalf("读 SDK go.mod 失败: %v", err)
}
var mod string
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "module ") {
mod = strings.TrimSpace(line[len("module "):])
break
}
}
if mod == "" {
t.Fatal("SDK go.mod 里没有 module 行")
}
if SDKModule != mod {
t.Fatalf("★ meta.SDKModule 与 go.mod 不一致:\n"+
" meta.SDKModule = %q\n"+
" go.mod module = %q\n"+
"两者不一致会让 hmapdev 生成的插件工程同时依赖两个 SDK 模块名,"+
"表现为 `no required module provides package .../sdk`。"+
"改 module 名时必须同时改这两处。", SDKModule, mod)
}
// 反向保护:LegacySDKModule 不得与当前名相同(否则"识别旧名"失去意义)。
if LegacySDKModule == SDKModule {
t.Fatal("LegacySDKModule 与 SDKModule 相同 —— 迁移前的名字应当不同")
}
}

View File

@ -0,0 +1,82 @@
package sdk
// 插件**启动形态**的声明契约(plg.json 的 auto_start / requires_facilities)。
//
// ## 三种启动形态
//
// 自动启动(缺省) auto_start 不写或 true
// 启用但不自动启动 auto_start: false ⇒ 待 agent 经 plugin_enable 拉起
// 禁用 registry 的持久开关 ⇒ 连 enable 都不允许
//
// 中间那种是本文件的主要目的:**按需加载**。
//
// ## 为什么要有"启用但不自动启动"
//
// 全部插件默认自启有两个具体代价(2026-10-18 实测):
//
// · 启动时间:22 个示例插件全部拉起(20 个是子进程),冷启动因此多出
// 数百毫秒到秒级,并常驻 24 个进程。
// · 上下文占用:173 个工具定义每轮请求都重发,实测约 2,809 tokens。
//
// 对**纯工具型**插件(weather / ocr / music / bili …)这些代价是白付的:
// 它们只在被调用时才做事,没人调用时只是静静占着槽位。
//
// ## ★ 但有一类插件**不能**按需加载
//
// 会**主动向 agent 注入输入**的插件必须常驻。判断依据是它是否调用了
// Inject* 系列 API 且由**外部事件**驱动(而不是 agent 调用触发):
//
// qq 监听 NapCat 推送,QQ 消息到达要立刻注入
// rss 轮询订阅源,新文章要主动推送
// calendar 定时检查提醒
// memo 定时检查待办到期
// a2a / acp 监听 TCP,外部 Agent 请求要应答
// browser 长命令回调 + ticker
// commondev sidecar 完成结果回投
//
// 把它们改成按需加载 ⇒ 功能**静默失效**:QQ 不响、提醒不来、
// 外部请求无人应答,而且**不报任何错**。这正是本仓反复吃亏的降级形态。
//
// 所以 auto_start 不是一个"性能调优开关",而是**行为契约**:
// 声明 false 等于说"没有人调用我时,我不需要做任何事"。
//
// ## 用法
//
// 本文件只提供**常量与说明**,实际声明写在插件的 plg.json:
//
// {
// "name": "weather",
// "auto_start": false, // ← 纯工具型:按需加载
// "requires_facilities": ["llm"] // 可选:依赖就绪即可提前启动
// }
//
// ★ 只声明 auto_start 而没有配套 skill,agent 就不知道"要先把插件拉起来",
// 结果是把一个能用的插件变成了永远不被使用的插件。**两者必须成对出现**:
// skill 里写清楚"本工具需 plugin_enable 后调用,用完可 plugin_disable"。
// AutoStart 是三种启动形态的取值说明(供文档与工具链共用)。
//
// plg.json 里写的是 **bool**(true/false),这里给的是它的语义名,
// 避免各处用裸 true/false 导致读者不知道 false 到底意味着
// "禁用"还是"启用但不自启"。
const (
// AutoStartOnKernel 表示伴随内核启动(缺少该字段时的默认形态)。
AutoStartOnKernel = true
// AutoStartOnDemand 表示启用但不自启:待 agent 显式拉起。
//
// ★ 用它的插件必须配 skill,否则 agent 无从得知要先拉起。
AutoStartOnDemand = false
)
// OnDemandNeedsSkill 说明 auto_start=false 的插件为什么必须有 skill。
//
// 这条不是"建议"而是**功能完整性的一部分**:
//
// 插件写了 auto_start=false 但没配 skill
// ⇒ 该插件的工具不出现在工具的初始清单里
// ⇒ 模型不知道它存在
// ⇒ 它永远不会被调用,也永远不会被拉起
//
// 也就是说,缺少 skill 的按需插件等价于**没装**,但比没装更糟 ——
// 它占着磁盘和安装记录,让人以为它可用。
const OnDemandNeedsSkill = true

View File

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

View File

@ -0,0 +1,85 @@
// Package sdk —— 内核**设施名**(插件声明启动时机时用的名字)。
//
// ## 用途
//
// 插件在 `plg.json` / `plugin.json` 里声明 `requires_facilities`,
// 内核据此决定**何时拉起它**(设计:kernel-modularization-design.md §2.6):
//
// 不声明 ⇒ 等内核**完全**初始化(默认;存量插件零改动)
// 声明 ["config"] ⇒ config 就绪即拉起(**尽早启动**)
// 声明 ["config","llm"]⇒ 两者都就绪才拉起
//
// ## ★ 为什么这些常量必须在**公开 SDK** 里
//
// 它们原本只定义在内核的 `internal/kernelmod`(Go 的 internal 规则禁止
// 外部包 import)⇒ 插件作者**无从得知**能写哪些名字,只能照文档抄,
// 而抄错(如 "llmservice" vs "llm")的后果是:
//
// 插件**永远等不到就绪** ⇒ **永不启动**,且无报错。
//
// 这正是本仓反复吃亏的"静默降级"形态。把名字放进公开 SDK 后:
//
// · 插件作者 import 常量 ⇒ **拼错是编译错误**(而不是运行期静默)
// · 且这是内核与插件之间的**契约**,理应公开
//
// ## 内核侧的一致性
//
// 内核在 `internal/kernelmod/facilities.go` 定义同名常量,
// 判据 `TestFacilityNamesMatchPublicSDK` 保证两边**逐字一致**
// (防止改一处忘另一处 —— 那会让插件声明的名字悄悄失效)。
package sdk
// 内核设施名(模块 Provides 的名字)。
//
// ★ 这些是**字符串值**,与内核 internal/kernelmod/facilities.go 必须逐字一致。
const (
// FacilityConfig 是配置快照(cfg)就绪。
FacilityConfig = "config"
// FacilityKernelIO 是 IOManager + EventBus 就绪。
FacilityKernelIO = "kernel-io"
// FacilityMemory 是图记忆(含索引/社交图/蒸馏)就绪。
FacilityMemory = "memory"
// FacilityLua 是 Lua 协议适配 VM 就绪。
FacilityLua = "lua"
// FacilitySupervisor 是代理生命周期守护就绪。
FacilitySupervisor = "supervisor"
// FacilityTracker 是变更追踪(overlayfs)就绪。
FacilityTracker = "tracker"
// FacilityTextMemory 是文本记忆就绪。
FacilityTextMemory = "textmem"
// FacilityLLM 是多源 Provider 管理就绪。
FacilityLLM = "llm"
// FacilityDocStore 是文档记忆就绪。
FacilityDocStore = "docmem"
// FacilityKnowledge 是知识库就绪。
FacilityKnowledge = "knowledge"
// FacilityMultimodal 是多模态向量空间就绪。
FacilityMultimodal = "multimodal"
// FacilityONNX 是依存句法分析器就绪。
FacilityONNX = "onnx"
// FacilityStageHost 是阶段管道 + 插件注册表就绪。
FacilityStageHost = "stagehost"
// FacilityAgent 是主 Agent 就绪(**最晚**的一个,通常在插件加载前)。
FacilityAgent = "agent"
// FacilitySecurity 是安全防火墙就绪。
//
// 它不是"某项能力",而是横跨 Core 与全部外部模块的数据面闸门:
// Core → 外部、外部 → Core 的每条数据流都经它审查。需要被安全审查后
// 才能访问外部模块的插件声明依赖它。
FacilitySecurity = "security"
// FacilityDevice 是设备/工具数据面就绪(工具注册与分发、inputch、outputch)。
FacilityDevice = "device"
)
// KnownFacilities 返回本 SDK 已知的全部设施名(供工具链校验插件声明)。
//
// ★ 它让**打包期**就能发现"插件声明了不存在的设施名" —— 比运行期发现早得多
// (运行期发现意味着用户已经装上了那个永不启动的插件)。
func KnownFacilities() []string {
return []string{
FacilityConfig, FacilityKernelIO, FacilityMemory, FacilityLua,
FacilitySupervisor, FacilityTracker, FacilityTextMemory, FacilityLLM,
FacilityDocStore, FacilityKnowledge, FacilityMultimodal, FacilityONNX,
FacilityStageHost, FacilityAgent, FacilitySecurity, FacilityDevice,
}
}

View File

@ -0,0 +1,37 @@
package sdk
// KernelControlAPI:插件请求**内核本身**重启或关机。
//
// ## 为什么需要它
//
// 有些状态变更只能靠重启内核生效(典型:改了 LLM 源、换了主密钥、
// 升级了要重新装配的配置)。没有这条通道时,用户只能自己去 ssh 上敲
// systemctl —— 而 WebUI 是大多数人唯一会用的界面。
//
// ## 语义边界(重要,别指望它做更多)
//
// 这两个方法都是**异步**的:它们只把请求投递给内核,立刻返回。
// 调用方(WebUI)应当先给用户一个明确回应("正在重启,页面稍后自动重连"),
// 而不是等待返回 —— 内核真的会在这几秒内消失。
//
// 进程会怎么走:
//
// RequestKernelRestart ⇒ 内核清理(停插件、flush、CloseAll)→ exit 42
// → guard 用**当前二进制**重拉,不经过 systemd、
// 不计失败轮次(这就是"热更新"那条路)
// RequestKernelShutdown ⇒ 内核清理 → 通知 guard 主动停机 → systemd 不再拉起
//
// ★ 关机为什么不能用"guard 退出"实现:systemd 是 Restart=always,
//
// guard 退出会被它无条件重新拉起。必须显式 `systemctl stop`)
//
// ## 失败语义
//
// 返回错误只表示**请求没被接受**(例如内核未装配控制通道、或该功能被禁用),
// 不表示"没重启成功"——成功投递后本进程就会退出,返回值本身没有后续。
type KernelControlAPI interface {
// RequestKernelRestart 请求重启内核(热重启,guard 重拉)。
RequestKernelRestart(reason string) error
// RequestKernelShutdown 请求关闭内核(systemd 不再拉起)。
RequestKernelShutdown(reason string) error
}

View File

@ -0,0 +1,22 @@
package sdk
// KnowledgeAPI provides access to the knowledge store.
type KnowledgeAPI interface {
Search(query string, topK int) ([]*Knowledge, error)
Add(name, content string) error
List() ([]string, error)
}
// Knowledge represents a knowledge entry.
type Knowledge struct {
Name string `json:"name"`
// Category 是该条目的父分类路径(如 "tech/go"),根下条目为空。
//
// 为何加这个字段:对外服务(kbtree)要做**暴露范围过滤**就必须知道
// 每条结果属于哪个分类 —— 过滤只能发生在服务端(客户端过滤等于
// 没过滤,范围外内容已经随响应发出去了)。
// 之前这里只有 Name/Content,内核明明返回了 Category 却在
// knowledge_impl.SearchIn 的拷贝里丢掉,导致外部无法按分类判定。
Category string `json:"category,omitempty"`
Content string `json:"content"`
}

View File

@ -0,0 +1,8 @@
package sdk
// LLMAPI provides access to the LLM provider manager.
type LLMAPI interface {
ListSources() []string
SetSource(name string) error
CurrentSource() string
}

View File

@ -0,0 +1,413 @@
-- HologramAgent Lua Plugin SDK
-- Interface contract between Lua plugins and HologramAgent kernel.
-- !impl functions are replaced by Go implementations at runtime.
-- Standalone/debug: pure Lua mock implementations are used.
-- Usage: local sdk = require("sdk")
sdk = {}
-- !impl
-- level: "debug" | "info" | "warn" | "error"
function sdk.log(level, msg)
print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg))
end
-- !impl
-- def: { description="...", parameters={...}, no_memory=true/false, cleaner=function(text)->text }
-- handler: function(args) -> result
function sdk.register_tool(name, def, handler)
print("[lua-plugin] register_tool: " .. tostring(name))
end
-- !impl
-- stage: "on_input" | "pre_action" | "post_action" | ...
-- scope: nil/"global" (默认) | "own_tools"(仅 before_toolcall/after_toolcall 且工具属于本插件时触发)
function sdk.register_stage(stage, handler, scope)
print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope))
end
-- !impl
function sdk.register_api(name)
print("[lua-plugin] register_api: " .. tostring(name))
end
-- !impl
-- def: { no_memory=true/false, cleaner=function(text)->text }
-- handler: function(args) -> result
function sdk.register_output_channel(name, caps, desc, def, handler)
print("[lua-plugin] register_output_channel: " .. tostring(name))
end
-- !impl
-- def: { no_memory=true/false, cleaner=function(text)->text }
function sdk.register_input_channel(name, def)
print("[lua-plugin] register_input_channel: " .. tostring(name))
end
-- !impl
function sdk.get_setting(key)
return nil
end
-- !impl
function sdk.set_setting(key, value)
print("[lua-plugin] set_setting: " .. tostring(key))
end
-- !impl
function sdk.inject_text(source, channel, text)
print("[lua-plugin] inject_text: " .. tostring(source) .. "/" .. tostring(channel))
end
-- !impl
function sdk.inject_interrupt(source, channel, text)
print("[lua-plugin] inject_interrupt: " .. tostring(source))
end
-- !impl
function sdk.inject_text_no_memory(source, channel, text)
print("[lua-plugin] inject_text_no_memory: " .. tostring(source))
end
-- !impl
-- opts: { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }
-- 零值/缺省 = 记入记忆 + 不裁剪(与三参数版本等价)。
function sdk.inject_text_opts(source, channel, text, opts)
print("[lua-plugin] inject_text_opts: " .. tostring(source))
end
-- !impl
function sdk.inject_interrupt_opts(source, channel, text, opts)
print("[lua-plugin] inject_interrupt_opts: " .. tostring(source))
end
-- !impl
-- 同步注入在 Lua 插件中**不可用**:会等本轮回复,而本轮正持有插件锁 ⇒ 必然自锁。
-- 真实内核里恒返回 (nil, err);这里返回同样的错误,避免离线测试误以为可用。
function sdk.inject_input_sync(source, channel, text)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt;确需同步等待请改用 Go 插件。"
end
-- !impl
function sdk.inject_input_sync_opts(source, channel, text, opts)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt;确需同步等待请改用 Go 插件。"
end
-- !impl
-- blocks: ContentBlock 数组,见 sdk.inject_input_media。
-- 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频)。
function sdk.set_tool_blocks(blocks)
print("[lua-plugin] set_tool_blocks: " .. tostring(blocks and #blocks or 0))
end
-- !impl
-- blocks 每项:{ type="text", text="..." }
-- | { type="image_url", image_url={ url="...", detail="high" } }
-- | { type="audio_url", audio_url={ url="..." } }
function sdk.inject_input_media(source, channel, text, blocks)
print("[lua-plugin] inject_input_media: " .. tostring(source))
end
-- !impl
function sdk.inject_input_media_opts(source, channel, text, blocks, opts)
print("[lua-plugin] inject_input_media_opts: " .. tostring(source))
end
-- !impl
-- 同 sdk.inject_input_sync:Lua 中不可用。
function sdk.inject_input_media_sync(source, channel, text, blocks)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media;确需同步等待请改用 Go 插件。"
end
-- !impl
function sdk.inject_input_media_sync_opts(source, channel, text, blocks, opts)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media_opts;确需同步等待请改用 Go 插件。"
end
-- !impl
function sdk.inject_interrupt_media(source, channel, text, blocks)
print("[lua-plugin] inject_interrupt_media: " .. tostring(source))
end
-- !impl
function sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)
print("[lua-plugin] inject_interrupt_media_opts: " .. tostring(source))
end
-- !impl
-- 注销输出通道(随资源生灭的动态通道,如远程设备)。返回 (nil, err)。
function sdk.unregister_output_channel(name) return nil, nil end
-- !impl
-- enabled: true/false,崩溃时内核自动拉起
function sdk.set_auto_restart(enabled)
print("[lua-plugin] set_auto_restart: " .. tostring(enabled))
end
-- ============ graph memory ============
-- !impl
sdk.memory = {}
-- !impl
-- query: string, depth: number -> {entities={...}, relations={...}}
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
-- !impl
-- triples: { {subject=, relation=, object=, [confidence=], [sentence_text=]} } -> err
function sdk.memory.commit(triples) return nil end
-- !impl
function sdk.memory.introspect() return {} end
-- !impl
function sdk.memory.merge(source, target) return 0 end
-- !impl
-- criteria: {key=value}, hard: boolean
function sdk.memory.purge(criteria, hard) return 0 end
-- ============ document memory ============
-- !impl
sdk.doc = {}
-- !impl
function sdk.doc.query(text, top_k) return {} end
-- !impl
-- doc: { id=, title=, content= }
function sdk.doc.insert(doc) return nil end
-- !impl
-- attachments 每项:{ digest=, mime=, name=, data=<base64> }
function sdk.doc.insert_with_media(doc, attachments) return nil end
-- !impl
function sdk.doc.remove(id) return nil end
-- !impl
function sdk.doc.stats() return {} end
-- ============ knowledge ============
-- !impl
sdk.knowledge = {}
-- !impl
function sdk.knowledge.search(query, limit) return {} end
-- !impl
function sdk.knowledge.add(tag, content) return nil end
-- !impl
function sdk.knowledge.list() return {} end
-- ============ text memory ============
-- !impl
sdk.text_memory = {}
-- !impl
-- evt: { timestamp=, role=, content=, channel= }
function sdk.text_memory.append(evt) return nil end
-- ============ llm ============
-- !impl
sdk.llm = {}
-- !impl
function sdk.llm.list_sources() return {} end
-- !impl
function sdk.llm.set_source(name) return nil end
-- !impl
function sdk.llm.current_source() return nil end
-- ============ social (只读) ============
-- !impl
sdk.social = {}
-- !impl
function sdk.social.get_person(name) return {} end
-- !impl
function sdk.social.get_network(name, depth) return {} end
-- !impl
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
-- !impl
function sdk.social.get_relations(name) return {} end
-- !impl
function sdk.social.list_persons() return {} end
-- ============ settings (作用域变体) ============
-- !impl
sdk.settings = {}
-- !impl
function sdk.settings.get_core(key) return nil end
-- !impl
function sdk.settings.set_core(key, value) return nil end
-- !impl
function sdk.settings.list_core(prefix) return {} end
-- !impl
function sdk.settings.get_plugin(plugin, key) return nil end
-- !impl
function sdk.settings.set_plugin(plugin, key, value) return nil end
-- !impl
function sdk.settings.list_plugin(plugin, prefix) return {} end
-- !impl
function sdk.settings.list(prefix) return {} end
-- !impl
-- def: { key=, type=, display_name=, description=, category=, options=, default=,
-- min=, max=, step=, required=, secret= }
function sdk.settings.register_def(def) return nil end
-- !impl
function sdk.settings.defs(prefix) return {} end
-- !impl
function sdk.settings.dump() return {} end
-- !impl
function sdk.settings.plugins() return {} end
-- ============ events(只读订阅) ============
-- !impl
-- subscribe(event_type, handler) -> unsubscribe()
-- handler 收到 { type=, source=, timestamp=, payload= };
-- 回调在其内核事件发布 goroutine 上执行,只做轻量转发,不可阻塞(Lua 单状态 + 互斥锁)。
sdk.events = {}
function sdk.events.subscribe(event_type, handler)
print("[lua-plugin] events.subscribe: " .. tostring(event_type))
return function() end
end
-- ============ plugin_mgr ============
-- !impl
sdk.plugin_mgr = {}
function sdk.plugin_mgr.reload_one(name) return nil end
function sdk.plugin_mgr.list_loaded() return {} end
function sdk.plugin_mgr.is_disabled(name) return false end
-- json utils (pure Lua)
sdk.json = {}
function sdk.json.encode(val)
local ok, result = pcall(function()
local function _encode(v)
local t = type(v)
if t == "string" then
local s = v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t')
return '"' .. s .. '"'
elseif t == "number" then
return tostring(v)
elseif t == "boolean" then
return tostring(v)
elseif t == "table" then
local keys = {}
local is_array = true
local maxn = 0
for k in pairs(v) do
keys[#keys + 1] = k
if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then
is_array = false
end
if type(k) == "number" and k > maxn then maxn = k end
end
if is_array and #keys >= maxn then
local parts = {}
for i = 1, maxn do
parts[#parts + 1] = _encode(v[i])
end
return "[" .. table.concat(parts, ",") .. "]"
else
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = _encode(tostring(k)) .. ":" .. _encode(v[k])
end
return "{" .. table.concat(parts, ",") .. "}"
end
else
return "null"
end
end
return _encode(val)
end)
if ok then return result end
return "null"
end
function sdk.json.decode(str)
local ok, result = pcall(function()
local pos, _end = 1, #str
local function skip()
while pos <= _end and str:sub(pos, pos):match("%s") do pos = pos + 1 end
end
local function parse()
skip()
if pos > _end then return nil end
local c = str:sub(pos, pos)
if c == '"' then
local s = {}
pos = pos + 1
while pos <= _end do
local ch = str:sub(pos, pos)
if ch == '"' then
pos = pos + 1
return table.concat(s)
elseif ch == '\\' then
pos = pos + 1
local n = str:sub(pos, pos)
if n == '"' then s[#s+1] = '"'
elseif n == '\\' then s[#s+1] = '\\'
elseif n == '/' then s[#s+1] = '/'
elseif n == 'b' then s[#s+1] = '\b'
elseif n == 'f' then s[#s+1] = '\f'
elseif n == 'n' then s[#s+1] = '\n'
elseif n == 'r' then s[#s+1] = '\r'
elseif n == 't' then s[#s+1] = '\t'
elseif n == 'u' then
local hex = str:sub(pos+1, pos+4)
pos = pos + 4
s[#s+1] = utf8 and utf8.char(tonumber(hex, 16)) or '?'
end
pos = pos + 1
else
s[#s+1] = ch
pos = pos + 1
end
end
return table.concat(s)
elseif c == 't' then pos = pos + 4; return true
elseif c == 'f' then pos = pos + 5; return false
elseif c == 'n' then pos = pos + 4; return nil
elseif c == '{' then
pos = pos + 1; skip()
local t = {}
if str:sub(pos, pos) == '}' then pos = pos + 1; return t end
while true do
skip(); local k = parse(); skip()
if str:sub(pos, pos) == ':' then pos = pos + 1 end
skip(); t[k] = parse(); skip()
local sep = str:sub(pos, pos)
if sep == '}' then pos = pos + 1; return t end
if sep == ',' then pos = pos + 1 end
end
elseif c == '[' then
pos = pos + 1; skip()
local t = {}
if str:sub(pos, pos) == ']' then pos = pos + 1; return t end
local idx = 1
while true do
skip(); t[idx] = parse(); idx = idx + 1; skip()
local sep = str:sub(pos, pos)
if sep == ']' then pos = pos + 1; return t end
if sep == ',' then pos = pos + 1 end
end
else
local s, e = str:find('^[-%d%.eE]+', pos)
if s then
local num = tonumber(str:sub(s, e))
pos = e + 1
return num
end
return nil
end
end
return parse()
end)
if ok then return result end
return nil
end
-- http utils
sdk.http = {}
-- !impl
function sdk.http.get(url)
print("[lua-plugin] http.get: " .. tostring(url))
return {status=200, body='{"mock":true}', headers={}}
end
-- !impl
function sdk.http.post(url, body, content_type)
print("[lua-plugin] http.post: " .. tostring(url))
return {status=200, body='{"mock":true}', headers={}}
end
return sdk

View File

@ -0,0 +1,119 @@
package sdk
// MemoryAPI provides access to the graph memory (entity-relation store).
type MemoryAPI interface {
Recall(query []string, depth int) ([]Entity, []Relation, error)
Commit(triples []Triple) error
Introspect() (map[string]interface{}, error)
MergeEntities(source, target string) (int, error)
Purge(criteria map[string]string, mode string) (int, error)
}
// Entity represents a named entity in the knowledge graph.
type Entity struct {
Name string `json:"name"`
Type string `json:"type"`
MentionCount int `json:"mention_count"`
}
// Relation represents a relationship between two entities.
type Relation struct {
SourceName string `json:"source_name"`
TargetName string `json:"target_name"`
RelationType string `json:"relation_type"`
Confidence float64 `json:"confidence,omitempty"`
}
// Triple represents a subject-relation-object triple for the knowledge graph.
//
// SentenceText 是这条三元组的原句,会写进 sentences 表;媒体引用挂在句子上,
// 所以 MediaDigests 非空时内核会保证句子存在(不给就自动合成一句)。
type Triple struct {
Subject string `json:"subject"`
Relation string `json:"relation"`
Object string `json:"object"`
Confidence float64 `json:"confidence,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
ObjectType string `json:"object_type,omitempty"`
SentenceText string `json:"sentence_text,omitempty"`
MediaDigests []string `json:"media_digests,omitempty"`
}
// TextMemoryAPI provides access to chronological text event storage.
type TextMemoryAPI interface {
Append(evt TextEvent) error
}
// TextEvent represents a single text memory event.
// MediaAttachment 描述一份与记忆关联的媒体。
//
// 两个方向共用一个类型:
// - 写入(InsertWithMedia):给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。
// - 读出(Query):内核只填 Digest/MIME,**不回 Data**——
// 一次检索可能命中几十张图,把字节全塞回插件会把 ABI 消息撑爆。
// 需要字节时拿 Digest 单独取。
//
// 刻意没有 Description 字段:媒体不作为文本被索引,也不带任何生成的描述。
// 它只按自己的原生向量被检索与召回;附加文字请写在文档 / 三元组的文本里。
type MediaAttachment struct {
Digest string `json:"digest,omitempty"`
MIME string `json:"mime,omitempty"`
Data []byte `json:"data,omitempty"`
Name string `json:"name,omitempty"`
}
type TextEvent struct {
Role string `json:"role"`
Content string `json:"content"`
Timestamp int64 `json:"timestamp"`
Channel string `json:"channel,omitempty"`
Attachments []MediaAttachment `json:"attachments,omitempty"`
}
// DocMemoryAPI provides access to the document vector store.
type DocMemoryAPI interface {
Query(text string, topK int) []*Doc
Insert(doc *Doc) error
// InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
// 内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
// 媒体成为文档直接持有的一等记忆块:文档向量会融合它们的原生向量,
// 因此图片按自己的向量被召回,不依赖任何生成的描述文本。
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
Remove(id string)
Stats() map[string]interface{}
}
// Doc represents a document in the document store.
//
// MediaDigests / Attachments 在 Query 返回时由内核填充(仅元数据,不带字节)。
type Doc struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Score float64 `json:"score,omitempty"`
MediaDigests []string `json:"media_digests,omitempty"`
Attachments []MediaAttachment `json:"attachments,omitempty"`
}
// SocialAPI provides read-only access to the social graph (person profiles and relationships).
// External plugins can query person traits and social networks but cannot modify them.
type SocialAPI interface {
GetPerson(name string) (*PersonProfile, error)
GetTrait(name, trait string) (string, bool)
GetRelations(name string) ([]SocialRelation, error)
GetNetwork(name string, depth int) ([]*PersonProfile, error)
ListPersons() ([]string, error)
}
// PersonProfile represents a person's complete profile (traits + social relations).
type PersonProfile struct {
Name string `json:"name"`
Traits map[string]string `json:"traits,omitempty"`
Relations []SocialRelation `json:"relations,omitempty"`
}
// SocialRelation represents a social relationship between two persons.
type SocialRelation struct {
Person string `json:"person"`
Relation string `json:"relation"`
}

1249
third_party/hologramagentsdk/sdk/plugin.go vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,253 @@
package sdk
import (
"testing"
)
func TestRegisterStageGlobalDefault(t *testing.T) {
called := false
regStage := func(stage Stage, handler StageHandler) {
called = true
}
s := &PluginSDK{regStage: regStage, name: "test"}
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil })
if !called {
t.Error("global scope: handler not registered")
}
}
func TestRegisterStageGlobalExplicit(t *testing.T) {
called := false
regStage := func(stage Stage, handler StageHandler) {
called = true
}
s := &PluginSDK{regStage: regStage, name: "test"}
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeGlobal)
if !called {
t.Error("global scope: handler not registered")
}
}
func TestRegisterStageOwnToolsMatch(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &StageContext{}
ctx.ToolCalls = []ToolCall{{Plugin: "myplugin", Name: "my_tool"}}
ctx.ToolResults = nil
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
}
func TestRegisterStageOwnToolsSkipOtherPlugin(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
callCount := 0
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
callCount++
return nil
}, StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &StageContext{}
ctx.ToolCalls = []ToolCall{{Plugin: "other", Name: "other_tool"}}
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
if callCount != 0 {
t.Error("handler should not be called for other plugin's tool")
}
}
func TestRegisterStageOwnToolsNonToolcallDegrades(t *testing.T) {
regStage := func(stage Stage, handler StageHandler) {
if stage != StagePreAction {
t.Errorf("expected StagePreAction, got %s", stage)
}
}
s := &PluginSDK{regStage: regStage, name: "test"}
s.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
}
func TestRegisterStageOwnToolsStageBeforeToolcallNoToolCalls(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
callCount := 0
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
callCount++
return nil
}, StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &StageContext{}
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
if callCount != 0 {
t.Error("handler should not be called when ToolCalls is empty")
}
}
func TestRegisterStageOwnToolsStageAfterToolcallMatch(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
callCount := 0
s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error {
callCount++
return nil
}, StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &StageContext{}
ctx.ToolResults = []ToolResult{{Plugin: "myplugin", Name: "my_tool"}}
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
if callCount != 1 {
t.Error("handler should be called for own plugin's tool result")
}
}
func TestRegisterStageOwnToolsStageAfterToolcallSkip(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
callCount := 0
s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error {
callCount++
return nil
}, StageScopeOwnTools)
ctx := &StageContext{}
ctx.ToolResults = []ToolResult{{Plugin: "other", Name: "other_tool"}}
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
if callCount != 0 {
t.Error("handler should not be called for other plugin's tool result")
}
}
func TestRegisterStageOwnToolsNilRegStage(t *testing.T) {
s := &PluginSDK{name: "test"}
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
}
func TestToolDefCleaner(t *testing.T) {
called := false
def := ToolDef{
Name: "test_clean",
Description: "A test tool with cleaner",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Cleaner: func(output string) string {
called = true
return "cleaned:" + output
},
}
if def.Cleaner == nil {
t.Fatal("Cleaner should not be nil")
}
result := def.Cleaner("raw output")
if !called {
t.Error("Cleaner was not called")
}
if result != "cleaned:raw output" {
t.Errorf("expected 'cleaned:raw output', got '%s'", result)
}
}
func TestToolDefNoMemory(t *testing.T) {
def := ToolDef{
Name: "test_nomem",
Description: "A test tool with NoMemory",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
NoMemory: true,
}
if !def.NoMemory {
t.Error("NoMemory should be true")
}
if def.Cleaner != nil {
t.Error("Cleaner should be nil when not set")
}
}
func TestToolDefNoMemoryDefaultFalse(t *testing.T) {
def := ToolDef{
Name: "test_default",
Description: "A test tool with defaults",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}
if def.NoMemory {
t.Error("NoMemory should default to false")
}
}
func TestToolDefRegisterPreservesNoMemory(t *testing.T) {
var capturedDef ToolDef
regTool := func(name string, def ToolDef, handler ToolHandler) error {
capturedDef = def
return nil
}
s := &PluginSDK{regTool: regTool, name: "test"}
def := ToolDef{
Name: "test_tool",
Description: "test desc",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
NoMemory: true,
Cleaner: func(s string) string { return s },
}
s.RegisterTool("test_tool", def, func(args map[string]interface{}) (interface{}, error) {
return nil, nil
})
if !capturedDef.NoMemory {
t.Error("NoMemory should be preserved through RegisterTool")
}
if capturedDef.Cleaner == nil {
t.Error("Cleaner should be preserved through RegisterTool")
}
}

View File

@ -0,0 +1,368 @@
package sdk
import (
"net"
"strconv"
"strings"
)
// 反向代理声明:插件告诉 HologramAgent「我起了个 HTTP 服务,请把它反代出去」。
//
// 为什么需要:插件自带 Web UI / HTTP API 时,监听地址在插件自己的配置里
// (如 127.0.0.1:12100),外部无从得知;而 webui 的对外端口通常只有一个
// (默认 :8080,且常经 frp 单端口隧道穿透)。没有声明机制时,用户只能
// 「知道端口 + 自己配转发」,插件换端口就失效。
//
// 设计取舍——**声明式而非注册式**:声明写在 plugin.json 里,由 HologramAgent
// 在加载插件时读取聚合,而不是让插件在运行期调 API 注册。理由:
// 1. 静态可发现:未启动/已崩溃的插件,其服务声明依然可见(可给出准确报错
// 「插件 X 声明了 ui 但目标 127.0.0.1:12100 不可达」,而不是静默 404);
// 2. 可版本化:声明随插件包一起分发、可 diff、可审计;
// 3. 旧内核无害:manifest 解析忽略未知字段,未支持该能力的 HologramAgent 读旧
// 插件、或旧 HologramAgent 读新插件都不会报错。
//
// 与 ToolDef / ChannelDef / ConfigDef 同族:SDK 定义声明契约,内核实现行为。
// 声明方式与其它能力一致 —— 在 Start() 里调 RegisterProxy(name, def),
// 或写进 plugin.json 的 proxies 字段(外部插件两种都支持)。
//
// 安全性:**不声明 = 不被反代**。声明本身就是能力声明,因此不需要在
// capabilities 里另外开一个开关——最小权限默认生效。
//
// # 单一入口原则(强制要求)
//
// **一个声明 = 一个入口**。被反代的插件必须让它的全部资源与接口都能从
// 该入口的一个基准路径出发访问到,不得依赖「入口之外的根路径」。
//
// 为什么强制:反代有两种挂载形态,而它们对「根路径」的处理截然不同——
//
// Host 形态(host):插件独占 <标签>.<基域名>,根路径就是插件的根。
// 根绝对路径(fetch('/api/x'))**天然正确**。
// Path 形态(path):插件挂在门户自身 host 的某个前缀下,根路径属于**门户**。
// 此时插件里的 fetch('/api/x') 会打到门户自己的 /api/x
// —— 静默错路由,页面能开但功能全坏。
//
// 于是「同一个插件必须同时支持两种形态」这条要求,等价于:
//
// **插件内部一律使用相对路径**(或基于 <base>/location 推导的路径),
// 绝不硬编码以 / 开头的绝对路径。
//
// 这样同一份前端在两种形态下都正确,插件作者也不必知道自己被挂在哪。
// 反代层据此可以:外部子域可用时给 Host 形态,子域不可用(证书/放行限制)
// 时给 Path 形态,**无需插件配合改动**。
//
// 自检(插件作者在本地就该做):把页面挂到 <门户>/<任意前缀>/ 下访问,
// 所有请求都必须仍然打到插件自己。
//
// 本项目实测案例:某插件前端写死 fetch('/api/status'),配在
// /p/huawei/ 下会打到门户的 /api/status(404 或返回门户数据);
// 改成相对路径后两种形态同时可用。
// ProxyDef 是一个服务的**反代声明体**。
//
// 与 ToolDef 同构:Name 同时出现在字段与 RegisterProxy 的第一个参数里
// (ToolDef 也是这么做的 —— 字段供 plugin.json 序列化,参数供运行期调用)。
// Name 只用于展示、日志与冲突提示,**不参与路由**(路由键是 Host 与 Path)。
type ProxyDef struct {
// Name 是同一插件内多条声明的唯一标识(如 "ui"、"api")。
// 运行期由 RegisterProxy 的第一个参数填入;声明式由 plugin.json 的
// name 键填入。省略时由 HologramAgent 兜底为 "service"。
Name string `json:"name,omitempty"`
// Host 是**子域名标签**(不含基域名),如 "huawei" 对应 huawei.<基域名>。
//
// 约束:仅小写字母、数字与连字符,不以连字符开头/结尾,长度 ≤ 63
// (DNS label 规则)。省略时默认取插件名(下划线转连字符,因为下划线
// 不是合法 DNS label 字符)。
//
// 冲突处理:两个插件声明同一 Host 时,HologramAgent 不做「后者覆盖前者」——
// 那样会让先声明者静默消失。冲突条目被拒绝并在反代表里记录原因。
Host string `json:"host,omitempty"`
// Target 是上游地址,形如 "127.0.0.1:12100" 或 "http://127.0.0.1:12100"。
// 可带路径前缀(如 "127.0.0.1:3000/base"),HologramAgent 转发时保留该前缀。
//
// 端口由插件自己填它**实际监听**的地址,避免「声明与实际漂移」。
Target string `json:"target"`
// WebSocket 表示该服务需要 WebSocket 升级透传(默认 false)。
//
// 为什么必须显式声明而不是「有 Upgrade 头就转」:WS 是长连接,会占用
// 反代侧连接与 goroutine,且绕过普通请求的响应缓冲/超时逻辑。默认关闭
// 让普通 HTTP 服务的失败模式保持简单;未声明时的升级请求会被明确拒绝,
// 而不是静默降级成普通请求(后者表现为前端一直重连、排查困难)。
WebSocket bool `json:"websocket,omitempty"`
// Path 是可选的**路径挂载前缀**(如 "/api/v1/device")。
//
// 为什么 Host 子域之外还需要它:子域形态依赖 DNS 解析,而 *.localhost
// 只有浏览器内置该特例(RFC 6761)—— 普通进程(设备客户端、固件、
// CLI)走系统解析器,实测解析不到,会以「no such host」失败。
// 路径形态挂在门户自身 host 下,**无任何 DNS 依赖**,是给非浏览器
// 客户端用的。
//
// 语义:请求路径**原样保留**(不做前缀剥除)——声明者按上游真实路径填写,
// 例如上游注册 /api/v1/device/ws,就声明 Path="/api/v1/device"。
// 这样设备客户端可以直接使用它已硬编码的路径,不需要知道反代的存在。
//
// 与 Host 形态的关系(见包注释的「单一入口原则」):声明的服务应当
// **同时**能被两种形态访问。因此 Path 形态下插件内部必须用相对路径,
// 否则它的前端会把请求打到门户自己身上。
//
// 留空 = 只提供子域形态(插件自带 UI 的常见情形:UI 与它自己的 API
// 同源,走子域天然正确)。
Path string `json:"path,omitempty"`
// StripPath 决定转发前是否**剥掉** Path 前缀。默认 false(原样保留)。
//
// 两种挂载语义真实不同,必须由声明者选,不能靠猜:
//
// false(别名模式):Path 就是上游真实路径的一部分。
// 请求 /api/v1/device/ws + Path="/api/v1/device"
// → 上游收到 /api/v1/device/ws(一模一样)。
// 适用:客户端**已硬编码**路径的机器接口(设备网关就是如此,
// 它按 /api/v1/device/ws 连接,不可能知道反代的存在)。
//
// true(前缀模式):Path 只是门户上的挂载点,上游不知道它。
// 请求 /p/myapp/api/status + Path="/p/myapp"
// → 上游收到 /api/status。
// 适用:自带 UI 的服务(前端用相对路径,被挂到哪里都对)。
//
// 为什么不能自动判定:同一个声明「Path=/api/v1/device」在两种语义下
// 都说得通,代理无从分辨 —— 猜错的结果是全部请求 404,且看起来像
// 上游故障。所以由声明者显式写清楚。
StripPath bool `json:"strip_path,omitempty"`
// Auth 决定这条反代由谁保护,取值见 ProxyAuthNone / ProxyAuthHomeAgent。
// 空串等价于 ProxyAuthHomeAgent(默认安全)。
//
// 为什么做成可声明项:设备网关(remotedevice)这类服务的调用方是**设备**,
// 它们不可能持有浏览器会话 cookie,而服务自身已有接入令牌(如 ws_token)。
// 强制走 HologramAgent 门户鉴权会把这类链路挡死;反过来,插件自带的 UI 若
// 声明 none,就等于把管理界面裸露给任何能访问该端口的人。
// 因此必须由插件**逐条**声明,而不是全局一刀切。
Auth string `json:"auth,omitempty"`
}
// ProxyAuth 取值。空串按 ProxyAuthAgent 处理(安全的默认)。
const (
// ProxyAuthAgent 表示由内核统一保护:浏览器走门户会话
// (HologramAgent session cookie),非浏览器客户端走 X-API-Key。
// 两者都没有时返回 401,而不是把请求透传给上游。
ProxyAuthAgent = "agent"
// ProxyAuthNone 表示不经内核鉴权,直接把请求转发给上游。
//
// 适用场景:上游自己有鉴权且调用方不是浏览器(设备/嵌入式客户端),
// 或上游是刻意公开的服务。选用它意味着**信任上游自身的鉴权**,
// 且该服务在网络层可达范围内对所有人开放。
ProxyAuthNone = "none"
// ProxyAuthAgentLegacy 是旧值的字面量,**只用于兼容读取**,不要在新声明里写它。
//
// 为什么必须继续接受(2026-10-22):`Auth` 是写进 plugin.json 的**持久化
// 声明值**,存量插件的配置文件里已经写着旧值(本机实测 huawei_smarthome 的
// plugin.json:19 就是 "auth": "homeagent")。删掉这个分支 ⇒ 那些插件在加载
// 时被判为非法声明,反代直接不生效,且症状是"插件好好的但页面打不开"。
//
// 这正是 sdkModuleNames 处理 module 路径改名时的同一条教训:**协议值的
// 改名必须保留旧值的读取路径**,否则存量数据无声失效。
ProxyAuthAgentLegacy = "homeagent"
)
// ProxyAuthHomeAgent 是 ProxyAuthAgent 的旧名,仅为**存量源码**保留。
//
// Deprecated: 用 ProxyAuthAgent。存量插件的 plugin.json 里写的旧值仍被
// ValidProxyAuth/EffectiveProxyAuth 接受(见 ProxyAuthAgentLegacy)。
const ProxyAuthHomeAgent = ProxyAuthAgent
// ValidProxyAuth 校验 Auth 取值;空串合法(等价 ProxyAuthAgent)。
//
// 同时接受旧值 ProxyAuthAgentLegacy —— 存量 plugin.json 里写的仍是它。
func ValidProxyAuth(auth string) bool {
switch auth {
case "", ProxyAuthAgent, ProxyAuthNone, ProxyAuthAgentLegacy:
return true
}
return false
}
// EffectiveProxyAuth 返回生效的鉴权模式(空串与旧值都归一化为 ProxyAuthAgent)。
//
// 归一化旧值是关键:下游一律只可能拿到 ProxyAuthAgent,不必各自兼容旧值
// (否则每个判断点都要写 `== ProxyAuthAgent || == ProxyAuthAgentLegacy`,
// 漏一处就是鉴权绕过)。
func EffectiveProxyAuth(auth string) string {
if auth == "" || auth == ProxyAuthAgentLegacy {
return ProxyAuthAgent
}
return auth
}
// ValidProxyHostLabel 校验子域名标签是否合法(DNS label 规则)。
//
// 独立成导出函数:插件作者在写声明时、HologramAgent 在加载时、工具链在打包时
// 都要用同一套规则判定,避免三处各写一份而互相不一致。
func ValidProxyHostLabel(label string) bool {
if label == "" || len(label) > 63 {
return false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
default:
return false
}
}
return true
}
// NormalizeProxyHost 由插件名派生默认 Host 标签。
//
// 下划线转连字符:插件名允许下划线(huawei_smarthome),但 DNS label 不允许,
// 直接用会导致该子域名无法解析——这里统一转换,避免每个插件各自碰运气。
func NormalizeProxyHost(pluginName string) string {
s := strings.ToLower(strings.TrimSpace(pluginName))
s = strings.ReplaceAll(s, "_", "-")
// 去掉其它非法字符,保证结果是合法 label(宁可退化成保守值也不产出非法域名)
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
b.WriteByte(c)
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "plugin"
}
if len(out) > 63 {
out = strings.Trim(out[:63], "-")
}
return out
}
// ValidateProxyDef 校验一条反代声明,返回人类可读的错误说明(合法时为空)。
//
// 为什么要在 SDK 里做校验:HologramAgent 加载插件时必须能明确拒绝坏声明并说明
// 原因(而不是静默忽略导致用户以为配好了);插件作者也需要在本地就能查出
// 拼错的 Target/Host。同一套规则两端共用。
func ValidateProxyDef(d ProxyDef) string {
if strings.TrimSpace(d.Target) == "" {
return "target 为空:必须给出上游地址(如 127.0.0.1:12100 或 http://127.0.0.1:12100)"
}
if !ValidProxyAuth(d.Auth) {
return "auth 取值非法:" + d.Auth + "(只允许 \"\" / \"homeagent\" / \"none\")"
}
if d.Host != "" && !ValidProxyHostLabel(d.Host) {
return "host 不是合法的子域名标签(只允许小写字母/数字/连字符,且不以连字符开头结尾): " + d.Host
}
if p := strings.TrimSpace(d.Path); p != "" {
if !strings.HasPrefix(p, "/") {
return "path 必须以 / 开头: " + d.Path
}
if strings.HasSuffix(p, "/") {
return "path 不应以 / 结尾(它是前缀,不是目录): " + d.Path
}
if strings.Contains(p, "..") || strings.ContainsAny(p, " \t\r\n\x00?#") {
return "path 含非法字符: " + d.Path
}
}
// 前缀模式必须给出可剥的前缀。
// 注意 "/" 不需要单独判:它是前缀又同时以 "/" 结尾,已被上面的
// 「不应以 / 结尾」规则挡掉(挂到门户根会覆盖整站的意图因此无法达成)。
if d.StripPath && strings.TrimSpace(d.Path) == "" {
return "strip_path=true 时必须给出 path(否则没有可剥的前缀)"
}
// Target 的 host:port 部分必须可解析;路径前缀允许保留。
//
// 规则(刻意从严,因为地址写错是最常见的声明错误,而错误的反代会把
// 用户带到别处去):
// - 带 scheme 时(http://…)允许省略端口,由反代层按 scheme 补默认值;
// - 不带 scheme 时必须给出 host:port;
// - 端口必须是数字(SplitHostPort 本身不校验数字,"host:abc" 会通过)。
scheme := ""
raw := d.Target
if i := strings.Index(raw, "://"); i >= 0 {
scheme = strings.ToLower(raw[:i])
if scheme != "http" && scheme != "https" {
return "target scheme 只支持 http/https(WS 由 websocket 字段声明,不写 ws://): " + d.Target
}
raw = raw[i+3:]
}
if i := strings.IndexByte(raw, '/'); i >= 0 {
raw = raw[:i]
}
if raw == "" {
return "target 缺少主机部分: " + d.Target
}
host, port, err := net.SplitHostPort(raw)
if err != nil {
if scheme == "" {
return "target 必须给出 host:port(或带 http:// 前缀以便省略端口): " + d.Target
}
// 带 scheme 且解析失败:只剩主机名一种合法情形。
host, port = raw, ""
}
if host == "" {
return "target 缺少主机部分: " + d.Target
}
if port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return "target 端口非法(应为 1-65535 的数字): " + d.Target
}
}
return ""
}
// ProxyRegistrar 由内核注入(与 ToolRegistrar / InputChannelRegistrar 同族)。
// 插件不直接调它,用 RegisterProxy。
//
// 为什么需要运行期注册(明明有 plugin.json 自动发现):**内置插件**
// (编译进内核、没有独立插件目录与 plugin.json,如 remotedevice)扫不到;
// 而它们恰恰最需要被反代出去(设备网关就是内置的)。两种来源互补:
// - 外部插件 → plugin.json 的 proxies(静态,未启动也可见)
// - 内置插件 → RegisterProxy(运行期,随 Start 注册)
type ProxyRegistrar func(name string, def ProxyDef)
// SetProxyRegistrar 由内核注入。插件不直接调它(与 SetInputChannelRegistrar 同族)。
func (s *PluginSDK) SetProxyRegistrar(r ProxyRegistrar) {
if s == nil {
return
}
s.apiMu.Lock()
s.proxyReg = r
s.apiMu.Unlock()
}
// RegisterProxy 声明一个需要 HologramAgent 反代出去的服务。
//
// 与 RegisterTool / RegisterInputChannel / RegisterOutputChannel 同一风格:
// 显式给名字 + 声明体。名字用于展示、日志与冲突提示(不参与路由 —— 路由键是
// def.Host / def.Path)。
//
// 用法(通常在 Start 里调用):
//
// s.RegisterProxy("ui", sdk.ProxyDef{
// Host: "myapp", Target: "127.0.0.1:12100",
// })
//
// 声明立即生效(反代表在下一次请求时重建)。**不做去重**:同一 Host/Path
// 被两条声明占用时由反代层判定冲突并明确报错,而不是在这里静默吞掉 ——
// 插件作者需要看见冲突。
//
// 与 plugin.json 的 proxies 字段等价:写哪个都行,两者会合并(同名以本调用为准)。
func (s *PluginSDK) RegisterProxy(name string, def ProxyDef) {
if s == nil {
return
}
s.apiMu.RLock()
r := s.proxyReg
s.apiMu.RUnlock()
if r != nil {
r(name, def)
}
}

View File

@ -0,0 +1,184 @@
package sdk
import (
"os"
"strings"
"testing"
)
func TestProxyAuthDefaultsToHologramAgent(t *testing.T) {
// 空串必须归一化为「HologramAgent 统一保护」——这是安全默认。
// 若哪天有人把默认改成 none,这条会立刻红。
if got := EffectiveProxyAuth(""); got != ProxyAuthAgent {
t.Fatalf("空 auth 应归一化为 %q,实际 %q", ProxyAuthAgent, got)
}
if got := EffectiveProxyAuth(ProxyAuthNone); got != ProxyAuthNone {
t.Fatalf("显式 none 应保持 none,实际 %q", got)
}
for _, ok := range []string{"", ProxyAuthAgent, ProxyAuthNone} {
if !ValidProxyAuth(ok) {
t.Errorf("%q 应合法", ok)
}
}
for _, bad := range []string{"nope", "HOMEAGENT", "None", "true"} {
if ValidProxyAuth(bad) {
t.Errorf("%q 应非法", bad)
}
}
}
// 存量 plugin.json 里写的是旧值 "homeagent"(本机实测 huawei_smarthome)。
// 改名后必须**继续接受并归一化**它 —— 否则那些插件在加载时被判为非法声明,
// 反代直接不生效,症状是"插件好好的但页面打不开",且没有任何报错指向改名。
func TestProxyAuthLegacyValueStillAccepted(t *testing.T) {
if !ValidProxyAuth(ProxyAuthAgentLegacy) {
t.Fatalf("旧值 %q 必须仍被接受", ProxyAuthAgentLegacy)
}
if ProxyAuthAgentLegacy == ProxyAuthAgent {
t.Fatal("旧值应与新值不同,否则本判据测不到东西")
}
// ★ 归一化是最关键的一步:下游一律只可能拿到新值,
// 否则每个判断点都要写两遍比较,漏一处就是鉴权绕过。
if got := EffectiveProxyAuth(ProxyAuthAgentLegacy); got != ProxyAuthAgent {
t.Fatalf("旧值应归一化为 %q,实际 %q", ProxyAuthAgent, got)
}
// 存量 plugin.json 的字面量必须能过校验(这是真实数据,不是假设)。
if !ValidProxyAuth("homeagent") {
t.Fatal(`存量 plugin.json 里的 "auth": "homeagent" 必须仍被接受`)
}
}
func TestValidProxyHostLabel(t *testing.T) {
legit := []string{"huawei", "a", "a-b", "abc123", "0", "x" + string(make([]byte, 0)) + "yz"}
for _, s := range legit {
if !ValidProxyHostLabel(s) {
t.Errorf("%q 应为合法 label", s)
}
}
bad := []string{
"", "-a", "a-", "-", "a_b", "a.b", "A", "aB", "a b",
"a/b", "a:b", string(make([]byte, 64)), // 超长 63
}
for _, s := range bad {
if ValidProxyHostLabel(s) {
t.Errorf("%q 应为非法 label", s)
}
}
// 边界:恰好 63 合法,64 非法
l63 := ""
for i := 0; i < 63; i++ {
l63 += "a"
}
if !ValidProxyHostLabel(l63) {
t.Error("63 字符应为合法 label")
}
if ValidProxyHostLabel(l63 + "a") {
t.Error("64 字符应为非法 label")
}
}
func TestNormalizeProxyHost(t *testing.T) {
cases := map[string]string{
"huawei_smarthome": "huawei-smarthome", // 下划线不是合法 DNS label
"webui": "webui",
"UPPER_Case": "upper-case",
"a__b": "a--b",
"__x__": "x",
"---": "plugin", // 全非法 → 保守回退
"": "plugin",
"a.b.c": "abc",
}
for in, want := range cases {
if got := NormalizeProxyHost(in); got != want {
t.Errorf("NormalizeProxyHost(%q) = %q,期望 %q", in, got, want)
}
}
// 归一化结果必须自身合法(产物自洽)
for _, in := range []string{"huawei_smarthome", "UPPER_Case", "__x__", "a.b.c", "非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字"} {
if got := NormalizeProxyHost(in); !ValidProxyHostLabel(got) {
t.Errorf("NormalizeProxyHost(%q) = %q 不合法", in, got)
}
}
}
func TestValidateProxyDef(t *testing.T) {
valid := []ProxyDef{
{Target: "127.0.0.1:12100"},
{Target: "http://127.0.0.1:12100"},
{Target: "127.0.0.1:12100", Host: "huawei"},
{Target: "127.0.0.1:12100", Auth: ProxyAuthNone},
{Target: "127.0.0.1:12100", Auth: ProxyAuthAgent, WebSocket: true},
{Target: "127.0.0.1:3000/base", Host: "x"},
{Target: "https://example.com", Host: "ext"}, // 远程上游也允许(由 auth 决定安全性)
}
for _, d := range valid {
if msg := ValidateProxyDef(d); msg != "" {
t.Errorf("%+v 应合法,却报: %s", d, msg)
}
}
bad := []ProxyDef{
{}, // 无 target
{Target: " "}, // 空白 target
{Target: "127.0.0.1:12100", Auth: "yes"}, // auth 非法
{Target: "127.0.0.1:12100", Host: "a_b"}, // host 非法
{Target: "127.0.0.1:12100", Host: "-x"},
{Target: "127.0.0.1:12100", Host: "X"},
{Target: "://12100"}, // 无主机
{Target: "http:///path"}, // 无主机
{Target: "127.0.0.1:notaport"}, // 端口非数字
}
for _, d := range bad {
if msg := ValidateProxyDef(d); msg == "" {
t.Errorf("%+v 应被拒绝,却通过了", d)
}
}
}
// ---- 单一入口原则 ----
// 被反代的插件必须能同时适配 Host 形态与 Path 形态。这两条判据把
// 「插件内部不得用根绝对路径」这条契约钉在**可执行**的层面:
// 声明合法不代表它的资源能被两种形态访问到 —— 后者取决于插件前端的写法,
// 而 SDK 只能把要求写清楚并给出校验工具。
func TestSingleEntryPrincipleDocumented(t *testing.T) {
// Path 形态下插件前端必须用相对路径,否则请求会打到门户自己。
// 这是**文档级约定**,只能靠 review 与这份判据共同保证:
// 判据确保 SDK 里确实写明了这条要求(防止后来者删掉注释)。
src, err := os.ReadFile("proxy.go")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"单一入口原则",
"相对路径",
"根绝对路径",
} {
if !strings.Contains(string(src), want) {
t.Errorf("SDK 文档缺少「%s」—— 单一入口原则是反代的硬要求,不能只存在于口头约定里", want)
}
}
}
// strip_path 的两种语义必须由声明者显式选,且非法组合要被挡住。
func TestStripPathValidation(t *testing.T) {
// 合法:两种模式
for _, d := range []ProxyDef{
{Target: "127.0.0.1:1", Path: "/p/app", StripPath: true},
{Target: "127.0.0.1:1", Path: "/api/v1/device", StripPath: false},
} {
if msg := ValidateProxyDef(d); msg != "" {
t.Errorf("应合法却被拒: %+v → %s", d, msg)
}
}
// 非法:strip_path 但没有 path(没有可剥的前缀)
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", StripPath: true}); msg == "" {
t.Error("strip_path=true 而无 path 应被拒(没有可剥的前缀)")
}
// 非法:前缀模式挂到根会吞掉整个门户。
// 实际由「不应以 / 结尾」规则挡下("/" 同时是前缀又以 / 结尾),
// 这里断言的是**行为**:这种声明无论如何都不能通过。
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", Path: "/", StripPath: true}); msg == "" {
t.Error("path=\"/\" + strip_path 应被拒(会覆盖整个门户)")
}
}

View File

@ -0,0 +1,56 @@
package sdk
// RawFilesAPI 让插件读写**随包分发的资源**(rawfile)。
//
// ## 是什么
//
// `hmapdev build` 会把工程根 `rawfile/` 下的全部内容(以及 plg.json
// `rawfiles` 声明的外部路径)打进 .hmap;内核解包时保留路径,落到:
//
// <插件目录>/rawfile/<原相对路径>
//
// 本接口就是插件读这些资源的唯一入口。
//
// ## 为什么不让插件自己拼路径
//
// 插件**不知道自己的安装目录**:内核的 `PluginDir()` 是给插件管理器用的,
// 不暴露给插件自身。而且"包内资源"与"运行期生成的文件"必须分开 ——
// 前者只读、随升级替换,后者可写、跨升级保留。混在一个目录里,
// 升级时会误删运行期数据,或把旧资源留在原地。
//
// 所以:
//
// RawFiles().Read("main/prompt.txt") → 读**包内**资源(只读)
// RawFiles().WorkDir() → 拿**可写**工作目录(运行期数据放这)
//
// ## 安全边界
//
// 所有路径都相对 rawfile/ 根,**必须拒绝路径穿越**(`../`)。
// 资源文件来自包,包可能被替换 —— 让插件能借它读到 `/etc/shadow`
// 就是一个真实的提权面。
type RawFilesAPI interface {
// Read 读一个包内资源(路径相对 rawfile/ 根)。
//
// 例:Read("main/prompt.txt") 读 <插件目录>/rawfile/main/prompt.txt。
Read(rel string) ([]byte, error)
// List 列出某个目录下的条目(相对 rawfile/ 根;"" 表示根)。
//
// 返回的名字是**相对 path 的单层名**(不是全路径),目录名带 `/` 后缀。
List(rel string) ([]string, error)
// Exists 报告某个资源是否存在。
Exists(rel string) bool
// Dir 返回包内资源的根目录(只读)。
//
// 给需要把整个目录交给外部工具的插件用(如把 node 目录的路径
// 传给 exec.Command)。**不要往这里写东西** —— 它在包里,随升级替换。
Dir() string
// WorkDir 返回一个**可写**目录,供插件放运行期生成的文件。
//
// 与 Dir() 分开的原因:Dir() 里的东西随升级被整体替换,
// 运行期数据放那里会在下次升级时丢失。
WorkDir() string
}

View File

@ -0,0 +1,111 @@
package sdk
// SecretsAPI 是插件访问**内核变量库**的接口。
//
// ## 为什么要有它(去重)
//
// 在此之前,commondev 插件自带了一整套加密存储(`secrets.json` +
// AES-256-GCM + 别名表 + `${ENV}` 解析),与内核的 `internal/security`
// **完全重复**:
//
// 内核 security.Store 插件 unifiedSecretFile
// 落盘 secrets.json secrets.json
// 加密 AES-256-GCM AES-256-GCM
// 别名 HMAC 自动派生 用户指定
// 引用语法 @{sec_xxx} @{name}
//
// 重复的代价不只是代码量:**两套变量库互不可见** —— 内核拦截时登记的
// 变量,插件看不到;插件登记的,内核拦截不认识。结果是同一个凭据可能
// 在两处各存一份,而使用者不知道哪个才是权威。
//
// ## ★ 安全设计:明文的边界
//
// 内核的硬约束 S1 是「不提供取回明文的接口」—— 理由是一旦 LLM 能取回,
// 它就能把明文写回上下文,整层防护作废。
//
// 那为什么插件可以拿到明文?因为**边界不在插件侧**:
//
// ① 内核在**插件 handler 之前**就把工具参数里的 @{别名} 填好了
// (`StageHost.ExecuteTool` → `security.FillMap`)—— 插件本来
// 就能从 args 里读到明文,这是设计使然(否则插件无法用凭据)。
// ② 明文若被插件**返回**给内核,会被两处拦回别名:
// · `ContextEvent` 的 scrub(记忆/历史侧路)
// · provider 装饰器(出向,LLM 永不看到)
//
// 两条都有判据钉住(`leakback_test.go`)。所以:
//
// **暴露 Fill 不新增泄漏面** —— 它不是「开了一个口子」,
// 而是把「本来就存在的能力」从各插件自己实现改为统一接口。
//
// ⚠️ 但绝不能暴露 `Reveal(alias) -> 明文` 这样的**按名字直取**接口:
// 那会让插件(以及通过插件操纵它的 LLM)枚举别名并逐个取值。
// 本接口刻意**没有**这样的方法 —— 见下面 Fill 与 List 的签名。
//
// ## 与 Encrypt/Decrypt 的区别
//
// 插件常把凭据存进自己的配置文件(如 pi 的 `auth.json`)。那些文件权限
// 0600 但**明文落盘**。`Encrypt`/`Decrypt` 提供 at-rest 加密,
// 密钥由内核统一管理,插件不必自持密钥(自持会让密钥散落、无法轮换)。
type SecretsAPI interface {
// Store 把一个凭据登记进内核变量库,返回其**别名**。
//
// 之后在工具参数里写 @{别名},内核会在调用插件前换回真实值。
//
// ★ 只返回别名,不回显明文 —— 避免插件(或它转述给 LLM 的内容)
// 顺带把明文带出去。
//
// note 是给人看的用途说明(如「Gitea 推送令牌」)。
Store(value, note string) (alias string, err error)
// List 列出变量库的元信息(别名/来源/备注/命中次数)—— **不含明文**。
//
// ★ 刻意返回元信息而非值:调用方拿不到明文,只能引用。
List() []SecretInfo
// Forget 按别名删除一个变量。
//
// ★ 变量库**不自动淘汰**(用户明确):泄漏控制靠主动删除,不靠超时。
// 所以凭据用完后应主动删。
Forget(alias string) error
// Fill 把一段文本里的 @{别名} 换成明文。
//
// 用途:插件需要把明文喂给**不经过内核参数回填**的地方 ——
// 例如 commondev 的 enc_shell 要把命令经 **stdin** 送给 bash
//(不是 argv,避免本机其他进程从 /proc/<pid>/cmdline 读到明文)。
//
// ★ 为什么这个方法是安全的(而不是"开了个取回明文的口子"):
// 插件**本来就**能从 `args` 里拿到内核填好的明文 ——
// 内核在 handler 之前就填了。Fill 只是让插件能对**自己构造的文本**
// 做同样的事(如读文件内容、拼一段脚本)。
//
// 它**不是**按名字直取:要填谁必须先在文本里写出那个别名,
// 而别名本身不泄漏明文(HMAC 派生)。想枚举就得先 List,
// 而 List 只给元信息 —— 拿到别名后仍只能用,不能读回。
//
// 返回替换处数与用到的别名(便于插件做输出脱敏时的比对)。
Fill(text string) (out string, replaced int, aliases []string)
// Encrypt 加密一段字节用于插件自己落盘(密钥由内核管理)。
// 返回的串带版本前缀,可直接存文件。
Encrypt(plain []byte) (string, error)
// Decrypt 解密由 Encrypt 产生的串。
Decrypt(stored string) ([]byte, error)
// Available 报告变量库是否可用(内核未启用安全模块时为 false)。
//
// ★ 插件应据此**响亮失败**而不是静默降级 —— 例如
// 「密钥不可用 ⇒ 拒绝明文存储」。
Available() bool
}
// SecretInfo 是一条变量的元信息(**不含明文**)。
type SecretInfo struct {
Alias string `json:"alias"`
Source string `json:"source"` // intercepted | submitted
Note string `json:"note,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at,omitempty"`
Hits int `json:"hits,omitempty"`
}

View File

@ -0,0 +1,129 @@
package sdk
// SecurityAPI 是插件**声明**自身安全策略的接口。
//
// ## 为什么需要它(用户裁定 2026-10-26,2026-10-29 补齐实现)
//
// > 插件可以声明自身安全限制策略,并在插件自身 inputch 触发时携带参数
// > 选择其中一种 —— 正好对应 QQ 插件内部 owner 与非 owner 的差别对待。
//
// > 这是可选参数,不声明安全策略则使用内核默认安全策略。
//
// ## 分工:插件只声明,内核只执行
//
// 插件:DeclareProfile / DeclareProfiles (只声明,不判定)
// 内核:CheckTool / CheckToolFor / ActivateProfile (只执行,不信任插件)
//
// ★ 为什么判定必须在内核:插件崩溃或被重载时,声明不能跟着消失
// (否则"挂掉插件"就是绕过策略的手段);而且判定要与全局基线、主体策略
// 走同一条链 —— 分散到各插件就是"被约束者自己决定约束",
// 本仓已有实测教训(QQ 的 beforeToolcall 自己判身份/白名单/硬私有,
// 结果每个插件各写一套、拒绝语义散落、插件崩了策略就没了)。
//
// ## 声明 vs 全局基线:两层,职责不重叠
//
// ① 全局基线(部署方配置 core.security.*):不受插件影响,防"插件自己放水"
// ② 档位(本接口):单次请求的**收窄**
//
// 档位**只能收窄**:内核的判定顺序是「全局 deny → 档位 deny → 主体 deny
// → 全局 allow → 档位 allow」,全局黑名单永远先判,档位绕不过去。
//
// ## 缺省语义(重要)
//
// 不声明 / 不传 = 不使用档位,一切判定沿用内核默认策略
//
// 也就是说这是**可选**能力:不用的插件行为与之前完全一致。
// 而一旦传了**未声明**的档位名,内核会**报错**而不是静默回落 ——
// 拼错档位名却默默用默认策略是安全事故的经典形态。
//
// ## 典型用法(QQ 插件)
//
// // Start 阶段:声明两个档位(只声明,不判定)
// s.Security().DeclareProfiles([]SecurityProfile{
// {Name: "qq.owner", Owner: "qq", AllowTools: []string{"*"}},
// {Name: "qq.guest", Owner: "qq",
// DenyTools: []string{"cmd_*", "files_write"},
// SensitiveWords: []string{"内部代号"}},
// })
//
// // 注入阶段:按发送者身份选择档位
// s.InjectTextOpts(source, channel, text, sdk.InjectOptions{
// SecurityProfile: "qq.guest",
// })
//
// ## 名字约束
//
// 档位名必须是 `<owner>.<档位>`,其中 owner 等于插件名。这条在内核侧
// 强制(否则插件 A 能声明 `qq.owner` 并夺走插件 B 的档位语义)。
type SecurityAPI interface {
// DeclareProfile 声明一个命名档位。同名重复声明会覆盖(同一 owner
// 只能覆盖自己的档位,不能覆盖别人的)。
//
// 返回 error 的情形:
// · 名字不符合 `<owner>.<档位>` 形式
// · owner 与本插件名不一致(试图声明别人的档位)
// · 安全模块未启用(此时没有可声明的策略容器)
DeclareProfile(p SecurityProfile) error
// DeclareProfiles 批量声明(任一失败即整体失败,便于早失败)。
//
// 为什么提供批量版:QQ 这类插件有 3~4 个档位,逐个判断 error 会让
// Start 里堆满重复代码;而"声明到一半失败"是更难排查的状态。
DeclareProfiles(ps []SecurityProfile) error
}
// SecurityProfile 是一个**命名安全档位**的声明。
//
// 与 ToolPolicy 类配置的区别:档位是**运行时可选**的(按 inputch 输入
// 携带的参数选择),不是全局生效的。
type SecurityProfile struct {
// Name 是档位名,必须形如 `<owner>.<档位>`(如 "qq.guest")。
Name string
// Owner 是声明者(插件名)。与 Name 的前缀必须一致。
Owner string
// AllowTools / DenyTools 是本档位下的工具白/黑名单(支持尾部 `*` 前缀)。
//
// 语义(与全局策略一致,且**只能收窄**):
// · DenyTools 命中 ⇒ 拒绝(且它叠加在全局黑名单之上,不能反向放行)
// · AllowTools 非空且未命中 ⇒ 拒绝
// · 两者都空 ⇒ 本档位不额外限制
AllowTools []string
DenyTools []string
// SensitiveWords 是本档位额外追加的敏感词(在全局敏感词之上追加)。
//
// 用途:某些词只在特定身份下才敏感(如非所有者对话时不该看到内部代号)。
SensitiveWords []string
// ToolSensitiveWords 是**按工具**追加的敏感词(在 SensitiveWords 之上再追加)。
//
// key = 工具名模式,支持尾部 `*`(与 AllowTools/DenyTools 同一套匹配规则)
// value = 该工具额外追加的词
//
// 用途:不同工具带回来的外部内容风险不同 —— `web_fetch` 拿的是网页正文
// (间接提示词注入的主入口),而 `qq_get_message` 拿的是聊天记录。
// 插件最清楚自己每个工具的内容形态,所以词表由插件按工具声明。
//
// ★ 语义是**追加不是覆盖**:整轮 SensitiveWords 仍然生效。
// 当前处置是 B 档(标记+警示,不阻断),多标一条只是多一句警示,
// 漏标则是安全缺口 —— 所以取宽不取窄。
//
// ToolSensitiveWords: map[string][]string{
// "web_fetch": {"内部代号", "机密"},
// "qq_get_*": {"客户名单"},
// }
ToolSensitiveWords map[string][]string
// DetectInjection 开启内核内置的**提示词注入迹象**识别。
//
// 与 ToolSensitiveWords 的分工:词表是插件给的**数据**(它知道自己的
// 哪些词敏感),注入迹象是内核内置的**形态**规则(ignore previous /
// 角色劫持 / 索要系统提示 / 伪造更高优先级)。
//
// ★ 默认 false:注入识别会命中"扮演""你现在是"这类正常对话词,
// 默认开启会让常识对话充满误报 —— 由插件按自己的内容形态显式声明。
//
// ★ 当前处置是 B 档:命中只**标记并警示**,不阻断。
DetectInjection bool
}

View File

@ -0,0 +1,78 @@
package sdk
type SettingsAPI interface {
// Get reads the plugin's own config value (config_<name> table).
Get(key string) (interface{}, error)
// Set writes a config value to the plugin's own config table.
Set(key string, value interface{}) error
// SetSecret writes a value that is ALWAYS encrypted at rest, regardless of
// the key name. Use it when the key name is neutral (e.g. "blob", "conn")
// and so cannot be detected as sensitive automatically.
//
// Returns an error (not a silent plaintext write) when no master key is
// installed — a plugin that believes it stored a secret must not end up
// with plaintext on disk.
//
// The companion read is GetSecret. ConfigDef.Secret/Type=="password" and
// sensitive key names are encrypted automatically and do NOT need this.
SetSecret(key string, value interface{}) error
// GetSecret reads a value written by SetSecret (transparently decrypted).
GetSecret(key string) (string, error)
// List returns all keys matching the given prefix.
List(prefix string) ([]string, error)
// GetCore reads the core config table.
GetCore(key string) (interface{}, error)
// SetCore writes to the core config table.
SetCore(key string, value interface{}) error
// ListCore lists core config keys matching the prefix.
ListCore(prefix string) ([]string, error)
// DataDir returns the plugin-specific data directory (guaranteed to exist):
// <daemon data>/plugin_data/<plugin_name>. Plugins should persist any
// runtime files (generated images, caches, downloads) here.
DataDir() string
// GetPlugin reads another plugin's config table.
GetPlugin(plugin, key string) (interface{}, error)
// SetPlugin writes to another plugin's config table.
SetPlugin(plugin, key string, value interface{}) error
// ListPlugin lists another plugin's config keys matching the prefix.
ListPlugin(plugin, prefix string) ([]string, error)
// RegisterDef registers a config definition for UI display.
RegisterDef(def ConfigDef)
// Defs returns config definitions matching the prefix.
Defs(prefix string) []*ConfigDef
// Dump returns all config values.
Dump() map[string]interface{}
// Plugins returns a list of all plugin config namespaces.
Plugins() []string
}
// ConfigDef describes a configuration field for the WebUI.
type ConfigDef struct {
Key string `json:"key"`
Default interface{} `json:"default,omitempty"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
Options []string `json:"options,omitempty"`
Min float64 `json:"min,omitempty"`
Max float64 `json:"max,omitempty"`
Step float64 `json:"step,omitempty"`
Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"`
}

View File

@ -0,0 +1,760 @@
package sdk
import (
"encoding/json"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
)
// SDK 公开接口的并发压力测试(1.1.0 媒体接口上线后新增)。
//
// 为什么这一层需要压测:SDK 是**被多个 goroutine 同时使用的共享对象**。
// 一个插件的典型形态是 Start() 里起若干后台 goroutine(轮询、监听、定时器),
// 它们各自持同一个 *PluginSDK 往里注入消息;内核侧同时还有 stage 扇出、
// 工具调用、以及读 AutoRestart() 决定崩溃后是否重启。
// 单线程单测全绿不代表这些并发路径成立。
//
// 关注点不是吞吐数字,而是不变量:
// 1. 注入调用不丢、不串(媒体块必须与文本配对,不能张冠李戴)
// 2. 状态字段的读写不产生数据竞争(-race 下必须干净)
// 3. handler 注册/执行在并发下"恰好一次"
// 4. 跨进程 JSON 序列化对新媒体类型必须字节级往返一致
//
// 媒体接口尤其需要 3 与 4:媒体块要经 JSON 过子进程边界,
// 而 []byte 在 JSON 里是 base64,往返不一致的后果是图片静默损坏。
// ---------- 测试替身 ----------
// recordingInjector 记录每一次注入调用,用于验证"不丢不串"。
type recordingInjector struct {
mu sync.Mutex
calls []injectCall
// 计数用原子量:并发路径上只增不减,可在不持锁时安全读。
nText, nMedia, nInterrupt, nSync atomic.Int64
}
type injectCall struct {
kind string // text / media / interruptMedia / sync ...
source string
channel string
text string
blocks []ContentBlock
opts InjectOptions // 调用点声明的记忆/裁剪行为
}
func (r *recordingInjector) record(c injectCall) {
r.mu.Lock()
r.calls = append(r.calls, c)
r.mu.Unlock()
}
func (r *recordingInjector) InjectInterruptText(s, c, t string) {
r.nInterrupt.Add(1)
r.record(injectCall{kind: "interruptText", source: s, channel: c, text: t})
}
func (r *recordingInjector) InjectText(s, c, t string) {
r.nText.Add(1)
r.record(injectCall{kind: "text", source: s, channel: c, text: t})
}
func (r *recordingInjector) InjectTextNoMemory(s, c, t string) {
r.nText.Add(1)
r.record(injectCall{kind: "textNoMem", source: s, channel: c, text: t})
}
func (r *recordingInjector) InjectInputSync(s, c, t string) string {
r.nSync.Add(1)
r.record(injectCall{kind: "sync", source: s, channel: c, text: t})
return "reply:" + t
}
func (r *recordingInjector) SetToolBlocks(blocks []ContentBlock) {
r.record(injectCall{kind: "toolBlocks", blocks: blocks})
}
func (r *recordingInjector) InjectInputMedia(s, c, t string, b []ContentBlock) {
r.nMedia.Add(1)
r.record(injectCall{kind: "media", source: s, channel: c, text: t, blocks: b})
}
func (r *recordingInjector) InjectInputMediaSync(s, c, t string, b []ContentBlock) string {
r.nMedia.Add(1)
r.nSync.Add(1)
r.record(injectCall{kind: "mediaSync", source: s, channel: c, text: t, blocks: b})
return "reply:" + t
}
func (r *recordingInjector) InjectInterruptMedia(s, c, t string, b []ContentBlock) {
r.nMedia.Add(1)
r.record(injectCall{kind: "interruptMedia", source: s, channel: c, text: t, blocks: b})
}
// ---- 带 InjectOptions 的注入:记录 opts 以便测试断言标志位确实传到了内核 ----
func (r *recordingInjector) InjectTextOpts(s, c, t string, o InjectOptions) {
r.nText.Add(1)
r.record(injectCall{kind: "textOpts", source: s, channel: c, text: t, opts: o})
}
func (r *recordingInjector) InjectInterruptTextOpts(s, c, t string, o InjectOptions) {
r.nInterrupt.Add(1)
r.record(injectCall{kind: "interruptTextOpts", source: s, channel: c, text: t, opts: o})
}
func (r *recordingInjector) InjectInputSyncOpts(s, c, t string, o InjectOptions) string {
r.nSync.Add(1)
r.record(injectCall{kind: "syncOpts", source: s, channel: c, text: t, opts: o})
return "reply:" + t
}
func (r *recordingInjector) InjectInputMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
r.nMedia.Add(1)
r.record(injectCall{kind: "mediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
}
func (r *recordingInjector) InjectInputMediaSyncOpts(s, c, t string, b []ContentBlock, o InjectOptions) string {
r.nMedia.Add(1)
r.nSync.Add(1)
r.record(injectCall{kind: "mediaSyncOpts", source: s, channel: c, text: t, blocks: b, opts: o})
return "reply:" + t
}
func (r *recordingInjector) InjectInterruptMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
r.nMedia.Add(1)
r.record(injectCall{kind: "interruptMediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
}
func (r *recordingInjector) snapshot() []injectCall {
r.mu.Lock()
defer r.mu.Unlock()
return append([]injectCall{}, r.calls...)
}
var _ IOInjector = (*recordingInjector)(nil)
// imageBlock 构造一个带可识别 URL 的图片块。
func imageBlock(tag string) ContentBlock {
return ContentBlock{
Type: "image_url",
ImageURL: &ImageURL{URL: "data:image/png;base64," + tag, Detail: "auto"},
}
}
// ---------- 1. 媒体注入并发不丢不串 ----------
// 三个媒体注入方法在高并发下必须:调用数精确、且每次调用的 text 与 blocks 配对不错。
//
// "不串"是这里的关键断言。注入是插件里最容易被后台 goroutine 并发调用的入口,
// 若实现里出现任何共享中间状态(比如把 blocks 暂存到 SDK 字段再读出),
// 高并发下就会出现 A 的文本配上 B 的图——而两者单独看都"成功"了,不报错。
func TestStress_MediaInjectionConcurrentNoCrossTalk(t *testing.T) {
const workers, perWorker = 32, 200
inj := &recordingInjector{}
s := &PluginSDK{name: "stress"}
s.SetIOInjector(inj)
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < perWorker; i++ {
// tag 唯一标识这次调用,文本与图片 URL 里都带上它。
tag := fmt.Sprintf("w%d-i%d", w, i)
switch i % 3 {
case 0:
s.InjectInputMedia("src", "ch", tag, []ContentBlock{imageBlock(tag)})
case 1:
if got := s.InjectInputMediaSync("src", "ch", tag, []ContentBlock{imageBlock(tag)}); got != "reply:"+tag {
t.Errorf("同步注入回复错位: got %q want %q", got, "reply:"+tag)
}
default:
s.InjectInterruptMedia("src", "ch", tag, []ContentBlock{imageBlock(tag)})
}
}
}(w)
}
wg.Wait()
total := int64(workers * perWorker)
if got := inj.nMedia.Load(); got != total {
t.Fatalf("媒体注入调用数 = %d,期望 %d(有调用丢失)", got, total)
}
// 逐条校验文本与媒体块配对:URL 必须含该次调用自己的 tag。
seen := map[string]bool{}
for _, c := range inj.snapshot() {
if len(c.blocks) == 0 {
continue
}
if c.blocks[0].ImageURL == nil {
t.Fatalf("媒体块 ImageURL 丢失: %+v", c.blocks[0])
}
if !strings.HasSuffix(c.blocks[0].ImageURL.URL, c.text) {
t.Fatalf("文本与媒体块错位: text=%q url=%q", c.text, c.blocks[0].ImageURL.URL)
}
if seen[c.text] {
t.Fatalf("同一次调用被记录两次: %s", c.text)
}
seen[c.text] = true
}
if len(seen) != int(total) {
t.Fatalf("去重后调用数 = %d,期望 %d", len(seen), total)
}
}
// ---------- 2. 注入期间热替换 injector ----------
// 内核在插件运行期间可能重新注入 API(重载、恢复、子进程重连握手)。
// 此时插件的后台 goroutine 仍在注入。这条路径若无同步就是对 s.io 的数据竞争,
// 在 -race 下会被抓出;生产表现是偶发 nil 解引用崩溃。
func TestStress_InjectorSwapDuringInjection(t *testing.T) {
s := &PluginSDK{name: "stress"}
s.SetIOInjector(&recordingInjector{})
stop := make(chan struct{})
var injectors, swapper sync.WaitGroup
// 注入方:持续打直到 stop
for w := 0; w < 8; w++ {
injectors.Add(1)
go func() {
defer injectors.Done()
for {
select {
case <-stop:
return
default:
s.InjectInputMedia("src", "ch", "x", []ContentBlock{imageBlock("x")})
s.InjectText("src", "ch", "y")
}
}
}()
}
// 替换方:反复换 injector(含换成 nil——内核卸载 API 时的真实状态)
swapper.Add(1)
go func() {
defer swapper.Done()
for i := 0; i < 500; i++ {
if i%7 == 0 {
s.SetIOInjector(nil)
} else {
s.SetIOInjector(&recordingInjector{})
}
}
}()
// 先等替换跑完,再告知注入方退出。
// 顺序写反了就是死锁:注入方只依 close(stop) 退出。
swapper.Wait()
close(stop)
injectors.Wait()
// 断言就是「没崩、-race 没报」。nil injector 时必须静默跳过而非 panic。
}
// ---------- 3. autoRestart 标志的并发读写 ----------
// SetAutoRestart 的文档用途是"插件有无法恢复的状态(如外部连接)时设为 false"——
// 而连接建立本身通常是异步的,所以这个写入天然发生在后台 goroutine。
// 内核侧 registry 在另一个 goroutine 读 AutoRestart() 决定崩溃后是否重启。
// 这是一对跨 goroutine 的读写,必须同步。
func TestStress_AutoRestartFlagConcurrent(t *testing.T) {
s := &PluginSDK{name: "stress", autoRestart: true}
var wg sync.WaitGroup
for w := 0; w < 16; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < 500; i++ {
s.SetAutoRestart(i%2 == 0)
}
}(w)
}
// 读方模拟内核 registry
for r := 0; r < 8; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 500; i++ {
_ = s.AutoRestart()
}
}()
}
wg.Wait()
}
// ---------- 4. stop / onRemove handler 的"恰好一次" ----------
// RunStopHandlers 的契约是"执行后清空,幂等"。内核在停止插件时可能并发触发
// (超时强杀与正常 Stop 竞争),handler 里往往是关连接、落盘——
// 执行两次的后果从"重复写文件"到"close 已关闭的 channel 直接 panic"。
func TestStress_StopHandlersExactlyOnce(t *testing.T) {
const n = 300
s := &PluginSDK{name: "stress"}
var counters [n]atomic.Int64
for i := 0; i < n; i++ {
i := i
s.RegisterStopHandler(func() { counters[i].Add(1) })
}
var wg sync.WaitGroup
for w := 0; w < 16; w++ {
wg.Add(1)
go func() {
defer wg.Done()
s.RunStopHandlers()
}()
}
wg.Wait()
for i := 0; i < n; i++ {
if got := counters[i].Load(); got != 1 {
t.Fatalf("handler %d 执行 %d 次,期望恰好 1 次", i, got)
}
}
}
// 注册与执行并发:已注册的 handler 一次都不能多跑,未跑到的也不能被丢。
// 断言用"每个 handler 的执行次数 <= 1"而非"总数相等"——
// 与 RunStopHandlers 竞争的注册可能落在快照之后,那属于合法的未执行。
func TestStress_StopHandlersRegisterWhileRunning(t *testing.T) {
s := &PluginSDK{name: "stress"}
const n = 500
var counters [n]atomic.Int64
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < n; i++ {
i := i
s.RegisterStopHandler(func() { counters[i].Add(1) })
}
}()
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
s.RunStopHandlers()
}
}()
wg.Wait()
s.RunStopHandlers() // 收尾:把剩下的都跑掉
for i := 0; i < n; i++ {
if got := counters[i].Load(); got > 1 {
t.Fatalf("handler %d 被执行 %d 次(重复执行)", i, got)
}
}
}
func TestStress_OnRemoveHandlersExactlyOnce(t *testing.T) {
const n = 200
s := &PluginSDK{name: "stress"}
var counters [n]atomic.Int64
for i := 0; i < n; i++ {
i := i
s.RegisterOnRemoveHandler(func() { counters[i].Add(1) })
}
var wg sync.WaitGroup
for w := 0; w < 12; w++ {
wg.Add(1)
go func() {
defer wg.Done()
s.RunOnRemoveHandlers()
}()
}
wg.Wait()
for i := 0; i < n; i++ {
if got := counters[i].Load(); got != 1 {
t.Fatalf("onRemove handler %d 执行 %d 次,期望恰好 1 次", i, got)
}
}
}
// ---------- 5. StageContext 并发读改写 ----------
// StageContext 是全部 stage handler 共享的可变状态,字段全导出、靠调用方自觉
// 持 Lock/RLock。媒体链路让 Extra 成为新热点(media_blocks 挂在这里),
// 而 map 的并发写在 Go 里是直接 fatal,recover 都接不住。
//
// 这条测试锁定的不变量:按约定持锁的并发读改写不丢更新、不 fatal。
func TestStress_StageContextConcurrentExtraAndFinalText(t *testing.T) {
ctx := &StageContext{Extra: map[string]interface{}{}}
const workers, rounds = 16, 200
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < rounds; i++ {
// 写:模拟插件往 Extra 塞媒体块并追加文本(读-改-写)
ctx.Lock()
ctx.Extra[fmt.Sprintf("k%d-%d", w, i)] = []ContentBlock{imageBlock("x")}
ctx.FinalText += "."
ctx.Unlock()
// 读:模拟另一个 handler 检查是否已被响应
_ = ctx.IsResponded()
ctx.RLock()
_ = len(ctx.Extra)
ctx.RUnlock()
}
}(w)
}
wg.Wait()
ctx.RLock()
defer ctx.RUnlock()
if len(ctx.Extra) != workers*rounds {
t.Fatalf("Extra 键数 = %d,期望 %d(出现 lost update)", len(ctx.Extra), workers*rounds)
}
if len(ctx.FinalText) != workers*rounds {
t.Fatalf("FinalText 长度 = %d,期望 %d(出现 lost update)", len(ctx.FinalText), workers*rounds)
}
}
// ---------- 6. OwnTools scope 包装器的并发正确性 ----------
// StageScopeOwnTools 的包装闭环里要读 ctx.ToolCalls 判断归属。
// 并发下若判断与执行之间状态被改写,就会出现"别人的工具触发了我的 handler"——
// 后果是插件对不属于自己的工具结果动手,且没有任何错误。
func TestStress_OwnToolsScopeNoCrossPluginLeak(t *testing.T) {
var registered StageHandler
s := &PluginSDK{
name: "mine",
regStage: func(stage Stage, h StageHandler) { registered = h },
}
var fired atomic.Int64
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
fired.Add(1)
ctx.RLock()
defer ctx.RUnlock()
// 触发了就必须确实是自己的工具
if len(ctx.ToolCalls) == 0 || ctx.ToolCalls[0].Plugin != "mine" {
t.Errorf("handler 被别的插件的工具触发: %+v", ctx.ToolCalls)
}
return nil
}, StageScopeOwnTools)
if registered == nil {
t.Fatal("handler 未注册")
}
const workers, rounds = 16, 100
var wg sync.WaitGroup
var mineCount atomic.Int64
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < rounds; i++ {
// 每个 goroutine 用自己的 ctx——真实内核里 stage 扇出共享同一个
// ctx,但那部分的并发由内核 host 仲裁;这里验证包装器本身。
owner := "other"
if (w+i)%2 == 0 {
owner = "mine"
mineCount.Add(1)
}
ctx := &StageContext{Extra: map[string]interface{}{}}
ctx.ToolCalls = []ToolCall{{Plugin: owner, Name: "t"}}
if err := registered(ctx); err != nil {
t.Errorf("handler 返回错误: %v", err)
}
}
}(w)
}
wg.Wait()
if got, want := fired.Load(), mineCount.Load(); got != want {
t.Fatalf("handler 触发 %d 次,期望 %d 次(漏触发或跨插件触发)", got, want)
}
}
// ---------- 7. 媒体类型的 JSON 往返(跨进程边界的真实形态) ----------
// 媒体块与附件要经 JSON 过子进程边界。[]byte 在 JSON 里是 base64,
// 往返不一致的后果是图片字节静默损坏——落进 CAS 后 digest 校验才会发现,
// 而那时已经无从追查是谁改坏的。
func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
// 覆盖真实会遇到的边界:空、单字节、含 0x00、全 0xFF、超过 base64 分组边界的长度
sizes := []int{0, 1, 2, 3, 255, 256, 1023, 4096, 65537}
for _, n := range sizes {
data := make([]byte, n)
for i := range data {
data[i] = byte(i * 7 % 256)
}
att := MediaAttachment{
Digest: strings.Repeat("a", 64),
MIME: "image/png",
Data: data,
Name: "图片-名字 with space & 符号.png",
}
b, err := json.Marshal(att)
if err != nil {
t.Fatalf("size=%d marshal: %v", n, err)
}
var back MediaAttachment
if err := json.Unmarshal(b, &back); err != nil {
t.Fatalf("size=%d unmarshal: %v", n, err)
}
if len(back.Data) != n {
t.Fatalf("size=%d 往返后长度 = %d", n, len(back.Data))
}
for i := range data {
if back.Data[i] != data[i] {
t.Fatalf("size=%d 第 %d 字节损坏: %02x != %02x", n, i, back.Data[i], data[i])
}
}
if back.Name != att.Name || back.MIME != att.MIME || back.Digest != att.Digest {
t.Fatalf("size=%d 元数据往返不一致: %+v", n, back)
}
}
}
// omitempty 必须真的生效:读路径上内核不回 Data,若序列化仍产出 "data":null
// 之类的键,跨进程消息会凭空变大,且插件侧无法区分"没有字节"与"空字节"。
func TestStress_MediaTypesOmitEmpty(t *testing.T) {
cases := []struct {
name string
v interface{}
absent []string
present []string
}{
{
name: "Triple 无媒体",
v: Triple{Subject: "甲方", Relation: "签署", Object: "合同"},
absent: []string{"media_digests", "sentence_text", "confidence", "subject_type", "object_type"},
present: []string{"subject", "relation", "object"},
},
{
name: "Triple 带媒体",
v: Triple{Subject: "甲方", Relation: "包含", Object: "图", MediaDigests: []string{"abc12345"}, SentenceText: "句子"},
absent: []string{"confidence"},
present: []string{"media_digests", "sentence_text"},
},
{
name: "Doc 读路径无字节",
v: Doc{ID: "d1", Title: "标题", Content: "正文", Attachments: []MediaAttachment{{Digest: "abc12345", MIME: "image/png"}}},
absent: []string{"\"data\"", "media_digests", "score"},
present: []string{"attachments", "digest", "mime"},
},
{
name: "TextEvent 无附件",
v: TextEvent{Role: "user", Content: "hi"},
absent: []string{"attachments", "channel"},
present: []string{"role", "content"},
},
{
name: "ContentBlock 纯文本",
v: ContentBlock{Type: "text", Text: "hi"},
absent: []string{"image_url", "audio_url"},
present: []string{"type", "text"},
},
{
name: "ContentBlock 图片",
v: imageBlock("AAA"),
absent: []string{"audio_url", "\"text\""},
present: []string{"image_url", "detail"},
},
}
for _, c := range cases {
b, err := json.Marshal(c.v)
if err != nil {
t.Fatalf("%s marshal: %v", c.name, err)
}
s := string(b)
for _, k := range c.absent {
if strings.Contains(s, k) {
t.Errorf("%s: 不该出现的键 %s —— %s", c.name, k, s)
}
}
for _, k := range c.present {
if !strings.Contains(s, k) {
t.Errorf("%s: 缺少键 %s —— %s", c.name, k, s)
}
}
}
}
// 媒体块在并发序列化下必须各自独立:ImageURL/AudioURL 是指针,
// 若某处复用同一个指针再改写,序列化结果会互相污染。
func TestStress_ContentBlockConcurrentMarshal(t *testing.T) {
const workers, rounds = 16, 300
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < rounds; i++ {
tag := fmt.Sprintf("w%d-i%d", w, i)
blocks := []ContentBlock{
{Type: "text", Text: tag},
imageBlock(tag),
{Type: "audio_url", AudioURL: &AudioURL{URL: "data:audio/wav;base64," + tag}},
}
b, err := json.Marshal(blocks)
if err != nil {
t.Errorf("marshal: %v", err)
return
}
var back []ContentBlock
if err := json.Unmarshal(b, &back); err != nil {
t.Errorf("unmarshal: %v", err)
return
}
if len(back) != 3 {
t.Errorf("块数 = %d", len(back))
return
}
if back[0].ImageURL != nil || back[0].AudioURL != nil {
t.Errorf("文本块被填了媒体指针: %+v", back[0])
}
if back[1].ImageURL == nil || !strings.HasSuffix(back[1].ImageURL.URL, tag) {
t.Errorf("图片块 URL 错位: %+v", back[1].ImageURL)
}
if back[1].AudioURL != nil {
t.Errorf("图片块被填了音频指针")
}
if back[2].AudioURL == nil || !strings.HasSuffix(back[2].AudioURL.URL, tag) {
t.Errorf("音频块 URL 错位: %+v", back[2].AudioURL)
}
}
}(w)
}
wg.Wait()
}
// ---------- 8. 注册面的并发 ----------
// 插件在 Start() 里起多个 goroutine 分别注册工具是常见写法。
// def.Plugin 的默认填充若不是每次调用独立的,就会出现工具归属错乱——
// 表现是 OwnTools scope 失效、WebUI 里工具挂在别的插件名下。
func TestStress_RegisterToolConcurrentPluginDefaulting(t *testing.T) {
var mu sync.Mutex
got := map[string]string{} // toolName -> def.Plugin
s := &PluginSDK{
name: "mine",
regTool: func(name string, def ToolDef, h ToolHandler) error {
mu.Lock()
got[name] = def.Plugin
mu.Unlock()
return nil
},
}
const workers, perWorker = 16, 100
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < perWorker; i++ {
name := fmt.Sprintf("tool_w%d_i%d", w, i)
def := ToolDef{Description: "d", Parameters: map[string]interface{}{}}
// 一半显式指定归属,一半靠 SDK 填默认值
if i%2 == 0 {
def.Plugin = "explicit"
}
if err := s.RegisterTool(name, def, func(map[string]interface{}) (interface{}, error) {
return nil, nil
}); err != nil {
t.Errorf("RegisterTool: %v", err)
}
}
}(w)
}
wg.Wait()
if len(got) != workers*perWorker {
t.Fatalf("注册工具数 = %d,期望 %d", len(got), workers*perWorker)
}
for name, owner := range got {
want := "mine"
if isEvenSuffix(name) {
want = "explicit"
}
if owner != want {
t.Fatalf("工具 %s 归属 = %q,期望 %q", name, owner, want)
}
}
}
// isEvenSuffix 判断 tool_wX_iY 里的 Y 是否为偶数。
func isEvenSuffix(name string) bool {
idx := strings.LastIndex(name, "_i")
if idx < 0 {
return false
}
n := 0
if _, err := fmt.Sscanf(name[idx+2:], "%d", &n); err != nil {
return false
}
return n%2 == 0
}
// nil 依赖下所有便捷方法必须静默降级而非 panic。
//
// 这是"媒体存储可关闭"在 SDK 层的对应物:内核未注入某个 API 时
// (精简部署、插件权限不足、子进程握手尚未完成),插件的调用不该崩。
func TestStress_NilDependenciesDegradeSilently(t *testing.T) {
s := &PluginSDK{name: "bare"}
const workers = 16
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 200; i++ {
s.InjectText("s", "c", "t")
s.InjectTextNoMemory("s", "c", "t")
s.InjectInterruptText("s", "c", "t")
if got := s.InjectInputSync("s", "c", "t"); got != "" {
t.Errorf("无 injector 时同步注入应返回空串,got %q", got)
}
s.InjectInputMedia("s", "c", "t", []ContentBlock{imageBlock("x")})
if got := s.InjectInputMediaSync("s", "c", "t", nil); got != "" {
t.Errorf("无 injector 时媒体同步注入应返回空串,got %q", got)
}
s.InjectInterruptMedia("s", "c", "t", nil)
// getter 全部应返回 nil 而非 panic
_ = s.Memory()
_ = s.TextMemory()
_ = s.DocMemory()
_ = s.Knowledge()
_ = s.LLM()
_ = s.Social()
_ = s.Events()
_ = s.PluginMgr()
_ = s.Settings()
// 注册面无 registrar 时应返回 nil error
if err := s.RegisterTool("t", ToolDef{}, nil); err != nil {
t.Errorf("无 registrar 时 RegisterTool 应返回 nil,got %v", err)
}
if err := s.RegisterPluginAPI("a"); err != nil {
t.Errorf("无 registrar 时 RegisterPluginAPI 应返回 nil,got %v", err)
}
s.RegisterStage(StageOnInput, func(*StageContext) error { return nil })
}
}()
}
wg.Wait()
}