mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 06:17:25 +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 条「判据本身的判据」(防止判据假绿)。
211 lines
8.0 KiB
Go
211 lines
8.0 KiB
Go
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)
|
||
}
|
||
}
|