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.
@@ -33,8 +33,17 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.__setMachineIdentityDepsForTest = __setMachineIdentityDepsForTest;
37
+ exports.resetMachineIdentityCache = resetMachineIdentityCache;
36
38
  exports.normalizeHostname = normalizeHostname;
39
+ exports.shortMachineId = shortMachineId;
40
+ exports.isIdentityReadOnlyCommand = isIdentityReadOnlyCommand;
41
+ exports.setMachineIdentityReadOnly = setMachineIdentityReadOnly;
42
+ exports.isPinConfirmed = isPinConfirmed;
43
+ exports.readPinnedIdentity = readPinnedIdentity;
37
44
  exports.getMachineIdentity = getMachineIdentity;
45
+ exports.reconcileMachineIdentity = reconcileMachineIdentity;
46
+ exports.confirmMachineIdentity = confirmMachineIdentity;
38
47
  exports.localMachineKind = localMachineKind;
39
48
  exports.resetLocalMachineKindCache = resetLocalMachineKindCache;
40
49
  exports.stampMachineIdentityContext = stampMachineIdentityContext;
@@ -46,6 +55,7 @@ const fs = __importStar(require("fs"));
46
55
  const os = __importStar(require("os"));
47
56
  const path = __importStar(require("path"));
48
57
  const devConfirm_1 = require("./devConfirm");
58
+ const distress_1 = require("./distress");
49
59
  /**
50
60
  * OS-agnostic stable machine identity.
51
61
  *
@@ -60,8 +70,49 @@ const devConfirm_1 = require("./devConfirm");
60
70
  * - Linux: /etc/machine-id (fallback /var/lib/dbus/machine-id)
61
71
  *
62
72
  * `username@hostname` is kept only as a human-readable display label.
73
+ *
74
+ * PINNING. Reading MachineGuid needs a `reg.exe` child process, and Microsoft
75
+ * Defender flags that exact command line (`Trojan:Win32/Commando.A!ml`,
76
+ * FastPath) on machines that start many CLI processes: the spawn fails with
77
+ * EPERM and the id used to fall back to whatever a stale fallback file held —
78
+ * the machine silently changed identity and the backend refused it (#1853).
79
+ * So the id is resolved ONCE and pinned in `identity.json`; every later
80
+ * process (hooks, flush-spool, watchdog, daemon) reads the pin and spawns
81
+ * nothing. The only remaining spawns:
82
+ * - no pin at all (first run after install/upgrade): one native read,
83
+ * at most once per boot and 24 h per user (`identity-probe.json`);
84
+ * - an UNCONFIRMED fallback pin, or a pin whose hostname no longer matches:
85
+ * the daemon (`reconcileMachineIdentity('heal')`) re-reads natively under
86
+ * the same throttle; `login` re-reads unthrottled (a human is enrolling).
87
+ * Node has no registry API and no bundled N-API module exposes one; a
88
+ * PowerShell read is also a child process, so reg.exe stays for that one read.
89
+ *
90
+ * Precedence: backend-confirmed (enrollment, recovery, or a heartbeat the
91
+ * backend accepted) > native > fallback. A confirmed id is never replaced by
92
+ * a native read that disagrees — that records `machine_id_conflict` and keeps
93
+ * the confirmed id. The pin is per-user and written only into an existing
94
+ * `~/.fullcourtdefense`; inspect / remove commands run read-only (#1870).
63
95
  */
64
96
  let cached;
