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