fullcourtdefense-cli 1.26.5 → 1.26.7

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.
Files changed (41) hide show
  1. package/dist/commands/daemon.d.ts +14 -0
  2. package/dist/commands/daemon.js +181 -28
  3. package/dist/commands/desktopChatGuard.js +5 -4
  4. package/dist/commands/deterministicGuard.d.ts +8 -0
  5. package/dist/commands/deterministicGuard.js +6 -0
  6. package/dist/commands/doctor.d.ts +4 -0
  7. package/dist/commands/doctor.js +99 -0
  8. package/dist/commands/hook.d.ts +1 -0
  9. package/dist/commands/hook.js +25 -2
  10. package/dist/commands/installClaudeHook.d.ts +2 -0
  11. package/dist/commands/installClaudeHook.js +4 -0
  12. package/dist/commands/installCursorHook.d.ts +8 -0
  13. package/dist/commands/installCursorHook.js +5 -0
  14. package/dist/commands/mcpGateway.js +234 -43
  15. package/dist/commands/onboard.d.ts +9 -0
  16. package/dist/commands/onboard.js +7 -0
  17. package/dist/commands/watchdog.d.ts +5 -2
  18. package/dist/commands/watchdog.js +34 -4
  19. package/dist/config.js +6 -1
  20. package/dist/daemonForensics.d.ts +27 -0
  21. package/dist/daemonForensics.js +27 -0
  22. package/dist/distress.d.ts +17 -0
  23. package/dist/distress.js +17 -0
  24. package/dist/hookIo.js +1 -0
  25. package/dist/index.js +6 -0
  26. package/dist/integrity.js +8 -0
  27. package/dist/localSafetySnapshot.d.ts +9 -0
  28. package/dist/localSafetySnapshot.js +58 -2
  29. package/dist/machineIdentity.js +70 -4
  30. package/dist/runtimeConfig.d.ts +12 -0
  31. package/dist/runtimeConfig.js +181 -7
  32. package/dist/selfTest.d.ts +10 -0
  33. package/dist/selfTest.js +21 -8
  34. package/dist/selfUpdate.d.ts +19 -4
  35. package/dist/selfUpdate.js +108 -12
  36. package/dist/telemetry.js +67 -2
  37. package/dist/verdictIpc.js +76 -30
  38. package/dist/verdictIpcClient.d.ts +27 -0
  39. package/dist/verdictIpcClient.js +68 -0
  40. package/dist/version.json +1 -1
  41. package/package.json +5 -1
@@ -39,6 +39,7 @@ exports.resetCacheOnEnrollment = resetCacheOnEnrollment;
39
39
  exports.isSuspendedByCache = isSuspendedByCache;
40
40
  exports.getCachedScanRootOverrides = getCachedScanRootOverrides;
41
41
  exports.getCachedExtraScanRoots = getCachedExtraScanRoots;
42
+ const crypto = __importStar(require("crypto"));
42
43
  const fs = __importStar(require("fs"));
43
44
  const os = __importStar(require("os"));
44
45
  const path = __importStar(require("path"));
@@ -47,6 +48,15 @@ const sessionLimits_1 = require("./sessionLimits");
47
48
  const CACHE_PATH = path.join(os.homedir(), '.fullcourtdefense-runtime.json');
48
49
  const DEFAULT_TTL_MS = 60_000;
49
50
  const REFRESH_TIMEOUT_MS = 1_500; // tight: the hook must stay fast
