node-command 2.0.9 โ†’ 2.0.11

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 +171 -11
  2. package/lib/setup.mjs +10 -1
  3. package/package.json +4 -6
package/bin/nc.mjs CHANGED
@@ -1529,6 +1529,75 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
1529
1529
  }
1530
1530
  }
1531
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
+
1532
1601
  /**
1533
1602
  * Downloads, verifies, extracts, and installs the agent package. Writes NO
1534
1603
  * credentials โ€” the env file must already exist before this runs.
@@ -1554,13 +1623,15 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
1554
1623
  * (`/releases/client/<version>/bootstrap.tar.gz`) stays anonymous. The
1555
1624
  * proof binds method GET + the full request URL + the redemption-issued
1556
1625
  * client token, exactly like redeemEnrollment.
1557
- * @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.
1558
1628
  */
1559
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 } = {}) {
1560
1630
  ncRoot = ncRoot || (installMode ? getNcRootForMode(installMode) : getNcRoot());
1561
1631
  const modeEnv = { ...(installMode ? installerEnvForMode(installMode) : {}), NC_ROOT_COMMAND_CONSENT: rootConsent === true ? 'yes' : 'no' };
1562
- const invokeInstaller =
1563
- 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);
1564
1635
  const root = ncRoot;
1565
1636
  console.log(`๐Ÿ“ Installation root: ${root}`);
1566
1637
 
@@ -1771,6 +1842,68 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1771
1842
  throw new PairingError('INSTALL_SCRIPT_MISSING', `Installation script not found: ${scriptPath}.`);
1772
1843
  }
1773
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
+
1774
1907
  try {
1775
1908
  invokeInstaller(scriptPath, root);
1776
1909
  } catch (err) {
@@ -1785,6 +1918,21 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1785
1918
  console.log('๐Ÿงน Cleaning up temporary directory...');
1786
1919
  cleanup();
1787
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 };
1788
1936
  }
1789
1937
 
1790
1938
  /**
@@ -1839,7 +1987,7 @@ export async function pairDeviceAndInstall({
1839
1987
  const installAgent =
1840
1988
  hooks.installAgent ||
1841
1989
  (async ({ ncRoot: installRoot, serverUrl: installServerUrl, release: installRelease = undefined, hostId: installHostId = undefined, hostToken: installHostToken = undefined, privateKey: installPrivateKey = undefined }) => {
1842
- 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 });
1843
1991
  });
1844
1992
  let selectedRootConsent = false;
1845
1993
 
@@ -1876,7 +2024,7 @@ export async function pairDeviceAndInstall({
1876
2024
  return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
1877
2025
  }
1878
2026
  console.log(`๐Ÿ” Resuming installation for host ${localEnv.NC_CLIENT_ID} (stored credentials verified)...`);
1879
- 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 });
1880
2028
  writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'installed' });
1881
2029
  await verifyHostRegistration({
1882
2030
  apiBase: localEnv.NC_SERVER_URL,
@@ -1887,7 +2035,8 @@ export async function pairDeviceAndInstall({
1887
2035
  intervalMs: verifyIntervalMs
1888
2036
  });
1889
2037
  writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'verified' });
1890
- 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 };
1891
2040
  } catch (err) {
1892
2041
  // Failure at the bound destination is not permission to enroll on
1893
2042
  // another server or replace this installed host identity.
@@ -1956,7 +2105,7 @@ export async function pairDeviceAndInstall({
1956
2105
  writePairState(statePath, { host_id: hostId, phase: 'redeemed' });
1957
2106
 
1958
2107
  console.log('๐Ÿš€ Installing agent...');
1959
- 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 });
1960
2109
  writePairState(statePath, { host_id: hostId, phase: 'installed' });
1961
2110
 
1962
2111
  console.log(`๐Ÿ” Verifying host registration at ${sanitizeForDisplay(enrollServerUrl, 256)}...`);
@@ -1973,7 +2122,8 @@ export async function pairDeviceAndInstall({
1973
2122
  });
1974
2123
  writePairState(statePath, { host_id: hostId, phase: 'verified' });
1975
2124
  return { hostId, serverUrl: enrollServerUrl, resumed: false, version: agentVersion, rootConsent: selectedRootConsent,
1976
- privilegeReadiness: selectedRootConsent ? 'pending' : 'unavailable', executionInitialization };
2125
+ privilegeReadiness: selectedRootConsent ? 'pending' : 'unavailable', executionInitialization,
2126
+ helperCandidate: installOutcome?.helperCandidate ?? null };
1977
2127
  }
1978
2128
 
1979
2129
  /**
@@ -2235,9 +2385,9 @@ function getCliVersion() {
2235
2385
  /**
2236
2386
  * Prints the one-line install summary required after a successful setup:
2237
2387
  * host id, install mode, CLI version, and verification status.
2238
- * @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
2239
2389
  */
