diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b919074..76312e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: cache: npm - run: npm ci - run: npm run build - - run: node --test dist/test/client-installer.test.js + - run: node --test dist/test/client-installer.test.js dist/test/portable-node-bootstrap.test.js test: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 46819bd..f8bc76b 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,11 @@ Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确 https://approval.justconn.top/downloads/dingmcp/install.md ``` -用户把这个链接交给 Agent 后,Agent 只需校验并运行一个原生 Node 安装器。安装器使用固定 Ed25519 公钥验证清单和发布物,把轻量客户端安装到稳定的用户数据目录,并统一返回 `bridgePath`、`skillPath`、`loginCommand` 和标准 `mcpConfig`;不再区分 Agent 品牌,不全局安装 npm 包,也不会猜测或直接修改 Agent 的私有配置。安装器会立即在受保护的本地会话中检查登录并返回机器可读 `postInstall`:未登录时直接带回当前授权链接,网关暂时不可用也不会使安装回滚。Agent 随后按自身官方方式安装 Skill、注册并信任本地 stdio MCP;不能热加载连接器时再新建会话或重启。用户端不安装 DWS,不需要企业凭证。 +用户把这个链接交给 Agent 后,Agent 先校验并运行当前平台的原生 PowerShell 或 POSIX 安装引导。Windows、Linux、macOS 均支持 x64 与 ARM64,系统无需预装 Node.js:引导会复用兼容的系统 Node,或者从阿里云、npmmirror、Node 官方的精确版本地址依次下载 Node 22.23.2,并用固定的 Node 官方 SHA-256 校验后安装为插件私有运行时。它不修改系统 PATH、不需要管理员权限,也不使用任何 `latest` 镜像别名。 -公开 `install.md` 是一份可直接交给 Agent 执行的逐步教程,Windows PowerShell 为完整主路径,另附 Linux/macOS 命令。它覆盖环境检查、下载与 SHA-256 校验、安装 JSON 验收、Skill/MCP 注册、连接器信任、首次登录、钉钉 OA 与薪福通双来源验证、更新、卸载和故障恢复。Windows 必须原样使用安装器返回的本地 `.cmd` stdio 配置;远程网关 URL 不能直接配置为 MCP,否则宿主可能显示 HTTP 302 或把本地 Bridge 误判为崩溃。 +随后安装器使用固定 Ed25519 公钥验证清单和发布物,把轻量客户端安装到稳定的用户数据目录,并统一返回 `bridgePath`、`skillPath`、`loginCommand`、`nodeRuntime` 和标准 `mcpConfig`;不再区分 Agent 品牌,不全局安装 npm 包,也不会猜测或直接修改 Agent 的私有配置。启动器固定使用安装时选定的 Node,用户以后升级系统 Node 或切换版本管理器不会改变 MCP 命令。安装器会立即在受保护的本地会话中检查登录并返回机器可读 `postInstall`:未登录时直接带回当前授权链接,网关暂时不可用也不会使安装回滚。Agent 随后按自身官方方式安装 Skill、注册并信任本地 stdio MCP;不能热加载连接器时再新建会话或重启。用户端不安装 DWS,不需要企业凭证。 + +公开 `install.md` 是一份可直接交给 Agent 执行的逐步教程,为 Windows PowerShell 与 Linux/macOS 分别提供完整路径。它覆盖平台/架构检测、安装引导校验、国内下载回退、安装 JSON 验收、Skill/MCP 注册、连接器信任、首次登录、钉钉 OA 与薪福通双来源验证、更新、卸载和故障恢复。Windows 必须原样使用安装器返回的本地 `.cmd` stdio 配置;远程网关 URL 不能直接配置为 MCP,否则宿主可能显示 HTTP 302 或把本地 Bridge 误判为崩溃。 Agent 必须在安装后立即展示 `postInstall.authChallenge.directUrl`,不得等到第一次审批查询,也不得声称链接已另行推送。用户授权后,Agent 通过 `approval_login` 确认登录并用 `source=all` 做一次双来源只读验证;首次成功认证响应还会在普通 `content.text` 和 `structuredContent.onboarding` 中返回一次性的简短使用示例,展示后不会重复,也不会自动执行任何写操作。 diff --git a/deploy/approval.justconn.top.nginx.conf b/deploy/approval.justconn.top.nginx.conf index b9bff35..793e1b1 100644 --- a/deploy/approval.justconn.top.nginx.conf +++ b/deploy/approval.justconn.top.nginx.conf @@ -56,7 +56,7 @@ server { add_header X-Content-Type-Options "nosniff" always; } - location ~ ^/downloads/dingmcp/(?:dingtalk-approval-installer\.mjs|dingtalk-approval-SKILL\.md|release-signing-public\.pem|SHA256SUMS|dingmcp-test-bundle\.tar\.gz)$ { + location ~ ^/downloads/dingmcp/(?:dingtalk-approval-installer\.mjs|dingtalk-approval-bootstrap\.(?:ps1|sh)|dingtalk-approval-SKILL\.md|release-signing-public\.pem|SHA256SUMS|dingmcp-test-bundle\.tar\.gz)$ { root /var/www/approval.justconn.top; add_header Strict-Transport-Security "max-age=31536000" always; add_header Cache-Control "no-cache" always; diff --git a/deploy/install.sh b/deploy/install.sh index 4e5267e..dc084a2 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -41,6 +41,7 @@ fi echo "[1/7] 测试并构建" cd "${project_root}" npm --cache "${npm_cache}" test +npm run runtime:verify echo "[2/7] 生成包含钉钉 OA 与薪福通的发行包" npm --cache "${npm_cache}" run release:build -- \ diff --git a/distribution/agent-install.template.md b/distribution/agent-install.template.md index 39c8602..ed2a0f3 100644 --- a/distribution/agent-install.template.md +++ b/distribution/agent-install.template.md @@ -15,7 +15,11 @@ - 签名清单:`{{MANIFEST_URL}}` - 清单签名:`{{MANIFEST_SIGNATURE_URL}}` - 统一网关:`{{GATEWAY_URL}}` -- Node.js:`>=22.13.0` +- Windows 安装引导:`{{BOOTSTRAP_WINDOWS_URL}}` +- Windows 安装引导 SHA-256:`{{BOOTSTRAP_WINDOWS_SHA256}}` +- Linux/macOS 安装引导:`{{BOOTSTRAP_POSIX_URL}}` +- Linux/macOS 安装引导 SHA-256:`{{BOOTSTRAP_POSIX_SHA256}}` +- 私有 Node.js:`{{NODE_VERSION}}`(最低兼容版本 `{{NODE_MINIMUM_VERSION}}`) 首次安装通过上述 HTTPS 地址和固定 SHA-256 建立信任;安装器随后必须使用固定公钥验证 Ed25519 清单签名和全部发布物哈希。URL、TLS、签名或哈希任一不匹配都必须停止。不得索取 AppKey、AppSecret、AuthoritySecret、CorpId、userId、员工号、Cookie 或 Token。 @@ -23,7 +27,7 @@ 只有以下条件全部成立,才能告诉用户“安装完成,可以使用”: -1. Node.js 版本满足要求,安装器与公钥 SHA-256 均正确; +1. 对应平台安装引导的 SHA-256 正确,并已选用兼容的系统 Node 或经过官方哈希校验的私有 Node; 2. 安装器 JSON 返回 `ok: true`; 3. 已按当前 Agent 的官方方式安装返回的 `skillPath`; 4. 已原样使用返回的 `mcpConfig` 注册并信任本地 stdio MCP; @@ -31,73 +35,79 @@ 6. `approval_list_todos` 的双来源验证中,钉钉 OA 与薪福通都为 `ok`; 7. 未执行任何审批写操作。 -## 步骤 1:检查运行环境 +## 步骤 1:确认操作系统与架构 -在当前用户权限下运行: +支持以下当前用户环境: -```text -node --version -npm --version -``` +- Windows 10/11:x64 或 ARM64; +- Linux:x64 或 ARM64; +- macOS:Intel x64 或 Apple Silicon ARM64。 -Node.js 必须为 `22.13.0` 或更高版本,npm 必须可用。不要使用管理员权限、`sudo`、全局 npm 安装、DWS 或浏览器自动化。若刚安装或升级 Node.js,先打开一个新的终端,再重新执行版本检查。 +系统无需预装 Node.js。安装引导会优先复用版本不低于 `{{NODE_MINIMUM_VERSION}}` 且 npm 可用的系统 Node;否则自动准备只属于本插件的 Node `{{NODE_VERSION}}`。不要让用户安装全局 Node,不要使用管理员权限、`sudo`、全局 npm 安装、DWS 或浏览器自动化。 -## 步骤 2:下载并校验首次安装文件 +## 步骤 2:下载、校验并执行平台安装引导 -不要在浏览器中复制下载内容,不要对 URL 解码、重新编码或手工拼接。原样使用上面的 HTTPS URL。 +不要在浏览器中复制脚本内容,不要对 URL 解码、重新编码或手工拼接。必须先校验安装引导 SHA-256,再执行它。 ### Windows(PowerShell) 在普通 PowerShell 中逐行运行: ```powershell -$dingInstallDir = Join-Path ([System.IO.Path]::GetTempPath()) ("dingmcp-install-" + [guid]::NewGuid().ToString("N")) -New-Item -ItemType Directory -Path $dingInstallDir -Force | Out-Null -$dingInstaller = Join-Path $dingInstallDir "installer.mjs" -$dingPublicKey = Join-Path $dingInstallDir "release-signing-public.pem" -Invoke-WebRequest -Uri "{{INSTALLER_URL}}" -OutFile $dingInstaller -Invoke-WebRequest -Uri "{{PUBLIC_KEY_URL}}" -OutFile $dingPublicKey -$dingInstallerHash = (Get-FileHash -Algorithm SHA256 $dingInstaller).Hash.ToLowerInvariant() -$dingPublicKeyHash = (Get-FileHash -Algorithm SHA256 $dingPublicKey).Hash.ToLowerInvariant() -if ($dingInstallerHash -ne "{{INSTALLER_SHA256}}") { throw "安装器 SHA-256 不匹配,停止安装" } -if ($dingPublicKeyHash -ne "{{PUBLIC_KEY_SHA256}}") { throw "发布公钥 SHA-256 不匹配,停止安装" } -``` - -任一 `Invoke-WebRequest` 或哈希检查失败都必须停止,不能忽略错误继续。 - -### Linux / macOS(POSIX shell) - -```bash -ding_install_dir="$(mktemp -d)" -curl --fail --location --output "$ding_install_dir/installer.mjs" "{{INSTALLER_URL}}" -curl --fail --location --output "$ding_install_dir/release-signing-public.pem" "{{PUBLIC_KEY_URL}}" -printf '%s %s\n' '{{INSTALLER_SHA256}}' "$ding_install_dir/installer.mjs" | sha256sum --check --strict -printf '%s %s\n' '{{PUBLIC_KEY_SHA256}}' "$ding_install_dir/release-signing-public.pem" | sha256sum --check --strict -``` - -macOS 没有 `sha256sum` 时,使用 `shasum -a 256` 分别计算并精确比较,不得跳过校验。 - -## 步骤 3:执行唯一通用安装入口 - -### Windows(PowerShell) - -继续使用步骤 2 的同一个 PowerShell: - -```powershell -$dingInstallJson = & node $dingInstaller install --manifest-url "{{MANIFEST_URL}}" --public-key $dingPublicKey --json -if ($LASTEXITCODE -ne 0) { throw "钉钉审批安装器执行失败:$dingInstallJson" } +$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" +Invoke-WebRequest -UseBasicParsing -Uri "{{BOOTSTRAP_WINDOWS_URL}}" -OutFile $dingBootstrap +$dingBootstrapHash = (Get-FileHash -Algorithm SHA256 $dingBootstrap).Hash.ToLowerInvariant() +if ($dingBootstrapHash -ne "{{BOOTSTRAP_WINDOWS_SHA256}}") { throw "Windows 安装引导 SHA-256 不匹配,停止安装" } +$dingInstallJson = & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $dingBootstrap +if ($LASTEXITCODE -ne 0) { throw "钉钉审批安装引导执行失败:$dingInstallJson" } $dingInstall = $dingInstallJson | ConvertFrom-Json if ($dingInstall.ok -ne $true) { throw "钉钉审批安装失败:$($dingInstall.error)" } $dingInstall | ConvertTo-Json -Depth 20 ``` -### Linux / macOS +完成后可删除 `$dingBootstrapDir`。任一下载或哈希检查失败都不能忽略。 + +### Linux / macOS(POSIX shell) ```bash -node "$ding_install_dir/installer.mjs" install --manifest-url "{{MANIFEST_URL}}" --public-key "$ding_install_dir/release-signing-public.pem" --json +ding_bootstrap_dir="$(mktemp -d)" +ding_bootstrap="$ding_bootstrap_dir/dingtalk-approval-bootstrap.sh" +if command -v curl >/dev/null 2>&1; then + curl --fail --location --output "$ding_bootstrap" "{{BOOTSTRAP_POSIX_URL}}" +elif command -v wget >/dev/null 2>&1; then + wget --https-only --output-document="$ding_bootstrap" "{{BOOTSTRAP_POSIX_URL}}" +else + echo '缺少 curl 或 wget,无法下载安装引导' >&2 + exit 1 +fi +if command -v sha256sum >/dev/null 2>&1; then + ding_bootstrap_hash="$(sha256sum "$ding_bootstrap" | awk '{print $1}')" +else + ding_bootstrap_hash="$(shasum -a 256 "$ding_bootstrap" | awk '{print $1}')" +fi +if [ "$ding_bootstrap_hash" != '{{BOOTSTRAP_POSIX_SHA256}}' ]; then + echo 'Linux/macOS 安装引导 SHA-256 不匹配,停止安装' >&2 + exit 1 +fi +ding_install_json="$(sh "$ding_bootstrap")" +printf '%s\n' "$ding_install_json" ``` -这是唯一通用安装入口。安装器会在稳定的当前用户数据目录安装客户端与 Skill,不会全局安装 npm 包,也不会修改 Agent 私有配置。保留完整 JSON 结果,后续只使用其中的真实字段,不要猜路径。 +完成后可删除 `$ding_bootstrap_dir`。如果 `sha256sum` 与 `shasum` 都不存在,必须停止,不能跳过校验。 + +## 步骤 3:验收安装结果 + +步骤 2 的平台引导是唯一通用安装入口。它会: + +1. 检查系统 Node;缺失或版本不兼容时,根据当前操作系统和 CPU 架构选择私有 Node; +2. 按“阿里云精确版本 → npmmirror 精确版本 → Node 官方精确版本”的顺序下载。只有网络失败、超时或 404 才切换来源;任一已下载文件的 SHA-256 不匹配都会立即终止; +3. 把私有 Node 放在插件自己的当前用户数据目录,不修改系统 PATH,不要求管理员权限; +4. 下载并校验首次安装器和发布公钥,再由安装器验证 Ed25519 清单签名与全部客户端发布物; +5. 返回一份 JSON 安装结果。 + +安装器会在稳定的当前用户数据目录安装客户端与 Skill,不会全局安装 npm 包,也不会修改 Agent 私有配置。保留完整 JSON 结果,后续只使用其中的真实字段,不要猜路径。 安装结果至少必须包含: @@ -106,6 +116,7 @@ node "$ding_install_dir/installer.mjs" install --manifest-url "{{MANIFEST_URL}}" - `loginCommand`; - `mcpConfig.mcpServers.dingtalk-approval`; - `postInstall`。 +- `nodeRuntime`,其中 `managed: true` 表示正在使用插件私有 Node。 Windows 的 `bridgePath` 和 `mcpConfig...command` 正常应以 `dingtalk-approval-bridge.cmd` 结尾。路径中可能包含空格;保留完整字符串,不要拆分,不要去掉 `.cmd`,不要改为 `node`、`npx` 或远程 URL。 @@ -155,7 +166,7 @@ $dingLogin | ConvertTo-Json -Depth 20 1. 如果当前 Agent 要求信任本地连接器,按其官方界面或命令确认名称为 `dingtalk-approval` 的连接器。 2. 如果不能热加载,完整退出并重新启动 Agent,或新建一个确定会重新读取 MCP 配置的会话;只刷新聊天内容通常不够。 3. 重启后确认能发现 `approval_login`、`approval_list_todos` 等钉钉审批工具。当前版本应发现 13 个工具。 -4. 若连接器进程退出、显示 302 或无法启动,先执行步骤 9 的 Windows 排障,不要把连接器改成远程 HTTP MCP。 +4. 若连接器进程退出、显示 302 或无法启动,先执行步骤 9 的故障排查,不要把连接器改成远程 HTTP MCP。 ## 步骤 7:确认登录并验证两个审批来源 @@ -173,17 +184,18 @@ $dingLogin | ConvertTo-Json -Depth 20 安装阶段不得处理审批,也不得在登录和双来源验证完成前声称“已可使用”。 -## 步骤 9:Windows 常见故障排查 +## 步骤 9:常见故障排查 按顺序排查,不要跳到重装: 1. **显示 302 或启动后立即退出**:检查连接器是否被错误配置成 `{{GATEWAY_URL}}` 或其他 HTTP URL。正确配置必须是 `type: "stdio"`,`command` 为本机 `dingtalk-approval-bridge.cmd`,`args: []`。重新复制安装结果中的 `mcpConfig`。 2. **路径不存在或 Node 升级后失效**:不要引用 Node/NVM 的版本目录。执行安装结果中的 `cliPath`,或运行 `dingtalk-approval print-config --format mcp-json` 重新取得稳定配置。 3. **Bridge 仍无法启动**:执行 ` doctor --json`。只有所有 `checks` 都为 `ok: true` 才继续;把失败检查和错误原文报告给维护者,不要提供 Token 或会话文件。 -4. **提示找不到 npm-cli.js**:当前 Node 安装不完整。安装带 npm 的 Node.js 22.13+,打开新 PowerShell,重新检查 `node --version` 和 `npm --version` 后再运行安装器。 +4. **提示找不到 npm-cli.js 或 Node 运行时损坏**:重新执行步骤 2 中已经过哈希校验的平台引导,让它修复插件私有 Node;不要安装全局 npm。 5. **路径包含空格**:不要手写命令行字符串。把安装器返回的整个 `mcpConfig` 作为 JSON 对象交给 Agent;在 PowerShell 中执行 CLI 时用调用运算符 `&` 和独立参数。 6. **连接器一直未出现**:确认已信任本地连接器并完整重启 Agent。不要通过反复重装刷新连接器。 7. **授权链接过期**:MCP 已加载就再次调用 `approval_login`;否则执行保存的 `loginCommand`。不要重装。 +8. **Linux/macOS 缺少下载或校验命令**:必须先具备 `curl` 或 `wget`,以及 `sha256sum` 或 `shasum`;不能绕过 SHA-256 校验。系统 `tar` 必须支持 gzip 归档。 ## 更新 diff --git a/distribution/bootstrap-posix.template.sh b/distribution/bootstrap-posix.template.sh new file mode 100644 index 0000000..3064209 --- /dev/null +++ b/distribution/bootstrap-posix.template.sh @@ -0,0 +1,159 @@ +#!/bin/sh +set -eu + +ding_source_bases='{{NODE_SOURCE_BASES_POSIX}}' +ding_installer_url='{{INSTALLER_URL}}' +ding_installer_sha256='{{INSTALLER_SHA256}}' +ding_public_key_url='{{PUBLIC_KEY_URL}}' +ding_public_key_sha256='{{PUBLIC_KEY_SHA256}}' +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_hash_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + echo '缺少 sha256sum 或 shasum,无法安全安装' >&2 + return 1 + fi +} + +ding_fetch() { + if command -v curl >/dev/null 2>&1; then + if ding_fetch_http="$(curl --fail --location --silent --show-error --connect-timeout 10 --max-time 300 \ + --write-out '%{http_code}' --output "$2" "$1")"; then + return 0 + else + ding_fetch_status=$? + fi + if { [ "$ding_fetch_status" -eq 22 ] && [ "$ding_fetch_http" = '404' ]; } \ + || [ "$ding_fetch_status" -eq 5 ] || [ "$ding_fetch_status" -eq 6 ] \ + || [ "$ding_fetch_status" -eq 7 ] || [ "$ding_fetch_status" -eq 18 ] \ + || [ "$ding_fetch_status" -eq 28 ] || [ "$ding_fetch_status" -eq 35 ] \ + || [ "$ding_fetch_status" -eq 52 ] || [ "$ding_fetch_status" -eq 55 ] \ + || [ "$ding_fetch_status" -eq 56 ]; then + return 75 + fi + echo "下载失败且不允许换源(HTTP ${ding_fetch_http:-unknown} / curl $ding_fetch_status):$1" >&2 + return 1 + elif command -v wget >/dev/null 2>&1; then + ding_wget_log="${2}.wget.log" + if wget --https-only --timeout=300 --server-response --output-document="$2" "$1" 2>"$ding_wget_log"; then + rm -f "$ding_wget_log" + return 0 + else + ding_fetch_status=$? + fi + ding_fetch_http="$(awk '/^ HTTP\/[0-9.]+ [0-9][0-9][0-9]/{code=$2} END{print code}' "$ding_wget_log")" + cat "$ding_wget_log" >&2 + rm -f "$ding_wget_log" + if [ "$ding_fetch_http" = '404' ] || [ "$ding_fetch_status" -eq 4 ] || [ "$ding_fetch_status" -eq 5 ]; then + return 75 + fi + echo "下载失败且不允许换源(HTTP ${ding_fetch_http:-unknown} / wget $ding_fetch_status):$1" >&2 + return 1 + else + echo '缺少 curl 或 wget,无法下载安装文件' >&2 + return 1 + fi +} + +ding_download_verified() { + ding_download_target="$1" + ding_download_expected="$2" + shift 2 + ding_download_succeeded=0 + for ding_download_url in "$@"; do + rm -f "$ding_download_target" + if ding_fetch "$ding_download_url" "$ding_download_target"; then + ding_download_succeeded=1 + ding_download_actual="$(ding_hash_file "$ding_download_target")" + if [ "$ding_download_actual" != "$ding_download_expected" ]; then + echo "下载文件 SHA-256 不匹配,停止安装:$ding_download_url" >&2 + return 1 + fi + return 0 + else + ding_download_status=$? + if [ "$ding_download_status" -ne 75 ]; then return 1; fi + fi + done + if [ "$ding_download_succeeded" -eq 0 ]; then + echo '所有下载源均不可用' >&2 + fi + return 1 +} + +ding_test_node() { + [ -x "$1" ] || return 1 + "$1" -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>22||(a===22&&b>=13)?0:1)' >/dev/null 2>&1 +} + +case "$(uname -s)" in + Linux) ding_os='linux' ;; + Darwin) ding_os='darwin' ;; + *) echo "不支持的操作系统:$(uname -s)" >&2; exit 1 ;; +esac +case "$(uname -m)" in + x86_64|amd64) ding_arch='x64' ;; + arm64|aarch64) ding_arch='arm64' ;; + *) echo "不支持的 CPU 架构:$(uname -m)" >&2; exit 1 ;; +esac + +case "$ding_os-$ding_arch" in + linux-x64) ding_node_file='{{NODE_LINUX_X64_FILE}}'; ding_node_sha256='{{NODE_LINUX_X64_SHA256}}' ;; + linux-arm64) ding_node_file='{{NODE_LINUX_ARM64_FILE}}'; ding_node_sha256='{{NODE_LINUX_ARM64_SHA256}}' ;; + darwin-x64) ding_node_file='{{NODE_DARWIN_X64_FILE}}'; ding_node_sha256='{{NODE_DARWIN_X64_SHA256}}' ;; + darwin-arm64) ding_node_file='{{NODE_DARWIN_ARM64_FILE}}'; ding_node_sha256='{{NODE_DARWIN_ARM64_SHA256}}' ;; +esac + +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)" +else + if [ -n "${DINGTALK_APPROVAL_CLIENT_HOME:-}" ]; then + ding_client_root="$DINGTALK_APPROVAL_CLIENT_HOME" + elif [ "$ding_os" = 'darwin' ]; then + ding_client_root="$HOME/Library/Application Support/dingtalk-approval" + else + ding_client_root="${XDG_DATA_HOME:-$HOME/.local/share}/dingtalk-approval" + fi + ding_runtime_name="${ding_node_file%.tar.gz}" + ding_runtime_parent="$ding_client_root/node" + ding_runtime_dir="$ding_runtime_parent/$ding_runtime_name" + 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 + 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" + ding_runtime_urls='' + for ding_source_base in $ding_source_bases; do + ding_runtime_urls="$ding_runtime_urls $ding_source_base$ding_node_file" + done + # The URLs are fixed by the signed release process and contain no whitespace. + # shellcheck disable=SC2086 + ding_download_verified "$ding_runtime_archive" "$ding_node_sha256" $ding_runtime_urls + ding_extract_root="$ding_temp_dir/node-extract" + mkdir -p "$ding_extract_root" + tar -xzf "$ding_runtime_archive" -C "$ding_extract_root" + ding_extracted_dir="$ding_extract_root/$ding_runtime_name" + if ! ding_test_node "$ding_extracted_dir/bin/node" || [ ! -f "$ding_extracted_dir/lib/node_modules/npm/bin/npm-cli.js" ]; then + echo '便携 Node 归档缺少可用的 node 或 npm-cli.js' >&2 + exit 1 + fi + rm -rf "$ding_runtime_dir" + mv "$ding_extracted_dir" "$ding_runtime_dir" + fi +fi + +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_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 new file mode 100644 index 0000000..65cc0e1 --- /dev/null +++ b/distribution/bootstrap-windows.template.ps1 @@ -0,0 +1,111 @@ +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + +$dingSourceBases = @({{NODE_SOURCE_BASES_POWERSHELL}}) +$dingInstallerUrl = "{{INSTALLER_URL}}" +$dingInstallerSha256 = "{{INSTALLER_SHA256}}" +$dingPublicKeyUrl = "{{PUBLIC_KEY_URL}}" +$dingPublicKeySha256 = "{{PUBLIC_KEY_SHA256}}" +$dingManifestUrl = "{{MANIFEST_URL}}" + +function Get-DingSha256([string] $dingPath) { + return (Get-FileHash -Algorithm SHA256 -LiteralPath $dingPath).Hash.ToLowerInvariant() +} + +function Save-DingDownload([string[]] $dingUrls, [string] $dingTarget, [string] $dingExpectedSha256) { + $dingLastError = $null + foreach ($dingUrl in $dingUrls) { + try { + if (Test-Path -LiteralPath $dingTarget) { Remove-Item -LiteralPath $dingTarget -Force } + Invoke-WebRequest -UseBasicParsing -Uri $dingUrl -OutFile $dingTarget -TimeoutSec 300 + } catch { + $dingLastError = $_.Exception.Message + $dingStatusCode = $null + if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { + try { $dingStatusCode = [int]$_.Exception.Response.StatusCode } catch { $dingStatusCode = $null } + } + if ($null -eq $dingStatusCode -or $dingStatusCode -eq 404) { continue } + throw "下载失败且不允许换源(HTTP $dingStatusCode):$dingUrl;$dingLastError" + } + $dingActualSha256 = Get-DingSha256 $dingTarget + if ($dingActualSha256 -ne $dingExpectedSha256) { + throw "下载文件 SHA-256 不匹配,停止安装:$dingUrl" + } + return + } + throw "所有下载源均不可用:$dingLastError" +} + +function Test-DingNode([string] $dingNodePath, [bool] $dingRequireAdjacentNpm) { + if (-not $dingNodePath -or -not (Test-Path -LiteralPath $dingNodePath -PathType Leaf)) { return $false } + try { + & $dingNodePath -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>22||(a===22&&b>=13)?0:1)' + if ($LASTEXITCODE -ne 0) { return $false } + if ($dingRequireAdjacentNpm) { + $dingNpmCli = Join-Path (Split-Path -Parent $dingNodePath) "node_modules\npm\bin\npm-cli.js" + return Test-Path -LiteralPath $dingNpmCli -PathType Leaf + } + return $null -ne (Get-Command npm -ErrorAction SilentlyContinue) + } catch { return $false } +} + +$dingArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +switch ($dingArchitecture) { + "x64" { $dingNodeFile = "{{NODE_WIN32_X64_FILE}}"; $dingNodeSha256 = "{{NODE_WIN32_X64_SHA256}}" } + "arm64" { $dingNodeFile = "{{NODE_WIN32_ARM64_FILE}}"; $dingNodeSha256 = "{{NODE_WIN32_ARM64_SHA256}}" } + default { throw "不支持的 Windows CPU 架构:$dingArchitecture" } +} + +$dingSystemNode = Get-Command node -ErrorAction SilentlyContinue +$dingNodePath = if ($dingSystemNode) { $dingSystemNode.Source } else { $null } +if (-not (Test-DingNode $dingNodePath $false)) { + if (-not $env:LOCALAPPDATA -and -not $env:DINGTALK_APPROVAL_CLIENT_HOME) { + throw "无法确定当前用户数据目录:LOCALAPPDATA 为空" + } + $dingClientRoot = if ($env:DINGTALK_APPROVAL_CLIENT_HOME) { + [IO.Path]::GetFullPath($env:DINGTALK_APPROVAL_CLIENT_HOME) + } else { + Join-Path $env:LOCALAPPDATA "DingTalkApproval" + } + $dingRuntimeParent = Join-Path $dingClientRoot "node" + $dingRuntimeName = [IO.Path]::GetFileNameWithoutExtension([IO.Path]::GetFileNameWithoutExtension($dingNodeFile)) + $dingRuntimeDir = Join-Path $dingRuntimeParent $dingRuntimeName + $dingNodePath = Join-Path $dingRuntimeDir "node.exe" + if (-not (Test-DingNode $dingNodePath $true)) { + 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 + try { + $dingRuntimeArchive = Join-Path $dingRuntimeTemp $dingNodeFile + $dingRuntimeUrls = @($dingSourceBases | ForEach-Object { $_ + $dingNodeFile }) + Save-DingDownload $dingRuntimeUrls $dingRuntimeArchive $dingNodeSha256 + $dingExtractRoot = Join-Path $dingRuntimeTemp "extract" + Expand-Archive -LiteralPath $dingRuntimeArchive -DestinationPath $dingExtractRoot -Force + $dingExtractedDir = Join-Path $dingExtractRoot $dingRuntimeName + $dingExtractedNode = Join-Path $dingExtractedDir "node.exe" + if (-not (Test-DingNode $dingExtractedNode $true)) { throw "便携 Node 归档缺少可用的 node.exe 或 npm-cli.js" } + if (Test-Path -LiteralPath $dingRuntimeDir) { Remove-Item -LiteralPath $dingRuntimeDir -Recurse -Force } + Move-Item -LiteralPath $dingExtractedDir -Destination $dingRuntimeDir + } finally { + if (Test-Path -LiteralPath $dingRuntimeTemp) { Remove-Item -LiteralPath $dingRuntimeTemp -Recurse -Force } + } + } +} + +$dingInstallTemp = Join-Path ([IO.Path]::GetTempPath()) ("dingmcp-install-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $dingInstallTemp -Force | Out-Null +try { + $dingInstaller = Join-Path $dingInstallTemp "installer.mjs" + $dingPublicKey = Join-Path $dingInstallTemp "release-signing-public.pem" + Save-DingDownload @($dingInstallerUrl) $dingInstaller $dingInstallerSha256 + Save-DingDownload @($dingPublicKeyUrl) $dingPublicKey $dingPublicKeySha256 + $dingInstallOutput = & $dingNodePath $dingInstaller install --manifest-url $dingManifestUrl --public-key $dingPublicKey --json + $dingInstallExit = $LASTEXITCODE + if ($dingInstallExit -ne 0) { throw "钉钉审批安装器执行失败:$dingInstallOutput" } + $dingResult = $dingInstallOutput | ConvertFrom-Json + if ($dingResult.ok -ne $true) { throw "钉钉审批安装失败:$($dingResult.error)" } + $dingResult | ConvertTo-Json -Depth 20 -Compress +} finally { + if (Test-Path -LiteralPath $dingInstallTemp) { Remove-Item -LiteralPath $dingInstallTemp -Recurse -Force } +} diff --git a/distribution/node-runtime.json b/distribution/node-runtime.json new file mode 100644 index 0000000..5788b3f --- /dev/null +++ b/distribution/node-runtime.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "version": "22.23.2", + "minimumVersion": "22.13.0", + "sourceBases": [ + "https://mirrors.aliyun.com/nodejs-release/v22.23.2/", + "https://npmmirror.com/mirrors/node/v22.23.2/", + "https://nodejs.org/dist/v22.23.2/" + ], + "platforms": { + "win32-x64": { + "file": "node-v22.23.2-win-x64.zip", + "sha256": "1177b4137ba5adaa56354ae40f1080c7450e8ae09cecb47da459d1c52ac99f97" + }, + "win32-arm64": { + "file": "node-v22.23.2-win-arm64.zip", + "sha256": "fec025a6da31757e3b6af84c5a1628e9d38442ca99a2161091d78f2fcfa35ef3" + }, + "linux-x64": { + "file": "node-v22.23.2-linux-x64.tar.gz", + "sha256": "b294a556e639d64338823920e5866c21c02741742d2e1529ee1a225c1ec9252a" + }, + "linux-arm64": { + "file": "node-v22.23.2-linux-arm64.tar.gz", + "sha256": "013b59cfd2819703a6f4a14ab891fc46fc2a4e3f5bcd92de3fb4929b43e35b30" + }, + "darwin-x64": { + "file": "node-v22.23.2-darwin-x64.tar.gz", + "sha256": "58e99022c2ff89395576cc7fd4d98cea24bb68081475d5f88b801ee8729fb026" + }, + "darwin-arm64": { + "file": "node-v22.23.2-darwin-arm64.tar.gz", + "sha256": "61130f394c1630d211dd50aecc4353d379480f36d3ac913cd85dbba1aed585c6" + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index e9bb891..8b10a56 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,9 @@ 终端不再使用全局 npm prefix。无第三方依赖的首次安装器通过自有 HTTPS 到达用户机器,随后使用固定 Ed25519 公钥验证 schema v3 清单签名,并校验每个 artifact 的 SHA-256。客户端运行依赖被内置在签名 artifact 中,终端使用 npm 离线模式展开,不在安装时引入清单以外的代码。首次安装以 HTTPS 和安装说明中固定的安装器/公钥哈希建立信任;本地固定公钥保护后续更新链路。发布私钥不进入 Git、网关进程或公开目录。 -运行时位于稳定的用户数据目录,启动器与 Node/NVM 具体版本解耦。安装器只有一条通用路径,输出稳定 `bridgePath`、通用 `skillPath`、标准 `mcpConfig` 和机器可读 `postInstall`,不识别 Agent 品牌,也不写任何 Agent 的私有配置。`postInstall` 通过已安装的 Bridge 和正式网关主动检查登录;它只返回认证状态、短期授权链接与下一步工具提示,不返回 Token 或 device secret。网关不可用只会把状态标为 `unavailable`,不会回滚已验证的客户端安装。 +运行时位于稳定的用户数据目录,启动器与 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`,不会回滚已验证的客户端安装。 连接器信任和重启属于 Agent 自身的安全边界,安装器不会绕过。卸载默认删除运行时、Skill、安装记录和 OAuth 会话;Agent 拥有的连接器条目由其官方入口移除。旧 npm 入口只在名称和目标包都精确匹配时清理。 @@ -82,7 +84,7 @@ Bridge 的受保护会话中只记录 `pending/delivered` 一次性 onboarding 撤回接口若返回 `XFTSYS*` 服务异常或网络级异常,网关会立即只读复核本人单据状态:已撤回则返回 `revoked`,仍为待审批则返回带 `postWriteVerified` 的 `failed`,无法明确归类才返回 `outcome_uncertain`。任何分支都不会自动重试写操作。 -截至 `1.1.0-rc.8`,薪福通撤回仍是实验能力:当前企业真实调用 `approve/recall` 会返回 `XFTSYS9999`,写后复核确认单据仍为待审批,尚无成功样本。该限制不影响薪福通查询、审批处理或发起审批;正式版不得把“接口已接通”表述为“撤回已验证成功”。 +截至 `1.1.0-rc.9`,薪福通撤回仍是实验能力:当前企业真实调用 `approve/recall` 会返回 `XFTSYS9999`,写后复核确认单据仍为待审批,尚无成功样本。该限制不影响薪福通查询、审批处理或发起审批;正式版不得把“接口已接通”表述为“撤回已验证成功”。 薪福通创建或撤回需要复核时,`approval_get_submission_status` 先用服务器派生的本人员工号和单据详情双重确认目标属于当前用户的已发起单据,再返回状态名称和精简流程历史。只有实时状态仍为 `1(待审批)` 才签发新的短期、用户绑定 `revokeId`;已通过、草稿、拒绝或已撤回单据都不会获得撤回引用。该工具不返回审批正文,也不触发写操作。 diff --git a/docs/deployment.md b/docs/deployment.md index 17e2b9e..86e30b6 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -37,7 +37,7 @@ https://approval.justconn.top/oauth/dingtalk/callback 创建接口必须保持权限校验开启。网关统一提交 `APPRV_PEND`,并在写后实时读取本人单据状态:进入待审批时提供撤回引用;按企业流程规则即时通过时返回 `completed`,不提供撤回引用且禁止重复提交。 -`1.1.0-rc.8` 中薪福通撤回仍标记为实验能力:权限和调用契约已接通,但当前企业真实请求返回 `XFTSYS9999`,尚未取得成功样本。网关会写后复核并禁止自动重试。面向试用用户时必须保留这一说明;若业务要求撤回必须稳定可用,应等待薪福通定位后再升级为正式能力。 +`1.1.0-rc.9` 中薪福通撤回仍标记为实验能力:权限和调用契约已接通,但当前企业真实请求返回 `XFTSYS9999`,尚未取得成功样本。网关会写后复核并禁止自动重试。面向试用用户时必须保留这一说明;若业务要求撤回必须稳定可用,应等待薪福通定位后再升级为正式能力。 ## 首次凭证配置 @@ -101,6 +101,8 @@ release/ ├── manifest.sig ├── SHA256SUMS ├── dingtalk-approval-installer.mjs +├── dingtalk-approval-bootstrap.ps1 +├── dingtalk-approval-bootstrap.sh ├── release-signing-public.pem ├── dingtalk-approval-client-*.tgz ├── dingtalk-approval-SKILL.md @@ -114,11 +116,13 @@ release/ https://approval.justconn.top/downloads/dingmcp/install.md ``` -Agent 只需校验安装器和公钥 SHA-256,再调用原生安装器。安装器验证 `manifest.sig` 和全部 artifact 哈希,将内置运行依赖的客户端以 npm 离线模式安装到稳定用户目录并返回标准 MCP 配置;终端安装过程不会从 npm registry 下载额外代码。Bridge 的稳定绝对路径不会包含 NVM 的具体 Node 版本,配置也不再携带 `--url`;网关地址由受签名保护的安装状态提供。 +Agent 先校验当前平台引导脚本的固定 SHA-256,再执行该引导。平台引导选择并校验 Node 运行时,同时校验首次安装器和发布公钥;安装器随后验证 `manifest.sig` 和全部 artifact 哈希,将内置运行依赖的客户端以 npm 离线模式安装到稳定用户目录并返回标准 MCP 配置。终端安装过程不会从 npm registry 下载额外代码。Bridge 的稳定绝对路径不会包含 NVM 的具体 Node 版本,配置也不再携带 `--url`;网关地址由受签名保护的安装状态提供。 -公开 `install.md` 必须保留从环境检查到双来源验收的完整编号步骤,并把 Windows PowerShell 作为独立主路径,而不是 POSIX 命令的附注。Windows 安装说明至少要覆盖 `Invoke-WebRequest` 下载、`Get-FileHash` 校验、JSON 解析、`.cmd` stdio 配置、路径空格、连接器信任与完整重启、独立登录续期、`doctor --json`、更新和卸载。教程必须明确禁止把网关 HTTPS URL 直接注册为 MCP。 +公开 `install.md` 必须保留从平台检查到双来源验收的完整编号步骤,并分别提供 Windows PowerShell 与 Linux/macOS 的独立路径。三平台都必须支持系统没有 Node 的情况:发布流程固定 Node 精确版本及 Windows/Linux/macOS x64/ARM64 的官方 SHA-256;平台引导依次使用阿里云、npmmirror 和 Node 官方精确版本地址,网络失败才换源,哈希不符立即终止。禁止使用 `latest` 镜像别名、修改系统 PATH 或要求管理员权限。 -安装器对每个下载设置 30 秒超时,并在流式读取时执行 25 MB 硬上限,不会先把无界响应完整缓冲到内存。Windows 必须能定位由当前 Node 安全启动的 `npm-cli.js`;不会退回到受 Node 安全加固影响的 `npm.cmd` 隐式执行。 +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。 安装器不会识别 Agent 品牌或直接修改其配置。所有 Agent 都使用返回的 `skillPath` 和标准 `mcpConfig`,按自身官方方式安装 Skill、注册并信任名为 `dingtalk-approval` 的本地 stdio MCP;不能热加载连接器时再新建会话或重启。 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 492955e..d24b8b9 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -4,10 +4,12 @@ - 当前提交位于 `main`,工作区除明确忽略的本地缓存外干净;版本号、插件版本和 Git 标签一致。 - `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`、数据库、私钥或机器私有路径。 - Nginx bootstrap 只开放 ACME challenge;正式站点启用 HSTS,mutable 发行文件返回 `no-cache`,客户端 tgz 才返回 immutable。连续两次部署时公开目录始终对应一个完整签名清单,旧版本保留数符合策略。 - 从新生成的公开 `install.md` 在干净的用户级目录安装,不使用 `sudo`;安装结果主动返回 `postInstall`,可发现全部 13 个 MCP 工具。 -- 在干净 Windows 用户环境用公开教程的 PowerShell 路径完成下载、两项 SHA-256 校验、安装 JSON 解析和 stdio 注册;实际 `command` 以 `.cmd` 结尾、`args` 为空,不含网关 URL,并通过 `doctor --json`。同时验证路径含空格、连接器信任和完整重启场景。 +- 在未安装 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`。同时验证路径含空格、连接器信任和完整重启场景。 - 验证安装结果返回 `loginCommand`:未加载 MCP 时执行 `login --json` 能复用现有运行时生成或检查登录链接,不下载发布物;链接过期后生成新链接,已登录时不再返回挑战,未安装时明确失败。 - 首次安装不等待业务查询即产生一条可传递链接;挑战阶段 onboarding 仍为 pending,授权后的首次成功响应在普通文本中展示一次示例,此后不重复。 - 授权后钉钉 OA 与薪福通均为 `ok`,网关重启后登录状态仍可恢复;登录准备暂时失败不会回滚安装或删除原 OAuth 会话。 diff --git a/package-lock.json b/package-lock.json index d7fd2df..f3d1009 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dingtalk-approval-mcp", - "version": "1.1.0-rc.8", + "version": "1.1.0-rc.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dingtalk-approval-mcp", - "version": "1.1.0-rc.8", + "version": "1.1.0-rc.9", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "express": "^5.2.1", diff --git a/package.json b/package.json index 08fc04f..17344da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dingtalk-approval-mcp", - "version": "1.1.0-rc.8", + "version": "1.1.0-rc.9", "description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.", "private": true, "type": "module", @@ -26,6 +26,7 @@ "deploy:uninstall": "bash deploy/uninstall.sh", "release:build": "node scripts/build-release.mjs", "release:verify": "node scripts/verify-release.mjs", + "runtime:verify": "node scripts/verify-node-runtime-sources.mjs", "release:keygen": "node scripts/generate-release-key.mjs", "test-bundle:build": "node scripts/build-test-bundle.mjs", "test": "npm run build && node --test dist/test/*.test.js" diff --git a/scripts/build-release.mjs b/scripts/build-release.mjs index 7db4400..7ad0756 100644 --- a/scripts/build-release.mjs +++ b/scripts/build-release.mjs @@ -19,8 +19,10 @@ const signingKeyPath = resolve(String(values["signing-key"])); const publicKeyPath = resolve("distribution/release-signing-public.pem"); const packageMetadata = JSON.parse(readFileSync(resolve("package.json"), "utf8")); const packageLock = JSON.parse(readFileSync(resolve("package-lock.json"), "utf8")); +const nodeRuntime = JSON.parse(readFileSync(resolve("distribution/node-runtime.json"), "utf8")); const version = String(packageMetadata.version ?? "").trim(); if (!version) throw new Error("package.json 缺少版本号"); +validateNodeRuntime(nodeRuntime); if (out === projectRoot || !out.startsWith(`${projectRoot}${sep}`)) throw new Error("--out 必须是项目目录下的专用子目录"); assertPrivateKey(signingKeyPath); const privateKey = readFileSync(signingKeyPath); @@ -75,19 +77,44 @@ cpSync(publicKeyPath, join(out, publicKeyName)); cpSync(resolve("skills/dingtalk-approval/SKILL.md"), join(out, skillName)); rmSync(clientStaging, { recursive: true, force: true }); -const artifacts = { +const baseArtifacts = { installer: artifact(installerName), publicKey: { ...artifact(publicKeyName), keyId }, client: artifact(clientName), skill: artifact(skillName), }; +const commonBootstrapReplacements = new Map([ + ["{{NODE_VERSION}}", nodeRuntime.version], ["{{NODE_MINIMUM_VERSION}}", nodeRuntime.minimumVersion], + ["{{MANIFEST_URL}}", `${downloadBaseUrl}/manifest.json`], + ["{{INSTALLER_URL}}", baseArtifacts.installer.url], ["{{INSTALLER_SHA256}}", baseArtifacts.installer.sha256], + ["{{PUBLIC_KEY_URL}}", baseArtifacts.publicKey.url], ["{{PUBLIC_KEY_SHA256}}", baseArtifacts.publicKey.sha256], + ["{{NODE_SOURCE_BASES_POWERSHELL}}", nodeRuntime.sourceBases.map((value) => JSON.stringify(value)).join(", ")], + ["{{NODE_SOURCE_BASES_POSIX}}", nodeRuntime.sourceBases.join(" ")], + ...Object.entries(nodeRuntime.platforms).flatMap(([platform, runtime]) => { + const prefix = `{{NODE_${platform.replace(/-/g, "_").toUpperCase()}_`; + return [[`${prefix}FILE}}`, runtime.file], [`${prefix}SHA256}}`, runtime.sha256]]; + }), +]); +const bootstrapWindowsName = "dingtalk-approval-bootstrap.ps1"; +const bootstrapPosixName = "dingtalk-approval-bootstrap.sh"; +renderTemplate("distribution/bootstrap-windows.template.ps1", join(out, bootstrapWindowsName), commonBootstrapReplacements); +renderTemplate("distribution/bootstrap-posix.template.sh", join(out, bootstrapPosixName), commonBootstrapReplacements); +execFileSync("sh", ["-n", join(out, bootstrapPosixName)]); +const artifacts = { + ...baseArtifacts, + bootstrapWindows: artifact(bootstrapWindowsName), + bootstrapPosix: artifact(bootstrapPosixName), +}; const manifest = { schemaVersion: 3, version, createdAt: new Date().toISOString(), gatewayUrl, signing: { algorithm: "Ed25519", keyId }, - requirements: { node: ">=22.13.0" }, + requirements: { + node: `>=${nodeRuntime.minimumVersion}`, + managedNode: { version: nodeRuntime.version, platforms: Object.keys(nodeRuntime.platforms).sort() }, + }, artifacts, }; const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); @@ -103,6 +130,9 @@ const replacements = new Map([ ["{{MANIFEST_SIGNATURE_URL}}", `${downloadBaseUrl}/manifest.sig`], ["{{GATEWAY_URL}}", gatewayUrl], ["{{INSTALLER_URL}}", artifacts.installer.url], ["{{INSTALLER_SHA256}}", artifacts.installer.sha256], ["{{PUBLIC_KEY_URL}}", artifacts.publicKey.url], ["{{PUBLIC_KEY_SHA256}}", artifacts.publicKey.sha256], + ["{{BOOTSTRAP_WINDOWS_URL}}", artifacts.bootstrapWindows.url], ["{{BOOTSTRAP_WINDOWS_SHA256}}", artifacts.bootstrapWindows.sha256], + ["{{BOOTSTRAP_POSIX_URL}}", artifacts.bootstrapPosix.url], ["{{BOOTSTRAP_POSIX_SHA256}}", artifacts.bootstrapPosix.sha256], + ["{{NODE_VERSION}}", nodeRuntime.version], ["{{NODE_MINIMUM_VERSION}}", nodeRuntime.minimumVersion], ["{{RELEASE_KEY_ID}}", keyId], ]); let prompt = template; @@ -133,6 +163,38 @@ function lockedVersion(name) { return value; } +function renderTemplate(source, target, replacements) { + let body = readFileSync(resolve(source), "utf8"); + for (const [from, to] of replacements) body = body.replaceAll(from, String(to)); + if (/\{\{[^}]+\}\}/.test(body)) throw new Error(`${source} 仍包含未替换占位符`); + writeFileSync(target, body, "utf8"); +} + +function validateNodeRuntime(value) { + if (value?.schemaVersion !== 1 || !/^\d+\.\d+\.\d+$/.test(value.version) + || !/^\d+\.\d+\.\d+$/.test(value.minimumVersion)) { + throw new Error("node-runtime.json 版本信息无效"); + } + if (!Array.isArray(value.sourceBases) || value.sourceBases.length < 2) throw new Error("Node 运行时至少需要两个下载源"); + for (const source of value.sourceBases) { + const url = parseHttps(source, "Node source base"); + if (!url.pathname.endsWith(`/v${value.version}/`) || url.search || url.hash) { + throw new Error("Node 下载源必须固定精确版本目录"); + } + } + const expected = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-arm64", "win32-x64"]; + if (JSON.stringify(Object.keys(value.platforms ?? {}).sort()) !== JSON.stringify(expected)) { + throw new Error("Node 运行时必须完整覆盖 Windows/Linux/macOS 的 x64 与 ARM64"); + } + for (const [platform, runtime] of Object.entries(value.platforms)) { + if (typeof runtime.file !== "string" || !runtime.file.includes(`v${value.version}`) + || !/^[a-f0-9]{64}$/.test(runtime.sha256)) throw new Error(`Node 运行时 ${platform} 无效`); + if (platform.startsWith("win32-") ? !runtime.file.endsWith(".zip") : !runtime.file.endsWith(".tar.gz")) { + throw new Error(`Node 运行时 ${platform} 使用了不支持的归档格式`); + } + } +} + function assertPrivateKey(path) { const stat = statSync(path); if (!stat.isFile()) throw new Error("发布签名私钥不是普通文件"); diff --git a/scripts/verify-node-runtime-sources.mjs b/scripts/verify-node-runtime-sources.mjs new file mode 100644 index 0000000..51155eb --- /dev/null +++ b/scripts/verify-node-runtime-sources.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; + +const catalog = JSON.parse(readFileSync(new URL("../distribution/node-runtime.json", import.meta.url), "utf8")); +const expected = new Map(Object.values(catalog.platforms).map((value) => [value.file, value.sha256])); +const results = await Promise.all(catalog.sourceBases.map(checkSource)); +for (const result of results) { + console.log(`${result.ok ? "OK" : "FAIL"} ${result.host}: ${result.message}`); +} +const official = results.find((result) => result.host === "nodejs.org"); +const domestic = results.filter((result) => result.host !== "nodejs.org"); +if (!official?.ok) throw new Error("Node 官方精确版本源不可用或校验清单不匹配"); +if (!domestic.some((result) => result.ok)) throw new Error("没有可用且校验一致的国内 Node 下载源"); + +async function checkSource(sourceBase) { + const base = new URL(sourceBase); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20_000); + timeout.unref(); + try { + const checksumResponse = await fetch(new URL("SHASUMS256.txt", base), { signal: controller.signal }); + if (!checksumResponse.ok) return failure(base, `SHASUMS256.txt HTTP ${checksumResponse.status}`); + const checksums = parseChecksums(await checksumResponse.text()); + for (const [file, sha256] of expected) { + if (checksums.get(file) !== sha256) return failure(base, `${file} 的官方 SHA-256 不匹配或缺失`); + } + const heads = await Promise.all([...expected.keys()].map(async (file) => { + const response = await fetch(new URL(file, base), { method: "HEAD", signal: controller.signal }); + return { file, status: response.status, ok: response.ok }; + })); + const unavailable = heads.filter((item) => !item.ok); + if (unavailable.length) return failure(base, `${unavailable[0].file} HTTP ${unavailable[0].status}`); + return { host: base.host, ok: true, message: `${expected.size} 个三平台制品可用且哈希一致` }; + } catch (error) { + return failure(base, error instanceof Error ? error.message : String(error)); + } finally { + clearTimeout(timeout); + } +} + +function parseChecksums(body) { + const values = new Map(); + for (const line of body.split(/\r?\n/)) { + const match = /^([a-f0-9]{64})\s+\*?(.+)$/.exec(line.trim()); + if (match) values.set(match[2], match[1]); + } + return values; +} + +function failure(base, message) { return { host: base.host, ok: false, message }; } diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 2cf9934..cb75a67 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -80,9 +80,25 @@ try { const install = readFileSync(join(releaseDir, "install.md"), "utf8"); if (install.includes("{{") || !install.includes(`版本:\`${version}\``) || !install.includes("唯一通用安装入口") || !install.includes("postInstall") - || !install.includes("onboarding.firstRun=true") || /--host|WorkBuddy|Codex|Claude/.test(install)) { + || !install.includes("onboarding.firstRun=true") || !install.includes("系统无需预装 Node.js") + || /--host|WorkBuddy|Codex|Claude/.test(install)) { throw new Error("install.md 版本、占位符或通用安装引导不正确"); } + const windowsBootstrap = readFileSync(join(releaseDir, artifactName(manifest.artifacts.bootstrapWindows, "bootstrapWindows")), "utf8"); + const posixBootstrapPath = join(releaseDir, artifactName(manifest.artifacts.bootstrapPosix, "bootstrapPosix")); + const posixBootstrap = readFileSync(posixBootstrapPath, "utf8"); + if (windowsBootstrap.includes("{{") || posixBootstrap.includes("{{") + || !windowsBootstrap.includes("Save-DingDownload") || !windowsBootstrap.includes("Expand-Archive") + || !posixBootstrap.startsWith("#!/bin/sh\nset -eu\n") || !posixBootstrap.includes("tar -xzf") + || /latest-v|npm install -g/.test(`${windowsBootstrap}\n${posixBootstrap}`)) { + throw new Error("无 Node 安装引导无效或仍包含不安全的动态版本来源"); + } + execFileSync("sh", ["-n", posixBootstrapPath]); + const managedPlatforms = [...(manifest.requirements?.managedNode?.platforms ?? [])].sort(); + if (!/^\d+\.\d+\.\d+$/.test(String(manifest.requirements?.managedNode?.version ?? "")) + || JSON.stringify(managedPlatforms) !== JSON.stringify([ + "darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-arm64", "win32-x64", + ])) throw new Error("清单没有完整声明三平台私有 Node 运行时"); const skill = readFileSync(join(releaseDir, artifactName(manifest.artifacts.skill, "skill")), "utf8"); if (!skill.startsWith("---\nname: dingtalk-approval\n")) throw new Error("通用 Skill artifact 无效"); } finally { diff --git a/src/bridge.ts b/src/bridge.ts index 546da82..49fe356 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -117,7 +117,7 @@ export class RemoteBridge { await this.tryCompleteDeviceLogin(); if (!this.usableAccessToken()) await this.refreshAccessToken(); if (!this.usableAccessToken()) throw new AuthRequired(await this.startDeviceLogin()); - const client = new Client({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.8" }); + const client = new Client({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.9" }); const transport = new StreamableHTTPClientTransport(this.endpoint, { requestInit: { headers: { authorization: `Bearer ${this.state.tokens!.access_token}` } }, }); @@ -388,7 +388,7 @@ function parseEndpoint(argv: string[]): string { } export function createBridgeMcpServer(bridge: RemoteBridge): McpServer { - const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.8" }, { + const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.9" }, { instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。", }); server.registerTool("approval_login", { diff --git a/src/client-installer.ts b/src/client-installer.ts index d73316a..0353cc0 100644 --- a/src/client-installer.ts +++ b/src/client-installer.ts @@ -20,12 +20,17 @@ export interface ClientReleaseManifest { createdAt: string; gatewayUrl: string; signing: { algorithm: "Ed25519"; keyId: string }; - requirements: { node: string }; + requirements: { + node: string; + managedNode?: { version: string; platforms: string[] }; + }; artifacts: { installer: ReleaseArtifact; publicKey: ReleaseArtifact & { keyId: string }; client: ReleaseArtifact; skill: ReleaseArtifact; + bootstrapWindows?: ReleaseArtifact; + bootstrapPosix?: ReleaseArtifact; }; } @@ -39,6 +44,9 @@ export interface ClientInstallState { skillSha256: string; runtimeDir: string; skillFile: string; + nodeExecutable?: string; + managedNode?: boolean; + nodeVersion?: string; } export interface ClientPaths { @@ -292,7 +300,11 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm 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]); + const installed = runNpm( + context, + ["install", "--prefix", staging, "--offline", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", clientArchive], + join(temporary, "npm-cache"), + ); if (installed.status !== 0) { rmSync(staging, { recursive: true, force: true }); throw new Error(`客户端运行时安装失败:${(installed.stderr || installed.stdout || "npm 执行失败").trim()}`); @@ -305,7 +317,7 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm } installBundledSkill(paths, join(versionDir, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval"), skillBody); writeAtomic(paths.publicKeyFile, publicArtifact, 0o600); - writeLaunchers(paths, context.platform); + const nodeRuntime = activeNodeRuntime(paths); const state: ClientInstallState = { schemaVersion: 1, version: manifest.version, @@ -316,7 +328,11 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm skillSha256: manifest.artifacts.skill.sha256, runtimeDir: relative(paths.root, versionDir), skillFile: relative(paths.root, paths.skillFile), + nodeExecutable: nodeRuntime.storedExecutable, + managedNode: nodeRuntime.managed, + nodeVersion: process.versions.node, }; + writeLaunchers(paths, context.platform, nodeRuntime.executable); writeAtomic(paths.stateFile, Buffer.from(`${JSON.stringify(state, null, 2)}\n`), 0o600); const legacyCleanupFailed: string[] = []; const legacyRemoved = cleanupLegacyInstallations(context.env, context.platform, context.home, legacyCleanupFailed); @@ -338,7 +354,7 @@ function uninstallClient(paths: ClientPaths, args: ParsedArgs, context: CommandC if (isSafeLegacySessionPath(legacySession, context.home)) rmSync(legacySession, { recursive: true, force: true }); const runningInsideRoot = isInside(paths.root, process.argv[1] ?? ""); if (existsSync(paths.root)) { - if (runningInsideRoot) scheduleSelfCleanup(paths.root); + if (runningInsideRoot) scheduleSelfCleanup(paths.root, context.platform); else rmSync(paths.root, { recursive: true, force: true }); } return { @@ -381,6 +397,14 @@ function doctor(paths: ClientPaths, context: CommandContext): Record): { ok: bool return version ? { ok: true, value: version } : { ok: false, message: "npm --version 未返回版本号" }; } -function runNpm(context: Pick, args: string[]) { +function runNpm(context: Pick, args: string[], cache?: string) { const invocation = npmInvocation(context, args); return spawnSync(invocation.command, invocation.args, { encoding: "utf8", - env: { ...context.env, npm_config_update_notifier: "false" }, + env: { + ...context.env, + ...(cache ? { npm_config_cache: cache } : {}), + npm_config_update_notifier: "false", + }, windowsHide: true, }); } function npmInvocation(context: Pick, args: string[]): { command: string; args: string[] } { - if (context.platform !== "win32") return { command: "npm", args }; + const pathSeparator = context.platform === "win32" ? ";" : ":"; const candidates = [ context.env.npm_execpath, - ...(context.env.PATH ?? "").split(";").filter(Boolean).map((entry) => join(entry.replace(/^"|"$/g, ""), "node_modules", "npm", "bin", "npm-cli.js")), join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), + resolve(dirname(process.execPath), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), + ...(context.env.PATH ?? "").split(pathSeparator).filter(Boolean).map((entry) => join(entry.replace(/^"|"$/g, ""), "node_modules", "npm", "bin", "npm-cli.js")), ]; const npmCli = candidates.find((candidate) => candidate && existsSync(candidate)); - if (!npmCli) throw new Error("Windows 环境未找到可由 Node 安全启动的 npm-cli.js;请修复 Node/npm 安装后重试"); - return { command: process.execPath, args: [npmCli, ...args] }; + if (npmCli) return { command: process.execPath, args: [npmCli, ...args] }; + if (context.platform !== "win32") return { command: "npm", args }; + throw new Error("Windows 环境未找到可由 Node 安全启动的 npm-cli.js;请运行公开安装引导修复私有 Node 运行时"); } export function cleanupLegacyInstallations( @@ -503,6 +533,11 @@ function installResult( legacyCleanupFailed, configurationModified: false, postInstall: { ...postInstall, loginCommand: loginCommand(paths) }, + nodeRuntime: { + version: state.nodeVersion ?? process.versions.node, + managed: state.managedNode === true, + executable: configuredNodeExecutable(paths, state), + }, nextSteps, }; } @@ -634,17 +669,32 @@ function installBundledSkill(paths: ClientPaths, bundledSkillDir: string, skillB } } -function writeLaunchers(paths: ClientPaths, platform: NodeJS.Platform): void { +function writeLaunchers(paths: ClientPaths, platform: NodeJS.Platform, nodeExecutable: string): void { mkdirPrivate(paths.binDir); const launcher = `#!/usr/bin/env node\nimport { readFileSync } from "node:fs";\nimport { dirname, join, resolve } from "node:path";\nimport { fileURLToPath } from "node:url";\nimport { spawn } from "node:child_process";\nconst mode = process.argv[2];\nconst root = resolve(dirname(fileURLToPath(import.meta.url)), "..");\nconst state = JSON.parse(readFileSync(join(root, "install-state.json"), "utf8"));\nconst runtime = resolve(root, state.runtimeDir);\nconst target = join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", mode === "bridge" ? "bridge.js" : "client-installer.js");\nconst args = mode === "bridge" ? ["--url", state.gatewayUrl, ...process.argv.slice(3)] : [...process.argv.slice(3), "--app-root", root];\nconst env = mode === "bridge" ? { ...process.env, DINGTALK_APPROVAL_BRIDGE_STATE: join(root, "state", "session.json") } : process.env;\nconst child = spawn(process.execPath, [target, ...args], { stdio: "inherit", env });\nchild.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });\nchild.on("error", (error) => { console.error(error.message); process.exitCode = 1; });\n`; writeAtomic(paths.launcherFile, Buffer.from(launcher), 0o700); + const bodies = clientLauncherBodies(platform, nodeExecutable); + writeAtomic(paths.cliCommand, Buffer.from(bodies.cli), 0o700); + writeAtomic(paths.bridgeCommand, Buffer.from(bodies.bridge), 0o700); +} + +export function clientLauncherBodies( + platform: NodeJS.Platform, + nodeExecutable: string, +): { cli: string; bridge: string } { if (platform === "win32") { - writeAtomic(paths.cliCommand, Buffer.from(`@echo off\r\nnode "%~dp0launcher.mjs" cli %*\r\n`), 0o700); - writeAtomic(paths.bridgeCommand, Buffer.from(`@echo off\r\nnode "%~dp0launcher.mjs" bridge %*\r\n`), 0o700); - } else { - writeAtomic(paths.cliCommand, Buffer.from(`#!/usr/bin/env sh\nexec node "$(dirname "$0")/launcher.mjs" cli "$@"\n`), 0o700); - writeAtomic(paths.bridgeCommand, Buffer.from(`#!/usr/bin/env sh\nexec node "$(dirname "$0")/launcher.mjs" bridge "$@"\n`), 0o700); + if (/["\r\n]/.test(nodeExecutable)) throw new Error("Node 可执行文件路径包含 Windows launcher 不支持的字符"); + const command = nodeExecutable.replace(/%/g, "%%"); + return { + cli: `@echo off\r\n"${command}" "%~dp0launcher.mjs" cli %*\r\n`, + bridge: `@echo off\r\n"${command}" "%~dp0launcher.mjs" bridge %*\r\n`, + }; } + const command = shellQuote(nodeExecutable); + return { + cli: `#!/usr/bin/env sh\nexec ${command} "$(dirname "$0")/launcher.mjs" cli "$@"\n`, + bridge: `#!/usr/bin/env sh\nexec ${command} "$(dirname "$0")/launcher.mjs" bridge "$@"\n`, + }; } function validateManifest(manifest: ClientReleaseManifest, manifestUrl: string, keyId: string): void { @@ -820,11 +870,37 @@ function assertSafeClientRoot(root: string, home: string): void { } } -function scheduleSelfCleanup(root: string): void { - const cleanupRoot = mkdtempSync(join(tmpdir(), "dingtalk-approval-cleanup-")); - const helper = join(cleanupRoot, "cleanup.mjs"); - writeFileSync(helper, `import { rmSync } from "node:fs";\nsetTimeout(() => { rmSync(${JSON.stringify(root)}, { recursive: true, force: true }); rmSync(${JSON.stringify(cleanupRoot)}, { recursive: true, force: true }); }, 750);\n`, { mode: 0o700 }); - const child = spawn(process.execPath, [helper], { detached: true, stdio: "ignore" }); +function activeNodeRuntime(paths: ClientPaths): { executable: string; storedExecutable: string; managed: boolean } { + const executable = realpathSync(process.execPath); + const managed = isInside(paths.root, executable); + return { + executable, + storedExecutable: managed ? relative(paths.root, executable) : executable, + managed, + }; +} + +function configuredNodeExecutable(paths: ClientPaths, state: ClientInstallState): string { + if (!state.nodeExecutable) return process.execPath; + if (state.managedNode === true) return resolveInside(paths.root, state.nodeExecutable); + if (!isAbsolute(state.nodeExecutable)) throw new Error("安装状态中的系统 Node 路径不是绝对路径"); + return resolve(state.nodeExecutable); +} + +function shellQuote(value: string): string { + if (/\0|\r|\n/.test(value)) throw new Error("Node 可执行文件路径包含 shell launcher 不支持的字符"); + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function scheduleSelfCleanup(root: string, platform: NodeJS.Platform): void { + const child = platform === "win32" + ? spawn("powershell.exe", [ + "-NoProfile", "-NonInteractive", "-Command", + "Start-Sleep -Milliseconds 900; Remove-Item -LiteralPath $args[0] -Recurse -Force", root, + ], { detached: true, stdio: "ignore", windowsHide: true }) + : spawn("/bin/sh", ["-c", "sleep 1; rm -rf -- \"$1\"", "dingtalk-approval-cleanup", root], { + detached: true, stdio: "ignore", + }); child.unref(); } diff --git a/src/server.ts b/src/server.ts index 9bacfa2..a839d63 100644 --- a/src/server.ts +++ b/src/server.ts @@ -56,7 +56,7 @@ export function createApprovalMcpServer( invoiceDedup?: InvoiceDeduplicationService, ): McpServer { const server = new McpServer( - { name: "dingtalk-approval-mcp", version: "1.1.0-rc.8" }, + { name: "dingtalk-approval-mcp", version: "1.1.0-rc.9" }, { instructions: APPROVAL_MCP_INSTRUCTIONS }, ); const unified = new UnifiedApprovalService(config, service, xft, xftChallenges, officialOaReturns, invoiceDedup); diff --git a/test/client-installer.test.ts b/test/client-installer.test.ts index 0ce6705..aae41dc 100644 --- a/test/client-installer.test.ts +++ b/test/client-installer.test.ts @@ -1,13 +1,13 @@ import assert from "node:assert/strict"; import { createHash, createPublicKey, generateKeyPairSync, sign } from "node:crypto"; import { - chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, writeFileSync, + chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { basename, delimiter, dirname, join } from "node:path"; import test from "node:test"; import { - cleanupLegacyInstallations, clientPaths, releaseKeyId, runClientInstaller, verifySignedManifest, + cleanupLegacyInstallations, clientLauncherBodies, clientPaths, releaseKeyId, runClientInstaller, verifySignedManifest, type ClientReleaseManifest, } from "../src/client-installer.js"; @@ -19,6 +19,18 @@ test("client paths stay in stable per-user locations on all supported platforms" assert.equal(clientPaths({}, "linux", "/users/alice").bridgeCommand, "/users/alice/.local/share/dingtalk-approval/bin/dingtalk-approval-bridge"); }); +test("client launchers pin the selected Node executable on Windows and POSIX", () => { + const windows = clientLauncherBodies("win32", "C:\\Users\\100% ready\\node.exe"); + assert.match(windows.cli, /^@echo off\r\n"C:\\Users\\100%% ready\\node\.exe"/); + assert.match(windows.bridge, /launcher\.mjs" bridge %\*/); + assert.doesNotMatch(windows.bridge, /\r\nnode /); + + const posix = clientLauncherBodies("linux", "/Users/alice's runtime/node"); + assert.match(posix.cli, /exec '\/Users\/alice'"'"'s runtime\/node'/); + assert.match(posix.bridge, /launcher\.mjs" bridge "\$@"/); + assert.throws(() => clientLauncherBodies("win32", "C:\\bad\nnode.exe"), /不支持的字符/); +}); + test("signed manifest verification rejects tampering and a foreign key", () => { const trusted = generateKeyPairSync("ed25519"); const foreign = generateKeyPairSync("ed25519"); @@ -107,7 +119,13 @@ test("generic installer preserves identity across safe updates, rollback, downgr }; const publicPath = join(root, "release-public.pem"); writeFileSync(publicPath, publicPem, { mode: 0o600 }); - const env: NodeJS.ProcessEnv = { PATH: [fakeBin, process.env.PATH ?? ""].filter(Boolean).join(delimiter), DINGTALK_APPROVAL_CLIENT_HOME: appRoot }; + const env: NodeJS.ProcessEnv = { + PATH: [fakeBin, process.env.PATH ?? ""].filter(Boolean).join(delimiter), + DINGTALK_APPROVAL_CLIENT_HOME: appRoot, + npm_execpath: process.platform === "win32" + ? join(fakeBin, "node_modules", "npm", "bin", "npm-cli.js") + : join(fakeBin, "fake-npm.mjs"), + }; const output: string[] = []; const commandContext = { env, platform: process.platform, home: join(root, "home"), fetchFn: fetchFn as typeof fetch, @@ -143,7 +161,14 @@ test("generic installer preserves identity across safe updates, rollback, downgr const state = JSON.parse(readFileSync(paths.stateFile, "utf8")); assert.equal(state.version, "1.1.0-rc.5"); assert.equal(state.host, undefined); + assert.equal(state.nodeExecutable, realpathSync(process.execPath)); + assert.equal(state.managedNode, false); + assert.equal(state.nodeVersion, process.versions.node); + assert.deepEqual(installOutput.nodeRuntime, { + version: process.versions.node, managed: false, executable: realpathSync(process.execPath), + }); assert.doesNotMatch(readFileSync(paths.bridgeCommand, "utf8"), /--url/); + assert.match(readFileSync(paths.bridgeCommand, "utf8"), new RegExp(realpathSync(process.execPath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); const stableBridgePath = paths.bridgeCommand; const initialRuntime = state.runtimeDir; @@ -189,6 +214,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr const health = JSON.parse(output.pop()!); assert.equal(health.ok, true); assert.equal(health.checks.find((check: { name: string }) => check.name === "npm").ok, true); + assert.equal(health.checks.find((check: { name: string }) => check.name === "nodeRuntime").ok, true); output.length = 0; assert.equal(await runClientInstaller([ @@ -371,6 +397,9 @@ if (process.argv.includes("--login") || process.argv.includes("--post-install-lo import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; if (process.argv[2] === "--version") { console.log("11.0.0"); process.exit(0); } +if (!process.env.npm_config_cache || !process.env.npm_config_cache.includes("dingtalk-approval-install-")) { + console.error("installer did not isolate the npm cache"); process.exit(3); +} const index = process.argv.indexOf("--prefix"); if (index < 0 || !process.argv[index + 1]) { console.error("missing prefix"); process.exit(2); } const prefix = process.argv[index + 1]; diff --git a/test/distribution.test.ts b/test/distribution.test.ts index b866131..0f41f12 100644 --- a/test/distribution.test.ts +++ b/test/distribution.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import test from "node:test"; @@ -19,6 +20,10 @@ 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, /系统无需预装 Node\.js/); + assert.match(installer, /阿里云精确版本.*npmmirror 精确版本.*Node 官方精确版本/s); + assert.match(installer, /BOOTSTRAP_WINDOWS_URL|Windows 安装引导/); + assert.match(installer, /Linux\/macOS 安装引导/); assert.match(installer, /dingtalk-approval-bridge\.cmd/); assert.match(installer, /显示 302 或启动后立即退出[\s\S]*type: \"stdio\"[\s\S]*doctor --json/); assert.match(installer, /sourceStatus[\s\S]*dingtalk\.status[\s\S]*xft\.status[\s\S]*都必须是 `ok`/); @@ -28,6 +33,25 @@ test("customer distribution is a generic single-login gateway bridge", () => { assert.doesNotMatch(installer, /通过当前 Messenger/); assert.doesNotMatch(installer, /DWS_CLI_PATH|dingtalk-workspace-cli|APP_MODE=local/); + const runtime = JSON.parse(readFileSync("distribution/node-runtime.json", "utf8")); + assert.equal(runtime.version, "22.23.2"); + assert.deepEqual(Object.keys(runtime.platforms).sort(), [ + "darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-arm64", "win32-x64", + ]); + assert.equal(runtime.sourceBases.length, 3); + assert.ok(runtime.sourceBases.every((value: string) => value.includes(`/v${runtime.version}/`) && !value.includes("latest"))); + assert.ok(Object.values(runtime.platforms).every((value) => /^[a-f0-9]{64}$/.test((value as { sha256: string }).sha256))); + + const windowsBootstrap = readFileSync("distribution/bootstrap-windows.template.ps1", "utf8"); + 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(posixBootstrap, /^#!\/bin\/sh\nset -eu/); + assert.match(posixBootstrap, /uname -s[\s\S]*uname -m[\s\S]*tar -xzf/); + assert.match(posixBootstrap, /SHA-256 不匹配,停止安装/); + assert.equal(spawnSync("sh", ["-n", "distribution/bootstrap-posix.template.sh"]).status, 0); + assert.doesNotMatch(`${windowsBootstrap}\n${posixBootstrap}`, /latest-v|npm install -g/); + const skill = readFileSync("skills/dingtalk-approval/SKILL.md", "utf8"); const createApproval = readFileSync("skills/dingtalk-approval/references/create-approval.md", "utf8"); assert.match(skill, /read `references\/create-approval\.md` completely/); @@ -42,6 +66,9 @@ test("customer distribution is a generic single-login gateway bridge", () => { assert.match(builder, /schemaVersion: 3,\s+version,/s); assert.match(builder, /signData\(null, manifestBytes/); assert.match(builder, /dingtalk-approval-client/); + assert.match(builder, /bootstrap-windows\.template\.ps1/); + assert.match(builder, /bootstrap-posix\.template\.sh/); + assert.match(builder, /validateNodeRuntime/); 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/); diff --git a/test/portable-node-bootstrap.test.ts b/test/portable-node-bootstrap.test.ts new file mode 100644 index 0000000..0f7b473 --- /dev/null +++ b/test/portable-node-bootstrap.test.ts @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const NODE_VERSION = "22.23.2"; +const RUNTIME_NAME = `node-v${NODE_VERSION}-test`; +const RUNTIME_ARCHIVE = `${RUNTIME_NAME}.tar.gz`; + +test("POSIX bootstrap installs a private Node and falls back after a network failure", { + skip: process.platform === "win32", +}, () => { + const fixture = createPosixFixture(); + try { + 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.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), [ + `https://primary.example.test/node/${RUNTIME_ARCHIVE}`, + `https://mirror.example.test/node/${RUNTIME_ARCHIVE}`, + ]); + assert.ok(calls.includes("https://release.example.test/installer.mjs")); + assert.ok(calls.includes("https://release.example.test/release-signing-public.pem")); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("POSIX bootstrap stops immediately when a downloaded runtime has the wrong hash", { + skip: process.platform === "win32", +}, () => { + const fixture = createPosixFixture(); + try { + const result = runPosixFixture(fixture, "corrupt"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /SHA-256 不匹配,停止安装/); + const calls = readFileSync(fixture.callLog, "utf8").trim().split(/\r?\n/); + assert.deepEqual(calls, [`https://primary.example.test/node/${RUNTIME_ARCHIVE}`]); + assert.equal(existsSync(join(fixture.clientRoot, "node", RUNTIME_NAME, "bin", "node")), false); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("POSIX bootstrap does not hide a non-404 HTTP error by switching mirrors", { + skip: process.platform === "win32", +}, () => { + const fixture = createPosixFixture(); + try { + const result = runPosixFixture(fixture, "server-error"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /不允许换源.*HTTP 500/); + const calls = readFileSync(fixture.callLog, "utf8").trim().split(/\r?\n/); + assert.deepEqual(calls, [`https://primary.example.test/node/${RUNTIME_ARCHIVE}`]); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("Windows bootstrap is valid PowerShell after release rendering", { + skip: process.platform !== "win32", +}, () => { + const root = mkdtempSync(join(tmpdir(), "dingmcp-windows-bootstrap-")); + try { + const script = join(root, "bootstrap.ps1"); + writeFileSync(script, renderWindowsBootstrap()); + const powershell = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + const parser = [ + "$tokens = $null; $errors = $null", + "[System.Management.Automation.Language.Parser]::ParseFile($args[0], [ref]$tokens, [ref]$errors) | Out-Null", + "if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_.Message }; exit 1 }", + ].join("; "); + const result = spawnSync(powershell, ["-NoProfile", "-NonInteractive", "-Command", parser, script], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +interface PosixFixture { + root: string; + clientRoot: string; + callLog: string; + script: string; + fakeBin: string; + archive: string; + installer: string; + publicKey: string; +} + +function createPosixFixture(): PosixFixture { + const root = mkdtempSync(join(tmpdir(), "dingmcp-posix-bootstrap-")); + const archiveRoot = join(root, "archive", RUNTIME_NAME); + const fakeBin = join(root, "bin"); + const clientRoot = join(root, "home", "dingtalk-approval"); + const callLog = join(root, "download-calls.log"); + const archive = join(root, RUNTIME_ARCHIVE); + const installer = join(root, "installer.mjs"); + const publicKey = join(root, "release-signing-public.pem"); + const script = join(root, "bootstrap.sh"); + mkdirSync(join(archiveRoot, "bin"), { recursive: true }); + mkdirSync(join(archiveRoot, "lib", "node_modules", "npm", "bin"), { recursive: true }); + mkdirSync(fakeBin, { recursive: true }); + mkdirSync(join(root, "home"), { recursive: true }); + + const privateNode = join(archiveRoot, "bin", "node"); + writeFileSync(privateNode, [ + "#!/bin/sh", + "if [ \"${1:-}\" = '-e' ]; then exit 0; fi", + "printf '%s\\n' '{\"ok\":true,\"status\":\"installed\",\"managedNode\":true}'", + "", + ].join("\n")); + chmodSync(privateNode, 0o700); + writeFileSync(join(archiveRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js"), "// fixture\n"); + const tar = spawnSync("tar", ["-czf", archive, "-C", join(root, "archive"), RUNTIME_NAME], { encoding: "utf8" }); + assert.equal(tar.status, 0, tar.stderr); + writeFileSync(installer, "// fixture installer\n"); + writeFileSync(publicKey, "fixture public key\n"); + writeFileSync(callLog, ""); + + const curl = join(fakeBin, "curl"); + writeFileSync(curl, `#!/bin/sh +ding_target='' +ding_url='' +while [ "$#" -gt 0 ]; do + case "$1" in + --output) ding_target="$2"; shift 2 ;; + http*) ding_url="$1"; shift ;; + *) shift ;; + esac +done +printf '%s\\n' "$ding_url" >> "$DING_TEST_CALL_LOG" +case "$ding_url" in + https://primary.example.test/node/*) + if [ "$DING_TEST_MODE" = 'corrupt' ]; then printf 'corrupt' > "$ding_target"; printf '200'; exit 0; fi + if [ "$DING_TEST_MODE" = 'server-error' ]; then printf '500'; exit 22; fi + printf '000'; exit 7 ;; + https://mirror.example.test/node/*) /bin/cp "$DING_TEST_ARCHIVE" "$ding_target"; printf '200' ;; + https://official.example.test/node/*) /bin/cp "$DING_TEST_ARCHIVE" "$ding_target"; printf '200' ;; + https://release.example.test/installer.mjs) /bin/cp "$DING_TEST_INSTALLER" "$ding_target"; printf '200' ;; + https://release.example.test/release-signing-public.pem) /bin/cp "$DING_TEST_PUBLIC_KEY" "$ding_target"; printf '200' ;; + *) printf '404'; exit 22 ;; +esac +`); + chmodSync(curl, 0o700); + const rejectedSystemNode = join(fakeBin, "node"); + writeFileSync(rejectedSystemNode, "#!/bin/sh\nexit 1\n"); + chmodSync(rejectedSystemNode, 0o700); + + let body = readFileSync("distribution/bootstrap-posix.template.sh", "utf8"); + const replacements: Record = { + "{{NODE_SOURCE_BASES_POSIX}}": "https://primary.example.test/node/ https://mirror.example.test/node/ https://official.example.test/node/", + "{{INSTALLER_URL}}": "https://release.example.test/installer.mjs", + "{{INSTALLER_SHA256}}": digest(installer), + "{{PUBLIC_KEY_URL}}": "https://release.example.test/release-signing-public.pem", + "{{PUBLIC_KEY_SHA256}}": digest(publicKey), + "{{MANIFEST_URL}}": "https://release.example.test/manifest.json", + }; + for (const platform of ["LINUX_X64", "LINUX_ARM64", "DARWIN_X64", "DARWIN_ARM64"]) { + replacements[`{{NODE_${platform}_FILE}}`] = RUNTIME_ARCHIVE; + replacements[`{{NODE_${platform}_SHA256}}`] = digest(archive); + } + for (const [placeholder, value] of Object.entries(replacements)) body = body.replaceAll(placeholder, value); + assert.doesNotMatch(body, /\{\{/); + writeFileSync(script, body); + chmodSync(script, 0o700); + return { root, clientRoot, callLog, script, fakeBin, archive, installer, publicKey }; +} + +function runPosixFixture(fixture: PosixFixture, mode: "fallback" | "corrupt" | "server-error") { + return spawnSync("/bin/sh", [fixture.script], { + encoding: "utf8", + env: { + HOME: join(fixture.root, "home"), + PATH: `${fixture.fakeBin}:/usr/bin:/bin`, + DINGTALK_APPROVAL_CLIENT_HOME: fixture.clientRoot, + DING_TEST_MODE: mode, + DING_TEST_CALL_LOG: fixture.callLog, + DING_TEST_ARCHIVE: fixture.archive, + DING_TEST_INSTALLER: fixture.installer, + DING_TEST_PUBLIC_KEY: fixture.publicKey, + }, + }); +} + +function renderWindowsBootstrap(): string { + let body = readFileSync("distribution/bootstrap-windows.template.ps1", "utf8"); + const replacements: Record = { + "{{NODE_SOURCE_BASES_POWERSHELL}}": '"https://primary.example.test/node/", "https://official.example.test/node/"', + "{{INSTALLER_URL}}": "https://release.example.test/installer.mjs", + "{{INSTALLER_SHA256}}": "a".repeat(64), + "{{PUBLIC_KEY_URL}}": "https://release.example.test/release-signing-public.pem", + "{{PUBLIC_KEY_SHA256}}": "b".repeat(64), + "{{MANIFEST_URL}}": "https://release.example.test/manifest.json", + }; + for (const platform of ["WIN32_X64", "WIN32_ARM64"]) { + replacements[`{{NODE_${platform}_FILE}}`] = `node-v${NODE_VERSION}-win-x64.zip`; + replacements[`{{NODE_${platform}_SHA256}}`] = "c".repeat(64); + } + for (const [placeholder, value] of Object.entries(replacements)) body = body.replaceAll(placeholder, value); + assert.doesNotMatch(body, /\{\{/); + return body; +} + +function digest(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +}