node-command 2.0.11 → 2.0.13

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 +17 -7
  2. package/lib/setup.mjs +396 -17
  3. package/package.json +1 -1
package/bin/nc.mjs CHANGED
@@ -24,7 +24,9 @@ import {
24
24
  isPartialSystemRemovalCandidate,
25
25
  isPartialUserRemovalCandidate,
26
26
  isUserInstallRoot,
27
+ incompleteSystemInstallationShape,
27
28
  probeInstallRoot,
29
+ removeIncompleteSystemInstallation,
28
30
  removePartialSystemRoot,
29
31
  removePartialUserRoot,
30
32
  resolveExecutable,
@@ -3118,20 +3120,25 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
3118
3120
  }
3119
3121
  for (const root of removable) {
3120
3122
  const pair = paired.find(selected => selected.ncRoot === root);
3123
+ // A pending-helper or failed-install system tree is removed by the narrow
3124
+ // bounded path below even when its packaged entry still exists: the
3125
+ // protected drain requires lifecycle custody those shapes never created.
3126
+ const incompleteShape = !pair && os.platform() === 'linux' ? incompleteSystemInstallationShape(root) : null;
3121
3127
  const script = pair ? LINUX_PAIRED_UNINSTALL_ENTRY : uninstallerForRoot(root);
3122
3128
  // A failed per-user install can leave its own tree (config plus partial
3123
3129
  // payload) without the packaged uninstaller. Only the canonical user root
3124
3130
  // is eligible, and the bounded removal revalidates ancestry, ownership and
3125
3131
  // every top-level entry. Real system installs never use this fallback;
3126
3132
  // the exact /opt/nc partial tree has its own narrower root-only exception.
3127
- const scriptExists = pair || fs.existsSync(script);
3128
3133
  const explicitRemoval = command === 'uninstall' || command === 'remove';
3134
+ const incompleteSystemTree = incompleteShape !== null && explicitRemoval && isPartialSystemRemovalCandidate(root);
3135
+ const scriptExists = pair || incompleteSystemTree || fs.existsSync(script);
3129
3136
  const partialUserTree = !scriptExists && explicitRemoval && isPartialUserRemovalCandidate(root);
3130
3137
  // The narrow system exception is /opt/nc only, requires real+effective
3131
3138
  // root, and refuses app/, uninstall.sh, units, libexec/ and lifecycle
3132
3139
  // metadata inside removePartialSystemRoot.
3133
3140
  const partialSystemTree = !scriptExists && explicitRemoval && isPartialSystemRemovalCandidate(root);
3134
- const boundedTree = partialUserTree || partialSystemTree;
3141
+ const boundedTree = partialUserTree || partialSystemTree || incompleteSystemTree;
3135
3142
  if (!scriptExists && !boundedTree) {
3136
3143
  console.error(`error: [UNINSTALLER_MISSING] uninstaller not found at ${script}; skipping ${root}.`);
3137
3144
  console.error(uninstallFailureSuggestion());
@@ -3149,15 +3156,18 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
3149
3156
  try {
3150
3157
  if (boundedTree) {
3151
3158
  try {
3152
- if (partialSystemTree) removePartialSystemRoot(root);
3159
+ if (incompleteSystemTree) removeIncompleteSystemInstallation(root);
3160
+ else if (partialSystemTree) removePartialSystemRoot(root);
3153
3161
  else removePartialUserRoot(root);
3154
- console.log(partialSystemTree
3155
- ? `Removed partial system Node Command installation at ${root} (no packaged uninstaller, unit or application payload).`
3156
- : `Removed partial per-user Node Command installation at ${root} (no packaged uninstaller was present).`);
3162
+ console.log(incompleteSystemTree
3163
+ ? `Removed ${incompleteShape.kind === 'pending-helper' ? 'pending-helper' : 'failed'} system Node Command installation at ${root} (exact unit/bounded tree only).`
3164
+ : partialSystemTree
3165
+ ? `Removed partial system Node Command installation at ${root} (no packaged uninstaller, unit or application payload).`
3166
+ : `Removed partial per-user Node Command installation at ${root} (no packaged uninstaller was present).`);
3157
3167
  } catch (err) {
3158
3168
  const code = typeof err?.code === 'string' && /_REFUSED$/.test(err.code) ? err.code : 'PARTIAL_ROOT_REMOVAL_REFUSED';
3159
3169
  console.error(`error: [${code}] ${sanitizeForDisplay(err instanceof Error ? err.message : String(err), 300)}`);
3160
- console.error(partialSystemTree
3170
+ console.error(incompleteSystemTree || partialSystemTree
3161
3171
  ? '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.'
3162
3172
  : '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.');
3163
3173
  failed += 1;
package/lib/setup.mjs CHANGED
@@ -479,6 +479,42 @@ const PARTIAL_SYSTEM_FORBIDDEN_PATHS = [
479
479
  '/var/lib/nodecommand/nc'
480
480
  ];
481
481
 
482
+ /** Files a rejected/expired enrollment may leave in the system config tree. */
483
+ const PARTIAL_ENROLLMENT_CONFIG_ENTRIES = new Set(['.nc-cli.lock', 'nc-device-key.pem', 'nc-enroll-state.json']);
484
+
485
+ /** The exact residue that identifies the narrow recoverable enrollment shape. */
486
+ const PARTIAL_ENROLLMENT_REQUIRED = ['nc-device-key.pem', 'nc-enroll-state.json'];
487
+
488
+ /**
489
+ * True only for the exact canonical system root carrying the residue of a
490
+ * rejected/expired enrollment: a root-owned config directory with the device
491
+ * key and enrollment state, no host environment, and no application payload.
492
+ * Discovery uses this so the abandoned tree is a removal target; removal
493
+ * revalidates every entry before deleting anything.
494
+ * @param {string} root
495
+ * @param {typeof fs} [io]
496
+ */
497
+ function partialEnrollmentSystemRoot(root, io = fs) {
498
+ if (path.resolve(root) !== PARTIAL_SYSTEM_ROOT) return false;
499
+ const lstat = io.lstatSync || fs.lstatSync;
500
+ const readdir = io.readdirSync || fs.readdirSync;
501
+ try {
502
+ if (!readdir(PARTIAL_SYSTEM_ROOT).every((name) => PARTIAL_SYSTEM_ENTRIES.has(name))) return false;
503
+ const config = path.join(PARTIAL_SYSTEM_ROOT, 'config');
504
+ const configInfo = lstat(config);
505
+ if (configInfo.isSymbolicLink() || !configInfo.isDirectory()) return false;
506
+ const names = readdir(config);
507
+ if (!names.every((name) => PARTIAL_ENROLLMENT_CONFIG_ENTRIES.has(name))) return false;
508
+ if (!PARTIAL_ENROLLMENT_REQUIRED.every((name) => names.includes(name))) return false;
509
+ return PARTIAL_ENROLLMENT_REQUIRED.every((name) => {
510
+ const entry = lstat(path.join(config, name));
511
+ return !entry.isSymbolicLink() && entry.isFile();
512
+ });
513
+ } catch {
514
+ return false;
515
+ }
516
+ }
517
+
482
518
  /**
483
519
  * True when `root` is the canonical per-user tree for the current account and
484
520
  * is not a protected system root. This is the cheap path/ownership gate; the
@@ -522,8 +558,43 @@ export function isPartialSystemRemovalCandidate(root, { platform = os.platform()
522
558
  return path.resolve(root) === PARTIAL_SYSTEM_ROOT;
523
559
  }
524
560
 
561
+ /** A real installation is proven by a readable, owner-matching host env. */
562
+ function requireHostEnvironment(root, { uid, io, refused, owner }) {
563
+ for (const [file, directory] of [['config', true], [path.join('config', 'nc-client.env'), false]]) {
564
+ const info = io.lstatSync(path.join(root, file));
565
+ if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
566
+ throw refused(`the ${file} marker is missing or not a regular ${directory ? 'directory' : 'file'}`);
567
+ }
568
+ if (info.uid !== uid) throw refused(`the ${file} marker is not owned by ${owner}`);
569
+ }
570
+ io.accessSync(path.join(root, 'config', 'nc-client.env'), fs.constants.R_OK);
571
+ }
572
+
573
+ /**
574
+ * A rejected/expired enrollment leaves only the device key and enrollment
575
+ * state (plus this process's own CLI lock) in config/. A host environment, an
576
+ * app payload or any unknown file means this is not the narrow recoverable
577
+ * shape, so removal fails closed.
578
+ */
579
+ function requirePartialEnrollment(root, { uid, io, refused, owner }) {
580
+ const config = path.join(root, 'config');
581
+ const info = io.lstatSync(config);
582
+ if (info.isSymbolicLink() || !info.isDirectory()) throw refused('the config marker is missing or not a regular directory');
583
+ if (info.uid !== uid) throw refused(`the config marker is not owned by ${owner}`);
584
+ const names = io.readdirSync(config);
585
+ for (const name of names) {
586
+ if (!PARTIAL_ENROLLMENT_CONFIG_ENTRIES.has(name)) throw refused(`unexpected config entry "${name}"`);
587
+ const entry = io.lstatSync(path.join(config, name));
588
+ if (entry.isSymbolicLink() || !entry.isFile()) throw refused(`config entry "${name}" is not a regular file`);
589
+ if (entry.uid !== uid) throw refused(`config entry "${name}" is not owned by ${owner}`);
590
+ }
591
+ for (const name of PARTIAL_ENROLLMENT_REQUIRED) {
592
+ if (!names.includes(name)) throw refused(`the partial-enrollment marker "${name}" is missing`);
593
+ }
594
+ }
595
+
525
596
  /** Shared bounded removal of an already-authorized root. */
526
- function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner }) {
597
+ function removeBoundedRoot(root, { uid, allowedUids = null, allowedEntries, io, refused, owner, verifyConfig = requireHostEnvironment }) {
527
598
  const resolved = path.resolve(root);
528
599
  if (io.realpathSync(resolved) !== resolved) throw refused('a symlinked ancestor is not removable');
529
600
  const rootInfo = io.lstatSync(resolved);
@@ -534,16 +605,9 @@ function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner }) {
534
605
  if (!allowedEntries.has(name)) throw refused(`unexpected top-level entry "${name}"`);
535
606
  const info = io.lstatSync(path.join(resolved, name));
536
607
  if (info.isSymbolicLink()) throw refused(`top-level entry "${name}" is a symlink`);
537
- if (info.uid !== uid) throw refused(`top-level entry "${name}" is not owned by ${owner}`);
608
+ if (allowedUids ? !allowedUids.has(info.uid) : info.uid !== uid) throw refused(`top-level entry "${name}" is not owned by ${owner}`);
538
609
  }
539
- for (const [file, directory] of [['config', true], [path.join('config', 'nc-client.env'), false]]) {
540
- const info = io.lstatSync(path.join(resolved, file));
541
- if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
542
- throw refused(`the ${file} marker is missing or not a regular ${directory ? 'directory' : 'file'}`);
543
- }
544
- if (info.uid !== uid) throw refused(`the ${file} marker is not owned by ${owner}`);
545
- }
546
- io.accessSync(path.join(resolved, 'config', 'nc-client.env'), fs.constants.R_OK);
610
+ verifyConfig(resolved, { uid, io, refused, owner });
547
611
  for (const name of entries) {
548
612
  io.rmSync(path.join(resolved, name), { recursive: true, force: true });
549
613
  }
@@ -609,12 +673,22 @@ export function removePartialSystemRoot(root, { platform = os.platform(), getuid
609
673
  }
610
674
  throw refused(`${file} exists; a real installation requires its packaged maintenance`);
611
675
  }
612
- removeBoundedRoot(path.resolve(root), {
676
+ const resolved = path.resolve(root);
677
+ const hostEnvMarker = path.join(resolved, 'config', 'nc-client.env');
678
+ let verifyConfig = requireHostEnvironment;
679
+ try {
680
+ io.lstatSync(hostEnvMarker);
681
+ } catch (err) {
682
+ if (err?.code !== 'ENOENT') throw refused(`the host environment marker at ${hostEnvMarker} cannot be verified`);
683
+ verifyConfig = requirePartialEnrollment;
684
+ }
685
+ removeBoundedRoot(resolved, {
613
686
  uid: 0,
614
687
  allowedEntries: PARTIAL_SYSTEM_ENTRIES,
615
688
  io,
616
689
  refused,
617
- owner: 'root'
690
+ owner: 'root',
691
+ verifyConfig
618
692
  });
619
693
  } catch (err) {
620
694
  if (err?.code === 'PARTIAL_SYSTEM_REMOVAL_REFUSED') throw err;
@@ -622,6 +696,297 @@ export function removePartialSystemRoot(root, { platform = os.platform(), getuid
622
696
  }
623
697
  }
624
698
 
699
+ /** Top-level entries a real system install may contain. */
700
+ const INCOMPLETE_SYSTEM_ENTRIES = new Set([
701
+ 'app', 'config', 'data', 'logs', 'releases', 'backups', 'tmp', 'tools', 'libexec', 'uninstall.sh', 'install-linux.sh'
702
+ ]);
703
+
704
+ const INCOMPLETE_SYSTEM_LAUNCHER = '/opt/nc/app/launcher.mjs';
705
+ const INCOMPLETE_SYSTEM_NODE = '/opt/nc/tools/node/bin/node';
706
+ const INCOMPLETE_SYSTEM_HOST_ENV = '/opt/nc/config/nc-client.env';
707
+ const INCOMPLETE_SYSTEM_UNIT = '/etc/systemd/system/nc-client.service';
708
+ const INCOMPLETE_SYSTEM_UNIT_DROPIN = '/etc/systemd/system/nc-client.service.d';
709
+ const INCOMPLETE_HELPER_APP = '/opt/nc/libexec/privileged-helper';
710
+ const INCOMPLETE_HELPER_DAEMON = INCOMPLETE_HELPER_APP + '/privileged-helper-linux-daemon.mjs';
711
+ const INCOMPLETE_HELPER_PROFILE = INCOMPLETE_HELPER_APP + '/privileged-helper-profile.json';
712
+ const INCOMPLETE_HELPER_UNIT = '/etc/systemd/system/nc-privileged-helper.service';
713
+ const INCOMPLETE_HELPER_UNIT_DROPIN = '/etc/systemd/system/nc-privileged-helper.service.d';
714
+ const INCOMPLETE_HELPER_SOCKET = '/etc/systemd/system/nc-privileged-helper.socket';
715
+ const INCOMPLETE_HELPER_SOCKET_DROPIN = '/etc/systemd/system/nc-privileged-helper.socket.d';
716
+ const INCOMPLETE_HELPER_TMPFILES = '/etc/tmpfiles.d/nc-privileged-helper.conf';
717
+ const INCOMPLETE_HELPER_SOCKET_PATH = '/run/nc/privileged-helper-v1.sock';
718
+ const INCOMPLETE_SYSTEM_LIFECYCLE = '/var/lib/nc-lifecycle';
719
+ const INCOMPLETE_SYSTEM_PAIRED_ROOT = '/var/lib/nodecommand/nc';
720
+
721
+ const SYSTEM_BOOTSTRAP_FIELDS = [
722
+ 'client_id', 'control_plane_id', 'tenant_id', 'root_consent', 'ack_private_key_file', 'helper_key_id',
723
+ 'helper_key_binding_id', 'binding_state', 'node_bin', 'ordinary_root', 'ordinary_service_scope', 'lifecycle_custody_state'
724
+ ];
725
+
726
+ const systemLstat = (io) => io.lstatSync || fs.lstatSync;
727
+ const systemRealpath = (io) => io.realpathSync || fs.realpathSync;
728
+ const systemReaddir = (io) => io.readdirSync || fs.readdirSync;
729
+
730
+ function systemLstatOrNull(io, file) {
731
+ try { return systemLstat(io)(file); } catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
732
+ }
733
+
734
+ /** Read a fixed protected file with root-only metadata and an exclusive fd. */
735
+ function readSystemFile(io, file, { uids = new Set([0]), mode = null, max = 65536 } = {}) {
736
+ const info = systemLstat(io)(file);
737
+ if (info.isSymbolicLink() || !info.isFile() || !uids.has(info.uid) || info.nlink !== 1 ||
738
+ (info.mode & 0o022) || (mode !== null && (info.mode & 0o7777) !== mode) ||
739
+ !Number.isSafeInteger(info.size) || info.size <= 0 || info.size > max) throw new Error('unprotected');
740
+ if (systemRealpath(io)(file) !== file) throw new Error('noncanonical');
741
+ const fd = (io.openSync || fs.openSync)(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
742
+ try {
743
+ const checked = (io.fstatSync || fs.fstatSync)(fd);
744
+ if (checked.dev !== info.dev || checked.ino !== info.ino || checked.size !== info.size ||
745
+ checked.uid !== info.uid || checked.nlink !== 1) throw new Error('changed');
746
+ const text = String((io.readFileSync || fs.readFileSync)(fd, 'utf8'));
747
+ if (Buffer.byteLength(text) !== info.size) throw new Error('changed');
748
+ return text;
749
+ } finally { (io.closeSync || fs.closeSync)(fd); }
750
+ }
751
+
752
+ const systemUnitValues = (text, key) => text.split(/\r?\n/)
753
+ .filter((line) => line.startsWith(key + '=')).map((line) => line.slice(key.length + 1));
754
+
755
+ function systemExecStart(value) {
756
+ const match = /^(?:"([^"\n]+)"|([^\s]+))\s+(?:"([^"\n]+)"|([^\s]+))\s*$/.exec(value);
757
+ return match ? { runtime: match[1] || match[2], launcher: match[3] || match[4] } : null;
758
+ }
759
+
760
+ const systemCanonical = (value) => typeof value === 'string' && path.isAbsolute(value) &&
761
+ path.resolve(value) === value && !/[\x00-\x1f\x7f]/.test(value);
762
+
763
+ /** A unit runtime may be the canonical path or an equivalent symlink to it. */
764
+ function systemRuntimeMatches(io, actual, runtime) {
765
+ if (actual === runtime) return true;
766
+ if (!systemCanonical(actual)) return false;
767
+ try { return systemRealpath(io)(actual) === runtime; } catch { return false; }
768
+ }
769
+
770
+ /** Verify the exact ordinary client unit this tree produced. */
771
+ function systemValidateClientUnit(io, runtime) {
772
+ if (systemLstatOrNull(io, INCOMPLETE_SYSTEM_UNIT_DROPIN)) throw new Error('unit drop-in');
773
+ const text = readSystemFile(io, INCOMPLETE_SYSTEM_UNIT);
774
+ const directories = systemUnitValues(text, 'WorkingDirectory');
775
+ const launches = systemUnitValues(text, 'ExecStart');
776
+ const launch = launches.length === 1 ? systemExecStart(launches[0]) : null;
777
+ if (directories.length !== 1 || !['/opt/nc', '/opt/nc/app'].includes(directories[0]) ||
778
+ !launch || launch.launcher !== INCOMPLETE_SYSTEM_LAUNCHER || !systemRuntimeMatches(io, launch.runtime, runtime))
779
+ throw new Error('foreign client unit');
780
+ for (const key of ['User', 'Group']) {
781
+ const values = systemUnitValues(text, key);
782
+ if (values.length !== 1 || values[0] !== 'nodecommand') throw new Error('foreign client identity');
783
+ }
784
+ }
785
+
786
+ /** Verify the exact helper socket/service/tmpfiles that point at this tree. */
787
+ function systemValidateHelperArtifacts(io, runtime) {
788
+ if (systemLstatOrNull(io, INCOMPLETE_HELPER_UNIT_DROPIN) || systemLstatOrNull(io, INCOMPLETE_HELPER_SOCKET_DROPIN))
789
+ throw new Error('helper drop-in');
790
+ const service = systemLstatOrNull(io, INCOMPLETE_HELPER_UNIT);
791
+ const socket = systemLstatOrNull(io, INCOMPLETE_HELPER_SOCKET);
792
+ if (!!service !== !!socket) throw new Error('incomplete helper unit pair');
793
+ let group = null;
794
+ if (service) {
795
+ const text = readSystemFile(io, INCOMPLETE_HELPER_UNIT);
796
+ const directories = systemUnitValues(text, 'WorkingDirectory');
797
+ const launches = systemUnitValues(text, 'ExecStart');
798
+ const launch = launches.length === 1 ? systemExecStart(launches[0]) : null;
799
+ if (directories.length !== 1 || directories[0] !== INCOMPLETE_HELPER_APP ||
800
+ !launch || launch.launcher !== INCOMPLETE_HELPER_DAEMON || !systemRuntimeMatches(io, launch.runtime, runtime))
801
+ throw new Error('foreign helper unit');
802
+ for (const key of ['User', 'Group']) {
803
+ const values = systemUnitValues(text, key);
804
+ if (values.length !== 1 || values[0] !== 'root') throw new Error('foreign helper identity');
805
+ }
806
+ const socketText = readSystemFile(io, INCOMPLETE_HELPER_SOCKET);
807
+ const listen = systemUnitValues(socketText, 'ListenStream');
808
+ const services = systemUnitValues(socketText, 'Service');
809
+ const groups = systemUnitValues(socketText, 'SocketGroup');
810
+ const removeOnStop = systemUnitValues(socketText, 'RemoveOnStop');
811
+ if (listen.length !== 1 || listen[0] !== INCOMPLETE_HELPER_SOCKET_PATH || services.length > 1 ||
812
+ (services[0] !== undefined && services[0] !== 'nc-privileged-helper.service') ||
813
+ groups.length !== 1 || !/^[a-z_][a-z0-9_-]*[$]?$/.test(groups[0]) ||
814
+ removeOnStop.length !== 1 || removeOnStop[0] !== 'true') throw new Error('foreign helper socket');
815
+ group = groups[0];
816
+ }
817
+ const tmpfiles = systemLstatOrNull(io, INCOMPLETE_HELPER_TMPFILES);
818
+ if (tmpfiles) {
819
+ if (!group) throw new Error('orphan helper tmpfiles');
820
+ const text = readSystemFile(io, INCOMPLETE_HELPER_TMPFILES);
821
+ if (text.trim() !== `d /run/nc 0750 root ${group} -`) throw new Error('foreign helper tmpfiles');
822
+ }
823
+ return { service: !!service, tmpfiles: !!tmpfiles };
824
+ }
825
+
826
+ /**
827
+ * Recognize the two root-recoverable system-install leftovers this CLI may
828
+ * remove without the packaged maintenance entry: a helper provisioned but
829
+ * never bound (`pending`/`not_initialized`, no profile, no lifecycle custody)
830
+ * and a failed install whose app payload never landed but whose stale unit
831
+ * remains. Anything else returns null so callers keep failing closed.
832
+ * @param {string} root
833
+ * @param {{ platform?: string, io?: typeof fs }} [options]
834
+ * @returns {null | { kind: 'pending-helper'|'failed-install', runtime: string, payloadUid: number, helperArtifacts: { service: boolean, tmpfiles: boolean } }}
835
+ */
836
+ export function incompleteSystemInstallationShape(root, { platform = os.platform(), io = fs } = {}) {
837
+ if (platform !== 'linux' || path.resolve(root) !== PARTIAL_SYSTEM_ROOT) return null;
838
+ try {
839
+ const lstat = systemLstat(io);
840
+ if (systemRealpath(io)(root) !== root) return null;
841
+ const rootInfo = lstat(root);
842
+ if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory() || rootInfo.uid !== 0 || (rootInfo.mode & 0o022)) return null;
843
+ const names = systemReaddir(io)(root);
844
+ if (names.length === 0 || !names.every((name) => INCOMPLETE_SYSTEM_ENTRIES.has(name))) return null;
845
+ const appInfo = systemLstatOrNull(io, path.join(root, 'app'));
846
+ if (!appInfo || appInfo.isSymbolicLink() || !appInfo.isDirectory()) return null;
847
+ let payloadUid = appInfo.uid;
848
+ const configInfo = systemLstatOrNull(io, path.join(root, 'config'));
849
+ if (configInfo) {
850
+ if (configInfo.isSymbolicLink() || !configInfo.isDirectory()) return null;
851
+ payloadUid = configInfo.uid;
852
+ }
853
+ for (const name of names) {
854
+ const info = lstat(path.join(root, name));
855
+ if (info.isSymbolicLink() || (info.uid !== 0 && info.uid !== payloadUid)) return null;
856
+ }
857
+ const helper = systemLstatOrNull(io, INCOMPLETE_HELPER_APP);
858
+ if (helper) {
859
+ if (helper.isSymbolicLink() || !helper.isDirectory() || helper.uid !== 0 || (helper.mode & 0o022)) return null;
860
+ // A bound/prepared helper, external lifecycle custody or the other
861
+ // ordinary root all require their own trusted maintenance.
862
+ if (systemLstatOrNull(io, INCOMPLETE_HELPER_PROFILE) || systemLstatOrNull(io, INCOMPLETE_SYSTEM_LIFECYCLE) ||
863
+ systemLstatOrNull(io, INCOMPLETE_SYSTEM_PAIRED_ROOT)) return null;
864
+ const bootstrap = JSON.parse(readSystemFile(io, INCOMPLETE_HELPER_APP + '/helper-bootstrap.json', { mode: 0o600 }));
865
+ const controlPlaneId = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 &&
866
+ value.trim() === value && !/[\x00-\x1f\x7f]/.test(value);
867
+ const id = (value) => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,200}$/.test(value);
868
+ if (!bootstrap || typeof bootstrap !== 'object' || Array.isArray(bootstrap) ||
869
+ Object.keys(bootstrap).length !== SYSTEM_BOOTSTRAP_FIELDS.length ||
870
+ !SYSTEM_BOOTSTRAP_FIELDS.every((field) => Object.hasOwn(bootstrap, field)) ||
871
+ !/^nc_[0-9a-f]{64}$/.test(bootstrap.client_id) || !controlPlaneId(bootstrap.control_plane_id) ||
872
+ !id(bootstrap.tenant_id) || !id(bootstrap.helper_key_id) || bootstrap.root_consent !== true ||
873
+ bootstrap.helper_key_binding_id !== null || bootstrap.binding_state !== 'pending' ||
874
+ bootstrap.lifecycle_custody_state !== 'not_initialized' || bootstrap.ordinary_root !== root ||
875
+ bootstrap.ordinary_service_scope !== 'system' ||
876
+ bootstrap.ack_private_key_file !== INCOMPLETE_HELPER_APP + '/privileged-helper-ack-key.pem' ||
877
+ !systemCanonical(bootstrap.node_bin)) return null;
878
+ readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper.json', { mode: 0o600 });
879
+ readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper-ack-key.pem', { mode: 0o600 });
880
+ readSystemFile(io, INCOMPLETE_HELPER_APP + '/privileged-helper-public.pem', { mode: 0o644 });
881
+ const peercred = systemLstatOrNull(io, '/opt/nc/libexec/nc-linux-peercred');
882
+ if (!peercred || peercred.isSymbolicLink() || !peercred.isFile() || peercred.uid !== 0 || peercred.nlink !== 1) return null;
883
+ const daemon = systemLstatOrNull(io, INCOMPLETE_HELPER_DAEMON);
884
+ if (!daemon || daemon.isSymbolicLink() || !daemon.isFile() || daemon.uid !== 0 || !(daemon.mode & 0o111)) return null;
885
+ readSystemFile(io, INCOMPLETE_SYSTEM_LAUNCHER, { uids: new Set([0, payloadUid]) });
886
+ const hostEnv = readSystemFile(io, INCOMPLETE_SYSTEM_HOST_ENV, { uids: new Set([0, payloadUid]) });
887
+ const ids = hostEnv.split(/\r?\n/).filter((line) => /^\s*NC_CLIENT_ID\s*=/.test(line));
888
+ const clientId = ids[0]?.match(/^\s*NC_CLIENT_ID\s*=\s*(?:"(nc_[0-9a-f]{64})"|'(nc_[0-9a-f]{64})'|(nc_[0-9a-f]{64}))\s*$/);
889
+ if (ids.length !== 1 || !clientId || (clientId[1] || clientId[2] || clientId[3]) !== bootstrap.client_id) return null;
890
+ systemValidateClientUnit(io, bootstrap.node_bin);
891
+ const helperArtifacts = systemValidateHelperArtifacts(io, bootstrap.node_bin);
892
+ return Object.freeze({ kind: 'pending-helper', runtime: bootstrap.node_bin, payloadUid, helperArtifacts });
893
+ }
894
+ // Failed-install shape: no helper payload, no packaged uninstaller, and an
895
+ // exactly matching stale unit whose launcher never landed (or is empty).
896
+ if (systemLstatOrNull(io, '/opt/nc/libexec/nc-linux-peercred')) return null;
897
+ if (systemLstatOrNull(io, path.join(root, 'uninstall.sh'))) return null;
898
+ for (const artifact of [INCOMPLETE_HELPER_UNIT, INCOMPLETE_HELPER_SOCKET, INCOMPLETE_HELPER_TMPFILES])
899
+ if (systemLstatOrNull(io, artifact)) return null;
900
+ if (systemLstatOrNull(io, INCOMPLETE_SYSTEM_LIFECYCLE) || systemLstatOrNull(io, INCOMPLETE_SYSTEM_PAIRED_ROOT)) return null;
901
+ const libexec = systemLstatOrNull(io, path.join(root, 'libexec'));
902
+ if (libexec) {
903
+ if (libexec.isSymbolicLink() || !libexec.isDirectory() || systemReaddir(io)(path.join(root, 'libexec')).length !== 0) return null;
904
+ }
905
+ const launcher = systemLstatOrNull(io, INCOMPLETE_SYSTEM_LAUNCHER);
906
+ if (launcher && (launcher.isSymbolicLink() || !launcher.isFile() || launcher.size !== 0 ||
907
+ (launcher.uid !== 0 && launcher.uid !== payloadUid))) return null;
908
+ systemValidateClientUnit(io, INCOMPLETE_SYSTEM_NODE);
909
+ return Object.freeze({
910
+ kind: 'failed-install',
911
+ runtime: INCOMPLETE_SYSTEM_NODE,
912
+ payloadUid,
913
+ helperArtifacts: Object.freeze({ service: false, tmpfiles: false })
914
+ });
915
+ } catch {
916
+ return null;
917
+ }
918
+ }
919
+
920
+ /**
921
+ * Root-only removal of the exact incomplete system shapes above. Stops and
922
+ * disables the selected client, removes only the exact helper artifacts that
923
+ * point at /opt/nc, then removes the bounded tree. The dedicated nodecommand
924
+ * account is never touched, and no other path is eligible.
925
+ * @param {string} root
926
+ * @param {{ platform?: string, getuid?: (() => number)|null, geteuid?: (() => number)|null, io?: typeof fs, run?: Function }} [options]
927
+ * @returns {'pending-helper'|'failed-install'}
928
+ */
929
+ export function removeIncompleteSystemInstallation(root, {
930
+ platform = os.platform(), getuid = process.getuid, geteuid = process.geteuid, io = fs, run = child_process.spawnSync
931
+ } = {}) {
932
+ const refused = (reason) => Object.assign(
933
+ new Error(`Refusing incomplete system removal of ${root}: ${reason}.`),
934
+ { code: 'PARTIAL_SYSTEM_REMOVAL_REFUSED' }
935
+ );
936
+ if (!isPartialSystemRemovalCandidate(root, { platform, getuid, geteuid })) {
937
+ throw refused('it is not the canonical incomplete system root');
938
+ }
939
+ const shape = incompleteSystemInstallationShape(root, { platform, io });
940
+ if (!shape) throw refused('the system tree is not the exact pending-helper or failed-install shape');
941
+ const unlink = io.unlinkSync || fs.unlinkSync;
942
+ const systemctl = (args) => {
943
+ const result = run('/usr/bin/systemctl', ['--system', '--no-pager', '--no-ask-password', ...args], {
944
+ env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C', LC_ALL: 'C' }, cwd: '/', encoding: 'utf8',
945
+ timeout: 15000, stdio: ['ignore', 'pipe', 'pipe']
946
+ });
947
+ if (!result || result.error || result.signal || result.status !== 0) throw refused(`systemctl ${args.join(' ')} failed`);
948
+ return String(result.stdout ?? '');
949
+ };
950
+ try {
951
+ systemctl(['disable', '--now', 'nc-client.service']);
952
+ if (shape.kind === 'pending-helper' && shape.helperArtifacts.service) {
953
+ systemctl(['disable', '--now', 'nc-privileged-helper.socket']);
954
+ systemctl(['stop', 'nc-privileged-helper.service']);
955
+ try {
956
+ systemLstat(io)(INCOMPLETE_HELPER_SOCKET_PATH);
957
+ throw refused('the privileged helper socket is still present after stop');
958
+ } catch (error) {
959
+ if (error?.code !== 'ENOENT') throw error;
960
+ }
961
+ if (shape.helperArtifacts.tmpfiles) unlink(INCOMPLETE_HELPER_TMPFILES);
962
+ unlink(INCOMPLETE_HELPER_SOCKET);
963
+ unlink(INCOMPLETE_HELPER_UNIT);
964
+ }
965
+ unlink(INCOMPLETE_SYSTEM_UNIT);
966
+ systemctl(['daemon-reload']);
967
+ const units = shape.kind === 'pending-helper' && shape.helperArtifacts.service
968
+ ? ['nc-client.service', 'nc-privileged-helper.socket', 'nc-privileged-helper.service']
969
+ : ['nc-client.service'];
970
+ for (const unit of units) {
971
+ const state = systemctl(['show', unit, '--property=LoadState', '--value']).trim();
972
+ if (state !== 'not-found') throw refused(`${unit} is still loaded as ${state}`);
973
+ }
974
+ removeBoundedRoot(root, {
975
+ uid: 0,
976
+ allowedUids: new Set([0, shape.payloadUid]),
977
+ allowedEntries: INCOMPLETE_SYSTEM_ENTRIES,
978
+ io,
979
+ refused,
980
+ owner: 'root',
981
+ verifyConfig: () => {}
982
+ });
983
+ return shape.kind;
984
+ } catch (error) {
985
+ if (error?.code === 'PARTIAL_SYSTEM_REMOVAL_REFUSED') throw error;
986
+ throw refused(error instanceof Error ? error.message : 'the tree could not be verified');
987
+ }
988
+ }
989
+
625
990
  /**
626
991
  * Canonical machine-wide roots that exist but cannot be inspected by the
627
992
  * current unprivileged caller. Setup must escalate before deciding anything
@@ -699,16 +1064,21 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
699
1064
  const install = [];
700
1065
  const unreadable = [];
701
1066
  let pair = null;
1067
+ let incompleteRoot = null;
702
1068
  if ((rest.platform || os.platform()) === 'linux') {
1069
+ let pairedError = null;
703
1070
  try {
704
1071
  pair = linuxPairedInstallation(io);
705
1072
  } catch (err) {
706
1073
  // Protected metadata this account cannot stat or read is not a local
707
- // removal target. Inventory discovery continues so an ordinary user can
708
- // still remove their own readable tree; nothing here executes a root.
709
- // Any other validation failure remains a hard stop.
710
- if (err?.code !== 'TRUSTED_ROOT_MAINTENANCE_REQUIRED' || !pairedMetadataUnreadable(io)) throw err;
711
- pair = null;
1074
+ // removal target. Any other validation failure remains a hard stop
1075
+ // unless the tree is the exact recoverable pending/failed shape.
1076
+ if (err?.code !== 'TRUSTED_ROOT_MAINTENANCE_REQUIRED') throw err;
1077
+ pairedError = err;
1078
+ }
1079
+ if (!pair) {
1080
+ if (incompleteSystemInstallationShape(PARTIAL_SYSTEM_ROOT, { platform: 'linux', io })) incompleteRoot = PARTIAL_SYSTEM_ROOT;
1081
+ if (pairedError && !incompleteRoot && !pairedMetadataUnreadable(io)) throw pairedError;
712
1082
  }
713
1083
  }
714
1084
  const env = rest.env || process.env;
@@ -719,6 +1089,15 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
719
1089
  const status = probeRemovalRoot(root, io);
720
1090
  if (status === 'install' && !install.includes(root)) install.push(root);
721
1091
  else if (status === 'unreadable' && !unreadable.includes(root)) unreadable.push(root);
1092
+ else if (status === 'foreign' && !install.includes(root) && isPartialSystemRemovalCandidate(root, rest) &&
1093
+ partialEnrollmentSystemRoot(root, io)) install.push(root);
1094
+ }
1095
+ // The exact pending-helper / failed-install system tree is a bounded target
1096
+ // only for a real+effective root caller; an ordinary caller gets the
1097
+ // trusted-maintenance message instead of a silent "nothing to remove".
1098
+ if (incompleteRoot && !install.includes(incompleteRoot) && !unreadable.includes(incompleteRoot)) {
1099
+ if (isPartialSystemRemovalCandidate(incompleteRoot, rest)) install.push(incompleteRoot);
1100
+ else unreadable.push(incompleteRoot);
722
1101
  }
723
1102
  // The paired relation is only actionable when its root is itself readable.
724
1103
  // An unverifiable/foreign paired root is reported through `unreadable`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-command",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {