fullcourtdefense-cli 1.36.2 → 1.36.4

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,50 @@
1
+ import type { FlushFailure } from './telemetry';
2
+ /**
3
+ * Server-assisted identity recovery (#1853).
4
+ *
5
+ * When events:batch answers 409 MACHINE_NOT_ENROLLED, the id this machine
6
+ * sends is not the one its shield is enrolled under (a stale fallback id
7
+ * after a blocked native read). The daemon asks the backend which machine its
8
+ * per-machine shield is bound to. The backend answers only for EXACTLY one
9
+ * live machine record bound to this shield, in the shield's organization,
10
+ * with this hostname, and only to a caller holding the shield key — it never
11
+ * lets the caller choose an id. The answer is pinned as `recovered`.
12
+ */
13
+ export declare const MACHINE_NOT_ENROLLED = "MACHINE_NOT_ENROLLED";
14
+ /** Daemon log wording for a heartbeat that did not land: refused by the backend vs never reached it. */
15
+ export declare function describeHeartbeatMiss(failure: FlushFailure | undefined): string;
16
+ export type RecoveryResult = {
17
+ ok: true;
18
+ machineId: string;
19
+ } | {
20
+ ok: false;
21
+ status?: number;
22
+ code?: string;
23
+ error: string;
24
+ };
25
+ /** POST /api/cli/machines/identity-recovery. Requires the shield key; never throws. */
26
+ export declare function requestIdentityRecovery(input: {
27
+ apiUrl: string;
28
+ shieldId: string;
29
+ shieldKey?: string;
30
+ hostname: string;
31
+ offeredIds: string[];
32
+ timeoutMs?: number;
33
+ fetchImpl?: typeof fetch;
34
+ }): Promise<RecoveryResult>;
35
+ /**
36
+ * Daemon hook: after a refused heartbeat, recover the enrolled id when the
37
+ * refusal is MACHINE_NOT_ENROLLED. At most once per 15 min per process.
38
+ * Returns true when the machine now uses a different (recovered) id.
39
+ */
40
+ export declare function recoverIdentityAfterRefusal(input: {
41
+ failure: FlushFailure | undefined;
42
+ apiUrl: string;
43
+ shieldId?: string;
44
+ shieldKey?: string;
45
+ log: (line: string) => void;
46
+ now?: number;
47
+ fetchImpl?: typeof fetch;
48
+ }): Promise<boolean>;
49
+ /** Test seam: forget the recovery throttle. */
50
+ export declare function resetIdentityRecoveryThrottle(): void;
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MACHINE_NOT_ENROLLED = void 0;
4
+ exports.describeHeartbeatMiss = describeHeartbeatMiss;
5
+ exports.requestIdentityRecovery = requestIdentityRecovery;
6
+ exports.recoverIdentityAfterRefusal = recoverIdentityAfterRefusal;
7
+ exports.resetIdentityRecoveryThrottle = resetIdentityRecoveryThrottle;
8
+ const machineIdentity_1 = require("./machineIdentity");
9
+ /**
10
+ * Server-assisted identity recovery (#1853).
11
+ *
12
+ * When events:batch answers 409 MACHINE_NOT_ENROLLED, the id this machine
13
+ * sends is not the one its shield is enrolled under (a stale fallback id
14
+ * after a blocked native read). The daemon asks the backend which machine its
15
+ * per-machine shield is bound to. The backend answers only for EXACTLY one
16
+ * live machine record bound to this shield, in the shield's organization,
17
+ * with this hostname, and only to a caller holding the shield key — it never
18
+ * lets the caller choose an id. The answer is pinned as `recovered`.
19
+ */
20
+ exports.MACHINE_NOT_ENROLLED = 'MACHINE_NOT_ENROLLED';
21
+ const RECOVERY_RETRY_MS = 15 * 60_000;
22
+ let lastAttemptAt = 0;
23
+ /** Daemon log wording for a heartbeat that did not land: refused by the backend vs never reached it. */
24
+ function describeHeartbeatMiss(failure) {
25
+ if (failure?.kind === 'http' && failure.status) {
26
+ const what = failure.code === exports.MACHINE_NOT_ENROLLED
27
+ ? 'machine not enrolled under this id'
28
+ : failure.code || failure.error || 'request refused';
29
+ return `backend refused the heartbeat (HTTP ${failure.status}: ${what}) — daemon liveness was not recorded`;
30
+ }
31
+ if (failure?.kind === 'locked')
32
+ return 'heartbeat skipped: another flusher held the spool lock — daemon liveness was not recorded';
33
+ const detail = failure?.error ? ` (${failure.error})` : '';
34
+ return `heartbeat did not reach the backend${detail} — daemon liveness was not recorded`;
35
+ }
36
+ /** POST /api/cli/machines/identity-recovery. Requires the shield key; never throws. */
37
+ async function requestIdentityRecovery(input) {
38
+ if (!input.shieldKey)
39
+ return { ok: false, error: 'no shield key on this machine — recovery needs it' };
40
+ try {
41
+ const resp = await (input.fetchImpl || fetch)(`${input.apiUrl}/api/cli/machines/identity-recovery`, {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json', 'x-shield-key': input.shieldKey },
44
+ body: JSON.stringify({ shieldId: input.shieldId, hostname: input.hostname, offeredIds: input.offeredIds }),
45
+ signal: AbortSignal.timeout(input.timeoutMs ?? 8000),
46
+ });
47
+ const body = await resp.json().catch(() => ({}));
48
+ const machineId = typeof body.data?.machineId === 'string' ? body.data.machineId.toLowerCase() : '';
49
+ if (resp.ok && body.success && /^[0-9a-f]{16,64}$/.test(machineId))
50
+ return { ok: true, machineId };
51
+ return { ok: false, status: resp.status, code: body.code, error: body.error || `HTTP ${resp.status}` };
52
+ }
53
+ catch (error) {
54
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
55
+ }
56
+ }
57
+ /**
58
+ * Daemon hook: after a refused heartbeat, recover the enrolled id when the
59
+ * refusal is MACHINE_NOT_ENROLLED. At most once per 15 min per process.
60
+ * Returns true when the machine now uses a different (recovered) id.
61
+ */
62
+ async function recoverIdentityAfterRefusal(input) {
63
+ if (input.failure?.code !== exports.MACHINE_NOT_ENROLLED || !input.shieldId)
64
+ return false;
65
+ if ((process.env.FCD_MACHINE_ID || '').trim())
66
+ return false;
67
+ const now = input.now ?? Date.now();
68
+ if (now - lastAttemptAt < RECOVERY_RETRY_MS)
69
+ return false;
70
+ lastAttemptAt = now;
71
+ const identity = (0, machineIdentity_1.getMachineIdentity)();
72
+ const pin = (0, machineIdentity_1.readPinnedIdentity)();
73
+ const offeredIds = [...new Set([identity.machineId, pin?.machineId].filter((id) => !!id).map(machineIdentity_1.shortMachineId))];
74
+ const result = await requestIdentityRecovery({
75
+ apiUrl: input.apiUrl,
76
+ shieldId: input.shieldId,
77
+ shieldKey: input.shieldKey,
78
+ hostname: identity.hostname,
79
+ offeredIds,
80
+ fetchImpl: input.fetchImpl,
81
+ });
82
+ if (!result.ok) {
83
+ input.log(`Identity recovery: the backend did not name an enrolled machine for this shield (${result.code || result.error}) — this machine keeps id ${(0, machineIdentity_1.shortMachineId)(identity.machineId)}; re-enroll with \`fullcourtdefense onboard --token <token>\` if it persists.`);
84
+ return false;
85
+ }
86
+ if (result.machineId === identity.machineId)
87
+ return false;
88
+ const outcome = (0, machineIdentity_1.confirmMachineIdentity)(result.machineId, 'recovered');
89
+ if (outcome === 'conflict') {
90
+ input.log(`Identity recovery: the backend names ${(0, machineIdentity_1.shortMachineId)(result.machineId)} but this machine holds a confirmed id ${(0, machineIdentity_1.shortMachineId)(identity.machineId)} — kept; reported as machine_id_conflict.`);
91
+ return false;
92
+ }
93
+ input.log(`Identity recovery: this machine was sending id ${(0, machineIdentity_1.shortMachineId)(identity.machineId)}; its shield is enrolled as ${(0, machineIdentity_1.shortMachineId)(result.machineId)} — pinned (${outcome}).`);
94
+ return (0, machineIdentity_1.getMachineIdentity)().machineId === result.machineId;
95
+ }
96
+ /** Test seam: forget the recovery throttle. */
97
+ function resetIdentityRecoveryThrottle() {
98
+ lastAttemptAt = 0;
99
+ }
@@ -11,14 +11,102 @@ export interface MachineIdentity {
11
11
  platform: NodeJS.Platform;
12
12
  /** Friendly OS label, e.g. `Windows 11`, `macOS 14`, `Ubuntu 22.04`. */
13
13
  osFriendly: string;
14
+ /** Where `machineId` came from (`ci` = FCD_MACHINE_ID override). */
15
+ idSource?: IdentitySource | 'ci';
16
+ /** True when the backend has accepted this id (enrollment / heartbeat / recovery). */
17
+ idConfirmed?: boolean;
14
18
  }
