node-command 2.0.12 → 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 +310 -7
  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
@@ -594,7 +594,7 @@ function requirePartialEnrollment(root, { uid, io, refused, owner }) {
594
594
  }
595
595
 
596
596
  /** Shared bounded removal of an already-authorized root. */
597
- function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner, verifyConfig = requireHostEnvironment }) {
597
+ function removeBoundedRoot(root, { uid, allowedUids = null, allowedEntries, io, refused, owner, verifyConfig = requireHostEnvironment }) {
598
598
  const resolved = path.resolve(root);
599
599
  if (io.realpathSync(resolved) !== resolved) throw refused('a symlinked ancestor is not removable');
600
600
  const rootInfo = io.lstatSync(resolved);
@@ -605,7 +605,7 @@ function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner, veri
605
605
  if (!allowedEntries.has(name)) throw refused(`unexpected top-level entry "${name}"`);
606
606
  const info = io.lstatSync(path.join(resolved, name));
607
607
  if (info.isSymbolicLink()) throw refused(`top-level entry "${name}" is a symlink`);
608
- 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}`);
609
609
  }
610
610
  verifyConfig(resolved, { uid, io, refused, owner });
611
611
  for (const name of entries) {
@@ -696,6 +696,297 @@ export function removePartialSystemRoot(root, { platform = os.platform(), getuid
696
696
  }
697
697
  }
698
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
+
699
990
  /**
700
991
  * Canonical machine-wide roots that exist but cannot be inspected by the
701
992
  * current unprivileged caller. Setup must escalate before deciding anything
@@ -773,16 +1064,21 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
773
1064
  const install = [];
774
1065
  const unreadable = [];
775
1066
  let pair = null;
1067
+ let incompleteRoot = null;
776
1068
  if ((rest.platform || os.platform()) === 'linux') {
1069
+ let pairedError = null;
777
1070
  try {
778
1071
  pair = linuxPairedInstallation(io);
779
1072
  } catch (err) {
780
1073
  // Protected metadata this account cannot stat or read is not a local
781
- // removal target. Inventory discovery continues so an ordinary user can
782
- // still remove their own readable tree; nothing here executes a root.
783
- // Any other validation failure remains a hard stop.
784
- if (err?.code !== 'TRUSTED_ROOT_MAINTENANCE_REQUIRED' || !pairedMetadataUnreadable(io)) throw err;
785
- 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;
786
1082
  }
787
1083
  }
788
1084
  const env = rest.env || process.env;
@@ -796,6 +1092,13 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
796
1092
  else if (status === 'foreign' && !install.includes(root) && isPartialSystemRemovalCandidate(root, rest) &&
797
1093
  partialEnrollmentSystemRoot(root, io)) install.push(root);
798
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);
1101
+ }
799
1102
  // The paired relation is only actionable when its root is itself readable.
800
1103
  // An unverifiable/foreign paired root is reported through `unreadable`.
801
1104
  const paired = pair && install.includes(pair.ncRoot) ? [pair] : [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-command",
3
- "version": "2.0.12",
3
+ "version": "2.0.13",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {