@bli-cockpit/cli 0.2.30 → 0.2.31
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/adapters/agent-image-evidence.js +4 -0
- package/dist/adapters/attribution-core.js +12 -0
- package/dist/adapters/car-state.js +12 -1
- package/dist/adapters/claude-attribution.js +81 -7
- package/dist/adapters/codex-attribution.js +37 -3
- package/dist/adapters/raw-evidence-manifest.js +12 -1
- package/dist/adapters/raw-evidence-pack-store.js +28 -3
- package/dist/adapters/raw-evidence-sanitize.js +46 -2
- package/dist/adapters/raw-evidence.js +51 -6
- package/dist/agent-rules.js +34 -3
- package/dist/autostart.js +3 -0
- package/dist/backfill-lock.js +22 -1
- package/dist/commands/backfill.js +41 -7
- package/dist/commands/cli-io.js +3 -0
- package/dist/commands/doctor.js +35 -4
- package/dist/commands/install-receipts.js +43 -6
- package/dist/commands/install-update.js +3 -1
- package/dist/commands/local-args.js +4 -0
- package/dist/commands/local-auth.js +14 -0
- package/dist/commands/local.js +20 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync.js +35 -6
- package/dist/commands/status.js +17 -1
- package/dist/cursors/backfill-cursor.js +23 -2
- package/dist/cursors/raw-evidence-cursor.js +14 -1
- package/dist/discovery-limits.js +12 -1
- package/dist/evidence-upload-client.js +41 -4
- package/dist/health-detail.js +111 -2
- package/dist/local-state.js +98 -11
- package/dist/onboarding-roots.js +3 -0
- package/dist/raw-evidence-attribution-policy.js +7 -0
- package/dist/raw-evidence-gc.js +5 -0
- package/dist/raw-evidence-staging.js +12 -1
- package/dist/repo-identity.js +15 -1
- package/dist/scheduled-self-update.js +14 -1
- package/dist/spool/install-event-outbox.js +11 -1
- package/dist/spool/local-spool.js +3 -0
- package/dist/sync-lock.js +24 -1
- package/dist/upload-agent-artifacts.js +11 -1
- package/dist/upload-envelope.js +30 -3
- package/dist/upload-http.js +10 -0
- package/dist/upload-session-reports.js +36 -3
- package/dist/upload.js +16 -5
- package/package.json +2 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { describeError, isMissingFileFailure } from "../health-detail.js";
|
|
4
5
|
/**
|
|
5
6
|
* Durable local cursor for raw-evidence harvesting.
|
|
6
7
|
*
|
|
@@ -41,7 +42,19 @@ export async function readRawEvidenceCursor(paths, options = {}) {
|
|
|
41
42
|
const raw = JSON.parse(await fs.readFile(rawEvidenceCursorPath(paths, options.filename), "utf8"));
|
|
42
43
|
return parseCursorState(raw);
|
|
43
44
|
}
|
|
44
|
-
catch {
|
|
45
|
+
catch (error) {
|
|
46
|
+
// An empty cursor is the correct answer before the first sync, and saying
|
|
47
|
+
// so every 15 minutes would bury the case that matters. A cursor that
|
|
48
|
+
// exists and cannot be read is a different event entirely: the collector
|
|
49
|
+
// is about to re-offer every object it already delivered and no line
|
|
50
|
+
// anywhere says why (BLI-3238).
|
|
51
|
+
if (!isMissingFileFailure(error)) {
|
|
52
|
+
console.error("[raw-evidence-cursor] cursor unreadable, starting from empty", JSON.stringify({
|
|
53
|
+
reason: "cursor_unreadable",
|
|
54
|
+
cursor_file: options.filename ?? PRIMARY_CURSOR_FILENAME,
|
|
55
|
+
...describeError(error),
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
45
58
|
return emptyRawEvidenceCursorState();
|
|
46
59
|
}
|
|
47
60
|
}
|
package/dist/discovery-limits.js
CHANGED
|
@@ -3,6 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, } from "./repo-identity.js";
|
|
5
5
|
import { getCollectorRuntimePaths } from "./local-state.js";
|
|
6
|
+
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
6
7
|
const SCHEMA_VERSION = "cockpit-discovery-limits.v1";
|
|
7
8
|
// A depth beyond this is a typo rather than a workspace, and an unbounded walk
|
|
8
9
|
// on a huge tree is its own outage. Same for the repo cap.
|
|
@@ -32,7 +33,17 @@ export async function readSavedDiscoveryLimits(homeDir = os.homedir()) {
|
|
|
32
33
|
max_repos: sanitizeLimit(parsed.max_repos, MAX_ALLOWED_REPOS),
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
|
-
catch {
|
|
36
|
+
catch (error) {
|
|
37
|
+
// No saved limits is the normal state and stays quiet. A file that exists
|
|
38
|
+
// and will not parse silently reinstates the shipped defaults, which is
|
|
39
|
+
// exactly the shape of the outage where depth 3 / 50 repos quietly
|
|
40
|
+
// undercollected for days — so it gets a line (BLI-3238).
|
|
41
|
+
if (!isMissingFileFailure(error)) {
|
|
42
|
+
console.error("[discovery-limits] saved limits unreadable, falling back to defaults", JSON.stringify({
|
|
43
|
+
reason: "discovery_limits_unreadable",
|
|
44
|
+
...describeError(error),
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
36
47
|
return {};
|
|
37
48
|
}
|
|
38
49
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
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";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
|
+
import { describeError } from "./health-detail.js";
|
|
4
5
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
5
6
|
const RETRY_DELAY_MS = 250;
|
|
6
7
|
export async function uploadRawEvidenceFilesChunked(options) {
|
|
@@ -16,7 +17,16 @@ export async function uploadRawEvidenceFilesChunked(options) {
|
|
|
16
17
|
try {
|
|
17
18
|
stat = await fs.stat(file.local_path);
|
|
18
19
|
}
|
|
19
|
-
catch {
|
|
20
|
+
catch (error) {
|
|
21
|
+
// `file_read_failed` is a durable ledger label and stays. Beside it: a
|
|
22
|
+
// staged object that vanished (the GC won a race) and one the process
|
|
23
|
+
// has no permission to open are the same word and opposite repairs
|
|
24
|
+
// (BLI-3238).
|
|
25
|
+
console.error("[evidence-upload] staged object could not be stat'd", JSON.stringify({
|
|
26
|
+
reason: "file_read_failed",
|
|
27
|
+
stage: "stat",
|
|
28
|
+
...describeError(error),
|
|
29
|
+
}));
|
|
20
30
|
outcomes.push(failedOutcome(file, "file_read_failed"));
|
|
21
31
|
continue;
|
|
22
32
|
}
|
|
@@ -32,7 +42,15 @@ export async function uploadRawEvidenceFilesChunked(options) {
|
|
|
32
42
|
try {
|
|
33
43
|
bytes = await fs.readFile(file.local_path);
|
|
34
44
|
}
|
|
35
|
-
catch {
|
|
45
|
+
catch (error) {
|
|
46
|
+
// Stat succeeded and the read did not — narrower than the stat failure
|
|
47
|
+
// above and worth telling apart, so it carries its own stage label.
|
|
48
|
+
console.error("[evidence-upload] staged object could not be read after a successful stat", JSON.stringify({
|
|
49
|
+
reason: "file_read_failed",
|
|
50
|
+
stage: "read",
|
|
51
|
+
byte_size: stat.size,
|
|
52
|
+
...describeError(error),
|
|
53
|
+
}));
|
|
36
54
|
outcomes.push(failedOutcome(file, "file_read_failed"));
|
|
37
55
|
continue;
|
|
38
56
|
}
|
|
@@ -437,7 +455,18 @@ async function requestJson(options, routePath, body) {
|
|
|
437
455
|
return { ok: response.ok, status: response.status, body: lastBody };
|
|
438
456
|
}
|
|
439
457
|
}
|
|
440
|
-
catch {
|
|
458
|
+
catch (error) {
|
|
459
|
+
// `status: 0` is this function's word for "no answer came back", and it
|
|
460
|
+
// travels all the way to the upload ledger without ever saying whether
|
|
461
|
+
// the request left the machine. This is the exact silence BLI-2528 sat
|
|
462
|
+
// behind for 57 days; the route is named, the body never is.
|
|
463
|
+
console.error("[evidence-upload] request failed before a status came back", JSON.stringify({
|
|
464
|
+
reason: "upload_request_transport_error",
|
|
465
|
+
route: routePath,
|
|
466
|
+
attempt,
|
|
467
|
+
max_attempts: maxAttempts,
|
|
468
|
+
...describeError(error),
|
|
469
|
+
}));
|
|
441
470
|
lastStatus = 0;
|
|
442
471
|
lastBody = null;
|
|
443
472
|
}
|
|
@@ -578,7 +607,15 @@ async function readResponseJson(response) {
|
|
|
578
607
|
}
|
|
579
608
|
catch {
|
|
580
609
|
// The raw text is kept for the caller that wants to show it, never for a
|
|
581
|
-
// label or a log — it is an unbounded HTML page.
|
|
610
|
+
// label or a log — it is an unbounded HTML page. Its SHAPE is safe and is
|
|
611
|
+
// the part that matters: a non-JSON body means something in front of the
|
|
612
|
+
// dashboard answered instead of it (BLI-3067, BLI-3238).
|
|
613
|
+
console.error("[evidence-upload] reply was not JSON", JSON.stringify({
|
|
614
|
+
reason: "response_body_not_json",
|
|
615
|
+
http_status: response.status,
|
|
616
|
+
byte_size: text.length,
|
|
617
|
+
content_type: response.headers.get("content-type") ?? "none",
|
|
618
|
+
}));
|
|
582
619
|
return { message: text, [NON_JSON_RESPONSE_BODY_KEY]: "non_json" };
|
|
583
620
|
}
|
|
584
621
|
}
|
package/dist/health-detail.js
CHANGED
|
@@ -87,17 +87,126 @@ function localIdentifiers() {
|
|
|
87
87
|
identifiers.push([os.hostname(), "[host]"]);
|
|
88
88
|
}
|
|
89
89
|
catch {
|
|
90
|
-
// A host with no resolvable name has nothing to leak.
|
|
90
|
+
// A host with no resolvable name has nothing to leak. Deliberately silent
|
|
91
|
+
// (BLI-3238): this runs inside the redactor, so reporting it here would
|
|
92
|
+
// recurse into the very function that is failing.
|
|
91
93
|
}
|
|
92
94
|
try {
|
|
93
95
|
identifiers.push([os.userInfo().username, "[user]"]);
|
|
94
96
|
}
|
|
95
97
|
catch {
|
|
96
|
-
// Same: no account name available means none can travel
|
|
98
|
+
// Same: no account name available means none can travel, and the same
|
|
99
|
+
// recursion argument applies.
|
|
97
100
|
}
|
|
98
101
|
// A one- or two-character name would match far too much ordinary text.
|
|
99
102
|
return identifiers.filter(([value]) => value && value.length > 2);
|
|
100
103
|
}
|
|
101
104
|
function literalPattern(value) {
|
|
102
105
|
return new RegExp(value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "giu");
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Describes a caught error in fields that are safe to log, and never throws
|
|
109
|
+
* doing it.
|
|
110
|
+
*
|
|
111
|
+
* BLI-3238. A bare `catch {}` in the harvester is how collection dies for days
|
|
112
|
+
* without anyone noticing (BLI-2528: 57 days, one probe away the whole time).
|
|
113
|
+
* The counter-pressure is that an error message on this machine is not neutral
|
|
114
|
+
* text — an fs error carries the operator's absolute path, a fetch error
|
|
115
|
+
* carries a host, and a parse error can carry a fragment of the transcript
|
|
116
|
+
* being parsed. So:
|
|
117
|
+
*
|
|
118
|
+
* - A syscall error (`code` + `syscall`) reports its code and syscall and
|
|
119
|
+
* drops the message entirely. `ENOENT: no such file or directory, open
|
|
120
|
+
* '/Users/<person>/…'` says nothing `ENOENT open` does not, and the tail is
|
|
121
|
+
* pure local identifier.
|
|
122
|
+
* - Every other message goes through `redactedHealthDetail`, the same
|
|
123
|
+
* secret-and-path scrubber the health receipts use before leaving the
|
|
124
|
+
* machine, and is capped at `HEALTH_DETAIL_MAX_CHARS`.
|
|
125
|
+
* - One level of `cause` is unwrapped, because `TypeError: fetch failed` on
|
|
126
|
+
* its own names nothing and its cause is the whole answer.
|
|
127
|
+
*/
|
|
128
|
+
export function describeError(error) {
|
|
129
|
+
if (!(error instanceof Error)) {
|
|
130
|
+
// `null` and `undefined` are fully described by their name; repeating them
|
|
131
|
+
// as a detail says nothing twice.
|
|
132
|
+
const named = error === null || error === undefined;
|
|
133
|
+
return {
|
|
134
|
+
error_name: error === null ? "null" : typeof error,
|
|
135
|
+
...(named ? {} : detailField(safeString(error))),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const fields = {
|
|
139
|
+
error_name: error.name || "Error",
|
|
140
|
+
...codeField(error),
|
|
141
|
+
...syscallField(error),
|
|
142
|
+
...messageField(error),
|
|
143
|
+
};
|
|
144
|
+
const cause = error.cause;
|
|
145
|
+
if (cause instanceof Error) {
|
|
146
|
+
const causeFields = describeError(cause);
|
|
147
|
+
if (causeFields.error_name)
|
|
148
|
+
fields.cause_name = causeFields.error_name;
|
|
149
|
+
if (causeFields.error_code)
|
|
150
|
+
fields.cause_code = causeFields.error_code;
|
|
151
|
+
if (causeFields.error_detail)
|
|
152
|
+
fields.cause_detail = causeFields.error_detail;
|
|
153
|
+
}
|
|
154
|
+
return fields;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Is this the ordinary "there is nothing there yet" failure?
|
|
158
|
+
*
|
|
159
|
+
* Nearly every collector read has a legitimate empty state — no cursor before
|
|
160
|
+
* the first sync, no config before onboarding, no session before login — and
|
|
161
|
+
* logging that on every 15-minute tick would bury the failures that matter.
|
|
162
|
+
* A read that fails for any OTHER reason is the interesting one: a corrupt
|
|
163
|
+
* cursor and a permission-denied config both read as "first run" today, and
|
|
164
|
+
* that is the exact shape of BLI-2528.
|
|
165
|
+
*/
|
|
166
|
+
export function isMissingFileFailure(error) {
|
|
167
|
+
const code = errorCode(error);
|
|
168
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
169
|
+
}
|
|
170
|
+
function codeField(error) {
|
|
171
|
+
const code = errorCode(error);
|
|
172
|
+
return code ? { error_code: code } : {};
|
|
173
|
+
}
|
|
174
|
+
function errorCode(error) {
|
|
175
|
+
if (!error || typeof error !== "object")
|
|
176
|
+
return undefined;
|
|
177
|
+
const code = error.code;
|
|
178
|
+
if (typeof code === "string" && code)
|
|
179
|
+
return code;
|
|
180
|
+
if (typeof code === "number")
|
|
181
|
+
return String(code);
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
function syscallField(error) {
|
|
185
|
+
const syscall = error.syscall;
|
|
186
|
+
return typeof syscall === "string" && syscall ? { error_syscall: syscall } : {};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The message, or nothing at all when the code and syscall already carry the
|
|
190
|
+
* whole meaning. Dropping it is not lost information: it is a path.
|
|
191
|
+
*/
|
|
192
|
+
function messageField(error) {
|
|
193
|
+
const syscall = error.syscall;
|
|
194
|
+
if (errorCode(error) && typeof syscall === "string" && syscall)
|
|
195
|
+
return {};
|
|
196
|
+
return detailField(error.message);
|
|
197
|
+
}
|
|
198
|
+
function detailField(message) {
|
|
199
|
+
const detail = redactedHealthDetail(message);
|
|
200
|
+
return detail ? { error_detail: detail } : {};
|
|
201
|
+
}
|
|
202
|
+
/** `String(value)` can itself throw on an exotic object; a describer must not. */
|
|
203
|
+
function safeString(value) {
|
|
204
|
+
try {
|
|
205
|
+
return String(value);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Deliberately silent, and the only silence in this module that cannot be
|
|
209
|
+
// reported: the failure happened inside the reporter.
|
|
210
|
+
return "";
|
|
211
|
+
}
|
|
103
212
|
}
|
package/dist/local-state.js
CHANGED
|
@@ -9,6 +9,7 @@ import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
|
|
|
9
9
|
import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
|
|
10
10
|
import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
|
|
11
11
|
import { readRawEvidenceStagingState, summarizeStuckEvidence, } from "./raw-evidence-staging.js";
|
|
12
|
+
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
12
13
|
const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
13
14
|
export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
|
|
14
15
|
? localCollectorPackage.version
|
|
@@ -115,7 +116,16 @@ export async function pairLocalCollector(options = {}) {
|
|
|
115
116
|
const homeDir = options.homeDir ?? os.homedir();
|
|
116
117
|
const paths = getCollectorRuntimePaths(homeDir);
|
|
117
118
|
await ensureRuntimeDirectories(paths);
|
|
118
|
-
const config = await readLocalCollectorConfig(paths).catch(() => {
|
|
119
|
+
const config = await readLocalCollectorConfig(paths).catch((error) => {
|
|
120
|
+
// "Local config missing" is correct when it is absent. A config that
|
|
121
|
+
// exists and will not parse gets the same sentence and the same useless
|
|
122
|
+
// advice — run onboard again, which will not fix it (BLI-3238).
|
|
123
|
+
if (!isMissingFileFailure(error)) {
|
|
124
|
+
console.error("[local-state] collector config present but unreadable, reporting it as missing", JSON.stringify({
|
|
125
|
+
reason: "config_unreadable",
|
|
126
|
+
...describeError(error),
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
119
129
|
throw new Error("Local config missing. Run `cockpit onboard` (or `cockpit install`) first, then retry `cockpit login`.");
|
|
120
130
|
});
|
|
121
131
|
const dashboardUrl = normalizeDashboardUrl(options.dashboardUrl ?? config.dashboard_url);
|
|
@@ -454,7 +464,18 @@ export async function readLocalSessionReference(paths, fallback = {}) {
|
|
|
454
464
|
}
|
|
455
465
|
return LocalUserSessionReferenceSchema.parse(rawSession);
|
|
456
466
|
}
|
|
457
|
-
catch {
|
|
467
|
+
catch (error) {
|
|
468
|
+
// `session_state: "missing"` is correct before login and says so loudly
|
|
469
|
+
// enough on its own. It is also what a session file that EXISTS but is
|
|
470
|
+
// corrupt, truncated or unreadable collapses to — a paired machine that
|
|
471
|
+
// silently reads as never-signed-in, which is indistinguishable in every
|
|
472
|
+
// downstream receipt (BLI-3238).
|
|
473
|
+
if (!isMissingFileFailure(error)) {
|
|
474
|
+
console.error("[local-state] session file present but unusable, reading as missing", JSON.stringify({
|
|
475
|
+
reason: "session_file_unusable",
|
|
476
|
+
...describeError(error),
|
|
477
|
+
}));
|
|
478
|
+
}
|
|
458
479
|
return LocalUserSessionReferenceSchema.parse({
|
|
459
480
|
operator_id: fallback.operatorId ?? "unknown",
|
|
460
481
|
auth_subject_id: "unknown",
|
|
@@ -480,7 +501,17 @@ export async function resolveGitBranch(repoRoot) {
|
|
|
480
501
|
}
|
|
481
502
|
return head ? `detached:${head.slice(0, 12)}` : "unknown";
|
|
482
503
|
}
|
|
483
|
-
catch {
|
|
504
|
+
catch (error) {
|
|
505
|
+
// A folder that is not a git repo is a legitimate workspace under the
|
|
506
|
+
// session-first commandment, so a missing `.git` stays quiet. A `.git`
|
|
507
|
+
// that exists and cannot be read is a different thing: every session
|
|
508
|
+
// collected from this repo gets branch `unknown` and nothing says why.
|
|
509
|
+
if (!isMissingFileFailure(error)) {
|
|
510
|
+
console.error("[local-state] could not read HEAD, branch recorded as unknown", JSON.stringify({
|
|
511
|
+
reason: "git_head_unreadable",
|
|
512
|
+
...describeError(error),
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
484
515
|
return "unknown";
|
|
485
516
|
}
|
|
486
517
|
}
|
|
@@ -499,13 +530,30 @@ async function ensureRuntimeDirectories(paths) {
|
|
|
499
530
|
await fs.mkdir(paths.cursors_dir, { recursive: true, mode: 0o700 });
|
|
500
531
|
await fs.mkdir(paths.work_contexts_dir, { recursive: true, mode: 0o700 });
|
|
501
532
|
if (process.platform !== "win32") {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
533
|
+
// The `mode` above only applies to directories this call CREATES, so these
|
|
534
|
+
// chmods are what actually tightens a directory that already existed with
|
|
535
|
+
// looser bits. Failing means the operator's device token and cursors stay
|
|
536
|
+
// world-readable — non-fatal, deliberately, but not something to find out
|
|
537
|
+
// about never (BLI-3238). Reported by directory name only, never a path.
|
|
538
|
+
const tightened = [
|
|
539
|
+
["config_dir", fs.chmod(paths.config_dir, 0o700)],
|
|
540
|
+
["state_dir", fs.chmod(paths.state_dir, 0o700)],
|
|
541
|
+
["spool_dir", fs.chmod(paths.spool_dir, 0o700)],
|
|
542
|
+
["cursors_dir", fs.chmod(paths.cursors_dir, 0o700)],
|
|
543
|
+
["work_contexts_dir", fs.chmod(paths.work_contexts_dir, 0o700)],
|
|
544
|
+
];
|
|
545
|
+
await Promise.all(tightened.map(async ([name, work]) => {
|
|
546
|
+
try {
|
|
547
|
+
await work;
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
console.error("[local-state] could not restrict a runtime directory to owner-only", JSON.stringify({
|
|
551
|
+
reason: "runtime_dir_chmod_failed",
|
|
552
|
+
directory: name,
|
|
553
|
+
...describeError(error),
|
|
554
|
+
}));
|
|
555
|
+
}
|
|
556
|
+
}));
|
|
509
557
|
}
|
|
510
558
|
}
|
|
511
559
|
function workContextFile(paths, worktreeFingerprint) {
|
|
@@ -545,8 +593,38 @@ function stableWorkContextId(input) {
|
|
|
545
593
|
function sha256(value) {
|
|
546
594
|
return crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
547
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* The one reader behind the config file, the session file and every work
|
|
598
|
+
* context — and therefore the one place worth reporting from.
|
|
599
|
+
*
|
|
600
|
+
* Roughly twenty call sites swallow this to `null` or a default with
|
|
601
|
+
* `.catch(() => null)`, each of them asking a reasonable question ("is this
|
|
602
|
+
* machine set up?") to which "no" is a legitimate answer. What none of them
|
|
603
|
+
* could distinguish is "no, nothing is there" from "yes, and it is corrupt or
|
|
604
|
+
* unreadable" — so the distinction is drawn HERE, once, rather than in twenty
|
|
605
|
+
* places where it would be twenty chances to forget (BLI-3238).
|
|
606
|
+
*
|
|
607
|
+
* The error still propagates unchanged; callers keep whatever they decided.
|
|
608
|
+
*/
|
|
548
609
|
async function readJsonFile(filePath) {
|
|
549
|
-
|
|
610
|
+
try {
|
|
611
|
+
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
// Absent is the ordinary pre-onboarding state on every one of these files
|
|
615
|
+
// and stays quiet; anything else means state exists and cannot be used.
|
|
616
|
+
if (!isMissingFileFailure(error)) {
|
|
617
|
+
console.error("[local-state] a collector state file exists but could not be read", JSON.stringify({
|
|
618
|
+
reason: "state_file_unreadable",
|
|
619
|
+
// Which file, without the path: the basename of these is a fixed
|
|
620
|
+
// vocabulary (`config.json`, `session.json`, a work-context
|
|
621
|
+
// fingerprint) and carries no repo or operator name.
|
|
622
|
+
state_file: path.basename(filePath),
|
|
623
|
+
...describeError(error),
|
|
624
|
+
}));
|
|
625
|
+
}
|
|
626
|
+
throw error;
|
|
627
|
+
}
|
|
550
628
|
}
|
|
551
629
|
async function writeJsonFile(filePath, value) {
|
|
552
630
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
@@ -643,6 +721,15 @@ async function readResponseJson(response) {
|
|
|
643
721
|
return JSON.parse(text);
|
|
644
722
|
}
|
|
645
723
|
catch {
|
|
724
|
+
// Pairing path. A captive portal or a proxy answering with HTML is the
|
|
725
|
+
// classic reason `cockpit login` fails on a new machine and the operator
|
|
726
|
+
// sees only "pair request failed". The body is never logged; its shape is.
|
|
727
|
+
console.error("[local-state] pairing reply was not JSON", JSON.stringify({
|
|
728
|
+
reason: "response_body_not_json",
|
|
729
|
+
http_status: response.status,
|
|
730
|
+
byte_size: text.length,
|
|
731
|
+
content_type: response.headers.get("content-type") ?? "none",
|
|
732
|
+
}));
|
|
646
733
|
return { message: text };
|
|
647
734
|
}
|
|
648
735
|
}
|
package/dist/onboarding-roots.js
CHANGED
|
@@ -294,6 +294,9 @@ function canonicalPath(input, pathApi = path, realpath = realpathSync) {
|
|
|
294
294
|
return pathApi.resolve(realpath(resolved));
|
|
295
295
|
}
|
|
296
296
|
catch {
|
|
297
|
+
// Deliberately silent (BLI-3238), per the comment above: a path that does
|
|
298
|
+
// not exist yet is the ordinary input here, and the lexical resolve is the
|
|
299
|
+
// documented behavior for it, not a fallback after a failure.
|
|
297
300
|
return resolved;
|
|
298
301
|
}
|
|
299
302
|
}
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
* fallback that produced a worktree identity. Session-first terminal labels
|
|
4
4
|
* are uploadable only when attribution attached an approved-root synthetic
|
|
5
5
|
* workspace; the label stays terminal and no repository provenance is invented.
|
|
6
|
+
*
|
|
7
|
+
* A `false` answer is a decision, and every caller that builds a session row
|
|
8
|
+
* MUST record it as one — `upload_state: "not_uploaded"` plus
|
|
9
|
+
* `notUploadableAttributionStateReason(<attribution reason>)` from
|
|
10
|
+
* `@bli-cockpit/telemetry-core`. Spreading `{}` on this branch is what put 1,256
|
|
11
|
+
* production sessions in `ambient_codex_sessions` with a NULL upload_state and a
|
|
12
|
+
* NULL upload_reason (BLI-3272): withheld, and silent about it.
|
|
6
13
|
*/
|
|
7
14
|
export function isRawEvidenceUploadableAttributionState(state, hasApprovedWorkspace = false) {
|
|
8
15
|
return (state === "attributed" ||
|
package/dist/raw-evidence-gc.js
CHANGED
|
@@ -227,6 +227,11 @@ async function packContentFingerprint(dir) {
|
|
|
227
227
|
.digest("hex");
|
|
228
228
|
}
|
|
229
229
|
catch {
|
|
230
|
+
// Deliberately silent (BLI-3238) — and it is the one silence here that is
|
|
231
|
+
// already reported. `null` means "not fingerprintable", the dedup sweep
|
|
232
|
+
// counts every one of them as `unfingerprintable_dirs`, and that count is
|
|
233
|
+
// logged on EVERY sweep including the clean one. A line per pack would
|
|
234
|
+
// add nothing the sweep does not already say.
|
|
230
235
|
return null;
|
|
231
236
|
}
|
|
232
237
|
}
|
|
@@ -2,6 +2,7 @@ import { DELIVERY_BACKOFF_HOLDING } from "@bli-cockpit/telemetry-core";
|
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
5
6
|
/**
|
|
6
7
|
* Durable local state for raw-evidence STAGING and DELIVERY ATTEMPTS.
|
|
7
8
|
*
|
|
@@ -67,7 +68,17 @@ export async function readRawEvidenceStagingState(stateDir) {
|
|
|
67
68
|
const raw = JSON.parse(await fs.readFile(rawEvidenceStagingStatePath(stateDir), "utf8"));
|
|
68
69
|
return parseStagingState(raw);
|
|
69
70
|
}
|
|
70
|
-
catch {
|
|
71
|
+
catch (error) {
|
|
72
|
+
// Empty is right before the first staged pack. Unreadable is not: this
|
|
73
|
+
// file carries the delivery-attempt history, so losing it silently resets
|
|
74
|
+
// every backoff window and re-offers objects the server already refused
|
|
75
|
+
// (BLI-3066 is what that costs; BLI-3238 is why it now says so).
|
|
76
|
+
if (!isMissingFileFailure(error)) {
|
|
77
|
+
console.error("[raw-evidence-staging] staging state unreadable, delivery history reset", JSON.stringify({
|
|
78
|
+
reason: "staging_state_unreadable",
|
|
79
|
+
...describeError(error),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
71
82
|
return emptyRawEvidenceStagingState();
|
|
72
83
|
}
|
|
73
84
|
}
|
package/dist/repo-identity.js
CHANGED
|
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { containsPath, isCodexWorktreePath } from "./root-normalization.js";
|
|
7
|
+
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
const SKIPPED_DIR_NAMES = new Set([
|
|
9
10
|
".cache",
|
|
@@ -352,7 +353,16 @@ async function resolveBranchFromHead(repoRoot) {
|
|
|
352
353
|
}
|
|
353
354
|
return head ? `detached:${head.slice(0, 12)}` : "unknown";
|
|
354
355
|
}
|
|
355
|
-
catch {
|
|
356
|
+
catch (error) {
|
|
357
|
+
// Not a repo → quiet, that is an ordinary approved folder. A `.git` that
|
|
358
|
+
// exists and will not read → every session from this worktree is labelled
|
|
359
|
+
// branch `unknown` and, until BLI-3238, nothing said why.
|
|
360
|
+
if (!isMissingFileFailure(error)) {
|
|
361
|
+
console.error("[repo-identity] could not read HEAD, branch recorded as unknown", JSON.stringify({
|
|
362
|
+
reason: "git_head_unreadable",
|
|
363
|
+
...describeError(error),
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
356
366
|
return "unknown";
|
|
357
367
|
}
|
|
358
368
|
}
|
|
@@ -377,6 +387,10 @@ export function normalizeGitOrigin(rawOrigin) {
|
|
|
377
387
|
return normalizeOriginParts(url.hostname, url.pathname);
|
|
378
388
|
}
|
|
379
389
|
catch {
|
|
390
|
+
// Deliberately silent (BLI-3238). `new URL` is being used as the test for
|
|
391
|
+
// "is this origin URL-shaped?", and a plain path or an unusual remote form
|
|
392
|
+
// failing to parse IS the answer — the fallback below is the intended
|
|
393
|
+
// normalization for exactly that case, not a degradation.
|
|
380
394
|
return trimmed
|
|
381
395
|
.replace(/\.git$/i, "")
|
|
382
396
|
.replace(/^\/+|\/+$/g, "")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { describeError } from "./health-detail.js";
|
|
3
4
|
const SELF_UPDATE_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
4
5
|
export const SELF_UPDATE_THROTTLE_MARKER = ".last-self-update-check";
|
|
5
6
|
/**
|
|
@@ -179,7 +180,16 @@ async function probeInstalledCliVersion(exec) {
|
|
|
179
180
|
const version = parsed.dependencies?.["@bli-cockpit/cli"]?.version;
|
|
180
181
|
return typeof version === "string" && version.trim() ? version.trim() : null;
|
|
181
182
|
}
|
|
182
|
-
catch {
|
|
183
|
+
catch (error) {
|
|
184
|
+
// `null` means "installed version unknown", which suppresses the whole
|
|
185
|
+
// self-update decision including the BLI-2678 forced-version floor. A
|
|
186
|
+
// fleet stuck on an old CLI because `npm ls` started printing something
|
|
187
|
+
// unparseable would look exactly like a fleet that is up to date.
|
|
188
|
+
console.error("[self-update] could not read the installed CLI version from npm ls", JSON.stringify({
|
|
189
|
+
reason: "installed_version_unreadable",
|
|
190
|
+
byte_size: result.stdout.length,
|
|
191
|
+
...describeError(error),
|
|
192
|
+
}));
|
|
183
193
|
return null;
|
|
184
194
|
}
|
|
185
195
|
}
|
|
@@ -197,6 +207,9 @@ function parseNpmVersionField(stdout) {
|
|
|
197
207
|
return typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
|
|
198
208
|
}
|
|
199
209
|
catch {
|
|
210
|
+
// Deliberately silent (BLI-3238), same as doctor.ts's copy: the parse is
|
|
211
|
+
// the test for whether this npm quoted its output, and the unquoted
|
|
212
|
+
// fallback is the intended handling of the other form.
|
|
200
213
|
return trimmed.replace(/^"|"$/gu, "") || null;
|
|
201
214
|
}
|
|
202
215
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { describeError } from "../health-detail.js";
|
|
4
5
|
const OUTBOX_DIRECTORY = "install-events";
|
|
5
6
|
const MAX_PENDING_ENTRIES = 100;
|
|
6
7
|
const MAX_EVENTS_PER_ENTRY = 40;
|
|
@@ -44,12 +45,21 @@ export async function readPendingInstallEventEntries(paths) {
|
|
|
44
45
|
try {
|
|
45
46
|
const parsed = parseEntry(JSON.parse(await fs.readFile(filePath, "utf8")));
|
|
46
47
|
if (!parsed) {
|
|
48
|
+
console.error("[install-outbox] discarding an entry that does not match the schema", JSON.stringify({ reason: "outbox_entry_invalid" }));
|
|
47
49
|
await fs.rm(filePath, { force: true });
|
|
48
50
|
continue;
|
|
49
51
|
}
|
|
50
52
|
entries.push(parsed);
|
|
51
53
|
}
|
|
52
|
-
catch {
|
|
54
|
+
catch (error) {
|
|
55
|
+
// This branch DELETES a queued receipt. Whatever the reason — truncated
|
|
56
|
+
// write, bad JSON, no read permission — the machine loses a health event
|
|
57
|
+
// it was holding for the server, so it says which reason it was before
|
|
58
|
+
// dropping it (BLI-3238).
|
|
59
|
+
console.error("[install-outbox] discarding an unreadable entry", JSON.stringify({
|
|
60
|
+
reason: "outbox_entry_unreadable",
|
|
61
|
+
...describeError(error),
|
|
62
|
+
}));
|
|
53
63
|
await fs.rm(filePath, { force: true }).catch(() => undefined);
|
|
54
64
|
}
|
|
55
65
|
}
|
|
@@ -416,6 +416,9 @@ async function fsyncDirectoryBestEffort(directoryPath) {
|
|
|
416
416
|
catch {
|
|
417
417
|
// Directory fsync is not supported by every host/filesystem (notably some
|
|
418
418
|
// Windows versions). The file itself was fsynced before the atomic rename.
|
|
419
|
+
// Deliberately silent (BLI-3238): on those hosts this fails on every
|
|
420
|
+
// single write, so a line here would be pure noise on exactly the
|
|
421
|
+
// platform the collector most needs readable logs on.
|
|
419
422
|
}
|
|
420
423
|
finally {
|
|
421
424
|
await handle?.close().catch(() => undefined);
|
package/dist/sync-lock.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { describeError } from "./health-detail.js";
|
|
4
5
|
/**
|
|
5
6
|
* Single-flight lock for `cockpit sync`. A launchd timer and a manual sync can
|
|
6
7
|
* fire at the same time and interleave the cursor read-modify-write; this lock
|
|
@@ -71,10 +72,27 @@ async function tryExclusiveCreate(lockPath, token, now) {
|
|
|
71
72
|
await handle.close();
|
|
72
73
|
return true;
|
|
73
74
|
}
|
|
74
|
-
catch {
|
|
75
|
+
catch (error) {
|
|
76
|
+
// EEXIST is the whole point of `wx`: another sync holds the lock, and the
|
|
77
|
+
// caller reports `sync_already_running`. Any OTHER code — no permission on
|
|
78
|
+
// the cursors directory, a full disk, a read-only volume — produces the
|
|
79
|
+
// identical false answer, and every sync on the machine then exits saying
|
|
80
|
+
// another one is running while none ever is (BLI-3238).
|
|
81
|
+
if (!isLockHeldError(error)) {
|
|
82
|
+
console.error("[sync-lock] could not create the lock file; reporting the lock as held", JSON.stringify({
|
|
83
|
+
reason: "sync_lock_create_failed",
|
|
84
|
+
...describeError(error),
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
75
87
|
return false;
|
|
76
88
|
}
|
|
77
89
|
}
|
|
90
|
+
/** `wx` refusing because the lock exists — the ordinary contended case. */
|
|
91
|
+
function isLockHeldError(error) {
|
|
92
|
+
return (typeof error === "object" &&
|
|
93
|
+
error !== null &&
|
|
94
|
+
error.code === "EEXIST");
|
|
95
|
+
}
|
|
78
96
|
async function writeLock(lockPath, token, now) {
|
|
79
97
|
await fs.writeFile(lockPath, serializeLock(token, now), { mode: 0o600 });
|
|
80
98
|
}
|
|
@@ -108,6 +126,11 @@ async function readLock(lockPath) {
|
|
|
108
126
|
};
|
|
109
127
|
}
|
|
110
128
|
catch {
|
|
129
|
+
// Deliberately silent (BLI-3238). This read is the "is anyone holding it?"
|
|
130
|
+
// probe: no lock file, a half-written one, a lock being removed underneath
|
|
131
|
+
// us — every one of those means "not held", which is the answer the caller
|
|
132
|
+
// wants and acts on correctly. Failure IS the result here. The failing
|
|
133
|
+
// ACQUIRE above is the branch that owns the reporting.
|
|
111
134
|
return null;
|
|
112
135
|
}
|
|
113
136
|
}
|