import assert from "node:assert/strict"; import { createHash, createPublicKey, generateKeyPairSync, sign } from "node:crypto"; import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { basename, delimiter, dirname, join } from "node:path"; import test from "node:test"; import { cleanupLegacyInstallations, clientPaths, releaseKeyId, runClientInstaller, verifySignedManifest, type ClientReleaseManifest, } from "../src/client-installer.js"; test("client paths stay in stable per-user locations on all supported platforms", () => { assert.equal(clientPaths({}, "linux", "/users/alice").root, "/users/alice/.local/share/dingtalk-approval"); assert.equal(clientPaths({ XDG_DATA_HOME: "/data/alice" }, "linux", "/users/alice").root, "/data/alice/dingtalk-approval"); assert.equal(clientPaths({}, "darwin", "/Users/alice").root, "/Users/alice/Library/Application Support/dingtalk-approval"); assert.equal(clientPaths({ LOCALAPPDATA: "C:\\Users\\alice\\AppData\\Local" }, "win32", "C:\\Users\\alice").root, "C:\\Users\\alice\\AppData\\Local\\DingTalkApproval"); assert.equal(clientPaths({}, "linux", "/users/alice").bridgeCommand, "/users/alice/.local/share/dingtalk-approval/bin/dingtalk-approval-bridge"); }); test("signed manifest verification rejects tampering and a foreign key", () => { const trusted = generateKeyPairSync("ed25519"); const foreign = generateKeyPairSync("ed25519"); const publicPem = trusted.publicKey.export({ type: "spki", format: "pem" }); const keyId = releaseKeyId(publicPem); const manifest = manifestFixture(keyId, publicPem); const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); const signature = signatureEnvelope(keyId, sign(null, bytes, trusted.privateKey)); assert.equal(verifySignedManifest(bytes, signature, publicPem, "https://approval.example.test/downloads/manifest.json").version, "1.1.0-rc.5"); assert.throws(() => verifySignedManifest(Buffer.concat([bytes, Buffer.from(" ")]), signature, publicPem, "https://approval.example.test/downloads/manifest.json"), /签名无效/); assert.throws(() => verifySignedManifest(bytes, signature, foreign.publicKey.export({ type: "spki", format: "pem" }), "https://approval.example.test/downloads/manifest.json"), /密钥编号不受信任/); const stale = { ...manifest, createdAt: new Date(Date.now() - 181 * 24 * 60 * 60_000).toISOString() }; const staleBytes = Buffer.from(`${JSON.stringify(stale, null, 2)}\n`); const staleSignature = signatureEnvelope(keyId, sign(null, staleBytes, trusted.privateKey)); assert.throws(() => verifySignedManifest(staleBytes, staleSignature, publicPem, "https://approval.example.test/downloads/manifest.json"), /超过 180 天/); }); test("legacy cleanup removes only owned broken bins and exact old package", () => { const root = mkdtempSync(join(tmpdir(), "ding-client-legacy-")); const home = join(root, "home"); const bin = join(home, ".nvm", "versions", "node", "v22.22.2", "bin"); const packageRoot = join(home, ".nvm", "versions", "node", "v22.22.2", "lib", "node_modules", "dingtalk-approval-mcp"); mkdirSync(bin, { recursive: true }); mkdirSync(packageRoot, { recursive: true }); writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "dingtalk-approval-mcp" })); for (const name of ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"]) { symlinkSync(`../lib/node_modules/dingtalk-approval-mcp/dist/src/${name}.js`, join(bin, name)); } symlinkSync("../lib/node_modules/some-other-package/cli.js", join(bin, "unrelated")); const removed = cleanupLegacyInstallations({ PATH: bin }, "linux", home); assert.equal(removed.filter((path) => path.includes("dingtalk-")).length, 5); for (const name of ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"]) assert.equal(existsSync(join(bin, name)), false); assert.equal(existsSync(packageRoot), false); assert.equal(existsSync(join(bin, "unrelated")), false, "existsSync is false for dangling links"); assert.equal(lstatSync(join(bin, "unrelated")).isSymbolicLink(), true, "unrelated dangling link remains"); }); test("legacy cleanup removes owned Windows npm shims without touching unrelated commands", () => { const root = mkdtempSync(join(tmpdir(), "ding-client-windows-legacy-")); const bin = join(root, "npm"); const packageRoot = join(bin, "node_modules", "dingtalk-approval-mcp"); mkdirSync(packageRoot, { recursive: true }); writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "dingtalk-approval-mcp" })); writeFileSync(join(bin, "dingtalk-approval-bridge.cmd"), "@node C:\\npm\\node_modules\\dingtalk-approval-mcp\\dist\\src\\bridge.js %*\r\n"); writeFileSync(join(bin, "dingtalk-mcp.ps1"), "& node C:\\npm\\node_modules\\dingtalk-approval-mcp\\dist\\src\\index.js $args\r\n"); writeFileSync(join(bin, "dingtalk-admin.cmd"), "@node C:\\npm\\node_modules\\another-package\\admin.js %*\r\n"); cleanupLegacyInstallations({ PATH: bin, APPDATA: bin }, "win32", join(root, "home")); assert.equal(existsSync(join(bin, "dingtalk-approval-bridge.cmd")), false); assert.equal(existsSync(join(bin, "dingtalk-mcp.ps1")), false); assert.equal(existsSync(packageRoot), false); assert.equal(existsSync(join(bin, "dingtalk-admin.cmd")), true); }); test("generic installer preserves identity across safe updates, rollback, downgrade, and uninstall", async () => { const root = mkdtempSync(join(tmpdir(), "ding-client-install-")); const appRoot = join(root, "dingtalk-approval"); const fakeBin = join(root, "fake-bin"); mkdirSync(fakeBin, { recursive: true }); createFakeNpm(fakeBin); const keys = generateKeyPairSync("ed25519"); const publicPem = Buffer.from(keys.publicKey.export({ type: "spki", format: "pem" })); const keyId = releaseKeyId(publicPem); const bodies = { installer: Buffer.from("installer"), publicKey: publicPem, client: Buffer.from("fake-client-tgz"), skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test\n---\n\n# Test\n"), }; const manifest = manifestFixture(keyId, publicPem, bodies); const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); const signature = signatureEnvelope(keyId, sign(null, manifestBytes, keys.privateKey)); const responses = new Map([ ["https://approval.example.test/downloads/manifest.json", manifestBytes], ["https://approval.example.test/downloads/manifest.sig", signature], ...Object.entries(manifest.artifacts).map(([name, artifact]) => [artifact.url, bodies[name as keyof typeof bodies]] as [string, Buffer]), ]); const fetchFn = async (input: string | URL | Request) => { const url = String(input); const body = responses.get(url); return body ? new Response(new Uint8Array(body), { status: 200 }) : new Response("missing", { status: 404 }); }; const publicPath = join(root, "release-public.pem"); writeFileSync(publicPath, publicPem, { mode: 0o600 }); const env: NodeJS.ProcessEnv = { PATH: [fakeBin, process.env.PATH ?? ""].filter(Boolean).join(delimiter), DINGTALK_APPROVAL_CLIENT_HOME: appRoot }; const output: string[] = []; const commandContext = { env, platform: process.platform, home: join(root, "home"), fetchFn: fetchFn as typeof fetch, stdout: (value: string) => { output.push(value); }, stderr: (value: string) => { output.push(value); }, }; const installed = await runClientInstaller([ "install", "--manifest-url", "https://approval.example.test/downloads/manifest.json", "--public-key", publicPath, "--app-root", appRoot, "--json", ], commandContext); assert.equal(installed, 0); const installOutput = JSON.parse(output.pop()!); assert.equal(installOutput.restartRequired, undefined); assert.match(installOutput.nextSteps.join(" "), /当前 Agent.*skillPath.*stdio MCP/); assert.match(installOutput.nextSteps.join(" "), /立即处理 postInstall.*不得等待用户首次查询审批/s); assert.equal(installOutput.postInstall.status, "authorization_required"); assert.equal(installOutput.postInstall.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=INSTALL-1234"); assert.deepEqual(installOutput.postInstall.confirmTool, { name: "approval_login", arguments: {} }); assert.deepEqual(installOutput.postInstall.verifyTool, { name: "approval_list_todos", arguments: { source: "all", query: "", limit: 1 }, }); assert.equal(installOutput.mcpConfig.mcpServers["dingtalk-approval"].args.length, 0); const paths = clientPaths({ DINGTALK_APPROVAL_CLIENT_HOME: appRoot }, process.platform, join(root, "home")); assert.equal(existsSync(paths.bridgeCommand), true); assert.equal(existsSync(paths.skillFile), true); const state = JSON.parse(readFileSync(paths.stateFile, "utf8")); assert.equal(state.version, "1.1.0-rc.5"); assert.equal(state.host, undefined); assert.doesNotMatch(readFileSync(paths.bridgeCommand, "utf8"), /--url/); const stableBridgePath = paths.bridgeCommand; const initialRuntime = state.runtimeDir; mkdirSync(dirname(paths.sessionFile), { recursive: true }); const sessionBody = Buffer.from(`${JSON.stringify({ authenticated: true, marker: "preserve-across-updates", onboarding: { schemaVersion: 1, status: "delivered", deliveredAt: "2030-01-01T00:00:00.000Z" }, })}\n`); writeFileSync(paths.sessionFile, sessionBody, { mode: 0o600 }); output.length = 0; assert.equal(await runClientInstaller(["print-path", "bridge", "--app-root", appRoot], commandContext), 0); assert.equal(output.pop(), `${paths.bridgeCommand}\n`); output.length = 0; assert.equal(await runClientInstaller(["print-config", "--format", "mcp-json", "--app-root", appRoot, "--json"], commandContext), 0); const mcpConfig = JSON.parse(output.pop()!); assert.deepEqual(mcpConfig, { mcpServers: { "dingtalk-approval": { type: "stdio", command: paths.bridgeCommand, args: [] } } }); output.length = 0; assert.equal(await runClientInstaller(["doctor", "--app-root", appRoot, "--json"], commandContext), 0); const health = JSON.parse(output.pop()!); assert.equal(health.ok, true); assert.equal(health.checks.find((check: { name: string }) => check.name === "npm").ok, true); output.length = 0; assert.equal(await runClientInstaller([ "update", "--app-root", appRoot, "--json", ], { ...commandContext, env: { ...env, FAKE_BRIDGE_FAIL: "1" } }), 0, "login preparation failure does not roll back an installed client"); const sameVersionOutput = JSON.parse(output.pop()!); assert.equal(sameVersionOutput.postInstall.status, "unavailable"); const sameVersionState = JSON.parse(readFileSync(paths.stateFile, "utf8")); assert.equal(sameVersionState.host, undefined); assert.equal(sameVersionState.runtimeDir, initialRuntime, "same-version update reuses the complete runtime"); assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "same-version update preserves the OAuth session"); assert.deepEqual(readdirSync(paths.versionsDir), [basename(initialRuntime)]); output.length = 0; assert.equal(await runClientInstaller(["update", "--host", "workbuddy", "--app-root", appRoot, "--json"], commandContext), 1); assert.match(JSON.parse(output.pop()!).error, /未知参数:--host/); const upgradedBodies = { ...bodies, client: Buffer.from("fake-client-tgz-v2"), skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test v2\n---\n\n# Test v2\n"), }; const upgraded = manifestFixture(keyId, publicPem, upgradedBodies, "1.1.1"); const upgradedBytes = Buffer.from(`${JSON.stringify(upgraded, null, 2)}\n`); responses.set("https://approval.example.test/downloads/manifest.json", upgradedBytes); responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, upgradedBytes, keys.privateKey))); for (const [name, artifact] of Object.entries(upgraded.artifacts)) { responses.set(artifact.url, upgradedBodies[name as keyof typeof upgradedBodies]); } output.length = 0; assert.equal(await runClientInstaller([ "update", "--app-root", appRoot, "--json", ], commandContext), 0, "update reuses the pinned manifest URL and public key"); const upgradedOutput = JSON.parse(output.pop()!); const upgradedState = JSON.parse(readFileSync(paths.stateFile, "utf8")); assert.equal(upgradedOutput.version, "1.1.1"); assert.equal(upgradedOutput.restartRequired, undefined); assert.equal(upgradedState.host, undefined); assert.notEqual(upgradedState.runtimeDir, initialRuntime); assert.equal(existsSync(join(appRoot, initialRuntime)), false, "successful upgrade removes the obsolete runtime"); assert.deepEqual(readdirSync(paths.versionsDir), [basename(upgradedState.runtimeDir)]); assert.equal(readFileSync(paths.skillFile, "utf8"), upgradedBodies.skill.toString("utf8")); assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "cross-version update preserves the OAuth session"); assert.equal(paths.bridgeCommand, stableBridgePath, "the MCP command path remains stable across versions"); const beforeFailedState = readFileSync(paths.stateFile); const beforeFailedSkill = readFileSync(paths.skillFile); const beforeFailedVersions = readdirSync(paths.versionsDir); const failedBodies = { ...upgradedBodies, client: Buffer.from("fake-client-tgz-v3") }; const failedUpgrade = manifestFixture(keyId, publicPem, failedBodies, "1.1.2"); const failedUpgradeBytes = Buffer.from(`${JSON.stringify(failedUpgrade, null, 2)}\n`); responses.set("https://approval.example.test/downloads/manifest.json", failedUpgradeBytes); responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, failedUpgradeBytes, keys.privateKey))); responses.set(failedUpgrade.artifacts.client.url, Buffer.from("corrupted-client-tgz")); output.length = 0; assert.equal(await runClientInstaller(["update", "--app-root", appRoot, "--json"], commandContext), 1); assert.match(JSON.parse(output.pop()!).error, /SHA-256 不匹配/); assert.deepEqual(readFileSync(paths.stateFile), beforeFailedState, "failed update keeps the active install state"); assert.deepEqual(readFileSync(paths.skillFile), beforeFailedSkill, "failed update keeps the active Skill"); assert.deepEqual(readdirSync(paths.versionsDir), beforeFailedVersions, "failed update keeps the active runtime"); assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "failed update keeps the OAuth session"); const nextRelease = manifestFixture(keyId, publicPem, failedBodies, "1.1.2"); const nextBytes = Buffer.from(`${JSON.stringify(nextRelease, null, 2)}\n`); responses.set("https://approval.example.test/downloads/manifest.json", nextBytes); responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, nextBytes, keys.privateKey))); for (const [name, artifact] of Object.entries(nextRelease.artifacts)) { responses.set(artifact.url, failedBodies[name as keyof typeof failedBodies]); } output.length = 0; assert.equal(await runClientInstaller(["update", "--app-root", appRoot, "--json"], commandContext), 0); assert.equal(JSON.parse(output.pop()!).version, "1.1.2"); assert.equal(JSON.parse(readFileSync(paths.stateFile, "utf8")).host, undefined); assert.deepEqual(readFileSync(paths.sessionFile), sessionBody); const downgraded = manifestFixture(keyId, publicPem, bodies, "1.0.0"); const downgradedBytes = Buffer.from(`${JSON.stringify(downgraded, null, 2)}\n`); responses.set("https://approval.example.test/downloads/manifest.json", downgradedBytes); responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, downgradedBytes, keys.privateKey))); output.length = 0; assert.equal(await runClientInstaller([ "update", "--app-root", appRoot, "--json", ], commandContext), 1); assert.match(JSON.parse(output.pop()!).error, /拒绝.*降级/); assert.equal(JSON.parse(readFileSync(paths.stateFile, "utf8")).version, "1.1.2"); assert.deepEqual(readFileSync(paths.sessionFile), sessionBody); for (const [name, artifact] of Object.entries(downgraded.artifacts)) { responses.set(artifact.url, bodies[name as keyof typeof bodies]); } output.length = 0; assert.equal(await runClientInstaller([ "update", "--allow-downgrade", "--app-root", appRoot, "--json", ], commandContext), 0, "an explicitly authorized downgrade remains possible"); const downgradedState = JSON.parse(readFileSync(paths.stateFile, "utf8")); assert.equal(downgradedState.version, "1.0.0"); assert.equal(downgradedState.host, undefined); assert.deepEqual(readFileSync(paths.sessionFile), sessionBody); assert.equal(readdirSync(paths.versionsDir).length, 1); output.length = 0; assert.equal(await runClientInstaller(["print-config", "--bogus", "--app-root", appRoot, "--json"], commandContext), 1); assert.match(JSON.parse(output.pop()!).error, /未知参数/); output.length = 0; assert.equal(await runClientInstaller(["uninstall", "--yes", "--app-root", root, "--json"], commandContext), 1); assert.match(JSON.parse(output.pop()!).error, /专用.*用户数据目录/); output.length = 0; const removed = await runClientInstaller(["uninstall", "--yes", "--app-root", appRoot, "--json"], { ...commandContext, }); assert.equal(removed, 0); assert.equal(existsSync(appRoot), false); }); function manifestFixture( keyId: string, publicKey: string | Buffer, bodies = { installer: Buffer.from("installer"), publicKey: Buffer.from(publicKey), client: Buffer.from("client"), skill: Buffer.from("skill"), }, version = "1.1.0-rc.5", ): ClientReleaseManifest { const base = "https://approval.example.test/downloads/"; const artifact = (name: keyof typeof bodies) => ({ url: `${base}${name}`, sha256: digest(bodies[name]) }); return { schemaVersion: 3, version, createdAt: new Date().toISOString(), gatewayUrl: "https://approval.example.test/mcp", signing: { algorithm: "Ed25519", keyId }, requirements: { node: ">=22.13.0" }, artifacts: { installer: artifact("installer"), publicKey: { ...artifact("publicKey"), keyId }, client: artifact("client"), skill: artifact("skill"), }, }; } function signatureEnvelope(keyId: string, value: Buffer): Buffer { return Buffer.from(JSON.stringify({ schemaVersion: 1, algorithm: "Ed25519", keyId, signature: value.toString("base64") })); } function digest(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); } function createFakeNpm(bin: string): void { const fakeBridge = `#!/usr/bin/env node if (process.env.FAKE_BRIDGE_FAIL === "1") process.exit(2); if (process.argv.includes("--post-install-login")) { console.log(JSON.stringify({ authenticated: false, message: "需要完成钉钉授权登录。", authChallenge: { provider: "dingtalk", type: "link", directUrl: "https://approval.example.test/oauth/device?user_code=INSTALL-1234", userCode: "INSTALL-1234", expiresAt: "2030-01-01T00:00:00.000Z", pollAfterSeconds: 5 } })); } `; const implementation = process.platform === "win32" ? join(bin, "node_modules", "npm", "bin", "npm-cli.js") : join(bin, "fake-npm.mjs"); mkdirSync(join(implementation, ".."), { recursive: true }); writeFileSync(implementation, `#!/usr/bin/env node import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; if (process.argv[2] === "--version") { console.log("11.0.0"); process.exit(0); } const index = process.argv.indexOf("--prefix"); if (index < 0 || !process.argv[index + 1]) { console.error("missing prefix"); process.exit(2); } const prefix = process.argv[index + 1]; const target = join(prefix, "node_modules", "dingtalk-approval-client", "dist", "src"); mkdirSync(target, { recursive: true }); writeFileSync(join(target, "bridge.js"), ${JSON.stringify(fakeBridge)}); writeFileSync(join(target, "client-installer.js"), "#!/usr/bin/env node\\n"); `); if (process.platform === "win32") { writeFileSync(join(bin, "npm.cmd"), "@echo off\r\nexit /b 99\r\n"); } else { const executable = join(bin, "npm"); writeFileSync(executable, `#!/usr/bin/env sh\nexec "${process.execPath}" "$(dirname "$0")/fake-npm.mjs" "$@"\n`, { mode: 0o700 }); chmodSync(executable, 0o700); } }