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
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:
+70
-11
@@ -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"
|
||||
|
||||
@@ -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 结果,后续只使用其中的真实字段,不要猜路径。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-*` 残留。
|
||||
|
||||
|
||||
@@ -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、不读取审批、不执行写操作。
|
||||
|
||||
@@ -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 会话。
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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])) {
|
||||
|
||||
@@ -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 不匹配/);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user