fullcourtdefense-cli 1.25.1 → 1.25.3

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.
@@ -38,14 +38,27 @@ export type TaskLogonType = 'S4U' | 'InteractiveToken';
38
38
  export declare function taskUserId(): string;
39
39
  /** Local wall-clock timestamp (no ms, no tz) as Task Scheduler expects. */
40
40
  export declare function taskLocalTimestamp(date: Date): string;
41
+ /** Full path to conhost.exe — the signed Windows binary whose `--headless`
42
+ * flag hosts a console child on a hidden pseudoconsole (no window, ever). */
43
+ export declare function conhostPath(): string;
41
44
  /**
42
45
  * Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
43
46
  *
44
47
  * @param command CLI subcommand + args ('daemon' | 'watchdog' | 'discover …').
45
48
  * @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
46
49
  * time trigger for the 5-minute watchdog.
47
- * @param logonType S4U (windowless, needs elevation to register) or
48
- * InteractiveToken (registers unelevated, may flash).
50
+ * @param logonType S4U (windowless by session, needs elevation to register) or
51
+ * InteractiveToken (registers unelevated the action is
52
+ * wrapped in `conhost --headless` so it is ALSO windowless).
53
+ *
54
+ * Why the conhost wrapper: an InteractiveToken task runs console apps on the
55
+ * interactive desktop — a visible window on EVERY trigger (the "console flash
56
+ * every 5 minutes" complaint). Current Windows builds deny S4U registration to
57
+ * everyone except the task's own user running elevated (even SYSTEM gets
58
+ * Access denied — verified empirically; the updater's SYSTEM repair pass can
59
+ * no longer fix it). `conhost.exe --headless` sidesteps the whole fight: the
60
+ * child gets a hidden pseudoconsole, no window ever exists, registration
61
+ * stays unelevated, and no script host is involved (Defender-safe).
49
62
  */
50
63
  export declare function buildTaskXml(command: string, triggers: string, logonType?: TaskLogonType): string;
51
64
  /**
@@ -74,6 +87,11 @@ export declare function windowsTaskReferencesScriptHost(taskName: string): boole
74
87
  * the 1.22.1 window-flash bug (console window on every trigger). Migration
75
88
  * trigger for the windowless S4U principal. */
76
89
  export declare function windowsTaskRunsInteractive(taskName: string): boolean;
90
+ /** True when triggering the task would open a VISIBLE console window:
91
+ * InteractiveToken principal AND a bare console action (not wrapped in
92
+ * `conhost --headless`). This — not the principal alone — is the flash
93
+ * condition; a conhost-wrapped InteractiveToken task is fully windowless. */
94
+ export declare function windowsTaskRunsVisibly(taskName: string): boolean;
77
95
  /** Snapshot of the resident daemon for out-of-process callers (watchdog/status). */
