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:
1 parent
654d694ff6
commit
b7f44f7a64
5 files changed
+206
-8
No files matched your search
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 项');
|
||||
});
|
||||
Reference in new issue
Block a user