@bli-cockpit/cli 0.2.45 → 0.2.47

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.
@@ -1,6 +1,7 @@
1
- import { COMMIT_CRASHED_PLATFORM, RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, RawEvidenceLegacyUploadResponseSchema, RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, RawEvidenceUploadCommitResponseSchema, isPermanentUploadFailure, } from "@bli-cockpit/telemetry-core";
1
+ import { COMMIT_CRASHED_PLATFORM, RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, RawEvidenceLegacyUploadResponseSchema, RawEvidenceRedactionMetadataSchema, RawEvidenceUploadBeginResponseSchema, RawEvidenceUploadCommitResponseSchema, isPermanentUploadFailure, isRekeyableUploadConflict, } from "@bli-cockpit/telemetry-core";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
+ import { rekeyedEvidencePointer } from "./evidence-upload-rekey.js";
4
5
  import { describeError } from "./health-detail.js";
5
6
  const DEFAULT_MAX_ATTEMPTS = 3;
6
7
  const RETRY_DELAY_MS = 250;
@@ -182,10 +183,18 @@ export async function uploadRawEvidenceFilesChunked(options) {
182
183
  resolvedEntries.add(entry);
183
184
  continue;
184
185
  }
185
- const outcome = await uploadOneObject(options, entry, disposition, chunkSizeBytes);
186
- await reportAbandonedUpload(options, disposition, outcome);
186
+ // A conflict a different name can settle gets one, here, before the
187
+ // upload is attempted (BLI-3552). Anything else comes back unchanged and
188
+ // fails on its own reason inside `uploadOneObject`.
189
+ const resolved = await rekeyConflictedEntry(options, entry, disposition, chunkSizeBytes);
190
+ const outcome = await uploadOneObject(options, resolved.entry, resolved.disposition, chunkSizeBytes);
191
+ await reportAbandonedUpload(options, resolved.disposition, outcome);
187
192
  outcomes.push(outcome);
