@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 +72 -5
- package/dist/commands/doctor.js +13 -1
- package/dist/commands/local.js +19 -7
- package/dist/commands/public-root.js +1 -1
- package/dist/health-detail.js +80 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
448
|
-
|
|
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
|
-
|
|
481
|
-
|
|
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;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/commands/local.js
CHANGED
|
@@ -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
|
-
...(
|
|
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
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
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.
|
|
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
|
+
}
|