fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染

## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道

子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:

    [proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'

**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。

修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
  + 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法

判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。

## hmapdev

- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
  原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
  **导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
  三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)

## 判据

- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
  ★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
  必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
  报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
  (第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据

## 其他

- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)
This commit is contained in:
JianFeeeee
2026-10-17 16:34:29 +08:00
parent c2474f57a2
commit 0111ac3a7a
36 changed files with 5059 additions and 227 deletions

4
.gitignore vendored
View File

@ -45,3 +45,7 @@ site_build/
# 每次改任何一页都会整份重写(115KB),进版本库只产生噪声。它由构建产出,
# llms.txt(索引,小且稳定)仍提交。
docs/llms-full.txt
# ★ rawfile/ 的内容是随包资源(可能内嵌 node/pi 等上百兆二进制),
# 由构建方自行准备,不进版本库。忽略规则放在 rawfile/.gitignore 内
# (自包含,避免依赖上层规则的作用域与否定式语义)。

View File

@ -31,6 +31,198 @@
零值 `InjectOptions` 与旧的三参数方法完全等价,因此存量插件**不需要改、也不需要重编**;
想用新字段的重编即可。
### SDK 1.4.0 → 本轮新增(PluginMgr 运行态启停)⚠️ 有一个例外
本轮给 `PluginMgrAPI` 新增 3 个方法(`StopPlugin` / `StartPlugin` /
`IsPluginRuntimeStopped`,见 `docs/zh/kernel-modularization-design.md` §2.6.7)。
按上面的规则,这属于"**插件调用、内核实现**"的纯追加 ⇒ 无需重编。**但有一个例外**:
| 对象 | 是否需要动 | 原因 |
|---|---|---|
| **已编译的存量插件**(`plugin.bin`) | ❌ 不需要 | 不调新方法就不受影响(与 1.1/1.2 一致) |
| **存量插件的源码要重编** | ⚠️ **需要同步 hmapdev** | `proc_main.go.tmpl` 里的 `procPluginMgr` 必须实现新方法,否则**编译不过** |
| **新插件** | ✅ 自动 | 模板已同步(`hmapdev` 已更新) |
!!! warning "为什么这次和 1.1/1.2 不一样"
1.1/1.2 只改了 `sdk/`(插件**调用**的一侧)与内核实现。
本轮还改了 **`tools/hmapdev/templates/proc_main.go.tmpl`** ——
那是**生成插件代码**的模板,属于"插件**被编译**的一侧"。
`plugin.bin` 里的 `procPluginMgr` 是**编译期**由模板生成的具体类型,
它必须满足 `PluginMgrAPI` 的**全部**方法(Go 接口是精确匹配)。
模板不同步 ⇒ 生成物不满足接口 ⇒ **编译失败**(不是运行期降级)。
⇒ 判据 `internal/plugin/proc/bridge_sync_test.go` 已有 3 条自动守这个
(SDK 方法集 ⇒ 能力表 / Handle 分派 / hmapdev 模板,三处必须同步)。
## 脚手架模板的协议通道纪律(`hmapdev init` 生成物)
子进程插件的 **stdout 是 JSON-RPC 协议通道**,**stderr 才是日志通道**
(SDK 侧 `log.SetOutput(os.Stderr)`)。这条纪律**必须写进脚手架**,
否则每个新插件作者都会踩。
### 曾踩的坑(2026-10-16 实测)
`hmapdev init` 生成的 `plugin.go` 原先用 `fmt.Printf` 打日志 ⇒ 写进 stdout
⇒ 内核 readLoop 收到非 JSON 文本:
```
[proc] final_probe 收到非法 JSON 帧(21 字节): invalid character 'i' in literal false
```
两个后果叠加、都很难定位:
1. 那句日志**丢了**(内核把它当坏帧丢弃);
2. 多了一条看起来像**协议故障**的报错 —— 而真因是"插件自己 printf"。
**A/B 实证**(同一内核,两个插件):
| 插件 | 脚手架 | 非法 JSON 帧 | 日志可见 |
|---|---|---|---|
| `final_probe` | 旧(`fmt.Printf`) | **1 次** | ❌ 丢失 |
| `fixed_probe` | 新(`log.Printf`) | **0 次** | ✅ `[fixed_probe] started` |
### 修法
模板改成 `log.Printf`,并把这条纪律写进生成物的**头部注释**
(让每个插件作者第一眼就看到)。
### 判据(`tools/hmapdev/scaffold_stdout_test.go`,3 条)
1. `TestScaffoldTemplateDoesNotWriteStdout` —— **代码行**里不得有
`fmt.Printf/Println/Print/os.Stdout`
2. `TestScaffoldTemplateUsesStderrLogging` —— 反向钉住:光禁 stdout 不够,
把日志全删也能过第一条 ⇒ 必须**真的**用 `log.Printf`(并 import "log")
3. `TestScaffoldLuaTemplateDoesNotWriteStdout` —— Lua 脚手架同理
**变异 2/2 咬住**:改回 `fmt.Printf` ⇒ 红;删光日志 ⇒ 红。
!!! warning "判据自身踩的坑(值得记住)"
第一版判据直接扫描模板字符串 ⇒ **被模板里的注释判红**(注释**故意**
提到 `fmt.Printf` 来讲这个坑)。必须**先剥注释**再扫代码行。
这是"源码扫描判据"的固有脆弱性:本仓规范要求把坑写进注释,
而注释里必然出现那些符号名 ⇒ **任何时候用字符串扫描判据,
都要先想"注释里会不会也有这个串"**(同类事故见
`docs/zh/kernel-modularization-design.md` §8.6,那里连续骗过三次)。
## 示例插件的协议通道纪律(已修)
上面的脚手架纪律同样适用于**示例插件** —— 它们是**子进程插件**
(`package main` + `plugin.bin`),stdout 即协议通道。
### 生产环境实测的缺陷(不是理论问题)
`journalctl -u homeagent | grep -c "非法 JSON 帧"` ⇒ **1038 条**,来源:
| 插件 | 次数 | 本仓? |
|---|---|---|
| `huawei_smarthome` | 720 | 第三方(内核侧报错就是为它准备的) |
| **`weather`** | 113 | ✅ 示例 |
| **`rss`** | 103 | ✅ 示例 |
| **`calendar`** | 102 | ✅ 示例 |
**根因**:这三个示例插件(以及 `ai_image`)用了 `fmt.Printf` 打日志 ⇒
裸文本进入 stdout 协议流 ⇒ 内核报非法帧并**丢弃该日志**。
### 修法(已完成)
4 个插件共 **14 处** `fmt.Printf` → `log.Printf`,并补 `"log"` import
(`calendar`/`rss`/`weather` 原先没 import log;`ai_image` 已有)。
★ 注意:`internal/plugins/cli` 里也有一处 `fmt.Println`,但它是
**进程内插件**(`plugin.RegisterFactory`,编译进内核),
写的是内核的真实终端 ⇒ **不是缺陷**。判据只针对"子进程插件"
(`package main`)。
### 判据(`tools/hmapdev/example_stdout_test.go`,2 条)
1. `TestExamplePluginsDoNotWriteStdout` —— 扫描全部**子进程**示例插件
(实测 20 个),剥注释后不得含 `fmt.Printf/Println/Print/os.Stdout`;
报错时给出**文件:行号**
2. `TestExamplePluginsUseStderrLogging` —— 反向钉住:至少 weather/rss/calendar
真的在用 `log.Printf`(否则"删光日志"也能过第一条)
### 内核侧:让非法帧报错**可定位**(这是对第三方插件的通用修复)
原报错只有 `收到非法 JSON 帧(N 字节): invalid character 'x'`:
不显示内容(不知是哪句话)、不提原因(看起来像内核故障)。
1038 条报错的定位花了一整轮排查 —— 就因为缺这两样。
现在报错含**三要素**:
```
[proc] bad_probe 收到非法 JSON 帧(19 字节): invalid character 'b' looking for beginning of value
↑ 内容摘要: [bad_probe] started
可能原因:插件往 **stdout** 打印了日志,而 stdout 是JSON-RPC **协议通道**(stderr 才是日志通道)。
修法:把插件里的 fmt.Printf/fmt.Println 改成 log.Printf(脚手架模板 1.4.0 起已修正)。
```
(`summarizeFrame` 做控制字符替换 + 截断,保证单行且不刷屏。)
判据在 `internal/plugin/proc/process_test.go`:
`TestSummarizeFrameIsReadable`、`TestIllegalFrameErrorIsActionable`。
**变异 2/2 咬住**(去掉 summarizeFrame 调用 ⇒ 红;报错不提原因 ⇒ 红)。
## 升级已装的 SDK store 与 hmapdev 二进制(操作步骤)
!!! danger "顺序不能反 —— 否则会装出「二进制新、store 旧」的组合"
**模板是 `//go:embed` 进 hmapdev 二进制的**(`tools/hmapdev/proc_runtime.go`),
而 **SDK 接口(`sdk/`)来自 store**。二者必须**同代**:
| 组合 | 结果 |
|---|---|
| 旧二进制 + 旧 store | 自洽(但拿不到新能力) |
| 新二进制 + 新 store | ✅ 自洽 |
| **新二进制 + 旧 store** | ⚠️ 模板的 `procPluginMgr` 有 6 个方法,旧接口只有 3 个 ⇒ 生成物**多出 3 个方法**(Go 允许,但新方法调用的 `plugin.mgrStop` 内核可能不认) |
| **旧二进制 + 新 store** | ❌ 模板 3 个方法 vs 接口 6 个 ⇒ 生成物**不满足接口** ⇒ **编译失败** |
⇒ **先升级 store,再升级二进制**(或两者同一批做完)。
### 步骤(本机,用源码)
```bash
# 1) 用**当前**hmapdev 把本机源码装成 store(--from 在旧版也支持)
hmapdev sdk install --from <SDK 源码目录> # 例:third_party/homeagent-sdk
# 2) 重编 hmapdev 二进制(模板 embed 进去了,必须重编)
cd <SDK 源码>/tools/hmapdev
CGO_ENABLED=1 go build -tags onnxruntime -o /tmp/hmapdev-new . # TAG_ARGS 视内核而定
# 3) 原子替换(先备份!)
cp /usr/local/bin/hmapdev /usr/local/bin/hmapdev.bak.$(date +%Y%m%d-%H%M%S)
install -m 0755 /tmp/hmapdev-new /usr/local/bin/hmapdev
```
### 验证(三步都要过)
```bash
hmapdev --version # 版本号
strings /usr/local/bin/hmapdev | grep -c mgrStop # ≥1 ⇒ 新模板已 embed
diff <源码>/tools/hmapdev/templates/proc_main.go.tmpl \
~/.homeagent/hmapdev/sdk/<ver>/tools/hmapdev/templates/proc_main.go.tmpl # 必须无差异
```
★ **`diff` 陷阱**:本机 PATH 上的 `diff` 是 HarmonyOS 工具链里的一个
**坏二进制**(`/opt/huawei/harmonyos/ohos-sdk/linux/toolchains/diff`),
对**不同**的文件也返回 **exit 0 且无输出** ⇒ 会给出"已同步"的**假结论**。
用 `cmp` 或 Python 比对,或显式用 `/usr/bin/diff`。
### 判据(自动守"三处同步")
`internal/plugin/proc/bridge_sync_test.go` 有 3 条,任一不同步即红:
1. `TestBridgeCoversEveryPublishedPluginMgrMethod` —— SDK 方法集 ↔ 能力表
2. `TestBridgeDispatchesEveryPluginMethod` —— 内核 Handle 分派
3. `TestHmapdevTemplateImplementsAllPluginMgrMethods` —— **hmapdev 模板**
第 3 条直接读 `third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl`,
所以**源码模板**始终被守;store 里的副本靠上面的 `diff` 验证。
!!! tip "什么时候必须重编"
只有一种情况:**内核跨了主版本**(如 1.x → 2.x)。
主版本跃迁是破坏性变更的定义,此后的帧格式与共享段布局都不兼容,

View File

@ -17,15 +17,15 @@ import (
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
client *http.Client
apiKey string
name string
sdk *sdk.PluginSDK
client *http.Client
apiKey string
provider string
model string
size string
baseURL string
dataDir string // <data>/ai_images:生成本地图片存放目录
model string
size string
baseURL string
dataDir string // <data>/ai_images:生成本地图片存放目录
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -119,12 +119,12 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "base_url", Default: "", Type: "string",
DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081);为空走官方 https://api.openai.com",
Category: "ai_image",
Category: "ai_image",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "provider", Default: "openai", Type: "string",
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
Category: "ai_image",
Category: "ai_image",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "model", Default: "dall-e-3", Type: "string",
@ -143,17 +143,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.size = getSetting(s.Settings(), "size", "1024x1024")
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
if p.sdk != nil {
if dd := s.Settings().DataDir(); dd != "" {
p.dataDir = dd
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
if p.sdk != nil {
if dd := s.Settings().DataDir(); dd != "" {
p.dataDir = dd
}
}
}
if p.dataDir == "" {
// 旧版内核无 DataDir API 时退到 /tmp
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
}
os.MkdirAll(p.dataDir, 0755)
if p.dataDir == "" {
// 旧版内核无 DataDir API 时退到 /tmp
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
}
os.MkdirAll(p.dataDir, 0755)
tp := p.name + "_"
s.RegisterTool(tp+"generate", sdk.ToolDef{
@ -170,12 +170,12 @@ os.MkdirAll(p.dataDir, 0755)
},
}, p.handleGenerate)
fmt.Printf("[%s] started (provider=%s, model=%s)\n", p.name, p.provider, p.model)
log.Printf("[%s] started (provider=%s, model=%s)", p.name, p.provider, p.model)
return nil
}
func (p *Plugin) Stop() error {
fmt.Printf("[%s] stopped\n", p.name)
log.Printf("[%s] stopped", p.name)
return nil
}

View File

@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@ -285,7 +286,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar")
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
log.Printf("[%s] mkdir %s: %v", p.name, p.dataDir, err)
}
p.loadEvents()
@ -401,7 +402,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.wg.Add(1)
go p.remindLoop()
fmt.Printf("[%s] started (%d events)\n", p.name, len(p.events))
log.Printf("[%s] started (%d events)", p.name, len(p.events))
return nil
}
@ -409,7 +410,7 @@ func (p *Plugin) Stop() error {
p.remindTicker.Stop()
close(p.stopCh)
p.wg.Wait()
fmt.Printf("[%s] stopped\n", p.name)
log.Printf("[%s] stopped", p.name)
return nil
}
@ -611,9 +612,9 @@ func (p *Plugin) cleanupData() {
p.mu.Lock()
defer p.mu.Unlock()
if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) {
fmt.Printf("[calendar] onRemove cleanup: %v\n", err)
log.Printf("[calendar] onRemove cleanup: %v", err)
} else {
fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile())
log.Printf("[calendar] onRemove removed %s", p.eventsFile())
}
}

View File

@ -1,49 +1,62 @@
# editdoc · Office 文档编辑
# editdoc · 全能办公
编辑 `.docx` / `.xlsx` / `.pptx` 内容:查找替换、改单元格、插行。
对 `.docx` / `.xlsx` / `.pptx` / `.xls` / `.csv` / `.md` / `.txt` 做**新建、读取、编辑、转换**。
> ⚠️ **版本说明**:本目录是 **v1.0.0**,只有 `edit_document` 一个工具。
> 线上部署的 v2.0.0(全能办公版,支持新建/读取/转换 docx·xlsx·pptx·md·csv·txt)
> **源码尚未公开**,本文档不描述那些能力。参见 `plugin.json` 的 `version`。
> **版本**:v2.0.0(全能办公)。较 v1.0.0 新增 `read_document`、`create_document`、
> `create_from_template`、`convert_document`、`office_check` 五个工具;
> `edit_document` 的名字与全部 v1 参数**保持不变**,老调用不会破。
## 工具
| 工具 | 说明 |
| 工具 | 作用 |
|---|---|
| `edit_document` | 编辑文档内容,**编辑后原文件被覆盖** |
参数:
| 参数 | 说明 |
|---|---|
| `file` | 文档路径(必填) |
| `operation` | `replace_text`(查找替换)/ `set_cell`(设置单元格)/ `insert_row`(插入行)(必填) |
| `target` | 要查找的文本(`replace_text` 用) |
| `replacement` | 替换为的文本(`replace_text` 用) |
| `sheet` | 工作表名(xlsx 可选) |
| `row` | 行号(`set_cell` / `insert_row` 用) |
| `col` | 列号(`set_cell` 用) |
| `value` | 单元格值(`set_cell` 用) |
编辑前建议先读一遍内容确认目标文本 —— 查找替换是**全文件覆盖写**,没有撤销。
| `read_document` | 读为结构化 JSON(docx 段落+表格+页眉页脚 / xlsx 逐表逐格含公式与计算值 / pptx 逐页 / xls / csv / md / txt),大文件可 `offset`/`limit` 分段 |
| `create_document` | 从零新建。docx 支持 Markdown 正文;xlsx 支持多表(表名+表头+行);pptx 用 `##` 分页 |
| `create_from_template` | 复制模板再套用替换 |
| `edit_document` | 编辑既有文件(v1 八参数 + 查找替换/正则大小写/第 N 处/表格与单元格/图片/样式/幻灯片/`dry_run`) |
| `convert_document` | 格式转换(docx↔md/pdf、xlsx↔csv 等,借助 soffice / pandoc) |
| `office_check` | 自检:依赖库可用性与版本、外部工具、格式×能力矩阵 |
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `script_path` | 空 | `edit_doc.py` 的绝对路径。留空则用插件可执行文件同目录下的 `edit_doc.py` |
| `venv_python` | 空 | 执行 `edit_doc.py` 的 Python 解释器(建议用 venv 里的)。**必须配置,留空会报错** |
| `script_path` | 空 | `edit_doc.py` 的绝对路径。**留空即推荐值** —— 用内嵌脚本;仅当该文件含 v2 协议标记时才被采用,否则自动忽略并回退内嵌 |
| `venv_python` | 空 | 执行脚本的 Python 解释器(需装 python-docx / openpyxl / python-pptx)。**必须配置** |
| `timeout_sec` | 60 | 单次调用超时;大文件或格式转换(soffice 首次启动慢)建议调到 180 |
## 工作原理
本插件是 Go 写的薄壳:把参数序列化成 JSON,交给 Python 脚本 `edit_doc.py` 执行实际文档操作。
文档解析依赖 Python 侧的库(python-docx / openpyxl / python-pptx 之类),所以:
Go 侧只做参数编排与结构化返回,全部业务逻辑在 Python 脚本 `edit_doc.py` 里。
- **需要自备 `edit_doc.py`**:它不在本目录里。
- 用 `venv_python` 指向装了这些库的解释器,避免污染系统 Python。
**脚本是内嵌的**(`//go:embed embed/edit_doc.py`),原因:`hmapdev` 打包器只把
`plugin.json` + `plugin.bin` 放进 `.hmap`,**不打包任何资源文件**。若让脚本单独
存在于 `plugin_data/`,就会出现「Go 侧升级了、脚本忘了换」的版本漂移。
内嵌 + 内容指纹校验使两侧永远同版本:
1. 首次调用时把内嵌脚本释放到 `DataDir()/runtime/edit_doc.py`,并写 `.stamp` 指纹;
2. 插件升级后指纹变化 → 自动重新释放;
3. `script_path` 显式指向的文件**仅当含 `EDITDOC_PROTOCOL = "v2"` 标记时**才被采用
(便于运维临时覆盖调试);否则忽略并在返回体里带 `script_path_ignored`,让降级可见。
**业务脚本只有一份**:`embed/edit_doc.py`。不要再添加同名的顶层副本 ——
它不会被 embed,改了不生效,而两份同名文件是典型的漂移源(见
`TestOnlyOneBusinessScript`)。
## 依赖
Python 侧:`python-docx`、`openpyxl`、`python-pptx`(`.xls` 另需 `xlrd`)。
外部工具(可选,转换能力用):`soffice`(LibreOffice)、`pandoc`。
用 `office_check` 随时查看本机实际可用的能力矩阵。
## 构建
```bash
hmapdev build
```
## 测试
```bash
go test ./...
```

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,18 @@ module editdoc
go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0
require github.com/JianFeeeee/homeagentsdk v1.4.0
replace github.com/JianFeeeee/homeagentsdk => ../../
replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

