@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.
- package/dist/commands/heartbeat-token.js +80 -0
- package/dist/commands/heartbeat.js +24 -12
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/setup-receipt-lines.js +17 -0
- package/dist/local-state-pairing.js +41 -1
- package/dist/local-state.js +2 -2
- package/dist/upload-envelope-build.js +7 -0
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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.
|
|
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",
|
package/dist/local-state.js
CHANGED
|
@@ -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`."
|