19
+ /** Origin of a pinned id. `enrollment` and `recovered` are backend-confirmed by definition. */
20
+ export type IdentitySource = 'native' | 'fallback' | 'enrollment' | 'recovered';
21
+ export interface PinnedIdentity {
22
+ schema: 1;
23
+ machineId: string;
24
+ source: IdentitySource;
25
+ firstSeen: string;
26
+ updatedAt: string;
27
+ /** Set once the backend accepted this id. */
28
+ confirmedAt?: string;
29
+ /** Normalized hostname at pin time; a change allows one native re-check (clone / rename). */
30
+ hostname?: string;
31
+ }
32
+ /**
33
+ * How far resolution may go to read the OS id:
34
+ * - `unpinned` (every process): only when there is no pin, throttled;
35
+ * - `heal` (daemon boot): also re-check an unconfirmed fallback pin, throttled;
36
+ * - `force` (enrollment): re-check an unconfirmed pin regardless of the throttle.
37
+ */
38
+ export type NativeReadMode = 'unpinned' | 'heal' | 'force';
39
+ export interface IdentityReconcileReport {
40
+ machineId: string;
41
+ source: IdentitySource;
42
+ confirmed: boolean;
43
+ /** A native read was attempted by this call. */
44
+ probed: boolean;
45
+ probeError?: string;
46
+ /** The pin moved from an unconfirmed fallback id to the native one. */
47
+ healed?: {
48
+ from: string;
49
+ to: string;
50
+ };
51
+ /** Native disagrees with a backend-confirmed pin (kept). */
52
+ conflict?: {
53
+ pinned: string;
54
+ native: string;
55
+ };
56
+ }
57
+ interface IdentityDeps {
58
+ execFileSync: (file: string, args: string[], options: {
59
+ encoding: 'utf8';
60
+ windowsHide: boolean;
61
+ timeout: number;
62
+ }) => string;
63
+ platform: () => NodeJS.Platform;
64
+ now: () => number;
65
+ uptimeSeconds: () => number;
66
+ hostname: () => string;
67
+ /** Linux: /etc/machine-id, else /var/lib/dbus/machine-id (a file read, no spawn). */
68
+ linuxMachineId: () => string | undefined;
69
+ }
70
+ /** Test seam: replace the spawn / clock / platform, and forget the cached identity. */
71
+ export declare function __setMachineIdentityDepsForTest(overrides?: Partial<IdentityDeps>): void;
72
+ /** Forget the in-process identity (the pin changed). */
73
+ export declare function resetMachineIdentityCache(): void;
15
74
  /** Normalize a hostname: strip `.local`/domain suffix, lowercase. */
