问题:只同步 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 项通过
281 lines
15 KiB
JavaScript
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); }
|