mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 06:17:25 +00:00
feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode) (#3)
* feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode)
## 是什么
agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。
38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。
## sidecar 后端抽象(本轮核心)
传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:
driver.json 描述一个后端:script / cliPath / env / args / llmConfig
三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}
★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。
默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
· 单线:commondev_branch_open(cli="zcode")
· 按机器:commondev_endpoint_set(host=..., cli="zcode")
## zcode 接入(三条真机判据,全部要求真改工程文件)
· TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
· TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
· TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
cli_path,走端点表而不是全局 settings
★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。
## 其他
· rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
· 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
· secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
· 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)
判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。
* fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染
## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道
子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:
[proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'
**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。
修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
+ 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法
判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。
## hmapdev
- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
**导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)
## 判据
- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
(第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据
## 其他
- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)
* fix(examples): 恢复 go.mod 相对路径 + 移除 plg.json 的 sdk 固定(修 CI 红)
CI 上 `Examples (linux/amd64)` 与 `(darwin/arm64)` 双红,只有两个失败:
✗ commondev 构建失败
✗ editdoc 构建失败
error: 项目声明 sdk=1.4.0,但本地 SDK 存储里没有这个版本
其余 20 个示例全部成功 —— 说明不是通用问题,而是这两个被改坏了。
## 根因(两个叠加)
**① go.mod 被本机绝对路径污染**
我在上一个提交里用 `git add -A`,把本机 `go build` **自动改写**过的
go.mod 一并提交了:
require github.com/JianFeeeee/homeagentsdk v1.4.0
replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0
基线(origin/main)是:
require github.com/JianFeeeee/homeagentsdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../
本机那个目录真的存在 ⇒ 本地全绿;CI 没有 ⇒ 必红。
**② plg.json 声明了 `"sdk": "1.4.0"`**
一声明版本,hmapdev 就去**本地 SDK store** 找它,找不到直接拒绝构建。
全仓 20+ 示例**都不声明**,只有我改过的这两个声明了。
示例的正确依赖表达是 go.mod 的 `v0.0.0` + `replace ../../`。
## 修法
两个文件都恢复到与其余示例一致的写法。
## 判据(3 条,都是新加的)
· `TestExampleGoModHasNoAbsoluteReplace`:任何示例 go.mod 不得含
`/root/`、`/home/`、`/Users/`、`C:\`
· `TestExampleGoModUsesRelativeSDKReplace`:replace 必须是相对路径 `../../`
· `TestExamplePlgJsonDoesNotPinSDKVersion`:plg.json 不得声明 sdk
变异 2/2 咬住(重新引入绝对路径 / 重新声明 sdk,都准确报出文件路径)。
## 本地验证(模拟 CI 干净环境)
用干净 HOME 跑 `package/build-examples.sh`:
· 修复前:成功 18 / 失败 4(commondev, editdoc, browser, rss)
· 修复后:成功 20 / 失败 2(browser, rss —— **本机代理不通**,
`proxy.golang.org` 超时;用本地 modcache 验证这两个可正常构建)
即 22/22 源码都能构建,CI 有网络不受影响。
## 教训
`git add -A` 会把「构建工具自动改写的文件」一起提交。
提交前必须 `git status` 逐个看,尤其 go.mod / go.sum。
---------
Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
This commit is contained in:
@ -17,15 +17,15 @@ import (
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
client *http.Client
|
||||
apiKey string
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
client *http.Client
|
||||
apiKey string
|
||||
provider string
|
||||
model string
|
||||
size string
|
||||
baseURL string
|
||||
dataDir string // <data>/ai_images:生成本地图片存放目录
|
||||
model string
|
||||
size string
|
||||
baseURL string
|
||||
dataDir string // <data>/ai_images:生成本地图片存放目录
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
@ -119,12 +119,12 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "base_url", Default: "", Type: "string",
|
||||
DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081);为空走官方 https://api.openai.com",
|
||||
Category: "ai_image",
|
||||
Category: "ai_image",
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "provider", Default: "openai", Type: "string",
|
||||
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
|
||||
Category: "ai_image",
|
||||
Category: "ai_image",
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "model", Default: "dall-e-3", Type: "string",
|
||||
@ -143,17 +143,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.size = getSetting(s.Settings(), "size", "1024x1024")
|
||||
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
|
||||
|
||||
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
|
||||
if p.sdk != nil {
|
||||
if dd := s.Settings().DataDir(); dd != "" {
|
||||
p.dataDir = dd
|
||||
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
|
||||
if p.sdk != nil {
|
||||
if dd := s.Settings().DataDir(); dd != "" {
|
||||
p.dataDir = dd
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.dataDir == "" {
|
||||
// 旧版内核无 DataDir API 时退到 /tmp
|
||||
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
|
||||
}
|
||||
os.MkdirAll(p.dataDir, 0755)
|
||||
if p.dataDir == "" {
|
||||
// 旧版内核无 DataDir API 时退到 /tmp
|
||||
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
|
||||
}
|
||||
os.MkdirAll(p.dataDir, 0755)
|
||||
|
||||
tp := p.name + "_"
|
||||
s.RegisterTool(tp+"generate", sdk.ToolDef{
|
||||
@ -170,12 +170,12 @@ os.MkdirAll(p.dataDir, 0755)
|
||||
},
|
||||
}, p.handleGenerate)
|
||||
|
||||
fmt.Printf("[%s] started (provider=%s, model=%s)\n", p.name, p.provider, p.model)
|
||||
log.Printf("[%s] started (provider=%s, model=%s)", p.name, p.provider, p.model)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
fmt.Printf("[%s] stopped\n", p.name)
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@ -285,7 +286,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar")
|
||||
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
|
||||
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
|
||||
log.Printf("[%s] mkdir %s: %v", p.name, p.dataDir, err)
|
||||
}
|
||||
p.loadEvents()
|
||||
|
||||
@ -401,7 +402,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.wg.Add(1)
|
||||
go p.remindLoop()
|
||||
|
||||
fmt.Printf("[%s] started (%d events)\n", p.name, len(p.events))
|
||||
log.Printf("[%s] started (%d events)", p.name, len(p.events))
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -409,7 +410,7 @@ func (p *Plugin) Stop() error {
|
||||
p.remindTicker.Stop()
|
||||
close(p.stopCh)
|
||||
p.wg.Wait()
|
||||
fmt.Printf("[%s] stopped\n", p.name)
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -611,9 +612,9 @@ func (p *Plugin) cleanupData() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) {
|
||||
fmt.Printf("[calendar] onRemove cleanup: %v\n", err)
|
||||
log.Printf("[calendar] onRemove cleanup: %v", err)
|
||||
} else {
|
||||
fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile())
|
||||
log.Printf("[calendar] onRemove removed %s", p.eventsFile())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
28
example/commondev/README.md
Normal file
28
example/commondev/README.md
Normal 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.
|
||||
222
example/commondev/addressing_test.go
Normal file
222
example/commondev/addressing_test.go
Normal 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("★ 会话漂移应明确拒绝,而非仅记录")
|
||||
}
|
||||
}
|
||||
338
example/commondev/alignment_test.go
Normal file
338
example/commondev/alignment_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
103
example/commondev/async_test.go
Normal file
103
example/commondev/async_test.go
Normal 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()
|
||||
}
|
||||
216
example/commondev/audit_test.go
Normal file
216
example/commondev/audit_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
84
example/commondev/channelname_test.go
Normal file
84
example/commondev/channelname_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
400
example/commondev/conflict.go
Normal file
400
example/commondev/conflict.go
Normal 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
|
||||
}
|
||||
335
example/commondev/conflict_parse_test.go
Normal file
335
example/commondev/conflict_parse_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
184
example/commondev/credentials.go
Normal file
184
example/commondev/credentials.go
Normal 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")
|
||||
}
|
||||
767
example/commondev/criteria.go
Normal file
767
example/commondev/criteria.go
Normal 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
|
||||
}
|
||||
378
example/commondev/criteria_test.go
Normal file
378
example/commondev/criteria_test.go
Normal 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 下变异被正确咬住(未受缓存影响)")
|
||||
}
|
||||
1
example/commondev/e2e-agentdir/auth.json
Normal file
1
example/commondev/e2e-agentdir/auth.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
1
example/commondev/e2e-agentdir/models-store.json
Normal file
1
example/commondev/e2e-agentdir/models-store.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
177
example/commondev/e2e_criteria_test.go
Normal file
177
example/commondev/e2e_criteria_test.go
Normal 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")
|
||||
}
|
||||
429
example/commondev/encshell.go
Normal file
429
example/commondev/encshell.go
Normal 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
|
||||
243
example/commondev/encshell_test.go
Normal file
243
example/commondev/encshell_test.go
Normal 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())
|
||||
}
|
||||
408
example/commondev/encstore.go
Normal file
408
example/commondev/encstore.go
Normal 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) }
|
||||
339
example/commondev/encstore_test.go
Normal file
339
example/commondev/encstore_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
637
example/commondev/endpoints.go
Normal file
637
example/commondev/endpoints.go
Normal 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
|
||||
}
|
||||
388
example/commondev/endpoints_test.go
Normal file
388
example/commondev/endpoints_test.go
Normal 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 ""
|
||||
}
|
||||
217
example/commondev/examples/custom-cli/README.md
Normal file
217
example/commondev/examples/custom-cli/README.md
Normal 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`。
|
||||
22
example/commondev/examples/custom-cli/zcode/driver.json
Normal file
22
example/commondev/examples/custom-cli/zcode/driver.json
Normal 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"
|
||||
}
|
||||
287
example/commondev/examples/custom-cli/zcode/sidecar.mjs
Normal file
287
example/commondev/examples/custom-cli/zcode/sidecar.mjs
Normal 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);
|
||||
});
|
||||
368
example/commondev/flow_coverage_test.go
Normal file
368
example/commondev/flow_coverage_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
190
example/commondev/full_pipeline_test.go
Normal file
190
example/commondev/full_pipeline_test.go
Normal 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"])
|
||||
}
|
||||
7
example/commondev/go.mod
Normal file
7
example/commondev/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module commondev
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/JianFeeeee/homeagentsdk v0.0.0
|
||||
|
||||
replace github.com/JianFeeeee/homeagentsdk => ../../
|
||||
447
example/commondev/llmsource.go
Normal file
447
example/commondev/llmsource.go
Normal 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,
|
||||
}
|
||||
}
|
||||
352
example/commondev/llmsource_e2e_test.go
Normal file
352
example/commondev/llmsource_e2e_test.go
Normal 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
11
example/commondev/main.go
Normal 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)
|
||||
}
|
||||
273
example/commondev/pi_e2e_test.go
Normal file
273
example/commondev/pi_e2e_test.go
Normal 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))
|
||||
}
|
||||
387
example/commondev/pi_runtime_test.go
Normal file
387
example/commondev/pi_runtime_test.go
Normal 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 里仍有写旧格式的地方")
|
||||
}
|
||||
}
|
||||
}
|
||||
664
example/commondev/pi_tools.go
Normal file
664
example/commondev/pi_tools.go
Normal 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
|
||||
}
|
||||
12
example/commondev/plg.json
Normal file
12
example/commondev/plg.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "commondev",
|
||||
"name_zh": "多仓库开发主管",
|
||||
"name_en": "CommonDev",
|
||||
"version": "0.1.0",
|
||||
"description": "主管式多仓库开发流水线:登记项目、开工程对话、为每条线拉起内置 pi 并注册一对通道,投递任务给 pi 并在同一会话内续投。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["commondev", "git", "agent", "develop"],
|
||||
"targets": "linux/amd64",
|
||||
"rawfiles": []
|
||||
}
|
||||
1585
example/commondev/plugin.go
Normal file
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
10
example/commondev/rawfile/.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
# rawfile/ 里的是随包资源,由构建方自行准备(下载/拷贝),不进版本库。
|
||||
#
|
||||
# 为什么用 .gitignore 而不是上层规则:资源可能内嵌 node/pi 等
|
||||
# 上百兆二进制,一个克隆带上它们既慢又每次升级产生巨型 diff。
|
||||
#
|
||||
# 保留:本文件与 .gitkeep(目录骨架要留在版本库里,
|
||||
# 模板工程与 hmapdev init 依赖它存在)。
|
||||
*
|
||||
!.gitignore
|
||||
!.gitkeep
|
||||
0
example/commondev/rawfile/.gitkeep
Normal file
0
example/commondev/rawfile/.gitkeep
Normal file
14
example/commondev/runtime/pi/driver.json
Normal file
14
example/commondev/runtime/pi/driver.json
Normal 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() 必须逐字一致(判据会比对)"
|
||||
}
|
||||
590
example/commondev/sidecar.go
Normal file
590
example/commondev/sidecar.go
Normal 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)
|
||||
}
|
||||
173
example/commondev/sidecar.mjs
Normal file
173
example/commondev/sidecar.mjs
Normal 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);
|
||||
});
|
||||
331
example/commondev/sidecar_driver.go
Normal file
331
example/commondev/sidecar_driver.go
Normal 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
|
||||
297
example/commondev/sidecar_driver_test.go
Normal file
297
example/commondev/sidecar_driver_test.go
Normal 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
|
||||
345
example/commondev/sidecar_ssh.go
Normal file
345
example/commondev/sidecar_ssh.go
Normal 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
|
||||
}
|
||||
210
example/commondev/sidecar_ssh_test.go
Normal file
210
example/commondev/sidecar_ssh_test.go
Normal 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
3
example/commondev/skill/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# skill/ 里的内容是随包分发的纯 SKILL 条目。
|
||||
# 它们是真实需要版本控制的文档(不像 rawfile/ 下可能有上百兆二进制),
|
||||
# 所以这里**不忽略**任何内容,只保留本说明。
|
||||
855
example/commondev/skill/commondev-discipline/SKILL.md
Normal file
855
example/commondev/skill/commondev-discipline/SKILL.md
Normal 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 的源码** —— 先确认它是不是内置插件。
|
||||
6
example/commondev/skill/commondev-discipline/skill.json
Normal file
6
example/commondev/skill/commondev-discipline/skill.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "commondev-discipline",
|
||||
"description": "commondev 主管式多仓库开发流水线的使用与开发纪律:为每个工程对话新建驻留子并划入其通道、投递必须落同一 session、复核必须含变异阶段;以及 hmap 插件开发纪律(plg.json、SDK 版本、产物形态、Serial/ParallelSafe、外部插件能力边界、凭据与孤儿清理)",
|
||||
"version": "1.0.0",
|
||||
"author": "HomeAgent"
|
||||
}
|
||||
252
example/commondev/ssh_e2e_test.go
Normal file
252
example/commondev/ssh_e2e_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
808
example/commondev/ssh_wiring_test.go
Normal file
808
example/commondev/ssh_wiring_test.go
Normal 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
72
example/commondev/util.go
Normal 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
|
||||
}
|
||||
154
example/commondev/workdir.go
Normal file
154
example/commondev/workdir.go
Normal 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
|
||||
}
|
||||
235
example/commondev/workdir_remote.go
Normal file
235
example/commondev/workdir_remote.go
Normal 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
|
||||
}
|
||||
400
example/commondev/workdir_remote_test.go
Normal file
400
example/commondev/workdir_remote_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
172
example/commondev/workdir_security.go
Normal file
172
example/commondev/workdir_security.go
Normal 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] != ""
|
||||
}
|
||||
71
example/commondev/workdir_test.go
Normal file
71
example/commondev/workdir_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
514
example/commondev/zcode_e2e_test.go
Normal file
514
example/commondev/zcode_e2e_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -1,49 +1,62 @@
|
||||
# editdoc · Office 文档编辑
|
||||
# editdoc · 全能办公
|
||||
|
||||
编辑 `.docx` / `.xlsx` / `.pptx` 内容:查找替换、改单元格、插行。
|
||||
对 `.docx` / `.xlsx` / `.pptx` / `.xls` / `.csv` / `.md` / `.txt` 做**新建、读取、编辑、转换**。
|
||||
|
||||
> ⚠️ **版本说明**:本目录是 **v1.0.0**,只有 `edit_document` 一个工具。
|
||||
> 线上部署的 v2.0.0(全能办公版,支持新建/读取/转换 docx·xlsx·pptx·md·csv·txt)
|
||||
> **源码尚未公开**,本文档不描述那些能力。参见 `plugin.json` 的 `version`。
|
||||
> **版本**:v2.0.0(全能办公)。较 v1.0.0 新增 `read_document`、`create_document`、
|
||||
> `create_from_template`、`convert_document`、`office_check` 五个工具;
|
||||
> `edit_document` 的名字与全部 v1 参数**保持不变**,老调用不会破。
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 说明 |
|
||||
| 工具 | 作用 |
|
||||
|---|---|
|
||||
| `edit_document` | 编辑文档内容,**编辑后原文件被覆盖** |
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `file` | 文档路径(必填) |
|
||||
| `operation` | `replace_text`(查找替换)/ `set_cell`(设置单元格)/ `insert_row`(插入行)(必填) |
|
||||
| `target` | 要查找的文本(`replace_text` 用) |
|
||||
| `replacement` | 替换为的文本(`replace_text` 用) |
|
||||
| `sheet` | 工作表名(xlsx 可选) |
|
||||
| `row` | 行号(`set_cell` / `insert_row` 用) |
|
||||
| `col` | 列号(`set_cell` 用) |
|
||||
| `value` | 单元格值(`set_cell` 用) |
|
||||
|
||||
编辑前建议先读一遍内容确认目标文本 —— 查找替换是**全文件覆盖写**,没有撤销。
|
||||
| `read_document` | 读为结构化 JSON(docx 段落+表格+页眉页脚 / xlsx 逐表逐格含公式与计算值 / pptx 逐页 / xls / csv / md / txt),大文件可 `offset`/`limit` 分段 |
|
||||
| `create_document` | 从零新建。docx 支持 Markdown 正文;xlsx 支持多表(表名+表头+行);pptx 用 `##` 分页 |
|
||||
| `create_from_template` | 复制模板再套用替换 |
|
||||
| `edit_document` | 编辑既有文件(v1 八参数 + 查找替换/正则大小写/第 N 处/表格与单元格/图片/样式/幻灯片/`dry_run`) |
|
||||
| `convert_document` | 格式转换(docx↔md/pdf、xlsx↔csv 等,借助 soffice / pandoc) |
|
||||
| `office_check` | 自检:依赖库可用性与版本、外部工具、格式×能力矩阵 |
|
||||
|
||||
## 配置项
|
||||
|
||||
| 键 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `script_path` | 空 | `edit_doc.py` 的绝对路径。留空则用插件可执行文件同目录下的 `edit_doc.py` |
|
||||
| `venv_python` | 空 | 执行 `edit_doc.py` 的 Python 解释器(建议用 venv 里的)。**必须配置,留空会报错** |
|
||||
| `script_path` | 空 | `edit_doc.py` 的绝对路径。**留空即推荐值** —— 用内嵌脚本;仅当该文件含 v2 协议标记时才被采用,否则自动忽略并回退内嵌 |
|
||||
| `venv_python` | 空 | 执行脚本的 Python 解释器(需装 python-docx / openpyxl / python-pptx)。**必须配置** |
|
||||
| `timeout_sec` | 60 | 单次调用超时;大文件或格式转换(soffice 首次启动慢)建议调到 180 |
|
||||
|
||||
## 工作原理
|
||||
|
||||
本插件是 Go 写的薄壳:把参数序列化成 JSON,交给 Python 脚本 `edit_doc.py` 执行实际文档操作。
|
||||
文档解析依赖 Python 侧的库(python-docx / openpyxl / python-pptx 之类),所以:
|
||||
Go 侧只做参数编排与结构化返回,全部业务逻辑在 Python 脚本 `edit_doc.py` 里。
|
||||
|
||||
- **需要自备 `edit_doc.py`**:它不在本目录里。
|
||||
- 用 `venv_python` 指向装了这些库的解释器,避免污染系统 Python。
|
||||
**脚本是内嵌的**(`//go:embed embed/edit_doc.py`),原因:`hmapdev` 打包器只把
|
||||
`plugin.json` + `plugin.bin` 放进 `.hmap`,**不打包任何资源文件**。若让脚本单独
|
||||
存在于 `plugin_data/`,就会出现「Go 侧升级了、脚本忘了换」的版本漂移。
|
||||
内嵌 + 内容指纹校验使两侧永远同版本:
|
||||
|
||||
1. 首次调用时把内嵌脚本释放到 `DataDir()/runtime/edit_doc.py`,并写 `.stamp` 指纹;
|
||||
2. 插件升级后指纹变化 → 自动重新释放;
|
||||
3. `script_path` 显式指向的文件**仅当含 `EDITDOC_PROTOCOL = "v2"` 标记时**才被采用
|
||||
(便于运维临时覆盖调试);否则忽略并在返回体里带 `script_path_ignored`,让降级可见。
|
||||
|
||||
**业务脚本只有一份**:`embed/edit_doc.py`。不要再添加同名的顶层副本 ——
|
||||
它不会被 embed,改了不生效,而两份同名文件是典型的漂移源(见
|
||||
`TestOnlyOneBusinessScript`)。
|
||||
|
||||
## 依赖
|
||||
|
||||
Python 侧:`python-docx`、`openpyxl`、`python-pptx`(`.xls` 另需 `xlrd`)。
|
||||
外部工具(可选,转换能力用):`soffice`(LibreOffice)、`pandoc`。
|
||||
用 `office_check` 随时查看本机实际可用的能力矩阵。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
hmapdev build
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
1390
example/editdoc/embed/edit_doc.py
Normal file
1390
example/editdoc/embed/edit_doc.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,3 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
{
|
||||
"name": "editdoc",
|
||||
"name_zh": "文档编辑",
|
||||
"name_zh": "全能办公",
|
||||
"name_en": "Document Editor",
|
||||
"version": "1.0.0",
|
||||
"description": "办公文档编辑工具(.docx),基于 Python python-docx 库实现。支持文本替换、表格操作、内容插入等。",
|
||||
"version": "2.0.0",
|
||||
"description": "全能办公插件:新建/读取/编辑/转换 docx·xlsx·pptx·md·csv·txt(基于 python-docx / openpyxl / python-pptx / soffice / pandoc)",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["editdoc", "office", "document"],
|
||||
"entry": "plugin.bin",
|
||||
"tags": [
|
||||
"editdoc",
|
||||
"office",
|
||||
"document",
|
||||
"excel",
|
||||
"word",
|
||||
"powerpoint"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
}
|
||||
@ -2,21 +2,60 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/homeagentsdk/sdk"
|
||||
)
|
||||
|
||||
// editDocPy 内嵌 Python 后端脚本。
|
||||
//
|
||||
// 为何必须内嵌:hmapdev 的打包器只把 plugin.json + plugin.bin 放进 .hmap
|
||||
// (加上可选的 README/LICENSE),**不打包任何资源文件**。
|
||||
// 而本插件的全部业务逻辑就在 edit_doc.py 里,若让它单独存在于
|
||||
// plugin_data/editdoc/,就会出现「Go 侧升级了、脚本忘了换」的版本漂移
|
||||
// (mc 插件就踩过这个坑:工具注册了但子脚本还是旧的,报未知命令)。
|
||||
// 内嵌 + 内容指纹校验可以让两侧永远同版本。
|
||||
//
|
||||
//go:embed embed/edit_doc.py
|
||||
var embeddedScript []byte
|
||||
|
||||
// scriptStamp 返回内嵌脚本的内容指纹。
|
||||
func scriptStamp() string {
|
||||
h := sha256.Sum256(embeddedScript)
|
||||
return hex.EncodeToString(h[:])[:16]
|
||||
}
|
||||
|
||||
// scriptProtocolMarker 是 edit_doc.py 里的 v2 协议标记(见 embed/edit_doc.py 顶部)。
|
||||
// resolveScript 靠它识别「script_path 指向的是不是 v2 脚本」,防止旧脚本被误用。
|
||||
const scriptProtocolMarker = `EDITDOC_PROTOCOL = "v2"`
|
||||
|
||||
// Plugin 是 editdoc v2.0.0(全能办公)插件。
|
||||
//
|
||||
// 设计要点(与 v1.0.0 的关系):
|
||||
// - edit_document 工具名与 file/operation/target/replacement/sheet/row/col/value
|
||||
// 参数语义**完全保留**,老调用不会破。
|
||||
// - 全部实现委托给同目录下的 edit_doc.py,Go 侧只做参数编排 + 结构化返回。
|
||||
// - 所有子命令统一走 runScript,错误一律结构化(ok:false + error + hint),不 panic。
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
scriptPath string
|
||||
venvPython string
|
||||
timeoutSec int
|
||||
|
||||
// ignoredScriptPath 记录「配置里的 script_path 因不是 v2 脚本而被忽略」的路径。
|
||||
// 非空时 runScript 会在返回体里带上 script_path_ignored,让上层看得见这次降级。
|
||||
ignoredScriptPath string
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
@ -25,138 +64,469 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
|
||||
// ---- 配置项(v1 的 script_path / venv_python 原样保留,新增 timeout_sec)----
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "script_path", Default: "", Type: "string",
|
||||
DisplayName: "编辑脚本路径",
|
||||
Description: "edit_doc.py 的绝对路径;留空时使用插件可执行文件同目录下的 edit_doc.py",
|
||||
Category: p.name,
|
||||
DisplayName: "办公脚本路径",
|
||||
Description: "edit_doc.py 的绝对路径;留空(推荐)时使用 DataDir()/runtime/edit_doc.py(升级时按指纹自动更新)。" +
|
||||
"仅当该文件含 v2 协议标记时才会被采用,否则自动忽略并回退内嵌脚本",
|
||||
Category: p.name,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "venv_python", Default: "", Type: "string",
|
||||
DisplayName: "venv Python 解释器",
|
||||
Description: "执行 edit_doc.py 使用的 Python 解释器(建议用 venv 内的 python);必须配置,留空将报错",
|
||||
Description: "执行 edit_doc.py 使用的 Python 解释器(需装有 python-docx/openpyxl/python-pptx);必须配置",
|
||||
Category: p.name,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "timeout_sec", Default: 60, Type: "int",
|
||||
DisplayName: "脚本超时(秒)",
|
||||
Description: "单次调用 edit_doc.py 的超时上限;大文件或格式转换(soffice 首次启动慢)建议调到 180",
|
||||
Category: p.name, Min: 5, Max: 900,
|
||||
})
|
||||
|
||||
if v, err := s.Settings().Get("script_path"); err == nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.scriptPath = str
|
||||
}
|
||||
}
|
||||
if v, err := s.Settings().Get("venv_python"); err == nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.venvPython = str
|
||||
}
|
||||
}
|
||||
s.RegisterTool("edit_document", sdk.ToolDef{
|
||||
Name: "edit_document",
|
||||
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
|
||||
NoMemory: true,
|
||||
p.reloadSettings()
|
||||
|
||||
// ---- 工具注册 ----
|
||||
// 工具名不带插件名前缀:v1 的 edit_document 就是无前缀的,保持一致性。
|
||||
|
||||
s.RegisterTool("read_document", sdk.ToolDef{
|
||||
Name: "read_document",
|
||||
Description: "读取办公文档内容并返回结构化数据。支持 .docx(段落+表格+页眉页脚)、" +
|
||||
".xlsx/.xlsm(逐表逐格,公式与计算值都给)、.pptx(逐页文本框与表格)、.xls、.csv/.md/.txt。" +
|
||||
"大文件用 offset/limit 分段读。改文件前先用它看内容。",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"file": map[string]interface{}{"type": "string", "description": "文档绝对路径"},
|
||||
"sheet": map[string]interface{}{"type": "string", "description": "xlsx:只读某张表(可选)"},
|
||||
"offset": map[string]interface{}{"type": "integer", "description": "分段读的起始偏移(docx=块序号, xlsx/pptx=页/表序号),默认 0"},
|
||||
"limit": map[string]interface{}{"type": "integer", "description": "本次最多返回多少段,默认全部"},
|
||||
"formulas": map[string]interface{}{"type": "boolean", "description": "xlsx 是否附带公式的计算值,默认 true"},
|
||||
},
|
||||
"required": []string{"file"},
|
||||
},
|
||||
}, p.handleRead)
|
||||
|
||||
s.RegisterTool("create_document", sdk.ToolDef{
|
||||
Name: "create_document",
|
||||
Description: "从零新建办公文档。支持 .docx(title+content,content 支持 Markdown:# 标题、**粗体**、*斜体*、- 列表)、" +
|
||||
".xlsx(sheets 多表:表名+表头+行数据)、.pptx(title+content,## 分页)、.md、.csv、.txt。" +
|
||||
"目录不存在会自动创建;文件已存在时需传 overwrite=true 才会覆盖(覆盖前自动 .bak 留档)。",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"file": map[string]interface{}{"type": "string", "description": "要创建的文件绝对路径(扩展名决定格式)"},
|
||||
"title": map[string]interface{}{"type": "string", "description": "标题(docx/pptx/md)"},
|
||||
"subtitle": map[string]interface{}{"type": "string", "description": "副标题(pptx 首页)"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "正文内容(docx/md/txt 支持 Markdown;pptx 用 ## 分页)"},
|
||||
"sheets": map[string]interface{}{"type": "array", "description": "xlsx 多表:[{name, headers, rows}]", "items": map[string]interface{}{"type": "object"}},
|
||||
"headers": map[string]interface{}{"type": "array", "description": "单表模式的表头(xlsx/csv)", "items": map[string]interface{}{"type": "string"}},
|
||||
"rows": map[string]interface{}{"type": "array", "description": "单表模式的数据行(xlsx/csv)", "items": map[string]interface{}{"type": "array"}},
|
||||
"tables": map[string]interface{}{"type": "array", "description": "docx 追加表格:[{headers, rows}]", "items": map[string]interface{}{"type": "object"}},
|
||||
"overwrite": map[string]interface{}{"type": "boolean", "description": "文件已存在时是否覆盖,默认 false(报错)"},
|
||||
},
|
||||
"required": []string{"file"},
|
||||
},
|
||||
}, p.handleCreate)
|
||||
|
||||
s.RegisterTool("create_from_template", sdk.ToolDef{
|
||||
Name: "create_from_template",
|
||||
Description: "基于已有文件复制一份再套用替换(模板场景)。先 shutil.copy2 复制,再对 docx/xlsx/文本做文本替换。" +
|
||||
"replacements 是 {\"旧文本\":\"新文本\"} 映射;也可用 target/replacement 只替换一处。",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"template": map[string]interface{}{"type": "string", "description": "模板文件绝对路径"},
|
||||
"file": map[string]interface{}{"type": "string", "description": "新文件绝对路径"},
|
||||
"replacements": map[string]interface{}{"type": "object", "description": "替换映射 {\"旧\":\"新\"}"},
|
||||
"target": map[string]interface{}{"type": "string", "description": "单处替换的原文本(可选)"},
|
||||
"replacement": map[string]interface{}{"type": "string", "description": "单处替换的新文本(可选)"},
|
||||
"overwrite": map[string]interface{}{"type": "boolean", "description": "目标已存在时是否覆盖"},
|
||||
},
|
||||
"required": []string{"template", "file"},
|
||||
},
|
||||
}, p.handleCreateFromTemplate)
|
||||
|
||||
// ---- 兼容性红线:工具名 + 参数与 v1.0.0 完全一致 ----
|
||||
s.RegisterTool("edit_document", sdk.ToolDef{
|
||||
Name: "edit_document",
|
||||
Description: "编辑 Office 文档(会覆盖原文件,编辑前自动 .bak 留档)。支持 .docx / .xlsx / .xlsm / .pptx。" +
|
||||
"operation 可选:replace_text(查找替换,支持 regex/case_sensitive/nth)、set_cell、insert_row、" +
|
||||
"delete_row、insert_paragraph、append_paragraph、set_style、" +
|
||||
"insert_image、insert_table、merge_cells、add_sheet、delete_sheet、rename_sheet。" +
|
||||
"注意:pptx 无插入行 API,insert_row 在 pptx 上语义为「表格末尾追加一行」;merge_cells 不支持 pptx。",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
// v1 原有 8 个参数,语义不变
|
||||
"file": map[string]interface{}{"type": "string", "description": "文档文件路径"},
|
||||
"operation": map[string]interface{}{"type": "string", "description": "操作: replace_text(查找替换), set_cell(设置单元格), insert_row(插入行)"},
|
||||
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text)"},
|
||||
"operation": map[string]interface{}{"type": "string", "description": "操作(见工具描述)"},
|
||||
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text);set_style 时为空表示全文"},
|
||||
"replacement": map[string]interface{}{"type": "string", "description": "替换为的文本(replace_text)"},
|
||||
"sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx,可选)"},
|
||||
"row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row)"},
|
||||
"sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx)"},
|
||||
"row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row/delete_row)"},
|
||||
"col": map[string]interface{}{"type": "integer", "description": "列号(set_cell)"},
|
||||
"value": map[string]interface{}{"type": "string", "description": "单元格值(set_cell)"},
|
||||
"value": map[string]interface{}{"type": "string", "description": "写入值(set_cell/add_sheet/rename_sheet/段落文本)"},
|
||||
// v2 扩展参数
|
||||
"regex": map[string]interface{}{"type": "boolean", "description": "target 按正则解释,默认 false"},
|
||||
"case_sensitive": map[string]interface{}{"type": "boolean", "description": "是否区分大小写,默认 true"},
|
||||
"nth": map[string]interface{}{"type": "integer", "description": "只替换第 N 处(1 基),默认全部替换"},
|
||||
"count": map[string]interface{}{"type": "integer", "description": "delete_row 删除几行,默认 1"},
|
||||
"index": map[string]interface{}{"type": "integer", "description": "insert_paragraph 的插入位置(段落序号)"},
|
||||
"row2": map[string]interface{}{"type": "integer", "description": "merge_cells 的结束行"},
|
||||
"col2": map[string]interface{}{"type": "integer", "description": "merge_cells 的结束列"},
|
||||
"table": map[string]interface{}{"type": "integer", "description": "docx 表格序号(merge_cells/insert_table),从 0 开始"},
|
||||
"headers": map[string]interface{}{"type": "array", "description": "insert_table 的表头", "items": map[string]interface{}{"type": "string"}},
|
||||
"rows": map[string]interface{}{"type": "array", "description": "insert_table 的数据行", "items": map[string]interface{}{"type": "array"}},
|
||||
"image": map[string]interface{}{"type": "string", "description": "insert_image 的图片路径"},
|
||||
"width_inch": map[string]interface{}{"type": "number", "description": "insert_image 的宽度(英寸)"},
|
||||
"slide": map[string]interface{}{"type": "integer", "description": "pptx 页号(从 1 开始)"},
|
||||
"size": map[string]interface{}{"type": "number", "description": "set_style 字号(磅)"},
|
||||
"bold": map[string]interface{}{"type": "boolean", "description": "set_style 加粗"},
|
||||
"italic": map[string]interface{}{"type": "boolean", "description": "set_style 斜体"},
|
||||
"color": map[string]interface{}{"type": "string", "description": "set_style 字体颜色(如 FF0000)"},
|
||||
"bg_color": map[string]interface{}{"type": "string", "description": "xlsx set_style 背景色"},
|
||||
"align": map[string]interface{}{"type": "string", "description": "set_style 对齐:left/center/right/justify"},
|
||||
"dry_run": map[string]interface{}{"type": "boolean", "description": "只校验不改动"},
|
||||
},
|
||||
"required": []string{"file", "operation"},
|
||||
},
|
||||
}, p.handleEditDocument)
|
||||
}, p.handleEdit)
|
||||
|
||||
s.RegisterTool("convert_document", sdk.ToolDef{
|
||||
Name: "convert_document",
|
||||
Description: "办公文档格式转换。md→docx/html/pdf(pandoc)、xlsx→csv(openpyxl,多表自动分文件)、" +
|
||||
"pptx→txt/md(结构化抽取)、office→pdf(soffice)。缺外部依赖时会在返回里点名缺哪个,不静默失败。",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"file": map[string]interface{}{"type": "string", "description": "源文件绝对路径"},
|
||||
"to": map[string]interface{}{"type": "string", "description": "目标格式:docx / pdf / html / csv / txt / md"},
|
||||
"output": map[string]interface{}{"type": "string", "description": "输出路径(可选,默认同名换扩展名)"},
|
||||
"sheet": map[string]interface{}{"type": "string", "description": "xlsx→csv 时指定工作表(可选,默认全部)"},
|
||||
"overwrite": map[string]interface{}{"type": "boolean", "description": "输出已存在时覆盖"},
|
||||
"timeout": map[string]interface{}{"type": "integer", "description": "转换超时(秒),默认 120"},
|
||||
},
|
||||
"required": []string{"file", "to"},
|
||||
},
|
||||
}, p.handleConvert)
|
||||
|
||||
s.RegisterTool("office_check", sdk.ToolDef{
|
||||
Name: "office_check",
|
||||
Description: "办公能力自检(独立可调用,不依赖真实文件)。返回:Python 解释器版本、每个依赖库" +
|
||||
"(python-docx/openpyxl/python-pptx/xlrd/markdown)的可用性与版本、外部工具(soffice/pandoc)、" +
|
||||
"以及格式×能力矩阵。换机器或升级插件后可用它一条命令验收。",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object", "properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleCheck)
|
||||
|
||||
// 首次启动就把脚本释放好,让 office_check 立刻可用(也便于运维确认落盘位置)
|
||||
if sp, err := p.resolveScript(); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "[%s] 已就绪(脚本: %s, 指纹: %s)\n", p.name, sp, scriptStamp())
|
||||
if p.ignoredScriptPath != "" {
|
||||
fmt.Fprintf(os.Stderr, "[%s] 注意:script_path=%s 已被忽略(非 v2 脚本),当前使用内嵌脚本\n",
|
||||
p.name, p.ignoredScriptPath)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[%s] 脚本释放失败: %v\n", p.name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error { return nil }
|
||||
|
||||
func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, error) {
|
||||
file, _ := args["file"].(string)
|
||||
if file == "" {
|
||||
return nil, fmt.Errorf("file is required")
|
||||
func (p *Plugin) reloadSettings() {
|
||||
s := p.sdk.Settings()
|
||||
if v, err := s.Get("script_path"); err == nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.scriptPath = strings.TrimSpace(str)
|
||||
}
|
||||
}
|
||||
operation, _ := args["operation"].(string)
|
||||
if operation == "" {
|
||||
return nil, fmt.Errorf("operation is required")
|
||||
if v, err := s.Get("venv_python"); err == nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.venvPython = strings.TrimSpace(str)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(file); os.IsNotExist(err) {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("文件不存在: %s", file),
|
||||
}, nil
|
||||
p.timeoutSec = 60
|
||||
if v, err := s.Get("timeout_sec"); err == nil {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
if n > 0 {
|
||||
p.timeoutSec = int(n)
|
||||
}
|
||||
case int64:
|
||||
if n > 0 {
|
||||
p.timeoutSec = int(n)
|
||||
}
|
||||
case int:
|
||||
if n > 0 {
|
||||
p.timeoutSec = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pyArgs := map[string]interface{}{}
|
||||
if v, ok := args["target"]; ok {
|
||||
pyArgs["target"] = v
|
||||
}
|
||||
if v, ok := args["replacement"]; ok {
|
||||
pyArgs["replacement"] = v
|
||||
}
|
||||
if v, ok := args["sheet"]; ok {
|
||||
pyArgs["sheet"] = v
|
||||
}
|
||||
if v, ok := args["row"]; ok {
|
||||
pyArgs["row"] = v
|
||||
}
|
||||
if v, ok := args["col"]; ok {
|
||||
pyArgs["col"] = v
|
||||
}
|
||||
if v, ok := args["value"]; ok {
|
||||
pyArgs["value"] = v
|
||||
}
|
||||
pyArgsJSON, _ := json.Marshal(pyArgs)
|
||||
|
||||
scriptPath := p.scriptPath
|
||||
if scriptPath == "" {
|
||||
scriptPath = filepath.Join(filepath.Dir(os.Args[0]), "edit_doc.py")
|
||||
log.Printf("[%s] script_path 未配置,使用默认脚本路径: %s", p.name, scriptPath)
|
||||
}
|
||||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("edit_doc.py not found at %s(请在插件配置 script_path 中指定脚本路径)", scriptPath)
|
||||
}
|
||||
|
||||
if p.venvPython == "" {
|
||||
return nil, fmt.Errorf("venv_python 未配置,无法执行脚本;请在插件配置中设置 venv_python(venv 内 python 的绝对路径)")
|
||||
}
|
||||
if _, err := os.Stat(p.venvPython); err != nil {
|
||||
return nil, fmt.Errorf("venv python 不存在: %s(请检查 venv_python 配置)", p.venvPython)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := exec.Command(p.venvPython, scriptPath, file, operation, string(pyArgsJSON))
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("edit document: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Ok bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("编辑完成,输出: %s", out.String()),
|
||||
"file": file,
|
||||
}, nil
|
||||
}
|
||||
if !result.Ok {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("编辑失败: %s", result.Error),
|
||||
}, nil
|
||||
}
|
||||
msg := fmt.Sprintf("编辑完成,已保存到原文件: %s", file)
|
||||
if result.Count > 0 {
|
||||
msg += fmt.Sprintf("\n共处理 %d 处", result.Count)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": msg,
|
||||
"file": file,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveScript 返回 edit_doc.py 的可用路径。
|
||||
//
|
||||
// 优先级:
|
||||
// 1. 配置 script_path 指向的文件,且**含 v2 协议标记**(便于运维手动覆盖调试)
|
||||
// 2. DataDir()/runtime/edit_doc.py(内嵌脚本释放位置,指纹驱动的自动更新)
|
||||
//
|
||||
// 关键防呆:script_path 指向的文件若存在但**不含** v2 协议标记,说明它是旧版脚本
|
||||
// (v1 的 argv 约定是 <file> <op> <json>,与 v2 的 <cmd> <json> 完全不同)。
|
||||
// 若照样采用,6 个工具会全部报「文件不存在: create/read/edit/...」,等于把线上打挂。
|
||||
// 因此这里主动忽略它、回退内嵌脚本,并置 p.ignoredScriptPath 让调用方能看到。
|
||||
func (p *Plugin) resolveScript() (string, error) {
|
||||
p.ignoredScriptPath = ""
|
||||
if p.scriptPath != "" {
|
||||
if data, err := os.ReadFile(p.scriptPath); err == nil {
|
||||
if bytes.Contains(data, []byte(scriptProtocolMarker)) {
|
||||
return p.scriptPath, nil
|
||||
}
|
||||
// 存在但不是 v2 脚本:忽略,回退内嵌(绝不能让它把工具打挂)
|
||||
p.ignoredScriptPath = p.scriptPath
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"[%s] 忽略 script_path=%s:缺少 v2 协议标记 %q,判定为旧版脚本;改用内嵌脚本\n",
|
||||
p.name, p.scriptPath, scriptProtocolMarker)
|
||||
}
|
||||
}
|
||||
// 释放内嵌脚本
|
||||
dir := ""
|
||||
if p.sdk != nil {
|
||||
dir = p.sdk.Settings().DataDir()
|
||||
}
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("拿不到插件数据目录(Settings().DataDir() 为空),且 script_path 未指向有效文件")
|
||||
}
|
||||
rt := filepath.Join(dir, "runtime")
|
||||
if err := os.MkdirAll(rt, 0755); err != nil {
|
||||
return "", fmt.Errorf("创建运行时目录失败: %w", err)
|
||||
}
|
||||
jsPath := filepath.Join(rt, "edit_doc.py")
|
||||
stampPath := filepath.Join(rt, ".stamp")
|
||||
want := scriptStamp()
|
||||
|
||||
if got, err := os.ReadFile(stampPath); err == nil && string(got) == want {
|
||||
if _, e := os.Stat(jsPath); e == nil {
|
||||
return jsPath, nil
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(jsPath, embeddedScript, 0644); err != nil {
|
||||
return "", fmt.Errorf("释放内嵌脚本失败: %w", err)
|
||||
}
|
||||
_ = os.WriteFile(stampPath, []byte(want), 0644)
|
||||
return jsPath, nil
|
||||
}
|
||||
|
||||
// resolvePython 返回可用的 python 解释器(配置为空时回退到 PATH 里的 python3)。
|
||||
func (p *Plugin) resolvePython() (string, error) {
|
||||
py := p.venvPython
|
||||
if py == "" {
|
||||
if cand, err := exec.LookPath("python3"); err == nil {
|
||||
py = cand
|
||||
}
|
||||
}
|
||||
if py == "" {
|
||||
return "", fmt.Errorf("venv_python 未配置且 PATH 中找不到 python3")
|
||||
}
|
||||
if _, err := os.Stat(py); err != nil {
|
||||
return "", fmt.Errorf("Python 解释器不存在: %s", py)
|
||||
}
|
||||
return py, nil
|
||||
}
|
||||
|
||||
// scriptError 是结构化错误体,直接透传给上层 LLM。
|
||||
type scriptError struct {
|
||||
Error string `json:"error"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// runScript 执行 edit_doc.py <cmd> <argsJSON>,返回解析后的 map。
|
||||
//
|
||||
// 统一错误处理:脚本缺失/解释器缺失/超时/非 JSON 输出,都转成带 hint 的结构化返回,
|
||||
// 而不是抛裸 error(v1 是直接 return nil, err,上层只能看到一句无上下文的失败)。
|
||||
func (p *Plugin) runScript(cmd string, args map[string]interface{}) (map[string]interface{}, error) {
|
||||
p.reloadSettings() // 支持改配置后无需重载插件
|
||||
|
||||
scriptPath, err := p.resolveScript()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s;请在插件配置 script_path 指定 edit_doc.py 的绝对路径", err)
|
||||
}
|
||||
py, err := p.resolvePython()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s;请在插件配置 venv_python 指定 python 解释器", err)
|
||||
}
|
||||
|
||||
argsJSON, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("参数序列化失败: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(),
|
||||
time.Duration(p.timeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
c := exec.CommandContext(ctx, py, scriptPath, cmd, string(argsJSON))
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
runErr := c.Run()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("脚本执行超时(%d 秒);大文件或格式转换可调高 timeout_sec 配置",
|
||||
p.timeoutSec)
|
||||
}
|
||||
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
if out == "" {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if runErr != nil {
|
||||
return nil, fmt.Errorf("脚本无输出且退出异常: %v; stderr: %s", runErr, truncate(msg, 300))
|
||||
}
|
||||
return nil, fmt.Errorf("脚本无输出(stderr: %s)", truncate(msg, 300))
|
||||
}
|
||||
// 脚本只保证最后一行是 JSON(soffice/pandoc 可能往 stdout 混东西)
|
||||
line := out
|
||||
if i := strings.LastIndex(out, "\n"); i >= 0 {
|
||||
line = strings.TrimSpace(out[i+1:])
|
||||
}
|
||||
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||
return nil, fmt.Errorf("脚本输出不是合法 JSON: %s; 原文: %s",
|
||||
err, truncate(out, 300))
|
||||
}
|
||||
if se := strings.TrimSpace(stderr.String()); se != "" && runErr != nil {
|
||||
m["stderr"] = truncate(se, 300)
|
||||
}
|
||||
// script_path 因非 v2 脚本被忽略时,把这次降级明确告知上层
|
||||
if p.ignoredScriptPath != "" {
|
||||
m["script_path_ignored"] = p.ignoredScriptPath
|
||||
m["script_path_ignored_reason"] = "该文件缺少 v2 协议标记,判定为旧版脚本;本次已改用内嵌脚本"
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// respond 把脚本返回整形成工具返回值:ok:true 时给 content/summary,
|
||||
// ok:false 时给 isError + error + hint。
|
||||
func respondOffice(m map[string]interface{}) (interface{}, error) {
|
||||
if ok, _ := m["ok"].(bool); !ok {
|
||||
out := map[string]interface{}{
|
||||
"isError": true,
|
||||
"content": "操作失败: " + str(m["error"]),
|
||||
"error": m["error"],
|
||||
"hint": m["hint"],
|
||||
}
|
||||
for _, k := range []string{"path", "exists", "missing", "format", "cmd",
|
||||
"script_path_ignored", "script_path_ignored_reason"} {
|
||||
if v, has := m[k]; has {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
// 成功:把脚本的结构化字段原样上抛,便于 LLM 二次利用
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
if s, has := m["summary"]; has {
|
||||
out["content"] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func str(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- handlers
|
||||
|
||||
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
|
||||
m, err := p.runScript("read", args)
|
||||
if err != nil {
|
||||
return toolErr("read_document", err), nil
|
||||
}
|
||||
return respondOffice(m)
|
||||
}
|
||||
|
||||
func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) {
|
||||
m, err := p.runScript("create", args)
|
||||
if err != nil {
|
||||
return toolErr("create_document", err), nil
|
||||
}
|
||||
return respondOffice(m)
|
||||
}
|
||||
|
||||
func (p *Plugin) handleCreateFromTemplate(args map[string]interface{}) (interface{}, error) {
|
||||
m, err := p.runScript("create_from_template", args)
|
||||
if err != nil {
|
||||
return toolErr("create_from_template", err), nil
|
||||
}
|
||||
return respondOffice(m)
|
||||
}
|
||||
|
||||
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
|
||||
// v1 兼容:file / operation 必填
|
||||
if str(args["file"]) == "" {
|
||||
return map[string]interface{}{"isError": true, "content": "edit_document 需要 file 参数"}, nil
|
||||
}
|
||||
if str(args["operation"]) == "" {
|
||||
return map[string]interface{}{"isError": true, "content": "edit_document 需要 operation 参数"}, nil
|
||||
}
|
||||
m, err := p.runScript("edit", args)
|
||||
if err != nil {
|
||||
return toolErr("edit_document", err), nil
|
||||
}
|
||||
return respondOffice(m)
|
||||
}
|
||||
|
||||
func (p *Plugin) handleConvert(args map[string]interface{}) (interface{}, error) {
|
||||
m, err := p.runScript("convert", args)
|
||||
if err != nil {
|
||||
return toolErr("convert_document", err), nil
|
||||
}
|
||||
return respondOffice(m)
|
||||
}
|
||||
|
||||
func (p *Plugin) handleCheck(args map[string]interface{}) (interface{}, error) {
|
||||
m, err := p.runScript("check", map[string]interface{}{})
|
||||
if err != nil {
|
||||
return toolErr("office_check", err), nil
|
||||
}
|
||||
return respondOffice(m)
|
||||
}
|
||||
|
||||
func toolErr(tool string, err error) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"isError": true,
|
||||
"content": fmt.Sprintf("%s 失败:%s", tool, err.Error()),
|
||||
"error": err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
|
||||
178
example/editdoc/plugin_test.go
Normal file
178
example/editdoc/plugin_test.go
Normal file
@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/homeagentsdk/sdk"
|
||||
)
|
||||
|
||||
// fakeSettings 是 SettingsAPI 的最小实现,只为把 resolveScript 的 DataDir 分支跑通。
|
||||
// 其余方法测试用不到,给个安全默认值即可。
|
||||
type fakeSettings struct {
|
||||
dataDir string
|
||||
defs []*sdk.ConfigDef
|
||||
}
|
||||
|
||||
func (f *fakeSettings) Get(string) (interface{}, error) { return nil, nil }
|
||||
func (f *fakeSettings) Set(string, interface{}) error { return nil }
|
||||
func (f *fakeSettings) SetSecret(string, interface{}) error { return nil }
|
||||
func (f *fakeSettings) GetSecret(string) (string, error) { return "", nil }
|
||||
func (f *fakeSettings) List(string) ([]string, error) { return nil, nil }
|
||||
func (f *fakeSettings) GetCore(string) (interface{}, error) { return nil, nil }
|
||||
func (f *fakeSettings) SetCore(string, interface{}) error { return nil }
|
||||
func (f *fakeSettings) ListCore(string) ([]string, error) { return nil, nil }
|
||||
func (f *fakeSettings) DataDir() string { return f.dataDir }
|
||||
func (f *fakeSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil }
|
||||
func (f *fakeSettings) SetPlugin(string, string, interface{}) error { return nil }
|
||||
func (f *fakeSettings) ListPlugin(string, string) ([]string, error) { return nil, nil }
|
||||
func (f *fakeSettings) RegisterDef(def sdk.ConfigDef) { f.defs = append(f.defs, &def) }
|
||||
func (f *fakeSettings) Defs(string) []*sdk.ConfigDef { return f.defs }
|
||||
func (f *fakeSettings) Dump() map[string]interface{} { return nil }
|
||||
func (f *fakeSettings) Plugins() []string { return nil }
|
||||
|
||||
// newTestPlugin 造一个带可用 DataDir 的 Plugin(sdk 走 sdk.New,只注入 SettingsAPI)。
|
||||
func newTestPlugin(t *testing.T, scriptPath string) *Plugin {
|
||||
t.Helper()
|
||||
sett := &fakeSettings{dataDir: t.TempDir()}
|
||||
s := sdk.New("editdoc", sett, nil, nil, nil, nil)
|
||||
return &Plugin{
|
||||
name: "editdoc",
|
||||
sdk: s,
|
||||
scriptPath: scriptPath,
|
||||
venvPython: "/usr/local/bin/python3",
|
||||
timeoutSec: 60,
|
||||
}
|
||||
}
|
||||
|
||||
// 内嵌脚本必须自带 v2 协议标记,否则防呆会把自己的脚本也挡掉。
|
||||
func TestEmbeddedScriptHasProtocolMarker(t *testing.T) {
|
||||
if !containsMarker(embeddedScript) {
|
||||
t.Fatalf("内嵌脚本缺少协议标记 %q —— 防呆会把内嵌脚本自己判为旧版", scriptProtocolMarker)
|
||||
}
|
||||
// 标记必须与 embed/edit_doc.py 顶部一致(防手改两处不同步)
|
||||
t.Logf("协议标记=%q 内嵌脚本指纹=%s", scriptProtocolMarker, scriptStamp())
|
||||
}
|
||||
|
||||
// 业务脚本只能有**一份**:embed/edit_doc.py。
|
||||
//
|
||||
// 为何钉这一条(2026-10-13 迁入仓库时实测):原工作目录里同时存在
|
||||
// edit_doc.py 与 embed/edit_doc.py —— 两份逐字节相同的副本,而只有
|
||||
// embed/ 那份被 //go:embed 引用。同名双副本是典型的漂移源:
|
||||
// 改了顶层那份(看着像“主文件”)而真正生效的是 embed/ 里的,
|
||||
// 或反过来;两种都不会报错,只会让行为与源码对不上。
|
||||
func TestOnlyOneBusinessScript(t *testing.T) {
|
||||
if _, err := os.Stat("edit_doc.py"); err == nil {
|
||||
t.Error("存在顶层 edit_doc.py:业务脚本只应有 embed/edit_doc.py 一份(顶部副本未被 embed,改了不生效)")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("embed", "edit_doc.py")); err != nil {
|
||||
t.Fatalf("缺少 embed/edit_doc.py: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// script_path 指向 v1 脚本(无标记)→ 必须忽略它、回退内嵌脚本,
|
||||
// 并记录 ignoredScriptPath。这是防呆的核心契约。
|
||||
func TestResolveScriptIgnoresV1Script(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v1 := filepath.Join(dir, "edit_doc_v1.py")
|
||||
// v1 脚本的真实特征:argv 约定 <file> <op> <json>,且没有 v2 标记
|
||||
if err := os.WriteFile(v1, []byte(
|
||||
"import sys\npath = sys.argv[1]\noperation = sys.argv[2]\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := newTestPlugin(t, v1)
|
||||
got, err := p.resolveScript()
|
||||
if err != nil {
|
||||
t.Fatalf("应回退到内嵌脚本,却报错: %v", err)
|
||||
}
|
||||
if got == v1 {
|
||||
t.Fatal("v1 脚本被采用了 —— 防呆失效,6 个工具会被打挂")
|
||||
}
|
||||
if filepath.Base(got) != "edit_doc.py" {
|
||||
t.Fatalf("应回退到 runtime/edit_doc.py,实际: %s", got)
|
||||
}
|
||||
if p.ignoredScriptPath != v1 {
|
||||
t.Fatalf("ignoredScriptPath 未记录,实际: %q", p.ignoredScriptPath)
|
||||
}
|
||||
// 回退出来的脚本必须是内嵌那份(字节一致)
|
||||
released, err := os.ReadFile(got)
|
||||
if err != nil {
|
||||
t.Fatalf("内嵌脚本未落盘: %v", err)
|
||||
}
|
||||
if string(released) != string(embeddedScript) {
|
||||
t.Fatal("落盘的脚本与内嵌脚本字节不一致")
|
||||
}
|
||||
t.Logf("v1 被正确忽略,回退到 %s", got)
|
||||
}
|
||||
|
||||
// script_path 指向含标记的 v2 脚本 → 必须采用它,且不记 ignoredScriptPath。
|
||||
func TestResolveScriptAcceptsV2Script(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v2 := filepath.Join(dir, "edit_doc_v2.py")
|
||||
if err := os.WriteFile(v2, []byte(
|
||||
"#!/usr/bin/env python3\n"+scriptProtocolMarker+"\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := newTestPlugin(t, v2)
|
||||
got, err := p.resolveScript()
|
||||
if err != nil {
|
||||
t.Fatalf("应直接采用 v2 脚本,却报错: %v", err)
|
||||
}
|
||||
if got != v2 {
|
||||
t.Fatalf("v2 脚本未被采用,实际用了: %s", got)
|
||||
}
|
||||
if p.ignoredScriptPath != "" {
|
||||
t.Fatalf("v2 脚本不应被标记忽略: %q", p.ignoredScriptPath)
|
||||
}
|
||||
}
|
||||
|
||||
// script_path 为空 → 走内嵌脚本释放,且指纹命中第二次不再重复写。
|
||||
func TestResolveScriptFallsBackToEmbedded(t *testing.T) {
|
||||
p := newTestPlugin(t, "")
|
||||
got, err := p.resolveScript()
|
||||
if err != nil {
|
||||
t.Fatalf("空 script_path 应回退内嵌脚本: %v", err)
|
||||
}
|
||||
if p.ignoredScriptPath != "" {
|
||||
t.Fatalf("空配置不该有 ignoredScriptPath: %q", p.ignoredScriptPath)
|
||||
}
|
||||
// 第二次调用应复用(指纹 + 文件都在),路径一致
|
||||
got2, err := p.resolveScript()
|
||||
if err != nil || got2 != got {
|
||||
t.Fatalf("第二次解析不稳定: %s vs %s (%v)", got, got2, err)
|
||||
}
|
||||
// .stamp 指纹应有内容
|
||||
stamp, err := os.ReadFile(filepath.Join(filepath.Dir(got), ".stamp"))
|
||||
if err != nil || string(stamp) != scriptStamp() {
|
||||
t.Fatalf("指纹文件异常: %q %v", stamp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 指向不存在的路径 → 不该报错,应静默回退内嵌脚本。
|
||||
func TestResolveScriptMissingPathFallsBack(t *testing.T) {
|
||||
p := newTestPlugin(t, filepath.Join(t.TempDir(), "not-there.py"))
|
||||
got, err := p.resolveScript()
|
||||
if err != nil {
|
||||
t.Fatalf("路径不存在应回退内嵌脚本: %v", err)
|
||||
}
|
||||
if p.ignoredScriptPath != "" {
|
||||
t.Fatalf("不存在的路径不该记为 ignored: %q", p.ignoredScriptPath)
|
||||
}
|
||||
if filepath.Base(got) != "edit_doc.py" {
|
||||
t.Fatalf("应回退到 runtime/edit_doc.py,实际: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// containsMarker 与 resolveScript 内部用的是同一判据,供测试复用。
|
||||
func containsMarker(b []byte) bool {
|
||||
m := []byte(scriptProtocolMarker)
|
||||
for i := 0; i+len(m) <= len(b); i++ {
|
||||
if string(b[i:i+len(m)]) == string(m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -159,19 +159,19 @@ const (
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
listenAddr string
|
||||
napcatURL string
|
||||
remoteDir string
|
||||
filesDir string
|
||||
webhookToken string
|
||||
ownerIDs []int64
|
||||
botID int64
|
||||
botNickname string
|
||||
dmPolicy string
|
||||
groupPolicy string
|
||||
httpClient *http.Client
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
listenAddr string
|
||||
napcatURL string
|
||||
remoteDir string
|
||||
filesDir string
|
||||
webhookToken string
|
||||
ownerIDs []int64
|
||||
botID int64
|
||||
botNickname string
|
||||
dmPolicy string
|
||||
groupPolicy string
|
||||
httpClient *http.Client
|
||||
// sendClient 专用于**发送类**端点(send_private_msg / send_group_msg)。
|
||||
//
|
||||
// ★ 为何与查询类分开(2026-10-13 实测):QQ 发送要经过 NapCat 的
|
||||
@ -188,7 +188,7 @@ type Plugin struct {
|
||||
//
|
||||
// 发送超时给足 120s(与插件内 TTS/下载同量级);查询类保持 30s
|
||||
// (它们是幂等的,超时就是真超时,快速失败更有利于排查)。
|
||||
sendClient *http.Client
|
||||
sendClient *http.Client
|
||||
allowFrom map[int64]struct{}
|
||||
groupAllowFrom map[int64]struct{}
|
||||
srv *http.Server
|
||||
@ -1302,6 +1302,36 @@ func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, q
|
||||
return matchesToolAllowlist(name, patterns), auth
|
||||
}
|
||||
|
||||
// extraKeyComeFrom 是内核写在帧上的「来源标识」键。
|
||||
//
|
||||
// 契约(见内核 internal/agent/core/comefrom.go):
|
||||
//
|
||||
// "user" 用户请求在边界上发起(默认语义,本插件的权限门只应管这类)
|
||||
// "internal" agent 体系内部发起(驻留子/转投/自主续跑)⇒ 不走用户权限模型
|
||||
// 缺失 旧内核 ⇒ 按 "user" 处理,**行为必须与改动前逐字一致**
|
||||
//
|
||||
// 为什么用 Extra 而不是具名字段:内核用 Extra 的通用袋跨进程透传插件自建键
|
||||
// (proc 桥的 fExtraBag),与既有 `input_source` / `qq_auth` 同一读法。
|
||||
const extraKeyComeFrom = "comefrom"
|
||||
|
||||
// isInternalToolCall 报告本帧的工具调用是否由 agent 内部委派发起。
|
||||
//
|
||||
// ★ 保守默认:**只有明确读到 "internal" 才算内部**。
|
||||
// 缺失、空串、类型不对、未知取值 —— 全部当作 "user"(旧行为)。
|
||||
// 这个方向不能反:把用户请求误判成 internal 就是**权限门整体失效**,
|
||||
// 那比"多拦一次内部调用"严重得多。
|
||||
func isInternalToolCall(ctx *sdk.StageContext) bool {
|
||||
if ctx == nil || ctx.Extra == nil {
|
||||
return false
|
||||
}
|
||||
v, ok := ctx.Extra[extraKeyComeFrom]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
s, _ := v.(string)
|
||||
return s == "internal"
|
||||
}
|
||||
|
||||
// isAtBot checks if the message contains an @-mention of the bot.
|
||||
func (p *Plugin) isAtBot(msg interface{}) bool {
|
||||
segments, ok := msg.([]interface{})
|
||||
@ -1391,6 +1421,37 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
|
||||
if len(ctx.ToolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
// ★ **内部委派的工具调用不走用户权限模型**(2026-10-16)。
|
||||
//
|
||||
// 内核在帧上写了 `comefrom`:
|
||||
// "user" = 用户请求在边界上发起(**本插件要管的就是这类**)
|
||||
// "internal" = agent 体系内部发起(驻留子/转投/自主续跑)
|
||||
// 缺失 = 旧内核,按 "user" 处理(保持既有行为,勿改)
|
||||
//
|
||||
// ## 为何这是正确的(实测故障 + 语义)
|
||||
//
|
||||
// 现场(2026-10-16):驻留子 `dev-br-jianf-NextAgent-fix_abs-path` 自报 running
|
||||
// 但**零产出**,它那一轮 8 个工具(cmd_run/files_ls/notify_parent/…)全被
|
||||
// 本门拦下。日志实证:每个工具调用前都有
|
||||
// `[proc] qq stage before_toolcall 改写了 1 个字段`(= 写入了拒绝响应)。
|
||||
//
|
||||
// 根因:驻留子的输入**继承自父**(转投刻意保留原事件,否则同步调用方永久
|
||||
// 挂起),于是 `input_source` 仍是 "qq" ⇒ 本门把驻留子当成"那位 QQ 用户
|
||||
// 在提请求" ⇒ 按用户白名单判 ⇒ 开发工具一条都不在白名单里 ⇒ 全拒。
|
||||
//
|
||||
// 但用户请求**已经过了这道门**(父按真实身份判定过)。驻留子做的是
|
||||
// **父自己的委派决定**;对已经准入的工作再查一次"用户白名单",既拦掉合法
|
||||
// 工作、又零安全增益(同一次请求,父已判定)。
|
||||
//
|
||||
// 为何不"只跳白名单、仍查硬私有":硬私有(calendar_/email_/memory_…)
|
||||
// 防的是用户偷看私人数据;驻留子是父的内部执行体,父对它本就有完整
|
||||
// 权限 —— 再查一遍等于"机器人自己派出去的活被自己拦住",同样荒谬。
|
||||
//
|
||||
// 为何放在 `auth.Active` 判断**之前**:internal 与"非本插件来源"在语义上
|
||||
// 是同一类(都不是"一条 QQ 用户消息"),走同一条放行路径最不容易出错。
|
||||
if isInternalToolCall(ctx) {
|
||||
return nil
|
||||
}
|
||||
tc := &ctx.ToolCalls[0]
|
||||
allowed, auth := p.currentToolAllowed(ctx, tc.Name)
|
||||
if !auth.Active {
|
||||
|
||||
@ -725,6 +725,7 @@ func TestNapcatConvertsOneBotFailureToError(t *testing.T) {
|
||||
// 与我修过的 HTTP 客户端超时是**两条不同路径**:
|
||||
// - HTTP 超时:client.Post 返回 err(已有告警)
|
||||
// - NapCat 超时:HTTP 200 + 响应体里带 NTEvent 字样(本次才补)
|
||||
//
|
||||
// 只报警「失败」而不说「可能已送出」,模型必重发。
|
||||
//
|
||||
// 判据必须穿过 napcat() 真实调用链(单测 isNapcatSendUnconfirmed 测不出
|
||||
@ -816,3 +817,151 @@ func TestNapcatSendActionTimeoutRegardlessOfServiceName(t *testing.T) {
|
||||
t.Errorf("服务名不是 sendMsg 的 NTEvent 超时也要套用发送超时语义,实为 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 「来源标识」(comefrom)与用户权限门的关系(2026-10-16)
|
||||
//
|
||||
// 现场故障:驻留子 `dev-br-jianf-NextAgent-fix_abs-path` 自报 running 但
|
||||
// **零产出** —— 它那一轮 8 个工具(cmd_run / files_ls / notify_parent /
|
||||
// output_send__commondev_* / inputch_note …)**全部**被本插件的
|
||||
// before_toolcall 拦下。它自己的处理表原文:
|
||||
//
|
||||
// 本轮所有需要的工具都被权限策略挡下了,实测被拒的有:
|
||||
// - cmd_run —— 无法进入工作目录、无法看 git 状态
|
||||
//
|
||||
// 日志实证(09:21:25~09:21:40,每个工具调用前都紧跟):
|
||||
//
|
||||
// [agent] executing tool: cmd_run (plugin=cmd)
|
||||
// [proc] qq stage before_toolcall 改写了 1 个字段 ← 写入了拒绝响应
|
||||
//
|
||||
// 而同一时段父 main 的工具调用**有 result**、驻留子的**零 result**。
|
||||
//
|
||||
// 根因:驻留子的输入继承自父(转投刻意保留原事件),`input_source` 仍是
|
||||
// "qq",于是本门把“父的内部委派”误当成“那位 QQ 用户新提的请求”,
|
||||
// 按用户白名单判定 ⇒ 开发工具一条都不在白名单里 ⇒ 全拒。
|
||||
// ===========================================================================
|
||||
|
||||
// ★★★ **核心回归判据**:内部委派的工具调用**不得**被用户权限门拦。
|
||||
//
|
||||
// 这条直接覆盖现场故障:用同一份身份(非 owner)、同一个被拒工具(cmd_run),
|
||||
// 只改 comefrom 一个键 —— internal 必须放行、user 必须拦。
|
||||
func TestInternalToolCallBypassesUserPermissionGate(t *testing.T) {
|
||||
// 基准:非 owner 私聊 ⇒ cmd_run 不在白名单 ⇒ 必须被拦
|
||||
base := func() *sdk.StageContext {
|
||||
ctx := toolCallContext("cmd_run", nil)
|
||||
ctx.Extra = map[string]interface{}{}
|
||||
return ctx
|
||||
}
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.auth = qqAuthContext{Active: true, UserID: 2198972886}
|
||||
ctxUser := base()
|
||||
if err := p.beforeToolcall(ctxUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctxUser.Response == nil {
|
||||
t.Fatal("前置条件错:非 owner 调 cmd_run 本应被拦(判据基线不成立)")
|
||||
}
|
||||
|
||||
// ① comefrom=internal(驻留子)⇒ 必须放行
|
||||
p2 := newPermissionTestPlugin(t)
|
||||
p2.auth = qqAuthContext{Active: true, UserID: 2198972886}
|
||||
ctxInternal := base()
|
||||
ctxInternal.Extra[extraKeyComeFrom] = "internal"
|
||||
if err := p2.beforeToolcall(ctxInternal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctxInternal.Response != nil {
|
||||
t.Fatalf("★ 内部委派的 cmd_run 仍被用户权限门拦住(缺陷回归): %s", *ctxInternal.Response)
|
||||
}
|
||||
|
||||
// ② 硬私有工具同样放行(驻留子是父的内部执行体,父本就有完整权限)
|
||||
p3 := newPermissionTestPlugin(t)
|
||||
p3.auth = qqAuthContext{Active: true, UserID: 2198972886}
|
||||
ctxHard := toolCallContext("memory_recall", nil)
|
||||
ctxHard.Extra = map[string]interface{}{extraKeyComeFrom: "internal"}
|
||||
if err := p3.beforeToolcall(ctxHard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctxHard.Response != nil {
|
||||
t.Fatalf("★ 内部委派的硬私有工具被拦: %s", *ctxHard.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**只有**明确 "internal" 才放行 —— 其余一律按用户处理。
|
||||
//
|
||||
// 这个方向绝不能反:把用户请求误判成 internal = 权限门整体失效,
|
||||
// 比"多拦一次内部调用"严重得多。
|
||||
func TestComeFromOnlyInternalIsTrusted(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(m map[string]interface{})
|
||||
trusted bool
|
||||
}{
|
||||
{"internal", func(m map[string]interface{}) { m[extraKeyComeFrom] = "internal" }, true},
|
||||
{"user", func(m map[string]interface{}) { m[extraKeyComeFrom] = "user" }, false},
|
||||
{"缺失(旧内核)", func(m map[string]interface{}) {}, false},
|
||||
{"空串", func(m map[string]interface{}) { m[extraKeyComeFrom] = "" }, false},
|
||||
{"未知取值", func(m map[string]interface{}) { m[extraKeyComeFrom] = "internal-ish" }, false},
|
||||
{"大小写不同", func(m map[string]interface{}) { m[extraKeyComeFrom] = "INTERNAL" }, false},
|
||||
{"类型错(bool)", func(m map[string]interface{}) { m[extraKeyComeFrom] = true }, false},
|
||||
{"类型错(数字)", func(m map[string]interface{}) { m[extraKeyComeFrom] = 1.0 }, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.auth = qqAuthContext{Active: true, UserID: 2198972886}
|
||||
ctx := toolCallContext("cmd_run", nil)
|
||||
ctx.Extra = map[string]interface{}{}
|
||||
tc.setup(ctx.Extra)
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocked := ctx.Response != nil
|
||||
if tc.trusted && blocked {
|
||||
t.Errorf("★ 应放行却拦了: %s", *ctx.Response)
|
||||
}
|
||||
if !tc.trusted && !blocked {
|
||||
t.Error("★ 应拦却放行了(权限门失效)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**owner 的请求行为不变**(comefrom=user 时不得因为改动而放宽)。
|
||||
//
|
||||
// 防的是"为了让驻留子通过,把门整体放松"这类错误修法。
|
||||
func TestComeFromUserKeepsOwnerAndNonOwnerSemantics(t *testing.T) {
|
||||
// owner + comefrom=user ⇒ 完整放行(含硬私有)
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886}
|
||||
ctx := toolCallContext("calendar_list", nil)
|
||||
ctx.Extra = map[string]interface{}{extraKeyComeFrom: "user"}
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response != nil {
|
||||
t.Fatalf("★ owner 的请求被拦(权限门被收紧了): %s", *ctx.Response)
|
||||
}
|
||||
|
||||
// 非 owner + comefrom=user + 硬私有 ⇒ 仍必须拒
|
||||
p2 := newPermissionTestPlugin(t)
|
||||
p2.auth = qqAuthContext{Active: true, UserID: 10001}
|
||||
ctx2 := toolCallContext("calendar_list", nil)
|
||||
ctx2.Extra = map[string]interface{}{extraKeyComeFrom: "user"}
|
||||
if err := p2.beforeToolcall(ctx2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx2.Response == nil {
|
||||
t.Fatal("★ 非 owner 的硬私有调用应仍被拒(改动把门放松了)")
|
||||
}
|
||||
|
||||
// 无身份(auth.Active=false)+ 有 comefrom=user ⇒ 本门不介入(旧行为)
|
||||
p3 := newPermissionTestPlugin(t)
|
||||
ctx3 := toolCallContext("cmd_run", nil)
|
||||
ctx3.Extra = map[string]interface{}{extraKeyComeFrom: "user"}
|
||||
if err := p3.beforeToolcall(ctx3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx3.Response != nil {
|
||||
t.Fatalf("非本插件来源不该被本门拦: %s", *ctx3.Response)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -119,7 +120,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss")
|
||||
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
|
||||
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
|
||||
log.Printf("[%s] mkdir %s: %v", p.name, p.dataDir, err)
|
||||
}
|
||||
p.loadData()
|
||||
|
||||
@ -184,7 +185,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.wg.Add(1)
|
||||
go p.pollLoop()
|
||||
|
||||
fmt.Printf("[%s] started (%d feeds, poll every %dm)\n", p.name, len(p.feeds), pollMin)
|
||||
log.Printf("[%s] started (%d feeds, poll every %dm)", p.name, len(p.feeds), pollMin)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -193,7 +194,7 @@ func (p *Plugin) Stop() error {
|
||||
p.pollTicker.Stop()
|
||||
p.wg.Wait()
|
||||
p.saveData()
|
||||
fmt.Printf("[%s] stopped\n", p.name)
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -486,7 +487,7 @@ func (p *Plugin) cleanupData() {
|
||||
for _, f := range []string{"feeds.json"} {
|
||||
path := filepath.Join(p.dataDir, f)
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
fmt.Printf("[%s] onRemove cleanup %s: %v\n", p.name, path, err)
|
||||
log.Printf("[%s] onRemove cleanup %s: %v", p.name, path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -95,7 +96,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
ctx.Lock()
|
||||
defer ctx.Unlock()
|
||||
if len(ctx.ToolResults) > 0 {
|
||||
fmt.Printf("[%s] stage after_toolcall(own): %s\n", p.name, ctx.ToolResults[0].Name)
|
||||
log.Printf("[%s] stage after_toolcall(own): %s", p.name, ctx.ToolResults[0].Name)
|
||||
}
|
||||
return nil
|
||||
}, sdk.StageScopeOwnTools)
|
||||
@ -115,25 +116,25 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] started\n", p.name)
|
||||
log.Printf("[%s] started", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
fmt.Printf("[%s] stopped\n", p.name)
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
type wttrResp struct {
|
||||
CurrentCondition []struct {
|
||||
TempC string `json:"temp_C"`
|
||||
FeelsLikeC string `json:"FeelsLikeC"`
|
||||
Humidity string `json:"humidity"`
|
||||
WindspeedKmph string `json:"windspeedKmph"`
|
||||
Winddir16Point string `json:"winddir16Point"`
|
||||
Pressure string `json:"pressure"`
|
||||
Visibility string `json:"visibility"`
|
||||
WeatherDesc []struct {
|
||||
TempC string `json:"temp_C"`
|
||||
FeelsLikeC string `json:"FeelsLikeC"`
|
||||
Humidity string `json:"humidity"`
|
||||
WindspeedKmph string `json:"windspeedKmph"`
|
||||
Winddir16Point string `json:"winddir16Point"`
|
||||
Pressure string `json:"pressure"`
|
||||
Visibility string `json:"visibility"`
|
||||
WeatherDesc []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"weatherDesc"`
|
||||
LocalObsDateTime string `json:"localObsDateTime"`
|
||||
@ -153,7 +154,7 @@ type wttrResp struct {
|
||||
}
|
||||
|
||||
type wttrDay struct {
|
||||
Date string `json:"date"`
|
||||
Date string `json:"date"`
|
||||
Astronomy []struct {
|
||||
Sunrise string `json:"sunrise"`
|
||||
Sunset string `json:"sunset"`
|
||||
@ -161,8 +162,8 @@ type wttrDay struct {
|
||||
MaxtempC string `json:"maxtempC"`
|
||||
MintempC string `json:"mintempC"`
|
||||
Hourly []struct {
|
||||
TempC string `json:"tempC"`
|
||||
WeatherDesc []struct {
|
||||
TempC string `json:"tempC"`
|
||||
WeatherDesc []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"weatherDesc"`
|
||||
WindspeedKmph string `json:"windspeedKmph"`
|
||||
|
||||
Reference in New Issue
Block a user