@bli-cockpit/cli 0.2.48 → 0.2.50

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 (83) hide show
  1. package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
  2. package/dist/adapters/raw-evidence-claude-reader.js +108 -0
  3. package/dist/adapters/raw-evidence-codex-reader.js +147 -0
  4. package/dist/adapters/raw-evidence-collection-state.js +199 -0
  5. package/dist/adapters/raw-evidence-facts.js +338 -0
  6. package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
  7. package/dist/adapters/raw-evidence-image-reader.js +107 -0
  8. package/dist/adapters/raw-evidence-sanitize.js +56 -0
  9. package/dist/adapters/raw-evidence-transcript-file.js +182 -0
  10. package/dist/adapters/raw-evidence.js +94 -1203
  11. package/dist/autostart-contract.js +79 -0
  12. package/dist/autostart-darwin-plist.js +265 -0
  13. package/dist/autostart-darwin.js +171 -0
  14. package/dist/autostart-windows-scripts.js +310 -0
  15. package/dist/autostart-windows-task-xml.js +260 -0
  16. package/dist/autostart-windows.js +237 -0
  17. package/dist/autostart-xml.js +23 -0
  18. package/dist/autostart.js +35 -1148
  19. package/dist/commands/agent-rules-command.js +55 -0
  20. package/dist/commands/agent-session-report.js +290 -0
  21. package/dist/commands/analyze.js +131 -0
  22. package/dist/commands/autostart-command.js +105 -0
  23. package/dist/commands/backfill-batches.js +34 -0
  24. package/dist/commands/backfill-candidates.js +54 -0
  25. package/dist/commands/backfill-checkpoint.js +101 -0
  26. package/dist/commands/backfill-command-line.js +70 -0
  27. package/dist/commands/backfill-evidence-outcomes.js +104 -0
  28. package/dist/commands/backfill-issues.js +265 -0
  29. package/dist/commands/backfill-output.js +75 -0
  30. package/dist/commands/backfill-plan.js +71 -0
  31. package/dist/commands/backfill-reasons.js +107 -0
  32. package/dist/commands/backfill-report.js +298 -0
  33. package/dist/commands/backfill-result.js +150 -0
  34. package/dist/commands/backfill-scan.js +274 -0
  35. package/dist/commands/backfill-scope.js +114 -0
  36. package/dist/commands/backfill-session-report.js +145 -0
  37. package/dist/commands/backfill-types.js +1 -0
  38. package/dist/commands/backfill-upload.js +212 -0
  39. package/dist/commands/backfill.js +58 -1705
  40. package/dist/commands/cli-io.js +13 -0
  41. package/dist/commands/doctor.js +57 -0
  42. package/dist/commands/jarvis-trace.js +184 -0
  43. package/dist/commands/jarvis.js +323 -7
  44. package/dist/commands/local-arg-values.js +169 -0
  45. package/dist/commands/local-args-collector.js +604 -0
  46. package/dist/commands/local-args-tower.js +891 -0
  47. package/dist/commands/local-args.js +10 -1549
  48. package/dist/commands/local-help.js +30 -5
  49. package/dist/commands/local.js +21 -1786
  50. package/dist/commands/login.js +53 -0
  51. package/dist/commands/logout.js +66 -0
  52. package/dist/commands/memory-install-claude.js +294 -0
  53. package/dist/commands/memory-install-codex.js +205 -0
  54. package/dist/commands/memory-install-contract.js +231 -0
  55. package/dist/commands/memory-install-files.js +63 -0
  56. package/dist/commands/memory-install-skills.js +121 -0
  57. package/dist/commands/memory-install-toml.js +265 -0
  58. package/dist/commands/memory-install.js +378 -0
  59. package/dist/commands/onboard-receipts.js +66 -0
  60. package/dist/commands/onboard-report.js +274 -0
  61. package/dist/commands/onboard.js +449 -0
  62. package/dist/commands/ops-render.js +36 -0
  63. package/dist/commands/public-root.js +1 -1
  64. package/dist/commands/serve.js +13 -0
  65. package/dist/commands/session-sync.js +513 -534
  66. package/dist/commands/settings-render.js +28 -0
  67. package/dist/commands/settings.js +66 -2
  68. package/dist/commands/start.js +47 -0
  69. package/dist/commands/sync-followups.js +308 -0
  70. package/dist/commands/sync.js +387 -0
  71. package/dist/local-state-attributed-target.js +75 -0
  72. package/dist/local-state-config.js +147 -0
  73. package/dist/local-state-files.js +59 -0
  74. package/dist/local-state-identity.js +73 -0
  75. package/dist/local-state-pairing.js +263 -0
  76. package/dist/local-state-paths.js +61 -0
  77. package/dist/local-state-session.js +68 -0
  78. package/dist/local-state-status.js +163 -0
  79. package/dist/local-state-work-context.js +190 -0
  80. package/dist/local-state.js +34 -848
  81. package/dist/tower-client.js +3 -2
  82. package/dist/tower-stream.js +76 -6
  83. package/package.json +2 -1
package/dist/autostart.js CHANGED
@@ -1,65 +1,32 @@
1
- import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
1
  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 { redactedHealthDetail } from "./health-detail.js";
7
- import { DARWIN_LOGIN_SHELL, resolveStableNodeExecutable, } from "./autostart-node-path.js";
8
- /** launchd LaunchAgent label; matches docs/runbooks/cockpit-launchd-sync.md. */
9
- export const AUTOSTART_LABEL = "com.bli.cockpit.sync";
10
- export const WINDOWS_AUTOSTART_TASK_NAME = "BLI Cockpit Sync";
11
- const WINDOWS_AUTOSTART_SCRIPT_NAME = "autostart-sync.ps1";
12
- const WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME = "autostart-register.ps1";
13
- /**
14
- * Windowless launcher for the scheduled sync (BLI-2677). Task Scheduler ran
15
- * powershell.exe directly under an interactive token, and PowerShell is a
16
- * console-subsystem binary, so every 15-minute tick flashed a conhost window
17
- * over whatever the operator was doing \u2014 on every Windows machine in the
18
- * fleet. wscript.exe is a GUI-subsystem host, so it never allocates a console,
19
- * and it starts the PowerShell child with window style 0 (hidden). S4U was
20
- * considered and rejected: it breaks on Microsoft-account logons and the task
21
- * deliberately stays on InteractiveToken with least privilege.
22
- */
23
- const WINDOWS_AUTOSTART_LAUNCHER_NAME = "autostart-sync.vbs";
24
- const WINDOWS_SYNC_LOG_NAME = "sync.log";
25
- /** With the window hidden, the log is the only place output goes; cap it so an
26
- * always-on machine syncing every 15 minutes cannot grow it without bound. */
27
- const WINDOWS_SYNC_LOG_LIMIT_BYTES = 10 * 1024 * 1024;
28
- const UTF8_BOM = "\uFEFF";
29
- export const DEFAULT_AUTOSTART_INTERVAL_SECONDS = 15 * 60;
30
- /**
31
- * macOS rewrites this file on every network transition (join, leave, DNS
32
- * change), so watching it retries spooled uploads the moment connectivity
33
- * returns instead of waiting out the StartInterval \u2014 the lid-closed-mid-upload
34
- * machine hopping caf\u00E9s is where most historical upload failures came from
35
- * (BLI-2604). The real file, not the /etc/resolv.conf symlink: launchd watches
36
- * the path it is given, and the symlink itself never changes. Burst fires are
37
- * cheap \u2014 the collection lock turns overlap into a named no-op.
38
- */
39
- export const DARWIN_NETWORK_CHANGE_SIGNAL = "/private/var/run/resolv.conf";
40
- const UNSUPPORTED_MESSAGE = "autostart is supported on macOS and Windows only";
41
- /**
42
- * What the tick says to sync.err.log when neither the registered node binary
43
- * nor a login-shell node exists. Named, actionable, path-free (BLI-3553).
2
+ import { fileExists, unsupportedResult, } from "./autostart-contract.js";
3
+ import { darwinAgentStatus, installDarwinAgent, uninstallDarwinAgent, } from "./autostart-darwin.js";
4
+ import { readRegisteredDarwinRuntimePaths } from "./autostart-darwin-plist.js";
5
+ import { installWindowsTask, uninstallWindowsTask, windowsTaskStatus, } from "./autostart-windows.js";
6
+ import { readRegisteredWindowsRuntimePaths } from "./autostart-windows-scripts.js";
7
+ /**
8
+ * Background collection's front door: install, uninstall, and status for the
9
+ * OS scheduler, whichever OS this is.
10
+ *
11
+ * Everything here picks a host family and delegates. The two families each own
12
+ * a render → write → register → validate stack of their own:
13
+ *
14
+ * - macOS: autostart-darwin.ts (launchctl) over autostart-darwin-plist.ts.
15
+ * - Windows: autostart-windows.ts (schtasks) over autostart-windows-scripts.ts
16
+ * for what gets written and autostart-windows-task-xml.ts for what Windows
17
+ * says it stored.
18
+ *
19
+ * Shared vocabulary labels, options, results, settings resolution lives in
20
+ * autostart-contract.ts and is re-exported here because this module is the
21
+ * import site the rest of the collector already knows (BLI-3571).
44
22
  */
45
- export const DARWIN_NODE_MISSING_MESSAGE = "[autostart] tick skipped: reason=node_binary_missing - the registered node binary is gone and no node is on the login PATH; run cockpit doctor to re-register";
46
- function plistPathFor(homeDir) {
47
- return path.join(homeDir, "Library", "LaunchAgents", `${AUTOSTART_LABEL}.plist`);
48
- }
49
- function unsupportedResult(homeDir) {
50
- return {
51
- status: "unsupported",
52
- label: AUTOSTART_LABEL,
53
- plist_path: plistPathFor(homeDir ?? os.homedir()),
54
- message: UNSUPPORTED_MESSAGE,
55
- };
56
- }
23
+ export { AUTOSTART_LABEL, DEFAULT_AUTOSTART_INTERVAL_SECONDS, WINDOWS_AUTOSTART_TASK_NAME, } from "./autostart-contract.js";
24
+ export { DARWIN_NETWORK_CHANGE_SIGNAL, DARWIN_NODE_MISSING_MESSAGE, writeDarwinAutostartPlist, } from "./autostart-darwin-plist.js";
25
+ export { launchdUserDomain, reregisterDarwinAgent, } from "./autostart-darwin.js";
57
26
  /**
58
- * Installs (or refreshes) the launchd LaunchAgent that keeps `cockpit sync`
59
- * running at login and every `intervalSeconds`. Mirrors the plist in
60
- * docs/runbooks/cockpit-launchd-sync.md exactly, but writes resolved absolute
61
- * log paths (launchd does not expand `$HOME`). The unload before load makes the
62
- * install idempotent — re-running picks up a changed repo/url/interval.
27
+ * Installs (or refreshes) the scheduled `cockpit sync` this machine's OS
28
+ * supports. Idempotent on both host families: re-running picks up changed
29
+ * roots, dashboard URL, or cadence.
63
30
  */
