@bli-cockpit/cli 0.2.208 → 0.2.210

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,16 @@
1
1
  import { containsSecretLikeContent, redactSecretLikeContent, scannedCleanRedactionMetadata, } from "@bli-cockpit/telemetry-core";
2
2
  import { sha256 } from "./raw-evidence-keys.js";
3
3
  import { describeError } from "../health-detail.js";
4
+ /**
5
+ * The one line a whole file becomes when masking its secret-bearing lines still leaves something
6
+ * secret-looking. It carries none of the file's content, so a reader that compares sizes or hashes
7
+ * against it (`cockpit sources check`) must treat it as "no redacted form", never as the file.
8
+ */
9
+ export const WHOLE_FILE_MASK_LINE = "[REDACTED_LINE:secret_redaction_failed]\n";
10
+ /** True when these upload bytes are the whole-file mask, not a redacted copy of the content. */
11
+ export function isWholeFileMask(bytes) {
12
+ return Buffer.from(bytes).toString("utf8") === WHOLE_FILE_MASK_LINE;
13
+ }
4
14
  /** BLI-3238: a crashed redactor has to say so; see the catch below. */
5
15
  export function sanitizeTextEvidenceForUpload(options) {
6
16
  const originalBytes = options.originalBytes ?? Buffer.from(options.text, "utf8");
@@ -75,7 +85,7 @@ function maskFileWithSecretLikeName(input) {
75
85
  ? input.redactionResult.text
76
86
  : input.text;
77
87
  const safeText = containsSecretLikeContent(sanitizedText)
78
- ? "[REDACTED_LINE:secret_redaction_failed]\n"
88
+ ? WHOLE_FILE_MASK_LINE
79
89
  : sanitizedText;
80
90
  const sanitizedBytes = Buffer.from(safeText, "utf8");
81
91
  return {
@@ -101,7 +111,7 @@ function maskSecretBearingLinesOnly(input) {
101
111
  });
102
112
  let maskedText = maskSecretBearingLines(input.text, redaction);
103
113
  if (containsSecretLikeContent(maskedText)) {
104
- maskedText = "[REDACTED_LINE:secret_redaction_failed]\n";
114
+ maskedText = WHOLE_FILE_MASK_LINE;
105
115
  }
106
116
  const sanitizedBytes = Buffer.from(maskedText, "utf8");
107
117
  return {
@@ -136,11 +136,15 @@ async function deliverPendingInstallEvents(options, paths, deviceToken) {
136
136
  let delivered = 0;
137
137
  let observedMinCliVersion = null;
138
138
  for (let offset = 0; offset < pending.length; offset += 5) {
139
- await Promise.all(pending.slice(offset, offset + 5).map((entry) => deliverInstallEventEntry(options.io, paths, entry, deviceToken, failures).then((floor) => {
140
- if (floor != null)
141
- observedMinCliVersion = floor;
142
- if (floor !== undefined)
143
- delivered += 1;
139
+ await Promise.all(pending.slice(offset, offset + 5).map((entry) => deliverInstallEventEntry(options.io, paths, entry, deviceToken, failures).then((receipt) => {
140
+ if (receipt === undefined)
141
+ return;
142
+ if (receipt.floor != null)
143
+ observedMinCliVersion = receipt.floor;
144
+ if (receipt.imageHandlingMode !== undefined && options.observed) {
145
+ options.observed.imageHandlingMode = receipt.imageHandlingMode;
146
+ }
147
+ delivered += 1;
144
148
  })));
145
149
  }
146
150
  if (delivered > 0) {
@@ -165,9 +169,9 @@ async function deliverInstallEventEntry(io, paths, entry, deviceToken, failures)
165
169
  const timeout = setTimeout(() => controller.abort(), 5_000);
166
170
  try {
167
171
  const response = await postInstallEventEntry(io, entry, deviceToken, controller.signal);
168
- const floor = await readObservedMinCliVersion(response);
172
+ const receipt = await readReceiptReply(response);
169
173
  await removeInstallEventEntry(paths, entry.outbox_id);
170
- return floor;
174
+ return receipt;
171
175
  }
172
176
  catch (error) {
173
177
  await keepFailedInstallEventEntry(paths, entry, error, failures);
@@ -193,18 +197,25 @@ async function postInstallEventEntry(io, entry, deviceToken, signal) {
193
197
  throw new Error(`http_${response.status}`);
194
198
  return response;
195
199
  }
196
- async function readObservedMinCliVersion(response) {
200
+ /**
201
+ * The reply carries two things the tick listens for: the server-published `min_cli_version` floor
202
+ * (BLI-2678) and this machine's `image_handling_mode` (BLI-17273; undefined when the reply names
203
+ * none, validated by the caller and never here).
204
+ */
205
+ async function readReceiptReply(response) {
197
206
  const receipt = (await response.json().catch((error) => {
198
- // This reply carries the server-published `min_cli_version` floor
199
- // (BLI-2678). A body that will not parse means the floor is not
200
- // observed on this tick and the forced-update path silently does
201
- // nothing — while the 2xx above says the receipt landed fine.
202
- console.error("[install-receipts] receipt body unreadable; no min_cli_version observed", JSON.stringify({ reason: "receipt_body_unreadable", http_status: response.status, ...describeError(error) }));
207
+ // A body that will not parse means the floor and the image mode are not observed on this tick
208
+ // (the forced-update path silently does nothing, the machine keeps its cached mode) while the
209
+ // 2xx above says the receipt landed fine.
210
+ console.error("[install-receipts] receipt body unreadable; no min_cli_version or image_handling_mode observed", JSON.stringify({ reason: "receipt_body_unreadable", http_status: response.status, ...describeError(error) }));
203
211
  return null;
204
212
  }));
205
- return typeof receipt?.min_cli_version === "string" && receipt.min_cli_version.trim()
206
- ? receipt.min_cli_version.trim()
207
- : null;
213
+ return {
214
+ floor: typeof receipt?.min_cli_version === "string" && receipt.min_cli_version.trim()
215
+ ? receipt.min_cli_version.trim()
216
+ : null,
217
+ imageHandlingMode: receipt?.image_handling_mode,
218
+ };
208
219
  }
209
220
  async function keepFailedInstallEventEntry(paths, entry, error, failures) {
210
221
  const failureReason = classifyInstallTelemetryError(error);
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.208");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.210");
25
25
  return 0;
26
26
  }
27
27
 
@@ -138,6 +138,10 @@ export function renderSwitches(body) {
138
138
  if (typeof body.readFailureReason === "string") {
139
139
  lines.push(`${INDENT}values could not be read: ${body.readFailureReason}`);
140
140
  }
141
+ // "No machine is on slim" must never hide "could not ask" (BLI-17273).
142
+ if (typeof body.collectorImageModeReadFailureReason === "string") {
143
+ lines.push(`${INDENT}machine image modes could not be listed: ${body.collectorImageModeReadFailureReason}`);
144
+ }
141
145
  return lines;
142
146
  }
143
147
  export function renderModelRouting(body) {
@@ -1,11 +1,12 @@
1
1
  import { writeLine } from "./cli-io.js";
2
2
  import { readDescribedImageKeys } from "../image-description-state.js";
3
+ import { resolveImageHandlingMode } from "../image-mode-state.js";
3
4
  import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
4
5
  import { fetchTranscriptObjects } from "../evidence-transcripts-client.js";
5
6
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
6
7
  import { discoverLocalTranscripts } from "./sources-discovery.js";
7
8
  import { fileFacts, judgeSource } from "./sources-verdict.js";
8
- const VERDICTS = ["stored_full", "stored_partial", "not_stored"];
9
+ const VERDICTS = ["stored_full", "stored_slim", "stored_partial", "not_stored"];
9
10
  const DAY_MS = 24 * 60 * 60 * 1000;
10
11
  export async function runSourcesCheck(command, io, now = new Date()) {
11
12
  const startedAt = Date.now();
@@ -30,13 +31,18 @@ export async function runSourcesCheck(command, io, now = new Date()) {
30
31
  claude_code: await readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME, sessionsOnly: true }),
31
32
  };
32
33
  const describedKeys = new Set(await readDescribedImageKeys(paths.state_dir));
34
+ // BLI-17273: judge against THIS machine's mode (the cache the sync-start receipt wrote, else the
35
+ // fleet default), never the bare constant. A machine that runs or ever ran slim holds slim server
36
+ // copies, which must never read as safe to delete.
37
+ const imageMode = await resolveImageHandlingMode(paths.state_dir);
38
+ console.error("[collector sources] judging against this machine's image mode", JSON.stringify({ reason: `image_mode_from_${imageMode.source}`, mode: imageMode.mode, slim_aware: imageMode.slimAware }));
33
39
  const report = emptyReport(command, remote, local.newer_count, local.file_read_failed + local.directory_read_failed);
34
40
  for (const file of local.files) {
35
41
  const judged = await judgeSource({
36
42
  file,
37
43
  remotes: remote.files.get(file.key) ?? [],
38
44
  cursorEntry: file.kind === "claude_subagent" ? null : (cursors[file.source].sessions[file.session_id] ?? null),
39
- facts: fileFacts(file, undefined, { describedKeys }),
45
+ facts: fileFacts(file, undefined, { mode: imageMode.mode, describedKeys, slimAware: imageMode.slimAware }),
40
46
  }).catch((error) => {
41
47
  report.local.unreadable += 1;
42
48
  console.error("[collector sources] a transcript could not be judged; left out of the answer", JSON.stringify({ reason: "file_unreadable", kind: file.kind, byte_size: file.byte_size, error_name: error?.name ?? "Error" }));
@@ -71,6 +77,7 @@ export async function runSourcesCheck(command, io, now = new Date()) {
71
77
  newer_skipped: report.local.newer_skipped,
72
78
  unreadable: report.local.unreadable,
73
79
  stored_full: report.counts.stored_full,
80
+ stored_slim: report.counts.stored_slim,
74
81
  stored_partial: report.counts.stored_partial,
75
82
  not_stored: report.counts.not_stored,
76
83
  reasons: report.reasons,
@@ -93,6 +100,7 @@ function emptyReport(command, remote, newerSkipped, unreadable) {
93
100
  local: { examined: 0, newer_skipped: newerSkipped, unreadable },
94
101
  counts: {
95
102
  stored_full: { files: 0, bytes: 0 },
103
+ stored_slim: { files: 0, bytes: 0 },
96
104
  stored_partial: { files: 0, bytes: 0 },
97
105
  not_stored: { files: 0, bytes: 0 },
98
106
  },
@@ -115,7 +123,10 @@ export function sourcesCheckLines(report) {
115
123
  const lines = [
116
124
  `Tower sources check: ${scope} on this machine`,
117
125
  ` stored in full ${report.counts.stored_full.files} file(s), ${mb(report.counts.stored_full.bytes)} MB`,
118
- ` stored in part ${report.counts.stored_partial.files} file(s), ${mb(report.counts.stored_partial.bytes)} MB`,
126
+ ...(report.counts.stored_slim.files > 0
127
+ ? [` stored slim ${report.counts.stored_slim.files} file(s), ${mb(report.counts.stored_slim.bytes)} MB (KEEP: Tower holds the slim form, the image bytes are only here)`]
128
+ : []),
129
+ ` stored in part ${report.counts.stored_partial.files} file(s), ${mb(report.counts.stored_partial.bytes)} MB`,
119
130
  ` not stored ${report.counts.not_stored.files} file(s), ${mb(report.counts.not_stored.bytes)} MB`,
120
131
  ];
121
132
  const reasons = Object.entries(report.reasons).sort((a, b) => b[1] - a[1]);
@@ -129,7 +140,7 @@ export function sourcesCheckLines(report) {
129
140
  if (report.status === "incomplete") {
130
141
  lines.push(` INCOMPLETE Tower's list was cut short (${report.reason}); "not stored" and "stored in part" may be understated. Run it again.`);
131
142
  }
132
- lines.push("Tower never deletes these files. `--json` lists every file with its path and verdict. Only a \"stored in full\" file is safe to delete by your own hand; see docs/runbooks/freeing-disk-on-a-collector-machine.md.");
143
+ lines.push("Tower never deletes these files. `--json` lists every file with its path and verdict. Only a \"stored in full\" file is safe to delete by your own hand (never \"stored slim\"); see docs/runbooks/freeing-disk-on-a-collector-machine.md.");
133
144
  return lines;
134
145
  }
135
146
  function mb(bytes) {
@@ -18,6 +18,14 @@
18
18
  * grow after its upload, else `remote_smaller`), or its size
19
19
  * is not recorded (`remote_size_unknown`).
20
20
  *
21
+ * stored_slim (BLI-17273) the copy Tower holds is the SLIM form of the file: images
22
+ * described and replaced by a marker, duplicate copies and bookkeeping
23
+ * stripped. Complete for every reader, but the local file holds the only
24
+ * copy of the image bytes, so a slim copy is NEVER `stored_full`: keep the
25
+ * file. Only judged for a machine that runs, or ever ran, slim
26
+ * (`image-mode-state.ts`); every other machine reads exactly as before.
27
+ * The rules are on `judgeWithSlimCopy` below.
28
+ *
21
29
  * A file too large to redact in memory is compared by its raw size
22
30
  * (`remote_at_least_file_size`). A file whose redaction would crash to the stub
23
31
  * is compared the same way, never by the stub's 42 bytes.
@@ -28,7 +36,7 @@ import crypto from "node:crypto";
28
36
  import fs from "node:fs/promises";
29
37
  import path from "node:path";
30
38
  import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, REDACTION_STUB_SHA256, } from "@bli-cockpit/telemetry-core";
31
- import { sanitizeTextEvidenceForUpload } from "../adapters/raw-evidence-sanitize.js";
39
+ import { isWholeFileMask, sanitizeTextEvidenceForUpload } from "../adapters/raw-evidence-sanitize.js";
32
40
  import { isSecretLikePath } from "../adapters/raw-evidence-keys.js";
33
41
  import { IMAGE_HANDLING_MODE } from "@bli-cockpit/telemetry-core";
34
42
  import { uploadFormOfTranscript } from "../image-description-session.js";
@@ -47,7 +55,15 @@ export async function judgeSource(input) {
47
55
  reason,
48
56
  remote_byte_size: best,
49
57
  });
50
- if (cursorEntry &&
58
+ const split = input.facts.slimSplit ? await input.facts.slimSplit() : null;
59
+ if (split)
60
+ return judgeWithSlimCopy({ file, cursorEntry, facts: input.facts, held, best, split });
61
+ // A slim-aware machine with a null split may have lost its described-image list: the server copy
62
+ // can be slim while this side sees nothing to slim. The cursor cannot tell, so judge only against
63
+ // the whole form below (a slim copy then reads stored_partial/remote_smaller: keep the file).
64
+ const trustCursor = input.facts.slimAware !== true;
65
+ if (trustCursor &&
66
+ cursorEntry &&
51
67
  !cursorEntry.stubbed &&
52
68
  cursorEntry.uploaded_object_key &&
53
69
  held.some((remote) => remote.object_key === cursorEntry.uploaded_object_key) &&
@@ -56,7 +72,12 @@ export async function judgeSource(input) {
56
72
  cursorEntry.file_hash_sha256 === (await input.facts.rawSha256())) {
57
73
  return full("unchanged_since_upload");
58
74
  }
59
- const sanitized = await input.facts.sanitized();
75
+ if (!trustCursor) {
76
+ console.error("[sources-verdict] slim-aware machine, no slim split: cursor shortcut skipped", JSON.stringify({ reason: "slim_aware_split_null", has_cursor: cursorEntry !== null }));
77
+ }
78
+ // Slim-aware with no split: only the WHOLE form is evidence of a whole server copy. When it
79
+ // stubs (null) the raw size below answers, so a slim-only copy is never stored_full.
80
+ const sanitized = !trustCursor && input.facts.wholeForm ? await input.facts.wholeForm() : await input.facts.sanitized();
60
81
  if (sanitized) {
61
82
  if (held.some((remote) => remote.content_hash_sha256 === sanitized.sha256))
62
83
  return full("content_hash_match");
@@ -76,16 +97,91 @@ export async function judgeSource(input) {
76
97
  cursorEntry.uploaded_byte_size < file.byte_size;
77
98
  return partial(grewAfterUpload ? "changed_since_upload" : "remote_smaller");
78
99
  }
100
+ /**
101
+ * BLI-17273. The file has two redacted forms (whole and slim) and this machine has run slim, so a
102
+ * copy Tower holds may be either. The rule that keeps local image bytes safe: only evidence that the
103
+ * WHOLE form is on the server makes `stored_full`; a copy that is the slim form, or cannot be told
104
+ * apart from it, is `stored_slim` (keep the file).
105
+ *
106
+ * hash equals the whole form stored_full content_hash_match
107
+ * hash equals the slim form stored_slim content_hash_match
108
+ * largest copy is at least the whole form's size stored_full remote_at_least_sanitized_size
109
+ * cursor says its upload is current, file unchanged stored_slim unchanged_since_upload
110
+ * (the upload may be either form and its hash matches neither, so it is not proven whole)
111
+ * largest copy is at least the slim form's size stored_slim remote_at_least_slim_size
112
+ * size never recorded stored_partial remote_size_unknown
113
+ * otherwise stored_partial changed_since_upload / remote_smaller
114
+ *
115
+ * A stale whole copy cannot outrank the slim size: a copy smaller than the whole form is never
116
+ * `stored_full` here, however it compares to the slim form.
117
+ */
118
+ async function judgeWithSlimCopy(input) {
119
+ const { file, cursorEntry, held, best, split } = input;
120
+ const result = (verdict, reason) => ({
121
+ verdict,
122
+ reason,
123
+ remote_byte_size: best,
124
+ });
125
+ if (held.some((remote) => remote.content_hash_sha256 === split.full.sha256))
126
+ return result("stored_full", "content_hash_match");
127
+ if (held.some((remote) => remote.content_hash_sha256 === split.slim.sha256))
128
+ return result("stored_slim", "content_hash_match");
129
+ if (best !== null && best >= split.full.byte_size)
130
+ return result("stored_full", "remote_at_least_sanitized_size");
131
+ if (cursorEntry &&
132
+ !cursorEntry.stubbed &&
133
+ cursorEntry.uploaded_object_key &&
134
+ held.some((remote) => remote.object_key === cursorEntry.uploaded_object_key) &&
135
+ cursorEntry.uploaded_byte_size === file.byte_size &&
136
+ cursorEntry.file_hash_sha256 &&
137
+ cursorEntry.file_hash_sha256 === (await input.facts.rawSha256())) {
138
+ return result("stored_slim", "unchanged_since_upload");
139
+ }
140
+ if (best === null)
141
+ return result("stored_partial", "remote_size_unknown");
142
+ if (best >= split.slim.byte_size)
143
+ return result("stored_slim", "remote_at_least_slim_size");
144
+ const grewAfterUpload = cursorEntry?.uploaded_object_key != null &&
145
+ cursorEntry.uploaded_byte_size != null &&
146
+ cursorEntry.uploaded_byte_size < file.byte_size;
147
+ return result("stored_partial", grewAfterUpload ? "changed_since_upload" : "remote_smaller");
148
+ }
79
149
  /**
80
150
  * Reads the file at most once, on first ask, and redacts it exactly as the
81
151
  * upload does (`raw-evidence-transcript-file.ts`): same sanitizer, same
82
152
  * secret-like-name rule, same byte cap.
83
153
  */
84
154
  export function fileFacts(file, maxBytes = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
85
- /** Tests only set `mode`. Production reads the one switch. `describedKeys` is the collector's described-image state. */
155
+ /**
156
+ * `mode` is this machine's mode (`image-mode-state.ts`; absent means the fleet default
157
+ * `IMAGE_HANDLING_MODE`). `describedKeys` is the collector's described-image state. `slimAware`
158
+ * (BLI-17273) is true for a machine that runs or ever ran slim: its server copies may be slim, so
159
+ * `slimSplit()` answers; absent it defaults to "the mode is drop".
160
+ */
86
161
  upload = {}) {
87
162
  const mode = upload.mode ?? IMAGE_HANDLING_MODE;
88
163
  const describedKeys = upload.describedKeys ?? new Set();
164
+ const slimAware = upload.slimAware ?? mode === "describe_and_drop";
165
+ const redactForUpload = (text, original) => {
166
+ const result = sanitizeTextEvidenceForUpload({
167
+ text,
168
+ originalBytes: text === original.toString("utf8") ? original : Buffer.from(text, "utf8"),
169
+ redactedFields: [`${file.kind === "codex_rollout" ? "codex_jsonl" : file.kind === "claude_main" ? "claude_jsonl" : "claude_jsonl_sidecar"}.body`],
170
+ secretLikeFileName: isSecretLikePath(path.basename(file.file_path)),
171
+ });
172
+ const hash = sha256(result.bytes);
173
+ if (hash === REDACTION_STUB_SHA256)
174
+ return null;
175
+ // A whole-file mask is one 40-byte line, not the content: every copy Tower holds would look
176
+ // "at least as large" and read stored_full. No redacted form, so the raw size answers instead.
177
+ if (isWholeFileMask(result.bytes)) {
178
+ console.error("[sources-verdict] redacted form is the whole-file mask: judged by raw size", JSON.stringify({ reason: "whole_file_mask", source: file.source, file_bytes: file.byte_size }));
179
+ return null;
180
+ }
181
+ return { sha256: hash, byte_size: result.bytes.byteLength };
182
+ };
183
+ let split = null;
184
+ let whole = null;
89
185
  let bytes = null;
90
186
  const read = () => {
91
187
  bytes ??= file.byte_size > maxBytes ? Promise.resolve(null) : fs.readFile(file.file_path);
@@ -94,6 +190,7 @@ upload = {}) {
94
190
  let rawHash = null;
95
191
  let sanitized = null;
96
192
  return {
193
+ slimAware,
97
194
  rawSha256() {
98
195
  rawHash ??= read().then(async (buffer) => (buffer ? sha256(buffer) : await streamSha256(file.file_path)));
99
196
  return rawHash;
@@ -104,19 +201,33 @@ upload = {}) {
104
201
  return null;
105
202
  // Compare against what the uploader sends: in drop mode that is the slim form, not the disk bytes.
106
203
  const uploadText = uploadFormOfTranscript(buffer.toString("utf8"), file.source === "codex" ? "codex" : "claude_code", mode, describedKeys);
107
- const result = sanitizeTextEvidenceForUpload({
108
- text: uploadText,
109
- originalBytes: uploadText === buffer.toString("utf8") ? buffer : Buffer.from(uploadText, "utf8"),
110
- redactedFields: [`${file.kind === "codex_rollout" ? "codex_jsonl" : file.kind === "claude_main" ? "claude_jsonl" : "claude_jsonl_sidecar"}.body`],
111
- secretLikeFileName: isSecretLikePath(path.basename(file.file_path)),
112
- });
113
- const hash = sha256(result.bytes);
114
- if (hash === REDACTION_STUB_SHA256)
115
- return null;
116
- return { sha256: hash, byte_size: result.bytes.byteLength };
204
+ return redactForUpload(uploadText, buffer);
117
205
  });
118
206
  return sanitized;
119
207
  },
208
+ wholeForm() {
209
+ whole ??= read().then((buffer) => (buffer ? redactForUpload(buffer.toString("utf8"), buffer) : null));
210
+ return whole;
211
+ },
212
+ async slimSplit() {
213
+ if (!slimAware)
214
+ return null;
215
+ split ??= read().then((buffer) => {
216
+ if (!buffer)
217
+ return null;
218
+ const text = buffer.toString("utf8");
219
+ const slimText = uploadFormOfTranscript(text, file.source === "codex" ? "codex" : "claude_code", "describe_and_drop", describedKeys);
220
+ // Slimming changes nothing: one form, and `sanitized()` is the answer.
221
+ if (slimText === text)
222
+ return null;
223
+ const whole = redactForUpload(text, buffer);
224
+ const slim = redactForUpload(slimText, buffer);
225
+ if (!whole || !slim || whole.sha256 === slim.sha256)
226
+ return null;
227
+ return { full: whole, slim };
228
+ });
229
+ return split;
230
+ },
120
231
  };
121
232
  }
122
233
  function sha256(buffer) {
@@ -45,6 +45,7 @@ import { sendSyncHeartbeat } from "./sync-heartbeat.js";
45
45
  import { runSyncWithHealthReceipt } from "./sync-receipt.js";
46
46
  import { runAutostartSelfHealAfterSync, runMemoryInstallAfterSync, runScheduledSelfUpdateAfterSync, runStagingPruneAfterSync, } from "./sync-followups.js";
47
47
  import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
48
+ import { settleTickImageModeBestEffort } from "../image-mode-state.js";
48
49
  import { stampSyncTick } from "../sync-tick-stamp.js";
49
50
  import { startTickTimer } from "../sync-tick-timing.js";
50
51
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
@@ -137,14 +138,20 @@ async function tickDashboardUrl(command, paths) {
137
138
  * one to fall back on (BLI-2678).
138
139
  */
139
140
  async function reportTickStarted(command, io, dashboardUrl) {
140
- return reportInstallEventsBestEffort({
141
+ const observed = {};
142
+ const minCliVersion = await reportInstallEventsBestEffort({
141
143
  homeDir: command.homeDir,
142
144
  dashboardUrl,
143
145
  command: "sync",
144
146
  events: [{ step: "sync_started", status: "ok" }],
145
147
  json: command.json,
146
148
  io,
149
+ observed,
147
150
  });
151
+ // BLI-17273: the reply also names this machine's image-handling mode. Put it on file BEFORE
152
+ // collection starts (the uploader reads the file); an offline tick keeps the last value on file.
153
+ await settleTickImageModeBestEffort(getCollectorRuntimePaths(command.homeDir).state_dir, observed.imageHandlingMode);
154
+ return minCliVersion;
148
155
  }
149
156
  /** Post the receipt the run earned, whatever it says. */
150
157
  async function reportTickOutcome(command, io, dashboardUrl, completion) {
@@ -9,7 +9,8 @@
9
9
  * the describe door, a few at a time, inside a per-sync count and time budget. The state file
10
10
  * remembers what was sent, so a screenshot that appears in 40 turns, or in 3 sessions, is sent
11
11
  * once. A failure never fails the transcript.
12
- * 3. REPLACE, only when the one switch (`IMAGE_HANDLING_MODE` in telemetry-core) says
12
+ * 3. REPLACE, only when this machine's mode (set per machine from Tower, kept on file by
13
+ * `image-mode-state.ts`; the fleet default is `IMAGE_HANDLING_MODE` in telemetry-core) says
13
14
  * `describe_and_drop`: every image whose description is stored becomes its short marker. An
14
15
  * image with no stored description is KEPT. In `describe_and_keep` (the default) the text
15
16
  * comes back byte for byte as it went in.
@@ -22,6 +23,7 @@
22
23
  */
23
24
  import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_HANDLING_MODE, base64Of, findEmbeddedImages, replaceImagesWithMarkers, } from "@bli-cockpit/telemetry-core";
24
25
  import { postImageToDescribeDoor } from "./image-describe-client.js";
26
+ import { resolveImageHandlingMode } from "./image-mode-state.js";
25
27
  import { slimOrKeep, slimTranscript } from "./slim/slim-transcript.js";
26
28
  import { imageStateKey, imageRetryDelayMs, readDescribedImageKeys, readFailedImages, writeImageDescriptionState, } from "./image-description-state.js";
27
29
  /** New images sent to the door in one sync (all worktrees together). */
@@ -204,11 +206,15 @@ export async function openImageStepForSync(options) {
204
206
  if (options.budget?.images)
205
207
  return options.budget.images;
206
208
  try {
209
+ // BLI-17273: the mode is PER MACHINE: the last value Tower sent (kept on file so an offline
210
+ // tick does not flip a slim machine back), else the fleet default `IMAGE_HANDLING_MODE`.
211
+ const mode = options.mode ?? (await resolveImageHandlingMode(options.stateDir)).mode;
207
212
  const session = await openImageDescriptions({
208
213
  stateDir: options.stateDir,
209
214
  dashboardUrl: options.dashboardUrl,
210
215
  deviceToken: options.deviceToken,
211
216
  fetchImpl: options.fetchImpl,
217
+ mode,
212
218
  });
213
219
  if (options.budget)
214
220
  options.budget.images = session;
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import path from "node:path";
11
11
  import { isMissingFileFailure } from "./health-detail.js";
12
- import { readJsonFile, writeJsonFile } from "./local-state-files.js";
12
+ import { readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
13
13
  export const IMAGE_DESCRIPTION_STATE_FILENAME = "image-descriptions.json";
14
14
  /** Oldest keys leave first past this many (about 5 MB on disk). */
15
15
  export const IMAGE_DESCRIPTION_STATE_MAX_KEYS = 150_000;
@@ -76,7 +76,7 @@ export async function writeImageDescriptionState(stateDir, state) {
76
76
  const kept = keys.length > IMAGE_DESCRIPTION_STATE_MAX_KEYS ? keys.slice(keys.length - IMAGE_DESCRIPTION_STATE_MAX_KEYS) : keys;
77
77
  const failedEntries = Object.entries(state.failed).sort((a, b) => a[1].retry_after - b[1].retry_after);
78
78
  const failed = Object.fromEntries(failedEntries.slice(Math.max(0, failedEntries.length - IMAGE_FAILED_STATE_MAX_KEYS)));
79
- await writeJsonFile(imageDescriptionStatePath(stateDir), {
79
+ await writeJsonFileAtomic(imageDescriptionStatePath(stateDir), {
80
80
  schema_version: "cockpit-image-descriptions.v1",
81
81
  described: kept,
82
82
  failed,
@@ -0,0 +1,154 @@
1
+ /**
2
+ * This machine's image-handling mode, as Tower last told it (BLI-17273).
3
+ *
4
+ * The fleet default is `IMAGE_HANDLING_MODE` (keep). Tower can set a different mode for ONE machine
5
+ * (`collector_image_mode:<device_id>` in `system_settings`); the answer rides the reply to the
6
+ * sync-start receipt (`image_handling_mode`). The tick records what it heard here, in one small file in
7
+ * the collector state directory, and every later reader (the uploader, `cockpit sources check`)
8
+ * reads the file. The mode for a tick is therefore:
9
+ *
10
+ * the reply's value, when this tick got one (it is written to the file first, so the file IS it)
11
+ * else the last value on file (an offline tick must not flip a slim machine back
12
+ * to keep: each flip re-uploads every live-window
13
+ * transcript as a new object)
14
+ * else `IMAGE_HANDLING_MODE`
15
+ *
16
+ * The file also remembers, sticky, that this machine EVER ran slim (`drop_seen_at`). A machine put
17
+ * back on keep still holds slim server copies of the files it uploaded while slim, and
18
+ * `cockpit sources check` must go on treating those as "keep your local file".
19
+ *
20
+ * Holds the mode and two timestamps. Never an id, a path or a byte.
21
+ */
22
+ import path from "node:path";
23
+ import { IMAGE_HANDLING_MODE } from "@bli-cockpit/telemetry-core";
24
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
25
+ import { readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
26
+ export const IMAGE_MODE_STATE_FILENAME = "image-handling-mode.json";
27
+ const TAG = "[image-mode]";
28
+ export function isImageHandlingMode(value) {
29
+ return value === "describe_and_keep" || value === "describe_and_drop";
30
+ }
31
+ export function imageModeStatePath(stateDir) {
32
+ return path.join(stateDir, IMAGE_MODE_STATE_FILENAME);
33
+ }
34
+ /**
35
+ * Stands in for `drop_seen_at` when the file was there but could not be read, so whether this machine
36
+ * ever ran slim is unknown. It counts as "ran slim": the wrong guess costs a kept local file, the
37
+ * other wrong guess lets `cockpit sources check` call a slim server copy safe to delete.
38
+ */
39
+ export const DROP_SEEN_UNKNOWN = "unknown_state_unreadable";
40
+ async function readImageModeFile(stateDir) {
41
+ let raw;
42
+ try {
43
+ raw = await readJsonFile(imageModeStatePath(stateDir));
44
+ }
45
+ catch (error) {
46
+ if (isMissingFileFailure(error))
47
+ return { state: null, damaged: false, recoveredDropSeenAt: null };
48
+ console.error(TAG + " cache unreadable, using the fleet default and treating this machine as ever-slim", JSON.stringify({ reason: "image_mode_cache_unreadable", ...describeError(error) }));
49
+ return { state: null, damaged: true, recoveredDropSeenAt: null };
50
+ }
51
+ const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
52
+ if (!record) {
53
+ console.error(TAG + " cache is not an object, using the fleet default and treating this machine as ever-slim", JSON.stringify({ reason: "image_mode_cache_shape_invalid" }));
54
+ return { state: null, damaged: true, recoveredDropSeenAt: null };
55
+ }
56
+ const dropSeenAt = typeof record.drop_seen_at === "string" ? record.drop_seen_at : null;
57
+ if (!isImageHandlingMode(record.mode)) {
58
+ console.error(TAG + " cached mode not recognised, using the fleet default and treating this machine as ever-slim", JSON.stringify({ reason: "image_mode_cache_value_invalid" }));
59
+ return { state: null, damaged: true, recoveredDropSeenAt: dropSeenAt };
60
+ }
61
+ return {
62
+ state: {
63
+ mode: record.mode,
64
+ observed_at: typeof record.observed_at === "string" ? record.observed_at : "",
65
+ drop_seen_at: dropSeenAt,
66
+ },
67
+ damaged: false,
68
+ recoveredDropSeenAt: null,
69
+ };
70
+ }
71
+ /** The file's content, or null when there is none or it cannot be trusted (said out loud when unreadable). */
72
+ export async function readImageModeState(stateDir) {
73
+ return (await readImageModeFile(stateDir)).state;
74
+ }
75
+ /**
76
+ * What a reader of the file should use right now: the cached mode, else (damaged file) slim when a
77
+ * `drop_seen_at` could be recovered from it, else the fleet default. `slimAware` is true when this
78
+ * machine runs slim OR has ever run slim. `read` is a read the caller already did this tick, so a
79
+ * damaged file is read and logged once.
80
+ */
81
+ export async function resolveImageHandlingMode(stateDir, read) {
82
+ const { state, damaged, recoveredDropSeenAt } = read ?? (await readImageModeFile(stateDir));
83
+ // A damaged file that still named when this machine first ran slim: it ran slim, keep it slim
84
+ // (flipping to keep would re-upload every live-window transcript as a new whole object).
85
+ const mode = state?.mode ?? (damaged && recoveredDropSeenAt !== null ? "describe_and_drop" : IMAGE_HANDLING_MODE);
86
+ return {
87
+ mode,
88
+ source: state ? "cache" : damaged ? "damaged_cache" : "default",
89
+ // A file that is there but unreadable is "ever slim, unknown": fail closed (BLI-17273).
90
+ slimAware: mode === "describe_and_drop" || state?.drop_seen_at != null || damaged,
91
+ state,
92
+ };
93
+ }
94
+ /**
95
+ * Put what the reply said on file. An unknown value is ignored with a log (the old value stays);
96
+ * a write failure is logged and the previous file stays in force. Returns true only when the file
97
+ * now holds the observed mode.
98
+ */
99
+ export async function recordObservedImageMode(stateDir, observed, now = () => new Date(), read) {
100
+ if (observed === undefined || observed === null)
101
+ return false;
102
+ if (!isImageHandlingMode(observed)) {
103
+ console.error(TAG + " reply carried a mode this collector does not know, ignored", JSON.stringify({ reason: "image_mode_reply_value_unknown", value_type: typeof observed }));
104
+ return false;
105
+ }
106
+ const { state: previous, damaged, recoveredDropSeenAt } = read ?? (await readImageModeFile(stateDir));
107
+ const stamp = now().toISOString();
108
+ const next = {
109
+ mode: observed,
110
+ observed_at: stamp,
111
+ // Never rebuild "never ran slim" over a file we could not read: keep what parsed, else the unknown marker.
112
+ drop_seen_at: previous?.drop_seen_at ?? (damaged ? (recoveredDropSeenAt ?? DROP_SEEN_UNKNOWN) : observed === "describe_and_drop" ? stamp : null),
113
+ };
114
+ if (damaged) {
115
+ console.error(TAG + " rewriting a damaged mode file; ever-slim history kept", JSON.stringify({ reason: "image_mode_cache_damaged_rewritten", drop_seen_recovered: recoveredDropSeenAt !== null }));
116
+ }
117
+ try {
118
+ await writeJsonFileAtomic(imageModeStatePath(stateDir), { schema_version: "cockpit-image-handling-mode.v1", ...next });
119
+ }
120
+ catch (error) {
121
+ console.error(TAG + " could not cache the mode Tower sent; the previous value stays in force", JSON.stringify({ reason: "image_mode_cache_write_failed", mode: observed, ...describeError(error) }));
122
+ return false;
123
+ }
124
+ if (previous?.mode !== observed) {
125
+ console.error(TAG + " mode for this machine changed", JSON.stringify({ reason: "image_mode_changed", from: previous?.mode ?? (damaged ? "damaged" : "none"), to: observed }));
126
+ }
127
+ return true;
128
+ }
129
+ /**
130
+ * The tick's decision, logged once: where the mode came from and what it is. Called right after the
131
+ * sync-start receipt. `observed` is the reply's value (undefined when no reply arrived).
132
+ */
133
+ export async function settleTickImageMode(stateDir, observed, now) {
134
+ // One read per tick: a damaged file is logged once, and the 'changed' line can say from:'damaged'.
135
+ const read = await readImageModeFile(stateDir);
136
+ const wrote = await recordObservedImageMode(stateDir, observed, now, read);
137
+ // After a write the file is whole again, so read it fresh; otherwise the tick's own read stands.
138
+ const resolved = wrote ? await resolveImageHandlingMode(stateDir) : await resolveImageHandlingMode(stateDir, read);
139
+ const source = wrote ? "reply" : resolved.source;
140
+ console.error(TAG + " mode for this tick", JSON.stringify({ reason: `image_mode_from_${source}`, source, mode: resolved.mode }));
141
+ return { mode: resolved.mode, source };
142
+ }
143
+ /**
144
+ * `settleTickImageMode` for the sync tick, which it must never fail: a throw is logged and the
145
+ * uploader falls back to the file or the fleet default.
146
+ */
147
+ export async function settleTickImageModeBestEffort(stateDir, observed) {
148
+ try {
149
+ await settleTickImageMode(stateDir, observed);
150
+ }
151
+ catch (error) {
152
+ console.error(TAG + " could not settle the mode for this tick; the uploader reads the file or the fleet default", JSON.stringify({ reason: "image_mode_settle_failed", error_name: error instanceof Error ? error.name : typeof error }));
153
+ }
154
+ }
@@ -47,6 +47,26 @@ export async function writeJsonFile(filePath, value) {
47
47
  await fs.chmod(filePath, 0o600).catch(() => undefined);
48
48
  }
49
49
  }
50
+ /**
51
+ * Same file, same owner-only mode, but written to a sibling temp file and renamed over the target, so
52
+ * a reader (or a crash) never sees a half-written file (BLI-17273: a torn image-mode file made a slim
53
+ * machine forget it had ever run slim). The temp file is removed when the write or the rename fails.
54
+ */
55
+ export async function writeJsonFileAtomic(filePath, value) {
56
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
57
+ const temp = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
58
+ try {
59
+ await fs.writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
60
+ if (process.platform !== "win32") {
61
+ await fs.chmod(temp, 0o600).catch(() => undefined);
62
+ }
63
+ await fs.rename(temp, filePath);
64
+ }
65
+ catch (error) {
66
+ await fs.rm(temp, { force: true }).catch(() => undefined);
67
+ throw error;
68
+ }
69
+ }
50
70
  /**
51
71
  * Separate from `isMissingFileFailure`: the two callers here hold a raw `fs`
52
72
  * rejection rather than a wrapped failure, and "the file was never there" is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.208",
3
+ "version": "0.2.210",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.63",
31
- "@bli-cockpit/mcp": "0.1.118",
32
- "@bli-cockpit/telemetry-core": "0.1.72"
30
+ "@bli-cockpit/memory-mcp": "0.1.65",
31
+ "@bli-cockpit/mcp": "0.1.120",
32
+ "@bli-cockpit/telemetry-core": "0.1.74"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",