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

## 是什么

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

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

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

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

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

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

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

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

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

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

## 其他

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

判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。
This commit is contained in:
JianFeeeee
2026-10-17 16:16:04 +08:00
parent 65d2bd9e61
commit c2474f57a2
55 changed files with 16071 additions and 0 deletions

View File

@ -0,0 +1,28 @@
# commondev
commondev plugin
## Build
```bash
hmapdev build
```
## Channels
入站与出站是分开登记的两件事:
| 方向 | API | 用途 |
|---|---|---|
| 入站 inputch | RegisterInputChannel(name, def) | 声明「谁会往这个通道注入输入」。**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...) 注入的通道名都要登记** |
| 出站 output | RegisterOutputChannel(name, caps, desc, def, handler) | 声明 output_send__<name> 的回复发给谁;handler 收到 {payload,type,meta} |
def(ChannelDef)描述该通道在记忆计算层的行为:NoMemory: true = 该通道输入不进记忆;
Cleaner = 计算层清洗后再向量化/提关键词(原文不改)。
> 只登记出站通道、却用同名通道注入输入时,内核会兜底登记同名 inputch 并在日志里告警。
> 兜底只为兼容老插件 —— 请显式登记,让「这是入站通道」成为插件的明确意图。
## Install
Upload the .hmap file through the Plugin Manager API.

View File

@ -0,0 +1,222 @@
package main
// 「投到指定目录的指定 session」的判据。
//
// 用户问的是这句话能不能成立。成立的前提是**地址唯一确定一条线**:
// branch_id → 工作目录(program/<hash>-<name>)
// → 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__<name>)
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("★ 会话漂移应明确拒绝,而非仅记录")
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,84 @@
package main
import "testing"
// 钉住文档 §2.2 的命名约束:
// inputch 可含斜杠;outputch **必须**只剩 [A-Za-z0-9_-],
// 否则会产出非法的 output_send__<name>(上游 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/<id>)
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)
}
}
}

View File

@ -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 <base> <b1> <b2>`)的对比
//
// 旧语法输出是给人看的文本,要靠 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):
//
// <tree-oid>
// 100644 <oid> 1\tpath/to/file ← 阶段 1/2/3 即为冲突条目
// 100644 <oid> 2\tpath/to/file
// 100644 <oid> 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 <path>"
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 <oid> <stage>\t<path>"
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 <path>")。
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
}

View File

@ -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 <path>",
// 只有 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)
}
}

View File

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

View File

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

View File

@ -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 下变异被正确咬住(未受缓存影响)")
}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,177 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// ★★ 真机端到端:**真实项目 + 真实 go test**,验证刀 4 能识破
// 「真 bug 藏在松判据后面」。
//
// 场景(与 /tmp/d4e2e 手工复现的一致):
//
// Clamp(v,lo,hi) 有三个分支;判据只测了 v<lo
// 代码里删掉 v>hi 分支(真 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")
}

View File

@ -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. `<data>/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": { "<name>": { "sealed": "<base64>", ... } } }
//
// 兼容:旧版明文 `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

View File

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

View File

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

View File

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

View File

@ -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 == "<nil>" {
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.<ssh_host>.cli_path(该机所有后端的默认)或 "+
"hosts.<ssh_host>.clis.%s.cli_path(该后端的专属路径)。"+
"可用 commondev_endpoint_set 写入(会校验路径在远端是否存在)。", cli, cli)
}
if containsLocalPlaceholder(cliPath) {
return fmt.Errorf(
"★ 远端 CLI 路径含**本机专属占位符**(%s):%q。"+
"{rawfile}/{pluginDir}/{dataDir} 展开后是**本机**路径,发给远端必然不存在。"+
"请把它换成该机器上的真实路径(endpoint_set host=<ssh_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
}

View File

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

View File

@ -0,0 +1,217 @@
# 自定义 CLI 后端(示例:ZCode)
> ## ★ 边界:**内嵌只做 pi**
>
> 插件**内嵌支持的后端只有 pi**(`runtime/pi/`,不装 driver.json 也照常工作)。
> 本文及同目录示例是给**想让插件驱动别的 CLI** 的用户用的。
>
> 这不是"官方内嵌支持"——它们是**参考实现**:插件不承诺跟着上游 CLI 变更同步更新,
> 你要根据自己的 CLI 版本调整(尤其参数名与输出格式)。
>
> 沿用本仓的一条纪律:**内核/插件只保证机器必须保证的事,策略与平台知识交给能
> 独立演进的地方**(见 skill `commondev-discipline` 里"平台知识进 skill"的同一条理由)。
## 怎么加一个后端
插件按 **数据**(不是代码)选后端。加一个 CLI 只要放两个文件:
```
<插件数据目录>/runtime/<cli>/driver.json ← 声明:起什么命令、什么环境
<插件数据目录>/runtime/<cli>/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>/` | **本机覆盖**(最高优先)—— 指到本机真实的 CLI 路径 |
| ② | `<插件目录>/rawfile/runtime/<cli>/` | **随包分发** —— 升级插件即带上 |
| ③ | `<可执行文件目录>/` | 开发期 |
**为什么要 ②**:`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-<project>-<branch>` | 插件(`--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 <sess_id>]
```
| 点 | 实况(真机核实) |
|---|---|
| `--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`。

View File

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

View File

@ -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 <id> 延续;首轮不给
// 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-<project>-<branch>),
// 它是插件台账里的会话名,**不是** 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);
});

View File

@ -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: <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__<name> 命名约束
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)
}
}
}

View File

@ -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/<cli>/ 在部署时没人创建 ⇒ 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"])
}

65
example/commondev/go.mod Normal file
View File

@ -0,0 +1,65 @@
module commondev
go 1.21.0
require github.com/JianFeeeee/homeagentsdk v1.4.0
// 与同目录其它示例一致:SDK 指向仓库内的 vendored 副本
replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

View File

@ -0,0 +1,447 @@
package main
// LLM 源导入:把 HomeAgent 内核已配的 LLM 源写进内置 pi 的配置文件。
//
// 用户要求(2026-10-14):
//
// 「插件内置 pi 默认使用 homeagent 内部配置的 llms 源,支持添加其他源,
// 且支持在插件中配置默认使用哪个源」
// 「pi 配置的源直接写入配置文件」← 不另建存储(pi 本身就有完整配置系统)
//
// ## 落点(都是 pi 自己的配置文件)
//
// <agentdir>/models.json providers.<name>.{baseUrl,api,apiKey,models[]}
// <agentdir>/auth.json provider 凭据
// <agentdir>/settings.json defaultProvider / defaultModel
//
// ## 内核侧源的真实位置 [已核]
//
// 存在 **config 表**(不是 config_<plugin>),键前缀 core.llm.:
//
// core.llm.provider ← ★ 默认源就是这一项
// core.llm.sources.<n>.base_url
// core.llm.sources.<n>.api_key
// core.llm.sources.<n>.model
// core.llm.sources.<n>.adapter (“openai” → pi 的 "openai-completions")
// core.llm.sources.<n>.context_window (可选)
// core.llm.sources.<n>.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.<n>.<field>),
// 所以要先扫出所有 <n>。
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,
}
}

View File

@ -0,0 +1,352 @@
package main
// LLM 源端到端判据:**内核源 → 插件导入 → pi 配置 → 真实调用成功**。
//
// ## 为什么必须端到端而不能只测导入
//
// 导入逻辑正确 ≠ pi 能用。中间隔着三层容易各自出错:
//
// ① 源字段映射(内核 core.llm.sources.<n>.* → pi 的 providers.<n>.{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=<config.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
}

11
example/commondev/main.go Normal file
View File

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

View File

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

View File

@ -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: <name>` —— 内嵌 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 里仍有写旧格式的地方")
}
}
}

View File

@ -0,0 +1,664 @@
package main
// pi 配置面工具补全(审计发现项)。
//
// 设计 §3.6.4 明确:**插件是 pi 配置系统的受控入口** —— 写的就是 pi 自己的
// 配置文件,不造中间层。§3.6.3 已定的落地方式:
//
// <agentdir>/models.json providers.<name>.{baseUrl,api,apiKey,models[]}
// <agentdir>/auth.json provider 凭据
// <agentdir>/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: <name>`,而配置看起来完全正常。
// 实测(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(默认,读 <agentdir>)| project(读 <workdir>/.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
}

View File

@ -0,0 +1,13 @@
{
"name": "commondev",
"name_zh": "多仓库开发主管",
"name_en": "CommonDev",
"version": "0.1.0",
"description": "主管式多仓库开发流水线:登记项目、开工程对话、为每条线拉起内置 pi 并注册一对通道,投递任务给 pi 并在同一会话内续投。",
"author": "HomeAgent",
"entry": "plugin.so",
"sdk": "1.4.0",
"tags": ["commondev", "git", "agent", "develop"],
"targets": "linux/amd64",
"rawfiles": []
}

1585
example/commondev/plugin.go Normal file

File diff suppressed because it is too large Load Diff

10
example/commondev/rawfile/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# rawfile/ 里的是随包资源,由构建方自行准备(下载/拷贝),不进版本库。
#
# 为什么用 .gitignore 而不是上层规则:资源可能内嵌 node/pi 等
# 上百兆二进制,一个克隆带上它们既慢又每次升级产生巨型 diff。
#
# 保留:本文件与 .gitkeep(目录骨架要留在版本库里,
# 模板工程与 hmapdev init 依赖它存在)。
*
!.gitignore
!.gitkeep

View File

View File

@ -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() 必须逐字一致(判据会比对)"
}

View File

@ -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/<cli>/<script> 用户自备/覆盖(最高优先)
// ② <插件目录>/rawfile/runtime/<cli>/<script> ★ 随包分发(见下)
// ③ <可执行文件同目录>/<script> 开发期(hmapdev debug)
//
// ★ ② 是必需的,不是锦上添花:sidecar 脚本此前**没有随包分发**
// (打包只收 rawfile/ 与 skill/),而 ① 在部署时没人创建 ⇒
// `agent_up` 在全新部署上**必然失败**(报「找不到 sidecar 脚本」),
// 表现为"插件装了、工具在、但一拉 agent 就错"。
//
// 放在 rawfile/ 而不是新建约定目录:rawfile 已是"随包任意资源"的通道,
// 且 nodeBin 已经用同一机制定位内嵌 node —— 复用同一条路,不新增概念。
script, scriptErr := p.locateSidecarScript(drv, dir, cli)
if scriptErr != nil {
return nil, scriptErr
}
dataDir := filepath.Dir(p.path)
rawfileDir := ""
if rf := p.rawFiles(); rf != nil {
rawfileDir = rf.Dir()
}
ctx := driverCtx{
Workdir: b.Workdir, SessionID: sessionID,
SessionName: "br-" + b.ID, DataDir: dataDir, PluginDir: dir,
RawfileDir: rawfileDir,
}
// ★ 传输层分派:local(exec)| ssh(远端同一份 sidecar)。
// 驱动声明(跑什么)与传输(在哪跑)是正交的 —— 这里只分两路,
// 不随 CLI/后端数量增长。
if b.Runtime == "ssh" {
return p.startSSHSidecar(b, drv, ctx, cli)
}
node := p.resolveNode(drv)
if node == "" {
return nil, fmt.Errorf("找不到 node(随包分发应为 <插件>/rawfile/node/bin/node;也可设 node_bin 或用 PATH)")
}
// 会话目录放工作区内,避免污染用户全局会话库。
if drv.SessionDirSuffix != "" {
if err := os.MkdirAll(filepath.Join(b.Workdir, drv.SessionDirSuffix), 0o700); err != nil {
return nil, err
}
}
// LLM 源由**声明**决定谁准备(不是 if cli == "pi" 这种分支)。
if err := p.prepareLLMConfig(drv, ctx); err != nil {
return nil, err
}
args := append([]string{script}, drv.buildArgs(ctx)...)
cmd := exec.Command(node, args...)
cmd.Dir = b.Workdir
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), drv.buildEnv(ctx)...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("启动 sidecar 失败: %w", err)
}
timeout := defaultSidecarTimeout
if v, _ := p.sdk.Settings().Get("sidecar_timeout_sec"); v != nil {
if n, err := strconv.Atoi(fmt.Sprint(v)); err == nil && n > 0 {
timeout = time.Duration(n) * time.Second
}
}
sp := newSidecarProcess(b.ID, b.Workdir, cmd, bufio.NewWriter(stdin), stdout, timeout)
// ★ 握手必须短且确定 —— 绝不能用默认 timeout(那是 60s,正好撞上内核的
// 工具调用 60s 硬上限 ⇒ 工具超时但进程已起,状态"做了一半")。
sp.handshakeTimeout = 10 * time.Second
if ping, err := sp.call("ping", nil); err != nil {
sp.Close()
return nil, fmt.Errorf("%s sidecar 握手失败(10s): %w", cli, err)
} else if m, ok := ping.(map[string]interface{}); ok {
// 版本字段名按后端而异,两者都认(不要求后端改名)
for _, k := range []string{"pi_version", "cli_version", "version"} {
if v, ok := m[k].(string); ok && v != "" {
b.PiVersion = v
break
}
}
}
return sp, nil
}
// prepareLLMConfig 按驱动的 LLMConfig 声明准备 LLM 源。
//
// ★ 用**声明**而不是分支:核心只说「这个后端需要我先喂源」,
// 至于怎么喂(写哪些文件、什么格式)由各后端的实现决定。
// none = 后端自己管(如 ZCode 用内置 provider 配置)。
func (p *Plugin) prepareLLMConfig(drv driverSpec, ctx driverCtx) error {
switch drv.LLMConfig {
case "", "none":
return nil
case "pi":
agentDir := filepath.Join(ctx.DataDir, "runtime", "agentdir")
if err := os.MkdirAll(agentDir, 0o700); err != nil {
return err
}
// ★ 导入内核的 LLM 源(用户要求:默认用 homeagent 内部配置的源)。
// 不导入的话 pi 会报 `No API key found for the selected model`
//(实测:agentdir/auth.json 只有 2 字节)。
if err := p.ensurePiLLMConfig(agentDir); err != nil {
return fmt.Errorf("导入内核 LLM 源失败(不静默回退到宿主凭据): %w", err)
}
return nil
default:
// ★ 内嵌只实现 pi 的 LLM 喂法。别的后端要么自己管(none),
// 要么由用户在自己的 sidecar 里处理 —— 插件不替未知 CLI 猜格式。
return fmt.Errorf(
"未知的 llmConfig 声明 %q(内嵌只实现 pi | none)。"+
"自定义后端请用 none 并在自己的 sidecar 里准备配置 —— "+
"插件不替未知 CLI 猜配置文件格式(猜错会表现为「模型不回复」)",
drv.LLMConfig)
}
}
// ensurePiLLMConfig 确保内置 pi 有可用的 LLM 配置。
//
// 已配置就**不覆盖**(用户可能手工调过、或用了 commondev_pi_source_add 加的源)。
func (p *Plugin) ensurePiLLMConfig(agentDir string) error {
modelsPath := filepath.Join(agentDir, "models.json")
if b, err := os.ReadFile(modelsPath); err == nil {
var f piModelsFile
if json.Unmarshal(b, &f) == nil && len(f.Providers) > 0 {
return nil // 已有配置
}
}
n, def, err := p.importKernelSources(agentDir)
if err != nil {
return err
}
log.Printf("[commondev] 已导入内核 LLM 源:%d 个,默认=%s", n, def)
return nil
}
// sidecarScript 定位 sidecar 脚本。
func (p *Plugin) sidecarScript() string {
cands := []string{
filepath.Join(filepath.Dir(p.path), "runtime", "sidecar.mjs"),
filepath.Join(filepath.Dir(p.path), "sidecar.mjs"),
}
// 开发期:与插件源码同目录(hmapdev debug 场景)
if exe, err := os.Executable(); err == nil {
cands = append(cands, filepath.Join(filepath.Dir(exe), "sidecar.mjs"))
}
for _, c := range cands {
if _, err := os.Stat(c); err == nil {
return c
}
}
return ""
}
// nodeBin 定位 node:随包分发优先,其次 PATH。
// nodeBin 定位 node,**随包内嵌优先**。
//
// 优先级(用户要求:白板机器也能用):
//
// ① setting node_bin(显式覆盖,排查用)
// ② <插件目录>/rawfile/node/bin/node ← 随包内嵌(白板机器靠这条)
// ③ PATH 里的 node ← 回退(老部署/开发机)
//
// ★ ② 的路径由 rawfile 机制提供:hmapdev 把工程根 `rawfile/` 打进包,
// 内核解包保留结构 ⇒ 落在插件目录下。所以插件"内嵌 node"只需把
// node 放进工程的 rawfile/ 并从 plg.json 声明(或直接放 rawfile/ 下)。
func (p *Plugin) nodeBin() string {
if v, _ := p.sdk.Settings().Get("node_bin"); v != nil {
if s, _ := v.(string); strings.TrimSpace(s) != "" {
return s
}
}
// ② 随包内嵌(rawfile 机制)
if rf := p.rawFiles(); rf != nil && rf.Exists("node/bin/node") {
if cand := filepath.Join(rf.Dir(), "node", "bin", "node"); fileExists(cand) {
return cand
}
}
// 兼容旧路径(rawfile 之前的约定)
if cand := filepath.Join(filepath.Dir(p.path), "runtime", "node", "bin", "node"); fileExists(cand) {
return cand
}
if cand, err := exec.LookPath("node"); err == nil {
return cand
}
return ""
}
// rawFiles 取随包资源接口(可能为 nil —— 旧内核未注入)。
func (p *Plugin) rawFiles() sdk.RawFilesAPI {
if p.sdk == nil {
return nil
}
return p.sdk.RawFiles()
}
func fileExists(p string) bool {
st, err := os.Stat(p)
return err == nil && !st.IsDir()
}
// locateSidecarScript 按优先级链定位 sidecar 脚本。
//
// 查找链(按优先级):
//
// ① <插件数据>/runtime/<cli>/<script> 用户自备/覆盖(最高优先)
// ② <插件目录>/rawfile/runtime/<cli>/<script> ★ 随包分发
// ③ <可执行文件同目录>/<script> 开发期(hmapdev debug)
//
// ★ ② 是必需的,不是锦上添花。实测(2026-10-15)发现:sidecar 脚本此前
// **没有随包分发** —— 打包只收 rawfile/ 与 skill/,而 ① 在部署时没人创建。
// 于是全新部署上 `agent_up` **必然失败**(「找不到 pi 的 sidecar 脚本」),
// 表现为"插件装了、工具都在、但一拉 agent 就错",且错误信息指向一个
// 用户从没听说过的目录。
//
// 为什么放在 rawfile/ 而不是新开一个约定目录:rawfile 已经是
// "随包任意资源"的通道,且 nodeBin 已经用**同一机制**定位内嵌 node ——
// 复用同一条路,不新增概念、不新增内核理解负担。
func (p *Plugin) locateSidecarScript(drv driverSpec, cliDir, cli string) (string, error) {
// ① 用户自备(插件数据目录)——允许覆盖随包脚本
if cand := filepath.Join(cliDir, drv.Script); fileExists(cand) {
return cand, nil
}
// ② 随包分发(rawfile/runtime/<cli>/)
if rf := p.rawFiles(); rf != nil {
rel := filepath.ToSlash(filepath.Join("runtime", cli, drv.Script))
if rf.Exists(rel) {
return filepath.Join(rf.Dir(), "runtime", cli, drv.Script), nil
}
}
// ③ 开发期:与可执行文件同目录
if exe, e := os.Executable(); e == nil {
if alt := filepath.Join(filepath.Dir(exe), drv.Script); fileExists(alt) {
return alt, nil
}
}
// 全部落空:错误必须**列出找过哪里**,否则用户只能猜
return "", fmt.Errorf(
"找不到 %s 的 sidecar 脚本 %s;已找过:① %s ② <插件>/rawfile/runtime/%s/%s ③ <可执行文件目录>/%s。"+
"若要自带 CLI,请把 driver.json 与脚本放到 <插件数据>/runtime/%s/",
cli, drv.Script, filepath.Join(cliDir, drv.Script), cli, drv.Script, drv.Script, cli)
}

View File

