/** * 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 }); });