Files
homeagent-sdk/example/commondev/sidecar_ssh.go
JianFeeeee 81a3efa4df 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>
2026-10-17 19:44:15 +08:00

346 lines
13 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
// 传输层: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
}