@ -0,0 +1,173 @@
#!/usr/bin/env node
// commondev sidecar:把一个 pi 会话包成可反复 prompt 的 JSON-RPC 服务。
//
// 协议(与 clawhubadapter 同形):
// stdin 请求 {"jsonrpc":"2.0","id":N,"method":"prompt","params":{...}}
// stdout 应答 {"jsonrpc":"2.0","id":N,"result":{...}}
//
// 方法:
// ping 握手,返回 pi 版本与会话 cwd
// prompt 投递一条消息给会话(★ 同一进程内,会话天然连续)
// state 查当前会话状态(session_id / 消息数)
//
// 设计依据(均已在本机实测):
// · createAgentSession({cwd}) 可精确指定工作目录与会话,且同会话可连续追问
// · 会话库用 sessionDir 与工作区隔离,不污染用户 ~/.pi
// · 内层 pi 靠 PI_CODING_AGENT_DIR 用插件自己的 agent dir
import { createRequire } from "node:module";
import { createInterface } from "node:readline";
const require = createRequire(import.meta.url);
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];
}
return out;
}
const args = parseArgs(process.argv.slice(2));
const workdir = args.workdir || process.cwd();
const sessionId = args.sessionId || undefined;
// U6:会话列表里的可读名(pi 侧仅作标注,不参与身份识别)。
const sessionName = args.sessionName || undefined;
// pi 的 SDK 从宿主安装位置解析;随包分发时走 runtime/node_modules。
let mod;
try {
mod = await import("@earendil-works/pi-coding-agent");
} catch (e) {
// 回退:用环境里已装的 pi(PI_WEB / 系统安装)
const fallback =
"/usr/lib/node_modules/@jmfederico/pi-web/node_modules/@earendil-works/pi-coding-agent/dist/index.js";
mod = await import(fallback);
}
const { createAgentSession, SessionManager } = mod;
// piVersion 从模块导出或 package.json 取(U8:内层 pi 版本必须可回显、可锁定)。
// 取不到就是取不到 —— 返回 null 让 Go 侧如实记录,不编一个版本号。
let piVersion = mod.version ?? mod.VERSION ?? null;
try {
const url = import.meta.resolve
? import.meta.resolve("@earendil-works/pi-coding-agent/package.json")
: null;
if (url) {
const meta = await import(url, { with: { type: "json" } }).catch(() => null);
piVersion = meta?.default?.version ?? piVersion;
}
} catch {}
let session;
let started = false;
async function ensureSession() {
if (session) return session;
// ★ 必须用 SessionManager.create(cwd, sessionDir, {id}) **显式指定会话 id**。
//
// 为何不能只靠 createAgentSession({cwd}):那样 pi 会**自己生成一个 UUID**
// 作为会话 id,与 Go 侧台账记的 session_id 不一致 —— 于是
// “投递后断言会话未变”这条纪律会拿两个不同的名字去比,永远失败
//(实测:期望 br-cdtest-feat_alpha,实际 01a13959-bcc3-7007-…)。
//
// 而用固定 id 建 manager,则:① 首次投递建会话;② 重启/重投时
// SessionManager 能打开**同一个**会话文件 ⇒ “续投同一会话”是真保障,不是运气。
const sessionDir = process.env.PI_CODING_AGENT_SESSION_DIR || undefined;
const manager = sessionId
? SessionManager.create(workdir, sessionDir, { id: sessionId })
: SessionManager.create(workdir, sessionDir);
const { session: s } = await createAgentSession({
cwd: workdir,
sessionManager: manager,
});
session = s;
started = true;
return session;
}
function send(obj) {
process.stdout.write(JSON.stringify(obj) + "\n");
}
async function handle(method, params) {
switch (method) {
case "ping": {
await ensureSession();
return {
ok: true,
cwd: workdir,
session_id: sessionId || null,
messages: Array.isArray(session?.messages) ? session.messages.length : 0,
// U8:把内层 pi 版本回显给 Go 侧(台账记录 + agent_up 回显)
pi_version: piVersion,
session_name: sessionName || null,
};
}
case "prompt": {
const text = params?.text ?? "";
const want = params?.session_id || sessionId;
const s = await ensureSession();
await s.prompt(text);
const got = s.sessionId ?? s.id ?? want ?? null;
return {
ok: true,
// ★ 必须回报 session_id:Go 侧据此断言「会话漂移」
session_id: got,
reply: s.getLastAssistantText ? s.getLastAssistantText() : "",
};
}
case "state": {
const s = await ensureSession();
return {
ok: true,
session_id: s.sessionId ?? sessionId ?? null,
messages: Array.isArray(s.messages) ? s.messages.length : 0,
};
}
case "close": {
try {
session?.dispose?.();
} catch {}
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", () => {
try {
session?.dispose?.();
} catch {}
process.exit(0);
});

View File

@ -0,0 +1,331 @@
package main
// sidecar **驱动层**:把「跑哪个 CLI、怎么起、给什么环境」从插件核心里分离。
//
// ## 为什么需要这一层
//
// 设计目标(用户拍板 2026-10-15):
//
// **通过不同的 sidecar 支持不同的 CLI 后端,插件核心一行不用改。**
//
// 拆开看,这就是**两个正交的轴**:
//
// Runtime = local | ssh ← 在哪跑(传输层)
// AgentCLI = pi | zcode | ... ← 跑什么(执行后端)
//
// 而插件核心真正依赖 sidecar 的只有**两个动作**:
//
// startSidecar(branch, sessionID) → *sidecarProcess
// sc.call("prompt"|"ping"|"state"|"close", params) → JSON
//
// 所以只要「起什么进程 + 什么环境变量」变成**数据**而不是代码,
// 换 CLI 就是换一份 driver 描述,核心不需要条件分支。
//
// ## 为什么是声明式 JSON 而不是 Go 插件接口
//
// Go 接口要求每个后端都编译进同一个二进制 ⇒ **换 CLI 要重编插件**,
// 那正是要避免的(与「平台逻辑进 skill」同一个理由:知识的载体应该能独立演进)。
// driver.json 是数据,随 sidecar 脚本一起分发,加一个后端 = 加一个目录。
//
// ## 目录约定
//
// <插件数据目录>/runtime/<cli>/driver.json
// <插件数据目录>/runtime/<cli>/sidecar.mjs ← 或 driver.json 指定的脚本
//
// ## driver.json 形状
//
// {
// "name": "zcode",
// "script": "sidecar.mjs", // 相对本目录
// "node": "auto", // auto | 路径
// "env": { // 追加/覆盖的环境变量
// "ZCODE_DATA_BASE_DIR": "{dataDir}/runtime/zcode",
// "ZCODE_SESSION_DIR": "{workdir}/.zcode-sessions"
// },
// "args": ["--workdir", "{workdir}", "--session-id", "{sessionId}"],
// "llmConfig": "zcode", // pi | zcode | none —— 谁负责喂 LLM 源
// "sessionDirSuffix": ".zcode-sessions"
// }
//
// 占位符:{workdir} {sessionId} {sessionName} {dataDir} {pluginDir}
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// driverSpec 是一个 CLI 后端的启动描述。
type driverSpec struct {
Name string `json:"name"`
// Script 是 sidecar 脚本相对本目录的路径(默认 sidecar.mjs)。
Script string `json:"script,omitempty"`
// Node 是 node 可执行文件:"auto"(默认,随包优先)或显式路径。
Node string `json:"node,omitempty"`
// Env 是要注入的环境变量(值支持占位符)。
Env map[string]string `json:"env,omitempty"`
// Args 是传给脚本的参数(值支持占位符)。
Args []string `json:"args,omitempty"`
// LLMConfig 说明谁负责准备 LLM 源:pi | zcode | none。
//
// ★ 这是**能力声明**而不是分支条件:核心只问「这个后端要不要我先喂 LLM 源」,
// 至于怎么喂由各后端的配置写入器实现(pi 写 models.json/auth.json)。
LLMConfig string `json:"llmConfig,omitempty"`
// SessionDirSuffix 是会话目录名(放在工作区内,避免污染用户全局)。
SessionDirSuffix string `json:"sessionDirSuffix,omitempty"`
// SessionNameFlag 声明是否支持 --session-name(不支持就不传)。
SessionNameFlag *bool `json:"sessionNameFlag,omitempty"`
// CLIPath 是**后端 CLI 的入口路径**(zcode 这类需要外部二进制的后端用)。
//
// 为何必须有这个字段(实测 2026-10-16):zcode 的 sidecar 靠自己的
// findCLI() 在**三个固定位置**里碰运气(/opt/ZCode/resources/glm/zcode.cjs 等)。
// 本机装法千差万别(构建产物、nvm、自定义目录),碰不到就报
// 「找不到 ZCode CLI」—— 而用户唯一的办法是设环境变量,插件里有
// 一个**声明的字段**比"记得设 env"可靠得多,也让 SSH 场景能分别声明
// 本地与远端各自的路径。
//
// 支持占位符(尤其 {rawfile}:随包分发的 CLI 就放那里)。
CLIPath string `json:"cliPath,omitempty"`
}
// driverDir 返回某个 CLI 后端的驱动目录。
//
// dir 为空时用默认后端(pi)。
func (p *Plugin) driverDir(cli string) string {
if strings.TrimSpace(cli) == "" {
cli = defaultAgentCLI
}
return filepath.Join(filepath.Dir(p.path), "runtime", cli)
}
// defaultAgentCLI 是没有指定后端时的默认后端 = pi。
//
// ## 为什么默认是 pi(2026-10-17 确认)
//
// 我们本地主要用 pi;zcode 是**交付方主用**的后端。所以默认值保持 pi,
// 交付方那台机器通过**端点表**把默认设成 zcode,而不是改全局默认。
//
// ## 为什么默认值不该跟着某个客户走
//
// 默认值是"新开线且未指定 cli 时"的行为,它应反映**最常见**的场景。
// 局部需求用局部开关表达(见下),否则每次交付换客户都要改代码。
//
// ## 交付方怎么用 zcode(两种,都不改全局默认)
//
// ① 单线指定:commondev_branch_open(cli="zcode")
// ② 按机器设默认:commondev_endpoint_set(host="<交付方机器>", cli="zcode")
// —— 该机器上所有未显式指定 cli 的线都走 zcode
//
// zcode 的三条真机判据(本地全流水线 / SSH / 每机独立端点)见
// zcode_e2e_test.go(build tag zcode_e2e),已验证可用。
const defaultAgentCLI = "pi"
// loadDriver 读一个后端的 driver.json。
//
// ★ 找不到 driver.json 时**回退到内置的 pi 描述**(而不是报错):
// 现有部署的 runtime/ 下只有 sidecar.mjs、没有 driver.json,
// 若因此启动失败,等于把这个改动变成破坏性变更。
func (p *Plugin) loadDriver(cli string) (driverSpec, string, error) {
if strings.TrimSpace(cli) == "" {
cli = defaultAgentCLI
}
dir := p.driverDir(cli)
path := filepath.Join(dir, "driver.json")
b, err := os.ReadFile(path)
if err != nil {
// ★ 回退 ②:随包分发的描述(rawfile/runtime/<cli>/driver.json)。
//
// 为什么必须有这条回退(实测 2026-10-16):`locateSidecarScript`
// 已经有三级链(用户目录 → rawfile → 可执行文件目录),
// 但 `loadDriver` **只看插件数据目录** ⇒ 随包分发的 driver.json
// 永远读不到 ⇒ 全新部署上 `agent_cli=zcode` 直接报「驱动不存在」,
// 而文件明明就在包里。这与 sidecar 脚本曾经踩的坑是**同一个**
// ("装了插件、文件都在、一用就找不到"),只是漏在了描述文件上。
//
// 顺序与 locateSidecarScript 一致:用户目录优先,随包次之。
if d2, ok := p.loadShippedDriver(cli); ok {
return d2, dir, nil
}
// ★ 内置 pi 描述:判断条件必须是**字面 "pi"**,不能是 defaultAgentCLI。
//
// 为什么(2026-10-17 踩到):这里原本写 `cli == defaultAgentCLI`,
// 把"内置回退给谁"和"默认选谁"耦合成了同一个常量。
// 默认从 pi 改成 zcode 之后,pi **失去了内置回退** ⇒
// 显式传 cli="pi" 的调用开始报「驱动不存在」,
// 而 pi 从来就不需要 driver.json(它的描述是代码里的 builtinPiDriver)。
// 两条判据当场变红:
// TestRuntimeAndCLIAreOrthogonal
// TestBuiltinPiStillWorksWithoutDriverJSON
//
// ⇒ 语义澄清:builtinPiDriver 是 **pi 这个后端**的兜底,
// 与"默认后端是哪个"完全无关。
if cli == "pi" {
// 内置 pi 描述:与改动前的行为逐字一致
return builtinPiDriver(), dir, nil
}
return driverSpec{}, dir, fmt.Errorf(
"agent_cli=%s 的驱动不存在(应为 %s,或随包 %s)—— 不支持的 CLI 不静默回退到 pi,"+
"否则会用错的后端跑任务而没有任何提示", cli, path,
filepath.Join("<插件>/rawfile", "runtime", cli, "driver.json"))
}
var d driverSpec
if err := json.Unmarshal(b, &d); err != nil {
// 损坏必须响亮:静默用默认值会让人以为"改了没生效"
return driverSpec{}, dir, fmt.Errorf("驱动描述损坏(拒绝猜默认): %s: %w", path, err)
}
return p.finishDriver(d, cli), dir, nil
}
// loadShippedDriver 读**随包分发**的 driver.json(<插件>/rawfile/runtime/<cli>/driver.json)。
//
// 独立成函数是为了让"两条来源各自怎么解析"都能被单独判据覆盖:
// 随包路径必须与用户路径解析出**同一套默认值填充**,否则会出现
// "本机覆盖能用、随包分发不能用"这种只在部分机器复现的怪问题。
func (p *Plugin) loadShippedDriver(cli string) (driverSpec, bool) {
rf := p.rawFiles()
if rf == nil {
return driverSpec{}, false
}
rel := filepath.ToSlash(filepath.Join("runtime", cli, "driver.json"))
if !rf.Exists(rel) {
return driverSpec{}, false
}
b, err := rf.Read(rel)
if err != nil {
return driverSpec{}, false
}
var d driverSpec
if err := json.Unmarshal(b, &d); err != nil {
// 随包描述损坏同样响亮,不静默退到内置 pi(那会用错后端)
return driverSpec{}, false
}
return p.finishDriver(d, cli), true
}
// finishDriver 补齐描述里的默认值(两条来源**共用**,保证行为一致)。
func (p *Plugin) finishDriver(d driverSpec, cli string) driverSpec {
if d.Name == "" {
d.Name = cli
}
if d.Script == "" {
d.Script = "sidecar.mjs"
}
if d.LLMConfig == "" {
d.LLMConfig = "none"
}
if d.SessionDirSuffix == "" {
// ★ 必须按 cli 派生(原来就是 "." + cli + "-sessions")。
// 写死成某个具体后端会把"默认值"变成"某后端的硬编码",
// 对其他后端就是静默的行为回归。
d.SessionDirSuffix = "." + cli + "-sessions"
}
return d
}
// builtinPiDriver 是未提供 driver.json 时的内置 pi 描述。
//
// 与环境变量写法必须与改动前**逐字一致** —— 这是"核心一行不改"的保证:
// 老部署不装 driver.json 也照常工作。
func builtinPiDriver() driverSpec {
return driverSpec{
Name: "pi", Script: "sidecar.mjs", Node: "auto",
Env: map[string]string{
"PI_CODING_AGENT_DIR": "{dataDir}/runtime/agentdir",
"PI_CODING_AGENT_SESSION_DIR": "{workdir}/.pi-sessions",
},
Args: []string{"--workdir", "{workdir}", "--session-id", "{sessionId}", "--session-name", "{sessionName}"},
LLMConfig: "pi",
SessionDirSuffix: ".pi-sessions",
}
}
// expand 把值里的占位符替换成实际参数。
func (d driverSpec) expand(val string, ctx driverCtx) string {
r := strings.NewReplacer(
"{workdir}", ctx.Workdir,
"{sessionId}", ctx.SessionID,
"{sessionName}", ctx.SessionName,
"{dataDir}", ctx.DataDir,
"{pluginDir}", ctx.PluginDir,
// {rawfile} 指向**随包资源目录**(<插件目录>/rawfile)。
// 有了它,driver.json 可以直接声明"用随包分发的 CLI":
// "cliPath": "{rawfile}/zcode/zcode.cjs"
"{rawfile}", ctx.RawfileDir,
)
return r.Replace(val)
}
// driverCtx 是占位符替换用的上下文。
type driverCtx struct {
Workdir string
SessionID string
SessionName string
DataDir string
PluginDir string
// RawfileDir 是随包资源根(<插件目录>/rawfile)。
// 用于 {rawfile} 占位符:让 driver.json 能声明"用随包分发的 CLI"。
RawfileDir string
}
// resolveNode 按驱动声明解析 node 路径。
func (p *Plugin) resolveNode(d driverSpec) string {
if d.Node != "" && d.Node != "auto" {
return d.Node
}
return p.nodeBin()
}
// buildArgs 生成传给 sidecar 脚本的参数表。
func (d driverSpec) buildArgs(ctx driverCtx) []string {
args := make([]string, 0, len(d.Args))
supportsName := d.SessionNameFlag == nil || *d.SessionNameFlag
for i := 0; i < len(d.Args); i++ {
a := d.Args[i]
// 不支持 --session-name 的后端:跳过这个开关及其值
if !supportsName && a == "--session-name" {
i++
continue
}
args = append(args, d.expand(a, ctx))
}
// ★ 后端 CLI 路径(若声明了)作为 --cli 追加。
//
// 为什么由**核心**追加而不是写死在 driver.json 的 args 里:
// 路径要支持占位符展开({rawfile}/{dataDir}/{pluginDir}),
// 而这些只有核心知道。写在 args 里也能展开,但把"这个后端需要一个
// 外部 CLI"这件事藏在了通用参数表里 —— 显式字段让 SSH 场景可以
// 分别声明本地/远端两套路径,sidecar 也能据此给出更好的错误。
if strings.TrimSpace(d.CLIPath) != "" {
args = append(args, "--cli", d.expand(d.CLIPath, ctx))
}
return args
}
// buildEnv 生成驱动要求的额外环境变量(已展开占位符)。
func (d driverSpec) buildEnv(ctx driverCtx) []string {
out := make([]string, 0, len(d.Env)+1)
// 固定排序(map 遍历随机 ⇒ 环境变量顺序不稳,日志难对照)
keys := make([]string, 0, len(d.Env))
for k := range d.Env {
keys = append(keys, k)
}
sortStrings(keys)
for _, k := range keys {
out = append(out, k+"="+d.expand(d.Env[k], ctx))
}
return out
}
func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j] < s[j-1]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
var _ = strconv.Itoa

View File

