node-command 2.0.8 → 2.0.10

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 +194 -20
  2. package/lib/setup.mjs +111 -23
  3. package/package.json +10 -5
package/bin/nc.mjs CHANGED
@@ -21,9 +21,11 @@ import {
21
21
  getNcRootForMode,
22
22
  installerEnvForMode,
23
23
  isElevated,
24
+ isPartialSystemRemovalCandidate,
24
25
  isPartialUserRemovalCandidate,
25
26
  isUserInstallRoot,
26
27
  probeInstallRoot,
28
+ removePartialSystemRoot,
27
29
  removePartialUserRoot,
28
30
  resolveExecutable,
29
31
  setupTarget,
@@ -1527,6 +1529,75 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
1527
1529
  }
1528
1530
  }
1529
1531
 
1532
+ /**
1533
+ * Fetches the device-authenticated install context for the protected helper.
1534
+ * The control plane supplies only client/tenant/control-plane identity; the
1535
+ * trust anchor (pin + digest) is the OTA-signed runtime release. The response
1536
+ * must be exactly {tenant_id, control_plane_id}: any pin or extra field is
1537
+ * rejected. A missing/incomplete response is a non-fatal warning: ordinary
1538
+ * enrollment continues without the helper.
1539
+ */
1540
+ async function fetchPrivilegedInstallContext({ baseUrl, hostId, hostToken, privateKey }) {
1541
+ const contextUrl = `${baseUrl}/api/clients/${encodeURIComponent(hostId)}/privileged-helper/context`;
1542
+ try {
1543
+ assertCredentialOrigin(contextUrl, { label: 'helper context URL', action: 'fetch' });
1544
+ const data = await fetchJson(contextUrl, buildDeviceDownloadHeaders({ hostId, hostToken, privateKey }, contextUrl));
1545
+ const fields = ['tenant_id', 'control_plane_id'];
1546
+ const tenantId = typeof data?.tenant_id === 'string' ? data.tenant_id.trim() : '';
1547
+ const controlPlaneId = typeof data?.control_plane_id === 'string' ? data.control_plane_id.trim() : '';
1548
+ if (!data || typeof data !== 'object' || Array.isArray(data) ||
1549
+ Object.keys(data).length !== fields.length || !fields.every((field) => Object.hasOwn(data, field)) ||
1550
+ !tenantId || !controlPlaneId || tenantId.length > 256 || controlPlaneId.length > 256) {
1551
+ console.warn('Warning: the control plane did not return the identity-only helper install context (tenant id, control-plane id) and no pin is accepted from device auth. Continuing ordinary enrollment without the privileged helper; re-run setup after the control plane advertises the helper context endpoint.');
1552
+ return null;
1553
+ }
1554
+ return { client_id: hostId, tenant_id: tenantId, control_plane_id: controlPlaneId };
1555
+ } catch (err) {
1556
+ const detail = sanitizeForDisplay(err instanceof Error ? err.message : String(err), 200);
1557
+ console.warn(`Warning: could not obtain the helper install context (${detail}). Continuing ordinary enrollment without the privileged helper; re-run setup with root consent after the control plane adds the /privileged-helper/context endpoint.`);
1558
+ return null;
1559
+ }
1560
+ }
1561
+
1562
+ /**
1563
+ * Publishes the locally generated helper public key as a pending candidate.
1564
+ * Only the public key is sent; a failure never rolls back the installed agent.
1565
+ */
1566
+ async function publishPrivilegedHelperCandidate({ root, baseUrl, hostId, hostToken, privateKey }) {
1567
+ const publicKeyPath = path.join(root, 'libexec', 'privileged-helper', 'privileged-helper-public.pem');
1568
+ let pem;
1569
+ try {
1570
+ const info = fs.lstatSync(publicKeyPath);
1571
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error('not a regular file');
1572
+ pem = fs.readFileSync(publicKeyPath, 'utf8');
1573
+ } catch {
1574
+ return { published: false, warning: 'Admin remains pending; candidate publication failed: protected helper public key not found.' };
1575
+ }
1576
+ try {
1577
+ const publicKey = crypto.createPublicKey(pem);
1578
+ if (publicKey.asymmetricKeyType !== 'ed25519') throw new Error('helper public key is not Ed25519');
1579
+ const candidateUrl = `${baseUrl}/api/clients/${encodeURIComponent(hostId)}/privileged-helper/candidate`;
1580
+ assertCredentialOrigin(candidateUrl, { label: 'helper candidate URL', action: 'publish' });
1581
+ const proof = createDeviceProofCli({ privateKey, hostId, method: 'POST', url: candidateUrl, credential: hostToken });
1582
+ const res = await postJson(
1583
+ candidateUrl,
1584
+ { public_key: pem },
1585
+ { Authorization: `NC-Device ${hostToken}`, 'NC-Device-Proof': proof }
1586
+ );
1587
+ if (res.status < 200 || res.status >= 300) {
1588
+ throw new Error(sanitizeForDisplay(res.data?.error || `HTTP ${res.status}`, 120));
1589
+ }
1590
+ const keyId = crypto.createHash('sha256').update(publicKey.export({ type: 'spki', format: 'der' })).digest('hex');
1591
+ return { published: true, fingerprint: `sha256:${keyId}` };
1592
+ } catch (err) {
1593
+ const detail = sanitizeForDisplay(err instanceof Error ? err.message : String(err), 200);
1594
+ return {
1595
+ published: false,
1596
+ warning: `Admin remains pending; candidate publication failed: ${detail}. The agent is installed without the Admin helper; approve the helper candidate from the dashboard once the control plane accepts it.`
1597
+ };
1598
+ }
1599
+ }
1600
+
1530
1601
  /**
1531
1602
  * Downloads, verifies, extracts, and installs the agent package. Writes NO
1532
1603
  * credentials — the env file must already exist before this runs.
@@ -1552,13 +1623,15 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
1552
1623
  * (`/releases/client/<version>/bootstrap.tar.gz`) stays anonymous. The
1553
1624
  * proof binds method GET + the full request URL + the redemption-issued
1554
1625
  * client token, exactly like redeemEnrollment.
1555
- * @param {{ ncRoot?: string, updatesUrl?: string, serverUrl?: string, release?: Record<string, unknown>|null, installMode?: 'system'|'user', rootConsent?: boolean, runInstaller?: (scriptPath: string, ncRoot: string) => void, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject, downloadTimeoutMs?: number }} options
1626
+ * @param {{ ncRoot?: string, updatesUrl?: string, serverUrl?: string, release?: Record<string, unknown>|null, installMode?: 'system'|'user', rootConsent?: boolean, runInstaller?: (scriptPath: string, ncRoot: string, env?: Record<string, string>) => void, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject, downloadTimeoutMs?: number }} options
1627
+ * @returns {Promise<{ helperCandidate: { published: boolean, fingerprint?: string, warning?: string }|null }>} `helperCandidate` is null when no helper was requested.
1556
1628
  */