97
+ const defaultDeps = {
98
+ execFileSync: (file, args, options) => String((0, child_process_1.execFileSync)(file, args, options)),
99
+ platform: () => os.platform(),
100
+ now: () => Date.now(),
101
+ uptimeSeconds: () => os.uptime(),
102
+ hostname: () => os.hostname(),
103
+ linuxMachineId: () => safe(() => fs.readFileSync('/etc/machine-id', 'utf8'))
104
+ || safe(() => fs.readFileSync('/var/lib/dbus/machine-id', 'utf8')),
105
+ };
106
+ let deps = defaultDeps;
107
+ /** Test seam: replace the spawn / clock / platform, and forget the cached identity. */
108
+ function __setMachineIdentityDepsForTest(overrides) {
109
+ deps = overrides ? { ...defaultDeps, ...overrides } : defaultDeps;
110
+ cached = undefined;
111
+ }
112
+ /** Forget the in-process identity (the pin changed). */
113
+ function resetMachineIdentityCache() {
114
+ cached = undefined;
115
+ }
65
116
  function safe(fn) {
66
117
  try {
67
118
  const value = fn();
@@ -79,79 +130,290 @@ function normalizeHostname(raw) {
79
130
  .replace(/\..*$/, '') // drop any remaining domain suffix
80
131
  .toLowerCase() || 'unknown-host';
81
132
  }
133
+ /** First 8 hex chars — how ids appear in logs, distress details and recovery requests. */
134
+ function shortMachineId(machineId) {
135
+ return machineId.slice(0, 8);
136
+ }
137
+ const MACHINE_ID_RE = /^[0-9a-f]{16,64}$/;
138
+ /** A spawn the OS or an EDR refused, or that hung: repeating the same command line cannot help. */
139
+ const SPAWN_REFUSED_RE = /^(EPERM|EACCES|ENOENT|ETIMEDOUT|EBUSY|UNKNOWN)$/;
140
+ function spawnFailureReason(error) {
141
+ const e = error;
142
+ if (typeof e?.code === 'string' && e.code)
143
+ return e.code;
144
+ if (typeof e?.status === 'number')
145
+ return `exit ${e.status}`;
146
+ return String((e && e.message) || error).slice(0, 80);
147
+ }
148
+ /** Linux reads files; Windows (reg.exe) and macOS (ioreg) need a child process. */
149
+ function nativeReadSpawns() {
150
+ const platform = deps.platform();
151
+ return platform === 'win32' || platform === 'darwin';
152
+ }
82
153
  /** Raw OS-native stable id (UNHASHED). Internal — prefer `getMachineIdentity().machineId`. */
83
- function rawStableId() {
84
- const platform = os.platform();
154
+ function readNativeStableId() {
155
+ const platform = deps.platform();
85
156
  if (platform === 'win32') {
86
157
  // MachineGuid lives only in the 64-bit registry view. When this process is
87
158
  // 32-bit (e.g. spawned by an MSI custom action via SysWOW64 PowerShell),
88
159
  // a plain query is redirected to WOW6432Node and finds nothing — which
89
160
  // would silently change the machineId and enroll a DUPLICATE fleet record.
90
161
  // Query the 64-bit view first, then fall back for true 32-bit Windows.
91
- const queryMachineGuid = (extraArgs) => safe(() => {
92
- const out = (0, child_process_1.execFileSync)('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid', ...extraArgs], { encoding: 'utf8', windowsHide: true, timeout: 4000 });
93
- const match = out.match(/MachineGuid\s+REG_SZ\s+([\w-]+)/i);
94
- return match ? match[1] : undefined;
95
- });
96
- return queryMachineGuid(['/reg:64']) || queryMachineGuid([]);
162
+ const query = (extraArgs) => {
163
+ try {
164
+ const out = deps.execFileSync('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid', ...extraArgs], { encoding: 'utf8', windowsHide: true, timeout: 4000 });
165
+ const match = out.match(/MachineGuid\s+REG_SZ\s+([\w-]+)/i);
166
+ return match ? { ok: true, raw: match[1] } : { ok: false, reason: 'MachineGuid missing from reg output' };
167
+ }
168
+ catch (error) {
169
+ return { ok: false, reason: spawnFailureReason(error) };
170
+ }
171
+ };
172
+ const view64 = query(['/reg:64']);
173
+ if (view64.ok || SPAWN_REFUSED_RE.test(view64.reason))
174
+ return view64;
175
+ return query([]);
97
176
  }
98
177
  if (platform === 'darwin') {
99
- return safe(() => {
100
- const out = (0, child_process_1.execFileSync)('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { encoding: 'utf8', timeout: 4000 });
178
+ try {
179
+ const out = deps.execFileSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { encoding: 'utf8', windowsHide: true, timeout: 4000 });
101
180
  const match = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
102
- return match ? match[1] : undefined;
103
- });
181
+ return match ? { ok: true, raw: match[1] } : { ok: false, reason: 'IOPlatformUUID missing from ioreg output' };
182
+ }
183
+ catch (error) {
184
+ return { ok: false, reason: spawnFailureReason(error) };
185
+ }
104
186
  }
105
187
  // linux + others
106
- return (safe(() => fs.readFileSync('/etc/machine-id', 'utf8'))
107
- || safe(() => fs.readFileSync('/var/lib/dbus/machine-id', 'utf8')));
188
+ const raw = safe(() => deps.linuxMachineId());
189
+ return raw ? { ok: true, raw } : { ok: false, reason: 'no /etc/machine-id' };
190
+ }
191
+ function hashNativeId(raw) {
192
+ return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 32);
108
193
  }
