From 0111ac3a7ab1d78c251fbe48602f02bc6ad373b2 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 17 Oct 2026 16:34:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(sdk):=20=E5=8D=8F=E8=AE=AE=E9=80=9A?= =?UTF-8?q?=E9=81=93=E7=BA=AA=E5=BE=8B=20+=20hmapdev=20=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=20+=20=E7=A4=BA=E4=BE=8B=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=20stdout=20=E6=B1=A1=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 核心缺陷:插件往 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` +§协议通道纪律(正误代码对比) --- .gitignore | 4 + docs/versions.md | 192 +++ example/ai_image/plugin.go | 44 +- example/calendar/plugin.go | 11 +- example/editdoc/README.md | 69 +- example/editdoc/embed/edit_doc.py | 1390 ++++++++++++++++++ example/editdoc/go.mod | 16 +- example/editdoc/main.go | 2 - example/editdoc/plg.json | 22 +- example/editdoc/plugin.go | 580 ++++++-- example/editdoc/plugin_test.go | 178 +++ example/qq/plugin.go | 89 +- example/qq/plugin_test.go | 149 ++ example/rss/plugin.go | 9 +- example/weather/plugin.go | 29 +- package/build-examples.sh | 35 +- sdk/facilities.go | 77 + sdk/plugin.go | 65 +- sdk/rawfiles.go | 56 + sdk/secrets.go | 111 ++ sdk/settings.go | 15 + skills/homeagent-plugin-dev/SKILL.md | 75 +- tools/hmapdev/cmd_build.go | 136 +- tools/hmapdev/cmd_init.go | 46 + tools/hmapdev/example_stdout_test.go | 126 ++ tools/hmapdev/package_examples_test.go | 303 ++++ tools/hmapdev/rawfiles.go | 294 ++++ tools/hmapdev/rawfiles_test.go | 420 ++++++ tools/hmapdev/scaffold_stdout_helper_test.go | 18 + tools/hmapdev/scaffold_stdout_test.go | 125 ++ tools/hmapdev/skills.go | 129 ++ tools/hmapdev/skills_test.go | 176 +++ tools/hmapdev/templates.go | 23 +- tools/hmapdev/templates/plg.json.tmpl | 11 - tools/hmapdev/templates/proc_main.go.tmpl | 253 ++++ tools/hmapdev/yaegi/mocksdk/plugin.go | 8 + 36 files changed, 5059 insertions(+), 227 deletions(-) create mode 100644 example/editdoc/embed/edit_doc.py create mode 100644 example/editdoc/plugin_test.go create mode 100644 sdk/facilities.go create mode 100644 sdk/rawfiles.go create mode 100644 sdk/secrets.go create mode 100644 tools/hmapdev/example_stdout_test.go create mode 100644 tools/hmapdev/package_examples_test.go create mode 100644 tools/hmapdev/rawfiles.go create mode 100644 tools/hmapdev/rawfiles_test.go create mode 100644 tools/hmapdev/scaffold_stdout_helper_test.go create mode 100644 tools/hmapdev/scaffold_stdout_test.go create mode 100644 tools/hmapdev/skills.go create mode 100644 tools/hmapdev/skills_test.go delete mode 100644 tools/hmapdev/templates/plg.json.tmpl diff --git a/.gitignore b/.gitignore index f2effc7..58e2c54 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ site_build/ # 每次改任何一页都会整份重写(115KB),进版本库只产生噪声。它由构建产出, # llms.txt(索引,小且稳定)仍提交。 docs/llms-full.txt + +# ★ rawfile/ 的内容是随包资源(可能内嵌 node/pi 等上百兆二进制), +# 由构建方自行准备,不进版本库。忽略规则放在 rawfile/.gitignore 内 +# (自包含,避免依赖上层规则的作用域与否定式语义)。 diff --git a/docs/versions.md b/docs/versions.md index 8308b18..4bff568 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -31,6 +31,198 @@ 零值 `InjectOptions` 与旧的三参数方法完全等价,因此存量插件**不需要改、也不需要重编**; 想用新字段的重编即可。 +### SDK 1.4.0 → 本轮新增(PluginMgr 运行态启停)⚠️ 有一个例外 + +本轮给 `PluginMgrAPI` 新增 3 个方法(`StopPlugin` / `StartPlugin` / +`IsPluginRuntimeStopped`,见 `docs/zh/kernel-modularization-design.md` §2.6.7)。 + +按上面的规则,这属于"**插件调用、内核实现**"的纯追加 ⇒ 无需重编。**但有一个例外**: + +| 对象 | 是否需要动 | 原因 | +|---|---|---| +| **已编译的存量插件**(`plugin.bin`) | ❌ 不需要 | 不调新方法就不受影响(与 1.1/1.2 一致) | +| **存量插件的源码要重编** | ⚠️ **需要同步 hmapdev** | `proc_main.go.tmpl` 里的 `procPluginMgr` 必须实现新方法,否则**编译不过** | +| **新插件** | ✅ 自动 | 模板已同步(`hmapdev` 已更新) | + +!!! warning "为什么这次和 1.1/1.2 不一样" + 1.1/1.2 只改了 `sdk/`(插件**调用**的一侧)与内核实现。 + 本轮还改了 **`tools/hmapdev/templates/proc_main.go.tmpl`** —— + 那是**生成插件代码**的模板,属于"插件**被编译**的一侧"。 + + `plugin.bin` 里的 `procPluginMgr` 是**编译期**由模板生成的具体类型, + 它必须满足 `PluginMgrAPI` 的**全部**方法(Go 接口是精确匹配)。 + 模板不同步 ⇒ 生成物不满足接口 ⇒ **编译失败**(不是运行期降级)。 + + ⇒ 判据 `internal/plugin/proc/bridge_sync_test.go` 已有 3 条自动守这个 + (SDK 方法集 ⇒ 能力表 / Handle 分派 / hmapdev 模板,三处必须同步)。 + + + +## 脚手架模板的协议通道纪律(`hmapdev init` 生成物) + +子进程插件的 **stdout 是 JSON-RPC 协议通道**,**stderr 才是日志通道** +(SDK 侧 `log.SetOutput(os.Stderr)`)。这条纪律**必须写进脚手架**, +否则每个新插件作者都会踩。 + +### 曾踩的坑(2026-10-16 实测) + +`hmapdev init` 生成的 `plugin.go` 原先用 `fmt.Printf` 打日志 ⇒ 写进 stdout +⇒ 内核 readLoop 收到非 JSON 文本: + +``` +[proc] final_probe 收到非法 JSON 帧(21 字节): invalid character 'i' in literal false +``` + +两个后果叠加、都很难定位: + +1. 那句日志**丢了**(内核把它当坏帧丢弃); +2. 多了一条看起来像**协议故障**的报错 —— 而真因是"插件自己 printf"。 + +**A/B 实证**(同一内核,两个插件): + +| 插件 | 脚手架 | 非法 JSON 帧 | 日志可见 | +|---|---|---|---| +| `final_probe` | 旧(`fmt.Printf`) | **1 次** | ❌ 丢失 | +| `fixed_probe` | 新(`log.Printf`) | **0 次** | ✅ `[fixed_probe] started` | + +### 修法 + +模板改成 `log.Printf`,并把这条纪律写进生成物的**头部注释** +(让每个插件作者第一眼就看到)。 + +### 判据(`tools/hmapdev/scaffold_stdout_test.go`,3 条) + +1. `TestScaffoldTemplateDoesNotWriteStdout` —— **代码行**里不得有 + `fmt.Printf/Println/Print/os.Stdout` +2. `TestScaffoldTemplateUsesStderrLogging` —— 反向钉住:光禁 stdout 不够, + 把日志全删也能过第一条 ⇒ 必须**真的**用 `log.Printf`(并 import "log") +3. `TestScaffoldLuaTemplateDoesNotWriteStdout` —— Lua 脚手架同理 + +**变异 2/2 咬住**:改回 `fmt.Printf` ⇒ 红;删光日志 ⇒ 红。 + +!!! warning "判据自身踩的坑(值得记住)" + 第一版判据直接扫描模板字符串 ⇒ **被模板里的注释判红**(注释**故意** + 提到 `fmt.Printf` 来讲这个坑)。必须**先剥注释**再扫代码行。 + + 这是"源码扫描判据"的固有脆弱性:本仓规范要求把坑写进注释, + 而注释里必然出现那些符号名 ⇒ **任何时候用字符串扫描判据, + 都要先想"注释里会不会也有这个串"**(同类事故见 + `docs/zh/kernel-modularization-design.md` §8.6,那里连续骗过三次)。 + + +## 示例插件的协议通道纪律(已修) + +上面的脚手架纪律同样适用于**示例插件** —— 它们是**子进程插件** +(`package main` + `plugin.bin`),stdout 即协议通道。 + +### 生产环境实测的缺陷(不是理论问题) + +`journalctl -u homeagent | grep -c "非法 JSON 帧"` ⇒ **1038 条**,来源: + +| 插件 | 次数 | 本仓? | +|---|---|---| +| `huawei_smarthome` | 720 | 第三方(内核侧报错就是为它准备的) | +| **`weather`** | 113 | ✅ 示例 | +| **`rss`** | 103 | ✅ 示例 | +| **`calendar`** | 102 | ✅ 示例 | + +**根因**:这三个示例插件(以及 `ai_image`)用了 `fmt.Printf` 打日志 ⇒ +裸文本进入 stdout 协议流 ⇒ 内核报非法帧并**丢弃该日志**。 + +### 修法(已完成) + +4 个插件共 **14 处** `fmt.Printf` → `log.Printf`,并补 `"log"` import +(`calendar`/`rss`/`weather` 原先没 import log;`ai_image` 已有)。 + +★ 注意:`internal/plugins/cli` 里也有一处 `fmt.Println`,但它是 +**进程内插件**(`plugin.RegisterFactory`,编译进内核), +写的是内核的真实终端 ⇒ **不是缺陷**。判据只针对"子进程插件" +(`package main`)。 + +### 判据(`tools/hmapdev/example_stdout_test.go`,2 条) + +1. `TestExamplePluginsDoNotWriteStdout` —— 扫描全部**子进程**示例插件 + (实测 20 个),剥注释后不得含 `fmt.Printf/Println/Print/os.Stdout`; + 报错时给出**文件:行号** +2. `TestExamplePluginsUseStderrLogging` —— 反向钉住:至少 weather/rss/calendar + 真的在用 `log.Printf`(否则"删光日志"也能过第一条) + +### 内核侧:让非法帧报错**可定位**(这是对第三方插件的通用修复) + +原报错只有 `收到非法 JSON 帧(N 字节): invalid character 'x'`: +不显示内容(不知是哪句话)、不提原因(看起来像内核故障)。 +1038 条报错的定位花了一整轮排查 —— 就因为缺这两样。 + +现在报错含**三要素**: + +``` +[proc] bad_probe 收到非法 JSON 帧(19 字节): invalid character 'b' looking for beginning of value + ↑ 内容摘要: [bad_probe] started + 可能原因:插件往 **stdout** 打印了日志,而 stdout 是JSON-RPC **协议通道**(stderr 才是日志通道)。 + 修法:把插件里的 fmt.Printf/fmt.Println 改成 log.Printf(脚手架模板 1.4.0 起已修正)。 +``` + +(`summarizeFrame` 做控制字符替换 + 截断,保证单行且不刷屏。) + +判据在 `internal/plugin/proc/process_test.go`: +`TestSummarizeFrameIsReadable`、`TestIllegalFrameErrorIsActionable`。 +**变异 2/2 咬住**(去掉 summarizeFrame 调用 ⇒ 红;报错不提原因 ⇒ 红)。 + +## 升级已装的 SDK store 与 hmapdev 二进制(操作步骤) + +!!! danger "顺序不能反 —— 否则会装出「二进制新、store 旧」的组合" + **模板是 `//go:embed` 进 hmapdev 二进制的**(`tools/hmapdev/proc_runtime.go`), + 而 **SDK 接口(`sdk/`)来自 store**。二者必须**同代**: + + | 组合 | 结果 | + |---|---| + | 旧二进制 + 旧 store | 自洽(但拿不到新能力) | + | 新二进制 + 新 store | ✅ 自洽 | + | **新二进制 + 旧 store** | ⚠️ 模板的 `procPluginMgr` 有 6 个方法,旧接口只有 3 个 ⇒ 生成物**多出 3 个方法**(Go 允许,但新方法调用的 `plugin.mgrStop` 内核可能不认) | + | **旧二进制 + 新 store** | ❌ 模板 3 个方法 vs 接口 6 个 ⇒ 生成物**不满足接口** ⇒ **编译失败** | + + ⇒ **先升级 store,再升级二进制**(或两者同一批做完)。 + +### 步骤(本机,用源码) + +```bash +# 1) 用**当前**hmapdev 把本机源码装成 store(--from 在旧版也支持) +hmapdev sdk install --from # 例:third_party/homeagent-sdk + +# 2) 重编 hmapdev 二进制(模板 embed 进去了,必须重编) +cd /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//tools/hmapdev/templates/proc_main.go.tmpl # 必须无差异 +``` + +★ **`diff` 陷阱**:本机 PATH 上的 `diff` 是 HarmonyOS 工具链里的一个 +**坏二进制**(`/opt/huawei/harmonyos/ohos-sdk/linux/toolchains/diff`), +对**不同**的文件也返回 **exit 0 且无输出** ⇒ 会给出"已同步"的**假结论**。 +用 `cmp` 或 Python 比对,或显式用 `/usr/bin/diff`。 + +### 判据(自动守"三处同步") + +`internal/plugin/proc/bridge_sync_test.go` 有 3 条,任一不同步即红: + +1. `TestBridgeCoversEveryPublishedPluginMgrMethod` —— SDK 方法集 ↔ 能力表 +2. `TestBridgeDispatchesEveryPluginMethod` —— 内核 Handle 分派 +3. `TestHmapdevTemplateImplementsAllPluginMgrMethods` —— **hmapdev 模板** + +第 3 条直接读 `third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl`, +所以**源码模板**始终被守;store 里的副本靠上面的 `diff` 验证。 + !!! tip "什么时候必须重编" 只有一种情况:**内核跨了主版本**(如 1.x → 2.x)。 主版本跃迁是破坏性变更的定义,此后的帧格式与共享段布局都不兼容, diff --git a/example/ai_image/plugin.go b/example/ai_image/plugin.go index 2c1fad2..9e3753e 100644 --- a/example/ai_image/plugin.go +++ b/example/ai_image/plugin.go @@ -17,15 +17,15 @@ import ( ) type Plugin struct { - name string - sdk *sdk.PluginSDK - client *http.Client - apiKey string + name string + sdk *sdk.PluginSDK + client *http.Client + apiKey string provider string - model string - size string - baseURL string - dataDir string // /ai_images:生成本地图片存放目录 + model string + size string + baseURL string + dataDir string // /ai_images:生成本地图片存放目录 } func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { @@ -119,12 +119,12 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.Settings().RegisterDef(sdk.ConfigDef{ Key: "base_url", Default: "", Type: "string", DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081);为空走官方 https://api.openai.com", - Category: "ai_image", + Category: "ai_image", }) s.Settings().RegisterDef(sdk.ConfigDef{ Key: "provider", Default: "openai", Type: "string", DisplayName: "Provider", Description: "Image generation provider: openai / stability", - Category: "ai_image", + Category: "ai_image", }) s.Settings().RegisterDef(sdk.ConfigDef{ Key: "model", Default: "dall-e-3", Type: "string", @@ -143,17 +143,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { p.size = getSetting(s.Settings(), "size", "1024x1024") p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/") -// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。 -if p.sdk != nil { - if dd := s.Settings().DataDir(); dd != "" { - p.dataDir = dd + // 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。 + if p.sdk != nil { + if dd := s.Settings().DataDir(); dd != "" { + p.dataDir = dd + } } -} -if p.dataDir == "" { - // 旧版内核无 DataDir API 时退到 /tmp - p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images") -} -os.MkdirAll(p.dataDir, 0755) + if p.dataDir == "" { + // 旧版内核无 DataDir API 时退到 /tmp + p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images") + } + os.MkdirAll(p.dataDir, 0755) tp := p.name + "_" s.RegisterTool(tp+"generate", sdk.ToolDef{ @@ -170,12 +170,12 @@ os.MkdirAll(p.dataDir, 0755) }, }, p.handleGenerate) - fmt.Printf("[%s] started (provider=%s, model=%s)\n", p.name, p.provider, p.model) + log.Printf("[%s] started (provider=%s, model=%s)", p.name, p.provider, p.model) return nil } func (p *Plugin) Stop() error { - fmt.Printf("[%s] stopped\n", p.name) + log.Printf("[%s] stopped", p.name) return nil } diff --git a/example/calendar/plugin.go b/example/calendar/plugin.go index a22a835..c526655 100644 --- a/example/calendar/plugin.go +++ b/example/calendar/plugin.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "log" "os" "path/filepath" "sort" @@ -285,7 +286,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { } p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar") if err := os.MkdirAll(p.dataDir, 0755); err != nil { - fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err) + log.Printf("[%s] mkdir %s: %v", p.name, p.dataDir, err) } p.loadEvents() @@ -401,7 +402,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { p.wg.Add(1) go p.remindLoop() - fmt.Printf("[%s] started (%d events)\n", p.name, len(p.events)) + log.Printf("[%s] started (%d events)", p.name, len(p.events)) return nil } @@ -409,7 +410,7 @@ func (p *Plugin) Stop() error { p.remindTicker.Stop() close(p.stopCh) p.wg.Wait() - fmt.Printf("[%s] stopped\n", p.name) + log.Printf("[%s] stopped", p.name) return nil } @@ -611,9 +612,9 @@ func (p *Plugin) cleanupData() { p.mu.Lock() defer p.mu.Unlock() if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) { - fmt.Printf("[calendar] onRemove cleanup: %v\n", err) + log.Printf("[calendar] onRemove cleanup: %v", err) } else { - fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile()) + log.Printf("[calendar] onRemove removed %s", p.eventsFile()) } } diff --git a/example/editdoc/README.md b/example/editdoc/README.md index 9e4c8d7..2e9d9ea 100644 --- a/example/editdoc/README.md +++ b/example/editdoc/README.md @@ -1,49 +1,62 @@ -# editdoc · Office 文档编辑 +# editdoc · 全能办公 -编辑 `.docx` / `.xlsx` / `.pptx` 内容:查找替换、改单元格、插行。 +对 `.docx` / `.xlsx` / `.pptx` / `.xls` / `.csv` / `.md` / `.txt` 做**新建、读取、编辑、转换**。 -> ⚠️ **版本说明**:本目录是 **v1.0.0**,只有 `edit_document` 一个工具。 -> 线上部署的 v2.0.0(全能办公版,支持新建/读取/转换 docx·xlsx·pptx·md·csv·txt) -> **源码尚未公开**,本文档不描述那些能力。参见 `plugin.json` 的 `version`。 +> **版本**:v2.0.0(全能办公)。较 v1.0.0 新增 `read_document`、`create_document`、 +> `create_from_template`、`convert_document`、`office_check` 五个工具; +> `edit_document` 的名字与全部 v1 参数**保持不变**,老调用不会破。 ## 工具 -| 工具 | 说明 | +| 工具 | 作用 | |---|---| -| `edit_document` | 编辑文档内容,**编辑后原文件被覆盖** | - -参数: - -| 参数 | 说明 | -|---|---| -| `file` | 文档路径(必填) | -| `operation` | `replace_text`(查找替换)/ `set_cell`(设置单元格)/ `insert_row`(插入行)(必填) | -| `target` | 要查找的文本(`replace_text` 用) | -| `replacement` | 替换为的文本(`replace_text` 用) | -| `sheet` | 工作表名(xlsx 可选) | -| `row` | 行号(`set_cell` / `insert_row` 用) | -| `col` | 列号(`set_cell` 用) | -| `value` | 单元格值(`set_cell` 用) | - -编辑前建议先读一遍内容确认目标文本 —— 查找替换是**全文件覆盖写**,没有撤销。 +| `read_document` | 读为结构化 JSON(docx 段落+表格+页眉页脚 / xlsx 逐表逐格含公式与计算值 / pptx 逐页 / xls / csv / md / txt),大文件可 `offset`/`limit` 分段 | +| `create_document` | 从零新建。docx 支持 Markdown 正文;xlsx 支持多表(表名+表头+行);pptx 用 `##` 分页 | +| `create_from_template` | 复制模板再套用替换 | +| `edit_document` | 编辑既有文件(v1 八参数 + 查找替换/正则大小写/第 N 处/表格与单元格/图片/样式/幻灯片/`dry_run`) | +| `convert_document` | 格式转换(docx↔md/pdf、xlsx↔csv 等,借助 soffice / pandoc) | +| `office_check` | 自检:依赖库可用性与版本、外部工具、格式×能力矩阵 | ## 配置项 | 键 | 默认 | 说明 | |---|---|---| -| `script_path` | 空 | `edit_doc.py` 的绝对路径。留空则用插件可执行文件同目录下的 `edit_doc.py` | -| `venv_python` | 空 | 执行 `edit_doc.py` 的 Python 解释器(建议用 venv 里的)。**必须配置,留空会报错** | +| `script_path` | 空 | `edit_doc.py` 的绝对路径。**留空即推荐值** —— 用内嵌脚本;仅当该文件含 v2 协议标记时才被采用,否则自动忽略并回退内嵌 | +| `venv_python` | 空 | 执行脚本的 Python 解释器(需装 python-docx / openpyxl / python-pptx)。**必须配置** | +| `timeout_sec` | 60 | 单次调用超时;大文件或格式转换(soffice 首次启动慢)建议调到 180 | ## 工作原理 -本插件是 Go 写的薄壳:把参数序列化成 JSON,交给 Python 脚本 `edit_doc.py` 执行实际文档操作。 -文档解析依赖 Python 侧的库(python-docx / openpyxl / python-pptx 之类),所以: +Go 侧只做参数编排与结构化返回,全部业务逻辑在 Python 脚本 `edit_doc.py` 里。 -- **需要自备 `edit_doc.py`**:它不在本目录里。 -- 用 `venv_python` 指向装了这些库的解释器,避免污染系统 Python。 +**脚本是内嵌的**(`//go:embed embed/edit_doc.py`),原因:`hmapdev` 打包器只把 +`plugin.json` + `plugin.bin` 放进 `.hmap`,**不打包任何资源文件**。若让脚本单独 +存在于 `plugin_data/`,就会出现「Go 侧升级了、脚本忘了换」的版本漂移。 +内嵌 + 内容指纹校验使两侧永远同版本: + +1. 首次调用时把内嵌脚本释放到 `DataDir()/runtime/edit_doc.py`,并写 `.stamp` 指纹; +2. 插件升级后指纹变化 → 自动重新释放; +3. `script_path` 显式指向的文件**仅当含 `EDITDOC_PROTOCOL = "v2"` 标记时**才被采用 + (便于运维临时覆盖调试);否则忽略并在返回体里带 `script_path_ignored`,让降级可见。 + +**业务脚本只有一份**:`embed/edit_doc.py`。不要再添加同名的顶层副本 —— +它不会被 embed,改了不生效,而两份同名文件是典型的漂移源(见 +`TestOnlyOneBusinessScript`)。 + +## 依赖 + +Python 侧:`python-docx`、`openpyxl`、`python-pptx`(`.xls` 另需 `xlrd`)。 +外部工具(可选,转换能力用):`soffice`(LibreOffice)、`pandoc`。 +用 `office_check` 随时查看本机实际可用的能力矩阵。 ## 构建 ```bash hmapdev build ``` + +## 测试 + +```bash +go test ./... +``` diff --git a/example/editdoc/embed/edit_doc.py b/example/editdoc/embed/edit_doc.py new file mode 100644 index 0000000..92fbbd0 --- /dev/null +++ b/example/editdoc/embed/edit_doc.py @@ -0,0 +1,1390 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +editdoc v2.0.0 —— 全能办公插件后端脚本。 + +调用约定(Go 侧 plugin.go): + edit_doc.py + command ∈ read | create | edit | convert | check + +输出:stdout 上一行 JSON。约定统一为 + {"ok": bool, ...payload} +失败时:{"ok": false, "error": str, "hint": str} + +设计要点: + * 所有写操作沿用 v1 的「.bak 留档 + 临时文件原子替换」策略,不许退化。 + * 错误一律结构化(ok=false + error + hint),绝不把裸 traceback 扔给上层。 + * 依赖缺失时点名缺哪个包 + 给出 pip 命令,不静默失败。 + +命令与操作(args_json 里的 op 字段): + read : 读任意支持格式为结构化 JSON(docx/xlsx/pptx/xls/csv/txt/md) + create : 从零新建(docx/xlsx/pptx/md/txt/csv) + edit : 编辑既有文件(见 OPS 表) + convert : 格式转换(docx↔md、xlsx↔csv、pptx→文本,走 soffice/pandoc) + check : 依赖与能力自检(独立可调用) + +协议标记(勿删): + Go 侧 plugin.go 的 resolveScript() 靠本标记判断「配置里 script_path 指向的脚本 + 是不是 v2 脚本」。缺少它的文件(例如 v1 的 edit_doc.py,其 argv 约定为 + )会被忽略并回退到内嵌脚本,避免把 6 个工具全部打挂。 +""" + +EDITDOC_PROTOCOL = "v2" + +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime + +# ---------------------------------------------------------------- 基础工具 + +# 支持格式矩阵(供 check 与描述使用) +FORMATS = { + "docx": ["read", "create", "edit", "convert"], + "xlsx": ["read", "create", "edit", "convert"], + "xlsm": ["read", "edit"], + "pptx": ["read", "create", "edit", "convert"], + "xls": ["read"], # 老格式,仅读(需 xlrd) + "csv": ["read", "create", "convert"], + "md": ["read", "create", "convert"], + "txt": ["read", "create"], +} + +# 依赖探测表:(import 名, pip 名, 用途) +DEPS = [ + ("docx", "python-docx", "Word .docx 读写"), + ("openpyxl", "openpyxl", "Excel .xlsx 读写"), + ("pptx", "python-pptx", "PowerPoint .pptx 读写"), + ("xlrd", "xlrd", "老式 .xls 只读"), + ("markdown", "markdown", "Markdown → HTML(新建 docx 时的富文本)"), +] +# 外部命令行工具 +BINS = [ + ("soffice", "LibreOffice(格式转换)"), + ("pandoc", "Pandoc(Markdown 转换)"), +] + + +def emit(payload): + """输出一行 JSON 并退出。""" + sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n") + sys.stdout.flush() + sys.exit(0) + + +def fail(error, hint="", **extra): + d = {"ok": False, "error": str(error), "hint": hint} + d.update(extra) + emit(d) + + +def dep_ok(mod): + try: + __import__(mod) + return True + except Exception: + return False + + +def require_dep(mod, pipname, purpose, fmt): + """依赖检查:缺失时给出可执行的 pip 命令。""" + if not dep_ok(mod): + fail( + "缺少 Python 依赖 %s(用于 %s)" % (pipname, purpose), + "请执行: %s -m pip install %s" % (sys.executable, pipname), + missing=[pipname], format=fmt, + ) + + +def which(binname): + return shutil.which(binname) + + +def backup(path): + """编辑前留档,避免改坏无法回滚。""" + try: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + dst = "%s.bak-%s" % (path, stamp) + shutil.copy2(path, dst) + return dst + except Exception: + return None + + +def save_atomic(save_fn, path): + """先写临时文件再替换,避免写一半损坏原文件。""" + d = os.path.dirname(os.path.abspath(path)) or "." + fd, tmp = tempfile.mkstemp(suffix=os.path.splitext(path)[1], dir=d) + os.close(fd) + try: + save_fn(tmp) + shutil.move(tmp, path) + except Exception: + if os.path.exists(tmp): + try: + os.remove(tmp) + except Exception: + pass + raise + + +def ensure_dir(path): + d = os.path.dirname(os.path.abspath(path)) + if d: + os.makedirs(d, exist_ok=True) + + +def ext_of(path): + return os.path.splitext(path)[1].lower().lstrip(".") + + +def match_text(hay, needle, case_sensitive=True, regex=False): + """按参数匹配,返回命中次数。""" + if not needle: + return 0 + if regex: + flags = 0 if case_sensitive else re.IGNORECASE + return len(re.findall(needle, hay, flags)) + if case_sensitive: + return hay.count(needle) + return hay.lower().count(needle.lower()) + + +def apply_text(hay, needle, repl, case_sensitive=True, regex=False, nth=None): + """ + 执行替换。nth 为 None 时全替换;为整数时只替换第 nth 个(1 基)。 + 返回 (新文本, 实际替换处数) + + 注意:返回的第二个值是「真正被替换掉的处数」,不是「匹配到多少处」。 + 指定 nth=2 且文本里有 3 处匹配时,应为 1(只换了第 2 处)。 + (曾经返回匹配总数,导致上层误以为三处都被改了。) + """ + if regex: + flags = 0 if case_sensitive else re.IGNORECASE + pat = re.compile(needle, flags) + if nth is None: + new, n = pat.subn(repl, hay) + return new, n + cnt = [0] + + def _r(m): + cnt[0] += 1 + if cnt[0] == int(nth): + cnt[0] = -cnt[0] # 标记已替换,继续用绝对值计数 + return repl + return m.group(0) + + new = pat.sub(_r, hay) + return new, (1 if cnt[0] < 0 else 0) + + if nth is None: + if case_sensitive: + return hay.replace(needle, repl), hay.count(needle) + pat = re.compile(re.escape(needle), re.IGNORECASE) + return pat.sub(lambda m: repl, hay), len(pat.findall(hay)) + + # 大小写敏感/不敏感 + nth:只换第 nth 个 + pat = re.compile(re.escape(needle), 0 if case_sensitive else re.IGNORECASE) + seen = [0] + hit = [False] + + def _r2(m): + seen[0] += 1 + if seen[0] == int(nth): + hit[0] = True + return repl + return m.group(0) + + new = pat.sub(_r2, hay) + return new, (1 if hit[0] else 0) + + +# ---------------------------------------------------------------- read + +def read_docx(path, offset=0, limit=0): + from docx import Document + + doc = Document(path) + blocks = [] + + def para_dict(p, where): + return {"type": "paragraph", "where": where, "style": p.style.name if p.style else None, + "text": p.text} + + for p in doc.paragraphs: + blocks.append(para_dict(p, "body")) + + for ti, t in enumerate(doc.tables): + rows = [] + for row in t.rows: + rows.append([c.text for c in row.cells]) + blocks.append({"type": "table", "index": ti, "rows": rows}) + + for si, section in enumerate(doc.sections): + for label, part in (("header", section.header), ("footer", section.footer)): + try: + for p in part.paragraphs: + if p.text.strip(): + blocks.append(para_dict(p, "%s[%d]" % (label, si))) + except Exception: + pass + + total = len(blocks) + if offset or limit: + end = (offset + limit) if limit else total + blocks = blocks[offset:end] + + return {"format": "docx", "total_blocks": total, "offset": offset, + "returned": len(blocks), "blocks": blocks} + + +def read_xlsx(path, offset=0, limit=0, sheet=None, formulas=True): + import openpyxl + + # data_only 同时取一次,用来看公式的计算结果 + wb = openpyxl.load_workbook(path, data_only=False) + wbv = openpyxl.load_workbook(path, data_only=True) + + sheets_out = [] + names = [sheet] if (sheet and sheet in wb.sheetnames) else wb.sheetnames + for name in names: + ws = wb[name] + wsv = wbv[name] + rows = [] + for r_i, row in enumerate(ws.iter_rows(), start=1): + cells = [] + for c in row: + if c.value is None: + continue + item = {"cell": c.coordinate, "value": c.value} + if formulas and isinstance(c.value, str) and c.value.startswith("="): + # 附上计算值 + try: + item["computed"] = wsv[c.coordinate].value + except Exception: + item["computed"] = None + cells.append(item) + if cells: + rows.append({"row": r_i, "cells": cells}) + sheets_out.append({"name": name, "max_row": ws.max_row, + "max_col": ws.max_column, "rows": rows}) + + if offset or limit: + end = (offset + limit) if limit else None + sheets_out = sheets_out[offset:end] + + return {"format": "xlsx", "sheetnames": wb.sheetnames, "sheets": sheets_out} + + +def read_pptx(path, offset=0, limit=0): + from pptx import Presentation + + prs = Presentation(path) + slides = [] + for i, slide in enumerate(prs.slides, start=1): + texts, tables, images = [], [], 0 + for sh in slide.shapes: + if getattr(sh, "has_text_frame", False) and sh.has_text_frame: + t = sh.text_frame.text + if t.strip(): + texts.append(t) + if getattr(sh, "has_table", False) and sh.has_table: + rows = [[c.text for c in r.cells] for r in sh.table.rows] + tables.append(rows) + if sh.shape_type is not None and "PICTURE" in str(sh.shape_type): + images += 1 + slides.append({"slide": i, "texts": texts, "tables": tables, "images": images}) + + total = len(slides) + if offset or limit: + end = (offset + limit) if limit else total + slides = slides[offset:end] + + return {"format": "pptx", "total_slides": total, "offset": offset, + "returned": len(slides), "slides": slides} + + +def read_xls(path): + require_dep("xlrd", "xlrd", "读取老式 .xls", "xls") + import xlrd + + book = xlrd.open_workbook(path) + sheets = [] + for sh in book.sheets(): + rows = [] + for r in range(sh.nrows): + vals = [sh.cell_value(r, c) for c in range(sh.ncols)] + if any(v != "" for v in vals): + rows.append({"row": r + 1, "values": vals}) + sheets.append({"name": sh.name, "nrows": sh.nrows, "ncols": sh.ncols, "rows": rows}) + return {"format": "xls", "sheets": sheets} + + +def read_plain(path): + with open(path, "r", encoding="utf-8", errors="replace") as f: + content = f.read() + lines = content.splitlines() + return {"format": ext_of(path), "total_lines": len(lines), "content": content} + + +def op_read(a): + path = a.get("file") or "" + if not path: + fail("read 需要 file 参数", "传入要读取的文件绝对路径") + if not os.path.exists(path): + fail("文件不存在: %s" % path, "先用 create 新建,或检查路径拼写", + path=path, exists=False) + e = ext_of(path) + offset = int(a.get("offset") or 0) + limit = int(a.get("limit") or 0) + try: + if e == "docx": + r = read_docx(path, offset, limit) + elif e in ("xlsx", "xlsm"): + r = read_xlsx(path, offset, limit, a.get("sheet"), + formulas=bool(a.get("formulas", True))) + elif e == "pptx": + r = read_pptx(path, offset, limit) + elif e == "xls": + r = read_xls(path) + elif e in ("csv", "txt", "md"): + r = read_plain(path) + else: + fail("不支持读取的格式: .%s" % e, + "支持: " + ", ".join(sorted(FORMATS.keys()))) + except Exception as ex: + fail("%s: %s" % (type(ex).__name__, ex), + "文件可能损坏,或缺少对应 Python 库") + r["ok"] = True + r["file"] = path + # 附一段人读摘要,方便 LLM 直接用 + r["summary"] = summarize_read(r) + emit(r) + + +def summarize_read(r): + f = r.get("format") + if f == "docx": + tops = [b["text"] for b in r["blocks"] if b["type"] == "paragraph" and b["text"].strip()][:5] + return "docx 共 %d 个块,前几段: %s" % (r.get("total_blocks", 0), " | ".join(tops)[:300]) + if f == "xlsx": + parts = ["表%s(%dx%d行)" % (s["name"], s["max_row"], s["max_col"]) for s in r["sheets"]] + return "xlsx 工作表: " + ", ".join(parts) + if f == "pptx": + return "pptx 共 %d 页,已返回 %d 页" % (r.get("total_slides", 0), r.get("returned", 0)) + if f == "xls": + return "xls 工作表: " + ", ".join(s["name"] for s in r["sheets"]) + return "%s 共 %d 行" % (f, r.get("total_lines", 0)) + + +# ---------------------------------------------------------------- create + +def md_to_plain_blocks(md): + """把 Markdown 粗解析成 (kind, text) 列表:# → 标题,- → 列表,其余 → 段落。""" + out = [] + for raw in md.splitlines(): + line = raw.rstrip() + if not line.strip(): + continue + m = re.match(r"^(#{1,6})\s+(.*)$", line) + if m: + out.append(("h%d" % len(m.group(1)), m.group(2).strip())) + continue + m = re.match(r"^\s*[-*+]\s+(.*)$", line) + if m: + out.append(("li", m.group(1).strip())) + continue + m = re.match(r"^\s*\d+[.)]\s+(.*)$", line) + if m: + out.append(("li", m.group(1).strip())) + continue + out.append(("p", line.strip())) + return out + + +def md_inline_runs(text): + """ + 把 **粗体** / *斜体* / `代码` 解析成 [(text, bold, italic)] 段落 runs,供 docx 用。 + """ + runs = [] + pat = re.compile(r"(\*\*.+?\*\*|\*.+?\*|`.+?`)") + pos = 0 + for m in pat.finditer(text): + if m.start() > pos: + runs.append((text[pos:m.start()], False, False)) + tok = m.group(0) + if tok.startswith("**"): + runs.append((tok[2:-2], True, False)) + elif tok.startswith("`"): + runs.append((tok[1:-1], False, False)) + else: + runs.append((tok[1:-1], False, True)) + pos = m.end() + if pos < len(text): + runs.append((text[pos:], False, False)) + return runs or [(text, False, False)] + + +def create_docx(path, a): + from docx import Document + + doc = Document() + title = a.get("title") + if title: + doc.add_heading(str(title), level=0) + + content = a.get("content") or "" + for kind, text in md_to_plain_blocks(content): + if kind.startswith("h"): + doc.add_heading(text, level=min(int(kind[1:]), 4)) + elif kind == "li": + doc.add_paragraph(text, style="List Bullet") + else: + p = doc.add_paragraph() + for seg, bold, italic in md_inline_runs(text): + r = p.add_run(seg) + r.bold = bold + r.italic = italic + + # 可选:直接给表格 + for t in a.get("tables") or []: + headers = t.get("headers") or [] + rows = t.get("rows") or [] + ncol = len(headers) or (len(rows[0]) if rows else 0) + if not ncol: + continue + tbl = doc.add_table(rows=1, cols=ncol) + tbl.style = "Table Grid" + for i, h in enumerate(headers): + tbl.rows[0].cells[i].text = str(h) + for row in rows: + cells = tbl.add_row().cells + for i in range(ncol): + cells[i].text = str(row[i]) if i < len(row) else "" + + ensure_dir(path) + doc.save(path) + return {"format": "docx", "paragraphs": len(doc.paragraphs), "tables": len(doc.tables)} + + +def create_xlsx(path, a): + import openpyxl + from openpyxl.styles import Font + + wb = openpyxl.Workbook() + # 默认有一张空表;有 sheets 时删掉它 + sheets = a.get("sheets") + if sheets: + wb.remove(wb.active) + else: + ws = wb.active + ws.title = a.get("sheet_name") or "Sheet1" + headers = a.get("headers") or [] + rows = a.get("rows") or [] + if headers: + ws.append([str(h) for h in headers]) + for c in ws[1]: + c.font = Font(bold=True) + for r in rows: + ws.append(list(r)) + sheets = [] + + for s in sheets: + ws = wb.create_sheet(title=str(s.get("name") or "Sheet")) + headers = s.get("headers") or [] + rows = s.get("rows") or [] + if headers: + ws.append([str(h) for h in headers]) + for c in ws[1]: + c.font = Font(bold=True) + for r in rows: + if isinstance(r, dict): + r = list(r.values()) + ws.append(list(r)) + + ensure_dir(path) + wb.save(path) + return {"format": "xlsx", "sheetnames": wb.sheetnames} + + +def create_pptx(path, a): + from pptx import Presentation + from pptx.util import Inches, Pt + + prs = Presentation() + title = a.get("title") or "" + content = a.get("content") or "" + + # 首页:标题版式 + slide = prs.slides.add_slide(prs.slide_layouts[0]) + slide.shapes.title.text = str(title) if title else "Untitled" + try: + slide.placeholders[1].text = a.get("subtitle") or "" + except Exception: + pass + + # 正文:按 ## 切分成多页 + sections = re.split(r"^##\s+", content, flags=re.M) if content else [] + if sections and sections[0].strip(): + # 第一个 ## 之前的内容并到首页副标题 + pass + for sec in sections: + if not sec.strip(): + continue + lines = sec.splitlines() + head = lines[0].strip() + body = "\n".join(lines[1:]).strip() + s = prs.slides.add_slide(prs.slide_layouts[1]) + s.shapes.title.text = head + if body: + tf = s.placeholders[1].text_frame + tf.text = body.splitlines()[0][:200] + for extra in body.splitlines()[1:]: + p = tf.add_paragraph() + p.text = extra[:200] + + if not sections and content: + s = prs.slides.add_slide(prs.slide_layouts[1]) + s.shapes.title.text = title or "内容" + tf = s.placeholders[1].text_frame + lines = content.splitlines() + tf.text = lines[0][:200] if lines else "" + for extra in lines[1:]: + p = tf.add_paragraph() + p.text = extra[:200] + + ensure_dir(path) + prs.save(path) + return {"format": "pptx", "slides": len(prs.slides.__iter__.__self__._sldIdLst) if hasattr(prs.slides, "__iter__") else len(list(prs.slides))} + + +def create_markdown(path, a): + """Markdown 直接落盘;给了 title 就补一级标题。""" + content = a.get("content") or "" + title = a.get("title") + text = "" + if title and not content.lstrip().startswith("# "): + text += "# %s\n\n" % title + text += content + ensure_dir(path) + with open(path, "w", encoding="utf-8") as f: + f.write(text) + return {"format": "md", "bytes": len(text.encode("utf-8"))} + + +def create_csv(path, a): + import csv + + ensure_dir(path) + headers = a.get("headers") or [] + rows = a.get("rows") or [] + # 也支持 sheets[0] + if not headers and not rows and a.get("sheets"): + s0 = a["sheets"][0] + headers = s0.get("headers") or [] + rows = s0.get("rows") or [] + with open(path, "w", encoding="utf-8", newline="") as f: + w = csv.writer(f) + if headers: + w.writerow(headers) + for r in rows: + w.writerow(list(r) if not isinstance(r, dict) else list(r.values())) + return {"format": "csv", "rows": len(rows) + (1 if headers else 0)} + + +def op_create(a): + path = a.get("file") or "" + if not path: + fail("create 需要 file 参数", "传入要创建的文件绝对路径(扩展名决定格式)") + e = ext_of(path) + if e not in FORMATS: + fail("不支持的创建格式: .%s" % e, + "支持的扩展名: " + ", ".join(sorted(FORMATS.keys()))) + exists = os.path.exists(path) + if exists and not a.get("overwrite"): + fail("文件已存在: %s" % path, + "传 overwrite=true 覆盖,或换一个文件名", + path=path, exists=True) + if exists: + bk = backup(path) + else: + bk = None + + try: + if e == "docx": + info = create_docx(path, a) + elif e == "xlsx": + info = create_xlsx(path, a) + elif e == "pptx": + info = create_pptx(path, a) + elif e == "md": + info = create_markdown(path, a) + elif e == "csv": + info = create_csv(path, a) + elif e == "txt": + ensure_dir(path) + with open(path, "w", encoding="utf-8") as f: + f.write((a.get("content") or "")) + info = {"format": "txt"} + else: + fail("暂不支持创建 .%s" % e, "支持: docx, xlsx, pptx, md, csv, txt") + except Exception as ex: + fail("创建失败 %s: %s" % (type(ex).__name__, ex), + "检查参数(content/sheets/rows 结构)是否正确") + + info.update({"ok": True, "file": path, "created": not exists, + "overwritten": exists, "backup": bk, + "bytes": os.path.getsize(path)}) + info["summary"] = "已%s %s(%s,%d 字节)" % ( + "覆盖" if exists else "创建", path, info.get("format"), info["bytes"]) + emit(info) + + +def op_create_from_template(a): + """复制模板再套用替换。""" + src = a.get("template") or a.get("source") or "" + dst = a.get("file") or a.get("target_file") or "" + if not src or not dst: + fail("create_from_template 需要 template 与 file 参数", + '示例: {"template":"/path/a.docx","file":"/path/b.docx","replacements":{"旧":"新"}}') + if not os.path.exists(src): + fail("模板不存在: %s" % src, "检查 template 路径") + if os.path.exists(dst) and not a.get("overwrite"): + fail("目标已存在: %s" % dst, "传 overwrite=true 覆盖") + ensure_dir(dst) + try: + shutil.copy2(src, dst) + except Exception as ex: + fail("复制模板失败: %s" % ex, "检查权限与磁盘空间") + + reps = a.get("replacements") or {} + if a.get("target") and "replacement" in a: + reps = {a["target"]: a.get("replacement", "")} + + count = 0 + if reps: + e = ext_of(dst) + if e == "docx": + from docx import Document + doc = Document(dst) + + def sub_paras(paras): + nonlocal count + for p in paras: + for k, v in reps.items(): + if k in p.text: + count += p.text.count(k) + nt = p.text + for kk, vv in reps.items(): + nt = nt.replace(kk, str(vv)) + if p.runs: + p.runs[0].text = nt + for r in p.runs[1:]: + r.text = "" + else: + p.text = nt + + def sub_tables(tables): + for t in tables: + for row in t.rows: + for c in row.cells: + sub_paras(c.paragraphs) + sub_tables(c.tables) + + sub_paras(doc.paragraphs) + sub_tables(doc.tables) + if count: + save_atomic(lambda p: doc.save(p), dst) + elif e in ("xlsx", "xlsm"): + import openpyxl + wb = openpyxl.load_workbook(dst) + for ws in wb.worksheets: + for row in ws.iter_rows(): + for c in row: + if isinstance(c.value, str): + for k, v in reps.items(): + if k in c.value: + count += c.value.count(k) + c.value = c.value.replace(k, str(v)) + if count: + save_atomic(lambda p: wb.save(p), dst) + else: + # 文本类直接替换 + with open(dst, "r", encoding="utf-8", errors="replace") as f: + t = f.read() + for k, v in reps.items(): + count += t.count(k) + t = t.replace(k, str(v)) + if count: + with open(dst, "w", encoding="utf-8") as f: + f.write(t) + + emit({"ok": True, "file": dst, "template": src, "replacements_applied": count, + "summary": "已从模板 %s 创建 %s,替换 %d 处" % (src, dst, count)}) + + +# ---------------------------------------------------------------- edit + +def edit_docx(path, op, a): + """docx 编辑。返回 (count, extra)""" + from docx import Document + from docx.shared import Pt, RGBColor + from docx.enum.text import WD_ALIGN_PARAGRAPH + + doc = Document(path) + target = a.get("target", "") + repl = a.get("replacement", "") + cs = bool(a.get("case_sensitive", True)) + rx = bool(a.get("regex", False)) + nth = a.get("nth") + + def all_paragraphs(): + for p in doc.paragraphs: + yield p + for t in doc.tables: + for row in t.rows: + for c in row.cells: + for p in c.paragraphs: + yield p + for section in doc.sections: + for part in (section.header, section.footer): + try: + for p in part.paragraphs: + yield p + except Exception: + pass + + if op == "replace_text": + if not target: + fail("replace_text 需要 target 参数", "传入要查找的文本") + count = 0 + for p in all_paragraphs(): + if match_text(p.text, target, cs, rx) or match_text(p.text, target, cs, rx) == 0 and rx: + new, n = apply_text(p.text, target, repl, cs, rx, nth) + if n: + count += n + if p.runs: + p.runs[0].text = new + for r in p.runs[1:]: + r.text = "" + else: + p.text = new + if count: + save_atomic(lambda p: doc.save(p), path) + return count, {} + + if op in ("insert_paragraph", "append_paragraph"): + text = a.get("value") or a.get("text") or "" + idx = a.get("index") + if op == "append_paragraph" or idx is None: + doc.add_paragraph(text) + count = 1 + else: + # 插到第 index 段之前 + idx = int(idx) + paras = doc.paragraphs + if idx < 0 or idx >= len(paras): + fail("index 越界: %d(文档共 %d 段)" % (idx, len(paras)), + "index 从 0 开始,最大 %d" % (len(paras) - 1)) + ref = paras[idx] + newp = ref.insert_paragraph_before(text) + count = 1 + save_atomic(lambda p: doc.save(p), path) + return count, {} + + if op == "insert_table": + headers = a.get("headers") or [] + rows = a.get("rows") or [] + data = ([headers] if headers else []) + [list(r) if not isinstance(r, dict) else list(r.values()) for r in rows] + if not data: + fail("insert_table 需要 headers 或 rows", '示例 {"headers":["A","B"],"rows":[[1,2]]}') + ncol = max(len(r) for r in data) + t = doc.add_table(rows=0, cols=ncol) + t.style = "Table Grid" + for r in data: + cells = t.add_row().cells + for i in range(ncol): + cells[i].text = str(r[i]) if i < len(r) else "" + save_atomic(lambda p: doc.save(p), path) + return len(data), {} + + if op == "set_style": + # 对含 target 的段落设置样式;target 为空则作用于全文 + size = a.get("size") + bold = a.get("bold") + italic = a.get("italic") + color = a.get("color") + align = a.get("align") + n = 0 + for p in all_paragraphs(): + if target and target not in p.text: + continue + if align in ("left", "center", "right", "justify"): + p.alignment = {"left": WD_ALIGN_PARAGRAPH.LEFT, + "center": WD_ALIGN_PARAGRAPH.CENTER, + "right": WD_ALIGN_PARAGRAPH.RIGHT, + "justify": WD_ALIGN_PARAGRAPH.JUSTIFY}[align] + for r in p.runs: + if size is not None: + r.font.size = Pt(float(size)) + if bold is not None: + r.font.bold = bool(bold) + if italic is not None: + r.font.italic = bool(italic) + if color: + try: + r.font.color.rgb = RGBColor.from_string(str(color).lstrip("#").upper()) + except Exception: + pass + n += 1 + if n: + save_atomic(lambda p: doc.save(p), path) + return n, {} + + if op == "insert_image": + img = a.get("image") or a.get("value") or "" + if not img or not os.path.exists(img): + fail("insert_image 需要 image 参数且文件必须存在: %s" % img, + '示例 {"image":"/path/a.png","width_inch":4}') + w = a.get("width_inch") + try: + if w: + doc.add_picture(img, width=__import__("docx").shared.Inches(float(w))) + else: + doc.add_picture(img) + except Exception as ex: + fail("插入图片失败: %s" % ex, "确认是 png/jpg 等支持格式") + save_atomic(lambda p: doc.save(p), path) + return 1, {} + + if op == "merge_cells": + table_idx = int(a.get("table", 0)) + r1, c1 = int(a.get("row", 1)), int(a.get("col", 1)) + r2 = int(a.get("row2", r1)) + c2 = int(a.get("col2", c1)) + if table_idx >= len(doc.tables): + fail("表格序号越界: %d(文档共 %d 张表)" % (table_idx, len(doc.tables)), + "table 从 0 开始") + t = doc.tables[table_idx] + try: + cell_a = t.cell(r1 - 1, c1 - 1) + cell_b = t.cell(r2 - 1, c2 - 1) + cell_a.merge(cell_b) + except Exception as ex: + fail("合并单元格失败: %s" % ex, "检查 row/col/row2/col2 是否越界") + save_atomic(lambda p: doc.save(p), path) + return 1, {} + + fail("docx 不支持该操作: %s" % op, + "docx 支持: replace_text, insert_paragraph, append_paragraph, set_style, " + "insert_image, insert_table, merge_cells") + + +def edit_xlsx(path, op, a): + import openpyxl + from openpyxl.styles import Font, Alignment, PatternFill + + wb = openpyxl.load_workbook(path) + sheet = a.get("sheet") + ws = wb[sheet] if (sheet and sheet in wb.sheetnames) else wb.active + target = a.get("target", "") + repl = a.get("replacement", "") + cs = bool(a.get("case_sensitive", True)) + rx = bool(a.get("regex", False)) + nth = a.get("nth") + + if op == "replace_text": + if not target: + fail("replace_text 需要 target 参数", "传入要查找的文本") + count = 0 + sheets = [wb[sheet]] if (sheet and sheet in wb.sheetnames) else wb.worksheets + for s in sheets: + for row in s.iter_rows(): + for c in row: + if isinstance(c.value, str): + new, n = apply_text(c.value, target, repl, cs, rx, nth) + if n: + count += n + c.value = new + if count: + save_atomic(lambda p: wb.save(p), path) + return count, {} + + if op == "set_cell": + if a.get("row") is None or a.get("col") is None: + fail("set_cell 需要 row / col 参数", '示例 {"row":1,"col":1,"value":"x"}') + ws.cell(row=int(a["row"]), column=int(a["col"]), value=a.get("value", "")) + save_atomic(lambda p: wb.save(p), path) + return 1, {} + + if op == "insert_row": + if a.get("row") is None: + fail("insert_row 需要 row 参数", '示例 {"row":2}(在第 2 行前插入)') + ws.insert_rows(int(a["row"])) + save_atomic(lambda p: wb.save(p), path) + return 1, {} + + if op == "delete_row": + if a.get("row") is None: + fail("delete_row 需要 row 参数", '示例 {"row":3}') + n = int(a.get("count") or 1) + ws.delete_rows(int(a["row"]), n) + save_atomic(lambda p: wb.save(p), path) + return n, {} + + if op == "add_sheet": + name = a.get("value") or a.get("name") or "" + if not name: + fail("add_sheet 需要 value(新表名)", '示例 {"value":"Sheet2"}') + if name in wb.sheetnames: + fail("工作表已存在: %s" % name, "换一个名字") + wb.create_sheet(title=name) + save_atomic(lambda p: wb.save(p), path) + return 1, {"sheetnames": wb.sheetnames} + + if op == "delete_sheet": + name = a.get("value") or a.get("name") or sheet + if not name or name not in wb.sheetnames: + fail("要删除的工作表不存在: %s" % name, + "现有: " + ", ".join(wb.sheetnames)) + if len(wb.sheetnames) <= 1: + fail("不能删除最后一张工作表", "Excel 要求至少保留一张表") + del wb[name] + save_atomic(lambda p: wb.save(p), path) + return 1, {"sheetnames": wb.sheetnames} + + if op == "rename_sheet": + old = sheet or a.get("old_name") + new = a.get("value") or a.get("new_name") + if not old or old not in wb.sheetnames: + fail("原工作表不存在: %s" % old, "现有: " + ", ".join(wb.sheetnames)) + if not new: + fail("rename_sheet 需要 value(新名字)", '示例 {"sheet":"Sheet1","value":"汇总"}') + wb[old].title = new + save_atomic(lambda p: wb.save(p), path) + return 1, {"sheetnames": wb.sheetnames} + + if op == "set_style": + row = a.get("row") + col = a.get("col") + if row is None or col is None: + fail("xlsx set_style 需要 row/col", '示例 {"row":1,"col":1,"bold":true}') + cell = ws.cell(row=int(row), column=int(col)) + if a.get("bold") is not None: + cell.font = Font(bold=bool(a["bold"]), size=float(a["size"]) if a.get("size") else cell.font.size, + color=str(a["color"]).lstrip("#").upper() if a.get("color") else cell.font.color) + elif a.get("size") is not None: + cell.font = Font(bold=cell.font.bold, size=float(a["size"])) + elif a.get("color"): + cell.font = Font(bold=cell.font.bold, size=cell.font.size, + color=str(a["color"]).lstrip("#").upper()) + if a.get("align"): + cell.alignment = Alignment(horizontal=str(a["align"])) + if a.get("bg_color"): + cell.fill = PatternFill(start_color=str(a["bg_color"]).lstrip("#").upper(), + end_color=str(a["bg_color"]).lstrip("#").upper(), fill_type="solid") + save_atomic(lambda p: wb.save(p), path) + return 1, {} + + if op == "merge_cells": + r1, c1 = int(a.get("row", 1)), int(a.get("col", 1)) + r2 = int(a.get("row2", r1)) + c2 = int(a.get("col2", c1)) + ws.merge_cells(start_row=r1, start_column=c1, end_row=r2, end_column=c2) + save_atomic(lambda p: wb.save(p), path) + return 1, {} + + if op == "insert_row" and a.get("row") is None: + fail("insert_row 需要 row 参数", "") + + fail("xlsx 不支持该操作: %s" % op, + "xlsx 支持: replace_text, set_cell, insert_row, delete_row, set_style, " + "merge_cells, add_sheet, delete_sheet, rename_sheet") + + +def edit_pptx(path, op, a): + from pptx import Presentation + from pptx.util import Pt, Inches + + prs = Presentation(path) + target = a.get("target", "") + repl = a.get("replacement", "") + cs = bool(a.get("case_sensitive", True)) + rx = bool(a.get("regex", False)) + nth = a.get("nth") + + def text_frames(): + for slide in prs.slides: + for sh in slide.shapes: + if getattr(sh, "has_text_frame", False) and sh.has_text_frame: + yield sh.text_frame + if getattr(sh, "has_table", False) and sh.has_table: + for row in sh.table.rows: + for c in row.cells: + yield c.text_frame + + if op == "replace_text": + if not target: + fail("replace_text 需要 target 参数", "传入要查找的文本") + count = 0 + for tf in text_frames(): + for para in tf.paragraphs: + new, n = apply_text(para.text, target, repl, cs, rx, nth) + if n: + count += n + if para.runs: + para.runs[0].text = new + for r in para.runs[1:]: + r.text = "" + else: + para.text = new + if count: + save_atomic(lambda p: prs.save(p), path) + return count, {} + + if op == "append_paragraph": + text = a.get("value") or a.get("text") or "" + slide_no = int(a.get("slide") or 1) + slides = list(prs.slides) + if slide_no < 1 or slide_no > len(slides): + fail("slide 越界: %d(共 %d 页)" % (slide_no, len(slides)), + "slide 从 1 开始") + slide = slides[slide_no - 1] + # 追加到一个新文本框 + from pptx.util import Emu + left = Inches(0.5) + top = Inches(1.5) + width = prs.slide_width - Inches(1) + height = Inches(1) + tb = slide.shapes.add_textbox(left, top, width, height) + tb.text_frame.text = text + save_atomic(lambda p: prs.save(p), path) + return 1, {} + + if op == "set_cell": + if a.get("row") is None or a.get("col") is None: + fail("set_cell 需要 row / col 参数", "row/col 从 1 开始,作用于第一张表") + for slide in prs.slides: + for sh in slide.shapes: + if getattr(sh, "has_table", False) and sh.has_table: + cell = sh.table.cell(int(a["row"]) - 1, int(a["col"]) - 1) + tf = cell.text_frame + if tf.paragraphs and tf.paragraphs[0].runs: + tf.paragraphs[0].runs[0].text = a.get("value", "") + for r in tf.paragraphs[0].runs[1:]: + r.text = "" + else: + tf.text = a.get("value", "") + save_atomic(lambda p: prs.save(p), path) + return 1, {} + fail("pptx 中未找到表格", "set_cell 需要页面里已有表格") + + if op == "insert_row": + # 语义降级:python-pptx 无插入行 API,这里是「在表格末尾追加一行」 + from copy import deepcopy + tbl = None + for slide in prs.slides: + for sh in slide.shapes: + if getattr(sh, "has_table", False) and sh.has_table: + tbl = sh.table + break + if tbl is not None: + break + if tbl is None: + fail("pptx 中未找到表格", "insert_row 需要页面里已有表格") + tr = deepcopy(tbl._tbl.tr_lst[-1]) + tbl._tbl.append(tr) + for tc in tr.tc_lst: + for p in tc.findall('.//{http://schemas.openxmlformats.org/drawingml/2006/main}p'): + for r in p.findall('{http://schemas.openxmlformats.org/drawingml/2006/main}r'): + p.remove(r) + save_atomic(lambda p: prs.save(p), path) + return 1, {"degraded": "pptx 无插入行 API,已降级为在表格末尾追加一行"} + + if op == "insert_image": + img = a.get("image") or a.get("value") or "" + if not img or not os.path.exists(img): + fail("insert_image 需要 image 参数且文件必须存在: %s" % img, + '示例 {"image":"/path/a.png","slide":1}') + slide_no = int(a.get("slide") or 1) + slides = list(prs.slides) + if slide_no < 1 or slide_no > len(slides): + fail("slide 越界: %d(共 %d 页)" % (slide_no, len(slides)), "slide 从 1 开始") + slides[slide_no - 1].shapes.add_picture( + img, Inches(float(a.get("left_inch", 1))), Inches(float(a.get("top_inch", 1))), + width=Inches(float(a["width_inch"])) if a.get("width_inch") else None) + save_atomic(lambda p: prs.save(p), path) + return 1, {} + + if op in ("merge_cells", "set_style"): + fail("pptx 不支持 %s" % op, + "merge_cells 仅支持 xlsx/docx;pptx 表格无合并 API", + unsupported_op=op, format="pptx") + + fail("pptx 不支持该操作: %s" % op, + "pptx 支持: replace_text, append_paragraph, set_cell, insert_row(追加行), insert_image") + + +def op_edit(a): + path = a.get("file") or "" + if not path: + fail("edit 需要 file 参数", "传入要编辑的文件绝对路径") + op = a.get("operation") or "" + if not op: + fail("edit 需要 operation 参数", + "支持: replace_text, set_cell, insert_row, delete_row, " + "insert_paragraph, append_paragraph, set_style, insert_image, " + "insert_table, merge_cells, add_sheet, delete_sheet, rename_sheet") + if not os.path.exists(path): + fail("文件不存在: %s" % path, + "编辑只能作用于已存在文件;要新建请用 create_document", + path=path, exists=False) + if a.get("dry_run"): + emit({"ok": True, "dry_run": True, "file": path, "operation": op, + "summary": "dry_run:未做任何修改"}) + + backup_path = backup(path) + e = ext_of(path) + try: + if e == "docx": + count, extra = edit_docx(path, op, a) + elif e in ("xlsx", "xlsm"): + count, extra = edit_xlsx(path, op, a) + elif e == "pptx": + count, extra = edit_pptx(path, op, a) + else: + fail("不支持编辑的格式: .%s" % e, + "支持: .docx / .xlsx / .xlsm / .pptx") + except SystemExit: + raise + except Exception as ex: + fail("%s: %s" % (type(ex).__name__, ex), + "检查 operation 与参数组合是否匹配") + + r = {"ok": True, "file": path, "operation": op, "count": count, + "backup": backup_path} + r.update(extra) + msg = "编辑完成,已保存到原文件: %s" % path + if count: + msg += "\n共处理 %d 处" % count + else: + msg += "\n未匹配到任何内容(0 处)" + if extra.get("degraded"): + msg += "\n注意: " + extra["degraded"] + r["summary"] = msg + r["content"] = msg # 兼容 v1 调用方读取 content + emit(r) + + +# ---------------------------------------------------------------- convert + +def op_convert(a): + src = a.get("file") or "" + to = (a.get("to") or "").lower().lstrip(".") + if not src or not to: + fail("convert 需要 file 与 to 参数", + '示例: {"file":"/p/a.md","to":"docx"}') + if not os.path.exists(src): + fail("源文件不存在: %s" % src, "检查路径") + dst = a.get("output") or (os.path.splitext(src)[0] + "." + to) + if os.path.exists(dst) and not a.get("overwrite"): + fail("输出文件已存在: %s" % dst, "传 overwrite=true 覆盖") + ensure_dir(dst) + se, de = ext_of(src), to + + tool = None + if se == "md" and de in ("docx", "html", "pdf", "pptx"): + tool = "pandoc" + elif de in ("pdf",) or (se in ("docx", "xlsx", "pptx") and de in ("csv", "txt", "html")): + tool = "soffice" + + # xlsx → csv 用 openpyxl 更干净 + if se in ("xlsx", "xlsm") and de == "csv": + require_dep("openpyxl", "openpyxl", "Excel 转换", "xlsx") + import openpyxl + import csv as _csv + wb = openpyxl.load_workbook(src, data_only=True) + sheet = a.get("sheet") + ws = wb[sheet] if (sheet and sheet in wb.sheetnames) else wb.active + # 多表时按 表名.csv 输出 + if not sheet and len(wb.sheetnames) > 1 and not a.get("output"): + outs = [] + base = os.path.splitext(src)[0] + for name in wb.sheetnames: + p = "%s.%s.csv" % (base, name) + with open(p, "w", encoding="utf-8", newline="") as f: + w = _csv.writer(f) + for row in wb[name].iter_rows(values_only=True): + w.writerow(["" if v is None else v for v in row]) + outs.append(p) + emit({"ok": True, "file": src, "to": "csv", "outputs": outs, + "summary": "已导出 %d 张工作表为 CSV: %s" % (len(outs), ", ".join(outs))}) + with open(dst, "w", encoding="utf-8", newline="") as f: + w = _csv.writer(f) + for row in ws.iter_rows(values_only=True): + w.writerow(["" if v is None else v for v in row]) + emit({"ok": True, "file": src, "output": dst, + "summary": "已导出 %s 的 %s 表到 %s" % (src, ws.title, dst)}) + + # pptx → txt / md 用 python-pptx 直接抽 + if se == "pptx" and de in ("txt", "md"): + require_dep("pptx", "python-pptx", "PPTX 读取", "pptx") + r = read_pptx(src) + lines = [] + for s in r["slides"]: + lines.append("# 第 %d 页" % s["slide"]) + lines.extend(s["texts"]) + for t in s["tables"]: + for row in t: + lines.append(" | ".join(row)) + lines.append("") + with open(dst, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + emit({"ok": True, "file": src, "output": dst, "slides": len(r["slides"]), + "summary": "已抽取 %d 页到 %s" % (len(r["slides"]), dst)}) + + if tool and not which(tool): + fail("缺少外部工具 %s,无法完成 .%s → .%s 转换" % (tool, se, de), + "请安装: sudo apt install %s" % ("pandoc" if tool == "pandoc" else "libreoffice"), + missing=[tool]) + + try: + if tool == "pandoc": + cmd = ["pandoc", src, "-o", dst] + elif tool == "soffice": + outdir = os.path.dirname(os.path.abspath(dst)) or "." + cmd = ["soffice", "--headless", "--convert-to", de, "--outdir", outdir, src] + else: + fail("不支持的转换组合: .%s → .%s" % (se, de), + "支持: md→docx/html/pdf、xlsx→csv、pptx→txt/md、office→pdf") + p = subprocess.run(cmd, capture_output=True, timeout=int(a.get("timeout") or 120)) + if p.returncode != 0: + fail("%s 转换失败(退出码 %d)" % (tool, p.returncode), + (p.stderr or b"").decode("utf-8", "replace")[:300], + cmd=" ".join(cmd)) + except subprocess.TimeoutExpired: + fail("转换超时", "文件过大或 soffice 首次启动较慢,可稍后重试") + + if not os.path.exists(dst): + # soffice 可能改了扩展名 + alt = os.path.splitext(src)[0] + "." + de + if os.path.exists(alt): + dst = alt + else: + fail("转换命令已执行但未找到输出文件", + "检查 %s 是否支持该格式组合" % tool, cmd=" ".join(cmd)) + emit({"ok": True, "file": src, "output": dst, "tool": tool, + "bytes": os.path.getsize(dst), + "summary": "已用 %s 将 %s 转为 %s" % (tool, src, dst)}) + + +# ---------------------------------------------------------------- check + +def op_check(a): + """独立可调用的自检:返回每个库的可用性 + 支持格式矩阵。""" + import sys as _sys + libs = [] + for mod, pipname, purpose in DEPS: + ok = dep_ok(mod) + ver = None + if ok: + try: + m = __import__(mod) + ver = getattr(m, "__version__", None) + if ver is None and hasattr(m, "VERSION"): + ver = ".".join(str(x) for x in m.VERSION) + except Exception: + pass + libs.append({"module": mod, "pip": pipname, "purpose": purpose, + "available": ok, "version": ver, + "install_cmd": None if ok else "%s -m pip install %s" % (_sys.executable, pipname)}) + + bins = [] + for b, purpose in BINS: + p = which(b) + bins.append({"bin": b, "purpose": purpose, "available": bool(p), "path": p, + "install_cmd": None if p else "sudo apt install %s" % b}) + + missing = [l["pip"] for l in libs if not l["available"]] + missing_bins = [b["bin"] for b in bins if not b["available"]] + + # 支持矩阵:按实际依赖算「当前可用」还是「装包后可用」 + matrix = {} + for fmt, ops in FORMATS.items(): + need = {"docx": "python-docx", "xlsx": "openpyxl", "xlsm": "openpyxl", + "pptx": "python-pptx", "xls": "xlrd"}.get(fmt) + usable = True + if need and need in missing: + usable = False + matrix[fmt] = {"operations": ops, "usable_now": usable, + "requires": need or "-"} + + lines = ["依赖自检结果:"] + for l in libs: + lines.append(" %-10s %s %s" % (l["module"], + "OK" if l["available"] else "缺失", + l["version"] or "")) + for b in bins: + lines.append(" %-10s %s %s" % (b["bin"], "OK" if b["available"] else "缺失", + b["path"] or "")) + lines.append("支持格式: " + ", ".join("%s(%s)" % (f, "可用" if m["usable_now"] else "缺依赖") + for f, m in sorted(matrix.items()))) + if missing or missing_bins: + lines.append("缺失项: " + ", ".join(missing + missing_bins)) + for l in libs: + if not l["available"]: + lines.append(" 修复: " + l["install_cmd"]) + for b in bins: + if not b["available"]: + lines.append(" 修复: " + b["install_cmd"]) + else: + lines.append("全部依赖就绪。") + + emit({"ok": True, "all_ready": not (missing or missing_bins), + "python": _sys.version.split()[0], "interpreter": _sys.executable, + "libs": libs, "bins": bins, "formats": matrix, + "missing": missing + missing_bins, + "summary": "\n".join(lines)}) + + +# ---------------------------------------------------------------- main + +COMMANDS = { + "read": op_read, + "create": op_create, + "create_from_template": op_create_from_template, + "edit": op_edit, + "convert": op_convert, + "check": op_check, +} + + +def main(): + if len(sys.argv) < 2: + fail("用法: edit_doc.py [args_json]", + "command ∈ " + ", ".join(sorted(COMMANDS))) + cmd = sys.argv[1] + try: + args = json.loads(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].strip() else {} + except Exception as ex: + fail("参数 JSON 解析失败: %s" % ex, "检查 JSON 引号转义") + + fn = COMMANDS.get(cmd) + if fn is None: + fail("未知命令: %s" % cmd, "支持: " + ", ".join(sorted(COMMANDS))) + try: + fn(args) + except SystemExit: + raise + except Exception as ex: + import traceback + fail("%s: %s" % (type(ex).__name__, ex), + traceback.format_exc()[-400:]) + + +if __name__ == "__main__": + main() diff --git a/example/editdoc/go.mod b/example/editdoc/go.mod index a6f704c..21dc80e 100644 --- a/example/editdoc/go.mod +++ b/example/editdoc/go.mod @@ -2,6 +2,18 @@ module editdoc go 1.25.0 -require github.com/JianFeeeee/homeagentsdk v0.0.0 +require github.com/JianFeeeee/homeagentsdk v1.4.0 -replace github.com/JianFeeeee/homeagentsdk => ../../ + + + + + + + + + + + + +replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0 diff --git a/example/editdoc/main.go b/example/editdoc/main.go index 8d53937..202b3d3 100644 --- a/example/editdoc/main.go +++ b/example/editdoc/main.go @@ -1,5 +1,3 @@ -//go:build !windows || !cgo - package main import ( diff --git a/example/editdoc/plg.json b/example/editdoc/plg.json index c96fa43..3121bbb 100644 --- a/example/editdoc/plg.json +++ b/example/editdoc/plg.json @@ -1,15 +1,23 @@ { "name": "editdoc", - "name_zh": "文档编辑", + "name_zh": "全能办公", "name_en": "Document Editor", - "version": "1.0.0", - "description": "办公文档编辑工具(.docx),基于 Python python-docx 库实现。支持文本替换、表格操作、内容插入等。", + "version": "2.0.0", + "description": "全能办公插件:新建/读取/编辑/转换 docx·xlsx·pptx·md·csv·txt(基于 python-docx / openpyxl / python-pptx / soffice / pandoc)", "author": "HomeAgent", - "entry": "plugin.so", - "tags": ["editdoc", "office", "document"], + "entry": "plugin.bin", + "tags": [ + "editdoc", + "office", + "document", + "excel", + "word", + "powerpoint" + ], "targets": "linux/amd64", "outdir": "dist", "bundle": true, "replaces": {}, - "source_dirs": [] -} + "source_dirs": [], + "sdk": "1.4.0" +} \ No newline at end of file diff --git a/example/editdoc/plugin.go b/example/editdoc/plugin.go index 90bde05..ac0be4f 100644 --- a/example/editdoc/plugin.go +++ b/example/editdoc/plugin.go @@ -2,21 +2,60 @@ package main import ( "bytes" + "context" + "crypto/sha256" + _ "embed" + "encoding/hex" "encoding/json" "fmt" - "log" "os" "os/exec" "path/filepath" + "strings" + "time" "github.com/JianFeeeee/homeagentsdk/sdk" ) +// editDocPy 内嵌 Python 后端脚本。 +// +// 为何必须内嵌:hmapdev 的打包器只把 plugin.json + plugin.bin 放进 .hmap +// (加上可选的 README/LICENSE),**不打包任何资源文件**。 +// 而本插件的全部业务逻辑就在 edit_doc.py 里,若让它单独存在于 +// plugin_data/editdoc/,就会出现「Go 侧升级了、脚本忘了换」的版本漂移 +// (mc 插件就踩过这个坑:工具注册了但子脚本还是旧的,报未知命令)。 +// 内嵌 + 内容指纹校验可以让两侧永远同版本。 +// +//go:embed embed/edit_doc.py +var embeddedScript []byte + +// scriptStamp 返回内嵌脚本的内容指纹。 +func scriptStamp() string { + h := sha256.Sum256(embeddedScript) + return hex.EncodeToString(h[:])[:16] +} + +// scriptProtocolMarker 是 edit_doc.py 里的 v2 协议标记(见 embed/edit_doc.py 顶部)。 +// resolveScript 靠它识别「script_path 指向的是不是 v2 脚本」,防止旧脚本被误用。 +const scriptProtocolMarker = `EDITDOC_PROTOCOL = "v2"` + +// Plugin 是 editdoc v2.0.0(全能办公)插件。 +// +// 设计要点(与 v1.0.0 的关系): +// - edit_document 工具名与 file/operation/target/replacement/sheet/row/col/value +// 参数语义**完全保留**,老调用不会破。 +// - 全部实现委托给同目录下的 edit_doc.py,Go 侧只做参数编排 + 结构化返回。 +// - 所有子命令统一走 runScript,错误一律结构化(ok:false + error + hint),不 panic。 type Plugin struct { name string sdk *sdk.PluginSDK scriptPath string venvPython string + timeoutSec int + + // ignoredScriptPath 记录「配置里的 script_path 因不是 v2 脚本而被忽略」的路径。 + // 非空时 runScript 会在返回体里带上 script_path_ignored,让上层看得见这次降级。 + ignoredScriptPath string } func (p *Plugin) Name() string { return p.name } @@ -25,138 +64,469 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.SetAutoRestart(true) p.sdk = s + // ---- 配置项(v1 的 script_path / venv_python 原样保留,新增 timeout_sec)---- s.Settings().RegisterDef(sdk.ConfigDef{ Key: "script_path", Default: "", Type: "string", - DisplayName: "编辑脚本路径", - Description: "edit_doc.py 的绝对路径;留空时使用插件可执行文件同目录下的 edit_doc.py", - Category: p.name, + DisplayName: "办公脚本路径", + Description: "edit_doc.py 的绝对路径;留空(推荐)时使用 DataDir()/runtime/edit_doc.py(升级时按指纹自动更新)。" + + "仅当该文件含 v2 协议标记时才会被采用,否则自动忽略并回退内嵌脚本", + Category: p.name, }) s.Settings().RegisterDef(sdk.ConfigDef{ Key: "venv_python", Default: "", Type: "string", DisplayName: "venv Python 解释器", - Description: "执行 edit_doc.py 使用的 Python 解释器(建议用 venv 内的 python);必须配置,留空将报错", + Description: "执行 edit_doc.py 使用的 Python 解释器(需装有 python-docx/openpyxl/python-pptx);必须配置", Category: p.name, }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "timeout_sec", Default: 60, Type: "int", + DisplayName: "脚本超时(秒)", + Description: "单次调用 edit_doc.py 的超时上限;大文件或格式转换(soffice 首次启动慢)建议调到 180", + Category: p.name, Min: 5, Max: 900, + }) - if v, err := s.Settings().Get("script_path"); err == nil { - if str, ok := v.(string); ok { - p.scriptPath = str - } - } - if v, err := s.Settings().Get("venv_python"); err == nil { - if str, ok := v.(string); ok { - p.venvPython = str - } - } - s.RegisterTool("edit_document", sdk.ToolDef{ - Name: "edit_document", - Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。", - NoMemory: true, + p.reloadSettings() + + // ---- 工具注册 ---- + // 工具名不带插件名前缀:v1 的 edit_document 就是无前缀的,保持一致性。 + + s.RegisterTool("read_document", sdk.ToolDef{ + Name: "read_document", + Description: "读取办公文档内容并返回结构化数据。支持 .docx(段落+表格+页眉页脚)、" + + ".xlsx/.xlsm(逐表逐格,公式与计算值都给)、.pptx(逐页文本框与表格)、.xls、.csv/.md/.txt。" + + "大文件用 offset/limit 分段读。改文件前先用它看内容。", + NoMemory: true, Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ + "file": map[string]interface{}{"type": "string", "description": "文档绝对路径"}, + "sheet": map[string]interface{}{"type": "string", "description": "xlsx:只读某张表(可选)"}, + "offset": map[string]interface{}{"type": "integer", "description": "分段读的起始偏移(docx=块序号, xlsx/pptx=页/表序号),默认 0"}, + "limit": map[string]interface{}{"type": "integer", "description": "本次最多返回多少段,默认全部"}, + "formulas": map[string]interface{}{"type": "boolean", "description": "xlsx 是否附带公式的计算值,默认 true"}, + }, + "required": []string{"file"}, + }, + }, p.handleRead) + + s.RegisterTool("create_document", sdk.ToolDef{ + Name: "create_document", + Description: "从零新建办公文档。支持 .docx(title+content,content 支持 Markdown:# 标题、**粗体**、*斜体*、- 列表)、" + + ".xlsx(sheets 多表:表名+表头+行数据)、.pptx(title+content,## 分页)、.md、.csv、.txt。" + + "目录不存在会自动创建;文件已存在时需传 overwrite=true 才会覆盖(覆盖前自动 .bak 留档)。", + NoMemory: true, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "file": map[string]interface{}{"type": "string", "description": "要创建的文件绝对路径(扩展名决定格式)"}, + "title": map[string]interface{}{"type": "string", "description": "标题(docx/pptx/md)"}, + "subtitle": map[string]interface{}{"type": "string", "description": "副标题(pptx 首页)"}, + "content": map[string]interface{}{"type": "string", "description": "正文内容(docx/md/txt 支持 Markdown;pptx 用 ## 分页)"}, + "sheets": map[string]interface{}{"type": "array", "description": "xlsx 多表:[{name, headers, rows}]", "items": map[string]interface{}{"type": "object"}}, + "headers": map[string]interface{}{"type": "array", "description": "单表模式的表头(xlsx/csv)", "items": map[string]interface{}{"type": "string"}}, + "rows": map[string]interface{}{"type": "array", "description": "单表模式的数据行(xlsx/csv)", "items": map[string]interface{}{"type": "array"}}, + "tables": map[string]interface{}{"type": "array", "description": "docx 追加表格:[{headers, rows}]", "items": map[string]interface{}{"type": "object"}}, + "overwrite": map[string]interface{}{"type": "boolean", "description": "文件已存在时是否覆盖,默认 false(报错)"}, + }, + "required": []string{"file"}, + }, + }, p.handleCreate) + + s.RegisterTool("create_from_template", sdk.ToolDef{ + Name: "create_from_template", + Description: "基于已有文件复制一份再套用替换(模板场景)。先 shutil.copy2 复制,再对 docx/xlsx/文本做文本替换。" + + "replacements 是 {\"旧文本\":\"新文本\"} 映射;也可用 target/replacement 只替换一处。", + NoMemory: true, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "template": map[string]interface{}{"type": "string", "description": "模板文件绝对路径"}, + "file": map[string]interface{}{"type": "string", "description": "新文件绝对路径"}, + "replacements": map[string]interface{}{"type": "object", "description": "替换映射 {\"旧\":\"新\"}"}, + "target": map[string]interface{}{"type": "string", "description": "单处替换的原文本(可选)"}, + "replacement": map[string]interface{}{"type": "string", "description": "单处替换的新文本(可选)"}, + "overwrite": map[string]interface{}{"type": "boolean", "description": "目标已存在时是否覆盖"}, + }, + "required": []string{"template", "file"}, + }, + }, p.handleCreateFromTemplate) + + // ---- 兼容性红线:工具名 + 参数与 v1.0.0 完全一致 ---- + s.RegisterTool("edit_document", sdk.ToolDef{ + Name: "edit_document", + Description: "编辑 Office 文档(会覆盖原文件,编辑前自动 .bak 留档)。支持 .docx / .xlsx / .xlsm / .pptx。" + + "operation 可选:replace_text(查找替换,支持 regex/case_sensitive/nth)、set_cell、insert_row、" + + "delete_row、insert_paragraph、append_paragraph、set_style、" + + "insert_image、insert_table、merge_cells、add_sheet、delete_sheet、rename_sheet。" + + "注意:pptx 无插入行 API,insert_row 在 pptx 上语义为「表格末尾追加一行」;merge_cells 不支持 pptx。", + NoMemory: true, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + // v1 原有 8 个参数,语义不变 "file": map[string]interface{}{"type": "string", "description": "文档文件路径"}, - "operation": map[string]interface{}{"type": "string", "description": "操作: replace_text(查找替换), set_cell(设置单元格), insert_row(插入行)"}, - "target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text)"}, + "operation": map[string]interface{}{"type": "string", "description": "操作(见工具描述)"}, + "target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text);set_style 时为空表示全文"}, "replacement": map[string]interface{}{"type": "string", "description": "替换为的文本(replace_text)"}, - "sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx,可选)"}, - "row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row)"}, + "sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx)"}, + "row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row/delete_row)"}, "col": map[string]interface{}{"type": "integer", "description": "列号(set_cell)"}, - "value": map[string]interface{}{"type": "string", "description": "单元格值(set_cell)"}, + "value": map[string]interface{}{"type": "string", "description": "写入值(set_cell/add_sheet/rename_sheet/段落文本)"}, + // v2 扩展参数 + "regex": map[string]interface{}{"type": "boolean", "description": "target 按正则解释,默认 false"}, + "case_sensitive": map[string]interface{}{"type": "boolean", "description": "是否区分大小写,默认 true"}, + "nth": map[string]interface{}{"type": "integer", "description": "只替换第 N 处(1 基),默认全部替换"}, + "count": map[string]interface{}{"type": "integer", "description": "delete_row 删除几行,默认 1"}, + "index": map[string]interface{}{"type": "integer", "description": "insert_paragraph 的插入位置(段落序号)"}, + "row2": map[string]interface{}{"type": "integer", "description": "merge_cells 的结束行"}, + "col2": map[string]interface{}{"type": "integer", "description": "merge_cells 的结束列"}, + "table": map[string]interface{}{"type": "integer", "description": "docx 表格序号(merge_cells/insert_table),从 0 开始"}, + "headers": map[string]interface{}{"type": "array", "description": "insert_table 的表头", "items": map[string]interface{}{"type": "string"}}, + "rows": map[string]interface{}{"type": "array", "description": "insert_table 的数据行", "items": map[string]interface{}{"type": "array"}}, + "image": map[string]interface{}{"type": "string", "description": "insert_image 的图片路径"}, + "width_inch": map[string]interface{}{"type": "number", "description": "insert_image 的宽度(英寸)"}, + "slide": map[string]interface{}{"type": "integer", "description": "pptx 页号(从 1 开始)"}, + "size": map[string]interface{}{"type": "number", "description": "set_style 字号(磅)"}, + "bold": map[string]interface{}{"type": "boolean", "description": "set_style 加粗"}, + "italic": map[string]interface{}{"type": "boolean", "description": "set_style 斜体"}, + "color": map[string]interface{}{"type": "string", "description": "set_style 字体颜色(如 FF0000)"}, + "bg_color": map[string]interface{}{"type": "string", "description": "xlsx set_style 背景色"}, + "align": map[string]interface{}{"type": "string", "description": "set_style 对齐:left/center/right/justify"}, + "dry_run": map[string]interface{}{"type": "boolean", "description": "只校验不改动"}, }, "required": []string{"file", "operation"}, }, - }, p.handleEditDocument) + }, p.handleEdit) + + s.RegisterTool("convert_document", sdk.ToolDef{ + Name: "convert_document", + Description: "办公文档格式转换。md→docx/html/pdf(pandoc)、xlsx→csv(openpyxl,多表自动分文件)、" + + "pptx→txt/md(结构化抽取)、office→pdf(soffice)。缺外部依赖时会在返回里点名缺哪个,不静默失败。", + NoMemory: true, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "file": map[string]interface{}{"type": "string", "description": "源文件绝对路径"}, + "to": map[string]interface{}{"type": "string", "description": "目标格式:docx / pdf / html / csv / txt / md"}, + "output": map[string]interface{}{"type": "string", "description": "输出路径(可选,默认同名换扩展名)"}, + "sheet": map[string]interface{}{"type": "string", "description": "xlsx→csv 时指定工作表(可选,默认全部)"}, + "overwrite": map[string]interface{}{"type": "boolean", "description": "输出已存在时覆盖"}, + "timeout": map[string]interface{}{"type": "integer", "description": "转换超时(秒),默认 120"}, + }, + "required": []string{"file", "to"}, + }, + }, p.handleConvert) + + s.RegisterTool("office_check", sdk.ToolDef{ + Name: "office_check", + Description: "办公能力自检(独立可调用,不依赖真实文件)。返回:Python 解释器版本、每个依赖库" + + "(python-docx/openpyxl/python-pptx/xlrd/markdown)的可用性与版本、外部工具(soffice/pandoc)、" + + "以及格式×能力矩阵。换机器或升级插件后可用它一条命令验收。", + NoMemory: true, + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{}, + }, + }, p.handleCheck) + + // 首次启动就把脚本释放好,让 office_check 立刻可用(也便于运维确认落盘位置) + if sp, err := p.resolveScript(); err == nil { + fmt.Fprintf(os.Stderr, "[%s] 已就绪(脚本: %s, 指纹: %s)\n", p.name, sp, scriptStamp()) + if p.ignoredScriptPath != "" { + fmt.Fprintf(os.Stderr, "[%s] 注意:script_path=%s 已被忽略(非 v2 脚本),当前使用内嵌脚本\n", + p.name, p.ignoredScriptPath) + } + } else { + fmt.Fprintf(os.Stderr, "[%s] 脚本释放失败: %v\n", p.name, err) + } + return nil } func (p *Plugin) Stop() error { return nil } -func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, error) { - file, _ := args["file"].(string) - if file == "" { - return nil, fmt.Errorf("file is required") +func (p *Plugin) reloadSettings() { + s := p.sdk.Settings() + if v, err := s.Get("script_path"); err == nil { + if str, ok := v.(string); ok { + p.scriptPath = strings.TrimSpace(str) + } } - operation, _ := args["operation"].(string) - if operation == "" { - return nil, fmt.Errorf("operation is required") + if v, err := s.Get("venv_python"); err == nil { + if str, ok := v.(string); ok { + p.venvPython = strings.TrimSpace(str) + } } - if _, err := os.Stat(file); os.IsNotExist(err) { - return map[string]interface{}{ - "content": fmt.Sprintf("文件不存在: %s", file), - }, nil + p.timeoutSec = 60 + if v, err := s.Get("timeout_sec"); err == nil { + switch n := v.(type) { + case float64: + if n > 0 { + p.timeoutSec = int(n) + } + case int64: + if n > 0 { + p.timeoutSec = int(n) + } + case int: + if n > 0 { + p.timeoutSec = n + } + } } - - pyArgs := map[string]interface{}{} - if v, ok := args["target"]; ok { - pyArgs["target"] = v - } - if v, ok := args["replacement"]; ok { - pyArgs["replacement"] = v - } - if v, ok := args["sheet"]; ok { - pyArgs["sheet"] = v - } - if v, ok := args["row"]; ok { - pyArgs["row"] = v - } - if v, ok := args["col"]; ok { - pyArgs["col"] = v - } - if v, ok := args["value"]; ok { - pyArgs["value"] = v - } - pyArgsJSON, _ := json.Marshal(pyArgs) - - scriptPath := p.scriptPath - if scriptPath == "" { - scriptPath = filepath.Join(filepath.Dir(os.Args[0]), "edit_doc.py") - log.Printf("[%s] script_path 未配置,使用默认脚本路径: %s", p.name, scriptPath) - } - if _, err := os.Stat(scriptPath); os.IsNotExist(err) { - return nil, fmt.Errorf("edit_doc.py not found at %s(请在插件配置 script_path 中指定脚本路径)", scriptPath) - } - - if p.venvPython == "" { - return nil, fmt.Errorf("venv_python 未配置,无法执行脚本;请在插件配置中设置 venv_python(venv 内 python 的绝对路径)") - } - if _, err := os.Stat(p.venvPython); err != nil { - return nil, fmt.Errorf("venv python 不存在: %s(请检查 venv_python 配置)", p.venvPython) - } - - var out bytes.Buffer - cmd := exec.Command(p.venvPython, scriptPath, file, operation, string(pyArgsJSON)) - cmd.Stdout = &out - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("edit document: %w", err) - } - - var result struct { - Ok bool `json:"ok"` - Error string `json:"error,omitempty"` - Count int `json:"count,omitempty"` - } - if err := json.Unmarshal(out.Bytes(), &result); err != nil { - return map[string]interface{}{ - "content": fmt.Sprintf("编辑完成,输出: %s", out.String()), - "file": file, - }, nil - } - if !result.Ok { - return map[string]interface{}{ - "content": fmt.Sprintf("编辑失败: %s", result.Error), - }, nil - } - msg := fmt.Sprintf("编辑完成,已保存到原文件: %s", file) - if result.Count > 0 { - msg += fmt.Sprintf("\n共处理 %d 处", result.Count) - } - return map[string]interface{}{ - "content": msg, - "file": file, - }, nil } +// resolveScript 返回 edit_doc.py 的可用路径。 +// +// 优先级: +// 1. 配置 script_path 指向的文件,且**含 v2 协议标记**(便于运维手动覆盖调试) +// 2. DataDir()/runtime/edit_doc.py(内嵌脚本释放位置,指纹驱动的自动更新) +// +// 关键防呆:script_path 指向的文件若存在但**不含** v2 协议标记,说明它是旧版脚本 +// (v1 的 argv 约定是 ,与 v2 的 完全不同)。 +// 若照样采用,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 ,返回解析后的 map。 +// +// 统一错误处理:脚本缺失/解释器缺失/超时/非 JSON 输出,都转成带 hint 的结构化返回, +// 而不是抛裸 error(v1 是直接 return nil, err,上层只能看到一句无上下文的失败)。 +func (p *Plugin) runScript(cmd string, args map[string]interface{}) (map[string]interface{}, error) { + p.reloadSettings() // 支持改配置后无需重载插件 + + scriptPath, err := p.resolveScript() + if err != nil { + return nil, fmt.Errorf("%s;请在插件配置 script_path 指定 edit_doc.py 的绝对路径", err) + } + py, err := p.resolvePython() + if err != nil { + return nil, fmt.Errorf("%s;请在插件配置 venv_python 指定 python 解释器", err) + } + + argsJSON, err := json.Marshal(args) + if err != nil { + return nil, fmt.Errorf("参数序列化失败: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), + time.Duration(p.timeoutSec)*time.Second) + defer cancel() + + var stdout, stderr bytes.Buffer + c := exec.CommandContext(ctx, py, scriptPath, cmd, string(argsJSON)) + c.Stdout = &stdout + c.Stderr = &stderr + runErr := c.Run() + + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("脚本执行超时(%d 秒);大文件或格式转换可调高 timeout_sec 配置", + p.timeoutSec) + } + + out := strings.TrimSpace(stdout.String()) + if out == "" { + msg := strings.TrimSpace(stderr.String()) + if runErr != nil { + return nil, fmt.Errorf("脚本无输出且退出异常: %v; stderr: %s", runErr, truncate(msg, 300)) + } + return nil, fmt.Errorf("脚本无输出(stderr: %s)", truncate(msg, 300)) + } + // 脚本只保证最后一行是 JSON(soffice/pandoc 可能往 stdout 混东西) + line := out + if i := strings.LastIndex(out, "\n"); i >= 0 { + line = strings.TrimSpace(out[i+1:]) + } + + var m map[string]interface{} + if err := json.Unmarshal([]byte(line), &m); err != nil { + return nil, fmt.Errorf("脚本输出不是合法 JSON: %s; 原文: %s", + err, truncate(out, 300)) + } + if se := strings.TrimSpace(stderr.String()); se != "" && runErr != nil { + m["stderr"] = truncate(se, 300) + } + // script_path 因非 v2 脚本被忽略时,把这次降级明确告知上层 + if p.ignoredScriptPath != "" { + m["script_path_ignored"] = p.ignoredScriptPath + m["script_path_ignored_reason"] = "该文件缺少 v2 协议标记,判定为旧版脚本;本次已改用内嵌脚本" + } + return m, nil +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + +// respond 把脚本返回整形成工具返回值:ok:true 时给 content/summary, +// ok:false 时给 isError + error + hint。 +func respondOffice(m map[string]interface{}) (interface{}, error) { + if ok, _ := m["ok"].(bool); !ok { + out := map[string]interface{}{ + "isError": true, + "content": "操作失败: " + str(m["error"]), + "error": m["error"], + "hint": m["hint"], + } + for _, k := range []string{"path", "exists", "missing", "format", "cmd", + "script_path_ignored", "script_path_ignored_reason"} { + if v, has := m[k]; has { + out[k] = v + } + } + return out, nil + } + // 成功:把脚本的结构化字段原样上抛,便于 LLM 二次利用 + out := map[string]interface{}{} + for k, v := range m { + out[k] = v + } + if s, has := m["summary"]; has { + out["content"] = s + } + return out, nil +} + +func str(v interface{}) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + return fmt.Sprintf("%v", v) +} + +// ---------------------------------------------------------------- handlers + +func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) { + m, err := p.runScript("read", args) + if err != nil { + return toolErr("read_document", err), nil + } + return respondOffice(m) +} + +func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) { + m, err := p.runScript("create", args) + if err != nil { + return toolErr("create_document", err), nil + } + return respondOffice(m) +} + +func (p *Plugin) handleCreateFromTemplate(args map[string]interface{}) (interface{}, error) { + m, err := p.runScript("create_from_template", args) + if err != nil { + return toolErr("create_from_template", err), nil + } + return respondOffice(m) +} + +func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) { + // v1 兼容:file / operation 必填 + if str(args["file"]) == "" { + return map[string]interface{}{"isError": true, "content": "edit_document 需要 file 参数"}, nil + } + if str(args["operation"]) == "" { + return map[string]interface{}{"isError": true, "content": "edit_document 需要 operation 参数"}, nil + } + m, err := p.runScript("edit", args) + if err != nil { + return toolErr("edit_document", err), nil + } + return respondOffice(m) +} + +func (p *Plugin) handleConvert(args map[string]interface{}) (interface{}, error) { + m, err := p.runScript("convert", args) + if err != nil { + return toolErr("convert_document", err), nil + } + return respondOffice(m) +} + +func (p *Plugin) handleCheck(args map[string]interface{}) (interface{}, error) { + m, err := p.runScript("check", map[string]interface{}{}) + if err != nil { + return toolErr("office_check", err), nil + } + return respondOffice(m) +} + +func toolErr(tool string, err error) map[string]interface{} { + return map[string]interface{}{ + "isError": true, + "content": fmt.Sprintf("%s 失败:%s", tool, err.Error()), + "error": err.Error(), + } +} func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { return &Plugin{name: name}, nil diff --git a/example/editdoc/plugin_test.go b/example/editdoc/plugin_test.go new file mode 100644 index 0000000..55a5878 --- /dev/null +++ b/example/editdoc/plugin_test.go @@ -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 约定 ,且没有 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 +} diff --git a/example/qq/plugin.go b/example/qq/plugin.go index aaee68a..69408a8 100644 --- a/example/qq/plugin.go +++ b/example/qq/plugin.go @@ -159,19 +159,19 @@ const ( ) type Plugin struct { - name string - sdk *sdk.PluginSDK - listenAddr string - napcatURL string - remoteDir string - filesDir string - webhookToken string - ownerIDs []int64 - botID int64 - botNickname string - dmPolicy string - groupPolicy string - httpClient *http.Client + name string + sdk *sdk.PluginSDK + listenAddr string + napcatURL string + remoteDir string + filesDir string + webhookToken string + ownerIDs []int64 + botID int64 + botNickname string + dmPolicy string + groupPolicy string + httpClient *http.Client // sendClient 专用于**发送类**端点(send_private_msg / send_group_msg)。 // // ★ 为何与查询类分开(2026-10-13 实测):QQ 发送要经过 NapCat 的 @@ -188,7 +188,7 @@ type Plugin struct { // // 发送超时给足 120s(与插件内 TTS/下载同量级);查询类保持 30s // (它们是幂等的,超时就是真超时,快速失败更有利于排查)。 - sendClient *http.Client + sendClient *http.Client allowFrom map[int64]struct{} groupAllowFrom map[int64]struct{} srv *http.Server @@ -1302,6 +1302,36 @@ func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, q return matchesToolAllowlist(name, patterns), auth } +// extraKeyComeFrom 是内核写在帧上的「来源标识」键。 +// +// 契约(见内核 internal/agent/core/comefrom.go): +// +// "user" 用户请求在边界上发起(默认语义,本插件的权限门只应管这类) +// "internal" agent 体系内部发起(驻留子/转投/自主续跑)⇒ 不走用户权限模型 +// 缺失 旧内核 ⇒ 按 "user" 处理,**行为必须与改动前逐字一致** +// +// 为什么用 Extra 而不是具名字段:内核用 Extra 的通用袋跨进程透传插件自建键 +// (proc 桥的 fExtraBag),与既有 `input_source` / `qq_auth` 同一读法。 +const extraKeyComeFrom = "comefrom" + +// isInternalToolCall 报告本帧的工具调用是否由 agent 内部委派发起。 +// +// ★ 保守默认:**只有明确读到 "internal" 才算内部**。 +// 缺失、空串、类型不对、未知取值 —— 全部当作 "user"(旧行为)。 +// 这个方向不能反:把用户请求误判成 internal 就是**权限门整体失效**, +// 那比"多拦一次内部调用"严重得多。 +func isInternalToolCall(ctx *sdk.StageContext) bool { + if ctx == nil || ctx.Extra == nil { + return false + } + v, ok := ctx.Extra[extraKeyComeFrom] + if !ok { + return false + } + s, _ := v.(string) + return s == "internal" +} + // isAtBot checks if the message contains an @-mention of the bot. func (p *Plugin) isAtBot(msg interface{}) bool { segments, ok := msg.([]interface{}) @@ -1391,6 +1421,37 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error { if len(ctx.ToolCalls) == 0 { return nil } + // ★ **内部委派的工具调用不走用户权限模型**(2026-10-16)。 + // + // 内核在帧上写了 `comefrom`: + // "user" = 用户请求在边界上发起(**本插件要管的就是这类**) + // "internal" = agent 体系内部发起(驻留子/转投/自主续跑) + // 缺失 = 旧内核,按 "user" 处理(保持既有行为,勿改) + // + // ## 为何这是正确的(实测故障 + 语义) + // + // 现场(2026-10-16):驻留子 `dev-br-jianf-NextAgent-fix_abs-path` 自报 running + // 但**零产出**,它那一轮 8 个工具(cmd_run/files_ls/notify_parent/…)全被 + // 本门拦下。日志实证:每个工具调用前都有 + // `[proc] qq stage before_toolcall 改写了 1 个字段`(= 写入了拒绝响应)。 + // + // 根因:驻留子的输入**继承自父**(转投刻意保留原事件,否则同步调用方永久 + // 挂起),于是 `input_source` 仍是 "qq" ⇒ 本门把驻留子当成"那位 QQ 用户 + // 在提请求" ⇒ 按用户白名单判 ⇒ 开发工具一条都不在白名单里 ⇒ 全拒。 + // + // 但用户请求**已经过了这道门**(父按真实身份判定过)。驻留子做的是 + // **父自己的委派决定**;对已经准入的工作再查一次"用户白名单",既拦掉合法 + // 工作、又零安全增益(同一次请求,父已判定)。 + // + // 为何不"只跳白名单、仍查硬私有":硬私有(calendar_/email_/memory_…) + // 防的是用户偷看私人数据;驻留子是父的内部执行体,父对它本就有完整 + // 权限 —— 再查一遍等于"机器人自己派出去的活被自己拦住",同样荒谬。 + // + // 为何放在 `auth.Active` 判断**之前**:internal 与"非本插件来源"在语义上 + // 是同一类(都不是"一条 QQ 用户消息"),走同一条放行路径最不容易出错。 + if isInternalToolCall(ctx) { + return nil + } tc := &ctx.ToolCalls[0] allowed, auth := p.currentToolAllowed(ctx, tc.Name) if !auth.Active { diff --git a/example/qq/plugin_test.go b/example/qq/plugin_test.go index 611b891..12b7920 100644 --- a/example/qq/plugin_test.go +++ b/example/qq/plugin_test.go @@ -725,6 +725,7 @@ func TestNapcatConvertsOneBotFailureToError(t *testing.T) { // 与我修过的 HTTP 客户端超时是**两条不同路径**: // - HTTP 超时:client.Post 返回 err(已有告警) // - NapCat 超时:HTTP 200 + 响应体里带 NTEvent 字样(本次才补) +// // 只报警「失败」而不说「可能已送出」,模型必重发。 // // 判据必须穿过 napcat() 真实调用链(单测 isNapcatSendUnconfirmed 测不出 @@ -816,3 +817,151 @@ func TestNapcatSendActionTimeoutRegardlessOfServiceName(t *testing.T) { t.Errorf("服务名不是 sendMsg 的 NTEvent 超时也要套用发送超时语义,实为 %v", err) } } + +// =========================================================================== +// 「来源标识」(comefrom)与用户权限门的关系(2026-10-16) +// +// 现场故障:驻留子 `dev-br-jianf-NextAgent-fix_abs-path` 自报 running 但 +// **零产出** —— 它那一轮 8 个工具(cmd_run / files_ls / notify_parent / +// output_send__commondev_* / inputch_note …)**全部**被本插件的 +// before_toolcall 拦下。它自己的处理表原文: +// +// 本轮所有需要的工具都被权限策略挡下了,实测被拒的有: +// - cmd_run —— 无法进入工作目录、无法看 git 状态 +// +// 日志实证(09:21:25~09:21:40,每个工具调用前都紧跟): +// +// [agent] executing tool: cmd_run (plugin=cmd) +// [proc] qq stage before_toolcall 改写了 1 个字段 ← 写入了拒绝响应 +// +// 而同一时段父 main 的工具调用**有 result**、驻留子的**零 result**。 +// +// 根因:驻留子的输入继承自父(转投刻意保留原事件),`input_source` 仍是 +// "qq",于是本门把“父的内部委派”误当成“那位 QQ 用户新提的请求”, +// 按用户白名单判定 ⇒ 开发工具一条都不在白名单里 ⇒ 全拒。 +// =========================================================================== + +// ★★★ **核心回归判据**:内部委派的工具调用**不得**被用户权限门拦。 +// +// 这条直接覆盖现场故障:用同一份身份(非 owner)、同一个被拒工具(cmd_run), +// 只改 comefrom 一个键 —— internal 必须放行、user 必须拦。 +func TestInternalToolCallBypassesUserPermissionGate(t *testing.T) { + // 基准:非 owner 私聊 ⇒ cmd_run 不在白名单 ⇒ 必须被拦 + base := func() *sdk.StageContext { + ctx := toolCallContext("cmd_run", nil) + ctx.Extra = map[string]interface{}{} + return ctx + } + p := newPermissionTestPlugin(t) + p.auth = qqAuthContext{Active: true, UserID: 2198972886} + ctxUser := base() + if err := p.beforeToolcall(ctxUser); err != nil { + t.Fatal(err) + } + if ctxUser.Response == nil { + t.Fatal("前置条件错:非 owner 调 cmd_run 本应被拦(判据基线不成立)") + } + + // ① comefrom=internal(驻留子)⇒ 必须放行 + p2 := newPermissionTestPlugin(t) + p2.auth = qqAuthContext{Active: true, UserID: 2198972886} + ctxInternal := base() + ctxInternal.Extra[extraKeyComeFrom] = "internal" + if err := p2.beforeToolcall(ctxInternal); err != nil { + t.Fatal(err) + } + if ctxInternal.Response != nil { + t.Fatalf("★ 内部委派的 cmd_run 仍被用户权限门拦住(缺陷回归): %s", *ctxInternal.Response) + } + + // ② 硬私有工具同样放行(驻留子是父的内部执行体,父本就有完整权限) + p3 := newPermissionTestPlugin(t) + p3.auth = qqAuthContext{Active: true, UserID: 2198972886} + ctxHard := toolCallContext("memory_recall", nil) + ctxHard.Extra = map[string]interface{}{extraKeyComeFrom: "internal"} + if err := p3.beforeToolcall(ctxHard); err != nil { + t.Fatal(err) + } + if ctxHard.Response != nil { + t.Fatalf("★ 内部委派的硬私有工具被拦: %s", *ctxHard.Response) + } +} + +// ★★★ 判据:**只有**明确 "internal" 才放行 —— 其余一律按用户处理。 +// +// 这个方向绝不能反:把用户请求误判成 internal = 权限门整体失效, +// 比"多拦一次内部调用"严重得多。 +func TestComeFromOnlyInternalIsTrusted(t *testing.T) { + for _, tc := range []struct { + name string + setup func(m map[string]interface{}) + trusted bool + }{ + {"internal", func(m map[string]interface{}) { m[extraKeyComeFrom] = "internal" }, true}, + {"user", func(m map[string]interface{}) { m[extraKeyComeFrom] = "user" }, false}, + {"缺失(旧内核)", func(m map[string]interface{}) {}, false}, + {"空串", func(m map[string]interface{}) { m[extraKeyComeFrom] = "" }, false}, + {"未知取值", func(m map[string]interface{}) { m[extraKeyComeFrom] = "internal-ish" }, false}, + {"大小写不同", func(m map[string]interface{}) { m[extraKeyComeFrom] = "INTERNAL" }, false}, + {"类型错(bool)", func(m map[string]interface{}) { m[extraKeyComeFrom] = true }, false}, + {"类型错(数字)", func(m map[string]interface{}) { m[extraKeyComeFrom] = 1.0 }, false}, + } { + t.Run(tc.name, func(t *testing.T) { + p := newPermissionTestPlugin(t) + p.auth = qqAuthContext{Active: true, UserID: 2198972886} + ctx := toolCallContext("cmd_run", nil) + ctx.Extra = map[string]interface{}{} + tc.setup(ctx.Extra) + if err := p.beforeToolcall(ctx); err != nil { + t.Fatal(err) + } + blocked := ctx.Response != nil + if tc.trusted && blocked { + t.Errorf("★ 应放行却拦了: %s", *ctx.Response) + } + if !tc.trusted && !blocked { + t.Error("★ 应拦却放行了(权限门失效)") + } + }) + } +} + +// ★★★ 判据:**owner 的请求行为不变**(comefrom=user 时不得因为改动而放宽)。 +// +// 防的是"为了让驻留子通过,把门整体放松"这类错误修法。 +func TestComeFromUserKeepsOwnerAndNonOwnerSemantics(t *testing.T) { + // owner + comefrom=user ⇒ 完整放行(含硬私有) + p := newPermissionTestPlugin(t) + p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886} + ctx := toolCallContext("calendar_list", nil) + ctx.Extra = map[string]interface{}{extraKeyComeFrom: "user"} + if err := p.beforeToolcall(ctx); err != nil { + t.Fatal(err) + } + if ctx.Response != nil { + t.Fatalf("★ owner 的请求被拦(权限门被收紧了): %s", *ctx.Response) + } + + // 非 owner + comefrom=user + 硬私有 ⇒ 仍必须拒 + p2 := newPermissionTestPlugin(t) + p2.auth = qqAuthContext{Active: true, UserID: 10001} + ctx2 := toolCallContext("calendar_list", nil) + ctx2.Extra = map[string]interface{}{extraKeyComeFrom: "user"} + if err := p2.beforeToolcall(ctx2); err != nil { + t.Fatal(err) + } + if ctx2.Response == nil { + t.Fatal("★ 非 owner 的硬私有调用应仍被拒(改动把门放松了)") + } + + // 无身份(auth.Active=false)+ 有 comefrom=user ⇒ 本门不介入(旧行为) + p3 := newPermissionTestPlugin(t) + ctx3 := toolCallContext("cmd_run", nil) + ctx3.Extra = map[string]interface{}{extraKeyComeFrom: "user"} + if err := p3.beforeToolcall(ctx3); err != nil { + t.Fatal(err) + } + if ctx3.Response != nil { + t.Fatalf("非本插件来源不该被本门拦: %s", *ctx3.Response) + } +} diff --git a/example/rss/plugin.go b/example/rss/plugin.go index 03d1214..f79b89a 100644 --- a/example/rss/plugin.go +++ b/example/rss/plugin.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "log" "net/http" "os" "path/filepath" @@ -119,7 +120,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { } p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss") if err := os.MkdirAll(p.dataDir, 0755); err != nil { - fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err) + log.Printf("[%s] mkdir %s: %v", p.name, p.dataDir, err) } p.loadData() @@ -184,7 +185,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { p.wg.Add(1) go p.pollLoop() - fmt.Printf("[%s] started (%d feeds, poll every %dm)\n", p.name, len(p.feeds), pollMin) + log.Printf("[%s] started (%d feeds, poll every %dm)", p.name, len(p.feeds), pollMin) return nil } @@ -193,7 +194,7 @@ func (p *Plugin) Stop() error { p.pollTicker.Stop() p.wg.Wait() p.saveData() - fmt.Printf("[%s] stopped\n", p.name) + log.Printf("[%s] stopped", p.name) return nil } @@ -486,7 +487,7 @@ func (p *Plugin) cleanupData() { for _, f := range []string{"feeds.json"} { path := filepath.Join(p.dataDir, f) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - fmt.Printf("[%s] onRemove cleanup %s: %v\n", p.name, path, err) + log.Printf("[%s] onRemove cleanup %s: %v", p.name, path, err) } } } diff --git a/example/weather/plugin.go b/example/weather/plugin.go index e609940..c79b4c3 100644 --- a/example/weather/plugin.go +++ b/example/weather/plugin.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "strconv" "strings" @@ -95,7 +96,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { ctx.Lock() defer ctx.Unlock() if len(ctx.ToolResults) > 0 { - fmt.Printf("[%s] stage after_toolcall(own): %s\n", p.name, ctx.ToolResults[0].Name) + log.Printf("[%s] stage after_toolcall(own): %s", p.name, ctx.ToolResults[0].Name) } return nil }, sdk.StageScopeOwnTools) @@ -115,25 +116,25 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { return err } - fmt.Printf("[%s] started\n", p.name) + log.Printf("[%s] started", p.name) return nil } func (p *Plugin) Stop() error { - fmt.Printf("[%s] stopped\n", p.name) + log.Printf("[%s] stopped", p.name) return nil } type wttrResp struct { CurrentCondition []struct { - TempC string `json:"temp_C"` - FeelsLikeC string `json:"FeelsLikeC"` - Humidity string `json:"humidity"` - WindspeedKmph string `json:"windspeedKmph"` - Winddir16Point string `json:"winddir16Point"` - Pressure string `json:"pressure"` - Visibility string `json:"visibility"` - WeatherDesc []struct { + TempC string `json:"temp_C"` + FeelsLikeC string `json:"FeelsLikeC"` + Humidity string `json:"humidity"` + WindspeedKmph string `json:"windspeedKmph"` + Winddir16Point string `json:"winddir16Point"` + Pressure string `json:"pressure"` + Visibility string `json:"visibility"` + WeatherDesc []struct { Value string `json:"value"` } `json:"weatherDesc"` LocalObsDateTime string `json:"localObsDateTime"` @@ -153,7 +154,7 @@ type wttrResp struct { } type wttrDay struct { - Date string `json:"date"` + Date string `json:"date"` Astronomy []struct { Sunrise string `json:"sunrise"` Sunset string `json:"sunset"` @@ -161,8 +162,8 @@ type wttrDay struct { MaxtempC string `json:"maxtempC"` MintempC string `json:"mintempC"` Hourly []struct { - TempC string `json:"tempC"` - WeatherDesc []struct { + TempC string `json:"tempC"` + WeatherDesc []struct { Value string `json:"value"` } `json:"weatherDesc"` WindspeedKmph string `json:"windspeedKmph"` diff --git a/package/build-examples.sh b/package/build-examples.sh index 7d4e68d..2079590 100644 --- a/package/build-examples.sh +++ b/package/build-examples.sh @@ -99,7 +99,17 @@ fail=0 failed_names="" for dir in "$SDK_ROOT"/example/*/; do - [ -f "$dir/plugin.go" ] || continue + # ★ 入口判据:Go 插件是 plugin.go,**Lua 插件是 main.lua**。 + # + # 原判据只有 `[ -f plugin.go ]` ⇒ **Lua 插件被静默跳过** + # (实测:luademo 是 Lua 插件,entry=main.lua,不产 .hmap 也不报错, + # 发版产物就少一个插件而没人发现)。改为认两种入口。 + # + # 用 plg.json 的 entry 做最终判定更准,但**入口文件存在性**足以区分 + # "这是不是一个插件工程";真正的入口名由 hmapdev 从 plg.json 读。 + if [ ! -f "$dir/plugin.go" ] && [ ! -f "$dir/main.lua" ]; then + continue + fi name="$(basename "$dir")" # 清掉旧产物:残留会让人(和本脚本)误判成功。 @@ -111,7 +121,28 @@ for dir in "$SDK_ROOT"/example/*/; do # 判据是**退出码 + 产物存在**,两者都要。 # 只看退出码:hmapdev 曾经出错也退 0(已修,但脚本不该依赖它「现在」是对的)。 # 只看产物:部分平台失败时会留下上一次的产物,看起来像成功。 - hmap="$(ls "$dir"/dist/*.hmap 2>/dev/null | head -1)" + # + # ★★ 必须挑**本次目标平台**的产物,不能取 `head -1`。 + # + # ## 为什么(实测缺陷,2026-10-17) + # + # `--target` 的语义是**追加**(docs/guide/multi-platform.md 明确写「追加一个目标」), + # 而示例的 plg.json 普遍声明 `targets: "linux/amd64"`。于是 + # `build --no-bundle --target linux/arm64` 会**同时**产出 + # `..._linux_amd64.hmap` 与 `..._linux_arm64.hmap`。 + # + # 原代码取 `ls | head -1` ⇒ **按文件名字典序拿到 amd64 那份** + # ⇒ 打 arm64 包时实际装进去的是 **x86-64 二进制**, + # 而文件名却写着 `linux_arm64_...`(**文件名与内容不符**)。 + # 实测:arm64 产物 `plugin.bin` 的 ELF machine = 0x003e(x86-64,应为 0x00b7=aarch64)。 + # 这种包在 arm64 机器上只会以 "Exec format error" 失败,而排查方向会被文件名带偏。 + # + # ⇒ 精确匹配 `__.hmap`;匹配不到则视为失败(不静默退回任意产物)。 + hmap="$(ls "$dir"/dist/*_"${GOOS}_${GOARCH}".hmap 2>/dev/null | head -1)" + if [ -z "$hmap" ]; then + # Lua 插件的产物名是 _lua.hmap(无平台后缀,Lua 不编译)。 + hmap="$(ls "$dir"/dist/*_lua.hmap 2>/dev/null | head -1)" + fi if [ $rc -eq 0 ] && [ -n "$hmap" ]; then # 保留插件自己声明的产物名(它用的是 plg.json 的 name_en,是插件的身份), # 只在前面加平台前缀避免多平台互相覆盖。 diff --git a/sdk/facilities.go b/sdk/facilities.go new file mode 100644 index 0000000..da2f023 --- /dev/null +++ b/sdk/facilities.go @@ -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, + } +} diff --git a/sdk/plugin.go b/sdk/plugin.go index f055ec6..9cc10fb 100644 --- a/sdk/plugin.go +++ b/sdk/plugin.go @@ -385,8 +385,35 @@ type PluginMgrAPI interface { ReloadOne(name string) error // ListLoadedPlugins 列出已加载插件。 ListLoadedPlugins() []string - // IsPluginDisabled 查询插件是否被禁用。 + // IsPluginDisabled 查询插件是否被**持久**禁用。 IsPluginDisabled(name string) bool + + // ---- 运行态启停(设计 docs/zh/kernel-modularization-design.md §2.6.7)---- + // + // ★ 与 DisablePlugin/EnablePlugin 的区别(这是这两个方法存在的全部理由): + // + // DisablePlugin/EnablePlugin ⇒ 改**持久**状态(disabled_plugins 配置) + // StopPlugin/StartPlugin ⇒ 只改**运行**状态,不写任何配置 + // + // 心智模型:插件是 agent 的应用程序。 + // · disabled_plugins = 系统里"禁止运行这个程序"(持久决定) + // · runtimeStopped = 用户"现在关掉这个窗口"(运行决定,重启复位) + // + // 混用二者的后果(实测缺陷):想临时关一下却顺带关掉了开机自启 + // ⇒ 用户下次开机会发现"怎么不启动了"。 + + // StopPlugin 运行态关闭:现在停掉,**不改持久状态**。重启后按 + // auto_start + disabled_plugins 正常决定。 + // + // 拒绝作用于 pluginmgr 自身与 webui(自锁/失联,内核层面拦死)。 + StopPlugin(name string) error + // StartPlugin 运行态启动:现在拉起,**不解除持久禁用**。 + // + // 被持久禁用的插件会被拒绝(提示先用 EnablePlugin)—— + // 持久禁用是用户的明确决定,运行态操作不该绕过它。 + StartPlugin(name string) error + // IsPluginRuntimeStopped 查询插件是否处于运行态关闭。 + IsPluginRuntimeStopped(name string) bool } // StageScope controls which events a stage handler receives. @@ -449,6 +476,8 @@ type PluginSDK struct { llm LLMAPI sett SettingsAPI social SocialAPI + secrets SecretsAPI + rawFiles RawFilesAPI events EventSubscriber plgMgr PluginMgrAPI @@ -540,6 +569,16 @@ func (s *PluginSDK) LLM() LLMAPI { } // Social returns the social graph API (may be nil if not available). +// Secrets returns the kernel secret vault API. +// +// ★ 内核未启用安全模块时它**不是 nil**,而是 Available() 返回 false 的 +// 实现 —— 这样插件可以统一地"先问 Available 再决定",不必到处判空。 +func (s *PluginSDK) Secrets() SecretsAPI { + s.apiMu.RLock() + defer s.apiMu.RUnlock() + return s.secrets +} + func (s *PluginSDK) Social() SocialAPI { s.apiMu.RLock() defer s.apiMu.RUnlock() @@ -703,6 +742,30 @@ func (s *PluginSDK) SetIOInjector(io IOInjector) { } // SetMemoryAPI sets the memory API (called by the core at startup). +// RawFiles 返回随包分发资源的读取接口(内核装配时注入)。 +// +// 未注入时返回 nil —— 那是旧内核或测试环境。插件应判空后**响亮失败**, +// 而不是拿一个空实现继续(会表现为"资源读出来是空的",与"资源不存在"难区分)。 +func (s *PluginSDK) RawFiles() RawFilesAPI { + s.apiMu.RLock() + defer s.apiMu.RUnlock() + return s.rawFiles +} + +// SetRawFilesAPI 由内核装配时注入(插件不调用它)。 +func (s *PluginSDK) SetRawFilesAPI(v RawFilesAPI) { + s.apiMu.Lock() + s.rawFiles = v + s.apiMu.Unlock() +} + +// SetSecretsAPI 由内核装配时注入(插件不调用它)。 +func (s *PluginSDK) SetSecretsAPI(v SecretsAPI) { + s.apiMu.Lock() + s.secrets = v + s.apiMu.Unlock() +} + func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) { s.apiMu.Lock() s.mem = mem diff --git a/sdk/rawfiles.go b/sdk/rawfiles.go new file mode 100644 index 0000000..bd0ff23 --- /dev/null +++ b/sdk/rawfiles.go @@ -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 +} diff --git a/sdk/secrets.go b/sdk/secrets.go new file mode 100644 index 0000000..a31ae35 --- /dev/null +++ b/sdk/secrets.go @@ -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//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"` +} diff --git a/sdk/settings.go b/sdk/settings.go index 75f553d..f6590fa 100644 --- a/sdk/settings.go +++ b/sdk/settings.go @@ -7,6 +7,21 @@ type SettingsAPI interface { // Set writes a config value to the plugin's own config table. Set(key string, value interface{}) error + // SetSecret writes a value that is ALWAYS encrypted at rest, regardless of + // the key name. Use it when the key name is neutral (e.g. "blob", "conn") + // and so cannot be detected as sensitive automatically. + // + // Returns an error (not a silent plaintext write) when no master key is + // installed — a plugin that believes it stored a secret must not end up + // with plaintext on disk. + // + // The companion read is GetSecret. ConfigDef.Secret/Type=="password" and + // sensitive key names are encrypted automatically and do NOT need this. + SetSecret(key string, value interface{}) error + + // GetSecret reads a value written by SetSecret (transparently decrypted). + GetSecret(key string) (string, error) + // List returns all keys matching the given prefix. List(prefix string) ([]string, error) diff --git a/skills/homeagent-plugin-dev/SKILL.md b/skills/homeagent-plugin-dev/SKILL.md index 0893eca..fa86588 100644 --- a/skills/homeagent-plugin-dev/SKILL.md +++ b/skills/homeagent-plugin-dev/SKILL.md @@ -8,6 +8,37 @@ description: 开发、安装、调试 HomeAgent 插件。涉及 hmapdev 工具 > 本机(192.168.2.60)HomeAgent 的插件工具链手册。**所有命令都实测过**, > 不是从文档抄的。改了本机环境后请回来更正。 +## ★ 协议通道纪律:插件**绝不能**往 stdout 打印(高频踩坑) + +子进程插件的 **stdout 是 JSON-RPC 协议通道**,**stderr 才是日志通道** +(SDK 侧已 `log.SetOutput(os.Stderr)`)。 + +```go +// ❌ 错误:写进 stdout ⇒ 内核收到裸文本 ⇒ 报「非法 JSON 帧」并**丢弃这行日志** +fmt.Printf("[%s] started\n", p.name) + +// ✅ 正确:log 默认走 stderr,且会被内核收进插件日志 +log.Printf("[%s] started", p.name) +``` + +**症状**(很容易误判成内核/协议故障): + +``` +[proc] myplugin 收到非法 JSON 帧(19 字节): invalid character 'm' looking for beginning of value + ↑ 内容摘要: [myplugin] started + 可能原因:插件往 **stdout** 打印了日志,而 stdout 是JSON-RPC **协议通道**(stderr 才是日志通道)。 + 修法:把插件里的 fmt.Printf/fmt.Println 改成 log.Printf(脚手架模板 1.4.0 起已修正)。 +``` + +看到这条就说明**是你自己 printf 了** —— 日志本身丢了,还多一条看起来像协议故障的报错。 + +**实测代价**:生产环境累计 **1038 条**这种报错(weather 113 / rss 103 / +calendar 102 / 第三方 720),定位真因花了一整轮排查。 + +**例外**:**进程内插件**(`plugin.RegisterFactory`,编译进内核)的 +`fmt.Println` 写的是内核真实终端,**不是**缺陷。判别方法: +插件源码是不是 `package main`(子进程)还是普通的库包(进程内)。 + ## 0. 先搞清三件事,别猜 ```bash @@ -84,16 +115,21 @@ hmapdev debug . # 不想装就能先跑一遍 ### 注册工具的形状(照 example 写,别自创) +**★ `sdk.ToolDef` 里没有 `Handler` 字段** —— handler 是 `RegisterTool` 的**第三个参数**, +不是结构体成员。把 `Handler:` 写进结构体会**编译失败**(`unknown field Handler`)。 + ```go s.RegisterTool(tp+"mytool", sdk.ToolDef{ Name: tp + "mytool", // ← Name 必填,且要放在结构体**首位** Description: "……", Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, - Handler: h.mytool, NoMemory: true, // 声明字段一律放**末尾**,仿 NoMemory 的写法 -}, s) +}, h.mytool) // ← handler 在这里,不在结构体内 ``` +(`ToolDef` 的全部字段:`Name` / `Plugin` / `Description` / `Parameters` / `NoMemory` / +`Cleaner` / `ContextPolicy` / `RecallPolicy` / `ParallelSafe` / `Serial`。) + **并发声明**(同轮多个 tool_call 时): - `ParallelSafe: true` —— 可并发(**三个条件都满足才可声明**:handler 线程安全 / @@ -202,4 +238,39 @@ curl -s -H "X-API-Key: $K" http://127.0.0.1:8080/api/v1/status | grep -oE '"comm - **不要手改生成的文档**:`docs/api/*.md` 由 `tools/apidoc/gensite` 生成, 首行写着「请勿手改」;要改就去改 `sdk/*.go` 的注释再重新生成。 - **不要在插件里硬编码 token / 密钥**:走 `Settings()` 或宿主注入。 + +### 7.1 敏感配置的落盘加密(用 `SetSecret` / `GetSecret`) + +`SettingsAPI` 提供两个方法让插件**无需自己实现加密**(主密钥由内核统一管理): + +```go +// 强制加密落盘(不看键名)—— 返回 error 表示内核没装主密钥, +// 此时**拒绝写入**而不是静默存明文;不要吞掉这个 error。 +if err := s.Settings().SetSecret("blob", token); err != nil { + return err +} +// 读回(透明解密) +token, _ := s.Settings().GetSecret("blob") +``` + +**两条自动加密规则**(不写 `SetSecret` 也会加密): + +1. `ConfigDef{Secret: true}` —— 推荐,意图最明确 +2. `ConfigDef{Type: "password"}` —— 旧写法,兼容保留 +3. 键名含 `password` / `secret` / `token` / `api_key` 等词段 —— 兜底 + +⚠️ 什么时候**必须**用 `SetSecret`:键名是中性的(如 `blob`、`conn`、`payload`), +键名模式猜不到 —— 那就显式声明。 + +⚠️ 不要自己实现加密:主密钥由内核统一管理(`/security.key` 或 +`HOMED_SECURITY_KEY`),插件各自持钥会让密钥散落、无法轮换。 + +**注意**:`Secret` 字段在旧版本里就已存在但**从未生效**(内核侧的转换漏了它, +见内核仓 `docs/zh/kernel-secret-firewall-design.md` §5.1)。若你的插件声明了 +`Secret: true` 却看到明文落盘,说明内核侧还没接通 —— 升级内核,或改用 +`SetSecret`(若该方法不存在,就是内核版本过旧)。 + +⚠️ **升级须知**:`SetSecret`/`GetSecret` 是**接口新增方法**。它们只增不减, +所以插件**消费**侧不受影响;但如果你自己实现了 `SettingsAPI`(如自测替身), +需要补上这两个方法。 - **不要为了让插件生效去改 `homed` 的源码** —— 先确认它是不是内置插件。 diff --git a/tools/hmapdev/cmd_build.go b/tools/hmapdev/cmd_build.go index ba01055..bbcdfff 100644 --- a/tools/hmapdev/cmd_build.go +++ b/tools/hmapdev/cmd_build.go @@ -9,6 +9,9 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" + + pubsdk "github.com/JianFeeeee/homeagentsdk/sdk" "strings" ) @@ -256,8 +259,24 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) { writePluginJSON(plg, plats, procEntryFile) // package single .hmap with correctly named entries + // + // rawfile:工程根 rawfile/ 默认全收 + plg.json 声明的外部路径。 + // 收集失败即**整包失败**(少资源就是个坏包,却仍会产出 .hmap 让人以为成功)。 + raws, rerr := collectRawFiles(".", plg.RawFiles) + if rerr != nil { + fmt.Printf(" error: rawfiles: %v\n", rerr) + return + } + // 随包 skill(skill/ 目录,与 rawfile/ 平行;见 skills.go) + skills, serr := collectSkillFiles(".") + if serr != nil { + fmt.Printf(" error: skill: %v\n", serr) + return + } hmapPath := filepath.Join(outDir, fmt.Sprintf("%s_bundle.hmap", toSnake(plg.NameEn))) - createBundleHmap(hmapPath, "plugin.json", binaries) + // skill 与 rawfile 用同一个写入路径(都是"保留路径"的条目), + // 但分开收集以便分别报错与摘要。 + createBundleHmap(hmapPath, "plugin.json", binaries, append(raws, skills...)) fmt.Printf(" packaged %s\n", filepath.Base(hmapPath)) } @@ -343,6 +362,23 @@ func writePluginJSON(plg *PlgConfig, platforms []string, entry string) { if len(plg.Proxies) > 0 { m["proxies"] = plg.Proxies } + // ★ 启动时机声明**必须透传**(与 proxies 同一类"漏了插件作者会静默踩坑"): + // 内核读 plugin.json 的 requires_facilities 决定"何时拉起该插件"。 + // 不透传 ⇒ 插件照常装上运行,但启动时机与作者预期不符,**无任何提示**。 + if len(plg.RequiresFacilities) > 0 { + // 打包期校验:名字必须是内核已知设施(拼错在打包时报错, + // 而不是让用户装上一个"永不等不到就绪"因而**永不启动**的插件)。 + if bad := unknownFacilities(plg.RequiresFacilities); len(bad) > 0 { + fmt.Printf(" error: requires_facilities 含内核未知的设施名 %v(已知: %v)\n", + bad, pubsdk.KnownFacilities()) + return + } + m["requires_facilities"] = plg.RequiresFacilities + } + // auto_start:只有**显式 false** 才写(缺省 true 与旧行为一致,不写字段最兼容) + if plg.AutoStart != nil && !*plg.AutoStart { + m["auto_start"] = false + } data, _ := json.MarshalIndent(m, "", " ") os.WriteFile("plugin.json", data, 0644) } @@ -711,7 +747,17 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) { } } hmapName := fmt.Sprintf("%s_%s_%s.hmap", toSnake(plg.NameEn), cfg.goos, cfg.goarch) - createHmap(filepath.Join(outDir, hmapName), pkgFiles) + raws, rerr := collectRawFiles(".", plg.RawFiles) + if rerr != nil { + fmt.Printf(" error: rawfiles: %v\n", rerr) + return + } + skills, serr := collectSkillFiles(".") + if serr != nil { + fmt.Printf(" error: skill: %v\n", serr) + return + } + createHmapWithRaw(filepath.Join(outDir, hmapName), pkgFiles, append(raws, skills...)) fmt.Printf(" packaged %s\n", hmapName) } @@ -723,7 +769,7 @@ type binEntry struct { // createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。 // jsonName 是 plugin.json 在 zip 中的条目名; // binaries 的 src 为磁盘路径,zip 为 zip 中的条目名。 -func createBundleHmap(hmapPath, jsonName string, binaries []binEntry) { +func createBundleHmap(hmapPath, jsonName string, binaries []binEntry, raws []rawEntry) { f, err := os.Create(hmapPath) if err != nil { fmt.Printf("error: create hmap %s: %v\n", hmapPath, err) @@ -773,6 +819,27 @@ func createBundleHmap(hmapPath, jsonName string, binaries []binEntry) { writeZipEntry(fname, fname) } } + + // ★ rawfile:非代码资源(**保留路径**,与 writeZipEntry 的扁平化不同) + if len(raws) > 0 { + if err := writeRawEntries(w, raws); err != nil { + fmt.Printf("error: 写入 rawfiles 失败: %v\n", err) + return + } + if sum := rawFilesSummary(raws); sum != "" { + fmt.Printf(" 已打包 %s\n", sum) + } + // skill 单独一行(与 rawfile 分开报,便于判断"我加的 skill 进去了吗") + var skillOnly []rawEntry + for _, e := range raws { + if strings.HasPrefix(e.zip, SkillZipPrefix+"/") { + skillOnly = append(skillOnly, e) + } + } + if sum := skillFilesSummary(skillOnly); sum != "" { + fmt.Printf(" 已打包 %s\n", sum) + } + } } func createHmap(hmapPath string, files []string) { @@ -813,6 +880,49 @@ func createHmap(hmapPath string, files []string) { } } +// createHmapWithRaw 是 createHmap 的 rawfile 版本(保留路径)。 +func createHmapWithRaw(hmapPath string, files []string, raws []rawEntry) { + f, err := os.Create(hmapPath) + if err != nil { + fmt.Printf("error: create hmap %s: %v\n", hmapPath, err) + return + } + defer f.Close() + w := zip.NewWriter(f) + defer w.Close() + for _, path := range files { + if path == "" { + continue + } + info, err := os.Stat(path) + if err != nil { + continue + } + hdr, err := zip.FileInfoHeader(info) + if err != nil { + continue + } + hdr.Method = zip.Deflate + hdr.Name = filepath.Base(path) + writer, err := w.CreateHeader(hdr) + if err != nil { + continue + } + src, err := os.Open(path) + if err != nil { + continue + } + io.Copy(writer, src) + src.Close() + } + if len(raws) > 0 { + if err := writeRawEntries(w, raws); err != nil { + fmt.Printf("error: 写入 rawfiles 失败: %v\n", err) + return + } + } +} + func toSnake(s string) string { return strings.ToLower(strings.ReplaceAll(s, " ", "_")) } @@ -942,3 +1052,23 @@ func sdkHasInjectPriority(sdkPath string) bool { } return strings.Contains(seg, "Priority") } + +// unknownFacilities 返回**内核未知**的设施名(打包期校验,防拼错)。 +// +// ★ 为什么在打包期校验而不是留给内核:拼错的后果是插件**永不启动** +// (永远等不到那个名字就绪),且内核侧此前无任何报错。在这里报错时 +// 用户还没装它,代价最小。 +func unknownFacilities(declared []string) []string { + known := map[string]bool{} + for _, f := range pubsdk.KnownFacilities() { + known[f] = true + } + var bad []string + for _, f := range declared { + if !known[f] { + bad = append(bad, f) + } + } + sort.Strings(bad) + return bad +} diff --git a/tools/hmapdev/cmd_init.go b/tools/hmapdev/cmd_init.go index 03cf538..58e6530 100644 --- a/tools/hmapdev/cmd_init.go +++ b/tools/hmapdev/cmd_init.go @@ -113,9 +113,40 @@ type PlgConfig struct { // 字段语义见 SDK 的 sdk.ProxyDef(工具链与内核共用同一套校验规则)。 Proxies []ProxyConfig `json:"proxies,omitempty"` + // RawFiles 声明要一并打包的**非代码资源**(见 rawfiles.go)。 + // + // 工程根的 `rawfile/` 目录**默认全收**,不需要在这里声明; + // 本数组用于**额外**的外部路径。解包后统一落在 <插件目录>/rawfile/ 下。 + RawFiles []RawFileDecl `json:"rawfiles,omitempty"` + // ResolvedSDK 是本次构建实际选中的 SDK 版本(build 按 SDK 声明解析后回填), // 只写进产物里的 plugin.json,便于事后追溯「这个 .hmap 是哪版 SDK 编的」。 ResolvedSDK string `json:"-"` + + // RequiresFacilities 声明本插件需要**内核哪些设施就绪**后才能启动。 + // + // 取值见 sdk.KnownFacilities()(如 FacilityConfig / FacilityLLM / FacilityStageHost)。 + // **不声明** ⇒ 等内核完全初始化(默认;与旧行为一致)。 + // + // ★ 为什么必须在 PlgConfig 里声明、且必须写进产物 plugin.json: + // 内核靠读 plugin.json 的该字段决定**何时拉起插件**。工具链若不透传, + // 插件作者在 plg.json 里写了也**不会进包** —— 而且是**静默丢失** + // (插件照常装上、照常运行,只是启动时机与作者预期不符,无任何提示)。 + // 同理见 proxies 字段的注释(同一类"漏透传"坑)。 + // + // ★ 打包期校验:值必须是 sdk.KnownFacilities() 里的名字。 + // 拼错在**打包时就报错**,比运行期发现早得多(运行期发现意味着用户 + // 已经装上了那个**永不启动**的插件)。 + RequiresFacilities []string `json:"requires_facilities,omitempty"` + + // AutoStart 声明是否**伴随内核启动**(缺省 true)还是等显式启用(false)。 + // + // 用 *bool 而非 bool:必须区分"未声明"与"显式 false"。 + // 缺省(nil) ⇒ true(伴随启动,与旧行为一致) + // 显式 false ⇒ 不自动拉起,等 agent 经 plugin_enable 启用 + // 若用普通 bool,零值是 false ⇒ "没写这个字段"会被当成"不自动启动" + // ⇒ 存量插件全部不启动(灾难性)。 + AutoStart *bool `json:"auto_start,omitempty"` } // TargetList parses the Targets string into a slice. @@ -286,6 +317,21 @@ func cmdInit(args []string) { // create thirdpart directory for external library sources os.MkdirAll(filepath.Join(dir, "thirdpart"), 0755) + // create rawfile directory:**默认打包目录**。 + // 放这里的东西会被 hmapdev build 自动打进 .hmap, + // 解包后落在 <插件目录>/rawfile/ 下(见 tools/hmapdev/rawfiles.go)。 + // 留一个 .gitkeep 让空目录也能进版本库。 + rawDir := filepath.Join(dir, "rawfile") + os.MkdirAll(rawDir, 0755) + os.WriteFile(filepath.Join(rawDir, ".gitkeep"), []byte(""), 0644) + // ★ 自带的 .gitignore:rawfile/ 里放的是随包资源(可能内嵌 node/pi + // 等上百兆二进制),由构建方自行准备,不能进版本库 —— + // 一个克隆带上 300MB 二进制、每次升级产生巨型 diff。 + // 规则放在目录内部(自包含),不依赖上层 .gitignore 的否定式语义。 + os.WriteFile(filepath.Join(rawDir, ".gitignore"), []byte( + "# rawfile/ 里的是随包资源,由构建方自行准备(下载/拷贝),不进版本库。\n"+ + "*\n!.gitignore\n!.gitkeep\n"), 0644) + fmt.Printf("Created plugin project %q (%s)\n", dir, entry) if isLua { fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir) diff --git a/tools/hmapdev/example_stdout_test.go b/tools/hmapdev/example_stdout_test.go new file mode 100644 index 0000000..66a9fca --- /dev/null +++ b/tools/hmapdev/example_stdout_test.go @@ -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) + } + } +} diff --git a/tools/hmapdev/package_examples_test.go b/tools/hmapdev/package_examples_test.go new file mode 100644 index 0000000..6a1a09e --- /dev/null +++ b/tools/hmapdev/package_examples_test.go @@ -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 镜像**(临时目录): +// +// /package/build-examples.sh ← 复制真实脚本 +// /example/memo/ ← symlink 到真实示例(含 plg.json targets) +// /build/hmapdev_ ← 宿主工具链(脚本按此名查找) +// +// 然后以 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_ 名查找工具链 + 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 /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 /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) + } + + // 脚本自己命名产物为 __.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 "" +} diff --git a/tools/hmapdev/rawfiles.go b/tools/hmapdev/rawfiles.go new file mode 100644 index 0000000..1877ea8 --- /dev/null +++ b/tools/hmapdev/rawfiles.go @@ -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 +} diff --git a/tools/hmapdev/rawfiles_test.go b/tools/hmapdev/rawfiles_test.go new file mode 100644 index 0000000..1a38766 --- /dev/null +++ b/tools/hmapdev/rawfiles_test.go @@ -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/) + 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)) + } +} diff --git a/tools/hmapdev/scaffold_stdout_helper_test.go b/tools/hmapdev/scaffold_stdout_helper_test.go new file mode 100644 index 0000000..d5c5035 --- /dev/null +++ b/tools/hmapdev/scaffold_stdout_helper_test.go @@ -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 脚手架模板") +} diff --git a/tools/hmapdev/scaffold_stdout_test.go b/tools/hmapdev/scaffold_stdout_test.go new file mode 100644 index 0000000..6db3e08 --- /dev/null +++ b/tools/hmapdev/scaffold_stdout_test.go @@ -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) + } + } +} diff --git a/tools/hmapdev/skills.go b/tools/hmapdev/skills.go new file mode 100644 index 0000000..cd5ce9b --- /dev/null +++ b/tools/hmapdev/skills.go @@ -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//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//SKILL.md,要么该放 rawfile/)。 + return nil, fmt.Errorf( + "%s/%s 是文件;%s/ 下只接受**目录**(每个目录一个 skill,形如 %s//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)) +} diff --git a/tools/hmapdev/skills_test.go b/tools/hmapdev/skills_test.go new file mode 100644 index 0000000..bb3ee7e --- /dev/null +++ b/tools/hmapdev/skills_test.go @@ -0,0 +1,176 @@ +package main + +// 随包 skill 打包的判据。 +// +// 约定(与 rawfile/ 平行但**意图不同**): +// +// skill//SKILL.md 每个子目录一个 skill,登记给 skillmgr +// skill//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 +} diff --git a/tools/hmapdev/templates.go b/tools/hmapdev/templates.go index dc47598..d4866ce 100644 --- a/tools/hmapdev/templates.go +++ b/tools/hmapdev/templates.go @@ -13,7 +13,8 @@ const tmplPlgJSON = `{ "entry": "{{.Plg.Entry}}", "sdk": "{{.Plg.SDK}}", "tags": [{{range $i, $t := .Plg.Tags}}{{if $i}}, {{end}}"{{$t}}"{{end}}], - "targets": "{{.Plg.Targets}}" + "targets": "{{.Plg.Targets}}", + "rawfiles": [] } ` @@ -38,10 +39,22 @@ replace {{.SDKModule}} => {{.SDKLocalPath}} const tmplPluginGo = `package main import ( - "fmt" + "log" + "github.com/JianFeeeee/homeagentsdk/sdk" ) +// ★★ 插件里**绝不能往 stdout 打印任何东西**(本注释是踩坑记录,2026-10-16)。 +// +// 子进程插件的 **stdout 是 JSON-RPC 协议通道**(与内核的帧都走它), +// stderr 才是日志通道(SDK 侧 log.SetOutput(os.Stderr))。 +// +// 用 fmt.Printf 打一行日志 ⇒ 内核 readLoop 收到一段非 JSON 文本, +// 报「收到非法 JSON 帧(N 字节)」并**丢弃**。日志本身丢了,还刷了一条 +// 看起来像协议故障的报错(极难定位到「是我自己 printf 的」)。 +// +// ⇒ 一律用 log.Printf(默认走 stderr)。 + type Plugin struct { name string sdk *sdk.PluginSDK @@ -51,7 +64,7 @@ func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { p.sdk = s - s.RegisterStopHandler(func() { fmt.Printf("[%s] stop handler running\n", p.name) }) + s.RegisterStopHandler(func() { log.Printf("[%s] stop handler running", p.name) }) s.Settings().RegisterDef(sdk.ConfigDef{ Key: "plugin.{{.Plg.Name}}.example", Default: "hello", Type: "string", DisplayName: "示例配置", Description: "An example configuration key", @@ -84,11 +97,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { // return output // }, }, p.handleHello) - fmt.Printf("[%s] started\n", p.name) + log.Printf("[%s] started", p.name) return nil } -func (p *Plugin) Stop() error { fmt.Printf("[%s] stopped\n", p.name); return nil } +func (p *Plugin) Stop() error { log.Printf("[%s] stopped", p.name); return nil } func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) { return map[string]interface{}{"content": "Hello from {{.Plg.Name}} plugin!"}, nil diff --git a/tools/hmapdev/templates/plg.json.tmpl b/tools/hmapdev/templates/plg.json.tmpl deleted file mode 100644 index 9047080..0000000 --- a/tools/hmapdev/templates/plg.json.tmpl +++ /dev/null @@ -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}}" -} diff --git a/tools/hmapdev/templates/proc_main.go.tmpl b/tools/hmapdev/templates/proc_main.go.tmpl index 88dda74..46c7558 100644 --- a/tools/hmapdev/templates/proc_main.go.tmpl +++ b/tools/hmapdev/templates/proc_main.go.tmpl @@ -13,11 +13,14 @@ package main import ( "bufio" + "encoding/base64" "encoding/binary" "encoding/json" "fmt" "log" "os" + "path/filepath" + "sort" "strings" "sync" @@ -740,6 +743,8 @@ func buildPluginSDK(name string) *sdk.PluginSDK { base.SetKnowledgeAPI(procKnowledge{}) base.SetLLMAPI(procLLM{}) base.SetSocialAPI(procSocial{}) + base.SetSecretsAPI(procSecrets{}) + base.SetRawFilesAPI(procRawFiles{}) base.SetTextMemoryAPI(procTextMemory{}) base.SetPluginMgrAPI(procPluginMgr{}) base.SetInputChannelRegistrar(func(chName string, def sdk.ChannelDef) error { @@ -1160,6 +1165,234 @@ func (procPluginMgr) IsPluginDisabled(name string) bool { return r.Disabled } +// ---- 运行态启停(设计 docs/zh/kernel-modularization-design.md §2.6.7)---- +// +// ★ 注意 method 名是 `plugin.mgrStop/mgrStart/mgrIsRuntimeStopped`, +// **不是** `plugin.stop/plugin.start` —— 后两个是内核→插件的生命周期消息 +// (MethodPluginStart/Stop,见 protocol.go),语义完全不同。用错名会撞车。 +func (procPluginMgr) StopPlugin(name string) error { + return callCoreVoid("plugin.mgrStop", map[string]string{"name": name}) +} +func (procPluginMgr) StartPlugin(name string) error { + return callCoreVoid("plugin.mgrStart", map[string]string{"name": name}) +} +func (procPluginMgr) IsPluginRuntimeStopped(name string) bool { + raw, err := callCore("plugin.mgrIsRuntimeStopped", map[string]string{"name": name}) + if err != nil { + return false + } + var r struct { + RuntimeStopped bool `json:"runtime_stopped"` + } + json.Unmarshal(raw, &r) + return r.RuntimeStopped +} + +// procRawFiles 是 rawfile 资源的插件侧实现。 +// +// ★ 它**不走 RPC**:资源就在插件进程自己的目录下(`<插件目录>/rawfile/`), +// 直接读本地文件即可。走 RPC 是错的 —— 随包资源可能有几十上百兆 +// (如内置的 node 二进制),把它们搬过进程边界毫无意义。 +// +// 路径来源:内核在 spawn 时注入 HA_PLUGIN_RAWFILE_DIR / HA_PLUGIN_WORK_DIR。 +// 不用 cwd 推断(插件可能自己 chdir),也不读内核的 PluginDir()(那个不给插件)。 +type procRawFiles struct{} + +func (procRawFiles) root() string { + if v := os.Getenv("HA_PLUGIN_RAWFILE_DIR"); v != "" { + return v + } + // 回退:cwd 就是插件目录(内核 spawn 时设的 Dir) + wd, _ := os.Getwd() + return filepath.Join(wd, "rawfile") +} + +func (r procRawFiles) workRoot() string { + if v := os.Getenv("HA_PLUGIN_WORK_DIR"); v != "" { + return v + } + wd, _ := os.Getwd() + return filepath.Join(wd, "work") +} + +// resolve 解析相对路径并拒绝穿越(与内核侧 rawFilesImpl 同一套规则)。 +func (r procRawFiles) resolve(rel string) (string, error) { + rel = strings.TrimSpace(rel) + if rel == "" { + return "", fmt.Errorf("rawfile 路径不能为空") + } + if filepath.IsAbs(rel) { + return "", fmt.Errorf("rawfile 路径必须是相对路径: %q", rel) + } + root := r.root() + clean := filepath.Clean(filepath.Join(root, rel)) + if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) { + return "", fmt.Errorf("rawfile 路径逃出资源目录: %q", rel) + } + return clean, nil +} + +func (r procRawFiles) Read(rel string) ([]byte, error) { + p, err := r.resolve(rel) + if err != nil { + return nil, err + } + b, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("rawfile 不存在: %s(打包时是否漏了 rawfile/ 或 plg.json 的 rawfiles?)", rel) + } + return nil, err + } + return b, nil +} + +func (r procRawFiles) List(rel string) ([]string, error) { + p, err := r.resolve(rel) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(p) + if err != nil { + return nil, err + } + out := make([]string, 0, len(entries)) + for _, e := range entries { + name := e.Name() + if e.IsDir() { + name += "/" + } + out = append(out, name) + } + sort.Strings(out) + return out, nil +} + +func (r procRawFiles) Exists(rel string) bool { + p, err := r.resolve(rel) + if err != nil { + return false + } + st, err := os.Stat(p) + return err == nil && !st.IsDir() +} + +func (r procRawFiles) Dir() string { return r.root() } + +func (r procRawFiles) WorkDir() string { + d := r.workRoot() + _ = os.MkdirAll(d, 0o755) + return d +} + +var _ sdk.RawFilesAPI = procRawFiles{} + +// procSecrets 是变量库 API 的插件侧一半:全部经 RPC 打回内核。 +// +// ★ 它**不是**插件自己的存储 —— 内核的变量库是进程级单例, +// 主/子/驻留子 agent 与全部插件共用同一套别名。所以这里只做转发, +// 不在插件侧留任何副本(留了就会出现"两库互不可见"的经典问题: +// 内核拦截登记的变量插件看不到)。 +type procSecrets struct{} + +// Available 用**专门的**可用性查询,不靠 List 推断。 +// +// ★ 实测教训:初版用 `secrets.list` 成功与否判断 —— 而未启用时 +// List 返回空列表(不报错),于是 Available() 误判为 true, +// 接着 Store 才失败。错误信息于是变成"存储失败"而非"能力不可用", +// 排查方向完全错。 +func (procSecrets) Available() bool { + raw, err := callCore("secrets.available", nil) + if err != nil { + return false + } + var r struct { + Available bool `json:"available"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return false + } + return r.Available +} + +func (procSecrets) Store(value, note string) (string, error) { + raw, err := callCore("secrets.store", map[string]interface{}{"value": value, "note": note}) + if err != nil { + return "", err + } + var r struct { + Alias string `json:"alias"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return "", err + } + return r.Alias, nil +} + +func (procSecrets) List() []sdk.SecretInfo { + raw, err := callCore("secrets.list", nil) + if err != nil { + return nil + } + var r struct { + Infos []sdk.SecretInfo `json:"infos"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return nil + } + return r.Infos +} + +func (procSecrets) Forget(alias string) error { + return callCoreVoid("secrets.forget", map[string]string{"alias": alias}) +} + +func (procSecrets) Fill(text string) (string, int, []string) { + raw, err := callCore("secrets.fill", map[string]string{"text": text}) + if err != nil { + return text, 0, nil + } + var r struct { + Text string `json:"text"` + Replaced int `json:"replaced"` + Aliases []string `json:"aliases"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return text, 0, nil + } + return r.Text, r.Replaced, r.Aliases +} + +func (procSecrets) Encrypt(plain []byte) (string, error) { + raw, err := callCore("secrets.encrypt", map[string]string{ + "plain_b64": base64.StdEncoding.EncodeToString(plain), + }) + if err != nil { + return "", err + } + var r struct { + Encrypted string `json:"encrypted"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return "", err + } + return r.Encrypted, nil +} + +func (procSecrets) Decrypt(stored string) ([]byte, error) { + raw, err := callCore("secrets.decrypt", map[string]string{"stored": stored}) + if err != nil { + return nil, err + } + var r struct { + PlainB64 string `json:"plain_b64"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return nil, err + } + return base64.StdEncoding.DecodeString(r.PlainB64) +} + type procSettings struct{} func (procSettings) Get(key string) (interface{}, error) { @@ -1168,6 +1401,26 @@ func (procSettings) Get(key string) (interface{}, error) { func (procSettings) Set(key string, v interface{}) error { return callCoreVoid("settings.set", map[string]interface{}{"key": key, "value": v}) } + +// SetSecret 强制加密落盘(不看键名)。内核侧拒绝在无密钥时写明文。 +func (procSettings) SetSecret(key string, v interface{}) error { + return callCoreVoid("settings.setSecret", map[string]interface{}{"key": key, "value": v}) +} + +// GetSecret 读取 SetSecret 写入的值。 +func (procSettings) GetSecret(key string) (string, error) { + raw, err := callCore("settings.getSecret", map[string]string{"key": key}) + if err != nil { + return "", err + } + var r struct { + Value string `json:"value"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return "", err + } + return r.Value, nil +} func (procSettings) List(prefix string) ([]string, error) { return settingsKeys("settings.list", map[string]string{"prefix": prefix}) } diff --git a/tools/hmapdev/yaegi/mocksdk/plugin.go b/tools/hmapdev/yaegi/mocksdk/plugin.go index fb93a0e..7ac5652 100644 --- a/tools/hmapdev/yaegi/mocksdk/plugin.go +++ b/tools/hmapdev/yaegi/mocksdk/plugin.go @@ -225,6 +225,14 @@ func (s *mockSettings) Get(key string) (interface{}, error) { return v, nil } func (s *mockSettings) Set(key string, value interface{}) error { s.data[key] = value; return nil } +func (s *mockSettings) SetSecret(key string, value interface{}) error { + // mock 无加密能力:与真实实现一致地**拒绝**明文落盘。 + if str, ok := value.(string); ok && str != "" { + return fmt.Errorf("mock: 密钥未装配,拒绝以明文存储敏感项 %q", key) + } + return nil +} +func (s *mockSettings) GetSecret(key string) (string, error) { return "", nil } func (s *mockSettings) List(prefix string) ([]string, error) { var ks []string for k := range s.data {