@ -0,0 +1,297 @@
package main
// 侧车驱动层判据(「换 CLI,核心一行不改」)。
//
// 要证明三件事:
// ① 核心不认 pi —— startSidecar 的参数/环境全部来自 driver 描述
// ② 声明式与内置描述**等价**(否则显式化就改变了行为)
// ③ 换驱动真的能换后端(用假 CLI 端到端验证)
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// ★★★ 判据:**核心源码里不得出现 pi 专有的启动细节**。
//
// 这是「核心一行不改」的可验证形式:pi 的 agentdir / sessions 目录名、
// `PI_CODING_AGENT_*` 环境变量,都只能出现在 **driver 描述**里,
// 不能出现在 startSidecar 里。
func TestCoreDoesNotHardcodePiLaunchDetails(t *testing.T) {
b, err := os.ReadFile("sidecar.go")
if err != nil {
t.Fatal(err)
}
// startSidecar 函数体:从函数头到下一个顶层函数
src := string(b)
start := strings.Index(src, "func (p *Plugin) startSidecar(")
if start < 0 {
t.Fatal("找不到 startSidecar")
}
rest := src[start:]
if end := strings.Index(rest[1:], "\nfunc "); end > 0 {
rest = rest[:end+1]
}
for _, forbidden := range []string{
"PI_CODING_AGENT_DIR", // pi 专有环境变量
"PI_CODING_AGENT_SESSION_DIR",
".pi-sessions", // pi 专有目录名
"ensurePiLLMConfig", // pi 专有配置准备
"sidecar.mjs", // 写死的脚本名
} {
for i, line := range strings.Split(rest, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "//") {
continue // 注释里解释"为什么不该有"是允许的
}
if strings.Contains(line, forbidden) {
t.Errorf("★ startSidecar 仍硬编码 pi 细节 %q(第 %d 行)—— "+
"它必须来自 driver 描述,否则换 CLI 就得改核心", forbidden, i+1)
}
}
}
}
// ★★ 判据:**内置 pi 描述与 runtime/pi/driver.json 逐字等价**。
//
// 用途:把隐式行为显式化时最容易出的错是"显式版和隐式版写了一点点不同",
// 而那种差异在**只走 driver.json 的部署**里根本看不出来。
func TestPiDriverJSONMatchesBuiltin(t *testing.T) {
b, err := os.ReadFile(filepath.Join("runtime", "pi", "driver.json"))
if err != nil {
t.Skipf("没有 runtime/pi/driver.json(可选): %v", err)
}
var d driverSpec
if err := json.Unmarshal(b, &d); err != nil {
t.Fatal(err)
}
want := builtinPiDriver()
if d.Name != want.Name || d.Script != want.Script || d.Node != want.Node {
t.Errorf("基础字段不一致: %+v vs %+v", d, want)
}
if strings.Join(d.Args, "\x00") != strings.Join(want.Args, "\x00") {
t.Errorf("args 不一致:\n json=%v\n 内置=%v", d.Args, want.Args)
}
if len(d.Env) != len(want.Env) {
t.Fatalf("env 数量不一致: %v vs %v", d.Env, want.Env)
}
for k, v := range want.Env {
if d.Env[k] != v {
t.Errorf("env[%s] 不一致: %q vs %q", k, d.Env[k], v)
}
}
if d.LLMConfig != want.LLMConfig || d.SessionDirSuffix != want.SessionDirSuffix {
t.Errorf("llmConfig/sessionDirSuffix 不一致: %q/%q", d.LLMConfig, d.SessionDirSuffix)
}
}
// ★★ 判据:未知 CLI **必须报错**,不得静默回退到 pi。
//
// 静默回退会让「我配了 zcode」变成「实际在跑 pi」而没有任何提示 ——
// 任务照跑,用的是错的后端。
func TestUnknownAgentCLIFailsLoudly(t *testing.T) {
p := newTestPluginForShell(t)
if _, _, err := p.loadDriver("nonexistent-cli"); err == nil {
t.Fatal("★ 未知 CLI 未报错 —— 静默回退会让任务用错后端跑")
}
// ★ 显式传 "pi" 必须始终可用(内置描述,不依赖 driver.json)。
//
// 这与"默认后端是谁"是两件事,必须分开测:
// 2026-10-17 把默认从 pi 改成 zcode 时,实现里用
// `if cli == defaultAgentCLI` 决定给谁内置回退 ⇒ pi 当场失去兜底。
// 所以这里显式钉住 pi 的可用性,不再让它随默认值漂移。
d, _, err := p.loadDriver("pi")
if err != nil {
t.Fatalf("★ 显式 pi 必须可用(内置描述不依赖 driver.json): %v", err)
}
if d.Name != "pi" {
t.Errorf("显式 pi 应解析为 pi,实际 %q", d.Name)
}
// 默认值语义单独一条判据(见 TestDefaultAgentCLIIsZcode)。
}
// ★★ 判据:默认后端是 **pi**(本地主用),且 zcode 是可选后端。
func TestDefaultAgentCLIIsPi(t *testing.T) {
if defaultAgentCLI != "pi" {
t.Fatalf("默认后端应为 pi(本地主用;交付方用端点表设 zcode),实际 %q", defaultAgentCLI)
}
// 空串必须解析成 pi,且内置描述可用(不需要 driver.json)
p := newTestPluginForShell(t)
d, _, err := p.loadDriver("")
if err != nil {
t.Fatalf("默认(空 cli)应解析为 pi 且可用: %v", err)
}
if d.Name != "pi" {
t.Fatalf("空 cli 应解析为 pi,实际 %q", d.Name)
}
}
// ★★ 判据:**内置 pi 回退与默认值解耦**。
//
// 这条判据守的是一个真实踩过的坑(2026-10-17):实现里原本写
//
// if cli == defaultAgentCLI { return builtinPiDriver(), ... }
//
// 把"给谁内置回退"和"默认选谁"耦合到同一常量。一旦有人改默认
// (哪怕只是想在交付环境试 zcode),pi 就**当场失去兜底**,
// 显式 cli="pi" 开始报「驱动不存在」。
//
// ⇒ 正确语义:builtinPiDriver 是 pi 这个**后端**的兜底,与默认值无关。
// 这里通过"临时把默认设成 zcode 后,显式 pi 仍必须可用"来钉住解耦。
func TestBuiltinPiFallbackIsDecoupledFromDefault(t *testing.T) {
p := newTestPluginForShell(t)
// 直接验证字面 "pi" 走内置描述(不依赖 driver.json 存在)
d, _, err := p.loadDriver("pi")
if err != nil {
t.Fatalf("★ 显式 pi 必须走内置描述: %v", err)
}
if d.Name != "pi" || d.Script != "sidecar.mjs" {
t.Fatalf("内置 pi 描述不符: name=%q script=%q", d.Name, d.Script)
}
// 该目录下确实没有 driver.json —— 证明是代码里的兜底
if _, statErr := os.Stat(filepath.Join(p.driverDir("pi"), "driver.json")); statErr == nil {
t.Fatal("测试前提不成立:竟然存在 pi/driver.json")
}
}
// ★★ 判据:占位符替换正确(workdir/sessionId/... 全部生效)。
func TestDriverPlaceholderExpansion(t *testing.T) {
d := driverSpec{
Env: map[string]string{"A": "{workdir}/x", "B": "{dataDir}/y"},
Args: []string{"--cwd", "{workdir}", "--sid", "{sessionId}", "--name", "{sessionName}"},
}
ctx := driverCtx{
Workdir: "/w", SessionID: "s1", SessionName: "br-1",
DataDir: "/d", PluginDir: "/p",
}
env := strings.Join(d.buildEnv(ctx), ",")
if !strings.Contains(env, "A=/w/x") || !strings.Contains(env, "B=/d/y") {
t.Errorf("env 占位符未替换: %s", env)
}
args := strings.Join(d.buildArgs(ctx), " ")
if !strings.Contains(args, "--cwd /w") || !strings.Contains(args, "--sid s1") ||
!strings.Contains(args, "--name br-1") {
t.Errorf("args 占位符未替换: %s", args)
}
// 环境变量顺序必须稳定(map 遍历随机 ⇒ 日志难对照)
if strings.Join(d.buildEnv(ctx), ",") != env {
t.Error("env 顺序不稳定")
}
}
// ★★ 判据:`sessionNameFlag:false` 时**不传** --session-name 及其值。
//
// 理由:ZCode CLI 没有这个开关,传了会 `Unknown option` 直接退到 usage ——
// 一行参数毁掉整条命令。
func TestSessionNameFlagRespected(t *testing.T) {
no := false
d := driverSpec{Args: []string{"--workdir", "{workdir}", "--session-name", "{sessionName}"}, SessionNameFlag: &no}
ctx := driverCtx{Workdir: "/w", SessionName: "br-1"}
got := strings.Join(d.buildArgs(ctx), " ")
if strings.Contains(got, "session-name") || strings.Contains(got, "br-1") {
t.Fatalf("★ 后端声明不支持 --session-name 却仍传了: %s", got)
}
// 支持时应传(默认行为)
d2 := driverSpec{Args: d.Args}
got2 := strings.Join(d2.buildArgs(ctx), " ")
if !strings.Contains(got2, "--session-name br-1") {
t.Fatalf("默认应传 --session-name: %s", got2)
}
}
// ★★★ 端到端判据:**换一个 driver 就能换后端**,插件核心零改动。
//
// 做法:造一个假 CLI 的 sidecar(实现同名 JSON-RPC 契约),
// 断言 startSidecar 用**它的**参数与环境把它拉起来,且 call 能拿到结果。
func TestSwappingDriverSwapsBackend(t *testing.T) {
p := newTestPluginForShell(t)
cli := "fakecli"
dir := p.driverDir(cli)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
// 假 sidecar:把收到的参数与环境写进文件,然后按契约应答
script := filepath.Join(dir, "sidecar.mjs")
body := `
import { writeFileSync } from "node:fs";
import { createInterface } from "node:readline";
// 证据:把调用参数与环境落盘,判据据此断言"确实换了后端"
writeFileSync(process.env.FAKE_EVIDENCE, JSON.stringify({
argv: process.argv.slice(2), env: { FAKE_MARK: process.env.FAKE_MARK },
cwd: process.cwd(),
}));
const rl = createInterface({ input: process.stdin });
rl.on("line", (line) => {
line = line.trim(); if (!line) return;
const req = JSON.parse(line);
let result;
if (req.method === "ping") result = { ok: true, session_id: null, cli_version: "fake-1.0" };
else if (req.method === "prompt") result = { ok: true, session_id: "sess_fake", reply: "来自假后端" };
else if (req.method === "state") result = { ok: true, session_id: "sess_fake", messages: 1 };
else result = { ok: true };
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: req.id, result }) + "\n");
if (req.method === "close") process.exit(0);
});
`
if err := os.WriteFile(script, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
evidence := filepath.Join(t.TempDir(), "evidence.json")
drvJSON := map[string]interface{}{
"name": cli, "script": "sidecar.mjs", "node": "auto",
"sessionNameFlag": false,
"env": map[string]string{"FAKE_MARK": "yes-from-driver", "FAKE_EVIDENCE": evidence},
"args": []string{"--workdir", "{workdir}", "--session-id", "{sessionId}"},
"llmConfig": "none",
}
b, _ := json.MarshalIndent(drvJSON, "", " ")
if err := os.WriteFile(filepath.Join(dir, "driver.json"), b, 0o644); err != nil {
t.Fatal(err)
}
wd := t.TempDir()
br := &Branch{ID: "br-1", ProjectID: "p", Name: "feat/x", Workdir: wd, AgentCLI: cli, Status: "open"}
sc, err := p.startSidecar(br, "sess-1")
if err != nil {
t.Fatalf("★ 换驱动后启动失败(核心仍依赖 pi?): %v", err)
}
defer sc.Close()
// ping 的版本应被记到台账(核心认 cli_version 字段)
if br.PiVersion != "fake-1.0" {
t.Errorf("应记录后端版本,实际 %q", br.PiVersion)
}
// prompt 必须能走通并回报 session_id
res, err := sc.call("prompt", map[string]interface{}{"text": "hello"})
if err != nil {
t.Fatalf("prompt 失败: %v", err)
}
m, ok := res.(map[string]interface{})
if !ok || m["session_id"] != "sess_fake" {
t.Fatalf("prompt 未按契约回报 session_id: %v", res)
}
// ★ 证据:后端确实拿到了**驱动声明**的参数与环境
ev, err := os.ReadFile(evidence)
if err != nil {
t.Fatalf("假后端未写证据(说明它没被启动): %v", err)
}
s := string(ev)
if !strings.Contains(s, "yes-from-driver") {
t.Errorf("驱动声明的环境未注入: %s", s)
}
if !strings.Contains(s, wd) {
t.Errorf("驱动声明的 --workdir 未传入: %s", s)
}
if strings.Contains(s, "session-name") {
t.Errorf("sessionNameFlag:false 却传了 --session-name: %s", s)
}
}
var _ = exec.Command

View File

@ -0,0 +1,345 @@
package main
// 传输层:local(exec)| ssh(远端同一份 sidecar)。
//
// ## 设计依据(续篇 §3.1)
//
// 「默认本地内嵌,配置切到远程 SSH。两者**共用同一份 sidecar**,
// 差别只在**传输层**(本地 exec vs 远程 ssh)」
//
// ⇒ 所以这不改变 sidecar 契约、不改变驱动声明 —— 只是把「怎么起进程」
// 从 `exec.Command(node, script, args…)` 换成 `ssh host <同样的命令>`。
//
// ## 两个轴是正交的(关键)
//
// Runtime = local | ssh ← 在哪跑(本文件)
// AgentCLI = pi | zcode | ... ← 跑什么(sidecar_driver.go)
//
// 四种组合都成立:本地 pi / 本地 zcode / 远端 pi / 远端 zcode。
// 组合数不该让代码分支数量相乘 —— 所以传输层只关心「命令行 + 环境」,
// 完全不知道跑的是哪个 CLI。
//
// ## ★ SSH 的硬边界(必须诚实,不能假装等价)
//
// 1. **环境变量不能像本地那样注**:`ssh host cmd` 不继承本地环境。
// 必须把驱动要求的 env **显式写进远端命令**(`env K=V … node script …`)。
// 2. **node 可能是远端的**:本地随包分发的 node 在远端不存在。
// ⇒ node 路径必须可被驱动声明为**远端路径**(`Node` 字段),
// 且默认值应是 `node`(走远端 PATH),而不是本地绝对路径。
// 3. **脚本要先在远端存在**:本地改的 sidecar.mjs 不会自动出现在远端。
// ⇒ 提供 `syncSidecar` 把一个目录下的脚本推过去(首次拉起时)。
// 4. **退出码/信号语义不同**:ssh 会把远端退出码透传,但进程树杀不掉
// (本地 kill 杀不到远端),必须用远端 `timeout`/`pkill` 兜底。
// 5. **stdin 经 ssh 传递是可行的**(ssh 转发 stdin),所以脚本仍走 stdin ——
// 这条安全纪律(明文不进 argv)在远端同样成立。
//
// 第 4 条决定了:远端场景下**超时要在远端执行**,不能只靠本地 kill。
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// sshTarget 描述一个远端。
type sshTarget struct {
// Host 是 ssh 的目标,如 `user@host` 或 ~/.ssh/config 里的别名。
Host string
// Port 可选。
Port int
// WorkdirRoot 是远端放工程目录的根(镜像本地 program/ 的角色)。
WorkdirRoot string
// NodePath 是**远端**的 node(默认 `node`,走远端 PATH)。
NodePath string
// RemoteRuntimeRoot 是远端放 sidecar 脚本的目录。
RemoteRuntimeRoot string
}
// parseSSHTarget 从分支的远端声明里解析出目标。
//
// 分支上只存一个字符串(`user@host` 或 `user@host:port`),其余用默认值 ——
// 让台账保持简单,高级配置走 settings。
func parseSSHTarget(b *Branch) (sshTarget, error) {
raw := strings.TrimSpace(b.SSHHost)
if raw == "" {
return sshTarget{}, fmt.Errorf(
"分支 %s 声明 runtime=ssh 但没给 ssh_host(形如 user@host 或 alias)", b.ID)
}
t := sshTarget{Host: raw, NodePath: "node"}
if i := strings.LastIndex(raw, ":"); i > 0 && !strings.Contains(raw[i:], "@") {
// user@host:2222 → 拆出端口
var port int
if _, err := fmt.Sscanf(raw[i+1:], "%d", &port); err == nil && port > 0 {
t.Host = raw[:i]
t.Port = port
}
}
return t, nil
}
// sshBaseArgs 是 ssh 的公共参数。
//
// BatchMode=yes:**不交互**。若没配好免密,必须立刻失败 ——
// 否则 ssh 会停在密码提示上等输入,而工具调用会一直挂着直到 60s 超时,
// 表现为「拉起 agent 卡住」而不是「没有免密」(排查方向完全不同)。
func (t sshTarget) sshBaseArgs() []string {
args := []string{
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=10",
}
if t.Port > 0 {
args = append(args, "-p", fmt.Sprint(t.Port))
}
return args
}
// sshCommand 把「远端要跑的命令」包成一条 ssh 调用。
//
// ★ 环境变量显式前置(`env K=V …`)—— ssh 不继承本地环境。
// 这是 SSH 与本地最大的行为差异,也是本地能跑、远端跑不起来的头号原因。
func (t sshTarget) sshCommand(node string, script string, args []string, env map[string]string) []string {
remote := []string{}
if len(env) > 0 {
remote = append(remote, "env")
// 固定顺序(map 随机 ⇒ 命令不可对照)
keys := make([]string, 0, len(env))
for k := range env {
keys = append(keys, k)
}
sortStrings(keys)
for _, k := range keys {
remote = append(remote, shellQuote(k+"="+env[k]))
}
}
remote = append(remote, shellQuote(node), shellQuote(script))
for _, a := range args {
remote = append(remote, shellQuote(a))
}
argv := t.sshBaseArgs()
argv = append(argv, t.Host, strings.Join(remote, " "))
return argv
}
// shellQuote 单引号转义(远端命令要过一层 shell)。
//
// ★ 必须转义:工作目录、session id、驱动声明的 env 值都可能含空格/引号,
// 不转义就会被远端 shell 当作多个参数 —— 那是最典型的"本地好、远端坏"。
func shellQuote(s string) string {
if s == "" {
return "''"
}
// 安全字符集直接返回(可读性)
safe := true
for _, r := range s {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' ||
r == '-' || r == '_' || r == '.' || r == '/' || r == ':' || r == '@' || r == '=') {
safe = false
break
}
}
if safe {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// buildRemoteCommand 组装远端启动命令(供 ssh 与本地共用同一套语义)。
//
// 返回 (node, script, args, env),其中 env 已按驱动展开。
func buildRemoteCommand(drv driverSpec, ctx driverCtx, script string, node string) (string, []string, map[string]string) {
env := map[string]string{}
for k, v := range drv.Env {
env[k] = drv.expand(v, ctx)
}
return node, drv.buildArgs(ctx), env
}
// buildRemoteCommandFor 按**端点表里该机器该后端**的实际配置拼远端命令。
//
// ★ 这是相对 buildRemoteCommand 的关键修正:后者用 `drv.buildArgs(ctx)`,
// 其中 `drv.CLIPath` 会被展开成 `--cli <**本机**路径>`(如 `{rawfile}` → 本机插件目录)
// 然后原样发到远端 —— 远端上那个路径百分之百不存在。
//
// 两个真实后果(单台远端时都被掩盖,多台时必现):
//
// 1. 远端 zcode 起不来,报的却是**远端 shell 的** “No such file”,
// 用户会去远端找原因,而真正的问题是本地把路径发错了。
// 2. 每台机器的 CLI 安装位置不同(这正是在端点表里按 host 分开的原因),
// 单一全局值必然至少对一台是错的。
//
// 所以:远端命令里的 `--cli` **只能**用端点表解析出来的值。
func buildRemoteCommandFor(drv driverSpec, ctx driverCtx, node string, ep resolvedEndpoint) (string, []string, map[string]string) {
env := map[string]string{}
for k, v := range drv.Env {
env[k] = drv.expand(v, ctx)
}
// 端点表声明的额外 env(值同样支持占位符)
for k, v := range ep.Env {
env[k] = drv.expand(v, ctx)
}
args := make([]string, 0, len(drv.Args)+2)
for _, a := range drv.Args {
args = append(args, drv.expand(a, ctx))
}
// ★ 只加远端自己那条路径。这里**刻意不复用** buildArgs,
// 因为 buildArgs 会带上 drv.CLIPath(本地值)。
if strings.TrimSpace(ep.CLIPath) != "" {
args = append(args, "--cli", ep.CLIPath)
}
return node, args, env
}
// pushSidecar 把本地 driver 目录下的脚本推到远端(幂等)。
//
// 为何需要:本地改的 sidecar.mjs 不会自动出现在远端。不推的话,
// 远端跑的是**上次推的旧版** —— 而症状是"改了没生效",极难归因。
//
// 用 `scp -q`;失败**响亮报错**(不静默继续用远端旧脚本)。
func (p *Plugin) pushSidecar(t sshTarget, drv driverSpec, localDir string, remoteRoot string) error {
if remoteRoot == "" {
return fmt.Errorf("远端 runtime 目录未配置(ssh_runtime_root)")
}
// 远端建目录
mk := append(t.sshBaseArgs(), t.Host, "mkdir -p "+shellQuote(remoteRoot))
if out, err := exec.Command("ssh", mk...).CombinedOutput(); err != nil {
return fmt.Errorf("远端建目录失败: %s: %w", strings.TrimSpace(string(out)), err)
}
local := filepath.Join(localDir, drv.Script)
if _, err := os.Stat(local); err != nil {
return fmt.Errorf("本地找不到 sidecar 脚本: %s", local)
}
// scp(端口与 ssh 一致)
args := []string{"-q", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new"}
if t.Port > 0 {
args = append(args, "-P", fmt.Sprint(t.Port))
}
args = append(args, local, t.Host+":"+remoteRoot+"/")
if out, err := exec.Command("scp", args...).CombinedOutput(); err != nil {
return fmt.Errorf("推送 sidecar 脚本失败(远程可能仍是旧脚本): %s: %w",
strings.TrimSpace(string(out)), err)
}
return nil
}
// probeSSH 探测远端可用性(免密 + node)。
//
// 拉起 agent 之前先探一次:**早失败**。直接拉起的话,失败会混在
// sidecar 握手超时里(10s 后报"握手失败"),而真因可能是没免密。
// probeSSH 探测远端可用性(免密 + node)。
//
// ★ 必须用 t.NodePath 而不是硬编码 "node":
// ssh_node_path 的存在意义就是"远端 node 不在 PATH 里"
// (例如 /opt/node/bin/node)。初版探的是 `node`,于是**用户按提示配好了
// ssh_node_path,probe 却仍然失败**并报"远端不可达或未配免密" ——
// 把一个配置问题伪装成连通性问题,方向完全错。
func (p *Plugin) probeSSH(t sshTarget) error {
node := strings.TrimSpace(t.NodePath)
if node == "" {
node = "node"
}
argv := append(t.sshBaseArgs(), t.Host, shellQuote(node)+" --version")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ssh", argv...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf(
"远端不可达或未配免密(ssh %s): %s —— 本插件不交互输密码,请先配好免密",
t.Host, strings.TrimSpace(string(out)))
}
if !strings.HasPrefix(strings.TrimSpace(string(out)), "v") {
return fmt.Errorf("远端 `node --version` 输出异常: %q", strings.TrimSpace(string(out)))
}
return nil
}
// startSSHSidecar 起一个远端 sidecar 进程(本地 ssh 客户端持有 stdin/stdout)。
//
// ★ 与本地路径**共用同一份 sidecar 契约**:ssh 转发 stdin/stdout,
// 所以脚本仍走 stdin(明文不进 argv 的纪律在远端同样成立)。
func (p *Plugin) startSSHSidecar(b *Branch, drv driverSpec, ctx driverCtx, cli string) (*sidecarProcess, error) {
t, err := parseSSHTarget(b)
if err != nil {
return nil, err
}
// ★ 端点解析:host 默认 → (host,cli) 覆盖 → driver.json 兜底。
// 合并逻辑在 endpoints.go(含旧 settings 的兼容回退)。
ep := p.resolveEndpoint(t.Host, cli, drv, ctx)
t.NodePath = ep.NodePath
remoteRoot := ep.RuntimeRoot
if remoteRoot == "" {
return nil, fmt.Errorf(
"未配置远端 runtime 目录(放 sidecar 脚本的地方)。"+
"请用 commondev_endpoint_set host=%s runtime_root=<远端目录>,"+
"或配置全局 settings 的 ssh_runtime_root", t.Host)
}
// ★ 本机路径絕不下发(含本机占位符 / 本机绝对路径两种情况)。
// 必须在 ssh 之前拦,否则错误来自远端 shell,排查方向全错。
if err := validateRemoteCLIPath(ep.CLIPath, cli); err != nil {
return nil, err
}
if err := p.probeSSH(t); err != nil {
return nil, err
}
// 推脚本(幂等)。不推的话远端跑的是旧版而症状是"改了没生效"。
if err := p.pushSidecar(t, drv, p.driverDir(cli), remoteRoot); err != nil {
return nil, err
}
node, args, env := buildRemoteCommandFor(drv, ctx, t.NodePath, ep)
remoteScript := remoteRoot + "/" + drv.Script
sshArgv := t.sshCommand(node, remoteScript, args, env)
cmd := exec.Command("ssh", sshArgv...)
cmd.Dir = ctx.Workdir // 本地 cwd 无关紧要,但保持一致
cmd.Stderr = os.Stderr
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("ssh stdin: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("ssh stdout: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("启动 ssh 失败: %w", err)
}
timeout := defaultSidecarTimeout
if v, _ := p.sdk.Settings().Get("sidecar_timeout_sec"); v != nil {
if n, err := parseInt(v); err == nil && n > 0 {
timeout = time.Duration(n) * time.Second
}
}
sp := newSidecarProcess(b.ID, ctx.Workdir, cmd, bufio.NewWriter(stdin), stdout, timeout)
sp.handshakeTimeout = 20 * time.Second // ssh 建链比本地慢
if ping, err := sp.call("ping", nil); err != nil {
sp.Close()
return nil, fmt.Errorf("远端 %s sidecar 握手失败: %w", cli, err)
} else if m, ok := ping.(map[string]interface{}); ok {
for _, k := range []string{"pi_version", "cli_version", "version"} {
if v, ok := m[k].(string); ok && v != "" {
b.PiVersion = v
break
}
}
}
return sp, nil
}
func parseInt(v interface{}) (int, error) {
var n int
_, err := fmt.Sscanf(fmt.Sprint(v), "%d", &n)
return n, err
}

View File

@ -0,0 +1,210 @@
package main
// SSH 传输层判据。
//
// 重点验三件事(都是"本地好、远端坏"的典型来源):
// ① 环境变量**显式前置**(ssh 不继承本地环境)
// ② 参数**正确转义**(远端还有一层 shell)
// ③ 远端命令字面量正确(node 用远端路径、脚本用远端路径)
//
// ⚠ 这些判据**不需要真的 ssh 到远端** —— 它们验证的是命令组装,
// 那正是本地能测且最常错的部分。真机远端验证需另配环境(见文末说明)。
import (
"os"
"path/filepath"
"strings"
"testing"
)
// ★★ 判据:ssh 命令里**环境变量必须显式前置**。
//
// ssh 不继承本地环境。若只把 env 塞进本地 cmd.Env,远端进程拿不到 ——
// 而它的表现是「远端 pi/zcode 找不到 agentdir / 会话目录」,
// 看起来像远端配置问题,实际是我们没传过去。
func TestSSHCommandExportsEnvExplicitly(t *testing.T) {
tgt := sshTarget{Host: "u@h"}
argv := tgt.sshCommand("node", "/r/sidecar.mjs", []string{"--workdir", "/w"},
map[string]string{"PI_CODING_AGENT_DIR": "/rd/agentdir"})
joined := strings.Join(argv, " ")
if !strings.Contains(joined, "env ") {
t.Fatalf("★ ssh 命令里没有 `env` 前缀 —— 远端拿不到环境变量: %s", joined)
}
if !strings.Contains(joined, "PI_CODING_AGENT_DIR=/rd/agentdir") {
t.Fatalf("环境变量未出现在远端命令里: %s", joined)
}
// 环境顺序必须稳定(否则命令不可对照、diff 无意义)
if strings.Join(tgt.sshCommand("node", "/r/s.mjs", nil, map[string]string{"B": "2", "A": "1"}), " ") !=
strings.Join(tgt.sshCommand("node", "/r/s.mjs", nil, map[string]string{"A": "1", "B": "2"}), " ") {
t.Error("环境变量顺序不稳定")
}
}
// ★★★ 判据:含空格/引号的参数**必须正确转义**。
//
// 远端还有一层 shell:不转义就会被拆成多个参数。
// 这条最容易漏,因为本地测试路径通常不含空格(`/tmp/xxx`)——
// 一旦真实路径是 `/home/my project/…` 就炸,而本地全绿。
func TestSSHCommandQuotesArgumentsWithSpaces(t *testing.T) {
tgt := sshTarget{Host: "u@h"}
argv := tgt.sshCommand("/usr/bin/node", "/r/side car.mjs",
[]string{"--workdir", "/home/my project/repo"},
map[string]string{"NOTE": "it's a test"})
joined := strings.Join(argv, " ")
// 远端命令里必须出现被引号包住的整体(而不是裸的空格分隔)
if !strings.Contains(joined, `'/home/my project/repo'`) {
t.Fatalf("★ 含空格的工作目录未转义(远端会拆成两个参数): %s", joined)
}
// 引号包住的是整个 `NOTE=it's a test`,所以出现的是
// `'NOTE=it'\''s a test'` —— 关键证据是那个转义序列 `'\''`。
if !strings.Contains(joined, "'\\''") {
t.Fatalf("★ 含单引号的值未做 shell 转义: %s", joined)
}
// shellQuote 单元级验证
for in, want := range map[string]string{
"simple": "simple",
"with space": "'with space'",
"a'b": `'a'\''b'`,
"": "''",
"/a/b-c.d_e:f@g=h": "/a/b-c.d_e:f@g=h",
} {
if got := shellQuote(in); got != want {
t.Errorf("shellQuote(%q)=%q 期望 %q", in, got, want)
}
}
}
// ★★ 判据:**node 必须用远端的路径**,不是本地随包分发的绝对路径。
//
// 本地 node 在远端通常不存在;用本地路径会报 "No such file or directory",
// 而那个错误看起来像"远端没装 node",实际是我们传错了路径。
func TestSSHUsesRemoteNodePath(t *testing.T) {
tgt := sshTarget{Host: "u@h", NodePath: "node"}
argv := tgt.sshCommand(tgt.NodePath, "/r/s.mjs", nil, nil)
joined := strings.Join(argv, " ")
if strings.Contains(joined, "/root/.homeagent") || strings.Contains(joined, "plugin.bin") {
t.Fatalf("★ 远端命令里出现了本地路径: %s", joined)
}
// 默认应是裸 `node`(走远端 PATH)
last := argv[len(argv)-1]
if !strings.HasPrefix(last, "node ") {
t.Fatalf("远端命令应以远端 node 开头: %s", last)
}
}
// ★★ 判据:ssh 必须**非交互**(BatchMode)。
//
// 缺它会停在密码提示上等输入 ⇒ 工具调用挂到 60s 超时,
// 表现为「拉起 agent 卡住」而不是「没配免密」—— 排查方向完全不同。
func TestSSHIsNonInteractive(t *testing.T) {
tgt := sshTarget{Host: "u@h"}
argv := tgt.sshBaseArgs()
joined := strings.Join(argv, " ")
if !strings.Contains(joined, "BatchMode=yes") {
t.Fatal("★ 缺少 BatchMode=yes —— 没配免密时会在密码提示上挂死")
}
if !strings.Contains(joined, "ConnectTimeout=") {
t.Error("缺少 ConnectTimeout(不可达主机会挂很久)")
}
}
// ★★ 判据:runtime=ssh 但没给 ssh_host ⇒ **响亮报错**。
func TestSSHRequiresHost(t *testing.T) {
b := &Branch{ID: "br-1", Runtime: "ssh"}
if _, err := parseSSHTarget(b); err == nil {
t.Fatal("★ runtime=ssh 但无 ssh_host 却未报错")
}
// 带端口的形式
b2 := &Branch{ID: "br-2", Runtime: "ssh", SSHHost: "u@h:2222"}
tgt, err := parseSSHTarget(b2)
if err != nil {
t.Fatal(err)
}
if tgt.Host != "u@h" || tgt.Port != 2222 {
t.Errorf("端口解析错: %+v", tgt)
}
}
// ★★★ 判据:**两个轴正交** —— runtime 与 agent_cli 的任意组合都能构造出正确命令。
//
// 这条防止将来有人把两者耦合成"if ssh then pi"这种分支
// (那会让组合数变成分支数)。
func TestRuntimeAndCLIAreOrthogonal(t *testing.T) {
p := newTestPluginForShell(t)
cases := []struct {
cli string
wantEnv string
wantNoEnv string
}{
{"pi", "PI_CODING_AGENT_DIR", "ZCODE_DATA_BASE_DIR"},
{"zcode", "ZCODE_DATA_BASE_DIR", "PI_CODING_AGENT_DIR"},
}
for _, c := range cases {
drv, dir, err := p.loadDriver(c.cli)
if err != nil {
if c.cli == "zcode" {
t.Skipf("没有 %s 驱动(可选)", c.cli)
}
t.Fatal(err)
}
ctx := driverCtx{
Workdir: "/w", SessionID: "s", SessionName: "br-1",
DataDir: dir, PluginDir: dir,
}
node, args, env := buildRemoteCommand(drv, ctx, "", "node")
if node != "node" {
t.Errorf("%s: 远端 node 应为裸 node,实际 %q", c.cli, node)
}
var keys []string
for k := range env {
keys = append(keys, k)
}
joined := strings.Join(keys, ",")
if !strings.Contains(joined, c.wantEnv) {
t.Errorf("%s: 远端 env 缺 %s(实际 %s)", c.cli, c.wantEnv, joined)
}
if strings.Contains(joined, c.wantNoEnv) {
t.Errorf("%s: 远端 env 混入了别的后端变量 %s", c.cli, c.wantNoEnv)
}
// sessionNameFlag=false 的后端不得带 --session-name
argStr := strings.Join(args, " ")
if drv.SessionNameFlag != nil && !*drv.SessionNameFlag && strings.Contains(argStr, "session-name") {
t.Errorf("%s: 声明不支持 --session-name 却带了: %s", c.cli, argStr)
}
}
}
// ★★ 判据:**推送脚本失败必须响亮**(不静默用远端旧脚本)。
func TestPushFailureIsLoud(t *testing.T) {
p := newTestPluginForShell(t)
tgt := sshTarget{Host: "u@h"}
// 本地脚本不存在 ⇒ 必须在推送前就报错
err := p.pushSidecar(tgt, driverSpec{Script: "nonexistent-sidecar.mjs"},
t.TempDir(), "/remote/runtime")
if err == nil {
t.Fatal("★ 本地脚本不存在却未报错")
}
// 没配远端 runtime 根目录 ⇒ 报错
if err := p.pushSidecar(tgt, driverSpec{Script: "x.mjs"}, t.TempDir(), ""); err == nil {
t.Fatal("★ 未配置 ssh_runtime_root 却未报错")
}
}
// ★ 判据:远端脚本路径必须是**远端根 + 驱动声明的脚本名**(不是本地路径)。
func TestRemoteScriptPathUsesRemoteRoot(t *testing.T) {
local := filepath.Join(t.TempDir(), "sidecar.mjs")
if err := os.WriteFile(local, []byte("// x"), 0o644); err != nil {
t.Fatal(err)
}
// 这条只验证"远端路径由 remoteRoot 决定"这一事实(不实际 ssh)
remoteRoot := "/remote/runtime/zcode"
got := remoteRoot + "/" + "sidecar.mjs"
if strings.Contains(got, os.TempDir()) {
t.Fatalf("远端路径混进了本地临时目录: %s", got)
}
if !strings.HasPrefix(got, remoteRoot) {
t.Fatalf("远端路径应以远端根开头: %s", got)
}
}

3
example/commondev/skill/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# skill/ 里的内容是随包分发的纯 SKILL 条目。
# 它们是真实需要版本控制的文档(不像 rawfile/ 下可能有上百兆二进制),
# 所以这里**不忽略**任何内容,只保留本说明。

View File

@ -0,0 +1,855 @@
---
name: commondev-discipline
description: commondev 主管式多仓库开发流水线的使用与开发纪律。当你要为某个工程开一条分支、把工程派给一个下属 agent、或用 pi 拉起执行 agent 干活时必读。核心动作:为每个工程对话新建驻留式子 agent 并把该工程的一对通道划给它(否则所有工程都挤在根 agent 上)。**git 托管平台的操作(Gitea/GitHub/GitLab)不在插件里,而在本文** —— 用 enc_shell + 保存的凭据调 curl/CLI 完成。也含 hmap 插件开发与部署纪律。
version: 2.0.0
author: HomeAgent
---
# commondev 纪律(主管模式 · 工程对话 · 平台操作)
本技能三部分,**都可能与你的任务直接相关**:
- **第一部分:使用者纪律** —— 怎么开工程、怎么派活。
- **第二部分:★ git 平台操作** —— **本文是权威**。开 PR、审批、分支保护怎么做。
- **第三部分:开发与部署纪律** —— 怎么不踩坑。
> ## ★ 架构边界(用户拍板 2026-10-15)
>
> | 层 | 负责 |
> |---|---|
> | **插件 commondev** | 项目台账、工作目录、pi 会话与通道、判据与变异复核、冲突预检、凭据保存与安全执行 |
> | **本文(skill)** | **各 git 托管平台的认证头、API 端点、PR 与分支保护流程** |
>
> **插件里没有平台逻辑。** 操作 Gitea/GitHub/GitLab 等平台一律:
> 读本文 → 用 `enc_shell` + `@{凭据名}` 执行 `curl`/`gh`/`tea`。
>
> ⇒ **新增一个托管平台只需改本文,不需要改插件、不需要重编译、不需要重启服务。**
>
> 原本这些在插件里(`platformSpec` / `commondev_account_*` / `commondev_pr_*` /
> `commondev_protect_branch` / `commondev_repo_search`),代价是:
> 每接一个平台都要改 Go 代码 → 重编译 → 重打包 → 重启,
> 且**未列出的平台直接报错**(GitLab/Gitee/Bitbucket 全不支持)——
> 而 agent 用 curl 本来什么平台都能打。这 10 个工具已移除。
> 看不懂某个词时的权威文档(按顺序读):
> 1. `docs/zh/commondev-design.md` —— 目标机制、台账 schema、判据与变异、安全(§12 是架构变更)。
> 2. `docs/zh/commondev-agent-runtime-design.md` —— 运行时与内核接入(续篇)。
> 3. `docs/zh/kernel-secret-firewall-design.md` —— 凭据模块(enc_* 的底座)。
> 4. `docs/zh/resident-subagent-design.md` —— 驻留子设计的原始文档。
>
> **本文与文档冲突时,以文档为准,并回来修正本文。**
---
## Setup: 第二部分 git 平台操作(本文是权威,不要凭记忆写 API)
### 11. 所有平台都是同一个形状
「带认证头发一个 REST 请求」。用 `enc_shell` + `curl` 即可:
```
enc_shell(command="curl -s -H \"<认证头>\" <API前缀><路径>")
```
**差异只有两个维度:API 前缀 与 认证头。** 其余是 REST 常规。
### 12. 认证头与 API 前缀(逐平台,★ 四种形状互不相同)
| 平台 | API 前缀 | 认证头 |
|---|---|---|
| **Gitea** | `<base>/api/v1` | `Authorization: token <T>` ← **不是 Bearer** |
| **GitHub** | `https://api.github.com` | `Authorization: Bearer <T>` |
| **GitLab** | `<base>/api/v4` | `PRIVATE-TOKEN: <T>` ← **不是 Authorization** |
| **GitCode** | `https://api.gitcode.com/api/v5` | `Authorization: Bearer <T>` |
| **Gitee** | `https://gitee.com/api/v5` | `access_token=<T>`(**查询参数**,不是头) |
⚠ 头名、前缀、甚至「用头还是用查询参数」都不同。**未知平台先查官方文档,不要猜形状。**
### 13. 先保存凭据,再用它
```
enc_store(alias="gitea-token", value="${GITEA_TOKEN}") # 值优先走 ${ENV} 或 @file:
enc_shell(command="curl -s -H \"Authorization: token @{gitea-token}\" http://192.168.2.106:3000/api/v1/user")
```
⚠ **优先用 `enc_store`,不要用 `commondev_account_add`** —— 后者已不存在
(它内建了平台语义:platform/role/base_url,那正是被移出的东西)。
**先验凭据再干活**(早失败,别推了一半才发现 token 错):
```
enc_shell(command="curl -s -o /dev/null -w '%{http_code}' -H \"Authorization: token @{gitea-token}\" http://192.168.2.106:3000/api/v1/user")
# 期望 200;401/403 = 凭据无效或权限不足
```
### 14. 常见操作(以 Gitea 为例,其他平台换前缀与头)
```bash
# 搜仓库
curl -s -H "Authorization: token @{t}" "http://host/api/v1/repos/search?q=myname&limit=20"
# 看仓库 / 分支
curl -s -H "Authorization: token @{t}" "http://host/api/v1/repos/jianf/MyProj"
curl -s -H "Authorization: token @{t}" "http://host/api/v1/repos/jianf/MyProj/branches"
# 开 PR(head=你的分支,base=目标分支)
curl -s -X POST -H "Authorization: token @{t}" -H 'Content-Type: application/json' -d '{"title":"...","head":"feat/x","base":"main"}' "http://host/api/v1/repos/jianf/MyProj/pulls"
# 查 PR 状态
curl -s -H "Authorization: token @{t}" "http://host/api/v1/repos/jianf/MyProj/pulls/7"
# 合 PR
curl -s -X POST -H "Authorization: token @{t}" -H 'Content-Type: application/json' -d '{"Do":"merge"}' "http://host/api/v1/repos/jianf/MyProj/pulls/7/merge"
```
**push 时凭据不进 remote URL**:
```bash
# ✅ 一次性注入(仅本次命令);clone 出来的 remote 保持干净
enc_shell(command="cd <repo> && git -c http.extraheader=\"Authorization: token @{t}\" push origin feat/x")
```
⚠ 若改用 `https://token@host/...` 形式,token 会**持久化进 `.git/config`** ——
删掉临时目录也删不掉凭据(`git remote -v` 就能读出来)。设计 §7.2 明令禁止。
### 15. Gitea 分支保护:让「必须复审」成为**机制**而非礼貌
**光有流程约定不算机制** —— 执行 agent 可以直接推主干。必须让**平台**拦。
```bash
curl -s -X POST -H "Authorization: token @{t}" -H 'Content-Type: application/json' -d '{"enable_push":false,"required_approvals":1,"block_admin_merge_override":true,"dismiss_stale_approvals":true}' "http://host/api/v1/repos/jianf/MyProj/branch_protections?name=main"
```
四个开关的意义(缺一个机制就有洞):
| 开关 | 作用 | 缺了会怎样 |
|---|---|---|
| `enable_push: false` | 禁止直接推送 | 执行 agent 直推主干,绕过评审 |
| `required_approvals: ≥1` | 至少一个审批 | 自己就能合 |
| `block_admin_merge_override: true` | admin 也不能绕过 | 用 admin token 的人绕过一切 |
| `dismiss_stale_approvals: true` | 新提交作废旧审批 | 先批准再改代码,批准白拿 |
**真机实测(Gitea 1.24.2)**:
| 动作 | 普通用户 token | admin token |
|---|---|---|
| push `main` | ❌ `pre-receive hook declined` | ⚠ 可能成功(见下)|
| push `feat/*` | ✅ | ✅ |
| 自合 PR | ❌ `Does not have enough approvals` | ⚠ 取决于开关 |
| 审批 → 合 PR | — | ✅ |
⇒ **推论:执行侧必须用普通用户 token。** 用 admin token 会**静默绕过分支保护**
(看起来正常,实际机制失效且无报错)—— 这是最容易搞错的一点。
⚠ 分支保护已存在时用 **PATCH**(POST 会 403 `already exist`)。
### 16. 审批:三个必踩的 API 坑(实测)
1. **`event` 必须是 `APPROVED`,不是 `APPROVE`。**
传 `APPROVE` 时请求**返回 200 且不报错**,但 review 状态落成 `PENDING` ——
于是 `required_approvals` 永远不满足,合并时报 `Does not have enough approvals`。
表象(权限不足)与真因(枚举值少个 `D`)毫无关联,极难自查。
2. **必须带 `commit_id`** —— 不带时同样落成 `PENDING`(与 `event` 是两个独立条件)。
3. **`required_approvals` 也约束 admin** —— 别指望用 admin 跳过审批。
```bash
# ① 先取 head sha(审批必须绑定**具体提交**,不是"移动的目标")
enc_shell(command="curl -s -H \"Authorization: token @{t}\" http://host/api/v1/repos/jianf/MyProj/pulls/7 | grep -o '\"sha\":\"[a-f0-9]*\"' | head -1")
# ② 审批(event 必须 APPROVED,且带 commit_id)
enc_shell(command="curl -s -X POST -H \"Authorization: token @{t}\" -H 'Content-Type: application/json' -d '{\"body\":\"通过\",\"event\":\"APPROVED\",\"commit_id\":\"<sha>\"}' http://host/api/v1/repos/jianf/MyProj/pulls/7/reviews")
```
**失败时保留平台原话**:`Does not have enough approvals` 是区分「机制生效」
与「配置错了」的唯一线索,别把它翻译掉或吞掉。
---
## Usage: 第一部分 使用者纪律
> **★ 设计总纲 [已定]**:插件对 pi 做的事,本质就是
> **「把给 pi 发消息包装一下、把 pi 返回包装一下」**,深度融合内核的 IO 抽象与中断系统
> —— 即 **pi = 一个 Device**。**不要自己造调度器 / 会话路由 / 中断栈**,
> 全部接到内核已有的那套上(`InjectInputTo` / `RegisterOutputChannel` / `InjectInterrupt*Opts`)。
### 21. 角色与单位
| 角色 | 是谁 | 干什么 |
|---|---|---|
| **人** | 老板 | 只与主管对接 |
| **主管** | 你(长期驻留的 HomeAgent 主 agent) | 拆任务、**定义判据**、复审、决定合入 |
| **执行 agent** | 按工程拉起的一个 pi / dsh 实例 | 在某棵工作树里干活 |
| **commondev 插件** | 外部独立插件 | 仓库/凭据/分支/PR/测试,全部机械化操作 |
**为何能用内核机制**(而不是自己造):
| 内核概念 | pi 侧对应 | 落点(内核已有) |
|---|---|---|
| **给 pi 发消息** | `pi.prompt` / `steer` / `follow_up` | `InjectInputTo(source, "commondev/<id>", …)` |
| **pi 返回** | sidecar 回调 / `tool_execution_*` / `agent_settled` | `InjectTextTo(source, "commondev/<id>", …)`(按 inputch Owner 路由) |
| **中断 pi** | `steer`(插到当前轮之后) | `InjectInterruptTextOpts(..., Priority: L3)` |
| **音声/图像产物** | pi 可产出的媒体 | `OutputCapabilities()` 声明 |
⇒ **插件只做两件额外事**:① 把 pi 接成 Device;② 台账记 `session_id` 并断言未变。
调度/中断/路由/记忆策略**全是内核已经在做的事**。
### 22. 「工程对话」是一等对象
它是四元组,缺一不可:
```
工程对话 (id = br-<project>-<slug>)
├─ workdir <插件数据目录>/program/<hash>-<name>
│ ★ hash = H(项目+分支) 是**唯一性来源**
│ ⇒ **同一仓库的不同分支必然是不同目录**(否则两 agent 共用工作树、改动互相污染)
│ ★ name = 分支名派生,**仅做可读性**,不承担唯一性
│ (派生后可能撞:`feat/a` 与 `feat.a` 都成 `feat_a`,所以必须带 hash)
│ 独立 clone(非 worktree、非 --reference);合入后**整目录删除**
├─ session_id pi 会话(★ 续投的唯一依据)
├─ runtime local(默认,内嵌) | ssh(远端同样建目录拉 pi)
├─ host runtime=ssh 时的目标主机(**branch_open 时给**,见 §24.1)
├─ agent_cli 执行后端(默认 pi;其他 CLI 需自备 sidecar,见 §24.2)
└─ channels 插件为该工程注册的一对通道
├─ inputch commondev/<id>
└─ outputch commondev_<id>
```
**一个仓库可并行开发**:每分支一份独立 clone,互不污染、可同时推进。
但并行有前提——必须是**互不相干的模块**;有依赖的任务请先拆边界。
> **为何不能用 worktree / `--reference`**(实测):worktree 删后有
> `.git/worktrees/` 残留(需 `prune`);`--reference` 有 alternates 外部依赖,
> mirror 被清即损坏。**本地 clone 对象为硬链接**(实测 inode 相同)⇒ 省磁盘、
> 无外部依赖、刪掉即彻底。代价:失去 worktree「同分支不可双挂」的保护
> ⇒ 台账层必须补**唯一性闸**(同名未结分支响亮拒绝)。
### 23. ★ 必做:为每个工程对话新建驻留子,并把通道划给它
**★ 顺序很关键**:通道是**拉起 pi 拿到 session 之后**才注册的,所以你的动作是:
```
建工程 ──► commondev_agent_up(拉起 pi,拿到 session_id)
└─► 插件**此刻才**注册通道 inputch commondev/<id> + outputch commondev_<id>
└─► ★ 你建驻留子并把这对通道划给它
```
**为何必须先有 session**:通道是“与某个具体 pi 会话对话的入口”,没 session 就是空壳。
顺序错了(先建子)会拿到一个指向不存在通道的 `output_send__…`。
**插件只能把通道登记出来;「划入(assign)」只有内核能做。** 所以这一步**必须由你(agent)
自己调内核工具完成**,插件无法代劳。
```
resident_agents create
id = dev-<br-id>
input_chs = commondev/<br-id> ← 划入该 inputch(可用斜杠)
allowed_outputs = commondev_<br-id> ← 授权该 outputch(★ 下划线,见下)
capacity = 64
task_prompt = "你在 <workdir> 推进 <work>。
下任务给自己用 output_send__commondev_<br-id>(payload=…),
pi 的结果会回投到你的 commondev/<br-id>;
有结论用 notify_parent 汇报。"
```
**★ 下任务不需要新工具**:给 pi 发消息就是子调**内核原生的输出门**
`output_send__commondev_<br-id>(payload="…")` ⇒ 打到插件的通道 handler ⇒ 插件
查台账拿到 `session_id` 后 `sidecar.prompt()`。**插件不提供 `agent_send` 之类的工具**。
**⚠️ 通道命名(很容易踩)**:
| | 能用 `/` 吗 | 为什么 |
|---|---|---|
| **inputch** | ✅ | 内核自己就用 `child/<id>` |
| **outputch** | ❌ **绝对不行** | 它会拼成 `output_send__<name>`,上游要求函数名 `^[a-zA-Z0-9_-]{1,64}$`;违反的后果不是“这个工具不可用”,而是**整条请求 400、agent 整体失语** |
⇒ 所以 inputch 叫 `commondev/<br-id>`、outputch 叫 `commondev_<br-id>`。
`<br-id>` 本身也只能用 `[A-Za-z0-9_-]`。
**为什么必须这么做(不是可选的优化):**
1. inputch 是**最基本的输入路由单位**,路由按 `Owner` 在**进内核之前**完成。
划给子之后,**该工程的输入只流向那个子**,根 agent 看不到它 —— 这正是
多工程并行能做到"互不串台"的原因。
2. **不划会的代价**(如果你偷懒):所有工程对话的输入都涌向**根 agent**,
根成了瓶颈、上下文被 N 条工程的细节淹没,多工程并行**等于没做**。
现场教训(`resident-subagent-design.md`):子挂着 `inputch=[timer]` 而 timer
的输入打在父身上,**子的轮次永远是 0** —— 登记了不等于划了。
**落地判据**(怎么知道做对了):
- 投一条输入到 `commondev/<br-id>`,应只被 `dev-<br-id>` 处理;
- 根 agent 的轮次**不**因这条输入增加;
- `input_channels` 工具(内核自带,单工具多视图)用 `view=by_agent` 能看到划分总览。
### 24. 形态选择、自包含运行时与远端环境
**默认本地内嵌**(进程内 pi 实例);通过配置 `runtime=ssh` 切到**远程**,
远端**同样创建目录并在该目录拉起 pi**。
**★ 自包含运行时**:插件**自带独立的 node 与独立的 pi**,都装在**插件工作目录**,
**pi 的插件也装到这个目录** —— 不靠宿主机的 node/pi,也不污染用户的 `~/.pi`。
**独立 node/pi 随包分发**(不首次下载、不依赖用户装)。
```
<插件数据目录>/runtime/
node/ pi/
agentdir/ ← 每个内层 pi 必须传 PI_CODING_AGENT_DIR 指这里
settings.json ← pi 自己的配置(偏好 / 默认 model / 全局包声明)
models.json ← pi 自己的配置(★ LLM 源写这里)
auth.json ← pi 自己的配置(key / OAuth,0600)
mcp.json ← pi 自己的配置(MCP 服务器)
skills/ prompts/ themes/ extensions/
```
#### 24.1 远端(ssh)怎么开
两个轴**正交**,开线时一起给:
```
commondev_branch_open(project="p", branch="feat/x",
runtime="ssh", ssh_host="build@10.0.0.9")
```
- `runtime=ssh` 而**没给 `ssh_host`** → 直接拒绝(不会拖到拉起时才报)
- **未知 runtime**(比如 `k8s`)→ 响亮拒绝,**不静默降级为 local**
(静默降级会让"我以为跑在远端"一路错下去)
- 远端前置条件(**先配好,不要指望交互输密码**):
- 从本机到目标机的**免密**(`BatchMode=yes`,卡在密码提示会超时)
- 远端有 node(不在 PATH 里就配 `ssh_node_path`,例如 `/opt/node/bin/node`)
- **必配两个根目录**(缺任一,远端线开不起来):
- `ssh_workdir_root` — 远端放**工程工作目录**的根
- `ssh_runtime_root` — 远端放 **sidecar 脚本**的目录
- **★ 工作目录在远端准备**:本地 `clone --mirror` 出来的目录远端看不到,
所以远端线走的是**远端建目录 → 远端共享 mirror(`<root>/.mirrors/`)→
远端 clone → 远端切分支**。同一远端上的多条分支线共享一份 mirror。
台账里的 `workdir` 会被改写成**远端路径**(不是本地路径)。
远端拉取要求**远端自己能认证该仓库** —— 本插件**不把凭据推到远端**
(凭据不进工作目录、不进 remote URL)。拉不动就响亮报错。
- 每次拉起会 `scp` 把 sidecar 推过去(幂等)——**不推就会跑上一次的旧脚本**,
症状是"改了没生效",极难归因。推送失败**响亮报错**,不静默用旧脚本。
- 用 `commondev_agent_state` 看 `ssh_host` 确认打到哪台机。
#### 24.2 接其他 CLI(zcode 等)——**不改插件核心**
执行后端是**声明式**的:放一个目录就多一个后端。
```
<插件数据目录>/runtime/<cli>/
driver.json ← 描述:脚本名 / 参数 / 环境变量 / LLM 源策略
sidecar.mjs ← 实现 sidecar 契约(ping / prompt / state / ...)
```
`driver.json` 关键字段:
| 字段 | 含义 |
|---|---|
| `script` | 脚本文件名(默认 `sidecar.mjs`) |
| `node` | `auto` = 用插件自带的 node |
| `args` | 参数模板,占位符 `{workdir} {sessionId} {sessionName} {dataDir} {pluginDir}` |
| `env` | 环境变量模板(同样支持占位符) |
| `llmConfig` | `pi` = 插件替它写 pi 的配置;**`none` = 它自管凭据,插件不碰** |
| `sessionDirSuffix` | 会话目录后缀 |
**纪律**:
- **未知 `agent_cli` 响亮报错,绝不回退到 pi** —— 回退会用错后端跑任务而无提示。
- `driver.json` **损坏也报错**(不猜默认),否则是"改了没生效"的经典来源。
- 内置 pi **不需要** `driver.json`(有内建描述),老部署不受影响。
- 现成例子看 `examples/custom-cli/zcode/`(**源码核实过**的 ZCode 契约)。
### 25. ★ 两种插件安装粒度(临时 vs 全局)
| 粒度 | 作用域 | 落到实处 | 用什么 |
|---|---|---|---|
| **临时** | 一个工程对话 | `<workdir>/.pi/settings.json` | `pi install -l` |
| **全局** | 插件自带 pi 的**所有**工程 | `<agentdir>/settings.json` | `pi install` + `PI_CODING_AGENT_DIR` |
**支持的源**(实测):`npm:@scope/pkg@1.0.0`(带版本即钉住)/ `git:github.com/u/r@v1` /
`https://github.com/u/r`(当 git 源)/ `ssh://…` / 本地路径(**不复制**)。
**三条纪律**:
1. **必须传 `PI_CODING_AGENT_DIR`**,否则「全局」安装会写进**用户的** `~/.pi/agent/settings.json`
—— 那是对宿主的越界。
2. **⚠ 安装是复制,不是引用**:`pi install ./pkg` 会把包**复制**到目标;改源目录
**不影响已装的那份**(我曾因此得出「插件不生效」的错误结论)。要更新必须重装。
3. **安装是显式一步**,不藏在 `agent_up` 里;来源(npm 包名 / 仓库 URL / 版本钉点)要可见。
### 26. ★ LLM 源与 pi 配置:直接写 pi 的配置文件
⛔ **不要自建第二套配置存储**(本稿前版曾提 `config_commondev`,**已废弃**)。
**pi 本身就有完整的配置系统**,插件只是它的**受控写入入口**:
```
<agentdir>/settings.json ← 偏好 / 默认 model / 「全局」包声明
<agentdir>/models.json ← 兼容端点与模型(★ LLM 源写这里)
<agentdir>/auth.json ← API key / OAuth(0600)
<agentdir>/mcp.json ← MCP 服务器
```
内核把 LLM 源存在 **`config` 表**(不是 `config_<plugin>`),键前缀 `core.llm.`:
```
core.llm.provider = llmsproxy ← ★ 默认源就是这一项
core.llm.sources.<n>.base_url / .api_key / .model / .adapter / .context_window …
```
**导入时字段一对一对上**:`base_url`→`baseUrl`、`api_key`→`apiKey`、
`adapter=openai`→`api="openai-completions"`、`model`→`models[].id`、
`context_window`→`contextWindow`。
**工具面**(每个工具对应一处 pi 配置):
```
commondev_pi_source_import 把内核的源批量导入内置 pi
commondev_pi_source_add 添加源(写 models.json / auth.json)
commondev_pi_source_list 列出(默认**不返回** api_key)
commondev_pi_source_default 指定默认源(写 settings.json)
commondev_pi_config_get/set 读写其他 pi 配置
commondev_pi_mcp_add/remove/list
```
**四条实现纪律**:
1. **写的就是 pi 的配置文件**,不造中间层;格式与 pi 自己写出的保持一致
(否则 pi 重写文件时会把自定义字段抹掉)。
2. **改前备份、改后回读校验**:json 写坏会让内层 pi 直接起不来;失败则回滚 + **响亮报错**。
3. **key 不进日志/不进返回值**。
4. **不改用户全局**:所有写入带 `PI_CODING_AGENT_DIR=<agentdir>` 或显式 scope,
绡不碰 `~/.pi/agent/`。
**两条红线**:
1. **key 不能明文落盘到无保护位置**:`models.json`/`auth.json` 含明文 key ⇒ 目录 **0700**、
文件 **0600**。内核那套是明文存 `config.db`(**644 全局可读**)—— **别把这套惯例带进来**。
2. **不得静默回退到宿主凭据**:导入失败/源不可用就**报错**,不要偷偷用 `~/.pi/agent/auth.json`
跑起来(那是宿主的凭据,用了就是越界)。
### 27. 远端环境缺失:问用户,不要自己装(硬纪律)
```
ssh 不通 → 报错(配置问题,不是缺环境)
无 pi / 无 node → ★ 注入 L3「远端 <host> 未装 X,是否安装?」→ **你(agent)去问用户**
目录不可写 → 报错(权限问题,需人处理)
```
- **SDK 没有“向用户提问”的 API**(插件是子进程,不能直接弹 UI)。现有机制是插件把
决策消息注入给 agent(L1–L3),**由 agent 问用户**(先例:`cli` 的 `/stop` 用 L3)。
- **不得擅自安装**:不 `curl | sh` 到别人的机器上。要先说清「装什么、装哪里、要什么权限」。
- **不冒充成功**:环境缺失时 `agent_up` **响亮失败** + 留一条待决策项,
**不静默降级到本地跑**(那会让人以为活派到了远端)。
- **问完要能续**:安装动作本身是**可见的一步**(可查、可重跑),不是 `agent_up` 的隐式副作用。
### 28. 投递纪律(**这是最容易犯的错**)
**每个工程的投递必须落到同一个 `session_id`。** 实测反例(别重犯):
| 错法 | 后果 |
|---|---|
| 漏传 `--session-id` | `pi` **静默开新会话** —— 你以为在催上一次,其实是把活派给了陌生人 |
| 两个进程**并发**同 `--session-id` | 会话文件被写坏:`user A`/`user B` 相邻,`assistant B` 在前、`assistant A` 落在最后 ⇒ B 那轮的模型上下文里**根本没有 A 的用户消息**,树链交错 |
⇒ 投递前先拿台账里的 `session_id`,投完**断言它没变**;一个 session **绝不并发投递**。
这两条插件侧已做成结构性保证(单一投递队列 + 断言),你做主管时**不要绕过插件**直接
去敲 `pi -p`。
**★ 另外两条实测硬经验(别再踩):**
3. **SDK 路径必须显式指定会话 id**。用 `createAgentSession({cwd})` 而不传
`sessionManager` 时,pi **自己生成 UUID** 作会话 id ⇒ 与台账名字不一致 ⇒
断言**永远失败**(实测:期望 `br-cdtest-feat_alpha`,实际 `01a13959-…`)。
正确:`SessionManager.create(cwd, sessionDir, {id})` 再传给 `createAgentSession`。
4. **工具返 `ok` ≠ 消息到了**。内核对成功回执刻意**极简**(防 output loop),
所以 `ok` 只说 handler 成功了。必须去会话文件里看到 `user: <payload>` 才算数。
(这条能防住“以为投了、其实没投”这类假成功。)
### 29. 三层审查的分工(别把自己变成贵的高级码农)
| 层 | 审什么 | 不审什么 |
|---|---|---|
| agent 自审 | 代码正确性、风格、实现细节 | — |
| **你(主管)复审** | **这次的改动,判据够不够**:引入了哪些新失败方式?判据覆盖了吗?变异咬得住吗? | 逐行读 diff |
| 插件复核 | 机械执行「正向 + 变异」 | 语义判断 |
### 30. ★ 不要把「测试通过」当作「改对了」
插件只能跑**你预先写下的判据**。判据没覆盖那条 bug,插件照样打勾 ——
自动化**不会**发现假绿,**只会让假绿跑得更快、更可信**。
因此复核**必须两步**:
| 阶段 | 做什么 | 不通过的后果 |
|---|---|---|
| **正向** | 跑判据 → 期望通过 | 实现有 bug,驳回 |
| **变异** ★ | 自动改坏被测代码 → 期望判据**报红** | **判据太松,驳回** |
**没有变异阶段,就是在给「测试通过」这个信号不断充值信用,直到它骗一次大的。**
本仓实证:一条只查前缀的判据在变异测试下仍全绿,是变异把它咬出来的。
配套纪律:判据写「某标识符是否出现在函数体」时**必须先剔除注释再匹配**
(曾因注释里含 `outputParent` 之类的词导致假绿)。
### 31. 工程的目录与生命周期(一个仓库并行开发)
```
branch_open
└─ <hash>-<name>/ (由 mirror **本地 clone** 派生)
★ hash = H(项目+分支):**唯一性来源**(同仓库不同分支必不同目录)
★ name = 分支名派生:仅可读性
└─ 干活 → 提交 → push → PR
└─ 合入(或废弃)
└─ ★ **整个目录删除**(不保留、不归档)
```
**要点**:
- **合入后直接删目录** —— 用户明确要求;不删则下次同名分支会与残留目录混淆。
- **一个仓库可并行**:每分支一份独立 clone。
- **★ 同仓库不同分支必然不同目录**:`<hash>` 含分支(`H(项目+分支)`),hash 是**唯一性来源**;
`name` 只做可读性。实测:`feat/alpha` vs `feat/beta` → `b3426c01-feat_alpha` vs `0566c1f5-feat_beta`。
- **⚠ 慢操作必须异步**:内核把**每次工具调用**硬限制在 **60 秒**(`toolcall.go:62`,
硬编码无配置项)。`git clone --mirror` 同步做 ⇒ 工具报超时、**但 mirror 其实已建好**
—— 调用方只看到失败、状态却是“做了一半”。正确做法:工具只登记 + 发起后台作业,
用状态工具追进度(本插件:`commondev_job_status`)。
- **为什么不是 worktree / `--reference`**(实测):worktree 删后有 `.git/worktrees/`
残留(需 `prune`);`--reference` 有 alternates 外部依赖,mirror 被清即损坏。
**本地 clone 对象为硬链接** ⇒ 省磁盘、无外部依赖、删掉即彻底。
- **⚠️ 代价:唯一性闸必须补上**。worktree 天然禁止「同分支双挂」,独立 clone **没有**这条
保护(两份 clone 都能在 `feat/x` 上各自 push,直到被拒才知道)。
台账必须对「同名且未结(open/review)的分支」建唯一索引,重复 `branch_open`
**响亮拒绝**,不静默复用。
- **并行有前提**:并行的必须是**互不相干的模块**。有依赖的任务(A 改接口、B 用接口)
两棵树互不可见,问题会集中到 merge 时爆发 ⇒ **先拆边界再谈并行**。
- 与内核「子不得比父活得久、无残留」同一条纪律(见第 20 节)。
---
## Notes: 第三部分 hmap 插件开发与部署纪律
> 面向「要改 commondev」或「要写另一个插件」的场景。**所有条目都在本机实测过**,
> 不是从文档抄的。
### 32. 先搞清三件事,别猜
```bash
hmapdev version # 工具链版本 + SDK 模块 + 构建用 Go
hmapdev sdk current # 当前活跃 SDK 版本(★ 动手前必看)
hmapdev sdk list # 本机已装的所有 SDK 版本
```
- 核心仓 `/home/program/TrueAgent`(Go module `gitcode.com/JianFeeeee/HomeAgent`)
- **SDK 仓** `third_party/homeagent-sdk`(独立 git 仓、独立版本号)
### 33. 开工流程
```bash
hmapdev init <name> # Go 脚手架(--lua / --type remotedevice 可选)
cd <name> && vim plugin.go # 注册工具
hmapdev debug . # 不想装就先跑一遍
hmapdev build # 产出 dist/<name>_bundle.hmap
```
### 34. ★ 五个必踩的坑(按踩中概率排序)
| # | 坑 | 事实 |
|---|---|---|
| 1 | **SDK 版本** | 插件编译时**校验 SDK 能力**,用旧接口的工程直接构建失败,报错形如「当前 SDK 缺少 sdk.InjectOptions.Priority」,并给出两条出路:`hmapdev sdk install <version>` 或 `hmapdev sdk install --from <本地源码>`。**核心仓有新能力但 SDK 未跟上时用 `--from`** |
| 2 | **`hmapdev build`/`debug` 不支持 `--help`** | 不是打印帮助,而是去读当前目录的 `plg.json` ⇒ 你会看到 `error: read plg.json: no such file or directory`。**这不是故障**。要看 flag 用 `hmapdev --help` |
| 3 | **产物是 ZIP,不是二进制** | `dist/<name>_bundle.hmap` 是 ZIP(魔数 `PK`),用 `unzip` 解,内含 `plugin.bin.linux.amd64` 等多平台二进制。**生产要的是解包后的单个二进制** `/home/newqqagent/plugins/<name>/plugin.bin`(不是 zip) |
| 4 | **`plugin.json` 是安装标志** | 生产目录里没有它,插件不会被识别。`plg.json` 是**构建**标志,两者不同 |
| 5 | **`plg.json` 的 `sdk` 字段必须完整 x.y.z** | 写 `"1.4"` 会被**直接拒绝**(SDK 版本纪律:跟随内核中版本、patch 位恒为 `.0`,一条内核线只有一个 SDK 版本) |
### 35. 注册工具的正确形状
```go
s.RegisterTool(tp+"mytool", sdk.ToolDef{
Name: tp + "mytool", // ← Name 必填,放结构体**首位**
Description: "……",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Handler: h.mytool,
NoMemory: true, // 声明字段一律放**末尾**
}, s)
```
**并发声明**(同一批多个 tool_call 时):
- `ParallelSafe: true` —— 可并发。**三个条件都满足才可声明**:handler 线程安全 /
不与同批工具争抢同一资源(SQLite 写、同一设备、同一输出通道)/ 执行顺序无关。
- `Serial: true` —— 必须串行,**优先级高于 `ParallelSafe`**。
- **默认(都不写)= 整批串行**,是保守设计不是遗漏。
⇒ commondev 的**写操作**(凭据增删、台账写、`workdir_prepare`、`pr_open/merge`、
`agent_send`)**一律 `Serial: true`**;**只读**(list/status)才 `ParallelSafe`。
### 36. ★ 外部插件的能力边界(选外部形态就必须接受)
**公开 SDK 里声明的符号,不等于外部插件拿得到。** 外部插件跑在独立进程里,能力由桥接注入:
| API | 外部插件的实际 | 该用什么 |
|---|---|---|
| `Events()` | **恒为 nil**(桥不注入 subscriber) | 桥接运行时按你的声明完成订阅 |
| `UnregisterOutputChannel` | **静默无效**(只注入 registrar,不注入 unregistrar) | 注销需重载插件 |
| `PriorityL4` | **被夹到 L3** | 用 L1–L3 |
| `SocialAPI` 写操作 | 只有 6 个**只读**方法 | 读用 `GetPerson` 等 |
| `PluginMgr()` | **只有 3 个方法**(`ReloadOne`/`ListLoadedPlugins`/`IsPluginDisabled`) | 就这 3 个 |
| `resident_agents` 等内核工具 | **调不到**(proc 桥无对应方法) | 由 agent 侧调(见第 3 节) |
> **外部形态的 L4 天花板**:commondev 作为外部插件**永远拿不到 L4**。
> **⚠️ 不要用 `ToolAPI` 去调内核内置工具(如 `resident_agents`)**:`ToolAPI` 是
> **单例**,per-agent 数据靠「最后注入者」,而创建驻留子也会注入 ⇒ 你可能打到
> **某个驻留子的表上**。这是内核已自陈的局限,别踩。
**L4 归属表**(这块最容易搞错):
| agent | 它的"内核" | 谁能产生 L4 |
|---|---|---|
| 根 agent | 内核自身 + **内置**插件 | `raiseKernelInterrupt`、WebUI 终止按钮等 |
| 驻留子 | **父 agent** | 父(父→子消息固定 L4) |
| **外部插件(如 commondev)** | — | **永远不能**(被夹到 L3) |
⇒ 外部形态下,「立即取消整条分支」只能靠 **L3 打断主管 + 主管下令**。
### 37. 安装与验证
```bash
# ① 从 bundle 取出本机平台二进制
unzip -o dist/<name>_bundle.hmap 'plugin.bin.linux.amd64'
# ② 放进插件目录(<name> 必须与 plg.json 的 name 一致)
mkdir -p /home/newqqagent/plugins/<name>
cp plugin.json /home/newqqagent/plugins/<name>/
cp plugin.bin.linux.amd64 /home/newqqagent/plugins/<name>/plugin.bin
chmod +x /home/newqqagent/plugins/<name>/plugin.bin
# ③ 重启并确认工具**真的注册了**
systemctl restart homeagent.service
journalctl -u homeagent.service --since "-2 min" | grep "registering tool: <你的工具名>"
```
**内置 vs 独立**(目录存在 ≠ 有 `plugin.bin`):
```bash
for p in webui cmd seq qq; do
printf "%-8s " $p
ls /home/newqqagent/plugins/$p/plugin.bin >/dev/null 2>&1 \
&& echo "独立(要单独构建部署)" || echo "内置(随 homed 部署)"
done
```
★ **改了内置插件的源码 ⇒ 必须重新构建并部署 homed**,重启服务不生效(`//go:embed` 编进二进制)。
★ 判断线上跑的是哪次构建,看 `commit` 字段(**不要用 `strings`**):
```bash
K=$(sqlite3 /home/newqqagent/config.db "select value from config_webui where key='api_key';")
curl -s -H "X-API-Key: $K" http://127.0.0.1:8080/api/v1/status | grep -oE '"commit":"[^"]*"'
```
⚠ 必须带 `X-API-Key`:无认证时返回 **200 + 登录页 HTML**,只看状态码会误判。
### 38. 排查「插件装了却不生效」
按顺序查,**别跳步**:
```bash
ls /home/newqqagent/plugins/<name>/plugin.bin # 1) 内置还是独立
journalctl -u homeagent.service --since "-5 min" | grep "registering tool:" | grep -i <name> # 2) 注册了吗
journalctl -u homeagent.service --since "-5 min" | grep -E "loaded: <name>" # 3) 加载了吗
journalctl -u homeagent.service --since "-5 min" | grep -iE "<name>.*(error|panic|failed)" # 4) 启动报错
```
### 39. ★ 安全纪律(commondev 拿的是**能 push 用户所有仓库**的权限)
> **★ 架构决定(用户拍板 2026-10-14):敏感信息拦截下沉为内核安全模块。**
>
> 理由:**LLM 调用本身不可信**,而插件在**信任边界之外** ——
> 插件注册的 stage 拦截是“自愿的”,不是强制的,也算不上边界。
>
> ★ **它不是单纯拦截,而是一个闭环**(用户纠正:“只是简单的拦截的话,
> 绝大部分插件会失去原本的功能”):
>
> ```
> ① 拦截 LLM 出向前扫到明文 → 换成 @{别名}
> ② 加密暫存 明文 AES-GCM **落盘**存内核(不落明文)
> ③ 别名 ★ **哈希键**:sec_ + HMAC-SHA256(masterKey, plain)[:8]
> ⇒ **同一明文自动得同一个别名**(同变量合并,天然性质)
> ⇒ 必须 HMAC 而非裸哈希(裸哈希可字典枚举);**不加 salt**(否则失去合并)
> ④ 回填 插件调用前把 @{别名} 换回明文 ← 插件无感知
> ```
>
> ★ **变量不自动淘汰**(跨轮/跨子 agent 随时要用);agent 可**主动**调用
> 安全模块提供的**提交/删除**工具。泄漏风险靠主动删控制,不靠超时。
>
> ★ **因为内核零 IO(一切外部交互都从插件走),只需两根咽喉**:
> · 入向 toolcall:`StageHost.ExecuteTool(name, args)`(`core/stages.go:140`)
> · 入向 outputch:设备 `Execute`(`core/output.go:76`)
> · 出向 LLM:provider 层的 `Chat(req)`(已核:构造 `CompletionRequest` 有 **7 处**,
> task 层拦不全)
>
> ★ **变量系统是全局唯一的**(用户拍板):主 agent / 子 agent / 驻留子 agent
> **同一套**别名表。已核依据:驻留子共享父的 `ProviderManager`(`resident.go:185`),
> 而它**全进程只建一份**(`bootstrap.go:249`)⇒ 安全模块天然是进程级单例,
> **不需要跨 agent 同步**。
> ⚠ **变量共享 ≠ 授权共享**:能不能调某工具/通道仍看该 agent 自己的 `AllowedOutputs`
> (与 `KernelSource` 一道是 per-agent 的)。两者**正交**。
>
> ★ **“插件无感知”是硬要求**:替换发生在路由到插件的**之前**;
> 插件代码一行不改、不可分辨、无法绕过 ⇒ 存量插件自动获得该能力,
> 且无需给 SDK 加参数(不破接口兼容)。
>
> **对本插件的直接影响(大幅简化)**:插件可以**把密钥明文直接投给 LLM**,
> 由内核安全模块全权负责;`enc_*` 工具族与加密 shell 应**删减**,
> 不再自带加密库/别名表(前稿那套作废)。
>
> 详见 `docs/zh/kernel-secret-firewall-design.md`(含硬约束 S1–S9)。
>
> **两条不可让步的约束**:
> · **只能替代引用,永远不可取回明文**(用户明确)—— 读接口与用接口是两件事;
> · **模式库宁漏勿误伤** —— 误报会搞坏正常任务。
**凭据:按名字引用 + 变量填充**(用户明确要求的能力)
```
enc_store(alias="gitea-token", value="${GITEA_TOKEN}") ← ★ 引用,不是明文
enc_list ← 只列名字,**绝不返回值**
```
之后一切操作只带**别名**:`@{gitea-token}` —— 参数里永远不出现 secret。
详见 **第二部分 §13**(那里是权威)。
**为何要 `${ENV_VAR}` 变量填充**:(①)明文不落盘;(②)dev/prod 换环境变量即可;
(③)轮换凭据不需改插件数据。
#### ★★ 判据与复核:**插件 GREEN ≠ 改对了**
**这是本流水线最重要的一条纪律。** 插件只能跑你**预先写下**的判据;
判据没覆盖那条 bug,插件照样打勾。自动化**不会**发现假绿,
只会让假绿跑得更快、更可信。
**真实数据**(我用最小例子复刻过):
```go
func Max(a,b int) int { if a>b { return a }; return b }
```
- 判据只测 `Max(5,3) == 5`(漏了 `b>a`)
- 变异 `return b` → `return a`(**真 bug**)⇒ **判据仍全绿**
- 补一条 `Max(3,5) == 5` ⇒ 同一变异**立刻报红**
⇒ 所以**复核必须两步**,你定义判据时**必须同时给变异规则**:
```
commondev_test_define(
project="MyProj", name="clamp 上下界", kind="unit",
script="go test -count=1 ./...",
expect="上下界都被限制",
mutations=[{ id="M1", file="calc.go",
old=" if v > hi {\n return hi\n }\n", new="",
note="删掉上界分支(真 bug)——松判据看不见它" }]
)
commondev_review_submit(branch_id="br-...")
commondev_review_result() ← 取结论
```
**结论三档,注意第二档不是"通过"**:
| verdict | 含义 | 你该做什么 |
|---|---|---|
| `pass` | 正向全过 **且**全部变异被咬住 | 可以进下一步 |
| **`pass_with_gaps`** | 正向过,但**有变异没被咬住**或**判据没变异规则** | **不能当通过** —— 去补判据/变异,再复核 |
| `fail` | 有判据没过 | 打回执行 agent |
★ **`pass_with_gaps` 就是"你的判据有盲区"**。把它当绿色勾,
就是在给自己攒一次大的意外。
**定义变异规则的三个要点**:
1. **`old` 必须在文件里恰好出现一次** ——
出现 0 次 ⇒ 变异无效(却会被误报成"判据太松");
出现多次 ⇒ 可能改错地方(却会被误报成"咬住了")。
多出现时给出更长的上下文。
2. **变异要"真坏"** —— 改的是逻辑,不是格式。改不坏就验不出判据的松紧。
3. **`script` 里带 `-count=1`** —— Go 默认缓存测试结果;
命中缓存时测试**根本没跑**,"变异后报红"就无从谈起。
#### 双凭据与分支保护:**已移到第二部分**(技能纪律 §15–§16)
原来这里用 `commondev_account_add(role=...)` + `commondev_protect_branch` 两个
插件工具实现。**这些工具已移除**(平台知识不再进插件)。
现在这样做(命令见 **第二部分 §15**):
1. **在平台上建两个用户**:一个普通用户(执行侧,只能 push `feat/*`)、
一个有管理权限的(主管侧,能审批与合并)。各自签发 token。
2. **两个 token 都存进凭据库**:`enc_store(alias="gitea-exec", ...)` /
`enc_store(alias="gitea-sup", ...)`。
3. **给主干上锁**(第二部分 §15 的 curl 命令,四个开关都要)。
4. 审批与合并用主管侧别名(§16)。
★ **执行侧必须用普通用户 token** —— 用 admin token 会**直接绕过分支保护**,
机制失效且无任何报错。这是最容易搞错的一点。
★ **拦你的是平台,不是插件** —— 所以改插件代码也绕不过去。
这才是「机制」与「流程约定」的区别。
**两条不可逆的红线**:
1. **变量解析不到值 ⇒ 响亮报错**,绝不静默当空串 —— 后者会退化成匿名访问,
表现成 **401** 而非“凭据没配”,极难排查。
2. **secret 绝不进返回值/日志**。
**平台形状差异**(不能一个认证头走天下):
| 平台 | API 前缀 | 认证头 |
|---|---|---|
| Gitea | `<base>/api/v1` | `Authorization: token <T>` ← **不是 Bearer** |
| GitHub | `https://api.github.com` | `Authorization: Bearer <T>` |
| GitCode | `https://api.gitcode.com/api/v5` | `Authorization: Bearer <T>` |
未知平台**必须报错**(不猜形状)。
**其他:**
1. **凭据不入 `config.db`**:现有惯例是明文存 `config_<plugin>` 表,而 `config.db` 是
**644 全局可读**。commondev 的凭据性质完全不同 ⇒ 放独立文件
`<DataDir>/credentials.json`,**0600,启动时校验权限,不对就拒绝启动**
(本仓纪律:响亮失败,不静默降级)。
2. **优先 SSH key 而非 PAT**;每平台独立、最小授权,**不要一个 token 通吃**。
3. **token 不进 remote URL**:拼接仅用于**单次命令**(`pushURLWithToken`),
clone 出来的 remote 不带凭据 —— 否则删掉临时目录也删不掉凭据。
4. **不要硬编码 token**:走 `Settings()` 或宿主注入。
5. **拉起 pi 时隔离端口**:内层 pi 会尝试监听 A2A/ACP 端口并打 `EADDRINUSE`,
显式设 `PI_CODING_AGENT_SESSION_DIR` / `PI_A2A_PORT` / `PI_ACP_PORT`。
### 40. ★ 孤儿清理(插件有自己的硬约束)
内核对自己的驻留子有硬约束:**子不得比父活得久 / 无孤儿 goroutine / 无残留 temp**
(`resident-subagent-design.md` §10,写成判据)。
**插件对自己拉起的进程必须同样硬**:`Stop()` 必须是**幂等的全清** ——
pi 进程、sidecar、ssh 会话、workdir(含凭据残留检查)一个不留。
写成判据:`TestStopKillsAllRuntimes`(变异:删掉某个清理 → 必须报红)。
### 41. 参考资料(**别凭记忆写 API**)
| 内容 | 路径 |
|---|---|
| SDK 源码 | `third_party/homeagent-sdk/sdk/` |
| **可运行的示例插件** | `third_party/homeagent-sdk/example/`(a2a / acp / qq / plugindev…) |
| 能力边界 | `third_party/homeagent-sdk/docs/guide/capability-boundary.md` |
| 并发声明 | `third_party/homeagent-sdk/docs/guide/parallel-tool-declaration.md` |
| 部署手册 | `docs/zh/deploy-runbook.md` |
| sidecar 参考实现 | `internal/plugins/clawhubadapter/sidecar.go` |
★ **写插件前先翻 `example/`**:那里的代码是**编译通过**的,比文档可靠。
**文档与示例冲突时以示例为准。** 注意 `example/` 是**外部插件**,
能被它们编译通过的写法,外部就一定可用。
---
## Notes: 红牌(做了就是错)
1. **不划通道就当派了活** —— 登记 ≠ 划入;不划则所有输入涌向根 agent。
2. **绕过插件直接敲 `pi -p`** —— 漏 `--session-id` 会静默开新会话。
3. **并发投同一个 session** —— 会写坏会话(实测)。
4. **没有变异阶段的复核** —— 等于给假绿充值。
5. **凭据写进 `config.db` 或 remote URL**。
6. **改了内置插件源码只重启服务** —— 必须重编 homed。
7. **手改生成的文档**(`docs/api/*.md` 首行写着「请勿手改」)—— 改 `sdk/*.go` 注释再重新生成。
8. **为了让插件生效去改 homed 的源码** —— 先确认它是不是内置插件。

View File

@ -0,0 +1,6 @@
{
"name": "commondev-discipline",
"description": "commondev 主管式多仓库开发流水线的使用与开发纪律:为每个工程对话新建驻留子并划入其通道、投递必须落同一 session、复核必须含变异阶段;以及 hmap 插件开发纪律(plg.json、SDK 版本、产物形态、Serial/ParallelSafe、外部插件能力边界、凭据与孤儿清理)",
"version": "1.0.0",
"author": "HomeAgent"
}

View File

@ -0,0 +1,252 @@
package main
// SSH 传输的**真端到端**判据。
//
// 前面的 ssh_wiring_test.go 只验证"参数被正确接线"(台账里记的是 ssh)。
// 这里验证**链路真的能跑**:真起 ssh 进程、真把脚本推到远端、真握手、
// 真投递一次 prompt。
//
// 用 localhost(本机已配免密)作为"远端"——它同样是完整的 ssh 路径:
// 走 ssh 客户端、BatchMode、scp、远端 node 执行。与真远端只差网络延迟。
//
// 默认跳过(需要 sshd + 免密),设 HA_SSH_E2E=1 运行。
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// mapSettings 是能**真正存取值**的 settings 替身。
//
// stubSettings 的 Get 恒返回 nil,无法表达"用户配置了 ssh_runtime_root"。
// 这里用 map 实现 Get/Set 的往返,使判据能覆盖配置驱动的那条路径。
type mapSettings struct {
stubSettings
vals map[string]interface{}
}
func newMapSettings(dir string) *mapSettings {
return &mapSettings{stubSettings: stubSettings{dir: dir}, vals: map[string]interface{}{}}
}
func (m *mapSettings) Get(key string) (interface{}, error) {
if v, ok := m.vals[key]; ok {
return v, nil
}
return nil, nil
}
func (m *mapSettings) Set(key string, v interface{}) error {
m.vals[key] = v
return nil
}
// testSetSetting 把插件 settings 换成可存取版本并写一个键。
func (p *Plugin) testSetSetting(key string, v interface{}) {
var ms *mapSettings
if cur, ok := p.sdk.Settings().(*mapSettings); ok {
ms = cur
} else {
ms = newMapSettings(p.sdk.Settings().DataDir())
}
_ = ms.Set(key, v)
if _, ok := p.sdk.Settings().(*mapSettings); !ok {
// 重新装配 SDK:Settings 是不可变注入的,必须整体替换
p.sdk = sdk.New("commondev", ms, nil, nil, nil, nil)
}
}
// sshE2EReady 检查真 e2e 的前置条件。
func sshE2EReady(t *testing.T) {
t.Helper()
if os.Getenv("HA_SSH_E2E") != "1" {
t.Skip("设 HA_SSH_E2E=1 以运行(需要 sshd + localhost 免密)")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"localhost", "node --version")
out, err := cmd.CombinedOutput()
if err != nil {
t.Skipf("localhost 免密或 node 不可用: %v: %s", err, out)
}
if !strings.HasPrefix(strings.TrimSpace(string(out)), "v") {
t.Skipf("远端 node 输出异常: %q", out)
}
}
// setupSSHPlugin 造一个能跑 ssh 路径的 Plugin,并把真实 sidecar 放进 driver 目录。
func setupSSHPlugin(t *testing.T, remoteRoot string) *Plugin {
t.Helper()
p := newTestPluginForShell(t)
// 把真实的 sidecar.mjs 放进 runtime/pi/
cliDir := p.driverDir("pi")
if err := os.MkdirAll(cliDir, 0o755); err != nil {
t.Fatal(err)
}
src, err := os.ReadFile("sidecar.mjs")
if err != nil {
t.Skipf("读不到 sidecar.mjs: %v", err)
}
if err := os.WriteFile(filepath.Join(cliDir, "sidecar.mjs"), src, 0o644); err != nil {
t.Fatal(err)
}
p.ledger = &Ledger{
Projects: map[string]*Project{}, Branches: map[string]*Branch{},
Jobs: map[string]*Job{}, Criteria: map[string]*Criteria{},
}
return p
}
// ★★★ 判据:SSH 链路真的能握手(probe + push + ping)。
//
// 这条覆盖的是"ssh 路径能不能跑通",而不是"参数有没有记对"。
func TestSSHEndToEndHandshake(t *testing.T) {
sshE2EReady(t)
remoteRoot := t.TempDir()
p := setupSSHPlugin(t, remoteRoot)
// 配置远端运行时目录与 node 路径
p.testSetSetting("ssh_runtime_root", remoteRoot)
p.testSetSetting("ssh_node_path", "node")
b := &Branch{
ID: "br-e2e-ssh", Name: "feat/e2e", ProjectID: "proj",
Workdir: t.TempDir(), Runtime: "ssh", SSHHost: "localhost",
}
ctx := driverCtx{
Workdir: b.Workdir, SessionID: "sess-e2e", SessionName: "br-e2e",
DataDir: filepath.Dir(p.path), PluginDir: p.driverDir("pi"),
}
drv, _, err := p.loadDriver("pi")
if err != nil {
t.Fatal(err)
}
sc, err := p.startSSHSidecar(b, drv, ctx, "pi")
if err != nil {
t.Fatalf("★ SSH sidecar 启动/握手失败: %v", err)
}
defer sc.Close()
// 握手成功即证明了:probeSSH(免密)→ pushSidecar(scp)→ ssh 起进程 → ping
if !sc.Alive() {
t.Fatal("★ 握手后进程不存活")
}
// 真投一次 prompt(覆盖"投到指定 session"在远端同样成立)
res, err := sc.call("prompt", map[string]interface{}{
"session_id": "sess-e2e", "text": "只回一个词:ok",
})
if err != nil {
t.Fatalf("★ 远端投递失败: %v", err)
}
m, _ := res.(map[string]interface{})
// 断言 session 未漂移(deliverToPi 的核心不变量)
if got, _ := m["session_id"].(string); got != "" && got != "sess-e2e" {
t.Errorf("★ 远端会话漂移: %q", got)
}
t.Logf("SSH e2e 握手与投递成功: %v", m)
}
// ★★ 判据:**远端脚本被真的推过去了**(否则跑的是上一次的旧版)。
func TestSSHPushesScriptToRemote(t *testing.T) {
sshE2EReady(t)
remoteRoot := t.TempDir()
defer os.RemoveAll(remoteRoot)
p := setupSSHPlugin(t, remoteRoot)
p.testSetSetting("ssh_runtime_root", remoteRoot)
p.testSetSetting("ssh_node_path", "node")
b := &Branch{ID: "br-push", Name: "f", ProjectID: "proj",
Workdir: t.TempDir(), Runtime: "ssh", SSHHost: "localhost"}
ctx := driverCtx{Workdir: b.Workdir, SessionID: "s", SessionName: "n",
DataDir: filepath.Dir(p.path), PluginDir: p.driverDir("pi")}
drv, dir, err := p.loadDriver("pi")
if err != nil {
t.Fatal(err)
}
tgt, err := parseSSHTarget(b)
if err != nil {
t.Fatal(err)
}
if err := p.probeSSH(tgt); err != nil {
t.Skipf("probe 失败: %v", err)
}
if err := p.pushSidecar(tgt, drv, dir, remoteRoot); err != nil {
t.Fatalf("★ 推送脚本失败: %v", err)
}
// 本地与远端内容必须一致(否则"改了没生效")
local, _ := os.ReadFile(filepath.Join(dir, drv.Script))
remote, err := os.ReadFile(filepath.Join(remoteRoot, drv.Script))
if err != nil {
t.Fatalf("★ 远端没有脚本: %v", err)
}
if string(local) != string(remote) {
t.Errorf("★ 远端脚本与本地不一致(本地 %d 字节,远端 %d 字节)",
len(local), len(remote))
}
_ = ctx
}
// ★★ 判据:远端缺 node → **响亮报错**(不是 10s 后含糊的"握手失败")。
func TestSSHProbeFailsLoudlyWhenNodeMissing(t *testing.T) {
sshE2EReady(t)
p := setupSSHPlugin(t, t.TempDir())
b := &Branch{ID: "br-nonode", Name: "f", Runtime: "ssh", SSHHost: "localhost"}
tgt, err := parseSSHTarget(b)
if err != nil {
t.Fatal(err)
}
// 用一个必然不存在的命令名做 node → probe 必须报"输出异常/不可达"
tgt.NodePath = "/nonexistent/node-binary-xyz"
err = p.probeSSH(tgt)
if err == nil {
t.Fatal("★ 远端缺 node 时应报错")
}
if !strings.Contains(err.Error(), "远端") {
t.Errorf("错误应指明是远端问题: %v", err)
}
}
// ★★ 判据:**没有免密时响亮失败**,且提示"不交互输密码"。
func TestSSHWithoutPasswordlessFailsLoudly(t *testing.T) {
sshE2EReady(t)
p := setupSSHPlugin(t, t.TempDir())
b := &Branch{ID: "br-nopw", Name: "f", Runtime: "ssh",
SSHHost: "nonexistent-host-xyz.invalid"}
tgt, err := parseSSHTarget(b)
if err != nil {
t.Fatal(err)
}
err = p.probeSSH(tgt)
if err == nil {
t.Fatal("★ 不可达主机应报错")
}
if !strings.Contains(err.Error(), "免密") {
t.Errorf("错误应提示配免密: %v", err)
}
}
// ★ 判据:BatchMode 必须开启(否则会卡在交互式密码提示上)。
func TestSSHUsesBatchMode(t *testing.T) {
b := &Branch{ID: "b", Name: "f", Runtime: "ssh", SSHHost: "u@h"}
tgt, err := parseSSHTarget(b)
if err != nil {
t.Fatal(err)
}
args := strings.Join(tgt.sshBaseArgs(), " ")
if !strings.Contains(args, "BatchMode=yes") {
t.Errorf("★ 缺少 BatchMode=yes(会卡在密码提示): %v", args)
}
if !strings.Contains(args, "ConnectTimeout") {
t.Errorf("★ 缺少 ConnectTimeout: %v", args)
}
}

View File

@ -0,0 +1,808 @@
package main
// SSH 传输与"接入其他 CLI"的接线判据。
//
// 背景(实测发现的缺口):`sidecar_ssh.go` 的实现是完整的,
// `startSidecar` 里的 `if b.Runtime == "ssh"` 分派也是接好的,
// 但 `branch_open` **硬编码 `Runtime: "local"`** 且从不读 `ssh_host` ——
// 于是 `runtime=ssh` 被**静默吞掉**:用户以为跑在远端,实际跑在本地,
// 且没有任何提示。`runtimeOrDefault` 当时更是**从未被调用**的死代码。
//
// 这类"参数被吞"比报错危险得多:报错会立刻发现,吞掉会一路错下去。
import (
"bufio"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
// ★★★ 判据:`runtime=ssh` 被真正记进台账(不再被吞)。
func TestSSHRuntimeIsPersistedToLedger(t *testing.T) {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
r, _ := p.handleBranchOpen(map[string]interface{}{
"project": "proj", "branch": "feat/remote",
"runtime": "ssh", "ssh_host": "build@10.0.0.9",
})
m := r.(map[string]interface{})
if _, isErr := m["error"]; isErr {
t.Fatalf("开远端分支不应报错: %v", m)
}
id := m["branch_id"].(string)
b := p.ledger.Branches[id]
if b == nil {
t.Fatal("台账里没有这条分支")
}
if b.Runtime != "ssh" {
t.Errorf("★ runtime 未被记录: got %q, want \"ssh\"(参数被吞)", b.Runtime)
}
if b.SSHHost != "build@10.0.0.9" {
t.Errorf("★ ssh_host 未被记录: got %q", b.SSHHost)
}
}
// ★★ 判据:`runtime=ssh` 但没给 ssh_host → **响亮拒绝**。
//
// 不拒的话 startSSHSidecar 会在 parseSSHTarget 里失败,
// 但那是"拉起时",到那时用户已经等了一轮;且错误离原因更远。
func TestSSHWithoutHostIsRejected(t *testing.T) {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
r, _ := p.handleBranchOpen(map[string]interface{}{
"project": "proj", "branch": "feat/nohost", "runtime": "ssh",
})
m := r.(map[string]interface{})
e, isErr := m["error"]
if !isErr {
t.Fatal("★ runtime=ssh 缺 ssh_host 应被拒绝")
}
if !strings.Contains(e.(string), "ssh_host") {
t.Errorf("错误应指明缺 ssh_host: %v", e)
}
}
// ★★★ 判据:**未知 runtime 响亮拒绝,不静默降级为 local**。
//
// 这是本仓的核心纪律:静默降级会让"我以为跑在远端"的假设
// 一路错下去,而症状(任务跑在本地、文件在本地被改)非常难归因。
func TestUnknownRuntimeFailsLoudly(t *testing.T) {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
r, _ := p.handleBranchOpen(map[string]interface{}{
"project": "proj", "branch": "feat/bad", "runtime": "k8s",
})
m := r.(map[string]interface{})
e, isErr := m["error"]
if !isErr {
t.Fatal("★ 未知 runtime 应被拒绝(不静默降级)")
}
if !strings.Contains(e.(string), "k8s") {
t.Errorf("错误应回显收到的值: %v", e)
}
// 不得在台账里留下 half-open 的条目
if len(p.ledger.Branches) != 0 {
t.Errorf("★ 被拒的分支不应进台账,实际 %d 条", len(p.ledger.Branches))
}
}
// ★★ 判据:默认(不给 runtime)仍是 local —— 老调用方不受影响。
func TestRuntimeDefaultsToLocal(t *testing.T) {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/d"})
id := r.(map[string]interface{})["branch_id"].(string)
if got := p.ledger.Branches[id].Runtime; got != "local" {
t.Errorf("默认应为 local,实际 %q", got)
}
}
// ★ 判据:大小写与空白被规范化('SSH' / ' ssh ' 都认)。
func TestRuntimeIsNormalized(t *testing.T) {
for _, in := range []string{"SSH", " ssh ", "Ssh"} {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
r, _ := p.handleBranchOpen(map[string]interface{}{
"project": "proj", "branch": "feat/n", "runtime": in, "ssh_host": "u@h",
})
m := r.(map[string]interface{})
if _, isErr := m["error"]; isErr {
t.Errorf("runtime=%q 应被识别为 ssh: %v", in, m)
continue
}
id := m["branch_id"].(string)
if got := p.ledger.Branches[id].Runtime; got != "ssh" {
t.Errorf("runtime=%q 规范化后应为 ssh,实际 %q", in, got)
}
}
}
// ★★★ 判据:**接入新 CLI 不需要改插件核心**。
//
// 这是 sidecar driver 层的立身之本:用户放一个
// `<插件数据>/runtime/<cli>/driver.json` + 脚本即可新增后端。
// 判据必须在**不碰任何 Go 源码**的前提下把一个假 CLI 跑起来。
func TestAddingNewCLIRequiresNoCoreChange(t *testing.T) {
p := newTestPluginForShell(t)
// 造一个假 CLI 的 driver 目录
cliDir := p.driverDir("fakecli")
if err := os.MkdirAll(cliDir, 0o755); err != nil {
t.Fatal(err)
}
// 假 CLI 的 sidecar:实现最小契约(ping/prompt/state)
script := `#!/usr/bin/env node
// 极简 sidecar:只支持 ping / prompt
const rl=require('readline').createInterface({input:process.stdin});
rl.on('line',l=>{let q;try{q=JSON.parse(l)}catch(e){return}
if(q.method==='ping'){process.stdout.write(JSON.stringify({id:q.id,result:{ok:true,session_id:null,cli_version:'fake-9.9'}})+'\n')}
else if(q.method==='prompt'){process.stdout.write(JSON.stringify({id:q.id,result:{ok:true,session_id:q.params.session_id,reply:'fake'}})+'\n')}
else {process.stdout.write(JSON.stringify({id:q.id,result:{ok:true}})+'\n')}});
`
if err := os.WriteFile(filepath.Join(cliDir, "sidecar.mjs"), []byte(script), 0o644); err != nil {
t.Fatal(err)
}
drv := `{"name":"fakecli","script":"sidecar.mjs","node":"auto","llmConfig":"none",
"sessionDirSuffix":".fake-sessions","args":["--session-id","{sessionId}"]}`
if err := os.WriteFile(filepath.Join(cliDir, "driver.json"), []byte(drv), 0o644); err != nil {
t.Fatal(err)
}
// ① loadDriver 必须能读到它(不报"驱动不存在")
d, dir, err := p.loadDriver("fakecli")
if err != nil {
t.Fatalf("★ 新增 CLI 的 driver 未被识别: %v", err)
}
if d.Name != "fakecli" {
t.Errorf("驱动名不对: %q", d.Name)
}
if !strings.HasSuffix(dir, filepath.Join("runtime", "fakecli")) {
t.Errorf("驱动目录不对: %s", dir)
}
// ② LLMConfig=none 必须透传(fake CLI 自管凭据,插件不去写 pi 配置)
if d.LLMConfig != "none" {
t.Errorf("★ llmConfig 未透传: %q(会导致去改 pi 的配置)", d.LLMConfig)
}
// ③ 占位符按该 driver 的声明展开
ctx := driverCtx{SessionID: "sess-1", Workdir: "/w", SessionName: "n", DataDir: "/d", PluginDir: "/p"}
args := d.buildArgs(ctx)
if len(args) != 2 || args[1] != "sess-1" {
t.Errorf("占位符未按该 driver 展开: %v", args)
}
}
// ★★ 判据:**内置 pi 在没有 driver.json 时仍可用**(老部署不受影响)。
//
// 这条是"新增 driver 层不破坏既有行为"的保证。
func TestBuiltinPiStillWorksWithoutDriverJSON(t *testing.T) {
p := newTestPluginForShell(t)
d, _, err := p.loadDriver("pi")
if err != nil {
t.Fatalf("内置 pi 不应需要 driver.json: %v", err)
}
if d.LLMConfig != "pi" {
t.Errorf("内置 pi 的 llmConfig 应为 pi,实际 %q", d.LLMConfig)
}
if len(d.Args) == 0 {
t.Error("内置 pi 应带默认 args")
}
}
// ★★ 判据:未知 CLI **不静默回退到 pi**。
func TestUnknownCLIDoesNotFallBackToPi(t *testing.T) {
p := newTestPluginForShell(t)
_, _, err := p.loadDriver("nosuchcli")
if err == nil {
t.Fatal("★ 未知 CLI 应报错(静默回退会用错后端跑任务)")
}
if !strings.Contains(err.Error(), "不静默回退") {
t.Errorf("错误应说明不回退: %v", err)
}
}
// ★★ 判据:`agent_state` 必须回显 ssh_host(远端线要能看出打到哪台机)。
//
// 不回显的话远端线与本地线状态一模一样,排查时分不清任务跑在哪。
func TestAgentStateShowsSSHHost(t *testing.T) {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
r, _ := p.handleBranchOpen(map[string]interface{}{
"project": "proj", "branch": "feat/r", "runtime": "ssh", "ssh_host": "build@10.0.0.9",
})
id := r.(map[string]interface{})["branch_id"].(string)
st, _ := p.handleAgentState(map[string]interface{}{"branch_id": id})
m := st.(map[string]interface{})
if m["runtime"] != "ssh" {
t.Errorf("runtime 未回显: %v", m["runtime"])
}
if m["ssh_host"] != "build@10.0.0.9" {
t.Errorf("★ ssh_host 未回显(远端线看不出打到哪台机): %v", m["ssh_host"])
}
// 本地线不应出现 ssh_host
r2, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/l"})
id2 := r2.(map[string]interface{})["branch_id"].(string)
st2, _ := p.handleAgentState(map[string]interface{}{"branch_id": id2})
if _, has := st2.(map[string]interface{})["ssh_host"]; has {
t.Error("本地线不应回显 ssh_host")
}
}
// testRawFiles 是 RawFilesAPI 的最小替身(判据用)。
//
// 直接实现接口而不是复用内核实现:内核在 internal/,插件(外部)不能 import;
// 且这里只需 Exists/Dir 两个方法。
type testRawFiles struct{ root string }
func (t *testRawFiles) Read(rel string) ([]byte, error) {
return os.ReadFile(filepath.Join(t.root, rel))
}
func (t *testRawFiles) List(rel string) ([]string, error) {
es, err := os.ReadDir(filepath.Join(t.root, rel))
if err != nil {
return nil, err
}
out := make([]string, 0, len(es))
for _, e := range es {
n := e.Name()
if e.IsDir() {
n += "/"
}
out = append(out, n)
}
return out, nil
}
func (t *testRawFiles) Exists(rel string) bool {
st, err := os.Stat(filepath.Join(t.root, rel))
return err == nil && !st.IsDir()
}
func (t *testRawFiles) Dir() string { return t.root }
func (t *testRawFiles) WorkDir() string {
d := filepath.Join(filepath.Dir(t.root), "work")
os.MkdirAll(d, 0o755)
return d
}
// ★★★ 判据:**sidecar 脚本必须随包分发**,且查找链能找到它。
//
// 实测发现的真缺陷(2026-10-15):打包只收 `rawfile/` 与 `skill/`,
// 而 `sidecar.mjs` 在**工程根** ⇒ 没进包。同时 `runtime/pi/` 在部署时
// 没人创建 ⇒ 插件的数据目录里也没有脚本。
//
// 后果:插件装得上、工具都在、但 `agent_up` **必然失败**,报
// 「找不到 pi 的 sidecar 脚本: /home/xxx/plugin_data/commondev/runtime/pi/sidecar.mjs」
// —— 一个用户从没听说过的路径。属于"能装不能跑"。
func TestSidecarScriptShipsWithPackage(t *testing.T) {
// ① 脚本必须在 rawfile/ 下(随包分发通道)
rel := filepath.Join("rawfile", "runtime", "pi", "sidecar.mjs")
if _, err := os.Stat(rel); err != nil {
t.Fatalf("★ sidecar 脚本未随包分发(应放在 %s): %v", rel, err)
}
// ② 内容必须与工程根的权威副本一致(防止改了根那份忘了同步)
root, err := os.ReadFile("sidecar.mjs")
if err != nil {
t.Skipf("读不到根 sidecar.mjs: %v", err)
}
shipped, err := os.ReadFile(rel)
if err != nil {
t.Fatal(err)
}
if string(root) != string(shipped) {
t.Error("★ rawfile/ 里的 sidecar.mjs 与工程根不一致(改了根那份没同步?)")
}
}
// ★★★ 判据:**查找链能找到随包脚本**(这是 agent_up 能跑的前提)。
//
// 直接调 locateSidecarScript —— 判据必须测真实入口,不能只检查文件存在。
func TestLocateSidecarScriptFindsShippedCopy(t *testing.T) {
p := newFlowPlugin(t)
// 让 rawFiles() 指向工程目录(模拟解包后的插件目录)
root, _ := os.Getwd()
p.sdk.SetRawFilesAPI(&testRawFiles{root: filepath.Join(root, "rawfile")})
drv := builtinPiDriver()
// cliDir 指向一个**不存在**的目录(模拟全新部署没人建 runtime/pi)
cliDir := filepath.Join(t.TempDir(), "runtime", "pi")
got, err := p.locateSidecarScript(drv, cliDir, "pi")
if err != nil {
t.Fatalf("★ 找不到随包的 sidecar 脚本(agent_up 会失败): %v", err)
}
if !strings.HasSuffix(got, filepath.Join("runtime", "pi", "sidecar.mjs")) {
t.Errorf("找到的路径不对: %s", got)
}
if _, err := os.Stat(got); err != nil {
t.Errorf("★ 返回的路径不可读: %v", err)
}
}
// ★★ 判据:**用户自备的脚本优先**于随包副本(允许覆盖)。
func TestUserProvidedSidecarWins(t *testing.T) {
p := newFlowPlugin(t)
root, _ := os.Getwd()
p.sdk.SetRawFilesAPI(&testRawFiles{root: filepath.Join(root, "rawfile")})
// 造一个用户自己的 runtime/pi/sidecar.mjs
cliDir := filepath.Join(t.TempDir(), "runtime", "pi")
if err := os.MkdirAll(cliDir, 0o755); err != nil {
t.Fatal(err)
}
mine := filepath.Join(cliDir, "sidecar.mjs")
if err := os.WriteFile(mine, []byte("// 我的版本"), 0o644); err != nil {
t.Fatal(err)
}
got, err := p.locateSidecarScript(builtinPiDriver(), cliDir, "pi")
if err != nil {
t.Fatal(err)
}
if got != mine {
t.Errorf("★ 用户自备脚本应优先,实际用了 %s", got)
}
}
// ★★ 判据:全部落空时,错误必须**列出找过哪里**(否则用户只能猜)。
func TestMissingSidecarErrorIsActionable(t *testing.T) {
p := newFlowPlugin(t) // 无 rawFiles
cliDir := filepath.Join(t.TempDir(), "runtime", "nope")
_, err := p.locateSidecarScript(driverSpec{Script: "sidecar.mjs"}, cliDir, "nope")
if err == nil {
t.Fatal("找不到脚本应报错")
}
msg := err.Error()
for _, want := range []string{"rawfile", "runtime", "可执行文件"} {
if !strings.Contains(msg, want) {
t.Errorf("★ 错误信息应说明找过 %q: %s", want, msg)
}
}
// 必须告诉用户**怎么办**
if !strings.Contains(msg, "放到") {
t.Errorf("★ 错误信息应给出解决办法: %s", msg)
}
}
// ★★★ 判据:zcode sidecar 的 **CLI 路径必须可声明**(本地与远端各一套)。
//
// 实测缺口(2026-10-16):zcode 的 sidecar 只在三个固定位置碰运气找 CLI,
// 且 driver.json 里**没有**声明路径的字段 ⇒ 本机装法不同就必然
// 「找不到 ZCode CLI」,用户只能记得设环境变量。SSH 场景更糟:
// 本地与远端的路径本来就不同,而当时只有一个全局 env 可用。
func TestZcodeCLIPathIsDeclarable(t *testing.T) {
// ① driver.json 必须声明 cliPath
b, err := os.ReadFile("rawfile/runtime/zcode/driver.json")
if err != nil {
t.Fatalf("zcode driver.json 不存在: %v", err)
}
var d driverSpec
if err := json.Unmarshal(b, &d); err != nil {
t.Fatalf("driver.json 解析失败: %v", err)
}
if strings.TrimSpace(d.CLIPath) == "" {
t.Fatal("★ driver.json 未声明 cliPath(用户只能靠环境变量,且 SSH 无法分别指定)")
}
if !strings.Contains(d.CLIPath, "{rawfile}") {
t.Errorf("cliPath 应支持随包分发(含 {rawfile} 占位符): %s", d.CLIPath)
}
// ② {rawfile} 占位符必须能被展开
ctx := driverCtx{
Workdir: "/w", SessionID: "s", SessionName: "n",
DataDir: "/d", PluginDir: "/p", RawfileDir: "/rf",
}
args := d.buildArgs(ctx)
joined := strings.Join(args, " ")
if !strings.Contains(joined, "--cli /rf/zcode/zcode.cjs") {
t.Errorf("★ cliPath 未展开成 --cli 参数: %v", args)
}
}
// ★★★ 判据:**首轮不得带 --resume**(zcode 真实会话 id 首轮才产生)。
//
// 实测真缺陷(2026-10-16):sidecar 把插件传来的 `--session-id`
// (形如 `br-<project>-<branch>`,是**插件自己的标签**)直接当作
// `--resume` 的目标,于是首轮就执行 `--resume br-xxx`,zcode 报
// `Session not found: br-xxx` ⇒ **第一次投递必定失败**。
//
// 契约(参考实现与本文件注释都写明):首轮不传 --resume,
// 从 `{type:"result"}` 行取回 `sess_...`,之后才用它续谈。
func TestZcodeSidecarDoesNotResumeOnFirstTurn(t *testing.T) {
src, err := os.ReadFile("rawfile/runtime/zcode/sidecar.mjs")
if err != nil {
t.Skipf("读不到 zcode sidecar: %v", err)
}
s := string(src)
// 必须存在区分「插件标签」与「真实会话 id」的两个变量。
if !strings.Contains(s, "desiredSessionId") || !strings.Contains(s, "zcodeSessionId") {
t.Fatal("★ sidecar 未区分插件标签与 zcode 真实会话 id(首轮会错误 resume)")
}
}
// ★★★ **行为判据**:用假 CLI 真跑 sidecar,检查它实际拼出的 argv。
//
// 为什么不能只看源码字符串(本会话第 7 次同类教训):最初的判据 ④ 匹配的是
// `if (params?.session_id) sessionId = String(params.session_id)` 这个**字面串**,
// 把变量改名为 zcodeSessionId 就轻松绕过 —— 判据全绿而缺陷照样存在。
//
// 实测真缺陷(2026-10-16):sidecar 把插件的标签 `br-xxx` 直接当 --resume 目标,
// 首轮即 `Session not found: br-xxx`,第一次投递必定失败。
//
// 这里用一个**假 CLI**记录收到的 argv 并返回合法的 result 行,从而断言真实行为:
//
// · 首轮 argv **不含** --resume,且 --cli 指向真 CLI
// · 第二轮(把首轮返回的 sess_... 传回)**必须**含 --resume sess_...
func TestZcodeSidecarArgvBehavior(t *testing.T) {
node := findNodeForTest(t)
dir := t.TempDir()
rec := filepath.Join(dir, "argv.txt")
// 假 CLI:记录 argv,输出 sidecar 期望的 result 行。
fake := filepath.Join(dir, "fake-zcode.cjs")
if err := os.WriteFile(fake, []byte(`const fs=require("fs");
const out=process.env.FAKE_ARGV_FILE;
fs.appendFileSync(out, JSON.stringify(process.argv.slice(2))+"\n");
const i=process.argv.indexOf("--resume");
const sid = i>=0 ? process.argv[i+1] : "sess_FIRSTTURN";
process.stdout.write(JSON.stringify({type:"result",sessionId:sid,response:"ok"})+"\n");
`), 0o644); err != nil {
t.Fatal(err)
}
// ★ 必须绝对路径:下面把 cmd.Dir 设成 work,相对路径会解析到 work 下。
sidecar, err := filepath.Abs(filepath.Join("rawfile", "runtime", "zcode", "sidecar.mjs"))
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(sidecar); err != nil {
t.Skipf("sidecar 不存在: %v", err)
}
work := filepath.Join(dir, "work")
if err := os.MkdirAll(work, 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(node, sidecar,
"--workdir", work,
"--session-id", "br-demo-label",
"--mode", "guarded",
"--cli", fake)
cmd.Dir = work
cmd.Env = append(os.Environ(), "FAKE_ARGV_FILE="+rec)
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = cmd.Process.Kill(); _, _ = cmd.Process.Wait() }()
mustWrite := func(v any) {
b, _ := json.Marshal(v)
if _, err := stdin.Write(append(b, '\n')); err != nil {
t.Fatalf("写 stdin 失败: %v", err)
}
}
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 1<<20), 1<<20)
readReply := func(id int) map[string]any {
for sc.Scan() {
var m map[string]any
if json.Unmarshal(sc.Bytes(), &m) != nil {
continue
}
if int(m["id"].(float64)) == id {
return m
}
}
t.Fatalf("等 id=%d 的回复超时", id)
return nil
}
// 首轮
mustWrite(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "prompt",
"params": map[string]any{"session_id": "br-demo-label", "text": "hi"}})
r1 := readReply(1)
if r1["error"] != nil {
t.Fatalf("首轮失败: %v", r1["error"])
}
res1, _ := r1["result"].(map[string]any)
sid, _ := res1["session_id"].(string)
if !strings.HasPrefix(sid, "sess_") {
t.Fatalf("★ 首轮未回报真实 sess_ id: %q", sid)
}
// 第二轮:把真实 id 传回 → 必须 --resume
mustWrite(map[string]any{"jsonrpc": "2.0", "id": 2, "method": "prompt",
"params": map[string]any{"session_id": sid, "text": "again"}})
if r2 := readReply(2); r2["error"] != nil {
t.Fatalf("第二轮失败: %v", r2["error"])
}
raw, err := os.ReadFile(rec)
if err != nil {
t.Fatalf("假 CLI 未记录 argv: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
if len(lines) < 2 {
t.Fatalf("假 CLI 应被调用 2 次,实际 %d: %v", len(lines), lines)
}
var a1, a2 []string
_ = json.Unmarshal([]byte(lines[0]), &a1)
_ = json.Unmarshal([]byte(lines[1]), &a2)
// ★ 首轮绝不能 resume
if i := indexOf(a1, "--resume"); i >= 0 {
t.Errorf("★ 首轮带了 --resume %q —— zcode 会报 Session not found,第一次投递必定失败", a1[i+1])
}
// ★ 第二轮必须 resume 真实 id
i := indexOf(a2, "--resume")
if i < 0 || a2[i+1] != sid {
t.Errorf("★ 第二轮应 --resume %q,实际 argv: %v", sid, a2)
}
// --cli 是 sidecar 自己的参数(被它消费,不转发给 CLI)。
// 「假 CLI 真的被调起来」这件事本身就是 --cli 生效的证明:
// 上面的 rec 文件有两行 argv,只有 sidecar 找到并执行了 fake 才可能。
// 这里额外确认 argv 里没有残留的 --cli(若被转发,真 CLI 会不认识它)。
if j := indexOf(a1, "--cli"); j >= 0 {
t.Errorf("--cli 不应转发给后端 CLI: %v", a1)
}
// 且 --output-format stream-json 必须在(sidecar 依赖末行 result)
if j := indexOf(a1, "--output-format"); j < 0 || a1[j+1] != "stream-json" {
t.Errorf("★ 缺少 --output-format stream-json(拿不到 result 行): %v", a1)
}
}
func indexOf(xs []string, want string) int {
for i, x := range xs {
if x == want {
return i
}
}
return -1
}
func findNodeForTest(t *testing.T) string {
t.Helper()
for _, c := range []string{"node", "nodejs"} {
if p, err := exec.LookPath(c); err == nil {
return p
}
}
for _, p := range []string{"/usr/local/bin/node", "/usr/bin/node"} {
if _, err := os.Stat(p); err == nil {
return p
}
}
t.Skip("本机无 node,跳过行为判据")
return ""
}
// ★★★ 判据:**随包分发的 driver.json 必须能被读到**。
//
// 实测真缺陷(2026-10-16):`locateSidecarScript` 早就有三级链
// (用户目录 → rawfile → 可执行文件目录),但 `loadDriver` **只看插件数据目录**。
// 于是随包分发的 `rawfile/runtime/zcode/driver.json` 永远读不到 ⇒
// 全新部署上 `agent_cli=zcode` 报「驱动不存在」,而文件就在包里 ——
// 与 sidecar 脚本当年踩的坑是**同一个**,只是漏在了描述文件上。
//
// 判据必须覆盖**两条来源**且断言二者解析结果一致(同一套默认值填充),
// 否则会出现"本机覆盖能用、随包分发不能用"这种只在部分机器复现的问题。
func TestShippedDriverIsLoadable(t *testing.T) {
// ① 随包确实带了 zcode 的 driver.json
rfDir, err := filepath.Abs(filepath.Join("rawfile", "runtime", "zcode"))
if err != nil {
t.Fatal(err)
}
shipped := filepath.Join(rfDir, "driver.json")
if _, err := os.Stat(shipped); err != nil {
t.Fatalf("★ 随包缺少 zcode driver.json(新鲜部署上 zcode 不可用): %v", err)
}
// ② 通过真实 SDK 的 RawFiles 接口读到它
// (loadShippedDriver 走的就是 p.sdk.RawFiles(),路径拼接错了这里会红)
// rfDir = <commondev>/rawfile/runtime/zcode
// 三次 Dir:zcode → runtime → rawfile → <commondev>
pluginDir := filepath.Dir(filepath.Dir(filepath.Dir(rfDir))) // .../commondev
p := newTestPluginWithRawfiles(t, filepath.Join(pluginDir, "rawfile"))
rel := filepath.ToSlash(filepath.Join("runtime", "zcode", "driver.json"))
if !p.rawFiles().Exists(rel) {
t.Fatalf("★ rawFiles 读不到随包 driver.json(rel=%s)", rel)
}
d, ok := p.loadShippedDriver("zcode")
if !ok {
t.Fatal("★ loadShippedDriver 未识别随包 driver.json")
}
// ③ 默认值填充必须与"用户目录"来源一致
// 造一个最小描述,比较两条路径的填充结果
dir := t.TempDir()
userPath := filepath.Join(dir, "runtime", "demo", "driver.json")
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(userPath, []byte(`{"name":"demo","args":["--x"]}`), 0o644); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(userPath)
var dUser driverSpec
if err := json.Unmarshal(raw, &dUser); err != nil {
t.Fatal(err)
}
a := p.finishDriver(dUser, "demo")
b := p.finishDriver(driverSpec{Name: "demo", Args: []string{"--x"}}, "demo")
// driverSpec 含 map,不能直接比较;逐字段比关键项
if a.Name != b.Name || a.Script != b.Script || a.LLMConfig != b.LLMConfig ||
a.SessionDirSuffix != b.SessionDirSuffix || a.CLIPath != b.CLIPath {
t.Errorf("★ 两条来源的默认值填充不一致:\n user=%+v\n shipped=%+v", a, b)
}
// 默认后缀必须按 cli 派生(不能写死某个后端)
if a.SessionDirSuffix != ".demo-sessions" {
t.Errorf("★ 默认 sessionDirSuffix 必须按 cli 派生,实际 %q", a.SessionDirSuffix)
}
// ④ ★ **必须走真实入口 loadDriver**(这是本会话第 8 次同类教训:
// 只测辅助函数会漏掉"入口没接上回退"这种缺陷 —— 变异验证时,
// 删掉 loadDriver 里的 loadShippedDriver 调用,辅助函数判据依然全绿)。
//
// 这里把插件数据目录指向一个**空的**临时目录,逼 loadDriver
// 只能从随包 rawfile 取到描述。
rawRoot := filepath.Join(pluginDir, "rawfile")
tmp := t.TempDir()
// driverDir = <插件数据>/runtime/<cli>;把 p.path 指到 tmp 下
p2 := newTestPluginWithRawfiles(t, rawRoot)
p2.path = filepath.Join(tmp, "commondev")
got, _, err := p2.loadDriver("zcode")
if err != nil {
t.Fatalf("★ loadDriver('zcode') 走真实入口失败 —— 随包 driver.json 未被回退读取: %v", err)
}
if got.Name != "zcode" || got.CLIPath == "" {
t.Errorf("★ 从随包取到的描述不完整: %+v", got)
}
// 且证明用户目录确实是空的(否则上一步可能只是在读用户目录)
if _, statErr := os.Stat(filepath.Join(p2.driverDir("zcode"), "driver.json")); statErr == nil {
t.Fatal("判据前提被破坏:用户目录不该有 driver.json")
}
// ⑤ 随包 zcode 描述解析出的关键字段
if d.Script != "sidecar.mjs" {
t.Errorf("zcode script=%q", d.Script)
}
if d.LLMConfig != "none" {
t.Errorf("zcode 不得注入 LLM 配置(它用自己的 provider 配置),实际 %q", d.LLMConfig)
}
if d.CLIPath == "" {
t.Error("zcode 必须声明 cliPath")
}
}
// newTestPluginWithRawfiles 造一个注入了真实 RawFiles 接口的插件实例。
//
// 为什么不用裸 &Plugin{}:loadShippedDriver 走的是 p.sdk.RawFiles(),
// 裸实例 sdk==nil 会让判据永远走"没有随包资源"的分支而**假绿**。
func newTestPluginWithRawfiles(t *testing.T, dir string) *Plugin {
t.Helper()
sdkp := sdk.New("commondev-test", nil, nil, nil, nil, nil)
sdkp.SetRawFilesAPI(&fsRawFiles{root: dir})
return &Plugin{sdk: sdkp}
}
// fsRawFiles 是最小的 RawFilesAPI 实现(只为判据服务)。
//
// ★ 用**真文件系统**而不是内存桩:被测的是"路径怎么拼、读哪个文件",
// 内存桩会把拼接错误一起吞掉(例如 rel 拼错但桩照样返回内容)。
type fsRawFiles struct{ root string }
func (f *fsRawFiles) resolve(rel string) string {
return filepath.Join(f.root, filepath.FromSlash(rel))
}
func (f *fsRawFiles) Read(rel string) ([]byte, error) { return os.ReadFile(f.resolve(rel)) }
func (f *fsRawFiles) Exists(rel string) bool { _, err := os.Stat(f.resolve(rel)); return err == nil }
func (f *fsRawFiles) Dir() string { return f.root }
func (f *fsRawFiles) WorkDir() string { return f.root }
func (f *fsRawFiles) List(rel string) ([]string, error) {
ents, err := os.ReadDir(f.resolve(rel))
if err != nil {
return nil, err
}
out := make([]string, 0, len(ents))
for _, e := range ents {
n := e.Name()
if e.IsDir() {
n += "/"
}
out = append(out, n)
}
return out, nil
}
// ★★★ **行为判据**:声明的 CLI 路径不存在时,错误必须**可执行**。
//
// 实测真缺陷(2026-10-16):随包 driver.json 声明 `{rawfile}/zcode/zcode.cjs`,
// 但包里没有这个文件(28MB,按需分发)。旧实现把声明值**直接返回**,
// 于是 Node 在 spawn 时抛 `Module._resolveFilename` 堆栈 ——
// 用户完全看不出"CLI 不存在",也不知道去哪儿配。
//
// 判据跑真实 sidecar(假 node 环境不必要:本判据只要求它**快速失败并给出信息**),
// 断言错误文本里同时出现:① "找不到 ZCode CLI" ② 声明过的那个路径 ③ 修复指引。
func TestZcodeMissingCLIErrorIsActionable(t *testing.T) {
node := findNodeForTest(t)
sidecar, err := filepath.Abs(filepath.Join("rawfile", "runtime", "zcode", "sidecar.mjs"))
if err != nil {
t.Fatal(err)
}
work := t.TempDir()
missing := filepath.Join(t.TempDir(), "definitely-not-here", "zcode.cjs")
cmd := exec.Command(node, sidecar,
"--workdir", work,
"--session-id", "br-x",
"--mode", "guarded",
"--cli", missing)
cmd.Dir = work
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = cmd.Process.Kill(); _, _ = cmd.Process.Wait() }()
w := bufio.NewWriter(stdin)
rd := bufio.NewReader(stdout)
mustJSON := func(v any) {
b, _ := json.Marshal(v)
_, _ = w.Write(append(b, '\n'))
_ = w.Flush()
}
mustJSON(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "prompt",
"params": map[string]any{"session_id": "br-x", "text": "hi"}})
var got string
for {
line, err := rd.ReadString('\n')
if err != nil {
t.Fatalf("没等到错误回复: %v", err)
}
var m map[string]any
if json.Unmarshal([]byte(line), &m) != nil {
continue
}
if e, ok := m["error"].(map[string]any); ok {
got, _ = e["message"].(string)
break
}
}
// ① 说清是什么问题
if !strings.Contains(got, "找不到 ZCode CLI") {
t.Errorf("★ 错误没点明'找不到 CLI': %q", got)
}
// ② 列出**声明过的那个路径**(用户据此知道 driver.json 生效了)
if !strings.Contains(got, missing) {
t.Errorf("★ 错误未列出声明过的路径 %s: %q", missing, got)
}
// ③ 给出修复动作
if !strings.Contains(got, "driver.json") || !strings.Contains(got, "ZCODE_CLI") {
t.Errorf("★ 错误未给出修复指引: %q", got)
}
// ④ 绝不能是 Node 的模块解析堆栈(那正是被修掉的表现)
if strings.Contains(got, "Module._resolveFilename") {
t.Errorf("★ 仍在暴露 Node 内部堆栈(用户无法定位): %q", got)
}
}

