mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
1475 lines
73 KiB
JavaScript
1475 lines
73 KiB
JavaScript
/**
|
|
* Safe cleanup of legacy V1 Windows scheduled-task residue (#956).
|
|
*
|
|
* Issue #956 reports that some historical V1 builds may have registered
|
|
* `EvoMapEvolverProxyDaemon` / `EvoMapEvolverAutoexecDaemon` as scheduled tasks whose action
|
|
* invoked `powershell.exe` / `node.exe` DIRECTLY (the console window flashes at logon before
|
|
* `-WindowStyle Hidden` takes effect, and once the pointed-at binary is gone the task fails
|
|
* and retries `RestartCount` times). V2 registers the same
|
|
* names through a hidden `wscript.exe` VBS launcher — currently via the ABSOLUTE trusted
|
|
* path `<SystemRoot>\System32\wscript.exe` written by the bootstrap transaction, and in
|
|
* older v2 generations via the bare `wscript.exe` name (handled by the existing legacy
|
|
* adoption path, NOT by this module).
|
|
*
|
|
* This module is a destructive migration and obeys the lifecycle authority contract:
|
|
*
|
|
* - Fingerprint, not name match: a task can be removed only after an authoritative V1
|
|
* registration template and launcher/generation evidence have proved that it is residue.
|
|
* The repository currently carries no such evidence. Therefore a complete direct-action
|
|
* shape (`powershell.exe` or `node.exe`) is deliberately classified as `ambiguous` and
|
|
* refused; every missing, ambiguous, or otherwise unproven field fails closed
|
|
* (`inconclusive`). The current v2 absolute `wscript.exe` binding and older bare-`wscript`
|
|
* generations are kept. This module never deletes on a first-match, name-only, or
|
|
* shape-only basis.
|
|
* - Revalidation: the fingerprint is re-verified immediately before unregister, and again
|
|
* after stop, inside the mutation script (mirroring the bootstrap rollback unregister
|
|
* pattern); a task replaced between probe and mutation is left untouched.
|
|
* - Authority: mutation runs under the bootstrap owner lock (or an `assertOwner` seam held
|
|
* by the bootstrap transaction itself), so it cannot race a concurrent bootstrap.
|
|
* - Durable preimage: the task XML (Export-ScheduledTask) is persisted owner-only/atomic in
|
|
* the lifecycle state dir BEFORE any stop/unregister, and `--restore=<preimage>` can
|
|
* re-register it.
|
|
* - Trusted executable: every PowerShell invocation uses the absolute
|
|
* `<SystemRoot>\System32\WindowsPowerShell\v1.0\powershell.exe` path; never a PATH lookup.
|
|
* - Honest status: `clean` / `legacy_detected` / `cleaned` / `inconclusive` / `failed` /
|
|
* `skipped` are never collapsed into each other. A failed or inconclusive run is never
|
|
* reported as `clean`; the automatic SessionStart sweep records it in a negative
|
|
* backoff marker (1h base, doubling per consecutive failure, capped at 24h) so a
|
|
* constrained host does not re-probe on every session, and a conclusive success
|
|
* overwrites it, resetting the counter. The manual command exits non-zero for it.
|
|
* Diagnostics carry task names and bounded reasons only — never raw
|
|
* PowerShell output, absolute paths, or exceptions.
|
|
*/
|
|
import { randomUUID, verify as verifySignature } from 'node:crypto';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { lstatSync, renameSync } from 'node:fs';
|
|
import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path';
|
|
import { ops as coreOps } from '@evomap/evolver-core';
|
|
import { lifecyclePaths, trustedWindowsPowerShell } from './lifecycle.js';
|
|
import { acquireBootstrapOwnerLock, assertTrustedArtifactParent, removeDurableFile, readBootstrapArtifactFile, writeDurableText, } from './lifecycleBootstrapTransaction.js';
|
|
/** The only task names this module ever probes; nothing outside this set is ever touched. */
|
|
export const LEGACY_WINDOWS_TASK_NAMES = [
|
|
'EvoMapEvolverProxyDaemon',
|
|
'EvoMapEvolverAutoexecDaemon',
|
|
];
|
|
/** Direct-action leaves that require unavailable V1 provenance before any cleanup. */
|
|
const LEGACY_ACTION_EXECUTABLE_LEAVES = new Set(['powershell.exe', 'node.exe']);
|
|
/** `wscript.exe` leaf (any path form) identifies v2 generations — current or legacy v2. */
|
|
const V2_ACTION_EXECUTABLE_LEAF = 'wscript.exe';
|
|
const PROBE_TIMEOUT_MS = 20_000;
|
|
const EXPORT_TIMEOUT_MS = 20_000;
|
|
const MUTATION_TIMEOUT_MS = 40_000;
|
|
const MAX_PROBE_OUTPUT_BYTES = 256 * 1024;
|
|
const MAX_PREIMAGE_BYTES = 512 * 1024;
|
|
const MAX_COOLDOWN_MARKER_BYTES = 4096;
|
|
export const LEGACY_TASK_SKIP_ENV = 'EVOLVER_SKIP_LEGACY_TASK_PROBE';
|
|
export const LEGACY_TASK_COOLDOWN_FILE = 'legacy-task-cleanup.json';
|
|
export const LEGACY_TASK_PREIMAGE_DIR = 'legacy-task-preimages';
|
|
export const LEGACY_TASK_PROVENANCE_FILE = 'legacy-task-provenance.json';
|
|
export const LEGACY_TASK_PROVENANCE_PUBLIC_KEY_ENV = 'EVOLVER_LEGACY_TASK_PROVENANCE_PUBLIC_KEY';
|
|
export const LEGACY_TASK_PROVENANCE_SCHEMA = 'evolver/windows-legacy-task-provenance/v1';
|
|
export const LEGACY_TASK_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
|
/** Negative backoff for inconclusive/failed sweeps: 1h base, doubling, capped at 24h. */
|
|
const LEGACY_TASK_NEGATIVE_BACKOFF_BASE_MS = 60 * 60 * 1000;
|
|
const LEGACY_TASK_NEGATIVE_BACKOFF_MAX_MS = 24 * 60 * 60 * 1000;
|
|
const COOLDOWN_MARKER_SCHEMA = 'evolver/legacy-task-cleanup/v1';
|
|
function defaultLegacyTaskRun() {
|
|
const powershell = trustedWindowsPowerShell();
|
|
return (command, args, timeoutMs) => {
|
|
if (command !== powershell) {
|
|
return { status: null, error: new Error('legacy task probe refused a non-trusted executable') };
|
|
}
|
|
const result = spawnSync(command, [...args], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: timeoutMs,
|
|
windowsHide: true,
|
|
});
|
|
return {
|
|
status: result.status,
|
|
...(result.error ? { error: result.error } : {}),
|
|
...(typeof result.stdout === 'string' ? { stdout: result.stdout } : {}),
|
|
...(typeof result.stderr === 'string' ? { stderr: result.stderr } : {}),
|
|
};
|
|
};
|
|
}
|
|
function powershellArgs(script) {
|
|
return ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script];
|
|
}
|
|
function boundedDetail(value, max = 240) {
|
|
const text = (value instanceof Error ? value.message : String(value))
|
|
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]')
|
|
// Diagnostic text can contain file:// or http(s):// URLs whose host, port, query,
|
|
// and path are all potentially sensitive. Redact the complete token before the
|
|
// platform-specific forms below so the slash after a URI scheme cannot leak a path.
|
|
.replace(/\b(?:file|https?):\/\/[^"'<>|\r\n]+/gi, '[redacted-path]')
|
|
// Paths may contain spaces; consume through the next quote/control delimiter rather than
|
|
// stopping at the first whitespace and leaking the remainder of the path.
|
|
.replace(/\b[A-Za-z]:[\\/][^"'<>|\r\n]+/g, '[redacted-path]')
|
|
.replace(/(^|[\s("'=:])\\\\[^"'<>|\r\n]+/g, '$1[redacted-path]')
|
|
.replace(/(^|[\s("'=:])\/[^"'<>|\r\n]+/g, '$1[redacted-path]')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
}
|
|
/** Normalize the scheduler's case-insensitive SHA-256 representation before embedding it. */
|
|
function normalizeDefinitionHash(value) {
|
|
if (value === undefined)
|
|
return undefined;
|
|
const normalized = value.toLowerCase();
|
|
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : undefined;
|
|
}
|
|
const LEGACY_TASK_PROVENANCE_KEYS = [
|
|
'schema', 'taskName', 'taskPath', 'definitionHash', 'principalSid', 'issuedAt', 'expiresAt', 'signatureAlg', 'signature',
|
|
];
|
|
const LEGACY_TASK_PROVENANCE_BUNDLE_KEYS = ['schema', 'receipts'];
|
|
function hasOnlyKeys(value, allowed, required) {
|
|
const keys = Object.keys(value);
|
|
return keys.every((key) => allowed.includes(key)) && required.every((key) => Object.hasOwn(value, key));
|
|
}
|
|
export function canonicalLegacyTaskProvenanceBytes(payload) {
|
|
const canonical = {
|
|
schema: LEGACY_TASK_PROVENANCE_SCHEMA,
|
|
taskName: payload.taskName,
|
|
taskPath: '\\',
|
|
definitionHash: payload.definitionHash.toLowerCase(),
|
|
principalSid: payload.principalSid,
|
|
issuedAt: payload.issuedAt,
|
|
...(payload.expiresAt !== undefined ? { expiresAt: payload.expiresAt } : {}),
|
|
signatureAlg: 'ed25519',
|
|
};
|
|
return Buffer.from(JSON.stringify(canonical), 'utf8');
|
|
}
|
|
function parseLegacyTaskProvenanceReceipt(value, publicKey, now) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
return undefined;
|
|
const raw = value;
|
|
if (!hasOnlyKeys(raw, LEGACY_TASK_PROVENANCE_KEYS, [
|
|
'schema', 'taskName', 'taskPath', 'definitionHash', 'principalSid', 'issuedAt', 'signatureAlg', 'signature',
|
|
]))
|
|
return undefined;
|
|
if (raw.schema !== LEGACY_TASK_PROVENANCE_SCHEMA
|
|
|| typeof raw.taskName !== 'string'
|
|
|| !LEGACY_WINDOWS_TASK_NAMES.includes(raw.taskName)
|
|
|| raw.taskPath !== '\\'
|
|
|| typeof raw.definitionHash !== 'string'
|
|
|| normalizeDefinitionHash(raw.definitionHash) === undefined
|
|
|| typeof raw.principalSid !== 'string'
|
|
|| !/^S-\d-\d+(?:-\d+){1,15}$/.test(raw.principalSid)
|
|
|| typeof raw.issuedAt !== 'string'
|
|
|| !Number.isFinite(Date.parse(raw.issuedAt))
|
|
|| raw.signatureAlg !== 'ed25519'
|
|
|| typeof raw.signature !== 'string')
|
|
return undefined;
|
|
if (raw.expiresAt !== undefined
|
|
&& (typeof raw.expiresAt !== 'string' || !Number.isFinite(Date.parse(raw.expiresAt)) || Date.parse(raw.expiresAt) <= now)) {
|
|
return undefined;
|
|
}
|
|
const signature = Buffer.from(raw.signature, 'base64');
|
|
if (signature.length !== 64 || signature.toString('base64') !== raw.signature)
|
|
return undefined;
|
|
const payload = {
|
|
schema: LEGACY_TASK_PROVENANCE_SCHEMA,
|
|
taskName: raw.taskName,
|
|
taskPath: '\\',
|
|
definitionHash: normalizeDefinitionHash(raw.definitionHash),
|
|
principalSid: raw.principalSid,
|
|
issuedAt: raw.issuedAt,
|
|
...(raw.expiresAt !== undefined ? { expiresAt: raw.expiresAt } : {}),
|
|
signatureAlg: 'ed25519',
|
|
};
|
|
try {
|
|
const key = coreOps.toPublicKey(publicKey);
|
|
if (!verifySignature(null, canonicalLegacyTaskProvenanceBytes(payload), key, signature))
|
|
return undefined;
|
|
}
|
|
catch {
|
|
return undefined;
|
|
}
|
|
return { ...payload, signature: raw.signature };
|
|
}
|
|
function loadLegacyTaskProvenance(stateDir, env, now) {
|
|
const path = join(stateDir, LEGACY_TASK_PROVENANCE_FILE);
|
|
// 缺少 provenance 是正常的 fail-closed 状态。先探测叶节点,避免调用方只提供尚未创建的
|
|
// state directory 时被错误报告为 receipt 不可读。
|
|
try {
|
|
lstatSync(path);
|
|
}
|
|
catch (error) {
|
|
if (error?.code === 'ENOENT')
|
|
return { receipts: new Map() };
|
|
return { receipts: new Map(), detail: 'legacy task provenance receipt is unreadable' };
|
|
}
|
|
let bytes;
|
|
try {
|
|
assertLegacyStateArtifactPath(path);
|
|
bytes = readBootstrapArtifactFile(path, MAX_COOLDOWN_MARKER_BYTES * 8, { role: 'owned' }).bytes;
|
|
}
|
|
catch {
|
|
return { receipts: new Map(), detail: 'legacy task provenance receipt is unreadable' };
|
|
}
|
|
const publicKey = env[LEGACY_TASK_PROVENANCE_PUBLIC_KEY_ENV]?.trim();
|
|
if (!publicKey)
|
|
return { receipts: new Map(), detail: `${LEGACY_TASK_PROVENANCE_PUBLIC_KEY_ENV} is not configured` };
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(bytes.toString('utf8'));
|
|
}
|
|
catch {
|
|
return { receipts: new Map(), detail: 'legacy task provenance receipt is not valid JSON' };
|
|
}
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return { receipts: new Map(), detail: 'legacy task provenance receipt has an invalid shape' };
|
|
}
|
|
const bundle = parsed;
|
|
if (!hasOnlyKeys(bundle, LEGACY_TASK_PROVENANCE_BUNDLE_KEYS, ['schema', 'receipts'])
|
|
|| bundle.schema !== LEGACY_TASK_PROVENANCE_SCHEMA
|
|
|| !Array.isArray(bundle.receipts)
|
|
|| bundle.receipts.length > LEGACY_WINDOWS_TASK_NAMES.length) {
|
|
return { receipts: new Map(), detail: 'legacy task provenance receipt has an invalid shape' };
|
|
}
|
|
const receipts = new Map();
|
|
for (const candidate of bundle.receipts) {
|
|
const receipt = parseLegacyTaskProvenanceReceipt(candidate, publicKey, now);
|
|
if (!receipt || receipts.has(receipt.taskName)) {
|
|
return { receipts: new Map(), detail: 'legacy task provenance receipt failed signature or identity validation' };
|
|
}
|
|
receipts.set(receipt.taskName, receipt);
|
|
}
|
|
return { receipts };
|
|
}
|
|
/** True when the automatic entry points must never cross the Task Scheduler boundary. */
|
|
export function legacyTaskProbeSkipped(env) {
|
|
const value = env[LEGACY_TASK_SKIP_ENV]?.trim();
|
|
return value !== undefined && value !== '' && value !== '0';
|
|
}
|
|
/**
|
|
* The probe script emits one JSON record per known task name. It only READS the Task
|
|
* Scheduler; names are compile-time constants, so no interpolation ever reaches it.
|
|
*
|
|
* Enumeration note: every script below enumerates with `-TaskPath '\*'` (a LIKE query)
|
|
* rather than the exact key `'\'` — the exact-key query throws
|
|
* CmdletizationQuery_NotFound_TaskPath when the root folder holds ZERO tasks (a clean
|
|
* host), while the LIKE form returns an empty set. `-ErrorAction Stop` is kept on every
|
|
* call so genuine scheduler faults still fail closed. Root-only semantics are preserved
|
|
* by an explicit `$_.TaskPath -eq '\'` filter on every enumeration.
|
|
*/
|
|
const PROBE_SCRIPT = `$ErrorActionPreference = 'Stop'
|
|
$names = @(${LEGACY_WINDOWS_TASK_NAMES.map((name) => `'${name}'`).join(', ')})
|
|
$results = foreach ($name in $names) {
|
|
$tasks = @(Get-ScheduledTask -TaskPath '\\*' -ErrorAction Stop | Where-Object { $_.TaskPath -eq '\\' -and $_.TaskName -eq $name })
|
|
if ($tasks.Count -eq 0) { [pscustomobject]@{ name = $name; present = $false; count = 0 } }
|
|
elseif ($tasks.Count -ne 1) { [pscustomobject]@{ name = $name; present = $true; ambiguous = $true; count = $tasks.Count } }
|
|
else {
|
|
$task = $tasks[0]
|
|
$actions = if ($null -eq $task.Actions) { $null } else { @($task.Actions) }
|
|
$triggers = if ($null -eq $task.Triggers) { $null } else { @($task.Triggers) }
|
|
$principalUser = if ($null -ne $task.Principal -and $null -ne $task.Principal.UserId) { [string]$task.Principal.UserId } else { $null }
|
|
$principalSid = $null
|
|
if ($principalUser -match '^S-\\d-') { $principalSid = $principalUser }
|
|
elseif ($principalUser) {
|
|
try { $principalSid = ([System.Security.Principal.NTAccount]$principalUser).Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { $principalSid = $null }
|
|
}
|
|
$definitionXml = [string](Export-ScheduledTask -TaskPath '\\' -TaskName $name -ErrorAction Stop)
|
|
$definitionSha = [System.Security.Cryptography.SHA256]::Create()
|
|
$definitionHash = ([System.BitConverter]::ToString($definitionSha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($definitionXml)))).Replace('-', '').ToLowerInvariant()
|
|
$definitionSha.Dispose()
|
|
[pscustomobject]@{
|
|
name = $name; present = $true; ambiguous = $false; count = 1
|
|
taskPath = if ($null -ne $task.TaskPath) { [string]$task.TaskPath } else { $null }
|
|
actionCount = if ($null -ne $actions) { $actions.Count } else { $null }
|
|
execute = if ($null -ne $actions -and $actions.Count -ge 1 -and $null -ne $actions[0].Execute) { [string]$actions[0].Execute } else { $null }
|
|
arguments = if ($null -ne $actions -and $actions.Count -ge 1 -and $null -ne $actions[0].Arguments) { [string]$actions[0].Arguments } else { $null }
|
|
workingDirectory = if ($null -ne $actions -and $actions.Count -ge 1 -and $null -ne $actions[0].WorkingDirectory) { [string]$actions[0].WorkingDirectory } else { $null }
|
|
triggerCount = if ($null -ne $triggers) { $triggers.Count } else { $null }
|
|
userId = $principalUser
|
|
principalSid = $principalSid
|
|
logonType = if ($null -ne $task.Principal -and $null -ne $task.Principal.LogonType) { [string]$task.Principal.LogonType } else { $null }
|
|
runLevel = if ($null -ne $task.Principal -and $null -ne $task.Principal.RunLevel) { [string]$task.Principal.RunLevel } else { $null }
|
|
enabled = if ($null -ne $task.Settings -and $null -ne $task.Settings.Enabled) { [bool]$task.Settings.Enabled } else { $null }
|
|
restartCount = if ($null -ne $task.Settings -and $null -ne $task.Settings.RestartCount) { [int]$task.Settings.RestartCount } else { $null }
|
|
description = if ($null -ne $task.Description) { [string]$task.Description } else { $null }
|
|
definitionHash = $definitionHash
|
|
}
|
|
}
|
|
}
|
|
ConvertTo-Json @($results) -Compress -Depth 3`;
|
|
/**
|
|
* Fingerprint revalidation + export for one task. Exit codes: 0 preimage on stdout,
|
|
* 3 count drift, 4 action drift, 5 execute/provenance refusal, 10 definition drift, 9
|
|
* scheduler error.
|
|
*/
|
|
function renderDefinitionHashGuard(name, expectedHash) {
|
|
if (expectedHash === undefined)
|
|
return '';
|
|
const normalizedHash = normalizeDefinitionHash(expectedHash);
|
|
if (normalizedHash === undefined)
|
|
return 'exit 10';
|
|
const escapedHash = normalizedHash.replace(/'/g, "''");
|
|
return `$definitionXml = [string](Export-ScheduledTask -TaskPath '\\' -TaskName '${name}' -ErrorAction Stop)
|
|
$definitionSha = [System.Security.Cryptography.SHA256]::Create()
|
|
$definitionHash = ([System.BitConverter]::ToString($definitionSha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($definitionXml)))).Replace('-', '').ToLowerInvariant()
|
|
$definitionSha.Dispose()
|
|
if ($definitionHash -ne '${escapedHash}') { exit 10 }`;
|
|
}
|
|
/**
|
|
* Defense in depth for the dormant destructive path. The classifier refuses direct
|
|
* powershell/node actions because this repository has no authoritative V1 provenance. Keep
|
|
* the same refusal in every generated export/mutation script so a future caller cannot turn
|
|
* a shape-only record into an unregister by bypassing the classifier.
|
|
*/
|
|
function renderV1ProvenanceGuard(receipt) {
|
|
if (receipt === undefined) {
|
|
return `# V1 provenance evidence is unavailable; direct-task cleanup is disabled
|
|
exit 5`;
|
|
}
|
|
if (normalizeDefinitionHash(receipt.definitionHash) === undefined)
|
|
return 'exit 5';
|
|
// Ed25519 receipt 在渲染脚本前由 Node authority 验证。PowerShell 侧仍绑定精确的任务身份和快照哈希,
|
|
// 因此测试调用方或未来调用方都不能把缺失 receipt 变成仅凭形状注销。
|
|
return `# V1 provenance receipt verified by the lifecycle owner; keep the exact task snapshot bound
|
|
if ($task.TaskPath -ne '\\') { exit 6 }`;
|
|
}
|
|
function renderExportScript(name, expectedHash, receipt) {
|
|
const definitionHashGuard = renderDefinitionHashGuard(name, expectedHash);
|
|
const provenanceGuard = renderV1ProvenanceGuard(receipt);
|
|
const exportOutput = expectedHash !== undefined ? '$definitionXml' : `(Export-ScheduledTask -TaskPath '\\' -TaskName '${name}' -ErrorAction Stop)`;
|
|
return `$ErrorActionPreference = 'Stop'
|
|
try {
|
|
$tasks = @(Get-ScheduledTask -TaskPath '\\*' -ErrorAction Stop | Where-Object { $_.TaskPath -eq '\\' -and $_.TaskName -eq '${name}' })
|
|
if ($tasks.Count -ne 1) { exit 3 }
|
|
$task = $tasks[0]; $actions = @($task.Actions)
|
|
if ($actions.Count -ne 1) { exit 4 }
|
|
$leaf = Split-Path -Leaf ([string]$actions[0].Execute)
|
|
if (($leaf -ine 'powershell.exe') -and ($leaf -ine 'node.exe')) { exit 5 }
|
|
${provenanceGuard}
|
|
${definitionHashGuard}
|
|
Write-Output ${exportOutput}
|
|
exit 0
|
|
} catch { exit 9 }`;
|
|
}
|
|
/**
|
|
* Fingerprint revalidation → stop → revalidation → unregister → absence verification for
|
|
* one task (the bootstrap rollback unregister pattern). Exit codes: 0 removed, 3 count
|
|
* drift, 4 action drift, 5 execute/provenance refusal, 6 TaskPath drift, 7 stop timeout, 8
|
|
* absence not confirmed, 9 scheduler error, 10 definition drift.
|
|
*/
|
|
function renderMutationScript(name, expectedHash, receipt) {
|
|
const definitionHashGuard = renderDefinitionHashGuard(name, expectedHash);
|
|
const provenanceGuard = renderV1ProvenanceGuard(receipt);
|
|
const fingerprint = `$task = $tasks[0]; $actions = @($task.Actions)
|
|
if ($actions.Count -ne 1) { exit 4 }
|
|
$leaf = Split-Path -Leaf ([string]$actions[0].Execute)
|
|
if (($leaf -ine 'powershell.exe') -and ($leaf -ine 'node.exe')) { exit 5 }
|
|
${provenanceGuard}
|
|
if ($task.TaskPath -ne '\\') { exit 6 }
|
|
${definitionHashGuard}`;
|
|
return `$ErrorActionPreference = 'Stop'
|
|
try {
|
|
$tasks = @(Get-ScheduledTask -TaskPath '\\*' -ErrorAction Stop | Where-Object { $_.TaskPath -eq '\\' -and $_.TaskName -eq '${name}' })
|
|
if ($tasks.Count -ne 1) { exit 3 }
|
|
${fingerprint}
|
|
if ($task.State -eq 'Running') {
|
|
# Stop the inspected CIM object as well, so a replacement cannot be stopped by name
|
|
# before the post-stop fingerprint check has a chance to reject it.
|
|
Stop-ScheduledTask -InputObject $task -ErrorAction Stop
|
|
$deadline = [DateTime]::UtcNow.AddSeconds(15)
|
|
do {
|
|
Start-Sleep -Milliseconds 100
|
|
$wait = @(Get-ScheduledTask -TaskPath '\\*' -ErrorAction Stop | Where-Object { $_.TaskPath -eq '\\' -and $_.TaskName -eq '${name}' })
|
|
if ($wait.Count -eq 0) { $task = $null; break }
|
|
$task = $wait[0]
|
|
} while ($null -ne $task -and $task.State -eq 'Running' -and [DateTime]::UtcNow -lt $deadline)
|
|
if ($null -ne $task -and $task.State -eq 'Running') { exit 7 }
|
|
}
|
|
$tasks = @(Get-ScheduledTask -TaskPath '\\*' -ErrorAction Stop | Where-Object { $_.TaskPath -eq '\\' -and $_.TaskName -eq '${name}' })
|
|
if ($tasks.Count -ne 1) { exit 3 }
|
|
${fingerprint}
|
|
# Bind unregister to the CIM object returned by the final guarded enumeration. A name-only
|
|
# lookup here could delete a replacement task that wins the race after the fingerprint check.
|
|
Unregister-ScheduledTask -InputObject $task -Confirm:$false -ErrorAction Stop | Out-Null
|
|
$left = @(Get-ScheduledTask -TaskPath '\\*' -ErrorAction Stop | Where-Object { $_.TaskPath -eq '\\' -and $_.TaskName -eq '${name}' })
|
|
if ($left.Count -ne 0) { exit 8 }
|
|
exit 0
|
|
} catch { exit 9 }`;
|
|
}
|
|
/**
|
|
* Restore refuses to overwrite: it re-registers a persisted preimage only when the known
|
|
* name is currently absent. Exit codes: 0 restored, 3 name already present, 4 registration
|
|
* verification failed after a guarded rollback, 5 preimage changed while restoring, 9
|
|
* scheduler error, 11 rollback could not prove that the just-registered generation was still
|
|
* present (the task is deliberately left for operator inspection).
|
|
*/
|
|
function renderRestoreScript(name, preimageReadPath, expectedHash) {
|
|
const escapedPath = preimageReadPath.replace(/'/g, "''");
|
|
const normalizedHash = normalizeDefinitionHash(expectedHash);
|
|
const escapedHash = normalizedHash
|
|
? normalizedHash.replace(/'/g, "''")
|
|
: undefined;
|
|
const hashGuard = expectedHash !== undefined && escapedHash === undefined
|
|
? 'exit 5'
|
|
: escapedHash
|
|
? `$preimageBytes = [System.IO.File]::ReadAllBytes('${escapedPath}')
|
|
if ($preimageBytes.Length -eq 0 -or $preimageBytes.Length -gt ${MAX_PREIMAGE_BYTES}) { exit 5 }
|
|
$preimageSha = [System.Security.Cryptography.SHA256]::Create()
|
|
$preimageHash = ([System.BitConverter]::ToString($preimageSha.ComputeHash($preimageBytes))).Replace('-', '').ToLowerInvariant()
|
|
$preimageSha.Dispose()
|
|
if ($preimageHash -ne '${escapedHash}') { exit 5 }
|
|
$xml = [System.Text.Encoding]::UTF8.GetString($preimageBytes)`
|
|
: `$xml = [System.IO.File]::ReadAllText('${escapedPath}')`;
|
|
return `$ErrorActionPreference = 'Stop'
|
|
function Get-CanonicalTaskXml([string]$text) {
|
|
$xmlSettings = New-Object System.Xml.XmlReaderSettings
|
|
$xmlSettings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit
|
|
$xmlSettings.XmlResolver = $null
|
|
$xmlReader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($text), $xmlSettings)
|
|
try {
|
|
$xmlDoc = New-Object System.Xml.XmlDocument
|
|
$xmlDoc.PreserveWhitespace = $false
|
|
$xmlDoc.XmlResolver = $null
|
|
$xmlDoc.Load($xmlReader)
|
|
if ($null -eq $xmlDoc.DocumentElement -or $xmlDoc.DocumentElement.LocalName -ne 'Task') { throw 'invalid scheduled-task XML root' }
|
|
return [string]$xmlDoc.DocumentElement.OuterXml
|
|
} finally {
|
|
$xmlReader.Dispose()
|
|
}
|
|
}
|
|
function Get-CanonicalTaskHash([string]$text) {
|
|
$canonical = Get-CanonicalTaskXml $text
|
|
$sha = [System.Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
return ([System.BitConverter]::ToString($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($canonical)))).Replace('-', '').ToLowerInvariant()
|
|
} finally {
|
|
$sha.Dispose()
|
|
}
|
|
}
|
|
function Try-RollbackRegisteredTask([string]$expectedCanonicalHash) {
|
|
try {
|
|
$current = @(Get-ScheduledTask -TaskPath '\\*' -ErrorAction Stop | Where-Object { $_.TaskPath -eq '\\' -and $_.TaskName -eq '${name}' })
|
|
if ($current.Count -eq 0) { return $true }
|
|
if ($current.Count -ne 1) { return $false }
|
|
$currentXml = [string](Export-ScheduledTask -TaskPath '\\' -TaskName '${name}' -ErrorAction Stop)
|
|
if ((Get-CanonicalTaskHash $currentXml) -ne $expectedCanonicalHash) { return $false }
|
|
# Use the inspected CIM object, not a second name-only lookup, so a replacement is not
|
|
# silently unregistered after the guarded identity check.
|
|
Unregister-ScheduledTask -InputObject $current[0] -Confirm:$false -ErrorAction Stop | Out-Null
|
|
$remaining = @(Get-ScheduledTask -TaskPath '\\*' -ErrorAction Stop | Where-Object { $_.TaskPath -eq '\\' -and $_.TaskName -eq '${name}' })
|
|
return $remaining.Count -eq 0
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
$registered = $false
|
|
$registrationAttempted = $false
|
|
$expectedCanonicalHash = $null
|
|
try {
|
|
$existing = @(Get-ScheduledTask -TaskPath '\\*' -ErrorAction Stop | Where-Object { $_.TaskPath -eq '\\' -and $_.TaskName -eq '${name}' })
|
|
if ($existing.Count -ne 0) { exit 3 }
|
|
${hashGuard}
|
|
$expectedCanonicalHash = Get-CanonicalTaskHash $xml
|
|
$existingAfterRead = @(Get-ScheduledTask -TaskPath '\\*' -ErrorAction Stop | Where-Object { $_.TaskPath -eq '\\' -and $_.TaskName -eq '${name}' })
|
|
if ($existingAfterRead.Count -ne 0) { exit 3 }
|
|
# Mark the boundary before invoking the cmdlet. A provider can commit the task and then
|
|
# surface a timeout/transport error; the catch path must still attempt guarded cleanup.
|
|
$registrationAttempted = $true
|
|
Register-ScheduledTask -TaskPath '\\' -TaskName '${name}' -Xml $xml -ErrorAction Stop | Out-Null
|
|
$registered = $true
|
|
$verify = @(Get-ScheduledTask -TaskPath '\\*' -ErrorAction Stop | Where-Object { $_.TaskPath -eq '\\' -and $_.TaskName -eq '${name}' })
|
|
if ($verify.Count -ne 1) {
|
|
if (Try-RollbackRegisteredTask $expectedCanonicalHash) { exit 4 }
|
|
exit 11
|
|
}
|
|
# Export the registered generation and compare a parsed/canonical representation. This
|
|
# covers every action, argument, working directory, principal, trigger, setting,
|
|
# description, and generation field, while tolerating scheduler whitespace/encoding
|
|
# normalization. A command/argument-only check is insufficient because an extra action or
|
|
# changed principal could otherwise survive a failed restore.
|
|
$registeredXml = [string](Export-ScheduledTask -TaskPath '\\' -TaskName '${name}' -ErrorAction Stop)
|
|
if ((Get-CanonicalTaskHash $registeredXml) -ne $expectedCanonicalHash) {
|
|
if (Try-RollbackRegisteredTask $expectedCanonicalHash) { exit 4 }
|
|
exit 11
|
|
}
|
|
exit 0
|
|
} catch {
|
|
if (($registrationAttempted -or $registered) -and $null -ne $expectedCanonicalHash -and (Try-RollbackRegisteredTask $expectedCanonicalHash)) { exit 9 }
|
|
if ($registrationAttempted -or $registered) { exit 11 }
|
|
exit 9
|
|
}`;
|
|
}
|
|
function parseProbeRecords(stdout) {
|
|
const trimmed = stdout.trim();
|
|
if (!trimmed)
|
|
return undefined;
|
|
if (Buffer.byteLength(trimmed, 'utf8') > MAX_PROBE_OUTPUT_BYTES)
|
|
return undefined;
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
const list = Array.isArray(parsed) ? parsed : [parsed];
|
|
if (list.length === 0)
|
|
return undefined;
|
|
return list;
|
|
}
|
|
catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
function executeLeaf(execute) {
|
|
if (typeof execute !== 'string')
|
|
return undefined;
|
|
const trimmed = execute.trim();
|
|
if (!trimmed)
|
|
return undefined;
|
|
const leaf = trimmed.split(/[\\/]/).pop() ?? trimmed;
|
|
return leaf.toLowerCase();
|
|
}
|
|
function isFiniteInteger(value) {
|
|
return typeof value === 'number' && Number.isSafeInteger(value);
|
|
}
|
|
/**
|
|
* A present record must carry every field needed to make the conservative shape decision. A
|
|
* missing value is not equivalent to an empty/foreign task: it means the scheduler response
|
|
* was incomplete, so callers must stop before mutation. Even a complete direct-action shape
|
|
* remains ambiguous until a trusted V1 template is available; see `classifyProbedTask`.
|
|
*/
|
|
function hasCompleteProbeFingerprint(record) {
|
|
return typeof record.taskPath === 'string'
|
|
&& isFiniteInteger(record.actionCount)
|
|
&& typeof record.execute === 'string'
|
|
&& typeof record.arguments === 'string'
|
|
&& typeof record.workingDirectory === 'string'
|
|
&& isFiniteInteger(record.triggerCount)
|
|
&& typeof record.userId === 'string'
|
|
&& typeof record.principalSid === 'string'
|
|
&& typeof record.logonType === 'string'
|
|
&& typeof record.runLevel === 'string'
|
|
&& typeof record.enabled === 'boolean'
|
|
&& isFiniteInteger(record.restartCount)
|
|
&& typeof record.description === 'string'
|
|
&& typeof record.definitionHash === 'string'
|
|
&& /^[a-f0-9]{64}$/i.test(record.definitionHash);
|
|
}
|
|
export function classifyProbedTask(record, provenance) {
|
|
if (!record || record.present !== true)
|
|
return { classification: 'absent' };
|
|
if (typeof record.name !== 'string' || !LEGACY_WINDOWS_TASK_NAMES.includes(record.name)) {
|
|
return { classification: 'foreign', reason: 'task name is outside the cleanup allowlist' };
|
|
}
|
|
// The probe protocol always emits a count and an explicit ambiguous flag for present
|
|
// tasks; anything else is a protocol break and fails closed.
|
|
if (record.ambiguous !== false || record.count !== 1) {
|
|
return { classification: 'ambiguous', reason: 'multiple same-name tasks registered' };
|
|
}
|
|
if (!hasCompleteProbeFingerprint(record)) {
|
|
return { classification: 'ambiguous', reason: 'task fingerprint is incomplete' };
|
|
}
|
|
if (record.taskPath !== '\\') {
|
|
return { classification: 'foreign', reason: 'registered outside the root task path' };
|
|
}
|
|
if (record.actionCount !== 1) {
|
|
return { classification: 'foreign', reason: 'action shape does not match the v1 template' };
|
|
}
|
|
const userId = typeof record.userId === 'string' ? record.userId : '';
|
|
const principalSid = typeof record.principalSid === 'string' ? record.principalSid : '';
|
|
const logonType = typeof record.logonType === 'string' ? record.logonType : '';
|
|
const runLevel = typeof record.runLevel === 'string' ? record.runLevel : '';
|
|
const actionArguments = typeof record.arguments === 'string' ? record.arguments : '';
|
|
if (record.triggerCount !== 1
|
|
|| userId.trim() === ''
|
|
|| !/^S-\d-\d+(?:-\d+){1,15}$/.test(principalSid)
|
|
|| logonType.toLowerCase() !== 'interactive'
|
|
|| runLevel.toLowerCase() !== 'limited'
|
|
|| record.enabled !== true
|
|
|| record.restartCount !== 5
|
|
|| actionArguments.trim() === '') {
|
|
return { classification: 'foreign', reason: 'principal, trigger, settings, or action arguments drifted' };
|
|
}
|
|
const leaf = executeLeaf(record.execute);
|
|
if (leaf === undefined) {
|
|
return { classification: 'ambiguous', reason: 'action executable could not be read' };
|
|
}
|
|
if (LEGACY_ACTION_EXECUTABLE_LEAVES.has(leaf)) {
|
|
if (provenance === undefined) {
|
|
return { classification: 'ambiguous', reason: 'V1 provenance evidence unavailable; refusing destructive cleanup' };
|
|
}
|
|
if (provenance.taskName !== record.name
|
|
|| provenance.taskPath !== record.taskPath
|
|
|| provenance.principalSid !== principalSid
|
|
|| provenance.definitionHash !== String(record.definitionHash).toLowerCase()) {
|
|
return { classification: 'foreign', reason: 'signed V1 provenance does not match the observed task generation' };
|
|
}
|
|
return { classification: 'legacy', reason: 'signed V1 generation receipt matched' };
|
|
}
|
|
if (leaf === V2_ACTION_EXECUTABLE_LEAF) {
|
|
return { classification: 'current', reason: 'v2 wscript launcher binding' };
|
|
}
|
|
return { classification: 'foreign', reason: 'action executable is not an evolver shape' };
|
|
}
|
|
function runProbe(run, powershell) {
|
|
const result = run(powershell, powershellArgs(PROBE_SCRIPT), PROBE_TIMEOUT_MS);
|
|
if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
|
|
return { detail: result.error ? boundedDetail(result.error) : `probe exited with status ${result.status ?? 'timeout'}` };
|
|
}
|
|
const records = parseProbeRecords(result.stdout);
|
|
if (!records)
|
|
return { detail: 'probe output could not be parsed' };
|
|
return { records };
|
|
}
|
|
/**
|
|
* The PowerShell probe has a fixed, closed-world response contract. A missing,
|
|
* duplicated, malformed, or unexpected record means enumeration was incomplete;
|
|
* callers must not interpret it as an absent task.
|
|
*/
|
|
function probeCoverageIssue(records) {
|
|
const knownNames = new Set(LEGACY_WINDOWS_TASK_NAMES);
|
|
const names = records.map((record) => record && typeof record.name === 'string' ? record.name : undefined);
|
|
if (names.some((name) => name === undefined))
|
|
return 'probe returned a malformed task record';
|
|
if (names.some((name) => !knownNames.has(name)))
|
|
return 'probe returned an unexpected task record';
|
|
for (const record of records) {
|
|
if (!record || (record.present !== true && record.present !== false))
|
|
return 'probe returned a malformed task record';
|
|
if (record.present === false) {
|
|
if (record.count !== 0 || (record.ambiguous !== undefined && record.ambiguous !== false)) {
|
|
return 'probe returned an inconsistent absent-task record';
|
|
}
|
|
const absentFingerprintFields = [
|
|
'taskPath', 'actionCount', 'execute', 'arguments', 'workingDirectory', 'triggerCount',
|
|
'userId', 'principalSid', 'logonType', 'runLevel', 'enabled', 'restartCount',
|
|
'description', 'definitionHash',
|
|
];
|
|
if (absentFingerprintFields.some((field) => record[field] !== undefined)) {
|
|
return 'probe returned an inconsistent absent-task record';
|
|
}
|
|
}
|
|
}
|
|
const missing = LEGACY_WINDOWS_TASK_NAMES.filter((name) => !names.includes(name));
|
|
if (missing.length > 0)
|
|
return 'probe omitted a known task name';
|
|
const duplicate = LEGACY_WINDOWS_TASK_NAMES.find((name) => names.filter((candidate) => candidate === name).length !== 1);
|
|
if (duplicate)
|
|
return 'probe returned a duplicate known task record';
|
|
return undefined;
|
|
}
|
|
function preimageDirFor(stateDir) {
|
|
return join(stateDir, LEGACY_TASK_PREIMAGE_DIR);
|
|
}
|
|
function cooldownMarkerPath(stateDir) {
|
|
return join(stateDir, LEGACY_TASK_COOLDOWN_FILE);
|
|
}
|
|
/** Keep lifecycle state artifacts on the trusted, non-reparse state directory chain. */
|
|
function assertLegacyStateArtifactPath(path) {
|
|
assertTrustedArtifactParent(path, process.platform);
|
|
}
|
|
function writeLegacyStateText(path, content) {
|
|
assertLegacyStateArtifactPath(path);
|
|
writeDurableText(path, content, 0o600);
|
|
// Re-check the parent chain after publication so a replaced/symlinked state directory is
|
|
// never silently accepted as lifecycle-owned state.
|
|
assertLegacyStateArtifactPath(path);
|
|
// Validate the published leaf as well: owner-only mode, regular-file identity, no reparse
|
|
// link, and a stable descriptor/hash. This closes the gap between the generic durable writer
|
|
// and the lifecycle artifact contract used by reads.
|
|
readBootstrapArtifactFile(path, MAX_PREIMAGE_BYTES, { role: 'owned' });
|
|
}
|
|
function readCooldownMarker(stateDir) {
|
|
const path = cooldownMarkerPath(stateDir);
|
|
try {
|
|
assertLegacyStateArtifactPath(path);
|
|
const receipt = readBootstrapArtifactFile(path, MAX_COOLDOWN_MARKER_BYTES, { role: 'owned' });
|
|
const parsed = JSON.parse(receipt.bytes.toString('utf8'));
|
|
if (typeof parsed !== 'object' || parsed === null)
|
|
return undefined;
|
|
const marker = parsed;
|
|
if (marker.schema !== COOLDOWN_MARKER_SCHEMA)
|
|
return undefined;
|
|
if (typeof marker.lastConclusiveAt !== 'string' || Number.isNaN(Date.parse(marker.lastConclusiveAt)))
|
|
return undefined;
|
|
if (!Array.isArray(marker.removed) || marker.removed.some((entry) => typeof entry !== 'string'))
|
|
return undefined;
|
|
if (marker.status === 'clean' || marker.status === 'cleaned') {
|
|
return {
|
|
schema: COOLDOWN_MARKER_SCHEMA,
|
|
lastConclusiveAt: marker.lastConclusiveAt,
|
|
status: marker.status,
|
|
removed: marker.removed,
|
|
};
|
|
}
|
|
// Negative backoff markers (inconclusive/failed) are only valid with well-formed
|
|
// attempts + nextRetryAt; anything else falls through to the no-marker fail-safe.
|
|
if (marker.status === 'inconclusive' || marker.status === 'failed') {
|
|
if (typeof marker.attempts !== 'number' || !Number.isInteger(marker.attempts) || marker.attempts < 1)
|
|
return undefined;
|
|
if (typeof marker.nextRetryAt !== 'string' || Number.isNaN(Date.parse(marker.nextRetryAt)))
|
|
return undefined;
|
|
return {
|
|
schema: COOLDOWN_MARKER_SCHEMA,
|
|
lastConclusiveAt: marker.lastConclusiveAt,
|
|
status: marker.status,
|
|
removed: marker.removed,
|
|
attempts: marker.attempts,
|
|
nextRetryAt: marker.nextRetryAt,
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
/** Negative-backoff delay for inconclusive/failed sweeps: 1h base, doubling, capped at 24h. */
|
|
function legacyTaskNegativeBackoffMs(attempts) {
|
|
const clamped = Math.max(1, Math.floor(attempts));
|
|
const delay = LEGACY_TASK_NEGATIVE_BACKOFF_BASE_MS * 2 ** Math.min(clamped - 1, 30);
|
|
return Math.min(delay, LEGACY_TASK_NEGATIVE_BACKOFF_MAX_MS);
|
|
}
|
|
function withLegacyOwnerLock(stateDir, lockOptions, operation) {
|
|
const lock = _legacyTaskInternalsForTest.acquireBootstrapOwnerLock(stateDir, lockOptions ?? { maxTries: 300, waitMs: 100 });
|
|
try {
|
|
lock.assertOwned();
|
|
return operation();
|
|
}
|
|
finally {
|
|
lock.release();
|
|
}
|
|
}
|
|
function writeCooldownMarker(stateDir, status, removed, now) {
|
|
const marker = {
|
|
schema: COOLDOWN_MARKER_SCHEMA,
|
|
lastConclusiveAt: new Date(now()).toISOString(),
|
|
status,
|
|
removed,
|
|
};
|
|
writeLegacyStateText(cooldownMarkerPath(stateDir), `${JSON.stringify(marker)}\n`);
|
|
}
|
|
function writeNegativeCooldownMarker(stateDir, status, attempts, now) {
|
|
const current = now();
|
|
const marker = {
|
|
schema: COOLDOWN_MARKER_SCHEMA,
|
|
lastConclusiveAt: new Date(current).toISOString(),
|
|
status,
|
|
removed: [],
|
|
attempts,
|
|
nextRetryAt: new Date(current + legacyTaskNegativeBackoffMs(attempts)).toISOString(),
|
|
};
|
|
writeLegacyStateText(cooldownMarkerPath(stateDir), `${JSON.stringify(marker)}\n`);
|
|
}
|
|
function writeCooldownMarkerWithOwner(stateDir, status, removed, attempts, now, lockOptions) {
|
|
withLegacyOwnerLock(stateDir, lockOptions, () => {
|
|
if (status === 'clean' || status === 'cleaned')
|
|
writeCooldownMarker(stateDir, status, removed, now);
|
|
else
|
|
writeNegativeCooldownMarker(stateDir, status, attempts ?? 1, now);
|
|
});
|
|
}
|
|
function clearCooldownMarker(stateDir, assertOwner) {
|
|
assertOwner?.();
|
|
const path = cooldownMarkerPath(stateDir);
|
|
assertLegacyStateArtifactPath(path);
|
|
let identity;
|
|
try {
|
|
identity = readBootstrapArtifactFile(path, MAX_COOLDOWN_MARKER_BYTES, { role: 'owned' }).identity;
|
|
}
|
|
catch (error) {
|
|
if (error?.code === 'ENOENT')
|
|
return;
|
|
throw error;
|
|
}
|
|
const quarantine = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.quarantine`);
|
|
let moved = false;
|
|
const sameIdentity = (left, right) => left.sha256 === right.sha256
|
|
&& left.size === right.size && left.device === right.device && left.inode === right.inode;
|
|
const entryExists = (candidate) => {
|
|
try {
|
|
lstatSync(candidate);
|
|
return true;
|
|
}
|
|
catch (error) {
|
|
if (error?.code === 'ENOENT')
|
|
return false;
|
|
throw error;
|
|
}
|
|
};
|
|
try {
|
|
const settled = readBootstrapArtifactFile(path, MAX_COOLDOWN_MARKER_BYTES, { role: 'owned' }).identity;
|
|
if (!sameIdentity(settled, identity))
|
|
throw new Error('cooldown marker changed before removal');
|
|
try {
|
|
lstatSync(quarantine);
|
|
throw new Error('cooldown marker quarantine is unexpectedly occupied');
|
|
}
|
|
catch (error) {
|
|
if (error?.code !== 'ENOENT')
|
|
throw error;
|
|
}
|
|
// Move the exact inspected inode out of the canonical name before deleting it. If a
|
|
// concurrent writer publishes a replacement, the canonical path is observed as occupied
|
|
// and the quarantined marker is preserved instead of deleting the replacement.
|
|
assertLegacyStateArtifactPath(path);
|
|
assertLegacyStateArtifactPath(quarantine);
|
|
renameSync(path, quarantine);
|
|
moved = true;
|
|
// The rename is a filesystem boundary: re-check the trusted parent chain on both names
|
|
// before reading or deleting the quarantined inode.
|
|
assertLegacyStateArtifactPath(quarantine);
|
|
assertLegacyStateArtifactPath(path);
|
|
assertOwner?.();
|
|
const movedIdentity = readBootstrapArtifactFile(quarantine, MAX_COOLDOWN_MARKER_BYTES, { role: 'owned' }).identity;
|
|
if (!sameIdentity(movedIdentity, identity))
|
|
throw new Error('cooldown marker changed during quarantine');
|
|
if (entryExists(path))
|
|
throw new Error('cooldown marker was replaced during quarantine');
|
|
const beforeDelete = readBootstrapArtifactFile(quarantine, MAX_COOLDOWN_MARKER_BYTES, { role: 'owned' }).identity;
|
|
if (!sameIdentity(beforeDelete, identity))
|
|
throw new Error('cooldown marker changed before quarantine removal');
|
|
removeDurableFile(quarantine);
|
|
assertOwner?.();
|
|
}
|
|
catch (error) {
|
|
if (moved) {
|
|
// Restore the moved inode only while the canonical path is absent; otherwise leave both
|
|
// generations for operator inspection rather than overwriting a newer marker.
|
|
try {
|
|
// The rename itself may have moved a newer or foreign generation if it won the race
|
|
// with our settled read. Preserve whichever inode was moved; never silently drop it.
|
|
if (!entryExists(path) && entryExists(quarantine))
|
|
renameSync(quarantine, path);
|
|
}
|
|
catch {
|
|
// The quarantine remains durable and will not be mistaken for the canonical marker.
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
function persistPreimage(stateDir, name, xml, now) {
|
|
const stamp = new Date(now()).toISOString().replaceAll(':', '').replaceAll('-', '');
|
|
// The clock is not an ownership primitive: two cleanup processes can observe the same
|
|
// millisecond, and wall clocks can move backwards. Keep the operator-readable timestamp but
|
|
// add a cryptographic nonce so a new preimage can never replace an older receipt by name.
|
|
const nonce = randomUUID().replaceAll('-', '');
|
|
const path = join(preimageDirFor(stateDir), `${name}.${stamp}.${nonce}.xml`);
|
|
writeLegacyStateText(path, xml.endsWith('\n') ? xml : `${xml}\n`);
|
|
return basename(path);
|
|
}
|
|
/**
|
|
* The full safe cleanup. NEVER throws: every failure surface maps onto an honest
|
|
* `inconclusive` / `failed` status. Non-Windows platforms are reported as `skipped` — an
|
|
* unprobed host is never reported clean. The `EVOLVER_SKIP_LEGACY_TASK_PROBE` opt-out is
|
|
* honored by the AUTOMATIC entry points (session-start sweep, bootstrap sweep, doctor
|
|
* probe); this function implements the explicit operator/transaction path and always
|
|
* probes, so an explicit `evolver lifecycle cleanup-legacy-tasks` can never be silently
|
|
* disabled out from under the operator.
|
|
*/
|
|
function cleanupLegacyWindowsDaemonTasksInternal(options) {
|
|
const platform = options.platform ?? process.platform;
|
|
const dryRun = options.dryRun === true;
|
|
if (platform !== 'win32') {
|
|
return { status: 'skipped', dryRun, tasks: [], detail: 'not a Windows host' };
|
|
}
|
|
const stateDir = options.stateDir ?? lifecyclePaths(options.env).stateDir;
|
|
const run = options.run ?? defaultLegacyTaskRun();
|
|
const now = options.now ?? Date.now;
|
|
let trackedEntries = [];
|
|
const removedNames = [];
|
|
let powershell;
|
|
try {
|
|
powershell = trustedWindowsPowerShell();
|
|
}
|
|
catch (error) {
|
|
return { status: 'inconclusive', dryRun, tasks: [], detail: boundedDetail(error) };
|
|
}
|
|
try {
|
|
const provenance = loadLegacyTaskProvenance(stateDir, options.env, now());
|
|
if (provenance.detail) {
|
|
return { status: 'inconclusive', dryRun, tasks: [], detail: provenance.detail };
|
|
}
|
|
const probed = runProbe(run, powershell);
|
|
if ('detail' in probed) {
|
|
return { status: 'inconclusive', dryRun, tasks: [], detail: probed.detail };
|
|
}
|
|
const entries = LEGACY_WINDOWS_TASK_NAMES.map((name) => {
|
|
const record = probed.records.find((candidate) => candidate && candidate.name === name);
|
|
const { classification, reason } = classifyProbedTask(record, provenance.receipts.get(name));
|
|
return { name, classification, ...(reason ? { reason } : {}) };
|
|
});
|
|
trackedEntries = entries;
|
|
// A record for a known name that the probe never returned, or any other response-shape
|
|
// drift, is a protocol break, not a negative result.
|
|
const coverageIssue = probeCoverageIssue(probed.records);
|
|
if (coverageIssue)
|
|
return { status: 'inconclusive', dryRun, tasks: entries, detail: coverageIssue };
|
|
if (entries.some((entry) => entry.classification === 'ambiguous')) {
|
|
return { status: 'inconclusive', dryRun, tasks: entries, detail: 'ambiguous task shape; refusing to mutate' };
|
|
}
|
|
const expectedDefinitionHashes = new Map();
|
|
for (const record of probed.records) {
|
|
if (record && typeof record.name === 'string' && typeof record.definitionHash === 'string') {
|
|
expectedDefinitionHashes.set(record.name, record.definitionHash);
|
|
}
|
|
}
|
|
const expectedProvenanceReceipts = new Map();
|
|
for (const entry of entries) {
|
|
const receipt = provenance.receipts.get(entry.name);
|
|
if (receipt)
|
|
expectedProvenanceReceipts.set(entry.name, receipt);
|
|
}
|
|
const legacy = entries.filter((entry) => entry.classification === 'legacy');
|
|
if (legacy.length === 0) {
|
|
if (!dryRun) {
|
|
try {
|
|
if (options.assertOwner) {
|
|
options.assertOwner();
|
|
writeCooldownMarker(stateDir, 'clean', [], now);
|
|
}
|
|
else {
|
|
writeCooldownMarkerWithOwner(stateDir, 'clean', [], undefined, now, options.lock);
|
|
}
|
|
}
|
|
catch {
|
|
// The cooldown marker is throttle state only; its loss merely retries sooner.
|
|
}
|
|
}
|
|
return { status: 'clean', dryRun, tasks: entries };
|
|
}
|
|
if (dryRun) {
|
|
return { status: 'legacy_detected', dryRun, tasks: entries };
|
|
}
|
|
return cleanupClassifiedLegacyTasks(options, powershell, entries, legacy, expectedDefinitionHashes, expectedProvenanceReceipts);
|
|
}
|
|
catch (error) {
|
|
const detail = boundedDetail(error);
|
|
const status = detail.startsWith('bootstrap owner lock unavailable')
|
|
? 'inconclusive'
|
|
: 'failed';
|
|
const partialPrefix = removedNames.length > 0
|
|
? `partial cleanup removed ${removedNames.join(', ')}; `
|
|
: '';
|
|
return { status, dryRun, tasks: trackedEntries, detail: `${partialPrefix}${detail}` };
|
|
}
|
|
}
|
|
/**
|
|
* Manual cleanup owns the lifecycle lock before it probes. Without this outer lock a clean
|
|
* probe could race a concurrent bootstrap that registers a legacy-named task and then publish
|
|
* a positive cooldown marker, suppressing the next real sweep for 24 hours. Automatic callers
|
|
* already hold the same lock and pass `assertOwner`, while dry-run remains read-only.
|
|
*/
|
|
export function cleanupLegacyWindowsDaemonTasks(options) {
|
|
const platform = options.platform ?? process.platform;
|
|
const dryRun = options.dryRun === true;
|
|
if (platform !== 'win32' || dryRun || options.assertOwner) {
|
|
return cleanupLegacyWindowsDaemonTasksInternal(options);
|
|
}
|
|
const stateDir = options.stateDir ?? lifecyclePaths(options.env).stateDir;
|
|
let lock;
|
|
try {
|
|
try {
|
|
lock = _legacyTaskInternalsForTest.acquireBootstrapOwnerLock(stateDir, options.lock ?? { maxTries: 300, waitMs: 100 });
|
|
}
|
|
catch (error) {
|
|
// An owner lock that cannot be acquired is an authority failure, not evidence that
|
|
// the task set is broken. Report it as inconclusive so the manual path never turns
|
|
// an unavailable or untrusted state directory into a misleading hard failure.
|
|
return {
|
|
status: 'inconclusive',
|
|
dryRun,
|
|
tasks: [],
|
|
detail: `bootstrap owner lock unavailable: ${boundedDetail(error)}`,
|
|
};
|
|
}
|
|
lock.assertOwned();
|
|
const result = cleanupLegacyWindowsDaemonTasksInternal({
|
|
...options,
|
|
assertOwner: lock.assertOwned,
|
|
});
|
|
try {
|
|
lock.release();
|
|
}
|
|
catch (releaseError) {
|
|
if (result.status === 'cleaned' || result.status === 'clean') {
|
|
result.detail = `owner lock release failed: ${boundedDetail(releaseError)}`;
|
|
}
|
|
}
|
|
lock = undefined;
|
|
return result;
|
|
}
|
|
catch (error) {
|
|
if (lock) {
|
|
try {
|
|
lock.release();
|
|
}
|
|
catch { /* preserve the primary bounded outcome */ }
|
|
}
|
|
const detail = boundedDetail(error);
|
|
return {
|
|
status: detail.startsWith('bootstrap owner lock unavailable') ? 'inconclusive' : 'failed',
|
|
dryRun,
|
|
tasks: [],
|
|
detail,
|
|
};
|
|
}
|
|
}
|
|
/** Read-only probe for `evolver doctor` — never mutates, never throws. */
|
|
export function probeLegacyWindowsDaemonTasks(options) {
|
|
const platform = options.platform ?? process.platform;
|
|
if (platform !== 'win32') {
|
|
return { conclusive: false, legacy: [], detail: 'not a Windows host' };
|
|
}
|
|
if (legacyTaskProbeSkipped(options.env)) {
|
|
return { conclusive: false, legacy: [], detail: `${LEGACY_TASK_SKIP_ENV} is set` };
|
|
}
|
|
let powershell;
|
|
try {
|
|
powershell = trustedWindowsPowerShell();
|
|
}
|
|
catch (error) {
|
|
return { conclusive: false, legacy: [], detail: boundedDetail(error) };
|
|
}
|
|
const run = options.run ?? defaultLegacyTaskRun();
|
|
try {
|
|
const stateDir = options.stateDir ?? lifecyclePaths(options.env).stateDir;
|
|
const provenance = loadLegacyTaskProvenance(stateDir, options.env, Date.now());
|
|
if (provenance.detail)
|
|
return { conclusive: false, legacy: [], detail: provenance.detail };
|
|
const probed = runProbe(run, powershell);
|
|
if ('detail' in probed) {
|
|
return { conclusive: false, legacy: [], detail: probed.detail };
|
|
}
|
|
const coverageIssue = probeCoverageIssue(probed.records);
|
|
if (coverageIssue)
|
|
return { conclusive: false, legacy: [], detail: coverageIssue };
|
|
const legacy = [];
|
|
for (const name of LEGACY_WINDOWS_TASK_NAMES) {
|
|
const record = probed.records.find((candidate) => candidate && candidate.name === name);
|
|
const { classification } = classifyProbedTask(record, provenance.receipts.get(name));
|
|
if (classification === 'ambiguous') {
|
|
return { conclusive: false, legacy: [], detail: `ambiguous shape for ${name}` };
|
|
}
|
|
if (classification === 'legacy')
|
|
legacy.push(name);
|
|
}
|
|
return { conclusive: true, legacy, detail: legacy.length > 0 ? `detected ${legacy.length} legacy v1 task(s)` : 'no legacy v1-style scheduled tasks registered' };
|
|
}
|
|
catch (error) {
|
|
return { conclusive: false, legacy: [], detail: boundedDetail(error) };
|
|
}
|
|
}
|
|
/**
|
|
* Automatic entry point (SessionStart hook). Throttled by the cooldown marker: after a
|
|
* conclusive success the sweep skips re-probing for the cooldown window; an
|
|
* inconclusive/failed run writes a NEGATIVE backoff marker (1h base, doubling per
|
|
* consecutive failure, capped at 24h) and the sweep skips until `nextRetryAt`, so a
|
|
* constrained host does not spawn PowerShell on every session. A conclusive success
|
|
* overwrites the marker, resetting the backoff counter. Never throws.
|
|
*/
|
|
export function maybeCleanupLegacyWindowsDaemonTasks(options) {
|
|
const platform = options.platform ?? process.platform;
|
|
if (platform !== 'win32')
|
|
return undefined;
|
|
if (legacyTaskProbeSkipped(options.env))
|
|
return undefined;
|
|
const stateDir = options.stateDir ?? lifecyclePaths(options.env).stateDir;
|
|
const now = options.now ?? Date.now;
|
|
const cooldownMs = options.cooldownMs ?? LEGACY_TASK_COOLDOWN_MS;
|
|
let ownerLock;
|
|
let result;
|
|
try {
|
|
// Keep the owner lock across marker read, probe/mutation, and marker publication. This
|
|
// prevents a stale negative result from overwriting a newer conclusive marker produced by
|
|
// a concurrent SessionStart/bootstrap process.
|
|
ownerLock = _legacyTaskInternalsForTest.acquireBootstrapOwnerLock(stateDir, options.lock ?? { maxTries: 30, waitMs: 100 });
|
|
ownerLock.assertOwned();
|
|
const marker = readCooldownMarker(stateDir);
|
|
if (marker) {
|
|
if (marker.status === 'inconclusive' || marker.status === 'failed') {
|
|
const retryAt = Date.parse(marker.nextRetryAt ?? '');
|
|
if (Number.isFinite(retryAt) && now() < retryAt)
|
|
return undefined;
|
|
}
|
|
else {
|
|
const age = now() - Date.parse(marker.lastConclusiveAt);
|
|
if (Number.isFinite(age) && age >= 0 && age < cooldownMs)
|
|
return undefined;
|
|
}
|
|
}
|
|
result = cleanupLegacyWindowsDaemonTasks({
|
|
env: options.env,
|
|
stateDir,
|
|
platform,
|
|
run: options.run,
|
|
now,
|
|
assertOwner: ownerLock.assertOwned,
|
|
});
|
|
ownerLock.assertOwned();
|
|
if (result.status === 'inconclusive' || result.status === 'failed') {
|
|
try {
|
|
const previousAttempts = marker && (marker.status === 'inconclusive' || marker.status === 'failed')
|
|
? marker.attempts ?? 0
|
|
: 0;
|
|
writeNegativeCooldownMarker(stateDir, result.status, previousAttempts + 1, now);
|
|
}
|
|
catch {
|
|
// The cooldown marker is throttle state only; its loss merely retries sooner.
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
catch (error) {
|
|
result = {
|
|
status: 'inconclusive',
|
|
dryRun: false,
|
|
tasks: [],
|
|
detail: boundedDetail(error),
|
|
};
|
|
return result;
|
|
}
|
|
finally {
|
|
if (ownerLock) {
|
|
try {
|
|
ownerLock.release();
|
|
}
|
|
catch (releaseError) {
|
|
if (result) {
|
|
result.detail = `${result.detail ? `${result.detail}; ` : ''}owner lock release failed: ${boundedDetail(releaseError)}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Post-bootstrap sweep seam: runs under the already-held bootstrap owner lock and returns
|
|
* report lines for the bootstrap `actions`. Never throws and never fails the bootstrap —
|
|
* but a failed/inconclusive sweep is reported honestly instead of being dropped.
|
|
*/
|
|
export function sweepLegacyWindowsDaemonTasksAfterBootstrap(options) {
|
|
try {
|
|
if (legacyTaskProbeSkipped(options.env))
|
|
return [];
|
|
const result = cleanupLegacyWindowsDaemonTasks({
|
|
env: options.env,
|
|
stateDir: options.stateDir,
|
|
assertOwner: options.assertOwner,
|
|
...(options.platform ? { platform: options.platform } : {}),
|
|
...(options.run ? { run: options.run } : {}),
|
|
});
|
|
if (result.status === 'cleaned') {
|
|
const names = result.tasks.filter((task) => task.outcome === 'removed').map((task) => task.name);
|
|
return [`removed legacy v1 scheduled-task residue: ${names.join(', ')} (durable preimage saved under the lifecycle state dir)`];
|
|
}
|
|
if (result.status === 'failed' || result.status === 'inconclusive') {
|
|
return [`legacy v1 scheduled-task cleanup ${result.status}${result.detail ? `: ${result.detail}` : ''}; run evolver lifecycle cleanup-legacy-tasks manually`];
|
|
}
|
|
return [];
|
|
}
|
|
catch (error) {
|
|
return [`legacy v1 scheduled-task cleanup inconclusive: ${boundedDetail(error)}; run evolver lifecycle cleanup-legacy-tasks manually`];
|
|
}
|
|
}
|
|
/**
|
|
* Manual restore of a persisted preimage. Only files named `<KnownTaskName>.<stamp>.xml`
|
|
* are accepted, the target name must be currently ABSENT (restore never overwrites), and
|
|
* the XML is validated before it reaches PowerShell. Never throws.
|
|
*/
|
|
export function restoreLegacyTaskPreimage(preimagePath, options) {
|
|
const platform = options.platform ?? process.platform;
|
|
if (platform !== 'win32')
|
|
return { status: 'failed', detail: 'not a Windows host' };
|
|
let outcome;
|
|
let ownerLock;
|
|
try {
|
|
const stateDir = options.stateDir ?? lifecyclePaths(options.env).stateDir;
|
|
const preimageRoot = resolvePath(preimageDirFor(stateDir));
|
|
const requestedPath = isAbsolute(preimagePath)
|
|
? resolvePath(preimagePath)
|
|
: resolvePath(preimageRoot, preimagePath);
|
|
const requestedRelative = relative(preimageRoot, requestedPath);
|
|
const sameDirectory = dirname(requestedPath).toLowerCase() === preimageRoot.toLowerCase();
|
|
if (!requestedRelative || requestedRelative.startsWith('..') || isAbsolute(requestedRelative) || !sameDirectory) {
|
|
return { status: 'failed', detail: 'preimage must be a direct file in the lifecycle preimage directory' };
|
|
}
|
|
assertLegacyStateArtifactPath(requestedPath);
|
|
const base = basename(requestedPath);
|
|
const name = LEGACY_WINDOWS_TASK_NAMES.find((candidate) => base.startsWith(`${candidate}.`) && base.endsWith('.xml'));
|
|
if (!name) {
|
|
return { status: 'failed', detail: 'preimage file name does not encode a known task name' };
|
|
}
|
|
const stampPattern = new RegExp(`^${name}\\.\\d{8}T\\d{6}(?:\\.\\d{3}|\\d{3})Z(?:\\.[a-f0-9]{32})?\\.xml$`);
|
|
if (!stampPattern.test(base)) {
|
|
return { status: 'failed', detail: 'preimage file name does not encode a valid lifecycle timestamp' };
|
|
}
|
|
ownerLock = _legacyTaskInternalsForTest.acquireBootstrapOwnerLock(stateDir, options.lock);
|
|
ownerLock.assertOwned();
|
|
const receipt = readBootstrapArtifactFile(requestedPath, MAX_PREIMAGE_BYTES, { role: 'owned' });
|
|
if (receipt.bytes.length === 0) {
|
|
return { status: 'failed', detail: 'preimage file is empty' };
|
|
}
|
|
const xml = receipt.bytes.toString('utf8');
|
|
if (!/^\s*<\?xml/.test(xml) || !/<Task\b/.test(xml) || !/<Actions\b/.test(xml)
|
|
|| /<!DOCTYPE|<!ENTITY/i.test(xml)) {
|
|
return { status: 'failed', detail: 'preimage is not a scheduled-task XML definition' };
|
|
}
|
|
if (xml.split('\n').some((line) => line.startsWith("'@"))) {
|
|
return { status: 'failed', detail: 'preimage content failed embedding validation' };
|
|
}
|
|
const powershell = trustedWindowsPowerShell();
|
|
const run = options.run ?? defaultLegacyTaskRun();
|
|
const result = run(powershell, powershellArgs(renderRestoreScript(name, requestedPath, receipt.identity.sha256)), MUTATION_TIMEOUT_MS);
|
|
// The scheduler script performs the absent-check, registration, and verification while
|
|
// the owner lock is held. Reassert after it returns so a lost lock cannot be reported as
|
|
// a successful restore.
|
|
ownerLock.assertOwned();
|
|
if (result.status === 0 && !result.error) {
|
|
try {
|
|
const settled = readBootstrapArtifactFile(requestedPath, MAX_PREIMAGE_BYTES, { role: 'owned' });
|
|
if (settled.identity.sha256 !== receipt.identity.sha256
|
|
|| settled.identity.size !== receipt.identity.size
|
|
|| settled.identity.device !== receipt.identity.device
|
|
|| settled.identity.inode !== receipt.identity.inode) {
|
|
outcome = { status: 'failed', task: name, detail: 'preimage changed during restore; registration outcome is uncertain' };
|
|
return outcome;
|
|
}
|
|
}
|
|
catch (error) {
|
|
outcome = { status: 'failed', task: name, detail: `preimage changed during restore: ${boundedDetail(error)}` };
|
|
return outcome;
|
|
}
|
|
try {
|
|
clearCooldownMarker(stateDir, ownerLock.assertOwned);
|
|
}
|
|
catch (error) {
|
|
outcome = {
|
|
status: 'restored',
|
|
task: name,
|
|
detail: `preimage re-registered and verified; cooldown marker cleanup failed: ${boundedDetail(error)}`,
|
|
};
|
|
return outcome;
|
|
}
|
|
outcome = { status: 'restored', task: name, detail: 'preimage re-registered and verified' };
|
|
return outcome;
|
|
}
|
|
const detail = result.status === 3
|
|
? 'task name is already present; restore never overwrites (remove it first)'
|
|
: result.status === 4
|
|
? 'registration could not be verified; guarded rollback completed'
|
|
: result.status === 5
|
|
? 'preimage changed while restoring; registration was refused'
|
|
: result.status === 11
|
|
? 'registration verification was uncertain; task was left for operator inspection'
|
|
: result.error
|
|
? boundedDetail(result.error)
|
|
: `restore exited with status ${result.status ?? 'timeout'}`;
|
|
outcome = { status: 'failed', task: name, detail };
|
|
return outcome;
|
|
}
|
|
catch (error) {
|
|
outcome = { status: 'failed', detail: boundedDetail(error) };
|
|
return outcome;
|
|
}
|
|
finally {
|
|
if (ownerLock) {
|
|
try {
|
|
ownerLock.release();
|
|
}
|
|
catch (releaseError) {
|
|
// A successful registration remains durable even when cleanup of the lock handle
|
|
// reports an ownership change; preserve the success but surface the bounded warning.
|
|
if (outcome?.status === 'restored')
|
|
outcome.detail = `owner lock release failed: ${boundedDetail(releaseError)}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
export const _legacyTaskInternalsForTest = {
|
|
PROBE_SCRIPT,
|
|
acquireBootstrapOwnerLock,
|
|
defaultLegacyTaskRun,
|
|
canonicalLegacyTaskProvenanceBytes,
|
|
parseLegacyTaskProvenanceReceipt,
|
|
loadLegacyTaskProvenance,
|
|
renderExportScript,
|
|
renderMutationScript,
|
|
renderRestoreScript,
|
|
parseProbeRecords,
|
|
readCooldownMarker,
|
|
writeCooldownMarker,
|
|
writeNegativeCooldownMarker,
|
|
negativeBackoffMs: legacyTaskNegativeBackoffMs,
|
|
cooldownMarkerPath,
|
|
preimageDirFor,
|
|
cleanupClassifiedLegacyTasksForTest,
|
|
};
|
|
/**
|
|
* Mutation mechanics for entries that have already passed the production classifier.
|
|
*
|
|
* 公开清理路径只有在可信 provenance classifier 至少返回一个 `legacy` 条目后才会进入这里。
|
|
* 缺少有效签名 receipt 时,所有完整 direct-action shape 都会保持 `ambiguous`。下方测试专用
|
|
* wrapper 仅接收预分类合成条目,以覆盖 preimage/revalidation 的失败机制;它不是
|
|
* provenance/configuration 绕过,也不属于 package API。
|
|
*/
|
|
function cleanupClassifiedLegacyTasks(options, powershell, entries, legacy, expectedDefinitionHashes, expectedProvenanceReceipts = new Map()) {
|
|
const dryRun = options.dryRun === true;
|
|
const stateDir = options.stateDir ?? lifecyclePaths(options.env).stateDir;
|
|
const run = options.run ?? defaultLegacyTaskRun();
|
|
const now = options.now ?? Date.now;
|
|
const removedNames = [];
|
|
const trackedEntries = entries;
|
|
if (legacy.some((entry) => entry.classification !== 'legacy')) {
|
|
throw new Error('mutation helper requires preclassified legacy entries');
|
|
}
|
|
try {
|
|
// Mutation phase: under lifecycle owner authority, preimage first, then unregister with
|
|
// final revalidation. Lock contention means authority could not be proven; nothing is
|
|
// mutated and the run reports inconclusive (never clean).
|
|
const lock = options.assertOwner
|
|
? undefined
|
|
: (() => {
|
|
try {
|
|
return _legacyTaskInternalsForTest.acquireBootstrapOwnerLock(stateDir, options.lock ?? { maxTries: 300, waitMs: 100 });
|
|
}
|
|
catch (error) {
|
|
throw new Error(`bootstrap owner lock unavailable: ${boundedDetail(error)}`);
|
|
}
|
|
})();
|
|
const assertOwner = options.assertOwner ?? lock.assertOwned;
|
|
// The computed outcome is returned by reference, so the release below can annotate a
|
|
// conclusive success without ever replacing it.
|
|
let computed;
|
|
try {
|
|
assertOwner();
|
|
const removed = removedNames;
|
|
let drifted = false;
|
|
let failed = false;
|
|
let failDetail;
|
|
for (const entry of legacy) {
|
|
const name = entry.name;
|
|
const expectedDefinitionHash = expectedDefinitionHashes.get(name);
|
|
const provenanceReceipt = expectedProvenanceReceipts.get(name);
|
|
if (provenanceReceipt !== undefined
|
|
&& (expectedDefinitionHash === undefined
|
|
|| normalizeDefinitionHash(expectedDefinitionHash) !== normalizeDefinitionHash(provenanceReceipt.definitionHash))) {
|
|
drifted = true;
|
|
entry.outcome = 'removal-aborted';
|
|
entry.reason = 'signed V1 provenance is not bound to the observed definition hash';
|
|
continue;
|
|
}
|
|
const exported = run(powershell, powershellArgs(renderExportScript(name, expectedDefinitionHash, provenanceReceipt)), EXPORT_TIMEOUT_MS);
|
|
if (exported.error || exported.status !== 0 || typeof exported.stdout !== 'string' || !exported.stdout.trim()) {
|
|
if (exported.status === 3 || exported.status === 4 || exported.status === 5 || exported.status === 10) {
|
|
drifted = true;
|
|
entry.outcome = 'removal-aborted';
|
|
entry.reason = exported.status === 5
|
|
? 'fingerprint/provenance guard refused preimage capture'
|
|
: 'fingerprint drifted before preimage capture';
|
|
continue;
|
|
}
|
|
failed = true;
|
|
entry.outcome = 'removal-failed';
|
|
failDetail ??= exported.error
|
|
? boundedDetail(exported.error)
|
|
: `preimage capture exited with status ${exported.status ?? 'timeout'}`;
|
|
continue;
|
|
}
|
|
const xml = exported.stdout.trim();
|
|
if (Buffer.byteLength(xml, 'utf8') > MAX_PREIMAGE_BYTES || xml.split('\n').some((line) => line.startsWith("'@"))) {
|
|
failed = true;
|
|
entry.outcome = 'removal-failed';
|
|
failDetail ??= 'preimage content failed durability validation';
|
|
continue;
|
|
}
|
|
let preimage;
|
|
try {
|
|
preimage = persistPreimage(stateDir, name, xml, now);
|
|
}
|
|
catch (error) {
|
|
failed = true;
|
|
entry.outcome = 'removal-failed';
|
|
failDetail ??= `preimage persistence failed: ${boundedDetail(error)}`;
|
|
continue;
|
|
}
|
|
assertOwner();
|
|
const mutation = run(powershell, powershellArgs(renderMutationScript(name, expectedDefinitionHash, provenanceReceipt)), MUTATION_TIMEOUT_MS);
|
|
if (mutation.status === 0 && !mutation.error) {
|
|
entry.outcome = 'removed';
|
|
entry.preimage = preimage;
|
|
removed.push(name);
|
|
continue;
|
|
}
|
|
if (mutation.status === 3 || mutation.status === 4 || mutation.status === 5 || mutation.status === 6 || mutation.status === 10) {
|
|
drifted = true;
|
|
entry.outcome = 'removal-aborted';
|
|
entry.reason = mutation.status === 5
|
|
? 'fingerprint/provenance guard refused unregister'
|
|
: 'fingerprint drifted before unregister';
|
|
continue;
|
|
}
|
|
failed = true;
|
|
entry.outcome = 'removal-failed';
|
|
failDetail ??= mutation.status === 7
|
|
? 'task did not stop within the budget'
|
|
: mutation.status === 8
|
|
? 'task still present after unregister'
|
|
: mutation.error
|
|
? boundedDetail(mutation.error)
|
|
: `unregister exited with status ${mutation.status ?? 'timeout'}`;
|
|
}
|
|
assertOwner();
|
|
const status = failed ? 'failed' : drifted ? 'inconclusive' : 'cleaned';
|
|
const partialPrefix = removed.length > 0 ? `partial cleanup removed ${removed.join(', ')}; ` : '';
|
|
const result = {
|
|
status,
|
|
dryRun,
|
|
tasks: entries,
|
|
...(failDetail
|
|
? { detail: `${partialPrefix}${failDetail}` }
|
|
: drifted
|
|
? { detail: `${partialPrefix}task shape drifted during cleanup; residue left untouched` }
|
|
: {}),
|
|
};
|
|
if (status === 'cleaned') {
|
|
try {
|
|
assertOwner();
|
|
writeCooldownMarker(stateDir, status, removed, now);
|
|
}
|
|
catch {
|
|
// The cooldown marker is throttle state only; its loss merely retries sooner.
|
|
}
|
|
}
|
|
computed = result;
|
|
return result;
|
|
}
|
|
finally {
|
|
// A release failure must never overwrite the computed outcome: after a conclusive
|
|
// success the tasks are removed and their preimages are durable (the release failure
|
|
// is bounded into the detail only), and on the error path the original error keeps
|
|
// propagating unchanged.
|
|
if (lock) {
|
|
try {
|
|
lock.release();
|
|
}
|
|
catch (releaseError) {
|
|
if (computed?.status === 'cleaned') {
|
|
computed.detail = `owner lock release failed: ${boundedDetail(releaseError)}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (error) {
|
|
const detail = boundedDetail(error);
|
|
const status = detail.startsWith('bootstrap owner lock unavailable')
|
|
? 'inconclusive'
|
|
: 'failed';
|
|
const partialPrefix = removedNames.length > 0
|
|
? `partial cleanup removed ${removedNames.join(', ')}; `
|
|
: '';
|
|
return { status, dryRun, tasks: trackedEntries, detail: `${partialPrefix}${detail}` };
|
|
}
|
|
}
|
|
/** Test-only wrapper for mutation mechanics; callers must supply preclassified legacy entries. */
|
|
function cleanupClassifiedLegacyTasksForTest(options, entries, expectedDefinitionHashes = new Map(), expectedProvenanceReceipts = new Map()) {
|
|
if (entries.some((entry) => entry.classification !== 'legacy')) {
|
|
throw new Error('test mutation helper requires preclassified legacy entries');
|
|
}
|
|
const powershell = trustedWindowsPowerShell();
|
|
return cleanupClassifiedLegacyTasks(options, powershell, entries, entries, expectedDefinitionHashes, expectedProvenanceReceipts);
|
|
} |