188
- for (const duplicate of entry.duplicates) {
193
+ // The duplicates of a re-keyed primary were re-keyed with it: a duplicate
194
+ // is byte-identical and shared the primary's key, so it shares the new
195
+ // one too. Leaving them on the old key would point their evidence refs at
196
+ // somebody else's durable object.
197
+ for (const duplicate of resolved.entry.duplicates) {
189
198
  outcomes.push(duplicateOutcome(outcome, duplicate));
190
199
  }
191
200
  resolvedEntries.add(entry);
@@ -281,6 +290,105 @@ function conformReasonLabel(reason) {
281
290
  const conformed = reason.replace(/[^a-z0-9_:.-]/gi, "_").slice(0, 120);
282
291
  return conformed.length > 0 ? conformed : "upload_failed_unlabelled";
283
292
  }
293
+ /**
294
+ * Settle a re-keyable conflict by asking for a second name (BLI-3552).
295
+ *
296
+ * `hash_mismatch_committed_object` means the key already holds different bytes
297
+ * that are already durable. Re-sending is the loop; deleting the durable object
298
+ * to make room is destroying evidence. The third answer is to offer this
299
+ * content under a name derived from its own hash, which is what this does, once
300
+ * per object per sync.
301
+ *
302
+ * Every path that cannot get there returns the ORIGINAL pair, so the object
303
+ * fails on the conflict reason `begin` actually gave rather than on a label
304
+ * invented here. Nothing is written off permanently either way: the upload
305
+ * cursor only remembers successes, so the next eligible sync offers the bytes
306
+ * again — at delivery-backoff cadence now instead of every 15 minutes.
307
+ */
308
+ async function rekeyConflictedEntry(options, entry, disposition, chunkSizeBytes) {
309
+ if (disposition.disposition !== "conflict" ||
310
+ !isRekeyableUploadConflict(disposition.reason)) {
311
+ return { entry, disposition };
312
+ }
313
+ const contentHashPrefix = (entry.file.pointer.content_hash_sha256 ?? "none").slice(0, 12);
314
+ const rekeyedPointer = rekeyedEvidencePointer(entry.file.pointer);
315
+ if (!rekeyedPointer) {
316
+ // The key already names this content and the server still says it holds
317
+ // something else. A rename cannot answer that; a person has to.
318
+ console.error("[evidence-rekey] the key already carries this content hash; leaving the conflict for a person", JSON.stringify({
319
+ reason: disposition.reason,
320
+ kind: entry.file.kind ?? "unknown",
321
+ content_hash_prefix: contentHashPrefix,
322
+ byte_size: entry.bytes.byteLength,
323
+ }));
324
+ return { entry, disposition };
325
+ }
326
+ const rekeyedEntry = {
327
+ ...entry,
328
+ file: { ...entry.file, pointer: rekeyedPointer },
329
+ duplicates: entry.duplicates.map((duplicate) => ({
330
+ ...duplicate,
331
+ pointer: rekeyedEvidencePointer(duplicate.pointer) ?? duplicate.pointer,
332
+ })),
333
+ };
334
+ const rekeyedDisposition = await beginOneObject(options, rekeyedEntry, chunkSizeBytes);
335
+ if (!rekeyedDisposition) {
336
+ return { entry, disposition };
337
+ }
338
+ console.error("[evidence-rekey] committed object holds other content; offering these bytes under their own hash", JSON.stringify({
339
+ reason: disposition.reason,
340
+ kind: entry.file.kind ?? "unknown",
341
+ content_hash_prefix: contentHashPrefix,
342
+ byte_size: entry.bytes.byteLength,
343
+ chunk_count: entry.chunkCount,
344
+ rekeyed_disposition: rekeyedDisposition.disposition,
345
+ rekeyed_reason: rekeyedDisposition.reason ?? "none",
346
+ duplicate_count: entry.duplicates.length,
347
+ }));
348
+ return { entry: rekeyedEntry, disposition: rekeyedDisposition };
349
+ }
350
+ /**
351
+ * `begin` for a single object. Null whenever the answer cannot be trusted —
352
+ * transport failure, an unparseable body, a duplicate or missing key, or a
353
+ * pointer id that is not the one we sent — and the caller then keeps the
354
+ * original conflict rather than acting on a guess.
355
+ */
356
+ async function beginOneObject(options, entry, chunkSizeBytes) {
357
+ const objectKey = entry.file.pointer.object_key ?? "";
358
+ const response = await requestJson(options, "/api/ambient/evidence/upload/begin", {
359
+ schema_version: "ambient-raw-evidence-upload-begin.v1",
360
+ generated_at: options.generatedAt,
361
+ provenance: options.provenance,
362
+ objects: [
363
+ {
364
+ pointer: entry.file.pointer,
365
+ chunk_size_bytes: chunkSizeBytes,
366
+ chunk_count: entry.chunkCount,
367
+ },
368
+ ],
369
+ });
370
+ if (!response.ok) {
371
+ console.error("[evidence-rekey] begin refused the re-keyed object; keeping the original conflict", JSON.stringify({
372
+ reason: "rekey_begin_rejected",
373
+ http_status: response.status,
374
+ server_reason: safeFailureDetail(response.body) ?? "none",
375
+ kind: entry.file.kind ?? "unknown",
376
+ }));
377
+ return null;
378
+ }
379
+ const parsed = RawEvidenceUploadBeginResponseSchema.safeParse(response.body);
380
+ if (!parsed.success)
381
+ return null;
382
+ const dispositions = readBeginDispositions(parsed.data);
383
+ const disposition = dispositions?.get(objectKey);
384
+ if (!disposition)
385
+ return null;
386
+ if (disposition.raw_evidence_pointer_id !==
387
+ entry.file.pointer.raw_evidence_pointer_id) {
388
+ return null;
389
+ }
390
+ return disposition;
391
+ }
284
392
  async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
285
393
  const objectKey = entry.file.pointer.object_key ?? "";
286
394
  if (disposition.disposition === "already_committed") {
@@ -0,0 +1,40 @@
1
+ /** How much of the content hash goes into the name, matching the pack keys. */
2
+ const KEY_HASH_PREFIX_LENGTH = 16;
3
+ /**
4
+ * The same object under a name that carries its own content hash.
5
+ *
6
+ * `null` when a re-key cannot help and must not be attempted:
7
+ * - there is no object key to rewrite;
8
+ * - the last segment already starts with this content's hash prefix, so the
9
+ * re-keyed name would be the identical string and the second `begin` would
10
+ * answer the identical conflict;
11
+ * - the key has no `/`, which the server's namespace rules make impossible, so
12
+ * rewriting it would be inventing a key rather than deriving one.
13
+ *
14
+ * The new segment is `<hash16>-<old segment>`: still inside the operator's
15
+ * readable namespace (so `evidenceObjectKeyBelongsToWorkContext` still passes),
16
+ * still inside the key charset, and still legible to a person doing an incident
17
+ * walk — they can see which pack file it came from.
18
+ */
19
+ export function rekeyedEvidencePointer(pointer) {
20
+ const objectKey = pointer.object_key;
21
+ if (!objectKey)
22
+ return null;
23
+ const cut = objectKey.lastIndexOf("/");
24
+ if (cut <= 0 || cut === objectKey.length - 1)
25
+ return null;
26
+ const prefix = objectKey.slice(0, cut);
27
+ const segment = objectKey.slice(cut + 1);
28
+ // A pointer with no content hash cannot name itself, and the upload routes
29
+ // reject it anyway; there is nothing to re-key it to.
30
+ const hash = (pointer.content_hash_sha256 ?? "").slice(0, KEY_HASH_PREFIX_LENGTH);
31
+ if (!hash || segment.startsWith(`${hash}-`) || segment.startsWith(hash)) {
32
+ return null;
33
+ }
34
+ const rekeyed = `${prefix}/${hash}-${segment}`;
35
+ return {
36
+ ...pointer,
37
+ object_key: rekeyed,
38
+ raw_evidence_pointer_id: rekeyed,
39
+ };
40
+ }
@@ -0,0 +1,144 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ /**
4
+ * BLI-3553. Measured on the reference Mac, 2026-09-04, not estimated:
5
+ * `sync.log` 219,866,695 bytes and `sync.err.log` 59,635,270 after two months
6
+ * of a 15-minute tick. The repo's logging contract says log the success branch
7
+ * too, which is correct and is exactly why the files grow: the cost of that
8
+ * rule is bounded here, not by logging less.
9
+ *
10
+ * A cap DID exist for sync.log — buried in `raw-evidence-gc.ts`, 50 MB,
11
+ * truncate-to-zero with no archive. It could not work: the GC is throttled to
12
+ * once a day and skipped outright by `COCKPIT_DISABLE_GC`, while the log grows
13
+ * about 8 MB an hour on an active machine, so the file spent almost all of its
14
+ * life several times over the cap (last GC 19 hours before the measurement
15
+ * above). sync.err.log had no cap at all. That copy is gone; this is the one
16
+ * owner, it runs every tick, and it keeps an archive instead of destroying the
17
+ * only record of what happened.
18
+ *
19
+ * WHY TRUNCATE-AFTER-COPY, NOT RENAME-AND-REOPEN
20
+ *
21
+ * launchd opens StandardOutPath/StandardErrorPath once and holds that fd for
22
+ * the life of the job (the paths are right there in the `launchctl print`
23
+ * capture pinned in autostart.test.ts). Renaming the file does not move the fd:
24
+ * the job would keep writing into the renamed — eventually unlinked — inode, so
25
+ * the disk would never be reclaimed and `sync.err.log` would stop receiving new
26
+ * lines entirely until the agent was reloaded. Only the tick can reopen it, and
27
+ * the tick is not the writer; launchd is.
28
+ *
29
+ * A dated StandardErrorPath plus a pruner was the other candidate and is worse:
30
+ * it changes the plist on a schedule, every change has to be re-registered
31
+ * (which on macOS means the BLI-2583 bootout/bootstrap hazard), and it makes
32
+ * the rendered-plist comparison in autostartStatus a moving target — the exact
33
+ * shape that made every Windows machine read "needs repair" in BLI-2541.
34
+ *
35
+ * So: copy the last `maxBytes` of the file aside, then `truncate(path, 0)`.
36
+ * Same inode, so launchd's fd stays valid and its next write lands at offset 0
37
+ * of a file operators can read. The tail is capped rather than copied whole, so
38
+ * the archives cannot themselves become the disk problem — worst case on disk
39
+ * is (keep + 1) x maxBytes per stream.
40
+ */
41
+ export const DEFAULT_LOG_ROTATION_MAX_BYTES = 20 * 1024 * 1024;
42
+ export const DEFAULT_LOG_ROTATION_KEEP = 3;
43
+ /** The two files launchd writes for the scheduled tick. */
44
+ export const ROTATED_LOG_NAMES = ["sync.log", "sync.err.log"];
45
+ /**
46
+ * Caps the scheduled tick's own logs. Never throws: a rotation failure is data
47
+ * for the caller to log, never a reason a sync does not run.
48
+ */
49
+ export async function rotateCollectorLogs(paths, options = {}) {
50
+ const maxBytes = options.maxBytes ?? DEFAULT_LOG_ROTATION_MAX_BYTES;
51
+ const keep = options.keep ?? DEFAULT_LOG_ROTATION_KEEP;
52
+ const names = options.names ?? ROTATED_LOG_NAMES;
53
+ const result = { rotated: [], failures: [] };
54
+ for (const name of names) {
55
+ const filePath = path.join(paths.state_dir, name);
56
+ const size = await fs
57
+ .stat(filePath)
58
+ .then((info) => (info.isFile() ? info.size : null))
59
+ .catch(() => null);
60
+ if (size === null || size <= maxBytes)
61
+ continue;
62
+ try {
63
+ const kept = await rotateOne(filePath, maxBytes, keep);
64
+ result.rotated.push({
65
+ name,
66
+ bytes_before: size,
67
+ bytes_kept: kept,
68
+ archives: keep,
69
+ });
70
+ }
71
+ catch (error) {
72
+ result.failures.push({
73
+ name,
74
+ reason: error instanceof Error && error.message
75
+ ? `rotation_failed:${error.code ?? "unknown"}`
76
+ : "rotation_failed:unknown",
77
+ });
78
+ }
79
+ }
80
+ return result;
81
+ }
82
+ async function rotateOne(filePath, maxBytes, keep) {
83
+ // Shift the archives down first: .2 -> .3, .1 -> .2, and drop whatever fell
84
+ // off the end. Done before the copy so a crash mid-rotation loses an old
85
+ // archive, never the live log.
86
+ await fs.rm(`${filePath}.${keep}`, { force: true });
87
+ for (let index = keep - 1; index >= 1; index -= 1) {
88
+ await fs
89
+ .rename(`${filePath}.${index}`, `${filePath}.${index + 1}`)
90
+ .catch(() => undefined);
91
+ }
92
+ const handle = await fs.open(filePath, "r+");
93
+ try {
94
+ const info = await handle.stat();
95
+ const start = Math.max(0, info.size - maxBytes);
96
+ const buffer = Buffer.allocUnsafe(info.size - start);
97
+ await handle.read(buffer, 0, buffer.length, start);
98
+ // Drop the partial first line so the archive never opens mid-JSON.
99
+ const newline = start > 0 ? buffer.indexOf(0x0a) : -1;
100
+ const tail = newline >= 0 ? buffer.subarray(newline + 1) : buffer;
101
+ await fs.writeFile(`${filePath}.1`, tail);
102
+ // The load-bearing line: same inode, so launchd's held fd keeps working.
103
+ await handle.truncate(0);
104
+ return tail.length;
105
+ }
106
+ finally {
107
+ await handle.close();
108
+ }
109
+ }
110
+ /**
111
+ * Rotates and says what happened, on both branches. Safe to call every tick:
112
+ * the size check is one stat per file and a rotation is rare.
113
+ */
114
+ export async function rotateCollectorLogsBestEffort(paths, options = {}) {
115
+ let result = { rotated: [], failures: [] };
116
+ try {
117
+ result = await rotateCollectorLogs(paths, options);
118
+ }
119
+ catch (error) {
120
+ result = {
121
+ rotated: [],
122
+ failures: [
123
+ {
124
+ name: "*",
125
+ reason: `rotation_threw:${error?.code ?? "unknown"}`,
126
+ },
127
+ ],
128
+ };
129
+ }
130
+ // Metadata only: file names (fixed strings) and byte counts, never a path
131
+ // and never a line of content.
132
+ for (const rotated of result.rotated) {
133
+ console.error("[log-rotation] capped a scheduled-tick log", JSON.stringify({
134
+ file: rotated.name,
135
+ bytes_before: rotated.bytes_before,
136
+ bytes_kept: rotated.bytes_kept,
137
+ archives_kept: rotated.archives,
138
+ }));
139
+ }
140
+ for (const failure of result.failures) {
141
+ console.error("[log-rotation] could not cap a scheduled-tick log", JSON.stringify({ file: failure.name, reason: failure.reason }));
142
+ }
143
+ return result;
144
+ }
@@ -4,6 +4,23 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
6
6
  export const COLLECTION_ROOT_REQUIRED = "collection_root_required";
7
+ /**
8
+ * No approved collection root, so there is nothing this machine may look at.
9
+ *
10
+ * A type rather than a message prefix (BLI-3551): the health-receipt classifier
11
+ * used to recognise this by searching the error text, which is the same habit
12
+ * that filed `session_report_unposted` as an auth failure. The message is
13
+ * unchanged — `collection_root_required: <what to do about it>` — so every
14
+ * operator-facing string and every test that reads one still matches; what
15
+ * changed is that the classifier reads the type.
16
+ */
17
+ export class CollectionRootRequiredError extends Error {
18
+ reason = COLLECTION_ROOT_REQUIRED;
19
+ constructor(detail) {
20
+ super(`${COLLECTION_ROOT_REQUIRED}: ${detail}`);
21
+ this.name = "CollectionRootRequiredError";
22
+ }
23
+ }
7
24
  export function homeRootConsentPrompt(homeDirInput) {
8
25
  const homeDir = path.resolve(homeDirInput ?? os.homedir());
9
26
  return `You're in your home folder (${homeDir}).\n Sync ALL projects on this machine? Every git repo under here gets captured now and in the future.\n This is a work machine — exclude personal projects yourself if needed.\n Press Enter to sync everything, or answer n to name one folder instead. [Y/n]: `;
@@ -22,7 +39,7 @@ export async function resolveOnboardingRoots(options) {
22
39
  }
23
40
  if (hasRootInput(explicitInput) && explicit.rejected.length > 0) {
24
41
  if (!options.interactive) {
25
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${rootRejectionExplanation(explicit.rejected[0], options)}`);
42
+ throw new CollectionRootRequiredError(rootRejectionExplanation(explicit.rejected[0], options));
26
43
  }
27
44
  explainRejectedRoots(options, withoutHomeRejections(explicit.rejected));
28
45
  const homeOptIn = await promptForHomeRootOptIn(options, explicit.rejected);
@@ -54,7 +71,7 @@ export async function resolveOnboardingRoots(options) {
54
71
  return resolvedRoots(options, [homeDir], "cwd_likely_root", true);
55
72
  }
56
73
  if (!options.interactive) {
57
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${homeRootTutorial(homeDir)}`);
74
+ throw new CollectionRootRequiredError(homeRootTutorial(homeDir));
58
75
  }
59
76
  const homeOptIn = await promptForHomeRootOptIn(options, [
60
77
  { input: homeDir, reason: "home_dir" },
@@ -63,7 +80,7 @@ export async function resolveOnboardingRoots(options) {
63
80
  return homeOptIn;
64
81
  }
65
82
  if (!options.interactive) {
66
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(options)}`);
83
+ throw new CollectionRootRequiredError(missingCollectionRootMessage(options));
67
84
  }
68
85
  const cwdRoot = likelyBliRootFromCwd(cwd);
69
86
  if (cwdRoot) {
@@ -160,7 +177,7 @@ async function promptForRoots(options, message) {
160
177
  prompt.message?.(missingCollectionRootMessage(options));
161
178
  }
162
179
  }
163
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(options)}`);
180
+ throw new CollectionRootRequiredError(missingCollectionRootMessage(options));
164
181
  }
165
182
  function savedRootsPrompt(roots) {
166
183
  if (roots.length === 1) {
@@ -174,7 +191,7 @@ function savedRootsPrompt(roots) {
174
191
  }
175
192
  function requirePrompt(options) {
176
193
  if (!options.prompt) {
177
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: interactive prompt unavailable.`);
194
+ throw new CollectionRootRequiredError(`interactive prompt unavailable.`);
178
195
  }
179
196
  return options.prompt;
180
197
  }
@@ -269,7 +286,7 @@ async function promptForDeclinedHomeRoot(options, homeDir) {
269
286
  break;
270
287
  }
271
288
  prompt.message?.(HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE);
272
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE}`);
289
+ throw new CollectionRootRequiredError(HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE);
273
290
  }
274
291
  function resolveRootInputPath(input, homeDir, cwd) {
275
292
  const expanded = input === "~" || /^~[\\/]/u.test(input)
@@ -4,7 +4,6 @@ import path from "node:path";
4
4
  import { readRawEvidenceCursor } from "./cursors/raw-evidence-cursor.js";
5
5
  import { readRawEvidenceStagingState } from "./raw-evidence-staging.js";
6
6
  const RAW_EVIDENCE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
7
- const SYNC_LOG_MAX_BYTES = 50 * 1024 * 1024;
8
7
  /**
9
8
  * A staging directory belongs to one in-flight collection pass. Anything this
10
9
  * old is the remains of a crash or a kill, never live work — the longest sync
@@ -17,22 +16,12 @@ const STAGING_ORPHAN_MS = 6 * 60 * 60 * 1000;
17
16
  const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
18
17
  export async function runRawEvidenceLocalGc(paths, env = process.env, now = new Date()) {
19
18
  if (env["COCKPIT_DISABLE_GC"] === "1") {
20
- return {
21
- skipped: true,
22
- removed_dirs: 0,
23
- freed_bytes: 0,
24
- rotated_sync_log: false,
25
- };
19
+ return { skipped: true, removed_dirs: 0, freed_bytes: 0 };
26
20
  }
27
21
  const throttleMarker = path.join(paths.state_dir, ".last-raw-evidence-gc");
28
22
  const lastRun = await fs.stat(throttleMarker).catch(() => null);
29
23
  if (lastRun && now.getTime() - lastRun.mtimeMs < GC_MIN_INTERVAL_MS) {
30
- return {
31
- skipped: true,
32
- removed_dirs: 0,
33
- freed_bytes: 0,
34
- rotated_sync_log: false,
35
- };
24
+ return { skipped: true, removed_dirs: 0, freed_bytes: 0 };
36
25
  }
37
26
  await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
38
27
  await fs.writeFile(throttleMarker, now.toISOString()).catch(() => undefined);
@@ -60,11 +49,17 @@ export async function runRawEvidenceLocalGc(paths, env = process.env, now = new
60
49
  removedDirs += 1;
61
50
  freedBytes += inspection.byteSize;
62
51
  }
52
+ // Log capping used to live here, and that is why it did not work: this GC
53
+ // is throttled to once a day and skipped entirely by COCKPIT_DISABLE_GC,
54
+ // while sync.log grows ~8 MB an hour on an active machine (measured on the
55
+ // reference Mac, 2026-09-04: 219,866,695 bytes with the last GC 19 hours
56
+ // earlier). A daily truncate-to-zero of a file that reaches ~190 MB a day is
57
+ // not a cap, and sync.err.log had no cap at all. One owner now, running in
58
+ // the tick preamble: log-rotation.ts (BLI-3553).
63
59
  return {
64
60
  skipped: false,
65
61
  removed_dirs: removedDirs,
66
62
  freed_bytes: freedBytes,
67
- rotated_sync_log: await rotateSyncLog(paths),
68
63
  };
69
64
  }
70
65
  export function rawEvidenceGcSummary(result) {
@@ -288,15 +283,6 @@ async function listFiles(root) {
288
283
  }
289
284
  return files;
290
285
  }
291
- async function rotateSyncLog(paths) {
292
- const syncLog = path.join(paths.state_dir, "sync.log");
293
- const info = await fs.stat(syncLog).catch(() => null);
294
- if (!info?.isFile() || info.size <= SYNC_LOG_MAX_BYTES)
295
- return false;
296
- await fs.truncate(syncLog, 0).catch(() => undefined);
297
- const after = await fs.stat(syncLog).catch(() => null);
298
- return after?.isFile() === true && after.size === 0;
299
- }
300
286
  async function exists(target) {
301
287
  return fs.stat(target).then(() => true, () => false);
302
288
  }
@@ -127,6 +127,7 @@ export async function runScheduledSelfUpdate(paths, deps, options = {}) {
127
127
  reason: "updated",
128
128
  target_version: targetVersion ?? installedVersion,
129
129
  installed_version: installedVersion,
130
+ previous_version: deps.currentVersion,
130
131
  ...forcedFields,
131
132
  };
132
133
  }
@@ -0,0 +1,160 @@
1
+ import path from "node:path";
2
+ import { lstat, realpath } from "node:fs/promises";
3
+ import { DARWIN_LOGIN_SHELL } from "./autostart-node-path.js";
4
+ const NOT_CHECKED = {
5
+ detected: false,
6
+ reason: "probe_unavailable",
7
+ other_bin: null,
8
+ install_count: 0,
9
+ paths: [],
10
+ };
11
+ export async function detectSecondCockpitInstall(options) {
12
+ const platform = options.platform ?? process.platform;
13
+ const lookup = platform === "win32"
14
+ ? { cmd: "where.exe", args: ["cockpit"] }
15
+ : platform === "darwin"
16
+ ? { cmd: DARWIN_LOGIN_SHELL, args: ["-lc", "which -a cockpit"] }
17
+ : null;
18
+ if (!lookup)
19
+ return NOT_CHECKED;
20
+ const result = await options
21
+ .exec(lookup.cmd, lookup.args)
22
+ .catch(() => null);
23
+ if (!result) {
24
+ return { ...NOT_CHECKED, reason: "probe_failed" };
25
+ }
26
+ const candidates = parseCandidatePaths(result.stdout, platform);
27
+ if (candidates.length === 0) {
28
+ // `which`/`where` found nothing: this process was started by an absolute
29
+ // path (the scheduler always does) and the operator's PATH has no cockpit
30
+ // at all. Nothing to compare against, and nothing to complain about.
31
+ return {
32
+ detected: false,
33
+ reason: result.code === 0 ? "probe_failed" : "single_install",
34
+ other_bin: null,
35
+ install_count: 0,
36
+ paths: [],
37
+ };
38
+ }
39
+ const byDirectory = new Map();
40
+ for (const candidate of candidates) {
41
+ const key = directoryKey(candidate, platform);
42
+ if (!byDirectory.has(key))
43
+ byDirectory.set(key, candidate);
44
+ }
45
+ const installs = [...byDirectory.values()];
46
+ if (installs.length > 1) {
47
+ const other = await pickOther(installs, options, platform);
48
+ return {
49
+ detected: true,
50
+ reason: "second_install_detected",
51
+ other_bin: platformBasename(other ?? installs[installs.length - 1] ?? "", platform),
52
+ install_count: installs.length,
53
+ paths: installs,
54
+ };
55
+ }
56
+ // Exactly one on PATH. It can still be a DIFFERENT install from the one
57
+ // running now — the scheduler holds an absolute path to whichever prefix it
58
+ // was registered with. Only decidable where the bin is a symlink into the
59
+ // package (npm's POSIX layout); a Windows `.cmd` shim is a generated script,
60
+ // not a link, so there is nothing to resolve and the check stays quiet
61
+ // rather than guessing.
62
+ const only = installs[0];
63
+ if (!only || platform === "win32") {
64
+ return single(installs);
65
+ }
66
+ const ownEntryPoint = options.cliEntryPoint ?? process.argv[1] ?? "";
67
+ if (!ownEntryPoint)
68
+ return single(installs);
69
+ const isLink = await (options.isSymbolicLink ?? defaultIsSymbolicLink)(only);
70
+ if (!isLink)
71
+ return single(installs);
72
+ const resolve = options.realpath ?? ((candidate) => realpath(candidate));
73
+ const [linked, own] = await Promise.all([
74
+ resolve(only).catch(() => null),
75
+ resolve(ownEntryPoint).catch(() => ownEntryPoint),
76
+ ]);
77
+ if (!linked || linked === own)
78
+ return single(installs);
79
+ return {
80
+ detected: true,
81
+ reason: "second_install_detected",
82
+ other_bin: platformBasename(only, platform),
83
+ install_count: 2,
84
+ paths: [only, ownEntryPoint],
85
+ };
86
+ }
87
+ function single(installs) {
88
+ return {
89
+ detected: false,
90
+ reason: "single_install",
91
+ other_bin: null,
92
+ install_count: installs.length,
93
+ paths: installs,
94
+ };
95
+ }
96
+ /**
97
+ * Which of several installs is NOT the one this process came from.
98
+ *
99
+ * npm lays a global install out as `<prefix>/bin/cockpit` beside
100
+ * `<prefix>/lib/node_modules/...`, and on Windows as `<prefix>\cockpit.cmd`
101
+ * beside `<prefix>\node_modules\...`. Either way the bin's GRANDPARENT is the
102
+ * prefix and our own entry point lives under it, so anything under a different
103
+ * prefix is the other install. If nothing matches — an unusual layout — name
104
+ * the last candidate rather than nothing: an operator can act on a named bin
105
+ * and cannot act on silence.
106
+ */
107
+ async function pickOther(installs, options, platform) {
108
+ const entryPoint = options.cliEntryPoint ?? process.argv[1] ?? "";
109
+ const other = installs.find((candidate) => !isUnderPrefixOf(candidate, entryPoint, platform));
110
+ return other ?? installs[installs.length - 1] ?? null;
111
+ }
112
+ function isUnderPrefixOf(candidateBin, entryPoint, platform) {
113
+ if (!entryPoint)
114
+ return false;
115
+ const platformPath = platform === "win32" ? path.win32 : path.posix;
116
+ const prefix = platformPath.dirname(platformPath.dirname(candidateBin));
117
+ if (!prefix || prefix === "." || prefix === platformPath.sep)
118
+ return false;
119
+ const normalize = (value) => platform === "win32" ? value.toLowerCase() : value;
120
+ const bounded = prefix.endsWith(platformPath.sep)
121
+ ? prefix
122
+ : prefix + platformPath.sep;
123
+ return normalize(entryPoint).startsWith(normalize(bounded));
124
+ }
125
+ function platformBasename(candidate, platform) {
126
+ return platform === "win32"
127
+ ? path.win32.basename(candidate)
128
+ : path.posix.basename(candidate);
129
+ }
130
+ function parseCandidatePaths(stdout, platform) {
131
+ return stdout
132
+ .split(/\r?\n/u)
133
+ .map((line) => line.trim())
134
+ .filter((line) => {
135
+ if (!line)
136
+ return false;
137
+ // zsh's `which -a` also prints shell-function bodies and
138
+ // "cockpit not found"; only absolute paths are installs.
139
+ return platform === "win32"
140
+ ? /^[A-Za-z]:\\/u.test(line)
141
+ : line.startsWith("/");
142
+ });
143
+ }
144
+ function directoryKey(candidate, platform) {
145
+ if (!candidate)
146
+ return "";
147
+ const directory = platform === "win32"
148
+ ? path.win32.dirname(candidate).toLowerCase()
149
+ : path.posix.dirname(candidate);
150
+ return directory;
151
+ }
152
+ async function defaultIsSymbolicLink(candidate) {
153
+ return lstat(candidate).then((info) => info.isSymbolicLink(), () => false);
154
+ }
155
+ /** One line for a receipt or a log: named, path-free. */
156
+ export function secondInstallReason(finding) {
157
+ return finding.detected
158
+ ? `second_install_detected ${finding.other_bin ?? "cockpit"}`
159
+ : finding.reason;
160
+ }