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
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:
@@ -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];
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user