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

@ -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))
}
}