From 7e2a01c913e3ab27365bb6b631311b821784f93f Mon Sep 17 00:00:00 2001 From: Donald Date: Wed, 19 Aug 2026 01:12:13 +0000 Subject: [PATCH] fix: make Windows portable Node bootstrap reliable --- distribution/agent-compat-install.template.md | 4 ++++ distribution/bootstrap-windows.template.ps1 | 6 +++++- docs/architecture.md | 2 +- docs/release-checklist.md | 2 +- scripts/verify-release.mjs | 3 +++ test/distribution.test.ts | 3 +++ 6 files changed, 17 insertions(+), 3 deletions(-) diff --git a/distribution/agent-compat-install.template.md b/distribution/agent-compat-install.template.md index 7838f12..12ab46a 100644 --- a/distribution/agent-compat-install.template.md +++ b/distribution/agent-compat-install.template.md @@ -68,6 +68,10 @@ 在普通 PowerShell 中逐行运行: ```powershell +$dingUtf8 = New-Object System.Text.UTF8Encoding($false) +[Console]::InputEncoding = $dingUtf8 +[Console]::OutputEncoding = $dingUtf8 +$OutputEncoding = $dingUtf8 $dingBootstrapDir = Join-Path ([System.IO.Path]::GetTempPath()) ("dingmcp-bootstrap-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $dingBootstrapDir -Force | Out-Null $dingBootstrap = Join-Path $dingBootstrapDir "dingtalk-approval-bootstrap.ps1" diff --git a/distribution/bootstrap-windows.template.ps1 b/distribution/bootstrap-windows.template.ps1 index 80f89b8..2571f2f 100644 --- a/distribution/bootstrap-windows.template.ps1 +++ b/distribution/bootstrap-windows.template.ps1 @@ -1,5 +1,9 @@ $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" +$dingUtf8 = New-Object System.Text.UTF8Encoding($false) +[Console]::InputEncoding = $dingUtf8 +[Console]::OutputEncoding = $dingUtf8 +$OutputEncoding = $dingUtf8 if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -ErrorAction SilentlyContinue) { $PSNativeCommandUseErrorActionPreference = $false } @@ -92,7 +96,7 @@ if (-not (Test-DingNode $dingNodePath $false)) { Join-Path $env:LOCALAPPDATA "DingTalkApproval" } $dingRuntimeParent = Join-Path $dingClientRoot "node" - $dingRuntimeName = [IO.Path]::GetFileNameWithoutExtension([IO.Path]::GetFileNameWithoutExtension($dingNodeFile)) + $dingRuntimeName = [IO.Path]::GetFileNameWithoutExtension($dingNodeFile) $dingRuntimeDir = Join-Path $dingRuntimeParent $dingRuntimeName $dingNodePath = Join-Path $dingRuntimeDir "node.exe" if (-not (Test-DingNode $dingNodePath $true)) { diff --git a/docs/architecture.md b/docs/architecture.md index 4e18161..45e2a2b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,7 +16,7 @@ 兼容发行物只为不支持远程 MCP/OAuth 的宿主保留。它不使用全局 npm prefix:首次安装器以固定 Ed25519 公钥验证 schema v3 清单和 artifact SHA-256,并把内置依赖展开到稳定的用户目录。Windows/Linux/macOS 引导可复用系统 Node 或安装经官方哈希校验的固定私有 Node,不改 PATH、不要求管理员权限。发布物优先使用国内 Gitea 和 Node 固定版本源,自建 HTTPS/官方源作为受同一签名或哈希保护的备用。 -兼容 Windows 引导以 UTF-8 BOM 发布,确保 Windows PowerShell 5.1 正确解析中文;传给 `node -e` 的脚本使用 PowerShell 不会吞掉的引号组合。下载优先调用 `curl.exe` 复用代理,并设置 10 秒连接、20 秒低速和 90 秒总时限;`Invoke-WebRequest` 只在没有 curl 时兜底。安装器仍输出稳定 `bridgePath`、`skillPath`、`mcpConfig` 和 `postInstall`,保留原子安装、跨进程锁、明确诊断、更新与卸载能力。 +兼容 Windows 引导以 UTF-8 BOM 发布,确保 Windows PowerShell 5.1 正确解析中文,并显式把控制台和原生程序管道固定为 UTF-8,避免 Node 返回的 JSON/诊断被按系统代码页解码。传给 `node -e` 的脚本使用 PowerShell 不会吞掉的引号组合。Windows ZIP 目录名只去掉一次 `.zip`,避免把 Node 语义版本中的 `.2-win-x64` 误当作第二层扩展名。下载优先调用 `curl.exe` 复用代理,并设置 10 秒连接、20 秒低速和 90 秒总时限;`Invoke-WebRequest` 只在没有 curl 时兜底。安装器仍输出稳定 `bridgePath`、`skillPath`、`mcpConfig` 和 `postInstall`,保留原子安装、跨进程锁、明确诊断、更新与卸载能力。 ## Agent 领域接口 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index aa903bc..311806c 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -12,7 +12,7 @@ - 远程 OAuth 后可发现全部 12 个业务工具,`approval_list_todos(source=all, limit=1)` 中钉钉 OA 与薪福通均为 `ok`;网关重启后宿主能刷新或重新授权,用户身份仍由服务端 Token 决定。 - `install.md` 只有在能力缺失时才链接 `compat-install.md`。用至少一个只支持 stdio 的宿主验证兼容安装可发现 `approval_login` 加 12 个业务工具,且不产生第二套用户身份。 - 在未安装 Node 的干净 Windows x64/ARM64、Linux x64/ARM64、macOS Intel/Apple Silicon 环境验证兼容引导。确认固定版本、国内镜像回退、哈希失败即停止、私有 Node 不改 PATH;至少每类操作系统一次端到端,其余架构由 CI/制品校验覆盖。 -- 在 Windows PowerShell 5.1 验证生成的 `.ps1` 具有 UTF-8 BOM,中文可解析,Node 版本和代理能力探测不会因原生程序参数引号被吞而失败。Windows 下载优先 `curl.exe` 并继承 `HTTPS_PROXY`;连接 10 秒、低速 20 秒或总计 90 秒触发明确失败换源,不得出现 300 秒静默等待。 +- 在 Windows PowerShell 5.1 验证生成的 `.ps1` 具有 UTF-8 BOM,中文可解析,Node 版本和代理能力探测不会因原生程序参数引号被吞而失败;确认 `.zip` 仅去除一次扩展名,解压目录与 `node-v-win-` 完全一致。Windows 下载优先 `curl.exe` 并继承 `HTTPS_PROXY`;连接 10 秒、低速 20 秒或总计 90 秒触发明确失败换源,不得出现 300 秒静默等待。 - 兼容安装继续验证 stdout 只有最终 JSON、stderr 有阶段进度;模拟离线 npm 超时、外部终止、死亡锁、staging 和并发安装,确认明确失败、安全恢复和零并发写入。父进程含 Node 代理选项时,npm 子进程不得继承 `--use-env-proxy`。 - 兼容安装结果的 `loginCommand` 能在 MCP 未加载时刷新登录链接;MCP 已加载时 `approval_login` 完成同一操作。不得因链接过期而重装,首次成功响应只展示一次 onboarding 示例。 - 只读验证待办、本人已处理历史、本人发起历史、历史默认最近 30 天及显式时间窗口、搜索、详情、费用结构化字段、动作、模板 Schema、部门/城市选项和越权拒绝;历史引用必须被动作查询和批处理拒绝。 diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index aa5fbd7..cd19715 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -110,8 +110,11 @@ try { const posixBootstrap = readFileSync(posixBootstrapPath, "utf8"); if (windowsBootstrap.includes("{{") || posixBootstrap.includes("{{") || !windowsBootstrap.includes("Save-DingDownload") || !windowsBootstrap.includes("Expand-Archive") + || !windowsBootstrap.includes("[Console]::OutputEncoding = $dingUtf8") || !windowsBootstrap.includes("$OutputEncoding = $dingUtf8") || !windowsBootstrap.includes("curl.exe") || !windowsBootstrap.includes("--max-time 90") || !windowsBootstrap.includes(`-e "const [a,b]=process.versions.node.split('.').map(Number)`) + || !windowsBootstrap.includes("$dingRuntimeName = [IO.Path]::GetFileNameWithoutExtension($dingNodeFile)") + || windowsBootstrap.includes("GetFileNameWithoutExtension([IO.Path]::GetFileNameWithoutExtension") || windowsBootstrap.includes("TimeoutSec 300") || !windowsBootstrap.includes("Write-DingProgress") || !posixBootstrap.includes("ding_progress") || !posixBootstrap.includes("--max-time 90") || /--max-time 300|--timeout=300/.test(posixBootstrap) diff --git a/test/distribution.test.ts b/test/distribution.test.ts index 0a6152e..1cdd857 100644 --- a/test/distribution.test.ts +++ b/test/distribution.test.ts @@ -49,10 +49,13 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati assert.match(windowsBootstrap, /OSArchitecture[\s\S]*Save-DingDownload[\s\S]*Expand-Archive/); assert.match(windowsBootstrap, /SHA-256 不匹配,停止安装/); assert.match(windowsBootstrap, /Write-DingProgress[\s\S]*\[dingtalk-approval\]/); + assert.match(windowsBootstrap, /UTF8Encoding\(\$false\)[\s\S]*Console\]::InputEncoding[\s\S]*Console\]::OutputEncoding[\s\S]*\$OutputEncoding/); assert.match(windowsBootstrap, /dingInstallerUrls[\s\S]*dingManifestMirrorUrls[\s\S]*use-env-proxy/s); assert.match(windowsBootstrap, /curl\.exe[\s\S]*--connect-timeout 10[\s\S]*--max-time 90/); assert.match(windowsBootstrap, /-e "const \[a,b\]=process\.versions\.node\.split\('\.'\)/); assert.match(windowsBootstrap, /-e "process\.exit\(process\.allowedNodeEnvironmentFlags\.has\('--use-env-proxy'\)/); + assert.match(windowsBootstrap, /dingRuntimeName = \[IO\.Path\]::GetFileNameWithoutExtension\(\$dingNodeFile\)/); + assert.doesNotMatch(windowsBootstrap, /GetFileNameWithoutExtension\(\[IO\.Path\]::GetFileNameWithoutExtension/); assert.doesNotMatch(windowsBootstrap, /TimeoutSec 300|-e '.*split\("\."\)/); assert.match(posixBootstrap, /^#!\/bin\/sh\nset -eu/); assert.match(posixBootstrap, /uname -s[\s\S]*uname -m[\s\S]*tar -xzf/);