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:
@@ -640,3 +640,150 @@ test("E2E:无推送模式只落本地,不碰远端", { timeout: 120000 }, () =>
|
||||
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("E2E:UI 分组(provider_config)完整同步 —— 旧实现会丢只在其中的分组", { timeout: 120000 }, () => {
|
||||
const root = sandboxRoot();
|
||||
const homeA = makeMachine(root, "machineL");
|
||||
seedMachine(homeA, { user: "lena" });
|
||||
fs.mkdirSync(path.join(homeA, ".zcode/v2"), { recursive: true });
|
||||
const SHARED = "11111111-1111-1111-1111-111111111111";
|
||||
const ONLY_PC = "new-provider";
|
||||
const rule = (id, name, key) => ({ providerId: id, providerName: name, config: {
|
||||
group: "standard-personal",
|
||||
access: { type: "api-key", apiKey: key },
|
||||
api: { type: "openai-chat-completions", baseUrl: "http://example.invalid/v1" },
|
||||
personalModelIds: [`${name}-m1`], modelOrder: [`${name}-m1`],
|
||||
} });
|
||||
// config.json 只有 SHARED;provider_config.json 有两个(ONLY_PC 只存在于这里)
|
||||
fs.writeFileSync(path.join(homeA, ".zcode/v2/config.json"), JSON.stringify({
|
||||
provider: { [SHARED]: { name: "shared", kind: "openai-compatible", models: { "shared-m1": {} },
|
||||
options: { apiKey: "sk-e2e-shared-1234567890" } } },
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(homeA, ".zcode/v2/provider_config.json"), JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
config: {
|
||||
providerOrder: [SHARED, ONLY_PC],
|
||||
providerConfigRules: { providerRules: [rule(SHARED, "shared", "sk-e2e-shared-1234567890"),
|
||||
rule(ONLY_PC, "only-here", "sk-e2e-onlyhere-0987654321")] },
|
||||
modelConfigRules: { providerModelRules: [
|
||||
{ modelId: "shared-m1", config: { properties: { contextWindow: 1000000 } }, providerId: SHARED }],
|
||||
manualProviderModelRules: [] },
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
const bare = path.join(root, "bare.git");
|
||||
git(["init", "--bare", "-b", "main", bare]);
|
||||
const w1 = path.join(root, "w1");
|
||||
const ex = runScript("export.mjs", { home: homeA, env: {
|
||||
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_WITH_MODELS: "1", ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: w1 } });
|
||||
assert.equal(ex.code, 0, `导出差错:\n${ex.err}`);
|
||||
assert.match(ex.out, /UI 分组:2 个/, `导出报告应含分组数 → ${(ex.out.match(/UI 分组:[^\n]*/) || ["—"])[0]}`);
|
||||
|
||||
// 快照明文区必须脱敏
|
||||
const snapName = JSON.parse(fs.readFileSync(path.join(w1, "repo", "latest.json"), "utf8")).snapshot;
|
||||
const pcSnap = fs.readFileSync(path.join(w1, "repo", snapName, "v2.provider-config.json"), "utf8");
|
||||
assert.ok(!pcSnap.includes("sk-e2e-shared-"), "明文区不得出现真密钥(共有分组)");
|
||||
assert.ok(!pcSnap.includes("sk-e2e-onlyhere-"), "明文区不得出现真密钥(独有分组)");
|
||||
assert.match(pcSnap, /\$\{MODEL_SECRET_REF\}/, "密钥位置应为占位符");
|
||||
const manSnap = fs.readFileSync(path.join(w1, "repo", snapName, "manifest.json"), "utf8");
|
||||
assert.ok(!manSnap.includes("sk-e2e-"), "manifest 不得出现密钥值");
|
||||
|
||||
// 队友机器:B 完全没有任何 provider_config.json
|
||||
const homeB = makeMachine(root, "machineM");
|
||||
seedMachine(homeB, { user: "mike" });
|
||||
fs.mkdirSync(path.join(homeB, ".zcode/v2"), { recursive: true });
|
||||
fs.writeFileSync(path.join(homeB, ".zcode/v2/config.json"), JSON.stringify({ provider: {}, setting: {} }, null, 2));
|
||||
const w2 = path.join(root, "w2");
|
||||
const im = runScript("import.mjs", { home: homeB, env: {
|
||||
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_WITH_MODELS: "1", ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: w2 } });
|
||||
assert.equal(im.code, 0, `导入差错:\n${im.err}`);
|
||||
assert.match(im.out, /UI 分组:2 个分组/, `导入报告应含分组 → ${(im.out.match(/UI 分组:[^\n]*/) || ["—"])[0]}`);
|
||||
|
||||
const got = JSON.parse(fs.readFileSync(path.join(homeB, ".zcode/v2/provider_config.json"), "utf8")).config;
|
||||
assert.deepEqual(got.providerOrder, [SHARED, ONLY_PC], "分组顺序必须原样还原");
|
||||
const byId = new Map(got.providerConfigRules.providerRules.map((r) => [r.providerId, r]));
|
||||
assert.equal(byId.size, 2, "两个分组都要在(旧实现会丢掉只存在于 provider_config 的那个)");
|
||||
assert.equal(byId.get(SHARED).config.access.apiKey, "sk-e2e-shared-1234567890", "共有分组密钥回填");
|
||||
assert.equal(byId.get(ONLY_PC).config.access.apiKey, "sk-e2e-onlyhere-0987654321",
|
||||
"只在 provider_config 里存在的分组,密钥也必须回填");
|
||||
assert.equal(byId.get(ONLY_PC).config.group, "standard-personal", "group 字段要还原");
|
||||
assert.deepEqual(byId.get(ONLY_PC).config.personalModelIds, ["only-here-m1"], "模型清单要还原");
|
||||
assert.equal(got.modelConfigRules.providerModelRules.length, 1, "模型级配置(如 contextWindow)要带过来");
|
||||
assert.equal(got.modelConfigRules.providerModelRules[0].config.properties.contextWindow, 1000000);
|
||||
|
||||
// 幂等:再导一次不应重复追加
|
||||
const w3 = path.join(root, "w3");
|
||||
const im2 = runScript("import.mjs", { home: homeB, env: {
|
||||
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_WITH_MODELS: "1", ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: w3 } });
|
||||
assert.equal(im2.code, 0, `二次导入应成功\n${im2.err}`);
|
||||
const got2 = JSON.parse(fs.readFileSync(path.join(homeB, ".zcode/v2/provider_config.json"), "utf8")).config;
|
||||
assert.equal(got2.providerConfigRules.providerRules.length, 2, "重复导入不得追加重复分组");
|
||||
assert.equal(got2.modelConfigRules.providerModelRules.length, 1, "重复导入不得追加重复模型级配置");
|
||||
assert.equal(got2.providerOrder.length, 2, "重复导入不得把 providerOrder 撑大");
|
||||
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("E2E:OVERWRITE_MODEL_SECRETS —— 默认护住本机密钥,显式开启才强制覆盖(两份文件)", { timeout: 120000 }, () => {
|
||||
const root = sandboxRoot();
|
||||
const PID = "aaaaaaaa-1111-2222-3333-444444444444";
|
||||
const A_KEY = "sk-snapshot-source-1111111111"; // 快照里的值
|
||||
const B_KEY = "sk-stale-local-9999999999"; // 队友机器上已有的陈旧值
|
||||
const writeV2 = (home, key) => {
|
||||
fs.mkdirSync(path.join(home, ".zcode/v2"), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, ".zcode/v2/config.json"), JSON.stringify({
|
||||
provider: { [PID]: { name: "p", kind: "openai-compatible", models: { m: {} }, options: { apiKey: key } } },
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(home, ".zcode/v2/provider_config.json"), JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
config: {
|
||||
providerOrder: [PID],
|
||||
providerConfigRules: { providerRules: [{ providerId: PID, providerName: "p",
|
||||
config: { group: "standard-personal", access: { type: "api-key", apiKey: key } } }] },
|
||||
modelConfigRules: { providerModelRules: [], manualProviderModelRules: [] },
|
||||
},
|
||||
}, null, 2));
|
||||
};
|
||||
const readKeys = (home) => ({
|
||||
cfg: JSON.parse(fs.readFileSync(path.join(home, ".zcode/v2/config.json"), "utf8")).provider[PID].options.apiKey,
|
||||
pc: JSON.parse(fs.readFileSync(path.join(home, ".zcode/v2/provider_config.json"), "utf8"))
|
||||
.config.providerConfigRules.providerRules[0].config.access.apiKey,
|
||||
});
|
||||
|
||||
const homeA = makeMachine(root, "machineN");
|
||||
seedMachine(homeA, { user: "nina" });
|
||||
writeV2(homeA, A_KEY);
|
||||
|
||||
const bare = path.join(root, "bare.git");
|
||||
git(["init", "--bare", "-b", "main", bare]);
|
||||
const ex = runScript("export.mjs", { home: homeA, env: {
|
||||
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_WITH_MODELS: "1", ZCODE_SYNC_GIT_REMOTE: bare,
|
||||
ZCODE_SYNC_WORK: path.join(root, "w1") } });
|
||||
assert.equal(ex.code, 0, `导出差错:\n${ex.err}`);
|
||||
|
||||
// 队友机器:两份文件里都已有一个"陈旧的、非空的"密钥
|
||||
const homeB = makeMachine(root, "machineO");
|
||||
seedMachine(homeB, { user: "omar" });
|
||||
const importInto = (work, extra = {}) => runScript("import.mjs", { home: homeB, env: {
|
||||
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_WITH_MODELS: "1", ZCODE_SYNC_GIT_REMOTE: bare,
|
||||
ZCODE_SYNC_WORK: path.join(root, work), ...extra } });
|
||||
|
||||
// --- 默认行为:护住本机密钥,不覆盖 ---
|
||||
writeV2(homeB, B_KEY);
|
||||
const r1 = importInto("w2");
|
||||
assert.equal(r1.code, 0, `导入应成功\n${r1.err}`);
|
||||
assert.match(r1.out, /跳过覆盖本机已有密钥|护住本机已有密钥/, `默认应报告被护住的密钥 → ${(r1.out.match(/模型 provider:[^\n]*/) || ["—"])[0]}`);
|
||||
let got = readKeys(homeB);
|
||||
assert.equal(got.cfg, B_KEY, "默认不得覆盖 config.json 里的本机密钥");
|
||||
assert.equal(got.pc, B_KEY, "默认不得覆盖 provider_config.json 里的本机密钥");
|
||||
|
||||
// --- 显式开启:强制覆盖为快照值(两份文件都要换) ---
|
||||
writeV2(homeB, B_KEY);
|
||||
const r2 = importInto("w3", { ZCODE_SYNC_OVERWRITE_MODEL_SECRETS: "1" });
|
||||
assert.equal(r2.code, 0, `强制覆盖导入应成功\n${r2.err}`);
|
||||
got = readKeys(homeB);
|
||||
assert.equal(got.cfg, A_KEY, "强制覆盖后 config.json 应等于快照值");
|
||||
assert.equal(got.pc, A_KEY, "强制覆盖后 provider_config.json 应等于快照值");
|
||||
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user