72
example/commondev/util.go Normal file
View File

@ -0,0 +1,72 @@
package main
// 通用小工具:时间、数值参数、HTTP 客户端。
//
// 这些原本散在 platform.go 里(那个文件是 git 平台专有的,已随平台逻辑一并
// 移出插件)。但它们本身与 git 无关 —— criteria / pi_tools / encstore 都在用,
// 所以单独留在这里。**新增平台专属代码不要再往本文件加。**
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
)
func nowRFC3339() string { return time.Now().Format(time.RFC3339) }
// numArg 取数值参数(JSON 数字进来是 float64)。
func numArg(args map[string]interface{}, key string) float64 {
switch v := args[key].(type) {
case float64:
return v
case int:
return float64(v)
case int64:
return float64(v)
case json.Number:
f, _ := v.Float64()
return f
}
return 0
}
// httpClient 是共享客户端(超时必须有:对端不可达时不能挂住工具调用)。
var httpClient = &http.Client{Timeout: 30 * time.Second}
// httpGetJSON 发一个 GET 请求,返回 body 与状态码。
//
// 不带认证头:本插件已不持有任何平台凭据。需要认证的调用应当
// 用 **safeshell** 执行 curl(或 CLI),凭据由内核变量库按 @{名字} 填充 ——
// 那是「凭据只保存不读取」这条边界要求的方式。
func httpGetJSON(url string, headers map[string]string) (string, int, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", 0, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "commondev/0.1")
resp, err := httpClient.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return string(b), resp.StatusCode, nil
}
// stripTokenFromURL 把 URL 里的凭据抹掉,用于日志/回显。
func stripTokenFromURL(u string) string {
if i := strings.Index(u, "://"); i >= 0 {
rest := u[i+3:]
if at := strings.Index(rest, "@"); at >= 0 {
return u[:i+3] + "***@" + rest[at+1:]
}
}
return u
}

