@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.
Files changed (42) hide show
  1. package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
  2. package/dist/adapters/raw-evidence.js +360 -349
  3. package/dist/autostart-contract.js +79 -0
  4. package/dist/autostart-darwin-plist.js +265 -0
  5. package/dist/autostart-darwin.js +171 -0
  6. package/dist/autostart-windows-scripts.js +310 -0
  7. package/dist/autostart-windows-task-xml.js +260 -0
  8. package/dist/autostart-windows.js +237 -0
  9. package/dist/autostart-xml.js +23 -0
  10. package/dist/autostart.js +35 -1148
  11. package/dist/commands/agent-rules-command.js +55 -0
  12. package/dist/commands/agent-session-report.js +290 -0
  13. package/dist/commands/analyze.js +131 -0
  14. package/dist/commands/autostart-command.js +105 -0
  15. package/dist/commands/backfill.js +824 -551
  16. package/dist/commands/cli-io.js +13 -0
  17. package/dist/commands/heartbeat.js +18 -0
  18. package/dist/commands/install-receipts.js +34 -0
  19. package/dist/commands/jarvis.js +179 -3
  20. package/dist/commands/local-arg-values.js +169 -0
  21. package/dist/commands/local-args-collector.js +578 -0
  22. package/dist/commands/local-args-tower.js +870 -0
  23. package/dist/commands/local-args.js +8 -1549
  24. package/dist/commands/local-help.js +11 -3
  25. package/dist/commands/local.js +18 -1786
  26. package/dist/commands/login.js +53 -0
  27. package/dist/commands/logout.js +66 -0
  28. package/dist/commands/onboard-receipts.js +66 -0
  29. package/dist/commands/onboard-report.js +274 -0
  30. package/dist/commands/onboard.js +449 -0
  31. package/dist/commands/ops-render.js +36 -0
  32. package/dist/commands/public-root.js +1 -1
  33. package/dist/commands/serve.js +13 -0
  34. package/dist/commands/session-sync.js +513 -534
  35. package/dist/commands/settings-render.js +28 -0
  36. package/dist/commands/settings.js +66 -2
  37. package/dist/commands/start.js +47 -0
  38. package/dist/commands/sync-followups.js +203 -0
  39. package/dist/commands/sync.js +381 -0
  40. package/dist/dev-build.js +186 -0
  41. package/dist/tower-stream.js +20 -4
  42. 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
+ }