1557
1629
  export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl = undefined, serverUrl = undefined, release = undefined, installMode = undefined, rootConsent = false, runInstaller = undefined, hostId = undefined, hostToken = undefined, privateKey = undefined, downloadTimeoutMs = undefined } = {}) {
1558
1630
  ncRoot = ncRoot || (installMode ? getNcRootForMode(installMode) : getNcRoot());
1559
1631
  const modeEnv = { ...(installMode ? installerEnvForMode(installMode) : {}), NC_ROOT_COMMAND_CONSENT: rootConsent === true ? 'yes' : 'no' };
1560
- const invokeInstaller =
1561
- runInstaller || ((scriptPath, root) => runInstallerScript(scriptPath, root, modeEnv));
1632
+ const helperRequested = rootConsent === true && installMode !== 'user' && os.platform() === 'linux';
1633
+ const invokeInstaller = (scriptPath, installRoot) =>
1634
+ runInstaller ? runInstaller(scriptPath, installRoot, modeEnv) : runInstallerScript(scriptPath, installRoot, modeEnv);
1562
1635
  const root = ncRoot;
1563
1636
  console.log(`📍 Installation root: ${root}`);
1564
1637
 
@@ -1769,6 +1842,68 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1769
1842
  throw new PairingError('INSTALL_SCRIPT_MISSING', `Installation script not found: ${scriptPath}.`);
1770
1843
  }
