@bli-cockpit/cli 0.2.95 → 0.2.96

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.
@@ -0,0 +1,80 @@
1
+ import { describeError } from "../health-detail.js";
2
+ import { getCollectorRuntimePaths, readLocalCollectorSessionFile, recordDeviceTokenExpiry, toSessionReference, } from "../local-state.js";
3
+ /**
4
+ * Read the session and decide whether this tick may check in.
5
+ *
6
+ * The check-in goes out while the machine believes it is EXPIRED, as long as it
7
+ * still holds a device token. That is not a loosening: the SERVER decides
8
+ * whether a token is accepted, and a collector that refused to knock could
9
+ * never be let back in. Uploads are unaffected — they still gate on `valid`
10
+ * (`upload-envelope-build.ts`), so a machine past the grace window spends no
11
+ * bandwidth on evidence that will be refused.
12
+ *
13
+ * Both refusing branches say something, because "the dashboard shows this
14
+ * machine as quiet" has two very different causes.
15
+ */
16
+ export async function readHeartbeatSession(homeDir) {
17
+ const paths = getCollectorRuntimePaths(homeDir);
18
+ const session = await readLocalCollectorSessionFile(paths).catch(() => null);
19
+ const state = session ? toSessionReference(session).session_state : "missing";
20
+ const usable = state === "valid" || state === "expired";
21
+ if (!session ||
22
+ !usable ||
23
+ typeof session.device_token !== "string" ||
24
+ !session.device_token) {
25
+ console.error("[heartbeat] no usable device session on this machine; the dashboard will show it as quiet", JSON.stringify({
26
+ reason: "no_device_session",
27
+ session_state: state,
28
+ next_action: "run `cockpit do-everything` to pair this machine again",
29
+ }));
30
+ return { usable: false };
31
+ }
32
+ if (state === "expired") {
33
+ console.error("[heartbeat] this machine's session looks expired; checking in anyway so the dashboard can renew it", JSON.stringify({
34
+ reason: "session_expired_locally",
35
+ expires_at: session.expires_at ?? null,
36
+ next_action: "the dashboard renews a token that lapsed inside its 30-day grace window on this call",
37
+ }));
38
+ }
39
+ return { usable: true, session, state: state };
40
+ }
41
+ /**
42
+ * Record the `token_expires_at` the heartbeat door answered with.
43
+ *
44
+ * The LOCAL copy is what decides whether this machine even attempts an upload,
45
+ * so a machine the server has just renewed would otherwise go on refusing its
46
+ * own uploads until somebody signed in by hand.
47
+ *
48
+ * A body that is not JSON, or that carries no such field, is not an error and
49
+ * is not logged: an older dashboard answers `{ ok: true }` and a proxy can
50
+ * answer anything. It simply means "this reply said nothing about the expiry",
51
+ * and the machine keeps the date it already holds. Best-effort throughout — a
52
+ * heartbeat never fails a sync.
53
+ */
54
+ export async function recordHeartbeatTokenExpiry(homeDir, response) {
55
+ const expiresAt = await readTokenExpiry(response);
56
+ if (!expiresAt)
57
+ return;
58
+ await recordDeviceTokenExpiry({
59
+ paths: getCollectorRuntimePaths(homeDir),
60
+ expiresAt,
61
+ }).catch((error) => {
62
+ console.error("[heartbeat] the token expiry the dashboard reported was not recorded", JSON.stringify({
63
+ reason: "token_expiry_writeback_threw",
64
+ ...describeError(error),
65
+ }));
66
+ return null;
67
+ });
68
+ }
69
+ async function readTokenExpiry(response) {
70
+ try {
71
+ const body = (await response.json());
72
+ if (!body || typeof body !== "object")
73
+ return null;
74
+ const value = body["token_expires_at"];
75
+ return typeof value === "string" && value ? value : null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
@@ -28,7 +28,9 @@ import { readCachedSetupReceipt } from "./setup-receipt.js";
28
28
  import { readStagingInventory } from "../disk-usage.js";
29
29
  import { describeError } from "../health-detail.js";
30
30
  import { shouldSuppressFleetReceipts, } from "../dev-build.js";
31
- import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
31
+ import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
32
+ import { readHeartbeatSession, recordHeartbeatTokenExpiry, } from "./heartbeat-token.js";
33
+ import { classifySyncHealthError } from "../sync-health-class.js";
32
34
  import { readMemoryReceiptFile } from "./memory-install-receipt.js";
33
35
  import { readMemoryHookCounts } from "./memory-hook-counts.js";
34
36
  import { readMemoryHookPerformance } from "./memory-hook-performance.js";
@@ -232,18 +234,14 @@ export async function sendCollectorHeartbeatBestEffort(options) {
232
234
  }));
233
235
  return false;
234
236
  }
