feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode) (#3)

* feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode)

## 是什么

agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。

38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。

## sidecar 后端抽象(本轮核心)

传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:

  driver.json 描述一个后端:script / cliPath / env / args / llmConfig
  三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
  占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}

★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。

默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
 · 单线:commondev_branch_open(cli="zcode")
 · 按机器:commondev_endpoint_set(host=..., cli="zcode")

## zcode 接入(三条真机判据,全部要求真改工程文件)

 · TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
 · TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
 · TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
   cli_path,走端点表而不是全局 settings

★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。

## 其他

 · rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
 · 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
 · secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
 · 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)

判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。

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

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

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

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

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

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

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

## hmapdev

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

## 判据

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

## 其他

- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)

* fix(examples): 恢复 go.mod 相对路径 + 移除 plg.json 的 sdk 固定(修 CI 红)

CI 上 `Examples (linux/amd64)` 与 `(darwin/arm64)` 双红,只有两个失败:

  ✗ commondev   构建失败
  ✗ editdoc     构建失败
  error: 项目声明 sdk=1.4.0,但本地 SDK 存储里没有这个版本

其余 20 个示例全部成功 —— 说明不是通用问题,而是这两个被改坏了。

## 根因(两个叠加)

**① go.mod 被本机绝对路径污染**

我在上一个提交里用 `git add -A`,把本机 `go build` **自动改写**过的
go.mod 一并提交了:

  require github.com/JianFeeeee/homeagentsdk v1.4.0
  replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

基线(origin/main)是:

  require github.com/JianFeeeee/homeagentsdk v0.0.0
  replace github.com/JianFeeeee/homeagentsdk => ../../

本机那个目录真的存在 ⇒ 本地全绿;CI 没有 ⇒ 必红。

**② plg.json 声明了 `"sdk": "1.4.0"`**

一声明版本,hmapdev 就去**本地 SDK store** 找它,找不到直接拒绝构建。
全仓 20+ 示例**都不声明**,只有我改过的这两个声明了。
示例的正确依赖表达是 go.mod 的 `v0.0.0` + `replace ../../`。

## 修法

两个文件都恢复到与其余示例一致的写法。

