feat: bootstrap portable Node runtimes
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s

This commit is contained in:
2026-08-18 13:53:54 +00:00
parent 2e275b684d
commit dd189c2acb
22 changed files with 900 additions and 96 deletions
+32 -3
View File
@@ -1,13 +1,13 @@
import assert from "node:assert/strict";
import { createHash, createPublicKey, generateKeyPairSync, sign } from "node:crypto";
import {
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, writeFileSync,
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, 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,
cleanupLegacyInstallations, clientLauncherBodies, clientPaths, releaseKeyId, runClientInstaller, verifySignedManifest,
type ClientReleaseManifest,
} from "../src/client-installer.js";
@@ -19,6 +19,18 @@ test("client paths stay in stable per-user locations on all supported platforms"
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("signed manifest verification rejects tampering and a foreign key", () => {
const trusted = generateKeyPairSync("ed25519");
const foreign = generateKeyPairSync("ed25519");
@@ -107,7 +119,13 @@ test("generic installer preserves identity across safe updates, rollback, downgr
};
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 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 commandContext = {
env, platform: process.platform, home: join(root, "home"), fetchFn: fetchFn as typeof fetch,
@@ -143,7 +161,14 @@ test("generic installer preserves identity across safe updates, rollback, downgr
const state = JSON.parse(readFileSync(paths.stateFile, "utf8"));
assert.equal(state.version, "1.1.0-rc.5");
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, "\\$&")));
const stableBridgePath = paths.bridgeCommand;
const initialRuntime = state.runtimeDir;
@@ -189,6 +214,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr
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;
assert.equal(await runClientInstaller([
@@ -371,6 +397,9 @@ if (process.argv.includes("--login") || process.argv.includes("--post-install-lo
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];
+27
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
@@ -19,6 +20,10 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.match(installer, /onboarding\.firstRun=true.*简短示例.*不要自动执行/s);
assert.match(installer, /Windows(PowerShell)/);
assert.match(installer, /Invoke-WebRequest[\s\S]*Get-FileHash[\s\S]*ConvertFrom-Json/);
assert.match(installer, /系统无需预装 Node\.js/);
assert.match(installer, /阿里云精确版本.*npmmirror 精确版本.*Node 官方精确版本/s);
assert.match(installer, /BOOTSTRAP_WINDOWS_URL|Windows 安装引导/);
assert.match(installer, /Linux\/macOS 安装引导/);
assert.match(installer, /dingtalk-approval-bridge\.cmd/);
assert.match(installer, /显示 302 或启动后立即退出[\s\S]*type: \"stdio\"[\s\S]*doctor --json/);
assert.match(installer, /sourceStatus[\s\S]*dingtalk\.status[\s\S]*xft\.status[\s\S]*都必须是 `ok`/);
@@ -28,6 +33,25 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.doesNotMatch(installer, /通过当前 Messenger/);
assert.doesNotMatch(installer, /DWS_CLI_PATH|dingtalk-workspace-cli|APP_MODE=local/);
const runtime = JSON.parse(readFileSync("distribution/node-runtime.json", "utf8"));
assert.equal(runtime.version, "22.23.2");
assert.deepEqual(Object.keys(runtime.platforms).sort(), [
"darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-arm64", "win32-x64",
]);
assert.equal(runtime.sourceBases.length, 3);
assert.ok(runtime.sourceBases.every((value: string) => value.includes(`/v${runtime.version}/`) && !value.includes("latest")));
assert.ok(Object.values(runtime.platforms).every((value) => /^[a-f0-9]{64}$/.test((value as { sha256: string }).sha256)));
const windowsBootstrap = readFileSync("distribution/bootstrap-windows.template.ps1", "utf8");
const posixBootstrap = readFileSync("distribution/bootstrap-posix.template.sh", "utf8");
assert.match(windowsBootstrap, /OSArchitecture[\s\S]*Save-DingDownload[\s\S]*Expand-Archive/);
assert.match(windowsBootstrap, /SHA-256 不匹配,停止安装/);
assert.match(posixBootstrap, /^#!\/bin\/sh\nset -eu/);
assert.match(posixBootstrap, /uname -s[\s\S]*uname -m[\s\S]*tar -xzf/);
assert.match(posixBootstrap, /SHA-256 不匹配,停止安装/);
assert.equal(spawnSync("sh", ["-n", "distribution/bootstrap-posix.template.sh"]).status, 0);
assert.doesNotMatch(`${windowsBootstrap}\n${posixBootstrap}`, /latest-v|npm install -g/);
const skill = readFileSync("skills/dingtalk-approval/SKILL.md", "utf8");
const createApproval = readFileSync("skills/dingtalk-approval/references/create-approval.md", "utf8");
assert.match(skill, /read `references\/create-approval\.md` completely/);
@@ -42,6 +66,9 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.match(builder, /schemaVersion: 3,\s+version,/s);
assert.match(builder, /signData\(null, manifestBytes/);
assert.match(builder, /dingtalk-approval-client/);
assert.match(builder, /bootstrap-windows\.template\.ps1/);
assert.match(builder, /bootstrap-posix\.template\.sh/);
assert.match(builder, /validateNodeRuntime/);
assert.match(builder, /cpSync\(resolve\("skills\/dingtalk-approval"\).*recursive: true/s);
assert.doesNotMatch(builder, /codexPlugin|codex-plugin|\.codex-plugin|openai\.yaml/);
assert.doesNotMatch(builder, /dingtalk-workspace-cli|DWS_URL|DWS_SHA256/);
+214
View File
@@ -0,0 +1,214 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
const NODE_VERSION = "22.23.2";
const RUNTIME_NAME = `node-v${NODE_VERSION}-test`;
const RUNTIME_ARCHIVE = `${RUNTIME_NAME}.tar.gz`;
test("POSIX bootstrap installs a private Node and falls back after a network failure", {
skip: process.platform === "win32",
}, () => {
const fixture = createPosixFixture();
try {
const result = runPosixFixture(fixture, "fallback");
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout.trim()), { ok: true, status: "installed", managedNode: true });
assert.equal(existsSync(join(fixture.clientRoot, "node", RUNTIME_NAME, "bin", "node")), true);
const calls = readFileSync(fixture.callLog, "utf8").trim().split(/\r?\n/);
assert.deepEqual(calls.slice(0, 2), [
`https://primary.example.test/node/${RUNTIME_ARCHIVE}`,
`https://mirror.example.test/node/${RUNTIME_ARCHIVE}`,
]);
assert.ok(calls.includes("https://release.example.test/installer.mjs"));
assert.ok(calls.includes("https://release.example.test/release-signing-public.pem"));
} finally {
rmSync(fixture.root, { recursive: true, force: true });
}
});
test("POSIX bootstrap stops immediately when a downloaded runtime has the wrong hash", {
skip: process.platform === "win32",
}, () => {
const fixture = createPosixFixture();
try {
const result = runPosixFixture(fixture, "corrupt");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /SHA-256 不匹配,停止安装/);
const calls = readFileSync(fixture.callLog, "utf8").trim().split(/\r?\n/);
assert.deepEqual(calls, [`https://primary.example.test/node/${RUNTIME_ARCHIVE}`]);
assert.equal(existsSync(join(fixture.clientRoot, "node", RUNTIME_NAME, "bin", "node")), false);
} finally {
rmSync(fixture.root, { recursive: true, force: true });
}
});
test("POSIX bootstrap does not hide a non-404 HTTP error by switching mirrors", {
skip: process.platform === "win32",
}, () => {
const fixture = createPosixFixture();
try {
const result = runPosixFixture(fixture, "server-error");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /不允许换源.*HTTP 500/);
const calls = readFileSync(fixture.callLog, "utf8").trim().split(/\r?\n/);
assert.deepEqual(calls, [`https://primary.example.test/node/${RUNTIME_ARCHIVE}`]);
} finally {
rmSync(fixture.root, { recursive: true, force: true });
}
});
test("Windows bootstrap is valid PowerShell after release rendering", {
skip: process.platform !== "win32",
}, () => {
const root = mkdtempSync(join(tmpdir(), "dingmcp-windows-bootstrap-"));
try {
const script = join(root, "bootstrap.ps1");
writeFileSync(script, renderWindowsBootstrap());
const powershell = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
const parser = [
"$tokens = $null; $errors = $null",
"[System.Management.Automation.Language.Parser]::ParseFile($args[0], [ref]$tokens, [ref]$errors) | Out-Null",
"if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_.Message }; exit 1 }",
].join("; ");
const result = spawnSync(powershell, ["-NoProfile", "-NonInteractive", "-Command", parser, script], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
interface PosixFixture {
root: string;
clientRoot: string;
callLog: string;
script: string;
fakeBin: string;
archive: string;
installer: string;
publicKey: string;
}
function createPosixFixture(): PosixFixture {
const root = mkdtempSync(join(tmpdir(), "dingmcp-posix-bootstrap-"));
const archiveRoot = join(root, "archive", RUNTIME_NAME);
const fakeBin = join(root, "bin");
const clientRoot = join(root, "home", "dingtalk-approval");
const callLog = join(root, "download-calls.log");
const archive = join(root, RUNTIME_ARCHIVE);
const installer = join(root, "installer.mjs");
const publicKey = join(root, "release-signing-public.pem");
const script = join(root, "bootstrap.sh");
mkdirSync(join(archiveRoot, "bin"), { recursive: true });
mkdirSync(join(archiveRoot, "lib", "node_modules", "npm", "bin"), { recursive: true });
mkdirSync(fakeBin, { recursive: true });
mkdirSync(join(root, "home"), { recursive: true });
const privateNode = join(archiveRoot, "bin", "node");
writeFileSync(privateNode, [
"#!/bin/sh",
"if [ \"${1:-}\" = '-e' ]; then exit 0; fi",
"printf '%s\\n' '{\"ok\":true,\"status\":\"installed\",\"managedNode\":true}'",
"",
].join("\n"));
chmodSync(privateNode, 0o700);
writeFileSync(join(archiveRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js"), "// fixture\n");
const tar = spawnSync("tar", ["-czf", archive, "-C", join(root, "archive"), RUNTIME_NAME], { encoding: "utf8" });
assert.equal(tar.status, 0, tar.stderr);
writeFileSync(installer, "// fixture installer\n");
writeFileSync(publicKey, "fixture public key\n");
writeFileSync(callLog, "");
const curl = join(fakeBin, "curl");
writeFileSync(curl, `#!/bin/sh
ding_target=''
ding_url=''
while [ "$#" -gt 0 ]; do
case "$1" in
--output) ding_target="$2"; shift 2 ;;
http*) ding_url="$1"; shift ;;
*) shift ;;
esac
done
printf '%s\\n' "$ding_url" >> "$DING_TEST_CALL_LOG"
case "$ding_url" in
https://primary.example.test/node/*)
if [ "$DING_TEST_MODE" = 'corrupt' ]; then printf 'corrupt' > "$ding_target"; printf '200'; exit 0; fi
if [ "$DING_TEST_MODE" = 'server-error' ]; then printf '500'; exit 22; fi
printf '000'; exit 7 ;;
https://mirror.example.test/node/*) /bin/cp "$DING_TEST_ARCHIVE" "$ding_target"; printf '200' ;;
https://official.example.test/node/*) /bin/cp "$DING_TEST_ARCHIVE" "$ding_target"; printf '200' ;;
https://release.example.test/installer.mjs) /bin/cp "$DING_TEST_INSTALLER" "$ding_target"; printf '200' ;;
https://release.example.test/release-signing-public.pem) /bin/cp "$DING_TEST_PUBLIC_KEY" "$ding_target"; printf '200' ;;
*) printf '404'; exit 22 ;;
esac
`);
chmodSync(curl, 0o700);
const rejectedSystemNode = join(fakeBin, "node");
writeFileSync(rejectedSystemNode, "#!/bin/sh\nexit 1\n");
chmodSync(rejectedSystemNode, 0o700);
let body = readFileSync("distribution/bootstrap-posix.template.sh", "utf8");
const replacements: Record<string, string> = {
"{{NODE_SOURCE_BASES_POSIX}}": "https://primary.example.test/node/ https://mirror.example.test/node/ https://official.example.test/node/",
"{{INSTALLER_URL}}": "https://release.example.test/installer.mjs",
"{{INSTALLER_SHA256}}": digest(installer),
"{{PUBLIC_KEY_URL}}": "https://release.example.test/release-signing-public.pem",
"{{PUBLIC_KEY_SHA256}}": digest(publicKey),
"{{MANIFEST_URL}}": "https://release.example.test/manifest.json",
};
for (const platform of ["LINUX_X64", "LINUX_ARM64", "DARWIN_X64", "DARWIN_ARM64"]) {
replacements[`{{NODE_${platform}_FILE}}`] = RUNTIME_ARCHIVE;
replacements[`{{NODE_${platform}_SHA256}}`] = digest(archive);
}
for (const [placeholder, value] of Object.entries(replacements)) body = body.replaceAll(placeholder, value);
assert.doesNotMatch(body, /\{\{/);
writeFileSync(script, body);
chmodSync(script, 0o700);
return { root, clientRoot, callLog, script, fakeBin, archive, installer, publicKey };
}
function runPosixFixture(fixture: PosixFixture, mode: "fallback" | "corrupt" | "server-error") {
return spawnSync("/bin/sh", [fixture.script], {
encoding: "utf8",
env: {
HOME: join(fixture.root, "home"),
PATH: `${fixture.fakeBin}:/usr/bin:/bin`,
DINGTALK_APPROVAL_CLIENT_HOME: fixture.clientRoot,
DING_TEST_MODE: mode,
DING_TEST_CALL_LOG: fixture.callLog,
DING_TEST_ARCHIVE: fixture.archive,
DING_TEST_INSTALLER: fixture.installer,
DING_TEST_PUBLIC_KEY: fixture.publicKey,
},
});
}
function renderWindowsBootstrap(): string {
let body = readFileSync("distribution/bootstrap-windows.template.ps1", "utf8");
const replacements: Record<string, string> = {
"{{NODE_SOURCE_BASES_POWERSHELL}}": '"https://primary.example.test/node/", "https://official.example.test/node/"',
"{{INSTALLER_URL}}": "https://release.example.test/installer.mjs",
"{{INSTALLER_SHA256}}": "a".repeat(64),
"{{PUBLIC_KEY_URL}}": "https://release.example.test/release-signing-public.pem",
"{{PUBLIC_KEY_SHA256}}": "b".repeat(64),
"{{MANIFEST_URL}}": "https://release.example.test/manifest.json",
};
for (const platform of ["WIN32_X64", "WIN32_ARM64"]) {
replacements[`{{NODE_${platform}_FILE}}`] = `node-v${NODE_VERSION}-win-x64.zip`;
replacements[`{{NODE_${platform}_SHA256}}`] = "c".repeat(64);
}
for (const [placeholder, value] of Object.entries(replacements)) body = body.replaceAll(placeholder, value);
assert.doesNotMatch(body, /\{\{/);
return body;
}
function digest(path: string): string {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}