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:
JianFeeeee
2026-10-17 19:18:24 +08:00
committed by GitHub
parent 03d87a4e21
commit a68d6a428c
266 changed files with 30185 additions and 1424 deletions

View File

@ -0,0 +1,242 @@
package proc
// proc 桥**同步一致性**的判据(2026-10-16)。
//
// ## 为什么需要这组判据
//
// 我不久前给公开 SDK 的 `PluginMgrAPI` 加了 3 个方法(StopPlugin/
// StartPlugin/IsPluginRuntimeStopped),当时以为"仅新增,非必须声明,
// 低风险"。**实测发现有两处会漏**:
//
// ① `internal/plugin/proc` 的方法白名单(4 处:常量 / 能力表 / Handle 分派 /
// case 体)—— 漏任何一处 ⇒ 子进程插件调这个方法得到"未知 method"
// ② `hmapdev` 的 `proc_main.go.tmpl`(生成 plugin.bin 的模板)——
// 漏 ⇒ **新生成的插件编译不过**(`procPluginMgr` 不满足新接口)
//
// 这两处都不是"仅新增"能覆盖的:同进程插件(.so)直接调 SDK 不受影响,
// 而子进程插件走桥 ⇒ **不对称**,正是最容易漏的地方。
//
// 判据必须**自动跟随** SDK 接口(不能手工维护清单 —— 手写清单在"新增
// 忘了登记"时同样不会报,守卫形同虚设;本仓已有先例)。
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// publishedPluginMgrMethods 从**公开 SDK 源码**提取 PluginMgrAPI 的方法名。
//
// ★ 必须从 SDK 源码提取,不能手写清单:手写清单在"SDK 加了方法但忘了
// 桥接"时**也不会包含新方法** ⇒ 判据照样绿(这就是要防的失效模式)。
func publishedPluginMgrMethods(t *testing.T, sdkPath string) []string {
t.Helper()
src, err := os.ReadFile(sdkPath)
if err != nil {
t.Fatalf("读公开 SDK: %v", err)
}
// 抓 `type PluginMgrAPI interface { ... }` 块
re := regexp.MustCompile(`(?s)type PluginMgrAPI interface \{(.*?)\n\}`)
m := re.FindStringSubmatch(string(src))
if m == nil {
t.Fatal("未在公开 SDK 里找到 PluginMgrAPI 接口 —— 接口改名了?本判据需修正")
}
body := m[1]
// 方法行:`Name(args) ret`
mre := regexp.MustCompile(`(?m)^\s*([A-Z][A-Za-z0-9_]*)\([^)]*\)\s+[A-Za-z\[\]\*\.]+`)
var out []string
for _, mm := range mre.FindAllStringSubmatch(body, -1) {
out = append(out, mm[1])
}
if len(out) < 4 {
t.Fatalf("只从 PluginMgrAPI 提取到 %d 个方法 —— 正则失效(本判据会假绿): %v", len(out), out)
}
return out
}
// ★★★ 判据:**SDK 上每个 PluginMgrAPI 方法都必须在 proc 桥里可达**。
//
// 可达 = 能力表里有它(capabilityOf 能查到)且值非空。
// 这挡住了"SDK 加了方法但忘记登记能力" ⇒ 那种方法会被按 CapCore 放行,
// 或者直接"未知 method"。
func TestBridgeCoversEveryPublishedPluginMgrMethod(t *testing.T) {
sdkPath := filepath.Join("..", "..", "..", "third_party", "homeagent-sdk", "sdk", "plugin.go")
methods := publishedPluginMgrMethods(t, sdkPath)
// SDK 方法名 → proc method 值(命名约定:StopPlugin → plugin.mgrStop)
// ★ 这里的映射表本身也要有守卫:下面断言"每个 SDK 方法都能找到映射",
// 且映射出的值必须真的在能力表里 ⇒ 漏一个就报红。
wantValues := map[string]string{
"ReloadOne": "plugin.reloadOne",
"ListLoadedPlugins": "plugin.listLoaded",
"IsPluginDisabled": "plugin.isDisabled",
"StopPlugin": "plugin.mgrStop",
"StartPlugin": "plugin.mgrStart",
"IsPluginRuntimeStopped": "plugin.mgrIsRuntimeStopped",
}
for _, m := range methods {
val, ok := wantValues[m]
if !ok {
t.Errorf("★ SDK 的 PluginMgrAPI 新增了方法 %s,但本判据的映射表没有它 —— "+
"必须同时:① 在 protocol.go 加 method 常量 ② 登记能力 ③ 加 Handle 分派 "+
"④ 加 case 体 ⑤ 更新 hmapdev 模板", m)
continue
}
if _, ok := capabilityOf(val); !ok {
t.Errorf("★ %s(%s)未登记能力归属 —— 子进程插件调用会被拒或绕过权限", m, val)
}
}
// 反向:映射表里不该有 SDK 已删除的方法(防改名后只改一处)
for name := range wantValues {
found := false
for _, m := range methods {
if m == name {
found = true
break
}
}
if !found {
t.Errorf("映射表里的 %s 在 SDK 的 PluginMgrAPI 里不存在(改名/删除后漏改?)", name)
}
}
}
// ★★★ 判据:**Handle 的分派表覆盖全部 case 体**。
//
// protocol.go 的常量、corehandler.go 的 case 标签、corehandler_runtime.go 的
// case 体是**三处**必须同步的地方。漏任意一处 ⇒ "未知 method" 或死代码。
//
// 这里检查"每个 plugin.* 常量都出现在某个 Handle 分派组里"。
func TestBridgeDispatchesEveryPluginMethod(t *testing.T) {
proto, err := os.ReadFile("protocol.go")
if err != nil {
t.Fatal(err)
}
disp, err := os.ReadFile("corehandler.go")
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile("corehandler_runtime.go")
if err != nil {
t.Fatal(err)
}
re := regexp.MustCompile(`(?m)^\s*(MethodPlugin[A-Za-z0-9_]+)\s*=\s*"([^"]+)"`)
matches := re.FindAllStringSubmatch(string(proto), -1)
if len(matches) < 5 {
t.Fatalf("只提取到 %d 个 plugin.* 常量 —— 正则失效", len(matches))
}
// 内核→插件方向的 3 个不走 Handle 的 plugin.* 常量
kernelToPlugin := map[string]bool{
"MethodPluginInit": true,
"MethodPluginStart": true,
"MethodPluginStop": true,
}
for _, m := range matches {
name, value := m[1], m[2]
if kernelToPlugin[name] {
continue
}
// ① 必须在 Handle 分派里出现
if !strings.Contains(string(disp), name) {
t.Errorf("★ %s (%s) 未出现在 Handle 分派里 —— 子进程插件调用会得到\"未知 method\"",
name, value)
continue
}
// ② 必须在某个 case 体里被处理(否则是空分派 ⇒ 静默 nil)
if !strings.Contains(string(body), "case "+name+":") {
t.Errorf("★ %s (%s) 在分派里出现但没有对应的 case 体 —— 会被静默吞掉", name, value)
}
}
}
// ★★★ 判据:**hmapdev 模板实现了 SDK 的全部 PluginMgrAPI 方法**。
//
// 漏 ⇒ 新生成的 plugin.bin **编译不过**(procPluginMgr 不满足接口)。
// 这是最晚才发现的一环(编译期才报,且只在用户执行 hmapdev build 时暴露)。
func TestHmapdevTemplateImplementsAllPluginMgrMethods(t *testing.T) {
sdkPath := filepath.Join("..", "..", "..", "third_party", "homeagent-sdk", "sdk", "plugin.go")
methods := publishedPluginMgrMethods(t, sdkPath)
tmplPath := filepath.Join("..", "..", "..", "third_party", "homeagent-sdk",
"tools", "hmapdev", "templates", "proc_main.go.tmpl")
src, err := os.ReadFile(tmplPath)
if err != nil {
// ★ 文件缺失必须 **skip 而不是 fail**(2026-10-17 CI 实测):
//
// `third_party/homeagent-sdk/tools/` 在 .gitignore 里(SDK 仓自治,
// 本仓不跟踪)。所以:
// · 本机(完整 SDK 检出)有该文件 ⇒ 判据能跑
// · CI(只 checkout 本仓)没有该文件 ⇒ 原本 Fatalf ⇒ **CI 必红**
//
// 我第一次提交时只在本机验证,没看 CI,于是这个判据让
// "Go build / vet / test" 和 "Build linux/amd64" 双红,把 PR 卡住。
//
// skip 不是放弃检查:跨仓一致性由 **SDK 仓自己的判据**守着
// (third_party/homeagent-sdk/tools/hmapdev/scaffold_stdout_test.go
// 等),那边是模板的真实归属地。本判据在本机开发时仍然生效。
t.Skipf("跳过:SDK 仓的 tools/ 不在本仓跟踪范围(.gitignore),"+
"跨仓一致性由 SDK 仓判据守护。缺文件: %v", err)
}
// 只看 procPluginMgr 的方法块。
//
// ★ 用**行扫描**而不是正则切块:正则的边界在遇到嵌套/注释时容易提前结束
// (实测撞了两次:先是 `(\n// |\z)` 被段落注释截断,再是 `\n(type |...)`
// 仍不够稳)。行扫描的语义直白且不受内容影响。
block := extractTypeBlock(t, string(src), "type procPluginMgr struct{}")
if !strings.Contains(block, "func (procPluginMgr) ReloadOne(") {
t.Fatal("提取到的 procPluginMgr 块不含 ReloadOne ⇒ 边界错误,判据会假绿")
}
for _, name := range methods {
// 期望存在 `func (procPluginMgr) <Name>(`
if !strings.Contains(block, "func (procPluginMgr) "+name+"(") {
t.Errorf("★ hmapdev 模板的 procPluginMgr 缺少 %s —— "+
"用它生成的 plugin.bin **编译不过**(不满足 SDK 接口)", name)
}
}
}
// extractTypeBlock 从源码里取出以 header 开头、到下一个顶层声明为止的块。
//
// 行扫描实现:遇到下一个处于**第 0 列**的 `type `/`func `/`var `/`const ` 即结束。
// 这比正则稳,也不需要理解内容(注释/嵌套括号都不影响)。
func extractTypeBlock(t *testing.T, src, header string) string {
t.Helper()
lines := strings.Split(src, "\n")
start := -1
for i, ln := range lines {
if strings.TrimSpace(ln) == header {
start = i
break
}
}
if start < 0 {
t.Fatalf("未找到 %q", header)
}
_ = start
var out []string
for i := start; i < len(lines); i++ {
if i > start {
ln := lines[i]
// 结束条件:下一个**顶层类型/变量/常量声明**。
//
// ★ 不能把第 0 列的 `func` 当结束:方法声明(带接收者)也是
// 第 0 列 `func` —— 初版这样写 ⇒ 块在第一个方法处就结束
// ⇒ 所有方法都"缺失"(判据假红,实测撞到)。
// 区分点:方法声明是 `func (recv)`,普通函数是 `func Name(`。
isOtherTopDecl := strings.HasPrefix(ln, "type ") ||
strings.HasPrefix(ln, "var ") || strings.HasPrefix(ln, "const ")
if isOtherTopDecl {
break
}
}
out = append(out, lines[i])
}
return strings.Join(out, "\n")
}