## 判据(3 条,都是新加的)

 · `TestExampleGoModHasNoAbsoluteReplace`:任何示例 go.mod 不得含
   `/root/`、`/home/`、`/Users/`、`C:\`
 · `TestExampleGoModUsesRelativeSDKReplace`:replace 必须是相对路径 `../../`
 · `TestExamplePlgJsonDoesNotPinSDKVersion`:plg.json 不得声明 sdk

变异 2/2 咬住(重新引入绝对路径 / 重新声明 sdk,都准确报出文件路径)。

## 本地验证(模拟 CI 干净环境)

用干净 HOME 跑 `package/build-examples.sh`:
 · 修复前:成功 18 / 失败 4(commondev, editdoc, browser, rss)
 · 修复后:成功 20 / 失败 2(browser, rss —— **本机代理不通**,
   `proxy.golang.org` 超时;用本地 modcache 验证这两个可正常构建)

即 22/22 源码都能构建,CI 有网络不受影响。

## 教训

`git add -A` 会把「构建工具自动改写的文件」一起提交。
提交前必须 `git status` 逐个看,尤其 go.mod / go.sum。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
This commit is contained in:
JianFeeeee
2026-10-17 19:44:15 +08:00
committed by GitHub
parent 65d2bd9e61
commit 81a3efa4df
91 changed files with 21184 additions and 224 deletions

View File

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

View File

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

View File

@ -0,0 +1,129 @@
package main
// 示例插件的 go.mod 判据(2026-10-17 CI 事故)。
//
// ## 事故
//
// 我在一个提交里用 `git add -A` 把本机 `go build` **自动改写**过的
// go.mod 一并提交了:
//
// require github.com/JianFeeeee/homeagentsdk v1.4.0
// replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 本机绝对路径
//
// 后果:CI(干净环境)上 commondev / editdoc 构建**必然失败**
//
// error: 项目声明 sdk=1.4.0,但本地 SDK 存储里没有这个版本
//
// 而本机能过(那个目录真的存在)⇒ 本地全绿掩盖了问题,PR 卡在 CI 才暴露。
//
// ## 判据
//
// 1. 任何示例的 go.mod 都**不得**含本机绝对路径
// 2. replace 必须是相对路径(`../../`),这是 SDK 仓内示例的约定
// 3. plg.json 不得声明 `sdk` 版本 —— 一声明就要求本地 SDK store 有该版本,
// 而示例的正确依赖表达是 go.mod 的 v0.0.0 + replace ../../
//
// 这三条合起来保证「CI 干净环境能构建全部示例」。
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// ★★★ 判据:示例 go.mod 不得含本机绝对路径。
func TestExampleGoModHasNoAbsoluteReplace(t *testing.T) {
for _, gm := range findExampleGoMods(t) {
b, err := os.ReadFile(gm)
if err != nil {
t.Fatal(err)
}
src := string(b)
for _, bad := range []string{"/root/", "/home/", "/Users/", "C:\\"} {
if strings.Contains(src, bad) {
rel := mustRel(t, gm)
t.Fatalf("★ %s 含本机绝对路径 %q ——\n"+
" CI(干净环境)会因此构建失败,而本机因为有那个目录反而通过。\n"+
" 示例必须用相对路径:replace ... => ../../", rel, bad)
}
}
}
}
// ★★★ 判据:示例的 replace 指向 SDK 仓根(相对路径)。
func TestExampleGoModUsesRelativeSDKReplace(t *testing.T) {
reReplace := regexp.MustCompile(`(?m)^replace\s+\S*homeagentsdk\S*\s*=>\s*(\S+)`)
for _, gm := range findExampleGoMods(t) {
b, _ := os.ReadFile(gm)
m := reReplace.FindStringSubmatch(string(b))
if m == nil {
continue // 没有 replace 也算合法(某些示例可能不用)
}
tgt := m[1]
if filepath.IsAbs(tgt) {
t.Fatalf("★ %s 的 replace 是绝对路径 %q(必须相对)", mustRel(t, gm), tgt)
}
if tgt != "../../" {
t.Errorf("%s 的 replace 是 %q,本仓约定为 ../../(与 ai_image/calendar 等一致)",
mustRel(t, gm), tgt)
}
}
}
// ★★★ 判据:示例 plg.json 不得声明 sdk 版本。
//
// 为什么:一声明 `"sdk": "1.4.0"`,hmapdev 就会去**本地 SDK store** 找该版本,
// 找不到直接拒绝构建。而示例的正确依赖表达是 go.mod 的 v0.0.0 + replace ../../。
// 全仓 20+ 个示例都不声明,只有被我改过的两个声明了 ⇒ CI 只在这两个上红。
func TestExamplePlgJsonDoesNotPinSDKVersion(t *testing.T) {
root := repoRootForTest(t)
matches, _ := filepath.Glob(filepath.Join(root, "example", "*", "plg.json"))
reSDK := regexp.MustCompile(`"sdk"\s*:`)
for _, p := range matches {
b, err := os.ReadFile(p)
if err != nil {
continue
}
if reSDK.Match(b) {
rel := mustRel(t, p)
t.Fatalf("★ %s 声明了 sdk 版本 ——\n"+
" 这会让 hmapdev 去本地 SDK store 找该版本,干净 CI 上必然失败。\n"+
" 示例请用 go.mod 的 `require ... v0.0.0` + `replace ... => ../../`", rel)
}
}
}
func findExampleGoMods(t *testing.T) []string {
t.Helper()
root := repoRootForTest(t)
matches, err := filepath.Glob(filepath.Join(root, "example", "*", "go.mod"))
if err != nil {
t.Fatal(err)
}
if len(matches) == 0 {
t.Fatal("没找到任何示例 go.mod ⇒ 判据会假绿,路径写错了?")
}
return matches
}
func repoRootForTest(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
// tools/hmapdev → 上两级
return filepath.Dir(filepath.Dir(wd))
}
func mustRel(t *testing.T, p string) string {
t.Helper()
r, err := filepath.Rel(repoRootForTest(t), p)
if err != nil {
return p
}
return r
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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