View File

@ -1,5 +1,3 @@
//go:build !windows || !cgo
package main
import (

View File

@ -1,15 +1,23 @@
{
"name": "editdoc",
"name_zh": "文档编辑",
"name_zh": "全能办公",
"name_en": "Document Editor",
"version": "1.0.0",
"description": "办公文档编辑工具(.docx),基于 Python python-docx 库实现。支持文本替换、表格操作、内容插入等。",
"version": "2.0.0",
"description": "全能办公插件:新建/读取/编辑/转换 docx·xlsx·pptx·md·csv·txt(基于 python-docx / openpyxl / python-pptx / soffice / pandoc)",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["editdoc", "office", "document"],
"entry": "plugin.bin",
"tags": [
"editdoc",
"office",
"document",
"excel",
"word",
"powerpoint"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}
"source_dirs": [],
"sdk": "1.4.0"
}

View File

@ -2,21 +2,60 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// editDocPy 内嵌 Python 后端脚本。
//
// 为何必须内嵌:hmapdev 的打包器只把 plugin.json + plugin.bin 放进 .hmap
// (加上可选的 README/LICENSE),**不打包任何资源文件**。
// 而本插件的全部业务逻辑就在 edit_doc.py 里,若让它单独存在于
// plugin_data/editdoc/,就会出现「Go 侧升级了、脚本忘了换」的版本漂移
// (mc 插件就踩过这个坑:工具注册了但子脚本还是旧的,报未知命令)。
// 内嵌 + 内容指纹校验可以让两侧永远同版本。
//
//go:embed embed/edit_doc.py
var embeddedScript []byte
// scriptStamp 返回内嵌脚本的内容指纹。
func scriptStamp() string {
h := sha256.Sum256(embeddedScript)
return hex.EncodeToString(h[:])[:16]
}
// scriptProtocolMarker 是 edit_doc.py 里的 v2 协议标记(见 embed/edit_doc.py 顶部)。
// resolveScript 靠它识别「script_path 指向的是不是 v2 脚本」,防止旧脚本被误用。
const scriptProtocolMarker = `EDITDOC_PROTOCOL = "v2"`
// Plugin 是 editdoc v2.0.0(全能办公)插件。
//
// 设计要点(与 v1.0.0 的关系):
// - edit_document 工具名与 file/operation/target/replacement/sheet/row/col/value
// 参数语义**完全保留**,老调用不会破。
// - 全部实现委托给同目录下的 edit_doc.py,Go 侧只做参数编排 + 结构化返回。
// - 所有子命令统一走 runScript,错误一律结构化(ok:false + error + hint),不 panic。
type Plugin struct {
name string
sdk *sdk.PluginSDK
scriptPath string
venvPython string
timeoutSec int
// ignoredScriptPath 记录「配置里的 script_path 因不是 v2 脚本而被忽略」的路径。
// 非空时 runScript 会在返回体里带上 script_path_ignored,让上层看得见这次降级。
ignoredScriptPath string
}
func (p *Plugin) Name() string { return p.name }
@ -25,138 +64,469 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
// ---- 配置项(v1 的 script_path / venv_python 原样保留,新增 timeout_sec)----
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "script_path", Default: "", Type: "string",
DisplayName: "编辑脚本路径",
Description: "edit_doc.py 的绝对路径;留空时使用插件可执行文件同目录下的 edit_doc.py",
Category: p.name,
DisplayName: "办公脚本路径",
Description: "edit_doc.py 的绝对路径;留空(推荐)时使用 DataDir()/runtime/edit_doc.py(升级时按指纹自动更新)。" +
"仅当该文件含 v2 协议标记时才会被采用,否则自动忽略并回退内嵌脚本",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "venv_python", Default: "", Type: "string",
DisplayName: "venv Python 解释器",
Description: "执行 edit_doc.py 使用的 Python 解释器(建议用 venv 内的 python);必须配置,留空将报错",
Description: "执行 edit_doc.py 使用的 Python 解释器(需装有 python-docx/openpyxl/python-pptx);必须配置",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "timeout_sec", Default: 60, Type: "int",
DisplayName: "脚本超时(秒)",
Description: "单次调用 edit_doc.py 的超时上限;大文件或格式转换(soffice 首次启动慢)建议调到 180",
Category: p.name, Min: 5, Max: 900,
})
if v, err := s.Settings().Get("script_path"); err == nil {
if str, ok := v.(string); ok {
p.scriptPath = str
}
}
if v, err := s.Settings().Get("venv_python"); err == nil {
if str, ok := v.(string); ok {
p.venvPython = str
}
}
s.RegisterTool("edit_document", sdk.ToolDef{
Name: "edit_document",
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
NoMemory: true,
p.reloadSettings()
// ---- 工具注册 ----
// 工具名不带插件名前缀:v1 的 edit_document 就是无前缀的,保持一致性。
s.RegisterTool("read_document", sdk.ToolDef{
Name: "read_document",
Description: "读取办公文档内容并返回结构化数据。支持 .docx(段落+表格+页眉页脚)、" +
".xlsx/.xlsm(逐表逐格,公式与计算值都给)、.pptx(逐页文本框与表格)、.xls、.csv/.md/.txt。" +
"大文件用 offset/limit 分段读。改文件前先用它看内容。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"file": map[string]interface{}{"type": "string", "description": "文档绝对路径"},
"sheet": map[string]interface{}{"type": "string", "description": "xlsx:只读某张表(可选)"},
"offset": map[string]interface{}{"type": "integer", "description": "分段读的起始偏移(docx=块序号, xlsx/pptx=页/表序号),默认 0"},
"limit": map[string]interface{}{"type": "integer", "description": "本次最多返回多少段,默认全部"},
"formulas": map[string]interface{}{"type": "boolean", "description": "xlsx 是否附带公式的计算值,默认 true"},
},
"required": []string{"file"},
},
}, p.handleRead)
s.RegisterTool("create_document", sdk.ToolDef{
Name: "create_document",
Description: "从零新建办公文档。支持 .docx(title+content,content 支持 Markdown:# 标题、**粗体**、*斜体*、- 列表)、" +
".xlsx(sheets 多表:表名+表头+行数据)、.pptx(title+content,## 分页)、.md、.csv、.txt。" +
"目录不存在会自动创建;文件已存在时需传 overwrite=true 才会覆盖(覆盖前自动 .bak 留档)。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"file": map[string]interface{}{"type": "string", "description": "要创建的文件绝对路径(扩展名决定格式)"},
"title": map[string]interface{}{"type": "string", "description": "标题(docx/pptx/md)"},
"subtitle": map[string]interface{}{"type": "string", "description": "副标题(pptx 首页)"},
"content": map[string]interface{}{"type": "string", "description": "正文内容(docx/md/txt 支持 Markdown;pptx 用 ## 分页)"},
"sheets": map[string]interface{}{"type": "array", "description": "xlsx 多表:[{name, headers, rows}]", "items": map[string]interface{}{"type": "object"}},
"headers": map[string]interface{}{"type": "array", "description": "单表模式的表头(xlsx/csv)", "items": map[string]interface{}{"type": "string"}},
"rows": map[string]interface{}{"type": "array", "description": "单表模式的数据行(xlsx/csv)", "items": map[string]interface{}{"type": "array"}},
"tables": map[string]interface{}{"type": "array", "description": "docx 追加表格:[{headers, rows}]", "items": map[string]interface{}{"type": "object"}},
"overwrite": map[string]interface{}{"type": "boolean", "description": "文件已存在时是否覆盖,默认 false(报错)"},
},
"required": []string{"file"},
},
}, p.handleCreate)
s.RegisterTool("create_from_template", sdk.ToolDef{
Name: "create_from_template",
Description: "基于已有文件复制一份再套用替换(模板场景)。先 shutil.copy2 复制,再对 docx/xlsx/文本做文本替换。" +
"replacements 是 {\"旧文本\":\"新文本\"} 映射;也可用 target/replacement 只替换一处。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"template": map[string]interface{}{"type": "string", "description": "模板文件绝对路径"},
"file": map[string]interface{}{"type": "string", "description": "新文件绝对路径"},
"replacements": map[string]interface{}{"type": "object", "description": "替换映射 {\"旧\":\"新\"}"},
"target": map[string]interface{}{"type": "string", "description": "单处替换的原文本(可选)"},
"replacement": map[string]interface{}{"type": "string", "description": "单处替换的新文本(可选)"},
"overwrite": map[string]interface{}{"type": "boolean", "description": "目标已存在时是否覆盖"},
},
"required": []string{"template", "file"},
},
}, p.handleCreateFromTemplate)
// ---- 兼容性红线:工具名 + 参数与 v1.0.0 完全一致 ----
s.RegisterTool("edit_document", sdk.ToolDef{
Name: "edit_document",
Description: "编辑 Office 文档(会覆盖原文件,编辑前自动 .bak 留档)。支持 .docx / .xlsx / .xlsm / .pptx。" +
"operation 可选:replace_text(查找替换,支持 regex/case_sensitive/nth)、set_cell、insert_row、" +
"delete_row、insert_paragraph、append_paragraph、set_style、" +
"insert_image、insert_table、merge_cells、add_sheet、delete_sheet、rename_sheet。" +
"注意:pptx 无插入行 API,insert_row 在 pptx 上语义为「表格末尾追加一行」;merge_cells 不支持 pptx。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
// v1 原有 8 个参数,语义不变
"file": map[string]interface{}{"type": "string", "description": "文档文件路径"},
"operation": map[string]interface{}{"type": "string", "description": "操作: replace_text(查找替换), set_cell(设置单元格), insert_row(插入行)"},
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text)"},
"operation": map[string]interface{}{"type": "string", "description": "操作(见工具描述)"},
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text);set_style 时为空表示全文"},
"replacement": map[string]interface{}{"type": "string", "description": "替换为的文本(replace_text)"},
"sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx,可选)"},
"row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row)"},
"sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx)"},
"row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row/delete_row)"},
"col": map[string]interface{}{"type": "integer", "description": "列号(set_cell)"},
"value": map[string]interface{}{"type": "string", "description": "单元格值(set_cell)"},
"value": map[string]interface{}{"type": "string", "description": "写入值(set_cell/add_sheet/rename_sheet/段落文本)"},
// v2 扩展参数
"regex": map[string]interface{}{"type": "boolean", "description": "target 按正则解释,默认 false"},
"case_sensitive": map[string]interface{}{"type": "boolean", "description": "是否区分大小写,默认 true"},
"nth": map[string]interface{}{"type": "integer", "description": "只替换第 N 处(1 基),默认全部替换"},
"count": map[string]interface{}{"type": "integer", "description": "delete_row 删除几行,默认 1"},
"index": map[string]interface{}{"type": "integer", "description": "insert_paragraph 的插入位置(段落序号)"},
"row2": map[string]interface{}{"type": "integer", "description": "merge_cells 的结束行"},
"col2": map[string]interface{}{"type": "integer", "description": "merge_cells 的结束列"},
"table": map[string]interface{}{"type": "integer", "description": "docx 表格序号(merge_cells/insert_table),从 0 开始"},
"headers": map[string]interface{}{"type": "array", "description": "insert_table 的表头", "items": map[string]interface{}{"type": "string"}},
"rows": map[string]interface{}{"type": "array", "description": "insert_table 的数据行", "items": map[string]interface{}{"type": "array"}},
"image": map[string]interface{}{"type": "string", "description": "insert_image 的图片路径"},
"width_inch": map[string]interface{}{"type": "number", "description": "insert_image 的宽度(英寸)"},
"slide": map[string]interface{}{"type": "integer", "description": "pptx 页号(从 1 开始)"},
"size": map[string]interface{}{"type": "number", "description": "set_style 字号(磅)"},
"bold": map[string]interface{}{"type": "boolean", "description": "set_style 加粗"},
"italic": map[string]interface{}{"type": "boolean", "description": "set_style 斜体"},
"color": map[string]interface{}{"type": "string", "description": "set_style 字体颜色(如 FF0000)"},
"bg_color": map[string]interface{}{"type": "string", "description": "xlsx set_style 背景色"},
"align": map[string]interface{}{"type": "string", "description": "set_style 对齐:left/center/right/justify"},
"dry_run": map[string]interface{}{"type": "boolean", "description": "只校验不改动"},
},
"required": []string{"file", "operation"},
},
}, p.handleEditDocument)
}, p.handleEdit)
s.RegisterTool("convert_document", sdk.ToolDef{
Name: "convert_document",
Description: "办公文档格式转换。md→docx/html/pdf(pandoc)、xlsx→csv(openpyxl,多表自动分文件)、" +
"pptx→txt/md(结构化抽取)、office→pdf(soffice)。缺外部依赖时会在返回里点名缺哪个,不静默失败。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"file": map[string]interface{}{"type": "string", "description": "源文件绝对路径"},
"to": map[string]interface{}{"type": "string", "description": "目标格式:docx / pdf / html / csv / txt / md"},
"output": map[string]interface{}{"type": "string", "description": "输出路径(可选,默认同名换扩展名)"},
"sheet": map[string]interface{}{"type": "string", "description": "xlsx→csv 时指定工作表(可选,默认全部)"},
"overwrite": map[string]interface{}{"type": "boolean", "description": "输出已存在时覆盖"},
"timeout": map[string]interface{}{"type": "integer", "description": "转换超时(秒),默认 120"},
},
"required": []string{"file", "to"},
},
}, p.handleConvert)
s.RegisterTool("office_check", sdk.ToolDef{
Name: "office_check",
Description: "办公能力自检(独立可调用,不依赖真实文件)。返回:Python 解释器版本、每个依赖库" +
"(python-docx/openpyxl/python-pptx/xlrd/markdown)的可用性与版本、外部工具(soffice/pandoc)、" +
"以及格式×能力矩阵。换机器或升级插件后可用它一条命令验收。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{},
},
}, p.handleCheck)
// 首次启动就把脚本释放好,让 office_check 立刻可用(也便于运维确认落盘位置)
if sp, err := p.resolveScript(); err == nil {
fmt.Fprintf(os.Stderr, "[%s] 已就绪(脚本: %s, 指纹: %s)\n", p.name, sp, scriptStamp())
if p.ignoredScriptPath != "" {
fmt.Fprintf(os.Stderr, "[%s] 注意:script_path=%s 已被忽略(非 v2 脚本),当前使用内嵌脚本\n",
p.name, p.ignoredScriptPath)
}
} else {
fmt.Fprintf(os.Stderr, "[%s] 脚本释放失败: %v\n", p.name, err)
}
return nil
}
func (p *Plugin) Stop() error { return nil }
func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, error) {
file, _ := args["file"].(string)
if file == "" {
return nil, fmt.Errorf("file is required")
func (p *Plugin) reloadSettings() {
s := p.sdk.Settings()
if v, err := s.Get("script_path"); err == nil {
if str, ok := v.(string); ok {
p.scriptPath = strings.TrimSpace(str)
}
}
operation, _ := args["operation"].(string)
if operation == "" {
return nil, fmt.Errorf("operation is required")
if v, err := s.Get("venv_python"); err == nil {
if str, ok := v.(string); ok {
p.venvPython = strings.TrimSpace(str)
}
}
if _, err := os.Stat(file); os.IsNotExist(err) {
return map[string]interface{}{
"content": fmt.Sprintf("文件不存在: %s", file),
}, nil
p.timeoutSec = 60
if v, err := s.Get("timeout_sec"); err == nil {
switch n := v.(type) {
case float64:
if n > 0 {
p.timeoutSec = int(n)
}
case int64:
if n > 0 {
p.timeoutSec = int(n)
}
case int:
if n > 0 {
p.timeoutSec = n
}
}
}
pyArgs := map[string]interface{}{}
if v, ok := args["target"]; ok {
pyArgs["target"] = v
}
if v, ok := args["replacement"]; ok {
pyArgs["replacement"] = v
}
if v, ok := args["sheet"]; ok {
pyArgs["sheet"] = v
}
if v, ok := args["row"]; ok {
pyArgs["row"] = v
}
if v, ok := args["col"]; ok {
pyArgs["col"] = v
}
if v, ok := args["value"]; ok {
pyArgs["value"] = v
}
pyArgsJSON, _ := json.Marshal(pyArgs)
scriptPath := p.scriptPath
if scriptPath == "" {
scriptPath = filepath.Join(filepath.Dir(os.Args[0]), "edit_doc.py")
log.Printf("[%s] script_path 未配置,使用默认脚本路径: %s", p.name, scriptPath)
}
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
return nil, fmt.Errorf("edit_doc.py not found at %s(请在插件配置 script_path 中指定脚本路径)", scriptPath)
}
if p.venvPython == "" {
return nil, fmt.Errorf("venv_python 未配置,无法执行脚本;请在插件配置中设置 venv_python(venv 内 python 的绝对路径)")
}
if _, err := os.Stat(p.venvPython); err != nil {
return nil, fmt.Errorf("venv python 不存在: %s(请检查 venv_python 配置)", p.venvPython)
}
var out bytes.Buffer
cmd := exec.Command(p.venvPython, scriptPath, file, operation, string(pyArgsJSON))
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("edit document: %w", err)
}
var result struct {
Ok bool `json:"ok"`
Error string `json:"error,omitempty"`
Count int `json:"count,omitempty"`
}
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
return map[string]interface{}{
"content": fmt.Sprintf("编辑完成,输出: %s", out.String()),
"file": file,
}, nil
}
if !result.Ok {
return map[string]interface{}{
"content": fmt.Sprintf("编辑失败: %s", result.Error),
}, nil
}
msg := fmt.Sprintf("编辑完成,已保存到原文件: %s", file)
if result.Count > 0 {
msg += fmt.Sprintf("\n共处理 %d 处", result.Count)
}
return map[string]interface{}{
"content": msg,
"file": file,
}, nil
}
// resolveScript 返回 edit_doc.py 的可用路径。
//
// 优先级:
// 1. 配置 script_path 指向的文件,且**含 v2 协议标记**(便于运维手动覆盖调试)
// 2. DataDir()/runtime/edit_doc.py(内嵌脚本释放位置,指纹驱动的自动更新)
//
// 关键防呆:script_path 指向的文件若存在但**不含** v2 协议标记,说明它是旧版脚本
// (v1 的 argv 约定是 <file> <op> <json>,与 v2 的 <cmd> <json> 完全不同)。
// 若照样采用,6 个工具会全部报「文件不存在: create/read/edit/...」,等于把线上打挂。
// 因此这里主动忽略它、回退内嵌脚本,并置 p.ignoredScriptPath 让调用方能看到。
func (p *Plugin) resolveScript() (string, error) {
p.ignoredScriptPath = ""
if p.scriptPath != "" {
if data, err := os.ReadFile(p.scriptPath); err == nil {
if bytes.Contains(data, []byte(scriptProtocolMarker)) {
return p.scriptPath, nil
}
// 存在但不是 v2 脚本:忽略,回退内嵌(绝不能让它把工具打挂)
p.ignoredScriptPath = p.scriptPath
fmt.Fprintf(os.Stderr,
"[%s] 忽略 script_path=%s:缺少 v2 协议标记 %q,判定为旧版脚本;改用内嵌脚本\n",
p.name, p.scriptPath, scriptProtocolMarker)
}
}
// 释放内嵌脚本
dir := ""
if p.sdk != nil {
dir = p.sdk.Settings().DataDir()
}
if dir == "" {
return "", fmt.Errorf("拿不到插件数据目录(Settings().DataDir() 为空),且 script_path 未指向有效文件")
}
rt := filepath.Join(dir, "runtime")
if err := os.MkdirAll(rt, 0755); err != nil {
return "", fmt.Errorf("创建运行时目录失败: %w", err)
}
jsPath := filepath.Join(rt, "edit_doc.py")
stampPath := filepath.Join(rt, ".stamp")
want := scriptStamp()
if got, err := os.ReadFile(stampPath); err == nil && string(got) == want {
if _, e := os.Stat(jsPath); e == nil {
return jsPath, nil
}
}
if err := os.WriteFile(jsPath, embeddedScript, 0644); err != nil {
return "", fmt.Errorf("释放内嵌脚本失败: %w", err)
}
_ = os.WriteFile(stampPath, []byte(want), 0644)
return jsPath, nil
}
// resolvePython 返回可用的 python 解释器(配置为空时回退到 PATH 里的 python3)。
func (p *Plugin) resolvePython() (string, error) {
py := p.venvPython
if py == "" {
if cand, err := exec.LookPath("python3"); err == nil {
py = cand
}
}
if py == "" {
return "", fmt.Errorf("venv_python 未配置且 PATH 中找不到 python3")
}
if _, err := os.Stat(py); err != nil {
return "", fmt.Errorf("Python 解释器不存在: %s", py)
}
return py, nil
}
// scriptError 是结构化错误体,直接透传给上层 LLM。
type scriptError struct {
Error string `json:"error"`
Hint string `json:"hint"`
}
// runScript 执行 edit_doc.py <cmd> <argsJSON>,返回解析后的 map。
//
// 统一错误处理:脚本缺失/解释器缺失/超时/非 JSON 输出,都转成带 hint 的结构化返回,
// 而不是抛裸 error(v1 是直接 return nil, err,上层只能看到一句无上下文的失败)。
func (p *Plugin) runScript(cmd string, args map[string]interface{}) (map[string]interface{}, error) {
p.reloadSettings() // 支持改配置后无需重载插件
scriptPath, err := p.resolveScript()
if err != nil {
return nil, fmt.Errorf("%s;请在插件配置 script_path 指定 edit_doc.py 的绝对路径", err)
}
py, err := p.resolvePython()
if err != nil {
return nil, fmt.Errorf("%s;请在插件配置 venv_python 指定 python 解释器", err)
}
argsJSON, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("参数序列化失败: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(),
time.Duration(p.timeoutSec)*time.Second)
defer cancel()
var stdout, stderr bytes.Buffer
c := exec.CommandContext(ctx, py, scriptPath, cmd, string(argsJSON))
c.Stdout = &stdout
c.Stderr = &stderr
runErr := c.Run()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("脚本执行超时(%d 秒);大文件或格式转换可调高 timeout_sec 配置",
p.timeoutSec)
}
out := strings.TrimSpace(stdout.String())
if out == "" {
msg := strings.TrimSpace(stderr.String())
if runErr != nil {
return nil, fmt.Errorf("脚本无输出且退出异常: %v; stderr: %s", runErr, truncate(msg, 300))
}
return nil, fmt.Errorf("脚本无输出(stderr: %s)", truncate(msg, 300))
}
// 脚本只保证最后一行是 JSON(soffice/pandoc 可能往 stdout 混东西)
line := out
if i := strings.LastIndex(out, "\n"); i >= 0 {
line = strings.TrimSpace(out[i+1:])
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(line), &m); err != nil {
return nil, fmt.Errorf("脚本输出不是合法 JSON: %s; 原文: %s",
err, truncate(out, 300))
}
if se := strings.TrimSpace(stderr.String()); se != "" && runErr != nil {
m["stderr"] = truncate(se, 300)
}
// script_path 因非 v2 脚本被忽略时,把这次降级明确告知上层
if p.ignoredScriptPath != "" {
m["script_path_ignored"] = p.ignoredScriptPath
m["script_path_ignored_reason"] = "该文件缺少 v2 协议标记,判定为旧版脚本;本次已改用内嵌脚本"
}
return m, nil
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// respond 把脚本返回整形成工具返回值:ok:true 时给 content/summary,
// ok:false 时给 isError + error + hint。
func respondOffice(m map[string]interface{}) (interface{}, error) {
if ok, _ := m["ok"].(bool); !ok {
out := map[string]interface{}{
"isError": true,
"content": "操作失败: " + str(m["error"]),
"error": m["error"],
"hint": m["hint"],
}
for _, k := range []string{"path", "exists", "missing", "format", "cmd",
"script_path_ignored", "script_path_ignored_reason"} {
if v, has := m[k]; has {
out[k] = v
}
}
return out, nil
}
// 成功:把脚本的结构化字段原样上抛,便于 LLM 二次利用
out := map[string]interface{}{}
for k, v := range m {
out[k] = v
}
if s, has := m["summary"]; has {
out["content"] = s
}
return out, nil
}
func str(v interface{}) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
// ---------------------------------------------------------------- handlers
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("read", args)
if err != nil {
return toolErr("read_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("create", args)
if err != nil {
return toolErr("create_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleCreateFromTemplate(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("create_from_template", args)
if err != nil {
return toolErr("create_from_template", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
// v1 兼容:file / operation 必填
if str(args["file"]) == "" {
return map[string]interface{}{"isError": true, "content": "edit_document 需要 file 参数"}, nil
}
if str(args["operation"]) == "" {
return map[string]interface{}{"isError": true, "content": "edit_document 需要 operation 参数"}, nil
}
m, err := p.runScript("edit", args)
if err != nil {
return toolErr("edit_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleConvert(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("convert", args)
if err != nil {
return toolErr("convert_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleCheck(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("check", map[string]interface{}{})
if err != nil {
return toolErr("office_check", err), nil
}
return respondOffice(m)
}
func toolErr(tool string, err error) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": fmt.Sprintf("%s 失败:%s", tool, err.Error()),
"error": err.Error(),
}
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil

View File

@ -0,0 +1,178 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// fakeSettings 是 SettingsAPI 的最小实现,只为把 resolveScript 的 DataDir 分支跑通。
// 其余方法测试用不到,给个安全默认值即可。
type fakeSettings struct {
dataDir string
defs []*sdk.ConfigDef
}
func (f *fakeSettings) Get(string) (interface{}, error) { return nil, nil }
func (f *fakeSettings) Set(string, interface{}) error { return nil }
func (f *fakeSettings) SetSecret(string, interface{}) error { return nil }
func (f *fakeSettings) GetSecret(string) (string, error) { return "", nil }
func (f *fakeSettings) List(string) ([]string, error) { return nil, nil }
func (f *fakeSettings) GetCore(string) (interface{}, error) { return nil, nil }
func (f *fakeSettings) SetCore(string, interface{}) error { return nil }
func (f *fakeSettings) ListCore(string) ([]string, error) { return nil, nil }
func (f *fakeSettings) DataDir() string { return f.dataDir }
func (f *fakeSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil }
func (f *fakeSettings) SetPlugin(string, string, interface{}) error { return nil }
func (f *fakeSettings) ListPlugin(string, string) ([]string, error) { return nil, nil }
func (f *fakeSettings) RegisterDef(def sdk.ConfigDef) { f.defs = append(f.defs, &def) }
func (f *fakeSettings) Defs(string) []*sdk.ConfigDef { return f.defs }
func (f *fakeSettings) Dump() map[string]interface{} { return nil }
func (f *fakeSettings) Plugins() []string { return nil }
// newTestPlugin 造一个带可用 DataDir 的 Plugin(sdk 走 sdk.New,只注入 SettingsAPI)。
func newTestPlugin(t *testing.T, scriptPath string) *Plugin {
t.Helper()
sett := &fakeSettings{dataDir: t.TempDir()}
s := sdk.New("editdoc", sett, nil, nil, nil, nil)
return &Plugin{
name: "editdoc",
sdk: s,
scriptPath: scriptPath,
venvPython: "/usr/local/bin/python3",
timeoutSec: 60,
}
}
// 内嵌脚本必须自带 v2 协议标记,否则防呆会把自己的脚本也挡掉。
func TestEmbeddedScriptHasProtocolMarker(t *testing.T) {
if !containsMarker(embeddedScript) {
t.Fatalf("内嵌脚本缺少协议标记 %q —— 防呆会把内嵌脚本自己判为旧版", scriptProtocolMarker)
}
// 标记必须与 embed/edit_doc.py 顶部一致(防手改两处不同步)
t.Logf("协议标记=%q 内嵌脚本指纹=%s", scriptProtocolMarker, scriptStamp())
}
// 业务脚本只能有**一份**:embed/edit_doc.py。
//
// 为何钉这一条(2026-10-13 迁入仓库时实测):原工作目录里同时存在
// edit_doc.py 与 embed/edit_doc.py —— 两份逐字节相同的副本,而只有
// embed/ 那份被 //go:embed 引用。同名双副本是典型的漂移源:
// 改了顶层那份(看着像“主文件”)而真正生效的是 embed/ 里的,
// 或反过来;两种都不会报错,只会让行为与源码对不上。
func TestOnlyOneBusinessScript(t *testing.T) {
if _, err := os.Stat("edit_doc.py"); err == nil {
t.Error("存在顶层 edit_doc.py:业务脚本只应有 embed/edit_doc.py 一份(顶部副本未被 embed,改了不生效)")
}
if _, err := os.Stat(filepath.Join("embed", "edit_doc.py")); err != nil {
t.Fatalf("缺少 embed/edit_doc.py: %v", err)
}
}
// script_path 指向 v1 脚本(无标记)→ 必须忽略它、回退内嵌脚本,
// 并记录 ignoredScriptPath。这是防呆的核心契约。
func TestResolveScriptIgnoresV1Script(t *testing.T) {
dir := t.TempDir()
v1 := filepath.Join(dir, "edit_doc_v1.py")
// v1 脚本的真实特征:argv 约定 <file> <op> <json>,且没有 v2 标记
if err := os.WriteFile(v1, []byte(
"import sys\npath = sys.argv[1]\noperation = sys.argv[2]\n"), 0644); err != nil {
t.Fatal(err)
}
p := newTestPlugin(t, v1)
got, err := p.resolveScript()
if err != nil {
t.Fatalf("应回退到内嵌脚本,却报错: %v", err)
}
if got == v1 {
t.Fatal("v1 脚本被采用了 —— 防呆失效,6 个工具会被打挂")
}
if filepath.Base(got) != "edit_doc.py" {
t.Fatalf("应回退到 runtime/edit_doc.py,实际: %s", got)
}
if p.ignoredScriptPath != v1 {
t.Fatalf("ignoredScriptPath 未记录,实际: %q", p.ignoredScriptPath)
}
// 回退出来的脚本必须是内嵌那份(字节一致)
released, err := os.ReadFile(got)
if err != nil {
t.Fatalf("内嵌脚本未落盘: %v", err)
}
if string(released) != string(embeddedScript) {
t.Fatal("落盘的脚本与内嵌脚本字节不一致")
}
t.Logf("v1 被正确忽略,回退到 %s", got)
}
// script_path 指向含标记的 v2 脚本 → 必须采用它,且不记 ignoredScriptPath。
func TestResolveScriptAcceptsV2Script(t *testing.T) {
dir := t.TempDir()
v2 := filepath.Join(dir, "edit_doc_v2.py")
if err := os.WriteFile(v2, []byte(
"#!/usr/bin/env python3\n"+scriptProtocolMarker+"\n"), 0644); err != nil {
t.Fatal(err)
}
p := newTestPlugin(t, v2)
got, err := p.resolveScript()
if err != nil {
t.Fatalf("应直接采用 v2 脚本,却报错: %v", err)
}
if got != v2 {
t.Fatalf("v2 脚本未被采用,实际用了: %s", got)
}
if p.ignoredScriptPath != "" {
t.Fatalf("v2 脚本不应被标记忽略: %q", p.ignoredScriptPath)
}
}
// script_path 为空 → 走内嵌脚本释放,且指纹命中第二次不再重复写。
func TestResolveScriptFallsBackToEmbedded(t *testing.T) {
p := newTestPlugin(t, "")
got, err := p.resolveScript()
if err != nil {
t.Fatalf("空 script_path 应回退内嵌脚本: %v", err)
}
if p.ignoredScriptPath != "" {
t.Fatalf("空配置不该有 ignoredScriptPath: %q", p.ignoredScriptPath)
}
// 第二次调用应复用(指纹 + 文件都在),路径一致
got2, err := p.resolveScript()
if err != nil || got2 != got {
t.Fatalf("第二次解析不稳定: %s vs %s (%v)", got, got2, err)
}
// .stamp 指纹应有内容
stamp, err := os.ReadFile(filepath.Join(filepath.Dir(got), ".stamp"))
if err != nil || string(stamp) != scriptStamp() {
t.Fatalf("指纹文件异常: %q %v", stamp, err)
}
}
// 指向不存在的路径 → 不该报错,应静默回退内嵌脚本。
func TestResolveScriptMissingPathFallsBack(t *testing.T) {
p := newTestPlugin(t, filepath.Join(t.TempDir(), "not-there.py"))
got, err := p.resolveScript()
if err != nil {
t.Fatalf("路径不存在应回退内嵌脚本: %v", err)
}
if p.ignoredScriptPath != "" {
t.Fatalf("不存在的路径不该记为 ignored: %q", p.ignoredScriptPath)
}
if filepath.Base(got) != "edit_doc.py" {
t.Fatalf("应回退到 runtime/edit_doc.py,实际: %s", got)
}
}
// containsMarker 与 resolveScript 内部用的是同一判据,供测试复用。
func containsMarker(b []byte) bool {
m := []byte(scriptProtocolMarker)
for i := 0; i+len(m) <= len(b); i++ {
if string(b[i:i+len(m)]) == string(m) {
return true
}
}
return false
}

View File

@ -159,19 +159,19 @@ const (
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
listenAddr string
napcatURL string
remoteDir string
filesDir string
webhookToken string
ownerIDs []int64
botID int64
botNickname string
dmPolicy string
groupPolicy string
httpClient *http.Client
name string
sdk *sdk.PluginSDK
listenAddr string
napcatURL string
remoteDir string
filesDir string
webhookToken string
ownerIDs []int64
botID int64
botNickname string
dmPolicy string
groupPolicy string
httpClient *http.Client
// sendClient 专用于**发送类**端点(send_private_msg / send_group_msg)。
//
// ★ 为何与查询类分开(2026-10-13 实测):QQ 发送要经过 NapCat 的
@ -188,7 +188,7 @@ type Plugin struct {
//
// 发送超时给足 120s(与插件内 TTS/下载同量级);查询类保持 30s
// (它们是幂等的,超时就是真超时,快速失败更有利于排查)。
sendClient *http.Client
sendClient *http.Client
allowFrom map[int64]struct{}
groupAllowFrom map[int64]struct{}
srv *http.Server
@ -1302,6 +1302,36 @@ func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, q
return matchesToolAllowlist(name, patterns), auth
}
// extraKeyComeFrom 是内核写在帧上的「来源标识」键。
//
// 契约(见内核 internal/agent/core/comefrom.go):
//
// "user" 用户请求在边界上发起(默认语义,本插件的权限门只应管这类)
// "internal" agent 体系内部发起(驻留子/转投/自主续跑)⇒ 不走用户权限模型
// 缺失 旧内核 ⇒ 按 "user" 处理,**行为必须与改动前逐字一致**
//
// 为什么用 Extra 而不是具名字段:内核用 Extra 的通用袋跨进程透传插件自建键
// (proc 桥的 fExtraBag),与既有 `input_source` / `qq_auth` 同一读法。
const extraKeyComeFrom = "comefrom"
// isInternalToolCall 报告本帧的工具调用是否由 agent 内部委派发起。
//
// ★ 保守默认:**只有明确读到 "internal" 才算内部**。
// 缺失、空串、类型不对、未知取值 —— 全部当作 "user"(旧行为)。
// 这个方向不能反:把用户请求误判成 internal 就是**权限门整体失效**,
// 那比"多拦一次内部调用"严重得多。
func isInternalToolCall(ctx *sdk.StageContext) bool {
if ctx == nil || ctx.Extra == nil {
return false
}
v, ok := ctx.Extra[extraKeyComeFrom]
if !ok {
return false
}
s, _ := v.(string)
return s == "internal"
}
// isAtBot checks if the message contains an @-mention of the bot.
func (p *Plugin) isAtBot(msg interface{}) bool {
segments, ok := msg.([]interface{})
@ -1391,6 +1421,37 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
if len(ctx.ToolCalls) == 0 {
return nil
}
// ★ **内部委派的工具调用不走用户权限模型**(2026-10-16)。
//
// 内核在帧上写了 `comefrom`:
// "user" = 用户请求在边界上发起(**本插件要管的就是这类**)
// "internal" = agent 体系内部发起(驻留子/转投/自主续跑)
// 缺失 = 旧内核,按 "user" 处理(保持既有行为,勿改)
//
// ## 为何这是正确的(实测故障 + 语义)
//
// 现场(2026-10-16):驻留子 `dev-br-jianf-NextAgent-fix_abs-path` 自报 running
// 但**零产出**,它那一轮 8 个工具(cmd_run/files_ls/notify_parent/…)全被
// 本门拦下。日志实证:每个工具调用前都有
// `[proc] qq stage before_toolcall 改写了 1 个字段`(= 写入了拒绝响应)。
//
// 根因:驻留子的输入**继承自父**(转投刻意保留原事件,否则同步调用方永久
// 挂起),于是 `input_source` 仍是 "qq" ⇒ 本门把驻留子当成"那位 QQ 用户
// 在提请求" ⇒ 按用户白名单判 ⇒ 开发工具一条都不在白名单里 ⇒ 全拒。
//
// 但用户请求**已经过了这道门**(父按真实身份判定过)。驻留子做的是
// **父自己的委派决定**;对已经准入的工作再查一次"用户白名单",既拦掉合法
// 工作、又零安全增益(同一次请求,父已判定)。
//
// 为何不"只跳白名单、仍查硬私有":硬私有(calendar_/email_/memory_…)
// 防的是用户偷看私人数据;驻留子是父的内部执行体,父对它本就有完整
// 权限 —— 再查一遍等于"机器人自己派出去的活被自己拦住",同样荒谬。
//
// 为何放在 `auth.Active` 判断**之前**:internal 与"非本插件来源"在语义上
// 是同一类(都不是"一条 QQ 用户消息"),走同一条放行路径最不容易出错。
if isInternalToolCall(ctx) {
return nil
}
tc := &ctx.ToolCalls[0]
allowed, auth := p.currentToolAllowed(ctx, tc.Name)
if !auth.Active {

View File

@ -725,6 +725,7 @@ func TestNapcatConvertsOneBotFailureToError(t *testing.T) {
// 与我修过的 HTTP 客户端超时是**两条不同路径**:
// - HTTP 超时:client.Post 返回 err(已有告警)
// - NapCat 超时:HTTP 200 + 响应体里带 NTEvent 字样(本次才补)
//
// 只报警「失败」而不说「可能已送出」,模型必重发。
//
// 判据必须穿过 napcat() 真实调用链(单测 isNapcatSendUnconfirmed 测不出
@ -816,3 +817,151 @@ func TestNapcatSendActionTimeoutRegardlessOfServiceName(t *testing.T) {
t.Errorf("服务名不是 sendMsg 的 NTEvent 超时也要套用发送超时语义,实为 %v", err)
}
}
// ===========================================================================
// 「来源标识」(comefrom)与用户权限门的关系(2026-10-16)
//
// 现场故障:驻留子 `dev-br-jianf-NextAgent-fix_abs-path` 自报 running 但
// **零产出** —— 它那一轮 8 个工具(cmd_run / files_ls / notify_parent /
// output_send__commondev_* / inputch_note …)**全部**被本插件的
// before_toolcall 拦下。它自己的处理表原文:
//
// 本轮所有需要的工具都被权限策略挡下了,实测被拒的有:
// - cmd_run —— 无法进入工作目录、无法看 git 状态
//
// 日志实证(09:21:25~09:21:40,每个工具调用前都紧跟):
//
// [agent] executing tool: cmd_run (plugin=cmd)
// [proc] qq stage before_toolcall 改写了 1 个字段 ← 写入了拒绝响应
//
// 而同一时段父 main 的工具调用**有 result**、驻留子的**零 result**。
//
// 根因:驻留子的输入继承自父(转投刻意保留原事件),`input_source` 仍是
// "qq",于是本门把“父的内部委派”误当成“那位 QQ 用户新提的请求”,
// 按用户白名单判定 ⇒ 开发工具一条都不在白名单里 ⇒ 全拒。
// ===========================================================================
// ★★★ **核心回归判据**:内部委派的工具调用**不得**被用户权限门拦。
//
// 这条直接覆盖现场故障:用同一份身份(非 owner)、同一个被拒工具(cmd_run),
// 只改 comefrom 一个键 —— internal 必须放行、user 必须拦。
func TestInternalToolCallBypassesUserPermissionGate(t *testing.T) {
// 基准:非 owner 私聊 ⇒ cmd_run 不在白名单 ⇒ 必须被拦
base := func() *sdk.StageContext {
ctx := toolCallContext("cmd_run", nil)
ctx.Extra = map[string]interface{}{}
return ctx
}
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{Active: true, UserID: 2198972886}
ctxUser := base()
if err := p.beforeToolcall(ctxUser); err != nil {
t.Fatal(err)
}
if ctxUser.Response == nil {
t.Fatal("前置条件错:非 owner 调 cmd_run 本应被拦(判据基线不成立)")
}
// ① comefrom=internal(驻留子)⇒ 必须放行
p2 := newPermissionTestPlugin(t)
p2.auth = qqAuthContext{Active: true, UserID: 2198972886}
ctxInternal := base()
ctxInternal.Extra[extraKeyComeFrom] = "internal"
if err := p2.beforeToolcall(ctxInternal); err != nil {
t.Fatal(err)
}
if ctxInternal.Response != nil {
t.Fatalf("★ 内部委派的 cmd_run 仍被用户权限门拦住(缺陷回归): %s", *ctxInternal.Response)
}
// ② 硬私有工具同样放行(驻留子是父的内部执行体,父本就有完整权限)
p3 := newPermissionTestPlugin(t)
p3.auth = qqAuthContext{Active: true, UserID: 2198972886}
ctxHard := toolCallContext("memory_recall", nil)
ctxHard.Extra = map[string]interface{}{extraKeyComeFrom: "internal"}
if err := p3.beforeToolcall(ctxHard); err != nil {
t.Fatal(err)
}
if ctxHard.Response != nil {
t.Fatalf("★ 内部委派的硬私有工具被拦: %s", *ctxHard.Response)
}
}
// ★★★ 判据:**只有**明确 "internal" 才放行 —— 其余一律按用户处理。
//
// 这个方向绝不能反:把用户请求误判成 internal = 权限门整体失效,
// 比"多拦一次内部调用"严重得多。
func TestComeFromOnlyInternalIsTrusted(t *testing.T) {
for _, tc := range []struct {
name string
setup func(m map[string]interface{})
trusted bool
}{
{"internal", func(m map[string]interface{}) { m[extraKeyComeFrom] = "internal" }, true},
{"user", func(m map[string]interface{}) { m[extraKeyComeFrom] = "user" }, false},
{"缺失(旧内核)", func(m map[string]interface{}) {}, false},
{"空串", func(m map[string]interface{}) { m[extraKeyComeFrom] = "" }, false},
{"未知取值", func(m map[string]interface{}) { m[extraKeyComeFrom] = "internal-ish" }, false},
{"大小写不同", func(m map[string]interface{}) { m[extraKeyComeFrom] = "INTERNAL" }, false},
{"类型错(bool)", func(m map[string]interface{}) { m[extraKeyComeFrom] = true }, false},
{"类型错(数字)", func(m map[string]interface{}) { m[extraKeyComeFrom] = 1.0 }, false},
} {
t.Run(tc.name, func(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{Active: true, UserID: 2198972886}
ctx := toolCallContext("cmd_run", nil)
ctx.Extra = map[string]interface{}{}
tc.setup(ctx.Extra)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
blocked := ctx.Response != nil
if tc.trusted && blocked {
t.Errorf("★ 应放行却拦了: %s", *ctx.Response)
}
if !tc.trusted && !blocked {
t.Error("★ 应拦却放行了(权限门失效)")
}
})
}
}
// ★★★ 判据:**owner 的请求行为不变**(comefrom=user 时不得因为改动而放宽)。
//
// 防的是"为了让驻留子通过,把门整体放松"这类错误修法。
func TestComeFromUserKeepsOwnerAndNonOwnerSemantics(t *testing.T) {
// owner + comefrom=user ⇒ 完整放行(含硬私有)
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886}
ctx := toolCallContext("calendar_list", nil)
ctx.Extra = map[string]interface{}{extraKeyComeFrom: "user"}
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("★ owner 的请求被拦(权限门被收紧了): %s", *ctx.Response)
}
// 非 owner + comefrom=user + 硬私有 ⇒ 仍必须拒
p2 := newPermissionTestPlugin(t)
p2.auth = qqAuthContext{Active: true, UserID: 10001}
ctx2 := toolCallContext("calendar_list", nil)
ctx2.Extra = map[string]interface{}{extraKeyComeFrom: "user"}
if err := p2.beforeToolcall(ctx2); err != nil {
t.Fatal(err)
}
if ctx2.Response == nil {
t.Fatal("★ 非 owner 的硬私有调用应仍被拒(改动把门放松了)")
}
// 无身份(auth.Active=false)+ 有 comefrom=user ⇒ 本门不介入(旧行为)
p3 := newPermissionTestPlugin(t)
ctx3 := toolCallContext("cmd_run", nil)
ctx3.Extra = map[string]interface{}{extraKeyComeFrom: "user"}
if err := p3.beforeToolcall(ctx3); err != nil {
t.Fatal(err)
}
if ctx3.Response != nil {
t.Fatalf("非本插件来源不该被本门拦: %s", *ctx3.Response)
}
}

View File

@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
@ -119,7 +120,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss")
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
log.Printf("[%s] mkdir %s: %v", p.name, p.dataDir, err)
}
p.loadData()
@ -184,7 +185,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.wg.Add(1)
go p.pollLoop()
fmt.Printf("[%s] started (%d feeds, poll every %dm)\n", p.name, len(p.feeds), pollMin)
log.Printf("[%s] started (%d feeds, poll every %dm)", p.name, len(p.feeds), pollMin)
return nil
}
@ -193,7 +194,7 @@ func (p *Plugin) Stop() error {
p.pollTicker.Stop()
p.wg.Wait()
p.saveData()
fmt.Printf("[%s] stopped\n", p.name)
log.Printf("[%s] stopped", p.name)
return nil
}
@ -486,7 +487,7 @@ func (p *Plugin) cleanupData() {
for _, f := range []string{"feeds.json"} {
path := filepath.Join(p.dataDir, f)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
fmt.Printf("[%s] onRemove cleanup %s: %v\n", p.name, path, err)
log.Printf("[%s] onRemove cleanup %s: %v", p.name, path, err)
}
}
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
@ -95,7 +96,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
ctx.Lock()
defer ctx.Unlock()
if len(ctx.ToolResults) > 0 {
fmt.Printf("[%s] stage after_toolcall(own): %s\n", p.name, ctx.ToolResults[0].Name)
log.Printf("[%s] stage after_toolcall(own): %s", p.name, ctx.ToolResults[0].Name)
}
return nil
}, sdk.StageScopeOwnTools)
@ -115,25 +116,25 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
return err
}
fmt.Printf("[%s] started\n", p.name)
log.Printf("[%s] started", p.name)
return nil
}
func (p *Plugin) Stop() error {
fmt.Printf("[%s] stopped\n", p.name)
log.Printf("[%s] stopped", p.name)
return nil
}
type wttrResp struct {
CurrentCondition []struct {
TempC string `json:"temp_C"`
FeelsLikeC string `json:"FeelsLikeC"`
Humidity string `json:"humidity"`
WindspeedKmph string `json:"windspeedKmph"`
Winddir16Point string `json:"winddir16Point"`
Pressure string `json:"pressure"`
Visibility string `json:"visibility"`
WeatherDesc []struct {
TempC string `json:"temp_C"`
FeelsLikeC string `json:"FeelsLikeC"`
Humidity string `json:"humidity"`
WindspeedKmph string `json:"windspeedKmph"`
Winddir16Point string `json:"winddir16Point"`
Pressure string `json:"pressure"`
Visibility string `json:"visibility"`
WeatherDesc []struct {
Value string `json:"value"`
} `json:"weatherDesc"`
LocalObsDateTime string `json:"localObsDateTime"`
@ -153,7 +154,7 @@ type wttrResp struct {
}
type wttrDay struct {
Date string `json:"date"`
Date string `json:"date"`
Astronomy []struct {
Sunrise string `json:"sunrise"`
Sunset string `json:"sunset"`
@ -161,8 +162,8 @@ type wttrDay struct {
MaxtempC string `json:"maxtempC"`
MintempC string `json:"mintempC"`
Hourly []struct {
TempC string `json:"tempC"`
WeatherDesc []struct {
TempC string `json:"tempC"`
WeatherDesc []struct {
Value string `json:"value"`
} `json:"weatherDesc"`
WindspeedKmph string `json:"windspeedKmph"`

View File

@ -99,7 +99,17 @@ fail=0
failed_names=""
for dir in "$SDK_ROOT"/example/*/; do
[ -f "$dir/plugin.go" ] || continue
# ★ 入口判据:Go 插件是 plugin.go,**Lua 插件是 main.lua**。
#
# 原判据只有 `[ -f plugin.go ]` ⇒ **Lua 插件被静默跳过**
# (实测:luademo 是 Lua 插件,entry=main.lua,不产 .hmap 也不报错,
# 发版产物就少一个插件而没人发现)。改为认两种入口。
#
# 用 plg.json 的 entry 做最终判定更准,但**入口文件存在性**足以区分
# "这是不是一个插件工程";真正的入口名由 hmapdev 从 plg.json 读。
if [ ! -f "$dir/plugin.go" ] && [ ! -f "$dir/main.lua" ]; then
continue
fi
name="$(basename "$dir")"
# 清掉旧产物:残留会让人(和本脚本)误判成功。
@ -111,7 +121,28 @@ for dir in "$SDK_ROOT"/example/*/; do
# 判据是**退出码 + 产物存在**,两者都要。
# 只看退出码:hmapdev 曾经出错也退 0(已修,但脚本不该依赖它「现在」是对的)。
# 只看产物:部分平台失败时会留下上一次的产物,看起来像成功。
hmap="$(ls "$dir"/dist/*.hmap 2>/dev/null | head -1)"
#
# ★★ 必须挑**本次目标平台**的产物,不能取 `head -1`。
#
# ## 为什么(实测缺陷,2026-10-17)
#
# `--target` 的语义是**追加**(docs/guide/multi-platform.md 明确写「追加一个目标」),
# 而示例的 plg.json 普遍声明 `targets: "linux/amd64"`。于是
# `build --no-bundle --target linux/arm64` 会**同时**产出
# `..._linux_amd64.hmap` 与 `..._linux_arm64.hmap`。
#
# 原代码取 `ls | head -1` ⇒ **按文件名字典序拿到 amd64 那份**
# ⇒ 打 arm64 包时实际装进去的是 **x86-64 二进制**,
# 而文件名却写着 `linux_arm64_...`(**文件名与内容不符**)。
# 实测:arm64 产物 `plugin.bin` 的 ELF machine = 0x003e(x86-64,应为 0x00b7=aarch64)。
# 这种包在 arm64 机器上只会以 "Exec format error" 失败,而排查方向会被文件名带偏。
#
# ⇒ 精确匹配 `<name>_<goos>_<goarch>.hmap`;匹配不到则视为失败(不静默退回任意产物)。
hmap="$(ls "$dir"/dist/*_"${GOOS}_${GOARCH}".hmap 2>/dev/null | head -1)"
if [ -z "$hmap" ]; then
# Lua 插件的产物名是 <name>_lua.hmap(无平台后缀,Lua 不编译)。
hmap="$(ls "$dir"/dist/*_lua.hmap 2>/dev/null | head -1)"
fi
if [ $rc -eq 0 ] && [ -n "$hmap" ]; then
# 保留插件自己声明的产物名(它用的是 plg.json 的 name_en,是插件的身份),
# 只在前面加平台前缀避免多平台互相覆盖。

77
sdk/facilities.go Normal file
View File

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

View File

@ -385,8 +385,35 @@ type PluginMgrAPI interface {
ReloadOne(name string) error
// ListLoadedPlugins 列出已加载插件。
ListLoadedPlugins() []string
// IsPluginDisabled 查询插件是否被禁用。
// IsPluginDisabled 查询插件是否被**持久**禁用。
IsPluginDisabled(name string) bool
// ---- 运行态启停(设计 docs/zh/kernel-modularization-design.md §2.6.7)----
//
// ★ 与 DisablePlugin/EnablePlugin 的区别(这是这两个方法存在的全部理由):
//
// DisablePlugin/EnablePlugin ⇒ 改**持久**状态(disabled_plugins 配置)
// StopPlugin/StartPlugin ⇒ 只改**运行**状态,不写任何配置
//
// 心智模型:插件是 agent 的应用程序。
// · disabled_plugins = 系统里"禁止运行这个程序"(持久决定)
// · runtimeStopped = 用户"现在关掉这个窗口"(运行决定,重启复位)
//
// 混用二者的后果(实测缺陷):想临时关一下却顺带关掉了开机自启
// ⇒ 用户下次开机会发现"怎么不启动了"。
// StopPlugin 运行态关闭:现在停掉,**不改持久状态**。重启后按
// auto_start + disabled_plugins 正常决定。
//
// 拒绝作用于 pluginmgr 自身与 webui(自锁/失联,内核层面拦死)。
StopPlugin(name string) error
// StartPlugin 运行态启动:现在拉起,**不解除持久禁用**。
//
// 被持久禁用的插件会被拒绝(提示先用 EnablePlugin)——
// 持久禁用是用户的明确决定,运行态操作不该绕过它。
StartPlugin(name string) error
// IsPluginRuntimeStopped 查询插件是否处于运行态关闭。
IsPluginRuntimeStopped(name string) bool
}
// StageScope controls which events a stage handler receives.
@ -449,6 +476,8 @@ type PluginSDK struct {
llm LLMAPI
sett SettingsAPI
social SocialAPI
secrets SecretsAPI
rawFiles RawFilesAPI
events EventSubscriber
plgMgr PluginMgrAPI
@ -540,6 +569,16 @@ func (s *PluginSDK) LLM() LLMAPI {
}
// Social returns the social graph API (may be nil if not available).
// Secrets returns the kernel secret vault API.
//
// ★ 内核未启用安全模块时它**不是 nil**,而是 Available() 返回 false 的
// 实现 —— 这样插件可以统一地"先问 Available 再决定",不必到处判空。
func (s *PluginSDK) Secrets() SecretsAPI {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
return s.secrets
}
func (s *PluginSDK) Social() SocialAPI {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
@ -703,6 +742,30 @@ func (s *PluginSDK) SetIOInjector(io IOInjector) {
}
// SetMemoryAPI sets the memory API (called by the core at startup).
// RawFiles 返回随包分发资源的读取接口(内核装配时注入)。
//
// 未注入时返回 nil —— 那是旧内核或测试环境。插件应判空后**响亮失败**,
// 而不是拿一个空实现继续(会表现为"资源读出来是空的",与"资源不存在"难区分)。
func (s *PluginSDK) RawFiles() RawFilesAPI {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
return s.rawFiles
}
// SetRawFilesAPI 由内核装配时注入(插件不调用它)。
func (s *PluginSDK) SetRawFilesAPI(v RawFilesAPI) {
s.apiMu.Lock()
s.rawFiles = v
s.apiMu.Unlock()
}
// SetSecretsAPI 由内核装配时注入(插件不调用它)。
func (s *PluginSDK) SetSecretsAPI(v SecretsAPI) {
s.apiMu.Lock()
s.secrets = v
s.apiMu.Unlock()
}
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) {
s.apiMu.Lock()
s.mem = mem

56
sdk/rawfiles.go Normal file
View File

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

111
sdk/secrets.go Normal file
View File

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

View File

@ -7,6 +7,21 @@ type SettingsAPI interface {
// Set writes a config value to the plugin's own config table.
Set(key string, value interface{}) error
// SetSecret writes a value that is ALWAYS encrypted at rest, regardless of
// the key name. Use it when the key name is neutral (e.g. "blob", "conn")
// and so cannot be detected as sensitive automatically.
//
// Returns an error (not a silent plaintext write) when no master key is
// installed — a plugin that believes it stored a secret must not end up
// with plaintext on disk.
//
// The companion read is GetSecret. ConfigDef.Secret/Type=="password" and
// sensitive key names are encrypted automatically and do NOT need this.
SetSecret(key string, value interface{}) error
// GetSecret reads a value written by SetSecret (transparently decrypted).
GetSecret(key string) (string, error)
// List returns all keys matching the given prefix.
List(prefix string) ([]string, error)

View File

@ -8,6 +8,37 @@ description: 开发、安装、调试 HomeAgent 插件。涉及 hmapdev 工具
> 本机(192.168.2.60)HomeAgent 的插件工具链手册。**所有命令都实测过**,
> 不是从文档抄的。改了本机环境后请回来更正。
## ★ 协议通道纪律:插件**绝不能**往 stdout 打印(高频踩坑)
子进程插件的 **stdout 是 JSON-RPC 协议通道**,**stderr 才是日志通道**
(SDK 侧已 `log.SetOutput(os.Stderr)`)。
```go
// ❌ 错误:写进 stdout ⇒ 内核收到裸文本 ⇒ 报「非法 JSON 帧」并**丢弃这行日志**
fmt.Printf("[%s] started\n", p.name)
// ✅ 正确:log 默认走 stderr,且会被内核收进插件日志
log.Printf("[%s] started", p.name)
```
**症状**(很容易误判成内核/协议故障):
```
[proc] myplugin 收到非法 JSON 帧(19 字节): invalid character 'm' looking for beginning of value
↑ 内容摘要: [myplugin] started
可能原因:插件往 **stdout** 打印了日志,而 stdout 是JSON-RPC **协议通道**(stderr 才是日志通道)。
修法:把插件里的 fmt.Printf/fmt.Println 改成 log.Printf(脚手架模板 1.4.0 起已修正)。
```
看到这条就说明**是你自己 printf 了** —— 日志本身丢了,还多一条看起来像协议故障的报错。
**实测代价**:生产环境累计 **1038 条**这种报错(weather 113 / rss 103 /
calendar 102 / 第三方 720),定位真因花了一整轮排查。
**例外**:**进程内插件**(`plugin.RegisterFactory`,编译进内核)的
`fmt.Println` 写的是内核真实终端,**不是**缺陷。判别方法:
插件源码是不是 `package main`(子进程)还是普通的库包(进程内)。
## 0. 先搞清三件事,别猜
```bash
@ -84,16 +115,21 @@ hmapdev debug . # 不想装就能先跑一遍
### 注册工具的形状(照 example 写,别自创)
**★ `sdk.ToolDef` 里没有 `Handler` 字段** —— handler 是 `RegisterTool` 的**第三个参数**,
不是结构体成员。把 `Handler:` 写进结构体会**编译失败**(`unknown field Handler`)。
```go
s.RegisterTool(tp+"mytool", sdk.ToolDef{
Name: tp + "mytool", // ← Name 必填,且要放在结构体**首位**
Description: "……",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Handler: h.mytool,
NoMemory: true, // 声明字段一律放**末尾**,仿 NoMemory 的写法
}, s)
}, h.mytool) // ← handler 在这里,不在结构体内
```
(`ToolDef` 的全部字段:`Name` / `Plugin` / `Description` / `Parameters` / `NoMemory` /
`Cleaner` / `ContextPolicy` / `RecallPolicy` / `ParallelSafe` / `Serial`。)
**并发声明**(同轮多个 tool_call 时):
- `ParallelSafe: true` —— 可并发(**三个条件都满足才可声明**:handler 线程安全 /
@ -202,4 +238,39 @@ curl -s -H "X-API-Key: $K" http://127.0.0.1:8080/api/v1/status | grep -oE '"comm
- **不要手改生成的文档**:`docs/api/*.md` 由 `tools/apidoc/gensite` 生成,
首行写着「请勿手改」;要改就去改 `sdk/*.go` 的注释再重新生成。
- **不要在插件里硬编码 token / 密钥**:走 `Settings()` 或宿主注入。
### 7.1 敏感配置的落盘加密(用 `SetSecret` / `GetSecret`)
`SettingsAPI` 提供两个方法让插件**无需自己实现加密**(主密钥由内核统一管理):
```go
// 强制加密落盘(不看键名)—— 返回 error 表示内核没装主密钥,
// 此时**拒绝写入**而不是静默存明文;不要吞掉这个 error。
if err := s.Settings().SetSecret("blob", token); err != nil {
return err
}
// 读回(透明解密)
token, _ := s.Settings().GetSecret("blob")
```
**两条自动加密规则**(不写 `SetSecret` 也会加密):
1. `ConfigDef{Secret: true}` —— 推荐,意图最明确
2. `ConfigDef{Type: "password"}` —— 旧写法,兼容保留
3. 键名含 `password` / `secret` / `token` / `api_key` 等词段 —— 兜底
⚠️ 什么时候**必须**用 `SetSecret`:键名是中性的(如 `blob`、`conn`、`payload`),
键名模式猜不到 —— 那就显式声明。
⚠️ 不要自己实现加密:主密钥由内核统一管理(`<dataDir>/security.key` 或
`HOMED_SECURITY_KEY`),插件各自持钥会让密钥散落、无法轮换。
**注意**:`Secret` 字段在旧版本里就已存在但**从未生效**(内核侧的转换漏了它,
见内核仓 `docs/zh/kernel-secret-firewall-design.md` §5.1)。若你的插件声明了
`Secret: true` 却看到明文落盘,说明内核侧还没接通 —— 升级内核,或改用
`SetSecret`(若该方法不存在,就是内核版本过旧)。
⚠️ **升级须知**:`SetSecret`/`GetSecret` 是**接口新增方法**。它们只增不减,
所以插件**消费**侧不受影响;但如果你自己实现了 `SettingsAPI`(如自测替身),
需要补上这两个方法。
- **不要为了让插件生效去改 `homed` 的源码** —— 先确认它是不是内置插件。

View File

@ -9,6 +9,9 @@ import (
"os/exec"
"path/filepath"
"runtime"
"sort"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
"strings"
)
@ -256,8 +259,24 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
writePluginJSON(plg, plats, procEntryFile)
// package single .hmap with correctly named entries
//
// rawfile:工程根 rawfile/ 默认全收 + plg.json 声明的外部路径。
// 收集失败即**整包失败**(少资源就是个坏包,却仍会产出 .hmap 让人以为成功)。
raws, rerr := collectRawFiles(".", plg.RawFiles)
if rerr != nil {
fmt.Printf(" error: rawfiles: %v\n", rerr)
return
}
// 随包 skill(skill/ 目录,与 rawfile/ 平行;见 skills.go)
skills, serr := collectSkillFiles(".")
if serr != nil {
fmt.Printf(" error: skill: %v\n", serr)
return
}
hmapPath := filepath.Join(outDir, fmt.Sprintf("%s_bundle.hmap", toSnake(plg.NameEn)))
createBundleHmap(hmapPath, "plugin.json", binaries)
// skill 与 rawfile 用同一个写入路径(都是"保留路径"的条目),
// 但分开收集以便分别报错与摘要。
createBundleHmap(hmapPath, "plugin.json", binaries, append(raws, skills...))
fmt.Printf(" packaged %s\n", filepath.Base(hmapPath))
}
@ -343,6 +362,23 @@ func writePluginJSON(plg *PlgConfig, platforms []string, entry string) {
if len(plg.Proxies) > 0 {
m["proxies"] = plg.Proxies
}
// ★ 启动时机声明**必须透传**(与 proxies 同一类"漏了插件作者会静默踩坑"):
// 内核读 plugin.json 的 requires_facilities 决定"何时拉起该插件"。
// 不透传 ⇒ 插件照常装上运行,但启动时机与作者预期不符,**无任何提示**。
if len(plg.RequiresFacilities) > 0 {
// 打包期校验:名字必须是内核已知设施(拼错在打包时报错,
// 而不是让用户装上一个"永不等不到就绪"因而**永不启动**的插件)。
if bad := unknownFacilities(plg.RequiresFacilities); len(bad) > 0 {
fmt.Printf(" error: requires_facilities 含内核未知的设施名 %v(已知: %v)\n",
bad, pubsdk.KnownFacilities())
return
}
m["requires_facilities"] = plg.RequiresFacilities
}
// auto_start:只有**显式 false** 才写(缺省 true 与旧行为一致,不写字段最兼容)
if plg.AutoStart != nil && !*plg.AutoStart {
m["auto_start"] = false
}
data, _ := json.MarshalIndent(m, "", " ")
os.WriteFile("plugin.json", data, 0644)
}
@ -711,7 +747,17 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
}
}
hmapName := fmt.Sprintf("%s_%s_%s.hmap", toSnake(plg.NameEn), cfg.goos, cfg.goarch)
createHmap(filepath.Join(outDir, hmapName), pkgFiles)
raws, rerr := collectRawFiles(".", plg.RawFiles)
if rerr != nil {
fmt.Printf(" error: rawfiles: %v\n", rerr)
return
}
skills, serr := collectSkillFiles(".")
if serr != nil {
fmt.Printf(" error: skill: %v\n", serr)
return
}
createHmapWithRaw(filepath.Join(outDir, hmapName), pkgFiles, append(raws, skills...))
fmt.Printf(" packaged %s\n", hmapName)
}
@ -723,7 +769,7 @@ type binEntry struct {
// createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。
// jsonName 是 plugin.json 在 zip 中的条目名;
// binaries 的 src 为磁盘路径,zip 为 zip 中的条目名。
func createBundleHmap(hmapPath, jsonName string, binaries []binEntry) {
func createBundleHmap(hmapPath, jsonName string, binaries []binEntry, raws []rawEntry) {
f, err := os.Create(hmapPath)
if err != nil {
fmt.Printf("error: create hmap %s: %v\n", hmapPath, err)
@ -773,6 +819,27 @@ func createBundleHmap(hmapPath, jsonName string, binaries []binEntry) {
writeZipEntry(fname, fname)
}
}
// ★ rawfile:非代码资源(**保留路径**,与 writeZipEntry 的扁平化不同)
if len(raws) > 0 {
if err := writeRawEntries(w, raws); err != nil {
fmt.Printf("error: 写入 rawfiles 失败: %v\n", err)
return
}
if sum := rawFilesSummary(raws); sum != "" {
fmt.Printf(" 已打包 %s\n", sum)
}
// skill 单独一行(与 rawfile 分开报,便于判断"我加的 skill 进去了吗")
var skillOnly []rawEntry
for _, e := range raws {
if strings.HasPrefix(e.zip, SkillZipPrefix+"/") {
skillOnly = append(skillOnly, e)
}
}
if sum := skillFilesSummary(skillOnly); sum != "" {
fmt.Printf(" 已打包 %s\n", sum)
}
}
}
func createHmap(hmapPath string, files []string) {
@ -813,6 +880,49 @@ func createHmap(hmapPath string, files []string) {
}
}
// createHmapWithRaw 是 createHmap 的 rawfile 版本(保留路径)。
func createHmapWithRaw(hmapPath string, files []string, raws []rawEntry) {
f, err := os.Create(hmapPath)
if err != nil {
fmt.Printf("error: create hmap %s: %v\n", hmapPath, err)
return
}
defer f.Close()
w := zip.NewWriter(f)
defer w.Close()
for _, path := range files {
if path == "" {
continue
}
info, err := os.Stat(path)
if err != nil {
continue
}
hdr, err := zip.FileInfoHeader(info)
if err != nil {
continue
}
hdr.Method = zip.Deflate
hdr.Name = filepath.Base(path)
writer, err := w.CreateHeader(hdr)
if err != nil {
continue
}
src, err := os.Open(path)
if err != nil {
continue
}
io.Copy(writer, src)
src.Close()
}
if len(raws) > 0 {
if err := writeRawEntries(w, raws); err != nil {
fmt.Printf("error: 写入 rawfiles 失败: %v\n", err)
return
}
}
}
func toSnake(s string) string {
return strings.ToLower(strings.ReplaceAll(s, " ", "_"))
}
@ -942,3 +1052,23 @@ func sdkHasInjectPriority(sdkPath string) bool {
}
return strings.Contains(seg, "Priority")
}
// unknownFacilities 返回**内核未知**的设施名(打包期校验,防拼错)。
//
// ★ 为什么在打包期校验而不是留给内核:拼错的后果是插件**永不启动**
// (永远等不到那个名字就绪),且内核侧此前无任何报错。在这里报错时
// 用户还没装它,代价最小。
func unknownFacilities(declared []string) []string {
known := map[string]bool{}
for _, f := range pubsdk.KnownFacilities() {
known[f] = true
}
var bad []string
for _, f := range declared {
if !known[f] {
bad = append(bad, f)
}
}
sort.Strings(bad)
return bad
}

View File

@ -113,9 +113,40 @@ type PlgConfig struct {
// 字段语义见 SDK 的 sdk.ProxyDef(工具链与内核共用同一套校验规则)。
Proxies []ProxyConfig `json:"proxies,omitempty"`
// RawFiles 声明要一并打包的**非代码资源**(见 rawfiles.go)。
//
// 工程根的 `rawfile/` 目录**默认全收**,不需要在这里声明;
// 本数组用于**额外**的外部路径。解包后统一落在 <插件目录>/rawfile/ 下。
RawFiles []RawFileDecl `json:"rawfiles,omitempty"`
// ResolvedSDK 是本次构建实际选中的 SDK 版本(build 按 SDK 声明解析后回填),
// 只写进产物里的 plugin.json,便于事后追溯「这个 .hmap 是哪版 SDK 编的」。
ResolvedSDK string `json:"-"`
// RequiresFacilities 声明本插件需要**内核哪些设施就绪**后才能启动。
//
// 取值见 sdk.KnownFacilities()(如 FacilityConfig / FacilityLLM / FacilityStageHost)。
// **不声明** ⇒ 等内核完全初始化(默认;与旧行为一致)。
//
// ★ 为什么必须在 PlgConfig 里声明、且必须写进产物 plugin.json:
// 内核靠读 plugin.json 的该字段决定**何时拉起插件**。工具链若不透传,
// 插件作者在 plg.json 里写了也**不会进包** —— 而且是**静默丢失**
// (插件照常装上、照常运行,只是启动时机与作者预期不符,无任何提示)。
// 同理见 proxies 字段的注释(同一类"漏透传"坑)。
//
// ★ 打包期校验:值必须是 sdk.KnownFacilities() 里的名字。
// 拼错在**打包时就报错**,比运行期发现早得多(运行期发现意味着用户
// 已经装上了那个**永不启动**的插件)。
RequiresFacilities []string `json:"requires_facilities,omitempty"`
// AutoStart 声明是否**伴随内核启动**(缺省 true)还是等显式启用(false)。
//
// 用 *bool 而非 bool:必须区分"未声明"与"显式 false"。
// 缺省(nil) ⇒ true(伴随启动,与旧行为一致)
// 显式 false ⇒ 不自动拉起,等 agent 经 plugin_enable 启用
// 若用普通 bool,零值是 false ⇒ "没写这个字段"会被当成"不自动启动"
// ⇒ 存量插件全部不启动(灾难性)。
AutoStart *bool `json:"auto_start,omitempty"`
}
// TargetList parses the Targets string into a slice.
@ -286,6 +317,21 @@ func cmdInit(args []string) {
// create thirdpart directory for external library sources
os.MkdirAll(filepath.Join(dir, "thirdpart"), 0755)
// create rawfile directory:**默认打包目录**。
// 放这里的东西会被 hmapdev build 自动打进 .hmap,
// 解包后落在 <插件目录>/rawfile/ 下(见 tools/hmapdev/rawfiles.go)。
// 留一个 .gitkeep 让空目录也能进版本库。
rawDir := filepath.Join(dir, "rawfile")
os.MkdirAll(rawDir, 0755)
os.WriteFile(filepath.Join(rawDir, ".gitkeep"), []byte(""), 0644)
// ★ 自带的 .gitignore:rawfile/ 里放的是随包资源(可能内嵌 node/pi
// 等上百兆二进制),由构建方自行准备,不能进版本库 ——
// 一个克隆带上 300MB 二进制、每次升级产生巨型 diff。
// 规则放在目录内部(自包含),不依赖上层 .gitignore 的否定式语义。
os.WriteFile(filepath.Join(rawDir, ".gitignore"), []byte(
"# rawfile/ 里的是随包资源,由构建方自行准备(下载/拷贝),不进版本库。\n"+
"*\n!.gitignore\n!.gitkeep\n"), 0644)
fmt.Printf("Created plugin project %q (%s)\n", dir, entry)
if isLua {
fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir)

View File

@ -0,0 +1,126 @@
package main
// 示例插件的**协议通道纪律**判据(2026-10-16)。
//
// ## 为什么必须有这条
//
// 示例插件是**子进程插件**(`package main` + `plugin.bin`),它们的
// **stdout 是 JSON-RPC 协议通道**。用 `fmt.Printf` 打日志 ⇒ 裸文本进入
// 协议流 ⇒ 内核报「收到非法 JSON 帧」并丢弃该日志。
//
// ## 这是**生产环境正在发生**的缺陷(不是理论问题)
//
// 实测 `journalctl -u homeagent`:**1038 条**非法帧报错,来源:
//
// huawei_smarthome 720 / weather 113 / rss 103 / calendar 102
//
// weather / rss / calendar 正是本仓示例插件 ⇒ 已全部修为 log.Printf。
//(huawei_smarthome 是第三方插件,源码不在本仓 —— 内核侧的可定位报错
// 就是为它那类情况准备的。)
//
// ## 判据形态:源码扫描(这里是**恰当**的)
//
// 每个示例插件是**独立 Go module**(21 个 go.mod),无法在一个测试进程里
// 全部编译 ⇒ 只能扫源码。断言的是"某个模式是否出现",属于
// "符号/模式存在性"那一类(对比:断言接线/顺序时源码扫描会骗人)。
//
// ★ 必须**先剥注释**:示例插件里可能**刻意**提到 fmt.Printf 来讲这个坑。
import (
"os"
"path/filepath"
"strings"
"testing"
)
// ★★★ 判据:所有子进程示例插件**不得**往 stdout 打印。
func TestExamplePluginsDoNotWriteStdout(t *testing.T) {
exDir := filepath.Join("..", "..", "example")
entries, err := os.ReadDir(exDir)
if err != nil {
t.Skipf("读不到 example 目录: %v", err)
}
checked := 0
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
// 只查**子进程**插件(有 plugin.go 且以 package main 开头)
var files []string
fis, err := os.ReadDir(filepath.Join(exDir, name))
if err != nil {
continue
}
for _, fi := range fis {
if fi.IsDir() || !strings.HasSuffix(fi.Name(), ".go") ||
strings.HasSuffix(fi.Name(), "_test.go") {
continue
}
files = append(files, filepath.Join(exDir, name, fi.Name()))
}
isSubprocess := false
for _, f := range files {
b, _ := os.ReadFile(f)
if strings.HasPrefix(string(b), "package main") {
isSubprocess = true
break
}
}
if !isSubprocess {
continue
}
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
continue
}
code := stripTemplateComments(string(b))
for _, pat := range []string{"fmt.Printf(", "fmt.Println(", "fmt.Print(", "os.Stdout"} {
if !strings.Contains(code, pat) {
continue
}
// 找出具体行号(便于修)
for i, ln := range strings.Split(code, "\n") {
if strings.Contains(ln, pat) {
t.Errorf("★ %s:%d 含 `%s` —— 子进程插件的 **stdout 是 "+
"JSON-RPC 协议通道**,内核会报「收到非法 JSON 帧」并"+
"丢弃该日志。\n 修法:改成 log.Printf(默认 stderr)。\n 行: %s",
filepath.Join(name, filepath.Base(f)), i+1, pat, strings.TrimSpace(ln))
}
}
}
}
checked++
}
if checked == 0 {
t.Fatal("没找到任何子进程示例插件 —— 判据会永远绿(请检查目录名/结构)")
}
t.Logf("检查了 %d 个子进程示例插件", checked)
}
// ★★★ 判据:**反向钉住** —— 必须有示例插件真的在用 log.Printf。
//
// ★ 为什么需要:上面那条可以靠"把所有日志删光"通过,但那样示例就
//
// 失去了"示范正确做法"的意义。这条确保至少有几个插件在用 log.Printf。
func TestExamplePluginsUseStderrLogging(t *testing.T) {
exDir := filepath.Join("..", "..", "example")
want := []string{"weather", "rss", "calendar"}
for _, name := range want {
p := filepath.Join(exDir, name, "plugin.go")
b, err := os.ReadFile(p)
if err != nil {
t.Errorf("读 %s: %v", p, err)
continue
}
src := string(b)
if !strings.Contains(src, "log.Printf") {
t.Errorf("★ %s 没有用 log.Printf —— 它原先用 fmt.Printf 写 stdout,"+
"实测生产环境累计 113/103/102 条非法帧报错。", name)
}
if !strings.Contains(src, `"log"`) {
t.Errorf("★ %s 用了 log.Printf 但没 import \"log\"", name)
}
}
}

View File

@ -0,0 +1,303 @@
package main
// 发版打包脚本(package/build-examples.sh)的判据(2026-10-17)。
//
// ## 守什么
//
// 1. **产物架构必须与目标平台一致** —— 打 arm64 包不能装进 x86-64 二进制
// 2. **Lua 插件不得被静默跳过** —— 它们没有 plugin.go,但有 main.lua
//
// ## ★ 为什么必须是行为判据(而不是扫脚本源码)
//
// 这两个缺陷的共性是「**脚本逻辑错了但看起来成功**」:
//
// · 架构错:`ls dist/*.hmap | head -1` 在 `--target` 追加语义下
// 按字典序拿到 amd64 那份 ⇒ 文件名写 `linux_arm64_...` 而内容是 x86-64。
// 脚本**退出码 0、校验和也对**(校验和只证明文件没坏,不证明架构对)。
// · Lua 跳过:入口判据只认 plugin.go ⇒ luademo 静默不产包,
// 同样退出码 0、同样"成功 21/21"。
//
// ⇒ 如果只扫脚本里有没有 `_"${GOOS}_${GOARCH}"` 字样,把匹配写错、
// 或 `head -1` 又溜回来,判据仍会绿。**必须真的构建并检查 ELF 头。**
import (
"archive/zip"
"encoding/binary"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// ★★★ 判据:**打包脚本真的按目标平台挑产物**(跑真实脚本,检查 ELF 架构)。
//
// ## 为什么必须跑**脚本本身**(而不是只测 hmapdev)
//
// 缺陷在脚本的"从产物里挑哪一份"这一步:
//
// hmap="$(ls "$dir"/dist/*.hmap | head -1)" ← 按字典序拿到 amd64
//
// 而 `--target` 是**追加**语义(docs/guide/multi-platform.md 写明),
// 示例 plg.json 又普遍声明 `targets: "linux/amd64"`
// ⇒ `--target linux/arm64` 会**同时**产出 amd64 与 arm64 两份
// ⇒ `head -1` 拿到 amd64 ⇒ **打 arm64 包却装进 x86-64 二进制**。
//
// ★ 我第一版判据直接调 `hmapdev` 并自己挑目标平台那份 ⇒ **把变异改回
//
// `head -1` 后判据照样绿**(它根本没跑脚本,测的是 hmapdev 而非脚本)。
// ⇒ 判据必须驱动**真实的 package/build-examples.sh**。
//
// ## 做法
//
// 造一个**最小 SDK 镜像**(临时目录):
//
// <tmp>/package/build-examples.sh ← 复制真实脚本
// <tmp>/example/memo/ ← symlink 到真实示例(含 plg.json targets)
// <tmp>/build/hmapdev_<host> ← 宿主工具链(脚本按此名查找)
//
// 然后以 arm64 为目标跑脚本,读**它产出的**文件里的 ELF machine。
func TestPackagingScriptPicksTargetArchArtifact(t *testing.T) {
if testing.Short() {
t.Skip("需要真实交叉构建")
}
host := hostArch()
var target, wantMachine, wantSuffix string
switch host {
case "amd64":
target, wantMachine, wantSuffix = "linux/arm64", "aarch64", "linux_arm64"
case "arm64":
target, wantMachine, wantSuffix = "linux/amd64", "x86-64", "linux_amd64"
default:
t.Skipf("未知宿主架构 %s", host)
}
sdkRoot := filepath.Join("..", "..")
scriptSrc := filepath.Join(sdkRoot, "package", "build-examples.sh")
if _, err := os.Stat(scriptSrc); err != nil {
t.Skipf("找不到打包脚本: %v", err)
}
exampleSrc := filepath.Join(sdkRoot, "example", "memo")
plg, err := os.ReadFile(filepath.Join(exampleSrc, "plg.json"))
if err != nil || !strings.Contains(string(plg), `"targets"`) {
t.Skip("示例未声明 targets,复现不了「追加」前提")
}
// 最小镜像
mirror := t.TempDir()
mustMkdir(t, filepath.Join(mirror, "package"))
mustMkdir(t, filepath.Join(mirror, "example"))
mustMkdir(t, filepath.Join(mirror, "build"))
copyFile(t, scriptSrc, filepath.Join(mirror, "package", "build-examples.sh"))
// ★ symlink 目标必须是**绝对路径**:脚本与插件内部有 `cd`,
// 相对目标会相对**链接所在目录**解析(实测 `../../example/memo` 从
// mirror/example/ 解析成了 mirror 的父目录 ⇒ 循环匹配不到任何插件,
// 表现为「成功 0 / 失败 0」而不报错)。
if err := os.Symlink(absOf(t, exampleSrc), filepath.Join(mirror, "example", "memo")); err != nil {
t.Fatalf("symlink: %v", err)
}
// 脚本按 hmapdev_<host> 名查找工具链
dev := buildHostHmapdev(t)
if err := os.Symlink(dev, filepath.Join(mirror, "build", "hmapdev_"+host)); err != nil {
t.Fatalf("symlink hmapdev: %v", err)
}
// 插件的 go.mod 里有 `replace github.com/JianFeeeee/homeagentsdk => ../../`,
// 它解析到**镜像根** ⇒ 镜像根必须有真实的 SDK Go 源码(go.mod + sdk/ + meta/…)。
// 不接上的话,交叉构建会以
// `reading <mirror>/go.mod: no such file or directory` 失败 ——
// 看起来像工具链坏了,其实只是测试夹具不全。
sdkAbs := absOf(t, sdkRoot)
for _, name := range []string{"go.mod", "go.sum", "sdk", "meta", "lua"} {
src := filepath.Join(sdkAbs, name)
if _, err := os.Stat(src); err != nil {
continue
}
if err := os.Symlink(src, filepath.Join(mirror, name)); err != nil {
t.Logf("symlink %s(非致命): %v", name, err)
}
}
// 脚本在找不到工具链时会尝试 `cd <sdk>/tools/hmapdev && go build`。
// 这里已提供工具链,但保险起见仍把该目录接上(脚本的 fallback 分支)。
mustMkdir(t, filepath.Join(mirror, "tools"))
if err := os.Symlink(filepath.Join(absOf(t, sdkRoot), "tools", "hmapdev"),
filepath.Join(mirror, "tools", "hmapdev")); err != nil {
t.Logf("symlink tools/hmapdev(非致命): %v", err)
}
// ★ OUT_DIR 必须传**绝对路径**:脚本内部有 `cd "$dir"`(进插件目录构建),
// 若 OUT_DIR 是相对的,后续 `mkdir -p`/`cp` 会解析到插件目录下 ——
// 实测那样会得到「脚本报成功、但 outDir 里只有 MANIFEST/SHA256SUMS」,
// 看起来像"没有插件被构建",其实产物落到了别处。
outDir := filepath.Join(mirror, "out")
cmd := exec.Command("bash", filepath.Join(mirror, "package", "build-examples.sh"), target, outDir)
cmd.Dir = mirror
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
scriptOut, err := cmd.CombinedOutput()
t.Logf("脚本输出:\n%s", scriptOut)
if err != nil {
t.Fatalf("脚本失败: %v\n%s", err, scriptOut)
}
// 脚本自己命名产物为 <goos>_<goarch>_<name>.hmap
var picked string
entries, _ := os.ReadDir(outDir)
for _, e := range entries {
if strings.HasPrefix(e.Name(), wantSuffix+"_") && strings.HasSuffix(e.Name(), ".hmap") {
picked = filepath.Join(outDir, e.Name())
break
}
}
if picked == "" {
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
t.Fatalf("脚本未产出 %s 前缀的包。实际: %v", wantSuffix, names)
}
if got := elfMachineOfHmap(t, picked); got != wantMachine {
t.Fatalf("★ 脚本产出的 %s 里 plugin.bin 架构是 %s,期望 %s\n"+
" ⇒ 脚本挑错了产物(文件名写 %s,内容却是别的平台)。\n"+
" 在目标机器上只会以 Exec format error 失败,且排查方向被文件名带偏。",
filepath.Base(picked), got, wantMachine, target)
}
}
func absOf(t *testing.T, p string) string {
t.Helper()
a, err := filepath.Abs(p)
if err != nil {
t.Fatal(err)
}
return a
}
// mustMkdir / copyFile:测试辅助
func mustMkdir(t *testing.T, p string) {
t.Helper()
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
}
func copyFile(t *testing.T, src, dst string) {
t.Helper()
b, err := os.ReadFile(src)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, b, 0o755); err != nil {
t.Fatal(err)
}
}
// ★★★ 判据:**Lua 插件不被打包脚本跳过**。
//
// luademo 没有 plugin.go(它是 main.lua),原脚本的
// `[ -f "$dir/plugin.go" ] || continue` 会**静默跳过它**
// ⇒ 发版产物少一个插件,而日志显示"成功 N/N"、退出码 0。
//
// ★ 判据形态:扫脚本源码里"入口判据"那一行 —— 因为要断言的是
//
// "脚本对 Lua 插件的**接纳条件**是否放宽",这是一个**模式存在性**断言
// (不是接线/顺序断言),源码扫描在此恰当。
func TestPackagingScriptAcceptsLuaPlugins(t *testing.T) {
p := filepath.Join("..", "..", "package", "build-examples.sh")
b, err := os.ReadFile(p)
if err != nil {
t.Skipf("读不到打包脚本: %v", err)
}
src := string(b)
// 找"入口判据":必须同时认 plugin.go 与 main.lua
hasGoEntry := strings.Contains(src, "plugin.go")
hasLuaEntry := strings.Contains(src, "main.lua")
if !hasGoEntry {
t.Fatal("脚本不再认 plugin.go 入口?请检查是否改坏了")
}
if !hasLuaEntry {
t.Fatal("★ 打包脚本只认 plugin.go 入口 ⇒ **Lua 插件被静默跳过**\n" +
" (luademo 是 main.lua,不产 .hmap,脚本仍报成功、退出码 0)")
}
// 反向钉住:不能只把 main.lua 写在注释里就算数 ——
// 判据行(含 continue 的那段)必须在 main.lua 附近出现
idx := strings.Index(src, "main.lua")
if idx < 0 {
t.Fatal("main.lua 未出现在脚本中")
}
// 该处周边 300 字符内应有 continue 或 -f 判断(即真的是入口判据)
lo := idx - 300
if lo < 0 {
lo = 0
}
hi := idx + 300
if hi > len(src) {
hi = len(src)
}
around := src[lo:hi]
if !strings.Contains(around, "continue") && !strings.Contains(around, "-f") {
t.Error("★ main.lua 只出现在注释里,入口判据并未真的接纳 Lua 插件")
}
}
// ---- helpers ----
func hostArch() string {
out, err := exec.Command("go", "env", "GOARCH").Output()
if err != nil {
return ""
}
a := strings.TrimSpace(string(out))
switch a {
case "x86_64":
return "amd64"
case "aarch64":
return "arm64"
}
return a
}
func buildHostHmapdev(t *testing.T) string {
t.Helper()
out := filepath.Join(t.TempDir(), "hmapdev")
cmd := exec.Command("go", "build", "-o", out, ".")
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
if b, err := cmd.CombinedOutput(); err != nil {
t.Skipf("构建宿主 hmapdev 失败(跳过): %v\n%s", err, b)
}
return out
}
// elfMachineOfHmap 读出 .hmap 里 plugin.bin 的架构名。
func elfMachineOfHmap(t *testing.T, hmapPath string) string {
t.Helper()
zr, err := zip.OpenReader(hmapPath)
if err != nil {
t.Fatalf("打开 %s: %v", hmapPath, err)
}
defer zr.Close()
for _, f := range zr.File {
if !strings.HasPrefix(f.Name, "plugin.bin") {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
hdr := make([]byte, 20)
n, _ := rc.Read(hdr)
rc.Close()
if n < 20 || hdr[0] != 0x7f || hdr[1] != 'E' || hdr[2] != 'L' || hdr[3] != 'F' {
t.Fatalf("%s 里的 %s 不是 ELF", hmapPath, f.Name)
}
switch binary.LittleEndian.Uint16(hdr[18:20]) {
case 0x3e:
return "x86-64"
case 0xb7:
return "aarch64"
}
return "unknown"
}
t.Fatalf("%s 里没有 plugin.bin", hmapPath)
return ""
}

294
tools/hmapdev/rawfiles.go Normal file
View File

@ -0,0 +1,294 @@
package main
// rawfile:把**非代码资源**打进 .hmap,解包后供插件在运行期读取。
//
// ## 为什么需要
//
// 在它之前 `hmapdev build` 只收 `plugin.bin` + `plugin.json`(+README/LICENSE),
// **任何资源都得靠部署脚本手工拷** —— 那让"一个 .hmap 就是完整插件"这个
// 前提失效:白板机器上装完直接缺文件。
//
// 典型需求:随包分发的 node 与 pi、各 CLI 后端的 sidecar 脚本与 driver.json、
// 模板、prompt 文本、字典、默认配置、wasm、字体……
//
// ## ★ 规则(用户拍板 2026-10-15)
//
// 1. 工程根下的 `rawfile/` 目录 **默认全部打包**(不需要在 plg.json 里声明)。
// 2. 其他位置的资源,用户在 plg.json 的 `rawfiles` 数组里**显式列出**,一同打包。
// 3. 解包后**一律落在 `<插件目录>/rawfile/` 下**:
// · `rawfile/` 目录里的内容 → `<插件目录>/rawfile/<原名>`
// · 外部声明的路径 → `<插件目录>/rawfile/<声明名>`
//
// 即 `rawfile/` 是**唯一入口**,插件只需认一个目录。
//
// ## plg.json 形状
//
// {
// "rawfiles": [
// "config/defaults.json", // 字符串:源路径,落到 rawfile/defaults.json
// { "from": "assets", "exclude": ["assets/tmp"] }
// ]
// }
//
// ## 为什么统一落到 rawfile/ 下
//
// 若允许任意目标路径(比如把 node 直接放到 `runtime/node/bin/node`),
// 就会出现两类路径(工程路径 vs 包内路径)互相穿模,且**与插件自己写的
// 运行期文件混在一起**——升级时无法区分"包里带的"与"运行期生成的"。
// 单一入口让"包内的"与"运行期的"天然分离:包内只读,运行期在别处。
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
// RawFilesDir 是默认打包目录(工程根下)。
const RawFilesDir = "rawfile"
// RawFilesPrefix 是解包后这些资源的统一前缀。
const RawFilesPrefix = "rawfile"
// RawFileDecl 是一条 rawfiles 声明(支持字符串与对象两种写法)。
type RawFileDecl struct {
From string `json:"from"`
Exclude []string `json:"exclude,omitempty"`
Optional bool `json:"optional,omitempty"`
}
// UnmarshalJSON 同时接受 `"path"` 与 `{"from": "path"}` 两种写法。
//
// 为何两种都收:只要一个路径时写字符串最省事(`"config/x.json"`),
// 需要 exclude/optional 时才用对象。强迫所有人写对象是没必要的仪式。
func (d *RawFileDecl) UnmarshalJSON(b []byte) error {
s := strings.TrimSpace(string(b))
if strings.HasPrefix(s, `"`) {
var only string
if err := json.Unmarshal(b, &only); err != nil {
return err
}
d.From = only
return nil
}
type alias RawFileDecl
var a alias
if err := json.Unmarshal(b, &a); err != nil {
return err
}
*d = RawFileDecl(a)
return nil
}
type rawEntry struct {
src string // 磁盘路径
zip string // zip 内条目名(保留路径)
}
// collectRawFiles 收集要打包的资源。
//
// 两部分合起来:
//
// ① 工程根的 `rawfile/` 目录(默认全收)
// ② plg.json 里 rawfiles 声明的外部路径
//
// 输出按 zip 条目名排序(**确定性**:同样输入产出同样字节,
// 否则每次构建的 .hmap 都不同,校验和与 diff 全无意义)。
func collectRawFiles(root string, decls []RawFileDecl) ([]rawEntry, error) {
var out []rawEntry
// ① 默认目录
defDir := filepath.Join(root, RawFilesDir)
if fi, err := os.Stat(defDir); err == nil && fi.IsDir() {
err := filepath.Walk(defDir, func(p string, fi os.FileInfo, werr error) error {
if werr != nil {
return werr
}
if fi.IsDir() {
return nil
}
rel, err := filepath.Rel(defDir, p)
if err != nil {
return err
}
// ★ 排除脚手架文件:`.gitkeep`/`.gitignore` 是**目录占位与版本控制**
// 用的,不是资源。不打进包的原因有两个:
// ① 它们是工程内部约定,对插件运行期毫无意义;
// ② 它们是**噪声** —— 一个空工程会打出"已打包 2 个 rawfile",
// 让人误以为里面有资源。
// (实测发现:init 出的新工程第一次 build 就把这两个文件打进去了。)
if IsRawFileScaffold(rel) {
return nil
}
out = append(out, rawEntry{
src: p,
zip: filepath.ToSlash(filepath.Join(RawFilesPrefix, rel)),
})
return nil
})
if err != nil {
return nil, fmt.Errorf("展开 %s/ 失败: %w", RawFilesDir, err)
}
}
// ② 显式声明
for _, d := range decls {
from := strings.TrimSpace(d.From)
if from == "" {
return nil, fmt.Errorf("rawfiles 有条目缺 from")
}
// ★ from 的语义:**相对 plg.json 所在目录(工程根)**。
//
// 绝对路径**不接受** —— 不是因为技术上做不到,而是它会引入一个
// 无法判定合法性的输入:`/etc/passwd` 与 `/home/me/proj/x` 在语法上
// 没有区别,只能靠"是否在工程内"事后判断,而那个判断依赖 cwd
// (构建时 cwd 不保证是工程根)⇒ 同一个 plg.json 在不同目录下构建
// 结果不同,**构建不可复现**。
//
// 所以:拒绝绝对路径,只接受相对路径。这样 Clean 后前缀比较就是完备的
// (实测发现:初版允许绝对路径,`"a/../../../etc/passwd"` 能绕出去)。
if filepath.IsAbs(from) {
return nil, fmt.Errorf(
"rawfiles 不接受绝对路径: %q(请写成相对工程根的路径)", from)
}
abs := filepath.Join(root, from)
cleanRoot := filepath.Clean(root)
cleanAbs := filepath.Clean(abs)
if cleanAbs != cleanRoot && !strings.HasPrefix(cleanAbs, cleanRoot+string(os.PathSeparator)) {
return nil, fmt.Errorf(
"rawfiles 路径逃出工程目录: %q(资源必须放在工程内)", from)
}
info, err := os.Stat(abs)
if err != nil {
if d.Optional {
fmt.Printf(" warn: rawfile 可选源缺失,跳过: %s\n", from)
continue
}
return nil, fmt.Errorf("rawfiles 源不存在: %s(要允许缺失请加 optional)", from)
}
base := filepath.Base(filepath.Clean(from))
if !info.IsDir() {
// 显式声明也排除脚手架(同一套规则,避免"两条来源行为不一致")
if IsRawFileScaffold(from) {
continue
}
out = append(out, rawEntry{
src: abs,
zip: filepath.ToSlash(filepath.Join(RawFilesPrefix, base)),
})
continue
}
err = filepath.Walk(abs, func(p string, fi os.FileInfo, werr error) error {
if werr != nil {
return werr
}
if fi.IsDir() {
return nil
}
rel, err := filepath.Rel(abs, p)
if err != nil {
return err
}
relSlash := filepath.ToSlash(rel)
if IsRawFileScaffold(relSlash) {
return nil
}
for _, ex := range d.Exclude {
ex = filepath.ToSlash(strings.TrimSpace(ex))
if ex != "" && strings.HasPrefix(relSlash, ex) {
return nil
}
}
out = append(out, rawEntry{
src: p,
zip: filepath.ToSlash(filepath.Join(RawFilesPrefix, base, rel)),
})
return nil
})
if err != nil {
return nil, fmt.Errorf("展开 rawfiles 目录 %s 失败: %w", from, err)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].zip < out[j].zip })
// 撞名检测:两条来源落到同一包内路径 —— 不报的话"我改的没生效"极难查
seen := map[string]string{}
for _, e := range out {
if prev, dup := seen[e.zip]; dup {
return nil, fmt.Errorf("rawfile 目标冲突: %q 同时来自 %s 与 %s", e.zip, prev, e.src)
}
seen[e.zip] = e.src
}
return out, nil
}
// writeRawEntries 把资源写进 zip(**保留路径**)。
//
// ★ 与既有 createHmap 的区别:那个用 `filepath.Base(path)` 当条目名
// (只适合单文件),目录进去会全部拍平撞名。rawfile 必须保留结构,
// 否则 `rawfile/main/foo.json` 会变成 `foo.json` 落在插件目录根上。
func writeRawEntries(w *zip.Writer, entries []rawEntry) error {
for _, e := range entries {
info, err := os.Stat(e.src)
if err != nil {
return fmt.Errorf("rawfile 源消失: %s: %w", e.src, err)
}
hdr, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
hdr.Method = zip.Deflate
hdr.Name = e.zip // ← 完整路径,不是 Base
writer, err := w.CreateHeader(hdr)
if err != nil {
return err
}
src, err := os.Open(e.src)
if err != nil {
return err
}
if _, err := io.Copy(writer, src); err != nil {
src.Close()
return err
}
src.Close()
}
return nil
}
// rawFilesSummary 给构建输出一行摘要。
func rawFilesSummary(entries []rawEntry) string {
if len(entries) == 0 {
return ""
}
var total int64
for _, e := range entries {
if fi, err := os.Stat(e.src); err == nil {
total += fi.Size()
}
}
return fmt.Sprintf("%d 个 rawfile(%.1f MiB)", len(entries), float64(total)/1048576)
}
// IsRawFileScaffold 判断某个**相对路径**是否是版本控制/目录占位用的脚手架文件。
//
// 这类文件随工程存在,但不是随包资源:
// - `.gitkeep` 空目录占位
// - `.gitignore` rawfile/ 自带的忽略规则
// - `.DS_Store` macOS Finder 的目录元数据(常被误提交)
//
// 排除它们既是"别把工程内部约定发给运行期",也是**去噪**:
// 不排的话一个空工程会报"已打包 2 个 rawfile",误导开发者以为包里有东西。
func IsRawFileScaffold(rel string) bool {
base := filepath.Base(filepath.ToSlash(rel))
switch base {
case ".gitkeep", ".gitignore", ".DS_Store":
return true
}
// 任意层级下的这些名字都不算资源
return false
}

View File

@ -0,0 +1,420 @@
package main
// rawfile 打包判据。
//
// 规则(用户拍板):
// ① 工程根 `rawfile/` 目录**默认全收**(无需在 plg.json 声明)
// ② plg.json 的 `rawfiles` 数组声明**额外**的外部路径
// ③ 解包后一律落在 `<插件目录>/rawfile/` 下
import (
"archive/zip"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// mkProject 造一个最小工程结构。
func mkProject(t *testing.T) string {
t.Helper()
root := t.TempDir()
write := func(rel, content string) {
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// 默认目录(含嵌套)
write("rawfile/main/prompt.txt", "you are a dev")
write("rawfile/main/nested/data.json", `{"a":1}`)
// 外部路径(声明后才收)
write("assets/theme.css", "body{}")
write("third_party/node/bin/node", "#!/bin/sh\n")
return root
}
// ★★ 判据:**rawfile/ 目录默认全收**,无需任何声明。
func TestRawfileDirIsCollectedByDefault(t *testing.T) {
root := mkProject(t)
ents, err := collectRawFiles(root, nil) // 不给声明
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, e := range ents {
got[e.zip] = true
}
for _, want := range []string{
"rawfile/main/prompt.txt",
"rawfile/main/nested/data.json", // ★ 嵌套结构必须保留
} {
if !got[want] {
t.Errorf("默认目录漏了 %s(实际 %v)", want, keysOf(got))
}
}
// 未声明的外部路径**不该**被收
for _, shouldNot := range []string{"rawfile/theme.css", "rawfile/node"} {
if got[shouldNot] {
t.Errorf("未声明的外部路径被误收: %s", shouldNot)
}
}
}
// ★★ 判据:声明的外部路径也收,且**统一落到 rawfile/ 下**。
func TestDeclaredExternalPathsAlsoCollected(t *testing.T) {
root := mkProject(t)
ents, err := collectRawFiles(root, []RawFileDecl{
{From: "assets/theme.css"},
{From: "third_party/node/bin/node"},
})
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, e := range ents {
got[e.zip] = true
}
// ★ 规则③:一律在 rawfile/ 下
if !got["rawfile/theme.css"] {
t.Errorf("声明的文件未落到 rawfile/ 下: %v", keysOf(got))
}
if !got["rawfile/node"] {
t.Errorf("声明的文件未落到 rawfile/ 下: %v", keysOf(got))
}
// 不得出现 rawfile/ 之外的条目
for z := range got {
if !strings.HasPrefix(z, "rawfile/") {
t.Errorf("★ 有资源落在 rawfile/ 之外: %s", z)
}
}
}
// ★★ 判据:目录递归展开时**保留结构**(不是拍平)。
//
// 这是 rawfile 与既有 createHmap 的关键区别:后者用 filepath.Base(),
// 目录进去会全部拍平成一层,`main/nested/data.json` 与另一处的 `data.json` 撞名。
func TestDirectoryStructureIsPreserved(t *testing.T) {
root := mkProject(t)
ents, err := collectRawFiles(root, []RawFileDecl{{From: "assets"}})
if err != nil {
t.Fatal(err)
}
var found bool
for _, e := range ents {
if e.zip == "rawfile/assets/theme.css" {
found = true
}
if e.zip == "rawfile/theme.css" {
t.Error("★ 目录被拍平了(丢了 assets/ 这一层)")
}
}
if !found {
t.Errorf("目录展开结果不对: %v", zipNames(ents))
}
}
// ★ 判据:exclude 生效。
func TestExcludeWorks(t *testing.T) {
root := mkProject(t)
if err := os.WriteFile(filepath.Join(root, "assets/skip.tmp"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
ents, err := collectRawFiles(root, []RawFileDecl{{From: "assets", Exclude: []string{"skip.tmp"}}})
if err != nil {
t.Fatal(err)
}
for _, e := range ents {
if strings.Contains(e.zip, "skip.tmp") {
t.Errorf("exclude 未生效: %s", e.zip)
}
}
}
// ★★ 判据:声明路径**逃出工程目录**必须报错。
//
// 不挡的话既让构建不可复现,也可能无意把凭据/密钥打进发布包。
func TestPathEscapeIsRejected(t *testing.T) {
root := mkProject(t)
if _, err := collectRawFiles(root, []RawFileDecl{{From: "../../etc/passwd"}}); err == nil {
t.Fatal("★ 逃出工程目录的声明未被拒绝")
}
}
// ★★ 判据:两条来源落到同一包内路径 ⇒ **报错**(不静默覆盖)。
//
// 不报的话"我改的没生效"极难查(后写的覆盖前者,且无任何提示)。
func TestConflictingTargetsAreRejected(t *testing.T) {
root := mkProject(t)
// ★ 真冲突:两条外部声明落到同一个 base 名(都变成 rawfile/<base>)
if err := os.MkdirAll(filepath.Join(root, "a"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "b"), 0o755); err != nil {
t.Fatal(err)
}
for _, d := range []string{"a", "b"} {
if err := os.WriteFile(filepath.Join(root, d, "same.txt"), []byte(d), 0o644); err != nil {
t.Fatal(err)
}
}
if _, err := collectRawFiles(root, []RawFileDecl{
{From: "a/same.txt"}, {From: "b/same.txt"},
}); err == nil {
t.Fatal("★ 目标冲突未被检出(会静默覆盖)")
}
}
// ★ 判据:optional 缺失只警告不报错;非 optional 缺失报错。
func TestOptionalMissingSource(t *testing.T) {
root := mkProject(t)
if _, err := collectRawFiles(root, []RawFileDecl{{From: "nope.txt"}}); err == nil {
t.Error("非 optional 的缺失源应报错")
}
if _, err := collectRawFiles(root, []RawFileDecl{{From: "nope.txt", Optional: true}}); err != nil {
t.Errorf("optional 缺失不应报错: %v", err)
}
}
// ★★ 判据:输出**确定有序**(同样输入产出同样字节)。
//
// 不稳定的话每次构建的 .hmap 都不同,校验和与 diff 全无意义。
func TestCollectionIsDeterministic(t *testing.T) {
root := mkProject(t)
a, err := collectRawFiles(root, nil)
if err != nil {
t.Fatal(err)
}
b, err := collectRawFiles(root, nil)
if err != nil {
t.Fatal(err)
}
if len(a) != len(b) {
t.Fatalf("两次收集数量不同: %d vs %d", len(a), len(b))
}
for i := range a {
if a[i].zip != b[i].zip {
t.Fatalf("顺序不稳定(第 %d 项): %s vs %s", i, a[i].zip, b[i].zip)
}
}
}
// ★★ 判据:**plg.json 同时接受字符串与对象两种写法**。
func TestRawFileDeclAcceptsBothForms(t *testing.T) {
var decls []RawFileDecl
raw := `["plain/path.txt", {"from": "obj/path", "exclude": ["x"]}, {"from": "opt", "optional": true}]`
if err := json.Unmarshal([]byte(raw), &decls); err != nil {
t.Fatal(err)
}
if len(decls) != 3 {
t.Fatalf("应解析出 3 条,实际 %d", len(decls))
}
if decls[0].From != "plain/path.txt" {
t.Errorf("字符串写法解析错: %+v", decls[0])
}
if decls[1].From != "obj/path" || len(decls[1].Exclude) != 1 {
t.Errorf("对象写法解析错: %+v", decls[1])
}
if !decls[2].Optional {
t.Errorf("optional 未解析: %+v", decls[2])
}
}
// ★★ 判据:**真正写进 zip 后,条目名保留完整路径**。
//
// 这条是端到端的:前面的判据只看收集结果,这里看 zip 里的真实条目。
func TestRawEntriesWrittenWithFullPath(t *testing.T) {
root := mkProject(t)
ents, err := collectRawFiles(root, nil)
if err != nil {
t.Fatal(err)
}
out := filepath.Join(t.TempDir(), "t.hmap")
f, err := os.Create(out)
if err != nil {
t.Fatal(err)
}
w := zip.NewWriter(f)
if err := writeRawEntries(w, ents); err != nil {
t.Fatal(err)
}
w.Close()
f.Close()
r, err := zip.OpenReader(out)
if err != nil {
t.Fatal(err)
}
defer r.Close()
names := map[string]bool{}
for _, zf := range r.File {
names[zf.Name] = true
}
if !names["rawfile/main/nested/data.json"] {
t.Fatalf("zip 里没有保留路径的条目(实际 %v)", keysOf(names))
}
// 内容也要对
for _, zf := range r.File {
if zf.Name == "rawfile/main/prompt.txt" {
rc, _ := zf.Open()
buf := make([]byte, 64)
n, _ := rc.Read(buf)
rc.Close()
if !strings.Contains(string(buf[:n]), "you are a dev") {
t.Errorf("内容不对: %q", string(buf[:n]))
}
}
}
}
func keysOf(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func zipNames(ents []rawEntry) []string {
out := make([]string, 0, len(ents))
for _, e := range ents {
out = append(out, e.zip)
}
return out
}
// ★★★ 判据:**绝对路径也必须挡在工程外**(实测发现的真 bug)。
//
// 初版只在相对路径分支里检查 `../`,于是 `{"from": "/etc/passwd"}`
// 被原样打进发布包 —— 那是把宿主机凭据/密钥带进发行的真实通道。
// `filepath.Join(root, "/etc/passwd")` 也不会逃出(Join 会把绝对路径
// 当相对处理),所以这条走的是"根 + 绝对路径"这条更隐蔽的入口。
func TestAbsolutePathOutsideProjectIsRejected(t *testing.T) {
// ★ 关键:必须**造出真实存在的逃逸目标**。
//
// 初版用 `/etc/passwd` 之类的固定路径 —— 那是错的:在没有该文件的
// 环境里(如临时目录的父级),实现会因"文件不存在"报错,
// 于是**判据通过但那不是因为拒绝了逃逸**。实测变异(去掉前缀检查)
// 没被咬住,正是这个原因:报错来自 Stat 失败,不是来自安全检查。
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("REDACTED"), 0o644); err != nil {
t.Fatal(err)
}
root := mkProject(t)
// 构造一个**从工程根出发、真实指向工程外**的相对路径
rel, err := filepath.Rel(root, filepath.Join(outside, "secret.txt"))
if err != nil {
t.Fatal(err)
}
for _, from := range []string{rel, "/etc/passwd", "a/../../../etc/passwd"} {
ents, cerr := collectRawFiles(root, []RawFileDecl{{From: from}})
if cerr == nil {
t.Errorf("★ 逃出工程的声明 %q 未被拒绝(收集到 %d 项)", from, len(ents))
continue
}
// 报错原因必须是**逃逸**,不能是"文件不存在"
if !strings.Contains(cerr.Error(), "逃出") && !strings.Contains(cerr.Error(), "绝对路径") {
t.Errorf("拒绝 %q 的理由不对(应为逃逸/绝对路径,实际: %v)", from, cerr)
}
}
// 相对路径(相对工程根)是唯一接受的形态
ents, err := collectRawFiles(root, []RawFileDecl{{From: "assets/theme.css"}})
if err != nil {
t.Errorf("工程内的相对路径应被接受: %v", err)
} else {
found := false
for _, e := range ents {
if e.zip == "rawfile/theme.css" {
found = true
}
}
if !found {
t.Errorf("声明的相对路径未落到 rawfile/ 下: %v", zipNames(ents))
}
}
}
// ★★ 判据:**外部声明的目录也要保留结构**(不只是默认目录)。
//
// 最初只对默认 rawfile/ 目录验了结构保留,于是"外部目录被拍平"
// 这个变异没有咬住 —— 判据必须覆盖**每一条**会产生 zip 路径的分支。
func TestDeclaredDirectoryKeepsStructure(t *testing.T) {
root := mkProject(t)
if err := os.MkdirAll(filepath.Join(root, "assets/sub/deep"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "assets/sub/deep/x.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
ents, err := collectRawFiles(root, []RawFileDecl{{From: "assets"}})
if err != nil {
t.Fatal(err)
}
var want string
for _, e := range ents {
if strings.HasSuffix(e.zip, "x.txt") {
want = e.zip
}
}
if want != "rawfile/assets/sub/deep/x.txt" {
t.Fatalf("★ 外部目录未保留结构(期望 rawfile/assets/sub/deep/x.txt,实际 %q)", want)
}
}
// ★★ 判据:脚手架文件(.gitkeep/.gitignore/.DS_Store)**不进包**。
//
// 实测发现:init 出的空工程第一次 build 就报"已打包 2 个 rawfile",
// 里面只有 .gitkeep 与 .gitignore。既是噪声(误导开发者以为有资源),
// 也是把工程内部约定发给了运行期。
func TestScaffoldFilesAreExcluded(t *testing.T) {
root := mkProject(t)
for _, f := range []string{".gitkeep", ".gitignore", ".DS_Store", "main/.gitkeep"} {
p := filepath.Join(root, "rawfile", f)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
ents, err := collectRawFiles(root, nil)
if err != nil {
t.Fatal(err)
}
for _, e := range ents {
if IsRawFileScaffold(e.zip) {
t.Errorf("★ 脚手架文件被打进包: %s", e.zip)
}
}
// 真实资源仍要在
found := false
for _, e := range ents {
if strings.HasSuffix(e.zip, "prompt.txt") {
found = true
}
}
if !found {
t.Error("真实资源被误排")
}
}
// ★ 判据:空工程(只有脚手架)→ 收集结果为空,不该报"打包了 N 个"。
func TestEmptyProjectCollectsNothing(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "rawfile"), 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(root, "rawfile/.gitkeep"), []byte(""), 0o644)
os.WriteFile(filepath.Join(root, "rawfile/.gitignore"), []byte("*\n"), 0o644)
ents, err := collectRawFiles(root, nil)
if err != nil {
t.Fatal(err)
}
if len(ents) != 0 {
t.Errorf("★ 空工程应收 0 项,实际 %d: %v", len(ents), zipNames(ents))
}
}

View File

@ -0,0 +1,18 @@
package main
import (
"errors"
"os"
)
// readEmbeddedLuaTemplate 读 Lua 脚手架模板(若存在)。
func readEmbeddedLuaTemplate() (string, error) {
// Lua 模板以 embed 或常量形式存在时取之;不存在则跳过。
if tmplMainLua != "" {
return tmplMainLua, nil
}
if b, err := os.ReadFile("templates/main.lua.tmpl"); err == nil {
return string(b), nil
}
return "", errors.New("未找到 Lua 脚手架模板")
}

View File

@ -0,0 +1,125 @@
package main
// 脚手架模板的**协议通道纪律**判据(2026-10-16)。
//
// ## 为什么必须有这条
//
// 子进程插件的 **stdout 是 JSON-RPC 协议通道**,**stderr 才是日志通道**
//(SDK 侧 `log.SetOutput(os.Stderr)`,见 proc_main.go.tmpl)。
//
// 而 `hmapdev init` 生成的 `plugin.go` 原先用 `fmt.Printf` 打日志
// ⇒ 写进 **stdout** ⇒ 内核 readLoop 收到非 JSON 文本:
//
// [proc] final_probe 收到非法 JSON 帧(21 字节): invalid character 'i' in literal false
//
// 两个后果叠加,都很难定位:
// ① 那句日志**丢了**(内核把它当坏帧丢弃);
// ② 多了一条看起来像**协议故障**的报错 —— 而真因是"插件自己 printf"。
//
// 实测发现路径:用新版 hmapdev 脚手架了一个插件装进真机内核,
// 启动日志里出现上面那行(其余 20 个插件都没有)。
//
// ## 判据为什么是"源码扫描"而不是"跑一次插件看日志"
//
// 该模板是**字符串常量**(`tmplPluginGo`),不是可直接执行的程序;
// 真正的行为验证在真机 e2e(已做:final_probe 启动日志干净)。
// 这里断言的是**生成物里不存在会写 stdout 的调用** —— 属于
// "某个模式是否出现"那一类,源码扫描在此是**恰当**的。
//(对比:断言"接线/调用顺序"时源码扫描会骗人,见
// docs/zh/kernel-modularization-design.md §8.6。)
import (
"strings"
"testing"
)
// ★★★ 判据:脚手架生成的 plugin.go **不得**往 stdout 打印。
//
// ★ 实现要点:**必须先剥掉注释行**。
// 模板里的注释**故意**提到 `fmt.Printf`(讲这个坑),若直接扫描字符串
// 就会**自己被自己写的注释判红**(实测第一版就是这样,假红)。
// 这就是"源码扫描判据"的固有脆弱性 —— 本仓的规范要求把坑写进注释,
// 而注释里必然出现那些符号名。
//
// ⇒ 剥注释后只扫**代码行**,语义才准确。
func TestScaffoldTemplateDoesNotWriteStdout(t *testing.T) {
code := stripTemplateComments(tmplPluginGo)
for _, pat := range []string{"fmt.Printf(", "fmt.Println(", "fmt.Print(", "os.Stdout"} {
if strings.Contains(code, pat) {
t.Errorf("★ 脚手架模板的**代码**含 `%s` —— 它写 **stdout**,"+
"而 stdout 是子进程插件的 **JSON-RPC 协议通道**"+
"(内核会报「收到非法 JSON 帧」并丢弃该行日志)。"+
"日志必须用 log.Printf(默认 stderr)。", pat)
}
}
}
// stripTemplateComments 剥掉 Go 源码里的 // 行注释(模板只有行注释)。
//
// 只处理 `//`:模板里没有块注释,也不需要处理字符串内的 `//`
// (模板代码里没有含 // 的字符串字面量;若有,这里会误剥 —— 但那会让
//
// 判据**更严**(漏扫),不会假绿,可接受)。
func stripTemplateComments(src string) string {
var out []string
for _, ln := range strings.Split(src, "\n") {
l := strings.TrimSpace(ln)
if strings.HasPrefix(l, "//") {
continue
}
// 行尾注释也剥掉(`x := 1 // fmt.Printf` 这种)
if i := strings.Index(ln, "//"); i >= 0 {
// 只在该 // 之前没有引号时剥(避免剥掉 URL 之类)
if !strings.Contains(ln[:i], `"`) {
ln = ln[:i]
}
}
out = append(out, ln)
}
return strings.Join(out, "\n")
}
// ★★★ 判据:模板必须**有**日志(否则上面的判据可以靠"删掉所有日志"通过)。
//
// ★ 这条是"反向钉住":光禁 stdout 不够 —— 把日志全删也能过。
//
// 模板的价值在于示范**正确的**日志方式,所以必须真的用 log.Printf。
func TestScaffoldTemplateUsesStderrLogging(t *testing.T) {
if !strings.Contains(tmplPluginGo, "log.Printf") {
t.Fatal("★ 脚手架模板没有 log.Printf —— 删光日志能通过上一条判据," +
"但那就失去了「示范正确做法」的意义")
}
if !strings.Contains(tmplPluginGo, `"log"`) {
t.Fatal("★ 用了 log.Printf 但没 import \"log\" —— 生成物编译不过")
}
// 关键位置都要走 log(启动/停止/停止钩子)
for _, want := range []string{
`log.Printf("[%s] started", p.name)`,
`log.Printf("[%s] stopped", p.name)`,
"stop handler running",
} {
if !strings.Contains(tmplPluginGo, want) {
t.Errorf("★ 模板缺少预期日志: %s", want)
}
}
}
// ★★★ 判据:**别的脚手架模板**(Lua)也不得写 stdout。
//
// Lua 插件同理:它走的是同一套子进程协议(main.lua 的 print 会污染帧)。
func TestScaffoldLuaTemplateDoesNotWriteStdout(t *testing.T) {
src, err := readEmbeddedLuaTemplate()
if err != nil {
t.Skipf("读不到 Lua 模板(可能已改名): %v", err)
}
// 正常路径:print(...) 在 Lua 里就是 stdout
for _, ln := range strings.Split(src, "\n") {
l := strings.TrimSpace(ln)
if strings.HasPrefix(l, "--") {
continue // 注释
}
if strings.Contains(l, "print(") && !strings.Contains(l, "io.stderr") {
t.Errorf("★ Lua 模板含 `print(`(→ stdout,会污染协议帧): %s", l)
}
}
}

129
tools/hmapdev/skills.go Normal file
View File

@ -0,0 +1,129 @@
package main
// 随包 skill 的打包(`skill/` 目录)。
//
// ## 为什么与 `rawfile/` 分开,而不是放进 rawfile/
//
// 两者**意图不同**:
//
// rawfile/ 任意随包资源(node 二进制、pi 依赖、配置模板…)——内核只是解开
// skill/ 要**登记给 skillmgr** 的纯 SKILL 条目 —— 内核安装后会移交注册
//
// 若把 skill 放在 rawfile/ 下(如 `rawfile/skills/x/SKILL.md`),内核就必须
// 靠**子路径名**去猜哪些是要注册的 —— 那是隐式约定,正是本仓反复吃过亏的
// 模式(例:config.db 因"默认 0644"这个隐式假设而长期明文)。
// 显式目录让意图在**结构**里可见。
//
// ## 解包形态
//
// 与 rawfile 一样**保留路径**(不拍平):
//
// <插件目录>/skill/<name>/SKILL.md
//
// 内核侧(pluginmgr)扫 `<插件目录>/skill/*` 并把每个目录发
// skill_detected 事件,由 skillmgr 接管注册(见
// internal/plugins/pluginmgr/skill_handout.go)。
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// SkillDir 是工程根下存放随包 skill 的目录(与 rawfile/ 并列)。
const SkillDir = "skill"
// SkillZipPrefix 是解包后这些条目的统一前缀。
//
// 与 SkillDir 同值 —— 保留这个独立常量是为了让"打包目录名"与
// "包内前缀"可以分别演进(rawfile 那边是两个同名常量,同样的理由)。
const SkillZipPrefix = "skill"
// collectSkillFiles 收集 `skill/` 目录下的条目。
//
// 与 collectRawFiles 的差别:
// - **只收一层**:skill 是「每个子目录一个 skill」,嵌套不再是 skill。
// 递归收会让 `skill/a/.git/SKILL.md` 这种被当成 skill 发出去。
// - **只收目录**:单文件 skill 不在本约定内(skillmgr 支持,
// 但随包分发统一用目录形态,避免两种形态都要判)。
// - 仍然排除脚手架(.gitkeep/.gitignore)。
func collectSkillFiles(root string) ([]rawEntry, error) {
dir := filepath.Join(root, SkillDir)
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
// 没有 skill/ 是**常态**(绝大多数插件不带),不是错误
return nil, nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("读取 %s/ 失败: %w", SkillDir, err)
}
var out []rawEntry
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue // 脚手架/隐藏项
}
if !e.IsDir() {
// 明确拒绝而不是静默跳过:放在 skill/ 下的文件几乎肯定是放错了
//(要么该是 skill/<name>/SKILL.md,要么该放 rawfile/)。
return nil, fmt.Errorf(
"%s/%s 是文件;%s/ 下只接受**目录**(每个目录一个 skill,形如 %s/<name>/SKILL.md)"+
";普通资源请放 rawfile/",
SkillDir, name, SkillDir, SkillDir)
}
sub := filepath.Join(dir, name)
// 每个 skill 目录下的文件**递归保留结构**
// (有的 skill 带 references/、assets/ 等子目录)。
err := filepath.Walk(sub, func(p string, fi os.FileInfo, werr error) error {
if werr != nil {
return werr
}
if fi.IsDir() {
return nil
}
rel, err := filepath.Rel(dir, p)
if err != nil {
return err
}
if IsRawFileScaffold(rel) {
return nil
}
out = append(out, rawEntry{
src: p,
zip: filepath.ToSlash(filepath.Join(SkillZipPrefix, rel)),
})
return nil
})
if err != nil {
return nil, fmt.Errorf("展开 %s/%s 失败: %w", SkillDir, name, err)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].zip < out[j].zip })
return out, nil
}
// skillFilesSummary 给构建输出一行摘要(0 个时不打印)。
func skillFilesSummary(ents []rawEntry) string {
if len(ents) == 0 {
return ""
}
// 数 skill 个数(一层目录名去重)而不只是文件数 —— 后者对用户无意义
names := map[string]bool{}
for _, e := range ents {
rest := strings.TrimPrefix(e.zip, SkillZipPrefix+"/")
if i := strings.Index(rest, "/"); i > 0 {
names[rest[:i]] = true
}
}
var list []string
for n := range names {
list = append(list, n)
}
sort.Strings(list)
return fmt.Sprintf("%d 个 skill(%s,共 %d 个文件)",
len(list), strings.Join(list, ", "), len(ents))
}

View File

@ -0,0 +1,176 @@
package main
// 随包 skill 打包的判据。
//
// 约定(与 rawfile/ 平行但**意图不同**):
//
// skill/<name>/SKILL.md 每个子目录一个 skill,登记给 skillmgr
// skill/<name>/skill.json
//
// 为什么与 rawfile 分开:内核需要知道**哪些是要注册的**。
// 靠子路径名猜(rawfile/skills/)是隐式约定,本仓为此吃过亏。
import (
"archive/zip"
"os"
"path/filepath"
"strings"
"testing"
)
// mkSkillProject 造一个带 skill/ 的工程。
func mkSkillProject(t *testing.T, files map[string]string) string {
t.Helper()
root := t.TempDir()
for rel, content := range files {
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
// ★★ 判据:skill/ 下的每个目录被打包,**保留路径**。
func TestCollectSkillFiles(t *testing.T) {
root := mkSkillProject(t, map[string]string{
"skill/alpha/SKILL.md": "# alpha",
"skill/alpha/skill.json": `{"name":"alpha"}`,
"skill/beta/SKILL.md": "# beta",
"skill/beta/references/note.md": "# ref", // 子目录也要保留
"rawfile/some/data.bin": "x", // 不该被 skill 收集器收
})
ents, err := collectSkillFiles(root)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, e := range ents {
got[e.zip] = true
}
for _, want := range []string{
"skill/alpha/SKILL.md",
"skill/alpha/skill.json",
"skill/beta/SKILL.md",
"skill/beta/references/note.md",
} {
if !got[want] {
t.Errorf("缺条目 %s(实际 %v)", want, keys(got))
}
}
// 不得收 rawfile 下的东西
for z := range got {
if !strings.HasPrefix(z, "skill/") {
t.Errorf("★ 收到了 skill/ 之外的条目: %s", z)
}
}
}
// ★ 判据:**没有 skill/ 目录是常态**,不报错、返回空。
func TestNoSkillDirIsFine(t *testing.T) {
root := mkSkillProject(t, map[string]string{"rawfile/x.txt": "x"})
ents, err := collectSkillFiles(root)
if err != nil {
t.Fatalf("无 skill/ 不应报错: %v", err)
}
if len(ents) != 0 {
t.Errorf("应返回空,实际 %d", len(ents))
}
}
// ★★ 判据:skill/ 下放**文件**要报错(几乎肯定是放错了)。
//
// 静默跳过会让"我加的 skill 没生效"变成无提示的谜题。
func TestFileInSkillDirIsRejected(t *testing.T) {
root := mkSkillProject(t, map[string]string{
"skill/stray.md": "# 我放错了",
"skill/ok/SKILL.md": "# ok",
})
_, err := collectSkillFiles(root)
if err == nil {
t.Fatal("★ skill/ 下的文件应报错(不静默跳过)")
}
if !strings.Contains(err.Error(), "rawfile/") {
t.Errorf("错误应指出正确去处(rawfile/): %v", err)
}
}
// ★ 判据:脚手架不被打包。
func TestSkillScaffoldExcluded(t *testing.T) {
root := mkSkillProject(t, map[string]string{
"skill/.gitignore": "x",
"skill/alpha/.gitkeep": "",
"skill/alpha/SKILL.md": "# a",
})
ents, err := collectSkillFiles(root)
if err != nil {
t.Fatal(err)
}
for _, e := range ents {
if IsRawFileScaffold(e.zip) {
t.Errorf("★ 脚手架被打包: %s", e.zip)
}
}
}
// ★★ 判据:真正的 zip 里条目名保留完整路径。
func TestSkillEntriesInZip(t *testing.T) {
root := mkSkillProject(t, map[string]string{
"skill/commondev-discipline/SKILL.md": "# 纪律",
})
ents, err := collectSkillFiles(root)
if err != nil {
t.Fatal(err)
}
out := filepath.Join(t.TempDir(), "t.hmap")
f, _ := os.Create(out)
w := zip.NewWriter(f)
if err := writeRawEntries(w, ents); err != nil {
t.Fatal(err)
}
w.Close()
f.Close()
zr, err := zip.OpenReader(out)
if err != nil {
t.Fatal(err)
}
defer zr.Close()
found := false
for _, zf := range zr.File {
if zf.Name == "skill/commondev-discipline/SKILL.md" {
found = true
}
}
if !found {
t.Error("★ zip 里未保留 skill 的目录结构")
}
}
// ★ 判据:摘要报的是 **skill 个数**(不是文件数)。
func TestSkillSummaryCountsSkillsNotFiles(t *testing.T) {
root := mkSkillProject(t, map[string]string{
"skill/a/SKILL.md": "x",
"skill/a/references/r.md": "x",
"skill/b/SKILL.md": "x",
})
ents, _ := collectSkillFiles(root)
sum := skillFilesSummary(ents)
if !strings.Contains(sum, "2 个 skill") {
t.Errorf("摘要应按 skill 数计(2 个),实际: %s", sum)
}
if !strings.Contains(sum, "3 个文件") {
t.Errorf("摘要应同时给出文件数: %s", sum)
}
}
func keys(m map[string]bool) []string {
var o []string
for k := range m {
o = append(o, k)
}
return o
}

View File

@ -13,7 +13,8 @@ const tmplPlgJSON = `{
"entry": "{{.Plg.Entry}}",
"sdk": "{{.Plg.SDK}}",
"tags": [{{range $i, $t := .Plg.Tags}}{{if $i}}, {{end}}"{{$t}}"{{end}}],
"targets": "{{.Plg.Targets}}"
"targets": "{{.Plg.Targets}}",
"rawfiles": []
}
`
@ -38,10 +39,22 @@ replace {{.SDKModule}} => {{.SDKLocalPath}}
const tmplPluginGo = `package main
import (
"fmt"
"log"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// ★★ 插件里**绝不能往 stdout 打印任何东西**(本注释是踩坑记录,2026-10-16)。
//
// 子进程插件的 **stdout 是 JSON-RPC 协议通道**(与内核的帧都走它),
// stderr 才是日志通道(SDK 侧 log.SetOutput(os.Stderr))。
//
// 用 fmt.Printf 打一行日志 ⇒ 内核 readLoop 收到一段非 JSON 文本,
// 报「收到非法 JSON 帧(N 字节)」并**丢弃**。日志本身丢了,还刷了一条
// 看起来像协议故障的报错(极难定位到「是我自己 printf 的」)。
//
// ⇒ 一律用 log.Printf(默认走 stderr)。
type Plugin struct {
name string
sdk *sdk.PluginSDK
@ -51,7 +64,7 @@ func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.RegisterStopHandler(func() { fmt.Printf("[%s] stop handler running\n", p.name) })
s.RegisterStopHandler(func() { log.Printf("[%s] stop handler running", p.name) })
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin.{{.Plg.Name}}.example", Default: "hello", Type: "string",
DisplayName: "示例配置", Description: "An example configuration key",
@ -84,11 +97,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
// return output
// },
}, p.handleHello)
fmt.Printf("[%s] started\n", p.name)
log.Printf("[%s] started", p.name)
return nil
}
func (p *Plugin) Stop() error { fmt.Printf("[%s] stopped\n", p.name); return nil }
func (p *Plugin) Stop() error { log.Printf("[%s] stopped", p.name); return nil }
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
return map[string]interface{}{"content": "Hello from {{.Plg.Name}} plugin!"}, nil

View File

@ -1,11 +0,0 @@
{
"name": "{{.Plg.Name}}",
"name_zh": "{{.Plg.NameZh}}",
"name_en": "{{.Plg.NameEn}}",
"version": "{{.Plg.Version}}",
"description": "{{.Plg.Description}}",
"author": "{{.Plg.Author}}",
"entry": "{{.Plg.Entry}}",
"tags": {{.Plg.Tags}},
"targets": "{{.Plg.Targets}}"
}

View File

@ -13,11 +13,14 @@ package main
import (
"bufio"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
@ -740,6 +743,8 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
base.SetKnowledgeAPI(procKnowledge{})
base.SetLLMAPI(procLLM{})
base.SetSocialAPI(procSocial{})
base.SetSecretsAPI(procSecrets{})
base.SetRawFilesAPI(procRawFiles{})
base.SetTextMemoryAPI(procTextMemory{})
base.SetPluginMgrAPI(procPluginMgr{})
base.SetInputChannelRegistrar(func(chName string, def sdk.ChannelDef) error {
@ -1160,6 +1165,234 @@ func (procPluginMgr) IsPluginDisabled(name string) bool {
return r.Disabled
}
// ---- 运行态启停(设计 docs/zh/kernel-modularization-design.md §2.6.7)----
//
// ★ 注意 method 名是 `plugin.mgrStop/mgrStart/mgrIsRuntimeStopped`,
// **不是** `plugin.stop/plugin.start` —— 后两个是内核→插件的生命周期消息
// (MethodPluginStart/Stop,见 protocol.go),语义完全不同。用错名会撞车。
func (procPluginMgr) StopPlugin(name string) error {
return callCoreVoid("plugin.mgrStop", map[string]string{"name": name})
}
func (procPluginMgr) StartPlugin(name string) error {
return callCoreVoid("plugin.mgrStart", map[string]string{"name": name})
}
func (procPluginMgr) IsPluginRuntimeStopped(name string) bool {
raw, err := callCore("plugin.mgrIsRuntimeStopped", map[string]string{"name": name})
if err != nil {
return false
}
var r struct {
RuntimeStopped bool `json:"runtime_stopped"`
}
json.Unmarshal(raw, &r)
return r.RuntimeStopped
}
// procRawFiles 是 rawfile 资源的插件侧实现。
//
// ★ 它**不走 RPC**:资源就在插件进程自己的目录下(`<插件目录>/rawfile/`),
// 直接读本地文件即可。走 RPC 是错的 —— 随包资源可能有几十上百兆
// (如内置的 node 二进制),把它们搬过进程边界毫无意义。
//
// 路径来源:内核在 spawn 时注入 HA_PLUGIN_RAWFILE_DIR / HA_PLUGIN_WORK_DIR。
// 不用 cwd 推断(插件可能自己 chdir),也不读内核的 PluginDir()(那个不给插件)。
type procRawFiles struct{}
func (procRawFiles) root() string {
if v := os.Getenv("HA_PLUGIN_RAWFILE_DIR"); v != "" {
return v
}
// 回退:cwd 就是插件目录(内核 spawn 时设的 Dir)
wd, _ := os.Getwd()
return filepath.Join(wd, "rawfile")
}
func (r procRawFiles) workRoot() string {
if v := os.Getenv("HA_PLUGIN_WORK_DIR"); v != "" {
return v
}
wd, _ := os.Getwd()
return filepath.Join(wd, "work")
}
// resolve 解析相对路径并拒绝穿越(与内核侧 rawFilesImpl 同一套规则)。
func (r procRawFiles) resolve(rel string) (string, error) {
rel = strings.TrimSpace(rel)
if rel == "" {
return "", fmt.Errorf("rawfile 路径不能为空")
}
if filepath.IsAbs(rel) {
return "", fmt.Errorf("rawfile 路径必须是相对路径: %q", rel)
}
root := r.root()
clean := filepath.Clean(filepath.Join(root, rel))
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
return "", fmt.Errorf("rawfile 路径逃出资源目录: %q", rel)
}
return clean, nil
}
func (r procRawFiles) Read(rel string) ([]byte, error) {
p, err := r.resolve(rel)
if err != nil {
return nil, err
}
b, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("rawfile 不存在: %s(打包时是否漏了 rawfile/ 或 plg.json 的 rawfiles?)", rel)
}
return nil, err
}
return b, nil
}
func (r procRawFiles) List(rel string) ([]string, error) {
p, err := r.resolve(rel)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(p)
if err != nil {
return nil, err
}
out := make([]string, 0, len(entries))
for _, e := range entries {
name := e.Name()
if e.IsDir() {
name += "/"
}
out = append(out, name)
}
sort.Strings(out)
return out, nil
}
func (r procRawFiles) Exists(rel string) bool {
p, err := r.resolve(rel)
if err != nil {
return false
}
st, err := os.Stat(p)
return err == nil && !st.IsDir()
}
func (r procRawFiles) Dir() string { return r.root() }
func (r procRawFiles) WorkDir() string {
d := r.workRoot()
_ = os.MkdirAll(d, 0o755)
return d
}
var _ sdk.RawFilesAPI = procRawFiles{}
// procSecrets 是变量库 API 的插件侧一半:全部经 RPC 打回内核。
//
// ★ 它**不是**插件自己的存储 —— 内核的变量库是进程级单例,
// 主/子/驻留子 agent 与全部插件共用同一套别名。所以这里只做转发,
// 不在插件侧留任何副本(留了就会出现"两库互不可见"的经典问题:
// 内核拦截登记的变量插件看不到)。
type procSecrets struct{}
// Available 用**专门的**可用性查询,不靠 List 推断。
//
// ★ 实测教训:初版用 `secrets.list` 成功与否判断 —— 而未启用时
// List 返回空列表(不报错),于是 Available() 误判为 true,
// 接着 Store 才失败。错误信息于是变成"存储失败"而非"能力不可用",
// 排查方向完全错。
func (procSecrets) Available() bool {
raw, err := callCore("secrets.available", nil)
if err != nil {
return false
}
var r struct {
Available bool `json:"available"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return false
}
return r.Available
}
func (procSecrets) Store(value, note string) (string, error) {
raw, err := callCore("secrets.store", map[string]interface{}{"value": value, "note": note})
if err != nil {
return "", err
}
var r struct {
Alias string `json:"alias"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return "", err
}
return r.Alias, nil
}
func (procSecrets) List() []sdk.SecretInfo {
raw, err := callCore("secrets.list", nil)
if err != nil {
return nil
}
var r struct {
Infos []sdk.SecretInfo `json:"infos"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return nil
}
return r.Infos
}
func (procSecrets) Forget(alias string) error {
return callCoreVoid("secrets.forget", map[string]string{"alias": alias})
}
func (procSecrets) Fill(text string) (string, int, []string) {
raw, err := callCore("secrets.fill", map[string]string{"text": text})
if err != nil {
return text, 0, nil
}
var r struct {
Text string `json:"text"`
Replaced int `json:"replaced"`
Aliases []string `json:"aliases"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return text, 0, nil
}
return r.Text, r.Replaced, r.Aliases
}
func (procSecrets) Encrypt(plain []byte) (string, error) {
raw, err := callCore("secrets.encrypt", map[string]string{
"plain_b64": base64.StdEncoding.EncodeToString(plain),
})
if err != nil {
return "", err
}
var r struct {
Encrypted string `json:"encrypted"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return "", err
}
return r.Encrypted, nil
}
func (procSecrets) Decrypt(stored string) ([]byte, error) {
raw, err := callCore("secrets.decrypt", map[string]string{"stored": stored})
if err != nil {
return nil, err
}
var r struct {
PlainB64 string `json:"plain_b64"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return nil, err
}
return base64.StdEncoding.DecodeString(r.PlainB64)
}
type procSettings struct{}
func (procSettings) Get(key string) (interface{}, error) {
@ -1168,6 +1401,26 @@ func (procSettings) Get(key string) (interface{}, error) {
func (procSettings) Set(key string, v interface{}) error {
return callCoreVoid("settings.set", map[string]interface{}{"key": key, "value": v})
}
// SetSecret 强制加密落盘(不看键名)。内核侧拒绝在无密钥时写明文。
func (procSettings) SetSecret(key string, v interface{}) error {
return callCoreVoid("settings.setSecret", map[string]interface{}{"key": key, "value": v})
}
// GetSecret 读取 SetSecret 写入的值。
func (procSettings) GetSecret(key string) (string, error) {
raw, err := callCore("settings.getSecret", map[string]string{"key": key})
if err != nil {
return "", err
}
var r struct {
Value string `json:"value"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return "", err
}
return r.Value, nil
}
func (procSettings) List(prefix string) ([]string, error) {
return settingsKeys("settings.list", map[string]string{"prefix": prefix})
}

View File

@ -225,6 +225,14 @@ func (s *mockSettings) Get(key string) (interface{}, error) {
return v, nil
}
func (s *mockSettings) Set(key string, value interface{}) error { s.data[key] = value; return nil }
func (s *mockSettings) SetSecret(key string, value interface{}) error {
// mock 无加密能力:与真实实现一致地**拒绝**明文落盘。
if str, ok := value.(string); ok && str != "" {
return fmt.Errorf("mock: 密钥未装配,拒绝以明文存储敏感项 %q", key)
}
return nil
}
func (s *mockSettings) GetSecret(key string) (string, error) { return "", nil }
func (s *mockSettings) List(prefix string) ([]string, error) {
var ks []string
for k := range s.data {