fix: prefer native domestic installer downloads
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 17:12:20 +00:00
parent a08a2c1a96
commit 3b7d6a5a24
10 changed files with 270 additions and 40 deletions
+17 -17
View File
@@ -5,7 +5,7 @@ project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
domain="approval.justconn.top"
public_base_url="https://${domain}"
gateway_url="${public_base_url}/mcp"
download_base_url="${public_base_url}/downloads/dingmcp"
public_download_base_url="${public_base_url}/downloads/dingmcp"
gitea_repo="gitea-admin/dingMCP"
release_dir="${project_root}/release"
web_base="/var/www/${domain}"
@@ -28,7 +28,7 @@ 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}"
gitea_release_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
@@ -62,14 +62,14 @@ npm run runtime:verify
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}" \
--download-base-url "${gitea_release_base_url}" \
--download-mirror-base-url "${public_download_base_url}" \
--created-at "${release_created_at}" \
--out release
npm run test-bundle:build -- \
--release-dir release \
--bundle release/dingmcp-test-bundle.tar.gz \
--bundle-url "${download_base_url}/dingmcp-test-bundle.tar.gz" \
--bundle-url "${public_download_base_url}/dingmcp-test-bundle.tar.gz" \
--prompt release/agent-test-install.md
(cd "${release_dir}" && sha256sum -c SHA256SUMS)
npm run release:verify -- --release-dir release
@@ -90,7 +90,7 @@ 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 curl --fail --silent --location "${gitea_release_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
@@ -104,7 +104,7 @@ if ((${#missing_mirror_assets[@]} > 0)); then
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}"
curl --fail --silent --show-error --location "${gitea_release_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
@@ -162,16 +162,16 @@ curl --fail --silent --show-error --retry 10 --retry-delay 1 --retry-all-errors
"${public_base_url}/healthz" >/dev/null
curl --fail --silent --show-error \
"${public_base_url}/.well-known/oauth-protected-resource/mcp" >/dev/null
curl --fail --silent --show-error "${download_base_url}/install.md" >/dev/null
curl --fail --silent --show-error "${download_base_url}/manifest.json" >/dev/null
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
curl --fail --silent --show-error "${public_download_base_url}/install.md" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/manifest.json" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/manifest.sig" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/dingtalk-approval-installer.mjs" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/release-signing-public.pem" >/dev/null
curl --fail --silent --show-error --location "${gitea_release_base_url}/manifest.json" >/dev/null
curl --fail --silent --show-error --location "${gitea_release_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"
echo "用户安装链接:${public_download_base_url}/install.md"
echo "国内静态发布源:${gitea_release_base_url}/install.md"
echo "黑盒测试提示词:${public_download_base_url}/agent-test-install.md"
+3 -3
View File
@@ -14,7 +14,7 @@
- 公钥编号:`{{RELEASE_KEY_ID}}`
- 签名清单:`{{MANIFEST_URL}}`
- 清单签名:`{{MANIFEST_SIGNATURE_URL}}`
- 国内备用发布源:
- 备用发布源:
{{DOWNLOAD_MIRROR_BASE_URLS_MARKDOWN}}
- 统一网关:`{{GATEWAY_URL}}`
- Windows 安装引导:`{{BOOTSTRAP_WINDOWS_URL}}`
@@ -57,7 +57,7 @@
为执行平台安装引导的 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 的代理机制叠加,并破坏安装器的原子安装与故障恢复。
安装器和后续更新优先使用系统 `curl` 下载签名清单与发布物;`curl` 会直接使用 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 和 `NO_PROXY`,Node `fetch` 仅在系统没有 `curl` 时兜底。登录和 Bridge 仍会按 Node 能力启用环境代理。不要设置全局 `NODE_OPTIONS=--use-env-proxy`,也不要手工预装 `node_modules`;这会让 npm 与 Node 的代理机制叠加,并破坏安装器的原子安装与故障恢复。
### Windows(PowerShell)
@@ -135,7 +135,7 @@ printf '%s\n' "$ding_install_json"
步骤 2 的平台引导是唯一通用安装入口。它会:
1. 检查系统 Node;缺失或版本不兼容时,根据当前操作系统和 CPU 架构选择私有 Node;
2. 按“阿里云精确版本 → npmmirror 精确版本 → Node 官方精确版本”的顺序下载 Node;项目安装引导和发布物则按“主发布源 → 国内 Gitea 备用源 → 其他已配置镜像”回退。网络或 HTTP 下载失败才切换来源,任一已下载文件的签名或 SHA-256 不匹配都会立即终止;
2. 按“阿里云精确版本 → npmmirror 精确版本 → Node 官方精确版本”的顺序下载 Node;项目安装引导和发布物则优先使用国内 Gitea 静态发布源,再回退到自建 HTTPS 下载源和其他已配置镜像。网络或 HTTP 下载失败才切换来源,任一已下载文件的签名或 SHA-256 不匹配都会立即终止;
3. 把私有 Node 放在插件自己的当前用户数据目录,不修改系统 PATH,不要求管理员权限;
4. 下载并校验首次安装器和发布公钥,再由安装器验证 Ed25519 清单签名与全部客户端发布物;下载源不是信任根,备用源不能绕过签名或哈希;
5. 返回一份 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 子进程启用环境代理,离线 npm 子进程不会继承 Node 专用代理开关。
运行时位于稳定的用户数据目录,启动器与 Node/NVM 具体版本解耦。Windows、Linux、macOS 的平台引导会复用兼容系统 Node;系统没有 Node 时,从固定精确版本的国内镜像下载并用 Node 官方 SHA-256 校验 x64/ARM64 制品,再放入插件自己的用户目录。项目发布物把国内 Gitea Release 作为首选静态源,并在签名清单中固定自建 HTTPS 备用源,首次安装与更新可在网络失败时自动回退;下载源不构成信任根,签名或哈希不符时立即终止。安装和更新优先调用系统 `curl`,因此直接复用宿主已验证的代理配置;Node `fetch` 只在没有 `curl` 时兜底。安装不修改 PATH,也不需要管理员权限。启动器固定使用安装时选定的 Node,更新和卸载沿用同一运行时;离线 npm 子进程不会继承 Node 专用代理开关。
安装器只有一条通用路径,输出稳定 `bridgePath`、通用 `skillPath`、标准 `mcpConfig`、`nodeRuntime` 和机器可读 `postInstall`,不识别 Agent 品牌,也不写任何 Agent 的私有配置。stdout 只承载最终 JSON,stderr 输出可安全展示的阶段进度;默认写入使用同步文件描述符,减少宿主结束进程时诊断文本尚未刷新。离线 npm 展开有 180 秒硬超时和明确错误,三个独立发布物并行下载。跨进程目录锁阻止两个 Agent 同时安装;锁记录 PID,外部终止后下一次安装可回收死亡锁及 `.staging-*` 残留。
+2 -2
View File
@@ -118,7 +118,7 @@ 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。
正式部署会先把同一批不可变发布文件上传到公开 Gitea Release,并把它写成每个 artifact 的首选下载地址;`approval.justconn.top` 静态目录作为签名清单固定的备用地址。平台引导、首次安装和后续更新都会在网络失败、超时或 404 时自动回退;任何来源一旦返回内容,Ed25519 签名或 SHA-256 不匹配都会立即终止。两个域名仍可能共享基础设施,如需机器级容灾,可再向 `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 或要求管理员权限。
@@ -128,7 +128,7 @@ 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 修改全局 `NODE_OPTIONS`。首次安装和后续更新优先使用系统 `curl` 获取清单与发布物,直接复用 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 与 `NO_PROXY`;只有系统没有 `curl` 时才使用 Node `fetch`。平台引导和稳定启动器只对登录及 Bridge 等联网 Node 子进程按能力添加 `--use-env-proxy`;npm 子进程会移除该 Node 专用开关和 `NODE_USE_ENV_PROXY`,但保留标准代理变量,由 npm 使用自己的代理实现。禁止用手工预装 `node_modules` 绕过这一流程。
安装器不会识别 Agent 品牌或直接修改其配置。所有 Agent 都使用返回的 `skillPath` 和标准 `mcpConfig`,按自身官方方式安装 Skill、注册并信任名为 `dingtalk-approval` 的本地 stdio MCP;不能热加载连接器时再新建会话或重启。
+2 -2
View File
@@ -6,13 +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 后能从备用源完成首次安装和更新,模拟主源返回错误哈希或签名时必须立即停止而不换源。
- 国内 Gitea 首选源与自建 HTTPS 备用目录的 `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 选项并继续保留标准代理变量,离线展开不得卡死。
- 在 Windows 代理沙箱中验证系统 `curl` 是安装和更新的首选下载器,并实际使用 `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.10",
"version": "1.1.0-rc.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.10",
"version": "1.1.0-rc.11",
"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.10",
"version": "1.1.0-rc.11",
"description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.",
"private": true,
"type": "module",
+117 -10
View File
@@ -103,6 +103,7 @@ interface CommandContext {
stdout: (value: string) => void;
stderr: (value: string) => void;
}
type DownloadContext = Pick<CommandContext, "env" | "platform" | "fetchFn">;
const LEGACY_BIN_NAMES = ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"];
const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/;
@@ -301,7 +302,7 @@ async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context
const publicKeyPath = resolvePublicKeyPath(paths, args.publicKey);
const publicKey = readFileSync(publicKeyPath);
reportInstallProgress(context, "2/6", "下载并验证签名发布清单");
const { manifest } = await downloadSignedManifest(context.fetchFn, manifestUrls, publicKey, (failed, next) => {
const { manifest } = await downloadSignedManifest(context, manifestUrls, publicKey, (failed, next) => {
reportInstallProgress(context, "2/6", `下载源不可用(${failed.origin}),切换至 ${next.origin}`);
});
const persistedManifestUrls = uniqueUrls([
@@ -317,13 +318,13 @@ 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, manifest.artifacts.client, clientArchive, (failed, next) => {
downloadArtifact(context, 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) => {
downloadArtifact(context, 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) => {
downloadArtifact(context, manifest.artifacts.publicKey, join(temporary, "release-public.pem"), (failed, next) => {
reportInstallProgress(context, "3/6", `发布公钥下载源不可用(${failed.origin}),切换至 ${next.origin}`);
}),
]);
@@ -887,7 +888,113 @@ class DownloadFailure extends Error {
}
}
async function download(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
async function download(context: DownloadContext, url: URL): Promise<Buffer> {
if (context.fetchFn === fetch) {
try {
return await downloadWithCurl(url, context.env, context.platform);
} catch (error) {
if (!(error instanceof NativeDownloaderUnavailable)) throw error;
}
}
return downloadWithFetch(context.fetchFn, url);
}
class NativeDownloaderUnavailable extends Error {}
export async function downloadWithCurl(
url: URL,
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): Promise<Buffer> {
const temporary = mkdtempSync(join(tmpdir(), "dingtalk-approval-download-"));
const target = join(temporary, "body");
try {
const command = platform === "win32" ? "curl.exe" : "curl";
const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, rejectResult) => {
const child = spawn(command, [
"--fail", "--location", "--silent", "--show-error",
"--proto", "=https", "--proto-redir", "=https", "--tlsv1.2",
"--connect-timeout", "10", "--max-time", String(Math.ceil(DOWNLOAD_TIMEOUT_MS / 1_000)),
"--max-redirs", "5", "--max-filesize", String(MAX_DOWNLOAD_BYTES),
"--output", target, "--write-out", "%{http_code}", url.toString(),
], {
env,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
const outputLimit = 256 * 1024;
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let settled = false;
let timedOut = false;
let outputExceeded = false;
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
callback();
};
const rejectOversizedOutput = () => {
if (outputExceeded) return;
outputExceeded = true;
child.kill("SIGTERM");
};
child.stdout.on("data", (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes > outputLimit) rejectOversizedOutput();
else stdout.push(chunk);
});
child.stderr.on("data", (chunk: Buffer) => {
stderrBytes += chunk.length;
if (stderrBytes > outputLimit) rejectOversizedOutput();
else stderr.push(chunk);
});
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, DOWNLOAD_TIMEOUT_MS + 5_000);
child.once("error", (error: NodeJS.ErrnoException) => {
settle(() => {
if (error.code === "ENOENT") rejectResult(new NativeDownloaderUnavailable("系统 curl 不可用"));
else rejectResult(new DownloadFailure(`curl 启动失败:${error.message}`, false, { cause: error }));
});
});
child.once("close", (status) => {
settle(() => {
if (timedOut) {
rejectResult(new DownloadFailure(`curl 下载超时(${Math.ceil(DOWNLOAD_TIMEOUT_MS / 1_000)} 秒):${url}`, true));
} else if (outputExceeded) {
rejectResult(new DownloadFailure(`curl 输出超过 256KB 安全限制:${url}`, false));
} else {
resolveResult({
status,
stdout: Buffer.concat(stdout).toString("utf8"),
stderr: Buffer.concat(stderr).toString("utf8"),
});
}
});
});
});
const curlCode = result.status;
const httpStatus = Number.parseInt(result.stdout.trim().slice(-3), 10);
if (curlCode !== 0) {
const networkFailure = [5, 6, 7, 18, 28, 35, 52, 55, 56].includes(curlCode ?? -1);
const fallbackAllowed = networkFailure || (curlCode === 22 && httpStatus === 404);
const detail = result.stderr.trim().slice(-2_000) || `curl 退出码 ${curlCode ?? "unknown"}`;
throw new DownloadFailure(`curl 下载失败${Number.isFinite(httpStatus) && httpStatus > 0 ? `(HTTP ${httpStatus})` : ""}:${url};${detail}`, fallbackAllowed);
}
if (!existsSync(target)) throw new Error(`curl 未生成下载文件:${url}`);
const size = statSync(target).size;
if (size > MAX_DOWNLOAD_BYTES) throw new Error(`下载文件超过 25MB 限制:${url}`);
return readFileSync(target);
} finally {
rmSync(temporary, { recursive: true, force: true });
}
}
async function downloadWithFetch(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("下载超时")), DOWNLOAD_TIMEOUT_MS);
timeout.unref();
@@ -927,7 +1034,7 @@ async function download(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
}
async function downloadSignedManifest(
fetchFn: typeof fetch,
context: DownloadContext,
urls: URL[],
publicKey: Buffer,
onFallback?: (failed: URL, next: URL) => void,
@@ -936,8 +1043,8 @@ async function downloadSignedManifest(
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));
const manifestBytes = await download(context, url);
const signatureBytes = await download(context, new URL("manifest.sig", url));
return { manifest: verifySignedManifest(manifestBytes, signatureBytes, publicKey, url.toString()), source: url };
} catch (error) {
lastError = error;
@@ -950,7 +1057,7 @@ async function downloadSignedManifest(
}
async function downloadArtifact(
fetchFn: typeof fetch,
context: DownloadContext,
artifact: ReleaseArtifact,
target: string,
onFallback?: (failed: URL, next: URL) => void,
@@ -960,7 +1067,7 @@ async function downloadArtifact(
for (let index = 0; index < urls.length; index += 1) {
const url = urls[index]!;
try {
const body = await download(fetchFn, url);
const body = await download(context, url);
if (sha256(body) !== artifact.sha256) throw new Error(`发布物 SHA-256 不匹配:${basename(url.pathname)}`);
writeAtomic(target, body, 0o600);
return body;
+120 -1
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,
nodeProxyArgs, npmChildEnvironment, npmFailureMessage, runClientInstaller, runNpm, verifySignedManifest,
downloadWithCurl, nodeProxyArgs, npmChildEnvironment, npmFailureMessage, runClientInstaller, runNpm, verifySignedManifest,
type ClientReleaseManifest,
} from "../src/client-installer.js";
@@ -130,6 +130,125 @@ test("runNpm passes the isolated proxy environment to the real child process", (
}
});
test("native curl downloader honors the proxy environment and never fails silently", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-client-curl-"));
try {
const fakeBin = join(root, "bin");
const curl = join(fakeBin, "curl.exe");
const log = join(root, "curl.log");
mkdirSync(fakeBin, { recursive: true });
writeFileSync(curl, `#!/bin/sh
target=''
url=''
while [ "$#" -gt 0 ]; do
case "$1" in
--output) target="$2"; shift 2 ;;
https://*) url="$1"; shift ;;
*) shift ;;
esac
done
printf '%s|%s\n' "$url" "${'${HTTPS_PROXY:-}'}" >> "$DING_CURL_LOG"
case "$url" in
*/ok) printf 'native-payload' > "$target"; printf '200'; exit 0 ;;
*/missing) printf '404'; exit 22 ;;
*) exit 1 ;;
esac
`);
chmodSync(curl, 0o700);
const env = {
PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`,
HTTPS_PROXY: "http://192.168.199.62:7890",
DING_CURL_LOG: log,
};
assert.equal((await downloadWithCurl(new URL("https://release.example.test/ok"), env, "win32")).toString(), "native-payload");
assert.match(readFileSync(log, "utf8"), /https:\/\/release\.example\.test\/ok\|http:\/\/192\.168\.199\.62:7890/);
await assert.rejects(downloadWithCurl(new URL("https://release.example.test/missing"), env, "win32"), /curl 下载失败.*HTTP 404/);
await assert.rejects(downloadWithCurl(new URL("https://release.example.test/silent"), env, "win32"), /curl 下载失败.*curl 退出码 1/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("installer uses native curl end to end in a proxied Windows-style environment", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-client-curl-install-"));
try {
const appRoot = join(root, "dingtalk-approval");
const fakeBin = join(root, "bin");
const fixtureDir = join(root, "downloads");
const curlLog = join(root, "curl.log");
mkdirSync(fakeBin, { recursive: true });
mkdirSync(fixtureDir, { recursive: true });
createFakeNpm(fakeBin);
const keys = generateKeyPairSync("ed25519");
const publicPem = Buffer.from(keys.publicKey.export({ type: "spki", format: "pem" }));
const keyId = releaseKeyId(publicPem);
const bodies = {
installer: Buffer.from("installer"), publicKey: publicPem, client: Buffer.from("fake-client-tgz"),
skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test\n---\n"),
};
const manifest = manifestFixture(keyId, publicPem, bodies);
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
const signature = signatureEnvelope(keyId, sign(null, manifestBytes, keys.privateKey));
writeFileSync(join(fixtureDir, "manifest.json"), manifestBytes);
writeFileSync(join(fixtureDir, "manifest.sig"), signature);
for (const [name, body] of Object.entries(bodies)) writeFileSync(join(fixtureDir, name), body);
const curlBody = `#!/bin/sh
target=''
url=''
while [ "$#" -gt 0 ]; do
case "$1" in
--output) target="$2"; shift 2 ;;
https://*) url="$1"; shift ;;
*) shift ;;
esac
done
printf '%s|%s\n' "$url" "${'${HTTPS_PROXY:-}'}" >> "$DING_CURL_LOG"
name="${'${url##*/}'}"
if [ -f "$DING_CURL_FIXTURE_DIR/$name" ]; then
cp "$DING_CURL_FIXTURE_DIR/$name" "$target"
printf '200'
exit 0
fi
printf '404'
exit 22
`;
for (const name of ["curl", "curl.exe"]) {
writeFileSync(join(fakeBin, name), curlBody);
chmodSync(join(fakeBin, name), 0o700);
}
const publicPath = join(root, "release-public.pem");
writeFileSync(publicPath, publicPem, { mode: 0o600 });
const env = {
PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`,
DINGTALK_APPROVAL_CLIENT_HOME: appRoot,
npm_execpath: process.platform === "win32"
? join(fakeBin, "node_modules", "npm", "bin", "npm-cli.js") : join(fakeBin, "fake-npm.mjs"),
HTTPS_PROXY: "http://192.168.199.62:7890",
NODE_OPTIONS: "--trace-warnings --use-env-proxy",
DING_CURL_FIXTURE_DIR: fixtureDir,
DING_CURL_LOG: curlLog,
};
const output: string[] = [];
const progress: string[] = [];
const code = await runClientInstaller([
"install", "--manifest-url", "https://release.example.test/manifest.json",
"--public-key", publicPath, "--app-root", appRoot, "--json",
], {
env, platform: process.platform, home: join(root, "home"), fetchFn: fetch,
stdout: (value) => { output.push(value); }, stderr: (value) => { progress.push(value); },
});
assert.equal(code, 0, progress.join(""));
assert.equal(JSON.parse(output.at(-1)!).status, "installed");
const calls = readFileSync(curlLog, "utf8");
assert.match(calls, /manifest\.json\|http:\/\/192\.168\.199\.62:7890/);
assert.match(calls, /client\|http:\/\/192\.168\.199\.62:7890/);
} 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");
+5 -1
View File
@@ -25,7 +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, /备用发布源[\s\S]*优先使用国内 Gitea 静态发布源/s);
assert.match(installer, /优先使用系统 `curl`[\s\S]*Node `fetch` 仅在系统没有 `curl` 时兜底/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 安装引导/);
@@ -82,6 +83,9 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.match(builder, /cpSync\(resolve\("skills\/dingtalk-approval"\).*recursive: true/s);
assert.doesNotMatch(builder, /codexPlugin|codex-plugin|\.codex-plugin|openai\.yaml/);
assert.doesNotMatch(builder, /dingtalk-workspace-cli|DWS_URL|DWS_SHA256/);
const deploy = readFileSync("deploy/install.sh", "utf8");
assert.match(deploy, /--download-base-url "\$\{gitea_release_base_url\}"[\s\S]*--download-mirror-base-url "\$\{public_download_base_url\}"/s);
});
test("public install page explicitly declares UTF-8 for browser rendering", () => {