fix: harden proxied client installation
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 16:01:06 +00:00
parent 40c5021c85
commit a08a2c1a96
15 changed files with 513 additions and 87 deletions
+70 -11
View File
@@ -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"
+49 -20
View File
@@ -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 结果,后续只使用其中的真实字段,不要猜路径。
+22 -5
View File
@@ -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
+18 -5
View File
@@ -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
+1 -1
View File
@@ -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-*` 残留。
+4
View File
@@ -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、不读取审批、不执行写操作。
+2
View File
@@ -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 会话。
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+36 -2
View File
@@ -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();
}
+13
View File
@@ -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" });
+172 -30
View File
@@ -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<string, string>();
const stringLists = new Map<string, string[]>();
const flags = new Set<string>();
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<string> = 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<string, unknown>;
@@ -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<Buffer> {
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<Buffer> {
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<Buffer> {
}
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<Buffer> {
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<Buffer> {
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<string, unknown>, 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])) {
+66 -2
View File
@@ -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<string, Buffer>([
["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<string>();
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 不匹配/);
+5
View File
@@ -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/);
+52 -8
View File
@@ -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<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_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<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_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`;