node-command 2.0.7 → 2.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/bin/nc.mjs +94 -5
  2. package/lib/setup.mjs +184 -0
  3. package/package.json +1 -1
package/bin/nc.mjs CHANGED
@@ -21,8 +21,12 @@ import {
21
21
  getNcRootForMode,
22
22
  installerEnvForMode,
23
23
  isElevated,
24
+ isPartialSystemRemovalCandidate,
25
+ isPartialUserRemovalCandidate,
24
26
  isUserInstallRoot,
25
27
  probeInstallRoot,
28
+ removePartialSystemRoot,
29
+ removePartialUserRoot,
26
30
  resolveExecutable,
27
31
  setupTarget,
28
32
  unreadableSystemInstallations,
@@ -708,6 +712,37 @@ export function runInstallerScript(scriptPath, ncRoot, extraEnv = {}) {
708
712
  });
709
713
  }
710
714
 
715
+ /**
716
+ * Best-effort evidence when the agent installer reports a Node.js runtime
717
+ * failure. The Linux installer suppresses the portable runtime's loader error
718
+ * and prints only "Node.js 24+ required (portable runtime failed to run)";
719
+ * replay the binary to surface the real reason (e.g. a missing `libatomic`
720
+ * dependency of Node.js 25+ official Linux builds). Diagnostic only: it never
721
+ * changes the installer's runtime requirement or substitutes a downgrade.
722
+ * @param {string} ncRoot
723
+ * @returns {string|null}
724
+ */
725
+ function portableRuntimeFailureDetail(ncRoot) {
726
+ const bin = path.join(ncRoot, 'tools', 'node', 'bin', 'node');
727
+ try {
728
+ const info = fs.lstatSync(bin);
729
+ if (!info.isFile() || info.isSymbolicLink()) return null;
730
+ // Probe only a runtime this account owns: never execute a planted binary
731
+ // from an arbitrary/attacker-writable install tree.
732
+ if (typeof process.getuid === 'function' && info.uid !== process.getuid()) return null;
733
+ } catch {
734
+ return null;
735
+ }
736
+ const probe = child_process.spawnSync(bin, ['-p', 'process.versions.node'], {
737
+ encoding: 'utf8',
738
+ timeout: 5000,
739
+ env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' }
740
+ });
741
+ const detail = String(probe.stderr || '').trim() || (probe.error?.message || '');
742
+ if (!detail) return null;
743
+ return sanitizeForDisplay(detail.split(/\r?\n/).filter(Boolean).slice(-3).join(' '), 300);
744
+ }
745
+
711
746
  /**
712
747
  * Zero-touch device pairing library.
713
748
  *
@@ -1740,7 +1775,11 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1740
1775
  invokeInstaller(scriptPath, root);
1741
1776
  } catch (err) {
1742
1777
  if (err instanceof PairingError) throw err;
1743
- throw new PairingError('INSTALL_FAILED', `Agent installer failed: ${sanitizeForDisplay(err.message, 300)}. Re-running resumes from the stored host credentials.`);
1778
+ const runtimeDetail = installMode === 'user' ? portableRuntimeFailureDetail(root) : null;
1779
+ const hint = runtimeDetail
1780
+ ? ` The bundled Node.js runtime at ${path.join(root, 'tools', 'node', 'bin', 'node')} also fails to run here: ${runtimeDetail}. Install the distribution runtime dependency (Node.js 25+ official Linux builds require libatomic) or a working Node.js 24+ system runtime, then re-run.`
1781
+ : '';
1782
+ throw new PairingError('INSTALL_FAILED', `Agent installer failed: ${sanitizeForDisplay(err.message, 300)}.${hint} Re-running resumes from the stored host credentials.`);
1744
1783
  }
1745
1784
  } finally {
1746
1785
  console.log('🧹 Cleaning up temporary directory...');
@@ -2440,7 +2479,12 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
2440
2479
  // existing device identity. The normal verified resume flow is kept.
2441
2480
  const keyPath = getDeviceKeyPath(root);
2442
2481
  if (!protectedContext) {
2443
- const keyInfo = fs.lstatSync(keyPath);
2482
+ let keyInfo;
2483
+ try {
2484
+ keyInfo = fs.lstatSync(keyPath);
2485
+ } catch (err) {
2486
+ throw new Error(`Existing device key at ${root} is missing or cannot be verified; preserve it for selected-installation maintenance.`, { cause: err });
2487
+ }
2444
2488
  if (!keyInfo.isFile() || keyInfo.isSymbolicLink() || keyInfo.size > 65536 ||
2445
2489
  (os.platform() !== 'win32' && installMode === 'user' && keyInfo.uid !== process.getuid())) {
2446
2490
  throw new Error(`Existing device key at ${root} has an unverifiable type/owner; refusing identity replacement.`);
@@ -2452,8 +2496,23 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
2452
2496
  throw new Error(`Existing device key at ${root} cannot be verified; preserve it for selected-installation maintenance.`);
2453
2497
  }
2454
2498
  existingEnv = localEnv;
2455
- const appInfo = fs.lstatSync(path.join(root, 'app'));
2456
- if (!appInfo.isDirectory() || appInfo.isSymbolicLink()) throw new Error(`Existing application at ${root} is incomplete; use selected-installation maintenance.`);
2499
+ // A failed per-user install can leave config + device key while the
2500
+ // application directory was never staged. That is repairable from the
2501
+ // stored identity exactly like a missing launcher; it must not surface as
2502
+ // a raw ENOENT and block every retry.
2503
+ let appInfo = null;
2504
+ try {
2505
+ appInfo = fs.lstatSync(path.join(root, 'app'));
2506
+ } catch (err) {
2507
+ if (err?.code !== 'ENOENT') throw new Error(`Existing application at ${root} cannot be verified; use selected-installation maintenance.`, { cause: err });
2508
+ }
2509
+ if (!appInfo || !appInfo.isDirectory() || appInfo.isSymbolicLink()) {
2510
+ if (repairUserApplication && canRepairUserApplication({ mode: installMode, protectedContext })) {
2511
+ needsUserAppRepair = true;
2512
+ return;
2513
+ }
2514
+ throw new Error(`Existing application at ${root} is incomplete: the app/ directory is missing or unverifiable. Repair or reinstall the application from the existing root (config/nc-client.env and config/nc-device-key.pem are preserved); no new enrollment was attempted.`);
2515
+ }
2457
2516
  const launcherPath = path.join(root, 'app', 'launcher.mjs');
2458
2517
  let launcherInfo;
2459
2518
  try {
@@ -2900,7 +2959,20 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2900
2959
  for (const root of removable) {
2901
2960
  const pair = paired.find(selected => selected.ncRoot === root);
2902
2961
  const script = pair ? LINUX_PAIRED_UNINSTALL_ENTRY : uninstallerForRoot(root);
2903
- if (!pair && !fs.existsSync(script)) {
2962
+ // A failed per-user install can leave its own tree (config plus partial
2963
+ // payload) without the packaged uninstaller. Only the canonical user root
2964
+ // is eligible, and the bounded removal revalidates ancestry, ownership and
2965
+ // every top-level entry. Real system installs never use this fallback;
2966
+ // the exact /opt/nc partial tree has its own narrower root-only exception.
2967
+ const scriptExists = pair || fs.existsSync(script);
2968
+ const explicitRemoval = command === 'uninstall' || command === 'remove';
2969
+ const partialUserTree = !scriptExists && explicitRemoval && isPartialUserRemovalCandidate(root);
2970
+ // The narrow system exception is /opt/nc only, requires real+effective
2971
+ // root, and refuses app/, uninstall.sh, units, libexec/ and lifecycle
2972
+ // metadata inside removePartialSystemRoot.
2973
+ const partialSystemTree = !scriptExists && explicitRemoval && isPartialSystemRemovalCandidate(root);
2974
+ const boundedTree = partialUserTree || partialSystemTree;
2975
+ if (!scriptExists && !boundedTree) {
2904
2976
  console.error(`error: [UNINSTALLER_MISSING] uninstaller not found at ${script}; skipping ${root}.`);
2905
2977
  console.error(uninstallFailureSuggestion());
2906
2978
  failed += 1;
@@ -2915,6 +2987,23 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2915
2987
  continue;
2916
2988
  }
2917
2989
  try {
2990
+ if (boundedTree) {
2991
+ try {
2992
+ if (partialSystemTree) removePartialSystemRoot(root);
2993
+ else removePartialUserRoot(root);
2994
+ console.log(partialSystemTree
2995
+ ? `Removed partial system Node Command installation at ${root} (no packaged uninstaller, unit or application payload).`
2996
+ : `Removed partial per-user Node Command installation at ${root} (no packaged uninstaller was present).`);
2997
+ } catch (err) {
2998
+ const code = typeof err?.code === 'string' && /_REFUSED$/.test(err.code) ? err.code : 'PARTIAL_ROOT_REMOVAL_REFUSED';
2999
+ console.error(`error: [${code}] ${sanitizeForDisplay(err instanceof Error ? err.message : String(err), 300)}`);
3000
+ console.error(partialSystemTree
3001
+ ? 'suggested_action: The system tree was left untouched. Inspect /opt/nc and complete or repair the failed system installation through explicit root maintenance; credentials/config were preserved.'
3002
+ : 'suggested_action: The tree was left untouched. Repair it with `nodecommand --user`, or remove it yourself after confirming it is your own Node Command directory.');
3003
+ failed += 1;
3004
+ }
3005
+ continue;
3006
+ }
2918
3007
  runUninstallerScript(script);
2919
3008
  console.log(`Removed ${root}${pair && pair.helperRoot !== root ? ` and paired protected helper ${pair.helperRoot}` : ''} (exit 0).`);
2920
3009
  } catch (err) {
package/lib/setup.mjs CHANGED
@@ -429,6 +429,190 @@ export function probeRemovalRoot(root, io = {}) {
429
429
  }
430
430
  }
431
431
 
432
+ /** Top-level entries a per-user Node Command tree may contain. A removal never
433
+ * touches anything else, so a user-owned foreign directory cannot be wiped by
434
+ * a misdetected install. */
435
+ const USER_INSTALL_ENTRIES = new Set([
436
+ 'app',
437
+ 'backups',
438
+ 'bin',
439
+ 'config',
440
+ 'data',
441
+ 'install-darwin.sh',
442
+ 'install-linux.sh',
443
+ 'install-macos.sh',
444
+ 'lib',
445
+ 'libexec',
446
+ 'logs',
447
+ 'releases',
448
+ 'tmp',
449
+ 'tools',
450
+ 'uninstall.sh'
451
+ ]);
452
+
453
+ /** Canonical system root eligible only for a narrow, root-only cleanup. */
454
+ export const PARTIAL_SYSTEM_ROOT = '/opt/nc';
455
+
456
+ /** A failed system install may leave only these payload/config directories. */
457
+ const PARTIAL_SYSTEM_ENTRIES = new Set(['config', 'tools', 'logs', 'tmp', 'releases', 'backups']);
458
+
459
+ /** Artifacts that prove a real system installation exists; never bypassed. */
460
+ const PARTIAL_SYSTEM_FORBIDDEN_PATHS = [
461
+ '/etc/systemd/system/nc-client.service',
462
+ '/etc/systemd/system/nc-client.service.d',
463
+ '/etc/systemd/user/nc-client.service',
464
+ '/etc/systemd/user/nc-client.service.d',
465
+ '/etc/systemd/system/multi-user.target.wants/nc-client.service',
466
+ '/etc/systemd/system/nc-privileged-helper.service',
467
+ '/etc/systemd/system/nc-privileged-helper.socket',
468
+ '/etc/tmpfiles.d/nc-privileged-helper.conf',
469
+ '/var/lib/nc-lifecycle',
470
+ '/var/lib/nodecommand/nc'
471
+ ];
472
+
473
+ /**
474
+ * True when `root` is the canonical per-user tree for the current account and
475
+ * is not a protected system root. This is the cheap path/ownership gate; the
476
+ * tree itself is validated again inside `removePartialUserRoot`.
477
+ * @param {string} root
478
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, uid?: number }} [options]
479
+ */
480
+ export function isPartialUserRemovalCandidate(root, { platform = os.platform(), env = process.env, homedir = os.homedir(), uid = process.getuid?.() } = {}) {
481
+ // `uninstall.sh` is a POSIX entry; Windows keeps its own uninstaller contract.
482
+ if (platform === 'win32') return false;
483
+ if (!Number.isSafeInteger(uid) || uid <= 0) return false;
484
+ const canonical = { ...env };
485
+ delete canonical.NC_ROOT;
486
+ const resolved = path.resolve(root);
487
+ const userRoot = path.resolve(getNcRootForMode('user', { platform, env: canonical, homedir }));
488
+ if (resolved !== userRoot) return false;
489
+ const systemRoots = [
490
+ getNcRootForMode('system', { platform, env: canonical, homedir }),
491
+ ...(platform === 'linux' ? ['/opt/nc', '/var/lib/nodecommand/nc'] : platform === 'darwin' ? ['/opt/nc'] : [])
492
+ ].map((candidate) => path.resolve(candidate));
493
+ return !systemRoots.some(
494
+ (candidate) => resolved === candidate || resolved.startsWith(candidate + path.sep) || candidate.startsWith(resolved + path.sep)
495
+ );
496
+ }
497
+
498
+ /**
499
+ * True only for the exact Linux system root when the process holds real AND
500
+ * effective root. The tree contents and every real-install artifact are
501
+ * checked again inside `removePartialSystemRoot`.
502
+ * @param {string} root
503
+ * @param {{ platform?: string, getuid?: (() => number)|null, geteuid?: (() => number)|null }} [options]
504
+ */
505
+ export function isPartialSystemRemovalCandidate(root, { platform = os.platform(), getuid = process.getuid, geteuid = process.geteuid } = {}) {
506
+ if (platform !== 'linux') return false;
507
+ if (typeof getuid !== 'function' || typeof geteuid !== 'function') return false;
508
+ try {
509
+ if (getuid() !== 0 || geteuid() !== 0) return false;
510
+ } catch {
511
+ return false;
512
+ }
513
+ return path.resolve(root) === PARTIAL_SYSTEM_ROOT;
514
+ }
515
+
516
+ /** Shared bounded removal of an already-authorized root. */
517
+ function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner }) {
518
+ const resolved = path.resolve(root);
519
+ if (io.realpathSync(resolved) !== resolved) throw refused('a symlinked ancestor is not removable');
520
+ const rootInfo = io.lstatSync(resolved);
521
+ if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) throw refused('the root is not a regular directory');
522
+ if (rootInfo.uid !== uid) throw refused(`the root is not owned by ${owner}`);
523
+ const entries = io.readdirSync(resolved);
524
+ for (const name of entries) {
525
+ if (!allowedEntries.has(name)) throw refused(`unexpected top-level entry "${name}"`);
526
+ const info = io.lstatSync(path.join(resolved, name));
527
+ if (info.isSymbolicLink()) throw refused(`top-level entry "${name}" is a symlink`);
528
+ if (info.uid !== uid) throw refused(`top-level entry "${name}" is not owned by ${owner}`);
529
+ }
530
+ for (const [file, directory] of [['config', true], [path.join('config', 'nc-client.env'), false]]) {
531
+ const info = io.lstatSync(path.join(resolved, file));
532
+ if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
533
+ throw refused(`the ${file} marker is missing or not a regular ${directory ? 'directory' : 'file'}`);
534
+ }
535
+ if (info.uid !== uid) throw refused(`the ${file} marker is not owned by ${owner}`);
536
+ }
537
+ io.accessSync(path.join(resolved, 'config', 'nc-client.env'), fs.constants.R_OK);
538
+ for (const name of entries) {
539
+ io.rmSync(path.join(resolved, name), { recursive: true, force: true });
540
+ }
541
+ io.rmdirSync(resolved);
542
+ }
543
+
544
+ /**
545
+ * Bounded removal of a per-user Node Command tree whose packaged `uninstall.sh`
546
+ * is missing (a partial/failed install). Only the canonical per-user root is
547
+ * eligible; system/protected roots always require their packaged uninstaller.
548
+ * Refuses symlinked ancestors or entries, foreign ownership, unexpected
549
+ * top-level entries, and a missing/unreadable `config/nc-client.env` marker.
550
+ * The caller must hold the install lock. Credentials are removed only because
551
+ * this function is reached from an explicit uninstall/remove command.
552
+ * @param {string} root
553
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, uid?: number, io?: typeof fs }} [options]
554
+ */
555
+ export function removePartialUserRoot(root, { platform = os.platform(), env = process.env, homedir = os.homedir(), uid = process.getuid?.(), io = fs } = {}) {
556
+ const refused = (reason) => Object.assign(
557
+ new Error(`Refusing bounded removal of ${root}: ${reason}.`),
558
+ { code: 'USER_TREE_REMOVAL_REFUSED' }
559
+ );
560
+ if (!isPartialUserRemovalCandidate(root, { platform, env, homedir, uid })) {
561
+ throw refused('it is not the canonical per-user Node Command root');
562
+ }
563
+ try {
564
+ removeBoundedRoot(root, {
565
+ uid,
566
+ allowedEntries: USER_INSTALL_ENTRIES,
567
+ io,
568
+ refused,
569
+ owner: 'the current user'
570
+ });
571
+ } catch (err) {
572
+ if (err?.code === 'USER_TREE_REMOVAL_REFUSED') throw err;
573
+ throw refused(err instanceof Error ? err.message : 'the tree could not be verified');
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Root-only bounded removal of a failed SYSTEM install at /opt/nc. This is not
579
+ * a general system-root fallback: it requires real+effective root, the exact
580
+ * canonical root, no unit/helper/lifecycle artifact, and only the known partial
581
+ * payload directories. A real or unverifiable installation stays fail-closed.
582
+ * @param {string} root
583
+ * @param {{ platform?: string, getuid?: (() => number)|null, geteuid?: (() => number)|null, io?: typeof fs }} [options]
584
+ */
585
+ export function removePartialSystemRoot(root, { platform = os.platform(), getuid = process.getuid, geteuid = process.geteuid, io = fs } = {}) {
586
+ const refused = (reason) => Object.assign(
587
+ new Error(`Refusing bounded removal of ${root}: ${reason}.`),
588
+ { code: 'PARTIAL_SYSTEM_REMOVAL_REFUSED' }
589
+ );
590
+ if (!isPartialSystemRemovalCandidate(root, { platform, getuid, geteuid })) {
591
+ throw refused('it is not the canonical partial system root');
592
+ }
593
+ try {
594
+ for (const file of PARTIAL_SYSTEM_FORBIDDEN_PATHS) {
595
+ try {
596
+ io.lstatSync(file);
597
+ } catch (err) {
598
+ if (err?.code === 'ENOENT') continue;
599
+ throw refused(`existing installation state at ${file} cannot be verified`);
600
+ }
601
+ throw refused(`${file} exists; a real installation requires its packaged maintenance`);
602
+ }
603
+ removeBoundedRoot(path.resolve(root), {
604
+ uid: 0,
605
+ allowedEntries: PARTIAL_SYSTEM_ENTRIES,
606
+ io,
607
+ refused,
608
+ owner: 'root'
609
+ });
610
+ } catch (err) {
611
+ if (err?.code === 'PARTIAL_SYSTEM_REMOVAL_REFUSED') throw err;
612
+ throw refused(err instanceof Error ? err.message : 'the tree could not be verified');
613
+ }
614
+ }
615
+
432
616
  /**
433
617
  * Canonical machine-wide roots that exist but cannot be inspected by the
434
618
  * current unprivileged caller. Setup must escalate before deciding anything
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-command",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {