import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createHash, createPublicKey, generateKeyPairSync, sign } from "node:crypto"; import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { basename, delimiter, dirname, join } from "node:path"; import test from "node:test"; import { acquireClientInstallLock, cleanupLegacyInstallations, clientLauncherBodies, clientPaths, releaseKeyId, downloadWithCurl, nodeProxyArgs, npmChildEnvironment, npmFailureMessage, runClientInstaller, runNpm, 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("client launchers pin the selected Node executable on Windows and POSIX", () => { const windows = clientLauncherBodies("win32", "C:\\Users\\100% ready\\node.exe"); assert.match(windows.cli, /^@echo off\r\n"C:\\Users\\100%% ready\\node\.exe"/); assert.match(windows.bridge, /launcher\.mjs" bridge %\*/); assert.doesNotMatch(windows.bridge, /\r\nnode /); const posix = clientLauncherBodies("linux", "/Users/alice's runtime/node"); assert.match(posix.cli, /exec '\/Users\/alice'"'"'s runtime\/node'/); assert.match(posix.bridge, /launcher\.mjs" bridge "\$@"/); assert.throws(() => clientLauncherBodies("win32", "C:\\bad\nnode.exe"), /不支持的字符/); }); test("client installer lock rejects concurrency and recovers a dead owner", () => { const root = mkdtempSync(join(tmpdir(), "ding-client-lock-")); const appRoot = join(root, "dingtalk-approval"); mkdirSync(appRoot, { recursive: true }); const release = acquireClientInstallLock(appRoot); assert.throws(() => acquireClientInstallLock(appRoot), /另一个安装或更新进程仍在运行/); release(); const lockDir = join(appRoot, ".install-lock"); mkdirSync(lockDir, { recursive: true }); writeFileSync(join(lockDir, "owner.json"), JSON.stringify({ pid: 2_147_483_647, token: "dead" })); const recovered = acquireClientInstallLock(appRoot); assert.equal(existsSync(join(lockDir, "owner.json")), true); recovered(); assert.equal(existsSync(lockDir), false); mkdirSync(lockDir, { recursive: true }); writeFileSync(join(lockDir, "owner.json"), JSON.stringify({ pid: process.pid, token: "expired" })); const expiredAt = new Date(Date.now() - 16 * 60_000); utimesSync(lockDir, expiredAt, expiredAt); const expired = acquireClientInstallLock(appRoot); expired(); assert.equal(existsSync(lockDir), false, "an implausibly old lock cannot block installation forever after PID reuse"); rmSync(root, { recursive: true, force: true }); }); test("direct JSON entrypoint never silently handles a controlled failure", () => { const result = spawnSync(process.execPath, ["dist/src/client-installer.js", "unsupported", "--json"], { encoding: "utf8" }); assert.equal(result.status, 1); assert.match(result.stderr, /^\[dingtalk-approval\] 失败 .*unsupported/); const error = JSON.parse(result.stdout); assert.equal(error.ok, false); assert.match(error.error, /unsupported/); }); test("npm subprocess timeout is bounded and returns an actionable diagnostic", () => { const root = mkdtempSync(join(tmpdir(), "ding-client-npm-timeout-")); try { const sleepyNpm = join(root, "sleepy-npm.mjs"); writeFileSync(sleepyNpm, "setTimeout(() => {}, 10_000);\n"); const result = runNpm( { env: { npm_execpath: sleepyNpm }, platform: process.platform }, ["--version"], { timeoutMs: 50 }, ); assert.equal((result.error as NodeJS.ErrnoException | undefined)?.code, "ETIMEDOUT"); assert.match(npmFailureMessage(result, 50), /npm 超过 1 秒仍未完成.*宿主工具超时设置/); } finally { rmSync(root, { recursive: true, force: true }); } }); test("npm subprocess removes Node fetch proxy flags without dropping npm proxy settings", () => { const input = { NODE_OPTIONS: "--trace-warnings --use-env-proxy --max-old-space-size=512", NODE_USE_ENV_PROXY: "1", HTTPS_PROXY: "http://proxy.example.test:8080", NO_PROXY: "localhost,127.0.0.1", }; const output = npmChildEnvironment(input); assert.equal(output.NODE_OPTIONS, "--trace-warnings --max-old-space-size=512"); assert.equal(output.NODE_USE_ENV_PROXY, undefined); assert.equal(output.HTTPS_PROXY, input.HTTPS_PROXY); assert.equal(output.NO_PROXY, input.NO_PROXY); assert.equal(input.NODE_OPTIONS.includes("--use-env-proxy"), true, "the caller environment is not mutated"); assert.deepEqual(nodeProxyArgs({ HTTPS_PROXY: input.HTTPS_PROXY }, new Set(["--use-env-proxy"])), ["--use-env-proxy"]); assert.deepEqual(nodeProxyArgs({ HTTPS_PROXY: input.HTTPS_PROXY, NODE_OPTIONS: "--use-env-proxy" }, new Set(["--use-env-proxy"])), []); assert.deepEqual(nodeProxyArgs({ HTTPS_PROXY: input.HTTPS_PROXY }, new Set()), []); assert.deepEqual(nodeProxyArgs({}, new Set(["--use-env-proxy"])), []); }); test("runNpm passes the isolated proxy environment to the real child process", () => { const root = mkdtempSync(join(tmpdir(), "ding-client-npm-env-")); try { const inspectNpm = join(root, "inspect-npm.mjs"); writeFileSync(inspectNpm, "console.log(JSON.stringify({nodeOptions:process.env.NODE_OPTIONS,nodeProxy:process.env.NODE_USE_ENV_PROXY,httpsProxy:process.env.HTTPS_PROXY,noProxy:process.env.NO_PROXY}));\n"); const result = runNpm({ env: { npm_execpath: inspectNpm, NODE_OPTIONS: "--trace-warnings --use-env-proxy", NODE_USE_ENV_PROXY: "true", HTTPS_PROXY: "http://proxy.example.test:8080", NO_PROXY: "localhost", }, platform: process.platform, }, ["inspect"], { timeoutMs: 2_000 }); assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(result.stdout), { nodeOptions: "--trace-warnings", httpsProxy: "http://proxy.example.test:8080", noProxy: "localhost", }); } finally { rmSync(root, { recursive: true, force: true }); } }); test("native curl downloader honors the proxy environment and never fails silently", async () => { const root = mkdtempSync(join(tmpdir(), "ding-client-curl-")); try { const fakeBin = join(root, "bin"); const curl = join(fakeBin, "curl.exe"); const log = join(root, "curl.log"); mkdirSync(fakeBin, { recursive: true }); writeFileSync(curl, `#!/bin/sh target='' url='' while [ "$#" -gt 0 ]; do case "$1" in --output) target="$2"; shift 2 ;; https://*) url="$1"; shift ;; *) shift ;; esac done printf '%s|%s\n' "$url" "${'${HTTPS_PROXY:-}'}" >> "$DING_CURL_LOG" case "$url" in */ok) printf 'native-payload' > "$target"; printf '200'; exit 0 ;; */missing) printf '404'; exit 22 ;; *) exit 1 ;; esac `); chmodSync(curl, 0o700); const env = { PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, HTTPS_PROXY: "http://192.168.199.62:7890", DING_CURL_LOG: log, }; assert.equal((await downloadWithCurl(new URL("https://release.example.test/ok"), env, "win32")).toString(), "native-payload"); assert.match(readFileSync(log, "utf8"), /https:\/\/release\.example\.test\/ok\|http:\/\/192\.168\.199\.62:7890/); await assert.rejects(downloadWithCurl(new URL("https://release.example.test/missing"), env, "win32"), /curl 下载失败.*HTTP 404/); await assert.rejects(downloadWithCurl(new URL("https://release.example.test/silent"), env, "win32"), /curl 下载失败.*curl 退出码 1/); } finally { rmSync(root, { recursive: true, force: true }); } }); test("installer uses native curl end to end in a proxied Windows-style environment", async () => { const root = mkdtempSync(join(tmpdir(), "ding-client-curl-install-")); try { const appRoot = join(root, "dingtalk-approval"); const fakeBin = join(root, "bin"); const fixtureDir = join(root, "downloads"); const curlLog = join(root, "curl.log"); mkdirSync(fakeBin, { recursive: true }); mkdirSync(fixtureDir, { 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"), }; 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)); writeFileSync(join(fixtureDir, "manifest.json"), manifestBytes); writeFileSync(join(fixtureDir, "manifest.sig"), signature); for (const [name, body] of Object.entries(bodies)) writeFileSync(join(fixtureDir, name), body); const curlBody = `#!/bin/sh target='' url='' while [ "$#" -gt 0 ]; do case "$1" in --output) target="$2"; shift 2 ;; https://*) url="$1"; shift ;; *) shift ;; esac done printf '%s|%s\n' "$url" "${'${HTTPS_PROXY:-}'}" >> "$DING_CURL_LOG" name="${'${url##*/}'}" if [ -f "$DING_CURL_FIXTURE_DIR/$name" ]; then cp "$DING_CURL_FIXTURE_DIR/$name" "$target" printf '200' exit 0 fi printf '404' exit 22 `; for (const name of ["curl", "curl.exe"]) { writeFileSync(join(fakeBin, name), curlBody); chmodSync(join(fakeBin, name), 0o700); } const publicPath = join(root, "release-public.pem"); writeFileSync(publicPath, publicPem, { mode: 0o600 }); const env = { PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, DINGTALK_APPROVAL_CLIENT_HOME: appRoot, npm_execpath: process.platform === "win32" ? join(fakeBin, "node_modules", "npm", "bin", "npm-cli.js") : join(fakeBin, "fake-npm.mjs"), HTTPS_PROXY: "http://192.168.199.62:7890", NODE_OPTIONS: "--trace-warnings --use-env-proxy", DING_CURL_FIXTURE_DIR: fixtureDir, DING_CURL_LOG: curlLog, }; const output: string[] = []; const progress: string[] = []; const code = await runClientInstaller([ "install", "--manifest-url", "https://release.example.test/manifest.json", "--public-key", publicPath, "--app-root", appRoot, "--json", ], { env, platform: process.platform, home: join(root, "home"), fetchFn: fetch, stdout: (value) => { output.push(value); }, stderr: (value) => { progress.push(value); }, }); assert.equal(code, 0, progress.join("")); assert.equal(JSON.parse(output.at(-1)!).status, "installed"); const calls = readFileSync(curlLog, "utf8"); assert.match(calls, /manifest\.json\|http:\/\/192\.168\.199\.62:7890/); assert.match(calls, /client\|http:\/\/192\.168\.199\.62:7890/); } finally { rmSync(root, { recursive: true, force: true }); } }); 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 mirrorBase = "https://mirror.example.test/dingmcp/"; manifest.distribution = { manifestMirrors: [`${mirrorBase}manifest.json`] }; for (const artifact of Object.values(manifest.artifacts)) { artifact.mirrors = [`${mirrorBase}${basename(new URL(artifact.url).pathname)}`]; } 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], [`${mirrorBase}manifest.json`, manifestBytes], [`${mirrorBase}manifest.sig`, signature], ...Object.entries(manifest.artifacts).flatMap(([name, artifact]) => [artifact.url, ...(artifact.mirrors ?? [])].map((url) => [url, bodies[name as keyof typeof bodies]] as [string, Buffer])), ]); let downloadCalls = 0; const unavailableUrls = new Set(); const fetchFn = async (input: string | URL | Request) => { downloadCalls += 1; const url = String(input); if (unavailableUrls.has(url)) return new Response("missing", { status: 404 }); 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, npm_execpath: process.platform === "win32" ? join(fakeBin, "node_modules", "npm", "bin", "npm-cli.js") : join(fakeBin, "fake-npm.mjs"), }; const output: string[] = []; const progress: 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) => { progress.push(value); }, }; const installed = await runClientInstaller([ "install", "--manifest-url", "https://approval.example.test/downloads/manifest.json", "--manifest-mirror-url", `${mirrorBase}manifest.json`, "--public-key", publicPath, "--app-root", appRoot, "--json", ], commandContext); assert.equal(installed, 0); const installOutput = JSON.parse(output.pop()!); assert.match(progress.join(""), /启动 正在取得单实例安装锁[\s\S]*1\/6[\s\S]*6\/6[\s\S]*完成 客户端安装成功/); assert.equal(installOutput.restartRequired, undefined); assert.match(installOutput.nextSteps.join(" "), /当前 Agent.*skillPath.*stdio MCP/); assert.match(installOutput.nextSteps.join(" "), /Windows.*\.cmd.*禁止把网关 URL 配成 MCP/s); assert.match(installOutput.nextSteps.join(" "), /立即处理 postInstall.*不得等待用户首次查询审批/s); assert.match(installOutput.nextSteps.join(" "), /双来源只读验证.*钉钉 OA 与薪福通均为 ok/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.loginCommand, { command: join(appRoot, "bin", process.platform === "win32" ? "dingtalk-approval.cmd" : "dingtalk-approval"), args: ["login", "--json"], }); assert.deepEqual(installOutput.postInstall.loginCommand, installOutput.loginCommand); 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); assert.equal(existsSync(join(paths.skillDir, "references", "create-approval.md")), true); assert.equal(existsSync(join(paths.skillDir, "references", "local-drafts.md")), true); const state = JSON.parse(readFileSync(paths.stateFile, "utf8")); assert.equal(state.version, "1.1.0-rc.5"); assert.deepEqual(state.manifestMirrors, [`${mirrorBase}manifest.json`]); assert.equal(state.host, undefined); assert.equal(state.nodeExecutable, realpathSync(process.execPath)); assert.equal(state.managedNode, false); assert.equal(state.nodeVersion, process.versions.node); assert.deepEqual(installOutput.nodeRuntime, { version: process.versions.node, managed: false, executable: realpathSync(process.execPath), }); assert.doesNotMatch(readFileSync(paths.bridgeCommand, "utf8"), /--url/); assert.match(readFileSync(paths.bridgeCommand, "utf8"), new RegExp(realpathSync(process.execPath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); assert.match(readFileSync(paths.launcherFile, "utf8"), /allowedNodeEnvironmentFlags\.has\("--use-env-proxy"\)/); const stableBridgePath = paths.bridgeCommand; const initialRuntime = state.runtimeDir; const callsBeforeStandaloneLogin = downloadCalls; output.length = 0; assert.equal(await runClientInstaller(["login", "--app-root", appRoot, "--json"], commandContext), 0); const loginOutput = JSON.parse(output.pop()!); assert.equal(loginOutput.operation, "login"); assert.equal(loginOutput.status, "authorization_required"); assert.equal(loginOutput.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=INSTALL-1234"); assert.deepEqual(loginOutput.loginCommand, installOutput.loginCommand); assert.equal(downloadCalls, callsBeforeStandaloneLogin, "standalone login must not download or reinstall artifacts"); output.length = 0; assert.equal(await runClientInstaller(["login", "--app-root", appRoot, "--json"], { ...commandContext, env: { ...env, FAKE_BRIDGE_AUTHENTICATED: "1" }, }), 0); const authenticatedLogin = JSON.parse(output.pop()!); assert.equal(authenticatedLogin.status, "authenticated"); assert.equal(authenticatedLogin.authenticated, true); assert.equal(authenticatedLogin.authChallenge, undefined); assert.equal(downloadCalls, callsBeforeStandaloneLogin, "authenticated login check must stay local except for the Bridge flow"); 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); assert.equal(health.checks.find((check: { name: string }) => check.name === "nodeRuntime").ok, true); output.length = 0; const interruptedStaging = join(paths.versionsDir, ".staging-interrupted-test"); mkdirSync(interruptedStaging, { recursive: true }); unavailableUrls.add("https://approval.example.test/downloads/manifest.json"); unavailableUrls.add("https://approval.example.test/downloads/manifest.sig"); for (const artifact of Object.values(manifest.artifacts)) unavailableUrls.add(artifact.url); 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)]); assert.equal(existsSync(interruptedStaging), false, "a dead installer staging directory is recovered on the next run"); assert.match(progress.join(""), /下载源不可用.*mirror\.example\.test/); unavailableUrls.clear(); 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"); failedUpgrade.artifacts.client.mirrors = [`${mirrorBase}client-v3`]; 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")); responses.set(failedUpgrade.artifacts.client.mirrors[0]!, failedBodies.client); 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); output.length = 0; assert.equal(await runClientInstaller(["login", "--app-root", appRoot, "--json"], commandContext), 1); assert.match(JSON.parse(output.pop()!).error, /尚未安装/); }); 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("--login") || process.argv.includes("--post-install-login")) { if (process.env.FAKE_BRIDGE_AUTHENTICATED === "1") { console.log(JSON.stringify({ authenticated: true, message: "已登录,可以读取审批。" })); process.exit(0); } 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); } if (!process.env.npm_config_cache || !process.env.npm_config_cache.includes("dingtalk-approval-install-")) { console.error("installer did not isolate the npm cache"); process.exit(3); } 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"); const skill = join(prefix, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval"); mkdirSync(join(skill, "references"), { recursive: true }); writeFileSync(join(skill, "SKILL.md"), "---\\nname: dingtalk-approval\\ndescription: Bundled test\\n---\\n"); writeFileSync(join(skill, "references", "create-approval.md"), "# Create approval\\n"); writeFileSync(join(skill, "references", "local-drafts.md"), "# Local drafts\\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); } }