2240
- 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 }) {
2241
2391
  console.log(`\nโœ… Setup complete for host ${sanitizeForDisplay(hostId, 128)}${resumed ? ' (resumed)' : ''}.`);
2242
2392
  if (installMode) console.log(`installed as ${installMode}`);
2243
2393
  if (version) console.log(`agent version: ${sanitizeForDisplay(version, 120)}`);
@@ -2251,7 +2401,17 @@ export function printSetupSuccess({ hostId, serverUrl, installMode = undefined,
2251
2401
  console.log('execution permission: existing choice preserved; no privilege maintenance performed');
2252
2402
  } else {
2253
2403
  console.log(`root command consent: ${rootConsent === true ? 'Yes' : 'No'}`);
2254
- 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
+ }
2255
2415
  const receipt = executionInitialization;
2256
2416
  const fields = ['outcome', 'reason', 'subject_user_id', 'permission'];
2257
2417
  const reasons = { requested: ['admin_requested'], preserved: ['existing_permission_preserved', 'existing_host_preserved'],
package/lib/setup.mjs CHANGED
@@ -296,7 +296,16 @@ function linuxPairedInstallation(io = fs) {
296
296
  } finally { io.closeSync(fd); }
297
297
  };
298
298
  check(app, true);
299
- if (io.readdirSync(helperRoot + '/libexec').sort().join('\n') !== 'nc-linux-peercred\nprivileged-helper') fail();
299
+ const libexec = helperRoot + '/libexec';
300
+ const requiredLibexecEntries = ['nc-linux-peercred', 'privileged-helper'];
301
+ const managedSealedSnapshot = name => {
302
+ if (!/^privileged-helper-sealed-[0-9a-f]{64}$/.test(name)) return false;
303
+ const st = lstat(libexec + '/' + name);
304
+ return st.isDirectory() && !st.isSymbolicLink() && st.uid === 0 && st.gid === 0 && (st.mode & 0o7777) === 0o700;
305
+ };
306
+ const libexecEntries = io.readdirSync(libexec).sort();
307
+ if (!requiredLibexecEntries.every(name => libexecEntries.includes(name)) ||
308
+ !libexecEntries.every(name => requiredLibexecEntries.includes(name) || managedSealedSnapshot(name))) fail();
300
309
  check(helperRoot + '/libexec/nc-linux-peercred');
301
310
  const bootstrap = JSON.parse(read(app + '/helper-bootstrap.json'));
302
311
  const selection = JSON.parse(read('/var/lib/nc-lifecycle/selected.json'));
package/package.json CHANGED
@@ -1,16 +1,14 @@
1
1
  {
2
2
  "name": "node-command",
3
- "version": "2.0.9",
3
+ "version": "2.0.11",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
7
- "node": ">=20"
7
+ "node": ">=24"
8
8
  },
9
9
  "bin": {
10
- "nc": "bin/nc.mjs",
11
- "nodecommand": "bin/nc.mjs",
12
- "nc-agent": "bin/nc.mjs",
13
- "node-command": "bin/nc.mjs"
10
+ "nc": "./bin/nc.mjs",
11
+ "nodecommand": "./bin/nc.mjs"
14
12
  },
15
13
  "files": [
16
14
  "bin/",