feat: 模块化内核(编译期依赖图定序)+ 密钥防火墙 + 插件三态生命周期 (#10)

* feat(kernel): 模块化内核 + 编译期依赖图定序

把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。

## 为什么

原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
 · nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
   只靠 ticker 10min 才没炸(隐式依赖,无声明)
 · 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
 · 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
   ⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)

## 怎么改

- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
  nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
  scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
  lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
  all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)

## 判据

kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
 · 删掉 AddCloser 的锁 → -race 报数据竞争
 · Scheduler 同批跑去重 → 203 vs 200
 · 图 hash 不匹配 → 拒绝启动

## 顺带修的缺陷

热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。

实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。

* feat(security): 密钥防火墙 + 配置加密 + close 纪律

## 密钥防火墙

模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
 · 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
 · 入向:模型传别名,入口处填回真值(插件完全无感知)
 · 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件

模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。

## 配置加密

- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
  enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计

## close 纪律(用户明确要求:只要调 close 就要有前置检查)

internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
 · `select { case <-ch: default: close(ch) }` **不是**并发安全的
   (两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
 · `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
   症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
 · map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
   改成 *pendingRPC 包装,每通道一个 once

## 判据

security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。

* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复

## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)

- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
  "未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
  按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
  (不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
  那是进程退出用的,误用于"暂停"就是关了就死

## 数据目录迁移(伪关机 → 迁移 → 唤醒)

- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
  逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
  → 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
  恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本

★ 迁移脚本的三个实测教训(都写成了判据):
 1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
    加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
 2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
    并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)

## 发行包修复(全部经实测确认)

 1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
    ("生产构建(内嵌 hash 非空)但读不到依赖图")
 2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
 3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
    ⇒ --version 显示 unknown
 4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH

## ★ build.sh 的版本号必须校验(真实事故)

原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。

修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。

## deb 的 systemd unit 改为 guard 模式

ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。

## 其他

- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
  修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)

* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)

- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
  串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
  "出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
  白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
  mgrIsRuntimeStopped)+ settings 对齐

判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。

* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)

GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:

  internal/security/prefilter_test.go:39
  internal/security/store_test.go:177
  internal/security/survey_test.go:20

它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。

★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。

改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
  glpat-abcdefghijklmnopqrst       → glpat-FAKE-TEST-TOKEN-NOT-REAL
  sk-abcdefghij...012345           → sk-FAKE-TEST-KEY-NOT-REAL-0000000
  AKIAIOSFODNN7EXAMPLE             → AKIA0000FAKE0000TEST   (保持 20 字符)
  ASIAIOSFODNN7EXAMPLE             → ASIA0000FAKE0000TEST
  xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL

★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。

顺带核实:全仓 grep 无任何真实密钥泄漏。

* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一

上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:

  VERSION="${VERSION:-$(git describe --tags --dirty)}"

无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。

危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。

修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。

判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。

实测:不传 VERSION 跑 package-linux.sh,输出
  [PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
  Version: 1.4.0

* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)

CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:

  --- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
      bridge_sync_test.go:170: 读模板: open
      ../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
      no such file or directory

## 根因:判据依赖了一个**被 gitignore 的路径**

`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
  · 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
  · CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**

我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。

## 修法

文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。

★ 这不是"放弃检查":
  · 跨仓一致性由 **SDK 仓自己的判据**守着
    (tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
  · 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)

## 验证

 · 本机(有文件)  → PASS(判据真跑)
 · 模拟 CI(移走 tools/)→ SKIP(不再 fail)
 · 全量 53 包 ok / 0 fail

## 顺带

同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
This commit is contained in:
JianFeeeee
2026-10-17 19:18:24 +08:00
committed by GitHub
parent 03d87a4e21
commit a68d6a428c
266 changed files with 30185 additions and 1424 deletions

View File

@ -0,0 +1,871 @@
---
name: commondev-discipline
description: commondev 主管式多仓库开发流水线的使用与开发纪律。当你要为某个工程开一条分支、把工程派给一个下属 agent、或用 pi 拉起执行 agent 干活时必读。核心动作:为每个工程对话新建驻留式子 agent 并把该工程的一对通道划给它(否则所有工程都挤在根 agent 上)。**git 托管平台的操作(Gitea/GitHub/GitLab)不在插件里,而在本文** —— 用 enc_shell + 保存的凭据调 curl/CLI 完成。也含 hmap 插件开发与部署纪律。
version: 2.0.0
author: HomeAgent
---
# commondev 纪律(主管模式 · 工程对话 · 平台操作)
本技能三部分,**都可能与你的任务直接相关**:
- **第一部分:使用者纪律** —— 怎么开工程、怎么派活。
- **第二部分:★ git 平台操作** —— **本文是权威**。开 PR、审批、分支保护怎么做。
- **第三部分:开发与部署纪律** —— 怎么不踩坑。
> ## ★ 架构边界(用户拍板 2026-10-15)
>
> | 层 | 负责 |
> |---|---|
> | **插件 commondev** | 项目台账、工作目录、pi 会话与通道、判据与变异复核、冲突预检、凭据保存与安全执行 |
> | **本文(skill)** | **各 git 托管平台的认证头、API 端点、PR 与分支保护流程** |
>
> **插件里没有平台逻辑。** 操作 Gitea/GitHub/GitLab 等平台一律:
> 读本文 → 用 `enc_shell` + `@{凭据名}` 执行 `curl`/`gh`/`tea`。
>
> ⇒ **新增一个托管平台只需改本文,不需要改插件、不需要重编译、不需要重启服务。**
>
> 原本这些在插件里(`platformSpec` / `commondev_account_*` / `commondev_pr_*` /
> `commondev_protect_branch` / `commondev_repo_search`),代价是:
> 每接一个平台都要改 Go 代码 → 重编译 → 重打包 → 重启,
> 且**未列出的平台直接报错**(GitLab/Gitee/Bitbucket 全不支持)——
> 而 agent 用 curl 本来什么平台都能打。这 10 个工具已移除。
> 看不懂某个词时的权威文档(按顺序读):
> 1. `docs/zh/commondev-design.md` —— 目标机制、台账 schema、判据与变异、安全(§12 是架构变更)。
> 2. `docs/zh/commondev-agent-runtime-design.md` —— 运行时与内核接入(续篇)。
> 3. `docs/zh/kernel-secret-firewall-design.md` —— 凭据模块(enc_* 的底座)。
> 4. `docs/zh/resident-subagent-design.md` —— 驻留子设计的原始文档。
>
> **本文与文档冲突时,以文档为准,并回来修正本文。**
---
## Setup: 第二部分 git 平台操作(本文是权威,不要凭记忆写 API)
### 11. 所有平台都是同一个形状
「带认证头发一个 REST 请求」。用 `enc_shell` + `curl` 即可:
```
enc_shell(command="curl -s -H \"<认证头>\" <API前缀><路径>")
```
**差异只有两个维度:API 前缀 与 认证头。** 其余是 REST 常规。
### 12. 认证头与 API 前缀(逐平台,★ 四种形状互不相同)
| 平台 | API 前缀 | 认证头 |
|---|---|---|
| **Gitea** | `<base>/api/v1` | `Authorization: token <T>` ← **不是 Bearer** |
| **GitHub** | `https://api.github.com` | `Authorization: Bearer <T>` |
| **GitLab** | `<base>/api/v4` | `PRIVATE-TOKEN: <T>` ← **不是 Authorization** |
| **GitCode** | `https://api.gitcode.com/api/v5` | `Authorization: Bearer <T>` |
| **Gitee** | `https://gitee.com/api/v5` | `access_token=<T>`(**查询参数**,不是头) |
⚠ 头名、前缀、甚至「用头还是用查询参数」都不同。**未知平台先查官方文档,不要猜形状。**
### 13. 先保存凭据,再用它
```
enc_store(alias="gitea-token", value="${GITEA_TOKEN}") # 值优先走 ${ENV} 或 @file:
enc_shell(command="curl -s -H \"Authorization: token @{gitea-token}\" http://192.168.2.106:3000/api/v1/user")
```
⚠ **优先用 `enc_store`,不要用 `commondev_account_add`** —— 后者已不存在
(它内建了平台语义:platform/role/base_url,那正是被移出的东西)。
**先验凭据再干活**(早失败,别推了一半才发现 token 错):
```
enc_shell(command="curl -s -o /dev/null -w '%{http_code}' -H \"Authorization: token @{gitea-token}\" http://192.168.2.106:3000/api/v1/user")
# 期望 200;401/403 = 凭据无效或权限不足
```
### 14. 常见操作(以 Gitea 为例,其他平台换前缀与头)
```bash
# 搜仓库
curl -s -H "Authorization: token @{t}" "http://host/api/v1/repos/search?q=myname&limit=20"
# 看仓库 / 分支
curl -s -H "Authorization: token @{t}" "http://host/api/v1/repos/jianf/MyProj"
curl -s -H "Authorization: token @{t}" "http://host/api/v1/repos/jianf/MyProj/branches"
# 开 PR(head=你的分支,base=目标分支)
curl -s -X POST -H "Authorization: token @{t}" -H 'Content-Type: application/json' -d '{"title":"...","head":"feat/x","base":"main"}' "http://host/api/v1/repos/jianf/MyProj/pulls"
# 查 PR 状态
curl -s -H "Authorization: token @{t}" "http://host/api/v1/repos/jianf/MyProj/pulls/7"
# 合 PR
curl -s -X POST -H "Authorization: token @{t}" -H 'Content-Type: application/json' -d '{"Do":"merge"}' "http://host/api/v1/repos/jianf/MyProj/pulls/7/merge"
```
**push 时凭据不进 remote URL**:
```bash
# ✅ 一次性注入(仅本次命令);clone 出来的 remote 保持干净
enc_shell(command="cd <repo> && git -c http.extraheader=\"Authorization: token @{t}\" push origin feat/x")
```
⚠ 若改用 `https://token@host/...` 形式,token 会**持久化进 `.git/config`** ——
删掉临时目录也删不掉凭据(`git remote -v` 就能读出来)。设计 §7.2 明令禁止。
### 15. Gitea 分支保护:让「必须复审」成为**机制**而非礼貌
**光有流程约定不算机制** —— 执行 agent 可以直接推主干。必须让**平台**拦。
```bash
curl -s -X POST -H "Authorization: token @{t}" -H 'Content-Type: application/json' -d '{"enable_push":false,"required_approvals":1,"block_admin_merge_override":true,"dismiss_stale_approvals":true}' "http://host/api/v1/repos/jianf/MyProj/branch_protections?name=main"
```
四个开关的意义(缺一个机制就有洞):
| 开关 | 作用 | 缺了会怎样 |
|---|---|---|
| `enable_push: false` | 禁止直接推送 | 执行 agent 直推主干,绕过评审 |
| `required_approvals: ≥1` | 至少一个审批 | 自己就能合 |
| `block_admin_merge_override: true` | admin 也不能绕过 | 用 admin token 的人绕过一切 |
| `dismiss_stale_approvals: true` | 新提交作废旧审批 | 先批准再改代码,批准白拿 |
**真机实测(Gitea 1.24.2)**:
| 动作 | 普通用户 token | admin token |
|---|---|---|
| push `main` | ❌ `pre-receive hook declined` | ⚠ 可能成功(见下)|
| push `feat/*` | ✅ | ✅ |
| 自合 PR | ❌ `Does not have enough approvals` | ⚠ 取决于开关 |
| 审批 → 合 PR | — | ✅ |
⇒ **推论:执行侧必须用普通用户 token。** 用 admin token 会**静默绕过分支保护**
(看起来正常,实际机制失效且无报错)—— 这是最容易搞错的一点。
⚠ 分支保护已存在时用 **PATCH**(POST 会 403 `already exist`)。
### 16. 审批:三个必踩的 API 坑(实测)
1. **`event` 必须是 `APPROVED`,不是 `APPROVE`。**
传 `APPROVE` 时请求**返回 200 且不报错**,但 review 状态落成 `PENDING` ——
于是 `required_approvals` 永远不满足,合并时报 `Does not have enough approvals`。
表象(权限不足)与真因(枚举值少个 `D`)毫无关联,极难自查。
2. **必须带 `commit_id`** —— 不带时同样落成 `PENDING`(与 `event` 是两个独立条件)。
3. **`required_approvals` 也约束 admin** —— 别指望用 admin 跳过审批。
```bash
# ① 先取 head sha(审批必须绑定**具体提交**,不是"移动的目标")
enc_shell(command="curl -s -H \"Authorization: token @{t}\" http://host/api/v1/repos/jianf/MyProj/pulls/7 | grep -o '\"sha\":\"[a-f0-9]*\"' | head -1")
# ② 审批(event 必须 APPROVED,且带 commit_id)
enc_shell(command="curl -s -X POST -H \"Authorization: token @{t}\" -H 'Content-Type: application/json' -d '{\"body\":\"通过\",\"event\":\"APPROVED\",\"commit_id\":\"<sha>\"}' http://host/api/v1/repos/jianf/MyProj/pulls/7/reviews")
```
**失败时保留平台原话**:`Does not have enough approvals` 是区分「机制生效」
与「配置错了」的唯一线索,别把它翻译掉或吞掉。
---
## Usage: 第一部分 使用者纪律
> **★ 设计总纲 [已定]**:插件对 pi 做的事,本质就是
> **「把给 pi 发消息包装一下、把 pi 返回包装一下」**,深度融合内核的 IO 抽象与中断系统
> —— 即 **pi = 一个 Device**。**不要自己造调度器 / 会话路由 / 中断栈**,
> 全部接到内核已有的那套上(`InjectInputTo` / `RegisterOutputChannel` / `InjectInterrupt*Opts`)。
### 21. 角色与单位
| 角色 | 是谁 | 干什么 |
|---|---|---|
| **人** | 老板 | 只与主管对接 |
| **主管** | 你(长期驻留的 HomeAgent 主 agent) | 拆任务、**定义判据**、复审、决定合入 |
| **执行 agent** | 按工程拉起的一个 pi / dsh 实例 | 在某棵工作树里干活 |
| **commondev 插件** | 外部独立插件 | 仓库/凭据/分支/PR/测试,全部机械化操作 |
**为何能用内核机制**(而不是自己造):
| 内核概念 | pi 侧对应 | 落点(内核已有) |
|---|---|---|
| **给 pi 发消息** | `pi.prompt` / `steer` / `follow_up` | `InjectInputTo(source, "commondev/<id>", …)` |
| **pi 返回** | sidecar 回调 / `tool_execution_*` / `agent_settled` | `InjectTextTo(source, "commondev/<id>", …)`(按 inputch Owner 路由) |
| **中断 pi** | `steer`(插到当前轮之后) | `InjectInterruptTextOpts(..., Priority: L3)` |
| **音声/图像产物** | pi 可产出的媒体 | `OutputCapabilities()` 声明 |
⇒ **插件只做两件额外事**:① 把 pi 接成 Device;② 台账记 `session_id` 并断言未变。
调度/中断/路由/记忆策略**全是内核已经在做的事**。
### 22. 「工程对话」是一等对象
它是四元组,缺一不可:
```
工程对话 (id = br-<project>-<slug>)
├─ workdir <插件数据目录>/program/<hash>-<name>
│ ★ hash = H(项目+分支) 是**唯一性来源**
│ ⇒ **同一仓库的不同分支必然是不同目录**(否则两 agent 共用工作树、改动互相污染)
│ ★ name = 分支名派生,**仅做可读性**,不承担唯一性
│ (派生后可能撞:`feat/a` 与 `feat.a` 都成 `feat_a`,所以必须带 hash)
│ 独立 clone(非 worktree、非 --reference);合入后**整目录删除**
├─ session_id pi 会话(★ 续投的唯一依据)
├─ runtime local(默认,内嵌) | ssh(远端同样建目录拉 pi)
├─ host runtime=ssh 时的目标主机(**branch_open 时给**,见 §24.1)
├─ agent_cli 执行后端(默认 pi;其他 CLI 需自备 sidecar,见 §24.2)
└─ channels 插件为该工程注册的一对通道
├─ inputch commondev/<id>
└─ outputch commondev_<id>
```
**一个仓库可并行开发**:每分支一份独立 clone,互不污染、可同时推进。
但并行有前提——必须是**互不相干的模块**;有依赖的任务请先拆边界。
> **为何不能用 worktree / `--reference`**(实测):worktree 删后有
> `.git/worktrees/` 残留(需 `prune`);`--reference` 有 alternates 外部依赖,
> mirror 被清即损坏。**本地 clone 对象为硬链接**(实测 inode 相同)⇒ 省磁盘、
> 无外部依赖、刪掉即彻底。代价:失去 worktree「同分支不可双挂」的保护
> ⇒ 台账层必须补**唯一性闸**(同名未结分支响亮拒绝)。
### 23. ★ 必做:为每个工程对话新建驻留子,并把通道划给它
**★ 顺序很关键**:通道是**拉起 pi 拿到 session 之后**才注册的,所以你的动作是:
```
建工程 ──► commondev_agent_up(拉起 pi,拿到 session_id)
└─► 插件**此刻才**注册通道 inputch commondev/<id> + outputch commondev_<id>
└─► ★ 你建驻留子并把这对通道划给它
```
**为何必须先有 session**:通道是“与某个具体 pi 会话对话的入口”,没 session 就是空壳。
顺序错了(先建子)会拿到一个指向不存在通道的 `output_send__…`。
**插件只能把通道登记出来;「划入(assign)」只有内核能做。** 所以这一步**必须由你(agent)
自己调内核工具完成**,插件无法代劳。
```
resident_agents create
id = dev-<br-id>
input_chs = commondev/<br-id> ← 划入该 inputch(可用斜杠)
allowed_outputs = commondev_<br-id> ← 授权该 outputch(★ 下划线,见下)
capacity = 64
task_prompt = "你在 <workdir> 推进 <work>。
下任务给自己用 output_send__commondev_<br-id>(payload=…),
pi 的结果会回投到你的 commondev/<br-id>;
有结论用 notify_parent 汇报。"
```
**★ 下任务不需要新工具**:给 pi 发消息就是子调**内核原生的输出门**
`output_send__commondev_<br-id>(payload="…")` ⇒ 打到插件的通道 handler ⇒ 插件
查台账拿到 `session_id` 后 `sidecar.prompt()`。**插件不提供 `agent_send` 之类的工具**。
**⚠️ 通道命名(很容易踩)**:
| | 能用 `/` 吗 | 为什么 |
|---|---|---|
| **inputch** | ✅ | 内核自己就用 `child/<id>` |
| **outputch** | ❌ **绝对不行** | 它会拼成 `output_send__<name>`,上游要求函数名 `^[a-zA-Z0-9_-]{1,64}$`;违反的后果不是“这个工具不可用”,而是**整条请求 400、agent 整体失语** |
⇒ 所以 inputch 叫 `commondev/<br-id>`、outputch 叫 `commondev_<br-id>`。
`<br-id>` 本身也只能用 `[A-Za-z0-9_-]`。
**为什么必须这么做(不是可选的优化):**
1. inputch 是**最基本的输入路由单位**,路由按 `Owner` 在**进内核之前**完成。
划给子之后,**该工程的输入只流向那个子**,根 agent 看不到它 —— 这正是
多工程并行能做到"互不串台"的原因。
2. **不划会的代价**(如果你偷懒):所有工程对话的输入都涌向**根 agent**,
根成了瓶颈、上下文被 N 条工程的细节淹没,多工程并行**等于没做**。
现场教训(`resident-subagent-design.md`):子挂着 `inputch=[timer]` 而 timer
的输入打在父身上,**子的轮次永远是 0** —— 登记了不等于划了。
**落地判据**(怎么知道做对了):
- 投一条输入到 `commondev/<br-id>`,应只被 `dev-<br-id>` 处理;
- 根 agent 的轮次**不**因这条输入增加;
- `input_channels` 工具(内核自带,单工具多视图)用 `view=by_agent` 能看到划分总览。
### 24. 形态选择、自包含运行时与远端环境
**默认本地内嵌**(进程内 pi 实例);通过配置 `runtime=ssh` 切到**远程**,
远端**同样创建目录并在该目录拉起 pi**。
**★ 自包含运行时**:插件**自带独立的 node 与独立的 pi**,都装在**插件工作目录**,
**pi 的插件也装到这个目录** —— 不靠宿主机的 node/pi,也不污染用户的 `~/.pi`。
**独立 node/pi 随包分发**(不首次下载、不依赖用户装)。
```
<插件数据目录>/runtime/
node/ pi/
agentdir/ ← 每个内层 pi 必须传 PI_CODING_AGENT_DIR 指这里
settings.json ← pi 自己的配置(偏好 / 默认 model / 全局包声明)
models.json ← pi 自己的配置(★ LLM 源写这里)
auth.json ← pi 自己的配置(key / OAuth,0600)
mcp.json ← pi 自己的配置(MCP 服务器)
skills/ prompts/ themes/ extensions/
```
#### 24.1 远端(ssh)怎么开
两个轴**正交**,开线时一起给:
```
commondev_branch_open(project="p", branch="feat/x",
runtime="ssh", ssh_host="build@10.0.0.9")
```
- `runtime=ssh` 而**没给 `ssh_host`** → 直接拒绝(不会拖到拉起时才报)
- **未知 runtime**(比如 `k8s`)→ 响亮拒绝,**不静默降级为 local**
(静默降级会让"我以为跑在远端"一路错下去)
- 远端前置条件(**先配好,不要指望交互输密码**):
- 从本机到目标机的**免密**(`BatchMode=yes`,卡在密码提示会超时)
- 远端有 node(不在 PATH 里就配 `ssh_node_path`,例如 `/opt/node/bin/node`)
- **必配两个根目录**(缺任一,远端线开不起来):
- `ssh_workdir_root` — 远端放**工程工作目录**的根
- `ssh_runtime_root` — 远端放 **sidecar 脚本**的目录
- **★ 工作目录在远端准备**:本地 `clone --mirror` 出来的目录远端看不到,
所以远端线走的是**远端建目录 → 远端共享 mirror(`<root>/.mirrors/`)→
远端 clone → 远端切分支**。同一远端上的多条分支线共享一份 mirror。
台账里的 `workdir` 会被改写成**远端路径**(不是本地路径)。
远端拉取要求**远端自己能认证该仓库** —— 本插件**不把凭据推到远端**
(凭据不进工作目录、不进 remote URL)。拉不动就响亮报错。
- 每次拉起会 `scp` 把 sidecar 推过去(幂等)——**不推就会跑上一次的旧脚本**,
症状是"改了没生效",极难归因。推送失败**响亮报错**,不静默用旧脚本。
- 用 `commondev_agent_state` 看 `ssh_host` 确认打到哪台机。
#### 24.2 接其他 CLI(zcode 等)——**不改插件核心**
执行后端是**声明式**的:放一个目录就多一个后端。
```
<插件数据目录>/runtime/<cli>/
driver.json ← 描述:脚本名 / 参数 / 环境变量 / LLM 源策略
sidecar.mjs ← 实现 sidecar 契约(ping / prompt / state / ...)
```
**脚本查找链**(按优先级,go 侧 `locateSidecarScript`):
| 顺序 | 位置 | 用途 |
|---|---|---|
| ① | `<插件数据>/runtime/<cli>/<script>` | 用户自备 / 覆盖随包版本 |
| ② | `<插件目录>/rawfile/runtime/<cli>/<script>` | **随包分发**(内置 pi 靠这条) |
| ③ | `<可执行文件同目录>/<script>` | 开发期(hmapdev debug) |
⚠ **② 是内置 pi 的必需项**(不是可选):`sidecar.mjs` 随插件包分发,
装在 `rawfile/runtime/pi/`。全新机器上不需要任何手工准备 ——
若这条断了,表现是「插件装了、工具都在、但一拉 agent 就报找不到脚本」,
且错误里那个路径用户从没听说过。
自己加 CLI 时:把 `driver.json` + 脚本放在 `<插件数据>/runtime/<cli>/`(①),
两条都行;① 优先。
`driver.json` 关键字段:
| 字段 | 含义 |
|---|---|
| `script` | 脚本文件名(默认 `sidecar.mjs`) |
| `node` | `auto` = 用插件自带的 node |
| `args` | 参数模板,占位符 `{workdir} {sessionId} {sessionName} {dataDir} {pluginDir}` |
| `env` | 环境变量模板(同样支持占位符) |
| `llmConfig` | `pi` = 插件替它写 pi 的配置;**`none` = 它自管凭据,插件不碰** |
| `sessionDirSuffix` | 会话目录后缀 |
**纪律**:
- **未知 `agent_cli` 响亮报错,绝不回退到 pi** —— 回退会用错后端跑任务而无提示。
- `driver.json` **损坏也报错**(不猜默认),否则是"改了没生效"的经典来源。
- 内置 pi **不需要** `driver.json`(有内建描述),老部署不受影响。
- 现成例子看 `examples/custom-cli/zcode/`(**源码核实过**的 ZCode 契约)。
### 25. ★ 两种插件安装粒度(临时 vs 全局)
| 粒度 | 作用域 | 落到实处 | 用什么 |
|---|---|---|---|
| **临时** | 一个工程对话 | `<workdir>/.pi/settings.json` | `pi install -l` |
| **全局** | 插件自带 pi 的**所有**工程 | `<agentdir>/settings.json` | `pi install` + `PI_CODING_AGENT_DIR` |
**支持的源**(实测):`npm:@scope/pkg@1.0.0`(带版本即钉住)/ `git:github.com/u/r@v1` /
`https://github.com/u/r`(当 git 源)/ `ssh://…` / 本地路径(**不复制**)。
**三条纪律**:
1. **必须传 `PI_CODING_AGENT_DIR`**,否则「全局」安装会写进**用户的** `~/.pi/agent/settings.json`
—— 那是对宿主的越界。
2. **⚠ 安装是复制,不是引用**:`pi install ./pkg` 会把包**复制**到目标;改源目录
**不影响已装的那份**(我曾因此得出「插件不生效」的错误结论)。要更新必须重装。
3. **安装是显式一步**,不藏在 `agent_up` 里;来源(npm 包名 / 仓库 URL / 版本钉点)要可见。
### 26. ★ LLM 源与 pi 配置:直接写 pi 的配置文件
⛔ **不要自建第二套配置存储**(本稿前版曾提 `config_commondev`,**已废弃**)。
**pi 本身就有完整的配置系统**,插件只是它的**受控写入入口**:
```
<agentdir>/settings.json ← 偏好 / 默认 model / 「全局」包声明
<agentdir>/models.json ← 兼容端点与模型(★ LLM 源写这里)
<agentdir>/auth.json ← API key / OAuth(0600)
<agentdir>/mcp.json ← MCP 服务器
```
内核把 LLM 源存在 **`config` 表**(不是 `config_<plugin>`),键前缀 `core.llm.`:
```
core.llm.provider = llmsproxy ← ★ 默认源就是这一项
core.llm.sources.<n>.base_url / .api_key / .model / .adapter / .context_window …
```
**导入时字段一对一对上**:`base_url`→`baseUrl`、`api_key`→`apiKey`、
`adapter=openai`→`api="openai-completions"`、`model`→`models[].id`、
`context_window`→`contextWindow`。
**工具面**(每个工具对应一处 pi 配置):
```
commondev_pi_source_import 把内核的源批量导入内置 pi
commondev_pi_source_add 添加源(写 models.json / auth.json)
commondev_pi_source_list 列出(默认**不返回** api_key)
commondev_pi_source_default 指定默认源(写 settings.json)
commondev_pi_config_get/set 读写其他 pi 配置
commondev_pi_mcp_add/remove/list
```
**四条实现纪律**:
1. **写的就是 pi 的配置文件**,不造中间层;格式与 pi 自己写出的保持一致
(否则 pi 重写文件时会把自定义字段抹掉)。
2. **改前备份、改后回读校验**:json 写坏会让内层 pi 直接起不来;失败则回滚 + **响亮报错**。
3. **key 不进日志/不进返回值**。
4. **不改用户全局**:所有写入带 `PI_CODING_AGENT_DIR=<agentdir>` 或显式 scope,
绡不碰 `~/.pi/agent/`。
**两条红线**:
1. **key 不能明文落盘到无保护位置**:`models.json`/`auth.json` 含明文 key ⇒ 目录 **0700**、
文件 **0600**。内核那套是明文存 `config.db`(**644 全局可读**)—— **别把这套惯例带进来**。
2. **不得静默回退到宿主凭据**:导入失败/源不可用就**报错**,不要偷偷用 `~/.pi/agent/auth.json`
跑起来(那是宿主的凭据,用了就是越界)。
### 27. 远端环境缺失:问用户,不要自己装(硬纪律)
```
ssh 不通 → 报错(配置问题,不是缺环境)
无 pi / 无 node → ★ 注入 L3「远端 <host> 未装 X,是否安装?」→ **你(agent)去问用户**
目录不可写 → 报错(权限问题,需人处理)
```
- **SDK 没有“向用户提问”的 API**(插件是子进程,不能直接弹 UI)。现有机制是插件把
决策消息注入给 agent(L1–L3),**由 agent 问用户**(先例:`cli` 的 `/stop` 用 L3)。
- **不得擅自安装**:不 `curl | sh` 到别人的机器上。要先说清「装什么、装哪里、要什么权限」。
- **不冒充成功**:环境缺失时 `agent_up` **响亮失败** + 留一条待决策项,
**不静默降级到本地跑**(那会让人以为活派到了远端)。
- **问完要能续**:安装动作本身是**可见的一步**(可查、可重跑),不是 `agent_up` 的隐式副作用。
### 28. 投递纪律(**这是最容易犯的错**)
**每个工程的投递必须落到同一个 `session_id`。** 实测反例(别重犯):
| 错法 | 后果 |
|---|---|
| 漏传 `--session-id` | `pi` **静默开新会话** —— 你以为在催上一次,其实是把活派给了陌生人 |
| 两个进程**并发**同 `--session-id` | 会话文件被写坏:`user A`/`user B` 相邻,`assistant B` 在前、`assistant A` 落在最后 ⇒ B 那轮的模型上下文里**根本没有 A 的用户消息**,树链交错 |
⇒ 投递前先拿台账里的 `session_id`,投完**断言它没变**;一个 session **绝不并发投递**。
这两条插件侧已做成结构性保证(单一投递队列 + 断言),你做主管时**不要绕过插件**直接
去敲 `pi -p`。
**★ 另外两条实测硬经验(别再踩):**
3. **SDK 路径必须显式指定会话 id**。用 `createAgentSession({cwd})` 而不传
`sessionManager` 时,pi **自己生成 UUID** 作会话 id ⇒ 与台账名字不一致 ⇒
断言**永远失败**(实测:期望 `br-cdtest-feat_alpha`,实际 `01a13959-…`)。
正确:`SessionManager.create(cwd, sessionDir, {id})` 再传给 `createAgentSession`。
4. **工具返 `ok` ≠ 消息到了**。内核对成功回执刻意**极简**(防 output loop),
所以 `ok` 只说 handler 成功了。必须去会话文件里看到 `user: <payload>` 才算数。
(这条能防住“以为投了、其实没投”这类假成功。)
### 29. 三层审查的分工(别把自己变成贵的高级码农)
| 层 | 审什么 | 不审什么 |
|---|---|---|
| agent 自审 | 代码正确性、风格、实现细节 | — |
| **你(主管)复审** | **这次的改动,判据够不够**:引入了哪些新失败方式?判据覆盖了吗?变异咬得住吗? | 逐行读 diff |
| 插件复核 | 机械执行「正向 + 变异」 | 语义判断 |
### 30. ★ 不要把「测试通过」当作「改对了」
插件只能跑**你预先写下的判据**。判据没覆盖那条 bug,插件照样打勾 ——
自动化**不会**发现假绿,**只会让假绿跑得更快、更可信**。
因此复核**必须两步**:
| 阶段 | 做什么 | 不通过的后果 |
|---|---|---|
| **正向** | 跑判据 → 期望通过 | 实现有 bug,驳回 |
| **变异** ★ | 自动改坏被测代码 → 期望判据**报红** | **判据太松,驳回** |
**没有变异阶段,就是在给「测试通过」这个信号不断充值信用,直到它骗一次大的。**
本仓实证:一条只查前缀的判据在变异测试下仍全绿,是变异把它咬出来的。
配套纪律:判据写「某标识符是否出现在函数体」时**必须先剔除注释再匹配**
(曾因注释里含 `outputParent` 之类的词导致假绿)。
### 31. 工程的目录与生命周期(一个仓库并行开发)
```
branch_open
└─ <hash>-<name>/ (由 mirror **本地 clone** 派生)
★ hash = H(项目+分支):**唯一性来源**(同仓库不同分支必不同目录)
★ name = 分支名派生:仅可读性
└─ 干活 → 提交 → push → PR
└─ 合入(或废弃)
└─ ★ **整个目录删除**(不保留、不归档)
```
**要点**:
- **合入后直接删目录** —— 用户明确要求;不删则下次同名分支会与残留目录混淆。
- **一个仓库可并行**:每分支一份独立 clone。
- **★ 同仓库不同分支必然不同目录**:`<hash>` 含分支(`H(项目+分支)`),hash 是**唯一性来源**;
`name` 只做可读性。实测:`feat/alpha` vs `feat/beta` → `b3426c01-feat_alpha` vs `0566c1f5-feat_beta`。
- **⚠ 慢操作必须异步**:内核把**每次工具调用**硬限制在 **60 秒**(`toolcall.go:62`,
硬编码无配置项)。`git clone --mirror` 同步做 ⇒ 工具报超时、**但 mirror 其实已建好**
—— 调用方只看到失败、状态却是“做了一半”。正确做法:工具只登记 + 发起后台作业,
用状态工具追进度(本插件:`commondev_job_status`)。
- **为什么不是 worktree / `--reference`**(实测):worktree 删后有 `.git/worktrees/`
残留(需 `prune`);`--reference` 有 alternates 外部依赖,mirror 被清即损坏。
**本地 clone 对象为硬链接** ⇒ 省磁盘、无外部依赖、删掉即彻底。
- **⚠️ 代价:唯一性闸必须补上**。worktree 天然禁止「同分支双挂」,独立 clone **没有**这条
保护(两份 clone 都能在 `feat/x` 上各自 push,直到被拒才知道)。
台账必须对「同名且未结(open/review)的分支」建唯一索引,重复 `branch_open`
**响亮拒绝**,不静默复用。
- **并行有前提**:并行的必须是**互不相干的模块**。有依赖的任务(A 改接口、B 用接口)
两棵树互不可见,问题会集中到 merge 时爆发 ⇒ **先拆边界再谈并行**。
- 与内核「子不得比父活得久、无残留」同一条纪律(见第 20 节)。
---
## Notes: 第三部分 hmap 插件开发与部署纪律
> 面向「要改 commondev」或「要写另一个插件」的场景。**所有条目都在本机实测过**,
> 不是从文档抄的。
### 32. 先搞清三件事,别猜
```bash
hmapdev version # 工具链版本 + SDK 模块 + 构建用 Go
hmapdev sdk current # 当前活跃 SDK 版本(★ 动手前必看)
hmapdev sdk list # 本机已装的所有 SDK 版本
```
- 核心仓 `/home/program/TrueAgent`(Go module `gitcode.com/JianFeeeee/HomeAgent`)
- **SDK 仓** `third_party/homeagent-sdk`(独立 git 仓、独立版本号)
### 33. 开工流程
```bash
hmapdev init <name> # Go 脚手架(--lua / --type remotedevice 可选)
cd <name> && vim plugin.go # 注册工具
hmapdev debug . # 不想装就先跑一遍
hmapdev build # 产出 dist/<name>_bundle.hmap
```
### 34. ★ 五个必踩的坑(按踩中概率排序)
| # | 坑 | 事实 |
|---|---|---|
| 1 | **SDK 版本** | 插件编译时**校验 SDK 能力**,用旧接口的工程直接构建失败,报错形如「当前 SDK 缺少 sdk.InjectOptions.Priority」,并给出两条出路:`hmapdev sdk install <version>` 或 `hmapdev sdk install --from <本地源码>`。**核心仓有新能力但 SDK 未跟上时用 `--from`** |
| 2 | **`hmapdev build`/`debug` 不支持 `--help`** | 不是打印帮助,而是去读当前目录的 `plg.json` ⇒ 你会看到 `error: read plg.json: no such file or directory`。**这不是故障**。要看 flag 用 `hmapdev --help` |
| 3 | **产物是 ZIP,不是二进制** | `dist/<name>_bundle.hmap` 是 ZIP(魔数 `PK`),用 `unzip` 解,内含 `plugin.bin.linux.amd64` 等多平台二进制。**生产要的是解包后的单个二进制** `/home/newqqagent/plugins/<name>/plugin.bin`(不是 zip) |
| 4 | **`plugin.json` 是安装标志** | 生产目录里没有它,插件不会被识别。`plg.json` 是**构建**标志,两者不同 |
| 5 | **`plg.json` 的 `sdk` 字段必须完整 x.y.z** | 写 `"1.4"` 会被**直接拒绝**(SDK 版本纪律:跟随内核中版本、patch 位恒为 `.0`,一条内核线只有一个 SDK 版本) |
### 35. 注册工具的正确形状
```go
s.RegisterTool(tp+"mytool", sdk.ToolDef{
Name: tp + "mytool", // ← Name 必填,放结构体**首位**
Description: "……",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Handler: h.mytool,
NoMemory: true, // 声明字段一律放**末尾**
}, s)
```
**并发声明**(同一批多个 tool_call 时):
- `ParallelSafe: true` —— 可并发。**三个条件都满足才可声明**:handler 线程安全 /
不与同批工具争抢同一资源(SQLite 写、同一设备、同一输出通道)/ 执行顺序无关。
- `Serial: true` —— 必须串行,**优先级高于 `ParallelSafe`**。
- **默认(都不写)= 整批串行**,是保守设计不是遗漏。
⇒ commondev 的**写操作**(凭据增删、台账写、`workdir_prepare`、`pr_open/merge`、
`agent_send`)**一律 `Serial: true`**;**只读**(list/status)才 `ParallelSafe`。
### 36. ★ 外部插件的能力边界(选外部形态就必须接受)
**公开 SDK 里声明的符号,不等于外部插件拿得到。** 外部插件跑在独立进程里,能力由桥接注入:
| API | 外部插件的实际 | 该用什么 |
|---|---|---|
| `Events()` | **恒为 nil**(桥不注入 subscriber) | 桥接运行时按你的声明完成订阅 |
| `UnregisterOutputChannel` | **静默无效**(只注入 registrar,不注入 unregistrar) | 注销需重载插件 |
| `PriorityL4` | **被夹到 L3** | 用 L1–L3 |
| `SocialAPI` 写操作 | 只有 6 个**只读**方法 | 读用 `GetPerson` 等 |
| `PluginMgr()` | **只有 3 个方法**(`ReloadOne`/`ListLoadedPlugins`/`IsPluginDisabled`) | 就这 3 个 |
| `resident_agents` 等内核工具 | **调不到**(proc 桥无对应方法) | 由 agent 侧调(见第 3 节) |
> **外部形态的 L4 天花板**:commondev 作为外部插件**永远拿不到 L4**。
> **⚠️ 不要用 `ToolAPI` 去调内核内置工具(如 `resident_agents`)**:`ToolAPI` 是
> **单例**,per-agent 数据靠「最后注入者」,而创建驻留子也会注入 ⇒ 你可能打到
> **某个驻留子的表上**。这是内核已自陈的局限,别踩。
**L4 归属表**(这块最容易搞错):
| agent | 它的"内核" | 谁能产生 L4 |
|---|---|---|
| 根 agent | 内核自身 + **内置**插件 | `raiseKernelInterrupt`、WebUI 终止按钮等 |
| 驻留子 | **父 agent** | 父(父→子消息固定 L4) |
| **外部插件(如 commondev)** | — | **永远不能**(被夹到 L3) |
⇒ 外部形态下,「立即取消整条分支」只能靠 **L3 打断主管 + 主管下令**。
### 37. 安装与验证
```bash
# ① 从 bundle 取出本机平台二进制
unzip -o dist/<name>_bundle.hmap 'plugin.bin.linux.amd64'
# ② 放进插件目录(<name> 必须与 plg.json 的 name 一致)
mkdir -p /home/newqqagent/plugins/<name>
cp plugin.json /home/newqqagent/plugins/<name>/
cp plugin.bin.linux.amd64 /home/newqqagent/plugins/<name>/plugin.bin
chmod +x /home/newqqagent/plugins/<name>/plugin.bin
# ③ 重启并确认工具**真的注册了**
systemctl restart homeagent.service
journalctl -u homeagent.service --since "-2 min" | grep "registering tool: <你的工具名>"
```
**内置 vs 独立**(目录存在 ≠ 有 `plugin.bin`):
```bash
for p in webui cmd seq qq; do
printf "%-8s " $p
ls /home/newqqagent/plugins/$p/plugin.bin >/dev/null 2>&1 \
&& echo "独立(要单独构建部署)" || echo "内置(随 homed 部署)"
done
```
★ **改了内置插件的源码 ⇒ 必须重新构建并部署 homed**,重启服务不生效(`//go:embed` 编进二进制)。
★ 判断线上跑的是哪次构建,看 `commit` 字段(**不要用 `strings`**):
```bash
K=$(sqlite3 /home/newqqagent/config.db "select value from config_webui where key='api_key';")
curl -s -H "X-API-Key: $K" http://127.0.0.1:8080/api/v1/status | grep -oE '"commit":"[^"]*"'
```
⚠ 必须带 `X-API-Key`:无认证时返回 **200 + 登录页 HTML**,只看状态码会误判。
### 38. 排查「插件装了却不生效」
按顺序查,**别跳步**:
```bash
ls /home/newqqagent/plugins/<name>/plugin.bin # 1) 内置还是独立
journalctl -u homeagent.service --since "-5 min" | grep "registering tool:" | grep -i <name> # 2) 注册了吗
journalctl -u homeagent.service --since "-5 min" | grep -E "loaded: <name>" # 3) 加载了吗
journalctl -u homeagent.service --since "-5 min" | grep -iE "<name>.*(error|panic|failed)" # 4) 启动报错
```
### 39. ★ 安全纪律(commondev 拿的是**能 push 用户所有仓库**的权限)
> **★ 架构决定(用户拍板 2026-10-14):敏感信息拦截下沉为内核安全模块。**
>
> 理由:**LLM 调用本身不可信**,而插件在**信任边界之外** ——
> 插件注册的 stage 拦截是“自愿的”,不是强制的,也算不上边界。
>
> ★ **它不是单纯拦截,而是一个闭环**(用户纠正:“只是简单的拦截的话,
> 绝大部分插件会失去原本的功能”):
>
> ```
> ① 拦截 LLM 出向前扫到明文 → 换成 @{别名}
> ② 加密暫存 明文 AES-GCM **落盘**存内核(不落明文)
> ③ 别名 ★ **哈希键**:sec_ + HMAC-SHA256(masterKey, plain)[:8]
> ⇒ **同一明文自动得同一个别名**(同变量合并,天然性质)
> ⇒ 必须 HMAC 而非裸哈希(裸哈希可字典枚举);**不加 salt**(否则失去合并)
> ④ 回填 插件调用前把 @{别名} 换回明文 ← 插件无感知
> ```
>
> ★ **变量不自动淘汰**(跨轮/跨子 agent 随时要用);agent 可**主动**调用
> 安全模块提供的**提交/删除**工具。泄漏风险靠主动删控制,不靠超时。
>
> ★ **因为内核零 IO(一切外部交互都从插件走),只需两根咽喉**:
> · 入向 toolcall:`StageHost.ExecuteTool(name, args)`(`core/stages.go:140`)
> · 入向 outputch:设备 `Execute`(`core/output.go:76`)
> · 出向 LLM:provider 层的 `Chat(req)`(已核:构造 `CompletionRequest` 有 **7 处**,
> task 层拦不全)
>
> ★ **变量系统是全局唯一的**(用户拍板):主 agent / 子 agent / 驻留子 agent
> **同一套**别名表。已核依据:驻留子共享父的 `ProviderManager`(`resident.go:185`),
> 而它**全进程只建一份**(`bootstrap.go:249`)⇒ 安全模块天然是进程级单例,
> **不需要跨 agent 同步**。
> ⚠ **变量共享 ≠ 授权共享**:能不能调某工具/通道仍看该 agent 自己的 `AllowedOutputs`
> (与 `KernelSource` 一道是 per-agent 的)。两者**正交**。
>
> ★ **“插件无感知”是硬要求**:替换发生在路由到插件的**之前**;
> 插件代码一行不改、不可分辨、无法绕过 ⇒ 存量插件自动获得该能力,
> 且无需给 SDK 加参数(不破接口兼容)。
>
> **对本插件的直接影响(大幅简化)**:插件可以**把密钥明文直接投给 LLM**,
> 由内核安全模块全权负责;`enc_*` 工具族与加密 shell 应**删减**,
> 不再自带加密库/别名表(前稿那套作废)。
>
> 详见 `docs/zh/kernel-secret-firewall-design.md`(含硬约束 S1–S9)。
>
> **两条不可让步的约束**:
> · **只能替代引用,永远不可取回明文**(用户明确)—— 读接口与用接口是两件事;
> · **模式库宁漏勿误伤** —— 误报会搞坏正常任务。
**凭据:按名字引用 + 变量填充**(用户明确要求的能力)
```
enc_store(alias="gitea-token", value="${GITEA_TOKEN}") ← ★ 引用,不是明文
enc_list ← 只列名字,**绝不返回值**
```
之后一切操作只带**别名**:`@{gitea-token}` —— 参数里永远不出现 secret。
详见 **第二部分 §13**(那里是权威)。
**为何要 `${ENV_VAR}` 变量填充**:(①)明文不落盘;(②)dev/prod 换环境变量即可;
(③)轮换凭据不需改插件数据。
#### ★★ 判据与复核:**插件 GREEN ≠ 改对了**
**这是本流水线最重要的一条纪律。** 插件只能跑你**预先写下**的判据;
判据没覆盖那条 bug,插件照样打勾。自动化**不会**发现假绿,
只会让假绿跑得更快、更可信。
**真实数据**(我用最小例子复刻过):
```go
func Max(a,b int) int { if a>b { return a }; return b }
```
- 判据只测 `Max(5,3) == 5`(漏了 `b>a`)
- 变异 `return b` → `return a`(**真 bug**)⇒ **判据仍全绿**
- 补一条 `Max(3,5) == 5` ⇒ 同一变异**立刻报红**
⇒ 所以**复核必须两步**,你定义判据时**必须同时给变异规则**:
```
commondev_test_define(
project="MyProj", name="clamp 上下界", kind="unit",
script="go test -count=1 ./...",
expect="上下界都被限制",
mutations=[{ id="M1", file="calc.go",
old=" if v > hi {\n return hi\n }\n", new="",
note="删掉上界分支(真 bug)——松判据看不见它" }]
)
commondev_review_submit(branch_id="br-...")
commondev_review_result() ← 取结论
```
**结论三档,注意第二档不是"通过"**:
| verdict | 含义 | 你该做什么 |
|---|---|---|
| `pass` | 正向全过 **且**全部变异被咬住 | 可以进下一步 |
| **`pass_with_gaps`** | 正向过,但**有变异没被咬住**或**判据没变异规则** | **不能当通过** —— 去补判据/变异,再复核 |
| `fail` | 有判据没过 | 打回执行 agent |
★ **`pass_with_gaps` 就是"你的判据有盲区"**。把它当绿色勾,
就是在给自己攒一次大的意外。
**定义变异规则的三个要点**:
1. **`old` 必须在文件里恰好出现一次** ——
出现 0 次 ⇒ 变异无效(却会被误报成"判据太松");
出现多次 ⇒ 可能改错地方(却会被误报成"咬住了")。
多出现时给出更长的上下文。
2. **变异要"真坏"** —— 改的是逻辑,不是格式。改不坏就验不出判据的松紧。
3. **`script` 里带 `-count=1`** —— Go 默认缓存测试结果;
命中缓存时测试**根本没跑**,"变异后报红"就无从谈起。
#### 双凭据与分支保护:**已移到第二部分**(技能纪律 §15–§16)
原来这里用 `commondev_account_add(role=...)` + `commondev_protect_branch` 两个
插件工具实现。**这些工具已移除**(平台知识不再进插件)。
现在这样做(命令见 **第二部分 §15**):
1. **在平台上建两个用户**:一个普通用户(执行侧,只能 push `feat/*`)、
一个有管理权限的(主管侧,能审批与合并)。各自签发 token。
2. **两个 token 都存进凭据库**:`enc_store(alias="gitea-exec", ...)` /
`enc_store(alias="gitea-sup", ...)`。
3. **给主干上锁**(第二部分 §15 的 curl 命令,四个开关都要)。
4. 审批与合并用主管侧别名(§16)。
★ **执行侧必须用普通用户 token** —— 用 admin token 会**直接绕过分支保护**,
机制失效且无任何报错。这是最容易搞错的一点。
★ **拦你的是平台,不是插件** —— 所以改插件代码也绕不过去。
这才是「机制」与「流程约定」的区别。
**两条不可逆的红线**:
1. **变量解析不到值 ⇒ 响亮报错**,绝不静默当空串 —— 后者会退化成匿名访问,
表现成 **401** 而非“凭据没配”,极难排查。
2. **secret 绝不进返回值/日志**。
**平台形状差异**(不能一个认证头走天下):
| 平台 | API 前缀 | 认证头 |
|---|---|---|
| Gitea | `<base>/api/v1` | `Authorization: token <T>` ← **不是 Bearer** |
| GitHub | `https://api.github.com` | `Authorization: Bearer <T>` |
| GitCode | `https://api.gitcode.com/api/v5` | `Authorization: Bearer <T>` |
未知平台**必须报错**(不猜形状)。
**其他:**
1. **凭据不入 `config.db`**:现有惯例是明文存 `config_<plugin>` 表,而 `config.db` 是
**644 全局可读**。commondev 的凭据性质完全不同 ⇒ 放独立文件
`<DataDir>/credentials.json`,**0600,启动时校验权限,不对就拒绝启动**
(本仓纪律:响亮失败,不静默降级)。
2. **优先 SSH key 而非 PAT**;每平台独立、最小授权,**不要一个 token 通吃**。
3. **token 不进 remote URL**:拼接仅用于**单次命令**(`pushURLWithToken`),
clone 出来的 remote 不带凭据 —— 否则删掉临时目录也删不掉凭据。
4. **不要硬编码 token**:走 `Settings()` 或宿主注入。
5. **拉起 pi 时隔离端口**:内层 pi 会尝试监听 A2A/ACP 端口并打 `EADDRINUSE`,
显式设 `PI_CODING_AGENT_SESSION_DIR` / `PI_A2A_PORT` / `PI_ACP_PORT`。
### 40. ★ 孤儿清理(插件有自己的硬约束)
内核对自己的驻留子有硬约束:**子不得比父活得久 / 无孤儿 goroutine / 无残留 temp**
(`resident-subagent-design.md` §10,写成判据)。
**插件对自己拉起的进程必须同样硬**:`Stop()` 必须是**幂等的全清** ——
pi 进程、sidecar、ssh 会话、workdir(含凭据残留检查)一个不留。
写成判据:`TestStopKillsAllRuntimes`(变异:删掉某个清理 → 必须报红)。
### 41. 参考资料(**别凭记忆写 API**)
| 内容 | 路径 |
|---|---|
| SDK 源码 | `third_party/homeagent-sdk/sdk/` |
| **可运行的示例插件** | `third_party/homeagent-sdk/example/`(a2a / acp / qq / plugindev…) |
| 能力边界 | `third_party/homeagent-sdk/docs/guide/capability-boundary.md` |
| 并发声明 | `third_party/homeagent-sdk/docs/guide/parallel-tool-declaration.md` |
| 部署手册 | `docs/zh/deploy-runbook.md` |
| sidecar 参考实现 | `internal/plugins/clawhubadapter/sidecar.go` |
★ **写插件前先翻 `example/`**:那里的代码是**编译通过**的,比文档可靠。
**文档与示例冲突时以示例为准。** 注意 `example/` 是**外部插件**,
能被它们编译通过的写法,外部就一定可用。
---
## Notes: 红牌(做了就是错)
1. **不划通道就当派了活** —— 登记 ≠ 划入;不划则所有输入涌向根 agent。
2. **绕过插件直接敲 `pi -p`** —— 漏 `--session-id` 会静默开新会话。
3. **并发投同一个 session** —— 会写坏会话(实测)。
4. **没有变异阶段的复核** —— 等于给假绿充值。
5. **凭据写进 `config.db` 或 remote URL**。
6. **改了内置插件源码只重启服务** —— 必须重编 homed。
7. **手改生成的文档**(`docs/api/*.md` 首行写着「请勿手改」)—— 改 `sdk/*.go` 注释再重新生成。
8. **为了让插件生效去改 homed 的源码** —— 先确认它是不是内置插件。

View File

@ -0,0 +1,6 @@
{
"name": "commondev-discipline",
"description": "commondev 主管式多仓库开发流水线的使用与开发纪律:为每个工程对话新建驻留子并划入其通道、投递必须落同一 session、复核必须含变异阶段;以及 hmap 插件开发纪律(plg.json、SDK 版本、产物形态、Serial/ParallelSafe、外部插件能力边界、凭据与孤儿清理)",
"version": "1.0.0",
"author": "HomeAgent"
}