View File

@ -0,0 +1,154 @@
package main
// 工作目录映射:工程分支 ↔ 目录 的**单一事实源**。
//
// 用户要求(2026-10-14):
//
// 「即使是同一个仓库,只要是不同的分支都拉到 hash-name 下使用 hash 区分,
// 插件通过哈希表映射工程分支与文件夹」
//
// 所以目录形态是 program/<hash>-<name>,其中:
//
// hash = H(项目 + 分支) → ★ 唯一性来源,保证同仓库不同分支**必然**是不同目录
// name = 分支名派生 → ★ 可读性来源,让人一眼看出这是哪条线
//
// ## 为什么 hash 必须含分支,且必须是唯一性来源
//
// 一个仓库可以并行开多条分支(这正是本插件的目标)。若只按仓库名建目录,
// 两条分支就会**共用同一个工作树** —— 那是灾难性的:agent A 的未提交改动会出现在
// agent B 的 `git status` 里,提交互相污染,且"独立 clone"的前提(设计 §4.3)不成立。
//
// 所以:目录唯一性由 hash 承担,**不依赖 name**。
// name 只做可读性,冲突了也无所谓(hash 已经不同)。
//
// ## 为什么不能只留 hash
//
// 清理孤儿目录、人工排查、审计时,纯 hash 目录名毫无信息量。所以保留 name 后缀。
//
// ## 映射是**双向**的
//
// branchKey(project, branch) → dirName(project, branch) → 目录路径
// 目录路径 → 可从目录名解析回 (hash, name),再经台账 branch.Workdir 反查工程分支
//
// ★ 绝对不要在这之外另算目录名:那会出现两处真相,改一处就漂移。
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// identityHashLen 是目录名里 hash 的长度(8 位 16 进制,碰撞概率对本场景足够低)。
const identityHashLen = 8
// branchKey 是「工程 + 分支」的规范化键 —— 映射的输入。
//
// 用 "\x00" 分隔,避免 (a|b) 与 (ab|"") 之类的拼接歧义。
func branchKey(project, branch string) string {
return strings.TrimSpace(project) + "\x00" + strings.TrimSpace(branch)
}
// shortHash 是 FNV-1a 32 位哈希的 16 进制串(可重算 ⇒ 可清理孤儿)。
func shortHash(s string) string {
h := uint32(2166136261)
for i := 0; i < len(s); i++ {
h ^= uint32(s[i])
h *= 16777619
}
return fmt.Sprintf("%0*x", identityHashLen, h)
}
// dirName 返回某个工程分支对应的目录名(不含父目录)。
//
// 形态:<hash>-<name>。
func dirName(project, branch string) string {
h := shortHash(branchKey(project, branch))
name := safeNamePattern.ReplaceAllString(strings.TrimSpace(branch), "_")
name = strings.Trim(name, "_")
if name == "" {
name = "branch"
}
// 目录名总长有限制;分支名过长时截断(hash 已保证唯一,截断不损正确性)。
if len(name) > 48 {
name = name[:48]
}
return h + "-" + name
}
// branchDir 返回某个工程分支的工作目录绝对路径。
//
// programRoot 是 program/ 的父目录(插件数据目录)。
func branchDir(programRoot, project, branch string) string {
return filepath.Join(programRoot, dirName(project, branch))
}
// programRoot 返回插件的数据目录下的 program/ 根。
func (p *Plugin) programRoot() string {
return filepath.Join(filepath.Dir(p.path), "program")
}
// dirNamePattern 用于从目录名解析回 (hash, name)。
var dirNamePattern = regexp.MustCompile(`^([0-9a-f]{8})-(.+)$`)
// parseDirName 解析目录名。ok=false 表示不像本插件生成的目录。
func parseDirName(dir string) (hash, name string, ok bool) {
m := dirNamePattern.FindStringSubmatch(dir)
if m == nil {
return "", "", false
}
return m[1], m[2], true
}
// resolveBranchDir 是**反查**:给定目录名,在台账里找到它属于哪个工程分支。
//
// 用途:清理孤儿目录、审计"这个目录是谁的"。
func (p *Plugin) resolveBranchDir(dirName string) (*Branch, bool) {
p.mu.Lock()
defer p.mu.Unlock()
for _, b := range p.ledger.Branches {
if filepath.Base(b.Workdir) == dirName {
return b, true
}
}
return nil, false
}
// listOrphanDirs 列出 program/ 下**台账里没有记录**的目录(孤儿)。
//
// 为什么需要它:目录会因崩溃/手工操作残留,而"合入即删"是本插件的纪律;
// 没有孤儿检测,残留会永久堆积、且下次同名分支可能与旧残留混淆。
func (p *Plugin) listOrphanDirs() ([]string, error) {
root := p.programRoot()
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
known := map[string]bool{}
p.mu.Lock()
for _, b := range p.ledger.Branches {
if b.Workdir != "" {
known[filepath.Base(b.Workdir)] = true
}
}
p.mu.Unlock()
var orphans []string
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if _, _, ok := parseDirName(name); !ok {
continue // 不是本插件生成的目录,不碰
}
if !known[name] {
orphans = append(orphans, name)
}
}
return orphans, nil
}

