mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
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:
4
.gitignore
vendored
4
.gitignore
vendored
@ -45,3 +45,7 @@ site_build/
|
|||||||
# 每次改任何一页都会整份重写(115KB),进版本库只产生噪声。它由构建产出,
|
# 每次改任何一页都会整份重写(115KB),进版本库只产生噪声。它由构建产出,
|
||||||
# llms.txt(索引,小且稳定)仍提交。
|
# llms.txt(索引,小且稳定)仍提交。
|
||||||
docs/llms-full.txt
|
docs/llms-full.txt
|
||||||
|
|
||||||
|
# ★ rawfile/ 的内容是随包资源(可能内嵌 node/pi 等上百兆二进制),
|
||||||
|
# 由构建方自行准备,不进版本库。忽略规则放在 rawfile/.gitignore 内
|
||||||
|
# (自包含,避免依赖上层规则的作用域与否定式语义)。
|
||||||
|
|||||||
192
docs/versions.md
192
docs/versions.md
@ -31,6 +31,198 @@
|
|||||||
零值 `InjectOptions` 与旧的三参数方法完全等价,因此存量插件**不需要改、也不需要重编**;
|
零值 `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 "什么时候必须重编"
|
!!! tip "什么时候必须重编"
|
||||||
只有一种情况:**内核跨了主版本**(如 1.x → 2.x)。
|
只有一种情况:**内核跨了主版本**(如 1.x → 2.x)。
|
||||||
主版本跃迁是破坏性变更的定义,此后的帧格式与共享段布局都不兼容,
|
主版本跃迁是破坏性变更的定义,此后的帧格式与共享段布局都不兼容,
|
||||||
|
|||||||
@ -17,15 +17,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
name string
|
name string
|
||||||
sdk *sdk.PluginSDK
|
sdk *sdk.PluginSDK
|
||||||
client *http.Client
|
client *http.Client
|
||||||
apiKey string
|
apiKey string
|
||||||
provider string
|
provider string
|
||||||
model string
|
model string
|
||||||
size string
|
size string
|
||||||
baseURL string
|
baseURL string
|
||||||
dataDir string // <data>/ai_images:生成本地图片存放目录
|
dataDir string // <data>/ai_images:生成本地图片存放目录
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
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{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "base_url", Default: "", Type: "string",
|
Key: "base_url", Default: "", Type: "string",
|
||||||
DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081);为空走官方 https://api.openai.com",
|
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{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "provider", Default: "openai", Type: "string",
|
Key: "provider", Default: "openai", Type: "string",
|
||||||
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
|
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
|
||||||
Category: "ai_image",
|
Category: "ai_image",
|
||||||
})
|
})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "model", Default: "dall-e-3", Type: "string",
|
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.size = getSetting(s.Settings(), "size", "1024x1024")
|
||||||
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
|
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
|
||||||
|
|
||||||
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
|
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
|
||||||
if p.sdk != nil {
|
if p.sdk != nil {
|
||||||
if dd := s.Settings().DataDir(); dd != "" {
|
if dd := s.Settings().DataDir(); dd != "" {
|
||||||
p.dataDir = dd
|
p.dataDir = dd
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if p.dataDir == "" {
|
||||||
if p.dataDir == "" {
|
// 旧版内核无 DataDir API 时退到 /tmp
|
||||||
// 旧版内核无 DataDir API 时退到 /tmp
|
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
|
||||||
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
|
}
|
||||||
}
|
os.MkdirAll(p.dataDir, 0755)
|
||||||
os.MkdirAll(p.dataDir, 0755)
|
|
||||||
|
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
s.RegisterTool(tp+"generate", sdk.ToolDef{
|
s.RegisterTool(tp+"generate", sdk.ToolDef{
|
||||||
@ -170,12 +170,12 @@ os.MkdirAll(p.dataDir, 0755)
|
|||||||
},
|
},
|
||||||
}, p.handleGenerate)
|
}, 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Stop() error {
|
func (p *Plugin) Stop() error {
|
||||||
fmt.Printf("[%s] stopped\n", p.name)
|
log.Printf("[%s] stopped", p.name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@ -285,7 +286,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
}
|
}
|
||||||
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar")
|
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar")
|
||||||
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
|
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()
|
p.loadEvents()
|
||||||
|
|
||||||
@ -401,7 +402,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.wg.Add(1)
|
p.wg.Add(1)
|
||||||
go p.remindLoop()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -409,7 +410,7 @@ func (p *Plugin) Stop() error {
|
|||||||
p.remindTicker.Stop()
|
p.remindTicker.Stop()
|
||||||
close(p.stopCh)
|
close(p.stopCh)
|
||||||
p.wg.Wait()
|
p.wg.Wait()
|
||||||
fmt.Printf("[%s] stopped\n", p.name)
|
log.Printf("[%s] stopped", p.name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -611,9 +612,9 @@ func (p *Plugin) cleanupData() {
|
|||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) {
|
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 {
|
} else {
|
||||||
fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile())
|
log.Printf("[calendar] onRemove removed %s", p.eventsFile())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(全能办公)。较 v1.0.0 新增 `read_document`、`create_document`、
|
||||||
> 线上部署的 v2.0.0(全能办公版,支持新建/读取/转换 docx·xlsx·pptx·md·csv·txt)
|
> `create_from_template`、`convert_document`、`office_check` 五个工具;
|
||||||
> **源码尚未公开**,本文档不描述那些能力。参见 `plugin.json` 的 `version`。
|
> `edit_document` 的名字与全部 v1 参数**保持不变**,老调用不会破。
|
||||||
|
|
||||||
## 工具
|
## 工具
|
||||||
|
|
||||||
| 工具 | 说明 |
|
| 工具 | 作用 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `edit_document` | 编辑文档内容,**编辑后原文件被覆盖** |
|
| `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` | 自检:依赖库可用性与版本、外部工具、格式×能力矩阵 |
|
||||||
| `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` 用) |
|
|
||||||
|
|
||||||
编辑前建议先读一遍内容确认目标文本 —— 查找替换是**全文件覆盖写**,没有撤销。
|
|
||||||
|
|
||||||
## 配置项
|
## 配置项
|
||||||
|
|
||||||
| 键 | 默认 | 说明 |
|
| 键 | 默认 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `script_path` | 空 | `edit_doc.py` 的绝对路径。留空则用插件可执行文件同目录下的 `edit_doc.py` |
|
| `script_path` | 空 | `edit_doc.py` 的绝对路径。**留空即推荐值** —— 用内嵌脚本;仅当该文件含 v2 协议标记时才被采用,否则自动忽略并回退内嵌 |
|
||||||
| `venv_python` | 空 | 执行 `edit_doc.py` 的 Python 解释器(建议用 venv 里的)。**必须配置,留空会报错** |
|
| `venv_python` | 空 | 执行脚本的 Python 解释器(需装 python-docx / openpyxl / python-pptx)。**必须配置** |
|
||||||
|
| `timeout_sec` | 60 | 单次调用超时;大文件或格式转换(soffice 首次启动慢)建议调到 180 |
|
||||||
|
|
||||||
## 工作原理
|
## 工作原理
|
||||||
|
|
||||||
本插件是 Go 写的薄壳:把参数序列化成 JSON,交给 Python 脚本 `edit_doc.py` 执行实际文档操作。
|
Go 侧只做参数编排与结构化返回,全部业务逻辑在 Python 脚本 `edit_doc.py` 里。
|
||||||
文档解析依赖 Python 侧的库(python-docx / openpyxl / python-pptx 之类),所以:
|
|
||||||
|
|
||||||
- **需要自备 `edit_doc.py`**:它不在本目录里。
|
**脚本是内嵌的**(`//go:embed embed/edit_doc.py`),原因:`hmapdev` 打包器只把
|
||||||
- 用 `venv_python` 指向装了这些库的解释器,避免污染系统 Python。
|
`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
|
```bash
|
||||||
hmapdev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|||||||
1390
example/editdoc/embed/edit_doc.py
Normal file
1390
example/editdoc/embed/edit_doc.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,18 @@ module editdoc
|
|||||||
|
|
||||||
go 1.25.0
|
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
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
//go:build !windows || !cgo
|
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -1,15 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "editdoc",
|
"name": "editdoc",
|
||||||
"name_zh": "文档编辑",
|
"name_zh": "全能办公",
|
||||||
"name_en": "Document Editor",
|
"name_en": "Document Editor",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"description": "办公文档编辑工具(.docx),基于 Python python-docx 库实现。支持文本替换、表格操作、内容插入等。",
|
"description": "全能办公插件:新建/读取/编辑/转换 docx·xlsx·pptx·md·csv·txt(基于 python-docx / openpyxl / python-pptx / soffice / pandoc)",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.bin",
|
||||||
"tags": ["editdoc", "office", "document"],
|
"tags": [
|
||||||
|
"editdoc",
|
||||||
|
"office",
|
||||||
|
"document",
|
||||||
|
"excel",
|
||||||
|
"word",
|
||||||
|
"powerpoint"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": [],
|
||||||
}
|
"sdk": "1.4.0"
|
||||||
|
}
|
||||||
@ -2,21 +2,60 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/JianFeeeee/homeagentsdk/sdk"
|
"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 {
|
type Plugin struct {
|
||||||
name string
|
name string
|
||||||
sdk *sdk.PluginSDK
|
sdk *sdk.PluginSDK
|
||||||
scriptPath string
|
scriptPath string
|
||||||
venvPython string
|
venvPython string
|
||||||
|
timeoutSec int
|
||||||
|
|
||||||
|
// ignoredScriptPath 记录「配置里的 script_path 因不是 v2 脚本而被忽略」的路径。
|
||||||
|
// 非空时 runScript 会在返回体里带上 script_path_ignored,让上层看得见这次降级。
|
||||||
|
ignoredScriptPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Name() string { return p.name }
|
func (p *Plugin) Name() string { return p.name }
|
||||||
@ -25,138 +64,469 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
|
|
||||||
|
// ---- 配置项(v1 的 script_path / venv_python 原样保留,新增 timeout_sec)----
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "script_path", Default: "", Type: "string",
|
Key: "script_path", Default: "", Type: "string",
|
||||||
DisplayName: "编辑脚本路径",
|
DisplayName: "办公脚本路径",
|
||||||
Description: "edit_doc.py 的绝对路径;留空时使用插件可执行文件同目录下的 edit_doc.py",
|
Description: "edit_doc.py 的绝对路径;留空(推荐)时使用 DataDir()/runtime/edit_doc.py(升级时按指纹自动更新)。" +
|
||||||
Category: p.name,
|
"仅当该文件含 v2 协议标记时才会被采用,否则自动忽略并回退内嵌脚本",
|
||||||
|
Category: p.name,
|
||||||
})
|
})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "venv_python", Default: "", Type: "string",
|
Key: "venv_python", Default: "", Type: "string",
|
||||||
DisplayName: "venv Python 解释器",
|
DisplayName: "venv Python 解释器",
|
||||||
Description: "执行 edit_doc.py 使用的 Python 解释器(建议用 venv 内的 python);必须配置,留空将报错",
|
Description: "执行 edit_doc.py 使用的 Python 解释器(需装有 python-docx/openpyxl/python-pptx);必须配置",
|
||||||
Category: p.name,
|
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 {
|
p.reloadSettings()
|
||||||
if str, ok := v.(string); ok {
|
|
||||||
p.scriptPath = str
|
// ---- 工具注册 ----
|
||||||
}
|
// 工具名不带插件名前缀:v1 的 edit_document 就是无前缀的,保持一致性。
|
||||||
}
|
|
||||||
if v, err := s.Settings().Get("venv_python"); err == nil {
|
s.RegisterTool("read_document", sdk.ToolDef{
|
||||||
if str, ok := v.(string); ok {
|
Name: "read_document",
|
||||||
p.venvPython = str
|
Description: "读取办公文档内容并返回结构化数据。支持 .docx(段落+表格+页眉页脚)、" +
|
||||||
}
|
".xlsx/.xlsm(逐表逐格,公式与计算值都给)、.pptx(逐页文本框与表格)、.xls、.csv/.md/.txt。" +
|
||||||
}
|
"大文件用 offset/limit 分段读。改文件前先用它看内容。",
|
||||||
s.RegisterTool("edit_document", sdk.ToolDef{
|
NoMemory: true,
|
||||||
Name: "edit_document",
|
|
||||||
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
|
|
||||||
NoMemory: true,
|
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"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": "文档文件路径"},
|
"file": map[string]interface{}{"type": "string", "description": "文档文件路径"},
|
||||||
"operation": map[string]interface{}{"type": "string", "description": "操作: replace_text(查找替换), set_cell(设置单元格), insert_row(插入行)"},
|
"operation": map[string]interface{}{"type": "string", "description": "操作(见工具描述)"},
|
||||||
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text)"},
|
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text);set_style 时为空表示全文"},
|
||||||
"replacement": map[string]interface{}{"type": "string", "description": "替换为的文本(replace_text)"},
|
"replacement": map[string]interface{}{"type": "string", "description": "替换为的文本(replace_text)"},
|
||||||
"sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx,可选)"},
|
"sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx)"},
|
||||||
"row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row)"},
|
"row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row/delete_row)"},
|
||||||
"col": map[string]interface{}{"type": "integer", "description": "列号(set_cell)"},
|
"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"},
|
"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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Stop() error { return nil }
|
func (p *Plugin) Stop() error { return nil }
|
||||||
|
|
||||||
func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) reloadSettings() {
|
||||||
file, _ := args["file"].(string)
|
s := p.sdk.Settings()
|
||||||
if file == "" {
|
if v, err := s.Get("script_path"); err == nil {
|
||||||
return nil, fmt.Errorf("file is required")
|
if str, ok := v.(string); ok {
|
||||||
|
p.scriptPath = strings.TrimSpace(str)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
operation, _ := args["operation"].(string)
|
if v, err := s.Get("venv_python"); err == nil {
|
||||||
if operation == "" {
|
if str, ok := v.(string); ok {
|
||||||
return nil, fmt.Errorf("operation is required")
|
p.venvPython = strings.TrimSpace(str)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(file); os.IsNotExist(err) {
|
p.timeoutSec = 60
|
||||||
return map[string]interface{}{
|
if v, err := s.Get("timeout_sec"); err == nil {
|
||||||
"content": fmt.Sprintf("文件不存在: %s", file),
|
switch n := v.(type) {
|
||||||
}, nil
|
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) {
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
return &Plugin{name: name}, nil
|
return &Plugin{name: name}, nil
|
||||||
|
|||||||
178
example/editdoc/plugin_test.go
Normal file
178
example/editdoc/plugin_test.go
Normal 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
|
||||||
|
}
|
||||||
@ -159,19 +159,19 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
name string
|
name string
|
||||||
sdk *sdk.PluginSDK
|
sdk *sdk.PluginSDK
|
||||||
listenAddr string
|
listenAddr string
|
||||||
napcatURL string
|
napcatURL string
|
||||||
remoteDir string
|
remoteDir string
|
||||||
filesDir string
|
filesDir string
|
||||||
webhookToken string
|
webhookToken string
|
||||||
ownerIDs []int64
|
ownerIDs []int64
|
||||||
botID int64
|
botID int64
|
||||||
botNickname string
|
botNickname string
|
||||||
dmPolicy string
|
dmPolicy string
|
||||||
groupPolicy string
|
groupPolicy string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
// sendClient 专用于**发送类**端点(send_private_msg / send_group_msg)。
|
// sendClient 专用于**发送类**端点(send_private_msg / send_group_msg)。
|
||||||
//
|
//
|
||||||
// ★ 为何与查询类分开(2026-10-13 实测):QQ 发送要经过 NapCat 的
|
// ★ 为何与查询类分开(2026-10-13 实测):QQ 发送要经过 NapCat 的
|
||||||
@ -188,7 +188,7 @@ type Plugin struct {
|
|||||||
//
|
//
|
||||||
// 发送超时给足 120s(与插件内 TTS/下载同量级);查询类保持 30s
|
// 发送超时给足 120s(与插件内 TTS/下载同量级);查询类保持 30s
|
||||||
// (它们是幂等的,超时就是真超时,快速失败更有利于排查)。
|
// (它们是幂等的,超时就是真超时,快速失败更有利于排查)。
|
||||||
sendClient *http.Client
|
sendClient *http.Client
|
||||||
allowFrom map[int64]struct{}
|
allowFrom map[int64]struct{}
|
||||||
groupAllowFrom map[int64]struct{}
|
groupAllowFrom map[int64]struct{}
|
||||||
srv *http.Server
|
srv *http.Server
|
||||||
@ -1302,6 +1302,36 @@ func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, q
|
|||||||
return matchesToolAllowlist(name, patterns), auth
|
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.
|
// isAtBot checks if the message contains an @-mention of the bot.
|
||||||
func (p *Plugin) isAtBot(msg interface{}) bool {
|
func (p *Plugin) isAtBot(msg interface{}) bool {
|
||||||
segments, ok := msg.([]interface{})
|
segments, ok := msg.([]interface{})
|
||||||
@ -1391,6 +1421,37 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
|
|||||||
if len(ctx.ToolCalls) == 0 {
|
if len(ctx.ToolCalls) == 0 {
|
||||||
return nil
|
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]
|
tc := &ctx.ToolCalls[0]
|
||||||
allowed, auth := p.currentToolAllowed(ctx, tc.Name)
|
allowed, auth := p.currentToolAllowed(ctx, tc.Name)
|
||||||
if !auth.Active {
|
if !auth.Active {
|
||||||
|
|||||||
@ -725,6 +725,7 @@ func TestNapcatConvertsOneBotFailureToError(t *testing.T) {
|
|||||||
// 与我修过的 HTTP 客户端超时是**两条不同路径**:
|
// 与我修过的 HTTP 客户端超时是**两条不同路径**:
|
||||||
// - HTTP 超时:client.Post 返回 err(已有告警)
|
// - HTTP 超时:client.Post 返回 err(已有告警)
|
||||||
// - NapCat 超时:HTTP 200 + 响应体里带 NTEvent 字样(本次才补)
|
// - NapCat 超时:HTTP 200 + 响应体里带 NTEvent 字样(本次才补)
|
||||||
|
//
|
||||||
// 只报警「失败」而不说「可能已送出」,模型必重发。
|
// 只报警「失败」而不说「可能已送出」,模型必重发。
|
||||||
//
|
//
|
||||||
// 判据必须穿过 napcat() 真实调用链(单测 isNapcatSendUnconfirmed 测不出
|
// 判据必须穿过 napcat() 真实调用链(单测 isNapcatSendUnconfirmed 测不出
|
||||||
@ -816,3 +817,151 @@ func TestNapcatSendActionTimeoutRegardlessOfServiceName(t *testing.T) {
|
|||||||
t.Errorf("服务名不是 sendMsg 的 NTEvent 超时也要套用发送超时语义,实为 %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -119,7 +120,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
}
|
}
|
||||||
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss")
|
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss")
|
||||||
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
|
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()
|
p.loadData()
|
||||||
|
|
||||||
@ -184,7 +185,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.wg.Add(1)
|
p.wg.Add(1)
|
||||||
go p.pollLoop()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,7 +194,7 @@ func (p *Plugin) Stop() error {
|
|||||||
p.pollTicker.Stop()
|
p.pollTicker.Stop()
|
||||||
p.wg.Wait()
|
p.wg.Wait()
|
||||||
p.saveData()
|
p.saveData()
|
||||||
fmt.Printf("[%s] stopped\n", p.name)
|
log.Printf("[%s] stopped", p.name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -486,7 +487,7 @@ func (p *Plugin) cleanupData() {
|
|||||||
for _, f := range []string{"feeds.json"} {
|
for _, f := range []string{"feeds.json"} {
|
||||||
path := filepath.Join(p.dataDir, f)
|
path := filepath.Join(p.dataDir, f)
|
||||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -95,7 +96,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
ctx.Lock()
|
ctx.Lock()
|
||||||
defer ctx.Unlock()
|
defer ctx.Unlock()
|
||||||
if len(ctx.ToolResults) > 0 {
|
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
|
return nil
|
||||||
}, sdk.StageScopeOwnTools)
|
}, sdk.StageScopeOwnTools)
|
||||||
@ -115,25 +116,25 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[%s] started\n", p.name)
|
log.Printf("[%s] started", p.name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Stop() error {
|
func (p *Plugin) Stop() error {
|
||||||
fmt.Printf("[%s] stopped\n", p.name)
|
log.Printf("[%s] stopped", p.name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type wttrResp struct {
|
type wttrResp struct {
|
||||||
CurrentCondition []struct {
|
CurrentCondition []struct {
|
||||||
TempC string `json:"temp_C"`
|
TempC string `json:"temp_C"`
|
||||||
FeelsLikeC string `json:"FeelsLikeC"`
|
FeelsLikeC string `json:"FeelsLikeC"`
|
||||||
Humidity string `json:"humidity"`
|
Humidity string `json:"humidity"`
|
||||||
WindspeedKmph string `json:"windspeedKmph"`
|
WindspeedKmph string `json:"windspeedKmph"`
|
||||||
Winddir16Point string `json:"winddir16Point"`
|
Winddir16Point string `json:"winddir16Point"`
|
||||||
Pressure string `json:"pressure"`
|
Pressure string `json:"pressure"`
|
||||||
Visibility string `json:"visibility"`
|
Visibility string `json:"visibility"`
|
||||||
WeatherDesc []struct {
|
WeatherDesc []struct {
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
} `json:"weatherDesc"`
|
} `json:"weatherDesc"`
|
||||||
LocalObsDateTime string `json:"localObsDateTime"`
|
LocalObsDateTime string `json:"localObsDateTime"`
|
||||||
@ -153,7 +154,7 @@ type wttrResp struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type wttrDay struct {
|
type wttrDay struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
Astronomy []struct {
|
Astronomy []struct {
|
||||||
Sunrise string `json:"sunrise"`
|
Sunrise string `json:"sunrise"`
|
||||||
Sunset string `json:"sunset"`
|
Sunset string `json:"sunset"`
|
||||||
@ -161,8 +162,8 @@ type wttrDay struct {
|
|||||||
MaxtempC string `json:"maxtempC"`
|
MaxtempC string `json:"maxtempC"`
|
||||||
MintempC string `json:"mintempC"`
|
MintempC string `json:"mintempC"`
|
||||||
Hourly []struct {
|
Hourly []struct {
|
||||||
TempC string `json:"tempC"`
|
TempC string `json:"tempC"`
|
||||||
WeatherDesc []struct {
|
WeatherDesc []struct {
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
} `json:"weatherDesc"`
|
} `json:"weatherDesc"`
|
||||||
WindspeedKmph string `json:"windspeedKmph"`
|
WindspeedKmph string `json:"windspeedKmph"`
|
||||||
|
|||||||
@ -99,7 +99,17 @@ fail=0
|
|||||||
failed_names=""
|
failed_names=""
|
||||||
|
|
||||||
for dir in "$SDK_ROOT"/example/*/; do
|
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")"
|
name="$(basename "$dir")"
|
||||||
|
|
||||||
# 清掉旧产物:残留会让人(和本脚本)误判成功。
|
# 清掉旧产物:残留会让人(和本脚本)误判成功。
|
||||||
@ -111,7 +121,28 @@ for dir in "$SDK_ROOT"/example/*/; do
|
|||||||
# 判据是**退出码 + 产物存在**,两者都要。
|
# 判据是**退出码 + 产物存在**,两者都要。
|
||||||
# 只看退出码:hmapdev 曾经出错也退 0(已修,但脚本不该依赖它「现在」是对的)。
|
# 只看退出码: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
|
if [ $rc -eq 0 ] && [ -n "$hmap" ]; then
|
||||||
# 保留插件自己声明的产物名(它用的是 plg.json 的 name_en,是插件的身份),
|
# 保留插件自己声明的产物名(它用的是 plg.json 的 name_en,是插件的身份),
|
||||||
# 只在前面加平台前缀避免多平台互相覆盖。
|
# 只在前面加平台前缀避免多平台互相覆盖。
|
||||||
|
|||||||
77
sdk/facilities.go
Normal file
77
sdk/facilities.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -385,8 +385,35 @@ type PluginMgrAPI interface {
|
|||||||
ReloadOne(name string) error
|
ReloadOne(name string) error
|
||||||
// ListLoadedPlugins 列出已加载插件。
|
// ListLoadedPlugins 列出已加载插件。
|
||||||
ListLoadedPlugins() []string
|
ListLoadedPlugins() []string
|
||||||
// IsPluginDisabled 查询插件是否被禁用。
|
// IsPluginDisabled 查询插件是否被**持久**禁用。
|
||||||
IsPluginDisabled(name string) bool
|
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.
|
// StageScope controls which events a stage handler receives.
|
||||||
@ -449,6 +476,8 @@ type PluginSDK struct {
|
|||||||
llm LLMAPI
|
llm LLMAPI
|
||||||
sett SettingsAPI
|
sett SettingsAPI
|
||||||
social SocialAPI
|
social SocialAPI
|
||||||
|
secrets SecretsAPI
|
||||||
|
rawFiles RawFilesAPI
|
||||||
events EventSubscriber
|
events EventSubscriber
|
||||||
plgMgr PluginMgrAPI
|
plgMgr PluginMgrAPI
|
||||||
|
|
||||||
@ -540,6 +569,16 @@ func (s *PluginSDK) LLM() LLMAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Social returns the social graph API (may be nil if not available).
|
// 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 {
|
func (s *PluginSDK) Social() SocialAPI {
|
||||||
s.apiMu.RLock()
|
s.apiMu.RLock()
|
||||||
defer s.apiMu.RUnlock()
|
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).
|
// 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) {
|
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) {
|
||||||
s.apiMu.Lock()
|
s.apiMu.Lock()
|
||||||
s.mem = mem
|
s.mem = mem
|
||||||
|
|||||||
56
sdk/rawfiles.go
Normal file
56
sdk/rawfiles.go
Normal 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
111
sdk/secrets.go
Normal 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"`
|
||||||
|
}
|
||||||
@ -7,6 +7,21 @@ type SettingsAPI interface {
|
|||||||
// Set writes a config value to the plugin's own config table.
|
// Set writes a config value to the plugin's own config table.
|
||||||
Set(key string, value interface{}) error
|
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 returns all keys matching the given prefix.
|
||||||
List(prefix string) ([]string, error)
|
List(prefix string) ([]string, error)
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,37 @@ description: 开发、安装、调试 HomeAgent 插件。涉及 hmapdev 工具
|
|||||||
> 本机(192.168.2.60)HomeAgent 的插件工具链手册。**所有命令都实测过**,
|
> 本机(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. 先搞清三件事,别猜
|
## 0. 先搞清三件事,别猜
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -84,16 +115,21 @@ hmapdev debug . # 不想装就能先跑一遍
|
|||||||
|
|
||||||
### 注册工具的形状(照 example 写,别自创)
|
### 注册工具的形状(照 example 写,别自创)
|
||||||
|
|
||||||
|
**★ `sdk.ToolDef` 里没有 `Handler` 字段** —— handler 是 `RegisterTool` 的**第三个参数**,
|
||||||
|
不是结构体成员。把 `Handler:` 写进结构体会**编译失败**(`unknown field Handler`)。
|
||||||
|
|
||||||
```go
|
```go
|
||||||
s.RegisterTool(tp+"mytool", sdk.ToolDef{
|
s.RegisterTool(tp+"mytool", sdk.ToolDef{
|
||||||
Name: tp + "mytool", // ← Name 必填,且要放在结构体**首位**
|
Name: tp + "mytool", // ← Name 必填,且要放在结构体**首位**
|
||||||
Description: "……",
|
Description: "……",
|
||||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||||
Handler: h.mytool,
|
|
||||||
NoMemory: true, // 声明字段一律放**末尾**,仿 NoMemory 的写法
|
NoMemory: true, // 声明字段一律放**末尾**,仿 NoMemory 的写法
|
||||||
}, s)
|
}, h.mytool) // ← handler 在这里,不在结构体内
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(`ToolDef` 的全部字段:`Name` / `Plugin` / `Description` / `Parameters` / `NoMemory` /
|
||||||
|
`Cleaner` / `ContextPolicy` / `RecallPolicy` / `ParallelSafe` / `Serial`。)
|
||||||
|
|
||||||
**并发声明**(同轮多个 tool_call 时):
|
**并发声明**(同轮多个 tool_call 时):
|
||||||
|
|
||||||
- `ParallelSafe: true` —— 可并发(**三个条件都满足才可声明**:handler 线程安全 /
|
- `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` 生成,
|
- **不要手改生成的文档**:`docs/api/*.md` 由 `tools/apidoc/gensite` 生成,
|
||||||
首行写着「请勿手改」;要改就去改 `sdk/*.go` 的注释再重新生成。
|
首行写着「请勿手改」;要改就去改 `sdk/*.go` 的注释再重新生成。
|
||||||
- **不要在插件里硬编码 token / 密钥**:走 `Settings()` 或宿主注入。
|
- **不要在插件里硬编码 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` 的源码** —— 先确认它是不是内置插件。
|
- **不要为了让插件生效去改 `homed` 的源码** —— 先确认它是不是内置插件。
|
||||||
|
|||||||
@ -9,6 +9,9 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -256,8 +259,24 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
|||||||
writePluginJSON(plg, plats, procEntryFile)
|
writePluginJSON(plg, plats, procEntryFile)
|
||||||
|
|
||||||
// package single .hmap with correctly named entries
|
// 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)))
|
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))
|
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 {
|
if len(plg.Proxies) > 0 {
|
||||||
m["proxies"] = plg.Proxies
|
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, "", " ")
|
data, _ := json.MarshalIndent(m, "", " ")
|
||||||
os.WriteFile("plugin.json", data, 0644)
|
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)
|
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)
|
fmt.Printf(" packaged %s\n", hmapName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -723,7 +769,7 @@ type binEntry struct {
|
|||||||
// createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。
|
// createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。
|
||||||
// jsonName 是 plugin.json 在 zip 中的条目名;
|
// jsonName 是 plugin.json 在 zip 中的条目名;
|
||||||
// binaries 的 src 为磁盘路径,zip 为 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)
|
f, err := os.Create(hmapPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("error: create hmap %s: %v\n", hmapPath, err)
|
fmt.Printf("error: create hmap %s: %v\n", hmapPath, err)
|
||||||
@ -773,6 +819,27 @@ func createBundleHmap(hmapPath, jsonName string, binaries []binEntry) {
|
|||||||
writeZipEntry(fname, fname)
|
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) {
|
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 {
|
func toSnake(s string) string {
|
||||||
return strings.ToLower(strings.ReplaceAll(s, " ", "_"))
|
return strings.ToLower(strings.ReplaceAll(s, " ", "_"))
|
||||||
}
|
}
|
||||||
@ -942,3 +1052,23 @@ func sdkHasInjectPriority(sdkPath string) bool {
|
|||||||
}
|
}
|
||||||
return strings.Contains(seg, "Priority")
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -113,9 +113,40 @@ type PlgConfig struct {
|
|||||||
// 字段语义见 SDK 的 sdk.ProxyDef(工具链与内核共用同一套校验规则)。
|
// 字段语义见 SDK 的 sdk.ProxyDef(工具链与内核共用同一套校验规则)。
|
||||||
Proxies []ProxyConfig `json:"proxies,omitempty"`
|
Proxies []ProxyConfig `json:"proxies,omitempty"`
|
||||||
|
|
||||||
|
// RawFiles 声明要一并打包的**非代码资源**(见 rawfiles.go)。
|
||||||
|
//
|
||||||
|
// 工程根的 `rawfile/` 目录**默认全收**,不需要在这里声明;
|
||||||
|
// 本数组用于**额外**的外部路径。解包后统一落在 <插件目录>/rawfile/ 下。
|
||||||
|
RawFiles []RawFileDecl `json:"rawfiles,omitempty"`
|
||||||
|
|
||||||
// ResolvedSDK 是本次构建实际选中的 SDK 版本(build 按 SDK 声明解析后回填),
|
// ResolvedSDK 是本次构建实际选中的 SDK 版本(build 按 SDK 声明解析后回填),
|
||||||
// 只写进产物里的 plugin.json,便于事后追溯「这个 .hmap 是哪版 SDK 编的」。
|
// 只写进产物里的 plugin.json,便于事后追溯「这个 .hmap 是哪版 SDK 编的」。
|
||||||
ResolvedSDK string `json:"-"`
|
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.
|
// TargetList parses the Targets string into a slice.
|
||||||
@ -286,6 +317,21 @@ func cmdInit(args []string) {
|
|||||||
// create thirdpart directory for external library sources
|
// create thirdpart directory for external library sources
|
||||||
os.MkdirAll(filepath.Join(dir, "thirdpart"), 0755)
|
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)
|
fmt.Printf("Created plugin project %q (%s)\n", dir, entry)
|
||||||
if isLua {
|
if isLua {
|
||||||
fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir)
|
fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir)
|
||||||
|
|||||||
126
tools/hmapdev/example_stdout_test.go
Normal file
126
tools/hmapdev/example_stdout_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
303
tools/hmapdev/package_examples_test.go
Normal file
303
tools/hmapdev/package_examples_test.go
Normal 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
294
tools/hmapdev/rawfiles.go
Normal 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
|
||||||
|
}
|
||||||
420
tools/hmapdev/rawfiles_test.go
Normal file
420
tools/hmapdev/rawfiles_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
18
tools/hmapdev/scaffold_stdout_helper_test.go
Normal file
18
tools/hmapdev/scaffold_stdout_helper_test.go
Normal 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 脚手架模板")
|
||||||
|
}
|
||||||
125
tools/hmapdev/scaffold_stdout_test.go
Normal file
125
tools/hmapdev/scaffold_stdout_test.go
Normal 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
129
tools/hmapdev/skills.go
Normal 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))
|
||||||
|
}
|
||||||
176
tools/hmapdev/skills_test.go
Normal file
176
tools/hmapdev/skills_test.go
Normal 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
|
||||||
|
}
|
||||||
@ -13,7 +13,8 @@ const tmplPlgJSON = `{
|
|||||||
"entry": "{{.Plg.Entry}}",
|
"entry": "{{.Plg.Entry}}",
|
||||||
"sdk": "{{.Plg.SDK}}",
|
"sdk": "{{.Plg.SDK}}",
|
||||||
"tags": [{{range $i, $t := .Plg.Tags}}{{if $i}}, {{end}}"{{$t}}"{{end}}],
|
"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
|
const tmplPluginGo = `package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"log"
|
||||||
|
|
||||||
"github.com/JianFeeeee/homeagentsdk/sdk"
|
"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 {
|
type Plugin struct {
|
||||||
name string
|
name string
|
||||||
sdk *sdk.PluginSDK
|
sdk *sdk.PluginSDK
|
||||||
@ -51,7 +64,7 @@ func (p *Plugin) Name() string { return p.name }
|
|||||||
|
|
||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
p.sdk = s
|
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{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "plugin.{{.Plg.Name}}.example", Default: "hello", Type: "string",
|
Key: "plugin.{{.Plg.Name}}.example", Default: "hello", Type: "string",
|
||||||
DisplayName: "示例配置", Description: "An example configuration key",
|
DisplayName: "示例配置", Description: "An example configuration key",
|
||||||
@ -84,11 +97,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
// return output
|
// return output
|
||||||
// },
|
// },
|
||||||
}, p.handleHello)
|
}, p.handleHello)
|
||||||
fmt.Printf("[%s] started\n", p.name)
|
log.Printf("[%s] started", p.name)
|
||||||
return nil
|
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) {
|
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
|
||||||
return map[string]interface{}{"content": "Hello from {{.Plg.Name}} plugin!"}, nil
|
return map[string]interface{}{"content": "Hello from {{.Plg.Name}} plugin!"}, nil
|
||||||
|
|||||||
@ -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}}"
|
|
||||||
}
|
|
||||||
@ -13,11 +13,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@ -740,6 +743,8 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
|
|||||||
base.SetKnowledgeAPI(procKnowledge{})
|
base.SetKnowledgeAPI(procKnowledge{})
|
||||||
base.SetLLMAPI(procLLM{})
|
base.SetLLMAPI(procLLM{})
|
||||||
base.SetSocialAPI(procSocial{})
|
base.SetSocialAPI(procSocial{})
|
||||||
|
base.SetSecretsAPI(procSecrets{})
|
||||||
|
base.SetRawFilesAPI(procRawFiles{})
|
||||||
base.SetTextMemoryAPI(procTextMemory{})
|
base.SetTextMemoryAPI(procTextMemory{})
|
||||||
base.SetPluginMgrAPI(procPluginMgr{})
|
base.SetPluginMgrAPI(procPluginMgr{})
|
||||||
base.SetInputChannelRegistrar(func(chName string, def sdk.ChannelDef) error {
|
base.SetInputChannelRegistrar(func(chName string, def sdk.ChannelDef) error {
|
||||||
@ -1160,6 +1165,234 @@ func (procPluginMgr) IsPluginDisabled(name string) bool {
|
|||||||
return r.Disabled
|
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{}
|
type procSettings struct{}
|
||||||
|
|
||||||
func (procSettings) Get(key string) (interface{}, error) {
|
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 {
|
func (procSettings) Set(key string, v interface{}) error {
|
||||||
return callCoreVoid("settings.set", map[string]interface{}{"key": key, "value": v})
|
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) {
|
func (procSettings) List(prefix string) ([]string, error) {
|
||||||
return settingsKeys("settings.list", map[string]string{"prefix": prefix})
|
return settingsKeys("settings.list", map[string]string{"prefix": prefix})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -225,6 +225,14 @@ func (s *mockSettings) Get(key string) (interface{}, error) {
|
|||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
func (s *mockSettings) Set(key string, value interface{}) error { s.data[key] = value; return 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) {
|
func (s *mockSettings) List(prefix string) ([]string, error) {
|
||||||
var ks []string
|
var ks []string
|
||||||
for k := range s.data {
|
for k := range s.data {
|
||||||
|
|||||||
Reference in New Issue
Block a user