109
194
  /**
110
- * Where the FALLBACK machine id is persisted when no OS-native stable id is
111
- * readable (locked-down registry / missing /etc/machine-id). Two locations:
112
- *
113
- * - machine-wide (ProgramData on Windows): shared by EVERY user AND the
114
- * SYSTEM/S4U daemon tasks. Without it, the composite fallback — which
115
- * includes the username — gives the daemon (running as SYSTEM) a DIFFERENT
116
- * machineId than the enrolling user, creating duplicate fleet records with
117
- * split telemetry for the same laptop.
118
- * - per-user (~/.fullcourtdefense): fallback when ProgramData is not
119
- * writable; keeps the id stable across upgrades for that user at least.
120
- *
121
- * The persisted value is the final HASHED id (never the raw composite).
195
+ * Where the pin lives: the per-user state directory only, and only when it
196
+ * already exists (an installed / enrolled machine). Identity persistence never
197
+ * CREATES a directory: `verify-removed` or any command run after an uninstall
198
+ * must leave a machine with zero FullCourtDefense remnants (#1870). The MSI
199
+ * uninstall deletes `~/.fullcourtdefense` whole, so nothing written here
200
+ * survives it. A machine-wide pin needs the MSI's SYSTEM cleanup to remove it
201
+ * first (#1871).
122
202
  */
