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
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:
+17
-17
@@ -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"
|
||||
|
||||
@@ -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 安装结果。
|
||||
|
||||
@@ -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
@@ -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;不能热加载连接器时再新建会话或重启。
|
||||
|
||||
|
||||
@@ -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 会话。
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user