diff --git a/deploy/install.sh b/deploy/install.sh index 9bb91ac..cecd999 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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" diff --git a/distribution/agent-install.template.md b/distribution/agent-install.template.md index c6b5563..0490806 100644 --- a/distribution/agent-install.template.md +++ b/distribution/agent-install.template.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 安装结果。 diff --git a/docs/architecture.md b/docs/architecture.md index e2a63c6..7895959 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,7 @@ 终端不再使用全局 npm prefix。无第三方依赖的首次安装器通过自有 HTTPS 到达用户机器,随后使用固定 Ed25519 公钥验证 schema v3 清单签名,并校验每个 artifact 的 SHA-256。客户端运行依赖被内置在签名 artifact 中,终端使用 npm 离线模式展开,不在安装时引入清单以外的代码。首次安装以 HTTPS 和安装说明中固定的安装器/公钥哈希建立信任;本地固定公钥保护后续更新链路。发布私钥不进入 Git、网关进程或公开目录。 -运行时位于稳定的用户数据目录,启动器与 Node/NVM 具体版本解耦。Windows、Linux、macOS 的平台引导会复用兼容系统 Node;系统没有 Node 时,从固定精确版本的国内镜像下载并用 Node 官方 SHA-256 校验 x64/ARM64 制品,再放入插件自己的用户目录。项目发布物同时声明主下载源和签名清单固定的国内备用源,首次安装与更新可在网络失败时自动回退;下载源不构成信任根,签名或哈希不符时立即终止。安装不修改 PATH,也不需要管理员权限。启动器固定使用安装时选定的 Node,更新和卸载沿用同一运行时;代理环境只为联网 Node 子进程启用环境代理,离线 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-*` 残留。 diff --git a/docs/deployment.md b/docs/deployment.md index 6d5a8eb..4be3571 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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;不能热加载连接器时再新建会话或重启。 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 6aabadf..a06998a 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -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 会话。 diff --git a/package-lock.json b/package-lock.json index c08c41b..759634a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 04585dd..e8068bf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client-installer.ts b/src/client-installer.ts index 750eb64..0fc1092 100644 --- a/src/client-installer.ts +++ b/src/client-installer.ts @@ -103,6 +103,7 @@ interface CommandContext { stdout: (value: string) => void; stderr: (value: string) => void; } +type DownloadContext = Pick; 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 { +async function download(context: DownloadContext, url: URL): Promise { + 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 { + 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 { 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 { } 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; diff --git a/test/client-installer.test.ts b/test/client-installer.test.ts index b7e8249..0820f38 100644 --- a/test/client-installer.test.ts +++ b/test/client-installer.test.ts @@ -10,7 +10,7 @@ import { basename, delimiter, dirname, join } from "node:path"; import test from "node:test"; import { acquireClientInstallLock, cleanupLegacyInstallations, clientLauncherBodies, clientPaths, releaseKeyId, - 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"); diff --git a/test/distribution.test.ts b/test/distribution.test.ts index 05795d1..3701a14 100644 --- a/test/distribution.test.ts +++ b/test/distribution.test.ts @@ -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", () => {