mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
feat: 模块化内核(编译期依赖图定序)+ 密钥防火墙 + 插件三态生命周期 (#10)
* feat(kernel): 模块化内核 + 编译期依赖图定序
把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。
## 为什么
原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
· nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
只靠 ticker 10min 才没炸(隐式依赖,无声明)
· 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
· 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)
## 怎么改
- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)
## 判据
kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
· 删掉 AddCloser 的锁 → -race 报数据竞争
· Scheduler 同批跑去重 → 203 vs 200
· 图 hash 不匹配 → 拒绝启动
## 顺带修的缺陷
热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。
实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。
* feat(security): 密钥防火墙 + 配置加密 + close 纪律
## 密钥防火墙
模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
· 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
· 入向:模型传别名,入口处填回真值(插件完全无感知)
· 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件
模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。
## 配置加密
- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计
## close 纪律(用户明确要求:只要调 close 就要有前置检查)
internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
· `select { case <-ch: default: close(ch) }` **不是**并发安全的
(两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
· `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
· map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
改成 *pendingRPC 包装,每通道一个 once
## 判据
security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。
* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复
## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)
- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
"未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
(不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
那是进程退出用的,误用于"暂停"就是关了就死
## 数据目录迁移(伪关机 → 迁移 → 唤醒)
- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
→ 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本
★ 迁移脚本的三个实测教训(都写成了判据):
1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)
## 发行包修复(全部经实测确认)
1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
("生产构建(内嵌 hash 非空)但读不到依赖图")
2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
⇒ --version 显示 unknown
4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH
## ★ build.sh 的版本号必须校验(真实事故)
原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。
修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。
## deb 的 systemd unit 改为 guard 模式
ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。
## 其他
- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)
* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)
- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
"出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
mgrIsRuntimeStopped)+ settings 对齐
判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。
* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)
GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:
internal/security/prefilter_test.go:39
internal/security/store_test.go:177
internal/security/survey_test.go:20
它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。
★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。
改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
glpat-abcdefghijklmnopqrst → glpat-FAKE-TEST-TOKEN-NOT-REAL
sk-abcdefghij...012345 → sk-FAKE-TEST-KEY-NOT-REAL-0000000
AKIAIOSFODNN7EXAMPLE → AKIA0000FAKE0000TEST (保持 20 字符)
ASIAIOSFODNN7EXAMPLE → ASIA0000FAKE0000TEST
xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL
★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。
顺带核实:全仓 grep 无任何真实密钥泄漏。
* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一
上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:
VERSION="${VERSION:-$(git describe --tags --dirty)}"
无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。
危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。
修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。
判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。
实测:不传 VERSION 跑 package-linux.sh,输出
[PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
Version: 1.4.0
* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)
CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:
--- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
bridge_sync_test.go:170: 读模板: open
../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
no such file or directory
## 根因:判据依赖了一个**被 gitignore 的路径**
`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
· 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
· CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**
我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。
## 修法
文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。
★ 这不是"放弃检查":
· 跨仓一致性由 **SDK 仓自己的判据**守着
(tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
· 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)
## 验证
· 本机(有文件) → PASS(判据真跑)
· 模拟 CI(移走 tools/)→ SKIP(不再 fail)
· 全量 53 包 ok / 0 fail
## 顺带
同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。
---------
Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
This commit is contained in:
@ -84,11 +84,12 @@ import "unsafe"
|
||||
// codecABIVersionExpected 是 C 侧 ha_abi.h 里 HA_CODEC_ABI_VERSION 的 Go 副本。
|
||||
//
|
||||
// ★ 为什么要手工拄一份而不是让 cgo 直接读宏:
|
||||
// cgo 顶部的 C 代码在 cgo 阶段被**预处理并丢弃**,其中的宏在 Go 侧不可见;
|
||||
// 能看到的只有 cgo 生成的文件。用 cgo 的 `const` 桥接(C.ha_codec_abi_version)只能在
|
||||
// **运行期**问到版本,编译期拿不到,无法把「两侧版本不一致」变成构建失败。
|
||||
// 而「注释里说冻结」不是机制。这份 Go 常量 + codec_abiversion_test.go
|
||||
// 把版本漂移变成**测试期断言**,真正对得上才跑得起来。
|
||||
//
|
||||
// cgo 顶部的 C 代码在 cgo 阶段被**预处理并丢弃**,其中的宏在 Go 侧不可见;
|
||||
// 能看到的只有 cgo 生成的文件。用 cgo 的 `const` 桥接(C.ha_codec_abi_version)只能在
|
||||
// **运行期**问到版本,编译期拿不到,无法把「两侧版本不一致」变成构建失败。
|
||||
// 而「注释里说冻结」不是机制。这份 Go 常量 + codec_abiversion_test.go
|
||||
// 把版本漂移变成**测试期断言**,真正对得上才跑得起来。
|
||||
//
|
||||
// 改动规则(与 ha_abi.h 一致):
|
||||
// - C 侧新增函数/枚举值(纯追加)→ 同步把这里 +1,并改 abi_test 的期望
|
||||
@ -101,20 +102,21 @@ const (
|
||||
// codecABIVersionMacroValue 查询 C 侧 HA_CODEC_ABI_VERSION 宏展开后的值。
|
||||
//
|
||||
// ★ 它的存在是为了堵一个盲区:TestABIVersionMatches 比的是
|
||||
// 「Go 常量 vs C 函数返回值」。若有人同时把 Go 常量和 C 函数
|
||||
// 一起改掉(而忘了改 ha_abi.h 的宏),那条测试照样通过 ——
|
||||
// **两边一起错成一样**是它的盲区。这个值直接取自 C 宏,
|
||||
// 由 codec_abimacro_test.go 拿来交叉核对。
|
||||
//
|
||||
// 「Go 常量 vs C 函数返回值」。若有人同时把 Go 常量和 C 函数
|
||||
// 一起改掉(而忘了改 ha_abi.h 的宏),那条测试照样通过 ——
|
||||
// **两边一起错成一样**是它的盲区。这个值直接取自 C 宏,
|
||||
// 由 codec_abimacro_test.go 拿来交叉核对。
|
||||
//
|
||||
// ★ 为何是函数而非 Go 常量:cgo 生成的 `*_Cvar_*` 不是 Go 常量
|
||||
// (实测 "is not constant"),无法在编译期参与断言。
|
||||
// 编译期的保证在 C 侧(codec_cgo.go 里的 _Static_assert)。
|
||||
//
|
||||
// (实测 "is not constant"),无法在编译期参与断言。
|
||||
// 编译期的保证在 C 侧(codec_cgo.go 里的 _Static_assert)。
|
||||
func codecABIVersionMacroValue() int { return int(C.ha_abi_version_macro()) }
|
||||
|
||||
// codecABIVersion 查询 C 侧自称的 ABI 版本(major*1000 + minor)。
|
||||
func codecABIVersion() int { return int(C.ha_codec_abi_version()) }
|
||||
|
||||
|
||||
// cstr2 与 cstr 同义(返回 Go 的 string 版本),供 cgo 桥接层使用。
|
||||
// 名字不同是为了与测试文件里的辅助函数区分,避免包内重名。
|
||||
func cstr2(s string) (*C.char, C.size_t) { return cstr(s) }
|
||||
|
||||
@ -12,11 +12,11 @@ import (
|
||||
)
|
||||
|
||||
var benchChunks = map[string]string{
|
||||
"content_zh": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":"这是一段来自真实流式响应的中文内容,用于测量解析开销。"},"finish_reason":null}]}`,
|
||||
"content_zh": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":"这是一段来自真实流式响应的中文内容,用于测量解析开销。"},"finish_reason":null}]}`,
|
||||
"content_ascii": `{"id":"chatcmpl-abc","choices":[{"index":0,"delta":{"content":"hello world this is a longer ascii content chunk"},"finish_reason":null}]}`,
|
||||
"toolcall": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`,
|
||||
"usage": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}],"usage":{"prompt_tokens":3821,"completion_tokens":117,"total_tokens":3938,"prompt_cache_hit_tokens":3584,"prompt_cache_miss_tokens":237}}`,
|
||||
"finish": `{"id":"c","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
|
||||
"toolcall": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`,
|
||||
"usage": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}],"usage":{"prompt_tokens":3821,"completion_tokens":117,"total_tokens":3938,"prompt_cache_hit_tokens":3584,"prompt_cache_miss_tokens":237}}`,
|
||||
"finish": `{"id":"c","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
|
||||
}
|
||||
|
||||
// BenchmarkChunkFast_Entry 走真实入口(含 C 路径 + 必要的回退)。
|
||||
@ -52,10 +52,11 @@ func BenchmarkChunkFast_GoOnly(b *testing.B) {
|
||||
// TestChunkFast_BenchGate 钉死当前事实:chunkFastEnabled 必须为 false。
|
||||
//
|
||||
// ★ 为什么把「一个优化是关的」也做成断言:
|
||||
// 「还没验证有效就先关着」是**容易丢失的状态** —— 后来者看到
|
||||
// 「快速路径写得挺全 + 6 万条差分测试全过」,很自然会以为它已生效,
|
||||
// 进而打开它、甚至删掉开关。而实测它**更慢**。
|
||||
// 断言把这个事实钉在测试里,开关一旦被改就立刻判红。
|
||||
//
|
||||
// 「还没验证有效就先关着」是**容易丢失的状态** —— 后来者看到
|
||||
// 「快速路径写得挺全 + 6 万条差分测试全过」,很自然会以为它已生效,
|
||||
// 进而打开它、甚至删掉开关。而实测它**更慢**。
|
||||
// 断言把这个事实钉在测试里,开关一旦被改就立刻判红。
|
||||
func TestChunkFast_BenchGate(t *testing.T) {
|
||||
if chunkFastEnabled {
|
||||
t.Fatalf("chunkFastEnabled 被打开了,但实测本架构比原实现慢:\n" +
|
||||
|
||||
@ -53,7 +53,6 @@ package api
|
||||
// ③ 一条**永不静默回退**的机制:若未来把它切回默认开启,
|
||||
// TestChunkFast_BenchGate 会立刻用基准把它按回去。
|
||||
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const chunkFastEnabled = false
|
||||
|
||||
@ -212,9 +212,9 @@ func randJSONValue(rng *rand.Rand, depth int) interface{} {
|
||||
return []interface{}{randJSONValue(rng, depth-1)}
|
||||
case 2:
|
||||
return map[string]interface{}{
|
||||
"prompt_tokens": rng.Intn(9999),
|
||||
"total_tokens": rng.Intn(9999),
|
||||
"completion": rng.Intn(999),
|
||||
"prompt_tokens": rng.Intn(9999),
|
||||
"total_tokens": rng.Intn(9999),
|
||||
"completion": rng.Intn(999),
|
||||
"prompt_cache_hit_tokens": rng.Intn(10),
|
||||
}
|
||||
default:
|
||||
@ -241,7 +241,7 @@ func TestChunkFast_RandomJSON(t *testing.T) {
|
||||
case 0:
|
||||
obj["choices"] = []interface{}{map[string]interface{}{
|
||||
"index": rng.Intn(3),
|
||||
"delta": map[string]interface{}{"content": randJSONValue(rng, 2)},
|
||||
"delta": map[string]interface{}{"content": randJSONValue(rng, 2)},
|
||||
"finish_reason": []interface{}{nil, "", "stop", "length"}[rng.Intn(4)],
|
||||
}}
|
||||
case 1:
|
||||
|
||||
@ -96,7 +96,7 @@ func TestEstimateNeverBelowRealWorldFloors(t *testing.T) {
|
||||
func TestEstimateMonotonicNonDecreasingInBytes(t *testing.T) {
|
||||
prev := 0
|
||||
for n := 1; n <= 64; n++ {
|
||||
s := strings.Repeat("a", n) // ASCII 侧
|
||||
s := strings.Repeat("a", n) // ASCII 侧
|
||||
got := estimateTokensPure(s)
|
||||
if got < prev {
|
||||
t.Fatalf("estimate(\"a\"×%d)=%d < 前值 %d —— 非单调", n, got, prev)
|
||||
@ -105,7 +105,7 @@ func TestEstimateMonotonicNonDecreasingInBytes(t *testing.T) {
|
||||
}
|
||||
prev = 0
|
||||
for n := 1; n <= 64; n++ {
|
||||
s := strings.Repeat("你", n) // CJK 侧
|
||||
s := strings.Repeat("你", n) // CJK 侧
|
||||
got := estimateTokensPure(s)
|
||||
if got < prev {
|
||||
t.Fatalf("estimate(\"你\"×%d)=%d < 前值 %d —— 非单调", n, got, prev)
|
||||
|
||||
@ -176,4 +176,3 @@ func TestGolden_TruncateAlwaysPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -113,10 +113,10 @@ func TestJSONGolden_StringDecode(t *testing.T) {
|
||||
`\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`,
|
||||
`\u0041`, `\u00e9`, `\u4f60\u597d`, `\ud83d\ude00`, `\u0000`,
|
||||
`mixed \u4e2d\u6587 and ascii`,
|
||||
`\ud83d` + `real`, // 孤立高代理
|
||||
`\udc00` + `real`, // 孤立低代理
|
||||
`\ud83dx`, // 高代理 + 非转义
|
||||
`\ud83d\u0041`, // 高代理 + 非低代理
|
||||
`\ud83d` + `real`, // 孤立高代理
|
||||
`\udc00` + `real`, // 孤立低代理
|
||||
`\ud83dx`, // 高代理 + 非转义
|
||||
`\ud83d\u0041`, // 高代理 + 非低代理
|
||||
"\xff", "\xfe", "\xff\xfe", "\xc3", "\xc3\x28", "\xe0\x80\x80",
|
||||
"\xed\xa0\x80", "\xf5\x80\x80\x80", "\xf0\x9f\x98\x80", // 正常 4 字节
|
||||
"a\xffb", "\x80", "\xbf",
|
||||
@ -255,11 +255,12 @@ func TestJSONScanABIVersion(t *testing.T) {
|
||||
// goUnquoteBody 用 encoding/json 自身解码一个 JSON 字符串体(raw = 不含两端引号)。
|
||||
//
|
||||
// ★ 正确做法是**直接把 body 原样**放进引号里交给 Unmarshal ——
|
||||
// body 里本来就带着它自己的转义(`\n` 是两个字节),若在此处再转义一遍,
|
||||
// 就把「转义序列」变成了「字面量」,参照值会整体跑偏。
|
||||
// 实测踩过:初版对 body 里的 `\` 和 `"` 做了二次转义,
|
||||
// 导致 Go 侧期望 `\n`(两字节)而 C 侧正确给出换行符 ——
|
||||
// 测试报了一堆「分歧」,其实错的是测试自己的参照。
|
||||
//
|
||||
// body 里本来就带着它自己的转义(`\n` 是两个字节),若在此处再转义一遍,
|
||||
// 就把「转义序列」变成了「字面量」,参照值会整体跑偏。
|
||||
// 实测踩过:初版对 body 里的 `\` 和 `"` 做了二次转义,
|
||||
// 导致 Go 侧期望 `\n`(两字节)而 C 侧正确给出换行符 ——
|
||||
// 测试报了一堆「分歧」,其实错的是测试自己的参照。
|
||||
func goUnquoteBody(body string) (string, error) {
|
||||
var out string
|
||||
if err := json.Unmarshal([]byte(`"`+body+`"`), &out); err != nil {
|
||||
|
||||
@ -110,10 +110,11 @@ func cjsMembersInit(m *C.ha_json_members, s string) bool {
|
||||
// cjsMembersStep 推进一次迭代,只把**键**交回 Go。
|
||||
//
|
||||
// ★ 为什么只返回键:cgo 规则禁止把「Go 指针指向的 Go 指针」传给 C
|
||||
// (cgo argument has Go pointer to unpinned Go pointer)——若把 key 与
|
||||
// value 两个 span 都交回 Go,再在同一个调用里传回 C,就会构成
|
||||
// 「Go 切片 → Go 指针 → Go 指针」的未固定链,运行时直接 panic。
|
||||
// 所以每次跨语言只搬运**一个**字符串,其余信息留到下一次调用。
|
||||
//
|
||||
// (cgo argument has Go pointer to unpinned Go pointer)——若把 key 与
|
||||
// value 两个 span 都交回 Go,再在同一个调用里传回 C,就会构成
|
||||
// 「Go 切片 → Go 指针 → Go 指针」的未固定链,运行时直接 panic。
|
||||
// 所以每次跨语言只搬运**一个**字符串,其余信息留到下一次调用。
|
||||
//
|
||||
// ★ 值 span 只在需要时**在 C 侧**用(见 cjsWalkMembers)。
|
||||
func cjsMembersStep(m *C.ha_json_members) (key string, ok bool) {
|
||||
@ -201,9 +202,10 @@ func cjsWalkMembers(s string) (inited bool, count int, complete bool) {
|
||||
// JSON 值,尾部除空白外不得有残留。
|
||||
//
|
||||
// ★ 为什么测试不能只调 ha_json_skip:skip 的语义是「跳过这里的一个值」,
|
||||
// 它成功返回并不能证明「整串就是这一个值」。实测 `{"a":1}{"b":2}`
|
||||
// 在 skip 下成功,而 json.Valid=false —— 这正是两者职责的差别。
|
||||
// 内核协议层要的是严格语义,故这里显式做尾部校验。
|
||||
//
|
||||
// 它成功返回并不能证明「整串就是这一个值」。实测 `{"a":1}{"b":2}`
|
||||
// 在 skip 下成功,而 json.Valid=false —— 这正是两者职责的差别。
|
||||
// 内核协议层要的是严格语义,故这里显式做尾部校验。
|
||||
func cjsSkipStrict(s string) bool {
|
||||
p, n := cstr2(s)
|
||||
return C.go_skip_strict(p, n) == 1
|
||||
|
||||
@ -45,7 +45,8 @@ const contextWindowUnknown = -1
|
||||
// 标称窗口 ≠ 有效窗口:接近满时注意力涣散,调用方应取 70-80% 为目标利用率。
|
||||
//
|
||||
// ★ 分支顺序即语义:先匹配者胜出(例:gpt-4-turbo 必须先于裸 gpt-4)。
|
||||
// C 侧 ha_codec_model_context_window 必须保持同一顺序。
|
||||
//
|
||||
// C 侧 ha_codec_model_context_window 必须保持同一顺序。
|
||||
func modelContextWindowPure(model string) int {
|
||||
model = strings.ToLower(model)
|
||||
switch {
|
||||
|
||||
@ -144,7 +144,8 @@ func (s strSpan) valid() bool { return s.p != nil && s.n > 0 }
|
||||
// bytes 把 span 变成 Go 字节切片(此处才产生一次拷贝)。
|
||||
//
|
||||
// ★ 用途:把「已定位的原始子树」交给 json.Unmarshal —— 用同一 Go 类型
|
||||
// unmarshal 同一形状,是本层保证「类型检查语义与原实现一致」的手段。
|
||||
//
|
||||
// unmarshal 同一形状,是本层保证「类型检查语义与原实现一致」的手段。
|
||||
func (s strSpan) bytes() []byte {
|
||||
if s.p == nil || s.n == 0 {
|
||||
return nil
|
||||
@ -172,7 +173,6 @@ func (s strSpan) firstByte() byte {
|
||||
// 定位
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 取值(C 可判定的热分支)
|
||||
// ---------------------------------------------------------------------
|
||||
@ -180,8 +180,9 @@ func (s strSpan) firstByte() byte {
|
||||
// decBuf 是解码/反转义用的可写缓冲。
|
||||
//
|
||||
// ★ 尺寸必须按输入长度定:C 侧要求 cap >= len*3+4(最坏每字节一个 U+FFFD),
|
||||
// 不足时它会返回 0 让调用方回退 Go(宁可慢也不截断)。
|
||||
// 每次调用 1 次分配(原来整块 Unmarshal 是 12~21 次)—— 这是主要的节省点。
|
||||
//
|
||||
// 不足时它会返回 0 让调用方回退 Go(宁可慢也不截断)。
|
||||
// 每次调用 1 次分配(原来整块 Unmarshal 是 12~21 次)—— 这是主要的节省点。
|
||||
func decBuf(n int) []byte { return make([]byte, n*3+8) }
|
||||
|
||||
// stringifyC 对应 Go stringifyContent 的**C 可判定分支**
|
||||
@ -200,7 +201,6 @@ func stringifyC(val strSpan) (string, bool) {
|
||||
return string(buf[:int(outLen)]), true
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 顶层 helper:大小写不敏感(struct 字段语义)与根对象校验
|
||||
// ---------------------------------------------------------------------
|
||||
@ -253,7 +253,6 @@ func sseRootObject(doc strSpan) bool {
|
||||
return C.go_root_object(doc.p, doc.n) == 1
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 批量定位(第三刀的重做:一次 cgo 调用代替 5+ 次)
|
||||
// ---------------------------------------------------------------------
|
||||
@ -302,9 +301,9 @@ const (
|
||||
kindArray = 4
|
||||
kindOther = 5
|
||||
|
||||
chunkOK = 0
|
||||
chunkFallback = -1
|
||||
chunkTypeFail = -2
|
||||
chunkOK = 0
|
||||
chunkFallback = -1
|
||||
chunkTypeFail = -2
|
||||
)
|
||||
|
||||
// locateChunkBatch 一次调用完成整块定位。
|
||||
@ -348,5 +347,3 @@ func locateChunkBatch(data string) chunkLocateResult {
|
||||
out.finish = string(s[co.finish_off : co.finish_off+co.finish_len])
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -17,6 +17,8 @@ import (
|
||||
"time"
|
||||
|
||||
luaVM "github.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
// ContentBlock 定义多模态内容块,用于图片/音频等非文本输入。
|
||||
@ -945,7 +947,8 @@ func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequ
|
||||
ch := make(chan StreamChunk, 64)
|
||||
go func() {
|
||||
defer resp.Body.Close()
|
||||
defer close(ch)
|
||||
var chOnce sync.Once
|
||||
defer safeclose.Safe(&chOnce, ch)
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
@ -1037,7 +1040,8 @@ func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequ
|
||||
}
|
||||
out := make(chan StreamChunk, 64)
|
||||
go func() {
|
||||
defer close(out)
|
||||
var outOnce sync.Once
|
||||
defer safeclose.Safe(&outOnce, out)
|
||||
out <- first
|
||||
for ck := range ch {
|
||||
out <- ck
|
||||
@ -1094,7 +1098,28 @@ func (m *ProviderManager) Reset() {
|
||||
m.status = make(map[string]*providerStatus)
|
||||
}
|
||||
|
||||
// Register 登记一个 provider。
|
||||
//
|
||||
// ★ 这里是**安全模块的唯一挂载点**:出向改写的装饰器包在这里,
|
||||
// 就覆盖了全部取用路径(Get / Default / OrderedProviders /
|
||||
// ResolveForModel 都从同一张 map 取,已核)。
|
||||
//
|
||||
// 为何不逐个改调用方:CodeGraph 交叉验证显示 NewLuaAdaptedProvider 有 6 个
|
||||
// 调用者(含插件运行时动态注册 llm_impl.go:115、failback.go:28),
|
||||
// 逐个改必漏;而且插件新增 provider 时无人在意去挂装饰器。
|
||||
//
|
||||
// 安全模块未启用时 security.Global() 返回 nil ⇒ 装饰器完全不介入,零成本。
|
||||
func (m *ProviderManager) Register(name string, p Provider) {
|
||||
// 出向明文 → 别名(防泄漏)。未启用时原样透传。
|
||||
//
|
||||
// 守卫:已是本层装饰过的实例就不再包一层。
|
||||
// 双重包裹本身无害(第二遍看到的已无明文,是空转),但会白跑一遗
|
||||
// 正则扫描 —— 而热路径上每条消息都扫一遍,不值得。
|
||||
if store := security.Global(); store != nil {
|
||||
if _, alreadyWrapped := p.(*secureProviderDecorator); !alreadyWrapped {
|
||||
p = WrapProviderForSecurity(p, store)
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.providers[name] = p
|
||||
|
||||
@ -99,4 +99,4 @@ func TestClassify_Mutation_调换优先级后401翻案(t *testing.T) {
|
||||
t.Fatalf("当前实现应判 ErrCredential,实际 %v", got)
|
||||
}
|
||||
// 若有人把顺序改成「先查特征词」,上面这行就会失败 —— 即本测试能捕获该回归。
|
||||
}
|
||||
}
|
||||
|
||||
99
internal/agent/api/provider_security.go
Normal file
99
internal/agent/api/provider_security.go
Normal file
@ -0,0 +1,99 @@
|
||||
// provider 出向装饰器:把明文替换成变量别名。
|
||||
//
|
||||
// 落点:`ProviderManager.Register`(唯一入口,已核 —— 见
|
||||
// docs/zh/kernel-secret-firewall-design.md §5.1)。
|
||||
//
|
||||
// 装饰在这里而不是每个调用方,是因为 CodeGraph 交叉验证确认:
|
||||
// Get / Default / OrderedProviders / ResolveForModel 全都从 Register
|
||||
// 写入的那张 map 取,装饰 Register 即全覆盖。
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
// secureProviderDecorator 在**发往 LLM 之前**把消息里的明文换成别名。
|
||||
//
|
||||
// ### 为何要逐条消息扫,而不是拼起来扫
|
||||
//
|
||||
// 拼接会跨越消息边界产生误报:上一条的结尾与下一条的开头恰好连成
|
||||
// 一个 token 形状。逐条扫不可能有这种跨边界命中。
|
||||
//
|
||||
// ### 为何同时处理 Content 与 Blocks
|
||||
//
|
||||
// 内核的消息有多模态 Blocks(截图描述等)。只扫 Content 会漏掉
|
||||
// 图片 URL 之类的载荷。这里两者都过一遍。
|
||||
type secureProviderDecorator struct {
|
||||
inner Provider
|
||||
store *security.Store
|
||||
}
|
||||
|
||||
// WrapProviderForSecurity 给 provider 套上出向改写。
|
||||
//
|
||||
// store 为 nil 时原样返回(安全模块未启用 ⇒ **零成本**)。
|
||||
func WrapProviderForSecurity(p Provider, store *security.Store) Provider {
|
||||
if p == nil || store == nil {
|
||||
return p
|
||||
}
|
||||
return &secureProviderDecorator{inner: p, store: store}
|
||||
}
|
||||
|
||||
func (d *secureProviderDecorator) Name() string { return d.inner.Name() }
|
||||
func (d *secureProviderDecorator) MaxContextTokens() int { return d.inner.MaxContextTokens() }
|
||||
|
||||
// scrubRequest 逐条消息改写(**原地**改,因为 req 是要发出去的那一份)。
|
||||
func (d *secureProviderDecorator) scrubRequest(req *CompletionRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
for i := range req.Messages {
|
||||
m := &req.Messages[i]
|
||||
|
||||
if m.Content != "" {
|
||||
out, evts := d.store.Replace(m.Content, "llm_out:"+m.Role, security.Patterns)
|
||||
if len(evts) > 0 {
|
||||
m.Content = out
|
||||
d.logEvts(evts)
|
||||
}
|
||||
}
|
||||
|
||||
// 多模态 Blocks:文本块要扫;image_url 里的 data URL 也要扫
|
||||
// (截图/文档里可能带凭据)。
|
||||
for j := range m.Blocks {
|
||||
b := &m.Blocks[j]
|
||||
if b.Text != "" {
|
||||
if out, evts := d.store.Replace(b.Text, "llm_out:"+m.Role+":block", security.Patterns); len(evts) > 0 {
|
||||
b.Text = out
|
||||
d.logEvts(evts)
|
||||
}
|
||||
}
|
||||
if b.ImageURL != nil && b.ImageURL.URL != "" {
|
||||
if out, evts := d.store.Replace(b.ImageURL.URL, "llm_out:"+m.Role+":image", security.Patterns); len(evts) > 0 {
|
||||
b.ImageURL.URL = out
|
||||
d.logEvts(evts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logEvts 记录拦截(**只记别名,绝不记明文**)。
|
||||
func (d *secureProviderDecorator) logEvts(evts []security.Evt) {
|
||||
for _, e := range evts {
|
||||
log.Printf("[security] 出向拦截 %s ← 模式 %s,%d 字节,合并=%v,位置 %s",
|
||||
e.Alias, e.Kind, e.Len, e.Merged, e.Where)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *secureProviderDecorator) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
|
||||
d.scrubRequest(req)
|
||||
return d.inner.Chat(ctx, req)
|
||||
}
|
||||
|
||||
func (d *secureProviderDecorator) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
|
||||
d.scrubRequest(req)
|
||||
return d.inner.ChatStream(ctx, req)
|
||||
}
|
||||
341
internal/agent/api/security_premise_test.go
Normal file
341
internal/agent/api/security_premise_test.go
Normal file
@ -0,0 +1,341 @@
|
||||
package api
|
||||
|
||||
// 前提验证:**安全模块能否稳在 provider 层**。
|
||||
//
|
||||
// 设计依据:docs/zh/kernel-secret-firewall-design.md §5.1。
|
||||
//
|
||||
// 整件事成立的前提是两条:
|
||||
//
|
||||
// P1. 所有发往 LLM 的调用都经 `Provider.Chat` / `Provider.ChatStream`
|
||||
// (没有绕过接口的直发路径)
|
||||
// P2. `ProviderManager.Register` 是所有 provider 的**唯一入口**
|
||||
// ⇒ 在 Register 处包一层装饰器,就能覆盖全部取用路径
|
||||
// (Get / Default / OrderedProviders / ResolveForModel 都从同一 map 取)
|
||||
//
|
||||
// 若 P1 或 P2 不成立,安全模块必须下沉到更底层(HTTP 客户端),改动面大得多。
|
||||
// ⇒ 先验它,再写实现(本仓纪律:前提不成立时不要继续设计上层)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
// countingProvider 记录它被调用了几次、收到什么消息。
|
||||
type countingProvider struct {
|
||||
calls int
|
||||
seen []string
|
||||
stream int
|
||||
}
|
||||
|
||||
func (c *countingProvider) Name() string { return "counting" }
|
||||
|
||||
func (c *countingProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
|
||||
c.calls++
|
||||
for _, m := range req.Messages {
|
||||
c.seen = append(c.seen, m.Content)
|
||||
}
|
||||
return &CompletionResponse{Content: "ok"}, nil
|
||||
}
|
||||
|
||||
func (c *countingProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
|
||||
c.stream++
|
||||
ch := make(chan StreamChunk, 1)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (c *countingProvider) MaxContextTokens() int { return 8192 }
|
||||
|
||||
// ★★ P2:装饰 Register ⇒ **所有取用路径都拿到装饰后的实例**。
|
||||
//
|
||||
// 这是安全模块能"一处生效、处处覆盖"的机制保证。
|
||||
func TestPremise_RegisterDecorateCoversAllAccessPaths(t *testing.T) {
|
||||
m := NewProviderManager()
|
||||
|
||||
inner := &countingProvider{}
|
||||
var wrapped Provider
|
||||
// 模拟安全模块:在 Register 处包一层
|
||||
m.Register("p1", &proxyProvider{inner: inner, onCall: func() { wrapped = nil }})
|
||||
_ = wrapped
|
||||
|
||||
// ① Get 拿到的是装饰后的
|
||||
got := m.Get("p1")
|
||||
if got == nil {
|
||||
t.Fatal("Get 返回 nil")
|
||||
}
|
||||
if _, isProxy := got.(*proxyProvider); !isProxy {
|
||||
t.Fatalf("Get 未拿到装饰后的实例,实际类型 %T", got)
|
||||
}
|
||||
// ② Default 也拿到装饰后的
|
||||
if _, isProxy := m.Default().(*proxyProvider); !isProxy {
|
||||
t.Fatal("Default 未拿到装饰后的实例")
|
||||
}
|
||||
// ③ OrderedProviders 也是
|
||||
ord := m.OrderedProviders()
|
||||
if len(ord) != 1 {
|
||||
t.Fatalf("OrderedProviders 数量 %d", len(ord))
|
||||
}
|
||||
if _, isProxy := ord[0].(*proxyProvider); !isProxy {
|
||||
t.Fatal("OrderedProviders 未拿到装饰后的实例")
|
||||
}
|
||||
// ④ ResolveForModel 也是
|
||||
res := m.ResolveForModel("")
|
||||
if len(res) == 0 {
|
||||
t.Fatal("ResolveForModel 为空")
|
||||
}
|
||||
if _, isProxy := res[0].(*proxyProvider); !isProxy {
|
||||
t.Fatal("ResolveForModel 未拿到装饰后的实例")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ P1:经 manager 取到的 provider,其 Chat 必经装饰器。
|
||||
//
|
||||
// 这条证明"安全模块能看见每一条出网请求"。
|
||||
func TestPremise_DecoratorSeesEveryChat(t *testing.T) {
|
||||
m := NewProviderManager()
|
||||
inner := &countingProvider{}
|
||||
var seen int
|
||||
m.Register("p1", &proxyProvider{inner: inner, onCall: func() { seen++ }})
|
||||
|
||||
p := m.Get("p1")
|
||||
if _, err := p.Chat(context.Background(), &CompletionRequest{
|
||||
Messages: []Message{{Role: "user", Content: "hello"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen != 1 {
|
||||
t.Fatalf("装饰器应看到 1 次 Chat,实际 %d", seen)
|
||||
}
|
||||
if inner.calls != 1 {
|
||||
t.Fatalf("内层应被调 1 次,实际 %d", inner.calls)
|
||||
}
|
||||
// ChatStream 同样要经过
|
||||
if _, err := p.ChatStream(context.Background(), &CompletionRequest{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen != 2 {
|
||||
t.Fatalf("ChatStream 也应经装饰器,实际 seen=%d", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 关键能力验证:装饰器**能改写请求**(这正是安全模块要做的替换)。
|
||||
func TestPremise_DecoratorCanRewriteMessages(t *testing.T) {
|
||||
m := NewProviderManager()
|
||||
inner := &countingProvider{}
|
||||
// 模拟安全模块:把明文换成别名
|
||||
m.Register("p1", &proxyProvider{inner: inner, onCall: func() {}})
|
||||
|
||||
// 直接用能改写的装饰器
|
||||
m.Register("p2", &rewriteProvider{inner: inner})
|
||||
|
||||
req := &CompletionRequest{Messages: []Message{
|
||||
{Role: "user", Content: "token=SECRET-XYZ"},
|
||||
}}
|
||||
if _, err := m.Get("p2").Chat(context.Background(), req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 内层必须收到**改写后**的内容(证明改写真的生效、不是只读)
|
||||
found := false
|
||||
for _, s := range inner.seen {
|
||||
if strings.Contains(s, "SECRET-XYZ") {
|
||||
t.Fatal("明文被原样透传,改写未生效")
|
||||
}
|
||||
if strings.Contains(s, "@{sec_") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("内层未收到改写后的别名,实际收到: %v", inner.seen)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 覆盖面:ChatStream 也必须能被装饰(流式是主路径之一)。
|
||||
func TestPremise_StreamAlsoDecorated(t *testing.T) {
|
||||
m := NewProviderManager()
|
||||
inner := &countingProvider{}
|
||||
m.Register("p1", &proxyProvider{inner: inner, onCall: func() {}})
|
||||
if _, err := m.Get("p1").ChatStream(context.Background(), &CompletionRequest{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inner.stream != 1 {
|
||||
t.Fatalf("内层 ChatStream 应被调 1 次,实际 %d", inner.stream)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 测试替身 ----------------
|
||||
|
||||
// proxyProvider 是"只观察不修改"的装饰器。
|
||||
type proxyProvider struct {
|
||||
inner Provider
|
||||
onCall func()
|
||||
}
|
||||
|
||||
func (p *proxyProvider) Name() string { return p.inner.Name() }
|
||||
func (p *proxyProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
|
||||
if p.onCall != nil {
|
||||
p.onCall()
|
||||
}
|
||||
return p.inner.Chat(ctx, req)
|
||||
}
|
||||
func (p *proxyProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
|
||||
if p.onCall != nil {
|
||||
p.onCall()
|
||||
}
|
||||
return p.inner.ChatStream(ctx, req)
|
||||
}
|
||||
func (p *proxyProvider) MaxContextTokens() int { return p.inner.MaxContextTokens() }
|
||||
|
||||
// rewriteProvider 是"会改写请求"的装饰器(模拟安全模块)。
|
||||
type rewriteProvider struct{ inner Provider }
|
||||
|
||||
func (p *rewriteProvider) Name() string { return p.inner.Name() }
|
||||
func (p *rewriteProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
|
||||
// 模拟:把明文换成别名(真实实现会用模式库 + HMAC 键)
|
||||
for i := range req.Messages {
|
||||
if strings.Contains(req.Messages[i].Content, "SECRET-XYZ") {
|
||||
req.Messages[i].Content = "token=@{sec_deadbeef}"
|
||||
}
|
||||
}
|
||||
return p.inner.Chat(ctx, req)
|
||||
}
|
||||
func (p *rewriteProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
|
||||
return p.inner.ChatStream(ctx, req)
|
||||
}
|
||||
func (p *rewriteProvider) MaxContextTokens() int { return p.inner.MaxContextTokens() }
|
||||
|
||||
// ---------------- 接线判据(装饰器真的挂在 Register 上) ----------------
|
||||
|
||||
// ★★ 判据:经 Register 登记后,取出的 provider 就是**装饰过的**,
|
||||
// 且出向明文被换成了别名。
|
||||
//
|
||||
// 这是端到端接线判据:它同时验证
|
||||
//
|
||||
// ① Register 挂上了装饰器
|
||||
// ② 装饰器能拿到全局单例
|
||||
// ③ 替换真的发生了
|
||||
func TestWired_RegisterDecoratesAndScrubs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := security.InitGlobal(security.Options{
|
||||
Path: filepath.Join(dir, "vars.json"),
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
var got string
|
||||
inner := &recordingProvider{name: "wired", capture: &got}
|
||||
m := NewProviderManager()
|
||||
m.Register("p", inner)
|
||||
|
||||
// 取出(任何一条取用路径都应拿到装饰后的实例)
|
||||
p := m.Default()
|
||||
if p == nil {
|
||||
t.Fatal("Default 返回 nil")
|
||||
}
|
||||
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "token=" + tok}}}
|
||||
if _, err := p.Chat(context.Background(), req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if strings.Contains(got, tok) {
|
||||
t.Fatalf("明文泄漏到 provider: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "安全模块自动拦截") {
|
||||
t.Errorf("未见拦截提示: %s", got)
|
||||
}
|
||||
if store.Count() != 1 {
|
||||
t.Errorf("应登记 1 条变量,实际 %d", store.Count())
|
||||
}
|
||||
// 记录里不得有明文
|
||||
b, _ := json.Marshal(store.List())
|
||||
if strings.Contains(string(b), tok) {
|
||||
t.Error("变量记录泄露明文")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:安全模块未初始化时,Register **不介入**(零成本)。
|
||||
func TestWired_NoStoreMeansNoInterference(t *testing.T) {
|
||||
security.SetGlobal(nil)
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
var got string
|
||||
m := NewProviderManager()
|
||||
m.Register("p", &recordingProvider{name: "plain", capture: &got})
|
||||
|
||||
if _, err := m.Default().Chat(context.Background(),
|
||||
&CompletionRequest{Messages: []Message{{Role: "user", Content: "token=" + tok}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "token="+tok {
|
||||
t.Errorf("未启用安全模块时不得改动内容: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:回填把别名换回明文(入向通道)。
|
||||
func TestWired_FillToolArgs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := security.InitGlobal(security.Options{
|
||||
Path: filepath.Join(dir, "vars.json"),
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
args := map[string]interface{}{"token": "@{" + alias + "}", "other": "unchanged"}
|
||||
n, used := security.FillToolArgs("some_plugin_tool", args)
|
||||
if n != 1 {
|
||||
t.Fatalf("应回填 1 处,实际 %d", n)
|
||||
}
|
||||
if args["token"] != tok {
|
||||
t.Errorf("回填结果不对: %v", args["token"])
|
||||
}
|
||||
if args["other"] != "unchanged" {
|
||||
t.Error("无关字段不应被改动")
|
||||
}
|
||||
if len(used) != 1 || used[0] != alias {
|
||||
t.Errorf("应报告别名: %v", used)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingProvider 记录它收到的最后一条消息内容。
|
||||
type recordingProvider struct {
|
||||
name string
|
||||
capture *string
|
||||
}
|
||||
|
||||
func (r *recordingProvider) Name() string { return r.name }
|
||||
func (r *recordingProvider) Chat(_ context.Context, req *CompletionRequest) (*CompletionResponse, error) {
|
||||
if req != nil && len(req.Messages) > 0 {
|
||||
*r.capture = req.Messages[0].Content
|
||||
}
|
||||
return &CompletionResponse{}, nil
|
||||
}
|
||||
func (r *recordingProvider) ChatStream(_ context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
|
||||
if req != nil && len(req.Messages) > 0 {
|
||||
*r.capture = req.Messages[0].Content
|
||||
}
|
||||
ch := make(chan StreamChunk)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
func (r *recordingProvider) MaxContextTokens() int { return 1000 }
|
||||
193
internal/agent/api/security_wiring_test.go
Normal file
193
internal/agent/api/security_wiring_test.go
Normal file
@ -0,0 +1,193 @@
|
||||
package api
|
||||
|
||||
// 装配顺序判据。
|
||||
//
|
||||
// ## 为什么这个顺序值得单独立判据
|
||||
//
|
||||
// 安全模块的初始化必须在**任何 provider 注册之前**。装晚了的后果是:
|
||||
// 先注册的那批 provider 没被装饰 ⇒ **静默漏掉一条出网路径**,
|
||||
// 而且没有任何报错、日志也正常 —— 属于最难发现的那类失效。
|
||||
//
|
||||
// 而这个坑在本仓是真实存在的:
|
||||
// · `cmd/homed/main.go` 的 initLLMProviders(主路径)
|
||||
// · `internal/sdk/llm_impl.go` 的 ReloadFromConfig(配置热重载)
|
||||
// · `cmd/homed/failback.go` 的 runFailbackRecovery(降级救援)
|
||||
//
|
||||
// 三个地方都会 Register。前两个在 security 之后;failback 也必须之后
|
||||
// (main.go 里它是第 174 行,安全模块是 162 行)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
// ★★ 判据:**晚注册的 provider 也自动被装饰**(因为装饰挂在 Register 上)。
|
||||
//
|
||||
// 这条钉住的是"挂载点选择正确":如果装饰挂在某个初始化函数里
|
||||
// (而不是 Register),那么后注册的 provider 就会漏掉。
|
||||
func TestLateRegistrationStillDecorated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := security.InitGlobal(security.Options{
|
||||
Path: filepath.Join(dir, "v.json"),
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
m := NewProviderManager()
|
||||
|
||||
// ① 先注册一个
|
||||
var first string
|
||||
m.Register("early", &recordingProvider{name: "early", capture: &first})
|
||||
|
||||
// ② 模拟"热重载":Reset 后注册全新一批(ReloadFromConfig 的真实流程)
|
||||
m.Reset()
|
||||
var second string
|
||||
m.Register("late", &recordingProvider{name: "late", capture: &second})
|
||||
|
||||
// ③ 再模拟 failback 的 rescue provider
|
||||
var third string
|
||||
m.Register("rescue", &recordingProvider{name: "rescue", capture: &third})
|
||||
|
||||
// 三个都必须被装饰
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
got *string
|
||||
}{{"late", &second}, {"rescue", &third}} {
|
||||
if _, err := m.Get(tc.name).Chat(context.Background(),
|
||||
&CompletionRequest{Messages: []Message{{Role: "user", Content: "t=" + tok}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(*tc.got, tok) {
|
||||
t.Errorf("provider %s 未被装饰(明文泄漏): %s", tc.name, *tc.got)
|
||||
}
|
||||
}
|
||||
_ = first
|
||||
if store.Count() == 0 {
|
||||
t.Error("应登记变量")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:**已装饰的不再包第二层**(避免热路径白跑)。
|
||||
func TestNoDoubleWrapping(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := security.InitGlobal(security.Options{
|
||||
Path: filepath.Join(dir, "v.json"),
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
|
||||
m := NewProviderManager()
|
||||
base := &recordingProvider{name: "x", capture: new(string)}
|
||||
m.Register("a", base)
|
||||
inner := m.Get("a")
|
||||
|
||||
// 把已装饰的实例再注册一次(某些装配路径会这么干)
|
||||
m.Register("b", inner)
|
||||
if _, isDecorator := m.Get("b").(*secureProviderDecorator); !isDecorator {
|
||||
t.Fatal("应当仍是装饰实例")
|
||||
}
|
||||
// 再包一层的话,类型仍是装饰器但会嵌套 —— 用指针比较确认是同一个
|
||||
if m.Get("b") != inner {
|
||||
t.Error("已装饰的实例不应被再包一层(会白跑一遍正则扫描)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**main.go 里安全模块的初始化必须早于 initLLMProviders**。
|
||||
//
|
||||
// 这是源码级判据(读文件而不是跑逻辑),因为顺序错误在单测里
|
||||
// 构造不出来(main 的装配流程没法在测试里复现)。
|
||||
// 源码判据在此是有意为之:它盯的是一个**真实的、曾经坑过的**装配顺序。
|
||||
func TestMainInitsSecurityBeforeProviders(t *testing.T) {
|
||||
src, err := os.ReadFile("../../../cmd/homed/main.go")
|
||||
if err != nil {
|
||||
t.Skipf("读不到 main.go(相对路径变了?): %v", err)
|
||||
}
|
||||
text := string(src)
|
||||
|
||||
iSec := strings.Index(text, "security.InitGlobal")
|
||||
// ★ 锚点已随"内核模块化"改写(2026-10-16)。
|
||||
//
|
||||
// 原先锚 `initLLMProviders(cfg,`(那时它直接写在 main 里)。
|
||||
// 模块化后 **provider 注册搬进了 llm 模块**(`internal/kernelmod/llm`),
|
||||
// 它在 `RunKernelModules` 内部执行 ⇒ 旧的字符串在 main.go 里**已经不存在**,
|
||||
// 判据因"找不到"而失败(假红)。
|
||||
//
|
||||
// **注意:这不是放宽判据,而是把锚点换到新的真实装配点。**
|
||||
// 不变量本身没变:安全模块必须早于**任何 provider 注册**。
|
||||
// 新的等价表述:安全模块早于 `RunKernelModules`(llm 模块在它内部)。
|
||||
//
|
||||
// 为防止"换个锚点就变绿"式的偷懒,另加两条硬校验(见下)。
|
||||
iProv := strings.Index(text, "RunKernelModules(")
|
||||
if iSec < 0 {
|
||||
t.Fatal("main.go 里找不到 security.InitGlobal —— 安全模块根本没被装配")
|
||||
}
|
||||
if iProv < 0 {
|
||||
t.Fatal("main.go 里找不到 RunKernelModules —— 内核模块装配点没了")
|
||||
}
|
||||
if iSec > iProv {
|
||||
t.Fatalf("装配顺序错误:security.InitGlobal 在第 %d 字节,RunKernelModules 在第 %d 字节\n"+
|
||||
"必须先初始化安全模块 —— 内核模块装配时会注册 LLM provider,"+
|
||||
"晚于它则 provider 不会被装饰(静默漏掉一条出网路径)",
|
||||
iSec, iProv)
|
||||
}
|
||||
// ★ 加固 ①:provider 注册**确实**在内核模块里(否则新锚点就是空的 ——
|
||||
// 那等于把判据锚在一个与 provider 无关的位置上)。
|
||||
// 判据:kernelmod/llm 模块必须注册,且其 Init 里出现 initLLMProviders。
|
||||
llmSrc, lerr := os.ReadFile("../../kernelmod/llm/mod.go")
|
||||
if lerr != nil {
|
||||
t.Fatalf("读不到 llm 模块(provider 注册应在这里): %v", lerr)
|
||||
}
|
||||
if !strings.Contains(string(llmSrc), "initLLMProviders") {
|
||||
t.Fatal("★ llm 模块里没有 initLLMProviders —— " +
|
||||
"那么上面锚 RunKernelModules 就与 provider 注册无关了(判据会变空)")
|
||||
}
|
||||
// ★ 加固 ②:llm 模块必须是**真注册过**的(不是死代码)
|
||||
allSrc, aerr := os.ReadFile("../../kernelmod/all/all.go")
|
||||
if aerr != nil {
|
||||
t.Fatalf("读不到模块总表: %v", aerr)
|
||||
}
|
||||
if !strings.Contains(string(allSrc), "kernelmod/llm") {
|
||||
t.Fatal("★ llm 模块没被 import ⇒ 它的 init() 不跑 ⇒ provider 永不注册")
|
||||
}
|
||||
|
||||
// failback 也要在之后
|
||||
iFail := strings.Index(text, "runFailbackRecovery")
|
||||
if iFail >= 0 && iSec > iFail {
|
||||
t.Error("runFailbackRecovery 在安全模块之前 —— rescue provider 会漏装饰")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:阶段断言 —— 源码里装饰发生在 Register 内部(挂载点正确)。
|
||||
func TestDecorateHappensInsideRegister(t *testing.T) {
|
||||
src, err := os.ReadFile("provider.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(src)
|
||||
// Register 函数体内必须出现 WrapProviderForSecurity
|
||||
i := strings.Index(text, "func (m *ProviderManager) Register(")
|
||||
if i < 0 {
|
||||
t.Fatal("找不到 Register")
|
||||
}
|
||||
// 取到下一个顶层 func 为止
|
||||
rest := text[i:]
|
||||
if j := strings.Index(rest[1:], "\nfunc "); j > 0 {
|
||||
rest = rest[:j+1]
|
||||
}
|
||||
if !strings.Contains(rest, "WrapProviderForSecurity") {
|
||||
t.Fatal("Register 函数体内没有装饰调用 —— 装饰被挪走了(后注册的 provider 会漏掉)")
|
||||
}
|
||||
}
|
||||
@ -258,10 +258,10 @@ type AgentConfig struct {
|
||||
Indexer *memory.Indexer
|
||||
Tracker *tracker.Tracker
|
||||
|
||||
DocStore *document.Store
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
DocStore *document.Store
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
// 媒体存储句柄已随 CAS 淘汰(块自带负载,见 plan.md)。
|
||||
MultimodalSpace vector.MultimodalEmbedder
|
||||
// EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因,
|
||||
@ -393,27 +393,27 @@ func New(cfg AgentConfig) *Agent {
|
||||
}
|
||||
|
||||
a := &Agent{
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
graph: graphMemoryOf(cfg),
|
||||
kernelSource: cfg.KernelSource,
|
||||
parentID: cfg.ParentID,
|
||||
taskPrompt: cfg.TaskPrompt,
|
||||
dataDir: cfg.DataDir,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
graph: graphMemoryOf(cfg),
|
||||
kernelSource: cfg.KernelSource,
|
||||
parentID: cfg.ParentID,
|
||||
taskPrompt: cfg.TaskPrompt,
|
||||
dataDir: cfg.DataDir,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
// mediaStore 已随 CAS 淘汰:块自带负载。知识层的媒体也已自带字节
|
||||
// (见 knowledge 的媒体侧车),不再需要外部 getter/putter 接线。
|
||||
personality: cfg.Personality,
|
||||
|
||||
96
internal/agent/core/comefrom.go
Normal file
96
internal/agent/core/comefrom.go
Normal file
@ -0,0 +1,96 @@
|
||||
package core
|
||||
|
||||
// 工具调用帧的**来源标识**(`comefrom`):让插件能精确判定
|
||||
// "这次工具调用是谁发起的",而不是只看"输入来自哪个通道"。
|
||||
//
|
||||
// ## 为什么必须加这个(真实故障,2026-10-16 现场实证)
|
||||
//
|
||||
// 现象:驻留子 `dev-br-jianf-NextAgent-fix_abs-path` 自报 running 但**零产出**,
|
||||
// 那一轮所有工具被权限策略挡下。它自己的处理表原文:
|
||||
//
|
||||
// 本轮所有需要的工具都被权限策略挡下了,实测被拒的有:
|
||||
// - cmd_run —— 无法进入工作目录、无法看 git 状态
|
||||
//
|
||||
// 日志实证(同一时段,09:21:25~09:21:40):
|
||||
//
|
||||
// [agent] executing tool: cmd_run (plugin=cmd)
|
||||
// [proc] qq stage before_toolcall 改写了 1 个字段 ← 插件写入了拒绝响应
|
||||
// [agent] executing tool: files_ls ...
|
||||
// [proc] qq stage before_toolcall 改写了 1 个字段
|
||||
// (而同一时段父 main 的工具调用**有 result**,驻留子的**零 result**)
|
||||
//
|
||||
// ## 根因
|
||||
//
|
||||
// 通道插件的权限门是 `StageScopeGlobal` —— 它对**所有 agent** 的每一轮都生效。
|
||||
// 而它手里只有"输入来源"(`Extra["input_source"]`),**没有"执行者是谁"**:
|
||||
//
|
||||
// · 父:输入来自 qq ⇒ 按那位 QQ 用户判权限 ✅ 正确
|
||||
// · 驻留子:输入**继承自父**(QQ 来源),但执行者**不是那位用户**
|
||||
// ⇒ 插件仍按"那个 QQ 用户"判 ⇒ 开发工具不在白名单 ⇒ 全被拒 ❌
|
||||
//
|
||||
// 关键在于:**用户请求已经过了门**(父按真实身份判定过),驻留子做的是
|
||||
// 父自己的内部委派决定。对已经准入的工作再做一次"用户白名单"检查,
|
||||
// 既拦掉了合法工作,又不产生任何安全增益(同一次请求,父已判定)。
|
||||
//
|
||||
// ## 契约(插件侧读法)
|
||||
//
|
||||
// comefrom == "user" 该调用代表**用户请求**在系统边界上发起 ⇒ 走用户权限模型
|
||||
// comefrom == "internal" 该调用是 agent 体系内部的(委派/转投/自主续跑)
|
||||
// ⇒ 不套"用户白名单";是否放行由插件按自身策略决定
|
||||
//
|
||||
// 缺省(键不存在)时插件应**按旧行为**处理(= "user" 语义):
|
||||
// 这条是为了不破坏已发布的插件 —— 它们不认识这个键,行为必须逐字不变。
|
||||
//
|
||||
// ## 为什么放 Extra 而不是给 StageContext 加具名字段
|
||||
//
|
||||
// `Extra` 已有**通用袋**(proc 桥的 `fExtraBag`)能跨进程/跨 stage 往返
|
||||
// (见 internal/plugin/proc/shm.go 的注释,那正是为"插件自建键"加的)。
|
||||
// 走 Extra 不需要改 SDK 结构体、不需要动 shm 的具名字段表,
|
||||
// 且插件侧 `ctx.Extra["comefrom"]` 与既有 `input_source` 读法一致。
|
||||
//
|
||||
// ★ 一旦某天需要它**强类型**(编译期保证),再提升为具名字段;
|
||||
// 现在 Extra 是成本最低且与既有惯例一致的选择。
|
||||
const extraKeyComeFrom = "comefrom"
|
||||
|
||||
// comefrom 的两个取值。
|
||||
const (
|
||||
// comeFromUser:用户请求在边界上发起(默认语义)。
|
||||
comeFromUser = "user"
|
||||
// comeFromInternal:agent 体系内部发起(委派、转投、自主续跑)。
|
||||
comeFromInternal = "internal"
|
||||
)
|
||||
|
||||
// stampComeFrom 把来源标识写进帧的 Extra(幂等;不覆盖已有值)。
|
||||
//
|
||||
// 不覆盖是有意的:更内层(知道更准)的调用点先写了就以它为准;
|
||||
// 外层只在"没人标过"时补一个保守值。
|
||||
func stampComeFrom(extra map[string]interface{}, v string) {
|
||||
if extra == nil {
|
||||
return
|
||||
}
|
||||
if _, exists := extra[extraKeyComeFrom]; exists {
|
||||
return
|
||||
}
|
||||
extra[extraKeyComeFrom] = v
|
||||
}
|
||||
|
||||
// comefromForAgent 由 agent 的层级身份推导来源标识。
|
||||
//
|
||||
// 规则(就这么简单,不需要更多分支):
|
||||
//
|
||||
// 驻留子(有 parentID)⇒ internal 它不是用户请求的边界,是父的委派体
|
||||
// 根 agent(无 parentID)⇒ user 它才是用户请求的落点
|
||||
//
|
||||
// 为什么不去看 `input_source`:来源通道说明"消息从哪来",不说明"谁在执行"。
|
||||
// 父处理 QQ 消息时来源是 qq(user);把这条工作转投给驻留子后,
|
||||
// 驻留子的来源标签**仍是 qq**(转投刻意保留原事件),但执行者变了。
|
||||
// 只看来源必然判错 —— 这正是本缺陷的成因。
|
||||
func (a *Agent) comefromForAgent() string {
|
||||
if a == nil {
|
||||
return comeFromUser
|
||||
}
|
||||
if a.parentID != "" {
|
||||
return comeFromInternal
|
||||
}
|
||||
return comeFromUser
|
||||
}
|
||||
165
internal/agent/core/comefrom_test.go
Normal file
165
internal/agent/core/comefrom_test.go
Normal file
@ -0,0 +1,165 @@
|
||||
package core
|
||||
|
||||
// 工具调用帧的「来源标识」判据(2026-10-16)。
|
||||
//
|
||||
// 要证明的核心是:**驻留子的工具调用能被识别为"内部委派",
|
||||
// 而不是被当成"那位用户发起的请求"** —— 后者会让通道插件的
|
||||
// 用户权限模型去拦一份已经准入的工作(现场实证:驻留子零产出)。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
// ★★★ 判据:来源标识**必须在帧的起点就写入**(不能只在工具帧上)。
|
||||
//
|
||||
// 为什么:插件可能在 on_input 阶段就据此决定是否介入,
|
||||
// 而那个阶段**早于**工具帧构造。只在工具帧写会漏掉这条路径。
|
||||
func TestComeFromIsStampedAtFrameOrigin(t *testing.T) {
|
||||
root := newBareAgent("main", "")
|
||||
ctx := root.stageCtxFromInput("hi", "", "")
|
||||
got, ok := ctx.Extra[extraKeyComeFrom]
|
||||
if !ok {
|
||||
t.Fatal("★ 帧起点未写 comefrom(插件在 on_input 阶段拿不到来源标识)")
|
||||
}
|
||||
if got != comeFromUser {
|
||||
t.Errorf("根 agent 的帧应为 %q,实际 %q", comeFromUser, got)
|
||||
}
|
||||
|
||||
// 驻留子:有 parentID ⇒ internal
|
||||
child := newBareAgent("worker-1", "main")
|
||||
cctx := child.stageCtxFromInput("hi", "", "")
|
||||
if got := cctx.Extra[extraKeyComeFrom]; got != comeFromInternal {
|
||||
t.Errorf("★ 驻留子的帧应为 %q(它是父的委派体,不是用户请求的边界),实际 %q",
|
||||
comeFromInternal, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ **最关键的一条**:工具调用帧必须带标识,且驻留子的是 internal。
|
||||
//
|
||||
// 这是本次缺陷的直接回归判据:缺了它,通道插件的 before_toolcall
|
||||
// 会把驻留子的调用按"非所有者用户"判定 ⇒ 开发工具全被拒。
|
||||
func TestToolFrameCarriesComeFrom(t *testing.T) {
|
||||
// 驻留子(有父)⇒ 工具帧必须是 internal
|
||||
child := newBareAgent("worker-1", "main")
|
||||
base := child.stageCtxFromInput("do work", "", "")
|
||||
f := &TaskFrame{StageCtx: base, PendingTools: []agentAPI.ToolCall{
|
||||
{Name: "cmd_run"}, {Name: "files_ls"},
|
||||
}}
|
||||
f.buildToolContexts()
|
||||
if len(f.toolCtxs) != 2 {
|
||||
t.Fatalf("工具帧数量不对: %d", len(f.toolCtxs))
|
||||
}
|
||||
for i, name := range []string{"cmd_run", "files_ls"} {
|
||||
got, ok := f.toolCtxs[i].Extra[extraKeyComeFrom]
|
||||
if !ok {
|
||||
t.Fatalf("★ 工具 %s 的帧没有 comefrom(缺陷回归)", name)
|
||||
}
|
||||
if got != comeFromInternal {
|
||||
t.Errorf("★ 驻留子的工具 %s 应为 %q,实际 %q —— "+
|
||||
"会被通道插件按用户权限模型拦住", name, comeFromInternal, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 根 agent ⇒ 工具帧必须是 user(不能因为加了这个就放宽用户权限)
|
||||
root := newBareAgent("main", "")
|
||||
rbase := root.stageCtxFromInput("hi", "", "")
|
||||
rf := &TaskFrame{StageCtx: rbase, PendingTools: []agentAPI.ToolCall{{Name: "cmd_run"}}}
|
||||
rf.buildToolContexts()
|
||||
if got := rf.toolCtxs[0].Extra[extraKeyComeFrom]; got != comeFromUser {
|
||||
t.Errorf("★ 根 agent 的工具帧应为 %q(否则会放宽用户权限门),实际 %q",
|
||||
comeFromUser, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:即使 base 帧**没有**标识,工具帧也必须带上(不变量兜底)。
|
||||
//
|
||||
// 为什么这条必须独立存在(本会话第 9 次同类教训):
|
||||
// 初版判据只验证"走 stageCtxFromInput 的正常路径",而 copyExtraMap
|
||||
// 已经把 comefrom 复制进工具帧 —— 于是**删掉工具帧里那行 stampComeFrom
|
||||
// 判据依然全绿**(变异不咬)。那说明判据测的是"继承",没测"兜底"。
|
||||
//
|
||||
// 而兜底是有意义的:TaskFrame 可以被手搓(新内部路径、测试替身),
|
||||
// 那种帧的 StageCtx 不经 stageCtxFromInput。没有兜底就会出现
|
||||
// "某些路径的工具帧没有来源标识" —— 而缺标识时插件按旧语义(用户)
|
||||
// 判定 ⇒ 驻留子又被拦。不变量必须由**工具帧自己**保证。
|
||||
func TestToolFrameStampsComeFromEvenWhenBaseLacksIt(t *testing.T) {
|
||||
// 手搓一个**没有** comefrom 的 base(模拟不经 stageCtxFromInput 的路径)
|
||||
base := &sdk.StageContext{Extra: map[string]interface{}{}, Phase: sdk.StageOnInput}
|
||||
if _, ok := base.Extra[extraKeyComeFrom]; ok {
|
||||
t.Fatal("前置条件错:这个 base 不该有标识")
|
||||
}
|
||||
f := &TaskFrame{StageCtx: base, PendingTools: []agentAPI.ToolCall{{Name: "cmd_run"}}}
|
||||
f.buildToolContexts()
|
||||
got, ok := f.toolCtxs[0].Extra[extraKeyComeFrom]
|
||||
if !ok {
|
||||
t.Fatal("★ 工具帧未兜底写入 comefrom —— base 没标时就完全没标识了")
|
||||
}
|
||||
// 兜底值必须是**保守**的 user(宁可多查一次,不可静默放宽权限)
|
||||
if got != comeFromUser {
|
||||
t.Errorf("兜底值应为保守的 %q,实际 %q", comeFromUser, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:工具帧的 Extra 仍然**逐份独立**(不能因为加键而退化成共享 map)。
|
||||
//
|
||||
// 既有纪律(task.go 注释):Extra 被 stage handler 写入,若多份共享同一
|
||||
// map,一个工具的身份绑定会串到同批其他工具上 —— 那是越权。
|
||||
func TestToolFramesHaveIndependentExtra(t *testing.T) {
|
||||
a := newBareAgent("worker", "main")
|
||||
base := a.stageCtxFromInput("x", "", "")
|
||||
f := &TaskFrame{StageCtx: base, PendingTools: []agentAPI.ToolCall{{Name: "t1"}, {Name: "t2"}}}
|
||||
f.buildToolContexts()
|
||||
if len(f.toolCtxs) != 2 {
|
||||
t.Fatal("工具帧数量不对")
|
||||
}
|
||||
f.toolCtxs[0].Extra["probe"] = "only-first"
|
||||
if _, leaked := f.toolCtxs[1].Extra["probe"]; leaked {
|
||||
t.Fatal("★ 工具帧共享了 Extra(一个工具的改写会串到别的工具上 = 越权风险)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**不覆盖**已有标识(更内层知道更准)。
|
||||
func TestStampComeFromDoesNotOverwrite(t *testing.T) {
|
||||
m := map[string]interface{}{extraKeyComeFrom: comeFromInternal}
|
||||
stampComeFrom(m, comeFromUser)
|
||||
if m[extraKeyComeFrom] != comeFromInternal {
|
||||
t.Errorf("★ 不该覆盖已有值(内层更准),实际被改成 %v", m[extraKeyComeFrom])
|
||||
}
|
||||
// nil map 不得 panic
|
||||
stampComeFrom(nil, comeFromUser)
|
||||
}
|
||||
|
||||
// ★★★ 判据:缺标识时的语义 = 旧行为(user)。
|
||||
//
|
||||
// 已发布插件不认识 comefrom。内核必须保证:**缺失时插件看到的行为
|
||||
// 与改动前完全一致**,否则这个改动对存量插件是破坏性的。
|
||||
func TestMissingComeFromKeepsLegacySemantics(t *testing.T) {
|
||||
// 一个没有任何标识的帧(模拟旧路径)
|
||||
ctx := &sdk.StageContext{Extra: map[string]interface{}{}}
|
||||
if _, ok := ctx.Extra[extraKeyComeFrom]; ok {
|
||||
t.Fatal("前置条件错:这个帧不该有标识")
|
||||
}
|
||||
// 内核侧的保守默认就是 comeFromUser —— 插件缺失时按旧语义(用户)处理
|
||||
if comeFromUser != "user" {
|
||||
t.Errorf("默认值必须是与旧行为等价的 %q,实际 %q", "user", comeFromUser)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 测试脚手架 ----
|
||||
|
||||
// newBareAgent 造一个只有身份(id + parentID)的最小 Agent。
|
||||
//
|
||||
// 不装配 io/provider:判据只验证"来源标识怎么算、怎么落到帧上",
|
||||
// 装重型依赖会让判据变慢且掩盖真正的断言点。
|
||||
func newBareAgent(id, parent string) *Agent {
|
||||
return New(AgentConfig{
|
||||
ID: types.AgentID(id),
|
||||
ParentID: parent,
|
||||
IO: agentIO.NewIOManager(),
|
||||
})
|
||||
}
|
||||
229
internal/agent/core/commondev_premise_test.go
Normal file
229
internal/agent/core/commondev_premise_test.go
Normal file
@ -0,0 +1,229 @@
|
||||
package core
|
||||
|
||||
// 刀 0:验证 commondev 架构赖以成立的前提。
|
||||
//
|
||||
// 设计 docs/zh/commondev-agent-runtime-design.md §2.2 / §2.3 / §2.5。
|
||||
//
|
||||
// 整套「插件注册通道 → 主 agent 建驻留子并划入 → 子调 output_send__ 下任务」
|
||||
// 的架构,建立在**两个**此前只有读码结论、从未跑过的事实上:
|
||||
//
|
||||
// P1. 驻留子能调到**外部插件**注册的工具(若不能,子无法下任务给 pi)
|
||||
// P2. 驻留子能用**外部插件**注册的输出门 output_send__<ch>(若不能,通道白注册)
|
||||
//
|
||||
// 这两条不成立,上层机制全是纸面的 —— 所以先验它,再写插件。
|
||||
//
|
||||
// 注册路径与真实插件一致:工具走 StageHost,输出通道走 IOManager.RegisterDevice
|
||||
// (插件经 proc 桥最终也落到这两处,见 internal/plugin/registry.go:305-320)。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// 与 commondev 真实用法一致的通道名:inputch 可含斜杠,outputch 必须合规
|
||||
// (会被拼成 output_send__<name>,见 SDK RegisterOutputChannel 注释)。
|
||||
const (
|
||||
probeInputCh = "commondev/br-1"
|
||||
probeOutCh = "commondev_br-1"
|
||||
probeTool = "commondev_probe"
|
||||
probeOutGate = "output_send__" + probeOutCh
|
||||
)
|
||||
|
||||
// probeDevice 模拟插件注册的输出通道(与内部 channelDevice 同构)。
|
||||
type probeDevice struct {
|
||||
name string
|
||||
caps agentIO.OutputCapability
|
||||
handler sdk.ToolHandler
|
||||
got *string
|
||||
}
|
||||
|
||||
func (d *probeDevice) Name() string { return d.name }
|
||||
func (d *probeDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *probeDevice) Description() string { return "探针通道:给 pi 投递任务" }
|
||||
func (d *probeDevice) Start() error { return nil }
|
||||
func (d *probeDevice) Stop() error { return nil }
|
||||
func (d *probeDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
|
||||
func (d *probeDevice) Tools() []agentIO.ToolDef { return nil }
|
||||
func (d *probeDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{NoMemory: true} }
|
||||
func (d *probeDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
payload, _ := args["payload"].(string)
|
||||
if d.got != nil {
|
||||
*d.got = payload // 记录插件 handler 真的收到了什么
|
||||
}
|
||||
return d.handler(args)
|
||||
}
|
||||
|
||||
// registerProbePlugin 模拟 commondev 插件注册工具与通道。
|
||||
// delivered 非 nil 时记录插件 handler 实际收到的 payload(证明"包一下"真的发生)。
|
||||
func registerProbePlugin(t *testing.T, a *Agent, delivered *string) {
|
||||
t.Helper()
|
||||
|
||||
// ① 插件工具:子要能调它(P1)
|
||||
if err := a.stageHost.RegisterTool(probeTool, sdk.ToolDef{
|
||||
Name: probeTool,
|
||||
Description: "探针:插件注册的普通工具",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "plugin-tool-ok", nil
|
||||
}); err != nil {
|
||||
t.Fatalf("插件工具注册失败: %v", err)
|
||||
}
|
||||
|
||||
// ② 插件输出通道:子要能用 output_send__commondev_br-1(P2)
|
||||
if err := a.io.RegisterDevice(&probeDevice{
|
||||
name: probeOutCh,
|
||||
caps: agentIO.CapText,
|
||||
handler: func(args map[string]interface{}) (interface{}, error) {
|
||||
return "已投递", nil
|
||||
},
|
||||
got: delivered,
|
||||
}); err != nil {
|
||||
t.Fatalf("插件输出通道注册失败: %v", err)
|
||||
}
|
||||
|
||||
// ③ inputch:pi → 子 的回投入口
|
||||
if err := a.io.RegisterInputChannelFrom("commondev", probeInputCh, agentIO.ChannelDef{NoMemory: true}); err != nil {
|
||||
t.Fatalf("inputch 注册失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestP1_ResidentSeesExternalPluginTools 钉住 P1:
|
||||
// 驻留子的工具表里必须有**外部插件**注册的工具,且真的能执行。
|
||||
//
|
||||
// 若不成立:子无法下任务给 pi ⇒ 整套架构作废(必须改为父代投)。
|
||||
func TestP1_ResidentSeesExternalPluginTools(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
registerProbePlugin(t, parent, nil)
|
||||
|
||||
info := spawnTestResident(t, parent, dir, "dev-br-1", probeInputCh)
|
||||
child := residentOf(t, parent, info.ID)
|
||||
|
||||
// ① 工具表(模型看得到的)里要有
|
||||
names := childToolNames(child)
|
||||
if !hasToolNamed(names, probeTool) {
|
||||
t.Fatalf("P1 不成立:驻留子看不到外部插件工具 %s\n看到的是: %v", probeTool, names)
|
||||
}
|
||||
t.Logf("P1a ✅ 驻留子工具表含插件工具(工具总数 %d)", len(names))
|
||||
|
||||
// ② 且真的能执行(能看到 ≠ 能调到)
|
||||
got := child.executeToolCall(agentAPI.ToolCall{
|
||||
ID: "p1", Name: probeTool, Arguments: map[string]interface{}{},
|
||||
}, probeInputCh)
|
||||
if !strings.Contains(got, "plugin-tool-ok") {
|
||||
t.Fatalf("P1 不成立:工具在表里但调不通: %s", got)
|
||||
}
|
||||
t.Log("P1b ✅ 且可执行(plugin-tool-ok)")
|
||||
}
|
||||
|
||||
// TestP2_ResidentCanUseExternalPluginOutputGate 钉住 P2:
|
||||
// 驻留子必须能通过 output_send__commondev_br-1 打到**插件注册的通道 handler**。
|
||||
//
|
||||
// 若不成立:通道注册了但子用不了 ⇒ 投递路径断掉。
|
||||
func TestP2_ResidentCanUseExternalPluginOutputGate(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
var delivered string
|
||||
registerProbePlugin(t, parent, &delivered)
|
||||
|
||||
info := spawnTestResident(t, parent, dir, "dev-br-1", probeInputCh)
|
||||
child := residentOf(t, parent, info.ID)
|
||||
|
||||
// ① 输出门工具存在(未授权则工具表里根本不会有)
|
||||
names := childToolNames(child)
|
||||
if !hasToolNamed(names, probeOutGate) {
|
||||
t.Fatalf("P2 不成立:驻留子看不到 %s\n看到的是: %v", probeOutGate, names)
|
||||
}
|
||||
t.Logf("P2a ✅ 驻留子有 %s", probeOutGate)
|
||||
|
||||
// ② 调用它 —— 必须真的打到插件 handler
|
||||
//
|
||||
// 期望返回 "ok":`output.go:94` 只在 `dev.Execute()` **成功**时返回它。
|
||||
// ⚠️ 不能断言返回插件 handler 的文本:内核对成功的回执刻意**极简**
|
||||
//(rich 回执会驱动模型继续调 output_send,是 output loop 的根源)。
|
||||
// 所以"插件真的收到了"要由 handler 自己记下来(delivered)来证。
|
||||
got := child.executeToolCall(agentAPI.ToolCall{
|
||||
ID: "p2", Name: probeOutGate,
|
||||
Arguments: map[string]interface{}{"payload": "给 pi 的任务:修 bug", "type": "text"},
|
||||
}, probeInputCh)
|
||||
if strings.Contains(got, "不存在") || strings.Contains(got, "未授权") {
|
||||
t.Fatalf("P2 不成立:输出门不可用: %s", got)
|
||||
}
|
||||
if got != "ok" {
|
||||
t.Fatalf("P2 不成立:输出门应返回 ok(dev.Execute 成功),实际: %q", got)
|
||||
}
|
||||
// ★ 真正的判据:插件 handler 收到的 payload
|
||||
if delivered != "给 pi 的任务:修 bug" {
|
||||
t.Fatalf("P2 不成立:插件 handler 收到的 payload 不符: %q", delivered)
|
||||
}
|
||||
t.Logf("P2b ✅ 输出门打到插件 handler,payload 完整传达(%d 字节)", len(delivered))
|
||||
}
|
||||
|
||||
// TestP3_ChannelNameSlashIsIllegalForOutputGate 钉住命名约束(文档 §2.2):
|
||||
// outputch 带斜杠会让 output_send__<name> 不合法 ⇒ 工具名违规。
|
||||
//
|
||||
// 这条是**反例**:证明"inputch 可用斜杠、outputch 不行"不是猜想。
|
||||
func TestP3_ChannelNameSlashIsIllegalForOutputGate(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
registerProbePlugin(t, parent, nil)
|
||||
|
||||
info := spawnTestResident(t, parent, dir, "dev-br-1", probeInputCh)
|
||||
child := residentOf(t, parent, info.ID)
|
||||
names := childToolNames(child)
|
||||
|
||||
// 合法命名:有
|
||||
if !hasToolNamed(names, probeOutGate) {
|
||||
t.Fatalf("下划线命名应可用,但没有 %s", probeOutGate)
|
||||
}
|
||||
// 非法命名:绝不能出现在工具表里
|
||||
for _, n := range names {
|
||||
if strings.HasPrefix(n, "output_send__") && strings.Contains(n, "/") {
|
||||
t.Fatalf("发现带斜杠的输出门工具名 %q —— 会触发上游 400", n)
|
||||
}
|
||||
}
|
||||
t.Log("P3 ✅ 输出门命名合规(无斜杠)")
|
||||
}
|
||||
|
||||
// ---------------- 测试辅助 ----------------
|
||||
|
||||
// residentOf 取父登记表里的驻留子(不存在则 fatal)。
|
||||
func residentOf(t *testing.T, parent *Agent, id string) *Agent {
|
||||
t.Helper()
|
||||
parent.residentMu.Lock()
|
||||
rc, ok := parent.residents[id]
|
||||
parent.residentMu.Unlock()
|
||||
if !ok || rc == nil || rc.agent == nil {
|
||||
t.Fatalf("驻留子 %s 不存在", id)
|
||||
}
|
||||
return rc.agent
|
||||
}
|
||||
|
||||
// childToolNames 取某个 agent 当前工具表里的名字。
|
||||
func childToolNames(a *Agent) []string {
|
||||
var out []string
|
||||
for _, raw := range a.buildToolDefs() {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fn, _ := m["function"].(map[string]interface{})
|
||||
if fn == nil {
|
||||
continue
|
||||
}
|
||||
if name, _ := fn["name"].(string); name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasToolNamed(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -351,10 +351,17 @@ func (c *RelevanceContext) Save() error {
|
||||
return os.WriteFile(c.savePath, data, 0644)
|
||||
}
|
||||
|
||||
// Append 追加一条事件。
|
||||
//
|
||||
// ★ 这里是**持久化记忆的唯一咽喉**:全部 5 处写 context 的调用都经过它
|
||||
// (另一处 InsertByTimestamp 同样处理)。所以明文拦截放在这里,
|
||||
// 而不是在每个调用点 —— 逐个补必漏(实测踩到:只拦了 f.Msgs,
|
||||
// 而 f.CleanInput / f.ToolResults / f.Response 都是独立副本)。
|
||||
func (c *RelevanceContext) Append(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
scrubEventSecrets(&evt)
|
||||
c.computeVector(&evt)
|
||||
c.events = append(c.events, &evt)
|
||||
|
||||
@ -365,6 +372,7 @@ func (c *RelevanceContext) InsertByTimestamp(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
scrubEventSecrets(&evt) // 同 Append:咽喉处拦截
|
||||
c.computeVector(&evt)
|
||||
|
||||
idx := sort.Search(len(c.events), func(i int) bool {
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// 这组判据把「上下文管理的阈值」从硬编码变成可配置,并守住两条纪律:
|
||||
//
|
||||
// 1. **零值等价**:ContextTuning 零值必须与硬编码时代**逐值相同**。
|
||||
|
||||
@ -19,11 +19,11 @@ import (
|
||||
// memory.RecallBlocks(读块自己的向量),因此不存在「召回已无处可归的
|
||||
// 内容」这回事——能召回的必然是被某层持有的块。
|
||||
type CrossModalHit struct {
|
||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||
Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块
|
||||
MediaScore float64 // 视觉路原始 cosine(无则 0)
|
||||
DocScore float64 // 文本路原始 cosine(无则 0)
|
||||
Fused float64 // 归一化加权融合分,供最终排序
|
||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||
Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块
|
||||
MediaScore float64 // 视觉路原始 cosine(无则 0)
|
||||
DocScore float64 // 文本路原始 cosine(无则 0)
|
||||
Fused float64 // 归一化加权融合分,供最终排序
|
||||
// 该媒体同时被两路命中时 DoubleHit=true —— 双信号确认,应排在只被
|
||||
// 一路命中的候选之前。
|
||||
DoubleHit bool
|
||||
|
||||
@ -605,7 +605,7 @@ func isTemplateSummary(s string) bool {
|
||||
}
|
||||
|
||||
func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults []ToolResultItem, toolsUsed []string) {
|
||||
a.io.EmitOutput("memory", "memory_candidate", map[string]interface{}{
|
||||
payload := map[string]interface{}{
|
||||
"source": source,
|
||||
"input": input,
|
||||
"response": response,
|
||||
@ -613,7 +613,11 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults
|
||||
"tools_used": toolsUsed,
|
||||
"agent_id": string(a.id),
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
// ★ 记忆候选是**不经 context** 的另一条持久化侧路(走 EmitOutput)。
|
||||
// 不在这里拦,明文会直接进长期记忆 —— 而记忆跨会话保留、每轮被召回。
|
||||
scrubMemoryCandidate(payload)
|
||||
a.io.EmitOutput("memory", "memory_candidate", payload)
|
||||
}
|
||||
|
||||
func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
|
||||
|
||||
54
internal/agent/core/encshell_premise_test.go
Normal file
54
internal/agent/core/encshell_premise_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package core
|
||||
|
||||
// 前提验证:插件 enc_shell 的**命令参数**是否已被内核填好明文?
|
||||
//
|
||||
// 若已填 ⇒ 插件自己的 lookupAnySecret 对内核别名是**死代码**,
|
||||
// 且「保留 enc_shell」的理由只剩 argv 保护(脚本走 stdin)。
|
||||
//
|
||||
// 这决定了 SDK 该不该暴露 Fill():
|
||||
// · 若插件本来就能从 args 拿到明文 ⇒ 暴露 Fill 不新增任何权限
|
||||
// · 若拿不到 ⇒ 暴露 Fill 会削弱边界(插件可枚举 List 再逐个 Fill)
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func TestPremise_EncShellCommandPrefilledByKernel(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "测")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var cmdSaw string
|
||||
if err := a.stageHost.RegisterTool("probe_encsh", sdk.ToolDef{
|
||||
Name: "probe_encsh", Description: "模拟 enc_shell",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
cmdSaw, _ = args["command"].(string)
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := a.stageHost.ExecuteTool("probe_encsh", map[string]interface{}{
|
||||
"command": "curl -H 'Authorization: token @{" + alias + "}'",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if strings.Contains(cmdSaw, "@{"+alias+"}") {
|
||||
t.Fatal("handler 仍看到别名 ⇒ 内核没填,插件需要自己的填充逻辑")
|
||||
}
|
||||
if !strings.Contains(cmdSaw, tok) {
|
||||
t.Fatalf("handler 应看到明文,实际 %q", cmdSaw)
|
||||
}
|
||||
t.Logf("✅ 内核已填 ⇒ 插件对**内核别名**的填充是死代码;" +
|
||||
"插件仍需自己解析的是**平台凭据**(@ {gitea-local},含连字符,内核不认)")
|
||||
}
|
||||
154
internal/agent/core/encshell_redundancy_test.go
Normal file
154
internal/agent/core/encshell_redundancy_test.go
Normal file
@ -0,0 +1,154 @@
|
||||
package core
|
||||
|
||||
// 前提验证(C7):内核的 `@{别名}` 回填是否让插件的 `enc_shell` 变得**多余**。
|
||||
//
|
||||
// ## 为什么要先验这个再删代码
|
||||
//
|
||||
// C7 要「大幅删减插件的加密 shell 工具族」。删的依据是一个断言:
|
||||
// 用户可以用**任何**工具(如内置的 cmd_run)配合 `@{别名}`,
|
||||
// 由内核对所有工具参数统一回填 —— 而不必依赖插件专有的 enc_shell。
|
||||
//
|
||||
// 这个断言不成立的话,删掉 enc_shell 就是**削功能**而非去冗余。
|
||||
//
|
||||
// 已验证过 `TestFirewall_ToolArgsFilledBeforePlugin`(插件工具收到明文)。
|
||||
// 这里补的是**决定性**的那一条:用**内置工具名**(cmd_run)也走同一条回填路径。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// ★★ 判据:`cmd_run`(内置终端工具,非插件)的参数同样被内核回填。
|
||||
//
|
||||
// 这条成立 ⇒ 用户不必用插件专有的 enc_shell:
|
||||
//
|
||||
// enc_shell(command="git push https://x:{token}@host/r.git fav")
|
||||
// ↓ 等价于
|
||||
// cmd_run(command="git push https://x:@{sec_xxx}@host/r.git fav")
|
||||
//
|
||||
// 后者完全走内核机制,插件无需自带加密库与别名表。
|
||||
func TestKernelFillCoversBuiltinToolsToo(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "测试")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 注册一个**模拟 cmd_run 的内置工具**(不真的执行 shell),
|
||||
// 验证它通过 StageHost.ExecuteTool 拿到的是明文。
|
||||
var seen string
|
||||
if err := a.stageHost.RegisterTool("cmd_run", sdk.ToolDef{
|
||||
Name: "cmd_run", Description: "模拟内置终端工具",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
seen, _ = args["command"].(string)
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := a.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
|
||||
"command": "git push https://x:@" + "{" + alias + "}@host/r.git feat/x",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if seen == "" {
|
||||
t.Fatal("工具未被调用")
|
||||
}
|
||||
if !strings.Contains(seen, tok) {
|
||||
t.Fatalf("★ 内核回填未覆盖内置工具名 —— 那 enc_shell 就有存在理由(不能删)\n实际: %q", seen)
|
||||
}
|
||||
t.Logf("✅ 内置工具名的参数也被回填 ⇒ enc_shell 的填充功能是冗余的: %q", seen)
|
||||
}
|
||||
|
||||
// ★★ 判据:内核回填**发生在插件 handler 之前** ⇒ 插件自己的别名表对
|
||||
// 内核别名是**死代码**(永远不会命中)。
|
||||
//
|
||||
// 这个顺序很关键:
|
||||
//
|
||||
// StageHost.ExecuteTool → security.FillMap(args) → handler(args)
|
||||
//
|
||||
// 所以当内核安全模块启用时,插件的 enc_shell 收到的 command 里
|
||||
// `@{sec_xxx}` **已经变成明文**了;插件自己的别名正则再扫也扫不到。
|
||||
//
|
||||
// ⇒ 结论:插件的「填充 @{别名}」这部分是**冗余**的(内核先做了)。
|
||||
//
|
||||
// 但它的 argv 保护(脚本走 stdin)与输出脱敏**仍然独有**,不可删。
|
||||
func TestKernelFillHappensBeforePluginHandler(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "测试")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var handlerSaw string
|
||||
if err := a.stageHost.RegisterTool("probe_encshell", sdk.ToolDef{
|
||||
Name: "probe_encshell", Description: "模拟 enc_shell",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
handlerSaw, _ = args["command"].(string)
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := a.stageHost.ExecuteTool("probe_encshell", map[string]interface{}{
|
||||
"command": "curl -H 'Authorization: token @{" + alias + "}'",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// handler 里看到的**已经没有 @{别名}** 了(已被内核换掉)
|
||||
if strings.Contains(handlerSaw, "@{"+alias+"}") {
|
||||
t.Fatalf("内核回填应在 handler 之前完成;实际 handler 仍看到别名: %q", handlerSaw)
|
||||
}
|
||||
if !strings.Contains(handlerSaw, tok) {
|
||||
t.Fatalf("handler 应看到明文: %q", handlerSaw)
|
||||
}
|
||||
t.Logf("✅ 内核先回填,插件看不到别名 ⇒ 插件自带别名表对内核别名是死代码")
|
||||
}
|
||||
|
||||
// ★★ 判据:内核与插件的 `@{...}` 引用**语法不冲突**(带连字符的名字归插件)。
|
||||
//
|
||||
// 这是个**偶然但真实**的兼容,必须钉住,否则将来有人"统一语法"就炸:
|
||||
//
|
||||
// 内核 refPattern: @\{([A-Za-z0-9_]{1,64})\} ← 不含 - 和 .
|
||||
// 插件 credRefPattern: @\{([A-Za-z0-9_.-]{1,64})\} ← 含 - 和 .
|
||||
//
|
||||
// 于是:
|
||||
//
|
||||
// @{sec_3718c87a11e9478b} 内核吃(无连字符)
|
||||
// @{gitea-local} 内核**不吃**(有连字符)⇒ 留给插件解析
|
||||
//
|
||||
// ★ 若把内核的字符集放宽到含 `-`,插件自己的凭据(gitea-local)会被内核
|
||||
//
|
||||
// 先吃掉 —— 那些名字不在内核变量库里,内核会原样保留(认识不到),
|
||||
// 看起来"没坏",但只要恰好有一个同名内核变量,就会静默换错值。
|
||||
func TestKernelAndPluginAliasSyntaxDoNotCollide(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "内核变量")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 内核别名(无连字符)→ 被回填
|
||||
if got, n, _ := store.Fill("x:@" + "{" + alias + "}"); n != 1 || !strings.Contains(got, tok) {
|
||||
t.Fatalf("内核别名应被回填: n=%d got=%q", n, got)
|
||||
}
|
||||
// 插件风格的名字(含连字符)→ **不被内核碰**
|
||||
pluginStyle := "git push https://x:@" + "{gitea-local}@host/r.git"
|
||||
if got, n, _ := store.Fill(pluginStyle); n != 0 || !strings.Contains(got, "{gitea-local}") {
|
||||
t.Fatalf("含连字符的引用内核不应碰(那是插件的凭据): n=%d got=%q", n, got)
|
||||
}
|
||||
t.Logf("✅ 内核只吃 [A-Za-z0-9_],@{gitea-local} 留给插件")
|
||||
}
|
||||
284
internal/agent/core/firewall_e2e_test.go
Normal file
284
internal/agent/core/firewall_e2e_test.go
Normal file
@ -0,0 +1,284 @@
|
||||
package core
|
||||
|
||||
// 刀 S:**真机端到端**验证安全模块。
|
||||
//
|
||||
// 前面的判据都跑在 mock provider 上。这里用**真实的 Agent**(与生产同一套
|
||||
// StageHost / IOManager / 设备出口),验证三件事真的连通:
|
||||
//
|
||||
// ① 出向:Agent 发往 provider 的消息里,明文已变成别名
|
||||
// ② 落盘:变量库有记录,且磁盘上没有明文
|
||||
// ③ 入向:插件工具收到的参数是**明文**(回填生效,插件无感知)
|
||||
// ④ 入向:设备通道收到的 payload 是**明文**
|
||||
//
|
||||
// 为什么必须做真机验证:mock 判据只能证明"装饰器逻辑对",
|
||||
// 证明不了"装饰器挂在正确的点位上"。位置错(比如挂在 stage 之后)
|
||||
// 在 mock 里完全看不出来。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
const firewallToken = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// fwToolDef 构造一个最小工具声明(本文件专用,避开与既有测试的同名辅助冲突)。
|
||||
func fwToolDef(name string) sdk.ToolDef {
|
||||
return sdk.ToolDef{
|
||||
Name: name,
|
||||
Description: "安全模块测试工具",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{"token": map[string]interface{}{"type": "string"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func setupFirewall(t *testing.T) (*security.Store, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store, err := security.InitGlobal(security.Options{
|
||||
Path: filepath.Join(dir, "vars.json"),
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
return store, dir
|
||||
}
|
||||
|
||||
// ★★ 判据:插件工具收到的参数是**明文**(回填生效)。
|
||||
//
|
||||
// 这是整个设计的核心目的:模型手上只有别名,插件拿到真凭据,
|
||||
// 而**插件对替换完全无感知**。
|
||||
func TestFirewall_ToolArgsFilledBeforePlugin(t *testing.T) {
|
||||
store, _ := setupFirewall(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
// 插件工具:记录它实际收到的参数
|
||||
var seenToken string
|
||||
const toolName = "plugintest_store_secret"
|
||||
if err := a.stageHost.RegisterTool(toolName, fwToolDef(toolName),
|
||||
func(args map[string]interface{}) (interface{}, error) {
|
||||
seenToken, _ = args["token"].(string)
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 模型只知道别名(它从未见过明文)
|
||||
alias, err := store.Submit(firewallToken, "测试")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.stageHost.ExecuteTool(toolName, map[string]interface{}{
|
||||
"token": "@{" + alias + "}",
|
||||
"note": "保持不变",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ★ 插件看到的是明文
|
||||
if seenToken != firewallToken {
|
||||
t.Fatalf("插件应收到明文,实际收到 %q(回填未生效 ⇒ 插件拿不到真凭据)", seenToken)
|
||||
}
|
||||
t.Logf("✅ 插件工具收到的 token 是明文(%d 字节),别名 %s 已回填", len(seenToken), alias)
|
||||
}
|
||||
|
||||
// ★★ 判据:设备通道收到的 payload 是**明文**。
|
||||
//
|
||||
// 这条验证回填插在了正确位置 —— 在插件 stage **之后**、设备 Execute **之前**。
|
||||
// 若插错(比如 stage 之前),插件会看到明文;
|
||||
// 若压根没插,设备会收到字面的 "@{sec_xxx}"。
|
||||
func TestFirewall_DevicePayloadFilledBeforeExecute(t *testing.T) {
|
||||
store, _ := setupFirewall(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
var delivered string
|
||||
const outCh = "plugintest_secch"
|
||||
if err := a.io.RegisterDevice(&probeDevice{
|
||||
name: outCh,
|
||||
caps: agentIO.CapText,
|
||||
handler: func(args map[string]interface{}) (interface{}, error) {
|
||||
return "已发送", nil
|
||||
},
|
||||
got: &delivered,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
alias, err := store.Submit(firewallToken, "测试")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 模型发出的 payload 里只有别名
|
||||
got := a.executeOutputSendTool(agentAPI.ToolCall{
|
||||
Name: "output_send__" + outCh,
|
||||
Arguments: map[string]interface{}{
|
||||
"payload": "我的 token 是 @{" + alias + "} 请使用",
|
||||
},
|
||||
})
|
||||
|
||||
// ★ 设备收到明文
|
||||
if !strings.Contains(delivered, firewallToken) {
|
||||
t.Fatalf("设备应收到明文,实际 %q(回填位置错或未生效)\n工具返回: %s",
|
||||
delivered, got)
|
||||
}
|
||||
if strings.Contains(delivered, alias) {
|
||||
t.Errorf("别名不应出现在设备载荷里: %q", delivered)
|
||||
}
|
||||
t.Logf("✅ 设备通道收到明文(%d 字节)", len(delivered))
|
||||
}
|
||||
|
||||
// ★ 判据:出向拦截真的发生在真实 provider 调用上(用真实 ProviderManager)。
|
||||
func TestFirewall_OutboundScrubbedOnRealProviderPath(t *testing.T) {
|
||||
setupFirewall(t)
|
||||
var captured string
|
||||
mgr := agentAPI.NewProviderManager()
|
||||
mgr.Register("probe", &fwCaptureProvider{name: "probe", capture: &captured})
|
||||
|
||||
_, err := mgr.Default().Chat(context.Background(), &agentAPI.CompletionRequest{
|
||||
Messages: []agentAPI.Message{
|
||||
{Role: "user", Content: "这是我的凭据: " + firewallToken},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(captured, firewallToken) {
|
||||
t.Fatalf("明文泄漏到 provider: %s", captured)
|
||||
}
|
||||
if !strings.Contains(captured, "安全模块自动拦截") {
|
||||
t.Errorf("未见拦截提示: %s", captured)
|
||||
}
|
||||
t.Logf("✅ 出向拦截生效,provider 收到: %s", captured)
|
||||
}
|
||||
|
||||
// ★ 判据:磁盘上**没有明文**(加密落盘),且权限 0600。
|
||||
func TestFirewall_NoPlaintextOnDisk(t *testing.T) {
|
||||
store, dir := setupFirewall(t)
|
||||
if _, err := store.Submit(firewallToken, "落盘"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 扫变量库目录下所有文件
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(b), firewallToken) {
|
||||
t.Fatalf("磁盘文件 %s 出现明文", e.Name())
|
||||
}
|
||||
fi, _ := e.Info()
|
||||
if perm := fi.Mode().Perm(); perm&0o077 != 0 {
|
||||
t.Errorf("文件 %s 权限过宽: %04o", e.Name(), perm)
|
||||
}
|
||||
}
|
||||
t.Logf("✅ 目录下 %d 个文件均无明文,权限均受限", len(entries))
|
||||
}
|
||||
|
||||
// ★ 判据:**同明文合并**(同一次对话里多个通道用同一个 secret ⇒ 同一别名)。
|
||||
func TestFirewall_SameSecretMergesAcrossChannels(t *testing.T) {
|
||||
store, _ := setupFirewall(t)
|
||||
|
||||
// 两处独立出现同一明文(模拟两个插件都用同一个 token)
|
||||
alias1, _ := store.Submit(firewallToken, "通道A")
|
||||
alias2, _ := store.Submit(firewallToken, "通道B")
|
||||
|
||||
if alias1 != alias2 {
|
||||
t.Fatalf("同一明文必须合并到同一别名: %s vs %s", alias1, alias2)
|
||||
}
|
||||
if store.Count() != 1 {
|
||||
t.Errorf("应只有 1 条记录(合并),实际 %d", store.Count())
|
||||
}
|
||||
t.Logf("✅ 同一明文跨通道合并到 %s(记录数 1)", alias1)
|
||||
}
|
||||
|
||||
// ★ 判据:端到端一轮 —— 出向后别名回填,模型可用同一别名再次调用。
|
||||
//
|
||||
// 这是完整闭环:拦截 → 别名进上下文 → 模型用别名 → 回填 → 插件拿到明文。
|
||||
func TestFirewall_FullRoundTrip(t *testing.T) {
|
||||
store, _ := setupFirewall(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
var pluginSaw string
|
||||
const toolName = "plugintest_roundtrip"
|
||||
if err := a.stageHost.RegisterTool(toolName, fwToolDef(toolName),
|
||||
func(args map[string]interface{}) (interface{}, error) {
|
||||
pluginSaw, _ = args["secret"].(string)
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ① 第一步:模型输出的文本里带明文 ⇒ 被内核拦截成别名
|
||||
// (模拟真实链路:模型回复 → 出向扫描)
|
||||
scrubbed, evts := store.Replace("用这个部署: "+firewallToken, "llm_out:assistant", security.Patterns)
|
||||
if len(evts) != 1 {
|
||||
t.Fatalf("应拦截 1 处,实际 %d", len(evts))
|
||||
}
|
||||
alias := evts[0].Alias
|
||||
t.Logf("① 拦截: 明文 → %s", alias)
|
||||
|
||||
// ② 模型后续调用工具时只能引用别名(它没见过明文)
|
||||
if _, err := a.stageHost.ExecuteTool(toolName, map[string]interface{}{
|
||||
"secret": "@{" + alias + "}",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ③ 插件拿到真凭据
|
||||
if pluginSaw != firewallToken {
|
||||
t.Fatalf("插件应拿到明文,实际 %q", pluginSaw)
|
||||
}
|
||||
|
||||
// ④ 上下文中始终只有别名(模型的视野里无明文)
|
||||
if strings.Contains(scrubbed, firewallToken) {
|
||||
t.Fatal("上下文里出现明文")
|
||||
}
|
||||
// ⑤ 变量记录里也无明文
|
||||
b, _ := json.Marshal(store.List())
|
||||
if strings.Contains(string(b), firewallToken) {
|
||||
t.Fatal("变量记录泄露明文")
|
||||
}
|
||||
t.Logf("② 模型用 @{%s} 调用 → ③ 插件收到明文 → ④⑤ 上下文与记录均无明文", alias)
|
||||
}
|
||||
|
||||
// fwCaptureProvider 记录收到的消息内容(模拟真实 provider 收到的请求)。
|
||||
type fwCaptureProvider struct {
|
||||
name string
|
||||
capture *string
|
||||
}
|
||||
|
||||
func (c *fwCaptureProvider) Name() string { return c.name }
|
||||
func (c *fwCaptureProvider) Chat(_ context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
if req != nil && len(req.Messages) > 0 {
|
||||
*c.capture = req.Messages[0].Content
|
||||
}
|
||||
return &agentAPI.CompletionResponse{}, nil
|
||||
}
|
||||
func (c *fwCaptureProvider) ChatStream(_ context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
if req != nil && len(req.Messages) > 0 {
|
||||
*c.capture = req.Messages[0].Content
|
||||
}
|
||||
ch := make(chan agentAPI.StreamChunk)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
func (c *fwCaptureProvider) MaxContextTokens() int { return 1000 }
|
||||
72
internal/agent/core/inplace_scrub_test.go
Normal file
72
internal/agent/core/inplace_scrub_test.go
Normal file
@ -0,0 +1,72 @@
|
||||
package core
|
||||
|
||||
// 判据:出向拦截的**原地改写**语义(共享底层数组)。
|
||||
//
|
||||
// ## 为什么这条值得单独立判据
|
||||
//
|
||||
// `task.go:663` 构造请求时是 `Messages: f.Msgs` —— **值切片,无拷贝**。
|
||||
// 所以装饰器在出网前改写消息内容时,改的是 `f.Msgs` 的元素本身。
|
||||
//
|
||||
// 这个语义是**有意的**,且必须钉住:
|
||||
//
|
||||
// 好处:明文在第一次出网时就永久离开上下文。
|
||||
// 若不共享,明文会一直躺在 f.Msgs 里,每一轮都要重新拦截,
|
||||
// 并且任何读 f.Msgs 的地方(记忆写入、会话持久化、WebUI 回显)
|
||||
// 都会看到明文 —— 泄漏面大得多。
|
||||
//
|
||||
// 代价:改写会渗进**非 LLM 路径**(同上那几处)。这是刻意的取舍:
|
||||
// 宁可让这些地方看到别名(一致、可诊断),也不要让明文到处躺着。
|
||||
//
|
||||
// ⇒ 若有人把 `Messages: f.Msgs` 改成深拷贝,本判据会报红,
|
||||
// 提醒他同时要在「记忆写入前」和「持久化前」各加一道拦截。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
// ★★ 判据:装饰器原地改写 ⇒ 原始消息切片也变成别名。
|
||||
func TestOutboundScrubIsInPlaceByDesign(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := security.InitGlobal(security.Options{
|
||||
Path: dir + "/v.json",
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// 与 task.go:663 同构:Messages 直接赋 f.Msgs(值切片共享底层数组)
|
||||
msgs := []agentAPI.Message{{Role: "user", Content: "token=" + tok}}
|
||||
|
||||
var captured string
|
||||
mgr := agentAPI.NewProviderManager()
|
||||
mgr.Register("p", &fwCaptureProvider{name: "p", capture: &captured})
|
||||
|
||||
if _, err := mgr.Default().Chat(context.Background(),
|
||||
&agentAPI.CompletionRequest{Messages: msgs}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ① 出网安全
|
||||
if strings.Contains(captured, tok) {
|
||||
t.Fatal("明文泄漏到 provider")
|
||||
}
|
||||
// ② 原地改写:原始切片也变了(**这是刻意的**)
|
||||
if strings.Contains(msgs[0].Content, tok) {
|
||||
t.Fatal("原始消息仍含明文 —— 说明 Messages 被深拷贝了。" +
|
||||
"若确实要改成拷贝,必须同时在记忆写入与持久化前各加一道拦截," +
|
||||
"否则明文会一直躺在 f.Msgs 里被反复读出")
|
||||
}
|
||||
if !strings.Contains(msgs[0].Content, "@{sec_") {
|
||||
t.Fatalf("原始消息应含别名,实际 %q", msgs[0].Content)
|
||||
}
|
||||
t.Logf("原地改写生效:原始 f.Msgs 元素已变别名 %q", msgs[0].Content)
|
||||
}
|
||||
119
internal/agent/core/leakback_test.go
Normal file
119
internal/agent/core/leakback_test.go
Normal file
@ -0,0 +1,119 @@
|
||||
package core
|
||||
|
||||
// ★★ 决定性的安全前提:**插件把明文返回时,内核能否拦住**?
|
||||
//
|
||||
// 这决定了「让安全模块向插件暴露 Fill()」是否安全:
|
||||
//
|
||||
// 若拦得住 ⇒ 暴露 Fill 不新增泄漏面(明文回到内核时会被换回别名)
|
||||
// 若拦不住 ⇒ 暴露 Fill 会让 LLM 通过插件读到任意明文,S1 作废
|
||||
//
|
||||
// 链路:插件 handler 返回明文 → ToolResults → ①context scrub(C5/C6)
|
||||
// → ②provider 装饰器(C1)
|
||||
// 两处都必须拦住。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func TestPremise_PluginReturnedPlaintextIsScrubbed(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "测")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 插件工具:**故意**把明文当返回值吐出来(模拟"恶意/粗糙插件")
|
||||
a := New(AgentConfig{
|
||||
ID: "leakback",
|
||||
Provider: &echoTokenProvider{reply: "收到"},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
if err := a.stageHost.RegisterTool("leaky", sdk.ToolDef{
|
||||
Name: "leaky", Description: "故意回吐明文",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "凭据是 " + tok, nil // ★ 直接回吐明文
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
// 走真实调度循环:模型调 leaky,结果进 context
|
||||
a.io.InjectTextSync("http", "查一下")
|
||||
|
||||
// ① context 侧必须已换成别名
|
||||
for _, evt := range a.context.Recent(0) {
|
||||
for _, tr := range evt.ToolResults {
|
||||
if strings.Contains(tr.Output, tok) {
|
||||
t.Errorf("★ context 侧未拦住:%q", tr.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
// ② 记录里也不能有明文
|
||||
if b := store.List(); len(b) == 0 {
|
||||
t.Fatal("应登记变量")
|
||||
}
|
||||
_ = alias
|
||||
t.Log("✅ 插件回吐的明文被内核拦下(context 侧)")
|
||||
}
|
||||
|
||||
// ★★ 同上的 provider 侧:明文不得到达 LLM。
|
||||
func TestPremise_PluginReturnedPlaintextNeverReachesLLM(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
if _, err := store.Submit(tok, "测"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 用已有的 captureProvider 看它实际收到什么
|
||||
cp := &captureProvider{}
|
||||
a := New(AgentConfig{
|
||||
ID: "leakback2",
|
||||
Provider: cp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
if err := a.stageHost.RegisterTool("leaky2", sdk.ToolDef{
|
||||
Name: "leaky2", Description: "故意回吐明文",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "凭据是 " + tok, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
a.io.InjectTextSync("http", "查一下")
|
||||
|
||||
// provider 收到的**全部**消息里都不能有明文
|
||||
cp.mu.Lock()
|
||||
msgs := append([]agentAPI.Message(nil), cp.messages...)
|
||||
cp.mu.Unlock()
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m.Content, tok) {
|
||||
t.Errorf("★ 明文到达了 provider(LLM 能看到): %q", m.Content)
|
||||
}
|
||||
for _, tr := range m.ToolCalls {
|
||||
if strings.Contains(fmt.Sprint(tr.Arguments), tok) {
|
||||
t.Errorf("★ 工具参数里的明文到达 provider: %v", tr.Arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
t.Fatal("provider 一条消息都没收到 —— 判据无效")
|
||||
}
|
||||
t.Logf("✅ provider 侧未见明文(看了 %d 条消息)", len(msgs))
|
||||
_ = context.Background
|
||||
}
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
|
||||
)
|
||||
|
||||
// 媒体与记忆块的生命周期辅助。
|
||||
@ -64,6 +65,7 @@ func (a *Agent) reembedStaleMedia() {
|
||||
log.Printf("[media] 启动向量迁移: %d 个图像块 → fp=%s dim=%d workers=%d",
|
||||
len(targets), shortFP(fp), a.multimodalSpace.Dim(), workers)
|
||||
|
||||
var jobsOnce sync.Once
|
||||
jobs := make(chan memory.MemoryBlock, workers*2)
|
||||
var done, failed int64
|
||||
var wg sync.WaitGroup
|
||||
@ -89,7 +91,7 @@ func (a *Agent) reembedStaleMedia() {
|
||||
i+1, len(targets), atomic.LoadInt64(&done), failed)
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
safeclose.Safe(&jobsOnce, jobs)
|
||||
wg.Wait()
|
||||
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 总计=%d fp=%s",
|
||||
done, failed, len(targets), shortFP(fp))
|
||||
|
||||
158
internal/agent/core/memory_scrub.go
Normal file
158
internal/agent/core/memory_scrub.go
Normal file
@ -0,0 +1,158 @@
|
||||
package core
|
||||
|
||||
// 持久化侧路的明文拦截(「记忆防火墙」)。
|
||||
//
|
||||
// ## 为什么必须单独拦(而不是只靠 provider 装饰器)
|
||||
//
|
||||
// 出向拦截(`api/provider.go` 的装饰器)只改 `f.Msgs` —— 那是**要发出去的
|
||||
// 那一份**。但内核把同一批内容**另存副本**送往持久化存储,那些副本
|
||||
// 不经过 provider:
|
||||
//
|
||||
// ContextEvent.Input ← f.CleanInput (用户输入原文)
|
||||
// ContextEvent.Response ← f.Response (模型回复)
|
||||
// ContextEvent.ToolResults ← f.ToolResults (工具输出)
|
||||
// emitMemoryCandidate.payload.tool_results ← 同上,走 EmitOutput 不经 context
|
||||
//
|
||||
// 实测确认(变异实验 C6-M1 / C6-M2 / 输入侧探针):
|
||||
// 只拦 `f.Msgs` 时,明文会完整地写进 `context.json` **与长期记忆**,
|
||||
// 而后者跨会话保留、每轮被召回 —— 泄漏面比"重放一次"大得多。
|
||||
//
|
||||
// ## 为什么放在 Append/InsertByTimestamp 而不是各调用点
|
||||
//
|
||||
// 写 context 的地方有 5 处(task.go 3 处、toolcall.go 1 处,以及插入变体)。
|
||||
// 逐个补必然漏 —— 本文件的前身就是这么漏掉 `f.CleanInput` 的。
|
||||
// `Append` / `InsertByTimestamp` 是全部写入的唯一咽喉,拦这里一处即全覆盖。
|
||||
//
|
||||
// `emitMemoryCandidate` 不经 context(走 `EmitOutput`),所以单独处理。
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
// scrubEventSecrets 就地清洗一条待持久化事件里的明文。
|
||||
//
|
||||
// 失败策略:Store 未装配(未启用安全模块)时**原样返回**,零成本。
|
||||
func scrubEventSecrets(evt *ContextEvent) {
|
||||
if evt == nil {
|
||||
return
|
||||
}
|
||||
s := security.Global()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if out, evts := s.Replace(evt.Input, "ctx_input", security.Patterns); len(evts) > 0 {
|
||||
evt.Input = out
|
||||
logEvtHits("上下文输入", evts)
|
||||
}
|
||||
if out, evts := s.Replace(evt.Response, "ctx_response", security.Patterns); len(evts) > 0 {
|
||||
evt.Response = out
|
||||
logEvtHits("上下文回复", evts)
|
||||
}
|
||||
for i := range evt.ToolResults {
|
||||
if evt.ToolResults[i].Output == "" {
|
||||
continue
|
||||
}
|
||||
if out, evts := s.Replace(evt.ToolResults[i].Output,
|
||||
"ctx_tool:"+evt.ToolResults[i].Name, security.Patterns); len(evts) > 0 {
|
||||
evt.ToolResults[i].Output = out
|
||||
logEvtHits("上下文工具结果 "+evt.ToolResults[i].Name, evts)
|
||||
}
|
||||
}
|
||||
// 一等记忆块:**文本**块同样可能带凭据(截图描述、文档正文、转写稿)。
|
||||
// 媒体块(image/video/audio 的 Data)不处理 —— 那是二进制负载,
|
||||
// 正则扫它既无意义也会损坏数据。
|
||||
for i := range evt.Blocks {
|
||||
if evt.Blocks[i].Text == "" {
|
||||
continue
|
||||
}
|
||||
if out, evts := s.Replace(evt.Blocks[i].Text, "ctx_block", security.Patterns); len(evts) > 0 {
|
||||
evt.Blocks[i].Text = out
|
||||
logEvtHits("上下文记忆块", evts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scrubMemoryCandidate 清洗记忆候选载荷(不经 context 的那条侧路)。
|
||||
//
|
||||
// 就地改写 map:调用方传进来的是一次性的快照,改它没有副作用。
|
||||
func scrubMemoryCandidate(payload map[string]interface{}) {
|
||||
if payload == nil {
|
||||
return
|
||||
}
|
||||
s := security.Global()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"input", "response"} {
|
||||
str, ok := payload[key].(string)
|
||||
if !ok || str == "" {
|
||||
continue
|
||||
}
|
||||
if out, evts := s.Replace(str, "memory:"+key, security.Patterns); len(evts) > 0 {
|
||||
payload[key] = out
|
||||
logEvtHits("记忆候选 "+key, evts)
|
||||
}
|
||||
}
|
||||
switch trs := payload["tool_results"].(type) {
|
||||
case []ToolResultItem:
|
||||
for i := range trs {
|
||||
if trs[i].Output == "" {
|
||||
continue
|
||||
}
|
||||
if out, evts := s.Replace(trs[i].Output,
|
||||
"memory:tool:"+trs[i].Name, security.Patterns); len(evts) > 0 {
|
||||
trs[i].Output = out
|
||||
logEvtHits("记忆候选工具结果 "+trs[i].Name, evts)
|
||||
}
|
||||
}
|
||||
payload["tool_results"] = trs
|
||||
case []interface{}:
|
||||
// 有的调用方传的是未类型化的切片(如经 JSON 中转)。
|
||||
for i := range trs {
|
||||
m, ok := trs[i].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out, ok := m["output"].(string)
|
||||
if !ok || out == "" {
|
||||
continue
|
||||
}
|
||||
if scrubbed, evts := s.Replace(out, "memory:tool", security.Patterns); len(evts) > 0 {
|
||||
m["output"] = scrubbed
|
||||
logEvtHits("记忆候选工具结果", evts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logEvtHits(where string, evts []security.Evt) {
|
||||
for _, e := range evts {
|
||||
log.Printf("[security] %s 拦截 %s(模式 %s,%d 字节,合并=%v)",
|
||||
where, e.Alias, e.Kind, e.Len, e.Merged)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 两处拦截的关系(重要,别删任一处) ----------------
|
||||
//
|
||||
// 实测确认:`Append` 里的 `scrubEventSecrets` 会**连带改掉** `f.ToolResults` ——
|
||||
// 因为 `turnEvt.ToolResults = f.ToolResults` 是值切片,共享底层数组,
|
||||
// 而 scrub 对 `evt.ToolResults[i].Output` 是就地赋值。
|
||||
//
|
||||
// 于是 `emitMemoryCandidate` 收到时往往已是别名 ⇒ 单独去掉
|
||||
// `scrubMemoryCandidate` 的变异实验**不会报红**(已验证)。
|
||||
//
|
||||
// ## 那为什么还要保留 scrubMemoryCandidate
|
||||
//
|
||||
// 因为「隐式依赖别名」是**脆弱**的:只要有人
|
||||
// · 把 `turnEvt.ToolResults` 改成深拷贝(很自然的"防御性拷贝"改动),
|
||||
// · 或调整 Append 与 emitMemoryCandidate 的先后顺序,
|
||||
// · 或新增一条不经过 Append 的写入路径,
|
||||
// 记忆侧就会静默恢复泄漏,而没有任何判据会报红。
|
||||
//
|
||||
// `scrubMemoryCandidate` 是**显式契约**:无论上游怎么排版,记忆候选本身
|
||||
// 必须自己清洗。幂等(已是别名的不会再命中),代价只是一次正则扫描。
|
||||
//
|
||||
// 同理 `Append` 与 `InsertByTimestamp` 两处都要调 `scrubEventSecrets`:
|
||||
// 它们是两个独立入口,不能只保一处。
|
||||
@ -2,11 +2,13 @@ package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
||||
@ -73,6 +75,20 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
||||
args["payload"] = stageCtx.FinalText
|
||||
|
||||
if dev := a.io.GetDevice(channel); dev != nil {
|
||||
// ★ 安全模块入向回填:把 @{别名} 换回明文。
|
||||
//
|
||||
// 位置在**插件 stage 之后、设备 Execute 之前**是有讲究的:
|
||||
// · 放在 stage 之前 ⇒ 插件会看到明文(不可,插件在信任边界外)
|
||||
// · 放在 Execute 之后 ⇒ 设备已收到带别名的载荷(功能坏了)
|
||||
// 所以只能夹在中间:插件看不到,设备拿到真的。
|
||||
//
|
||||
// 注意单独处理 payload:args 里的其它字段(channel 等)不需要回填。
|
||||
if s := security.Global(); s != nil {
|
||||
if filled, n, used := s.Fill(args["payload"].(string)); n > 0 {
|
||||
args["payload"] = filled
|
||||
log.Printf("[security] 通道 %s 载荷回填 %d 处(别名 %v)", channel, n, used)
|
||||
}
|
||||
}
|
||||
result, err := dev.Execute("output", args)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err)
|
||||
|
||||
@ -232,7 +232,7 @@ func (a *Agent) raiseContextOverflow(pruned int) {
|
||||
// overflowNotice 构造超页中断消息。
|
||||
//
|
||||
// 抽成纯函数是为了可测:这条消息的措辞不是装饰,它是 agent 唯一的认知输入
|
||||
//(L4 任务按调度器设计拿不到被打断者的上下文)。「查不到不等于不存在」这句
|
||||
// (L4 任务按调度器设计拿不到被打断者的上下文)。「查不到不等于不存在」这句
|
||||
// 直接对应 v4 实测的一类真实失败 —— HA 对检索不到的事实回答
|
||||
// 「库里根本没有 X,任何数字都是编的」,因为它不知道有内容被移走了。
|
||||
func overflowNotice(pruned int) string {
|
||||
@ -267,4 +267,4 @@ func (a *Agent) contextLenSafe() int {
|
||||
return a.context.Len()
|
||||
}
|
||||
|
||||
// overflowStat 字段定义在 Agent 上(见 agent.go),此处只做访问辅助。
|
||||
// overflowStat 字段定义在 Agent 上(见 agent.go),此处只做访问辅助。
|
||||
|
||||
@ -221,6 +221,7 @@ func TestOverflow_并发安全(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ── T5(变异项):帧内恢复预算耗尽 ⇒ 终止而非死循环 ──
|
||||
//
|
||||
// 为什么要单独测:这个防护一度是**死代码**(TaskFrame.OverflowRecover 声明了
|
||||
@ -309,8 +310,8 @@ func TestOverflow_T9_topK随窗口变化(t *testing.T) {
|
||||
return newOverflowAgent(window, 30, 20, 400) // maxContextSize=30(默认)
|
||||
}
|
||||
|
||||
small := build(10_000) // 小窗口
|
||||
large := build(1_000_000) // 大窗口(用户关心的 1M 场景)
|
||||
small := build(10_000) // 小窗口
|
||||
large := build(1_000_000) // 大窗口(用户关心的 1M 场景)
|
||||
|
||||
topKSmall := small.contextTopK()
|
||||
topKLarge := large.contextTopK()
|
||||
|
||||
221
internal/agent/core/persist_leak_test.go
Normal file
221
internal/agent/core/persist_leak_test.go
Normal file
@ -0,0 +1,221 @@
|
||||
package core
|
||||
|
||||
// 判据:**持久化侧路的明文拦截**(文档 C5/C6)。
|
||||
//
|
||||
// ## 背景:出向拦截挡不住持久化
|
||||
//
|
||||
// provider 装饰器只改 `f.Msgs`(要发出去的那一份)。但内核把同样的内容
|
||||
// **另存副本**送往持久化存储,它们不经过 provider:
|
||||
//
|
||||
// ContextEvent.Input ← f.CleanInput 用户输入原文
|
||||
// ContextEvent.Response ← f.Response 模型回复
|
||||
// ContextEvent.ToolResults ← f.ToolResults 工具输出
|
||||
// memory_candidate.payload 走 EmitOutput,不经 context
|
||||
//
|
||||
// 实测确认(本文件的判据原本都是探针,先量出泄漏再修):
|
||||
// 只拦 f.Msgs 时明文完整写进 context.json **与长期记忆** —— 而记忆
|
||||
// 跨会话保留、每轮被召回,泄漏面比重放一次大得多。
|
||||
//
|
||||
// ## 为什么拦在 Append/InsertByTimestamp 而不是各调用点
|
||||
//
|
||||
// 写 context 有 5 处;逐个补必漏(本文件的前身就漏掉了 f.CleanInput)。
|
||||
// 那两处是唯一咽喉,拦一处即全覆盖。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// ★★ 判据:**用户输入**里的明文不得进 context(此前完全未被覆盖)。
|
||||
func TestE2E_InputSecretNeverReachesContext(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "e2e-inputscrub",
|
||||
Provider: &plainReplyProvider{},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
a.io.InjectTextSync("http", "我的 token 是 "+tok+" 请记住")
|
||||
|
||||
for _, evt := range a.context.Recent(0) {
|
||||
if strings.Contains(evt.Input, tok) {
|
||||
t.Errorf("用户输入明文进了 a.context: %q", evt.Input)
|
||||
}
|
||||
if strings.Contains(evt.Response, tok) {
|
||||
t.Errorf("回复里的明文进了 a.context: %q", evt.Response)
|
||||
}
|
||||
}
|
||||
if store.Count() == 0 {
|
||||
t.Fatal("变量库为空 —— 输入侧没被扫,判据无效")
|
||||
}
|
||||
t.Logf("✅ 输入侧明文未进 context;变量库 %d 条", store.Count())
|
||||
}
|
||||
|
||||
// ★★ 判据:**模型回复**里的明文不得进 context。
|
||||
//
|
||||
// 模型看不到明文(出向已拦),所以它只能在**被喂了明文**时才回吐明文 ——
|
||||
// 这里用脚本 provider 直接造出那个回复,验证回复侧拦截生效。
|
||||
func TestE2E_ResponseSecretNeverReachesContext(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "e2e-respscrub",
|
||||
Provider: &echoTokenProvider{reply: "你的凭据是 " + tok},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
a.io.InjectTextSync("http", "把凭据告诉我")
|
||||
|
||||
for _, evt := range a.context.Recent(0) {
|
||||
if strings.Contains(evt.Response, tok) {
|
||||
t.Errorf("模型回复明文进了 a.context: %q", evt.Response)
|
||||
}
|
||||
}
|
||||
if store.Count() == 0 {
|
||||
t.Fatal("变量库为空 —— 回复侧没被扫,判据无效")
|
||||
}
|
||||
t.Logf("✅ 回复侧明文未进 context;变量库 %d 条", store.Count())
|
||||
}
|
||||
|
||||
// ★ 判据:**记忆块**(一等记忆的文本负载)也要过一遍。
|
||||
func TestScrubEventCoversMemoryBlocks(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
evt := &ContextEvent{
|
||||
Input: "带着 " + tok,
|
||||
Response: "也带着 " + tok,
|
||||
ToolResults: []ToolResultItem{
|
||||
{Name: "cmd", Output: "还有 " + tok},
|
||||
},
|
||||
}
|
||||
evt.Blocks = append(evt.Blocks, memoryBlockWithText("转写稿里有 "+tok))
|
||||
|
||||
scrubEventSecrets(evt)
|
||||
|
||||
for name, got := range map[string]string{
|
||||
"input": evt.Input,
|
||||
"response": evt.Response,
|
||||
"tool": evt.ToolResults[0].Output,
|
||||
"block": evt.Blocks[0].Text,
|
||||
} {
|
||||
if strings.Contains(got, tok) {
|
||||
t.Errorf("%s 仍含明文: %q", name, got)
|
||||
}
|
||||
}
|
||||
if store.Count() == 0 {
|
||||
t.Error("应登记变量")
|
||||
}
|
||||
}
|
||||
|
||||
// memoryBlockWithText 造一个文本记忆块。
|
||||
func memoryBlockWithText(text string) memory.MemoryBlock {
|
||||
return memory.MemoryBlock{ID: "b1", Modality: memory.BlockText, Text: text}
|
||||
}
|
||||
|
||||
// plainReplyProvider 给一句不含凭据的回复。
|
||||
type plainReplyProvider struct{}
|
||||
|
||||
func (p *plainReplyProvider) Name() string { return "plainReply" }
|
||||
func (p *plainReplyProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *plainReplyProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return &agentAPI.CompletionResponse{Content: "好的,记住了"}, nil
|
||||
}
|
||||
func (p *plainReplyProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
ch := make(chan agentAPI.StreamChunk, 1)
|
||||
ch <- agentAPI.StreamChunk{Content: "好的,记住了", Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// echoTokenProvider 回吐一段固定文本(用于造出"回复含明文"的场景)。
|
||||
type echoTokenProvider struct{ reply string }
|
||||
|
||||
func (p *echoTokenProvider) Name() string { return "echoToken" }
|
||||
func (p *echoTokenProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *echoTokenProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return &agentAPI.CompletionResponse{Content: p.reply}, nil
|
||||
}
|
||||
func (p *echoTokenProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
ch := make(chan agentAPI.StreamChunk, 1)
|
||||
ch <- agentAPI.StreamChunk{Content: p.reply, Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// ★★ 判据:`emitMemoryCandidate` **自己**清洗载荷,不依赖 Append 的隐含行为。
|
||||
//
|
||||
// 背景(实测):`Append` 的 scrub 经共享底层数组会连带改掉 `f.ToolResults`,
|
||||
// 所以"去掉 scrubMemoryCandidate"的变异**不会**被走 a.context 的端到端判据发现。
|
||||
// 那意味着记忆侧防护是隐式依赖别名的 —— 一旦有人给 turnEvt.ToolResults
|
||||
// 加个深拷贝(很自然的"防御性拷贝"),记忆侧就静默泄漏。
|
||||
//
|
||||
// ❗本判据必须走 `a.emitMemoryCandidate` 这个**真实入口**,而不是直接调
|
||||
//
|
||||
// `scrubMemoryCandidate` —— 初版直接调底层函数,于是"去掉入口处的调用"
|
||||
// 这个变异**不会报红**(判据测错了层次)。走真实入口才能钉住"入口必须调它"。
|
||||
func TestEmitMemoryCandidateScrubsPayload(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "e2e-memcand",
|
||||
Provider: &plainReplyProvider{},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
|
||||
// 故意传**未经过任何上游清洗**的原始值
|
||||
a.emitMemoryCandidate("http", "输入 "+tok, "回复 "+tok,
|
||||
[]ToolResultItem{{Name: "cmd", Output: "输出 " + tok}}, []string{"cmd"})
|
||||
|
||||
var seen map[string]interface{}
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case evt := <-a.io.OutputChan():
|
||||
if evt != nil && evt.Type == "memory_candidate" {
|
||||
seen = evt.Payload
|
||||
}
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
if seen == nil {
|
||||
t.Fatal("没有观察到 memory_candidate 输出")
|
||||
}
|
||||
if s, _ := seen["input"].(string); strings.Contains(s, tok) {
|
||||
t.Errorf("记忆候选 input 含明文: %q", s)
|
||||
}
|
||||
if s, _ := seen["response"].(string); strings.Contains(s, tok) {
|
||||
t.Errorf("记忆候选 response 含明文: %q", s)
|
||||
}
|
||||
if trs, ok := seen["tool_results"].([]ToolResultItem); ok {
|
||||
for _, tr := range trs {
|
||||
if strings.Contains(tr.Output, tok) {
|
||||
t.Errorf("记忆候选 tool_results 含明文: %q", tr.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
if store.Count() == 0 {
|
||||
t.Error("应登记变量")
|
||||
}
|
||||
}
|
||||
@ -80,6 +80,8 @@ var builtinToolOwner = map[string]string{
|
||||
"llm_list_sources": "core", "llm_set_source": "core", "llm_current_source": "core",
|
||||
"plgreload": "core", "get_plugin_tools": "core", "list_devices": "core",
|
||||
"devicedetect": "core", "device_ctl_status": "core", "device_ctl_cmdrun": "core",
|
||||
// 安全模块的变量库(内核能力,不是插件)
|
||||
"secret_submit": "core", "secret_list": "core", "secret_forget": "core",
|
||||
"device_ctl_cmdresult": "core", "screensee": "core",
|
||||
}
|
||||
|
||||
|
||||
154
internal/agent/core/secret_tools.go
Normal file
154
internal/agent/core/secret_tools.go
Normal file
@ -0,0 +1,154 @@
|
||||
package core
|
||||
|
||||
// 安全模块的**面向 agent 的工具族**(文档 C8)。
|
||||
//
|
||||
// ## 为什么需要这几个工具
|
||||
//
|
||||
// 用户拍板的模型是:
|
||||
//
|
||||
// 变量 = 内核全局库;**不自动淘汰**,但 agent 可以**主动提交/删除**。
|
||||
//
|
||||
// 「不自动淘汰」意味着泄漏控制不能靠超时,只能靠 agent(或人)主动删。
|
||||
// 那就必须有工具能做这两件事 —— 否则「可主动删除」只是文档里的一句空话。
|
||||
//
|
||||
// ## ★ 设计红线:**永不返回明文**
|
||||
//
|
||||
// 这几个工具**只能引用别名,不能读回明文**(设计文档 S1)。一旦提供
|
||||
// 「取回明文」的接口,模型就能把明文写回上下文,整层防护作废 ——
|
||||
// 因为它会把明文交给 LLM,而那正是要避免的。
|
||||
//
|
||||
// 所以:
|
||||
// · `secret_submit` 接受明文(这是唯一入口,且必须如此才能登记)
|
||||
// · `secret_list` 只给元信息(别名、来源、命中次数)
|
||||
// · `secret_forget` 按别名删除
|
||||
// · **没有** `secret_get`
|
||||
//
|
||||
// ## 关于 secret_submit 的明文暴露面(文档 T10)
|
||||
//
|
||||
// 提交时明文会经过 context(模型输出的参数)。这是**已知且可接受**的:
|
||||
// 提交的目的是「让内核记住这个凭据」,而模型本来就是从这里知道的。
|
||||
// 真正要防的是「凭据被送到 LLM 之外或写进持久化」—— 那由
|
||||
// memory_scrub 与 provider 装饰器覆盖。
|
||||
//
|
||||
// 更安全的替代路径(`${ENV}` / `@file:`)留给后续(T10),
|
||||
// 当前先提供最直接的形态。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
func (a *Agent) executeSecretTool(tc agentAPI.ToolCall) string {
|
||||
s := security.Global()
|
||||
if s == nil {
|
||||
return "安全模块未启用(core.security.enabled=false),变量库不可用"
|
||||
}
|
||||
switch tc.Name {
|
||||
case "secret_submit":
|
||||
return a.execSecretSubmit(s, tc)
|
||||
case "secret_list":
|
||||
return a.execSecretList(s, tc)
|
||||
case "secret_forget":
|
||||
return a.execSecretForget(s, tc)
|
||||
}
|
||||
return "未知的 secret 工具: " + tc.Name
|
||||
}
|
||||
|
||||
// execSecretSubmit 主动登记一条凭据,返回其别名。
|
||||
//
|
||||
// 用途:agent 知道后面要用某个凭据(如刚读到的 token),
|
||||
// 先登记它,后续在工具参数里用 @{别名} 引用。
|
||||
func (a *Agent) execSecretSubmit(s *security.Store, tc agentAPI.ToolCall) string {
|
||||
value, _ := tc.Arguments["value"].(string)
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "value 不能为空"
|
||||
}
|
||||
note, _ := tc.Arguments["note"].(string)
|
||||
alias, err := s.Submit(value, note)
|
||||
if err != nil {
|
||||
// 无密钥等情况:**响亮失败**,不静默存明文。
|
||||
return fmt.Sprintf("登记失败(未以明文降级存储): %v", err)
|
||||
}
|
||||
// ★ 只回别名,绝不回明文 —— 否则这个工具就成了"明文读回接口"。
|
||||
return fmt.Sprintf("已登记为变量 @{%s}。后续调用请用 @{%s} 引用,不要直接写明文。", alias, alias)
|
||||
}
|
||||
|
||||
// execSecretList 列出变量元信息(**不含明文**)。
|
||||
func (a *Agent) execSecretList(s *security.Store, tc agentAPI.ToolCall) string {
|
||||
infos := s.List()
|
||||
if len(infos) == 0 {
|
||||
return "变量库为空。用 secret_submit 登记凭据。"
|
||||
}
|
||||
// 稳定排序:便于人/模型对照(map 遍历顺序是随机的)。
|
||||
sort.Slice(infos, func(i, j int) bool { return infos[i].Alias < infos[j].Alias })
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "共 %d 个变量(只显示元信息,明文不可读回):\n", len(infos))
|
||||
for _, in := range infos {
|
||||
fmt.Fprintf(&b, " @{%s}", in.Alias)
|
||||
if in.Note != "" {
|
||||
fmt.Fprintf(&b, " %s", in.Note)
|
||||
}
|
||||
fmt.Fprintf(&b, " [来源 %s, 命中 %d]", in.Source, in.Hits)
|
||||
if in.CreatedAt != "" {
|
||||
fmt.Fprintf(&b, " 建于 %s", in.CreatedAt)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString("\n在工具参数里用 @{别名} 引用即可(内核会换回真实值,插件无感知)。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// execSecretForget 按别名删除一个变量。
|
||||
func (a *Agent) execSecretForget(s *security.Store, tc agentAPI.ToolCall) string {
|
||||
alias, _ := tc.Arguments["alias"].(string)
|
||||
alias = strings.TrimSpace(alias)
|
||||
// 宽容一点:模型可能把 @{sec_xxx} 整个传进来
|
||||
alias = strings.TrimPrefix(alias, "@{")
|
||||
alias = strings.TrimSuffix(alias, "}")
|
||||
if alias == "" {
|
||||
return "alias 不能为空"
|
||||
}
|
||||
if err := s.Forget(alias); err != nil {
|
||||
if err == security.ErrUnknownAlias {
|
||||
return fmt.Sprintf("未找到变量 %s(用 secret_list 查看现有变量)", alias)
|
||||
}
|
||||
return fmt.Sprintf("删除失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("已删除 %s。该别名不再可用。", alias)
|
||||
}
|
||||
|
||||
// secretToolDefs 返回这几个工具的定义(供 buildToolDefs 注入)。
|
||||
//
|
||||
// 直接用 toolDef 构造,与其它内置工具同形 —— 免得两处各写一份 schema。
|
||||
func secretToolDefs() []interface{} {
|
||||
return []interface{}{
|
||||
toolDef("secret_submit",
|
||||
"登记一条凭据(token/密钥/口令)到内核变量库,返回别名。之后在工具参数里用 @{别名} 引用它 —— 内核会在调用插件前把别名换回真实值,插件完全无感知。用于:你刚读到一个凭据、后面要用它,但不想让明文反复出现在对话里。注意:明文只应在本次登记时出现一次。",
|
||||
map[string]interface{}{
|
||||
"value": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "凭据原文(只需在这里出现一次)",
|
||||
},
|
||||
"note": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选:给人看的用途说明(如「Gitea 推送令牌」)",
|
||||
},
|
||||
}, "value"),
|
||||
toolDef("secret_list",
|
||||
"列出内核变量库里的全部变量(只给别名与元信息,**不含明文** —— 明文无法读回,这是刻意设计)。用于查看有哪些可用变量、某变量是否已登记。",
|
||||
map[string]interface{}{}),
|
||||
toolDef("secret_forget",
|
||||
"删除一个变量(按别名)。变量库**不会自动淘汰** —— 泄漏控制靠人/agent 主动删,所以凭据用完后应主动删除。",
|
||||
map[string]interface{}{
|
||||
"alias": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要删除的别名(sec_xxx 或 @{sec_xxx} 均可)",
|
||||
},
|
||||
}, "alias"),
|
||||
}
|
||||
}
|
||||
191
internal/agent/core/secret_tools_test.go
Normal file
191
internal/agent/core/secret_tools_test.go
Normal file
@ -0,0 +1,191 @@
|
||||
package core
|
||||
|
||||
// 判据:agent 面向的变量库工具(文档 C8)。
|
||||
//
|
||||
// 设计红线(文档 S1):**永不返回明文**。
|
||||
// 一旦有"取回明文"的路径,模型就能把明文写回上下文,整层防护作废。
|
||||
// 下面的判据把这条红线钉死。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
const c8Token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
func newSecretToolAgent(t *testing.T) (*Agent, *security.Store) {
|
||||
t.Helper()
|
||||
store := newScrubStore(t) // 复用已有的测试夹具
|
||||
a := New(AgentConfig{
|
||||
ID: "c8",
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
return a, store
|
||||
}
|
||||
|
||||
func callSecret(a *Agent, name string, args map[string]interface{}) string {
|
||||
return a.executeSecretTool(agentAPI.ToolCall{Name: name, Arguments: args})
|
||||
}
|
||||
|
||||
// ★★ 判据:secret_submit 登记后**只返回别名,不回显明文**。
|
||||
func TestSecretSubmitReturnsAliasNotPlaintext(t *testing.T) {
|
||||
a, store := newSecretToolAgent(t)
|
||||
|
||||
out := callSecret(a, "secret_submit", map[string]interface{}{
|
||||
"value": c8Token, "note": "测试令牌",
|
||||
})
|
||||
if strings.Contains(out, c8Token) {
|
||||
t.Fatalf("★ 违反 S1:secret_submit 回显了明文: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "@{sec_") {
|
||||
t.Fatalf("应返回别名: %q", out)
|
||||
}
|
||||
if store.Count() != 1 {
|
||||
t.Fatalf("应登记 1 条,实际 %d", store.Count())
|
||||
}
|
||||
// 别名可用:回填能换回明文
|
||||
alias := store.List()[0].Alias
|
||||
if got, _, _ := store.Fill("@" + "{" + alias + "}"); !strings.Contains(got, c8Token) {
|
||||
t.Error("别名应能回填出明文(这是唯一通道)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**secret_list 绝不泄露明文**(S1 的核心)。
|
||||
func TestSecretListNeverLeaksPlaintext(t *testing.T) {
|
||||
a, _ := newSecretToolAgent(t)
|
||||
callSecret(a, "secret_submit", map[string]interface{}{"value": c8Token, "note": "n"})
|
||||
|
||||
out := callSecret(a, "secret_list", nil)
|
||||
if strings.Contains(out, c8Token) {
|
||||
t.Fatalf("★ 违反 S1:secret_list 泄露了明文: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "@{sec_") {
|
||||
t.Errorf("应列出别名: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "明文不可读回") {
|
||||
t.Error("应明确告知明文不可读回(防模型反复尝试)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:**不存在 secret_get**(防回归:有人加了它,这条会指出为什么不行)。
|
||||
func TestNoSecretGetTool(t *testing.T) {
|
||||
a, _ := newSecretToolAgent(t)
|
||||
callSecret(a, "secret_submit", map[string]interface{}{"value": c8Token})
|
||||
|
||||
out := callSecret(a, "secret_get", map[string]interface{}{"alias": "sec_x"})
|
||||
if !strings.Contains(out, "未知的 secret 工具") {
|
||||
t.Fatalf("不应存在 secret_get(会把明文交回模型): %q", out)
|
||||
}
|
||||
// 工具表里也不能有
|
||||
for _, d := range secretToolDefs() {
|
||||
m, _ := d.(map[string]interface{})
|
||||
name, _ := m["name"].(string)
|
||||
if name == "secret_get" || name == "secret_show" || name == "secret_reveal" {
|
||||
t.Fatalf("工具表里出现了明文读回工具 %q —— 违反 S1", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:secret_forget 删除后别名失效,且**不自动淘汰**。
|
||||
func TestSecretForgetRemovesAndNoAutoEviction(t *testing.T) {
|
||||
a, store := newSecretToolAgent(t)
|
||||
callSecret(a, "secret_submit", map[string]interface{}{"value": c8Token})
|
||||
alias := store.List()[0].Alias
|
||||
|
||||
// 宽容传参:@{sec_xxx} 与裸别名都可
|
||||
if out := callSecret(a, "secret_forget", map[string]interface{}{
|
||||
"alias": "@{" + alias + "}",
|
||||
}); !strings.Contains(out, "已删除") {
|
||||
t.Fatalf("删除应成功: %q", out)
|
||||
}
|
||||
if store.Count() != 0 {
|
||||
t.Fatalf("删除后应为空,实际 %d", store.Count())
|
||||
}
|
||||
if got, n, _ := store.Fill("@" + "{" + alias + "}"); n != 0 || strings.Contains(got, c8Token) {
|
||||
t.Error("删除后别名不应再回填")
|
||||
}
|
||||
// 重复删 → 明确提示(不是静默成功)
|
||||
if out := callSecret(a, "secret_forget", map[string]interface{}{"alias": alias}); !strings.Contains(out, "未找到") {
|
||||
t.Errorf("重复删除应明确提示: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:空值提交被拒(不是静默登记一个空变量)。
|
||||
func TestSecretSubmitRejectsEmpty(t *testing.T) {
|
||||
a, store := newSecretToolAgent(t)
|
||||
for _, v := range []string{"", " "} {
|
||||
if out := callSecret(a, "secret_submit", map[string]interface{}{"value": v}); !strings.Contains(out, "不能为空") {
|
||||
t.Errorf("空值应被拒: %q", out)
|
||||
}
|
||||
}
|
||||
if store.Count() != 0 {
|
||||
t.Error("空值不应入库")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:安全模块未启用时,工具明确报不可用(不静默失败)。
|
||||
func TestSecretToolsReportUnavailable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = dir
|
||||
security.SetGlobal(nil)
|
||||
defer newScrubStore(t) // 复原给后续判据
|
||||
|
||||
a := New(AgentConfig{ID: "c8-off", StageHost: NewStageHost()})
|
||||
out := callSecret(a, "secret_list", nil)
|
||||
if !strings.Contains(out, "未启用") {
|
||||
t.Fatalf("未启用时应明确报不可用: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:未启用时**不向模型暴露**这些工具(避免无谓往返)。
|
||||
func TestSecretToolsHiddenWhenDisabled(t *testing.T) {
|
||||
security.SetGlobal(nil)
|
||||
a := New(AgentConfig{ID: "c8-hidden", StageHost: NewStageHost()})
|
||||
|
||||
var names []string
|
||||
for _, d := range a.buildToolDefs() {
|
||||
m, ok := d.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fn, _ := m["function"].(map[string]interface{})
|
||||
if fn == nil {
|
||||
fn = m
|
||||
}
|
||||
if n, _ := fn["name"].(string); strings.HasPrefix(n, "secret_") {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
t.Fatalf("未启用安全模块时不应暴露 secret_* 工具: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:启用时**要暴露**(否则模型不知道能用)。
|
||||
func TestSecretToolsExposedWhenEnabled(t *testing.T) {
|
||||
newScrubStore(t)
|
||||
a := New(AgentConfig{ID: "c8-shown", StageHost: NewStageHost()})
|
||||
|
||||
got := map[string]bool{}
|
||||
for _, d := range a.buildToolDefs() {
|
||||
m, ok := d.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// toolDef 返回的是 {"type":"function","function":{...}}
|
||||
if fn, ok := m["function"].(map[string]interface{}); ok {
|
||||
if n, _ := fn["name"].(string); strings.HasPrefix(n, "secret_") {
|
||||
got[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"secret_submit", "secret_list", "secret_forget"} {
|
||||
if !got[want] {
|
||||
t.Errorf("启用后应暴露 %s;实际得到 %v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
287
internal/agent/core/security_concurrency_test.go
Normal file
287
internal/agent/core/security_concurrency_test.go
Normal file
@ -0,0 +1,287 @@
|
||||
package core
|
||||
|
||||
// 安全模块的**端到端并发压测**。
|
||||
//
|
||||
// ## 为什么必须做
|
||||
//
|
||||
// 安全模块挂在每次 LLM 调用与每次工具调用的必经路径上,而内核是
|
||||
// **并发**的:多 agent、多任务、调度器并行、插件回调各自的 goroutine。
|
||||
//
|
||||
// security 包自己的并发判据只验了 Store 内部;
|
||||
// **Store 的使用方**(provider 装饰器、ExecuteTool、Append、emitMemoryCandidate)
|
||||
// 是否并发安全,只有让真实 Agent 并跑才看得出来。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
// ★★ 判据:**多 agent 并发出向** —— 明文一律不得泄漏到 provider。
|
||||
//
|
||||
// 每个 agent 有自己的 provider 实例,但它们共享**同一个**
|
||||
// ProviderManager 装饰器与变量库单例(这正是设计要验的点)。
|
||||
func TestConcurrency_MultiAgentOutboundScrub(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
const agents = 16
|
||||
const rounds = 40
|
||||
|
||||
var leaked atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for a := 0; a < agents; a++ {
|
||||
wg.Add(1)
|
||||
go func(a int) {
|
||||
defer wg.Done()
|
||||
var captured string
|
||||
mgr := agentAPI.NewProviderManager()
|
||||
mgr.Register("p", &fwCaptureProvider{name: "p", capture: &captured})
|
||||
p := mgr.Default()
|
||||
|
||||
for r := 0; r < rounds; r++ {
|
||||
// 并发登记(同明文 ⇒ 应合并到同一别名)
|
||||
if _, err := store.Submit(tok, "conc"); err != nil {
|
||||
t.Errorf("Submit: %v", err)
|
||||
return
|
||||
}
|
||||
req := &agentAPI.CompletionRequest{Messages: []agentAPI.Message{
|
||||
{Role: "user", Content: fmt.Sprintf("agent%d round%d token=%s", a, r, tok)},
|
||||
}}
|
||||
if _, err := p.Chat(nil, req); err != nil { //nolint:staticcheck
|
||||
t.Errorf("Chat: %v", err)
|
||||
return
|
||||
}
|
||||
if strings.Contains(captured, tok) {
|
||||
leaked.Add(1)
|
||||
}
|
||||
}
|
||||
}(a)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if leaked.Load() > 0 {
|
||||
t.Fatalf("★ 并发下 %d 次出向泄漏明文(装饰器不是并发安全的)", leaked.Load())
|
||||
}
|
||||
// 同明文必须合并成一条
|
||||
if got := store.Count(); got != 1 {
|
||||
t.Errorf("★ 同明文并发登记应合并为 1 条,实际 %d", got)
|
||||
}
|
||||
t.Logf("✅ %d agent × %d 轮并发出向,零泄漏;变量库 1 条", agents, rounds)
|
||||
}
|
||||
|
||||
// ★★ 判据:**并发工具调用** —— 参数回填不串号。
|
||||
//
|
||||
// 真实风险:A 的工具参数里的别名被填成 B 的凭据(串号),
|
||||
// 这会造成"用了错的凭据"且**不报错**。
|
||||
func TestConcurrency_ToolArgsDoNotCrossTalk(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
// 每个 goroutine 用**自己**的凭据与别名
|
||||
const workers = 12
|
||||
aliases := make([]string, workers)
|
||||
plains := make([]string, workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
plains[i] = fmt.Sprintf("distinct-secret-%02d-aaaaaaaaaaaaaaaaaaaa", i)
|
||||
al, err := store.Submit(plains[i], fmt.Sprintf("w%d", i))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aliases[i] = al
|
||||
}
|
||||
|
||||
var mixed atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
var seen string
|
||||
tool := fmt.Sprintf("conc_tool_%d", i)
|
||||
// 每个 goroutine 注册自己的工具(StageHost 的注册是加锁的)
|
||||
if err := a.stageHost.RegisterTool(tool, sdk.ToolDef{
|
||||
Name: tool, Description: "并发压测",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
seen, _ = args["secret"].(string)
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
t.Errorf("RegisterTool: %v", err)
|
||||
return
|
||||
}
|
||||
for r := 0; r < 60; r++ {
|
||||
seen = ""
|
||||
if _, err := a.stageHost.ExecuteTool(tool, map[string]interface{}{
|
||||
"secret": "@{" + aliases[i] + "}",
|
||||
}); err != nil {
|
||||
t.Errorf("ExecuteTool: %v", err)
|
||||
return
|
||||
}
|
||||
// ★ 必须填成**自己的**那个,不能是别人的
|
||||
if seen != plains[i] {
|
||||
mixed.Add(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if mixed.Load() > 0 {
|
||||
t.Fatal("★ 并发工具调用发生**串号**(填成了别的凭据)—— 用错凭据且不报错")
|
||||
}
|
||||
t.Logf("✅ %d goroutine × 60 轮并发工具调用,零串号", workers)
|
||||
}
|
||||
|
||||
// ★★ 判据:**并发上下文写入**(Append 咽喉的 scrub)不丢不串。
|
||||
func TestConcurrency_ConcurrentContextScrub(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
if _, err := store.Submit(tok, "ctx"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
const writers = 10
|
||||
const perWriter = 50
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < writers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perWriter; i++ {
|
||||
a.context.Append(ContextEvent{
|
||||
Source: fmt.Sprintf("w%d", w),
|
||||
Input: fmt.Sprintf("in %d token=%s", i, tok),
|
||||
Response: "out contains " + tok,
|
||||
ToolResults: []ToolResultItem{
|
||||
{Name: "t", Output: "tool " + tok},
|
||||
},
|
||||
})
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 全部事件都不该含明文
|
||||
bad := 0
|
||||
for _, evt := range a.context.Recent(0) {
|
||||
if strings.Contains(evt.Input, tok) || strings.Contains(evt.Response, tok) {
|
||||
bad++
|
||||
}
|
||||
for _, tr := range evt.ToolResults {
|
||||
if strings.Contains(tr.Output, tok) {
|
||||
bad++
|
||||
}
|
||||
}
|
||||
}
|
||||
if bad > 0 {
|
||||
t.Fatalf("★ 并发写入下有 %d 处明文未被拦下", bad)
|
||||
}
|
||||
if n := a.context.Len(); n < writers*perWriter {
|
||||
t.Errorf("并发写入丢事件:期望至少 %d,实际 %d", writers*perWriter, n)
|
||||
}
|
||||
t.Logf("✅ %d goroutine × %d 次并发写入全部拦下(事件数 %d)",
|
||||
writers, perWriter, a.context.Len())
|
||||
}
|
||||
|
||||
// ★★ 判据:**并发记忆候选**(emitMemoryCandidate 的 scrub)。
|
||||
func TestConcurrency_ConcurrentMemoryCandidate(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
if _, err := store.Submit(tok, "mem"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "conc-mem",
|
||||
Provider: &plainReplyProvider{},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
|
||||
const n = 200
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
a.emitMemoryCandidate("src",
|
||||
fmt.Sprintf("in %d %s", i, tok),
|
||||
"resp "+tok,
|
||||
[]ToolResultItem{{Name: "t", Output: "tool " + tok}},
|
||||
[]string{"t"})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 排空输出,逐条检查载荷
|
||||
leaked := 0
|
||||
got := 0
|
||||
for {
|
||||
select {
|
||||
case evt := <-a.io.OutputChan():
|
||||
if evt == nil || evt.Type != "memory_candidate" {
|
||||
continue
|
||||
}
|
||||
got++
|
||||
if s, _ := evt.Payload["input"].(string); strings.Contains(s, tok) {
|
||||
leaked++
|
||||
}
|
||||
if s, _ := evt.Payload["response"].(string); strings.Contains(s, tok) {
|
||||
leaked++
|
||||
}
|
||||
if trs, ok := evt.Payload["tool_results"].([]ToolResultItem); ok {
|
||||
for _, tr := range trs {
|
||||
if strings.Contains(tr.Output, tok) {
|
||||
leaked++
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
goto done
|
||||
}
|
||||
}
|
||||
done:
|
||||
if leaked > 0 {
|
||||
t.Fatalf("★ 并发记忆候选中 %d 处含明文(会写进长期记忆)", leaked)
|
||||
}
|
||||
if got == 0 {
|
||||
t.Fatal("没有观察到记忆候选 —— 判据无效")
|
||||
}
|
||||
t.Logf("✅ %d 条并发记忆候选全部已清洗", got)
|
||||
}
|
||||
|
||||
// ★ 判据:并发守卫(未启用安全模块)时零成本且不 panic。
|
||||
func TestConcurrency_DisabledIsSafe(t *testing.T) {
|
||||
security.SetGlobal(nil)
|
||||
t.Cleanup(func() { newScrubStore(t) })
|
||||
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
a.context.Append(ContextEvent{
|
||||
Source: "s", Input: "in", Response: "out",
|
||||
ToolResults: []ToolResultItem{{Name: "t", Output: "o"}},
|
||||
})
|
||||
a.emitMemoryCandidate("s", "in", "out", nil, nil)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
t.Log("✅ 未启用时并发调用不 panic")
|
||||
}
|
||||
@ -53,13 +53,18 @@ func (a *Agent) publishEvent(evtType events.EventType, payload map[string]interf
|
||||
}
|
||||
|
||||
func (a *Agent) stageCtxFromInput(input, userID, groupID string) *sdk.StageContext {
|
||||
return &sdk.StageContext{
|
||||
ctx := &sdk.StageContext{
|
||||
RawMessage: input,
|
||||
UserID: userID,
|
||||
GroupID: groupID,
|
||||
Phase: sdk.StageOnInput,
|
||||
Extra: make(map[string]interface{}),
|
||||
}
|
||||
// ★ 来源标识必须在**帧的起点**就写入,不能只写在工具帧上:
|
||||
// 插件可能在 on_input 阶段就据此决定是否介入(而那个阶段早于工具帧构造)。
|
||||
// 见 comefrom.go 的完整说明。
|
||||
stampComeFrom(ctx.Extra, a.comefromForAgent())
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (a *Agent) injectSourceContext(stageCtx *sdk.StageContext, evt *agentIO.InputEvent) {
|
||||
|
||||
@ -7,10 +7,15 @@ import (
|
||||
"sync"
|
||||
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
type StageHost struct {
|
||||
// frozenFn 报告内核是否处于伪关机(迁移中)。nil = 永不冻结。
|
||||
// 用函数而不是直接引用 plugin.Registry,避免 agent/core 反向依赖 plugin 包。
|
||||
frozenFn func() bool
|
||||
mu sync.RWMutex
|
||||
toolDefs []sdk.ToolDef
|
||||
tools map[string]sdk.ToolHandler
|
||||
@ -156,6 +161,16 @@ func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (ret i
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("tool %s has nil handler", name)
|
||||
}
|
||||
// ★ 安全模块入向回填:这里是**插件工具调用的唯一入口**,
|
||||
// 填在这里就覆盖了全部插件 —— 插件本身完全无感知。
|
||||
//
|
||||
// 为何必须填而不是拒绝:模型手上只有别名(它从未见过明文),
|
||||
// 而插件需要真凭据。回填是二者之间唯一的通道。
|
||||
if s := security.Global(); s != nil && args != nil {
|
||||
if n, used := s.FillMap(args); n > 0 {
|
||||
log.Printf("[security] 工具 %s 参数回填 %d 处(别名 %v)", name, n, used)
|
||||
}
|
||||
}
|
||||
return handler(args)
|
||||
}
|
||||
|
||||
@ -237,6 +252,7 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, len(entries))
|
||||
var errChOnce sync.Once
|
||||
for _, entry := range entries {
|
||||
wg.Add(1)
|
||||
go func(fn sdk.StageHandler) {
|
||||
@ -252,7 +268,7 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||
}(entry.fn)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
safeclose.Safe(&errChOnce, errCh)
|
||||
|
||||
var errs []string
|
||||
for err := range errCh {
|
||||
|
||||
@ -534,6 +534,8 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
|
||||
ToolResults: f.ToolResults,
|
||||
}
|
||||
a.bindEventMedia(&turnEvt, a.drainMediaBlocks())
|
||||
// ★ 明文拦截发生在 a.context.Append **内部**(见 context.go 的 Append),
|
||||
// 不是在每个调用点 —— 写 context 的地方有 5 处,逐个补必漏。
|
||||
a.context.Append(turnEvt)
|
||||
|
||||
a.emitResponse(evt, f.Response, f.turnUsage)
|
||||
@ -1353,6 +1355,15 @@ func (f *TaskFrame) buildToolContexts() {
|
||||
Extra: copyExtraMap(base.Extra),
|
||||
NoMemory: base.NoMemory,
|
||||
}
|
||||
// ★ **工具调用帧必须带来源标识**(comefrom):
|
||||
// 通道插件的权限门(before_toolcall)靠它区分"这是用户请求"还是
|
||||
// "这是 agent 内部委派"。缺了它,驻留子的工具调用会被按**用户**
|
||||
// 权限模型判定 ⇒ 合法工作全被拦(2026-10-16 现场实证,见 comefrom.go)。
|
||||
//
|
||||
// 从 base 继承(StageCtx 已由 stageCtxFromInput 标过);copyExtraMap
|
||||
// 会把该键一并复制,这里只在因某种路径缺失时补一个**保守值**
|
||||
// (保守 = 按旧语义当作用户请求,宁可多查一次也不再出现"静默越权")。
|
||||
stampComeFrom(f.toolCtxs[i].Extra, comeFromUser)
|
||||
// Phase 由 runStage 每次调用时设置,此处不预置。
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,6 +122,8 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnS
|
||||
return toolOutcome{Text: a.executeChildResultTool(tc)}
|
||||
case strings.HasPrefix(tc.Name, "llm_"):
|
||||
return toolOutcome{Text: a.executeLLMTool(tc)}
|
||||
case strings.HasPrefix(tc.Name, "secret_"):
|
||||
return toolOutcome{Text: a.executeSecretTool(tc)}
|
||||
// ★★ 2026-10-06:describe_image / transcribe_audio / ocr_image 已移出内核,
|
||||
// 改为内置插件 multimodal(见 internal/plugins/multimodal)。
|
||||
// 理由:它们要读本地文件(IO),而内核承诺零 IO。
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
sdkmeta "github.com/JianFeeeee/homeagentsdk/meta"
|
||||
)
|
||||
|
||||
@ -356,6 +357,8 @@ func init() {
|
||||
"get_plugin_tools", "llm_list_sources",
|
||||
// 文档:检索
|
||||
"doc_query",
|
||||
// 安全模块:列举变量(只读,无共享写)
|
||||
"secret_list",
|
||||
} {
|
||||
declareParallelTool(name)
|
||||
}
|
||||
@ -403,6 +406,14 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
}, "mode"))
|
||||
}
|
||||
|
||||
// 安全模块的变量库工具(文档 C8)。
|
||||
//
|
||||
// 仅当安全模块真的启用时才暴露:未启用时变量库不可用,
|
||||
// 把工具给模型只会让它反复撞到"安全模块未启用"(无谓往返)。
|
||||
if security.Global() != nil {
|
||||
tools = append(tools, secretToolDefs()...)
|
||||
}
|
||||
|
||||
if a.memory != nil {
|
||||
tools = append(tools, toolDef("memory_merge", "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。", map[string]interface{}{
|
||||
"source": map[string]interface{}{"type": "string", "description": "被合并的实体名(合并后消失)"},
|
||||
|
||||
222
internal/agent/core/toolresult_scrub_test.go
Normal file
222
internal/agent/core/toolresult_scrub_test.go
Normal file
@ -0,0 +1,222 @@
|
||||
package core
|
||||
|
||||
// 判据:工具结果侧路的明文拦截(文档 C6)。
|
||||
//
|
||||
// ## 为什么这条侧路必须单独拦
|
||||
//
|
||||
// 出向拦截(provider 装饰器)只改 `f.Msgs`。但工具结果另有一条路经
|
||||
//
|
||||
// turnEvt.ToolResults = f.ToolResults → a.context.Append → 持久化历史
|
||||
// ↘ emitMemoryCandidate → 长期记忆
|
||||
//
|
||||
// 到达**持久化存储**。先前实测确认这条路**不经过** provider 装饰器
|
||||
// (`TestToolResultsPathBypassesScrub` 的原始探针给出了阳性证据)。
|
||||
//
|
||||
// 后果:凭据被 LLM 看到之前虽然换成了别名,但早已从侧路进了跨会话保留、
|
||||
// 每轮被召回的长期记忆 —— 泄漏面比重放一次大得多。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
func newScrubStore(t *testing.T) *security.Store {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
s, err := security.InitGlobal(security.Options{
|
||||
Path: dir + "/v.json",
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
return s
|
||||
}
|
||||
|
||||
// ★★ 判据:进 a.context 的工具结果不含明文(走 agent 的真实代码路径)。
|
||||
func TestToolResultsScrubbedBeforeHistory(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// 直接验证 scrub 逻辑本身(与 task.go 里那段同构)。
|
||||
// 用真实 Store 而不是 mock,确保别名形态/落盘都一致。
|
||||
items := []ToolResultItem{
|
||||
{Name: "cmd_run", Output: "部署用的 token 是 " + tok + " ,请使用"},
|
||||
{Name: "read_file", Output: "普通内容,不该被改"},
|
||||
}
|
||||
scrubbed := make([]ToolResultItem, len(items))
|
||||
copy(scrubbed, items)
|
||||
for i := range scrubbed {
|
||||
if out, evts := store.Replace(scrubbed[i].Output,
|
||||
"tool_result:"+scrubbed[i].Name, security.Patterns); len(evts) > 0 {
|
||||
scrubbed[i].Output = out
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(scrubbed[0].Output, tok) {
|
||||
t.Fatalf("工具结果仍含明文,会进持久化记忆: %q", scrubbed[0].Output)
|
||||
}
|
||||
if !strings.Contains(scrubbed[0].Output, "@{sec_") {
|
||||
t.Errorf("应含别名提示: %q", scrubbed[0].Output)
|
||||
}
|
||||
if scrubbed[1].Output != items[1].Output {
|
||||
t.Errorf("无关结果不应被改动: %q", scrubbed[1].Output)
|
||||
}
|
||||
// 原始切片不受影响(我们显式拷贝 —— 与 Msgs 的原地改写不同,见下)
|
||||
if !strings.Contains(items[0].Output, tok) {
|
||||
t.Error("不应改动调用方的原始切片(f.ToolResults 仍被其它路径读取)")
|
||||
}
|
||||
_ = a
|
||||
}
|
||||
|
||||
// ★ 判据:**工具结果路径用拷贝而非原地改写** —— 与 f.Msgs 的取舍相反。
|
||||
//
|
||||
// 为何不同:`f.ToolResults` 在同一轮里可能被其它代码读到(如
|
||||
// `f.ToolsUsed` 之外的诊断、状态面渲染),原地改会让它们看到替换后的文本;
|
||||
// 而 `f.Msgs` 本来就是「要发出去的那一份」,原地改正是我们要的。
|
||||
//
|
||||
// 这个不对称是刻意的,判据把它钉住,免得后来者「统一」成一种写法。
|
||||
func TestToolResultsScrubDoesNotMutateSource(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
src := []ToolResultItem{{Name: "t", Output: tok}}
|
||||
dst := make([]ToolResultItem, len(src))
|
||||
copy(dst, src)
|
||||
for i := range dst {
|
||||
dst[i].Output, _ = store.Replace(dst[i].Output, "tool_result", security.Patterns)
|
||||
}
|
||||
if dst[0].Output == src[0].Output {
|
||||
t.Fatal("拷贝出来的应被改写(否则拦截没生效)")
|
||||
}
|
||||
if src[0].Output != tok {
|
||||
t.Fatal("原始 f.ToolResults 不应被改动 —— 它还被其它路径读取")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 决定性判据:**走真实调度循环**,工具返回的明文不得进 a.context。
|
||||
//
|
||||
// 上面两条判据复刻了 scrub 逻辑。这条不同 —— 它跑真实的
|
||||
// `a.Start()` + 注入 + 工具回环 + finishInputTask,然后读 `a.context`
|
||||
// 看实际落进去的是什么。若我只在 task.go 里写错位置(比如放在
|
||||
// Append 之后、或忘了给 emitMemoryCandidate 用过滤后的副本),
|
||||
// 只有这条会报红。
|
||||
func TestE2E_ToolResultSecretNeverReachesContext(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "e2e-trscrub",
|
||||
Provider: &secretToolProvider{},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
|
||||
// 注册一个会吐出凭据的工具(模拟 cmd_run 读了 .env)
|
||||
if err := a.stageHost.RegisterTool("secretecho", sdk.ToolDef{
|
||||
Name: "secretecho", Description: "回显凭据",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(map[string]interface{}) (interface{}, error) {
|
||||
return "credential=" + tok, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
if out := a.io.InjectTextSync("http", "查一下凭据"); out == nil {
|
||||
t.Fatal("同步注入没有回执")
|
||||
}
|
||||
|
||||
// ★ 读真实 context:明文不得出现在任何事件里
|
||||
found := false
|
||||
for _, evt := range a.context.Recent(0) {
|
||||
for _, tr := range evt.ToolResults {
|
||||
if strings.Contains(tr.Output, tok) {
|
||||
found = true
|
||||
t.Errorf("工具结果明文进了 a.context(持久化记忆的源头): %q", tr.Output)
|
||||
}
|
||||
}
|
||||
if strings.Contains(evt.Response, tok) || strings.Contains(evt.Input, tok) {
|
||||
t.Errorf("事件正文含明文: input=%q response=%q", evt.Input, evt.Response)
|
||||
}
|
||||
}
|
||||
if found {
|
||||
t.Fatal("★ 侧路泄漏:出向拦截挡不住工具结果进记忆")
|
||||
}
|
||||
|
||||
// ★ 第二条侧路:memory_candidate。它经 EmitOutput 把 tool_results
|
||||
// 交给记忆管线(distill.go:607),**不经过 a.context**。
|
||||
// 只看 a.context 会漏掉这条(变异实验 C6-M2 证实了这个盲区)。
|
||||
memLeak := false
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case evt := <-a.io.OutputChan():
|
||||
if evt == nil || evt.Type != "memory_candidate" {
|
||||
continue
|
||||
}
|
||||
if trs, ok := evt.Payload["tool_results"].([]ToolResultItem); ok {
|
||||
for _, tr := range trs {
|
||||
if strings.Contains(tr.Output, tok) {
|
||||
memLeak = true
|
||||
t.Errorf("记忆候选里的工具结果含明文(会写进长期记忆): %q", tr.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
if memLeak {
|
||||
t.Fatal("★ 记忆侧路泄漏")
|
||||
}
|
||||
// 确认确实发生了拦截(否则可能是工具压根没被调用,判据假绿)
|
||||
if store.Count() == 0 {
|
||||
t.Fatal("变量库为空 —— 说明工具没被调用或没拦截,本判据无效")
|
||||
}
|
||||
t.Logf("✅ 工具回环后 a.context 无明文;变量库已登记 %d 条", store.Count())
|
||||
}
|
||||
|
||||
// secretToolProvider 第一轮请求调工具,第二轮给最终答复。
|
||||
type secretToolProvider struct{ round int }
|
||||
|
||||
func (p *secretToolProvider) Name() string { return "secretTool" }
|
||||
func (p *secretToolProvider) MaxContextTokens() int { return 8192 }
|
||||
|
||||
func (p *secretToolProvider) next() *agentAPI.CompletionResponse {
|
||||
p.round++
|
||||
if p.round == 1 {
|
||||
return &agentAPI.CompletionResponse{
|
||||
Content: "让我查一下",
|
||||
ToolCalls: []agentAPI.ToolCall{
|
||||
{ID: "c1", Name: "secretecho", Arguments: map[string]interface{}{}},
|
||||
},
|
||||
}
|
||||
}
|
||||
return &agentAPI.CompletionResponse{Content: "查完了"}
|
||||
}
|
||||
|
||||
func (p *secretToolProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return p.next(), nil
|
||||
}
|
||||
|
||||
func (p *secretToolProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
ch := make(chan agentAPI.StreamChunk, 1)
|
||||
r := p.next()
|
||||
ch <- agentAPI.StreamChunk{Content: r.Content, ToolCalls: r.ToolCalls, Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
@ -20,10 +20,10 @@ type mockDevice struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (d *mockDevice) Name() string { return d.name }
|
||||
func (d *mockDevice) Type() DeviceType { return d.devType }
|
||||
func (d *mockDevice) Description() string { return d.desc }
|
||||
func (d *mockDevice) Tools() []ToolDef { return d.tools }
|
||||
func (d *mockDevice) Name() string { return d.name }
|
||||
func (d *mockDevice) Type() DeviceType { return d.devType }
|
||||
func (d *mockDevice) Description() string { return d.desc }
|
||||
func (d *mockDevice) Tools() []ToolDef { return d.tools }
|
||||
func (d *mockDevice) Start() error {
|
||||
d.mu.Lock()
|
||||
d.startCallCount++
|
||||
@ -43,7 +43,7 @@ func (d *mockDevice) Stop() error {
|
||||
return nil
|
||||
}
|
||||
func (d *mockDevice) OutputCapabilities() OutputCapability { return d.caps }
|
||||
func (d *mockDevice) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
func (d *mockDevice) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
func (d *mockDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
if d.executeFn != nil {
|
||||
return d.executeFn(tool, args)
|
||||
@ -275,13 +275,13 @@ func TestExecuteToolNotFound(t *testing.T) {
|
||||
func TestListChannels(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.RegisterDevice(&mockDevice{
|
||||
name: "screen",
|
||||
caps: CapText | CapImage,
|
||||
name: "screen",
|
||||
caps: CapText | CapImage,
|
||||
devType: DeviceOutput,
|
||||
})
|
||||
m.RegisterDevice(&mockDevice{
|
||||
name: "mic",
|
||||
caps: 0,
|
||||
name: "mic",
|
||||
caps: 0,
|
||||
devType: DeviceInput,
|
||||
})
|
||||
|
||||
|
||||
93
internal/config/backup_diag_test.go
Normal file
93
internal/config/backup_diag_test.go
Normal file
@ -0,0 +1,93 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
// ★★ E-2:备份来自**另一台机器**(密钥不同)时,审计必须报出“不可解”,
|
||||
// 而不是静默当明文/空值,也不是等到某个功能坏了才发现。
|
||||
//
|
||||
// 这是备份恢复场景的核心判据:db 能打开、表能读、但密文解不开 ——
|
||||
// 那种状态最容易被误判成“配置丢了”。
|
||||
func TestAuditSecretsDetectsForeignKeyBackup(t *testing.T) {
|
||||
// 本包的 codec 是**进程级全局**,测试必须还原现场,
|
||||
// 否则会把密钥泄漏给同包其它判据(实测:导致 GetInt 读到密文)。
|
||||
t.Cleanup(security.ClearConfigCodec)
|
||||
dir := t.TempDir()
|
||||
keyA := []byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
keyB := []byte("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
||||
|
||||
// 机器 A:写入一条加密配置
|
||||
security.ClearConfigCodec()
|
||||
if _, err := security.InitConfigCodec(keyA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regA := NewConfigRegistry(filepath.Join(dir, "config.db"))
|
||||
regA.RegisterDef(cfgDef("core.llm.api_key"))
|
||||
if err := regA.Set("core.llm.api_key", "sk-machine-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, bad, err := regA.AuditSecrets()
|
||||
if err != nil || ok != 1 || bad != 0 {
|
||||
t.Fatalf("machine A 自审应为 ok=1 bad=0,实际 ok=%d bad=%d err=%v", ok, bad, err)
|
||||
}
|
||||
regA.Close()
|
||||
|
||||
// 机器 B:同一份 db,但密钥不同
|
||||
security.ClearConfigCodec()
|
||||
if _, err := security.InitConfigCodec(keyB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regB := NewConfigRegistry(filepath.Join(dir, "config.db"))
|
||||
defer regB.Close()
|
||||
ok2, bad2, err := regB.AuditSecrets()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bad2 == 0 {
|
||||
t.Fatal("★ 外来密钥的备份未被识别 —— 会被误判为「配置丢了」而非「密钥不匹配」")
|
||||
}
|
||||
if ok2 != 0 {
|
||||
t.Errorf("外来密钥下可解条数应为 0,实际 %d", ok2)
|
||||
}
|
||||
// 读取该键必须**响亮报错**,不能把密文当明文交出去
|
||||
if v, err := regB.Get("core.llm.api_key"); err == nil {
|
||||
t.Fatalf("★ 密文解不开却返回了值: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 同一密钥下重启(正常恢复)必须全部可解 —— 不能把正常备份误报成坏。
|
||||
func TestAuditSecretsCleanOnSameKey(t *testing.T) {
|
||||
t.Cleanup(security.ClearConfigCodec)
|
||||
dir := t.TempDir()
|
||||
key := []byte("cccccccccccccccccccccccccccccccc")
|
||||
security.ClearConfigCodec()
|
||||
if _, err := security.InitConfigCodec(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := filepath.Join(dir, "config.db")
|
||||
reg := NewConfigRegistry(db)
|
||||
reg.RegisterDef(cfgDef("core.llm.api_key"))
|
||||
if err := reg.Set("core.llm.api_key", "sk-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg.Close()
|
||||
|
||||
if _, err := security.InitConfigCodec(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg2 := NewConfigRegistry(db)
|
||||
defer reg2.Close()
|
||||
ok, bad, err := reg2.AuditSecrets()
|
||||
if err != nil || bad != 0 || ok != 1 {
|
||||
t.Fatalf("同密钥重启应为 ok=1 bad=0,实际 ok=%d bad=%d err=%v", ok, bad, err)
|
||||
}
|
||||
}
|
||||
|
||||
// cfgDef 造一个最小 secret 键定义。
|
||||
func cfgDef(key string) ConfigDef {
|
||||
return ConfigDef{Key: key, Type: "string", Secret: true}
|
||||
}
|
||||
@ -28,6 +28,16 @@ type ConfigDef struct {
|
||||
Options []string `json:"options,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
|
||||
// Secret 标记该配置项为敏感值:**落盘加密且不回显**。
|
||||
//
|
||||
// ★ 与 Type=="password" 等价,但更显式。SDK 侧(pubsdk.ConfigDef)
|
||||
// 一直有这个字段,只是内核这边没实现 —— 插件早就声明了意图却没有效果。
|
||||
//
|
||||
// 两种写法都会生效(见 isSecretKey):
|
||||
// · Type: "password" 旧写法,兼容保留
|
||||
// · Secret: true 推荐,意图明确
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigRegistry struct {
|
||||
@ -36,6 +46,12 @@ type ConfigRegistry struct {
|
||||
dbPath string
|
||||
defs map[string]*ConfigDef
|
||||
llmSnapFile string
|
||||
|
||||
// secrets 是「需要加密落盘」的键集合(由 def 推导,见 isSecretKey)。
|
||||
//
|
||||
// 为何缓存成集合而不是每次查 def:写入是热路径,而 def 查询要拿锁;
|
||||
// 且 secret 判定必须对**无 def 的键**也能生效(见 secretByKeyPattern)。
|
||||
secrets map[string]bool
|
||||
}
|
||||
|
||||
func NewConfigRegistry(dbPath string) *ConfigRegistry {
|
||||
@ -47,7 +63,7 @@ func NewConfigRegistry(dbPath string) *ConfigRegistry {
|
||||
panic(fmt.Sprintf("open config db: %v", err))
|
||||
}
|
||||
db.Exec("PRAGMA journal_mode=WAL")
|
||||
r := &ConfigRegistry{db: db, dbPath: dbPath, defs: make(map[string]*ConfigDef)}
|
||||
r := &ConfigRegistry{db: db, dbPath: dbPath, defs: make(map[string]*ConfigDef), secrets: make(map[string]bool)}
|
||||
r.initCoreTable()
|
||||
return r
|
||||
}
|
||||
@ -133,11 +149,19 @@ func (r *ConfigRegistry) IsPluginDisabled(name string) bool {
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) ensurePluginTable(name string) {
|
||||
table := r.pluginTableName(name)
|
||||
r.db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`, table))
|
||||
)`, r.pluginTableName(name)))
|
||||
}
|
||||
|
||||
// ensurePluginTableErr 同 ensurePluginTable 但返回错误(显式加密写入需要它)。
|
||||
func (r *ConfigRegistry) ensurePluginTableErr(name string) error {
|
||||
_, err := r.db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`, r.pluginTableName(name)))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) pluginTableName(name string) string {
|
||||
@ -153,7 +177,11 @@ func (r *ConfigRegistry) pluginTableName(name string) string {
|
||||
func (r *ConfigRegistry) Register(key string, value interface{}) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, key, fmt.Sprint(value))
|
||||
stored := fmt.Sprint(value)
|
||||
if r.isSecretKey(key) {
|
||||
stored, _ = sealForStorage(key, stored)
|
||||
}
|
||||
r.db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, key, stored)
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) RegisterDefault(key string, value interface{}) {
|
||||
@ -164,7 +192,12 @@ func (r *ConfigRegistry) RegisterDef(def ConfigDef) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.defs[def.Key] = &def
|
||||
r.db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, def.Key, def.Default)
|
||||
// 默认值也要加密落盘(否则声明了 secret 的项一启动就是明文)。
|
||||
stored := def.Default
|
||||
if r.isSecretKey(def.Key) {
|
||||
stored, _ = sealForStorage(def.Key, stored)
|
||||
}
|
||||
r.db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, def.Key, stored)
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) GetDef(key string) *ConfigDef {
|
||||
@ -306,7 +339,12 @@ func (r *ConfigRegistry) Get(key string) (interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return val, nil
|
||||
// 敏感项在库里是密文 ⇒ 对调用方透明解密(读接口语义不变)。
|
||||
if plain, oerr := openFromStorage(key, val); oerr == nil {
|
||||
return plain, nil
|
||||
} else {
|
||||
return nil, oerr
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) Set(key string, value interface{}) error {
|
||||
@ -315,7 +353,12 @@ func (r *ConfigRegistry) Set(key string, value interface{}) error {
|
||||
if strings.HasPrefix(key, "core.llm.") {
|
||||
r.writeLLMSnapshotLocked()
|
||||
}
|
||||
_, err := r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`, key, fmt.Sprint(value))
|
||||
stored := fmt.Sprint(value)
|
||||
// ★ 敏感项在**写库前**加密:库文件里只有密文。
|
||||
if r.isSecretKey(key) {
|
||||
stored, _ = sealForStorage(key, stored)
|
||||
}
|
||||
_, err := r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`, key, stored)
|
||||
if err == nil && strings.HasPrefix(key, "core.llm.sources.") {
|
||||
rest := strings.TrimPrefix(key, "core.llm.sources.")
|
||||
parts := strings.SplitN(rest, ".", 2)
|
||||
@ -455,6 +498,9 @@ func LoadLLMSnapshot(path string) (map[string]string, error) {
|
||||
}
|
||||
|
||||
// listPrefixLocked 调用方须持有锁;返回 prefix 开头的全部键值。
|
||||
//
|
||||
// ★ 返回的是**解密后**的值(调用方都是内核内部可信路径,如
|
||||
// ToConfig / LLM 快照 / guard 基线)。
|
||||
func (r *ConfigRegistry) listPrefixLocked(prefix string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
rows, err := r.db.Query(`SELECT key, value FROM config WHERE key LIKE ? ORDER BY key`, prefix+"%")
|
||||
@ -465,7 +511,12 @@ func (r *ConfigRegistry) listPrefixLocked(prefix string) map[string]string {
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err == nil {
|
||||
out[k] = v
|
||||
if plain, oerr := openFromStorage(k, v); oerr == nil {
|
||||
out[k] = plain
|
||||
} else {
|
||||
// 解不开就不给值:宁可缺失(可诊断)也不把密文当明文交出去。
|
||||
log.Printf("[config] listPrefix 解密 %s 失败,已跳过: %v", k, oerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
@ -483,7 +534,14 @@ func (r *ConfigRegistry) Dump() map[string]interface{} {
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err == nil {
|
||||
result[k] = v
|
||||
// ★ 返回**明文**:Dump 的调用方都是内核内部可信路径
|
||||
//(ToConfig 重建 LLMSource、WebUI 设置页、插件读自身/他人配置)。
|
||||
// 本需求防的是「静态文件泄漏」,不是「内部读取」。
|
||||
if plain, oerr := openFromStorage(k, v); oerr == nil {
|
||||
result[k] = plain
|
||||
} else {
|
||||
log.Printf("[config] Dump 解密 %s 失败: %v", k, oerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
@ -723,7 +781,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
||||
set("core.memory.documents", filepath.Join(dataDir, "memory", "documents"))
|
||||
// 发行版默认启用本地向量空间。用 chineseclip(text+image、512 维、实测
|
||||
// 稳态约 0.89GB、Apache-2.0)而不是 qwen3vl(视觉塔按需加载,峰值约 9.4GB):
|
||||
// 多数机器装不下后者。
|
||||
// 多数机器装不下后者。
|
||||
// 产物不在仓库里,用 scripts/export_chineseclip_onnx.py 生成到这个路径;
|
||||
// 产物缺失时 homed 会打印明确错误并退回 fastText 文本路径(不静默假装启用)。
|
||||
set("core.memory.multimodal_space.provider", "chineseclip")
|
||||
@ -885,7 +943,10 @@ func (r *ConfigRegistry) GetString(key, defaultVal string) string {
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return v
|
||||
if plain, oerr := openFromStorage(key, v); oerr == nil {
|
||||
return plain
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) GetInt(key string, defaultVal int) int {
|
||||
@ -1154,7 +1215,11 @@ func (r *ConfigRegistry) SetPluginConfig(name, key string, value interface{}) er
|
||||
defer r.mu.Unlock()
|
||||
r.ensurePluginTable(name)
|
||||
table := r.pluginTableName(name)
|
||||
_, err := r.db.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, table), key, fmt.Sprint(value))
|
||||
stored := fmt.Sprint(value)
|
||||
if r.isSecretKey("plugin." + name + "." + key) {
|
||||
stored, _ = sealForStorage("plugin."+name+"."+key, stored)
|
||||
}
|
||||
_, err := r.db.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, table), key, stored)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -1183,17 +1248,65 @@ func (p *PluginSettings) Get(key string) (interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return val, nil
|
||||
// 敏感项透明解密
|
||||
if plain, oerr := openFromStorage("plugin."+p.name+"."+key, val); oerr == nil {
|
||||
return plain, nil
|
||||
} else {
|
||||
return nil, oerr
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PluginSettings) Set(key string, value interface{}) error {
|
||||
p.registry.mu.Lock()
|
||||
defer p.registry.mu.Unlock()
|
||||
_, err := p.registry.db.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, p.table), key, fmt.Sprint(value))
|
||||
stored := fmt.Sprint(value)
|
||||
// ★ 插件敏感项同样加密落盘(插件无感知:它 Set 明文,Get 也得到明文)。
|
||||
if p.isSecretKeyLocal(key) {
|
||||
stored, _ = sealForStorage("plugin."+p.name+"."+key, stored)
|
||||
}
|
||||
_, err := p.registry.db.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, p.table), key, stored)
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove 删除插件配置中的单个键(用于插件删除时的自身配置清理)。
|
||||
// SetSecret 强制加密写入(不看键名,也不需 def)。
|
||||
//
|
||||
// ★ 为何要显式 API:键名模式无法覆盖所有情形 —— 插件可能用
|
||||
// `blob`、`payload`、`conn` 这类中性键名存凭据。显式声明意图比猜名字可靠。
|
||||
//
|
||||
// 未装配密钥时 **拒绝写入**(不静默存明文):插件以为存了密文而
|
||||
// 实际是明文,是最危险的静默降级。空值例外(删除语义)。
|
||||
func (p *PluginSettings) SetSecret(key string, value interface{}) error {
|
||||
stored := fmt.Sprint(value)
|
||||
sealed := stored
|
||||
if stored != "" {
|
||||
var ok bool
|
||||
sealed, ok = sealForStorage("plugin."+p.name+"."+key, stored)
|
||||
if !ok {
|
||||
return fmt.Errorf("config: 密钥未装配,拒绝以明文存储敏感项 %q", key)
|
||||
}
|
||||
}
|
||||
p.registry.mu.Lock()
|
||||
defer p.registry.mu.Unlock()
|
||||
if err := p.registry.ensurePluginTableErr(p.name); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := p.registry.db.Exec(
|
||||
fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, p.table), key, sealed)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSecret 读取由 SetSecret 写入的值(透明解密)。
|
||||
func (p *PluginSettings) GetSecret(key string) (string, error) {
|
||||
p.registry.mu.RLock()
|
||||
defer p.registry.mu.RUnlock()
|
||||
var val string
|
||||
err := p.registry.db.QueryRow(
|
||||
fmt.Sprintf(`SELECT value FROM %s WHERE key = ?`, p.table), key).Scan(&val)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return openFromStorage("plugin."+p.name+"."+key, val)
|
||||
}
|
||||
func (p *PluginSettings) Remove(key string) error {
|
||||
p.registry.mu.Lock()
|
||||
defer p.registry.mu.Unlock()
|
||||
@ -1226,10 +1339,18 @@ func (p *PluginSettings) RegisterDef(def ConfigDef) {
|
||||
// 只有注册配置定义才创建插件配置表:任意 scope 的读写不得隐式建表,
|
||||
// 避免非插件(如 SKILL 目录名)被注册成配置命名空间。
|
||||
p.registry.ensurePluginTable(p.name)
|
||||
p.registry.db.Exec(fmt.Sprintf(`INSERT OR IGNORE INTO %s (key, value) VALUES (?, ?)`, p.table), def.Key, def.Default)
|
||||
qualified := "plugin." + p.name + "." + def.Key
|
||||
|
||||
localKey := def.Key // 表内用局部键
|
||||
qualified := "plugin." + p.name + "." + localKey // defs 里用限定键
|
||||
def.Key = qualified
|
||||
p.registry.defs[def.Key] = &def
|
||||
// ★ def 必须先入 defs:isSecretKey 靠它判定,而下面的默认值要加密。
|
||||
p.registry.defs[qualified] = &def
|
||||
|
||||
stored := def.Default
|
||||
if p.registry.isSecretKey(qualified) {
|
||||
stored, _ = sealForStorage(qualified, stored)
|
||||
}
|
||||
p.registry.db.Exec(fmt.Sprintf(`INSERT OR IGNORE INTO %s (key, value) VALUES (?, ?)`, p.table), localKey, stored)
|
||||
}
|
||||
|
||||
func (p *PluginSettings) ListDefs(prefix string) []*ConfigDef {
|
||||
|
||||
408
internal/config/secret.go
Normal file
408
internal/config/secret.go
Normal file
@ -0,0 +1,408 @@
|
||||
package config
|
||||
|
||||
// 配置落盘加密(敏感项 at-rest encryption)。
|
||||
//
|
||||
// ## 要解决的问题
|
||||
//
|
||||
// `config.db` 是 **0644 全局可读**的(它同目录还有 WAL/SHM),
|
||||
// 而里面装着 LLM API 密钥、WebUI 口令、插件凭据等 —— 任何本机用户
|
||||
// `sqlite3 config.db 'select * from config'` 就能拿到全部明文。
|
||||
//
|
||||
// ## 做法:在**存储边界**加密,读写接口不变
|
||||
//
|
||||
// Set/Register/seed → Seal → DB 里是 `enc:v1:<base64>`
|
||||
// Get/Dump/ToConfig → Open → 调用方永远只看到明文
|
||||
//
|
||||
// ★ 关键取舍:**读接口保持"返回明文"**。
|
||||
//
|
||||
// 初稿曾考虑让 `Dump()` 返回掩码、让 WebUI 看不到明文。但那样会同时
|
||||
// 打断 `ToConfig()`(它靠 Dump 重建 LLMSource,含 api_key)与任何
|
||||
// 需要真实值的内部读取方 —— 而它们**本来就在可信边界内**。
|
||||
// 本需求要防的是「静态文件泄漏」,不是「内部读取」。
|
||||
//
|
||||
// ## 标记来源(两种都支持)
|
||||
//
|
||||
// 1. `ConfigDef.Secret == true` ← SDK 里早就有这个字段,但从未被实现
|
||||
// 2. `ConfigDef.Type == "password"` / `"secret"`
|
||||
//
|
||||
// ## 无 def 的键怎么办
|
||||
//
|
||||
// 插件的 key 常常没有 def(`Set()` 可以直接写任意键)。
|
||||
// 所以还要靠**键名模式**兜底(`secretKeyPattern`),否则
|
||||
// `plugin.qq.token` 这类会被漏掉。
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
// secretSegmentPattern 是无 def 时的兜底判定。
|
||||
//
|
||||
// ## 为何不能直接 `strings.Contains(key, "token")`
|
||||
//
|
||||
// 初版就是这么写的,结果把这两个**非敏感**项也加密了:
|
||||
//
|
||||
// core.llm.max_tokens ← 输出 token 上限
|
||||
// core.agent.context.max_target_tokens ← 上下文预算
|
||||
//
|
||||
// 功能上无害(透明解密,仍可用),但语义错了,且库变得难以人工检查。
|
||||
// 根因:`max_tokens` 包含子串 "token",而它是**复数、指数量**。
|
||||
//
|
||||
// ## 现在的判据:按**词段**匹配
|
||||
//
|
||||
// 先把键按 `.` `_` `-` 切成词段,再看:
|
||||
//
|
||||
// 词段完全等于敏感词 → 命中(token / password / apikey …)
|
||||
// 词段以 `_<敏感词>` 结尾 → 命中(api_key / access_token …)
|
||||
//
|
||||
// 关键差别:`max_tokens` 的末词段是 `tokens`(复数),不等于 `token`,
|
||||
// 也不以 `_token` 结尾 ⇒ **不命中**。而 `api_key` 末词段是 `key`
|
||||
// 且整串以 `_key` 结尾 ⇒ 命中。
|
||||
var secretWords = []string{
|
||||
"token", "password", "passwd", "secret", "credential", "credentials",
|
||||
"apikey", "key",
|
||||
}
|
||||
|
||||
// secretExactSegments 是允许“整词即敏感”的情形。
|
||||
//
|
||||
// `key` 单独成词段(如某插件的配置就叫 `key`)算敏感;但像 `keyboard`、
|
||||
// `keys`(复数)这样的就不算。
|
||||
func looksLikeSecretKey(key string) bool {
|
||||
lower := strings.ToLower(key)
|
||||
// 切成词段:先按命名空间分隔,再拆下划线/连字符
|
||||
for _, part := range strings.FieldsFunc(lower, func(r rune) bool {
|
||||
return r == '.' || r == '_' || r == '-'
|
||||
}) {
|
||||
for _, w := range secretWords {
|
||||
if part == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// 复合形态:整串以 _<敏感词> 结尾(api_key / access_token / client_secret)
|
||||
for _, w := range secretWords {
|
||||
if strings.HasSuffix(lower, "_"+w) || strings.HasSuffix(lower, "-"+w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isSecretKey 判定一个(全局或插件内局部的)键是否敏感。
|
||||
//
|
||||
// 判定顺序:显式 def(最权威) → 键名模式(兜底)。
|
||||
func (r *ConfigRegistry) isSecretKey(key string) bool {
|
||||
// 调用方须持锁(读或写皆可)
|
||||
if d, ok := r.defs[key]; ok {
|
||||
if d.Secret || d.Type == "password" || d.Type == "secret" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return looksLikeSecretKey(key)
|
||||
}
|
||||
|
||||
// isSecretKeyLocal 供 PluginSettings 用(键是插件内局部键,无命名空间前缀)。
|
||||
func (p *PluginSettings) isSecretKeyLocal(key string) bool {
|
||||
return p.registry.isSecretKey("plugin." + p.name + "." + key)
|
||||
}
|
||||
|
||||
// sealForStorage 在写库前加密(仅对敏感键 + 编解码器已装配时)。
|
||||
//
|
||||
// 返回值语义:加密发生则 mem=false(调用方据此决定是否需回读校验);
|
||||
// 非敏感键或未装配 codec 时原样返回。
|
||||
func sealForStorage(key, value string) (string, bool) {
|
||||
if !security.IsEncrypted(value) {
|
||||
c := security.ConfigCodec()
|
||||
if c == nil || !c.Enabled() {
|
||||
return value, false
|
||||
}
|
||||
if sealed, err := c.Seal(value); err == nil {
|
||||
return sealed, true
|
||||
} else {
|
||||
log.Printf("[config] 加密 %s 失败(将按明文写入,此项暴露风险): %v", key, err)
|
||||
}
|
||||
}
|
||||
return value, false
|
||||
}
|
||||
|
||||
// openFromStorage 在读库后解密。
|
||||
//
|
||||
// 非密文原样返回(**存量明文兼容**):老数据不需要一次性迁移。
|
||||
// 密文但解不开 ⇒ **响亮失败**(返回错误而非密文),因为把密文
|
||||
// 当明文交出去会让下游拿到垃圾(比如把 `enc:v1:...` 当 API key 发出去)。
|
||||
func openFromStorage(key, stored string) (string, error) {
|
||||
if !security.IsEncrypted(stored) {
|
||||
return stored, nil
|
||||
}
|
||||
c := security.ConfigCodec()
|
||||
if c == nil || !c.Enabled() {
|
||||
return "", security.ErrNoMasterKey
|
||||
}
|
||||
plain, err := c.Open(stored)
|
||||
if err != nil {
|
||||
log.Printf("[config] 解密 %s 失败: %v", key, err)
|
||||
return "", err
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// EncryptExistingSecrets 把库里**尚为明文**的敏感项就地加密(启动时跑一次)。
|
||||
//
|
||||
// ## 为何需要
|
||||
//
|
||||
// 加密只对新写入生效。升级前库里已有的 api_key / 口令仍是明文 ——
|
||||
// 不迁移的话"升级了但密钥还在裸奔",而且用户无法察觉。
|
||||
//
|
||||
// ## 可回滚
|
||||
//
|
||||
// 只改值形态、不改键名与表结构,旧版本二进制读明文项仍然工作
|
||||
// (它不认 `enc:v1:` 前缀,会把密文当明文用 —— 那是**功能坏但可见**的
|
||||
// 失败,不是静默错值)。所以升级要么配套换二进制,要么备份。
|
||||
//
|
||||
// 返回 (加密条数, 错误)。幂等:已加密的项不会被二次加密。
|
||||
func (r *ConfigRegistry) EncryptExistingSecrets() (int, error) {
|
||||
c := security.ConfigCodec()
|
||||
if c == nil || !c.Enabled() {
|
||||
return 0, security.ErrNoMasterKey
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
type row struct{ table, key, val string }
|
||||
var todo []row
|
||||
|
||||
// ① 全局 config 表
|
||||
rows, err := r.db.Query(`SELECT key, value FROM config`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
continue
|
||||
}
|
||||
if !r.isSecretKey(k) || security.IsEncrypted(v) || v == "" {
|
||||
continue
|
||||
}
|
||||
todo = append(todo, row{"config", k, v})
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// ② 各插件表
|
||||
for _, name := range r.ListPluginsLocked() {
|
||||
table := r.pluginTableName(name)
|
||||
pr, err := r.db.Query("SELECT key, value FROM " + table)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for pr.Next() {
|
||||
var k, v string
|
||||
if err := pr.Scan(&k, &v); err != nil {
|
||||
continue
|
||||
}
|
||||
if !r.isSecretKey("plugin."+name+"."+k) || security.IsEncrypted(v) || v == "" {
|
||||
continue
|
||||
}
|
||||
todo = append(todo, row{table, k, v})
|
||||
}
|
||||
pr.Close()
|
||||
}
|
||||
|
||||
n := 0
|
||||
for _, t := range todo {
|
||||
sealed, err := c.Seal(t.val)
|
||||
if err != nil {
|
||||
log.Printf("[config] 迁移加密 %s.%s 失败: %v", t.table, t.key, err)
|
||||
continue
|
||||
}
|
||||
if _, err := r.db.Exec(
|
||||
"UPDATE "+t.table+" SET value = ? WHERE key = ?", sealed, t.key); err != nil {
|
||||
log.Printf("[config] 迁移加密 %s.%s 写回失败: %v", t.table, t.key, err)
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[config] 已加密 %d 条存量敏感配置(落盘不再有明文)", n)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// RekeySecrets 把库里的密文重加密到**当前**主密钥(E-1 密钥轮换后的收口)。
|
||||
//
|
||||
// 与 EncryptExistingSecrets 的区别:
|
||||
// - 后者处理「明文 → 密文」(升级路径);
|
||||
// - 本函数处理「旧钥密文 → 新钥密文」(轮换路径)。
|
||||
//
|
||||
// 为何必须有:轮换只换密钥文件 / keyring 是不够的 —— 旧密文仍由旧钥保护,
|
||||
// 而旧钥必须留在环里才能读它们。不重加密的话,旧钥永远不会退出。
|
||||
//
|
||||
// 幂等:已是当前钥的项不动。解不开的项**响亮计数**并跳过(不静默删)。
|
||||
func (r *ConfigRegistry) RekeySecrets() (int, int, error) {
|
||||
c := security.ConfigCodec()
|
||||
if c == nil || !c.Enabled() {
|
||||
return 0, 0, security.ErrNoMasterKey
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
tables := []string{"config"}
|
||||
for _, name := range r.ListPluginsLocked() {
|
||||
tables = append(tables, r.pluginTableName(name))
|
||||
}
|
||||
|
||||
rekeyed, failed := 0, 0
|
||||
for _, table := range tables {
|
||||
rows, err := r.db.Query("SELECT key, value FROM " + table)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
type pair struct{ k, v string }
|
||||
var need []pair
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
continue
|
||||
}
|
||||
if c.NeedsRekey(v) {
|
||||
need = append(need, pair{k, v})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, p := range need {
|
||||
fresh, err := c.Rekey(p.v)
|
||||
if err != nil {
|
||||
// ★ 解不开不能静默跳:那是“密钥环缺一代”的信号,必须可见。
|
||||
failed++
|
||||
log.Printf("[config] 重加密 %s.%s 失败(旧密钥不在环里?): %v", table, p.k, err)
|
||||
continue
|
||||
}
|
||||
if _, err := r.db.Exec("UPDATE "+table+" SET value = ? WHERE key = ?", fresh, p.k); err != nil {
|
||||
failed++
|
||||
log.Printf("[config] 重加密 %s.%s 写回失败: %v", table, p.k, err)
|
||||
continue
|
||||
}
|
||||
rekeyed++
|
||||
}
|
||||
}
|
||||
if rekeyed > 0 || failed > 0 {
|
||||
log.Printf("[config] 密钥轮换重加密:完成 %d 条,失败 %d 条", rekeyed, failed)
|
||||
}
|
||||
return rekeyed, failed, nil
|
||||
}
|
||||
|
||||
// AuditSecrets 统计库里密文的可读性(E-2:备份跨机器恢复的诊断)。
|
||||
//
|
||||
// 为何需要:备份恢复到另一台机器时,DB 里的密文是**旧机器的密钥**加的。
|
||||
// 此前只有读某一条具体配置时才会报错,用户看到的是「某个功能坏了」,
|
||||
// 而不是「这份备份与当前密钥不匹配」。这个审计把事实一次性说清楚。
|
||||
//
|
||||
// 返回 (可解, 不可解)。不可解 > 0 就是密钥不匹配/缺代的硬信号。
|
||||
func (r *ConfigRegistry) AuditSecrets() (int, int, error) {
|
||||
c := security.ConfigCodec()
|
||||
if c == nil || !c.Enabled() {
|
||||
return 0, 0, security.ErrNoMasterKey
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
tables := []string{"config"}
|
||||
for _, name := range r.ListPluginsLocked() {
|
||||
tables = append(tables, r.pluginTableName(name))
|
||||
}
|
||||
ok, bad := 0, 0
|
||||
for _, table := range tables {
|
||||
rows, err := r.db.Query("SELECT value FROM " + table)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
continue
|
||||
}
|
||||
if !security.IsEncrypted(v) {
|
||||
continue // 明文(未加密项)不计入
|
||||
}
|
||||
if _, err := c.Open(v); err == nil {
|
||||
ok++
|
||||
} else {
|
||||
bad++
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return ok, bad, nil
|
||||
}
|
||||
|
||||
// ListPluginsLocked 是 ListPlugins 的不加锁版本(调用方已持锁)。
|
||||
func (r *ConfigRegistry) ListPluginsLocked() []string {
|
||||
rows, err := r.db.Query(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'config_%' ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var t string
|
||||
if err := rows.Scan(&t); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, strings.TrimPrefix(t, "config_"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsSecret 报告某个(全局或 plugin. 前缀的)键是否敏感。
|
||||
//
|
||||
// 用途(E-3):WebUI 在返回设置时必须用它**屏蔽明文**,否则任何能打开
|
||||
// 设置页的人都看得到全部密钥(含其他插件的)。
|
||||
//
|
||||
// 它刻意复用与落盘加密**同一套判定**(isSecretKey):两处判定若不一致,
|
||||
// 就会出现「已加密但界面照旧显示明文」或反向的漏洞。
|
||||
func (r *ConfigRegistry) IsSecret(key string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.isSecretKey(key)
|
||||
}
|
||||
|
||||
// SecretKeys 返回当前被判定为敏感的键(供诊断 / 测试 / WebUI 标记)。
|
||||
func (r *ConfigRegistry) SecretKeys() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for k := range r.defs {
|
||||
if r.isSecretKey(k) && !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DB 暴露底层连接(仅供**同仓测试**读取原始密文用)。
|
||||
//
|
||||
// 语义警告:它绕过全部加解密与权限逻辑,仅用于判据里断言
|
||||
// 「磁盘上确实是密文」。生产代码不得使用。
|
||||
func (r *ConfigRegistry) DB() *sql.DB { return r.db }
|
||||
|
||||
// ResetTestCodec 为判据装配一把固定主密钥。
|
||||
//
|
||||
// 单独放在非 _test 文件里,是因为 proc 包(另一个包)的端到端判据
|
||||
// 也需要它 —— 跨包无法直接调 *_test.go 里的辅助函数。
|
||||
func ResetTestCodec(t interface {
|
||||
Helper()
|
||||
Fatalf(string, ...interface{})
|
||||
}) {
|
||||
if _, err := security.InitConfigCodec(
|
||||
[]byte("0123456789abcdef0123456789abcdef")); err != nil {
|
||||
t.Fatalf("装配测试 codec 失败: %v", err)
|
||||
}
|
||||
}
|
||||
416
internal/config/secret_test.go
Normal file
416
internal/config/secret_test.go
Normal file
@ -0,0 +1,416 @@
|
||||
package config
|
||||
|
||||
// 配置落盘加密的判据。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
func testCodec(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := security.InitConfigCodec([]byte("0123456789abcdef0123456789abcdef")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func newSecReg(t *testing.T) *ConfigRegistry {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
testCodec(t)
|
||||
return NewConfigRegistry(filepath.Join(dir, "config.db"))
|
||||
}
|
||||
|
||||
// 直接在 sqlite 层面读原始值(绕过 ConfigRegistry 的解密)。
|
||||
func rawValue(t *testing.T, r *ConfigRegistry, key string) string {
|
||||
t.Helper()
|
||||
var v string
|
||||
if err := r.db.QueryRow(`SELECT value FROM config WHERE key = ?`, key).Scan(&v); err != nil {
|
||||
t.Fatalf("读原始值失败: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ★★ 判据:敏感配置**落盘是密文**,而读接口返回明文。
|
||||
func TestSecretEncryptedOnDiskPlainOnRead(t *testing.T) {
|
||||
r := newSecReg(t)
|
||||
const secret = "sk-super-secret-value-1234567890"
|
||||
|
||||
if err := r.Set("core.llm.api_key", secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ① 磁盘上是密文
|
||||
raw := rawValue(t, r, "core.llm.api_key")
|
||||
if raw == secret {
|
||||
t.Fatal("敏感项在磁盘上是明文")
|
||||
}
|
||||
if !security.IsEncrypted(raw) {
|
||||
t.Fatalf("应有 enc:v1: 前缀,实际 %q", raw)
|
||||
}
|
||||
|
||||
// ② 读接口返回明文(内部可信路径语义不变)
|
||||
got, err := r.Get("core.llm.api_key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != secret {
|
||||
t.Fatalf("Get 应返回明文,实际 %v", got)
|
||||
}
|
||||
// ③ GetString / Dump 同样给明文
|
||||
if r.GetString("core.llm.api_key", "") != secret {
|
||||
t.Error("GetString 应返回明文")
|
||||
}
|
||||
if r.Dump()["core.llm.api_key"] != secret {
|
||||
t.Error("Dump 应返回明文(ToConfig 依赖它)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**非敏感项不受影响**(不能被误加密)。
|
||||
func TestNonSecretStaysPlain(t *testing.T) {
|
||||
r := newSecReg(t)
|
||||
if err := r.Set("core.llm.base_url", "https://api.deepseek.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := rawValue(t, r, "core.llm.base_url")
|
||||
if raw != "https://api.deepseek.com" {
|
||||
t.Fatalf("非敏感项不应被加密: %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:`Secret: true` 与 `Type: "password"` 两种声明都生效。
|
||||
func TestBothDeclarationFormsWork(t *testing.T) {
|
||||
r := newSecReg(t)
|
||||
r.RegisterDef(ConfigDef{Key: "core.a.token_field", Default: "", Secret: true})
|
||||
r.RegisterDef(ConfigDef{Key: "core.b.password_field", Default: "", Type: "password"})
|
||||
|
||||
for _, k := range []string{"core.a.token_field", "core.b.password_field"} {
|
||||
if err := r.Set(k, "value-1234567890"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !security.IsEncrypted(rawValue(t, r, k)) {
|
||||
t.Errorf("%s 未被加密(Secret 或 password 声明未生效)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**插件配置同样加密**(插件无感知)。
|
||||
func TestPluginSecretEncrypted(t *testing.T) {
|
||||
r := newSecReg(t)
|
||||
ps := r.PluginConfig("demo")
|
||||
ps.RegisterDef(ConfigDef{Key: "token", Default: "", Secret: true})
|
||||
|
||||
const secret = "plugin-token-abcdef123456"
|
||||
if err := ps.Set("token", secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 磁盘(插件表)上是密文
|
||||
var raw string
|
||||
if err := r.db.QueryRow(`SELECT value FROM config_demo WHERE key = 'token'`).Scan(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !security.IsEncrypted(raw) {
|
||||
t.Fatalf("插件敏感项应加密,实际 %q", raw)
|
||||
}
|
||||
// 插件读回明文
|
||||
got, err := ps.Get("token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != secret {
|
||||
t.Fatalf("插件应读回明文,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:无 def 的插件键靠**键名模式**兜底加密。
|
||||
func TestPluginSecretByKeyPattern(t *testing.T) {
|
||||
r := newSecReg(t)
|
||||
ps := r.PluginConfig("demo2")
|
||||
// 先注册一个**非敏感** def:插件表只在 RegisterDef 时创建,
|
||||
// 但 api_key 这个键本身没有 def —— 正好检验键名模式兜底。
|
||||
ps.RegisterDef(ConfigDef{Key: "enabled", Default: "true"})
|
||||
if err := ps.Set("api_key", "abcdefgh12345678"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw string
|
||||
r.db.QueryRow(`SELECT value FROM config_demo2 WHERE key = 'api_key'`).Scan(&raw)
|
||||
if !security.IsEncrypted(raw) {
|
||||
t.Fatalf("无 def 时 api_key 键名应触发加密,实际 %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:存量明文在启动迁移后被加密(幂等)。
|
||||
func TestMigrateExistingSecrets(t *testing.T) {
|
||||
r := newSecReg(t)
|
||||
// 模拟升级前的明文存量(直接写库,绕过 Set)
|
||||
r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`,
|
||||
"core.llm.api_key", "legacy-plaintext-key-123")
|
||||
r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`,
|
||||
"webui.listen_addr", ":8080")
|
||||
|
||||
n, err := r.EncryptExistingSecrets()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("应加密 1 条,实际 %d", n)
|
||||
}
|
||||
if !security.IsEncrypted(rawValue(t, r, "core.llm.api_key")) {
|
||||
t.Error("存量敏感项未被加密")
|
||||
}
|
||||
// 非敏感项不动
|
||||
if rawValue(t, r, "webui.listen_addr") != ":8080" {
|
||||
t.Error("非敏感项被误改")
|
||||
}
|
||||
// 迁移后仍可读明文
|
||||
if v, _ := r.Get("core.llm.api_key"); v != "legacy-plaintext-key-123" {
|
||||
t.Errorf("迁移后应可读明文,实际 %v", v)
|
||||
}
|
||||
// 幂等
|
||||
if n2, _ := r.EncryptExistingSecrets(); n2 != 0 {
|
||||
t.Errorf("迁移应幂等,第二次却加密了 %d 条", n2)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**存量明文不需要迁移也能读**(老数据向后兼容)。
|
||||
func TestLegacyPlaintextReadableWithoutMigration(t *testing.T) {
|
||||
r := newSecReg(t)
|
||||
r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`,
|
||||
"core.llm.api_key", "still-plain")
|
||||
v, err := r.Get("core.llm.api_key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v != "still-plain" {
|
||||
t.Fatalf("明文存量应可读,实际 %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:换密钥后**响亮失败**(不静默返回密文)。
|
||||
func TestWrongKeyFailsLoudly(t *testing.T) {
|
||||
r := newSecReg(t)
|
||||
if err := r.Set("core.llm.api_key", "secret-value-1234567890"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 换一把主密钥
|
||||
if _, err := security.InitConfigCodec([]byte("ffffffffffffffffffffffffffffffff")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Get("core.llm.api_key"); err == nil {
|
||||
t.Fatal("换密钥后必须报错,不能把密文当明文返回")
|
||||
}
|
||||
// Dump 也不得吐密文
|
||||
if v := r.Dump()["core.llm.api_key"]; v != nil {
|
||||
if s, ok := v.(string); ok && strings.HasPrefix(s, "enc:") {
|
||||
t.Fatal("Dump 把密文交出去了")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:未装配 codec 时退化为明文读写(本地开发/测试不阻塞)。
|
||||
func TestNoCodecDegradesToPlain(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 清空全局 codec
|
||||
security.ClearConfigCodec()
|
||||
r := NewConfigRegistry(filepath.Join(dir, "c.db"))
|
||||
if err := r.Set("core.llm.api_key", "plain-dev"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rawValue(t, r, "core.llm.api_key") != "plain-dev" {
|
||||
t.Error("未装配 codec 时应保持明文")
|
||||
}
|
||||
_ = os.Remove
|
||||
}
|
||||
|
||||
// ★ 判据:域隔离 —— 配置子密钥与别名密钥不可互换。
|
||||
func TestDomainSeparation(t *testing.T) {
|
||||
master := []byte("0123456789abcdef0123456789abcdef")
|
||||
cfgCodec, err := security.NewCodec(master, "config-at-rest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherCodec, err := security.NewCodec(master, "some-other-domain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed, err := cfgCodec.Seal("hello")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := otherCodec.Open(sealed); err == nil {
|
||||
t.Fatal("不同域的子密钥不应能解开 —— 域隔离失效")
|
||||
}
|
||||
if got, _ := cfgCodec.Open(sealed); got != "hello" {
|
||||
t.Fatal("同域应可解")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**ToConfig 在加密开启后仍能取到真 api_key**。
|
||||
//
|
||||
// 这是最容易被加密改坏的一条:ToConfig 靠 Dump() 重建 LLMSource。
|
||||
// 若 Dump 改返回掩码/密文,LLM 就会拿着垃圾去调上游 —— 而且
|
||||
// 失败现象是「认证失败」,与配置加密毫无关联,归因极难。
|
||||
func TestToConfigStillGetsRealAPIKey(t *testing.T) {
|
||||
r := newSecReg(t)
|
||||
const key = "sk-real-key-for-upstream-123456"
|
||||
if err := r.Set("core.llm.api_key", key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Set("core.llm.sources.probe.base_url", "https://example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Set("core.llm.sources.probe.api_key", key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Set("core.llm.sources.probe.model", "m1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := r.ToConfig()
|
||||
if cfg.LLM.APIKey != key {
|
||||
t.Fatalf("ToConfig 的 APIKey 不真:%q", cfg.LLM.APIKey)
|
||||
}
|
||||
var found bool
|
||||
for _, s := range cfg.LLM.Sources {
|
||||
if s.Name == "probe" {
|
||||
found = true
|
||||
if s.APIKey != key {
|
||||
t.Fatalf("source api_key 不真:%q", s.APIKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("source probe 丢失")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**数量类键名不得被误判为敏感**。
|
||||
//
|
||||
// 这是真机跑出来的缺陷:初版用 strings.Contains(key,"token"),
|
||||
// 把 core.llm.max_tokens(输出上限)与
|
||||
// core.agent.context.max_target_tokens(上下文预算)也加密了。
|
||||
// 功能上无害,但语义错误、且库变得无法人工检查。
|
||||
func TestNonSecretTokenCountsNotEncrypted(t *testing.T) {
|
||||
r := newSecReg(t)
|
||||
benign := []string{
|
||||
"core.llm.max_tokens",
|
||||
"core.agent.context.max_target_tokens",
|
||||
"core.compact.target_tokens",
|
||||
"core.keyboard_layout", // 含 "key" 但不是凭据
|
||||
"core.monkey_mode", // 同上
|
||||
"webui.listen_addr",
|
||||
"core.llm.temperature",
|
||||
}
|
||||
for _, k := range benign {
|
||||
if r.isSecretKey(k) {
|
||||
t.Errorf("%q 被误判为敏感(会被无谓加密)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:真敏感键名必须命中(含无 def 的插件键)。
|
||||
func TestSecretKeyNamesDetected(t *testing.T) {
|
||||
r := newSecReg(t)
|
||||
sensitive := []string{
|
||||
"core.llm.api_key",
|
||||
"core.llm.sources.x.access_token",
|
||||
"plugin.qq.token",
|
||||
"plugin.demo.password",
|
||||
"plugin.a.client_secret",
|
||||
"plugin.b.apikey",
|
||||
"plugin.c.key",
|
||||
"core.memory.multimodal_space.options.api_key",
|
||||
}
|
||||
for _, k := range sensitive {
|
||||
if !r.isSecretKey(k) {
|
||||
t.Errorf("%q 应被判定为敏感", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:显式加密 API(SetSecret/GetSecret)**不看键名**也加密。
|
||||
//
|
||||
// 存在意义:插件可能用中性键名(blob/payload/conn)存凭据,
|
||||
// 键名模式猜不到 ⇒ 必须能显式声明意图。
|
||||
func TestExplicitSetSecretIgnoresKeyName(t *testing.T) {
|
||||
r := newSecReg(t)
|
||||
ps := r.PluginConfig("demo3")
|
||||
ps.RegisterDef(ConfigDef{Key: "enabled", Default: "1"})
|
||||
|
||||
const neutral = "blob" // 中性键名,模式判定为**非敏感**
|
||||
if r.isSecretKey("plugin.demo3." + neutral) {
|
||||
t.Fatal("前提失效:blob 不该被模式判定为敏感")
|
||||
}
|
||||
|
||||
const secret = "creds-payload-with-neutral-key-name"
|
||||
if err := ps.SetSecret(neutral, secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 磁盘上是密文
|
||||
var raw string
|
||||
if err := r.db.QueryRow(`SELECT value FROM config_demo3 WHERE key = ?`, neutral).Scan(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !security.IsEncrypted(raw) {
|
||||
t.Fatalf("SetSecret 应强制加密,实际 %q", raw)
|
||||
}
|
||||
// GetSecret 读回明文
|
||||
got, err := ps.GetSecret(neutral)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != secret {
|
||||
t.Fatalf("GetSecret 应返回明文,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**密钥未装配时 SetSecret 拒绝写入**(不静默存明文)。
|
||||
//
|
||||
// 这是最危险的静默降级:插件以为存了密文,实际磁盘上是明文。
|
||||
func TestSetSecretRefusesWithoutKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
security.ClearConfigCodec()
|
||||
defer testCodec(t) // 复原,避免影响其它判据
|
||||
|
||||
r := NewConfigRegistry(filepath.Join(dir, "c.db"))
|
||||
ps := r.PluginConfig("demo4")
|
||||
ps.RegisterDef(ConfigDef{Key: "enabled", Default: "1"})
|
||||
|
||||
err := ps.SetSecret("blob", "must-not-be-plain")
|
||||
if err == nil {
|
||||
t.Fatal("无密钥时 SetSecret 必须报错,不能静默写明文")
|
||||
}
|
||||
// 且确实没写进去
|
||||
var cnt int
|
||||
r.db.QueryRow(`SELECT COUNT(*) FROM config_demo4 WHERE key = 'blob'`).Scan(&cnt)
|
||||
if cnt != 0 {
|
||||
t.Error("拒绝写入后库里不应有该键")
|
||||
}
|
||||
// 空值是允许的(删除语义)
|
||||
if err := ps.SetSecret("blob", ""); err != nil {
|
||||
t.Errorf("空值应允许写入: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:SetSecret 写入的值能被普通 Get 读到(两种读法互通,不产生孤岛)。
|
||||
func TestSetSecretReadableByGet(t *testing.T) {
|
||||
r := newSecReg(t)
|
||||
ps := r.PluginConfig("demo5")
|
||||
ps.RegisterDef(ConfigDef{Key: "enabled", Default: "1"})
|
||||
if err := ps.SetSecret("blob", "value-xyz-123456"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := ps.Get("blob") // Get 会走 openFromStorage
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(v) != "value-xyz-123456" {
|
||||
t.Fatalf("Get 应能读到 SetSecret 写的值,实际 %v", v)
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ package client
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
@ -36,6 +37,8 @@ type PushHandler func(reqID, typ, payload, meta string)
|
||||
// 授权状态由设备端本地存储(客户端鉴权),服务端不存储;
|
||||
// 未授权时收到 cmd 直接拒绝执行并回执 error。
|
||||
type Bridge struct {
|
||||
stopOnce sync.Once
|
||||
doneOnce sync.Once
|
||||
mu sync.RWMutex
|
||||
gateway string
|
||||
token string
|
||||
@ -297,11 +300,11 @@ func (b *Bridge) Stop() {
|
||||
if !b.started {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-b.stopCh:
|
||||
// 幂等(本仓 close 纪律)。原写法是
|
||||
// select { case <-b.stopCh: return; default: close(b.stopCh) }
|
||||
// 单线程安全,但**并发**下两个 goroutine 可同时落到 default ⇒ 双 close panic。
|
||||
if !safeclose.Safe(&b.stopOnce, b.stopCh) {
|
||||
return
|
||||
default:
|
||||
close(b.stopCh)
|
||||
}
|
||||
if b.ws != nil {
|
||||
_ = b.ws.close()
|
||||
@ -442,7 +445,7 @@ func (b *Bridge) readLoop() {
|
||||
b.ws = nil
|
||||
}
|
||||
b.mu.Unlock()
|
||||
close(b.doneCh)
|
||||
safeclose.Safe(&b.doneOnce, b.doneCh)
|
||||
}()
|
||||
|
||||
for {
|
||||
|
||||
@ -7,9 +7,11 @@ import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -20,12 +22,12 @@ func sinkPath(dataDir string) string {
|
||||
|
||||
// Status worker 上报给 guard 的自诊断快照。
|
||||
type Status struct {
|
||||
PID int `json:"pid"`
|
||||
Boot string `json:"boot"` // normal | failback
|
||||
UptimeSec int64 `json:"uptime_sec"` // 进程存活秒数
|
||||
LLMOK *bool `json:"llm_ok,omitempty"` // rescue 源可达性(nil=未探)
|
||||
Tools int `json:"tools"` // 已注册工具数
|
||||
LastDiag string `json:"last_diag,omitempty"` // 最近一次自诊断结论(如 diag_loc.cause)
|
||||
PID int `json:"pid"`
|
||||
Boot string `json:"boot"` // normal | failback
|
||||
UptimeSec int64 `json:"uptime_sec"` // 进程存活秒数
|
||||
LLMOK *bool `json:"llm_ok,omitempty"` // rescue 源可达性(nil=未探)
|
||||
Tools int `json:"tools"` // 已注册工具数
|
||||
LastDiag string `json:"last_diag,omitempty"` // 最近一次自诊断结论(如 diag_loc.cause)
|
||||
}
|
||||
|
||||
// StatusFunc 组装 worker 当前状态。
|
||||
@ -33,11 +35,12 @@ type StatusFunc func() *Status
|
||||
|
||||
// Server worker 侧:监听 unix socket,处理 PING→ACK。
|
||||
type Server struct {
|
||||
path string
|
||||
ln net.Listener
|
||||
status StatusFunc
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
doneOnce sync.Once
|
||||
path string
|
||||
ln net.Listener
|
||||
status StatusFunc
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewServer 创建心跳服务端(尚未 Listen,见 Start)。
|
||||
@ -63,7 +66,7 @@ func (s *Server) Start() error {
|
||||
}
|
||||
|
||||
func (s *Server) acceptLoop() {
|
||||
defer close(s.done)
|
||||
defer safeclose.Safe(&s.doneOnce, s.done)
|
||||
for {
|
||||
conn, err := s.ln.Accept()
|
||||
if err != nil {
|
||||
@ -127,7 +130,7 @@ func (c *Client) Ping(timeout time.Duration) (*Status, error) {
|
||||
return nil, fmt.Errorf("ipc: empty ACK")
|
||||
}
|
||||
var resp struct {
|
||||
Type string `json:"type"`
|
||||
Type string `json:"type"`
|
||||
Status *Status `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(sc.Bytes(), &resp); err != nil {
|
||||
@ -140,4 +143,4 @@ func (c *Client) Ping(timeout time.Duration) (*Status, error) {
|
||||
}
|
||||
|
||||
// Close 无状态(客户端用完即释放连接)。
|
||||
func (c *Client) Close() {}
|
||||
func (c *Client) Close() {}
|
||||
|
||||
77
internal/kernelboot/funcmodule.go
Normal file
77
internal/kernelboot/funcmodule.go
Normal file
@ -0,0 +1,77 @@
|
||||
package kernelboot
|
||||
|
||||
// 把既有的内核 `init*` 函数**适配**成 Module,而不必重写它们的签名。
|
||||
//
|
||||
// ## 为什么不直接改 init* 的签名
|
||||
//
|
||||
// `init*` 函数是**既有代码**,各有不同签名(有的返回 1 个值、有的 2 个、
|
||||
// 有的带 close 函数、有的只做副作用)。逐个改签名意味着:
|
||||
// · 大面积改动(评审困难、易引入回归)
|
||||
// · 且 `init*` 函数本身是清晰的(`initLuaVM(cfg) (*VM, func())`)——
|
||||
// 为迁就调度器的接口而把它改丑,是**倒置的取舍**
|
||||
//
|
||||
// 所以本文件提供**适配层**:声明式地描述"这个模块的名字/依赖/产物/怎么调用",
|
||||
// `init*` 函数一行都不改。
|
||||
//
|
||||
// ## 设计:FuncModule
|
||||
//
|
||||
// ```go
|
||||
// kernelboot.FuncModule(kernelboot.FuncSpec{
|
||||
// Name: "lua",
|
||||
// Requires: []string{"config"},
|
||||
// Provides: []string{"lua"},
|
||||
// Init: func(ks *kernelboot.KernelState) error {
|
||||
// vm, closeFn := initLuaVM(ks.Cfg) // 依赖的产物:直接读字段
|
||||
// ks.LuaVM = vm // 自己的产物:写回字段
|
||||
// ks.AddCloser(closeFn) // 关闭函数统一登记
|
||||
// return nil
|
||||
// },
|
||||
// })
|
||||
// ```
|
||||
//
|
||||
// ## 为什么"关闭函数"也要作为产物发布
|
||||
//
|
||||
// `init*` 常返回 `(value, func())`:value 要用,close 要在停机时调。
|
||||
// 若适配器只发布 value,**close 就丢了** ⇒ 资源泄漏(实测有先例:
|
||||
// tracker 的 overlay 挂载、memory stack 的关闭)。
|
||||
//
|
||||
// 把 close 也当产物(约定名 `<facility>.close`)的好处:
|
||||
// 停机时统一遍历容器调用所有 close,**不依赖人记得在 main 末尾手写**。
|
||||
// 这正好顺手解决了 main() 里那一堆 `defer closeMem()` 的散落问题。
|
||||
|
||||
// FuncSpec 描述一个由函数适配的模块。
|
||||
type FuncSpec struct {
|
||||
Name string
|
||||
Requires []string
|
||||
Provides []string
|
||||
// Init 执行装配:从 ctx 取依赖、调既有函数、把产物 Put 回 ctx。
|
||||
Init func(ks *KernelState) error
|
||||
}
|
||||
|
||||
// funcModule 是 FuncSpec 的 Module 实现。
|
||||
type funcModule struct {
|
||||
spec FuncSpec
|
||||
}
|
||||
|
||||
func (m *funcModule) Name() string { return m.spec.Name }
|
||||
func (m *funcModule) Provides() []string { return m.spec.Provides }
|
||||
func (m *funcModule) Requires() []string { return m.spec.Requires }
|
||||
func (m *funcModule) Init(ks *KernelState) error {
|
||||
if m.spec.Init == nil {
|
||||
return nil
|
||||
}
|
||||
return m.spec.Init(ks)
|
||||
}
|
||||
|
||||
// FuncModule 创建一个由函数适配的模块。
|
||||
func FuncModule(spec FuncSpec) Module {
|
||||
return &funcModule{spec: spec}
|
||||
}
|
||||
|
||||
// --- 关闭函数的统一回收 ---
|
||||
//
|
||||
// 约定:模块在 Init 里用 ks.AddCloser(fn) 注册关闭函数。
|
||||
// 停机时由 KernelState.CloseAll 按**注册顺序的逆序**执行(LIFO,与 defer 一致)。
|
||||
//
|
||||
// ★ 为何不再用"设施名以 .close 结尾"的约定:那个约定要靠字符串判断,
|
||||
// 而结构体方案下直接 AddCloser 更直白(且不会因为设施改名而失效)。
|
||||
443
internal/kernelboot/graphfile.go
Normal file
443
internal/kernelboot/graphfile.go
Normal file
@ -0,0 +1,443 @@
|
||||
package kernelboot
|
||||
|
||||
// 依赖图的**制品化**:编译期产出的精密二进制 + 双重 hash 保护。
|
||||
//
|
||||
// 设计权威:docs/zh/kernel-modularization-design.md §2.5。
|
||||
//
|
||||
// ## 两个不同的 hash,各防一类故障(★ 关键区分)
|
||||
//
|
||||
// ① 图内自校验 hash 防「图文件损坏」(磁盘错、传输截断、位翻转)
|
||||
// ② 内嵌 hash(二进制) 防「图与二进制不匹配」(改了 Requires 但图没重生成)
|
||||
//
|
||||
// ★ 只做 ① 会漏掉更危险的那类:图文件**自身完好**、自校验也过,
|
||||
// 但它描述的是**旧代码** ⇒ 按旧图调度 ⇒ 顺序错 ⇒ 正是本设计要消灭的病 A。
|
||||
//
|
||||
// 所以 §2.5.2 要求 hash 绑定二进制,且不一致时**拒绝启动**(不猜哪边对)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 文件格式常量。
|
||||
const (
|
||||
// graphMagic 是格式标识。破坏性变更时改末位数字。
|
||||
graphMagic = "HGDEPS01"
|
||||
// graphFormatVersion 是格式版本(与 magic 末位同步维护)。
|
||||
graphFormatVersion = 2
|
||||
// GraphFileName 是制品文件名(与内核二进制同目录,满足"随核心分发")。
|
||||
GraphFileName = "depgraph.bin"
|
||||
|
||||
// maxFieldLen 是单个字符串字段的长度上限(防损坏文件声明一个超大长度)。
|
||||
//
|
||||
// 为何必须设限:一个损坏的长度字段会让 make([]byte, n) 申请巨量内存
|
||||
// (或直接 OOM)。解析器对**不可信输入**必须设边界。
|
||||
maxFieldLen = 4096
|
||||
// maxModules 是模块数上限(同上,防损坏文件声明一个巨大计数)。
|
||||
maxModules = 4096
|
||||
)
|
||||
|
||||
// GraphEdge 是图中一条模块声明(用于序列化)。
|
||||
type GraphEdge struct {
|
||||
Name string
|
||||
Provides []string
|
||||
Requires []string
|
||||
|
||||
// Layer 是**编译期算好的起始层号**(设计 §2.5,用户要求:
|
||||
// "启动顺序是编译期间按照声明的依赖顺序生成的启动依赖图排列的")。
|
||||
//
|
||||
// ## 为什么把层号也编码进图(而不只存边)
|
||||
//
|
||||
// 若只存边,运行期就还得跑一次 BFS 才算得出顺序 —— 那等于
|
||||
// **内核在运行期决定启动顺序**,与"编译期定序"的设计相悖
|
||||
// (且运行期算法与编译期不一致时无人察觉)。
|
||||
//
|
||||
// 存下 Layer 后:运行期**读**它即可,不重算。
|
||||
// `Build()` 仍保留(单测/无图场景用),但**生产路径不调它**。
|
||||
//
|
||||
// ★ 自校验:读图时仍会重算分层并与 Layer 比对(CheckLayers),
|
||||
// 不一致 ⇒ 拒绝启动。这样"图里的顺序"与"声明"不会漂移。
|
||||
Layer int
|
||||
}
|
||||
|
||||
// GraphFile 是图制品的解析结果。
|
||||
type GraphFile struct {
|
||||
Version int
|
||||
Modules []GraphEdge
|
||||
// Digest 是文件内自校验 hash 的十六进制(§2.5.2 的 ①)。
|
||||
Digest string
|
||||
}
|
||||
|
||||
// buildGraphPayload 产出**参与 hash 与序列化**的全部字节(不含自校验 hash 本身)。
|
||||
//
|
||||
// ★ 所有字段**排序**:同一份声明必须产出逐字节相同的结果。
|
||||
// 否则代码里调换两个模块的顺序就会让 hash 变化 ⇒
|
||||
// "每次构建都说不一致" ⇒ hash 失去意义(人开始无视它)。
|
||||
func buildGraphPayload(modules []GraphEdge) []byte {
|
||||
// 深拷贝并排序(不改调用方的切片)
|
||||
ms := make([]GraphEdge, len(modules))
|
||||
copy(ms, modules)
|
||||
for i := range ms {
|
||||
ms[i].Provides = sortedCopy(ms[i].Provides)
|
||||
ms[i].Requires = sortedCopy(ms[i].Requires)
|
||||
}
|
||||
// ★ 排序键是 **(Layer, Name)** —— 不是只按名字。
|
||||
//
|
||||
// 这样**解码后的顺序就是启动顺序**(先按层,层内按名字),
|
||||
// 运行期读到即可直接执行,不必重算分层。
|
||||
//
|
||||
// 层内按名字是为了**可复现**(同一份声明两次产出逐字节相同 ⇒ 能进 PR diff)。
|
||||
sort.Slice(ms, func(i, j int) bool {
|
||||
if ms[i].Layer != ms[j].Layer {
|
||||
return ms[i].Layer < ms[j].Layer
|
||||
}
|
||||
return ms[i].Name < ms[j].Name
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(graphMagic)
|
||||
_ = binary.Write(&buf, binary.LittleEndian, uint32(graphFormatVersion))
|
||||
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(ms)))
|
||||
|
||||
writeStr := func(s string) {
|
||||
_ = binary.Write(&buf, binary.LittleEndian, uint16(len(s)))
|
||||
buf.WriteString(s)
|
||||
}
|
||||
for _, m := range ms {
|
||||
writeStr(m.Name)
|
||||
writeStr(strings.Join(m.Provides, ","))
|
||||
writeStr(strings.Join(m.Requires, ","))
|
||||
// Layer 作为 uint32 追加在每条记录末尾。
|
||||
//
|
||||
// ★ 放在末尾而不是开头:这样"旧版解码逻辑"遇到新文件时
|
||||
// 会在**尾部残留检查**处报错(响亮),而不是把 layer 当名字读错。
|
||||
_ = binary.Write(&buf, binary.LittleEndian, uint32(m.Layer))
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func sortedCopy(in []string) []string {
|
||||
out := append([]string(nil), in...)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// EncodeGraph 把模块声明序列化成制品字节(含自校验 hash)。
|
||||
func EncodeGraph(modules []GraphEdge) []byte {
|
||||
payload := buildGraphPayload(modules)
|
||||
sum := sha256.Sum256(payload)
|
||||
out := make([]byte, 0, len(payload)+sha256.Size)
|
||||
out = append(out, payload...)
|
||||
out = append(out, sum[:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// GraphDigest 返回制品自校验 hash 的十六进制(不含文件本身)。
|
||||
//
|
||||
// 供构建期把 digest 注入二进制(§2.5.2 的 ②)。
|
||||
func GraphDigest(modules []GraphEdge) string {
|
||||
sum := sha256.Sum256(buildGraphPayload(modules))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// DecodeGraph 解析制品字节,校验自校验 hash。
|
||||
//
|
||||
// 任何结构异常都**报错**(不返回"部分结果"):
|
||||
// 图是启动调度的唯一依据,残缺的图比没有图更危险
|
||||
// (它会生成一个**看似合法**的调度计划)。
|
||||
func DecodeGraph(data []byte) (*GraphFile, error) {
|
||||
if len(data) < len(graphMagic)+8+sha256.Size {
|
||||
return nil, fmt.Errorf("kernelboot: 依赖图文件过短(%d 字节),已损坏", len(data))
|
||||
}
|
||||
// ① 先校验自校验 hash(防截断/位翻转)
|
||||
payload := data[:len(data)-sha256.Size]
|
||||
wantSum := data[len(data)-sha256.Size:]
|
||||
gotSum := sha256.Sum256(payload)
|
||||
if !bytes.Equal(gotSum[:], wantSum) {
|
||||
return nil, fmt.Errorf("kernelboot: 依赖图自校验失败(文件已损坏)")
|
||||
}
|
||||
|
||||
// ② 解析
|
||||
if string(payload[:len(graphMagic)]) != graphMagic {
|
||||
return nil, fmt.Errorf("kernelboot: 依赖图格式标识不匹配(期望 %q,得到 %q)——"+
|
||||
"文件可能来自不兼容的版本", graphMagic, string(payload[:len(graphMagic)]))
|
||||
}
|
||||
off := len(graphMagic)
|
||||
rd32 := func() uint32 {
|
||||
v := binary.LittleEndian.Uint32(payload[off:])
|
||||
off += 4
|
||||
return v
|
||||
}
|
||||
ver := rd32()
|
||||
if ver != graphFormatVersion {
|
||||
return nil, fmt.Errorf("kernelboot: 依赖图格式版本 %d 与本二进制期望的 %d 不一致——"+
|
||||
"请用配套的生成器重出图", ver, graphFormatVersion)
|
||||
}
|
||||
n := rd32()
|
||||
if n > maxModules {
|
||||
return nil, fmt.Errorf("kernelboot: 依赖图声明 %d 个模块,超过上限 %d(文件已损坏)",
|
||||
n, maxModules)
|
||||
}
|
||||
|
||||
readStr := func() (string, error) {
|
||||
if off+2 > len(payload) {
|
||||
return "", fmt.Errorf("kernelboot: 依赖图在读取字符串长度时越界")
|
||||
}
|
||||
l := int(binary.LittleEndian.Uint16(payload[off:]))
|
||||
off += 2
|
||||
if l > maxFieldLen {
|
||||
return "", fmt.Errorf("kernelboot: 依赖图字段长度 %d 超过上限 %d(文件已损坏)",
|
||||
l, maxFieldLen)
|
||||
}
|
||||
if off+l > len(payload) {
|
||||
return "", fmt.Errorf("kernelboot: 依赖图字段越界(需要 %d 字节,剩余 %d)",
|
||||
l, len(payload)-off)
|
||||
}
|
||||
s := string(payload[off : off+l])
|
||||
off += l
|
||||
return s, nil
|
||||
}
|
||||
|
||||
gf := &GraphFile{Version: int(ver), Digest: hex.EncodeToString(gotSum[:])}
|
||||
for i := uint32(0); i < n; i++ {
|
||||
name, err := readStr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
provs, err := readStr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqs, err := readStr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if off+4 > len(payload) {
|
||||
return nil, fmt.Errorf("kernelboot: 依赖图在读取层号时越界(文件已损坏)")
|
||||
}
|
||||
layer := int(binary.LittleEndian.Uint32(payload[off:]))
|
||||
off += 4
|
||||
gf.Modules = append(gf.Modules, GraphEdge{
|
||||
Name: name,
|
||||
Provides: splitNonEmpty(provs),
|
||||
Requires: splitNonEmpty(reqs),
|
||||
Layer: layer,
|
||||
})
|
||||
}
|
||||
if off != len(payload) {
|
||||
return nil, fmt.Errorf("kernelboot: 依赖图有 %d 字节尾部残留(文件格式不符)",
|
||||
len(payload)-off)
|
||||
}
|
||||
return gf, nil
|
||||
}
|
||||
|
||||
func splitNonEmpty(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CheckAgainstBinary 校验磁盘上的图与**本二进制**是否配套(§2.5.2)。
|
||||
//
|
||||
// embeddedHash 是构建期注入的 digest(空 = 开发环境,跳过校验)。
|
||||
//
|
||||
// ★ 不一致时**拒绝启动**,不猜哪边对:
|
||||
// 图的作者是构建流程,二进制的作者也是它。两者不一致意味着制品装配出了问题,
|
||||
// 此时任何一边都不可信,猜一个是拿启动正确性赌运气。
|
||||
func CheckAgainstBinary(gf *GraphFile, embeddedHash string) error {
|
||||
if embeddedHash == "" {
|
||||
// 开发/单测环境:构建脚本没注入 digest ⇒ 不做配套校验。
|
||||
// 这让 `go test` / `go run` 无需先生成图。
|
||||
return nil
|
||||
}
|
||||
if gf == nil {
|
||||
return fmt.Errorf("kernelboot: 缺少依赖图,而本二进制要求必须有(内嵌 hash=%s)——"+
|
||||
"生产环境不允许回退到运行期重建", embeddedHash[:min(8, len(embeddedHash))])
|
||||
}
|
||||
if !strings.EqualFold(gf.Digest, embeddedHash) {
|
||||
return fmt.Errorf("kernelboot: ★ 依赖图与本二进制**不配套**\n"+
|
||||
" 磁盘图的 hash: %s\n"+
|
||||
" 二进制内嵌的: %s\n"+
|
||||
"最常见原因:改了模块的 Requires/Provides 但**没有重新生成依赖图**。\n"+
|
||||
"修复:重新运行构建(会重出图并注入新 hash),不要手工改图文件。",
|
||||
gf.Digest, embeddedHash)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// EmbeddedGraphHash 由**构建期**用 -ldflags 注入(设计 §2.5.2)。
|
||||
//
|
||||
// -X github.com/JianFeeeee/HomeAgent/internal/kernelboot.EmbeddedGraphHash=<digest>
|
||||
//
|
||||
// 空值(默认)= 开发/单测环境:不做"图与二进制配套"校验,
|
||||
// 允许 homed 回退到运行期从声明重建图。
|
||||
//
|
||||
// ★ 为何默认空而不是某个占位值:`go test` / `go run` 不可能先跑生成器。
|
||||
// 若默认非空,所有本地开发与判据都会因"图不存在"而失败 ——
|
||||
// 那会逼着人绕过检查(比没有检查更糟)。
|
||||
var EmbeddedGraphHash string
|
||||
|
||||
// PlanFromGraph 从**编译期产出的图**构造启动计划(设计 §2.5,用户要求:
|
||||
// "启动顺序是编译期间按照声明的依赖顺序生成的启动依赖图排列的")。
|
||||
//
|
||||
// ## 与 Build() 的区别(这是本函数存在的理由)
|
||||
//
|
||||
// Build(mods) ⇒ 运行期**现算**分层(依赖 BFS 算法)
|
||||
// PlanFromGraph(gf, …) ⇒ **读**编译期算好的层号,不重算
|
||||
//
|
||||
// 按用户设计,生产启动路径走后者:**顺序在编译期定,运行期只执行**。
|
||||
//
|
||||
// ## ★ 自校验:仍会重算一次并比对(防"图与代码漂移")
|
||||
//
|
||||
// 只读不验会有个危险:图里写的是旧顺序(改了 Requires 但没重出图),
|
||||
// 而文件自身 hash 完全有效 ⇒ 按**旧顺序**启动(正是 §2.5.2 的病 A)。
|
||||
//
|
||||
// 所以本函数:
|
||||
//
|
||||
// ① 从图里取模块与层号
|
||||
// ② 用**图里的边**重算一次分层(Build)
|
||||
// ③ 比对:重算结果与图中层号不一致 ⇒ **拒绝启动**
|
||||
//
|
||||
// 第 ③ 步能同时抓两类问题:图内自相矛盾(边与层号不符)、
|
||||
// 生成器算法与运行期算法不一致。**两者都是制品装配错误,不该猜哪边对。**
|
||||
//
|
||||
// ## 参数 mods
|
||||
//
|
||||
// 是**当前二进制里真实登记的模块**(来自 kernelmod 注册表)。
|
||||
// 它有两个作用:
|
||||
//
|
||||
// · 校验"图里描述的模块"与"本二进制实际有的模块"一致
|
||||
// (防:图是新的但二进制是旧的,或反之)
|
||||
// · 提供实现体(图只有名字,没有 Init)
|
||||
func PlanFromGraph(gf *GraphFile, mods []Module) (*Plan, error) {
|
||||
if gf == nil {
|
||||
return nil, fmt.Errorf("kernelboot: 依赖图为 nil")
|
||||
}
|
||||
// ① 建立 名字 → 实现 的映射
|
||||
byName := make(map[string]Module, len(mods))
|
||||
for _, m := range mods {
|
||||
if _, dup := byName[m.Name()]; dup {
|
||||
return nil, fmt.Errorf("kernelboot: 模块名重复: %s", m.Name())
|
||||
}
|
||||
byName[m.Name()] = m
|
||||
}
|
||||
|
||||
// ② 图与二进制必须描述**同一组模块**
|
||||
var missingInBinary, missingInGraph []string
|
||||
for _, e := range gf.Modules {
|
||||
if _, ok := byName[e.Name]; !ok {
|
||||
missingInBinary = append(missingInBinary, e.Name)
|
||||
}
|
||||
}
|
||||
inGraph := make(map[string]bool, len(gf.Modules))
|
||||
for _, e := range gf.Modules {
|
||||
inGraph[e.Name] = true
|
||||
}
|
||||
for _, m := range mods {
|
||||
if !inGraph[m.Name()] {
|
||||
missingInGraph = append(missingInGraph, m.Name())
|
||||
}
|
||||
}
|
||||
if len(missingInBinary) > 0 || len(missingInGraph) > 0 {
|
||||
sort.Strings(missingInBinary)
|
||||
sort.Strings(missingInGraph)
|
||||
return nil, fmt.Errorf("kernelboot: 依赖图与二进制不一致 —— "+
|
||||
"图里有但二进制没有: %v;二进制有但图里没有: %v。"+
|
||||
"这通常意味着**图与二进制不是同一次构建产出的**(请重新构建)",
|
||||
missingInBinary, missingInGraph)
|
||||
}
|
||||
|
||||
// ③ 自校验:用图里的边重算分层,与图中层号比对
|
||||
edges := make([]GraphEdge, len(gf.Modules))
|
||||
copy(edges, gf.Modules)
|
||||
recomputed, err := Build(mods)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kernelboot: 无法用当前模块重算分层(声明之间有冲突): %w", err)
|
||||
}
|
||||
layerOf := map[string]int{}
|
||||
for i, l := range recomputed.Layers {
|
||||
for _, m := range l {
|
||||
layerOf[m.Name()] = i
|
||||
}
|
||||
}
|
||||
var drift []string
|
||||
for _, e := range gf.Modules {
|
||||
if got, ok := layerOf[e.Name]; ok && got != e.Layer {
|
||||
drift = append(drift, fmt.Sprintf("%s(图中第 %d 层,按声明算应为第 %d 层)",
|
||||
e.Name, e.Layer, got))
|
||||
}
|
||||
}
|
||||
if len(drift) > 0 {
|
||||
sort.Strings(drift)
|
||||
return nil, fmt.Errorf("kernelboot: ★ 依赖图与代码声明**漂移** —— %s。"+
|
||||
"说明改了 Requires/Provides 但**没有重新生成图**。"+
|
||||
"按此图启动会得到错误的顺序(§2.5.2 的病 A),故拒绝启动。"+
|
||||
"修复:重新执行构建(make build 会重出图并注入 hash)",
|
||||
strings.Join(drift, ";"))
|
||||
}
|
||||
|
||||
// ④ 按图中的 (Layer, Name) 组装计划 —— **直接读**,不再排序/重算
|
||||
sorted := make([]GraphEdge, len(gf.Modules))
|
||||
copy(sorted, gf.Modules)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
if sorted[i].Layer != sorted[j].Layer {
|
||||
return sorted[i].Layer < sorted[j].Layer
|
||||
}
|
||||
return sorted[i].Name < sorted[j].Name
|
||||
})
|
||||
plan := &Plan{}
|
||||
for _, e := range sorted {
|
||||
for len(plan.Layers) <= e.Layer {
|
||||
plan.Layers = append(plan.Layers, nil)
|
||||
}
|
||||
plan.Layers[e.Layer] = append(plan.Layers[e.Layer], byName[e.Name])
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// LoadAndCheckGraph 读图并做"图与二进制配套"校验(§2.5.2)。
|
||||
//
|
||||
// path 为空时用默认位置(与二进制同目录,见 GraphFileName)。
|
||||
// embeddedHash 来自 -ldflags 注入(EmbeddedGraphHash)。
|
||||
//
|
||||
// 缺失策略(§2.5.4):dev(embeddedHash=="")允许没有图 ⇒ 返回 (nil, nil),
|
||||
// 由调用方回退到运行期 Build;生产(有 hash)必须有图,否则报错。
|
||||
func LoadAndCheckGraph(path, embeddedHash string) (*GraphFile, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if embeddedHash == "" {
|
||||
return nil, nil // dev:没有图是正常的
|
||||
}
|
||||
return nil, fmt.Errorf("kernelboot: 生产构建(内嵌 hash 非空)但读不到依赖图 %s: %w —— "+
|
||||
"图必须与二进制同目录(部署时一起拷贝)", path, err)
|
||||
}
|
||||
gf, err := DecodeGraph(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := CheckAgainstBinary(gf, embeddedHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gf, nil
|
||||
}
|
||||
291
internal/kernelboot/graphfile_test.go
Normal file
291
internal/kernelboot/graphfile_test.go
Normal file
@ -0,0 +1,291 @@
|
||||
package kernelboot
|
||||
|
||||
// 依赖图制品化的判据(2026-10-16)。
|
||||
//
|
||||
// 设计见 docs/zh/kernel-modularization-design.md §2.5。
|
||||
//
|
||||
// 这组判据的重心是**两个不同 hash 各防一类故障**(§2.5.2):
|
||||
//
|
||||
// ① 图内自校验 ⇒ 防「图文件损坏」
|
||||
// ② 内嵌 hash ⇒ 防「图与二进制不匹配」
|
||||
//
|
||||
// ★ 只测 ① 是不够的:图文件自身完好、自校验也过,但它描述的是**旧代码**
|
||||
// ⇒ 按旧图调度 ⇒ 顺序错 ⇒ 正是本设计要消灭的病 A。
|
||||
// 所以 ② 必须有独立判据,且要证明它**真的会拦住不匹配**。
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func sampleEdges() []GraphEdge {
|
||||
return []GraphEdge{
|
||||
{Name: "security", Provides: []string{"crypto", "keystore"}},
|
||||
{Name: "config", Provides: []string{"config"}, Requires: []string{"crypto"}},
|
||||
{Name: "llm", Provides: []string{"llm"}, Requires: []string{"config", "keystore"}},
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:编解码**往返一致**(含嵌套依赖的形状)。
|
||||
func TestGraphEncodeDecodeRoundTrip(t *testing.T) {
|
||||
in := sampleEdges()
|
||||
data := EncodeGraph(in)
|
||||
gf, err := DecodeGraph(data)
|
||||
if err != nil {
|
||||
t.Fatalf("解码失败: %v", err)
|
||||
}
|
||||
if len(gf.Modules) != len(in) {
|
||||
t.Fatalf("模块数不符: %d vs %d", len(gf.Modules), len(in))
|
||||
}
|
||||
// 按名字比对(编码时排序了,所以顺序可能不同 —— 这是刻意的)
|
||||
byName := map[string]GraphEdge{}
|
||||
for _, m := range gf.Modules {
|
||||
byName[m.Name] = m
|
||||
}
|
||||
for _, want := range in {
|
||||
got, ok := byName[want.Name]
|
||||
if !ok {
|
||||
t.Fatalf("模块 %s 丢失", want.Name)
|
||||
}
|
||||
if strings.Join(got.Provides, ",") != strings.Join(sortedCopy(want.Provides), ",") {
|
||||
t.Errorf("%s 的 Provides 不符: %v vs %v", want.Name, got.Provides, want.Provides)
|
||||
}
|
||||
if strings.Join(got.Requires, ",") != strings.Join(sortedCopy(want.Requires), ",") {
|
||||
t.Errorf("%s 的 Requires 不符: %v vs %v", want.Name, got.Requires, want.Requires)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**同一份声明产出逐字节相同的结果**(hash 稳定的前提)。
|
||||
//
|
||||
// 为何必须独立判据:如果顺序影响字节,hash 在无害改动(代码里调换两个模块)
|
||||
// 后也会变 ⇒ "每次构建都说不一致" ⇒ 人开始无视告警 ⇒ hash 失去意义。
|
||||
func TestGraphEncodingIsOrderIndependent(t *testing.T) {
|
||||
a := sampleEdges()
|
||||
// 打乱顺序(含 Provides/Requires 数组内部顺序)
|
||||
b := []GraphEdge{
|
||||
{Name: "llm", Provides: []string{"llm"}, Requires: []string{"keystore", "config"}},
|
||||
{Name: "config", Provides: []string{"config"}, Requires: []string{"crypto"}},
|
||||
{Name: "security", Provides: []string{"keystore", "crypto"}},
|
||||
}
|
||||
da, db := EncodeGraph(a), EncodeGraph(b)
|
||||
if !bytesEqual(da, db) {
|
||||
t.Fatalf("★ 声明顺序不同导致字节不同(hash 会漂移,告警失去意义)\n"+
|
||||
"a=%x\nb=%x", da[:32], db[:32])
|
||||
}
|
||||
if GraphDigest(a) != GraphDigest(b) {
|
||||
t.Fatalf("★ digest 随声明顺序变化: %s vs %s", GraphDigest(a), GraphDigest(b))
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ **关键判据 ①**:图文件损坏必须被拦。
|
||||
func TestGraphDetectsFileCorruption(t *testing.T) {
|
||||
data := EncodeGraph(sampleEdges())
|
||||
|
||||
// ① 位翻转(模拟磁盘错误)
|
||||
flipped := append([]byte(nil), data...)
|
||||
flipped[len(flipped)/2] ^= 0x01
|
||||
if _, err := DecodeGraph(flipped); err == nil {
|
||||
t.Fatal("★ 位翻转未被检出(自校验失效)")
|
||||
}
|
||||
|
||||
// ② 截断(模拟传输不完整)
|
||||
if _, err := DecodeGraph(data[:len(data)-4]); err == nil {
|
||||
t.Fatal("★ 截断未被检出")
|
||||
}
|
||||
// ③ 尾部多余字节
|
||||
if _, err := DecodeGraph(append(append([]byte(nil), data...), 0xAA)); err == nil {
|
||||
t.Fatal("★ 尾部残留未被检出")
|
||||
}
|
||||
// ④ 空文件 / 过短
|
||||
for _, n := range []int{0, 8, 16} {
|
||||
if _, err := DecodeGraph(data[:n]); err == nil {
|
||||
t.Errorf("★ 长度为 %d 的输入未被拒", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ **关键判据 ②**:格式标识/版本不符必须被拦(防来自不兼容版本的图)。
|
||||
func TestGraphRejectsWrongMagicAndVersion(t *testing.T) {
|
||||
data := EncodeGraph(sampleEdges())
|
||||
// 改 magic 后重算自校验(模拟"图是完好的,但来自别的格式版本")
|
||||
bad := append([]byte(nil), data...)
|
||||
copy(bad[0:8], []byte("HGDEPS99"))
|
||||
// 重算末尾 hash 让它"自校验通过",从而**只有版本检查能拦住它**
|
||||
fixSelfHash(t, bad)
|
||||
if _, err := DecodeGraph(bad); err == nil {
|
||||
t.Fatal("★ 格式标识不符未被检出")
|
||||
} else if !strings.Contains(err.Error(), "格式标识") && !strings.Contains(err.Error(), "版本") {
|
||||
t.Errorf("错误应点明格式/版本问题: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:损坏的长度字段不得导致巨额分配(解析器对不可信输入要有界)。
|
||||
func TestGraphRejectsAbsurdLengths(t *testing.T) {
|
||||
// 手工构造:合法头部 + 一个超大长度
|
||||
//
|
||||
// ★ 必须构造成"自校验能通过"的样子,才能证明**长度检查**本身在起作用
|
||||
// (否则是自校验先拦下,判据测不到长度检查)。
|
||||
var b []byte
|
||||
b = append(b, []byte(graphMagic)...)
|
||||
b = append(b, 1, 0, 0, 0) // version = 1
|
||||
b = append(b, 1, 0, 0, 0) // modCount = 1
|
||||
b = append(b, 0xFF, 0xFF) // nameLen = 65535(超过 maxFieldLen=4096)
|
||||
b = append(b, make([]byte, 32)...) // 自校验 hash 占位(下面重算)
|
||||
fixSelfHash(t, b)
|
||||
if _, err := DecodeGraph(b); err == nil {
|
||||
t.Fatal("★ 超大字段长度未被拒(可能造成巨额分配)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ **最重要的判据**:图与二进制**不配套**必须被拦(§2.5.2 的情况 2)。
|
||||
//
|
||||
// 这是本设计的核心保护:图文件自身完好、自校验也过(所以 ① 拦不住),
|
||||
// 但它描述的是**旧代码** ⇒ 按旧图调度 ⇒ 顺序错 = 病 A。
|
||||
func TestGraphMismatchWithBinaryIsRejected(t *testing.T) {
|
||||
// 实际产出的图(当前代码)
|
||||
actual := EncodeGraph(sampleEdges())
|
||||
gf, err := DecodeGraph(actual)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 二进制内嵌的是**另一份声明**的 hash(模拟"改了代码但没重出图")
|
||||
oldCode := []GraphEdge{
|
||||
{Name: "security", Provides: []string{"crypto"}},
|
||||
{Name: "config", Provides: []string{"config"}, Requires: []string{"crypto"}},
|
||||
// 注意:llm 模块当时还不存在
|
||||
}
|
||||
staleHash := GraphDigest(oldCode)
|
||||
|
||||
err = CheckAgainstBinary(gf, staleHash)
|
||||
if err == nil {
|
||||
t.Fatal("★ 图与二进制不配套却通过了校验 —— 会按旧图调度(病 A 复发)")
|
||||
}
|
||||
// 错误必须给出**可执行的修复方向**
|
||||
for _, want := range []string{"不配套", "重新"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误应含 %q 以指导修复: %v", want, err)
|
||||
}
|
||||
}
|
||||
// 且必须同时打印两个 hash(便于人工核对是哪边旧了)
|
||||
if !strings.Contains(err.Error(), gf.Digest[:8]) || !strings.Contains(err.Error(), staleHash[:8]) {
|
||||
t.Errorf("错误应同时给出两个 hash 的前缀: %v", err)
|
||||
}
|
||||
|
||||
// 配套时必须通过
|
||||
if err := CheckAgainstBinary(gf, gf.Digest); err != nil {
|
||||
t.Errorf("配套的图不该被拒: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**开发环境**(未注入 hash)不做配套校验,但**生产**(已注入)缺图即错。
|
||||
//
|
||||
// 为何要区分:单测/`go run` 不可能先跑生成器;
|
||||
// 而生产缺图若静默回退到"运行期重建",就把 §2.5.2 要防的风险变成静默容忍。
|
||||
func TestGraphPresencePolicyByEnvironment(t *testing.T) {
|
||||
gf, _ := DecodeGraph(EncodeGraph(sampleEdges()))
|
||||
|
||||
// ① 开发:未注入 hash ⇒ 跳过校验(无论图在不在)
|
||||
if err := CheckAgainstBinary(gf, ""); err != nil {
|
||||
t.Errorf("开发环境(未注入 hash)不该做配套校验: %v", err)
|
||||
}
|
||||
if err := CheckAgainstBinary(nil, ""); err != nil {
|
||||
t.Errorf("开发环境缺图不该报错: %v", err)
|
||||
}
|
||||
|
||||
// ② 生产:已注入 hash ⇒ 缺图必须报错(不许回退到重建)
|
||||
err := CheckAgainstBinary(nil, "deadbeefdeadbeef")
|
||||
if err == nil {
|
||||
t.Fatal("★ 生产环境(已注入 hash)缺图却通过了 —— 会静默回退到运行期重建")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "生产") && !strings.Contains(err.Error(), "回退") {
|
||||
t.Errorf("错误应点明不允许回退: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**落盘后读回**(真实文件 IO 路径,不只是内存往返)。
|
||||
func TestGraphFileRoundTripOnDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, GraphFileName)
|
||||
|
||||
want := EncodeGraph(sampleEdges())
|
||||
if err := os.WriteFile(path, want, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gf, err := DecodeGraph(got)
|
||||
if err != nil {
|
||||
t.Fatalf("从磁盘读回后解码失败: %v", err)
|
||||
}
|
||||
if len(gf.Modules) != 3 {
|
||||
t.Errorf("模块数不符: %d", len(gf.Modules))
|
||||
}
|
||||
// 文件命名必须与文档一致(生产靠这个名字找图)
|
||||
if GraphFileName != "depgraph.bin" {
|
||||
t.Errorf("制品文件名应为 depgraph.bin(文档 §2.5.4),实际 %s", GraphFileName)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:嵌套依赖的形状在制品里**保真**(用户的明确要求)。
|
||||
//
|
||||
// 场景:llm 依赖 config,config 依赖 crypto(crypto 由 security 提供)
|
||||
// ⇒ 制品必须完整保留这条链,而不是被"展平"或丢失中间层。
|
||||
func TestGraphPreservesNestedDependencyChain(t *testing.T) {
|
||||
edges := []GraphEdge{
|
||||
{Name: "security", Provides: []string{"crypto"}},
|
||||
{Name: "config", Provides: []string{"config"}, Requires: []string{"crypto"}},
|
||||
{Name: "llm", Provides: []string{"llm"}, Requires: []string{"config"}},
|
||||
{Name: "agent", Provides: []string{"agent"}, Requires: []string{"llm"}},
|
||||
}
|
||||
gf, err := DecodeGraph(EncodeGraph(edges))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byName := map[string]GraphEdge{}
|
||||
for _, m := range gf.Modules {
|
||||
byName[m.Name] = m
|
||||
}
|
||||
// 逐环核对链条完整性
|
||||
if got := byName["config"].Requires; len(got) != 1 || got[0] != "crypto" {
|
||||
t.Errorf("config 的依赖链断裂: %v", got)
|
||||
}
|
||||
if got := byName["llm"].Requires; len(got) != 1 || got[0] != "config" {
|
||||
t.Errorf("llm 的依赖链断裂: %v", got)
|
||||
}
|
||||
if got := byName["agent"].Requires; len(got) != 1 || got[0] != "llm" {
|
||||
t.Errorf("agent 的依赖链断裂: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// fixSelfHash 重算末尾的 32 字节自校验 hash,让"非法内容但自校验通过"成为可能
|
||||
// (用于测试**格式检查**而不是 hash 检查)。
|
||||
//
|
||||
// 前提:data 长度 >= 32(即已有 32 字节 hash 尾巴)。用例需自行保证。
|
||||
func fixSelfHash(t *testing.T, data []byte) {
|
||||
t.Helper()
|
||||
if len(data) < 32 {
|
||||
t.Fatalf("判据构造错误:payload 太短(%d 字节),无法容纳 32 字节自校验 hash", len(data))
|
||||
}
|
||||
payload := data[:len(data)-32]
|
||||
sum := sha256.Sum256(payload)
|
||||
copy(data[len(data)-32:], sum[:])
|
||||
}
|
||||
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
402
internal/kernelboot/kernelboot.go
Normal file
402
internal/kernelboot/kernelboot.go
Normal file
@ -0,0 +1,402 @@
|
||||
// Package kernelboot 是内核模块的**声明式装配调度器**。
|
||||
//
|
||||
// 设计权威:docs/zh/kernel-modularization-design.md §2.3。
|
||||
//
|
||||
// ## 要解决的问题(实测,2026-10-16)
|
||||
//
|
||||
// `cmd/homed/main.go` 里 277 行装配代码、11 处「必须早于 / 必须在之前」注释。
|
||||
// 这些**顺序依赖只写在注释里,没有任何机器校验** —— 错了不报错,
|
||||
// 而是静默降级,症状出现在很远的地方。
|
||||
//
|
||||
// 现场故障:安全模块装配晚于读 `core.llm.*` 的操作 ⇒ 密文解不开 ⇒
|
||||
// key 变空 ⇒ 所有 LLM 调用 401,而看起来像"key 配错了"。
|
||||
//
|
||||
// ## 本包提供的性质(用户指出的核心洞察)
|
||||
//
|
||||
// > 依赖图是一个由依赖关系生成的图,通过 BFS 遍历:当前遍历到的节点初始化后,
|
||||
// > 下一层 BFS 就可以安全地并行初始化 —— 这是依赖图的性质。
|
||||
//
|
||||
// 形式化:设边 `a → b` 表示 b 依赖 a。BFS 从入度 0 的节点开始分层。
|
||||
// 第 k 层任意节点的**全部前驱都在 <k 层**,且**第 k 层内部两两无边**
|
||||
// (否则其中一个会在第 ≥k+1 层)。
|
||||
//
|
||||
// ⇒ 只要第 <k 层已全部就绪,第 k 层的**任意子集**都可以并发 Init。
|
||||
//
|
||||
// 分层本身已经保证了并行安全,无需任何额外判断。
|
||||
//
|
||||
// 这就是为什么用 BFS 而不是「一次拓扑排序 + 逐个执行」:
|
||||
// 拓扑序给的是**一个**合法顺序(全串行),BFS 分层给的是**最大化并行的顺序集合**。
|
||||
//
|
||||
// ## 本包只做纯逻辑
|
||||
//
|
||||
// 不依赖任何内核子系统,因此可以用极硬的判据覆盖:
|
||||
// 分层正确性、同层并行、有界并发、三类失败(缺依赖/循环/Init 错)。
|
||||
// 真正的模块定义与接线在 Step 4 的后续增量里做(见文档 §6)。
|
||||
package kernelboot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Module 是一个可装配的内核模块。
|
||||
//
|
||||
// ★ 注意 Requires 的语义:**不是"我要在谁之后",而是"我需要谁已就绪"**。
|
||||
// 前者是顺序(要人肉维护),后者是依赖(可由图推导)——
|
||||
// 本设计的全部价值就在于把前者换成后者。
|
||||
type Module interface {
|
||||
// Name 是模块的唯一标识(依赖图节点名)。
|
||||
Name() string
|
||||
// Requires 声明依赖的**设施名**(即其他模块 Provides 的名字)。
|
||||
Requires() []string
|
||||
// Provides 声明本模块提供的设施名(供他人 Requires 引用)。
|
||||
//
|
||||
// 允许一个模块提供多个设施名(如"配置库"同时提供"配置读"与"配置写")。
|
||||
// 返回空表示"不提供任何可被依赖的设施"(纯副作用模块)。
|
||||
Provides() []string
|
||||
// Init 执行装配。ks 是内核**唯一的公共数据结构体**:
|
||||
// 模块从中**读**已就绪的依赖,把自己产出的公共数据**写**回它的字段。
|
||||
//
|
||||
// 返回错误表示装配失败(**必须传播,不得吞掉**)。
|
||||
Init(ks *KernelState) error
|
||||
}
|
||||
|
||||
// Plan 是一次依赖解析的结果:分好层的调度计划。
|
||||
type Plan struct {
|
||||
// Layers 是 BFS 分层结果。Layers[0] 无依赖,Layers[k+1] 依赖 Layers[0..k]。
|
||||
Layers [][]Module
|
||||
}
|
||||
|
||||
// TotalModules 返回计划里的模块总数。
|
||||
func (p *Plan) TotalModules() int {
|
||||
n := 0
|
||||
for _, l := range p.Layers {
|
||||
n += len(l)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// UnresolvableError 是"依赖图无法解析"的错误。
|
||||
//
|
||||
// 它必须携带**足够定位问题的信息**(本仓纪律:错误要能指向修复动作):
|
||||
// - Missing:被依赖但从没被任何模块 Provides 的设施名
|
||||
// - Cycle:还在等待中的模块名(在无解时,它们构成环或环的下游)
|
||||
// - Declared:已声明提供的设施名(便于发现拼写差异,如 "configur" vs "config")
|
||||
type UnresolvableError struct {
|
||||
Missing []string
|
||||
Cycle []string
|
||||
Declared []string
|
||||
}
|
||||
|
||||
func (e *UnresolvableError) Error() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("kernelboot: 依赖图无法解析")
|
||||
if len(e.Missing) > 0 {
|
||||
fmt.Fprintf(&b, ";依赖了不存在的设施: %v(已声明可提供的: %v)",
|
||||
e.Missing, e.Declared)
|
||||
}
|
||||
if len(e.Cycle) > 0 {
|
||||
fmt.Fprintf(&b, ";循环依赖或等待环上的模块: %v", e.Cycle)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Build 解析依赖并分层。
|
||||
//
|
||||
// 算法(文档 §2.3.2):
|
||||
//
|
||||
// ready := {} // 已就绪的设施名
|
||||
// for 还有未装配的模块 {
|
||||
// layer := 所有 Requires ⊆ ready 的模块
|
||||
// if layer 为空 => 无解(缺依赖或环)=> 报错
|
||||
// ready ∪= 所有 layer 成员的 Provides
|
||||
// }
|
||||
//
|
||||
// 返回的 Plan 可直接按层执行(层内可并行)。
|
||||
func Build(modules []Module) (*Plan, error) {
|
||||
// ① 先收集所有被声明的设施名,用于区分"缺依赖"与"有环"两类错误。
|
||||
declared := map[string]bool{}
|
||||
for _, m := range modules {
|
||||
for _, p := range m.Provides() {
|
||||
declared[p] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ② 检查重名(两个模块 Provides 同名设施 ⇒ 歧义,必须报错)
|
||||
// 为何必须拦:重名会让"依赖了谁"变得不确定,
|
||||
// 而依赖图的价值恰恰在于确定性。
|
||||
owner := map[string]string{} // 设施名 -> 提供者模块名
|
||||
var dupes []string
|
||||
for _, m := range modules {
|
||||
for _, p := range m.Provides() {
|
||||
if prev, ok := owner[p]; ok && prev != m.Name() {
|
||||
dupes = append(dupes, fmt.Sprintf("%s(%s 与 %s 都声明提供)", p, prev, m.Name()))
|
||||
continue
|
||||
}
|
||||
owner[p] = m.Name()
|
||||
}
|
||||
}
|
||||
if len(dupes) > 0 {
|
||||
sort.Strings(dupes)
|
||||
return nil, fmt.Errorf("kernelboot: 设施名被重复声明: %v", dupes)
|
||||
}
|
||||
|
||||
// ③ 模块名不得重复
|
||||
seen := map[string]bool{}
|
||||
for _, m := range modules {
|
||||
if seen[m.Name()] {
|
||||
return nil, fmt.Errorf("kernelboot: 模块名重复: %s", m.Name())
|
||||
}
|
||||
seen[m.Name()] = true
|
||||
}
|
||||
|
||||
// ④ BFS 分层
|
||||
ready := map[string]bool{}
|
||||
pending := make([]Module, len(modules))
|
||||
copy(pending, modules)
|
||||
var layers [][]Module
|
||||
|
||||
for len(pending) > 0 {
|
||||
var layer []Module
|
||||
var rest []Module
|
||||
for _, m := range pending {
|
||||
if satisfied(m.Requires(), ready) {
|
||||
layer = append(layer, m)
|
||||
} else {
|
||||
rest = append(rest, m)
|
||||
}
|
||||
}
|
||||
if len(layer) == 0 {
|
||||
// 无解:区分"缺依赖"与"循环"
|
||||
return nil, diagnoseUnresolvable(rest, declared, owner, ready)
|
||||
}
|
||||
// 同层内按名字排序:让分层结果**稳定可复现**(便于判据断言与日志对照)。
|
||||
// 并行执行顺序不影响正确性(同层无边),但稳定的输出让"层次变了"这类
|
||||
// 隐式变更容易被发现。
|
||||
sort.Slice(layer, func(i, j int) bool { return layer[i].Name() < layer[j].Name() })
|
||||
layers = append(layers, layer)
|
||||
|
||||
for _, m := range layer {
|
||||
for _, p := range m.Provides() {
|
||||
ready[p] = true
|
||||
}
|
||||
}
|
||||
pending = rest
|
||||
}
|
||||
|
||||
return &Plan{Layers: layers}, nil
|
||||
}
|
||||
|
||||
// satisfied 报告 requires 是否全部已在 ready 里。
|
||||
func satisfied(requires []string, ready map[string]bool) bool {
|
||||
for _, r := range requires {
|
||||
if !ready[r] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// diagnoseUnresolvable 区分两类无解原因,并给出可定位的信息。
|
||||
//
|
||||
// 为何要区分:两者的**修复动作完全不同**——
|
||||
//
|
||||
// · 缺依赖 ⇒ 去补 Provides(常是拼写错误)
|
||||
// · 有环 ⇒ 去拆环(设计问题)
|
||||
//
|
||||
// 只报"有环"会让人去错误的方向查。
|
||||
func diagnoseUnresolvable(pending []Module, declared map[string]bool, owner map[string]string, ready map[string]bool) error {
|
||||
var missing, cycle []string
|
||||
for _, m := range pending {
|
||||
cycle = append(cycle, m.Name())
|
||||
for _, r := range m.Requires() {
|
||||
// 该依赖既没被任何模块声明提供、也不在 ready ⇒ 真正的"不存在"
|
||||
if !declared[r] && !ready[r] {
|
||||
missing = append(missing, fmt.Sprintf("%s(%s 需要)", r, m.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(cycle)
|
||||
|
||||
declaredList := make([]string, 0, len(declared))
|
||||
for d := range declared {
|
||||
declaredList = append(declaredList, d)
|
||||
}
|
||||
sort.Strings(declaredList)
|
||||
|
||||
return &UnresolvableError{Missing: missing, Cycle: cycle, Declared: declaredList}
|
||||
}
|
||||
|
||||
// RunOptions 控制执行。
|
||||
type RunOptions struct {
|
||||
// MaxParallel 是**层内**的并发上限(<=0 表示不限制)。
|
||||
//
|
||||
// ★ 为何必须有界:同层无边保证**安全**,但不保证**无代价**——
|
||||
// 42 个插件同时 spawn 会造成内存/CPU 尖峰(每个 ~11MB 二进制加载 + 握手)。
|
||||
// 并发度只影响**速度**,不影响**顺序**(同层节点两两无边,
|
||||
// 无论以什么顺序完成,下一层的就绪条件都相同)。
|
||||
MaxParallel int
|
||||
|
||||
// OnLayerDone 在每层完成后回调(层号从 0 起)。可为 nil。
|
||||
OnLayerDone func(layerIndex int, modules []Module)
|
||||
|
||||
// State 是内核**唯一的公共数据结构体**(用户设计):
|
||||
// 模块从中读依赖、往它写自己的产物。
|
||||
//
|
||||
// 为何由调用方传入而不是 Run 内部自建:产物在 Run 返回后**仍然要用**
|
||||
// (如 LuaVM、Memory 要一直活着),所以它必须比 Run 活得久。
|
||||
State *KernelState
|
||||
|
||||
// Bus 是状态同步总线。非 nil 时,Run 会在**每个层边界**把该层模块贡献的
|
||||
// 设施标记为就绪(设计 §2.6.2)。
|
||||
//
|
||||
// ★ 为何由 Run 自动写而不是各模块自己写:
|
||||
// 若模块自己 MarkReady,顺序取决于**层内并发的完成顺序** ⇒ 不可复现;
|
||||
// 而且模块可能"忘记标记"(静默漏掉一个设施 ⇒ 插件永远等不到它就绪)。
|
||||
// 由 Run 在层边界统一写,既确定又不可能漏。
|
||||
Bus *SyncBus
|
||||
}
|
||||
|
||||
// RunResult 是执行结果。
|
||||
type RunResult struct {
|
||||
// LayerCount 是实际执行的层数。
|
||||
LayerCount int
|
||||
// MaxConcurrent 是观测到的**峰值并发**(用于判据证明并行确实发生)。
|
||||
MaxConcurrent int
|
||||
// Order 是按完成顺序记录的模块名(仅用于诊断/日志,**不作正确性依据**)。
|
||||
Order []string
|
||||
}
|
||||
|
||||
// Run 按计划执行:逐层 Init,层内有界并行。
|
||||
//
|
||||
// 失败策略(文档 §2.3.3):**任何 Init 失败都令整体失败**。
|
||||
// 不静默跳过、不"带着半初始化状态继续"——后者正是病 A 的形态。
|
||||
//
|
||||
// 同层内若**多个**模块失败,返回第一个(按名字序,稳定);
|
||||
// 但**不等同于只跑一个**:同层其余模块仍会被执行完(避免半途中断造成更乱的状态)。
|
||||
func Run(plan *Plan, opts RunOptions) (*RunResult, error) {
|
||||
if plan == nil {
|
||||
return nil, fmt.Errorf("kernelboot: 计划为 nil")
|
||||
}
|
||||
res := &RunResult{}
|
||||
var orderMu sync.Mutex
|
||||
|
||||
ks := opts.State
|
||||
if ks == nil {
|
||||
ks = NewKernelState()
|
||||
}
|
||||
|
||||
// ★ 执行前把**全部**模块声明的设施登记为"内核注册过"。
|
||||
//
|
||||
// 这样插件的 `requires_facilities` 校验(防拼错 ⇒ 静默永不启动)
|
||||
// 在"装配结果之外"也成立:即使某层失败、后面的模块没跑,
|
||||
// "这个设施名是模块声明过的"依然为真(语义与"是否已就绪"解耦)。
|
||||
if opts.Bus != nil {
|
||||
var all []string
|
||||
for _, layer := range plan.Layers {
|
||||
for _, m := range layer {
|
||||
all = append(all, m.Provides()...)
|
||||
}
|
||||
}
|
||||
opts.Bus.MarkDeclared(all)
|
||||
// ★ 把 bus 放进 KernelState:模块的 Init 只能看到 ks,
|
||||
// 而 plugin-host 需要订阅状态变化(挂"等 agent 就绪"的回调)。
|
||||
// 不放这里 ⇒ 模块拿不到 bus(实测撞到)。
|
||||
ks.SyncBus = opts.Bus
|
||||
}
|
||||
|
||||
for li, layer := range plan.Layers {
|
||||
if len(layer) == 0 {
|
||||
continue
|
||||
}
|
||||
limit := opts.MaxParallel
|
||||
if limit <= 0 || limit > len(layer) {
|
||||
limit = len(layer)
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, limit)
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(layer))
|
||||
|
||||
// 峰值并发观测(判据用它证明"并行确实发生了")
|
||||
var active, peak int
|
||||
var peakMu sync.Mutex
|
||||
|
||||
for i, m := range layer {
|
||||
wg.Add(1)
|
||||
go func(i int, m Module) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
peakMu.Lock()
|
||||
active++
|
||||
if active > peak {
|
||||
peak = active
|
||||
}
|
||||
peakMu.Unlock()
|
||||
|
||||
err := m.Init(ks)
|
||||
|
||||
peakMu.Lock()
|
||||
active--
|
||||
peakMu.Unlock()
|
||||
|
||||
orderMu.Lock()
|
||||
res.Order = append(res.Order, m.Name())
|
||||
orderMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
errs[i] = fmt.Errorf("模块 %s 装配失败: %w", m.Name(), err)
|
||||
}
|
||||
}(i, m)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
peakMu.Lock()
|
||||
if peak > res.MaxConcurrent {
|
||||
res.MaxConcurrent = peak
|
||||
}
|
||||
peakMu.Unlock()
|
||||
|
||||
// 层内失败:收集后**一次性**报告(不要 fail-fast 丢掉其他失败信息)
|
||||
var failed []string
|
||||
for _, e := range errs {
|
||||
if e != nil {
|
||||
failed = append(failed, e.Error())
|
||||
}
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
sort.Strings(failed)
|
||||
return res, fmt.Errorf("kernelboot: 第 %d 层装配失败: %s",
|
||||
li, strings.Join(failed, "; "))
|
||||
}
|
||||
|
||||
res.LayerCount++
|
||||
// ★ 层边界:把本层贡献的设施标记就绪(设计 §2.6.2)。
|
||||
// 在层内并发**全部完成之后**统一标记 ⇒ 就绪顺序确定、可复现。
|
||||
if opts.Bus != nil {
|
||||
var provides []string
|
||||
for _, m := range layer {
|
||||
provides = append(provides, m.Provides()...)
|
||||
}
|
||||
opts.Bus.MarkLayerReady(provides)
|
||||
}
|
||||
if opts.OnLayerDone != nil {
|
||||
opts.OnLayerDone(li, layer)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 全部层完成 ⇒ 内核完全初始化。
|
||||
//
|
||||
// 这是"未声明依赖的插件"的启动信号(默认路径 = 现行为,设计 §2.6.5)。
|
||||
// 它在**所有层都成功之后**才发 —— 中途失败会提前 return,不发这个信号,
|
||||
// 于是那些插件**不会被拉起**(这是对的:内核没装好就拉插件 = 病 A)。
|
||||
if opts.Bus != nil {
|
||||
opts.Bus.MarkKernelComplete()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
403
internal/kernelboot/kernelboot_test.go
Normal file
403
internal/kernelboot/kernelboot_test.go
Normal file
@ -0,0 +1,403 @@
|
||||
package kernelboot
|
||||
|
||||
// BFS 分层调度器的判据(2026-10-16)。
|
||||
//
|
||||
// 设计见 docs/zh/kernel-modularization-design.md §2.3 / §4.1。
|
||||
//
|
||||
// ## 判据不能只看"最终都 Init 了"
|
||||
//
|
||||
// 那是**串行也能满足**的弱断言。必须断言**层次结构与并行性本身**:
|
||||
// · 分层正确(同层两两无边、前驱都在更早层)—— 这是正确性的根
|
||||
// · 同层确实并行(用观测到的峰值并发证明)
|
||||
// · 有界并发生效
|
||||
// · 三类失败(缺依赖 / 循环 / Init 错)都必须**响亮拒绝**
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeModule 是可观测的假模块。
|
||||
type fakeModule struct {
|
||||
name string
|
||||
provides []string
|
||||
requires []string
|
||||
initErr error
|
||||
// delay 让同层并行的效果可观测(总耗时 ≈ max 而非 sum)
|
||||
delay time.Duration
|
||||
// initCount 记录被 Init 的次数(幂等性/重复执行检测)
|
||||
initCount *int32
|
||||
// onInit 供并发观测
|
||||
onInit func()
|
||||
}
|
||||
|
||||
func (m *fakeModule) Name() string { return m.name }
|
||||
func (m *fakeModule) Provides() []string { return m.provides }
|
||||
func (m *fakeModule) Requires() []string { return m.requires }
|
||||
func (m *fakeModule) Init(_ *KernelState) error {
|
||||
if m.initCount != nil {
|
||||
atomic.AddInt32(m.initCount, 1)
|
||||
}
|
||||
if m.onInit != nil {
|
||||
m.onInit()
|
||||
}
|
||||
if m.delay > 0 {
|
||||
time.Sleep(m.delay)
|
||||
}
|
||||
return m.initErr
|
||||
}
|
||||
|
||||
func names(layer []Module) []string {
|
||||
out := make([]string, 0, len(layer))
|
||||
for _, m := range layer {
|
||||
out = append(out, m.Name())
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ★★★ 判据:**嵌套依赖**分层正确(用户明确要求的场景)。
|
||||
//
|
||||
// 链:security → config → llm → agent(每层依赖上一层)
|
||||
// ⇒ 必须得到 4 个单元素层,而不是被展平。
|
||||
func TestBuildLayersNestedChain(t *testing.T) {
|
||||
mods := []Module{
|
||||
&fakeModule{name: "agent", requires: []string{"llm"}},
|
||||
&fakeModule{name: "llm", provides: []string{"llm"}, requires: []string{"config"}},
|
||||
&fakeModule{name: "config", provides: []string{"config"}, requires: []string{"crypto"}},
|
||||
&fakeModule{name: "security", provides: []string{"crypto"}},
|
||||
}
|
||||
plan, err := Build(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Layers) != 4 {
|
||||
t.Fatalf("嵌套链应分 4 层,实际 %d 层: %v", len(plan.Layers), layerNames(plan))
|
||||
}
|
||||
want := [][]string{{"security"}, {"config"}, {"llm"}, {"agent"}}
|
||||
for i, w := range want {
|
||||
if got := names(plan.Layers[i]); !equalStrs(got, w) {
|
||||
t.Errorf("第 %d 层应为 %v,实际 %v", i, w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**同层并行**(一个节点的所有前驱就绪后,它与同层的其他无冲突节点同层)。
|
||||
//
|
||||
// 图:A;B 依赖 A;C 依赖 A;D 依赖 B 与 C
|
||||
// ⇒ 层: [A] [B,C] [D] —— C 虽依赖 A,与 B 同层(这是 BFS 的核心价值)
|
||||
func TestBuildLayersGroupsSiblingsTogether(t *testing.T) {
|
||||
mods := []Module{
|
||||
&fakeModule{name: "D", requires: []string{"b", "c"}},
|
||||
&fakeModule{name: "B", provides: []string{"b"}, requires: []string{"a"}},
|
||||
&fakeModule{name: "C", provides: []string{"c"}, requires: []string{"a"}},
|
||||
&fakeModule{name: "A", provides: []string{"a"}},
|
||||
}
|
||||
plan, err := Build(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Layers) != 3 {
|
||||
t.Fatalf("应分 3 层,实际 %d: %v", len(plan.Layers), layerNames(plan))
|
||||
}
|
||||
if got := names(plan.Layers[0]); !equalStrs(got, []string{"A"}) {
|
||||
t.Errorf("第 0 层应只有 A,实际 %v", got)
|
||||
}
|
||||
// ★ 关键:B 与 C 必须**同层**(它们互不依赖,且都只依赖已就绪的 A)
|
||||
if got := names(plan.Layers[1]); !equalStrs(got, []string{"B", "C"}) {
|
||||
t.Errorf("★ 第 1 层应为 [B C](BFS 的核心价值:无冲突节点同层并行),实际 %v", got)
|
||||
}
|
||||
if got := names(plan.Layers[2]); !equalStrs(got, []string{"D"}) {
|
||||
t.Errorf("第 2 层应为 D,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:无依赖的模块**全部在第一层**(并行度最大化)。
|
||||
//
|
||||
// 这条直接对应实测评测:42 个插件相互无依赖 ⇒ 应能全部并行。
|
||||
func TestBuildAllIndependentGoToSingleLayer(t *testing.T) {
|
||||
var mods []Module
|
||||
for i := 0; i < 42; i++ {
|
||||
mods = append(mods, &fakeModule{name: fmt.Sprintf("p%02d", i)})
|
||||
}
|
||||
plan, err := Build(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Layers) != 1 {
|
||||
t.Fatalf("★ 42 个独立模块应全在第 0 层,实际 %d 层 —— "+
|
||||
"并行度被无谓降低", len(plan.Layers))
|
||||
}
|
||||
if len(plan.Layers[0]) != 42 {
|
||||
t.Fatalf("第 0 层应有 42 个模块,实际 %d", len(plan.Layers[0]))
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**缺依赖必须拒绝启动**,且错误要能定位(含缺的名字)。
|
||||
//
|
||||
// 为什么不能静默跳过:那就是病 A(顺序/依赖错了不报错,静默降级)。
|
||||
func TestBuildRejectsMissingDependency(t *testing.T) {
|
||||
mods := []Module{
|
||||
&fakeModule{name: "llm", requires: []string{"configur"}}, // 拼错:应为 config
|
||||
&fakeModule{name: "config", provides: []string{"config"}},
|
||||
}
|
||||
_, err := Build(mods)
|
||||
if err == nil {
|
||||
t.Fatal("★ 依赖了不存在的设施,却构建成功了(静默跳过 = 病 A)")
|
||||
}
|
||||
msg := err.Error()
|
||||
// 必须指出缺的是什么(便于发现拼写错误)
|
||||
if !strings.Contains(msg, "configur") {
|
||||
t.Errorf("错误应含缺失的设施名: %v", err)
|
||||
}
|
||||
// 必须列出已声明的名字(便于对照发现拼写差异)
|
||||
if !strings.Contains(msg, "config") {
|
||||
t.Errorf("错误应列出已声明的设施名: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**循环依赖必须拒绝启动**,且错误要**列出环上的成员**。
|
||||
//
|
||||
// 只报"有环"而不说是谁 ⇒ 无法定位修复。
|
||||
func TestBuildRejectsCycleAndNamesMembers(t *testing.T) {
|
||||
mods := []Module{
|
||||
&fakeModule{name: "A", provides: []string{"a"}, requires: []string{"c"}},
|
||||
&fakeModule{name: "B", provides: []string{"b"}, requires: []string{"a"}},
|
||||
&fakeModule{name: "C", provides: []string{"c"}, requires: []string{"b"}},
|
||||
}
|
||||
_, err := Build(mods)
|
||||
if err == nil {
|
||||
t.Fatal("★ 循环依赖未被检出")
|
||||
}
|
||||
var ue *UnresolvableError
|
||||
if !errors.As(err, &ue) {
|
||||
t.Fatalf("错误类型应为 *UnresolvableError: %T", err)
|
||||
}
|
||||
// 环上三个成员都应被列出(否则用户不知道该拆哪个)
|
||||
cycle := strings.Join(ue.Cycle, ",")
|
||||
for _, want := range []string{"A", "B", "C"} {
|
||||
if !strings.Contains(cycle, want) {
|
||||
t.Errorf("★ 错误未列出环上成员 %s(无法定位): %v", want, ue.Cycle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**重名设施必须拒绝**(歧义会让依赖不确定)。
|
||||
func TestBuildRejectsDuplicateProvides(t *testing.T) {
|
||||
mods := []Module{
|
||||
&fakeModule{name: "A", provides: []string{"config"}},
|
||||
&fakeModule{name: "B", provides: []string{"config"}}, // 撞名
|
||||
}
|
||||
if _, err := Build(mods); err == nil {
|
||||
t.Fatal("★ 设施名被两个模块声明提供,却构建成功了(依赖变得不确定)")
|
||||
}
|
||||
// 重名模块名同样必须拒绝
|
||||
if _, err := Build([]Module{
|
||||
&fakeModule{name: "X"}, &fakeModule{name: "X"},
|
||||
}); err == nil {
|
||||
t.Fatal("★ 模块名重复未被检出")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**同层确实并行**(用观测到的峰值并发 + 耗时证明)。
|
||||
//
|
||||
// 只断言"都 Init 了"是弱断言(串行也满足)。这里用两个可观测证据:
|
||||
//
|
||||
// · 峰值并发 >= 2
|
||||
// · 总耗时 ≈ 单个延迟(而非相加)
|
||||
func TestRunExecutesLayerInParallel(t *testing.T) {
|
||||
const d = 200 * time.Millisecond
|
||||
var active, peak int32
|
||||
var mu sync.Mutex
|
||||
|
||||
mk := func(n string) *fakeModule {
|
||||
return &fakeModule{name: n, delay: d, onInit: func() {
|
||||
cur := atomic.AddInt32(&active, 1)
|
||||
mu.Lock()
|
||||
if cur > peak {
|
||||
peak = cur
|
||||
}
|
||||
mu.Unlock()
|
||||
time.Sleep(d) // 让并发窗口真实存在
|
||||
atomic.AddInt32(&active, -1)
|
||||
}}
|
||||
}
|
||||
mods := []Module{mk("A"), mk("B"), mk("C"), mk("D")}
|
||||
plan, err := Build(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
res, err := Run(plan, RunOptions{})
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ① 峰值并发:必须 >= 2(证明并行确实发生)
|
||||
if res.MaxConcurrent < 2 {
|
||||
t.Errorf("★ 峰值并发 %d —— 同层没有并行执行", res.MaxConcurrent)
|
||||
}
|
||||
// ② 耗时:4 个模块各 400ms(delay 200 + onInit 内 200),
|
||||
// 串行需 ~1600ms,并行应显著更短
|
||||
if elapsed > 900*time.Millisecond {
|
||||
t.Errorf("★ 耗时 %v 接近串行(4×400ms)—— 同层未并行", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**有界并发生效**(层内 8 个模块、上限 2 ⇒ 峰值并发 <= 2)。
|
||||
//
|
||||
// 为何必须有界:42 个插件同时 spawn 会造成内存/CPU 尖峰。
|
||||
// 但注意:有界**不削弱正确性**(同层无边,完成顺序不影响下一层就绪)。
|
||||
func TestRunRespectsMaxParallel(t *testing.T) {
|
||||
var active, peak int32
|
||||
var mu sync.Mutex
|
||||
var mods []Module
|
||||
for i := 0; i < 8; i++ {
|
||||
mods = append(mods, &fakeModule{
|
||||
name: fmt.Sprintf("m%d", i), delay: 40 * time.Millisecond,
|
||||
onInit: func() {
|
||||
cur := atomic.AddInt32(&active, 1)
|
||||
mu.Lock()
|
||||
if cur > peak {
|
||||
peak = cur
|
||||
}
|
||||
mu.Unlock()
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
atomic.AddInt32(&active, -1)
|
||||
},
|
||||
})
|
||||
}
|
||||
plan, err := Build(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := Run(plan, RunOptions{MaxParallel: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.MaxConcurrent > 2 {
|
||||
t.Errorf("★ 并发上限设为 2,实际峰值 %d —— 上限未生效", res.MaxConcurrent)
|
||||
}
|
||||
// 上限生效不应导致漏跑或重跑
|
||||
if len(res.Order) != 8 {
|
||||
t.Errorf("应执行 8 个模块,实际 %d", len(res.Order))
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**Init 失败必须传播**(不被其他成功掩盖),且整体失败。
|
||||
func TestRunPropagatesInitFailure(t *testing.T) {
|
||||
boom := errors.New("磁盘写不进去")
|
||||
mods := []Module{
|
||||
&fakeModule{name: "ok1"},
|
||||
&fakeModule{name: "bad", initErr: boom},
|
||||
&fakeModule{name: "ok2"},
|
||||
}
|
||||
plan, err := Build(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = Run(plan, RunOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("★ 同层有模块 Init 失败,整体却成功了(错误被吞)")
|
||||
}
|
||||
// 错误必须含模块名与原始原因
|
||||
if !strings.Contains(err.Error(), "bad") || !strings.Contains(err.Error(), "磁盘写不进去") {
|
||||
t.Errorf("错误应含模块名与原始原因: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**层间顺序不可违**(下一层必须等上一层全部完成)。
|
||||
//
|
||||
// 这条是正确性的底线:如果下一层在上一层完成前就跑,
|
||||
// 就是病 A 的形态(依赖未就绪即使用)。
|
||||
func TestRunRespectsLayerBarrier(t *testing.T) {
|
||||
var firstDone int32
|
||||
var violated int32
|
||||
|
||||
first := &fakeModule{name: "first", provides: []string{"x"}, delay: 60 * time.Millisecond}
|
||||
second := &fakeModule{
|
||||
name: "second", requires: []string{"x"},
|
||||
onInit: func() {
|
||||
// 若第一层还没完成就进了第二层 ⇒ 违反层次屏障
|
||||
if atomic.LoadInt32(&firstDone) == 0 {
|
||||
atomic.StoreInt32(&violated, 1)
|
||||
}
|
||||
},
|
||||
}
|
||||
// 让 first 在 Init 结束前标记
|
||||
go func() { time.Sleep(80 * time.Millisecond); atomic.StoreInt32(&firstDone, 1) }()
|
||||
|
||||
plan, err := Build([]Module{first, second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Run(plan, RunOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 注意:firstDone 由独立 goroutine 在 80ms 设置,而 first 的 Init 在 60ms 返回。
|
||||
// 所以正常情况(层屏障正确)下,second 在 >=60ms 才跑,此时 80ms 可能未到 ——
|
||||
// 这个判据用时间**不可靠**,改为断言层次本身(见下)。
|
||||
if len(plan.Layers) != 2 {
|
||||
t.Fatalf("应分 2 层,实际 %d", len(plan.Layers))
|
||||
}
|
||||
if names(plan.Layers[0])[0] != "first" || names(plan.Layers[1])[0] != "second" {
|
||||
t.Fatalf("层次划分错误: %v", layerNames(plan))
|
||||
}
|
||||
_ = violated
|
||||
}
|
||||
|
||||
// ★★★ 判据:**计划可复现**(同一份声明 → 相同层次与相同层内顺序)。
|
||||
//
|
||||
// 为何重要:分层结果要能进 PR diff(§2.5.1 的可审计性)。
|
||||
// 不可复现的图无法评审。
|
||||
func TestBuildIsDeterministic(t *testing.T) {
|
||||
mk := func() []Module {
|
||||
return []Module{
|
||||
&fakeModule{name: "z", provides: []string{"z"}},
|
||||
&fakeModule{name: "a", provides: []string{"a"}},
|
||||
&fakeModule{name: "m", provides: []string{"m"}, requires: []string{"a", "z"}},
|
||||
}
|
||||
}
|
||||
p1, err := Build(mk())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p2, err := Build(mk())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if layerNames(p1) != layerNames(p2) {
|
||||
t.Fatalf("★ 同一份声明两次构建得到不同层次(不可复现):\n%s\n%s",
|
||||
layerNames(p1), layerNames(p2))
|
||||
}
|
||||
}
|
||||
|
||||
func layerNames(p *Plan) string {
|
||||
var parts []string
|
||||
for i, l := range p.Layers {
|
||||
parts = append(parts, fmt.Sprintf("L%d=%v", i, names(l)))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func equalStrs(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var errFake = errors.New("假错误")
|
||||
327
internal/kernelboot/kernelstate.go
Normal file
327
internal/kernelboot/kernelstate.go
Normal file
@ -0,0 +1,327 @@
|
||||
package kernelboot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentCore "github.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
luapkg "github.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"github.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
// MemoryBundle 是"记忆子系统"的打包(memory 模块产出)。
|
||||
//
|
||||
// ★ 为何打包成一个类型而不是拆成 4 个字段:
|
||||
// 这 4 个东西**同生共死**(同一个 initMemoryStack 产出、同一个 close 回收、
|
||||
// 它们的持有者都假设彼此一致)。拆开会让"只填了 db 忘了 indexer"变成可能。
|
||||
//
|
||||
// ★ 为何放在 kernelboot 而不是 cmd/homed:
|
||||
// KernelState 要引用它的类型 ⇒ 类型必须与 KernelState 同包
|
||||
// (否则 kernelboot 得 import cmd/homed,那是不可能的)。
|
||||
type MemoryBundle struct {
|
||||
DB *memory.GraphDB
|
||||
Indexer *memory.Indexer
|
||||
Social *social.SocialStore
|
||||
Distiller *pipeline.Distiller
|
||||
}
|
||||
|
||||
// KernelState 是内核**唯一的公共数据持有者**(用户设计)。
|
||||
//
|
||||
// ## 设计意图(用户原话)
|
||||
//
|
||||
// > 内核通过一个结构体收集全部包级共享变量,其他模块通过这个结构体访问数据,
|
||||
// > 各个模块的公共数据也统一注册到内核这个公共结构体中。
|
||||
//
|
||||
// > 结构体成员初始置 nil 加读取前检查不就好了
|
||||
//
|
||||
// ## 为什么这样比"每个包各持全局变量"好
|
||||
//
|
||||
// 本仓实测的隐藏依赖(设计 §2.8.2)大多来自**散落各包的包级变量**:
|
||||
//
|
||||
// internal/nlp/parser.go: var defaultParser Parser // SetDefaultParser 写,
|
||||
// // NewExtractor 运行期读
|
||||
//
|
||||
// 这类变量的共同问题:
|
||||
//
|
||||
// · **看不见**:不读那个包的源码就不知道它存在、谁写谁读
|
||||
// · **拼错不报**:`SetDefaultParser` 忘调 ⇒ 运行期静默降级
|
||||
// (实测:图记忆实体退化成整句复合值,跨维度检索全失效)
|
||||
// · **顺序无保证**:写入时机靠"碰巧"(原 main 里蒸馏先启动、解析器后装上,
|
||||
// 靠 Interval=10min 兜着 —— 见 §2.8.2 A)
|
||||
//
|
||||
// 收到一个结构体后:
|
||||
//
|
||||
// · **一处可见**:一眼看全内核有哪些共享状态(可审计)
|
||||
// · **类型化**:字段拼错 / 取错类型 ⇒ **编译错误**
|
||||
// · **有归属**:字段属于某个模块的产物 ⇒ 顺序由依赖图保证
|
||||
//
|
||||
// ## ★ 取值方式:直接读字段 + 调用方检查 nil(用户指定的做法)
|
||||
//
|
||||
// 不用 map[string]any、不用泛型取用、不用"未声明依赖就取不到"的拦截。
|
||||
// 理由:
|
||||
//
|
||||
// · 类型化字段 ⇒ 拼错/取错类型是**编译错误**(比任何运行期检查都早)
|
||||
// · nil 检查 ⇒ 读得越直观越好(`if ks.LuaVM == nil { ... }`)
|
||||
// · 少一层间接 ⇒ 少一处可能出错的地方
|
||||
//
|
||||
// 顺序正确性由**声明**驱动:`Build()` 校验缺依赖 / 环 / 重名,
|
||||
// 判据断言关键顺序(见 internal/kernelboot/modules 的判据)。
|
||||
//
|
||||
// ## ★ 并发
|
||||
//
|
||||
// 同层模块**并行** Init,各自只写**自己那一个字段**
|
||||
// ⇒ 字段互不相同,无数据竞争(判据用 -race 验证)。
|
||||
//
|
||||
// 内核**不**在字段上加锁:加锁会给调用方"可以乱写"的错觉,
|
||||
// 正确约束是"各模块只写自己的字段"(由代码审查 + 判据守)。
|
||||
type KernelState struct {
|
||||
// ── Group 0 产出(core 自己立起来的东西,所有模块只读) ──
|
||||
|
||||
// Cfg 是配置快照(config 模块产出)。
|
||||
//
|
||||
// ★ 它就是原先"无主"的那个 cfg:`cfg := cfgReg.ToConfig()` 之后被手工
|
||||
// 传给 11 个 init*。归到这里后,任何模块读 ks.Cfg 即可。
|
||||
// 它在依赖图上是 config 模块的产物(第 0 层,前提由 Group 0 保证)。
|
||||
Cfg *types.Config
|
||||
|
||||
// CfgReg 是配置中心(Group 0 产出,所有模块只读用)。
|
||||
CfgReg *config.ConfigRegistry
|
||||
|
||||
// DataDir 是数据目录(Group 0 产出)。
|
||||
DataDir string
|
||||
|
||||
// AgentWorkDir 是 agent 工作目录(ensureDataDirs 产出)。
|
||||
AgentWorkDir string
|
||||
|
||||
// CLISocket 是 CLI 插件的 unix socket 路径(--cli-socket 产出)。
|
||||
//
|
||||
// 放在公共结构体里的理由:它是插件读的配置,必须与插件系统同时就绪
|
||||
// (stage-host 模块用它设 cli.DefaultSocket —— 原先那行在 main 里
|
||||
// 紧邻 loadPlugins)。
|
||||
CLISocket string
|
||||
|
||||
// HTTPAddr 是 WebUI 监听地址覆盖值(--http-addr 产出)。
|
||||
HTTPAddr string
|
||||
|
||||
// ── 各模块注册的公共数据(模块只写自己那几个字段) ──
|
||||
|
||||
// Memory 是图记忆栈(memory 模块产出)。
|
||||
Memory *MemoryBundle
|
||||
|
||||
// LuaVM 是 Lua 协议适配 VM(lua 模块产出)。
|
||||
LuaVM *luapkg.VM
|
||||
|
||||
// Supervisor 是代理生命周期守护(supervisor 模块产出)。
|
||||
Supervisor *supervisor.Daemon
|
||||
|
||||
// Tracker 是变更追踪(tracker 模块产出)。
|
||||
Tracker *tracker.Tracker
|
||||
|
||||
// IO 是内核 IO 抽象层(kernel-api 模块产出)。
|
||||
IO *agentIO.IOManager
|
||||
|
||||
// EventBus 是事件总线(kernel-api 模块产出)。
|
||||
EventBus *events.Bus
|
||||
|
||||
// TextMemory 是文本记忆(text-memory 模块产出)。
|
||||
TextMemory *text.Memory
|
||||
|
||||
// Embedder 是共享词嵌入(memory 模块产出)。
|
||||
//
|
||||
// 蒸馏提取与 Agent 上下文**复用同一实例**,避免同一模型被二次加载
|
||||
// (约 200k×300 维 ≈ 数百 MB 内存)。
|
||||
Embedder *memory.StaticEmbedder
|
||||
|
||||
// LLMManager 是多源 provider 管理(llm 模块产出)。
|
||||
LLMManager *agentAPI.ProviderManager
|
||||
|
||||
// LLMProvider 是默认 provider(llm 模块产出,= LLMManager.Default())。
|
||||
LLMProvider agentAPI.Provider
|
||||
|
||||
// BaseAPIKey 是网关基础 key(llm 模块产出)。
|
||||
BaseAPIKey string
|
||||
|
||||
// DocStore 是文档记忆(doc-store 模块产出)。
|
||||
DocStore *document.Store
|
||||
|
||||
// Knowledge 是知识库(knowledge-store 模块产出)。
|
||||
Knowledge *knowledge.Store
|
||||
|
||||
// MultimodalSpace 是多模态向量空间(multimodal 模块产出)。
|
||||
MultimodalSpace vector.MultimodalEmbedder
|
||||
|
||||
// MultimodalProviderName / MultimodalErr 是"配了哪个 provider/为什么没启用"
|
||||
// (multimodal 模块产出,供 healthcheck 状态报告)。
|
||||
MultimodalProviderName string
|
||||
MultimodalErr string
|
||||
|
||||
// StageHost 是阶段管道(stage-host 模块产出)。
|
||||
StageHost *agentCore.StageHost
|
||||
|
||||
// PluginRegistry 是插件注册表(stage-host 模块产出)。
|
||||
PluginRegistry *plugin.Registry
|
||||
|
||||
// PluginCatalog 是**插件元数据缓存**(plugin-host 模块产出)。
|
||||
//
|
||||
// ## 为什么需要它(用户指出的设计)
|
||||
//
|
||||
// > 插件管理器不应该在被拉起后就去读插件列表获取元数据并写入缓存准备拉起吗?
|
||||
//
|
||||
// 对。原先元数据散在两处、且**每次都要重扫磁盘**:
|
||||
// · cmd/homed 的 collectPluginSpecs(决定启动时机)
|
||||
// · pluginmgr 的 listPlugins(人看的列表)
|
||||
// 两者各读各的 plugin.json,且调度器每轮评估都要用到。
|
||||
//
|
||||
// 现在统一由 plugin-host 模块**扫一次**、缓存下来,供:
|
||||
// · 调度器(判断时机)
|
||||
// · pluginmgr(列列表/状态)
|
||||
// · 显式启用路径(校验名字是否存在)
|
||||
//
|
||||
// 类型是 any 以避免 import 环(kernelboot 不能依赖 plugin 包之外的
|
||||
// 插件元数据类型)—— 取用处断言为 *PluginCatalog。
|
||||
PluginCatalog any
|
||||
|
||||
// SyncBus 是内核**就绪状态总线**(由 Run 创建并填入)。
|
||||
//
|
||||
// ## ★ 为什么它必须在这里(而不只是 Run 的局部变量)
|
||||
//
|
||||
// 模块的 `Init(ks)` 只能看到 KernelState —— 若 bus 只在 Run 里,
|
||||
// **模块拿不到它**,于是无法:
|
||||
// · 订阅状态变化(plugin-host 要据此拉起"等更晚设施"的插件)
|
||||
// · 标记就绪(虽然 Run 会自动做,但模块可能需要主动补标)
|
||||
//
|
||||
// 实测撞到:plugin-host 想在 Init 里挂"等 agent 就绪再拉起某些插件"的
|
||||
// 回调,却找不到 bus。把它放进 KernelState(内核公共数据,用户设计)
|
||||
// 是最自然的落点 —— 它本来就是**内核级共享状态**。
|
||||
SyncBus *SyncBus
|
||||
|
||||
// PluginScheduler 是**插件拉起调度器**(plugin-host 模块产出)。
|
||||
//
|
||||
// 用途:诊断(还剩哪些插件挂起)、以及将来"显式启用某插件"时
|
||||
// 判断它的依赖是否就绪。
|
||||
//
|
||||
// 类型是 any 以避免 kernelboot 反向依赖插件包(import 环)。
|
||||
// 取用处断言为 *Scheduler。
|
||||
PluginScheduler any
|
||||
|
||||
// PluginsReady 标记"插件调度已完成"(plugin-host 模块产出)。
|
||||
//
|
||||
// 用途:main 在装配后据此知道"插件该起的都起了",再往下走
|
||||
// (如 compactConfigDB / 启动 runtime)。
|
||||
PluginsReady bool
|
||||
|
||||
// Agent 是主 agent(agent 模块产出)。
|
||||
Agent *agentCore.Agent
|
||||
|
||||
// ── 运行期(装配阶段建立,后续流程用) ──
|
||||
|
||||
// Ctx / Stop 是 agent 运行期的取消上下文。
|
||||
//
|
||||
// ★ 为何在这里而不是模块内部:main 在装配之后还要把它传给
|
||||
// startAgentRuntime / startIPCServer。生命周期必须一致 ——
|
||||
// 装配时创建的 ctx 若不交出去,那些组件会用另一个 ctx,
|
||||
// 停机信号就传不进去。
|
||||
Ctx context.Context
|
||||
Stop context.CancelFunc
|
||||
|
||||
// Closers 是各模块注册的关闭函数(按**注册顺序**记录)。
|
||||
//
|
||||
// ★ 为何按注册顺序而不是按名字排:名字与依赖序**无关**。
|
||||
// 实测反例:注册序 config→memory→llm,按名字逆序得 memory,llm,config(错),
|
||||
// 正确的是 llm,memory,config。关闭时**逆序**执行(LIFO,与 defer 一致)。
|
||||
//
|
||||
// ★★ 为什么需要独立的锁(closerMu):
|
||||
// **同层的模块是并行 Init 的**,而它们都会调 AddCloser。
|
||||
// 实测(压力测试 + -race):第 1 层有 8 个模块并行,其中 5 个
|
||||
// (docstore/lua/memory/multimodal/textmem)都注册关闭函数
|
||||
// ⇒ 并发 append 到同一 slice ⇒ **数据竞争**。
|
||||
//
|
||||
// 后果不只是 race 警告:切片 append 在容量增长时可能**丢失元素**
|
||||
// (两个 goroutine 同时读到旧 len、各自写同一槽)⇒ 某个模块的
|
||||
// 关闭函数永远不被调用 ⇒ **资源泄漏**(且无任何报错)。
|
||||
//
|
||||
// 为什么不用 KernelState 上"模块只写自己字段"的约定来避免:
|
||||
// Closers 不属于任一模块 —— 它是**共享的回收站**。
|
||||
// 共享可变状态就必须有锁(这与"各写各的字段"是两条不同的保证)。
|
||||
Closers []func()
|
||||
|
||||
// closerMu 保护 Closers(见上)。
|
||||
closerMu sync.Mutex
|
||||
|
||||
// MigrationPreflight 是数据目录迁移体检(由 migration 模块填充,可 nil)。
|
||||
//
|
||||
// 用 func 字段而不是具体类型:kernelboot 不能依赖 migration 模块
|
||||
// (那会成环),而迁移能力本身是**可选**的 —— nil 时调用方跳过即可。
|
||||
MigrationPreflight func(target string) (interface{}, error)
|
||||
|
||||
// Quiescers 是可**暂停/恢复**的写者(伪关机用,见 lifecycle.go)。
|
||||
//
|
||||
// 与 Closers 的区别:Closers 是**一次性**的(关了就没了),
|
||||
// 而迁移需要"停下来 → 搬数据 → 再起来" ⇒ 必须可逆。
|
||||
Quiescers []Quiescer
|
||||
|
||||
quiesceMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewKernelState 创建公共结构体(所有字段零值 = nil,由各模块填充)。
|
||||
func NewKernelState() *KernelState {
|
||||
return &KernelState{}
|
||||
}
|
||||
|
||||
// AddCloser 注册一个关闭函数(模块在 Init 里调用)。
|
||||
//
|
||||
// 关闭时按**注册顺序的逆序**执行(LIFO,与 `defer` 语义一致):
|
||||
// 先初始化的一般是底层(config → memory → llm),停机应反向拆。
|
||||
func (s *KernelState) AddCloser(fn func()) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
// ★ 必须加锁:同层模块**并行** Init 时都会调它(见 Closers 字段的注释)。
|
||||
s.closerMu.Lock()
|
||||
s.Closers = append(s.Closers, fn)
|
||||
s.closerMu.Unlock()
|
||||
}
|
||||
|
||||
// CloseAll 逆序执行全部关闭函数,返回首个错误。
|
||||
//
|
||||
// ★ 失败策略:**继续执行剩余关闭**(返回首个错误)。
|
||||
// 一个模块关不掉不该阻止其他模块关 —— 那会造成更多泄漏。
|
||||
// 关闭函数 panic 也被隔离(否则一个模块的问题会连带其他模块都关不掉)。
|
||||
func (s *KernelState) CloseAll() error {
|
||||
// ★ 先取快照再执行(不持锁执行回调):关闭函数可能很慢
|
||||
// (停进程/flush 数据库),持锁会让并发调用 AddCloser 的模块卡住;
|
||||
// 且回调可能反查 KernelState ⇒ 死锁。
|
||||
s.closerMu.Lock()
|
||||
fns := make([]func(), len(s.Closers))
|
||||
copy(fns, s.Closers)
|
||||
s.Closers = nil
|
||||
s.closerMu.Unlock()
|
||||
|
||||
for i := len(fns) - 1; i >= 0; i-- {
|
||||
fn := fns[i]
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[kernelboot] 关闭函数 panic(已隔离): %v", r)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
110
internal/kernelboot/kernelstate_test.go
Normal file
110
internal/kernelboot/kernelstate_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package kernelboot
|
||||
|
||||
// KernelState(公共数据结构体)的判据。
|
||||
//
|
||||
// ## ★ 这个文件为什么是被重建的(一个真实的覆盖漏洞)
|
||||
//
|
||||
// `CloseAll` 的逆序判据原先在 `bootctx_test.go` 里。我后来用 `KernelState`
|
||||
// 取代 `BootCtx` 时**删掉了那个文件**,而新文件里**没有**补回这条判据。
|
||||
//
|
||||
// 是**变异测试**发现的:把 `CloseAll` 改成正序执行,测试**全绿**
|
||||
// (没有任何判据在检查它)⇒ 覆盖漏洞。
|
||||
//
|
||||
// 教训:删文件/重写实现时,必须**逐条核对原判据是否都补回了**
|
||||
// (判据的"数量"不是指标,"每条原判据都有对应"才是)。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★★ 判据:**CloseAll 逆序执行**(LIFO,与 defer 一致)。
|
||||
//
|
||||
// 为何逆序:先初始化的一般是底层(config → memory → llm),
|
||||
// 停机应**反向拆**,否则可能拆掉别人还在用的东西。
|
||||
func TestKernelStateCloseAllIsReverseOrder(t *testing.T) {
|
||||
ks := NewKernelState()
|
||||
var mu sync.Mutex
|
||||
var order []string
|
||||
|
||||
for _, name := range []string{"config", "memory", "llm"} {
|
||||
n := name
|
||||
ks.AddCloser(func() {
|
||||
mu.Lock()
|
||||
order = append(order, n)
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
if err := ks.CloseAll(); err != nil {
|
||||
t.Fatalf("CloseAll: %v", err)
|
||||
}
|
||||
want := []string{"llm", "memory", "config"}
|
||||
if strings.Join(order, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("★ CloseAll 应逆序(LIFO,与 defer 一致): 期望 %v 实际 %v", want, order)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**CloseAll 不因单个关闭函数 panic 而中断**。
|
||||
func TestKernelStateCloseAllSurvivesPanic(t *testing.T) {
|
||||
ks := NewKernelState()
|
||||
closed := false
|
||||
ks.AddCloser(func() { panic("关闭时炸了") })
|
||||
ks.AddCloser(func() { closed = true })
|
||||
|
||||
_ = ks.CloseAll()
|
||||
if !closed {
|
||||
t.Fatal("★ 一个关闭函数 panic 导致其他模块没被关闭(资源泄漏)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**CloseAll 幂等**(清空后再次调用不重复执行)。
|
||||
func TestKernelStateCloseAllIsIdempotent(t *testing.T) {
|
||||
ks := NewKernelState()
|
||||
var n int32
|
||||
ks.AddCloser(func() { atomic.AddInt32(&n, 1) })
|
||||
|
||||
_ = ks.CloseAll()
|
||||
_ = ks.CloseAll() // 第二次不应再执行
|
||||
if got := atomic.LoadInt32(&n); got != 1 {
|
||||
t.Fatalf("★ 关闭函数被执行 %d 次(应恰好 1 次)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**AddCloser 并发安全**(同层模块并行 Init 都会调它)。
|
||||
//
|
||||
// ★ 这条是压力测试+race 抓到的**真缺陷**的回归判据:
|
||||
// 第 1 层有 8 个模块并行,其中 5 个注册关闭函数 ⇒ 并发 append
|
||||
// ⇒ 数据竞争 + 可能丢失元素(某个模块的关闭函数永不调用 ⇒ 资源泄漏)。
|
||||
func TestKernelStateAddCloserConcurrent(t *testing.T) {
|
||||
ks := NewKernelState()
|
||||
const n = 500
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
ks.AddCloser(func() {})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(ks.Closers) != n {
|
||||
t.Fatalf("★ 并发 AddCloser 丢失了关闭函数:应 %d 个,实际 %d(slice append 竞争)",
|
||||
n, len(ks.Closers))
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**nil 关闭函数被忽略**(不 panic、不占位)。
|
||||
func TestKernelStateAddCloserIgnoresNil(t *testing.T) {
|
||||
ks := NewKernelState()
|
||||
ks.AddCloser(nil)
|
||||
if len(ks.Closers) != 0 {
|
||||
t.Fatalf("nil 关闭函数应被忽略,实际记录 %d 个", len(ks.Closers))
|
||||
}
|
||||
if err := ks.CloseAll(); err != nil {
|
||||
t.Fatalf("空表 CloseAll 应成功: %v", err)
|
||||
}
|
||||
}
|
||||
123
internal/kernelboot/lifecycle.go
Normal file
123
internal/kernelboot/lifecycle.go
Normal file
@ -0,0 +1,123 @@
|
||||
package kernelboot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// 内核生命周期:**伪关机(Quiesce)** 与 **唤醒(Resume)**。
|
||||
//
|
||||
// ## 为什么需要它(用户 2026-10-17 要求)
|
||||
//
|
||||
// 数据目录迁移时**绝不能有模块在写数据**:distiller 每 10 分钟写一次
|
||||
// memory/graph.db,text memory 滚动写 memory/text,tracker 在 overlay 里写,
|
||||
// 40 个插件各写自己的 plugin_data。迁移 21G 数据要几分钟,期间任何写入
|
||||
// 都会造成"迁移到一半的数据"或迁移后新旧不一致。
|
||||
//
|
||||
// 用户原话:
|
||||
//
|
||||
// 「迁移模块要先让 core 暂停其他模块运行(伪关机)完成迁移后再唤醒」
|
||||
//
|
||||
// ## 为什么不能用现成的 CloseAll
|
||||
//
|
||||
// `CloseAll` 是**一次性**的:它执行完就把 `Closers` 清空(切片置 nil),
|
||||
// 且各模块的 Init 有副作用(建库、播种、迁移扫描),**不能简单重跑**。
|
||||
// 用它做伪关机 = 关掉就再也起不来,等于要求重启进程 —— 那就不是"伪"关机了。
|
||||
//
|
||||
// ## 本文件的做法
|
||||
//
|
||||
// 分两级,都**可逆**:
|
||||
//
|
||||
// Quiesce() → 让所有"活动写者"停下来,但**保留连接/句柄**
|
||||
// Resume() → 把停下来的重新启动
|
||||
//
|
||||
// 参与方式:模块在 Init 里通过 `AddQuiescer(name, pause, resume)` 登记。
|
||||
// 与 CloseAll 一样**逆序**唤醒(LIFO)——后启动的先恢复,保证依赖顺序。
|
||||
//
|
||||
// ## ★ 关键约束(写进类型里)
|
||||
//
|
||||
// 1. pause 必须**幂等**且**不关闭文件/DB 句柄**(关了就无法 resume)
|
||||
// 2. pause 必须**阻塞到确实不再写**(否则迁移仍有竞态)
|
||||
// 3. resume 失败必须**可见**(返回错误),不能静默"半醒"
|
||||
// 4. 只有**持有写句柄**的模块才需要登记 —— 纯读模块不必(如 config)
|
||||
type Quiescer struct {
|
||||
// Name 用于日志与诊断(哪个模块停不下来,一眼看到)。
|
||||
Name string
|
||||
// Pause 停下活动写入并**等到确实停了**才返回。
|
||||
Pause func() error
|
||||
// Resume 恢复写入。失败必须返回错误(不能假装成功)。
|
||||
Resume func() error
|
||||
}
|
||||
|
||||
// AddQuiescer 登记一个可暂停/恢复的写者(在模块 Init 里调用)。
|
||||
func (s *KernelState) AddQuiescer(name string, pause, resume func() error) {
|
||||
if pause == nil {
|
||||
return
|
||||
}
|
||||
s.quiesceMu.Lock()
|
||||
s.Quiescers = append(s.Quiescers, Quiescer{Name: name, Pause: pause, Resume: resume})
|
||||
s.quiesceMu.Unlock()
|
||||
}
|
||||
|
||||
// Quiesce 伪关机:**逆序**暂停全部已登记的写者。
|
||||
//
|
||||
// 返回成功暂停的清单 —— Resume 只唤醒这些(没有的不要"凭空唤醒")。
|
||||
// 任何一个 Pause 失败都会**停止继续暂停**并返回错误:宁可只关一半
|
||||
// 让调用方放弃迁移,也不要"看起来关了但仍有写者在跑"。
|
||||
func (s *KernelState) Quiesce() ([]string, error) {
|
||||
s.quiesceMu.Lock()
|
||||
qs := make([]Quiescer, len(s.Quiescers))
|
||||
copy(qs, s.Quiescers)
|
||||
s.quiesceMu.Unlock()
|
||||
|
||||
// 逆序暂停:后启动的先停(与启动顺序相反,先停"消费者"再停"生产者")
|
||||
var paused []string
|
||||
for i := len(qs) - 1; i >= 0; i-- {
|
||||
q := qs[i]
|
||||
if err := q.Pause(); err != nil {
|
||||
// 已暂停的按**反序**回滚(把它唤醒回去),别留半关状态
|
||||
for j := len(paused) - 1; j >= 0; j-- {
|
||||
if r := s.resumeOne(qs, paused[j]); r != nil {
|
||||
log.Printf("[quiesce] 回滚唤醒 %s 失败: %v", paused[j], r)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("暂停 %s 失败(已回滚 %d 个): %w", q.Name, len(paused), err)
|
||||
}
|
||||
paused = append(paused, q.Name)
|
||||
log.Printf("[quiesce] 已暂停: %s", q.Name)
|
||||
}
|
||||
return paused, nil
|
||||
}
|
||||
|
||||
// Resume 唤醒:按**暂停的逆序**(即原启动顺序)恢复。
|
||||
func (s *KernelState) Resume(paused []string) error {
|
||||
s.quiesceMu.Lock()
|
||||
qs := make([]Quiescer, len(s.Quiescers))
|
||||
copy(qs, s.Quiescers)
|
||||
s.quiesceMu.Unlock()
|
||||
|
||||
var firstErr error
|
||||
for i := len(paused) - 1; i >= 0; i-- {
|
||||
if err := s.resumeOne(qs, paused[i]); err != nil {
|
||||
log.Printf("[quiesce] 唤醒 %s 失败: %v", paused[i], err)
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("唤醒 %s: %w", paused[i], err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[quiesce] 已唤醒: %s", paused[i])
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *KernelState) resumeOne(qs []Quiescer, name string) error {
|
||||
for _, q := range qs {
|
||||
if q.Name == name {
|
||||
if q.Resume == nil {
|
||||
return nil
|
||||
}
|
||||
return q.Resume()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("未登记的写者: %s", name)
|
||||
}
|
||||
123
internal/kernelboot/makefile_inject_test.go
Normal file
123
internal/kernelboot/makefile_inject_test.go
Normal file
@ -0,0 +1,123 @@
|
||||
package kernelboot
|
||||
|
||||
// Makefile 的**编译期 hash 注入**判据(2026-10-17)。
|
||||
//
|
||||
// ## 守什么
|
||||
//
|
||||
// `make build` 必须把**真实的**依赖图 digest 注入 homed 的
|
||||
// `kernelboot.EmbeddedGraphHash`。若注入失败(值为空、或变成字面量
|
||||
// `$GRAPH_HASH`),内核启动时会做配套校验:
|
||||
//
|
||||
// 磁盘图的 hash: 44823b02…
|
||||
// 二进制内嵌的: $GRAPH_HASH
|
||||
// ⇒ ★ 依赖图与本二进制**不配套** ⇒ **拒绝启动**
|
||||
//
|
||||
// ## ★ 为什么必须有这条(实测缺陷的形态)
|
||||
//
|
||||
// 原 Makefile 把整串 ldflags 包在**单引号**里:
|
||||
//
|
||||
// -ldflags '... -X ...EmbeddedGraphHash=$$GRAPH_HASH'
|
||||
//
|
||||
// shell 里**单引号不做变量展开** ⇒ 注入的是**字面量** `$GRAPH_HASH`。
|
||||
//
|
||||
// 这个缺陷最难的地方是「**所有表面信号都是绿的**」:
|
||||
// · `make build` 退出码 0
|
||||
// · 日志打印 `已注入启动依赖图 hash: 44823b02…`(那是 echo,不是注入结果)
|
||||
// · 依赖图文件正常生成、自校验通过
|
||||
// 只有**真跑内核**才炸,而且报错指向"改了 Requires 没重出图"——**方向是错的**。
|
||||
//
|
||||
// ⇒ 判据必须查两件事:
|
||||
// ① Makefile 里注入 hash 用的引号**允许展开**(不能是单引号包 $$VAR)
|
||||
// ② 构建产物里**没有**字面量 `$GRAPH_HASH` 残留
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★★ 判据:Makefile 注入 hash 时不得用单引号包 `$$GRAPH_HASH`。
|
||||
//
|
||||
// ★ 为什么是源码判据而不是"跑一次 make build 再看二进制":
|
||||
//
|
||||
// 跑构建很贵(分钟级 + 需要 cgo/onnx),不适合放进单测;
|
||||
// 而这里要断言的是**一个引号选择**,属于"写法模式存在性",
|
||||
// 源码断言**精确且便宜**。真正的端到端验证由发版流程做
|
||||
// (见 README 的「构建后必须真跑一次内核」)。
|
||||
func TestMakefileGraphHashIsShellExpandable(t *testing.T) {
|
||||
b, err := os.ReadFile("../../Makefile")
|
||||
if err != nil {
|
||||
t.Skipf("读不到 Makefile: %v", err)
|
||||
}
|
||||
src := string(b)
|
||||
|
||||
// 定位注入行
|
||||
idx := strings.Index(src, "EmbeddedGraphHash=$$GRAPH_HASH")
|
||||
if idx < 0 {
|
||||
t.Fatal("Makefile 里找不到 EmbeddedGraphHash=$$GRAPH_HASH 的注入点 —— " +
|
||||
"若改了注入方式,请同步更新本判据(不要删掉它)")
|
||||
}
|
||||
// 取该行
|
||||
lineStart := strings.LastIndex(src[:idx], "\n") + 1
|
||||
lineEnd := strings.Index(src[idx:], "\n")
|
||||
if lineEnd < 0 {
|
||||
lineEnd = len(src) - idx
|
||||
}
|
||||
line := src[lineStart : idx+lineEnd]
|
||||
|
||||
// ★ 核心断言:该行里 `$$GRAPH_HASH` 前面不能是**单引号开启的引用段**。
|
||||
// 判定方式:检查 `-ldflags` 到 `$$GRAPH_HASH` 之间的引号是否平衡,
|
||||
// 若在到达 $$GRAPH_HASH 之前遇到**奇数个**单引号 ⇒ 它处在单引号里 ⇒ 不展开。
|
||||
ld := strings.Index(line, "-ldflags")
|
||||
if ld < 0 {
|
||||
t.Fatalf("注入行里没有 -ldflags: %s", line)
|
||||
}
|
||||
seg := line[ld:strings.Index(line, "$$GRAPH_HASH")]
|
||||
sq := strings.Count(seg, "'")
|
||||
dq := strings.Count(seg, `"`)
|
||||
if sq%2 == 1 {
|
||||
t.Fatalf("★ -ldflags 段用**单引号**包住了 $$GRAPH_HASH ⇒ shell 不做变量展开\n"+
|
||||
" ⇒ 注入进二进制的是**字面量** `$GRAPH_HASH` ⇒ 内核启动时\n"+
|
||||
" 「磁盘图 digest」vs「内嵌 $GRAPH_HASH」不等 ⇒ **拒绝启动**。\n"+
|
||||
" (而 make build 日志全绿,只有真跑内核才暴露)\n"+
|
||||
" 修法:改用双引号包 ldflags。\n 实际行: %s", line)
|
||||
}
|
||||
// 正向:应当有双引号(允许展开)
|
||||
if dq%2 == 0 {
|
||||
t.Logf("提示:本行未用双引号包 ldflags(若用无引号形式且无空格也可接受): %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:构建产物里**没有**字面量 `$GRAPH_HASH` 残留。
|
||||
//
|
||||
// 需要 build/homed 存在(跑过 make build)。不存在则跳过 ——
|
||||
// 这条是**补充**判据,主判据是上面那条源码检查。
|
||||
func TestBuiltHomedHasNoLiteralGraphHash(t *testing.T) {
|
||||
b, err := os.ReadFile("../../build/homed")
|
||||
if err != nil {
|
||||
t.Skipf("未构建 build/homed(跳过): %v", err)
|
||||
}
|
||||
if strings.Contains(string(b), "$GRAPH_HASH") {
|
||||
t.Fatal("★ build/homed 里含**字面量** `$GRAPH_HASH` —— " +
|
||||
"说明 -ldflags 注入没展开(单引号问题)⇒ 内核会拒绝启动")
|
||||
}
|
||||
// 正向:必须含有某个 64 位 hex(注入成功)
|
||||
hasHex := false
|
||||
for i := 0; i+64 <= len(b); i++ {
|
||||
ok := true
|
||||
for j := i; j < i+64; j++ {
|
||||
c := b[j]
|
||||
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
hasHex = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasHex {
|
||||
t.Error("build/homed 里找不到 64 位 hex(依赖图 digest 未注入?)")
|
||||
}
|
||||
}
|
||||
182
internal/kernelboot/migrate.go
Normal file
182
internal/kernelboot/migrate.go
Normal file
@ -0,0 +1,182 @@
|
||||
package kernelboot
|
||||
|
||||
// 数据目录迁移的**编排**:伪关机 → 迁移 → 唤醒。
|
||||
//
|
||||
// ## 流程(用户要求的顺序)
|
||||
//
|
||||
// ① Freeze 插件工具入口 —— 不再进新的工具调用
|
||||
// ② Quiesce 各模块写者 —— distiller/textmem 等停下后台写
|
||||
// ③ WaitDrain 等在途调用归零 —— 不能搬"写了一半的数据"
|
||||
// ④ (外部脚本执行 rsync) —— 真搬数据 + 改 systemd + 改 DB
|
||||
// ⑤ Resume 写者 —— 按启动顺序恢复
|
||||
// ⑥ Thaw 工具入口 —— 重新接受工具调用
|
||||
//
|
||||
// ## ★ 本包只做 ①②③⑤⑥,不执行 ④
|
||||
//
|
||||
// 迁移是**破坏性且不可逆**的(21G 数据 + 12 处 DB 改写 + 46 个插件文件改写),
|
||||
// 而且内核跑在 systemd 沙箱里(ProtectSystem/ProtectHome)**本来就写不了
|
||||
// /etc/systemd**。⇒ 真迁移交给运维执行生成的脚本(见 internal/systemd/migrate.go),
|
||||
// 本包负责**把它前后包起来**:确保迁移窗口内没有任何写者。
|
||||
//
|
||||
// ## ★ 失败策略:任何一步失败都要**回滚到能跑的状态**
|
||||
//
|
||||
// 伪关机失败 ⇒ 立刻唤醒已停的,让内核回到正常态,别卡在半关。
|
||||
// 迁移失败 ⇒ 调用方负责回滚数据;本包的 Resume 仍然要执行(否则内核永久冻结)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// inflightToolsFn 报告当前在途工具调用数(由装配处注入)。
|
||||
//
|
||||
// 用函数注入而不用字段直连:kernelboot 不能依赖 agent/core。
|
||||
var inflightToolsFn atomic.Value // func() int64
|
||||
|
||||
// SetInflightToolCounter 注入在途工具计数(装配处调一次)。
|
||||
func SetInflightToolCounter(fn func() int64) {
|
||||
if fn != nil {
|
||||
inflightToolsFn.Store(fn)
|
||||
}
|
||||
}
|
||||
|
||||
func hasInflightTools() bool {
|
||||
v := inflightToolsFn.Load()
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
fn, _ := v.(func() int64)
|
||||
if fn == nil {
|
||||
return false
|
||||
}
|
||||
return fn() > 0
|
||||
}
|
||||
|
||||
// PluginQuiescer 是**插件层的伪关机能力**。
|
||||
//
|
||||
// ★ 这里刻意复用已有的**运行态停启**(Registry.StopPlugin/StartPlugin),
|
||||
//
|
||||
// 而不是新造"冻结"机制 —— 三态生命周期(自动启动 / 启用但不自动启动 /
|
||||
// 禁用)早已存在,且运行态停启**不写任何持久状态**(不碰 disabled_plugins,
|
||||
// 也不置 shuttingDown)⇒ 天然可逆,正是伪关机需要的语义。
|
||||
//
|
||||
// StopPlugin ⇒ 停掉子进程 + **摘掉注册面**(工具/stage/通道)
|
||||
// ⇒ 停完模型看不到、也调不到它们(比"冻结入口"更彻底)
|
||||
// StartPlugin ⇒ 重新拉起,清运行态门
|
||||
//
|
||||
// ★ 与 StopAll 的区别(用错就是灾难):StopAll 置 shuttingDown 后
|
||||
//
|
||||
// **插件永远无法再加载**,那是进程退出用的,绝不能用于伪关机。
|
||||
type PluginQuiescer interface {
|
||||
// StopPluginRuntime 运行态停掉一个插件(不改持久状态)。
|
||||
StopPluginRuntime(name string) error
|
||||
// StartPluginRuntime 运行态拉起一个插件。
|
||||
StartPluginRuntime(name string) error
|
||||
// RunningPlugins 返回当前正在运行的插件名(用于决定停哪些)。
|
||||
RunningPlugins() []string
|
||||
}
|
||||
|
||||
// MigrateSession 表示一次"伪关机窗口"。用它包住外部迁移动作。
|
||||
//
|
||||
// ★ 用法必须用 defer 保证唤醒 —— 否则迁移失败时内核永久冻结:
|
||||
//
|
||||
// sess, err := ks.BeginMigration(freezer)
|
||||
// if err != nil { return err }
|
||||
// defer sess.End() // 无论成功失败都唤醒
|
||||
// // ... 外部脚本迁移 ...
|
||||
type MigrateSession struct {
|
||||
ks *KernelState
|
||||
plugins PluginQuiescer
|
||||
paused []string
|
||||
stopped []string // 已运行态停掉的插件(按停止顺序,唤醒时逆序)
|
||||
ended atomic.Bool
|
||||
}
|
||||
|
||||
// BeginMigration 进入伪关机:停插件(运行态)→ 逆序暂停内核写者 → 等在途归零。
|
||||
//
|
||||
// 返回的 session 必须由调用方 `defer sess.End()`。
|
||||
func (s *KernelState) BeginMigration(pq PluginQuiescer) (*MigrateSession, error) {
|
||||
sess := &MigrateSession{ks: s, plugins: pq}
|
||||
|
||||
// ① 先停**插件**(运行态停启,可逆)。
|
||||
//
|
||||
// 为什么插件排第一:插件是最大的写入源(rss 写 feeds、calendar 写 events、
|
||||
// commondev 写 git 镜像)。而且 StopPlugin 会**摘掉注册面** ⇒ 停完
|
||||
// 模型看不到也调不到它们,比"只挡入口"更彻底。
|
||||
//
|
||||
// ★ 为什么不用 StopAll:它会置 shuttingDown,之后插件**永久无法加载**。
|
||||
// 那是进程退出路径用的,用于伪关机等于"关了就起不来"。
|
||||
if pq != nil {
|
||||
running := pq.RunningPlugins()
|
||||
// 逆序停(最后启动的先停,与依赖相反)
|
||||
for i := len(running) - 1; i >= 0; i-- {
|
||||
name := running[i]
|
||||
if err := pq.StopPluginRuntime(name); err != nil {
|
||||
// 停不下来 ⇒ 回滚已停的,别留半关
|
||||
for j := len(sess.stopped) - 1; j >= 0; j-- {
|
||||
if r := pq.StartPluginRuntime(sess.stopped[j]); r != nil {
|
||||
log.Printf("[migrate] 回滚启动插件 %s 失败: %v", sess.stopped[j], r)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("运行态停止插件 %s 失败(已回滚 %d 个): %w",
|
||||
name, len(sess.stopped), err)
|
||||
}
|
||||
sess.stopped = append(sess.stopped, name)
|
||||
}
|
||||
log.Printf("[migrate] 已运行态停止 %d 个插件(不改持久状态,唤醒即恢复)", len(sess.stopped))
|
||||
}
|
||||
|
||||
// ② 逆序暂停内核写者(distiller/textmem 等后台定时写)
|
||||
paused, err := s.Quiesce()
|
||||
if err != nil {
|
||||
// 失败 ⇒ 把插件拉回去,内核回到正常态
|
||||
for j := len(sess.stopped) - 1; j >= 0; j-- {
|
||||
if r := pq.StartPluginRuntime(sess.stopped[j]); r != nil {
|
||||
log.Printf("[migrate] 回滚启动插件 %s 失败: %v", sess.stopped[j], r)
|
||||
}
|
||||
}
|
||||
log.Printf("[migrate] 伪关机失败,已回滚: %v", err)
|
||||
return nil, fmt.Errorf("伪关机失败: %w", err)
|
||||
}
|
||||
sess.paused = paused
|
||||
|
||||
// ③ 等在途工具调用归零(尽力而为,见下)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !hasInflightTools() {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if hasInflightTools() {
|
||||
log.Printf("[migrate] warning: 仍有在途工具调用未完成(继续;迁移脚本 stop 服务后天然安全)")
|
||||
}
|
||||
|
||||
log.Printf("[migrate] 伪关机完成(插件 %d 个、内核写者 %d 个)", len(sess.stopped), len(paused))
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// End 唤醒:逆序恢复内核写者 → 逆序拉起插件。幂等。
|
||||
func (m *MigrateSession) End() {
|
||||
if m == nil || m.ended.Swap(true) {
|
||||
return
|
||||
}
|
||||
// ① 先恢复内核写者(它们是被插件依赖的下层)
|
||||
if err := m.ks.Resume(m.paused); err != nil {
|
||||
log.Printf("[migrate] ★ 唤醒内核写者失败(可能半醒): %v", err)
|
||||
}
|
||||
// ② 再拉起插件(按**停止的逆序** = 原启动顺序)
|
||||
if m.plugins != nil {
|
||||
for i := len(m.stopped) - 1; i >= 0; i-- {
|
||||
name := m.stopped[i]
|
||||
if err := m.plugins.StartPluginRuntime(name); err != nil {
|
||||
log.Printf("[migrate] ★ 拉起插件 %s 失败: %v", name, err)
|
||||
} else {
|
||||
log.Printf("[migrate] 已拉起插件: %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("[migrate] 唤醒完成")
|
||||
}
|
||||
219
internal/kernelboot/migrate_test.go
Normal file
219
internal/kernelboot/migrate_test.go
Normal file
@ -0,0 +1,219 @@
|
||||
package kernelboot
|
||||
|
||||
// 伪关机 / 唤醒(迁移窗口)的判据(2026-10-17)。
|
||||
//
|
||||
// ## 背景
|
||||
//
|
||||
// 用户要求:「迁移模块要先让 core 暂停其他模块运行(伪关机)完成迁移后再唤醒」。
|
||||
//
|
||||
// 数据目录迁移 21G 要几分钟,期间任何写者(distiller 每 10min 写 graph.db、
|
||||
// textmem 滚动写、插件写 plugin_data)都会污染数据。
|
||||
//
|
||||
// ## 这组判据守什么
|
||||
//
|
||||
// 1. Quiesce 必须**逆序**暂停(后启动的先停:先停消费者再停生产者)
|
||||
// 2. Pause 失败必须**回滚**(不能留半关状态)
|
||||
// 3. Resume 必须**逆序**恢复(与暂停相反 = 原启动顺序)
|
||||
// 4. End 必须**幂等**(重复调用不重复 Resume)
|
||||
// 5. **失败也必须唤醒**(否则内核永久冻结,比不迁移更糟)
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestKS() *KernelState { return NewKernelState() }
|
||||
|
||||
// ★★★ 判据:Quiesce 逆序暂停、Resume 逆序恢复。
|
||||
func TestQuiescePausesInReverseOrder(t *testing.T) {
|
||||
ks := newTestKS()
|
||||
var mu sync.Mutex
|
||||
var pausedOrder, resumedOrder []string
|
||||
|
||||
add := func(name string) {
|
||||
ks.AddQuiescer(name,
|
||||
func() error {
|
||||
mu.Lock()
|
||||
pausedOrder = append(pausedOrder, name)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
},
|
||||
func() error {
|
||||
mu.Lock()
|
||||
resumedOrder = append(resumedOrder, name)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
// 模拟启动顺序:a → b → c
|
||||
add("a")
|
||||
add("b")
|
||||
add("c")
|
||||
|
||||
paused, err := ks.Quiesce()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 暂停必须逆序:c, b, a(先停最后启动的)
|
||||
want := []string{"c", "b", "a"}
|
||||
if len(pausedOrder) != 3 || pausedOrder[0] != "c" || pausedOrder[2] != "a" {
|
||||
t.Fatalf("★ Quiesce 顺序错:实际 %v,应为 %v(后启动的先停,先停消费者)",
|
||||
pausedOrder, want)
|
||||
}
|
||||
|
||||
if err := ks.Resume(paused); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 恢复必须与暂停相反 = 原启动顺序 a, b, c
|
||||
if len(resumedOrder) != 3 || resumedOrder[0] != "a" || resumedOrder[2] != "c" {
|
||||
t.Fatalf("★ Resume 顺序错:实际 %v,应为 [a b c](与暂停相反 = 启动顺序)", resumedOrder)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:Pause 失败必须**回滚已暂停的**(不留半关状态)。
|
||||
func TestQuiesceRollsBackOnFailure(t *testing.T) {
|
||||
ks := newTestKS()
|
||||
var mu sync.Mutex
|
||||
resumed := map[string]int{}
|
||||
|
||||
ks.AddQuiescer("ok1", func() error { return nil }, func() error {
|
||||
mu.Lock()
|
||||
resumed["ok1"]++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
ks.AddQuiescer("boom", func() error { return errors.New("停不下来") }, func() error {
|
||||
mu.Lock()
|
||||
resumed["boom"]++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
// 逆序:boom 先被暂停 ⇒ 它失败 ⇒ ok1 还没被暂停(因为逆序时 ok1 在 boom 之后)
|
||||
// 为了真正测到"回滚",让失败发生在**后面的**那个
|
||||
ks2 := newTestKS()
|
||||
ks2.AddQuiescer("boom", func() error { return errors.New("停不下来") }, nil)
|
||||
ks2.AddQuiescer("ok1", func() error { return nil }, func() error {
|
||||
mu.Lock()
|
||||
resumed["ok1"]++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
// 逆序暂停:ok1 先(成功)→ boom(失败)⇒ 应回滚 ok1
|
||||
if _, err := ks2.Quiesce(); err == nil {
|
||||
t.Fatal("Pause 失败时 Quiesce 必须返回错误(不能假装成功)")
|
||||
}
|
||||
if resumed["ok1"] == 0 {
|
||||
t.Fatal("★ Pause 失败后未回滚已暂停的 ok1 ⇒ 内核处于**半关**状态")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:End 幂等(重复调用不重复 Resume)。
|
||||
func TestMigrateSessionEndIsIdempotent(t *testing.T) {
|
||||
ks := newTestKS()
|
||||
var mu sync.Mutex
|
||||
n := 0
|
||||
ks.AddQuiescer("w", func() error { return nil }, func() error {
|
||||
mu.Lock()
|
||||
n++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
sess, err := ks.BeginMigration(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sess.End()
|
||||
sess.End()
|
||||
sess.End()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Fatalf("★ End 不幂等:Resume 被调用 %d 次(应为 1)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// fakePlugins 模拟插件层的运行态停启(**可逆**,与 StopAll 相反)。
|
||||
type fakePlugins struct {
|
||||
mu sync.Mutex
|
||||
running []string
|
||||
stops int
|
||||
starts int
|
||||
failOn string // 停这个插件时失败
|
||||
}
|
||||
|
||||
func (f *fakePlugins) StopPluginRuntime(name string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if name == f.failOn {
|
||||
return errors.New("停不下来")
|
||||
}
|
||||
f.stops++
|
||||
for i, n := range f.running {
|
||||
if n == name {
|
||||
f.running = append(f.running[:i], f.running[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePlugins) StartPluginRuntime(name string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.starts++
|
||||
f.running = append(f.running, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePlugins) RunningPlugins() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]string, len(f.running))
|
||||
copy(out, f.running)
|
||||
return out
|
||||
}
|
||||
|
||||
// ★★★ 判据:伪关机停掉全部插件,唤醒后**全部恢复**(可逆)。
|
||||
//
|
||||
// 这是"用运行态停启而不是 StopAll"的核心保证:
|
||||
// StopAll 置 shuttingDown 之后插件**永久起不来**,用于伪关机就是关了就死。
|
||||
func TestMigrationStopsAndRestoresAllPlugins(t *testing.T) {
|
||||
ks := newTestKS()
|
||||
fp := &fakePlugins{running: []string{"webui", "rss", "calendar", "commondev"}}
|
||||
|
||||
sess, err := ks.BeginMigration(fp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fp.RunningPlugins(); len(got) != 0 {
|
||||
t.Fatalf("★ 伪关机后仍有插件在运行: %v(迁移期间会写数据)", got)
|
||||
}
|
||||
if fp.stops != 4 {
|
||||
t.Fatalf("应停 4 个插件,实际 %d", fp.stops)
|
||||
}
|
||||
|
||||
sess.End()
|
||||
if got := fp.RunningPlugins(); len(got) != 4 {
|
||||
t.Fatalf("★ 唤醒后插件未全部恢复: %v(应 4 个)—— 这正是用 StopAll 会造成的后果", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:停插件失败必须**回滚已停的**(不留半关)。
|
||||
func TestMigrationRollsBackPluginsOnFailure(t *testing.T) {
|
||||
ks := newTestKS()
|
||||
// 逆序停:commondev → calendar → rss → webui
|
||||
// 让 calendar 失败 ⇒ 已停的 commondev 必须被拉回
|
||||
fp := &fakePlugins{running: []string{"webui", "rss", "calendar", "commondev"}, failOn: "calendar"}
|
||||
|
||||
if _, err := ks.BeginMigration(fp); err == nil {
|
||||
t.Fatal("停插件失败时 BeginMigration 必须返回错误")
|
||||
}
|
||||
got := fp.RunningPlugins()
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("★ 回滚不完整:只剩 %v(应全部 4 个)—— 内核处于半关状态", got)
|
||||
}
|
||||
}
|
||||
94
internal/kernelboot/modules/registry.go
Normal file
94
internal/kernelboot/modules/registry.go
Normal file
@ -0,0 +1,94 @@
|
||||
// Package modules 是内核模块的**登记表**(不含任何具体模块的实现)。
|
||||
//
|
||||
// ## 设计意图(用户设计)
|
||||
//
|
||||
// > 每个内核模块显式各自声明依赖 … 最先初始化的 core 自然不依赖任何模块,
|
||||
// > 下一步初始化的依赖 core 以此类推
|
||||
//
|
||||
// ## 怎么加一个新模块(这是本设计的**验收标准**)
|
||||
//
|
||||
// ① 新建一个文件(如 modules/mymod.go)
|
||||
// ② 写一个类型,实现 kernelboot.Module(Name/Requires/Provides/Init)
|
||||
// ③ 在同文件的 init() 里调 modules.Register(&MyMod{})
|
||||
// ④ 在 modules/imports.go 加一行 `_ "...modules"` —— **仅此而已**
|
||||
//
|
||||
// **不需要**编辑任何中央清单:依赖写在模块**自己**的 Requires() 里。
|
||||
//
|
||||
// ★ 为何要"防漏 import"的判据(见 registry_test.go):
|
||||
// 忘了 import ⇒ 模块**根本不登记** ⇒ 它不参与装配 ⇒ 依赖它的模块报
|
||||
// "缺依赖"(响亮),但**不依赖它的模块会静默少了那个设施**
|
||||
// (如插件注册表没建 ⇒ 插件全部加载不了,而错误信息指向插件而非根因)。
|
||||
// ⇒ 判据必须自动扫描目录,要求每个模块文件都被 import。
|
||||
package modules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
)
|
||||
|
||||
var (
|
||||
regMu sync.Mutex
|
||||
registry []kernelboot.Module
|
||||
seenName = map[string]bool{}
|
||||
)
|
||||
|
||||
// Register 由模块在自己的 init() 里调用(自注册)。
|
||||
//
|
||||
// 重复注册同一模块名会**记录**并在 BuildModules 时报错 —— 不用 panic:
|
||||
// panic 发生在 init 阶段,栈信息对使用者毫无意义(他只想知道哪个模块重名)。
|
||||
func Register(m kernelboot.Module) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
regMu.Lock()
|
||||
defer regMu.Unlock()
|
||||
if seenName[m.Name()] {
|
||||
dupErr = append(dupErr, m.Name())
|
||||
return
|
||||
}
|
||||
seenName[m.Name()] = true
|
||||
registry = append(registry, m)
|
||||
}
|
||||
|
||||
// dupErr 累积重名模块(BuildModules 时统一报告)。
|
||||
var dupErr []string
|
||||
|
||||
// BuildModules 返回全部已登记模块(按名字排序,保证可复现)。
|
||||
//
|
||||
// ## 为什么排序
|
||||
//
|
||||
// 分层结果的**层内顺序**若依赖 map/切片顺序,同一份声明两次构建会得到
|
||||
// 不同顺序 ⇒ 不可审计(设计 §2.5:图要能进 PR diff 比对)。
|
||||
// 排序让结果稳定。
|
||||
func BuildModules() ([]kernelboot.Module, error) {
|
||||
regMu.Lock()
|
||||
defer regMu.Unlock()
|
||||
if len(dupErr) > 0 {
|
||||
names := append([]string(nil), dupErr...)
|
||||
sort.Strings(names)
|
||||
return nil, fmt.Errorf("kernelboot/modules: 模块名重复注册: %v"+
|
||||
"(同一个模块被 import 了两次?或两个模块取了同名)", names)
|
||||
}
|
||||
out := append([]kernelboot.Module(nil), registry...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("kernelboot/modules: 没有任何模块被登记 —— " +
|
||||
"通常是没有 import 模块包(见本文件顶部的'怎么加一个新模块')")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Names 返回已登记模块名(诊断/判据用)。
|
||||
func Names() []string {
|
||||
regMu.Lock()
|
||||
defer regMu.Unlock()
|
||||
out := make([]string, 0, len(registry))
|
||||
for _, m := range registry {
|
||||
out = append(out, m.Name())
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
167
internal/kernelboot/plaingraph_test.go
Normal file
167
internal/kernelboot/plaingraph_test.go
Normal file
@ -0,0 +1,167 @@
|
||||
package kernelboot
|
||||
|
||||
// 编译期图 → 运行期启动计划的判据(设计 §2.5,用户要求:
|
||||
// "启动顺序是编译期间按照声明的依赖顺序生成的启动依赖图排列的")。
|
||||
//
|
||||
// ## 这组判据守什么
|
||||
//
|
||||
// ① 运行期**读**图里的顺序,不重算(层号被真正使用)
|
||||
// ② 图与声明**漂移**时拒绝启动(§2.5.2 的病 A)
|
||||
// ③ 图与二进制**描述不同模块集**时拒绝启动
|
||||
// ④ 自校验能抓损坏(已有判据,这里补"层号参与校验")
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 造一个可分层的小世界。
|
||||
func planTestModules() []Module {
|
||||
return []Module{
|
||||
&fakeModule{name: "config", provides: []string{"config"}},
|
||||
&fakeModule{name: "lua", provides: []string{"lua"}, requires: []string{"config"}},
|
||||
&fakeModule{name: "llm", provides: []string{"llm"}, requires: []string{"config", "lua"}},
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**PlanFromGraph 按图中的层号排计划**(而不是重算)。
|
||||
func TestPlanFromGraphUsesEmbeddedLayers(t *testing.T) {
|
||||
mods := planTestModules()
|
||||
built, err := Build(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edges := planToEdgesForTest(built)
|
||||
gf := decodeGraphOrFatal(t, EncodeGraph(edges))
|
||||
|
||||
plan, err := PlanFromGraph(gf, mods)
|
||||
if err != nil {
|
||||
t.Fatalf("PlanFromGraph: %v", err)
|
||||
}
|
||||
if len(plan.Layers) != len(built.Layers) {
|
||||
t.Fatalf("层数不符: %d != %d", len(plan.Layers), len(built.Layers))
|
||||
}
|
||||
for i := range built.Layers {
|
||||
got := names(plan.Layers[i])
|
||||
want := names(built.Layers[i])
|
||||
if !equalStrs(got, want) {
|
||||
t.Errorf("第 %d 层不符: %v != %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**层号真的被使用**(有人重算而不读图 ⇒ 报红)。
|
||||
//
|
||||
// 做法:手工把图里 lua 的层号改成 0(与声明矛盾)。
|
||||
// 若 PlanFromGraph 重算而不读,它会"照常工作";读了图并校验 ⇒ 报漂移。
|
||||
func TestPlanFromGraphDetectsLayerDrift(t *testing.T) {
|
||||
mods := planTestModules()
|
||||
built, _ := Build(mods)
|
||||
edges := planToEdgesForTest(built)
|
||||
|
||||
// 篡改:把 lua 标成第 0 层(它实际在第 1 层)
|
||||
for i := range edges {
|
||||
if edges[i].Name == "lua" {
|
||||
edges[i].Layer = 0
|
||||
}
|
||||
}
|
||||
gf := decodeGraphOrFatal(t, EncodeGraph(edges))
|
||||
|
||||
_, err := PlanFromGraph(gf, mods)
|
||||
if err == nil {
|
||||
t.Fatal("★ 图里的层号与声明矛盾,却照常构造了计划 —— " +
|
||||
"运行期必须用图里的顺序,并校验它与声明一致(否则旧图会给出错误顺序)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "漂移") {
|
||||
t.Errorf("错误应点明是\"漂移\"(改了声明没重出图): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**图与二进制模块集不一致时拒绝启动**。
|
||||
func TestPlanFromGraphDetectsModuleSetMismatch(t *testing.T) {
|
||||
mods := planTestModules()
|
||||
built, _ := Build(mods)
|
||||
edges := planToEdgesForTest(built)
|
||||
|
||||
// ① 二进制里少了一个模块(模拟"图是新的、二进制是旧的")
|
||||
gf := decodeGraphOrFatal(t, EncodeGraph(edges))
|
||||
if _, err := PlanFromGraph(gf, mods[:2]); err == nil {
|
||||
t.Fatal("★ 图描述了二进制里没有的模块,却通过了 —— 会把旧芯片的图配新二进制")
|
||||
}
|
||||
|
||||
// ② 图里少了一个模块
|
||||
short := make([]Module, 0, 2)
|
||||
short = append(short, mods[0], mods[1])
|
||||
shortBuilt, _ := Build(short)
|
||||
gf2 := decodeGraphOrFatal(t, EncodeGraph(planToEdgesForTest(shortBuilt)))
|
||||
if _, err := PlanFromGraph(gf2, mods); err == nil {
|
||||
t.Fatal("★ 二进制里有图里没有的模块,却通过了")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**层号进了二进制**(编解码往返保真)。
|
||||
func TestGraphRoundTripsLayer(t *testing.T) {
|
||||
mods := planTestModules()
|
||||
built, _ := Build(mods)
|
||||
edges := planToEdgesForTest(built)
|
||||
gf := decodeGraphOrFatal(t, EncodeGraph(edges))
|
||||
|
||||
byName := map[string]int{}
|
||||
for _, e := range gf.Modules {
|
||||
byName[e.Name] = e.Layer
|
||||
}
|
||||
for _, m := range mods {
|
||||
_ = m
|
||||
}
|
||||
if byName["config"] != 0 || byName["lua"] != 1 || byName["llm"] != 2 {
|
||||
t.Fatalf("层号未正确往返: %v", byName)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**编码顺序 = 启动顺序**(按 (layer,name),层内可复现)。
|
||||
func TestGraphEncodeOrderIsStartupOrder(t *testing.T) {
|
||||
mods := planTestModules()
|
||||
built, _ := Build(mods)
|
||||
gf := decodeGraphOrFatal(t, EncodeGraph(planToEdgesForTest(built)))
|
||||
|
||||
var order []string
|
||||
for _, e := range gf.Modules {
|
||||
order = append(order, e.Name)
|
||||
}
|
||||
// config 必须在 lua 之前,lua 必须在 llm 之前(按层号排好的顺序)
|
||||
idx := func(n string) int {
|
||||
for i, s := range order {
|
||||
if s == n {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if !(idx("config") < idx("lua") && idx("lua") < idx("llm")) {
|
||||
t.Fatalf("★ 解码后的顺序不是启动顺序: %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 测试辅助 ----
|
||||
|
||||
func planToEdgesForTest(p *Plan) []GraphEdge {
|
||||
var out []GraphEdge
|
||||
for layer, mods := range p.Layers {
|
||||
for _, m := range mods {
|
||||
out = append(out, GraphEdge{
|
||||
Name: m.Name(), Provides: m.Provides(),
|
||||
Requires: m.Requires(), Layer: layer,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeGraphOrFatal(t *testing.T, data []byte) *GraphFile {
|
||||
t.Helper()
|
||||
gf, err := DecodeGraph(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeGraph: %v", err)
|
||||
}
|
||||
return gf
|
||||
}
|
||||
309
internal/kernelboot/scheduler.go
Normal file
309
internal/kernelboot/scheduler.go
Normal file
@ -0,0 +1,309 @@
|
||||
package kernelboot
|
||||
|
||||
// 插件调度器:按 syncbus 状态决定**何时**拉起各插件(设计 §2.6.4)。
|
||||
//
|
||||
// ## 核心规则(三条,按优先级)
|
||||
//
|
||||
// 未声明依赖 ⇒ 等内核**完全**初始化(默认路径 = 现行为,§2.6.5)
|
||||
// 声明了依赖且全就绪 ⇒ **立即**拉起
|
||||
// 声明了依赖但未全就绪 ⇒ 挂起,等 syncbus 更新后再判断
|
||||
//
|
||||
// ## ★ 兼容性的落点就在第一条
|
||||
//
|
||||
// 存量 42 个插件都不声明依赖 ⇒ 全部走第一条 ⇒ 行为**逐字不变**。
|
||||
// 这不是"顺便兼容",而是本设计的硬约束:42 个插件里任何一个若因本设计
|
||||
// 改变了启动时机,就可能在不该动的时候动(例如假设"Start 时 llm 已就绪")。
|
||||
// **静默改变启动时机 = 静默引入竞态。**
|
||||
//
|
||||
// ## 为什么"未声明"不能等价于"依赖集为空"
|
||||
//
|
||||
// 空依赖集在数学上"已满足",若按此处理,未声明的插件会在**第一层完成后**
|
||||
// 就被拉起 —— 比现状早得多,直接破坏兼容性。
|
||||
// 所以"未声明"与"声明了空集"是**不同**语义,必须分开判断(见 Wants)。
|
||||
//
|
||||
// ## 为什么调度器不自带并发控制
|
||||
//
|
||||
// 拉起插件的实际动作(load/Start)由插件管理器做 —— 那里已有
|
||||
// Registry 的锁与生命周期管理。本调度器只负责**判断时机**,
|
||||
// 保持单一职责,也让判断逻辑可以脱离真实插件系统被单测(判据的关键)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// PluginSpec 是调度器需要知道的插件声明(设计 §2.6.4/2.6.6)。
|
||||
type PluginSpec struct {
|
||||
Name string
|
||||
|
||||
// RequiresFacilities 是插件声明需要的设施名。
|
||||
//
|
||||
// nil/空 ⇒ **未声明**(走"等内核完全初始化"路径)
|
||||
// 非空 ⇒ 声明的依赖;全就绪即拉起
|
||||
RequiresFacilities []string
|
||||
|
||||
// AutoStart 为 false 表示"不伴随启动":即使依赖齐了也**不**自动拉起,
|
||||
// 等 agent 经 plugin_enable 显式启用(设计 §2.6.6)。
|
||||
//
|
||||
// 用指针语义的注意:这里用 bool + 一个显式标记区分"未声明"与"false"。
|
||||
// 但为了简洁,约定:**缺省(未声明)= true**(伴随启动,与现状一致),
|
||||
// 由 manifest 层负责把"未声明"翻译成 true。
|
||||
AutoStart bool
|
||||
}
|
||||
|
||||
// Scheduler 按 syncbus 状态调度插件。
|
||||
type Scheduler struct {
|
||||
mu sync.Mutex
|
||||
bus SyncState
|
||||
pending []PluginSpec
|
||||
launched map[string]bool
|
||||
launch func(PluginSpec) error
|
||||
|
||||
// onLaunchErr 记录拉起失败(不中断其他插件)
|
||||
onLaunchErr func(PluginSpec, error)
|
||||
}
|
||||
|
||||
// NewScheduler 创建调度器。launch 是实际拉起动作(由插件管理器提供)。
|
||||
//
|
||||
// ★ bus 参数类型是 **SyncState**(只读接口),不是 *SyncBus。
|
||||
// 这就是设计 §2.6.3 的权限隔离:调度器**在编译期就没有**写 syncbus 的能力。
|
||||
func NewScheduler(bus SyncState, launch func(PluginSpec) error) *Scheduler {
|
||||
s := &Scheduler{
|
||||
bus: bus,
|
||||
launch: launch,
|
||||
launched: map[string]bool{},
|
||||
}
|
||||
// ★ 订阅状态变化:syncbus 每次更新(层边界 / 内核完成)就重新评估挂起队列。
|
||||
//
|
||||
// 这是"自动拉起"的实现方式:调度器不需要轮询(空转)也不需要调用方
|
||||
// 记得在每处手动触发(漏了就会静默卡住)。订阅一次,永久生效。
|
||||
//
|
||||
// 若 bus 不支持订阅(只传了个只读快照实现),退化为"只在 Submit 时评估"——
|
||||
// 那种情况下调度器仍可用,只是不会自动跟进后续状态变化。
|
||||
if sub, ok := bus.(interface{ OnUpdate(func(SyncState)) }); ok {
|
||||
sub.OnUpdate(func(SyncState) { s.evaluate() })
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetLaunch 补上拉起动作(供"两段式"调度:装配前订阅、装配后接动作)。
|
||||
//
|
||||
// ## 为什么需要它(真实缺口)
|
||||
//
|
||||
// 插件调度器必须在**装配开始前**订阅 syncbus,否则"声明提前启动"不会生效
|
||||
// (提交时全部设施早已就绪 ⇒ 永远退化成"立即拉起")。
|
||||
//
|
||||
// 但**拉起动作**依赖插件注册表,而那是 stage-host 模块的产物
|
||||
// ⇒ 装配前拿不到。所以分两段:
|
||||
//
|
||||
// · 装配前:NewScheduler(bus, nil) 只订阅
|
||||
// · 装配后:SetLaunch(fn) 接上真实动作
|
||||
//
|
||||
// ★ 此时可能已有插件被标记 launched(分层过程中达到条件)。
|
||||
// 调用方需自行补偿"已标记但未真正加载"的那些 —— 见 LaunchedFlag。
|
||||
func (s *Scheduler) SetLaunch(fn func(PluginSpec) error) {
|
||||
s.mu.Lock()
|
||||
s.launch = fn
|
||||
s.mu.Unlock()
|
||||
// ★ 立刻重新评估一次:接上动作之前可能已有插件达到条件并被标记
|
||||
// (它们在 syncbus 更新时就评估过,但那时 launch==nil ⇒ 只标记未执行)。
|
||||
// 不补这一下,它们要等到**下一次** syncbus 更新才会被真正拉起 ——
|
||||
// 而若内核装配已结束(不再有更新),就永远不拉。
|
||||
s.evaluate()
|
||||
}
|
||||
|
||||
// LaunchedFlag 报告某插件是否已被标记为"可拉起/已拉起"。
|
||||
//
|
||||
// ★ 与 Launched()(返回列表)分开:本方法用于**单个查询**,
|
||||
// 供两段式调度的补偿逻辑判断"它到条件了但可能还没真正加载"。
|
||||
func (s *Scheduler) LaunchedFlag(name string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.launched[name]
|
||||
}
|
||||
|
||||
// SetLaunchErrorHandler 注册拉起失败的观察者(诊断用)。
|
||||
func (s *Scheduler) SetLaunchErrorHandler(fn func(PluginSpec, error)) {
|
||||
s.mu.Lock()
|
||||
s.onLaunchErr = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Submit 提交一批插件声明。返回**立即可拉起**的那些插件名。
|
||||
//
|
||||
// 未达条件的进挂起队列,等 OnUpdate 触发时再评估。
|
||||
func (s *Scheduler) Submit(specs []PluginSpec) []string {
|
||||
// ★ 先校验:声明的设施名必须是**内核注册过的**。
|
||||
//
|
||||
// ## 为什么这条校验必须存在(修一个"静默永不启动"的缺口)
|
||||
//
|
||||
// 插件声明 `requires_facilities` 是字符串。拼错时原行为是:
|
||||
// 永远等不到 ⇒ **永不拉起**,且**无任何报错**、无超时兜底。
|
||||
// 那是本仓反复吃亏的"静默降级"形态(系统看着正常,某插件就是不工作)。
|
||||
//
|
||||
// 有了这条校验,拼错在**提交那一刻**就报出来,并列出全部已知设施名
|
||||
// 供对照("拼错了"当场可修)。
|
||||
//
|
||||
// ★ 为什么在这里而不在 canLaunch:canLaunch 是**每轮状态变化**都会调的
|
||||
// 热路径,在那里报错会重复刷屏;而"名字不存在"是**一次性**的静态错误,
|
||||
// 提交时检查一次即可。
|
||||
s.mu.Lock()
|
||||
var rejected []PluginSpec
|
||||
var valid []PluginSpec
|
||||
for _, p := range specs {
|
||||
if bad := s.unknownFacilities(p); len(bad) > 0 {
|
||||
rejected = append(rejected, p)
|
||||
continue
|
||||
}
|
||||
valid = append(valid, p)
|
||||
}
|
||||
s.pending = append(s.pending, valid...)
|
||||
errFn := s.onLaunchErr
|
||||
s.mu.Unlock()
|
||||
|
||||
// 报错在锁外(回调可能反查调度器状态 ⇒ 持锁会死锁)
|
||||
for _, p := range rejected {
|
||||
bad := s.unknownFacilities(p)
|
||||
err := fmt.Errorf("插件 %s 声明的设施名内核**从未注册过**: %v —— "+
|
||||
"这通常是拼写错误。内核已知的设施名: %v。"+
|
||||
"(若不校验,该插件会**永远等不到就绪**而静默不启动)",
|
||||
p.Name, bad, s.bus.KnownFacilities())
|
||||
if errFn != nil {
|
||||
errFn(p, err)
|
||||
}
|
||||
}
|
||||
return s.evaluate()
|
||||
}
|
||||
|
||||
// unknownFacilities 返回插件声明里**内核从未注册过**的设施名(拼错检测)。
|
||||
func (s *Scheduler) unknownFacilities(p PluginSpec) []string {
|
||||
var bad []string
|
||||
for _, f := range p.RequiresFacilities {
|
||||
if !s.bus.Known(f) {
|
||||
bad = append(bad, f)
|
||||
}
|
||||
}
|
||||
sort.Strings(bad)
|
||||
return bad
|
||||
}
|
||||
|
||||
// Pending 返回仍在挂起的插件名(诊断/判据用)。
|
||||
func (s *Scheduler) Pending() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]string, 0, len(s.pending))
|
||||
for _, p := range s.pending {
|
||||
out = append(out, p.Name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Launched 返回已拉起的插件名(判据用)。
|
||||
func (s *Scheduler) Launched() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]string, 0, len(s.launched))
|
||||
for k := range s.launched {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// evaluate 挑出当前可拉起的插件并拉起它们。
|
||||
//
|
||||
// 返回本次真正拉起的插件名。
|
||||
func (s *Scheduler) evaluate() []string {
|
||||
s.mu.Lock()
|
||||
// 挑出可拉起的 + 留下的。
|
||||
//
|
||||
// ★★ 必须**就地去重**:pending 里可能有同一插件的多份副本,
|
||||
// 因为并发 Submit 各自把同一批插件追加进来。
|
||||
//
|
||||
// 实测(压力测试 TestStressSchedulerConcurrentStateChange):
|
||||
// 8 个 goroutine 并发提交 200 个插件 + 内核并发标记就绪
|
||||
// ⇒ 同一插件在**同一次** evaluate 的 ready 列表里出现多次
|
||||
// ⇒ `launched` 查重救不了(它只在**跨轮**生效)⇒ 被拉起 203 次。
|
||||
//
|
||||
// 后果:插件被重复加载(spawn 两个进程 / 工具注册冲突)。
|
||||
//
|
||||
// 去重用 seen 集合(同一次 evaluate 内),并把它记进 launched
|
||||
// 以确保跨轮也幂等 —— 两者都需要。
|
||||
var ready []PluginSpec
|
||||
var still []PluginSpec
|
||||
seen := map[string]bool{}
|
||||
for _, p := range s.pending {
|
||||
if s.launched[p.Name] || seen[p.Name] {
|
||||
continue // 已拉起(跨轮)或本批已选中(同批)⇒ 丢弃
|
||||
}
|
||||
if s.canLaunch(p) {
|
||||
seen[p.Name] = true
|
||||
ready = append(ready, p)
|
||||
} else {
|
||||
still = append(still, p)
|
||||
}
|
||||
}
|
||||
s.pending = still
|
||||
for _, p := range ready {
|
||||
s.launched[p.Name] = true
|
||||
}
|
||||
launchFn := s.launch
|
||||
errFn := s.onLaunchErr
|
||||
s.mu.Unlock()
|
||||
|
||||
// ★ 在**不持锁**的情况下执行拉起动作:
|
||||
// 拉起可能很慢(spawn 进程、握手),持锁会让其他插件全卡住;
|
||||
// 且拉起过程可能回调调度器(如插件注册新插件)⇒ 死锁。
|
||||
var names []string
|
||||
for _, p := range ready {
|
||||
names = append(names, p.Name)
|
||||
if launchFn == nil {
|
||||
continue
|
||||
}
|
||||
if err := launchFn(p); err != nil && errFn != nil {
|
||||
errFn(p, err)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// canLaunch 判断单个插件当前是否可拉起。
|
||||
func (s *Scheduler) canLaunch(p PluginSpec) bool {
|
||||
// ① 不伴随启动:依赖齐了也**不**自动拉起(§2.6.6)
|
||||
if !p.AutoStart {
|
||||
return false
|
||||
}
|
||||
// ② 未声明依赖 ⇒ 等内核**完全**初始化(§2.6.5,存量插件走这条)
|
||||
if len(p.RequiresFacilities) == 0 {
|
||||
return s.bus.KernelComplete()
|
||||
}
|
||||
// ③ 声明了依赖 ⇒ 全就绪即拉起
|
||||
return s.bus.AllReady(p.RequiresFacilities)
|
||||
}
|
||||
|
||||
// WaitString 渲染"还差什么"(供日志:让等待可解释,而不是静默卡住)。
|
||||
func (s *Scheduler) WaitString(p PluginSpec) string {
|
||||
if !p.AutoStart {
|
||||
return "不伴随启动(等显式启用)"
|
||||
}
|
||||
if len(p.RequiresFacilities) == 0 {
|
||||
return "等内核完全初始化"
|
||||
}
|
||||
// AllReady 需要 *SyncBus;这里只做只读渲染,用 Snapshot 判断
|
||||
snap := s.bus.Snapshot()
|
||||
var missing []string
|
||||
for _, f := range p.RequiresFacilities {
|
||||
if !snap[f] {
|
||||
missing = append(missing, f)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return "依赖已就绪"
|
||||
}
|
||||
sort.Strings(missing)
|
||||
return fmt.Sprintf("还差: %s", strings.Join(missing, ","))
|
||||
}
|
||||
319
internal/kernelboot/stress_test.go
Normal file
319
internal/kernelboot/stress_test.go
Normal file
@ -0,0 +1,319 @@
|
||||
package kernelboot
|
||||
|
||||
// 内核装配层(syncbus / scheduler / KernelState)的**压力测试**。
|
||||
//
|
||||
// ## 为什么必须有压力测试(不是"跑一遍判据就够了")
|
||||
//
|
||||
// 这层代码的并发行为**只有压力才能暴露**:
|
||||
// · 判据用 2~3 个 goroutine 验证语义正确性
|
||||
// · 压力用数百 goroutine × 数千轮验证**没有数据竞争 / 死锁 / 状态丢失**
|
||||
//
|
||||
// 本仓已有两处真实教训支持这个判断:
|
||||
// ① ModuleView 第一版把「当前模块」放在共享对象上 ⇒ 判据**偶发**失败
|
||||
// (3 次里 1 次)—— 那是"偶发"而不是"必现",正是压力测试的领域
|
||||
// ② syncbus 的 OnUpdate 若持锁回调 ⇒ **死锁**(只在回调读状态时触发)
|
||||
//
|
||||
// ## 压什么(本层真实的并发面)
|
||||
//
|
||||
// · SyncBus:内核写(MarkDeclared/MarkLayerReady/MarkKernelComplete)
|
||||
// 与插件读(Known/IsReady/Snapshot/AllReady/**订阅回调**)并发
|
||||
// · Scheduler:多 goroutine 并发 Submit(同一插件可能被重复提交)
|
||||
// + 状态变化并发触发 evaluate
|
||||
// · KernelState:同层模块**并行**写**各自**字段(不同字段,无竞争)
|
||||
// · PlanFromGraph:并发读图(只读,但验证无共享可变状态)
|
||||
// · Run:真实分层执行,验证有界并发真的没超
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ★★★ 压力:**SyncBus 读写并发**(内核写 + N 个插件读 + 订阅回调)。
|
||||
//
|
||||
// 用 -race 跑时,任何未保护的状态都会报数据竞争。
|
||||
func TestStressSyncBusConcurrentReadWrite(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
const readers = 64
|
||||
const facilities = 200
|
||||
|
||||
var callbacks int64
|
||||
// 订阅者**读状态**(这正是死锁的触发条件:若 fire 持锁调用回调,
|
||||
// 回调里 Snapshot() 会等读锁 ⇒ 死锁。压力下必现)
|
||||
for i := 0; i < 8; i++ {
|
||||
bus.OnUpdate(func(s SyncState) {
|
||||
_ = s.Snapshot()
|
||||
_ = s.AllReady([]string{"llm"})
|
||||
atomic.AddInt64(&callbacks, 1)
|
||||
})
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
// 读侧:N 个"插件"持续查询
|
||||
for i := 0; i < readers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
f := fmt.Sprintf("fac%d", i%facilities)
|
||||
_ = bus.Known(f)
|
||||
_ = bus.IsReady(f)
|
||||
_ = bus.Snapshot()
|
||||
_ = bus.ReadyInOrder()
|
||||
_ = bus.KernelComplete()
|
||||
_ = bus.AllReady([]string{"fac1", "fac2"})
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 写侧:内核持续标记
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < facilities; i++ {
|
||||
bus.MarkLayerReady([]string{fmt.Sprintf("fac%d", i)})
|
||||
}
|
||||
}()
|
||||
|
||||
// 让它跑一会儿
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
bus.MarkKernelComplete()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
if atomic.LoadInt64(&callbacks) == 0 {
|
||||
t.Fatal("订阅回调从未被调用 —— 压力没有真正覆盖通知路径")
|
||||
}
|
||||
if !bus.KernelComplete() {
|
||||
t.Fatal("KernelComplete 应已置位")
|
||||
}
|
||||
t.Logf("回调触发 %d 次,%d 个并发读者", atomic.LoadInt64(&callbacks), readers)
|
||||
}
|
||||
|
||||
// ★★★ 压力:**多 goroutine 并发 Submit**(同一插件可能被重复提交)。
|
||||
//
|
||||
// 真实场景:插件加载器可能分批提交(发现新插件、重扫目录),
|
||||
// 而每次提交都会触发状态评估。并发下要保证:
|
||||
//
|
||||
// · 不重复拉起同一插件(幂等)
|
||||
// · 不丢插件(全部最终被拉起)
|
||||
// · 无数据竞争
|
||||
func TestStressSchedulerConcurrentSubmit(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
bus.MarkDeclared([]string{"config", "llm", "memory"})
|
||||
// ★ 必须 MarkLayerReady:MarkDeclared 只登记"名字存在",
|
||||
// 不代表就绪。插件要求 config **就绪**才拉起。
|
||||
// (我第一版漏了这步 ⇒ 压力判据报"0 个拉起" —— 那是判据 bug,非缺陷。)
|
||||
bus.MarkLayerReady([]string{"config", "llm", "memory"})
|
||||
|
||||
const plugins = 300
|
||||
var mu sync.Mutex
|
||||
launched := map[string]int{}
|
||||
|
||||
sch := NewScheduler(bus, func(p PluginSpec) error {
|
||||
mu.Lock()
|
||||
launched[p.Name]++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
sch.SetLaunchErrorHandler(func(p PluginSpec, err error) {
|
||||
t.Errorf("不应有拉起错误: %v", err)
|
||||
})
|
||||
|
||||
// 16 个 goroutine 并发提交同一批插件(模拟重复扫描)
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 16; g++ {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
var specs []PluginSpec
|
||||
for i := 0; i < plugins; i++ {
|
||||
specs = append(specs, PluginSpec{
|
||||
Name: fmt.Sprintf("p%03d", i),
|
||||
RequiresFacilities: []string{"config"},
|
||||
AutoStart: true,
|
||||
})
|
||||
}
|
||||
sch.Submit(specs)
|
||||
}(g)
|
||||
}
|
||||
bus.MarkKernelComplete()
|
||||
wg.Wait()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(launched) != plugins {
|
||||
t.Fatalf("★ 应拉起 %d 个插件,实际 %d(并发提交丢了插件)", plugins, len(launched))
|
||||
}
|
||||
for name, n := range launched {
|
||||
if n != 1 {
|
||||
t.Fatalf("★ 插件 %s 被拉起 %d 次(应恰好 1 次 —— 并发下幂等性破坏)", name, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 压力:**状态变化 + 提交并发**(真实时序:内核在标记就绪的同时插件在提交)。
|
||||
func TestStressSchedulerConcurrentStateChange(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
bus.MarkDeclared([]string{"config", "llm", "memory", "stagehost"})
|
||||
|
||||
var launched int64
|
||||
sch := NewScheduler(bus, func(p PluginSpec) error {
|
||||
atomic.AddInt64(&launched, 1)
|
||||
return nil
|
||||
})
|
||||
sch.SetLaunchErrorHandler(func(p PluginSpec, err error) {})
|
||||
|
||||
const plugins = 200
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 写侧:内核逐层标记就绪
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for _, f := range []string{"config", "llm", "memory", "stagehost"} {
|
||||
bus.MarkLayerReady([]string{f})
|
||||
}
|
||||
bus.MarkKernelComplete()
|
||||
}()
|
||||
|
||||
// 提交侧:8 个并发提交者
|
||||
for g := 0; g < 8; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < plugins; i++ {
|
||||
sch.Submit([]PluginSpec{{
|
||||
Name: fmt.Sprintf("q%03d", i),
|
||||
RequiresFacilities: []string{"config"},
|
||||
AutoStart: true,
|
||||
}})
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
bus.MarkKernelComplete() // 再触发一次评估,确保挂起的也被拉起
|
||||
|
||||
if n := atomic.LoadInt64(&launched); n != plugins {
|
||||
t.Fatalf("★ 并发 提交×状态变化 下应拉起 %d 个,实际 %d", plugins, n)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 压力:**PlanFromGraph 并发读**(多插件/多协程同时读启动计划)。
|
||||
func TestStressPlanFromGraphConcurrentRead(t *testing.T) {
|
||||
mods := planTestModules()
|
||||
built, err := Build(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gf := decodeGraphOrFatal(t, EncodeGraph(planToEdgesForTest(built)))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 64)
|
||||
for i := 0; i < 64; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 50; j++ {
|
||||
plan, err := PlanFromGraph(gf, mods)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if len(plan.Layers) != 3 {
|
||||
errs <- fmt.Errorf("层数不对: %d", len(plan.Layers))
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 压力:**Run 的并发上限真的生效**(大量模块同层时不得超出)。
|
||||
//
|
||||
// ★ 这条与判据的 TestRunRespectsMaxParallel 不同:那条 8 个模块,
|
||||
// 这条 **128 个模块**同层 —— 验证信号量在高压下不漏(观察峰值)。
|
||||
func TestStressRunRespectsMaxParallelAtScale(t *testing.T) {
|
||||
const n = 128
|
||||
const maxP = 8
|
||||
|
||||
var active, peak int64
|
||||
var mu sync.Mutex
|
||||
var mods []Module
|
||||
for i := 0; i < n; i++ {
|
||||
mods = append(mods, &fakeModule{
|
||||
name: fmt.Sprintf("m%03d", i),
|
||||
provides: []string{fmt.Sprintf("f%03d", i)},
|
||||
onInit: func() {
|
||||
cur := atomic.AddInt64(&active, 1)
|
||||
mu.Lock()
|
||||
if cur > peak {
|
||||
peak = cur
|
||||
}
|
||||
mu.Unlock()
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
atomic.AddInt64(&active, -1)
|
||||
},
|
||||
})
|
||||
}
|
||||
plan, err := Build(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := Run(plan, RunOptions{MaxParallel: maxP})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
observed := peak
|
||||
mu.Unlock()
|
||||
if observed > maxP {
|
||||
t.Fatalf("★ 并发上限 %d 被突破:观测峰值 %d(%d 个模块同层)", maxP, observed, n)
|
||||
}
|
||||
if len(res.Order) != n {
|
||||
t.Fatalf("应执行 %d 个模块,实际 %d", n, len(res.Order))
|
||||
}
|
||||
t.Logf("%d 个模块同层,上限 %d,观测峰值 %d", n, maxP, observed)
|
||||
}
|
||||
|
||||
// ★★★ 压力:**KernelState 同层并行写**(各写自己字段,无竞争)。
|
||||
func TestStressKernelStateParallelWrites(t *testing.T) {
|
||||
ks := NewKernelState()
|
||||
const n = 256
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
// 每个"模块"只写自己字段 —— 这正是真实约定。
|
||||
// 若有人违反(写别人的字段),-race 会报。
|
||||
ks.AddCloser(func() {})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if len(ks.Closers) != n {
|
||||
t.Fatalf("应记录 %d 个关闭函数,实际 %d(AddCloser 有丢失)", n, len(ks.Closers))
|
||||
}
|
||||
if err := ks.CloseAll(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ks.Closers) != 0 {
|
||||
t.Fatal("CloseAll 后应清空")
|
||||
}
|
||||
}
|
||||
290
internal/kernelboot/syncbus.go
Normal file
290
internal/kernelboot/syncbus.go
Normal file
@ -0,0 +1,290 @@
|
||||
package kernelboot
|
||||
|
||||
// syncbus:内核就绪状态的同步快照,驱动插件调度(设计 §2.6)。
|
||||
//
|
||||
// ## 一句话
|
||||
//
|
||||
// 内核每完成一层装配就把该层贡献的设施标记为"就绪";
|
||||
// 插件管理器**只读**这份状态,据此拉起"依赖已齐"的插件。
|
||||
//
|
||||
// ## 为什么要有它(解决什么)
|
||||
//
|
||||
// 现状(`cmd/homed/main.go:291 loadPlugins`)是**一刀切**:
|
||||
// 12 个 init* 全部做完才加载全部插件。
|
||||
// 于是"只读本地文件"的插件也要等多模态空间(ONNX 加载)+ 知识库建索引做完。
|
||||
//
|
||||
// 而内核那 12 步本身有明确先后(§2.3 算出 4 层),"阶段"是天然存在的,
|
||||
// 只是此前没有对外发布。syncbus 就是那个发布物。
|
||||
//
|
||||
// ## ★ 权限:插件管理器只读(这不是习惯,是设计)
|
||||
//
|
||||
// 若插件管理器能写 syncbus,它就能**谎报内核状态**,让插件在自己依赖的东西
|
||||
// 还不存在时启动 —— 那正是病 A(静默顺序错)。
|
||||
//
|
||||
// 所以类型层面分离:内核拿 `*SyncBus`(可写),
|
||||
// 插件管理器只拿 `SyncState` 接口(**上面没有写方法**)。
|
||||
// 想写也写不了 —— 编译期保证,而非"约定不要写"。
|
||||
// 同 `Get[T]` 泛型取用(类型错=编译不过)是一个思路。
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SyncState 是 syncbus 的**只读**视图。
|
||||
//
|
||||
// ★ 刻意不含 MarkReady/Reset:插件管理器拿到的就是这个接口,
|
||||
// 因此它**在编译期就不可能**篡改内核状态。
|
||||
type SyncState interface {
|
||||
// IsReady 报告某设施是否已就绪。
|
||||
IsReady(facility string) bool
|
||||
// Snapshot 返回当前全部就绪状态的副本(调用方改不动内部)。
|
||||
Snapshot() map[string]bool
|
||||
// ReadyInOrder 按**就绪顺序**返回设施名(调试/判据用)。
|
||||
ReadyInOrder() []string
|
||||
// KernelComplete 报告内核是否已完全初始化(最后一层完成)。
|
||||
KernelComplete() bool
|
||||
// AllReady 报告给定设施是否**全部**就绪(插件调度器的核心判断)。
|
||||
//
|
||||
// ★ 空列表返回 false(不是 true):见实现处的说明 ——
|
||||
// "没有依赖"与"依赖全就绪"是不同语义,混淆会让未声明依赖的插件提前拉起。
|
||||
AllReady(facilities []string) bool
|
||||
|
||||
// Known 报告某设施名**是否被内核注册过**(不论是否已就绪)。
|
||||
//
|
||||
// ## ★ 为什么必须有它(修一个"静默永不启动"的缺口)
|
||||
//
|
||||
// 插件声明 `requires_facilities` 是**字符串**。若拼错(如写 "llm " 或
|
||||
// "llmservice"),原本的行为是:它永远等不到 → **永不拉起**,
|
||||
// 且**没有任何报错**、没有超时兜底(我第一版设计文档写了"超时兜底"
|
||||
// 但从未实现)。
|
||||
//
|
||||
// 这正是本仓反复吃亏的"静默降级"形态:系统看着正常,某插件就是不工作。
|
||||
//
|
||||
// ⇒ 有了 Known,调度器能在**提交时**立刻报错:
|
||||
// "插件 X 声明了内核从未注册过的设施 Y(可能是拼写错误)"
|
||||
// 并列出全部已知设施名供对照。
|
||||
Known(facility string) bool
|
||||
|
||||
// KnownFacilities 返回内核**注册过的全部设施名**(含尚未就绪的)。
|
||||
//
|
||||
// 用途:错误信息里列出可选值(让"拼错了"当场可修)。
|
||||
KnownFacilities() []string
|
||||
}
|
||||
|
||||
// SyncBus 是可写的同步总线(内核持有)。
|
||||
type SyncBus struct {
|
||||
mu sync.RWMutex
|
||||
// known 是内核**注册过的全部设施名**(不论是否已就绪)。
|
||||
//
|
||||
// ★ 它存在的唯一目的是让"插件拼错设施名"从**静默永不启动**
|
||||
// 变成**提交时立刻报错**(见 SyncState.Known 的说明)。
|
||||
known map[string]bool
|
||||
ready map[string]bool
|
||||
order []string
|
||||
complete bool
|
||||
onUpdate []func(SyncState)
|
||||
notifyMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewSyncBus 创建空总线。
|
||||
func NewSyncBus() *SyncBus {
|
||||
return &SyncBus{ready: map[string]bool{}, known: map[string]bool{}}
|
||||
}
|
||||
|
||||
// MarkReady 标记一个设施就绪。**只有内核该调它。**
|
||||
//
|
||||
// 幂等:重复标记同一设施不报错(便于层边界回调重入)。
|
||||
// 但**不重复记录**顺序,保证 ReadyInOrder 可复现。
|
||||
func (b *SyncBus) MarkReady(facility string) {
|
||||
if facility == "" {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.ready[facility] {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.ready[facility] = true
|
||||
b.order = append(b.order, facility)
|
||||
b.mu.Unlock()
|
||||
b.fire()
|
||||
}
|
||||
|
||||
// MarkLayerReady 在**层边界**标记该层所有模块贡献的设施(设计 §2.6.2)。
|
||||
//
|
||||
// ★ 为何以"层"而不是"单个模块"为同步点:
|
||||
// 层内是**并发**执行的,若层内逐个模块标记就绪,就绪顺序会**不确定**,
|
||||
// 而判据要求可复现。层边界天然是一个确定的同步点。
|
||||
func (b *SyncBus) MarkLayerReady(provides []string) {
|
||||
// 先在锁内一次性写入,再统一通知(避免 N 次通知、N 次唤醒)
|
||||
b.mu.Lock()
|
||||
changed := false
|
||||
for _, f := range provides {
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
b.known[f] = true // 注册过(不论是否首次就绪)
|
||||
if b.ready[f] {
|
||||
continue
|
||||
}
|
||||
b.ready[f] = true
|
||||
b.order = append(b.order, f)
|
||||
changed = true
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if changed {
|
||||
b.fire()
|
||||
}
|
||||
}
|
||||
|
||||
// MarkDeclared 在**执行前**登记一批设施名(视为"内核注册过")。
|
||||
//
|
||||
// ## ★ 为什么需要它(时序)
|
||||
//
|
||||
// 插件的 `requires_facilities` 校验要在**插件提交时**做(那是错误最该被发现
|
||||
// 的时刻,紧挨着写错的插件作者)。但插件的提交发生在内核装配**完成之后**
|
||||
// —— 那时所有层都跑完了,Known 自然齐了。
|
||||
//
|
||||
// 但仍然需要本方法,因为:
|
||||
//
|
||||
// · 装配**中途失败**时,后面的层没跑 ⇒ 那些设施名从未登记 ⇒
|
||||
// 若此时还有插件提交,会报"内核从未注册过 X"(**误报**:其实是没跑到)
|
||||
// · 判据要能在**不执行**的情况下校验"插件声明 ⊆ 内核声明"
|
||||
// (只看声明,不看执行结果)
|
||||
//
|
||||
// ⇒ 装配开始时把**全部模块的 Provides** 一次性登记为 known。
|
||||
// 这样 Known 的语义精确为"**模块声明过**这个设施",与"是否已就绪"解耦。
|
||||
func (b *SyncBus) MarkDeclared(facilities []string) {
|
||||
b.mu.Lock()
|
||||
for _, f := range facilities {
|
||||
if f != "" {
|
||||
b.known[f] = true
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
// MarkKernelComplete 标记内核已完全初始化。
|
||||
//
|
||||
// 这是**未声明依赖的插件**的启动信号(默认路径 = 现行为,§2.6.5)。
|
||||
func (b *SyncBus) MarkKernelComplete() {
|
||||
b.mu.Lock()
|
||||
if b.complete {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.complete = true
|
||||
b.mu.Unlock()
|
||||
b.fire()
|
||||
}
|
||||
|
||||
// OnUpdate 注册状态变化回调(插件调度器用它重新检查挂起的插件)。
|
||||
//
|
||||
// 回调在**不持锁**的情况下调用 —— 否则回调里再读 syncbus 会死锁。
|
||||
// (这个坑很容易踩:回调显然要读状态,持锁调用必然死锁。)
|
||||
func (b *SyncBus) OnUpdate(fn func(SyncState)) {
|
||||
b.notifyMu.Lock()
|
||||
b.onUpdate = append(b.onUpdate, fn)
|
||||
b.notifyMu.Unlock()
|
||||
}
|
||||
|
||||
func (b *SyncBus) fire() {
|
||||
b.notifyMu.Lock()
|
||||
cbs := make([]func(SyncState), len(b.onUpdate))
|
||||
copy(cbs, b.onUpdate)
|
||||
b.notifyMu.Unlock()
|
||||
for _, fn := range cbs {
|
||||
fn(b) // b 实现了 SyncState(只读方法)
|
||||
}
|
||||
}
|
||||
|
||||
// --- SyncState 实现(只读) ---
|
||||
|
||||
func (b *SyncBus) IsReady(facility string) bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.ready[facility]
|
||||
}
|
||||
|
||||
func (b *SyncBus) Snapshot() map[string]bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
out := make(map[string]bool, len(b.ready))
|
||||
for k, v := range b.ready {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *SyncBus) ReadyInOrder() []string {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
out := make([]string, len(b.order))
|
||||
copy(out, b.order)
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *SyncBus) KernelComplete() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.complete
|
||||
}
|
||||
|
||||
// Known 报告某设施名是否被内核注册过(不论是否已就绪)。
|
||||
//
|
||||
// ★ 为何"注册过但未就绪"也算 Known:那是**正常的中间状态**
|
||||
// (如 llm 声明了但还在第 1 层)。真正的错误是"内核从未注册过这个名字"。
|
||||
func (b *SyncBus) Known(facility string) bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.known[facility]
|
||||
}
|
||||
|
||||
// KnownFacilities 返回内核注册过的全部设施名(升序,便于对照阅读)。
|
||||
func (b *SyncBus) KnownFacilities() []string {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
out := make([]string, 0, len(b.known))
|
||||
for k := range b.known {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// AllReady 报告给定的设施**全部**就绪(插件调度器的核心判断)。
|
||||
//
|
||||
// 空列表 = 真的全就绪 → 返回 false。
|
||||
//
|
||||
// ★ 为何空列表返回 false 而不是 true:
|
||||
// "没有依赖" 与 "依赖全就绪" 是**不同语义**,混淆会让"未声明依赖的插件"
|
||||
// 被当成"依赖已满足"而**提前拉起** —— 那就破坏了 §2.6.5 的存量兼容。
|
||||
// 未声明走的是 KernelComplete 那条路径,与这条刻意分开。
|
||||
func (b *SyncBus) AllReady(facilities []string) bool {
|
||||
if len(facilities) == 0 {
|
||||
return false
|
||||
}
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
for _, f := range facilities {
|
||||
if !b.ready[f] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Missing 返回尚未就绪的设施名(诊断用:告诉用户"还差什么")。
|
||||
func (b *SyncBus) Missing(facilities []string) []string {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
var out []string
|
||||
for _, f := range facilities {
|
||||
if !b.ready[f] {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
412
internal/kernelboot/syncbus_test.go
Normal file
412
internal/kernelboot/syncbus_test.go
Normal file
@ -0,0 +1,412 @@
|
||||
package kernelboot
|
||||
|
||||
// syncbus + 插件调度器的判据(2026-10-16)。
|
||||
//
|
||||
// 设计见 docs/zh/kernel-modularization-design.md §2.6。
|
||||
//
|
||||
// 这组判据里**最重要的一条是"默认路径为零改动"**:
|
||||
// 存量 42 个插件都不声明依赖,它们的启动时机必须逐字不变。
|
||||
// 静默改变启动时机 = 静默引入竞态。
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ★★★ 判据(最关键):**未声明依赖的插件等内核完全初始化**(存量兼容)。
|
||||
//
|
||||
// 这是整个设计里风险最高的一处:若未声明的插件被"提前"拉起,
|
||||
// 42 个存量插件的启动时机全部改变 ⇒ 静默竞态。
|
||||
//
|
||||
// 反例(若实现错误):把"未声明"当成"依赖集为空",而空集在数学上已满足
|
||||
// ⇒ 第一层完成后就拉起 ⇒ 比现状早得多。
|
||||
func TestSchedulerUndeclaredWaitsForKernelComplete(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
var launched []string
|
||||
var mu sync.Mutex
|
||||
sch := NewScheduler(bus, func(p PluginSpec) error {
|
||||
mu.Lock()
|
||||
launched = append(launched, p.Name)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
// 存量插件:不声明依赖
|
||||
sch.Submit([]PluginSpec{{Name: "legacy1", AutoStart: true}})
|
||||
sch.Submit([]PluginSpec{{Name: "legacy2", AutoStart: true}})
|
||||
|
||||
// 内核还在装配中(几层已就绪,但没完成)
|
||||
bus.MarkLayerReady([]string{"config"})
|
||||
bus.MarkLayerReady([]string{"memory", "lua"})
|
||||
bus.MarkLayerReady([]string{"llm"})
|
||||
|
||||
mu.Lock()
|
||||
n := len(launched)
|
||||
mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("★ 内核尚未完全初始化,未声明依赖的插件就被拉起了 %d 个(存量行为被改变)", n)
|
||||
}
|
||||
|
||||
// 内核完成 ⇒ 现在才该拉起
|
||||
bus.MarkKernelComplete()
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(launched) != 2 {
|
||||
t.Fatalf("内核完成后应拉起 2 个,实际 %d: %v", len(launched), launched)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**声明了依赖的插件在依赖就绪时立即拉起**(新能力的正面证据)。
|
||||
//
|
||||
// 断言"早于内核完成" —— 否则这条判据在"等内核完成"的实现下也会通过(假绿)。
|
||||
func TestSchedulerDeclaredLaunchesEarly(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
var launched []string
|
||||
var mu sync.Mutex
|
||||
sch := NewScheduler(bus, func(p PluginSpec) error {
|
||||
mu.Lock()
|
||||
launched = append(launched, p.Name)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
// ★ 镜像真实顺序:Run 在**执行前** MarkDeclared(登记全部模块设施名),
|
||||
// 插件 Submit 在装配之后发生 ⇒ 判据必须先声明再提交。
|
||||
bus.MarkDeclared([]string{"config"})
|
||||
|
||||
// 声明只需要 config
|
||||
sch.Submit([]PluginSpec{{Name: "early", RequiresFacilities: []string{"config"}, AutoStart: true}})
|
||||
|
||||
// 第 0 层只提供 config ⇒ 应立刻拉起
|
||||
bus.MarkLayerReady([]string{"config"})
|
||||
|
||||
mu.Lock()
|
||||
got := append([]string{}, launched...)
|
||||
mu.Unlock()
|
||||
if len(got) != 1 || got[0] != "early" {
|
||||
t.Fatalf("★ 依赖已就绪却未拉起: %v", got)
|
||||
}
|
||||
|
||||
// ★ 关键:此时内核**尚未**完成 —— 证明它是"尽早启动"而不是"等完成"
|
||||
if bus.KernelComplete() {
|
||||
t.Fatal("测试设置错误:此刻内核不该已完成")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**依赖未齐则挂起**,不会误拉起。
|
||||
func TestSchedulerWaitsForMissingDependency(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
var launched []string
|
||||
sch := NewScheduler(bus, func(p PluginSpec) error {
|
||||
launched = append(launched, p.Name)
|
||||
return nil
|
||||
})
|
||||
|
||||
// ★ 先声明 llm(登记名字,但尚未就绪)—— 与真实顺序一致。
|
||||
bus.MarkDeclared([]string{"llm", "config", "memory"})
|
||||
|
||||
// 需要 llm
|
||||
sch.Submit([]PluginSpec{{Name: "needsllm", RequiresFacilities: []string{"llm"}, AutoStart: true}})
|
||||
|
||||
bus.MarkLayerReady([]string{"config", "memory"}) // llm 还没好
|
||||
if len(launched) != 0 {
|
||||
t.Fatalf("★ llm 未就绪却拉起了: %v", launched)
|
||||
}
|
||||
if p := sch.Pending(); len(p) != 1 {
|
||||
t.Fatalf("应挂起 1 个,实际 %v", p)
|
||||
}
|
||||
|
||||
// llm 就绪 ⇒ 拉起
|
||||
bus.MarkLayerReady([]string{"llm"})
|
||||
if len(launched) != 1 || launched[0] != "needsllm" {
|
||||
t.Fatalf("llm 就绪后应拉起: %v", launched)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**只读隔离**(编译期保证,不是约定)。
|
||||
//
|
||||
// 断言 Scheduler 拿到的接口**没有**写方法 —— 如果给它 *SyncBus,
|
||||
// 它就能谎报内核状态,让插件在依赖不存在时启动(病 A)。
|
||||
func TestSchedulerCannotWriteSyncState(t *testing.T) {
|
||||
// ★ 检查必须是**接口类型的方法集**,不是某个具体值。
|
||||
//
|
||||
// 我第一次写错了:用 `var st SyncState = NewSyncBus()` 再断言 `st.(writer)` ——
|
||||
// 那检查的是**动态类型**(*SyncBus 当然有 MarkReady),判据当场报假红。
|
||||
// 要约束的是"拿到 SyncState 的人**能不能**调写方法" ⇒ 必须查接口的方法集。
|
||||
rt := reflect.TypeOf((*SyncState)(nil)).Elem()
|
||||
forbidden := []string{"MarkReady", "MarkKernelComplete", "MarkLayerReady"}
|
||||
for i := 0; i < rt.NumMethod(); i++ {
|
||||
m := rt.Method(i).Name
|
||||
for _, bad := range forbidden {
|
||||
if m == bad {
|
||||
t.Fatalf("★ SyncState(插件管理器拿到的接口)暴露了写方法 %s —— "+
|
||||
"它能谎报内核状态,让插件在依赖不存在时启动(病 A)", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 反向确认:读方法必须在(否则接口退化到没用了)
|
||||
for _, want := range []string{"IsReady", "Snapshot", "AllReady", "KernelComplete"} {
|
||||
if _, ok := rt.MethodByName(want); !ok {
|
||||
t.Errorf("SyncState 缺少读方法 %s", want)
|
||||
}
|
||||
}
|
||||
// Scheduler 持有的字段类型也必须是 SyncState(不能是 *SyncBus)
|
||||
st := reflect.TypeOf(Scheduler{})
|
||||
f, ok := st.FieldByName("bus")
|
||||
if !ok {
|
||||
t.Fatal("Scheduler 应有 bus 字段")
|
||||
}
|
||||
if f.Type.String() != "kernelboot.SyncState" {
|
||||
t.Fatalf("★ Scheduler.bus 的类型是 %s —— 必须是只读的 SyncState", f.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**不伴随启动的插件即使依赖齐了也不自动拉起**(§2.6.6)。
|
||||
func TestSchedulerAutoStartFalseIsNotLaunched(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
var launched []string
|
||||
sch := NewScheduler(bus, func(p PluginSpec) error {
|
||||
launched = append(launched, p.Name)
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.MarkDeclared([]string{"config"})
|
||||
sch.Submit([]PluginSpec{
|
||||
{Name: "manual", RequiresFacilities: []string{"config"}, AutoStart: false},
|
||||
})
|
||||
bus.MarkLayerReady([]string{"config"})
|
||||
bus.MarkKernelComplete()
|
||||
|
||||
if len(launched) != 0 {
|
||||
t.Fatalf("★ auto_start=false 的插件被自动拉起了(应等显式启用): %v", launched)
|
||||
}
|
||||
if len(sch.Pending()) != 1 {
|
||||
t.Fatalf("它应一直挂起等显式启用: %v", sch.Pending())
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**就绪顺序可复现**(层内并发不得导致就绪顺序抖动)。
|
||||
//
|
||||
// 为何重要:syncbus 的就绪顺序决定哪些插件何时被拉起;
|
||||
// 若顺序随机,同一份声明两次启动会拉出不同结果 ⇒ 不可审计、不可复现。
|
||||
func TestSyncBusReadyOrderIsDeterministic(t *testing.T) {
|
||||
// 模拟"层内并发完成":同一层 8 个设施,用 goroutine 乱序 MarkReady
|
||||
var first []string
|
||||
for round := 0; round < 5; round++ {
|
||||
bus := NewSyncBus()
|
||||
var wg sync.WaitGroup
|
||||
fac := []string{"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8"}
|
||||
for i, f := range fac {
|
||||
wg.Add(1)
|
||||
go func(i int, f string) {
|
||||
defer wg.Done()
|
||||
// 反过来启动,制造乱序完成
|
||||
_ = i
|
||||
bus.MarkReady(f)
|
||||
}(i, f)
|
||||
}
|
||||
wg.Wait()
|
||||
got := bus.ReadyInOrder()
|
||||
if round == 0 {
|
||||
first = got
|
||||
continue
|
||||
}
|
||||
// 注意:MarkReady(单设施)的顺序本就取决于并发,不保证可复现;
|
||||
// 设计保证的是 **MarkLayerReady**(层边界一次性写入)可复现。
|
||||
// 所以这里只断言"同样的输入产生同样的集合"。
|
||||
if len(got) != len(first) {
|
||||
t.Fatalf("就绪集合大小抖动: %d vs %d", len(got), len(first))
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 真正的可复现性断言:MarkLayerReady 一次性写入
|
||||
var order []string
|
||||
for round := 0; round < 3; round++ {
|
||||
bus := NewSyncBus()
|
||||
bus.MarkLayerReady([]string{"a", "b", "c"})
|
||||
bus.MarkLayerReady([]string{"d", "e"})
|
||||
got := bus.ReadyInOrder()
|
||||
joined := strings.Join(got, ",")
|
||||
if round == 0 {
|
||||
order = got
|
||||
continue
|
||||
}
|
||||
if joined != strings.Join(order, ",") {
|
||||
t.Fatalf("★ MarkLayerReady 的就绪顺序不可复现:\n%v\n%v", order, got)
|
||||
}
|
||||
}
|
||||
if strings.Join(order, ",") != "a,b,c,d,e" {
|
||||
t.Fatalf("就绪顺序应为层内声明序: %v", order)
|
||||
}
|
||||
|
||||
// ★ 幂等性:**重复标记**同一设施不得重复记录顺序。
|
||||
//
|
||||
// 为何要单独断言:Run 的层边界回调可能在某些路径重入
|
||||
//(且调用方也可能手动重复标记)。若不幂等,order 会不断膨胀
|
||||
// ⇒ 就绪顺序不再反映真实层序 ⇒ 依赖它的调度判断变得不可解释。
|
||||
//
|
||||
// 这条是补的:变异"去掉幂等检查"第一次没被咬住 —— 因为原判据
|
||||
// 只跑了一份声明、没重复标记,测不到幂等性。
|
||||
bus := NewSyncBus()
|
||||
bus.MarkLayerReady([]string{"a", "b"})
|
||||
bus.MarkLayerReady([]string{"a", "b"}) // 重复
|
||||
bus.MarkLayerReady([]string{"c"})
|
||||
if got := strings.Join(bus.ReadyInOrder(), ","); got != "a,b,c" {
|
||||
t.Fatalf("★ 重复标记导致就绪顺序膨胀(不幂等): %v(期望 a,b,c)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**内核未完成时绝不发 KernelComplete**(内核没装好就不该放插件)。
|
||||
//
|
||||
// 这条守的是:装配中途失败 ⇒ 未声明依赖的插件**不**被拉起。
|
||||
func TestRunDoesNotMarkCompleteOnFailure(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
mods := []Module{
|
||||
&fakeModule{name: "A", provides: []string{"a"}},
|
||||
&fakeModule{name: "B", provides: []string{"b"}, initErr: errFake},
|
||||
}
|
||||
plan, err := Build(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Run(plan, RunOptions{Bus: bus}); err == nil {
|
||||
t.Fatal("应失败")
|
||||
}
|
||||
if bus.KernelComplete() {
|
||||
t.Fatal("★ 装配失败了却标记内核完成 —— 未声明依赖的插件会被拉起(病 A)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**Run 在层边界自动发布本层设施**(不用模块自己标记)。
|
||||
//
|
||||
// 为何由 Run 做:模块自己标记会漏(静默漏一个设施 ⇒ 插件永远等不到),
|
||||
// 且顺序取决于层内并发完成序。
|
||||
func TestRunPublishesFacilitiesAtLayerBoundary(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
mods := []Module{
|
||||
&fakeModule{name: "config", provides: []string{"config", "config-write"}},
|
||||
&fakeModule{name: "llm", provides: []string{"llm"}, requires: []string{"config"}},
|
||||
}
|
||||
plan, err := Build(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Run(plan, RunOptions{Bus: bus}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range []string{"config", "config-write", "llm"} {
|
||||
if !bus.IsReady(f) {
|
||||
t.Errorf("★ 设施 %q 未在层边界被发布", f)
|
||||
}
|
||||
}
|
||||
if !bus.KernelComplete() {
|
||||
t.Error("全部完成后应标记内核完成")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**空依赖列表 ≠ 已满足**(语义区分的直接断言)。
|
||||
//
|
||||
// 若这条反了,未声明依赖的插件会被提前拉起(见第一条判据)。
|
||||
func TestAllReadyEmptyIsFalse(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
bus.MarkKernelComplete()
|
||||
if bus.AllReady(nil) {
|
||||
t.Fatal("★ AllReady(空) 返回 true —— \"没有依赖\"被当成\"依赖全就绪\",未声明插件会提前拉起")
|
||||
}
|
||||
if bus.AllReady([]string{}) {
|
||||
t.Fatal("★ AllReady(空切片) 返回 true")
|
||||
}
|
||||
// 非空且齐 ⇒ true
|
||||
bus.MarkReady("x")
|
||||
if !bus.AllReady([]string{"x"}) {
|
||||
t.Fatal("依赖确实就绪应返回 true")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**拉起失败不阻塞其他插件**(一个坏插件不该拖垮全局)。
|
||||
func TestSchedulerLaunchFailureDoesNotBlockOthers(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
var okLaunched []string
|
||||
sch := NewScheduler(bus, func(p PluginSpec) error {
|
||||
if p.Name == "bad" {
|
||||
return errFake
|
||||
}
|
||||
okLaunched = append(okLaunched, p.Name)
|
||||
return nil
|
||||
})
|
||||
var failures int
|
||||
sch.SetLaunchErrorHandler(func(PluginSpec, error) { failures++ })
|
||||
|
||||
bus.MarkDeclared([]string{"config"})
|
||||
sch.Submit([]PluginSpec{
|
||||
{Name: "bad", RequiresFacilities: []string{"config"}, AutoStart: true},
|
||||
{Name: "good", RequiresFacilities: []string{"config"}, AutoStart: true},
|
||||
})
|
||||
bus.MarkLayerReady([]string{"config"})
|
||||
|
||||
if len(okLaunched) != 1 || okLaunched[0] != "good" {
|
||||
t.Fatalf("★ 一个插件拉起失败影响了其他插件: %v", okLaunched)
|
||||
}
|
||||
if failures != 1 {
|
||||
t.Fatalf("失败应被报告: %d", failures)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**重复 Submit 不重复拉起**(幂等)。
|
||||
func TestSchedulerIsIdempotent(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
n := 0
|
||||
sch := NewScheduler(bus, func(PluginSpec) error { n++; return nil })
|
||||
bus.MarkKernelComplete()
|
||||
|
||||
sch.Submit([]PluginSpec{{Name: "p", AutoStart: true}})
|
||||
sch.Submit([]PluginSpec{{Name: "p", AutoStart: true}})
|
||||
bus.MarkKernelComplete() // 再触发一次评估
|
||||
|
||||
if n != 1 {
|
||||
t.Fatalf("★ 同一插件被拉起 %d 次(应幂等)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**MarkReady 的通知也在锁外**(走单设施路径的订阅者不死锁)。
|
||||
//
|
||||
// 变异 8b 暴露的边界:MarkLayerReady 持锁回调会死锁(已被
|
||||
// TestSchedulerDeclaredLaunchesEarly 咬住),但 MarkReady 是**另一条路径**
|
||||
// (单个设施的标记),若它持锁回调,只有"既调 MarkReady 又订阅"的场景才会卡住。
|
||||
// 补这条判据把两条路径都盖住。
|
||||
func TestSyncBusMarkReadyNotifiesWithoutDeadlock(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
got := make(chan string, 4)
|
||||
|
||||
// 订阅者**读状态**(真实回调必然会读,这正是死锁触发条件)
|
||||
bus.OnUpdate(func(s SyncState) {
|
||||
_ = s.Snapshot() // 持锁调用的话,这里会等 RLock ⇒ 死锁
|
||||
got <- "ok"
|
||||
})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
bus.MarkReady("config")
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-got:
|
||||
case <-done:
|
||||
t.Fatal("★ MarkReady 未通知订阅者(订阅者拿不到更新 ⇒ 插件不会自动拉起)")
|
||||
case <-timeAfter(5):
|
||||
t.Fatal("★ MarkReady 持锁调用回调 ⇒ 死锁(回调读状态时会等锁)")
|
||||
}
|
||||
}
|
||||
|
||||
func timeAfter(sec int) <-chan struct{} {
|
||||
ch := make(chan struct{})
|
||||
go func() {
|
||||
time.Sleep(time.Duration(sec) * time.Second)
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
139
internal/kernelboot/unknownfac_test.go
Normal file
139
internal/kernelboot/unknownfac_test.go
Normal file
@ -0,0 +1,139 @@
|
||||
package kernelboot
|
||||
|
||||
// "插件拼错设施名 ⇒ 静默永不启动" 的判据(2026-10-16)。
|
||||
//
|
||||
// ## 修的缺口
|
||||
//
|
||||
// 插件声明 `requires_facilities` 是**字符串**。拼错时原行为:
|
||||
// 永远等不到就绪 ⇒ **永不拉起**,且**无任何报错**、无超时兜底。
|
||||
// 这正是本仓反复吃亏的"静默降级"形态。
|
||||
//
|
||||
// 修法:Submit 时校验"声明的设施 ⊆ 内核注册过的",不符即报错并列出可选值。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★★ 判据:**拼错的设施名在 Submit 时被拒绝**(不再静默挂起)。
|
||||
func TestSchedulerRejectsUnknownFacility(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
bus.MarkDeclared([]string{"config", "llm", "memory"}) // 内核注册过这些
|
||||
|
||||
var launched []string
|
||||
var reported []string
|
||||
sch := NewScheduler(bus, func(p PluginSpec) error {
|
||||
launched = append(launched, p.Name)
|
||||
return nil
|
||||
})
|
||||
sch.SetLaunchErrorHandler(func(p PluginSpec, err error) {
|
||||
reported = append(reported, err.Error())
|
||||
})
|
||||
|
||||
// 拼错:写 "llmservice" 而不是 "llm"
|
||||
sch.Submit([]PluginSpec{{Name: "typo", RequiresFacilities: []string{"llmservice"}, AutoStart: true}})
|
||||
bus.MarkKernelComplete()
|
||||
|
||||
if len(launched) != 0 {
|
||||
t.Fatalf("★ 拼错设施名的插件被拉起了: %v", launched)
|
||||
}
|
||||
if len(reported) == 0 {
|
||||
t.Fatal("★ 拼错设施名却**没有任何报错** —— 插件会静默永不启动(这正是要修的缺口)")
|
||||
}
|
||||
msg := reported[0]
|
||||
if !strings.Contains(msg, "llmservice") {
|
||||
t.Errorf("错误应点出**是哪个名字**错了: %s", msg)
|
||||
}
|
||||
// 必须列出内核已知的设施名("拼错了"当场可修)
|
||||
if !strings.Contains(msg, "llm") || !strings.Contains(msg, "config") {
|
||||
t.Errorf("错误应列出内核已知的设施名供对照: %s", msg)
|
||||
}
|
||||
// 不得把它留在挂起队列(否则会永远占位)
|
||||
if p := sch.Pending(); len(p) != 0 {
|
||||
t.Errorf("被拒的插件不该留在挂起队列: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**正确的设施名不受影响**(不能把正常路径也拦了)。
|
||||
func TestSchedulerAcceptsKnownFacility(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
bus.MarkDeclared([]string{"config", "llm"})
|
||||
// ★ 必须同时 MarkLayerReady —— 真实路径里 Run 两者都会做:
|
||||
// MarkDeclared(执行前,登记名字)与 MarkLayerReady(每层完成,标记就绪)。
|
||||
// 只声明不就绪 ⇒ 插件会一直挂起(那是"未就绪"而非"拼错",两者不同)。
|
||||
bus.MarkLayerReady([]string{"config", "llm"})
|
||||
|
||||
var launched []string
|
||||
sch := NewScheduler(bus, func(p PluginSpec) error {
|
||||
launched = append(launched, p.Name)
|
||||
return nil
|
||||
})
|
||||
sch.SetLaunchErrorHandler(func(p PluginSpec, err error) {
|
||||
t.Errorf("不应报错: %v", err)
|
||||
})
|
||||
|
||||
sch.Submit([]PluginSpec{{Name: "ok", RequiresFacilities: []string{"config", "llm"}, AutoStart: true}})
|
||||
bus.MarkKernelComplete()
|
||||
|
||||
if len(launched) != 1 || launched[0] != "ok" {
|
||||
t.Fatalf("声明正确的插件应被拉起: %v", launched)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**"注册过但未就绪"是合法中间状态**(不能误判为拼错)。
|
||||
//
|
||||
// ★ 这条很重要:llm 声明了但还在第 1 层时,Known 为 true、Ready 为 false。
|
||||
// 若把"未就绪"当"拼错",所有多依赖插件都会在提交时被误拒。
|
||||
func TestSchedulerAcceptsDeclaredButNotYetReady(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
bus.MarkDeclared([]string{"config", "llm"}) // 两者都已声明
|
||||
bus.MarkLayerReady([]string{"config"}) // 只有 config 就绪
|
||||
|
||||
var reported []string
|
||||
sch := NewScheduler(bus, nil)
|
||||
sch.SetLaunchErrorHandler(func(p PluginSpec, err error) { reported = append(reported, err.Error()) })
|
||||
|
||||
sch.Submit([]PluginSpec{{Name: "needsllm", RequiresFacilities: []string{"llm"}, AutoStart: true}})
|
||||
if len(reported) != 0 {
|
||||
t.Fatalf("★ '已声明但未就绪'被误判为拼错: %v", reported)
|
||||
}
|
||||
if p := sch.Pending(); len(p) != 1 {
|
||||
t.Fatalf("应挂起等 llm 就绪: %v", p)
|
||||
}
|
||||
// llm 就绪后应被拉起
|
||||
bus.MarkLayerReady([]string{"llm"})
|
||||
}
|
||||
|
||||
// ★★★ 判据:**Run 执行前登记全部模块设施**(装配中途失败也能校验)。
|
||||
//
|
||||
// ★ 为何需要:插件提交发生在装配之后。若某层失败、后面的模块没跑,
|
||||
// 那些设施名就从未被 MarkLayerReady 登记 ⇒ 会**误报**"内核从未注册过"。
|
||||
// 所以 Run 必须**执行前**就 MarkDeclared。
|
||||
func TestRunDeclaresAllFacilitiesUpfront(t *testing.T) {
|
||||
bus := NewSyncBus()
|
||||
mods := []Module{
|
||||
&fakeModule{name: "a", provides: []string{"fa"}},
|
||||
&fakeModule{name: "b", provides: []string{"fb"}, requires: []string{"fa"},
|
||||
initErr: fmt.Errorf("故意失败")},
|
||||
&fakeModule{name: "c", provides: []string{"fc"}, requires: []string{"fb"}},
|
||||
}
|
||||
plan, err := Build(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 这个 Run 会**失败**(b 的 Init 报错)⇒ c 那一层不会跑
|
||||
if _, err := Run(plan, RunOptions{Bus: bus}); err == nil {
|
||||
t.Fatal("前置:应失败")
|
||||
}
|
||||
// ★ 但全部设施名都应已登记(因为执行前就 MarkDeclared 了)
|
||||
for _, f := range []string{"fa", "fb", "fc"} {
|
||||
if !bus.Known(f) {
|
||||
t.Errorf("★ 设施 %q 未被登记 —— 装配中途失败后,插件提交会误报\"内核从未注册过\"", f)
|
||||
}
|
||||
}
|
||||
// 而未跑到的层不该是"就绪"
|
||||
if bus.IsReady("fc") {
|
||||
t.Error("未执行的模块不该标记就绪")
|
||||
}
|
||||
}
|
||||
316
internal/kernelmod/agent/mod.go
Normal file
316
internal/kernelmod/agent/mod.go
Normal file
@ -0,0 +1,316 @@
|
||||
// Package agent 是 agent 模块:主 Agent(需在插件加载前创建,
|
||||
// 因为插件 Configure 需要 StatusProvider)。
|
||||
//
|
||||
// ## 依赖
|
||||
//
|
||||
// buildAgent 收 provider/providerMgr/iom/mem.db/mem.indexer/trk/docStore/
|
||||
// knowledge/mem.social/textMem/personality/pluginReg/embedder/
|
||||
// multimodalSpace/stageHost/evBus ⇒ 依赖除 supervisor 外的几乎全部下层模块。
|
||||
//
|
||||
// 人格设定(loadPersonality)在本模块内完成 —— 它只读配置文件,
|
||||
// 没有独立产物的价值,不必单列模块。
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentCore "github.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"github.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
|
||||
agentPkg "github.com/JianFeeeee/HomeAgent/internal/agent"
|
||||
)
|
||||
|
||||
// Module 提供主 Agent。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "agent" }
|
||||
func (Module) Requires() []string {
|
||||
return []string{kernelmod.FacLLM, kernelmod.FacMemory, kernelmod.FacTracker,
|
||||
kernelmod.FacDocStore, kernelmod.FacKnowledge, kernelmod.FacTextMem,
|
||||
kernelmod.FacStageHost}
|
||||
}
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacAgent} }
|
||||
|
||||
func (Module) Init(k *kernelboot.KernelState) error {
|
||||
if k.Memory == nil {
|
||||
return errMissing("agent", kernelmod.FacMemory)
|
||||
}
|
||||
if k.LLMManager == nil {
|
||||
return errMissing("agent", kernelmod.FacLLM)
|
||||
}
|
||||
if k.PluginRegistry == nil {
|
||||
return errMissing("agent", kernelmod.FacStageHost)
|
||||
}
|
||||
k.Agent = buildAgent(k)
|
||||
|
||||
// 插件 SDK 接线(必须在 loadPlugins 之前)。
|
||||
wirePluginSDK(k)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
|
||||
const defaultSystemPrompt = `你是 HomeAgent,一个持续运行的个人管家。
|
||||
你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。
|
||||
如需切换回复通道,使用 output_set_channel。
|
||||
如需异步发送消息或通知,使用 output_send 指定通道和内容。
|
||||
使用 output_list_channels 查看可用通道及其能力。
|
||||
|
||||
可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具:
|
||||
- memory_* — 图记忆(长期记忆,记录和查询个人信息/事实)
|
||||
- knowledge_* — 知识库(查阅预设知识文档)
|
||||
- doc_* — 文档记忆(近期对话的存档,查询后自动清除)
|
||||
- person_* — 人物特质与社交关系网
|
||||
- llm_* — LLM 源管理(列出/切换模型提供商)
|
||||
- output_* — 输出通道管理(切换/发送消息)
|
||||
- timer_set — 设置定时提醒
|
||||
- plgreload — 热重载插件
|
||||
- spawn_child — 生成子 Agent 异步执行独立任务(可传 max_turns 控制工具轮数,默认 5)
|
||||
|
||||
并行策略:遇到多个互不依赖的子任务时,优先并行 spawn 多个子 Agent 而非自己串行逐个执行;
|
||||
长耗时任务(批量处理、多轮搜索汇总)也应交给子 Agent,避免阻塞当前对话。
|
||||
- describe_image — 描述用户上传的图片
|
||||
- transcribe_audio — 转写用户上传的音频
|
||||
- ocr_image — 识别图片中的文字
|
||||
|
||||
命令与文件操作策略:
|
||||
- cmd_run 经完整 shell(bash)执行,支持管道、分号、&&、命令替换、heredoc、重定向。
|
||||
- 多步交互式程序(vim/top/ssh 会话、需要持续输入的进程)用 terminal_create 创建终端,
|
||||
terminal_write 发送输入、terminal_read 读输出——不要用 cmd_run 硬等交互程序退出。
|
||||
- 写文件优先 files_write(原子+留档),生成多行内容时可用 heredoc 或 files_write,
|
||||
不要用 echo 拼接长文本。
|
||||
- 读用户发来的文件用 files_read;向 webui 回传图片/文件用 output_send__webui(type=image/file)。
|
||||
|
||||
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。
|
||||
|
||||
回复你的真实想法,用自然语言与用户交流。不要在回复中使用 emoji 表情。`
|
||||
|
||||
func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
|
||||
cfg := k.Cfg
|
||||
cfgReg := k.CfgReg
|
||||
provider := k.LLMProvider
|
||||
providerMgr := k.LLMManager
|
||||
iom := k.IO
|
||||
memDB := k.Memory.DB
|
||||
memIdx := k.Memory.Indexer
|
||||
trk := k.Tracker
|
||||
docStore := k.DocStore
|
||||
ks := k.Knowledge
|
||||
socialStore := k.Memory.Social
|
||||
textMem := k.TextMemory
|
||||
pluginReg := k.PluginRegistry
|
||||
embedder := k.Embedder
|
||||
multimodalSpace := k.MultimodalSpace
|
||||
mmProviderName := k.MultimodalProviderName
|
||||
mmErr := k.MultimodalErr
|
||||
stageHost := k.StageHost
|
||||
evBus := k.EventBus
|
||||
|
||||
personality := loadPersonality(cfg, cfgReg)
|
||||
|
||||
sysPrompt := cfgReg.GetString("core.agent.system_prompt", defaultSystemPrompt)
|
||||
if sysPrompt == "" {
|
||||
sysPrompt = defaultSystemPrompt
|
||||
}
|
||||
|
||||
// D4:把"设备是否已授权"的判据注入插件侧(toolImpl.CanUse 用)。
|
||||
// ⚠️ 必须放在 agent 构造**之后**——判据要用 agent 的 allowedOutputs,
|
||||
// 而 registry 早于 agent 构造(故这里传的是晚绑定闭包)。
|
||||
// 未注入时 ToolAPI 路径对设备放行:那是"授权可被绕过"的既成缺口。
|
||||
// 注入后,cli 的 /terminal、seq 序列等一切走 ToolAPI 的调用都受同一道闸。
|
||||
agent := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "main",
|
||||
SystemPrompt: sysPrompt,
|
||||
Provider: provider,
|
||||
ProviderManager: providerMgr,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: docStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
// MediaStore 已随 CAS 淘汰:块自带负载(见 plan.md)。
|
||||
Personality: personality,
|
||||
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
|
||||
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
|
||||
PersonaStore: config.RegistryPersonaStore{Reg: cfgReg},
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
// DataDir:驻留子的 temp 图库锚点(<data>/residents/<id>/graph.db)。
|
||||
// 漏接时的现象是"工具存在、可调用、但创建必失败"——只有真实二进制才看得出来。
|
||||
DataDir: cfg.Daemon.DataDir,
|
||||
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
|
||||
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
|
||||
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
|
||||
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
|
||||
MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10),
|
||||
// 上下文管理的可调阈值。此前这些值全部硬编码在 ComputeTokenBudget 里,
|
||||
// 界面改不了、不同窗口的实例也没法各自调优。
|
||||
CtxTuning: agentCore.ContextTuning{
|
||||
UtilizationPercent: cfgReg.GetInt("core.agent.context.utilization_percent", 80),
|
||||
MaxTargetTokens: cfgReg.GetInt("core.agent.context.max_target_tokens", 600000),
|
||||
MemoryRatioPercent: cfgReg.GetInt("core.agent.context.memory_ratio_percent", 0),
|
||||
ProtectedCount: cfgReg.GetInt("core.agent.context.protected_count", 10),
|
||||
},
|
||||
// ⚠️ 这条键此前被注册进配置面板、也有默认值,但**从未被读取** ——
|
||||
// 界面上改它没有任何效果,且不报任何错。同一类「死配置」正是
|
||||
// 本次把阈值接入配置系统时要一并修掉的。
|
||||
MaxContextSize: cfgReg.GetInt("core.agent.max_context_size", 30),
|
||||
Offload: agentCore.OffloadOptions{
|
||||
Enabled: cfgReg.GetBool("core.agent.offload_enabled", false),
|
||||
BusyAfter: cfgReg.GetDuration("core.agent.offload_busy_after", 5*time.Minute),
|
||||
MinPending: cfgReg.GetInt("core.agent.offload_min_pending", 3),
|
||||
MaxResidents: cfgReg.GetInt("core.agent.offload_max_residents", 2),
|
||||
},
|
||||
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
||||
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
|
||||
Embedder: embedder,
|
||||
MultimodalSpace: multimodalSpace,
|
||||
EmbeddingProvider: mmProviderName,
|
||||
EmbeddingError: mmErr,
|
||||
StageHost: stageHost,
|
||||
EventBus: evBus,
|
||||
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
||||
InputProcessing: cfg.InputProcessing,
|
||||
})
|
||||
|
||||
// 图记忆读写钩子 → 事件总线。
|
||||
//
|
||||
// ★ 为什么在这里接(而不是让各调用方自己发事件):
|
||||
// 图读写散在 26 个方法与 180+ 个 db 调用点上,各调用方自报必漏发;
|
||||
// 而**图库自己就知道它碰了哪些块 ID**。星图此前只能靠工具名猜
|
||||
// 节点(把 "memory_recall" 拆词去匹配中文实体名,实测命中 0),
|
||||
// 于是每次都点亮同一批无关节点——这正是「星图没有读写反馈」的真身。
|
||||
//
|
||||
// ★ 只在 evBus 非 nil 时接:它把「内核进程」与「独立 CLI 工具」区分开
|
||||
// (homed-graph-migrate / memgc 等也用 NewGraphDB,但没有事件总线,
|
||||
// 也不该为一次批量迁移向不存在的订阅者发几万条事件)。
|
||||
//
|
||||
// ★ 钩子**必须非阻塞**:它在记忆读写的同步路径上,发布到总线
|
||||
// 是内存操作(各订阅者回调也都很短),实测开销可忽略。
|
||||
if memDB != nil && evBus != nil {
|
||||
memDB.SetAccessHook(func(ev memory.AccessEvent) {
|
||||
payload := map[string]interface{}{
|
||||
"op": ev.Op,
|
||||
}
|
||||
// 只在非空时带上字段:空数组会让前端白白重算一次。
|
||||
if len(ev.Blocks) > 0 {
|
||||
payload["blocks"] = ev.Blocks
|
||||
}
|
||||
if len(ev.Created) > 0 {
|
||||
payload["created"] = ev.Created
|
||||
}
|
||||
if len(ev.Removed) > 0 {
|
||||
payload["removed"] = ev.Removed
|
||||
}
|
||||
if len(ev.Merged) > 0 {
|
||||
merged := make([]map[string]string, 0, len(ev.Merged))
|
||||
for _, m := range ev.Merged {
|
||||
merged = append(merged, map[string]string{"from": m.From, "to": m.To})
|
||||
}
|
||||
payload["merged"] = merged
|
||||
}
|
||||
if ev.Tool != "" {
|
||||
payload["tool"] = ev.Tool
|
||||
}
|
||||
evBus.Publish(&events.Event{
|
||||
Type: events.EventMemoryAccess,
|
||||
Source: "main",
|
||||
Payload: payload,
|
||||
Timestamp: time.Now().Unix(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// D4:把「设备是否已授权」的判据注入插件侧(toolImpl.CanUse 消费它)。
|
||||
//
|
||||
// 为什么必须在这里:判据要用 agent 自己的 allowedOutputs,而
|
||||
// pluginReg 早于 agent 构造(newStageAndRegistry 在 main() 里先跑),
|
||||
// 所以 registry 存的是**晚绑定**闭包,注入点必须在 agent 建好之后。
|
||||
//
|
||||
// 不注入的后果(已实测的真实缺口):设备授权闸只存在于
|
||||
// core.executeToolCallInner,即「agent 收到模型 tool_call」那条路径;
|
||||
// 而 ToolAPI.ExecuteTool 是**另一条**独立入口,不经那道闸 ⇒
|
||||
// 凡是走 ToolAPI 的调用都能绕过 AllowedOutputs。实测范围不止序列:
|
||||
// cli 的 /terminal 就直接经 ToolAPI 调 agentcli 的终端工具。
|
||||
pluginReg.SetDeviceAuthQuery(func(deviceID string) bool {
|
||||
return agent.IsOutputAllowed("device/" + deviceID)
|
||||
})
|
||||
|
||||
// 方案 B:把 agent 的**内置工具面**注入 ToolAPI。
|
||||
//
|
||||
// 缺口背景:`memory_*` / `knowledge_*` / `doc_*` / `person_*` 这 20+ 个
|
||||
// 在 executeToolCallInner 里按前缀分派,从不进 ToolAPI ⇒ 插件经 ToolAPI
|
||||
// 既查不到也调不了。真机实跑实证:seq_run 报「工具 knowledge_list
|
||||
// 不存在或未注册」,而同一轮模型直接调它是成功的。
|
||||
//
|
||||
// 注入必须在此处(agent 构造之后):内置工具的可见性由运行期状态门控
|
||||
// (memory/knowledge 是否就绪),而 provider 持有的是 agent。
|
||||
agent.InstallBuiltinToolProvider()
|
||||
|
||||
return agent
|
||||
}
|
||||
|
||||
// wirePluginSDK 把内核依赖注入插件注册表(原 cmd/homed 的整块平移)。
|
||||
func wirePluginSDK(k *kernelboot.KernelState) {
|
||||
k.PluginRegistry.SetLuaVM(k.LuaVM)
|
||||
k.PluginRegistry.SetBaseAPIKey(k.BaseAPIKey)
|
||||
k.PluginRegistry.SetSupervisor(supervisor.NewSDKAdapter(k.Supervisor))
|
||||
k.PluginRegistry.SetTracker(k.Tracker)
|
||||
k.PluginRegistry.SetConfig(k.Cfg)
|
||||
k.PluginRegistry.SetStageHost(k.StageHost)
|
||||
k.PluginRegistry.SetIndexer(k.Memory.Indexer)
|
||||
k.PluginRegistry.SetStatusProvider(k.Agent)
|
||||
k.PluginRegistry.SetTerminalAPI(k.Agent)
|
||||
}
|
||||
|
||||
// loadPersonality 载入人格(原 cmd/homed 的整块平移)。
|
||||
func loadPersonality(cfg *types.Config, cfgReg *config.ConfigRegistry) *agentPkg.Personality {
|
||||
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
||||
personality, err := agentPkg.LoadPersonality(personalPath)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: load personality: %v", err)
|
||||
}
|
||||
if personality != nil && personality.Content != "" {
|
||||
log.Printf("[homed] 人格来源=文件 %s(优先于配置项),%d 字节", personalPath, len(personality.Content))
|
||||
if hints := agentPkg.PersonaStaleHints(personality.Content); len(hints) > 0 {
|
||||
log.Printf("[homed] warning: 人格文件含会腐坏的内容 %v — 建议迁到配置项 core.agent.personal_prompt"+
|
||||
"(默认模板不含版本号,被问版本时以运行时快照为准)", hints)
|
||||
}
|
||||
} else if pv := cfgReg.GetString("core.agent.personal_prompt", config.DefaultPersonaPrompt); strings.TrimSpace(pv) != "" {
|
||||
personality = &agentPkg.Personality{Content: pv, Path: "(core.agent.personal_prompt)"}
|
||||
log.Printf("[homed] 人格来源=配置项 core.agent.personal_prompt,%d 字节", len(pv))
|
||||
} else {
|
||||
log.Printf("[homed] 人格来源=无(配置项为空且无人格文件)")
|
||||
}
|
||||
return personality
|
||||
}
|
||||
|
||||
// errMissing 报告"依赖的模块产物为 nil"。
|
||||
//
|
||||
// ★ 为什么立刻报错而不是让 nil 传下去:nil 读出零值后 continue 会在**很远
|
||||
// 的地方**变成模糊症状(如"模型没有配置")。这里点出**是哪个模块的哪个
|
||||
// 产物缺失**,把排查成本从"追几小时"降到"看一行"。
|
||||
//
|
||||
// 措辞刻意点出"依赖图本应保证它非 nil" —— 若真出现,说明
|
||||
// ① 模块忘了声明依赖,或 ② Requires/Provides 设施名不一致(拼写漂移)。
|
||||
func errMissing(moduleName, facility string) error {
|
||||
return fmt.Errorf("模块 %s 依赖的设施 %q 对应的数据为 nil —— "+
|
||||
"依赖图本应保证它已就绪。两种可能:① 该模块没在 Requires 里声明这个依赖;"+
|
||||
"② Provides/Requires 的设施名写得不一致(拼写漂移)", moduleName, facility)
|
||||
}
|
||||
|
||||
var _ = events.EventMemoryAccess
|
||||
var _ = memory.AccessEvent{}
|
||||
43
internal/kernelmod/all/all.go
Normal file
43
internal/kernelmod/all/all.go
Normal file
@ -0,0 +1,43 @@
|
||||
// Package all 把全部内核模块**引入编译**(Go 要求 import 才会编译进来)。
|
||||
//
|
||||
// ## 为什么这层是必要的(一个真实的 Go 约束)
|
||||
//
|
||||
// 模块各自在 init() 里 `kernelmod.Register(...)`,但必须有东西把它们
|
||||
// 引进来。若把 import 写在 `kernelmod/core.go` 里:
|
||||
//
|
||||
// core.go import lua/ ⇒ core → lua → core(lua 要调 Register 必须 import core)
|
||||
// ⇒ **循环导入**,Go 禁止
|
||||
//
|
||||
// 所以注册表 API 在 `kernelmod` 包,模块 import 它(单向),
|
||||
// 而"引进来"的清单放在**本子包**(`kernelmod/all`)—— 它 import 两者。
|
||||
//
|
||||
// ## 加一个新模块要做什么(**唯一的一处编辑**)
|
||||
//
|
||||
// ① 新建 internal/kernelmod/<name>/mod.go,实现 kernelboot.Module
|
||||
// ② 在**本文件**加一行空白 import
|
||||
//
|
||||
// 依赖写在模块自己的 Requires() 里 —— **没有**中央依赖清单。
|
||||
package all
|
||||
|
||||
import (
|
||||
// 每个模块一个独立文件夹(用户设计)。
|
||||
//
|
||||
// ★ 顺序**无关**:模块注册顺序由 init() 决定,而注册表刻意乱序
|
||||
// (见 kernelmod/core.go)。真正的先后只由 Requires/Provides 决定。
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/agent"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/config"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/docstore"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/kernelapi"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/knowledge"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/llm"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/lua"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/memory"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/migration"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/multimodal"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/onnx"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/pluginhost"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/stagehost"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/supervisor"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/textmem"
|
||||
_ "github.com/JianFeeeee/HomeAgent/internal/kernelmod/tracker"
|
||||
)
|
||||
112
internal/kernelmod/all/facilities_sync_test.go
Normal file
112
internal/kernelmod/all/facilities_sync_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
package all
|
||||
|
||||
// 设施名「内核 ↔ 公开 SDK」一致性判据(2026-10-16)。
|
||||
//
|
||||
// ## 为什么需要它
|
||||
//
|
||||
// 设施名同时存在于**两处**,且必须逐字一致:
|
||||
//
|
||||
// 内核:internal/kernelmod/facilities.go (模块 Provides 用)
|
||||
// SDK:third_party/homeagent-sdk/sdk/facilities.go (插件声明用)
|
||||
//
|
||||
// 它们**不能合并**:SDK 是独立发布的模块(外部插件依赖它),
|
||||
// 而内核的 internal 规则又禁止内部包被外部 import。
|
||||
//
|
||||
// ⇒ 两处并存 ⇒ 有漂移风险。**改一处忘另一处**的后果是:
|
||||
// 插件按 SDK 声明的名字,内核从未注册 ⇒ 插件**永不启动**,且(在本判据
|
||||
// 与运行期校验出现前)无任何报错。
|
||||
//
|
||||
// 本判据把"两边一致"变成可执行的检查。
|
||||
//
|
||||
// ## 为什么必须从**源码**提取而不是手写清单
|
||||
//
|
||||
// 手写清单在"新增了一个设施名但只改了内核"时**也不会包含新名字**
|
||||
// ⇒ 判据照样绿(守卫形同虚设)。本仓已有先例
|
||||
// (TestCapability_AllMethodsClassified 初版就是这个毛病)。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// internalOnlyFacilities 是**内核内部**顺序标记(不是给插件的契约)。
|
||||
//
|
||||
// ★ 判据要排除它们:它们的消费者是内核模块,不该进公开 SDK。
|
||||
// 若把它们也拿去比对,会得到"内核有但 SDK 没有"的**假红**(实测撞到:
|
||||
// knowledge-migrated)。反向也要防:有人误把内部标记放进 SDK 当对外契约。
|
||||
var internalOnlyFacilities = map[string]bool{
|
||||
"knowledge-migrated": true,
|
||||
}
|
||||
|
||||
// extractFacilities 从 Go 源码里提取 `FacilityXxx = "值"` 形式的常量。
|
||||
func extractFacilities(t *testing.T, path string) map[string]string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("读 %s: %v", path, err)
|
||||
}
|
||||
re := regexp.MustCompile(`(?m)^\s*(Fac[A-Za-z0-9_]*|Facility[A-Za-z0-9_]*)\s*=\s*"([^"]+)"`)
|
||||
out := map[string]string{}
|
||||
for _, m := range re.FindAllStringSubmatch(string(b), -1) {
|
||||
out[m[1]] = m[2]
|
||||
}
|
||||
if len(out) < 10 {
|
||||
t.Fatalf("只从 %s 提取到 %d 个设施常量 —— 正则或文件结构变了(判据会假绿)",
|
||||
path, len(out))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ★★★ 判据:**内核与 SDK 的设施名值集合逐字一致**。
|
||||
func TestFacilityNamesMatchPublicSDK(t *testing.T) {
|
||||
kernel := extractFacilities(t, filepath.Join("..", "facilities.go"))
|
||||
sdk := extractFacilities(t, filepath.Join("..", "..", "..",
|
||||
"third_party", "homeagent-sdk", "sdk", "facilities.go"))
|
||||
|
||||
kernelVals := map[string]string{} // 值 → 内核常量名
|
||||
for name, v := range kernel {
|
||||
if internalOnlyFacilities[v] {
|
||||
continue // 内核内部顺序标记,不是插件契约
|
||||
}
|
||||
kernelVals[v] = name
|
||||
}
|
||||
sdkVals := map[string]string{}
|
||||
for name, v := range sdk {
|
||||
sdkVals[v] = name
|
||||
}
|
||||
|
||||
var missingInSDK, missingInKernel []string
|
||||
for v, name := range kernelVals {
|
||||
if _, ok := sdkVals[v]; !ok {
|
||||
missingInSDK = append(missingInSDK, name+"="+v)
|
||||
}
|
||||
}
|
||||
for v, name := range sdkVals {
|
||||
if _, ok := kernelVals[v]; !ok {
|
||||
missingInKernel = append(missingInKernel, name+"="+v)
|
||||
}
|
||||
}
|
||||
sort.Strings(missingInSDK)
|
||||
sort.Strings(missingInKernel)
|
||||
|
||||
if len(missingInSDK) > 0 {
|
||||
t.Errorf("★ 内核有但**公开 SDK 没有**的设施名: %v\n"+
|
||||
" 后果:插件作者无法 import 该常量(只能照文档抄 ⇒ 易拼错),"+
|
||||
"且打包期校验会把它当'未知设施名'而拒绝", missingInSDK)
|
||||
}
|
||||
for v := range sdkVals {
|
||||
if internalOnlyFacilities[v] {
|
||||
t.Errorf("★ 公开 SDK 里出现了内核**内部顺序标记** %q —— "+
|
||||
"它的消费者是内核模块,不该成为插件契约(插件声明它没有意义,"+
|
||||
"且会误导作者以为可用)", v)
|
||||
}
|
||||
}
|
||||
if len(missingInKernel) > 0 {
|
||||
t.Errorf("★ 公开 SDK 有但**内核没有**的设施名: %v\n"+
|
||||
" 后果:插件按此声明 ⇒ 内核从未注册 ⇒ 插件**永不启动**(静默)",
|
||||
missingInKernel)
|
||||
}
|
||||
}
|
||||
351
internal/kernelmod/all/registry_test.go
Normal file
351
internal/kernelmod/all/registry_test.go
Normal file
@ -0,0 +1,351 @@
|
||||
package all
|
||||
|
||||
// 内核模块注册表 + 分层的判据(2026-10-16)。
|
||||
//
|
||||
// 设计见 docs/zh/kernel-modularization-design.md §2.3/§2.6/§2.8。
|
||||
//
|
||||
// ## 用户的验收标准(必须被判据钉住)
|
||||
//
|
||||
// > 模块应该在 /modelname/ 独立文件夹下,根目录只有 core.go,
|
||||
// > core 内有一个注册表注册各个模块 … 注意注册表中的模块列表是乱序的
|
||||
//
|
||||
// ⇒ 四条底线:
|
||||
// ① 每个模块**独立文件夹**
|
||||
// ② 依赖写在**模块自己**的 Requires() 里(无中央依赖清单)
|
||||
// ③ 注册表列表**乱序**(顺序无意义,不能被依赖)
|
||||
// ④ 第 0 层模块**不依赖任何模块**("core 不依赖任何模块"字面成立)
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
)
|
||||
|
||||
// ★★★ 判据:**依赖图可建 + 模块全登记 + 无重复**。
|
||||
func TestRegistryBuildsValidGraph(t *testing.T) {
|
||||
if d := kernelmod.Duplicates(); len(d) > 0 {
|
||||
t.Fatalf("★ 模块名重复注册: %v(两个模块同名,或一个模块被导入两次)", d)
|
||||
}
|
||||
mods := kernelmod.Registered()
|
||||
if len(mods) < 10 {
|
||||
t.Fatalf("★ 只登记 %d 个模块 —— 可能有模块漏了 init() 注册", len(mods))
|
||||
}
|
||||
plan, err := kernelboot.Build(mods)
|
||||
if err != nil {
|
||||
t.Fatalf("★ 依赖图无解(结构错误): %v", err)
|
||||
}
|
||||
total := 0
|
||||
for _, l := range plan.Layers {
|
||||
total += len(l)
|
||||
}
|
||||
if total != len(mods) {
|
||||
t.Fatalf("★ 分层丢失模块: %d != %d", total, len(mods))
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据(用户验收标准 ④):**第 0 层不依赖任何模块**。
|
||||
func TestFirstLayerHasNoDependencies(t *testing.T) {
|
||||
plan, err := kernelboot.Build(kernelmod.Registered())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range plan.Layers[0] {
|
||||
if len(m.Requires()) != 0 {
|
||||
t.Errorf("★ 第 0 层模块 %s 有依赖 %v —— 分层正确的话不该有",
|
||||
m.Name(), m.Requires())
|
||||
}
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, m := range plan.Layers[0] {
|
||||
names[m.Name()] = true
|
||||
}
|
||||
for _, want := range []string{"config", "kernel-api"} {
|
||||
if !names[want] {
|
||||
t.Errorf("★ %s 应在第 0 层(它不依赖任何模块)", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据(用户验收标准 ①②):**每模块独立文件夹;依赖写在自己文件里**。
|
||||
func TestEachModuleInOwnFolder(t *testing.T) {
|
||||
root := ".."
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modDirs := 0
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
modDirs++
|
||||
files, _ := filepath.Glob(filepath.Join(root, e.Name(), "*.go"))
|
||||
if len(files) == 0 {
|
||||
t.Errorf("★ 模块目录 %s 下没有 .go 文件", e.Name())
|
||||
}
|
||||
}
|
||||
if modDirs < 10 {
|
||||
t.Fatalf("★ 只找到 %d 个模块目录(期望 ≥10)", modDirs)
|
||||
}
|
||||
|
||||
// ② 依赖不能出现在"中央清单"里:根目录只有 core.go/facilities.go/all 子包
|
||||
for _, f := range []string{"core.go", "facilities.go"} {
|
||||
b, err := os.ReadFile(filepath.Join(root, f))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
src := string(b)
|
||||
if strings.Contains(src, "Requires() []string { return []string{") {
|
||||
t.Errorf("★ %s 里出现了具体的 Requires —— 依赖应写在各模块自己的文件里", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据(用户验收标准 ③):**注册表列表乱序**(顺序无意义,不能被依赖)。
|
||||
//
|
||||
// ★ 怎么测"乱序":注册顺序由 init() 决定。这里断言 Names() 的顺序
|
||||
// **不等于**按名字排序的顺序 —— 若相等,说明有人给它排过序,
|
||||
// 那会给使用者"这个顺序有意义"的错觉(用户明确要求乱序)。
|
||||
//
|
||||
// ⚠ 注意:这条是**性质检查**,不是绝对的(init 顺序恰好等于字典序时也会通过
|
||||
// 排序检查)。所以真正守"顺序无意义"的是**文档 + 分层器内部排序**,
|
||||
// 而本判据是"如果有人显式排序就报红"的报警器。
|
||||
func TestRegistryOrderIsNotMeaningful(t *testing.T) {
|
||||
names := kernelmod.Names()
|
||||
if len(names) < 2 {
|
||||
t.Fatal("模块太少")
|
||||
}
|
||||
sorted := append([]string(nil), names...)
|
||||
sort.Strings(sorted)
|
||||
if strings.Join(names, ",") == strings.Join(sorted, ",") {
|
||||
t.Logf("提示:注册顺序恰好等于字典序(不必然是错误,但请确认没有人"+
|
||||
"给注册表排序——顺序无意义是设计的一部分): %v", names)
|
||||
}
|
||||
// 核心断言:Registered() 返回的列表**不得**按依赖序排列
|
||||
// (即:不能有人把"启动顺序"写进注册表)
|
||||
plan, err := kernelboot.Build(kernelmod.Registered())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var layerOrder []string
|
||||
for _, l := range plan.Layers {
|
||||
for _, m := range l {
|
||||
layerOrder = append(layerOrder, m.Name())
|
||||
}
|
||||
}
|
||||
if strings.Join(names, ",") == strings.Join(layerOrder, ",") {
|
||||
t.Fatal("★ 注册表顺序恰好等于**依赖分层序** —— 说明注册表被用来表达启动顺序了," +
|
||||
"那正是要消灭的隐式顺序依赖(用户要求注册表乱序)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**关键顺序被依赖图保证**(每条都来自真实约束)。
|
||||
func TestLayeringPreservesCriticalOrder(t *testing.T) {
|
||||
plan, err := kernelboot.Build(kernelmod.Registered())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layerOf := map[string]int{}
|
||||
for i, l := range plan.Layers {
|
||||
for _, m := range l {
|
||||
layerOf[m.Name()] = i
|
||||
}
|
||||
}
|
||||
cases := []struct{ a, b, why string }{
|
||||
{"config", "lua", "initLuaVM(cfg) 收 cfg"},
|
||||
{"lua", "llm", "initLLMProviders 收 luaVM"},
|
||||
{"config", "memory", "initMemoryStack 收 cfgReg"},
|
||||
{"knowledge-migration", "knowledge-store", "迁移必须早于 store 扫盘"},
|
||||
{"llm", "stage-host", "newStageAndRegistry 收 providerMgr"},
|
||||
{"memory", "stage-host", "newStageAndRegistry 收 mem.db"},
|
||||
{"stage-host", "agent", "newMainAgent 收 stageHost"},
|
||||
{"llm", "agent", "newMainAgent 收 provider"},
|
||||
{"memory", "agent", "newMainAgent 收 mem.db"},
|
||||
{"tracker", "agent", "newMainAgent 收 trk"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
la, ok1 := layerOf[c.a]
|
||||
lb, ok2 := layerOf[c.b]
|
||||
if !ok1 || !ok2 {
|
||||
t.Errorf("模块缺失: %s=%v %s=%v", c.a, ok1, c.b, ok2)
|
||||
continue
|
||||
}
|
||||
if la >= lb {
|
||||
t.Errorf("★ %s 应在 %s 之前(%s),实际层号 %d >= %d", c.a, c.b, c.why, la, lb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**text-memory 显式依赖 onnx-parser**(隐藏依赖,见 textmem 包注释)。
|
||||
//
|
||||
// ★ 不能用层号断言:text-memory 已因 memory 被推到第 2 层,
|
||||
// 而 onnx-parser 在第 1 层 ⇒ 删掉依赖后层号依然满足。必须断言**声明本身**。
|
||||
func TestHiddenDependencyOnnxDeclared(t *testing.T) {
|
||||
src := readMod(t, filepath.Join("..", "textmem", "mod.go"))
|
||||
if !strings.Contains(src, "FacONNX") {
|
||||
t.Fatal("★ text-memory 未声明对 onnx-parser 的依赖 —— " +
|
||||
"它在运行期读 nlp.defaultParser(onnx 模块写的包级单例)。" +
|
||||
"当前靠 distillLoop 的 Interval=10min 碰巧安全;" +
|
||||
"Interval 变小或解析器装配更后 ⇒ 静默降级(图记忆实体退化成整句复合值)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**分层带来并行**(对照原 17 步串行)。
|
||||
func TestLayeringYieldsParallelism(t *testing.T) {
|
||||
mods := kernelmod.Registered()
|
||||
plan, err := kernelboot.Build(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
maxLayer := 0
|
||||
for _, l := range plan.Layers {
|
||||
if len(l) > maxLayer {
|
||||
maxLayer = len(l)
|
||||
}
|
||||
}
|
||||
if len(plan.Layers) >= len(mods) {
|
||||
t.Fatalf("★ 无并行度(%d 层 / %d 模块)", len(plan.Layers), len(mods))
|
||||
}
|
||||
t.Logf("并行度: %d 层 / %d 模块,最宽层 %d 个", len(plan.Layers), len(mods), maxLayer)
|
||||
}
|
||||
|
||||
func readMod(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("读 %s: %v", path, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ★★★ 判据:**每个模块目录都被 import 引入**(漏 import ⇒ 模块静默消失)。
|
||||
//
|
||||
// ## ★ 这条是变异测试发现的**真实覆盖漏洞**
|
||||
//
|
||||
// 我删掉 `all.go` 里 `multimodal` 的 import 后,**全部判据仍然通过** ——
|
||||
// 因为:
|
||||
//
|
||||
// · 依赖图仍然解得开(multimodal **没有下游依赖**,没人需要它)
|
||||
// · 注册表数量检查只有 `>= 10` 这种下限(少一个不报)
|
||||
//
|
||||
// 后果:**多模态能力凭空消失**,而启动照常成功、无任何报错。
|
||||
// 这与本仓反复吃亏的"静默降级"是同一形态:
|
||||
//
|
||||
// · 漏 import 插件包 ⇒ 插件静默消失(已有同类教训)
|
||||
// · 漏 import 模块包 ⇒ 模块静默消失(本条)
|
||||
//
|
||||
// ## 判据做法:**统计目录 vs 统计注册**
|
||||
//
|
||||
// 扫描 `internal/kernelmod/` 下的模块目录(含 `mod.go` 或实现 Module 的 .go
|
||||
// 文件),与注册表里的模块名比对 —— 目录有而注册表没有 ⇒ 漏 import。
|
||||
//
|
||||
// ★ 为什么不用"手写清单":手写清单在"新增目录忘了加 import"时
|
||||
// **也不会包含新目录** ⇒ 判据照样绿(本仓已有先例)。
|
||||
func TestEveryModuleDirIsImported(t *testing.T) {
|
||||
root := ".."
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
registered := map[string]bool{}
|
||||
for _, n := range kernelmod.Names() {
|
||||
registered[n] = true
|
||||
}
|
||||
|
||||
// 收集"看起来是模块目录"的:含 .go 文件、且文件里有 kernelmod.Register 调用
|
||||
var moduleDirs []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if name == "all" {
|
||||
continue // 这个子包是 import 清单本身
|
||||
}
|
||||
files, _ := filepath.Glob(filepath.Join(root, name, "*.go"))
|
||||
hasRegister := false
|
||||
for _, f := range files {
|
||||
if strings.Contains(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(b), "kernelmod.Register(") {
|
||||
hasRegister = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasRegister {
|
||||
moduleDirs = append(moduleDirs, name)
|
||||
}
|
||||
}
|
||||
if len(moduleDirs) < 5 {
|
||||
t.Fatalf("只找到 %d 个模块目录 —— 扫描逻辑失效(判据会假绿)", len(moduleDirs))
|
||||
}
|
||||
|
||||
// 每个模块目录里的 Register(...) 参数名 → 期望的模块名
|
||||
// (用"目录名"与"注册名"的映射,因为目录名与 Name() 不必相同)
|
||||
dirToNames := map[string][]string{}
|
||||
for _, d := range moduleDirs {
|
||||
files, _ := filepath.Glob(filepath.Join(root, d, "*.go"))
|
||||
for _, f := range files {
|
||||
if strings.Contains(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
b, _ := os.ReadFile(f)
|
||||
// ★ 正则要容忍 gofmt 的对齐空格(列对齐会让 `{` 前有多个空格)。
|
||||
// 初版写成 `\{ return` (单空格)⇒ 只匹配到 agent 一个,
|
||||
// 其余全部"找不到目录"(判据假红)。实测撞到。
|
||||
re := regexp.MustCompile(`(?m)^func \([A-Za-z0-9_]+\) Name\(\) string\s+\{ return "([^"]+)" \}`)
|
||||
for _, m := range re.FindAllStringSubmatch(string(b), -1) {
|
||||
dirToNames[d] = append(dirToNames[d], m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for dir, names := range dirToNames {
|
||||
if len(names) == 0 {
|
||||
continue
|
||||
}
|
||||
// 该目录的**每个**模块名都必须在注册表里
|
||||
//(一个目录可含多个模块,如 knowledge/{migration,store})
|
||||
anyFound := false
|
||||
for _, n := range names {
|
||||
if registered[n] {
|
||||
anyFound = true
|
||||
}
|
||||
}
|
||||
if !anyFound {
|
||||
missing = append(missing, fmt.Sprintf("%s(%v)", dir, names))
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("★ 这些模块目录**没有被 import 引入**(模块静默消失,启动不报错): %v\n"+
|
||||
" 修复:在 internal/kernelmod/all/all.go 里加空白 import", missing)
|
||||
}
|
||||
|
||||
// 反向:注册表里的每个模块都应能对应到某个目录
|
||||
known := map[string]bool{}
|
||||
for _, ns := range dirToNames {
|
||||
for _, n := range ns {
|
||||
known[n] = true
|
||||
}
|
||||
}
|
||||
for n := range registered {
|
||||
if !known[n] {
|
||||
t.Errorf("★ 注册表里的模块 %s 找不到对应目录 —— 可能是测试夹具注册的,或扫描漏了", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
43
internal/kernelmod/config/mod.go
Normal file
43
internal/kernelmod/config/mod.go
Normal file
@ -0,0 +1,43 @@
|
||||
// Package config 是 config 模块:提供配置快照(ks.Cfg)。
|
||||
//
|
||||
// ## ★ 它解决了"cfg 是无主模块"的问题
|
||||
//
|
||||
// 原先 `cfg := cfgReg.ToConfig()` 之后,cfg 是**游离的局部变量**,
|
||||
// 被手工传给 11 个 init* —— 谁需要它就靠人肉排在那一行之后。
|
||||
// 现在它住在 `ks.Cfg`,任何模块直接读即可。
|
||||
//
|
||||
// ## 依赖:图上为空,但隐含"必须在 Group 0 之后"
|
||||
//
|
||||
// 它读 core.llm.* 里的**密文** ⇒ 必须在安全块(装好密钥)之后。
|
||||
// 但这不是**图内依赖**(安全块不由任何调度模块提供;声明 crypto 会让
|
||||
// Build 报"依赖了不存在的设施")。更本质:那是**时序**要求,不是**数据**依赖。
|
||||
//
|
||||
// ⇒ 由 Group 0 阶段保证:Group 0 串行跑完 ⇒ 才启动本调度器。
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
)
|
||||
|
||||
// Module 提供配置快照。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "config" }
|
||||
func (Module) Requires() []string { return nil } // 前提由 Group 0 保证
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacConfig} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
// ★ 检查而不是假设(用户说的"nil 加读取前检查"):
|
||||
// ks.Cfg 为 nil 说明 Group 0 段没跑或顺序错了。响亮报错远好于让后续
|
||||
// 模块拿到 nil —— 那会在很远的地方变成"模型没有配置"之类的模糊症状。
|
||||
if ks.Cfg == nil {
|
||||
return fmt.Errorf("config 模块:ks.Cfg 为 nil —— Group 0 必须先完成 " +
|
||||
"`cfg := cfgReg.ToConfig()`(且必须在安全块之后,否则密文解不开 ⇒ 全 provider 401)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
141
internal/kernelmod/core.go
Normal file
141
internal/kernelmod/core.go
Normal file
@ -0,0 +1,141 @@
|
||||
// Package kernelmod 是内核模块的**注册表**(用户设计的 core)。
|
||||
//
|
||||
// ## 用户的设计意图(原话)
|
||||
//
|
||||
// > 模块应该在 /modelname/ 独立文件夹下,根目录只有 core.go,
|
||||
// > core 内有一个注册表注册各个模块,各个模块通过该注册表得知内核有哪些模块,
|
||||
// > 从而声明对其他模块的依赖,注意注册表中的模块列表是乱序的,
|
||||
// > 只是为了向模块展示内核注册了哪些模块
|
||||
//
|
||||
// ## 目录形状
|
||||
//
|
||||
// internal/kernelmod/
|
||||
// ├── core.go ← 本文件:注册表(+ 唯一的 import 清单)
|
||||
// ├── facilities.go ← 设施名常量(模块间的协议)
|
||||
// ├── config/mod.go ← 每个模块一个独立文件夹
|
||||
// ├── kernelapi/mod.go
|
||||
// ├── lua/mod.go
|
||||
// ├── llm/mod.go
|
||||
// ├── memory/mod.go
|
||||
// ├── supervisor/mod.go
|
||||
// ├── tracker/mod.go
|
||||
// ├── textmem/mod.go
|
||||
// ├── docstore/mod.go
|
||||
// ├── knowledge/{migration,store}.go
|
||||
// ├── multimodal/mod.go
|
||||
// ├── onnx/mod.go
|
||||
// ├── stagehost/mod.go
|
||||
// └── agent/mod.go
|
||||
//
|
||||
// ## 职责边界(刻意做窄)
|
||||
//
|
||||
// 本文件**只做两件事**:
|
||||
//
|
||||
// · `Register(m)` —— 模块在 init() 里自注册
|
||||
// · `Registered()` —— 返回已注册模块(**乱序**,仅供查询"内核有哪些模块")
|
||||
//
|
||||
// 它**不做**依赖解析/分层/执行 —— 那些是 `internal/kernelboot` 的事。
|
||||
// 分开的理由:注册表是"名册",分层器是"调度器"。混在一起会让
|
||||
// "只想知道有哪些模块"的人被迫拉进整个调度逻辑。
|
||||
//
|
||||
// ## ★ 为什么列表是"乱序",以及这为什么是对的
|
||||
//
|
||||
// 注册顺序取决于 **Go 的 init() 执行顺序**(由 import 图决定,
|
||||
// 规范未定义跨包的**具体**顺序)。若把注册顺序当成"可信的启动顺序",
|
||||
// 那就是**隐式顺序依赖** —— 正是本设计要消灭的东西(§1 的病 A)。
|
||||
//
|
||||
// 所以 `Registered()` **刻意不排序**,并明确"顺序无意义":
|
||||
//
|
||||
// · 模块只能用它回答"内核注册了哪些模块"
|
||||
// · **不能**用它推断先后 —— 先后只由 Requires/Provides 决定
|
||||
//
|
||||
// ★ 分层器(kernelboot.Build)内部会按名字排序以保证**结果可复现**,
|
||||
// 那是分层器的实现细节,与"注册表乱序"不矛盾。
|
||||
//
|
||||
// ## ★ 关于下面那份 import 清单(为什么必须有,且它是**唯一**的一处)
|
||||
//
|
||||
// Go 要求"包被 import 才会编译进来"。模块各自在 init() 里注册,
|
||||
// 但**必须有东西把它们引进来** —— 这就是下面那些空白导入。
|
||||
//
|
||||
// 为什么不能省(也解释了一个真实的 Go 约束):
|
||||
//
|
||||
// core.go 想 import lua/ ⇒ core → lua → core(lua 要调 Register)
|
||||
// ⇒ **循环导入**,Go 禁止
|
||||
//
|
||||
// 解法:注册表 API 放在**本包**、模块 import 本包(单向),
|
||||
// 而"把它们引进来"的清单只能放在**本包之外** —— 见
|
||||
// `internal/kernelmod/all/all.go`(只做 import,无逻辑)。
|
||||
//
|
||||
// ⇒ 加模块 = **加一个文件夹 + 在 all.go 加一行**(唯一的一处编辑)。
|
||||
package kernelmod
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
list []kernelboot.Module
|
||||
seen = map[string]bool{}
|
||||
dups []string
|
||||
)
|
||||
|
||||
// Register 由模块在自己的 init() 里调用。
|
||||
//
|
||||
// 重复注册同名模块会被**记录**(不 panic):panic 发生在 init 阶段,
|
||||
// 栈信息对使用者毫无意义(他只想知道哪个模块重名)。由 Registered 报出。
|
||||
func Register(m kernelboot.Module) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
name := m.Name()
|
||||
if seen[name] {
|
||||
dups = append(dups, name)
|
||||
return
|
||||
}
|
||||
seen[name] = true
|
||||
list = append(list, m)
|
||||
}
|
||||
|
||||
// Registered 返回全部已注册模块。
|
||||
//
|
||||
// ★ **顺序无意义**(见包注释):它取决于 init() 执行顺序。
|
||||
// 只用它回答"内核注册了哪些模块",**不要**用它推断先后。
|
||||
func Registered() []kernelboot.Module {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
out := make([]kernelboot.Module, len(list))
|
||||
copy(out, list)
|
||||
return out
|
||||
}
|
||||
|
||||
// Duplicates 返回重复注册的模块名(诊断用)。
|
||||
func Duplicates() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
out := make([]string, len(dups))
|
||||
copy(out, dups)
|
||||
return out
|
||||
}
|
||||
|
||||
// Names 返回已注册模块名(诊断用;顺序同 Registered,无意义)。
|
||||
func Names() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
out := make([]string, 0, len(list))
|
||||
for _, m := range list {
|
||||
out = append(out, m.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsRegistered 报告某模块是否已注册(模块可用它判断依赖是否存在)。
|
||||
func IsRegistered(name string) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return seen[name]
|
||||
}
|
||||
37
internal/kernelmod/docstore/mod.go
Normal file
37
internal/kernelmod/docstore/mod.go
Normal file
@ -0,0 +1,37 @@
|
||||
// Package docstore 是 doc-store 模块:文档记忆(文本块 + 向量)。
|
||||
//
|
||||
// 依赖 config:`initDocStore(cfg)` 读 cfg.Daemon.DataDir。
|
||||
package docstore
|
||||
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
)
|
||||
|
||||
// Module 提供文档记忆。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "doc-store" }
|
||||
func (Module) Requires() []string { return []string{kernelmod.FacConfig} }
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacDocStore} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
docStore := document.NewStore(
|
||||
filepath.Join(ks.Cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
log.Printf("[homed] warning: document store: %v", err)
|
||||
}
|
||||
// 关停时落盘:文档记忆的内存态变更(迁移结果、访问计数等)只在 flush 里写盘,
|
||||
// 而 flush 的唯一入口是 Stop() —— 此前全仓无人调用它,
|
||||
// 于是迁移结果永不落盘、每次启动白算一遍。
|
||||
ks.DocStore = docStore
|
||||
ks.AddCloser(docStore.Stop)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
46
internal/kernelmod/facilities.go
Normal file
46
internal/kernelmod/facilities.go
Normal file
@ -0,0 +1,46 @@
|
||||
package kernelmod
|
||||
|
||||
// 设施名常量(依赖图上被引用的名字)。
|
||||
//
|
||||
// ★ 为何用常量而不是裸字符串:拼错依赖名时**编译不过**,
|
||||
// 而不是"永远等不到就绪"(运行期表现是静默挂起 / 静默拿到 nil,极难排查)。
|
||||
//
|
||||
// ★ 为何放在 core.go 之外:用户要求"根目录只有 core.go",
|
||||
// 而 core.go 专注注册表职责。设施名是**模块间的协议**,与注册表分开更清晰。
|
||||
const (
|
||||
// ── 插件可依赖的设施(与公开 SDK 的 sdk.Facility* **逐字一致**)──
|
||||
//
|
||||
// ★ 判据 TestFacilityNamesMatchPublicSDK 守这个一致性。
|
||||
// 这些名字会出现在 plugin.json 的 requires_facilities 里。
|
||||
FacConfig = "config"
|
||||
FacKernelIO = "kernel-io"
|
||||
FacMemory = "memory"
|
||||
FacLua = "lua"
|
||||
FacSupervisor = "supervisor"
|
||||
FacTracker = "tracker"
|
||||
FacTextMem = "textmem"
|
||||
FacLLM = "llm"
|
||||
FacDocStore = "docmem"
|
||||
FacKnowledge = "knowledge"
|
||||
FacMultimodal = "multimodal"
|
||||
FacONNX = "onnx"
|
||||
FacStageHost = "stagehost"
|
||||
FacAgent = "agent"
|
||||
|
||||
// ── 内核**内部**顺序标记(**不是**给插件的设施)──
|
||||
//
|
||||
// ★ 为什么不与上面的混在一起:这两类东西的**消费者不同**。
|
||||
// · 上面的:插件按它声明启动时机(对外契约,必须进公开 SDK)
|
||||
// · 这里的:只用于**内核模块之间**表达顺序(如"迁移必须先于扫盘")
|
||||
//
|
||||
// 混在一起会让"内核内部标记"被误当成"插件可选设施"
|
||||
// ⇒ 插件可能声明它(然后永远等不到 —— 因为它的就绪时机是装配中途,
|
||||
// 而插件提交发生在装配**完成之后**……实际会立刻满足,但语义是错的:
|
||||
// 它不该成为插件的契约)。
|
||||
//
|
||||
// 判据 TestFacilityNamesMatchPublicSDK 只比对上面那组 ⇒ 天然拦住
|
||||
// "把内部标记泄漏成对外契约"。
|
||||
|
||||
// FacMigrated 标记"知识库目录迁移已完成"(用于让 store 排在迁移之后)。
|
||||
FacMigrated = "knowledge-migrated"
|
||||
)
|
||||
35
internal/kernelmod/kernelapi/mod.go
Normal file
35
internal/kernelmod/kernelapi/mod.go
Normal file
@ -0,0 +1,35 @@
|
||||
// Package kernelapi 是 kernel-api 模块:IOManager + EventBus。
|
||||
//
|
||||
// ## 依赖:**真无依赖**
|
||||
//
|
||||
// `initKernelAPI()` 不收任何参数 ⇒ 它与 config 并列第 0 层。
|
||||
// 这条"真无依赖"是有意的:让"最先初始化的 core 不依赖任何模块"
|
||||
// 在代码里**字面成立**。
|
||||
package kernelapi
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
)
|
||||
|
||||
// Module 提供 IO 抽象层与事件总线。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "kernel-api" }
|
||||
func (Module) Requires() []string { return nil }
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacKernelIO} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
iom := agentIO.NewIOManager()
|
||||
evBus := events.NewBus()
|
||||
log.Printf("[homed] kernel API ready: IOManager + EventBus")
|
||||
ks.IO = iom
|
||||
ks.EventBus = evBus
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
53
internal/kernelmod/knowledge/migrate_impl.go
Normal file
53
internal/kernelmod/knowledge/migrate_impl.go
Normal file
@ -0,0 +1,53 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
)
|
||||
|
||||
// runKnowledgeMigration 是本迁移的真实实现(原 cmd/homed 的整块平移:
|
||||
// 语句、日志文本、错误语义不变,只把 cfg 参数换成 root)。
|
||||
func runKnowledgeMigration(root string) {
|
||||
const (
|
||||
// defaultApply = false ⇒ 启动时只扫描并报告,不改名。
|
||||
defaultApply = false
|
||||
// maxRenamePerRun 限制单次重命名数:给失控的目录规模设上限,
|
||||
// 避免启动阶段被一次大迁移拖住。
|
||||
maxRenamePerRun = 200
|
||||
)
|
||||
items, err := knowledge.PlanMigration(root)
|
||||
if err != nil {
|
||||
log.Printf("[homed] 知识库迁移扫描失败(跳过): %v", err)
|
||||
return
|
||||
}
|
||||
need, illegal := 0, 0
|
||||
for _, it := range items {
|
||||
if it.Illegal {
|
||||
illegal++
|
||||
} else if it.NewName != "" {
|
||||
need++
|
||||
}
|
||||
}
|
||||
if need == 0 && illegal == 0 {
|
||||
return
|
||||
}
|
||||
if illegal > 0 {
|
||||
log.Printf("[homed] 知识库迁移:%d 条名称非法(含 .. / 点段 / 隐藏段),写入与删除均已拒绝,需人工处理", illegal)
|
||||
}
|
||||
if need == 0 {
|
||||
return
|
||||
}
|
||||
log.Printf("[homed] 知识库迁移:%d/%d 条目录名待规范化(例:%s → %s)", need, len(items),
|
||||
items[0].OldName, items[0].NewName)
|
||||
if !defaultApply {
|
||||
log.Printf("[homed] 知识库迁移:当前为只报告模式。确认清单后执行:homed-kb-migrate -root %s -apply", root)
|
||||
return
|
||||
}
|
||||
if need > maxRenamePerRun {
|
||||
log.Printf("[homed] 知识库迁移:需改名 %d 条超过单次上限 %d,本次只处理前 %d 条",
|
||||
need, maxRenamePerRun, maxRenamePerRun)
|
||||
}
|
||||
applied, failed := knowledge.ApplyMigration(root, items, maxRenamePerRun)
|
||||
log.Printf("[homed] 知识库迁移完成:成功 %d,失败 %d", applied, failed)
|
||||
}
|
||||
36
internal/kernelmod/knowledge/migration.go
Normal file
36
internal/kernelmod/knowledge/migration.go
Normal file
@ -0,0 +1,36 @@
|
||||
// 知识库目录改名迁移(幂等)。**独立模块**,见下。
|
||||
//
|
||||
// ## ★ 为什么迁移必须是独立模块而不是 store 里的一行
|
||||
//
|
||||
// 原 main 注释:"迁移必须在 store 扫盘**之前**:改名后扫盘一次到位,
|
||||
// 避免先以旧名建索引、再改名造成内存键与盘上目录短暂不一致"。
|
||||
//
|
||||
// 这是**真实的顺序约束**(不是副作用顺序,是"必须先改名再扫盘")⇒
|
||||
// 在依赖图上是知识库 store 的前置。声明成依赖 ⇒ 分层器保证它们**不同层**
|
||||
// (同层就是并行了,那会破坏约束)。
|
||||
//
|
||||
// ★ 若把迁移并进 store 的 Init 里,这个约束就"藏在函数体内"了 ——
|
||||
// 那正是"加模块必须调顺序"的病根形态。
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
)
|
||||
|
||||
// MigrationModule 做知识库目录改名迁移。
|
||||
type MigrationModule struct{}
|
||||
|
||||
func (MigrationModule) Name() string { return "knowledge-migration" }
|
||||
func (MigrationModule) Requires() []string { return []string{kernelmod.FacConfig} }
|
||||
func (MigrationModule) Provides() []string { return []string{kernelmod.FacMigrated} }
|
||||
|
||||
func (MigrationModule) Init(ks *kernelboot.KernelState) error {
|
||||
root := filepath.Join(ks.Cfg.Daemon.DataDir, "knowledge")
|
||||
runKnowledgeMigration(root)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(MigrationModule{}) }
|
||||
33
internal/kernelmod/knowledge/store.go
Normal file
33
internal/kernelmod/knowledge/store.go
Normal file
@ -0,0 +1,33 @@
|
||||
// 知识库 store 模块。
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
)
|
||||
|
||||
// StoreModule 提供知识库。
|
||||
type StoreModule struct{}
|
||||
|
||||
func (StoreModule) Name() string { return "knowledge-store" }
|
||||
func (StoreModule) Requires() []string {
|
||||
// ★ FacMigrated 是**刻意的顺序依赖**:迁移必须早于扫盘(见 migration.go)。
|
||||
return []string{kernelmod.FacConfig, kernelmod.FacMigrated}
|
||||
}
|
||||
func (StoreModule) Provides() []string { return []string{kernelmod.FacKnowledge} }
|
||||
|
||||
func (StoreModule) Init(ks *kernelboot.KernelState) error {
|
||||
ks.Knowledge = knowledge.NewStore(filepath.Join(ks.Cfg.Daemon.DataDir, "knowledge"))
|
||||
if err := ks.Knowledge.Start(); err != nil {
|
||||
log.Printf("[homed] warning: knowledge store: %v", err)
|
||||
} else {
|
||||
log.Printf("[homed] knowledge store active with %d items", len(ks.Knowledge.List()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(StoreModule{}) }
|
||||
89
internal/kernelmod/llm/mod.go
Normal file
89
internal/kernelmod/llm/mod.go
Normal file
@ -0,0 +1,89 @@
|
||||
// Package llm 是 llm 模块:多源 Provider 管理(经 Lua 适配器做协议转换)。
|
||||
//
|
||||
// 依赖 config + lua:`initLLMProviders(cfg, luaVM, baseAPIKey)`。
|
||||
package llm
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
luapkg "github.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"github.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
// Module 提供多源 provider 管理。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "llm" }
|
||||
func (Module) Requires() []string { return []string{kernelmod.FacConfig, kernelmod.FacLua} }
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacLLM} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
// ★ 敏感信息防火模块的装配点。
|
||||
//
|
||||
// 原 main 注释:"必须在任何 provider 注册之前装配"—— provider 的出向
|
||||
// 装饰器在 ProviderManager.Register 里读 security.Global()。装配晚了,
|
||||
// 先注册的那批 provider 就没被装饰(静默漏掉一条出网路径)。
|
||||
//
|
||||
// 实测:该装配**就在 initLLMProviders 内部**(不是 main 的独立语句)
|
||||
// ⇒ 声明式下它天然"紧邻",不再依赖 main 里那两行的相对位置。
|
||||
ks.BaseAPIKey = resolveBaseAPIKey(ks.Cfg)
|
||||
ks.LLMManager = buildProviderManager(ks.Cfg, ks.LuaVM, ks.BaseAPIKey)
|
||||
ks.LLMProvider = ks.LLMManager.Default()
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildProviderManager 按配置注册全部 LLM 源(原 cmd/homed 的整块平移)。
|
||||
func buildProviderManager(cfg *types.Config, luaVM *luapkg.VM, baseAPIKey string) *api.ProviderManager {
|
||||
providerMgr := api.NewProviderManager()
|
||||
adapterConcurrency := map[string]int{}
|
||||
for _, src := range cfg.LLM.Sources {
|
||||
if !api.IsValidSourceConfig(src.Name, src.BaseURL, src.Model, src.Adapter) {
|
||||
log.Printf("[homed] skip invalid llm source %q (base_url=%q model=%q adapter=%q)",
|
||||
src.Name, src.BaseURL, src.Model, src.Adapter)
|
||||
continue
|
||||
}
|
||||
key := src.APIKey
|
||||
if key == "" {
|
||||
key = baseAPIKey
|
||||
}
|
||||
luaProvider := api.NewLuaAdaptedProvider(api.BaseConfig{
|
||||
Model: src.Model,
|
||||
BaseURL: src.BaseURL,
|
||||
APIKey: key,
|
||||
Temperature: cfg.LLM.Temperature,
|
||||
MaxTokens: cfg.LLM.MaxTokens,
|
||||
ContextWindow: src.ContextWindow,
|
||||
MaxConcurrent: src.MaxConcurrent,
|
||||
Priority: src.Priority,
|
||||
Vision: src.Vision,
|
||||
Audio: src.Audio,
|
||||
}, luaVM, src.Name, src.Adapter)
|
||||
providerMgr.Register(src.Name, luaProvider)
|
||||
if src.Adapter != "" {
|
||||
adapterConcurrency[src.Adapter] += src.MaxConcurrent
|
||||
}
|
||||
}
|
||||
luaVM.ConfigureConcurrency(adapterConcurrency)
|
||||
if cfg.LLM.Provider != "" {
|
||||
providerMgr.SetDefault(cfg.LLM.Provider)
|
||||
}
|
||||
return providerMgr
|
||||
}
|
||||
|
||||
// resolveBaseAPIKey 决定基础 key(配置 > LLM_API_KEY > DEEPSEEK_API_KEY)。
|
||||
func resolveBaseAPIKey(cfg *types.Config) string {
|
||||
apiKey := cfg.LLM.APIKey
|
||||
if apiKey == "" {
|
||||
apiKey = os.Getenv("LLM_API_KEY")
|
||||
}
|
||||
if apiKey == "" {
|
||||
apiKey = os.Getenv("DEEPSEEK_API_KEY")
|
||||
}
|
||||
return apiKey
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
34
internal/kernelmod/lua/mod.go
Normal file
34
internal/kernelmod/lua/mod.go
Normal file
@ -0,0 +1,34 @@
|
||||
// Package lua 是 lua 模块:Lua VM(LLM 协议适配)。
|
||||
//
|
||||
// 依赖 config:`initLuaVM(cfg)` 读 cfg.Daemon.DataDir(adapter 目录)。
|
||||
package lua
|
||||
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
luapkg "github.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
)
|
||||
|
||||
// Module 提供 Lua 协议适配 VM。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "lua" }
|
||||
func (Module) Requires() []string { return []string{kernelmod.FacConfig} }
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacLua} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
luaVM := luapkg.NewVM(filepath.Join(ks.Cfg.Daemon.DataDir, "adapters"))
|
||||
if err := luaVM.Start(); err != nil {
|
||||
log.Printf("[homed] warning: lua vm init failed: %v", err)
|
||||
ks.LuaVM = luaVM
|
||||
return nil // 与原语义一致:起不来只告警,仍交回实例
|
||||
}
|
||||
ks.LuaVM = luaVM
|
||||
ks.AddCloser(luaVM.Stop)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
85
internal/kernelmod/memory/distiller_wiring_test.go
Normal file
85
internal/kernelmod/memory/distiller_wiring_test.go
Normal file
@ -0,0 +1,85 @@
|
||||
package memory
|
||||
|
||||
// 蒸馏器接线回归判据(从 cmd/homed/bootstrap_test.go 迁移而来)。
|
||||
//
|
||||
// ## 为什么这条判据必须存在(历史 bug)
|
||||
//
|
||||
// `main()` 拆分时,initMemoryStack 函数体内残留一句 `defer distiller.Stop()`,
|
||||
// 函数一 return 就 cancel 掉刚启动的循环 ⇒ **规则蒸馏 10min 心跳从不运行**。
|
||||
//
|
||||
// ★ 该缺陷**不会让任何单测变红** —— pipeline 的 `TestDistillOnce*` 直接调
|
||||
// `distillOnce`,**绕过了 Start/Stop 接线**。只有按「启动阶段函数」的真实
|
||||
// 调用方式断言(调用 → 检查仍在跑 → 调 cleanup → 检查已停),才照得出来。
|
||||
//
|
||||
// ## 迁移说明
|
||||
//
|
||||
// 本判据原先针对 `cmd/homed` 的 `initMemoryStack`。模块化后该逻辑搬进
|
||||
// `internal/kernelmod/memory.Module.Init`,所以判据跟着搬 —— 且**必须保留**:
|
||||
// 迁移过程中"忘了 AddCloser"或"改成 defer"是同一类缺陷的新形态。
|
||||
//
|
||||
// 判据改为直接跑真实模块(经 kernelboot.Run),确保测的是**真实入口**。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
internalConfig "github.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
)
|
||||
|
||||
// ★★★ 判据:模块 Init 返回后蒸馏器**仍在运行**(无 defer Stop 残留)。
|
||||
func TestMemoryModuleKeepsDistillerRunning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "memory"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ★ 需要真 cfgReg:memory 模块读 core.agent.embedding_model_path,
|
||||
// 而它对 nil 会现在**明确报错**(而不是 panic 在 strings.Split)。
|
||||
reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
|
||||
defer reg.Close()
|
||||
ks := &kernelboot.KernelState{DataDir: dir, CfgReg: reg}
|
||||
|
||||
if err := (Module{}).Init(ks); err != nil {
|
||||
t.Fatalf("memory 模块 Init: %v", err)
|
||||
}
|
||||
if ks.Memory == nil || ks.Memory.Distiller == nil {
|
||||
t.Fatal("memory 模块未产出蒸馏器")
|
||||
}
|
||||
if ks.Memory.DB == nil {
|
||||
t.Skip("图库未初始化,无法验证蒸馏接线")
|
||||
}
|
||||
// ★ 关键断言:Init 已返回,蒸馏循环必须还在跑
|
||||
if ks.Memory.Distiller.Stopped() {
|
||||
t.Fatal("★ memory 模块 Init 返回后蒸馏循环已被停掉" +
|
||||
"(defer Stop 残留?)—— 规则蒸馏 10min 心跳将从不运行")
|
||||
}
|
||||
|
||||
// 关闭函数是唯一的停机点(经 AddCloser 登记,由 KernelState.CloseAll 逆序执行)
|
||||
if err := ks.CloseAll(); err != nil {
|
||||
t.Fatalf("CloseAll: %v", err)
|
||||
}
|
||||
if !ks.Memory.Distiller.Stopped() {
|
||||
t.Fatal("CloseAll 之后蒸馏器应已停止(关闭函数未登记?)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**关闭函数经 AddCloser 登记**(不是 defer)。
|
||||
//
|
||||
// 这是上一条的机制保证:若有人把 AddCloser 改回 defer,
|
||||
// 上一条判据报红;本判据报"没登记",两条互相印证、定位更快。
|
||||
func TestMemoryModuleRegistersCloser(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(dir, "memory"), 0o755)
|
||||
reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
|
||||
defer reg.Close()
|
||||
ks := &kernelboot.KernelState{DataDir: dir, CfgReg: reg}
|
||||
if err := (Module{}).Init(ks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 内存模块应恰好登记 1 个关闭函数(停蒸馏器 + 关图库,合在一个闭包里)
|
||||
if len(ks.Closers) != 1 {
|
||||
t.Fatalf("★ 应登记 1 个关闭函数,实际 %d —— "+
|
||||
"若为 0 说明改成了 defer(Init 返回即停机)", len(ks.Closers))
|
||||
}
|
||||
}
|
||||
149
internal/kernelmod/memory/mod.go
Normal file
149
internal/kernelmod/memory/mod.go
Normal file
@ -0,0 +1,149 @@
|
||||
// Package memory 是 memory 模块:图记忆 + 索引 + 社交图 + 蒸馏管线 + 共享词嵌入。
|
||||
//
|
||||
// ## 为什么依赖 kernel-io
|
||||
//
|
||||
// 本模块产出 MemoryBundle(4 个同生共死的东西打包)。它**不**直接读 IO,
|
||||
// 但"记忆子系统就绪"在语义上包含它后续要被候选消费管线使用,而那条管线需要 iom。
|
||||
//
|
||||
// ★ 这是**刻意声明**的依赖(不是签名要求的):让"记忆子系统在 IO 就绪后才建"
|
||||
// 成为结构保证 —— 若将来 initMemoryStack 内部要用 iom,不会因为"忘了声明"
|
||||
// 而静默拿 nil。
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/distill"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"github.com/JianFeeeee/HomeAgent/pkg/generation"
|
||||
)
|
||||
|
||||
// Module 提供图记忆子系统。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "memory" }
|
||||
func (Module) Requires() []string { return []string{kernelmod.FacKernelIO} }
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacMemory} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
// ★ 读取前检查(用户指定的做法:"结构体成员初始置 nil 加读取前检查")。
|
||||
//
|
||||
// 为何要检查 CfgReg:它是 **Group 0** 的产物(不由任何调度模块提供),
|
||||
// 所以不可能通过 Requires 声明它 —— 依赖图保证不了它非 nil。
|
||||
// 而上面那行 `ks.CfgReg.GetString(...)` 在 nil 时会**panic**,
|
||||
// 栈信息指向 strings.Split 之类无关处,排查成本高。
|
||||
if ks.CfgReg == nil {
|
||||
return fmt.Errorf("memory 模块:ks.CfgReg 为 nil —— " +
|
||||
"Group 0 必须先建好配置中心(NewConfigRegistry)再启动本调度器")
|
||||
}
|
||||
if ks.DataDir == "" {
|
||||
return fmt.Errorf("memory 模块:ks.DataDir 为空 —— 它是 Group 0 的产物,必须先设置")
|
||||
}
|
||||
memDB, err := memory.NewGraphDB(filepath.Join(ks.DataDir, "memory", "graph.db"))
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: memory init failed: %v", err)
|
||||
memDB = nil
|
||||
} else {
|
||||
log.Printf("[homed] graph memory initialized")
|
||||
}
|
||||
memIdx := memory.NewIndexer(memDB)
|
||||
memIdx.Sync() // 启动时立即同步,避免前 30 分钟空窗
|
||||
socialStore := social.New(memDB)
|
||||
|
||||
distiller := pipeline.NewDistiller(memDB, ks.DataDir, pipeline.DistillerConfig{
|
||||
Interval: 10 * time.Minute,
|
||||
RetentionDays: 7,
|
||||
BatchSize: 50,
|
||||
})
|
||||
// ★ 必须在 distiller.Start() **之前**注入:Start 会起 distillLoop,
|
||||
// 而第一次 tick 在 Interval 之后。若先 Start 再注入,头一个周期
|
||||
// (实测配置 10 分钟)会白跑一次 jieba 路 —— 而那条路实测产出 0 条。
|
||||
if memDB != nil {
|
||||
distiller.SetSplitter(initDistillSplitter(ks.CfgReg))
|
||||
}
|
||||
if memDB != nil {
|
||||
// 这里**故意不写 defer distiller.Stop()**:函数 return 即触发 defer,
|
||||
// 而 Stop() → cancel() 会让刚启动的 distillLoop 立刻退出,
|
||||
// 规则蒸馏管线启动即死、10min 心跳从不运行(旧 main() 拆分时的残留)。
|
||||
// 停机由 AddCloser 负责(见下方)。
|
||||
distiller.Start()
|
||||
}
|
||||
|
||||
ks.Memory = &kernelboot.MemoryBundle{
|
||||
DB: memDB,
|
||||
Indexer: memIdx,
|
||||
Social: socialStore,
|
||||
Distiller: distiller,
|
||||
}
|
||||
// 共享词嵌入:蒸馏与 Agent 上下文复用同一实例
|
||||
// (避免同一模型被二次加载,约 200k×300 维 ≈ 数百 MB)。
|
||||
ks.Embedder = memory.NewStaticEmbedder(
|
||||
strings.Split(ks.CfgReg.GetString("core.agent.embedding_model_path", ""), ",")...)
|
||||
distiller.SetEmbedder(ks.Embedder)
|
||||
|
||||
// ★ 伪关机登记(迁移数据目录用,见 kernelboot/lifecycle.go)。
|
||||
// 只登记**持有写句柄**的 distiller —— 它每 10min 写 memory/graph.db,
|
||||
// 迁移期间一旦写入就会搬走"写了一半的库"。
|
||||
if ks.Memory != nil && ks.Memory.Distiller != nil {
|
||||
ks.AddQuiescer("distiller",
|
||||
func() error { return ks.Memory.Distiller.Pause() },
|
||||
func() error { return ks.Memory.Distiller.Resume() })
|
||||
}
|
||||
|
||||
ks.AddCloser(func() {
|
||||
// 与原 main 的两个 defer 同序(LIFO):先停蒸馏器,再关图库。
|
||||
if memDB != nil {
|
||||
distiller.Stop()
|
||||
memDB.Close()
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// initDistillSplitter 按配置打开蒸馏用的生成侧 provider 并组装拆分器。
|
||||
//
|
||||
// 返回 nil 表示不启用(未配置 provider、或打开失败)—— 此时蒸馏回退既有的
|
||||
// jieba/ONNX 路。**不因为模型不可用而让蒸馏停摆**:那条路虽然实测产出 0 条,
|
||||
// 但它至少不会让对话记忆丢失。
|
||||
func initDistillSplitter(cfgReg *config.ConfigRegistry) pipeline.RecordSplitter {
|
||||
if cfgReg == nil {
|
||||
return nil
|
||||
}
|
||||
providerName := cfgReg.GetString("core.memory.distill.generation.provider", "")
|
||||
if providerName == "" {
|
||||
return nil
|
||||
}
|
||||
opts := map[string]string{}
|
||||
const optPrefix = "core.memory.distill.generation.options."
|
||||
for _, key := range cfgReg.List(optPrefix) {
|
||||
opts[strings.TrimPrefix(key, optPrefix)] = cfgReg.GetString(key, "")
|
||||
}
|
||||
provider, err := generation.Open(providerName, generation.Config{Options: opts})
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: 蒸馏生成 provider %q 打开失败: %v(蒸馏回退 jieba 路;已注册: %s)",
|
||||
providerName, err, strings.Join(generation.Names(), ", "))
|
||||
return nil
|
||||
}
|
||||
if !provider.Info().SupportsJSONSchema {
|
||||
// 拆分器的零幻觉完全依赖 schema 约束。不支持 schema 的 provider
|
||||
// 会拿到自由文本,解析必炸 —— 与其让它运行时反复失败,不如现在就拒。
|
||||
log.Printf("[homed] warning: 蒸馏生成 provider %q 不支持 JSON Schema,拆分不可靠(蒸馏回退 jieba 路)",
|
||||
providerName)
|
||||
provider.Close()
|
||||
return nil
|
||||
}
|
||||
log.Printf("[homed] distill generation active: provider=%s model=%s",
|
||||
providerName, provider.Info().Model)
|
||||
return distill.NewExtractor(provider, nil)
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
71
internal/kernelmod/memory/nilcheck_test.go
Normal file
71
internal/kernelmod/memory/nilcheck_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package memory
|
||||
|
||||
// "nil 加读取前检查" 的判据(用户指定的做法)。
|
||||
//
|
||||
// ## 为什么需要判据守它
|
||||
//
|
||||
// `KernelState` 的字段是**类型化的**(用户设计),好处是拼错/取错类型是
|
||||
// 编译错误。但**跨模块的"未就绪"仍表现为 nil** —— 而 nil 解引用会
|
||||
// **panic**,栈信息指向无关处(如 `strings.Split`),排查成本高。
|
||||
//
|
||||
// 用户明确要求:"结构体成员初始置 nil 加**读取前检查**"。
|
||||
// 本判据确保每个模块对**不由依赖图保证**的字段(Group 0 产物)做检查。
|
||||
//
|
||||
// ★ 为什么要判据而不是靠自觉:`ks.CfgReg` 是 Group 0 的产物,
|
||||
// **无法通过 Requires 声明**(Group 0 不是调度模块)⇒ 依赖图保证不了它。
|
||||
// 这正是最容易忘、忘了就 panic 的地方。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
internalConfig "github.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
)
|
||||
|
||||
// ★★★ 判据:**CfgReg 为 nil 时给可操作错误,而不是 panic**。
|
||||
func TestMemoryModuleRejectsNilCfgReg(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ks := &kernelboot.KernelState{DataDir: dir} // CfgReg 故意为 nil
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("★ CfgReg 为 nil 时 panic 了(应返回可操作错误): %v\n"+
|
||||
" 用户要求的是「nil 加读取前检查」,panic 的栈信息指向无关处", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err := (Module{}).Init(ks)
|
||||
if err == nil {
|
||||
t.Fatal("★ CfgReg 为 nil 却成功返回 —— 后面会在读配置时 panic")
|
||||
}
|
||||
// 错误必须点出**是哪个字段**以及**该做什么**
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "CfgReg") {
|
||||
t.Errorf("错误应点名字段 CfgReg(便于定位): %v", err)
|
||||
}
|
||||
if !strings.Contains(msg, "Group 0") {
|
||||
t.Errorf("错误应指出根因(Group 0 必须先跑): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**DataDir 为空时也拒绝**(同类检查)。
|
||||
func TestMemoryModuleRejectsEmptyDataDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := newTestReg(t, dir)
|
||||
ks := &kernelboot.KernelState{CfgReg: reg} // DataDir 空
|
||||
|
||||
err := (Module{}).Init(ks)
|
||||
if err == nil {
|
||||
t.Fatal("★ DataDir 为空却成功返回")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "DataDir") {
|
||||
t.Errorf("错误应点名字段 DataDir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestReg(t *testing.T, dir string) *internalConfig.ConfigRegistry {
|
||||
t.Helper()
|
||||
return internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
|
||||
}
|
||||
284
internal/kernelmod/migration/mod.go
Normal file
284
internal/kernelmod/migration/mod.go
Normal file
@ -0,0 +1,284 @@
|
||||
// Package migration 提供**数据目录迁移**的内核能力(伪关机 → 迁移 → 唤醒)。
|
||||
//
|
||||
// ## 它提供什么
|
||||
//
|
||||
// 两个工具(暴露给模型/WebUI):
|
||||
//
|
||||
// migrate_data_preflight → 只读体检:报告能不能迁、要迁多少、有哪些风险
|
||||
// migrate_data_plan → 生成可执行迁移脚本(伪关机窗口由脚本自己 stop 覆盖)
|
||||
//
|
||||
// ## ★ 为什么不提供"一键迁移"工具
|
||||
//
|
||||
// 迁移是**破坏性且不可逆**的(21G 数据 + 12 处 DB 改写 + 46 个文件改写),
|
||||
// 而且内核跑在 systemd 沙箱里(ProtectSystem/ProtectHome)**本来就写不了
|
||||
// /etc/systemd**,更没法把 /home/newqqagent 搬到别的挂载点(跨目录 rename 会失败,
|
||||
// 必须 rsync 全量)。
|
||||
//
|
||||
// ⇒ 内核的职责是:**体检 + 生成正确的脚本 + 提供伪关机/唤醒能力**;
|
||||
//
|
||||
// 真正的搬数据由运维执行脚本。这样每一步都可审查、可回滚。
|
||||
//
|
||||
// ## 依赖声明
|
||||
//
|
||||
// Requires 里**故意不含** plugin-host:本模块只读 fs + 读 config,
|
||||
// 伪关机能力(PluginQuiescer)由调用方在需要时注入,不硬依赖。
|
||||
package migration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"syscall"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/systemd"
|
||||
)
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
|
||||
// Module 是数据目录迁移模块。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "migration" }
|
||||
|
||||
// Requires 只需要配置(读 data_dir)与 kernel-io(日志)。
|
||||
func (Module) Requires() []string { return []string{"config", "kernel-io"} }
|
||||
|
||||
func (Module) Provides() []string { return []string{"migration"} }
|
||||
|
||||
// MigrateReport 是体检结果。
|
||||
type MigrateReport struct {
|
||||
DataDir string `json:"data_dir"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
Dirs []DirInfo `json:"dirs"`
|
||||
Mounts []string `json:"mounts"` // 必须先卸载的挂载点
|
||||
Risks []string `json:"risks"` // 会导致数据丢失/错误的点
|
||||
DBPathKeys []string `json:"db_path_keys"` // 数据库里存绝对路径的键
|
||||
SameDevice bool `json:"same_device"` // 目标与源是否同一设备(决定能否 rename)
|
||||
CanRename bool `json:"can_rename"` // 同设备可直接 rename,否则必须 rsync
|
||||
ScriptPath string `json:"script_path,omitempty"`
|
||||
}
|
||||
|
||||
// DirInfo 是单目录的信息。
|
||||
type DirInfo struct {
|
||||
Name string `json:"name"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Kind string `json:"kind"` // data | runtime | optional | mount
|
||||
}
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
// 体检结果放在 KernelState 上,供工具/WebUI 读(可选能力,nil 安全)
|
||||
ks.MigrationPreflight = func(target string) (interface{}, error) {
|
||||
return Preflight(ks.DataDir, target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Preflight 体检:数据目录现在是什么状态、迁到 target 需要注意什么。
|
||||
func Preflight(dataDir, target string) (*MigrateReport, error) {
|
||||
rep := &MigrateReport{DataDir: dataDir}
|
||||
|
||||
// 目录分类(来自 2026-10-17 生产盘点,21G)
|
||||
// data=必须迁 runtime=不迁(重启重建) optional=可丢 mount=挂载点需先卸
|
||||
classify := map[string]string{
|
||||
"agentfs": "mount",
|
||||
"plugindev": "data",
|
||||
"blocklauncher": "data",
|
||||
"browser_profiles": "data",
|
||||
"models": "data",
|
||||
"plugins": "data",
|
||||
"mcbridge": "data",
|
||||
"memory": "data",
|
||||
"plugin_data": "data",
|
||||
"knowledge": "data",
|
||||
"skills": "data",
|
||||
"mcp": "data",
|
||||
"nlp": "data",
|
||||
"adapters": "data",
|
||||
"data": "data",
|
||||
"uploads": "data",
|
||||
"device_media": "data",
|
||||
"webui_files": "data",
|
||||
"changesets": "data",
|
||||
"heartbeat": "runtime",
|
||||
"log": "optional",
|
||||
"backups": "optional",
|
||||
"memory.bak": "optional",
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dataDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读数据目录: %w", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
info := DirInfo{Name: name, Kind: classify[name]}
|
||||
if info.Kind == "" {
|
||||
info.Kind = "data" // 未知目录默认**必须迁**(宁可多迁,不可漏迁)
|
||||
}
|
||||
info.Bytes = dirSize(filepath.Join(dataDir, name))
|
||||
rep.Dirs = append(rep.Dirs, info)
|
||||
rep.TotalBytes += info.Bytes
|
||||
}
|
||||
sort.Slice(rep.Dirs, func(i, j int) bool { return rep.Dirs[i].Bytes > rep.Dirs[j].Bytes })
|
||||
|
||||
// ① 挂载点:agentfs 是 overlay(lower/upper/work),不卸就搬空壳。
|
||||
//
|
||||
// ★ 风险说明**无条件**报出,只有"要不要 umount"取决于实际挂载状态。
|
||||
//
|
||||
// 为什么分开:如果只在挂载时才提风险,那"现在没挂、迁移脚本跑起来时
|
||||
// 服务又把它挂上了"这种时序就完全没提示 —— 而 agentfs 恰恰是内核
|
||||
// 启动时才挂的。运维在**停机状态**做体检会看不到这条警示,
|
||||
// 但迁移脚本里的 stop 又会把它挂回来(见脚本的 umount 步骤)。
|
||||
// 判据 TestPreflightReportsKnownRisks 就是为了钉住这条。
|
||||
if _, err := os.Stat(filepath.Join(dataDir, "agentfs")); err == nil {
|
||||
merged := filepath.Join(dataDir, "agentfs", "merged")
|
||||
if isMountpoint(merged) {
|
||||
rep.Mounts = append(rep.Mounts, merged)
|
||||
}
|
||||
rep.Risks = append(rep.Risks,
|
||||
"agentfs/merged 是 overlay 挂载点(lower/upper/work 三段):不先 umount 会搬走'空壳',"+
|
||||
"真正内容仍挂在旧路径 ⇒ 新目录 agentfs 不可用。"+
|
||||
"注意:内核启动时才挂它,所以**停机体检看不到挂载**也仍要卸")
|
||||
}
|
||||
|
||||
// ② 数据库绝对路径:不改会"看起来记忆全丢"(内核按旧路径找不到就重建空库,且不报错)
|
||||
rep.DBPathKeys = []string{
|
||||
"core.daemon.data_dir", "core.plugin.dir", "core.memory.graph",
|
||||
"core.memory.text", "core.memory.documents", "core.knowledge.path",
|
||||
"core.log.path", "core.memory.multimodal_space.options.model_dir",
|
||||
}
|
||||
rep.Risks = append(rep.Risks,
|
||||
fmt.Sprintf("数据库有 %d 个键存绝对路径;不改写会导致重启后按旧路径找数据,"+
|
||||
"找不到就**静默重建空库**(看起来像记忆全丢)", len(rep.DBPathKeys)),
|
||||
"plugin_data 下实测有 46 个文件写死了绝对路径(典型:commondev 的 git 镜像/workdir)",
|
||||
"security.key 不迁 ⇒ 所有加密配置解不开(敏感信息全部失效)",
|
||||
)
|
||||
|
||||
// ③ 是否同设备:决定 rename(瞬时)还是 rsync(几分钟)
|
||||
if target != "" {
|
||||
if src, err := os.Stat(dataDir); err == nil {
|
||||
if dst, err := os.Stat(filepath.Dir(target)); err == nil {
|
||||
a := src.Sys().(*syscall.Stat_t).Dev
|
||||
b := dst.Sys().(*syscall.Stat_t).Dev
|
||||
rep.SameDevice = a == b
|
||||
rep.CanRename = rep.SameDevice
|
||||
if !rep.SameDevice {
|
||||
rep.Risks = append(rep.Risks,
|
||||
"目标不在同一设备 ⇒ 不能用 rename,必须 rsync 全量拷贝(耗时随数据量增长)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 当前 systemd 权威 vs DB 权威是否一致(两个权威不等价)
|
||||
// 运行时权威 = -data(本模块的 dataDir);配置权威 = DB core.daemon.data_dir
|
||||
if st, err := systemd.Check(dataDir, dataDir); err == nil {
|
||||
if st.RuntimeDataDir != "" && st.ConfigDataDir != "" && st.RuntimeDataDir != st.ConfigDataDir {
|
||||
rep.Risks = append(rep.Risks,
|
||||
fmt.Sprintf("★ 数据目录有**两个权威且不一致**:-data=%s(管 memory/graph.db + 插件 DataDir)"+
|
||||
" vs DB core.daemon.data_dir=%s(管 memory/text、knowledge、nlp 等 6 个模块)⇒ 数据会分裂",
|
||||
st.RuntimeDataDir, st.ConfigDataDir))
|
||||
}
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// dirSize 递归统计目录大小(符号链接不跟随,避免重复计算硬链接/环)。
|
||||
func dirSize(root string) int64 {
|
||||
var total int64
|
||||
filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info == nil {
|
||||
return nil
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
total += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return total
|
||||
}
|
||||
|
||||
// isMountpoint 判断路径是否为挂载点(读 /proc/mounts,不依赖外部命令)。
|
||||
func isMountpoint(p string) bool {
|
||||
data, err := os.ReadFile("/proc/mounts")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
clean := filepath.Clean(p)
|
||||
for _, line := range splitLines(string(data)) {
|
||||
f := splitFields(line)
|
||||
if len(f) >= 2 && unescapeMount(f[1]) == clean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var out []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
out = append(out, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
out = append(out, s[start:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitFields(s string) []string {
|
||||
var out []string
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
for i < len(s) && s[i] == ' ' {
|
||||
i++
|
||||
}
|
||||
j := i
|
||||
for j < len(s) && s[j] != ' ' {
|
||||
j++
|
||||
}
|
||||
if j > i {
|
||||
out = append(out, s[i:j])
|
||||
}
|
||||
i = j
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unescapeMount 还原 /proc/mounts 里的八进制转义(空格写作 \040)。
|
||||
func unescapeMount(s string) string {
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+3 < len(s) {
|
||||
v := 0
|
||||
ok := true
|
||||
for k := 1; k <= 3; k++ {
|
||||
c := s[i+k]
|
||||
if c < '0' || c > '7' {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
v = v*8 + int(c-'0')
|
||||
}
|
||||
if ok {
|
||||
out = append(out, byte(v))
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, s[i])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
171
internal/kernelmod/migration/mod_test.go
Normal file
171
internal/kernelmod/migration/mod_test.go
Normal file
@ -0,0 +1,171 @@
|
||||
package migration
|
||||
|
||||
// 数据目录迁移体检的判据。
|
||||
//
|
||||
// ## 为什么要这些判据
|
||||
//
|
||||
// 迁移是**破坏性**操作。体检是唯一能让运维在动手前看到"会丢什么"的东西,
|
||||
// 所以它漏报的后果是**数据丢失**(而不是一个报错)。
|
||||
//
|
||||
// 三个必须守住的点(都来自 2026-10-17 生产实测):
|
||||
// 1. overlay 挂载点必须被识别(不识别 ⇒ 搬走空壳)
|
||||
// 2. 未知目录默认**必须迁**(宁可多迁,不可漏迁)
|
||||
// 3. 同设备/跨设备必须判对(跨设备不能 rename,必须 rsync)
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★★ 判据:未知目录默认 data(必须迁),不能被当成"可丢"。
|
||||
func TestPreflightDefaultsUnknownDirsToData(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 造一个体检表里没有的目录
|
||||
mustMkdir(t, filepath.Join(dir, "some_new_thing"))
|
||||
os.WriteFile(filepath.Join(dir, "some_new_thing", "f"), make([]byte, 100), 0o644)
|
||||
|
||||
rep, err := Preflight(dir, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, d := range rep.Dirs {
|
||||
if d.Name == "some_new_thing" {
|
||||
found = true
|
||||
if d.Kind != "data" {
|
||||
t.Fatalf("★ 未知目录被归为 %q(应为 data)—— 漏迁 = 丢数据", d.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("体检未列出目录")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:heartbeat 归 runtime(不迁),log/backups/memory.bak 归 optional。
|
||||
func TestPreflightClassifiesRuntimeAndOptional(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, n := range []string{"heartbeat", "log", "backups", "memory.bak", "memory"} {
|
||||
mustMkdir(t, filepath.Join(dir, n))
|
||||
}
|
||||
rep, err := Preflight(dir, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{
|
||||
"heartbeat": "runtime", "log": "optional", "backups": "optional",
|
||||
"memory.bak": "optional", "memory": "data",
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, d := range rep.Dirs {
|
||||
got[d.Name] = d.Kind
|
||||
}
|
||||
for n, w := range want {
|
||||
if got[n] != w {
|
||||
t.Fatalf("★ %s 分类为 %q,应为 %q", n, got[n], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:overlay 挂载点必须被识别(不识别 = 搬走空壳)。
|
||||
//
|
||||
// 用真实的 /proc/mounts 判断:若本机恰好有 agentfs/merged 挂载则验证识别,
|
||||
// 否则验证"没挂载时不给假警告"。
|
||||
func TestPreflightDetectsOverlayMount(t *testing.T) {
|
||||
if !isMountpoint("/proc") {
|
||||
t.Fatal("isMountpoint 连 /proc 都识别不出 ⇒ /proc/mounts 解析坏了")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(dir, "agentfs", "merged"))
|
||||
rep, err := Preflight(dir, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 临时目录里 agentfs/merged 不是真挂载点 ⇒ 不应报进 Mounts
|
||||
for _, m := range rep.Mounts {
|
||||
if m == filepath.Join(dir, "agentfs", "merged") {
|
||||
t.Fatal("★ 非挂载点被误报为挂载点(会导致迁移脚本 umount 报错)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:体检必须报出"数据库绝对路径"与"security.key"两类风险。
|
||||
//
|
||||
// 这四条风险是生产实测得到的;少报任何一条,运维都会踩坑。
|
||||
func TestPreflightReportsKnownRisks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(dir, "memory"))
|
||||
// agentfs 必须存在,overlay 风险才会计入(风险是关于 agentfs 的)
|
||||
mustMkdir(t, filepath.Join(dir, "agentfs", "merged"))
|
||||
rep, err := Preflight(dir, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
needles := []string{"overlay", "绝对路径", "security.key"}
|
||||
joined := ""
|
||||
for _, r := range rep.Risks {
|
||||
joined += r + "\n"
|
||||
}
|
||||
for _, n := range needles {
|
||||
if !contains(joined, n) {
|
||||
t.Fatalf("★ 体检未报出风险 %q;实际风险: %v", n, rep.Risks)
|
||||
}
|
||||
}
|
||||
if len(rep.DBPathKeys) != 8 {
|
||||
t.Fatalf("★ DB 绝对路径键应为 8 个,实际 %d", len(rep.DBPathKeys))
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:同设备 → CanRename=true;跨设备 → false 且报风险。
|
||||
func TestPreflightDeviceDetection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(dir, "memory"))
|
||||
|
||||
// 同设备:目标父目录就是 dir 自己
|
||||
rep, err := Preflight(dir, filepath.Join(dir, "target"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rep.SameDevice || !rep.CanRename {
|
||||
t.Fatalf("★ 同设备未被识别:same=%v canRename=%v", rep.SameDevice, rep.CanRename)
|
||||
}
|
||||
// 跨设备:/dev/shm 通常 tmpfs,与 t.TempDir() 不同设备
|
||||
if st, err := os.Stat("/dev/shm"); err == nil && st.IsDir() {
|
||||
rep2, err := Preflight(dir, "/dev/shm/xxagent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep2.SameDevice {
|
||||
t.Skip("/dev/shm 与临时目录同设备,跳过跨设备分支")
|
||||
}
|
||||
if rep2.CanRename {
|
||||
t.Fatal("★ 跨设备被判为可 rename ⇒ 迁移会失败")
|
||||
}
|
||||
found := false
|
||||
for _, r := range rep2.Risks {
|
||||
if contains(r, "同设备") || contains(r, "rename") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("★ 跨设备未报风险")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustMkdir(t *testing.T, p string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
70
internal/kernelmod/multimodal/mod.go
Normal file
70
internal/kernelmod/multimodal/mod.go
Normal file
@ -0,0 +1,70 @@
|
||||
// Package multimodal 是 multimodal 模块:多模态向量空间。
|
||||
//
|
||||
// 依赖 config:从配置读"用哪个 provider"。
|
||||
package multimodal
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"github.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||||
)
|
||||
|
||||
// Module 提供多模态向量空间。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "multimodal" }
|
||||
func (Module) Requires() []string { return []string{kernelmod.FacConfig} }
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacMultimodal} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
cfgReg := ks.CfgReg
|
||||
var multimodalSpace vector.MultimodalEmbedder
|
||||
// 这两个值只用于状态报告(healthcheck_kernel 的 onnx 段):
|
||||
// 「配了哪个 provider」与「为什么没启用」,避免只能看到 false 却不知原因。
|
||||
var mmProviderName, mmErr string
|
||||
var closeAdapted func()
|
||||
|
||||
if mmProvider := cfgReg.GetString("core.memory.multimodal_space.provider", ""); mmProvider != "" {
|
||||
mmProviderName = mmProvider
|
||||
opts := map[string]string{}
|
||||
const optPrefix = "core.memory.multimodal_space.options."
|
||||
for _, key := range cfgReg.List(optPrefix) {
|
||||
opts[strings.TrimPrefix(key, optPrefix)] = cfgReg.GetString(key, "")
|
||||
}
|
||||
provider, err := embedding.Open(mmProvider, embedding.Config{Options: opts})
|
||||
if err != nil {
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 打开失败: %v(多模态向量检索已禁用;已注册: %s)",
|
||||
mmProvider, err, strings.Join(embedding.Names(), ", "))
|
||||
} else if adapted, err := vector.AdaptProvider(provider); err != nil {
|
||||
provider.Close()
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 元数据不合法: %v(多模态向量检索已禁用)", mmProvider, err)
|
||||
} else {
|
||||
multimodalSpace = adapted
|
||||
closeAdapted = provider.Close
|
||||
info := provider.Info()
|
||||
// 指纹可能很长(模型文件哈希),日志里只取前 12 个字符便于对照。
|
||||
shortFP := info.Fingerprint
|
||||
if len(shortFP) > 12 {
|
||||
shortFP = shortFP[:12]
|
||||
}
|
||||
log.Printf("[homed] multimodal space active: provider=%s dim=%d fp=%s modalities=%v",
|
||||
mmProvider, info.Dimension, shortFP, info.Modalities)
|
||||
}
|
||||
}
|
||||
|
||||
ks.MultimodalSpace = multimodalSpace
|
||||
ks.MultimodalProviderName = mmProviderName
|
||||
ks.MultimodalErr = mmErr
|
||||
if closeAdapted != nil {
|
||||
ks.AddCloser(closeAdapted)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
47
internal/kernelmod/onnx/mod.go
Normal file
47
internal/kernelmod/onnx/mod.go
Normal file
@ -0,0 +1,47 @@
|
||||
// Package onnx 是 onnx-parser 模块:依存句法分析器(失败退回规则引擎)。
|
||||
//
|
||||
// ## ★ 它为什么是独立模块
|
||||
//
|
||||
// 它写的是**包级单例** `nlp.defaultParser`(internal/nlp/parser.go:15),
|
||||
// 而该单例由**运行期**读取(`nlp.NewExtractor(nil)` 构造时快照它,
|
||||
// 由蒸馏路径 `extractKeyTriples` 调用)。
|
||||
//
|
||||
// 这正是设计 §2.8.2 说的"签名看不到的隐藏依赖":没有参数传递,
|
||||
// 而是**包级变量 + 运行期读**。
|
||||
//
|
||||
// ⇒ 独立成模块,让 text-memory 能**显式声明**对它的依赖
|
||||
// (见 kernelmod/textmem 的说明)。这样"碰巧安全"变成"结构保证"。
|
||||
package onnx
|
||||
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
)
|
||||
|
||||
// Module 初始化依存句法分析器。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "onnx-parser" }
|
||||
func (Module) Requires() []string { return []string{kernelmod.FacConfig} }
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacONNX} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
modelPath := ks.CfgReg.GetString("core.agent.onnx_model_path", "")
|
||||
onnxParser, err := nlp.NewONNXParser(nlp.ONNXConfig{
|
||||
ModelPath: modelPath,
|
||||
DataDir: filepath.Join(ks.Cfg.Daemon.DataDir, "nlp"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[homed] warn: ONNX parser init: %v, using fallback", err)
|
||||
return nil
|
||||
}
|
||||
nlp.SetDefaultParser(onnxParser)
|
||||
log.Printf("[homed] dep parser initialized (model: %s)", modelPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
194
internal/kernelmod/pluginhost/catalog.go
Normal file
194
internal/kernelmod/pluginhost/catalog.go
Normal file
@ -0,0 +1,194 @@
|
||||
// Package pluginhost 是 plugin-host 模块:**插件宿主**(设计 §2.6 + 用户要求)。
|
||||
//
|
||||
// ## 职责(用户的说法)
|
||||
//
|
||||
// > 插件管理器不应该在被拉起后就去读插件列表获取元数据并写入缓存准备拉起吗?
|
||||
//
|
||||
// 本模块做四件事:
|
||||
//
|
||||
// ① **扫一次**插件目录 → 元数据**缓存**(PluginCatalog)
|
||||
// ② 按**三形态**分流(自动启动 / 启用但不自动启动 / 禁用)
|
||||
// ③ 按依赖声明(requires_facilities)决定**逐个插件**的拉起时机
|
||||
// ④ 执行拉起(用 stage-host 产出的 Registry)
|
||||
//
|
||||
// ## ★ 为什么它是**内核模块**而不是插件(架构决定)
|
||||
//
|
||||
// 用户选定的方案 A。理由(自举问题):
|
||||
//
|
||||
// · `pluginmgr` 是**插件** ⇒ 它自己要被拉起 ⇒ 若调度逻辑在它内部,
|
||||
// 就得先拉起自己才能拉起自己(循环)。
|
||||
// · 而调度器要订阅内核的 syncbus(内核设施),依附内核模块更自然。
|
||||
//
|
||||
// ⇒ 调度放内核模块(本包),`pluginmgr` 插件只保留**人机界面**职责
|
||||
// (列列表/启停/装卸的工具与 HTTP)。
|
||||
//
|
||||
// ## ★ 依赖(关键:**不依赖 agent**)
|
||||
//
|
||||
// Requires: config, lua, supervisor, tracker, memory, textmem, docmem,
|
||||
// knowledge, kernel-io, stage-host
|
||||
//
|
||||
// 插件**加载**需要的是 stage-host(Registry + StageHost)与几个底层设施,
|
||||
// **不需要** agent。原先把 wirePluginSDK 放在 agent 模块里,导致插件加载
|
||||
// 被迫排在 agent 之后(agent 层实测 35 秒)。
|
||||
//
|
||||
// 实测发现:SDK 的 `Plugin` 接口只有 `Name/Start/Stop`,**没有 Configure**
|
||||
// (那条"插件 Configure 需要 StatusProvider"的注释已过时)。
|
||||
// 且唯一在 Start 阶段引 Status 的插件(healthcheck)是 **nil-guarded** 的。
|
||||
// ⇒ 拆成"底层 7 项在这里注入、agent 相关的 2 项由 agent 模块**后补注入**"。
|
||||
package pluginhost
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
)
|
||||
|
||||
// PluginMeta 是单个插件的**元数据**(缓存项)。
|
||||
//
|
||||
// ★ 为何不直接用 plugin.PluginManifest:缓存只保留"调度与展示"需要的字段,
|
||||
// 且加一层让"元数据来源"可替换(将来若从 DB 而非文件读,上层不用改)。
|
||||
type PluginMeta struct {
|
||||
Name string
|
||||
// RequiresFacilities 是声明的启动前置设施(空 = 等内核完全初始化)。
|
||||
RequiresFacilities []string
|
||||
// AutoStart:true=伴随启动;false=启用但不自动启动(等显式启用)。
|
||||
AutoStart bool
|
||||
// Disabled 来自 disabled_plugins 配置(持久禁用,最高优先级)。
|
||||
Disabled bool
|
||||
|
||||
// 以下用于展示(pluginmgr 的列表/状态)
|
||||
Version string
|
||||
Description string
|
||||
NameZh string
|
||||
Tags []string
|
||||
// ManifestPath 便于诊断("这个插件的元数据从哪来的")
|
||||
ManifestPath string
|
||||
// ScannedAt 记录扫描时刻(缓存新鲜度;也便于"元数据过期"排查)
|
||||
ScannedAt time.Time
|
||||
}
|
||||
|
||||
// PluginCatalog 是插件**元数据缓存**。
|
||||
//
|
||||
// ## 为什么要有它(用户指出的设计)
|
||||
//
|
||||
// 原先元数据散落且**每次重扫**:
|
||||
//
|
||||
// · cmd/homed 的 collectPluginSpecs(决定启动时机)
|
||||
// · pluginmgr 的 listPlugins(人看的列表)
|
||||
//
|
||||
// ⇒ 扫一次、缓存下来,供调度器 / pluginmgr / 显式启用路径共用。
|
||||
//
|
||||
// ## ★ 并发
|
||||
//
|
||||
// 调度在装配期(单线程语义),但 pluginmgr 的 HTTP 工具可能在运行期读它。
|
||||
// 用 RWMutex 保护;读多写少(只在扫描时写)。
|
||||
type PluginCatalog struct {
|
||||
mu *rwMutex
|
||||
items map[string]*PluginMeta
|
||||
dir string
|
||||
}
|
||||
|
||||
// newCatalog 创建空缓存。
|
||||
func newCatalog(dir string) *PluginCatalog {
|
||||
return &PluginCatalog{mu: newRWMutex(), items: map[string]*PluginMeta{}, dir: dir}
|
||||
}
|
||||
|
||||
// Get 取单个插件的元数据。
|
||||
func (c *PluginCatalog) Get(name string) (*PluginMeta, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
m, ok := c.items[name]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// Names 返回全部插件名(升序,可复现)。
|
||||
func (c *PluginCatalog) Names() []string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make([]string, 0, len(c.items))
|
||||
for n := range c.items {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// All 返回全部元数据(按名字升序)。
|
||||
func (c *PluginCatalog) All() []*PluginMeta {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
names := make([]string, 0, len(c.items))
|
||||
for n := range c.items {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
out := make([]*PluginMeta, 0, len(names))
|
||||
for _, n := range names {
|
||||
out = append(out, c.items[n])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Dir 返回被扫描的插件目录。
|
||||
func (c *PluginCatalog) Dir() string { return c.dir }
|
||||
|
||||
// Size 返回缓存条目数。
|
||||
func (c *PluginCatalog) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.items)
|
||||
}
|
||||
|
||||
// set 写入一条(扫描时用)。
|
||||
func (c *PluginCatalog) set(m *PluginMeta) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.items[m.Name] = m
|
||||
}
|
||||
|
||||
// scanCatalog 扫插件目录,把元数据写进缓存。
|
||||
//
|
||||
// ## 容错策略
|
||||
//
|
||||
// 无 manifest / 解析失败 ⇒ **跳过该目录**(不是错误):插件目录里可能有
|
||||
// 非插件内容(README、临时备份、install 中途的半成品)。返回跳过列表
|
||||
// 供日志说明"为什么某个目录没被识别成插件"。
|
||||
func scanCatalog(dir string, isDisabled func(string) bool) (*PluginCatalog, []string) {
|
||||
c := newCatalog(dir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return c, []string{"读取插件目录失败: " + err.Error()}
|
||||
}
|
||||
var skipped []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
mft, err := plugin.ReadManifest(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
skipped = append(skipped, name)
|
||||
continue
|
||||
}
|
||||
display := mft.Name
|
||||
if display == "" {
|
||||
display = name
|
||||
}
|
||||
c.set(&PluginMeta{
|
||||
Name: name, // ★ 用**目录名**:它与 LoadOneDir/注册表键一致
|
||||
RequiresFacilities: mft.RequiresFacilities,
|
||||
AutoStart: mft.ShouldAutoStart(),
|
||||
Disabled: isDisabled != nil && isDisabled(name),
|
||||
Version: mft.Version,
|
||||
Description: mft.Description,
|
||||
NameZh: mft.NameZh,
|
||||
Tags: mft.Tags,
|
||||
ManifestPath: filepath.Join(dir, name, "plugin.json"),
|
||||
ScannedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
return c, skipped
|
||||
}
|
||||
446
internal/kernelmod/pluginhost/catalog_test.go
Normal file
446
internal/kernelmod/pluginhost/catalog_test.go
Normal file
@ -0,0 +1,446 @@
|
||||
package pluginhost
|
||||
|
||||
// plugin-host(插件宿主)的判据(2026-10-16)。
|
||||
//
|
||||
// 设计见 docs/zh/kernel-modularization-design.md §2.6 + 用户方案 A。
|
||||
//
|
||||
// ## 这组判据守什么
|
||||
//
|
||||
// ① **三形态分流正确**(用户明确要求:自动启动 / 启用但不自动启动 / 禁用)
|
||||
// ② **元数据缓存**(用户指出的设计:扫一次、供调度与展示共用)
|
||||
// ③ **内置插件不被漏掉** ← 这是我实际踩过的回归(19 个全丢)
|
||||
// ④ 关键顺序:failback 白名单必须在插件拉起前生效
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
)
|
||||
|
||||
// makePluginDir 造一个带 plugin.json 的插件目录。
|
||||
func makePluginDir(t *testing.T, root, name, manifest string) {
|
||||
t.Helper()
|
||||
d := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(d, "plugin.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**三形态分流正确**(用户设计要求的三种形态)。
|
||||
func TestClassifyThreeForms(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// ① 自动启动 + 声明依赖
|
||||
makePluginDir(t, root, "auto_declared",
|
||||
`{"name":"auto_declared","version":"1","entry":"plugin.bin","requires_facilities":["config"]}`)
|
||||
// ① 自动启动 + 未声明
|
||||
makePluginDir(t, root, "auto_plain",
|
||||
`{"name":"auto_plain","version":"1","entry":"plugin.bin"}`)
|
||||
// ② 启用但不自动启动
|
||||
makePluginDir(t, root, "manual",
|
||||
`{"name":"manual","version":"1","entry":"plugin.bin","requires_facilities":["config"],"auto_start":false}`)
|
||||
// ③ 禁用
|
||||
makePluginDir(t, root, "banned",
|
||||
`{"name":"banned","version":"1","entry":"plugin.bin"}`)
|
||||
|
||||
cat, _ := scanCatalog(root, func(n string) bool { return n == "banned" })
|
||||
g := classify(cat)
|
||||
|
||||
eq := func(got, want []string, what string) {
|
||||
t.Helper()
|
||||
sort.Strings(got)
|
||||
sort.Strings(want)
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("★ %s: 期望 %v 实际 %v", what, want, got)
|
||||
}
|
||||
}
|
||||
eq(g.autoStart, []string{"auto_declared", "auto_plain"}, "① 自动启动")
|
||||
eq(g.declared, []string{"auto_declared"}, "① 中声明了依赖的")
|
||||
eq(g.manual, []string{"manual"}, "② 启用但不自动启动")
|
||||
eq(g.disabled, []string{"banned"}, "③ 持久禁用")
|
||||
|
||||
// ★ 关键互斥:三形态必须互不重叠(一个插件只能属于一种)
|
||||
inBoth := func(a, b []string, an, bn string) {
|
||||
t.Helper()
|
||||
m := map[string]bool{}
|
||||
for _, x := range a {
|
||||
m[x] = true
|
||||
}
|
||||
for _, x := range b {
|
||||
if m[x] {
|
||||
t.Errorf("★ %s 与 %s 同时包含 %s(三形态必须互斥)", an, bn, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
inBoth(g.autoStart, g.manual, "autoStart", "manual")
|
||||
inBoth(g.autoStart, g.disabled, "autoStart", "disabled")
|
||||
inBoth(g.manual, g.disabled, "manual", "disabled")
|
||||
}
|
||||
|
||||
// ★★★ 判据:**元数据缓存内容正确**(用户设计:读列表→写缓存)。
|
||||
func TestCatalogCachesMetadata(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makePluginDir(t, root, "p1",
|
||||
`{"name":"p1","name_zh":"一","version":"2.3.4","entry":"plugin.bin",
|
||||
"description":"演示","requires_facilities":["config","llm"],"tags":["a","b"]}`)
|
||||
|
||||
cat, noskip := scanCatalog(root, nil)
|
||||
if len(noskip) != 0 {
|
||||
t.Errorf("不该有跳过的目录: %v", noskip)
|
||||
}
|
||||
if cat.Size() != 1 {
|
||||
t.Fatalf("缓存应有 1 条,实际 %d", cat.Size())
|
||||
}
|
||||
m, ok := cat.Get("p1")
|
||||
if !ok {
|
||||
t.Fatal("取不到 p1 的元数据")
|
||||
}
|
||||
// 每个字段都要缓存对(调度靠 RequiresFacilities,展示靠其余)
|
||||
if len(m.RequiresFacilities) != 2 || m.RequiresFacilities[0] != "config" {
|
||||
t.Errorf("RequiresFacilities 未正确缓存: %v", m.RequiresFacilities)
|
||||
}
|
||||
if !m.AutoStart {
|
||||
t.Error("缺省 auto_start 应为 true")
|
||||
}
|
||||
if m.Version != "2.3.4" || m.NameZh != "一" || m.Description != "演示" {
|
||||
t.Errorf("展示字段未正确缓存: %+v", m)
|
||||
}
|
||||
if len(m.Tags) != 2 {
|
||||
t.Errorf("Tags 未正确缓存: %v", m.Tags)
|
||||
}
|
||||
if m.ManifestPath == "" || m.ScannedAt.IsZero() {
|
||||
t.Error("诊断字段(路径/扫描时刻)应被记录")
|
||||
}
|
||||
// 缓存必须能列出(pluginmgr 的列表用它)
|
||||
if names := cat.Names(); len(names) != 1 || names[0] != "p1" {
|
||||
t.Errorf("Names 不对: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**无 plugin.json 的目录被记为"无 manifest"而不是"错误"**。
|
||||
//
|
||||
// ★ 这条守的是我实际踩过的回归:19 个**内置插件**目录没有 plugin.json
|
||||
// (它们是编译期工厂),我第一版把它们记成"跳过 19 个非插件目录"并
|
||||
// **完全没有加载它们**(webui/pluginmgr/cli/files/timer… 全丢)。
|
||||
//
|
||||
// 判据分两层:
|
||||
//
|
||||
// · 本层:scanCatalog 必须**如实**报告哪些目录没 manifest(便于诊断)
|
||||
// · 装配层:LoadSkipping 必须补上内置插件(见 TestBuiltinsNotDropped)
|
||||
func TestScanReportsNoManifestDirs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makePluginDir(t, root, "has_manifest", `{"name":"has_manifest","version":"1","entry":"plugin.bin"}`)
|
||||
// 模拟内置插件目录:只有可执行文件,没有 plugin.json
|
||||
builtin := filepath.Join(root, "builtin_plugin")
|
||||
os.MkdirAll(builtin, 0o755)
|
||||
os.WriteFile(filepath.Join(builtin, "plugin.bin"), []byte("x"), 0o755)
|
||||
|
||||
cat, noManifest := scanCatalog(root, nil)
|
||||
if cat.Size() != 1 {
|
||||
t.Errorf("缓存应只含带 manifest 的(1 条),实际 %d", cat.Size())
|
||||
}
|
||||
if len(noManifest) != 1 || noManifest[0] != "builtin_plugin" {
|
||||
t.Fatalf("★ 无 manifest 的目录必须被如实报告(便于区分「内置插件」与「坏插件」): %v",
|
||||
noManifest)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**内置插件不会被漏掉**(行为判据,回归守)。
|
||||
//
|
||||
// ## 为什么这条必须存在
|
||||
//
|
||||
// 我第一版让 plugin-host 只处理"有 plugin.json 的外部插件",
|
||||
// 结果 **19 个内置插件全部没加载** —— webui(人机界面)、pluginmgr
|
||||
// (插件管理)、cli、files、cmd、timer、mcp… 全丢,而**日志看起来正常**
|
||||
// (只有一条"跳过 19 个非插件目录"的提示,很容易被当成噪音略过)。
|
||||
//
|
||||
// ## ★ 为什么必须是**行为**判据(我第一版写成源码检查 ⇒ 变异不咬)
|
||||
//
|
||||
// 初版检查"源码里有 `LoadSkipping(`"。但删掉**调用**后,
|
||||
// 注释里仍有 `LoadSkipping` 字样 ⇒ 判据照样绿
|
||||
// (**测写法不测行为** —— 本仓反复踩的同一类坑)。
|
||||
//
|
||||
// ⇒ 改为跑**生产同一个函数** `buildSkipSet`,断言:
|
||||
//
|
||||
// ① 内置插件(无 manifest)**不在** skip 里 ⇒ 会走全量加载
|
||||
// ② 三形态的插件**在** skip 里(防重复加载 / 防误加载)
|
||||
func TestBuiltinsAreNotSkipped(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makePluginDir(t, root, "ext_auto",
|
||||
`{"name":"ext_auto","version":"1","entry":"plugin.bin","requires_facilities":["config"]}`)
|
||||
makePluginDir(t, root, "ext_manual",
|
||||
`{"name":"ext_manual","version":"1","entry":"plugin.bin","auto_start":false}`)
|
||||
|
||||
cat, _ := scanCatalog(root, nil)
|
||||
g := classify(cat)
|
||||
skip := buildSkipSet(cat, g)
|
||||
|
||||
// ★ 核心:内置插件(不在 catalog 里 = 没有 plugin.json)**不得**被跳过。
|
||||
// 若它在 skip 里 ⇒ LoadSkipping 会跳过它 ⇒ 它永远不会被加载。
|
||||
const builtinName = "builtin_only"
|
||||
if skip[builtinName] {
|
||||
t.Fatalf("★ 内置插件 %s 在 skip 集合里 ⇒ 不会被加载"+
|
||||
"(webui/pluginmgr/cli/files/timer/mcp 全是这类,会静默消失)", builtinName)
|
||||
}
|
||||
if !skip["ext_auto"] {
|
||||
t.Error("① 声明依赖的插件应在 skip 里(plugin-host 自己拉起,避免重复)")
|
||||
}
|
||||
if !skip["ext_manual"] {
|
||||
t.Fatal("★ auto_start=false 的插件必须在 skip 里 —— 否则会被全量加载" +
|
||||
"(与「不自动启动」完全相反)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**meta 层不谎报"已接线"** + 接线本身由 e2e 守。
|
||||
//
|
||||
// ## ★ 这里我连续踩了三次同一个坑,值得写下来
|
||||
//
|
||||
// 初版判据:`strings.Contains(code, "LoadSkipping(")` ⇒ 变异不咬,
|
||||
//
|
||||
// 因为**注释里也有这个字样**。
|
||||
//
|
||||
// 二版判据:`strings.Contains(code, "LoadSkipping(dir, skip)")` ⇒
|
||||
//
|
||||
// 变异成 `LoadSkipping(dir, nil)` 后**仍然绿**,因为 mod.go 的
|
||||
// **注释第 116 行正好写着 `` `LoadSkipping(dir, skip)` ``**。
|
||||
//
|
||||
// **教训(第 N+1 次):源码字符串检查测的是"文本",不是"行为"。**
|
||||
// 只要文档/注释里出现过同一串,判据就永远绿 —— 而本仓的规范恰恰是
|
||||
// "每个关键决定都要在注释里讲清楚",所以注释里几乎必然出现这些串。
|
||||
// ⇒ **源码检查只能用于"是否存在某个符号定义",绝不能用于断言接线。**
|
||||
//
|
||||
// ## 那接线靠什么守?
|
||||
//
|
||||
// 行为层:真机 e2e 已验证(20 个插件加载、manual_probe **未**加载、
|
||||
// 内置插件全部在列)。这条 e2e 是接线的真正判据。
|
||||
//
|
||||
// 本判据只做一件**不依赖注释**的事:确认 `buildSkipSet` 有**真实调用点**
|
||||
// (AST 层面:调用表达式,不是注释里的文本)。
|
||||
func TestSkipSetHasRealCallSite(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "mod.go", nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
ce, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if id, ok := ce.Fun.(*ast.Ident); ok && id.Name == "buildSkipSet" {
|
||||
calls++
|
||||
}
|
||||
return true
|
||||
})
|
||||
if calls == 0 {
|
||||
t.Fatal("★ buildSkipSet 必须被**真实调用**(AST 层面)" +
|
||||
"—— 定义而不用等于没接。\n" +
|
||||
" (注意:不能用字符串 Contains 判 —— 注释里会命中,实测骗过我三次)")
|
||||
}
|
||||
// LoadSkipping 的**调用**(AST 层面)必须存在
|
||||
ls := 0
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
ce, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if sel, ok := ce.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "LoadSkipping" {
|
||||
ls++
|
||||
// ★ 且第二个实参必须**不是** nil(nil = skip 失效)
|
||||
if len(ce.Args) >= 2 {
|
||||
if id, ok := ce.Args[1].(*ast.Ident); ok && id.Name == "nil" {
|
||||
t.Error("★ LoadSkipping 的 skip 实参是 nil ⇒ " +
|
||||
"三形态跳过逻辑全部失效(内置插件会被误跳过、" +
|
||||
"manual 会被误加载)")
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if ls == 0 {
|
||||
t.Fatal("★ 必须真实调用 LoadSkipping(AST 层面,非注释)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**缓存并发安全**(pluginmgr 运行期读、装配期写)。
|
||||
func TestCatalogConcurrentRead(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for i := 0; i < 20; i++ {
|
||||
makePluginDir(t, root, "p"+string(rune('a'+i)),
|
||||
`{"name":"x","version":"1","entry":"plugin.bin"}`)
|
||||
}
|
||||
cat, _ := scanCatalog(root, nil)
|
||||
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < 16; i++ {
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
for j := 0; j < 200; j++ {
|
||||
_ = cat.Names()
|
||||
_ = cat.All()
|
||||
_ = cat.Size()
|
||||
_, _ = cat.Get("pa")
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 16; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**SyncBus 为 nil 时不得 panic**(typed-nil 陷阱,实测踩到)。
|
||||
//
|
||||
// ## 为什么必须有这条
|
||||
//
|
||||
// `var nb *SyncBus; var i SyncState = nb` ⇒ **i != nil**(经典陷阱)。
|
||||
// 于是 `NewScheduler(i, …)` 内部 `i.IsReady(...)` 解引用空指针 ⇒ **panic**。
|
||||
//
|
||||
// 实测:`ks.SyncBus == nil`(非生产装配路径 / 单测)时 plugin-host 会崩。
|
||||
// 而 panic 发生在**插件拉起路径**上 ⇒ 整个内核启动失败。
|
||||
//
|
||||
// ⇒ 判据:把 typed-nil 传给 Scheduler 前必须已被拦截。
|
||||
// 这里直接验证**拦截逻辑本身**(不依赖 produciton 装配)。
|
||||
func TestNilSyncBusDoesNotPanic(t *testing.T) {
|
||||
var nb *kernelboot.SyncBus // typed-nil
|
||||
var iface kernelboot.SyncState = nb
|
||||
if iface == nil {
|
||||
t.Skip("接口就是 nil(不会有陷阱)—— 与本判据的前提不符")
|
||||
}
|
||||
// ★ 确认陷阱真实存在(否则这条判据是空的)
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("★ 前提不成立:typed-nil bus 竟然没 panic —— " +
|
||||
"那就不需要那条 nil 拦截了,请一并删掉测试与拦截")
|
||||
}
|
||||
}()
|
||||
_ = kernelboot.NewScheduler(iface, func(kernelboot.PluginSpec) error { return nil })
|
||||
}()
|
||||
// ★ 而生产代码里的拦截:mod.go 必须**显式判 nil** 后再传
|
||||
src, err := os.ReadFile("mod.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code := string(src)
|
||||
if !strings.Contains(code, "ks.SyncBus != nil") {
|
||||
t.Fatal("★ mod.go 必须显式判 `ks.SyncBus != nil` 再构造 Scheduler —— " +
|
||||
"否则 typed-nil 会在 IsReady 处 panic(实测)")
|
||||
}
|
||||
if strings.Contains(code, "NewScheduler(ks.SyncBus,") {
|
||||
t.Fatal("★ 不得把 ks.SyncBus **直接**传给 NewScheduler(typed-nil 陷阱)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★★ 判据:**未声明依赖的插件必须等内核完全初始化**(§2.6.5 硬约束)。
|
||||
//
|
||||
// ## 这是我实际违反过、且有实测后果的约束 —— 必须写成判据
|
||||
//
|
||||
// 我第一版把插件按"本模块依赖是否覆盖插件声明"分成 launchNow / waitLater,
|
||||
// 并在 Init 里**直接**加载 launchNow。而未声明依赖的插件 `RequiresFacilities`
|
||||
// 是**空** ⇒ "覆盖空集"恒真 ⇒ 它们被归入 launchNow ⇒ **第 4 层就被加载**。
|
||||
//
|
||||
// 而 §2.6.5 的硬约束是「未声明 ⇒ 启动时机**逐字不变**(内核**完全**初始化后)」。
|
||||
// 原文警告:"静默改变启动时机 = 静默引入竞态"。
|
||||
//
|
||||
// ## ★ 后果不是理论上的 —— 实测丢了一个工具
|
||||
//
|
||||
// 内置插件 `healthcheck` 在 `Start` 里做:
|
||||
//
|
||||
// if s.Status() != nil { s.RegisterTool("healthcheck_kernel", …) }
|
||||
//
|
||||
// 第 4 层时 agent 还没建好(它与 plugin-host **并行**)⇒ `Status()` 为 nil
|
||||
// ⇒ **静默少注册一个工具**。实测:86 个工具(应为 87),**无任何报错**。
|
||||
// (nginx 式的 nil 保护把"时序错了"降级成了"少个功能",最难发现。)
|
||||
//
|
||||
// ⇒ 判据:未声明的插件**不得**在 `KernelComplete()` 之前被拉起。
|
||||
// 用**真的 Scheduler + 真的 SyncBus** 验证(跑生产同一路径)。
|
||||
func TestUndeclaredPluginsWaitForKernelComplete(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makePluginDir(t, root, "undeclared",
|
||||
`{"name":"undeclared","version":"1","entry":"plugin.bin"}`)
|
||||
makePluginDir(t, root, "declared",
|
||||
`{"name":"declared","version":"1","entry":"plugin.bin","requires_facilities":["config"]}`)
|
||||
|
||||
cat, _ := scanCatalog(root, nil)
|
||||
g := classify(cat)
|
||||
specs := buildSpecs(cat, g)
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("两个自动启动插件都应进调度器(含**未声明**的),实际 %d 个", len(specs))
|
||||
}
|
||||
|
||||
bus := kernelboot.NewSyncBus()
|
||||
bus.MarkDeclared([]string{"config", "agent"})
|
||||
bus.MarkReady("config")
|
||||
|
||||
launched := map[string]bool{}
|
||||
sched := kernelboot.NewScheduler(bus, func(s kernelboot.PluginSpec) error {
|
||||
launched[s.Name] = true
|
||||
return nil
|
||||
})
|
||||
got := sched.Submit(specs)
|
||||
for _, n := range got {
|
||||
launched[n] = true
|
||||
}
|
||||
|
||||
// ① 声明的:依赖(config)已就绪 ⇒ **应当**立即拉起
|
||||
if !launched["declared"] {
|
||||
t.Error("声明了 config 且 config 已就绪的插件应当被拉起(这是本步的核心收益)")
|
||||
}
|
||||
// ② ★ 未声明的:内核**未**完成 ⇒ **不得**拉起
|
||||
if launched["undeclared"] {
|
||||
t.Fatal("★ 未声明依赖的插件在内核完全初始化**之前**被拉起了 ⇒ 违反 §2.6.5。\n" +
|
||||
" 实测后果:healthcheck 的 s.Status() 为 nil ⇒ 静默少注册一个工具" +
|
||||
"(86 vs 87,无任何报错)。")
|
||||
}
|
||||
// ③ 内核完成后 ⇒ 未声明的才应拉起
|
||||
bus.MarkKernelComplete()
|
||||
if !launched["undeclared"] {
|
||||
t.Fatal("★ 内核完成后未声明的插件仍未被拉起 ⇒ 它永远起不来")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**未声明的插件必须在 skip 集合【外面】**(否则会被重复加载)。
|
||||
//
|
||||
// 调度器会拉起它,LoadSkipping 也会碰到它 ⇒ 两条路都加载 = 重复。
|
||||
// (实测未复现重复,是因为时序上 LoadSkipping 晚于调度器且 registry 有门,
|
||||
//
|
||||
// 但**不应依赖巧合** —— 判据把语义钉住。)
|
||||
func TestUndeclaredNotDoubleLoaded(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makePluginDir(t, root, "undeclared",
|
||||
`{"name":"undeclared","version":"1","entry":"plugin.bin"}`)
|
||||
cat, _ := scanCatalog(root, nil)
|
||||
g := classify(cat)
|
||||
skip := buildSkipSet(cat, g)
|
||||
|
||||
// 未声明的插件在 autoStart 里(调度器会管)
|
||||
if len(g.autoStart) != 1 || g.autoStart[0] != "undeclared" {
|
||||
t.Fatalf("前提不成立: %v", g.autoStart)
|
||||
}
|
||||
// ★ 修法后:catalog 里登记的(含未声明的)**一律**不在 LoadSkipping 的
|
||||
// 管辖范围 ⇒ 必须在 skip 里。这样"调度器"与"LoadSkipping"两条路
|
||||
// **按构造不相交**,不可能双加载。
|
||||
//
|
||||
// (我第一版只 skip declared/manual/disabled ⇒ 未声明的外部插件会被
|
||||
// 两条路各加载一次。实测变异:healthcheck loaded 2 次、工具 87→161。)
|
||||
if !skip["undeclared"] {
|
||||
t.Fatal("★ catalog 里登记的插件都必须在 skip 里 —— " +
|
||||
"否则未声明的外部插件会被调度器与 LoadSkipping **各加载一次**" +
|
||||
"(实测:工具数翻倍)")
|
||||
}
|
||||
// ★ 反向钉住:LoadSkipping 仍必须能加载**内置插件**(不在 catalog 里)
|
||||
if skip["some_builtin_without_manifest"] {
|
||||
t.Fatal("★ 内置插件(无 plugin.json,不在 catalog)不得被 skip —— " +
|
||||
"它是 LoadSkipping 唯一能加载的对象")
|
||||
}
|
||||
}
|
||||
60
internal/kernelmod/pluginhost/migrate_adapter.go
Normal file
60
internal/kernelmod/pluginhost/migrate_adapter.go
Normal file
@ -0,0 +1,60 @@
|
||||
package pluginhost
|
||||
|
||||
// 把**真实的** plugin.Registry 接到 kernelboot.PluginQuiescer(伪关机用)。
|
||||
//
|
||||
// ## 为什么需要适配器而不是让 kernelboot 直接依赖 plugin
|
||||
//
|
||||
// kernelboot 是装配内核,plugin 是插件系统 —— 后者依赖前者(模块声明依赖),
|
||||
// 反向 import 就成环。⇒ 用一个小接口 + 适配器隔开。
|
||||
//
|
||||
// ## ★ 为什么是 StopPlugin/StartPlugin,不是 StopAll
|
||||
//
|
||||
// 三态生命周期早已存在(自动启动 / 启用但不自动启动 / 禁用),而
|
||||
// `StopPlugin`/`StartPlugin` 是**运行态**操作:
|
||||
//
|
||||
// · 不写 disabled_plugins ⇒ 不改用户决定
|
||||
// · 不置 shuttingDown ⇒ 之后还能重新加载(**可逆**)
|
||||
// · 会摘掉注册面 ⇒ 停完模型看不到也调不到(彻底)
|
||||
//
|
||||
// `StopAll` 完全相反:置 shuttingDown 后插件**永久无法再加载**,
|
||||
// 那是进程退出用的。用它做伪关机 = 关了就起不来。
|
||||
//
|
||||
// ## 为什么跳过自身与 UI
|
||||
//
|
||||
// `refuseSelfLock` 已经拒绝了 pluginmgr(自锁,停了就没法再启)与 webui
|
||||
// (停了 UI 就失联,迁移进度看不见)。这里**主动跳过**而不是去撞错误:
|
||||
// 迁移期间我们需要 UI 报告进度,撞错只会让日志充满噪声。
|
||||
|
||||
import "github.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
|
||||
// RegistryQuiescer 适配 *plugin.Registry → kernelboot.PluginQuiescer。
|
||||
type RegistryQuiescer struct{ Reg *plugin.Registry }
|
||||
|
||||
// StopPluginRuntime 运行态停掉一个插件(不改持久状态)。
|
||||
func (r RegistryQuiescer) StopPluginRuntime(name string) error {
|
||||
return r.Reg.StopPlugin(name)
|
||||
}
|
||||
|
||||
// StartPluginRuntime 运行态拉起一个插件。
|
||||
func (r RegistryQuiescer) StartPluginRuntime(name string) error {
|
||||
return r.Reg.StartPlugin(name)
|
||||
}
|
||||
|
||||
// RunningPlugins 返回当前在运行的插件名。
|
||||
//
|
||||
// ★ 排除 pluginmgr 与 webui:前者停了无法再启(自锁),后者是迁移进度的
|
||||
// 唯一可视通道。这与 refuseSelfLock 的口径一致,但在这里**提前排除**
|
||||
// 而不是靠报错兜底(报错会进入 BeginMigration 的回滚路径,把正常迁移搞挂)。
|
||||
func (r RegistryQuiescer) RunningPlugins() []string {
|
||||
// Registry.List() 返回 r.plugins 的键集 = **当前已加载并运行**的插件
|
||||
// (StopPlugin 会从该 map 删除 ⇒ 停掉的自然不在其中)。
|
||||
all := r.Reg.List()
|
||||
out := make([]string, 0, len(all))
|
||||
for _, n := range all {
|
||||
if n == "pluginmgr" || n == "webui" {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
352
internal/kernelmod/pluginhost/mod.go
Normal file
352
internal/kernelmod/pluginhost/mod.go
Normal file
@ -0,0 +1,352 @@
|
||||
package pluginhost
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
)
|
||||
|
||||
// FacPlugins 是本模块提供的设施名。
|
||||
//
|
||||
// 意义:**"谁是最后一批被拉起的插件"** —— 声明 `plugins` 的插件要等所有
|
||||
// 自动启动的插件都起完才起(如"插件总览"类插件)。
|
||||
const FacPlugins = "plugins"
|
||||
|
||||
// Module 是插件宿主:扫元数据、按形态分流、按依赖时机拉起。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "plugin-host" }
|
||||
|
||||
// Requires:插件**加载**所需的全部前置 —— **刻意不含 agent**。
|
||||
//
|
||||
// ★ 这是本模块存在的关键价值(用户方案 A 的核心收益):
|
||||
// 原先 wirePluginSDK 在 agent 模块里 ⇒ 插件加载被排在 agent 之后,
|
||||
// 而 agent 层实测 **35 秒**(context dense backfill 750 events + 重建索引)。
|
||||
//
|
||||
// 而插件真正需要的只是 stage-host(Registry + StageHost)与几个底层设施。
|
||||
// agent 相关的两项(StatusProvider / TerminalAPI)改为**后补注入**(见
|
||||
// agent 模块),插件在 Start 阶段不用它们(唯一的 healthcheck 是 nil-guarded)。
|
||||
func (Module) Requires() []string {
|
||||
return []string{
|
||||
kernelmod.FacConfig, kernelmod.FacKernelIO, kernelmod.FacMemory,
|
||||
kernelmod.FacLua, kernelmod.FacSupervisor, kernelmod.FacTracker,
|
||||
kernelmod.FacTextMem, kernelmod.FacDocStore, kernelmod.FacKnowledge,
|
||||
kernelmod.FacStageHost,
|
||||
}
|
||||
}
|
||||
|
||||
func (Module) Provides() []string { return []string{FacPlugins} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
// ── 前置检查(用户要求:"nil 加读取前检查")──
|
||||
if ks.PluginRegistry == nil || ks.StageHost == nil {
|
||||
return fmt.Errorf("plugin-host:ks.PluginRegistry/StageHost 为 nil —— " +
|
||||
"stage-host 模块必须先完成(插件加载的硬前置)")
|
||||
}
|
||||
if ks.Cfg == nil {
|
||||
return fmt.Errorf("plugin-host:ks.Cfg 为 nil —— config 模块必须先完成")
|
||||
}
|
||||
if ks.CfgReg == nil || ks.DataDir == "" {
|
||||
return fmt.Errorf("plugin-host:CfgReg/DataDir 为 nil/空 —— Group 0 必须先完成")
|
||||
}
|
||||
|
||||
// ── ① 注入插件 SDK 的**底层依赖**(不含 agent 相关的两项)──
|
||||
injectBaseDeps(ks)
|
||||
|
||||
// ── ② 扫一次目录 → 元数据缓存 ──
|
||||
dir := ks.Cfg.Plugin.Dir
|
||||
os.MkdirAll(dir, 0o755)
|
||||
catalog, noManifest := scanCatalog(dir, ks.CfgReg.IsPluginDisabled)
|
||||
ks.PluginCatalog = catalog
|
||||
// ★ 措辞必须准确(我第一版写成"跳过 N 个非插件目录" ⇒ **误导**):
|
||||
// 那些目录大多是**内置插件**(编译期工厂,本就没有 plugin.json,
|
||||
// 由下面的 LoadSkipping 从工厂表加载)。只有既无 manifest
|
||||
// 又无工厂的才是真的非插件目录。
|
||||
//
|
||||
// 这条日志的价值在于:新增外部插件若忘写 plugin.json,会出现在这里
|
||||
// (那时"无 manifest ⇒ 只能走工厂 ⇒ 找不到 ⇒ 不加载",可据此发现)。
|
||||
if len(noManifest) > 0 {
|
||||
log.Printf("[pluginhost] %d 个目录无 plugin.json(内置插件属正常:它们由编译期工厂加载): %v",
|
||||
len(noManifest), noManifest)
|
||||
}
|
||||
log.Printf("[pluginhost] 外部插件元数据缓存就绪:%d 个(目录 %s)",
|
||||
catalog.Size(), dir)
|
||||
|
||||
// ── ③ 按**三形态**分流(用户设计要求)──
|
||||
groups := classify(catalog)
|
||||
log.Printf("[pluginhost] 形态分流:自动启动 %d / 声明了依赖 %d / "+
|
||||
"启用但不自动启动 %d / 持久禁用 %d",
|
||||
len(groups.autoStart), len(groups.declared),
|
||||
len(groups.manual), len(groups.disabled))
|
||||
|
||||
// ── ④ 拉起:**全部自动启动的插件都交给 Scheduler** ──
|
||||
//
|
||||
// ★★ 我第一版在这里犯了一个**违反硬约束**的错,值得完整记录:
|
||||
//
|
||||
// 第一版按"本模块依赖是否覆盖插件声明"把插件分成 launchNow / waitLater,
|
||||
// 并在 Init 里**直接**把 launchNow 全加载了。
|
||||
// 但**未声明依赖**的插件,`RequiresFacilities` 是空 ⇒ "本模块依赖覆盖空集"
|
||||
// 恒真 ⇒ 它们被归入 launchNow ⇒ **在第 4 层就被加载**。
|
||||
//
|
||||
// 而 §2.6.5 的硬约束是:
|
||||
//
|
||||
// 未声明 ⇒ 启动时机**逐字不变**(内核**完全**初始化完成后)
|
||||
//
|
||||
// 实测证据(planA4.log):内置插件在 `21:54:37`(第 4 层)被加载,
|
||||
// 而内核装配完成是 `21:55:12` —— **早了 35 秒**。
|
||||
// 约束原文写着"静默改变启动时机 = 静默引入竞态",
|
||||
// 42 个存量插件里任何一个若假设"Start 时 llm 一定已就绪"就会中招。
|
||||
//
|
||||
// ⇒ 修法:**不再自己判时机**,全部交给 `kernelboot.Scheduler`
|
||||
// ——它已正确实现 §2.6.5(空声明 ⇒ 等 `KernelComplete()`)与
|
||||
// §2.6.6(`!AutoStart` ⇒ 不自动拉起),且有完整判据。
|
||||
// 我只需要提供"拉起一个插件"的动作。
|
||||
//
|
||||
// 这样声明的插件仍在依赖就绪时尽早启动(本步的核心收益不变),
|
||||
// 而未声明的**恢复原行为**(等内核完全初始化)。
|
||||
var bus kernelboot.SyncState
|
||||
var busConcrete *kernelboot.SyncBus // 保留具体类型以便订阅 OnUpdate
|
||||
if ks.SyncBus != nil {
|
||||
bus = ks.SyncBus
|
||||
busConcrete = ks.SyncBus
|
||||
} else {
|
||||
// ★★ typed-nil 陷阱:`var nb *SyncBus; var i SyncState = nb`
|
||||
// ⇒ i **非 nil** ⇒ 方法里解引用空指针 panic(实测复现)。
|
||||
// 所以这里必须显式判 `!= nil` 再赋给接口变量。
|
||||
log.Printf("[pluginhost] warning: SyncBus 为空 —— 非生产装配路径," +
|
||||
"退化为立即加载(依赖未就绪的插件在 Start 时自行处理)")
|
||||
fallback := kernelboot.NewSyncBus()
|
||||
fallback.MarkKernelComplete()
|
||||
bus = fallback
|
||||
busConcrete = fallback
|
||||
}
|
||||
|
||||
sched := kernelboot.NewScheduler(bus, func(spec kernelboot.PluginSpec) error {
|
||||
return ks.PluginRegistry.LoadOneDir(filepath.Join(dir, spec.Name), spec.Name)
|
||||
})
|
||||
sched.SetLaunchErrorHandler(func(spec kernelboot.PluginSpec, err error) {
|
||||
log.Printf("[pluginhost] warning: 拉起 %s 失败: %v", spec.Name, err)
|
||||
})
|
||||
ks.PluginScheduler = sched
|
||||
|
||||
specs := buildSpecs(catalog, groups)
|
||||
// Submit 返回"此刻就能拉起"的:
|
||||
// · 声明了依赖且已全就绪 ⇒ 立即拉起(**本步的核心收益**)
|
||||
// · 未声明的 ⇒ 不进返回列表(等 KernelComplete,§2.6.5)
|
||||
// · !AutoStart ⇒ 不进(§2.6.6)
|
||||
// 同时 Submit 会做拼错校验(声明的设施名内核从未注册 ⇒ 立即报错)。
|
||||
for _, n := range sched.Submit(specs) {
|
||||
log.Printf("[pluginhost] 插件已拉起(依赖已就绪,早于装配完成): %s", n)
|
||||
}
|
||||
|
||||
// ── ⑤ 加载**内置插件**(编译期工厂,**没有 plugin.json**)──
|
||||
//
|
||||
// ★★ 这一步是我第一版漏掉的(实测:19 个内置插件全部没加载 ——
|
||||
// webui / pluginmgr / cli / files / timer / mcp …,只剩 2 个外部插件起)。
|
||||
//
|
||||
// 原因:上面的 catalog 是**扫目录读 plugin.json** 得来的,
|
||||
// 而内置插件是**编译期注册的工厂**(`plugin.RegisterFactory` 在 init() 里),
|
||||
// 目录里可能连 plugin.json 都没有 ⇒ 表里根本没有它们。
|
||||
//
|
||||
// 走 `LoadSkipping(dir, skip)`:它内部既扫目录(外部插件)
|
||||
// 也遍历工厂(内置插件),是**唯一完整**的加载入口。
|
||||
// skip 集合 = 已由本模块调度处理的那些(按三形态分流的),
|
||||
// 避免重复加载。
|
||||
// ── ⑤ 其余插件(未声明的 + 内置)→ **等内核完全初始化**后加载 ──
|
||||
//
|
||||
// ★ 为什么不能在这里直接 LoadSkipping(我第一版的第二个错):
|
||||
// plugin-host 在第 4 层,而 agent 与它**并行** ⇒ 此刻内核**尚未**
|
||||
// 完全初始化。直接加载同样违反 §2.6.5 的"未声明 ⇒ 逐字不变"。
|
||||
//
|
||||
// ⇒ 挂到 syncbus 的 `KernelComplete` 上(内核**推**状态,不轮询)。
|
||||
//
|
||||
// skip = 已由 Scheduler 接管的(declared ∪ manual ∪ disabled)。
|
||||
// 不在 skip 里的:未声明的外部插件 + **内置插件**(编译期工厂,
|
||||
// 不在 catalog 里,只有 LoadSkipping 的工厂分支能加载它们)。
|
||||
skip := buildSkipSet(catalog, groups)
|
||||
var loadOnce sync.Once
|
||||
loadRest := func(st kernelboot.SyncState) {
|
||||
if !st.KernelComplete() {
|
||||
return
|
||||
}
|
||||
loadOnce.Do(func() {
|
||||
if err := ks.PluginRegistry.LoadSkipping(dir, skip); err != nil {
|
||||
log.Printf("[pluginhost] warning: 加载内置/未声明插件: %v", err)
|
||||
}
|
||||
ks.PluginsReady = true
|
||||
log.Printf("[pluginhost] 内核完全初始化 → 已加载其余插件(含 %d 个内置)",
|
||||
len(noManifest))
|
||||
})
|
||||
}
|
||||
// 订阅 + 立即评估一次(若 bus 已 complete,比如非生产路径的退化 bus)
|
||||
// ★ 订阅用**具体类型**的 SyncBus(SyncState 是**只读接口**,
|
||||
// 故意没有 OnUpdate —— 设计 §2.6.3 "插件管理器对 syncbus 只读")。
|
||||
// 本模块是内核模块,本就持有具体类型,无需放宽接口。
|
||||
if busConcrete != nil {
|
||||
busConcrete.OnUpdate(loadRest)
|
||||
}
|
||||
loadRest(bus)
|
||||
|
||||
log.Printf("[pluginhost] 本阶段:声明了依赖的 %d 个由调度器处理"+
|
||||
"(已就绪的已拉起),未声明的 %d 个 + 内置 %d 个等内核完全初始化",
|
||||
len(groups.declared), len(groups.autoStart)-len(groups.declared), len(noManifest))
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
|
||||
// buildSpecs 把"自动启动"的插件元数据转成调度器声明。
|
||||
//
|
||||
// ## ★ 为什么**未声明**的插件也要进来(我第一版漏了这个语义)
|
||||
//
|
||||
// 第一版把"未声明依赖"的插件排除在调度器之外、在 Init 里直接加载
|
||||
// ⇒ 它们在第 4 层(agent 并行期间)就起了,而 §2.6.5 要求"等内核
|
||||
// **完全**初始化"。实测后果:`healthcheck` 的 `s.Status()` 为 nil
|
||||
// ⇒ **静默少注册一个工具** `healthcheck_kernel`(86 vs 87,无任何报错)。
|
||||
//
|
||||
// ⇒ 全部自动启动的插件都交给调度器;调度器对空声明返回
|
||||
// `KernelComplete()`(§2.6.5),正是原行为。
|
||||
func buildSpecs(c *PluginCatalog, g formGroups) []kernelboot.PluginSpec {
|
||||
var out []kernelboot.PluginSpec
|
||||
for _, n := range g.autoStart {
|
||||
m, ok := c.Get(n)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, kernelboot.PluginSpec{
|
||||
Name: m.Name,
|
||||
RequiresFacilities: m.RequiresFacilities,
|
||||
AutoStart: m.AutoStart,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildSkipSet 计算"不能让全量 LoadSkipping 碰"的插件名集合。
|
||||
//
|
||||
// ## ★ 为什么抽成独立函数(判据要求)
|
||||
//
|
||||
// 初版把这段逻辑内联在 Init 里 ⇒ 判据只能查源码字符串 ⇒
|
||||
// 删掉代码但注释仍在时**判据不咬**(实测)。
|
||||
// 抽出来后可**真的调用它**做行为断言(TestBuiltinsAreNotSkipped)。
|
||||
//
|
||||
// ## 集合语义(三形态 + 内置插件的边界)
|
||||
//
|
||||
// ② auto_start=false ⇒ 必须在集合里(否则会被全量加载 = 违背语义)
|
||||
// ③ disabled ⇒ 在集合里(显式跳过;registry 还有禁用门兜底)
|
||||
// ① 声明的 ⇒ 由本模块按依赖时机拉起 ⇒ 必须在集合里(防重复)
|
||||
// **内置插件** ⇒ **不在**集合里 ⇒ 走 LoadSkipping 的工厂分支被加载
|
||||
// ★ 这正是我第一版漏掉的关键:内置插件没有 plugin.json,
|
||||
// 不在 catalog 里,只有 LoadSkipping 的工厂分支能加载它们。
|
||||
func buildSkipSet(c *PluginCatalog, g formGroups) map[string]bool {
|
||||
skip := map[string]bool{}
|
||||
// ★★ 原则:**catalog 里登记过的插件,一律不交给 LoadSkipping**。
|
||||
//
|
||||
// ## 为什么不是"只跳过 declared/manual/disabled"(我第一版就是这么写的)
|
||||
//
|
||||
// 那会让**未声明的外部插件**同时被两条路加载:
|
||||
// · 调度器(它管未声明的,等 KernelComplete 后拉起)
|
||||
// · LoadSkipping(未声明的**不在** skip 里 ⇒ 也加载)
|
||||
//
|
||||
// 实测(变异 F,把 LoadSkipping 挪回 Init):`healthcheck` 被 loaded **两次**,
|
||||
// 工具数 87 → **161**(重复注册)。planA5 侥幸未触发,只因测试目录里
|
||||
// 那 3 个外部插件恰好都声明了依赖或不自启 —— **不能依赖这种巧合**。
|
||||
//
|
||||
// ⇒ 改成**按构造**消除:catalog 里的全部(三形态之一)都由本模块/调度器
|
||||
// 负责;LoadSkipping 只处理 catalog **之外**的,也就是那些
|
||||
// **没有 plugin.json 的内置插件**(编译期工厂,只有它能加载)。
|
||||
//
|
||||
// 这样两条路**按定义**不相交。
|
||||
for _, n := range c.Names() {
|
||||
skip[n] = true
|
||||
}
|
||||
// 保底:三形态里若有 catalog 未收录的(理论上不会),也别漏
|
||||
for _, n := range g.autoStart {
|
||||
skip[n] = true
|
||||
}
|
||||
for _, n := range g.manual {
|
||||
skip[n] = true
|
||||
}
|
||||
for _, n := range g.disabled {
|
||||
skip[n] = true
|
||||
}
|
||||
return skip
|
||||
}
|
||||
|
||||
// injectBaseDeps 注入插件 SDK 的底层依赖(原 wirePluginSDK 的 7 项)。
|
||||
//
|
||||
// ★ 这里**刻意不做** SetStatusProvider / SetTerminalAPI —— 它们依赖 agent,
|
||||
// 由 agent 模块**后补注入**(见其注释)。这不是遗漏,是有意的分层:
|
||||
// 让插件加载不必等 agent 那 35 秒。
|
||||
func injectBaseDeps(ks *kernelboot.KernelState) {
|
||||
reg := ks.PluginRegistry
|
||||
reg.SetLuaVM(ks.LuaVM)
|
||||
reg.SetBaseAPIKey(ks.BaseAPIKey)
|
||||
if ks.Supervisor != nil {
|
||||
reg.SetSupervisor(supervisor.NewSDKAdapter(ks.Supervisor))
|
||||
}
|
||||
reg.SetTracker(ks.Tracker)
|
||||
reg.SetConfig(ks.Cfg)
|
||||
reg.SetStageHost(ks.StageHost)
|
||||
|
||||
if ks.Memory != nil {
|
||||
reg.SetIndexer(ks.Memory.Indexer)
|
||||
}
|
||||
}
|
||||
|
||||
// formGroups 是三形态分流结果。
|
||||
//
|
||||
// ★ 用户明确要求的三种形态(§2.6.6):
|
||||
//
|
||||
// ① autoStart :自动启动(缺省/true)
|
||||
// ② manual :**启用但不自动启动**(auto_start=false)⇒ 等显式启用
|
||||
// ③ disabled :禁用(disabled_plugins 配置)⇒ 不加载
|
||||
//
|
||||
// 外加 `declared`:自动启动**且声明了依赖**的子集(决定"何时"拉)。
|
||||
type formGroups struct {
|
||||
autoStart []string // ① 自动启动的全部(含 declared)
|
||||
declared []string // ① 中声明了 requires_facilities 的
|
||||
manual []string // ② 不自动启动
|
||||
disabled []string // ③ 持久禁用
|
||||
}
|
||||
|
||||
func classify(c *PluginCatalog) formGroups {
|
||||
var g formGroups
|
||||
for _, m := range c.All() {
|
||||
switch {
|
||||
case m.Disabled:
|
||||
// ③ 禁用:最高优先级(不加载)
|
||||
g.disabled = append(g.disabled, m.Name)
|
||||
case !m.AutoStart:
|
||||
// ② 启用但不自动启动:**不自动加载**,等 agent 显式启用
|
||||
g.manual = append(g.manual, m.Name)
|
||||
default:
|
||||
// ① 自动启动
|
||||
g.autoStart = append(g.autoStart, m.Name)
|
||||
if len(m.RequiresFacilities) > 0 {
|
||||
g.declared = append(g.declared, m.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// loadOne 加载单个插件(走既有 LoadOneDir 路径,不绕过任何机制)。
|
||||
func loadOne(ks *kernelboot.KernelState, dir, name string) (bool, error) {
|
||||
if err := ks.PluginRegistry.LoadOneDir(filepath.Join(dir, name), name); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// planCatalog 取元数据缓存(类型断言集中一处,便于排查"缓存没建起来")。
|
||||
func planCatalog(ks *kernelboot.KernelState) *PluginCatalog {
|
||||
c, _ := ks.PluginCatalog.(*PluginCatalog)
|
||||
return c
|
||||
}
|
||||
17
internal/kernelmod/pluginhost/rwmutex.go
Normal file
17
internal/kernelmod/pluginhost/rwmutex.go
Normal file
@ -0,0 +1,17 @@
|
||||
package pluginhost
|
||||
|
||||
import "sync"
|
||||
|
||||
// rwMutex 是 sync.RWMutex 的薄包装。
|
||||
//
|
||||
// 为什么不直接用 sync.RWMutex:PluginCatalog 要能被**复制/传递**时
|
||||
// 保持清晰的所有权语义(指针字段而非嵌入值),避免"复制了含锁的结构体"
|
||||
// 这个经典的 vet 警告(copylocks)。
|
||||
type rwMutex struct{ mu sync.RWMutex }
|
||||
|
||||
func newRWMutex() *rwMutex { return &rwMutex{} }
|
||||
|
||||
func (m *rwMutex) RLock() { m.mu.RLock() }
|
||||
func (m *rwMutex) RUnlock() { m.mu.RUnlock() }
|
||||
func (m *rwMutex) Lock() { m.mu.Lock() }
|
||||
func (m *rwMutex) Unlock() { m.mu.Unlock() }
|
||||
97
internal/kernelmod/stagehost/mod.go
Normal file
97
internal/kernelmod/stagehost/mod.go
Normal file
@ -0,0 +1,97 @@
|
||||
// Package stagehost 是 stage-host 模块:阶段管道(StageHost)+ 插件注册表 + SDK 接线。
|
||||
//
|
||||
// ## 依赖
|
||||
//
|
||||
// 收 iom/evBus/mem.db/textMem/docStore/knowledge/providerMgr
|
||||
// ⇒ 声明 kernel-io / memory / textmem / docmem / knowledge / llm。
|
||||
//
|
||||
// ★ 顺序上它在 llm 之后(providerMgr 是它的参数)—— 这不是"刻意安排",
|
||||
// 而是签名直接推出来的。
|
||||
package stagehost
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/config"
|
||||
|
||||
agentCore "github.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
cli "github.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
||||
webui "github.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// Module 提供阶段管道与插件注册表。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "stage-host" }
|
||||
func (Module) Requires() []string {
|
||||
return []string{kernelmod.FacKernelIO, kernelmod.FacMemory, kernelmod.FacTextMem,
|
||||
kernelmod.FacDocStore, kernelmod.FacKnowledge, kernelmod.FacLLM}
|
||||
}
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacStageHost} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
stageHost := agentCore.NewStageHost()
|
||||
|
||||
pluginReg := plugin.NewRegistry()
|
||||
pluginReg.SetIOManager(ks.IO)
|
||||
pluginReg.SetEventBus(ks.EventBus)
|
||||
pluginReg.SetMemory(ks.Memory.DB)
|
||||
pluginReg.SetTextMemory(ks.TextMemory)
|
||||
pluginReg.SetDocStore(ks.DocStore)
|
||||
pluginReg.SetKnowledge(ks.Knowledge)
|
||||
pluginReg.SetProviderManager(ks.LLMManager)
|
||||
pluginReg.SetConfigRegistry(ks.CfgReg)
|
||||
pluginReg.SetPluginDir(ks.Cfg.Plugin.Dir)
|
||||
pluginReg.SetDataDir(ks.DataDir) // 插件 SettingsAPI.DataDir() 的数据根目录
|
||||
|
||||
// Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost
|
||||
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
log.Printf("[homed] SetToolRegistrar registering tool: %s (plugin=%s)", name, def.Plugin)
|
||||
return stageHost.RegisterTool(name, def, handler)
|
||||
})
|
||||
pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||
stageHost.RegisterStage(stage, handler)
|
||||
})
|
||||
pluginReg.SetAPIRegistrar(func(name string) error { return nil })
|
||||
pluginReg.SetToolCleaner(stageHost)
|
||||
|
||||
ks.StageHost = stageHost
|
||||
ks.PluginRegistry = pluginReg
|
||||
|
||||
// ★ 这两行必须在插件 Start(loadPlugins)**之前**设置:
|
||||
// CLI 插件的 unix socket 路径,与 webui 的监听地址覆盖。
|
||||
//
|
||||
// 归入本模块的理由:它们都是**插件读的配置**,与插件系统同生共死。
|
||||
// (原先它们在 main 里紧邻 loadPlugins —— 声明式下"同模块"是等价保证。)
|
||||
cli.DefaultSocket = ks.CLISocket
|
||||
webui.SetListenOverride(resolveWebUIOverride(ks.CfgReg, ks.HTTPAddr))
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveWebUIOverride 解析 webui 监听地址的覆盖值,空串表示不覆盖。
|
||||
//
|
||||
// 优先级:CLI --webui > 核心配置 webui.listen_addr(仅当被改成非内置默认值)。
|
||||
// 两者都不给时由 webui 插件自己的 settings["addr"] 决定。
|
||||
//
|
||||
// 为什么不写成"内核在插件加载前 Set 插件 settings['addr']":那时
|
||||
// config_webui 表还没建(表只在插件注册 def 时创建),PluginSettings.Set 的
|
||||
// INSERT 会失败而错误被忽略,随后插件 Start 里 RegisterDef 才建表并写入默认
|
||||
// :8080 —— 于是 CLI --webui 与 webui.listen_addr **一直是死配置**。
|
||||
func resolveWebUIOverride(cfgReg *config.ConfigRegistry, httpAddr string) string {
|
||||
if strings.TrimSpace(httpAddr) != "" {
|
||||
return strings.TrimSpace(httpAddr)
|
||||
}
|
||||
// webui.listen_addr 的播种默认值就是 ":8080";与默认值相同视为"未配置",
|
||||
// 否则会把用户在设置页里改过的插件 addr 顶掉。
|
||||
if v := strings.TrimSpace(cfgReg.GetString("webui.listen_addr", ":8080")); v != "" && v != ":8080" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
32
internal/kernelmod/supervisor/mod.go
Normal file
32
internal/kernelmod/supervisor/mod.go
Normal file
@ -0,0 +1,32 @@
|
||||
// Package supervisor 是 supervisor 模块:代理生命周期守护。
|
||||
//
|
||||
// 依赖 config:`initSupervisor(cfg)` 收 cfg。
|
||||
//
|
||||
// ★ 全仓唯一有 `log.Fatalf` 的 init*(其余都是记日志不中断)⇒ 它**必须成功**。
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
)
|
||||
|
||||
// Module 提供代理生命周期守护。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "supervisor" }
|
||||
func (Module) Requires() []string { return []string{kernelmod.FacConfig} }
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacSupervisor} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
sup := supervisor.New(ks.Cfg)
|
||||
if err := sup.Start(); err != nil {
|
||||
log.Fatalf("start supervisor: %v", err)
|
||||
}
|
||||
ks.Supervisor = sup
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
61
internal/kernelmod/textmem/mod.go
Normal file
61
internal/kernelmod/textmem/mod.go
Normal file
@ -0,0 +1,61 @@
|
||||
// Package textmem 是 text-memory 模块:文本记忆。
|
||||
//
|
||||
// ## ★ 显式依赖 onnx-parser —— 一条**签名看不到**的依赖
|
||||
//
|
||||
// 候选消费管线会启动蒸馏循环,而蒸馏路径在**运行期**读
|
||||
// `nlp.defaultParser`(包级单例,由 onnx 模块写):
|
||||
//
|
||||
// distiller.Start() → go distillLoop() → distillOnce()
|
||||
// → extractKeyTriples() → nlp.NewExtractor(nil)
|
||||
// → 构造时快照 defaultParser(parser.go:60)
|
||||
//
|
||||
// 原 main 的顺序是**反的**(蒸馏在 226 行启动、解析器 289 行才装上),
|
||||
// 目前**碰巧安全** —— `distillLoop` 用 ticker,首个 tick 要等
|
||||
// `Interval = 10 分钟`,那时解析器早装好了。
|
||||
//
|
||||
// ★ 但"碰巧安全"不是保证:Interval 变小或解析器装配更后 ⇒ **静默降级**
|
||||
// (defaultParser 为 nil ⇒ POS 模板抽不出 ⇒ 图记忆实体退化成整句复合值
|
||||
// ⇒ 跨维度检索全失效,基线 0/5。该后果由 pipeline.go:60 注释记录。)
|
||||
//
|
||||
// ⇒ 声明出来就是**结构保证**:两者不同层,分层器保证先后。
|
||||
package textmem
|
||||
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
)
|
||||
|
||||
// Module 提供文本记忆。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "text-memory" }
|
||||
func (Module) Requires() []string {
|
||||
// onnx-parser:见包注释(隐藏依赖,显式声明)。
|
||||
return []string{kernelmod.FacConfig, kernelmod.FacMemory,
|
||||
kernelmod.FacKernelIO, kernelmod.FacONNX}
|
||||
}
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacTextMem} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
textMem := text.New(filepath.Join(ks.Cfg.Daemon.DataDir, "memory", "text"))
|
||||
if err := textMem.Start(); err != nil {
|
||||
log.Printf("[homed] warning: text memory start: %v", err)
|
||||
ks.TextMemory = textMem
|
||||
return nil
|
||||
}
|
||||
log.Printf("[homed] text memory active at %s", filepath.Join(ks.Cfg.Daemon.DataDir, "memory", "text"))
|
||||
ks.TextMemory = textMem
|
||||
// ★ 伪关机登记(迁移数据目录用):text memory 的 rotationLoop 会周期性写盘。
|
||||
ks.AddQuiescer("textmem",
|
||||
func() error { return textMem.Pause() },
|
||||
func() error { return textMem.Resume() })
|
||||
|
||||
ks.AddCloser(textMem.Stop)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
38
internal/kernelmod/tracker/mod.go
Normal file
38
internal/kernelmod/tracker/mod.go
Normal file
@ -0,0 +1,38 @@
|
||||
// Package tracker 是 tracker 模块:变更追踪(overlayfs)。
|
||||
//
|
||||
// 依赖 config + AgentWorkDir(后者由 Group 0 的 ensureDataDirs 产出)。
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
)
|
||||
|
||||
// Module 提供变更追踪。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "tracker" }
|
||||
func (Module) Requires() []string { return []string{kernelmod.FacConfig} }
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacTracker} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
trk := tracker.NewTracker(ks.Cfg.Daemon.DataDir, ks.AgentWorkDir,
|
||||
tracker.WithKeepChangesets(100),
|
||||
tracker.WithMaxChangesetAge(30*24*time.Hour),
|
||||
)
|
||||
if err := trk.Init(); err != nil {
|
||||
log.Printf("[homed] warning: tracker init: %v", err)
|
||||
} else if err := trk.Start(); err != nil {
|
||||
log.Printf("[homed] warning: tracker mount overlay: %v (non-fatal: no overlayfs support?)", err)
|
||||
} else {
|
||||
log.Printf("[homed] change tracker active at %s", trk.MergeDir())
|
||||
}
|
||||
ks.Tracker = trk
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
@ -132,7 +132,6 @@ type Store struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]*Knowledge
|
||||
|
||||
|
||||
indexPath string
|
||||
denseCachePath string
|
||||
denseDirty bool
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user