fullcourtdefense-cli 1.26.3 → 1.26.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hookIo.js ADDED
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.HOOK_DEBUG_VERBOSE = exports.HOOK_DEBUG_LOG = void 0;
37
+ exports.dbg = dbg;
38
+ exports.readStdin = readStdin;
39
+ exports.mapHookFlags = mapHookFlags;
40
+ exports.parseHookFlags = parseHookFlags;
41
+ exports.slimHookEntryPath = slimHookEntryPath;
42
+ const fs = __importStar(require("fs"));
43
+ const os = __importStar(require("os"));
44
+ const path = __importStar(require("path"));
45
+ /**
46
+ * Hook I/O leaf — the pieces of the hook runtime that BOTH entries share:
47
+ *
48
+ * - `dist/hookSlim.js` (the slim hook entry installed into IDE hook configs)
49
+ * - `dist/index.js hook` (the full CLI's hook command, and the daemon's
50
+ * in-process evaluator)
51
+ *
52
+ * COLD-START CONTRACT: this module is on the slim entry's module graph, which
53
+ * is loaded on EVERY IDE hook event. It may import Node built-ins ONLY —
54
+ * every additional module here is bytes the customer's EDR re-scans per
55
+ * docker command. `scripts/test-slim-hook.js` enforces the graph budget.
56
+ */
57
+ exports.HOOK_DEBUG_LOG = path.join(os.homedir(), '.fullcourtdefense-hook.log');
58
+ /**
59
+ * Verbose diagnostics (FCD_HOOK_DEBUG=1) additionally log content previews
60
+ * (raw payload + prompt text snippets). Default logging keeps operational
61
+ * metadata only — user prompt content must never sit in a plaintext file on
62
+ * a customer laptop unless support explicitly opts in.
63
+ */
64
+ exports.HOOK_DEBUG_VERBOSE = process.env.FCD_HOOK_DEBUG === '1';
65
+ /** Rotate the hook log when it grows past this size (same scheme as daemon.log). */
66
+ const DEBUG_LOG_MAX_BYTES = 1_000_000;
67
+ // Checked once per (short-lived) hook process: unbounded hook.log growth was
68
+ // real customer disk churn — and every append to a huge file re-triggers AV
69
+ // scanning. One stat per process is enough for a hook run; the resident
70
+ // daemon (verdict IPC evaluation) re-checks every 500 writes so a long
71
+ // uptime can't outgrow the cap either.
72
+ let debugLogRotationChecked = false;
73
+ let debugLogWritesSinceCheck = 0;
74
+ /** Append a diagnostic line so we can see exactly what the IDE invoked + the verdict. */
75
+ function dbg(obj) {
76
+ try {
77
+ debugLogWritesSinceCheck += 1;
78
+ if (!debugLogRotationChecked || debugLogWritesSinceCheck >= 500) {
79
+ debugLogRotationChecked = true;
80
+ debugLogWritesSinceCheck = 0;
81
+ try {
82
+ if (fs.existsSync(exports.HOOK_DEBUG_LOG) && fs.statSync(exports.HOOK_DEBUG_LOG).size > DEBUG_LOG_MAX_BYTES) {
83
+ fs.renameSync(exports.HOOK_DEBUG_LOG, `${exports.HOOK_DEBUG_LOG}.1`);
84
+ }
85
+ }
86
+ catch { /* rotation is best-effort */ }
87
+ }
88
+ fs.appendFileSync(exports.HOOK_DEBUG_LOG, JSON.stringify({ t: new Date().toISOString(), ...obj }) + '\n');
89
+ }
90
+ catch { /* ignore */ }
91
+ }
92
+ function readStdin() {
93
+ return new Promise((resolve) => {
94
+ const stdin = process.stdin;
95
+ if (stdin.isTTY) {
96
+ resolve('');
97
+ return;
98
+ }
99
+ // Read raw BYTES (no setEncoding) and decode the whole payload as UTF-8 once.
100
+ // Per-chunk string decoding + concat corrupts multibyte chars (e.g. Hebrew),
101
+ // turning them into Latin-1 mojibake before they ever reach the backend.
102
+ const chunks = [];
103
+ let settled = false;
104
+ const finish = () => {
105
+ if (settled)
106
+ return;
107
+ settled = true;
108
+ let buf = Buffer.concat(chunks);
109
+ // Strip UTF-8 BOM at the byte level (Cursor on Windows prepends EF BB BF).
110
+ if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) {
111
+ buf = buf.subarray(3);
112
+ }
113
+ resolve(buf.toString('utf8'));
114
+ };
115
+ stdin.on('data', (chunk) => {
116
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
117
+ });
118
+ stdin.on('end', finish);
119
+ stdin.on('error', finish);
120
+ // Safety: if nothing arrives, don't hang forever.
121
+ setTimeout(finish, 2000).unref?.();
122
+ });
123
+ }
124
+ /**
125
+ * kebab-case CLI flags -> the camelCase HookArgs shape. ONE mapping shared by
126
+ * the full CLI entry (`index.ts` case 'hook') and the slim entry, so the
127
+ * daemon's IPC evaluator always receives identical argument shapes no matter
128
+ * which entry the IDE invoked.
129
+ */
130
+ function mapHookFlags(flags) {
131
+ return {
132
+ event: flags.event,
133
+ apiUrl: flags['api-url'],
134
+ shieldId: flags['shield-id'],
135
+ shieldKey: flags['shield-key'],
136
+ shadow: flags.shadow,
137
+ enforce: flags.enforce,
138
+ failClosed: flags['fail-closed'],
139
+ localOnly: flags['local-only'],
140
+ timeout: flags.timeout,
141
+ approvalMode: flags['approval-mode'],
142
+ approvalTimeoutMs: flags['approval-timeout-ms'],
143
+ approvalPollMs: flags['approval-poll-ms'],
144
+ };
145
+ }
146
+ /**
147
+ * Minimal `--flag value` / `--flag=value` parser with the same semantics as
148
+ * the full CLI's parseArgs, restricted to what a hook command line carries.
149
+ * Positionals (the literal `hook` subcommand kept in installed commands for
150
+ * the managed-entry repair marker) are ignored.
151
+ */
152
+ function parseHookFlags(argv) {
153
+ const flags = {};
154
+ for (let i = 0; i < argv.length; i++) {
155
+ const arg = argv[i];
156
+ if (!arg.startsWith('-'))
157
+ continue;
158
+ const key = arg.startsWith('--') ? arg.slice(2) : arg.slice(1);
159
+ const eqIdx = key.indexOf('=');
160
+ if (eqIdx !== -1) {
161
+ flags[key.slice(0, eqIdx)] = key.slice(eqIdx + 1);
162
+ continue;
163
+ }
164
+ const next = argv[i + 1];
165
+ if (next && !next.startsWith('-')) {
166
+ flags[key] = next;
167
+ i++;
168
+ }
169
+ else {
170
+ flags[key] = 'true';
171
+ }
172
+ }
173
+ return flags;
174
+ }
175
+ /**
176
+ * Absolute path of the slim hook entry that sits next to a given CLI script
177
+ * (`dist/index.js` -> `dist/hookSlim.js`). Installers prefer the slim entry
178
+ * when it exists; older installs (pre-slim dist) keep pointing at index.js.
179
+ */
180
+ function slimHookEntryPath(cliScriptPath) {
181
+ try {
182
+ const candidate = path.join(path.dirname(cliScriptPath), 'hookSlim.js');
183
+ return fs.existsSync(candidate) ? candidate : undefined;
184
+ }
185
+ catch {
186
+ return undefined;
187
+ }
188
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SLIM HOOK ENTRY — what the IDE actually spawns on every hook event.
4
+ *
5
+ * The per-event tax on a hook used to be loading the ENTIRE CLI (dist/index.js
6
+ * and its transitive graph) just to ask the resident daemon one question over
7
+ * the verdict pipe. On a normal laptop that is tens of milliseconds; on a
8
+ * locked-down corporate machine whose EDR re-scans every loaded file, it is
9
+ * the 1-5s "sticky" pause a docker-heavy customer feels dozens of times an
10
+ * hour. This entry exists to make the common path (daemon up, IPC verdict)
11
+ * load almost nothing:
12
+ *
13
+ * node boot -> hookSlim.js -> hookIo.js + verdictIpcClient.js (3 small files,
14
+ * Node built-ins only) -> named-pipe verdict -> print -> exit.
15
+ *
16
+ * FALLBACK PARITY: when the daemon is unreachable, this entry lazily requires
17
+ * the full hook evaluation (`commands/hook.evaluateHookRequest`) — literally
18
+ * the same code the full CLI runs — so behavior with the daemon down is
19
+ * byte-identical to `index.js hook`. The slim win applies exactly where it
20
+ * matters: the warm, common case.
21
+ *
22
+ * FAIL-OPEN CONTRACT (same as the full hook): any unexpected error must never
23
+ * surface as a stack trace, a nonzero exit, or a blocked command. The ONE
24
+ * exception is the admin kill-switch — a crash on a suspended machine still
25
+ * denies, so a broken install can never unlock a machine an admin suspended.
26
+ *
27
+ * COLD-START CONTRACT: static imports here are restricted to the two leaves
28
+ * below (Node built-ins only). Everything else loads lazily, and ONLY on the
29
+ * fallback path. `scripts/test-slim-hook.js` fails CI if the warm-path module
30
+ * graph grows.
31
+ */
32
+ export {};
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * SLIM HOOK ENTRY — what the IDE actually spawns on every hook event.
5
+ *
6
+ * The per-event tax on a hook used to be loading the ENTIRE CLI (dist/index.js
7
+ * and its transitive graph) just to ask the resident daemon one question over
8
+ * the verdict pipe. On a normal laptop that is tens of milliseconds; on a
9
+ * locked-down corporate machine whose EDR re-scans every loaded file, it is
10
+ * the 1-5s "sticky" pause a docker-heavy customer feels dozens of times an
11
+ * hour. This entry exists to make the common path (daemon up, IPC verdict)
12
+ * load almost nothing:
13
+ *
14
+ * node boot -> hookSlim.js -> hookIo.js + verdictIpcClient.js (3 small files,
15
+ * Node built-ins only) -> named-pipe verdict -> print -> exit.
16
+ *
17
+ * FALLBACK PARITY: when the daemon is unreachable, this entry lazily requires
18
+ * the full hook evaluation (`commands/hook.evaluateHookRequest`) — literally
19
+ * the same code the full CLI runs — so behavior with the daemon down is
20
+ * byte-identical to `index.js hook`. The slim win applies exactly where it
21
+ * matters: the warm, common case.
22
+ *
23
+ * FAIL-OPEN CONTRACT (same as the full hook): any unexpected error must never
24
+ * surface as a stack trace, a nonzero exit, or a blocked command. The ONE
25
+ * exception is the admin kill-switch — a crash on a suspended machine still
26
+ * denies, so a broken install can never unlock a machine an admin suspended.
27
+ *
28
+ * COLD-START CONTRACT: static imports here are restricted to the two leaves
29
+ * below (Node built-ins only). Everything else loads lazily, and ONLY on the
30
+ * fallback path. `scripts/test-slim-hook.js` fails CI if the warm-path module
31
+ * graph grows.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ const hookIo_1 = require("./hookIo");
35
+ const verdictIpcClient_1 = require("./verdictIpcClient");
36
+ function emitFailOpen(event, message) {
37
+ // Mirror of the full hook's failOpenOutcome, reachable only when even the
38
+ // lazy fallback could not load (corrupt install). Best effort all the way
39
+ // down; the kill-switch check and spool are each allowed to fail alone.
40
+ let suspended = false;
41
+ try {
42
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
43
+ suspended = require('./runtimeConfig').isSuspendedByCache();
44
+ }
45
+ catch { /* runtime cache unreadable — stay fail-open */ }
46
+ try {
47
+ (0, hookIo_1.dbg)({ phase: 'slim_fail_open', event, error: message.slice(0, 300), suspended });
48
+ }
49
+ catch { /* never throw */ }
50
+ try {
51
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
52
+ const telemetry = require('./telemetry');
53
+ telemetry.setVerdictPath('fail_open');
54
+ telemetry.spoolEvent({
55
+ decision: suspended ? 'block' : 'allow',
56
+ toolName: event || 'unknown',
57
+ operation: `${event || 'unknown'}:hook_error`,
58
+ reason: suspended
59
+ ? `Hook error on a SUSPENDED machine — denied (kill-switch holds): ${message.slice(0, 200)}`
60
+ : `Hook error failed open: ${message.slice(0, 200)}`,
61
+ offlineEnforced: true,
62
+ ...(suspended ? { severity: 'critical', source: 'backend' } : {}),
63
+ });
64
+ telemetry.triggerFlush(true);
65
+ }
66
+ catch { /* telemetry unavailable — the verdict below still stands */ }
67
+ const verdict = suspended
68
+ ? {
69
+ continue: false,
70
+ permission: 'deny',
71
+ user_message: 'This machine is suspended by your security team — all AI agent actions are blocked. Contact your admin to resume it.',
72
+ agent_message: 'FullCourtDefense: this machine is suspended by an administrator. Stop — do not retry any action until it is resumed.',
73
+ }
74
+ : { continue: true, permission: 'allow' };
75
+ try {
76
+ process.stdout.write(JSON.stringify(verdict) + '\n');
77
+ }
78
+ catch { /* stdout gone */ }
79
+ process.exit(0);
80
+ }
81
+ async function main() {
82
+ const startedAt = Date.now();
83
+ const flags = (0, hookIo_1.parseHookFlags)(process.argv.slice(2));
84
+ const args = (0, hookIo_1.mapHookFlags)(flags);
85
+ try {
86
+ const raw = await (0, hookIo_1.readStdin)();
87
+ // FAST PATH: the resident daemon computes the verdict with everything warm.
88
+ // Bounded hard (connect ~250ms, response ~2.5s) — any non-verdict outcome
89
+ // falls through to the full evaluation below. FCD_VERDICT_IPC=off opts out.
90
+ if (process.env.FCD_VERDICT_IPC !== 'off') {
91
+ const ipc = await (0, verdictIpcClient_1.requestDaemonVerdict)(args, raw, undefined, startedAt);
92
+ if (ipc.outcome === 'verdict') {
93
+ (0, hookIo_1.dbg)({ phase: 'ipc_verdict', event: args.event, exitCode: ipc.exitCode, entry: 'slim' });
94
+ if (ipc.stderr) {
95
+ try {
96
+ process.stderr.write(ipc.stderr);
97
+ }
98
+ catch { /* stderr gone */ }
99
+ }
100
+ if (ipc.stdout) {
101
+ try {
102
+ process.stdout.write(ipc.stdout);
103
+ }
104
+ catch { /* stdout gone */ }
105
+ }
106
+ process.exit(ipc.exitCode ?? 0);
107
+ }
108
+ (0, hookIo_1.dbg)({ phase: 'ipc_unavailable', event: args.event, detail: ipc.detail, entry: 'slim' });
109
+ }
110
+ // FALLBACK: daemon down/busy — load the full evaluation lazily. This is
111
+ // the exact code `index.js hook` runs (including its own fail-open and
112
+ // kill-switch handling), so daemon-down behavior is identical to the
113
+ // pre-slim hook. evaluateHookRequest never rejects by contract.
114
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
115
+ const { evaluateHookRequest } = require('./commands/hook');
116
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
117
+ const { loadConfig } = require('./config');
118
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
119
+ const { markVerdictTiming } = require('./telemetry');
120
+ const result = await markVerdictTiming({ startedAt, path: 'local' }, () => evaluateHookRequest(args, raw, loadConfig()));
121
+ if (result.stderr) {
122
+ try {
123
+ process.stderr.write(result.stderr);
124
+ }
125
+ catch { /* stderr gone */ }
126
+ }
127
+ if (result.stdout) {
128
+ try {
129
+ process.stdout.write(result.stdout);
130
+ }
131
+ catch { /* stdout gone */ }
132
+ }
133
+ process.exit(result.exitCode);
134
+ }
135
+ catch (err) {
136
+ emitFailOpen(args.event, err instanceof Error ? err.message : String(err));
137
+ }
138
+ }
139
+ // Main-guarded so importing this module (tests, tooling) never runs the hook.
140
+ if (require.main === module) {
141
+ void main();
142
+ }
package/dist/index.js CHANGED
@@ -44,6 +44,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
44
44
  // touching this file.
45
45
  const config_1 = require("./config");
46
46
  const hook_1 = require("./commands/hook");
47
+ const hookIo_1 = require("./hookIo");
47
48
  const fs = __importStar(require("fs"));
48
49
  const path = __importStar(require("path"));
49
50
  function readCliVersion() {
@@ -679,20 +680,9 @@ async function main() {
679
680
  break;
680
681
  }
681
682
  case 'hook': {
682
- const args = {
683
- event: flags.event,
684
- apiUrl: flags['api-url'],
685
- shieldId: flags['shield-id'],
686
- shieldKey: flags['shield-key'],
687
- shadow: flags.shadow,
688
- enforce: flags.enforce,
689
- failClosed: flags['fail-closed'],
690
- localOnly: flags['local-only'],
691
- timeout: flags.timeout,
692
- approvalMode: flags['approval-mode'],
693
- approvalTimeoutMs: flags['approval-timeout-ms'],
694
- approvalPollMs: flags['approval-poll-ms'],
695
- };
683
+ // ONE flag mapping shared with the slim entry (hookSlim.ts) — the daemon
684
+ // IPC evaluator must see identical argument shapes from both entries.
685
+ const args = (0, hookIo_1.mapHookFlags)(flags);
696
686
  await (0, hook_1.hookCommand)(args, config);
697
687
  break;
698
688
  }
@@ -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
+ }