mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
* 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>
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
|
||
}
|