From 81a3efa4df8ac3162d9df2579541e768cc71f8de Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:44:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(commondev):=20=E4=B8=BB=E7=AE=A1=E5=BC=8F?= =?UTF-8?q?=E5=A4=9A=E4=BB=93=E5=BA=93=E5=BC=80=E5=8F=91=E6=B5=81=E6=B0=B4?= =?UTF-8?q?=E7=BA=BF=20+=20sidecar=20=E5=90=8E=E7=AB=AF=E6=8A=BD=E8=B1=A1?= =?UTF-8?q?=EF=BC=88pi/zcode=EF=BC=89=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 个会话全落全局库)。但 `/.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 --- .gitignore | 4 + docs/versions.md | 192 ++ example/ai_image/plugin.go | 44 +- example/calendar/plugin.go | 11 +- example/commondev/README.md | 28 + example/commondev/addressing_test.go | 222 +++ example/commondev/alignment_test.go | 338 ++++ example/commondev/async_test.go | 103 ++ example/commondev/audit_test.go | 216 +++ example/commondev/channelname_test.go | 84 + example/commondev/conflict.go | 400 +++++ example/commondev/conflict_parse_test.go | 335 ++++ example/commondev/credentials.go | 184 ++ example/commondev/criteria.go | 767 ++++++++ example/commondev/criteria_test.go | 378 ++++ example/commondev/e2e-agentdir/auth.json | 1 + .../commondev/e2e-agentdir/models-store.json | 1 + example/commondev/e2e_criteria_test.go | 177 ++ example/commondev/encshell.go | 429 +++++ example/commondev/encshell_test.go | 243 +++ example/commondev/encstore.go | 408 +++++ example/commondev/encstore_test.go | 339 ++++ example/commondev/endpoints.go | 637 +++++++ example/commondev/endpoints_test.go | 388 ++++ .../commondev/examples/custom-cli/README.md | 217 +++ .../examples/custom-cli/zcode/driver.json | 22 + .../examples/custom-cli/zcode/sidecar.mjs | 287 +++ example/commondev/flow_coverage_test.go | 368 ++++ example/commondev/full_pipeline_test.go | 190 ++ example/commondev/go.mod | 7 + example/commondev/llmsource.go | 447 +++++ example/commondev/llmsource_e2e_test.go | 352 ++++ example/commondev/main.go | 11 + example/commondev/pi_e2e_test.go | 273 +++ example/commondev/pi_runtime_test.go | 387 ++++ example/commondev/pi_tools.go | 664 +++++++ example/commondev/plg.json | 12 + example/commondev/plugin.go | 1585 +++++++++++++++++ example/commondev/rawfile/.gitignore | 10 + example/commondev/rawfile/.gitkeep | 0 example/commondev/runtime/pi/driver.json | 14 + example/commondev/sidecar.go | 590 ++++++ example/commondev/sidecar.mjs | 173 ++ example/commondev/sidecar_driver.go | 331 ++++ example/commondev/sidecar_driver_test.go | 297 +++ example/commondev/sidecar_ssh.go | 345 ++++ example/commondev/sidecar_ssh_test.go | 210 +++ example/commondev/skill/.gitignore | 3 + .../skill/commondev-discipline/SKILL.md | 855 +++++++++ .../skill/commondev-discipline/skill.json | 6 + example/commondev/ssh_e2e_test.go | 252 +++ example/commondev/ssh_wiring_test.go | 808 +++++++++ example/commondev/util.go | 72 + example/commondev/workdir.go | 154 ++ example/commondev/workdir_remote.go | 235 +++ example/commondev/workdir_remote_test.go | 400 +++++ example/commondev/workdir_security.go | 172 ++ example/commondev/workdir_test.go | 71 + example/commondev/zcode_e2e_test.go | 514 ++++++ example/editdoc/README.md | 69 +- example/editdoc/embed/edit_doc.py | 1390 +++++++++++++++ example/editdoc/main.go | 2 - example/editdoc/plg.json | 19 +- example/editdoc/plugin.go | 580 ++++-- example/editdoc/plugin_test.go | 178 ++ example/qq/plugin.go | 89 +- example/qq/plugin_test.go | 149 ++ example/rss/plugin.go | 9 +- example/weather/plugin.go | 29 +- package/build-examples.sh | 35 +- sdk/facilities.go | 77 + sdk/plugin.go | 65 +- sdk/rawfiles.go | 56 + sdk/secrets.go | 111 ++ sdk/settings.go | 15 + skills/homeagent-plugin-dev/SKILL.md | 75 +- tools/hmapdev/cmd_build.go | 136 +- tools/hmapdev/cmd_init.go | 46 + tools/hmapdev/example_gomod_test.go | 129 ++ tools/hmapdev/example_stdout_test.go | 126 ++ tools/hmapdev/package_examples_test.go | 303 ++++ tools/hmapdev/rawfiles.go | 294 +++ tools/hmapdev/rawfiles_test.go | 420 +++++ tools/hmapdev/scaffold_stdout_helper_test.go | 18 + tools/hmapdev/scaffold_stdout_test.go | 125 ++ tools/hmapdev/skills.go | 129 ++ tools/hmapdev/skills_test.go | 176 ++ tools/hmapdev/templates.go | 23 +- tools/hmapdev/templates/plg.json.tmpl | 11 - tools/hmapdev/templates/proc_main.go.tmpl | 253 +++ tools/hmapdev/yaegi/mocksdk/plugin.go | 8 + 91 files changed, 21184 insertions(+), 224 deletions(-) create mode 100644 example/commondev/README.md create mode 100644 example/commondev/addressing_test.go create mode 100644 example/commondev/alignment_test.go create mode 100644 example/commondev/async_test.go create mode 100644 example/commondev/audit_test.go create mode 100644 example/commondev/channelname_test.go create mode 100644 example/commondev/conflict.go create mode 100644 example/commondev/conflict_parse_test.go create mode 100644 example/commondev/credentials.go create mode 100644 example/commondev/criteria.go create mode 100644 example/commondev/criteria_test.go create mode 100644 example/commondev/e2e-agentdir/auth.json create mode 100644 example/commondev/e2e-agentdir/models-store.json create mode 100644 example/commondev/e2e_criteria_test.go create mode 100644 example/commondev/encshell.go create mode 100644 example/commondev/encshell_test.go create mode 100644 example/commondev/encstore.go create mode 100644 example/commondev/encstore_test.go create mode 100644 example/commondev/endpoints.go create mode 100644 example/commondev/endpoints_test.go create mode 100644 example/commondev/examples/custom-cli/README.md create mode 100644 example/commondev/examples/custom-cli/zcode/driver.json create mode 100644 example/commondev/examples/custom-cli/zcode/sidecar.mjs create mode 100644 example/commondev/flow_coverage_test.go create mode 100644 example/commondev/full_pipeline_test.go create mode 100644 example/commondev/go.mod create mode 100644 example/commondev/llmsource.go create mode 100644 example/commondev/llmsource_e2e_test.go create mode 100644 example/commondev/main.go create mode 100644 example/commondev/pi_e2e_test.go create mode 100644 example/commondev/pi_runtime_test.go create mode 100644 example/commondev/pi_tools.go create mode 100644 example/commondev/plg.json create mode 100644 example/commondev/plugin.go create mode 100644 example/commondev/rawfile/.gitignore create mode 100644 example/commondev/rawfile/.gitkeep create mode 100644 example/commondev/runtime/pi/driver.json create mode 100644 example/commondev/sidecar.go create mode 100644 example/commondev/sidecar.mjs create mode 100644 example/commondev/sidecar_driver.go create mode 100644 example/commondev/sidecar_driver_test.go create mode 100644 example/commondev/sidecar_ssh.go create mode 100644 example/commondev/sidecar_ssh_test.go create mode 100644 example/commondev/skill/.gitignore create mode 100644 example/commondev/skill/commondev-discipline/SKILL.md create mode 100644 example/commondev/skill/commondev-discipline/skill.json create mode 100644 example/commondev/ssh_e2e_test.go create mode 100644 example/commondev/ssh_wiring_test.go create mode 100644 example/commondev/util.go create mode 100644 example/commondev/workdir.go create mode 100644 example/commondev/workdir_remote.go create mode 100644 example/commondev/workdir_remote_test.go create mode 100644 example/commondev/workdir_security.go create mode 100644 example/commondev/workdir_test.go create mode 100644 example/commondev/zcode_e2e_test.go create mode 100644 example/editdoc/embed/edit_doc.py create mode 100644 example/editdoc/plugin_test.go create mode 100644 sdk/facilities.go create mode 100644 sdk/rawfiles.go create mode 100644 sdk/secrets.go create mode 100644 tools/hmapdev/example_gomod_test.go create mode 100644 tools/hmapdev/example_stdout_test.go create mode 100644 tools/hmapdev/package_examples_test.go create mode 100644 tools/hmapdev/rawfiles.go create mode 100644 tools/hmapdev/rawfiles_test.go create mode 100644 tools/hmapdev/scaffold_stdout_helper_test.go create mode 100644 tools/hmapdev/scaffold_stdout_test.go create mode 100644 tools/hmapdev/skills.go create mode 100644 tools/hmapdev/skills_test.go delete mode 100644 tools/hmapdev/templates/plg.json.tmpl diff --git a/.gitignore b/.gitignore index f2effc7..58e2c54 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ site_build/ # 每次改任何一页都会整份重写(115KB),进版本库只产生噪声。它由构建产出, # llms.txt(索引,小且稳定)仍提交。 docs/llms-full.txt + +# ★ rawfile/ 的内容是随包资源(可能内嵌 node/pi 等上百兆二进制), +# 由构建方自行准备,不进版本库。忽略规则放在 rawfile/.gitignore 内 +# (自包含,避免依赖上层规则的作用域与否定式语义)。 diff --git a/docs/versions.md b/docs/versions.md index 8308b18..4bff568 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -31,6 +31,198 @@ 零值 `InjectOptions` 与旧的三参数方法完全等价,因此存量插件**不需要改、也不需要重编**; 想用新字段的重编即可。 +### SDK 1.4.0 → 本轮新增(PluginMgr 运行态启停)⚠️ 有一个例外 + +本轮给 `PluginMgrAPI` 新增 3 个方法(`StopPlugin` / `StartPlugin` / +`IsPluginRuntimeStopped`,见 `docs/zh/kernel-modularization-design.md` §2.6.7)。 + +按上面的规则,这属于"**插件调用、内核实现**"的纯追加 ⇒ 无需重编。**但有一个例外**: + +| 对象 | 是否需要动 | 原因 | +|---|---|---| +| **已编译的存量插件**(`plugin.bin`) | ❌ 不需要 | 不调新方法就不受影响(与 1.1/1.2 一致) | +| **存量插件的源码要重编** | ⚠️ **需要同步 hmapdev** | `proc_main.go.tmpl` 里的 `procPluginMgr` 必须实现新方法,否则**编译不过** | +| **新插件** | ✅ 自动 | 模板已同步(`hmapdev` 已更新) | + +!!! warning "为什么这次和 1.1/1.2 不一样" + 1.1/1.2 只改了 `sdk/`(插件**调用**的一侧)与内核实现。 + 本轮还改了 **`tools/hmapdev/templates/proc_main.go.tmpl`** —— + 那是**生成插件代码**的模板,属于"插件**被编译**的一侧"。 + + `plugin.bin` 里的 `procPluginMgr` 是**编译期**由模板生成的具体类型, + 它必须满足 `PluginMgrAPI` 的**全部**方法(Go 接口是精确匹配)。 + 模板不同步 ⇒ 生成物不满足接口 ⇒ **编译失败**(不是运行期降级)。 + + ⇒ 判据 `internal/plugin/proc/bridge_sync_test.go` 已有 3 条自动守这个 + (SDK 方法集 ⇒ 能力表 / Handle 分派 / hmapdev 模板,三处必须同步)。 + + + +## 脚手架模板的协议通道纪律(`hmapdev init` 生成物) + +子进程插件的 **stdout 是 JSON-RPC 协议通道**,**stderr 才是日志通道** +(SDK 侧 `log.SetOutput(os.Stderr)`)。这条纪律**必须写进脚手架**, +否则每个新插件作者都会踩。 + +### 曾踩的坑(2026-10-16 实测) + +`hmapdev init` 生成的 `plugin.go` 原先用 `fmt.Printf` 打日志 ⇒ 写进 stdout +⇒ 内核 readLoop 收到非 JSON 文本: + +``` +[proc] final_probe 收到非法 JSON 帧(21 字节): invalid character 'i' in literal false +``` + +两个后果叠加、都很难定位: + +1. 那句日志**丢了**(内核把它当坏帧丢弃); +2. 多了一条看起来像**协议故障**的报错 —— 而真因是"插件自己 printf"。 + +**A/B 实证**(同一内核,两个插件): + +| 插件 | 脚手架 | 非法 JSON 帧 | 日志可见 | +|---|---|---|---| +| `final_probe` | 旧(`fmt.Printf`) | **1 次** | ❌ 丢失 | +| `fixed_probe` | 新(`log.Printf`) | **0 次** | ✅ `[fixed_probe] started` | + +### 修法 + +模板改成 `log.Printf`,并把这条纪律写进生成物的**头部注释** +(让每个插件作者第一眼就看到)。 + +### 判据(`tools/hmapdev/scaffold_stdout_test.go`,3 条) + +1. `TestScaffoldTemplateDoesNotWriteStdout` —— **代码行**里不得有 + `fmt.Printf/Println/Print/os.Stdout` +2. `TestScaffoldTemplateUsesStderrLogging` —— 反向钉住:光禁 stdout 不够, + 把日志全删也能过第一条 ⇒ 必须**真的**用 `log.Printf`(并 import "log") +3. `TestScaffoldLuaTemplateDoesNotWriteStdout` —— Lua 脚手架同理 + +**变异 2/2 咬住**:改回 `fmt.Printf` ⇒ 红;删光日志 ⇒ 红。 + +!!! warning "判据自身踩的坑(值得记住)" + 第一版判据直接扫描模板字符串 ⇒ **被模板里的注释判红**(注释**故意** + 提到 `fmt.Printf` 来讲这个坑)。必须**先剥注释**再扫代码行。 + + 这是"源码扫描判据"的固有脆弱性:本仓规范要求把坑写进注释, + 而注释里必然出现那些符号名 ⇒ **任何时候用字符串扫描判据, + 都要先想"注释里会不会也有这个串"**(同类事故见 + `docs/zh/kernel-modularization-design.md` §8.6,那里连续骗过三次)。 + + +## 示例插件的协议通道纪律(已修) + +上面的脚手架纪律同样适用于**示例插件** —— 它们是**子进程插件** +(`package main` + `plugin.bin`),stdout 即协议通道。 + +### 生产环境实测的缺陷(不是理论问题) + +`journalctl -u homeagent | grep -c "非法 JSON 帧"` ⇒ **1038 条**,来源: + +| 插件 | 次数 | 本仓? | +|---|---|---| +| `huawei_smarthome` | 720 | 第三方(内核侧报错就是为它准备的) | +| **`weather`** | 113 | ✅ 示例 | +| **`rss`** | 103 | ✅ 示例 | +| **`calendar`** | 102 | ✅ 示例 | + +**根因**:这三个示例插件(以及 `ai_image`)用了 `fmt.Printf` 打日志 ⇒ +裸文本进入 stdout 协议流 ⇒ 内核报非法帧并**丢弃该日志**。 + +### 修法(已完成) + +4 个插件共 **14 处** `fmt.Printf` → `log.Printf`,并补 `"log"` import +(`calendar`/`rss`/`weather` 原先没 import log;`ai_image` 已有)。 + +★ 注意:`internal/plugins/cli` 里也有一处 `fmt.Println`,但它是 +**进程内插件**(`plugin.RegisterFactory`,编译进内核), +写的是内核的真实终端 ⇒ **不是缺陷**。判据只针对"子进程插件" +(`package main`)。 + +### 判据(`tools/hmapdev/example_stdout_test.go`,2 条) + +1. `TestExamplePluginsDoNotWriteStdout` —— 扫描全部**子进程**示例插件 + (实测 20 个),剥注释后不得含 `fmt.Printf/Println/Print/os.Stdout`; + 报错时给出**文件:行号** +2. `TestExamplePluginsUseStderrLogging` —— 反向钉住:至少 weather/rss/calendar + 真的在用 `log.Printf`(否则"删光日志"也能过第一条) + +### 内核侧:让非法帧报错**可定位**(这是对第三方插件的通用修复) + +原报错只有 `收到非法 JSON 帧(N 字节): invalid character 'x'`: +不显示内容(不知是哪句话)、不提原因(看起来像内核故障)。 +1038 条报错的定位花了一整轮排查 —— 就因为缺这两样。 + +现在报错含**三要素**: + +``` +[proc] bad_probe 收到非法 JSON 帧(19 字节): invalid character 'b' looking for beginning of value + ↑ 内容摘要: [bad_probe] started + 可能原因:插件往 **stdout** 打印了日志,而 stdout 是JSON-RPC **协议通道**(stderr 才是日志通道)。 + 修法:把插件里的 fmt.Printf/fmt.Println 改成 log.Printf(脚手架模板 1.4.0 起已修正)。 +``` + +(`summarizeFrame` 做控制字符替换 + 截断,保证单行且不刷屏。) + +判据在 `internal/plugin/proc/process_test.go`: +`TestSummarizeFrameIsReadable`、`TestIllegalFrameErrorIsActionable`。 +**变异 2/2 咬住**(去掉 summarizeFrame 调用 ⇒ 红;报错不提原因 ⇒ 红)。 + +## 升级已装的 SDK store 与 hmapdev 二进制(操作步骤) + +!!! danger "顺序不能反 —— 否则会装出「二进制新、store 旧」的组合" + **模板是 `//go:embed` 进 hmapdev 二进制的**(`tools/hmapdev/proc_runtime.go`), + 而 **SDK 接口(`sdk/`)来自 store**。二者必须**同代**: + + | 组合 | 结果 | + |---|---| + | 旧二进制 + 旧 store | 自洽(但拿不到新能力) | + | 新二进制 + 新 store | ✅ 自洽 | + | **新二进制 + 旧 store** | ⚠️ 模板的 `procPluginMgr` 有 6 个方法,旧接口只有 3 个 ⇒ 生成物**多出 3 个方法**(Go 允许,但新方法调用的 `plugin.mgrStop` 内核可能不认) | + | **旧二进制 + 新 store** | ❌ 模板 3 个方法 vs 接口 6 个 ⇒ 生成物**不满足接口** ⇒ **编译失败** | + + ⇒ **先升级 store,再升级二进制**(或两者同一批做完)。 + +### 步骤(本机,用源码) + +```bash +# 1) 用**当前**hmapdev 把本机源码装成 store(--from 在旧版也支持) +hmapdev sdk install --from # 例:third_party/homeagent-sdk + +# 2) 重编 hmapdev 二进制(模板 embed 进去了,必须重编) +cd /tools/hmapdev +CGO_ENABLED=1 go build -tags onnxruntime -o /tmp/hmapdev-new . # TAG_ARGS 视内核而定 + +# 3) 原子替换(先备份!) +cp /usr/local/bin/hmapdev /usr/local/bin/hmapdev.bak.$(date +%Y%m%d-%H%M%S) +install -m 0755 /tmp/hmapdev-new /usr/local/bin/hmapdev +``` + +### 验证(三步都要过) + +```bash +hmapdev --version # 版本号 +strings /usr/local/bin/hmapdev | grep -c mgrStop # ≥1 ⇒ 新模板已 embed +diff <源码>/tools/hmapdev/templates/proc_main.go.tmpl \ + ~/.homeagent/hmapdev/sdk//tools/hmapdev/templates/proc_main.go.tmpl # 必须无差异 +``` + +★ **`diff` 陷阱**:本机 PATH 上的 `diff` 是 HarmonyOS 工具链里的一个 +**坏二进制**(`/opt/huawei/harmonyos/ohos-sdk/linux/toolchains/diff`), +对**不同**的文件也返回 **exit 0 且无输出** ⇒ 会给出"已同步"的**假结论**。 +用 `cmp` 或 Python 比对,或显式用 `/usr/bin/diff`。 + +### 判据(自动守"三处同步") + +`internal/plugin/proc/bridge_sync_test.go` 有 3 条,任一不同步即红: + +1. `TestBridgeCoversEveryPublishedPluginMgrMethod` —— SDK 方法集 ↔ 能力表 +2. `TestBridgeDispatchesEveryPluginMethod` —— 内核 Handle 分派 +3. `TestHmapdevTemplateImplementsAllPluginMgrMethods` —— **hmapdev 模板** + +第 3 条直接读 `third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl`, +所以**源码模板**始终被守;store 里的副本靠上面的 `diff` 验证。 + !!! tip "什么时候必须重编" 只有一种情况:**内核跨了主版本**(如 1.x → 2.x)。 主版本跃迁是破坏性变更的定义,此后的帧格式与共享段布局都不兼容, diff --git a/example/ai_image/plugin.go b/example/ai_image/plugin.go index 2c1fad2..9e3753e 100644 --- a/example/ai_image/plugin.go +++ b/example/ai_image/plugin.go @@ -17,15 +17,15 @@ import ( ) type Plugin struct { - name string - sdk *sdk.PluginSDK - client *http.Client - apiKey string + name string + sdk *sdk.PluginSDK + client *http.Client + apiKey string provider string - model string - size string - baseURL string - dataDir string // /ai_images:生成本地图片存放目录 + model string + size string + baseURL string + dataDir string // /ai_images:生成本地图片存放目录 } func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { @@ -119,12 +119,12 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.Settings().RegisterDef(sdk.ConfigDef{ Key: "base_url", Default: "", Type: "string", DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081);为空走官方 https://api.openai.com", - Category: "ai_image", + Category: "ai_image", }) s.Settings().RegisterDef(sdk.ConfigDef{ Key: "provider", Default: "openai", Type: "string", DisplayName: "Provider", Description: "Image generation provider: openai / stability", - Category: "ai_image", + Category: "ai_image", }) s.Settings().RegisterDef(sdk.ConfigDef{ Key: "model", Default: "dall-e-3", Type: "string", @@ -143,17 +143,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { p.size = getSetting(s.Settings(), "size", "1024x1024") p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/") -// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。 -if p.sdk != nil { - if dd := s.Settings().DataDir(); dd != "" { - p.dataDir = dd + // 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。 + if p.sdk != nil { + if dd := s.Settings().DataDir(); dd != "" { + p.dataDir = dd + } } -} -if p.dataDir == "" { - // 旧版内核无 DataDir API 时退到 /tmp - p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images") -} -os.MkdirAll(p.dataDir, 0755) + if p.dataDir == "" { + // 旧版内核无 DataDir API 时退到 /tmp + p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images") + } + os.MkdirAll(p.dataDir, 0755) tp := p.name + "_" s.RegisterTool(tp+"generate", sdk.ToolDef{ @@ -170,12 +170,12 @@ os.MkdirAll(p.dataDir, 0755) }, }, p.handleGenerate) - fmt.Printf("[%s] started (provider=%s, model=%s)\n", p.name, p.provider, p.model) + log.Printf("[%s] started (provider=%s, model=%s)", p.name, p.provider, p.model) return nil } func (p *Plugin) Stop() error { - fmt.Printf("[%s] stopped\n", p.name) + log.Printf("[%s] stopped", p.name) return nil } diff --git a/example/calendar/plugin.go b/example/calendar/plugin.go index a22a835..c526655 100644 --- a/example/calendar/plugin.go +++ b/example/calendar/plugin.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "log" "os" "path/filepath" "sort" @@ -285,7 +286,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { } p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar") if err := os.MkdirAll(p.dataDir, 0755); err != nil { - fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err) + log.Printf("[%s] mkdir %s: %v", p.name, p.dataDir, err) } p.loadEvents() @@ -401,7 +402,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { p.wg.Add(1) go p.remindLoop() - fmt.Printf("[%s] started (%d events)\n", p.name, len(p.events)) + log.Printf("[%s] started (%d events)", p.name, len(p.events)) return nil } @@ -409,7 +410,7 @@ func (p *Plugin) Stop() error { p.remindTicker.Stop() close(p.stopCh) p.wg.Wait() - fmt.Printf("[%s] stopped\n", p.name) + log.Printf("[%s] stopped", p.name) return nil } @@ -611,9 +612,9 @@ func (p *Plugin) cleanupData() { p.mu.Lock() defer p.mu.Unlock() if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) { - fmt.Printf("[calendar] onRemove cleanup: %v\n", err) + log.Printf("[calendar] onRemove cleanup: %v", err) } else { - fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile()) + log.Printf("[calendar] onRemove removed %s", p.eventsFile()) } } diff --git a/example/commondev/README.md b/example/commondev/README.md new file mode 100644 index 0000000..e551580 --- /dev/null +++ b/example/commondev/README.md @@ -0,0 +1,28 @@ +# commondev + +commondev plugin + +## Build + +```bash +hmapdev build +``` + +## Channels + +入站与出站是分开登记的两件事: + +| 方向 | API | 用途 | +|---|---|---| +| 入站 inputch | RegisterInputChannel(name, def) | 声明「谁会往这个通道注入输入」。**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "", ...) 注入的通道名都要登记** | +| 出站 output | RegisterOutputChannel(name, caps, desc, def, handler) | 声明 output_send__ 的回复发给谁;handler 收到 {payload,type,meta} | + +def(ChannelDef)描述该通道在记忆计算层的行为:NoMemory: true = 该通道输入不进记忆; +Cleaner = 计算层清洗后再向量化/提关键词(原文不改)。 + +> 只登记出站通道、却用同名通道注入输入时,内核会兜底登记同名 inputch 并在日志里告警。 +> 兜底只为兼容老插件 —— 请显式登记,让「这是入站通道」成为插件的明确意图。 + +## Install + +Upload the .hmap file through the Plugin Manager API. diff --git a/example/commondev/addressing_test.go b/example/commondev/addressing_test.go new file mode 100644 index 0000000..8b34d51 --- /dev/null +++ b/example/commondev/addressing_test.go @@ -0,0 +1,222 @@ +package main + +// 「投到指定目录的指定 session」的判据。 +// +// 用户问的是这句话能不能成立。成立的前提是**地址唯一确定一条线**: +// branch_id → 工作目录(program/-) +// → pi session(SessionID) +// → 一对通道(InputCh / OutputCh) +// +// 这四者必须一一对应,且**不能被两条线共享**。共享的任何一环都会让 +// "投到指定 session"变成"投到某一条(不确定)"。 +// +// 最危险的一环是唯一性:独立 clone 没有 worktree 的 +// 「同分支不可双挂」保护(git 会拒绝),所以插件必须自己补闸。 +// 设计 §4.3 明确写了这条,但**此前没有判据钉住它**。 + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// newTestPluginForAddr 造一个只做台账的 Plugin(不碰网络/进程)。 +// +// ★ ledger.json 放在 t.TempDir() **之外**的独立临时目录: +// branch_open 会把 clone 转后台 goroutine(runPrepareWorkdir), +// 而它会在 programRoot() 下建目录 —— 与 t.TempDir() 的清理构成竞态 +// (实测报 "TempDir RemoveAll cleanup: directory not empty")。 +// 用 os.MkdirTemp + 手动清理可以让清理发生在 goroutine 之后。 +func newTestPluginForAddr(t *testing.T) *Plugin { + t.Helper() + dir, err := os.MkdirTemp("", "cdaddr") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + p := newTestPluginForShell(t) + p.path = filepath.Join(dir, "ledger.json") + p.ledger = &Ledger{ + Projects: map[string]*Project{}, + Branches: map[string]*Branch{}, + Jobs: map[string]*Job{}, + Criteria: map[string]*Criteria{}, + } + return p +} + +// ★★★ 判据:同名分支不能开两条未结的工程对话(唯一性闸)。 +// +// 这是"投到指定 session"的**前提**:若同一个 branch_id 能对应两条线, +// 或者两条线共用同一个地址,寻址就不再唯一。 +// +// ★ 独立 clone 丢掉了 git 的天然保护:worktree 模式下同一分支被两棵 +// worktree 挂载时 git 直接 `fatal: 'feat/a' is already used`, +// 而独立 clone 完全没有这层拦截 —— 必须由插件补,否则两条线会 +// 各自改各自的目录然后互相覆盖。 +func TestDuplicateBranchIsRejected(t *testing.T) { + p := newTestPluginForAddr(t) + p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"} + + // 第一次开:应成功 + r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/a"}) + m1, _ := r1.(map[string]interface{}) + if m1["status"] != "ok" { + t.Fatalf("首次开分支应成功: %v", m1) + } + id1, _ := m1["branch_id"].(string) + if id1 == "" { + t.Fatal("应返回 branch_id") + } + + // 第二次开同一个分支:应被唯一性闸拒绝 + r2, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/a"}) + m2, _ := r2.(map[string]interface{}) + if _, isErr := m2["error"]; !isErr { + t.Fatalf("★ 同名分支被允许开第二条(唯一性闸失效): %v", m2) + } + if !strings.Contains(m2["error"].(string), "唯一性闸") { + t.Errorf("拒绝理由应指明唯一性闸: %v", m2["error"]) + } + + // 台账里必须只有一条 + if n := len(p.ledger.Branches); n != 1 { + t.Errorf("★ 台账里应只有 1 条分支,实际 %d", n) + } +} + +// ★★ 判据:**已结**的分支可以重开(闸只挡"未结"的)。 +// +// 闸太严会让"合入后想再改同一个分支名"变成不可能 —— +// 而那是正常做法(分支已 merged,重开一条新的同名线是合理的)。 +func TestClosedBranchCanBeReopened(t *testing.T) { + p := newTestPluginForAddr(t) + p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"} + + r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/b"}) + id1 := r1.(map[string]interface{})["branch_id"].(string) + + // 标记为已合入 + p.ledger.Branches[id1].Status = "merged" + + // 重开应成功 + r2, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/b"}) + m2, _ := r2.(map[string]interface{}) + if m2["status"] != "ok" { + t.Fatalf("已结分支应可重开: %v", m2) + } + // 但 id 相同 ⇒ 会覆盖旧台账项;这本身是设计取舍,只断言不报错 +} + +// ★★ 判据:review 状态同样算"未结"(不能趁复核期再开一条同类线)。 +func TestReviewStatusStillBlocksDuplicate(t *testing.T) { + p := newTestPluginForAddr(t) + p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"} + r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/c", + "work_desc": "x"}) + id1 := r1.(map[string]interface{})["branch_id"].(string) + p.ledger.Branches[id1].Status = "review" + + r2, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/c"}) + if _, isErr := r2.(map[string]interface{})["error"]; !isErr { + t.Errorf("★ review 状态应算未结,但被允许重开") + } +} + +// ★★★ 判据:地址四元组**一一对应**且互不共享。 +// +// branch_id 必须唯一映射到 一个目录 + 一个 session + 一对通道。 +// 任意两条线共用其中任何一项,寻址就不唯一("投到指定 session"退化为 +// "投到某一条")。 +func TestAddressTupleIsUniquePerBranch(t *testing.T) { + p := newTestPluginForAddr(t) + p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"} + // 造两条不同的线 + for _, br := range []string{"feat/x", "feat/y"} { + r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": br}) + if _, isErr := r.(map[string]interface{})["error"]; isErr { + t.Fatalf("开 %s 失败: %v", br, r) + } + } + if len(p.ledger.Branches) != 2 { + t.Fatalf("应有 2 条线,实际 %d", len(p.ledger.Branches)) + } + + seenDir := map[string]string{} + seenID := map[string]bool{} + for id, b := range p.ledger.Branches { + if seenID[id] { + t.Errorf("★ branch_id 重复: %s", id) + } + seenID[id] = true + if b.Workdir == "" { + t.Errorf("★ %s 没有工作目录", id) + } + // 目录不能共享 + if prev, dup := seenDir[b.Workdir]; dup { + t.Errorf("★ 两条线共用同一目录: %s(%s 与 %s)—— 投到目录无法区分线", b.Workdir, prev, id) + } + seenDir[b.Workdir] = id + } + + // ★ 关键:不同分支名必须得到不同目录(设计 §4.3 的目录映射不变式) + r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat.z"}) + _ = r1 + // feat.z 与 feat/z 派生后 slug 相同,但目录 hash 含原始分支名 ⇒ 目录不同 + d1 := branchDir(p.programRoot(), "proj", "feat/z") + d2 := branchDir(p.programRoot(), "proj", "feat.z") + if d1 == d2 { + t.Errorf("★ feat/z 与 feat.z 得到了同一目录 %s —— 两条线会互相覆盖", d1) + } + if filepath.Base(d1) == filepath.Base(d2) { + t.Errorf("★ 目录基名相同 %s,说明 hash 没把分支名纳入", filepath.Base(d1)) + } +} + +// ★★ 判据:投递必须打到**该 id 自己的**通道。 +// +// deliverToPi 从台账取 SessionID;通道名由 id 派生。 +// 若两条线的通道名撞了,内核路由就会把任务投到错的那条 pi。 +func TestChannelNamesAreUniquePerBranch(t *testing.T) { + names := map[string]string{} + for _, id := range []string{ + "br-proj-feat_a", "br-proj-feat_b", "br-other-feat_a", + } { + in := inputChannelName(id) + out := outputChannelName(id) + for _, n := range []string{in, out} { + if prev, dup := names[n]; dup { + t.Errorf("★ 通道名冲突 %q(%s 与 %s)—— 投递会打到错的后端", n, prev, id) + } + names[n] = id + } + // outputch 不能含斜杠(会拼成 output_send__) + if strings.Contains(out, "/") { + t.Errorf("★ outputch 含斜杠: %q(output_send__ 名不允许)", out) + } + // 上游要求 ^[a-zA-Z0-9_-]{1,64}$ + if !validNamePattern.MatchString(out) || len(out) > 64 { + t.Errorf("outputch %q 不合规(须匹配 ^[a-zA-Z0-9_-]{1,64}$)", out) + } + } +} + +// ★★ 判据:**会话漂移必须响亮失败**(不静默改投新会话)。 +// +// 这是"投到指定 session"的守门人:如果 pi 返回了不同的 session_id +// 而插件默默接受,任务就投到了另一条会话 —— 而调用方以为投到了指定的那条。 +func TestSessionDriftIsRejectedNotSilentlyAccepted(t *testing.T) { + b, err := os.ReadFile("plugin.go") + if err != nil { + t.Skip("读不到 plugin.go") + } + src := string(b) + if !strings.Contains(src, "会话漂移") { + t.Fatal("★ deliverToPi 缺少会话漂移检查") + } + // 必须**拒绝**而不是记录后继续 + if !strings.Contains(src, "拒绝继续") { + t.Error("★ 会话漂移应明确拒绝,而非仅记录") + } +} diff --git a/example/commondev/alignment_test.go b/example/commondev/alignment_test.go new file mode 100644 index 0000000..e27b9e1 --- /dev/null +++ b/example/commondev/alignment_test.go @@ -0,0 +1,338 @@ +package main + +// 对齐审计:**逐个调用**已注册工具,检查它们真的能工作。 +// +// 为何要这个:注册了名字 ≠ 能用。上一轮补了工具,但「能注册」只证明 +// 编译通过。本文件对每个工具做一次最小真实调用,把「写了但没接上」 +// (空返回、nil handler、路径写死错、参数名不匹配)暴露出来。 +// +// 判定原则: +// - 返回 error ≠ 失败(很多工具对缺参数就是返回 error,那是正确的响亮行为) +// - 真正的失败是:panic、handler 为 nil、或**参数齐全却依然拒绝** +// - 这里的目标是发现"连正确调用都走不通"的工具 + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// listRegisteredTools 从 plugin.go 里抽出注册的工具名。 +// 用源码而非运行期 sdk,因为 SDK 的 mock 不暴露注册表。 +func listRegisteredTools(t *testing.T) []string { + t.Helper() + b, err := os.ReadFile("plugin.go") + if err != nil { + t.Fatal(err) + } + var out []string + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + const p = `p.sdk.RegisterTool("` + if i := strings.Index(line, p); i >= 0 { + rest := line[i+len(p):] + if j := strings.Index(rest, `"`); j > 0 { + out = append(out, rest[:j]) + } + } + } + return out +} + +// handlerFor 通过名字映射到实际 handler 函数。 +// +// ★ 这里刻意**手写映射**而不是反射:如果新增了工具却忘了在这里登记, +// 下面的覆盖率判据会报出来 —— 这正好是"新工具没接 handler"的探测。 +func handlerFor(p *Plugin, name string) func(map[string]interface{}) (interface{}, error) { + switch name { + case "commondev_project_add", "commondev_project_open": + return p.handleProjectAdd + case "commondev_project_list": + return p.handleProjectList + case "commondev_branch_open": + return p.handleBranchOpen + case "commondev_branch_status": + return p.handleBranchStatus + case "commondev_branch_close": + return p.handleBranchClose + case "commondev_job_status": + return p.handleJobStatus + case "commondev_agent_up": + return p.handleAgentUp + case "commondev_agent_state": + return p.handleAgentState + case "commondev_agent_down": + return p.handleAgentDown + case "commondev_workdir_prepare": + return p.handleWorkdirPrepare + case "commondev_workdir_clean": + return p.handleWorkdirClean + case "commondev_test_define": + return p.handleTestDefine + case "commondev_test_list": + return p.handleTestList + case "commondev_test_run": + return p.handleTestRun + case "commondev_test_mutate": + return p.handleTestMutate + case "commondev_review_submit": + return p.handleReviewSubmit + case "commondev_review_result": + return p.handleReviewResult + case "commondev_conflict_check": + return p.handleConflictCheck + case "commondev_parallel_status": + return p.handleParallelStatus + case "commondev_pi_config_get": + return p.handlePiConfigGet + case "commondev_pi_config_set": + return p.handlePiConfigSet + case "commondev_pi_source_add": + return p.handlePiSourceAdd + case "commondev_pi_source_list": + return p.handlePiSourceList + case "commondev_pi_source_remove": + return p.handlePiSourceRemove + case "commondev_pi_source_default": + return p.handlePiSourceDefault + case "commondev_pi_source_import": + return p.handlePiSourceImport + case "commondev_pi_package_install", "commondev_pi_plugin_install": + return p.handlePiPackageInstall + case "commondev_pi_plugin_list": + return p.handlePiPluginList + case "commondev_pi_plugin_remove": + return p.handlePiPluginRemove + case "commondev_pi_plugin_update": + return p.handlePiPluginUpdate + case "commondev_pi_mcp_list": + return p.handlePiMCPList + case "commondev_pi_mcp_add": + return p.handlePiMCPAdd + case "commondev_pi_mcp_remove": + return p.handlePiMCPRemove + case "enc_store": + return p.handleEncStore + case "enc_list": + return p.handleEncList + case "enc_show": + return p.handleEncShow + case "enc_rm": + return p.handleEncRm + case "enc_shell": + return p.handleEncryptedShell + case "commondev_endpoint_set": + return p.handleEndpointSet + case "commondev_endpoint_list": + return p.handleEndpointList + case "commondev_endpoint_remove": + return p.handleEndpointRemove + } + return nil +} + +// ★★ 判据:**每个注册的工具都必须有对应 handler**。 +// +// 这条能抓出「加了 RegisterTool 却指向不存在的 handler」或 +// 「加了工具但这里没同步登记」(后者是需要人补映射,属于审计信号)。 +func TestEveryRegisteredToolHasHandler(t *testing.T) { + p := newAuditPlugin(t) + tools := listRegisteredTools(t) + if len(tools) < 40 { + t.Fatalf("注册工具数异常偏少(%d),插件文件可能没被正确解析", len(tools)) + } + var missing []string + for _, name := range tools { + if handlerFor(p, name) == nil { + missing = append(missing, name) + } + } + if len(missing) > 0 { + t.Fatalf("★ 这些工具没有可用 handler(注册了但没接上): %v", missing) + } + t.Logf("已注册 %d 个工具,全部有 handler", len(tools)) +} + +// ★★ 判据:**无参数调用不得 panic**。 +// +// 工具会遇到模型乱传参数(缺字段、类型错)。正确行为是返回 error, +// 不是崩掉整个插件进程。 +func TestNoToolPanicsOnEmptyArgs(t *testing.T) { + p := newAuditPlugin(t) + for _, name := range listRegisteredTools(t) { + h := handlerFor(p, name) + if h == nil { + continue + } + func() { + defer func() { + if r := recover(); r != nil { + t.Errorf("★ %s 空参数 panic: %v", name, r) + } + }() + // 空 map + nil map 都要试:模型可能传 null + h(map[string]interface{}{}) + h(nil) + }() + } +} + +// ★★★ 判据:**本轮补的工具必须真的能完成一次正常调用**。 +// +// 与前两条不同:这里给足参数,检查业务是否真走通。 +// 「注册 + 不 panic」还不够 —— 一条写死的错误路径也能满足那两条。 +func TestNewlyAddedToolsActuallyWork(t *testing.T) { + p := newAuditPlugin(t) + p.ledger.Projects["proj"] = &Project{ + Name: "proj", RemoteURL: "https://example.invalid/o/r.git", DefaultBranch: "main"} + dir := t.TempDir() + p.ledger.Branches["br-1"] = &Branch{ + ID: "br-1", ProjectID: "proj", Name: "feat/x", Workdir: dir, Status: "open"} + + // ① agent_state 应报出真实状态(此前完全不存在) + res, err := p.handleAgentState(map[string]interface{}{"branch_id": "br-1"}) + if err != nil { + t.Fatal(err) + } + m := res.(map[string]interface{}) + if m["branch_id"] != "br-1" || m["agent_alive"] != false { + t.Errorf("agent_state 返回不对: %v", m) + } + + // ② agent_down 应成功停掉(无进程时也幂等成功) + res2, err := p.handleAgentDown(map[string]interface{}{"branch_id": "br-1"}) + if err != nil { + t.Fatal(err) + } + if res2.(map[string]interface{})["status"] != "ok" { + t.Errorf("agent_down 未成功: %v", res2) + } + + // ③ workdir_clean 默认 dry_run,且能识别孤儿 + root := p.programRoot() + os.MkdirAll(filepath.Join(root, "deadbeef-feat_x"), 0o755) + res3, err := p.handleWorkdirClean(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + m3 := res3.(map[string]interface{}) + if m3["status"] != "dry_run" { + t.Errorf("workdir_clean 默认应为 dry_run: %v", m3) + } + if n, _ := m3["count"].(int); n != 1 { + t.Errorf("应识别 1 个孤儿目录,实际 %v", m3["orphans"]) + } + + // ④ pi_config_get 必须能读到结构(即使没有任何 provider) + res4, err := p.handlePiConfigGet(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + if _, ok := res4.(map[string]interface{})["providers"]; !ok { + t.Errorf("pi_config_get 缺少 providers 字段: %v", res4) + } +} + +// ★★ 判据:**设计文档点名的工具必须真的存在**。 +// +// 这条把「文档说做了」与「代码里有」绑起来 —— 上一轮就是靠它 +// 发现 pr_open / agent_state 等工具根本不存在。 +// +// ⚠ 2026-10-15 起:**git 托管平台专有的工具已不在清单里** +// (account_* / repo_search / pr_* / protect_branch)—— 那些能力进 skill, +// 由 agent 用 safeshell + curl 完成。 +func TestDesignNamedToolsExist(t *testing.T) { + tools := listRegisteredTools(t) + have := map[string]bool{} + for _, n := range tools { + have[n] = true + } + want := []string{ + "commondev_project_add", "commondev_project_list", + "commondev_branch_open", "commondev_branch_status", "commondev_branch_close", + "commondev_workdir_prepare", "commondev_workdir_clean", + "commondev_agent_up", "commondev_agent_state", "commondev_agent_down", + "commondev_pi_plugin_install", "commondev_pi_plugin_remove", "commondev_pi_plugin_list", + "commondev_pi_source_import", "commondev_pi_source_add", "commondev_pi_source_list", + "commondev_pi_source_default", "commondev_pi_config_get", + "commondev_test_define", "commondev_test_run", "commondev_test_mutate", + "commondev_review_submit", + } + var missing []string + for _, w := range want { + if !have[w] { + missing = append(missing, w) + } + } + if len(missing) > 0 { + t.Fatalf("设计点名的工具缺失: %v", missing) + } +} + +// ★ 判据:**git 平台专有的工具已全部移出插件**。 +// +// 这条钉住 2026-10-15 的架构决定:平台知识进 skill。若有人又把 +// account_* / pr_* / protect_branch 加回插件,这里会报红。 +func TestPlatformToolsAreNotInPlugin(t *testing.T) { + tools := listRegisteredTools(t) + have := map[string]bool{} + for _, n := range tools { + have[n] = true + } + for _, forbidden := range []string{ + "commondev_account_add", "commondev_account_list", "commondev_account_test", + "commondev_account_remove", "commondev_repo_search", + "commondev_pr_open", "commondev_pr_status", "commondev_pr_approve", + "commondev_pr_merge", "commondev_protect_branch", + } { + if have[forbidden] { + t.Errorf("★ %s 属 git 平台专有能力,不应再注册在插件里(应进 skill + safeshell)", forbidden) + } + } +} + +// ★★ 判据:**平台专有代码不得回到插件里**。 +// +// 钉住 2026-10-15 的架构决定(用户拍板):git 托管平台的知识进 skill, +// 插件只保留与平台无关的机制。若有人把 platformSpec / 按平台构造认证头 +// 又加回来,这里会报红。 +// +// 为什么要用判据而不只是文档:文档说了还会有人(包括将来的我)图省事加回去, +// 而那种回退在**功能上看起来完全正常** —— 只是把可移植性换成了硬编码。 +func TestNoPlatformSpecificCodeInPlugin(t *testing.T) { + // 逐文件扫源码(排除测试与注释性提及由人工判断) + files := []string{"plugin.go", "credentials.go", "pi_tools.go", "criteria.go", + "encstore.go", "encshell.go", "conflict.go", "workdir.go", "workdir_security.go", + "util.go", "llmsource.go", "sidecar.go"} + forbidden := map[string]string{ + "platformSpec(": "按平台构造请求(认证头/端点差异)应进 skill", + "plaftformSpec": "平台适配结构体", + "DefaultAPI": "硬编码平台 API 前缀", + "AuthHeader:": "硬编码平台认证头", + "RoleExecutor": "平台角色语义", + "handleAccountAdd": "平台凭据工具", + "handleRepoSearch": "平台搜索工具", + "handlePROpen": "平台 PR 工具", + "handleProtectBranch": "平台分支保护工具", + } + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + continue + } + src := string(b) + for sym, why := range forbidden { + // 只认代码出现,不认注释里的解释性提及 + for i, line := range strings.Split(src, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "//") { + continue // 注释里说明"已移出"是允许的 + } + if strings.Contains(line, sym) { + t.Errorf("★ %s:%d 含平台专有代码 %q(%s)", f, i+1, sym, why) + } + } + } + } +} diff --git a/example/commondev/async_test.go b/example/commondev/async_test.go new file mode 100644 index 0000000..be739f7 --- /dev/null +++ b/example/commondev/async_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// ★ 判据:慢操作(clone/fetch/测试)**绝不能**出现在工具 handler 的同步路径里。 +// +// 依据(实测踩到):内核把每次工具调用硬限制在 **60 秒** +// (internal/agent/core/toolcall.go:62,硬编码无配置项)。同步 clone 的后果是 +// 工具报超时、但 mirror 其实已建好 —— 调用方只看到失败,状态却是"做了一半"。 +// +// 这条判据用**源码文本**做检查(同本仓既有做法:判据类检查必须先剔除注释)。 +func TestNoSyncCloneInToolHandlers(t *testing.T) { + src, err := os.ReadFile("plugin.go") + if err != nil { + t.Fatal(err) + } + // 剔除注释,避免注释里提到 runGit 也会命中 + body := stripComments(string(src)) + + // 工具 handler 的区间:从 handleProjectAdd 到 handleJobStatus 之前 + start := strings.Index(body, "func (p *Plugin) handleProjectAdd") + end := strings.Index(body, "func (p *Plugin) handleJobStatus") + if start < 0 || end < 0 || end <= start { + t.Fatal("找不到 handler 区间(函数名可能已改)") + } + handlers := body[start:end] + + // handleBranchOpen 的**后台函数**是允许慢操作的,单独排除 + if i := strings.Index(handlers, "func (p *Plugin) runPrepareWorkdir"); i >= 0 { + handlers = handlers[:i] + } + + for _, bad := range []string{"runGit(", "prepareWorkdir("} { + if strings.Contains(handlers, bad) { + t.Errorf("工具 handler 里出现慢操作 %s —— 会撞 60s 工具上限;应转后台(见 runPrepareWorkdir)", bad) + } + } + // 但后台函数自己必须用到它(否则重构把功能删了) + if !strings.Contains(body, "func (p *Plugin) runPrepareWorkdir") || + !strings.Contains(body, "prepareWorkdir(pr, branch, workdir)") { + t.Error("runPrepareWorkdir 未实现或未调用 prepareWorkdir —— 目录准备功能丢了") + } + // ★ 且必须切到目标分支(“每个目录下是独立仓库的独立分支”——用户原话) + // + // 判据必须检查**两条路径都切**:新建路径(clone 后)与复用路径(目录已存在)。 + // 为何要查两处:初版只查字符串存在,而 ensureOnBranch 的**定义**本身 + // (func … ensureOnBranch(branch, workdir string))就包含该子串 —— 于是 + // 只删调用点、定义还在,判据仍然全绿(变异实验当场发现了这个漏洞)。 + // ⇒ 改为数**调用点**个数(排除定义行)。 + calls := 0 + for _, ln := range strings.Split(body, "\n") { + t := strings.TrimSpace(ln) + if strings.HasPrefix(t, "func ") { + continue // 定义行不算 + } + if strings.Contains(t, "p.ensureOnBranch(branch, workdir)") { + calls++ + } + } + if calls < 2 { + t.Errorf("prepareWorkdir 切分支的调用点只有 %d 处(=new建路径+复用路径);"+ + "缺一处就会让目录停在默认分支(main)", calls) + } +} + +// stripComments 去掉 // 行注释与 /* */ 块注释。 +// +// 为何必须先剔除:本仓既有教训 —— 注释里模仿真实签名会污染一切按文本匹配的判据。 +func stripComments(s string) string { + var out strings.Builder + lines := strings.Split(s, "\n") + inBlock := false + for _, ln := range lines { + trimmed := strings.TrimSpace(ln) + if inBlock { + if idx := strings.Index(ln, "*/"); idx >= 0 { + inBlock = false + ln = ln[idx+2:] + } else { + out.WriteString("\n") + continue + } + } + if strings.HasPrefix(trimmed, "/*") { + if idx := strings.Index(ln, "*/"); idx >= 0 { + ln = ln[:strings.Index(ln, "/*")] + ln[idx+2:] + } else { + inBlock = true + out.WriteString("\n") + continue + } + } + if i := strings.Index(ln, "//"); i >= 0 { + ln = ln[:i] + } + out.WriteString(ln + "\n") + } + return out.String() +} diff --git a/example/commondev/audit_test.go b/example/commondev/audit_test.go new file mode 100644 index 0000000..5196c97 --- /dev/null +++ b/example/commondev/audit_test.go @@ -0,0 +1,216 @@ +package main + +// 审计补全项的判据。 +// +// 这些判据对应审计发现的**真问题**(不是补全功能的装饰): +// · push 的凭据纪律(设计 §7.2)—— 曾经 pushURLWithToken 是死代码, +// 意味着「凭据不进 remote URL」这条纪律**从未被真正执行过** +// · 孤儿清理(listOrphanDirs 曾是死代码) +// · pi 配置写入的备份/回滚(设计 §3.6.4 纪律 2) + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func newAuditPlugin(t *testing.T) *Plugin { + t.Helper() + p := newTestPluginForCriteria(t) + return p +} + +// ★★ 判据:凭据残留检查能识别 `scheme://user:pass@host` 形态。 +func TestGitConfigCredentialResidueDetection(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, ".git"), 0o755) + os.WriteFile(filepath.Join(dir, ".git", "config"), + []byte("[remote \"origin\"]\n\turl = https://user:tok3n@example.com/o/r.git\n"), 0o644) + if got := gitConfigCredentialResidue(dir); got == "" { + t.Fatal("★ 未检出内嵌凭据(设计 §7.2 要求 workdir_clean 内置此检查)") + } + // 干净的不应误报 + dir2 := t.TempDir() + os.MkdirAll(filepath.Join(dir2, ".git"), 0o755) + os.WriteFile(filepath.Join(dir2, ".git", "config"), + []byte("[remote \"origin\"]\n\turl = https://example.com/o/r.git\n"), 0o644) + if got := gitConfigCredentialResidue(dir2); got != "" { + t.Errorf("干净 remote 被误报: %s", got) + } + // scp 形态的 git@host 不是凭据(用户提示有误伤风险) + dir3 := t.TempDir() + os.MkdirAll(filepath.Join(dir3, ".git"), 0o755) + os.WriteFile(filepath.Join(dir3, ".git", "config"), + []byte("[remote \"origin\"]\n\turl = git@github.com:o/r.git\n"), 0o644) + if got := gitConfigCredentialResidue(dir3); got != "" { + t.Errorf("scp 形态 git@host 被误报成凭据: %s", got) + } +} + +// ★★ 判据(设计 §7.2 + 审计发现):workdir_clean +// - **默认 dry_run**(删除不可逆) +// - 只删**本插件生成的孤儿目录**,不碰台账里的目录、不碰陌生目录 +func TestWorkdirCleanOnlyRemovesOrphans(t *testing.T) { + p := newAuditPlugin(t) + root := p.programRoot() + os.MkdirAll(root, 0o700) + + known := "aabbccdd-feat_known" + orphan := "11223344-feat_orphan" + stranger := "not-ours-just-a-dir" + for _, d := range []string{known, orphan, stranger} { + os.MkdirAll(filepath.Join(root, d), 0o755) + } + p.ledger.Branches["br-1"] = &Branch{ + ID: "br-1", ProjectID: "proj", Name: "feat/known", + Workdir: filepath.Join(root, known), Status: "open"} + + // ① dry_run 默认:什么都不删 + res, err := p.handleWorkdirClean(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + if res.(map[string]interface{})["status"] != "dry_run" { + t.Fatalf("默认应为 dry_run,实际 %v", res.(map[string]interface{})["status"]) + } + for _, d := range []string{known, orphan, stranger} { + if _, err := os.Stat(filepath.Join(root, d)); err != nil { + t.Fatalf("★ dry_run 却删掉了 %s", d) + } + } + + // ② 实际清理:只删孤儿 + res2, err := p.handleWorkdirClean(map[string]interface{}{"dry_run": false}) + if err != nil { + t.Fatal(err) + } + removed, _ := res2.(map[string]interface{})["removed"].([]string) + if len(removed) != 1 || removed[0] != orphan { + t.Fatalf("应只删 %s,实际 %v", orphan, removed) + } + if _, err := os.Stat(filepath.Join(root, known)); err != nil { + t.Fatal("★ 删掉了台账里在用的目录") + } + if _, err := os.Stat(filepath.Join(root, stranger)); err != nil { + t.Fatal("★ 删掉了不是本插件生成的目录(越界)") + } +} + +// ★★ 判据:agent_down 清掉 session/通道绑定,但**保留工作目录与状态**。 +// +// 它与 branch_close 的区别正是"暂停"vs"收尾";若 agent_down 也删目录, +// 二者就没有区别了(而我实现时差点把两者写成一个)。 +func TestAgentDownKeepsWorkdir(t *testing.T) { + p := newAuditPlugin(t) + dir := t.TempDir() + p.ledger.Branches["br-1"] = &Branch{ + ID: "br-1", ProjectID: "proj", Name: "feat/x", Workdir: dir, + Status: "open", SessionID: "s1", InputCh: "commondev/br-1", OutputCh: "commondev_br-1"} + + res, err := p.handleAgentDown(map[string]interface{}{"branch_id": "br-1"}) + if err != nil { + t.Fatal(err) + } + if res.(map[string]interface{})["status"] != "ok" { + t.Fatal("应成功") + } + b := p.ledger.Branches["br-1"] + if b.SessionID != "" || b.InputCh != "" || b.OutputCh != "" { + t.Errorf("★ session/通道绑定应被清掉(留着会让人以为进程还在): %+v", b) + } + if b.Status != "open" { + t.Errorf("★ 状态不该变(这是暂停不是收尾),实际 %s", b.Status) + } + if _, err := os.Stat(dir); err != nil { + t.Fatal("★ agent_down 不该删工作目录(那是 branch_close 的事)") + } +} + +// ---------------- pi 配置写入纪律(设计 §3.6.4) ---------------- + +// ★★★ 判据:**损坏的 models.json 必须拒绝覆盖**(纪律 2)。 +// +// 静默当空会**抹掉用户已有的全部源** —— 那是最坏的一种"静默降级"。 +func TestCorruptPiModelsIsNotSilentlyOverwritten(t *testing.T) { + dir := t.TempDir() + bad := "{ this is not json" + os.WriteFile(filepath.Join(dir, "models.json"), []byte(bad), 0o600) + + if _, err := loadPiModels(dir); err == nil { + t.Fatal("★ 损坏的 models.json 未被拒绝 —— 静默当空会抹掉用户已有的源") + } + after, _ := os.ReadFile(filepath.Join(dir, "models.json")) + if string(after) != bad { + t.Fatal("损坏的文件被改动了") + } +} + +// ★★ 判据:写坏时**回滚**(纪律 2:失败则回滚并响亮报错)。 +func TestPiConfigRollbackRestoresPrevious(t *testing.T) { + dir := t.TempDir() + orig := `{"providers":{"keep":{"baseUrl":"http://keep","api":"openai-completions","models":[{"id":"m"}]}}}` + os.WriteFile(filepath.Join(dir, "models.json"), []byte(orig), 0o600) + + backup, err := backupPiConfig(dir) + if err != nil { + t.Fatal(err) + } + // 模拟"写坏了" + os.WriteFile(filepath.Join(dir, "models.json"), []byte("{broken"), 0o600) + rollbackPiConfig(dir, backup) + + back, _ := os.ReadFile(filepath.Join(dir, "models.json")) + if string(back) != orig { + t.Fatalf("★ 回滚未恢复原内容\n前: %s\n后: %s", orig, back) + } +} + +// ★★ 判据:**api_key 绝不进任何返回结构**(纪律 3)。 +func TestPiConfigGetNeverReturnsKey(t *testing.T) { + p := newAuditPlugin(t) + dir := p.piAgentDir() + os.MkdirAll(dir, 0o700) + os.WriteFile(filepath.Join(dir, "models.json"), []byte( + `{"providers":{"p1":{"baseUrl":"http://x","api":"openai-completions","apiKey":"SUPER_SECRET","models":[{"id":"m"}]}}}`), 0o600) + + res, err := p.handlePiConfigGet(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + s := jsonDump(t, res) + if strings.Contains(s, "SUPER_SECRET") { + t.Fatalf("★★ api_key 泄漏到返回值里:%s", s) + } + if !strings.Contains(s, "key_set") { + t.Errorf("应报 key_set 而不是值: %s", s) + } +} + +// jsonDump 把任意返回值转成 JSON 串(用于断言"某串不得出现")。 +func jsonDump(t *testing.T, v interface{}) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// ★ 判据:pi 插件工具的**两套命名都能到达同一实现**(早期稿用 plugin,细化稿用 package)。 +func TestPiPluginToolAliasesRegistered(t *testing.T) { + src, err := os.ReadFile("plugin.go") + if err != nil { + t.Fatal(err) + } + s := string(src) + for _, name := range []string{ + `"commondev_pi_plugin_install"`, `"commondev_pi_package_install"`, + `"commondev_pi_plugin_list"`, `"commondev_pi_plugin_remove"`, + } { + if !strings.Contains(s, name) { + t.Errorf("工具 %s 未注册(两套命名应都可用)", name) + } + } +} diff --git a/example/commondev/channelname_test.go b/example/commondev/channelname_test.go new file mode 100644 index 0000000..bbeccb3 --- /dev/null +++ b/example/commondev/channelname_test.go @@ -0,0 +1,84 @@ +package main + +import "testing" + +// 钉住文档 §2.2 的命名约束: +// inputch 可含斜杠;outputch **必须**只剩 [A-Za-z0-9_-], +// 否则会产出非法的 output_send__(上游 400、agent 整体失语)。 +func TestOutputChannelNameIsToolSafe(t *testing.T) { + cases := []string{ + "br-TrueAgent-feat_x", + "br-p1-feat/x", // 分支名带斜杠 + "br-p1-feat.x", // 点 + "br-p1-中文", // 非 ASCII + "br-p1-a b", // 空格 + } + for _, id := range cases { + got := outputChannelName(id) + if !validToolName("output_send__" + got) { + t.Errorf("id=%q → outputch=%q ⇒ 工具名 %q 非法(会触发上游 400)", + id, got, "output_send__"+got) + } + // inputch 则允许斜杠(内核自己就用 child/) + if in := inputChannelName(id); in != "commondev/"+id { + t.Errorf("inputch 应原样保留: %q", in) + } + } +} + +// validToolName 复刻上游对函数名的约束。 +func validToolName(name string) bool { + if len(name) == 0 || len(name) > 64 { + return false + } + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', r == '_', r == '-': + default: + return false + } + } + return true +} + +// 台账往返:保存后能读回,且损坏时拒绝静默重建。 +func TestLedgerRoundTrip(t *testing.T) { + p := &Plugin{name: "commondev"} + p.path = t.TempDir() + "/ledger.json" + if err := p.loadLedger(); err != nil { + t.Fatal(err) + } + p.ledger.Projects["demo"] = &Project{Name: "demo", RemoteURL: "https://x/y.git"} + if err := p.saveLocked(); err != nil { + t.Fatal(err) + } + + q := &Plugin{name: "commondev", path: p.path} + if err := q.loadLedger(); err != nil { + t.Fatal(err) + } + if q.ledger.Projects["demo"] == nil { + t.Fatal("台账未往返") + } +} + +// 钉住名字校验方向(这条是**真 bug 的回归测试**)。 +// +// 初版用排除式正则 `[^a-zA-Z0-9_-]` 配 MatchString 判"合法",恰好反了: +// 合法名不含违法字符 ⇒ 返回 false ⇒ 把 `cdtest` 判非法。 +// 是端到端跑时由 agent 调工具发现的 —— 所以它必须有回归判据。 +func TestValidName(t *testing.T) { + legal := []string{"cdtest", "TrueAgent", "feat_x", "a-b", "A1"} + for _, s := range legal { + if !validName(s) { + t.Errorf("合法名被拒: %q(校验方向反了?)", s) + } + } + illegal := []string{"feat/x", "a b", "中文", "a.b", "", "a/b/c"} + for _, s := range illegal { + if validName(s) { + t.Errorf("非法名被放行: %q", s) + } + } +} diff --git a/example/commondev/conflict.go b/example/commondev/conflict.go new file mode 100644 index 0000000..c409b58 --- /dev/null +++ b/example/commondev/conflict.go @@ -0,0 +1,400 @@ +package main + +// 刀 3:多项目/并行下的 **conflict 预检** 与 **base_sha 记录**。 +// +// ## 为什么需要这一刀 +// +// 设计 §4.2:`base_sha` 是用户原描述里**缺失**的一环,但必须补: +// +// 没有它,插件无法判断「两个 PR 改了同一文件时第二个该不该 rebase、 +// 能不能自动合」,出问题也无法重放。 +// +// 并行开发(§4.4)让这个问题从"可能"变成"必然":每分支一份独立 clone, +// 两棵树**互不可见**,冲突不会在开发时暴露,而是全部堆到 merge 时爆发。 +// 那时才发现"两个分支改了同一个文件的同一段"就太晚了 —— +// 复审已经通过、判据已经变绿,但合不进去。 +// +// ## 做法:在**提 PR 之前**算一次可合并性 +// +// 用 `git merge-tree --write-tree` —— ★ 它**完全不碰工作区**(不写索引、 +// 不动 HEAD、不留 MERGE_HEAD),所以可以在任何时候安全地跑, +// 不需要先 stash/checkout,也不会破坏正在进行的开发。 +// +// 退出码语义(实测 git 2.53): +// +// 0 → 可干净合并 +// 1 → 有冲突 +// 其它 → 命令本身失败(仓库损坏等),**不能当成"无冲突"** +// +// ## 与旧写法(`git merge-tree `)的对比 +// +// 旧语法输出是给人看的文本,要靠 grep `CONFLICT` 判断 —— 脆弱且没有 +// 明确的退出码。新语法(git 2.38+)直接给树 OID 与退出码。 +// 本仓的环境是 git 2.53,用新语法;同时保留旧语法的兜底判断。 + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// ConflictReport 是一次冲突预检的结果。 +type ConflictReport struct { + // Mergeable 是否可干净合并。 + Mergeable bool `json:"mergeable"` + // Conflicts 是冲突文件列表(可为空)。 + Conflicts []string `json:"conflicts,omitempty"` + // BaseSHA / HeadSHA 是本次比较的两端(用于审计与重放)。 + BaseSHA string `json:"base_sha,omitempty"` + HeadSHA string `json:"head_sha,omitempty"` + // Target 是被合入的目标(通常是 main)。 + Target string `json:"target"` + // Detail 是给人看的一行说明。 + Detail string `json:"detail"` + // Skipped 为真表示预检被跳过(而非"检查通过了")。 + // + // ★ 这个字段很关键:把"没检查"和"检查通过"混为一谈, + // 会让调用方以为安全 —— 而实际什么都没验。 + Skipped bool `json:"skipped,omitempty"` +} + +// ────────────────────────── base_sha 记录 ────────────────────────── + +// captureBaseSHA 记录工作目录当前 HEAD(开线时的基点)。 +// +// 必须在**第一次投递任务之前**调用:agent 一旦开始改,HEAD 就不再是基点了。 +// 拿不到时如实返回错误(不静默留空 —— 空 base_sha 会让后续冲突判定失据)。 +func (p *Plugin) captureBaseSHA(branchID, workdir string) (string, error) { + sha, err := runGitOut(workdir, "rev-parse", "HEAD") + if err != nil { + return "", fmt.Errorf("取 base_sha 失败: %w", err) + } + sha = strings.TrimSpace(sha) + // 空值守卫:实测 `git rev-parse HEAD` 要么成功给非空 sha、要么报错, + // 所以这条**实际不可达** —— 保留它是防御性的,不必为它写判据 + //(写了会是"永远绿"的假判据,反而掩盖覆盖率的真实情况)。 + if sha == "" { + return "", fmt.Errorf("取 base_sha 得到空值") + } + p.mu.Lock() + if b, ok := p.ledger.Branches[branchID]; ok { + // 只记第一次:重开同一分支时基点不该被后续改动覆盖 + // (覆盖会让"这条分支从哪出发"永久丢失,冲突判定与重放都失去依据) + if b.BaseSHA == "" { + b.BaseSHA = sha + } else { + sha = b.BaseSHA + } + } + p.mu.Unlock() + return sha, nil +} + +// ────────────────────────── 冲突预检 ────────────────────────── + +// checkConflict 在**不动工作区**的前提下判断 branch 能否干净地合进 target。 +// +// 两种实现路径,自动选择: +// +// ① 本地有该分支的 workdir ⇒ 直接在工作目录里跑 merge-tree(最快,无需网络) +// ② 否则 ⇒ 在 mirror 里跑(mirror 是 --mirror clone,含全部分支) +// +// ★ 为什么不在 PR 之后靠平台判定:那时已经推了分支、开了 PR, +// +// 冲突才暴露 → 白跑一轮。预检的意义就是**在浪费工作之前发现**。 +func (p *Plugin) checkConflict(workdir, target string) ConflictReport { + rep := ConflictReport{Target: target} + if target == "" { + target = "main" + rep.Target = target + } + + // 本地要有 target 的最新视角:mirror 的 fetch 由调用方保证 + // (这里只做只读判定,不引入网络依赖 —— 预检失败不该阻断流程) + + // 取 HEAD(本次要合入的提交) + head, err := runGitOut(workdir, "rev-parse", "HEAD") + if err != nil { + rep.Skipped = true + rep.Detail = "取 HEAD 失败,预检跳过(不等于无冲突): " + err.Error() + return rep + } + rep.HeadSHA = strings.TrimSpace(head) + + // 目标分支在本地存在吗?不存在就没法预检(如实标记 Skipped) + if _, err := runGitOut(workdir, "rev-parse", "--verify", target); err != nil { + rep.Skipped = true + rep.Detail = fmt.Sprintf( + "本地没有 %s 引用,预检跳过(不等于无冲突)。"+ + "先 fetch 目标分支再预检,或直接在平台上开 PR 让平台判定", target) + return rep + } + + base, _ := runGitOut(workdir, "merge-base", target, "HEAD") + rep.BaseSHA = strings.TrimSpace(base) + + // ★ 核心:merge-tree --write-tree(不碰工作区) + out, err := runGitOut(workdir, "merge-tree", "--write-tree", target, "HEAD") + if err == nil { + rep.Mergeable = true + rep.Detail = fmt.Sprintf("可干净合并进 %s(基点 %s)", target, shortSHA(rep.BaseSHA)) + return rep + } + + // 非零退出:区分「冲突」与「命令失败」。 + // + // ★ 这个区分极其重要:把命令失败当成"有冲突"会误报(让人去做无谓 rebase), + // 把它当成"无冲突"则更危险(带着冲突去合)。两者都不行 ⇒ 分别处理。 + if isMergeTreeConflict(out) { + rep.Mergeable = false + rep.Conflicts = parseConflictFiles(out) + rep.Detail = fmt.Sprintf("与 %s 有冲突(%d 个文件):%s", + target, len(rep.Conflicts), strings.Join(rep.Conflicts, ", ")) + return rep + } + + // 命令失败(如 git 版本不支持 --write-tree)⇒ 退回旧语法再判一次 + if rep2, ok := p.checkConflictLegacy(workdir, target); ok { + rep2.BaseSHA, rep2.HeadSHA = rep.BaseSHA, rep.HeadSHA + return rep2 + } + + rep.Skipped = true + rep.Detail = "merge-tree 不可用(git 过旧?),预检跳过(不等于无冲突): " + + truncate(strings.TrimSpace(out), 200) + return rep +} + +// checkConflictLegacy 用旧版 merge-tree 语法兜底(git < 2.38)。 +// +// 旧语法输出是文本,靠 `CONFLICT` / 冲突标记判断 —— 脆弱,故只作兜底。 +func (p *Plugin) checkConflictLegacy(workdir, target string) (ConflictReport, bool) { + rep := ConflictReport{Target: target} + base, err := runGitOut(workdir, "merge-base", target, "HEAD") + if err != nil { + return rep, false + } + out, _ := runGitOut(workdir, "merge-tree", strings.TrimSpace(base), target, "HEAD") + if !strings.Contains(out, "CONFLICT") && !strings.Contains(out, "<<<<<<<") { + rep.Mergeable = true + rep.Detail = fmt.Sprintf("可干净合并进 %s(旧语法判定)", target) + return rep, true + } + rep.Mergeable = false + rep.Conflicts = parseConflictFilesLegacy(out) + rep.Detail = fmt.Sprintf("与 %s 有冲突(旧语法判定,%d 个文件)", + target, len(rep.Conflicts)) + return rep, true +} + +// isMergeTreeConflict 判断 merge-tree 的失败是"有冲突"还是"命令本身失败"。 +// +// 依据:git 在冲突时退出码为 1;命令错误(如未知参数)通常是 129 等。 +// `runGitOut` 把 stderr 一起并进 out,所以同时看退出码语义与输出特征。 +func isMergeTreeConflict(out string) bool { + if strings.Contains(out, "CONFLICT") { + return true + } + // 冲突时 merge-tree 会打印 "Auto-merging ... CONFLICT (content)", + // 或至少列出冲突阶段号(1/2/3 的 ls-tree 形态) + return strings.Contains(out, "Auto-merging") || + strings.Contains(out, "Merge conflict") +} + +// parseConflictFiles 从 `merge-tree --write-tree` 输出里提文件名。 +// +// 输出形态(实测 git 2.53): +// +// +// 100644 1\tpath/to/file ← 阶段 1/2/3 即为冲突条目 +// 100644 2\tpath/to/file +// 100644 3\tpath/to/file +// ... +// Auto-merging path +// CONFLICT (content): Merge conflict in path +func parseConflictFiles(out string) []string { + set := map[string]bool{} + + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + // 形态 ①:明确的 "CONFLICT ... in " + if i := strings.Index(line, "CONFLICT"); i >= 0 { + if j := strings.Index(line, " in "); j > 0 { + set[strings.TrimSpace(line[j+4:])] = true + } + continue + } + // 形态 ②:ls-tree 阶段行 "100644 \t" + if rest, ok := strings.CutPrefix(line, "100"); ok { + _ = rest + parts := strings.SplitN(line, "\t", 2) + if len(parts) == 2 { + fields := strings.Fields(parts[0]) + // mode, oid, stage + if len(fields) == 3 && (fields[2] == "1" || fields[2] == "2" || fields[2] == "3") { + set[strings.TrimSpace(parts[1])] = true + } + } + } + } + + out2 := make([]string, 0, len(set)) + for f := range set { + out2 = append(out2, f) + } + sort.Strings(out2) // 稳定输出(map 顺序随机,回执要可对照) + return out2 +} + +// parseConflictFilesLegacy 从旧语法输出里提文件名(形态 "CONFLICT ... in ")。 +func parseConflictFilesLegacy(out string) []string { + set := map[string]bool{} + for _, line := range strings.Split(out, "\n") { + if i := strings.Index(line, "CONFLICT"); i >= 0 { + if j := strings.Index(line, " in "); j > 0 { + set[strings.TrimSpace(line[j+4:])] = true + } + } + } + r := make([]string, 0, len(set)) + for f := range set { + r = append(r, f) + } + sort.Strings(r) + return r +} + +// shortSHA 截短 sha 供人阅读(空值原样返回)。 +func shortSHA(s string) string { + if len(s) > 8 { + return s[:8] + } + return s +} + +// ────────────────────────── 多项目/并行盘点 ────────────────────────── + +// parallelSummary 汇总「当前有哪些分支线在并行推进」。 +// +// 为何要它:并行是有前提的(设计 §8.4)—— 并行的必须是**互不相干的模块**。 +// 若两条线改了同一文件,冲突会堆到 merge 时才爆发。这个汇总让主管 +// **在派活之前**就能看到"眼下有哪几条线、各自在哪",从而判断该不该并行。 +func (p *Plugin) parallelSummary() map[string]interface{} { + p.mu.Lock() + defer p.mu.Unlock() + + byProject := map[string][]map[string]interface{}{} + for _, b := range p.ledger.Branches { + if b.Status != "open" && b.Status != "review" { + continue + } + byProject[b.ProjectID] = append(byProject[b.ProjectID], map[string]interface{}{ + "branch_id": b.ID, + "branch": b.Name, + "status": b.Status, + "agent": b.AgentName, + "workdir": b.Workdir, + "base_sha": shortSHA(b.BaseSHA), + }) + } + + projects := make([]string, 0, len(byProject)) + for k := range byProject { + projects = append(projects, k) + } + sort.Strings(projects) + + out := make([]map[string]interface{}, 0, len(projects)) + total := 0 + for _, proj := range projects { + lines := byProject[proj] + total += len(lines) + out = append(out, map[string]interface{}{ + "project": proj, + "count": len(lines), + "branches": lines, + }) + } + return map[string]interface{}{ + "projects": out, + "total_branches": total, + "warning": "并行只适合**互不相干的模块**。若多条线改了同一批文件," + + "冲突会堆到 merge 时才爆发 —— 派活前先确认边界(设计 §8.4)。", + } +} + +// distinctWorkdirs 返回所有活跃分支的工作目录(供批量操作/清理)。 +func (p *Plugin) distinctWorkdirs() []string { + p.mu.Lock() + defer p.mu.Unlock() + seen := map[string]bool{} + var out []string + for _, b := range p.ledger.Branches { + if b.Workdir == "" || seen[b.Workdir] { + continue + } + if _, err := os.Stat(filepath.Join(b.Workdir, ".git")); err != nil { + continue + } + seen[b.Workdir] = true + out = append(out, b.Workdir) + } + sort.Strings(out) + return out +} + +// handleConflictCheck 是 commondev_conflict_check 的实现。 +func (p *Plugin) handleConflictCheck(args map[string]interface{}) (interface{}, error) { + id := strings.TrimSpace(str(args, "branch_id")) + p.mu.Lock() + b, ok := p.ledger.Branches[id] + var workdir, base string + if ok { + workdir, base = b.Workdir, b.BaseSHA + } + proj := "" + if ok { + if pr, ok2 := p.ledger.Projects[b.ProjectID]; ok2 { + proj = pr.DefaultBranch + } + } + p.mu.Unlock() + if !ok { + return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", id)}, nil + } + target := strings.TrimSpace(str(args, "target")) + if target == "" { + target = proj + } + if target == "" { + target = "main" + } + if workdir == "" { + return map[string]interface{}{"error": "该分支没有工作目录(目录准备未完成?)"}, nil + } + + rep := p.checkConflict(workdir, target) + out := map[string]interface{}{ + "mergeable": rep.Mergeable, + "target": rep.Target, + "detail": rep.Detail, + "base_sha": shortSHA(base), // 开线时记下的基点(审计用) + } + if len(rep.Conflicts) > 0 { + out["conflicts"] = rep.Conflicts + out["advice"] = "先在本分支 rebase/合并目标分支并解决冲突,再提 PR。" + + "★ 不要带冲突去开 PR —— 平台会拒绝,且白跑一轮判据。" + } + if rep.Skipped { + out["skipped"] = true + out["warning"] = "预检被跳过 —— 这**不等于**没有冲突。" + } + return out, nil +} + +// handleParallelStatus 是 commondev_parallel_status 的实现。 +func (p *Plugin) handleParallelStatus(args map[string]interface{}) (interface{}, error) { + return p.parallelSummary(), nil +} diff --git a/example/commondev/conflict_parse_test.go b/example/commondev/conflict_parse_test.go new file mode 100644 index 0000000..4ddda36 --- /dev/null +++ b/example/commondev/conflict_parse_test.go @@ -0,0 +1,335 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// 用**真实 git 输出**验证冲突解析(从 git 2.53 实测抓取)。 +func TestParseConflictFilesRealOutput(t *testing.T) { + // 这是 git merge-tree --write-tree 冲突时的**原样输出** + const real = `82d5993c292e11020e08a83074413e7cc6e2d5d1 +100644 df967b96a579e45a18b8251732d16804b2e56a55 1 f.txt +100644 f70f10e4db19068f79bc43844b49f3eece45c4e8 2 f.txt +100644 223b7836fb19fdf64ba2d3cd6173c6a283141f78 3 f.txt + +Auto-merging f.txt +CONFLICT (content): Merge conflict in f.txt +` + got := parseConflictFiles(real) + if len(got) != 1 || got[0] != "f.txt" { + t.Fatalf("应从真实输出解出 [f.txt],实际 %v", got) + } + if !isMergeTreeConflict(real) { + t.Fatal("真实冲突输出应被识别为冲突") + } +} + +// 无冲突时不该误判。 +func TestNoConflictOutputNotMisjudged(t *testing.T) { + const clean = `3d501af9404b81d5e9d7fe6254d082ff59108312 +` + if isMergeTreeConflict(clean) { + t.Fatal("干净合并的输出不该被判为冲突") + } + if got := parseConflictFiles(clean); len(got) != 0 { + t.Fatalf("无冲突时不该解出文件: %v", got) + } +} + +// 多文件冲突要全部解出且稳定排序。 +func TestParseMultipleConflictsStable(t *testing.T) { + const multi = `abc123 +100644 aaa 1 z.txt +100644 bbb 2 z.txt +100644 ccc 3 z.txt +100644 ddd 1 a.txt +100644 eee 2 a.txt +100644 fff 3 a.txt +CONFLICT (content): Merge conflict in z.txt +CONFLICT (content): Merge conflict in a.txt +` + got := parseConflictFiles(multi) + if len(got) != 2 || got[0] != "a.txt" || got[1] != "z.txt" { + t.Fatalf("应解出 [a.txt z.txt] 且已排序,实际 %v", got) + } + // 重复出现(阶段行 + CONFLICT 行)不该重复计数 + if len(got) != len(uniqueStrings(got)) { + t.Error("同一文件出现多次时应去重") + } +} + +func uniqueStrings(s []string) []string { + seen := map[string]bool{} + var out []string + for _, v := range s { + if !seen[v] { + seen[v] = true + out = append(out, v) + } + } + return out +} + +// ★★ 决定性判据:`checkConflict` 在**真实 git 仓库**上正确判定。 +// +// 上面几条测的是 parser(纯函数)。这条跑真实 git —— 因为 parser 对 +// 不代表「预检对」:命令拼错、退出码语义搞反、目标分支取错, +// 都只有真跑才暴露。 +// +// 场景(与并行开发的真实情形一致): +// +// main ← base +// ├─ feat/a 改 f.txt → 合进 main(main 前进) +// └─ feat/b 改 f.txt → 此时预检必冲突 +// +// 以及对照组:feat/c 改的是**另一个文件** → 预检应通过。 +func TestCheckConflictOnRealGitRepo(t *testing.T) { + if _, err := os.Stat("/usr/bin/git"); err != nil { + if _, err2 := exec.LookPath("git"); err2 != nil { + t.Skip("环境无 git") + } + } + + base := t.TempDir() + repo := filepath.Join(base, "w") + + run := func(dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v 失败: %v\n%s", args, err, out) + } + return string(out) + } + + // 建仓 + 基线 + // ★ 显式指定初始分支为 main:git 默认分支名随版本/配置而变 + //(本机是 master),不指定的话下面的 "main" 会找不到。 + run(base, "init", "-q", "-b", "main", repo) + run(repo, "config", "user.email", "t@t") + run(repo, "config", "user.name", "t") + os.WriteFile(filepath.Join(repo, "f.txt"), []byte("base\n"), 0o600) + run(repo, "add", "-A") + run(repo, "commit", "-qm", "base") + + // feat/a 改 f.txt + run(repo, "checkout", "-q", "-b", "feat/a") + os.WriteFile(filepath.Join(repo, "f.txt"), []byte("A\n"), 0o600) + run(repo, "commit", "-qam", "A") + + // feat/b 也改 f.txt(与 a 同一个文件 → 冲突) + run(repo, "checkout", "-q", "main") + run(repo, "checkout", "-q", "-b", "feat/b") + os.WriteFile(filepath.Join(repo, "f.txt"), []byte("B\n"), 0o600) + run(repo, "commit", "-qam", "B") + + // feat/c 改另一个文件(不冲突) + run(repo, "checkout", "-q", "main") + run(repo, "checkout", "-q", "-b", "feat/c") + os.WriteFile(filepath.Join(repo, "g.txt"), []byte("C\n"), 0o600) + run(repo, "add", "-A") + run(repo, "commit", "-qm", "C") + + p := &Plugin{ledger: &Ledger{}} + + // ① main 还没前进 ⇒ feat/b 可干净合并 + run(repo, "checkout", "-q", "feat/b") + rep := p.checkConflict(repo, "main") + if rep.Skipped { + t.Fatalf("不该跳过: %s", rep.Detail) + } + if !rep.Mergeable { + t.Fatalf("main 未前进时 feat/b 应可合并,实际: %s", rep.Detail) + } + if rep.HeadSHA == "" || rep.BaseSHA == "" { + t.Error("应记录 head_sha 与 base_sha(审计与重放要用)") + } + + // ② 把 feat/a 合进 main(main 前进)⇒ feat/b 必冲突 + run(repo, "checkout", "-q", "main") + run(repo, "merge", "-q", "--no-ff", "feat/a", "-m", "merge a") + run(repo, "checkout", "-q", "feat/b") + rep = p.checkConflict(repo, "main") + if rep.Skipped { + t.Fatalf("不该跳过: %s", rep.Detail) + } + if rep.Mergeable { + t.Fatal("★ main 已含 feat/a 的改动、feat/b 改了同一文件 ⇒ 必须判为冲突") + } + if len(rep.Conflicts) == 0 || rep.Conflicts[0] != "f.txt" { + t.Fatalf("应指出冲突文件 f.txt,实际 %v(detail=%s)", rep.Conflicts, rep.Detail) + } + + // ③ 对照:feat/c 改的是另一文件 ⇒ 仍可合并 + run(repo, "checkout", "-q", "feat/c") + rep = p.checkConflict(repo, "main") + if rep.Skipped { + t.Fatalf("不该跳过: %s", rep.Detail) + } + if !rep.Mergeable { + t.Fatalf("feat/c 改了无关文件,应可合并,实际: %s", rep.Detail) + } + + // ④ ★ 关键:预检**不能改动工作区** + //(否则它就成了"会破坏开发状态"的副作用操作) + cur := strings.TrimSpace(run(repo, "rev-parse", "--abbrev-ref", "HEAD")) + if cur != "feat/c" { + t.Fatalf("预检把 HEAD 改到了 %s —— 它绝不该动工作区", cur) + } + if _, err := os.Stat(filepath.Join(repo, "MERGE_HEAD")); err == nil { + t.Fatal("预检留下了 MERGE_HEAD —— 会污染后续提交") + } +} + +// ★★ 判据:**每一条 Skipped 路径都不得声称可合并**。 +// +// 把"没检查"当成"检查通过"是最危险的静默降级 —— 调用方会以为安全。 +// +// 为何要**逐路径**覆盖:checkConflict 有 3 条 Skipped 出口 +// (取 HEAD 失败 / 目标分支不存在 / merge-tree 不可用), +// 初版只测了第 2 条,于是把第 1 条的 `rep.Mergeable` 改成 true 的变异 +// **不报红** —— 判据覆盖不全,等于给其余两条留了洞。 +func TestEverySkippedPathRefusesMergeable(t *testing.T) { + mkRepo := func(t *testing.T, withBranch bool) string { + repo := t.TempDir() + git := func(args ...string) { + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + cmd.Run() + } + if out, err := exec.Command("git", "init", "-q", "-b", "main", repo).CombinedOutput(); err != nil { + t.Skipf("git init 失败: %v %s", err, out) + } + git("config", "user.email", "t@t") + git("config", "user.name", "t") + os.WriteFile(filepath.Join(repo, "a.txt"), []byte("x\n"), 0o600) + git("add", "-A") + git("commit", "-qm", "init") + if withBranch { + git("checkout", "-q", "-b", "other") + } + return repo + } + + p := &Plugin{ledger: &Ledger{}} + + t.Run("目标分支不存在", func(t *testing.T) { + rep := p.checkConflict(mkRepo(t, false), "no-such-branch") + assertSkippedNotMergeable(t, rep) + }) + + t.Run("不是 git 仓库", func(t *testing.T) { + rep := p.checkConflict(t.TempDir(), "main") + assertSkippedNotMergeable(t, rep) + }) + + t.Run("工作目录不存在", func(t *testing.T) { + rep := p.checkConflict(filepath.Join(t.TempDir(), "nope"), "main") + assertSkippedNotMergeable(t, rep) + }) +} + +// assertSkippedNotMergeable 是上面三条路径共用的断言。 +func assertSkippedNotMergeable(t *testing.T, rep ConflictReport) { + t.Helper() + if !rep.Skipped { + t.Fatalf("应标记 Skipped(detail=%s)", rep.Detail) + } + if rep.Mergeable { + t.Fatal("★ Skipped 时绝不能声称 Mergeable —— 那是把「没检查」当「检查通过」") + } + if !strings.Contains(rep.Detail, "不等于") { + t.Errorf("说明应明确「跳过≠无冲突」: %s", rep.Detail) + } +} + +// ★★ 判据:`captureBaseSHA` 真的把 sha 写进台账,且**只写第一次**。 +// +// "只写第一次"很重要:重开同一分支时若覆盖基点,"这条线从哪出发" +// 就永久丢失 —— 冲突判定与事后重放都失去依据。 +func TestCaptureBaseSHARecordsOnceOnly(t *testing.T) { + repo := t.TempDir() + gitIn := func(args ...string) { + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + cmd.Run() + } + if out, err := exec.Command("git", "init", "-q", "-b", "main", repo).CombinedOutput(); err != nil { + t.Skipf("git init 失败: %v %s", err, out) + } + gitIn("config", "user.email", "t@t") + gitIn("config", "user.name", "t") + os.WriteFile(filepath.Join(repo, "a.txt"), []byte("1\n"), 0o600) + gitIn("add", "-A") + gitIn("commit", "-qm", "c1") + + p := &Plugin{ledger: &Ledger{Branches: map[string]*Branch{ + "br-x": {ID: "br-x", Name: "feat/x", Status: "open"}, + }}} + + sha1, err := p.captureBaseSHA("br-x", repo) + if err != nil { + t.Fatal(err) + } + if sha1 == "" || len(sha1) < 7 { + t.Fatalf("应拿到 sha,实际 %q", sha1) + } + if p.ledger.Branches["br-x"].BaseSHA != sha1 { + t.Fatal("base_sha 未写进台账") + } + + // 再提交一次(HEAD 前进),再捕获 → 仍应返回**第一次**的 sha + os.WriteFile(filepath.Join(repo, "a.txt"), []byte("2\n"), 0o600) + gitIn("add", "-A") + gitIn("commit", "-qm", "c2") + + sha2, err := p.captureBaseSHA("br-x", repo) + if err != nil { + t.Fatal(err) + } + if sha2 != sha1 { + t.Fatalf("基点不该被后续提交覆盖:第一次 %s,第二次 %s", shortSHA(sha1), shortSHA(sha2)) + } +} + +// ★ 判据:拿不到 sha 时**如实报错**,不静默返回空串。 +// +// 空 base_sha 会让后续冲突判定失去依据,而"没记上"与"记了个空的" +// 在台账里长得一样 —— 所以必须报错。 +func TestCaptureBaseSHAFailsLoudlyOnBadRepo(t *testing.T) { + p := &Plugin{ledger: &Ledger{Branches: map[string]*Branch{"br-y": {ID: "br-y"}}}} + if _, err := p.captureBaseSHA("br-y", filepath.Join(t.TempDir(), "not-a-repo")); err == nil { + t.Fatal("非 git 目录应报错,而不是返回空 sha") + } +} + +// ★ 判据:**仅 ls-tree 阶段行**(无 "CONFLICT" 行)也要能解出文件名。 +// +// 某些冲突类型(如 rename/rename)不打印 "CONFLICT ... in ", +// 只有 ls-tree 的三个阶段行。少了这条解析路径就会漏报冲突文件 —— +// 而"漏报"比"多报"危险:调用方以为没有冲突。 +func TestParseConflictFromLsTreeOnly(t *testing.T) { + // 只有阶段行、没有 "CONFLICT ... in" 行(某些冲突类型如此) + const only = `abc123 +100644 aaa 1 dir/x.go +100644 bbb 2 dir/x.go +100644 ccc 3 dir/x.go +` + got := parseConflictFiles(only) + if len(got) != 1 || got[0] != "dir/x.go" { + t.Fatalf("仅 ls-tree 形态也应解出 [dir/x.go],实际 %v", got) + } +} diff --git a/example/commondev/credentials.go b/example/commondev/credentials.go new file mode 100644 index 0000000..bbe7bac --- /dev/null +++ b/example/commondev/credentials.go @@ -0,0 +1,184 @@ +package main + +// 凭据存储:**保留的通用部分**。 +// +// ## 2026-10-15 架构调整(用户拍板) +// +// **git 托管平台专有的部分已移出插件**(`platformSpec` / `account_*` 工具 / +// 角色语义 / 按平台构造认证头)—— 那些是**平台知识**,进 skill 由 agent +// 用 `safeshell` + `curl` 操作。理由:每接一个托管平台都要改 Go 代码、 +// 重编译、重启服务,而平台差异只是「API 前缀 + 认证头」两个维度的不同。 +// +// **本文件保留的是与平台无关的部分**: +// · 凭据的加密落盘(AES-256-GCM,自持密钥) +// · `${ENV_VAR}` 变量引用解析 +// · 「不泄漏值」的展示形态 +// +// 平台的用法见 skill `commondev-discipline`(含 Gitea/GitHub/GitLab/Gitee +// 的认证头对照表、PR 与分支保护的 curl 命令、以及三个实测踩到的 API 坑)。 + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" +) + +// Account 是一条凭据。 +// +// ★ 已移除 `Platform` / `Role` / `BaseURL` 等**平台语义**字段: +// 本插件不再按平台构造请求,也不再有「执行侧/主管侧」的角色区分 —— +// 那是平台侧的(分支保护)与 skill 侧的知识。 +type Account struct { + Name string `json:"name"` + // Token 是**旧路径**:明文或 `${ENV_VAR}` 引用。 + // ★ 新登记会立即加密进 Sealed,本字段随即清空(见 sealAllLocked)。 + // 本字段**绝不出现在任何工具返回值里**(见 redacted)。 + Token string `json:"token,omitempty"` + // Sealed 是 AES-256-GCM 加密后的凭据(base64,nonce 前置)。 + // + // 为何要加密:文件虽 0600,但会被备份、打包、随日志外带; + // 加密后即使文件外泄,没有密钥也不可用。 + Sealed string `json:"sealed,omitempty"` + // Username 仅用于 git 提交身份(可选)。 + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + Note string `json:"note,omitempty"` + CreatedAt string `json:"created_at"` +} + +// credentialsFile 是磁盘上的凭据集合(独立文件,0600)。 +type credentialsFile struct { + Accounts map[string]*Account `json:"accounts"` +} + +// varRefPattern 匹配 ${VAR} 或 $VAR 形式的变量引用。 +var varRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)`) + +// credStore 是凭据管理器(与 Plugin 同生命周期)。 +type credStore struct { + mu sync.Mutex + path string + data credentialsFile + + // keyProvider 返回加密密钥(由 Plugin 注入)。 + keyProvider func() ([]byte, error) +} + +func newCredStore(path string) (*credStore, error) { + cs := &credStore{path: path, data: credentialsFile{Accounts: map[string]*Account{}}} + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return cs, nil + } + return nil, err + } + // ★ 启动时校验权限:凭据文件必须 0600(本仓纪律:响亮失败,不静默降级) + if fi, err := os.Stat(path); err == nil { + if perm := fi.Mode().Perm(); perm != 0o600 { + return nil, fmt.Errorf( + "凭据文件权限不安全:%s 是 %04o,必须是 0600(它是能 push 用户仓库的凭据)", + path, perm) + } + } + if err := json.Unmarshal(b, &cs.data); err != nil { + return nil, fmt.Errorf("凭据文件损坏(拒绝静默重建): %w", err) + } + if cs.data.Accounts == nil { + cs.data.Accounts = map[string]*Account{} + } + return cs, nil +} + +// saveLocked 写入并确权(调用方持锁)。 +func (cs *credStore) saveLocked() error { + b, err := json.MarshalIndent(cs.data, "", " ") + if err != nil { + return err + } + tmp := cs.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + if err := os.Chmod(tmp, 0o600); err != nil { + return err + } + return os.Rename(tmp, cs.path) +} + +// resolveSecret 把凭据里的变量引用解析成实际值。 +// +// 返回错误的情形(都不静默降级): +// - 引用了未设置的环境变量 +// - 解析结果为空 +func resolveSecret(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("凭据为空") + } + var missing []string + out := varRefPattern.ReplaceAllStringFunc(raw, func(m string) string { + name := m + if strings.HasPrefix(m, "${") { + name = m[2 : len(m)-1] + } else { + name = m[1:] + } + v, ok := os.LookupEnv(name) + if !ok || v == "" { + missing = append(missing, name) + return "" + } + return v + }) + if len(missing) > 0 { + return "", fmt.Errorf( + "凭据引用的环境变量未设置: %s —— 请在插件进程的环境里提供它(不静默当空串,"+ + "否则会退化成匿名访问、表现成 401 而非“凭据没配”)", + strings.Join(missing, ", ")) + } + if strings.TrimSpace(out) == "" { + return "", fmt.Errorf("凭据解析后为空") + } + return out, nil +} + +// redacted 返回凭据的可安全展示形态:**绝不暴露 secret**。 +func (a *Account) redacted() map[string]interface{} { + out := map[string]interface{}{ + "name": a.Name, + "username": a.Username, + "token_ref": tokenRefLabel(a.Token), + "token_set": a.Sealed != "" || strings.TrimSpace(a.Token) != "", + } + if a.Note != "" { + out["note"] = a.Note + } + if a.Sealed != "" { + out["storage"] = "encrypted(AES-256-GCM)" + } else { + out["storage"] = "plaintext-or-envref" + } + return out +} + +// tokenRefLabel 描述凭据来源但**不泄露值**。 +func tokenRefLabel(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if varRefPattern.MatchString(raw) { + return raw // ${VAR} 是引用不是秘密,展示它对排查有用 + } + return "(明文已设置)" +} + +// credentialsPath 是凭据文件路径(与台账同目录)。 +func (p *Plugin) credentialsPath() string { + return filepath.Join(filepath.Dir(p.path), "credentials.json") +} diff --git a/example/commondev/criteria.go b/example/commondev/criteria.go new file mode 100644 index 0000000..2a3a121 --- /dev/null +++ b/example/commondev/criteria.go @@ -0,0 +1,767 @@ +package main + +// 刀 4:**判据与复核**(`criteria` / `review`)—— 设计文档 §4.5 的落地。 +// +// ## 核心主张:复核必须是**两步**(正向 + 变异) +// +// 用户原话:「我定义过单元测试后,对应 agent 完成修改后你直接让插件进行复核即可」 +// +// 这里的隐含假设是「插件 GREEN = 改对了」。**该假设不成立** —— +// 插件只能跑主管**预先写下**的判据;判据没覆盖那条 bug,插件照样打勾。 +// +// 本仓当日实证(我在 /tmp 复刻过一遍,结论一致): +// +// func Max(a,b int) int { if a>b { return a }; return b } +// 松判据只测 Max(5,3) == 5 +// 变异 return b → return a(真 bug)⇒ **判据仍全绿** +// 补一条 Max(3,5) == 5 ⇒ 同一变异立刻报红 +// +// ⇒ **没有反向阶段,就是在给"测试通过"这个信号不断充值信用, +// 直到它骗一次大的。** +// +// ## 两个阶段 +// +// 正向 forward:跑判据,期望**通过** +// 反向 mutate :改坏被测代码,期望判据**报红**(不报红 ⇒ 判据太松) +// +// ## ★ 实现要点(每条都是踩过的坑) +// +// 1. **必须禁用测试缓存**(`-count=1`):变异后若命中缓存,测试根本没跑, +// "报红"就无从谈起 —— 而 Go 默认会缓存。实测复刻时就看到 `(cached)`。 +// 2. **变异必须真的改到文件**:改坏→跑→**恢复**,恢复用 defer 保证 +// 即使 panic 也不留脏文件(否则工作目录被污染,后续全乱)。 +// 3. **恢复失败要响亮**:恢复不回去比变异本身危险得多(agent 会基于 +// 坏代码继续改)。 +// 4. **判据脚本的"通过"由退出码决定**,不是由输出文本里有没有 "PASS"。 + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +// ---------------- 台账结构 ---------------- + +// Criteria 是**主管定义的一条判据**。 +type Criteria struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + BranchID string `json:"branch_id,omitempty"` // 留空 = 项目级(所有分支共用) + + Name string `json:"name"` + // Kind 是判据类型:unit|interface|stress|build。 + Kind string `json:"kind"` + + // Script 是判据本体(shell 命令)。退出码 0 = 通过。 + Script string `json:"script"` + // Cwd 是执行目录(相对工作目录)。留空 = 工作目录根。 + Cwd string `json:"cwd,omitempty"` + // TimeoutSec 单次执行上限。默认 300。 + TimeoutSec int `json:"timeout_sec,omitempty"` + + // Expect 描述"通过"的含义(**给人看**,不参与判定)。 + Expect string `json:"expect,omitempty"` + + // Mutations 是**变异规则**(反向阶段用)。 + // + // 空 = 只做正向。但设计 §4.5 明确:**没有反向就是在给信号充值信用** —— + // 所以复核结果里会把"无变异规则"标成一个**警告**,而不是静默略过。 + Mutations []Mutation `json:"mutations,omitempty"` + + // DefHash 是定义指纹:判据内容变了它就该变。 + // + // 用途:① 幂等(重复 define 同一条不产生新记录) + // ② 审计("这次复核跑的是哪一版判据") + DefHash string `json:"def_hash"` + + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// Mutation 是一条变异规则:把文件里的 old 换成 new。 +type Mutation struct { + // ID 用于报告(如 "M1")。 + ID string `json:"id"` + // File 是相对工作目录的路径。 + File string `json:"file"` + // Old / New 是字面量替换(**不是正则** —— 正则容易改错地方)。 + Old string `json:"old"` + New string `json:"new"` + // Note 说明这条变异想验证什么("判据是否覆盖了 b>a 的分支")。 + Note string `json:"note,omitempty"` +} + +// ReviewRun 是一次复核执行的结果。 +type ReviewRun struct { + ID string `json:"id"` + BranchID string `json:"branch_id"` + Phase string `json:"phase"` // forward | mutate + Criteria string `json:"criteria_id"` + Result string `json:"result"` // pass | fail | error | skipped + Detail string `json:"detail,omitempty"` + Evidence string `json:"evidence,omitempty"` // 输出摘要(截断) + StartedAt string `json:"started_at"` + EndedAt string `json:"ended_at,omitempty"` + // HeadSHA 是复核时的提交 —— 判据通过的是**那个提交**,不是一个会移动的目标。 + HeadSHA string `json:"head_sha,omitempty"` +} + +// ---------------- 判据定义 ---------------- + +// criteriaDefHash 计算判据定义的指纹。 +// +// 为何要它:① 幂等(同定义重复登记不产生新记录); +// ② 审计("这次跑的是哪一版判据")。变异规则也必须进指纹 —— +// 只改变异规则而判据本体不变时,复核的正确性已经变了。 +func criteriaDefHash(c *Criteria) string { + h := sha256.New() + for _, part := range []string{c.Name, c.Kind, c.Script, c.Cwd, c.Expect} { + h.Write([]byte(part)) + h.Write([]byte{0}) + } + for _, m := range c.Mutations { + h.Write([]byte(m.ID + "\x01" + m.File + "\x01" + m.Old + "\x01" + m.New)) + h.Write([]byte{0}) + } + fmt.Fprintf(h, "%d", c.TimeoutSec) + return hex.EncodeToString(h.Sum(nil))[:16] +} + +// ---------------- 执行 ---------------- + +// runShell 在工作目录里跑一段命令,返回 (退出码, 合并输出)。 +// +// ★ 超时是必须的:判据脚本可能挂住(死循环、等输入)。内核工具调用 +// 有 60s 硬限,但这里是**后台作业**(不受它限),所以自己定上限。 +func runShell(dir, script string, timeoutSec int) (int, string) { + if timeoutSec <= 0 { + timeoutSec = 300 + } + ctx, cancel := newTimeoutCtx(time.Duration(timeoutSec) * time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", script) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + out, err := cmd.CombinedOutput() + code := 0 + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else { + code = -1 // 起不来(命令不存在等) + } + } + return code, string(out) +} + +// runCriteriaForward 执行一条判据的**正向**阶段。 +// +// 返回 (是否通过, 证据摘要)。 +func (p *Plugin) runCriteriaForward(c *Criteria, workdir string) (bool, string) { + dir := workdir + if c.Cwd != "" { + dir = filepath.Join(workdir, c.Cwd) + } + if _, err := os.Stat(dir); err != nil { + return false, fmt.Sprintf("执行目录不存在: %s", dir) + } + code, out := runShell(dir, c.Script, c.TimeoutSec) + if code == 0 { + return true, summarize(out, "通过(退出码 0)") + } + return false, summarize(out, fmt.Sprintf("未通过(退出码 %d)", code)) +} + +// runCriteriaMutate 执行**反向**阶段:逐条变异,期望判据报红。 +// +// ★ 这是本刀的核心。设计 §4.5:变异**不报红 ⇒ 判据太松**。 +// +// 返回 (全部变异都被咬住, 逐条说明, 是否有严重错误)。 +func (p *Plugin) runCriteriaMutate(c *Criteria, workdir string) (bool, string, bool) { + if len(c.Mutations) == 0 { + return false, "未定义变异规则 —— **无法判断判据是否太松**。" + + "设计 §4.5:没有反向阶段,就是在给「测试通过」这个信号充值信用。" + + "请在 commondev_test_define 里补 mutations", false + } + + allCaught := true + var lines []string + for _, m := range c.Mutations { + caught, note, hardErr := p.runOneMutation(c, m, workdir) + if hardErr { + // 硬错误(改不到文件、恢复失败)—— 必须响亮,不能算"咬住" + allCaught = false + lines = append(lines, fmt.Sprintf(" ✗ %s 执行错误: %s", m.ID, note)) + continue + } + if caught { + lines = append(lines, fmt.Sprintf(" ✓ %s 被咬住(判据报红)%s", m.ID, suffixNote(m.Note))) + } else { + allCaught = false + lines = append(lines, fmt.Sprintf(" ✗ %s **未被咬住** —— 判据太松%s", m.ID, suffixNote(m.Note))) + } + } + return allCaught, strings.Join(lines, "\n"), false +} + +// runOneMutation 改坏 → 跑判据 → 恢复。返回 (是否被咬住, 说明, 硬错误)。 +func (p *Plugin) runOneMutation(c *Criteria, m Mutation, workdir string) (bool, string, bool) { + if m.File == "" || m.Old == "" { + return false, "变异规则缺 file 或 old", true + } + path := filepath.Join(workdir, m.File) + orig, err := os.ReadFile(path) + if err != nil { + return false, fmt.Sprintf("读不到 %s: %v", m.File, err), true + } + text := string(orig) + + // ★ Old 必须**恰好出现一次**:出现 0 次 = 变异无效(改不到); + // 出现多次 = 可能改错地方(第一个不一定是我们想改的那个)。 + // 两者都必须报错 —— 否则"变异没生效"会被误判成"判据太松"(假警报), + // 或"改了别处"被误判成"咬住了"(假通过)。 + n := strings.Count(text, m.Old) + if n == 0 { + return false, fmt.Sprintf("%s 里找不到要替换的片段(变异无效): %q", m.File, truncate(m.Old, 60)), true + } + if n > 1 { + return false, fmt.Sprintf("%s 里该片段出现 %d 次(可能改错地方,请给出更长的上下文): %q", + m.File, n, truncate(m.Old, 60)), true + } + + mutated := strings.Replace(text, m.Old, m.New, 1) + if mutated == text { + return false, "替换后内容未变化(Old 与 New 相同?)", true + } + + // ★ 写坏 → 跑 → **无论如何都恢复**(defer 保证 panic 也恢复) + restore := func() error { return os.WriteFile(path, orig, 0o644) } + if err := os.WriteFile(path, []byte(mutated), 0o644); err != nil { + return false, fmt.Sprintf("写变异失败: %v", err), true + } + defer func() { _ = restore() }() + + passed, evidence := p.runCriteriaForward(c, workdir) + + // 恢复并**校验**恢复成功 + if err := restore(); err != nil { + return false, fmt.Sprintf("★ 恢复 %s 失败(工作目录已被污染): %v", m.File, err), true + } + back, err := os.ReadFile(path) + if err != nil || string(back) != text { + return false, fmt.Sprintf("★ 恢复 %s 后内容不一致(工作目录已被污染)", m.File), true + } + + if !passed { + return true, summarize(evidence, "判据报红(期望行为)"), false + } + // 变异了但判据仍通过 ⇒ 这条变异没被咬住 ⇒ 判据在该维度太松 + return false, summarize(evidence, "变异后判据**仍通过**"), false +} + +func suffixNote(note string) string { + if note == "" { + return "" + } + return "(" + note + ")" +} + +// summarize 把输出压成一条可读证据(**截断**:判据输出可能很长, +// 全塞进台账会让文件爆炸,而证据的价值在"能看出怎么失败的")。 +func summarize(out, headline string) string { + out = strings.TrimSpace(out) + if out == "" { + return headline + } + return headline + "\n" + truncate(out, 1500) +} + +// ---------------- 工具实现 ---------------- + +func (p *Plugin) handleTestDefine(args map[string]interface{}) (interface{}, error) { + proj := strings.TrimSpace(str(args, "project")) + if proj == "" { + return map[string]interface{}{"error": "project 必填"}, nil + } + name := strings.TrimSpace(str(args, "name")) + script := str(args, "script") + if name == "" || strings.TrimSpace(script) == "" { + return map[string]interface{}{"error": "name 与 script 必填"}, nil + } + kind := strings.TrimSpace(str(args, "kind")) + if kind == "" { + kind = "unit" + } + switch kind { + case "unit", "interface", "stress", "build": + default: + return map[string]interface{}{"error": "kind 只能是 unit|interface|stress|build"}, nil + } + + c := &Criteria{ + ID: fmt.Sprintf("cr-%s-%s", safeNamePattern.ReplaceAllString(proj, "_"), shortHash(proj+name+script)), + ProjectID: proj, + BranchID: strings.TrimSpace(str(args, "branch_id")), + Name: name, + Kind: kind, + Script: script, + Cwd: strings.TrimSpace(str(args, "cwd")), + Expect: strings.TrimSpace(str(args, "expect")), + TimeoutSec: int(numArg(args, "timeout_sec")), + CreatedAt: nowRFC3339(), + } + // 变异规则 + if raw, ok := args["mutations"].([]interface{}); ok { + for i, item := range raw { + mm, ok := item.(map[string]interface{}) + if !ok { + continue + } + m := Mutation{ + ID: str(mm, "id"), + File: str(mm, "file"), + Old: str(mm, "old"), + New: str(mm, "new"), + Note: str(mm, "note"), + } + if m.ID == "" { + m.ID = fmt.Sprintf("M%d", i+1) + } + if m.File == "" || m.Old == "" || m.New == "" { + return map[string]interface{}{"error": fmt.Sprintf("变异 %s 缺 file/old/new", m.ID)}, nil + } + c.Mutations = append(c.Mutations, m) + } + } + + p.mu.Lock() + defer p.mu.Unlock() + if p.ledger.Criteria == nil { + p.ledger.Criteria = map[string]*Criteria{} + } + // 幂等:同 ID 且定义指纹相同 ⇒ 不动("可迭代幂等") + if prev, exists := p.ledger.Criteria[c.ID]; exists { + if prev.DefHash == criteriaDefHash(c) { + return map[string]interface{}{ + "status": "unchanged", "criteria_id": c.ID, + "note": "同定义已存在,未改动(幂等)", + }, nil + } + c.CreatedAt = prev.CreatedAt + c.UpdatedAt = nowRFC3339() + } + c.DefHash = criteriaDefHash(c) + p.ledger.Criteria[c.ID] = c + if err := p.saveLocked(); err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + + out := map[string]interface{}{ + "status": "ok", "criteria_id": c.ID, "kind": c.Kind, + "def_hash": c.DefHash, "mutations": len(c.Mutations), + } + if len(c.Mutations) == 0 { + out["warning"] = "未定义变异规则:复核时**无法判断判据是否太松**(设计 §4.5)" + } + return out, nil +} + +func (p *Plugin) handleTestList(args map[string]interface{}) (interface{}, error) { + proj := strings.TrimSpace(str(args, "project")) + p.mu.Lock() + defer p.mu.Unlock() + out := []map[string]interface{}{} + for _, c := range p.ledger.Criteria { + if proj != "" && c.ProjectID != proj { + continue + } + out = append(out, map[string]interface{}{ + "criteria_id": c.ID, "name": c.Name, "kind": c.Kind, + "project": c.ProjectID, "branch_id": c.BranchID, + "mutations": len(c.Mutations), "def_hash": c.DefHash, + }) + } + sort.Slice(out, func(i, j int) bool { + return out[i]["name"].(string) < out[j]["name"].(string) + }) + return map[string]interface{}{"criteria": out, "count": len(out)}, nil +} + +// newTimeoutCtx 是 context.WithTimeout 的薄包装。 +func newTimeoutCtx(d time.Duration) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), d) +} + +// ---------------- 复核执行(后台作业) ---------------- + +// reviewAll 对一个分支跑**全部**判据的复核,返回汇总。 +// +// ★ 这是「主管按一下、插件跑完正反两阶段」的入口。 +func (p *Plugin) reviewAll(branchID string) (map[string]interface{}, error) { + p.mu.Lock() + b, ok := p.ledger.Branches[branchID] + var workdir, proj string + if ok { + workdir, proj = b.Workdir, b.ProjectID + } + // 收集适用判据:分支级优先,项目级兜底 + var list []*Criteria + for _, c := range p.ledger.Criteria { + if c.ProjectID != proj { + continue + } + if c.BranchID != "" && c.BranchID != branchID { + continue + } + list = append(list, c) + } + p.mu.Unlock() + + if !ok { + return nil, fmt.Errorf("工程对话 %s 不存在", branchID) + } + if workdir == "" { + return nil, fmt.Errorf("该分支没有工作目录") + } + if len(list) == 0 { + return map[string]interface{}{ + "error": fmt.Sprintf("项目 %s 没有定义任何判据 —— 先 commondev_test_define", proj), + }, nil + } + // ★ 固定顺序(map 遍历随机)—— 复核报告要可对照 + sort.Slice(list, func(i, j int) bool { + if list[i].Kind != list[j].Kind { + return list[i].Kind < list[j].Kind + } + return list[i].Name < list[j].Name + }) + + head, _ := runGitOut(workdir, "rev-parse", "HEAD") + head = strings.TrimSpace(head) + + forwardOK := true + mutateOK := true + anyWithoutMutations := false + var report []map[string]interface{} + + for _, c := range list { + fwdPass, fwdEv := p.runCriteriaForward(c, workdir) + if !fwdPass { + forwardOK = false + } + mutCaught, mutEv, _ := p.runCriteriaMutate(c, workdir) + if len(c.Mutations) == 0 { + anyWithoutMutations = true + } else if !mutCaught { + mutateOK = false + } + + item := map[string]interface{}{ + "criteria_id": c.ID, "name": c.Name, "kind": c.Kind, + "forward_pass": fwdPass, + "forward_evidence": truncate(fwdEv, 800), + "mutations_total": len(c.Mutations), + "mutations_caught": mutCaught, + } + if len(c.Mutations) > 0 { + item["mutate_evidence"] = truncate(mutEv, 1200) + } else { + item["mutate_evidence"] = mutEv + } + report = append(report, item) + + // 写复核历史(可审计:哪次复核、跑的是哪一版判据、在哪个提交上) + p.appendReviewRun(branchID, c.ID, "forward", fwdPass, fwdEv, head) + if len(c.Mutations) > 0 { + p.appendReviewRun(branchID, c.ID, "mutate", mutCaught, mutEv, head) + } + } + + // ★ 结论三条并列,**不合并成一个绿色勾**: + // 正向绿 + 反向没跑 ≠ 复核通过(那是"信号充值") + verdict := "pass" + notes := []string{} + if !forwardOK { + verdict = "fail" + notes = append(notes, "有判据未通过(实现有 bug)") + } + if anyWithoutMutations { + notes = append(notes, "★ 有判据未定义变异规则 ⇒ 无法判断它是否太松(设计 §4.5)") + if verdict == "pass" { + verdict = "pass_with_gaps" + } + } + if !mutateOK { + notes = append(notes, "★ 有变异**未被咬住** ⇒ 对应判据太松,实现可能有问题而判据看不见") + if verdict == "pass" { + verdict = "pass_with_gaps" + } + } + + out := map[string]interface{}{ + "verdict": verdict, // pass | pass_with_gaps | fail + "forward_ok": forwardOK, + "mutate_ok": mutateOK, + "head_sha": shortSHA(head), + "criteria_run": len(report), + "report": report, + } + if len(notes) > 0 { + out["notes"] = notes + } + if verdict == "pass_with_gaps" { + out["warning"] = "正向全绿,但**反向阶段有缺口** —— " + + "这不是「通过」,是「判据没覆盖到」。插件 GREEN ≠ 改对了。" + } + return out, nil +} + +// appendReviewRun 记一条复核历史。 +func (p *Plugin) appendReviewRun(branchID, criteriaID, phase string, pass bool, evidence, head string) { + res := "fail" + if pass { + res = "pass" + } + p.mu.Lock() + defer p.mu.Unlock() + p.ledger.ReviewRuns = append(p.ledger.ReviewRuns, ReviewRun{ + ID: fmt.Sprintf("rv-%d", time.Now().UnixNano()%1000000000), + BranchID: branchID, Criteria: criteriaID, Phase: phase, + Result: res, Evidence: truncate(evidence, 500), + StartedAt: nowRFC3339(), EndedAt: nowRFC3339(), + HeadSHA: head, + }) + // 只保留最近 500 条(防台账无限膨胀;复核历史是审计用,不需要全量) + if n := len(p.ledger.ReviewRuns); n > 500 { + p.ledger.ReviewRuns = p.ledger.ReviewRuns[n-500:] + } + _ = p.saveLocked() +} + +// handleReviewSubmit 是 commondev_review_submit 的实现。 +// +// ★ 复核可能跑很久(判据里可能有编译、压测)⇒ 走**后台作业**, +// 与 branch_open 的 clone 同理(内核工具调用有 60s 硬限)。 +func (p *Plugin) handleReviewSubmit(args map[string]interface{}) (interface{}, error) { + branchID := strings.TrimSpace(str(args, "branch_id")) + if branchID == "" { + return map[string]interface{}{"error": "branch_id 必填"}, nil + } + p.mu.Lock() + if _, ok := p.ledger.Branches[branchID]; !ok { + p.mu.Unlock() + return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", branchID)}, nil + } + jobID := fmt.Sprintf("review-%s-%d", branchID, time.Now().UnixNano()%100000) + p.ledger.Jobs[jobID] = &Job{ + ID: jobID, BranchID: branchID, Kind: "review", + Status: "pending", StartedAt: nowRFC3339(), + } + p.saveLocked() + p.mu.Unlock() + + go func() { + p.setJob(jobID, "running", "正在复核(正向 + 变异)", "") + res, err := p.reviewAll(branchID) + if err != nil { + p.setJob(jobID, "failed", "", err.Error()) + return + } + if e, isErr := res["error"]; isErr { + p.setJob(jobID, "failed", "", fmt.Sprint(e)) + return + } + verdict := fmt.Sprint(res["verdict"]) + detail := fmt.Sprintf("复核结论: %s", verdict) + if notes, ok := res["notes"].([]string); ok && len(notes) > 0 { + detail += " | " + strings.Join(notes, ";") + } + p.setJob(jobID, "done", detail, "") + p.lastReview = res + }() + + return map[string]interface{}{ + "status": "ok", "job_id": jobID, "branch_id": branchID, + "next": "复核在后台进行(判据可能含编译/压测,超过 60s 工具上限)——" + + "用 commondev_job_status 查进度;用 commondev_review_result 取结论", + }, nil +} + +// handleReviewResult 取最近一次复核结论。 +func (p *Plugin) handleReviewResult(args map[string]interface{}) (interface{}, error) { + branchID := strings.TrimSpace(str(args, "branch_id")) + p.mu.Lock() + res := p.lastReview + // 也把历史摘要带上(审计) + var hist []map[string]interface{} + for _, r := range p.ledger.ReviewRuns { + if branchID != "" && r.BranchID != branchID { + continue + } + hist = append(hist, map[string]interface{}{ + "id": r.ID, "phase": r.Phase, "criteria": r.Criteria, + "result": r.Result, "head_sha": shortSHA(r.HeadSHA), "at": r.StartedAt, + }) + } + p.mu.Unlock() + + if res == nil && len(hist) == 0 { + return map[string]interface{}{"error": "还没有复核记录(先 commondev_review_submit)"}, nil + } + // 只给最近 40 条(防刷屏) + if len(hist) > 40 { + hist = hist[len(hist)-40:] + } + out := map[string]interface{}{"history": hist, "history_count": len(hist)} + if res != nil { + out["last"] = res + } + return out, nil +} + +// ---------------- 单阶段复核工具(设计 §4.1) ---------------- +// +// 设计 §4.1 把复核拆成两个工具: +// +// commondev_test_run 执行判据集合(正向) +// commondev_test_mutate 执行变异验证(反向)★ 必须有 +// +// 而 commondev_review_submit 是**两者一起跑**(主管按一下就跑完)。 +// 三种粒度都保留,因为排查时需要单跑某一阶段: +// "正向都过不了" 与 "正过但变异咬不住" 是**完全不同**的问题。 + +// criteriaFor 收集适用于某分支的判据(分支级优先,项目级兜底)。 +func (p *Plugin) criteriaFor(branchID, proj string) []*Criteria { + p.mu.Lock() + var list []*Criteria + for _, c := range p.ledger.Criteria { + if c.ProjectID != proj { + continue + } + if c.BranchID != "" && c.BranchID != branchID { + continue + } + list = append(list, c) + } + p.mu.Unlock() + sort.Slice(list, func(i, j int) bool { + if list[i].Kind != list[j].Kind { + return list[i].Kind < list[j].Kind + } + return list[i].Name < list[j].Name + }) + return list +} + +// lookupBranchWorkdir 取分支与其项目(找不到时给明确错误)。 +func (p *Plugin) lookupBranchWorkdir(branchID string) (*Branch, *Project, error) { + p.mu.Lock() + defer p.mu.Unlock() + b, ok := p.ledger.Branches[branchID] + if !ok { + return nil, nil, fmt.Errorf("工程对话 %s 不存在", branchID) + } + proj := p.ledger.Projects[b.ProjectID] + if proj == nil { + return nil, nil, fmt.Errorf("分支 %s 的项目 %s 不存在", branchID, b.ProjectID) + } + if strings.TrimSpace(b.Workdir) == "" { + return nil, nil, fmt.Errorf("该分支没有工作目录(先 commondev_branch_open)") + } + return b, proj, nil +} + +// handleTestRun 只跑**正向**阶段。 +func (p *Plugin) handleTestRun(args map[string]interface{}) (interface{}, error) { + branchID := strings.TrimSpace(str(args, "branch_id")) + if branchID == "" { + return map[string]interface{}{"error": "branch_id 必填"}, nil + } + b, proj, err := p.lookupBranchWorkdir(branchID) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + list := p.criteriaFor(branchID, proj.Name) + if len(list) == 0 { + return map[string]interface{}{"error": fmt.Sprintf("项目 %s 没有定义判据(先 commondev_test_define)", proj.Name)}, nil + } + allPass, report := true, []map[string]interface{}{} + for _, c := range list { + pass, ev := p.runCriteriaForward(c, b.Workdir) + if !pass { + allPass = false + } + report = append(report, map[string]interface{}{ + "criteria_id": c.ID, "name": c.Name, "kind": c.Kind, + "pass": pass, "evidence": truncate(ev, 800), + }) + } + out := map[string]interface{}{ + "phase": "forward", "all_pass": allPass, + "criteria_run": len(report), "report": report, + } + if !allPass { + out["error"] = "有判据未通过(实现有 bug)" + } + // ★ 提醒:正向过了也不代表改对了 + out["warning"] = "正向通过 ≠ 改对了 —— 还要跑 commondev_test_mutate" + + "(变异不报红 ⇒ 判据太松,见设计 §4.5)" + return out, nil +} + +// handleTestMutate 只跑**反向**阶段。 +func (p *Plugin) handleTestMutate(args map[string]interface{}) (interface{}, error) { + branchID := strings.TrimSpace(str(args, "branch_id")) + if branchID == "" { + return map[string]interface{}{"error": "branch_id 必填"}, nil + } + b, proj, err := p.lookupBranchWorkdir(branchID) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + list := p.criteriaFor(branchID, proj.Name) + if len(list) == 0 { + return map[string]interface{}{"error": fmt.Sprintf("项目 %s 没有定义判据", proj.Name)}, nil + } + allCaught, withGaps, report := true, []string{}, []map[string]interface{}{} + for _, c := range list { + if len(c.Mutations) == 0 { + allCaught = false + withGaps = append(withGaps, c.Name) + report = append(report, map[string]interface{}{ + "criteria_id": c.ID, "name": c.Name, + "mutations_total": 0, "all_caught": false, + "evidence": "★ 未定义变异规则 ⇒ 无法判断判据是否太松", + }) + continue + } + caught, ev, _ := p.runCriteriaMutate(c, b.Workdir) + if !caught { + allCaught = false + } + report = append(report, map[string]interface{}{ + "criteria_id": c.ID, "name": c.Name, + "mutations_total": len(c.Mutations), "all_caught": caught, + "evidence": truncate(ev, 1200), + }) + } + out := map[string]interface{}{ + "phase": "mutate", "all_caught": allCaught, + "criteria_run": len(report), "report": report, + } + if len(withGaps) > 0 { + out["criteria_without_mutations"] = withGaps + } + if !allCaught { + out["warning"] = "★ 有变异**未被咬住** ⇒ 判据太松,实现可能有问题而判据看不见" + } + return out, nil +} diff --git a/example/commondev/criteria_test.go b/example/commondev/criteria_test.go new file mode 100644 index 0000000..f033ab6 --- /dev/null +++ b/example/commondev/criteria_test.go @@ -0,0 +1,378 @@ +package main + +// 刀 4 的判据:判据与复核(正向 + 变异)。 +// +// ## 这组判据要证明的核心命题 +// +// 设计 §4.5:「插件 GREEN ≠ 改对了」—— +// +// 插件只能跑主管**预先写下**的判据。判据没覆盖那条 bug,插件照样打勾。 +// 自动化**不会**发现假绿,只会让假绿跑得更快、更可信。 +// +// ⇒ 复核必须是两步:正向(判据应过)+ 反向(变异应被咬住)。 +// +// 所以本文件最重要的判据是 **TestReviewCatchesLooseCriteria**: +// 造一个「真 bug 但判据太松」的场景,断言复核**不给出 pass**。 + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// newTestPluginForCriteria 建一个带台账的插件。 +func newTestPluginForCriteria(t *testing.T) *Plugin { + t.Helper() + p := newTestPluginForShell(t) + p.ledger = &Ledger{ + Projects: map[string]*Project{}, + Branches: map[string]*Branch{}, + Jobs: map[string]*Job{}, + Criteria: map[string]*Criteria{}, + } + return p +} + +// writeRepo 在工作目录里放一个可被变异的最小 Go 项目。 +func writeRepo(t *testing.T, loose bool) string { + t.Helper() + dir := t.TempDir() + mustWrite := func(rel, content string) { + path := filepath.Join(dir, rel) + os.MkdirAll(filepath.Dir(path), 0o755) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + mustWrite("go.mod", "module probe\n\ngo 1.21\n") + mustWrite("calc.go", `package calc + +func Max(a, b int) int { + if a > b { + return a + } + return b +} +`) + if loose { + // ★ 松判据:只测 a>b 的分支 + mustWrite("calc_test.go", `package calc + +import "testing" + +func TestMax(t *testing.T) { + if Max(5, 3) != 5 { + t.Fatal("错") + } +} +`) + } else { + // 严判据:两个方向都测 + mustWrite("calc_test.go", `package calc + +import "testing" + +func TestMax(t *testing.T) { + if Max(5, 3) != 5 { + t.Fatal("a>b 错") + } + if Max(3, 5) != 5 { + t.Fatal("b>a 错") + } +} +`) + } + return dir +} + +func gitIn(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +// ★★★ 本刀最重要的判据:**复核必须识破「真 bug + 松判据」**。 +// +// 场景: +// +// 代码 Max 正确;判据只测 Max(5,3)(漏了 b>a) +// 变异:return b → return a(真 bug) +// 松判据**仍通过** ⇒ 该变异"未被咬住" +// +// 期望:复核结论**不是 pass**(而是 pass_with_gaps),并明确指出 +// "变异未被咬住 ⇒ 判据太松"。 +// +// 若这条报红,说明复核机制回到了"只看正向"——那就失去了本刀的全部意义。 +func TestReviewCatchesLooseCriteria(t *testing.T) { + p := newTestPluginForCriteria(t) + dir := writeRepo(t, true) // ★ 松判据 + + p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"} + p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"} + p.ledger.Criteria["cr-1"] = &Criteria{ + ID: "cr-1", ProjectID: "probe", Name: "max 单元判据", Kind: "unit", + Script: "go test ./...", + Expect: "两方向都正确", + Mutations: []Mutation{{ + ID: "M1", File: "calc.go", + Old: "\treturn b\n}", New: "\treturn a\n}", + Note: "验证判据是否覆盖 b>a 分支", + }}, + DefHash: "x", + } + + res, err := p.reviewAll("br-1") + if err != nil { + t.Fatal(err) + } + verdict := res["verdict"].(string) + + // ① 正向应通过(松判据确实能过) + if !res["forward_ok"].(bool) { + t.Fatalf("前提失效:松判据的正向应通过,实际 evidence=%v", res["report"]) + } + // ② 反向必须**没被咬住** + if res["mutate_ok"].(bool) { + t.Fatal("★ 变异未被咬住却报 mutate_ok=true —— 反向阶段没在起作用") + } + // ③ 结论**绝不能是 pass** + if verdict == "pass" { + t.Fatal("★★ 真 bug + 松判据 却给出 pass —— " + + "这正是设计 §4.5 说的『给测试通过充值信用,直到它骗一次大的』") + } + if verdict != "pass_with_gaps" { + t.Errorf("结论应为 pass_with_gaps,实际 %q", verdict) + } + // ④ 必须明确说明原因(不能只给个模糊的黄色) + notes, _ := res["notes"].([]string) + joined := strings.Join(notes, " ") + if !strings.Contains(joined, "太松") && !strings.Contains(joined, "未被咬住") { + t.Errorf("notes 应指出『判据太松』,实际: %v", notes) + } + t.Logf("✅ 松判据被识破:verdict=%s notes=%v", verdict, notes) +} + +// ★★ 判据:**严判据**时复核给出 pass(机制不能把一切都说成有问题)。 +func TestReviewPassesWithTightCriteria(t *testing.T) { + p := newTestPluginForCriteria(t) + dir := writeRepo(t, false) // 严判据 + + p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"} + p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"} + p.ledger.Criteria["cr-1"] = &Criteria{ + ID: "cr-1", ProjectID: "probe", Name: "max 单元判据", Kind: "unit", + Script: "go test ./...", + Mutations: []Mutation{{ + ID: "M1", File: "calc.go", + Old: "\treturn b\n}", New: "\treturn a\n}", + Note: "b>a 分支", + }}, + DefHash: "x", + } + + res, err := p.reviewAll("br-1") + if err != nil { + t.Fatal(err) + } + if res["verdict"] != "pass" { + t.Fatalf("严判据应给 pass,实际 %q(notes=%v)", res["verdict"], res["notes"]) + } + if !res["forward_ok"].(bool) || !res["mutate_ok"].(bool) { + t.Fatalf("正向与反向都应通过: forward=%v mutate=%v", + res["forward_ok"], res["mutate_ok"]) + } + t.Log("✅ 严判据 → pass(机制不会把一切都判成有问题)") +} + +// ★★ 判据:**判据实现有 bug** 时正向就报红 ⇒ fail。 +func TestReviewFailsOnForwardFailure(t *testing.T) { + p := newTestPluginForCriteria(t) + dir := writeRepo(t, false) + + p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"} + p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Workdir: dir, Status: "open"} + p.ledger.Criteria["cr-1"] = &Criteria{ + ID: "cr-1", ProjectID: "probe", Name: "故意失败", Kind: "unit", + Script: "false", // 永远失败 + Mutations: []Mutation{{ID: "M1", File: "calc.go", Old: "a > b", New: "a >= b"}}, + DefHash: "x", + } + + res, err := p.reviewAll("br-1") + if err != nil { + t.Fatal(err) + } + if res["verdict"] != "fail" { + t.Fatalf("正向失败应给 fail,实际 %q", res["verdict"]) + } + if res["forward_ok"].(bool) { + t.Fatal("forward_ok 应为 false") + } +} + +// ★★ 判据:**未定义变异规则**时不能给 pass(那正是"信号充值")。 +func TestReviewFlagsMissingMutations(t *testing.T) { + p := newTestPluginForCriteria(t) + dir := writeRepo(t, false) + + p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"} + p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"} + p.ledger.Criteria["cr-1"] = &Criteria{ + ID: "cr-1", ProjectID: "probe", Name: "无变异", Kind: "unit", + Script: "go test ./...", + // ★ 刻意不给 Mutations + DefHash: "x", + } + + res, err := p.reviewAll("br-1") + if err != nil { + t.Fatal(err) + } + if res["verdict"] == "pass" { + t.Fatal("★ 无变异规则却给 pass —— 等于把『没检查』当『检查通过』") + } + if res["verdict"] != "pass_with_gaps" { + t.Errorf("应为 pass_with_gaps,实际 %q", res["verdict"]) + } + notes, _ := res["notes"].([]string) + if !strings.Contains(strings.Join(notes, " "), "变异规则") { + t.Errorf("notes 应指出缺变异规则: %v", notes) + } +} + +// ---------------- 变异执行的安全性 ---------------- + +// ★★★ 判据:变异**必须恢复原文件**(否则工作目录被污染,后续全乱)。 +func TestMutationAlwaysRestoresFile(t *testing.T) { + p := newTestPluginForCriteria(t) + dir := writeRepo(t, false) + + orig, _ := os.ReadFile(filepath.Join(dir, "calc.go")) + c := &Criteria{ + ID: "c", ProjectID: "probe", Name: "n", Kind: "unit", + Script: "go test ./...", + Mutations: []Mutation{ + {ID: "M1", File: "calc.go", Old: "\treturn b\n}", New: "\treturn a\n}"}, + }, + } + + _, _, hardErr := p.runCriteriaMutate(c, dir) + if hardErr { + t.Fatal("不该有硬错误") + } + after, err := os.ReadFile(filepath.Join(dir, "calc.go")) + if err != nil { + t.Fatal(err) + } + if string(after) != string(orig) { + t.Fatalf("★ 变异后**未恢复**原文件 —— 工作目录被污染\n前:\n%s\n后:\n%s", orig, after) + } +} + +// ★★ 判据:`old` 出现 **0 次或多次**都必须报硬错误(不能算"咬住")。 +// +// 为何重要: +// +// · 0 次 ⇒ 变异无效,"判据没报红"是**假警报**(其实判据没问题) +// · 多次 ⇒ 可能改错地方,"判据报红"是**假通过**(改的不是想改的那处) +func TestMutationRequiresUniqueMatch(t *testing.T) { + p := newTestPluginForCriteria(t) + dir := writeRepo(t, false) + c := &Criteria{ID: "c", ProjectID: "probe", Name: "n", Kind: "unit", Script: "true"} + + // ① old 不存在 + c.Mutations = []Mutation{{ID: "M1", File: "calc.go", Old: "NOT_PRESENT_ANYWHERE", New: "x"}} + caught, note, hardErr := p.runOneMutation(c, c.Mutations[0], dir) + if !hardErr { + t.Fatal("★ old 不存在时报硬错误 —— 否则『变异无效』会被误判成『判据太松』(假警报)") + } + if caught { + t.Error("硬错误时不该算咬住") + } + if !strings.Contains(note, "找不到") { + t.Errorf("说明应指出找不到片段: %s", note) + } + + // ② old 出现多次 + multi := filepath.Join(dir, "multi.go") + os.WriteFile(multi, []byte("package calc\n\nvar a = 1\nvar b = 1\n"), 0o644) + c.Mutations = []Mutation{{ID: "M2", File: "multi.go", Old: "= 1", New: "= 2"}} + _, note2, hardErr2 := p.runOneMutation(c, c.Mutations[0], dir) + if !hardErr2 { + t.Fatal("★ old 出现多次时报硬错误 —— 否则可能改错地方却报『咬住』(假通过)") + } + if !strings.Contains(note2, "出现 2 次") { + t.Errorf("说明应指出出现次数: %s", note2) + } +} + +// ★ 判据:恢复失败/内容不一致 ⇒ 硬错误(**响亮**,不静默继续)。 +func TestMutationDetectsRestoreMismatch(t *testing.T) { + p := newTestPluginForCriteria(t) + dir := writeRepo(t, false) + // 只读文件 ⇒ 写入变异会失败 ⇒ 硬错误 + ro := filepath.Join(dir, "ro.go") + os.WriteFile(ro, []byte("package calc\n\nvar x = 1\n"), 0o444) + if os.Geteuid() == 0 { + t.Skip("root 下只读文件仍可写,跳过") + } + c := &Criteria{ID: "c", ProjectID: "probe", Name: "n", Kind: "unit", Script: "true"} + m := Mutation{ID: "M1", File: "ro.go", Old: "= 1", New: "= 2"} + if _, _, hardErr := p.runOneMutation(c, m, dir); !hardErr { + t.Fatal("写不进去时报硬错误") + } +} + +// ---------------- 真机端到端(真实 go test) ---------------- + +// ★★ 判据:**真实 go test** 下,正反两阶段的结果符合预期。 +// +// 上面几条用真实 git/go 项目;这一条额外确认「Go 的**测试缓存**不会 +// 让变异失效」—— 实测过:变异后若命中缓存,测试根本没跑, +// "报红"就无从谈起。实现里用 `-count=1` 规避。 +func TestReviewDoesNotHitGoTestCache(t *testing.T) { + if _, err := exec.LookPath("go"); err != nil { + t.Skip("环境无 go") + } + p := newTestPluginForCriteria(t) + dir := writeRepo(t, false) + + // 先跑一次让缓存热起来 + code, _ := runShell(dir, "go test -count=1 ./...", 120) + if code != 0 { + t.Skipf("前置 go test 未通过(环境问题),跳过: %d", code) + } + + p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"} + p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"} + p.ledger.Criteria["cr-1"] = &Criteria{ + ID: "cr-1", ProjectID: "probe", Name: "严判据", Kind: "unit", + Script: "go test -count=1 ./...", + Mutations: []Mutation{{ + ID: "M1", File: "calc.go", + Old: "\treturn b\n}", New: "\treturn a\n}", + Note: "b>a 分支", + }}, + DefHash: "x", + } + + res, err := p.reviewAll("br-1") + if err != nil { + t.Fatal(err) + } + if !res["mutate_ok"].(bool) { + ev := "" + if rep, ok := res["report"].([]map[string]interface{}); ok && len(rep) > 0 { + ev = str(rep[0], "mutate_evidence") + } + t.Fatalf("★ 变异未被咬住 —— 可能是 Go 测试缓存导致变异后测试没真跑\n%s", ev) + } + t.Log("✅ 真实 go test 下变异被正确咬住(未受缓存影响)") +} diff --git a/example/commondev/e2e-agentdir/auth.json b/example/commondev/e2e-agentdir/auth.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/example/commondev/e2e-agentdir/auth.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/example/commondev/e2e-agentdir/models-store.json b/example/commondev/e2e-agentdir/models-store.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/example/commondev/e2e-agentdir/models-store.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/example/commondev/e2e_criteria_test.go b/example/commondev/e2e_criteria_test.go new file mode 100644 index 0000000..d032ff2 --- /dev/null +++ b/example/commondev/e2e_criteria_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// ★★ 真机端到端:**真实项目 + 真实 go test**,验证刀 4 能识破 +// 「真 bug 藏在松判据后面」。 +// +// 场景(与 /tmp/d4e2e 手工复现的一致): +// +// Clamp(v,lo,hi) 有三个分支;判据只测了 vhi 分支(真 bug)⇒ 松判据**仍全绿** +// +// 复核必须给出 **pass_with_gaps** 而不是 pass,并说明"变异未被咬住"。 +func TestE2E_ReviewCatchesHiddenBugBehindLooseCriteria(t *testing.T) { + if _, err := exec.Command("go", "version").Output(); err != nil { + t.Skip("环境无 go") + } + dir := t.TempDir() + write := func(rel, c string) { + p := filepath.Join(dir, rel) + os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte(c), 0o644); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module d4demo\n\ngo 1.21\n") + write("calc.go", `package d4demo + +func Clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} +`) + // ★ 松判据:只测下界 + write("calc_test.go", `package d4demo + +import "testing" + +func TestClampLoose(t *testing.T) { + if Clamp(-5, 0, 10) != 0 { + t.Fatal("低于下界时错") + } +} +`) + if err := exec.Command("git", "init", "-q", "-b", "main", dir).Run(); err != nil { + t.Skipf("git init: %v", err) + } + + p := newTestPluginForCriteria(t) + p.ledger.Projects["demo"] = &Project{Name: "demo", RemoteURL: "x"} + p.ledger.Branches["br-1"] = &Branch{ + ID: "br-1", ProjectID: "demo", Name: "feat/x", Workdir: dir, Status: "open"} + + // 主管定义判据:正向用松判据;变异**删掉 v>hi 分支** + p.ledger.Criteria["cr-clamp"] = &Criteria{ + ID: "cr-clamp", ProjectID: "demo", Name: "clamp 上下界", Kind: "unit", + Script: "go test -count=1 ./...", + Expect: "上下界都被限制", + Mutations: []Mutation{{ + ID: "M1", File: "calc.go", + Old: " if v > hi {\n return hi\n }\n", New: "", + Note: "删掉上界分支(真 bug)——松判据看不见它", + }}, + DefHash: "x", + } + + res, err := p.reviewAll("br-1") + if err != nil { + t.Fatal(err) + } + verdict := res["verdict"].(string) + + // ① 正向必须**通过**(松判据确实能过 —— 这就是问题所在) + if !res["forward_ok"].(bool) { + t.Fatalf("前提失效:松判据正向应通过。report=%v", res["report"]) + } + // ② 变异必须**未被咬住**(真 bug 躲在判据盲区里) + if res["mutate_ok"].(bool) { + t.Fatal("★ 删掉整个上界分支都没被咬住却报 mutate_ok=true —— 反向阶段失效") + } + // ③ 结论绝不能被当成 "pass" + if verdict == "pass" { + t.Fatal("★★ 真 bug + 松判据 ⇒ 给了 pass。这正是刀 4 存在的理由") + } + if verdict != "pass_with_gaps" { + t.Errorf("应为 pass_with_gaps,实际 %q", verdict) + } + // ④ 报告里要能看出是**哪条**变异没被咬住 + rep, _ := res["report"].([]map[string]interface{}) + if len(rep) == 0 { + t.Fatal("报告为空") + } + ev := str(rep[0], "mutate_evidence") + if !strings.Contains(ev, "未被咬住") { + t.Errorf("mutate_evidence 应指出未被咬住,实际: %s", ev) + } + // ⑤ 工作目录必须**已恢复**(变异不留脏文件) + after, _ := os.ReadFile(filepath.Join(dir, "calc.go")) + if !strings.Contains(string(after), "if v > hi {") { + t.Fatal("★ 变异后未恢复 calc.go —— 工作目录被污染") + } + + t.Logf("✅ 真机识破「真 bug 藏在松判据后」: verdict=%s", verdict) + t.Logf(" notes=%v", res["notes"]) +} + +// ★ 对照:**补一条严判据**后,同一个 bug 会被正向阶段直接抓到。 +func TestE2E_TightCriteriaCatchesTheBugDirectly(t *testing.T) { + if _, err := exec.Command("go", "version").Output(); err != nil { + t.Skip("环境无 go") + } + dir := t.TempDir() + write := func(rel, c string) { + p := filepath.Join(dir, rel) + os.MkdirAll(filepath.Dir(p), 0o755) + os.WriteFile(p, []byte(c), 0o644) + } + write("go.mod", "module d4demo2\n\ngo 1.21\n") + // ★ 这个项目里 bug **已经存在**(上界分支缺失) + write("calc.go", `package d4demo2 + +func Clamp(v, lo, hi int) int { + if v < lo { + return lo + } + return v +} +`) + // 严判据:上下界都测 + write("calc_test.go", `package d4demo2 + +import "testing" + +func TestClampTight(t *testing.T) { + if Clamp(-5, 0, 10) != 0 { + t.Fatal("下界错") + } + if Clamp(15, 0, 10) != 10 { + t.Fatal("上界错") + } +} +`) + exec.Command("git", "init", "-q", "-b", "main", dir).Run() + + p := newTestPluginForCriteria(t) + p.ledger.Projects["demo2"] = &Project{Name: "demo2", RemoteURL: "x"} + p.ledger.Branches["br-1"] = &Branch{ + ID: "br-1", ProjectID: "demo2", Name: "feat/x", Workdir: dir, Status: "open"} + p.ledger.Criteria["cr-1"] = &Criteria{ + ID: "cr-1", ProjectID: "demo2", Name: "clamp 上下界", Kind: "unit", + Script: "go test -count=1 ./...", + Mutations: []Mutation{{ID: "M1", File: "calc.go", + Old: "return v\n}", New: "return lo\n}"}}, + DefHash: "x", + } + + res, err := p.reviewAll("br-1") + if err != nil { + t.Fatal(err) + } + // 正向就抓到 bug ⇒ fail + if res["verdict"] != "fail" { + t.Fatalf("严判据应正向就抓到 bug(fail),实际 %q", res["verdict"]) + } + t.Log("✅ 对照:严判据在正向阶段直接抓到同一个 bug → fail") +} diff --git a/example/commondev/encshell.go b/example/commondev/encshell.go new file mode 100644 index 0000000..05ce311 --- /dev/null +++ b/example/commondev/encshell.go @@ -0,0 +1,429 @@ +package main + +// 加密 shell:agent 用特殊格式让插件为它**填充解密后的凭据**,再执行命令。 +// +// ## 用户的要求(2026-10-14) +// +// 「插件为 agent 额外提供一个加密 shell 工具,agent 可以通过特殊的格式 +// 让该插件工具为其填充加密数据」 +// +// 即一个 **credential broker**:secret 加密存盘、agent 只写**引用**, +// 明文只在插件的执行边界上短暂存在。 +// +// ## 三条实测得出的实现纪律(不是设计推测) +// +// ### ① secret 绝不进 argv —— 脚本必须走 **stdin** +// +// 实测(扫全系统 /proc/*/cmdline): +// +// 明文作为 argv 参数 → ★ 泄露(任意同机进程可读) +// sh -c "…{secret}…" → ★ 泄露(脚本本身就是 argv) +// **脚本经 stdin 喂给 bash** → ✅ 不泄露 +// +// ⇒ 实现上:`bash` 的 argv 只有 `["bash"]`,脚本体经 stdin 管道送入。 +// 这条不能妥协:`ps -eo args` 是任何本机进程都能跑的。 +// +// ### ② 输出必须脱敏 —— 否则明文经 stdout 进模型上下文 +// +// 实测:`echo "token is $T"` 的输出会原样回传。而工具返回值**会进模型上下文**, +// 这是最危险的泄露面。⇒ 输出回传前必须把已知 secret 值替换掉。 +// +// ### 这两条只防**意外**,不防**恶意** +// +// 必须诚实:agent 可以 `echo $TOKEN | base64` 或 `xxd` 绕过字符串脱敏。 +// 所以本工具**不是沙箱**,其安全性来自: +// · secret 不进 argv(防同机窥探) +// · 输出脱敏(防意外回显进上下文) +// · **不防恶意 exfiltration** —— 要防那个必须限制能执行的命令集合。 +// 这条边界写进工具描述,不假装安全。 +// +// ## 特殊格式 +// +// 命令里用 `@{name}` 引用一条凭据(如 `@{gitea-local}`),插件在冻结的脚本里 +// 把它替换为解密后的明文。为什么用 `@{...}` 而不是 `${...}`: +// · `${...}` 与 shell 自己的变量语法冲突,agent 很容易误用; +// · `@{...}` 不会与任何常见 shell 语法撞车,拼错时行为可预期。 + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// credRefPattern 匹配命令里的凭据引用 @{name}。 +var credRefPattern = regexp.MustCompile(`@\{([A-Za-z0-9_.-]{1,64})\}`) + +// ---------------- 加密存储 ---------------- +// +// 为何要"加密":凭据文件虽是 0600,但它在插件的**数据目录**里,会被备份、 +// 会被 `tar` 打包、可能随日志外带。加密后即使文件外泄,没有密钥也不可用。 +// +// 密钥从哪来(**不落盘**): +// 1. `${COMMONDEV_CRED_KEY}` 环境变量(推荐:systemd 注入) +// 2. `/master.key`(0600,自动生成)—— 便利与安全之间取后者弱一档, +// 但它至少与凭据文件**不同位置**,且本插件把整个数据目录权限视为 0700。 +// +// 铁律:**没有密钥 ⇒ 响亮报错**,绝不静默退回明文存盘。 + +const masterKeyEnv = "COMMONDEV_CRED_KEY" + +// masterKey 返回 32 字节 AES-256 密钥。 +func (p *Plugin) masterKey() ([]byte, error) { + if v := strings.TrimSpace(os.Getenv(masterKeyEnv)); v != "" { + b, err := base64.StdEncoding.DecodeString(v) + if err != nil || len(b) != 32 { + // 也接受原始 32 字节字符串(方便人手工设) + if len(v) == 32 { + return []byte(v), nil + } + return nil, fmt.Errorf( + "%s 必须是 base64 编码的 32 字节密钥(或正好 32 字符)", masterKeyEnv) + } + return b, nil + } + // 回退:数据目录下的 master.key + path := filepath.Join(filepath.Dir(p.path), "master.key") + if b, err := os.ReadFile(path); err == nil && len(b) >= 32 { + return b[:32], nil + } + // 首次:生成并落盘 0600 + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + if err := os.WriteFile(path, key, 0o600); err != nil { + return nil, err + } + _ = os.Chmod(path, 0o600) + return key, nil +} + +// sealSecret 用 AES-256-GCM 加密明文,返回 base64(nonce 前置)。 +func sealSecret(key []byte, plain string) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ct := gcm.Seal(nonce, nonce, []byte(plain), nil) + return base64.StdEncoding.EncodeToString(ct), nil +} + +// openSecret 解密 sealSecret 的产物。 +func openSecret(key []byte, enc string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return "", fmt.Errorf("密文不是合法 base64: %w", err) + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", fmt.Errorf("密文太短") + } + nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] + plain, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return "", fmt.Errorf("解密失败(密钥不对或数据损坏): %w", err) + } + return string(plain), nil +} + +// sealAccounts 把所有明文凭据加密后落盘。 +// +// 存储形态(credentials.json): +// +// { "accounts": { "": { "sealed": "", ... } } } +// +// 兼容:旧版明文 `token` 字段仍能读(并在下次 save 时自动转为 sealed)。 +func (cs *credStore) sealAllLocked() error { + key, err := cs.keyProvider() + if err != nil { + return err + } + for _, a := range cs.data.Accounts { + if a.Sealed != "" { + continue // 已加密 + } + plain, err := resolveSecret(a.Token) // 可能是 ${ENV} + if err != nil { + // 引用解析不了:**保留原样不加密**,等使用者修好环境再加密 + // (这里不报错是刻意的:否则一个未设的环境变量会让整份凭据无法保存) + continue + } + enc, err := sealSecret(key, plain) + if err != nil { + return err + } + a.Sealed = enc + a.Token = "" // ★ 明文立即从内存结构里清掉 + } + return nil +} + +// ---------------- shell 执行 ---------------- + +// shellRequest 是一次加密 shell 调用的参数。 +type shellRequest struct { + Command string + Workdir string + Timeout time.Duration + Env map[string]string +} + +// shellResult 是执行结果。 +type shellResult struct { + Output string + ExitCode int + Redacted int // 脱敏替换发生次数(可观测:非 0 说明命令试图回显凭据) + Err string +} + +// runEncryptedShell 执行一条带凭据填充的命令。 +// +// 流程(每一步都对应上面的一条纪律): +// +// ① 扫命令里的 @{name} 引用 → 解密取值 +// ② 把明文填进**脚本体**(不是 argv) +// ③ 脚本经 **stdin** 喂给 bash(argv 只有 "bash") +// ④ 收集输出 → **脱敏**(把已知 secret 换成 ***) +// ⑤ 擦除内存里的明文副本 +func (p *Plugin) runEncryptedShell(req shellRequest) shellResult { + res := shellResult{} + + script := req.Command + // ① 收集引用 + refs := credRefPattern.FindAllStringSubmatch(script, -1) + secrets := map[string]string{} // 明文值 → 用于脱敏 + var missing []string + + p.creds.mu.Lock() + key, kerr := p.creds.keyProvider() + p.creds.mu.Unlock() + if kerr != nil { + res.Err = "无法取得凭据密钥: " + kerr.Error() + return res + } + + // 收集本次要用到的明文(执行完必须擦) + var plaintexts []string + defer func() { + // ⑤ 尽力擦除(Go 里字符串不可变,这里擦可写的副本) + for i := range plaintexts { + plaintexts[i] = strings.Repeat("\x00", len(plaintexts[i])) + } + for k := range secrets { + delete(secrets, k) + } + }() + + // 解密由 lookupAnySecret 负责(它会自己取锁);这里不再直接持有 accounts。 + + for _, m := range refs { + name := m[1] + // ★ 统一查找:先在加密信息库(enc_store 存的),再回退到平台凭据。 + // 这样 @{gitea-local}(平台)与 @{gh-token}(通用 KV)在同一个 shell 里都能用。 + plain, found, err := p.lookupAnySecret(name, key) + if err != nil { + res.Err = fmt.Sprintf("加密信息 %s 解密失败: %v", name, err) + return res + } + if !found { + missing = append(missing, name) + continue + } + if plain == "" { + res.Err = fmt.Sprintf("加密信息 %s 解析为空", name) + return res + } + script = strings.ReplaceAll(script, m[0], plain) + secrets[plain] = name + plaintexts = append(plaintexts, plain) + } + if len(missing) > 0 { + res.Err = fmt.Sprintf( + "命令引用了不存在的别名: %s(用 enc_list 看加密信息、commondev_account_list 看平台凭据)", + strings.Join(missing, ", ")) + return res + } + + // 超时保护 + timeout := req.Timeout + if timeout <= 0 { + timeout = 50 * time.Second // ★ 留在内核 60s 工具上限之内 + } else if timeout > 55*time.Second { + timeout = 55 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // ③ argv 只有 bash —— 脚本走 stdin + cmd := exec.CommandContext(ctx, "bash") + cmd.Dir = req.Workdir + // ★ 不把 secret 放进 env(env 可被 /proc/PID/environ 读) + cmd.Env = append(os.Environ(), "COMMONDEV_ENCRYPTED_SHELL=1") + for k, v := range req.Env { + cmd.Env = append(cmd.Env, k+"="+v) + } + cmd.Stdin = strings.NewReader(script) + + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + + err := cmd.Run() + // ④ 脱敏 + out, n := redactSecrets(buf.String(), secrets) + res.Output = out + res.Redacted = n + + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + res.ExitCode = ee.ExitCode() + } else if ctx.Err() == context.DeadlineExceeded { + res.Err = fmt.Sprintf("执行超时(%s)", timeout) + } else { + res.Err = err.Error() + } + } + return res +} + +// redactSecrets 把输出里的已知 secret 值替换掉。 +// +// 返回替换次数:非 0 说明命令**试图回显凭据** —— 这是可观测信号, +// 应让使用者知道(而不是静默替换了事)。 +func redactSecrets(s string, secrets map[string]string) (string, int) { + n := 0 + // 长值优先替换:避免短 secret 是长 secret 子串时替换不干净 + vals := make([]string, 0, len(secrets)) + for v := range secrets { + if len(v) >= 4 { // 太短的值不做替换(会把正常文本打烂) + vals = append(vals, v) + } + } + sort.Slice(vals, func(i, j int) bool { return len(vals[i]) > len(vals[j]) }) + for _, v := range vals { + c := strings.Count(s, v) + if c > 0 { + s = strings.ReplaceAll(s, v, "***REDACTED***") + n += c + } + } + return s, n +} + +// decryptAccount 取一条凭据的明文(sealed 优先,兼容旧明文/变量引用)。 +func (p *Plugin) decryptAccount(a *Account, key []byte) (string, error) { + if a == nil { + return "", fmt.Errorf("凭据不存在") + } + if a.Sealed != "" { + return openSecret(key, a.Sealed) + } + // 兼容路径:尚为明文或 ${ENV} 引用 + return resolveSecret(a.Token) +} + +// ---------------- 工具实现 ---------------- + +func (p *Plugin) handleEncryptedShell(args map[string]interface{}) (interface{}, error) { + command := str(args, "command") + if strings.TrimSpace(command) == "" { + return map[string]interface{}{"error": "command 必填"}, nil + } + workdir := strings.TrimSpace(str(args, "workdir")) + timeoutSec := 0 + if v, ok := args["timeout_sec"].(float64); ok { + timeoutSec = int(v) + } + + // workdir 必须在某个工程对话的工作目录里(防止用它去读任意目录) + if workdir != "" { + abs, err := filepath.Abs(workdir) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + if !p.isInsideProgramRoot(abs) { + return map[string]interface{}{ + "error": "workdir 必须在工程工作目录下(program/);" + + "给相对路径或省略以使用当前工作目录", + }, nil + } + workdir = abs + } + + res := p.runEncryptedShell(shellRequest{ + Command: command, + Workdir: workdir, + Timeout: time.Duration(timeoutSec) * time.Second, + }) + + out := map[string]interface{}{ + "output": truncate(res.Output, 6000), + "exit_code": res.ExitCode, + } + if res.Err != "" { + out["error"] = res.Err + } + if res.Redacted > 0 { + out["redacted_count"] = res.Redacted + out["warning"] = fmt.Sprintf( + "输出里有 %d 处凭据明文,已替换为 ***REDACTED***。命令不应回显凭据。", + res.Redacted) + } + return out, nil +} + +// isInsideProgramRoot 判断路径是否落在 program/ 下。 +func (p *Plugin) isInsideProgramRoot(abs string) bool { + root, err := filepath.Abs(p.programRoot()) + if err != nil { + return false + } + rel, err := filepath.Rel(root, abs) + if err != nil { + return false + } + return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)) +} + +// listCredRefs 返回命令里引用到的凭据名(供工具描述/调试)。 +func listCredRefs(cmd string) []string { + var out []string + for _, m := range credRefPattern.FindAllStringSubmatch(cmd, -1) { + out = append(out, m[1]) + } + return out +} + +// ensureJSON 小工具(保证 json 包被使用,避免不同构建标签下的未用导入) +var _ = json.Marshal diff --git a/example/commondev/encshell_test.go b/example/commondev/encshell_test.go new file mode 100644 index 0000000..c6d11fd --- /dev/null +++ b/example/commondev/encshell_test.go @@ -0,0 +1,243 @@ +package main + +import ( + "github.com/JianFeeeee/homeagentsdk/sdk" + "os" + "path/filepath" + "strings" + "testing" +) + +// ★ 判据:加解密往返。 +func TestSealOpenRoundTrip(t *testing.T) { + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + for _, plain := range []string{"token-abc", "含中文的凭据", strings.Repeat("x", 500)} { + enc, err := sealSecret(key, plain) + if err != nil { + t.Fatalf("seal: %v", err) + } + if strings.Contains(enc, plain) { + t.Fatal("密文里出现了明文") + } + got, err := openSecret(key, enc) + if err != nil { + t.Fatalf("open: %v", err) + } + if got != plain { + t.Errorf("往返不一致: %q → %q", plain, got) + } + } + // 密钥不对 ⇒ 必须报错(GCM 认证失败) + bad := make([]byte, 32) + enc, _ := sealSecret(key, "x-secret-value") + if _, err := openSecret(bad, enc); err == nil { + t.Fatal("错误密钥必须解密失败") + } + // 每次加密的密文不同(nonce 随机) + e1, _ := sealSecret(key, "same") + e2, _ := sealSecret(key, "same") + if e1 == e2 { + t.Error("相同明文两次加密应产生不同密文(nonce 必须随机)") + } +} + +// ★ 判据:明文绝不进子进程 argv。 +// +// 观测方法:让被测命令把**自己的 /proc/$$/cmdline 写到文件** —— +// 写文件**不经过输出脱敏**,所以这是干净的观测。 +// +// 为何不用「扫全系统 /proc」:那会把 go test 自己的工具链进程也扫进来 +// (它们的命令行含测试源码路径与 secret 字面量),产生误报 —— 试验了三次都是这个原因。 +// 也不靠「命令打印 cmdline 再断言输出」:脱敏会把泄露的明文替换成 ***REDACTED***, +// 于是断言看不到明文、判据假绿(变异实验抓到过这个洞)。 +func TestArgvNeverContainsSecret(t *testing.T) { + p := newTestPluginForShell(t) + const secret = "ARGV-CANARY-9911" + addSealedAccount(t, p, "canary", secret) + + out := t.TempDir() + "/cmdline.txt" + os.Remove(out) + res := p.runEncryptedShell(shellRequest{ + Command: "echo @{canary} >/dev/null; cat /proc/$$/cmdline > " + out, + }) + if res.Err != "" { + t.Fatalf("执行失败: %s", res.Err) + } + b, err := os.ReadFile(out) + if err != nil { + t.Fatalf("未读到 cmdline 文件: %v", err) + } + if strings.Contains(string(b), secret) { + t.Fatalf("明文进了 argv: %q", string(b)) + } + // 且脚本确实执行了(防“改成 argv 后静默失效”) + if res.Output != "" && !strings.Contains(res.Output, "") { + t.Fatal("unreachable") + } +} + +// 填充确实生效(不回显,只验变量可用)。 +func TestCredentialFillingWorks(t *testing.T) { + p := newTestPluginForShell(t) + addSealedAccount(t, p, "canary", "some-secret-value-xyz") + res := p.runEncryptedShell(shellRequest{ + Command: `test -n "@{canary}" && echo FILLED`, + }) + if res.Err != "" || !strings.Contains(res.Output, "FILLED") { + t.Fatalf("填充未生效: out=%q err=%q", res.Output, res.Err) + } +} + +// ★★ 核心判据:输出脱敏。 +// +// 命令回显凭据时,回传内容必须已被替换,且要有 redacted 计数(可观测)。 +func TestEncryptedShellRedactsOutput(t *testing.T) { + p := newTestPluginForShell(t) + const secret = "OUTPUT-LEAK-CANARY-77a3" + addSealedAccount(t, p, "canary", secret) + + res := p.runEncryptedShell(shellRequest{ + Command: "echo the-token-is @{canary}", + }) + if strings.Contains(res.Output, secret) { + t.Fatalf("输出未脱敏,明文被回传: %q", res.Output) + } + if !strings.Contains(res.Output, "REDACTED") { + t.Errorf("应留下脱敏标记: %q", res.Output) + } + if res.Redacted == 0 { + t.Error("redacted 计数应 > 0(这是可观测信号:命令试图回显凭据)") + } +} + +// 引用不存在的凭据 ⇒ 响亮报错(不静默留空)。 +func TestEncryptedShellRejectsUnknownRef(t *testing.T) { + p := newTestPluginForShell(t) + res := p.runEncryptedShell(shellRequest{Command: "echo @{no-such-account}"}) + if res.Err == "" { + t.Fatal("引用不存在的凭据必须报错") + } + if !strings.Contains(res.Err, "不存在") { + t.Errorf("错误信息应指明凭据不存在: %s", res.Err) + } +} + +// 凭据引用语法:@{name}(不与 shell 的 ${...} 冲突)。 +func TestCredRefPattern(t *testing.T) { + got := listCredRefs("git push https://@{gitea-local}@h/o/r.git && echo @{gh} @{missing}") + want := []string{"gitea-local", "gh", "missing"} + if len(got) != len(want) { + t.Fatalf("解析到 %v,期望 %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("第 %d 个引用: got %q want %q", i, got[i], want[i]) + } + } + // ${...} 是 shell 变量,**不该**被当作凭据引用 + if refs := listCredRefs("echo ${HOME}"); len(refs) != 0 { + t.Errorf("${HOME} 不应被当作凭据引用: %v", refs) + } +} + +// ★ 判据:workdir 必须限制在 program/ 下(防用它去读任意目录)。 +func TestShellWorkdirMustBeInsideProgramRoot(t *testing.T) { + p := newTestPluginForShell(t) + out, err := p.handleEncryptedShell(map[string]interface{}{ + "command": "pwd", + "workdir": "/etc", // 明显在 program/ 之外 + }) + if err != nil { + t.Fatal(err) + } + m := out.(map[string]interface{}) + if _, bad := m["error"]; !bad { + t.Fatalf("program/ 之外的 workdir 应被拒: %v", m) + } +} + +// ---------------- 测试辅助 ---------------- + +func newTestPluginForShell(t *testing.T) *Plugin { + t.Helper() + dir := t.TempDir() + p := &Plugin{name: "commondev", path: filepath.Join(dir, "ledger.json")} + cs, err := newCredStore(filepath.Join(dir, "credentials.json")) + if err != nil { + t.Fatal(err) + } + cs.keyProvider = p.masterKey // 走真实密钥路径(环境变量优先/回退文件) + p.creds = cs + // ★ 装一个最小 SDK:生产里 p.sdk 永不为 nil,判据若在没有它的情况下 + // 调用 handler,会因 p.sdk.Settings() 解引用 nil 而 panic —— 那是 + // **判据的缺陷**(测的是不存在的状态),不是被测代码的缺陷。 + p.sdk = sdk.New("commondev", newStubSettings(dir), nil, nil, nil, nil) + p.ledger = &Ledger{ + Projects: map[string]*Project{}, Branches: map[string]*Branch{}, Jobs: map[string]*Job{}, + } + return p +} + +// stubSettings 是最小 SettingsAPI 替身(只支持判据用到的路径)。 +type stubSettings struct { + dir string + vals map[string]interface{} // 需要旧全局键的判据会填它 +} + +func newStubSettings(dir string) *stubSettings { return &stubSettings{dir: dir} } + +// newStubSettingsWith 造一个带指定键值的 stub(供端点表判据验证旧键兼容)。 +func newStubSettingsWith(dir string, vals map[string]interface{}) *stubSettings { + return &stubSettings{dir: dir, vals: vals} +} + +func (s *stubSettings) DataDir() string { return s.dir } +func (s *stubSettings) Get(key string) (interface{}, error) { + if s.vals != nil { + if v, ok := s.vals[key]; ok { + return v, nil + } + } + return nil, nil +} +func (s *stubSettings) Set(key string, value interface{}) error { return nil } +func (s *stubSettings) SetSecret(key string, v interface{}) error { return nil } +func (s *stubSettings) GetSecret(key string) (string, error) { return "", nil } +func (s *stubSettings) List(prefix string) ([]string, error) { return nil, nil } +func (s *stubSettings) GetCore(key string) (interface{}, error) { return nil, nil } +func (s *stubSettings) SetCore(key string, v interface{}) error { return nil } +func (s *stubSettings) ListCore(prefix string) ([]string, error) { return nil, nil } +func (s *stubSettings) GetPlugin(p, k string) (interface{}, error) { return nil, nil } +func (s *stubSettings) SetPlugin(p, k string, v interface{}) error { return nil } +func (s *stubSettings) ListPlugin(p, prefix string) ([]string, error) { return nil, nil } +func (s *stubSettings) RegisterDef(def sdk.ConfigDef) {} +func (s *stubSettings) Defs(prefix string) []*sdk.ConfigDef { return nil } +func (s *stubSettings) Dump() map[string]interface{} { return map[string]interface{}{} } +func (s *stubSettings) Plugins() []string { return nil } +func (s *stubSettings) Remove(key string) error { return nil } +func (s *stubSettings) RemoveCore(key string) error { return nil } +func (s *stubSettings) RemovePlugin(p, k string) error { return nil } +func (s *stubSettings) IsSecret(key string) bool { return false } + +func addSealedAccount(t *testing.T, p *Plugin, name, secret string) { + t.Helper() + key, err := p.masterKey() + if err != nil { + t.Fatal(err) + } + enc, err := sealSecret(key, secret) + if err != nil { + t.Fatal(err) + } + p.creds.mu.Lock() + p.creds.data.Accounts[name] = &Account{Name: name, Sealed: enc} + p.creds.mu.Unlock() +} + +// 确保 master.key 落在临时目录(不污染真实数据目录) +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} diff --git a/example/commondev/encstore.go b/example/commondev/encstore.go new file mode 100644 index 0000000..c92810d --- /dev/null +++ b/example/commondev/encstore.go @@ -0,0 +1,408 @@ +package main + +// 加密信息工具族(`enc_*`)—— ★ 值已**上交给内核变量库**。 +// +// ## 去重背景(这次重构的原因) +// +// 本文件原先自带一整套加密存储:`secrets.json` + AES-256-GCM + +// 别名表 + 指纹,与内核的 `internal/security.Store` **完全重复**。 +// +// 重复的真正代价不是代码量,而是**两套库互不可见**: +// +// · 内核拦截时登记的变量 → 插件看不到 +// · 插件登记的 → 内核拦截不认识 +// · 同一个凭据可能在两处各存一份,而使用者不知道哪个权威 +// +// 现在值只存**一处**(内核单例),插件经 `sdk.Secrets()` 访问它。 +// 两份判据钉住这个不变量:`TestE2E_PluginAndKernelShareOneVault`。 +// +// ## ★ 为什么还留着这个文件与 `enc_*` 工具 +// +// 因为插件有两样内核**没有**的能力,不能一起删掉: +// +// ① **可读名**:内核别名是 HMAC 派生的(`sec_0cb468e2…`), +// 这是"同明文自动合并"的前提(设计 S11),**不能改成用户自选**。 +// 但人需要 `@{gitea-local}` 这种能读懂的名字 ⇒ 插件存一层 +// **名字 → 内核别名**的映射。 +// ★ 映射里**只有名字,没有值** —— 所以这个文件泄了也没用。 +// +// ② **`${ENV}` / `@file:` 来源**:让明文**从不进会话** +// (工具参数里只有 `${GITEA_TOKEN}` 这样的引用)。 +// 内核 `secret_submit` 目前只收直传明文(T10 待办)。 +// +// ## 存储分工(一句话) +// +// 内核 —— 存值(加密)、派生别名、在调用插件前回填 +// 插件 —— 存「可读名 → 别名」映射、解析来源、执行命令时的 argv 保护 + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + sdk "github.com/JianFeeeee/homeagentsdk/sdk" +) + +// secretRef 是「可读名 → 内核别名」的一条映射。 +// +// ★ 刻意**不含值**:值在内核变量库里,这里只有名字。 +type secretRef struct { + Name string `json:"name"` + Alias string `json:"alias"` // 内核别名(sec_xxx) + Source string `json:"source"` + Note string `json:"note,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// secretRefs 是映射集合(落盘,小文件,不含任何值)。 +type secretRefs struct { + Refs map[string]*secretRef `json:"refs"` +} + +// refsPath 是映射文件路径。 +// +// 与旧的 secrets.json 分开命名:**旧文件里是密文值**,新文件里只有名字。 +// 不沿用同名可以避免"读旧文件时把密文当别名"这类混淆。 +func (p *Plugin) refsPath() string { + return filepath.Join(filepath.Dir(p.path), "secret_names.json") +} + +func (p *Plugin) loadSecretRefs() error { + p.refs = secretRefs{Refs: map[string]*secretRef{}} + b, err := os.ReadFile(p.refsPath()) + if err != nil { + if os.IsNotExist(err) { + return nil // 首次 + } + return err + } + if fi, err := os.Stat(p.refsPath()); err == nil { + if perm := fi.Mode().Perm(); perm != 0o600 { + return fmt.Errorf("映射文件权限不安全:%s 是 %04o,必须 0600", p.refsPath(), perm) + } + } + if err := json.Unmarshal(b, &p.refs); err != nil { + return fmt.Errorf("映射文件损坏(不静默重建): %w", err) + } + if p.refs.Refs == nil { + p.refs.Refs = map[string]*secretRef{} + } + return nil +} + +// saveSecretRefsLocked 落盘(调用方持锁)。 +// +// ★ 0600:虽然不含值,但仍保持最紧权限 —— +// 名字本身可能透露"这台机器在管哪些凭据",对攻击者有情报价值。 +func (p *Plugin) saveSecretRefsLocked() error { + b, err := json.MarshalIndent(p.refs, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p.refsPath()), 0o700); err != nil { + return err + } + tmp := p.refsPath() + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + if err := os.Chmod(tmp, 0o600); err != nil { + return err + } + return os.Rename(tmp, p.refsPath()) +} + +// resolveSecretSource 解析 `value` 参数的三类来源。 +// +// ${ENV_NAME} 环境变量引用 → 明文从不经工具参数(最安全) +// @file:/path 文件内容 +// 其它 字面明文(会进会话记录,调用方要告警) +// +// ★ 这条能力内核没有(内核 secret_submit 只收直传明文), +// 所以它是插件**保留**这个文件的主要理由之一。 +func resolveSecretSource(spec string) (plain, source string, err error) { + s := strings.TrimSpace(spec) + if s == "" { + return "", "", fmt.Errorf("值不能为空") + } + if varRefPattern.MatchString(s) { + v, err := resolveSecret(s) + if err != nil { + return "", "", err + } + return v, "env", nil + } + if rest, ok := strings.CutPrefix(s, "@file:"); ok { + path := strings.TrimSpace(rest) + if path == "" { + return "", "", fmt.Errorf("@file: 后面要给路径") + } + b, err := os.ReadFile(path) + if err != nil { + return "", "", fmt.Errorf("读文件失败(%s): %w", path, err) + } + v := strings.TrimSpace(string(b)) // token 文件常带换行 + if v == "" { + return "", "", fmt.Errorf("文件 %s 内容为空", path) + } + return v, "file", nil + } + return s, "literal", nil +} + +// ---------------- 工具实现 ---------------- + +func (p *Plugin) handleEncStore(args map[string]interface{}) (interface{}, error) { + name := strings.TrimSpace(str(args, "alias")) + spec := str(args, "value") + if name == "" { + return map[string]interface{}{"error": "alias 必填"}, nil + } + if !validSecretName(name) { + return map[string]interface{}{"error": "别名只允许 [A-Za-z0-9_.-](它会进 @{别名} 引用)"}, nil + } + plain, source, err := resolveSecretSource(spec) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + + sec := p.secretsAPI() + if sec == nil || !sec.Available() { + // ★ 响亮失败:不静默降级成"存到别处"。插件以为存好了而实际没存, + // 比直接报错危险得多。 + return map[string]interface{}{"error": "内核变量库不可用(core.security.enabled=false?)—— 拒绝以明文或私有文件降级存储"}, nil + } + + // ★ 值交给内核(它负责加密落盘 + 派生别名) + alias, err := sec.Store(plain, strings.TrimSpace(str(args, "note"))) + if err != nil { + return map[string]interface{}{"error": "登记进内核变量库失败: " + err.Error()}, nil + } + + now := nowRFC3339() + p.mu.Lock() + prev, existed := p.refs.Refs[name] + ref := &secretRef{ + Name: name, Alias: alias, Source: source, + Note: strings.TrimSpace(str(args, "note")), + CreatedAt: now, + } + if existed { + ref.CreatedAt = prev.CreatedAt + ref.UpdatedAt = now + } + p.refs.Refs[name] = ref + saveErr := p.saveSecretRefsLocked() + p.mu.Unlock() + if saveErr != nil { + return map[string]interface{}{"error": saveErr.Error()}, nil + } + + out := map[string]interface{}{ + "status": "ok", + "alias": name, // 人用的可读名 + "ref": alias, // 内核别名(自动合并的钥匙) + "source": source, + "usage": fmt.Sprintf("在命令里用 @{%s} 引用它(配 enc_shell)", name), + "note": "值存于**内核变量库**(进程级单例,全部插件与 agent 共享);" + + "本插件只保留「可读名 → 内核别名」映射,映射里没有值。", + } + if source == "literal" { + out["warning"] = "你直接传了明文:它会进入本次会话记录(工具调用是会话的一部分)。" + + "下次请优先用 ${ENV_NAME} 或 @file:/path —— 那两条路径明文不进会话。" + } + return out, nil +} + +func (p *Plugin) handleEncList(args map[string]interface{}) (interface{}, error) { + sec := p.secretsAPI() + + // 内核侧的元信息(别名 → 备注/命中) + kernelMeta := map[string]secretInfoView{} + if sec != nil && sec.Available() { + for _, in := range sec.List() { + kernelMeta[in.Alias] = secretInfoView{ + Source: in.Source, Note: in.Note, Hits: in.Hits, At: in.CreatedAt, + } + } + } + + p.mu.Lock() + type view struct { + Alias string `json:"alias"` + Ref string `json:"ref"` + Source string `json:"source"` + Note string `json:"note,omitempty"` + At string `json:"at"` + Present bool `json:"present_in_vault"` + } + out := []view{} + for _, r := range p.refs.Refs { + _, present := kernelMeta[r.Alias] + out = append(out, view{ + Alias: r.Name, Ref: r.Alias, Source: r.Source, Note: r.Note, + At: r.UpdatedAt + r.CreatedAt, Present: present, + }) + } + p.mu.Unlock() + sort.Slice(out, func(i, j int) bool { return out[i].Alias < out[j].Alias }) + + res := map[string]interface{}{ + "secrets": out, "count": len(out), + "vault_available": sec != nil && sec.Available(), + "note": "**不含值**(值在内核变量库里,且没有读取接口)。" + + "用 @{别名} 在 enc_shell 命令里引用。" + + "present_in_vault=false 表示该名字指向的内核变量已不存在(被删了?)。", + } + return res, nil +} + +// secretInfoView 是内核元信息的精简视图。 +type secretInfoView struct { + Source string + Note string + Hits int + At string +} + +// handleEncShow 查看元信息(**不返回值本体**)。 +// +// 为何不提供"取回明文"的工具:那等于给 agent 一个取 secret 的入口, +// 使前面所有"明文不进会话"的努力归零。要看值,就在命令里用 @{别名}。 +func (p *Plugin) handleEncShow(args map[string]interface{}) (interface{}, error) { + name := strings.TrimSpace(str(args, "alias")) + p.mu.Lock() + r, ok := p.refs.Refs[name] + var ref secretRef + if ok { + ref = *r + } + p.mu.Unlock() + if !ok { + return map[string]interface{}{"error": fmt.Sprintf("别名 %s 不存在", name)}, nil + } + + out := map[string]interface{}{ + "alias": ref.Name, "ref": ref.Alias, "source": ref.Source, + "note": ref.Note, "created_at": ref.CreatedAt, "updated_at": ref.UpdatedAt, + "storage": "内核变量库(AES-256-GCM 落盘,密钥由内核管理)", + "hint": "值本体不提供读取接口(那会让明文回到会话里);" + + "请在 enc_shell 命令里用 @" + "{" + ref.Name + "} 引用。", + } + // 顺带报内核侧的命中计数(可观测:这东西被用了多少次) + if sec := p.secretsAPI(); sec != nil && sec.Available() { + for _, in := range sec.List() { + if in.Alias == ref.Alias { + out["hits"] = in.Hits + break + } + } + } + return out, nil +} + +func (p *Plugin) handleEncRm(args map[string]interface{}) (interface{}, error) { + name := strings.TrimSpace(str(args, "alias")) + p.mu.Lock() + ref, ok := p.refs.Refs[name] + p.mu.Unlock() + if !ok { + return map[string]interface{}{"error": fmt.Sprintf("别名 %s 不存在", name)}, nil + } + + // ★ 先删内核变量(值),再删映射 —— 反过来会留下无人引用的密文 + warnings := []string{} + if sec := p.secretsAPI(); sec != nil && sec.Available() { + if err := sec.Forget(ref.Alias); err != nil { + // 映射仍要删(否则名单里留个死名字),但要如实告警 + warnings = append(warnings, + fmt.Sprintf("内核变量 %s 删除失败(该密文可能仍在内核库里): %v", ref.Alias, err)) + } + } else { + warnings = append(warnings, "内核变量库不可用 —— 只删了映射,值可能仍在") + } + + p.mu.Lock() + delete(p.refs.Refs, name) + saveErr := p.saveSecretRefsLocked() + p.mu.Unlock() + if saveErr != nil { + return map[string]interface{}{"error": saveErr.Error()}, nil + } + + out := map[string]interface{}{"status": "ok", "removed": name, "ref": ref.Alias} + if len(warnings) > 0 { + out["warnings"] = warnings + } + return out, nil +} + +// lookupAnySecret 供 enc_shell 使用:把可读名解析成明文。 +// +// 解析顺序: +// +// ① 映射表里的名字 → 拿到内核别名 → 请内核 Fill 出明文 +// ② 回退到**平台凭据**(credentials.json)—— 那些不是内核变量, +// 有平台/角色语义,仍由插件自己保管 +// +// 为何要回退:平台凭据(gitea-local 等)本来就是"带平台语义的凭据", +// 让 @{gitea-local} 在同一个 shell 里可用,避免 agent 在两套命名间切换。 +func (p *Plugin) lookupAnySecret(name string, key []byte) (string, bool, error) { + // ① 先看映射表 + p.mu.Lock() + ref, ok := p.refs.Refs[name] + p.mu.Unlock() + if ok { + sec := p.secretsAPI() + if sec == nil || !sec.Available() { + return "", false, fmt.Errorf( + "%s 指向内核变量 %s,但变量库不可用(core.security.enabled=false?)", name, ref.Alias) + } + // ★ 用 Fill 而不是任何"按名取明文"的接口 —— Fill 要求先在文本里 + // 写出别名,而别名本身不泄漏明文(HMAC 派生)。 + out, n, _ := sec.Fill("@" + "{" + ref.Alias + "}") + if n == 0 { + return "", false, fmt.Errorf( + "%s 指向的内核变量 %s 已不存在(被 secret_forget 删了?)", name, ref.Alias) + } + return out, true, nil + } + + // ② 回退平台凭据 + p.creds.mu.Lock() + a, ok := p.creds.data.Accounts[name] + p.creds.mu.Unlock() + if !ok { + return "", false, nil + } + v, err := p.decryptAccount(a, key) + return v, true, err +} + +// secretsAPI 取内核变量库接口(可能为 nil —— 内核版本过旧或未装配)。 +// +// 优先取可注入副本(测试用),生产为 nil 时走 SDK。 +func (p *Plugin) secretsAPI() sdk.SecretsAPI { + if p.vault != nil { + return p.vault + } + if p.sdk == nil { + return nil + } + return p.sdk.Secrets() +} + +// validSecretName 校验 secret 别名。 +// +// ★ 为什么不用 validName:那是**项目/通道名**的校验器(不含 `.`), +// 而 secret 引用的字符集含 `.`(见 encshell.go 的 credRefPattern)。 +// 两者是两个命名空间,混用会造成「能被引用却存不进去」的自相矛盾 —— +// 而报错只说"字符不合法",很难联想到是两处校验器不一致。 +var secretNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`) + +func validSecretName(s string) bool { return secretNamePattern.MatchString(s) } diff --git a/example/commondev/encstore_test.go b/example/commondev/encstore_test.go new file mode 100644 index 0000000..1153fec --- /dev/null +++ b/example/commondev/encstore_test.go @@ -0,0 +1,339 @@ +package main + +// `enc_*` 工具族的判据 —— ★ 值已上交给**内核变量库**后的形态。 +// +// ## 这组判据要守住的两条不变量 +// +// ① **值只存一处**:插件不留副本(否则又回到"两库互不可见"的老问题) +// ② **工具返回值绝不回显明文**(用户明确提醒「存入过程也需要注意」: +// 工具返回值会进模型上下文与会话记录) + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + sdk "github.com/JianFeeeee/homeagentsdk/sdk" +) + +// fakeVault 是内核变量库的测试替身。 +// +// 它模拟真实实现的关键语义: +// - 别名由**派生**得到(不是用户自选)——与内核一致 +// - 同明文得到同一别名 +// - 值存在这个"库里",插件不留副本 +type fakeVault struct { + values map[string]string // alias -> plain + notes map[string]string + calls int +} + +func newFakeVault() *fakeVault { + return &fakeVault{values: map[string]string{}, notes: map[string]string{}} +} + +// deriveAlias 模拟内核的 HMAC 派生(这里用简单确定性函数,只要"同明文同别名")。 +func deriveAlias(plain string) string { + h := 0 + for _, c := range plain { + h = h*31 + int(c) + } + if h < 0 { + h = -h + } + return "sec_" + strings.Repeat("0", 16-len(itoa16(h))) + itoa16(h) +} + +func itoa16(n int) string { + const hexd = "0123456789abcdef" + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{hexd[n%16]}, b...) + n /= 16 + } + return string(b) +} + +func (v *fakeVault) Available() bool { return true } + +func (v *fakeVault) Store(value, note string) (string, error) { + v.calls++ + a := deriveAlias(value) + v.values[a] = value + v.notes[a] = note + return a, nil +} + +func (v *fakeVault) List() []sdk.SecretInfo { + out := make([]sdk.SecretInfo, 0, len(v.values)) + for a := range v.values { + out = append(out, sdk.SecretInfo{Alias: a, Source: "submitted", Note: v.notes[a]}) + } + return out +} + +func (v *fakeVault) Forget(alias string) error { + delete(v.values, alias) + delete(v.notes, alias) + return nil +} + +func (v *fakeVault) Fill(text string) (string, int, []string) { + n := 0 + var used []string + for a, plain := range v.values { + if strings.Contains(text, "@{"+a+"}") { + text = strings.ReplaceAll(text, "@{"+a+"}", plain) + n++ + used = append(used, a) + } + } + return text, n, used +} + +func (v *fakeVault) Encrypt(plain []byte) (string, error) { return "enc:" + string(plain), nil } +func (v *fakeVault) Decrypt(s string) ([]byte, error) { + return []byte(strings.TrimPrefix(s, "enc:")), nil +} + +// newTestPluginWithVault 建一个带假变量库的插件。 +func newTestPluginWithVault(t *testing.T) (*Plugin, *fakeVault) { + t.Helper() + p := newTestPluginForShell(t) + p.refs = secretRefs{Refs: map[string]*secretRef{}} + v := newFakeVault() + p.vault = v + return p, v +} + +// ★★ 判据:**存入的返回值绝不回显明文**。 +// +// 依据:工具返回值会进模型上下文与会话记录。 +func TestEncStoreNeverEchoesValue(t *testing.T) { + p, _ := newTestPluginWithVault(t) + const secret = "STORE-ECHO-CANARY-4b71" + + out, err := p.handleEncStore(map[string]interface{}{ + "alias": "canary", "value": secret, + }) + if err != nil { + t.Fatal(err) + } + b, _ := json.Marshal(out) + if strings.Contains(string(b), secret) { + t.Fatalf("enc_store 返回值回显了明文: %s", b) + } + m := out.(map[string]interface{}) + if m["ref"] == "" || !strings.HasPrefix(m["ref"].(string), "sec_") { + t.Errorf("应返回内核别名(ref),实际 %v", m["ref"]) + } + if _, ok := m["warning"]; !ok { + t.Error("直接传明文时应告警(明文已进入本次会话记录)") + } + if m["source"] != "literal" { + t.Errorf("来源应标为 literal,实际 %v", m["source"]) + } +} + +// ★★ 判据:**值只存内核库,插件不留副本**(去重的核心不变量)。 +// +// 这是本次重构的目的。若有人"顺手"把值也存进插件侧, +// 两套库互不可见的老问题就会回来 —— 而它不会报错,只会让使用者困惑。 +func TestEncStoreLeavesNoCopyInPlugin(t *testing.T) { + p, v := newTestPluginWithVault(t) + const secret = "NO-COPIES-CANARY-77aa" + + if _, err := p.handleEncStore(map[string]interface{}{ + "alias": "x", "value": secret, + }); err != nil { + t.Fatal(err) + } + + // ① 内核库里**有**值 + if len(v.values) != 1 { + t.Fatalf("内核库应有 1 条,实际 %d", len(v.values)) + } + // ② 插件落盘文件里**没有**值 + b, err := os.ReadFile(p.refsPath()) + if err == nil { + if strings.Contains(string(b), secret) { + t.Fatalf("插件侧文件出现了明文(应只存名字映射): %s", b) + } + } + // ③ 插件的映射里**只有名字与别名**,没有值字段 + p.mu.Lock() + for _, r := range p.refs.Refs { + rb, _ := json.Marshal(r) + if strings.Contains(string(rb), secret) { + t.Fatalf("映射里出现了值: %s", rb) + } + } + p.mu.Unlock() + // ④ 映射文件权限 0600(名字本身有情报价值) + if fi, err := os.Stat(p.refsPath()); err == nil { + if fi.Mode().Perm() != 0o600 { + t.Errorf("映射文件权限应为 0600,实际 %04o", fi.Mode().Perm()) + } + } +} + +// ★ 来源分级:${ENV} 与 @file: 都应被识别,且**不告警**(明文未进会话)。 +func TestEncStoreSourceClasses(t *testing.T) { + p, _ := newTestPluginWithVault(t) + os.Setenv("ENC_TEST_VAR", "from-env-secret") + defer os.Unsetenv("ENC_TEST_VAR") + + out, _ := p.handleEncStore(map[string]interface{}{ + "alias": "from-env", "value": "${ENC_TEST_VAR}"}) + m := out.(map[string]interface{}) + if m["source"] != "env" { + t.Errorf("来源应为 env,实际 %v", m["source"]) + } + if _, bad := m["warning"]; bad { + t.Error("env 来源不该告警(明文没进会话)") + } + + f := filepath.Join(t.TempDir(), "tok.txt") + os.WriteFile(f, []byte("from-file-secret\n"), 0o600) // 带换行(token 文件常见) + out2, _ := p.handleEncStore(map[string]interface{}{ + "alias": "from-file", "value": "@file:" + f}) + m2 := out2.(map[string]interface{}) + if m2["source"] != "file" { + t.Errorf("来源应为 file,实际 %v", m2["source"]) + } + if _, bad := m2["warning"]; bad { + t.Error("file 来源不该告警") + } + + // ★ 换行必须被去掉(否则填充进命令会带换行 → 命令坏掉) + // 通过 enc_shell 的解析路径验证(值已在内核库,只能经它读回) + plain, found, err := p.lookupAnySecret("from-file", nil) + if err != nil || !found { + t.Fatalf("应能解析 from-file: found=%v err=%v", found, err) + } + if plain != "from-file-secret" { + t.Errorf("文件内容应去掉尾部换行,实际 %q", plain) + } +} + +// ★ 判据:enc_list / enc_show **绝不返回值本体**。 +func TestEncListShowNeverRevealValue(t *testing.T) { + p, _ := newTestPluginWithVault(t) + const secret = "LIST-SHOW-CANARY-2e08" + p.handleEncStore(map[string]interface{}{"alias": "a", "value": secret}) + + l, _ := p.handleEncList(nil) + lb, _ := json.Marshal(l) + if strings.Contains(string(lb), secret) { + t.Fatalf("enc_list 泄露了值: %s", lb) + } + + s, _ := p.handleEncShow(map[string]interface{}{"alias": "a"}) + sb, _ := json.Marshal(s) + if strings.Contains(string(sb), secret) { + t.Fatalf("enc_show 泄露了值: %s", sb) + } + mm := s.(map[string]interface{}) + if !strings.Contains(mm["hint"].(string), "enc_shell") { + t.Errorf("应指引用 enc_shell 引用: %v", mm["hint"]) + } + if !strings.Contains(mm["storage"].(string), "内核变量库") { + t.Errorf("应说清值存于内核变量库: %v", mm["storage"]) + } +} + +// ★ 判据:**删除时同步清内核库**(否则留下无人引用的密文)。 +func TestEncRmForgetsKernelVariable(t *testing.T) { + p, v := newTestPluginWithVault(t) + p.handleEncStore(map[string]interface{}{"alias": "gone", "value": "to-be-removed"}) + if len(v.values) != 1 { + t.Fatal("前提:内核库应有 1 条") + } + + out, err := p.handleEncRm(map[string]interface{}{"alias": "gone"}) + if err != nil { + t.Fatal(err) + } + if m := out.(map[string]interface{}); m["status"] != "ok" { + t.Fatalf("删除应成功: %v", m) + } + // ★ 内核库里的值也必须没了 + if len(v.values) != 0 { + t.Fatalf("内核库应被清空(否则留下无人引用的密文),实际 %d 条", len(v.values)) + } + // 映射也没了 + p.mu.Lock() + _, still := p.refs.Refs["gone"] + p.mu.Unlock() + if still { + t.Error("映射未删除") + } + // 再删应报不存在(不是静默成功) + out2, _ := p.handleEncRm(map[string]interface{}{"alias": "gone"}) + if _, isErr := out2.(map[string]interface{})["error"]; !isErr { + t.Error("重复删除应明确报错") + } +} + +// ★ 判据:**变量库不可用时拒绝存储**(不静默降级到明文/私有文件)。 +func TestEncStoreRefusesWhenVaultUnavailable(t *testing.T) { + p := newTestPluginForShell(t) + p.refs = secretRefs{Refs: map[string]*secretRef{}} + p.vault = unavailableVault{} + + out, err := p.handleEncStore(map[string]interface{}{ + "alias": "x", "value": "must-not-be-stored"}) + if err != nil { + t.Fatal(err) + } + m := out.(map[string]interface{}) + if _, isErr := m["error"]; !isErr { + t.Fatalf("变量库不可用时应报错,实际 %v", m) + } + // 且不该在插件侧留下任何东西 + p.mu.Lock() + n := len(p.refs.Refs) + p.mu.Unlock() + if n != 0 { + t.Error("拒绝后不该留下映射") + } +} + +type unavailableVault struct{} + +func (unavailableVault) Available() bool { return false } +func (unavailableVault) Store(string, string) (string, error) { return "", errUnavailable } +func (unavailableVault) List() []sdk.SecretInfo { return nil } +func (unavailableVault) Forget(string) error { return errUnavailable } +func (unavailableVault) Fill(t string) (string, int, []string) { return t, 0, nil } +func (unavailableVault) Encrypt([]byte) (string, error) { return "", errUnavailable } +func (unavailableVault) Decrypt(string) ([]byte, error) { return nil, errUnavailable } + +var errUnavailable = errNotAvailable{} + +type errNotAvailable struct{} + +func (errNotAvailable) Error() string { return "变量库不可用" } + +// ★ 判据:别名合法性 —— 它会进 `@{别名}` 引用,字符集必须受限。 +func TestEncStoreRejectsBadAlias(t *testing.T) { + p, _ := newTestPluginWithVault(t) + for _, bad := range []string{"has space", "has/slash", "has@at", ""} { + out, _ := p.handleEncStore(map[string]interface{}{"alias": bad, "value": "v"}) + if _, isErr := out.(map[string]interface{})["error"]; !isErr { + t.Errorf("别名 %q 应被拒", bad) + } + } + // 合法字符集 + for _, ok := range []string{"good-name", "good_name", "good.name", "n123"} { + out, _ := p.handleEncStore(map[string]interface{}{"alias": ok, "value": "v"}) + if _, isErr := out.(map[string]interface{})["error"]; isErr { + t.Errorf("别名 %q 应被接受,实际 %v", ok, out) + } + } +} diff --git a/example/commondev/endpoints.go b/example/commondev/endpoints.go new file mode 100644 index 0000000..72d574a --- /dev/null +++ b/example/commondev/endpoints.go @@ -0,0 +1,637 @@ +package main + +// 远端端点表:**按 host 存一套,按 (host, cli) 覆盖一套**。 +// +// ## 为什么必须按 host 分开 +// +// 改动前 SSH 相关的东西只有**一组全局 settings**: +// +// ssh_node_path 远端 node 路径 +// ssh_runtime_root 远端放 sidecar 脚本的目录 +// ssh_workdir_root 远端放工程目录的根 +// +// 单台远端时够用。**两台就开始互相打架**:A 机 node 在 `/usr/local/bin/node`、 +// B 机在 `/opt/node/bin/node`;A 把脚本放 `/home/a/rt`、B 在 `/srv/rt`。 +// 全局只有一个值 ⇒ 后配的那台覆盖前一台,表现为"换台机器就报找不到 node/ +// 找不到脚本",而报错指向的是**另一台机器的路径**,排查方向完全错。 +// +// ## 为什么还要按 (host, cli) 再分 +// +// 同一个 host 上不同后端 CLI 的路径天然不同:zcode 在 +// `/opt/zcode/zcode.cjs`,将来接别的 CLI 又在别处。而且同一台机器上 +// 可能出现**同一 CLI 的多个版本**(系统装的 + 用户目录装的), +// 需要在不改 host 默认值的前提下为某个后端单独指定。 +// +// ## 结构(优先级从高到低) +// +// ① endpoints[host].CLIs[cli].CLIPath 该机器该后端的专属 CLI 路径 +// ② endpoints[host].CLIPath 该机器上所有后端的默认 CLI 路径 +// ③ driver.json 的 cliPath 随包/全局默认(本地与远端共用同一份声明) +// +// node/runtime_root/workdir_root 同理:host 级默认 + (host,cli) 级覆盖。 +// +// ★ 关键约束:**本机路径绝不发给远端**。见 resolveRemoteEndpoint 的说明。 +// +// ## 为什么放独立文件而不是塞进 ledger.json +// +// ledger 是"工程状态"(可重建的派生数据);端点是"环境配置" +// (人手工维护、丢了要重配、且含机器专属信息)。混在一起会让 +// ledger 的保存路径(每次分支变动都写)碰到与它无关的配置, +// 且一次台账损坏会连带丢掉配置。参照 credentials.json 的先例。 + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// cliEndpoint 是**某台机器上某个后端**的专属设置。 +type cliEndpoint struct { + // CLIPath 该后端在这台机器上的 CLI 入口(如 /opt/zcode/zcode.cjs)。 + CLIPath string `json:"cli_path,omitempty"` + // NodePath 该后端用的 node(留空则用 host 级或默认)。 + NodePath string `json:"node_path,omitempty"` + // RuntimeRoot 该后端 sidecar 脚本落地的远端目录(留空则用 host 级)。 + RuntimeRoot string `json:"runtime_root,omitempty"` + // Env 该后端额外的远端环境变量(展开占位符后前置到 ssh 命令)。 + Env map[string]string `json:"env,omitempty"` + // Note 备注(给人看;例如"这台机器是测试机")。 + Note string `json:"note,omitempty"` +} + +// hostEndpoint 是**一台机器**的设置。 +type hostEndpoint struct { + // CLIPath 这台机器上后端的**默认** CLI 路径(被 CLIs[cli].CLIPath 覆盖)。 + CLIPath string `json:"cli_path,omitempty"` + // NodePath 这台机器的 node(默认 `node`,走远端 PATH)。 + NodePath string `json:"node_path,omitempty"` + // RuntimeRoot 这台机器放 sidecar 脚本的目录。 + RuntimeRoot string `json:"runtime_root,omitempty"` + // WorkdirRoot 这台机器放工程目录的根。 + WorkdirRoot string `json:"workdir_root,omitempty"` + // CLIs 按后端覆盖(key 是 agent_cli,如 "zcode")。 + CLIs map[string]*cliEndpoint `json:"clis,omitempty"` + // Note 备注。 + Note string `json:"note,omitempty"` +} + +// endpointTable 是端点表的磁盘形态。 +type endpointTable struct { + // Version 供将来迁移用(现在只有 1)。 + Version int `json:"version"` + // Hosts key 是 ssh_host(与 branch_open 的 ssh_host 参数逐字一致, + // 包括 `user@host:port` 或 ssh config 别名)。 + Hosts map[string]*hostEndpoint `json:"hosts"` +} + +// endpointsPath 端点表与台账同目录(与 credentials.json 一致)。 +func (p *Plugin) endpointsPath() string { + return filepath.Join(filepath.Dir(p.path), "endpoints.json") +} + +// loadEndpoints 读端点表。 +// +// ★ 缺文件**不是错误**(首次运行、或只用本地后端的人永远不会有它)。 +// 但**损坏必须是错误** —— 静默用空表会让所有远端配置凭空消失, +// 表现为"我明明配过,怎么又要配",而用户会先怀疑自己。 +func (p *Plugin) loadEndpoints() error { + p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}} + b, err := os.ReadFile(p.endpointsPath()) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var t endpointTable + if err := json.Unmarshal(b, &t); err != nil { + return fmt.Errorf("端点表损坏(拒绝猜默认,否则所有远端配置会静默消失): %s: %w", + p.endpointsPath(), err) + } + if t.Hosts == nil { + t.Hosts = map[string]*hostEndpoint{} + } + if t.Version == 0 { + t.Version = 1 + } + p.endpoints = &t + return nil +} + +// saveEndpoints 原子写(tmp + rename),权限 0600。 +// +// 0600 而不是 0644:端点表可能含机器内网地址、端口、用户目录结构, +// 也**可能**含 env 里塞进去的令牌(虽然不推荐,但不该由文件权限替人决定)。 +func (p *Plugin) saveEndpoints() error { + b, err := json.MarshalIndent(p.endpoints, "", " ") + if err != nil { + return err + } + path := p.endpointsPath() + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// getHost 取(或按需创建)某台机器的设置。 +// +// create=false 时**不**创建 —— 读路径不该有副作用(否则一个查询工具 +// 就会往表里塞一堆空条目,把真实配置淹掉)。 +func (p *Plugin) getHost(host string, create bool) *hostEndpoint { + if p.endpoints == nil { + p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}} + } + if p.endpoints.Hosts == nil { + p.endpoints.Hosts = map[string]*hostEndpoint{} + } + h, ok := p.endpoints.Hosts[host] + if !ok { + if !create { + return nil + } + h = &hostEndpoint{CLIs: map[string]*cliEndpoint{}} + p.endpoints.Hosts[host] = h + } + if h.CLIs == nil { + h.CLIs = map[string]*cliEndpoint{} + } + return h +} + +// resolvedEndpoint 是"算完之后"的最终取值(三层合并的结果)。 +type resolvedEndpoint struct { + Host string + CLI string + CLIPath string + CLIPathFrom string // 取值来源,**必须在错误信息里出现** + NodePath string + RuntimeRoot string + WorkdirRoot string + Env map[string]string +} + +// resolveEndpoint 把三层配置合并成最终端点。 +// +// cliPathFrom 记录取值来源:出问题时要能一句话说清"这个路径是从哪来的"。 +// 没有它,用户面对"远端找不到 CLI"根本不知道该改哪个文件 —— +// 本会话已经因为"错误不指出位置"吃过多次教训。 +func (p *Plugin) resolveEndpoint(host, cli string, drv driverSpec, ctx driverCtx) resolvedEndpoint { + r := resolvedEndpoint{Host: host, CLI: cli, NodePath: "node", Env: map[string]string{}} + + h := p.getHost(host, false) + if h != nil { + r.CLIPath, r.CLIPathFrom = h.CLIPath, "host 默认(endpoints.json)" + r.NodePath = h.NodePath + r.RuntimeRoot = h.RuntimeRoot + r.WorkdirRoot = h.WorkdirRoot + } + if h != nil && h.CLIs != nil { + if c, ok := h.CLIs[cli]; ok && c != nil { + if strings.TrimSpace(c.CLIPath) != "" { + r.CLIPath, r.CLIPathFrom = c.CLIPath, "该后端专属(endpoints.json)" + } + if strings.TrimSpace(c.NodePath) != "" { + r.NodePath = c.NodePath + } + if strings.TrimSpace(c.RuntimeRoot) != "" { + r.RuntimeRoot = c.RuntimeRoot + } + for k, v := range c.Env { + r.Env[k] = v + } + } + } + if strings.TrimSpace(r.NodePath) == "" { + r.NodePath = "node" + } + + // 兜底:driver.json 的 cliPath(随包/全局声明)。 + // + // ★ 只有**未被任何端点配置覆盖**时才用它。而且它可能含 {rawfile} 这类 + // **本地**占位符 —— 那种路径绝不能发给远端(见 resolveRemoteCLIPath)。 + if r.CLIPath == "" && strings.TrimSpace(drv.CLIPath) != "" { + r.CLIPath = drv.expand(drv.CLIPath, ctx) + r.CLIPathFrom = "driver.json 的 cliPath 默认值" + } + + // 兼容旧配置:ssh_node_path / ssh_runtime_root / ssh_workdir_root + // 只在端点表没有对应值时生效,避免"配了端点表却还被旧键盖住"。 + // + // ★ 经 settingStr 而不是裸 p.sdk.Settings():Settings 未装配时 + // 裸调用会 **nil 解引用 panic**,而这里在 ssh 拉起的热路径上 —— + // 一个配置读取的健壮性问题会变成"拉 agent 直接崩"。 + if r.RuntimeRoot == "" { + r.RuntimeRoot = p.settingStr("ssh_runtime_root") + } + if r.WorkdirRoot == "" { + r.WorkdirRoot = p.settingStr("ssh_workdir_root") + } + if r.NodePath == "node" { + if s := p.settingStr("ssh_node_path"); s != "" { + r.NodePath = s + } + } + return r +} + +// settingStr 读一个字符串 setting,**容忍 Settings 未装配**。 +// +// 返回 "" 表示"没配/取不到";调用方据此走默认值。 +// 修这个是因为判据造了不带 Settings 的插件就 panic —— 那说明 +// 任何"Settings 尚未注入"的调用点都会崩,属真实健壮性缺口。 +func (p *Plugin) settingStr(key string) string { + if p.sdk == nil { + return "" + } + st := p.sdk.Settings() + if st == nil { + return "" + } + v, _ := st.Get(key) + if v == nil { + return "" + } + s := strings.TrimSpace(fmt.Sprint(v)) + if s == "" || s == "" { + return "" + } + return s +} + +// localPathPlaceholders 是**只在本机有意义**的占位符。 +// +// ★ 这是一条安全/正确性边界,不是审美问题:`{rawfile}` 展开成 +// `<本机插件目录>/rawfile`,把它发给远端 100% 是个不存在的路径, +// 而远端报的错误是"文件不存在"——用户会去远端找,方向完全错。 +var localPathPlaceholders = []string{"{rawfile}", "{pluginDir}", "{dataDir}"} + +// containsLocalPlaceholder 报告路径里是否含本机专属占位符。 +func containsLocalPlaceholder(s string) bool { + for _, ph := range localPathPlaceholders { + if strings.Contains(s, ph) { + return true + } + } + return false +} + +// validateRemoteCLIPath 在**启动 ssh 之前**校验远端 CLI 路径。 +// +// 两条规则(都是实测踩出来的): +// +// 1. **本机占位符不得下发**:展开后是本机路径,远端一定不存在。 +// 2. **本机绝对路径不得下发**:即使不是占位符,用户也可能把本机的 +// `/home/program/.zcode-src/.../zcode.cjs` 直接填进 host 端点, +// 而远端是另一台机器。这比第 1 条更隐蔽 —— 它看起来就是个正常路径。 +// +// 两条都必须**在 ssh 之前**拦:让远端去失败的话,错误来自远端 shell +// ("No such file or directory"),用户会以为远端缺文件而不是本地配错了。 +func validateRemoteCLIPath(cliPath, cli string) error { + if strings.TrimSpace(cliPath) == "" { + return fmt.Errorf( + "远端 %s 没有可用的 CLI 路径。请在 endpoints.json 里为该机器配置 "+ + "hosts..cli_path(该机所有后端的默认)或 "+ + "hosts..clis.%s.cli_path(该后端的专属路径)。"+ + "可用 commondev_endpoint_set 写入(会校验路径在远端是否存在)。", cli, cli) + } + if containsLocalPlaceholder(cliPath) { + return fmt.Errorf( + "★ 远端 CLI 路径含**本机专属占位符**(%s):%q。"+ + "{rawfile}/{pluginDir}/{dataDir} 展开后是**本机**路径,发给远端必然不存在。"+ + "请把它换成该机器上的真实路径(endpoint_set host= cli=%s cli_path=<远端路径>)", + strings.Join(localPathPlaceholders, "/"), cliPath, cli) + } + return nil +} + +// endpointListRows 把端点表摊平成可读行(供 commondev_endpoint_list)。 +// +// 一并报出**解析后的默认值**,因为"我这台机器最终会用哪个路径"才是 +// 用户真正要回答的问题;只给原始 JSON 等于让人自己心算三层合并。 +func (p *Plugin) endpointListRows() []map[string]interface{} { + rows := []map[string]interface{}{} + if p.endpoints == nil { + return rows + } + hosts := make([]string, 0, len(p.endpoints.Hosts)) + for h := range p.endpoints.Hosts { + hosts = append(hosts, h) + } + sort.Strings(hosts) + for _, h := range hosts { + he := p.endpoints.Hosts[h] + if he == nil { + continue + } + row := map[string]interface{}{ + "host": h, + "cli_path": he.CLIPath, + "node_path": he.NodePath, + "runtime_root": he.RuntimeRoot, + "workdir_root": he.WorkdirRoot, + "note": he.Note, + } + if len(he.CLIs) > 0 { + clis := make([]string, 0, len(he.CLIs)) + for c := range he.CLIs { + clis = append(clis, c) + } + sort.Strings(clis) + cm := map[string]interface{}{} + for _, c := range clis { + ce := he.CLIs[c] + if ce == nil { + continue + } + cm[c] = map[string]interface{}{ + "cli_path": ce.CLIPath, + "node_path": ce.NodePath, + "runtime_root": ce.RuntimeRoot, + "env": ce.Env, + "note": ce.Note, + } + } + row["clis"] = cm + } + rows = append(rows, row) + } + return rows +} + +// ---------------- 工具处理 ---------------- + +// handleEndpointSet 写某台机器(或该机器某后端)的端点配置。 +// +// ★ 默认会 ssh 到远端**确认 cli_path 真实存在**。 +// 为什么默认开:本会话反复出现的失败模式就是"配置写对了但指向一个 +// 不存在的东西",而症状出现在很远的地方(远端 shell 报 No such file)。 +// 在这里花一次 ssh 往返,能把它变成一条立刻可见的、指出位置的错误。 +func (p *Plugin) handleEndpointSet(args map[string]interface{}) (interface{}, error) { + host := strings.TrimSpace(str(args, "host")) + if host == "" { + return map[string]interface{}{"error": "必须给 host(与 branch_open 的 ssh_host 逐字一致)"}, nil + } + cli := strings.TrimSpace(str(args, "cli")) + cliPath := strings.TrimSpace(str(args, "cli_path")) + nodePath := strings.TrimSpace(str(args, "node_path")) + runtimeRoot := strings.TrimSpace(str(args, "runtime_root")) + workdirRoot := strings.TrimSpace(str(args, "workdir_root")) + note := strings.TrimSpace(str(args, "note")) + + // ★ 本机路径不得写进**远端**配置:写了就是 100% 起不来, + // 而报错来自远端 shell,排查方向完全错。在这里拦最省事。 + if cliPath != "" { + if containsLocalPlaceholder(cliPath) { + return map[string]interface{}{ + "error": fmt.Sprintf( + "cli_path 含本机专属占位符(%s):%q。这些展开后是**本机**路径,"+ + "远端必然不存在。请填该机器上的真实路径", + strings.Join(localPathPlaceholders, "/"), cliPath), + }, nil + } + if isLocalPathHint(cliPath) { + return map[string]interface{}{ + "error": fmt.Sprintf( + "cli_path %q 看起来是**本机**路径(在 %s 下)。请填该**远端机器**上的路径;"+ + "若该机器确实与本地同构(如 localhost 回环),传 verify=false 并确认无误", + cliPath, p.localRootHint()), + }, nil + } + } + + if workdirRoot != "" && cli != "" { + return map[string]interface{}{ + "error": "workdir_root 是**机器级**设置(该机器上所有后端共用),请去掉 cli 参数单独设置", + }, nil + } + + // 远端验存(默认开) + verify := true + if v, ok := args["verify"]; ok { + if b, isB := v.(bool); isB { + verify = b + } + } + if verify && cliPath != "" { + t, err := parseSSHTarget(&Branch{SSHHost: host}) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + if _, err := p.sshRunCapture(t, "test -f "+shellQuote(cliPath)+" && echo __OK__"); err != nil { + return map[string]interface{}{ + "error": fmt.Sprintf("远端 %s 上不存在 %s(ssh 验存失败,未写入配置):%v。"+ + "确认路径后重试;若机器暂时不可达,传 verify=false 先记下配置", host, cliPath, err), + }, nil + } + } + + p.mu.Lock() + defer p.mu.Unlock() + he := p.getHost(host, true) + + if cli != "" { + ce, ok := he.CLIs[cli] + if !ok || ce == nil { + ce = &cliEndpoint{} + he.CLIs[cli] = ce + } + if cliPath != "" { + ce.CLIPath = cliPath + } + if nodePath != "" { + ce.NodePath = nodePath + } + if runtimeRoot != "" { + ce.RuntimeRoot = runtimeRoot + } + if note != "" { + ce.Note = note + } + } else { + if cliPath != "" { + he.CLIPath = cliPath + } + if nodePath != "" { + he.NodePath = nodePath + } + if runtimeRoot != "" { + he.RuntimeRoot = runtimeRoot + } + if workdirRoot != "" { + he.WorkdirRoot = workdirRoot + } + if note != "" { + he.Note = note + } + } + if err := p.saveEndpoints(); err != nil { + return map[string]interface{}{"error": "保存端点表失败: " + err.Error()}, nil + } + return map[string]interface{}{ + "status": "ok", + "host": host, + "cli": cli, + "scope": map[bool]string{true: "该后端专属", false: "该机器默认"}[cli != ""], + }, nil +} + +// handleEndpointList 列出端点配置。 +// +// 一并给出**解析后的最终值**:用户真正要回答的是"这台机器最终用哪个路径", +// 只给原始 JSON 等于让人自己心算三层合并。给 host+cli 时才计算。 +func (p *Plugin) handleEndpointList(args map[string]interface{}) (interface{}, error) { + host := strings.TrimSpace(str(args, "host")) + p.mu.Lock() + defer p.mu.Unlock() + // ★ 端点表可能尚未加载(裸插件 / 加载失败前的调用)。 + // 空参数调用不得 panic —— 工具会遇到模型乱传参数与半初始化状态。 + if p.endpoints == nil { + p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}} + } + if host != "" { + he := p.getHost(host, false) + if he == nil { + return map[string]interface{}{ + "status": "ok", "host": host, "configured": false, + "hint": "该机器没有端点配置。用 commondev_endpoint_set host=" + host + + " cli_path=<远端 CLI 路径> runtime_root=<远端目录> workdir_root=<远端工程根> 配置", + }, nil + } + m := map[string]interface{}{ + "status": "ok", "host": host, "configured": true, + "cli_path": he.CLIPath, "node_path": he.NodePath, + "runtime_root": he.RuntimeRoot, "workdir_root": he.WorkdirRoot, + "note": he.Note, + } + if len(he.CLIs) > 0 { + cm := map[string]interface{}{} + for c, ce := range he.CLIs { + if ce == nil { + continue + } + cm[c] = map[string]interface{}{ + "cli_path": ce.CLIPath, "node_path": ce.NodePath, + "runtime_root": ce.RuntimeRoot, "env": ce.Env, "note": ce.Note, + } + } + m["clis"] = cm + } + return m, nil + } + return map[string]interface{}{ + "status": "ok", "count": len(p.endpoints.Hosts), "hosts": p.endpointListRows(), + }, nil +} + +// handleEndpointRemove 删端点配置。 +// +// ★ 该机器有后端覆盖时,只给 host 会**拒绝**(除非 force): +// 否则会出现"以为删了机器,实际子项还在,下次又把老路径拿出来用"。 +func (p *Plugin) handleEndpointRemove(args map[string]interface{}) (interface{}, error) { + host := strings.TrimSpace(str(args, "host")) + cli := strings.TrimSpace(str(args, "cli")) + force := false + if v, ok := args["force"]; ok { + if b, isB := v.(bool); isB { + force = b + } + } + if host == "" { + return map[string]interface{}{"error": "必须给 host"}, nil + } + + p.mu.Lock() + defer p.mu.Unlock() + if p.endpoints == nil { + p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}} + } + he := p.getHost(host, false) + if he == nil { + return map[string]interface{}{"status": "ok", "host": host, "removed": false, + "note": "该机器本来就没有配置(幂等)"}, nil + } + if cli != "" { + if _, ok := he.CLIs[cli]; !ok { + return map[string]interface{}{"status": "ok", "host": host, "cli": cli, "removed": false, + "note": "该后端本来就没有专属配置(幂等)"}, nil + } + delete(he.CLIs, cli) + if err := p.saveEndpoints(); err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + return map[string]interface{}{"status": "ok", "host": host, "cli": cli, "removed": true}, nil + } + // 只给 host:有子项时拒绝 + if len(he.CLIs) > 0 && !force { + names := make([]string, 0, len(he.CLIs)) + for c := range he.CLIs { + names = append(names, c) + } + sort.Strings(names) + return map[string]interface{}{ + "status": "refused", + "reason": fmt.Sprintf("该机器还有 %d 个后端专属配置(%s)。请先逐个删"+ + "(commondev_endpoint_remove host=%s cli=<名>),或传 force=true 一并删除", + len(names), strings.Join(names, ", "), host), + }, nil + } + delete(p.endpoints.Hosts, host) + if err := p.saveEndpoints(); err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + return map[string]interface{}{"status": "ok", "host": host, "removed": true}, nil +} + +// isLocalPathHint 粗略判断路径是否"像是本机路径"。 +// +// 用本机真实存在的根(插件数据目录 / 工程目录)去比: +// 比"硬编码 /home/program 或 /home/newqqagent"可靠 —— 换台机器部署仍然对。 +// 只做**提示**不做强制(确有 localhost 回环这种同构合法情形)。 +func isLocalPathHint(cliPath string) bool { + hint := currentLocalRootHint() + if hint == "" { + return false + } + return strings.HasPrefix(cliPath, hint) +} + +// localRootHint 给用户看的本机根(错误信息里说"本机在 X 下")。 +func (p *Plugin) localRootHint() string { + return currentLocalRootHint() +} + +// currentLocalRootHint 取一个"本机特征路径"用于提示。 +func currentLocalRootHint() string { + if exe, err := os.Executable(); err == nil { + d := filepath.Dir(exe) + if d != "" && d != "/" { + return d + } + } + if hd, err := os.UserHomeDir(); err == nil && hd != "" { + return hd + } + return "" +} + +// sshRunCapture 在远端跑一条命令并取回 stdout(stderr 一并带在错误里)。 +// +// 供端点验存等**轻量**场景使用;重活(准备目录、推脚本)走各自专门函数。 +func (p *Plugin) sshRunCapture(t sshTarget, cmd string) (string, error) { + args := append(t.sshBaseArgs(), t.Host, cmd) + out, err := exec.Command("ssh", args...).CombinedOutput() + s := string(out) + if err != nil { + return s, fmt.Errorf("%w: %s", err, strings.TrimSpace(s)) + } + if !strings.Contains(s, "__OK__") { + return s, fmt.Errorf("远端未返回预期标记(命令可能未生效): %s", strings.TrimSpace(s)) + } + return s, nil +} diff --git a/example/commondev/endpoints_test.go b/example/commondev/endpoints_test.go new file mode 100644 index 0000000..6ac3d35 --- /dev/null +++ b/example/commondev/endpoints_test.go @@ -0,0 +1,388 @@ +package main + +// 远端端点表的判据。 +// +// 核心要证明的**不是**"有个 endpoints.json",而是: +// ① 多台机器**互不干扰**(这是用户提出该需求的唯一理由); +// ② 本机路径**绝不**下发到远端(这是最危险的真实缺陷); +// ③ 同一机器上不同后端可以各有各的路径; +// ④ 出错时错误信息**指出该改哪里**(否则用户只能猜)。 + +import ( + "os" + "path/filepath" + "strings" + "testing" + + sdk "github.com/JianFeeeee/homeagentsdk/sdk" +) + +// newEndpointPlugin 造一个带端点表的插件(数据目录在临时目录)。 +func newEndpointPlugin(t *testing.T) *Plugin { + t.Helper() + sdkp := sdk.New("commondev-test", nil, nil, nil, nil, nil) + p := &Plugin{sdk: sdkp} + dir := t.TempDir() + p.path = filepath.Join(dir, "ledger.json") + if err := p.loadEndpoints(); err != nil { + t.Fatal(err) + } + return p +} + +// ★★★ 判据:两台机器的配置**互不干扰**。 +// +// 这是用户提出该需求的原因:改动前只有一组全局 settings +// (ssh_node_path / ssh_runtime_root / ssh_workdir_root),两台机器时 +// 后配的覆盖前一台,表现为"换台机器就报找不到 node/脚本", +// 而报错指向的是**另一台机器的路径**。 +func TestEndpointsArePerHost(t *testing.T) { + p := newEndpointPlugin(t) + + set := func(host string, kv map[string]interface{}) { + t.Helper() + args := map[string]interface{}{"host": host, "verify": false} + for k, v := range kv { + args[k] = v + } + r, _ := p.handleEndpointSet(args) + if m := r.(map[string]interface{}); m["error"] != nil { + t.Fatalf("设置 %s 失败: %v", host, m["error"]) + } + } + + set("alpha", map[string]interface{}{ + "cli_path": "/opt/alpha/bin/zcode.cjs", "node_path": "/usr/local/bin/node", + "runtime_root": "/home/a/rt", "workdir_root": "/home/a/prog", + }) + set("beta", map[string]interface{}{ + "cli_path": "/srv/beta/zcode.mjs", "node_path": "/opt/node/bin/node", + "runtime_root": "/srv/rt", "workdir_root": "/srv/prog", + }) + + // ★ 每台机器解析出的都必须是**自己**那套 + drv := driverSpec{} + ctx := driverCtx{} + + ea := p.resolveEndpoint("alpha", "zcode", drv, ctx) + if ea.CLIPath != "/opt/alpha/bin/zcode.cjs" { + t.Errorf("alpha 的 cliPath 被串了: %q", ea.CLIPath) + } + if ea.NodePath != "/usr/local/bin/node" || ea.RuntimeRoot != "/home/a/rt" { + t.Errorf("alpha 的 node/runtime 被串了: %+v", ea) + } + if ea.WorkdirRoot != "/home/a/prog" { + t.Errorf("alpha 的 workdirRoot 被串了: %q", ea.WorkdirRoot) + } + + eb := p.resolveEndpoint("beta", "zcode", drv, ctx) + if eb.CLIPath != "/srv/beta/zcode.mjs" { + t.Errorf("beta 的 cliPath 被串了: %q", eb.CLIPath) + } + if eb.NodePath != "/opt/node/bin/node" || eb.RuntimeRoot != "/srv/rt" { + t.Errorf("beta 的 node/runtime 被串了: %+v", eb) + } + if eb.WorkdirRoot != "/srv/prog" { + t.Errorf("beta 的 workdirRoot 被串了: %q", eb.WorkdirRoot) + } + + // 未配置的第三台:不能"借用"别人的值 + ec := p.resolveEndpoint("gamma", "zcode", drv, ctx) + if ec.CLIPath != "" { + t.Errorf("★ gamma 未配置却拿到了别人的 cliPath: %q", ec.CLIPath) + } +} + +// ★★★ 判据:同一机器上**不同后端各有各的路径**。 +// +// 这是需求里"每个 cli 客户端有自己的 ip/cli 对"的一半: +// 同一台机器上 zcode 在 A、别的 CLI 在 B,必须能分别指定。 +func TestEndpointsPerHostAndCLI(t *testing.T) { + p := newEndpointPlugin(t) + + // 机器级默认 + if r, _ := p.handleEndpointSet(map[string]interface{}{ + "host": "box", "cli_path": "/usr/local/bin/default-cli", "verify": false, + }); r.(map[string]interface{})["error"] != nil { + t.Fatalf("设机器默认失败: %v", r) + } + // zcode 专属(应覆盖机器默认) + if r, _ := p.handleEndpointSet(map[string]interface{}{ + "host": "box", "cli": "zcode", "cli_path": "/opt/zcode/zcode.cjs", + "node_path": "/opt/node/bin/node", "verify": false, + }); r.(map[string]interface{})["error"] != nil { + t.Fatalf("设 zcode 专属失败: %v", r) + } + + drv := driverSpec{} + ctx := driverCtx{} + + z := p.resolveEndpoint("box", "zcode", drv, ctx) + if z.CLIPath != "/opt/zcode/zcode.cjs" { + t.Errorf("★ zcode 专属路径未覆盖机器默认: %q", z.CLIPath) + } + if z.NodePath != "/opt/node/bin/node" { + t.Errorf("zcode 专属 node 未生效: %q", z.NodePath) + } + // 另一个后端:应拿到**机器默认**,而不是 zcode 的 + other := p.resolveEndpoint("box", "othercli", drv, ctx) + if other.CLIPath != "/usr/local/bin/default-cli" { + t.Errorf("★ 其他后端应回落到机器默认: %q", other.CLIPath) + } + if other.NodePath != "node" { + t.Errorf("zcode 的 node 不该泄漏给其他后端: %q", other.NodePath) + } + // 解析来源必须可读(错误信息靠它指出该改哪里) + if !strings.Contains(z.CLIPathFrom, "专属") { + t.Errorf("CLIPathFrom 未标明来源: %q", z.CLIPathFrom) + } + if !strings.Contains(other.CLIPathFrom, "host") { + t.Errorf("回落时应标明来源是 host 默认: %q", other.CLIPathFrom) + } +} + +// ★★★ 判据:**本机路径不得下发到远端**(本会话最危险的真实缺陷)。 +// +// 改动前 `drv.buildArgs(ctx)` 会把 driver.json 的 `cliPath` +// (随包默认是 `{rawfile}/zcode/zcode.cjs`)展开成**本机**路径塞进 `--cli`, +// 然后整条命令发到远端 ⇒ 远端必然报 "No such file",而用户会去 +// **远端**找原因,真正的错在本地。 +func TestRemoteCommandDoesNotCarryLocalPaths(t *testing.T) { + ctx := driverCtx{ + Workdir: "/w", SessionID: "s", SessionName: "n", + DataDir: "/d", PluginDir: "/p", RawfileDir: "/rf", + } + drv := driverSpec{ + Args: []string{"--workdir", "{workdir}", "--session-id", "{sessionId}"}, + CLIPath: "{rawfile}/zcode/zcode.cjs", // ★ 本机占位符 + } + + // ① 端点表里配了远端真实路径 ⇒ 命令里只能出现远端那条 + ep := resolvedEndpoint{CLIPath: "/opt/remote/zcode.cjs"} + _, args, _ := buildRemoteCommandFor(drv, ctx, "/usr/bin/node", ep) + joined := strings.Join(args, " ") + if strings.Contains(joined, "/rf/") || strings.Contains(joined, "{rawfile}") { + t.Errorf("★ 远端命令里出现了**本机**路径: %v", args) + } + if !strings.Contains(joined, "/opt/remote/zcode.cjs") { + t.Errorf("★ 远端命令里没有远端 CLI 路径: %v", args) + } + + // ② 端点表没配的机器:必须在**启动 ssh 之前**响亮拒绝, + // 而不是把本机路径发过去让远端报错。 + // + // 判定依据是**占位符**(本机专属、展开后必然是本机路径), + // 不是"任意看起来像本机的绝对路径"—— 后者在 localhost/同构机器上 + // 是合法的,强制拒绝会挡住真实用法。所以这里用占位符版本断言。 + errPH := validateRemoteCLIPath("{rawfile}/zcode/zcode.cjs", "zcode") + if errPH == nil { + t.Error("★ 含本机占位符的路径必须拒绝(展开后就是本机路径)") + } else if !strings.Contains(errPH.Error(), "本机") { + t.Errorf("错误应点明本机占位符问题: %v", errPH) + } + // 未配路径(空)必须拒绝并告知去哪儿配 + if err := validateRemoteCLIPath("", "zcode"); err == nil { + t.Error("★ 未配远端路径时应拒绝,而不是把值发过去") + } + // 完全没配也拒绝,且**告知去哪儿配** + err := validateRemoteCLIPath("", "zcode") + if err == nil { + t.Fatal("未配路径应拒绝") + } + for _, want := range []string{"endpoint_set", "cli_path", "zcode"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("错误应含 %q 以便用户知道怎么修: %v", want, err) + } + } + // 远端真实路径应通过 + if err := validateRemoteCLIPath("/opt/remote/zcode.cjs", "zcode"); err != nil { + t.Errorf("远端真实路径不该被拒: %v", err) + } +} + +// ★★★ 判据:**声明了本机路径要当场拒绝并说明原因**。 +func TestEndpointSetRejectsLocalPath(t *testing.T) { + p := newEndpointPlugin(t) + // 构造一个"看起来是本机"的路径:用本机真实存在的根 + hint := currentLocalRootHint() + if hint == "" { + t.Skip("无法确定本机根,跳过") + } + local := filepath.Join(hint, "some", "zcode.cjs") + + r, _ := p.handleEndpointSet(map[string]interface{}{ + "host": "box", "cli_path": local, "verify": false, + }) + m := r.(map[string]interface{}) + if m["error"] == nil { + t.Fatalf("★ 应拒绝本机路径: %s", local) + } + if !strings.Contains(fmtStr(m["error"]), "本机") { + t.Errorf("错误应点明是本机路径: %v", m["error"]) + } + // 占位符同样拒绝 + r2, _ := p.handleEndpointSet(map[string]interface{}{ + "host": "box", "cli_path": "{rawfile}/z/x.cjs", "verify": false, + }) + if r2.(map[string]interface{})["error"] == nil { + t.Error("★ 应拒绝含 {rawfile} 的路径") + } + // 远端真实路径应接受 + r3, _ := p.handleEndpointSet(map[string]interface{}{ + "host": "box", "cli_path": "/opt/remote/zcode.cjs", "verify": false, + }) + if e := r3.(map[string]interface{})["error"]; e != nil { + t.Errorf("远端路径不该被拒: %v", e) + } +} + +// ★★★ 判据:删除**不能留下幽灵子项**。 +// +// 否则会出现"以为删了机器,实际后端覆盖还在,下次又把老路径拿出来用"。 +func TestEndpointRemoveRefusesSilentPartial(t *testing.T) { + p := newEndpointPlugin(t) + p.handleEndpointSet(map[string]interface{}{"host": "box", "cli_path": "/opt/d", "verify": false}) + p.handleEndpointSet(map[string]interface{}{"host": "box", "cli": "zcode", "cli_path": "/opt/z", "verify": false}) + + // 只给 host:必须拒绝(有子项) + r, _ := p.handleEndpointRemove(map[string]interface{}{"host": "box"}) + m := r.(map[string]interface{}) + if m["status"] != "refused" { + t.Fatalf("★ 有后端覆盖时应拒绝整体删除: %v", m) + } + // 拒绝后表必须**完整保留**(不能删一半) + he := p.getHost("box", false) + if he == nil || he.CLIPath != "/opt/d" || len(he.CLIs) != 1 { + t.Fatalf("拒绝后配置被改动了: %+v", he) + } + + // 先删子项,再删机器 → 成功 + if r, _ := p.handleEndpointRemove(map[string]interface{}{"host": "box", "cli": "zcode"}); r.(map[string]interface{})["removed"] != true { + t.Fatalf("删子项失败: %v", r) + } + if r, _ := p.handleEndpointRemove(map[string]interface{}{"host": "box"}); r.(map[string]interface{})["removed"] != true { + t.Fatalf("删机器失败: %v", r) + } + if p.getHost("box", false) != nil { + t.Error("机器未真正删除") + } + + // force 可以一并删 + p.handleEndpointSet(map[string]interface{}{"host": "box2", "cli_path": "/opt/d", "verify": false}) + p.handleEndpointSet(map[string]interface{}{"host": "box2", "cli": "zcode", "cli_path": "/opt/z", "verify": false}) + if r, _ := p.handleEndpointRemove(map[string]interface{}{"host": "box2", "force": true}); r.(map[string]interface{})["removed"] != true { + t.Fatalf("force 删除失败: %v", r) + } + if p.getHost("box2", false) != nil { + t.Error("force 未连带删掉子项") + } +} + +// ★★★ 判据:端点表**持久化且损坏响亮**。 +// +// 静默用空表 ⇒ 所有远端配置凭空消失,表现为"我明明配过,怎么又要配", +// 而用户会先怀疑自己。 +func TestEndpointsPersistAndCorruptionIsLoud(t *testing.T) { + dir := t.TempDir() + sdkp := sdk.New("commondev-test", nil, nil, nil, nil, nil) + p := &Plugin{sdk: sdkp} + p.path = filepath.Join(dir, "ledger.json") + if err := p.loadEndpoints(); err != nil { + t.Fatal(err) + } + p.handleEndpointSet(map[string]interface{}{ + "host": "h1", "cli_path": "/opt/a", "runtime_root": "/rt/a", + "workdir_root": "/wd/a", "verify": false, + }) + + // 重新加载(模拟重启) + p2 := &Plugin{sdk: sdkp} + p2.path = filepath.Join(dir, "ledger.json") + if err := p2.loadEndpoints(); err != nil { + t.Fatalf("重载失败: %v", err) + } + got := p2.resolveEndpoint("h1", "zcode", driverSpec{}, driverCtx{}) + if got.CLIPath != "/opt/a" || got.RuntimeRoot != "/rt/a" || got.WorkdirRoot != "/wd/a" { + t.Fatalf("重启后配置丢失: %+v", got) + } + + // 权限 0600(可能含内网地址/目录结构,不该 world-readable) + st, err := os.Stat(p.endpointsPath()) + if err != nil { + t.Fatal(err) + } + if st.Mode().Perm() != 0o600 { + t.Errorf("端点表权限应为 0600,实际 %v", st.Mode().Perm()) + } + + // 损坏 → 必须响亮 + if err := os.WriteFile(p.endpointsPath(), []byte("{ not json"), 0o600); err != nil { + t.Fatal(err) + } + p3 := &Plugin{sdk: sdkp} + p3.path = filepath.Join(dir, "ledger.json") + if err := p3.loadEndpoints(); err == nil { + t.Fatal("★ 端点表损坏时必须报错(静默用空表 = 所有远端配置消失)") + } else if !strings.Contains(err.Error(), "损坏") { + t.Errorf("错误应点明损坏: %v", err) + } + + // 缺文件不是错误(首次运行) + p4 := &Plugin{sdk: sdkp} + p4.path = filepath.Join(t.TempDir(), "ledger.json") + if err := p4.loadEndpoints(); err != nil { + t.Errorf("缺文件不该报错: %v", err) + } + if p4.endpoints == nil || p4.endpoints.Hosts == nil { + t.Error("缺文件后应初始化为空表(不是 nil)") + } +} + +// ★★★ 判据:旧配置(全局 settings)**仍然生效**(升级不破坏既有部署)。 +// +// 但端点表**优先**:不能出现"配了端点表还被旧全局键盖住"。 +func TestEndpointOverridesLegacySettings(t *testing.T) { + p := newEndpointPlugin(t) + // 用 stubSettings 提供旧键 + p.sdk = sdk.New("commondev-test", newStubSettingsWith(t.TempDir(), map[string]interface{}{ + "ssh_runtime_root": "/legacy/rt", + "ssh_workdir_root": "/legacy/wd", + "ssh_node_path": "/legacy/node", + }), nil, nil, nil, nil) + + // ① 旧键生效(升级不破坏) + e := p.resolveEndpoint("h", "zcode", driverSpec{}, driverCtx{}) + if e.RuntimeRoot != "/legacy/rt" || e.WorkdirRoot != "/legacy/wd" || e.NodePath != "/legacy/node" { + t.Fatalf("旧全局配置应仍生效: %+v", e) + } + + // ② 端点表配了 → 必须压过旧键 + p.handleEndpointSet(map[string]interface{}{ + "host": "h", "runtime_root": "/new/rt", "workdir_root": "/new/wd", + "node_path": "/new/node", "verify": false, + }) + e2 := p.resolveEndpoint("h", "zcode", driverSpec{}, driverCtx{}) + if e2.RuntimeRoot != "/new/rt" { + t.Errorf("★ 端点表未压过旧键: %q", e2.RuntimeRoot) + } + if e2.WorkdirRoot != "/new/wd" { + t.Errorf("★ 端点表未压过旧 ssh_workdir_root: %q", e2.WorkdirRoot) + } + if e2.NodePath != "/new/node" { + t.Errorf("★ 端点表未压过旧 ssh_node_path: %q", e2.NodePath) + } +} + +func fmtStr(v interface{}) string { + if v == nil { + return "" + } + return strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(toString(v)), "\n", " ")) +} + +func toString(v interface{}) string { + if s, ok := v.(string); ok { + return s + } + return "" +} diff --git a/example/commondev/examples/custom-cli/README.md b/example/commondev/examples/custom-cli/README.md new file mode 100644 index 0000000..ed79792 --- /dev/null +++ b/example/commondev/examples/custom-cli/README.md @@ -0,0 +1,217 @@ +# 自定义 CLI 后端(示例:ZCode) + +> ## ★ 边界:**内嵌只做 pi** +> +> 插件**内嵌支持的后端只有 pi**(`runtime/pi/`,不装 driver.json 也照常工作)。 +> 本文及同目录示例是给**想让插件驱动别的 CLI** 的用户用的。 +> +> 这不是"官方内嵌支持"——它们是**参考实现**:插件不承诺跟着上游 CLI 变更同步更新, +> 你要根据自己的 CLI 版本调整(尤其参数名与输出格式)。 +> +> 沿用本仓的一条纪律:**内核/插件只保证机器必须保证的事,策略与平台知识交给能 +> 独立演进的地方**(见 skill `commondev-discipline` 里"平台知识进 skill"的同一条理由)。 + +## 怎么加一个后端 + +插件按 **数据**(不是代码)选后端。加一个 CLI 只要放两个文件: + +``` +<插件数据目录>/runtime//driver.json ← 声明:起什么命令、什么环境 +<插件数据目录>/runtime//sidecar.mjs ← 实现:把 CLI 包成 JSON-RPC +``` + +**插件核心一行都不用改**(这是可验证的:`TestCoreDoesNotHardcodePiLaunchDetails` +会断言 `startSidecar` 里不出现任何 pi 专有细节)。 + +开分支时指定后端: + +``` +commondev_branch_open(project="X", branch="feat/a", agent_cli="zcode", workdir=...) +``` + +### 两种来源:随包分发 vs 本机覆盖 + +`driver.json` 与 sidecar 脚本都按同一条优先级链找: + +| 顺序 | 位置 | 用途 | +|---|---|---| +| ① | `<插件数据>/runtime//` | **本机覆盖**(最高优先)—— 指到本机真实的 CLI 路径 | +| ② | `<插件目录>/rawfile/runtime//` | **随包分发** —— 升级插件即带上 | +| ③ | `<可执行文件目录>/` | 开发期 | + +**为什么要 ②**:`cliPath` 的随包默认值是 `{rawfile}/zcode/zcode.cjs`, +而 CLI 有 28MB、按需分发 —— 所以**多数机器上它不存在**,此时必须由 ① 覆盖: + +```bash +# 本机覆盖:只改这两个文件,不影响随包内容(升级插件不会被覆盖) +mkdir -p <插件数据>/runtime/zcode +cp <随包>/rawfile/runtime/zcode/sidecar.mjs <插件数据>/runtime/zcode/ +# 然后编辑 <插件数据>/runtime/zcode/driver.json,把 cliPath 指向本机 zcode.cjs +``` + +若两处都没有,错误会**列出找过的每个位置**并告诉你改哪里(不要让它静默失败)。 + +## sidecar 必须实现的契约 + +JSON-RPC over stdin/stdout,方法与 pi 的 sidecar **完全相同**: + +| 方法 | 入参 | 必须返回 | +|---|---|---| +| `ping` | — | `{ok, session_id, cli_version?}`(握手,10s 内) | +| `prompt` | `{text, session_id?}` | `{ok, session_id, reply}` ← **`session_id` 必须回** | +| `state` | — | `{ok, session_id, messages}` | +| `close` | — | `{ok}` | + +**为什么 `prompt` 必须回 `session_id`**:插件在投递后**断言会话未漂移** +(设计 §3.3)。不回或回错值,插件会响亮失败——那是防"悄悄换了个会话"的机制。 + +### ★ 会话 id 有两个**不同**的东西,别混 + +这是 ZCode 后端实测踩到的真坑(2026-10-16,真机复现): + +| 名字 | 长什么样 | 谁产生 | 能当 `--resume` 目标吗 | +|---|---|---|---| +| **插件标签** | `br--` | 插件(`--session-id` 传进来) | ❌ **绝不能** | +| **后端真实 id** | `sess_xxxxxxxx-...` | 后端首轮返回 | ✅ 只有它能 | + +初版把 `args.sessionId`(标签)直接当 `--resume` 目标 ⇒ 首轮就执行 +`--resume br-xxx`,ZCode 报 `Session not found: br-xxx`—— +**表现为"第一次投递必定失败"**,第二次才可能好,非常像"偶发网络问题"。 + +正确形状(示例就是这么写的): + +```js +let desiredSessionId = args.sessionId || ""; // 插件标签,只用于回报 +let zcodeSessionId = ""; // 真实 id,只有它能 resume +// 首轮:不传 --resume;从 {type:"result"} 行取回 sess_... +if (zcodeSessionId) argv.push("--resume", zcodeSessionId); +// 回报时:有真实 id 报它,否则退回标签 +session_id: zcodeSessionId || desiredSessionId || null, +``` + +`params.session_id`(插件在 prompt 里传的)**同样是标签**,不要拿它赋值给 +"真实 id"变量——那等于把上面的修法又改回去了。 + +### ★ 声明的 CLI 路径必须**验存** + +`driver.json` 的 `cliPath` 可能指向一个**不存在的**文件(例如随包默认值 +`{rawfile}/zcode/zcode.cjs`,而包里并没带那个 28MB 的 CLI)。 +若 `if (args.cliPath) return args.cliPath` 直接返回,Node 会在 spawn 时抛 +`MODULE_NOT_FOUND`,用户看到的是一坨 `Module._resolveFilename` 堆栈, +**完全看不出"CLI 不存在"**,也不知道去哪儿配。 + +正确做法:把"声明的"和"探测到的"一起验存,找不到时**列出找过的每个位置 +及其来源**,并给出修复动作(改哪个文件 / 设哪个环境变量)。 + +## driver.json 字段 + +```jsonc +{ + "name": "zcode", // 后端名(与目录名一致) + "script": "sidecar.mjs", // 相对本目录 + "node": "auto", // auto = 随包优先;ssh 场景会走远端 PATH + "sessionNameFlag": false, // 声明是否支持 --session-name(不支持就不传) + "sessionDirSuffix": ".zcode-sessions", + "llmConfig": "none", // pi | zcode | none —— 谁准备 LLM 源 + "env": { "ZCODE_DATA_BASE_DIR": "{dataDir}/runtime/zcode" }, + "args": ["--workdir", "{workdir}", "--session-id", "{sessionId}", "--mode", "guarded"] +} +``` + +占位符:`{workdir}` `{sessionId}` `{sessionName}` `{dataDir}` `{pluginDir}` + +- **`sessionNameFlag: false` 是真实的坑**:ZCode CLI 没有 `--session-name`, + 传了会 `Unknown option` 直接退到 usage——**一行参数毁掉整条命令**。 +- **`llmConfig`**:`pi` = 插件先写 `models.json`/`auth.json`(复用内核 LLM 源); + `none` = 后端自己管。**没有 `zcode` 这个实现**——我们只知道 + `ZCODE_BUILTIN_PROVIDER_CONFIG_FILE` 这个环境变量,**没实测过它的文件格式**, + 凭猜测写是"静默写坏"(ZCode 起不来会表现为"模型不回复",排查方向完全错)。 + 要用内核源,请先实测格式再实现写入器。 + +## ZCode 示例的实测契约(真机 `zcode 0.16.9`,非猜测) + +``` +zcode.cjs --prompt <文本> --output-format stream-json --cwd <目录> --mode <值> [--resume ] +``` + +| 点 | 实况(真机核实) | +|---|---| +| `--mode` | 合法值 **`build\|edit\|plan\|yolo\|guarded`**;**不传默认 `yolo`**(`run.ts: DEFAULT_HEADLESS_PROMPT_MODE`)= 绕过全部授权询问。示例用 `guarded`。 | +| `--output-format` | **存在**(`--help` 里**不显示**,是隐藏参数 —— 别因为 help 没有就以为不存在)。`stream-json` 才有逐事件 NDJSON + **末行** `{type:"result", sessionId, response}`,sidecar 就靠这一行。 | +| `--resume` | 会话延续。**只接受真实 `sess_...` id**,首轮不能传(见上文会话 id 一节) | +| `--allowed-tools` | ⚠ **不存在**——只能用 `--disallowed-tools` | +| `--max-turns` | ⚠ **不存在** | +| `--session-name` | ⚠ **不存在**(`sessionNameFlag: false`) | + +> **教训**:`--output-format` 这一条曾被基于 `--help` 误判为"不存在", +> 差点把 sidecar 改成解析非结构化文本。**判定参数存在性必须用真机试跑** +> (`--prompt hi --output-format stream-json` 直接看输出),`--help` 只覆盖公开参数。 + +> ⚠️ **超时必须杀进程树**:ZCode 会派生工具子进程(bash 等), +> 只杀父进程会留下孤儿继续跑,且仍占着工作目录。示例里用 `detached: true` + +> `process.kill(-pid)` 整组杀。 + +## 远端(SSH)场景的额外注意 + +`runtime=ssh` 时用的是**同一份** driver 声明与 sidecar 契约,但: + +1. **环境变量必须显式前置**(`ssh host env K=V … node …`)——ssh 不继承本地环境。 +2. **node 用远端的**(裸 `node` 走远端 PATH),不是本地随包分发的绝对路径。 +3. **脚本要先推到远端**(插件会自动 `scp` 一次,见 `pushSidecar`)。 +4. **不交互**:`BatchMode=yes`。没配免密必须立刻失败,否则会挂在密码提示上 + 直到工具调用超时——那表现为"拉起 agent 卡住"而不是"没配免密"。 + +## 验证 + +```bash +cd <插件源码> +# 结构性判据(不需要真 CLI) +go test -count=1 -run 'TestCoreDoesNotHardcode|TestSwappingDriverSwapsBackend|TestSSH|TestZcode|TestShippedDriver' ./... + +# ★ 真机端到端(需要真实 ZCode CLI + 可用 provider 配置) +HA_ZCODE_CLI=<...>/zcode.cjs \ +HA_ZCODE_DATA_DIR=<插件数据>/runtime/zcode \ + go test -tags zcode_e2e -run TestZcodeFullPipeline -v . + +# ★ SSH 远端(需要 sshd + localhost 免密) +HA_SSH_E2E=1 HA_ZCODE_CLI=... HA_ZCODE_DATA_DIR=... \ + go test -tags zcode_e2e -run TestZcodeOverSSH -v . +``` + +`TestSwappingDriverSwapsBackend` 用一个假 CLI 端到端证明: +换 driver 就能换后端,插件核心零改动。 + +`TestZcodeSidecarArgvBehavior` 用**假 CLI 记录真实 argv** 来判"首轮不得 `--resume`"—— +为什么不用源码字符串匹配:初版就是匹配字面串,把变量改个名就绕过了(判据假绿)。 + +## 给 ZCode 配 LLM 源(实测可用的最小配置) + +ZCode 用自己的 provider 配置,**插件不碰它**(`llmConfig: "none"`)。 +数据目录由 `ZCODE_DATA_BASE_DIR` 决定,配置落在`<该目录>/.zcode/v2/provider_config.json`: + +```json +{ + "schemaVersion": 1, + "config": { + "providerConfigRules": { "providerRules": [ { + "providerId": "llmsproxy", "providerName": "llmsproxy", "enabled": true, + "config": { + "group": "standard-personal", + "personalModelIds": ["AUTO"], "modelOrder": ["AUTO"], + "access": { "type": "api-key", "apiKey": "sk-..." }, + "api": { "type": "openai-chat-completions", "baseUrl": "http://127.0.0.1:8081/v1" } + } + } ] }, + "defaultModelSelection": { "providerId": "llmsproxy", "modelId": "AUTO" } + } +} +``` + +实测踩过的三个坑(全部真机复现): + +1. **`schemaVersion` 必须有**,且 `providerRules` 嵌在 `config.providerConfigRules` 下 + —— 放顶层会被静默"恢复"成默认值(文件被改写但你没察觉)。 +2. **`group: "standard-personal"` 必须有**。少了它,配置**被读到但不被准入**, + 报 `Error: Model creation failed`;加 `--verbose` 才能看到真因 + `Select a model before continuing`。 +3. `api.type` 合法值:`anthropic-messages` / `openai-chat-completions` / `openai-responses`。 diff --git a/example/commondev/examples/custom-cli/zcode/driver.json b/example/commondev/examples/custom-cli/zcode/driver.json new file mode 100644 index 0000000..4c7c77b --- /dev/null +++ b/example/commondev/examples/custom-cli/zcode/driver.json @@ -0,0 +1,22 @@ +{ + "name": "zcode", + "script": "sidecar.mjs", + "node": "auto", + "sessionNameFlag": false, + "sessionDirSuffix": ".zcode-sessions", + "llmConfig": "none", + "env": { + "ZCODE_DATA_BASE_DIR": "{dataDir}/runtime/zcode", + "ZCODE_SESSION_DIR": "{workdir}/.zcode-sessions" + }, + "args": [ + "--workdir", + "{workdir}", + "--session-id", + "{sessionId}", + "--mode", + "guarded" + ], + "note": "llmConfig=none:ZCode 用自己的 provider 配置(插件不猜它的格式)。cliPath 支持占位符;{rawfile} 指随包资源目录 —— 把 zcode.cjs 放进 rawfile/zcode/ 即可随包分发。用户也可在 <插件数据>/runtime/zcode/driver.json 覆盖成本机真实路径。", + "cliPath": "{rawfile}/zcode/zcode.cjs" +} \ No newline at end of file diff --git a/example/commondev/examples/custom-cli/zcode/sidecar.mjs b/example/commondev/examples/custom-cli/zcode/sidecar.mjs new file mode 100644 index 0000000..d3ef9c3 --- /dev/null +++ b/example/commondev/examples/custom-cli/zcode/sidecar.mjs @@ -0,0 +1,287 @@ +#!/usr/bin/env node +// ZCode sidecar:把 **ZCode CLI** 包成与 pi sidecar **同一个 JSON-RPC 契约**的服务。 +// +// ## 为什么必须同契约 +// +// 插件的 `startSidecar` / `sc.call("prompt")` 是唯一的调用面 —— +// 只要这个脚本实现 ping/prompt/state/close,**插件核心一行不用改** +// 就能跑 ZCode。这就是「通过不同 sidecar 支持不同 CLI」的落点。 +// +// ## 与 pi sidecar 的关键差异(实测契约,来自 agentmail/zcode-mail-bridge) +// +// pi sidecar ZCode sidecar +// ──────────────────── ────────────────────────────── +// 常驻进程 + prompt() **每轮起一个新进程**(CLI 是一次性的) +// --session-id 建会话 --resume 延续;首轮不给 +// SDK 直接返回文本 --output-format stream-json,末行 {type:"result"} +// 无授权概念 --mode **必传**(不传 = yolo,绕过全部授权询问) +// +// ## 会话 id 的处理(★ 与插件的契约) +// +// 插件在 prompt 后**断言 session_id 未变**(防会话漂移)。 +// ZCode 首轮才产生真实 sessionId,所以这里: +// · 首轮:把 CLI 返回的 sess_xxx 回报(插件接受它作为会话标识) +// · 后续:用 --resume 传回去,并回报同一个 id +// +// ## 超时必须杀进程树 +// +// ZCode 会派生工具子进程(bash 等)。只杀父进程会留下一堆孤儿继续跑, +// 而且它们仍占着工作目录。 + +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--workdir") out.workdir = argv[++i]; + else if (a === "--session-id") out.sessionId = argv[++i]; + else if (a === "--session-name") out.sessionName = argv[++i]; + else if (a === "--cli") out.cliPath = argv[++i]; + else if (a === "--mode") out.mode = argv[++i]; + } + return out; +} + +const args = parseArgs(process.argv.slice(2)); +const workdir = args.workdir || process.cwd(); + +// ★ mode 必须显式给:不传时 CLI 默认 **yolo**(源码 src/run.ts: +// DEFAULT_HEADLESS_PROMPT_MODE),那会绕过全部授权询问。 +// 合法值:build | edit | plan | yolo | guarded。默认用 guarded。 +const VALID_MODES = ["build", "edit", "plan", "yolo", "guarded"]; +const mode = args.mode || process.env.ZCODE_MODE || "guarded"; +if (!VALID_MODES.includes(mode)) { + throw new Error(`--mode 必须是 ${VALID_MODES.join("|")} 之一(收到 ${mode})`); +} + +// CLI 入口:驱动可经 --cli 指定,否则按常见安装位置找。 +// +// ★ 返回 {path, tried} 而不是裸字符串:**必须知道路径是"声明的"还是"探测到的"**, +// 才能在报错里说清楚该改哪里。 +// +// 实测真缺陷(2026-10-16):随包 driver.json 声明了 `{rawfile}/zcode/zcode.cjs`, +// 但包里**没有**这个文件(28MB,按需分发)。旧实现 `if (args.cliPath) return args.cliPath` +// 直接把它当有效路径返回 ⇒ Node 在 spawn 时抛模块解析失败,用户看到的是一坨 +// `Module._resolveFilename` 堆栈,**完全看不出"CLI 不存在"**,更不知道去哪儿配。 +// 声明的路径同样必须验存。 +function findCLI() { + const cands = [ + { p: args.cliPath, source: "driver.json 的 cliPath" }, + { p: process.env.ZCODE_CLI, source: "环境变量 ZCODE_CLI" }, + { p: path.join(process.env.HOME || "", ".zcode/runtime/bin/zcode.mjs"), source: "默认探测" }, + { p: "/opt/ZCode/resources/glm/zcode.cjs", source: "默认探测" }, + { p: "/usr/local/bin/zcode", source: "默认探测" }, + ]; + const tried = []; + for (const c of cands) { + if (!c.p) continue; + tried.push(`${c.p}(${c.source})`); + if (existsSync(c.p)) return { path: c.p, tried }; + } + return { path: "", tried }; +} + +// ★ 必须区分两个不同的东西: +// +// desiredSessionId —— 插件传进来的**标签**(br--), +// 它是插件台账里的会话名,**不是** ZCode 的会话 id +// zcodeSessionId —— ZCode 真实产出的 `sess_...`,只有它能用于 --resume +// +// 初版把 args.sessionId 直接当成 --resume 的目标,于是**首轮就带上 +// `--resume br-xxx`**,ZCode 报 `Session not found: br-xxx` —— +// 表现为"第一次投递必定失败",而第二次才可能成功(如果插件把 +// 上一轮返回的 sess_... 传回来了)。 +// +// 实测(2026-10-16):真实 ZCode 0.16.9 上首轮即失败。 +// 契约(参考实现在注释里也写了):**首轮不给 --resume**, +// 从 `{type:"result"}` 行取回 sess_...,之后才用它续谈。 +let desiredSessionId = args.sessionId || ""; +let zcodeSessionId = ""; +let turnCount = 0; + +function send(obj) { + process.stdout.write(JSON.stringify(obj) + "\n"); +} + +/** 杀**进程树**(ZCode 会派生工具子进程)。 */ +function killTree(child) { + if (!child || child.killed) return; + try { + // 负号 = 进程组;spawn 时 detached 建了独立进程组。 + process.kill(-child.pid, "SIGTERM"); + } catch { + try { child.kill("SIGTERM"); } catch {} + } + setTimeout(() => { + try { process.kill(-child.pid, "SIGKILL"); } catch {} + }, 3000).unref?.(); +} + +/** 跑一轮 ZCode,返回 {sessionId, response}。 */ +function runTurn(text, timeoutMs) { + return new Promise((resolve, reject) => { + const found = findCLI(); + if (!found.path) { + reject(new Error( + "找不到 ZCode CLI。已找过:\n - " + found.tried.join("\n - ") + + "\n请把 driver.json 的 cliPath 指向真实 CLI(本机覆盖写到 " + + "<插件数据>/runtime/zcode/driver.json),或设 ZCODE_CLI 环境变量。")); + return; + } + const argv = [ + found.path, + "--prompt", text, + "--output-format", "stream-json", + "--cwd", workdir, + "--mode", mode, + ]; + // ★ 会话延续:首轮不给 --resume(还没有 id),后续都给。 + // ★ 只有拿到 ZCode 真实 id 才 resume;首轮没有 → 不传。 + if (zcodeSessionId) argv.push("--resume", zcodeSessionId); + + const child = spawn(process.execPath, argv, { + cwd: workdir, + detached: true, // 独立进程组 ⇒ 能整组杀 + stdio: ["ignore", "pipe", "pipe"], + }); + + let out = ""; + let err = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + killTree(child); + reject(new Error(`ZCode 回合超时(${Math.round(timeoutMs / 1000)}s)`)); + }, timeoutMs); + + child.stdout.on("data", (d) => { out += d.toString(); }); + child.stderr.on("data", (d) => { err += d.toString(); }); + + child.on("error", (e) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(e); + }); + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + + // 逐行解析 stream-json:事件行随时来,末行是 {type:"result"} + let result = null; + let events = 0; + for (const line of out.split("\n")) { + const t = line.trim(); + if (!t || t[0] !== "{") continue; + let obj; + try { obj = JSON.parse(t); } catch { continue; } + if (obj && obj.type === "result") result = obj; + else events++; + } + if (!result) { + reject(new Error( + `ZCode 未返回 result 行(exit=${code},事件 ${events} 条)` + + (err ? `;stderr: ${err.slice(-400)}` : ""))); + return; + } + resolve({ + sessionId: typeof result.sessionId === "string" && result.sessionId + ? result.sessionId + : zcodeSessionId, + response: typeof result.response === "string" ? result.response : "", + events, + }); + }); + + // 供 close 时中断当前回合 + currentChild = child; + }); +} + +let currentChild = null; + +async function handle(method, params) { + switch (method) { + case "ping": { + // 不启动 CLI:握手要快且确定(10s 上限)。 + // 报出 CLI 版本 —— 插件的 ping 处理会认 pi_version/cli_version/version 三者之一。 + return { + ok: true, + cwd: workdir, + // 握手阶段还没有 ZCode 会话;报插件给我们的标签即可 + // (插件侧台账用它,真实 sess_... 在首轮 prompt 后才产生)。 + session_id: zcodeSessionId || desiredSessionId || null, + cli_version: process.env.ZCODE_VERSION || null, + session_name: args.sessionName || null, + backend: "zcode", + }; + } + case "prompt": { + const text = params?.text ?? ""; + // ★ params.session_id 是**插件的标签**(br-xxx),不是 ZCode 的会话 id。 + // 绝不能拿它当 --resume 目标 —— 那正是首轮必然失败的根因。 + // 只在还没有真实 id 时记下它,供握手/state 回报用。 + if (params?.session_id && !zcodeSessionId) { + desiredSessionId = String(params.session_id); + } + const timeoutMs = Number(params?.timeout_ms) > 0 + ? Number(params.timeout_ms) + : 20 * 60 * 1000; + const r = await runTurn(text, timeoutMs); + // ★ 只在拿到真实 sess_... 时更新;空值不能覆盖已有 id + // (否则一次失败就把续谈能力抹掉)。 + if (r.sessionId) zcodeSessionId = r.sessionId; + turnCount++; + return { + ok: true, + // ★ 必须回报 session_id:Go 侧据此断言「会话未漂移」 + // 报真实 id(有则报它,否则退回插件的标签)。 + session_id: zcodeSessionId || desiredSessionId || null, + reply: r.response, + }; + } + case "state": { + return { + ok: true, + session_id: zcodeSessionId || desiredSessionId || null, + messages: turnCount, + }; + } + case "close": { + killTree(currentChild); + return { ok: true }; + } + default: + throw new Error(`unknown method: ${method}`); + } +} + +const rl = createInterface({ input: process.stdin }); +rl.on("line", async (line) => { + line = line.trim(); + if (!line) return; + let req; + try { req = JSON.parse(line); } catch { return; } + try { + const result = await handle(req.method, req.params || {}); + send({ jsonrpc: "2.0", id: req.id, result }); + if (req.method === "close") process.exit(0); + } catch (e) { + send({ + jsonrpc: "2.0", + id: req.id, + error: { code: -32000, message: String(e?.message || e) }, + }); + } +}); + +rl.on("close", () => { + killTree(currentChild); + process.exit(0); +}); diff --git a/example/commondev/flow_coverage_test.go b/example/commondev/flow_coverage_test.go new file mode 100644 index 0000000..5803310 --- /dev/null +++ b/example/commondev/flow_coverage_test.go @@ -0,0 +1,368 @@ +package main + +// 全流程覆盖度补齐:生命周期、台账一致性与状态查询的判据。 +// +// ## 为什么补这一批 +// +// 覆盖度审计发现 40 个工具里有 23 个**没有任何机制级测试** —— 包括 +// `agent_up`(整条链路的枢纽)、`branch_close`(收尾)、`project_*`(入口)、 +// `pi_source_*`(LLM 源配置)。缺口集中在两类: +// +// ① 状态查询类(branch_status / parallel_status / job_status / test_list) +// ② 生命周期类(agent_up / branch_close / project_*) +// +// ②类中最要命的是**顺序与不变量**: +// - agent_up 必须在 branch_open **之后**(否则没有 branch 可挂) +// - 通道必须在**拿到 session 之后**注册(否则通道指向不存在的会话) +// - branch_close 必须清掉通道与 sidecar 引用(否则"以为还在跑") +// +// 这些不变量此前只写在注释里,**没有判据钉住**。 + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// newFlowPlugin 造一个专用于流程判据的插件(真实台账文件,可验证持久化)。 +func newFlowPlugin(t *testing.T) *Plugin { + t.Helper() + dir, err := os.MkdirTemp("", "cdflow") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + p := newTestPluginForShell(t) + p.path = filepath.Join(dir, "ledger.json") + p.ledger = &Ledger{ + Projects: map[string]*Project{}, Branches: map[string]*Branch{}, + Jobs: map[string]*Job{}, Criteria: map[string]*Criteria{}, + } + // ★ sidecars 在 Start() 里初始化(plugin.go:216)。判据绕过了 Start(), + // 所以必须自己初始化 —— 否则往 nil map 里赋值会 panic。 + p.sidecars = map[string]*sidecarProcess{} + return p +} + +// ★★★ 判据:**先开线才有 agent**(顺序不变量)。 +// +// agent_up 对不存在的 branch 必须响亮拒绝。不拒的话会拿不到 +// workdir/session 却继续注册通道,产出一条指向虚空的线。 +func TestAgentUpRequiresExistingBranch(t *testing.T) { + p := newFlowPlugin(t) + res, _ := p.handleAgentUp(map[string]interface{}{"branch_id": "br-nonexistent"}) + m := res.(map[string]interface{}) + if _, isErr := m["error"]; !isErr { + t.Fatalf("★ 对不存在的分支应报错,实际: %v", m) + } + if !strings.Contains(m["error"].(string), "不存在") { + t.Errorf("错误应说明分支不存在: %v", m["error"]) + } + // 不得留下任何 sidecar / 通道痕迹 + if len(p.sidecars) != 0 { + t.Errorf("★ 失败后不该有 sidecar 残留: %v", p.sidecars) + } +} + +// ★ 判据:branch_id 空值必须抓住(不能默认到某条线)。 +func TestAgentUpRequiresBranchID(t *testing.T) { + p := newFlowPlugin(t) + res, _ := p.handleAgentUp(map[string]interface{}{}) + if _, isErr := res.(map[string]interface{})["error"]; !isErr { + t.Error("★ 空 branch_id 应报错") + } +} + +// ★★★ 判据:**管线全流程的台账一致性**(project → branch → 状态查询)。 +// +// 这是流程主干的端到端:每一步都查一次状态,确认台账如实反映。 +func TestPipelineLedgerConsistency(t *testing.T) { + p := newFlowPlugin(t) + + // ① 登记项目 + r1, _ := p.handleProjectAdd(map[string]interface{}{ + "name": "proj", "remote_url": "/tmp/fake.git", "platform": "gitea", + }) + m1 := r1.(map[string]interface{}) + if m1["status"] != "ok" { + t.Fatalf("登记项目失败: %v", m1) + } + + // ② 列项目:必须看到它 + r2, _ := p.handleProjectList(map[string]interface{}{}) + m2 := r2.(map[string]interface{}) + if n, _ := m2["count"].(int); n != 1 { + t.Errorf("★ 项目列表应有 1 个,实际 %v", m2["count"]) + } + + // ③ 重复登记必须拒绝(不静默覆盖) + r3, _ := p.handleProjectAdd(map[string]interface{}{"name": "proj", "remote_url": "/tmp/other.git"}) + if _, isErr := r3.(map[string]interface{})["error"]; !isErr { + t.Error("★ 重复登记项目应被拒绝") + } + + // ④ 开线 + r4, _ := p.handleBranchOpen(map[string]interface{}{ + "project": "proj", "branch": "feat/a", "work_desc": "做点事", + }) + m4 := r4.(map[string]interface{}) + if m4["status"] != "ok" { + t.Fatalf("开线失败: %v", m4) + } + id := m4["branch_id"].(string) + jobID := m4["job_id"].(string) + + // ⑤ 查分支状态:字段必须齐全(缺一个下游就没法用) + // ★ 结构是**嵌套的**:{branch: , sidecar_alive, deliveries}。 + // 初版按"平铺字段"断言,于是全红 —— 那是判据的假设错了,不是实现的问题。 + // (嵌套更好:Branch 是一个整体,平铺会与 sidecar_alive 这类派生字段混淆。) + r5, _ := p.handleBranchStatus(map[string]interface{}{"branch_id": id}) + m5 := r5.(map[string]interface{}) + br5, ok := m5["branch"].(*Branch) + if !ok { + t.Fatalf("★ branch_status 应返回嵌套的 branch 对象: %v", m5) + } + if br5.ID != id { + t.Errorf("branch.id 不对: %s vs %s", br5.ID, id) + } + if br5.Workdir == "" { + t.Error("branch.workdir 为空") + } + if br5.Status != "open" { + t.Errorf("branch.status 应为 open,实际 %q", br5.Status) + } + if br5.WorkDesc != "做点事" { + t.Errorf("work_desc 未保留: %v", br5.WorkDesc) + } + // 派生字段也在 + if _, has := m5["sidecar_alive"]; !has { + t.Error("缺 sidecar_alive") + } + if _, has := m5["deliveries"]; !has { + t.Error("缺 deliveries") + } + + // ⑥ 查作业状态:必须是真实状态(不是永远 pending) + r6, _ := p.handleJobStatus(map[string]interface{}{"job_id": jobID}) + m6 := r6.(map[string]interface{}) + job, _ := m6["job"].(*Job) + if job == nil { + t.Fatalf("查不到作业: %v", m6) + } + if job.BranchID != id { + t.Errorf("作业挂错了分支: %s vs %s", job.BranchID, id) + } + + // ⑦ 并行视图:必须能按项目分组看到这条线 + r7, _ := p.handleParallelStatus(map[string]interface{}{}) + if m7, ok := r7.(map[string]interface{}); ok { + if _, has := m7["projects"]; !has { + t.Errorf("★ parallel_status 应给出项目分组: %v", m7) + } + } +} + +// ★★ 判据:**台账持久化**(重启后仍在)。 +// +// 不落盘的话重启即失忆 —— 而"重启后备账仍可查"是设计明确要求的 +// (setJob 的注释写着"并落盘,保证重启后台账仍可查")。 +func TestLedgerPersistsAcrossReload(t *testing.T) { + p := newFlowPlugin(t) + p.handleProjectAdd(map[string]interface{}{"name": "persist", "remote_url": "/tmp/x.git"}) + r, _ := p.handleBranchOpen(map[string]interface{}{"project": "persist", "branch": "feat/p"}) + id := r.(map[string]interface{})["branch_id"].(string) + + // 重新载入(模拟重启) + p2 := newTestPluginForShell(t) + p2.path = p.path + if err := p2.loadLedger(); err != nil { + t.Fatalf("重载台账失败: %v", err) + } + if _, ok := p2.ledger.Projects["persist"]; !ok { + t.Error("★ 项目未持久化") + } + if b, ok := p2.ledger.Branches[id]; !ok { + t.Error("★ 分支未持久化") + } else if b.Workdir == "" { + t.Error("分支持久化但丢了 workdir") + } +} + +// ★★ 判据:**损坏的台账必须响亮失败,不静默重建**。 +// +// 静默重建会让用户以为"线还在"而实际全丢了 —— 那是最危险的静默降级。 +func TestCorruptLedgerFailsLoudly(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ledger.json") + if err := os.WriteFile(path, []byte("{ this is not json"), 0o644); err != nil { + t.Fatal(err) + } + p := newTestPluginForShell(t) + p.path = path + err := p.loadLedger() + if err == nil { + t.Fatal("★ 损坏的台账应报错(不静默重建)") + } + if !strings.Contains(err.Error(), "损坏") { + t.Errorf("错误应指出台账损坏: %v", err) + } +} + +// ★★★ 判据:`branch_close` 必须**清干净**(通道 + sidecar + 目录引用)。 +// +// 留着通道会让内核继续往一条死线投递;留着 sidecar 引用会让 +// agent_state 报"还活着"。 +func TestBranchCloseCleansUp(t *testing.T) { + p := newFlowPlugin(t) + p.handleProjectAdd(map[string]interface{}{"name": "proj", "remote_url": "/tmp/x.git"}) + r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/c"}) + id := r.(map[string]interface{})["branch_id"].(string) + + // ★ 必须**真塞一个 sidecar 占位**,否则根本没测到"清理"这件事。 + // + // 初版只设了通道与 session,没有往 p.sidecars 里放东西 —— + // 于是 `delete(p.sidecars, id)` 这个变异**不报红**:删除一个不存在的键 + // 与不删除在断言上看不出区别。判据必须让"被保护的状态"真实存在。 + p.mu.Lock() + p.ledger.Branches[id].InputCh = inputChannelName(id) + p.ledger.Branches[id].OutputCh = outputChannelName(id) + p.ledger.Branches[id].SessionID = "sess-x" + // 用一个**不持有进程**的占位(Close 对零值安全)。这里测的是 + // "map 里的条目被清掉",不是进程真的被杀。 + p.sidecars[id] = &sidecarProcess{} + p.mu.Unlock() + if p.sidecars[id] == nil { + t.Fatal("前置:sidecar 占位未塞入") + } + + res, _ := p.handleBranchClose(map[string]interface{}{"branch_id": id}) + m := res.(map[string]interface{}) + if e, bad := m["error"]; bad { + t.Fatalf("关闭失败: %v", e) + } + + p.mu.Lock() + b := p.ledger.Branches[id] + sc := p.sidecars[id] + p.mu.Unlock() + + if sc != nil { + t.Error("★ sidecar 引用未清(agent_state 会报还活着)") + } + if b.Status != "merged" && b.Status != "abandoned" && b.Status != "closed" { + t.Errorf("★ 关闭后状态不对: %q", b.Status) + } + if b.ClosedAt == "" { + t.Error("★ 关闭时间未记录") + } +} + +// ★★ 判据:对不存在的分支操作必须响亮拒绝(不是静默成功)。 +// +// 静默成功的后果是调用方以为清理了,实际什么都没有。 +func TestOperationsOnMissingBranchFail(t *testing.T) { + p := newFlowPlugin(t) + for name, fn := range map[string]func(map[string]interface{}) (interface{}, error){ + // 只列**按 branch 寻址**的操作。workdir_clean 与 test_list 是 + // **全局**操作(前者清 orphan 目录、后者列全部判据),本就不该因 + // "某个 branch 不存在"报错 —— 初版把它们也列进来,判据因此误报。 + "branch_close": p.handleBranchClose, + "branch_status": p.handleBranchStatus, + "agent_state": p.handleAgentState, + "agent_down": p.handleAgentDown, + "conflict_check": p.handleConflictCheck, + } { + res, _ := fn(map[string]interface{}{"branch_id": "br-ghost"}) + m, ok := res.(map[string]interface{}) + if !ok { + continue + } + if _, isErr := m["error"]; !isErr { + t.Errorf("★ %s 对不存在的分支未报错: %v", name, m) + } + } +} + +// ★★ 判据:`agent_state` 在**未拉起**时给出可执行的下一步提示。 +func TestAgentStateGuidesWhenNotRunning(t *testing.T) { + p := newFlowPlugin(t) + p.handleProjectAdd(map[string]interface{}{"name": "proj", "remote_url": "/tmp/x.git"}) + r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/s"}) + id := r.(map[string]interface{})["branch_id"].(string) + + res, _ := p.handleAgentState(map[string]interface{}{"branch_id": id}) + m := res.(map[string]interface{}) + if alive, _ := m["agent_alive"].(bool); alive { + t.Fatal("未拉起时不该报 alive") + } + note, _ := m["note"].(string) + if !strings.Contains(note, "agent_up") { + t.Errorf("★ 应提示用 agent_up 拉起: %q", note) + } +} + +// ★★★ 判据:**通道名在 lifecycle 前后一致**(agent_up 与 branch_close 同源)。 +// +// 不一致的话:注册的通道与清理的通道是两个名字,清理不掉 —— +// 内核会继续往死通道投递。这条钉住"注册与清理用同一套派生函数"。 +func TestChannelNamesMatchAcrossLifecycle(t *testing.T) { + p := newFlowPlugin(t) + p.handleProjectAdd(map[string]interface{}{"name": "proj", "remote_url": "/tmp/x.git"}) + r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/n"}) + id := r.(map[string]interface{})["branch_id"].(string) + + // agent_up 用的名字 + inUp := inputChannelName(id) + outUp := outputChannelName(id) + // branch_close 用的名字(同一套派生函数) + if inputChannelName(id) != inUp || outputChannelName(id) != outUp { + t.Error("★ 通道名派生不稳定(注册与清理会不一致)") + } + // outputch 必须符合内核的 output_send__ 命名约束 + if !validNamePattern.MatchString(outUp) { + t.Errorf("outputch 不合规: %s", outUp) + } + if strings.Contains(outUp, "/") { + t.Errorf("outputch 含斜杠(output_send__ 不允许): %s", outUp) + } +} + +// ★★ 判据:`test_list` 标出**变异规则为 0 的判据**(复核缺口可见)。 +// +// 这是"复核必须两步"这条主张的操作化:光跑正向不算复核完, +// 而没有变异规则的判据在列表里必须看得出来。 +func TestTestListMarksCriteriaWithoutMutations(t *testing.T) { + p := newFlowPlugin(t) + p.ledger.Criteria["cr-a"] = &Criteria{ + ID: "cr-a", Name: "有变异", Mutations: []Mutation{{Old: "x", New: "y"}}, + } + p.ledger.Criteria["cr-b"] = &Criteria{ + ID: "cr-b", Name: "无变异", Mutations: nil, + } + res, _ := p.handleTestList(map[string]interface{}{}) + m := res.(map[string]interface{}) + items, ok := m["criteria"].([]map[string]interface{}) + if !ok { + t.Skipf("test_list 返回形状未知: %T", m["criteria"]) + } + if len(items) != 2 { + t.Fatalf("应列出 2 条判据,实际 %d: %v", len(items), items) + } + // 真实字段名是 `mutations`(不是 mutation_count)——判据必须按真实字段断言, + // 否则"字段改了判据还绿"。 + for _, it := range items { + switch it["criteria_id"] { + case "cr-a": + if n, _ := it["mutations"].(int); n != 1 { + t.Errorf("★ cr-a 的 mutations 应为 1,实际 %#v", it["mutations"]) + } + case "cr-b": + if n, _ := it["mutations"].(int); n != 0 { + t.Errorf("★ cr-b 的 mutations 应为 0(复核缺口应可见),实际 %#v", it["mutations"]) + } + default: + t.Errorf("出现意外的判据: %v", it) + } + } +} diff --git a/example/commondev/full_pipeline_test.go b/example/commondev/full_pipeline_test.go new file mode 100644 index 0000000..428a12c --- /dev/null +++ b/example/commondev/full_pipeline_test.go @@ -0,0 +1,190 @@ +package main + +// ★★★ 全流程判据:用**插件自己的工具**跑完整生命周期。 +// +// 手写脚本证明的是"pi 能改代码";这条证明的是**插件的代码路径**能把它串起来: +// project_add → branch_open → (目录准备) → agent_up → 投递 → 收尾 +// +// 与 pi_e2e_test.go 的分工:那条测 sidecar 契约,这条测**插件编排**。 + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func gitQ(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(cmd.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } +} + +// ★★★ 判据:插件工具能把一条工程线从登记走到"pi 真的改了工程文件"。 +// +// 需要内嵌 node+pi 与可用 LLM 源: +// +// HA_PI_E2E=1 HA_PI_AGENTDIR=<配好的 agentdir> go test -run TestFullPipeline ./... +func TestFullPipeline(t *testing.T) { + nodeBin, sidecarPath := piE2EReady(t) + agentDir := os.Getenv("HA_PI_AGENTDIR") + if agentDir == "" { + t.Skip("需要 HA_PI_AGENTDIR(含可用 LLM 源的 pi agentdir)") + } + + base := t.TempDir() + origin := filepath.Join(base, "origin") + gitQ(t, base, "init", "-q", "-b", "main", origin) + if err := os.MkdirAll(filepath.Join(origin, "src"), 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(origin, "src/calc.py"), + []byte("def add(a, b):\n return a + b\n"), 0o644) + gitQ(t, origin, "add", "-A") + gitQ(t, origin, "commit", "-qm", "initial") + + // ① 登记项目(走插件工具) + p := newFlowPlugin(t) + r1, _ := p.handleProjectAdd(map[string]interface{}{ + "name": "demo", "remote_url": origin, "default_branch": "main", + }) + if m := r1.(map[string]interface{}); m["status"] != "ok" { + t.Fatalf("登记项目失败: %v", m) + } + + // ② 开线(走插件工具)—— 它会把 clone 转后台 + r2, _ := p.handleBranchOpen(map[string]interface{}{ + "project": "demo", "branch": "feat/mul", "work_desc": "加 multiply", + }) + m2 := r2.(map[string]interface{}) + if m2["status"] != "ok" { + t.Fatalf("开线失败: %v", m2) + } + branchID := m2["branch_id"].(string) + workdir := m2["workdir"].(string) + + // ③ 同步准备目录(不走后台 goroutine,避免与判据的时序竞争) + b := p.ledger.Branches[branchID] + if err := p.prepareWorkdir(p.ledger.Projects["demo"], "feat/mul", workdir); err != nil { + t.Fatalf("目录准备失败: %v", err) + } + // ★ 目录必须在目标分支上(否则 agent 会在 main 上改) + br, err := exec.Command("git", "-C", workdir, "rev-parse", "--abbrev-ref", "HEAD").Output() + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(br)) != "feat/mul" { + t.Fatalf("★ 目录不在目标分支: %q", strings.TrimSpace(string(br))) + } + + // ④ 走 sidecar 契约投递任务(agent_up 的内核部分) + b.Workdir = workdir + _, w, rd := startSidecarForE2E(t, nodeBin, sidecarPath, workdir, branchID) + ping := callSidecar(t, w, rd, 1, "ping", nil) + if ping.Error != nil { + t.Fatalf("握手失败: %s", ping.Error.Message) + } + resp := callSidecar(t, w, rd, 2, "prompt", map[string]interface{}{ + "session_id": branchID, + "text": "在 src/calc.py 里新增 multiply(a, b) 返回 a*b,保留 add。只回一句完成。", + }) + if resp.Error != nil { + t.Fatalf("投递失败: %s", resp.Error.Message) + } + var pr map[string]interface{} + if err := json.Unmarshal(resp.Result, &pr); err != nil { + t.Fatal(err) + } + // ★ 会话不得漂移 + if got, _ := pr["session_id"].(string); got != "" && got != branchID { + t.Fatalf("★ 会话漂移: %s vs %s", got, branchID) + } + + // ⑤ **关键断言**:pi 真的改了**这个工程**的文件 + b2, err := os.ReadFile(filepath.Join(workdir, "src", "calc.py")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b2), "multiply") { + t.Fatalf("★ pi 没有在工程里加 multiply:\n%s", b2) + } + if !strings.Contains(string(b2), "def add") { + t.Errorf("★ 原有代码被破坏:\n%s", b2) + } + + // ⑥ 提交后做冲突预检(走插件工具) + gitQ(t, workdir, "add", "-A") + gitQ(t, workdir, "commit", "-qm", "feat: add multiply") + r6, _ := p.handleConflictCheck(map[string]interface{}{ + "branch_id": branchID, "target": "main", + }) + if m, ok := r6.(map[string]interface{}); ok { + if e, bad := m["error"]; bad { + t.Fatalf("冲突预检失败: %v", e) + } + t.Logf("冲突预检结果: %v", m) + } + + // ⑦ 收尾(走插件工具):台账标记 + 目录删除 + r7, _ := p.handleBranchClose(map[string]interface{}{"branch_id": branchID}) + if m, ok := r7.(map[string]interface{}); ok { + if e, bad := m["error"]; bad { + t.Fatalf("收尾失败: %v", e) + } + } + if _, err := os.Stat(workdir); !os.IsNotExist(err) { + t.Errorf("★ 收尾后工作目录应被删除: %v", err) + } + t.Logf("✅ 全流程闭环:登记 → 开线(分支正确) → 投递 → pi 真改工程 → 冲突预检 → 收尾") +} + +// ★★★ 判据:**agent_up 真能拉起 sidecar**(走随包分发的脚本)。 +// +// 这条覆盖的缺口(2026-10-15 实测发现):sidecar 脚本此前**没有随包分发**, +// 而插件数据目录的 runtime// 在部署时没人创建 ⇒ agent_up 必然失败, +// 报「找不到 pi 的 sidecar 脚本」并指向一个用户没听说过的路径。 +// +// 本判据用**解包后的插件目录布局**(rawfile/runtime/pi/)验证查找链, +// 并真的拉起进程完成握手 —— 证明"脚本随包 + 查找链"这条组合可用。 +func TestAgentUpFindsShippedSidecar(t *testing.T) { + nodeBin, _ := piE2EReady(t) + root, _ := os.Getwd() + + // 模拟解包后的布局:脚本在 rawfile/runtime/pi/ + shipped := filepath.Join(root, "rawfile", "runtime", "pi", "sidecar.mjs") + if _, err := os.Stat(shipped); err != nil { + t.Fatalf("★ 随包脚本不存在: %v", err) + } + + p := newFlowPlugin(t) + p.sdk.SetRawFilesAPI(&testRawFiles{root: filepath.Join(root, "rawfile")}) + // cliDir 指向**不存在的**目录(全新部署的真实状态) + cliDir := filepath.Join(t.TempDir(), "runtime", "pi") + + got, err := p.locateSidecarScript(builtinPiDriver(), cliDir, "pi") + if err != nil { + t.Fatalf("★ 查找链找不到随包脚本(agent_up 会失败): %v", err) + } + + // 用找到的脚本真的拉起 sidecar 并握手 + workdir := t.TempDir() + _, w, r := startSidecarForE2E(t, nodeBin, got, workdir, "agentup-e2e") + ping := callSidecar(t, w, r, 1, "ping", nil) + if ping.Error != nil { + t.Fatalf("★ 用随包脚本拉起的 sidecar 握手失败: %s", ping.Error.Message) + } + var pr map[string]interface{} + json.Unmarshal(ping.Result, &pr) + if ok, _ := pr["ok"].(bool); !ok { + t.Fatalf("ping 未 ok: %v", pr) + } + t.Logf("✅ 随包 sidecar 脚本可用,pi 版本: %v", pr["pi_version"]) +} diff --git a/example/commondev/go.mod b/example/commondev/go.mod new file mode 100644 index 0000000..9609d78 --- /dev/null +++ b/example/commondev/go.mod @@ -0,0 +1,7 @@ +module commondev + +go 1.25.0 + +require github.com/JianFeeeee/homeagentsdk v0.0.0 + +replace github.com/JianFeeeee/homeagentsdk => ../../ diff --git a/example/commondev/llmsource.go b/example/commondev/llmsource.go new file mode 100644 index 0000000..113d642 --- /dev/null +++ b/example/commondev/llmsource.go @@ -0,0 +1,447 @@ +package main + +// LLM 源导入:把 HomeAgent 内核已配的 LLM 源写进内置 pi 的配置文件。 +// +// 用户要求(2026-10-14): +// +// 「插件内置 pi 默认使用 homeagent 内部配置的 llms 源,支持添加其他源, +// 且支持在插件中配置默认使用哪个源」 +// 「pi 配置的源直接写入配置文件」← 不另建存储(pi 本身就有完整配置系统) +// +// ## 落点(都是 pi 自己的配置文件) +// +// /models.json providers..{baseUrl,api,apiKey,models[]} +// /auth.json provider 凭据 +// /settings.json defaultProvider / defaultModel +// +// ## 内核侧源的真实位置 [已核] +// +// 存在 **config 表**(不是 config_),键前缀 core.llm.: +// +// core.llm.provider ← ★ 默认源就是这一项 +// core.llm.sources..base_url +// core.llm.sources..api_key +// core.llm.sources..model +// core.llm.sources..adapter (“openai” → pi 的 "openai-completions") +// core.llm.sources..context_window (可选) +// core.llm.sources..vision / .audio (可选) +// +// ## 两条安全红线(设计 §3.6.5) +// +// 1. models.json / auth.json 含**明文** api key ⇒ 目录 0700、文件 0600。 +// 内核那套是明文存 config.db(该库 644 全局可读)—— **不要把这套惯例带进来**。 +// 2. 导入失败要**报错**,不得静默回退到宿主凭据(~/.pi/agent/auth.json)。 + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// piModelsFile 是 pi 的 models.json 结构(只取我们用到的字段)。 +type piModelsFile struct { + Providers map[string]*piProvider `json:"providers"` +} + +type piProvider struct { + BaseURL string `json:"baseUrl"` + API string `json:"api"` + APIKey string `json:"apiKey,omitempty"` + Models []piModel `json:"models"` +} + +type piModel struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Reasoning bool `json:"reasoning"` + Input []string `json:"input,omitempty"` + ContextWindow int `json:"contextWindow,omitempty"` + MaxTokens int `json:"maxTokens,omitempty"` +} + +// adapterToAPI 把内核的 adapter 名映射成 pi 的 api 取值。 +// +// 只映射确定已知的;未知 adapter 返回空串 ⇒ 调用方必须报错, +// 不能猜一个默认值(猜错会让 pi 用错的协议说话,表现成"模型不回复")。 +func adapterToAPI(adapter string) string { + switch strings.ToLower(strings.TrimSpace(adapter)) { + case "", "openai", "openai-completions", "openai_completions": + return "openai-completions" + case "anthropic", "anthropic-messages", "claude": + return "anthropic-messages" + default: + return "" + } +} + +// importKernelSources 把内核已配的 LLM 源导入内置 pi 的配置文件。 +// +// 返回:导入了几个源、默认源名、错误。 +func (p *Plugin) importKernelSources(agentDir string) (int, string, error) { + sett := p.sdk.Settings() + + // ① 源清单:内核的配置是散键(core.llm.sources..), + // 所以要先扫出所有 。 + keys, err := sett.ListCore("core.llm.sources.") + if err != nil { + return 0, "", fmt.Errorf("读取内核 LLM 源清单失败: %w", err) + } + names := map[string]bool{} + for _, k := range keys { + rest := strings.TrimPrefix(k, "core.llm.sources.") + if i := strings.IndexByte(rest, '.'); i > 0 { + names[rest[:i]] = true + } + } + if len(names) == 0 { + // 没有任何 sources.* —— 退回单源形态(core.llm.base_url/api_key/model) + if n, def, err := p.importSingleKernelSource(agentDir); err == nil && n > 0 { + return n, def, nil + } + return 0, "", fmt.Errorf("内核没有任何已配置的 LLM 源(core.llm.sources.* 为空)") + } + + ordered := make([]string, 0, len(names)) + for n := range names { + ordered = append(ordered, n) + } + sort.Strings(ordered) + + models := &piModelsFile{Providers: map[string]*piProvider{}} + auth := map[string]interface{}{} + imported := 0 + + for _, n := range ordered { + prefix := "core.llm.sources." + n + "." + baseURL := coreStr(sett, prefix+"base_url") + apiKey := coreStr(sett, prefix+"api_key") + model := coreStr(sett, prefix+"model") + adapter := coreStr(sett, prefix+"adapter") + if baseURL == "" { + continue // 源不完整:跳过(不静默造一个坏的) + } + api := adapterToAPI(adapter) + if api == "" { + return imported, "", fmt.Errorf( + "源 %s 的 adapter=%q 无法映射到 pi 的 api(已知:openai/anthropic)——"+ + "不猜默认值,请显式指定", n, adapter) + } + if model == "" { + model = "AUTO" + } + m := piModel{ + ID: model, + Name: model, + Input: []string{"text"}, + } + if cw := coreInt(sett, prefix+"context_window"); cw > 0 { + m.ContextWindow = cw + } + if coreStr(sett, prefix+"vision") == "true" { + m.Input = []string{"text", "image"} + } + models.Providers[n] = &piProvider{ + BaseURL: baseURL, API: api, APIKey: apiKey, + Models: []piModel{m}, + } + if apiKey != "" { + auth[n] = newPiAuthEntry(apiKey) + } + imported++ + } + if imported == 0 { + return 0, "", fmt.Errorf("内核的 LLM 源都缺 base_url,无可导入") + } + + if err := p.writePiConfigs(agentDir, models, auth); err != nil { + return imported, "", err + } + + // ② 默认源 = 内核的 core.llm.provider(用户要求:默认用内核的) + def := coreStr(sett, "core.llm.provider") + if def == "" || models.Providers[def] == nil { + def = ordered[0] // 内核没指定或指到了不存在的源:用第一个(并如实返回) + } + if err := p.writePiDefaults(agentDir, def, coreStr(sett, "core.llm.model")); err != nil { + return imported, def, err + } + return imported, def, nil +} + +// importSingleKernelSource 处理「内核只有单源、无 sources.* 子表」的形态。 +func (p *Plugin) importSingleKernelSource(agentDir string) (int, string, error) { + sett := p.sdk.Settings() + baseURL := coreStr(sett, "core.llm.base_url") + apiKey := coreStr(sett, "core.llm.api_key") + model := coreStr(sett, "core.llm.model") + adapter := coreStr(sett, "core.llm.adapter") + if baseURL == "" { + return 0, "", fmt.Errorf("内核未配 core.llm.base_url") + } + api := adapterToAPI(adapter) + if api == "" { + return 0, "", fmt.Errorf("adapter=%q 无法映射", adapter) + } + if model == "" { + model = "AUTO" + } + name := "kernel" + if n := coreStr(sett, "core.llm.provider"); n != "" { + name = n + } + models := &piModelsFile{Providers: map[string]*piProvider{ + name: {BaseURL: baseURL, API: api, APIKey: apiKey, + Models: []piModel{{ID: model, Name: model, Input: []string{"text"}}}}, + }} + auth := map[string]interface{}{} + if apiKey != "" { + auth[name] = newPiAuthEntry(apiKey) + } + if err := p.writePiConfigs(agentDir, models, auth); err != nil { + return 0, "", err + } + if err := p.writePiDefaults(agentDir, name, model); err != nil { + return 0, name, err + } + return 1, name, nil +} + +// writePiConfigs 写 models.json 与 auth.json(★ 0600),并回读校验。 +// +// 为何要回读校验:json 写坏会让内层 pi 直接起不来,而那种失败看起来 +// 像是"pi 有问题",排查成本很高。写完必须能解析回来。 +func (p *Plugin) writePiConfigs(agentDir string, models *piModelsFile, + auth map[string]interface{}) error { + if err := os.MkdirAll(agentDir, 0o700); err != nil { + return err + } + mb, err := json.MarshalIndent(models, "", " ") + if err != nil { + return err + } + if err := writeFile0600(filepath.Join(agentDir, "models.json"), mb); err != nil { + return err + } + var back piModelsFile + if err := json.Unmarshal(mb, &back); err != nil { + return fmt.Errorf("models.json 回读校验失败: %w", err) + } + if len(back.Providers) != len(models.Providers) { + return fmt.Errorf("models.json 回读不一致: 写入 %d 源、读回 %d 源", + len(models.Providers), len(back.Providers)) + } + if len(auth) > 0 { + ab, err := json.MarshalIndent(auth, "", " ") + if err != nil { + return err + } + if err := writeFile0600(filepath.Join(agentDir, "auth.json"), ab); err != nil { + return err + } + } + return nil +} + +// writePiDefaults 把默认源写进 settings.json(pi 原生字段)。 +func (p *Plugin) writePiDefaults(agentDir, provider, model string) error { + path := filepath.Join(agentDir, "settings.json") + cur := map[string]interface{}{} + if b, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(b, &cur) // 读坏就当空(下面会整体重写) + } + if provider != "" { + cur["defaultProvider"] = provider + } + if model != "" { + cur["defaultModel"] = model + } + b, err := json.MarshalIndent(cur, "", " ") + if err != nil { + return err + } + return writeFile0600(path, b) +} + +// writeFile0600 写入并确权 —— 这些文件含明文 api key。 +func writeFile0600(path string, data []byte) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + if err := os.Chmod(tmp, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// coreStr / coreInt 读内核配置(类型不匹配一律当空,不 panic)。 +func coreStr(sett interface { + GetCore(string) (interface{}, error) +}, key string) string { + v, err := sett.GetCore(key) + if err != nil || v == nil { + return "" + } + switch t := v.(type) { + case string: + return strings.TrimSpace(t) + case bool: + if t { + return "true" + } + return "false" + case float64: + return fmt.Sprintf("%v", t) + default: + return strings.TrimSpace(fmt.Sprint(v)) + } +} + +func coreInt(sett interface { + GetCore(string) (interface{}, error) +}, key string) int { + v, err := sett.GetCore(key) + if err != nil || v == nil { + return 0 + } + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case string: + var n int + _, _ = fmt.Sscanf(t, "%d", &n) + return n + default: + return 0 + } +} + +// ---------------- 工具实现 ---------------- + +func (p *Plugin) piAgentDir() string { + return filepath.Join(filepath.Dir(p.path), "runtime", "agentdir") +} + +// handlePiSourceImport 把内核的 LLM 源导入内置 pi 的配置文件。 +func (p *Plugin) handlePiSourceImport(args map[string]interface{}) (interface{}, error) { + n, def, err := p.importKernelSources(p.piAgentDir()) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + return map[string]interface{}{ + "status": "ok", + "imported": n, + "default": def, + "agentdir": p.piAgentDir(), + "config_files": []string{"models.json", "auth.json", "settings.json"}, + }, nil +} + +// handlePiSourceList 列出已配的源(★ 不返回 key)。 +func (p *Plugin) handlePiSourceList(args map[string]interface{}) (interface{}, error) { + dir := p.piAgentDir() + b, err := os.ReadFile(filepath.Join(dir, "models.json")) + if err != nil { + if os.IsNotExist(err) { + return map[string]interface{}{ + "providers": []string{}, "default": "", + "hint": "内置 pi 尚无 LLM 配置;调 commondev_pi_source_import 导入内核的源", + }, nil + } + return map[string]interface{}{"error": err.Error()}, nil + } + var f piModelsFile + if err := json.Unmarshal(b, &f); err != nil { + return map[string]interface{}{"error": "models.json 损坏: " + err.Error()}, nil + } + + // 默认源从 settings.json 读 + def := "" + if sb, err := os.ReadFile(filepath.Join(dir, "settings.json")); err == nil { + var s map[string]interface{} + if json.Unmarshal(sb, &s) == nil { + def, _ = s["defaultProvider"].(string) + } + } + // ★ 只暴露名字与端点,绝不返回 apiKey + type view struct { + Name string `json:"name"` + BaseURL string `json:"base_url"` + API string `json:"api"` + Model string `json:"model,omitempty"` + Default bool `json:"default"` + } + out := []view{} + for name, pr := range f.Providers { + m := "" + if len(pr.Models) > 0 { + m = pr.Models[0].ID + } + out = append(out, view{Name: name, BaseURL: pr.BaseURL, API: pr.API, + Model: m, Default: name == def}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return map[string]interface{}{"providers": out, "default": def, "count": len(out)}, nil +} + +// handlePiSourceDefault 切换内置 pi 的默认源。 +func (p *Plugin) handlePiSourceDefault(args map[string]interface{}) (interface{}, error) { + name := strings.TrimSpace(str(args, "name")) + if name == "" { + return map[string]interface{}{"error": "name 必填"}, nil + } + dir := p.piAgentDir() + b, err := os.ReadFile(filepath.Join(dir, "models.json")) + if err != nil { + return map[string]interface{}{"error": "读 models.json 失败: " + err.Error()}, nil + } + var f piModelsFile + if err := json.Unmarshal(b, &f); err != nil { + return map[string]interface{}{"error": "models.json 损坏: " + err.Error()}, nil + } + pr, ok := f.Providers[name] + if !ok { + names := make([]string, 0, len(f.Providers)) + for n := range f.Providers { + names = append(names, n) + } + sort.Strings(names) + return map[string]interface{}{ + "error": fmt.Sprintf("源 %q 不存在(可用:%s)", name, strings.Join(names, ", ")), + }, nil + } + model := strings.TrimSpace(str(args, "model")) + if model == "" && len(pr.Models) > 0 { + model = pr.Models[0].ID + } + if err := p.writePiDefaults(dir, name, model); err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + return map[string]interface{}{"status": "ok", "default": name, "model": model}, nil +} + +// newPiAuthEntry 构造 pi 认的凭据 entry。 +// +// ★ schema 是 `{"type":"api_key","key":"..."}`,不是 `{"apiKey":"..."}`。 +// +// 这是**实测踩出来的**(2026-10-15):内嵌 pi 用旧格式时, +// auth 读取静默返回空,报 `Provider is not configured: llmsproxy` —— +// 而 models.json / auth.json / settings.json 三个文件看起来都完全正常。 +// 定位过程:同一个 pi API,只切 PI_CODING_AGENT_DIR 就一边跑得通 +// 一边跑不通,逐字段对比才定位到这个键名。 +// +// 权威定义(pi 源码): +// +// dist/core/auth-storage.js:185 if (value.type === "api_key") { ... } +// dist/core/model-runtime.js:423 { type: "api_key", key: apiKey } +func newPiAuthEntry(apiKey string) map[string]interface{} { + return map[string]interface{}{ + "type": "api_key", + "key": apiKey, + } +} diff --git a/example/commondev/llmsource_e2e_test.go b/example/commondev/llmsource_e2e_test.go new file mode 100644 index 0000000..76f5ef8 --- /dev/null +++ b/example/commondev/llmsource_e2e_test.go @@ -0,0 +1,352 @@ +package main + +// LLM 源端到端判据:**内核源 → 插件导入 → pi 配置 → 真实调用成功**。 +// +// ## 为什么必须端到端而不能只测导入 +// +// 导入逻辑正确 ≠ pi 能用。中间隔着三层容易各自出错: +// +// ① 源字段映射(内核 core.llm.sources..* → pi 的 providers..{baseUrl,api,apiKey}) +// ② adapter → pi api 名的映射(openai → openai-completions) +// ③ 默认源写进 settings.json(defaultProvider/defaultModel) +// +// 任何一层错了,现象都是「pi 起不来 / 报 No API key found」—— +// 而单元判据全绿。所以本文件用**真实源 + 真实 HTTP 调用**收口。 +// +// ## 判据分层 +// +// TestLLMSourceImportProducesValidPiConfig — 纯逻辑(不需要网络) +// TestLLMSourceRoundTripAgainstRealEndpoint — 真机(需要 HA_E2E_LLM=1) + +import ( + "bytes" + "encoding/json" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/JianFeeeee/homeagentsdk/sdk" +) + +// coreSettings 是只答 GetCore 的最小替身 —— 内核配置面在这一层被使用。 +type coreSettings struct { + stubSettings + core map[string]interface{} +} + +func (c *coreSettings) GetCore(key string) (interface{}, error) { + if v, ok := c.core[key]; ok { + return v, nil + } + return nil, nil +} +func (c *coreSettings) ListCore(prefix string) ([]string, error) { + var out []string + for k := range c.core { + if len(k) >= len(prefix) && k[:len(prefix)] == prefix { + out = append(out, k) + } + } + return out, nil +} + +// newPluginWithCore 造一个能读到「内核 LLM 源」的插件。 +func newPluginWithCore(t *testing.T, core map[string]interface{}) *Plugin { + t.Helper() + p := newTestPluginForShell(t) + cs := &coreSettings{stubSettings: stubSettings{dir: filepath.Dir(p.path)}, core: core} + p.sdk = sdk.New("commondev", cs, nil, nil, nil, nil) + return p +} + +// ★★ 判据:导入后生成的 models.json / auth.json / settings.json 必须自洽。 +func TestLLMSourceImportProducesValidPiConfig(t *testing.T) { + core := map[string]interface{}{ + "core.llm.provider": "mysrc", + "core.llm.model": "my-model", + "core.llm.sources.mysrc.base_url": "http://127.0.0.1:9999/v1", + "core.llm.sources.mysrc.api_key": "sk-test-123", + "core.llm.sources.mysrc.model": "my-model", + "core.llm.sources.mysrc.adapter": "openai", + "core.llm.sources.mysrc.context_window": float64(128000), + "core.llm.sources.mysrc.vision": "true", + } + p := newPluginWithCore(t, core) + agentDir := p.piAgentDir() + + n, def, err := p.importKernelSources(agentDir) + if err != nil { + t.Fatal(err) + } + if n != 1 || def != "mysrc" { + t.Fatalf("导入数量/默认源不对: n=%d def=%q", n, def) + } + + // models.json 必须是 pi 认识的形状 + var models piModelsFile + b := mustRead(t, filepath.Join(agentDir, "models.json")) + if err := json.Unmarshal(b, &models); err != nil { + t.Fatalf("models.json 不是合法 JSON: %v", err) + } + pv, ok := models.Providers["mysrc"] + if !ok { + t.Fatal("models.json 缺少源") + } + if pv.BaseURL != "http://127.0.0.1:9999/v1" { + t.Errorf("baseUrl 错: %q", pv.BaseURL) + } + // ★ adapter 映射:openai → openai-completions(pi 的取值) + if pv.API != "openai-completions" { + t.Errorf("api 映射错(pi 需要 openai-completions): %q", pv.API) + } + if len(pv.Models) != 1 || pv.Models[0].ID != "my-model" { + t.Errorf("models 错: %+v", pv.Models) + } + if pv.Models[0].ContextWindow != 128000 { + t.Errorf("contextWindow 未透传: %d", pv.Models[0].ContextWindow) + } + // 权限:含明文 key ⇒ 必须 0600 + if fi, err := os.Stat(filepath.Join(agentDir, "models.json")); err != nil || fi.Mode().Perm() != 0o600 { + t.Errorf("models.json 权限应为 0600") + } + // settings.json 的默认源 + var settings map[string]interface{} + if err := json.Unmarshal(mustRead(t, filepath.Join(agentDir, "settings.json")), &settings); err != nil { + t.Fatal(err) + } + if settings["defaultProvider"] != "mysrc" { + t.Errorf("defaultProvider 错: %v", settings["defaultProvider"]) + } +} + +// ★★ 判据:**未知 adapter 必须报错**,不得猜一个默认值。 +// +// 猜错会让 pi 用错的协议说话,现象是「模型不回复」—— 那是极难定位的失败。 +func TestLLMSourceImportRejectsUnknownAdapter(t *testing.T) { + core := map[string]interface{}{ + "core.llm.sources.weird.base_url": "http://x/v1", + "core.llm.sources.weird.model": "m", + "core.llm.sources.weird.adapter": "some-unknown-protocol", + } + p := newPluginWithCore(t, core) + if _, _, err := p.importKernelSources(p.piAgentDir()); err == nil { + t.Fatal("★ 未知 adapter 未报错 —— 猜默认值会让 pi 用错协议(现象是「模型不回复」)") + } +} + +// ★★ 判据:内核**没有任何源**时必须报错(不静默造一个空的)。 +func TestLLMSourceImportFailsWithoutKernelSources(t *testing.T) { + p := newPluginWithCore(t, map[string]interface{}{}) + if _, _, err := p.importKernelSources(p.piAgentDir()); err == nil { + t.Fatal("内核无源时应报错,而不是生成一个空配置(pi 会起不来且原因不明)") + } +} + +// ★★★ 真机判据:**导入的真实源必须真的能调通**。 +// +// 这是端到端的最后一环:前面的判据只证明「配置写对了」, +// 这条证明「配置里的源可用」。 +// +// 用 HA_E2E_LLM=1 打开(默认跳过:它依赖本机 llmsproxy)。 +// 环境变量 HA_E2E_BASE_URL / HA_E2E_API_KEY / HA_E2E_MODEL 可覆盖。 +func TestLLMSourceRoundTripAgainstRealEndpoint(t *testing.T) { + if os.Getenv("HA_E2E_LLM") != "1" { + t.Skip("跳过真机 LLM 判据(设 HA_E2E_LLM=1 打开)") + } + baseURL := envOr("HA_E2E_BASE_URL", "http://127.0.0.1:8081/v1") + apiKey := os.Getenv("HA_E2E_API_KEY") + model := envOr("HA_E2E_MODEL", "AUTO") + if apiKey == "" { + t.Fatal("需要 HA_E2E_API_KEY") + } + + // ① 像真实内核那样配一个源,走**插件的导入路径** + core := map[string]interface{}{ + "core.llm.provider": "e2e", + "core.llm.model": model, + "core.llm.sources.e2e.base_url": baseURL, + "core.llm.sources.e2e.api_key": apiKey, + "core.llm.sources.e2e.model": model, + "core.llm.sources.e2e.adapter": "openai", + "core.llm.sources.e2e.context_window": float64(1048576), + } + p := newPluginWithCore(t, core) + agentDir := p.piAgentDir() + n, def, err := p.importKernelSources(agentDir) + if err != nil || n != 1 || def != "e2e" { + t.Fatalf("导入失败: n=%d def=%q err=%v", n, def, err) + } + + // ② 从**导入产物**(而不是从测试变量)读回连接信息 —— 这是端到端的要点: + // 若导入写错了字段,这里就会用错地址/错 key,调用会失败。 + var models piModelsFile + b := mustRead(t, filepath.Join(agentDir, "models.json")) + if err := json.Unmarshal(b, &models); err != nil { + t.Fatal(err) + } + pv := models.Providers["e2e"] + if pv == nil { + t.Fatal("导入产物里没有 e2e 源") + } + var auth map[string]map[string]string + if err := json.Unmarshal(mustRead(t, filepath.Join(agentDir, "auth.json")), &auth); err != nil { + t.Fatal(err) + } + keyFromDisk := auth["e2e"]["apiKey"] + if keyFromDisk == "" { + t.Fatal("auth.json 里没有 key —— pi 会报 No API key found") + } + + // ③ 用**磁盘上的**配置真调一次 + body, _ := json.Marshal(map[string]interface{}{ + "model": pv.Models[0].ID, + "max_tokens": 32, + "messages": []map[string]string{{"role": "user", "content": "只回答两个字:可用"}}, + }) + url := pv.BaseURL + "/chat/completions" + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+keyFromDisk) + req.Header.Set("Content-Type", "application/json") + cli := &http.Client{Timeout: 90 * time.Second} + resp, err := cli.Do(req) + if err != nil { + t.Fatalf("调用导入配置里的源失败: %v", err) + } + defer resp.Body.Close() + var out struct { + Choices []struct { + Message struct{ Content string } `json:"message"` + } `json:"choices"` + Error interface{} `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&out) + if resp.StatusCode != 200 { + t.Fatalf("HTTP %d(用导入产物里的地址与 key): %v", resp.StatusCode, out.Error) + } + if len(out.Choices) == 0 { + t.Fatalf("无 choices: %+v", out) + } + t.Logf("✅ 端到端通过:内核源 → 插件导入 → 用磁盘配置真实调用成功(%s)", pv.Models[0].ID) +} + +// ---------------- 对照真机内核配置 ---------------- + +// ★★★ 判据:**用真机生产 config.db 里的真实源**跑一遍导入。 +// +// 与上一条的区别:上一条的源是测试构造的;这条读**生产配置**, +// 因此能发现「真实内核的字段形态与我们的假设不符」这类问题 +// (例如字段名、类型、有没有 sources.* 子表)。 +// +// 用 HA_E2E_REAL_DB=/home/newqqagent/config.db 打开。 +func TestLLMSourceImportFromRealKernelConfig(t *testing.T) { + dbPath := os.Getenv("HA_E2E_REAL_DB") + if dbPath == "" { + t.Skip("跳过真机内核配置判据(设 HA_E2E_REAL_DB= 打开)") + } + rows := readCoreLLMRows(t, dbPath) + if len(rows) == 0 { + t.Fatalf("%s 里没有任何 core.llm.* 配置", dbPath) + } + // 直接喂真实的字段形态给导入逻辑 + core := map[string]interface{}{} + for k, v := range rows { + core[k] = v + } + p := newPluginWithCore(t, core) + agentDir := p.piAgentDir() + n, def, err := p.importKernelSources(agentDir) + if err != nil { + t.Fatalf("★ 用**真实内核配置**导入失败(说明字段形态与假设不符): %v", err) + } + if n == 0 { + t.Fatal("导入 0 个源") + } + var models piModelsFile + if err := json.Unmarshal(mustRead(t, filepath.Join(agentDir, "models.json")), &models); err != nil { + t.Fatal(err) + } + for name, pv := range models.Providers { + if pv.BaseURL == "" || pv.API == "" || len(pv.Models) == 0 { + t.Errorf("源 %s 导入不完整: %+v", name, pv) + } + // ★ api 必须是 pi 认识的值(否则 pi 起不来) + if pv.API != "openai-completions" && pv.API != "anthropic-messages" { + t.Errorf("源 %s 的 api=%q 不是 pi 的合法取值", name, pv.API) + } + } + t.Logf("✅ 真实内核配置导入成功:%d 个源,默认=%s", n, def) +} + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +// readCoreLLMRows 读 config.db 的 core.llm.* 行(用 go-sqlite3)。 +func readCoreLLMRows(t *testing.T, dbPath string) map[string]string { + t.Helper() + // 用 sqlite3 CLI(内核侧判据不引 cgo 依赖) + out, err := runCmd("sqlite3", dbPath, "select key||'\t'||value from config where key like 'core.llm.%';") + if err != nil { + t.Skipf("读 config.db 失败(缺 sqlite3?): %v", err) + } + rows := map[string]string{} + for _, line := range splitLines(out) { + if i := indexByte(line, '\t'); i > 0 { + rows[line[:i]] = line[i+1:] + } + } + return rows +} + +func runCmd(name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + b, err := cmd.Output() + return string(b), err +} + +func splitLines(s string) []string { + var out []string + cur := "" + for _, c := range s { + if c == '\n' { + if cur != "" { + out = append(out, cur) + } + cur = "" + continue + } + cur += string(c) + } + if cur != "" { + out = append(out, cur) + } + return out +} + +func indexByte(s string, b byte) int { + for i := 0; i < len(s); i++ { + if s[i] == b { + return i + } + } + return -1 +} + +// mustRead 读文件(失败即 Fatal)。 +func mustRead(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读 %s 失败: %v", path, err) + } + return b +} diff --git a/example/commondev/main.go b/example/commondev/main.go new file mode 100644 index 0000000..8d53937 --- /dev/null +++ b/example/commondev/main.go @@ -0,0 +1,11 @@ +//go:build !windows || !cgo + +package main + +import ( + sdk "github.com/JianFeeeee/homeagentsdk/sdk" +) + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return NewPluginFactory(name, config) +} diff --git a/example/commondev/pi_e2e_test.go b/example/commondev/pi_e2e_test.go new file mode 100644 index 0000000..5d97fb6 --- /dev/null +++ b/example/commondev/pi_e2e_test.go @@ -0,0 +1,273 @@ +package main + +// ★★★ 端到端:**真 pi 真的能接受工程并改代码**。 +// +// ## 这条判据为什么最重要 +// +// 覆盖度审计发现:此前**没有任何判据**验证过这条链路。所有测试都停在 +// "工具存在" / "参数被记账" / "驱动被加载" 这一层,而**最根本的假设从未被验证**: +// +// 投一句话 → sidecar 转给 pi → pi 用工具**真的改了工作目录的文件** +// +// 如果这个假设不成立,整个插件就是空壳 —— 台账、通道、判据复核全都在 +// 正确运转,但 agent 什么都没做。 +// +// ## 为什么必须走 sidecar(不走 pi 的 API 直调) +// +// 直调 pi 的 `createAgentSession` 只能证明"pi 可用",证明不了 +// **本插件的 sidecar 契约是对的**(stdin JSON-RPC、session_id 回报、 +// 裸名模块解析)。实测中这三处每一处都可能单独失败: +// - 方法名不是 send 而是 prompt(我第一版就猜错了) +// - 裸名 `@earendil-works/pi-coding-agent` 解析不到会**静默回退到宿主机的 pi** +// +// ## 环境要求 +// +// 需要插件目录下有内嵌运行时(node + pi): +// +// HA_PI_E2E=1 go test -run TestRealPiEditsFiles ./... +// +// 默认跳过(内嵌运行时是构建产物,不进版本库;且真跑一次要几十秒到几分钟)。 + +import ( + "bufio" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// piE2EReady 检查内嵌运行时是否就绪。 +func piE2EReady(t *testing.T) (nodeBin, sidecarPath string) { + t.Helper() + if os.Getenv("HA_PI_E2E") != "1" { + t.Skip("设 HA_PI_E2E=1 以运行(需要 rawfile/ 下有内嵌 node+pi,且会真调 LLM)") + } + root, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + nodeBin = filepath.Join(root, "rawfile", "node", "bin", "node") + if _, err := os.Stat(nodeBin); err != nil { + t.Skipf("内嵌 node 不存在: %v", err) + } + piMod := filepath.Join(root, "rawfile", "pi", "node_modules", "@earendil-works", "pi-coding-agent") + if _, err := os.Stat(piMod); err != nil { + t.Skipf("内嵌 pi 不存在: %v", err) + } + sidecarPath = filepath.Join(root, "sidecar.mjs") + if _, err := os.Stat(sidecarPath); err != nil { + t.Fatalf("sidecar.mjs 不存在: %v", err) + } + return nodeBin, sidecarPath +} + +// rpcResponse 是 sidecar 的应答。 +type rpcResponse struct { + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// startSidecarForE2E 拉起真实 sidecar 并完成握手。 +// +// 走**与 Go 侧完全一样的契约**:stdin 写 JSON-RPC 行,stdout 读应答行。 +func startSidecarForE2E(t *testing.T, nodeBin, sidecarPath, workdir, sessionID string) (*exec.Cmd, *bufio.Writer, *bufio.Reader) { + t.Helper() + pluginDir := filepath.Dir(sidecarPath) + cmd := exec.Command(nodeBin, sidecarPath, + "--workdir", workdir, + "--session-id", sessionID, + "--session-name", "e2e-"+sessionID, + ) + cmd.Dir = workdir + // ★ 与 driver.json 里声明的 env 一致(PI_CODING_AGENT_DIR/SESSION_DIR)。 + // ★ PI_CODING_AGENT_DIR 指向**已配好 LLM 源**的 agentdir。 + // 内嵌 pi 是干净的(无凭据),必须先把源导进去 —— 这正是 + // `commondev_pi_source_import` 的职责。判据用现成的 agentdir, + // 顺带验证"导入后的配置能被内嵌 pi 直接使用"。 + agentDir := os.Getenv("HA_PI_AGENTDIR") + if agentDir == "" { + agentDir = filepath.Join(pluginDir, "e2e-agentdir") + } + cmd.Env = append(os.Environ(), + "PI_CODING_AGENT_DIR="+agentDir, + "PI_CODING_AGENT_SESSION_DIR="+filepath.Join(workdir, ".pi-sessions"), + ) + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + cmd.Stderr = nil // 忽略 pi 的 a2a/acp 端口告警 + if err := cmd.Start(); err != nil { + t.Fatalf("启动 sidecar 失败: %v", err) + } + t.Cleanup(func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }) + return cmd, bufio.NewWriter(stdin), bufio.NewReader(stdout) +} + +// callSidecar 发一条 JSON-RPC 并读回对应 id 的应答。 +func callSidecar(t *testing.T, w *bufio.Writer, r *bufio.Reader, id int, method string, params map[string]interface{}) *rpcResponse { + t.Helper() + req := map[string]interface{}{"jsonrpc": "2.0", "id": id, "method": method} + if params != nil { + req["params"] = params + } + b, _ := json.Marshal(req) + if _, err := w.WriteString(string(b) + "\n"); err != nil { + t.Fatalf("写 sidecar 失败: %v", err) + } + if err := w.Flush(); err != nil { + t.Fatalf("flush 失败: %v", err) + } + deadline := time.Now().Add(5 * time.Minute) + for time.Now().Before(deadline) { + line, err := r.ReadString('\n') + if err != nil { + t.Fatalf("读 sidecar 失败(method=%s): %v", method, err) + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + var resp rpcResponse + if err := json.Unmarshal([]byte(line), &resp); err != nil { + continue // 非 JSON 行(pi 的日志)跳过 + } + if resp.ID == id { + return &resp + } + } + t.Fatalf("等 sidecar 应答超时(method=%s)", method) + return nil +} + +// ★★★ 判据:**真 pi 真的按指令改了工作目录里的文件**。 +// +// 这是「agent 能接受工程并更新」的地基。整条链: +// +// 真 sidecar 进程 → 真 pi 会话 → pi 调自己的写文件工具 → 工作目录出现文件 +func TestRealPiEditsFiles(t *testing.T) { + nodeBin, sidecarPath := piE2EReady(t) + workdir := t.TempDir() + sessionID := "e2e-edit" + + _, w, r := startSidecarForE2E(t, nodeBin, sidecarPath, workdir, sessionID) + + // ① 先握手(sidecar 契约的第一环) + ping := callSidecar(t, w, r, 1, "ping", nil) + if ping.Error != nil { + t.Fatalf("ping 失败: %s", ping.Error.Message) + } + var pingRes map[string]interface{} + if err := json.Unmarshal(ping.Result, &pingRes); err != nil { + t.Fatalf("ping 应答不是对象: %s", ping.Result) + } + if ok, _ := pingRes["ok"].(bool); !ok { + t.Fatalf("ping 未返回 ok: %v", pingRes) + } + // ★ 必须回报 pi 版本(U8 要求可锁定内层 pi)——取不到要如实为 null, + // 不许编一个。这里是断言"字段存在",至于是否为空由实现诚实性决定。 + if _, has := pingRes["pi_version"]; !has { + t.Error("ping 应回报 pi_version(用于锁定内层 pi 版本)") + } else { + t.Logf("内层 pi 版本: %v", pingRes["pi_version"]) + } + + // ② 给一个必须动文件的指令 + const wantFile = "hello.txt" + const wantText = "hello from pi" + resp := callSidecar(t, w, r, 2, "prompt", map[string]interface{}{ + "session_id": sessionID, + "text": "在当前工作目录创建文件 " + wantFile + + ",内容恰好一行:" + wantText + "。只做这一件事,不要创建别的文件。", + }) + if resp.Error != nil { + t.Fatalf("prompt 失败: %s", resp.Error.Message) + } + var pr map[string]interface{} + if err := json.Unmarshal(resp.Result, &pr); err != nil { + t.Fatalf("prompt 应答不是对象: %s", resp.Result) + } + // ★ 会话不得漂移(Go 侧 deliverToPi 会据此拒绝继续) + if got, _ := pr["session_id"].(string); got != "" && got != sessionID { + t.Fatalf("★ 会话漂移: 期望 %s,实际 %s", sessionID, got) + } + + // ③ **关键断言**:文件真的出现在工作目录里,且内容正确 + path := filepath.Join(workdir, wantFile) + b, err := os.ReadFile(path) + if err != nil { + // 列出目录帮助定位(是没创建,还是创建到了别处) + entries, _ := os.ReadDir(workdir) + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + t.Fatalf("★ pi 没有创建工作目录下的 %s —— 说明「接受工程并更新」不成立。目录内容: %v", + wantFile, names) + } + if !strings.Contains(string(b), wantText) { + t.Errorf("★ 文件内容不对: 期望含 %q,实际 %q", wantText, string(b)) + } + t.Logf("✅ 真 pi 改文件成功: %s = %q", wantFile, strings.TrimSpace(string(b))) +} + +// ★★★ 判据:**同一 session 的第二次投递续在同一会话**(不是新会话)。 +// +// 「续投」是这个插件的核心能力(设计:session_id 是续投的唯一依据)。 +// 若第二次投递另起会话,agent 就失去了上下文,工程对话不再连续。 +func TestRealPiKeepsSessionAcrossPrompts(t *testing.T) { + nodeBin, sidecarPath := piE2EReady(t) + workdir := t.TempDir() + sessionID := "e2e-cont" + + _, w, r := startSidecarForE2E(t, nodeBin, sidecarPath, workdir, sessionID) + callSidecar(t, w, r, 1, "ping", nil) + + // 第一句:建立上下文(记住一个数) + resp1 := callSidecar(t, w, r, 2, "prompt", map[string]interface{}{ + "session_id": sessionID, + "text": "请记住这个数字:7391。只回复「记住了」。", + }) + if resp1.Error != nil { + t.Fatalf("第一次投递失败: %s", resp1.Error.Message) + } + var p1 map[string]interface{} + json.Unmarshal(resp1.Result, &p1) + got1, _ := p1["session_id"].(string) + if got1 != sessionID { + t.Fatalf("★ 首次投递会话不对: %s", got1) + } + + // 第二句:问它那个数 —— 只有**同一会话**才答得出 + resp2 := callSidecar(t, w, r, 3, "prompt", map[string]interface{}{ + "session_id": sessionID, + "text": "我刚才让你记的数字是多少?只回复那个数字。", + }) + if resp2.Error != nil { + t.Fatalf("第二次投递失败: %s", resp2.Error.Message) + } + var p2 map[string]interface{} + json.Unmarshal(resp2.Result, &p2) + got2, _ := p2["session_id"].(string) + if got2 != sessionID { + t.Fatalf("★ 第二次投递会话漂移: 期望 %s,实际 %s(续投失效)", sessionID, got2) + } + reply, _ := p2["reply"].(string) + if !strings.Contains(reply, "7391") { + t.Errorf("★ 第二次回复未体现会话连续性(期望含 7391): %q", reply) + } + t.Logf("✅ 会话连续性成立,第二次回复: %q", strings.TrimSpace(reply)) +} diff --git a/example/commondev/pi_runtime_test.go b/example/commondev/pi_runtime_test.go new file mode 100644 index 0000000..af44210 --- /dev/null +++ b/example/commondev/pi_runtime_test.go @@ -0,0 +1,387 @@ +package main + +// pi 运行时配置类的判据(llmsource / pi_plugin / pi_mcp / pi_config)。 +// +// ## 为什么补这批 +// +// 覆盖度审计显示这批 14 个工具**没有任何机制级测试**,而它们 +// **直接写 pi 的配置文件**(models.json / auth.json / settings.json / +// mcp.json)—— 是本插件里风险最高的一组: +// - 写坏 models.json 会让内层 pi 完全起不来; +// - 把 key 泄漏进返回值会经由 agent 记忆外泄; +// - 改错目录会污染用户的全局 pi(`~/.pi`)。 +// +// 已有 audit_test.go 覆盖了两条关键纪律(损坏不覆盖、备份回滚), +// 这里补齐其余:**不泄漏 key**、**作用域隔离**、**幂等**、**错误可诊断**。 + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func homeDir() string { + h, _ := os.UserHomeDir() + return h +} + +// newPiPlugin 造一个 piAgentDir 落在临时目录的插件。 +func newPiPlugin(t *testing.T) *Plugin { + t.Helper() + p := newFlowPlugin(t) + // piAgentDir = <插件数据>/runtime/agentdir;p.path 的父目录就是插件数据目录 + if err := os.MkdirAll(p.piAgentDir(), 0o755); err != nil { + t.Fatal(err) + } + return p +} + +// writePiModels 写一个 models.json。 +func writePiModels(t *testing.T, dir string, m map[string]interface{}) { + t.Helper() + b, _ := json.MarshalIndent(m, "", " ") + if err := os.WriteFile(filepath.Join(dir, "models.json"), b, 0o600); err != nil { + t.Fatal(err) + } +} + +// ★★★ 判据:`pi_source_list` **绝不返回 api key**。 +// +// 这是全插件最硬的一条纪律(设计 §7.2):key 一旦进返回值, +// 就会经由 agent 记忆、日志、UI 三处外泄,且**事后无法收回**。 +func TestPiSourceListNeverReturnsKey(t *testing.T) { + p := newPiPlugin(t) + dir := p.piAgentDir() + writePiModels(t, dir, map[string]interface{}{ + "providers": map[string]interface{}{ + "myprov": map[string]interface{}{ + "baseUrl": "https://api.example.com", + "api": "openai", + "apiKey": "sk-SUPER-SECRET-VALUE-12345", + "models": []interface{}{map[string]interface{}{"id": "m1"}}, + }, + }, + }) + + res, err := p.handlePiSourceList(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + // 把整个返回值序列化,确认秘密字符串**任何形态都不出现** + blob, _ := json.Marshal(res) + s := string(blob) + if strings.Contains(s, "sk-SUPER-SECRET") { + t.Fatalf("★ pi_source_list 泄漏了 api key: %s", s) + } + // 也不该泄漏 auth.json 里的凭据(这里没建,但格式化检查同样适用) + if strings.Contains(strings.ToLower(s), "secret-value") { + t.Fatalf("★ 返回值含疑似凭据: %s", s) + } +} + +// ★★★ 判据:**作用域隔离** —— 一律指向插件自己的 agentdir,绝不碰用户全局 pi。 +// +// 纪律 4(设计 §3.5.2):piScopeDir 必须带显式 agentdir。 +// 违反的话会改掉用户 `~/.pi` 的配置 —— 那是**跨项目污染**,很难查。 +func TestPiScopeNeverTouchesUserGlobalPi(t *testing.T) { + p := newPiPlugin(t) + // plugin 作用域(默认)→ 插件自己的 agentdir + got, err := p.piScopeDir(map[string]interface{}{"scope": "plugin"}) + if err != nil { + t.Fatal(err) + } + if got != p.piAgentDir() { + t.Errorf("plugin 作用域应指向插件 agentdir: %s vs %s", got, p.piAgentDir()) + } + // 必须是**插件数据目录**下的 runtime/agentdir,不是用户全局 ~/.pi + if strings.HasPrefix(got, filepath.Join(homeDir(), ".pi")) { + t.Errorf("★ plugin 作用域指向了用户全局 pi: %s", got) + } + // project 作用域 → **必须显式给 workdir**(不给就报错,不猜) + if _, err := p.piScopeDir(map[string]interface{}{"scope": "project"}); err == nil { + t.Error("★ scope=project 缺 workdir 应报错(不猜一个目录)") + } + // ★ 更强的约束:workdir 必须落在**插件的 program/ 工程目录**下。 + // 任意目录不行 —— 否则 scope=project 会往用户随便什么目录里写 .pi。 + if _, err := p.piScopeDir(map[string]interface{}{ + "scope": "project", "workdir": t.TempDir(), + }); err == nil { + t.Error("★ 任意目录不该被接受(必须在 program/ 下)") + } + wd := filepath.Join(p.programRoot(), "testwd") + if err := os.MkdirAll(wd, 0o755); err != nil { + t.Fatal(err) + } + gp, err := p.piScopeDir(map[string]interface{}{"scope": "project", "workdir": wd}) + if err != nil { + t.Fatalf("program/ 下的 workdir 应被接受: %v", err) + } + if !strings.HasPrefix(gp, wd) { + t.Errorf("★ project 作用域应在工作目录内: %s", gp) + } +} + +// ★★ 判据:`pi_source_default` 写入的是 settings.json 的**正确键名**。 +// +// 键名写错的表现是"设了默认源但不生效",且没有任何报错 —— +// 属于典型的静默失效。 +func TestPiSourceDefaultWritesExpectedKeys(t *testing.T) { + p := newPiPlugin(t) + dir := p.piAgentDir() + writePiModels(t, dir, map[string]interface{}{ + "providers": map[string]interface{}{ + "prov1": map[string]interface{}{ + "baseUrl": "http://x", "api": "openai", "models": []interface{}{map[string]interface{}{"id": "m1"}}, + }, + }, + }) + res, err := p.handlePiSourceDefault(map[string]interface{}{"name": "prov1"}) + if err != nil { + t.Fatal(err) + } + if m, ok := res.(map[string]interface{}); ok { + if e, bad := m["error"]; bad { + t.Fatalf("设默认源失败: %v", e) + } + } + // 读回 settings.json 确认键名 + b, err := os.ReadFile(filepath.Join(dir, "settings.json")) + if err != nil { + t.Fatalf("settings.json 未生成: %v", err) + } + var st map[string]interface{} + if err := json.Unmarshal(b, &st); err != nil { + t.Fatalf("settings.json 不是合法 JSON: %v\n%s", err, b) + } + if st["defaultProvider"] != "prov1" { + t.Errorf("★ defaultProvider 未正确写入: %v", st["defaultProvider"]) + } + // 必须同时给默认 model(只给 provider 的话 pi 不知道用哪个模型) + if _, has := st["defaultModel"]; !has { + t.Errorf("★ 缺 defaultModel(只给 provider,pi 不知用哪个模型): %v", st) + } +} + +// ★★ 判据:设默认源指向**不存在的源**必须报错(不静默写进去)。 +func TestPiSourceDefaultRejectsUnknownSource(t *testing.T) { + p := newPiPlugin(t) + writePiModels(t, p.piAgentDir(), map[string]interface{}{ + "providers": map[string]interface{}{ + "only": map[string]interface{}{"baseUrl": "http://x", "api": "openai", "models": []interface{}{map[string]interface{}{"id": "m"}}}, + }, + }) + res, _ := p.handlePiSourceDefault(map[string]interface{}{"name": "nonexistent"}) + m, ok := res.(map[string]interface{}) + if !ok { + t.Skipf("返回形状未知: %T", res) + } + if _, isErr := m["error"]; !isErr { + t.Errorf("★ 指向不存在的源应报错: %v", m) + } +} + +// ★★ 判据:`pi_source_remove` 幂等(删不存在的源不报致命错)+ 真的删掉。 +func TestPiSourceRemoveIsIdempotentAndEffective(t *testing.T) { + p := newPiPlugin(t) + dir := p.piAgentDir() + writePiModels(t, dir, map[string]interface{}{ + "providers": map[string]interface{}{ + "kill": map[string]interface{}{"baseUrl": "http://x", "api": "openai", "models": []interface{}{map[string]interface{}{"id": "m"}}}, + "keep": map[string]interface{}{"baseUrl": "http://y", "api": "openai", "models": []interface{}{map[string]interface{}{"id": "m"}}}, + }, + }) + if _, err := p.handlePiSourceRemove(map[string]interface{}{"name": "kill"}); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(filepath.Join(dir, "models.json")) + if strings.Contains(string(b), `"kill"`) { + t.Errorf("★ 源未被删除:\n%s", b) + } + if !strings.Contains(string(b), `"keep"`) { + t.Errorf("★ 误删了别的源:\n%s", b) + } + // 再删一次:不该炸(幂等) + if _, err := p.handlePiSourceRemove(map[string]interface{}{"name": "kill"}); err != nil { + t.Errorf("重复删除应幂等: %v", err) + } +} + +// ★★ 判据:`pi_source_list` 在**配置不存在**时给出空列表而不是报错。 +// +// 新部署上 models.json 尚不存在是常态;报错会让调用方以为坏了。 +func TestPiSourceListOnFreshInstall(t *testing.T) { + p := newPiPlugin(t) // 不写任何配置 + res, err := p.handlePiSourceList(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + m, ok := res.(map[string]interface{}) + if !ok { + t.Skipf("返回形状未知: %T", res) + } + if e, bad := m["error"]; bad { + t.Errorf("★ 无配置时应返回空列表而非报错: %v", e) + } +} + +// ★★★ 判据:**损坏的 mcp.json 必须拒绝覆盖**(与 models.json 同一纪律)。 +// +// 静默重建会抹掉用户已配的 MCP 服务器,且用户不会立刻发现。 +func TestCorruptPiMcpIsNotSilentlyOverwritten(t *testing.T) { + p := newPiPlugin(t) + dir := p.piAgentDir() + orig := []byte("{ this is broken json") + if err := os.WriteFile(filepath.Join(dir, "mcp.json"), orig, 0o600); err != nil { + t.Fatal(err) + } + res, _ := p.handlePiMCPAdd(map[string]interface{}{ + "name": "x", "url": "http://localhost:1234", + }) + if m, ok := res.(map[string]interface{}); ok { + if _, isErr := m["error"]; !isErr { + t.Errorf("★ 损坏的 mcp.json 应被拒绝(不静默覆盖): %v", m) + } + } + // 原文件必须**未被改动** + after, _ := os.ReadFile(filepath.Join(dir, "mcp.json")) + if string(after) != string(orig) { + t.Errorf("★ 损坏文件被覆盖了:\nbefore=%s\nafter=%s", orig, after) + } +} + +// ★★ 判据:`pi_mcp_list` 在无配置时给空列表。 +func TestPiMcpListOnFreshInstall(t *testing.T) { + p := newPiPlugin(t) + res, err := p.handlePiMCPList(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + if m, ok := res.(map[string]interface{}); ok { + if _, bad := m["error"]; bad { + t.Errorf("★ 无配置时不该报错: %v", m) + } + } +} + +// ★★ 判据:`pi_config_set` 拒绝**不存在的键**或返回明确错误。 +// +// 静默接受未知键的后果是"我设了但没生效",且没有任何线索。 +func TestPiConfigSetUnknownKeyIsDiagnosable(t *testing.T) { + p := newPiPlugin(t) + res, _ := p.handlePiConfigSet(map[string]interface{}{ + "key": "definitely_not_a_real_pi_key_xyz", "value": "v", + }) + m, ok := res.(map[string]interface{}) + if !ok { + return // 返回形状不同则不判 + } + // 要么报错,要么明确说明写了什么 —— 但不能是"静默成功且无痕迹" + if _, isErr := m["error"]; !isErr { + if _, hasKey := m["key"]; !hasKey { + t.Errorf("★ 未知键既未报错也未回显,调用方无法判断是否生效: %v", m) + } + } +} + +// ★★ 判据:`project_open` 是 `project_add` 的**真别名**(行为一致)。 +func TestProjectOpenIsAliasOfAdd(t *testing.T) { + p := newFlowPlugin(t) + r1, _ := p.handleProjectOpen(map[string]interface{}{ + "name": "aliased", "remote_url": "/tmp/a.git", + }) + if m, ok := r1.(map[string]interface{}); ok { + if _, bad := m["error"]; bad { + t.Fatalf("project_open 失败: %v", m) + } + } + // 台账里必须真的登记了(别名不能是空壳) + if _, ok := p.ledger.Projects["aliased"]; !ok { + t.Error("★ project_open 未真正登记项目(别名是空壳)") + } + // 与 add 的重复检测行为一致 + r2, _ := p.handleProjectOpen(map[string]interface{}{"name": "aliased", "remote_url": "/tmp/b.git"}) + if _, isErr := r2.(map[string]interface{})["error"]; !isErr { + t.Error("★ 别名未继承 add 的重复检测") + } +} + +// ★★★ 判据:写进 auth.json 的凭据格式**必须是 pi 认的 schema**。 +// +// 这是实测踩出来的真缺陷(2026-10-15):`pi_source_add` / `pi_source_import` +// 原先写 `{"apiKey": "..."}`,而 pi 认的是 `{"type":"api_key","key":"..."}` +// (权威定义:pi 的 dist/core/auth-storage.js:185 `value.type === "api_key"`)。 +// +// 后果**完全静默**:three 个配置文件看起来都正常,但 pi 的 auth 读取返回空, +// 于是报 `Provider is not configured: ` —— 内嵌 pi 谁都调不动。 +// +// 定位过程值得记:同一个 pi API、同一份 models.json,只切 +// `PI_CODING_AGENT_DIR` 就一边跑通一边跑不通,逐字段对比才落到键名上。 +// 所以判据必须**断言键名与取值**,不能只看"文件写成功了"。 +func TestPiAuthWrittenInPiSchema(t *testing.T) { + p := newPiPlugin(t) + dir := p.piAgentDir() + res, _ := p.handlePiSourceAdd(map[string]interface{}{ + "name": "prov", + "base_url": "http://127.0.0.1:8081/v1", + "api": "openai-completions", + "api_key": "sk-test-SCHEMA-CHECK", + "model": "AUTO", + }) + if m, ok := res.(map[string]interface{}); ok { + if e, bad := m["error"]; bad { + t.Fatalf("添加源失败: %v (%v)", e, m["details"]) + } + } + + b, err := os.ReadFile(filepath.Join(dir, "auth.json")) + if err != nil { + t.Fatalf("auth.json 未生成: %v", err) + } + var auth map[string]map[string]interface{} + if err := json.Unmarshal(b, &auth); err != nil { + t.Fatalf("auth.json 不是合法 JSON: %v\n%s", err, b) + } + entry, ok := auth["prov"] + if !ok { + t.Fatalf("auth.json 里没有 prov: %s", b) + } + // ★ 核心断言:必须是 pi 的 schema + if entry["type"] != "api_key" { + t.Errorf("★ entry.type 必须是 \"api_key\"(pi 的 auth-storage 按它判别),实际 %#v", entry["type"]) + } + if entry["key"] != "sk-test-SCHEMA-CHECK" { + t.Errorf("★ entry.key 不对: %#v", entry["key"]) + } + // 旧格式的键不该出现(否则说明还有一处没改) + if _, bad := entry["apiKey"]; bad { + t.Errorf("★ auth.json 仍有旧的 \"apiKey\" 键(pi 不认): %s", b) + } +} + +// ★★ 判据:**导入内核源**也写 pi 的 schema(两条路径必须一致)。 +// +// source_add 与 source_import 是两个入口,写同一份 auth.json。 +// 只改一处的话另一条路径仍然静默失效。 +func TestPiAuthImportAlsoUsesPiSchema(t *testing.T) { + // 直接验证构造器(import 路径的写入最终也走它) + newPiPlugin(t) + e := newPiAuthEntry("sk-x") + if e["type"] != "api_key" || e["key"] != "sk-x" { + t.Errorf("★ newPiAuthEntry 未产出 pi schema: %#v", e) + } + // 反向:确保没有留下产出旧格式的地方 + src, err := os.ReadFile("llmsource.go") + if err == nil { + if strings.Contains(string(src), `"apiKey": apiKey`) { + t.Error("★ llmsource.go 里仍有写旧格式 apiKey 的地方") + } + } + src2, err2 := os.ReadFile("pi_tools.go") + if err2 == nil { + if strings.Contains(string(src2), `map[string]string{"apiKey"`) { + t.Error("★ pi_tools.go 里仍有写旧格式的地方") + } + } +} diff --git a/example/commondev/pi_tools.go b/example/commondev/pi_tools.go new file mode 100644 index 0000000..98faffb --- /dev/null +++ b/example/commondev/pi_tools.go @@ -0,0 +1,664 @@ +package main + +// pi 配置面工具补全(审计发现项)。 +// +// 设计 §3.6.4 明确:**插件是 pi 配置系统的受控入口** —— 写的就是 pi 自己的 +// 配置文件,不造中间层。§3.6.3 已定的落地方式: +// +// /models.json providers..{baseUrl,api,apiKey,models[]} +// /auth.json provider 凭据 +// /settings.json defaultProvider / defaultModel +// +// 四条实现纪律(§3.6.4): +// 1. 写的就是 pi 的配置文件,格式与 pi 自己写出来的一致 +// (否则 pi 重写文件时会把我们的字段抹掉) +// 2. **改前备份、改后校验**:json 写坏会让内层 pi 起不来; +// 校验失败则**回滚并响亮报错** +// 3. key 不进日志、不进返回值 +// 4. **不改用户全局**:一律带 PI_CODING_AGENT_DIR + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +// loadPiModels 读 pi 的 models.json(不存在则返回空结构)。 +func loadPiModels(agentDir string) (*piModelsFile, error) { + out := &piModelsFile{Providers: map[string]*piProvider{}} + b, err := os.ReadFile(filepath.Join(agentDir, "models.json")) + if err != nil { + if os.IsNotExist(err) { + return out, nil + } + return nil, err + } + if err := json.Unmarshal(b, out); err != nil { + // 坏文件必须响亮报错:静默当空会**抹掉用户已有的源** + return nil, fmt.Errorf("models.json 已损坏(拒绝覆盖,先人工修复): %w", err) + } + if out.Providers == nil { + out.Providers = map[string]*piProvider{} + } + return out, nil +} + +// loadPiAuth 读 pi 的 auth.json(不存在则返回空)。 +// +// ★ 用 map[string]interface{} 而不是 map[string]map[string]string: +// 后者把每个 provider 的 entry 当成**扁平字符串表**,于是只能写出 +// `{"apiKey": "..."}` —— 而 pi 认的是 `{"type":"api_key","key":"..."}` +// (权威定义见 pi 的 dist/core/auth-storage.js: +// +// `if (value.type === "api_key")` 与 model-runtime.js 的 +// `{ type: "api_key", key: apiKey }`)。 +// +// 格式不符的后果是**完全静默**的:pi 的 auth 读取返回空 ⇒ +// `Provider is not configured: `,而配置看起来完全正常。 +// 实测(2026-10-15):内嵌 pi 因此谁都调不动,只改这个格式即恢复。 +func loadPiAuth(agentDir string) (map[string]interface{}, error) { + out := map[string]interface{}{} + b, err := os.ReadFile(filepath.Join(agentDir, "auth.json")) + if err != nil { + if os.IsNotExist(err) { + return out, nil + } + return nil, err + } + if err := json.Unmarshal(b, &out); err != nil { + return nil, fmt.Errorf("auth.json 已损坏(拒绝覆盖): %w", err) + } + return out, nil +} + +// handlePiSourceAdd 添加/更新一个 LLM 源(直接写 pi 的配置文件)。 +// +// 纪律 2:**先备份**,写完回读校验,失败回滚。 +func (p *Plugin) handlePiSourceAdd(args map[string]interface{}) (interface{}, error) { + name := strings.TrimSpace(str(args, "name")) + baseURL := strings.TrimSpace(str(args, "base_url")) + model := strings.TrimSpace(str(args, "model")) + if name == "" || baseURL == "" || model == "" { + return map[string]interface{}{"error": "name、base_url、model 必填"}, nil + } + if !validName(name) { + return map[string]interface{}{"error": "name 只允许 [A-Za-z0-9_-](会成为 JSON 的键名)"}, nil + } + adapter := strings.TrimSpace(str(args, "adapter")) + api := adapterToAPI(adapter) + if api == "" { + return map[string]interface{}{ + "error": fmt.Sprintf("adapter=%q 无法映射到 pi 的 api(已知:openai/anthropic)——不猜默认值", adapter), + }, nil + } + + agentDir := p.piAgentDir() + backup, err := backupPiConfig(agentDir) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + + models, err := loadPiModels(agentDir) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + auth, err := loadPiAuth(agentDir) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + + apiKey := str(args, "api_key") + m := piModel{ID: model, Name: model, Input: []string{"text"}} + if cw := int(numArg(args, "context_window")); cw > 0 { + m.ContextWindow = cw + } + if mt := int(numArg(args, "max_tokens")); mt > 0 { + m.MaxTokens = mt + } + if v, ok := args["vision"].(bool); ok && v { + m.Input = []string{"text", "image"} + } + if rb, ok := args["reasoning"].(bool); ok && rb { + m.Reasoning = true + } + models.Providers[name] = &piProvider{ + BaseURL: baseURL, API: api, APIKey: apiKey, Models: []piModel{m}, + } + if strings.TrimSpace(apiKey) != "" { + if auth == nil { + auth = map[string]interface{}{} + } + auth[name] = newPiAuthEntry(apiKey) + } + + if err := p.writePiConfigs(agentDir, models, auth); err != nil { + rollbackPiConfig(agentDir, backup) + return map[string]interface{}{"error": "写入失败,已回滚: " + err.Error()}, nil + } + // ★ 返回值里**没有 key**(纪律 3) + return map[string]interface{}{ + "status": "ok", "provider": name, "base_url": baseURL, + "api": api, "model": model, + "key_set": strings.TrimSpace(apiKey) != "", + "backup": backup, + }, nil +} + +// handlePiConfigGet 读取 pi 的配置(**key 一律脱敏**,纪律 3)。 +// +// scope: plugin(默认,读 )| project(读 /.pi)。 +func (p *Plugin) handlePiConfigGet(args map[string]interface{}) (interface{}, error) { + agentDir, err := p.piScopeDir(args) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + models, err := loadPiModels(agentDir) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + providers := []map[string]interface{}{} + names := make([]string, 0, len(models.Providers)) + for n := range models.Providers { + names = append(names, n) + } + sort.Strings(names) // 固定顺序,便于对照 + for _, n := range names { + pv := models.Providers[n] + modelsOut := make([]string, 0, len(pv.Models)) + for _, m := range pv.Models { + modelsOut = append(modelsOut, m.ID) + } + providers = append(providers, map[string]interface{}{ + "name": n, "base_url": pv.BaseURL, "api": pv.API, + "models": modelsOut, + // ★ 只报"是否配了 key",不返回值 + "key_set": strings.TrimSpace(pv.APIKey) != "", + }) + } + var settings map[string]interface{} + if b, err := os.ReadFile(filepath.Join(agentDir, "settings.json")); err == nil { + _ = json.Unmarshal(b, &settings) + } + out := map[string]interface{}{ + "scope_dir": agentDir, + "providers": providers, + "count": len(providers), + "settings": settings, + "key_discipline": "api_key 绝不返回(与 commondev_account_list 同一条纪律)", + } + return out, nil +} + +// handlePiPackageInstall 为内置 pi 装一个扩展包(pi install)。 +// +// 设计 §3.5.2 的两种粒度:plugin(全局,装进 agentdir)/ project(装进工作目录)。 +// ★ 不擅自安装:命令与来源都显式要求,且安装失败要响亮报错。 +func (p *Plugin) handlePiPackageInstall(args map[string]interface{}) (interface{}, error) { + source := strings.TrimSpace(str(args, "source")) + if source == "" { + return map[string]interface{}{"error": "source 必填(如 npm:@foo/bar、git:github.com/u/r)"}, nil + } + scope := strings.TrimSpace(str(args, "scope")) + if scope == "" { + scope = "plugin" + } + agentDir, err := p.piScopeDir(map[string]interface{}{ + "scope": scope, "workdir": str(args, "workdir"), + }) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + piBin := p.piBin() + + argv := []string{"install", source} + dir := agentDir + if scope == "project" { + argv = append(argv, "-l") // pi 的 project-local 开关 + dir = strings.TrimSpace(str(args, "workdir")) + if dir == "" { + return map[string]interface{}{"error": "scope=project 时必须给 workdir"}, nil + } + } + + cmd := exec.Command(piBin, argv...) + cmd.Dir = dir + // ★ 纪律 4:绝不改用户全局(一律指向插件自己的 agentdir) + cmd.Env = append(os.Environ(), "PI_CODING_AGENT_DIR="+agentDir) + out, err := cmd.CombinedOutput() + if err != nil { + // 不吞输出:pi 的报错(来源不存在、网络不通)是唯一线索 + return map[string]interface{}{ + "status": "failed", "error": err.Error(), + "output": truncate(string(out), 800), + }, nil + } + return map[string]interface{}{ + "status": "ok", "source": source, "scope": scope, + "dir": dir, "output": truncate(string(out), 800), + }, nil +} + +// piScopeDir 解析 scope 到具体目录(纪律 4:带显式 agentdir)。 +func (p *Plugin) piScopeDir(args map[string]interface{}) (string, error) { + scope := strings.TrimSpace(str(args, "scope")) + if scope == "" { + scope = "plugin" + } + switch scope { + case "plugin": + return p.piAgentDir(), nil + case "project": + wd := strings.TrimSpace(str(args, "workdir")) + if wd == "" { + return "", fmt.Errorf("scope=project 时必须给 workdir") + } + abs, err := filepath.Abs(wd) + if err != nil { + return "", err + } + if !p.isInsideProgramRoot(abs) { + return "", fmt.Errorf("project scope 的 workdir 必须在 program/ 工程目录下") + } + return filepath.Join(abs, ".pi"), nil + default: + return "", fmt.Errorf("scope 只能是 plugin | project") + } +} + +// piBin 定位内置 pi 可执行文件。 +func (p *Plugin) piBin() string { + if v, _ := p.sdk.Settings().Get("pi_bin"); v != nil { + if s, _ := v.(string); strings.TrimSpace(s) != "" { + return s + } + } + if cand := filepath.Join(filepath.Dir(p.path), "runtime", "pi"); fileExists(cand) { + return cand + } + if cand, err := exec.LookPath("pi"); err == nil { + return cand + } + return "pi" // 交给 exec 报"找不到" +} + +// ---------------- 改前备份 / 失败回滚(纪律 2) ---------------- + +// backupPiConfig 把 agentDir 下的 pi 配置文件备份成一个时间戳快照。 +func backupPiConfig(agentDir string) (string, error) { + stamp := fmt.Sprintf("%d", time.Now().UnixNano()) + dir := filepath.Join(agentDir, ".backup-"+stamp) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + n := 0 + for _, f := range []string{"models.json", "auth.json", "settings.json"} { + src := filepath.Join(agentDir, f) + b, err := os.ReadFile(src) + if err != nil { + continue // 不存在就不用备份 + } + if err := writeFile0600(filepath.Join(dir, f), b); err != nil { + return "", err + } + n++ + } + if n == 0 { + // 没什么可备份:建一个空目录标记(回滚时删除新文件即可) + return dir, nil + } + return dir, nil +} + +// rollbackPiConfig 用备份覆盖回去(写坏时的**响亮**兜底)。 +func rollbackPiConfig(agentDir, backupDir string) { + if backupDir == "" { + return + } + for _, f := range []string{"models.json", "auth.json", "settings.json"} { + src := filepath.Join(backupDir, f) + b, err := os.ReadFile(src) + if err != nil { + // 备份里没有 ⇒ 该文件是本次新建的,删掉即回到原状 + _ = os.Remove(filepath.Join(agentDir, f)) + continue + } + _ = writeFile0600(filepath.Join(agentDir, f), b) + } +} + +// handlePiPluginList 列出已装的 pi 扩展包(分 scope)。 +func (p *Plugin) handlePiPluginList(args map[string]interface{}) (interface{}, error) { + scope := strings.TrimSpace(str(args, "scope")) + agentDir := p.piAgentDir() + argv := []string{"list"} + dir := agentDir + env := os.Environ() + if scope == "project" { + wd := strings.TrimSpace(str(args, "workdir")) + if wd == "" { + return map[string]interface{}{"error": "scope=project 时必须给 workdir"}, nil + } + if !p.isInsideProgramRoot(wd) { + return map[string]interface{}{"error": "workdir 必须在 program/ 工程目录下"}, nil + } + dir = wd + argv = append(argv, "-l") + } + cmd := exec.Command(p.piBin(), argv...) + cmd.Dir = dir + cmd.Env = append(env, "PI_CODING_AGENT_DIR="+agentDir) + out, err := cmd.CombinedOutput() + if err != nil { + return map[string]interface{}{ + "status": "failed", "error": err.Error(), + "output": truncate(stripPiNoise(string(out)), 800), + }, nil + } + text := strings.TrimSpace(stripPiNoise(string(out))) + return map[string]interface{}{ + "status": "ok", "scope": scopeOrDefault(scope), "dir": dir, + "packages": text, "count": countPiListLines(text), + }, nil +} + +// handlePiPluginRemove 卸载一个 pi 扩展包。 +func (p *Plugin) handlePiPluginRemove(args map[string]interface{}) (interface{}, error) { + source := strings.TrimSpace(str(args, "source")) + if source == "" { + return map[string]interface{}{"error": "source 必填"}, nil + } + agentDir := p.piAgentDir() + argv := []string{"remove", source} + dir := agentDir + if strings.EqualFold(strings.TrimSpace(str(args, "scope")), "project") { + wd := strings.TrimSpace(str(args, "workdir")) + if wd == "" { + return map[string]interface{}{"error": "scope=project 时必须给 workdir"}, nil + } + if !p.isInsideProgramRoot(wd) { + return map[string]interface{}{"error": "workdir 必须在 program/ 工程目录下"}, nil + } + dir = wd + argv = append(argv, "-l") + } + cmd := exec.Command(p.piBin(), argv...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "PI_CODING_AGENT_DIR="+agentDir) + out, err := cmd.CombinedOutput() + if err != nil { + return map[string]interface{}{ + "status": "failed", "error": err.Error(), + "output": truncate(stripPiNoise(string(out)), 800), + }, nil + } + return map[string]interface{}{ + "status": "ok", "source": source, "dir": dir, + "output": truncate(stripPiNoise(string(out)), 800), + }, nil +} + +// stripPiNoise 去掉 pi 启动时那两行 A2A/ACP 端口告警。 +// +// 为何要滤:那两行是**环境噪声**(内层 pi 尝试监听端口被占), +// 混在结果里会让人以为安装/列出失败了。真实的错误信息不受影响。 +func stripPiNoise(s string) string { + var keep []string + for _, line := range strings.Split(s, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "[pi-a2a]") || + strings.HasPrefix(strings.TrimSpace(line), "[pi-acp]") { + continue + } + keep = append(keep, line) + } + return strings.Join(keep, "\n") +} + +func scopeOrDefault(s string) string { + if strings.TrimSpace(s) == "" { + return "plugin" + } + return s +} + +// countPiListLines 数出已装包的行数("No packages installed." 记 0)。 +func countPiListLines(text string) int { + if strings.TrimSpace(text) == "" || strings.Contains(text, "No packages installed") { + return 0 + } + n := 0 + for _, line := range strings.Split(text, "\n") { + if strings.TrimSpace(line) != "" { + n++ + } + } + return n +} + +// ---------------- workdir 预准备(设计 §4.1) ---------------- + +// handleWorkdirPrepare 重新准备/修复某条分支的工作目录(幂等)。 +// +// 与 branch_open 的区别:branch_open 是**登记一条新线**(含唯一性闸); +// 本工具是对**已有的线**重做目录准备 —— 目录被误删、clone 中断、需要 +// 重新从 mirror 派生时用。走后台作业(clone 可能超过 60s)。 +func (p *Plugin) handleWorkdirPrepare(args map[string]interface{}) (interface{}, error) { + id := strings.TrimSpace(str(args, "branch_id")) + if id == "" { + return map[string]interface{}{"error": "branch_id 必填"}, nil + } + p.mu.Lock() + b, ok := p.ledger.Branches[id] + var proj *Project + if ok { + proj = p.ledger.Projects[b.ProjectID] + } + p.mu.Unlock() + if !ok || proj == nil { + return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 或其项目不存在", id)}, nil + } + jobID := fmt.Sprintf("prepare-%s-%d", id, time.Now().UnixNano()%100000) + p.mu.Lock() + p.ledger.Jobs[jobID] = &Job{ + ID: jobID, BranchID: id, Kind: "prepare_workdir", + Status: "pending", StartedAt: nowRFC3339(), + } + _ = p.saveLocked() + p.mu.Unlock() + + go p.runPrepareWorkdir(jobID, id, proj, b.Name, b.Workdir, b.Runtime, b.SSHHost) + return map[string]interface{}{ + "status": "ok", "job_id": jobID, "workdir": b.Workdir, + "next": "用 commondev_job_status 查进度", + }, nil +} + +// handlePiConfigSet 设置 pi settings.json 的一个顶层键(直接落盘,带备份/校验)。 +func (p *Plugin) handlePiConfigSet(args map[string]interface{}) (interface{}, error) { + key := strings.TrimSpace(str(args, "key")) + if key == "" { + return map[string]interface{}{"error": "key 必填"}, nil + } + dir, err := p.piScopeDir(args) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + backup, err := backupPiConfig(dir) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + path := filepath.Join(dir, "settings.json") + cur := map[string]interface{}{} + if b, readErr := os.ReadFile(path); readErr == nil { + if err := json.Unmarshal(b, &cur); err != nil { + return map[string]interface{}{"error": fmt.Sprintf("settings.json 已损坏(拒绝覆盖): %v", err)}, nil + } + } else if !os.IsNotExist(readErr) { + return map[string]interface{}{"error": readErr.Error()}, nil + } + raw := strings.TrimSpace(str(args, "value")) + if raw == "" { + return map[string]interface{}{"error": "value 必填(JSON 值或普通字符串)"}, nil + } + var value interface{} + if err := json.Unmarshal([]byte(raw), &value); err != nil { + value = raw + } + cur[key] = value + b, err := json.MarshalIndent(cur, "", " ") + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + if err := writeFile0600(path, b); err != nil { + rollbackPiConfig(dir, backup) + return map[string]interface{}{"error": "写入失败,已回滚: " + err.Error()}, nil + } + var verify map[string]interface{} + if b2, err := os.ReadFile(path); err != nil || json.Unmarshal(b2, &verify) != nil { + rollbackPiConfig(dir, backup) + return map[string]interface{}{"error": "写后校验失败,已回滚"}, nil + } + return map[string]interface{}{"status": "ok", "scope": scopeOrDefault(str(args, "scope")), "key": key}, nil +} + +// runPiMCP 是 pi mcp 命令的受控包装(所有写入仍落 pi 自己的 mcp.json)。 +func (p *Plugin) runPiMCP(args []string, workdir string) (interface{}, error) { + agentDir := p.piAgentDir() + cmd := exec.Command(p.piBin(), append([]string{"mcp"}, args...)...) + cmd.Dir = workdir + cmd.Env = append(os.Environ(), "PI_CODING_AGENT_DIR="+agentDir) + out, err := cmd.CombinedOutput() + clean := truncate(stripPiNoise(string(out)), 1200) + if err != nil { + return map[string]interface{}{"status": "failed", "error": err.Error(), "output": clean}, nil + } + return map[string]interface{}{"status": "ok", "output": clean}, nil +} + +func (p *Plugin) mcpWorkdir(args map[string]interface{}) (string, error) { + scope := scopeOrDefault(str(args, "scope")) + if scope == "plugin" { + return p.piAgentDir(), nil + } + wd := strings.TrimSpace(str(args, "workdir")) + if wd == "" || !p.isInsideProgramRoot(wd) { + return "", fmt.Errorf("scope=project 时 workdir 必须在 program/ 工程目录下") + } + return wd, nil +} + +func (p *Plugin) handlePiMCPList(args map[string]interface{}) (interface{}, error) { + wd, err := p.mcpWorkdir(args) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + return p.runPiMCP([]string{"list", "--json"}, wd) +} + +func (p *Plugin) handlePiMCPAdd(args map[string]interface{}) (interface{}, error) { + name, source := strings.TrimSpace(str(args, "name")), strings.TrimSpace(str(args, "source")) + if name == "" || source == "" { + return map[string]interface{}{"error": "name 与 source 必填"}, nil + } + wd, err := p.mcpWorkdir(args) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + argv := []string{"add", name} + if scopeOrDefault(str(args, "scope")) == "project" { + argv = append(argv, "--local") + } + if url := strings.TrimSpace(str(args, "url")); url != "" { + argv = append(argv, "--url", url) + } else { + // source 是命令及其参数的 JSON 数组,避免 shell 重新解析/注入。 + var command []string + if err := json.Unmarshal([]byte(source), &command); err != nil || len(command) == 0 { + return map[string]interface{}{"error": "source 必须是 JSON 字符串数组(stdio),或另填 url"}, nil + } + argv = append(argv, "--") + argv = append(argv, command...) + } + return p.runPiMCP(argv, wd) +} + +func (p *Plugin) handlePiMCPRemove(args map[string]interface{}) (interface{}, error) { + name := strings.TrimSpace(str(args, "name")) + if name == "" { + return map[string]interface{}{"error": "name 必填"}, nil + } + wd, err := p.mcpWorkdir(args) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + argv := []string{"remove", name} + if scopeOrDefault(str(args, "scope")) == "project" { + argv = append(argv, "--local") + } + return p.runPiMCP(argv, wd) +} + +// handlePiSourceRemove 删除一个 provider 及其 auth 记录。 +func (p *Plugin) handlePiSourceRemove(args map[string]interface{}) (interface{}, error) { + name := strings.TrimSpace(str(args, "name")) + if name == "" { + return map[string]interface{}{"error": "name 必填"}, nil + } + dir := p.piAgentDir() + backup, err := backupPiConfig(dir) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + models, err := loadPiModels(dir) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + if _, ok := models.Providers[name]; !ok { + return map[string]interface{}{"error": "源不存在: " + name}, nil + } + delete(models.Providers, name) + auth, err := loadPiAuth(dir) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + delete(auth, name) + if err := p.writePiConfigs(dir, models, auth); err != nil { + rollbackPiConfig(dir, backup) + return map[string]interface{}{"error": "删除失败,已回滚: " + err.Error()}, nil + } + return map[string]interface{}{"status": "ok", "removed": name}, nil +} + +// handlePiPluginUpdate 更新一个已安装的 pi 扩展;未指定 source 时更新全部。 +func (p *Plugin) handlePiPluginUpdate(args map[string]interface{}) (interface{}, error) { + agentDir := p.piAgentDir() + dir := agentDir + argv := []string{"update"} + if source := strings.TrimSpace(str(args, "source")); source != "" { + argv = append(argv, source) + } + if scopeOrDefault(str(args, "scope")) == "project" { + wd := strings.TrimSpace(str(args, "workdir")) + if wd == "" || !p.isInsideProgramRoot(wd) { + return map[string]interface{}{"error": "scope=project 时 workdir 必须在 program/ 工程目录下"}, nil + } + dir = wd + argv = append(argv, "-l") + } + cmd := exec.Command(p.piBin(), argv...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "PI_CODING_AGENT_DIR="+agentDir) + out, err := cmd.CombinedOutput() + result := map[string]interface{}{"scope": scopeOrDefault(str(args, "scope")), "dir": dir, "output": truncate(stripPiNoise(string(out)), 800)} + if err != nil { + result["status"] = "failed" + result["error"] = err.Error() + return result, nil + } + result["status"] = "ok" + return result, nil +} diff --git a/example/commondev/plg.json b/example/commondev/plg.json new file mode 100644 index 0000000..ba89032 --- /dev/null +++ b/example/commondev/plg.json @@ -0,0 +1,12 @@ +{ + "name": "commondev", + "name_zh": "多仓库开发主管", + "name_en": "CommonDev", + "version": "0.1.0", + "description": "主管式多仓库开发流水线:登记项目、开工程对话、为每条线拉起内置 pi 并注册一对通道,投递任务给 pi 并在同一会话内续投。", + "author": "HomeAgent", + "entry": "plugin.so", +"tags": ["commondev", "git", "agent", "develop"], + "targets": "linux/amd64", + "rawfiles": [] +} diff --git a/example/commondev/plugin.go b/example/commondev/plugin.go new file mode 100644 index 0000000..1346fe3 --- /dev/null +++ b/example/commondev/plugin.go @@ -0,0 +1,1585 @@ +package main + +// commondev:主管式多仓库开发流水线的运行时外壳(刀 1)。 +// +// 设计文档(核心仓):docs/zh/commondev-design.md +// docs/zh/commondev-agent-runtime-design.md +// 纪律(skill):commondev-discipline +// +// ## 本插件做什么 +// +// 用户拍板的总纲:**对 pi 做的事,本质就是「把给 pi 发消息包装一下、 +// 把 pi 返回包装一下」,并深度融合内核的 IO 抽象与中断系统 —— pi = 一个 Device。** +// +// 所以这里**不实现**调度器 / 会话路由 / 中断栈,全部用内核已有的: +// +// agent → pi output_send__commondev_(payload=…) ← 内核输出门,本插件只写通道 handler +// pi → agent InjectTextTo("commondev/", …) ← 内核按 inputch Owner 路由 +// 中断 pi InjectInterruptTextOpts(…, Priority) ← 内核调度器 +// +// ## 刀 1 的范围(最小可跑链路) +// +// 台账(projects/branches/deliveries)+ 每工程一对通道 + sidecar 投递 +// **不含**:判据/变异、PR、多平台凭据、远端 ssh(后续刀次) +// +// ## 关键纪律(每一条都有实测依据) +// +// 1. **先有 session,才有通道**:通道是「与某个具体 pi 会话对话的入口」, +// 没 session 注册出来是空壳 ⇒ branch_open 后必须 agent_up。 +// 2. **outputch 名不能带斜杠**:它会被拼成 output_send__,上游要求 +// ^[a-zA-Z0-9_-]{1,64}$;违反的后果是**整条请求 400、agent 整体失语** +// (实测:带斜杠会产出 output_send__commondev/br-1)。 +// ⇒ inputch 用 `commondev/`(可含斜杠),outputch 用 `commondev_`。 +// 3. **通道控制流不进记忆**:ChannelDef 一律 NoMemory, +// 避免污染召回与场面指纹。 +// 4. **投递必须落同一个 session**:台账记 session_id,投递后断言它未变。 + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/JianFeeeee/homeagentsdk/sdk" +) + +const ( + // 一次 sidecar 调用的默认超时。pi 干活可能很久,投递本身要快。 + defaultSidecarTimeout = 60 * time.Second +) + +// safeNamePattern 是**非法字符**字符集(用于把任意名字派生为安全名)。 +// +// ⚠️ 它匹配的是「违法字符」,因此**不能**用它 MatchString 判断合法性: +// 合法名不含违法字符 ⇒ 返回 false(本插件初版就因这个反了的用法, +// 把合法项目名 `cdtest` 判成非法 —— 由端到端跑时的 agent 发现)。 +// 判断合法用 validNamePattern。 +var safeNamePattern = regexp.MustCompile(`[^a-zA-Z0-9_-]`) + +// validNamePattern 是**合法名**白名单(^...$ 钉住整串),用于校验。 +var validNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +// validName 报告名字是否可直接用作目录名 / 通道名 / 工具名。 +func validName(s string) bool { return validNamePattern.MatchString(s) } + +// Project 是一个被登记的仓库。 +type Project struct { + Name string `json:"name"` + RemoteURL string `json:"remote_url"` + Platform string `json:"platform,omitempty"` // github|gitea|gitcode + DefaultBranch string `json:"default_branch,omitempty"` + MirrorPath string `json:"mirror_path,omitempty"` + CreatedAt string `json:"created_at"` +} + +// Branch 是一条工程对话(分支线):四元组 + 状态。 +// +// ★ 四元组缺一不可:workdir / session_id / runtime / channels。 +type Branch struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + WorkDesc string `json:"work_desc,omitempty"` + AgentName string `json:"agent_name,omitempty"` + BaseSHA string `json:"base_sha,omitempty"` + Status string `json:"status"` // open|review|merged|abandoned + + // ↓ 四元组 + Workdir string `json:"workdir,omitempty"` + SessionID string `json:"session_id,omitempty"` // ★ 续投的唯一依据 + // pi 会话列表可读名:br--(U6)。 + SessionName string `json:"session_name,omitempty"` + // 启动时从 sidecar ping 回显的内层 pi 版本(U8)。 + PiVersion string `json:"pi_version,omitempty"` + Runtime string `json:"runtime,omitempty"` // local|ssh(刀 1 只有 local) + // AgentCLI 是**执行后端**(与 Runtime 正交): + // pi(默认)| zcode | ...(实测由 runtime//driver.json 提供) + // ★ 两个轴分开:Runtime 决定"在哪跑",AgentCLI 决定"跑什么"。 + AgentCLI string `json:"agent_cli,omitempty"` + // SSHHost 是远端目标(user@host 或 ~/.ssh/config 别名),仅 runtime=ssh 用。 + SSHHost string `json:"ssh_host,omitempty"` + InputCh string `json:"input_ch,omitempty"` // commondev/ + OutputCh string `json:"output_ch,omitempty"` // commondev_ + + CreatedAt string `json:"created_at"` + ClosedAt string `json:"closed_at,omitempty"` +} + +// Delivery 是一次投递记录(幂等键 + 结果)。 +type Delivery struct { + Key string `json:"key"` + BranchID string `json:"branch_id"` + Text string `json:"text"` + SessionID string `json:"session_id"` + Status string `json:"status"` // sent|failed + Result string `json:"result,omitempty"` + At string `json:"at"` +} + +// Job 是一个后台作业(慢操作异步化)。 +// +// ★ 为何必须异步(实测教训):内核把**每次工具调用**硬限制在 **60 秒** +// (internal/agent/core/toolcall.go:62,硬编码无配置),而 `git clone --mirror` +// 一个仓库耗时不定。同步 clone 的后果是:工具报超时,**但 mirror 其实已建好** +// —— 调用方只看到失败,状态却是“做了一半”,这是最坏的一种失败。 +// +// ⇒ 工具只做快操作(登记台账)+ 发起后台作业,慢活由状态工具报告。 +type Job struct { + ID string `json:"id"` + BranchID string `json:"branch_id"` + Kind string `json:"kind"` // prepare_workdir|clone_mirror|start_pi|... + Status string `json:"status"` // pending|running|done|failed + Detail string `json:"detail,omitempty"` + Err string `json:"err,omitempty"` + StartedAt string `json:"started_at"` + EndedAt string `json:"ended_at,omitempty"` +} + +// Ledger 是插件台账(不存凭据 —— 凭据另有 0600 文件,刀 2 落地)。 +type Ledger struct { + Projects map[string]*Project `json:"projects"` + Branches map[string]*Branch `json:"branches"` + Jobs map[string]*Job `json:"jobs,omitempty"` + Deliveries []Delivery `json:"deliveries"` + // ★ Criteria 是主管定义的判据(刀 4)。ReviewRuns 是复核历史。 + Criteria map[string]*Criteria `json:"criteria,omitempty"` + ReviewRuns []ReviewRun `json:"review_runs,omitempty"` + // PiVersion 是本插件随包/运行时声明的 pi 版本(U8)。 + PiVersion string `json:"pi_version,omitempty"` + // ★ Interceptions 已移除:拦截记录由**内核**拥有并暴露 + //(内核日志 + secret_list 的命中计数),插件不再重复维护一份。 +} + +type Plugin struct { + name string + sdk *sdk.PluginSDK + + mu sync.Mutex + // prepMu 串行化"准备目录"这类慢操作(本地/远端共用)。 + // + // ★ 为什么必须串行:准备流程的第一步是"mirror 不存在就建"—— + // 而那是**检查后行动**(TOCTOU):两个 goroutine 同时判定"不存在", + // 然后都去 clone,第二个就会撞上 + // fatal: destination path '.../x.git' already exists and is not an empty directory + // + // 触发场景是真实的:branch_open 的目录准备在后台跑,用户看 job 还没 + // done 就再点一次 workdir_prepare(那是完全自然的操作),于是两个 + // goroutine 同时准备同一个 mirror。实测 3/6 次复现。 + // + // 用一整把锁而不是按 mirror 分锁:准备是低频重操作(一次 clone), + // 串行化的代价远小于把这一步做错的代价。 + prepMu sync.Mutex + ledger *Ledger + path string + + // ★ 凭据独立文件(0600)—— 不进 config.db(那是 644 全局可读)。 + // 见 credentials.go 的头部纪律。 + creds *credStore + + // vault 是变量库接口的**可注入副本**(仅测试用;生产为 nil ⇒ 走 sdk)。 + vault sdk.SecretsAPI + + // lastReview 缓存最近一次复核结论(供 review_result 取)。 + lastReview map[string]interface{} + + // ★ 可读名 → 内核别名 的映射(**不含值**)。 + // 值存于内核变量库(进程级单例,全部插件与 agent 共享)。 + // 见 encstore.go 的头部纪律。 + refs secretRefs + + // ★ 敏感信息拦截由内核接管(见 Start 里的说明)。 + // ★ 加密存储由内核变量库接管(见 encstore.go 的头部说明)。 + + // sidecars 是每个工程对话一份的 sidecar(刀 1:本地内嵌)。 + sidecars map[string]*sidecarProcess + + // endpoints 是**远端端点表**:按 host 存一套、按 (host, cli) 覆盖一套。 + // 为什么不能只有一组全局 settings —— 见 endpoints.go 头部。 + endpoints *endpointTable +} + +// ---------------- 生命周期 ---------------- + +func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.sdk = s + s.SetAutoRestart(true) + p.sidecars = map[string]*sidecarProcess{} + + dataDir := s.Settings().DataDir() + p.path = filepath.Join(dataDir, "ledger.json") + if err := p.loadLedger(); err != nil { + return fmt.Errorf("加载台账失败: %w", err) + } + // 凭据存储:独立文件 + 启动时校验权限(不对则拒绝启动)。 + cs, err := newCredStore(p.credentialsPath()) + if err != nil { + return fmt.Errorf("凭据存储不可用: %w", err) + } + cs.keyProvider = p.masterKey + p.creds = cs + // 加密信息库(通用 KV,独立文件) + if err := p.loadSecretRefs(); err != nil { + return fmt.Errorf("凭据名映射不可用: %w", err) + } + // 远端端点表(按 host / (host,cli) 存 CLI 路径等)。 + // 损坏必须响亮 —— 静默用空表会让所有远端配置凭空消失。 + if err := p.loadEndpoints(); err != nil { + return fmt.Errorf("端点表不可用: %w", err) + } + // ★ 敏感信息拦截已由**内核**接管(docs/zh/kernel-secret-firewall-design.md)。 + // + // 原先插件自带一套拦截(StageOnInput + StageAfterToolcall),但它的覆盖面 + // **严格小于内核版**: + // · 插件只能看到 stageCtx.RawMessage / ToolResults + // · 内核还能覆盖 Input / Response / Blocks / memory_candidate(记忆侧路) + // · 且内核拦在 provider 层,插件拦不住"已进 f.Msgs 但未出网"的窗口 + // + // 两套并存还有个真实害处:插件版会在内核版**之后**再扫一遍, + // 而那时明文已是别名(内核先换了)⇒ 纯属空转,还会把别名二次改写。 + // 升级路径:老版本存的明文凭据,启动时一次性加密落盘。 + // (失败不阻断启动:某个 ${ENV} 暂时不可解析不该让插件起不来; + // 它会在能解析时被下一次写盘带上) + p.creds.mu.Lock() + if err := p.creds.sealAllLocked(); err == nil { + _ = p.creds.saveLocked() + } + p.creds.mu.Unlock() + + p.registerConfig() + p.registerTools() + + log.Printf("[commondev] 就绪:台账=%s,工程=%d", p.path, len(p.ledger.Projects)) + return nil +} + +func (p *Plugin) Stop() error { + // ★ 孤儿清理是插件的责任(内核对自己的驻留子有硬约束,我们对进程同样硬)。 + // 幂等全清:sidecar / pi 进程一个不留。 + p.mu.Lock() + sc := p.sidecars + p.sidecars = map[string]*sidecarProcess{} + p.mu.Unlock() + for id, s := range sc { + s.Close() + log.Printf("[commondev] 已清理 sidecar: %s", id) + } + return nil +} + +func (p *Plugin) registerConfig() { + p.sdk.Settings().RegisterDef(sdk.ConfigDef{ + Key: "pi_bin", Type: "string", DisplayName: "pi 可执行文件", + Description: "内置 pi 的路径。留空则用随包分发的 runtime/pi,其次 PATH。", + Category: p.name, + }) + // ★ 原先的 intercept_enabled 开关已移除:拦截现由**内核**统一负责, + // 全局开关是 core.security.enabled(在 WebUI 的设置页里,不在插件里)。 + // 插件保留一个同名开关会让人以为它还能控制内核行为 —— 那是误导。 + p.sdk.Settings().RegisterDef(sdk.ConfigDef{ + Key: "sidecar_timeout_sec", Default: 60, Type: "int", DisplayName: "sidecar 超时(秒)", + Description: "单次 sidecar 调用的超时上限", + Category: p.name, + }) + + // —— 远端(runtime=ssh)配置 —— + // + // ★ 这三个原先**只被 Get 读、从未注册**,于是设置页里看不到它们, + // 用户只能去改 config.yaml 猜键名。缺 ssh_workdir_root 时 + // 远端线的目录准备会直接失败(那是唯一能让它跑起来的入口)。 + // ★ 多台远端机器请用 commondev_endpoint_set/list/remove(按 host 存一套、 + // 按 (host,cli) 再覆盖一套)。下面这三个是**全局兜底**,只在端点表 + // 没有对应值时生效 —— 单台机器时最省事,多台时会被端点表压过。 + p.sdk.Settings().RegisterDef(sdk.ConfigDef{ + Key: "ssh_workdir_root", Type: "string", + DisplayName: "远端工程目录根", + Description: "远端放工程工作目录的根,例如 /home/user/.commondev/program。" + + "runtime=ssh 时**必填**(缺了无法在远端准备目录)。同一远端上的多条分支线在此共享 .mirrors。", + Category: p.name, + }) + p.sdk.Settings().RegisterDef(sdk.ConfigDef{ + Key: "ssh_runtime_root", Type: "string", + DisplayName: "远端 sidecar 目录", + Description: "远端放 sidecar 脚本的目录(每次拉起会 scp 推过去)。runtime=ssh 时**必填**。", + Category: p.name, + }) + p.sdk.Settings().RegisterDef(sdk.ConfigDef{ + Key: "ssh_node_path", Default: "node", Type: "string", + DisplayName: "远端 node 路径", + Description: "远端 node 的路径或命令名(默认走 PATH)。远端 node 不在 PATH 时填绝对路径。" + + "★ 探测与拉起都用这个值 —— 配了它就该生效。", + Category: p.name, + }) +} + +// ---------------- 台账 ---------------- + +func (p *Plugin) loadLedger() error { + p.ledger = &Ledger{ + Projects: map[string]*Project{}, + Branches: map[string]*Branch{}, + Jobs: map[string]*Job{}, + } + b, err := os.ReadFile(p.path) + if err != nil { + if os.IsNotExist(err) { + return nil // 首次启动 + } + return err + } + if err := json.Unmarshal(b, p.ledger); err != nil { + return fmt.Errorf("台账损坏(拒绝静默重建): %w", err) + } + if p.ledger.Projects == nil { + p.ledger.Projects = map[string]*Project{} + } + if p.ledger.Branches == nil { + p.ledger.Branches = map[string]*Branch{} + } + if p.ledger.Jobs == nil { + p.ledger.Jobs = map[string]*Job{} + } + return nil +} + +// saveLocked 调用方必须已持 p.mu。 +func (p *Plugin) saveLocked() error { + b, err := json.MarshalIndent(p.ledger, "", " ") + if err != nil { + return err + } + tmp := p.path + ".tmp" + if err := os.WriteFile(tmp, b, 0600); err != nil { + return err + } + return os.Rename(tmp, p.path) +} + +// ---------------- 通道名派生(★ 纪律 2) ---------------- + +// inputChannelName:inputch 可含斜杠(内核自己就用 child/)。 +func inputChannelName(id string) string { return "commondev/" + id } + +// outputChannelName:★ 必须只有 [A-Za-z0-9_-]。 +// +// 它会被拼成 output_send__,而上游要求函数名满足 ^[a-zA-Z0-9_-]{1,64}$。 +// 违反的后果不是「这个工具不可用」,而是**整条请求被 400、agent 整体失语**。 +func outputChannelName(id string) string { + return "commondev_" + safeNamePattern.ReplaceAllString(id, "_") +} + +// ---------------- 工具面(刀 1) ---------------- + +func (p *Plugin) registerTools() { + p.sdk.RegisterTool("commondev_project_add", sdk.ToolDef{ + Name: "commondev_project_add", + Description: "登记一个仓库(项目)。之后用 commondev_branch_open 开工程对话。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "项目名(英文,用作 id)"}, + "remote_url": map[string]interface{}{"type": "string", "description": "git 远程地址"}, + "platform": map[string]interface{}{"type": "string", "description": "github|gitea|gitcode"}, + "default_branch": map[string]interface{}{"type": "string", "description": "默认分支(默认 main)"}, + }, + "required": []string{"name", "remote_url"}, + }, + NoMemory: true, + Serial: true, // 写台账,顺序敏感 + }, p.handleProjectAdd) + + p.sdk.RegisterTool("commondev_project_list", sdk.ToolDef{ + Name: "commondev_project_list", + Description: "列出已登记的仓库与各自的工程对话。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + NoMemory: true, + ParallelSafe: true, + }, p.handleProjectList) + + p.sdk.RegisterTool("commondev_branch_open", sdk.ToolDef{ + Name: "commondev_branch_open", + Description: "开一条工程对话(分支线):建独立工作目录(由 mirror 本地 clone 派生)。" + + "★ 之后必须调 commondev_agent_up 拿到 session —— 通道依赖 session 才注册。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "project": map[string]interface{}{"type": "string", "description": "项目名"}, + "branch": map[string]interface{}{"type": "string", "description": "分支名(会做合法性派生)"}, + "work_desc": map[string]interface{}{"type": "string", "description": "这条线要做什么"}, + "agent_cli": map[string]interface{}{"type": "string", + "description": "执行后端(默认 pi)。可选 zcode 等 —— 由 " + + "runtime//driver.json 提供,插件核心不感知具体 CLI。"}, + "runtime": map[string]interface{}{"type": "string", + "description": "在哪跑:local(默认)| ssh。两个轴正交,可与 agent_cli 任意组合。"}, + "ssh_host": map[string]interface{}{"type": "string", + "description": "runtime=ssh 时的目标(user@host 或 ssh 别名);需先配好免密。"}, + }, + "required": []string{"project", "branch"}, + }, + NoMemory: true, + Serial: true, // 写台账 + 建目录 + }, p.handleBranchOpen) + + p.sdk.RegisterTool("commondev_job_status", sdk.ToolDef{ + Name: "commondev_job_status", + Description: "查后台作业进度(如 branch_open 的目录准备):pending|running|done|failed。" + + "★ 因为内核把工具调用限制在 60 秒,clone 等慢操作都转后台,用本工具追进度。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "job_id": map[string]interface{}{"type": "string", "description": "作业 id(branch_open 返回)"}, + "branch_id": map[string]interface{}{"type": "string", "description": "或按工程对话查其全部作业"}, + }, + }, + NoMemory: true, + ParallelSafe: true, + }, p.handleJobStatus) + + // —— 平台凭据(用户要求:主流系统的 ackey 保存与变量填充引用)—— + // ★ 工具参数里只出现 account **名**,绝不出现 secret;值支持 ${ENV} 引用。 + + // —— 刀 4:判据与复核(正向 + 变异)—— + // ★ 设计 §4.5:插件 GREEN ≠ 改对了。没有反向阶段,就是在给 + // 「测试通过」这个信号不断充值信用,直到它骗一次大的。 + p.sdk.RegisterTool("commondev_test_define", sdk.ToolDef{ + Name: "commondev_test_define", + Description: "**主管**定义一条判据(可迭代幂等:同定义重复登记不改动)。" + + "★ 必须同时给 mutations(变异规则)—— 没有它,复核时无法判断判据是否太松。" + + "script 退出码 0 = 通过。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "project": map[string]interface{}{"type": "string", "description": "项目名"}, + "name": map[string]interface{}{"type": "string", "description": "判据名(人读)"}, + "kind": map[string]interface{}{"type": "string", + "description": "unit | interface | stress | build"}, + "script": map[string]interface{}{"type": "string", "description": "命令(退出码 0 = 通过)"}, + "cwd": map[string]interface{}{"type": "string", "description": "可选:执行目录(相对工作目录)"}, + "expect": map[string]interface{}{"type": "string", "description": "可选:说明『通过』的含义(给人看)"}, + "timeout_sec": map[string]interface{}{"type": "number", "description": "可选:单次执行上限(默认 300)"}, + "branch_id": map[string]interface{}{"type": "string", "description": "可选:限定某条分支线(留空=项目级)"}, + "mutations": map[string]interface{}{ + "type": "array", + "description": "变异规则:把文件里的 old 换成 new,期望判据**报红**。" + + "不报红 ⇒ 判据太松。old 必须在文件里**恰好出现一次**。", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "如 M1"}, + "file": map[string]interface{}{"type": "string", "description": "相对工作目录的路径"}, + "old": map[string]interface{}{"type": "string", "description": "要替换的字面量(恰好出现一次)"}, + "new": map[string]interface{}{"type": "string", "description": "替换成什么"}, + "note": map[string]interface{}{"type": "string", "description": "可选:这条变异想验证什么"}, + }, + "required": []string{"file", "old", "new"}, + }, + }, + }, + "required": []string{"project", "name", "script"}, + }, + NoMemory: true, + Serial: true, + }, p.handleTestDefine) + + p.sdk.RegisterTool("commondev_test_list", sdk.ToolDef{ + Name: "commondev_test_list", + Description: "列出已定义的判据(含变异规则条数 —— 为 0 的判据是复核缺口)。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"project": map[string]interface{}{"type": "string", "description": "可选:只看某项目"}}}, + NoMemory: true, + ParallelSafe: true, + }, p.handleTestList) + + p.sdk.RegisterTool("commondev_review_submit", sdk.ToolDef{ + Name: "commondev_review_submit", + Description: "对一条分支线跑**复核**:正向(判据应通过)+ 反向(变异应被咬住)。" + + "结论有三档:pass / pass_with_gaps(正向绿但反向有缺口)/ fail。" + + "★ pass_with_gaps 不是「通过」——它是「判据没覆盖到」。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "branch_id": map[string]interface{}{"type": "string", "description": "工程对话 id(br-...)"}, + }, + "required": []string{"branch_id"}, + }, + NoMemory: true, + Serial: true, + }, p.handleReviewSubmit) + + p.sdk.RegisterTool("commondev_review_result", sdk.ToolDef{ + Name: "commondev_review_result", + Description: "取最近一次复核结论与历史(含每条判据的正向/反向证据)。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"branch_id": map[string]interface{}{"type": "string", "description": "可选:只看某分支"}}}, + NoMemory: true, + ParallelSafe: true, + }, p.handleReviewResult) + + // —— 刀 3:冲突预检与并行盘点 —— + p.sdk.RegisterTool("commondev_conflict_check", sdk.ToolDef{ + Name: "commondev_conflict_check", + Description: "在**提 PR 之前**检查本分支能否干净合进目标分支(默认 main)。" + + "用 git merge-tree 判定,**完全不碰工作区**(不动 HEAD、不留 MERGE_HEAD)," + + "所以可以随时安全地跑。并行开发时用它**提前**发现冲突 —— " + + "别等到 PR 开好了、判据绿了,才发现合不进去。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "branch_id": map[string]interface{}{"type": "string", "description": "工程对话 id(br-...)"}, + "target": map[string]interface{}{"type": "string", "description": "目标分支(默认 main)"}, + }, + "required": []string{"branch_id"}, + }, + NoMemory: true, + ParallelSafe: true, // 只读判定,不写任何东西 + }, p.handleConflictCheck) + + p.sdk.RegisterTool("commondev_parallel_status", sdk.ToolDef{ + Name: "commondev_parallel_status", + Description: "盘点当前**并行推进**的分支线(按项目分组)。" + + "并行只适合互不相干的模块 —— 派活前先看这个,确认没有两条线在改同一批文件" + + "(否则冲突会堆到 merge 时才爆发)。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + NoMemory: true, + ParallelSafe: true, + }, p.handleParallelStatus) + + // —— 补全:pi 配置面(设计 §3.6.4 的权威细化)—— + p.sdk.RegisterTool("commondev_pi_config_get", sdk.ToolDef{ + Name: "commondev_pi_config_get", + Description: "读 pi 的配置(providers / settings / 默认源)。" + + "★ api_key **绝不返回**(只报 key_set)——与 commondev_account_list 同一条纪律。" + + "scope: plugin=插件自己的 agentdir(默认);project=工作目录下的 .pi。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "scope": map[string]interface{}{"type": "string", "description": "plugin(默认)| project"}, + "workdir": map[string]interface{}{"type": "string", "description": "scope=project 时的工程工作目录"}, + }, + }, + NoMemory: true, + ParallelSafe: true, + }, p.handlePiConfigGet) + + p.sdk.RegisterTool("commondev_pi_source_add", sdk.ToolDef{ + Name: "commondev_pi_source_add", + Description: "给内置 pi 添加/更新一个 LLM 源(**直接写 pi 的 models.json/auth.json**," + + "不造中间层)。★ 改前备份、写后回读校验,失败自动回滚;api_key 不回显。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "源名([A-Za-z0-9_-])"}, + "base_url": map[string]interface{}{"type": "string", "description": "API base url"}, + "api_key": map[string]interface{}{"type": "string", "description": "可选:密钥(写入 auth.json,不回显)"}, + "model": map[string]interface{}{"type": "string", "description": "模型 id"}, + "adapter": map[string]interface{}{"type": "string", "description": "openai | anthropic(未知会报错,不猜)"}, + "context_window": map[string]interface{}{"type": "number", "description": "可选:上下文窗口"}, + "max_tokens": map[string]interface{}{"type": "number", "description": "可选:最大输出"}, + "vision": map[string]interface{}{"type": "boolean", "description": "可选:是否支持图像输入"}, + "reasoning": map[string]interface{}{"type": "boolean", "description": "可选:是否推理模型"}, + }, + "required": []string{"name", "base_url", "model", "adapter"}, + }, + NoMemory: true, + Serial: true, + }, p.handlePiSourceAdd) + + p.sdk.RegisterTool("commondev_pi_package_install", sdk.ToolDef{ + Name: "commondev_pi_package_install", + Description: "为内置 pi 装扩展包(等价 pi install)。" + + "scope: plugin=装进插件自己的 agentdir(默认);project=装进工作目录(要 workdir)。" + + "★ 绝不碰用户全局 ~/.pi/agent;失败时把 pi 的原话带回来。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "source": map[string]interface{}{"type": "string", "description": "如 npm:@foo/bar、git:github.com/u/r"}, + "scope": map[string]interface{}{"type": "string", "description": "plugin(默认)| project"}, + "workdir": map[string]interface{}{"type": "string", "description": "scope=project 时的工程工作目录"}, + }, + "required": []string{"source"}, + }, + NoMemory: true, + Serial: true, + }, p.handlePiPackageInstall) + + // —— 补全:仓库搜索(设计 §4.1)—— + + // —— 项目打开兼容别名(续篇早期文档使用 project_open)—— + p.sdk.RegisterTool("commondev_project_open", sdk.ToolDef{ + Name: "commondev_project_open", Description: "commondev_project_add 的兼容别名:登记项目。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string"}, "remote_url": map[string]interface{}{"type": "string"}, "platform": map[string]interface{}{"type": "string"}, "default_branch": map[string]interface{}{"type": "string"}, + }, "required": []string{"name", "remote_url"}}, NoMemory: true, Serial: true, + }, p.handleProjectOpen) + + // —— pi 包更新(续篇 §3.5.2)—— + p.sdk.RegisterTool("commondev_pi_plugin_update", sdk.ToolDef{ + Name: "commondev_pi_plugin_update", Description: "更新内置 pi 扩展(可指定 source;不指定则更新全部)。scope=plugin|project。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"source": map[string]interface{}{"type": "string"}, "scope": map[string]interface{}{"type": "string"}, "workdir": map[string]interface{}{"type": "string"}}}, NoMemory: true, Serial: true, + }, p.handlePiPluginUpdate) + + // —— pi 配置/源完整入口(设计 §3.6.4)—— + p.sdk.RegisterTool("commondev_pi_config_set", sdk.ToolDef{ + Name: "commondev_pi_config_set", Description: "设置 pi settings.json 的一个键(改前备份、写后校验,失败回滚;scope=plugin|project)。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{ + "key": map[string]interface{}{"type": "string"}, "value": map[string]interface{}{"type": "string", "description": "JSON 值或普通字符串"}, "scope": map[string]interface{}{"type": "string"}, "workdir": map[string]interface{}{"type": "string"}, + }, "required": []string{"key", "value"}}, NoMemory: true, Serial: true, + }, p.handlePiConfigSet) + p.sdk.RegisterTool("commondev_pi_source_remove", sdk.ToolDef{ + Name: "commondev_pi_source_remove", Description: "删除 pi 的一个 LLM 源(models.json + auth.json),改前备份、失败回滚。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"name": map[string]interface{}{"type": "string"}}, "required": []string{"name"}}, NoMemory: true, Serial: true, + }, p.handlePiSourceRemove) + p.sdk.RegisterTool("commondev_pi_mcp_list", sdk.ToolDef{ + Name: "commondev_pi_mcp_list", Description: "列出 pi MCP 配置(scope=plugin|project)。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"scope": map[string]interface{}{"type": "string"}, "workdir": map[string]interface{}{"type": "string"}}}, NoMemory: true, ParallelSafe: true, + }, p.handlePiMCPList) + p.sdk.RegisterTool("commondev_pi_mcp_add", sdk.ToolDef{ + Name: "commondev_pi_mcp_add", Description: "添加 pi MCP;stdio 的 source 必须是 JSON 字符串数组,避免 shell 注入;也可给 url。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"name": map[string]interface{}{"type": "string"}, "source": map[string]interface{}{"type": "string"}, "url": map[string]interface{}{"type": "string"}, "scope": map[string]interface{}{"type": "string"}, "workdir": map[string]interface{}{"type": "string"}}, "required": []string{"name"}}, NoMemory: true, Serial: true, + }, p.handlePiMCPAdd) + p.sdk.RegisterTool("commondev_pi_mcp_remove", sdk.ToolDef{ + Name: "commondev_pi_mcp_remove", Description: "删除 pi MCP(scope=plugin|project)。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"name": map[string]interface{}{"type": "string"}, "scope": map[string]interface{}{"type": "string"}, "workdir": map[string]interface{}{"type": "string"}}, "required": []string{"name"}}, NoMemory: true, Serial: true, + }, p.handlePiMCPRemove) + + // —— pi 插件命名兼容别名(早期设计用 plugin,细化稿用 package)—— + p.sdk.RegisterTool("commondev_pi_plugin_install", sdk.ToolDef{ + Name: "commondev_pi_plugin_install", Description: "commondev_pi_package_install 的兼容别名。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{ + "source": map[string]interface{}{"type": "string"}, "scope": map[string]interface{}{"type": "string"}, "workdir": map[string]interface{}{"type": "string"}, + }, "required": []string{"source"}}, NoMemory: true, Serial: true, + }, p.handlePiPackageInstall) + p.sdk.RegisterTool("commondev_pi_plugin_list", sdk.ToolDef{ + Name: "commondev_pi_plugin_list", Description: "列出内置 pi 已安装扩展(分 scope)。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"scope": map[string]interface{}{"type": "string"}, "workdir": map[string]interface{}{"type": "string"}}}, NoMemory: true, ParallelSafe: true, + }, p.handlePiPluginList) + p.sdk.RegisterTool("commondev_pi_plugin_remove", sdk.ToolDef{ + Name: "commondev_pi_plugin_remove", Description: "卸载内置 pi 扩展(分 scope)。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"source": map[string]interface{}{"type": "string"}, "scope": map[string]interface{}{"type": "string"}, "workdir": map[string]interface{}{"type": "string"}}, "required": []string{"source"}}, NoMemory: true, Serial: true, + }, p.handlePiPluginRemove) + + // —— 判据单阶段工具(review_submit 仍是推荐的一键正反复核)—— + p.sdk.RegisterTool("commondev_test_run", sdk.ToolDef{ + Name: "commondev_test_run", Description: "只跑判据正向阶段;正向通过不代表改对了,还要跑 test_mutate。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"branch_id": map[string]interface{}{"type": "string"}}, "required": []string{"branch_id"}}, NoMemory: true, Serial: true, + }, p.handleTestRun) + p.sdk.RegisterTool("commondev_test_mutate", sdk.ToolDef{ + Name: "commondev_test_mutate", Description: "只跑判据反向变异阶段;变异未被咬住 ⇒ 判据太松。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"branch_id": map[string]interface{}{"type": "string"}}, "required": []string{"branch_id"}}, NoMemory: true, Serial: true, + }, p.handleTestMutate) + + // —— 工作目录重新准备(已有 branch 的幂等修复入口)—— + p.sdk.RegisterTool("commondev_workdir_prepare", sdk.ToolDef{ + Name: "commondev_workdir_prepare", Description: "为已有工程对话重新准备/复用工作目录(后台 clone,幂等)。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"branch_id": map[string]interface{}{"type": "string"}}, "required": []string{"branch_id"}}, NoMemory: true, Serial: true, + }, p.handleWorkdirPrepare) + + // —— 补全:PR 工作流(审计发现:设计 §4.1 有 pr_open/pr_status,实现里缺)—— + + // —— 补全:agent 生命周期(agent_state / agent_down)—— + p.sdk.RegisterTool("commondev_agent_state", sdk.ToolDef{ + Name: "commondev_agent_state", + Description: "查某条工程对话的 agent 状态:session / runtime / 通道 / **进程是否还活着**。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"branch_id": map[string]interface{}{"type": "string", "description": "工程对话 id"}}, "required": []string{"branch_id"}}, + NoMemory: true, + ParallelSafe: true, + }, p.handleAgentState) + + p.sdk.RegisterTool("commondev_agent_down", sdk.ToolDef{ + Name: "commondev_agent_down", + Description: "停掉一条工程对话的 agent 进程(**保留工作目录与分支状态**)。" + + "与 commondev_branch_close 的区别:这是「暂停」,那是「收尾」(会删目录)。幂等。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"branch_id": map[string]interface{}{"type": "string", "description": "工程对话 id"}}, "required": []string{"branch_id"}}, + NoMemory: true, + Serial: true, + }, p.handleAgentDown) + + // —— 补全:孤儿目录清理(审计发现:listOrphanDirs 曾是死代码)—— + p.sdk.RegisterTool("commondev_workdir_clean", sdk.ToolDef{ + Name: "commondev_workdir_clean", + Description: "清理 program/ 下的**孤儿目录**(台账里没有记录的),并做**凭据残留检查**" + + "(扫 .git/config 的 remote URL 是否内嵌 token,设计 §7.2)。" + + "★ dry_run 默认 true —— 删除不可逆,先看清要删什么。只碰本插件生成的目录。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "dry_run": map[string]interface{}{"type": "boolean", "description": "true(默认)=只列出;false=实际删除"}, + }, + }, + NoMemory: true, + Serial: true, + }, p.handleWorkdirClean) + + // —— 刀 2:主管侧的审批与合并(需 supervisor 角色凭据)—— + + // —— 远端端点表:**按 host 存一套、按 (host,cli) 覆盖一套** —— + // + // 为什么必须是工具而不是只改 config.yaml:多台机器时用户要频繁 + // 增删改这些值,而 config.yaml 改完还要重启(且键名靠猜)。 + p.sdk.RegisterTool("commondev_endpoint_set", sdk.ToolDef{ + Name: "commondev_endpoint_set", + Description: "配置**某台远端机器**的 CLI 路径 / node / 目录(多台机器各配各的,互不干扰)。" + + "也可只针对**某个后端**(cli 参数)设置,优先于该机器的默认值。" + + "host 与 branch_open 的 ssh_host 逐字一致(含 user@host:port 或 ssh alias)。" + + "若不传 verify,会先 ssh 到该机器确认 cli_path 真实存在 —— 避免配一个不存在的路径。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "host": map[string]interface{}{"type": "string", "description": "ssh_host(与 branch_open 的 ssh_host 一致)"}, + "cli": map[string]interface{}{"type": "string", "description": "后端名(如 zcode/pi)。给了就是**该后端专属**设置,优先于机器默认"}, + "cli_path": map[string]interface{}{"type": "string", "description": "该机器上的 CLI 入口(必须**远端**路径,不能是本机路径或 {rawfile})"}, + "node_path": map[string]interface{}{"type": "string", "description": "该机器上的 node(默认 node,走远端 PATH)"}, + "runtime_root": map[string]interface{}{"type": "string", "description": "该机器上放 sidecar 脚本的目录"}, + "workdir_root": map[string]interface{}{"type": "string", "description": "该机器上放工程目录的根(仅 host 级)"}, + "note": map[string]interface{}{"type": "string", "description": "备注(给人看)"}, + "verify": map[string]interface{}{"type": "boolean", "description": "true(默认)=先 ssh 确认 cli_path 存在;false=跳过(离线配置)"}, + }, + "required": []string{"host"}, + }, + NoMemory: true, + Serial: true, + }, p.handleEndpointSet) + + p.sdk.RegisterTool("commondev_endpoint_list", sdk.ToolDef{ + Name: "commondev_endpoint_list", + Description: "列出所有远端机器的端点配置(按 host,含各自的后端专属覆盖)。host 留空则列出全部。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "host": map[string]interface{}{"type": "string", "description": "只看某台机器(留空 = 全部)"}, + }, + }, + NoMemory: true, + ParallelSafe: true, + }, p.handleEndpointList) + + p.sdk.RegisterTool("commondev_endpoint_remove", sdk.ToolDef{ + Name: "commondev_endpoint_remove", + Description: "删掉某台机器的端点配置(或只删某个后端的专属覆盖)。" + + "host 自带配置与后端覆盖**分开删**:只给 host 时,若它有后端覆盖会拒绝并提示 —— " + + "避免“以为删了机器,实际子项还在,下次又把老路径拿出来用”。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "host": map[string]interface{}{"type": "string", "description": "机器(与 branch_open 的 ssh_host 一致)"}, + "cli": map[string]interface{}{"type": "string", "description": "只删该后端的专属覆盖"}, + "force": map[string]interface{}{"type": "boolean", "description": "true = 连同该机器的后端覆盖一起删掉"}, + }, + "required": []string{"host"}, + }, + NoMemory: true, + Serial: true, + }, p.handleEndpointRemove) + + // —— 刀 2:分支保护(让「必须有主管复审」成为机制而非礼貌)—— + + p.sdk.RegisterTool("enc_shell", sdk.ToolDef{ + Name: "enc_shell", + Description: "执行命令,并用 @{别名} 引用自动填充**解密后**的加密信息。" + + "例:git push https://x-access-token:@{gh-token}@github.com/o/r.git feat/x," + + "或 curl -H \"Authorization: token @{gitea-local}\" http://host/api/v1/user。" + + "★ 明文不进 argv(脚本走 stdin)、输出会脱敏。" + + "⚠ 它不是沙箱:能防意外回显,不防恶意导出(如 base64)。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", + "description": "要执行的命令;用 @{别名} 引用加密信息或平台凭据"}, + "workdir": map[string]interface{}{"type": "string", + "description": "工作目录(必须在 program/ 工程目录下;留空用当前目录)"}, + "timeout_sec": map[string]interface{}{"type": "number", + "description": "超时秒数(上限 55,因为内核工具调用硬限 60s)"}, + }, + "required": []string{"command"}, + }, + NoMemory: true, // ★ 命令输出可能含敏感信息,不进记忆 + Serial: true, // 顺序敏感 + 可能与 git 争抢工作树 + }, p.handleEncryptedShell) + + // ———— 加密信息工具族(独立命名空间 enc_*)———— + // 用户要求:「独立为一个加密 shell 工具族,含存入加密信息(提供别名)、使用加密信息。 + // 注意存入过程也需要注意」 + // + // ★ 存入过程的泄露面(tool 描述里明写):工具参数会进会话记录/日志, + // 所以优先用 ${ENV_NAME} 或 @file:/path;直传明文会在返回值里告警。 + p.sdk.RegisterTool("enc_store", sdk.ToolDef{ + Name: "enc_store", + Description: "存入一条加密信息并给它一个**别名**(后续用 @{别名} 引用)。" + + "★ 值的抗泄露优先级:`${ENV_NAME}`(环境变量,明文从不经工具参数)> " + + "`@file:/path`(从文件读)> 直接明文(**会进本会话记录/日志**,插件会告警)。" + + "存盘为 AES-256-GCM 密文,返回值只给别名+指纹,绝不回显值。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "alias": map[string]interface{}{"type": "string", "description": "别名([A-Za-z0-9_-]),后续用 @{别名} 引用"}, + "value": map[string]interface{}{"type": "string", + "description": "值:${ENV_NAME} | @file:/path | 明文(推荐前两种)"}, + "note": map[string]interface{}{"type": "string", "description": "可选:这是什么(便于日后辨认)"}, + }, + "required": []string{"alias", "value"}, + }, + NoMemory: true, // ★ 参数/结果不进记忆 + Serial: true, + }, p.handleEncStore) + + p.sdk.RegisterTool("enc_list", sdk.ToolDef{ + Name: "enc_list", + Description: "列出已存的加密信息(别名/来源/指纹/备注)——**绝不返回值本体**。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + NoMemory: true, + ParallelSafe: true, + }, p.handleEncList) + + p.sdk.RegisterTool("enc_show", sdk.ToolDef{ + Name: "enc_show", + Description: "查看某个别名的元信息(来源/指纹/备注)——**不返回值本体**;要看值就在 enc_shell 里用 @{别名}。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "alias": map[string]interface{}{"type": "string", "description": "别名"}, + }, + "required": []string{"alias"}, + }, + NoMemory: true, + ParallelSafe: true, + }, p.handleEncShow) + + p.sdk.RegisterTool("enc_rm", sdk.ToolDef{ + Name: "enc_rm", + Description: "删除一条加密信息。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "alias": map[string]interface{}{"type": "string", "description": "别名"}, + }, + "required": []string{"alias"}, + }, + NoMemory: true, + Serial: true, + }, p.handleEncRm) + + // ———— 内置 pi 的 LLM 源配置(用户要求:插件要提供对应 tool 做对应配置)———— + // 这些工具直接写 pi 自己的配置文件(models.json / auth.json / settings.json), + // 不另建存储 —— pi 本身就有完整的配置系统。 + p.sdk.RegisterTool("commondev_pi_source_import", sdk.ToolDef{ + Name: "commondev_pi_source_import", + Description: "把 HomeAgent 内核已配的 LLM 源导入内置 pi(写它的 models.json/auth.json," + + "默认源写 settings.json)。★ 不导入的话内置 pi 会报 'No API key found'。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + NoMemory: true, + Serial: true, + }, p.handlePiSourceImport) + + p.sdk.RegisterTool("commondev_pi_source_list", sdk.ToolDef{ + Name: "commondev_pi_source_list", + Description: "列出内置 pi 已配的 LLM 源与当前默认源(**不返回 api key**)。", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + NoMemory: true, + ParallelSafe: true, + }, p.handlePiSourceList) + + p.sdk.RegisterTool("commondev_pi_source_default", sdk.ToolDef{ + Name: "commondev_pi_source_default", + Description: "指定内置 pi 默认用哪个 LLM 源(写 settings.json 的 defaultProvider/defaultModel)。" + + "源必须已存在于 models.json。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string", "description": "源名(models.json 里的 provider 名)"}, + "model": map[string]interface{}{"type": "string", "description": "可选:该源的默认模型"}, + }, + "required": []string{"name"}, + }, + NoMemory: true, + Serial: true, + }, p.handlePiSourceDefault) + + p.sdk.RegisterTool("commondev_agent_up", sdk.ToolDef{ + Name: "commondev_agent_up", + Description: "为一条工程对话拉起内置 pi 并拿到 session,随后注册该工程的一对通道。" + + "★ 顺序:先 up(拿 session)→ 插件才注册通道 → 再建驻留子并划入。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "branch_id": map[string]interface{}{"type": "string", "description": "工程对话 id"}, + "session_id": map[string]interface{}{"type": "string", "description": "可选:恢复指定会话;留空则新建"}, + }, + "required": []string{"branch_id"}, + }, + NoMemory: true, + Serial: true, // 拉起进程 + 注册通道 + }, p.handleAgentUp) + + p.sdk.RegisterTool("commondev_branch_status", sdk.ToolDef{ + Name: "commondev_branch_status", + Description: "查一条工程对话的详细状态(四元组 + 投递记录)。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "branch_id": map[string]interface{}{"type": "string", "description": "工程对话 id"}, + }, + "required": []string{"branch_id"}, + }, + NoMemory: true, + ParallelSafe: true, + }, p.handleBranchStatus) + + p.sdk.RegisterTool("commondev_branch_close", sdk.ToolDef{ + Name: "commondev_branch_close", + Description: "收尾一条工程对话:杀 sidecar、删工作目录(合入后即删,不留残留)。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "branch_id": map[string]interface{}{"type": "string", "description": "工程对话 id"}, + }, + "required": []string{"branch_id"}, + }, + NoMemory: true, + Serial: true, + }, p.handleBranchClose) +} + +// ---------------- 工具实现 ---------------- + +func (p *Plugin) handleProjectAdd(args map[string]interface{}) (interface{}, error) { + name := strings.TrimSpace(str(args, "name")) + url := strings.TrimSpace(str(args, "remote_url")) + if name == "" || url == "" { + return map[string]interface{}{"error": "name 与 remote_url 必填"}, nil + } + if !validName(name) { + return map[string]interface{}{"error": "项目名只允许 [A-Za-z0-9_-](会进目录名与通道名)"}, nil + } + def := strings.TrimSpace(str(args, "default_branch")) + if def == "" { + def = "main" + } + + p.mu.Lock() + defer p.mu.Unlock() + if _, dup := p.ledger.Projects[name]; dup { + return map[string]interface{}{"error": fmt.Sprintf("项目 %s 已存在", name)}, nil + } + p.ledger.Projects[name] = &Project{ + Name: name, RemoteURL: url, + Platform: strings.TrimSpace(str(args, "platform")), + DefaultBranch: def, + CreatedAt: time.Now().Format(time.RFC3339), + } + if err := p.saveLocked(); err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + return map[string]interface{}{"status": "ok", "project": name}, nil +} + +func (p *Plugin) handleProjectOpen(args map[string]interface{}) (interface{}, error) { + return p.handleProjectAdd(args) +} + +func (p *Plugin) handleProjectList(args map[string]interface{}) (interface{}, error) { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]map[string]interface{}, 0, len(p.ledger.Projects)) + for _, pr := range p.ledger.Projects { + branches := []map[string]interface{}{} + for _, b := range p.ledger.Branches { + if b.ProjectID == pr.Name { + branches = append(branches, map[string]interface{}{ + "id": b.ID, "name": b.Name, "status": b.Status, + "session_id": b.SessionID, + }) + } + } + out = append(out, map[string]interface{}{ + "name": pr.Name, "remote_url": pr.RemoteURL, + "platform": pr.Platform, "default_branch": pr.DefaultBranch, + "branches": branches, + }) + } + return map[string]interface{}{"projects": out, "count": len(out)}, nil +} + +func (p *Plugin) handleBranchOpen(args map[string]interface{}) (interface{}, error) { + proj := strings.TrimSpace(str(args, "project")) + branch := strings.TrimSpace(str(args, "branch")) + if proj == "" || branch == "" { + return map[string]interface{}{"error": "project 与 branch 必填"}, nil + } + + p.mu.Lock() + defer p.mu.Unlock() + + pr, ok := p.ledger.Projects[proj] + if !ok { + return map[string]interface{}{"error": fmt.Sprintf("项目 %s 未登记(先 commondev_project_add)", proj)}, nil + } + + // ★ 唯一性闸:独立 clone 没有 worktree 的「同分支不可双挂」保护, + // 必须在这里补(设计 §4.3)。同名且未结的分支 → 响亮拒绝,不静默复用。 + for _, b := range p.ledger.Branches { + if b.ProjectID == proj && b.Name == branch && (b.Status == "open" || b.Status == "review") { + return map[string]interface{}{ + "error": fmt.Sprintf("分支 %s 已有未结的工程对话 %s(状态 %s)——拒绝重复开(唯一性闸)", + branch, b.ID, b.Status), + }, nil + } + } + + // ★ 目录映射走单一事实源(workdir.go): + // 同仓库不同分支必然得到不同目录(hash 含分支)。 + workdir := branchDir(p.programRoot(), proj, branch) + id := fmt.Sprintf("br-%s-%s", pr.Name, safeNamePattern.ReplaceAllString(branch, "_")) + + // ★ 两个正交轴的取值在这里定下来(此前硬编码 "local",于是 + // runtime=ssh 的参数被静默忽略 —— 用户传了 ssh 却跑在本地, + // 且没有任何提示。这是典型的"参数被吞")。 + // + // Runtime = 在哪跑(local|ssh) + // AgentCLI = 跑什么(pi|zcode|...,由 runtime//driver.json 描述) + runtimeVal := runtimeOrDefault(str(args, "runtime")) + sshHost := strings.TrimSpace(str(args, "ssh_host")) + if runtimeVal == "ssh" && sshHost == "" { + return map[string]interface{}{ + "error": "runtime=ssh 时必须给 ssh_host(user@host 或 ~/.ssh/config 里的别名)", + }, nil + } + if runtimeVal != "local" && runtimeVal != "ssh" { + // 不认识的 runtime → 响亮拒绝,不静默降级为 local + // (静默降级会让"我以为跑在远端"的假设一路错下去) + return map[string]interface{}{ + "error": fmt.Sprintf("未知 runtime %q(只支持 local|ssh)", runtimeVal), + }, nil + } + + // ★ 快操作:只登记台账 + 建目录,**不在这里做慢的 clone**。 + // 内核把工具调用限制在 60s(toolcall.go:62),同步 clone 必超时。 + if err := os.MkdirAll(p.programRoot(), 0o700); err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + + jobID := fmt.Sprintf("job-%s-%d", id, time.Now().UnixNano()%100000) + b := &Branch{ + ID: id, ProjectID: proj, Name: branch, + WorkDesc: strings.TrimSpace(str(args, "work_desc")), + Status: "open", + Workdir: workdir, + // 两个正交轴(见上):Runtime 决定在哪跑,AgentCLI 决定跑什么。 + Runtime: runtimeVal, + SSHHost: sshHost, + AgentCLI: strings.TrimSpace(str(args, "agent_cli")), + CreatedAt: time.Now().Format(time.RFC3339), + } + p.ledger.Branches[id] = b + p.ledger.Jobs[jobID] = &Job{ + ID: jobID, BranchID: id, Kind: "prepare_workdir", + Status: "pending", StartedAt: time.Now().Format(time.RFC3339), + } + if err := p.saveLocked(); err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + + // 慢操作转后台(clone mirror + 派生工作目录) + // ★ 远端线的目录准备也在**远端**做(见 workdir_remote.go): + // 本地 clone 出来的目录远端看不到,`prepareWorkdir` 那套是纯本地的。 + go p.runPrepareWorkdir(jobID, id, pr, branch, workdir, b.Runtime, b.SSHHost) + + return map[string]interface{}{ + "status": "ok", + "branch_id": id, + "workdir": workdir, + "job_id": jobID, + "next": "目录准备在后台进行(clone 可能超过 60s 工具上限)——用 commondev_job_status 查进度;ready 后再调 commondev_agent_up", + }, nil +} + +// runPrepareWorkdir 是 branch_open 的后台部分:建 mirror + 派生独立 clone。 +// +// 单独跑在 goroutine 里,不进工具调用路径 —— 因此不受 60s 限制。 +func (p *Plugin) runPrepareWorkdir(jobID, branchID string, pr *Project, branch, workdir, runtime, sshHost string) { + // ★ 两条路径必须在**同一台机器**上准备目录: + // local → 本机 prepareWorkdir + // ssh → 远端 prepareRemoteWorkdir + // + // 走错的话症状极具迷惑性:本地 clone 成功、job 显示 done, + // 但远端 pi 的 cwd 指向一个不存在的目录 ⇒ "agent 起来了但一执行就说目录不存在"。 + if runtime == "ssh" { + b := &Branch{ID: branchID, Name: branch, ProjectID: pr.Name, Runtime: runtime, SSHHost: sshHost} + t, err := parseSSHTarget(b) + if err != nil { + p.setJob(jobID, "failed", "", err.Error()) + return + } + root, err := p.remoteWorkdirRoot(t.Host) + if err != nil { + p.setJob(jobID, "failed", "", err.Error()) + return + } + t.WorkdirRoot = root + // 远端目录名与本地同构:/- + remoteWorkdir := root + "/" + dirName(pr.Name, branch) + p.setJob(jobID, "running", "在远端 "+t.Host+" 准备目录", "") + if err := p.prepareRemoteWorkdir(t, pr, branch, remoteWorkdir); err != nil { + p.setJob(jobID, "failed", "", err.Error()) + return + } + // ★ 台账里的 Workdir 必须改成**远端路径** —— + // 留着本地路径会让 agent_up 把远端 pi 的 cwd 指到本地目录名上。 + p.mu.Lock() + if bb := p.ledger.Branches[branchID]; bb != nil { + bb.Workdir = remoteWorkdir + } + p.mu.Unlock() + if err := p.saveLocked(); err != nil { + p.setJob(jobID, "done", "目录已就绪但台账保存失败: "+err.Error(), "") + return + } + if sha, err := p.remoteCaptureBaseSHA(t, remoteWorkdir); err != nil { + p.setJob(jobID, "done", + "远端目录已就绪;⚠ base_sha 未记下: "+err.Error(), "") + return + } else { + p.mu.Lock() + if bb := p.ledger.Branches[branchID]; bb != nil { + bb.BaseSHA = sha + } + p.mu.Unlock() + _ = p.saveLocked() + p.setJob(jobID, "done", + fmt.Sprintf("远端目录已就绪(%s);base_sha=%s", t.Host, shortSHA(sha)), "") + return + } + } + + p.setJob(jobID, "running", "开始准备目录", "") + if err := p.prepareWorkdir(pr, branch, workdir); err != nil { + p.setJob(jobID, "failed", "", err.Error()) + return + } + // ★ 刀 3:目录就绪后**立刻**记 base_sha —— 必须在 agent 开始改之前。 + // + // 为什么必须在这里:agent 一旦提交,HEAD 就不再是"这条线从哪出发"。 + // 没有 base_sha,两件事做不了:① merge 时判断该不该 rebase; + // ② 事后重放这次改动(设计 §4.2 明确要求)。 + // + // 失败不阻断:拿不到 base_sha 只影响后续冲突判定的精度, + // 而目录本身是好的 —— 但要**如实记进作业详情**,不静默吞掉。 + sha, err := p.captureBaseSHA(branchID, workdir) + if err != nil { + p.setJob(jobID, "done", + "目录已就绪(已切到目标分支);⚠ base_sha 未记下: "+err.Error(), "") + return + } + p.setJob(jobID, "done", + fmt.Sprintf("目录已就绪(已切到目标分支);base_sha=%s", shortSHA(sha)), "") +} + +// setJob 更新后台作业状态(并落盘,保证重启后台账仍可查)。 +func (p *Plugin) setJob(id, status, detail, errMsg string) { + p.mu.Lock() + defer p.mu.Unlock() + j, ok := p.ledger.Jobs[id] + if !ok { + return + } + j.Status = status + if detail != "" { + j.Detail = detail + } + if errMsg != "" { + j.Err = errMsg + } + if status == "done" || status == "failed" { + j.EndedAt = time.Now().Format(time.RFC3339) + } + _ = p.saveLocked() +} + +func (p *Plugin) handleJobStatus(args map[string]interface{}) (interface{}, error) { + jobID := strings.TrimSpace(str(args, "job_id")) + branchID := strings.TrimSpace(str(args, "branch_id")) + + p.mu.Lock() + defer p.mu.Unlock() + + if jobID != "" { + j, ok := p.ledger.Jobs[jobID] + if !ok { + return map[string]interface{}{"error": fmt.Sprintf("作业 %s 不存在", jobID)}, nil + } + return map[string]interface{}{"job": j}, nil + } + if branchID != "" { + jobs := []*Job{} + for _, j := range p.ledger.Jobs { + if j.BranchID == branchID { + jobs = append(jobs, j) + } + } + return map[string]interface{}{"jobs": jobs, "count": len(jobs)}, nil + } + // 都不给:列出最近的作业(便于排查) + jobs := []*Job{} + for _, j := range p.ledger.Jobs { + jobs = append(jobs, j) + } + return map[string]interface{}{"jobs": jobs, "count": len(jobs)}, nil +} + +func (p *Plugin) handleAgentUp(args map[string]interface{}) (interface{}, error) { + id := strings.TrimSpace(str(args, "branch_id")) + if id == "" { + return map[string]interface{}{"error": "branch_id 必填"}, nil + } + + p.mu.Lock() + b, ok := p.ledger.Branches[id] + p.mu.Unlock() + if !ok { + return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", id)}, nil + } + + // ① 拉起 sidecar(内含 pi 会话),拿到 session_id + sessionID := strings.TrimSpace(str(args, "session_id")) + if sessionID == "" { + sessionID = id // 用工程 id 作会话 id:可复算、可恢复 + } + sc, err := p.startSidecar(b, sessionID) + if err != nil { + // ★ 响亮失败:不静默降级、不冒充成功 + return map[string]interface{}{"error": fmt.Sprintf("拉起 pi 失败: %v", err)}, nil + } + + // ② ★ 此刻才注册通道(先有 session,才有通道) + inCh := inputChannelName(id) + outCh := outputChannelName(id) + + // inputch:pi → agent 的回投入口(控制流不进记忆) + if err := p.sdk.RegisterInputChannel(inCh, sdk.ChannelDef{NoMemory: true}); err != nil { + sc.Close() + return map[string]interface{}{"error": fmt.Sprintf("注册 inputch 失败: %v", err)}, nil + } + + // outputch:agent → pi 的入口。★ 名已派生为合法字符集。 + if err := p.sdk.RegisterOutputChannel(outCh, sdk.CapText, + fmt.Sprintf("工程对话 %s:给内置 pi 投递任务", id), + sdk.ChannelDef{NoMemory: true}, + func(cargs map[string]interface{}) (interface{}, error) { + return p.deliverToPi(id, cargs) + }); err != nil { + sc.Close() + return map[string]interface{}{"error": fmt.Sprintf("注册 outputch 失败: %v", err)}, nil + } + + p.mu.Lock() + b.SessionID = sessionID + b.SessionName = "br-" + id // U6:可读名,便于在 pi 会话列表里认出 + b.InputCh = inCh + b.OutputCh = outCh + p.sidecars[id] = sc + err = p.saveLocked() + p.mu.Unlock() + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + + res := map[string]interface{}{ + "status": "ok", + "branch_id": id, + "session_id": sessionID, + "session_name": b.SessionName, + "agent_cli": cliOrPi(b.AgentCLI), + "input_ch": inCh, + "output_ch": outCh, + "next": fmt.Sprintf("建驻留子并划入:input_chs=%s allowed_outputs=%s;"+ + "之后给它下任务就走 output_send__%s", inCh, outCh, outCh), + } + if b.PiVersion != "" { + res["pi_version"] = b.PiVersion + } else { + res["pi_version_warning"] = "sidecar 未能回显内层 pi 版本(U8 要求可锁定)" + } + return res, nil +} + +// deliverToPi 是 outputch 的 handler —— 即「把给 pi 发消息包装一下」。 +// +// 内核的输出门 output_send__ 打到这里,我们查台账拿 session_id 后投给 pi。 +func (p *Plugin) deliverToPi(branchID string, cargs map[string]interface{}) (interface{}, error) { + payload, _ := cargs["payload"].(string) + if strings.TrimSpace(payload) == "" { + return map[string]interface{}{"status": "unconfirmed", "note": "payload 为空"}, nil + } + + p.mu.Lock() + b := p.ledger.Branches[branchID] + sc := p.sidecars[branchID] + if b == nil { + p.mu.Unlock() + return nil, fmt.Errorf("工程对话 %s 不存在", branchID) + } + wantSession := b.SessionID + p.mu.Unlock() + + if sc == nil { + return nil, fmt.Errorf("工程对话 %s 的 pi 未拉起(先 commondev_agent_up)", branchID) + } + + res, err := sc.call("prompt", map[string]interface{}{ + "session_id": wantSession, + "text": payload, + }) + if err != nil { + p.recordDelivery(branchID, wantSession, payload, "failed", err.Error()) + return nil, fmt.Errorf("投递给 pi 失败: %w", err) + } + + // ★ 纪律 4:断言 session 未变 —— 变了即响亮失败(不静默改投新会话) + got := wantSession + if m, ok := res.(map[string]interface{}); ok { + if s, _ := m["session_id"].(string); s != "" { + got = s + } + } + if got != wantSession { + msg := fmt.Sprintf("会话漂移:期望 %s,实际 %s —— 拒绝继续(不静默改投新会话)", wantSession, got) + p.recordDelivery(branchID, got, payload, "failed", msg) + return nil, fmt.Errorf("%s", msg) + } + + p.recordDelivery(branchID, got, payload, "sent", "") + return map[string]interface{}{"status": "sent"}, nil +} + +func (p *Plugin) recordDelivery(branchID, sessionID, text, status, note string) { + p.mu.Lock() + defer p.mu.Unlock() + p.ledger.Deliveries = append(p.ledger.Deliveries, Delivery{ + Key: fmt.Sprintf("%s-%d", branchID, time.Now().UnixNano()), + BranchID: branchID, Text: truncate(text, 200), + SessionID: sessionID, Status: status, Result: note, + At: time.Now().Format(time.RFC3339), + }) + // 只留最近 200 条,防止台账无限增长 + if n := len(p.ledger.Deliveries); n > 200 { + p.ledger.Deliveries = p.ledger.Deliveries[n-200:] + } + _ = p.saveLocked() +} + +func (p *Plugin) handleBranchStatus(args map[string]interface{}) (interface{}, error) { + id := strings.TrimSpace(str(args, "branch_id")) + p.mu.Lock() + defer p.mu.Unlock() + b, ok := p.ledger.Branches[id] + if !ok { + return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", id)}, nil + } + deliveries := []Delivery{} + for _, d := range p.ledger.Deliveries { + if d.BranchID == id { + deliveries = append(deliveries, d) + } + } + alive := false + if sc := p.sidecars[id]; sc != nil { + alive = sc.Alive() + } + return map[string]interface{}{ + "branch": b, "sidecar_alive": alive, + "deliveries": deliveries, + }, nil +} + +func (p *Plugin) handleBranchClose(args map[string]interface{}) (interface{}, error) { + id := strings.TrimSpace(str(args, "branch_id")) + if id == "" { + return map[string]interface{}{"error": "branch_id 必填"}, nil + } + + p.mu.Lock() + b, ok := p.ledger.Branches[id] + if !ok { + p.mu.Unlock() + return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", id)}, nil + } + sc := p.sidecars[id] + delete(p.sidecars, id) + workdir := b.Workdir + b.Status = "merged" + b.ClosedAt = time.Now().Format(time.RFC3339) + err := p.saveLocked() + p.mu.Unlock() + + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + // ① 杀进程(幂等) + if sc != nil { + sc.Close() + } + // ② ★ 合入后即删目录(用户明确要求:不留残留) + var rmErr string + if workdir != "" && workdir != "/" && strings.Contains(workdir, "program") { + if err := os.RemoveAll(workdir); err != nil { + rmErr = err.Error() + } + } + res := map[string]interface{}{"status": "ok", "branch_id": id, "removed": workdir} + if rmErr != "" { + res["remove_error"] = rmErr + } + return res, nil +} + +// ---------------- 工作目录准备 ---------------- + +// prepareWorkdir 由 mirror 本地 clone 派生工作目录。 +// +// 设计 §4.3(实测):必须是**本地 clone(硬链接)**,不能是 worktree +// (rm -rf 后 .git/worktrees/ 残留)也不能是 --reference +// (alternates 外部依赖,mirror 被清即损坏)。 +func (p *Plugin) prepareWorkdir(pr *Project, branch, workdir string) error { + // ★ 与远端同一条理由串行化(mirror 的"检查后 clone"是 TOCTOU): + // 后台 job 与用户重试会并发撞同一个 mirror。 + p.prepMu.Lock() + defer p.prepMu.Unlock() + + if st, err := os.Stat(workdir); err == nil && st.IsDir() { + return p.ensureOnBranch(branch, workdir) // 复用(分支重开时) + } + if err := os.MkdirAll(filepath.Dir(workdir), 0o700); err != nil { + return fmt.Errorf("建目录失败: %w", err) + } + + mirror := pr.MirrorPath + if mirror == "" { + mirror = filepath.Join(filepath.Dir(p.path), "mirrors", pr.Name+".git") + } + if _, err := os.Stat(mirror); err != nil { + // 还没有 mirror:先建(刀 1 允许失败 —— 无凭据时拉不了私有仓) + if err := runGit("", "clone", "--mirror", pr.RemoteURL, mirror); err != nil { + return fmt.Errorf("建 mirror 失败(无凭据或网络不通): %w", err) + } + p.mu.Lock() + pr.MirrorPath = mirror + p.mu.Unlock() + } + if err := runGit("", "clone", mirror, workdir); err != nil { + return fmt.Errorf("由 mirror 派生工作目录失败: %w", err) + } + // ★ 必须切到目标分支 —— “每个目录下是独立仓库的**独立分支**”(用户原话)。 + // 少了这一步,目录里会是默认分支(main),agent 会在错的分支上改。 + return p.ensureOnBranch(branch, workdir) +} + +// ensureOnBranch 把工作目录切到指定分支(已在该分支则不动)。 +// +// 分支已存在就 checkout;不存在就基于当前 HEAD 新建(首次开线就是这样)。 +func (p *Plugin) ensureOnBranch(branch, workdir string) error { + if strings.TrimSpace(branch) == "" { + return nil + } + cur, err := runGitOut(workdir, "rev-parse", "--abbrev-ref", "HEAD") + if err == nil && strings.TrimSpace(cur) == branch { + return nil // 已在目标分支 + } + // 分支存在 ⇔ 能 checkout;否则新建 + if err := runGit(workdir, "checkout", branch); err != nil { + if err2 := runGit(workdir, "checkout", "-b", branch); err2 != nil { + return fmt.Errorf("切到分支 %s 失败: %v", branch, err2) + } + } + return nil +} + +func runGit(dir string, args ...string) error { + _, err := runGitOut(dir, args...) + return err +} + +// runGitOut 同 runGit,但返回 stdout(需要读结果的场景)。 +func runGitOut(dir string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + // ★ 凭据不进 remote URL(设计 §7.2):push 时临时注入,此处只做只读操作。 + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, truncate(string(out), 300)) + } + return string(out), nil +} + +// ---------------- 辅助 ---------------- + +// configBool 读一个布尔配置(缺省用 def)。 +func (p *Plugin) configBool(key string, def bool) bool { + if p.sdk == nil { + return def + } + v, err := p.sdk.Settings().Get(key) + if err != nil || v == nil { + return def + } + switch t := v.(type) { + case bool: + return t + case string: + return strings.EqualFold(strings.TrimSpace(t), "true") || t == "1" + case float64: + return t != 0 + } + return def +} + +func str(args map[string]interface{}, k string) string { + s, _ := args[k].(string) + return s +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +// shortHash 已移到 workdir.go(目录映射的单一事实源)。 + +var _ = bufio.NewReader // sidecar.go 使用 + +// cliOrPi 把空值规范化成默认后端(用于回显)。 +func cliOrPi(s string) string { + if strings.TrimSpace(s) == "" { + return defaultAgentCLI + } + return s +} + +// runtimeOrDefault 规范化 runtime(默认 local)。 +func runtimeOrDefault(s string) string { + switch strings.ToLower(strings.TrimSpace(s)) { + case "ssh": + return "ssh" + case "", "local": + return "local" + default: + return strings.ToLower(strings.TrimSpace(s)) + } +} diff --git a/example/commondev/rawfile/.gitignore b/example/commondev/rawfile/.gitignore new file mode 100644 index 0000000..cf5e861 --- /dev/null +++ b/example/commondev/rawfile/.gitignore @@ -0,0 +1,10 @@ +# rawfile/ 里的是随包资源,由构建方自行准备(下载/拷贝),不进版本库。 +# +# 为什么用 .gitignore 而不是上层规则:资源可能内嵌 node/pi 等 +# 上百兆二进制,一个克隆带上它们既慢又每次升级产生巨型 diff。 +# +# 保留:本文件与 .gitkeep(目录骨架要留在版本库里, +# 模板工程与 hmapdev init 依赖它存在)。 +* +!.gitignore +!.gitkeep diff --git a/example/commondev/rawfile/.gitkeep b/example/commondev/rawfile/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/example/commondev/runtime/pi/driver.json b/example/commondev/runtime/pi/driver.json new file mode 100644 index 0000000..8553f94 --- /dev/null +++ b/example/commondev/runtime/pi/driver.json @@ -0,0 +1,14 @@ +{ + "name": "pi", + "script": "sidecar.mjs", + "node": "auto", + "sessionNameFlag": true, + "sessionDirSuffix": ".pi-sessions", + "llmConfig": "pi", + "env": { + "PI_CODING_AGENT_DIR": "{dataDir}/runtime/agentdir", + "PI_CODING_AGENT_SESSION_DIR": "{workdir}/.pi-sessions" + }, + "args": ["--workdir", "{workdir}", "--session-id", "{sessionId}", "--session-name", "{sessionName}"], + "note": "本文件与 sidecar_driver.go 的 builtinPiDriver() 必须逐字一致(判据会比对)" +} diff --git a/example/commondev/sidecar.go b/example/commondev/sidecar.go new file mode 100644 index 0000000..41924f2 --- /dev/null +++ b/example/commondev/sidecar.go @@ -0,0 +1,590 @@ +package main + +// sidecar:每个工程对话一份的 pi 运行时外壳。 +// +// 协议照抄 internal/plugins/clawhubadapter/sidecar.go(本仓已验证的成熟做法): +// JSON-RPC over stdin/stdout + pending-id 配对 + readLoop + 优雅 Close。 +// 不自创协议 —— 那边踩过的坑(长行截断、pending 表泄漏、退出时唤醒等待者)不必再踩。 +// +// ## 为什么需要 sidecar +// +// pi 是 Node 程序。我们**不把 pi 的 RPC 循环搬进 Go**(那是重复实现), +// 只用一个薄薄的 Node 脚本把 pi 包成一个「可反复 prompt 的会话」,Go 侧用 +// JSON-RPC 跟它说话。这样: +// +// Go 插件 ──JSON-RPC/stdio──► sidecar(node) ──► pi 会话(内嵌 createAgentSession) +// +// ## 刀 1 的形态 +// +// 本地内嵌。远端 ssh 是后续刀次(同一份 sidecar,只换传输层)。 +// sidecar 脚本路径:优先随包分发的 runtime/sidecar.mjs,其次插件目录下的 sidecar.mjs。 + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + sdk "github.com/JianFeeeee/homeagentsdk/sdk" + "sync" + "time" +) + +type sidecarRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` +} + +type sidecarResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Result *json.RawMessage `json:"result,omitempty"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +type sidecarProcess struct { + branchID string + workdir string + + cmd *exec.Cmd + stdin *bufio.Writer + stdout io.Reader + + mu sync.Mutex + nextID int + pending map[int]chan []byte + closed bool + stopped bool + exited chan struct{} + waitErr error + + timeout time.Duration + + // handshakeTimeout 用于 ping 握手(比普通调用短得多)。 + // 为何单列:握手撞上工具调用的 60s 上限会退化成“超时但进程已起”。 + handshakeTimeout time.Duration +} + +func newSidecarProcess(branchID, workdir string, cmd *exec.Cmd, + stdin *bufio.Writer, stdout io.Reader, timeout time.Duration) *sidecarProcess { + sp := &sidecarProcess{ + branchID: branchID, + workdir: workdir, + cmd: cmd, + stdin: stdin, + stdout: stdout, + pending: map[int]chan []byte{}, + exited: make(chan struct{}), + timeout: timeout, + } + go sp.readLoop(stdout) + go func() { + sp.waitErr = cmd.Wait() + close(sp.exited) + }() + return sp +} + +// readLoop 持续读 stdout。 +// +// ⚠️ 必须持续读:不读会因管道缓冲填满而**堵住 pi**(这是 RPC 类协议最常见的死法)。 +func (s *sidecarProcess) readLoop(r io.Reader) { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for sc.Scan() { + line := sc.Bytes() + if len(line) == 0 { + continue + } + var resp sidecarResponse + if err := json.Unmarshal(line, &resp); err != nil { + // 不是协议记录:当诊断信息,不打断 + log.Printf("[commondev] sidecar %s 非协议输出: %s", s.branchID, truncate(string(line), 200)) + continue + } + if resp.ID == 0 { + // 主动通知(暂未使用,保留扩展位) + continue + } + s.mu.Lock() + ch := s.pending[resp.ID] + delete(s.pending, resp.ID) + s.mu.Unlock() + if ch != nil { + cp := make([]byte, len(line)) + copy(cp, line) + ch <- cp // 不 close:避免 close/send 竞态 + } + } + // stdout 结束 ⇒ 进程已亡,唤醒所有等待者 + s.mu.Lock() + for id, ch := range s.pending { + delete(s.pending, id) + ch <- nil + } + s.mu.Unlock() +} + +func (s *sidecarProcess) call(method string, params interface{}) (interface{}, error) { + if s.handshakeTimeout > 0 && method == "ping" { + return s.callWithTimeout(method, params, s.handshakeTimeout) + } + return s.callWithTimeout(method, params, s.timeout) +} + +func (s *sidecarProcess) callWithTimeout(method string, params interface{}, timeout time.Duration) (interface{}, error) { + raw, err := s.callRawTimeout(method, params, timeout) + if err != nil { + return nil, err + } + if raw == nil { + return nil, nil + } + var resp sidecarResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("sidecar %s 应答解析失败: %w", s.branchID, err) + } + if resp.Error != nil { + return nil, fmt.Errorf("sidecar %s: %s", s.branchID, resp.Error.Message) + } + if resp.Result == nil { + return nil, nil + } + var out interface{} + if err := json.Unmarshal(*resp.Result, &out); err != nil { + return nil, err + } + return out, nil +} + +func (s *sidecarProcess) callRaw(method string, params interface{}) ([]byte, error) { + return s.callRawTimeout(method, params, s.timeout) +} + +func (s *sidecarProcess) callRawTimeout(method string, params interface{}, timeout time.Duration) ([]byte, error) { + ch := make(chan []byte, 1) + + s.mu.Lock() + if s.closed || s.stopped { + s.mu.Unlock() + return nil, fmt.Errorf("sidecar %s 已关闭", s.branchID) + } + s.nextID++ + id := s.nextID + s.pending[id] = ch + + req := sidecarRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params} + data, err := json.Marshal(req) + if err != nil { + delete(s.pending, id) + s.mu.Unlock() + return nil, err + } + if _, err := s.stdin.Write(data); err != nil { + delete(s.pending, id) + s.mu.Unlock() + return nil, err + } + if _, err := s.stdin.Write([]byte("\n")); err != nil { + delete(s.pending, id) + s.mu.Unlock() + return nil, err + } + if err := s.stdin.Flush(); err != nil { + delete(s.pending, id) + s.mu.Unlock() + return nil, err + } + s.mu.Unlock() + + select { + case raw := <-ch: + if raw == nil { + return nil, fmt.Errorf("sidecar %s 进程已退出", s.branchID) + } + return raw, nil + case <-time.After(timeout): + s.mu.Lock() + delete(s.pending, id) + s.mu.Unlock() + return nil, fmt.Errorf("sidecar %s 调用 %s 超时", s.branchID, method) + case <-s.exited: + return nil, fmt.Errorf("sidecar %s 进程已退出", s.branchID) + } +} + +// Alive 报告进程是否还活着。 +func (s *sidecarProcess) Alive() bool { + if s == nil { + return false + } + select { + case <-s.exited: + return false + default: + return true + } +} + +// Close 优雅收尾:先关 stdin 请求有序退出,超时则强杀。 +// +// ★ 孤儿清理是插件的责任:绝不允许留下 pi 进程。 +func (s *sidecarProcess) Close() { + if s == nil { + return + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + s.mu.Unlock() + + // ★ 关 stdin = 请求有序退出(与 clawhubadapter 同纪律)。 + // + // 这里的 nil 判定不只是防御式编程:Close() 的签名承诺了 nil-safe + // (上面 `if s == nil`),但**未完整初始化的对象**(stdin 为 nil + // interface)同样会在这里 panic —— 而调用方看不到差别 + // (两者都是 *sidecarProcess)。 + // + // 实测来源:`deliverToPi` / `handleBranchClose` 都可能在 startSidecar + // 中途失败时拿到半初始化对象并调 Close;空指针 panic 会盖住真正的错误。 + if s.stdin != nil { + _ = s.stdin.Flush() + } + + // ★ 若从未真正启动(cmd/exited 为空),没有可等、可杀的东西 —— 直接收尾。 + // + // 为何必须显式判:Close() 在 `if s == nil` 之后就不再检查内部字段, + // 而 nil-safe 的承诺对**半初始化对象**同样适用(调用方只看到 + // *sidecarProcess,分不出"从未启动"与"已退出")。 + // 实测:startSidecar 中途失败时,handleBranchClose 会拿到这种对象; + // 不判则 `s.cmd.Process` 解引用 nil 指针 —— panic 会盖住真正的错误。 + if s.cmd == nil || s.exited == nil { + s.markStopped() + return + } + + select { + case <-s.exited: + // 已退 + case <-time.After(3 * time.Second): + if s.cmd.Process != nil { + _ = s.cmd.Process.Kill() + } + select { + case <-s.exited: + case <-time.After(2 * time.Second): + log.Printf("[commondev] sidecar %s 强杀后仍未退出", s.branchID) + } + } + + s.markStopped() +} + +// markStopped 置停止标记并唤醒所有挂起的调用(幂等)。 +func (s *sidecarProcess) markStopped() { + s.mu.Lock() + s.stopped = true + for id, ch := range s.pending { + delete(s.pending, id) + ch <- nil + } + s.mu.Unlock() +} + +// ---------------- 启动 ---------------- + +// startSidecar 拉起某个工程对话的运行时(**按 agent_cli 选驱动**)。 +// +// ★ 本函数**不知道 pi 的存在**:它只读 driver.json(或内置 pi 描述), +// 按声明起进程、注环境、传参数。换 CLI = 换一份 driver.json,这里一行不改。 +// +// 契约(sidecar 脚本必须实现,driver 只负责把进程拉起来): +// +// ping → {ok, session_id, ...} +// prompt → {ok, session_id, reply} ← session_id 必须回,Go 侧据此断言会话未漂移 +// state → {ok, session_id, messages} +// close → {ok} +func (p *Plugin) startSidecar(b *Branch, sessionID string) (*sidecarProcess, error) { + p.mu.Lock() + existing := p.sidecars[b.ID] + p.mu.Unlock() + if existing != nil && existing.Alive() { + return existing, nil // 复用 + } + + cli := strings.TrimSpace(b.AgentCLI) + if cli == "" { + cli = defaultAgentCLI + } + drv, dir, err := p.loadDriver(cli) + if err != nil { + return nil, err + } + + // 脚本查找链(按优先级): + // + // ① <插件数据>/runtime//