51
+ /**
52
+ * Hot-path monitor/shadow callers accept a stale cache because the resident
53
+ * daemon owns freshness (60s poll). But when the cache is THIS old the daemon
54
+ * is presumably dead — the machine would never learn about a kill-switch flip
55
+ * or a monitor->block promotion. Past this bound ONE hook per interval pays a
56
+ * bounded (≤1.5s) refresh; everyone else keeps taking the stale cache.
57
+ */
58
+ const HOT_PATH_MAX_STALE_MS = 10 * 60_000;
59
+ const HOT_PATH_REVALIDATE_INTERVAL_MS = 5 * 60_000;
50
60
  function readCacheFile() {
51
61
  try {
52
62
  const raw = fs.readFileSync(CACHE_PATH, 'utf-8');
@@ -66,10 +76,111 @@ function writeCacheFile(data) {
66
76
  function isMode(value) {
67
77
  return value === 'block' || value === 'monitor' || value === 'shadow';
68
78
  }
79
+ // ---------------------------------------------------------------------------
80
+ // Tamper-resistant kill-switch marker
81
+ //
82
+ // The runtime cache is a plain JSON file: editing `suspended: false` into it
83
+ // (or deleting the file) must NOT clear an admin suspension. A second,
84
+ // HMAC-signed marker file records last-known-suspended per shield; it is
85
+ // asserted on every server response that says suspended and cleared ONLY by a
86
+ // server-confirmed response that says not-suspended. Effective bundles overlay
87
+ // it, so both files must be forged/deleted together — and the daemon re-asserts
88
+ // the marker on every poll while the server still says suspended.
89
+ // ---------------------------------------------------------------------------
90
+ const KILLSWITCH_DIR = path.join(os.homedir(), '.fullcourtdefense');
91
+ const KILLSWITCH_MARKER_PATH = path.join(KILLSWITCH_DIR, 'killswitch.json');
92
+ const KILLSWITCH_SECRET_PATH = path.join(KILLSWITCH_DIR, '.ks-secret');
93
+ /** Atomic write-temp-rename — a crash mid-write must never corrupt the marker. */
94
+ function writeFileAtomic(file, data, mode) {
95
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
96
+ fs.mkdirSync(path.dirname(file), { recursive: true });
97
+ fs.writeFileSync(tmp, data, { encoding: 'utf-8', mode });
98
+ fs.renameSync(tmp, file);
99
+ }
100
+ function killSwitchSecret(createIfMissing) {
101
+ try {
102
+ const raw = fs.readFileSync(KILLSWITCH_SECRET_PATH, 'utf-8').trim();
103
+ if (raw.length >= 32)
104
+ return raw;
105
+ }
106
+ catch { /* not created yet */ }
107
+ if (!createIfMissing)
108
+ return undefined;
109
+ try {
110
+ const secret = crypto.randomBytes(32).toString('hex');
111
+ writeFileAtomic(KILLSWITCH_SECRET_PATH, secret, 0o600);
112
+ return secret;
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
118
+ function killSwitchSig(secret, shieldId, suspendedAt) {
119
+ return crypto.createHmac('sha256', secret).update(`fcd-killswitch|${shieldId}|${suspendedAt}`).digest('hex');
120
+ }
121
+ function readKillSwitchMarker() {
122
+ try {
123
+ const parsed = JSON.parse(fs.readFileSync(KILLSWITCH_MARKER_PATH, 'utf-8'));
124
+ return parsed && typeof parsed === 'object' ? parsed : {};
125
+ }
126
+ catch {
127
+ return {};
128
+ }
129
+ }
130
+ function assertSuspendMarker(shieldId) {
131
+ try {
132
+ const secret = killSwitchSecret(true);
133
+ if (!secret)
134
+ return;
135
+ const marker = readKillSwitchMarker();
136
+ const entries = marker.entries && typeof marker.entries === 'object' ? marker.entries : {};
137
+ // Keep the original suspendedAt when re-asserting (audit-friendly).
138
+ const existing = entries[shieldId];
139
+ const suspendedAt = existing?.suspendedAt && killSwitchSig(secret, shieldId, existing.suspendedAt) === existing.sig
140
+ ? existing.suspendedAt
141
+ : new Date().toISOString();
142
+ entries[shieldId] = { suspendedAt, sig: killSwitchSig(secret, shieldId, suspendedAt) };
143
+ writeFileAtomic(KILLSWITCH_MARKER_PATH, JSON.stringify({ entries }), 0o600);
144
+ }
145
+ catch { /* marker is defense-in-depth — never break the caller */ }
146
+ }
147
+ function clearSuspendMarker(shieldId) {
148
+ try {
149
+ const marker = readKillSwitchMarker();
150
+ if (!marker.entries || !marker.entries[shieldId])
151
+ return;
152
+ delete marker.entries[shieldId];
153
+ writeFileAtomic(KILLSWITCH_MARKER_PATH, JSON.stringify({ entries: marker.entries }), 0o600);
154
+ }
155
+ catch { /* best-effort */ }
156
+ }
157
+ /** Any shield with a VALID (HMAC-verified) suspend marker? Never throws. */
158
+ function hasValidSuspendMarker() {
159
+ try {
160
+ const marker = readKillSwitchMarker();
161
+ if (!marker.entries)
162
+ return false;
163
+ const secret = killSwitchSecret(false);
164
+ if (!secret)
165
+ return false;
166
+ return Object.entries(marker.entries).some(([shieldId, entry]) => entry && typeof entry.suspendedAt === 'string' && typeof entry.sig === 'string'
167
+ && killSwitchSig(secret, shieldId, entry.suspendedAt) === entry.sig);
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ }
69
173
  /** A cached entry as an EffectiveBundle — single place that decides which fields survive the cache. */
70
174
  function cachedToEffective(cached) {
71
- const { fetchedAt: _fetchedAt, ...bundle } = cached;
72
- return { ...bundle, source: 'cache' };
175
+ const { fetchedAt, revalidateAttemptAt: _attemptAt, ...bundle } = cached;
176
+ // Kill-switch overlay: a hand-edited cache (`suspended: false`) cannot clear
177
+ // an admin suspension while the signed marker still stands.
178
+ const suspended = bundle.suspended === true || hasValidSuspendMarker();
179
+ // fetchedAt is refreshed on every 200 AND every 304, so it doubles as
180
+ // "when did the control plane last confirm this bundle" — consumers that
181
+ // must distinguish a validated cache from a network-down fallback (the
182
+ // daemon's update-policy mirror) read this instead of `source`.
183
+ return { ...bundle, suspended, source: 'cache', serverValidatedAt: fetchedAt };
73
184
  }
74
185
  /** Keep only a well-formed machine-role block (limits must be numbers or null; legacy keys normalized). */
75
186
  function sanitizeMachineRole(value) {
@@ -113,8 +224,26 @@ async function getRuntimeBundle(input) {
113
224
  }
114
225
  // MONITOR CONTRACT: hot-path callers never wait on a refresh for a
115
226
  // monitor/shadow machine — stale cache wins, the daemon refreshes it.
227
+ //
228
+ // BOUNDED: only while the cache is younger than HOT_PATH_MAX_STALE_MS. A
229
+ // cache that old means the daemon is dead and nothing else will ever pick
230
+ // up a kill-switch flip or a monitor->block promotion. Past the bound, ONE
231
+ // hook per revalidate interval falls through to the bounded (≤1.5s) refresh
232
+ // below; concurrent/subsequent hooks keep taking the stale cache.
116
233
  if (cached && !input.force && input.hotPath && cached.mode !== 'block') {
117
- return cachedToEffective(cached);
234
+ const age = Date.now() - cached.fetchedAt;
235
+ const sinceLastAttempt = Date.now() - (cached.revalidateAttemptAt || 0);
236
+ if (age < HOT_PATH_MAX_STALE_MS || sinceLastAttempt < HOT_PATH_REVALIDATE_INTERVAL_MS) {
237
+ return cachedToEffective(cached);
238
+ }
239
+ // Claim the revalidation slot BEFORE fetching so parallel hooks don't all
240
+ // pay the timeout together (docker compose spawns hooks in bursts).
241
+ try {
242
+ cached.revalidateAttemptAt = Date.now();
243
+ cache[input.shieldId] = cached;
244
+ writeCacheFile(cache);
245
+ }
246
+ catch { /* best-effort */ }
118
247
  }
119
248
  try {
120
249
  const headers = { 'Content-Type': 'application/json' };
@@ -129,11 +258,17 @@ async function getRuntimeBundle(input) {
129
258
  params.set('machineName', input.machineName);
130
259
  if (input.machineId)
131
260
  params.set('machineId', input.machineId);
132
- const resp = await fetch(`${input.apiUrl}/api/cli/bundle?${params.toString()}`, { method: 'GET', headers, signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) });
261
+ const resp = await fetch(`${input.apiUrl}/api/cli/bundle?${params.toString()}`, { method: 'GET', headers, signal: AbortSignal.timeout(input.timeoutMs ?? REFRESH_TIMEOUT_MS) });
133
262
  // 304 Not Modified — cache is still valid; refresh its timestamp.
263
+ // Re-assert the marker when the cache says suspended, but NEVER clear it
264
+ // here: a 304 validates the version string, not the (locally editable)
265
+ // suspended field — a hand-edited `suspended: false` cache would otherwise
266
+ // launder the edit through the 304. Clearing requires a full server body.
134
267
  if (resp.status === 304 && cached) {
135
268
  cache[input.shieldId] = { ...cached, fetchedAt: Date.now() };
136
269
  writeCacheFile(cache);
270
+ if (cached.suspended === true)
271
+ assertSuspendMarker(input.shieldId);
137
272
  return cachedToEffective(cached);
138
273
  }
139
274
  if (resp.status === 401 || resp.status === 403) {
@@ -174,7 +309,15 @@ async function getRuntimeBundle(input) {
174
309
  };
175
310
  cache[input.shieldId] = entry;
176
311
  writeCacheFile(cache);
177
- return { ...entry, source: 'server' };
312
+ // Sync the tamper-resistant marker from the ONLY trustworthy source —
313
+ // a live server response. Suspended asserts it; not-suspended is the
314
+ // sole path that clears it.
315
+ if (entry.suspended)
316
+ assertSuspendMarker(input.shieldId);
317
+ else
318
+ clearSuspendMarker(input.shieldId);
319
+ const { fetchedAt, revalidateAttemptAt: _attemptAt, ...bundle } = entry;
320
+ return { ...bundle, source: 'server', serverValidatedAt: fetchedAt };
178
321
  }
179
322
  }
180
323
  }
@@ -191,6 +334,13 @@ async function getRuntimeBundle(input) {
191
334
  // not hard-block the developer. Machines that WERE told to enforce keep
192
335
  // enforcement through the stale-while-error cache above, and explicit
193
336
  // install-time --enforce/--fail-closed flags still win in the callers.
337
+ //
338
+ // EXCEPT the kill-switch: a machine whose last-known state is SUSPENDED
339
+ // (signed marker) stays suspended even when the cache file goes missing —
340
+ // deleting the cache must not unlock a suspended machine.
341
+ if (hasValidSuspendMarker()) {
342
+ return { mode: 'monitor', version: '', suspended: true, source: 'default' };
343
+ }
194
344
  return { mode: 'monitor', version: '', source: 'default' };
195
345
  }
196
346
  /**
@@ -209,10 +359,12 @@ async function validateShieldCredentials(input) {
209
359
  }
210
360
  try {
211
361
  const params = new URLSearchParams({ shieldId: input.shieldId });
362
+ // Diagnostic default (10s), not the hot-path 1.5s: onboarding/doctor would
363
+ // rather wait than misreport a slow-but-healthy backend as unreachable.
212
364
  const resp = await fetch(`${input.apiUrl}/api/cli/bundle?${params.toString()}`, {
213
365
  method: 'GET',
214
366
  headers: { 'Content-Type': 'application/json', 'x-shield-key': input.shieldKey },
215
- signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
367
+ signal: AbortSignal.timeout(input.timeoutMs ?? 10_000),
216
368
  });
217
369
  if (resp.status === 401 || resp.status === 403) {
218
370
  (0, distress_1.reportDistress)('bundle', distress_1.DISTRESS.AUTH_BROKEN, `credential validation HTTP ${resp.status}`);
@@ -253,6 +405,25 @@ function resetCacheOnEnrollment() {
253
405
  writeCacheFile({});
254
406
  }
255
407
  catch { /* cache is best-effort — never fail an enrollment over it */ }
408
+ // Local Safety snapshots are keyed by shieldId + identity — after a
409
+ // re-enrollment the old shield's snapshot (possibly ENFORCING) must not
410
+ // survive under a key the new identity still happens to resolve. Lazy
411
+ // import keeps the hook hot path free of this module.
412
+ try {
413
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
414
+ const { clearAllLocalSafetySnapshotCaches } = require('./localSafetySnapshot');
415
+ clearAllLocalSafetySnapshotCaches();
416
+ }
417
+ catch { /* best-effort */ }
418
+ // Enrollment only reaches here after the SERVER accepted a valid fleet
419
+ // token, so clearing the last-known-suspended marker is legitimate — the
420
+ // old shield binding is gone and a stale marker would otherwise suspend the
421
+ // re-enrolled machine forever (marker entries are keyed by old shieldIds).
422
+ // If the org still suspends this machine, the first bundle poll re-asserts.
423
+ try {
424
+ fs.unlinkSync(KILLSWITCH_MARKER_PATH);
425
+ }
426
+ catch { /* marker may not exist */ }
256
427
  }
257
428
  /**
258
429
  * Is ANY cached bundle marked suspended by an admin (kill-switch)?
@@ -266,7 +437,10 @@ function resetCacheOnEnrollment() {
266
437
  function isSuspendedByCache() {
267
438
  try {
268
439
  const cache = readCacheFile();
269
- return Object.values(cache).some(entry => entry && entry.suspended === true);
440
+ if (Object.values(cache).some(entry => entry && entry.suspended === true))
441
+ return true;
442
+ // Signed last-known-suspended marker: survives cache edits AND deletion.
443
+ return hasValidSuspendMarker();
270
444
  }
271
445
  catch {
272
446
  return false;
@@ -18,6 +18,16 @@ export interface SelfTestCheck {
18
18
  label: string;
19
19
  ok: boolean;
20
20
  detail: string;
21
+ /**
22
+ * Advisory check: a failure is worth SEEING (console shows it) but must not
23
+ * fail the machine's overall health. The redundant credential-store probes
24
+ * (native / DPAPI / key file — `credentials` + `bundle_auth` are the
25
+ * aggregate proof that auth actually works), the PowerShell probe (the
26
+ * product is PowerShell-free; hardened fleets legitimately block it), and
27
+ * the cosmetic windowless-tasks check are advisory. Required checks are the
28
+ * ones whose failure means protection or updates are actually broken.
29
+ */
30
+ optional?: boolean;
21
31
  }
22
32
  export interface SelfTestReport {
23
33
  ok: boolean;
package/dist/selfTest.js CHANGED
@@ -32,13 +32,14 @@ async function runDeepSelfTest(input) {
32
32
  if (process.platform === 'win32') {
33
33
  const mode = (0, config_1.capturePowershellLanguageMode)();
34
34
  if (!mode) {
35
- add({ id: 'powershell', label: 'PowerShell available', ok: false, detail: 'powershell.exe/pwsh.exe did not answer (EDR/AppLocker block?)' });
35
+ add({ id: 'powershell', label: 'PowerShell available', ok: false, optional: true, detail: 'powershell.exe/pwsh.exe did not answer (EDR/AppLocker block?) — advisory: the CLI is PowerShell-free on its enforcement paths' });
36
36
  }
37
37
  else {
38
38
  add({
39
39
  id: 'powershell',
40
40
  label: 'PowerShell available',
41
41
  ok: true,
42
+ optional: true,
42
43
  detail: `language mode: ${mode}${mode !== 'FullLanguage' ? ' (hardened — CLM-safe code paths required)' : ''}`,
43
44
  });
44
45
  }
@@ -46,20 +47,20 @@ async function runDeepSelfTest(input) {
46
47
  // store (CLI ≥1.21.34). A failure here is NOT fatal while DPAPI works,
47
48
  // but the console should see which machines run on the fallback.
48
49
  const native = (0, credentialStore_1.nativeStoreRoundtripProbe)();
49
- add({ id: 'native_store', label: 'Credential Manager (native) roundtrip', ok: native.ok, detail: native.detail });
50
+ add({ id: 'native_store', label: 'Credential Manager (native) roundtrip', ok: native.ok, optional: true, detail: native.detail });
50
51
  // 3. DPAPI roundtrip — encrypt AND decrypt a probe value with the exact
51
52
  // snippets the CLI uses for the shield key. This is the check that would
52
53
  // have identified the lptx1110 credential break in one click. Kept as its
53
54
  // own check: it is the fallback store for machines without the native
54
55
  // binding and the rollback path to ≤1.21.33.
55
56
  const probe = (0, config_1.dpapiRoundtripProbe)();
56
- add({ id: 'dpapi_roundtrip', label: 'Credential store (DPAPI) roundtrip', ok: probe.ok, detail: probe.detail });
57
+ add({ id: 'dpapi_roundtrip', label: 'Credential store (DPAPI) roundtrip', ok: probe.ok, optional: true, detail: probe.detail });
57
58
  // 4. Machine key file — the S4U-safe mirror the daemon/watchdog tasks
58
59
  // read (Credential Manager and DPAPI are both sealed to S4U sessions).
59
60
  // Without it, a daemon under the windowless S4U principal is key-less
60
61
  // and every bundle poll 401s.
61
62
  const keyFile = (0, machineKeyFile_1.machineKeyFileProbe)();
62
- add({ id: 'machine_key_file', label: 'Machine key file (S4U mirror) roundtrip', ok: keyFile.ok, detail: keyFile.detail });
63
+ add({ id: 'machine_key_file', label: 'Machine key file (S4U mirror) roundtrip', ok: keyFile.ok, optional: true, detail: keyFile.detail });
63
64
  }
64
65
  // 3. Credentials resolved on THIS machine right now.
65
66
  const trace = (() => {
@@ -90,6 +91,8 @@ async function runDeepSelfTest(input) {
90
91
  machineId: input.machineId,
91
92
  force: true,
92
93
  ttlMs: 0,
94
+ // Diagnostic call — a 3s answer is a healthy backend, not a failure.
95
+ timeoutMs: 10_000,
93
96
  });
94
97
  add({
95
98
  id: 'bundle_auth',
@@ -160,6 +163,7 @@ async function runDeepSelfTest(input) {
160
163
  id: 'windowless_tasks',
161
164
  label: 'Scheduled tasks are windowless',
162
165
  ok: flashing.length === 0,
166
+ optional: true,
163
167
  detail: flashing.length === 0
164
168
  ? conhostShielded.length === 0
165
169
  ? 'no FCD task can open a console window (S4U principals)'
@@ -167,7 +171,11 @@ async function runDeepSelfTest(input) {
167
171
  : `visible console window on every trigger of: ${flashing.join(', ')} — bare console action on an InteractiveToken principal (neither S4U nor conhost --headless)`,
168
172
  });
169
173
  }
170
- return { ok: checks.every(check => check.ok), checks, ranAt: new Date().toISOString() };
174
+ // Overall health gates on REQUIRED checks only. An advisory failure (e.g.
175
+ // PowerShell blocked on a hardened bank machine, or one of the redundant
176
+ // credential stores unavailable while auth demonstrably works) used to fail
177
+ // the whole report, training operators to ignore red self-tests entirely.
178
+ return { ok: checks.every(check => check.ok || check.optional === true), checks, ranAt: new Date().toISOString() };
171
179
  }
172
180
  /** FCD per-user tasks that could flash a console window on every trigger. */
173
181
  const WINDOWED_TASK_CANDIDATES = ['FullCourtDefense Daemon', 'FullCourtDefense Daemon Watchdog', 'FullCourtDefense Auto-Protect', 'FullCourtDefenseDesktopDiscover'];
@@ -192,8 +200,13 @@ function listConhostShieldedFcdTasks() {
192
200
  }
193
201
  /** Compact one-line summary for the machine-action result card. */
194
202
  function summarizeSelfTest(report) {
195
- const failed = report.checks.filter(check => !check.ok);
196
- if (failed.length === 0)
203
+ const failed = report.checks.filter(check => !check.ok && check.optional !== true);
204
+ const advisory = report.checks.filter(check => !check.ok && check.optional === true);
205
+ if (failed.length === 0 && advisory.length === 0)
197
206
  return `Deep self-test passed — all ${report.checks.length} subsystems healthy.`;
198
- return `Deep self-test: ${report.checks.length - failed.length}/${report.checks.length} passed — FAILED: ${failed.map(check => `${check.id} (${check.detail})`).join('; ')}`;
207
+ if (failed.length === 0) {
208
+ return `Deep self-test passed (${report.checks.length - advisory.length}/${report.checks.length} required subsystems healthy) — advisory: ${advisory.map(check => `${check.id} (${check.detail})`).join('; ')}`;
209
+ }
210
+ const advisorySuffix = advisory.length > 0 ? ` — advisory: ${advisory.map(check => check.id).join(', ')}` : '';
211
+ return `Deep self-test: ${report.checks.length - failed.length - advisory.length}/${report.checks.length} passed — FAILED: ${failed.map(check => `${check.id} (${check.detail})`).join('; ')}${advisorySuffix}`;
199
212
  }
@@ -35,11 +35,23 @@ export declare function resolveNpmCommand(platform?: NodeJS.Platform, execPath?:
35
35
  */
36
36
  export declare function buildUpdaterTaskCommand(root: string, exists?: (p: string) => boolean): string | undefined;
37
37
  /**
38
- * True when a registered task command still points at the PowerShell updater
39
- * while this install carries the Node updater — i.e. the task predates the
40
- * PowerShell-free updater and should be re-registered.
38
+ * True when a registered task command should be re-registered:
39
+ * - it still points at the PowerShell updater while this install carries
40
+ * the Node updater (predates the PowerShell-free updater), or
41
+ * - it runs the Node updater WITHOUT `--api-url` (an old self-healed task —
42
+ * those default to prod, silently retargeting staging machines).
41
43
  */
42
44
  export declare function updaterTaskNeedsModernization(taskToRun: string, root: string, exists?: (p: string) => boolean): boolean;
45
+ /**
46
+ * Task Scheduler XML for the updater task — settings-parity with the MSI's
47
+ * own registration (Install-FullCourtDefense.ps1): StartWhenAvailable so a
48
+ * laptop asleep at 03:07 catches up on wake, battery-friendly so the task is
49
+ * not skipped/killed unplugged, SYSTEM at highest run level, daily trigger.
50
+ * `schtasks /Create` flags cannot express these (its defaults silently drop
51
+ * StartWhenAvailable and stop on battery) — registration goes through
52
+ * `/Create /XML` instead.
53
+ */
54
+ export declare function buildUpdaterTaskXml(root: string, apiUrl: string): string | undefined;
43
55
  /**
44
56
  * Upgrade an EXISTING updater task from the PowerShell command to the Node
45
57
  * command. Machines that upgraded from a pre-Node-updater MSI keep their old
@@ -47,7 +59,7 @@ export declare function updaterTaskNeedsModernization(taskToRun: string, root: s
47
59
  * this converges them whenever a daemon runs elevated. Best-effort: needs an
48
60
  * elevated token to re-register a SYSTEM task, silent no-op otherwise.
49
61
  */
50
- export declare function modernizeUpdaterTask(log: (message: string) => void): void;
62
+ export declare function modernizeUpdaterTask(log: (message: string) => void, apiUrl?: string): void;
51
63
  /**
52
64
  * Updater script log (%ProgramData%\FullCourtDefense\updater.log) — the
53
65
  * elevated task's own words. Read by the diagnostics bundle and the update-
@@ -81,6 +93,9 @@ export declare function maybeSelfUpdate(input: {
81
93
  /** Bypass the retry cooldown — used by explicit admin upgrade_cli actions so
82
94
  * they always attempt and report the REAL outcome instead of "in progress". */
83
95
  force?: boolean;
96
+ /** Control-plane URL this machine is enrolled against — preserved on any
97
+ * self-healed updater task so staging machines never retarget prod. */
98
+ apiUrl?: string;
84
99
  }): SelfUpdateResult | undefined;
85
100
  /** Version the MSI updater script reads from the installed package.json. */
86
101
  export declare function installedMsiVersion(installFolder: string): string | undefined;
@@ -39,6 +39,7 @@ exports.compareCliVersions = compareCliVersions;
39
39
  exports.resolveNpmCommand = resolveNpmCommand;
40
40
  exports.buildUpdaterTaskCommand = buildUpdaterTaskCommand;
41
41
  exports.updaterTaskNeedsModernization = updaterTaskNeedsModernization;
42
+ exports.buildUpdaterTaskXml = buildUpdaterTaskXml;
42
43
  exports.modernizeUpdaterTask = modernizeUpdaterTask;
43
44
  exports.readUpdaterLogTail = readUpdaterLogTail;
44
45
  exports.clearResolvedUpdateLoop = clearResolvedUpdateLoop;
@@ -161,26 +162,121 @@ function buildUpdaterTaskCommand(root, exists = fs.existsSync) {
161
162
  return undefined;
162
163
  }
163
164
  /**
164
- * True when a registered task command still points at the PowerShell updater
165
- * while this install carries the Node updater — i.e. the task predates the
166
- * PowerShell-free updater and should be re-registered.
165
+ * True when a registered task command should be re-registered:
166
+ * - it still points at the PowerShell updater while this install carries
167
+ * the Node updater (predates the PowerShell-free updater), or
168
+ * - it runs the Node updater WITHOUT `--api-url` (an old self-healed task —
169
+ * those default to prod, silently retargeting staging machines).
167
170
  */
168
171
  function updaterTaskNeedsModernization(taskToRun, root, exists = fs.existsSync) {
169
- if (!/powershell/i.test(taskToRun))
172
+ const hasNodeUpdater = exists(path.join(root, 'Update-FullCourtDefense.js')) && exists(path.join(root, 'runtime', 'node.exe'));
173
+ if (!hasNodeUpdater)
170
174
  return false;
171
- return exists(path.join(root, 'Update-FullCourtDefense.js')) && exists(path.join(root, 'runtime', 'node.exe'));
175
+ if (/powershell/i.test(taskToRun))
176
+ return true;
177
+ return /Update-FullCourtDefense\.js/i.test(taskToRun) && !/--api-url/i.test(taskToRun);
178
+ }
179
+ function escapeXml(value) {
180
+ return value
181
+ .replace(/&/g, '&amp;')
182
+ .replace(/</g, '&lt;')
183
+ .replace(/>/g, '&gt;')
184
+ .replace(/"/g, '&quot;')
185
+ .replace(/'/g, '&apos;');
186
+ }
187
+ /**
188
+ * Task Scheduler XML for the updater task — settings-parity with the MSI's
189
+ * own registration (Install-FullCourtDefense.ps1): StartWhenAvailable so a
190
+ * laptop asleep at 03:07 catches up on wake, battery-friendly so the task is
191
+ * not skipped/killed unplugged, SYSTEM at highest run level, daily trigger.
192
+ * `schtasks /Create` flags cannot express these (its defaults silently drop
193
+ * StartWhenAvailable and stop on battery) — registration goes through
194
+ * `/Create /XML` instead.
195
+ */
196
+ function buildUpdaterTaskXml(root, apiUrl) {
197
+ const nodeExe = path.join(root, 'runtime', 'node.exe');
198
+ const jsUpdater = path.join(root, 'Update-FullCourtDefense.js');
199
+ if (!fs.existsSync(nodeExe) || !fs.existsSync(jsUpdater))
200
+ return undefined;
201
+ return `<?xml version="1.0" encoding="UTF-16"?>
202
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
203
+ <RegistrationInfo>
204
+ <Description>Silently updates FullCourtDefense (downloads, verifies, and installs new MSI releases).</Description>
205
+ </RegistrationInfo>
206
+ <Triggers>
207
+ <CalendarTrigger>
208
+ <StartBoundary>2020-01-01T03:07:00</StartBoundary>
209
+ <Enabled>true</Enabled>
210
+ <ScheduleByDay>
211
+ <DaysInterval>1</DaysInterval>
212
+ </ScheduleByDay>
213
+ </CalendarTrigger>
214
+ </Triggers>
215
+ <Principals>
216
+ <Principal id="Author">
217
+ <UserId>S-1-5-18</UserId>
218
+ <RunLevel>HighestAvailable</RunLevel>
219
+ </Principal>
220
+ </Principals>
221
+ <Settings>
222
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
223
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
224
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
225
+ <StartWhenAvailable>true</StartWhenAvailable>
226
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
227
+ <AllowHardTerminate>true</AllowHardTerminate>
228
+ <Enabled>true</Enabled>
229
+ <Hidden>true</Hidden>
230
+ <ExecutionTimeLimit>PT2H</ExecutionTimeLimit>
231
+ </Settings>
232
+ <Actions Context="Author">
233
+ <Exec>
234
+ <Command>${escapeXml(nodeExe)}</Command>
235
+ <Arguments>${escapeXml(`"${jsUpdater}" --api-url "${apiUrl}"`)}</Arguments>
236
+ </Exec>
237
+ </Actions>
238
+ </Task>
239
+ `;
172
240
  }
241
+ const DEFAULT_UPDATER_API_URL = 'https://api.fullcourtdefense.ai';
173
242
  /**
174
243
  * Self-heal a missing updater task. Machines installed from an older MSI (or
175
244
  * where task creation was denied at install time) ship the updater SCRIPT but
176
245
  * not the scheduled task — without this, every self-update tick dead-ends with
177
246
  * "reinstall the MSI". Registration needs an elevated token, so this succeeds
178
247
  * on elevated daemons and stays a silent no-op otherwise.
248
+ *
249
+ * Registers via XML for settings-parity with the MSI task (StartWhenAvailable
250
+ * + battery-friendly + `--api-url`); the flag-based `schtasks /Create` remains
251
+ * a fallback so self-heal never regresses where XML registration is denied.
179
252
  */
180
- function tryRegisterMsiUpdaterTask(log) {
253
+ function tryRegisterMsiUpdaterTask(log, apiUrl) {
181
254
  const root = msiInstallRoot();
182
255
  if (!root)
183
256
  return false;
257
+ const xml = buildUpdaterTaskXml(root, apiUrl || DEFAULT_UPDATER_API_URL);
258
+ if (xml) {
259
+ const xmlFile = path.join(os.tmpdir(), `fcd-updater-task-${process.pid}.xml`);
260
+ try {
261
+ // schtasks /XML requires the file encoding to match the declaration
262
+ // (encoding="UTF-16") — UTF-16LE with BOM.
263
+ fs.writeFileSync(xmlFile, `\ufeff${xml}`, 'utf16le');
264
+ const created = (0, child_process_1.spawnSync)('schtasks', [
265
+ '/Create', '/TN', exports.MSI_UPDATER_TASK_NAME, '/XML', xmlFile, '/F',
266
+ ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
267
+ if (created.status === 0) {
268
+ log('Self-update: registered the MSI updater task (self-heal, full settings parity with the MSI task).');
269
+ return true;
270
+ }
271
+ }
272
+ catch { /* fall through to the flag-based create */ }
273
+ finally {
274
+ try {
275
+ fs.rmSync(xmlFile, { force: true });
276
+ }
277
+ catch { /* best effort */ }
278
+ }
279
+ }
184
280
  const taskCommand = buildUpdaterTaskCommand(root);
185
281
  if (!taskCommand)
186
282
  return false;
@@ -201,7 +297,7 @@ function tryRegisterMsiUpdaterTask(log) {
201
297
  * this converges them whenever a daemon runs elevated. Best-effort: needs an
202
298
  * elevated token to re-register a SYSTEM task, silent no-op otherwise.
203
299
  */
204
- function modernizeUpdaterTask(log) {
300
+ function modernizeUpdaterTask(log, apiUrl) {
205
301
  if (process.platform !== 'win32')
206
302
  return;
207
303
  try {
@@ -216,15 +312,15 @@ function modernizeUpdaterTask(log) {
216
312
  const taskToRun = (query.stdout.match(/^Task To Run:\s*(.*)$/m) || [])[1] || '';
217
313
  if (!updaterTaskNeedsModernization(taskToRun, root))
218
314
  return;
219
- if (tryRegisterMsiUpdaterTask(log)) {
220
- log('Self-update: updater task modernized to the PowerShell-free Node updater.');
315
+ if (tryRegisterMsiUpdaterTask(log, apiUrl)) {
316
+ log('Self-update: updater task modernized (PowerShell-free Node updater, api-url preserved).');
221
317
  }
222
318
  }
223
319
  catch { /* diagnostics-only convergence — never disturb the daemon */ }
224
320
  }
225
- function startMsiSelfUpdate(targetVersion, log) {
321
+ function startMsiSelfUpdate(targetVersion, log, apiUrl) {
226
322
  const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', exports.MSI_UPDATER_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
227
- if (query.status !== 0 && !tryRegisterMsiUpdaterTask(log)) {
323
+ if (query.status !== 0 && !tryRegisterMsiUpdaterTask(log, apiUrl)) {
228
324
  log('Self-update: MSI updater task is not registered on this machine — reinstall the MSI to enable silent updates.');
229
325
  (0, distress_1.reportDistress)('self-update', distress_1.DISTRESS.UPDATER_TASK_MISSING, `"${exports.MSI_UPDATER_TASK_NAME}" scheduled task missing and self-registration failed`);
230
326
  return {
@@ -398,7 +494,7 @@ function maybeSelfUpdate(input) {
398
494
  }
399
495
  const kind = detectInstallKind();
400
496
  const result = kind === 'msi'
401
- ? startMsiSelfUpdate(input.targetVersion, log)
497
+ ? startMsiSelfUpdate(input.targetVersion, log, input.apiUrl)
402
498
  : startNpmSelfUpdate(input.targetVersion, log);
403
499
  if (!result.started) {
404
500
  // Allow another attempt on the next tick after a shorter cooldown when we