Files
root 06891fa726 fix(env-sync): 同步 provider_config.json(UI 分组/顺序/模型级配置)
问题:只同步 v2/config.json 时,只在 v2/provider_config.json 里存在的
provider 分组到对面就是缺失的 —— 表现为「provider 数量对,但 UI 分组少几个」。
实测:9 个分组里有 4 个(GINKA API / zxcbug / zxcbug-p / d1)纯规则型,
密钥只存在 provider_config.json 的 config.access.apiKey 里,旧实现完全没收集。

修复:
- sync-core:新增 V2_PROVIDER_CONFIG、extract/sanitize/collectProviderConfigSecrets、
  providerConfigKeyManifest;密钥命名空间 PROVIDER_CONFIG/<providerId>/<key>
- export:导出 v2.provider-config.json(脱敏),密钥并入加密束,manifest 记摘要与哈希
- import:按 providerId 合并规则、回填密钥空位、按 (providerId,modelId) 合并模型级配置、
  providerOrder 以快照为准并保留本机特有分组;重复导入幂等
- import:provider_config 路径补「护住本机已有密钥 N 个」提示(原来只在 config.json 侧有)
- 测试:单元 6 项 + E2E 2 项(分组完整同步/强制覆盖),全量 72 项通过
2026-09-21 13:00:10 +08:00