1771
1844
 
1845
+ if (helperRequested) {
1846
+ const runtimePackageUrl = (() => {
1847
+ const raw = usableUrl(activeRelease.package_url) || usableUrl(activeRelease.download_url);
1848
+ if (!raw) return null;
1849
+ try {
1850
+ return new URL(raw, defaultBaseUrl).toString();
1851
+ } catch {
1852
+ return null;
1853
+ }
1854
+ })();
1855
+ const runtimeSha = typeof activeRelease.sha256 === 'string' ? activeRelease.sha256.toLowerCase() : '';
1856
+ const runtimeSize = activeRelease.size_bytes;
1857
+ const runtimeSignature = typeof activeRelease.signature === 'string' ? activeRelease.signature.trim() : '';
1858
+ const runtimeVersion = typeof version === 'string' ? version : '';
1859
+ const helperPrereqsReady =
1860
+ hasDeviceIdentity &&
1861
+ runtimePackageUrl &&
1862
+ /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(runtimeVersion) &&
1863
+ /^[a-f0-9]{64}$/.test(runtimeSha) &&
1864
+ Number.isSafeInteger(runtimeSize) && runtimeSize > 0 &&
1865
+ /^[A-Za-z0-9_-]{86}$/.test(runtimeSignature);
1866
+ if (!helperPrereqsReady) {
1867
+ console.warn('Warning: the release does not carry a complete signed runtime package (version, package URL, sha256, size, signature) or this setup has no device identity. Continuing ordinary User enrollment without the privileged helper.');
1868
+ } else {
1869
+ const context = await fetchPrivilegedInstallContext({ baseUrl: defaultBaseUrl, hostId, hostToken, privateKey });
1870
+ if (context) {
1871
+ // Fetch the runtime archive with the device credential here; the root
1872
+ // installer must never need a client token and cannot fetch the
1873
+ // authenticated package endpoint itself.
1874
+ const runtimeArchivePath = path.join(tempDir, 'client.tar.gz');
1875
+ try {
1876
+ assertCredentialOrigin(runtimePackageUrl, { label: 'runtime release URL', action: 'fetch' });
1877
+ if (!isSameOrigin(runtimePackageUrl, defaultBaseUrl)) {
1878
+ throw new Error('runtime release URL is not on the trusted enrollment origin');
1879
+ }
1880
+ const download = await downloadToFile(
1881
+ runtimePackageUrl,
1882
+ runtimeArchivePath,
1883
+ buildDeviceDownloadHeaders({ hostId, hostToken, privateKey }, runtimePackageUrl),
1884
+ { maxBytes: getCliMaxPackageBytes(), timeoutMs: downloadTimeoutMs ?? getCliDownloadTimeoutMs() }
1885
+ );
1886
+ if (download.bytes !== runtimeSize || download.sha256.toLowerCase() !== runtimeSha) {
1887
+ throw new Error('runtime release archive does not match the signed digest/size');
1888
+ }
1889
+ modeEnv.NC_PRIVILEGED_INSTALL_CONTEXT = JSON.stringify(context);
1890
+ modeEnv.NC_PRIVILEGED_ARCHIVE_FILE = runtimeArchivePath;
1891
+ modeEnv.NC_PRIVILEGED_RELEASE_VERSION = runtimeVersion;
1892
+ modeEnv.NC_PRIVILEGED_RELEASE_URL = runtimePackageUrl;
1893
+ modeEnv.NC_PRIVILEGED_RELEASE_SHA256 = runtimeSha;
1894
+ modeEnv.NC_PRIVILEGED_RELEASE_SIZE = String(runtimeSize);
1895
+ modeEnv.NC_PRIVILEGED_RELEASE_SIGNATURE = runtimeSignature;
1896
+ // The installer compares the archive URL origin with the enrollment
1897
+ // server origin. modeEnv values win over inherited NC_* variables.
1898
+ modeEnv.NC_SERVER_URL = defaultBaseUrl;
1899
+ } catch (err) {
1900
+ const detail = sanitizeForDisplay(err instanceof Error ? err.message : String(err), 200);
1901
+ console.warn(`Warning: could not prepare the signed runtime release for the privileged helper (${detail}). Continuing ordinary User enrollment without the privileged helper.`);
1902
+ }
1903
+ }
1904
+ }
1905
+ }
1906
+
1772
1907
  try {
1773
1908
  invokeInstaller(scriptPath, root);
1774
1909
  } catch (err) {
@@ -1783,6 +1918,21 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1783
1918
  console.log('🧹 Cleaning up temporary directory...');
1784
1919
  cleanup();
1785
1920
  }
1921
+
1922
+ let helperCandidate = null;
1923
+ if (helperRequested && hasDeviceIdentity) {
1924
+ helperCandidate = await publishPrivilegedHelperCandidate({
1925
+ root,
1926
+ baseUrl: defaultBaseUrl,
1927
+ hostId,
1928
+ hostToken,
1929
+ privateKey
1930
+ });
1931
+ if (helperCandidate.published === false && helperCandidate.warning) {
1932
+ console.warn(`Warning: ${helperCandidate.warning}`);
1933
+ }
1934
+ }
1935
+ return { helperCandidate };
1786
1936
  }
1787
1937
 
1788
1938
  /**
@@ -1837,7 +1987,7 @@ export async function pairDeviceAndInstall({
1837
1987
  const installAgent =
1838
1988
  hooks.installAgent ||
1839
1989
  (async ({ ncRoot: installRoot, serverUrl: installServerUrl, release: installRelease = undefined, hostId: installHostId = undefined, hostToken: installHostToken = undefined, privateKey: installPrivateKey = undefined }) => {
1840
- await downloadAndInstallAgent({ ncRoot: installRoot, serverUrl: installServerUrl, installMode, rootConsent: selectedRootConsent, release: installRelease, hostId: installHostId, hostToken: installHostToken, privateKey: installPrivateKey });
1990
+ return await downloadAndInstallAgent({ ncRoot: installRoot, serverUrl: installServerUrl, installMode, rootConsent: selectedRootConsent, release: installRelease, hostId: installHostId, hostToken: installHostToken, privateKey: installPrivateKey });
1841
1991
  });
1842
1992
  let selectedRootConsent = false;
1843
1993
 
@@ -1874,7 +2024,7 @@ export async function pairDeviceAndInstall({
1874
2024
  return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
1875
2025
  }
1876
2026
  console.log(`🔁 Resuming installation for host ${localEnv.NC_CLIENT_ID} (stored credentials verified)...`);
1877
- await installAgent({ ncRoot: root, serverUrl: storedServerUrl, hostId: localEnv.NC_CLIENT_ID, hostToken: localEnv.NC_CLIENT_TOKEN, privateKey: localKey.privateKey });
2027
+ const installOutcome = await installAgent({ ncRoot: root, serverUrl: storedServerUrl, hostId: localEnv.NC_CLIENT_ID, hostToken: localEnv.NC_CLIENT_TOKEN, privateKey: localKey.privateKey });
1878
2028
  writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'installed' });
1879
2029
  await verifyHostRegistration({
1880
2030
  apiBase: localEnv.NC_SERVER_URL,
@@ -1885,7 +2035,8 @@ export async function pairDeviceAndInstall({
1885
2035
  intervalMs: verifyIntervalMs
1886
2036
  });
1887
2037
  writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'verified' });
1888
- return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
2038
+ return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true,
2039
+ helperCandidate: installOutcome?.helperCandidate ?? null };
1889
2040
  } catch (err) {
1890
2041
  // Failure at the bound destination is not permission to enroll on
1891
2042
  // another server or replace this installed host identity.
@@ -1954,7 +2105,7 @@ export async function pairDeviceAndInstall({
1954
2105
  writePairState(statePath, { host_id: hostId, phase: 'redeemed' });
1955
2106
 
1956
2107
  console.log('🚀 Installing agent...');
1957
- await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey, rootConsent: selectedRootConsent });
2108
+ const installOutcome = await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey, rootConsent: selectedRootConsent });
1958
2109
  writePairState(statePath, { host_id: hostId, phase: 'installed' });
1959
2110
 
1960
2111
  console.log(`🔍 Verifying host registration at ${sanitizeForDisplay(enrollServerUrl, 256)}...`);
@@ -1971,7 +2122,8 @@ export async function pairDeviceAndInstall({
1971
2122
  });
1972
2123
  writePairState(statePath, { host_id: hostId, phase: 'verified' });
1973
2124
  return { hostId, serverUrl: enrollServerUrl, resumed: false, version: agentVersion, rootConsent: selectedRootConsent,
1974
- privilegeReadiness: selectedRootConsent ? 'pending' : 'unavailable', executionInitialization };
2125
+ privilegeReadiness: selectedRootConsent ? 'pending' : 'unavailable', executionInitialization,
2126
+ helperCandidate: installOutcome?.helperCandidate ?? null };
1975
2127
  }
1976
2128
 
1977
2129
  /**
@@ -2233,9 +2385,9 @@ function getCliVersion() {
2233
2385
  /**
2234
2386
  * Prints the one-line install summary required after a successful setup:
2235
2387
  * host id, install mode, CLI version, and verification status.
2236
- * @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null, rootConsent?: boolean, ncRoot?: string, executionInitialization?: any }} options
2388
+ * @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null, rootConsent?: boolean, ncRoot?: string, executionInitialization?: any, helperCandidate?: { published: boolean, fingerprint?: string, warning?: string }|null }} options
2237
2389
  */
2238
- export function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null, rootConsent = false, ncRoot = undefined, executionInitialization = null }) {
2390
+ export function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null, rootConsent = false, ncRoot = undefined, executionInitialization = null, helperCandidate = null }) {
2239
2391
  console.log(`\n✅ Setup complete for host ${sanitizeForDisplay(hostId, 128)}${resumed ? ' (resumed)' : ''}.`);
2240
2392
  if (installMode) console.log(`installed as ${installMode}`);
2241
2393
  if (version) console.log(`agent version: ${sanitizeForDisplay(version, 120)}`);
@@ -2249,7 +2401,17 @@ export function printSetupSuccess({ hostId, serverUrl, installMode = undefined,
2249
2401
  console.log('execution permission: existing choice preserved; no privilege maintenance performed');
2250
2402
  } else {
2251
2403
  console.log(`root command consent: ${rootConsent === true ? 'Yes' : 'No'}`);
2252
- console.log(`local privilege readiness: ${rootConsent === true ? 'pending independent management approval and protected helper binding' : 'unavailable (not requested)'}`);
2404
+ if (rootConsent !== true) {
2405
+ console.log('local privilege readiness: unavailable (not requested)');
2406
+ } else if (helperCandidate && helperCandidate.published === true) {
2407
+ const match = /^sha256:([0-9a-f]{64})$/i.exec(String(helperCandidate.fingerprint || ''));
2408
+ const shortFingerprint = match ? `sha256:${match[1].slice(0, 16).toLowerCase()}` : null;
2409
+ console.log(`local privilege readiness: Admin pending: approve the helper candidate in the dashboard${shortFingerprint ? ` (fingerprint: ${shortFingerprint})` : ''}`);
2410
+ } else if (helperCandidate && typeof helperCandidate.warning === 'string' && helperCandidate.warning) {
2411
+ console.log(`local privilege readiness: ${helperCandidate.warning}`);
2412
+ } else {
2413
+ console.log('local privilege readiness: pending independent management approval and protected helper binding');
2414
+ }
2253
2415
  const receipt = executionInitialization;
2254
2416
  const fields = ['outcome', 'reason', 'subject_user_id', 'permission'];
2255
2417
  const reasons = { requested: ['admin_requested'], preserved: ['existing_permission_preserved', 'existing_host_preserved'],
@@ -2960,11 +3122,17 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2960
3122
  // A failed per-user install can leave its own tree (config plus partial
2961
3123
  // payload) without the packaged uninstaller. Only the canonical user root
2962
3124
  // is eligible, and the bounded removal revalidates ancestry, ownership and
2963
- // every top-level entry. System/protected roots never use this fallback.
3125
+ // every top-level entry. Real system installs never use this fallback;
3126
+ // the exact /opt/nc partial tree has its own narrower root-only exception.
2964
3127
  const scriptExists = pair || fs.existsSync(script);
2965
- const boundedUserTree = !scriptExists &&
2966
- (command === 'uninstall' || command === 'remove') && isPartialUserRemovalCandidate(root);
2967
- if (!scriptExists && !boundedUserTree) {
3128
+ const explicitRemoval = command === 'uninstall' || command === 'remove';
3129
+ const partialUserTree = !scriptExists && explicitRemoval && isPartialUserRemovalCandidate(root);
3130
+ // The narrow system exception is /opt/nc only, requires real+effective
3131
+ // root, and refuses app/, uninstall.sh, units, libexec/ and lifecycle
3132
+ // metadata inside removePartialSystemRoot.
3133
+ const partialSystemTree = !scriptExists && explicitRemoval && isPartialSystemRemovalCandidate(root);
3134
+ const boundedTree = partialUserTree || partialSystemTree;
3135
+ if (!scriptExists && !boundedTree) {
2968
3136
  console.error(`error: [UNINSTALLER_MISSING] uninstaller not found at ${script}; skipping ${root}.`);
2969
3137
  console.error(uninstallFailureSuggestion());
2970
3138
  failed += 1;
@@ -2979,13 +3147,19 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2979
3147
  continue;
2980
3148
  }
2981
3149
  try {
2982
- if (boundedUserTree) {
3150
+ if (boundedTree) {
2983
3151
  try {
2984
- removePartialUserRoot(root);
2985
- console.log(`Removed partial per-user Node Command installation at ${root} (no packaged uninstaller was present).`);
3152
+ if (partialSystemTree) removePartialSystemRoot(root);
3153
+ 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).`);
2986
3157
  } catch (err) {
2987
- console.error(`error: [USER_TREE_REMOVAL_REFUSED] ${sanitizeForDisplay(err instanceof Error ? err.message : String(err), 300)}`);
2988
- console.error('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.');
3158
+ const code = typeof err?.code === 'string' && /_REFUSED$/.test(err.code) ? err.code : 'PARTIAL_ROOT_REMOVAL_REFUSED';
3159
+ console.error(`error: [${code}] ${sanitizeForDisplay(err instanceof Error ? err.message : String(err), 300)}`);
3160
+ console.error(partialSystemTree
3161
+ ? '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
+ : '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.');
2989
3163
  failed += 1;
2990
3164
  }
2991
3165
  continue;
package/lib/setup.mjs CHANGED
@@ -450,6 +450,26 @@ const USER_INSTALL_ENTRIES = new Set([
450
450
  'uninstall.sh'
451
451
  ]);
452
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
+
453
473
  /**
454
474
  * True when `root` is the canonical per-user tree for the current account and
455
475
  * is not a protected system root. This is the cheap path/ownership gate; the
@@ -475,6 +495,52 @@ export function isPartialUserRemovalCandidate(root, { platform = os.platform(),
475
495
  );
476
496
  }
477
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
+
478
544
  /**
479
545
  * Bounded removal of a per-user Node Command tree whose packaged `uninstall.sh`
480
546
  * is missing (a partial/failed install). Only the canonical per-user root is
@@ -494,33 +560,55 @@ export function removePartialUserRoot(root, { platform = os.platform(), env = pr
494
560
  if (!isPartialUserRemovalCandidate(root, { platform, env, homedir, uid })) {
495
561
  throw refused('it is not the canonical per-user Node Command root');
496
562
  }
497
- const resolved = path.resolve(root);
498
563
  try {
499
- if (io.realpathSync(resolved) !== resolved) throw refused('a symlinked ancestor is not removable');
500
- const rootInfo = io.lstatSync(resolved);
501
- if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) throw refused('the root is not a regular directory');
502
- if (rootInfo.uid !== uid) throw refused('the root is not owned by the current user');
503
- const entries = io.readdirSync(resolved);
504
- for (const name of entries) {
505
- if (!USER_INSTALL_ENTRIES.has(name)) throw refused(`unexpected top-level entry "${name}"`);
506
- const info = io.lstatSync(path.join(resolved, name));
507
- if (info.isSymbolicLink()) throw refused(`top-level entry "${name}" is a symlink`);
508
- if (info.uid !== uid) throw refused(`top-level entry "${name}" is not owned by the current user`);
509
- }
510
- for (const [file, directory] of [['config', true], [path.join('config', 'nc-client.env'), false]]) {
511
- const info = io.lstatSync(path.join(resolved, file));
512
- if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
513
- throw refused(`the ${file} marker is missing or not a regular ${directory ? 'directory' : 'file'}`);
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`);
514
600
  }
515
- if (info.uid !== uid) throw refused(`the ${file} marker is not owned by the current user`);
601
+ throw refused(`${file} exists; a real installation requires its packaged maintenance`);
516
602
  }
517
- io.accessSync(path.join(resolved, 'config', 'nc-client.env'), fs.constants.R_OK);
518
- for (const name of entries) {
519
- io.rmSync(path.join(resolved, name), { recursive: true, force: true });
520
- }
521
- io.rmdirSync(resolved);
603
+ removeBoundedRoot(path.resolve(root), {
604
+ uid: 0,
605
+ allowedEntries: PARTIAL_SYSTEM_ENTRIES,
606
+ io,
607
+ refused,
608
+ owner: 'root'
609
+ });
522
610
  } catch (err) {
523
- if (err?.code === 'USER_TREE_REMOVAL_REFUSED') throw err;
611
+ if (err?.code === 'PARTIAL_SYSTEM_REMOVAL_REFUSED') throw err;
524
612
  throw refused(err instanceof Error ? err.message : 'the tree could not be verified');
525
613
  }
526
614
  }
package/package.json CHANGED
@@ -1,19 +1,24 @@
1
1
  {
2
2
  "name": "node-command",
3
- "version": "2.0.8",
3
+ "version": "2.0.10",
4
+ "description": "Node Command CLI installer for the Node Command agent",
4
5
  "type": "module",
5
6
  "license": "Apache-2.0",
6
7
  "engines": {
7
- "node": ">=20"
8
+ "node": ">=24"
8
9
  },
9
10
  "bin": {
10
11
  "nc": "bin/nc.mjs",
11
- "nodecommand": "bin/nc.mjs",
12
12
  "nc-agent": "bin/nc.mjs",
13
+ "nodecommand": "bin/nc.mjs",
13
14
  "node-command": "bin/nc.mjs"
14
15
  },
15
16
  "files": [
16
17
  "bin/",
17
- "lib/"
18
- ]
18
+ "lib/",
19
+ "package.json"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ }
19
24
  }