78
96
  export interface DaemonRuntimeState {
79
97
  alive: boolean;
@@ -39,11 +39,13 @@ exports.discoverSweepCredentialEnv = discoverSweepCredentialEnv;
39
39
  exports.cliVersion = cliVersion;
40
40
  exports.taskUserId = taskUserId;
41
41
  exports.taskLocalTimestamp = taskLocalTimestamp;
42
+ exports.conhostPath = conhostPath;
42
43
  exports.buildTaskXml = buildTaskXml;
43
44
  exports.buildTaskXmlVariants = buildTaskXmlVariants;
44
45
  exports.registerHiddenTask = registerHiddenTask;
45
46
  exports.windowsTaskReferencesScriptHost = windowsTaskReferencesScriptHost;
46
47
  exports.windowsTaskRunsInteractive = windowsTaskRunsInteractive;
48
+ exports.windowsTaskRunsVisibly = windowsTaskRunsVisibly;
47
49
  exports.daemonRuntimeState = daemonRuntimeState;
48
50
  exports.spawnDetachedDaemon = spawnDetachedDaemon;
49
51
  exports.pidIsAlive = pidIsAlive;
@@ -1261,23 +1263,41 @@ function taskLocalTimestamp(date) {
1261
1263
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
1262
1264
  `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
1263
1265
  }
1266
+ /** Full path to conhost.exe — the signed Windows binary whose `--headless`
1267
+ * flag hosts a console child on a hidden pseudoconsole (no window, ever). */
1268
+ function conhostPath() {
1269
+ return path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'conhost.exe');
1270
+ }
1264
1271
  /**
1265
1272
  * Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
1266
1273
  *
1267
1274
  * @param command CLI subcommand + args ('daemon' | 'watchdog' | 'discover …').
1268
1275
  * @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
1269
1276
  * time trigger for the 5-minute watchdog.
1270
- * @param logonType S4U (windowless, needs elevation to register) or
1271
- * InteractiveToken (registers unelevated, may flash).
1277
+ * @param logonType S4U (windowless by session, needs elevation to register) or
1278
+ * InteractiveToken (registers unelevated the action is
1279
+ * wrapped in `conhost --headless` so it is ALSO windowless).
1280
+ *
1281
+ * Why the conhost wrapper: an InteractiveToken task runs console apps on the
1282
+ * interactive desktop — a visible window on EVERY trigger (the "console flash
1283
+ * every 5 minutes" complaint). Current Windows builds deny S4U registration to
1284
+ * everyone except the task's own user running elevated (even SYSTEM gets
1285
+ * Access denied — verified empirically; the updater's SYSTEM repair pass can
1286
+ * no longer fix it). `conhost.exe --headless` sidesteps the whole fight: the
1287
+ * child gets a hidden pseudoconsole, no window ever exists, registration
1288
+ * stays unelevated, and no script host is involved (Defender-safe).
1272
1289
  */
1273
1290
  function buildTaskXml(command, triggers, logonType = 'S4U') {
1274
1291
  const userId = xmlEscape(taskUserId());
1275
- const nodeExe = xmlEscape(process.execPath);
1276
- const args = xmlEscape(`"${cliEntry()}" ${command}`);
1292
+ const headless = logonType === 'InteractiveToken';
1293
+ const execCommand = headless ? xmlEscape(conhostPath()) : xmlEscape(process.execPath);
1294
+ const args = headless
1295
+ ? xmlEscape(`--headless "${process.execPath}" "${cliEntry()}" ${command}`)
1296
+ : xmlEscape(`"${cliEntry()}" ${command}`);
1277
1297
  return `<?xml version="1.0" encoding="UTF-16"?>
1278
1298
  <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
1279
1299
  <RegistrationInfo>
1280
- <Description>FullCourtDefense ${xmlEscape(command)} — runs node directly, windowless, no script host.</Description>
1300
+ <Description>FullCourtDefense ${xmlEscape(command)} — runs node ${headless ? 'under conhost --headless' : 'directly'}, windowless, no script host.</Description>
1281
1301
  </RegistrationInfo>
1282
1302
  <Triggers>
1283
1303
  ${triggers}
@@ -1307,7 +1327,7 @@ ${triggers}
1307
1327
  </Settings>
1308
1328
  <Actions Context="Author">
1309
1329
  <Exec>
1310
- <Command>${nodeExe}</Command>
1330
+ <Command>${execCommand}</Command>
1311
1331
  <Arguments>${args}</Arguments>
1312
1332
  </Exec>
1313
1333
  </Actions>
@@ -1383,10 +1403,10 @@ function registerHiddenTask(taskName, xml, command, scheduleArgs) {
1383
1403
  }
1384
1404
  catch { /* try next variant, then /TR */ }
1385
1405
  }
1386
- // Fallback: node directly via /TR (no wscript, no VBS). node/CLI may live
1387
- // under a path with spaces (Program Files), so quote each and let schtasks
1388
- // pass the whole /TR string through.
1389
- const tr = `"${process.execPath}" "${cliEntry()}" ${command}`;
1406
+ // Fallback: node via /TR under conhost --headless (no wscript, no VBS, no
1407
+ // window). node/CLI may live under a path with spaces (Program Files), so
1408
+ // quote each and let schtasks pass the whole /TR string through.
1409
+ const tr = `${conhostPath()} --headless "${process.execPath}" "${cliEntry()}" ${command}`;
1390
1410
  const fallback = (0, child_process_1.spawnSync)('schtasks', [
1391
1411
  '/Create', '/TN', taskName, '/TR', tr, ...scheduleArgs, '/F',
1392
1412
  ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
@@ -1421,16 +1441,30 @@ function windowsTaskReferencesScriptHost(taskName) {
1421
1441
  * the 1.22.1 window-flash bug (console window on every trigger). Migration
1422
1442
  * trigger for the windowless S4U principal. */
1423
1443
  function windowsTaskRunsInteractive(taskName) {
1444
+ return windowsTaskXmlState(taskName).interactive;
1445
+ }
1446
+ /** True when triggering the task would open a VISIBLE console window:
1447
+ * InteractiveToken principal AND a bare console action (not wrapped in
1448
+ * `conhost --headless`). This — not the principal alone — is the flash
1449
+ * condition; a conhost-wrapped InteractiveToken task is fully windowless. */
1450
+ function windowsTaskRunsVisibly(taskName) {
1451
+ const state = windowsTaskXmlState(taskName);
1452
+ return state.interactive && !state.headless;
1453
+ }
1454
+ function windowsTaskXmlState(taskName) {
1424
1455
  try {
1425
1456
  const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', taskName, '/XML'], {
1426
1457
  encoding: 'utf8', windowsHide: true, timeout: 10_000,
1427
1458
  });
1428
1459
  if (query.status !== 0 || typeof query.stdout !== 'string')
1429
- return false;
1430
- return /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(query.stdout);
1460
+ return { interactive: false, headless: false };
1461
+ return {
1462
+ interactive: /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(query.stdout),
1463
+ headless: /conhost(\.exe)?<\/Command>[\s\S]*?--headless/i.test(query.stdout),
1464
+ };
1431
1465
  }
1432
1466
  catch {
1433
- return false;
1467
+ return { interactive: false, headless: false };
1434
1468
  }
1435
1469
  }
1436
1470
  /**
@@ -1456,7 +1490,38 @@ function upgradeTaskWindowless(taskName, s4uXml, logFn) {
1456
1490
  logFn(`Self-heal: "${taskName}" migrated to the windowless S4U principal (no more console-window flash).`);
1457
1491
  return true;
1458
1492
  }
1459
- logFn(`Self-heal: could not migrate "${taskName}" to S4U (needs elevation) — asking the elevated updater task to do it.`);
1493
+ logFn(`Self-heal: could not migrate "${taskName}" to S4U (needs same-user elevation) — re-registering it headless instead.`);
1494
+ return false;
1495
+ }
1496
+ catch {
1497
+ return false; /* keep the existing working task */
1498
+ }
1499
+ }
1500
+ /**
1501
+ * Re-register an InteractiveToken task with its action wrapped in
1502
+ * `conhost --headless` — windowless without touching the principal, so the
1503
+ * SAME user can do it unelevated (unlike S4U, which current Windows only
1504
+ * grants to the task's own user running elevated — even SYSTEM is denied).
1505
+ * Returns true when the task no longer opens a window on trigger.
1506
+ */
1507
+ function rewrapTaskHeadless(taskName, interactiveHeadlessXml, logFn) {
1508
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
1509
+ const dir = path.join(base, 'FullCourtDefense');
1510
+ try {
1511
+ fs.mkdirSync(dir, { recursive: true });
1512
+ }
1513
+ catch { /* ignore */ }
1514
+ const xmlPath = path.join(dir, `${taskName.replace(/[^A-Za-z0-9]+/g, '_')}.task.xml`);
1515
+ try {
1516
+ fs.writeFileSync(xmlPath, `\ufeff${interactiveHeadlessXml}`, 'utf16le');
1517
+ const created = (0, child_process_1.spawnSync)('schtasks', ['/Create', '/TN', taskName, '/XML', xmlPath, '/F'], {
1518
+ stdio: 'ignore', windowsHide: true, timeout: 20_000,
1519
+ });
1520
+ if (created.status === 0) {
1521
+ logFn(`Self-heal: "${taskName}" re-registered with a conhost --headless action (windowless from its next trigger).`);
1522
+ return true;
1523
+ }
1524
+ logFn(`Self-heal: could not re-register "${taskName}" headless — asking the elevated updater task to migrate it.`);
1460
1525
  return false;
1461
1526
  }
1462
1527
  catch {
@@ -1478,14 +1543,20 @@ let updaterMigrationKicked = false;
1478
1543
  function kickUpdaterTaskForMigration(logFn) {
1479
1544
  if (updaterMigrationKicked)
1480
1545
  return;
1481
- updaterMigrationKicked = true;
1482
1546
  try {
1483
1547
  const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', selfUpdate_1.MSI_UPDATER_TASK_NAME], {
1484
1548
  stdio: 'ignore', windowsHide: true, timeout: 20_000,
1485
1549
  });
1486
- logFn(run.status === 0
1487
- ? `Self-heal: elevated updater task triggeredit migrates the tasks to S4U now (windowless within a minute).`
1488
- : `Self-heal: updater task unavailable for the S4U migration its next daily run (03:07) will migrate instead.`);
1550
+ // Latch ONLY on a successful kick. On a fresh MSI install the daemon can
1551
+ // boot before the SYSTEM updater task existsburning the one-shot latch
1552
+ // on that failure left the machine flashing until the 03:07 daily run.
1553
+ if (run.status === 0) {
1554
+ updaterMigrationKicked = true;
1555
+ logFn(`Self-heal: elevated updater task triggered — its repair pass migrates the tasks now.`);
1556
+ }
1557
+ else {
1558
+ logFn(`Self-heal: updater task unavailable for the task migration — will retry on a later self-heal; the daily run (03:07) is the backstop.`);
1559
+ }
1489
1560
  }
1490
1561
  catch { /* best-effort — the daily schedule remains the backstop */ }
1491
1562
  }
