@bli-cockpit/cli 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/autostart.js CHANGED
@@ -270,6 +270,13 @@ async function windowsTaskStatus(options) {
270
270
  const queryMessage = `${query.stderr}\n${query.stdout}`;
271
271
  const taskIsKnownAbsent = query.code === 1 &&
272
272
  /cannot find|not found|does not exist/iu.test(queryMessage);
273
+ // Metadata only: schtasks stderr can carry the state directory, so the exit
274
+ // code and the classification travel, never the text.
275
+ console.error("[autostart] windows task query failed", JSON.stringify({
276
+ task_name: WINDOWS_AUTOSTART_TASK_NAME,
277
+ exit_code: query.code,
278
+ status: taskIsKnownAbsent ? "absent" : "not_loaded",
279
+ }));
273
280
  return {
274
281
  status: taskIsKnownAbsent ? "absent" : "not_loaded",
275
282
  label: AUTOSTART_LABEL,
@@ -305,6 +312,23 @@ async function windowsTaskStatus(options) {
305
312
  registrationProblems.push("sync script does not match the current roots or Cockpit runtime");
306
313
  }
307
314
  }
315
+ // Both branches log. A line that only fires on failure cannot answer "did
316
+ // background collection validate at all today?", which is the question that
317
+ // would have caught a validator rejecting every healthy Windows task.
318
+ // Problem labels carry no paths, so they are safe to emit verbatim.
319
+ if (registrationProblems.length > 0) {
320
+ console.error("[autostart] windows task needs repair", JSON.stringify({
321
+ task_name: WINDOWS_AUTOSTART_TASK_NAME,
322
+ problem_count: registrationProblems.length,
323
+ problems: registrationProblems,
324
+ }));
325
+ }
326
+ else {
327
+ console.error("[autostart] windows task validated", JSON.stringify({
328
+ task_name: WINDOWS_AUTOSTART_TASK_NAME,
329
+ interval_minutes: intervalMinutes,
330
+ }));
331
+ }
308
332
  return {
309
333
  status: registrationProblems.length > 0 ? "not_loaded" : "loaded",
310
334
  label: AUTOSTART_LABEL,
@@ -415,7 +439,7 @@ function windowsTaskRegistrationProblems(taskXml, expected) {
415
439
  const actionArguments = exactExecBlock
416
440
  .match(/<Arguments>\s*([^<]*?)\s*<\/Arguments>/iu)?.[1]
417
441
  ?.trim() ?? "";
418
- const expectedActionArguments = `-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${expected.scriptPath}"`;
442
+ const expectedArgumentFlags = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File";
419
443
  const requirements = [
420
444
  [
421
445
  /<DisallowStartIfOnBatteries>\s*false\s*<\/DisallowStartIfOnBatteries>/iu,
@@ -444,8 +468,18 @@ function windowsTaskRegistrationProblems(taskXml, expected) {
444
468
  if (!/<LogonType>\s*InteractiveToken\s*<\/LogonType>/iu.test(exactPrincipalBlock)) {
445
469
  problems.push("logon type is not InteractiveToken");
446
470
  }
447
- if (!/<RunLevel>\s*LeastPrivilege\s*<\/RunLevel>/iu.test(exactPrincipalBlock)) {
448
- problems.push("task does not run with limited privileges");
471
+ // Windows omits <RunLevel> entirely when a task runs at the default
472
+ // LeastPrivilege, so `schtasks /Create ... /RL LIMITED` stores a principal
473
+ // block with no RunLevel element at all. Demanding the element asks Windows
474
+ // to state a default it never states, which is why this check failed on
475
+ // every Windows machine in the fleet and passed on none (0 of 4
476
+ // autostart-alive events ok, 2026-08-12). Absent means LeastPrivilege; only
477
+ // an explicit elevated value is a real problem.
478
+ const runLevel = exactPrincipalBlock
479
+ .match(/<RunLevel>\s*([^<]*?)\s*<\/RunLevel>/iu)?.[1]
480
+ ?.trim() || "LeastPrivilege";
481
+ if (!/^LeastPrivilege$/iu.test(runLevel)) {
482
+ problems.push(`task does not run with limited privileges (RunLevel=${runLevel})`);
449
483
  }
450
484
  }
451
485
  if (actionContext && actionContext !== principalId) {
@@ -477,8 +511,9 @@ function windowsTaskRegistrationProblems(taskXml, expected) {
477
511
  if (!sameWindowsPath(command, expected.powershellPath)) {
478
512
  problems.push("task action does not run the expected Windows PowerShell");
479
513
  }
480
- if (actionArguments !== expectedActionArguments) {
481
- problems.push("task action arguments do not exactly run the Cockpit sync script");
514
+ const actionArgumentProblem = windowsActionArgumentProblem(actionArguments, expectedArgumentFlags, expected.scriptPath);
515
+ if (actionArgumentProblem) {
516
+ problems.push(actionArgumentProblem);
482
517
  }
483
518
  if (/<Duration>/iu.test(repetition)) {
484
519
  problems.push("repetition has a finite duration");
@@ -489,6 +524,38 @@ function windowsPowerShellPath(env = process.env) {
489
524
  const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
490
525
  return path.win32.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
491
526
  }
527
+ /**
528
+ * Task Scheduler stores a normalized form of the action it was given, not the
529
+ * string we passed: `schtasks /Create /TR "<powershell> <args>"` is split into
530
+ * <Command> plus <Arguments>, and the quotes around a space-free script path
531
+ * are dropped. Comparing <Arguments> against the exact string we built
532
+ * therefore fails on a task that is registered perfectly — observed on
533
+ * DESKTOP-G2UO1GK (CLI 0.2.13, 2026-08-12), where the stored value differed
534
+ * from the expected one only by those quotes.
535
+ *
536
+ * Compare the flags exactly, because those are ours, and compare the script as
537
+ * a path, because that is what Windows normalizes. Each failure returns its own
538
+ * reason so a repair message says which half is wrong rather than "arguments
539
+ * differ".
540
+ */
541
+ function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedScriptPath) {
542
+ if (!actionArguments.startsWith(expectedFlags)) {
543
+ return "task action does not run PowerShell with the expected Cockpit sync flags";
544
+ }
545
+ const scriptArgument = unquoteWindowsArgument(actionArguments.slice(expectedFlags.length));
546
+ if (!scriptArgument) {
547
+ return "task action names no sync script to run";
548
+ }
549
+ if (!sameWindowsPath(scriptArgument, expectedScriptPath)) {
550
+ return "task action runs a script other than the Cockpit sync script";
551
+ }
552
+ return null;
553
+ }
554
+ function unquoteWindowsArgument(value) {
555
+ const trimmed = value.trim();
556
+ const quoted = trimmed.match(/^"([\s\S]*)"$/u) ?? trimmed.match(/^'([\s\S]*)'$/u);
557
+ return (quoted?.[1] ?? trimmed).trim();
558
+ }
492
559
  function sameWindowsPath(left, right) {
493
560
  if (!left || !right)
494
561
  return false;
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { autostartStatus, installAutostartAgent } from "../autostart.js";
4
4
  import { savedDiscoveryLimitArgs } from "../discovery-limits.js";
5
+ import { redactedHealthDetail } from "../health-detail.js";
5
6
  import { inspectBackfillLock } from "../backfill-lock.js";
6
7
  import { backfillCompletionCovers, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
7
8
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
@@ -383,10 +384,21 @@ function doctorEvent(row) {
383
384
  : row.status === "skipped"
384
385
  ? "skipped"
385
386
  : "ok";
387
+ if (status === "ok")
388
+ return { step: row.id, status };
389
+ // BLI-2542. The step already computed why it failed and already printed it to
390
+ // the operator; before this, only the bucket travelled. `autostart-alive`
391
+ // reports two distinct problems at once on Windows, so the detail carries the
392
+ // whole message rather than a first line.
393
+ const detail = redactedHealthDetail(row.message);
386
394
  return {
387
395
  step: row.id,
388
396
  status,
389
- ...(status === "ok" ? {} : { error_code: sanitizeEventCode(row.code) }),
397
+ error_code: sanitizeEventCode(row.code),
398
+ // A detail that only repeats the bucket is noise, not a reason.
399
+ ...(detail && detail !== sanitizeEventCode(row.code)
400
+ ? { error_detail: detail }
401
+ : {}),
390
402
  };
391
403
  }
392
404
  function doctorMark(row) {
@@ -7,6 +7,7 @@ import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../a
7
7
  import { backfillRetryCommand, runBackfill, runBackfillCommand, } from "./backfill.js";
8
8
  import { runDoctor } from "./doctor.js";
9
9
  import { inspectBackfillLock } from "../backfill-lock.js";
10
+ import { maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
10
11
  import { parseLocalArgs, normalizeUrl } from "./local-args.js";
11
12
  import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
12
13
  import { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
@@ -640,11 +641,17 @@ function updateCollectionRoots(command) {
640
641
  }
641
642
  return deduped;
642
643
  }
643
- function addInstallEvent(events, step, status, errorCode) {
644
+ function addInstallEvent(events, step, status, errorCode,
645
+ // BLI-2542: the bucket alone cannot be acted on. Callers that hold the reason
646
+ // pass it; it is redacted at this boundary, not at the call site.
647
+ errorMessage) {
648
+ const detail = errorMessage ? redactedHealthDetail(errorMessage) : "";
649
+ const code = errorCode ? sanitizeInstallErrorCode(errorCode) : undefined;
644
650
  events.push({
645
651
  step,
646
652
  status,
647
- ...(errorCode ? { error_code: sanitizeInstallErrorCode(errorCode) } : {}),
653
+ ...(code ? { error_code: code } : {}),
654
+ ...(detail && detail !== code ? { error_detail: detail } : {}),
648
655
  });
649
656
  }
650
657
  function addOnboardFailureEvent(events, blocker) {
@@ -660,10 +667,10 @@ function addAutostartInstallEvent(events, result) {
660
667
  return;
661
668
  }
662
669
  if (result.status === "unsupported") {
663
- addInstallEvent(events, "autostart", "skipped", "unsupported");
670
+ addInstallEvent(events, "autostart", "skipped", "unsupported", result.message);
664
671
  return;
665
672
  }
666
- addInstallEvent(events, "autostart", result.loaded === false ? "fail" : "ok", result.loaded === false ? "autostart_load_failed" : undefined);
673
+ addInstallEvent(events, "autostart", result.loaded === false ? "fail" : "ok", result.loaded === false ? "autostart_load_failed" : undefined, result.loaded === false ? result.message : undefined);
667
674
  }
668
675
  function onboardAutostartFailed(result) {
669
676
  return (result !== null &&
@@ -2272,9 +2279,14 @@ export function redactedSyncErrorDetail(error) {
2272
2279
  const { text } = redactSecretLikeContent(message, {
2273
2280
  appliedBy: "local_collector",
2274
2281
  });
2275
- return text.length > SYNC_ERROR_DETAIL_MAX_CHARS
2276
- ? `${text.slice(0, SYNC_ERROR_DETAIL_MAX_CHARS - 1)}…`
2277
- : text;
2282
+ // BLI-2542: the comment above always said this text can carry absolute paths,
2283
+ // and until now nothing removed them — secret redaction matches token shapes,
2284
+ // not filesystem paths. Same masking the doctor receipts use, so one boundary
2285
+ // rule covers every health receipt.
2286
+ const masked = maskLocalIdentifiers(text);
2287
+ return masked.length > SYNC_ERROR_DETAIL_MAX_CHARS
2288
+ ? `${masked.slice(0, SYNC_ERROR_DETAIL_MAX_CHARS - 1)}…`
2289
+ : masked;
2278
2290
  }
2279
2291
  /**
2280
2292
  * Turn a finished run into an exit code and the reasons behind it.
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.13");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.15");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,80 @@
1
+ import os from "node:os";
2
+ import { redactSecretLikeContent } from "@bli-cockpit/telemetry-core";
3
+ /**
4
+ * The reason a health receipt carries, redacted on the machine that produced it.
5
+ *
6
+ * `error_code` is the aggregation bucket. This is the cause — the text a step
7
+ * already computed and already showed the operator. BLI-2542: without it a
8
+ * receipt can say `autostart_load_failed` and nothing else, which names no cause
9
+ * and supports no repair. Measured on 2026-08-13, every receipt step except
10
+ * `sync_complete` uploaded a null detail: `npm_install` 6 failures, `gc-checked`
11
+ * 3, `backfill-complete` 3, `sync-fresh` 3, `autostart-alive` 1, all with the
12
+ * reason computed locally and discarded at the boundary.
13
+ *
14
+ * Two things are stripped before it leaves, in this order:
15
+ *
16
+ * 1. Secret-like fragments, via the same deterministic redaction the collector
17
+ * applies to evidence.
18
+ * 2. Local identifiers — absolute paths, UNC shares, the machine name, the
19
+ * account name. Step messages are static strings today, but several of them
20
+ * embed the output of `schtasks` or `launchctl`, and that output does carry
21
+ * the state directory and the host. Masking at the boundary means a future
22
+ * step cannot leak one by writing a more helpful message.
23
+ *
24
+ * Redaction is deliberately here rather than at each call site: the boundary is
25
+ * what leaves the machine, so the boundary is what has to be safe.
26
+ */
27
+ export const HEALTH_DETAIL_MAX_CHARS = 600;
28
+ const WINDOWS_ABSOLUTE_PATH = /[A-Za-z]:\\[^\s"';]+/gu;
29
+ const UNC_PATH = /\\\\[^\s"';]+/gu;
30
+ // An absolute POSIX path of at least two segments. One segment ("/tmp") carries
31
+ // nothing identifying and masking it would make messages harder to read.
32
+ const POSIX_ABSOLUTE_PATH = /\/[\w.@-]+(?:\/[\w.@ -]+)+/gu;
33
+ export function redactedHealthDetail(message) {
34
+ const collapsed = message.replace(/\s+/gu, " ").trim();
35
+ if (!collapsed)
36
+ return "";
37
+ const { text } = redactSecretLikeContent(collapsed, {
38
+ appliedBy: "local_collector",
39
+ });
40
+ const masked = maskLocalIdentifiers(text);
41
+ return masked.length > HEALTH_DETAIL_MAX_CHARS
42
+ ? `${masked.slice(0, HEALTH_DETAIL_MAX_CHARS - 1)}…`
43
+ : masked;
44
+ }
45
+ /**
46
+ * Paths first, because a path usually contains the account name; whatever
47
+ * mentions of the host or account survive on their own are masked after.
48
+ */
49
+ export function maskLocalIdentifiers(value) {
50
+ let text = value
51
+ .replace(UNC_PATH, "[path]")
52
+ .replace(WINDOWS_ABSOLUTE_PATH, "[path]")
53
+ .replace(POSIX_ABSOLUTE_PATH, "[path]");
54
+ for (const [identifier, placeholder] of localIdentifiers()) {
55
+ if (!identifier)
56
+ continue;
57
+ text = text.replace(literalPattern(identifier), placeholder);
58
+ }
59
+ return text;
60
+ }
61
+ function localIdentifiers() {
62
+ const identifiers = [];
63
+ try {
64
+ identifiers.push([os.hostname(), "[host]"]);
65
+ }
66
+ catch {
67
+ // A host with no resolvable name has nothing to leak.
68
+ }
69
+ try {
70
+ identifiers.push([os.userInfo().username, "[user]"]);
71
+ }
72
+ catch {
73
+ // Same: no account name available means none can travel.
74
+ }
75
+ // A one- or two-character name would match far too much ordinary text.
76
+ return identifiers.filter(([value]) => value && value.length > 2);
77
+ }
78
+ function literalPattern(value) {
79
+ return new RegExp(value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "giu");
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {