From b7f44f7a642c300c38d67f541ef4ff9a4ce94dba Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:01:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20na-gui=20=E9=99=88=E6=97=A7?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E4=BB=BB=E5=8A=A1=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E6=B8=85=E7=90=86=EF=BC=88ARCH=20B-P1-6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 历史版本在被控端上注册过 na-gui 等旧名计划任务,漏删会留下「端口被占、 新版本起不来」的幽灵任务。误删用户自己的任务则是比漏删严重得多的 生产事故 —— 故只按白名单删。 - scripts/lib/scheduled-tasks.mjs(新):TASK_WHITELIST(nodeagent / na-gui / nodeagent-console,只增不改)+ planTaskCleanup 纯函数 (查询结果注入,白名单外一律不删、精确匹配大小写敏感、重复名去重)。 - scripts/uninstall.ps1:卸载末尾按白名单逐个注销(-ccontains 存在性 检查 + 动作进日志)。 - scripts/control.ps1:新增 cleanup 子命令(ValidateSet + Clear-StaleTasks), Uninstall-Agent 复用同一白名单;control.cmd 菜单加第 8 项 (CRLF 安全插入,纯 ASCII 不变)。 - tests/unit/na-gui-cleanup.test.mjs(新,10 例):白名单命中才删 / 白名单外零动作 / 空列表 / 重复名 / 脏输入 + ps1 源码守卫与跨端 一致性守卫(白名单 mjs 与 ps1 漂移即红 —— 注入故障实测 2 处变红, 恢复后全绿)。 --- scripts/control.cmd | 3 + scripts/control.ps1 | 47 +++++++++++-- scripts/lib/scheduled-tasks.mjs | 41 ++++++++++++ scripts/uninstall.ps1 | 21 +++++- tests/unit/na-gui-cleanup.test.mjs | 102 +++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 scripts/lib/scheduled-tasks.mjs create mode 100644 tests/unit/na-gui-cleanup.test.mjs diff --git a/scripts/control.cmd b/scripts/control.cmd index 836d66d..18caf3e 100644 --- a/scripts/control.cmd +++ b/scripts/control.cmd @@ -9,6 +9,7 @@ REM 3) stop - stop the scheduled task REM 4) restart - stop then start REM 5) logs - tail the agent's audit log REM 6) uninstall - remove task + firewall rule (7) purge config and exit +REM 8) cleanup - remove stale nodeagent tasks (whitelist only) REM REM Usage: control.cmd [port] REM port defaults to 8765; pass it only if the agent uses another. @@ -43,6 +44,7 @@ echo 4) Restart agent echo 5) Show recent audit log echo 6) Uninstall ^(keep config^) echo 7) Uninstall and purge config +echo 8) Cleanup stale tasks echo Q) Quit echo ========================================================== echo. @@ -67,6 +69,7 @@ if "%CHOICE%"=="4" call :RUN restart if "%CHOICE%"=="5" call :RUN logs if "%CHOICE%"=="6" call :RUN uninstall if "%CHOICE%"=="7" call :RUN uninstall-purge +if "%CHOICE%"=="8" call :RUN cleanup goto :MENU :QUIT diff --git a/scripts/control.ps1 b/scripts/control.ps1 index 313ef7b..7865367 100644 --- a/scripts/control.ps1 +++ b/scripts/control.ps1 @@ -10,7 +10,7 @@ Chinese comments then break string boundaries -> bogus syntax errors. .PARAMETER Action - status | start | stop | restart | logs | uninstall | uninstall-purge + status | start | stop | restart | logs | cleanup | uninstall | uninstall-purge .EXAMPLE .\control.ps1 -Action status @@ -18,7 +18,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('status', 'start', 'stop', 'restart', 'logs', 'uninstall', 'uninstall-purge')] + [ValidateSet('status', 'start', 'stop', 'restart', 'logs', 'cleanup', 'uninstall', 'uninstall-purge')] [string]$Action, [int]$Port = 8765 ) @@ -32,6 +32,13 @@ function Write-Err($m) { Write-Host "[x] $m" -ForegroundColor Red } $taskName = "nodeagent" +# B-P1-6 / T05:计划任务白名单。历史版本注册过 na-gui 等旧名任务,漏删会留下 +# 「端口被占、新版本起不来」的幽灵任务。铁律:只删白名单内**且实际存在**的 +# 任务,禁通配删除 —— 误删用户自己的任务是比漏删严重得多的生产事故。 +# 与 scripts/lib/scheduled-tasks.mjs 的 TASK_WHITELIST 同口径 +# (tests/unit/na-gui-cleanup.test.mjs 有跨端一致性守卫,白名单不许两边漂移)。 +$taskWhitelist = @("nodeagent", "na-gui", "nodeagent-console") + # 数据目录:必须与 agent 侧一致 —— apps/agent/src/config.ts 的 agentDir() 是 # process.env.NODEAGENT_HOME ?? join(homedir(), '.nodeagent') # 计划任务里若设了 NODEAGENT_HOME,只查 %USERPROFILE%\.nodeagent 会读错配置。 @@ -205,15 +212,44 @@ function Show-Logs { Write-Host "" } +# ── cleanup(白名单清理陈旧计划任务)──────────────────────────────────────────── +function Clear-StaleTasks { + if (-not (Test-Admin)) { + Write-Err "清理计划任务需要管理员权限,请右键 control.cmd → 以管理员身份运行" + return + } + Write-Step "清理 nodeagent 历史计划任务(仅白名单内且存在的)..." + # -ccontains 为大小写敏感包含,与 scheduled-tasks.mjs 的精确匹配同口径 + $existing = Get-ScheduledTask | Select-Object -ExpandProperty TaskName + foreach ($name in $taskWhitelist) { + if ($existing -ccontains $name) { + Stop-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue + Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue + Write-Ok "已删除计划任务: $name" + } else { + Write-Host " - $name (不存在,跳过)" + } + } + Write-Host "" + Write-Host " 提示:白名单外的任务一律不碰(误删他人任务是生产事故)。" -ForegroundColor DarkGray + Write-Host " 若端口仍被占用,用 status 子命令查看占用进程 PID 后手动处理。" -ForegroundColor DarkGray +} + # ── uninstall ───────────────────────────────────────────────────────────────── function Uninstall-Agent([bool]$Purge) { if (-not (Test-Admin)) { Write-Err "卸载需要管理员权限,请右键 control.cmd → 以管理员身份运行" return } - Write-Step "停止并注销计划任务..." - Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue - Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + Write-Step "停止并注销计划任务(白名单内且存在的)..." + $existing = Get-ScheduledTask | Select-Object -ExpandProperty TaskName + foreach ($name in $taskWhitelist) { + if ($existing -ccontains $name) { + Stop-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue + Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue + Write-Ok "已删除计划任务: $name" + } + } Write-Step "移除防火墙规则..." Get-NetFirewallRule -DisplayName "nodeagent (TCP $Port)" -ErrorAction SilentlyContinue | @@ -235,6 +271,7 @@ switch ($Action) { 'stop' { Stop-Agent } 'restart' { Stop-Agent; Start-Sleep -Seconds 1; Start-Agent } 'logs' { Show-Logs } + 'cleanup' { Clear-StaleTasks } 'uninstall' { Uninstall-Agent -Purge $false } 'uninstall-purge' { Uninstall-Agent -Purge $true } } diff --git a/scripts/lib/scheduled-tasks.mjs b/scripts/lib/scheduled-tasks.mjs new file mode 100644 index 0000000..b70d19d --- /dev/null +++ b/scripts/lib/scheduled-tasks.mjs @@ -0,0 +1,41 @@ +/** + * Windows 计划任务清理白名单(B-P1-6 / T05)。 + * + * ## 为什么需要 + * nodeagent 历史版本/缺陷修复在被控端上注册过计划任务(na-gui 等旧名), + * 卸载时若漏删,会留下「端口被占、起不来新版本」的幽灵任务。 + * 但清理动作**只许按白名单删**——误删用户自己的任务是比漏删严重得多的 + * 生产事故(可能停掉业务服务),故本文件是纯函数 + 白名单,禁通配删除。 + * + * ## 铁律 + * - 零依赖、零 import:与 startup-guard-core 同风格,任何 Node ≥22 可加载; + * - 白名单**只增不改**:发现新的历史任务名时追加,不从清单里移除旧名 + * (老被控端上旧名任务可能还在); + * - 匹配是精确相等(大小写敏感):Windows 任务名实际大小写不敏感, + * 但这里宁可漏删也不模糊匹配——模糊匹配是误删之门。 + */ + +/** nodeagent 自己注册过的计划任务名(含历史遗留)。只增不改。 */ +export const TASK_WHITELIST = ['nodeagent', 'na-gui', 'nodeagent-console']; + +/** + * 计划删除名单:白名单内**且实际存在**的任务,按白名单固定顺序返回。 + * + * 纯函数:查询结果由调用方注入(PowerShell 侧 Get-ScheduledTask), + * 本函数不碰文件系统、不碰注册表 —— 判断可单测,误删风险可证明。 + * + * @param {string[]} existing 被控端上实际存在的任务名(可含重复/空串/非字符串) + * @returns {string[]} 需要删除的任务名(每个最多一次;白名单外一律不出现) + */ +export function planTaskCleanup(existing) { + if (!Array.isArray(existing)) return []; + const present = new Set(); + for (const n of existing) { + if (typeof n === 'string' && n.length > 0) present.add(n); + } + const out = []; + for (const name of TASK_WHITELIST) { + if (present.has(name)) out.push(name); + } + return out; +} diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 86ce561..ef3b352 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -19,9 +19,24 @@ $isAdmin = ([Security.Principal.WindowsPrincipal] ` ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin) { Write-Host "[x] Please run as Administrator."; exit 1 } -Write-Host "[*] Stopping and removing scheduled task..." -Stop-ScheduledTask -TaskName "nodeagent" -Unregister-ScheduledTask -TaskName "nodeagent" -Confirm:$false +Write-Host "[*] Stopping and removing scheduled tasks (whitelist only)..." +# B-P1-6 / T05:白名单清理。历史版本在被控端上注册过 na-gui 等旧名任务, +# 漏删会留下「端口被占、新版本起不来」的幽灵任务。 +# 铁律:只删白名单内**且实际存在**的任务,禁通配删除 —— 误删用户自己的 +# 任务是比漏删严重得多的生产事故。-ccontains 为大小写敏感包含,与 +# scripts/lib/scheduled-tasks.mjs 的 TASK_WHITELIST 精确匹配同口径 +# (tests/unit/na-gui-cleanup.test.mjs 有跨端一致性守卫,白名单不许两边漂移)。 +$taskWhitelist = @("nodeagent", "na-gui", "nodeagent-console") +$existingTasks = Get-ScheduledTask | Select-Object -ExpandProperty TaskName +foreach ($name in $taskWhitelist) { + if ($existingTasks -ccontains $name) { + Stop-ScheduledTask -TaskName $name + Unregister-ScheduledTask -TaskName $name -Confirm:$false + Write-Host " removed task: $name" + } else { + Write-Host " task not present: $name" + } +} Write-Host "[*] Removing firewall rule..." Get-NetFirewallRule -DisplayName "nodeagent (TCP $Port)" | Remove-NetFirewallRule diff --git a/tests/unit/na-gui-cleanup.test.mjs b/tests/unit/na-gui-cleanup.test.mjs new file mode 100644 index 0000000..158a571 --- /dev/null +++ b/tests/unit/na-gui-cleanup.test.mjs @@ -0,0 +1,102 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { TASK_WHITELIST, planTaskCleanup } from '../../scripts/lib/scheduled-tasks.mjs'; + +/** + * na-gui 陈旧计划任务清理(ARCH B-P1-6 / T05)。 + * + * 安全契约(本文件钉死): + * 1. **白名单外一律不删** —— 误删用户自己的任务是比漏删严重得多的生产事故; + * 2. 白名单内且**实际存在**才删(查询结果注入,不碰真实系统); + * 3. 幂等:重复名/脏输入不得产生重复删除项; + * 4. 跨端一致:ps1 卸载/清理脚本里的白名单必须与 TASK_WHITELIST 同口径 + * (两边漂移 = 单测绿的、真机漏删或真机删了单测没覆盖的名字)。 + */ + +test('白名单命中才删:存在的白名单任务进入删除名单', () => { + const plan = planTaskCleanup(['nodeagent', 'na-gui']); + assert.deepEqual(plan, ['nodeagent', 'na-gui']); +}); + +test('白名单外零动作:相似名与他人任务一律不进删除名单', () => { + const plan = planTaskCleanup([ + 'nodeagent', // 白名单内:删 + 'nodeagent-backup', // 相似名:不删(前缀相似不是通配理由) + 'NodeAgent', // 大小写变体:精确匹配哲学下不删(宁可漏删) + 'na-gui-helper', // 白名单名前缀+后缀:不删 + 'SomeBusinessTask', // 他人任务:绝不碰 + 'GoogleUpdateTaskMachineCore', + ]); + assert.deepEqual(plan, ['nodeagent'], `只应删白名单内的 nodeagent,实际: ${JSON.stringify(plan)}`); +}); + +test('空列表:无任务可删', () => { + assert.deepEqual(planTaskCleanup([]), []); +}); + +test('重复名:删除名单去重(幂等,重复执行卸载不报错不双删)', () => { + const plan = planTaskCleanup(['na-gui', 'nodeagent', 'na-gui', 'nodeagent', 'na-gui']); + assert.deepEqual(plan, ['nodeagent', 'na-gui']); + // 每个名字最多出现一次 + assert.equal(new Set(plan).size, plan.length); +}); + +test('脏输入防御:非数组/空串/非字符串按「不存在」处理,不抛', () => { + assert.deepEqual(planTaskCleanup(null), []); + assert.deepEqual(planTaskCleanup(undefined), []); + assert.deepEqual(planTaskCleanup('nodeagent'), [], '字符串不是数组:整体按无可删处理'); + assert.deepEqual(planTaskCleanup(['', null, 42, {}]), [], '脏成员一律过滤'); +}); + +test('白名单内容钉死:只增不改,当前为三个历史任务名', () => { + assert.deepEqual([...TASK_WHITELIST], ['nodeagent', 'na-gui', 'nodeagent-console']); +}); + +// --------------------------------------------------------------------------- +// 源码守卫:ps1 侧必须真的按白名单清理,且白名单不许与 mjs 漂移 +// --------------------------------------------------------------------------- + +const root = join(import.meta.dirname, '../..'); +const uninstallSrc = readFileSync(join(root, 'scripts/uninstall.ps1'), 'utf8'); +const controlSrc = readFileSync(join(root, 'scripts/control.ps1'), 'utf8'); + +test('源码守卫:uninstall.ps1 含白名单清理(枚举 + 白名单逐名 + 注销 + 日志)', () => { + assert.match(uninstallSrc, /\$taskWhitelist\s*=\s*@\(/, 'uninstall.ps1 必须定义白名单数组'); + for (const name of TASK_WHITELIST) { + assert.ok(uninstallSrc.includes(`"${name}"`), `uninstall.ps1 白名单缺少 ${name}`); + } + assert.match(uninstallSrc, /Get-ScheduledTask/, 'uninstall.ps1 必须先枚举现有任务'); + assert.match(uninstallSrc, /-ccontains/, 'uninstall.ps1 必须用大小写敏感包含(与 mjs 精确匹配同口径)'); + assert.match(uninstallSrc, /Unregister-ScheduledTask/, 'uninstall.ps1 必须注销任务'); + assert.match(uninstallSrc, /Write-Host\s+"?\s*removed task/, 'uninstall.ps1 删除动作必须进日志'); +}); + +test('源码守卫:control.ps1 的 cleanup 子命令复用同一白名单', () => { + assert.match(controlSrc, /'cleanup'/, 'control.ps1 必须有 cleanup 子命令(ValidateSet + dispatch)'); + assert.ok(controlSrc.includes('ValidateSet'), 'control.ps1 子命令必须在 ValidateSet 中声明'); + assert.match(controlSrc, /\$taskWhitelist\s*=\s*@\(/, 'control.ps1 必须定义同一白名单'); + for (const name of TASK_WHITELIST) { + assert.ok(controlSrc.includes(`"${name}"`), `control.ps1 白名单缺少 ${name}`); + } + assert.match(controlSrc, /-ccontains/, 'control.ps1 清理必须用大小写敏感包含'); + assert.match(controlSrc, /function Clear-StaleTasks/, 'control.ps1 应有 Clear-StaleTasks 清理函数'); +}); + +test('跨端一致:两个 ps1 的白名单数组与 mjs TASK_WHITELIST 逐字一致', () => { + // 从 ps1 源码里抠出 @("a", "b", ...) 的字面量,与 mjs 导出比对 —— + // 防止「改了 mjs 忘了 ps1」造成单测全绿、真机漏删。 + for (const [file, src] of [['uninstall.ps1', uninstallSrc], ['control.ps1', controlSrc]]) { + const m = /\$taskWhitelist\s*=\s*@\(([^)]*)\)/.exec(src); + assert.ok(m, `${file} 未找到 $taskWhitelist 定义`); + const names = [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]); + assert.deepEqual(names, [...TASK_WHITELIST], `${file} 白名单与 scheduled-tasks.mjs 漂移`); + } +}); + +test('control.cmd 菜单暴露 cleanup 入口(纯 ASCII + CRLF 由 ps1-encoding 守卫)', () => { + const cmd = readFileSync(join(root, 'scripts/control.cmd'), 'utf8'); + assert.match(cmd, /call :RUN cleanup/, 'control.cmd 菜单必须有 cleanup 项转发'); + assert.match(cmd, /8\)\s*Cleanup stale tasks/, 'control.cmd 菜单应展示 cleanup 项'); +});