fullcourtdefense-cli 1.15.12 → 1.17.0
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/commands/daemon.js +42 -0
- package/dist/commands/installAll.js +2 -1
- package/dist/commands/installCursorHook.js +16 -10
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +4 -0
- package/dist/commands/mcpGateway.js +40 -17
- package/dist/commands/onboard.d.ts +2 -0
- package/dist/commands/onboard.js +59 -1
- package/dist/index.js +2 -0
- package/dist/runtimeConfig.d.ts +6 -1
- package/dist/runtimeConfig.js +9 -3
- package/dist/selfUpdate.d.ts +41 -0
- package/dist/selfUpdate.js +180 -0
- package/dist/telemetry.js +22 -0
- package/dist/version.json +1 -1
- package/package.json +1 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -50,6 +50,7 @@ const notify_1 = require("../notify");
|
|
|
50
50
|
const integrity_1 = require("../integrity");
|
|
51
51
|
const machineIdentity_1 = require("../machineIdentity");
|
|
52
52
|
const discoveryMarker_1 = require("../discoveryMarker");
|
|
53
|
+
const selfUpdate_1 = require("../selfUpdate");
|
|
53
54
|
const COLOR = {
|
|
54
55
|
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
55
56
|
red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
|
|
@@ -293,6 +294,8 @@ async function runDaemon(args, config) {
|
|
|
293
294
|
let suspended = false;
|
|
294
295
|
let stopped = false;
|
|
295
296
|
const executingActionIds = new Set();
|
|
297
|
+
/** Latest org auto-update policy seen on a bundle poll. */
|
|
298
|
+
let autoUpdatePolicy;
|
|
296
299
|
const reprotect = async (reasonPaths) => {
|
|
297
300
|
if (reprotecting || stopped)
|
|
298
301
|
return;
|
|
@@ -525,6 +528,24 @@ async function runDaemon(args, config) {
|
|
|
525
528
|
log(`Repair protection: verified (${verification.protectedMcpConfigs}/${verification.discoveredMcpConfigs} MCP configs wrapped).`);
|
|
526
529
|
resultSummary = 'AgentGuard hooks, gateways, and protection configuration were repaired and verified.';
|
|
527
530
|
}
|
|
531
|
+
else if (action.type === 'upgrade_cli') {
|
|
532
|
+
// Explicit version in the action reason wins; otherwise the org
|
|
533
|
+
// auto-update target from the bundle (pinned or latest release).
|
|
534
|
+
const target = action.reason.match(/\d+\.\d+\.\d+/)?.[0] || autoUpdatePolicy?.targetVersion;
|
|
535
|
+
if (!target)
|
|
536
|
+
throw new Error('No target version available — the control plane could not resolve the latest release.');
|
|
537
|
+
log(`Upgrade CLI: admin requested an upgrade to ${target}.`);
|
|
538
|
+
const outcome = (0, selfUpdate_1.maybeSelfUpdate)({ currentVersion: cliVersion(), targetVersion: target, enabled: true, log });
|
|
539
|
+
if (!outcome) {
|
|
540
|
+
resultSummary = `CLI ${cliVersion() || 'unknown'} is already at ${target} (or an upgrade is in progress).`;
|
|
541
|
+
}
|
|
542
|
+
else if (outcome.started) {
|
|
543
|
+
resultSummary = outcome.detail;
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
throw new Error(outcome.detail);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
528
549
|
else if (action.type === 'discovery_scan') {
|
|
529
550
|
log('Discovery scan: starting full surface sweep (MCP + secrets + agent files + posture)…');
|
|
530
551
|
await uploadLogTail();
|
|
@@ -585,6 +606,27 @@ async function runDaemon(args, config) {
|
|
|
585
606
|
if (bundle.machineAction) {
|
|
586
607
|
void executeMachineAction(bundle.machineAction);
|
|
587
608
|
}
|
|
609
|
+
// Silent auto-update: upgrade toward the org's target version. Skipped
|
|
610
|
+
// while a remote action runs, while suspended, and rate-limited so a
|
|
611
|
+
// pending upgrade isn't re-kicked every poll.
|
|
612
|
+
autoUpdatePolicy = bundle.autoUpdate;
|
|
613
|
+
// Mirror the org policy for the elevated MSI updater task (it runs as
|
|
614
|
+
// SYSTEM and cannot read the shield-key-authenticated bundle itself).
|
|
615
|
+
if (bundle.autoUpdate) {
|
|
616
|
+
try {
|
|
617
|
+
fs.writeFileSync(path.join(daemonDir(), 'update-policy.json'), JSON.stringify({ ...bundle.autoUpdate, updatedAt: new Date().toISOString() }), 'utf8');
|
|
618
|
+
}
|
|
619
|
+
catch { /* mirror is best-effort; the updater defaults to enabled */ }
|
|
620
|
+
}
|
|
621
|
+
if (!suspended && bundle.autoUpdate?.enabled) {
|
|
622
|
+
(0, selfUpdate_1.maybeSelfUpdate)({
|
|
623
|
+
currentVersion: cliVersion(),
|
|
624
|
+
targetVersion: bundle.autoUpdate.targetVersion,
|
|
625
|
+
enabled: true,
|
|
626
|
+
busy: executingActionIds.size > 0,
|
|
627
|
+
log,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
588
630
|
}
|
|
589
631
|
catch { /* offline — cached stance applies */ }
|
|
590
632
|
};
|
|
@@ -42,8 +42,9 @@ async function installAllCommand(args, config) {
|
|
|
42
42
|
apiUrl: creds.apiUrl,
|
|
43
43
|
project: args.cursorProject,
|
|
44
44
|
// Scope is MCP/tool actions only — prompt text never leaves the machine.
|
|
45
|
+
// No failClosed flag: the offline stance is server-authoritative (from
|
|
46
|
+
// the runtime bundle), unified with the Claude/VS Code hook below.
|
|
45
47
|
events: 'shell,mcp',
|
|
46
|
-
failClosed: 'true',
|
|
47
48
|
};
|
|
48
49
|
try {
|
|
49
50
|
await (0, installCursorHook_1.installCursorHookCommand)(hookArgs, config);
|
|
@@ -59,12 +59,16 @@ const MANAGED_TAG = 'fullcourtdefense';
|
|
|
59
59
|
// developer would see a silent hook failure instead of a clean
|
|
60
60
|
// approved/rejected/timeout verdict.
|
|
61
61
|
const APPROVAL_CAPABLE_TIMEOUT_SEC = 960; // 16 min: 15 min approval window + poll slack
|
|
62
|
+
// Offline stance is deliberately NOT baked into the hook command by default:
|
|
63
|
+
// the runtime `hook` resolves it from the org's runtime bundle (enforcing
|
|
64
|
+
// machines fail closed, monitor machines fail open), identical to the Claude
|
|
65
|
+
// hook. Passing --fail-closed at install time writes an explicit override.
|
|
62
66
|
const EVENT_MAP = {
|
|
63
|
-
prompt: { hookKey: 'beforeSubmitPrompt', flag: 'prompt',
|
|
64
|
-
shell: { hookKey: 'beforeShellExecution', flag: 'shell',
|
|
65
|
-
mcp: { hookKey: 'beforeMCPExecution', flag: 'mcp',
|
|
66
|
-
file: { hookKey: 'afterFileEdit', flag: 'file',
|
|
67
|
-
read: { hookKey: 'beforeReadFile', flag: 'read',
|
|
67
|
+
prompt: { hookKey: 'beforeSubmitPrompt', flag: 'prompt', timeoutSec: 10 },
|
|
68
|
+
shell: { hookKey: 'beforeShellExecution', flag: 'shell', timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
|
|
69
|
+
mcp: { hookKey: 'beforeMCPExecution', flag: 'mcp', timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
|
|
70
|
+
file: { hookKey: 'afterFileEdit', flag: 'file', timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
|
|
71
|
+
read: { hookKey: 'beforeReadFile', flag: 'read', timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
|
|
68
72
|
};
|
|
69
73
|
/** Build the absolute, shell-agnostic command that invokes this CLI's `hook`. */
|
|
70
74
|
function buildHookCommand(flag, opts) {
|
|
@@ -142,12 +146,12 @@ function repairCursorManagedHooks() {
|
|
|
142
146
|
let changed = false;
|
|
143
147
|
if (before.managedEntries === 0) {
|
|
144
148
|
for (const event of ['shell', 'mcp']) {
|
|
145
|
-
const { hookKey, flag,
|
|
149
|
+
const { hookKey, flag, timeoutSec } = EVENT_MAP[event];
|
|
146
150
|
const list = Array.isArray(json.hooks[hookKey]) ? json.hooks[hookKey] : [];
|
|
147
151
|
list.push({
|
|
148
|
-
|
|
152
|
+
// Offline stance comes from the runtime bundle at hook time.
|
|
153
|
+
command: buildHookCommand(flag, { shadow: false, failClosed: false, waitForApproval: true }),
|
|
149
154
|
timeout: timeoutSec,
|
|
150
|
-
...(failClosedDefault ? { failClosed: true } : {}),
|
|
151
155
|
});
|
|
152
156
|
json.hooks[hookKey] = list;
|
|
153
157
|
}
|
|
@@ -213,8 +217,10 @@ async function installCursorHookCommand(args, config) {
|
|
|
213
217
|
delete json.hooks[key];
|
|
214
218
|
}
|
|
215
219
|
for (const e of events) {
|
|
216
|
-
const { hookKey, flag,
|
|
217
|
-
|
|
220
|
+
const { hookKey, flag, timeoutSec } = EVENT_MAP[e];
|
|
221
|
+
// No explicit flag => the hook runtime applies the server-authoritative
|
|
222
|
+
// stance from the runtime bundle (same behavior as the Claude hook).
|
|
223
|
+
const failClosed = e === 'prompt' ? false : args.failClosed === 'true';
|
|
218
224
|
const entry = {
|
|
219
225
|
command: buildHookCommand(flag, { shadow, failClosed, waitForApproval: e !== 'prompt' }),
|
|
220
226
|
timeout: timeoutSec,
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export interface LoginArgs {
|
|
|
3
3
|
/** Reusable, non-expiring fleet enrollment token (or FCD_ENROLL_TOKEN env). */
|
|
4
4
|
token?: string;
|
|
5
5
|
apiUrl?: string;
|
|
6
|
+
/** Admin-facing friendly machine name shown next to the hostname in the console. */
|
|
7
|
+
nickname?: string;
|
|
6
8
|
}
|
|
7
9
|
/**
|
|
8
10
|
* `fullcourtdefense login` — zero-paste machine enrollment.
|
package/dist/commands/login.js
CHANGED
|
@@ -21,8 +21,11 @@ async function loginCommand(args, config) {
|
|
|
21
21
|
}
|
|
22
22
|
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
23
23
|
const shieldName = (0, machineIdentity_1.suggestedShieldName)(identity);
|
|
24
|
+
const nickname = (args.nickname || process.env.FCD_MACHINE_NICKNAME || '').trim().slice(0, 80) || undefined;
|
|
24
25
|
console.log('\x1b[1mEnrolling this machine…\x1b[0m');
|
|
25
26
|
console.log(` Device: ${identity.developerName}`);
|
|
27
|
+
if (nickname)
|
|
28
|
+
console.log(` Name: ${nickname}`);
|
|
26
29
|
console.log(` OS: ${identity.osFriendly}`);
|
|
27
30
|
console.log(` Shield: ${shieldName}`);
|
|
28
31
|
const resp = await fetch(`${apiUrl}/api/cli/enroll`, {
|
|
@@ -39,6 +42,7 @@ async function loginCommand(args, config) {
|
|
|
39
42
|
osFriendly: identity.osFriendly,
|
|
40
43
|
platform: identity.platform,
|
|
41
44
|
shieldName,
|
|
45
|
+
nickname,
|
|
42
46
|
}),
|
|
43
47
|
});
|
|
44
48
|
const data = (await resp.json().catch(() => ({})));
|
|
@@ -579,18 +579,34 @@ class AgentGuardApi {
|
|
|
579
579
|
}
|
|
580
580
|
async waitForApproval(actionId) {
|
|
581
581
|
const deadline = Date.now() + this.config.approvalTimeoutMs;
|
|
582
|
+
// Tolerate transient poll blips, but fail FAST on a dead backend: waiting
|
|
583
|
+
// out the full approval window against a server that answers nothing just
|
|
584
|
+
// hangs the developer's tool call for minutes.
|
|
585
|
+
const MAX_CONSECUTIVE_POLL_ERRORS = 3;
|
|
586
|
+
let consecutiveErrors = 0;
|
|
582
587
|
while (Date.now() <= deadline) {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
588
|
+
try {
|
|
589
|
+
const status = await this.get(`/api/agent-security/runtime/approvals/${encodeURIComponent(actionId)}?shieldId=${encodeURIComponent(this.config.shieldId)}`);
|
|
590
|
+
consecutiveErrors = 0;
|
|
591
|
+
const approval = status.data?.approval;
|
|
592
|
+
if (approval?.approvalStatus === 'approved') {
|
|
593
|
+
if (approval.executionStatus === 'executed')
|
|
594
|
+
throw new Error(`Approval ${actionId} was already executed.`);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (approval?.approvalStatus === 'rejected')
|
|
598
|
+
throw new Error(`Approval rejected for ${approval.toolName}.`);
|
|
599
|
+
if (approval?.approvalStatus === 'expired')
|
|
600
|
+
throw new Error(`Approval expired for ${approval.toolName}.`);
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
if (error instanceof Error && /rejected|expired|already executed/.test(error.message))
|
|
604
|
+
throw error;
|
|
605
|
+
consecutiveErrors += 1;
|
|
606
|
+
if (consecutiveErrors >= MAX_CONSECUTIVE_POLL_ERRORS) {
|
|
607
|
+
throw new Error(`Approval status could not be checked (${consecutiveErrors} consecutive failures) — the control plane is unreachable. The request stays pending; retry once connectivity is restored.`);
|
|
608
|
+
}
|
|
589
609
|
}
|
|
590
|
-
if (approval?.approvalStatus === 'rejected')
|
|
591
|
-
throw new Error(`Approval rejected for ${approval.toolName}.`);
|
|
592
|
-
if (approval?.approvalStatus === 'expired')
|
|
593
|
-
throw new Error(`Approval expired for ${approval.toolName}.`);
|
|
594
610
|
await new Promise(resolve => setTimeout(resolve, Math.min(this.config.approvalPollMs, Math.max(0, deadline - Date.now()))));
|
|
595
611
|
}
|
|
596
612
|
throw new Error(`Approval timed out. Request ${actionId} is still waiting for review.`);
|
|
@@ -609,7 +625,10 @@ class AgentGuardApi {
|
|
|
609
625
|
agentName: this.config.agentName,
|
|
610
626
|
operation: input.operation,
|
|
611
627
|
toolArgs: input.toolArgs,
|
|
612
|
-
|
|
628
|
+
// Large tool outputs can legitimately take longer than policy checks
|
|
629
|
+
// (full Shield pipeline incl. AI judge) — but still far below the old
|
|
630
|
+
// 120s, which read as a frozen IDE.
|
|
631
|
+
}, 20_000);
|
|
613
632
|
if (!response.success && response.error)
|
|
614
633
|
throw new Error(response.error);
|
|
615
634
|
const data = response.data;
|
|
@@ -620,23 +639,26 @@ class AgentGuardApi {
|
|
|
620
639
|
? { content: [{ type: 'text', text: data.safeResponse }] }
|
|
621
640
|
: input.result;
|
|
622
641
|
}
|
|
623
|
-
|
|
642
|
+
// Tight timeouts on purpose: the gateway sits INSIDE the IDE's tool-call
|
|
643
|
+
// path, so a slow/hung backend must degrade into the offline stance in
|
|
644
|
+
// seconds — a 2-minute hang looks like a frozen IDE to the developer.
|
|
645
|
+
async post(pathValue, body, timeoutMs = 10_000) {
|
|
624
646
|
const resp = await fetch(`${this.config.apiUrl}${pathValue}`, {
|
|
625
647
|
method: 'POST',
|
|
626
648
|
headers: this.headers(),
|
|
627
649
|
body: JSON.stringify(body),
|
|
628
|
-
signal: AbortSignal.timeout(
|
|
650
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
629
651
|
});
|
|
630
652
|
const data = await resp.json().catch(() => ({}));
|
|
631
653
|
if (!resp.ok)
|
|
632
654
|
return { success: false, error: data.error || `AgentGuard API error (${resp.status})` };
|
|
633
655
|
return data;
|
|
634
656
|
}
|
|
635
|
-
async get(pathValue) {
|
|
657
|
+
async get(pathValue, timeoutMs = 10_000) {
|
|
636
658
|
const resp = await fetch(`${this.config.apiUrl}${pathValue}`, {
|
|
637
659
|
method: 'GET',
|
|
638
660
|
headers: this.headers(),
|
|
639
|
-
signal: AbortSignal.timeout(
|
|
661
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
640
662
|
});
|
|
641
663
|
const data = await resp.json().catch(() => ({}));
|
|
642
664
|
if (!resp.ok)
|
|
@@ -753,7 +775,9 @@ class McpGatewayServer {
|
|
|
753
775
|
try {
|
|
754
776
|
// The runtime bundle carries the org's current Local Safety policy hash —
|
|
755
777
|
// passing it as `expectedPolicyHash` makes a console policy edit take
|
|
756
|
-
// effect
|
|
778
|
+
// effect within the 60s cache TTL. NOT forced: a forced network round
|
|
779
|
+
// trip on EVERY tool call would add latency and a hang point to the
|
|
780
|
+
// IDE's hot path; the TTL'd cache refreshes with a 1.5s cap instead.
|
|
757
781
|
// Offline this resolves from the cached bundle (stale-while-error).
|
|
758
782
|
let expectedPolicyHash;
|
|
759
783
|
try {
|
|
@@ -763,7 +787,6 @@ class McpGatewayServer {
|
|
|
763
787
|
shieldKey: this.gatewayConfig.shieldKey,
|
|
764
788
|
developerName: this.gatewayConfig.developerName,
|
|
765
789
|
machineName: os.hostname(),
|
|
766
|
-
force: true,
|
|
767
790
|
});
|
|
768
791
|
if (bundle.source !== 'default') {
|
|
769
792
|
expectedPolicyHash = bundle.policyHash || bundle.version;
|
|
@@ -3,6 +3,8 @@ import { InstallAllArgs } from './installAll';
|
|
|
3
3
|
export interface OnboardArgs extends InstallAllArgs {
|
|
4
4
|
/** Fleet enrollment token (or FCD_ENROLL_TOKEN env). Optional when already enrolled. */
|
|
5
5
|
token?: string;
|
|
6
|
+
/** Admin-facing friendly machine name (or FCD_MACHINE_NICKNAME env) shown in the console. */
|
|
7
|
+
nickname?: string;
|
|
6
8
|
/** Continue a persisted onboarding transaction. */
|
|
7
9
|
resume?: string;
|
|
8
10
|
/** Re-run completed install steps to repair changed machine state. */
|
package/dist/commands/onboard.js
CHANGED
|
@@ -50,6 +50,8 @@ const posixShellGuard_1 = require("./posixShellGuard");
|
|
|
50
50
|
const notify_1 = require("../notify");
|
|
51
51
|
const daemon_1 = require("./daemon");
|
|
52
52
|
const machineIdentity_1 = require("../machineIdentity");
|
|
53
|
+
const integrity_1 = require("../integrity");
|
|
54
|
+
const telemetry_1 = require("../telemetry");
|
|
53
55
|
const onboardingJournal_1 = require("./onboardingJournal");
|
|
54
56
|
const GREEN = '\x1b[32m';
|
|
55
57
|
const RED = '\x1b[31m';
|
|
@@ -249,7 +251,7 @@ async function onboardCommand(args, config) {
|
|
|
249
251
|
mark('enrollment', 'skipped', alreadyEnrolled ? 'would reuse existing per-machine Shield credentials' : 'would enroll with supplied token');
|
|
250
252
|
}
|
|
251
253
|
else if (token) {
|
|
252
|
-
await (0, login_1.loginCommand)({ token, apiUrl: args.apiUrl }, config);
|
|
254
|
+
await (0, login_1.loginCommand)({ token, apiUrl: args.apiUrl, nickname: args.nickname }, config);
|
|
253
255
|
mark('enrollment', 'completed', 'machine enrolled');
|
|
254
256
|
}
|
|
255
257
|
else if (alreadyEnrolled) {
|
|
@@ -349,6 +351,34 @@ async function onboardCommand(args, config) {
|
|
|
349
351
|
console.log(`\n${BOLD}[6/6] Verifying protection surfaces…${RESET}`);
|
|
350
352
|
mark('verification', 'running');
|
|
351
353
|
const postCreds = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(), { apiUrl });
|
|
354
|
+
// Immediate heartbeat: the machine must appear ONLINE in the fleet console
|
|
355
|
+
// the moment onboarding finishes — not whenever the daemon's first 5-minute
|
|
356
|
+
// tick or the daily discovery job happens to fire. Carries the local
|
|
357
|
+
// integrity verdict so the console shows agent health right away too.
|
|
358
|
+
let firstHeartbeatOk = false;
|
|
359
|
+
if (!dryRun && postCreds.shieldId) {
|
|
360
|
+
try {
|
|
361
|
+
const integrity = (0, integrity_1.getLocalIntegrityReport)();
|
|
362
|
+
let agentVersion;
|
|
363
|
+
try {
|
|
364
|
+
agentVersion = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', 'package.json'), 'utf8')).version;
|
|
365
|
+
}
|
|
366
|
+
catch { /* version is optional on the heartbeat */ }
|
|
367
|
+
const flush = await (0, telemetry_1.flushSpool)({
|
|
368
|
+
apiUrl,
|
|
369
|
+
shieldId: postCreds.shieldId,
|
|
370
|
+
shieldKey: postCreds.shieldKey,
|
|
371
|
+
heartbeat: true,
|
|
372
|
+
agentVersion,
|
|
373
|
+
integrityOk: integrity.ok,
|
|
374
|
+
integrityReasons: integrity.reasons,
|
|
375
|
+
integrityCheckedAt: integrity.checkedAt,
|
|
376
|
+
timeoutMs: 8_000,
|
|
377
|
+
});
|
|
378
|
+
firstHeartbeatOk = flush !== null;
|
|
379
|
+
}
|
|
380
|
+
catch { /* the daemon heartbeat remains the backstop */ }
|
|
381
|
+
}
|
|
352
382
|
const checks = [
|
|
353
383
|
reachable,
|
|
354
384
|
{
|
|
@@ -371,6 +401,16 @@ async function onboardCommand(args, config) {
|
|
|
371
401
|
optional: true,
|
|
372
402
|
detail: (0, discoverSchedule_1.isDiscoverScheduleInstalled)() ? undefined : 'run discover --schedule daily',
|
|
373
403
|
},
|
|
404
|
+
{
|
|
405
|
+
// Informational: protection is local, so a flaky first ping must never
|
|
406
|
+
// fail onboarding — the daemon heartbeat retries within 5 minutes.
|
|
407
|
+
label: 'First heartbeat delivered',
|
|
408
|
+
ok: firstHeartbeatOk,
|
|
409
|
+
optional: true,
|
|
410
|
+
detail: firstHeartbeatOk
|
|
411
|
+
? 'this machine now shows ONLINE in the fleet console'
|
|
412
|
+
: dryRun ? 'skipped (dry run)' : 'not delivered yet — the daemon retries within 5 minutes',
|
|
413
|
+
},
|
|
374
414
|
];
|
|
375
415
|
console.log('');
|
|
376
416
|
for (const check of checks)
|
|
@@ -388,6 +428,24 @@ async function onboardCommand(args, config) {
|
|
|
388
428
|
else {
|
|
389
429
|
console.log(`${YELLOW}${BOLD}Onboarding finished with ${requiredFailures.length} unresolved surface(s).${RESET} Fix the ✗ lines above and re-run ${BOLD}fullcourtdefense onboard${RESET}.`);
|
|
390
430
|
mark('verification', 'failed', undefined, `${requiredFailures.length} critical protection surfaces unresolved`);
|
|
431
|
+
// Surface the partial install in the fleet console, not just locally: spool
|
|
432
|
+
// a failed-install event (flushed with the heartbeat above or by the daemon)
|
|
433
|
+
// so admins see WHICH surfaces are missing without touching the laptop.
|
|
434
|
+
if (!dryRun && postCreds.shieldId) {
|
|
435
|
+
try {
|
|
436
|
+
(0, telemetry_1.spoolEvent)({
|
|
437
|
+
type: 'verdict',
|
|
438
|
+
decision: 'allow',
|
|
439
|
+
source: 'builtin',
|
|
440
|
+
toolName: 'onboarding',
|
|
441
|
+
operation: 'install_verification_failed',
|
|
442
|
+
reason: `Onboarding finished with unresolved surfaces: ${requiredFailures.map(check => check.label).join(', ')}`,
|
|
443
|
+
severity: 'high',
|
|
444
|
+
});
|
|
445
|
+
await (0, telemetry_1.flushSpool)({ apiUrl, shieldId: postCreds.shieldId, shieldKey: postCreds.shieldKey, timeoutMs: 5_000 });
|
|
446
|
+
}
|
|
447
|
+
catch { /* best-effort */ }
|
|
448
|
+
}
|
|
391
449
|
report('Fix the failed required surfaces, then run fullcourtdefense onboard --resume --repair.');
|
|
392
450
|
process.exitCode = 1;
|
|
393
451
|
}
|
package/dist/index.js
CHANGED
|
@@ -506,6 +506,7 @@ async function main() {
|
|
|
506
506
|
const args = {
|
|
507
507
|
token: flags.token || flags['enroll-token'],
|
|
508
508
|
apiUrl: flags['api-url'],
|
|
509
|
+
nickname: flags.nickname || flags['machine-name'],
|
|
509
510
|
};
|
|
510
511
|
await (0, login_1.loginCommand)(args, config);
|
|
511
512
|
break;
|
|
@@ -646,6 +647,7 @@ async function main() {
|
|
|
646
647
|
const args = {
|
|
647
648
|
...buildInstallArgs(),
|
|
648
649
|
token: flags.token || flags['enroll-token'],
|
|
650
|
+
nickname: flags.nickname || flags['machine-name'],
|
|
649
651
|
hooks: flags.hooks,
|
|
650
652
|
discover: flags.discover,
|
|
651
653
|
autoProtect: flags['auto-protect'],
|
package/dist/runtimeConfig.d.ts
CHANGED
|
@@ -28,10 +28,15 @@ export interface RuntimeBundle {
|
|
|
28
28
|
extraScanRoots?: string[];
|
|
29
29
|
/** Default scan folders the admin explicitly removed from this machine's posture sweep. */
|
|
30
30
|
disabledScanRoots?: string[];
|
|
31
|
+
/** Org auto-update policy: silently upgrade the CLI to targetVersion. */
|
|
32
|
+
autoUpdate?: {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
targetVersion?: string;
|
|
35
|
+
};
|
|
31
36
|
/** One constrained, auditable action queued for the resident daemon. */
|
|
32
37
|
machineAction?: {
|
|
33
38
|
id: string;
|
|
34
|
-
type: 'health_check' | 'policy_refresh' | 'discovery_scan' | 'repair_protection';
|
|
39
|
+
type: 'health_check' | 'policy_refresh' | 'discovery_scan' | 'repair_protection' | 'upgrade_cli';
|
|
35
40
|
reason: string;
|
|
36
41
|
createdAt: string;
|
|
37
42
|
expiresAt: string;
|
package/dist/runtimeConfig.js
CHANGED
|
@@ -72,7 +72,7 @@ async function getRuntimeBundle(input) {
|
|
|
72
72
|
const cached = cache[input.shieldId];
|
|
73
73
|
const fresh = cached && Date.now() - cached.fetchedAt < ttl;
|
|
74
74
|
if (cached && fresh && !input.force) {
|
|
75
|
-
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, machineAction: cached.machineAction, source: 'cache' };
|
|
75
|
+
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, machineAction: cached.machineAction, source: 'cache' };
|
|
76
76
|
}
|
|
77
77
|
try {
|
|
78
78
|
const headers = { 'Content-Type': 'application/json' };
|
|
@@ -92,7 +92,7 @@ async function getRuntimeBundle(input) {
|
|
|
92
92
|
if (resp.status === 304 && cached) {
|
|
93
93
|
cache[input.shieldId] = { ...cached, fetchedAt: Date.now() };
|
|
94
94
|
writeCacheFile(cache);
|
|
95
|
-
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, machineAction: cached.machineAction, source: 'cache' };
|
|
95
|
+
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, machineAction: cached.machineAction, source: 'cache' };
|
|
96
96
|
}
|
|
97
97
|
if (resp.ok) {
|
|
98
98
|
const body = await resp.json().catch(() => ({}));
|
|
@@ -111,6 +111,12 @@ async function getRuntimeBundle(input) {
|
|
|
111
111
|
disabledScanRoots: Array.isArray(body.data.disabledScanRoots)
|
|
112
112
|
? body.data.disabledScanRoots.filter((root) => typeof root === 'string').slice(0, 20)
|
|
113
113
|
: undefined,
|
|
114
|
+
autoUpdate: body.data.autoUpdate && typeof body.data.autoUpdate === 'object'
|
|
115
|
+
? {
|
|
116
|
+
enabled: body.data.autoUpdate.enabled === true,
|
|
117
|
+
targetVersion: typeof body.data.autoUpdate.targetVersion === 'string' ? body.data.autoUpdate.targetVersion : undefined,
|
|
118
|
+
}
|
|
119
|
+
: undefined,
|
|
114
120
|
machineAction: body.data.machineAction,
|
|
115
121
|
fetchedAt: Date.now(),
|
|
116
122
|
};
|
|
@@ -122,7 +128,7 @@ async function getRuntimeBundle(input) {
|
|
|
122
128
|
}
|
|
123
129
|
catch { /* fall through to cache / default */ }
|
|
124
130
|
if (cached) {
|
|
125
|
-
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, machineAction: cached.machineAction, source: 'cache' };
|
|
131
|
+
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, machineAction: cached.machineAction, source: 'cache' };
|
|
126
132
|
}
|
|
127
133
|
return { mode: 'block', version: '', source: 'default' };
|
|
128
134
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silent CLI self-update.
|
|
3
|
+
*
|
|
4
|
+
* Two install kinds, two upgrade paths:
|
|
5
|
+
* - npm — `npm install -g fullcourtdefense-cli@<version>` replaces the
|
|
6
|
+
* global files in place; a freshly spawned daemon then supersedes
|
|
7
|
+
* the running one via the pid-lock version takeover.
|
|
8
|
+
* - msi — the CLI runs from Program Files with a bundled node.exe; a
|
|
9
|
+
* normal-user process cannot rewrite those files, so upgrades go
|
|
10
|
+
* through the elevated "FullCourtDefense Updater" scheduled task
|
|
11
|
+
* registered at MSI install time (downloads + verifies + msiexec).
|
|
12
|
+
*
|
|
13
|
+
* The org-level auto-update policy (on/off/pinned) arrives on the runtime
|
|
14
|
+
* bundle; the daemon calls maybeSelfUpdate() on its poll ticks.
|
|
15
|
+
*/
|
|
16
|
+
export declare const MSI_UPDATER_TASK_NAME = "FullCourtDefense Updater";
|
|
17
|
+
export type InstallKind = 'msi' | 'npm';
|
|
18
|
+
/** MSI installs run the bundled runtime\node.exe from the install folder. */
|
|
19
|
+
export declare function detectInstallKind(): InstallKind;
|
|
20
|
+
/** Semver compare; returns >0 when a is newer than b. Unparseable = oldest. */
|
|
21
|
+
export declare function compareCliVersions(a?: string, b?: string): number;
|
|
22
|
+
export interface SelfUpdateResult {
|
|
23
|
+
started: boolean;
|
|
24
|
+
kind: InstallKind;
|
|
25
|
+
detail: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Upgrade this machine to targetVersion if it is newer than currentVersion.
|
|
29
|
+
* Cheap no-op when already current, when a kick is still pending, or when the
|
|
30
|
+
* platform path is unavailable. Never throws.
|
|
31
|
+
*/
|
|
32
|
+
export declare function maybeSelfUpdate(input: {
|
|
33
|
+
currentVersion?: string;
|
|
34
|
+
targetVersion?: string;
|
|
35
|
+
enabled?: boolean;
|
|
36
|
+
log?: (message: string) => void;
|
|
37
|
+
/** Skip while a remote machine action is executing (never upgrade mid-action). */
|
|
38
|
+
busy?: boolean;
|
|
39
|
+
}): SelfUpdateResult | undefined;
|
|
40
|
+
/** Version the MSI updater script reads from the installed package.json. */
|
|
41
|
+
export declare function installedMsiVersion(installFolder: string): string | undefined;
|
|
@@ -0,0 +1,180 @@
|
|
|
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.MSI_UPDATER_TASK_NAME = void 0;
|
|
37
|
+
exports.detectInstallKind = detectInstallKind;
|
|
38
|
+
exports.compareCliVersions = compareCliVersions;
|
|
39
|
+
exports.maybeSelfUpdate = maybeSelfUpdate;
|
|
40
|
+
exports.installedMsiVersion = installedMsiVersion;
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const child_process_1 = require("child_process");
|
|
44
|
+
/**
|
|
45
|
+
* Silent CLI self-update.
|
|
46
|
+
*
|
|
47
|
+
* Two install kinds, two upgrade paths:
|
|
48
|
+
* - npm — `npm install -g fullcourtdefense-cli@<version>` replaces the
|
|
49
|
+
* global files in place; a freshly spawned daemon then supersedes
|
|
50
|
+
* the running one via the pid-lock version takeover.
|
|
51
|
+
* - msi — the CLI runs from Program Files with a bundled node.exe; a
|
|
52
|
+
* normal-user process cannot rewrite those files, so upgrades go
|
|
53
|
+
* through the elevated "FullCourtDefense Updater" scheduled task
|
|
54
|
+
* registered at MSI install time (downloads + verifies + msiexec).
|
|
55
|
+
*
|
|
56
|
+
* The org-level auto-update policy (on/off/pinned) arrives on the runtime
|
|
57
|
+
* bundle; the daemon calls maybeSelfUpdate() on its poll ticks.
|
|
58
|
+
*/
|
|
59
|
+
exports.MSI_UPDATER_TASK_NAME = 'FullCourtDefense Updater';
|
|
60
|
+
/** MSI installs run the bundled runtime\node.exe from the install folder. */
|
|
61
|
+
function detectInstallKind() {
|
|
62
|
+
const execPath = process.execPath.replace(/\\/g, '/').toLowerCase();
|
|
63
|
+
if (execPath.includes('/fullcourtdefense/runtime/node'))
|
|
64
|
+
return 'msi';
|
|
65
|
+
const entry = (process.argv[1] || '').replace(/\\/g, '/').toLowerCase();
|
|
66
|
+
if (entry.includes('/fullcourtdefense/dist/'))
|
|
67
|
+
return 'msi';
|
|
68
|
+
return 'npm';
|
|
69
|
+
}
|
|
70
|
+
/** Semver compare; returns >0 when a is newer than b. Unparseable = oldest. */
|
|
71
|
+
function compareCliVersions(a, b) {
|
|
72
|
+
const parse = (value) => String(value || '0').replace(/^v/, '').split('.').map(part => parseInt(part, 10) || 0);
|
|
73
|
+
const [pa, pb] = [parse(a), parse(b)];
|
|
74
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
75
|
+
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
76
|
+
if (diff !== 0)
|
|
77
|
+
return diff;
|
|
78
|
+
}
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
function npmCommand() {
|
|
82
|
+
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Kick off an npm global upgrade in a detached child, then (once npm exits 0)
|
|
86
|
+
* spawn a fresh daemon from the same entry path — the new build reads the
|
|
87
|
+
* updated package.json, wins the version-takeover in acquirePidLock(), and
|
|
88
|
+
* stops the old daemon. The old process never kills itself mid-upgrade, so a
|
|
89
|
+
* failed npm install leaves the machine exactly as it was.
|
|
90
|
+
*/
|
|
91
|
+
function startNpmSelfUpdate(targetVersion, log) {
|
|
92
|
+
const entry = path.resolve(process.argv[1] || '');
|
|
93
|
+
const script = [
|
|
94
|
+
`"${npmCommand()}" install -g fullcourtdefense-cli@${targetVersion}`,
|
|
95
|
+
// Relaunch the daemon from the (now replaced) global entry. The pid-lock
|
|
96
|
+
// takeover in the new daemon stops this one.
|
|
97
|
+
`&& "${process.execPath}" "${entry}" daemon`,
|
|
98
|
+
].join(' ');
|
|
99
|
+
try {
|
|
100
|
+
const child = process.platform === 'win32'
|
|
101
|
+
? (0, child_process_1.spawn)('cmd.exe', ['/d', '/s', '/c', script], { detached: true, stdio: 'ignore', windowsHide: true })
|
|
102
|
+
: (0, child_process_1.spawn)('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' });
|
|
103
|
+
child.unref();
|
|
104
|
+
log(`Self-update: npm upgrade to ${targetVersion} started (detached).`);
|
|
105
|
+
return { started: true, kind: 'npm', detail: `npm install -g fullcourtdefense-cli@${targetVersion} started; the new daemon supersedes this one when ready.` };
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
const message = error.message;
|
|
109
|
+
log(`Self-update: could not start npm upgrade: ${message}`);
|
|
110
|
+
return { started: false, kind: 'npm', detail: `Could not start npm upgrade: ${message}` };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* MSI path: run the elevated updater task registered at install time. Running
|
|
115
|
+
* an existing task does not require elevation for the task's own principal,
|
|
116
|
+
* but group policy can deny it — in that case the daily trigger remains the
|
|
117
|
+
* backstop and we report that honestly.
|
|
118
|
+
*/
|
|
119
|
+
function startMsiSelfUpdate(targetVersion, log) {
|
|
120
|
+
const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', exports.MSI_UPDATER_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
121
|
+
if (query.status !== 0) {
|
|
122
|
+
log('Self-update: MSI updater task is not registered on this machine — reinstall the MSI to enable silent updates.');
|
|
123
|
+
return {
|
|
124
|
+
started: false,
|
|
125
|
+
kind: 'msi',
|
|
126
|
+
detail: `The "${exports.MSI_UPDATER_TASK_NAME}" scheduled task is missing. Reinstall the latest MSI (or redeploy via MDM) to restore silent updates.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', exports.MSI_UPDATER_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
|
|
130
|
+
if (run.status === 0) {
|
|
131
|
+
log(`Self-update: MSI updater task triggered (target ${targetVersion}).`);
|
|
132
|
+
return { started: true, kind: 'msi', detail: `Updater task triggered; it downloads, verifies, and installs ${targetVersion} silently.` };
|
|
133
|
+
}
|
|
134
|
+
log('Self-update: could not trigger the MSI updater task now — its daily schedule remains the backstop.');
|
|
135
|
+
return {
|
|
136
|
+
started: false,
|
|
137
|
+
kind: 'msi',
|
|
138
|
+
detail: `Could not trigger the "${exports.MSI_UPDATER_TASK_NAME}" task immediately; it still runs on its daily schedule.`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
let updateInFlightSince = 0;
|
|
142
|
+
const UPDATE_RETRY_COOLDOWN_MS = 60 * 60_000; // don't re-kick a pending upgrade for an hour
|
|
143
|
+
/**
|
|
144
|
+
* Upgrade this machine to targetVersion if it is newer than currentVersion.
|
|
145
|
+
* Cheap no-op when already current, when a kick is still pending, or when the
|
|
146
|
+
* platform path is unavailable. Never throws.
|
|
147
|
+
*/
|
|
148
|
+
function maybeSelfUpdate(input) {
|
|
149
|
+
const log = input.log || (() => { });
|
|
150
|
+
if (!input.enabled || !input.targetVersion)
|
|
151
|
+
return undefined;
|
|
152
|
+
if (input.busy)
|
|
153
|
+
return undefined;
|
|
154
|
+
if (compareCliVersions(input.targetVersion, input.currentVersion) <= 0)
|
|
155
|
+
return undefined;
|
|
156
|
+
if (Date.now() - updateInFlightSince < UPDATE_RETRY_COOLDOWN_MS)
|
|
157
|
+
return undefined;
|
|
158
|
+
updateInFlightSince = Date.now();
|
|
159
|
+
log(`Self-update: CLI ${input.currentVersion || 'unknown'} -> ${input.targetVersion} (org auto-update).`);
|
|
160
|
+
const kind = detectInstallKind();
|
|
161
|
+
const result = kind === 'msi'
|
|
162
|
+
? startMsiSelfUpdate(input.targetVersion, log)
|
|
163
|
+
: startNpmSelfUpdate(input.targetVersion, log);
|
|
164
|
+
if (!result.started) {
|
|
165
|
+
// Allow another attempt on the next tick after a shorter cooldown when we
|
|
166
|
+
// never managed to start anything.
|
|
167
|
+
updateInFlightSince = Date.now() - UPDATE_RETRY_COOLDOWN_MS + 10 * 60_000;
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
/** Version the MSI updater script reads from the installed package.json. */
|
|
172
|
+
function installedMsiVersion(installFolder) {
|
|
173
|
+
try {
|
|
174
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(installFolder, 'package.json'), 'utf8'));
|
|
175
|
+
return typeof pkg.version === 'string' ? pkg.version : undefined;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
package/dist/telemetry.js
CHANGED
|
@@ -58,6 +58,15 @@ const SPOOL_PATH = path.join(os.homedir(), '.fullcourtdefense-spool.jsonl');
|
|
|
58
58
|
const FLUSH_MARKER = path.join(os.homedir(), '.fullcourtdefense-flush.ts');
|
|
59
59
|
const FLUSH_THROTTLE_MS = 30_000; // don't spawn a flusher more than this often
|
|
60
60
|
const MAX_BATCH = 200;
|
|
61
|
+
/**
|
|
62
|
+
* Hard cap on spooled events. A machine that stays offline for weeks must not
|
|
63
|
+
* grow an unbounded file on the developer's disk; when full, the OLDEST events
|
|
64
|
+
* are dropped — the newest decisions are the ones an admin needs after a
|
|
65
|
+
* reconnect. ~2k events ≈ single-digit MB worst case.
|
|
66
|
+
*/
|
|
67
|
+
const MAX_SPOOL_EVENTS = 2_000;
|
|
68
|
+
/** Cheap pre-check: skip the trim parse entirely until the file is plausibly over cap. */
|
|
69
|
+
const SPOOL_TRIM_BYTES = 2 * 1024 * 1024;
|
|
61
70
|
/** Append one decision to the spool. Never throws (telemetry must not break the hook). */
|
|
62
71
|
function spoolEvent(event) {
|
|
63
72
|
try {
|
|
@@ -81,6 +90,19 @@ function spoolEvent(event) {
|
|
|
81
90
|
offlineEnforced: event.offlineEnforced,
|
|
82
91
|
};
|
|
83
92
|
fs.appendFileSync(SPOOL_PATH, JSON.stringify(full) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
93
|
+
enforceSpoolCap();
|
|
94
|
+
}
|
|
95
|
+
catch { /* best-effort */ }
|
|
96
|
+
}
|
|
97
|
+
/** Drop-oldest cap so a long-offline machine never grows an unbounded spool. */
|
|
98
|
+
function enforceSpoolCap() {
|
|
99
|
+
try {
|
|
100
|
+
if (fs.statSync(SPOOL_PATH).size < SPOOL_TRIM_BYTES)
|
|
101
|
+
return;
|
|
102
|
+
const events = readSpool();
|
|
103
|
+
if (events.length <= MAX_SPOOL_EVENTS)
|
|
104
|
+
return;
|
|
105
|
+
rewriteSpool(events.slice(events.length - MAX_SPOOL_EVENTS));
|
|
84
106
|
}
|
|
85
107
|
catch { /* best-effort */ }
|
|
86
108
|
}
|
package/dist/version.json
CHANGED