235
- const paths = getCollectorRuntimePaths(options.homeDir);
236
- const session = await readLocalCollectorSessionFile(paths).catch(() => null);
237
- if (!session ||
238
- session.session_state !== "valid" ||
239
- typeof session.device_token !== "string" ||
240
- !session.device_token) {
241
- console.error("[heartbeat] no valid device session on this machine; the dashboard will show it as quiet", JSON.stringify({
242
- reason: "no_device_session",
243
- next_action: "run `cockpit do-everything` to pair this machine again",
244
- }));
237
+ // BLI-4019. Whether this machine may knock at all, and the two log lines
238
+ // that go with it, live in `heartbeat-token.ts` the check-in goes out even
239
+ // when the local copy says `expired`, because that is the door the server
240
+ // renews a lapsed token through.
241
+ const reading = await readHeartbeatSession(options.homeDir);
242
+ if (!reading.usable)
245
243
  return false;
246
- }
244
+ const { session } = reading;
247
245
  const memory = await readHeartbeatMemoryReceipt({
248
246
  ...(options.homeDir ? { homeDir: options.homeDir } : {}),
249
247
  ...(options.now ? { now: options.now } : {}),
@@ -287,13 +285,27 @@ export async function sendCollectorHeartbeatBestEffort(options) {
287
285
  });
288
286
  if (!response.ok) {
289
287
  // A heartbeat is not retried, so the reason has to be said once, here.
288
+ // BLI-4019: the CLASS comes from `sync-health-class.ts`, the one place
289
+ // that decides what a failure is — a 401/403 is `auth_failed` and
290
+ // anything else is not, read off the observed status and never off the
291
+ // words in a message (BLI-3551's rule).
290
292
  console.error("[heartbeat] the dashboard refused this tick's heartbeat", JSON.stringify({
291
293
  reason: "heartbeat_rejected",
294
+ health_class: classifySyncHealthError({ httpStatus: response.status }),
292
295
  http_status: response.status,
293
296
  root_count: heartbeat.roots.length,
297
+ ...(response.status === 401 || response.status === 403
298
+ ? {
299
+ next_action: "past the 30-day grace window this needs `cockpit login` on this machine",
300
+ }
301
+ : {}),
294
302
  }));
295
303
  return false;
296
304
  }
305
+ // BLI-4019. The server slides `token_expires_at` out on every call it
306
+ // authenticates and answers with the result; the sibling records it, so
307
+ // this machine's own copy follows the server rather than drifting.
308
+ await recordHeartbeatTokenExpiry(options.homeDir, response);
297
309
  console.error("[heartbeat] checked in", JSON.stringify({
298
310
  reason: "heartbeat_recorded",
299
311
  http_status: response.status,
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.95");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.96");
19
19
  return 0;
20
20
  }
21
21
 
@@ -21,6 +21,20 @@ const FIXES = {
21
21
  "codex.hooks": "Run `cockpit memory install`.",
22
22
  "collector.autostart": "Run `cockpit autostart install`.",
23
23
  };
24
+ /**
25
+ * A fix keyed on the piece's REASON rather than the piece, for the cases where
26
+ * the same gap has two different next actions (BLI-4019).
27
+ *
28
+ * `device` says "run `cockpit login`" for every lapse, and for an expired
29
+ * session that is now the wrong instruction most of the time: the dashboard
30
+ * slides a device token's expiry out on every call it authenticates and renews
31
+ * one that lapsed inside a 30-day grace window, so the ordinary answer is to do
32
+ * nothing and let the next tick fix it. Telling somebody to sign in again on a
33
+ * machine that is about to heal itself is how a receipt stops being believed.
34
+ */
35
+ const REASON_FIXES = {
36
+ session_expired: "Renews on the next sync tick while inside the 30-day grace window; past it, run `cockpit login`.",
37
+ };
24
38
  /** Codex hooks a person switched OFF is a decision, not a fault. */
25
39
  const UNSUPPORTED_FIX = "Switched off in your own config; nothing to do.";
26
40
  /**
@@ -64,6 +78,9 @@ function fixFor(key, piece) {
64
78
  if (piece.status === "skipped") {
65
79
  return "Skipped on purpose; nothing to do.";
66
80
  }
81
+ // The reason wins over the piece when it has its own next action.
82
+ if (piece.reason && REASON_FIXES[piece.reason])
83
+ return REASON_FIXES[piece.reason];
67
84
  return FIXES[key] ?? "Run `cockpit doctor`.";
68
85
  }
69
86
  function reasonSuffix(piece) {
@@ -7,7 +7,7 @@ import { serverFailureDetail } from "./upload-http.js";
7
7
  import { ensureRuntimeDirectories, getCollectorRuntimePaths, } from "./local-state-paths.js";
8
8
  import { isMissingFileError, writeJsonFile } from "./local-state-files.js";
9
9
  import { defaultDeviceName, LOCAL_COLLECTOR_VERSION, normalizeDashboardUrl, normalizeDeviceName, normalizeOptionalEmail, readLocalCollectorConfig, } from "./local-state-config.js";
10
- import { toSessionReference } from "./local-state-session.js";
10
+ import { readLocalCollectorSessionFile, toSessionReference, } from "./local-state-session.js";
11
11
  /**
12
12
  * `cockpit login` — the whole device-pairing handshake, because a machine can
13
13
  * collect locally without it but can never upload until the dashboard has
@@ -106,6 +106,46 @@ export async function logoutLocalCollector(options = {}) {
106
106
  }
107
107
  return { removed, session_file: paths.session_file };
108
108
  }
109
+ export async function recordDeviceTokenExpiry(options) {
110
+ const parsed = Date.parse(options.expiresAt);
111
+ if (!Number.isFinite(parsed)) {
112
+ console.error("[local-state] the dashboard's token expiry was not a date; this machine keeps the one it has", JSON.stringify({ reason: "token_expiry_unreadable" }));
113
+ return { status: "skipped", reason: "token_expiry_unreadable" };
114
+ }
115
+ let session;
116
+ try {
117
+ session = await readLocalCollectorSessionFile(options.paths);
118
+ }
119
+ catch (error) {
120
+ console.error("[local-state] no readable session file to record the token expiry on", JSON.stringify({
121
+ reason: "session_file_unreadable",
122
+ ...describeError(error),
123
+ }));
124
+ return { status: "skipped", reason: "session_file_unreadable" };
125
+ }
126
+ if (session.expires_at === options.expiresAt) {
127
+ return { status: "unchanged", expires_at: options.expiresAt };
128
+ }
129
+ try {
130
+ await writeJsonFile(options.paths.session_file, {
131
+ ...session,
132
+ expires_at: options.expiresAt,
133
+ });
134
+ }
135
+ catch (error) {
136
+ console.error("[local-state] the renewed token expiry could not be written; this machine still believes the old one", JSON.stringify({
137
+ reason: "session_file_write_failed",
138
+ ...describeError(error),
139
+ }));
140
+ return { status: "skipped", reason: "session_file_write_failed" };
141
+ }
142
+ console.error("[local-state] recorded the token expiry the dashboard reported", JSON.stringify({
143
+ reason: "token_expiry_recorded",
144
+ previous_expires_at: session.expires_at ?? null,
145
+ expires_at: options.expiresAt,
146
+ }));
147
+ return { status: "written", expires_at: options.expiresAt };
148
+ }
109
149
  async function postPairStart(fetchImpl, dashboardUrl, body, accessToken) {
110
150
  const response = await fetchImpl(`${dashboardUrl}/api/ambient/pair/start`, {
111
151
  method: "POST",
@@ -37,8 +37,8 @@
37
37
  */
38
38
  export { getCollectorRuntimePaths } from "./local-state-paths.js";
39
39
  export { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, installLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, } from "./local-state-config.js";
40
- export { readLocalCollectorSessionFile, readLocalSessionReference, } from "./local-state-session.js";
41
- export { logoutLocalCollector, pairLocalCollector, } from "./local-state-pairing.js";
40
+ export { readLocalCollectorSessionFile, readLocalSessionReference, toSessionReference, } from "./local-state-session.js";
41
+ export { logoutLocalCollector, pairLocalCollector, recordDeviceTokenExpiry, } from "./local-state-pairing.js";
42
42
  export { PairingCodeError, pairLocalCollectorViaLink, } from "./local-state-pairing-code.js";
43
43
  export { resolveGitBranch } from "./local-state-identity.js";
44
44
  export { readLocalWorkContext, readLocalWorkContextForRepo, startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from "./local-state-work-context.js";
@@ -60,6 +60,13 @@ async function readPairedCollector(paths) {
60
60
  throw new LocalUploadBlockedError("unpaired", "No paired collector session found. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
61
61
  });
62
62
  const session = await readLocalSessionReference(paths);
63
+ // BLI-4019 deliberately did NOT loosen this. The heartbeat now knocks while
64
+ // the machine believes it is expired — that is the door the server renews it
65
+ // through — but an UPLOAD stays gated on `valid`, so a machine past the
66
+ // 30-day grace window never spends bandwidth and staging on evidence the
67
+ // server will refuse. One tick after the renewal lands, this reads `valid`
68
+ // again, because the heartbeat writes the server's date back into
69
+ // `session.json` (`recordDeviceTokenExpiry`).
63
70
  if (session.session_state !== "valid") {
64
71
  throw new LocalUploadBlockedError("unpaired", session.session_state === "expired"
65
72
  ? "Collector session expired. Run `cockpit login` or `cockpit pair` again before `cockpit sync`."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.95",
3
+ "version": "0.2.96",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {