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 项通过
This commit is contained in:
2026-09-21 13:00:10 +08:00
parent 6ed5dda847
commit 06891fa726
6 changed files with 450 additions and 10 deletions
+44 -3
View File
@@ -7,13 +7,15 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
HOME, USER, HOSTNAME, CLI_CONFIG, PLUGINS_HOME, REPO_DIR, V2_CONFIG,
HOME, USER, HOSTNAME, CLI_CONFIG, PLUGINS_HOME, REPO_DIR, V2_CONFIG, V2_PROVIDER_CONFIG,
nowTag, readJson, writeJson, encodeJsonPaths, encodePaths, fwd,
officialPlugins, enabledPlugins, enabledPluginsDetailed, customPlugins, copyDirFiltered, hashDir,
secretTargets, secretsManifest, buildSecretsBundle, encryptSecrets,
requirePassphrase, scanForSecrets, ensureRepo, gitPush, dirSize, humanSize,
collectModelSecrets, sanitizeProviders, summarizeProviders, modelSelection,
extractModelSecrets, modelKeyManifest, WITH_MODELS, APPLY_MODEL_SELECTION,
extractProviderConfigSecrets, collectProviderConfigSecrets, sanitizeProviderConfig,
providerConfigKeyManifest, PROVIDER_CONFIG_PREFIX,
} from "./sync-core.mjs";
function fail(e) { console.error(`导出失败:${e?.message ?? e}`); process.exitCode = 1; }
@@ -69,6 +71,16 @@ try {
const providerSanitized = v2cfg ? sanitizeProviders(v2cfg) : { providers: {}, withSecrets: [] };
const modelKeys = v2cfg ? extractModelSecrets(v2cfg) : [];
const modelKeysManifest = modelKeyManifest(modelKeys);
/**
* provider_config.json:UI 的分组/顺序/模型级配置。它自带一份 access.apiKey,
* 与 config.json 那份可能重叠但不保证(只在 provider_config 里存在的分组,
* 其密钥也只在它这)。两份都要收,否则那些分组过去就是个空壳。
*/
const pcCfg = WITH_MODELS ? readJson(V2_PROVIDER_CONFIG, null) : null;
const pcSanitized = pcCfg ? sanitizeProviderConfig(pcCfg) : { config: null, withSecrets: [] };
const pcKeys = pcCfg ? extractProviderConfigSecrets(pcCfg) : [];
const pcKeysManifest = providerConfigKeyManifest(pcKeys);
const pcSecrets = pcCfg ? collectProviderConfigSecrets(pcCfg) : [];
let modelKeyCount = 0;
if (v2cfg) {
// 脱敏结果里不允许再有真密钥(helper 漏了就直接停,别把明文推上去)
@@ -82,6 +94,17 @@ try {
modelKeyCount++;
}
}
if (pcCfg) {
const rules = pcSanitized.config?.config?.providerConfigRules?.providerRules ?? [];
const left = rules.flatMap((r) => scanForSecrets(r?.config?.access ?? {}));
if (left.length) throw new Error(`provider_config 脱敏不彻底(${left.slice(0, 5).join(",")}),已中断`);
for (const { provider, key, value } of pcKeys) {
bundle.files.push({
rel: `${PROVIDER_CONFIG_PREFIX}${provider}/${key}`,
contentB64: Buffer.from(String(value), "utf8").toString("base64"),
});
}
}
const enc = encryptSecrets(bundle, pass);
const tag = `${HOSTNAME}-${nowTag()}`;
@@ -112,10 +135,22 @@ try {
modelKeyCount,
});
}
if (pcCfg) {
Object.assign(manifest, {
// UI 分组/顺序/模型级配置的摘要(结构本体在 v2.provider-config.json)
providerConfigOrder: pcSanitized.config?.config?.providerOrder ?? [],
providerConfigRuleCount: (pcSanitized.config?.config?.providerConfigRules?.providerRules ?? []).length,
providerConfigModelRuleCount: (pcSanitized.config?.config?.modelConfigRules?.providerModelRules ?? []).length,
providerConfigWithSecrets: pcSanitized.withSecrets,
providerConfigKeys: pcKeysManifest,
providerConfigKeyCount: pcKeys.length,
});
}
// manifest 与 latest.json 都留到自检通过后再落盘:失败时不留半个快照
writeJson(path.join(snapDir, "mcp.servers.json"), mcpEncoded);
writeJson(path.join(snapDir, "plugins-dirs.json"), dirsEncoded);
if (v2cfg) writeJson(path.join(snapDir, "v2.providers.json"), encodeJsonPaths(providerSanitized.providers));
if (pcCfg) writeJson(path.join(snapDir, "v2.provider-config.json"), encodeJsonPaths(pcSanitized.config));
fs.writeFileSync(path.join(snapDir, "secrets.enc"), enc);
writeJson(path.join(snapDir, "secrets.manifest.json"), manifest.secretFiles);
@@ -138,7 +173,7 @@ try {
// 密钥:全覆盖,命中即硬失败
const plainText = readAll(snapFiles) + "\n" + pendingManifestText;
const leakedSecrets = [...providerSecrets, ...secrets.map((s) => {
const leakedSecrets = [...providerSecrets, ...pcSecrets, ...secrets.map((s) => {
try { return fs.readFileSync(s.abs, "utf8").trim(); } catch { return ""; }
})].filter((v) => v && v.length >= 8 && plainText.includes(v));
if (leakedSecrets.length) {
@@ -214,7 +249,7 @@ try {
});
// 只提交本次快照 + latest.json:不做 add -A,免得把别的会话未推送的快照连带推上去
const pushMsg = gitPush(
`sync-export ${tag} (official:${official.length} custom:${custom.length} mcp:${Object.keys(mcpServers).length} secrets:${secrets.length}${v2cfg ? ` providers:${providerSummary.length}` : ""})`,
`sync-export ${tag} (official:${official.length} custom:${custom.length} mcp:${Object.keys(mcpServers).length} secrets:${secrets.length}${v2cfg ? ` providers:${providerSummary.length}` : ""}${pcCfg ? ` groups:${(pcSanitized.config?.config?.providerOrder ?? []).length}` : ""})`,
{ paths: [path.posix.join("snapshots", tag), "latest.json", ".gitattributes", ".gitignore", "local-overrides.example.json"] });
console.log(`# 导出成功`);
@@ -227,6 +262,12 @@ try {
console.log(`- 模型 provider:${providerSummary.length} 个(脱敏结构明文),密钥 ${modelKeyCount} 个已入加密束`);
console.log(`- 模型选中态:${APPLY_MODEL_SELECTION ? "已随快照应用" : "仅记录,导入时不覆盖本机"}`);
}
if (pcCfg) {
const order = pcSanitized.config?.config?.providerOrder ?? [];
const rules = pcSanitized.config?.config?.providerConfigRules?.providerRules ?? [];
const mRules = pcSanitized.config?.config?.modelConfigRules?.providerModelRules ?? [];
console.log(`- UI 分组:${order.length} 个(providerOrder),规则 ${rules.length} 条,模型级配置 ${mRules.length} 条,密钥 ${pcKeys.length} 个已入加密束`);
}
console.log(`- 明文区自检:无密钥泄漏、配置区无本机路径残留`);
console.log(`- 快照体积:${humanSize(manifest.bytes)}`);
if (pathWarnings.length) {