64
31
  export async function installAutostartAgent(options) {
65
32
  const platform = options.platform ?? process.platform;
@@ -67,140 +34,11 @@ export async function installAutostartAgent(options) {
67
34
  return installWindowsTask(options);
68
35
  if (platform !== "darwin")
69
36
  return unsupportedResult(options.homeDir);
70
- const written = await writeDarwinAutostartPlist(options);
71
- const { plist_path: plistPath, work_dir: workDir, work_dirs: resolvedWorkDirs, dashboard_url: dashboardUrl, interval_seconds: intervalSeconds, watch_path_count: watchPathCount, } = written;
72
- // Unload first so a changed plist is actually picked up; a not-yet-loaded
73
- // agent makes unload fail harmlessly, so the error is ignored.
74
- await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
75
- const load = await options.exec("launchctl", ["load", plistPath]);
76
- const loaded = load.code === 0;
77
- // Both branches log, for the same reason the Windows path does: a line that
78
- // only fires on failure cannot answer "did background collection get
79
- // installed at all today?". Until BLI-3483 the macOS failure existed only in
80
- // the returned `message`, which nothing on the sync path reads — a Mac could
81
- // finish `cockpit onboard` with no scheduler and nothing in sync.err.log.
82
- // Metadata only: launchctl's stderr can carry the state directory, so the
83
- // exit code and the redacted first line travel, never the raw text.
84
- if (loaded) {
85
- console.error("[autostart] launchd agent loaded", JSON.stringify({
86
- label: AUTOSTART_LABEL,
87
- interval_seconds: intervalSeconds,
88
- watch_path_count: watchPathCount,
89
- node_path_reason: written.node_path_reason,
90
- }));
91
- }
92
- else {
93
- console.error("[autostart] launchctl load failed; this Mac has no background collection", JSON.stringify({
94
- reason: "autostart_load_failed",
95
- label: AUTOSTART_LABEL,
96
- exit_code: load.code,
97
- detail: launchctlDetail(load),
98
- }));
99
- }
100
- return {
101
- status: "installed",
102
- label: AUTOSTART_LABEL,
103
- plist_path: plistPath,
104
- loaded,
105
- interval_seconds: intervalSeconds,
106
- work_dir: workDir,
107
- work_dirs: resolvedWorkDirs,
108
- dashboard_url: dashboardUrl,
109
- ...(loaded
110
- ? {}
111
- : {
112
- message: `launchctl load exited ${load.code}: ${launchctlDetail(load)}`,
113
- }),
114
- };
115
- }
116
- /**
117
- * Renders and writes the LaunchAgent plist without touching launchd.
118
- *
119
- * Split out of `installAutostartAgent` for BLI-3553: the detached macOS healer
120
- * (autostart-self-heal.ts) has to write the SAME plist this file writes and
121
- * then re-register it with `bootout`/`bootstrap` rather than `unload`/`load`,
122
- * and two renderers would be two chances to disagree. One writer, two
123
- * registration verbs.
124
- */
125
- export async function writeDarwinAutostartPlist(options) {
126
- const homeDir = options.homeDir ?? os.homedir();
127
- const workDirs = normalizeWorkDirs(options.repoRoots);
128
- const workDir = path.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
129
- const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
130
- const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
131
- const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
132
- const node = await schedulerNodeExecutable(options, "darwin");
133
- const cliEntryPoint = path.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
134
- const paths = getCollectorRuntimePaths(homeDir);
135
- const plistPath = plistPathFor(homeDir);
136
- const stdoutPath = path.join(paths.state_dir, "sync.log");
137
- const stderrPath = path.join(paths.state_dir, "sync.err.log");
138
- // launchd will not reliably watch a path that does not exist at load time, so
139
- // only feed it the transcript dirs that are present right now. A missing dir
140
- // is fine — the StartInterval floor still covers it. The network signal is
141
- // appended unconditionally: it always exists on the Macs this plist targets,
142
- // and an existence filter would drop it when rendering on another host.
143
- const watchPaths = [
144
- ...(await existingWatchPaths(homeDir)),
145
- DARWIN_NETWORK_CHANGE_SIGNAL,
146
- ];
147
- await mkdir(path.dirname(plistPath), { recursive: true });
148
- await mkdir(paths.state_dir, { recursive: true });
149
- await writeFile(plistPath, renderPlist({
150
- discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
151
- workDir,
152
- workDirs: resolvedWorkDirs,
153
- dashboardUrl,
154
- intervalSeconds,
155
- nodeExecutable: node.path,
156
- cliEntryPoint,
157
- stdoutPath,
158
- stderrPath,
159
- watchPaths,
160
- }), "utf8");
161
- return {
162
- plist_path: plistPath,
163
- work_dir: workDir,
164
- work_dirs: resolvedWorkDirs,
165
- dashboard_url: dashboardUrl,
166
- interval_seconds: intervalSeconds,
167
- node_executable: node.path,
168
- node_path_reason: node.reason,
169
- cli_entry_point: cliEntryPoint,
170
- watch_path_count: watchPaths.length,
171
- };
37
+ return installDarwinAgent(options);
172
38
  }
173
39
  /**
174
- * The node binary the scheduler should name. BOTH the writer and the read-back
175
- * validator go through here — if only the writer pinned the stable alias, every
176
- * machine would read "does not match the current Tower runtime" forever, which
177
- * is BLI-2541 with a new coat of paint.
178
- */
179
- async function schedulerNodeExecutable(options, platform) {
180
- const resolver = platform === "win32" ? path.win32.resolve : path.resolve;
181
- const given = resolver(options.nodeExecutable ?? process.execPath);
182
- return resolveStableNodeExecutable(given, { platform });
183
- }
184
- /**
185
- * What `launchctl` actually said, or an honest account of it saying nothing.
186
- *
187
- * "unknown error" was the old fallback, and it is the least useful string in
188
- * the file: `launchctl load` exits nonzero with EMPTY stderr for the ordinary
189
- * cases — the agent is already loaded, or the plist was rejected — so the
190
- * message an operator saw named neither the exit code's meaning nor the
191
- * silence itself (BLI-3483). Exit 1 with no output is a fact; say so, and say
192
- * what to do next. stdout is consulted too because launchctl is inconsistent
193
- * about which stream it uses across macOS versions.
194
- */
195
- function launchctlDetail(result) {
196
- const spoken = result.stderr.trim() || result.stdout.trim();
197
- if (spoken)
198
- return redactedHealthDetail(spoken);
199
- return `no output on either stream (exit ${result.code}); the agent may already be loaded, or launchd rejected the plist — check \`launchctl print gui/$(id -u)/${AUTOSTART_LABEL}\``;
200
- }
201
- /**
202
- * Removes the LaunchAgent. Reports `absent` when there was nothing to remove so
203
- * the command is safe to run repeatedly.
40
+ * Removes the scheduled sync. Reports `absent` when there was nothing to
41
+ * remove, so the command is safe to run repeatedly.
204
42
  */
205
43
  export async function uninstallAutostartAgent(options) {
206
44
  const platform = options.platform ?? process.platform;
@@ -208,21 +46,11 @@ export async function uninstallAutostartAgent(options) {
208
46
  return uninstallWindowsTask(options);
209
47
  if (platform !== "darwin")
210
48
  return unsupportedResult(options.homeDir);
211
- const homeDir = options.homeDir ?? os.homedir();
212
- const plistPath = plistPathFor(homeDir);
213
- if (!(await fileExists(plistPath))) {
214
- return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
215
- }
216
- // Same reasoning as the install path above: an agent that was never loaded
217
- // makes unload fail harmlessly, and the plist is removed either way, so the
218
- // error is deliberately ignored (BLI-3238).
219
- await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
220
- await rm(plistPath, { force: true });
221
- return { status: "uninstalled", label: AUTOSTART_LABEL, plist_path: plistPath };
49
+ return uninstallDarwinAgent(options);
222
50
  }
223
51
  /**
224
- * Reports whether the agent is installed, loaded, and still matches the
225
- * configured roots, cadence, dashboard URL, and Cockpit runtime.
52
+ * Reports whether the scheduled sync is installed, registered, and still
53
+ * matches the configured roots, cadence, dashboard URL, and Cockpit runtime.
226
54
  */
227
55
  export async function autostartStatus(options) {
228
56
  const platform = options.platform ?? process.platform;
@@ -230,883 +58,7 @@ export async function autostartStatus(options) {
230
58
  return windowsTaskStatus(options);
231
59
  if (platform !== "darwin")
232
60
  return unsupportedResult(options.homeDir);
233
- const homeDir = options.homeDir ?? os.homedir();
234
- const plistPath = plistPathFor(homeDir);
235
- if (!(await fileExists(plistPath))) {
236
- return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
237
- }
238
- const workDirs = normalizeWorkDirs(options.repoRoots);
239
- const workDir = path.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
240
- const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
241
- const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
242
- const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
243
- const nodeExecutable = (await schedulerNodeExecutable(options, "darwin")).path;
244
- const cliEntryPoint = path.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
245
- const plist = await readFile(plistPath, "utf8").catch(() => "");
246
- const registrationProblems = darwinAgentRegistrationProblems(plist, {
247
- discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
248
- workDirs: resolvedWorkDirs,
249
- dashboardUrl,
250
- intervalSeconds,
251
- nodeExecutable,
252
- cliEntryPoint,
253
- });
254
- const list = await options.exec("launchctl", ["list", AUTOSTART_LABEL]);
255
- if (list.code !== 0)
256
- registrationProblems.unshift("agent is not loaded");
257
- return {
258
- status: registrationProblems.length === 0 ? "loaded" : "not_loaded",
259
- label: AUTOSTART_LABEL,
260
- plist_path: plistPath,
261
- interval_seconds: intervalSeconds,
262
- work_dir: workDir,
263
- work_dirs: resolvedWorkDirs,
264
- dashboard_url: dashboardUrl,
265
- ...(registrationProblems.length > 0
266
- ? {
267
- message: `launchd agent needs repair: ${registrationProblems.join("; ")}`,
268
- }
269
- : {}),
270
- };
271
- }
272
- async function installWindowsTask(options) {
273
- const homeDir = options.homeDir ?? os.homedir();
274
- const workDirs = normalizeWindowsWorkDirs(options.repoRoots);
275
- const workDir = path.win32.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
276
- const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
277
- const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
278
- const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
279
- const intervalMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
280
- const scriptPath = path.join(getCollectorRuntimePaths(homeDir).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
281
- const registrationPath = path.join(getCollectorRuntimePaths(homeDir).state_dir, WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME);
282
- const launcherPath = windowsAutostartLauncherPath(homeDir);
283
- const node = await schedulerNodeExecutable(options, "win32");
284
- const nodeExecutable = node.path;
285
- const cliEntryPoint = path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
286
- await mkdir(path.dirname(scriptPath), { recursive: true });
287
- await writeFile(scriptPath, `${UTF8_BOM}${renderWindowsSyncScript({
288
- discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
289
- workDirs: resolvedWorkDirs,
290
- dashboardUrl,
291
- nodeExecutable,
292
- cliEntryPoint,
293
- syncLogPath: windowsSyncLogPath(homeDir),
294
- })}`, "utf8");
295
- // No BOM on the launcher: wscript.exe reads .vbs files as ANSI and treats a
296
- // UTF-8 BOM as an invalid character. The rendered content is ASCII-safe for
297
- // every path this fleet has.
298
- await writeFile(launcherPath, renderWindowsLauncherScript({
299
- powershellPath: windowsPowerShellPath(),
300
- scriptPath,
301
- }), "utf8");
302
- await writeFile(registrationPath, `${UTF8_BOM}${renderWindowsRegistrationScript({
303
- taskName: WINDOWS_AUTOSTART_TASK_NAME,
304
- launcherPath,
305
- intervalMinutes,
306
- })}`, "utf8");
307
- const created = await options.exec("powershell.exe", [
308
- "-NoProfile",
309
- "-NonInteractive",
310
- "-ExecutionPolicy",
311
- "Bypass",
312
- "-File",
313
- registrationPath,
314
- ]);
315
- // BLI-2996: before this, a nonzero exit here (schtasks rejected the
316
- // registration script, or Set-ScheduledTask threw) skipped straight to the
317
- // return with `verified: null` and never logged anything — the doctor row
318
- // said "needs repair" every run with no way to tell "the rewrite itself
319
- // never ran" apart from "it ran and Windows still disagrees" (BLI-2996,
320
- // Brandon's machine: same silent non-convergence under two different
321
- // validator messages across CLI versions). Metadata only: exit code and
322
- // whether the process produced any output, never the stderr/stdout text
323
- // (it can carry the state directory or a workspace path).
324
- if (created.code !== 0) {
325
- console.error("[autostart] windows task repair rewrite failed", JSON.stringify({
326
- task_name: WINDOWS_AUTOSTART_TASK_NAME,
327
- exit_code: created.code,
328
- had_output: Boolean(created.stderr.trim() || created.stdout.trim()),
329
- }));
330
- }
331
- const verified = created.code === 0
332
- ? await windowsTaskStatus({
333
- ...options,
334
- repoRoot: workDir,
335
- repoRoots: resolvedWorkDirs,
336
- dashboardUrl,
337
- intervalSeconds: intervalMinutes * 60,
338
- nodeExecutable,
339
- cliEntryPoint,
340
- })
341
- : null;
342
- const loaded = verified?.status === "loaded";
343
- // windowsTaskStatus() above already logs the needs-repair case (with the
344
- // problem list) when the rewrite ran but the read-back still disagrees; log
345
- // the converged branch here too, tagged as a repair outcome and by name, so
346
- // "did the rewrite actually fix it this run" never depends on inferring it
347
- // from a routine status log emitted for an unrelated reason.
348
- if (created.code === 0 && loaded) {
349
- console.error("[autostart] windows task repair converged", JSON.stringify({ task_name: WINDOWS_AUTOSTART_TASK_NAME }));
350
- }
351
- return {
352
- status: "installed",
353
- label: AUTOSTART_LABEL,
354
- plist_path: scriptPath,
355
- task_name: WINDOWS_AUTOSTART_TASK_NAME,
356
- registration_path: registrationPath,
357
- loaded,
358
- interval_seconds: intervalMinutes * 60,
359
- work_dir: workDir,
360
- work_dirs: resolvedWorkDirs,
361
- dashboard_url: dashboardUrl,
362
- ...(loaded
363
- ? {}
364
- : {
365
- message: created.code === 0
366
- ? verified?.message ??
367
- "Windows task registration could not be verified"
368
- : `PowerShell task registration exited ${created.code}: ${created.stderr.trim() ||
369
- created.stdout.trim() ||
370
- "unknown error"}`,
371
- }),
372
- };
373
- }
374
- async function uninstallWindowsTask(options) {
375
- const scriptPath = windowsAutostartScriptPath(options.homeDir);
376
- const registrationPath = windowsAutostartRegistrationScriptPath(options.homeDir);
377
- const launcherPath = windowsAutostartLauncherPath(options.homeDir);
378
- const current = await windowsTaskStatus(options);
379
- if (current.status === "absent") {
380
- await rm(scriptPath, { force: true });
381
- await rm(registrationPath, { force: true });
382
- await rm(launcherPath, { force: true });
383
- return {
384
- ...current,
385
- plist_path: scriptPath,
386
- registration_path: registrationPath,
387
- };
388
- }
389
- const removed = await options.exec("schtasks.exe", [
390
- "/Delete",
391
- "/TN",
392
- WINDOWS_AUTOSTART_TASK_NAME,
393
- "/F",
394
- ]);
395
- if (removed.code === 0) {
396
- await rm(scriptPath, { force: true });
397
- await rm(registrationPath, { force: true });
398
- await rm(launcherPath, { force: true });
399
- }
400
- return {
401
- status: removed.code === 0 ? "uninstalled" : "not_loaded",
402
- label: AUTOSTART_LABEL,
403
- plist_path: scriptPath,
404
- task_name: WINDOWS_AUTOSTART_TASK_NAME,
405
- registration_path: registrationPath,
406
- ...(removed.code === 0
407
- ? {}
408
- : {
409
- message: `schtasks /Delete exited ${removed.code}: ${removed.stderr.trim() || removed.stdout.trim() || "unknown error"}`,
410
- }),
411
- };
412
- }
413
- async function windowsTaskStatus(options) {
414
- const scriptPath = windowsAutostartScriptPath(options.homeDir);
415
- const launcherPath = windowsAutostartLauncherPath(options.homeDir);
416
- const query = await options.exec("schtasks.exe", [
417
- "/Query",
418
- "/TN",
419
- WINDOWS_AUTOSTART_TASK_NAME,
420
- "/XML",
421
- ]);
422
- if (query.code !== 0) {
423
- const queryMessage = `${query.stderr}\n${query.stdout}`;
424
- const taskIsKnownAbsent = query.code === 1 &&
425
- /cannot find|not found|does not exist/iu.test(queryMessage);
426
- // Metadata only: schtasks stderr can carry the state directory, so the exit
427
- // code and the classification travel, never the text.
428
- console.error("[autostart] windows task query failed", JSON.stringify({
429
- task_name: WINDOWS_AUTOSTART_TASK_NAME,
430
- exit_code: query.code,
431
- status: taskIsKnownAbsent ? "absent" : "not_loaded",
432
- }));
433
- return {
434
- status: taskIsKnownAbsent ? "absent" : "not_loaded",
435
- label: AUTOSTART_LABEL,
436
- plist_path: scriptPath,
437
- task_name: WINDOWS_AUTOSTART_TASK_NAME,
438
- message: query.stderr.trim() || query.stdout.trim() || undefined,
439
- };
440
- }
441
- const disabled = /<Enabled>\s*false\s*<\/Enabled>/iu.test(query.stdout);
442
- const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
443
- const intervalMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
444
- const registrationProblems = windowsTaskRegistrationProblems(query.stdout, {
445
- launcherPath,
446
- intervalMinutes,
447
- wscriptPath: windowsWScriptPath(),
448
- });
449
- if (disabled)
450
- registrationProblems.unshift("task is disabled");
451
- const scriptExists = await fileExists(scriptPath);
452
- if (!scriptExists) {
453
- registrationProblems.push("sync script is missing");
454
- }
455
- else if (options.repoRoots && options.repoRoots.length > 0) {
456
- const expectedScript = `${UTF8_BOM}${renderWindowsSyncScript({
457
- discoveryArgs: await savedDiscoveryLimitArgs(options.homeDir),
458
- workDirs: normalizeWindowsWorkDirs(options.repoRoots),
459
- dashboardUrl: options.dashboardUrl ?? DEFAULT_DASHBOARD_URL,
460
- nodeExecutable: (await schedulerNodeExecutable(options, "win32")).path,
461
- cliEntryPoint: path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? ""),
462
- syncLogPath: windowsSyncLogPath(options.homeDir),
463
- })}`;
464
- const currentScript = await readFile(scriptPath, "utf8").catch(() => null);
465
- const scriptDifference = renderedFileDifference(currentScript, expectedScript);
466
- if (scriptDifference) {
467
- registrationProblems.push(`sync script does not match the current roots or Tower runtime (${describeDifference(scriptDifference)})`);
468
- }
469
- }
470
- // Same shape as the sync-script checks: existence always, content only when
471
- // the caller supplied roots (a bare `autostart status` stays permissive by
472
- // design — see BLI-2362). The launcher's content depends on nothing but the
473
- // PowerShell path and the sync-script path, but gating keeps the two
474
- // commands' disagreement surface identical to the script check above.
475
- const launcherExists = await fileExists(launcherPath);
476
- if (!launcherExists) {
477
- registrationProblems.push("sync launcher is missing");
478
- }
479
- else if (options.repoRoots && options.repoRoots.length > 0) {
480
- const expectedLauncher = renderWindowsLauncherScript({
481
- powershellPath: windowsPowerShellPath(),
482
- scriptPath,
483
- });
484
- const currentLauncher = await readFile(launcherPath, "utf8").catch(() => null);
485
- const launcherDifference = renderedFileDifference(currentLauncher, expectedLauncher);
486
- if (launcherDifference) {
487
- registrationProblems.push(`sync launcher does not match the current Tower runtime (${describeDifference(launcherDifference)})`);
488
- }
489
- }
490
- // Both branches log. A line that only fires on failure cannot answer "did
491
- // background collection validate at all today?", which is the question that
492
- // would have caught a validator rejecting every healthy Windows task.
493
- // Problem labels carry no paths, so they are safe to emit verbatim.
494
- if (registrationProblems.length > 0) {
495
- console.error("[autostart] windows task needs repair", JSON.stringify({
496
- task_name: WINDOWS_AUTOSTART_TASK_NAME,
497
- problem_count: registrationProblems.length,
498
- problems: registrationProblems,
499
- }));
500
- }
501
- else {
502
- console.error("[autostart] windows task validated", JSON.stringify({
503
- task_name: WINDOWS_AUTOSTART_TASK_NAME,
504
- interval_minutes: intervalMinutes,
505
- }));
506
- }
507
- return {
508
- status: registrationProblems.length > 0 ? "not_loaded" : "loaded",
509
- label: AUTOSTART_LABEL,
510
- plist_path: scriptPath,
511
- task_name: WINDOWS_AUTOSTART_TASK_NAME,
512
- ...(registrationProblems.length > 0
513
- ? {
514
- message: `Windows task needs repair: ${registrationProblems.join("; ")}`,
515
- }
516
- : {}),
517
- };
518
- }
519
- function renderedFileDifference(current, expected) {
520
- const expectedLines = normalizedScriptLines(expected);
521
- if (current === null) {
522
- return {
523
- reason: "file_unreadable",
524
- first_differing_line: null,
525
- current_line_count: null,
526
- expected_line_count: expectedLines.length,
527
- };
528
- }
529
- if (hasByteOrderMark(current) !== hasByteOrderMark(expected)) {
530
- return {
531
- reason: "byte_order_mark",
532
- first_differing_line: 0,
533
- current_line_count: normalizedScriptLines(current).length,
534
- expected_line_count: expectedLines.length,
535
- };
536
- }
537
- const currentLines = normalizedScriptLines(current);
538
- const shared = Math.min(currentLines.length, expectedLines.length);
539
- for (let index = 0; index < shared; index += 1) {
540
- if (currentLines[index] !== expectedLines[index]) {
541
- return {
542
- reason: "line_content",
543
- first_differing_line: index,
544
- current_line_count: currentLines.length,
545
- expected_line_count: expectedLines.length,
546
- };
547
- }
548
- }
549
- if (currentLines.length !== expectedLines.length) {
550
- return {
551
- reason: "line_count",
552
- first_differing_line: shared,
553
- current_line_count: currentLines.length,
554
- expected_line_count: expectedLines.length,
555
- };
556
- }
557
- return null;
558
- }
559
- /** Safe to print: a reason label, a line index, two counts. No content. */
560
- function describeDifference(difference) {
561
- if (difference.reason === "file_unreadable")
562
- return "file could not be read";
563
- if (difference.reason === "byte_order_mark")
564
- return "byte-order mark differs";
565
- return `${difference.reason} at line ${difference.first_differing_line}, ${difference.current_line_count} lines on disk vs ${difference.expected_line_count} expected`;
566
- }
567
- function normalizedScriptLines(value) {
568
- const withoutBom = hasByteOrderMark(value) ? value.slice(UTF8_BOM.length) : value;
569
- return withoutBom
570
- .replace(/\r\n/gu, "\n")
571
- .replace(/\s+$/u, "")
572
- .split("\n");
573
- }
574
- function hasByteOrderMark(value) {
575
- return value.startsWith(UTF8_BOM);
576
- }
577
- function windowsAutostartScriptPath(homeDir) {
578
- return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
579
- }
580
- function windowsAutostartRegistrationScriptPath(homeDir) {
581
- return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME);
582
- }
583
- function windowsAutostartLauncherPath(homeDir) {
584
- return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_LAUNCHER_NAME);
585
- }
586
- function windowsSyncLogPath(homeDir) {
587
- return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_SYNC_LOG_NAME);
588
- }
589
- function renderWindowsRegistrationScript(options) {
590
- return [
591
- "$ErrorActionPreference = 'Stop'",
592
- `$taskName = ${powershellLiteral(options.taskName)}`,
593
- `$launcherPath = ${powershellLiteral(options.launcherPath)}`,
594
- // wscript.exe is the windowless script host: the task runs the .vbs
595
- // launcher, which starts PowerShell with its window hidden, so the
596
- // 15-minute sync never flashes a terminal (BLI-2677). //B is batch mode —
597
- // a script error becomes a nonzero exit instead of a modal dialog that
598
- // would hang the scheduled run.
599
- "$wscriptPath = Join-Path (Join-Path $env:SystemRoot 'System32') 'wscript.exe'",
600
- // Windows PowerShell 5.1 passes a native argument's embedded quotes to
601
- // CommandLineToArgvW unescaped, so a /TR value with bare quotes falls out
602
- // of the quoted region at the first space inside a quoted path:
603
- // `C:\Users\Brandon Chiem\...` reached schtasks as a stray `Chiem\...`
604
- // argument and /Create exited 0x80004005 on every CLI version (BLI-2598).
605
- // Escaping the embedded quotes as \" keeps the whole /TR value one
606
- // argument; Task Scheduler still normalizes what it stores (BLI-2541),
607
- // which the read-back validator already compares path-wise.
608
- "$actionArgs = '//B \\\"' + $launcherPath + '\\\"'",
609
- "$taskCommand = '\\\"' + $wscriptPath + '\\\" ' + $actionArgs",
610
- `& schtasks.exe /Create /TN $taskName /TR $taskCommand /SC MINUTE /MO ${options.intervalMinutes} /IT /RL LIMITED /F | Out-Null`,
611
- "if ($LASTEXITCODE -ne 0) { throw \"schtasks /Create exited $LASTEXITCODE\" }",
612
- "$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew",
613
- "Set-ScheduledTask -TaskName $taskName -Settings $settings | Out-Null",
614
- "",
615
- ].join("\r\n");
616
- }
617
- /**
618
- * The .vbs launcher the scheduled task actually runs. WshShell.Run with window
619
- * style 0 starts PowerShell with its console hidden — no flash, no focus
620
- * steal — and waiting on the child propagates the sync exit code back to Task
621
- * Scheduler as LastTaskResult. VBScript escapes a quote inside a string by
622
- * doubling it; Windows paths cannot contain quotes, so the doubled quotes here
623
- * are only the fixed delimiters around the two paths.
624
- */
625
- function renderWindowsLauncherScript(options) {
626
- const quotedPowershell = `""${options.powershellPath}""`;
627
- const quotedScript = `""${options.scriptPath}""`;
628
- return [
629
- "' BLI Cockpit sync launcher (BLI-2677). wscript.exe never allocates a",
630
- "' console and starts PowerShell hidden, so the 15-minute background sync",
631
- "' does not flash a terminal over the operator. Sync output lands in",
632
- "' sync.log next to this file instead of a visible window.",
633
- "Option Explicit",
634
- "Dim windowsShell, syncExitCode",
635
- 'Set windowsShell = CreateObject("WScript.Shell")',
636
- `syncExitCode = windowsShell.Run("${quotedPowershell} -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${quotedScript}", 0, True)`,
637
- "WScript.Quit syncExitCode",
638
- "",
639
- ].join("\r\n");
640
- }
641
- function renderWindowsSyncScript(options) {
642
- const dashboardArgs = options.dashboardUrl === DEFAULT_DASHBOARD_URL
643
- ? ""
644
- : ` --dashboard-url ${powershellLiteral(options.dashboardUrl)}`;
645
- const discoveryArgs = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
646
- const commands = options.workDirs.flatMap((root) => [
647
- "try {",
648
- ` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs}${discoveryArgs} --json 2>&1 | ForEach-Object { "$_" } | Add-Content -LiteralPath $syncLogPath -Encoding UTF8`,
649
- " if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE }",
650
- "} catch {",
651
- " Add-Content -LiteralPath $syncLogPath -Encoding UTF8 -Value $_.Exception.Message",
652
- " $exitCode = 1",
653
- "}",
654
- ]);
655
- return [
656
- // 'Continue', not 'Stop': the collector logs to stderr on success as well
657
- // as failure (that is the repo's logging contract), and Windows PowerShell
658
- // 5.1 under 'Stop' turns the first redirected stderr line of a native
659
- // command into a terminating NativeCommandError — 'Stop' would kill every
660
- // healthy run at its first log line. Failures are tracked by exit code.
661
- "$ErrorActionPreference = 'Continue'",
662
- "$exitCode = 0",
663
- `$nodeExecutable = ${powershellLiteral(options.nodeExecutable)}`,
664
- `$cliEntryPoint = ${powershellLiteral(options.cliEntryPoint)}`,
665
- // The task window is hidden (BLI-2677), so this log is the only place the
666
- // sync's output exists; sync.err.log stays macOS-only because launchd does
667
- // stream separation for free and PowerShell 5.1 does not.
668
- `$syncLogPath = ${powershellLiteral(options.syncLogPath)}`,
669
- `$syncLogFile = Get-Item -LiteralPath $syncLogPath -ErrorAction SilentlyContinue`,
670
- `if ($syncLogFile -and $syncLogFile.Length -gt ${WINDOWS_SYNC_LOG_LIMIT_BYTES}) { Set-Content -LiteralPath $syncLogPath -Value '' -Encoding UTF8 }`,
671
- ...commands,
672
- "exit $exitCode",
673
- "",
674
- ].join("\r\n");
675
- }
676
- function windowsTaskRegistrationProblems(taskXml, expected) {
677
- const decodedXml = decodeXmlEntities(taskXml);
678
- const expectedInterval = windowsTaskInterval(expected.intervalMinutes);
679
- const principalsBody = decodedXml
680
- .match(/<Principals(?:\s[^>]*)?>([\s\S]*?)<\/Principals>/iu)?.[1] ??
681
- "";
682
- const principalBlocks = [
683
- ...principalsBody.matchAll(/<Principal(?:\s[^>]*)?>[\s\S]*?<\/Principal>/giu),
684
- ].map((match) => match[0]);
685
- const principalRemainder = principalsBody
686
- .replace(/<Principal(?:\s[^>]*)?>[\s\S]*?<\/Principal>/giu, "")
687
- .trim();
688
- const exactPrincipalBlock = principalBlocks.length === 1 && principalRemainder === ""
689
- ? (principalBlocks[0] ?? "")
690
- : "";
691
- const principalId = exactPrincipalBlock
692
- .match(/<Principal\s[^>]*\bid\s*=\s*["']([^"']+)["'][^>]*>/iu)?.[1] ??
693
- null;
694
- const triggersBody = decodedXml.match(/<Triggers(?:\s[^>]*)?>([\s\S]*?)<\/Triggers>/iu)?.[1] ??
695
- "";
696
- const triggerBlocks = [
697
- ...triggersBody.matchAll(/<(?:TimeTrigger)(?:\s[^>]*)?>[\s\S]*?<\/(?:TimeTrigger)>/giu),
698
- ].map((match) => match[0]);
699
- const triggerRemainder = triggersBody
700
- .replace(/<(?:TimeTrigger)(?:\s[^>]*)?>[\s\S]*?<\/(?:TimeTrigger)>/giu, "")
701
- .trim();
702
- const exactTriggerBlock = triggerBlocks.length === 1 && triggerRemainder === ""
703
- ? (triggerBlocks[0] ?? "")
704
- : "";
705
- const repetitionBlocks = exactTriggerBlock
706
- ? [
707
- ...exactTriggerBlock.matchAll(/<Repetition(?:\s[^>]*)?>[\s\S]*?<\/Repetition>/giu),
708
- ].map((match) => match[0])
709
- : [];
710
- const repetition = repetitionBlocks.length === 1 ? repetitionBlocks[0] ?? "" : "";
711
- const actionsBody = decodedXml.match(/<Actions(?:\s[^>]*)?>([\s\S]*?)<\/Actions>/iu)?.[1] ?? "";
712
- const actionContext = decodedXml
713
- .match(/<Actions\s[^>]*\bContext\s*=\s*["']([^"']+)["'][^>]*>/iu)?.[1] ??
714
- null;
715
- const execBlocks = [
716
- ...actionsBody.matchAll(/<Exec(?:\s[^>]*)?>[\s\S]*?<\/Exec>/giu),
717
- ].map((match) => match[0]);
718
- const actionRemainder = actionsBody
719
- .replace(/<Exec(?:\s[^>]*)?>[\s\S]*?<\/Exec>/giu, "")
720
- .trim();
721
- const exactExecBlock = execBlocks.length === 1 && actionRemainder === "" ? execBlocks[0] : "";
722
- // Strip a wrapping quote pair defensively: every real capture we have shows
723
- // Task Scheduler storing <Command> unquoted, but the writer builds the /TR
724
- // executable segment with the same \"-escaped quoting it uses for the
725
- // launcher argument (BLI-2598), and there is no verified capture proving
726
- // schtasks' own TR-splitting heuristic always discards those quotes rather
727
- // than leaving them as literal text (BLI-2996 canary-gated gap). A stray
728
- // pair of quotes should never be the reason a correctly-registered task
729
- // reads as broken.
730
- const command = stripSurroundingQuotes(exactExecBlock
731
- ?.match(/<Command>\s*([^<]*?)\s*<\/Command>/iu)?.[1]
732
- ?.trim() ?? "");
733
- const actionArguments = exactExecBlock
734
- .match(/<Arguments>\s*([^<]*?)\s*<\/Arguments>/iu)?.[1]
735
- ?.trim() ?? "";
736
- const expectedArgumentFlags = "//B";
737
- const requirements = [
738
- [
739
- /<DisallowStartIfOnBatteries>\s*false\s*<\/DisallowStartIfOnBatteries>/iu,
740
- "task is blocked on battery",
741
- ],
742
- [
743
- /<StopIfGoingOnBatteries>\s*false\s*<\/StopIfGoingOnBatteries>/iu,
744
- "task stops on battery",
745
- ],
746
- [
747
- /<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/iu,
748
- "overlap policy is not IgnoreNew",
749
- ],
750
- [
751
- /<StartWhenAvailable>\s*true\s*<\/StartWhenAvailable>/iu,
752
- "missed-run recovery is disabled",
753
- ],
754
- ];
755
- const problems = requirements
756
- .filter(([pattern]) => !pattern.test(decodedXml))
757
- .map(([, message]) => message);
758
- if (!exactPrincipalBlock) {
759
- problems.push("task must contain exactly one principal");
760
- }
761
- else {
762
- if (!/<LogonType>\s*InteractiveToken\s*<\/LogonType>/iu.test(exactPrincipalBlock)) {
763
- problems.push("logon type is not InteractiveToken");
764
- }
765
- // Windows omits <RunLevel> entirely when a task runs at the default
766
- // LeastPrivilege, so `schtasks /Create ... /RL LIMITED` stores a principal
767
- // block with no RunLevel element at all. Demanding the element asks Windows
768
- // to state a default it never states, which is why this check failed on
769
- // every Windows machine in the fleet and passed on none (0 of 4
770
- // autostart-alive events ok, 2026-08-12). Absent means LeastPrivilege; only
771
- // an explicit elevated value is a real problem.
772
- const runLevel = exactPrincipalBlock
773
- .match(/<RunLevel>\s*([^<]*?)\s*<\/RunLevel>/iu)?.[1]
774
- ?.trim() || "LeastPrivilege";
775
- if (!/^LeastPrivilege$/iu.test(runLevel)) {
776
- problems.push(`task does not run with limited privileges (RunLevel=${runLevel})`);
777
- }
778
- }
779
- if (actionContext && actionContext !== principalId) {
780
- problems.push("task action context does not match its principal");
781
- }
782
- if (!exactTriggerBlock) {
783
- problems.push("task must contain exactly one TimeTrigger");
784
- }
785
- if (repetitionBlocks.length !== 1) {
786
- problems.push("task must contain exactly one repetition schedule");
787
- }
788
- else if (!new RegExp(`<Interval>\\s*${escapeRegExp(expectedInterval)}\\s*</Interval>`, "iu").test(repetition)) {
789
- problems.push(`repetition interval is not ${expectedInterval}`);
790
- }
791
- if (/<EndBoundary>/iu.test(exactTriggerBlock)) {
792
- problems.push("task trigger has an end boundary");
793
- }
794
- const randomDelay = exactTriggerBlock
795
- .match(/<RandomDelay>\s*([^<]+?)\s*<\/RandomDelay>/iu)?.[1]
796
- ?.trim()
797
- .toUpperCase() ?? null;
798
- if (randomDelay &&
799
- !["PT0S", "PT0M", "PT0H"].includes(randomDelay)) {
800
- problems.push("task trigger has a nonzero random delay");
801
- }
802
- if (!exactExecBlock) {
803
- problems.push("task must contain exactly one Exec action");
804
- }
805
- if (!sameWindowsPath(command, expected.wscriptPath)) {
806
- problems.push("task action does not run the expected windowless script host");
807
- }
808
- const actionArgumentProblem = windowsActionArgumentProblem(actionArguments, expectedArgumentFlags, expected.launcherPath);
809
- if (actionArgumentProblem) {
810
- problems.push(actionArgumentProblem);
811
- }
812
- if (/<Duration>/iu.test(repetition)) {
813
- problems.push("repetition has a finite duration");
814
- }
815
- return problems;
816
- }
817
- function windowsPowerShellPath(env = process.env) {
818
- const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
819
- return path.win32.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
820
- }
821
- function windowsWScriptPath(env = process.env) {
822
- const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
823
- return path.win32.join(windowsRoot, "System32", "wscript.exe");
824
- }
825
- /**
826
- * Task Scheduler stores a normalized form of the action it was given, not the
827
- * string we passed: `schtasks /Create /TR "<host> <args>"` is split into
828
- * <Command> plus <Arguments>, and the quotes around a space-free script path
829
- * are dropped. Comparing <Arguments> against the exact string we built
830
- * therefore fails on a task that is registered perfectly — observed on
831
- * DESKTOP-G2UO1GK (CLI 0.2.13, 2026-08-12), where the stored value differed
832
- * from the expected one only by those quotes (BLI-2541).
833
- *
834
- * BLI-2996: that lesson was only ever applied to the pre-BLI-2677 `-File`
835
- * action. The wscript launcher pair (`//B "<launcher>"`) has NO verified
836
- * real-Windows capture — `windowlessWindowsTaskXml()` in autostart.test.ts is
837
- * SYNTHETIC, built by hand-editing the one real capture we have rather than
838
- * exported from a machine actually running this action. A repair that rewrites
839
- * the task correctly but is judged against a guessed shape can fail forever
840
- * without ever being wrong (Brandon's machine: same non-convergence under two
841
- * different validator messages across two CLI versions, neither of which ever
842
- * repaired anything). So this comparison is deliberately structural, not
843
- * positional: tokenize the way CommandLineToArgvW groups quoted/unquoted
844
- * arguments, then check flag-token-then-path-token semantically (a real path
845
- * comparison, case- and quote-insensitive) instead of demanding the exact
846
- * byte layout we happened to send. Whatever the wscript action's real
847
- * normalization turns out to be once a canary captures it, this only fails on
848
- * an actual different flag or different script — not on Windows' own
849
- * reformatting.
850
- */
851
- function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedLauncherPath) {
852
- const tokens = splitWindowsArgumentTokens(actionArguments);
853
- if (tokens.length === 0) {
854
- return "task action names no sync launcher to run";
855
- }
856
- if ((tokens[0] ?? "").toUpperCase() !== expectedFlags.toUpperCase()) {
857
- return "task action does not run the launcher with the expected batch-mode flag";
858
- }
859
- if (tokens.length > 2) {
860
- return "task action passes unexpected extra arguments to the sync launcher";
861
- }
862
- const launcherArgument = tokens[1];
863
- if (!launcherArgument) {
864
- return "task action names no sync launcher to run";
865
- }
866
- if (!sameWindowsPath(launcherArgument, expectedLauncherPath)) {
867
- return "task action runs a script other than the Tower sync launcher";
868
- }
869
- return null;
870
- }
871
- /**
872
- * Quote-aware whitespace tokenizer for an already-XML-decoded `<Arguments>`
873
- * value: a `"..."` run is one token (quotes stripped, whitespace inside kept),
874
- * everything else splits on whitespace. Windows paths never contain a quote
875
- * character, so this does not need CommandLineToArgvW's backslash-escaping
876
- * rules — only its quoting rule — to tell "one argument with a space in it"
877
- * apart from "two arguments".
878
- */
879
- function splitWindowsArgumentTokens(value) {
880
- const tokens = [];
881
- let current = "";
882
- let inQuotes = false;
883
- let hasToken = false;
884
- for (const char of value) {
885
- if (char === '"') {
886
- inQuotes = !inQuotes;
887
- hasToken = true;
888
- continue;
889
- }
890
- if (!inQuotes && /\s/u.test(char)) {
891
- if (hasToken) {
892
- tokens.push(current);
893
- current = "";
894
- hasToken = false;
895
- }
896
- continue;
897
- }
898
- current += char;
899
- hasToken = true;
900
- }
901
- if (hasToken)
902
- tokens.push(current);
903
- return tokens;
904
- }
905
- /** Removes one wrapping pair of double quotes, if present. Used only to
906
- * normalize before a path comparison — never to reconstruct a shell-safe
907
- * value. */
908
- function stripSurroundingQuotes(value) {
909
- const match = value.match(/^"([\s\S]*)"$/u);
910
- return match ? match[1] : value;
911
- }
912
- function sameWindowsPath(left, right) {
913
- if (!left || !right)
914
- return false;
915
- return path.win32.resolve(left).toLowerCase() ===
916
- path.win32.resolve(right).toLowerCase();
917
- }
918
- function powershellLiteral(value) {
919
- return `'${value.replace(/'/gu, "''")}'`;
920
- }
921
- function windowsTaskInterval(minutes) {
922
- const hours = Math.floor(minutes / 60);
923
- const remainingMinutes = minutes % 60;
924
- return `PT${hours > 0 ? `${hours}H` : ""}${remainingMinutes > 0 ? `${remainingMinutes}M` : ""}`;
925
- }
926
- function decodeXmlEntities(value) {
927
- return value
928
- .replace(/&quot;/giu, '"')
929
- .replace(/&apos;/giu, "'")
930
- .replace(/&lt;/giu, "<")
931
- .replace(/&gt;/giu, ">")
932
- .replace(/&amp;/giu, "&");
933
- }
934
- function escapeRegExp(value) {
935
- return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
936
- }
937
- /**
938
- * The transcript directories whose changes should re-trigger a sync, in the
939
- * order they appear in WatchPaths. Resolved from the same homeDir as the plist
940
- * and log paths so `--home` redirects them together.
941
- */
942
- function watchPathCandidates(homeDir) {
943
- return [
944
- path.join(homeDir, ".claude", "projects"),
945
- path.join(homeDir, ".codex", "sessions"),
946
- ];
947
- }
948
- /** Subset of the transcript dirs that exist now (see the WatchPaths comment). */
949
- async function existingWatchPaths(homeDir) {
950
- const candidates = watchPathCandidates(homeDir);
951
- const present = await Promise.all(candidates.map((dir) => fileExists(dir)));
952
- return candidates.filter((_, i) => present[i]);
953
- }
954
- function renderPlist(options) {
955
- const roots = options.workDirs.length > 0 ? options.workDirs : [options.workDir];
956
- const command = renderDarwinSyncCommand({
957
- workDirs: roots,
958
- dashboardUrl: options.dashboardUrl,
959
- nodeExecutable: options.nodeExecutable,
960
- cliEntryPoint: options.cliEntryPoint,
961
- discoveryArgs: options.discoveryArgs,
962
- });
963
- return [
964
- '<?xml version="1.0" encoding="UTF-8"?>',
965
- '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
966
- '<plist version="1.0">',
967
- "<dict>",
968
- " <key>Label</key>",
969
- ` <string>${xmlEscape(AUTOSTART_LABEL)}</string>`,
970
- " <key>ProgramArguments</key>",
971
- " <array>",
972
- " <string>/bin/zsh</string>",
973
- " <string>-c</string>",
974
- ` <string>${xmlEscape(command)}</string>`,
975
- " </array>",
976
- " <key>StartInterval</key>",
977
- ` <integer>${options.intervalSeconds}</integer>`,
978
- " <key>RunAtLoad</key>",
979
- " <true/>",
980
- // WatchPaths makes a transcript write fire `cockpit sync` within seconds, so
981
- // captures land near-real-time; StartInterval above is the safety-net floor.
982
- // It fires often, but launchd single-flights the job, sync holds its own
983
- // lock, and growing-transcript re-upload is damped — so cost stays bounded
984
- // and no extra debounce is needed.
985
- ...watchPathsBlock(options.watchPaths),
986
- " <key>StandardOutPath</key>",
987
- ` <string>${xmlEscape(options.stdoutPath)}</string>`,
988
- " <key>StandardErrorPath</key>",
989
- ` <string>${xmlEscape(options.stderrPath)}</string>`,
990
- "</dict>",
991
- "</plist>",
992
- "",
993
- ].join("\n");
994
- }
995
- function renderDarwinSyncCommand(options) {
996
- const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
997
- ? ""
998
- : ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
999
- const discoveryArg = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
1000
- const commands = options.workDirs.map((root) => `"$node_bin" ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg}${discoveryArg} --json || exit_code=1`);
1001
- return [
1002
- "exit_code=0",
1003
- ...darwinNodeResolutionPreamble(options.nodeExecutable),
1004
- ...commands,
1005
- 'exit "$exit_code"',
1006
- ].join("; ");
1007
- }
1008
- /**
1009
- * The registered node path first, a login-shell PATH lookup second, a named
1010
- * failure third (BLI-3553).
1011
- *
1012
- * The hardcoded absolute path stays FIRST and stays the normal case — launchd
1013
- * hands the tick `PATH=/usr/bin:/bin:/usr/sbin:/sbin` and nothing this fleet
1014
- * runs lives there, which is the whole reason the plist names absolutes
1015
- * (scheduled-self-update.ts:55-62). The fallback only fires when that path is
1016
- * gone: `brew upgrade node` deleted the Cellar directory, `nvm uninstall`
1017
- * removed a version, someone moved a prefix. Before this, that state was a
1018
- * silent `exit 127` every 15 minutes forever, on every tick, with nothing in
1019
- * sync.err.log naming it.
1020
- *
1021
- * `/bin/zsh -lc` because a login shell is where Homebrew's `brew shellenv` and
1022
- * a machine's own PATH edits live; `command -v` because it is a builtin and
1023
- * cannot itself be missing. Recovering here is deliberately temporary: running
1024
- * under a node the plist does not name makes the next status read disagree,
1025
- * which is exactly what wakes the self-heal up to re-register the new path.
1026
- * A machine whose node is only on an INTERACTIVE shell's PATH (nvm sourced from
1027
- * .zshrc) still fails — but now it fails with a reason in the log instead of a
1028
- * bare 127.
1029
- */
1030
- function darwinNodeResolutionPreamble(nodeExecutable) {
1031
- return [
1032
- `node_bin=${shellQuote(nodeExecutable)}`,
1033
- `[ -x "$node_bin" ] || node_bin="$(${DARWIN_LOGIN_SHELL} -lc 'command -v node' 2>/dev/null)"`,
1034
- `[ -x "$node_bin" ] || { echo ${shellQuote(DARWIN_NODE_MISSING_MESSAGE)} >&2; exit 127; }`,
1035
- ];
1036
- }
1037
- function darwinAgentRegistrationProblems(plist, expected) {
1038
- if (!plist)
1039
- return ["plist could not be read"];
1040
- const problems = [];
1041
- if (!plist.includes(` <string>${xmlEscape(AUTOSTART_LABEL)}</string>`)) {
1042
- problems.push("label does not match");
1043
- }
1044
- if (!plist.includes(` <integer>${expected.intervalSeconds}</integer>`)) {
1045
- problems.push(`cadence is not ${expected.intervalSeconds} seconds`);
1046
- }
1047
- const expectedCommand = renderDarwinSyncCommand({
1048
- discoveryArgs: expected.discoveryArgs,
1049
- workDirs: expected.workDirs,
1050
- dashboardUrl: expected.dashboardUrl,
1051
- nodeExecutable: expected.nodeExecutable,
1052
- cliEntryPoint: expected.cliEntryPoint,
1053
- });
1054
- const expectedProgramArguments = [
1055
- " <key>ProgramArguments</key>",
1056
- " <array>",
1057
- " <string>/bin/zsh</string>",
1058
- " <string>-c</string>",
1059
- ` <string>${xmlEscape(expectedCommand)}</string>`,
1060
- " </array>",
1061
- ].join("\n");
1062
- if (!plist.includes(expectedProgramArguments)) {
1063
- problems.push("command does not match the current roots, dashboard URL, or Tower runtime");
1064
- }
1065
- return problems;
1066
- }
1067
- function normalizeWorkDirs(repoRoots) {
1068
- if (!repoRoots)
1069
- return [];
1070
- const seen = new Set();
1071
- const normalized = [];
1072
- for (const root of repoRoots) {
1073
- const resolved = path.resolve(root);
1074
- if (seen.has(resolved))
1075
- continue;
1076
- seen.add(resolved);
1077
- normalized.push(resolved);
1078
- }
1079
- return normalized;
1080
- }
1081
- function normalizeWindowsWorkDirs(repoRoots) {
1082
- if (!repoRoots)
1083
- return [];
1084
- const seen = new Set();
1085
- const normalized = [];
1086
- for (const root of repoRoots) {
1087
- const resolved = path.win32.resolve(root);
1088
- const key = resolved.toLowerCase();
1089
- if (seen.has(key))
1090
- continue;
1091
- seen.add(key);
1092
- normalized.push(resolved);
1093
- }
1094
- return normalized;
1095
- }
1096
- /**
1097
- * Renders the `<key>WatchPaths</key><array>…</array>` lines, or nothing when no
1098
- * transcript dir exists yet (an empty array would tell launchd to watch
1099
- * everything-and-nothing; omitting the key leaves StartInterval as the floor).
1100
- */
1101
- function watchPathsBlock(watchPaths) {
1102
- if (watchPaths.length === 0)
1103
- return [];
1104
- return [
1105
- " <key>WatchPaths</key>",
1106
- " <array>",
1107
- ...watchPaths.map((dir) => ` <string>${xmlEscape(dir)}</string>`),
1108
- " </array>",
1109
- ];
61
+ return darwinAgentStatus(options);
1110
62
  }