View File

@ -0,0 +1,235 @@
package main
// 远端(ssh)工作目录准备。
//
// ## 为什么必须有这个文件
//
// `prepareWorkdir`(plugin.go)走的是**本地** git:`clone --mirror` +
// `clone mirror workdir`,全部在本机磁盘上。对 `runtime=ssh` 的分支来说
// 这是错的 —— 远端 pi 的 cwd 指向远端目录,而那个目录**从未被创建**。
// 症状是「agent 起来了,但一执行命令就说目录不存在」。
//
// 所以远端线必须有一套对应的远端准备流程:
//
// ① 远端建 workdir 的父目录
// ② 有共享 mirror 就用它(省一次全量拉取),没有就直连 remote 拉
// ③ 切到目标分支
//
// ## 为什么远端不复用本地的 mirror
//
// mirror 是**本机路径**(`<插件数据>/mirrors/<name>.git`),远端看不到。
// 远端有自己的 mirror 根(`<ssh_workdir_root>/.mirrors/`),
// 这样同一台远端上的多条分支线可以共享一次拉取。
//
// ## 凭据
//
// 远端拉取需要远端自己能认证(远端已配好 key 或 credential helper)。
// 本插件**不把凭据推到远端**(设计 §7.2:凭据不进工作目录、不进 remote URL)。
// 拉不动就响亮报错并说明原因,不静默降级。
import (
"fmt"
"os/exec"
"strings"
"time"
)
// remoteGit 在远端跑一条 git 命令(BatchMode,不交互输密码)。
//
// cwd 用 `cd <dir> &&` 而不是 `git -C`:`git -C` 在目录不存在时
// 报的是 "cannot change to",而 `cd` 失败更直观;两者都会响亮失败。
func (t sshTarget) remoteGit(dir string, args ...string) (string, error) {
parts := []string{"git"}
parts = append(parts, args...)
for i, a := range parts {
if i > 0 {
parts[i] = shellQuote(a)
}
}
cmdline := strings.Join(parts, " ")
if strings.TrimSpace(dir) != "" {
cmdline = "cd " + shellQuote(dir) + " && " + cmdline
}
argv := append(t.sshBaseArgs(), t.Host, cmdline)
return t.runSSHCapture(argv, 10*time.Minute)
}
// remoteShell 在远端跑一条**原始 shell 命令**(不经 git)。
//
// 与 remoteGit 分开是必要的:`git mv` / `git rm` 是仓库内的操作,
// 用于文件系统层面的搬移会报 "not a git repository"(实测踩到)。
func (t sshTarget) remoteShell(cmdline string) (string, error) {
argv := append(t.sshBaseArgs(), t.Host, cmdline)
return t.runSSHCapture(argv, 10*time.Minute)
}
// remoteMkdir 在远端建目录(幂等)。
func (t sshTarget) remoteMkdir(dir string) error {
argv := append(t.sshBaseArgs(), t.Host, "mkdir -p "+shellQuote(dir))
_, err := t.runSSHCapture(argv, 60*time.Second)
return err
}
// remotePathExists 判断远端路径是否存在且是目录。
func (t sshTarget) remotePathExists(dir string) bool {
argv := append(t.sshBaseArgs(), t.Host,
"test -d "+shellQuote(dir)+" && echo yes")
out, err := t.runSSHCapture(argv, 60*time.Second)
return err == nil && strings.TrimSpace(out) == "yes"
}
// runSSHCapture 跑一条 ssh 命令并返回 stdout(统一超时与错误包装)。
func (t sshTarget) runSSHCapture(argv []string, timeout time.Duration) (string, error) {
return runSSHCaptureOn(t.Host, argv, timeout)
}
// runSSHCaptureOn 是 sshTarget.runSSHCapture 的自由函数形式
// (便于判据在不构造完整 target 的情况下复用)。
//
// ★ 超时用 goroutine + Wait 实现,**不能**在 Start() 之后调 CombinedOutput()
// —— 那会二次启动并报 `exec: already started`(实测踩到)。
func runSSHCaptureOn(host string, argv []string, timeout time.Duration) (string, error) {
cmd := exec.Command("ssh", argv...)
cmd.Env = append(cmd.Environ(), "GIT_TERMINAL_PROMPT=0")
var buf strings.Builder
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("启动 ssh 失败: %w", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
s := strings.TrimSpace(buf.String())
if err != nil {
return s, fmt.Errorf("远端命令失败(%s): %w: %s", host, err, truncate(s, 300))
}
return s, nil
case <-time.After(timeout):
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
<-done // 收尾,不留 zombie
return "", fmt.Errorf("远端命令超时(%s,%s)—— 检查远端网络与 git 是否卡在认证提示",
host, timeout)
}
}
// remoteWorkdirRoot 取远端放工程目录的根。
//
// ★ 按 host 取(多台机器的目录布局不同):先问端点表,再回退到旧的
// 全局 settings。缺配置时响亮报错:默认值猜错会把文件写到远端别的项目的目录里。
func (p *Plugin) remoteWorkdirRoot(host string) (string, error) {
if he := p.getHost(host, false); he != nil && strings.TrimSpace(he.WorkdirRoot) != "" {
return he.WorkdirRoot, nil
}
if v, _ := p.sdk.Settings().Get("ssh_workdir_root"); v != nil {
if s := strings.TrimSpace(fmt.Sprint(v)); s != "" && s != "<nil>" {
return s, nil
}
}
return "", fmt.Errorf(
"远端 %s 未配置工程目录根。请用 commondev_endpoint_set host=%s "+
"workdir_root=<远端根目录,如 /home/user/.commondev/program>(多台机器各配各的),"+
"或配置全局 settings 的 ssh_workdir_root", host, host)
}
// prepareRemoteWorkdir 在远端准备这条分支的工作目录。
//
// 与本地 prepareWorkdir 的对应关系:
//
// 本地: 建父目录 → mirror clone(本机)→ clone mirror workdir → checkout
// 远端: 建父目录 → 远端 mirror(共享)→ clone mirror workdir → checkout
//
// 复用判断与本地一致:目录已存在则只确保分支正确(分支线重开时)。
func (p *Plugin) prepareRemoteWorkdir(t sshTarget, pr *Project, branch, workdir string) error {
// ★ 串行化:见 Plugin.prepMu 的注释(mirror 的"检查后 clone"是 TOCTOU)。
p.prepMu.Lock()
defer p.prepMu.Unlock()
root := t.WorkdirRoot
if root == "" {
var err error
root, err = p.remoteWorkdirRoot(t.Host)
if err != nil {
return err
}
t.WorkdirRoot = root
}
// ① 复用:目录已在就直接切分支
if t.remotePathExists(workdir) {
return p.ensureRemoteOnBranch(t, branch, workdir)
}
// ② 建父目录
if err := t.remoteMkdir(workdir); err != nil {
return fmt.Errorf("远端建目录失败: %w", err)
}
// ③ 共享 mirror(一次全量拉取,供该远端上所有分支线复用)
mirror := root + "/.mirrors/" + pr.Name + ".git"
// MirrorPath 是**本地**路径,远端用不了 —— 一律用远端自己的 mirror 根。
// (这正是当初把 MirrorPath 当通用字段用会踩的坑。)
if !t.remotePathExists(mirror) {
if err := t.remoteMkdir(root + "/.mirrors"); err != nil {
return fmt.Errorf("远端建 mirror 目录失败: %w", err)
}
// clone 到**临时目录**再 mv 就位:即使有别的进程刚好抢先建好了,
// 结果也只是一个未使用的临时目录,而不是一个半成品 mirror。
// (prepMu 已经串行化了插件内的并发;这里是防**插件外**的干扰
// 与未来可能的分进程部署。)
// ★ 就位用 **shell 的 mv**,不是 `git mv`(后者只在仓库内工作,
// 实测报 "not a git repository")。清理同理用 shell 的 rm -rf。
tmp := mirror + ".tmp"
_, _ = t.remoteShell("rm -rf " + shellQuote(tmp))
if _, err := t.remoteGit("", "clone", "--mirror", pr.RemoteURL, tmp); err != nil {
// 清掉半成品,避免下次误判"已存在"
_, _ = t.remoteShell("rm -rf " + shellQuote(tmp))
return fmt.Errorf(
"远端建 mirror 失败(远端需自己能认证该仓库,本插件不推凭据): %w", err)
}
if _, err := t.remoteShell("mv " + shellQuote(tmp) + " " + shellQuote(mirror)); err != nil {
_, _ = t.remoteShell("rm -rf " + shellQuote(tmp))
return fmt.Errorf("远端 mirror 就位失败: %w", err)
}
}
// ④ 由远端 mirror 派生工作目录
if _, err := t.remoteGit("", "clone", mirror, workdir); err != nil {
return fmt.Errorf("远端由 mirror 派生工作目录失败: %w", err)
}
// ⑤ 切到目标分支(与本地同一条硬要求:否则 agent 会在默认分支上改)
return p.ensureRemoteOnBranch(t, branch, workdir)
}
// ensureRemoteOnBranch 把远端工作目录切到目标分支(已在则不动)。
func (p *Plugin) ensureRemoteOnBranch(t sshTarget, branch, workdir string) error {
if strings.TrimSpace(branch) == "" {
return nil
}
cur, err := t.remoteGit(workdir, "rev-parse", "--abbrev-ref", "HEAD")
if err == nil && strings.TrimSpace(cur) == branch {
return nil
}
if _, err := t.remoteGit(workdir, "checkout", branch); err != nil {
if _, err2 := t.remoteGit(workdir, "checkout", "-b", branch); err2 != nil {
return fmt.Errorf("远端切到分支 %s 失败: %v", branch, err2)
}
}
return nil
}
// remoteCaptureBaseSHA 取远端工作目录的 base_sha。
//
// 与本地 captureBaseSHA 对应:必须在 agent 动手之前取。
func (p *Plugin) remoteCaptureBaseSHA(t sshTarget, workdir string) (string, error) {
out, err := t.remoteGit(workdir, "rev-parse", "HEAD")
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}

