fullcourtdefense-cli 1.26.3 → 1.26.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.
@@ -59,6 +59,7 @@ const path = __importStar(require("path"));
59
59
  const child_process_1 = require("child_process");
60
60
  const config_1 = require("../config");
61
61
  const distress_1 = require("../distress");
62
+ const pollLoop_1 = require("../pollLoop");
62
63
  const selfTest_1 = require("../selfTest");
63
64
  const daemonForensics_1 = require("../daemonForensics");
64
65
  const securityAgents_1 = require("../securityAgents");
@@ -107,6 +108,14 @@ const RESCAN_INTERVAL_MS = envMs('FCD_DAEMON_RESCAN_MS', 5 * 60_000);
107
108
  const HEARTBEAT_INTERVAL_MS = envMs('FCD_DAEMON_HEARTBEAT_MS', 5 * 60_000);
108
109
  /** Bundle (mode / suspension / policy version) poll cadence. */
109
110
  const BUNDLE_POLL_MS = envMs('FCD_DAEMON_BUNDLE_POLL_MS', 60_000);
111
+ /**
112
+ * Hard ceilings for one poll attempt (see startPollLoop). Generous multiples of
113
+ * the work each poll actually does — a bundle poll is a 1.5s fetch plus an 8s
114
+ * snapshot refresh, a heartbeat is a spool flush plus a log upload — so these
115
+ * only ever fire on a genuinely hung socket, never on a merely slow network.
116
+ */
117
+ const BUNDLE_POLL_DEADLINE_MS = envMs('FCD_DAEMON_BUNDLE_DEADLINE_MS', 45_000);
118
+ const HEARTBEAT_DEADLINE_MS = envMs('FCD_DAEMON_HEARTBEAT_DEADLINE_MS', 120_000);
110
119
  /** Delay before the one-time initial discovery sweep on a fresh machine. */
111
120
  const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60_000);
112
121
  /** A discovery upload older than this is stale — the daemon catches up itself. */