1111
63
  /**
1112
64
  * What the platform says it will run, parsed out of the registration itself
@@ -1117,19 +69,10 @@ function watchPathsBlock(watchPaths) {
1117
69
  export async function readRegisteredRuntimePaths(options) {
1118
70
  const platform = options.platform ?? process.platform;
1119
71
  if (platform === "darwin") {
1120
- const plist = await readFile(plistPathFor(options.homeDir ?? os.homedir()), "utf8").catch(() => "");
1121
- const command = decodeXmlEntities(plist);
1122
- return {
1123
- node_executable: /node_bin='([^']*)'/u.exec(command)?.[1] ?? null,
1124
- cli_entry_point: /"\$node_bin" '([^']*)' sync /u.exec(command)?.[1] ?? null,
1125
- };
72
+ return readRegisteredDarwinRuntimePaths(options.homeDir ?? os.homedir());
1126
73
  }
1127
74
  if (platform === "win32") {
1128
- const script = await readFile(windowsAutostartScriptPath(options.homeDir), "utf8").catch(() => "");
1129
- return {
1130
- node_executable: /\$nodeExecutable = '([^']*)'/u.exec(script)?.[1] ?? null,
1131
- cli_entry_point: /\$cliEntryPoint = '([^']*)'/u.exec(script)?.[1] ?? null,
1132
- };
75
+ return readRegisteredWindowsRuntimePaths(options.homeDir);
1133
76
  }
1134
77
  return { node_executable: null, cli_entry_point: null };
1135
78
  }
@@ -1160,60 +103,4 @@ export async function registeredRuntimePathProblems(options) {
1160
103
  problems.push("registered Tower CLI entry point is missing");
1161
104
  }
1162
105
  return problems;
1163
- }
1164
- /** `gui/<uid>` — the per-user launchd domain the LaunchAgent lives in. */
1165
- export function launchdUserDomain(uid = process.getuid?.() ?? 0) {
1166
- return `gui/${uid}`;
1167
- }
1168
- /**
1169
- * Re-registers the LaunchAgent with the modern verbs (BLI-3553).
1170
- *
1171
- * `unload`/`load` is what the interactive install path uses and it stays there;
1172
- * this exists for the DETACHED healer, where the difference matters:
1173
- * `bootstrap` reports a rejected plist with a real error, whereas `load` is
1174
- * documented-deprecated and exits nonzero with empty output for several
1175
- * unrelated states (see launchctlDetail). The healer gets one shot and has no
1176
- * human watching, so it uses the verb that says what went wrong.
1177
- *
1178
- * `bootout` on a service that is not loaded exits nonzero (`3: No such
1179
- * process`); that is the expected steady state after the job that spawned us
1180
- * exited, so it is not treated as a failure. Only `bootstrap` decides the
1181
- * outcome. If it fails, the agent is left unloaded — recoverable, because
1182
- * launchd bootstraps everything in ~/Library/LaunchAgents at the next login,
1183
- * and the plist file itself was already rewritten before this call.
1184
- */
1185
- export async function reregisterDarwinAgent(options) {
1186
- const domain = launchdUserDomain(options.uid);
1187
- const bootout = await options
1188
- .exec("launchctl", ["bootout", `${domain}/${AUTOSTART_LABEL}`])
1189
- .catch(() => ({ code: 1, stdout: "", stderr: "" }));
1190
- const bootstrap = await options
1191
- .exec("launchctl", ["bootstrap", domain, options.plistPath])
1192
- .catch(() => ({ code: 1, stdout: "", stderr: "" }));
1193
- if (bootstrap.code === 0) {
1194
- return {
1195
- ok: true,
1196
- reason: "agent_rebootstrapped",
1197
- detail: `bootout exit ${bootout.code}`,
1198
- };
1199
- }
1200
- return {
1201
- ok: false,
1202
- reason: "bootstrap_failed",
1203
- detail: `launchctl bootstrap exited ${bootstrap.code}: ${launchctlDetail(bootstrap)}`,
1204
- };
1205
- }
1206
- function shellQuote(value) {
1207
- return `'${value.replace(/'/g, "'\\''")}'`;
1208
- }
1209
- function xmlEscape(value) {
1210
- return value
1211
- .replace(/&/g, "&amp;")
1212
- .replace(/</g, "&lt;")
1213
- .replace(/>/g, "&gt;")
1214
- .replace(/"/g, "&quot;")
1215
- .replace(/'/g, "&apos;");
1216
- }
1217
- async function fileExists(filePath) {
1218
- return stat(filePath).then(() => true, () => false);
1219
106
  }