@@ -1528,22 +1599,33 @@ function startDaemonNowWindows(viaTask = false) {
1528
1599
  }
1529
1600
  }
1530
1601
  catch { /* not running */ }
1531
- if (viaTask) {
1602
+ if (viaTask && !windowsTaskRunsVisibly(TASK_NAME)) {
1532
1603
  // Start through the scheduled task so the daemon runs with the task's
1533
1604
  // LIMITED (non-elevated) token. Launching directly from an elevated MSI
1534
1605
  // custom action would leave an elevated daemon that a normal-user CLI can
1535
1606
  // never stop or supersede.
1607
+ //
1608
+ // ONLY when the task cannot open a window (S4U principal, or an
1609
+ // InteractiveToken action wrapped in conhost --headless): starting a
1610
+ // bare-console InteractiveToken task runs node.exe on the interactive
1611
+ // desktop, and since the daemon never exits, that console window stays
1612
+ // OPEN on the user's screen — the "open console at the end of
1613
+ // installation" customer complaint. A visibly-registered task takes the
1614
+ // hidden WMI path below instead; self-heal re-registers it headless on
1615
+ // the next daemon boot.
1536
1616
  const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
1537
1617
  if (run.status === 0)
1538
1618
  return;
1539
1619
  }
1540
1620
  // WMI process creation escapes the MSI job object. Runs node.exe DIRECTLY —
1541
- // no wscript/VBS (Defender's Commando.A!ml heuristic) — with a hidden window.
1621
+ // no wscript/VBS (Defender's Commando.A!ml heuristic). Win32_ProcessStartup
1622
+ // ShowWindow=0 (SW_HIDE) is REQUIRED: without it Win32_Process.Create gives
1623
+ // the console app a visible window on the interactive desktop.
1542
1624
  const nodeEsc = process.execPath.replace(/'/g, "''");
1543
1625
  const entryEsc = cliEntry().replace(/'/g, "''");
1544
1626
  (0, child_process_1.spawnSync)('powershell', [
1545
1627
  '-NoProfile', '-NonInteractive', '-Command',
1546
- `Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('\"' + '${nodeEsc}' + '\" \"' + '${entryEsc}' + '\" daemon') } | Out-Null`,
1628
+ `$si = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 }; Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('\"' + '${nodeEsc}' + '\" \"' + '${entryEsc}' + '\" daemon'); ProcessStartupInformation = $si } | Out-Null`,
1547
1629
  ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
1548
1630
  }
1549
1631
  function installWindows() {
@@ -1555,11 +1637,11 @@ function installWindows() {
1555
1637
  let ok = taskOk;
1556
1638
  if (!ok) {
1557
1639
  // Fallback: per-user Run key — no elevation needed, runs at every logon.
1558
- // node.exe directly (no wscript). May flash briefly at logon in this rare
1559
- // fallback, but carries no script-host persistence fingerprint.
1640
+ // conhost --headless keeps the logon launch windowless (no wscript, no
1641
+ // script-host persistence fingerprint).
1560
1642
  const reg = (0, child_process_1.spawnSync)('reg', [
1561
1643
  'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
1562
- '/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
1644
+ '/d', `"${conhostPath()}" --headless "${process.execPath}" "${cliEntry()}" daemon`, '/f',
1563
1645
  ], { stdio: 'ignore', windowsHide: true });
1564
1646
  ok = reg.status === 0;
1565
1647
  }
@@ -1604,7 +1686,7 @@ function ensureWindowsAutostartHealthy(logFn) {
1604
1686
  if (!created) {
1605
1687
  (0, child_process_1.spawnSync)('reg', [
1606
1688
  'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
1607
- '/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
1689
+ '/d', `"${conhostPath()}" --headless "${process.execPath}" "${cliEntry()}" daemon`, '/f',
1608
1690
  ], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
1609
1691
  }
1610
1692
  if (daemonTask.status !== 0)
@@ -1618,17 +1700,24 @@ function ensureWindowsAutostartHealthy(logFn) {
1618
1700
  logFn('Self-heal: could not (re)create the watchdog task — daemon revival relies on logon autostart only.');
1619
1701
  }
1620
1702
  }
1621
- // Migration (1.22.1 -> 1.22.2): InteractiveToken tasks flash a console
1622
- // window on every trigger. Upgrade healthy existing tasks to S4U; S4U-only
1623
- // attempt so a denied re-registration never breaks a working task. When
1624
- // unelevated self-heal cannot flip them, kick the SYSTEM updater task
1625
- // once its repair pass migrates with real elevation (1.22.7).
1703
+ // Migration: an InteractiveToken task with a bare console action flashes a
1704
+ // window on every trigger. Try the S4U principal first (works when this
1705
+ // daemon happens to be elevated current Windows denies S4U registration
1706
+ // to everyone else, INCLUDING SYSTEM, so the updater usually cannot help).
1707
+ // When S4U is denied, re-register the task as InteractiveToken with the
1708
+ // action wrapped in `conhost --headless` — same-user unelevated
1709
+ // registration always works and is just as windowless. The updater kick
1710
+ // stays as the final backstop for machines where even that failed.
1626
1711
  let migrationDenied = false;
1627
- if (daemonTask.status === 0 && !legacyDaemon && windowsTaskRunsInteractive(TASK_NAME)) {
1628
- migrationDenied = !upgradeTaskWindowless(TASK_NAME, buildDaemonTaskXml()[0], logFn) || migrationDenied;
1712
+ if (daemonTask.status === 0 && !legacyDaemon && windowsTaskRunsVisibly(TASK_NAME)) {
1713
+ const daemonXml = buildDaemonTaskXml();
1714
+ migrationDenied = !(upgradeTaskWindowless(TASK_NAME, daemonXml[0], logFn)
1715
+ || rewrapTaskHeadless(TASK_NAME, daemonXml[1], logFn)) || migrationDenied;
1629
1716
  }
1630
- if (watchdogQuery.status === 0 && !legacyWatchdog && windowsTaskRunsInteractive(WATCHDOG_TASK_NAME)) {
1631
- migrationDenied = !upgradeTaskWindowless(WATCHDOG_TASK_NAME, buildWatchdogTaskXml()[0], logFn) || migrationDenied;
1717
+ if (watchdogQuery.status === 0 && !legacyWatchdog && windowsTaskRunsVisibly(WATCHDOG_TASK_NAME)) {
1718
+ const watchdogXml = buildWatchdogTaskXml();
1719
+ migrationDenied = !(upgradeTaskWindowless(WATCHDOG_TASK_NAME, watchdogXml[0], logFn)
1720
+ || rewrapTaskHeadless(WATCHDOG_TASK_NAME, watchdogXml[1], logFn)) || migrationDenied;
1632
1721
  }
1633
1722
  if (migrationDenied)
1634
1723
  kickUpdaterTaskForMigration(logFn);
@@ -1636,9 +1725,9 @@ function ensureWindowsAutostartHealthy(logFn) {
1636
1725
  if (windowsRunKeyReferencesScriptHost()) {
1637
1726
  (0, child_process_1.spawnSync)('reg', [
1638
1727
  'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
1639
- '/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
1728
+ '/d', `"${conhostPath()}" --headless "${process.execPath}" "${cliEntry()}" daemon`, '/f',
1640
1729
  ], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
1641
- logFn('Self-heal: rewrote legacy wscript Run-key entry to run node directly.');
1730
+ logFn('Self-heal: rewrote legacy wscript Run-key entry to run node headless.');
1642
1731
  }
1643
1732
  removeLegacyWindowsLaunchers();
1644
1733
  }
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.25.1"
2
+ "version": "1.25.3"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.25.1",
3
+ "version": "1.25.3",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {