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

790 lines
43 KiB
JavaScript

/**
* zcode-env-sync 端到端沙箱测试。
*
* 用 USERPROFILE 把 HOME 重定向到临时目录,造两台互不相干的"假机器" A/B,
* 用一个本地裸仓冒充 Gitea,跑完整链路:
*
* A: 造环境 → export → commit → push
* B: clone → import → 校验路径已被还原成 B 自己的、密钥已解密落地、备份已生成
*
* 全程不碰真实 HOME,不碰网络。
* 跑法: node --test tests/
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { execFileSync, spawnSync } from "node:child_process";
import { hashDir } from "../scripts/sync-core.mjs";
import { readSnapshotManifest } from "../scripts/sync-core.mjs";
const SCRIPTS = path.resolve(import.meta.dirname, "..", "scripts");
const PASS = "team-passphrase-for-tests";
const GIT_ENV = { GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0" };
/* ---------- 工具 ---------- */
function sandboxRoot() {
return fs.mkdtempSync(path.join(os.tmpdir(), "zes-e2e-"));
}
/** 造一台假机器的 home 骨架 */
function makeMachine(root, name) {
const home = path.join(root, name);
const dirs = [
".zcode/cli/plugins/data",
".zcode/cli/plugins/marketplaces/zcode-plugins-official",
".zcode/plugins",
".dsh/secrets",
".ssh",
];
for (const d of dirs) fs.mkdirSync(path.join(home, d), { recursive: true });
return home;
}
/** 造这台机器的 ZCode 环境(插件/MCP/密钥),路径全部指向本机 home */
function seedMachine(home, { user, mcpExtra = {} } = {}) {
const z = path.join(home, ".zcode"), d = path.join(home, ".dsh");
// 官方插件清单(市场远端的)
fs.writeFileSync(path.join(z, "cli/plugins/marketplaces/zcode-plugins-official/marketplace.json"),
JSON.stringify({ plugins: [
{ name: "browser-use", version: "0.4.2" },
{ name: "document-skills", version: "0.1.4" },
{ name: "computer-use", version: "0.5.14" },
] }, null, 2));
// 启用态 = data/ 目录
for (const n of ["browser-use@zcode-plugins-official", "document-skills@zcode-plugins-official", "zcode-tps@inline"]) {
fs.mkdirSync(path.join(z, "cli/plugins/data", n), { recursive: true });
}
// 自研插件(带 .zcode-plugin/plugin.json);含 CRLF 文件,用于验证 git 不改写换行符
for (const [nm, ver, files] of [["zcode-tps", "0.4.1", { "scripts/rate.mjs": "export const v=1;\r\n// CRLF 文件:git 若做换行符转换,hash 就会与 manifest 对不上\r\n" }],
["zcode-usage", "1.0.0", { "scripts/u.mjs": "export const v=2;\n" }]]) {
const dir = path.join(z, "plugins", nm);
fs.mkdirSync(path.join(dir, ".zcode-plugin"), { recursive: true });
fs.writeFileSync(path.join(dir, ".zcode-plugin/plugin.json"), JSON.stringify({ name: nm, version: ver }));
for (const [rel, body] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), body);
}
}
// cli/config.json —— MCP 全用本机绝对路径,含反斜杠与正斜杠两种写法
const nf = (p) => p.replace(/\\/g, "/"); // 正斜杠风格(本机真实风格)
const nb = (p) => p.replace(/\//g, "\\"); // 反斜杠风格(WM 上常见)
const cfg = {
mcp: { servers: {
context7: { type: "stdio", command: nb(path.join(home, "tools", "node.exe")), args: [nf(path.join(home, "tools", "ctx7", "dist", "index.js"))] },
gitea: { type: "stdio", command: nb(path.join(d, "bin", "gitea-mcp.exe")), args: ["-H", "https://gitea.example.invalid"],
env: { GITEA_ACCESS_TOKEN_FILE: nf(path.join(d, "secrets", "gitea-token.txt")) } },
mysql: { type: "stdio", command: nb(path.join(home, "tools", "node.exe")), args: [nf(path.join(d, "secrets", "mysql-mcp-launch.mjs"))] },
ssh: { type: "stdio", command: nb(path.join(home, "tools", "node.exe")), args: [nf(path.join(home, "tools", "ssh-mcp", "index.js"))],
env: { SSH_PROFILES_FILE: nf(path.join(home, ".ssh", "ssh-profiles.json")) } },
...mcpExtra,
} },
plugins: { enabled: true, dirs: [
nf(path.join(z, "plugins", "zcode-tps")),
nf(path.join(z, "plugins", "zcode-usage")),
] },
};
fs.writeFileSync(path.join(z, "cli", "config.json"), JSON.stringify(cfg, null, 2));
// 密钥
fs.writeFileSync(path.join(d, "secrets", "gitea-token.txt"), `tok-${user}-${"x".repeat(28)}\n`);
fs.writeFileSync(path.join(d, "secrets", "mysql-root-password.txt"), `pw-${user}-mysql`);
fs.writeFileSync(path.join(d, "secrets", "mysql-mcp-launch.mjs"), `// launcher for ${user}\nexport default 1;\n`);
fs.writeFileSync(path.join(home, ".ssh", "ssh-profiles.json"),
JSON.stringify({ profiles: { box1: { host: `${user}.example.invalid`, port: 22, user } } }, null, 2));
return cfg;
}
/** 子进程环境:把 HOME 指向假机器。extra 后应用,可覆盖任何继承值 */
function envFor(home, extra = {}) {
const env = { ...process.env, ...GIT_ENV, USERPROFILE: home, HOME: home };
delete env.HOMEDRIVE;
delete env.HOMEPATH;
delete env.ZCODE_SYNC_ALLOW_SECRETS;
return { ...env, ...extra };
}
function runScript(script, { home, args = [], env = {} }) {
const r = spawnSync(process.execPath, [path.join(SCRIPTS, script), ...args], {
env: envFor(home, env), encoding: "utf8", timeout: 120000,
});
return { code: r.status, out: r.stdout ?? "", err: r.stderr ?? "" };
}
function git(args, opts = {}) {
return execFileSync("git", args, { encoding: "utf8", ...GIT_ENV, ...opts });
}
function readJson(f) { return JSON.parse(fs.readFileSync(f, "utf8")); }
function sha256File(f) { return crypto.createHash("sha256").update(fs.readFileSync(f)).digest("hex"); }
/** 比较路径时归一化分隔符:还原保留各字段原本的正/反斜杠风格,不该强求统一 */
function norm(p) { return String(p).replace(/\\/g, "/").replace(/\/+$/, ""); }
/* ---------- 主流程(一次搭台,多个断言) ---------- */
test("E2E:A 机导出 → 裸仓 → B 机导入,跨机器路径正确还原", { timeout: 180000 }, () => {
const root = sandboxRoot();
const bare = path.join(root, "gitea.git");
git(["init", "--bare", "-b", "main", bare]);
const homeA = makeMachine(root, "machineA"), homeB = makeMachine(root, "machineB");
seedMachine(homeA, { user: "alice" });
seedMachine(homeB, { user: "bob" });
const NSYNC = path.join(root, "nsync");
const commonEnv = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: path.join(NSYNC, "work") };
/* ---- A 机导出并推送 ---- */
const ex = runScript("export.mjs", { home: homeA, env: commonEnv });
assert.equal(ex.code, 0, `导出应成功\nstdout=${ex.out}\nstderr=${ex.err}`);
assert.match(ex.out, /# 导出成功/);
assert.match(ex.out, /已推送/, `应真的推到裸仓\n${ex.out}`);
const repoA = path.join(NSYNC, "work", "repo");
const latest = readJson(path.join(repoA, "latest.json"));
const snap = path.join(repoA, latest.snapshot);
// 快照结构齐全
for (const f of ["manifest.json", "mcp.servers.json", "plugins-dirs.json", "secrets.enc", "secrets.manifest.json"]) {
assert.ok(fs.existsSync(path.join(snap, f)), `快照缺 ${f}`);
}
assert.ok(fs.existsSync(path.join(snap, "custom-plugins", "zcode-tps", ".zcode-plugin", "plugin.json")), "自研插件应被带进快照");
/* ---- 关键断言:明文区不含 A 机路径,且占位符是活的 ---- */
const mcpText = fs.readFileSync(path.join(snap, "mcp.servers.json"), "utf8");
const dirsText = fs.readFileSync(path.join(snap, "plugins-dirs.json"), "utf8");
assert.doesNotMatch(mcpText, /machineA/, "MCP 明文不得残留 A 机路径(含反斜杠写法)");
assert.doesNotMatch(dirsText, /machineA/, "plugins.dirs 不得残留 A 机路径");
assert.match(mcpText, /\$\{USER_HOME_WIN\}|\$\{USER_HOME\}/, "应使用主目录占位符");
assert.match(mcpText, /\$\{DSH_HOME_WIN\}|\$\{DSH_HOME\}/, "应使用 DSH 占位符");
assert.doesNotMatch(mcpText, /AppData|machineA/i);
// 明文区无密钥值(只有 *_FILE 引用)
const mcpObj = JSON.parse(mcpText);
assert.ok(mcpObj.gitea.env.GITEA_ACCESS_TOKEN_FILE.includes("gitea-token.txt"), "密钥应以文件引用形式出现");
// secrets.manifest 只有路径+hash,没有值
const secMan = readJson(path.join(snap, "secrets.manifest.json"));
const secText = JSON.stringify(secMan);
assert.doesNotMatch(secText, /tok-alice|pw-alice/, "清单里不得出现密钥明文");
assert.ok(secMan.some((f) => f.rel === "DSH_SECRETS/gitea-token.txt"));
// manifest 全文(明文进仓)不得带出本机绝对路径/用户名
const manText = fs.readFileSync(path.join(snap, "manifest.json"), "utf8");
assert.doesNotMatch(manText, /machineA/, "manifest 不得残留本机 home 路径");
assert.match(JSON.parse(manText).zcodeHomeTemplate, /\$\{ZCODE_HOME\}/, "应存占位符模板而非绝对路径");
// 配置区(MCP/dirs/manifest)不得残留本机路径;插件源码里的硬编码只警告不中断
assert.doesNotMatch(mcpText, /machineA/, "MCP 配置区不得残留");
assert.doesNotMatch(dirsText, /machineA/, "plugins.dirs 不得残留");
assert.match(ex.out, /明文区自检:无密钥泄漏/, "应报告自检通过");
// 密文里不得出现明文密钥
const encText = fs.readFileSync(path.join(snap, "secrets.enc"), "utf8");
assert.doesNotMatch(encText, /tok-alice|pw-alice/, "密钥束必须是密文");
/* ---- git:origin 已补上、commit 已产生、追踪引用健康 ---- */
const remoteUrl = git(["-C", repoA, "remote", "get-url", "origin"]).trim();
assert.ok(remoteUrl.length > 0, "origin 必须存在(旧实现克隆失败后不补 origin)");
assert.doesNotMatch(remoteUrl, /@/, "origin 明文里不得内嵌 token(凭据只在内存里用)");
const cfgText = fs.readFileSync(path.join(repoA, ".git", "config"), "utf8");
assert.doesNotMatch(cfgText, /tok-alice|ghp_|@gitea|@.*\.git/, ".git/config 不得落 token");
assert.match(git(["-C", repoA, "log", "--oneline"]), /sync-export/, "应有导出 commit");
assert.match(git(["--git-dir", bare, "log", "--oneline", "main"]), /sync-export/, "裸仓应收到提交");
// 追踪引用必须可用,否则 git status/log origin/main 全是残废状态
const headA = git(["-C", repoA, "rev-parse", "HEAD"]).trim();
assert.equal(git(["-C", repoA, "rev-parse", "origin/main"]).trim(), headA, "origin/main 应指向已推送的提交");
assert.equal(git(["-C", repoA, "rev-parse", "main@{upstream}"]).trim(), headA, "main 的 upstream 应已绑定");
/* ---- 字节完整性:克隆一份远端,校验内容 hash 与 manifest 一致 ---- */
// Windows 上 core.autocrlf 默认可能是 true,会改写换行符让 hash 永久漂移
assert.equal(git(["-C", repoA, "config", "--local", "core.autocrlf"]).trim(), "false", "本仓应禁用 autocrlf");
assert.equal(fs.readFileSync(path.join(repoA, ".gitattributes"), "utf8").includes("* -text"), true, "应有 .gitattributes 锁死换行符转换");
const cloneDir = path.join(root, "verify-clone");
git(["clone", "-q", bare, cloneDir]);
const manifestA = readSnapshotManifest(path.join(cloneDir, latest.snapshot));
for (const c of manifestA.custom) {
const got = hashDir(path.join(cloneDir, latest.snapshot, "custom-plugins", c.name));
assert.equal(got, c.hash, `克隆回来的 ${c.name} 内容 hash 必须与 manifest 一致(实际 ${got} vs ${c.hash})`);
}
// CRLF 文件必须原样穿过 git
const crlfFile = path.join(cloneDir, latest.snapshot, "custom-plugins", "zcode-tps", "scripts", "rate.mjs");
assert.match(fs.readFileSync(crlfFile, "utf8"), /\r\n/, "CRLF 不得被 git 改写成 LF");
assert.equal(fs.readFileSync(crlfFile, "utf8"), "export const v=1;\r\n// CRLF 文件:git 若做换行符转换,hash 就会与 manifest 对不上\r\n", "内容必须逐字节一致");
/* ---- B 机导入 ---- */
const im = runScript("import.mjs", { home: homeB, env: commonEnv });
assert.equal(im.code, 0, `导入应成功\nstdout=${im.out}\nstderr=${im.err}`);
assert.match(im.out, /# 导入快照/);
assert.match(im.out, /自研插件还原:2 项/);
assert.match(im.out, /密钥束还原:4 个文件/);
/* ---- 核心:路径已还原成 B 机的 ---- */
const cfgB = readJson(path.join(homeB, ".zcode", "cli", "config.json"));
const s = cfgB.mcp.servers;
const hB = norm(homeB);
assert.ok(s.context7.command.endsWith("node.exe"));
assert.ok(norm(s.context7.command).startsWith(hB), `command 应指向 B 机主目录,实际=${s.context7.command}`);
assert.ok(norm(s.mysql.args[0]).startsWith(hB), `mysql 启动脚本应指向 B 机,实际=${s.mysql.args[0]}`);
assert.ok(norm(s.ssh.env.SSH_PROFILES_FILE).startsWith(hB), "SSH_PROFILES_FILE 应指向 B 机");
assert.ok(norm(s.gitea.env.GITEA_ACCESS_TOKEN_FILE).startsWith(hB), "token 文件应指向 B 机");
assert.equal(norm(s.context7.args[0]), `${hB}/tools/ctx7/dist/index.js`,
"嵌套 args 路径应精确还原");
assert.doesNotMatch(JSON.stringify(cfgB), /machineA/, "B 机配置里不得残留 A 机路径");
assert.doesNotMatch(JSON.stringify(cfgB), /\$\{/, "B 机配置里不得残留未还原的占位符");
// 密钥文件引用字段:风格保持原样(反斜杠写法不应被强行转成正斜杠)
assert.ok(s.gitea.env.GITEA_ACCESS_TOKEN_FILE.includes("/"), "正斜杠风格应保留");
// plugins.dirs:B 机自己的 + 快照里的(A 机的已还原成 B 机),且去重
const dirsB = cfgB.plugins.dirs;
assert.deepEqual(new Set(dirsB).size, dirsB.length, "dirs 不得重复");
for (const d of dirsB) assert.match(d, /machineB/, `dirs 应全部指向 B 机:${d}`);
// 自研插件已落地
assert.ok(fs.existsSync(path.join(homeB, ".zcode/plugins/zcode-tps/scripts/rate.mjs")), "自研插件文件应还原");
/* ---- 密钥已解密落地,且 hash 与清单一致 ---- */
const giteaTok = path.join(homeB, ".dsh", "secrets", "gitea-token.txt");
assert.ok(fs.existsSync(giteaTok), "密钥文件应还原");
const want = new Map(secMan.map((f) => [f.rel, f.sha256]));
assert.equal(sha256File(giteaTok), want.get("DSH_SECRETS/gitea-token.txt"), "还原内容应与 A 机快照一致");
assert.match(fs.readFileSync(giteaTok, "utf8"), /tok-alice/, "密钥内容应原样带过来(跨机器共享凭据)");
assert.equal(sha256File(path.join(homeB, ".ssh", "ssh-profiles.json")), want.get("SSH/ssh-profiles.json"));
/* ---- 覆盖前已备份 ---- */
const bkRoot = path.join(NSYNC, "work", "backup");
assert.ok(fs.existsSync(bkRoot), "应产生备份目录");
const stamps = fs.readdirSync(bkRoot);
assert.ok(stamps.length >= 1, "应至少有一个备份批次");
const bFiles = stamps.flatMap((st) => {
const walk = (d) => fs.readdirSync(d, { withFileTypes: true }).flatMap((e) =>
e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]);
return walk(path.join(bkRoot, st));
});
assert.ok(bFiles.some((f) => f.endsWith("config.json")), "被覆盖的 config.json 应有备份");
assert.ok(bFiles.some((f) => f.endsWith("gitea-token.txt")), "被覆盖的密钥应有备份");
const bkCfg = bFiles.find((f) => f.endsWith("config.json"));
assert.match(fs.readFileSync(bkCfg, "utf8"), /machineB/, "备份的应是 B 机导入前的旧文件");
/* ---- B 机再跑一次 status,应报告与本机一致 ---- */
const st = runScript("status.mjs", { home: homeB, env: commonEnv });
assert.equal(st.code, 0, `status 应成功\n${st.err}`);
assert.match(st.out, /自研插件:一致/);
assert.match(st.out, /密钥束:清单一致/);
fs.rmSync(root, { recursive: true, force: true });
});
test("E2E:plugins/ 下的隐藏备份目录不得被当插件(旧实现会虚报并互相覆盖)", { timeout: 120000 }, () => {
const root = sandboxRoot();
const home = makeMachine(root, "machineF");
const cfg = seedMachine(home, { user: "frank" });
const z = path.join(home, ".zcode");
// 伪造一个插件备份目录:内容与 zcode-tps 同名,但版本不同
const bk = path.join(z, "plugins", ".backup-zcode-tps-20260911");
fs.mkdirSync(path.join(bk, ".zcode-plugin"), { recursive: true });
fs.writeFileSync(path.join(bk, ".zcode-plugin", "plugin.json"),
JSON.stringify({ name: "zcode-tps", version: "0.0.0-old" }));
const w = path.join(root, "w");
const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } });
assert.equal(r.code, 0, `导出应成功(隐藏目录被跳过)\n${r.err}`);
assert.doesNotMatch(r.out, /0\.0\.0-old/, "备份目录版本不得出现在报告里");
const latest = readJson(path.join(w, "repo", "latest.json"));
const m = readJson(path.join(w, "repo", latest.snapshot, "manifest.json"));
const names = m.custom.map((c) => c.name);
assert.deepEqual(new Set(names).size, names.length, `manifest 不得有重名:${names.join(",")}`);
assert.equal(names.filter((n) => n === "zcode-tps").length, 1, "zcode-tps 只应出现一次");
assert.equal(m.custom.find((c) => c.name === "zcode-tps").version, "0.4.1", "应是真插件版本");
// 快照里的实际目录数应与 manifest 一致
const cpDir = path.join(w, "repo", latest.snapshot, "custom-plugins");
assert.deepEqual(fs.readdirSync(cpDir).sort(), names.sort(), "快照目录必须与 manifest 一致");
// 真插件内容被复制,而不是备份版本
const rj = readJson(path.join(cpDir, "zcode-tps", ".zcode-plugin", "plugin.json"));
assert.equal(rj.version, "0.4.1");
fs.rmSync(root, { recursive: true, force: true });
});
test("E2E:两个插件 manifest 同名 → 导出明确报错,不静默丢一个", { timeout: 120000 }, () => {
const root = sandboxRoot();
const home = makeMachine(root, "machineG");
seedMachine(home, { user: "grace" });
const z = path.join(home, ".zcode");
// dir 名不同,但 plugin.json 里的 name 相同
const clash = path.join(z, "plugins", "zcode-tps-fork");
fs.mkdirSync(path.join(clash, ".zcode-plugin"), { recursive: true });
fs.writeFileSync(path.join(clash, ".zcode-plugin", "plugin.json"), JSON.stringify({ name: "zcode-tps", version: "9.9.9" }));
const w = path.join(root, "w");
const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } });
assert.notEqual(r.code, 0, "重名必须中断导出");
assert.match(r.err, /名字冲突|冲突/);
assert.ok(!fs.existsSync(path.join(w, "repo", "latest.json")), "中断时不得推进 latest.json");
fs.rmSync(root, { recursive: true, force: true });
});
test("E2E:开启模型同步 → provider 脱敏进快照,真密钥一个字节都不许留", { timeout: 120000 }, () => {
const root = sandboxRoot();
const home = makeMachine(root, "machineH");
seedMachine(home, { user: "henry" });
// 造一份带真密钥的 v2/config.json
const SECRET_A = "sk-providerAAAAsecret9999", SECRET_B = "sk-providerBBBBsecret8888";
const v2 = path.join(home, ".zcode", "v2");
fs.mkdirSync(v2, { recursive: true });
fs.writeFileSync(path.join(v2, "config.json"), JSON.stringify({ provider: {
"builtin:x": { name: "X", kind: "anthropic", models: { m1: {}, m2: {} }, options: { apiKey: SECRET_A } },
"custom:y": { name: "Y", kind: "openai-compatible", models: { m3: {} }, options: { apiKey: SECRET_B, baseURL: "https://api.example.invalid/v1" } },
} }, null, 2));
fs.writeFileSync(path.join(v2, "setting.json"), JSON.stringify({
modelProviderFamilyModes: { a: "auto" },
modelProviderFamilySelectedKeys: { a: "builtin:x" },
providerFamilyDomain: "example.invalid",
windowState: { should: "not-be-synced" },
}, null, 2));
const w = path.join(root, "w");
const r = runScript("export.mjs", { home, env: {
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w, ZCODE_SYNC_WITH_MODELS: "1",
} });
assert.equal(r.code, 0, `导出应成功\n${r.err}`);
assert.match(r.out, /模型 provider:2 个/, `应报告 2 个 provider → ${(r.out.match(/模型 provider:[^\n]*/) || ["—"])[0]}`);
assert.match(r.out, /明文区自检:无密钥泄漏/);
const latest = readJson(path.join(w, "repo", "latest.json"));
const snap = path.join(w, "repo", latest.snapshot);
const m = readJson(path.join(snap, "manifest.json"));
// manifest 只记结构
assert.equal(m.modelProviders.length, 2);
assert.deepEqual(m.modelProvidersWithSecrets.sort(), ["builtin:x", "custom:y"], "应标记哪些 provider 带密钥");
assert.doesNotMatch(JSON.stringify(m), /sk-provider/, "manifest 不得含密钥值");
assert.deepEqual(m.modelProviders.find((p) => p.id === "builtin:x").models, ["m1", "m2"], "应记录模型清单");
assert.equal(m.modelSelection.providerFamilyDomain, "example.invalid");
assert.ok(!("windowState" in (m.modelSelection ?? {})), "窗口状态之类不得进 manifest");
// 脱敏配置本体:结构在、密钥没了
const provs = readJson(path.join(snap, "v2.providers.json"));
assert.equal(provs["builtin:x"].kind, "anthropic", "非密钥字段保留");
assert.deepEqual(Object.keys(provs["builtin:x"].models), ["m1", "m2"]);
assert.equal(provs["builtin:x"].options.apiKey, "${MODEL_SECRET_REF}", "密钥位置应为占位符");
assert.equal(provs["custom:y"].options.baseURL, "https://api.example.invalid/v1", "非密钥选项保留");
// 全快照明文扫描:真密钥一个都不许出现(secrets.enc 是密文,单独放行)
const walkFiles = (d, acc = []) => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) walkFiles(p, acc); else acc.push(p);
}
return acc;
};
for (const f of walkFiles(snap)) {
if (path.basename(f) === "secrets.enc") continue;
const body = fs.readFileSync(f, "utf8");
assert.doesNotMatch(body, /sk-providerAAAAsecret9999|sk-providerBBBBsecret8888/, `密钥泄漏进 ${path.relative(snap, f)}`);
}
// 导入侧:报告 provider,但默认不覆盖本机 v2/config.json
const im = runScript("import.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } });
assert.equal(im.code, 0, `导入应成功\n${im.err}`);
assert.match(im.out, /模型 provider:快照含 2 个/, `→ ${(im.out.match(/模型 provider:[^\n]*/) || ["—"])[0]}`);
const localV2 = JSON.parse(fs.readFileSync(path.join(v2, "config.json"), "utf8"));
assert.equal(localV2.provider["builtin:x"].options.apiKey, SECRET_A, "默认不得改动本机 v2 配置");
// 显式开启后:密钥应从加密束回填(同一台机已有真值 → 受覆盖保护,不覆盖)
const im2 = runScript("import.mjs", { home, env: {
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w, ZCODE_SYNC_WITH_MODELS: "1",
} });
assert.equal(im2.code, 0, `开启模型同步的导入应成功\n${im2.err}`);
assert.match(im2.out, /模型 provider:新增 \d+ 个,密钥回填 \d+ 个/, `→ ${(im2.out.match(/模型 provider:[^\n]*/) || ["—"])[0]}`);
const afterV2 = JSON.parse(fs.readFileSync(path.join(v2, "config.json"), "utf8"));
assert.equal(afterV2.provider["builtin:x"].options.apiKey, SECRET_A, "本机已有真值应受覆盖保护");
assert.ok(/备份 v2\/config\.json/.test(im2.out), "改 v2 配置前应先备份");
assert.ok(afterV2.provider["builtin:x"].options.apiKey.length >= 8, "密钥必须仍是可用值");
fs.rmSync(root, { recursive: true, force: true });
});
test("E2E:空密钥的本机 → 模型密钥从加密束回填成功", { timeout: 120000 }, () => {
const root = sandboxRoot();
const homeA = makeMachine(root, "machineK1"), homeB = makeMachine(root, "machineK2");
seedMachine(homeA, { user: "kim" });
seedMachine(homeB, { user: "leo" });
const SECRET = "sk-roundtripsecret777777";
const v2A = path.join(homeA, ".zcode", "v2");
fs.mkdirSync(v2A, { recursive: true });
fs.writeFileSync(path.join(v2A, "config.json"), JSON.stringify({ provider: {
"builtin:q": { name: "Q", kind: "anthropic", models: { m1: {} }, options: { apiKey: SECRET } },
} }, null, 2));
const bare = path.join(root, "bare.git");
git(["init", "--bare", "-b", "main", bare]);
const W = path.join(root, "work");
const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: W };
// A 机导出(带模型)
const ex = runScript("export.mjs", { home: homeA, env: { ...env, ZCODE_SYNC_WITH_MODELS: "1" } });
assert.equal(ex.code, 0, `A 机导出应成功\n${ex.err}`);
// 快照明文里不得有密钥
const latest = readJson(path.join(W, "repo", "latest.json"));
const snap = path.join(W, "repo", latest.snapshot);
for (const f of fs.readdirSync(snap)) {
if (f === "secrets.enc") continue;
const p = path.join(snap, f);
if (fs.statSync(p).isFile()) {
assert.doesNotMatch(fs.readFileSync(p, "utf8"), /sk-roundtripsecret/, `密钥泄漏进 ${f}`);
}
}
// B 机导入:B 机 v2 里没有这个 provider → 应新建并回填密钥
const im = runScript("import.mjs", { home: homeB, env: { ...env, ZCODE_SYNC_WITH_MODELS: "1" } });
assert.equal(im.code, 0, `B 机导入应成功\n${im.err}`);
const v2B = JSON.parse(fs.readFileSync(path.join(homeB, ".zcode", "v2", "config.json"), "utf8"));
assert.ok(v2B.provider?.["builtin:q"], "B 机应新建该 provider");
assert.equal(v2B.provider["builtin:q"].options.apiKey, SECRET, "密钥应跨机回填成功");
assert.equal(v2B.provider["builtin:q"].kind, "anthropic", "结构字段应一并还原");
fs.rmSync(root, { recursive: true, force: true });
});
test("E2E:未开启模型同步时,provider 密钥绝不进快照", { timeout: 120000 }, () => {
const root = sandboxRoot();
const home = makeMachine(root, "machineI");
seedMachine(home, { user: "ivan" });
const v2 = path.join(home, ".zcode", "v2");
fs.mkdirSync(v2, { recursive: true });
fs.writeFileSync(path.join(v2, "config.json"), JSON.stringify({ provider: {
"builtin:z": { name: "Z", kind: "anthropic", models: { m: {} }, options: { apiKey: "sk-must-never-appear123" } },
} }, null, 2));
const w = path.join(root, "w");
const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } });
assert.equal(r.code, 0, `导出应成功\n${r.err}`);
assert.doesNotMatch(r.out, /模型 provider/, "默认不应报告 provider");
const latest = readJson(path.join(w, "repo", "latest.json"));
const snap = path.join(w, "repo", latest.snapshot);
assert.ok(!fs.existsSync(path.join(snap, "v2.providers.json")), "默认不得写 provider 文件");
assert.doesNotMatch(fs.readFileSync(path.join(snap, "manifest.json"), "utf8"), /sk-must-never/, "manifest 不得含密钥");
assert.doesNotMatch(fs.readFileSync(path.join(snap, "manifest.json"), "utf8"), /modelProviders/, "默认不应记录 provider 摘要");
fs.rmSync(root, { recursive: true, force: true });
});
test("E2E:ZCODE_SYNC_SKIP_SECRETS 跳过指定密钥(连接统一、身份各用各的)", { timeout: 120000 }, () => {
const root = sandboxRoot();
const homeA = makeMachine(root, "machineS1"), homeB = makeMachine(root, "machineS2");
seedMachine(homeA, { user: "sam" });
seedMachine(homeB, { user: "tina" });
const bare = path.join(root, "bare.git");
git(["init", "--bare", "-b", "main", bare]);
const W = path.join(root, "work");
const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: W };
const ex = runScript("export.mjs", { home: homeA, env });
assert.equal(ex.code, 0, `A 机导出应成功\n${ex.err}`);
// B 机导入时跳过 gitea-token:本机 token 必须原样保留
const beforeTok = fs.readFileSync(path.join(homeB, ".dsh", "secrets", "gitea-token.txt"), "utf8");
const im = runScript("import.mjs", { home: homeB, env: { ...env, ZCODE_SYNC_SKIP_SECRETS: "DSH_SECRETS/gitea-token.txt" } });
assert.equal(im.code, 0, `B 机导入应成功\n${im.err}`);
assert.match(im.out, /跳过 1 个\(ZCODE_SYNC_SKIP_SECRETS\):DSH_SECRETS\/gitea-token.txt/);
const afterTok = fs.readFileSync(path.join(homeB, ".dsh", "secrets", "gitea-token.txt"), "utf8");
assert.equal(afterTok, beforeTok, "被跳过的 token 不得被快照覆盖");
// 没被跳过的照常还原
const latest = readJson(path.join(W, "repo", "latest.json"));
assert.ok(latest.snapshot, "快照应存在");
fs.rmSync(root, { recursive: true, force: true });
});
test("E2E:配置区残留本机路径 → 导出中断(旧实现会静默推出去)", { timeout: 120000 }, () => {
const root = sandboxRoot();
const home = makeMachine(root, "machineJ");
seedMachine(home, { user: "judy" });
const w = path.join(root, "w");
const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w };
// 塞一条「别的用户名」的绝对路径:占位符规则只认本机用户名,这条必然漏网,
// 但到了别人机器上就是错的 —— 正是自检要拦的东西
const cfgPath = path.join(home, ".zcode", "cli", "config.json");
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
cfg.mcp.servers.weird = { type: "stdio", command: "C:\\Users\\someoneelse\\custom\\dir\\tool.exe", args: ["--x"] };
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
const r = runScript("export.mjs", { home, env });
assert.notEqual(r.code, 0, "配置区含未占位符化的绝对路径必须中断");
assert.match(r.err, /未占位符化的绝对路径|残留本机路径|占位符化不完整/);
assert.ok(!fs.existsSync(path.join(w, "repo", "latest.json")), "中断时不得推进 latest.json");
// 显式放行后应能导出
const r2 = runScript("export.mjs", { home, env: { ...env, ZCODE_SYNC_WORK: path.join(root, "w2"), ZCODE_SYNC_ALLOW_PATH_LEAK: "1" } });
assert.equal(r2.code, 0, `放行后应成功\n${r2.err}`);
fs.rmSync(root, { recursive: true, force: true });
});
/* ---------- 失败路径 ---------- */
test("E2E:口令错 → 导出直接失败,且不产生半截快照", { timeout: 120000 }, () => {
const root = sandboxRoot();
const home = makeMachine(root, "machineC");
seedMachine(home, { user: "carol" });
const bare = path.join(root, "bare.git");
git(["init", "--bare", "-b", "main", bare]);
// 口令缺失
const noPass = runScript("export.mjs", { home, env: { ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: path.join(root, "w1"), ZCODE_SYNC_PASSPHRASE: "" } });
assert.notEqual(noPass.code, 0, "无口令必须失败");
assert.match(noPass.err, /未设置|ZCODE_SYNC_PASSPHRASE/);
// 口令过短
const short = runScript("export.mjs", { home, env: { ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: path.join(root, "w2"), ZCODE_SYNC_PASSPHRASE: "abc" } });
assert.notEqual(short.code, 0, "口令过短必须失败");
assert.match(short.err, /太短/);
// 导入方口令错 → 解密失败
const w3 = path.join(root, "w3");
const env3 = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: w3 };
const ok = runScript("export.mjs", { home, env: env3 });
assert.equal(ok.code, 0, `导出应成功\n${ok.out}\n${ok.err}`);
const bad = runScript("import.mjs", { home, env: { ...env3, ZCODE_SYNC_PASSPHRASE: "totally-wrong-pass" } });
assert.notEqual(bad.code, 0, "错误口令导入必须失败");
assert.match(bad.err, /解密失败/);
fs.rmSync(root, { recursive: true, force: true });
});
test("E2E:明文区出现真密钥 → 导出中断(不落盘)", { timeout: 120000 }, () => {
const root = sandboxRoot();
const home = makeMachine(root, "machineD");
const bare = path.join(root, "bare.git");
git(["init", "--bare", "-b", "main", bare]);
// 故意把一个真密钥写进 MCP env(而不是 *_FILE 引用)
seedMachine(home, { user: "dave", mcpExtra: {
leaky: { type: "stdio", command: "node", env: { GITEA_TOKEN: "ghp_realtokenvalue12345" } },
} });
const w = path.join(root, "w");
const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: w } });
assert.notEqual(r.code, 0, "含明文密钥必须中断导出");
assert.match(r.err, /疑似含密钥/);
assert.ok(!fs.existsSync(path.join(w, "repo", "latest.json")), "中断时不得推进 latest.json");
// 白名单放行
const r2 = runScript("export.mjs", { home, env: {
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: path.join(root, "w2"),
ZCODE_SYNC_ALLOW_SECRETS: "$.mcpServers.leaky.env.GITEA_TOKEN",
} });
assert.equal(r2.code, 0, `白名单应放行\n${r2.err}`);
assert.match(r2.out, /# 导出成功/);
fs.rmSync(root, { recursive: true, force: true });
});
test("E2E:自研插件源码里写死同步口令 → 导出中断(钥匙不许跟保险箱同仓)", { timeout: 120000 }, () => {
const root = sandboxRoot();
const home = makeMachine(root, "machineK");
seedMachine(home, { user: "kim" });
// 模拟 live-roundtrip.mjs 曾经的样子:测试文件回退到写死口令,
// 而它作为自研插件源码会被整体归档进快照 —— 加密就形同虚设
fs.writeFileSync(path.join(home, ".zcode", "plugins", "zcode-tps", "scripts", "pass.mjs"),
`const PASS = process.env.ZCODE_SYNC_PASSPHRASE || "${PASS}";\nexport default PASS;\n`);
const w = path.join(root, "w");
const r = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w } });
assert.notEqual(r.code, 0, "源码含口令字面量必须中断导出");
assert.match(r.err, /同步口令本体/);
assert.ok(!fs.existsSync(path.join(w, "repo", "latest.json")), "中断时不得推进 latest.json");
// 改成从环境变量读取后,同一个插件应当能正常导出
fs.writeFileSync(path.join(home, ".zcode", "plugins", "zcode-tps", "scripts", "pass.mjs"),
"const PASS = process.env.ZCODE_SYNC_PASSPHRASE;\nexport default PASS;\n");
const r2 = runScript("export.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: path.join(root, "w2") } });
assert.equal(r2.code, 0, `去掉字面量后应成功\n${r2.err}`);
fs.rmSync(root, { recursive: true, force: true });
});
test("E2E:无推送模式只落本地,不碰远端", { timeout: 120000 }, () => {
const root = sandboxRoot();
const home = makeMachine(root, "machineE");
seedMachine(home, { user: "erin" });
const w = path.join(root, "w");
const r = runScript("export.mjs", { home, env: {
ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_NO_PUSH: "1", ZCODE_SYNC_WORK: w,
ZCODE_SYNC_GIT_REMOTE: path.join(root, "nonexistent.git"),
} });
assert.equal(r.code, 0, `无推送应成功\n${r.err}`);
assert.match(r.out, /跳过推送/);
assert.ok(fs.existsSync(path.join(w, "repo", "latest.json")), "本地快照应落盘");
// status 在无远端快照时给友好提示而非报错
const st = runScript("status.mjs", { home, env: { ZCODE_SYNC_WORK: path.join(root, "w2"), ZCODE_SYNC_GIT_REMOTE: path.join(root, "none2.git") } });
assert.equal(st.code, 0, `status 应优雅退出\n${st.err}`);
assert.match(st.out, /远端尚无快照/);
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 });
});