mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
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:
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
129
tools/hmapdev/example_gomod_test.go
Normal file
129
tools/hmapdev/example_gomod_test.go
Normal 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
|
||||
}
|
||||
126
tools/hmapdev/example_stdout_test.go
Normal file
126
tools/hmapdev/example_stdout_test.go
Normal file
@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
// 示例插件的**协议通道纪律**判据(2026-10-16)。
|
||||
//
|
||||
// ## 为什么必须有这条
|
||||
//
|
||||
// 示例插件是**子进程插件**(`package main` + `plugin.bin`),它们的
|
||||
// **stdout 是 JSON-RPC 协议通道**。用 `fmt.Printf` 打日志 ⇒ 裸文本进入
|
||||
// 协议流 ⇒ 内核报「收到非法 JSON 帧」并丢弃该日志。
|
||||
//
|
||||
// ## 这是**生产环境正在发生**的缺陷(不是理论问题)
|
||||
//
|
||||
// 实测 `journalctl -u homeagent`:**1038 条**非法帧报错,来源:
|
||||
//
|
||||
// huawei_smarthome 720 / weather 113 / rss 103 / calendar 102
|
||||
//
|
||||
// weather / rss / calendar 正是本仓示例插件 ⇒ 已全部修为 log.Printf。
|
||||
//(huawei_smarthome 是第三方插件,源码不在本仓 —— 内核侧的可定位报错
|
||||
// 就是为它那类情况准备的。)
|
||||
//
|
||||
// ## 判据形态:源码扫描(这里是**恰当**的)
|
||||
//
|
||||
// 每个示例插件是**独立 Go module**(21 个 go.mod),无法在一个测试进程里
|
||||
// 全部编译 ⇒ 只能扫源码。断言的是"某个模式是否出现",属于
|
||||
// "符号/模式存在性"那一类(对比:断言接线/顺序时源码扫描会骗人)。
|
||||
//
|
||||
// ★ 必须**先剥注释**:示例插件里可能**刻意**提到 fmt.Printf 来讲这个坑。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★★ 判据:所有子进程示例插件**不得**往 stdout 打印。
|
||||
func TestExamplePluginsDoNotWriteStdout(t *testing.T) {
|
||||
exDir := filepath.Join("..", "..", "example")
|
||||
entries, err := os.ReadDir(exDir)
|
||||
if err != nil {
|
||||
t.Skipf("读不到 example 目录: %v", err)
|
||||
}
|
||||
checked := 0
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
// 只查**子进程**插件(有 plugin.go 且以 package main 开头)
|
||||
var files []string
|
||||
fis, err := os.ReadDir(filepath.Join(exDir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, fi := range fis {
|
||||
if fi.IsDir() || !strings.HasSuffix(fi.Name(), ".go") ||
|
||||
strings.HasSuffix(fi.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
files = append(files, filepath.Join(exDir, name, fi.Name()))
|
||||
}
|
||||
isSubprocess := false
|
||||
for _, f := range files {
|
||||
b, _ := os.ReadFile(f)
|
||||
if strings.HasPrefix(string(b), "package main") {
|
||||
isSubprocess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isSubprocess {
|
||||
continue
|
||||
}
|
||||
for _, f := range files {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
code := stripTemplateComments(string(b))
|
||||
for _, pat := range []string{"fmt.Printf(", "fmt.Println(", "fmt.Print(", "os.Stdout"} {
|
||||
if !strings.Contains(code, pat) {
|
||||
continue
|
||||
}
|
||||
// 找出具体行号(便于修)
|
||||
for i, ln := range strings.Split(code, "\n") {
|
||||
if strings.Contains(ln, pat) {
|
||||
t.Errorf("★ %s:%d 含 `%s` —— 子进程插件的 **stdout 是 "+
|
||||
"JSON-RPC 协议通道**,内核会报「收到非法 JSON 帧」并"+
|
||||
"丢弃该日志。\n 修法:改成 log.Printf(默认 stderr)。\n 行: %s",
|
||||
filepath.Join(name, filepath.Base(f)), i+1, pat, strings.TrimSpace(ln))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
checked++
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("没找到任何子进程示例插件 —— 判据会永远绿(请检查目录名/结构)")
|
||||
}
|
||||
t.Logf("检查了 %d 个子进程示例插件", checked)
|
||||
}
|
||||
|
||||
// ★★★ 判据:**反向钉住** —— 必须有示例插件真的在用 log.Printf。
|
||||
//
|
||||
// ★ 为什么需要:上面那条可以靠"把所有日志删光"通过,但那样示例就
|
||||
//
|
||||
// 失去了"示范正确做法"的意义。这条确保至少有几个插件在用 log.Printf。
|
||||
func TestExamplePluginsUseStderrLogging(t *testing.T) {
|
||||
exDir := filepath.Join("..", "..", "example")
|
||||
want := []string{"weather", "rss", "calendar"}
|
||||
for _, name := range want {
|
||||
p := filepath.Join(exDir, name, "plugin.go")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Errorf("读 %s: %v", p, err)
|
||||
continue
|
||||
}
|
||||
src := string(b)
|
||||
if !strings.Contains(src, "log.Printf") {
|
||||
t.Errorf("★ %s 没有用 log.Printf —— 它原先用 fmt.Printf 写 stdout,"+
|
||||
"实测生产环境累计 113/103/102 条非法帧报错。", name)
|
||||
}
|
||||
if !strings.Contains(src, `"log"`) {
|
||||
t.Errorf("★ %s 用了 log.Printf 但没 import \"log\"", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
303
tools/hmapdev/package_examples_test.go
Normal file
303
tools/hmapdev/package_examples_test.go
Normal file
@ -0,0 +1,303 @@
|
||||
package main
|
||||
|
||||
// 发版打包脚本(package/build-examples.sh)的判据(2026-10-17)。
|
||||
//
|
||||
// ## 守什么
|
||||
//
|
||||
// 1. **产物架构必须与目标平台一致** —— 打 arm64 包不能装进 x86-64 二进制
|
||||
// 2. **Lua 插件不得被静默跳过** —— 它们没有 plugin.go,但有 main.lua
|
||||
//
|
||||
// ## ★ 为什么必须是行为判据(而不是扫脚本源码)
|
||||
//
|
||||
// 这两个缺陷的共性是「**脚本逻辑错了但看起来成功**」:
|
||||
//
|
||||
// · 架构错:`ls dist/*.hmap | head -1` 在 `--target` 追加语义下
|
||||
// 按字典序拿到 amd64 那份 ⇒ 文件名写 `linux_arm64_...` 而内容是 x86-64。
|
||||
// 脚本**退出码 0、校验和也对**(校验和只证明文件没坏,不证明架构对)。
|
||||
// · Lua 跳过:入口判据只认 plugin.go ⇒ luademo 静默不产包,
|
||||
// 同样退出码 0、同样"成功 21/21"。
|
||||
//
|
||||
// ⇒ 如果只扫脚本里有没有 `_"${GOOS}_${GOARCH}"` 字样,把匹配写错、
|
||||
// 或 `head -1` 又溜回来,判据仍会绿。**必须真的构建并检查 ELF 头。**
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★★ 判据:**打包脚本真的按目标平台挑产物**(跑真实脚本,检查 ELF 架构)。
|
||||
//
|
||||
// ## 为什么必须跑**脚本本身**(而不是只测 hmapdev)
|
||||
//
|
||||
// 缺陷在脚本的"从产物里挑哪一份"这一步:
|
||||
//
|
||||
// hmap="$(ls "$dir"/dist/*.hmap | head -1)" ← 按字典序拿到 amd64
|
||||
//
|
||||
// 而 `--target` 是**追加**语义(docs/guide/multi-platform.md 写明),
|
||||
// 示例 plg.json 又普遍声明 `targets: "linux/amd64"`
|
||||
// ⇒ `--target linux/arm64` 会**同时**产出 amd64 与 arm64 两份
|
||||
// ⇒ `head -1` 拿到 amd64 ⇒ **打 arm64 包却装进 x86-64 二进制**。
|
||||
//
|
||||
// ★ 我第一版判据直接调 `hmapdev` 并自己挑目标平台那份 ⇒ **把变异改回
|
||||
//
|
||||
// `head -1` 后判据照样绿**(它根本没跑脚本,测的是 hmapdev 而非脚本)。
|
||||
// ⇒ 判据必须驱动**真实的 package/build-examples.sh**。
|
||||
//
|
||||
// ## 做法
|
||||
//
|
||||
// 造一个**最小 SDK 镜像**(临时目录):
|
||||
//
|
||||
// <tmp>/package/build-examples.sh ← 复制真实脚本
|
||||
// <tmp>/example/memo/ ← symlink 到真实示例(含 plg.json targets)
|
||||
// <tmp>/build/hmapdev_<host> ← 宿主工具链(脚本按此名查找)
|
||||
//
|
||||
// 然后以 arm64 为目标跑脚本,读**它产出的**文件里的 ELF machine。
|
||||
func TestPackagingScriptPicksTargetArchArtifact(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("需要真实交叉构建")
|
||||
}
|
||||
host := hostArch()
|
||||
var target, wantMachine, wantSuffix string
|
||||
switch host {
|
||||
case "amd64":
|
||||
target, wantMachine, wantSuffix = "linux/arm64", "aarch64", "linux_arm64"
|
||||
case "arm64":
|
||||
target, wantMachine, wantSuffix = "linux/amd64", "x86-64", "linux_amd64"
|
||||
default:
|
||||
t.Skipf("未知宿主架构 %s", host)
|
||||
}
|
||||
|
||||
sdkRoot := filepath.Join("..", "..")
|
||||
scriptSrc := filepath.Join(sdkRoot, "package", "build-examples.sh")
|
||||
if _, err := os.Stat(scriptSrc); err != nil {
|
||||
t.Skipf("找不到打包脚本: %v", err)
|
||||
}
|
||||
exampleSrc := filepath.Join(sdkRoot, "example", "memo")
|
||||
plg, err := os.ReadFile(filepath.Join(exampleSrc, "plg.json"))
|
||||
if err != nil || !strings.Contains(string(plg), `"targets"`) {
|
||||
t.Skip("示例未声明 targets,复现不了「追加」前提")
|
||||
}
|
||||
|
||||
// 最小镜像
|
||||
mirror := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(mirror, "package"))
|
||||
mustMkdir(t, filepath.Join(mirror, "example"))
|
||||
mustMkdir(t, filepath.Join(mirror, "build"))
|
||||
copyFile(t, scriptSrc, filepath.Join(mirror, "package", "build-examples.sh"))
|
||||
// ★ symlink 目标必须是**绝对路径**:脚本与插件内部有 `cd`,
|
||||
// 相对目标会相对**链接所在目录**解析(实测 `../../example/memo` 从
|
||||
// mirror/example/ 解析成了 mirror 的父目录 ⇒ 循环匹配不到任何插件,
|
||||
// 表现为「成功 0 / 失败 0」而不报错)。
|
||||
if err := os.Symlink(absOf(t, exampleSrc), filepath.Join(mirror, "example", "memo")); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
// 脚本按 hmapdev_<host> 名查找工具链
|
||||
dev := buildHostHmapdev(t)
|
||||
if err := os.Symlink(dev, filepath.Join(mirror, "build", "hmapdev_"+host)); err != nil {
|
||||
t.Fatalf("symlink hmapdev: %v", err)
|
||||
}
|
||||
// 插件的 go.mod 里有 `replace github.com/JianFeeeee/homeagentsdk => ../../`,
|
||||
// 它解析到**镜像根** ⇒ 镜像根必须有真实的 SDK Go 源码(go.mod + sdk/ + meta/…)。
|
||||
// 不接上的话,交叉构建会以
|
||||
// `reading <mirror>/go.mod: no such file or directory` 失败 ——
|
||||
// 看起来像工具链坏了,其实只是测试夹具不全。
|
||||
sdkAbs := absOf(t, sdkRoot)
|
||||
for _, name := range []string{"go.mod", "go.sum", "sdk", "meta", "lua"} {
|
||||
src := filepath.Join(sdkAbs, name)
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.Symlink(src, filepath.Join(mirror, name)); err != nil {
|
||||
t.Logf("symlink %s(非致命): %v", name, err)
|
||||
}
|
||||
}
|
||||
// 脚本在找不到工具链时会尝试 `cd <sdk>/tools/hmapdev && go build`。
|
||||
// 这里已提供工具链,但保险起见仍把该目录接上(脚本的 fallback 分支)。
|
||||
mustMkdir(t, filepath.Join(mirror, "tools"))
|
||||
if err := os.Symlink(filepath.Join(absOf(t, sdkRoot), "tools", "hmapdev"),
|
||||
filepath.Join(mirror, "tools", "hmapdev")); err != nil {
|
||||
t.Logf("symlink tools/hmapdev(非致命): %v", err)
|
||||
}
|
||||
|
||||
// ★ OUT_DIR 必须传**绝对路径**:脚本内部有 `cd "$dir"`(进插件目录构建),
|
||||
// 若 OUT_DIR 是相对的,后续 `mkdir -p`/`cp` 会解析到插件目录下 ——
|
||||
// 实测那样会得到「脚本报成功、但 outDir 里只有 MANIFEST/SHA256SUMS」,
|
||||
// 看起来像"没有插件被构建",其实产物落到了别处。
|
||||
outDir := filepath.Join(mirror, "out")
|
||||
cmd := exec.Command("bash", filepath.Join(mirror, "package", "build-examples.sh"), target, outDir)
|
||||
cmd.Dir = mirror
|
||||
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
|
||||
scriptOut, err := cmd.CombinedOutput()
|
||||
t.Logf("脚本输出:\n%s", scriptOut)
|
||||
if err != nil {
|
||||
t.Fatalf("脚本失败: %v\n%s", err, scriptOut)
|
||||
}
|
||||
|
||||
// 脚本自己命名产物为 <goos>_<goarch>_<name>.hmap
|
||||
var picked string
|
||||
entries, _ := os.ReadDir(outDir)
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), wantSuffix+"_") && strings.HasSuffix(e.Name(), ".hmap") {
|
||||
picked = filepath.Join(outDir, e.Name())
|
||||
break
|
||||
}
|
||||
}
|
||||
if picked == "" {
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
t.Fatalf("脚本未产出 %s 前缀的包。实际: %v", wantSuffix, names)
|
||||
}
|
||||
|
||||
if got := elfMachineOfHmap(t, picked); got != wantMachine {
|
||||
t.Fatalf("★ 脚本产出的 %s 里 plugin.bin 架构是 %s,期望 %s\n"+
|
||||
" ⇒ 脚本挑错了产物(文件名写 %s,内容却是别的平台)。\n"+
|
||||
" 在目标机器上只会以 Exec format error 失败,且排查方向被文件名带偏。",
|
||||
filepath.Base(picked), got, wantMachine, target)
|
||||
}
|
||||
}
|
||||
|
||||
func absOf(t *testing.T, p string) string {
|
||||
t.Helper()
|
||||
a, err := filepath.Abs(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// mustMkdir / copyFile:测试辅助
|
||||
func mustMkdir(t *testing.T, p string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func copyFile(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dst, b, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**Lua 插件不被打包脚本跳过**。
|
||||
//
|
||||
// luademo 没有 plugin.go(它是 main.lua),原脚本的
|
||||
// `[ -f "$dir/plugin.go" ] || continue` 会**静默跳过它**
|
||||
// ⇒ 发版产物少一个插件,而日志显示"成功 N/N"、退出码 0。
|
||||
//
|
||||
// ★ 判据形态:扫脚本源码里"入口判据"那一行 —— 因为要断言的是
|
||||
//
|
||||
// "脚本对 Lua 插件的**接纳条件**是否放宽",这是一个**模式存在性**断言
|
||||
// (不是接线/顺序断言),源码扫描在此恰当。
|
||||
func TestPackagingScriptAcceptsLuaPlugins(t *testing.T) {
|
||||
p := filepath.Join("..", "..", "package", "build-examples.sh")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Skipf("读不到打包脚本: %v", err)
|
||||
}
|
||||
src := string(b)
|
||||
|
||||
// 找"入口判据":必须同时认 plugin.go 与 main.lua
|
||||
hasGoEntry := strings.Contains(src, "plugin.go")
|
||||
hasLuaEntry := strings.Contains(src, "main.lua")
|
||||
if !hasGoEntry {
|
||||
t.Fatal("脚本不再认 plugin.go 入口?请检查是否改坏了")
|
||||
}
|
||||
if !hasLuaEntry {
|
||||
t.Fatal("★ 打包脚本只认 plugin.go 入口 ⇒ **Lua 插件被静默跳过**\n" +
|
||||
" (luademo 是 main.lua,不产 .hmap,脚本仍报成功、退出码 0)")
|
||||
}
|
||||
// 反向钉住:不能只把 main.lua 写在注释里就算数 ——
|
||||
// 判据行(含 continue 的那段)必须在 main.lua 附近出现
|
||||
idx := strings.Index(src, "main.lua")
|
||||
if idx < 0 {
|
||||
t.Fatal("main.lua 未出现在脚本中")
|
||||
}
|
||||
// 该处周边 300 字符内应有 continue 或 -f 判断(即真的是入口判据)
|
||||
lo := idx - 300
|
||||
if lo < 0 {
|
||||
lo = 0
|
||||
}
|
||||
hi := idx + 300
|
||||
if hi > len(src) {
|
||||
hi = len(src)
|
||||
}
|
||||
around := src[lo:hi]
|
||||
if !strings.Contains(around, "continue") && !strings.Contains(around, "-f") {
|
||||
t.Error("★ main.lua 只出现在注释里,入口判据并未真的接纳 Lua 插件")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func hostArch() string {
|
||||
out, err := exec.Command("go", "env", "GOARCH").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
a := strings.TrimSpace(string(out))
|
||||
switch a {
|
||||
case "x86_64":
|
||||
return "amd64"
|
||||
case "aarch64":
|
||||
return "arm64"
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func buildHostHmapdev(t *testing.T) string {
|
||||
t.Helper()
|
||||
out := filepath.Join(t.TempDir(), "hmapdev")
|
||||
cmd := exec.Command("go", "build", "-o", out, ".")
|
||||
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
|
||||
if b, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Skipf("构建宿主 hmapdev 失败(跳过): %v\n%s", err, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// elfMachineOfHmap 读出 .hmap 里 plugin.bin 的架构名。
|
||||
func elfMachineOfHmap(t *testing.T, hmapPath string) string {
|
||||
t.Helper()
|
||||
zr, err := zip.OpenReader(hmapPath)
|
||||
if err != nil {
|
||||
t.Fatalf("打开 %s: %v", hmapPath, err)
|
||||
}
|
||||
defer zr.Close()
|
||||
for _, f := range zr.File {
|
||||
if !strings.HasPrefix(f.Name, "plugin.bin") {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hdr := make([]byte, 20)
|
||||
n, _ := rc.Read(hdr)
|
||||
rc.Close()
|
||||
if n < 20 || hdr[0] != 0x7f || hdr[1] != 'E' || hdr[2] != 'L' || hdr[3] != 'F' {
|
||||
t.Fatalf("%s 里的 %s 不是 ELF", hmapPath, f.Name)
|
||||
}
|
||||
switch binary.LittleEndian.Uint16(hdr[18:20]) {
|
||||
case 0x3e:
|
||||
return "x86-64"
|
||||
case 0xb7:
|
||||
return "aarch64"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
t.Fatalf("%s 里没有 plugin.bin", hmapPath)
|
||||
return ""
|
||||
}
|
||||
294
tools/hmapdev/rawfiles.go
Normal file
294
tools/hmapdev/rawfiles.go
Normal file
@ -0,0 +1,294 @@
|
||||
package main
|
||||
|
||||
// rawfile:把**非代码资源**打进 .hmap,解包后供插件在运行期读取。
|
||||
//
|
||||
// ## 为什么需要
|
||||
//
|
||||
// 在它之前 `hmapdev build` 只收 `plugin.bin` + `plugin.json`(+README/LICENSE),
|
||||
// **任何资源都得靠部署脚本手工拷** —— 那让"一个 .hmap 就是完整插件"这个
|
||||
// 前提失效:白板机器上装完直接缺文件。
|
||||
//
|
||||
// 典型需求:随包分发的 node 与 pi、各 CLI 后端的 sidecar 脚本与 driver.json、
|
||||
// 模板、prompt 文本、字典、默认配置、wasm、字体……
|
||||
//
|
||||
// ## ★ 规则(用户拍板 2026-10-15)
|
||||
//
|
||||
// 1. 工程根下的 `rawfile/` 目录 **默认全部打包**(不需要在 plg.json 里声明)。
|
||||
// 2. 其他位置的资源,用户在 plg.json 的 `rawfiles` 数组里**显式列出**,一同打包。
|
||||
// 3. 解包后**一律落在 `<插件目录>/rawfile/` 下**:
|
||||
// · `rawfile/` 目录里的内容 → `<插件目录>/rawfile/<原名>`
|
||||
// · 外部声明的路径 → `<插件目录>/rawfile/<声明名>`
|
||||
//
|
||||
// 即 `rawfile/` 是**唯一入口**,插件只需认一个目录。
|
||||
//
|
||||
// ## plg.json 形状
|
||||
//
|
||||
// {
|
||||
// "rawfiles": [
|
||||
// "config/defaults.json", // 字符串:源路径,落到 rawfile/defaults.json
|
||||
// { "from": "assets", "exclude": ["assets/tmp"] }
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// ## 为什么统一落到 rawfile/ 下
|
||||
//
|
||||
// 若允许任意目标路径(比如把 node 直接放到 `runtime/node/bin/node`),
|
||||
// 就会出现两类路径(工程路径 vs 包内路径)互相穿模,且**与插件自己写的
|
||||
// 运行期文件混在一起**——升级时无法区分"包里带的"与"运行期生成的"。
|
||||
// 单一入口让"包内的"与"运行期的"天然分离:包内只读,运行期在别处。
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RawFilesDir 是默认打包目录(工程根下)。
|
||||
const RawFilesDir = "rawfile"
|
||||
|
||||
// RawFilesPrefix 是解包后这些资源的统一前缀。
|
||||
const RawFilesPrefix = "rawfile"
|
||||
|
||||
// RawFileDecl 是一条 rawfiles 声明(支持字符串与对象两种写法)。
|
||||
type RawFileDecl struct {
|
||||
From string `json:"from"`
|
||||
Exclude []string `json:"exclude,omitempty"`
|
||||
Optional bool `json:"optional,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON 同时接受 `"path"` 与 `{"from": "path"}` 两种写法。
|
||||
//
|
||||
// 为何两种都收:只要一个路径时写字符串最省事(`"config/x.json"`),
|
||||
// 需要 exclude/optional 时才用对象。强迫所有人写对象是没必要的仪式。
|
||||
func (d *RawFileDecl) UnmarshalJSON(b []byte) error {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if strings.HasPrefix(s, `"`) {
|
||||
var only string
|
||||
if err := json.Unmarshal(b, &only); err != nil {
|
||||
return err
|
||||
}
|
||||
d.From = only
|
||||
return nil
|
||||
}
|
||||
type alias RawFileDecl
|
||||
var a alias
|
||||
if err := json.Unmarshal(b, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
*d = RawFileDecl(a)
|
||||
return nil
|
||||
}
|
||||
|
||||
type rawEntry struct {
|
||||
src string // 磁盘路径
|
||||
zip string // zip 内条目名(保留路径)
|
||||
}
|
||||
|
||||
// collectRawFiles 收集要打包的资源。
|
||||
//
|
||||
// 两部分合起来:
|
||||
//
|
||||
// ① 工程根的 `rawfile/` 目录(默认全收)
|
||||
// ② plg.json 里 rawfiles 声明的外部路径
|
||||
//
|
||||
// 输出按 zip 条目名排序(**确定性**:同样输入产出同样字节,
|
||||
// 否则每次构建的 .hmap 都不同,校验和与 diff 全无意义)。
|
||||
func collectRawFiles(root string, decls []RawFileDecl) ([]rawEntry, error) {
|
||||
var out []rawEntry
|
||||
|
||||
// ① 默认目录
|
||||
defDir := filepath.Join(root, RawFilesDir)
|
||||
if fi, err := os.Stat(defDir); err == nil && fi.IsDir() {
|
||||
err := filepath.Walk(defDir, func(p string, fi os.FileInfo, werr error) error {
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(defDir, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// ★ 排除脚手架文件:`.gitkeep`/`.gitignore` 是**目录占位与版本控制**
|
||||
// 用的,不是资源。不打进包的原因有两个:
|
||||
// ① 它们是工程内部约定,对插件运行期毫无意义;
|
||||
// ② 它们是**噪声** —— 一个空工程会打出"已打包 2 个 rawfile",
|
||||
// 让人误以为里面有资源。
|
||||
// (实测发现:init 出的新工程第一次 build 就把这两个文件打进去了。)
|
||||
if IsRawFileScaffold(rel) {
|
||||
return nil
|
||||
}
|
||||
out = append(out, rawEntry{
|
||||
src: p,
|
||||
zip: filepath.ToSlash(filepath.Join(RawFilesPrefix, rel)),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("展开 %s/ 失败: %w", RawFilesDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 显式声明
|
||||
for _, d := range decls {
|
||||
from := strings.TrimSpace(d.From)
|
||||
if from == "" {
|
||||
return nil, fmt.Errorf("rawfiles 有条目缺 from")
|
||||
}
|
||||
// ★ from 的语义:**相对 plg.json 所在目录(工程根)**。
|
||||
//
|
||||
// 绝对路径**不接受** —— 不是因为技术上做不到,而是它会引入一个
|
||||
// 无法判定合法性的输入:`/etc/passwd` 与 `/home/me/proj/x` 在语法上
|
||||
// 没有区别,只能靠"是否在工程内"事后判断,而那个判断依赖 cwd
|
||||
// (构建时 cwd 不保证是工程根)⇒ 同一个 plg.json 在不同目录下构建
|
||||
// 结果不同,**构建不可复现**。
|
||||
//
|
||||
// 所以:拒绝绝对路径,只接受相对路径。这样 Clean 后前缀比较就是完备的
|
||||
// (实测发现:初版允许绝对路径,`"a/../../../etc/passwd"` 能绕出去)。
|
||||
if filepath.IsAbs(from) {
|
||||
return nil, fmt.Errorf(
|
||||
"rawfiles 不接受绝对路径: %q(请写成相对工程根的路径)", from)
|
||||
}
|
||||
abs := filepath.Join(root, from)
|
||||
cleanRoot := filepath.Clean(root)
|
||||
cleanAbs := filepath.Clean(abs)
|
||||
if cleanAbs != cleanRoot && !strings.HasPrefix(cleanAbs, cleanRoot+string(os.PathSeparator)) {
|
||||
return nil, fmt.Errorf(
|
||||
"rawfiles 路径逃出工程目录: %q(资源必须放在工程内)", from)
|
||||
}
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
if d.Optional {
|
||||
fmt.Printf(" warn: rawfile 可选源缺失,跳过: %s\n", from)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("rawfiles 源不存在: %s(要允许缺失请加 optional)", from)
|
||||
}
|
||||
base := filepath.Base(filepath.Clean(from))
|
||||
if !info.IsDir() {
|
||||
// 显式声明也排除脚手架(同一套规则,避免"两条来源行为不一致")
|
||||
if IsRawFileScaffold(from) {
|
||||
continue
|
||||
}
|
||||
out = append(out, rawEntry{
|
||||
src: abs,
|
||||
zip: filepath.ToSlash(filepath.Join(RawFilesPrefix, base)),
|
||||
})
|
||||
continue
|
||||
}
|
||||
err = filepath.Walk(abs, func(p string, fi os.FileInfo, werr error) error {
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(abs, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if IsRawFileScaffold(relSlash) {
|
||||
return nil
|
||||
}
|
||||
for _, ex := range d.Exclude {
|
||||
ex = filepath.ToSlash(strings.TrimSpace(ex))
|
||||
if ex != "" && strings.HasPrefix(relSlash, ex) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
out = append(out, rawEntry{
|
||||
src: p,
|
||||
zip: filepath.ToSlash(filepath.Join(RawFilesPrefix, base, rel)),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("展开 rawfiles 目录 %s 失败: %w", from, err)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].zip < out[j].zip })
|
||||
// 撞名检测:两条来源落到同一包内路径 —— 不报的话"我改的没生效"极难查
|
||||
seen := map[string]string{}
|
||||
for _, e := range out {
|
||||
if prev, dup := seen[e.zip]; dup {
|
||||
return nil, fmt.Errorf("rawfile 目标冲突: %q 同时来自 %s 与 %s", e.zip, prev, e.src)
|
||||
}
|
||||
seen[e.zip] = e.src
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// writeRawEntries 把资源写进 zip(**保留路径**)。
|
||||
//
|
||||
// ★ 与既有 createHmap 的区别:那个用 `filepath.Base(path)` 当条目名
|
||||
// (只适合单文件),目录进去会全部拍平撞名。rawfile 必须保留结构,
|
||||
// 否则 `rawfile/main/foo.json` 会变成 `foo.json` 落在插件目录根上。
|
||||
func writeRawEntries(w *zip.Writer, entries []rawEntry) error {
|
||||
for _, e := range entries {
|
||||
info, err := os.Stat(e.src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rawfile 源消失: %s: %w", e.src, err)
|
||||
}
|
||||
hdr, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr.Method = zip.Deflate
|
||||
hdr.Name = e.zip // ← 完整路径,不是 Base
|
||||
writer, err := w.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := os.Open(e.src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(writer, src); err != nil {
|
||||
src.Close()
|
||||
return err
|
||||
}
|
||||
src.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rawFilesSummary 给构建输出一行摘要。
|
||||
func rawFilesSummary(entries []rawEntry) string {
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
var total int64
|
||||
for _, e := range entries {
|
||||
if fi, err := os.Stat(e.src); err == nil {
|
||||
total += fi.Size()
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%d 个 rawfile(%.1f MiB)", len(entries), float64(total)/1048576)
|
||||
}
|
||||
|
||||
// IsRawFileScaffold 判断某个**相对路径**是否是版本控制/目录占位用的脚手架文件。
|
||||
//
|
||||
// 这类文件随工程存在,但不是随包资源:
|
||||
// - `.gitkeep` 空目录占位
|
||||
// - `.gitignore` rawfile/ 自带的忽略规则
|
||||
// - `.DS_Store` macOS Finder 的目录元数据(常被误提交)
|
||||
//
|
||||
// 排除它们既是"别把工程内部约定发给运行期",也是**去噪**:
|
||||
// 不排的话一个空工程会报"已打包 2 个 rawfile",误导开发者以为包里有东西。
|
||||
func IsRawFileScaffold(rel string) bool {
|
||||
base := filepath.Base(filepath.ToSlash(rel))
|
||||
switch base {
|
||||
case ".gitkeep", ".gitignore", ".DS_Store":
|
||||
return true
|
||||
}
|
||||
// 任意层级下的这些名字都不算资源
|
||||
return false
|
||||
}
|
||||
420
tools/hmapdev/rawfiles_test.go
Normal file
420
tools/hmapdev/rawfiles_test.go
Normal file
@ -0,0 +1,420 @@
|
||||
package main
|
||||
|
||||
// rawfile 打包判据。
|
||||
//
|
||||
// 规则(用户拍板):
|
||||
// ① 工程根 `rawfile/` 目录**默认全收**(无需在 plg.json 声明)
|
||||
// ② plg.json 的 `rawfiles` 数组声明**额外**的外部路径
|
||||
// ③ 解包后一律落在 `<插件目录>/rawfile/` 下
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mkProject 造一个最小工程结构。
|
||||
func mkProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
write := func(rel, content string) {
|
||||
p := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 默认目录(含嵌套)
|
||||
write("rawfile/main/prompt.txt", "you are a dev")
|
||||
write("rawfile/main/nested/data.json", `{"a":1}`)
|
||||
// 外部路径(声明后才收)
|
||||
write("assets/theme.css", "body{}")
|
||||
write("third_party/node/bin/node", "#!/bin/sh\n")
|
||||
return root
|
||||
}
|
||||
|
||||
// ★★ 判据:**rawfile/ 目录默认全收**,无需任何声明。
|
||||
func TestRawfileDirIsCollectedByDefault(t *testing.T) {
|
||||
root := mkProject(t)
|
||||
ents, err := collectRawFiles(root, nil) // 不给声明
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, e := range ents {
|
||||
got[e.zip] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"rawfile/main/prompt.txt",
|
||||
"rawfile/main/nested/data.json", // ★ 嵌套结构必须保留
|
||||
} {
|
||||
if !got[want] {
|
||||
t.Errorf("默认目录漏了 %s(实际 %v)", want, keysOf(got))
|
||||
}
|
||||
}
|
||||
// 未声明的外部路径**不该**被收
|
||||
for _, shouldNot := range []string{"rawfile/theme.css", "rawfile/node"} {
|
||||
if got[shouldNot] {
|
||||
t.Errorf("未声明的外部路径被误收: %s", shouldNot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:声明的外部路径也收,且**统一落到 rawfile/ 下**。
|
||||
func TestDeclaredExternalPathsAlsoCollected(t *testing.T) {
|
||||
root := mkProject(t)
|
||||
ents, err := collectRawFiles(root, []RawFileDecl{
|
||||
{From: "assets/theme.css"},
|
||||
{From: "third_party/node/bin/node"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, e := range ents {
|
||||
got[e.zip] = true
|
||||
}
|
||||
// ★ 规则③:一律在 rawfile/ 下
|
||||
if !got["rawfile/theme.css"] {
|
||||
t.Errorf("声明的文件未落到 rawfile/ 下: %v", keysOf(got))
|
||||
}
|
||||
if !got["rawfile/node"] {
|
||||
t.Errorf("声明的文件未落到 rawfile/ 下: %v", keysOf(got))
|
||||
}
|
||||
// 不得出现 rawfile/ 之外的条目
|
||||
for z := range got {
|
||||
if !strings.HasPrefix(z, "rawfile/") {
|
||||
t.Errorf("★ 有资源落在 rawfile/ 之外: %s", z)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:目录递归展开时**保留结构**(不是拍平)。
|
||||
//
|
||||
// 这是 rawfile 与既有 createHmap 的关键区别:后者用 filepath.Base(),
|
||||
// 目录进去会全部拍平成一层,`main/nested/data.json` 与另一处的 `data.json` 撞名。
|
||||
func TestDirectoryStructureIsPreserved(t *testing.T) {
|
||||
root := mkProject(t)
|
||||
ents, err := collectRawFiles(root, []RawFileDecl{{From: "assets"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found bool
|
||||
for _, e := range ents {
|
||||
if e.zip == "rawfile/assets/theme.css" {
|
||||
found = true
|
||||
}
|
||||
if e.zip == "rawfile/theme.css" {
|
||||
t.Error("★ 目录被拍平了(丢了 assets/ 这一层)")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("目录展开结果不对: %v", zipNames(ents))
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:exclude 生效。
|
||||
func TestExcludeWorks(t *testing.T) {
|
||||
root := mkProject(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "assets/skip.tmp"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ents, err := collectRawFiles(root, []RawFileDecl{{From: "assets", Exclude: []string{"skip.tmp"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range ents {
|
||||
if strings.Contains(e.zip, "skip.tmp") {
|
||||
t.Errorf("exclude 未生效: %s", e.zip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:声明路径**逃出工程目录**必须报错。
|
||||
//
|
||||
// 不挡的话既让构建不可复现,也可能无意把凭据/密钥打进发布包。
|
||||
func TestPathEscapeIsRejected(t *testing.T) {
|
||||
root := mkProject(t)
|
||||
if _, err := collectRawFiles(root, []RawFileDecl{{From: "../../etc/passwd"}}); err == nil {
|
||||
t.Fatal("★ 逃出工程目录的声明未被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:两条来源落到同一包内路径 ⇒ **报错**(不静默覆盖)。
|
||||
//
|
||||
// 不报的话"我改的没生效"极难查(后写的覆盖前者,且无任何提示)。
|
||||
func TestConflictingTargetsAreRejected(t *testing.T) {
|
||||
root := mkProject(t)
|
||||
// ★ 真冲突:两条外部声明落到同一个 base 名(都变成 rawfile/<base>)
|
||||
if err := os.MkdirAll(filepath.Join(root, "a"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "b"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range []string{"a", "b"} {
|
||||
if err := os.WriteFile(filepath.Join(root, d, "same.txt"), []byte(d), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := collectRawFiles(root, []RawFileDecl{
|
||||
{From: "a/same.txt"}, {From: "b/same.txt"},
|
||||
}); err == nil {
|
||||
t.Fatal("★ 目标冲突未被检出(会静默覆盖)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:optional 缺失只警告不报错;非 optional 缺失报错。
|
||||
func TestOptionalMissingSource(t *testing.T) {
|
||||
root := mkProject(t)
|
||||
if _, err := collectRawFiles(root, []RawFileDecl{{From: "nope.txt"}}); err == nil {
|
||||
t.Error("非 optional 的缺失源应报错")
|
||||
}
|
||||
if _, err := collectRawFiles(root, []RawFileDecl{{From: "nope.txt", Optional: true}}); err != nil {
|
||||
t.Errorf("optional 缺失不应报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:输出**确定有序**(同样输入产出同样字节)。
|
||||
//
|
||||
// 不稳定的话每次构建的 .hmap 都不同,校验和与 diff 全无意义。
|
||||
func TestCollectionIsDeterministic(t *testing.T) {
|
||||
root := mkProject(t)
|
||||
a, err := collectRawFiles(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := collectRawFiles(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
t.Fatalf("两次收集数量不同: %d vs %d", len(a), len(b))
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].zip != b[i].zip {
|
||||
t.Fatalf("顺序不稳定(第 %d 项): %s vs %s", i, a[i].zip, b[i].zip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**plg.json 同时接受字符串与对象两种写法**。
|
||||
func TestRawFileDeclAcceptsBothForms(t *testing.T) {
|
||||
var decls []RawFileDecl
|
||||
raw := `["plain/path.txt", {"from": "obj/path", "exclude": ["x"]}, {"from": "opt", "optional": true}]`
|
||||
if err := json.Unmarshal([]byte(raw), &decls); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decls) != 3 {
|
||||
t.Fatalf("应解析出 3 条,实际 %d", len(decls))
|
||||
}
|
||||
if decls[0].From != "plain/path.txt" {
|
||||
t.Errorf("字符串写法解析错: %+v", decls[0])
|
||||
}
|
||||
if decls[1].From != "obj/path" || len(decls[1].Exclude) != 1 {
|
||||
t.Errorf("对象写法解析错: %+v", decls[1])
|
||||
}
|
||||
if !decls[2].Optional {
|
||||
t.Errorf("optional 未解析: %+v", decls[2])
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**真正写进 zip 后,条目名保留完整路径**。
|
||||
//
|
||||
// 这条是端到端的:前面的判据只看收集结果,这里看 zip 里的真实条目。
|
||||
func TestRawEntriesWrittenWithFullPath(t *testing.T) {
|
||||
root := mkProject(t)
|
||||
ents, err := collectRawFiles(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "t.hmap")
|
||||
f, err := os.Create(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(f)
|
||||
if err := writeRawEntries(w, ents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Close()
|
||||
f.Close()
|
||||
|
||||
r, err := zip.OpenReader(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
names := map[string]bool{}
|
||||
for _, zf := range r.File {
|
||||
names[zf.Name] = true
|
||||
}
|
||||
if !names["rawfile/main/nested/data.json"] {
|
||||
t.Fatalf("zip 里没有保留路径的条目(实际 %v)", keysOf(names))
|
||||
}
|
||||
// 内容也要对
|
||||
for _, zf := range r.File {
|
||||
if zf.Name == "rawfile/main/prompt.txt" {
|
||||
rc, _ := zf.Open()
|
||||
buf := make([]byte, 64)
|
||||
n, _ := rc.Read(buf)
|
||||
rc.Close()
|
||||
if !strings.Contains(string(buf[:n]), "you are a dev") {
|
||||
t.Errorf("内容不对: %q", string(buf[:n]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func zipNames(ents []rawEntry) []string {
|
||||
out := make([]string, 0, len(ents))
|
||||
for _, e := range ents {
|
||||
out = append(out, e.zip)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ★★★ 判据:**绝对路径也必须挡在工程外**(实测发现的真 bug)。
|
||||
//
|
||||
// 初版只在相对路径分支里检查 `../`,于是 `{"from": "/etc/passwd"}`
|
||||
// 被原样打进发布包 —— 那是把宿主机凭据/密钥带进发行的真实通道。
|
||||
// `filepath.Join(root, "/etc/passwd")` 也不会逃出(Join 会把绝对路径
|
||||
// 当相对处理),所以这条走的是"根 + 绝对路径"这条更隐蔽的入口。
|
||||
func TestAbsolutePathOutsideProjectIsRejected(t *testing.T) {
|
||||
// ★ 关键:必须**造出真实存在的逃逸目标**。
|
||||
//
|
||||
// 初版用 `/etc/passwd` 之类的固定路径 —— 那是错的:在没有该文件的
|
||||
// 环境里(如临时目录的父级),实现会因"文件不存在"报错,
|
||||
// 于是**判据通过但那不是因为拒绝了逃逸**。实测变异(去掉前缀检查)
|
||||
// 没被咬住,正是这个原因:报错来自 Stat 失败,不是来自安全检查。
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("REDACTED"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := mkProject(t)
|
||||
// 构造一个**从工程根出发、真实指向工程外**的相对路径
|
||||
rel, err := filepath.Rel(root, filepath.Join(outside, "secret.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, from := range []string{rel, "/etc/passwd", "a/../../../etc/passwd"} {
|
||||
ents, cerr := collectRawFiles(root, []RawFileDecl{{From: from}})
|
||||
if cerr == nil {
|
||||
t.Errorf("★ 逃出工程的声明 %q 未被拒绝(收集到 %d 项)", from, len(ents))
|
||||
continue
|
||||
}
|
||||
// 报错原因必须是**逃逸**,不能是"文件不存在"
|
||||
if !strings.Contains(cerr.Error(), "逃出") && !strings.Contains(cerr.Error(), "绝对路径") {
|
||||
t.Errorf("拒绝 %q 的理由不对(应为逃逸/绝对路径,实际: %v)", from, cerr)
|
||||
}
|
||||
}
|
||||
// 相对路径(相对工程根)是唯一接受的形态
|
||||
ents, err := collectRawFiles(root, []RawFileDecl{{From: "assets/theme.css"}})
|
||||
if err != nil {
|
||||
t.Errorf("工程内的相对路径应被接受: %v", err)
|
||||
} else {
|
||||
found := false
|
||||
for _, e := range ents {
|
||||
if e.zip == "rawfile/theme.css" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("声明的相对路径未落到 rawfile/ 下: %v", zipNames(ents))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**外部声明的目录也要保留结构**(不只是默认目录)。
|
||||
//
|
||||
// 最初只对默认 rawfile/ 目录验了结构保留,于是"外部目录被拍平"
|
||||
// 这个变异没有咬住 —— 判据必须覆盖**每一条**会产生 zip 路径的分支。
|
||||
func TestDeclaredDirectoryKeepsStructure(t *testing.T) {
|
||||
root := mkProject(t)
|
||||
if err := os.MkdirAll(filepath.Join(root, "assets/sub/deep"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "assets/sub/deep/x.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ents, err := collectRawFiles(root, []RawFileDecl{{From: "assets"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want string
|
||||
for _, e := range ents {
|
||||
if strings.HasSuffix(e.zip, "x.txt") {
|
||||
want = e.zip
|
||||
}
|
||||
}
|
||||
if want != "rawfile/assets/sub/deep/x.txt" {
|
||||
t.Fatalf("★ 外部目录未保留结构(期望 rawfile/assets/sub/deep/x.txt,实际 %q)", want)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:脚手架文件(.gitkeep/.gitignore/.DS_Store)**不进包**。
|
||||
//
|
||||
// 实测发现:init 出的空工程第一次 build 就报"已打包 2 个 rawfile",
|
||||
// 里面只有 .gitkeep 与 .gitignore。既是噪声(误导开发者以为有资源),
|
||||
// 也是把工程内部约定发给了运行期。
|
||||
func TestScaffoldFilesAreExcluded(t *testing.T) {
|
||||
root := mkProject(t)
|
||||
for _, f := range []string{".gitkeep", ".gitignore", ".DS_Store", "main/.gitkeep"} {
|
||||
p := filepath.Join(root, "rawfile", f)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ents, err := collectRawFiles(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range ents {
|
||||
if IsRawFileScaffold(e.zip) {
|
||||
t.Errorf("★ 脚手架文件被打进包: %s", e.zip)
|
||||
}
|
||||
}
|
||||
// 真实资源仍要在
|
||||
found := false
|
||||
for _, e := range ents {
|
||||
if strings.HasSuffix(e.zip, "prompt.txt") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("真实资源被误排")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:空工程(只有脚手架)→ 收集结果为空,不该报"打包了 N 个"。
|
||||
func TestEmptyProjectCollectsNothing(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "rawfile"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.WriteFile(filepath.Join(root, "rawfile/.gitkeep"), []byte(""), 0o644)
|
||||
os.WriteFile(filepath.Join(root, "rawfile/.gitignore"), []byte("*\n"), 0o644)
|
||||
ents, err := collectRawFiles(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ents) != 0 {
|
||||
t.Errorf("★ 空工程应收 0 项,实际 %d: %v", len(ents), zipNames(ents))
|
||||
}
|
||||
}
|
||||
18
tools/hmapdev/scaffold_stdout_helper_test.go
Normal file
18
tools/hmapdev/scaffold_stdout_helper_test.go
Normal file
@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
// readEmbeddedLuaTemplate 读 Lua 脚手架模板(若存在)。
|
||||
func readEmbeddedLuaTemplate() (string, error) {
|
||||
// Lua 模板以 embed 或常量形式存在时取之;不存在则跳过。
|
||||
if tmplMainLua != "" {
|
||||
return tmplMainLua, nil
|
||||
}
|
||||
if b, err := os.ReadFile("templates/main.lua.tmpl"); err == nil {
|
||||
return string(b), nil
|
||||
}
|
||||
return "", errors.New("未找到 Lua 脚手架模板")
|
||||
}
|
||||
125
tools/hmapdev/scaffold_stdout_test.go
Normal file
125
tools/hmapdev/scaffold_stdout_test.go
Normal file
@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
// 脚手架模板的**协议通道纪律**判据(2026-10-16)。
|
||||
//
|
||||
// ## 为什么必须有这条
|
||||
//
|
||||
// 子进程插件的 **stdout 是 JSON-RPC 协议通道**,**stderr 才是日志通道**
|
||||
//(SDK 侧 `log.SetOutput(os.Stderr)`,见 proc_main.go.tmpl)。
|
||||
//
|
||||
// 而 `hmapdev init` 生成的 `plugin.go` 原先用 `fmt.Printf` 打日志
|
||||
// ⇒ 写进 **stdout** ⇒ 内核 readLoop 收到非 JSON 文本:
|
||||
//
|
||||
// [proc] final_probe 收到非法 JSON 帧(21 字节): invalid character 'i' in literal false
|
||||
//
|
||||
// 两个后果叠加,都很难定位:
|
||||
// ① 那句日志**丢了**(内核把它当坏帧丢弃);
|
||||
// ② 多了一条看起来像**协议故障**的报错 —— 而真因是"插件自己 printf"。
|
||||
//
|
||||
// 实测发现路径:用新版 hmapdev 脚手架了一个插件装进真机内核,
|
||||
// 启动日志里出现上面那行(其余 20 个插件都没有)。
|
||||
//
|
||||
// ## 判据为什么是"源码扫描"而不是"跑一次插件看日志"
|
||||
//
|
||||
// 该模板是**字符串常量**(`tmplPluginGo`),不是可直接执行的程序;
|
||||
// 真正的行为验证在真机 e2e(已做:final_probe 启动日志干净)。
|
||||
// 这里断言的是**生成物里不存在会写 stdout 的调用** —— 属于
|
||||
// "某个模式是否出现"那一类,源码扫描在此是**恰当**的。
|
||||
//(对比:断言"接线/调用顺序"时源码扫描会骗人,见
|
||||
// docs/zh/kernel-modularization-design.md §8.6。)
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★★ 判据:脚手架生成的 plugin.go **不得**往 stdout 打印。
|
||||
//
|
||||
// ★ 实现要点:**必须先剥掉注释行**。
|
||||
// 模板里的注释**故意**提到 `fmt.Printf`(讲这个坑),若直接扫描字符串
|
||||
// 就会**自己被自己写的注释判红**(实测第一版就是这样,假红)。
|
||||
// 这就是"源码扫描判据"的固有脆弱性 —— 本仓的规范要求把坑写进注释,
|
||||
// 而注释里必然出现那些符号名。
|
||||
//
|
||||
// ⇒ 剥注释后只扫**代码行**,语义才准确。
|
||||
func TestScaffoldTemplateDoesNotWriteStdout(t *testing.T) {
|
||||
code := stripTemplateComments(tmplPluginGo)
|
||||
for _, pat := range []string{"fmt.Printf(", "fmt.Println(", "fmt.Print(", "os.Stdout"} {
|
||||
if strings.Contains(code, pat) {
|
||||
t.Errorf("★ 脚手架模板的**代码**含 `%s` —— 它写 **stdout**,"+
|
||||
"而 stdout 是子进程插件的 **JSON-RPC 协议通道**"+
|
||||
"(内核会报「收到非法 JSON 帧」并丢弃该行日志)。"+
|
||||
"日志必须用 log.Printf(默认 stderr)。", pat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stripTemplateComments 剥掉 Go 源码里的 // 行注释(模板只有行注释)。
|
||||
//
|
||||
// 只处理 `//`:模板里没有块注释,也不需要处理字符串内的 `//`
|
||||
// (模板代码里没有含 // 的字符串字面量;若有,这里会误剥 —— 但那会让
|
||||
//
|
||||
// 判据**更严**(漏扫),不会假绿,可接受)。
|
||||
func stripTemplateComments(src string) string {
|
||||
var out []string
|
||||
for _, ln := range strings.Split(src, "\n") {
|
||||
l := strings.TrimSpace(ln)
|
||||
if strings.HasPrefix(l, "//") {
|
||||
continue
|
||||
}
|
||||
// 行尾注释也剥掉(`x := 1 // fmt.Printf` 这种)
|
||||
if i := strings.Index(ln, "//"); i >= 0 {
|
||||
// 只在该 // 之前没有引号时剥(避免剥掉 URL 之类)
|
||||
if !strings.Contains(ln[:i], `"`) {
|
||||
ln = ln[:i]
|
||||
}
|
||||
}
|
||||
out = append(out, ln)
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// ★★★ 判据:模板必须**有**日志(否则上面的判据可以靠"删掉所有日志"通过)。
|
||||
//
|
||||
// ★ 这条是"反向钉住":光禁 stdout 不够 —— 把日志全删也能过。
|
||||
//
|
||||
// 模板的价值在于示范**正确的**日志方式,所以必须真的用 log.Printf。
|
||||
func TestScaffoldTemplateUsesStderrLogging(t *testing.T) {
|
||||
if !strings.Contains(tmplPluginGo, "log.Printf") {
|
||||
t.Fatal("★ 脚手架模板没有 log.Printf —— 删光日志能通过上一条判据," +
|
||||
"但那就失去了「示范正确做法」的意义")
|
||||
}
|
||||
if !strings.Contains(tmplPluginGo, `"log"`) {
|
||||
t.Fatal("★ 用了 log.Printf 但没 import \"log\" —— 生成物编译不过")
|
||||
}
|
||||
// 关键位置都要走 log(启动/停止/停止钩子)
|
||||
for _, want := range []string{
|
||||
`log.Printf("[%s] started", p.name)`,
|
||||
`log.Printf("[%s] stopped", p.name)`,
|
||||
"stop handler running",
|
||||
} {
|
||||
if !strings.Contains(tmplPluginGo, want) {
|
||||
t.Errorf("★ 模板缺少预期日志: %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**别的脚手架模板**(Lua)也不得写 stdout。
|
||||
//
|
||||
// Lua 插件同理:它走的是同一套子进程协议(main.lua 的 print 会污染帧)。
|
||||
func TestScaffoldLuaTemplateDoesNotWriteStdout(t *testing.T) {
|
||||
src, err := readEmbeddedLuaTemplate()
|
||||
if err != nil {
|
||||
t.Skipf("读不到 Lua 模板(可能已改名): %v", err)
|
||||
}
|
||||
// 正常路径:print(...) 在 Lua 里就是 stdout
|
||||
for _, ln := range strings.Split(src, "\n") {
|
||||
l := strings.TrimSpace(ln)
|
||||
if strings.HasPrefix(l, "--") {
|
||||
continue // 注释
|
||||
}
|
||||
if strings.Contains(l, "print(") && !strings.Contains(l, "io.stderr") {
|
||||
t.Errorf("★ Lua 模板含 `print(`(→ stdout,会污染协议帧): %s", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
129
tools/hmapdev/skills.go
Normal file
129
tools/hmapdev/skills.go
Normal file
@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
// 随包 skill 的打包(`skill/` 目录)。
|
||||
//
|
||||
// ## 为什么与 `rawfile/` 分开,而不是放进 rawfile/
|
||||
//
|
||||
// 两者**意图不同**:
|
||||
//
|
||||
// rawfile/ 任意随包资源(node 二进制、pi 依赖、配置模板…)——内核只是解开
|
||||
// skill/ 要**登记给 skillmgr** 的纯 SKILL 条目 —— 内核安装后会移交注册
|
||||
//
|
||||
// 若把 skill 放在 rawfile/ 下(如 `rawfile/skills/x/SKILL.md`),内核就必须
|
||||
// 靠**子路径名**去猜哪些是要注册的 —— 那是隐式约定,正是本仓反复吃过亏的
|
||||
// 模式(例:config.db 因"默认 0644"这个隐式假设而长期明文)。
|
||||
// 显式目录让意图在**结构**里可见。
|
||||
//
|
||||
// ## 解包形态
|
||||
//
|
||||
// 与 rawfile 一样**保留路径**(不拍平):
|
||||
//
|
||||
// <插件目录>/skill/<name>/SKILL.md
|
||||
//
|
||||
// 内核侧(pluginmgr)扫 `<插件目录>/skill/*` 并把每个目录发
|
||||
// skill_detected 事件,由 skillmgr 接管注册(见
|
||||
// internal/plugins/pluginmgr/skill_handout.go)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SkillDir 是工程根下存放随包 skill 的目录(与 rawfile/ 并列)。
|
||||
const SkillDir = "skill"
|
||||
|
||||
// SkillZipPrefix 是解包后这些条目的统一前缀。
|
||||
//
|
||||
// 与 SkillDir 同值 —— 保留这个独立常量是为了让"打包目录名"与
|
||||
// "包内前缀"可以分别演进(rawfile 那边是两个同名常量,同样的理由)。
|
||||
const SkillZipPrefix = "skill"
|
||||
|
||||
// collectSkillFiles 收集 `skill/` 目录下的条目。
|
||||
//
|
||||
// 与 collectRawFiles 的差别:
|
||||
// - **只收一层**:skill 是「每个子目录一个 skill」,嵌套不再是 skill。
|
||||
// 递归收会让 `skill/a/.git/SKILL.md` 这种被当成 skill 发出去。
|
||||
// - **只收目录**:单文件 skill 不在本约定内(skillmgr 支持,
|
||||
// 但随包分发统一用目录形态,避免两种形态都要判)。
|
||||
// - 仍然排除脚手架(.gitkeep/.gitignore)。
|
||||
func collectSkillFiles(root string) ([]rawEntry, error) {
|
||||
dir := filepath.Join(root, SkillDir)
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || !info.IsDir() {
|
||||
// 没有 skill/ 是**常态**(绝大多数插件不带),不是错误
|
||||
return nil, nil
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 %s/ 失败: %w", SkillDir, err)
|
||||
}
|
||||
|
||||
var out []rawEntry
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue // 脚手架/隐藏项
|
||||
}
|
||||
if !e.IsDir() {
|
||||
// 明确拒绝而不是静默跳过:放在 skill/ 下的文件几乎肯定是放错了
|
||||
//(要么该是 skill/<name>/SKILL.md,要么该放 rawfile/)。
|
||||
return nil, fmt.Errorf(
|
||||
"%s/%s 是文件;%s/ 下只接受**目录**(每个目录一个 skill,形如 %s/<name>/SKILL.md)"+
|
||||
";普通资源请放 rawfile/",
|
||||
SkillDir, name, SkillDir, SkillDir)
|
||||
}
|
||||
sub := filepath.Join(dir, name)
|
||||
// 每个 skill 目录下的文件**递归保留结构**
|
||||
// (有的 skill 带 references/、assets/ 等子目录)。
|
||||
err := filepath.Walk(sub, func(p string, fi os.FileInfo, werr error) error {
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(dir, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if IsRawFileScaffold(rel) {
|
||||
return nil
|
||||
}
|
||||
out = append(out, rawEntry{
|
||||
src: p,
|
||||
zip: filepath.ToSlash(filepath.Join(SkillZipPrefix, rel)),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("展开 %s/%s 失败: %w", SkillDir, name, err)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].zip < out[j].zip })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// skillFilesSummary 给构建输出一行摘要(0 个时不打印)。
|
||||
func skillFilesSummary(ents []rawEntry) string {
|
||||
if len(ents) == 0 {
|
||||
return ""
|
||||
}
|
||||
// 数 skill 个数(一层目录名去重)而不只是文件数 —— 后者对用户无意义
|
||||
names := map[string]bool{}
|
||||
for _, e := range ents {
|
||||
rest := strings.TrimPrefix(e.zip, SkillZipPrefix+"/")
|
||||
if i := strings.Index(rest, "/"); i > 0 {
|
||||
names[rest[:i]] = true
|
||||
}
|
||||
}
|
||||
var list []string
|
||||
for n := range names {
|
||||
list = append(list, n)
|
||||
}
|
||||
sort.Strings(list)
|
||||
return fmt.Sprintf("%d 个 skill(%s,共 %d 个文件)",
|
||||
len(list), strings.Join(list, ", "), len(ents))
|
||||
}
|
||||
176
tools/hmapdev/skills_test.go
Normal file
176
tools/hmapdev/skills_test.go
Normal file
@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
// 随包 skill 打包的判据。
|
||||
//
|
||||
// 约定(与 rawfile/ 平行但**意图不同**):
|
||||
//
|
||||
// skill/<name>/SKILL.md 每个子目录一个 skill,登记给 skillmgr
|
||||
// skill/<name>/skill.json
|
||||
//
|
||||
// 为什么与 rawfile 分开:内核需要知道**哪些是要注册的**。
|
||||
// 靠子路径名猜(rawfile/skills/)是隐式约定,本仓为此吃过亏。
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mkSkillProject 造一个带 skill/ 的工程。
|
||||
func mkSkillProject(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for rel, content := range files {
|
||||
p := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// ★★ 判据:skill/ 下的每个目录被打包,**保留路径**。
|
||||
func TestCollectSkillFiles(t *testing.T) {
|
||||
root := mkSkillProject(t, map[string]string{
|
||||
"skill/alpha/SKILL.md": "# alpha",
|
||||
"skill/alpha/skill.json": `{"name":"alpha"}`,
|
||||
"skill/beta/SKILL.md": "# beta",
|
||||
"skill/beta/references/note.md": "# ref", // 子目录也要保留
|
||||
"rawfile/some/data.bin": "x", // 不该被 skill 收集器收
|
||||
})
|
||||
ents, err := collectSkillFiles(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, e := range ents {
|
||||
got[e.zip] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"skill/alpha/SKILL.md",
|
||||
"skill/alpha/skill.json",
|
||||
"skill/beta/SKILL.md",
|
||||
"skill/beta/references/note.md",
|
||||
} {
|
||||
if !got[want] {
|
||||
t.Errorf("缺条目 %s(实际 %v)", want, keys(got))
|
||||
}
|
||||
}
|
||||
// 不得收 rawfile 下的东西
|
||||
for z := range got {
|
||||
if !strings.HasPrefix(z, "skill/") {
|
||||
t.Errorf("★ 收到了 skill/ 之外的条目: %s", z)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:**没有 skill/ 目录是常态**,不报错、返回空。
|
||||
func TestNoSkillDirIsFine(t *testing.T) {
|
||||
root := mkSkillProject(t, map[string]string{"rawfile/x.txt": "x"})
|
||||
ents, err := collectSkillFiles(root)
|
||||
if err != nil {
|
||||
t.Fatalf("无 skill/ 不应报错: %v", err)
|
||||
}
|
||||
if len(ents) != 0 {
|
||||
t.Errorf("应返回空,实际 %d", len(ents))
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:skill/ 下放**文件**要报错(几乎肯定是放错了)。
|
||||
//
|
||||
// 静默跳过会让"我加的 skill 没生效"变成无提示的谜题。
|
||||
func TestFileInSkillDirIsRejected(t *testing.T) {
|
||||
root := mkSkillProject(t, map[string]string{
|
||||
"skill/stray.md": "# 我放错了",
|
||||
"skill/ok/SKILL.md": "# ok",
|
||||
})
|
||||
_, err := collectSkillFiles(root)
|
||||
if err == nil {
|
||||
t.Fatal("★ skill/ 下的文件应报错(不静默跳过)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rawfile/") {
|
||||
t.Errorf("错误应指出正确去处(rawfile/): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:脚手架不被打包。
|
||||
func TestSkillScaffoldExcluded(t *testing.T) {
|
||||
root := mkSkillProject(t, map[string]string{
|
||||
"skill/.gitignore": "x",
|
||||
"skill/alpha/.gitkeep": "",
|
||||
"skill/alpha/SKILL.md": "# a",
|
||||
})
|
||||
ents, err := collectSkillFiles(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range ents {
|
||||
if IsRawFileScaffold(e.zip) {
|
||||
t.Errorf("★ 脚手架被打包: %s", e.zip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:真正的 zip 里条目名保留完整路径。
|
||||
func TestSkillEntriesInZip(t *testing.T) {
|
||||
root := mkSkillProject(t, map[string]string{
|
||||
"skill/commondev-discipline/SKILL.md": "# 纪律",
|
||||
})
|
||||
ents, err := collectSkillFiles(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "t.hmap")
|
||||
f, _ := os.Create(out)
|
||||
w := zip.NewWriter(f)
|
||||
if err := writeRawEntries(w, ents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Close()
|
||||
f.Close()
|
||||
|
||||
zr, err := zip.OpenReader(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer zr.Close()
|
||||
found := false
|
||||
for _, zf := range zr.File {
|
||||
if zf.Name == "skill/commondev-discipline/SKILL.md" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("★ zip 里未保留 skill 的目录结构")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:摘要报的是 **skill 个数**(不是文件数)。
|
||||
func TestSkillSummaryCountsSkillsNotFiles(t *testing.T) {
|
||||
root := mkSkillProject(t, map[string]string{
|
||||
"skill/a/SKILL.md": "x",
|
||||
"skill/a/references/r.md": "x",
|
||||
"skill/b/SKILL.md": "x",
|
||||
})
|
||||
ents, _ := collectSkillFiles(root)
|
||||
sum := skillFilesSummary(ents)
|
||||
if !strings.Contains(sum, "2 个 skill") {
|
||||
t.Errorf("摘要应按 skill 数计(2 个),实际: %s", sum)
|
||||
}
|
||||
if !strings.Contains(sum, "3 个文件") {
|
||||
t.Errorf("摘要应同时给出文件数: %s", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func keys(m map[string]bool) []string {
|
||||
var o []string
|
||||
for k := range m {
|
||||
o = append(o, k)
|
||||
}
|
||||
return o
|
||||
}
|
||||
@ -13,7 +13,8 @@ const tmplPlgJSON = `{
|
||||
"entry": "{{.Plg.Entry}}",
|
||||
"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
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "{{.Plg.Name}}",
|
||||
"name_zh": "{{.Plg.NameZh}}",
|
||||
"name_en": "{{.Plg.NameEn}}",
|
||||
"version": "{{.Plg.Version}}",
|
||||
"description": "{{.Plg.Description}}",
|
||||
"author": "{{.Plg.Author}}",
|
||||
"entry": "{{.Plg.Entry}}",
|
||||
"tags": {{.Plg.Tags}},
|
||||
"targets": "{{.Plg.Targets}}"
|
||||
}
|
||||
@ -13,11 +13,14 @@ package main
|
||||
|
||||
import (
|
||||
"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})
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user