diff --git a/deploy/install.sh b/deploy/install.sh index dc084a2..9bb91ac 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -6,6 +6,7 @@ domain="approval.justconn.top" public_base_url="https://${domain}" gateway_url="${public_base_url}/mcp" download_base_url="${public_base_url}/downloads/dingmcp" +gitea_repo="gitea-admin/dingMCP" release_dir="${project_root}/release" web_base="/var/www/${domain}" web_root="${web_base}/downloads/dingmcp" @@ -17,13 +18,29 @@ npm_cache="${project_root}/.npm-cache" release_private_key="${project_root}/.secrets/release-signing-private.pem" release_public_key="${project_root}/distribution/release-signing-public.pem" -for command_name in node npm curl systemctl sudo nginx certbot stat sha256sum tar find sort tail cut date; do +for command_name in node npm curl systemctl sudo nginx certbot stat sha256sum tar find sort tail cut date git tea; do command -v "${command_name}" >/dev/null 2>&1 || { echo "缺少部署命令:${command_name}" >&2 exit 1 } done +cd "${project_root}" +release_version="$(node -p "require('./package.json').version")" +release_tag="v${release_version}" +download_mirror_base_url="https://git.justconn.top/${gitea_repo}/releases/download/${release_tag}" +if [[ "$(git branch --show-current)" != "main" ]] || ! git diff --quiet || ! git diff --cached --quiet; then + echo "正式部署只能从无未提交改动的 main 分支执行。" >&2 + exit 1 +fi +release_commit="$(git rev-parse HEAD)" +release_created_at="$(git show -s --format=%cI "${release_commit}")" +remote_main="$(git ls-remote origin refs/heads/main | cut -f1)" +if [[ -z "${remote_main}" || "${remote_main}" != "${release_commit}" ]]; then + echo "当前 main 提交尚未同步到 Gitea origin/main,拒绝创建发布标签。" >&2 + exit 1 +fi + if [[ ! -f "${project_root}/.runtime/dingtalk-mcp.env" ]]; then echo "缺少 .runtime/dingtalk-mcp.env;首次部署前需先配置网关企业凭证。" >&2 exit 1 @@ -38,15 +55,16 @@ if [[ "$(stat -c '%a' "${release_private_key}")" != "600" ]]; then exit 1 fi -echo "[1/7] 测试并构建" -cd "${project_root}" +echo "[1/8] 测试并构建" npm --cache "${npm_cache}" test npm run runtime:verify -echo "[2/7] 生成包含钉钉 OA 与薪福通的发行包" +echo "[2/8] 生成包含钉钉 OA 与薪福通的发行包" npm --cache "${npm_cache}" run release:build -- \ --gateway-url "${gateway_url}" \ --download-base-url "${download_base_url}" \ + --download-mirror-base-url "${download_mirror_base_url}" \ + --created-at "${release_created_at}" \ --out release npm run test-bundle:build -- \ --release-dir release \ @@ -56,11 +74,49 @@ npm run test-bundle:build -- \ (cd "${release_dir}" && sha256sum -c SHA256SUMS) npm run release:verify -- --release-dir release -echo "[3/7] 安装并重启用户级网关服务" +echo "[3/8] 发布或复用 Gitea 国内备用下载源" +gitea_releases_json="$(mktemp)" +trap 'rm -f "${gitea_releases_json}"' EXIT +tea releases list --remote origin --limit 100 --output json >"${gitea_releases_json}" +if ! node -e 'const fs=require("fs");const tag=process.argv[1];const rows=JSON.parse(fs.readFileSync(0,"utf8"));process.exit(rows.some((row)=>row.tag_name===tag||row.tag===tag||row.Tag===tag)?0:1)' \ + "${release_tag}" <"${gitea_releases_json}"; then + release_flags=() + [[ "${release_version}" == *-* ]] && release_flags+=(--prerelease) + tea releases create --remote origin --tag "${release_tag}" --target "${release_commit}" \ + --title "${release_tag}" --note "Signed DingTalk approval client ${release_version}" "${release_flags[@]}" +fi +mapfile -t mirror_assets < <(find "${release_dir}" -mindepth 1 -maxdepth 1 -type f -print | sort) +missing_mirror_assets=() +mirror_check_file="$(mktemp)" +for asset in "${mirror_assets[@]}"; do + asset_name="$(basename "${asset}")" + if curl --fail --silent --location "${download_mirror_base_url}/${asset_name}" -o "${mirror_check_file}" 2>/dev/null; then + if [[ "$(sha256sum "${mirror_check_file}" | cut -d' ' -f1)" != "$(sha256sum "${asset}" | cut -d' ' -f1)" ]]; then + echo "Gitea 已存在同版本但内容不同的发布物:${asset_name};请提升版本号后重新发布。" >&2 + exit 1 + fi + else + missing_mirror_assets+=("${asset}") + fi +done +if ((${#missing_mirror_assets[@]} > 0)); then + tea releases assets create --remote origin "${release_tag}" "${missing_mirror_assets[@]}" +fi +for asset in "${mirror_assets[@]}"; do + asset_name="$(basename "${asset}")" + curl --fail --silent --show-error --location "${download_mirror_base_url}/${asset_name}" -o "${mirror_check_file}" + [[ "$(sha256sum "${mirror_check_file}" | cut -d' ' -f1)" == "$(sha256sum "${asset}" | cut -d' ' -f1)" ]] || { + echo "Gitea 备用发布物校验失败:${asset_name}" >&2 + exit 1 + } +done +rm -f "${mirror_check_file}" "${gitea_releases_json}" +trap - EXIT + +echo "[4/8] 安装并重启用户级网关服务" node dist/src/admin.js service start --project-root "${project_root}" -echo "[4/7] 原子发布安装文件" -release_version="$(node -p "require('./package.json').version")" +echo "[5/8] 原子发布安装文件" release_digest="$(sha256sum "${release_dir}/manifest.json" | cut -c1-12)" release_name="${release_version}-${release_digest}" release_target="${web_releases}/${release_name}" @@ -87,21 +143,21 @@ for obsolete in "${obsolete_releases[@]}"; do done if ! sudo test -f "${certificate}"; then - echo "[5/7] 首次签发 HTTPS 证书" + echo "[6/8] 首次签发 HTTPS 证书" sudo install -m 0644 "${project_root}/deploy/${domain}.bootstrap.nginx.conf" "${nginx_site}" sudo nginx -t sudo systemctl reload nginx sudo certbot certonly --webroot -w "${acme_root}" -d "${domain}" else - echo "[5/7] 复用现有 HTTPS 证书" + echo "[6/8] 复用现有 HTTPS 证书" fi -echo "[6/7] 安装正式 Nginx 配置" +echo "[7/8] 安装正式 Nginx 配置" sudo install -m 0644 "${project_root}/deploy/${domain}.nginx.conf" "${nginx_site}" sudo nginx -t sudo systemctl reload nginx -echo "[7/7] 验证公网服务" +echo "[8/8] 验证公网服务与备用发布源" curl --fail --silent --show-error --retry 10 --retry-delay 1 --retry-all-errors \ "${public_base_url}/healthz" >/dev/null curl --fail --silent --show-error \ @@ -111,8 +167,11 @@ curl --fail --silent --show-error "${download_base_url}/manifest.json" >/dev/nul curl --fail --silent --show-error "${download_base_url}/manifest.sig" >/dev/null curl --fail --silent --show-error "${download_base_url}/dingtalk-approval-installer.mjs" >/dev/null curl --fail --silent --show-error "${download_base_url}/release-signing-public.pem" >/dev/null +curl --fail --silent --show-error --location "${download_mirror_base_url}/manifest.json" >/dev/null +curl --fail --silent --show-error --location "${download_mirror_base_url}/manifest.sig" >/dev/null echo "部署完成。" echo "MCP:${gateway_url}" echo "用户安装链接:${download_base_url}/install.md" +echo "备用安装链接:${download_mirror_base_url}/install.md" echo "黑盒测试提示词:${download_base_url}/agent-test-install.md" diff --git a/distribution/agent-install.template.md b/distribution/agent-install.template.md index 2477770..c6b5563 100644 --- a/distribution/agent-install.template.md +++ b/distribution/agent-install.template.md @@ -14,10 +14,16 @@ - 公钥编号:`{{RELEASE_KEY_ID}}` - 签名清单:`{{MANIFEST_URL}}` - 清单签名:`{{MANIFEST_SIGNATURE_URL}}` +- 国内备用发布源: +{{DOWNLOAD_MIRROR_BASE_URLS_MARKDOWN}} - 统一网关:`{{GATEWAY_URL}}` - Windows 安装引导:`{{BOOTSTRAP_WINDOWS_URL}}` +- Windows 安装引导备用地址: +{{BOOTSTRAP_WINDOWS_MIRROR_URLS_MARKDOWN}} - Windows 安装引导 SHA-256:`{{BOOTSTRAP_WINDOWS_SHA256}}` - Linux/macOS 安装引导:`{{BOOTSTRAP_POSIX_URL}}` +- Linux/macOS 安装引导备用地址: +{{BOOTSTRAP_POSIX_MIRROR_URLS_MARKDOWN}} - Linux/macOS 安装引导 SHA-256:`{{BOOTSTRAP_POSIX_SHA256}}` - 私有 Node.js:`{{NODE_VERSION}}`(最低兼容版本 `{{NODE_MINIMUM_VERSION}}`) @@ -51,6 +57,8 @@ 为执行平台安装引导的 Agent 工具调用设置至少 10 分钟超时。引导和安装器会把 `[dingtalk-approval]` 开头的阶段进度写入 stderr,把唯一的最终结果 JSON 写入 stdout;进度文本不是失败,也不能混入 JSON 解析。进程仍在运行时不得并发启动第二次安装。 +如果当前环境通过 `HTTPS_PROXY`、`HTTP_PROXY` 或 `ALL_PROXY` 出网,安装引导会在兼容 Node 上自动为下载、登录和 Bridge 启用环境代理,并在离线 npm 展开前隔离 Node 的代理开关。不要设置全局 `NODE_OPTIONS=--use-env-proxy`,也不要手工预装 `node_modules`;这会让 npm 与 Node 的代理机制叠加,并破坏安装器的原子安装与故障恢复。 + ### Windows(PowerShell) 无论当前 Agent 默认使用 PowerShell、CMD 还是 Git Bash,Windows 都必须调用普通 PowerShell 执行下面的完整流程。不得从 Git Bash 直接运行内部的 `node dingtalk-approval-installer.mjs ...` 命令;内部安装器不是用户安装入口。 @@ -61,9 +69,20 @@ $dingBootstrapDir = Join-Path ([System.IO.Path]::GetTempPath()) ("dingmcp-bootstrap-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $dingBootstrapDir -Force | Out-Null $dingBootstrap = Join-Path $dingBootstrapDir "dingtalk-approval-bootstrap.ps1" -Invoke-WebRequest -UseBasicParsing -Uri "{{BOOTSTRAP_WINDOWS_URL}}" -OutFile $dingBootstrap -$dingBootstrapHash = (Get-FileHash -Algorithm SHA256 $dingBootstrap).Hash.ToLowerInvariant() -if ($dingBootstrapHash -ne "{{BOOTSTRAP_WINDOWS_SHA256}}") { throw "Windows 安装引导 SHA-256 不匹配,停止安装" } +$dingBootstrapUrls = @({{BOOTSTRAP_WINDOWS_URLS_POWERSHELL}}) +$dingBootstrapDownloaded = $false +foreach ($dingBootstrapUrl in $dingBootstrapUrls) { + try { + Invoke-WebRequest -UseBasicParsing -Uri $dingBootstrapUrl -OutFile $dingBootstrap -TimeoutSec 300 + } catch { + continue + } + $dingBootstrapHash = (Get-FileHash -Algorithm SHA256 $dingBootstrap).Hash.ToLowerInvariant() + if ($dingBootstrapHash -ne "{{BOOTSTRAP_WINDOWS_SHA256}}") { throw "Windows 安装引导 SHA-256 不匹配,停止安装:$dingBootstrapUrl" } + $dingBootstrapDownloaded = $true + break +} +if (-not $dingBootstrapDownloaded) { throw "所有 Windows 安装引导下载源均不可用" } $dingInstallJson = & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $dingBootstrap if ($LASTEXITCODE -ne 0) { throw "钉钉审批安装引导执行失败:$dingInstallJson" } $dingInstall = $dingInstallJson | ConvertFrom-Json @@ -78,21 +97,31 @@ $dingInstall | ConvertTo-Json -Depth 20 ```bash ding_bootstrap_dir="$(mktemp -d)" ding_bootstrap="$ding_bootstrap_dir/dingtalk-approval-bootstrap.sh" -if command -v curl >/dev/null 2>&1; then - curl --fail --location --output "$ding_bootstrap" "{{BOOTSTRAP_POSIX_URL}}" -elif command -v wget >/dev/null 2>&1; then - wget --https-only --output-document="$ding_bootstrap" "{{BOOTSTRAP_POSIX_URL}}" -else - echo '缺少 curl 或 wget,无法下载安装引导' >&2 - exit 1 -fi -if command -v sha256sum >/dev/null 2>&1; then - ding_bootstrap_hash="$(sha256sum "$ding_bootstrap" | awk '{print $1}')" -else - ding_bootstrap_hash="$(shasum -a 256 "$ding_bootstrap" | awk '{print $1}')" -fi -if [ "$ding_bootstrap_hash" != '{{BOOTSTRAP_POSIX_SHA256}}' ]; then - echo 'Linux/macOS 安装引导 SHA-256 不匹配,停止安装' >&2 +ding_bootstrap_downloaded=0 +for ding_bootstrap_url in {{BOOTSTRAP_POSIX_URLS_POSIX}}; do + rm -f "$ding_bootstrap" + if command -v curl >/dev/null 2>&1; then + curl --fail --location --connect-timeout 10 --max-time 300 --output "$ding_bootstrap" "$ding_bootstrap_url" || continue + elif command -v wget >/dev/null 2>&1; then + wget --https-only --timeout=300 --output-document="$ding_bootstrap" "$ding_bootstrap_url" || continue + else + echo '缺少 curl 或 wget,无法下载安装引导' >&2 + exit 1 + fi + if command -v sha256sum >/dev/null 2>&1; then + ding_bootstrap_hash="$(sha256sum "$ding_bootstrap" | awk '{print $1}')" + else + ding_bootstrap_hash="$(shasum -a 256 "$ding_bootstrap" | awk '{print $1}')" + fi + if [ "$ding_bootstrap_hash" != '{{BOOTSTRAP_POSIX_SHA256}}' ]; then + echo "Linux/macOS 安装引导 SHA-256 不匹配,停止安装:$ding_bootstrap_url" >&2 + exit 1 + fi + ding_bootstrap_downloaded=1 + break +done +if [ "$ding_bootstrap_downloaded" -ne 1 ]; then + echo '所有 Linux/macOS 安装引导下载源均不可用' >&2 exit 1 fi ding_install_json="$(sh "$ding_bootstrap")" @@ -106,9 +135,9 @@ printf '%s\n' "$ding_install_json" 步骤 2 的平台引导是唯一通用安装入口。它会: 1. 检查系统 Node;缺失或版本不兼容时,根据当前操作系统和 CPU 架构选择私有 Node; -2. 按“阿里云精确版本 → npmmirror 精确版本 → Node 官方精确版本”的顺序下载。只有网络失败、超时或 404 才切换来源;任一已下载文件的 SHA-256 不匹配都会立即终止; +2. 按“阿里云精确版本 → npmmirror 精确版本 → Node 官方精确版本”的顺序下载 Node;项目安装引导和发布物则按“主发布源 → 国内 Gitea 备用源 → 其他已配置镜像”回退。网络或 HTTP 下载失败才切换来源,任一已下载文件的签名或 SHA-256 不匹配都会立即终止; 3. 把私有 Node 放在插件自己的当前用户数据目录,不修改系统 PATH,不要求管理员权限; -4. 下载并校验首次安装器和发布公钥,再由安装器验证 Ed25519 清单签名与全部客户端发布物; +4. 下载并校验首次安装器和发布公钥,再由安装器验证 Ed25519 清单签名与全部客户端发布物;下载源不是信任根,备用源不能绕过签名或哈希; 5. 返回一份 JSON 安装结果。 安装器会在稳定的当前用户数据目录安装客户端与 Skill,不会全局安装 npm 包,也不会修改 Agent 私有配置。保留完整 JSON 结果,后续只使用其中的真实字段,不要猜路径。 diff --git a/distribution/bootstrap-posix.template.sh b/distribution/bootstrap-posix.template.sh index baf71a2..17d9dd6 100644 --- a/distribution/bootstrap-posix.template.sh +++ b/distribution/bootstrap-posix.template.sh @@ -2,11 +2,12 @@ set -eu ding_source_bases='{{NODE_SOURCE_BASES_POSIX}}' -ding_installer_url='{{INSTALLER_URL}}' +ding_installer_urls='{{INSTALLER_URLS_POSIX}}' ding_installer_sha256='{{INSTALLER_SHA256}}' -ding_public_key_url='{{PUBLIC_KEY_URL}}' +ding_public_key_urls='{{PUBLIC_KEY_URLS_POSIX}}' ding_public_key_sha256='{{PUBLIC_KEY_SHA256}}' ding_manifest_url='{{MANIFEST_URL}}' +ding_manifest_mirror_urls='{{MANIFEST_MIRROR_URLS_POSIX}}' ding_temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/dingmcp-install.XXXXXX")" trap 'rm -rf "$ding_temp_dir"' EXIT HUP INT TERM @@ -164,8 +165,24 @@ fi ding_progress '3/4 下载并校验签名安装器与发布公钥' ding_installer="$ding_temp_dir/installer.mjs" ding_public_key="$ding_temp_dir/release-signing-public.pem" -ding_download_verified "$ding_installer" "$ding_installer_sha256" "$ding_installer_url" -ding_download_verified "$ding_public_key" "$ding_public_key_sha256" "$ding_public_key_url" +ding_download_verified "$ding_installer" "$ding_installer_sha256" $ding_installer_urls +ding_download_verified "$ding_public_key" "$ding_public_key_sha256" $ding_public_key_urls ding_progress '4/4 执行安装器;最终 JSON 输出到 stdout,阶段进度输出到 stderr' -"$ding_node_path" "$ding_installer" install --manifest-url "$ding_manifest_url" --public-key "$ding_public_key" --json +set -- "$ding_installer" install --manifest-url "$ding_manifest_url" +for ding_manifest_mirror_url in $ding_manifest_mirror_urls; do + set -- "$@" --manifest-mirror-url "$ding_manifest_mirror_url" +done +set -- "$@" --public-key "$ding_public_key" --json +ding_node_proxy_arg='' +if [ -n "${HTTPS_PROXY:-}${https_proxy:-}${HTTP_PROXY:-}${http_proxy:-}${ALL_PROXY:-}${all_proxy:-}" ] \ + && ! printf '%s' "${NODE_OPTIONS:-}" | grep -Eq '(^|[[:space:]])--use-env-proxy($|[[:space:]])' \ + && ! printf '%s' "${NODE_USE_ENV_PROXY:-}" | grep -Eiq '^(1|true)$' \ + && "$ding_node_path" -e 'process.exit(process.allowedNodeEnvironmentFlags.has("--use-env-proxy") ? 0 : 1)'; then + ding_node_proxy_arg='--use-env-proxy' +fi +if [ -n "$ding_node_proxy_arg" ]; then + "$ding_node_path" "$ding_node_proxy_arg" "$@" +else + "$ding_node_path" "$@" +fi diff --git a/distribution/bootstrap-windows.template.ps1 b/distribution/bootstrap-windows.template.ps1 index 53c5cbe..115687f 100644 --- a/distribution/bootstrap-windows.template.ps1 +++ b/distribution/bootstrap-windows.template.ps1 @@ -6,11 +6,12 @@ if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -ErrorAction Sile [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $dingSourceBases = @({{NODE_SOURCE_BASES_POWERSHELL}}) -$dingInstallerUrl = "{{INSTALLER_URL}}" +$dingInstallerUrls = @({{INSTALLER_URLS_POWERSHELL}}) $dingInstallerSha256 = "{{INSTALLER_SHA256}}" -$dingPublicKeyUrl = "{{PUBLIC_KEY_URL}}" +$dingPublicKeyUrls = @({{PUBLIC_KEY_URLS_POWERSHELL}}) $dingPublicKeySha256 = "{{PUBLIC_KEY_SHA256}}" $dingManifestUrl = "{{MANIFEST_URL}}" +$dingManifestMirrorUrls = @({{MANIFEST_MIRROR_URLS_POWERSHELL}}) function Write-DingProgress([string] $dingMessage) { [Console]::Error.WriteLine("[dingtalk-approval] $dingMessage") @@ -113,10 +114,22 @@ New-Item -ItemType Directory -Path $dingInstallTemp -Force | Out-Null try { $dingInstaller = Join-Path $dingInstallTemp "installer.mjs" $dingPublicKey = Join-Path $dingInstallTemp "release-signing-public.pem" - Save-DingDownload @($dingInstallerUrl) $dingInstaller $dingInstallerSha256 - Save-DingDownload @($dingPublicKeyUrl) $dingPublicKey $dingPublicKeySha256 + Save-DingDownload $dingInstallerUrls $dingInstaller $dingInstallerSha256 + Save-DingDownload $dingPublicKeyUrls $dingPublicKey $dingPublicKeySha256 Write-DingProgress "4/4 执行安装器;最终 JSON 输出到 stdout,阶段进度输出到 stderr" - $dingInstallOutput = & $dingNodePath $dingInstaller install --manifest-url $dingManifestUrl --public-key $dingPublicKey --json + $dingInstallerArgs = @($dingInstaller, "install", "--manifest-url", $dingManifestUrl) + foreach ($dingManifestMirrorUrl in $dingManifestMirrorUrls) { + $dingInstallerArgs += @("--manifest-mirror-url", $dingManifestMirrorUrl) + } + $dingInstallerArgs += @("--public-key", $dingPublicKey, "--json") + $dingNodeProxyArgs = @() + $dingProxyConfigured = $env:HTTPS_PROXY -or $env:HTTP_PROXY -or $env:ALL_PROXY + $dingProxyAlreadyEnabled = ($env:NODE_OPTIONS -match '(^|\s)--use-env-proxy($|\s)') -or ($env:NODE_USE_ENV_PROXY -match '^(1|true)$') + if ($dingProxyConfigured -and -not $dingProxyAlreadyEnabled) { + & $dingNodePath -e 'process.exit(process.allowedNodeEnvironmentFlags.has("--use-env-proxy") ? 0 : 1)' + if ($LASTEXITCODE -eq 0) { $dingNodeProxyArgs = @("--use-env-proxy") } + } + $dingInstallOutput = & $dingNodePath @dingNodeProxyArgs @dingInstallerArgs $dingInstallExit = $LASTEXITCODE if ($dingInstallExit -ne 0) { throw "钉钉审批安装器执行失败:$dingInstallOutput" } $dingResult = $dingInstallOutput | ConvertFrom-Json diff --git a/docs/architecture.md b/docs/architecture.md index 3bf2f7c..e2a63c6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,7 @@ 终端不再使用全局 npm prefix。无第三方依赖的首次安装器通过自有 HTTPS 到达用户机器,随后使用固定 Ed25519 公钥验证 schema v3 清单签名,并校验每个 artifact 的 SHA-256。客户端运行依赖被内置在签名 artifact 中,终端使用 npm 离线模式展开,不在安装时引入清单以外的代码。首次安装以 HTTPS 和安装说明中固定的安装器/公钥哈希建立信任;本地固定公钥保护后续更新链路。发布私钥不进入 Git、网关进程或公开目录。 -运行时位于稳定的用户数据目录,启动器与 Node/NVM 具体版本解耦。Windows、Linux、macOS 的平台引导会复用兼容系统 Node;系统没有 Node 时,从固定精确版本的国内镜像下载并用 Node 官方 SHA-256 校验 x64/ARM64 制品,再放入插件自己的用户目录。下载源不构成信任根,哈希不符时立即终止;安装不修改 PATH,也不需要管理员权限。启动器固定使用安装时选定的 Node,更新和卸载沿用同一运行时。 +运行时位于稳定的用户数据目录,启动器与 Node/NVM 具体版本解耦。Windows、Linux、macOS 的平台引导会复用兼容系统 Node;系统没有 Node 时,从固定精确版本的国内镜像下载并用 Node 官方 SHA-256 校验 x64/ARM64 制品,再放入插件自己的用户目录。项目发布物同时声明主下载源和签名清单固定的国内备用源,首次安装与更新可在网络失败时自动回退;下载源不构成信任根,签名或哈希不符时立即终止。安装不修改 PATH,也不需要管理员权限。启动器固定使用安装时选定的 Node,更新和卸载沿用同一运行时;代理环境只为联网 Node 子进程启用环境代理,离线 npm 子进程不会继承 Node 专用代理开关。 安装器只有一条通用路径,输出稳定 `bridgePath`、通用 `skillPath`、标准 `mcpConfig`、`nodeRuntime` 和机器可读 `postInstall`,不识别 Agent 品牌,也不写任何 Agent 的私有配置。stdout 只承载最终 JSON,stderr 输出可安全展示的阶段进度;默认写入使用同步文件描述符,减少宿主结束进程时诊断文本尚未刷新。离线 npm 展开有 180 秒硬超时和明确错误,三个独立发布物并行下载。跨进程目录锁阻止两个 Agent 同时安装;锁记录 PID,外部终止后下一次安装可回收死亡锁及 `.staging-*` 残留。 diff --git a/docs/deployment.md b/docs/deployment.md index 557e64c..6d5a8eb 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -118,6 +118,8 @@ https://approval.justconn.top/downloads/dingmcp/install.md Agent 先校验当前平台引导脚本的固定 SHA-256,再执行该引导。平台引导选择并校验 Node 运行时,同时校验首次安装器和发布公钥;安装器随后验证 `manifest.sig` 和全部 artifact 哈希,将内置运行依赖的客户端以 npm 离线模式安装到稳定用户目录并返回标准 MCP 配置。终端安装过程不会从 npm registry 下载额外代码。Bridge 的稳定绝对路径不会包含 NVM 的具体 Node 版本,配置也不再携带 `--url`;网关地址由受签名保护的安装状态提供。 +正式部署还会把同一批不可变发布文件上传到公开 Gitea Release,签名清单为每个 artifact 固定主地址和备用地址。平台引导、首次安装和后续更新都会在网络失败、超时或 404 时自动回退;任何来源一旦返回内容,Ed25519 签名或 SHA-256 不匹配都会立即终止。Gitea 备用源与主下载源仍可能共享基础设施,如需机器级容灾,可再向 `release:build` 追加一个或多个 `--download-mirror-base-url` 指向独立的国内 OSS/COS。 + 公开 `install.md` 必须保留从平台检查到双来源验收的完整编号步骤,并分别提供 Windows PowerShell 与 Linux/macOS 的独立路径。三平台都必须支持系统没有 Node 的情况:发布流程固定 Node 精确版本及 Windows/Linux/macOS x64/ARM64 的官方 SHA-256;平台引导依次使用阿里云、npmmirror 和 Node 官方精确版本地址,网络失败才换源,哈希不符立即终止。禁止使用 `latest` 镜像别名、修改系统 PATH 或要求管理员权限。 Windows 安装说明至少要覆盖 `Invoke-WebRequest` 下载、`Get-FileHash` 校验、JSON 解析、`.cmd` stdio 配置、路径空格、连接器信任与完整重启、独立登录续期、`doctor --json`、更新和卸载。Linux/macOS 必须同时支持 `curl`/`wget` 和 `sha256sum`/`shasum`,并使用系统 gzip tar 解压私有 Node。教程必须明确禁止把网关 HTTPS URL 直接注册为 MCP。 @@ -126,6 +128,8 @@ Windows 安装说明至少要覆盖 `Invoke-WebRequest` 下载、`Get-FileHash` 平台引导与安装器把阶段进度写入 stderr,stdout 只保留最终 JSON。Agent 执行首次安装时必须给予至少 10 分钟工具超时;Windows 不论默认 shell 是什么都必须使用 PowerShell bootstrap,禁止直接调用内部 Node 安装器。安装器优先由当前 Node 直接启动相邻的 `npm-cli.js`,不会依赖易受 PATH、shell 或 Node 安全加固影响的隐式 npm shim;离线展开限时 180 秒。跨进程安装锁禁止并发写入,同一 PID 存活时要求等待,进程已死亡时自动回收锁并清理残留 staging。 +代理环境不需要 Agent 修改全局 `NODE_OPTIONS`。平台引导和稳定启动器只对真正需要联网的 Node 子进程按能力添加 `--use-env-proxy`;npm 子进程会移除该 Node 专用开关和 `NODE_USE_ENV_PROXY`,但保留 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 与 `NO_PROXY`,由 npm 使用自己的代理实现。禁止用手工预装 `node_modules` 绕过这一流程。 + 安装器不会识别 Agent 品牌或直接修改其配置。所有 Agent 都使用返回的 `skillPath` 和标准 `mcpConfig`,按自身官方方式安装 Skill、注册并信任名为 `dingtalk-approval` 的本地 stdio MCP;不能热加载连接器时再新建会话或重启。 安装器在返回前通过已安装 Bridge 主动准备登录,并在 JSON 的 `postInstall` 中返回 `authenticated`、`authorization_required` 或 `unavailable`,同时返回独立的 `loginCommand`。Agent 必须立即显示其中的短期 `directUrl`,不得等待首次审批请求;网页授权后调用 `approval_login`,再按 `verifyTool` 用 `source=all` 做双来源只读验证。链接过期时,MCP 已加载就重新调用 `approval_login`,尚未加载就执行 `loginCommand`,禁止为刷新链接重跑安装。首次认证成功会返回一次性 onboarding 示例,普通文本宿主也可见。登录准备失败不回滚安装,且整个流程不提交 consent、不读取审批、不执行写操作。 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 65ec6a8..6aabadf 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -6,11 +6,13 @@ - `npm ci`、`npm test`、`npm audit --omit=dev --audit-level=high` 全部通过。 - `npm run runtime:verify` 确认 Node 官方精确版本源和至少一个国内源同时提供全部六个平台制品,且镜像 `SHASUMS256.txt` 与仓库固定哈希一致。 - `release:build`、`test-bundle:build` 和 `release:verify` 成功,`SHA256SUMS` 校验通过,发行物不包含 `.secrets`、`.runtime`、数据库、私钥或机器私有路径。 +- 主下载目录与公开 Gitea Release 的 `manifest.json`、`manifest.sig` 和全部附件逐文件哈希一致;模拟主源网络失败/404 后能从备用源完成首次安装和更新,模拟主源返回错误哈希或签名时必须立即停止而不换源。 - Nginx bootstrap 只开放 ACME challenge;正式站点启用 HSTS,mutable 发行文件返回 `no-cache`,客户端 tgz 才返回 immutable。连续两次部署时公开目录始终对应一个完整签名清单,旧版本保留数符合策略。 - 从新生成的公开 `install.md` 在干净的用户级目录安装,不使用 `sudo`;安装结果主动返回 `postInstall`,可发现全部 13 个 MCP 工具。 - 在未安装 Node 的干净 Windows x64/ARM64、Linux x64/ARM64、macOS Intel/Apple Silicon 用户环境验证平台引导。确认只使用固定精确版本、国内镜像失败会依次回退、任一哈希不符立即终止、私有 Node 不修改 PATH 且更新后仍可启动。至少在实际可用的每类操作系统完成一次端到端安装;其余架构用对应 CI 或发行制品校验覆盖。 - 在干净 Windows 用户环境用公开教程的 PowerShell 路径完成引导 SHA-256 校验、安装 JSON 解析和 stdio 注册;实际 `command` 以 `.cmd` 结尾、`args` 为空,不含网关 URL,并通过 `doctor --json`。同时验证路径含空格、连接器信任和完整重启场景。 - 验证 stdout 只有最终 JSON、stderr 持续包含阶段进度;宿主工具超时设为至少 10 分钟。模拟离线 npm 超时、外部终止留下的死亡锁和 staging、并发第二次安装,分别确认明确失败、安全恢复和零并发写入。Windows 从 Git Bash 发起测试时仍须进入 PowerShell bootstrap,不得直接运行内部安装器。 +- 在 Windows 代理沙箱中验证 `HTTPS_PROXY` 可供安装下载、登录和 Bridge 使用;父进程含 `NODE_OPTIONS="--trace-warnings --use-env-proxy"` 与 `NODE_USE_ENV_PROXY=1` 时,npm 子进程只保留无关 Node 选项并继续保留标准代理变量,离线展开不得卡死。 - 验证安装结果返回 `loginCommand`:未加载 MCP 时执行 `login --json` 能复用现有运行时生成或检查登录链接,不下载发布物;链接过期后生成新链接,已登录时不再返回挑战,未安装时明确失败。 - 首次安装不等待业务查询即产生一条可传递链接;挑战阶段 onboarding 仍为 pending,授权后的首次成功响应在普通文本中展示一次示例,此后不重复。 - 授权后钉钉 OA 与薪福通均为 `ok`,网关重启后登录状态仍可恢复;登录准备暂时失败不会回滚安装或删除原 OAuth 会话。 diff --git a/package-lock.json b/package-lock.json index f3d1009..c08c41b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dingtalk-approval-mcp", - "version": "1.1.0-rc.9", + "version": "1.1.0-rc.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dingtalk-approval-mcp", - "version": "1.1.0-rc.9", + "version": "1.1.0-rc.10", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "express": "^5.2.1", diff --git a/package.json b/package.json index 17344da..04585dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dingtalk-approval-mcp", - "version": "1.1.0-rc.9", + "version": "1.1.0-rc.10", "description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.", "private": true, "type": "module", diff --git a/scripts/build-release.mjs b/scripts/build-release.mjs index 7ad0756..1d9d7e6 100644 --- a/scripts/build-release.mjs +++ b/scripts/build-release.mjs @@ -8,11 +8,17 @@ import { parseArgs } from "node:util"; const { values } = parseArgs({ options: { "gateway-url": { type: "string" }, "download-base-url": { type: "string" }, + "download-mirror-base-url": { type: "string", multiple: true, default: [] }, + "created-at": { type: "string" }, "signing-key": { type: "string", default: ".secrets/release-signing-private.pem" }, out: { type: "string", default: "release" }, } }); const gatewayUrl = exactHttps(values["gateway-url"], "/mcp", "--gateway-url"); const downloadBaseUrl = httpsBase(values["download-base-url"], "--download-base-url"); +const downloadMirrorBaseUrls = [...new Set(values["download-mirror-base-url"].map((value) => + httpsBase(value, "--download-mirror-base-url")))].filter((value) => value !== downloadBaseUrl); +if (downloadMirrorBaseUrls.length > 4) throw new Error("--download-mirror-base-url 最多允许 4 个"); +const createdAt = releaseCreatedAt(values["created-at"]); const out = resolve(String(values.out)); const projectRoot = resolve("."); const signingKeyPath = resolve(String(values["signing-key"])); @@ -86,8 +92,14 @@ const baseArtifacts = { const commonBootstrapReplacements = new Map([ ["{{NODE_VERSION}}", nodeRuntime.version], ["{{NODE_MINIMUM_VERSION}}", nodeRuntime.minimumVersion], ["{{MANIFEST_URL}}", `${downloadBaseUrl}/manifest.json`], + ["{{MANIFEST_MIRROR_URLS_POWERSHELL}}", downloadMirrorBaseUrls.map((value) => JSON.stringify(`${value}/manifest.json`)).join(", ")], + ["{{MANIFEST_MIRROR_URLS_POSIX}}", downloadMirrorBaseUrls.map((value) => `${value}/manifest.json`).join(" ")], ["{{INSTALLER_URL}}", baseArtifacts.installer.url], ["{{INSTALLER_SHA256}}", baseArtifacts.installer.sha256], + ["{{INSTALLER_URLS_POWERSHELL}}", artifactUrls(baseArtifacts.installer).map((value) => JSON.stringify(value)).join(", ")], + ["{{INSTALLER_URLS_POSIX}}", artifactUrls(baseArtifacts.installer).join(" ")], ["{{PUBLIC_KEY_URL}}", baseArtifacts.publicKey.url], ["{{PUBLIC_KEY_SHA256}}", baseArtifacts.publicKey.sha256], + ["{{PUBLIC_KEY_URLS_POWERSHELL}}", artifactUrls(baseArtifacts.publicKey).map((value) => JSON.stringify(value)).join(", ")], + ["{{PUBLIC_KEY_URLS_POSIX}}", artifactUrls(baseArtifacts.publicKey).join(" ")], ["{{NODE_SOURCE_BASES_POWERSHELL}}", nodeRuntime.sourceBases.map((value) => JSON.stringify(value)).join(", ")], ["{{NODE_SOURCE_BASES_POSIX}}", nodeRuntime.sourceBases.join(" ")], ...Object.entries(nodeRuntime.platforms).flatMap(([platform, runtime]) => { @@ -108,9 +120,12 @@ const artifacts = { const manifest = { schemaVersion: 3, version, - createdAt: new Date().toISOString(), + createdAt, gatewayUrl, signing: { algorithm: "Ed25519", keyId }, + ...(downloadMirrorBaseUrls.length ? { + distribution: { manifestMirrors: downloadMirrorBaseUrls.map((value) => `${value}/manifest.json`) }, + } : {}), requirements: { node: `>=${nodeRuntime.minimumVersion}`, managedNode: { version: nodeRuntime.version, platforms: Object.keys(nodeRuntime.platforms).sort() }, @@ -128,10 +143,16 @@ const template = readFileSync(resolve("distribution/agent-install.template.md"), const replacements = new Map([ ["{{VERSION}}", version], ["{{MANIFEST_URL}}", `${downloadBaseUrl}/manifest.json`], ["{{MANIFEST_SIGNATURE_URL}}", `${downloadBaseUrl}/manifest.sig`], ["{{GATEWAY_URL}}", gatewayUrl], + ["{{DOWNLOAD_MIRROR_BASE_URLS_MARKDOWN}}", downloadMirrorBaseUrls.length + ? downloadMirrorBaseUrls.map((value) => ` - \`${value}\``).join("\n") : " - 未配置"], ["{{INSTALLER_URL}}", artifacts.installer.url], ["{{INSTALLER_SHA256}}", artifacts.installer.sha256], ["{{PUBLIC_KEY_URL}}", artifacts.publicKey.url], ["{{PUBLIC_KEY_SHA256}}", artifacts.publicKey.sha256], ["{{BOOTSTRAP_WINDOWS_URL}}", artifacts.bootstrapWindows.url], ["{{BOOTSTRAP_WINDOWS_SHA256}}", artifacts.bootstrapWindows.sha256], + ["{{BOOTSTRAP_WINDOWS_URLS_POWERSHELL}}", artifactUrls(artifacts.bootstrapWindows).map((value) => JSON.stringify(value)).join(", ")], + ["{{BOOTSTRAP_WINDOWS_MIRROR_URLS_MARKDOWN}}", (artifacts.bootstrapWindows.mirrors ?? []).map((value) => ` - \`${value}\``).join("\n") || " - 未配置"], ["{{BOOTSTRAP_POSIX_URL}}", artifacts.bootstrapPosix.url], ["{{BOOTSTRAP_POSIX_SHA256}}", artifacts.bootstrapPosix.sha256], + ["{{BOOTSTRAP_POSIX_URLS_POSIX}}", artifactUrls(artifacts.bootstrapPosix).join(" ")], + ["{{BOOTSTRAP_POSIX_MIRROR_URLS_MARKDOWN}}", (artifacts.bootstrapPosix.mirrors ?? []).map((value) => ` - \`${value}\``).join("\n") || " - 未配置"], ["{{NODE_VERSION}}", nodeRuntime.version], ["{{NODE_MINIMUM_VERSION}}", nodeRuntime.minimumVersion], ["{{RELEASE_KEY_ID}}", keyId], ]); @@ -147,9 +168,15 @@ console.log(`签名密钥:${keyId}`); console.log(`给用户的唯一链接:${downloadBaseUrl}/install.md`); function artifact(name) { - return { url: `${downloadBaseUrl}/${basename(name)}`, sha256: sha256(join(out, name)) }; + return { + url: `${downloadBaseUrl}/${basename(name)}`, + ...(downloadMirrorBaseUrls.length ? { mirrors: downloadMirrorBaseUrls.map((value) => `${value}/${basename(name)}`) } : {}), + sha256: sha256(join(out, name)), + }; } +function artifactUrls(value) { return [value.url, ...(value.mirrors ?? [])]; } + function sha256(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); } function releaseKeyId(value) { const der = createPublicKey(value).export({ type: "spki", format: "der" }); @@ -217,3 +244,10 @@ function parseHttps(value, flag) { if (url.protocol !== "https:" || url.username || url.password) throw new Error(`${flag} 必须使用无内嵌凭证的 HTTPS URL`); return url; } + +function releaseCreatedAt(value) { + if (value === undefined) return new Date().toISOString(); + const timestamp = Date.parse(String(value)); + if (!Number.isFinite(timestamp)) throw new Error("--created-at 必须是有效 ISO-8601 时间"); + return new Date(timestamp).toISOString(); +} diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index a650e17..511b3d8 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -24,8 +24,16 @@ for (const [kind, artifact] of Object.entries(manifest.artifacts ?? {})) { const expected = String(artifact.sha256 ?? ""); const actual = sha256(join(releaseDir, name)); if (!/^[a-f0-9]{64}$/.test(expected) || actual !== expected) throw new Error(`${kind} SHA-256 不匹配`); + for (const mirror of artifact.mirrors ?? []) { + const url = verifiedHttpsUrl(mirror, `${kind} mirror`); + if (basename(url.pathname) !== name) throw new Error(`${kind} mirror 文件名不一致`); + } } if (manifest.artifacts.publicKey.keyId !== keyId) throw new Error("publicKey artifact keyId 不匹配"); +for (const mirror of manifest.distribution?.manifestMirrors ?? []) { + const url = verifiedHttpsUrl(mirror, "manifest mirror"); + if (!url.pathname.endsWith("/manifest.json")) throw new Error("manifest mirror 必须指向 manifest.json"); +} const root = mkdtempSync(join(tmpdir(), "dingmcp-release-verify-")); try { @@ -113,6 +121,11 @@ function artifactName(value, kind) { if (typeof url !== "string") throw new Error(`manifest 缺少 ${kind} URL`); return basename(new URL(url).pathname); } +function verifiedHttpsUrl(value, label) { + const url = new URL(String(value)); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error(`${label} 必须是 HTTPS URL`); + return url; +} function sha256(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); } function releaseKeyId(value) { const der = createPublicKey(value).export({ type: "spki", format: "der" }); diff --git a/src/client-installer.ts b/src/client-installer.ts index ce6da98..750eb64 100644 --- a/src/client-installer.ts +++ b/src/client-installer.ts @@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url"; export interface ReleaseArtifact { url: string; + mirrors?: string[]; sha256: string; } @@ -20,6 +21,7 @@ export interface ClientReleaseManifest { createdAt: string; gatewayUrl: string; signing: { algorithm: "Ed25519"; keyId: string }; + distribution?: { manifestMirrors?: string[] }; requirements: { node: string; managedNode?: { version: string; platforms: string[] }; @@ -39,6 +41,7 @@ export interface ClientInstallState { version: string; installedAt: string; manifestUrl: string; + manifestMirrors?: string[]; gatewayUrl: string; clientSha256: string; skillSha256: string; @@ -211,6 +214,7 @@ export async function runClientInstaller( interface ParsedArgs { command: string; manifestUrl?: string; + manifestMirrorUrls: string[]; publicKey?: string; json: boolean; yes: boolean; @@ -225,17 +229,22 @@ function parseClientArgs(argv: string[]): ParsedArgs { if (values[0] === "--print-path") values.splice(0, 1, "print-path"); const command = values.shift() ?? ""; const strings = new Map(); + const stringLists = new Map(); const flags = new Set(); let pathKind: string | undefined; - const stringOptions = new Set(["--manifest-url", "--public-key", "--app-root", "--format"]); + const stringOptions = new Set(["--manifest-url", "--manifest-mirror-url", "--public-key", "--app-root", "--format"]); const flagOptions = new Set(["--json", "--yes", "--allow-downgrade"]); for (let index = 0; index < values.length; index += 1) { const value = values[index]!; if (stringOptions.has(value)) { const next = values[index + 1]; if (!next || next.startsWith("--")) throw new Error(`${value} 缺少参数`); - if (strings.has(value)) throw new Error(`${value} 不能重复`); - strings.set(value, next); + if (value === "--manifest-mirror-url") { + stringLists.set(value, [...(stringLists.get(value) ?? []), next]); + } else { + if (strings.has(value)) throw new Error(`${value} 不能重复`); + strings.set(value, next); + } index += 1; } else if (flagOptions.has(value)) { if (flags.has(value)) throw new Error(`${value} 不能重复`); @@ -245,20 +254,22 @@ function parseClientArgs(argv: string[]): ParsedArgs { else throw new Error(`多余参数:${value}`); } const allowed = new Set(command === "install" || command === "update" - ? ["--manifest-url", "--public-key", "--app-root", "--json", "--allow-downgrade"] + ? ["--manifest-url", "--manifest-mirror-url", "--public-key", "--app-root", "--json", "--allow-downgrade"] : command === "login" ? ["--app-root", "--json"] : command === "uninstall" ? ["--app-root", "--json", "--yes"] : command === "print-path" ? ["--app-root", "--json"] : command === "print-config" ? ["--app-root", "--json", "--format"] : command === "doctor" ? ["--app-root", "--json"] : []); - for (const name of [...strings.keys(), ...flags]) { + for (const name of [...strings.keys(), ...stringLists.keys(), ...flags]) { if (!allowed.has(name)) throw new Error(`${command || "该命令"} 不支持参数 ${name}`); } const format = strings.get("--format"); if (format && format !== "mcp-json") throw new Error("--format 只支持 mcp-json"); + if ((stringLists.get("--manifest-mirror-url")?.length ?? 0) > 4) throw new Error("--manifest-mirror-url 最多允许 4 个"); return { command, manifestUrl: strings.get("--manifest-url"), + manifestMirrorUrls: stringLists.get("--manifest-mirror-url") ?? [], publicKey: strings.get("--public-key"), json: flags.has("--json"), yes: flags.has("--yes"), @@ -286,16 +297,18 @@ async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context const npmCheck = checkNpm(context); if (!npmCheck.ok) throw new Error(`未找到可用的 npm:${npmCheck.message}`); const previous = readState(paths.stateFile); - const manifestUrl = normalizedHttpsUrl(args.manifestUrl ?? previous?.manifestUrl ?? "", "manifest URL"); - if (!manifestUrl.pathname.endsWith("/manifest.json")) throw new Error("manifest URL 必须以 /manifest.json 结尾"); + const manifestUrls = configuredManifestUrls(args, previous); const publicKeyPath = resolvePublicKeyPath(paths, args.publicKey); const publicKey = readFileSync(publicKeyPath); reportInstallProgress(context, "2/6", "下载并验证签名发布清单"); - const [manifestBytes, signatureBytes] = await Promise.all([ - download(context.fetchFn, manifestUrl), - download(context.fetchFn, new URL("manifest.sig", manifestUrl)), + const { manifest } = await downloadSignedManifest(context.fetchFn, manifestUrls, publicKey, (failed, next) => { + reportInstallProgress(context, "2/6", `下载源不可用(${failed.origin}),切换至 ${next.origin}`); + }); + const persistedManifestUrls = uniqueUrls([ + ...manifestUrls, + ...(manifest.distribution?.manifestMirrors ?? []).map((value) => normalizedManifestUrl(value, "manifest mirror URL")), ]); - const manifest = verifySignedManifest(manifestBytes, signatureBytes, publicKey, manifestUrl.toString()); + if (persistedManifestUrls.length > 5) throw new Error("签名清单与本地配置合计最多允许 4 个备用源"); if (previous && compareVersions(manifest.version, previous.version) < 0 && !args.allowDowngrade) { throw new Error(`拒绝从 ${previous.version} 降级到 ${manifest.version};如确需降级请显式使用 --allow-downgrade`); } @@ -304,9 +317,15 @@ async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context reportInstallProgress(context, "3/6", `下载并校验 ${manifest.version} 客户端发布物`); const clientArchive = join(temporary, "client.tgz"); const [, skillBody, publicArtifact] = await Promise.all([ - downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.client, clientArchive), - downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.skill, join(temporary, "SKILL.md")), - downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.publicKey, join(temporary, "release-public.pem")), + downloadArtifact(context.fetchFn, manifest.artifacts.client, clientArchive, (failed, next) => { + reportInstallProgress(context, "3/6", `客户端包下载源不可用(${failed.origin}),切换至 ${next.origin}`); + }), + downloadArtifact(context.fetchFn, manifest.artifacts.skill, join(temporary, "SKILL.md"), (failed, next) => { + reportInstallProgress(context, "3/6", `Skill 下载源不可用(${failed.origin}),切换至 ${next.origin}`); + }), + downloadArtifact(context.fetchFn, manifest.artifacts.publicKey, join(temporary, "release-public.pem"), (failed, next) => { + reportInstallProgress(context, "3/6", `发布公钥下载源不可用(${failed.origin}),切换至 ${next.origin}`); + }), ]); if (releaseKeyId(publicArtifact) !== manifest.signing.keyId || !publicArtifact.equals(publicKey)) { throw new Error("发布公钥与本机信任的公钥不一致,拒绝安装"); @@ -347,7 +366,8 @@ async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context schemaVersion: 1, version: manifest.version, installedAt: new Date().toISOString(), - manifestUrl: manifestUrl.toString(), + manifestUrl: persistedManifestUrls[0]!.toString(), + ...(persistedManifestUrls.length > 1 ? { manifestMirrors: persistedManifestUrls.slice(1).map(String) } : {}), gatewayUrl: manifest.gatewayUrl, clientSha256: manifest.artifacts.client.sha256, skillSha256: manifest.artifacts.skill.sha256, @@ -455,13 +475,14 @@ export function runNpm( options: { cache?: string; timeoutMs: number }, ) { const invocation = npmInvocation(context, args); + const npmEnv = npmChildEnvironment(context.env); return spawnSync(invocation.command, invocation.args, { encoding: "utf8", timeout: options.timeoutMs, killSignal: "SIGTERM", maxBuffer: 2 * 1024 * 1024, env: { - ...context.env, + ...npmEnv, ...(options.cache ? { npm_config_cache: options.cache } : {}), npm_config_update_notifier: "false", }, @@ -469,6 +490,26 @@ export function runNpm( }); } +export function npmChildEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const child = { ...env }; + for (const key of Object.keys(child)) { + const normalized = key.toLowerCase(); + if (normalized === "node_use_env_proxy") { + delete child[key]; + continue; + } + if (normalized !== "node_options") continue; + const value = child[key] ?? ""; + const cleaned = value + .replace(/(^|[\t\r\n ])--use-env-proxy(?=$|[\t\r\n ])/g, "$1") + .replace(/[\t ]{2,}/g, " ") + .trim(); + if (cleaned) child[key] = cleaned; + else delete child[key]; + } + return child; +} + export function npmFailureMessage( result: { error?: Error; signal: NodeJS.Signals | null; status: number | null; stderr: string; stdout: string }, timeoutMs: number, @@ -676,7 +717,7 @@ function attemptLogin( verifyTool: { name: "approval_list_todos" as const, arguments: { source: "all" as const, query: "" as const, limit: 1 as const } }, completionCriteria: "登录确认并完成钉钉 OA 与薪福通双来源只读验证", }; - const result = spawnSync(process.execPath, [clientEntry, "--url", gatewayUrl, "--login"], { + const result = spawnSync(process.execPath, [...nodeProxyArgs(context.env), clientEntry, "--url", gatewayUrl, "--login"], { encoding: "utf8", timeout: 30_000, maxBuffer: 256 * 1024, @@ -734,6 +775,20 @@ function attemptLogin( }; } +export function nodeProxyArgs( + env: NodeJS.ProcessEnv, + allowedFlags: ReadonlySet = process.allowedNodeEnvironmentFlags, +): string[] { + const proxyConfigured = Object.entries(env).some(([key, value]) => + ["https_proxy", "http_proxy", "all_proxy"].includes(key.toLowerCase()) && Boolean(value?.trim())); + if (!proxyConfigured || !allowedFlags.has("--use-env-proxy")) return []; + const nodeOptions = Object.entries(env).find(([key]) => key.toLowerCase() === "node_options")?.[1] ?? ""; + const enabledByNodeOptions = /(^|[\t\r\n ])--use-env-proxy(?=$|[\t\r\n ])/.test(nodeOptions); + const enabledByDedicatedVariable = Object.entries(env).some(([key, value]) => + key.toLowerCase() === "node_use_env_proxy" && /^(?:1|true)$/i.test(value?.trim() ?? "")); + return enabledByNodeOptions || enabledByDedicatedVariable ? [] : ["--use-env-proxy"]; +} + function safeOnboarding(value: unknown): PostInstallResult["onboarding"] | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const record = value as Record; @@ -775,7 +830,7 @@ function installBundledSkill(paths: ClientPaths, bundledSkillDir: string, skillB function writeLaunchers(paths: ClientPaths, platform: NodeJS.Platform, nodeExecutable: string): void { mkdirPrivate(paths.binDir); - const launcher = `#!/usr/bin/env node\nimport { readFileSync } from "node:fs";\nimport { dirname, join, resolve } from "node:path";\nimport { fileURLToPath } from "node:url";\nimport { spawn } from "node:child_process";\nconst mode = process.argv[2];\nconst root = resolve(dirname(fileURLToPath(import.meta.url)), "..");\nconst state = JSON.parse(readFileSync(join(root, "install-state.json"), "utf8"));\nconst runtime = resolve(root, state.runtimeDir);\nconst target = join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", mode === "bridge" ? "bridge.js" : "client-installer.js");\nconst args = mode === "bridge" ? ["--url", state.gatewayUrl, ...process.argv.slice(3)] : [...process.argv.slice(3), "--app-root", root];\nconst env = mode === "bridge" ? { ...process.env, DINGTALK_APPROVAL_BRIDGE_STATE: join(root, "state", "session.json") } : process.env;\nconst child = spawn(process.execPath, [target, ...args], { stdio: "inherit", env });\nchild.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });\nchild.on("error", (error) => { console.error(error.message); process.exitCode = 1; });\n`; + const launcher = `#!/usr/bin/env node\nimport { readFileSync } from "node:fs";\nimport { dirname, join, resolve } from "node:path";\nimport { fileURLToPath } from "node:url";\nimport { spawn } from "node:child_process";\nconst mode = process.argv[2];\nconst root = resolve(dirname(fileURLToPath(import.meta.url)), "..");\nconst state = JSON.parse(readFileSync(join(root, "install-state.json"), "utf8"));\nconst runtime = resolve(root, state.runtimeDir);\nconst target = join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", mode === "bridge" ? "bridge.js" : "client-installer.js");\nconst args = mode === "bridge" ? ["--url", state.gatewayUrl, ...process.argv.slice(3)] : [...process.argv.slice(3), "--app-root", root];\nconst env = mode === "bridge" ? { ...process.env, DINGTALK_APPROVAL_BRIDGE_STATE: join(root, "state", "session.json") } : process.env;\nconst proxyConfigured = Object.entries(env).some(([key, value]) => ["https_proxy", "http_proxy", "all_proxy"].includes(key.toLowerCase()) && Boolean(value?.trim()));\nconst nodeOptions = Object.entries(env).find(([key]) => key.toLowerCase() === "node_options")?.[1] ?? "";\nconst proxyAlreadyEnabled = /(^|[\\t\\r\\n ])--use-env-proxy(?=$|[\\t\\r\\n ])/.test(nodeOptions) || Object.entries(env).some(([key, value]) => key.toLowerCase() === "node_use_env_proxy" && /^(?:1|true)$/i.test(value?.trim() ?? ""));\nconst proxyArgs = proxyConfigured && process.allowedNodeEnvironmentFlags.has("--use-env-proxy") && !proxyAlreadyEnabled ? ["--use-env-proxy"] : [];\nconst child = spawn(process.execPath, [...proxyArgs, target, ...args], { stdio: "inherit", env });\nchild.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });\nchild.on("error", (error) => { console.error(error.message); process.exitCode = 1; });\n`; writeAtomic(paths.launcherFile, Buffer.from(launcher), 0o700); const bodies = clientLauncherBodies(platform, nodeExecutable); writeAtomic(paths.cliCommand, Buffer.from(bodies.cli), 0o700); @@ -805,13 +860,19 @@ function validateManifest(manifest: ClientReleaseManifest, manifestUrl: string, if (manifest.schemaVersion !== 3 || !VERSION_PATTERN.test(manifest.version)) throw new Error("发布清单版本或版本号无效"); if (manifest.signing?.algorithm !== "Ed25519" || manifest.signing.keyId !== keyId) throw new Error("发布清单未绑定当前发布公钥"); const base = normalizedHttpsUrl(manifestUrl, "manifest URL"); + if (!base.pathname.endsWith("/manifest.json")) throw new Error("manifest URL 必须以 /manifest.json 结尾"); const gateway = normalizedHttpsUrl(manifest.gatewayUrl, "gateway URL"); if (gateway.pathname !== "/mcp" || gateway.search || gateway.hash) throw new Error("gateway URL 必须是 HTTPS /mcp 地址"); for (const [name, artifact] of Object.entries(manifest.artifacts ?? {})) { if (!artifact || !/^[a-f0-9]{64}$/.test(artifact.sha256)) throw new Error(`发布物 ${name} 缺少有效 SHA-256`); - const url = normalizedHttpsUrl(artifact.url, `${name} URL`); - if (url.origin !== base.origin) throw new Error(`发布物 ${name} 必须与清单同源`); + const urls = artifactUrls(artifact, `${name} URL`); + if (urls.length !== new Set(urls.map(String)).size) throw new Error(`发布物 ${name} 包含重复下载地址`); } + const manifestMirrors = manifest.distribution?.manifestMirrors ?? []; + if (!Array.isArray(manifestMirrors)) throw new Error("manifest mirrors 必须是数组"); + if (manifestMirrors.length > 4) throw new Error("manifest mirrors 最多允许 4 个"); + for (const value of manifestMirrors) normalizedManifestUrl(value, "manifest mirror URL"); + if (manifestMirrors.length !== new Set(manifestMirrors).size) throw new Error("manifest mirrors 包含重复地址"); if (manifest.artifacts.publicKey.keyId !== keyId) throw new Error("发布公钥 artifact 的 keyId 不匹配"); const createdAt = Date.parse(manifest.createdAt); if (!Number.isFinite(createdAt)) throw new Error("发布清单 createdAt 无效"); @@ -819,6 +880,13 @@ function validateManifest(manifest: ClientReleaseManifest, manifestUrl: string, if (createdAt < Date.now() - MANIFEST_MAX_AGE_MS) throw new Error("发布清单已超过 180 天有效期,请获取新的安装链接"); } +class DownloadFailure extends Error { + constructor(message: string, readonly fallbackAllowed: boolean, options?: ErrorOptions) { + super(message, options); + this.name = "DownloadFailure"; + } +} + async function download(fetchFn: typeof fetch, url: URL): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(new Error("下载超时")), DOWNLOAD_TIMEOUT_MS); @@ -828,7 +896,7 @@ async function download(fetchFn: typeof fetch, url: URL): Promise { headers: { accept: "application/octet-stream, application/json;q=0.9, */*;q=0.8" }, signal: controller.signal, }); - if (!response.ok) throw new Error(`下载失败 ${response.status}:${url}`); + if (!response.ok) throw new DownloadFailure(`下载失败 ${response.status}:${url}`, response.status === 404); const length = Number(response.headers.get("content-length") ?? 0); if (Number.isFinite(length) && length > MAX_DOWNLOAD_BYTES) throw new Error(`下载文件超过 25MB 限制:${url}`); if (!response.body) return Buffer.alloc(0); @@ -847,20 +915,94 @@ async function download(fetchFn: typeof fetch, url: URL): Promise { } return Buffer.concat(chunks, total); } catch (error) { - if (controller.signal.aborted) throw new Error(`下载超时(30 秒):${url}`, { cause: error }); + if (controller.signal.aborted) throw new DownloadFailure(`下载超时(30 秒):${url}`, true, { cause: error }); + if (error instanceof DownloadFailure) throw error; + if (error instanceof TypeError || isNetworkError(error)) { + throw new DownloadFailure(`网络连接失败:${url}`, true, { cause: error }); + } throw error; } finally { clearTimeout(timeout); } } -async function downloadArtifact(fetchFn: typeof fetch, manifestUrl: URL, artifact: ReleaseArtifact, target: string): Promise { - const url = normalizedHttpsUrl(artifact.url, "artifact URL"); - if (url.origin !== manifestUrl.origin) throw new Error("发布物必须与清单同源"); - const body = await download(fetchFn, url); - if (sha256(body) !== artifact.sha256) throw new Error(`发布物 SHA-256 不匹配:${basename(url.pathname)}`); - writeAtomic(target, body, 0o600); - return body; +async function downloadSignedManifest( + fetchFn: typeof fetch, + urls: URL[], + publicKey: Buffer, + onFallback?: (failed: URL, next: URL) => void, +): Promise<{ manifest: ClientReleaseManifest; source: URL }> { + let lastError: unknown; + for (let index = 0; index < urls.length; index += 1) { + const url = urls[index]!; + try { + const manifestBytes = await download(fetchFn, url); + const signatureBytes = await download(fetchFn, new URL("manifest.sig", url)); + return { manifest: verifySignedManifest(manifestBytes, signatureBytes, publicKey, url.toString()), source: url }; + } catch (error) { + lastError = error; + const next = urls[index + 1]; + if (!next || !(error instanceof DownloadFailure) || !error.fallbackAllowed) throw error; + onFallback?.(url, next); + } + } + throw lastError instanceof Error ? lastError : new Error("所有签名清单下载源均不可用"); +} + +async function downloadArtifact( + fetchFn: typeof fetch, + artifact: ReleaseArtifact, + target: string, + onFallback?: (failed: URL, next: URL) => void, +): Promise { + const urls = artifactUrls(artifact, "artifact URL"); + let lastError: unknown; + for (let index = 0; index < urls.length; index += 1) { + const url = urls[index]!; + try { + const body = await download(fetchFn, url); + if (sha256(body) !== artifact.sha256) throw new Error(`发布物 SHA-256 不匹配:${basename(url.pathname)}`); + writeAtomic(target, body, 0o600); + return body; + } catch (error) { + lastError = error; + const next = urls[index + 1]; + if (!next || !(error instanceof DownloadFailure) || !error.fallbackAllowed) throw error; + onFallback?.(url, next); + } + } + throw lastError instanceof Error ? lastError : new Error("所有发布物下载源均不可用"); +} + +function configuredManifestUrls(args: ParsedArgs, previous: ClientInstallState | undefined): URL[] { + const primary = normalizedManifestUrl(args.manifestUrl ?? previous?.manifestUrl ?? "", "manifest URL"); + const inherited = args.manifestUrl ? [] : (previous?.manifestMirrors ?? []); + const urls = uniqueUrls([primary, ...inherited.map((value) => normalizedManifestUrl(value, "manifest mirror URL")), + ...args.manifestMirrorUrls.map((value) => normalizedManifestUrl(value, "manifest mirror URL"))]); + if (urls.length > 5) throw new Error("manifest 下载地址最多允许 1 个主源和 4 个备用源"); + return urls; +} + +function normalizedManifestUrl(value: string, label: string): URL { + const url = normalizedHttpsUrl(value, label); + if (!url.pathname.endsWith("/manifest.json") || url.search || url.hash) throw new Error(`${label} 必须是 HTTPS manifest.json 地址`); + return url; +} + +function artifactUrls(artifact: ReleaseArtifact, label: string): URL[] { + if (artifact.mirrors !== undefined && !Array.isArray(artifact.mirrors)) throw new Error(`${label} mirrors 必须是数组`); + if ((artifact.mirrors?.length ?? 0) > 4) throw new Error(`${label} mirrors 最多允许 4 个`); + return [artifact.url, ...(artifact.mirrors ?? [])].map((value) => normalizedHttpsUrl(value, label)); +} + +function uniqueUrls(values: URL[]): URL[] { + return [...new Map(values.map((value) => [value.toString(), value])).values()]; +} + +function isNetworkError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code + ?? ((error as { cause?: NodeJS.ErrnoException } | undefined)?.cause?.code); + return typeof code === "string" && ["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "ETIMEDOUT"].includes(code); } function resolvePublicKeyPath(paths: ClientPaths, explicit: string | undefined): string { @@ -1018,7 +1160,7 @@ function writeResult(value: Record, args: ParsedArgs, context: } function usage(): string { - return "用法:dingtalk-approval install|update|login|uninstall|print-path|print-config|doctor [--manifest-url URL] [--format mcp-json] [--json]"; + return "用法:dingtalk-approval install|update|login|uninstall|print-path|print-config|doctor [--manifest-url URL] [--manifest-mirror-url URL] [--format mcp-json] [--json]"; } if (isClientDirectEntrypoint(import.meta.url, process.argv[1])) { diff --git a/test/client-installer.test.ts b/test/client-installer.test.ts index 53b299c..b7e8249 100644 --- a/test/client-installer.test.ts +++ b/test/client-installer.test.ts @@ -10,7 +10,7 @@ import { basename, delimiter, dirname, join } from "node:path"; import test from "node:test"; import { acquireClientInstallLock, cleanupLegacyInstallations, clientLauncherBodies, clientPaths, releaseKeyId, - npmFailureMessage, runClientInstaller, runNpm, verifySignedManifest, + nodeProxyArgs, npmChildEnvironment, npmFailureMessage, runClientInstaller, runNpm, verifySignedManifest, type ClientReleaseManifest, } from "../src/client-installer.js"; @@ -86,6 +86,50 @@ test("npm subprocess timeout is bounded and returns an actionable diagnostic", ( } }); +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("signed manifest verification rejects tampering and a foreign key", () => { const trusted = generateKeyPairSync("ed25519"); const foreign = generateKeyPairSync("ed25519"); @@ -158,17 +202,27 @@ test("generic installer preserves identity across safe updates, rollback, downgr 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], - ...Object.entries(manifest.artifacts).map(([name, artifact]) => [artifact.url, bodies[name as keyof typeof bodies]] as [string, Buffer]), + [`${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 }); }; @@ -189,6 +243,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr }; 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); @@ -217,6 +272,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr assert.equal(existsSync(join(paths.skillDir, "references", "create-approval.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); @@ -226,6 +282,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr }); 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; @@ -276,6 +333,9 @@ test("generic installer preserves identity across safe updates, rollback, downgr 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"); @@ -287,6 +347,8 @@ test("generic installer preserves identity across safe updates, rollback, downgr 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); @@ -325,10 +387,12 @@ test("generic installer preserves identity across safe updates, rollback, downgr 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 不匹配/); diff --git a/test/distribution.test.ts b/test/distribution.test.ts index 017507b..05795d1 100644 --- a/test/distribution.test.ts +++ b/test/distribution.test.ts @@ -25,6 +25,8 @@ test("customer distribution is a generic single-login gateway bridge", () => { assert.match(installer, /安装长时间无最终 JSON[\s\S]*残留 staging/s); assert.match(installer, /系统无需预装 Node\.js/); assert.match(installer, /阿里云精确版本.*npmmirror 精确版本.*Node 官方精确版本/s); + assert.match(installer, /国内备用发布源[\s\S]*主发布源.*国内 Gitea 备用源/s); + assert.match(installer, /不要设置全局 `NODE_OPTIONS=--use-env-proxy`[\s\S]*不要手工预装 `node_modules`/s); assert.match(installer, /BOOTSTRAP_WINDOWS_URL|Windows 安装引导/); assert.match(installer, /Linux\/macOS 安装引导/); assert.match(installer, /dingtalk-approval-bridge\.cmd/); @@ -50,10 +52,12 @@ test("customer distribution is a generic single-login gateway bridge", () => { assert.match(windowsBootstrap, /OSArchitecture[\s\S]*Save-DingDownload[\s\S]*Expand-Archive/); assert.match(windowsBootstrap, /SHA-256 不匹配,停止安装/); assert.match(windowsBootstrap, /Write-DingProgress[\s\S]*\[dingtalk-approval\]/); + assert.match(windowsBootstrap, /dingInstallerUrls[\s\S]*dingManifestMirrorUrls[\s\S]*use-env-proxy/s); 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.match(posixBootstrap, /ding_progress[\s\S]*\[dingtalk-approval\]/); + assert.match(posixBootstrap, /ding_installer_urls[\s\S]*ding_manifest_mirror_urls[\s\S]*use-env-proxy/s); assert.equal(spawnSync("sh", ["-n", "distribution/bootstrap-posix.template.sh"]).status, 0); assert.doesNotMatch(`${windowsBootstrap}\n${posixBootstrap}`, /latest-v|npm install -g/); @@ -69,6 +73,7 @@ test("customer distribution is a generic single-login gateway bridge", () => { const builder = readFileSync("scripts/build-release.mjs", "utf8"); assert.match(builder, /schemaVersion: 3,\s+version,/s); + assert.match(builder, /download-mirror-base-url[\s\S]*manifestMirrors[\s\S]*mirrors:/s); assert.match(builder, /signData\(null, manifestBytes/); assert.match(builder, /dingtalk-approval-client/); assert.match(builder, /bootstrap-windows\.template\.ps1/); diff --git a/test/portable-node-bootstrap.test.ts b/test/portable-node-bootstrap.test.ts index f9d7051..ec802b4 100644 --- a/test/portable-node-bootstrap.test.ts +++ b/test/portable-node-bootstrap.test.ts @@ -34,6 +34,36 @@ test("POSIX bootstrap installs a private Node and falls back after a network fai } }); +test("POSIX bootstrap scopes the Node environment proxy flag to the networked installer", { + skip: process.platform === "win32", +}, () => { + const fixture = createPosixFixture(); + try { + const result = runPosixFixture(fixture, "proxy"); + assert.equal(result.status, 0, result.stderr); + const invocations = readFileSync(fixture.nodeArgsLog, "utf8").trim().split(/\r?\n/); + assert.ok(invocations.some((value) => value.startsWith("--use-env-proxy ") && value.includes("installer.mjs install"))); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("POSIX bootstrap falls back for the signed installer and public key downloads", { + skip: process.platform === "win32", +}, () => { + const fixture = createPosixFixture(); + try { + const result = runPosixFixture(fixture, "release-fallback"); + assert.equal(result.status, 0, result.stderr); + const calls = readFileSync(fixture.callLog, "utf8").trim().split(/\r?\n/); + assert.ok(calls.includes("https://release.example.test/installer.mjs")); + assert.ok(calls.includes("https://release-mirror.example.test/installer.mjs")); + assert.ok(calls.includes("https://release-mirror.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", }, () => { @@ -94,6 +124,7 @@ interface PosixFixture { archive: string; installer: string; publicKey: string; + nodeArgsLog: string; } function createPosixFixture(): PosixFixture { @@ -105,6 +136,7 @@ function createPosixFixture(): PosixFixture { const archive = join(root, RUNTIME_ARCHIVE); const installer = join(root, "installer.mjs"); const publicKey = join(root, "release-signing-public.pem"); + const nodeArgsLog = join(root, "node-args.log"); const script = join(root, "bootstrap.sh"); mkdirSync(join(archiveRoot, "bin"), { recursive: true }); mkdirSync(join(archiveRoot, "lib", "node_modules", "npm", "bin"), { recursive: true }); @@ -114,6 +146,7 @@ function createPosixFixture(): PosixFixture { const privateNode = join(archiveRoot, "bin", "node"); writeFileSync(privateNode, [ "#!/bin/sh", + "if [ -n \"${DING_TEST_NODE_ARGS_LOG:-}\" ]; then printf '%s\\n' \"$*\" >> \"$DING_TEST_NODE_ARGS_LOG\"; fi", "if [ \"${1:-}\" = '-e' ]; then exit 0; fi", "printf '%s\\n' '{\"ok\":true,\"status\":\"installed\",\"managedNode\":true}'", "", @@ -125,6 +158,7 @@ function createPosixFixture(): PosixFixture { writeFileSync(installer, "// fixture installer\n"); writeFileSync(publicKey, "fixture public key\n"); writeFileSync(callLog, ""); + writeFileSync(nodeArgsLog, ""); const curl = join(fakeBin, "curl"); writeFileSync(curl, `#!/bin/sh @@ -145,8 +179,14 @@ case "$ding_url" in 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' ;; + https://release.example.test/installer.mjs) + if [ "$DING_TEST_MODE" = 'release-fallback' ]; then printf '000'; exit 7; fi + /bin/cp "$DING_TEST_INSTALLER" "$ding_target"; printf '200' ;; + https://release.example.test/release-signing-public.pem) + if [ "$DING_TEST_MODE" = 'release-fallback' ]; then printf '000'; exit 7; fi + /bin/cp "$DING_TEST_PUBLIC_KEY" "$ding_target"; printf '200' ;; + https://release-mirror.example.test/installer.mjs) /bin/cp "$DING_TEST_INSTALLER" "$ding_target"; printf '200' ;; + https://release-mirror.example.test/release-signing-public.pem) /bin/cp "$DING_TEST_PUBLIC_KEY" "$ding_target"; printf '200' ;; *) printf '404'; exit 22 ;; esac `); @@ -158,11 +198,12 @@ esac let body = readFileSync("distribution/bootstrap-posix.template.sh", "utf8"); const replacements: Record = { "{{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_URLS_POSIX}}": "https://release.example.test/installer.mjs https://release-mirror.example.test/installer.mjs", "{{INSTALLER_SHA256}}": digest(installer), - "{{PUBLIC_KEY_URL}}": "https://release.example.test/release-signing-public.pem", + "{{PUBLIC_KEY_URLS_POSIX}}": "https://release.example.test/release-signing-public.pem https://release-mirror.example.test/release-signing-public.pem", "{{PUBLIC_KEY_SHA256}}": digest(publicKey), "{{MANIFEST_URL}}": "https://release.example.test/manifest.json", + "{{MANIFEST_MIRROR_URLS_POSIX}}": "https://release-mirror.example.test/manifest.json", }; for (const platform of ["LINUX_X64", "LINUX_ARM64", "DARWIN_X64", "DARWIN_ARM64"]) { replacements[`{{NODE_${platform}_FILE}}`] = RUNTIME_ARCHIVE; @@ -172,10 +213,10 @@ esac assert.doesNotMatch(body, /\{\{/); writeFileSync(script, body); chmodSync(script, 0o700); - return { root, clientRoot, callLog, script, fakeBin, archive, installer, publicKey }; + return { root, clientRoot, callLog, script, fakeBin, archive, installer, publicKey, nodeArgsLog }; } -function runPosixFixture(fixture: PosixFixture, mode: "fallback" | "corrupt" | "server-error") { +function runPosixFixture(fixture: PosixFixture, mode: "fallback" | "corrupt" | "server-error" | "proxy" | "release-fallback") { return spawnSync("/bin/sh", [fixture.script], { encoding: "utf8", env: { @@ -187,6 +228,8 @@ function runPosixFixture(fixture: PosixFixture, mode: "fallback" | "corrupt" | " DING_TEST_ARCHIVE: fixture.archive, DING_TEST_INSTALLER: fixture.installer, DING_TEST_PUBLIC_KEY: fixture.publicKey, + DING_TEST_NODE_ARGS_LOG: fixture.nodeArgsLog, + ...(mode === "proxy" ? { HTTPS_PROXY: "http://proxy.example.test:8080" } : {}), }, }); } @@ -195,11 +238,12 @@ function renderWindowsBootstrap(): string { let body = readFileSync("distribution/bootstrap-windows.template.ps1", "utf8"); const replacements: Record = { "{{NODE_SOURCE_BASES_POWERSHELL}}": '"https://primary.example.test/node/", "https://official.example.test/node/"', - "{{INSTALLER_URL}}": "https://release.example.test/installer.mjs", + "{{INSTALLER_URLS_POWERSHELL}}": '"https://release.example.test/installer.mjs", "https://release-mirror.example.test/installer.mjs"', "{{INSTALLER_SHA256}}": "a".repeat(64), - "{{PUBLIC_KEY_URL}}": "https://release.example.test/release-signing-public.pem", + "{{PUBLIC_KEY_URLS_POWERSHELL}}": '"https://release.example.test/release-signing-public.pem", "https://release-mirror.example.test/release-signing-public.pem"', "{{PUBLIC_KEY_SHA256}}": "b".repeat(64), "{{MANIFEST_URL}}": "https://release.example.test/manifest.json", + "{{MANIFEST_MIRROR_URLS_POWERSHELL}}": '"https://release-mirror.example.test/manifest.json"', }; for (const platform of ["WIN32_X64", "WIN32_ARM64"]) { replacements[`{{NODE_${platform}_FILE}}`] = `node-v${NODE_VERSION}-win-x64.zip`;