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 条「判据本身的判据」(防止判据假绿)。
346 lines
13 KiB
Go
346 lines
13 KiB
Go
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
|
||
}
|