123
- function persistedIdPaths() {
124
- const perUser = path.join(os.homedir(), '.fullcourtdefense', 'machine-id');
125
- if (os.platform() === 'win32') {
203
+ function pinDir() {
204
+ return path.join(os.homedir(), '.fullcourtdefense');
205
+ }
206
+ /**
207
+ * Legacy `machine-id` locations, machine-wide first. Pre-1.36.3 CLIs wrote the
208
+ * fallback id here when the native read failed; it is READ for convergence
209
+ * (every user and the SYSTEM daemon on a locked-down machine agree on it) and
210
+ * REWRITTEN to the pinned id only where it already exists (heals old CLIs on a
211
+ * downgrade). Never created.
212
+ */
213
+ function legacyIdFiles() {
214
+ const perUser = path.join(pinDir(), LEGACY_FILE);
215
+ if (deps.platform() === 'win32') {
126
216
  const programData = process.env.ProgramData || 'C:\\ProgramData';
127
- return { machineWide: path.join(programData, 'FullCourtDefense', 'machine-id'), perUser };
217
+ return [path.join(programData, 'FullCourtDefense', LEGACY_FILE), perUser];
128
218
  }
129
- return { perUser };
219
+ return [perUser];
220
+ }
221
+ /** Authoritative pin (JSON). */
222
+ const PIN_FILE = 'identity.json';
223
+ /** Legacy fallback id (bare hashed id), see legacyIdFiles(). */
224
+ const LEGACY_FILE = 'machine-id';
225
+ /** Native-read throttle marker. */
226
+ const PROBE_FILE = 'identity-probe.json';
227
+ const PROBE_RETRY_MS = 24 * 60 * 60_000;
228
+ /** Two boot-time estimates this close are the same boot (uptime is read in whole seconds, clocks drift). */
229
+ const SAME_BOOT_TOLERANCE_MS = 5 * 60_000;
230
+ const READ_ONLY_COMMANDS = new Set([
231
+ 'verify-removed', 'uninstall', 'uninstall-all', 'detach', 'doctor', 'help',
232
+ 'unprotect-all', 'unwrap-all', 'rollback-codex-managed-hooks',
233
+ 'hotfix-status', 'codex-hook-status', 'shell-guard-status', 'cmd-guard-status',
234
+ ]);
235
+ /**
236
+ * CLI commands that inspect or remove the installation: `uninstall-*`,
237
+ * `*-status`, the removal commands, and `--uninstall` / `--unschedule` /
238
+ * `--status` runs of the daemon, auto-protect and discover.
239
+ */
240
+ function isIdentityReadOnlyCommand(command, flags = {}) {
241
+ if (READ_ONLY_COMMANDS.has(command) || command.startsWith('uninstall-') || command.endsWith('-status'))
242
+ return true;
243
+ return ['uninstall', 'unschedule', 'status'].some(flag => flags[flag] !== undefined && String(flags[flag]) !== 'false');
130
244
  }
131
- function readPersistedFallbackId() {
132
- const { machineWide, perUser } = persistedIdPaths();
133
- for (const file of [machineWide, perUser]) {
134
- if (!file)
245
+ /**
246
+ * Read-only mode decided from the CLI's own argv when this module loads:
247
+ * modules imported by the entry resolve the id at import time (detector
248
+ * updates), before the command dispatch could switch anything.
249
+ */
250
+ function readOnlyFromArgv(argv) {
251
+ if (!/(^|[\\/])index\.js$/.test(argv[1] || ''))
252
+ return false;
253
+ const [command = '', ...rest] = argv.slice(2);
254
+ const flags = {};
255
+ for (let i = 0; i < rest.length; i++) {
256
+ if (!rest[i].startsWith('--'))
135
257
  continue;
258
+ const next = rest[i + 1];
259
+ flags[rest[i].slice(2)] = next !== undefined && !next.startsWith('--') ? next : 'true';
260
+ }
261
+ return isIdentityReadOnlyCommand(command, flags);
262
+ }
263
+ let readOnly = readOnlyFromArgv(process.argv);
264
+ /**
265
+ * Inspect / remove commands (verify-removed, uninstall, doctor, …): resolve
266
+ * from what is on disk, write nothing and never spawn the native read.
267
+ */
268
+ function setMachineIdentityReadOnly(on) {
269
+ readOnly = on;
270
+ cached = undefined;
271
+ }
272
+ /** Atomic write into an EXISTING directory; never creates one. */
273
+ function writeAtomic(file, body) {
274
+ if (readOnly)
275
+ return false;
276
+ const dir = path.dirname(file);
277
+ try {
278
+ if (!fs.statSync(dir).isDirectory())
279
+ return false;
280
+ }
281
+ catch {
282
+ return false;
283
+ }
284
+ const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(3).toString('hex')}`;
285
+ try {
286
+ fs.writeFileSync(tmp, body, 'utf8');
287
+ fs.renameSync(tmp, file);
288
+ return true;
289
+ }
290
+ catch {
291
+ try {
292
+ fs.unlinkSync(tmp);
293
+ }
294
+ catch { /* never created */ }
295
+ return false;
296
+ }
297
+ }
298
+ function parsePin(text) {
299
+ if (!text)
300
+ return undefined;
301
+ try {
302
+ const raw = JSON.parse(text);
303
+ const machineId = typeof raw.machineId === 'string' ? raw.machineId.toLowerCase() : '';
304
+ if (!MACHINE_ID_RE.test(machineId))
305
+ return undefined;
306
+ if (!['native', 'fallback', 'enrollment', 'recovered'].includes(String(raw.source)))
307
+ return undefined;
308
+ const now = new Date(deps.now()).toISOString();
309
+ return {
310
+ schema: 1,
311
+ machineId,
312
+ source: raw.source,
313
+ firstSeen: typeof raw.firstSeen === 'string' ? raw.firstSeen : now,
314
+ updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : now,
315
+ ...(typeof raw.confirmedAt === 'string' ? { confirmedAt: raw.confirmedAt } : {}),
316
+ ...(typeof raw.hostname === 'string' && raw.hostname ? { hostname: raw.hostname } : {}),
317
+ };
318
+ }
319
+ catch {
320
+ return undefined;
321
+ }
322
+ }
323
+ function isPinConfirmed(pin) {
324
+ return !!pin.confirmedAt || pin.source === 'enrollment' || pin.source === 'recovered';
325
+ }
326
+ function pinRank(pin) {
327
+ if (isPinConfirmed(pin))
328
+ return 3;
329
+ return pin.source === 'native' ? 2 : 1;
330
+ }
331
+ function readPinnedIdentity() {
332
+ return parsePin(safe(() => fs.readFileSync(path.join(pinDir(), PIN_FILE), 'utf8')));
333
+ }
334
+ /**
335
+ * Write the pin, and sync the legacy files that already exist. Never
336
+ * downgrades: an existing pin that outranks this one stays, and a confirmed
337
+ * pin for a different id is only replaced by a new confirmed one (`force`).
338
+ */
339
+ function writePin(pin, options = {}) {
340
+ const existing = readPinnedIdentity();
341
+ if (existing && !options.force) {
342
+ if (pinRank(existing) > pinRank(pin))
343
+ return false;
344
+ if (existing.machineId !== pin.machineId && isPinConfirmed(existing))
345
+ return false;
346
+ }
347
+ if (!writeAtomic(path.join(pinDir(), PIN_FILE), `${JSON.stringify(pin, null, 2)}\n`))
348
+ return false;
349
+ for (const file of legacyIdFiles()) {
350
+ if (fs.existsSync(file))
351
+ writeAtomic(file, pin.machineId);
352
+ }
353
+ return true;
354
+ }
355
+ function readLegacyFallbackId() {
356
+ for (const file of legacyIdFiles()) {
136
357
  const value = safe(() => fs.readFileSync(file, 'utf8'));
137
- if (value && /^[0-9a-f]{16,64}$/i.test(value))
358
+ if (value && MACHINE_ID_RE.test(value.toLowerCase()))
138
359
  return value.toLowerCase();
139
360
  }
140
361
  return undefined;
141
362
  }
142
- function persistFallbackId(machineId) {
143
- const { machineWide, perUser } = persistedIdPaths();
144
- for (const file of [machineWide, perUser]) {
145
- if (!file)
146
- continue;
147
- try {
148
- fs.mkdirSync(path.dirname(file), { recursive: true });
149
- const tmp = `${file}.tmp-${process.pid}`;
150
- fs.writeFileSync(tmp, machineId, 'utf8');
151
- fs.renameSync(tmp, file);
152
- }
153
- catch { /* best-effort — the composite recomputes deterministically for this user */ }
363
+ function bootStartedAt() {
364
+ return deps.now() - deps.uptimeSeconds() * 1000;
365
+ }
366
+ /** A native read already happened (or is in flight) this boot, within 24 h. */
367
+ function probeThrottled() {
368
+ let marker;
369
+ try {
370
+ marker = JSON.parse(fs.readFileSync(path.join(pinDir(), PROBE_FILE), 'utf8'));
371
+ }
372
+ catch {
373
+ return false;
154
374
  }
375
+ if (!marker || typeof marker.attemptedAt !== 'string' || typeof marker.bootAt !== 'string')
376
+ return false;
377
+ return Math.abs(Date.parse(marker.bootAt) - bootStartedAt()) < SAME_BOOT_TOLERANCE_MS
378
+ && deps.now() - Date.parse(marker.attemptedAt) < PROBE_RETRY_MS;
379
+ }
380
+ function writeProbeMarker(marker) {
381
+ writeAtomic(path.join(pinDir(), PROBE_FILE), JSON.stringify(marker));
382
+ }
383
+ /** One native read, recorded in the throttle marker BEFORE the spawn so concurrent processes skip it. */
384
+ function probeNative() {
385
+ if (!nativeReadSpawns())
386
+ return readNativeStableId();
387
+ const base = { bootAt: new Date(bootStartedAt()).toISOString(), attemptedAt: new Date(deps.now()).toISOString() };
388
+ writeProbeMarker({ ...base, result: 'pending' });
389
+ const read = readNativeStableId();
390
+ writeProbeMarker({ ...base, result: read.ok ? 'ok' : 'failed', ...(read.ok ? {} : { reason: read.reason }) });
391
+ return read;
392
+ }
393
+ /**
394
+ * With no pin, any process may read (throttled when the read spawns). With a
395
+ * pin, only the daemon / enrollment re-check it — except that a spawn-free
396
+ * read (Linux) may heal an unconfirmed fallback pin anywhere. Hostname and
397
+ * conflict checks therefore never run, or report, per hook process. Read-only
398
+ * commands never spawn.
399
+ */
400
+ function mayProbe(mode, pin) {
401
+ if (readOnly && nativeReadSpawns())
402
+ return false;
403
+ if (!pin)
404
+ return !nativeReadSpawns() || mode === 'force' || !probeThrottled();
405
+ if (mode === 'unpinned')
406
+ return !nativeReadSpawns() && pin.unconfirmedFallback;
407
+ return mode === 'force' || !nativeReadSpawns() || !probeThrottled();
408
+ }
409
+ function nowIso() {
410
+ return new Date(deps.now()).toISOString();
411
+ }
412
+ function currentUser() {
413
+ return safe(() => os.userInfo().username) || process.env.USER || process.env.USERNAME || 'unknown-user';
414
+ }
415
+ function currentHostname() {
416
+ return normalizeHostname(safe(() => deps.hostname()) || 'unknown-host');
155
417
  }
156
418
  function friendlyOs() {
157
419
  const platform = os.platform();
@@ -177,62 +439,163 @@ function friendlyOs() {
177
439
  }
178
440
  return `${platform} ${release}`;
179
441
  }
442
+ /** Ephemeral fleet override (CI / workloads), or undefined on a real machine. */
443
+ function ciOverrideIdentity() {
444
+ // On CI runners the PIPELINE is the fleet identity, not the disposable
445
+ // runner hardware. `ci-protect` enrolls the pipeline and exports these envs
446
+ // (via GITHUB_ENV) so every subsequent hook/gateway process in the job
447
+ // attributes its events to the pipeline machine record.
448
+ const ciMachineId = (process.env.FCD_MACHINE_ID || '').trim();
449
+ if (!ciMachineId)
450
+ return undefined;
451
+ return {
452
+ machineId: ciMachineId.slice(0, 64),
453
+ user: 'ci',
454
+ hostname: (process.env.FCD_MACHINE_HOSTNAME || 'ci-pipeline').trim().toLowerCase().slice(0, 160) || 'ci-pipeline',
455
+ developerName: (process.env.FCD_DEVELOPER_NAME || 'ci@pipeline').trim().toLowerCase().slice(0, 160) || 'ci@pipeline',
456
+ platform: os.platform(),
457
+ osFriendly: 'CI runner',
458
+ idSource: 'ci',
459
+ };
460
+ }
461
+ /** Resolve the id: pin first; native only when the mode and throttle allow it. */
462
+ function resolveMachineId(mode, hostname, user) {
463
+ const pin = readPinnedIdentity();
464
+ if (pin) {
465
+ const unconfirmedFallback = !isPinConfirmed(pin) && pin.source === 'fallback';
466
+ const hostnameChanged = !!pin.hostname && pin.hostname !== hostname;
467
+ const report = { machineId: pin.machineId, source: pin.source, confirmed: isPinConfirmed(pin), probed: false };
468
+ if (!(unconfirmedFallback || hostnameChanged) || !mayProbe(mode, { unconfirmedFallback }))
469
+ return report;
470
+ const native = probeNative();
471
+ report.probed = true;
472
+ if (!native.ok) {
473
+ report.probeError = native.reason;
474
+ return report;
475
+ }
476
+ const nativeId = hashNativeId(native.raw);
477
+ if (nativeId === pin.machineId) {
478
+ writePin({ ...pin, source: pin.source === 'fallback' ? 'native' : pin.source, hostname, updatedAt: nowIso() });
479
+ return { ...report, source: pin.source === 'fallback' ? 'native' : pin.source };
480
+ }
481
+ if (!isPinConfirmed(pin)) {
482
+ // The owner's case (#1853): a stale fallback id beats nothing, but the OS id beats it.
483
+ writePin({ schema: 1, machineId: nativeId, source: 'native', firstSeen: nowIso(), updatedAt: nowIso(), hostname });
484
+ return { machineId: nativeId, source: 'native', confirmed: false, probed: true, healed: { from: pin.machineId, to: nativeId } };
485
+ }
486
+ (0, distress_1.reportDistress)('identity', 'machine_id_conflict', `pinned ${shortMachineId(pin.machineId)} (${pin.source}, confirmed) vs native ${shortMachineId(nativeId)}${hostnameChanged ? ` · hostname ${pin.hostname} -> ${hostname}` : ''}; keeping the confirmed id`);
487
+ return { ...report, conflict: { pinned: pin.machineId, native: nativeId } };
488
+ }
489
+ if (mayProbe(mode, undefined)) {
490
+ const native = probeNative();
491
+ if (native.ok) {
492
+ const machineId = hashNativeId(native.raw);
493
+ writePin({ schema: 1, machineId, source: 'native', firstSeen: nowIso(), updatedAt: nowIso(), hostname });
494
+ return { machineId, source: 'native', confirmed: false, probed: true };
495
+ }
496
+ // Native unreadable: the legacy fallback file (every user + the SYSTEM
497
+ // daemon converge on it), else the legacy composite, which keeps the
498
+ // pre-existing shape (incl. username) so machines already enrolled on this
499
+ // path keep their identity across upgrades. Pinned UNCONFIRMED: the
500
+ // backend's answer to the next heartbeat confirms it or triggers recovery.
501
+ const machineId = readLegacyFallbackId() || hashNativeId(`${hostname}|${user}|${deps.platform()}|${os.arch()}`);
502
+ writePin({ schema: 1, machineId, source: 'fallback', firstSeen: nowIso(), updatedAt: nowIso(), hostname });
503
+ return { machineId, source: 'fallback', confirmed: false, probed: true, probeError: native.reason };
504
+ }
505
+ // Another process read (or is reading) natively this boot: use the fallback
506
+ // in memory and leave pinning to that process.
507
+ const machineId = readLegacyFallbackId() || hashNativeId(`${hostname}|${user}|${deps.platform()}|${os.arch()}`);
508
+ return { machineId, source: 'fallback', confirmed: false, probed: false };
509
+ }
510
+ function buildIdentity(mode) {
511
+ const user = currentUser();
512
+ const hostname = currentHostname();
513
+ const report = resolveMachineId(mode, hostname, user);
514
+ return {
515
+ report,
516
+ identity: {
517
+ machineId: report.machineId,
518
+ user,
519
+ hostname,
520
+ developerName: `${user.toLowerCase()}@${hostname}`,
521
+ platform: os.platform(),
522
+ osFriendly: friendlyOs(),
523
+ idSource: report.source,
524
+ idConfirmed: report.confirmed,
525
+ },
526
+ };
527
+ }
180
528
  /**
181
- * Compute (and cache) the stable machine identity. The raw OS id is hashed
529
+ * The stable machine identity (cached per process). The raw OS id is hashed
182
530
  * (SHA-256, 32 hex chars) so we never transmit the raw hardware UUID.
183
531
  */
184
532
  function getMachineIdentity() {
185
533
  if (cached)
186
534
  return cached;
187
- // Ephemeral fleet override: on CI runners the PIPELINE is the fleet identity,
188
- // not the disposable runner hardware. `ci-protect` enrolls the pipeline and
189
- // exports these envs (via GITHUB_ENV) so every subsequent hook/gateway
190
- // process in the job attributes its events to the pipeline machine record.
191
- const ciMachineId = (process.env.FCD_MACHINE_ID || '').trim();
192
- if (ciMachineId) {
193
- cached = {
194
- machineId: ciMachineId.slice(0, 64),
195
- user: 'ci',
196
- hostname: (process.env.FCD_MACHINE_HOSTNAME || 'ci-pipeline').trim().toLowerCase().slice(0, 160) || 'ci-pipeline',
197
- developerName: (process.env.FCD_DEVELOPER_NAME || 'ci@pipeline').trim().toLowerCase().slice(0, 160) || 'ci@pipeline',
198
- platform: os.platform(),
199
- osFriendly: 'CI runner',
200
- };
201
- return cached;
535
+ const ci = ciOverrideIdentity();
536
+ if (ci)
537
+ return (cached = ci);
538
+ cached = buildIdentity('unpinned').identity;
539
+ return cached;
540
+ }
541
+ /**
542
+ * Re-resolve with a wider native-read allowance: the daemon at boot (`heal`)
543
+ * and enrollment (`force`). Replaces the cached identity.
544
+ */
545
+ function reconcileMachineIdentity(mode) {
546
+ const ci = ciOverrideIdentity();
547
+ if (ci) {
548
+ cached = ci;
549
+ return { machineId: ci.machineId, source: 'enrollment', confirmed: true, probed: false };
202
550
  }
203
- const user = safe(() => os.userInfo().username) || process.env.USER || process.env.USERNAME || 'unknown-user';
204
- const hostname = normalizeHostname(safe(() => os.hostname()) || 'unknown-host');
205
- // Seed the hash with the OS-native id when available; otherwise reuse the
206
- // persisted fallback id (so every user + the SYSTEM daemon on the same
207
- // locked-down machine converge on ONE identity); only then compute the
208
- // legacy composite — and persist it so future contexts converge on it.
209
- const nativeRaw = rawStableId();
210
- let machineId;
211
- if (nativeRaw) {
212
- machineId = crypto.createHash('sha256').update(nativeRaw).digest('hex').slice(0, 32);
551
+ const { identity, report } = buildIdentity(mode);
552
+ cached = identity;
553
+ return report;
554
+ }
555
+ /**
556
+ * Record that the backend accepted `machineId` for this machine:
557
+ * - `enrollment`: the enroll call succeeded — authoritative, always pinned;
558
+ * - `heartbeat`: events:batch returned 200 for the id this process uses;
559
+ * - `recovered`: the backend named the machine bound to this shield. Never
560
+ * replaces a different confirmed id (records `machine_id_conflict`).
561
+ */
562
+ function confirmMachineIdentity(machineId, how) {
563
+ if ((process.env.FCD_MACHINE_ID || '').trim())
564
+ return 'skipped';
565
+ const id = String(machineId || '').toLowerCase();
566
+ if (!MACHINE_ID_RE.test(id))
567
+ return 'skipped';
568
+ const hostname = currentHostname();
569
+ const now = nowIso();
570
+ const pin = readPinnedIdentity();
571
+ if (how === 'heartbeat') {
572
+ if (pin && pin.machineId !== id)
573
+ return 'skipped';
574
+ if (pin && isPinConfirmed(pin))
575
+ return 'already';
576
+ const source = pin?.source || (cached?.idSource === 'native' ? 'native' : 'fallback');
577
+ const written = writePin({ schema: 1, machineId: id, source, firstSeen: pin?.firstSeen || now, updatedAt: now, confirmedAt: now, hostname });
578
+ if (written && cached?.machineId === id)
579
+ cached = { ...cached, idConfirmed: true };
580
+ return written ? 'pinned' : 'skipped';
213
581
  }
214
- else {
215
- const persisted = readPersistedFallbackId();
216
- if (persisted) {
217
- machineId = persisted;
218
- }
219
- else {
220
- // Composite keeps the pre-existing shape (incl. username) so machines
221
- // already enrolled on this path keep their identity across upgrades.
222
- const composite = `${hostname}|${user}|${os.platform()}|${os.arch()}`;
223
- machineId = crypto.createHash('sha256').update(composite).digest('hex').slice(0, 32);
224
- persistFallbackId(machineId);
225
- }
582
+ if (how === 'recovered' && pin && pin.machineId !== id && isPinConfirmed(pin)) {
583
+ (0, distress_1.reportDistress)('identity', 'machine_id_conflict', `pinned ${shortMachineId(pin.machineId)} (${pin.source}, confirmed) vs backend-recovered ${shortMachineId(id)}; keeping the confirmed id`);
584
+ return 'conflict';
226
585
  }
227
- cached = {
228
- machineId,
229
- user,
586
+ if (pin && pin.machineId === id && isPinConfirmed(pin) && how === 'recovered')
587
+ return 'already';
588
+ const written = writePin({
589
+ schema: 1,
590
+ machineId: id,
591
+ source: how,
592
+ firstSeen: pin?.machineId === id ? pin.firstSeen : now,
593
+ updatedAt: now,
594
+ confirmedAt: now,
230
595
  hostname,
231
- developerName: `${user.toLowerCase()}@${hostname}`,
232
- platform: os.platform(),
233
- osFriendly: friendlyOs(),
234
- };
235
- return cached;
596
+ }, { force: true });
597
+ cached = undefined;
598
+ return written ? 'pinned' : 'skipped';
236
599
  }
237
600
  let cachedKind;
238
601
  /**
@@ -179,6 +179,15 @@ export interface FlushInput {
179
179
  };
180
180
  timeoutMs?: number;
181
181
  }
182
+ /** Why the last flush in this process returned null. `status` is set only when the backend answered. */
183
+ export interface FlushFailure {
184
+ kind: 'locked' | 'empty' | 'http' | 'network';
185
+ status?: number;
186
+ /** Backend machine-readable code, e.g. MACHINE_NOT_ENROLLED. */
187
+ code?: string;
188
+ error?: string;
189
+ }
190
+ export declare function getLastFlushFailure(): FlushFailure | undefined;
182
191
  /** Drain the spool to the backend in one batch (+ optional heartbeat). Returns accepted count. */
183
192
  export declare function flushSpool(input: FlushInput): Promise<{
184
193
  accepted: number;