From 40c5021c8525f03b101aba17a42f7c9d1443ab24 Mon Sep 17 00:00:00 2001 From: Donald Date: Tue, 18 Aug 2026 14:16:17 +0000 Subject: [PATCH] fix: make client installation observable --- README.md | 2 + distribution/agent-install.template.md | 5 + distribution/bootstrap-posix.template.sh | 12 ++ distribution/bootstrap-windows.template.ps1 | 16 +++ docs/architecture.md | 4 +- docs/deployment.md | 4 +- docs/release-checklist.md | 1 + scripts/verify-release.mjs | 2 + src/client-installer.ts | 136 +++++++++++++++++--- test/client-installer.test.ts | 66 +++++++++- test/distribution.test.ts | 5 + test/portable-node-bootstrap.test.ts | 1 + 12 files changed, 233 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index f8bc76b..c36173d 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ https://approval.justconn.top/downloads/dingmcp/install.md 公开 `install.md` 是一份可直接交给 Agent 执行的逐步教程,为 Windows PowerShell 与 Linux/macOS 分别提供完整路径。它覆盖平台/架构检测、安装引导校验、国内下载回退、安装 JSON 验收、Skill/MCP 注册、连接器信任、首次登录、钉钉 OA 与薪福通双来源验证、更新、卸载和故障恢复。Windows 必须原样使用安装器返回的本地 `.cmd` stdio 配置;远程网关 URL 不能直接配置为 MCP,否则宿主可能显示 HTTP 302 或把本地 Bridge 误判为崩溃。 +首次安装的 Agent 工具调用应预留至少 10 分钟。平台引导和安装器持续把不含凭证的阶段进度写入 stderr,stdout 只保留最终 JSON;Windows 即使宿主默认 shell 是 Git Bash,也必须转由 PowerShell bootstrap 执行,不能直接调用内部 Node 安装器。客户端用跨进程锁阻止并发安装,外部终止后再次运行会回收已经死亡的锁和残留 staging;离线 npm 展开超过 180 秒时会返回明确错误,不会无限静默等待。 + Agent 必须在安装后立即展示 `postInstall.authChallenge.directUrl`,不得等到第一次审批查询,也不得声称链接已另行推送。用户授权后,Agent 通过 `approval_login` 确认登录并用 `source=all` 做一次双来源只读验证;首次成功认证响应还会在普通 `content.text` 和 `structuredContent.onboarding` 中返回一次性的简短使用示例,展示后不会重复,也不会自动执行任何写操作。 授权链接约十分钟有效。过期后不需要更新或重装:MCP 已加载时再次调用 `approval_login`;尚未加载时执行安装结果中的 `loginCommand`(等价于稳定客户端的 `login --json`)。两条入口复用同一本地 OAuth 会话,都会返回当前有效链接或自动生成新链接。 diff --git a/distribution/agent-install.template.md b/distribution/agent-install.template.md index ed2a0f3..2477770 100644 --- a/distribution/agent-install.template.md +++ b/distribution/agent-install.template.md @@ -49,8 +49,12 @@ 不要在浏览器中复制脚本内容,不要对 URL 解码、重新编码或手工拼接。必须先校验安装引导 SHA-256,再执行它。 +为执行平台安装引导的 Agent 工具调用设置至少 10 分钟超时。引导和安装器会把 `[dingtalk-approval]` 开头的阶段进度写入 stderr,把唯一的最终结果 JSON 写入 stdout;进度文本不是失败,也不能混入 JSON 解析。进程仍在运行时不得并发启动第二次安装。 + ### Windows(PowerShell) +无论当前 Agent 默认使用 PowerShell、CMD 还是 Git Bash,Windows 都必须调用普通 PowerShell 执行下面的完整流程。不得从 Git Bash 直接运行内部的 `node dingtalk-approval-installer.mjs ...` 命令;内部安装器不是用户安装入口。 + 在普通 PowerShell 中逐行运行: ```powershell @@ -196,6 +200,7 @@ $dingLogin | ConvertTo-Json -Depth 20 6. **连接器一直未出现**:确认已信任本地连接器并完整重启 Agent。不要通过反复重装刷新连接器。 7. **授权链接过期**:MCP 已加载就再次调用 `approval_login`;否则执行保存的 `loginCommand`。不要重装。 8. **Linux/macOS 缺少下载或校验命令**:必须先具备 `curl` 或 `wget`,以及 `sha256sum` 或 `shasum`;不能绕过 SHA-256 校验。系统 `tar` 必须支持 gzip 归档。 +9. **安装长时间无最终 JSON或被宿主终止**:先确认工具调用超时不少于 10 分钟,并查看 stderr 中最后一条 `[dingtalk-approval]` 阶段信息。不要改用内部安装器,也不要并发重试。原进程确认已经退出后,可重新执行同一平台引导;它会识别已经死亡的安装进程、清理残留 staging 并安全续装。若明确提示另一个安装仍在运行,应等待该进程结束。 ## 更新 diff --git a/distribution/bootstrap-posix.template.sh b/distribution/bootstrap-posix.template.sh index 3064209..baf71a2 100644 --- a/distribution/bootstrap-posix.template.sh +++ b/distribution/bootstrap-posix.template.sh @@ -11,6 +11,10 @@ ding_manifest_url='{{MANIFEST_URL}}' ding_temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/dingmcp-install.XXXXXX")" trap 'rm -rf "$ding_temp_dir"' EXIT HUP INT TERM +ding_progress() { + printf '[dingtalk-approval] %s\n' "$1" >&2 +} + ding_hash_file() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' @@ -68,6 +72,7 @@ ding_download_verified() { shift 2 ding_download_succeeded=0 for ding_download_url in "$@"; do + ding_progress "正在下载并校验:$ding_download_url" rm -f "$ding_download_target" if ding_fetch "$ding_download_url" "$ding_download_target"; then ding_download_succeeded=1 @@ -111,9 +116,11 @@ case "$ding_os-$ding_arch" in darwin-arm64) ding_node_file='{{NODE_DARWIN_ARM64_FILE}}'; ding_node_sha256='{{NODE_DARWIN_ARM64_SHA256}}' ;; esac +ding_progress "1/4 检查 $ding_os-$ding_arch 的 Node/npm 运行环境" ding_node_path='' if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && ding_test_node "$(command -v node)"; then ding_node_path="$(command -v node)" + ding_progress "2/4 复用兼容的系统 Node" else if [ -n "${DINGTALK_APPROVAL_CLIENT_HOME:-}" ]; then ding_client_root="$DINGTALK_APPROVAL_CLIENT_HOME" @@ -128,6 +135,7 @@ else ding_node_path="$ding_runtime_dir/bin/node" ding_npm_cli="$ding_runtime_dir/lib/node_modules/npm/bin/npm-cli.js" if ! ding_test_node "$ding_node_path" || [ ! -f "$ding_npm_cli" ]; then + ding_progress "2/4 系统 Node 不可用,准备插件私有 Node;下载可能需要一些时间" mkdir -p "$ding_runtime_parent" chmod 700 "$ding_client_root" "$ding_runtime_parent" 2>/dev/null || true ding_runtime_archive="$ding_temp_dir/$ding_node_file" @@ -148,12 +156,16 @@ else fi rm -rf "$ding_runtime_dir" mv "$ding_extracted_dir" "$ding_runtime_dir" + else + ding_progress "2/4 复用已安装的插件私有 Node" fi 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_progress '4/4 执行安装器;最终 JSON 输出到 stdout,阶段进度输出到 stderr' "$ding_node_path" "$ding_installer" install --manifest-url "$ding_manifest_url" --public-key "$ding_public_key" --json diff --git a/distribution/bootstrap-windows.template.ps1 b/distribution/bootstrap-windows.template.ps1 index 65cc0e1..53c5cbe 100644 --- a/distribution/bootstrap-windows.template.ps1 +++ b/distribution/bootstrap-windows.template.ps1 @@ -1,5 +1,8 @@ $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" +if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -ErrorAction SilentlyContinue) { + $PSNativeCommandUseErrorActionPreference = $false +} [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $dingSourceBases = @({{NODE_SOURCE_BASES_POWERSHELL}}) @@ -9,6 +12,10 @@ $dingPublicKeyUrl = "{{PUBLIC_KEY_URL}}" $dingPublicKeySha256 = "{{PUBLIC_KEY_SHA256}}" $dingManifestUrl = "{{MANIFEST_URL}}" +function Write-DingProgress([string] $dingMessage) { + [Console]::Error.WriteLine("[dingtalk-approval] $dingMessage") +} + function Get-DingSha256([string] $dingPath) { return (Get-FileHash -Algorithm SHA256 -LiteralPath $dingPath).Hash.ToLowerInvariant() } @@ -16,6 +23,7 @@ function Get-DingSha256([string] $dingPath) { function Save-DingDownload([string[]] $dingUrls, [string] $dingTarget, [string] $dingExpectedSha256) { $dingLastError = $null foreach ($dingUrl in $dingUrls) { + Write-DingProgress "正在下载并校验:$dingUrl" try { if (Test-Path -LiteralPath $dingTarget) { Remove-Item -LiteralPath $dingTarget -Force } Invoke-WebRequest -UseBasicParsing -Uri $dingUrl -OutFile $dingTarget -TimeoutSec 300 @@ -57,6 +65,7 @@ switch ($dingArchitecture) { default { throw "不支持的 Windows CPU 架构:$dingArchitecture" } } +Write-DingProgress "1/4 检查 Windows-$dingArchitecture 的 Node/npm 运行环境" $dingSystemNode = Get-Command node -ErrorAction SilentlyContinue $dingNodePath = if ($dingSystemNode) { $dingSystemNode.Source } else { $null } if (-not (Test-DingNode $dingNodePath $false)) { @@ -73,6 +82,7 @@ if (-not (Test-DingNode $dingNodePath $false)) { $dingRuntimeDir = Join-Path $dingRuntimeParent $dingRuntimeName $dingNodePath = Join-Path $dingRuntimeDir "node.exe" if (-not (Test-DingNode $dingNodePath $true)) { + Write-DingProgress "2/4 系统 Node 不可用,准备插件私有 Node;下载可能需要一些时间" New-Item -ItemType Directory -Path $dingRuntimeParent -Force | Out-Null $dingRuntimeTemp = Join-Path ([IO.Path]::GetTempPath()) ("dingmcp-node-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $dingRuntimeTemp -Force | Out-Null @@ -90,9 +100,14 @@ if (-not (Test-DingNode $dingNodePath $false)) { } finally { if (Test-Path -LiteralPath $dingRuntimeTemp) { Remove-Item -LiteralPath $dingRuntimeTemp -Recurse -Force } } + } else { + Write-DingProgress "2/4 复用已安装的插件私有 Node" } +} else { + Write-DingProgress "2/4 复用兼容的系统 Node" } +Write-DingProgress "3/4 下载并校验签名安装器与发布公钥" $dingInstallTemp = Join-Path ([IO.Path]::GetTempPath()) ("dingmcp-install-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $dingInstallTemp -Force | Out-Null try { @@ -100,6 +115,7 @@ try { $dingPublicKey = Join-Path $dingInstallTemp "release-signing-public.pem" Save-DingDownload @($dingInstallerUrl) $dingInstaller $dingInstallerSha256 Save-DingDownload @($dingPublicKeyUrl) $dingPublicKey $dingPublicKeySha256 + Write-DingProgress "4/4 执行安装器;最终 JSON 输出到 stdout,阶段进度输出到 stderr" $dingInstallOutput = & $dingNodePath $dingInstaller install --manifest-url $dingManifestUrl --public-key $dingPublicKey --json $dingInstallExit = $LASTEXITCODE if ($dingInstallExit -ne 0) { throw "钉钉审批安装器执行失败:$dingInstallOutput" } diff --git a/docs/architecture.md b/docs/architecture.md index 8b10a56..3bf2f7c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,7 +16,9 @@ 运行时位于稳定的用户数据目录,启动器与 Node/NVM 具体版本解耦。Windows、Linux、macOS 的平台引导会复用兼容系统 Node;系统没有 Node 时,从固定精确版本的国内镜像下载并用 Node 官方 SHA-256 校验 x64/ARM64 制品,再放入插件自己的用户目录。下载源不构成信任根,哈希不符时立即终止;安装不修改 PATH,也不需要管理员权限。启动器固定使用安装时选定的 Node,更新和卸载沿用同一运行时。 -安装器只有一条通用路径,输出稳定 `bridgePath`、通用 `skillPath`、标准 `mcpConfig`、`nodeRuntime` 和机器可读 `postInstall`,不识别 Agent 品牌,也不写任何 Agent 的私有配置。`postInstall` 通过已安装的 Bridge 和正式网关主动检查登录;它只返回认证状态、短期授权链接与下一步工具提示,不返回 Token 或 device secret。网关不可用只会把状态标为 `unavailable`,不会回滚已验证的客户端安装。 +安装器只有一条通用路径,输出稳定 `bridgePath`、通用 `skillPath`、标准 `mcpConfig`、`nodeRuntime` 和机器可读 `postInstall`,不识别 Agent 品牌,也不写任何 Agent 的私有配置。stdout 只承载最终 JSON,stderr 输出可安全展示的阶段进度;默认写入使用同步文件描述符,减少宿主结束进程时诊断文本尚未刷新。离线 npm 展开有 180 秒硬超时和明确错误,三个独立发布物并行下载。跨进程目录锁阻止两个 Agent 同时安装;锁记录 PID,外部终止后下一次安装可回收死亡锁及 `.staging-*` 残留。 + +`postInstall` 通过已安装的 Bridge 和正式网关主动检查登录;它只返回认证状态、短期授权链接与下一步工具提示,不返回 Token 或 device secret。网关不可用只会把状态标为 `unavailable`,不会回滚已验证的客户端安装。 连接器信任和重启属于 Agent 自身的安全边界,安装器不会绕过。卸载默认删除运行时、Skill、安装记录和 OAuth 会话;Agent 拥有的连接器条目由其官方入口移除。旧 npm 入口只在名称和目标包都精确匹配时清理。 diff --git a/docs/deployment.md b/docs/deployment.md index 86e30b6..557e64c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -122,7 +122,9 @@ Agent 先校验当前平台引导脚本的固定 SHA-256,再执行该引导。 Windows 安装说明至少要覆盖 `Invoke-WebRequest` 下载、`Get-FileHash` 校验、JSON 解析、`.cmd` stdio 配置、路径空格、连接器信任与完整重启、独立登录续期、`doctor --json`、更新和卸载。Linux/macOS 必须同时支持 `curl`/`wget` 和 `sha256sum`/`shasum`,并使用系统 gzip tar 解压私有 Node。教程必须明确禁止把网关 HTTPS URL 直接注册为 MCP。 -平台引导下载 Node 时使用 300 秒超时,Node 制品不经过本项目的小型网关,避免占用网关带宽。引导只接受固定官方哈希,且把私有 Node 与 npm 保存在客户端专用目录。进入 Node 安装器后,每个项目发布物仍使用 30 秒超时和 25 MB 流式硬上限,不会先把无界响应完整缓冲到内存。安装器优先由当前 Node 直接启动相邻的 `npm-cli.js`,不会依赖易受 PATH、shell 或 Node 安全加固影响的隐式 npm shim。 +平台引导下载 Node 时使用 300 秒超时,Node 制品不经过本项目的小型网关,避免占用网关带宽。引导只接受固定官方哈希,且把私有 Node 与 npm 保存在客户端专用目录。进入 Node 安装器后,每个项目发布物仍使用 30 秒超时和 25 MB 流式硬上限,不会先把无界响应完整缓冲到内存;互不依赖的客户端包、Skill 和公钥并行下载。 + +平台引导与安装器把阶段进度写入 stderr,stdout 只保留最终 JSON。Agent 执行首次安装时必须给予至少 10 分钟工具超时;Windows 不论默认 shell 是什么都必须使用 PowerShell bootstrap,禁止直接调用内部 Node 安装器。安装器优先由当前 Node 直接启动相邻的 `npm-cli.js`,不会依赖易受 PATH、shell 或 Node 安全加固影响的隐式 npm shim;离线展开限时 180 秒。跨进程安装锁禁止并发写入,同一 PID 存活时要求等待,进程已死亡时自动回收锁并清理残留 staging。 安装器不会识别 Agent 品牌或直接修改其配置。所有 Agent 都使用返回的 `skillPath` 和标准 `mcpConfig`,按自身官方方式安装 Skill、注册并信任名为 `dingtalk-approval` 的本地 stdio MCP;不能热加载连接器时再新建会话或重启。 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index d24b8b9..65ec6a8 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -10,6 +10,7 @@ - 从新生成的公开 `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,不得直接运行内部安装器。 - 验证安装结果返回 `loginCommand`:未加载 MCP 时执行 `login --json` 能复用现有运行时生成或检查登录链接,不下载发布物;链接过期后生成新链接,已登录时不再返回挑战,未安装时明确失败。 - 首次安装不等待业务查询即产生一条可传递链接;挑战阶段 onboarding 仍为 pending,授权后的首次成功响应在普通文本中展示一次示例,此后不重复。 - 授权后钉钉 OA 与薪福通均为 `ok`,网关重启后登录状态仍可恢复;登录准备暂时失败不会回滚安装或删除原 OAuth 会话。 diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index cb75a67..a650e17 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -81,6 +81,7 @@ try { if (install.includes("{{") || !install.includes(`版本:\`${version}\``) || !install.includes("唯一通用安装入口") || !install.includes("postInstall") || !install.includes("onboarding.firstRun=true") || !install.includes("系统无需预装 Node.js") + || !install.includes("至少 10 分钟超时") || !install.includes("Git Bash") || /--host|WorkBuddy|Codex|Claude/.test(install)) { throw new Error("install.md 版本、占位符或通用安装引导不正确"); } @@ -89,6 +90,7 @@ try { const posixBootstrap = readFileSync(posixBootstrapPath, "utf8"); if (windowsBootstrap.includes("{{") || posixBootstrap.includes("{{") || !windowsBootstrap.includes("Save-DingDownload") || !windowsBootstrap.includes("Expand-Archive") + || !windowsBootstrap.includes("Write-DingProgress") || !posixBootstrap.includes("ding_progress") || !posixBootstrap.startsWith("#!/bin/sh\nset -eu\n") || !posixBootstrap.includes("tar -xzf") || /latest-v|npm install -g/.test(`${windowsBootstrap}\n${posixBootstrap}`)) { throw new Error("无 Node 安装引导无效或仍包含不安全的动态版本来源"); diff --git a/src/client-installer.ts b/src/client-installer.ts index 0353cc0..ce6da98 100644 --- a/src/client-installer.ts +++ b/src/client-installer.ts @@ -2,7 +2,7 @@ import { createHash, createPublicKey, randomUUID, verify as verifySignature } from "node:crypto"; import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, - readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, + readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync, } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path"; @@ -106,6 +106,9 @@ const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/; const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; const DOWNLOAD_TIMEOUT_MS = 30_000; const MANIFEST_MAX_AGE_MS = 180 * 24 * 60 * 60_000; +const NPM_CHECK_TIMEOUT_MS = 15_000; +const NPM_INSTALL_TIMEOUT_MS = 180_000; +const INSTALL_LOCK_STALE_MS = 15 * 60_000; export function clientPaths( env: NodeJS.ProcessEnv = process.env, @@ -177,8 +180,8 @@ export async function runClientInstaller( platform: overrides.platform ?? process.platform, home: overrides.home ?? homedir(), fetchFn: overrides.fetchFn ?? fetch, - stdout: overrides.stdout ?? ((value) => { process.stdout.write(value); }), - stderr: overrides.stderr ?? ((value) => { process.stderr.write(value); }), + stdout: overrides.stdout ?? ((value) => { writeSync(1, value); }), + stderr: overrides.stderr ?? ((value) => { writeSync(2, value); }), }; try { const args = parseClientArgs(context.argv); @@ -197,8 +200,10 @@ export async function runClientInstaller( return 0; } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (context.argv.includes("--json") || context.argv.includes("mcp-json")) context.stdout(`${JSON.stringify({ ok: false, error: message })}\n`); - else context.stderr(`${message}\n`); + if (context.argv.includes("--json") || context.argv.includes("mcp-json")) { + context.stderr(`[dingtalk-approval] 失败 ${message}\n`); + context.stdout(`${JSON.stringify({ ok: false, error: message })}\n`); + } else context.stderr(`${message}\n`); return 1; } } @@ -266,6 +271,18 @@ function parseClientArgs(argv: string[]): ParsedArgs { async function installClient(paths: ClientPaths, args: ParsedArgs, context: CommandContext): Promise> { requireNodeVersion(); + mkdirPrivate(paths.root); + reportInstallProgress(context, "启动", "正在取得单实例安装锁"); + const releaseLock = acquireClientInstallLock(paths.root); + try { + return await installClientLocked(paths, args, context); + } finally { + releaseLock(); + } +} + +async function installClientLocked(paths: ClientPaths, args: ParsedArgs, context: CommandContext): Promise> { + reportInstallProgress(context, "1/6", "检查 Node、npm 与本地安装状态"); const npmCheck = checkNpm(context); if (!npmCheck.ok) throw new Error(`未找到可用的 npm:${npmCheck.message}`); const previous = readState(paths.stateFile); @@ -273,6 +290,7 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm if (!manifestUrl.pathname.endsWith("/manifest.json")) throw new Error("manifest URL 必须以 /manifest.json 结尾"); 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)), @@ -283,38 +301,45 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm } const temporary = mkdtempSync(join(tmpdir(), "dingtalk-approval-install-")); try { + reportInstallProgress(context, "3/6", `下载并校验 ${manifest.version} 客户端发布物`); const clientArchive = join(temporary, "client.tgz"); - await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.client, clientArchive); - const skillBody = await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.skill, join(temporary, "SKILL.md")); - const publicArtifact = await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.publicKey, join(temporary, "release-public.pem")); + 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")), + ]); if (releaseKeyId(publicArtifact) !== manifest.signing.keyId || !publicArtifact.equals(publicKey)) { throw new Error("发布公钥与本机信任的公钥不一致,拒绝安装"); } - mkdirPrivate(paths.root); mkdirPrivate(paths.versionsDir); + cleanupInterruptedInstallStaging(paths.versionsDir); const versionName = `${safeName(manifest.version)}-${manifest.artifacts.client.sha256.slice(0, 12)}`; const versionDir = join(paths.versionsDir, versionName); const clientEntry = join(versionDir, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js"); const cliEntry = join(versionDir, "node_modules", "dingtalk-approval-client", "dist", "src", "client-installer.js"); if (!existsSync(clientEntry) || !existsSync(cliEntry)) { + reportInstallProgress(context, "4/6", "离线展开客户端运行时;首次安装可能需要一些时间"); if (existsSync(versionDir)) rmSync(versionDir, { recursive: true, force: true }); const staging = join(paths.versionsDir, `.staging-${process.pid}-${randomUUID()}`); mkdirPrivate(staging); const installed = runNpm( context, ["install", "--prefix", staging, "--offline", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", clientArchive], - join(temporary, "npm-cache"), + { cache: join(temporary, "npm-cache"), timeoutMs: NPM_INSTALL_TIMEOUT_MS }, ); - if (installed.status !== 0) { + if (installed.error || installed.status !== 0) { rmSync(staging, { recursive: true, force: true }); - throw new Error(`客户端运行时安装失败:${(installed.stderr || installed.stdout || "npm 执行失败").trim()}`); + throw new Error(`客户端运行时安装失败:${npmFailureMessage(installed, NPM_INSTALL_TIMEOUT_MS)}`); } if (!existsSync(join(staging, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js"))) { rmSync(staging, { recursive: true, force: true }); throw new Error("客户端包缺少 Bridge 入口"); } renameSync(staging, versionDir); + } else { + reportInstallProgress(context, "4/6", "复用已校验的本地客户端运行时"); } + reportInstallProgress(context, "5/6", "写入稳定启动器、Skill 与安装状态"); installBundledSkill(paths, join(versionDir, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval"), skillBody); writeAtomic(paths.publicKeyFile, publicArtifact, 0o600); const nodeRuntime = activeNodeRuntime(paths); @@ -337,7 +362,9 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm const legacyCleanupFailed: string[] = []; const legacyRemoved = cleanupLegacyInstallations(context.env, context.platform, context.home, legacyCleanupFailed); cleanupOldVersions(paths, versionDir); + reportInstallProgress(context, "6/6", "连接网关并准备登录状态"); const postInstall = attemptLogin(clientEntry, manifest.gatewayUrl, paths, context); + reportInstallProgress(context, "完成", "客户端安装成功,正在输出最终 JSON"); return installResult(paths, state, legacyRemoved, legacyCleanupFailed, postInstall); } finally { rmSync(temporary, { recursive: true, force: true }); @@ -415,26 +442,103 @@ function doctor(paths: ClientPaths, context: CommandContext): Record): { ok: boolean; value?: string; message?: string } { - const result = runNpm(context, ["--version"]); - if (result.error) return { ok: false, message: result.error.message }; + const result = runNpm(context, ["--version"], { timeoutMs: NPM_CHECK_TIMEOUT_MS }); + if (result.error) return { ok: false, message: npmFailureMessage(result, NPM_CHECK_TIMEOUT_MS) }; if (result.status !== 0) return { ok: false, message: (result.stderr || result.stdout || `退出码 ${result.status}`).trim() }; const version = result.stdout.trim(); return version ? { ok: true, value: version } : { ok: false, message: "npm --version 未返回版本号" }; } -function runNpm(context: Pick, args: string[], cache?: string) { +export function runNpm( + context: Pick, + args: string[], + options: { cache?: string; timeoutMs: number }, +) { const invocation = npmInvocation(context, args); return spawnSync(invocation.command, invocation.args, { encoding: "utf8", + timeout: options.timeoutMs, + killSignal: "SIGTERM", + maxBuffer: 2 * 1024 * 1024, env: { ...context.env, - ...(cache ? { npm_config_cache: cache } : {}), + ...(options.cache ? { npm_config_cache: options.cache } : {}), npm_config_update_notifier: "false", }, windowsHide: true, }); } +export function npmFailureMessage( + result: { error?: Error; signal: NodeJS.Signals | null; status: number | null; stderr: string; stdout: string }, + timeoutMs: number, +): string { + const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code; + if (errorCode === "ETIMEDOUT") return `npm 超过 ${Math.ceil(timeoutMs / 1000)} 秒仍未完成,已安全终止;请检查磁盘、杀毒软件或宿主工具超时设置`; + if (result.error) return result.error.message; + if (result.signal) return `npm 被信号 ${result.signal} 终止`; + const details = (result.stderr || result.stdout).trim(); + return details ? details.slice(-4_000) : `npm 退出码 ${result.status ?? "unknown"},未返回诊断文本`; +} + +function reportInstallProgress(context: Pick, stage: string, message: string): void { + context.stderr(`[dingtalk-approval] ${stage} ${message}\n`); +} + +export function acquireClientInstallLock(root: string): () => void { + const lockDir = join(root, ".install-lock"); + const ownerFile = join(lockDir, "owner.json"); + const token = randomUUID(); + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + mkdirSync(lockDir, { mode: 0o700 }); + if (process.platform !== "win32") chmodSync(lockDir, 0o700); + try { + writeAtomic(ownerFile, Buffer.from(`${JSON.stringify({ pid: process.pid, token, startedAt: new Date().toISOString() })}\n`), 0o600); + } catch (error) { + rmSync(lockDir, { recursive: true, force: true }); + throw error; + } + return () => { + try { + const owner = JSON.parse(readFileSync(ownerFile, "utf8")) as { token?: unknown }; + if (owner.token === token) rmSync(lockDir, { recursive: true, force: true }); + } catch { /* another process already recovered or removed the lock */ } + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (installLockIsActive(lockDir)) { + throw new Error("另一个安装或更新进程仍在运行;请等待它结束,不要并发重试"); + } + rmSync(lockDir, { recursive: true, force: true }); + } + } + throw new Error("无法恢复上次被中断的安装锁,请稍后重试"); +} + +function installLockIsActive(lockDir: string): boolean { + let recent = true; + try { recent = Date.now() - statSync(lockDir).mtimeMs < INSTALL_LOCK_STALE_MS; } + catch { return false; } + try { + const owner = JSON.parse(readFileSync(join(lockDir, "owner.json"), "utf8")) as { pid?: unknown }; + if (Number.isSafeInteger(owner.pid) && Number(owner.pid) > 0) return recent && processIsAlive(Number(owner.pid)); + } catch { /* fall back to the lock directory age */ } + return recent; +} + +function processIsAlive(pid: number): boolean { + try { process.kill(pid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; } +} + +function cleanupInterruptedInstallStaging(versionsDir: string): void { + for (const entry of readdirSync(versionsDir)) { + if (!entry.startsWith(".staging-")) continue; + rmSync(join(versionsDir, entry), { recursive: true, force: true }); + } +} + function npmInvocation(context: Pick, args: string[]): { command: string; args: string[] } { const pathSeparator = context.platform === "win32" ? ";" : ":"; const candidates = [ diff --git a/test/client-installer.test.ts b/test/client-installer.test.ts index aae41dc..53b299c 100644 --- a/test/client-installer.test.ts +++ b/test/client-installer.test.ts @@ -1,13 +1,16 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { createHash, createPublicKey, generateKeyPairSync, sign } from "node:crypto"; import { - chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, symlinkSync, writeFileSync, + chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, + symlinkSync, utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { basename, delimiter, dirname, join } from "node:path"; import test from "node:test"; import { - cleanupLegacyInstallations, clientLauncherBodies, clientPaths, releaseKeyId, runClientInstaller, verifySignedManifest, + acquireClientInstallLock, cleanupLegacyInstallations, clientLauncherBodies, clientPaths, releaseKeyId, + npmFailureMessage, runClientInstaller, runNpm, verifySignedManifest, type ClientReleaseManifest, } from "../src/client-installer.js"; @@ -31,6 +34,58 @@ test("client launchers pin the selected Node executable on Windows and POSIX", ( assert.throws(() => clientLauncherBodies("win32", "C:\\bad\nnode.exe"), /不支持的字符/); }); +test("client installer lock rejects concurrency and recovers a dead owner", () => { + const root = mkdtempSync(join(tmpdir(), "ding-client-lock-")); + const appRoot = join(root, "dingtalk-approval"); + mkdirSync(appRoot, { recursive: true }); + const release = acquireClientInstallLock(appRoot); + assert.throws(() => acquireClientInstallLock(appRoot), /另一个安装或更新进程仍在运行/); + release(); + + const lockDir = join(appRoot, ".install-lock"); + mkdirSync(lockDir, { recursive: true }); + writeFileSync(join(lockDir, "owner.json"), JSON.stringify({ pid: 2_147_483_647, token: "dead" })); + const recovered = acquireClientInstallLock(appRoot); + assert.equal(existsSync(join(lockDir, "owner.json")), true); + recovered(); + assert.equal(existsSync(lockDir), false); + + mkdirSync(lockDir, { recursive: true }); + writeFileSync(join(lockDir, "owner.json"), JSON.stringify({ pid: process.pid, token: "expired" })); + const expiredAt = new Date(Date.now() - 16 * 60_000); + utimesSync(lockDir, expiredAt, expiredAt); + const expired = acquireClientInstallLock(appRoot); + expired(); + assert.equal(existsSync(lockDir), false, "an implausibly old lock cannot block installation forever after PID reuse"); + rmSync(root, { recursive: true, force: true }); +}); + +test("direct JSON entrypoint never silently handles a controlled failure", () => { + const result = spawnSync(process.execPath, ["dist/src/client-installer.js", "unsupported", "--json"], { encoding: "utf8" }); + assert.equal(result.status, 1); + assert.match(result.stderr, /^\[dingtalk-approval\] 失败 .*unsupported/); + const error = JSON.parse(result.stdout); + assert.equal(error.ok, false); + assert.match(error.error, /unsupported/); +}); + +test("npm subprocess timeout is bounded and returns an actionable diagnostic", () => { + const root = mkdtempSync(join(tmpdir(), "ding-client-npm-timeout-")); + try { + const sleepyNpm = join(root, "sleepy-npm.mjs"); + writeFileSync(sleepyNpm, "setTimeout(() => {}, 10_000);\n"); + const result = runNpm( + { env: { npm_execpath: sleepyNpm }, platform: process.platform }, + ["--version"], + { timeoutMs: 50 }, + ); + assert.equal((result.error as NodeJS.ErrnoException | undefined)?.code, "ETIMEDOUT"); + assert.match(npmFailureMessage(result, 50), /npm 超过 1 秒仍未完成.*宿主工具超时设置/); + } 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"); @@ -127,9 +182,10 @@ test("generic installer preserves identity across safe updates, rollback, downgr : join(fakeBin, "fake-npm.mjs"), }; const output: string[] = []; + const progress: string[] = []; const commandContext = { env, platform: process.platform, home: join(root, "home"), fetchFn: fetchFn as typeof fetch, - stdout: (value: string) => { output.push(value); }, stderr: (value: string) => { output.push(value); }, + stdout: (value: string) => { output.push(value); }, stderr: (value: string) => { progress.push(value); }, }; const installed = await runClientInstaller([ "install", "--manifest-url", "https://approval.example.test/downloads/manifest.json", @@ -137,6 +193,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr ], commandContext); assert.equal(installed, 0); const installOutput = JSON.parse(output.pop()!); + assert.match(progress.join(""), /启动 正在取得单实例安装锁[\s\S]*1\/6[\s\S]*6\/6[\s\S]*完成 客户端安装成功/); assert.equal(installOutput.restartRequired, undefined); assert.match(installOutput.nextSteps.join(" "), /当前 Agent.*skillPath.*stdio MCP/); assert.match(installOutput.nextSteps.join(" "), /Windows.*\.cmd.*禁止把网关 URL 配成 MCP/s); @@ -217,6 +274,8 @@ test("generic installer preserves identity across safe updates, rollback, downgr assert.equal(health.checks.find((check: { name: string }) => check.name === "nodeRuntime").ok, true); output.length = 0; + const interruptedStaging = join(paths.versionsDir, ".staging-interrupted-test"); + mkdirSync(interruptedStaging, { recursive: true }); 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"); @@ -227,6 +286,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr assert.equal(sameVersionState.runtimeDir, initialRuntime, "same-version update reuses the complete runtime"); 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"); output.length = 0; assert.equal(await runClientInstaller(["update", "--host", "workbuddy", "--app-root", appRoot, "--json"], commandContext), 1); diff --git a/test/distribution.test.ts b/test/distribution.test.ts index 0f41f12..017507b 100644 --- a/test/distribution.test.ts +++ b/test/distribution.test.ts @@ -20,6 +20,9 @@ test("customer distribution is a generic single-login gateway bridge", () => { assert.match(installer, /onboarding\.firstRun=true.*简短示例.*不要自动执行/s); assert.match(installer, /Windows(PowerShell)/); assert.match(installer, /Invoke-WebRequest[\s\S]*Get-FileHash[\s\S]*ConvertFrom-Json/); + assert.match(installer, /至少 10 分钟超时[\s\S]*stderr[\s\S]*stdout/); + assert.match(installer, /Git Bash[\s\S]*不得.*内部.*installer\.mjs/s); + assert.match(installer, /安装长时间无最终 JSON[\s\S]*残留 staging/s); assert.match(installer, /系统无需预装 Node\.js/); assert.match(installer, /阿里云精确版本.*npmmirror 精确版本.*Node 官方精确版本/s); assert.match(installer, /BOOTSTRAP_WINDOWS_URL|Windows 安装引导/); @@ -46,9 +49,11 @@ test("customer distribution is a generic single-login gateway bridge", () => { const posixBootstrap = readFileSync("distribution/bootstrap-posix.template.sh", "utf8"); 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(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.equal(spawnSync("sh", ["-n", "distribution/bootstrap-posix.template.sh"]).status, 0); assert.doesNotMatch(`${windowsBootstrap}\n${posixBootstrap}`, /latest-v|npm install -g/); diff --git a/test/portable-node-bootstrap.test.ts b/test/portable-node-bootstrap.test.ts index 0f7b473..f9d7051 100644 --- a/test/portable-node-bootstrap.test.ts +++ b/test/portable-node-bootstrap.test.ts @@ -20,6 +20,7 @@ test("POSIX bootstrap installs a private Node and falls back after a network fai const result = runPosixFixture(fixture, "fallback"); assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(result.stdout.trim()), { ok: true, status: "installed", managedNode: true }); + assert.match(result.stderr, /\[dingtalk-approval\] 1\/4[\s\S]*2\/4[\s\S]*3\/4[\s\S]*4\/4/); assert.equal(existsSync(join(fixture.clientRoot, "node", RUNTIME_NAME, "bin", "node")), true); const calls = readFileSync(fixture.callLog, "utf8").trim().split(/\r?\n/); assert.deepEqual(calls.slice(0, 2), [