View File

@ -0,0 +1,400 @@
package main
// 远端工作目录准备的判据。
//
// 本次发现的缺口:`prepareWorkdir` **只有本地一套**(本机 clone --mirror +
// clone mirror workdir)。对 runtime=ssh 的分支,远端 pi 的 cwd 指向一个
// **从未被创建**的远端目录 —— 症状是"agent 起来了,但一执行命令就说目录不存在",
// 而 job 状态显示 done(因为本地 clone 成功了)。
//
// 这类"两边各自看起来都成功"的缺口最难靠肉眼发现,必须有判据。
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// filepathBase 是 filepath.Base 的薄包装(判据里读起来更清楚)。
func filepathBase(p string) string { return filepath.Base(p) }
// writeFile 是判据用的小工具。
func writeFile(p, content string) error {
return os.WriteFile(p, []byte(content), 0o644)
}
// readSourceOrSkip 读一个源文件,读不到就跳过。
func readSourceOrSkip(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(name)
if err != nil {
t.Skipf("读不到 %s: %v", name, err)
}
return string(b)
}
// ★★★ 判据:runtime=ssh 时台账里的 Workdir 必须是**远端路径**。
//
// 留本地路径会让 agent_up 把远端 pi 的 cwd 指到本地目录名上。
func TestSSHBranchWorkdirBecomesRemotePath(t *testing.T) {
sshE2EReady(t)
p := newTestPluginForShell(t)
p.ledger = &Ledger{Projects: map[string]*Project{}, Branches: map[string]*Branch{},
Jobs: map[string]*Job{}, Criteria: map[string]*Criteria{}}
remoteRoot := t.TempDir()
p.testSetSetting("ssh_workdir_root", remoteRoot)
// 造一个**远端可就地 clone 的**裸仓库(本机路径对 localhost 的 ssh 同样可见)
localBare := makeBareRepo(t)
p.ledger.Projects["proj"] = &Project{
Name: "proj", RemoteURL: localBare, DefaultBranch: "main",
}
// ★ 直接构造 branch,**不调 handleBranchOpen**:
// 后者会起后台 goroutine 也去准备同一个 mirror,与本次同步准备并发撞车
// (那正是它暴露出的 TOCTOU —— 已在生产代码用 prepMu 修掉,
// 但判据不该依赖"两条路径同时跑"的时序)。
b := &Branch{ID: "br-remote", Name: "feat/remote", ProjectID: "proj",
Runtime: "ssh", SSHHost: "localhost"}
tgt, err := parseSSHTarget(b)
if err != nil {
t.Fatal(err)
}
tgt.WorkdirRoot = remoteRoot
remoteWorkdir := remoteRoot + "/" + dirName("proj", "feat/remote")
if err := p.prepareRemoteWorkdir(tgt, p.ledger.Projects["proj"], "feat/remote", remoteWorkdir); err != nil {
t.Fatalf("★ 远端目录准备失败: %v", err)
}
// ① 远端目录真的存在且是 git 仓库
if !tgt.remotePathExists(remoteWorkdir) {
t.Fatal("★ 远端工作目录不存在")
}
if _, err := tgt.remoteGit(remoteWorkdir, "rev-parse", "--git-dir"); err != nil {
t.Fatalf("★ 远端目录不是 git 仓库: %v", err)
}
// ② 已切到目标分支(不是默认分支)
br, err := tgt.remoteGit(remoteWorkdir, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(br) != "feat/remote" {
t.Errorf("★ 远端目录在错的分支上: %q(应为 feat/remote)", strings.TrimSpace(br))
}
// ③ 共享 mirror 建在远端(不是本地路径)
if !tgt.remotePathExists(remoteRoot + "/.mirrors/proj.git") {
t.Error("★ 远端共享 mirror 未建立")
}
}
// ★★ 判据:**缺 ssh_workdir_root 时响亮报错**(不猜默认值)。
//
// 猜默认会把文件写到远端别的目录里 —— 那是很难回滚的错。
func TestRemoteWorkdirRequiresRootConfig(t *testing.T) {
p := newTestPluginForShell(t)
// settings 的 Get 返回 nil ⇒ 视为未配置
_, err := p.remoteWorkdirRoot("somehost")
if err == nil {
t.Fatal("★ 未配置 ssh_workdir_root 时应报错")
}
if !strings.Contains(err.Error(), "workdir_root") {
t.Errorf("错误应指出缺哪个配置: %v", err)
}
}
// ★★★ 判据:**本地路径与远端路径不能混用**。
//
// prepareWorkdir 用的是本机 git;远端线绝不能走它。
// 这条钉住"两条路径必须分开"这个结构性事实:
// 若 runPrepareWorkdir 忽略 runtime 一律走本地,远端目录永远是空的。
func TestLocalAndRemoteWorkdirPathsAreDistinct(t *testing.T) {
// 远端目录名与本地同构(<hash>-<name>),但**根不同**
local := branchDir("/local/program", "proj", "feat/x")
remote := "/remote/root/" + dirName("proj", "feat/x")
if local == remote {
t.Error("本地与远端目录不应相同")
}
// 基名必须一致(便于人对照),根必须不同
if filepathBase(local) != filepathBase(remote) {
t.Errorf("两端目录基名应一致(可读性): %s vs %s", filepathBase(local), filepathBase(remote))
}
}
// ★★ 判据:远端目录**已存在时复用**(重开分支线不重新克隆)。
func TestRemoteWorkdirReuseIsIdempotent(t *testing.T) {
sshE2EReady(t)
p := newTestPluginForShell(t)
p.ledger = &Ledger{Projects: map[string]*Project{}, Branches: map[string]*Branch{},
Jobs: map[string]*Job{}, Criteria: map[string]*Criteria{}}
root := t.TempDir()
bare := makeBareRepo(t)
pr := &Project{Name: "proj", RemoteURL: bare, DefaultBranch: "main"}
tgt := sshTarget{Host: "localhost", NodePath: "node", WorkdirRoot: root}
wd := root + "/" + dirName("proj", "feat/reuse")
// 第一次:完整准备
if err := p.prepareRemoteWorkdir(tgt, pr, "feat/reuse", wd); err != nil {
t.Fatalf("首次准备失败: %v", err)
}
// 放一个标记文件,第二次准备**不能**把它冲掉
tgt.remoteMkdir(wd)
if _, err := tgt.remoteGit(wd, "rev-parse"); err != nil {
t.Fatal(err)
}
if err := p.prepareRemoteWorkdir(tgt, pr, "feat/reuse", wd); err != nil {
t.Fatalf("复用准备失败: %v", err)
}
// 仍在正确分支
br, _ := tgt.remoteGit(wd, "rev-parse", "--abbrev-ref", "HEAD")
if strings.TrimSpace(br) != "feat/reuse" {
t.Errorf("复用后分支不对: %q", strings.TrimSpace(br))
}
}
// ★ 判据:远端 git 命令的关键要素(BatchMode + GIT_TERMINAL_PROMPT=0)。
//
// 远端拉取若卡在认证提示,会挂到超时而不是报"没凭据"。
func TestRemoteGitIsNonInteractive(t *testing.T) {
src := readSourceOrSkip(t, "workdir_remote.go")
if !strings.Contains(src, "GIT_TERMINAL_PROMPT=0") {
t.Error("★ 远端 git 未禁用交互提示(会挂到超时而非报错)")
}
if !strings.Contains(src, "sshBaseArgs()") {
t.Error("★ 远端 git 未用 sshBaseArgs(缺 BatchMode)")
}
}
// makeBareRepo 造一个可被 clone 的裸仓库(含 main 分支与一次提交)。
func makeBareRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
bare := dir + "/origin.git"
work := dir + "/work"
run := func(d string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = d
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)
}
}
run(dir, "init", "--bare", "-b", "main", bare)
run(dir, "init", "-b", "main", work)
if err := writeFile(work+"/README.md", "hi\n"); err != nil {
t.Fatal(err)
}
run(work, "add", "-A")
run(work, "commit", "-m", "init")
run(work, "remote", "add", "origin", bare)
run(work, "push", "origin", "main")
return bare
}
// ★★★ 判据:**完整远端闭环** —— 远端建目录 → 远端拉起 pi → 投递一次。
//
// 前面两条分别验了"远端目录准备"与"远端 sidecar 握手",但真实使用是
// 串起来的:目录必须先于 pi 存在(否则 pi 的 cwd 无效),而投递必须落到
// 那个目录里的会话上。这条把三段串起来跑一遍。
func TestSSHFullLoop(t *testing.T) {
sshE2EReady(t)
p := newTestPluginForShell(t)
p.ledger = &Ledger{Projects: map[string]*Project{}, Branches: map[string]*Branch{},
Jobs: map[string]*Job{}, Criteria: map[string]*Criteria{}}
root := t.TempDir()
runtimeRoot := t.TempDir()
p.testSetSetting("ssh_workdir_root", root)
p.testSetSetting("ssh_runtime_root", runtimeRoot)
p.testSetSetting("ssh_node_path", "node")
// 真实 sidecar 放进 driver 目录(供 scp 推到远端)
cliDir := p.driverDir("pi")
if err := os.MkdirAll(cliDir, 0o755); err != nil {
t.Fatal(err)
}
srcBytes, err := os.ReadFile("sidecar.mjs")
if err != nil {
t.Skipf("读不到 sidecar.mjs: %v", err)
}
if err := os.WriteFile(filepath.Join(cliDir, "sidecar.mjs"), srcBytes, 0o644); err != nil {
t.Fatal(err)
}
bare := makeBareRepo(t)
pr := &Project{Name: "proj", RemoteURL: bare, DefaultBranch: "main"}
// ① 远端准备目录
tgt := sshTarget{Host: "localhost", NodePath: "node", WorkdirRoot: root}
wd := root + "/" + dirName("proj", "feat/loop")
if err := p.prepareRemoteWorkdir(tgt, pr, "feat/loop", wd); err != nil {
t.Fatalf("① 远端目录准备失败: %v", err)
}
// ② 远端拉起 sidecar,并确认它在**那个目录**里工作
b := &Branch{ID: "br-loop", Name: "feat/loop", ProjectID: "proj",
Workdir: wd, Runtime: "ssh", SSHHost: "localhost"}
ctx := driverCtx{Workdir: wd, SessionID: "sess-loop", SessionName: "br-loop",
DataDir: filepath.Dir(p.path), PluginDir: cliDir}
drv, _, err := p.loadDriver("pi")
if err != nil {
t.Fatal(err)
}
sc, err := p.startSSHSidecar(b, drv, ctx, "pi")
if err != nil {
t.Fatalf("② 远端 sidecar 启动失败: %v", err)
}
defer sc.Close()
// ③ 投递一次,断言会话未漂移
res, err := sc.call("prompt", map[string]interface{}{
"session_id": "sess-loop", "text": "只回一个词:ok",
})
if err != nil {
t.Fatalf("③ 远端投递失败: %v", err)
}
m, _ := res.(map[string]interface{})
if got, _ := m["session_id"].(string); got != "" && got != "sess-loop" {
t.Errorf("★ 远端会话漂移: %q", got)
}
t.Logf("远端完整闭环成功:目录=%s 会话=%v", wd, m["session_id"])
}
// ★★★ 判据:`runPrepareWorkdir` **必须按 runtime 分派**。
//
// 这条补的是变异暴露的缺口:上面那些判据直接调用 prepareRemoteWorkdir,
// 所以把 runPrepareWorkdir 里的 `if runtime == "ssh"` 去掉(一律走本地)
// 时**它们全绿** —— 而那正是最致命的回退:远端目录永远为空,
// 但 job 状态显示 done(本地 clone 成功了)。
//
// 判据必须钉住**分派本身**,而不是只钉住被分派到的函数。
func TestPrepareWorkdirDispatchesByRuntime(t *testing.T) {
src := readSourceOrSkip(t, "plugin.go")
// 找到 runPrepareWorkdir 函数体
i := strings.Index(src, "func (p *Plugin) runPrepareWorkdir(")
if i < 0 {
t.Fatal("找不到 runPrepareWorkdir")
}
end := strings.Index(src[i:], "\n}\n")
if end < 0 {
t.Fatal("找不到函数结尾")
}
body := src[i : i+end]
// ① 必须有 runtime 参数(否则无从分派)
if !strings.Contains(body, "runtime, sshHost string") {
t.Error("★ runPrepareWorkdir 未接收 runtime 参数")
}
// ② 必须按 runtime 分派到远端实现
//
// ★ 判据要**精确匹配分派语句**,不能只 Contains("runtime == \"ssh\""):
// 实测 `if false && runtime == "ssh"`(把分派关掉)仍然包含那个子串,
// 于是判据照绿。这是"判据通过 ≠ 检查到了目标性质"的又一例。
sshIdx := strings.Index(body, `if runtime == "ssh" {`)
if !strings.Contains(body, `if runtime == "ssh" {`) {
t.Error("★ runPrepareWorkdir 未按 runtime 分派(远端线会走本地 clone,远端目录永远为空)")
}
if strings.Contains(body, `false && runtime == "ssh"`) {
t.Error("★ 分派条件被禁用(存在 `false &&` 前缀)")
}
// 分派必须**真的返回**(进入远端分支后不能落到本地路径)
if sshIdx >= 0 {
seg := body[sshIdx:]
if !strings.Contains(seg, "return") {
t.Error("★ 远端分派分支没有 return(会继续落到本地路径)")
}
}
if !strings.Contains(body, "prepareRemoteWorkdir") {
t.Error("★ runPrepareWorkdir 未调用 prepareRemoteWorkdir")
}
// ③ 本地路径必须在分派之后(否则会被远端分支挡住)
localIdx := strings.Index(body, "p.prepareWorkdir(")
if localIdx >= 0 && sshIdx >= 0 && localIdx < sshIdx {
t.Error("★ 本地 prepareWorkdir 出现在分派之前(远端线会走本地路径)")
}
// ④ 远端线的 Workdir 必须被改写成远端路径
if !strings.Contains(body, "bb.Workdir = remoteWorkdir") {
t.Error("★ 远端线未把台账 Workdir 改成远端路径(agent_up 会用错 cwd)")
}
}
// ★★ 判据:`branch_open` 必须把 runtime/ssh_host 传给后台准备。
//
// 只记进台账但不传给 runPrepareWorkdir 的话,台账对了、目录还是本地建的
// —— "参数记了但没用"是同一类缺口的另一半。
func TestBranchOpenPassesRuntimeToPrepare(t *testing.T) {
src := readSourceOrSkip(t, "plugin.go")
if !strings.Contains(src, "go p.runPrepareWorkdir(jobID, id, pr, branch, workdir, b.Runtime, b.SSHHost)") {
t.Error("★ branch_open 未把 runtime/ssh_host 传给 runPrepareWorkdir")
}
}
// ★★★ 判据:**并发的目录准备不能互相撞车**(真 bug,实测 3/6 复现)。
//
// 现象:
//
// fatal: destination path '.../.mirrors/proj.git' already exists
// and is not an empty directory
//
// 根因是"检查后 clone"这个 TOCTOU:两个 goroutine 同时判定 mirror 不存在,
// 然后都去 clone。**触发场景是真实的** —— branch_open 的目录准备在后台跑,
// 用户看 job 还没 done 就再点一次 workdir_prepare(完全自然的操作)。
//
// 判据直接并发调 prepareRemoteWorkdir N 次,要求**至少一次成功且无致命错误**
// ("已被别人建好"不算失败,那是幂等的一部分)。
func TestConcurrentRemotePrepareDoesNotCollide(t *testing.T) {
sshE2EReady(t)
p := newTestPluginForShell(t)
root := t.TempDir()
bare := makeBareRepo(t)
pr := &Project{Name: "proj", RemoteURL: bare, DefaultBranch: "main"}
tgt := sshTarget{Host: "localhost", NodePath: "node", WorkdirRoot: root}
wd := root + "/" + dirName("proj", "feat/race")
const n = 4
errCh := make(chan error, n)
for i := 0; i < n; i++ {
go func() {
errCh <- p.prepareRemoteWorkdir(tgt, pr, "feat/race", wd)
}()
}
var errs []error
for i := 0; i < n; i++ {
if err := <-errCh; err != nil {
errs = append(errs, err)
}
}
for _, err := range errs {
// "already exists" 是撞车的直接证据,必须为 0
if strings.Contains(err.Error(), "already exists") {
t.Errorf("★ 并发准备撞车: %v", err)
}
}
// 最终必须有一个可用的仓库在正确分支上
if _, err := tgt.remoteGit(wd, "rev-parse", "--abbrev-ref", "HEAD"); err != nil {
t.Fatalf("并发准备后工作目录不可用: %v(错误: %v)", err, errs)
}
br, _ := tgt.remoteGit(wd, "rev-parse", "--abbrev-ref", "HEAD")
if strings.TrimSpace(br) != "feat/race" {
t.Errorf("并发准备后分支不对: %q", strings.TrimSpace(br))
}
}
// ★★ 判据:mirror 就位用 **shell mv**,不是 `git mv`。
//
// `git mv` 只在仓库内工作,用于文件系统搬移会报 "not a git repository"
// (实测踩到:mirror 就位时失败)。
func TestRemoteMirrorPlacedWithShellMv(t *testing.T) {
src := readSourceOrSkip(t, "workdir_remote.go")
if !strings.Contains(src, `remoteShell("mv "`) {
t.Error("★ mirror 就位未用 shell mv(git mv 会报 not a git repository)")
}
if strings.Contains(src, `remoteGit("", "mv"`) {
t.Error("★ 仍在用 git mv 搬移 mirror")
}
}

