Author SHA1 Message Date
gitea-admin 0a7ad6a3b6 fix: honor proxies in runtime source verification
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-08-19 02:09:59 +00:00
gitea-admin 2aa78aebd9 fix: route cross-device OAuth through device flow
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-08-19 02:05:07 +00:00
gitea-admin 7e2a01c913 fix: make Windows portable Node bootstrap reliable 2026-08-19 01:12:13 +00:00
gitea-admin 0d5e192e95 feat: prefer remote MCP OAuth installation 2026-08-18 18:24:47 +00:00
gitea-admin 55e43e3451 fix: make release deployment safely repeatable
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-08-18 17:28:03 +00:00
22 changed files with 593 additions and 411 deletions
+8 -27
View File
@@ -3,11 +3,13 @@
一个面向 Agent 的统一审批 MCP。用户只登录一次钉钉,即可读取、处理和发起本人在钉钉 OA 与薪福通中的审批:
```text
Agent → 本地轻量 stdio 桥 → HTTPS/OAuth 统一网关
├─ 钉钉 OA OpenAPI
└─ 薪福通 OpenAPI
Agent → 远程 Streamable HTTP MCP / OAuth 统一网关
├─ 钉钉 OA OpenAPI
└─ 薪福通 OpenAPI
```
只有宿主不支持远程 MCP 或标准 OAuth 时,才使用本地 STDIO Bridge 兼容层。
## 工具
- `approval_login`:检查登录或返回唯一授权链接。
@@ -75,32 +77,11 @@ Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确
https://approval.justconn.top/downloads/dingmcp/install.md
```
用户把这个链接交给 Agent 后,Agent 先校验并运行当前平台的原生 PowerShell 或 POSIX 安装引导。Windows、Linux、macOS 均支持 x64 与 ARM64,系统无需预装 Node.js:引导会复用兼容的系统 Node,或者从阿里云、npmmirror、Node 官方的精确版本地址依次下载 Node 22.23.2,并用固定的 Node 官方 SHA-256 校验后安装为插件私有运行时。它不修改系统 PATH、不需要管理员权限,也不使用任何 `latest` 镜像别名。
同机交互的默认流程不下载任何客户端:Agent 按宿主官方方式把 `https://approval.justconn.top/mcp` 注册为远程 Streamable HTTP MCP,由宿主通过标准 OAuth 元数据、动态客户端注册和 PKCE 完成登录。若宿主注册 `127.0.0.1`/`localhost` 回调,授权页必须在运行 Agent 的同一设备打开。用户端不需要 Node.js、npm、DWS、企业凭证或本地常驻进程。用户本人确认授权后,Agent 用 `approval_list_todos(source=all, limit=1)` 只读验证钉钉 OA 与薪福通都可用。
随后安装器使用固定 Ed25519 公钥验证清单和发布物,把轻量客户端安装到稳定的用户数据目录,并统一返回 `bridgePath`、`skillPath`、`loginCommand`、`nodeRuntime` 和标准 `mcpConfig`;不再区分 Agent 品牌,不全局安装 npm 包,也不会猜测或直接修改 Agent 的私有配置。启动器固定使用安装时选定的 Node,用户以后升级系统 Node 或切换版本管理器不会改变 MCP 命令。安装器会立即在受保护的本地会话中检查登录并返回机器可读 `postInstall`:未登录时直接带回当前授权链接,网关暂时不可用也不会使安装回滚。Agent 随后按自身官方方式安装 Skill、注册并信任本地 stdio MCP;不能热加载连接器时再新建会话或重启。用户端不安装 DWS,不需要企业凭证。
服务端 MCP `instructions` 和工具描述包含完整审批工作流,因此远程模式不依赖本地 Skill 才能安全使用。宿主需要重新加载连接器时按其官方机制处理;OAuth 过期也由宿主标准刷新/重新授权流程解决,无需重装。
公开 `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 会话,都会返回当前有效链接或自动生成新链接。
首次授权会先显示网关自己的 OAuth 确认页,列出请求客户端、返回域名、权限范围或设备授权码;用户确认后才跳转钉钉。客户端名称由客户端自行声明,因此应同时核对域名或授权码。短暂的网关 5xx/网络故障不会清除本地 refresh token,也不会强迫用户重新登录。
客户端后续可使用稳定 CLI:
```text
dingtalk-approval login --json
dingtalk-approval print-path bridge
dingtalk-approval print-config --format mcp-json
dingtalk-approval doctor --json
dingtalk-approval update --json
dingtalk-approval uninstall --yes --json
```
实际 `dingtalk-approval` 路径由首次安装结果的 `cliPath` 给出。`login` 只使用现有运行时与登录会话;`update` 会复用已固定的清单地址与公钥,并保留 OAuth 登录会话。更新完成后应重新连接该 MCP,Agent 不能单独重启连接器时再新建会话或重启。卸载默认删除客户端运行时、Skill、安装记录和 OAuth 会话;Agent 中名为 `dingtalk-approval` 的连接器通过其官方入口删除。
宿主不支持远程 Streamable HTTP MCP/标准 OAuth,或用户通过 SSH、无头终端、Messenger 等方式跨设备操作而无法完成宿主本机回调时,使用 `compat-install.md` 安装本地 Bridge。Bridge 的 device-code 登录链接可以传到手机或另一台电脑打开。兼容引导仍支持 Windows、Linux、macOS x64/ARM64 和无 Node 环境,并使用固定 Ed25519 清单与 SHA-256 校验。PowerShell 发行脚本带 UTF-8 BOM,Node 探测避免 Windows PowerShell 5.1 吞引号;Windows 下载优先使用 `curl.exe`,连接、低速或 90 秒总超时会明确失败并换固定来源,不再静默等待 300 秒。
## 数据与安全
+11
View File
@@ -29,6 +29,17 @@ server {
add_header Content-Security-Policy "default-src 'none'" always;
}
location = /downloads/dingmcp/compat-install.md {
alias /var/www/approval.justconn.top/downloads/dingmcp/compat-install.md;
default_type text/markdown;
charset utf-8;
charset_types text/markdown;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'" always;
}
location = /downloads/dingmcp/manifest.json {
alias /var/www/approval.justconn.top/downloads/dingmcp/manifest.json;
default_type application/json;
+20 -6
View File
@@ -34,12 +34,18 @@ if [[ "$(git branch --show-current)" != "main" ]] || ! git diff --quiet || ! git
exit 1
fi
release_commit="$(git rev-parse HEAD)"
release_created_at="$(git show -s --format=%cI "${release_commit}")"
remote_main="$(git ls-remote origin refs/heads/main | cut -f1)"
if [[ -z "${remote_main}" || "${remote_main}" != "${release_commit}" ]]; then
echo "当前 main 提交尚未同步到 Gitea origin/main,拒绝创建发布标签。" >&2
exit 1
fi
release_tag_commit="$(git ls-remote origin "refs/tags/${release_tag}" "refs/tags/${release_tag}^{}" | tail -n1 | cut -f1)"
release_created_at_commit="${release_tag_commit:-${release_commit}}"
if ! git cat-file -e "${release_created_at_commit}^{commit}" 2>/dev/null; then
echo "现有发布标签指向本地不可验证的提交,拒绝重新生成同版本发布物。" >&2
exit 1
fi
release_created_at="$(git show -s --format=%cI "${release_created_at_commit}")"
if [[ ! -f "${project_root}/.runtime/dingtalk-mcp.env" ]]; then
echo "缺少 .runtime/dingtalk-mcp.env;首次部署前需先配置网关企业凭证。" >&2
@@ -74,18 +80,25 @@ npm run test-bundle:build -- \
(cd "${release_dir}" && sha256sum -c SHA256SUMS)
npm run release:verify -- --release-dir release
echo "[3/8] 发布或复用 Gitea 国内备用下载源"
echo "[3/8] 发布或复用 Gitea 国内首选下载源"
gitea_releases_json="$(mktemp)"
trap 'rm -f "${gitea_releases_json}"' EXIT
tea releases list --remote origin --limit 100 --output json >"${gitea_releases_json}"
if ! node -e 'const fs=require("fs");const tag=process.argv[1];const rows=JSON.parse(fs.readFileSync(0,"utf8"));process.exit(rows.some((row)=>row.tag_name===tag||row.tag===tag||row.Tag===tag)?0:1)' \
"${release_tag}" <"${gitea_releases_json}"; then
if node scripts/release-tag-exists.mjs "${release_tag}" <"${gitea_releases_json}"; then
:
else
release_lookup_status=$?
if [[ "${release_lookup_status}" -ne 1 ]]; then
echo "无法可靠判断 Gitea Release 是否存在,拒绝继续部署。" >&2
exit "${release_lookup_status}"
fi
release_flags=()
[[ "${release_version}" == *-* ]] && release_flags+=(--prerelease)
tea releases create --remote origin --tag "${release_tag}" --target "${release_commit}" \
--title "${release_tag}" --note "Signed DingTalk approval client ${release_version}" "${release_flags[@]}"
fi
mapfile -t mirror_assets < <(find "${release_dir}" -mindepth 1 -maxdepth 1 -type f -print | sort)
mapfile -t mirror_assets < <(find "${release_dir}" -mindepth 1 -maxdepth 1 -type f \
! -name 'agent-test-install.md' ! -name 'dingmcp-test-bundle.tar.gz' -print | sort)
missing_mirror_assets=()
mirror_check_file="$(mktemp)"
for asset in "${mirror_assets[@]}"; do
@@ -106,7 +119,7 @@ for asset in "${mirror_assets[@]}"; do
asset_name="$(basename "${asset}")"
curl --fail --silent --show-error --location "${gitea_release_base_url}/${asset_name}" -o "${mirror_check_file}"
[[ "$(sha256sum "${mirror_check_file}" | cut -d' ' -f1)" == "$(sha256sum "${asset}" | cut -d' ' -f1)" ]] || {
echo "Gitea 备用发布物校验失败:${asset_name}" >&2
echo "Gitea 首选发布物校验失败:${asset_name}" >&2
exit 1
}
done
@@ -163,6 +176,7 @@ curl --fail --silent --show-error --retry 10 --retry-delay 1 --retry-all-errors
curl --fail --silent --show-error \
"${public_base_url}/.well-known/oauth-protected-resource/mcp" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/install.md" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/compat-install.md" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/manifest.json" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/manifest.sig" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/dingtalk-approval-installer.mjs" >/dev/null
@@ -0,0 +1,279 @@
# 钉钉审批兼容客户端安装任务
仅当当前 Agent 明确不支持远程 Streamable HTTP MCP、标准 OAuth,或用户无法在宿主本机 OAuth 回调所在设备打开授权页面时,才使用本兼容安装。即使宿主支持远程 MCP,只要它使用 `127.0.0.1`/`localhost` 回调,而用户通过 SSH、无头环境或 Messenger 在另一台设备操作,就应使用本地 Bridge 的可跨设备 device-code 登录;同机回调可以完成时才返回主安装页直接连接 `{{GATEWAY_URL}}`。
用户把本文件或本文件的链接交给你,即表示授权你为当前用户安装或更新“钉钉审批”兼容客户端、Skill 和名为 `dingtalk-approval` 的 MCP。该授权不包含同意、拒绝、退回、撤回或发起任何审批。
请从“步骤 1”开始逐项执行并验收,不要跳步,不要让用户手工填写企业凭证,也不要根据经验自行改写安装器返回的路径或 MCP 配置。Windows 是完整支持的平台,必须优先使用下面的 PowerShell 步骤。
## 固定发布入口
- 版本:`{{VERSION}}`
- 安装器:`{{INSTALLER_URL}}`
- 安装器 SHA-256:`{{INSTALLER_SHA256}}`
- 发布公钥:`{{PUBLIC_KEY_URL}}`
- 公钥 SHA-256:`{{PUBLIC_KEY_SHA256}}`
- 公钥编号:`{{RELEASE_KEY_ID}}`
- 签名清单:`{{MANIFEST_URL}}`
- 清单签名:`{{MANIFEST_SIGNATURE_URL}}`
- 备用发布源:
{{DOWNLOAD_MIRROR_BASE_URLS_MARKDOWN}}
- 统一网关:`{{GATEWAY_URL}}`
- Windows 安装引导:`{{BOOTSTRAP_WINDOWS_URL}}`
- Windows 安装引导备用地址:
{{BOOTSTRAP_WINDOWS_MIRROR_URLS_MARKDOWN}}
- Windows 安装引导 SHA-256:`{{BOOTSTRAP_WINDOWS_SHA256}}`
- Linux/macOS 安装引导:`{{BOOTSTRAP_POSIX_URL}}`
- Linux/macOS 安装引导备用地址:
{{BOOTSTRAP_POSIX_MIRROR_URLS_MARKDOWN}}
- 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。
## 完成标准
只有以下条件全部成立,才能告诉用户“安装完成,可以使用”:
1. 对应平台安装引导的 SHA-256 正确,并已选用兼容的系统 Node 或经过官方哈希校验的私有 Node;
2. 安装器 JSON 返回 `ok: true`;
3. 已按当前 Agent 的官方方式安装返回的 `skillPath`;
4. 已原样使用返回的 `mcpConfig` 注册并信任本地 stdio MCP;
5. `approval_login` 确认已登录;
6. `approval_list_todos` 的双来源验证中,钉钉 OA 与薪福通都为 `ok`;
7. 未执行任何审批写操作。
## 步骤 1:确认操作系统与架构
支持以下当前用户环境:
- Windows 10/11:x64 或 ARM64;
- Linux:x64 或 ARM64;
- macOS:Intel x64 或 Apple Silicon ARM64。
系统无需预装 Node.js。安装引导会优先复用版本不低于 `{{NODE_MINIMUM_VERSION}}` 且 npm 可用的系统 Node;否则自动准备只属于本插件的 Node `{{NODE_VERSION}}`。不要让用户安装全局 Node,不要使用管理员权限、`sudo`、全局 npm 安装、DWS 或浏览器自动化。
## 步骤 2:下载、校验并执行平台安装引导
不要在浏览器中复制脚本内容,不要对 URL 解码、重新编码或手工拼接。必须先校验安装引导 SHA-256,再执行它。
为执行平台安装引导的 Agent 工具调用设置合理超时。引导的单个下载会在连接失败、长时间无数据或 90 秒总时限到达时明确失败并换用下一固定来源,不会静默等待 300 秒;安装器会把 `[dingtalk-approval]` 开头的阶段进度写入 stderr,把唯一的最终结果 JSON 写入 stdout。进度文本不是失败,也不能混入 JSON 解析。进程仍在运行时不得并发启动第二次安装。
安装器和后续更新优先使用系统 `curl` 下载签名清单与发布物;`curl` 会直接使用 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 和 `NO_PROXY`,Node `fetch` 仅在系统没有 `curl` 时兜底。登录和 Bridge 仍会按 Node 能力启用环境代理。不要设置全局 `NODE_OPTIONS=--use-env-proxy`,也不要手工预装 `node_modules`;这会让 npm 与 Node 的代理机制叠加,并破坏安装器的原子安装与故障恢复。
### Windows(PowerShell)
无论当前 Agent 默认使用 PowerShell、CMD 还是 Git Bash,Windows 都必须调用普通 PowerShell 执行下面的完整流程。不得从 Git Bash 直接运行内部的 `node dingtalk-approval-installer.mjs ...` 命令;内部安装器不是用户安装入口。
在普通 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"
$dingBootstrapUrls = @({{BOOTSTRAP_WINDOWS_URLS_POWERSHELL}})
$dingBootstrapDownloaded = $false
$dingCurl = Get-Command curl.exe -ErrorAction SilentlyContinue
foreach ($dingBootstrapUrl in $dingBootstrapUrls) {
try {
if ($dingCurl) {
& $dingCurl.Source --fail --location --silent --show-error --connect-timeout 10 --max-time 90 --speed-time 20 --speed-limit 1024 --output $dingBootstrap $dingBootstrapUrl
if ($LASTEXITCODE -ne 0) { throw "curl.exe 退出码 $LASTEXITCODE" }
} else {
Invoke-WebRequest -UseBasicParsing -Uri $dingBootstrapUrl -OutFile $dingBootstrap -TimeoutSec 90
}
} catch {
continue
}
$dingBootstrapHash = (Get-FileHash -Algorithm SHA256 $dingBootstrap).Hash.ToLowerInvariant()
if ($dingBootstrapHash -ne "{{BOOTSTRAP_WINDOWS_SHA256}}") { throw "Windows 安装引导 SHA-256 不匹配,停止安装:$dingBootstrapUrl" }
$dingBootstrapDownloaded = $true
break
}
if (-not $dingBootstrapDownloaded) { throw "所有 Windows 安装引导下载源均不可用" }
$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
```
完成后可删除 `$dingBootstrapDir`。任一下载或哈希检查失败都不能忽略。
### Linux / macOS(POSIX shell)
```bash
ding_bootstrap_dir="$(mktemp -d)"
ding_bootstrap="$ding_bootstrap_dir/dingtalk-approval-bootstrap.sh"
ding_bootstrap_downloaded=0
for ding_bootstrap_url in {{BOOTSTRAP_POSIX_URLS_POSIX}}; do
rm -f "$ding_bootstrap"
if command -v curl >/dev/null 2>&1; then
curl --fail --location --connect-timeout 10 --max-time 90 --speed-time 20 --speed-limit 1024 --output "$ding_bootstrap" "$ding_bootstrap_url" || continue
elif command -v wget >/dev/null 2>&1; then
wget --https-only --timeout=90 --output-document="$ding_bootstrap" "$ding_bootstrap_url" || continue
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 不匹配,停止安装:$ding_bootstrap_url" >&2
exit 1
fi
ding_bootstrap_downloaded=1
break
done
if [ "$ding_bootstrap_downloaded" -ne 1 ]; then
echo '所有 Linux/macOS 安装引导下载源均不可用' >&2
exit 1
fi
ding_install_json="$(sh "$ding_bootstrap")"
printf '%s\n' "$ding_install_json"
```
完成后可删除 `$ding_bootstrap_dir`。如果 `sha256sum` 与 `shasum` 都不存在,必须停止,不能跳过校验。
## 步骤 3:验收安装结果
步骤 2 的平台引导是唯一通用安装入口。它会:
1. 检查系统 Node;缺失或版本不兼容时,根据当前操作系统和 CPU 架构选择私有 Node;
2. 按“阿里云精确版本 → npmmirror 精确版本 → Node 官方精确版本”的顺序下载 Node;项目安装引导和发布物则优先使用国内 Gitea 静态发布源,再回退到自建 HTTPS 下载源和其他已配置镜像。网络或 HTTP 下载失败才切换来源,任一已下载文件的签名或 SHA-256 不匹配都会立即终止;
3. 把私有 Node 放在插件自己的当前用户数据目录,不修改系统 PATH,不要求管理员权限;
4. 下载并校验首次安装器和发布公钥,再由安装器验证 Ed25519 清单签名与全部客户端发布物;下载源不是信任根,备用源不能绕过签名或哈希;
5. 返回一份 JSON 安装结果。
安装器会在稳定的当前用户数据目录安装客户端与 Skill,不会全局安装 npm 包,也不会修改 Agent 私有配置。保留完整 JSON 结果,后续只使用其中的真实字段,不要猜路径。
安装结果至少必须包含:
- `ok: true`、`status: "installed"` 和 `version`;
- `cliPath`、`bridgePath`、`skillPath`;
- `loginCommand`;
- `mcpConfig.mcpServers.dingtalk-approval`;
- `postInstall`。
- `nodeRuntime`,其中 `managed: true` 表示正在使用插件私有 Node。
Windows 的 `bridgePath` 和 `mcpConfig...command` 正常应以 `dingtalk-approval-bridge.cmd` 结尾。路径中可能包含空格;保留完整字符串,不要拆分,不要去掉 `.cmd`,不要改为 `node`、`npx` 或远程 URL。
## 步骤 4:安装 Skill 并注册 MCP
1. 按当前 Agent 的官方方式安装 `skillPath` 指向的整个目录,而不是只复制 `SKILL.md`。确认目录中同时存在 `SKILL.md` 和 `references/create-approval.md`。
2. 使用 `mcpConfig` 只新增或替换名称精确为 `dingtalk-approval` 的连接器。安装器本身不会猜测或修改 Agent 的私有配置。
3. 如果 Agent 接受完整 MCP JSON,原样使用整个 `mcpConfig`;如果界面只接受单个服务器配置,原样使用 `mcpConfig.mcpServers.dingtalk-approval`。
4. 该连接器必须是本地 `stdio`,`command` 必须是返回的本地 `bridgePath`,`args` 必须为空数组。Windows 中必须保留 JSON 已正确转义的反斜杠。
5. 不要把 `{{GATEWAY_URL}}` 或 `https://approval.justconn.top/mcp` 作为 MCP URL、command 或参数;Bridge 会从已签名安装状态取得网关地址。
6. 不要添加 `--url`、企业凭证、用户 ID、额外环境变量或 shell 包装器。
正确配置的形态由安装器返回,语义如下;示例路径不能代替真实返回值:
```json
{
"mcpServers": {
"dingtalk-approval": {
"type": "stdio",
"command": "<安装器返回的本地 bridgePath;Windows 为 .cmd>",
"args": []
}
}
}
```
## 步骤 5:处理首次登录状态
安装器已经主动尝试准备登录。读取返回的 `postInstall` 及其 `status`,不得等到用户第一次查询审批:
- `authorization_required`:在完成本地 Skill/MCP 配置的同一轮回复中,把 `postInstall.authChallenge.directUrl` 作为完整、可点击的 URL 原样发给用户;不得只说“已生成”或“已推送”。同时说明链接有有效期,需由用户本人核对授权页面并确认。
- `authenticated`:已有登录可复用,不需要再次让用户授权,继续步骤 6 和步骤 7。
- `unavailable`:客户端安装仍然有效。先完成步骤 4;连接器加载后调用 `approval_login` 重试。不要删除会话、不要重装。
保存 `loginCommand.command` 和 `loginCommand.args`。如果首次链接过期且 MCP 尚未加载,按该对象原样执行命令;Windows PowerShell 的通用执行方式是:
```powershell
$dingLoginJson = & $dingInstall.loginCommand.command @($dingInstall.loginCommand.args)
$dingLogin = $dingLoginJson | ConvertFrom-Json
$dingLogin | ConvertTo-Json -Depth 20
```
把新结果中的 `authChallenge.directUrl` 原样展示。该命令只检查登录或生成新链接,不下载发布物、不修改 Agent 配置。MCP 已加载时则直接重新调用 `approval_login`。登录链接过期时绝不能重新安装。
## 步骤 6:信任并加载连接器
1. 如果当前 Agent 要求信任本地连接器,按其官方界面或命令确认名称为 `dingtalk-approval` 的连接器。
2. 如果不能热加载,完整退出并重新启动 Agent,或新建一个确定会重新读取 MCP 配置的会话;只刷新聊天内容通常不够。
3. 重启后确认能发现 `approval_login`、`approval_list_todos` 等钉钉审批工具。当前版本应发现 13 个工具。
4. 若连接器进程退出、显示 302 或无法启动,先执行步骤 9 的故障排查,不要把连接器改成远程 HTTP MCP。
## 步骤 7:确认登录并验证两个审批来源
用户完成网页授权后:
1. 调用 `approval_login`。工具不会通过其他消息通道自动推送链接;如果响应仍给出 URL,必须把 `content.text` 中的完整登录 URL 原样写入当前回复。
2. 登录成功后,按 `postInstall.verifyTool` 调用一次只读的 `approval_list_todos`,即 `source="all"`、空搜索、`limit=1`。
3. 检查结果中的 `sourceStatus`:`dingtalk.status` 和 `xft.status` 都必须是 `ok`。`disabled`、`auth_required`、`unavailable` 或缺少任一来源都不算安装验证完成。
4. 验证期间只读取最多一条待办,不调用任何写工具。
5. 成功响应包含 `onboarding.firstRun=true` 时,把其中的简短示例和安全提示向用户展示一次,不要自动执行示例。
## 步骤 8:向用户报告结果
最终只需清楚报告:已安装的版本、Skill 与 MCP 已加载、登录是否成功、钉钉 OA 与薪福通是否都通过只读验证。可以给出一次性使用示例。不要向用户倾倒安装 JSON、内部路径、Token、审批引用或诊断细节,除非用户正在排障。
安装阶段不得处理审批,也不得在登录和双来源验证完成前声称“已可使用”。
## 步骤 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 仍无法启动**:执行 `<cliPath> doctor --json`。只有所有 `checks` 都为 `ok: true` 才继续;把失败检查和错误原文报告给维护者,不要提供 Token 或会话文件。
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 归档。
9. **安装长时间无最终 JSON或被宿主终止**:查看 stderr 中最后一条 `[dingtalk-approval]` 阶段信息。单个下载超过 90 秒或连续 20 秒低速应自动失败,而不是继续等待;不要改用内部安装器,也不要并发重试。原进程确认已经退出后,可重新执行同一平台引导;它会识别已经死亡的安装进程、清理残留 staging 并安全续装。若明确提示另一个安装仍在运行,应等待该进程结束。
## 更新
更新不需要重新下载首次安装器。使用上次安装结果中的 `cliPath`:
Windows PowerShell:
```powershell
$dingUpdateJson = & $dingInstall.cliPath update --json
$dingUpdate = $dingUpdateJson | ConvertFrom-Json
$dingUpdate | ConvertTo-Json -Depth 20
```
Linux / macOS:
```text
<cliPath> update --json
```
更新会复用已固定的签名清单地址和公钥并保留 OAuth 会话。更新成功后,重新使用返回的 `mcpConfig` 核对同名连接器,并重新连接 MCP;必要时完整重启 Agent。然后重复步骤 7 的登录确认和双来源只读验证。
## 卸载
1. 按当前 Agent 的官方方式删除名称精确为 `dingtalk-approval` 的 MCP/连接器条目。
2. 执行安装结果中的 `cliPath uninstall --yes --json`;Windows PowerShell 可运行:
```powershell
& $dingInstall.cliPath uninstall --yes --json
```
3. 确认返回 `uninstalled` 或 `cleanup_scheduled`。后者表示当前 CLI 退出后完成自清理。
卸载默认清除客户端运行时、Skill、安装记录和 OAuth 会话。不要手工递归删除未知目录。
授权页面会展示请求客户端、回调域名、权限范围或设备授权码。必须由用户本人核对并确认,不得替用户自动提交 consent。网关短暂不可用时保留现有登录状态并稍后重试,不要删除会话文件或主动要求用户重新授权。
+26 -255
View File
@@ -1,267 +1,38 @@
# 钉钉审批客户端安装任务
# 钉钉审批 MCP 安装任务
用户把本文件或本文件的链接交给你,即表示授权你为当前用户安装或更新“钉钉审批”客户端、Skill 和名为 `dingtalk-approval` 的 MCP。该授权不包含同意、拒绝、退回、撤回或发起任何审批。
用户把本文件或链接交给你,即授权你为当前用户连接或更新名为 `dingtalk-approval` 的 MCP。该授权不包含任何审批写操作。
请从“步骤 1”开始逐项执行并验收,不要跳步,不要让用户手工填写企业凭证,也不要根据经验自行改写安装器返回的路径或 MCP 配置。Windows 是完整支持的平台,必须优先使用下面的 PowerShell 步骤。
版本:`{{VERSION}}`
## 固定发布入口
## 默认安装
- 版本:`{{VERSION}}`
- 安装器:`{{INSTALLER_URL}}`
- 安装器 SHA-256:`{{INSTALLER_SHA256}}`
- 发布公钥:`{{PUBLIC_KEY_URL}}`
- 公钥 SHA-256:`{{PUBLIC_KEY_SHA256}}`
- 公钥编号:`{{RELEASE_KEY_ID}}`
- 签名清单:`{{MANIFEST_URL}}`
- 清单签名:`{{MANIFEST_SIGNATURE_URL}}`
- 备用发布源:
{{DOWNLOAD_MIRROR_BASE_URLS_MARKDOWN}}
- 统一网关:`{{GATEWAY_URL}}`
- Windows 安装引导:`{{BOOTSTRAP_WINDOWS_URL}}`
- Windows 安装引导备用地址:
{{BOOTSTRAP_WINDOWS_MIRROR_URLS_MARKDOWN}}
- Windows 安装引导 SHA-256:`{{BOOTSTRAP_WINDOWS_SHA256}}`
- Linux/macOS 安装引导:`{{BOOTSTRAP_POSIX_URL}}`
- Linux/macOS 安装引导备用地址:
{{BOOTSTRAP_POSIX_MIRROR_URLS_MARKDOWN}}
- Linux/macOS 安装引导 SHA-256:`{{BOOTSTRAP_POSIX_SHA256}}`
- 私有 Node.js:`{{NODE_VERSION}}`(最低兼容版本 `{{NODE_MINIMUM_VERSION}}`)
1. 先同时检查两项条件:
- 当前 Agent 支持远程 **Streamable HTTP MCP** 和 MCP 标准 **OAuth 2.0**;
- 用户打开授权页面的设备能够完成宿主注册的 OAuth 回调。若回调地址是 `127.0.0.1` 或 `localhost`,用户必须在运行 Agent 的同一台设备上打开页面。通过 SSH、无头终端或 Messenger 操作远程 Agent,而用户会在手机或另一台电脑打开链接时,不满足此条件。
按实际能力和交互环境判断,不要按 Agent 品牌判断。若无法确定,只问用户一个问题:是否会在运行 Agent 的这台设备上打开授权页面。
2. 仅当两项都满足时,按宿主官方方式新增或更新连接器:
- 名称:`dingtalk-approval`
- URL:`{{GATEWAY_URL}}`
- 传输:Streamable HTTP
- 认证:OAuth(允许宿主使用服务端发现元数据和动态客户端注册)
3. 不要安装 Node.js、npm、本地 Bridge、DWS 或浏览器自动化;不要索取 AppKey、AppSecret、AuthoritySecret、CorpId、userId、员工号、Cookie、Token 或其他企业凭证。
4. 让宿主发起标准 OAuth 登录,并确保用户在能够完成该回调的设备上本人确认。不得代替用户点击授权,也不得声称链接已通过其他消息通道发送。若此时发现宿主使用本机回调而用户只能跨设备打开链接,应停止等待并改用下面的兼容安装,不要把不可完成的链接交给用户反复尝试。
5. 连接成功后调用一次只读 `approval_list_todos`,参数为 `source="all"`、空搜索、`limit=1`。只有响应中的钉钉 OA 与薪福通来源都为 `ok`,才报告安装完成。不得在安装验证中执行任何审批写操作。
6. 若宿主需要重启或重新加载才能识别新连接器,按其官方机制操作;不要因为等待加载而改装本地 Bridge。
首次安装通过上述 HTTPS 地址和固定 SHA-256 建立信任;安装器随后必须使用固定公钥验证 Ed25519 清单签名和全部发布物哈希。URL、TLS、签名或哈希任一不匹配都必须停止。不得索取 AppKey、AppSecret、AuthoritySecret、CorpId、userId、员工号、Cookie 或 Token。
服务端会通过 MCP `instructions` 和工具描述提供审批工作流。登录完成后的首次响应可能包含简短示例;向用户展示一次即可,不要自动执行示例。
## 完成标准
## 兼容安装
只有以下条件全部成立,才能告诉用户“安装完成,可以使用”:
只有在宿主明确不支持远程 Streamable HTTP MCP、不能完成 MCP 标准 OAuth,或用户无法在宿主本机回调所在设备打开授权页面时,才使用本地 STDIO Bridge:
1. 对应平台安装引导的 SHA-256 正确,并已选用兼容的系统 Node 或经过官方哈希校验的私有 Node;
2. 安装器 JSON 返回 `ok: true`;
3. 已按当前 Agent 的官方方式安装返回的 `skillPath`;
4. 已原样使用返回的 `mcpConfig` 注册并信任本地 stdio MCP;
5. `approval_login` 确认已登录;
6. `approval_list_todos` 的双来源验证中,钉钉 OA 与薪福通都为 `ok`;
7. 未执行任何审批写操作。
`{{COMPAT_INSTALL_URL}}`
## 步骤 1:确认操作系统与架构
备用地址:
{{COMPAT_INSTALL_MIRROR_URLS_MARKDOWN}}
支持以下当前用户环境:
本地 Bridge 使用可跨设备完成的 device-code 登录,授权页面可以通过当前对话发给用户并在手机或另一台电脑打开。不要在同机回调明确可用时先安装 Bridge 来“提高成功率”;若已经明确是 SSH、无头或跨设备交互,应直接进入兼容路径,不必先制造一次必然失败的远程 OAuth。
- Windows 10/11:x64 或 ARM64;
- Linux:x64 或 ARM64;
- macOS:Intel x64 或 Apple Silicon ARM64。
## 完成后
系统无需预装 Node.js。安装引导会优先复用版本不低于 `{{NODE_MINIMUM_VERSION}}` 且 npm 可用的系统 Node;否则自动准备只属于本插件的 Node `{{NODE_VERSION}}`。不要让用户安装全局 Node,不要使用管理员权限、`sudo`、全局 npm 安装、DWS 或浏览器自动化。
## 步骤 2:下载、校验并执行平台安装引导
不要在浏览器中复制脚本内容,不要对 URL 解码、重新编码或手工拼接。必须先校验安装引导 SHA-256,再执行它。
为执行平台安装引导的 Agent 工具调用设置至少 10 分钟超时。引导和安装器会把 `[dingtalk-approval]` 开头的阶段进度写入 stderr,把唯一的最终结果 JSON 写入 stdout;进度文本不是失败,也不能混入 JSON 解析。进程仍在运行时不得并发启动第二次安装。
安装器和后续更新优先使用系统 `curl` 下载签名清单与发布物;`curl` 会直接使用 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 和 `NO_PROXY`,Node `fetch` 仅在系统没有 `curl` 时兜底。登录和 Bridge 仍会按 Node 能力启用环境代理。不要设置全局 `NODE_OPTIONS=--use-env-proxy`,也不要手工预装 `node_modules`;这会让 npm 与 Node 的代理机制叠加,并破坏安装器的原子安装与故障恢复。
### Windows(PowerShell)
无论当前 Agent 默认使用 PowerShell、CMD 还是 Git Bash,Windows 都必须调用普通 PowerShell 执行下面的完整流程。不得从 Git Bash 直接运行内部的 `node dingtalk-approval-installer.mjs ...` 命令;内部安装器不是用户安装入口。
在普通 PowerShell 中逐行运行:
```powershell
$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"
$dingBootstrapUrls = @({{BOOTSTRAP_WINDOWS_URLS_POWERSHELL}})
$dingBootstrapDownloaded = $false
foreach ($dingBootstrapUrl in $dingBootstrapUrls) {
try {
Invoke-WebRequest -UseBasicParsing -Uri $dingBootstrapUrl -OutFile $dingBootstrap -TimeoutSec 300
} catch {
continue
}
$dingBootstrapHash = (Get-FileHash -Algorithm SHA256 $dingBootstrap).Hash.ToLowerInvariant()
if ($dingBootstrapHash -ne "{{BOOTSTRAP_WINDOWS_SHA256}}") { throw "Windows 安装引导 SHA-256 不匹配,停止安装:$dingBootstrapUrl" }
$dingBootstrapDownloaded = $true
break
}
if (-not $dingBootstrapDownloaded) { throw "所有 Windows 安装引导下载源均不可用" }
$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
```
完成后可删除 `$dingBootstrapDir`。任一下载或哈希检查失败都不能忽略。
### Linux / macOS(POSIX shell)
```bash
ding_bootstrap_dir="$(mktemp -d)"
ding_bootstrap="$ding_bootstrap_dir/dingtalk-approval-bootstrap.sh"
ding_bootstrap_downloaded=0
for ding_bootstrap_url in {{BOOTSTRAP_POSIX_URLS_POSIX}}; do
rm -f "$ding_bootstrap"
if command -v curl >/dev/null 2>&1; then
curl --fail --location --connect-timeout 10 --max-time 300 --output "$ding_bootstrap" "$ding_bootstrap_url" || continue
elif command -v wget >/dev/null 2>&1; then
wget --https-only --timeout=300 --output-document="$ding_bootstrap" "$ding_bootstrap_url" || continue
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 不匹配,停止安装:$ding_bootstrap_url" >&2
exit 1
fi
ding_bootstrap_downloaded=1
break
done
if [ "$ding_bootstrap_downloaded" -ne 1 ]; then
echo '所有 Linux/macOS 安装引导下载源均不可用' >&2
exit 1
fi
ding_install_json="$(sh "$ding_bootstrap")"
printf '%s\n' "$ding_install_json"
```
完成后可删除 `$ding_bootstrap_dir`。如果 `sha256sum` 与 `shasum` 都不存在,必须停止,不能跳过校验。
## 步骤 3:验收安装结果
步骤 2 的平台引导是唯一通用安装入口。它会:
1. 检查系统 Node;缺失或版本不兼容时,根据当前操作系统和 CPU 架构选择私有 Node;
2. 按“阿里云精确版本 → npmmirror 精确版本 → Node 官方精确版本”的顺序下载 Node;项目安装引导和发布物则优先使用国内 Gitea 静态发布源,再回退到自建 HTTPS 下载源和其他已配置镜像。网络或 HTTP 下载失败才切换来源,任一已下载文件的签名或 SHA-256 不匹配都会立即终止;
3. 把私有 Node 放在插件自己的当前用户数据目录,不修改系统 PATH,不要求管理员权限;
4. 下载并校验首次安装器和发布公钥,再由安装器验证 Ed25519 清单签名与全部客户端发布物;下载源不是信任根,备用源不能绕过签名或哈希;
5. 返回一份 JSON 安装结果。
安装器会在稳定的当前用户数据目录安装客户端与 Skill,不会全局安装 npm 包,也不会修改 Agent 私有配置。保留完整 JSON 结果,后续只使用其中的真实字段,不要猜路径。
安装结果至少必须包含:
- `ok: true`、`status: "installed"` 和 `version`;
- `cliPath`、`bridgePath`、`skillPath`;
- `loginCommand`;
- `mcpConfig.mcpServers.dingtalk-approval`;
- `postInstall`。
- `nodeRuntime`,其中 `managed: true` 表示正在使用插件私有 Node。
Windows 的 `bridgePath` 和 `mcpConfig...command` 正常应以 `dingtalk-approval-bridge.cmd` 结尾。路径中可能包含空格;保留完整字符串,不要拆分,不要去掉 `.cmd`,不要改为 `node`、`npx` 或远程 URL。
## 步骤 4:安装 Skill 并注册 MCP
1. 按当前 Agent 的官方方式安装 `skillPath` 指向的整个目录,而不是只复制 `SKILL.md`。确认目录中同时存在 `SKILL.md` 和 `references/create-approval.md`。
2. 使用 `mcpConfig` 只新增或替换名称精确为 `dingtalk-approval` 的连接器。安装器本身不会猜测或修改 Agent 的私有配置。
3. 如果 Agent 接受完整 MCP JSON,原样使用整个 `mcpConfig`;如果界面只接受单个服务器配置,原样使用 `mcpConfig.mcpServers.dingtalk-approval`。
4. 该连接器必须是本地 `stdio`,`command` 必须是返回的本地 `bridgePath`,`args` 必须为空数组。Windows 中必须保留 JSON 已正确转义的反斜杠。
5. 不要把 `{{GATEWAY_URL}}` 或 `https://approval.justconn.top/mcp` 作为 MCP URL、command 或参数;Bridge 会从已签名安装状态取得网关地址。
6. 不要添加 `--url`、企业凭证、用户 ID、额外环境变量或 shell 包装器。
正确配置的形态由安装器返回,语义如下;示例路径不能代替真实返回值:
```json
{
"mcpServers": {
"dingtalk-approval": {
"type": "stdio",
"command": "<安装器返回的本地 bridgePath;Windows 为 .cmd>",
"args": []
}
}
}
```
## 步骤 5:处理首次登录状态
安装器已经主动尝试准备登录。读取返回的 `postInstall` 及其 `status`,不得等到用户第一次查询审批:
- `authorization_required`:在完成本地 Skill/MCP 配置的同一轮回复中,把 `postInstall.authChallenge.directUrl` 作为完整、可点击的 URL 原样发给用户;不得只说“已生成”或“已推送”。同时说明链接有有效期,需由用户本人核对授权页面并确认。
- `authenticated`:已有登录可复用,不需要再次让用户授权,继续步骤 6 和步骤 7。
- `unavailable`:客户端安装仍然有效。先完成步骤 4;连接器加载后调用 `approval_login` 重试。不要删除会话、不要重装。
保存 `loginCommand.command` 和 `loginCommand.args`。如果首次链接过期且 MCP 尚未加载,按该对象原样执行命令;Windows PowerShell 的通用执行方式是:
```powershell
$dingLoginJson = & $dingInstall.loginCommand.command @($dingInstall.loginCommand.args)
$dingLogin = $dingLoginJson | ConvertFrom-Json
$dingLogin | ConvertTo-Json -Depth 20
```
把新结果中的 `authChallenge.directUrl` 原样展示。该命令只检查登录或生成新链接,不下载发布物、不修改 Agent 配置。MCP 已加载时则直接重新调用 `approval_login`。登录链接过期时绝不能重新安装。
## 步骤 6:信任并加载连接器
1. 如果当前 Agent 要求信任本地连接器,按其官方界面或命令确认名称为 `dingtalk-approval` 的连接器。
2. 如果不能热加载,完整退出并重新启动 Agent,或新建一个确定会重新读取 MCP 配置的会话;只刷新聊天内容通常不够。
3. 重启后确认能发现 `approval_login`、`approval_list_todos` 等钉钉审批工具。当前版本应发现 13 个工具。
4. 若连接器进程退出、显示 302 或无法启动,先执行步骤 9 的故障排查,不要把连接器改成远程 HTTP MCP。
## 步骤 7:确认登录并验证两个审批来源
用户完成网页授权后:
1. 调用 `approval_login`。工具不会通过其他消息通道自动推送链接;如果响应仍给出 URL,必须把 `content.text` 中的完整登录 URL 原样写入当前回复。
2. 登录成功后,按 `postInstall.verifyTool` 调用一次只读的 `approval_list_todos`,即 `source="all"`、空搜索、`limit=1`。
3. 检查结果中的 `sourceStatus`:`dingtalk.status` 和 `xft.status` 都必须是 `ok`。`disabled`、`auth_required`、`unavailable` 或缺少任一来源都不算安装验证完成。
4. 验证期间只读取最多一条待办,不调用任何写工具。
5. 成功响应包含 `onboarding.firstRun=true` 时,把其中的简短示例和安全提示向用户展示一次,不要自动执行示例。
## 步骤 8:向用户报告结果
最终只需清楚报告:已安装的版本、Skill 与 MCP 已加载、登录是否成功、钉钉 OA 与薪福通是否都通过只读验证。可以给出一次性使用示例。不要向用户倾倒安装 JSON、内部路径、Token、审批引用或诊断细节,除非用户正在排障。
安装阶段不得处理审批,也不得在登录和双来源验证完成前声称“已可使用”。
## 步骤 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 仍无法启动**:执行 `<cliPath> doctor --json`。只有所有 `checks` 都为 `ok: true` 才继续;把失败检查和错误原文报告给维护者,不要提供 Token 或会话文件。
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 归档。
9. **安装长时间无最终 JSON或被宿主终止**:先确认工具调用超时不少于 10 分钟,并查看 stderr 中最后一条 `[dingtalk-approval]` 阶段信息。不要改用内部安装器,也不要并发重试。原进程确认已经退出后,可重新执行同一平台引导;它会识别已经死亡的安装进程、清理残留 staging 并安全续装。若明确提示另一个安装仍在运行,应等待该进程结束。
## 更新
更新不需要重新下载首次安装器。使用上次安装结果中的 `cliPath`:
Windows PowerShell:
```powershell
$dingUpdateJson = & $dingInstall.cliPath update --json
$dingUpdate = $dingUpdateJson | ConvertFrom-Json
$dingUpdate | ConvertTo-Json -Depth 20
```
Linux / macOS:
```text
<cliPath> update --json
```
更新会复用已固定的签名清单地址和公钥并保留 OAuth 会话。更新成功后,重新使用返回的 `mcpConfig` 核对同名连接器,并重新连接 MCP;必要时完整重启 Agent。然后重复步骤 7 的登录确认和双来源只读验证。
## 卸载
1. 按当前 Agent 的官方方式删除名称精确为 `dingtalk-approval` 的 MCP/连接器条目。
2. 执行安装结果中的 `cliPath uninstall --yes --json`;Windows PowerShell 可运行:
```powershell
& $dingInstall.cliPath uninstall --yes --json
```
3. 确认返回 `uninstalled` 或 `cleanup_scheduled`。后者表示当前 CLI 退出后完成自清理。
卸载默认清除客户端运行时、Skill、安装记录和 OAuth 会话。不要手工递归删除未知目录。
授权页面会展示请求客户端、回调域名、权限范围或设备授权码。必须由用户本人核对并确认,不得替用户自动提交 consent。网关短暂不可用时保留现有登录状态并稍后重试,不要删除会话文件或主动要求用户重新授权。
只向用户说明连接器已就绪、登录状态,以及钉钉 OA/薪福通是否通过只读验证。不要展示内部 Token、用户 ID、审批引用或连接器诊断数据。
+2 -2
View File
@@ -4,9 +4,9 @@
- 测试包:`{{BUNDLE_URL}}`
- SHA-256:`{{BUNDLE_SHA256}}`
- 模式:轻量本地桥 + 统一审批网关;一次钉钉登录同时覆盖钉钉 OA 与薪福通,必须完整验证两个审批源
- 模式:同机回调可达时使用远程 Streamable HTTP MCP + 宿主原生 OAuth;跨设备且宿主使用 loopback 回调时使用本地 Bridge + device-code。一次钉钉登录同时覆盖钉钉 OA 与薪福通,必须完整验证两个审批源
安装必须从包内的 `install.md` 进入,通过原生安装器完成 Ed25519 清单和发布物校验;不要改用旧版全局 npm 安装步骤。
安装必须从包内的 `install.md` 进入。同机回调可达时不得安装 Node/npm 或本地 Bridge;只有宿主明确不支持远程 MCP/标准 OAuth,或用户无法在宿主 loopback 回调所在设备打开授权页时,才按其中链接的 `compat-install.md` 走签名兼容安装,并在报告中说明选择原因。
只通过安装后的 `dingtalk-approval` MCP 读取和处理审批业务,不使用上游接口旁路。验证一次登录、钉钉 OA 与薪福通的待办、详情和动作能力;任何来源显示 `disabled` 都应视为发行配置错误并停止测试。真实写操作仅限用户明确确认的一次同意;可从任一来源选择,但不得因其中一方不可用而跳过。不要真实拒绝或退回。若结果为 `outcome_uncertain`,禁止自动重试。
+3 -2
View File
@@ -29,7 +29,8 @@ ding_hash_file() {
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 \
if ding_fetch_http="$(curl --fail --location --silent --show-error --connect-timeout 10 --max-time 90 \
--speed-time 20 --speed-limit 1024 \
--write-out '%{http_code}' --output "$2" "$1")"; then
return 0
else
@@ -47,7 +48,7 @@ ding_fetch() {
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
if wget --https-only --timeout=90 --server-response --output-document="$2" "$1" 2>"$ding_wget_log"; then
rm -f "$ding_wget_log"
return 0
else
+21 -4
View File
@@ -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
}
@@ -23,17 +27,30 @@ function Get-DingSha256([string] $dingPath) {
function Save-DingDownload([string[]] $dingUrls, [string] $dingTarget, [string] $dingExpectedSha256) {
$dingLastError = $null
$dingCurl = Get-Command curl.exe -ErrorAction SilentlyContinue
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
if ($dingCurl) {
$dingCurlHttp = & $dingCurl.Source --fail --location --silent --show-error --connect-timeout 10 --max-time 90 --speed-time 20 --speed-limit 1024 --write-out "%{http_code}" --output $dingTarget $dingUrl
$dingCurlExit = $LASTEXITCODE
if ($dingCurlExit -ne 0) {
if ($dingCurlHttp -match '^[1-5][0-9][0-9]$' -and $dingCurlHttp -ne '000') { throw "HTTP $dingCurlHttp" }
throw "curl.exe 退出码 $dingCurlExit"
}
} else {
Invoke-WebRequest -UseBasicParsing -Uri $dingUrl -OutFile $dingTarget -TimeoutSec 90
}
} 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 -and $dingLastError -match 'HTTP ([0-9][0-9][0-9])') {
$dingStatusCode = [int]$Matches[1]
}
if ($null -eq $dingStatusCode -or $dingStatusCode -eq 404) { continue }
throw "下载失败且不允许换源(HTTP $dingStatusCode):$dingUrl;$dingLastError"
}
@@ -49,7 +66,7 @@ function Save-DingDownload([string[]] $dingUrls, [string] $dingTarget, [string]
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)'
& $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"
@@ -79,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)) {
@@ -126,7 +143,7 @@ try {
$dingProxyConfigured = $env:HTTPS_PROXY -or $env:HTTP_PROXY -or $env:ALL_PROXY
$dingProxyAlreadyEnabled = ($env:NODE_OPTIONS -match '(^|\s)--use-env-proxy($|\s)') -or ($env:NODE_USE_ENV_PROXY -match '^(1|true)$')
if ($dingProxyConfigured -and -not $dingProxyAlreadyEnabled) {
& $dingNodePath -e 'process.exit(process.allowedNodeEnvironmentFlags.has("--use-env-proxy") ? 0 : 1)'
& $dingNodePath -e "process.exit(process.allowedNodeEnvironmentFlags.has('--use-env-proxy') ? 0 : 1)"
if ($LASTEXITCODE -eq 0) { $dingNodeProxyArgs = @("--use-env-proxy") }
}
$dingInstallOutput = & $dingNodePath @dingNodeProxyArgs @dingInstallerArgs
+11 -15
View File
@@ -3,24 +3,20 @@
## 数据路径
```text
用户 ─ Messenger ─ Agent ─ stdio 轻量桥 ─ HTTPS/OAuth ─ 统一审批网关
├─ 钉钉 OA
└─ 薪福通
用户 ─ Messenger ─ Agent ─ 远程 Streamable HTTP MCP/OAuth ─ 统一审批网关
├─ 钉钉 OA
└─ 薪福通
```
本地桥只完成 MCP stdio 与远程 Streamable HTTP 的转换,保存用户自己的短期 access/refresh token,不包含企业密钥、不读取审批上游,也不监听端口。
支持远程 MCP 和标准 OAuth,且用户能完成宿主所注册回调的场景直接连接网关,不安装客户端运行时。若宿主使用 `127.0.0.1`/`localhost` 回调,授权页必须在 Agent 所在设备打开。宿主缺少远程传输/OAuth 能力,或用户通过 SSH、无头终端、Messenger 等方式跨设备交互而无法完成本机回调时,使用本地 Bridge 把 stdio 转换为远程 Streamable HTTP,并通过可跨设备的 device-code 登录。Bridge 只保存用户自己的短期 access/refresh token,不包含企业密钥、不读取审批上游,也不监听端口。
## 客户端安装边界
终端不再使用全局 npm prefix。无第三方依赖的首次安装器通过自有 HTTPS 到达用户机器,随后使用固定 Ed25519 公钥验证 schema v3 清单签名,并校验每个 artifact 的 SHA-256。客户端运行依赖被内置在签名 artifact 中,终端使用 npm 离线模式展开,不在安装时引入清单以外的代码。首次安装以 HTTPS 和安装说明中固定的安装器/公钥哈希建立信任;本地固定公钥保护后续更新链路。发布私钥不进入 Git、网关进程或公开目录。
同机回调可达时,默认安装仅在宿主中登记远程 HTTPS MCP URL。OAuth 客户端注册、PKCE、Token 保存和刷新均由宿主的标准 MCP 实现负责,终端不需要 Node/npm、Bridge 或项目安装器。安装 Agent 必须按实际回调拓扑选择路径:能够显示 URL 不等于能够完成回调;回调是 loopback 且用户会在另一台设备打开时直接选择兼容 Bridge。网关的 MCP `instructions` 与工具描述提供业务工作流,终端修改本地提示不能绕过服务器的身份和写前校验。
运行时位于稳定的用户数据目录,启动器与 Node/NVM 具体版本解耦。Windows、Linux、macOS 的平台引导会复用兼容系统 Node;系统没有 Node 时,从固定精确版本的国内镜像下载并用 Node 官方 SHA-256 校验 x64/ARM64 制品,再放入插件自己的用户目录。项目发布物把国内 Gitea Release 作为首选静态源,并在签名清单中固定自建 HTTPS 备用源,首次安装与更新可在网络失败时自动回退;下载源不构成信任根,签名或哈希不符时立即终止。安装和更新优先调用系统 `curl`,因此直接复用宿主已验证的代理配置;Node `fetch` 只在没有 `curl` 时兜底。安装不修改 PATH,也不需要管理员权限。启动器固定使用安装时选定的 Node,更新和卸载沿用同一运行时;离线 npm 子进程不会继承 Node 专用代理开关。
兼容发行物只为不支持远程 MCP/OAuth 的宿主保留。它不使用全局 npm prefix:首次安装器以固定 Ed25519 公钥验证 schema v3 清单和 artifact SHA-256,并把内置依赖展开到稳定的用户目录。Windows/Linux/macOS 引导可复用系统 Node 或安装经官方哈希校验的固定私有 Node,不改 PATH、不要求管理员权限。发布物优先使用国内 Gitea 和 Node 固定版本源,自建 HTTPS/官方源作为受同一签名或哈希保护的备用。
安装器只有一条通用路径,输出稳定 `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 入口只在名称和目标包都精确匹配时清理。
兼容 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 领域接口
@@ -44,16 +40,16 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId
## 一次登录
安装器在安装完成后立即调用 Bridge 的登录准备模式,不等待首次业务请求。未登录时,`postInstall.authChallenge.directUrl` 直接携带一个钉钉授权链接;用户授权后,Agent 调用 `approval_login` 确认状态。安装结果同时返回轻量 `loginCommand`:连接器尚未加载且链接过期时,它直接复用已安装 Bridge 和本地会话生成新链接,不下载发布物、不改 Agent 配置;连接器已加载时则再次调用 `approval_login`。授权成功后,网关从钉钉身份得到当前企业 `userId`:
远程模式由宿主按 MCP 标准读取网关的 Protected Resource / Authorization Server Metadata,通过动态客户端注册、授权码 + PKCE 和 Token 刷新完成登录。宿主负责呈现授权入口;用户本人完成 consent 和钉钉登录后,网关从已验证 Token 得到当前企业 `userId`:
- 钉钉 OA 直接按该 `userId` 查询本人审批任务;
- 薪福通先把该 `userId` 映射为企业成员,再从本人企业成员档案读取花名册员工号。
两条数据路径共用同一个网关 Token,因此不会出现本地 DWS 与网关分别登录的情况。
Bridge 的受保护会话中只记录 `pending/delivered` 一次性 onboarding 状态,不保存任何审批正文。登录挑战不会消费该状态;首次认证成功响应,或 Agent 跳过登录工具后的首个成功业务响应,会在普通 `content.text` 和 `structuredContent.onboarding` 中同时返回简短用法示例并标记已展示。错误响应、登录过期和后续升级都不会重复展示或自动执行示例。
首次认证后的首个成功业务响应会在普通 `content.text` 和 `structuredContent.onboarding` 中同时返回简短用法示例;错误响应不会自动执行示例。兼容 Bridge 另外在本地受保护会话中记录 `pending/delivered` 状态,避免重复展示,但不保存审批正文。
远程 `https://approval.justconn.top/mcp` 已按 MCP 授权规范提供 HTTP 401 challenge、OAuth Protected Resource Metadata、Authorization Server Metadata、动态客户端注册、PKCE、Token 刷新和撤销;原生支持远程 MCP OAuth 的宿主可以直接接管浏览器授权。面向只支持 stdio 或不能承接 OAuth 回调的宿主,轻量桥使用 device-code 兼容流程。由于部分宿主(例如只展示 `content.text` 的客户端)会丢弃 `structuredContent`,桥会同时在普通文本、标准 `resource_link` 和 `authChallenge.directUrl` 中返回同一个短期登录链接。桥不会调用不存在的消息通道自动推送链接;Agent 必须把 URL 原样写入当前回复,不得只声称已生成或已推送,也不得读取本地会话文件提取它。
远程 `https://approval.justconn.top/mcp` 已按 MCP 授权规范提供 HTTP 401 challenge、OAuth Protected Resource Metadata、Authorization Server Metadata、动态客户端注册、PKCE、Token 刷新和撤销。面向只支持 stdio 或不能承接 OAuth 回调的宿主,轻量桥使用 device-code 兼容流程;桥会在普通文本、标准 `resource_link` 和 `authChallenge.directUrl` 中返回同一个短期登录链接,不会调用不存在的消息通道自动推送。
开放动态客户端注册不等于静默授权。授权码和设备码流程都会先进入网关 consent 页,展示客户端自报名称、回调 origin、权限范围和设备授权码;只有用户显式确认后才生成钉钉登录跳转,回调也会再次检查 consent 状态。OAuth 客户端总量限制为 10,000,180 天未使用且没有活跃授权工件的客户端会被清理;过期登录请求、授权码和 Token 每小时清理。钉钉用户 token 刷新按用户 single-flight,避免并发消费同一个 refresh token;撤销 refresh token 会级联撤销同客户端、同用户的授权。
@@ -86,7 +82,7 @@ Bridge 的受保护会话中只记录 `pending/delivered` 一次性 onboarding
撤回接口若返回 `XFTSYS*` 服务异常或网络级异常,网关会立即只读复核本人单据状态:已撤回则返回 `revoked`,仍为待审批则返回带 `postWriteVerified` 的 `failed`,无法明确归类才返回 `outcome_uncertain`。任何分支都不会自动重试写操作。
截至 `1.1.0-rc.9`,薪福通撤回仍是实验能力:当前企业真实调用 `approve/recall` 会返回 `XFTSYS9999`,写后复核确认单据仍为待审批,尚无成功样本。该限制不影响薪福通查询、审批处理或发起审批;正式版不得把“接口已接通”表述为“撤回已验证成功”。
截至 `1.1.0-rc.14`,薪福通撤回仍是实验能力:当前企业真实调用 `approve/recall` 会返回 `XFTSYS9999`,写后复核确认单据仍为待审批,尚无成功样本。该限制不影响薪福通查询、审批处理或发起审批;正式版不得把“接口已接通”表述为“撤回已验证成功”。
薪福通创建或撤回需要复核时,`approval_get_submission_status` 先用服务器派生的本人员工号和单据详情双重确认目标属于当前用户的已发起单据,再返回状态名称和精简流程历史。只有实时状态仍为 `1(待审批)` 才签发新的短期、用户绑定 `revokeId`;已通过、草稿、拒绝或已撤回单据都不会获得撤回引用。该工具不返回审批正文,也不触发写操作。
+7 -20
View File
@@ -37,7 +37,7 @@ https://approval.justconn.top/oauth/dingtalk/callback
创建接口必须保持权限校验开启。网关统一提交 `APPRV_PEND`,并在写后实时读取本人单据状态:进入待审批时提供撤回引用;按企业流程规则即时通过时返回 `completed`,不提供撤回引用且禁止重复提交。
`1.1.0-rc.9` 中薪福通撤回仍标记为实验能力:权限和调用契约已接通,但当前企业真实请求返回 `XFTSYS9999`,尚未取得成功样本。网关会写后复核并禁止自动重试。面向试用用户时必须保留这一说明;若业务要求撤回必须稳定可用,应等待薪福通定位后再升级为正式能力。
`1.1.0-rc.14` 中薪福通撤回仍标记为实验能力:权限和调用契约已接通,但当前企业真实请求返回 `XFTSYS9999`,尚未取得成功样本。网关会写后复核并禁止自动重试。面向试用用户时必须保留这一说明;若业务要求撤回必须稳定可用,应等待薪福通定位后再升级为正式能力。
## 首次凭证配置
@@ -80,7 +80,7 @@ cd /path/to/dingMCP
脚本自动测试、构建、用 Ed25519 私钥签署 schema v3 清单、重启服务、原子发布静态文件、安装 Nginx 配置并验证公网健康检查、OAuth 元数据和安装链接。首次签证书时,临时 HTTP 配置只开放 ACME challenge,其余请求返回 403;不会在明文 HTTP 上代理 OAuth、Token 或 MCP。证书通过 Certbot webroot 模式签发,正式配置启用 HTTPS 跳转与不带 `includeSubDomains` 的 HSTS。已有证书会自动复用;签名私钥缺失、权限不是 `0600` 或与固定公钥不匹配时发行会失败。
发行物先复制到独立 staging 目录,再通过符号链接原子切换;客户端不会看到新清单配旧 artifact 的中间状态。服务器保留当前版本和最近两个旧版本用于排障,较老版本自动清理。`install.md`、清单、签名、安装器、Skill、校验文件和测试包均以 `no-cache` 提供;内容寻址的客户端 tgz 才使用 immutable 缓存。签名清单的 `createdAt` 超过 180 天时客户端拒绝安装,避免旧签名版本冻结攻击。
发行物先复制到独立 staging 目录,再通过符号链接原子切换;客户端不会看到新清单配旧 artifact 的中间状态。服务器保留当前版本和最近两个旧版本用于排障,较老版本自动清理。`install.md`、`compat-install.md`、清单、签名、安装器、Skill、校验文件和测试包均以 `no-cache` 提供;内容寻址的客户端 tgz 才使用 immutable 缓存。签名清单的 `createdAt` 超过 180 天时兼容客户端拒绝安装,避免旧签名版本冻结攻击。
卸载:
@@ -97,6 +97,7 @@ cd /path/to/dingMCP
```text
release/
├── install.md
├── compat-install.md
├── manifest.json
├── manifest.sig
├── SHA256SUMS
@@ -116,27 +117,13 @@ release/
https://approval.justconn.top/downloads/dingmcp/install.md
```
Agent 先校验当前平台引导脚本的固定 SHA-256,再执行该引导。平台引导选择并校验 Node 运行时,同时校验首次安装器和发布公钥;安装器随后验证 `manifest.sig` 和全部 artifact 哈希,将内置运行依赖的客户端以 npm 离线模式安装到稳定用户目录并返回标准 MCP 配置。终端安装过程不会从 npm registry 下载额外代码。Bridge 的稳定绝对路径不会包含 NVM 的具体 Node 版本,配置也不再携带 `--url`;网关地址由受签名保护的安装状态提供。
公开 `install.md` 是按回调拓扑选择路径的短安装说明。同机浏览器能够完成宿主回调时,Agent 应按宿主官方方式注册名为 `dingtalk-approval` 的远程 Streamable HTTP MCP,URL 为 `https://approval.justconn.top/mcp`,并由宿主使用标准 OAuth 元数据、动态客户端注册和 PKCE 登录。该路径不下载 Node/npm、Bridge 或 Skill;网关 MCP `instructions` 已提供完整业务规则。登录后只读调用 `approval_list_todos(source=all, limit=1)`,钉钉 OA 与薪福通都为 `ok` 才算验收通过。
正式部署会先把同一批不可变发布文件上传到公开 Gitea Release,并把它写成每个 artifact 的首选下载地址;`approval.justconn.top` 静态目录作为签名清单固定的备用地址。平台引导、首次安装和后续更新都会在网络失败、超时或 404 时自动回退;任何来源一旦返回内容,Ed25519 签名或 SHA-256 不匹配都会立即终止。两个域名仍可能共享基础设施,如需机器级容灾,可再向 `release:build` 追加一个或多个 `--download-mirror-base-url` 指向独立的国内 OSS/COS。
宿主缺少远程 MCP/标准 OAuth,或 loopback 回调与用户打开授权页的设备不一致时,`install.md` 引导到 `compat-install.md`,无需先尝试一次必然失败的远程登录。兼容页保留签名客户端、平台引导、Skill、stdio MCP、可跨设备的 device-code 登录、更新、卸载和故障恢复步骤。Windows PowerShell 产物必须带 UTF-8 BOM,Node 探测使用外层双引号/内层单引号,避免 PowerShell 5.1 吞掉传给原生程序的双引号。
公开 `install.md` 必须保留从平台检查到双来源验收的完整编号步骤,并分别提供 Windows PowerShell 与 Linux/macOS 的独立路径。三平台都必须支持系统没有 Node 的情况:发布流程固定 Node 精确版本及 Windows/Linux/macOS x64/ARM64 的官方 SHA-256;平台引导依次使用阿里云、npmmirror 和 Node 官方精确版本地址,网络失败才换源,哈希不符立即终止。禁止使用 `latest` 镜像别名、修改系统 PATH 或要求管理员权限。
兼容 Windows 下载优先使用 `curl.exe`,从系统代理变量继承代理,并设置 10 秒连接超时、20 秒低速判定和 90 秒单源总时限;没有 curl 才以 `Invoke-WebRequest -TimeoutSec 90` 兜底。Node 固定版本制品仍按阿里云、npmmirror、官方源顺序尝试并强制校验官方 SHA-256。项目 artifact 由 Ed25519 清单和 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 流式硬上限,不会先把无界响应完整缓冲到内存;互不依赖的客户端包、Skill 和公钥并行下载。
平台引导与安装器把阶段进度写入 stderr,stdout 只保留最终 JSON。Agent 执行首次安装时必须给予至少 10 分钟工具超时;Windows 不论默认 shell 是什么都必须使用 PowerShell bootstrap,禁止直接调用内部 Node 安装器。安装器优先由当前 Node 直接启动相邻的 `npm-cli.js`,不会依赖易受 PATH、shell 或 Node 安全加固影响的隐式 npm shim;离线展开限时 180 秒。跨进程安装锁禁止并发写入,同一 PID 存活时要求等待,进程已死亡时自动回收锁并清理残留 staging。
代理环境不需要 Agent 修改全局 `NODE_OPTIONS`。首次安装和后续更新优先使用系统 `curl` 获取清单与发布物,直接复用 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 与 `NO_PROXY`;只有系统没有 `curl` 时才使用 Node `fetch`。平台引导和稳定启动器只对登录及 Bridge 等联网 Node 子进程按能力添加 `--use-env-proxy`;npm 子进程会移除该 Node 专用开关和 `NODE_USE_ENV_PROXY`,但保留标准代理变量,由 npm 使用自己的代理实现。禁止用手工预装 `node_modules` 绕过这一流程。
安装器不会识别 Agent 品牌或直接修改其配置。所有 Agent 都使用返回的 `skillPath` 和标准 `mcpConfig`,按自身官方方式安装 Skill、注册并信任名为 `dingtalk-approval` 的本地 stdio MCP;不能热加载连接器时再新建会话或重启。
安装器在返回前通过已安装 Bridge 主动准备登录,并在 JSON 的 `postInstall` 中返回 `authenticated`、`authorization_required` 或 `unavailable`,同时返回独立的 `loginCommand`。Agent 必须立即显示其中的短期 `directUrl`,不得等待首次审批请求;网页授权后调用 `approval_login`,再按 `verifyTool` 用 `source=all` 做双来源只读验证。链接过期时,MCP 已加载就重新调用 `approval_login`,尚未加载就执行 `loginCommand`,禁止为刷新链接重跑安装。首次认证成功会返回一次性 onboarding 示例,普通文本宿主也可见。登录准备失败不回滚安装,且整个流程不提交 consent、不读取审批、不执行写操作。
安装后可用稳定 CLI:`login --json` 只检查登录或生成新授权链接;`print-path bridge` 默认只输出路径;`print-config --format mcp-json` 输出可直接消费的标准 MCP JSON;`doctor --json` 同时检查 Node、npm、运行时、Skill、公钥和网关配置。后续更新直接执行安装结果中 `cliPath` 对应的 `update --json`,它会复用已固定的签名清单地址和本地公钥,并保留 OAuth 会话。更新完成后应重新连接该 MCP;Agent 不能单独重启连接器时,再新建会话或重启。
客户端卸载使用安装结果返回的 `cliPath uninstall --yes --json`,默认删除 OAuth 会话,并安全清理由旧版手工删除留下的四个 npm 入口;只会删除名称精确匹配且目标属于 `dingtalk-approval-mcp` 的链接或 Windows shim。
正式部署会把同一批不可变兼容安装文件上传到公开 Gitea Release,并把自建 HTTPS 作为固定备用。远程默认路径不依赖这些客户端文件,只依赖网关健康、OAuth 元数据和 `/mcp`。同一版本重跑复用 Release Tag 的提交时间和归一化测试包,保持可重入。
## 运维检查
+9 -10
View File
@@ -3,19 +3,18 @@
## 每个候选版必须完成
- 当前提交位于 `main`,工作区除明确忽略的本地缓存外干净;版本号、插件版本和 Git 标签一致。
- `npm ci`、`npm test`、`npm audit --omit=dev --audit-level=high` 全部通过。
- `npm ci`、`npm test`、`npm audit --omit=dev --audit-level=high` 全部通过。发布机存在代理环境变量时,运行时来源校验应优先使用系统 `curl` 继承代理,并在短超时内给出明确失败;不要依赖默认不读取代理的 Node `fetch`。
- `npm run runtime:verify` 确认 Node 官方精确版本源和至少一个国内源同时提供全部六个平台制品,且镜像 `SHASUMS256.txt` 与仓库固定哈希一致。
- `release:build`、`test-bundle:build` 和 `release:verify` 成功,`SHA256SUMS` 校验通过,发行物不包含 `.secrets`、`.runtime`、数据库、私钥或机器私有路径。
- 国内 Gitea 首选源与自建 HTTPS 备用目录的 `manifest.json`、`manifest.sig` 和全部附件逐文件哈希一致;模拟首选源网络失败/404 后能从备用源完成首次安装和更新,模拟首选源返回错误哈希或签名时必须立即停止而不换源。
- 国内 Gitea 首选源与自建 HTTPS 备用目录的 `manifest.json`、`manifest.sig` 和全部安装附件逐文件哈希一致;黑盒测试包及动态提示词只要求自建目录可用。连续部署同一 Tag 必须复用既有 Release 和固定 `createdAt`,不得重复创建或因测试包时间戳产生哈希漂移。模拟首选源网络失败/404 后能从备用源完成首次安装和更新,模拟首选源返回错误哈希或签名时必须立即停止而不换源。
- Nginx bootstrap 只开放 ACME challenge;正式站点启用 HSTS,mutable 发行文件返回 `no-cache`,客户端 tgz 才返回 immutable。连续两次部署时公开目录始终对应一个完整签名清单,旧版本保留数符合策略。
- 从新生成的公开 `install.md` 在干净的用户级目录安装,不使用 `sudo`;安装结果主动返回 `postInstall`,可发现全部 13 个 MCP 工具。
- 在未安装 Node 的干净 Windows x64/ARM64、Linux x64/ARM64、macOS Intel/Apple Silicon 用户环境验证平台引导。确认只使用固定精确版本、国内镜像失败会依次回退、任一哈希不符立即终止、私有 Node 不修改 PATH 且更新后仍可启动。至少在实际可用的每类操作系统完成一次端到端安装;其余架构用对应 CI 或发行制品校验覆盖。
- 在干净 Windows 用户环境用公开教程的 PowerShell 路径完成引导 SHA-256 校验、安装 JSON 解析和 stdio 注册;实际 `command` 以 `.cmd` 结尾、`args` 为空,不含网关 URL,并通过 `doctor --json`。同时验证路径含空格、连接器信任和完整重启场景。
- 验证 stdout 只有最终 JSON、stderr 持续包含阶段进度;宿主工具超时设为至少 10 分钟。模拟离线 npm 超时、外部终止留下的死亡锁和 staging、并发第二次安装,分别确认明确失败、安全恢复和零并发写入。Windows 从 Git Bash 发起测试时仍须进入 PowerShell bootstrap,不得直接运行内部安装器。
- 在 Windows 代理沙箱中验证系统 `curl` 是安装和更新的首选下载器,并实际使用 `HTTPS_PROXY`;登录和 Bridge 继续可联网。父进程含 `NODE_OPTIONS="--trace-warnings --use-env-proxy"` 与 `NODE_USE_ENV_PROXY=1` 时,npm 子进程只保留无关 Node 选项并继续保留标准代理变量,离线展开不得卡死。
- 验证安装结果返回 `loginCommand`:未加载 MCP 时执行 `login --json` 能复用现有运行时生成或检查登录链接,不下载发布物;链接过期后生成新链接,已登录时不再返回挑战,未安装时明确失败。
- 首次安装不等待业务查询即产生一条可传递链接;挑战阶段 onboarding 仍为 pending,授权后的首次成功响应在普通文本中展示一次示例,此后不重复。
- 授权后钉钉 OA 与薪福通均为 `ok`,网关重启后登录状态仍可恢复;登录准备暂时失败不会回滚安装或删除原 OAuth 会话。
- 从新生成的公开 `install.md` 验证两条决策路径:同机浏览器可完成回调时注册远程 `https://approval.justconn.top/mcp`,确认不下载 Node/npm/Bridge、不要求本地命令,并能通过 Protected Resource / Authorization Server Metadata、DCR 和 PKCE 完成宿主原生 OAuth;SSH/无头/Messenger 跨设备打开授权页且宿主使用 loopback 回调时,不先尝试必然失败的远程登录,直接选择兼容 Bridge。
- 远程 OAuth 后可发现全部 12 个业务工具,`approval_list_todos(source=all, limit=1)` 中钉钉 OA 与薪福通均为 `ok`;网关重启后宿主能刷新或重新授权,用户身份仍由服务端 Token 决定。
- `install.md` 只有在远程能力缺失或本机 OAuth 回调对用户不可达时才链接 `compat-install.md`。用至少一个只支持 stdio 的宿主和一个跨设备 loopback 场景验证兼容安装可发现 `approval_login` 加 12 个业务工具,device-code 链接可在另一台设备打开,且不产生第二套用户身份。
- 在未安装 Node 的干净 Windows x64/ARM64、Linux x64/ARM64、macOS Intel/Apple Silicon 环境验证兼容引导。确认固定版本、国内镜像回退、哈希失败即停止、私有 Node 不改 PATH;至少每类操作系统一次端到端,其余架构由 CI/制品校验覆盖。
- 在 Windows PowerShell 5.1 验证生成的 `.ps1` 具有 UTF-8 BOM,中文可解析,Node 版本和代理能力探测不会因原生程序参数引号被吞而失败;确认 `.zip` 仅去除一次扩展名,解压目录与 `node-v<version>-win-<arch>` 完全一致。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、部门/城市选项和越权拒绝;历史引用必须被动作查询和批处理拒绝。
- 发票查重验证钉钉/XFT 跨来源精确匹配、弱匹配、用户隔离、状态更新、来源不可用、历史截断、详情失败、并发确认、35 天过期清理和网关重启恢复;数据库不得出现用户 ID、发票原号、审批正文或附件。重复风险和不完整覆盖未获用户确认时不得签发可提交 `confirmationId`。
- 真实写操作只执行用户明确授权的样本;任何 `outcome_uncertain` 都停止,不自动重试。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.11",
"version": "1.1.0-rc.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.11",
"version": "1.1.0-rc.14",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"express": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.11",
"version": "1.1.0-rc.14",
"description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.",
"private": true,
"type": "module",
+8 -8
View File
@@ -109,7 +109,7 @@ const commonBootstrapReplacements = new Map([
]);
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-windows.template.ps1", join(out, bootstrapWindowsName), commonBootstrapReplacements, { utf8Bom: true });
renderTemplate("distribution/bootstrap-posix.template.sh", join(out, bootstrapPosixName), commonBootstrapReplacements);
execFileSync("sh", ["-n", join(out, bootstrapPosixName)]);
const artifacts = {
@@ -139,7 +139,6 @@ writeFileSync(join(out, "manifest.sig"), `${JSON.stringify({
schemaVersion: 1, algorithm: "Ed25519", keyId, signature,
}, null, 2)}\n`, "utf8");
const template = readFileSync(resolve("distribution/agent-install.template.md"), "utf8");
const replacements = new Map([
["{{VERSION}}", version], ["{{MANIFEST_URL}}", `${downloadBaseUrl}/manifest.json`],
["{{MANIFEST_SIGNATURE_URL}}", `${downloadBaseUrl}/manifest.sig`], ["{{GATEWAY_URL}}", gatewayUrl],
@@ -153,13 +152,14 @@ const replacements = new Map([
["{{BOOTSTRAP_POSIX_URL}}", artifacts.bootstrapPosix.url], ["{{BOOTSTRAP_POSIX_SHA256}}", artifacts.bootstrapPosix.sha256],
["{{BOOTSTRAP_POSIX_URLS_POSIX}}", artifactUrls(artifacts.bootstrapPosix).join(" ")],
["{{BOOTSTRAP_POSIX_MIRROR_URLS_MARKDOWN}}", (artifacts.bootstrapPosix.mirrors ?? []).map((value) => ` - \`${value}\``).join("\n") || " - 未配置"],
["{{COMPAT_INSTALL_URL}}", `${downloadBaseUrl}/compat-install.md`],
["{{COMPAT_INSTALL_MIRROR_URLS_MARKDOWN}}", downloadMirrorBaseUrls.length
? downloadMirrorBaseUrls.map((value) => ` - \`${value}/compat-install.md\``).join("\n") : " - 未配置"],
["{{NODE_VERSION}}", nodeRuntime.version], ["{{NODE_MINIMUM_VERSION}}", nodeRuntime.minimumVersion],
["{{RELEASE_KEY_ID}}", keyId],
]);
let prompt = template;
for (const [from, to] of replacements) prompt = prompt.replaceAll(from, to);
if (/\{\{[^}]+\}\}/.test(prompt)) throw new Error("安装指令仍包含未替换占位符");
writeFileSync(join(out, "install.md"), prompt, "utf8");
renderTemplate("distribution/agent-install.template.md", join(out, "install.md"), replacements);
renderTemplate("distribution/agent-compat-install.template.md", join(out, "compat-install.md"), replacements);
const checksums = Object.values(artifacts).map((value) => `${value.sha256} ${basename(new URL(value.url).pathname)}`);
checksums.push(`${sha256(join(out, "manifest.json"))} manifest.json`, `${sha256(join(out, "manifest.sig"))} manifest.sig`);
writeFileSync(join(out, "SHA256SUMS"), `${checksums.join("\n")}\n`, "utf8");
@@ -190,11 +190,11 @@ function lockedVersion(name) {
return value;
}
function renderTemplate(source, target, replacements) {
function renderTemplate(source, target, replacements, options = {}) {
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");
writeFileSync(target, options.utf8Bom ? `\uFEFF${body}` : body, "utf8");
}
function validateNodeRuntime(value) {
+5 -2
View File
@@ -24,8 +24,11 @@ if (gateway.protocol !== "https:" || gateway.pathname !== "/mcp" || gateway.sear
throw new Error("manifest.gatewayUrl 必须是无 query/hash 的 HTTPS /mcp 地址");
}
const artifactNames = [...new Set(Object.values(manifest.artifacts ?? {}).map((item) => basename(String(item.url))))];
const members = ["SHA256SUMS", "install.md", "manifest.json", "manifest.sig", ...artifactNames];
execFileSync("tar", ["-czf", bundle, "-C", releaseDir, ...members]);
const members = ["SHA256SUMS", "install.md", "compat-install.md", "manifest.json", "manifest.sig", ...artifactNames];
execFileSync("tar", [
"--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "--format=ustar",
"-czf", bundle, "-C", releaseDir, ...members,
]);
const digest = createHash("sha256").update(readFileSync(bundle)).digest("hex");
let prompt = readFileSync(resolve("distribution/agent-test.template.md"), "utf8");
prompt = prompt.replaceAll("{{BUNDLE_URL}}", bundleUrl).replaceAll("{{BUNDLE_SHA256}}", digest);
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
const tag = process.argv[2];
if (!tag) {
console.error("用法:release-tag-exists.mjs <tag>");
process.exit(2);
}
let body = "";
for await (const chunk of process.stdin) body += chunk;
let value;
try {
value = JSON.parse(body);
} catch {
console.error("无法解析 Gitea Release 列表 JSON");
process.exit(2);
}
const rows = Array.isArray(value)
? value
: Array.isArray(value?.data)
? value.data
: Array.isArray(value?.releases)
? value.releases
: undefined;
if (!rows) {
console.error("Gitea Release 列表 JSON 结构不受支持");
process.exit(2);
}
const found = rows.some((row) => row && typeof row === "object" && Object.entries(row).some(([key, entry]) => {
const normalizedKey = key.toLowerCase().replace(/[^a-z]/g, "");
return (normalizedKey === "tag" || normalizedKey === "tagname") && entry === tag;
}));
process.exit(found ? 0 : 1);
+32 -9
View File
@@ -1,8 +1,13 @@
#!/usr/bin/env node
import { execFile as execFileCallback, spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { promisify } from "node:util";
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 execFile = promisify(execFileCallback);
const curlAvailable = spawnSync("curl", ["--version"], { stdio: "ignore" }).status === 0;
const nullDevice = process.platform === "win32" ? "NUL" : "/dev/null";
const results = await Promise.all(catalog.sourceBases.map(checkSource));
for (const result of results) {
console.log(`${result.ok ? "OK" : "FAIL"} ${result.host}: ${result.message}`);
@@ -18,18 +23,11 @@ async function checkSource(sourceBase) {
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());
const checksums = parseChecksums(await requestText(new URL("SHASUMS256.txt", base), controller.signal));
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}`);
await Promise.all([...expected.keys()].map((file) => assertAvailable(new URL(file, base), controller.signal)));
return { host: base.host, ok: true, message: `${expected.size} 个三平台制品可用且哈希一致` };
} catch (error) {
return failure(base, error instanceof Error ? error.message : String(error));
@@ -38,6 +36,31 @@ async function checkSource(sourceBase) {
}
}
async function requestText(url, signal) {
if (!curlAvailable) {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`${url.pathname} HTTP ${response.status}`);
return response.text();
}
const { stdout } = await execFile("curl", [
"--fail", "--silent", "--show-error", "--location",
"--connect-timeout", "10", "--max-time", "20", url.toString(),
], { encoding: "utf8", maxBuffer: 2 * 1024 * 1024, signal });
return stdout;
}
async function assertAvailable(url, signal) {
if (!curlAvailable) {
const response = await fetch(url, { method: "HEAD", signal });
if (!response.ok) throw new Error(`${url.pathname} HTTP ${response.status}`);
return;
}
await execFile("curl", [
"--fail", "--silent", "--show-error", "--location", "--head",
"--connect-timeout", "10", "--max-time", "20", "--output", nullDevice, url.toString(),
], { encoding: "utf8", maxBuffer: 256 * 1024, signal });
}
function parseChecksums(body) {
const values = new Map();
for (const line of body.split(/\r?\n/)) {
+26 -6
View File
@@ -87,18 +87,38 @@ try {
if (!installer.startsWith("#!/usr/bin/env node\n") || /from ["']\.\//.test(installer)) throw new Error("首次安装器必须是无相对依赖的独立 Node 脚本");
const install = readFileSync(join(releaseDir, "install.md"), "utf8");
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 版本、占位符或通用安装引导不正确");
|| !install.includes(manifest.gatewayUrl) || !install.includes("Streamable HTTP") || !install.includes("OAuth")
|| !install.includes("compat-install.md") || !install.includes("不要安装 Node.js、npm、本地 Bridge")
|| /PowerShell|Git Bash|postInstall|loginCommand|npm install -g|--host|WorkBuddy|Codex|Claude/.test(install)) {
throw new Error("install.md 不是有效的远程 MCP 优先安装引导");
}
const windowsBootstrap = readFileSync(join(releaseDir, artifactName(manifest.artifacts.bootstrapWindows, "bootstrapWindows")), "utf8");
const compatibilityInstall = readFileSync(join(releaseDir, "compat-install.md"), "utf8");
if (compatibilityInstall.includes("{{") || !compatibilityInstall.includes(`版本:\`${version}\``)
|| !compatibilityInstall.includes("仅当当前 Agent 明确不支持远程 Streamable HTTP MCP、标准 OAuth,或用户无法在宿主本机 OAuth 回调所在设备打开授权页面")
|| !compatibilityInstall.includes("可跨设备 device-code 登录")
|| !compatibilityInstall.includes("postInstall") || !compatibilityInstall.includes("onboarding.firstRun=true")
|| !compatibilityInstall.includes("系统无需预装 Node.js") || !compatibilityInstall.includes("Git Bash")
|| /TimeoutSec 300|--max-time 300|--timeout=300/.test(compatibilityInstall)
|| /--host|WorkBuddy|Codex|Claude/.test(compatibilityInstall)) {
throw new Error("compat-install.md 版本、占位符或兼容安装引导不正确");
}
const windowsBootstrapBytes = readFileSync(join(releaseDir, artifactName(manifest.artifacts.bootstrapWindows, "bootstrapWindows")));
if (windowsBootstrapBytes[0] !== 0xef || windowsBootstrapBytes[1] !== 0xbb || windowsBootstrapBytes[2] !== 0xbf) {
throw new Error("Windows PowerShell 引导缺少 UTF-8 BOM");
}
const windowsBootstrap = windowsBootstrapBytes.toString("utf8").replace(/^\uFEFF/, "");
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")
|| !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)
|| !posixBootstrap.startsWith("#!/bin/sh\nset -eu\n") || !posixBootstrap.includes("tar -xzf")
|| /latest-v|npm install -g/.test(`${windowsBootstrap}\n${posixBootstrap}`)) {
throw new Error("无 Node 安装引导无效或仍包含不安全的动态版本来源");
+3 -3
View File
@@ -9,11 +9,11 @@ Use only the `dingtalk-approval` MCP tools. DingTalk OA and XFT use the same gat
## Login and first run
After installation or update, handle the installer's `postInstall` result immediately instead of waiting for the user's first approval request. If it contains `authChallenge.directUrl`, copy that complete clickable URL into the current user-facing reply. After the user authorizes, call `approval_login`, then make one read-only `approval_list_todos` call with `source=all` to verify both DingTalk OA and XFT. If login preparation was temporarily unavailable, keep the installation and call `approval_login` after the MCP is loaded. If the installation link expires before the MCP can load, execute the returned `loginCommand` and show its new `authChallenge.directUrl`; never rerun installation just to renew a link.
Use the host's native remote-MCP OAuth flow when the user can complete its registered callback. If the redirect URI uses `127.0.0.1` or `localhost`, the user must open the authorization page on the same device that runs the Agent. An SSH, headless, Messenger, phone, or other cross-device interaction cannot complete that loopback callback and must use the local STDIO Bridge device-code flow instead. If the interaction topology is unknown during installation, ask only whether the user will open the page on the Agent's device. When native OAuth is suitable, let the host open or present the authorization URL and have the user authorize it personally. Never claim that a link was pushed through another channel. After OAuth succeeds, make one read-only `approval_list_todos` call with `source=all` and `limit=1`; both DingTalk OA and XFT must be available before reporting setup complete.
When a successful response contains `onboarding.firstRun=true`, show its short examples and write-safety note to the user once. Do not execute an example automatically. The same onboarding may be returned directly in `postInstall` when an existing login is reused.
When a successful response contains `onboarding.firstRun=true`, show its short examples and write-safety note once. Do not execute an example automatically.
On first use, expired authorization, or an expired login link, call `approval_login` again. It checks the existing session and creates a fresh link when necessary; never reinstall for this purpose. The tool does not send or push the login link through another channel. Copy the clickable URL from `content.text` verbatim into the current user-facing reply; when available, `authChallenge.directUrl` and the `resource_link` contain the same URL. Never merely claim that a link was generated or pushed, never read bridge state files to recover it, and never render QR images as ASCII. One successful login covers both approval sources.
Hosts that cannot use remote Streamable HTTP MCP or standard OAuth, and cross-device users who cannot reach a host loopback callback, should use the optional local STDIO Bridge. In that compatibility mode, handle the installer's `postInstall` immediately. If it contains `authChallenge.directUrl`, copy the complete clickable URL into the current reply; the user may open this device-code page on another device. If the link expires, call `approval_login`; if the connector is not loaded yet, run the returned `loginCommand`. Never reinstall merely to renew a link, read Bridge state files, or render QR images as ASCII. One successful gateway login covers both sources.
## Review and process
+2 -2
View File
@@ -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.9" });
const client = new Client({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.13" });
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.9" }, {
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.13" }, {
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。",
});
server.registerTool("approval_login", {
+1 -1
View File
@@ -56,7 +56,7 @@ export function createApprovalMcpServer(
invoiceDedup?: InvoiceDeduplicationService,
): McpServer {
const server = new McpServer(
{ name: "dingtalk-approval-mcp", version: "1.1.0-rc.9" },
{ name: "dingtalk-approval-mcp", version: "1.1.0-rc.13" },
{ instructions: APPROVAL_MCP_INSTRUCTIONS },
);
const unified = new UnifiedApprovalService(config, service, xft, xftChallenges, officialOaReturns, invoiceDedup);
+80 -36
View File
@@ -3,41 +3,43 @@ import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
test("customer distribution is a generic single-login gateway bridge", () => {
test("customer distribution prefers remote MCP OAuth and keeps a generic compatibility bridge", () => {
const packageMetadata = JSON.parse(readFileSync("package.json", "utf8")) as { version: string };
assert.match(packageMetadata.version, /^\d+\.\d+\.\d+-rc\.\d+$/);
assert.equal(existsSync("plugins/dingtalk-approval"), false);
const installer = readFileSync("distribution/agent-install.template.md", "utf8");
assert.match(installer, /Ed25519/);
assert.match(installer, /唯一通用安装入口/);
assert.match(installer, /使用 `mcpConfig`/);
assert.match(installer, /安装器本身不会猜测或修改 Agent 的私有配置/);
assert.match(installer, /远程.*Streamable HTTP MCP/s);
assert.match(installer, /URL:`\{\{GATEWAY_URL\}\}`/);
assert.match(installer, /认证:OAuth/);
assert.match(installer, /动态客户端注册/);
assert.match(installer, /127\.0\.0\.1.*localhost.*同一台设备/s);
assert.match(installer, /SSH.*无头终端.*Messenger.*手机或另一台电脑/s);
assert.match(installer, /无法确定.*只问用户一个问题/s);
assert.match(installer, /不要安装 Node\.js、npm、本地 Bridge/);
assert.match(installer, /只有.*不支持远程 Streamable HTTP MCP.*标准 OAuth.*无法.*本机回调.*兼容/s);
assert.match(installer, /device-code.*手机或另一台电脑/s);
assert.match(installer, /跨设备交互.*直接进入兼容路径.*不必先制造.*失败/s);
assert.match(installer, /COMPAT_INSTALL_URL/);
assert.match(installer, /approval_list_todos.*source="all".*limit=1/s);
assert.match(installer, /钉钉 OA 与薪福通来源都为 `ok`/);
assert.doesNotMatch(installer, /PowerShell|Git Bash|stdio|postInstall|loginCommand|npm install -g/);
assert.doesNotMatch(installer, /--host|WorkBuddy|Codex|Claude/);
assert.match(installer, /不会通过其他消息通道自动推送链接.*完整登录 URL.*当前回复/s);
assert.match(installer, /读取返回的 `postInstall`.*不得等到用户第一次查询审批/s);
assert.match(installer, /登录链接过期.*绝不能重新安装.*approval_login.*loginCommand/s);
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, /备用发布源[\s\S]*优先使用国内 Gitea 静态发布源/s);
assert.match(installer, /优先使用系统 `curl`[\s\S]*Node `fetch` 仅在系统没有 `curl` 时兜底/s);
assert.match(installer, /不要设置全局 `NODE_OPTIONS=--use-env-proxy`[\s\S]*不要手工预装 `node_modules`/s);
assert.match(installer, /BOOTSTRAP_WINDOWS_URL|Windows 安装引导/);
assert.match(installer, /Linux\/macOS 安装引导/);
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`/);
assert.match(installer, /更新[\s\S]*update --json[\s\S]*卸载[\s\S]*uninstall --yes --json/);
assert.match(installer, /不能热加载[\s\S]*完整退出并重新启动 Agent/);
assert.doesNotMatch(installer, /npm install -g|npx\s/);
assert.doesNotMatch(installer, /通过当前 Messenger/);
assert.doesNotMatch(installer, /DWS_CLI_PATH|dingtalk-workspace-cli|APP_MODE=local/);
const compatibilityInstaller = readFileSync("distribution/agent-compat-install.template.md", "utf8");
assert.match(compatibilityInstaller, /仅当.*不支持远程 Streamable HTTP MCP.*标准 OAuth.*无法.*本机 OAuth 回调/s);
assert.match(compatibilityInstaller, /127\.0\.0\.1.*localhost.*SSH.*无头环境.*Messenger.*device-code/s);
assert.match(compatibilityInstaller, /Ed25519/);
assert.match(compatibilityInstaller, /使用 `mcpConfig`/);
assert.match(compatibilityInstaller, /Windows(PowerShell)/);
assert.match(compatibilityInstaller, /curl\.exe[\s\S]*Invoke-WebRequest[\s\S]*Get-FileHash[\s\S]*ConvertFrom-Json/);
assert.match(compatibilityInstaller, /90 秒[\s\S]*stderr[\s\S]*stdout/);
assert.match(compatibilityInstaller, /登录链接过期.*绝不能重新安装.*approval_login.*loginCommand/s);
assert.match(compatibilityInstaller, /sourceStatus[\s\S]*dingtalk\.status[\s\S]*xft\.status[\s\S]*都必须是 `ok`/);
assert.match(compatibilityInstaller, /更新[\s\S]*update --json[\s\S]*卸载[\s\S]*uninstall --yes --json/);
assert.doesNotMatch(compatibilityInstaller, /TimeoutSec 300|--max-time 300|--timeout=300|至少 10 分钟超时/);
assert.doesNotMatch(compatibilityInstaller, /--host|WorkBuddy|Codex|Claude/);
assert.doesNotMatch(compatibilityInstaller, /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");
@@ -48,17 +50,31 @@ test("customer distribution is a generic single-login gateway bridge", () => {
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 runtimeVerifier = readFileSync("scripts/verify-node-runtime-sources.mjs", "utf8");
assert.match(runtimeVerifier, /spawnSync\("curl"[\s\S]*curlAvailable/);
assert.match(runtimeVerifier, /--connect-timeout", "10"[\s\S]*--max-time", "20"/);
assert.match(runtimeVerifier, /if \(!curlAvailable\)[\s\S]*fetch\(/);
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(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/);
assert.match(posixBootstrap, /SHA-256 不匹配,停止安装/);
assert.match(posixBootstrap, /ding_progress[\s\S]*\[dingtalk-approval\]/);
assert.match(posixBootstrap, /ding_installer_urls[\s\S]*ding_manifest_mirror_urls[\s\S]*use-env-proxy/s);
assert.match(posixBootstrap, /--connect-timeout 10[\s\S]*--max-time 90[\s\S]*--speed-time 20/);
assert.doesNotMatch(posixBootstrap, /--max-time 300|--timeout=300/);
assert.equal(spawnSync("sh", ["-n", "distribution/bootstrap-posix.template.sh"]).status, 0);
assert.doesNotMatch(`${windowsBootstrap}\n${posixBootstrap}`, /latest-v|npm install -g/);
@@ -67,10 +83,13 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.match(skill, /read `references\/create-approval\.md` completely/);
assert.doesNotMatch(skill, /XFT recall is experimental/);
assert.match(createApproval, /XFT recall is experimental.*XFTSYS9999/s);
assert.match(skill, /does not send or push.*current user-facing reply/s);
assert.match(skill, /handle the installer's `postInstall` result immediately.*waiting for the user's first approval request/s);
assert.match(skill, /native remote-MCP OAuth flow when the user can complete its registered callback/);
assert.match(skill, /127\.0\.0\.1.*localhost.*same device.*SSH.*headless.*Messenger.*cross-device.*STDIO Bridge device-code/s);
assert.match(skill, /both DingTalk OA and XFT must be available/);
assert.match(skill, /cannot use remote Streamable HTTP MCP or standard OAuth.*cross-device users.*loopback callback.*local STDIO Bridge/s);
assert.match(skill, /authChallenge\.directUrl.*current reply/s);
assert.match(skill, /onboarding\.firstRun=true.*Do not execute an example automatically/s);
assert.match(skill, /expired login link.*approval_login.*never reinstall/s);
assert.match(skill, /link expires.*approval_login.*Never reinstall/s);
const builder = readFileSync("scripts/build-release.mjs", "utf8");
assert.match(builder, /schemaVersion: 3,\s+version,/s);
@@ -79,6 +98,9 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.match(builder, /dingtalk-approval-client/);
assert.match(builder, /bootstrap-windows\.template\.ps1/);
assert.match(builder, /bootstrap-posix\.template\.sh/);
assert.match(builder, /agent-compat-install\.template\.md/);
assert.match(builder, /compat-install\.md/);
assert.match(builder, /utf8Bom: true/);
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/);
@@ -86,12 +108,34 @@ test("customer distribution is a generic single-login gateway bridge", () => {
const deploy = readFileSync("deploy/install.sh", "utf8");
assert.match(deploy, /--download-base-url "\$\{gitea_release_base_url\}"[\s\S]*--download-mirror-base-url "\$\{public_download_base_url\}"/s);
assert.match(deploy, /refs\/tags\/\$\{release_tag\}[\s\S]*release_created_at_commit="\$\{release_tag_commit:-\$\{release_commit\}\}"/s);
assert.match(deploy, /! -name 'agent-test-install\.md' ! -name 'dingmcp-test-bundle\.tar\.gz'/);
const testBundleBuilder = readFileSync("scripts/build-test-bundle.mjs", "utf8");
assert.match(testBundleBuilder, /--sort=name.*--mtime=@0.*--owner=0.*--group=0.*--numeric-owner.*--format=ustar/s);
});
test("public install page explicitly declares UTF-8 for browser rendering", () => {
const nginx = readFileSync("deploy/approval.justconn.top.nginx.conf", "utf8");
const installLocation = nginx.match(/location = \/downloads\/dingmcp\/install\.md \{([\s\S]*?)\n \}/)?.[1] ?? "";
assert.match(installLocation, /default_type text\/markdown;/);
assert.match(installLocation, /charset utf-8;/);
assert.match(installLocation, /charset_types text\/markdown;/);
for (const name of ["install", "compat-install"]) {
const location = nginx.match(new RegExp(`location = /downloads/dingmcp/${name}\\.md \\{([\\s\\S]*?)\\n \\}`))?.[1] ?? "";
assert.match(location, /default_type text\/markdown;/);
assert.match(location, /charset utf-8;/);
assert.match(location, /charset_types text\/markdown;/);
}
});
test("Gitea release lookup tolerates tea field-name corruption and fails closed", () => {
const lookup = (body: string, tag = "v1.1.0-rc.11") => spawnSync(
process.execPath,
["scripts/release-tag-exists.mjs", tag],
{ input: body, encoding: "utf8" },
);
assert.equal(lookup('[{"tag_name":"v1.1.0-rc.11"}]').status, 0);
assert.equal(lookup('[{"tag-_name":"v1.1.0-rc.11"}]').status, 0);
assert.equal(lookup('{"data":[{"Tag":"v1.1.0-rc.11"}]}').status, 0);
assert.equal(lookup('[{"tag-_name":"v1.1.0-rc.10"}]').status, 1);
const invalid = lookup("not json");
assert.equal(invalid.status, 2);
assert.match(invalid.stderr, /无法解析/);
});