Files
homeagent-sdk/example/commondev/ssh_wiring_test.go
JianFeeeee c2474f57a2 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 条「判据本身的判据」(防止判据假绿)。
2026-10-17 16:16:04 +08:00

809 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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