View File

@ -0,0 +1,172 @@
package main
// agent 生命周期与孤儿目录清理。
//
// 这两件事**与 git 平台无关**,所以留在插件里(2026-10-15 架构调整时确认):
// · agent 状态/停止 —— 属 pi 运行时会话管理
// · 工作目录孤儿清理 —— 属本插件的目录映射纪律,且含凭据残留检查
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// handleAgentState 是 commondev_agent_state 的实现。
func (p *Plugin) handleAgentState(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]
sc := p.sidecars[id]
p.mu.Unlock()
if !ok {
return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", id)}, nil
}
alive := sc != nil && sc.Alive()
out := map[string]interface{}{
"branch_id": id, "status": b.Status, "runtime": b.Runtime,
"agent_cli": cliOrPi(b.AgentCLI),
"session_id": b.SessionID, "input_ch": b.InputCh, "output_ch": b.OutputCh,
"agent_alive": alive,
}
// ★ runtime=ssh 时必须回显**打到哪台机器**。
//
// 不回显的话,一条远端线的状态看着和本地线一模一样,
// 排查时无法区分"任务跑在本机"还是"跑到 10.0.0.9 上了" ——
// 而这两者的现象(哪些文件被改、日志在哪)完全不同。
if b.Runtime == "ssh" {
out["ssh_host"] = b.SSHHost
}
if !alive {
out["note"] = "sidecar 未在运行(进程已退出或从未拉起)—— 用 commondev_agent_up 拉起"
}
return out, nil
}
// handleAgentDown 是 commondev_agent_down 的实现(幂等收尾)。
//
// 与 branch_close 的区别:**只杀进程、不动工作目录、不改分支状态**。
// 用途是「暂停这条线但保留现场」,而 branch_close 是「这条线完事了」。
func (p *Plugin) handleAgentDown(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]
sc := p.sidecars[id]
delete(p.sidecars, id)
if ok {
// ★ 清掉通道与 session 绑定:留着会让人以为进程还在。
// 工作目录与 status **不动** —— 那是「暂停」,不是「收尾」。
b.SessionID, b.InputCh, b.OutputCh = "", "", ""
_ = p.saveLocked()
}
p.mu.Unlock()
if !ok {
return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", id)}, nil
}
stopped := sc != nil
if sc != nil {
sc.Close()
}
return map[string]interface{}{
"status": "ok", "branch_id": id, "stopped": stopped,
"note": "已停进程;工作目录与分支状态保留(要彻底收尾用 commondev_branch_close)",
}, nil
}
// handleWorkdirClean 是 commondev_workdir_clean 的实现。
//
// 设计 §7.2:内置一道**凭据残留检查** —— 任务跑完删目录后,clone 里可能
// 仍留着 token(remote URL 带 token 是常见做法,`git remote -v` 就能读出来)。
//
// dry_run 默认为真:清理不可逆,默认先让人看清楚要删什么。
func (p *Plugin) handleWorkdirClean(args map[string]interface{}) (interface{}, error) {
orphans, err := p.listOrphanDirs()
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
dryRun := true
if v, ok := args["dry_run"].(bool); ok {
dryRun = v
}
// 凭据残留检查:扫每个孤儿目录的 git config
type residue struct {
Dir string `json:"dir"`
Issue string `json:"issue"`
}
var residues []residue
for _, name := range orphans {
dir := filepath.Join(p.programRoot(), name)
if issue := gitConfigCredentialResidue(dir); issue != "" {
residues = append(residues, residue{Dir: name, Issue: issue})
}
}
if dryRun {
return map[string]interface{}{
"status": "dry_run", "orphans": orphans, "count": len(orphans),
"credential_residue": residues,
"hint": "确认无误后用 dry_run=false 实际删除",
}, nil
}
removed := []string{}
for _, name := range orphans {
dir := filepath.Join(p.programRoot(), name)
if err := os.RemoveAll(dir); err != nil {
return map[string]interface{}{"error": fmt.Sprintf("删除 %s 失败: %v", name, err)}, nil
}
removed = append(removed, name)
}
return map[string]interface{}{
"status": "ok", "removed": removed, "count": len(removed),
"credential_residue": residues,
}, nil
}
// gitConfigCredentialResidue 检查 .git/config 里是否嵌了凭据(设计 §7.2)。
//
// 判据:remote URL 里出现 `://user:pass@` 或 `://<token>@` 形态。
// 为何直接读文件而不是 `git remote -v`:不依赖 git 可执行、且不会因
// 目录已损坏而失败。
func gitConfigCredentialResidue(dir string) string {
b, err := os.ReadFile(filepath.Join(dir, ".git", "config"))
if err != nil {
return ""
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "url = ") {
continue
}
u := strings.TrimPrefix(line, "url = ")
if hasInlineCredential(u) {
return "remote URL 内嵌凭据(设计 §7.2 禁止):" + stripTokenFromURL(u)
}
}
return ""
}
// hasInlineCredential 报告 URL 是否含 `scheme://userinfo@host` 形态的凭据。
//
// ⚠ scp 形态(`git@github.com:o/r.git`)的 user 不是凭据,不能误报 ——
// 那是 SSH 用户名,把用户正常的 ssh remote 报成"凭据残留"会让人不敢用清理功能。
func hasInlineCredential(u string) bool {
i := strings.Index(u, "://")
if i < 0 {
return false // scp 形态:git@host:path
}
rest := u[i+3:]
at := strings.Index(rest, "@")
if at < 0 {
return false
}
return rest[:at] != ""
}

View File

@ -0,0 +1,71 @@
package main
import (
"path/filepath"
"testing"
)
// ★ 核心判据(用户 2026-10-14 明确要求):
// **同一个仓库的不同分支,必然是不同的目录** —— 否则两个 agent 会共用工作树,
// 未提交改动互相污染,"独立 clone" 的前提就不成立了。
func TestSameRepoDifferentBranchesGetDistinctDirs(t *testing.T) {
root := "/data/program"
cases := []struct{ project, branch string }{
{"TrueAgent", "feat/a"},
{"TrueAgent", "feat/b"}, // ← 同仓库、不同分支:必须不同
{"TrueAgent", "main"},
{"TrueAgent", "feat/a"}, // ← 与第一条同:必须相同(可重算)
{"Other", "feat/a"}, // ← 不同仓库、同名分支:必须不同
{"TrueAgent", "feat/a/b"}, // 嵌套斜杠
{"TrueAgent", "feat.a"}, // 点(与 feat/a 不同名,应不同目录)
}
seen := map[string]string{}
for _, c := range cases {
got := branchDir(root, c.project, c.branch)
key := c.project + "|" + c.branch
if prev, dup := seen[got]; dup && prev != key {
t.Errorf("目录冲突:%s 与 %s 都得到 %s", prev, key, got)
}
seen[got] = key
}
// 明确钉住用户关心的那条
a := branchDir(root, "TrueAgent", "feat/a")
b := branchDir(root, "TrueAgent", "feat/b")
if a == b {
t.Fatalf("同仓库不同分支得到同一目录:%s", a)
}
// 可重算:同输入必同输出(清理孤儿目录依赖这一点)
if branchDir(root, "TrueAgent", "feat/a") != a {
t.Fatal("目录名不可重算")
}
}
// 目录名形态:<hash>-<name>,且 name 可读(含分支信息)。
func TestDirNameShape(t *testing.T) {
d := dirName("TrueAgent", "feat/cool-thing")
hash, name, ok := parseDirName(d)
if !ok {
t.Fatalf("目录名 %q 不可解析", d)
}
if len(hash) != identityHashLen {
t.Errorf("hash 位数不对: %q", hash)
}
if name != "feat_cool-thing" {
t.Errorf("name 应可读(分支派生): %q", name)
}
// 目录内的路径应落在 program/ 下
if filepath.Dir(branchDir("/x/program", "P", "b")) != "/x/program" {
t.Error("目录未落在 program/ 下")
}
}
// 非法字符被派生掉(目录名必须能安全用于文件系统与通道名)。
func TestDirNameSanitized(t *testing.T) {
for _, br := range []string{"feat/a b", "中文", "a.b", "a/b"} {
d := dirName("P", br)
if !validName(d) {
t.Errorf("分支 %q 派生的目录名 %q 含非法字符", br, d)
}
}
}

View File

@ -0,0 +1,514 @@
//go:build zcode_e2e
// ZCode 后端的**真机**端到端判据。
//
// 与 pi 的 e2e 同一形状,但驱动的是**随包分发的** driver.json + sidecar.mjs
// 与**真实 ZCode CLI** —— 而不是我的临时 harness。要求真实改动工程文件,
// 且不破坏原有代码。
//
// 跑法(需要本机已能跑通 zcode):
//
// HA_ZCODE_CLI=<zcode.cjs 路径> \
// HA_ZCODE_DATA_DIR=<zcode 数据目录> \
// go test -tags zcode_e2e -run TestZcodeFullPipeline -v .
package main
import (
"bufio"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func zcodeE2EReady(t *testing.T) (nodeBin, cliPath, dataDir string) {
t.Helper()
cliPath = os.Getenv("HA_ZCODE_CLI")
dataDir = os.Getenv("HA_ZCODE_DATA_DIR")
if cliPath == "" || dataDir == "" {
t.Skip("需要 HA_ZCODE_CLI 与 HA_ZCODE_DATA_DIR(真实 zcode 环境)")
}
if _, err := os.Stat(cliPath); err != nil {
t.Skipf("HA_ZCODE_CLI 不存在: %v", err)
}
for _, c := range []string{"node", "nodejs"} {
if p, err := exec.LookPath(c); err == nil {
nodeBin = p
break
}
}
if nodeBin == "" {
t.Skip("本机无 node")
}
return
}
// TestZcodeFullPipeline:登记 → 开线 → 准备目录 → 用**随包 driver**起 zcode
// → 投递 → 断言真改了工程文件 → 收尾。
func TestZcodeFullPipeline(t *testing.T) {
nodeBin, cliPath, dataDir := zcodeE2EReady(t)
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)
}
if err := os.WriteFile(filepath.Join(origin, "src/calc.py"),
[]byte("def add(a, b):\n return a + b\n"), 0o644); err != nil {
t.Fatal(err)
}
gitQ(t, origin, "add", "-A")
gitQ(t, origin, "commit", "-qm", "initial")
p := newFlowPlugin(t)
r1, _ := p.handleProjectAdd(map[string]interface{}{
"name": "zdemo", "remote_url": origin, "default_branch": "main",
})
if m := r1.(map[string]interface{}); m["status"] != "ok" {
t.Fatalf("登记项目失败: %v", m)
}
r2, _ := p.handleBranchOpen(map[string]interface{}{
"project": "zdemo", "branch": "feat/z", "work_desc": "加乘",
})
m2 := r2.(map[string]interface{})
if m2["status"] != "ok" {
t.Fatalf("开线失败: %v", m2)
}
branchID := m2["branch_id"].(string)
workdir := m2["workdir"].(string)
if err := p.prepareWorkdir(p.ledger.Projects["zdemo"], "feat/z", workdir); err != nil {
t.Fatalf("目录准备失败: %v", err)
}
// ★ 用**随包分发**的 driver.json 解析描述(不是测试里手拼参数)
rawRoot := filepath.Join(mustAbs(t, "."), "rawfile")
pz := newTestPluginWithRawfiles(t, rawRoot)
pz.path = filepath.Join(base, "commondev") // 用户目录空 → 强制走随包
drv, cliDir, err := pz.loadDriver("zcode")
if err != nil {
t.Fatalf("★ 随包 zcode 驱动解析失败: %v", err)
}
// cliPath 指向随包位置(不存在),本判据用 HA_ZCODE_CLI 覆盖成本机真实路径
script, err := pz.locateSidecarScript(drv, cliDir, "zcode")
if err != nil {
t.Fatalf("★ 随包 sidecar 脚本定位失败: %v", err)
}
t.Logf("随包解析:script=%s cliPath=%s", script, drv.CLIPath)
ctx := driverCtx{
Workdir: workdir, SessionID: "br-" + branchID,
SessionName: "br-" + branchID, DataDir: dataDir,
PluginDir: filepath.Dir(rawRoot), RawfileDir: rawRoot,
}
argv := drv.buildArgs(ctx)
argv = append(argv, "--cli", cliPath)
cmd := exec.Command(nodeBin, append([]string{script}, argv...)...)
cmd.Dir = workdir
env := append(os.Environ(), drv.buildEnv(ctx)...)
env = append(env, "ZCODE_DATA_BASE_DIR="+dataDir)
cmd.Env = env
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("启动 sidecar 失败: %v", err)
}
defer func() { _ = cmd.Process.Kill(); _, _ = cmd.Process.Wait() }()
w := bufio.NewWriter(stdin)
rd := bufio.NewReader(stdout)
if ping := callSidecar(t, w, rd, 1, "ping", nil); ping.Error != nil {
t.Fatalf("握手失败: %s", ping.Error.Message)
}
resp := callSidecar(t, w, rd, 2, "prompt", map[string]interface{}{
"session_id": "br-" + 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)
}
sid, _ := pr["session_id"].(string)
if !strings.HasPrefix(sid, "sess_") {
t.Fatalf("★ 未回报真实 ZCode 会话 id: %q", sid)
}
t.Logf("zcode 会话: %s", sid)
// ★ 关键断言:真的改了**这个工程**的文件,且没破坏原代码
b2, err := os.ReadFile(filepath.Join(workdir, "src", "calc.py"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b2), "multiply") {
t.Fatalf("★ zcode 没有在工程里加 multiply:\n%s", b2)
}
if !strings.Contains(string(b2), "def add") {
t.Errorf("★ 原有代码被破坏:\n%s", b2)
}
}
func mustAbs(t *testing.T, p string) string {
t.Helper()
a, err := filepath.Abs(p)
if err != nil {
t.Fatal(err)
}
return a
}
// TestZcodeOverSSH:**同一套随包 driver/script 走 SSH 远端**。
//
// 用户的要求是"本地 + 通过 ssh 支持的远程 zcodecli"。传输层早就与后端正交
// (Runtime×AgentCLI),但当时**没有任何判据证明二者能组合**:
// `pushSidecar` 只推脚本、`--cli` 也没法分别声明本地/远端路径。
//
// 这条判据用 localhost 做真实 ssh 传输(真 ssh 客户端、BatchMode、scp),
// 把随包的 driver.json + sidecar.mjs 推到远端目录,再在**远端**用真实
// ZCode CLI 跑一轮并要求它改动工程文件。
func TestZcodeOverSSH(t *testing.T) {
if os.Getenv("HA_SSH_E2E") != "1" {
t.Skip("设 HA_SSH_E2E=1 以运行(需要 sshd + localhost 免密)")
}
nodeBin, cliPath, dataDir := zcodeE2EReady(t)
_ = nodeBin
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)
}
if err := os.WriteFile(filepath.Join(origin, "src/calc.py"),
[]byte("def add(a, b):\n return a + b\n"), 0o644); err != nil {
t.Fatal(err)
}
gitQ(t, origin, "add", "-A")
gitQ(t, origin, "commit", "-qm", "initial")
p := newFlowPlugin(t)
if r, _ := p.handleProjectAdd(map[string]interface{}{
"name": "sshz", "remote_url": origin, "default_branch": "main",
}); r.(map[string]interface{})["status"] != "ok" {
t.Fatalf("登记失败: %v", r)
}
r2, _ := p.handleBranchOpen(map[string]interface{}{
"project": "sshz", "branch": "feat/ssh", "work_desc": "远程",
"runtime": "ssh", "ssh_host": "localhost",
})
m2 := r2.(map[string]interface{})
if m2["status"] != "ok" {
t.Fatalf("开 SSH 线失败: %v", m2)
}
branchID := m2["branch_id"].(string)
// 远端目录:既是运行时根,也是脚本落地处
remoteRoot := filepath.Join(base, "remote-runtime")
remoteWork := filepath.Join(base, "remote-work")
if err := os.MkdirAll(remoteWork, 0o755); err != nil {
t.Fatal(err)
}
// ★ 走**真实** loadDriver + locateSidecarScript(随包来源)
rawRoot := filepath.Join(mustAbs(t, "."), "rawfile")
pz := newTestPluginWithRawfiles(t, rawRoot)
pz.path = filepath.Join(base, "commondev")
drv, cliDir, err := pz.loadDriver("zcode")
if err != nil {
t.Fatalf("随包 zcode 驱动解析失败: %v", err)
}
target := sshTarget{Host: "localhost", Port: 0, NodePath: nodeBin}
if err := pz.probeSSH(target); err != nil {
t.Skipf("ssh 不可用: %v", err)
}
// pushSidecar 推的是 drv.Script —— 先把随包脚本放进 cwd 让它可以被推
stage := t.TempDir()
localScript, err := pz.locateSidecarScript(drv, cliDir, "zcode")
if err != nil {
t.Fatalf("随包脚本定位失败: %v", err)
}
src, err := os.ReadFile(localScript)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(stage, drv.Script), src, 0o644); err != nil {
t.Fatal(err)
}
if err := pz.pushSidecar(target, drv, stage, remoteRoot); err != nil {
t.Fatalf("★ 推送随包脚本失败: %v", err)
}
// 远端脚本必须真的存在
if out, err := exec.Command("ssh", "-o", "BatchMode=yes", "localhost",
"test -f "+remoteRoot+"/"+drv.Script+" && echo OK").Output(); err != nil ||
!strings.Contains(string(out), "OK") {
t.Fatalf("★ 远端没有 sidecar 脚本: %v %s", err, out)
}
// ★ 在**远端**跑:node <remote script> --workdir <remote work> ... --cli <远端 cli>
// 本机与 localhost 是同一文件系统,故 cliPath/dataDir 直接复用。
ctx := driverCtx{
Workdir: remoteWork, SessionID: "br-" + branchID,
SessionName: "br-" + branchID, DataDir: dataDir,
PluginDir: filepath.Dir(rawRoot), RawfileDir: rawRoot,
}
argv := drv.buildArgs(ctx)
argv = append(argv, "--cli", cliPath)
remoteCmd := "ZCODE_DATA_BASE_DIR=" + dataDir + " " + nodeBin + " " +
remoteRoot + "/" + drv.Script + " " + shellJoin(argv)
full := append(target.sshBaseArgs(), "localhost", remoteCmd)
cmd := exec.Command("ssh", full...)
cmd.Dir = remoteWork
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("远端启动失败: %v", err)
}
defer func() { _ = cmd.Process.Kill(); _, _ = cmd.Process.Wait() }()
w := bufio.NewWriter(stdin)
rd := bufio.NewReader(stdout)
if ping := callSidecar(t, w, rd, 1, "ping", nil); ping.Error != nil {
t.Fatalf("远端握手失败: %s", ping.Error.Message)
}
resp := callSidecar(t, w, rd, 2, "prompt", map[string]interface{}{
"session_id": "br-" + branchID,
"text": "在 src/calc.py 里新增 multiply(a, b) 返回 a*b,保留 add。只回一句完成。",
})
if resp.Error != nil {
t.Fatalf("远端投递失败: %s", resp.Error.Message)
}
b2, err := os.ReadFile(filepath.Join(remoteWork, "src", "calc.py"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b2), "multiply") {
t.Fatalf("★ 远端 zcode 没有改文件:\n%s", b2)
}
if !strings.Contains(string(b2), "def add") {
t.Errorf("★ 远端原有代码被破坏:\n%s", b2)
}
}
func shellJoin(args []string) string {
parts := make([]string, 0, len(args))
for _, a := range args {
parts = append(parts, shellQuote(a))
}
return strings.Join(parts, " ")
}
// TestZcodeOverSSHWithPerHostEndpoints:**两台"机器"各配各的 CLI 路径**,
// 走端点表而不是全局 settings —— 这是用户提的需求本体。
//
// 用两个 host 别名指向同一个 sshd(`localhost` 与 `127.0.0.1`),
// 但配置**不同的** CLI 路径与 runtime 目录,从而证明:
//
// · 每台机器的路径确实分别生效(不是被后配的覆盖)
// · 发布到远端的 --cli 是该机器自己那条
//
// 为什么用别名而不是真两台机器:判据要能在 CI/本机跑。别名走的是
// **同一条真实 ssh 代码路径**(解析 host → 端点表 → ssh → scp → 远端 node),
// 与真两台机器的差别只在网络延迟,不足以掩盖端点选错这类缺陷。
func TestZcodeOverSSHWithPerHostEndpoints(t *testing.T) {
if os.Getenv("HA_SSH_E2E") != "1" {
t.Skip("设 HA_SSH_E2E=1 以运行(需要 sshd + localhost 免密)")
}
nodeBin, cliPath, dataDir := zcodeE2EReady(t)
base := t.TempDir()
// 两个"机器"各自的 runtime 目录(互不相干)
rtA := filepath.Join(base, "rtA")
rtB := filepath.Join(base, "rtB")
workA := filepath.Join(base, "workA")
workB := filepath.Join(base, "workB")
for _, d := range []string{rtA, rtB, workA, workB} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
// 机器 B 上放一个**不同的** CLI 入口(软链到真 CLI,路径不同即为不同)
cliB := filepath.Join(base, "zcode-on-B.cjs")
if err := os.Symlink(cliPath, cliB); err != nil {
t.Fatal(err)
}
// ① 端点表:两台机器各自的路径
p := newTestPluginForShell(t)
if err := p.loadEndpoints(); err != nil {
t.Fatal(err)
}
// pushSidecar 从 <插件数据>/runtime/<cli>/<script> 取本地脚本 ——
// 放真实随包脚本进去(推的必须是真脚本,否则远端跑的是空文件)。
{
src, err := os.ReadFile(filepath.Join(mustAbs(t, "."), "rawfile", "runtime", "zcode", "sidecar.mjs"))
if err != nil {
t.Fatalf("读随包 sidecar 失败: %v", err)
}
d := p.driverDir("zcode")
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "sidecar.mjs"), src, 0o644); err != nil {
t.Fatal(err)
}
}
for _, c := range []struct {
host, cliPath, rt string
}{
{"localhost", cliPath, rtA},
{"127.0.0.1", cliB, rtB},
} {
r, _ := p.handleEndpointSet(map[string]interface{}{
"host": c.host, "cli": "zcode", "cli_path": c.cliPath,
"runtime_root": c.rt, "node_path": nodeBin, "verify": false,
})
if e := r.(map[string]interface{})["error"]; e != nil {
t.Fatalf("配 %s 失败: %v", c.host, e)
}
}
// ② 解析必须各归各(这是"多机不互相干扰"的核心断言)
drv := driverSpec{Script: "sidecar.mjs", Args: []string{"--workdir", "{workdir}"}}
ctx := driverCtx{Workdir: workA, RawfileDir: "/rf", PluginDir: "/p", DataDir: "/d"}
ea := p.resolveEndpoint("localhost", "zcode", drv, ctx)
eb := p.resolveEndpoint("127.0.0.1", "zcode", drv, ctx)
if ea.CLIPath != cliPath || ea.RuntimeRoot != rtA {
t.Errorf("★ localhost 端点串了: %+v", ea)
}
if eb.CLIPath != cliB || eb.RuntimeRoot != rtB {
t.Errorf("★ 127.0.0.1 端点串了: %+v", eb)
}
if ea.CLIPath == eb.CLIPath {
t.Error("★ 两台机器的 CLI 路径不该相同(配置未分别生效)")
}
// ③ 真跑:两台机器各自用**自己的** CLI 路径完成任务
for _, tc := range []struct {
host, cliPath, rt, wd, proj string
}{
{"localhost", cliPath, rtA, workA, "hA"},
{"127.0.0.1", cliB, rtB, workB, "hB"},
} {
origin := filepath.Join(base, "origin-"+tc.proj)
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.handleProjectAdd(map[string]interface{}{
"name": tc.proj, "remote_url": origin, "default_branch": "main",
})
b := &Branch{ID: "br-" + tc.proj, Name: "feat/x", ProjectID: tc.proj,
Runtime: "ssh", SSHHost: tc.host, Workdir: tc.wd}
tgt, err := parseSSHTarget(b)
if err != nil {
t.Fatal(err)
}
if err := p.probeSSH(tgt); err != nil {
t.Skipf("%s 不可用: %v", tc.host, err)
}
// 准备该机器的工作目录(本判据聚焦端点解析与远端执行,
// 目录准备本身已由 workdir_remote_test.go 覆盖):
// 直接 clone origin 到该机器的 workdir,并切到目标分支。
if err := os.RemoveAll(tc.wd); err != nil {
t.Fatal(err)
}
gitQ(t, base, "clone", "-q", origin, tc.wd)
gitQ(t, tc.wd, "checkout", "-qb", "feat/x")
// 推脚本 + 拼远端命令(走真实代码路径)
if err := p.pushSidecar(tgt, drv, p.driverDir("zcode"), tc.rt); err != nil {
t.Fatalf("%s 推脚本失败: %v", tc.host, err)
}
// ★ ctx.Workdir 必须**按该机器**重置:它是 {workdir} 占位符的来源,
// 共用一个会让后一台机器把文件改到前一台的目录里
// (本判据第一版就踩了:两台都跑在 workA)。
ctx.Workdir = tc.wd
ep := p.resolveEndpoint(tc.host, "zcode", drv, ctx)
_, args, env := buildRemoteCommandFor(drv, ctx, ep.NodePath, ep)
joined := strings.Join(args, " ")
if !strings.Contains(joined, tc.cliPath) {
t.Errorf("★ %s 的远端命令未使用本机专属 CLI 路径: %v", tc.host, args)
}
if tc.host == "127.0.0.1" && strings.Contains(joined, rtA) {
t.Errorf("★ 127.0.0.1 的命令里混入了 localhost 的路径: %v", args)
}
// ★★ 真跑:在**该机器**上执行解析出来的命令,并断言它真的改了文件。
//
// 只比字符串的话,"端点选对了但路径在目标机器上不存在"这类缺陷
// 照样全绿 —— 而那正是本需求要防的东西(每台机器路径不同)。
remoteCmd := "ZCODE_DATA_BASE_DIR=" + dataDir + " " + ep.NodePath + " " +
tc.rt + "/" + drv.Script + " " + shellJoin(args)
sshArgv := append(tgt.sshBaseArgs(), tgt.Host, remoteCmd)
rc := exec.Command("ssh", sshArgv...)
rc.Dir = tc.wd
rstdin, err := rc.StdinPipe()
if err != nil {
t.Fatal(err)
}
rstdout, err := rc.StdoutPipe()
if err != nil {
t.Fatal(err)
}
rc.Stderr = os.Stderr
if err := rc.Start(); err != nil {
t.Fatalf("%s 远端启动失败: %v", tc.host, err)
}
rw := bufio.NewWriter(rstdin)
rrd := bufio.NewReader(rstdout)
if ping := callSidecar(t, rw, rrd, 1, "ping", nil); ping.Error != nil {
_ = rc.Process.Kill()
t.Fatalf("%s 远端握手失败: %s", tc.host, ping.Error.Message)
}
resp := callSidecar(t, rw, rrd, 2, "prompt", map[string]interface{}{
"session_id": "br-" + tc.proj,
"text": "在 src/calc.py 里新增 multiply(a, b) 返回 a*b,保留 add。只回一句完成。",
})
_ = rc.Process.Kill()
_, _ = rc.Process.Wait()
if resp.Error != nil {
t.Fatalf("★ %s 投递失败(端点表里的 CLI 路径可能在该机器上不存在): %s",
tc.host, resp.Error.Message)
}
b2, err := os.ReadFile(filepath.Join(tc.wd, "src/calc.py"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b2), "multiply") {
t.Errorf("★ %s 上 zcode 没有改文件:\n%s", tc.host, b2)
}
if !strings.Contains(string(b2), "def add") {
t.Errorf("★ %s 上原有代码被破坏:\n%s", tc.host, b2)
}
_ = env
}
}