@bli-cockpit/cli 0.2.47 → 0.2.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
- package/dist/adapters/raw-evidence.js +360 -349
- package/dist/autostart-contract.js +79 -0
- package/dist/autostart-darwin-plist.js +265 -0
- package/dist/autostart-darwin.js +171 -0
- package/dist/autostart-windows-scripts.js +310 -0
- package/dist/autostart-windows-task-xml.js +260 -0
- package/dist/autostart-windows.js +237 -0
- package/dist/autostart-xml.js +23 -0
- package/dist/autostart.js +35 -1148
- package/dist/commands/agent-rules-command.js +55 -0
- package/dist/commands/agent-session-report.js +290 -0
- package/dist/commands/analyze.js +131 -0
- package/dist/commands/autostart-command.js +105 -0
- package/dist/commands/backfill.js +824 -551
- package/dist/commands/cli-io.js +13 -0
- package/dist/commands/heartbeat.js +18 -0
- package/dist/commands/install-receipts.js +34 -0
- package/dist/commands/jarvis.js +179 -3
- package/dist/commands/local-arg-values.js +169 -0
- package/dist/commands/local-args-collector.js +578 -0
- package/dist/commands/local-args-tower.js +870 -0
- package/dist/commands/local-args.js +8 -1549
- package/dist/commands/local-help.js +11 -3
- package/dist/commands/local.js +18 -1786
- package/dist/commands/login.js +53 -0
- package/dist/commands/logout.js +66 -0
- package/dist/commands/onboard-receipts.js +66 -0
- package/dist/commands/onboard-report.js +274 -0
- package/dist/commands/onboard.js +449 -0
- package/dist/commands/ops-render.js +36 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/serve.js +13 -0
- package/dist/commands/session-sync.js +513 -534
- package/dist/commands/settings-render.js +28 -0
- package/dist/commands/settings.js +66 -2
- package/dist/commands/start.js +47 -0
- package/dist/commands/sync-followups.js +203 -0
- package/dist/commands/sync.js +381 -0
- package/dist/dev-build.js +186 -0
- package/dist/tower-stream.js +20 -4
- package/package.json +2 -2
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
|
|
5
|
+
import { savedDiscoveryLimitArgs } from "./discovery-limits.js";
|
|
6
|
+
import { fileExists, resolveAutostartSettings, schedulerNodeExecutable, WINDOWS_AUTOSTART_TASK_NAME, } from "./autostart-contract.js";
|
|
7
|
+
/**
|
|
8
|
+
* The three files the Windows scheduled task actually runs, and how to tell
|
|
9
|
+
* whether the copies on disk still say what this build would write.
|
|
10
|
+
*
|
|
11
|
+
* Registration with Task Scheduler is the sibling file's job
|
|
12
|
+
* (autostart-windows.ts); reading back what Task Scheduler stored is
|
|
13
|
+
* autostart-windows-task-xml.ts. Renderer and drift check share this module
|
|
14
|
+
* because the check compares against the renderer's own output.
|
|
15
|
+
*/
|
|
16
|
+
const WINDOWS_AUTOSTART_SCRIPT_NAME = "autostart-sync.ps1";
|
|
17
|
+
const WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME = "autostart-register.ps1";
|
|
18
|
+
/**
|
|
19
|
+
* Windowless launcher for the scheduled sync (BLI-2677). Task Scheduler ran
|
|
20
|
+
* powershell.exe directly under an interactive token, and PowerShell is a
|
|
21
|
+
* console-subsystem binary, so every 15-minute tick flashed a conhost window
|
|
22
|
+
* over whatever the operator was doing — on every Windows machine in the
|
|
23
|
+
* fleet. wscript.exe is a GUI-subsystem host, so it never allocates a console,
|
|
24
|
+
* and it starts the PowerShell child with window style 0 (hidden). S4U was
|
|
25
|
+
* considered and rejected: it breaks on Microsoft-account logons and the task
|
|
26
|
+
* deliberately stays on InteractiveToken with least privilege.
|
|
27
|
+
*/
|
|
28
|
+
const WINDOWS_AUTOSTART_LAUNCHER_NAME = "autostart-sync.vbs";
|
|
29
|
+
const WINDOWS_SYNC_LOG_NAME = "sync.log";
|
|
30
|
+
/** With the window hidden, the log is the only place output goes; cap it so an
|
|
31
|
+
* always-on machine syncing every 15 minutes cannot grow it without bound. */
|
|
32
|
+
const WINDOWS_SYNC_LOG_LIMIT_BYTES = 10 * 1024 * 1024;
|
|
33
|
+
const UTF8_BOM = "\uFEFF";
|
|
34
|
+
/** Where each written file lives, so the flow can name them in one breath. */
|
|
35
|
+
export function windowsAutostartScriptPath(homeDir) {
|
|
36
|
+
return windowsStateFile(homeDir, WINDOWS_AUTOSTART_SCRIPT_NAME);
|
|
37
|
+
}
|
|
38
|
+
export function windowsAutostartRegistrationScriptPath(homeDir) {
|
|
39
|
+
return windowsStateFile(homeDir, WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME);
|
|
40
|
+
}
|
|
41
|
+
export function windowsAutostartLauncherPath(homeDir) {
|
|
42
|
+
return windowsStateFile(homeDir, WINDOWS_AUTOSTART_LAUNCHER_NAME);
|
|
43
|
+
}
|
|
44
|
+
function windowsSyncLogPath(homeDir) {
|
|
45
|
+
return windowsStateFile(homeDir, WINDOWS_SYNC_LOG_NAME);
|
|
46
|
+
}
|
|
47
|
+
function windowsStateFile(homeDir, fileName) {
|
|
48
|
+
return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, fileName);
|
|
49
|
+
}
|
|
50
|
+
/** Windows PowerShell 5.1, always by absolute path: the scheduled task runs
|
|
51
|
+
* with a bare environment and nothing here may depend on PATH. */
|
|
52
|
+
export function windowsPowerShellPath(env = process.env) {
|
|
53
|
+
const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
|
|
54
|
+
return path.win32.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
55
|
+
}
|
|
56
|
+
/** The windowless script host the task's action must name (BLI-2677). */
|
|
57
|
+
export function windowsWScriptPath(env = process.env) {
|
|
58
|
+
const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
|
|
59
|
+
return path.win32.join(windowsRoot, "System32", "wscript.exe");
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Writes the sync script, its windowless launcher, and the registration script
|
|
63
|
+
* Task Scheduler is driven by — the whole on-disk half of a Windows install,
|
|
64
|
+
* before anything is registered.
|
|
65
|
+
*/
|
|
66
|
+
export async function writeWindowsAutostartScripts(options) {
|
|
67
|
+
const scriptPath = windowsAutostartScriptPath(options.homeDir);
|
|
68
|
+
const launcherPath = windowsAutostartLauncherPath(options.homeDir);
|
|
69
|
+
const registrationPath = windowsAutostartRegistrationScriptPath(options.homeDir);
|
|
70
|
+
await mkdir(path.dirname(scriptPath), { recursive: true });
|
|
71
|
+
await writeFile(scriptPath, `${UTF8_BOM}${renderWindowsSyncScript({
|
|
72
|
+
discoveryArgs: await savedDiscoveryLimitArgs(options.homeDir),
|
|
73
|
+
workDirs: options.workDirs,
|
|
74
|
+
dashboardUrl: options.dashboardUrl,
|
|
75
|
+
nodeExecutable: options.nodeExecutable,
|
|
76
|
+
cliEntryPoint: options.cliEntryPoint,
|
|
77
|
+
syncLogPath: windowsSyncLogPath(options.homeDir),
|
|
78
|
+
})}`, "utf8");
|
|
79
|
+
// No BOM on the launcher: wscript.exe reads .vbs files as ANSI and treats a
|
|
80
|
+
// UTF-8 BOM as an invalid character. The rendered content is ASCII-safe for
|
|
81
|
+
// every path this fleet has.
|
|
82
|
+
await writeFile(launcherPath, renderWindowsLauncherScript({
|
|
83
|
+
powershellPath: windowsPowerShellPath(),
|
|
84
|
+
scriptPath,
|
|
85
|
+
}), "utf8");
|
|
86
|
+
await writeFile(registrationPath, `${UTF8_BOM}${renderWindowsRegistrationScript({
|
|
87
|
+
taskName: WINDOWS_AUTOSTART_TASK_NAME,
|
|
88
|
+
launcherPath,
|
|
89
|
+
intervalMinutes: options.intervalMinutes,
|
|
90
|
+
})}`, "utf8");
|
|
91
|
+
return {
|
|
92
|
+
script_path: scriptPath,
|
|
93
|
+
launcher_path: launcherPath,
|
|
94
|
+
registration_path: registrationPath,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Removes all three written files; used by uninstall, where a missing file is
|
|
98
|
+
* the desired end state either way. */
|
|
99
|
+
export async function removeWindowsAutostartScripts(homeDir) {
|
|
100
|
+
await rm(windowsAutostartScriptPath(homeDir), { force: true });
|
|
101
|
+
await rm(windowsAutostartRegistrationScriptPath(homeDir), { force: true });
|
|
102
|
+
await rm(windowsAutostartLauncherPath(homeDir), { force: true });
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* What the sync script says it will run, parsed out of the file itself
|
|
106
|
+
* (BLI-3553) rather than rebuilt from `process.execPath`.
|
|
107
|
+
*/
|
|
108
|
+
export async function readRegisteredWindowsRuntimePaths(homeDir) {
|
|
109
|
+
const script = await readFile(windowsAutostartScriptPath(homeDir), "utf8").catch(() => "");
|
|
110
|
+
return {
|
|
111
|
+
node_executable: /\$nodeExecutable = '([^']*)'/u.exec(script)?.[1] ?? null,
|
|
112
|
+
cli_entry_point: /\$cliEntryPoint = '([^']*)'/u.exec(script)?.[1] ?? null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Named problem with the sync script on disk, or null.
|
|
117
|
+
*
|
|
118
|
+
* Existence always; content only when the caller supplied roots (a bare
|
|
119
|
+
* `cockpit autostart status` stays permissive by design — see BLI-2362).
|
|
120
|
+
*/
|
|
121
|
+
export async function syncScriptProblem(options) {
|
|
122
|
+
const scriptPath = windowsAutostartScriptPath(options.homeDir);
|
|
123
|
+
if (!(await fileExists(scriptPath)))
|
|
124
|
+
return "sync script is missing";
|
|
125
|
+
if (!options.repoRoots || options.repoRoots.length === 0)
|
|
126
|
+
return null;
|
|
127
|
+
const settings = resolveAutostartSettings(options, "win32");
|
|
128
|
+
const expected = `${UTF8_BOM}${renderWindowsSyncScript({
|
|
129
|
+
discoveryArgs: await savedDiscoveryLimitArgs(options.homeDir),
|
|
130
|
+
workDirs: settings.work_dirs,
|
|
131
|
+
dashboardUrl: settings.dashboard_url,
|
|
132
|
+
nodeExecutable: (await schedulerNodeExecutable(options, "win32")).path,
|
|
133
|
+
cliEntryPoint: settings.cli_entry_point,
|
|
134
|
+
syncLogPath: windowsSyncLogPath(options.homeDir),
|
|
135
|
+
})}`;
|
|
136
|
+
const difference = renderedFileDifference(await readFile(scriptPath, "utf8").catch(() => null), expected);
|
|
137
|
+
return difference
|
|
138
|
+
? `sync script does not match the current roots or Tower runtime (${describeDifference(difference)})`
|
|
139
|
+
: null;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Named problem with the .vbs launcher on disk, or null.
|
|
143
|
+
*
|
|
144
|
+
* The launcher's content depends on nothing but the PowerShell path and the
|
|
145
|
+
* sync-script path, but the roots gate is kept identical to the sync script's
|
|
146
|
+
* so the two commands' disagreement surface stays the same.
|
|
147
|
+
*/
|
|
148
|
+
export async function syncLauncherProblem(options) {
|
|
149
|
+
const launcherPath = windowsAutostartLauncherPath(options.homeDir);
|
|
150
|
+
if (!(await fileExists(launcherPath)))
|
|
151
|
+
return "sync launcher is missing";
|
|
152
|
+
if (!options.repoRoots || options.repoRoots.length === 0)
|
|
153
|
+
return null;
|
|
154
|
+
const expected = renderWindowsLauncherScript({
|
|
155
|
+
powershellPath: windowsPowerShellPath(),
|
|
156
|
+
scriptPath: windowsAutostartScriptPath(options.homeDir),
|
|
157
|
+
});
|
|
158
|
+
const difference = renderedFileDifference(await readFile(launcherPath, "utf8").catch(() => null), expected);
|
|
159
|
+
return difference
|
|
160
|
+
? `sync launcher does not match the current Tower runtime (${describeDifference(difference)})`
|
|
161
|
+
: null;
|
|
162
|
+
}
|
|
163
|
+
function renderedFileDifference(current, expected) {
|
|
164
|
+
const expectedLines = normalizedScriptLines(expected);
|
|
165
|
+
if (current === null) {
|
|
166
|
+
return {
|
|
167
|
+
reason: "file_unreadable",
|
|
168
|
+
first_differing_line: null,
|
|
169
|
+
current_line_count: null,
|
|
170
|
+
expected_line_count: expectedLines.length,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (hasByteOrderMark(current) !== hasByteOrderMark(expected)) {
|
|
174
|
+
return {
|
|
175
|
+
reason: "byte_order_mark",
|
|
176
|
+
first_differing_line: 0,
|
|
177
|
+
current_line_count: normalizedScriptLines(current).length,
|
|
178
|
+
expected_line_count: expectedLines.length,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const currentLines = normalizedScriptLines(current);
|
|
182
|
+
const shared = Math.min(currentLines.length, expectedLines.length);
|
|
183
|
+
for (let index = 0; index < shared; index += 1) {
|
|
184
|
+
if (currentLines[index] !== expectedLines[index]) {
|
|
185
|
+
return {
|
|
186
|
+
reason: "line_content",
|
|
187
|
+
first_differing_line: index,
|
|
188
|
+
current_line_count: currentLines.length,
|
|
189
|
+
expected_line_count: expectedLines.length,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (currentLines.length !== expectedLines.length) {
|
|
194
|
+
return {
|
|
195
|
+
reason: "line_count",
|
|
196
|
+
first_differing_line: shared,
|
|
197
|
+
current_line_count: currentLines.length,
|
|
198
|
+
expected_line_count: expectedLines.length,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
/** Safe to print: a reason label, a line index, two counts. No content. */
|
|
204
|
+
function describeDifference(difference) {
|
|
205
|
+
if (difference.reason === "file_unreadable")
|
|
206
|
+
return "file could not be read";
|
|
207
|
+
if (difference.reason === "byte_order_mark")
|
|
208
|
+
return "byte-order mark differs";
|
|
209
|
+
return `${difference.reason} at line ${difference.first_differing_line}, ${difference.current_line_count} lines on disk vs ${difference.expected_line_count} expected`;
|
|
210
|
+
}
|
|
211
|
+
function normalizedScriptLines(value) {
|
|
212
|
+
const withoutBom = hasByteOrderMark(value) ? value.slice(UTF8_BOM.length) : value;
|
|
213
|
+
return withoutBom
|
|
214
|
+
.replace(/\r\n/gu, "\n")
|
|
215
|
+
.replace(/\s+$/u, "")
|
|
216
|
+
.split("\n");
|
|
217
|
+
}
|
|
218
|
+
function hasByteOrderMark(value) {
|
|
219
|
+
return value.startsWith(UTF8_BOM);
|
|
220
|
+
}
|
|
221
|
+
function renderWindowsRegistrationScript(options) {
|
|
222
|
+
return [
|
|
223
|
+
"$ErrorActionPreference = 'Stop'",
|
|
224
|
+
`$taskName = ${powershellLiteral(options.taskName)}`,
|
|
225
|
+
`$launcherPath = ${powershellLiteral(options.launcherPath)}`,
|
|
226
|
+
// wscript.exe is the windowless script host: the task runs the .vbs
|
|
227
|
+
// launcher, which starts PowerShell with its window hidden, so the
|
|
228
|
+
// 15-minute sync never flashes a terminal (BLI-2677). //B is batch mode —
|
|
229
|
+
// a script error becomes a nonzero exit instead of a modal dialog that
|
|
230
|
+
// would hang the scheduled run.
|
|
231
|
+
"$wscriptPath = Join-Path (Join-Path $env:SystemRoot 'System32') 'wscript.exe'",
|
|
232
|
+
// Windows PowerShell 5.1 passes a native argument's embedded quotes to
|
|
233
|
+
// CommandLineToArgvW unescaped, so a /TR value with bare quotes falls out
|
|
234
|
+
// of the quoted region at the first space inside a quoted path:
|
|
235
|
+
// `C:\Users\Brandon Chiem\...` reached schtasks as a stray `Chiem\...`
|
|
236
|
+
// argument and /Create exited 0x80004005 on every CLI version (BLI-2598).
|
|
237
|
+
// Escaping the embedded quotes as \" keeps the whole /TR value one
|
|
238
|
+
// argument; Task Scheduler still normalizes what it stores (BLI-2541),
|
|
239
|
+
// which the read-back validator already compares path-wise.
|
|
240
|
+
"$actionArgs = '//B \\\"' + $launcherPath + '\\\"'",
|
|
241
|
+
"$taskCommand = '\\\"' + $wscriptPath + '\\\" ' + $actionArgs",
|
|
242
|
+
`& schtasks.exe /Create /TN $taskName /TR $taskCommand /SC MINUTE /MO ${options.intervalMinutes} /IT /RL LIMITED /F | Out-Null`,
|
|
243
|
+
"if ($LASTEXITCODE -ne 0) { throw \"schtasks /Create exited $LASTEXITCODE\" }",
|
|
244
|
+
"$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew",
|
|
245
|
+
"Set-ScheduledTask -TaskName $taskName -Settings $settings | Out-Null",
|
|
246
|
+
"",
|
|
247
|
+
].join("\r\n");
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The .vbs launcher the scheduled task actually runs. WshShell.Run with window
|
|
251
|
+
* style 0 starts PowerShell with its console hidden — no flash, no focus
|
|
252
|
+
* steal — and waiting on the child propagates the sync exit code back to Task
|
|
253
|
+
* Scheduler as LastTaskResult. VBScript escapes a quote inside a string by
|
|
254
|
+
* doubling it; Windows paths cannot contain quotes, so the doubled quotes here
|
|
255
|
+
* are only the fixed delimiters around the two paths.
|
|
256
|
+
*/
|
|
257
|
+
function renderWindowsLauncherScript(options) {
|
|
258
|
+
const quotedPowershell = `""${options.powershellPath}""`;
|
|
259
|
+
const quotedScript = `""${options.scriptPath}""`;
|
|
260
|
+
return [
|
|
261
|
+
"' BLI Cockpit sync launcher (BLI-2677). wscript.exe never allocates a",
|
|
262
|
+
"' console and starts PowerShell hidden, so the 15-minute background sync",
|
|
263
|
+
"' does not flash a terminal over the operator. Sync output lands in",
|
|
264
|
+
"' sync.log next to this file instead of a visible window.",
|
|
265
|
+
"Option Explicit",
|
|
266
|
+
"Dim windowsShell, syncExitCode",
|
|
267
|
+
'Set windowsShell = CreateObject("WScript.Shell")',
|
|
268
|
+
`syncExitCode = windowsShell.Run("${quotedPowershell} -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${quotedScript}", 0, True)`,
|
|
269
|
+
"WScript.Quit syncExitCode",
|
|
270
|
+
"",
|
|
271
|
+
].join("\r\n");
|
|
272
|
+
}
|
|
273
|
+
function renderWindowsSyncScript(options) {
|
|
274
|
+
const dashboardArgs = options.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
275
|
+
? ""
|
|
276
|
+
: ` --dashboard-url ${powershellLiteral(options.dashboardUrl)}`;
|
|
277
|
+
const discoveryArgs = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
|
|
278
|
+
const commands = options.workDirs.flatMap((root) => [
|
|
279
|
+
"try {",
|
|
280
|
+
` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs}${discoveryArgs} --json 2>&1 | ForEach-Object { "$_" } | Add-Content -LiteralPath $syncLogPath -Encoding UTF8`,
|
|
281
|
+
" if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE }",
|
|
282
|
+
"} catch {",
|
|
283
|
+
" Add-Content -LiteralPath $syncLogPath -Encoding UTF8 -Value $_.Exception.Message",
|
|
284
|
+
" $exitCode = 1",
|
|
285
|
+
"}",
|
|
286
|
+
]);
|
|
287
|
+
return [
|
|
288
|
+
// 'Continue', not 'Stop': the collector logs to stderr on success as well
|
|
289
|
+
// as failure (that is the repo's logging contract), and Windows PowerShell
|
|
290
|
+
// 5.1 under 'Stop' turns the first redirected stderr line of a native
|
|
291
|
+
// command into a terminating NativeCommandError — 'Stop' would kill every
|
|
292
|
+
// healthy run at its first log line. Failures are tracked by exit code.
|
|
293
|
+
"$ErrorActionPreference = 'Continue'",
|
|
294
|
+
"$exitCode = 0",
|
|
295
|
+
`$nodeExecutable = ${powershellLiteral(options.nodeExecutable)}`,
|
|
296
|
+
`$cliEntryPoint = ${powershellLiteral(options.cliEntryPoint)}`,
|
|
297
|
+
// The task window is hidden (BLI-2677), so this log is the only place the
|
|
298
|
+
// sync's output exists; sync.err.log stays macOS-only because launchd does
|
|
299
|
+
// stream separation for free and PowerShell 5.1 does not.
|
|
300
|
+
`$syncLogPath = ${powershellLiteral(options.syncLogPath)}`,
|
|
301
|
+
`$syncLogFile = Get-Item -LiteralPath $syncLogPath -ErrorAction SilentlyContinue`,
|
|
302
|
+
`if ($syncLogFile -and $syncLogFile.Length -gt ${WINDOWS_SYNC_LOG_LIMIT_BYTES}) { Set-Content -LiteralPath $syncLogPath -Value '' -Encoding UTF8 }`,
|
|
303
|
+
...commands,
|
|
304
|
+
"exit $exitCode",
|
|
305
|
+
"",
|
|
306
|
+
].join("\r\n");
|
|
307
|
+
}
|
|
308
|
+
function powershellLiteral(value) {
|
|
309
|
+
return `'${value.replace(/'/gu, "''")}'`;
|
|
310
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { decodeXmlEntities } from "./autostart-xml.js";
|
|
3
|
+
/**
|
|
4
|
+
* The operator-facing problem list for a stored task definition, in the order
|
|
5
|
+
* it is read aloud in `Windows task needs repair: …`. Empty means healthy.
|
|
6
|
+
*/
|
|
7
|
+
export function windowsTaskRegistrationProblems(taskXml, expected) {
|
|
8
|
+
const definition = parseWindowsTaskDefinition(taskXml);
|
|
9
|
+
return [
|
|
10
|
+
...taskSettingProblems(definition),
|
|
11
|
+
...principalProblems(definition),
|
|
12
|
+
...actionContextProblems(definition),
|
|
13
|
+
...triggerProblems(definition, windowsTaskInterval(expected.intervalMinutes)),
|
|
14
|
+
...actionProblems(definition, expected),
|
|
15
|
+
// Last on purpose: this is the order operators have read these problems in
|
|
16
|
+
// since the check was written, and the message is a joined list.
|
|
17
|
+
...(/<Duration>/iu.test(definition.repetition)
|
|
18
|
+
? ["repetition has a finite duration"]
|
|
19
|
+
: []),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
function parseWindowsTaskDefinition(taskXml) {
|
|
23
|
+
const decodedXml = decodeXmlEntities(taskXml);
|
|
24
|
+
const principal = soleBlock(sectionBody(decodedXml, "Principals"), "Principal");
|
|
25
|
+
const trigger = soleBlock(sectionBody(decodedXml, "Triggers"), "TimeTrigger");
|
|
26
|
+
const repetitionBlocks = trigger ? allBlocks(trigger, "Repetition") : [];
|
|
27
|
+
const execAction = soleBlock(sectionBody(decodedXml, "Actions"), "Exec");
|
|
28
|
+
return {
|
|
29
|
+
decodedXml,
|
|
30
|
+
principal,
|
|
31
|
+
principalId: principal.match(/<Principal\s[^>]*\bid\s*=\s*["']([^"']+)["'][^>]*>/iu)?.[1] ?? null,
|
|
32
|
+
trigger,
|
|
33
|
+
repetition: repetitionBlocks.length === 1 ? repetitionBlocks[0] ?? "" : "",
|
|
34
|
+
repetitionCount: repetitionBlocks.length,
|
|
35
|
+
actionContext: decodedXml.match(/<Actions\s[^>]*\bContext\s*=\s*["']([^"']+)["'][^>]*>/iu)?.[1] ?? null,
|
|
36
|
+
execAction,
|
|
37
|
+
// Strip a wrapping quote pair defensively: every real capture we have shows
|
|
38
|
+
// Task Scheduler storing <Command> unquoted, but the writer builds the /TR
|
|
39
|
+
// executable segment with the same \"-escaped quoting it uses for the
|
|
40
|
+
// launcher argument (BLI-2598), and there is no verified capture proving
|
|
41
|
+
// schtasks' own TR-splitting heuristic always discards those quotes rather
|
|
42
|
+
// than leaving them as literal text (BLI-2996 canary-gated gap). A stray
|
|
43
|
+
// pair of quotes should never be the reason a correctly-registered task
|
|
44
|
+
// reads as broken.
|
|
45
|
+
command: stripSurroundingQuotes(execAction?.match(/<Command>\s*([^<]*?)\s*<\/Command>/iu)?.[1]?.trim() ?? ""),
|
|
46
|
+
actionArguments: execAction.match(/<Arguments>\s*([^<]*?)\s*<\/Arguments>/iu)?.[1]?.trim() ??
|
|
47
|
+
"",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** The inside of `<Tag …>…</Tag>`, or "" when the section is absent. */
|
|
51
|
+
function sectionBody(xml, tag) {
|
|
52
|
+
return (new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, "iu").exec(xml)?.[1] ?? "");
|
|
53
|
+
}
|
|
54
|
+
function allBlocks(body, tag) {
|
|
55
|
+
return [...body.matchAll(blockPattern(tag))].map((match) => match[0]);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The one `<Tag>…</Tag>` block in this section, or "" when there are zero, more
|
|
59
|
+
* than one, or anything else alongside it.
|
|
60
|
+
*/
|
|
61
|
+
function soleBlock(body, tag) {
|
|
62
|
+
const blocks = allBlocks(body, tag);
|
|
63
|
+
const remainder = body.replace(blockPattern(tag), "").trim();
|
|
64
|
+
return blocks.length === 1 && remainder === "" ? (blocks[0] ?? "") : "";
|
|
65
|
+
}
|
|
66
|
+
function blockPattern(tag) {
|
|
67
|
+
return new RegExp(`<${tag}(?:\\s[^>]*)?>[\\s\\S]*?</${tag}>`, "giu");
|
|
68
|
+
}
|
|
69
|
+
/** The battery, overlap, and missed-run settings the fleet depends on. */
|
|
70
|
+
function taskSettingProblems(definition) {
|
|
71
|
+
const requirements = [
|
|
72
|
+
[
|
|
73
|
+
/<DisallowStartIfOnBatteries>\s*false\s*<\/DisallowStartIfOnBatteries>/iu,
|
|
74
|
+
"task is blocked on battery",
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
/<StopIfGoingOnBatteries>\s*false\s*<\/StopIfGoingOnBatteries>/iu,
|
|
78
|
+
"task stops on battery",
|
|
79
|
+
],
|
|
80
|
+
[
|
|
81
|
+
/<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/iu,
|
|
82
|
+
"overlap policy is not IgnoreNew",
|
|
83
|
+
],
|
|
84
|
+
[
|
|
85
|
+
/<StartWhenAvailable>\s*true\s*<\/StartWhenAvailable>/iu,
|
|
86
|
+
"missed-run recovery is disabled",
|
|
87
|
+
],
|
|
88
|
+
];
|
|
89
|
+
return requirements
|
|
90
|
+
.filter(([pattern]) => !pattern.test(definition.decodedXml))
|
|
91
|
+
.map(([, message]) => message);
|
|
92
|
+
}
|
|
93
|
+
function principalProblems(definition) {
|
|
94
|
+
if (!definition.principal) {
|
|
95
|
+
return ["task must contain exactly one principal"];
|
|
96
|
+
}
|
|
97
|
+
const problems = [];
|
|
98
|
+
if (!/<LogonType>\s*InteractiveToken\s*<\/LogonType>/iu.test(definition.principal)) {
|
|
99
|
+
problems.push("logon type is not InteractiveToken");
|
|
100
|
+
}
|
|
101
|
+
// Windows omits <RunLevel> entirely when a task runs at the default
|
|
102
|
+
// LeastPrivilege, so `schtasks /Create ... /RL LIMITED` stores a principal
|
|
103
|
+
// block with no RunLevel element at all. Demanding the element asks Windows
|
|
104
|
+
// to state a default it never states, which is why this check failed on
|
|
105
|
+
// every Windows machine in the fleet and passed on none (0 of 4
|
|
106
|
+
// autostart-alive events ok, 2026-08-12). Absent means LeastPrivilege; only
|
|
107
|
+
// an explicit elevated value is a real problem.
|
|
108
|
+
const runLevel = definition.principal
|
|
109
|
+
.match(/<RunLevel>\s*([^<]*?)\s*<\/RunLevel>/iu)?.[1]
|
|
110
|
+
?.trim() || "LeastPrivilege";
|
|
111
|
+
if (!/^LeastPrivilege$/iu.test(runLevel)) {
|
|
112
|
+
problems.push(`task does not run with limited privileges (RunLevel=${runLevel})`);
|
|
113
|
+
}
|
|
114
|
+
return problems;
|
|
115
|
+
}
|
|
116
|
+
/** An action that runs as a different principal than the task declares. */
|
|
117
|
+
function actionContextProblems(definition) {
|
|
118
|
+
const mismatched = definition.actionContext !== null &&
|
|
119
|
+
definition.actionContext !== definition.principalId;
|
|
120
|
+
return mismatched ? ["task action context does not match its principal"] : [];
|
|
121
|
+
}
|
|
122
|
+
function triggerProblems(definition, expectedInterval) {
|
|
123
|
+
const problems = [];
|
|
124
|
+
if (!definition.trigger) {
|
|
125
|
+
problems.push("task must contain exactly one TimeTrigger");
|
|
126
|
+
}
|
|
127
|
+
if (definition.repetitionCount !== 1) {
|
|
128
|
+
problems.push("task must contain exactly one repetition schedule");
|
|
129
|
+
}
|
|
130
|
+
else if (!new RegExp(`<Interval>\\s*${escapeRegExp(expectedInterval)}\\s*</Interval>`, "iu").test(definition.repetition)) {
|
|
131
|
+
problems.push(`repetition interval is not ${expectedInterval}`);
|
|
132
|
+
}
|
|
133
|
+
if (/<EndBoundary>/iu.test(definition.trigger)) {
|
|
134
|
+
problems.push("task trigger has an end boundary");
|
|
135
|
+
}
|
|
136
|
+
const randomDelay = definition.trigger
|
|
137
|
+
.match(/<RandomDelay>\s*([^<]+?)\s*<\/RandomDelay>/iu)?.[1]
|
|
138
|
+
?.trim()
|
|
139
|
+
.toUpperCase() ?? null;
|
|
140
|
+
if (randomDelay && !["PT0S", "PT0M", "PT0H"].includes(randomDelay)) {
|
|
141
|
+
problems.push("task trigger has a nonzero random delay");
|
|
142
|
+
}
|
|
143
|
+
return problems;
|
|
144
|
+
}
|
|
145
|
+
/** Does the stored action still run our windowless launcher, and only it? */
|
|
146
|
+
function actionProblems(definition, expected) {
|
|
147
|
+
const problems = [];
|
|
148
|
+
if (!definition.execAction) {
|
|
149
|
+
problems.push("task must contain exactly one Exec action");
|
|
150
|
+
}
|
|
151
|
+
if (!sameWindowsPath(definition.command, expected.wscriptPath)) {
|
|
152
|
+
problems.push("task action does not run the expected windowless script host");
|
|
153
|
+
}
|
|
154
|
+
const argumentProblem = windowsActionArgumentProblem(definition.actionArguments, "//B", expected.launcherPath);
|
|
155
|
+
if (argumentProblem)
|
|
156
|
+
problems.push(argumentProblem);
|
|
157
|
+
return problems;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Task Scheduler stores a normalized form of the action it was given, not the
|
|
161
|
+
* string we passed: `schtasks /Create /TR "<host> <args>"` is split into
|
|
162
|
+
* <Command> plus <Arguments>, and the quotes around a space-free script path
|
|
163
|
+
* are dropped. Comparing <Arguments> against the exact string we built
|
|
164
|
+
* therefore fails on a task that is registered perfectly — observed on
|
|
165
|
+
* DESKTOP-G2UO1GK (CLI 0.2.13, 2026-08-12), where the stored value differed
|
|
166
|
+
* from the expected one only by those quotes (BLI-2541).
|
|
167
|
+
*
|
|
168
|
+
* BLI-2996: that lesson was only ever applied to the pre-BLI-2677 `-File`
|
|
169
|
+
* action. The wscript launcher pair (`//B "<launcher>"`) has NO verified
|
|
170
|
+
* real-Windows capture — `windowlessWindowsTaskXml()` in autostart.test.ts is
|
|
171
|
+
* SYNTHETIC, built by hand-editing the one real capture we have rather than
|
|
172
|
+
* exported from a machine actually running this action. A repair that rewrites
|
|
173
|
+
* the task correctly but is judged against a guessed shape can fail forever
|
|
174
|
+
* without ever being wrong (Brandon's machine: same non-convergence under two
|
|
175
|
+
* different validator messages across two CLI versions, neither of which ever
|
|
176
|
+
* repaired anything). So this comparison is deliberately structural, not
|
|
177
|
+
* positional: tokenize the way CommandLineToArgvW groups quoted/unquoted
|
|
178
|
+
* arguments, then check flag-token-then-path-token semantically (a real path
|
|
179
|
+
* comparison, case- and quote-insensitive) instead of demanding the exact
|
|
180
|
+
* byte layout we happened to send. Whatever the wscript action's real
|
|
181
|
+
* normalization turns out to be once a canary captures it, this only fails on
|
|
182
|
+
* an actual different flag or different script — not on Windows' own
|
|
183
|
+
* reformatting.
|
|
184
|
+
*/
|
|
185
|
+
function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedLauncherPath) {
|
|
186
|
+
const tokens = splitWindowsArgumentTokens(actionArguments);
|
|
187
|
+
if (tokens.length === 0) {
|
|
188
|
+
return "task action names no sync launcher to run";
|
|
189
|
+
}
|
|
190
|
+
if ((tokens[0] ?? "").toUpperCase() !== expectedFlags.toUpperCase()) {
|
|
191
|
+
return "task action does not run the launcher with the expected batch-mode flag";
|
|
192
|
+
}
|
|
193
|
+
if (tokens.length > 2) {
|
|
194
|
+
return "task action passes unexpected extra arguments to the sync launcher";
|
|
195
|
+
}
|
|
196
|
+
const launcherArgument = tokens[1];
|
|
197
|
+
if (!launcherArgument) {
|
|
198
|
+
return "task action names no sync launcher to run";
|
|
199
|
+
}
|
|
200
|
+
if (!sameWindowsPath(launcherArgument, expectedLauncherPath)) {
|
|
201
|
+
return "task action runs a script other than the Tower sync launcher";
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Quote-aware whitespace tokenizer for an already-XML-decoded `<Arguments>`
|
|
207
|
+
* value: a `"..."` run is one token (quotes stripped, whitespace inside kept),
|
|
208
|
+
* everything else splits on whitespace. Windows paths never contain a quote
|
|
209
|
+
* character, so this does not need CommandLineToArgvW's backslash-escaping
|
|
210
|
+
* rules — only its quoting rule — to tell "one argument with a space in it"
|
|
211
|
+
* apart from "two arguments".
|
|
212
|
+
*/
|
|
213
|
+
function splitWindowsArgumentTokens(value) {
|
|
214
|
+
const tokens = [];
|
|
215
|
+
let current = "";
|
|
216
|
+
let inQuotes = false;
|
|
217
|
+
let hasToken = false;
|
|
218
|
+
for (const char of value) {
|
|
219
|
+
if (char === '"') {
|
|
220
|
+
inQuotes = !inQuotes;
|
|
221
|
+
hasToken = true;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (!inQuotes && /\s/u.test(char)) {
|
|
225
|
+
if (hasToken) {
|
|
226
|
+
tokens.push(current);
|
|
227
|
+
current = "";
|
|
228
|
+
hasToken = false;
|
|
229
|
+
}
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
current += char;
|
|
233
|
+
hasToken = true;
|
|
234
|
+
}
|
|
235
|
+
if (hasToken)
|
|
236
|
+
tokens.push(current);
|
|
237
|
+
return tokens;
|
|
238
|
+
}
|
|
239
|
+
/** Removes one wrapping pair of double quotes, if present. Used only to
|
|
240
|
+
* normalize before a path comparison — never to reconstruct a shell-safe
|
|
241
|
+
* value. */
|
|
242
|
+
function stripSurroundingQuotes(value) {
|
|
243
|
+
const match = value.match(/^"([\s\S]*)"$/u);
|
|
244
|
+
return match ? match[1] : value;
|
|
245
|
+
}
|
|
246
|
+
function sameWindowsPath(left, right) {
|
|
247
|
+
if (!left || !right)
|
|
248
|
+
return false;
|
|
249
|
+
return path.win32.resolve(left).toLowerCase() ===
|
|
250
|
+
path.win32.resolve(right).toLowerCase();
|
|
251
|
+
}
|
|
252
|
+
/** The ISO-8601 duration Task Scheduler stores for a minutes-based cadence. */
|
|
253
|
+
function windowsTaskInterval(minutes) {
|
|
254
|
+
const hours = Math.floor(minutes / 60);
|
|
255
|
+
const remainingMinutes = minutes % 60;
|
|
256
|
+
return `PT${hours > 0 ? `${hours}H` : ""}${remainingMinutes > 0 ? `${remainingMinutes}M` : ""}`;
|
|
257
|
+
}
|
|
258
|
+
function escapeRegExp(value) {
|
|
259
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
260
|
+
}
|