@@ -1287,33 +1296,35 @@ async function runDaemon(args, config) {
1287
1296
  // The bundle poll additionally honors the server's pollIntervalMs, and the
1288
1297
  // next tick is scheduled only AFTER the previous poll finishes — a slow
1289
1298
  // gate can never pile up overlapping polls.
1290
- const jittered = (baseMs) => Math.max(5_000, Math.round(baseMs * (0.8 + Math.random() * 0.4)));
1291
- let bundleTimer;
1292
- const scheduleBundlePoll = () => {
1293
- if (stopped)
1294
- return;
1295
- bundleTimer = setTimeout(async () => {
1296
- try {
1297
- await pollBundle();
1298
- }
1299
- catch { /* poll never throws, but never stop the loop */ }
1300
- scheduleBundlePoll();
1301
- }, jittered(bundlePollBaseMs));
1302
- };
1303
- scheduleBundlePoll();
1304
- let heartbeatTimer;
1305
- const scheduleHeartbeat = () => {
1306
- if (stopped)
1307
- return;
1308
- heartbeatTimer = setTimeout(async () => {
1309
- try {
1310
- await heartbeat();
1311
- }
1312
- catch { /* keep the loop alive */ }
1313
- scheduleHeartbeat();
1314
- }, jittered(HEARTBEAT_INTERVAL_MS));
1315
- };
1316
- scheduleHeartbeat();
1299
+ //
1300
+ // Both loops run through startPollLoop, which bounds every attempt with a
1301
+ // hard deadline. A poll that never settles (socket dead without a FIN after
1302
+ // sleep / VPN change / proxy drop — `AbortSignal.timeout` does not always
1303
+ // rescue that) used to kill its loop for the life of the process. When that
1304
+ // happened to the bundle poll, remote actions — whose ONLY delivery path is
1305
+ // this poll — silently expired unacknowledged while heartbeats kept the
1306
+ // machine looking healthy. Abandoning the hung attempt restores delivery on
1307
+ // the next tick and leaves a `poll_stalled` signal in the fleet.
1308
+ const bundleLoop = (0, pollLoop_1.startPollLoop)({
1309
+ run: pollBundle,
1310
+ intervalMs: () => bundlePollBaseMs,
1311
+ deadlineMs: BUNDLE_POLL_DEADLINE_MS,
1312
+ onStalled: deadlineMs => {
1313
+ log(`Bundle poll did not finish within ${Math.round(deadlineMs / 1000)}s — abandoned; remote actions and policy updates were delayed. Next poll continues normally.`);
1314
+ (0, distress_1.reportDistress)('bundle', distress_1.DISTRESS.POLL_STALLED, `bundle poll exceeded ${deadlineMs}ms`);
1315
+ },
1316
+ onError: error => log(`Bundle poll error: ${error instanceof Error ? error.message : String(error)}`),
1317
+ });
1318
+ const heartbeatLoop = (0, pollLoop_1.startPollLoop)({
1319
+ run: heartbeat,
1320
+ intervalMs: () => HEARTBEAT_INTERVAL_MS,
1321
+ deadlineMs: HEARTBEAT_DEADLINE_MS,
1322
+ onStalled: deadlineMs => {
1323
+ log(`Heartbeat did not finish within ${Math.round(deadlineMs / 1000)}s — abandoned; spooled events stay on disk for the next tick.`);
1324
+ (0, distress_1.reportDistress)('heartbeat', distress_1.DISTRESS.POLL_STALLED, `heartbeat exceeded ${deadlineMs}ms`);
1325
+ },
1326
+ onError: error => log(`Heartbeat error: ${error instanceof Error ? error.message : String(error)}`),
1327
+ });
1317
1328
  // PowerShell transcript retention (Windows): the Transcription policy FCD
1318
1329
  // enables writes a file per session forever — prune anything older than the
1319
1330
  // retention window once a day (plus once shortly after boot, so laptops
@@ -1337,10 +1348,8 @@ async function runDaemon(args, config) {
1337
1348
  stopped = true;
1338
1349
  log(`Received ${signal} — shutting down.`);
1339
1350
  clearInterval(rescanTimer);
1340
- if (bundleTimer)
1341
- clearTimeout(bundleTimer);
1342
- if (heartbeatTimer)
1343
- clearTimeout(heartbeatTimer);
1351
+ bundleLoop.stop();
1352
+ heartbeatLoop.stop();
1344
1353
  clearTimeout(transcriptPruneBootTimer);
1345
1354
  clearInterval(transcriptPruneTimer);
1346
1355
  clearTimeout(discoverCatchUpBootTimer);
@@ -48,6 +48,12 @@ export declare const DISTRESS: {
48
48
  readonly MACHINE_KEYFILE_ACL_FAILED: "machine_keyfile_acl_failed";
49
49
  /** Fail-closed engaged: hook is BLOCKING user actions because the policy gate is persistently unreachable. */
50
50
  readonly HOOK_FAIL_CLOSED: "hook_fail_closed";
51
+ /**
52
+ * A daemon poll never settled and was abandoned at its deadline. Usually a
53
+ * socket that died without a FIN (sleep / VPN / proxy). Self-heals on the
54
+ * next tick — but while it lasted, remote actions were undeliverable.
55
+ */
56
+ readonly POLL_STALLED: "poll_stalled";
51
57
  /** Anything nobody predicted — reported via reportUnexpected(). */
52
58
  readonly UNEXPECTED: "unexpected_error";
53
59
  };
package/dist/distress.js CHANGED
@@ -93,6 +93,12 @@ exports.DISTRESS = {
93
93
  MACHINE_KEYFILE_ACL_FAILED: 'machine_keyfile_acl_failed',
94
94
  /** Fail-closed engaged: hook is BLOCKING user actions because the policy gate is persistently unreachable. */
95
95
  HOOK_FAIL_CLOSED: 'hook_fail_closed',
96
+ /**
97
+ * A daemon poll never settled and was abandoned at its deadline. Usually a
98
+ * socket that died without a FIN (sleep / VPN / proxy). Self-heals on the
99
+ * next tick — but while it lasted, remote actions were undeliverable.
100
+ */
101
+ POLL_STALLED: 'poll_stalled',
96
102
  /** Anything nobody predicted — reported via reportUnexpected(). */
97
103
  UNEXPECTED: 'unexpected_error',
98
104
  };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Self-healing poll scheduler for the resident daemon.
3
+ *
4
+ * WHY THIS EXISTS (a real production incident, 2026-08-22)
5
+ * The daemon's loops schedule the next tick only AFTER the current attempt
6
+ * settles — deliberate, so a slow control plane can never stack overlapping
7
+ * polls on a developer's laptop. The hidden cost: an attempt that NEVER
8
+ * settles kills the loop for the lifetime of the process.
9
+ *
10
+ * That is not theoretical. `fetch` can hang indefinitely despite its
11
+ * `AbortSignal.timeout` when the underlying socket dies without a FIN —
12
+ * laptop sleep, VPN/Wi-Fi change, or a corporate proxy silently dropping an
13
+ * idle connection. On the incident machine the bundle poll hung at 15:59 and
14
+ * never ran again, while heartbeats, hooks, and the watchdog all kept working
15
+ * for three hours. The machine therefore looked perfectly healthy in the
16
+ * console — but the bundle poll is the ONLY delivery path for remote actions,
17
+ * so every admin fix (upgrade, repair protection, collect diagnostics)
18
+ * silently expired unacknowledged. The worst possible failure mode: a machine
19
+ * we cannot reach that reports itself fine.
20
+ *
21
+ * The fix is a hard per-attempt deadline. When it expires we abandon the
22
+ * attempt (logging + distress so the fleet SEES it) and schedule the next
23
+ * tick anyway. The orphaned promise gets a permanent catch so a late
24
+ * rejection can never surface as an unhandled rejection and take the daemon
25
+ * down with it.
26
+ *
27
+ * Pure and injectable (timers + clock) so the "loop survives a poll that
28
+ * never settles" property is unit-tested without waiting on real minutes.
29
+ */
30
+ export interface PollLoopHandle {
31
+ stop(): void;
32
+ /** Attempts abandoned at the deadline — exposed for tests/diagnostics. */
33
+ stalledCount(): number;
34
+ }
35
+ export interface PollLoopInput {
36
+ /** One attempt. May reject, and may never settle — both are survivable. */
37
+ run: () => Promise<unknown>;
38
+ /**
39
+ * Base cadence, read fresh before every tick so the server can retune a
40
+ * whole fleet (bundle `pollIntervalMs`) without a new CLI.
41
+ */
42
+ intervalMs: () => number;
43
+ /**
44
+ * Hard ceiling for a single attempt. Keep it below the interval so an
45
+ * abandoned attempt cannot overlap the next one for long.
46
+ */
47
+ deadlineMs: number;
48
+ /** Called once per abandoned attempt (log + distress at the call site). */
49
+ onStalled?: (deadlineMs: number) => void;
50
+ /** Called when an attempt rejects. Never rethrow from here. */
51
+ onError?: (error: unknown) => void;
52
+ /** Cadence jitter fraction (0..1, default 0.2) — a proxied fleet must not poll in lockstep. */
53
+ jitter?: number;
54
+ /** Injectable timers for tests. */
55
+ timers?: {
56
+ setTimeout: (fn: () => void, ms: number) => unknown;
57
+ clearTimeout: (handle: unknown) => void;
58
+ };
59
+ /** Injectable randomness for deterministic tests. */
60
+ random?: () => number;
61
+ }
62
+ /**
63
+ * Await `run()` but never longer than `deadlineMs`. Resolves 'settled' when the
64
+ * attempt finished (fulfilled or rejected) and 'abandoned' when the deadline
65
+ * won the race.
66
+ */
67
+ export declare function runWithDeadline(input: Pick<PollLoopInput, 'run' | 'deadlineMs' | 'onError' | 'timers'>): Promise<'settled' | 'abandoned'>;
68
+ /**
69
+ * Start a loop that runs `run()` forever on a jittered cadence and CANNOT be
70
+ * killed by an attempt that hangs or throws.
71
+ */
72
+ export declare function startPollLoop(input: PollLoopInput): PollLoopHandle;
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ /**
3
+ * Self-healing poll scheduler for the resident daemon.
4
+ *
5
+ * WHY THIS EXISTS (a real production incident, 2026-08-22)
6
+ * The daemon's loops schedule the next tick only AFTER the current attempt
7
+ * settles — deliberate, so a slow control plane can never stack overlapping
8
+ * polls on a developer's laptop. The hidden cost: an attempt that NEVER
9
+ * settles kills the loop for the lifetime of the process.
10
+ *
11
+ * That is not theoretical. `fetch` can hang indefinitely despite its
12
+ * `AbortSignal.timeout` when the underlying socket dies without a FIN —
13
+ * laptop sleep, VPN/Wi-Fi change, or a corporate proxy silently dropping an
14
+ * idle connection. On the incident machine the bundle poll hung at 15:59 and
15
+ * never ran again, while heartbeats, hooks, and the watchdog all kept working
16
+ * for three hours. The machine therefore looked perfectly healthy in the
17
+ * console — but the bundle poll is the ONLY delivery path for remote actions,
18
+ * so every admin fix (upgrade, repair protection, collect diagnostics)
19
+ * silently expired unacknowledged. The worst possible failure mode: a machine
20
+ * we cannot reach that reports itself fine.
21
+ *
22
+ * The fix is a hard per-attempt deadline. When it expires we abandon the
23
+ * attempt (logging + distress so the fleet SEES it) and schedule the next
24
+ * tick anyway. The orphaned promise gets a permanent catch so a late
25
+ * rejection can never surface as an unhandled rejection and take the daemon
26
+ * down with it.
27
+ *
28
+ * Pure and injectable (timers + clock) so the "loop survives a poll that
29
+ * never settles" property is unit-tested without waiting on real minutes.
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.runWithDeadline = runWithDeadline;
33
+ exports.startPollLoop = startPollLoop;
34
+ const MIN_DELAY_MS = 5_000;
35
+ /**
36
+ * Await `run()` but never longer than `deadlineMs`. Resolves 'settled' when the
37
+ * attempt finished (fulfilled or rejected) and 'abandoned' when the deadline
38
+ * won the race.
39
+ */
40
+ async function runWithDeadline(input) {
41
+ const timers = input.timers || { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: handle => clearTimeout(handle) };
42
+ // The catch is attached NOW and stays attached: if this attempt is abandoned
43
+ // and rejects minutes later, the rejection is already handled.
44
+ let attemptSettled = false;
45
+ const attempt = (async () => {
46
+ try {
47
+ await input.run();
48
+ }
49
+ catch (error) {
50
+ try {
51
+ input.onError?.(error);
52
+ }
53
+ catch { /* a reporter must never break the loop */ }
54
+ }
55
+ finally {
56
+ attemptSettled = true;
57
+ }
58
+ })();
59
+ let deadlineHandle;
60
+ const deadline = new Promise(resolve => {
61
+ deadlineHandle = timers.setTimeout(() => resolve(), input.deadlineMs);
62
+ });
63
+ await Promise.race([attempt, deadline]);
64
+ timers.clearTimeout(deadlineHandle);
65
+ return attemptSettled ? 'settled' : 'abandoned';
66
+ }
67
+ /**
68
+ * Start a loop that runs `run()` forever on a jittered cadence and CANNOT be
69
+ * killed by an attempt that hangs or throws.
70
+ */
71
+ function startPollLoop(input) {
72
+ const timers = input.timers || { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: handle => clearTimeout(handle) };
73
+ const random = input.random || Math.random;
74
+ const jitter = Math.min(Math.max(input.jitter ?? 0.2, 0), 0.9);
75
+ let stopped = false;
76
+ let stalled = 0;
77
+ let tickHandle;
78
+ const nextDelay = () => {
79
+ const base = input.intervalMs();
80
+ const safeBase = Number.isFinite(base) && base > 0 ? base : MIN_DELAY_MS;
81
+ const factor = 1 - jitter + random() * jitter * 2;
82
+ return Math.max(MIN_DELAY_MS, Math.round(safeBase * factor));
83
+ };
84
+ const schedule = () => {
85
+ if (stopped)
86
+ return;
87
+ tickHandle = timers.setTimeout(async () => {
88
+ if (stopped)
89
+ return;
90
+ const outcome = await runWithDeadline({
91
+ run: input.run,
92
+ deadlineMs: input.deadlineMs,
93
+ onError: input.onError,
94
+ timers,
95
+ });
96
+ if (outcome === 'abandoned') {
97
+ stalled += 1;
98
+ try {
99
+ input.onStalled?.(input.deadlineMs);
100
+ }
101
+ catch { /* never break the loop */ }
102
+ }
103
+ schedule();
104
+ }, nextDelay());
105
+ };
106
+ schedule();
107
+ return {
108
+ stop() {
109
+ stopped = true;
110
+ timers.clearTimeout(tickHandle);
111
+ },
112
+ stalledCount() {
113
+ return stalled;
114
+ },
115
+ };
116
+ }
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.26.3"
2
+ "version": "1.26.4"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.26.3",
3
+ "version": "1.26.4",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -53,6 +53,7 @@
53
53
  "test:scan-root-compaction": "npm run build && node scripts/test-scan-root-compaction.js",
54
54
  "test:dpapi-config": "npm run build && node scripts/test-dpapi-config.js",
55
55
  "test:distress-ledger": "npm run build && node scripts/test-distress-ledger.js",
56
+ "test:poll-loop": "npm run build && node scripts/test-poll-loop-resilience.js",
56
57
  "test:deep-selftest": "npm run build && node scripts/test-deep-selftest.js",
57
58
  "test:update-loop": "npm run build && node scripts/test-update-loop-detection.js",
58
59
  "test:bricked-rescue": "npm run build && node scripts/test-bricked-machine-rescue.js",