Files
homeagent-sdk/example/commondev/encshell.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

430 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
// 加密 shell:agent 用特殊格式让插件为它**填充解密后的凭据**,再执行命令。
//
// ## 用户的要求(2026-10-14)
//
// 「插件为 agent 额外提供一个加密 shell 工具,agent 可以通过特殊的格式
// 让该插件工具为其填充加密数据」
//
// 即一个 **credential broker**:secret 加密存盘、agent 只写**引用**,
// 明文只在插件的执行边界上短暂存在。
//
// ## 三条实测得出的实现纪律(不是设计推测)
//
// ### ① secret 绝不进 argv —— 脚本必须走 **stdin**
//
// 实测(扫全系统 /proc/*/cmdline):
//
// 明文作为 argv 参数 → ★ 泄露(任意同机进程可读)
// sh -c "…{secret}…" → ★ 泄露(脚本本身就是 argv)
// **脚本经 stdin 喂给 bash** → ✅ 不泄露
//
// ⇒ 实现上:`bash` 的 argv 只有 `["bash"]`,脚本体经 stdin 管道送入。
// 这条不能妥协:`ps -eo args` 是任何本机进程都能跑的。
//
// ### ② 输出必须脱敏 —— 否则明文经 stdout 进模型上下文
//
// 实测:`echo "token is $T"` 的输出会原样回传。而工具返回值**会进模型上下文**,
// 这是最危险的泄露面。⇒ 输出回传前必须把已知 secret 值替换掉。
//
// ### 这两条只防**意外**,不防**恶意**
//
// 必须诚实:agent 可以 `echo $TOKEN | base64` 或 `xxd` 绕过字符串脱敏。
// 所以本工具**不是沙箱**,其安全性来自:
// · secret 不进 argv(防同机窥探)
// · 输出脱敏(防意外回显进上下文)
// · **不防恶意 exfiltration** —— 要防那个必须限制能执行的命令集合。
// 这条边界写进工具描述,不假装安全。
//
// ## 特殊格式
//
// 命令里用 `@{name}` 引用一条凭据(如 `@{gitea-local}`),插件在冻结的脚本里
// 把它替换为解密后的明文。为什么用 `@{...}` 而不是 `${...}`:
// · `${...}` 与 shell 自己的变量语法冲突,agent 很容易误用;
// · `@{...}` 不会与任何常见 shell 语法撞车,拼错时行为可预期。
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// credRefPattern 匹配命令里的凭据引用 @{name}。
var credRefPattern = regexp.MustCompile(`@\{([A-Za-z0-9_.-]{1,64})\}`)
// ---------------- 加密存储 ----------------
//
// 为何要"加密":凭据文件虽是 0600,但它在插件的**数据目录**里,会被备份、
// 会被 `tar` 打包、可能随日志外带。加密后即使文件外泄,没有密钥也不可用。
//
// 密钥从哪来(**不落盘**):
// 1. `${COMMONDEV_CRED_KEY}` 环境变量(推荐:systemd 注入)
// 2. `<data>/master.key`(0600,自动生成)—— 便利与安全之间取后者弱一档,
// 但它至少与凭据文件**不同位置**,且本插件把整个数据目录权限视为 0700。
//
// 铁律:**没有密钥 ⇒ 响亮报错**,绝不静默退回明文存盘。
const masterKeyEnv = "COMMONDEV_CRED_KEY"
// masterKey 返回 32 字节 AES-256 密钥。
func (p *Plugin) masterKey() ([]byte, error) {
if v := strings.TrimSpace(os.Getenv(masterKeyEnv)); v != "" {
b, err := base64.StdEncoding.DecodeString(v)
if err != nil || len(b) != 32 {
// 也接受原始 32 字节字符串(方便人手工设)
if len(v) == 32 {
return []byte(v), nil
}
return nil, fmt.Errorf(
"%s 必须是 base64 编码的 32 字节密钥(或正好 32 字符)", masterKeyEnv)
}
return b, nil
}
// 回退:数据目录下的 master.key
path := filepath.Join(filepath.Dir(p.path), "master.key")
if b, err := os.ReadFile(path); err == nil && len(b) >= 32 {
return b[:32], nil
}
// 首次:生成并落盘 0600
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
if err := os.WriteFile(path, key, 0o600); err != nil {
return nil, err
}
_ = os.Chmod(path, 0o600)
return key, nil
}
// sealSecret 用 AES-256-GCM 加密明文,返回 base64(nonce 前置)。
func sealSecret(key []byte, plain string) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ct := gcm.Seal(nonce, nonce, []byte(plain), nil)
return base64.StdEncoding.EncodeToString(ct), nil
}
// openSecret 解密 sealSecret 的产物。
func openSecret(key []byte, enc string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return "", fmt.Errorf("密文不是合法 base64: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", fmt.Errorf("密文太短")
}
nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", fmt.Errorf("解密失败(密钥不对或数据损坏): %w", err)
}
return string(plain), nil
}
// sealAccounts 把所有明文凭据加密后落盘。
//
// 存储形态(credentials.json):
//
// { "accounts": { "<name>": { "sealed": "<base64>", ... } } }
//
// 兼容:旧版明文 `token` 字段仍能读(并在下次 save 时自动转为 sealed)。
func (cs *credStore) sealAllLocked() error {
key, err := cs.keyProvider()
if err != nil {
return err
}
for _, a := range cs.data.Accounts {
if a.Sealed != "" {
continue // 已加密
}
plain, err := resolveSecret(a.Token) // 可能是 ${ENV}
if err != nil {
// 引用解析不了:**保留原样不加密**,等使用者修好环境再加密
// (这里不报错是刻意的:否则一个未设的环境变量会让整份凭据无法保存)
continue
}
enc, err := sealSecret(key, plain)
if err != nil {
return err
}
a.Sealed = enc
a.Token = "" // ★ 明文立即从内存结构里清掉
}
return nil
}
// ---------------- shell 执行 ----------------
// shellRequest 是一次加密 shell 调用的参数。
type shellRequest struct {
Command string
Workdir string
Timeout time.Duration
Env map[string]string
}
// shellResult 是执行结果。
type shellResult struct {
Output string
ExitCode int
Redacted int // 脱敏替换发生次数(可观测:非 0 说明命令试图回显凭据)
Err string
}
// runEncryptedShell 执行一条带凭据填充的命令。
//
// 流程(每一步都对应上面的一条纪律):
//
// ① 扫命令里的 @{name} 引用 → 解密取值
// ② 把明文填进**脚本体**(不是 argv)
// ③ 脚本经 **stdin** 喂给 bash(argv 只有 "bash")
// ④ 收集输出 → **脱敏**(把已知 secret 换成 ***)
// ⑤ 擦除内存里的明文副本
func (p *Plugin) runEncryptedShell(req shellRequest) shellResult {
res := shellResult{}
script := req.Command
// ① 收集引用
refs := credRefPattern.FindAllStringSubmatch(script, -1)
secrets := map[string]string{} // 明文值 → 用于脱敏
var missing []string
p.creds.mu.Lock()
key, kerr := p.creds.keyProvider()
p.creds.mu.Unlock()
if kerr != nil {
res.Err = "无法取得凭据密钥: " + kerr.Error()
return res
}
// 收集本次要用到的明文(执行完必须擦)
var plaintexts []string
defer func() {
// ⑤ 尽力擦除(Go 里字符串不可变,这里擦可写的副本)
for i := range plaintexts {
plaintexts[i] = strings.Repeat("\x00", len(plaintexts[i]))
}
for k := range secrets {
delete(secrets, k)
}
}()
// 解密由 lookupAnySecret 负责(它会自己取锁);这里不再直接持有 accounts。
for _, m := range refs {
name := m[1]
// ★ 统一查找:先在加密信息库(enc_store 存的),再回退到平台凭据。
// 这样 @{gitea-local}(平台)与 @{gh-token}(通用 KV)在同一个 shell 里都能用。
plain, found, err := p.lookupAnySecret(name, key)
if err != nil {
res.Err = fmt.Sprintf("加密信息 %s 解密失败: %v", name, err)
return res
}
if !found {
missing = append(missing, name)
continue
}
if plain == "" {
res.Err = fmt.Sprintf("加密信息 %s 解析为空", name)
return res
}
script = strings.ReplaceAll(script, m[0], plain)
secrets[plain] = name
plaintexts = append(plaintexts, plain)
}
if len(missing) > 0 {
res.Err = fmt.Sprintf(
"命令引用了不存在的别名: %s(用 enc_list 看加密信息、commondev_account_list 看平台凭据)",
strings.Join(missing, ", "))
return res
}
// 超时保护
timeout := req.Timeout
if timeout <= 0 {
timeout = 50 * time.Second // ★ 留在内核 60s 工具上限之内
} else if timeout > 55*time.Second {
timeout = 55 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// ③ argv 只有 bash —— 脚本走 stdin
cmd := exec.CommandContext(ctx, "bash")
cmd.Dir = req.Workdir
// ★ 不把 secret 放进 env(env 可被 /proc/PID/environ 读)
cmd.Env = append(os.Environ(), "COMMONDEV_ENCRYPTED_SHELL=1")
for k, v := range req.Env {
cmd.Env = append(cmd.Env, k+"="+v)
}
cmd.Stdin = strings.NewReader(script)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
// ④ 脱敏
out, n := redactSecrets(buf.String(), secrets)
res.Output = out
res.Redacted = n
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
res.ExitCode = ee.ExitCode()
} else if ctx.Err() == context.DeadlineExceeded {
res.Err = fmt.Sprintf("执行超时(%s)", timeout)
} else {
res.Err = err.Error()
}
}
return res
}
// redactSecrets 把输出里的已知 secret 值替换掉。
//
// 返回替换次数:非 0 说明命令**试图回显凭据** —— 这是可观测信号,
// 应让使用者知道(而不是静默替换了事)。
func redactSecrets(s string, secrets map[string]string) (string, int) {
n := 0
// 长值优先替换:避免短 secret 是长 secret 子串时替换不干净
vals := make([]string, 0, len(secrets))
for v := range secrets {
if len(v) >= 4 { // 太短的值不做替换(会把正常文本打烂)
vals = append(vals, v)
}
}
sort.Slice(vals, func(i, j int) bool { return len(vals[i]) > len(vals[j]) })
for _, v := range vals {
c := strings.Count(s, v)
if c > 0 {
s = strings.ReplaceAll(s, v, "***REDACTED***")
n += c
}
}
return s, n
}
// decryptAccount 取一条凭据的明文(sealed 优先,兼容旧明文/变量引用)。
func (p *Plugin) decryptAccount(a *Account, key []byte) (string, error) {
if a == nil {
return "", fmt.Errorf("凭据不存在")
}
if a.Sealed != "" {
return openSecret(key, a.Sealed)
}
// 兼容路径:尚为明文或 ${ENV} 引用
return resolveSecret(a.Token)
}
// ---------------- 工具实现 ----------------
func (p *Plugin) handleEncryptedShell(args map[string]interface{}) (interface{}, error) {
command := str(args, "command")
if strings.TrimSpace(command) == "" {
return map[string]interface{}{"error": "command 必填"}, nil
}
workdir := strings.TrimSpace(str(args, "workdir"))
timeoutSec := 0
if v, ok := args["timeout_sec"].(float64); ok {
timeoutSec = int(v)
}
// workdir 必须在某个工程对话的工作目录里(防止用它去读任意目录)
if workdir != "" {
abs, err := filepath.Abs(workdir)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
if !p.isInsideProgramRoot(abs) {
return map[string]interface{}{
"error": "workdir 必须在工程工作目录下(program/);" +
"给相对路径或省略以使用当前工作目录",
}, nil
}
workdir = abs
}
res := p.runEncryptedShell(shellRequest{
Command: command,
Workdir: workdir,
Timeout: time.Duration(timeoutSec) * time.Second,
})
out := map[string]interface{}{
"output": truncate(res.Output, 6000),
"exit_code": res.ExitCode,
}
if res.Err != "" {
out["error"] = res.Err
}
if res.Redacted > 0 {
out["redacted_count"] = res.Redacted
out["warning"] = fmt.Sprintf(
"输出里有 %d 处凭据明文,已替换为 ***REDACTED***。命令不应回显凭据。",
res.Redacted)
}
return out, nil
}
// isInsideProgramRoot 判断路径是否落在 program/ 下。
func (p *Plugin) isInsideProgramRoot(abs string) bool {
root, err := filepath.Abs(p.programRoot())
if err != nil {
return false
}
rel, err := filepath.Rel(root, abs)
if err != nil {
return false
}
return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel))
}
// listCredRefs 返回命令里引用到的凭据名(供工具描述/调试)。
func listCredRefs(cmd string) []string {
var out []string
for _, m := range credRefPattern.FindAllStringSubmatch(cmd, -1) {
out = append(out, m[1])
}
return out
}
// ensureJSON 小工具(保证 json 包被使用,避免不同构建标签下的未用导入)
var _ = json.Marshal