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

281 lines
15 KiB
JavaScript

#!/usr/bin/env node
/**
* /sync-export:导出本机快照 → 推 Gitea 私仓。
* 用法: ZCODE_SYNC_PASSPHRASE='口令' node export.mjs
*/
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, 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; }
try {
const pass = requirePassphrase();
ensureRepo();
const official = officialPlugins();
const enabled = enabledPlugins();
const enabledDetailed = enabledPluginsDetailed();
const custom = customPlugins();
const cfg = readJson(CLI_CONFIG, {});
const mcpServers = cfg?.mcp?.servers ?? {};
const pluginsDirs = cfg?.plugins?.dirs ?? [];
// 路径占位符化(跨机器)——按值处理,反斜杠路径才不会被漏掉
const mcpEncoded = encodeJsonPaths(mcpServers);
const dirsEncoded = encodeJsonPaths(pluginsDirs);
// 脱敏扫描:明文区不得出现疑似密钥值
const hits = scanForSecrets({ mcpServers: mcpEncoded });
if (hits.length) {
throw new Error(`明文区疑似含密钥(${hits.slice(0, 8).join(",")}),已中断。请把该 server 的敏感值改走 *_FILE/密钥文件引用,或先处理再重导`);
}
// 占位符化完整性自检:编码后不应再残留本机主目录/用户名(否则跨机器还原会指到别人家)
const txt = JSON.stringify(mcpEncoded) + JSON.stringify(dirsEncoded);
const leaks = [];
if (txt.includes(`Users/${USER}`) || txt.includes(`Users\\${USER}`)) leaks.push(`用户名 ${USER}`);
const homeF = fwd(path.join(os.homedir()));
if (homeF && txt.includes(homeF)) leaks.push(`主目录 ${homeF}`);
if (leaks.length) {
const msg = `占位符化不完整,明文区仍含 ${leaks.join("、")}(该快照还原到别的机器会指错路径)`;
if (process.env.ZCODE_SYNC_ALLOW_PATH_LEAK === "1") console.warn(`⚠ ${msg}(ZCODE_SYNC_ALLOW_PATH_LEAK=1 已忽略)`);
else throw new Error(`${msg}。确认这些字段是路径残留后,可设 ZCODE_SYNC_ALLOW_PATH_LEAK=1 强制导出`);
}
// 密钥束(文件类先装,模型密钥随后并入,最后统一加密)
const secrets = secretTargets();
const bundle = buildSecretsBundle(secrets);
/*
* 模型 provider:v2/config.json 里全是活的 apiKey,默认绝不整体复制。
* 开启 ZCODE_SYNC_WITH_MODELS=1 时:脱敏结构写明文,真值以 MODELS/<provider>/<key>
* 条目并入加密束;关闭时模型完全不进快照。导出后还有「密钥零残留」自检兜底。
*/
const v2cfg = WITH_MODELS ? readJson(V2_CONFIG, null) : null;
if (WITH_MODELS && !v2cfg) throw new Error(`ZCODE_SYNC_WITH_MODELS=1 但读不到 ${V2_CONFIG}`);
const providerSummary = v2cfg ? summarizeProviders(v2cfg) : [];
const providerSelection = v2cfg ? modelSelection() : null;
const providerSecrets = v2cfg ? collectModelSecrets(v2cfg) : [];
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 漏了就直接停,别把明文推上去)
const left = scanForSecrets({ providers: providerSanitized.providers });
if (left.length) throw new Error(`provider 脱敏不彻底(${left.slice(0, 5).join(",")}),已中断`);
for (const { provider, key, value } of modelKeys) {
bundle.files.push({
rel: `MODELS/${provider}/${key}`,
contentB64: Buffer.from(String(value), "utf8").toString("base64"),
});
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()}`;
const snapDir = path.join(REPO_DIR, "snapshots", tag);
fs.mkdirSync(snapDir, { recursive: true });
const manifest = {
version: 1, snapshot: `snapshots/${tag}`,
host: HOSTNAME, createdAt: new Date().toISOString(),
// 占位符而不是本机绝对路径:manifest 是明文进仓,不该带出用户名
zcodeHomeTemplate: "${ZCODE_HOME}",
zcodeHomeExportedFrom: encodePaths(fwd(path.join(os.homedir(), ".zcode"))),
official, enabled,
// inline 插件原本查不到版本,这里补全(市场插件查清单,inline 读本机插件目录)
enabledDetailed,
custom: custom.map((c) => ({ name: c.name, version: c.version, hash: hashDir(c.dir) })),
mcpServerNames: Object.keys(mcpServers),
secretFiles: secretsManifest(secrets).map(({ rel, sha256, bytes }) => ({ rel, sha256, bytes })),
};
if (v2cfg) {
Object.assign(manifest, {
modelProviders: providerSummary,
modelSelection: providerSelection,
modelSelectionApplied: APPLY_MODEL_SELECTION,
// 只记哪些 provider 带密钥+密钥哈希,不记值;密钥值在加密束 MODELS/ 条目里
modelProvidersWithSecrets: providerSanitized.withSecrets,
modelKeys: modelKeysManifest,
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);
const cpRoot = path.join(snapDir, "custom-plugins");
for (const c of custom) copyDirFiltered(c.dir, path.join(cpRoot, c.name));
/* ---- 写后自检:明文区不得出现任何真实密钥值或本机路径 ---- */
const walkFiles = (dir, acc = []) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walkFiles(p, acc);
else if (e.name !== "secrets.enc") acc.push(p); // 密文不必扫
}
return acc;
};
const snapFiles = walkFiles(snapDir);
const readAll = (files) => files.map((f) => { try { return fs.readFileSync(f, "utf8"); } catch { return ""; } }).join("\n");
// manifest 还没落盘,单独把它的内容并入扫描范围(它同样是明文进仓)
const pendingManifestText = JSON.stringify(manifest);
// 密钥:全覆盖,命中即硬失败
const plainText = readAll(snapFiles) + "\n" + pendingManifestText;
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) {
throw new Error(`明文区检测到 ${leakedSecrets.length} 个真实密钥值(已中断,快照未推送)。`
+ `密钥只能出现在 secrets.enc 里,请检查脱敏逻辑`);
}
// 口令本体:上面那轮只按「密钥文件的内容」比对,而口令来自环境变量、不在密钥清单里,
// 于是源码里写死的口令字面量会整个漏网 —— 钥匙跟着保险箱一起进仓,加密就白做了。
// 这条不设放行开关:命中说明该口令已等于公开值,正确处置是换口令,不是忽略。
if (plainText.includes(pass)) {
throw new Error(`明文区检测到同步口令本体(已中断,快照未推送)。`
+ `口令是 secrets.enc 的钥匙,随快照进仓等于加密失效,且 git 历史删不干净。`
+ `请把写死该口令的文件改成从 ZCODE_SYNC_PASSPHRASE 读取,并更换团队口令后重导`);
}
// 路径:配置区(本应占位符化)残留即硬失败;插件源码是字节级归档,只警告
// 不限于本机用户名:任何未占位符化的 `Users/<某用户>` 到了别的机器都是错路径。
// 注意按「解析后的值」判断:JSON 里反斜杠是转义态(`\\Users\\`),对文本做正则会漏。
const USER_PATH_RE = /[\\/]Users[\\/][^\\/"\s]+/;
const HOME_ABS = fwd(path.join(os.homedir(), ".zcode"));
const leakyStrings = (node, out = []) => {
if (typeof node === "string") { out.push(node); return out; }
if (Array.isArray(node)) return node.forEach((v) => leakyStrings(v, out)), out;
if (node && typeof node === "object") {
for (const [k, v] of Object.entries(node)) { leakyStrings(k, out); leakyStrings(v, out); }
}
return out;
};
/** 一段文本里是否还有未占位符化的绝对路径 */
const textLeaks = (body, isJson) => {
let strs;
if (isJson) {
try { strs = leakyStrings(JSON.parse(body)); } catch { strs = [body]; } // 解析不了就退回文本判断
} else {
strs = [body];
}
return strs.some((s) => USER_PATH_RE.test(s) || fwd(s).includes(HOME_ABS));
};
/** 文件里是否还有未占位符化的绝对路径 */
const fileLeaks = (f) => {
let body; try { body = fs.readFileSync(f, "utf8"); } catch { return false; }
return textLeaks(body, f.endsWith(".json"));
};
const cfgFiles = snapFiles.filter((f) => !f.includes(`${path.sep}custom-plugins${path.sep}`));
const cfgHits = cfgFiles.filter(fileLeaks).concat(
textLeaks(pendingManifestText, true) ? ["manifest.json"] : []);
if (cfgHits.length && process.env.ZCODE_SYNC_ALLOW_PATH_LEAK !== "1") {
throw new Error(`配置区残留未占位符化的绝对路径(已中断,快照未推送):`
+ `${cfgHits.map((f) => (f.startsWith("manifest") ? f : path.relative(snapDir, f))).slice(0, 5).join(", ")}。`
+ `这些路径还原到别的机器会指错位置,确认无害可设 ZCODE_SYNC_ALLOW_PATH_LEAK=1`);
}
const srcFiles = snapFiles.filter((f) => f.includes(`${path.sep}custom-plugins${path.sep}`));
const pathWarnings = srcFiles.filter(fileLeaks).map((f) => path.relative(snapDir, f));
/* ---- 自检通过,现在才落 manifest + latest.json ---- */
// 两遍算体积:第二遍把 manifest 自身算进去,prune 才能拿到准数
manifest.bytes = dirSize(snapDir);
writeJson(path.join(snapDir, "manifest.json"), manifest);
manifest.bytes = dirSize(snapDir);
writeJson(path.join(snapDir, "manifest.json"), manifest);
const ex = path.join(REPO_DIR, "local-overrides.example.json");
if (!fs.existsSync(ex)) {
writeJson(ex, {
_comment: "本机覆盖(不进仓):拷到 ~/.zcode-env-sync/overrides.json,导入时合并",
mcp: { servers: { "<server名>": { "command": "本机 node 路径", "args": ["..."] } } },
});
}
writeJson(path.join(REPO_DIR, "latest.json"), {
snapshot: `snapshots/${tag}`, host: HOSTNAME, createdAt: manifest.createdAt,
bytes: manifest.bytes, enabledCount: enabled.length, customCount: custom.length,
});
// 只提交本次快照 + 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}` : ""}${pcCfg ? ` groups:${(pcSanitized.config?.config?.providerOrder ?? []).length}` : ""})`,
{ paths: [path.posix.join("snapshots", tag), "latest.json", ".gitattributes", ".gitignore", "local-overrides.example.json"] });
console.log(`# 导出成功`);
console.log(`- 快照:${tag}`);
console.log(`- 官方插件:${official.length} 项(清单),已启用:${enabled.length} 项(${enabled.join(", ") || "—"})`);
console.log(`- 自研插件:${custom.length} 项(${custom.map((c) => `${c.name}@${c.version}`).join(", ") || "—"})`);
console.log(`- MCP:${Object.keys(mcpServers).join(", ") || "—"}`);
console.log(`- 密钥束:${secrets.length} 个文件(已加密)`);
if (v2cfg) {
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) {
console.log(`- ⚠ 自研插件源码里有 ${pathWarnings.length} 个文件含本机绝对路径(源码按字节归档不改动,`
+ `但这些路径到了别的机器会失效,建议改成动态定位):`);
for (const w of pathWarnings.slice(0, 6)) console.log(` ${w}`);
if (pathWarnings.length > 6) console.log(` …另 ${pathWarnings.length - 6} 个`);
}
console.log(`- 推送:${pushMsg}`);
} catch (e) { fail(e); }