View File

@ -90,10 +90,19 @@ var methodCapability = map[string]Capability{
// 自身配置读写与元信息属基础能力
MethodSettingsGet: CapCore,
MethodSettingsSet: CapCore,
MethodSettingsSetSecret: CapCore,
MethodSettingsGetSecret: CapCore,
MethodSettingsList: CapCore,
MethodSettingsDefs: CapCore,
MethodSettingsRegisterDef: CapCore,
MethodSettingsDataDir: CapCore,
MethodSecretsAvailable: CapCore,
MethodSecretsStore: CapCore,
MethodSecretsList: CapCore,
MethodSecretsForget: CapCore,
MethodSecretsFill: CapCore,
MethodSecretsEncrypt: CapCore,
MethodSecretsDecrypt: CapCore,
// 生命周期自述(插件声明自己是否可自动重启)
MethodLifecycleAutoRestart: CapCore,
// 多模态内容块注入是工具返回值的一部分,不越权
@ -151,9 +160,12 @@ var methodCapability = map[string]Capability{
MethodEventsUnsubscribe: CapEvents,
// ---- 插件管理 ----
MethodPluginReloadOne: CapPluginMgr,
MethodPluginListLoaded: CapPluginMgr,
MethodPluginIsDisabled: CapPluginMgr,
MethodPluginReloadOne: CapPluginMgr,
MethodPluginListLoaded: CapPluginMgr,
MethodPluginIsDisabled: CapPluginMgr,
MethodPluginMgrStop: CapPluginMgr,
MethodPluginMgrStart: CapPluginMgr,
MethodPluginMgrIsRuntimeStopped: CapPluginMgr,
// ---- 跨插件 / 核心配置 ----
MethodSettingsGetCore: CapCrossPluginSettings,

View File

@ -2,6 +2,8 @@ package proc
import (
"encoding/json"
"os"
"regexp"
"strings"
"testing"
)
@ -28,40 +30,47 @@ func TestCapability_AllMethodsClassified(t *testing.T) {
MethodOutputInvoke: true,
}
// 插件→内核的全部 method(手工清单,与 protocol.go 对照)
pluginToKernel := []string{
MethodHandshake,
MethodToolRegister, MethodStageRegister, MethodOutputRegister,
MethodAPIRegister, MethodInputRegister,
MethodIOInjectText, MethodIOInjectInterrupt, MethodIOInjectTextNoMem,
MethodIOInjectSync, MethodIOSetToolBlocks,
MethodIOInjectMedia, MethodIOInjectMediaSync, MethodIOInjectInterruptMedia,
MethodLifecycleAutoRestart,
MethodMemoryRecall, MethodMemoryCommit, MethodMemoryIntrospect,
MethodMemoryMerge, MethodMemoryPurge,
MethodDocQuery, MethodDocInsert, MethodDocRemove, MethodDocStats,
MethodDocInsertMedia,
MethodKnowledgeSearch, MethodKnowledgeAdd, MethodKnowledgeList,
MethodTextMemoryAppend,
MethodSettingsGet, MethodSettingsSet, MethodSettingsRegisterDef,
MethodSettingsGetCore, MethodSettingsSetCore, MethodSettingsListCore,
MethodSettingsGetPlugin, MethodSettingsSetPlugin, MethodSettingsListPlugin,
MethodSettingsList, MethodSettingsDefs, MethodSettingsDump,
MethodSettingsPlugins, MethodSettingsDataDir,
MethodLLMListSources, MethodLLMSetSource, MethodLLMCurrentSource,
MethodSocialGetPerson, MethodSocialGetNetwork, MethodSocialGetTrait,
MethodSocialGetRelation, MethodSocialListPersons,
MethodEventsSubscribe, MethodEventsUnsubscribe,
MethodPluginReloadOne, MethodPluginListLoaded, MethodPluginIsDisabled,
MethodStageLock, MethodStageUnlock,
// ★ 从 protocol.go **源码**提取全部 method 常量,而不是维护手工清单。
//
// 为什么改:初版是手写清单,于是「新增 method 忘了登记能力」时
// 清单里也没有它 —— 测试照样绿,守卫形同虚设(这正是初版的目的所在,
// 却因实现方式而失效)。源码扫描让覆盖面**自动**跟上常量定义。
src, err := os.ReadFile("protocol.go")
if err != nil {
t.Fatalf("读 protocol.go 失败: %v", err)
}
re := regexp.MustCompile(`(?m)^\s*(Method[A-Za-z0-9_]+)\s*=\s*"([^"]+)"`)
matches := re.FindAllStringSubmatch(string(src), -1)
if len(matches) < 40 {
t.Fatalf("只从 protocol.go 提取到 %d 个 method 常量 —— 正则或文件结构变了,"+
"请修正本判据(否则它会假绿)", len(matches))
}
for _, m := range pluginToKernel {
if kernelToPlugin[m] {
continue
seen := 0
for _, m := range matches {
value := m[2]
if kernelToPlugin[value] {
continue // 内核→插件方向:不经 Handle,无需能力
}
if _, ok := capabilityOf(m); !ok {
t.Errorf("method %q 未登记能力归属 —— 会按 CapCore 放行,绕过权限检查", m)
seen++
if _, ok := capabilityOf(value); !ok {
t.Errorf("method %q (%s) 未登记能力归属 —— 会按 CapCore 放行,绕过权限检查",
value, m[1])
}
}
if seen == 0 {
t.Fatal("没有校验到任何 method —— 判据失效")
}
// 反向:能力表里的每一项都必须是 protocol.go 里真实存在的 method。
// 防「改了 method 名却只改一处」这类错位。
declared := map[string]bool{}
for _, m := range matches {
declared[m[2]] = true
}
for method := range methodCapability {
if !declared[method] {
t.Errorf("能力表里的 %q 在 protocol.go 里不存在(常量改名后漏改?)", method)
}
}
}

View File

@ -91,6 +91,8 @@ type CoreSDK interface {
PluginName() string
Settings() pubsdk.SettingsAPI
// Secrets 是**全进程共享**的变量库(非 per-plugin)。
Secrets() pubsdk.SecretsAPI
Memory() pubsdk.MemoryAPI
TextMemory() pubsdk.TextMemoryAPI
DocMemory() pubsdk.DocMemoryAPI
@ -173,6 +175,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
return h.handleTextMemory(method, params)
case MethodSettingsGet, MethodSettingsSet, MethodSettingsRegisterDef,
MethodSettingsSetSecret, MethodSettingsGetSecret,
MethodSettingsGetCore, MethodSettingsSetCore, MethodSettingsListCore,
MethodSettingsGetPlugin, MethodSettingsSetPlugin, MethodSettingsListPlugin,
MethodSettingsList, MethodSettingsDefs, MethodSettingsDump, MethodSettingsPlugins,
@ -189,7 +192,8 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
case MethodLifecycleAutoRestart:
return h.handleLifecycle(method, params)
case MethodPluginReloadOne, MethodPluginListLoaded, MethodPluginIsDisabled:
case MethodPluginReloadOne, MethodPluginListLoaded, MethodPluginIsDisabled,
MethodPluginMgrStop, MethodPluginMgrStart, MethodPluginMgrIsRuntimeStopped:
return h.handlePluginMgr(method, params)
case MethodStageLock, MethodStageUnlock:
@ -198,6 +202,10 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
case MethodEventsSubscribe, MethodEventsUnsubscribe:
return h.handleEvents(method, params)
case MethodSecretsAvailable, MethodSecretsStore, MethodSecretsList,
MethodSecretsForget, MethodSecretsFill, MethodSecretsEncrypt, MethodSecretsDecrypt:
return h.secrets(method, params)
case MethodArenaAlloc, MethodArenaFree:
return h.handleArena(method, params)
@ -552,6 +560,19 @@ func (h *coreHandler) settings(method string, params json.RawMessage) (interface
case MethodSettingsRegisterDef:
sett.RegisterDef(p.Def)
return nil, nil
// ---- 密文读写(强制加密,不看键名)----
//
// 与 Set/Get 的区别:这两个**必然**走加密路径,用于插件用
// `blob`/`payload`/`conn` 这类中性键名存凭据的场景 ——
// 光靠键名模式猜不出它是敏感的。
case MethodSettingsSetSecret:
return nil, sett.SetSecret(p.Key, p.Value)
case MethodSettingsGetSecret:
v, err := sett.GetSecret(p.Key)
if err != nil {
return nil, err
}
return map[string]interface{}{"value": v}, nil
case MethodSettingsGetCore:
v, err := sett.GetCore(p.Key)
if err != nil {

View File

@ -48,6 +48,42 @@ func (h *coreHandler) handlePluginMgr(method string, params json.RawMessage) (in
return nil, err
}
return map[string]interface{}{"disabled": pm.IsPluginDisabled(p.Name)}, nil
case MethodPluginMgrStop:
pm := h.sdk.PluginMgr()
if pm == nil {
return nil, errUnavailable("plugin manager")
}
var p struct {
Name string `json:"name"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
return nil, pm.StopPlugin(p.Name)
case MethodPluginMgrStart:
pm := h.sdk.PluginMgr()
if pm == nil {
return nil, errUnavailable("plugin manager")
}
var p struct {
Name string `json:"name"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
return nil, pm.StartPlugin(p.Name)
case MethodPluginMgrIsRuntimeStopped:
pm := h.sdk.PluginMgr()
if pm == nil {
return nil, errUnavailable("plugin manager")
}
var p struct {
Name string `json:"name"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
return map[string]interface{}{"runtime_stopped": pm.IsPluginRuntimeStopped(p.Name)}, nil
}

View File

@ -0,0 +1,119 @@
package proc
// 变量库的 proc 桥(内核侧)。
//
// ## 与 settings 的分工
//
// settings —— **每个插件自己的**配置(各自的 config_<name> 表)
// secrets —— **全进程共享的**变量库(security.Global() 单例)
//
// 这个区别是本质的:变量库全局唯一(设计 S10),主/子/驻留子 agent
// 与全部插件共用同一套别名。所以它不该走 settings 的命名空间机制。
//
// ## 为何需要这层桥
//
// 外部插件是**子进程**,它的 `sdk.Secrets()` 拿到的是模板里的
// `procSecrets`(见 templates/proc_main.go.tmpl),它把调用转成 RPC
// 发到这里。这条链路有 4 个地方要同时改(协议常量 / 路由 / 分发 / 能力表),
// 少一处就报 "未知 method" —— 真机 e2e 会立刻发现(本次已踩过一次)。
import (
"encoding/base64"
"encoding/json"
"fmt"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
func (h *coreHandler) secrets(method string, params json.RawMessage) (interface{}, error) {
sec := h.sdk.Secrets()
if sec == nil {
return nil, errUnavailable("secrets")
}
switch method {
case MethodSecretsAvailable:
// 明确的可用性查询(不靠 List 推断 —— 见 secrets_impl 的注释)
return map[string]interface{}{"available": sec.Available()}, nil
case MethodSecretsStore:
var p struct {
Value string `json:"value"`
Note string `json:"note"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
alias, err := sec.Store(p.Value, p.Note)
if err != nil {
return nil, err
}
// ★ 只回别名,不回显明文(与 SDK 的契约一致)
return map[string]interface{}{"alias": alias}, nil
case MethodSecretsList:
infos := sec.List()
if infos == nil {
infos = []pubsdk.SecretInfo{}
}
return map[string]interface{}{"infos": infos}, nil
case MethodSecretsForget:
var p struct {
Alias string `json:"alias"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
return nil, sec.Forget(p.Alias)
case MethodSecretsFill:
var p struct {
Text string `json:"text"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
out, n, aliases := sec.Fill(p.Text)
if aliases == nil {
aliases = []string{}
}
return map[string]interface{}{
"text": out, "replaced": n, "aliases": aliases,
}, nil
case MethodSecretsEncrypt:
// 二进制负载经 base64 过 RPC(JSON 不能直接带 bytes)
var p struct {
PlainB64 string `json:"plain_b64"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
raw, err := base64.StdEncoding.DecodeString(p.PlainB64)
if err != nil {
return nil, fmt.Errorf("secrets.encrypt: base64 解码失败: %w", err)
}
enc, err := sec.Encrypt(raw)
if err != nil {
return nil, err
}
return map[string]interface{}{"encrypted": enc}, nil
case MethodSecretsDecrypt:
var p struct {
Stored string `json:"stored"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
plain, err := sec.Decrypt(p.Stored)
if err != nil {
return nil, err
}
return map[string]interface{}{
"plain_b64": base64.StdEncoding.EncodeToString(plain),
}, nil
}
return nil, fmt.Errorf("未知 secrets method: %s", method)
}

View File

@ -3,6 +3,8 @@ package proc
import (
"encoding/json"
"fmt"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
// handleSettings 处理全部设置项读写(14 个 method 共用一条实现)。
@ -13,6 +15,7 @@ func (h *coreHandler) handleSettings(method string, params json.RawMessage) (int
switch method {
// ---- 设置(原 case 16/17/18/26~31/42~45/51)----
case MethodSettingsGet, MethodSettingsSet, MethodSettingsRegisterDef,
MethodSettingsSetSecret, MethodSettingsGetSecret,
MethodSettingsGetCore, MethodSettingsSetCore, MethodSettingsListCore,
MethodSettingsGetPlugin, MethodSettingsSetPlugin, MethodSettingsListPlugin,
MethodSettingsList, MethodSettingsDefs, MethodSettingsDump,
@ -110,3 +113,39 @@ func (h *coreHandler) handleLifecycle(method string, params json.RawMessage) (in
// 出现即说明两处不同步。
return nil, fmt.Errorf("未知 method: %s", method)
}
// secretSettings 是 SettingsAPI 的**可选**加密能力。
//
// 为何用类型断言而不是把方法加进接口:proc 桥两侧(内核侧 settingsImpl、
// 模板侧 procSettings、以及各测试替身)实现面很宽,直接扩接口会让
// **所有实现者**同时编译失败 —— 包括已发布的 SDK 使用者。
// 用可选接口则:
//
// · 支持者(内核 settingsImpl、新模板)→ 走加密路径
// · 不支持者(旧测试替身)→ 返回明确错误,**不静默存明文**
type secretSettings interface {
SetSecret(key string, value interface{}) error
GetSecret(key string) (string, error)
}
// settingsSetSecret 强制加密写入。
//
// ★ 未实现该能力时**报错**而不是降级为 Set:插件以为存了密文,
// 实际磁盘上是明文 —— 这就是最危险的静默降级。
func settingsSetSecret(sett pubsdk.SettingsAPI, key string, value interface{}) error {
ss, ok := sett.(secretSettings)
if !ok {
return fmt.Errorf("settings: 本内核不支持加密存储(settings.setSecret)," +
"拒绝以明文写入敏感项;请升级内核")
}
return ss.SetSecret(key, value)
}
// settingsGetSecret 读取加密值。
func settingsGetSecret(sett pubsdk.SettingsAPI, key string) (string, error) {
ss, ok := sett.(secretSettings)
if !ok {
return "", fmt.Errorf("settings: 本内核不支持加密存储(settings.getSecret)")
}
return ss.GetSecret(key)
}

View File

@ -0,0 +1,138 @@
package proc
// 端到端:插件的**加密存储**能力(settings.setSecret/getSecret)。
//
// ## 为何必须做真机端到端
//
// 这条链路上有 5 段,任何一段漏掉都表现为「插件拿不到值」:
//
// ① 插件调 sdk.Settings().SetSecret
// ② 模板 procSettings.SetSecret → RPC "settings.setSecret"
// ③ proc 协议常量 MethodSettingsSetSecret
// ④ coreHandler 分发 → settingsSetSecret(可选接口断言)
// ⑤ ConfigRegistry.PluginSettings.SetSecret → 真加密落盘
//
// 单测只能证明 ⑤ 对。②③④ 是新写的,必须让**真实模板编译出的插件**
// 真的跑一遍 —— 否则就是「模板 e2e 报了 missing method,而我只在
// 内核侧修好了接口」这类错位(本次实际踩到过一次)。
import (
"os"
"path/filepath"
"strings"
"testing"
internalConfig "github.com/JianFeeeee/HomeAgent/internal/config"
internalSDK "github.com/JianFeeeee/HomeAgent/internal/sdk"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
// secretE2EPlugin 是一个**用真实模板**编译的插件:它只做两件事 ——
// 把密钥写进加密存储,然后读回来并通过返回值报告。
const secretE2EPlugin = `package main
import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
type secretPlugin struct{ name string }
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &secretPlugin{name: name}, nil
}
func (p *secretPlugin) Name() string { return p.name }
func (p *secretPlugin) Stop() error { return nil }
func (p *secretPlugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool("e2e_secret", sdk.ToolDef{
Description: "加密存储往返测试",
Parameters: map[string]interface{}{"type": "object"},
}, func(args map[string]interface{}) (interface{}, error) {
sett := s.Settings()
if sett == nil {
return "NO_SETTINGS", nil
}
if err := sett.SetSecret("blob", "e2e-secret-value-9876543210"); err != nil {
return "SET_ERR: " + err.Error(), nil
}
got, err := sett.GetSecret("blob")
if err != nil {
return "GET_ERR: " + err.Error(), nil
}
return got, nil
})
return nil
}
`
// ★★ 判据:真实模板编译出的插件,其 SetSecret/GetSecret 全链路可用,
// 且**磁盘上是密文**、插件读回是明文。
func TestE2E_RealTemplateSecretRoundTrip(t *testing.T) {
bin := buildPluginWithRealTemplate(t, secretE2EPlugin)
// 用**真实** ConfigRegistry(真加密)+ 真主密钥
dir := t.TempDir()
setTestCodec(t)
reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
defer reg.Close()
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
core := newFakeCore()
// 用**生产同款**适配器(sdk.NewSettings),而不是直接塞 PluginSettings ——
// 后者缺 DataDir 等成员,不是完整 SettingsAPI;用生产适配器才能
// 顺带验证「内核装配的那一层」也是通的。
core.settingsAPI = internalSDK.NewSettings("e2e", reg)
p := New("e2e", bin, dir, nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
core.mu.Lock()
handler, ok := core.tools["e2e_secret"]
core.mu.Unlock()
if !ok {
t.Fatal("插件未注册 e2e_secret 工具")
}
res, err := handler(map[string]interface{}{})
if err != nil {
t.Fatalf("工具调用失败: %v", err)
}
got, _ := res.(string)
if strings.HasPrefix(got, "SET_ERR") || strings.HasPrefix(got, "GET_ERR") {
t.Fatalf("加密存储往返失败: %s", got)
}
if got != "e2e-secret-value-9876543210" {
t.Fatalf("插件应读回明文,实际 %q", got)
}
// ★ 磁盘上必须是密文(这才是本特性的目的)
var raw string
if err := reg.DB().QueryRow(
`SELECT value FROM config_e2e WHERE key = 'blob'`).Scan(&raw); err != nil {
t.Fatalf("读原始值失败: %v", err)
}
if strings.Contains(raw, "e2e-secret-value") {
t.Fatalf("磁盘上出现明文(加密未生效): %q", raw)
}
if !strings.HasPrefix(raw, "enc:v1:") {
t.Fatalf("磁盘值应为 enc:v1: 密文,实际 %q", raw)
}
t.Logf("✅ 插件读回明文(%d 字节),磁盘存密文(%d 字节)", len(got), len(raw))
_ = os.Remove
_ = pubsdk.ConfigDef{}
}
// setTestCodec 装配一把测试主密钥。
func setTestCodec(t *testing.T) {
t.Helper()
internalConfig.ResetTestCodec(t)
}

View File

@ -0,0 +1,217 @@
package proc
// 端到端:插件的**变量库**能力(SDK `Secrets()`)。
//
// ## 这条链路为什么要真机验证
//
// 它与 settings 的 setSecret 是**同一个坑的翻版**:新增 proc method 要改
// **4 个地方**(协议常量 / 外层路由 / 内层分发 / 能力表),少一处就报
// "未知 method"。单测只能证明各段对,证不了接上了。
//
// 而且它跨了**三层**:
//
// ① 插件调 sdk.Secrets().Store
// ② 模板 procSecrets → RPC "secrets.store"
// ③ 内核 coreHandler.secrets → sdk.SecretsAPI(secrets_impl)
// ④ → internal/security.Global() 单例(真加密落盘)
//
// ## 为什么要有这个 API(去重的意义)
//
// 在此之前 commondev 插件自带一套 AES-256-GCM 存储,与内核完全重复。
// 两套库**互不可见**是这个设计最有害的地方 —— 现在统一到一个单例。
import (
"os"
"path/filepath"
"strings"
"testing"
internalConfig "github.com/JianFeeeee/HomeAgent/internal/config"
internalSDK "github.com/JianFeeeee/HomeAgent/internal/sdk"
internalSecurity "github.com/JianFeeeee/HomeAgent/internal/security"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
// secretsE2EPlugin 用真实模板编译:只做变量库往返。
const secretsE2EPlugin = `package main
import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
type secPlugin struct{ name string }
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &secPlugin{name: name}, nil
}
func (p *secPlugin) Name() string { return p.name }
func (p *secPlugin) Stop() error { return nil }
func (p *secPlugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool("e2e_secrets", sdk.ToolDef{
Description: "变量库往返",
Parameters: map[string]interface{}{"type": "object"},
}, func(args map[string]interface{}) (interface{}, error) {
sec := s.Secrets()
if sec == nil || !sec.Available() {
return "UNAVAILABLE", nil
}
alias, err := sec.Store("e2e-secret-value-9876543210", "e2e")
if err != nil {
return "STORE_ERR: " + err.Error(), nil
}
// ★ 只应拿到别名,拿不到明文
if alias == "" {
return "EMPTY_ALIAS", nil
}
out, n, _ := sec.Fill("token=@{" + alias + "}")
if n != 1 {
return "FILL_MISS", nil
}
return out, nil
})
return nil
}
`
// ★★ 判据:真实模板插件的变量库往返全链路可用。
func TestE2E_RealTemplateSecretsRoundTrip(t *testing.T) {
bin := buildPluginWithRealTemplate(t, secretsE2EPlugin)
dir := t.TempDir()
setTestCodec(t)
// 装配**进程级单例**(内核对所有插件共享的那一份)
if _, err := internalSecurity.InitGlobal(internalSecurity.Options{
Path: filepath.Join(dir, "variables.json"),
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
Enabled: true,
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { internalSecurity.SetGlobal(nil) })
_ = internalConfig.NewConfigRegistry
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
core := newFakeCore()
core.secretsAPI = internalSDK.NewSecretsAPI()
p := New("e2e", bin, dir, nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
core.mu.Lock()
handler, ok := core.tools["e2e_secrets"]
core.mu.Unlock()
if !ok {
t.Fatal("插件未注册 e2e_secrets 工具")
}
res, err := handler(map[string]interface{}{})
if err != nil {
t.Fatalf("调用失败: %v", err)
}
got, _ := res.(string)
if strings.HasPrefix(got, "UNAVAILABLE") || strings.HasPrefix(got, "STORE_ERR") ||
strings.HasPrefix(got, "EMPTY_ALIAS") || strings.HasPrefix(got, "FILL_MISS") {
t.Fatalf("变量库往返失败: %s", got)
}
// Fill 应把别名换回明文
if !strings.Contains(got, "e2e-secret-value-9876543210") {
t.Fatalf("Fill 未换回明文: %q", got)
}
// 单例里应真的登记了
if internalSecurity.Global().Count() != 1 {
t.Fatalf("单例应有 1 条,实际 %d", internalSecurity.Global().Count())
}
t.Logf("✅ 插件经内核变量库完成往返: %s", got)
}
// ★★ 判据:插件写入的是**内核那一个**变量库(不是自己又建了一套)。
//
// 这是去重的核心断言:插件 Store 之后,内核侧能立刻看到。
func TestE2E_PluginAndKernelShareOneVault(t *testing.T) {
bin := buildPluginWithRealTemplate(t, secretsE2EPlugin)
dir := t.TempDir()
setTestCodec(t)
st, err := internalSecurity.InitGlobal(internalSecurity.Options{
Path: filepath.Join(dir, "variables.json"),
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
Enabled: true,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { internalSecurity.SetGlobal(nil) })
host, _ := NewHost()
defer host.Close()
core := newFakeCore()
core.secretsAPI = internalSDK.NewSecretsAPI()
p := New("e2e", bin, dir, nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatal(err)
}
defer p.Close()
core.mu.Lock()
handler := core.tools["e2e_secrets"]
core.mu.Unlock()
if _, err := handler(map[string]interface{}{}); err != nil {
t.Fatal(err)
}
// ★ 内核侧(同一个单例)应看到插件登记的那条
infos := st.List()
if len(infos) != 1 {
t.Fatalf("内核单例应有 1 条(插件写的),实际 %d —— 说明插件自建了一套库", len(infos))
}
if infos[0].Note != "e2e" {
t.Errorf("备注未透传: %q", infos[0].Note)
}
// 内核的 Fill 也能用它
if out, n, _ := st.Fill("x=@{" + infos[0].Alias + "}"); n != 1 ||
!strings.Contains(out, "e2e-secret-value") {
t.Errorf("内核 Fill 用不了插件登记的变量: n=%d out=%q", n, out)
}
t.Logf("✅ 插件与内核共用同一个变量库(别名 %s)", infos[0].Alias)
}
// ★ 判据:安全模块未启用时 Available()==false(而非 nil panic)。
func TestE2E_SecretsUnavailableWhenDisabled(t *testing.T) {
bin := buildPluginWithRealTemplate(t, secretsE2EPlugin)
dir := t.TempDir()
internalSecurity.SetGlobal(nil)
t.Cleanup(func() { internalSecurity.SetGlobal(nil) })
host, _ := NewHost()
defer host.Close()
core := newFakeCore()
core.secretsAPI = internalSDK.NewSecretsAPI()
p := New("e2e", bin, dir, nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatal(err)
}
defer p.Close()
core.mu.Lock()
handler := core.tools["e2e_secrets"]
core.mu.Unlock()
res, err := handler(map[string]interface{}{})
if err != nil {
t.Fatalf("不该报错(应优雅报告不可用): %v", err)
}
if got, _ := res.(string); got != "UNAVAILABLE" {
t.Fatalf("未启用时应报 UNAVAILABLE,实际 %q", got)
}
_ = os.Remove
_ = pubsdk.SecretInfo{}
}

View File

@ -8,6 +8,7 @@ import (
"sync/atomic"
"time"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
@ -170,6 +171,7 @@ type EvtConsumer struct {
stop chan struct{}
stopOnce sync.Once
done chan struct{}
doneOnce sync.Once // close 纪律:见 internal/safeclose
}
type evtfdReader interface {
@ -214,7 +216,7 @@ func (c *EvtConsumer) Run() {
c.mu.Lock()
c.running = false
c.mu.Unlock()
close(c.done)
safeclose.Safe(&c.doneOnce, c.done)
}()
buf := make([]byte, 8)
@ -303,7 +305,7 @@ func (c *EvtConsumer) drainEvents() {
// 若要在 Stop 之后释放 ringData(host.Close 会 munmap 整个区域),
// 必须先 Stop() 再 Wait()。
func (c *EvtConsumer) Stop() {
c.stopOnce.Do(func() { close(c.stop) })
safeclose.Safe(&c.stopOnce, c.stop)
}
// Wait 阻塞至 Run 退出。返回后 drainEvents 保证不会再访问 ringData。

View File

@ -17,7 +17,9 @@ import (
// 解除整块 mmap)。
//
// 若 Exited() 先于 onExit 返回而关闭,就会出现:
// 等待者 → 释放映射 → onExit 仍在读那块内存 → SIGSEGV
//
// 等待者 → 释放映射 → onExit 仍在读那块内存 → SIGSEGV
//
// 这正是 2026-09-25 全量测试偶发崩溃的根因(栈见 process.go 的 markExited 注释)。
//
// ============================ 为什么用原子标志而非 channel ============================
@ -25,6 +27,7 @@ import (
// 用一个在回调里置位的原子量 + 在收到关闭信号后立刻读它:
// - 修复前:关闭先发生,回调尚未跑 ⇒ 读到 false ⇒ 判红
// - 修复后:回调先跑完再关闭 ⇒ 读到 true ⇒ 判绿
//
// 用 atomic 而非普通 bool 是为了让「回调的写」与「测试的读」之间
// 有明确的同步语义(否则是数据竞态,-race 下会报)。
func TestProcess_OnExitCompletesBeforeExitedCloses(t *testing.T) {

View File

@ -329,3 +329,44 @@ func TestExtraBag_SnapshotDetectsBagChange(t *testing.T) {
t.Fatalf("bag 身份翻转应恰好脏 1 字段(WriteDirty 漏检=绑帧写不回), got %d", n)
}
}
// ★★★ 判据:内核写的 `comefrom` 必须能过 proc 桥到插件。
//
// 为什么必须单独判这一条:QQ 是 **proc 插件**(跨进程、共享段)。
// 内核侧写对了 comefrom 不等于插件拿得到它 —— 若这个键在桥上丢失,
// 插件会按"缺失 = user"处理 ⇒ 驻留子**照旧被拦**,而内核侧的判据全绿。
// 这正是本仓反复吃过的"通道两端各修一半"的坑。
//
// 实测背景(2026-10-16):驻留子的工具调用被 QQ 的用户权限门拦下,
// 修法是内核在帧上写 comefrom、插件据此跳过用户权限模型。
// 两端之间的**唯一**通路就是这条。
func TestExtraBag_ComeFromRoundTrips(t *testing.T) {
for _, want := range []string{"internal", "user"} {
seg := newTestSeg(t)
src := &pubsdk.StageContext{
Extra: map[string]interface{}{
"input_source": "qq", // 与 comefrom 共存(真实场景就是这样)
"comefrom": want,
},
}
if err := seg.WriteAll(src); err != nil {
t.Fatalf("WriteAll(%s): %v", want, err)
}
var dst pubsdk.StageContext
if err := seg.ReadInto(&dst); err != nil {
t.Fatalf("ReadInto(%s): %v", want, err)
}
got, ok := dst.Extra["comefrom"]
if !ok {
t.Fatalf("★ comefrom=%s 在 proc 桥上丢失 —— 插件会按 user 处理,"+
"驻留子照旧被拦(内核侧判据却是绿的)", want)
}
if s, _ := got.(string); s != want {
t.Errorf("comefrom 失真: 期望 %q 实际 %q (%T)", want, got, got)
}
// 与 input_source 共存时不得互相覆盖
if dst.Extra["input_source"] != "qq" {
t.Errorf("comefrom 写入了却把 input_source 弄丢了: %v", dst.Extra["input_source"])
}
}
}

View File

@ -0,0 +1,199 @@
package proc
// 「插件自行声明的建链超时」——**真实消费者**判据(2026-10-16)。
//
// 设计见 docs/zh/kernel-modularization-design.md §2.4.2。
//
// ## 为什么必须测 proc.Spawn,而不是测 manifest 字段
//
// 本仓反复吃过的坑:**字段加了、解析对了,但没人读它**。
// 只测 `manifest.HandshakeTimeout()` 的换算,删掉 `Start()` 里传给 Spawn 的那一行
// 判据依然全绿 —— 那是假绿。
//
// 所以这里测**真实入口** `proc.Spawn` 的**可观测行为**:用同一个"慢握手"插件,
// 只改超时值,断言结果随之改变。
//
// ## 为什么需要专门的慢插件(slowshakeplugin)
//
// 现有的 echoplugin 等**立即**回握手 ⇒ 超时取多少都不会改变结果 ⇒
// 用它们写的判据必然假绿。要观测超时,被观测对象必须"慢到能让不同超时值分叉"。
import (
"os"
"strings"
"testing"
"time"
)
// spawnWithTimeout 起一个慢握手插件,返回结果(error 与耗时)。
//
// delayMS 由 SLOW_SHAKE_MS 环境变量传给插件(见 slowshakeplugin 的说明)。
func spawnWithTimeout(t *testing.T, timeout time.Duration, delayMS int) (err error, elapsed time.Duration) {
t.Helper()
bin := buildTestPlugin(t, "slowshakeplugin.go")
// 环境变量经 os.Environ 传给子进程(Spawn 会继承并追加自己的)。
// itoa 复用本包既有实现(shm_profile_test.go),避免重复定义。
t.Setenv("SLOW_SHAKE_MS", itoa(delayMS))
start := time.Now()
p, err := Spawn("slowshake", bin, Options{
Handler: noopHandler,
HandshakeTimeout: timeout,
})
elapsed = time.Since(start)
if err == nil {
p.Kill()
}
return err, elapsed
}
// ★★★ **核心判据**:声明的超时真的改变了 Spawn 的行为。
//
// 同一个慢插件(握手延迟 3s),只改超时:
//
// 超时 1s ⇒ 必须**失败**(短超时被用上)
// 超时 10s ⇒ 必须**成功**(长超时被用上,证明"声明更长"真的有效)
//
// 这两条一起才构成证明:
//
// · 只有第一条 ⇒ 可能超时参数根本没生效,"失败"另有原因
// · 只有第二条 ⇒ 可能参数没生效、只是默认值足够长
func TestSpawnHonorsDeclaredHandshakeTimeout(t *testing.T) {
const delayMS = 3000
// ① 短超时(1s)必须失败
errShort, elShort := spawnWithTimeout(t, 1*time.Second, delayMS)
if errShort == nil {
t.Fatal("★ 声明 1s 超时、插件延迟 3s,却握手成功了 —— 超时参数没被用上")
}
// 且必须**大约在** 1s 失败(不是等满 3s 或 10s 才失败)
if elShort > 2500*time.Millisecond {
t.Errorf("★ 1s 超时却等了 %v 才失败 —— 超时值未生效(像是用了默认值)", elShort)
}
if elShort < 500*time.Millisecond {
t.Errorf("失败过快(%v)—— 失败原因可能不是超时,判据失去意义", elShort)
}
// 失败原因必须是超时(避免"因为别的原因失败"而假绿)
if !strings.Contains(errShort.Error(), "超时") && !strings.Contains(errShort.Error(), "deadline") {
t.Errorf("失败原因应指向超时: %v", errShort)
}
// ② 长超时(10s)必须成功
errLong, elLong := spawnWithTimeout(t, 10*time.Second, delayMS)
if errLong != nil {
t.Fatalf("★ 声明 10s 超时、插件延迟 3s,却握手失败 —— 长超时未被用上: %v", errLong)
}
// 成功耗时必须 ≈ 插件延迟(3s),证明它确实等到了
if elLong < 2500*time.Millisecond {
t.Errorf("成功过快(%v)—— 插件真的延迟了吗?判据前提可疑", elLong)
}
}
// ★★★ 判据:**未声明超时(<=0)⇒ 用默认值**(存量插件行为逐字不变)。
//
// 硬约束:现网 42 个插件的 plugin.json 都没有该字段。
// 若"未声明"被当成"立即超时",它们会**全部加载失败** —— 那是破坏性变更。
func TestSpawnUsesDefaultWhenTimeoutNotDeclared(t *testing.T) {
// 延迟 3s:若默认值是 10s(现状),必须成功
err, el := spawnWithTimeout(t, 0, 3000)
if err != nil {
t.Fatalf("★ 未声明超时(0)应回退到默认 %v,却失败: %v", DefaultHandshakeTimeout, err)
}
if el < 2500*time.Millisecond {
t.Errorf("成功过快(%v),判据前提可疑", el)
}
// 负数同样回退默认(不是"立即超时")
err2, _ := spawnWithTimeout(t, -5*time.Second, 3000)
if err2 != nil {
t.Fatalf("★ 负数超时应回退默认而非立即失败: %v", err2)
}
}
// ★★★ 判据:默认值必须是 10s(唯一真相源,且与文档一致)。
//
// 这条独立存在是因为**默认值漂移**很难被发现:
// 改大/改小都能通过前面所有行为判据(它们只关心"回退发生了"),
// 而实际影响是"轻插件白等"或"重插件被误杀"。
func TestDefaultHandshakeTimeoutValue(t *testing.T) {
if DefaultHandshakeTimeout != 10*time.Second {
t.Errorf("默认建链超时应为 10s(文档与部署预期),实际 %v", DefaultHandshakeTimeout)
}
// 包内旧名必须与导出名一致(防止两处漂移)
if defaultHandshakeTimeout != DefaultHandshakeTimeout {
t.Errorf("defaultHandshakeTimeout 与导出名不一致: %v vs %v",
defaultHandshakeTimeout, DefaultHandshakeTimeout)
}
}
// 保证 os 包被引用(环境变量传递路径)。
var _ = os.Getenv
// ★★★ **接线判据**:走真实入口 `proc.NewWithTimeout → Plugin.Start`,
// 证明声明的超时**穿过构造函数与 Start** 到达 Spawn。
//
// ## 为什么必须独立存在(本会话第 10 次同类教训)
//
// 上面那条 `TestSpawnHonorsDeclaredHandshakeTimeout` 直接把
// `Options.HandshakeTimeout` 喂给 `Spawn` —— 它证明的是"**Spawn 会用这个值**",
// **没有覆盖** `NewWithTimeout(timeout) → Plugin.handshakeTimeout → Start 里传给 Spawn`
// 这一段接线。实测:把 `Start()` 里那一行删掉,那条判据**依然全绿**。
//
// 而真实路径是:`dynamic_proc.go` 读 manifest → `NewWithTimeout` → `Start` → `Spawn`。
// 断掉其中任何一环,"插件自行声明超时"就是空谈 —— 正是本仓最怕的
// "字段加了没人读"。
func TestPluginStartPassesDeclaredTimeoutToSpawn(t *testing.T) {
const delayMS = 3000
// ① 短超时(1s)+ 慢插件(3s)⇒ Start 必须失败
bin := buildTestPlugin(t, "slowshakeplugin.go")
t.Setenv("SLOW_SHAKE_MS", itoa(delayMS))
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
p := NewWithTimeout("slow1", bin, t.TempDir(), nil, host, nil, 1*time.Second)
start := time.Now()
errShort := p.Start(newFakeCore())
elShort := time.Since(start)
if errShort == nil {
p.Close()
t.Fatal("★ 声明 1s 超时、插件延迟 3s,Start 却成功了 —— " +
"NewWithTimeout 的超时没传到 Spawn(接线断了)")
}
if elShort > 2500*time.Millisecond {
t.Errorf("★ 1s 超时却等了 %v —— 接线可能断了(用了默认值)", elShort)
}
// ② 长超时(10s)+ 同一个慢插件 ⇒ Start 必须成功
host2, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host2.Close()
p2 := NewWithTimeout("slow2", bin, t.TempDir(), nil, host2, nil, 10*time.Second)
if err := p2.Start(newFakeCore()); err != nil {
t.Fatalf("★ 声明 10s 超时、插件延迟 3s,Start 却失败 —— "+
"接线可能把超时弄成了 0 或极小值: %v", err)
}
defer p2.Close()
// ③ 不声明的路径(New)必须等价于用默认值 —— 存量插件零改动
// 用 3s 延迟 + 默认 10s ⇒ 必须成功
host3, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host3.Close()
p3 := New("slow3", bin, t.TempDir(), nil, host3, nil) // 不传超时
if err := p3.Start(newFakeCore()); err != nil {
t.Fatalf("★ 不声明超时的插件(存量路径)应走默认 10s 并成功: %v", err)
}
defer p3.Close()
}

View File

@ -62,9 +62,10 @@ func BenchmarkIPC_OSFloorRawPipeEcho(b *testing.B) {
// BenchmarkIPC_MinimalRPC 最简 RPC 往返:无载荷、不经共享帧。
//
// ★ 选 output.invoke 而不是 tool.invoke:前者不要求插件注册任何东西,
// 插件会回一个「未实现」错误 —— 但**往返已经完成**。
// 本基准测的是**传输成本**,因此应答内容无关紧要
// (且 Call 的错误已被忽略,不会中断计时)。
//
// 插件会回一个「未实现」错误 —— 但**往返已经完成**。
// 本基准测的是**传输成本**,因此应答内容无关紧要
// (且 Call 的错误已被忽略,不会中断计时)。
func BenchmarkIPC_MinimalRPC(b *testing.B) {
bin := buildBenchPlugin(b, "echoplugin.go")
p, err := Spawn("echo", bin, Options{Handler: noopHandler})

View File

@ -2,6 +2,7 @@ package proc
import (
"fmt"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
"sync"
"time"
)
@ -76,9 +77,10 @@ func (l *stageLock) Acquire(plugin string) error {
l.ownerMu.Unlock()
acquired := make(chan struct{})
var acquiredOnce sync.Once
go func() {
l.mu.Lock()
close(acquired)
safeclose.Safe(&acquiredOnce, acquired)
}()
select {

View File

@ -5,8 +5,10 @@ import (
"encoding/json"
"fmt"
"log"
"path/filepath"
"sync"
"sync/atomic"
"time"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
@ -43,6 +45,11 @@ type Plugin struct {
// caps 是 manifest 声明的能力集(§3.8 权限梯度)。
caps *capabilitySet
// handshakeTimeout 是插件**自行声明**的建链超时(manifest 的
// handshake_timeout_sec)。<=0 ⇒ 用 DefaultHandshakeTimeout。
// 见 docs/zh/kernel-modularization-design.md §2.4.2。
handshakeTimeout time.Duration
// ownerID 是本插件在共享槽池里的身份(由 Host 分配)。
// 内核用它校验 arena.free 的归属,并在插件退出时回收残留槽。
ownerID uint32
@ -58,22 +65,36 @@ type Plugin struct {
stopping atomic.Bool
}
// New 创建子进程插件(不启动进程)。
//
// host 必须是全部子进程插件共用的实例(由 registry 创建一次)。
// New 创建子进程插件(不启动进程)。
//
// host 必须是全部子进程插件共用的实例(由 registry 创建一次)。
// capabilities 来自 manifest 的 capabilities 字段;为空时不限制(存量插件向后兼容)。
//
// 建链超时用内核默认值。需要按插件声明覆盖时用 NewWithTimeout。
func New(name, bin, dir string, config map[string]interface{}, host *Host, onCrash func(string, error), capabilities ...string) *Plugin {
return NewWithTimeout(name, bin, dir, config, host, onCrash, 0, capabilities...)
}
// NewWithTimeout 同 New,但可指定**建链(握手)超时**。
//
// timeout <= 0 ⇒ 用 DefaultHandshakeTimeout(与 New 行为一致)。
//
// ## 为何单独一个构造函数而不是改 New 的签名
//
// `New` 末尾是变参 `capabilities ...string`。中间插一个 duration 参数
// 会让所有调用点错位(值会静默地用错),而编译器**不会**报错
// (变参位置可吞类型兼容的实参)——那是本仓最怕的"静默错误"。
// 加一个新构造函数既保持旧签名不变,又把新能力显式化。
func NewWithTimeout(name, bin, dir string, config map[string]interface{}, host *Host, onCrash func(string, error), timeout time.Duration, capabilities ...string) *Plugin {
return &Plugin{
name: name,
bin: bin,
dir: dir,
config: config,
host: host,
onCrash: onCrash,
caps: newCapabilitySet(capabilities),
name: name,
bin: bin,
dir: dir,
config: config,
host: host,
onCrash: onCrash,
caps: newCapabilitySet(capabilities),
handshakeTimeout: timeout,
}
}
@ -131,13 +152,24 @@ func (p *Plugin) Start(core CoreSDK) error {
p.handler.invokeStageFn = p.invokeStage
p.handler.invokeOutput = p.invokeOutput
// ★ 显式把插件目录传给**插件进程**(随机化之前的隐式 cwd 不可靠:
// 插件自己 chdir 后就失效,而 rawfile 的路径解析依赖它)。
pluginEnv := append([]string{}, p.env...)
pluginEnv = append(pluginEnv,
"HA_PLUGIN_DIR="+p.dir,
"HA_PLUGIN_RAWFILE_DIR="+filepath.Join(p.dir, "rawfile"),
"HA_PLUGIN_WORK_DIR="+filepath.Join(p.dir, "work"),
)
proc, err := Spawn(p.name, p.bin, Options{
Dir: p.dir,
// 插件自行声明的建链超时(<=0 时 Spawn 内部回退到默认值)。
HandshakeTimeout: p.handshakeTimeout,
// 共享段的传递机制按平台不同(shmpass_*.go):
// Unix 经 ExtraFiles 传继承 fd(3=统一共享内存区域 SuperBlock+StageContext+EvtRing,
// 4=事件通知 eventfd);Windows 无 fd 继承语义,改用命名内核对象,名字经环境变量传入。
// 权威定义在 shmpass_unix.go 的 procExtraFilesForShm,修改时三处必须同步。
Env: append(p.env, p.host.procEnvForShm()...),
Env: append(pluginEnv, p.host.procEnvForShm()...),
ExtraFiles: p.host.procExtraFilesForShm(),
ShmSize: p.host.shmSize,
EvtRingSize: evtTotalSize,

View File

@ -39,6 +39,12 @@ type fakeCoreSDK struct {
// 用接口而非具体类型:需要能塞入"只实现公开 KnowledgeAPI、
// 不具备内核多模态扩展"的替身,以验证能力缺失时的报错路径。
knowledge knowledgeAPITest
// settingsAPI 是可注入的真实配置后端(端到端加密判据用)。
// 为 nil 时 Settings() 返回 nil(旧行为不变)。
settingsAPI pubsdk.SettingsAPI
// secretsAPI 同理:变量库后端(端到端判据用)。
secretsAPI pubsdk.SecretsAPI
}
// knowledgeAPITest 是公开 SDK 的知识库契约(不含内核扩展方法)。
@ -61,7 +67,8 @@ func newFakeCore() *fakeCoreSDK {
}
func (f *fakeCoreSDK) PluginName() string { return "fake" }
func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return nil }
func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return f.settingsAPI }
func (f *fakeCoreSDK) Secrets() pubsdk.SecretsAPI { return f.secretsAPI }
func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil }
func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil }
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return f.docMem }

View File

@ -10,12 +10,14 @@ import (
"log"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/JianFeeeee/HomeAgent/internal/meta"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
)
// Process 管理一个外部插件子进程:spawn / 双向 JSON-RPC / 优雅停止 / 崩溃检测。
@ -55,18 +57,20 @@ type Process struct {
handler RequestHandler
// exited 在进程被收割后关闭,用于唤醒所有等待者。
exited chan struct{}
exitOnce sync.Once
exitErr atomic.Pointer[error]
readerWG sync.WaitGroup
waiterWG sync.WaitGroup
readyOnce sync.Once
ready chan struct{}
exited chan struct{}
exitedOnce sync.Once
exitOnce sync.Once
exitErr atomic.Pointer[error]
readerWG sync.WaitGroup
waiterWG sync.WaitGroup
readyOnce sync.Once
ready chan struct{}
// waitErr 由**唯一的** waitLoop 写入:cmd.Wait() 的返回值。
// waitDone 关闭后 waitErr 才可读。
waitErr error
waitDone chan struct{}
waitErr error
waitDone chan struct{}
waitDoneOnce sync.Once
// onExit 在进程退出时回调(内核用它喂 plugin_health.recordCrash,
// 以及 ForceRelease 释放该插件持有的 stage 锁)。
@ -110,7 +114,12 @@ type Options struct {
// 默认超时。
const (
defaultHandshakeTimeout = 10 * time.Second
// DefaultHandshakeTimeout 是内核与插件**建链(握手)**的默认超时。
//
// 导出它是因为它是**唯一真相源**:插件的 manifest 换算
// (PluginManifest.HandshakeTimeout)必须引用这个常量,
// 否则两处会各自漂移("以为改了一处其实另一处没改")。
DefaultHandshakeTimeout = 10 * time.Second
// stopGracePeriod 是发出 plugin.stop 后等待进程自行退出的时间。
// 超时则 Kill——**这是"真正的取消"**,对比 cgo 路径超时后线程永久泄漏。
stopGracePeriod = 5 * time.Second
@ -119,6 +128,9 @@ const (
killReapTimeout = 2 * time.Second
)
// defaultHandshakeTimeout 是包内旧名(保持既有调用点不改)。
const defaultHandshakeTimeout = DefaultHandshakeTimeout
// ErrProcessExited 表示子进程已退出,调用无法完成。
var ErrProcessExited = errors.New("proc: 插件进程已退出")
@ -282,6 +294,33 @@ func (p *Process) handshake(timeout time.Duration) error {
//
// 参考 clawhubadapter/sidecarProcess 的成熟做法:大 buffer 防长行截断、
// pending 表定位应答、退出时唤醒全部等待者。
// summarizeFrame 把一段非 JSON 文本压成单行摘要(诊断用)。
//
// 用途:让"插件往 stdout 打印了什么"一眼可见 —— 否则只看到
// 「invalid character 'x'」而不知道是哪句话(实测排查代价见 readLoop 里那段注释)。
//
// 处理:
// - 控制字符(\r\n\t 等)替换成空格,避免多行打散日志
// - 超长截断并标注原始字节数
func summarizeFrame(b []byte, max int) string {
var sb strings.Builder
for _, c := range b {
if c < 0x20 || c == 0x7f {
sb.WriteByte(' ')
continue
}
sb.WriteByte(c)
if sb.Len() >= max {
break
}
}
out := strings.TrimSpace(sb.String())
if len(b) > max {
return out + fmt.Sprintf(" …(共 %d 字节,已截断)", len(b))
}
return out
}
func (p *Process) readLoop() {
defer p.readerWG.Done()
@ -290,7 +329,7 @@ func (p *Process) readLoop() {
// 超大 payload 应走共享内存 arena 而非 RPC 帧。
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
p.readyOnce.Do(func() { close(p.ready) })
safeclose.Safe(&p.readyOnce, p.ready)
for scanner.Scan() {
line := scanner.Bytes()
@ -303,7 +342,32 @@ func (p *Process) readLoop() {
Method string `json:"method"`
}
if err := json.Unmarshal(line, &probe); err != nil {
log.Printf("[proc] %s 收到非法 JSON 帧(%d 字节): %v", p.name, len(line), err)
// ★★★ 这条日志必须**可定位**(2026-10-16 实测修正)。
//
// ## 原问题的形态
//
// 子进程插件的 **stdout 是 JSON-RPC 协议通道**,stderr 才是日志通道
// (SDK 侧 log.SetOutput(os.Stderr))。插件若用 `fmt.Printf` 写日志,
// 那句话会以**裸文本**进入协议流 ⇒ 就是这里。
//
// 原日志只报「收到非法 JSON 帧(N 字节): invalid character 'x'」:
// · **不显示内容** ⇒ 看不到是"哪句话"混进来了;
// · **不提原因** ⇒ 看起来像协议/内核故障,不像"插件自己 printf"。
//
// 实测代价:生产环境累计 **1038 条**这种报错(weather 113 / rss 103 /
// calendar 102 / 第三方 huawei_smarthome 720),而真因只是那 4 个插件
// 用了 fmt.Printf。定位它花了一整轮排查。
//
// ⇒ 现在把**内容摘要**与**可能原因 + 修法**一起打出来。
// 限长是因为坏帧可能很大(插件打了长文本/二进制),
// 截断到 160 字节足以认出"这是哪句日志"。
log.Printf("[proc] %s 收到非法 JSON 帧(%d 字节): %v\n"+
" ↑ 内容摘要: %s\n"+
" 可能原因:插件往 **stdout** 打印了日志,而 stdout 是"+
"JSON-RPC **协议通道**(stderr 才是日志通道)。\n"+
" 修法:把插件里的 fmt.Printf/fmt.Println 改成 log.Printf"+
"(脚手架模板 1.4.0 起已修正)。",
p.name, len(line), err, summarizeFrame(line, 160))
continue
}
@ -357,7 +421,7 @@ func (p *Process) readLoop() {
func (p *Process) waitLoop() {
defer p.waiterWG.Done()
p.waitErr = p.cmd.Wait()
close(p.waitDone)
safeclose.Safe(&p.waitDoneOnce, p.waitDone)
// 主动拆管道:若孙子进程仍持有 stdout 写端,readLoop 不会自己退,
// 关掉读端逼它从 Scan 里出来(报 file already closed,已预期)。
@ -401,7 +465,7 @@ func (p *Process) markExited() {
ch <- &Response{Error: ErrProcessExited.Error()}
}
// ★ 顺序至关重要:onExit 必须在 close(p.exited) **之前**完成。
// ★ 顺序至关重要:onExit 必须在 safeclose.Safe(&p.exitedOnce, p.exited) **之前**完成。
//
// onExit(内核侧即 Plugin.handleExit)会调 Host.ReclaimOwner 回收该插件
// 残留的共享槽——**那是要读共享内存区域的**。而 exited 一关闭,
@ -424,7 +488,7 @@ func (p *Process) markExited() {
p.onExit(p.name, p.ExitError())
}
close(p.exited)
safeclose.Safe(&p.exitedOnce, p.exited)
})
}
@ -639,9 +703,10 @@ func (p *Process) Kill() error {
// 超时后主动关掉读端,强制 readLoop 从 Scan 里出来(file already closed,
// 已在 waitLoop 里被列为预期错误)。
readerDone := make(chan struct{})
var readerDoneOnce sync.Once
go func() {
p.readerWG.Wait()
close(readerDone)
safeclose.Safe(&readerDoneOnce, readerDone)
}()
select {
case <-readerDone:

View File

@ -502,3 +502,74 @@ func TestSupervisor_TrackAfterCloseKillsProcess(t *testing.T) {
t.Error("关停后冒出的进程应被立即结束")
}
}
// ★★★ 判据:**非法帧的报错必须可定位**(含内容摘要与修法)。
//
// ## 为什么必须有这条
//
// 原报错只有「收到非法 JSON 帧(N 字节): invalid character 'x'」——
//
// · **不显示内容** ⇒ 看不到是哪句话混进来了;
// · **不提原因** ⇒ 看起来像协议/内核故障,不像"插件自己 printf"。
//
// 实测代价:生产环境累计 **1038 条**这种报错(weather 113 / rss 103 /
// calendar 102 / 第三方 huawei_smarthome 720),定位真因花了一整轮排查。
//
// ⇒ 本判据守两件事:
//
// ① `summarizeFrame` 真的能把坏帧压成可读单行(含控制字符处理与截断);
// ② readLoop 的报错**文本里含**原因与修法(否则等于没改)。
func TestSummarizeFrameIsReadable(t *testing.T) {
cases := []struct {
in string
wantPart string
}{
{"[weather] started\n", "[weather] started"},
{"line1\r\nline2\n", "line1 line2"},
{"\x00\x01binary", "binary"},
{" padded ", "padded"},
}
for _, c := range cases {
got := summarizeFrame([]byte(c.in), 160)
if !strings.Contains(got, c.wantPart) {
t.Errorf("summarizeFrame(%q) = %q,期望含 %q", c.in, got, c.wantPart)
}
if strings.ContainsAny(got, "\n\r") {
t.Errorf("摘要不得含换行(会打散日志): %q", got)
}
}
// 截断:长输入必须标注原长
long := strings.Repeat("x", 500)
got := summarizeFrame([]byte(long), 160)
if len(got) > 260 {
t.Errorf("超长输入未被截断: %d 字节", len(got))
}
if !strings.Contains(got, "500") {
t.Errorf("截断时必须标注原始字节数: %q", got)
}
}
// ★★★ 判据:readLoop 的非法帧报错**必须点明原因与修法**。
//
// ★ 源码扫描在此**恰当**:断言的是"报错文案里是否含某关键词",
//
// 属于"模式存在性",不是接线/顺序断言。
func TestIllegalFrameErrorIsActionable(t *testing.T) {
src, err := os.ReadFile("process.go")
if err != nil {
t.Fatal(err)
}
code := string(src)
// 必须提到"stdout 是协议通道"这个原因
for _, kw := range []string{"协议通道", "log.Printf"} {
if !strings.Contains(code, kw) {
t.Errorf("★ 非法帧报错未提及 %q —— 插件作者看不到"+
"「这是我自己 printf 造成的」这个关键信息", kw)
}
}
// 必须真的调用 summarizeFrame(否则摘要不会出现在日志里)
if !strings.Contains(code, "summarizeFrame(line") {
t.Error("★ readLoop 必须调用 summarizeFrame 把坏帧内容打进日志" +
"(否则只看到 invalid character 'x',不知道是哪句话)")
}
}

View File

@ -166,6 +166,22 @@ const (
MethodSettingsDump = "settings.dump" // 44
MethodSettingsPlugins = "settings.plugins" // 45
MethodSettingsDataDir = "settings.dataDir" // 51
// 密文读写(强制加密,不看键名)—— 与 settings.set/get 的区别是
// 这两个**必然**走加密路径,用于插件用中性键名存凭据的场景。
MethodSettingsSetSecret = "settings.setSecret" // 52
MethodSettingsGetSecret = "settings.getSecret" // 53
// 变量库(全局共享,非 per-plugin;见 internal/security 与 corehandler_secrets.go)。
//
// 与 settings 的分工是本质的:settings 是**每个插件自己的**配置,
// secrets 是**全进程共享**的变量库单例(主/子/驻留子 agent 与全部插件共用)。
MethodSecretsAvailable = "secrets.available" // 32
MethodSecretsStore = "secrets.store" // 33
MethodSecretsList = "secrets.list" // 34
MethodSecretsForget = "secrets.forget" // 35
MethodSecretsFill = "secrets.fill" // 36
MethodSecretsEncrypt = "secrets.encrypt" // 46
MethodSecretsDecrypt = "secrets.decrypt" // 47
// LLM 源(原 case 19/20/37)
MethodLLMListSources = "llm.listSources" // 19
@ -188,6 +204,14 @@ const (
MethodPluginReloadOne = "plugin.reloadOne" // 48
MethodPluginListLoaded = "plugin.listLoaded" // 49
MethodPluginIsDisabled = "plugin.isDisabled" // 50
// ---- 运行态启停(设计 §2.6.7):与 isDisabled(持久态)分开 ----
//
// ★ 为何必须桥接:子进程插件走本桥,未在此登记的方法会被
// corehandler 直接拒("未知 method")⇒ SDK 上加了方法但插件**调不通**。
// 同进程插件(.so)直接调 SDK,不受影响 —— 这个不对称容易漏。
MethodPluginMgrStop = "plugin.mgrStop" // 51
MethodPluginMgrStart = "plugin.mgrStart" // 52
MethodPluginMgrIsRuntimeStopped = "plugin.mgrIsRuntimeStopped" // 53
// 共享段锁仲裁(新增,无对应 method id —— C ABI 下不存在跨进程锁概念)
MethodStageLock = "stage.lock"

View File

@ -325,7 +325,6 @@ func (s *Segment) ReadInto(sc *pubsdk.StageContext) error {
}
}
var respPtr *string
if s.getFlag(flagResponseSet) {
r, err := getStr(fResponse)

View File

@ -2,6 +2,7 @@ package proc
import (
"fmt"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
"log"
"sort"
"sync"
@ -137,7 +138,8 @@ func (s *Supervisor) StopAll(timeout time.Duration) {
}
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
var doneOnce sync.Once
go func() { wg.Wait(); safeclose.Safe(&doneOnce, done) }()
if timeout <= 0 {
timeout = stopGracePeriod * 2

View File

@ -0,0 +1,77 @@
//go:build ignore
// slowshakeplugin 故意**延迟回复握手**,用于验证「插件自行声明的建链超时」真的生效。
//
// 为什么需要这个测试替身(而不是复用现有的 echoplugin):
// 现有插件都**立即**回握手,于是"超时值"这个参数无论取多少都不会改变行为 ——
// 用它们写的判据必然假绿(删掉超时逻辑也不报红)。
// 要真正观测超时,被观测的插件必须**慢到能让不同超时值产生不同结果**。
//
// 延迟由环境变量 SLOW_SHAKE_MS 控制(毫秒),由判据按用例设置:
//
// · 声明超时 1s + 延迟 3s ⇒ 必须**失败**(证明短超时真的被用上)
// · 声明超时 10s + 延迟 3s ⇒ 必须**成功**(证明长超时真的被用上)
//
// 握手契约见 internal/plugin/proc/process.go 的 handshake():
// 内核发 method="handshake",插件回 {result:{sdk_version:...}};
// 主版本号必须与内核一致,否则握手被拒(那会让判据因无关原因失败)。
package main
import (
"bufio"
"encoding/json"
"os"
"strconv"
"time"
)
type request struct {
ID uint64 `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type response struct {
ID uint64 `json:"id"`
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
func main() {
delay := 0
if v := os.Getenv("SLOW_SHAKE_MS"); v != "" {
delay, _ = strconv.Atoi(v)
}
in := bufio.NewScanner(bufio.NewReader(os.Stdin))
in.Buffer(make([]byte, 1<<20), 1<<20)
out := bufio.NewWriter(os.Stdout)
send := func(v interface{}) {
b, _ := json.Marshal(v)
out.Write(b)
out.WriteByte('\n')
out.Flush()
}
for in.Scan() {
var req request
if err := json.Unmarshal(in.Bytes(), &req); err != nil {
continue
}
switch req.Method {
case "handshake":
// ★ 关键:故意延迟,且**在延迟期间不读下一帧** ——
// 真实的慢插件就是这样(忙着初始化,来不及应答)。
if delay > 0 {
time.Sleep(time.Duration(delay) * time.Millisecond)
}
// sdk_version 的主版本必须与内核一致(否则握手会因版本不匹配被拒,
// 判据就测不到超时这个变量了)。
send(response{ID: req.ID, Result: map[string]interface{}{
"sdk_version": "1.4.0",
}})
default:
send(response{ID: req.ID, Result: map[string]interface{}{}})
}
}
}