feat(scripts): na-gui 陈旧计划任务白名单清理(ARCH B-P1-6)

历史版本在被控端上注册过 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 处变红,
  恢复后全绿)。
This commit is contained in:
jkooe committed 2026-10-26 01:01:05 +08:00
1 parent 654d694ff6
commit b7f44f7a64
5 files changed
+206 -8

No files matched your search

+3
View File
@@ -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
+42 -5
View File
@@ -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 }
}
+41
View File
@@ -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;
}
+18 -3
View File
@@ -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
+102
View File
@@ -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 项');
});