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

@ -14,6 +14,7 @@ import (
"time"
"github.com/JianFeeeee/HomeAgent/internal/plugin"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
)
@ -55,6 +56,8 @@ type terminalWatch struct {
}
type TerminalSession struct {
stopOnce sync.Once
doneOnce sync.Once
id string
command string
cmd *exec.Cmd
@ -105,7 +108,7 @@ func (t *TerminalSession) Close() {
t.closed = true
t.mu.Unlock()
close(t.stopCh)
safeclose.Safe(&t.stopOnce, t.stopCh)
// 终止进程并释放 PTY(幂等;readLoop 自然退出时已调过就直接返回)。
t.releaseResources()
// 等 readLoop 走完退出流程(它会发最后的 output/停止事件)。
@ -156,6 +159,7 @@ func (t *TerminalSession) IsExpired() bool {
}
type Plugin struct {
stopChOnce sync.Once
name string
mu sync.Mutex
wg sync.WaitGroup
@ -435,8 +439,12 @@ func (p *Plugin) Stop() error {
// 幂等:Stop 可能被多条路径调到(StopAndUnload 后再 StopAll、
// 停机与热重载交错)。close 一个已关闭的 channel 会 panic,
// 故用 Once 兜住。
// ★ 不能写成 p.stopOnce.Do(func(){ safeclose.Safe(&p.stopOnce, ...) }):
// sync.Once 不可重入 ⇒ 外层 Do 持锁、内层 Do 等同一把锁 ⇒ **自锁死锁**
// (实测:agentcli 测试 600s 超时,栈显示 sync.(*Once).Do 嵌套)。
// 正确做法:close 的守卫用**独立的** once。
p.stopOnce.Do(func() {
close(p.stopCh)
safeclose.Safe(&p.stopChOnce, p.stopCh)
p.shutdown()
})
return nil
@ -919,7 +927,7 @@ func (t *TerminalSession) releaseResources() {
func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
defer p.wg.Done()
defer close(t.done)
defer safeclose.Safe(&t.doneOnce, t.done)
// 无论从哪个分支退出,都释放进程与 PTY。
//

View File

@ -16,6 +16,6 @@ func init() {
type stubPlugin struct{}
func (p *stubPlugin) Name() string { return "agentcli" }
func (p *stubPlugin) Name() string { return "agentcli" }
func (p *stubPlugin) Start(sdk *sdk.PluginSDK) error { return nil }
func (p *stubPlugin) Stop() error { return nil }
func (p *stubPlugin) Stop() error { return nil }

View File

@ -44,10 +44,16 @@ func drainFor(term ptyTerm, dur time.Duration) []byte {
buf := make([]byte, 4096)
var out []byte
for time.Now().Before(deadline) {
ch := make(chan struct{ N int; E error }, 1)
ch := make(chan struct {
N int
E error
}, 1)
go func() {
n, e := term.Read(buf)
ch <- struct{ N int; E error }{n, e}
ch <- struct {
N int
E error
}{n, e}
}()
select {
case r := <-ch:

View File

@ -20,6 +20,7 @@ import (
"github.com/JianFeeeee/HomeAgent/internal/events"
"github.com/JianFeeeee/HomeAgent/internal/plugin"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
)
@ -44,6 +45,7 @@ func init() {
}
type Plugin struct {
stopChOnce sync.Once
name string
skillsDir string
simulatorDir string
@ -54,8 +56,8 @@ type Plugin struct {
dispatcher *RegistryDispatcher
httpClient *http.Client
stopCh chan struct{}
stopOnce sync.Once
stopCh chan struct{}
stopOnce sync.Once
}
// pluginSingleton 内核单例引用(Start 时设置),供 SendToChannel/ChannelSender 使用
@ -308,41 +310,41 @@ func (p *Plugin) ipcGoroutine(s *sdk.PluginSDK) {
p.mu.Lock()
simDir := p.simulatorDir
p.mu.Unlock()
if simDir == "" {
continue
}
// 1. Write settings snapshot for CLI config:get/config:list
settings := s.Settings().Dump()
if data, err := json.MarshalIndent(settings, "", " "); err == nil {
os.WriteFile(filepath.Join(simDir, ".settings.json"), data, 0644)
}
// 2. Process pending settings changes from CLI config:set
pendingPath := filepath.Join(simDir, ".settings-pending.json")
if data, err := os.ReadFile(pendingPath); err == nil {
var pending map[string]interface{}
if json.Unmarshal(data, &pending) == nil {
for k, v := range pending {
s.Settings().Set(k, v)
}
if simDir == "" {
continue
}
os.Remove(pendingPath)
}
// 3. Process reload requests from CLI plugin:install
reloadPath := filepath.Join(simDir, ".reload-request")
if data, err := os.ReadFile(reloadPath); err == nil {
name := strings.TrimSpace(string(data))
if name != "" {
if err := p.reloadPlugin(name); err != nil {
log.Printf("[clawhubadapter] reload from CLI: %v", err)
} else {
log.Printf("[clawhubadapter] reloaded plugin from CLI request: %s", name)
}
// 1. Write settings snapshot for CLI config:get/config:list
settings := s.Settings().Dump()
if data, err := json.MarshalIndent(settings, "", " "); err == nil {
os.WriteFile(filepath.Join(simDir, ".settings.json"), data, 0644)
}
// 2. Process pending settings changes from CLI config:set
pendingPath := filepath.Join(simDir, ".settings-pending.json")
if data, err := os.ReadFile(pendingPath); err == nil {
var pending map[string]interface{}
if json.Unmarshal(data, &pending) == nil {
for k, v := range pending {
s.Settings().Set(k, v)
}
}
os.Remove(pendingPath)
}
// 3. Process reload requests from CLI plugin:install
reloadPath := filepath.Join(simDir, ".reload-request")
if data, err := os.ReadFile(reloadPath); err == nil {
name := strings.TrimSpace(string(data))
if name != "" {
if err := p.reloadPlugin(name); err != nil {
log.Printf("[clawhubadapter] reload from CLI: %v", err)
} else {
log.Printf("[clawhubadapter] reloaded plugin from CLI request: %s", name)
}
}
os.Remove(reloadPath)
}
os.Remove(reloadPath)
}
}
}
}
@ -983,7 +985,9 @@ func (p *Plugin) loadOCPlugin(s *sdk.PluginSDK, dir, name string) error {
data, listErr := mgr.call("plugins/list", nil)
if listErr == nil && data != nil {
var result struct {
Plugins []struct{ Name string `json:"name"` } `json:"plugins"`
Plugins []struct {
Name string `json:"name"`
} `json:"plugins"`
}
if json.Unmarshal(data, &result) == nil {
for _, pl := range result.Plugins {
@ -1131,7 +1135,8 @@ func ChannelSender() *Plugin {
return pluginSingleton
}
func (p *Plugin) loadPySidecar(s *sdk.PluginSDK, dir, name string) error { simPath := filepath.Join(p.simulatorDir, "pysim.py")
func (p *Plugin) loadPySidecar(s *sdk.PluginSDK, dir, name string) error {
simPath := filepath.Join(p.simulatorDir, "pysim.py")
if err := os.MkdirAll(p.simulatorDir, 0755); err != nil {
return fmt.Errorf("create simulator dir: %w", err)
}
@ -1225,8 +1230,9 @@ func (p *Plugin) Stop() error {
p.mu.Lock()
defer p.mu.Unlock()
// 关停 ipcGoroutine(stopCh),避免重载后旧 goroutine 残留导致线程累积
// 见 agentcli 同类注释:Once 不可重入,close 的守卫必须用独立 once。
p.stopOnce.Do(func() {
close(p.stopCh)
safeclose.Safe(&p.stopChOnce, p.stopCh)
})
for _, sp := range p.sidecars {
sp.Close()
@ -1264,8 +1270,8 @@ func hasOCExtensions(pkgPath string) bool {
}
var pkg struct {
OpenClaw *struct {
Extensions interface{} `json:"extensions"`
RuntimeExtensions interface{} `json:"runtimeExtensions"`
Extensions interface{} `json:"extensions"`
RuntimeExtensions interface{} `json:"runtimeExtensions"`
} `json:"openclaw"`
}
if err := json.Unmarshal(data, &pkg); err != nil {

View File

@ -4,6 +4,7 @@ import (
"bufio"
"encoding/json"
"fmt"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
"io"
"log"
"os"
@ -51,18 +52,31 @@ type OCNotification struct {
Params json.RawMessage `json:"params"`
}
// pendingResp 是待应答通道 + 它自己的 close 守卫。
//
// 为什么需要 once:Close() 可能被多条路径调用(Stop / 重载 / 停机),
// 而 `close(ch)` 二次即 panic(本仓 close 纪律,见 internal/safeclose)。
// 这些 channel 是**按请求创建**的,不能共用一个全局 Once ⇒ 每个自带一个。
type pendingResp struct {
ch chan []byte
once sync.Once
}
func (p *pendingResp) closeOnce() { safeclose.Safe(&p.once, p.ch) }
type sidecarProcess struct {
name string
dir string
cmd *exec.Cmd
stdin *bufio.Writer
mu sync.Mutex
nextID int
closed bool
stopped bool
readerReadyOnce sync.Once
name string
dir string
cmd *exec.Cmd
stdin *bufio.Writer
mu sync.Mutex
nextID int
closed bool
stopped bool
// 异步 reader
pending map[int]chan<- []byte
pending map[int]*pendingResp
notifyCh chan OCNotification
readerStop chan struct{}
readerWg sync.WaitGroup
@ -75,7 +89,7 @@ func newSidecarProcess(name, dir string, cmd *exec.Cmd, stdin *bufio.Writer, std
dir: dir,
cmd: cmd,
stdin: stdin,
pending: make(map[int]chan<- []byte),
pending: make(map[int]*pendingResp),
notifyCh: make(chan OCNotification, 1024),
readerStop: make(chan struct{}),
readerReady: make(chan struct{}),
@ -91,7 +105,7 @@ func (s *sidecarProcess) readLoop(r io.Reader) {
scanner := bufio.NewScanner(bufio.NewReader(r))
// 加大 scanner buffer 防止长行截断
scanner.Buffer(make([]byte, 0, 1024*64), 1024*64)
close(s.readerReady)
safeclose.Safe(&s.readerReadyOnce, s.readerReady)
for {
select {
@ -119,12 +133,11 @@ func (s *sidecarProcess) readLoop(r io.Reader) {
if base.ID != nil {
s.mu.Lock()
ch, ok := s.pending[*base.ID]
pr, ok := s.pending[*base.ID]
delete(s.pending, *base.ID)
s.mu.Unlock()
if ok {
ch <- []byte(line)
close(ch)
pr.ch <- []byte(line)
}
} else if base.Method != "" {
var notif OCNotification
@ -226,7 +239,7 @@ func (s *sidecarProcess) call(method string, params interface{}) ([]byte, error)
}
s.nextID++
id := s.nextID
s.pending[id] = ch
s.pending[id] = &pendingResp{ch: ch}
req := sidecarRequest{
JSONRPC: "2.0",
@ -324,9 +337,9 @@ func (s *sidecarProcess) CallTool(name string, args map[string]interface{}) (str
func (s *sidecarProcess) CallProvider(providerType string, args map[string]interface{}) (string, error) {
data, err := s.call("provider/call", map[string]interface{}{
"type": providerType,
"type": providerType,
"action": "execute",
"args": args,
"args": args,
})
if err != nil {
return "", err
@ -366,8 +379,8 @@ func (s *sidecarProcess) Close() {
s.readerWg.Wait()
s.mu.Lock()
for id, ch := range s.pending {
close(ch)
for id, pr := range s.pending {
pr.closeOnce()
delete(s.pending, id)
}
s.mu.Unlock()

View File

@ -15,24 +15,30 @@ type mockSettings struct{}
func (m *mockSettings) DataDir() string { return "/tmp/mock_data" }
func (m *mockSettings) Get(key string) (interface{}, error) { return nil, nil }
func (m *mockSettings) Set(key string, value interface{}) error { return nil }
func (m *mockSettings) List(prefix string) ([]string, error) { return nil, nil }
func (m *mockSettings) GetCore(key string) (interface{}, error) { return nil, nil }
func (m *mockSettings) SetCore(key string, value interface{}) error { return nil }
func (m *mockSettings) ListCore(prefix string) ([]string, error) { return nil, nil }
func (m *mockSettings) GetPlugin(plugin, key string) (interface{}, error) { return nil, nil }
func (m *mockSettings) Get(key string) (interface{}, error) { return nil, nil }
func (m *mockSettings) Set(key string, value interface{}) error { return nil }
func (m *mockSettings) SetSecret(key string, value interface{}) error { return nil }
func (m *mockSettings) GetSecret(key string) (string, error) { return "", nil }
func (m *mockSettings) List(prefix string) ([]string, error) { return nil, nil }
func (m *mockSettings) GetCore(key string) (interface{}, error) { return nil, nil }
func (m *mockSettings) SetCore(key string, value interface{}) error { return nil }
func (m *mockSettings) ListCore(prefix string) ([]string, error) { return nil, nil }
func (m *mockSettings) GetPlugin(plugin, key string) (interface{}, error) { return nil, nil }
func (m *mockSettings) SetPlugin(plugin, key string, value interface{}) error { return nil }
func (m *mockSettings) ListPlugin(plugin, prefix string) ([]string, error) { return nil, nil }
func (m *mockSettings) RegisterDef(def pubsdk.ConfigDef) {}
func (m *mockSettings) Defs(prefix string) []*pubsdk.ConfigDef { return nil }
func (m *mockSettings) Dump() map[string]interface{} { return nil }
func (m *mockSettings) Plugins() []string { return nil }
func (m *mockSettings) DefsCore(prefix string) []*sdk.ConfigDef { return nil }
func (m *mockSettings) ListPlugin(plugin, prefix string) ([]string, error) { return nil, nil }
// IsSecret:E-3 起 SettingsAPI 要求实现(判断某键是否按密文存储)。
// 判据实现返回 false(mock 里没有密文语义),够用即可。
func (m *mockSettings) IsSecret(key string) bool { return false }
func (m *mockSettings) RegisterDef(def pubsdk.ConfigDef) {}
func (m *mockSettings) Defs(prefix string) []*pubsdk.ConfigDef { return nil }
func (m *mockSettings) Dump() map[string]interface{} { return nil }
func (m *mockSettings) Plugins() []string { return nil }
func (m *mockSettings) DefsCore(prefix string) []*sdk.ConfigDef { return nil }
func (m *mockSettings) DefsPlugin(plugin, prefix string) []*sdk.ConfigDef { return nil }
func (m *mockSettings) Remove(key string) error { return nil }
func (m *mockSettings) RemoveCore(key string) error { return nil }
func (m *mockSettings) RemovePlugin(plugin, key string) error { return nil }
func (m *mockSettings) Remove(key string) error { return nil }
func (m *mockSettings) RemoveCore(key string) error { return nil }
func (m *mockSettings) RemovePlugin(plugin, key string) error { return nil }
func TestLaunchSidecarNoMainJS(t *testing.T) {
tmpDir := t.TempDir()
@ -205,8 +211,8 @@ func TestConcurrentCalls(t *testing.T) {
if err != nil {
t.Errorf("concurrent call %d: %v", n, err)
}
if result != fmt.Sprintf("%d", n + n*2) {
t.Errorf("call %d: expected %d, got %s", n, n + n*2, result)
if result != fmt.Sprintf("%d", n+n*2) {
t.Errorf("call %d: expected %d, got %s", n, n+n*2, result)
}
done <- true
}(i)

View File

@ -146,14 +146,17 @@ func TestRealPlugin_DeepSearchStatusInvoke(t *testing.T) {
// CAPTCHA 等**外部条件**,与代码是否正确无关。
//
// 此前这条测试把它们一视同仁地判红:实测失败信息是
// brave(Suspended: too many requests), duckduckgo(CAPTCHA), google cse(Suspended: ...)
//
// brave(Suspended: too many requests), duckduckgo(CAPTCHA), google cse(Suspended: ...)
//
// 于是「上游限流」被当成「搜索能力坏了」。更糟的是它**不可控地随机红**:
// 用 A/B 对照实测(同一时段连跑 20 轮)干净树也复现 2 次失败,
// 与任何代码改动无关 —— 这种判据会让真正的回归淹没在噪声里。
//
// 现在的语义:
// 上游限流/CAPTCHA ⇒ t.Skip(带明确理由,不静默通过)
// 其他异常 ⇒ t.Fatalf/Fail(真回归)
//
// 上游限流/CAPTCHA ⇒ t.Skip(带明确理由,不静默通过)
// 其他异常 ⇒ t.Fatalf/Fail(真回归)
func upstreamUnavailable(text string) bool {
// 插件只有在「无任何结果」时才输出这句;有结果时不会出现。
return strings.Contains(text, "未返回结果")

View File

@ -11,6 +11,7 @@ import (
"time"
"github.com/JianFeeeee/HomeAgent/internal/plugin"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
)
@ -42,6 +43,7 @@ func init() {
}
type Plugin struct {
stopOnce sync.Once
name string
mu sync.Mutex
reports []llmReport
@ -303,13 +305,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
func (p *Plugin) Stop() error {
// 幂等关闭:仅当 stopCh 未被关闭时 close。
p.mu.Lock()
select {
case <-p.stopCh:
default:
close(p.stopCh)
}
p.mu.Unlock()
// 用 safeclose 统一入口(本仓 close 纪律)。
// 原实现是 `select{case <-p.stopCh: default: close(...)}`:
// 单线程安全,但**并发**下两个 goroutine 可同时落到 default ⇒ 双 close panic。
safeclose.Safe(&p.stopOnce, p.stopCh)
return nil
}

View File

@ -32,7 +32,7 @@ func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.To
return nil
}
func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {}
func (tc *toolCapture) RegisterAPI(name string) error { return nil }
func (tc *toolCapture) RegisterAPI(name string) error { return nil }
func newTestSDK(cfg sdk.SDKConfig) *sdk.PluginSDK {
if cfg.Settings == nil {
@ -558,5 +558,3 @@ func TestReportsToChecks(t *testing.T) {
t.Error("fail report must NOT pass")
}
}

View File

@ -52,7 +52,7 @@ type Plugin struct {
// expose 是暴露范围(nil/空 = 全部可见)。见 scope.go。
expose *scope
// kn 是知识库句柄的测试注入口(生产从 sdkRef 取)。
kn sdk.KnowledgeAPI
kn sdk.KnowledgeAPI
server *http.Server
mux *http.ServeMux
// 启动时未显式配置 token 则自动生成(与 remotedevice 同策略)

View File

@ -14,10 +14,10 @@ import (
// MCP 服务器配置(来自 config_mcp 表或 skill.json)
type serverConfig struct {
Name string `json:"name"`
Command string `json:"command,omitempty"` // stdio 模式
Command string `json:"command,omitempty"` // stdio 模式
Args []string `json:"args,omitempty"`
Env []string `json:"env,omitempty"`
URL string `json:"url,omitempty"` // SSE 模式
URL string `json:"url,omitempty"` // SSE 模式
}
func init() {

View File

@ -4,6 +4,7 @@ import (
"bufio"
"encoding/json"
"fmt"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
"io"
"os"
"os/exec"
@ -12,13 +13,22 @@ import (
)
// StdioTransport 通过子进程 stdin/stdout 进行 JSON-RPC 通信
// pendingRPC 是待应答通道 + 它自己的 close 守卫(本仓 close 纪律)。
type pendingRPC struct {
ch chan *rpcResponse
once sync.Once
}
func (p *pendingRPC) closeOnce() { safeclose.Safe(&p.once, p.ch) }
type StdioTransport struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Reader
mu sync.Mutex
pending map[int]chan *rpcResponse
done chan struct{}
doneOnce sync.Once
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Reader
mu sync.Mutex
pending map[int]*pendingRPC
done chan struct{}
}
func NewStdioTransport(command string, args []string, env []string) (*StdioTransport, error) {
@ -46,7 +56,7 @@ func NewStdioTransport(command string, args []string, env []string) (*StdioTrans
cmd: cmd,
stdin: stdin,
stdout: bufio.NewReader(stdout),
pending: make(map[int]chan *rpcResponse),
pending: make(map[int]*pendingRPC),
done: make(chan struct{}),
}
@ -60,24 +70,24 @@ func (t *StdioTransport) readLoop() {
for {
var resp rpcResponse
if err := dec.Decode(&resp); err != nil {
close(t.done)
safeclose.Safe(&t.doneOnce, t.done)
// 通知所有等待的请求
t.mu.Lock()
for _, ch := range t.pending {
close(ch)
for _, pr := range t.pending {
pr.closeOnce()
}
t.pending = make(map[int]chan *rpcResponse)
t.pending = make(map[int]*pendingRPC)
t.mu.Unlock()
return
}
t.mu.Lock()
ch, ok := t.pending[resp.ID]
pr, ok := t.pending[resp.ID]
delete(t.pending, resp.ID)
t.mu.Unlock()
if ok {
ch <- &resp
pr.ch <- &resp
}
}
}
@ -90,7 +100,7 @@ func (t *StdioTransport) Send(req *rpcRequest) (*rpcResponse, error) {
ch := make(chan *rpcResponse, 1)
t.mu.Lock()
t.pending[req.ID] = ch
t.pending[req.ID] = &pendingRPC{ch: ch}
t.mu.Unlock()
if _, err := t.stdin.Write(data); err != nil {

View File

@ -0,0 +1,153 @@
package pluginmgr
// plugin_start/stop/enable/disable 工具的判据(2026-10-16)。
//
// 设计见 docs/zh/kernel-modularization-design.md §2.6.7.4。
//
// ## 判据重点:**工具的措辞必须让 agent 选对**
//
// 四个工具里两两一对,差别只在**是否持久**:
//
// plugin_stop vs plugin_disable (停掉 vs 停掉且以后也不自启)
// plugin_start vs plugin_enable (拉起 vs 拉起且以后开机也自启)
//
// agent 只能靠 Description 分辨。若描述不区分,agent 会在"临时关一下"
// 的场景误用 plugin_disable ⇒ 用户下次开机发现插件不自启(真实缺陷场景)。
//
// ## 判据必须走真实 registerTools
//
// 本仓教训:只查"源码里有这个字符串"会被 `if false &&` 之类的
// 写法骗过(E-3 那次实测)。所以这里真的调 registerTools 并捕获注册结果。
import (
"os"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/events"
"github.com/JianFeeeee/HomeAgent/internal/sdk"
)
// collectToolDefs 走真实 registerTools 收集工具定义。
//
// 捕获点是 `SDKConfig.RegTool`(真实注册落点),不是别处的副本 ——
// 这样"某工具被删掉/改名"必然反映在这里。
func collectToolDefs(t *testing.T) map[string]sdk.ToolDef {
t.Helper()
defs := map[string]sdk.ToolDef{}
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: &pmSettings{},
EventBus: events.NewBus(),
PluginMgr: &fakePluginMgr{},
RegTool: func(name string, def sdk.ToolDef, h sdk.ToolHandler) error {
defs[name] = def
return nil
},
})
p := &Plugin{name: "pluginmgr"}
p.registerTools(sdkInst)
if len(defs) == 0 {
t.Fatal("registerTools 未注册任何工具 —— 捕获点失效,本文件全部判据会假绿")
}
return defs
}
// ★★★ 判据:**四个生命周期工具全部注册**(走真实注册路径)。
func TestLifecycleToolsAreRegistered(t *testing.T) {
defs := collectToolDefs(t)
for _, want := range []string{"plugin_start", "plugin_stop", "plugin_enable", "plugin_disable"} {
if _, ok := defs[want]; !ok {
t.Errorf("★ 缺少工具 %s —— agent 无法%s插件", want, want)
}
}
// 原有 6 个不得因新增而丢失(回归)
for _, want := range []string{"plugin_install", "plugin_list", "plugin_status",
"plugin_restart", "plugin_remove", "plugin_info"} {
if _, ok := defs[want]; !ok {
t.Errorf("★ 原有工具 %s 丢失(回归)", want)
}
}
}
// ★★★ 判据:**运行态与持久态工具的描述必须互相点名**。
//
// 这是防止 agent 误用的**唯一手段**(工具选择完全靠描述)。
func TestRuntimeVsPersistentToolDescriptionsDisambiguate(t *testing.T) {
defs := collectToolDefs(t)
// ★ 两条**独立**断言(不能用 || 合并):
//
// 第一版写成 `!A && !B` ⇒ 去掉 A("不改持久状态")后 B("不会影响开机自启")
// 仍在 ⇒ 整条通过 ⇒ **变异没咬住**。两个措辞各有作用:
// A = 直接陈述"不改持久"(正面定义)
// B = 描述用户可观察的后果(背面印证)
// 缺任一都让 agent 更难判断,所以**两条都要在**。
stop := defs["plugin_stop"].Description
if !strings.Contains(stop, "不改持久状态") {
t.Errorf("★ plugin_stop 描述未出现\"不改持久状态\"(正面定义缺失)\n%s", stop)
}
if !strings.Contains(stop, "不会影响开机自启") {
t.Errorf("★ plugin_stop 描述未出现\"不会影响开机自启\"(后果说明缺失)\n%s", stop)
}
if !strings.Contains(stop, "plugin_disable") {
t.Errorf("★ plugin_stop 描述未指向 plugin_disable(持久关闭)作为对比\n%s", stop)
}
dis := defs["plugin_disable"].Description
if !strings.Contains(dis, "跨重启保留") && !strings.Contains(dis, "持久") {
t.Errorf("★ plugin_disable 描述未说明它是**持久**的\n%s", dis)
}
if !strings.Contains(dis, "plugin_stop") {
t.Errorf("★ plugin_disable 描述未指向 plugin_stop(临时关闭)作为对比\n%s", dis)
}
if st := defs["plugin_start"].Description; !strings.Contains(st, "持久") {
t.Errorf("★ plugin_start 描述未提持久禁用的处理\n%s", st)
}
if en := defs["plugin_enable"].Description; !strings.Contains(en, "清除") || !strings.Contains(en, "自启") {
t.Errorf("★ plugin_enable 描述未说明它会让之后开机自启\n%s", en)
}
}
// ★★★ 判据:**四个工具各自转调不同的 API**(不是别名)。
//
// 若 plugin_stop 内部转调 DisablePlugin(或反过来),两者行为相同
// ⇒ 上述描述全是谎话。这里对每个工具的 handler 做"源码块内包含期望调用"检查。
//
// ★ 为何能接受源码检查(本仓曾因此吃亏):这里检查的是**期望调用一定存在**,
// 是单向的(存在性),不会被 `if false &&` 骗过 —— 那种写法仍需写出该调用。
// 真正的行为验证在 internal/plugin 包的 runtime_state_test.go(真加载真启停)。
func TestRuntimeToolsCallDistinctAPIs(t *testing.T) {
src := readPluginSource(t)
cases := []struct{ tool, want string }{
{"plugin_start", "mgr.StartPlugin("},
{"plugin_stop", "mgr.StopPlugin("},
{"plugin_enable", "mgr.EnablePlugin("},
{"plugin_disable", "mgr.DisablePlugin("},
}
for _, c := range cases {
idx := strings.Index(src, `s.RegisterTool("`+c.tool+`"`)
if idx < 0 {
t.Fatalf("未找到 %s 的注册", c.tool)
}
rest := src[idx:]
// 截到下一个 RegisterTool
if nxt := strings.Index(rest[len(c.tool)+20:], `s.RegisterTool(`); nxt >= 0 {
rest = rest[:len(c.tool)+20+nxt]
}
if !strings.Contains(rest, c.want) {
t.Errorf("★ %s 的 handler 未调用 %s —— 可能误指向另一个 API(行为与描述不符)",
c.tool, c.want)
}
}
}
// readPluginSource 读本包源码(判据的静态检查用)。
func readPluginSource(t *testing.T) string {
t.Helper()
b, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatalf("读 plugin.go: %v", err)
}
return string(b)
}

View File

@ -0,0 +1,325 @@
package pluginmgr
// 插件包大小上限的判据。
//
// 核心命题(本次修复的实质):
// ① 上限**可配置**(而非硬编码 100MB)—— 内嵌运行时的包能装进来
// ② 上限在**读入内存之前**生效 —— 否则限制等于没有
// ③ 0 = 不限制
//
// ★ ② 是这次真正的修复。原实现在 io.ReadAll 之后才判大小,
// 一个声明超大 Content-Length 的请求会先把内存吃满再收到"包太大"。
// 判据必须能咬住"位置"这个属性,而不只是"超限会被拒绝"。
import (
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/events"
"github.com/JianFeeeee/HomeAgent/internal/sdk"
)
// countingReader 记录**实际被读走了多少字节**。
//
// 这是判断"限流在读前生效"的关键工具:若实现先全量读再检查,
// 计数会等于源总长;若在读中限流,计数只到 limit+1 就停。
type countingReader struct {
r io.Reader
read int64
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.read += int64(n)
return n, err
}
// ★★★ 判据:超限时**没有把整个 source 读进内存**。
//
// 这条咬的是"防线的位置",是本次修复的核心性质。
func TestPackageLimitStopsBeforeReadingEverything(t *testing.T) {
const limit = 1024
// 源远大于 limit(1MB)
source := bytes.Repeat([]byte("A"), 1<<20)
cr := &countingReader{r: bytes.NewReader(source)}
_, err := readLimited(cr, limit)
if err == nil {
t.Fatal("超限应报错")
}
// ★ 关键断言:读取量必须远小于源大小
// (允许多读 1 字节用于边界判定)
if cr.read > limit+1 {
t.Fatalf("★ 超限时读了 %d 字节(源 %d,上限 %d)—— 说明是先全量读再检查,防线位置错了",
cr.read, len(source), limit)
}
}
// ★★ 判据:恰好等于上限的包**不被误拒**(边界差一)。
func TestPackageLimitBoundaryIsInclusive(t *testing.T) {
const limit = 512
exact := bytes.Repeat([]byte("A"), limit)
if _, err := readLimited(bytes.NewReader(exact), limit); err != nil {
t.Errorf("恰好等于上限应通过: %v", err)
}
over := bytes.Repeat([]byte("A"), limit+1)
if _, err := readLimited(bytes.NewReader(over), limit); err == nil {
t.Error("超过上限 1 字节应被拒")
}
}
// ★★ 判据:0 = 不限制(内嵌运行时可能要更大的包)。
func TestPackageLimitZeroMeansUnlimited(t *testing.T) {
big := bytes.Repeat([]byte("A"), 4<<20) // 4MB
got, err := readLimited(bytes.NewReader(big), 0)
if err != nil {
t.Fatalf("0 应为不限制: %v", err)
}
if len(got) != len(big) {
t.Errorf("不限时长度应一致: %d vs %d", len(got), len(big))
}
// 负数同样视为不限(防御式:配置项误填负数不该变成"拒一切")
if _, err := readLimited(bytes.NewReader(big), -1); err != nil {
t.Errorf("负数应视为不限制: %v", err)
}
}
// ★★ 判据:默认上限**足够容纳内嵌运行时并留出真实余量**。
//
// 实测数据(2026-10-15,commondev 内嵌 node+pi):
// - 包大小 103,795,462 字节 = 99.0 MiB
// - 旧的硬编码上限 100<<20 = 104,857,600 字节
// ⇒ 余量仅 1,062,138 字节(1.01 MiB)
//
// ★ 这条判据的措辞经过修正。我最初写的是"内嵌运行时需要 >100MB,
// 所以旧上限会挡住它"——那是**错的**:实测包 99.0 MiB 并未超过
// 100 MiB。我自己写的对照项判据(TestRealEmbeddedRuntimeRejectedByOldLimit)
// 把这一点咬了出来。
//
// 所以真实情况是:旧上限**没挡住当前这个包**,但它只剩 1.01 MiB 余量 ——
// 任何新增一个资源文件就会爆。放宽到 512MB 的意义是把"随时会爆"
// 变成"有合理空间",而不是"从装不进变成装得进"。
func TestDefaultLimitLeavesRealHeadroom(t *testing.T) {
const observedPackageBytes = 103795462 // 实测 commondev 包
limit := int64(defaultMaxPackageMB) << 20
if limit <= observedPackageBytes {
t.Fatalf("★ 默认上限 %dMB 装不下实测的内嵌运行时包(%.1f MiB)",
defaultMaxPackageMB, float64(observedPackageBytes)/(1<<20))
}
// 余量要求:至少 2 倍于实测包大小(否则"随时会爆"的问题没解决)
if limit < observedPackageBytes*2 {
t.Errorf("★ 余量不足:上限 %dMB 对实测 %.1f MiB 的包余量不够(应至少 2 倍)",
defaultMaxPackageMB, float64(observedPackageBytes)/(1<<20))
}
}
// ★★★ 判据:HTTP 上传超限返回 **413**,且错误信息给出可执行提示。
//
// 413 而非 400/500:这是语义正确的状态码,调用方能据此区分
// "包太大"与"包损坏"。
func TestHTTPUploadOverLimitReturns413(t *testing.T) {
p := &Plugin{maxPackageBytes: 1024} // 故意设小
body := bytes.Repeat([]byte("A"), 4096)
req := httptest.NewRequest(http.MethodPost, "/plugins", bytes.NewReader(body))
rec := httptest.NewRecorder()
// 直接调用 handler(真实入口,不是 helper)
req.Body = http.MaxBytesReader(rec, req.Body, 1<<30) // 不干扰本判据
p.handlePlugins(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("★ 超限应返回 413,实际 %d(body=%s)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "max_package_mb") {
t.Errorf("错误信息应指出如何放宽(max_package_mb): %s", rec.Body.String())
}
}
// ★★ 判据:本地文件路径也执行上限(先 Stat 快速拒绝)。
func TestInstallFromPathEnforcesLimit(t *testing.T) {
dir := t.TempDir()
pth := filepath.Join(dir, "big.hmap")
if err := os.WriteFile(pth, bytes.Repeat([]byte("A"), 4096), 0o644); err != nil {
t.Fatal(err)
}
p := &Plugin{maxPackageBytes: 1024}
_, err := p.installFromPath(pth, false)
if err == nil {
t.Fatal("本地文件超限应报错")
}
if !strings.Contains(err.Error(), "too large") {
t.Errorf("应为大小错误,实际: %v", err)
}
}
// ★★ 判据:放宽上限后,**真正约 100MB 的包能装进来** —— 本次的目的。
//
// 用"读入 + 校验"而非完整安装:完整安装要写盘,成本高且与本判据无关。
// 这里钉住的是"读入阶段不再拦它"。
func TestLargePackagePassesWhenLimitRaised(t *testing.T) {
const size = 101 << 20 // 101MB,旧上限下必被拒
// 用惰性 reader 避免真的分配 101MB
lr := io.LimitReader(zeroReader{}, size)
p := &Plugin{maxPackageBytes: int64(defaultMaxPackageMB) << 20}
data, err := readLimited(lr, p.maxPackageBytes)
if err != nil {
t.Fatalf("★ 放宽后 101MB 包仍被拒: %v", err)
}
if int64(len(data)) != size {
t.Fatalf("大小不对: %d", len(data))
}
// 对照:旧的 100MB 硬编码上限会拒它
if _, err := readLimited(io.LimitReader(zeroReader{}, size), 100<<20); err == nil {
t.Error("对照项:100MB 上限应拒绝 101MB 包")
}
}
// zeroReader 无限供应零字节(惰性,不占内存)。
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 0
}
return len(p), nil
}
// ★ 判据:错误类型可判定(供上层区分"太大"与其他失败)。
func TestTooLargeErrorIsIdentifiable(t *testing.T) {
err := errPackageTooLarge(42 << 20)
if err == nil {
t.Fatal("应返回错误")
}
if !strings.Contains(err.Error(), "42MB") {
t.Errorf("错误应包含上限值: %v", err)
}
// 不应是 io.EOF 之类的伪装
if errors.Is(err, io.EOF) {
t.Error("不应是 io.EOF")
}
}
// ★★★ 判据:配置值按**真实形态**(string)被解析。
//
// 这是本仓反复出现的坑:配置从 DB 读出来是字符串
// (config.PluginSettings.Get → openFromStorage 返回 string)。
// 初版照抄 agentcli 的 `v.(float64)` 假设,switch 全不匹配 ⇒
// **配置被静默忽略** —— 用户改了上限却不生效,且无任何提示。
//
// 判据的形态很重要:**必须喂 string**。如果这里喂 int,
// 判据会绿,而生产仍然不生效。
func TestLimitConfigParsedFromString(t *testing.T) {
cases := []struct {
in interface{}
want int64 // MB
desc string
}{
{"64", 64, "字符串(真实形态)"},
{"0", 0, "0 = 不限制"},
{" 128 ", 128, "带空白的字符串"},
{"-5", 0, "负数视为不限制(不是拒一切)"},
{int(256), 256, "int(兼容)"},
{int64(300), 300, "int64(兼容)"},
{float64(32), 32, "float64(兼容)"},
}
for _, c := range cases {
got := parseLimitMB(c.in)
if got != c.want<<20 {
t.Errorf("parseLimitMB(%#v) [%s] = %d 字节,期望 %dMB", c.in, c.desc, got, c.want)
}
}
// 非法输入回退默认(不静默变成 0=不限,那会悄悄关掉防护)
if got := parseLimitMB("abc"); got != int64(defaultMaxPackageMB)<<20 {
t.Errorf("非法输入应回退默认,实际 %d", got)
}
if got := parseLimitMB(""); got != int64(defaultMaxPackageMB)<<20 {
t.Errorf("空串应回退默认,实际 %d", got)
}
if got := parseLimitMB(struct{}{}); got != int64(defaultMaxPackageMB)<<20 {
t.Errorf("意外类型应回退默认,实际 %d", got)
}
}
// limitSettings 是能返回**真实形态**(string)配置值的 settings mock。
//
// 用真实 SettingsAPI 接口,走 p.Start() 这条生产路径,
// 从而验证"配置值 → p.maxPackageBytes"的完整链路。
type limitSettings struct {
pmSettings
maxMB string
dir string
}
func (s *limitSettings) Get(key string) (interface{}, error) {
if key == "max_package_mb" {
// ★ 返回 string:这是 config.PluginSettings.Get 的真实形态
// (openFromStorage 返回 string)。返回 int 会让判据失去意义。
return s.maxMB, nil
}
return nil, nil
}
func (s *limitSettings) GetCore(key string) (interface{}, error) {
if key == "plugin.dir" {
return s.dir, nil
}
return nil, nil
}
func (s *limitSettings) RegisterDef(sdk.ConfigDef) {}
// ★★★ 判据:配置项经 **真实 Start()** 生效。
//
// 这条覆盖的是我初版真正出错的环节:Get 返回 string,
// 而代码按 float64 断言 ⇒ switch 全不匹配 ⇒ 配置被静默忽略。
// 上面的 parseLimitMB 单测也喂 string,但只有走 Start() 才能证明
// "装配路径确实把 string 交给了它"。
func TestLimitConfigAppliedThroughStart(t *testing.T) {
dir := t.TempDir()
for _, tc := range []struct {
cfg string
want int64
}{
{"64", 64 << 20},
{"0", 0},
{"-1", 0},
} {
s := &limitSettings{maxMB: tc.cfg, dir: dir}
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: s,
EventBus: events.NewBus(),
})
p := &Plugin{name: "pluginmgr", mux: http.NewServeMux(), httpAddr: ""}
if err := p.Start(sdkInst); err != nil {
// HTTP 关闭(httpAddr 空)时 Start 仍应成功
t.Fatalf("Start 失败: %v", err)
}
if p.maxPackageBytes != tc.want {
t.Errorf("★ max_package_mb=%q 未生效: got %d, want %d(配置被静默忽略)",
tc.cfg, p.maxPackageBytes, tc.want)
}
}
}
// ★★ 判据:**未配置时用默认值**(不能变成 0=不限而悄悄关掉防护)。
func TestLimitDefaultsWhenNotConfigured(t *testing.T) {
s := &limitSettings{maxMB: "", dir: t.TempDir()}
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: s,
EventBus: events.NewBus(),
})
p := &Plugin{name: "pluginmgr", mux: http.NewServeMux(), httpAddr: ""}
if err := p.Start(sdkInst); err != nil {
t.Fatal(err)
}
if p.maxPackageBytes != int64(defaultMaxPackageMB)<<20 {
t.Errorf("★ 未配置时应为默认 %dMB,实际 %d 字节(可能被误设为不限)",
defaultMaxPackageMB, p.maxPackageBytes)
}
}

View File

@ -95,6 +95,12 @@ type Plugin struct {
httpAddr string // 本实例的监听地址(默认 defaultHTTPAddr;来自 settings)
sdk *sdk.PluginSDK
pluginDir string
// maxPackageBytes 是单个插件包 **压缩后** 的上限(0 = 不限)。
//
// 为什么是配置项而不是常量:内嵌运行时(node+pi 之类)就要 ~100MB,
// 硬编码一个数必然在某天成为纯粹的障碍。默认值给得很宽松(见
// defaultMaxPackageMB),有需要的部署可以调大或设为 0 关闭。
maxPackageBytes int64
}
func New(name string) *Plugin {
@ -116,6 +122,18 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
Category: "pluginmgr",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "max_package_mb",
Default: defaultMaxPackageMB,
Type: "int",
DisplayName: "插件包大小上限 (MB)",
Description: "单个插件包**压缩后**的字节上限;0 = 不限制。" +
"内嵌运行时(node/pi 等)的包实测约 99MB,旧上限 100MB 下余量仅 1MB —— " +
"需要更大就调高,或设 0 关闭限制。" +
"该值在**读入内存之前**生效,不是事后检查。",
Category: "pluginmgr",
})
// 只写本实例字段,**不写任何包级状态**(见 defaultHTTPAddr 注释)。
p.httpAddr = defaultHTTPAddr
if v, _ := s.Settings().Get("http_addr"); v != nil {
@ -124,14 +142,44 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
}
if v, _ := s.Settings().GetCore("plugin.dir"); v != nil {
if dir, ok := v.(string); ok && dir != "" {
// ★ pluginDir 的正确来源是**内核**(PluginMgrAPI.PluginDir),
// 不是 core.plugin.dir 配置项。
//
// 实测教训(2026-10-15):用 GetCore("plugin.dir") 读时,生产 config.db
// 里**根本没有这个键**(内核只是"播种"默认值,不一定落库),于是
// p.pluginDir 为空 —— 而所有以它为起点的功能(技能重扫、删除插件目录)
// 会**静默什么都不做**。安装却照常成功(那条路径用的是自己算的 dir),
// 所以症状是"局部静默失效",极难发现。
//
// 先问内核,配置项只作兼容回退。
if mgr := p.pluginMgr(); mgr != nil {
if dir := strings.TrimSpace(mgr.PluginDir()); dir != "" {
p.pluginDir = dir
}
}
if p.pluginDir == "" {
if v, _ := s.Settings().GetCore("plugin.dir"); v != nil {
if dir, ok := v.(string); ok && dir != "" {
p.pluginDir = dir
}
}
}
// 上限也走"先给默认值再覆盖",与本文件其他配置项一致
// (避免 settings 尚未就绪时拿到 0 而变成"无限制")。
p.maxPackageBytes = int64(defaultMaxPackageMB) << 20
if v, _ := s.Settings().Get("max_package_mb"); v != nil {
p.maxPackageBytes = parseLimitMB(v)
}
p.registerTools(s)
// ★ 启动重扫随包 skill:移交只在**安装那一刻**发生,而 skill 落在
// 插件目录里(不在 skillmgr 的 skillsDir)⇒ worker 重启后它会**静默消失**。
// 延迟几秒是为了等 skillmgr 的订阅就绪(两者 Start 顺序不确定);
// skillmgr 的 loadOne 幂等,重复到达无副作用。见 skill_handout.go。
p.scheduleStartupSkillRescan()
if p.httpAddr != "" {
p.startHTTPServer()
}
@ -245,6 +293,160 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
return p.restartPlugin(name)
})
// ---- 持久启用/禁用(设计 §2.6.7.4)----
//
// 与 plugin_start/stop 的分工:
// enable/disable ⇒ **持久**(写 disabled_plugins,跨重启保留)= 系统里"禁止运行"
// start/stop ⇒ **运行态**(不写配置) = 现在关掉这个窗口
s.RegisterTool("plugin_disable", sdk.ToolDef{
Name: "plugin_disable",
Description: "**持久**禁用插件:停止它并写入禁用配置,**跨重启保留**(开机也不再自启)。" +
"如果只是想临时关掉它、不希望影响开机自启,请用 plugin_stop。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string",
"description": "插件名称",
},
},
"required": []string{"name"},
},
Serial: true,
}, func(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if name == "" {
return map[string]interface{}{"error": "name is required"}, nil
}
mgr := p.pluginMgr()
if mgr == nil {
return nil, fmt.Errorf("内核插件管理面不可用")
}
if err := mgr.DisablePlugin(name, "agent"); err != nil {
return nil, err
}
return map[string]interface{}{
"status": "disabled",
"name": name,
"persistent": "已写入禁用配置(跨重启保留)",
}, nil
})
s.RegisterTool("plugin_enable", sdk.ToolDef{
Name: "plugin_enable",
Description: "**持久**启用插件:解除禁用配置并立即加载它(跨重启保留)。" +
"注意:它同时**清除禁用标记**,所以之后开机会自启;" +
"若只是想把 plugin_stop 关掉的插件开回来,用 plugin_start 更精确。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string",
"description": "插件名称",
},
},
"required": []string{"name"},
},
Serial: true,
}, func(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if name == "" {
return map[string]interface{}{"error": "name is required"}, nil
}
mgr := p.pluginMgr()
if mgr == nil {
return nil, fmt.Errorf("内核插件管理面不可用")
}
if err := mgr.EnablePlugin(name); err != nil {
return nil, err
}
return map[string]interface{}{
"status": "enabled",
"name": name,
"persistent": "禁用标记已清除(之后开机会自启)",
}, nil
})
// ---- 运行态启停(设计 §2.6.7.4)----
//
// ★ 与 plugin_enable/plugin_disable 的分工(用户明确指出"只有 reload"的缺口):
//
// plugin_enable/disable ⇒ **持久**:写 disabled_plugins,跨重启保留
// plugin_start/stop ⇒ **运行态**:现在启停,不写任何配置
//
// 缺了这两个的直接后果:agent 想"临时关掉某插件看现象"只能调
// plugin_disable ⇒ 顺带把开机自启也关了 ⇒ 用户下次开机发现"怎么不启动了"。
s.RegisterTool("plugin_start", sdk.ToolDef{
Name: "plugin_start",
Description: "**运行态**启动一个插件:现在拉起它,**不改持久状态**(重启后仍按" +
"开机自启/禁用配置决定)。用于把 plugin_stop 关掉的插件开回来。" +
"注意:被**持久禁用**的插件会被拒绝,那种情况请用 plugin_enable。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string",
"description": "插件名称",
},
},
"required": []string{"name"},
},
Serial: true,
}, func(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if name == "" {
return map[string]interface{}{"error": "name is required"}, nil
}
mgr := p.pluginMgr()
if mgr == nil {
return nil, fmt.Errorf("内核插件管理面不可用")
}
if err := mgr.StartPlugin(name); err != nil {
return nil, err
}
return map[string]interface{}{
"status": "started",
"name": name,
// ★ 明确告知这是运行态操作,避免 agent 误以为改了持久设置
"persistent": "未改动(重启后按开机自启/禁用配置决定)",
}, nil
})
s.RegisterTool("plugin_stop", sdk.ToolDef{
Name: "plugin_stop",
Description: "**运行态**关闭一个插件:现在停掉它,**不改持久状态**" +
"(不会影响开机自启,重启后该插件正常加载)。用于临时腾出资源或隔离问题。" +
"与 plugin_disable 不同 —— 后者会**持久**禁用(跨重启保留)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string",
"description": "插件名称",
},
},
"required": []string{"name"},
},
Serial: true,
}, func(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if name == "" {
return map[string]interface{}{"error": "name is required"}, nil
}
mgr := p.pluginMgr()
if mgr == nil {
return nil, fmt.Errorf("内核插件管理面不可用")
}
if err := mgr.StopPlugin(name); err != nil {
return nil, err
}
return map[string]interface{}{
"status": "stopped",
"name": name,
"persistent": "未改动(开机自启设置不变,重启后会重新加载)",
}, nil
})
s.RegisterTool("plugin_remove", sdk.ToolDef{
Name: "plugin_remove",
Description: "卸载一个已安装的外部插件",
@ -370,9 +572,9 @@ func (p *Plugin) handlePlugins(w http.ResponseWriter, r *http.Request) {
return
}
} else {
data, err := io.ReadAll(r.Body)
data, err := readLimited(r.Body, p.maxPackageBytes)
if err != nil {
http.Error(w, "read body: "+err.Error(), http.StatusBadRequest)
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
return
}
result, err := p.installFromData(data, false)
@ -430,10 +632,22 @@ func (p *Plugin) handlePluginByID(w http.ResponseWriter, r *http.Request) {
// ======== Core Logic ========
func (p *Plugin) installFromPath(path string, overwrite bool) (interface{}, error) {
data, err := os.ReadFile(path)
// 本地文件同样走限流读:先 Stat 拿大小快速拒绝(避免白读一遍),
// 再用 readLimited 兜住"Stat 与读取之间文件变大"的竞态。
if p.maxPackageBytes > 0 {
if st, err := os.Stat(path); err == nil && st.Size() > p.maxPackageBytes {
return nil, errPackageTooLarge(p.maxPackageBytes)
}
}
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
defer f.Close()
data, err := readLimited(f, p.maxPackageBytes)
if err != nil {
return nil, err
}
return p.installFromData(data, overwrite)
}
@ -458,9 +672,14 @@ func (p *Plugin) installFromURL(rawURL string, overwrite bool) (interface{}, err
return nil, fmt.Errorf("download returned %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
// 先用 Content-Length 快速拒绝(覆盖绝大多数情况),再用 readLimited
// 兜住分块传输(Content-Length 缺失或撒谎)。
if p.maxPackageBytes > 0 && resp.ContentLength > p.maxPackageBytes {
return nil, errPackageTooLarge(p.maxPackageBytes)
}
data, err := readLimited(resp.Body, p.maxPackageBytes)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
return nil, err
}
result, err := p.installFromData(data, overwrite)
@ -548,6 +767,10 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
}
os.RemoveAll(backup)
// ★ 随包 skill 移交(升级路径):解压完成后才扫,否则目录还不完整。
// 失败不阻断 —— skill 登记是附带收益,插件本身已装好。
skillN, skillFailed := p.handOutPluginSkills(target, pkg.Name)
checksum := fmt.Sprintf("%x", sha256.Sum256(data))
action := "upgraded"
if cmpVersion(pkg.Version, oldVersion) < 0 {
@ -556,15 +779,17 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
action = "reinstalled"
}
return map[string]interface{}{
"status": "installed",
"name": pkg.Name,
"version": pkg.Version,
"previous_version": oldVersion,
"entry": pkg.Entry,
"checksum": checksum,
"action": action,
"reload_required": true,
"config_kept": true,
"status": "installed",
"name": pkg.Name,
"version": pkg.Version,
"previous_version": oldVersion,
"entry": pkg.Entry,
"checksum": checksum,
"action": action,
"reload_required": true,
"config_kept": true,
"skills_registered": skillN,
"skills_failed": skillFailed,
}, nil
}
@ -577,13 +802,18 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
checksum := fmt.Sprintf("%x", sha256.Sum256(data))
// ★ 随包 skill 移交(全新安装路径)。
skillN, skillFailed := p.handOutPluginSkills(target, pkg.Name)
return map[string]interface{}{
"status": "installed",
"name": pkg.Name,
"version": pkg.Version,
"entry": pkg.Entry,
"checksum": checksum,
"action": "reload_required",
"status": "installed",
"name": pkg.Name,
"version": pkg.Version,
"entry": pkg.Entry,
"checksum": checksum,
"action": "reload_required",
"skills_registered": skillN,
"skills_failed": skillFailed,
}, nil
}
@ -835,23 +1065,123 @@ type pluginPackage struct {
Platforms []string `json:"platforms,omitempty"`
}
func validatePackage(data []byte) (*pluginPackage, error) {
if len(data) > 100<<20 {
return nil, fmt.Errorf("package too large (>100MB)")
}
const (
// defaultMaxPackageMB 是插件包默认上限(MB)。
//
// 为什么给这么大:内嵌运行时的插件包本身就接近 100MB
// (node 二进制约 50MB + pi 依赖闭包约 50MB,压缩后)。
// 这个值只作为"防止手滑传错文件"的兜底,真正的 DoS 防护
// 由 readLimited 在**读取前**按 Content-Length / LimitReader 完成。
//
// 需要更大(或不需要限制)时改插件设置 max_package_mb(0 = 不限)。
defaultMaxPackageMB = 512
)
// errPackageTooLarge 是统一的超限错误(三种入口共用)。
func errPackageTooLarge(limit int64) error {
return fmt.Errorf("package too large (>%dMB);如确需更大,请调整插件设置 max_package_mb(0 = 不限)", limit>>20)
}
// parseLimitMB 把配置值解析成字节上限(0 = 不限)。
//
// ★ 必须处理 string:插件配置从 DB 读出来是**字符串**
// (config.PluginSettings.Get → openFromStorage 返回 string),
// 不是 int/float64。初版照抄了 agentcli 的 `v.(float64)` 假设,
// 于是 switch 一个分支都不匹配 ⇒ **配置被静默忽略**,
// 用户改了 max_package_mb 却不生效,且没有任何提示。
//
// 兼容其它形态是为了稳健(不同装配路径可能给出不同静态类型),
// 但 string 是真实路径,必须有判据钉住。
func parseLimitMB(v interface{}) int64 {
var mb int64
switch n := v.(type) {
case string:
s := strings.TrimSpace(n)
if s == "" {
return int64(defaultMaxPackageMB) << 20
}
parsed, err := strconv.ParseInt(s, 10, 64)
if err != nil {
log.Printf("[pluginmgr] max_package_mb=%q 不是整数,回退默认 %dMB", s, defaultMaxPackageMB)
return int64(defaultMaxPackageMB) << 20
}
mb = parsed
case int:
mb = int64(n)
case int64:
mb = n
case float64:
mb = int64(n)
default:
log.Printf("[pluginmgr] max_package_mb 类型意外(%T),回退默认 %dMB", v, defaultMaxPackageMB)
return int64(defaultMaxPackageMB) << 20
}
if mb < 0 {
// 负数视为"不限"(与 0 同义),而不是"拒一切" ——
// 配置误填负号不该让插件系统完全不可用。
mb = 0
}
return mb << 20
}
// readLimited 读入 reader 并在 **读取过程中** 施加上限。
//
// ★ 为什么必须在这里做,而不是读完再检查:
//
// 原实现在 io.ReadAll 之后才 `len(data) > 100<<20`。那时内存**已经占用**,
// 一个声明 `Content-Length: 10GB` 的请求可以先把进程撑爆再收到"包太大"的报错 ——
// 限制形同虚设。读前限流才是有意义的防护(也是本次把上限变成配置项的前提:
// 放宽一个无效检查不会带来新风险,放寛一个有效检查才会)。
//
// limit <= 0 表示不限制。
func readLimited(r io.Reader, limit int64) ([]byte, error) {
if limit <= 0 {
return io.ReadAll(r)
}
// 多读 1 字节:能读到 limit+1 才说明确实超了,
// 否则恰好等于 limit 的包会被误判(边界差一)。
lr := io.LimitReader(r, limit+1)
data, err := io.ReadAll(lr)
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, errPackageTooLarge(limit)
}
return data, nil
}
// validatePackage 只做**结构合法性**校验(元数据、平台二进制、路径安全)。
//
// 大小不在这里判:它必须在读入内存之前决定,见 readLimited。
// (曾经这里有一个 `len(data) > 100<<20` 的检查,属于"事后检查",
// 挡不住任何东西却会在有一天需要大包时成为障碍 —— 已移除。)
func validatePackage(data []byte) (*pluginPackage, error) {
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("invalid zip: %w", err)
}
// ★ 路径检查必须遍历**全部**条目,与找 manifest 分开。
//
// 原实现把路径检查放在寻找 plugin.json 的循环里,而该循环找到
// manifest 后会 `break` —— 于是排在 plugin.json **之后**的条目
// 完全没被检查。hmapdev 打包出的 rawfile 条目恒在 plugin.json 之后,
// 正好落在检查盲区(实测发现)。
//
// 落盘侧(extractPackage)另有独立的 path traversal 检查,
// 所以这不是可利用漏洞;但"校验函数只校验了一部分还叫校验"本身就是缺陷,
// 且一旦将来有人复用 validatePackage 做准入判断就会出事。
for _, f := range reader.File {
if strings.Contains(f.Name, "..") || strings.HasPrefix(f.Name, "/") {
return nil, fmt.Errorf("invalid path: %s", f.Name)
}
}
var pkg pluginPackage
hasManifest := false
for _, f := range reader.File {
if strings.Contains(f.Name, "..") || strings.HasPrefix(f.Name, "/") {
return nil, fmt.Errorf("invalid path: %s", f.Name)
}
if f.FileInfo().IsDir() {
continue
}

View File

@ -0,0 +1,157 @@
package pluginmgr
// rawfile 与安装链路的端到端判据。
//
// 目标:**用内核自己的 extractPackage**(不是测试里另写一份解包)
// 处理一个真实带 rawfile 的 .hmap,验证资源落在 <插件目录>/rawfile/ 下
// 且保持结构 —— 这正是插件运行期 RawFilesAPI 会去读的位置。
import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// buildHmapWithRaw 造一个最小可安装包(含 plugin.json + 假二进制 + rawfile 条目)。
func buildHmapWithRaw(t *testing.T, rawEntries map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
add := func(name, content string) {
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
fw, err := w.CreateHeader(hdr)
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
add("plugin.json", `{"name":"rawtest","version":"1.0.0","entry":"plugin.bin","platforms":["linux/amd64"]}`)
add("plugin.bin.linux.amd64", "#!/bin/sh\necho fake\n")
for name, content := range rawEntries {
add(name, content)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// ★★ 判据:安装后资源落在 <插件目录>/rawfile/,**保持嵌套结构**。
func TestInstallPreservesRawFileStructure(t *testing.T) {
data := buildHmapWithRaw(t, map[string]string{
"rawfile/node/bin/node": "fake-node",
"rawfile/pi/node_modules/x/index.js": "module.exports=1",
"rawfile/main/prompt.txt": "you are a dev",
})
dir := t.TempDir()
if err := extractPackage(data, dir); err != nil {
t.Fatalf("extractPackage 失败: %v", err)
}
base := filepath.Join(dir, "rawtest")
for _, rel := range []string{
"rawfile/node/bin/node",
"rawfile/pi/node_modules/x/index.js",
"rawfile/main/prompt.txt",
} {
p := filepath.Join(base, rel)
if _, err := os.Stat(p); err != nil {
// 若被拍平,文件会在 rawfile/ 下以 basename 出现 —— 报清楚
t.Errorf("★ 安装后资源不在预期路径 %s: %v", rel, err)
}
}
// 明确检查"没有拍平"
if _, err := os.Stat(filepath.Join(base, "rawfile", "node")); err != nil {
t.Errorf("★ 目录结构被拍平(rawfile/node 不存在): %v", err)
}
// 内容正确
b, err := os.ReadFile(filepath.Join(base, "rawfile/main/prompt.txt"))
if err != nil || string(b) != "you are a dev" {
t.Errorf("内容不对: %q err=%v", b, err)
}
}
// ★★ 判据:可执行位在安装后保留(内嵌 node/pi 必须能被执行)。
func TestInstallPreservesExecutableBit(t *testing.T) {
var buf bytes.Buffer
w := zip.NewWriter(&buf)
add := func(name, content string, mode os.FileMode) {
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
hdr.SetMode(mode)
fw, _ := w.CreateHeader(hdr)
fw.Write([]byte(content))
}
add("plugin.json", `{"name":"rawtest2","version":"1.0.0","entry":"plugin.bin","platforms":["linux/amd64"]}`, 0o644)
add("plugin.bin.linux.amd64", "#!/bin/sh\n", 0o755)
add("rawfile/node/bin/node", "#!/bin/sh\necho ok\n", 0o755)
if err := w.Close(); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := extractPackage(buf.Bytes(), dir); err != nil {
t.Fatal(err)
}
st, err := os.Stat(filepath.Join(dir, "rawtest2", "rawfile/node/bin/node"))
if err != nil {
t.Fatal(err)
}
if st.Mode()&0o111 == 0 {
t.Errorf("★ 可执行位丢失: %v(内嵌 node 将无法执行)", st.Mode())
}
}
// ★ 判据:rawfile 条目不能绕过 validatePackage 的路径安全检查。
func TestRawFileEntriesCannotEscapePackage(t *testing.T) {
// 恶意包:条目名带 ..(zip-slip)
var buf bytes.Buffer
w := zip.NewWriter(&buf)
add := func(name, content string) {
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
fw, _ := w.CreateHeader(hdr)
fw.Write([]byte(content))
}
add("plugin.json", `{"name":"evil","version":"1.0.0","entry":"plugin.bin","platforms":["linux/amd64"]}`)
add("plugin.bin.linux.amd64", "#!/bin/sh\n")
add("rawfile/../../../etc/evil.sh", "pwned")
if err := w.Close(); err != nil {
t.Fatal(err)
}
// ★★ 结论(实测澄清):**真正挡住它的是 extractPackage,不是 validatePackage**。
//
// validatePackage 的条目循环在读到 plugin.json 后 **break**,
// 于是排在 plugin.json **之后**的条目根本没做路径检查 ——
// 而 hmapdev 打包出的 rawfile 条目恒在 plugin.json 之后。
//
// 但解包有两个独立入口:
// ① validatePackage —— 元数据/平台校验(有早退,路径检查不完整)
// ② extractPackage —— 真正落盘(遍历**全部**条目,有 path traversal 检查)
// 所以不存在可被利用的漏洞;这里钉住的是 **② 必须始终挡住**。
//
// ★ 教训:判据不能只断言 err != nil。初版这样写"通过"了,
// 但理由其实是 "no valid binary found"(我造的包缺二进制)——
// 也就是说判据在**没有路径保护时照样通过**,完全没验证目标性质。
data := buf.Bytes()
// ① 准入校验必须**以路径理由**拒绝(修掉早退盲区后为真)
if _, err := validatePackage(data); err == nil {
t.Fatal("含 ../ 的包应被拒")
} else if !strings.Contains(err.Error(), "invalid path") {
t.Errorf("★ validatePackage 未以路径理由拒绝(实际: %v)—— 路径检查被 manifest 早退跳过了", err)
}
// 真正要钉的是解包路径
dir := t.TempDir()
err := extractPackage(data, dir)
if err == nil {
t.Fatal("★ extractPackage 未挡住路径穿越条目")
}
if !strings.Contains(err.Error(), "traversal") {
t.Fatalf("★ 拒绝理由不是路径穿越(实际: %v)—— 该用例没走到安全检查", err)
}
// 确认没有文件落到插件目录之外
if _, serr := os.Stat(filepath.Join(dir, "evil.sh")); serr == nil {
t.Error("★ 恶意文件落到了插件目录下")
}
}

View File

@ -0,0 +1,101 @@
package pluginmgr
// ★★★ 真实大包安装的端到端判据。
//
// 用**内核自己的 installFromData** 装一个真实约 99MB 的 .hmap
// (commondev 内嵌 node+pi 的产物)。这条判据回答的是用户的问题:
// "解除限制后,内嵌运行时真能装进来吗?"
import (
"os"
"path/filepath"
"testing"
)
func TestRealEmbeddedRuntimePackageInstalls(t *testing.T) {
src := os.Getenv("HA_RAWFILE_E2E_HMAP")
if src == "" {
t.Skip("设 HA_RAWFILE_E2E_HMAP=<hmap路径> 以运行")
}
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("读包失败: %v", err)
}
t.Logf("真实包大小: %.1f MiB", float64(len(data))/(1<<20))
// ★ 旧上限下这个包会被拒(对照)
if int64(len(data)) > 100<<20 {
t.Logf("注意:该包超过旧的 100MB 硬编码上限")
}
// ★ pluginDir 必须显式给定,否则 installFromData 直接返回
// {"error":"plugin dir not configured"} 且 err == nil ——
// 初版没设它,于是判据"通过"了但什么都没装。这是本仓反复出现的
// "假通过"模式:**必须断言返回值的内容,不能只看 err**。
dir := t.TempDir()
p := &Plugin{
pluginDir: dir,
maxPackageBytes: int64(defaultMaxPackageMB) << 20,
}
res, err := p.installFromData(data, false)
if err != nil {
t.Fatalf("安装失败: %v", err)
}
if m, ok := res.(map[string]interface{}); ok {
if e, bad := m["error"]; bad {
t.Fatalf("★ 放宽上限后真实包仍装不进来: %v (details=%v)", e, m["details"])
}
} else {
t.Fatalf("返回值类型意外: %T", res)
}
// 装完必须能看到内嵌运行时资源(这才证明"包真的解开了")
installed := filepath.Join(dir, "commondev")
if _, err := os.Stat(installed); err != nil {
t.Fatalf("★ 插件目录不存在,包没被解开: %v", err)
}
node := filepath.Join(installed, "rawfile", "node", "bin", "node")
st, err := os.Stat(node)
if err != nil {
t.Fatalf("★ 内嵌 node 不在预期路径: %v", err)
}
if st.Mode()&0o111 == 0 {
t.Errorf("★ 内嵌 node 丢了可执行位: %v", st.Mode())
}
t.Logf("解包成功:内嵌 node %d 字节,可执行位 OK", st.Size())
}
// ★★★ 判据(对照):钉住"真实包与旧上限的关系"这个**事实**。
//
// 这条判据的来历值得留着:我原本写的是"该包在旧 100MB 上限下会被拒",
// 想用它证明上面的安装判据有意义。**它报红了** —— 实测包 103,795,462 字节
// < 旧上限 104,857,600 字节,差 1.01 MiB,根本没超。
//
// 也就是说:旧上限**从来不是**当前这个包的障碍,真正的风险是
// "余量只剩 1 MiB,加任何一个文件就爆"。搞错这一点会得出错误的
// "解除了一个阻塞"的结论。判据在这里的作用就是不让错的叙事留下来。
func TestRealPackageHeadroomAgainstOldLimit(t *testing.T) {
src := os.Getenv("HA_RAWFILE_E2E_HMAP")
if src == "" {
t.Skip("设 HA_RAWFILE_E2E_HMAP=<hmap路径> 以运行")
}
st, err := os.Stat(src)
if err != nil {
t.Fatal(err)
}
const oldLimit = 100 << 20
headroom := oldLimit - st.Size()
t.Logf("真实包 %.1f MiB,旧上限 %.1f MiB,余量 %.2f MiB",
float64(st.Size())/(1<<20), float64(oldLimit)/(1<<20), float64(headroom)/(1<<20))
// 事实断言:余量小于 5MiB 就算"贴近上限",属于真实风险
if headroom < 5<<20 {
t.Logf("★ 确认:旧上限下余量仅 %.2f MiB —— 虽未超限,但任何新增资源都会爆。",
float64(headroom)/(1<<20))
}
// 新上限必须给出实质空间
newLimit := int64(defaultMaxPackageMB) << 20
if newLimit-st.Size() < st.Size() {
t.Errorf("★ 新上限 %dMB 未给出足够余量", defaultMaxPackageMB)
}
}

View File

@ -0,0 +1,182 @@
package pluginmgr
// 随包 skill 的登记(hand-out)。
//
// ## 为什么需要它
//
// 插件与 skill 是两种分发物,此前**互不相通**:
//
// 插件 → 解压到 <plugin.dir>/<name>/,由插件注册表加载
// skill → 放在 <data.dir>/skills/,由 skillmgr 扫描注册
//
// 于是 commondev 的 `commondev-discipline`(用这套插件的全部纪律,798 行)
// 装完插件的人**拿不到** —— 除非有人记得手动跑一次命令。
// 这就是「知识在文件里躺着,用户手上没有」的典型断链。
//
// ## 为什么要走事件,而不是直接调 skillmgr
//
// 两个插件之间不该有直接依赖(pluginmgr 不该 import skillmgr)。
// 内核已有现成的**事件式职责链**——原生 skillmgr 订阅 skill_detected
// 并接管注册(`internal/plugins/skillmgr/plugin.go`),
// 而发布者可以是任何持有完整 SDK 的内置插件。
//
// 所以这里只做**发现 + 移交**:解压后扫 `<插件目录>/skill/`,
// 对每个条目发一个 skill_detected。归属与生命周期归 skillmgr。
//
// ## 为什么只对「目录」发
//
// skillmgr 的 loadOne 要求目录里有 SKILL.md 或 skill.json。
// 直接对目录发是它已有的契约(clawhubadapter 也是这么发的)。
//
// ## 为什么失败不阻断安装
//
// 插件能装上、能跑,是主要目的;skill 登记是附带收益。
// 但**不静默**:每个失败都记日志并回报在安装结果里。
import (
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/JianFeeeee/HomeAgent/internal/events"
)
// SkillDirName 是插件包里约定存放 skill 的目录名。
//
// 与 `rawfile/` 并列:rawfile 是"任意随包资源",skill 是"要登记给
// skillmgr 的纯 SKILL 条目"。分开的理由是**意图不同** ——
// 内核需要知道哪些是要注册的,靠子路径名去猜(如 rawfile/skills/)
// 属于隐式约定,正是本仓反复吃过亏的模式。
const SkillDirName = "skill"
// handOutPluginSkills 扫 `<插件目录>/skill/*` 并把每个条目移交 skillmgr。
//
// 返回 (成功移交数, 失败清单)。失败不阻断安装,但必须回报。
func (p *Plugin) handOutPluginSkills(pluginDir, pluginName string) (int, []string) {
if p.sdk == nil {
return 0, nil
}
root := filepath.Join(pluginDir, SkillDirName)
entries, err := os.ReadDir(root)
if err != nil {
// 没有 skill/ 目录是**常态**(绝大多数插件不带 skill),
// 不是错误 —— 不记日志、不回报。
return 0, nil
}
var failed []string
n := 0
for _, e := range entries {
name := e.Name()
// 跳过隐藏文件与占位(.gitkeep/.gitignore 等工程脚手架)
if strings.HasPrefix(name, ".") {
continue
}
if !e.IsDir() {
continue
}
path := filepath.Join(root, name)
// ★ 只发**看起来是纯 SKILL** 的条目。
//
// 不检查的话,一个误放在 skill/ 下的 sidecar 目录(含 main.js)
// 会被 skillmgr 拒收(它的 loadOne 要求 SKILL.md/skill.json),
// 于是每次安装都报一条无意义的失败。在源头过滤更干净。
if !looksLikeNativeSkill(path) {
log.Printf("[pluginmgr] %s: %s/%s 缺 SKILL.md 或 skill.json,跳过登记",
pluginName, SkillDirName, name)
failed = append(failed, name+"(缺 SKILL.md 或 skill.json)")
continue
}
// Source 用插件名:skillmgr 只按 path 处理,Source 只供日志/溯源。
p.sdk.Publish(&events.Event{
Type: events.EventSkillDetected,
Source: p.name,
Payload: map[string]interface{}{
"path": path,
// 额外字段不改变 skillmgr 的行为(它只读 path),
// 但让日志能看出这条 skill 是谁带来的。
"from_plugin": pluginName,
},
Timestamp: time.Now().Unix(),
})
log.Printf("[pluginmgr] %s: 已移交 skill %s 给 skillmgr", pluginName, name)
n++
}
return n, failed
}
// looksLikeNativeSkill 判断目录是否是 skillmgr 认得的纯 SKILL 条目。
//
// 与 skillmgr.isNativeSkillPath 的判据保持一致(SKILL.md 或 skill.json),
// 但**故意不重复它的排除逻辑**(main.js/openclaw.plugin.json 等):
// 那些属于兼容层的职责划分,pluginmgr 不该复刻。这里只做"显然不是 skill
// 就别发"的粗筛,精判留给归属者 —— 这样两边的判据不会漂移成两份。
func looksLikeNativeSkill(dir string) bool {
for _, f := range []string{"SKILL.md", "skill.json"} {
if st, err := os.Stat(filepath.Join(dir, f)); err == nil && !st.IsDir() {
return true
}
}
return false
}
// reHandOutAllPluginSkills 在**启动时**重新登记所有已装插件的随包 skill。
//
// ## 为什么必须有这个(实测发现的真缺陷)
//
// 移交只发生在**安装那一刻**(installFromData → handOutPluginSkills)。
// 而 skill 在**插件目录**里(`<plugin.dir>/<name>/skill/`),不在
// skillmgr 的扫描目录(`<data.dir>/skills/`)里。于是:
//
// 装插件 → skill 注册 ✅
// ↓ worker 重启(部署、崩溃恢复、热更新)
// skillmgr 只扫 skillsDir → **skill 消失** ❌
//
// 实测:2026-10-15 装完 commondev 后 skill 正常注册;一次热更新
// (worker exit 42 重建)之后 `loaded SKILL: commondev-discipline`
// 不再出现 —— 用户以为 skill 还在,实际已经没了。属于**静默丢失**。
//
// ## 为什么放在 pluginmgr 而不是 skillmgr
//
// 只有 pluginmgr 知道插件装在哪(它管 pluginDir)。skillmgr 去读插件目录
// 会把"插件布局"这个知识复制到第二个地方 —— 正是本仓反复吃亏的模式。
//
// 顺序注意:pluginmgr 的 Start 与 skillmgr 的 Start 谁先跑不确定,
// 而 skillmgr 的订阅在它 Start 里建立。若 pluginmgr 先发事件、skillmgr
// 后订阅,事件就丢了。所以这里**延迟一小段再发**,并且 skillmgr 的
// loadOne 幂等 —— 重复到达无副作用。
func (p *Plugin) reHandOutAllPluginSkills() {
dir := p.pluginDir
if dir == "" || p.sdk == nil {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
n := 0
for _, e := range entries {
if !e.IsDir() {
continue
}
cnt, _ := p.handOutPluginSkills(filepath.Join(dir, e.Name()), e.Name())
n += cnt
}
if n > 0 {
log.Printf("[pluginmgr] 启动重扫:已重新移交 %d 个随包 skill(否则重启后会静默丢失)", n)
}
}
// scheduleStartupSkillRescan 延迟重扫:等 skillmgr 的订阅就绪。
//
// 延时常量 intentionally 小(本机实测 skillmgr 的 Start 在百毫秒级完成);
// 即便错过,loadOne 幂等 —— 下次安装或重启仍会补上,只是本次会话里晚一点。
func (p *Plugin) scheduleStartupSkillRescan() {
go func() {
time.Sleep(2 * time.Second)
p.reHandOutAllPluginSkills()
}()
}

View File

@ -0,0 +1,385 @@
package pluginmgr
// 随包 skill 移交的判据。
//
// 覆盖的缺口:插件与 skill 此前是两种**互不相通**的分发物 —— 插件解压到
// <plugin.dir>/<name>/,skill 由 skillmgr 扫 <data.dir>/skills/。
// 于是随插件分发的 skill 装完就到不了用户手上。
//
// 修法:pluginmgr(内置插件,持有完整 SDK)在解压后扫 <插件目录>/skill/,
// 对每个条目发 skill_detected;skillmgr 已订阅该事件并接管注册。
//
// 判据必须钉住**三个环节**,缺一个链路就不通:
// ① 发现:只认真正的纯 SKILL,不误发别的目录
// ② 移交:事件类型/载荷正确(skillmgr 只读 payload["path"])
// ③ 贯通:事件真的会被 skillmgr 的订阅者收到(用真事件总线验证)
import (
"archive/zip"
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/events"
"github.com/JianFeeeee/HomeAgent/internal/sdk"
)
// buildHmapWithSkills 造一个带 skill/ 目录的插件包。
func buildHmapWithSkills(t *testing.T, files map[string]string) []byte {
return buildHmapWithSkillsVer(t, "1.0.0", files)
}
// buildHmapWithSkillsVer 同前,但可指定版本号。
//
// ★ 不能对已生成的 zip 做字节替换来改版本 —— 那会破坏 zip 的校验和,
// 报 "zip: checksum error"(实测踩到)。必须**重新打包**。
func buildHmapWithSkillsVer(t *testing.T, version string, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
add := func(name, content string) {
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
fw, err := w.CreateHeader(hdr)
if err != nil {
t.Fatal(err)
}
fw.Write([]byte(content))
}
// ★ 不带 platforms 字段 ⇒ legacy 模式:entry 直接指向 plugin.bin。
//
// 初版写了 platforms 却没提供对应名字的二进制(platformBinary() 期望
// plugin.bin.<goos>.<goarch>),于是 validatePackage 直接以
// "no valid binary found" 拒绝 —— 判据全红但并非功能缺失。
// 这是**判据自己的缺陷**(造的包不合规),不是被测代码的问题。
add("plugin.json", `{"name":"skilltest","version":"`+version+`","entry":"plugin.bin"}`)
add("plugin.bin", "#!/bin/sh\n")
for n, c := range files {
add(n, c)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// newTestPluginWithBus 造一个挂了**真事件总线**的 pluginmgr。
func newTestPluginWithBus(t *testing.T, dir string) (*Plugin, *events.Bus) {
t.Helper()
bus := events.NewBus()
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: &pmSettings{},
EventBus: bus,
})
return &Plugin{name: "pluginmgr", pluginDir: dir, sdk: sdkInst}, bus
}
// ★★★ 判据:安装带 skill 的插件 → **skill_detected 事件真的发出来了**。
//
// 这是整条链路的核心:接收端(skillmgr)是现成的,要看的是发布端真的发。
func TestInstallPublishesSkillDetected(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
// 订阅(模拟 skillmgr 的订阅)
type got struct {
path string
src string
}
ch := make(chan got, 8)
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
path, _ := e.Payload["path"].(string)
ch <- got{path, e.Source}
})
data := buildHmapWithSkills(t, map[string]string{
"skill/my-skill/SKILL.md": "---\nname: my-skill\n---\n# hi\n",
"skill/other/skill.json": `{"name":"other"}`,
})
if _, err := p.installFromData(data, false); err != nil {
t.Fatalf("安装失败: %v", err)
}
// 必须收到恰好 2 个事件
var paths []string
for i := 0; i < 2; i++ {
select {
case g := <-ch:
paths = append(paths, g.path)
if g.src == "" {
t.Error("事件缺少 Source(无法溯源是谁带来的 skill)")
}
default:
t.Fatalf("只收到 %d 个 skill_detected 事件,期望 2 个(已收: %v)", i, paths)
}
}
joined := strings.Join(paths, ",")
if !strings.Contains(joined, "my-skill") || !strings.Contains(joined, "other") {
t.Errorf("移交的路径不对: %v", paths)
}
// 路径必须指向**解压后**的位置(而不是包里的相对路径)
for _, pa := range paths {
if !strings.HasPrefix(pa, filepath.Join(dir, "skilltest")) {
t.Errorf("★ 路径不在插件目录下(skillmgr 会找不到): %s", pa)
}
if _, err := os.Stat(pa); err != nil {
t.Errorf("★ 移交的路径不存在: %s", pa)
}
}
}
// ★★ 判据:**没有 skill/ 目录的插件不发任何事件**(绝大多数插件如此)。
//
// 不过滤的话每次装插件都会发噪音事件,skillmgr 反复收到无效路径。
func TestPluginWithoutSkillsPublishesNothing(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
n := 0
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) { n++ })
data := buildHmapWithSkills(t, map[string]string{
"plugin.json": `{"name":"x","version":"1.0.0","entry":"plugin.bin"}`,
"rawfile/some/data.txt": "x",
})
if _, err := p.installFromData(data, false); err != nil {
t.Fatalf("安装失败: %v", err)
}
if n != 0 {
t.Errorf("★ 无 skill 的插件不该发事件,实际发了 %d 个", n)
}
}
// ★★ 判据:**不是纯 SKILL 的目录不发**(避免 skillmgr 报无意义失败)。
//
// skillmgr 的 loadOne 要求 SKILL.md 或 skill.json。误放在 skill/ 下的
// sidecar 目录(含 main.js)会被它拒收 —— 与其发出去再失败,
// 不如在源头过滤并如实回报。
func TestNonSkillDirsAreNotPublished(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
var paths []string
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
pa, _ := e.Payload["path"].(string)
paths = append(paths, pa)
})
data := buildHmapWithSkills(t, map[string]string{
"skill/good/SKILL.md": "x",
"skill/bad-sidecar/main.js": "x", // 不是纯 SKILL
"skill/empty/notes.txt": "x", // 没有 SKILL.md/skill.json
})
res, _ := p.installFromData(data, false)
m := res.(map[string]interface{})
if len(paths) != 1 {
t.Errorf("★ 只应移交 1 个(good),实际 %d: %v", len(paths), paths)
}
if !strings.HasSuffix(paths[0], "good") {
t.Errorf("移交错了条目: %v", paths)
}
// 被跳过的必须**如实回报**,不静默吞掉
failed, _ := m["skills_failed"].([]string)
if len(failed) != 2 {
t.Errorf("★ 被跳过的 2 个应回报在 skills_failed,实际 %v", m["skills_failed"])
}
}
// ★★ 判据:脚手架文件不触发移交(.gitkeep/.gitignore)。
func TestScaffoldFilesDoNotTriggerPublish(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
var paths []string
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
pa, _ := e.Payload["path"].(string)
paths = append(paths, pa)
})
data := buildHmapWithSkills(t, map[string]string{
"skill/.gitkeep": "",
"skill/.gitignore": "*\n",
// ★ 隐藏**目录**:仅靠 IsDir() 挡不住,必须显式跳过 "."
// (隐藏文件是文件,会被 IsDir() 过滤掉,所以单测它们**测不到**这条规则)。
"skill/.hidden-dir/SKILL.md": "x",
})
res, _ := p.installFromData(data, false)
if len(paths) != 0 {
t.Errorf("★ 脚手架不应触发移交: %v", paths)
}
m := res.(map[string]interface{})
if n, _ := m["skills_registered"].(int); n != 0 {
t.Errorf("skills_registered 应为 0,实际 %v", m["skills_registered"])
}
}
// ★★★ 判据:**端到端贯通** —— pluginmgr 发的事件,skillmgr 的订阅者收得到。
//
// 前面几条验证"发出来了",这条验证"真的能被接收端收到"。
// 用真实事件总线 + 模拟 skillmgr 的处理逻辑(读 payload["path"] 并校验可加载)。
func TestEventReachesSkillmgrConsumer(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
// 模拟 skillmgr 的订阅者:它只读 path,然后 loadOne
loaded := make(chan string, 4)
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
path, _ := e.Payload["path"].(string)
if path == "" {
return // skillmgr 的实际行为:空路径直接返回
}
// 复刻 skillmgr 的准入判据
for _, f := range []string{"SKILL.md", "skill.json"} {
if _, err := os.Stat(filepath.Join(path, f)); err == nil {
loaded <- path
return
}
}
})
data := buildHmapWithSkills(t, map[string]string{
"skill/discipline/SKILL.md": "# 纪律\n",
})
if _, err := p.installFromData(data, false); err != nil {
t.Fatal(err)
}
select {
case path := <-loaded:
if !strings.HasSuffix(path, "discipline") {
t.Errorf("加载的路径不对: %s", path)
}
// 内容必须真的可读(证明路径指向解压后的真实文件)
b, err := os.ReadFile(filepath.Join(path, "SKILL.md"))
if err != nil || !strings.Contains(string(b), "纪律") {
t.Errorf("★ skill 内容不可读: %v %q", err, b)
}
default:
t.Fatal("★ 端到端不通:skillmgr 的订阅者没收到事件")
}
}
// ★★ 判据:**升级路径也移交**(不只在全新安装时)。
//
// 漏掉升级路径的话,已装插件的用户**永远拿不到**新版的 skill ——
// 而升级是最常见的更新方式。
func TestUpgradeAlsoHandsOutSkills(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
var paths []string
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
pa, _ := e.Payload["path"].(string)
paths = append(paths, pa)
})
v1 := buildHmapWithSkills(t, map[string]string{"skill/s1/SKILL.md": "v1"})
if _, err := p.installFromData(v1, false); err != nil {
t.Fatal(err)
}
first := len(paths)
if first == 0 {
t.Fatal("首次安装未移交")
}
// 升级到 v2:**重新打包**(不是替换字节 —— 那会坏校验和)
v2 := buildHmapWithSkillsVer(t, "2.0.0", map[string]string{"skill/s2/SKILL.md": "v2"})
res, _ := p.installFromData(v2, true)
m, _ := res.(map[string]interface{})
if e, bad := m["error"]; bad {
t.Fatalf("升级失败: %v (%v)", e, m["details"])
}
if len(paths) <= first {
t.Fatalf("★ 升级路径未移交 skill(首次 %d,升级后 %d)", first, len(paths))
}
// 升级后新的 skill 也要在
found := false
for _, pa := range paths[first:] {
if strings.HasSuffix(pa, "s2") {
found = true
}
}
if !found {
t.Errorf("升级后未移交新 skill: %v", paths[first:])
}
}
// ★ 判据:安装结果里如实回报 skill 数(不静默)。
func TestInstallResultReportsSkillCount(t *testing.T) {
dir := t.TempDir()
p, _ := newTestPluginWithBus(t, dir)
data := buildHmapWithSkills(t, map[string]string{"skill/a/SKILL.md": "x"})
res, _ := p.installFromData(data, false)
m := res.(map[string]interface{})
if n, ok := m["skills_registered"].(int); !ok || n != 1 {
t.Errorf("★ skills_registered 应为 1,实际 %#v", m["skills_registered"])
}
// 该字段必须能被 JSON 序列化(外部调用方要读它)
if _, err := json.Marshal(m); err != nil {
t.Errorf("安装结果无法序列化: %v", err)
}
}
// ★★★ 判据:**启动时必须重新登记随包 skill**(重启不该丢)。
//
// 这条是实测发现的真缺陷(2026-10-15):
//
// 装插件 → skill 注册 ✅
// ↓ worker 重启
// skillmgr 只扫 skillsDir(skill 在**插件目录**里)→ skill 静默消失 ❌
//
// 移交只发生在安装那一刻,重启后没有任何重新发现机制。
// 后果是"用户以为 skill 还在,实际已经没了"——典型的静默丢失。
func TestStartupReHandsOutPluginSkills(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
p.pluginDir = dir
// 造一个"已装插件"的目录布局:<pluginDir>/<name>/skill/<s>/SKILL.md
// (模拟上一次安装留下的产物,进程重启后就是这个状态)
skillDir := filepath.Join(dir, "someplugin", "skill", "my-discipline")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# 纪律"), 0o644); err != nil {
t.Fatal(err)
}
// 订阅(模拟 skillmgr 已在运行)
got := make(chan string, 4)
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
pa, _ := e.Payload["path"].(string)
got <- pa
})
// 调启动重扫(不启 goroutine,直接同步调便于断言)
p.reHandOutAllPluginSkills()
select {
case path := <-got:
if !strings.HasSuffix(path, "my-discipline") {
t.Errorf("移交路径不对: %s", path)
}
// 路径必须真实可读(否则 skillmgr 会失败)
if _, err := os.Stat(filepath.Join(path, "SKILL.md")); err != nil {
t.Errorf("★ 移交路径不可读: %v", err)
}
default:
t.Fatal("★ 启动重扫没有重新移交随包 skill(重启后会静默丢失)")
}
}
// ★★ 判据:启动重扫对**没有 skill 的插件**不发噪音事件。
func TestStartupRescanIgnoresPluginsWithoutSkills(t *testing.T) {
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
p.pluginDir = dir
// 两个没有 skill/ 的插件目录
for _, n := range []string{"plain1", "plain2"} {
os.MkdirAll(filepath.Join(dir, n), 0o755)
}
n := 0
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) { n++ })
p.reHandOutAllPluginSkills()
if n != 0 {
t.Errorf("无 skill 的插件不该发事件,实际 %d 个", n)
}
}

View File

@ -0,0 +1,69 @@
package pluginmgr
// ★★★ 真实包 → 安装 → skill 移交 的端到端判据。
//
// 用**真实的 commondev bundle**(含内嵌 node+pi + 随包 skill)跑一遍
// installFromData,验证 skill_detected 事件真的带着**可加载的路径**发出来。
//
// 这条把三段串起来:打包(hmapdev 的 skill/ 约定)→ 安装(解压保留路径)
// → 移交(pluginmgr 发事件)。任何一段断了,随包 skill 就到不了用户手上。
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/events"
)
func TestRealBundleHandsOutSkill(t *testing.T) {
src := os.Getenv("HA_RAWFILE_E2E_HMAP")
if src == "" {
t.Skip("设 HA_RAWFILE_E2E_HMAP=<hmap路径> 以运行(需先构建 commondev bundle)")
}
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("读包失败: %v", err)
}
t.Logf("真实包 %.1f MiB", float64(len(data))/(1<<20))
dir := t.TempDir()
p, bus := newTestPluginWithBus(t, dir)
var got []string
bus.Subscribe(events.EventSkillDetected, func(e *events.Event) {
pa, _ := e.Payload["path"].(string)
got = append(got, pa)
})
res, err := p.installFromData(data, false)
if err != nil {
t.Fatalf("安装失败: %v", err)
}
if m, ok := res.(map[string]interface{}); ok {
if e, bad := m["error"]; bad {
t.Fatalf("安装报错: %v (%v)", e, m["details"])
}
if n, _ := m["skills_registered"].(int); n != 1 {
t.Errorf("★ skills_registered 应为 1,实际 %v", m["skills_registered"])
}
}
if len(got) != 1 {
t.Fatalf("★ 应移交 1 个 skill,实际 %d: %v", len(got), got)
}
path := got[0]
if !strings.HasSuffix(path, "commondev-discipline") {
t.Errorf("移交的 skill 名不对: %s", path)
}
// ★ 关键:路径必须真的可加载(指向解压后的真实文件)
md := filepath.Join(path, "SKILL.md")
b, err := os.ReadFile(md)
if err != nil {
t.Fatalf("★ 移交路径不可读(skillmgr 会失败): %v", err)
}
if len(b) < 1000 {
t.Errorf("SKILL.md 内容过短(%d 字节),可能没打进包", len(b))
}
t.Logf("移交成功:%s(SKILL.md %d 字节)", path, len(b))
}

View File

@ -15,13 +15,16 @@ import (
)
func TestCmpVersion(t *testing.T) {
cases := []struct{ a, b string; want int }{
cases := []struct {
a, b string
want int
}{
{"1.0.0", "1.0.0", 0},
{"1.0.1", "1.0.0", 1},
{"1.0.0", "1.0.1", -1},
{"1.0", "1.0.0", 0},
{"v2.0.0", "1.9.9", 1},
{"2.0.0", "10.0.0", -1}, // 数字比较而非字典序
{"2.0.0", "10.0.0", -1}, // 数字比较而非字典序
{"1.0.0-alpha", "1.0.0", 0}, // 非数字段按 0
}
for _, c := range cases {
@ -39,6 +42,8 @@ func (m *pmSettings) DataDir() string { return "/tmp/mock_data" }
func (m *pmSettings) Get(string) (interface{}, error) { return nil, nil }
func (m *pmSettings) Set(string, interface{}) error { return nil }
func (m *pmSettings) SetSecret(string, interface{}) error { return nil }
func (m *pmSettings) GetSecret(string) (string, error) { return "", nil }
func (m *pmSettings) List(string) ([]string, error) { return nil, nil }
func (m *pmSettings) GetCore(string) (interface{}, error) { return nil, nil }
func (m *pmSettings) SetCore(string, interface{}) error { return nil }
@ -56,6 +61,9 @@ func (m *pmSettings) Remove(string) error { return nil
func (m *pmSettings) RemoveCore(string) error { return nil }
func (m *pmSettings) RemovePlugin(string, string) error { return nil }
// IsSecret:E-3 起 SettingsAPI 要求实现(判断某键是否按密文存储)。
func (m *pmSettings) IsSecret(string) bool { return false }
// fakePluginMgr 记录调用;StopAndUnload 只记标志,不真正操作。
type fakePluginMgr struct {
mu sync.Mutex
@ -68,6 +76,9 @@ func (f *fakePluginMgr) IsPluginDisabled(string) bool { return
func (f *fakePluginMgr) IsBuiltinPlugin(string) bool { return false }
func (f *fakePluginMgr) DisablePlugin(string, string) error { return nil }
func (f *fakePluginMgr) EnablePlugin(string) error { return nil }
func (f *fakePluginMgr) StopPlugin(string) error { return nil }
func (f *fakePluginMgr) StartPlugin(string) error { return nil }
func (f *fakePluginMgr) IsPluginRuntimeStopped(string) bool { return false }
func (f *fakePluginMgr) RemovePlugin(string) error { return nil }
func (f *fakePluginMgr) StopAndUnload(name string) error {
f.mu.Lock()
@ -222,4 +233,4 @@ func TestExtractFailureRollsBack(t *testing.T) {
if err != nil || mfest.Version != "1.0.0" {
t.Fatalf("old version not restored: %v / %v", mfest, err)
}
}
}

View File

@ -29,7 +29,8 @@ import (
// PluginSDK 与真实块引用(块 ID 或其唯一前缀,与生产同一取字节路径)。
//
// ★ CAS 淘汰后不再有媒体存储可 Put:字节进块(MemoryBlock.Data),
// 插件用 sdk.Memory().MediaBytes(ref) 取回 —— 这正是测试要钉的路径。
//
// 插件用 sdk.Memory().MediaBytes(ref) 取回 —— 这正是测试要钉的路径。
func sdkWithVideo(t *testing.T, raw []byte) (*sdk.PluginSDK, string) {
t.Helper()
dir := t.TempDir()

View File

@ -306,7 +306,8 @@ func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error
// mediaBytes 按媒体块引用取回字节与 MIME。
//
// ★ CAS 淘汰后插件取媒体的唯一路径:块 ID(内容派生)就是媒体身份。
// 取不到时返回 ok=false(“这个引用没媒体”是常态,不是错误)。
//
// 取不到时返回 ok=false(“这个引用没媒体”是常态,不是错误)。
func (p *Plugin) mediaBytes(ref string) (data []byte, mime string, ok bool, err error) {
if p.sdk == nil || p.sdk.Memory() == nil {
return nil, "", false, fmt.Errorf("记忆系统不可用")

View File

@ -55,7 +55,7 @@ type SkillEntry struct {
}
type Plugin struct {
name string
name string
skillsDir string
mu sync.RWMutex

View File

@ -21,24 +21,29 @@ type nilSettings struct{}
func (m *nilSettings) DataDir() string { return "/tmp/mock_data" }
func (m *nilSettings) Get(string) (interface{}, error) { return nil, nil }
func (m *nilSettings) Set(string, interface{}) error { return nil }
func (m *nilSettings) List(string) ([]string, error) { return nil, nil }
func (m *nilSettings) GetCore(string) (interface{}, error) { return nil, nil }
func (m *nilSettings) SetCore(string, interface{}) error { return nil }
func (m *nilSettings) ListCore(string) ([]string, error) { return nil, nil }
func (m *nilSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil }
func (m *nilSettings) SetPlugin(string, string, interface{}) error { return nil }
func (m *nilSettings) ListPlugin(string, string) ([]string, error) { return nil, nil }
func (m *nilSettings) RegisterDef(pubsdk.ConfigDef) {}
func (m *nilSettings) Defs(string) []*pubsdk.ConfigDef { return nil }
func (m *nilSettings) Dump() map[string]interface{} { return nil }
func (m *nilSettings) Plugins() []string { return nil }
func (m *nilSettings) DefsCore(string) []*sdk.ConfigDef { return nil }
func (m *nilSettings) DefsPlugin(string, string) []*sdk.ConfigDef { return nil }
func (m *nilSettings) Remove(string) error { return nil }
func (m *nilSettings) RemoveCore(string) error { return nil }
func (m *nilSettings) RemovePlugin(string, string) error { return nil }
func (m *nilSettings) Get(string) (interface{}, error) { return nil, nil }
func (m *nilSettings) Set(string, interface{}) error { return nil }
func (m *nilSettings) SetSecret(string, interface{}) error { return nil }
func (m *nilSettings) GetSecret(string) (string, error) { return "", nil }
// IsSecret:E-3 起 SettingsAPI 要求实现(判断某键是否按密文存储)。
func (m *nilSettings) IsSecret(string) bool { return false }
func (m *nilSettings) List(string) ([]string, error) { return nil, nil }
func (m *nilSettings) GetCore(string) (interface{}, error) { return nil, nil }
func (m *nilSettings) SetCore(string, interface{}) error { return nil }
func (m *nilSettings) ListCore(string) ([]string, error) { return nil, nil }
func (m *nilSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil }
func (m *nilSettings) SetPlugin(string, string, interface{}) error { return nil }
func (m *nilSettings) ListPlugin(string, string) ([]string, error) { return nil, nil }
func (m *nilSettings) RegisterDef(pubsdk.ConfigDef) {}
func (m *nilSettings) Defs(string) []*pubsdk.ConfigDef { return nil }
func (m *nilSettings) Dump() map[string]interface{} { return nil }
func (m *nilSettings) Plugins() []string { return nil }
func (m *nilSettings) DefsCore(string) []*sdk.ConfigDef { return nil }
func (m *nilSettings) DefsPlugin(string, string) []*sdk.ConfigDef { return nil }
func (m *nilSettings) Remove(string) error { return nil }
func (m *nilSettings) RemoveCore(string) error { return nil }
func (m *nilSettings) RemovePlugin(string, string) error { return nil }
// toolSpy 记录注册的工具,并可按名调用 handler。
type toolSpy struct {
@ -46,7 +51,9 @@ type toolSpy struct {
handlers map[string]func(args map[string]interface{}) (interface{}, error)
}
func newToolSpy() *toolSpy { return &toolSpy{handlers: map[string]func(map[string]interface{}) (interface{}, error){}} }
func newToolSpy() *toolSpy {
return &toolSpy{handlers: map[string]func(map[string]interface{}) (interface{}, error){}}
}
func (s *toolSpy) Register(name string, def interface{}, h func(args map[string]interface{}) (interface{}, error)) error {
s.mu.Lock()
@ -83,13 +90,13 @@ func newTestPlugin(t *testing.T) (*Plugin, *toolSpy, *events.Bus) {
spy := newToolSpy()
cfgSDK := sdk.New("skillmgr", sdk.SDKConfig{
RegTool: func(name string, def sdk.ToolDef, h sdk.ToolHandler) error {
RegTool: func(name string, def sdk.ToolDef, h sdk.ToolHandler) error {
return spy.Register(name, def, func(args map[string]interface{}) (interface{}, error) {
return h(args)
})
},
Settings: &nilSettings{},
EventBus: bus,
Settings: &nilSettings{},
EventBus: bus,
})
if err := p.Start(cfgSDK); err != nil {
t.Fatalf("Start: %v", err)

View File

@ -191,7 +191,7 @@ func (p *Plugin) registerSetEnabled(v bool) {
func (p *Plugin) registerCreate() {
name := tp + "create"
p.sdk.RegisterTool(name, sdk.ToolDef{
Name: name,
Name: name,
Description: "创建新技能骨架:生成标准 SKILL.md 模板写入 skills/<name>/。创建后在 content 参数填入完整文档内容并再次调用以落盘生效(两步式),或直接传 content 一步完成。",
Parameters: map[string]interface{}{
"type": "object",

View File

@ -7,6 +7,7 @@ import (
"time"
"github.com/JianFeeeee/HomeAgent/internal/plugin"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
)
@ -43,7 +44,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.maxDur = 24 * time.Hour
// 停止清理(取消倒计时)交由 stop handler:内核在调用 Stop() 之前执行。
s.RegisterStopHandler(func() { close(p.stopCh) })
// 幂等(本仓 close 纪律):stop handler 由内核调用;若将来多一条
// 停机路径(重载/热更新)就会二次 close ⇒ panic。
s.RegisterStopHandler(safeclose.SafeFunc(p.stopCh))
// 删除清理:移除插件自身配置(删除专用回调,重载不触发)。
s.RegisterOnRemoveHandler(func() {
if err := s.Settings().Remove("max_duration"); err != nil {

View File

@ -7806,6 +7806,18 @@ function pluginDisplayName(p) {
return name;
}
// toggleSecretVisibility 在「遮蔽 / 明文」间切换敏感输入框。
// 内联 onclick 需要全局可见,故挂到 window。
function toggleSecretVisibility(inpId) {
var el = document.getElementById(inpId);
if (!el) return;
var showing = el.type === "text";
el.type = showing ? "password" : "text";
var btn = el.parentElement ? el.parentElement.querySelector("button") : null;
if (btn) btn.textContent = showing ? __("显示", "Show") : __("隐藏", "Hide");
}
if (typeof window !== "undefined") window.toggleSecretVisibility = toggleSecretVisibility;
function renderSettingsSidebar() {
var el = document.querySelector(".settings-tabs");
if (!el) return;
@ -8025,6 +8037,30 @@ function renderOneSettings() {
"')\">" +
selOpts +
"</select>";
} else if (typ === "password" || m?.secret) {
// 敏感项:默认遮蔽,点「显示」才明文。
//
// 两种声明都走这里:Type=="password"(旧写法)与 Secret:true
// (新写法)。值本身是明文(内核透明解密),遮蔽只防肩窥/截屏,
// 不靠前端做保密 —— 真正的保护在落盘加密那一层。
inp =
"<label>" +
label +
'</label><div style="display:flex;gap:6px;align-items:center">' +
'<input type="password" id="' +
inpId +
'" value="' +
escHtml(sv) +
'" placeholder="' +
escHtml(ph) +
'" onchange="markDirty(\'' +
k +
"')\" style=\"flex:1\" autocomplete=\"new-password\">" +
'<button type="button" class="btn-sm" style="flex:0 0 auto" onclick="toggleSecretVisibility(\'' +
inpId +
"')\">" +
__("显示", "Show") +
"</button></div>";
} else if (typ === "text") {
inp =
"<label>" +
@ -8323,6 +8359,19 @@ function markDirty(k) {
async function saveSetting(k) {
var inp = document.getElementById("inp-" + k.replace(/\./g, "_"));
if (!inp) return;
// ★ 掩码占位符**不是值**,绝不能回传。
//
// 真实故障(2026-10-16):列表接口为防泄漏把敏感值换成 `••••••••`,
// 而设置页「保存」按钮会**一并回传**该组的全部字段(含刚被掩码的 api_key)
// ⇒ 掩码被当真值存进内核 ⇒ 下游(commondev 的 pi 源导入器)把掩码
// 写进 auth.json ⇒ 模型调用 401 invalid api key。
//
// 两层防护:这里(前端不回传)与后端(PUT 拒绝占位符)。
// 前端这层必须独立成立:后端拒绝会让整组保存失败,用户以为"保存坏了"。
if (isSecretPlaceholderValue(inp.value)) {
// 未改动密钥时静默跳过(不该弹错——用户只是想改别的字段)
return;
}
var val;
var m = state.meta?.[k];
if (m?.type === "bool") {
@ -8354,6 +8403,14 @@ async function saveSetting(k) {
}
}
// isSecretPlaceholderValue 判定输入框里的是否是掩码占位符(而非真实值)。
//
// 用**全等**而不是 includes:真 key 里恰好含这两个字符的概率极低,
// 但反过来说,只要不是"整个值就是掩码",就一律放行 —— 宁可放行也不误杀真值。
function isSecretPlaceholderValue(v) {
return typeof v === "string" && v.trim() === "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
}
function renderConfigDisabled() {
document.getElementById("tab-settings").innerHTML =
'<div class="card"><h2>' +

View File

@ -106,21 +106,22 @@ func init() {
}
type Handler struct {
sdk *sdk.PluginSDK
supervisor sdk.SupervisorAPI
memory sdk.MemoryAPI
indexer sdk.IndexerAPI
adapter sdk.AdapterAPI
config sdk.ConfigAPI
startTime time.Time
textMem sdk.TextMemoryAPI
knowledge sdk.KnowledgeAPI
tracker sdk.TrackerAPI
settings sdk.SettingsAPI
pluginMgr sdk.PluginManager
status sdk.StatusAPI
term sdk.TerminalAPI
llm sdk.LLMAPI
chatStopOnce2 sync.Once // close 纪律:chatStop 的幂等守卫
sdk *sdk.PluginSDK
supervisor sdk.SupervisorAPI
memory sdk.MemoryAPI
indexer sdk.IndexerAPI
adapter sdk.AdapterAPI
config sdk.ConfigAPI
startTime time.Time
textMem sdk.TextMemoryAPI
knowledge sdk.KnowledgeAPI
tracker sdk.TrackerAPI
settings sdk.SettingsAPI
pluginMgr sdk.PluginManager
status sdk.StatusAPI
term sdk.TerminalAPI
llm sdk.LLMAPI
// hostPort 是 webui 实际监听的 ":port"(用于推导服务入口链接)。
hostPort string
@ -487,6 +488,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/network", h.requireAPI(h.handleNetwork))
mux.HandleFunc("/api/v1/config", h.requireAPI(h.handleConfig))
mux.HandleFunc("/api/v1/settings", h.requireAPI(h.handleSettings))
mux.HandleFunc("/api/v1/settings/reveal", h.requireAPI(h.handleSettingsReveal))
mux.HandleFunc("/api/v1/settings/", h.requireAPI(h.handleSettings))
mux.HandleFunc("/api/v1/knowledge", h.requireAPI(h.handleKnowledge))
mux.HandleFunc("/api/v1/knowledge/", h.requireAPI(h.handleKnowledge))

View File

@ -231,12 +231,39 @@ func (h *Handler) handleRollback(w http.ResponseWriter, r *http.Request, agentID
writeJSON(w, http.StatusOK, map[string]string{"status": "rollback_initiated", "agent": string(agentID), "snap": string(snapID)})
}
// handleAgentAction 处理 start/stop/restart。
//
// ★ restart 是**热更新入口**(此前是 501 桩):
// 换掉 /usr/local/bin/homed 之后调它,worker 会以退出码 42 优雅退出,
// guard 随即用**新二进制**重新拉起 —— 不必 systemctl restart,
// 也不打断 guard(双进程架构的父进程)。
//
// stop/start 仍返回 501:它们的语义在「单 worker + guard 自动拉起」
// 架构下没有独立意义(stop 之后 guard 立刻会把它拉回来)。
func (h *Handler) handleAgentAction(w http.ResponseWriter, r *http.Request, agentID types.AgentID, action string) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": fmt.Sprintf("agent %s action not implemented by supervisor", action)})
switch action {
case "restart":
if h.supervisor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "supervisor not available"})
return
}
if err := h.supervisor.Restart(string(agentID)); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusAccepted, map[string]string{
"status": "restarting",
"agent": string(agentID),
"note": "worker 将优雅退出(exit 42),guard 随后用当前二进制重新拉起;" +
"若刚替换过 /usr/local/bin/homed,新版本即在此刻生效",
})
default:
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": fmt.Sprintf("agent %s action not implemented by supervisor", action)})
}
}
// handleRuntime 返回**只含运行态**的小快照:调度器(排队/四级中断队列/中断栈)、

View File

@ -6,10 +6,12 @@ import (
"log"
"strconv"
"strings"
"sync"
"time"
"encoding/json"
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
"net/http"
)
@ -66,8 +68,9 @@ const maxChatHistory = 200
const maxChatMsgCache = 256
type chatMsgEntry struct {
done chan struct{}
resp *agentIO.OutputEvent
done chan struct{}
doneOnce sync.Once // close 纪律:见 internal/safeclose
resp *agentIO.OutputEvent
}
func (h *Handler) claimChatMsg(id string) (*chatMsgEntry, bool) {
@ -90,7 +93,9 @@ func (h *Handler) claimChatMsg(id string) (*chatMsgEntry, bool) {
// completeChatMsg 记录首次处理结果并唤醒所有等待的同 ID 重放请求。
func (h *Handler) completeChatMsg(e *chatMsgEntry, resp *agentIO.OutputEvent) {
e.resp = resp
close(e.done)
// 幂等(本仓 close 纪律):同一 chat ID 若重复完成(重放/重试),
// 裸 close 会 panic。e.done 是"等待者被唤醒"的信号,重复唤醒无意义。
safeclose.Safe(&e.doneOnce, e.done)
}
func (h *Handler) loadChatHistory() {
@ -322,7 +327,10 @@ func (h *Handler) persistChatLocked() {
// chatPersistLoop 把聊天记录按节流节奏落盘,直到 Close。
func (h *Handler) chatPersistLoop() {
defer close(h.chatLoopDone)
// 幂等(本仓 close 纪律):本循环理论上只跑一次,但循环可能在
// 重载/重建 handler 时被再次启动 ⇒ 裸 close 会 panic。
var loopDoneOnce sync.Once
defer safeclose.Safe(&loopDoneOnce, h.chatLoopDone)
timer := time.NewTimer(chatSaveThrottle)
timer.Stop()
defer timer.Stop()
@ -382,7 +390,7 @@ func (h *Handler) flushChat() {
// 由插件 Stop 调用;不这样做会丢掉最后一轮对话。
func (h *Handler) Close() {
h.chatCloseOnce.Do(func() {
close(h.chatStop)
safeclose.Safe(&h.chatStopOnce2, h.chatStop)
<-h.chatLoopDone
})
}
@ -663,12 +671,13 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
// 写入侧用短超时(50ms)兜底,比立即丢弃更友好。
writeCh := make(chan string, 2048)
writerDone := make(chan struct{})
var writerDoneOnce sync.Once
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[SSE] writer panic: %v", r)
}
close(writerDone)
safeclose.Safe(&writerDoneOnce, writerDone)
}()
// 批量合并窗口:16ms 内收集的增量一次性 flush,降 flush 次数、
// 避免高频小包拖慢 socket 写导致 writeCh 积压丢 delta。

View File

@ -80,11 +80,11 @@ func graphFixture() map[string]interface{} {
// 修复前 pulse 读的是它,而它永远落在窗口外 ⇒ 恒返回空。
"memory_blocks": []memory.MemoryBlock{
{ID: "b_old", Modality: memory.BlockModality("text"), Text: "老块",
Vector: []float64{0.1, 0.2, 0.3},
Vector: []float64{0.1, 0.2, 0.3},
CreatedAt: old, UpdatedAt: now.Add(-8 * time.Hour)},
{ID: "b_new", Modality: memory.BlockModality("image"), Text: "刚学到的东西",
Data: []byte("png-bytes"), MIME: "image/png",
Vector: []float64{0.1, 0.2, 0.3},
Vector: []float64{0.1, 0.2, 0.3},
CreatedAt: now, UpdatedAt: now},
},
}

View File

@ -25,7 +25,8 @@ import (
// newKnowledgeHandler 构造一个带知识库的 Handler。
//
// ★ 不再有“媒体存储”参数:CAS 淘汰后媒体字节随引用自带(KnowledgeMediaRef.Data),
// 写知识不需要任何外部存储 —— 因此也不存在“存储未初始化”的失败态。
//
// 写知识不需要任何外部存储 —— 因此也不存在“存储未初始化”的失败态。
func newKnowledgeHandler(t *testing.T) (*Handler, *knowledge.Store, string, func()) {
t.Helper()
dir := t.TempDir()
@ -462,8 +463,9 @@ func TestKnowledgeUploadTextFileBecomesContent(t *testing.T) {
// 上传的媒体必须真能取回字节,否则引用是死的。
//
// ★ CAS 淘汰后字节的归属变了:不再是全局内容寻址库,而是**条目目录自带的文件**
// (与 content.md 同生共灭)。所以判据也从“按 digest 向存储读回”改为
// “按条目记录的 File 从条目目录读回,且与上传字节逐字节一致”。
//
// (与 content.md 同生共灭)。所以判据也从“按 digest 向存储读回”改为
// “按条目记录的 File 从条目目录读回,且与上传字节逐字节一致”。
func TestKnowledgeUploadedMediaRetrievable(t *testing.T) {
h, ks, dir, done := newKnowledgeHandler(t)
defer done()

View File

@ -10,6 +10,7 @@ import (
"sync"
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
"github.com/JianFeeeee/HomeAgent/internal/sdk"
)
@ -221,8 +222,9 @@ func (h *Handler) streamOpenAI(ctx context.Context, w http.ResponseWriter, model
v, _ := p["reasoning_content"].(string)
return v, v != ""
})
var doneOnce sync.Once
defer func() {
close(done)
safeclose.Safe(&doneOnce, done)
wg.Wait()
for _, u := range unsubs {
u()

View File

@ -54,6 +54,13 @@ func (m *mockPluginMgr) ReloadPlugins() (string, error) {
}
func (m *mockPluginMgr) ReloadOne(name string) error { return nil }
func (m *mockPluginMgr) StopAndUnload(name string) error { return nil }
// ★ SDK 1.4.0 起 PluginManager 新增 3 个"运行期停/启"方法(与持久禁用以区分)。
// mock 必须跟上接口 —— 否则本包**编译失败**(不是判据红,是整个包挂),
// 表现为 `webui [build failed]`,容易被误读成"webui 坏了"。
func (m *mockPluginMgr) StopPlugin(name string) error { return nil }
func (m *mockPluginMgr) StartPlugin(name string) error { return nil }
func (m *mockPluginMgr) IsPluginRuntimeStopped(name string) bool { return false }
func (m *mockPluginMgr) PluginMetas() map[string]sdk.PluginMeta {
return nil
}

View File

@ -114,6 +114,8 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
}
}
}
// ★ E-3:列表永远默认屏蔽敏感值。明文揭示必须走单键 reveal 接口。
h.maskSecretValues(values, false)
plugins := []string{"core"}
for _, p := range h.settings.Plugins() {
@ -151,6 +153,25 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
if f, ok := body.Value.(float64); ok && f == math.Trunc(f) && math.Abs(f) < 1e15 {
body.Value = int64(f)
}
// ★ 掩码占位符**不是值**,绝不能存回内核。
//
// 真实故障(2026-10-16):设置页里点一次「保存」,前端会把同一组字段
// (含刚被掩码的 api_key)一并回传 ⇒ `••••••••` 被当真实值存进
// `core.llm.sources.<n>.api_key` ⇒ 下游(commondev 的 pi 源导入器)
// 把掩码写进 pi 的 auth.json ⇒ 模型调用 401。
//
// 这里拒绝并**明确告诉调用方怎么做**:它没说"保存失败"就完了,
// 否则用户不知道该如何保存其他字段。
if IsSecretPlaceholder(body.Value) {
writeJSON(w, http.StatusConflict, map[string]string{
"error": "该值是**掩码占位符**(列表接口为防泄漏而显示的“已设置”标记)," +
"不是真实内容;存回它会用标记覆盖真值。" +
"若未修改此字段请**不要保存它**(只保存你改动的键);" +
"若确实要改,请填入新的真值(或先调 reveal 接口取回当前值后再改)。",
"key": body.Key,
})
return
}
deleting := body.Value == nil
if strings.HasPrefix(body.Key, "plugin.") {
parts := strings.SplitN(body.Key, ".", 3)

View File

@ -0,0 +1,117 @@
package webui
// 判据:**Secret 标志必须活着到达 WebUI 的 meta**。
//
// 由来:SDK 的 ConfigDef 一直有 Secret 字段,但内核侧三处转换
// (settingsImpl.RegisterDef / Defs / mapDefs)全部漏掉了它 ——
// 插件声明了 secret,前端拿到的 meta 里却没有这个标记,
// 于是敏感项被渲染成普通文本框明文回显。
//
// 这条判据钉住「声明 → meta」这一段,防止再次被漏掉。
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
internalConfig "github.com/JianFeeeee/HomeAgent/internal/config"
internalSDK "github.com/JianFeeeee/HomeAgent/internal/sdk"
)
func TestSecretFlagReachesSettingsMeta(t *testing.T) {
dir := t.TempDir()
internalConfig.ResetTestCodec(t)
reg := internalConfig.NewConfigRegistry(dir + "/config.db")
defer reg.Close()
// 插件用**两种写法**各声明一个敏感项
sett := internalSDK.NewSettings("secplug", reg)
sett.RegisterDef(internalSDK.ConfigDef{
Key: "by_flag", Type: "string", DisplayName: "标志式", Secret: true,
})
sett.RegisterDef(internalSDK.ConfigDef{
Key: "by_type", Type: "password", DisplayName: "类型式",
})
// 走真实 HTTP 处理器
h := &Handler{settings: internalSDK.NewSettings("webui", reg)}
req := httptest.NewRequest(http.MethodGet, "/api/settings?prefix=plugin.secplug", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusOK {
t.Fatalf("状态码 %d: %s", w.Code, w.Body.String())
}
var body struct {
Meta map[string]map[string]interface{} `json:"meta"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
byFlag, ok := body.Meta["plugin.secplug.by_flag"]
if !ok {
t.Fatalf("meta 里没有 by_flag;实际键: %v", keysOfMeta(body.Meta))
}
if byFlag["secret"] != true {
t.Errorf("Secret:true 未传递到 meta(前端会明文回显): %v", byFlag)
}
byType, ok := body.Meta["plugin.secplug.by_type"]
if !ok {
t.Fatalf("meta 里没有 by_type;实际键: %v", keysOfMeta(body.Meta))
}
if byType["type"] != "password" {
t.Errorf("password 类型应保持: %v", byType)
}
}
func keysOfMeta(m map[string]map[string]interface{}) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// 判据:敏感项在 WebUI 列表里的默认值**必须屏蔽**(E-3),
// 单键 reveal 才能看到明文;磁盘上仍是密文。
func TestSecretPlainInUIButCipherOnDisk(t *testing.T) {
dir := t.TempDir()
internalConfig.ResetTestCodec(t)
reg := internalConfig.NewConfigRegistry(dir + "/config.db")
defer reg.Close()
sett := internalSDK.NewSettings("webui", reg)
sett.RegisterDef(internalSDK.ConfigDef{Key: "apikey", Type: "password"})
if err := sett.Set("apikey", "ui-visible-secret-123456"); err != nil {
t.Fatal(err)
}
h := &Handler{settings: internalSDK.NewSettings("webui", reg)}
req := httptest.NewRequest(http.MethodGet, "/api/settings?prefix=plugin.webui", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
if strings.Contains(w.Body.String(), "ui-visible-secret-123456") {
t.Fatal("E-3:设置列表不应回显敏感明文")
}
if !strings.Contains(w.Body.String(), SecretPlaceholder) {
t.Fatal("设置列表应显示敏感值占位符")
}
// 显式揭示仍可核对(单键、当前 WebUI 插件范围)。
reveal := httptest.NewRecorder()
h.handleSettingsReveal(reveal, httptest.NewRequest(http.MethodGet, "/api/settings/reveal?key=plugin.webui.apikey", nil))
if reveal.Code != http.StatusOK || !strings.Contains(reveal.Body.String(), "ui-visible-secret-123456") {
t.Fatalf("显式 reveal 应返回当前插件的明文: status=%d body=%s", reveal.Code, reveal.Body.String())
}
var raw string
if err := reg.DB().QueryRow(
`SELECT value FROM config_webui WHERE key='apikey'`).Scan(&raw); err != nil {
t.Fatal(err)
}
if strings.Contains(raw, "ui-visible-secret") {
t.Errorf("磁盘上不应有明文: %q", raw)
}
}

View File

@ -0,0 +1,150 @@
package webui
// 密钥可见性(E-3):设置面的明文屏蔽与显式揭示。
//
// ## 问题
//
// `GET /api/v1/settings` 此前直接返回 `Dump()` 的**解密后**结果 ——
// 也就是任何能打开设置页的人都能看到全部密钥(含其他插件的)。
// 落盘的 AES 加密因此只保护了静态文件,对“已运行的服务”毫无作用。
//
// ## 不是靠“能不能进设置页”,而是靠显式揭示
//
// 权威用户的判断是:**任何能进设置页的人都可见**,所以要不要放宽不是重点;
// 重点是**默认不显示**,要看必须显式请求(且留痕),并且只看得到**自己**的。
//
// ## 两条纪律
//
// 1. **默认屏蔽**:列表接口对敏感键只返回占位符,真值要单独揭示。
// 2. **不跨插件**:`plugin.<other>.*` 的敏感值一律不可揭示 ——
// 否则“插件 A 的页面能读到插件 B 的密钥”,比不屏蔽更糟
// (因为它看起来像是被管住了)。
import (
"net/http"
"strings"
)
// SecretPlaceholder 是敏感值在列表里的占位符。
//
// 用固定串而不是“空”或“***”:前端据此显示「已设置(点击查看)」,
// 而空串会被误认成“没配”。
const SecretPlaceholder = "••••••••"
// currentPluginOf 返回请求方所属插件名(webui 的接口是给 WebUI 自己的)。
func (h *Handler) currentPluginOf() string { return "webui" }
// maskSecretValues 把 values 里的敏感键换成占位符。
//
// reveal 为真时**不**屏蔽(调用方已做权限判定)。
func (h *Handler) maskSecretValues(values map[string]interface{}, reveal bool) {
if reveal || h.settings == nil {
return
}
for k, v := range values {
if v == nil || v == "" {
continue // 未设置:保持原样,否则前端会把“没配”显示成“已配”
}
if h.settings.IsSecret(k) {
values[k] = SecretPlaceholder
}
}
}
// canRevealKey 判定某键的明文是否允许被揭示给当前调用方。
//
// 规则:核心键(core.*)与**本插件**的键可揭示;其他插件的键不可。
func (h *Handler) canRevealKey(key string) bool {
if !strings.HasPrefix(key, "plugin.") {
return true // 核心配置:调用方已是能进设置页的权威用户
}
parts := strings.SplitN(key, ".", 3)
if len(parts) < 3 {
return false
}
return parts[1] == h.currentPluginOf()
}
// handleSettingsReveal 是 `GET /api/v1/settings/reveal?key=...` 的实现。
//
// 显式揭示**单个**键的明文。为何一次只给一个:
// 批量揭示等于把“屏蔽”又变回“一次全给你”,屏蔽就成了装饰。
func (h *Handler) handleSettingsReveal(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.settings == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "config registry not available"})
return
}
key := strings.TrimSpace(r.URL.Query().Get("key"))
if key == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "key 必填"})
return
}
if !h.settings.IsSecret(key) {
// 非敏感键无需揭示(但也不报错:前端可能不分类型地调用)
writeJSON(w, http.StatusOK, map[string]interface{}{
"key": key, "value": h.getSettingValue(key), "secret": false,
})
return
}
if !h.canRevealKey(key) {
// ★ 不跨插件。这里必须拒绝,否则“插件页能读别的插件密钥”。
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "不允许读取其他插件的敏感配置(E-3)",
})
return
}
// 留痕:揭示明文是特权动作,必须可审计。
if h.settings != nil {
_ = h.settings.SetPlugin("webui", "last_secret_reveal", key)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"key": key, "value": h.getSettingValue(key), "secret": true,
})
}
// getSettingValue 按 namespace 取一个配置值(plugin.* 走插件表)。
func (h *Handler) getSettingValue(key string) interface{} {
if strings.HasPrefix(key, "plugin.") {
parts := strings.SplitN(key, ".", 3)
if len(parts) >= 3 {
v, _ := h.settings.GetPlugin(parts[1], parts[2])
return v
}
return nil
}
v, _ := h.settings.GetCore(key)
return v
}
// IsSecretPlaceholder 报告一个值是否是掩码占位符本身。
//
// ## 为什么写侧必须判它(真实故障,2026-10-16)
//
// E-3 让列表接口把敏感值换成 `••••••••`(防泄漏,这部分是对的),
// 但**写侧此前完全不检查占位符**,于是:
//
// ① 设置页 GET 拿到掩码 `••••••••`
// ② 用户点同一个表单里的「保存」按钮(前端 saveSetting 会一并回传该组的所有字段,
// 包括刚被掩码的 api_key)——**即使用户只想改别的字段**
// ③ PUT 把 `••••••••` 当真实值写进内核 `core.llm.sources.<n>.api_key`
// ④ 任何读该键的下游(如 commondev 的 pi 源导入器)拿到的是**掩码而不是 key**
// ⑤ pi 用它调 LLM ⇒ 401 invalid api key
//
// 实测证据:内核 `core.llm.api_key`(主键)解出来是**真 key**,
// 而 `core.llm.sources.llmsproxy.api_key`(子键)解出来是 `••••••••`。
// 插件导入器优先读 `sources.*` ⇒ 掩码进了 pi 的 auth.json。
//
// ⇒ 占位符**不是值**,它是"这里有个值但我不告诉你"的标记。
//
// 把它存回去等于用标记覆盖了真值。这不是"校验用户输入",而是**语义错误**。
func IsSecretPlaceholder(v interface{}) bool {
s, ok := v.(string)
if !ok {
return false
}
return strings.TrimSpace(s) == SecretPlaceholder
}

View File

@ -0,0 +1,316 @@
package webui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
internalConfig "github.com/JianFeeeee/HomeAgent/internal/config"
"github.com/JianFeeeee/HomeAgent/internal/sdk"
"github.com/JianFeeeee/HomeAgent/internal/supervisor"
"github.com/JianFeeeee/HomeAgent/pkg/types"
)
// ★ E-3:设置列表默认不得返回任何敏感明文(包括其他插件的)。
func TestSettingsMasksSecretValues(t *testing.T) {
reg := internalConfig.NewConfigRegistry("")
reg.RegisterDef(internalConfig.ConfigDef{Key: "core.llm.api_key", Type: "string", Secret: true})
if err := reg.Set("core.llm.api_key", "CORE_SECRET"); err != nil {
t.Fatal(err)
}
qq := reg.PluginConfig("qq")
qq.RegisterDef(internalConfig.ConfigDef{Key: "access_token", Type: "string", Secret: true})
if err := qq.Set("access_token", "QQ_SECRET"); err != nil {
t.Fatal(err)
}
sup := supervisor.New(&types.Config{Daemon: types.DaemonConfig{CheckInterval: 1, HeartbeatInterval: 1}})
sup.Start()
defer sup.Shutdown()
h := NewHandler(testSDK(sdk.SDKConfig{Supervisor: supervisor.NewSDKAdapter(sup), Settings: sdk.NewSettings("webui", reg), Config: sdk.NewConfig(&types.Config{})}))
w := httptest.NewRecorder()
h.handleSettings(w, httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil))
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if strings.Contains(body, "CORE_SECRET") || strings.Contains(body, "QQ_SECRET") {
t.Fatalf("★ settings 泄露敏感明文: %s", body)
}
if !strings.Contains(body, SecretPlaceholder) {
t.Fatal("敏感值应显示占位符")
}
}
// ★ E-3:单键显式揭示允许核心/本插件,拒绝其他插件。
func TestSettingsRevealScope(t *testing.T) {
reg := internalConfig.NewConfigRegistry("")
reg.RegisterDef(internalConfig.ConfigDef{Key: "core.llm.api_key", Type: "string", Secret: true})
reg.Set("core.llm.api_key", "CORE_SECRET")
web := reg.PluginConfig("webui")
web.RegisterDef(internalConfig.ConfigDef{Key: "api_key", Type: "string", Secret: true})
web.Set("api_key", "WEB_SECRET")
qq := reg.PluginConfig("qq")
qq.RegisterDef(internalConfig.ConfigDef{Key: "access_token", Type: "string", Secret: true})
qq.Set("access_token", "QQ_SECRET")
sup := supervisor.New(&types.Config{Daemon: types.DaemonConfig{CheckInterval: 1, HeartbeatInterval: 1}})
sup.Start()
defer sup.Shutdown()
h := NewHandler(testSDK(sdk.SDKConfig{Supervisor: supervisor.NewSDKAdapter(sup), Settings: sdk.NewSettings("webui", reg), Config: sdk.NewConfig(&types.Config{})}))
for _, tc := range []struct {
key, want string
status int
}{
{"core.llm.api_key", "CORE_SECRET", http.StatusOK},
{"plugin.webui.api_key", "WEB_SECRET", http.StatusOK},
{"plugin.qq.access_token", "不允许读取其他插件", http.StatusForbidden},
} {
w := httptest.NewRecorder()
path := "/api/v1/settings/reveal?key=" + tc.key
h.handleSettingsReveal(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != tc.status {
t.Errorf("%s status=%d want=%d body=%s", tc.key, w.Code, tc.status, w.Body.String())
continue
}
if !strings.Contains(w.Body.String(), tc.want) {
t.Errorf("%s body lacks %q: %s", tc.key, tc.want, w.Body.String())
}
if tc.status == http.StatusOK {
var got map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["secret"] != true {
t.Errorf("%s should mark secret=true", tc.key)
}
}
}
}
// ★★★ 判据:**掩码占位符绝不能经 PUT 存回内核**。
//
// 真实故障(2026-10-16,生产实测):
//
// ① 设置页 GET 把 api_key 掩码成 `••••••••`(E-3 防泄漏,本身是对的)
// ② 前端「保存」按钮会**一并回传**该组全部字段(含刚被掩码的 api_key)——
// 用户往往只想改同组的 base_url/model
// ③ 写侧此前**完全不检查占位符** ⇒ `••••••••` 被当真实值存进
// `core.llm.sources.<n>.api_key`
// ④ commondev 的 pi 源导入器优先读 `sources.*` ⇒ 把掩码写进 pi 的 auth.json
// ⑤ pi 拿掩码调模型 ⇒ 401 invalid api key(表现为"插件装了但模型不回复")
//
// 铁证:内核 `core.llm.api_key`(主键)解密后是**真 key**,
// 而 `core.llm.sources.llmsproxy.api_key`(子键)解密后是 `••••••••`。
//
// ⇒ 占位符是"这里有个值但我不告诉你"的**标记**,不是值。
//
// 把它存回去等于用标记覆盖真值 —— 这不是输入校验问题,是语义错误。
func TestSettingsPutRejectsSecretPlaceholder(t *testing.T) {
reg := internalConfig.NewConfigRegistry("")
reg.RegisterDef(internalConfig.ConfigDef{Key: "core.llm.sources.x.api_key", Type: "string", Secret: true})
if err := reg.Set("core.llm.sources.x.api_key", "REAL_KEY_VALUE"); err != nil {
t.Fatal(err)
}
sup := supervisor.New(&types.Config{Daemon: types.DaemonConfig{CheckInterval: 1, HeartbeatInterval: 1}})
sup.Start()
defer sup.Shutdown()
h := NewHandler(testSDK(sdk.SDKConfig{Supervisor: supervisor.NewSDKAdapter(sup), Settings: sdk.NewSettings("webui", reg), Config: sdk.NewConfig(&types.Config{})}))
put := func(v interface{}) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]interface{}{
"key": "core.llm.sources.x.api_key", "value": v,
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(string(body)))
w := httptest.NewRecorder()
h.handleSettings(w, req)
return w
}
// ① 掩码必须被拒绝(这是缺陷本体)
w := put(SecretPlaceholder)
if w.Code == http.StatusOK {
t.Fatalf("★ 掩码占位符被接受了(接口返回 200)—— 真值已被覆盖: %s", w.Body.String())
}
// ② 拒绝之后真值**必须还在**(拒绝而不是写坏)
got, _ := reg.Get("core.llm.sources.x.api_key")
if s, _ := got.(string); s != "REAL_KEY_VALUE" {
t.Fatalf("★ 拒绝后真值被改写: %q(拒绝必须是原子的:不写)", s)
}
// ③ 错误信息必须告诉用户**怎么办**(否则他不知道如何保存同组其他字段)
msg := w.Body.String()
for _, want := range []string{"掩码", "不要保存"} {
if !strings.Contains(msg, want) {
t.Errorf("错误应含 %q 以指导操作: %s", want, msg)
}
}
// ④ 真值仍然可以正常保存(不能误伤)
if w := put("NEW_REAL_KEY"); w.Code != http.StatusOK {
t.Fatalf("真值应可保存: %d %s", w.Code, w.Body.String())
}
got, _ = reg.Get("core.llm.sources.x.api_key")
if s, _ := got.(string); s != "NEW_REAL_KEY" {
t.Fatalf("真值保存失败: %q", s)
}
// ⑤ 相似但不全等的值不得被误杀(真 key 可能含任意字符)
for _, v := range []string{"•••", "••••••••X", "x••••••••", "••••••••••••••••"} {
if w := put(v); w.Code != http.StatusOK {
t.Errorf("值 %q 不该被当作占位符拒绝: %d %s", v, w.Code, w.Body.String())
}
}
}
// ★★★ **行为判据**:前端保存时**真的不会**把掩码回传。
//
// 为什么不能只看源码字符串(本会话反复踩的坑):初版断言
// "saveSetting 里出现 isSecretPlaceholderValue(inp.value)",
// 把条件改成 `if (false && isSecretPlaceholderValue(...))` 就绕过了 ——
// 判据全绿而缺陷照样存在。这里把 dashboard.js 里的两个函数**抠出来真跑**
// (用 node + 假 DOM/假 api),断言"发出去的请求体"里没有掩码。
func TestDashboardSkipsPlaceholderOnSave(t *testing.T) {
node, err := exec.LookPath("node")
if err != nil {
t.Skip("本机无 node,跳过行为判据")
}
b, err := os.ReadFile("dashboard.js")
if err != nil {
t.Skipf("读不到 dashboard.js: %v", err)
}
src := string(b)
// 抠出需要的两个函数(按名字定位到下一个顶层 function/async function)
extract := func(name string) string {
// ★ 先试 async 版本:否则会把 `async function saveSetting(` 从
// "function saveSetting(" 处切开,丢掉 async 前缀 ⇒ await 语法错误。
i := strings.Index(src, "async function "+name+"(")
if i < 0 {
i = strings.Index(src, "function "+name+"(")
}
if i < 0 {
return ""
}
rest := src[i:]
// 找下一个顶层声明作为边界
best := -1
for _, marker := range []string{"\nasync function ", "\nfunction "} {
if j := strings.Index(rest[1:], marker); j >= 0 {
e := j + 1
if best < 0 || e < best {
best = e
}
}
}
if best > 0 {
return rest[:best]
}
return rest
}
phFn := extract("isSecretPlaceholderValue")
if phFn == "" {
t.Fatal("★ 缺少 isSecretPlaceholderValue(前端会把掩码当真值回传)")
}
saveFn := extract("saveSetting")
if saveFn == "" {
t.Fatal("找不到 saveSetting")
}
harness := `
// ---- 假环境:只提供被抠出的两个函数需要的东西 ----
var state = { meta: {}, settings: {} };
var sent = [];
function api(path, opts) { sent.push(JSON.parse(opts.body)); return Promise.resolve({status:"ok"}); }
function toast() {}
function __(a) { return a; }
var INPUTS = {};
var document = { getElementById: function(id) { return INPUTS[id] || null; } };
` + phFn + "\n" + saveFn + `
// ---- 用例 ----
(async function () {
var out = [];
function mkInput(v) { return { value: v, style: {} }; }
// ① 掩码:必须**不发请求**
INPUTS["inp-core_llm_sources_x_api_key"] = mkInput("\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022");
sent.length = 0;
await saveSetting("core.llm.sources.x.api_key");
out.push(["mask", sent.length]);
// ② 真值:必须**发请求**且发的是真值
INPUTS["inp-core_llm_sources_x_api_key"] = mkInput("sk-real-value");
sent.length = 0;
await saveSetting("core.llm.sources.x.api_key");
out.push(["real", sent.length, sent[0] && sent[0].value]);
// ③ 含圆点的真值不得被误杀
INPUTS["inp-some_key"] = mkInput("a\u2022b");
sent.length = 0;
await saveSetting("some.key");
out.push(["dot", sent.length]);
// ④ 空值/普通值正常保存
INPUTS["inp-plain"] = mkInput("hello");
sent.length = 0;
await saveSetting("plain");
out.push(["plain", sent.length]);
console.log(JSON.stringify(out));
})();
`
tmp := filepath.Join(t.TempDir(), "h.js")
if err := os.WriteFile(tmp, []byte(harness), 0o644); err != nil {
t.Fatal(err)
}
out, err := exec.Command(node, tmp).CombinedOutput()
if err != nil {
t.Fatalf("harness 执行失败: %v\n%s", err, out)
}
var got []interface{}
if err := json.Unmarshal(bytesTrim(out), &got); err != nil {
t.Fatalf("harness 输出无法解析: %v\n%s", err, out)
}
if len(got) != 4 {
t.Fatalf("harness 未跑完: %s", out)
}
get := func(i int) []interface{} { return got[i].([]interface{}) }
// ① 掩码:0 次请求
if n := get(0)[1].(float64); n != 0 {
t.Errorf("★ 掩码被回传了(发了 %v 次请求)—— 真值会被掩码覆盖", n)
}
// ② 真值:发 1 次且值正确
r := get(1)
if len(r) < 3 || r[1].(float64) != 1 {
t.Fatalf("★ 真值应正常保存,实际: %v", r)
}
if r[2].(string) != "sk-real-value" {
t.Errorf("★ 保存的值不对: %v", r[2])
}
// ③ 含圆点的真值不得误杀
if n := get(2)[1].(float64); n != 1 {
t.Errorf("★ 含圆点的真值被误杀(%v 次请求)", n)
}
// ④ 普通值正常
if n := get(3)[1].(float64); n != 1 {
t.Errorf("普通值未保存: %v", n)
}
}
func bytesTrim(b []byte) []byte {
i := strings.IndexByte(string(b), '{')
if i < 0 {
return b
}
j := strings.LastIndexByte(string(b), '}')
if j < i {
return b
}
return b[i : j+1]
}