16
75
  export declare function normalizeHostname(raw: string): string;
76
+ /** First 8 hex chars — how ids appear in logs, distress details and recovery requests. */
77
+ export declare function shortMachineId(machineId: string): string;
17
78
  /**
18
- * Compute (and cache) the stable machine identity. The raw OS id is hashed
79
+ * CLI commands that inspect or remove the installation: `uninstall-*`,
80
+ * `*-status`, the removal commands, and `--uninstall` / `--unschedule` /
81
+ * `--status` runs of the daemon, auto-protect and discover.
82
+ */
83
+ export declare function isIdentityReadOnlyCommand(command: string, flags?: Record<string, string | undefined>): boolean;
84
+ /**
85
+ * Inspect / remove commands (verify-removed, uninstall, doctor, …): resolve
86
+ * from what is on disk, write nothing and never spawn the native read.
87
+ */
88
+ export declare function setMachineIdentityReadOnly(on: boolean): void;
89
+ export declare function isPinConfirmed(pin: PinnedIdentity): boolean;
90
+ export declare function readPinnedIdentity(): PinnedIdentity | undefined;
91
+ /**
92
+ * The stable machine identity (cached per process). The raw OS id is hashed
19
93
  * (SHA-256, 32 hex chars) so we never transmit the raw hardware UUID.
20
94
  */
21
95
  export declare function getMachineIdentity(): MachineIdentity;
96
+ /**
97
+ * Re-resolve with a wider native-read allowance: the daemon at boot (`heal`)
98
+ * and enrollment (`force`). Replaces the cached identity.
99
+ */
100
+ export declare function reconcileMachineIdentity(mode: Exclude<NativeReadMode, 'unpinned'>): IdentityReconcileReport;
101
+ export type ConfirmOutcome = 'pinned' | 'already' | 'conflict' | 'skipped';
102
+ /**
103
+ * Record that the backend accepted `machineId` for this machine:
104
+ * - `enrollment`: the enroll call succeeded — authoritative, always pinned;
105
+ * - `heartbeat`: events:batch returned 200 for the id this process uses;
106
+ * - `recovered`: the backend named the machine bound to this shield. Never
107
+ * replaces a different confirmed id (records `machine_id_conflict`).
108
+ */
109
+ export declare function confirmMachineIdentity(machineId: string, how: 'enrollment' | 'heartbeat' | 'recovered'): ConfirmOutcome;
22
110
  export type LocalMachineKind = 'workstation' | 'ci' | 'workload';
23
111
  /**
24
112
  * Which KIND of fleet machine this process belongs to — the value the policy
@@ -53,3 +141,4 @@ export declare function stampRuntimeIdentityContext<T extends Record<string, str
53
141
  }): T;
54
142
  /** Suggested per-machine Shield name, e.g. `boazl@boaz-pc · Windows 11`. */
55
143
  export declare function suggestedShieldName(identity?: MachineIdentity): string;
144
+ export {};