mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
## 是什么
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 条「判据本身的判据」(防止判据假绿)。
388 lines
14 KiB
Go
388 lines
14 KiB
Go
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 里仍有写旧格式的地方")
|
||
}
|
||
}
|
||
}
|