233 lines
13 KiB
Markdown
Raw Permalink 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.
# ZCode 环境同步插件(zcode-env-sync)
团队共享场景:一键导出 / 导入本机 ZCode 环境,默认四项全量:
1. **官方插件清单** — marketplace 版本 + 已启用列表(`data/` 目录即启用态)
2. **自研插件源码** — `~/.zcode/plugins/*` 整体按字节打包:**你自己写的插件全在里面**
(如本插件自身、`zcode-tps`、`zcode-usage`,以后新增的也自动带上);
`node_modules`/`.git` 排除,以 `.` 开头的备份目录不算插件,
两个插件 manifest 同名直接报错(否则快照里互相覆盖)
3. **MCP 配置** — `cli/config.json` 的 `mcp.servers` + `plugins.dirs`,路径占位符化
(gitea 的 `-H` 地址、`GITEA_ACCESS_TOKEN_FILE` 指针都在这,身份各用各的见下)
4. **密钥束** — `~/.dsh/secrets/*` + `~/.ssh/ssh-profiles.json`,AES-256-GCM 加密后才进仓
(含 `gitea-token.txt` 本体)
可选第 5 项(`ZCODE_SYNC_WITH_MODELS=1`):**模型 provider 脱敏快照**,见下。
后端:Gitea 私仓 `root/zcode-env-sync`。
## 安装
```bash
cp -r <本目录> ~/.zcode/plugins/zcode-env-sync
# 把该路径追加进 ~/.zcode/cli/config.json 的 plugins.dirs,然后重启 ZCode
```
## 用法
| 命令 | 动作 |
|---|---|
| `/sync-export` | 导出本机快照 → 推 Gitea 私仓 |
| `/sync-import` | 从私仓拉快照 → 还原到本机(先自动备份) |
| `/sync-status` | 对比本机与远端快照差异(只读) |
| `/sync-list` | 列出远端全部快照,标出 `latest` 指向哪个(只读) |
| `/sync-verify` | 校验快照完整性:hash / 密钥束 / 占位符(只读,只比哈希不落盘) |
| `/sync-prune` | 清理远端旧快照(默认只预演,`--apply` 才真删) |
| `/sync-doctor` | 自诊断:一次查完前置条件,告诉你同步为什么不能用(只读) |
**排查顺序**:`/sync-doctor` → `/sync-verify` → `/sync-status`。
先说"同步不工作"就跑 doctor(缺口令、latest 解不开、追踪引用分叉都在这层);
怀疑快照坏了跑 verify(它会用当前口令试解 `secrets.enc`);
只想看差异跑 status。
七个命令统一用 `SYNC_PLUGIN` 自动定位脚本,**命令文档里不写死绝对路径**——
快照要跨机器还原,写死的路径到了别的机器就是废的:
```bash
SYNC_PLUGIN="${ZCODE_SYNC_PLUGIN:-$(find ~/.zcode/plugins -maxdepth 3 -name export.mjs -path '*env-sync*' | head -1)}"
node "$SYNC_PLUGIN"
```
口令:环境变量 `ZCODE_SYNC_PASSPHRASE`(团队线下约定,**不进仓**)。
未设置或短于 8 位时脚本直接报错退出,不会用弱默认口令。
### 快照保留与清理
```bash
node prune.mjs # 预演:列出将删哪几个、各多大、共释放多少
node prune.mjs --keep 5 # 预演,只保留最近 5 个
node prune.mjs --keep 5 --apply # 真删并推送
node prune.mjs --exclude <快照名> # 钉住某个快照(可重复)
```
永远不动:`latest.json` 指向的那个、最新的那个、`--exclude` 钉住的。
删完若 `latest` 指向被删的快照会自动重指到存活的一个。`ZCODE_SYNC_KEEP` 设默认保留数。
### 快照校验
```bash
node verify.mjs # 校验 latest 指向的那个
node verify.mjs <快照名> # 校验指定快照
node verify.mjs --all # 扫全部:逐个标「结构OK / 可解密 / 口令不符」,给出能用的是哪个
node verify.mjs --strict # 有警告也算失败(退出码 1)
```
`--all` 用在**团队仓里混着不同口令导出的快照**时:GCM 认证失败无法区分"口令错"与"密文损坏",
它把每个都拿当前口令试一遍,直接告诉你 `/sync-import <哪个>` 能用。
## 快照结构
```text
snapshots/<hostname>-<yyyyMMdd-HHmmss>/
manifest.json # 清单:官方插件/自研插件 hash/MCP 名/密钥指针/provider 摘要
mcp.servers.json # 占位符化后的 MCP 配置(明文)
plugins-dirs.json # plugins.dirs 占位符化(明文)
v2.providers.json # 仅 WITH_MODELS=1:provider 脱敏版(密钥位置是占位符)
custom-plugins/ # 自研插件源码(明文,团队内私仓)
secrets.enc # 密钥束 AES-256-GCM 密文
secrets.manifest.json # 密钥清单(只有路径+sha256,无值)
latest.json # 指向最新快照
```
## 跨机器路径还原
导出时把本机绝对路径替换为占位符,导入时按本机实际路径反向替换:
| 占位符 | 含义 |
|---|---|
| `${ZCODE_HOME}` / `${ZCODE_HOME_WIN}` | `~/.zcode`(正/反斜杠两种写法) |
| `${DSH_HOME}` / `${DSH_HOME_WIN}` | `~/.dsh` |
| `${USER_HOME}` / `${USER_HOME_WIN}` | 用户主目录 |
| `${USERS_POSIX}` / `${USERS_WIN}` | `Users/<用户名>` 片段 |
替换按 **JSON 值**逐个进行,不是对序列化文本做字符串替换——
文本里反斜杠是转义态(`\\Users\\`),对文本替换会整片漏掉。
机器特有的 host/port/command 可写 `~/.zcode-env-sync/overrides.json`(**不进仓**),
导入时自动深度合并覆盖。
## 模型 provider 与 UI 分组(默认关)
`~/.zcode/v2/config.json` 里通常是**活的 apiKey**,所以默认完全不碰(快照里无模型)。
需要同步时导出导入两边都显式开:
```bash
ZCODE_SYNC_WITH_MODELS=1 node export.mjs # 导出:脱敏结构明文 + 真密钥进加密束
ZCODE_SYNC_WITH_MODELS=1 node import.mjs # 导入:结构合并 + 密钥回填
```
### 两份文件都要同步(只同步一份会导致「UI 分组少了」)
| 文件 | 管什么 | 快照里的名字 |
|---|---|---|
| `v2/config.json` 的 `provider` | provider **能不能调**(kind、options、models 定义) | `v2.providers.json` |
| `v2/provider_config.json` | UI **怎么分组显示**(providerOrder、group、access、personalModelIds、modelOrder,以及 contextWindow 等模型级配置) | `v2.provider-config.json` |
两份各带一份 `access.apiKey`,**不保证重叠**:有些分组只存在于 `provider_config.json`
(在 UI 里建了但没落进 `config.json`)。只同步前者时那些分组到对面就是缺失的 ——
表现为「provider 数量对,但 UI 分组少几个」。所以现在两份都导出、都回填。
导出:两个文件都写**脱敏版明文**(密钥位置是 `${MODEL_SECRET_REF}`),
真密钥分别以 `MODELS/<provider>/<key>` 与 `PROVIDER_CONFIG/<providerId>/<key>`
条目并入 `secrets.enc`;manifest 只记摘要 + 密钥哈希(`modelKeys` / `providerConfigKeys`),不记值。
导入(`ZCODE_SYNC_WITH_MODELS=1` 时才动这两个文件,先备份):
- 快照有、本机无的 provider / 分组:整体新增
- 已有 provider:只补缺的模型定义与非密钥字段,不碰本机已有密钥
- 已有分组:按 `providerId` 合并,补缺失字段(含 `group`/`personalModelIds`/`modelOrder`)
- 模型级配置:按 `(providerId, modelId)` 去重合并
- `providerOrder`:以快照顺序为准,本机特有的分组追加尾部(不丢本机分组)
- 密钥回填:只填**空位**(占位符/空/过短);本机已有密钥默认跳过保护,
确需覆盖再加 `ZCODE_SYNC_OVERWRITE_MODEL_SECRETS=1`
- 选中态(当前用哪个 provider/model)默认不动,
加 `ZCODE_SYNC_APPLY_MODEL_SELECTION=1` 才应用(同样先备份 `v2/setting.json`)
- 重复导入幂等:不会重复追加分组或模型级配置
| 变量 | 说明 |
|---|---|
| `ZCODE_SYNC_OVERWRITE_MODEL_SECRETS=1` | 允许用快照密钥覆盖本机已有密钥(默认跳过保护) |
## 内置闸门
导出过程有数道自检,命中即**中断且不推送**:
| 闸门 | 行为 |
|---|---|
| 明文区出现真密钥值 | 硬失败(扫全部非 `secrets.enc` 文件) |
| 配置区残留未占位符化的绝对路径 | 硬失败(按解析后的值判断,不限于本机用户名) |
| provider 脱敏不彻底 | 硬失败 |
| 自研插件 manifest 同名 | 硬失败(否则快照里互相覆盖) |
| 自研插件源码里的硬编码路径 | 警告并列出文件(源码按字节归档,不擅自改写) |
放行开关:`ZCODE_SYNC_ALLOW_SECRETS`(逗号分隔路径白名单)、`ZCODE_SYNC_ALLOW_PATH_LEAK=1`。
## 环境变量
| 变量 | 默认 | 说明 |
|---|---|---|
| `ZCODE_SYNC_PASSPHRASE` | — | **必填**,加密/解密密钥束 |
| `ZCODE_SYNC_REPO` | `root/zcode-env-sync` | 私仓坐标 |
| `ZCODE_SYNC_HOST` | `https://gitea.mymoyu.top` | 托管地址 |
| `ZCODE_SYNC_WORK` | `~/.zcode-env-sync` | 本地工作区(repo clone + backup) |
| `ZCODE_SYNC_WITH_MODELS` | 关 | `1` 时连带同步 provider(脱敏) |
| `ZCODE_SYNC_APPLY_MODEL_SELECTION` | 关 | `1` 时导入应用模型选中态 |
| `ZCODE_SYNC_SKIP_SECRETS` | 空 | 逗号分隔的密钥 rel,导入时跳过不落盘(只报告)。团队场景:连接方式统一、身份各用各的。例:`ZCODE_SYNC_SKIP_SECRETS='DSH_SECRETS/gitea-token.txt'`(每人保留自己的 gitea token,只同步 `-H` 地址等配置);模型密钥用 `MODELS/<provider>/<key>` 格式 |
| `ZCODE_SYNC_NO_PUSH` | 关 | `1` 只落本地,不推远端 |
| `ZCODE_SYNC_GIT_REMOTE` | 自动 | 覆盖远端地址(测试用) |
| `ZCODE_SYNC_APPLY_OFFICIAL` | 开 | `0` 跳过官方插件差异报告 |
| `ZCODE_SYNC_KEEP` | `10` | `/sync-prune` 默认保留数量 |
| `ZCODE_SYNC_VERIFY_STRICT` | 关 | `1` 等价于 `verify.mjs --strict`(有警告即失败) |
| `ZCODE_SYNC_ALLOW_PATH_LEAK` | 关 | `1` 放行路径残留自检(确认无害时才用) |
## 换行符与字节一致性
快照是**字节级归档**:仓库里锁了 `.gitattributes`(`* -text`)并把本仓
`core.autocrlf` 设为 `false`。否则 Windows 上常见的 `core.autocrlf=true`
会在 add/checkout 时改写 CRLF,导致 manifest 里的 hash 与克隆下来的内容对不上,
`/sync-status` 会永久报「版本不同」。
## 测试
```bash
npm test # 63 项:单元 + 沙箱 E2E + 新命令(prune/verify/doctor/list)
npm run test:core # 纯函数单测(35 项)
npm run test:e2e # 沙箱端到端(11 项,伪造两台机器 + 本地裸仓)
npm run test:tools # 新命令(17 项:prune/verify/doctor/list/推送隔离)
node tests/live-roundtrip.mjs # 真机往返(需私仓已建 + 口令)
```
E2E 用 `USERPROFILE` 把 HOME 重定向到临时目录、用本地裸仓冒充 Gitea,
全程不碰真实 HOME、不走网络。覆盖:跨机路径还原、密钥解密落地、覆盖前备份、
密钥/路径泄漏闸门、CRLF 字节完整性、provider 脱敏与密钥跨机回填;
tools 那组覆盖 prune 保留策略与 latest 重指、verify 的 hash 篡改检测与口令不符分支、
doctor 的三种 fail 场景、status --list、以及**推送隔离**
(`git add` 只加本次快照,不把别的会话未推送的快照连带推上去)。
## 故障排查
**先说一句**:跑 `/sync-doctor`,它把这页大部分症状直接指出来并给修复命令。
| 现象 | 原因与处理 |
|---|---|
| `未设置 ZCODE_SYNC_PASSPHRASE` | 先 `export ZCODE_SYNC_PASSPHRASE='...'`(≥8 位) |
| `密钥束解密失败:口令不对或文件损坏` | 双方口令不一致,或 `secrets.enc` 被改过。先 `node verify.mjs --all` 看当前口令能用哪版 |
| `推送失败` | 私仓没建 / token 无写权限;token 读 `~/.dsh/secrets/gitea-token.txt` |
| token 权限不足建仓 | Gitea 个人建仓需 `write:user`;管理员可用 `POST /api/v1/admin/users/<user>/repos` |
| `自研插件名冲突` | 两个插件的 `plugin.json` 里 `name` 相同,改名或移走一个 |
| status 一直报「版本不同」 | 检查仓库 `.gitattributes` 是否为 `* -text`、`core.autocrlf` 是否为 `false` |
| `配置区残留未占位符化的绝对路径` | 配置里有写死的绝对路径,改成占位符或确认后设 `ZCODE_SYNC_ALLOW_PATH_LEAK=1` |
| `HEAD != origin/main` | 本地与远端分叉,先 `/sync-status` 看差异再决定推还是拉 |
| 快照太多占空间 | `node prune.mjs --keep 10` 预演,确认后加 `--apply` |
| 导出报告「落下 N 项非本次内容」 | 正常:仓库里有别人的未推送内容,`git add` 只加本次快照。那些文件仍在本地,不会丢 |
| `latest` 指向的快照解不开 | 该快照可能用了别的口令。`node verify.mjs --all` 找出当前口令能用的版本,再 `/sync-import <快照名>` |
## 安全说明
- 除 `secrets.enc` 外不含任何密钥明文;导出时有脱敏扫描,命中即中断
- git 网络操作使用带 token 的临时 URL,`origin` 只存干净地址,token **不落** `.git/config`
- 密钥还原时拒绝目录穿越与绝对路径,权限设为 `600`
- 私仓保持 **private**;换口令即换 `ZCODE_SYNC_PASSPHRASE` 后重导
- 导入前自动备份被覆盖文件到 `~/.zcode-env-sync/backup/<时间>/`,报告里给出实际路径