node-command 2.0.1 → 2.0.3

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 +541 -136
  2. package/lib/setup.mjs +730 -42
  3. package/package.json +11 -16
package/bin/nc.mjs CHANGED
@@ -8,14 +8,23 @@ import path from 'node:path';
8
8
  import os from 'node:os';
9
9
  import child_process from 'node:child_process';
10
10
  import readline from 'node:readline';
11
+ import { Writable } from 'node:stream';
11
12
  import { fileURLToPath } from 'node:url';
12
13
  import {
13
14
  canElevate,
15
+ captureBrowserDefaultBrand,
16
+ normalizeBrowserBrand,
17
+ browserBrandReexecArgs,
14
18
  decideInstallMode,
15
19
  findRemovalTargets,
16
20
  getNcRootForMode,
17
21
  installerEnvForMode,
18
22
  isElevated,
23
+ isUserInstallRoot,
24
+ setupTarget,
25
+ maintainLinuxIdentity,
26
+ loadProtectedHelperProducer,
27
+ LINUX_PAIRED_UNINSTALL_ENTRY,
19
28
  modeWarningFor,
20
29
  runUninstallerScript,
21
30
  uninstallFailureSuggestion,
@@ -28,8 +37,9 @@ const baseUrl = process.env.NC_BASE_URL || 'https://auth.nodecommand.app';
28
37
  * downloads, and `/releases/*`. The auth host (`baseUrl` above) serves only
29
38
  * browser-facing OAuth/device flow and enrollment (`/oauth/*`, `/api/enroll*`);
30
39
  * Caddy routes `/api/clients*` solely on the agent (and dash) edge, so
31
- * verification against the auth host answers 404. `NC_SERVER_URL` is NOT
32
- * this value: it is the agent WS/server address written to the client env.
40
+ * verification against the auth host answers 404. Fresh enrollment uses this
41
+ * configured API destination; installed credentials instead use their stored
42
+ * NC_SERVER_URL for authenticated HTTP, just like the agent runtime.
33
43
  */
34
44
  export const apiBase = process.env.NC_API_URL || 'https://agent.nodecommand.app';
35
45
  const clientId = 'nc-cli';
@@ -531,9 +541,9 @@ export function getEnrollStatePath(ncRoot = getNcRoot()) {
531
541
  * cannot end up in the file. Host id/token shapes are enforced so a caller
532
542
  * mistake (e.g. passing an enrollment token as the client token) fails here
533
543
  * instead of producing a broken agent that crash-loops on 401.
534
- * @param {{ serverUrl: string, hostId: string, hostToken: string, clientName?: string }} options
544
+ * @param {{ serverUrl: string, hostId: string, hostToken: string, clientName?: string, browserBrand?: string }} options
535
545
  */
536
- export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName = os.hostname() }) {
546
+ export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName = os.hostname(), browserBrand = 'unknown' }) {
537
547
  if (typeof serverUrl !== 'string' || !serverUrl) {
538
548
  throw new PairingError('INVALID_SERVER_URL', 'Refusing to write agent config without a server URL.');
539
549
  }
@@ -555,6 +565,7 @@ export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName =
555
565
  `NC_CLIENT_NAME=${clientName}`,
556
566
  `NC_CLIENT_TOKEN=${hostToken}`,
557
567
  'NC_REMOTE_EXEC_ENABLED=true',
568
+ `NC_BROWSER_DEFAULT_BRAND=${normalizeBrowserBrand(browserBrand)}`,
558
569
  'NC_SESSION_ENABLED=true'
559
570
  ].join('\n') + '\n';
560
571
  }
@@ -610,6 +621,15 @@ export function loadValidHostEnv(envPath = getEnvFilePath()) {
610
621
  return env;
611
622
  }
612
623
 
624
+ function assertMatchingHostIdentity(localEnv, requestedHostId) {
625
+ if (localEnv && requestedHostId && localEnv.NC_CLIENT_ID !== requestedHostId) {
626
+ throw new PairingError(
627
+ 'HOST_ID_MISMATCH',
628
+ `This installation belongs to host ${localEnv.NC_CLIENT_ID}, but host ${requestedHostId} was requested. Stored credentials are unchanged; the supplied enrollment token has not been redeemed.`
629
+ );
630
+ }
631
+ }
632
+
613
633
  /**
614
634
  * Resume marker for idempotent re-runs. Holds the host id and lifecycle
615
635
  * phase only — never any token or key material (those live in nc-client.env
@@ -715,6 +735,52 @@ export function loadDeviceKeyPair(keyFile) {
715
735
  return { privateKey: key, publicKey, thumbprint: deviceKeyThumbprintCli(publicKey) };
716
736
  }
717
737
 
738
+ /** Read only the selected ordinary key for Root-managed paired verification. */
739
+ function loadRootManagedPairKey(ncRoot, ordinaryUid) {
740
+ const invalid = () => { throw new Error('Root-managed pair device key cannot be verified safely.'); };
741
+ if (os.platform() !== 'linux' || process.getuid?.() !== 0 || process.geteuid?.() !== 0 ||
742
+ !['/opt/nc', '/var/lib/nodecommand/nc'].includes(ncRoot) || !Number.isSafeInteger(ordinaryUid) || ordinaryUid <= 0) invalid();
743
+ let directoryFd, keyFd;
744
+ try {
745
+ // Pin each directory before opening its child. NOFOLLOW on the key alone
746
+ // would still follow a replaced ordinary root/config ancestor as Root.
747
+ const components = ncRoot === '/opt/nc' ? ['', 'opt', 'nc', 'config'] : ['', 'var', 'lib', 'nodecommand', 'nc', 'config'];
748
+ for (const component of components) {
749
+ const next = fs.openSync(component ? `/proc/self/fd/${directoryFd}/${component}` : '/',
750
+ fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
751
+ if (directoryFd !== undefined) fs.closeSync(directoryFd);
752
+ directoryFd = next;
753
+ const info = fs.fstatSync(directoryFd);
754
+ const ordinaryRoot = ncRoot === '/var/lib/nodecommand/nc' && component === 'nc';
755
+ const protectedAncestor = !ordinaryRoot && component !== 'config';
756
+ if (!info.isDirectory() || (ordinaryRoot ? ![0, ordinaryUid].includes(info.uid) : info.uid !== (protectedAncestor ? 0 : ordinaryUid)) ||
757
+ (protectedAncestor && (info.gid !== 0 || (info.mode & 0o022)))) invalid();
758
+ }
759
+ keyFd = fs.openSync(`/proc/self/fd/${directoryFd}/nc-device-key.pem`,
760
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
761
+ const before = fs.fstatSync(keyFd);
762
+ if (!before.isFile() || before.uid !== ordinaryUid || before.nlink !== 1 ||
763
+ !Number.isSafeInteger(before.size) || before.size <= 0 || before.size > 65536) invalid();
764
+ const buffer = Buffer.alloc(65537);
765
+ let length = 0;
766
+ while (length < buffer.length) {
767
+ const count = fs.readSync(keyFd, buffer, length, buffer.length - length, null);
768
+ if (count === 0) break;
769
+ length += count;
770
+ }
771
+ const after = fs.fstatSync(keyFd);
772
+ if (length !== before.size || length > 65536 || !after.isFile() ||
773
+ ['dev', 'ino', 'uid', 'gid', 'mode', 'nlink', 'size', 'mtimeMs', 'ctimeMs'].some(field => before[field] !== after[field])) invalid();
774
+ const privateKey = crypto.createPrivateKey(buffer.subarray(0, length));
775
+ if (privateKey.asymmetricKeyType !== 'ed25519') invalid();
776
+ const publicKey = publicRawFromPrivateCli(privateKey);
777
+ return { privateKey, publicKey, thumbprint: deviceKeyThumbprintCli(publicKey) };
778
+ } finally {
779
+ if (keyFd !== undefined) fs.closeSync(keyFd);
780
+ if (directoryFd !== undefined) fs.closeSync(directoryFd);
781
+ }
782
+ }
783
+
718
784
  /**
719
785
  * Loads the device key, generating and persisting a fresh Ed25519 key when
720
786
  * none exists (atomic tmp-write + rename, 0600). Mirrors
@@ -851,14 +917,15 @@ export async function requestEnrollment({ baseUrl, oauthToken, platform = 'linux
851
917
  /**
852
918
  * Redeems a one-time enrollment token with Device Proof v1, binding the
853
919
  * local Ed25519 key to the host identity. Returns the redemption payload
854
- * (client_id, client_token, release). A 410 ENROLLMENT_ALREADY_USED means a
855
- * previous run already consumed this token — the caller resumes from local
856
- * credentials instead of retrying redemption.
857
- * @param {{ baseUrl: string, hostId: string, enrollmentToken: string, keyPair: { privateKey: import('node:crypto').KeyObject, publicKey: string, thumbprint: string } }} options
920
+ * (client_id, client_token, release, optional execution_initialization). The
921
+ * selected intent is covered by the unchanged proof's exact pathname/query.
922
+ * Token authentication failures do not disclose whether redemption committed.
923
+ * @param {{ baseUrl: string, hostId: string, enrollmentToken: string, initialPermission?: 'user'|'admin', keyPair: { privateKey: import('node:crypto').KeyObject, publicKey: string, thumbprint: string } }} options
858
924
  */
859
- export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPair }) {
925
+ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPair, initialPermission = 'user' }) {
926
+ if (!['user', 'admin'].includes(initialPermission)) throw new PairingError('INVALID_INITIAL_PERMISSION', 'Initial execution intent must be user or admin.');
860
927
  const origin = String(baseUrl || '').replace(/\/$/, '');
861
- const redeemPath = '/api/enroll/redeem';
928
+ const redeemPath = `/api/enroll/redeem?initial_permission=${initialPermission}`;
862
929
  const proof = createDeviceProofCli({
863
930
  privateKey: keyPair.privateKey,
864
931
  hostId,
@@ -874,7 +941,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
874
941
  { 'NC-Device-Proof': proof }
875
942
  );
876
943
  } catch (err) {
877
- throw new PairingError('REDEEM_NETWORK_ERROR', `Could not reach the redemption endpoint: ${err.message}`);
944
+ throw new PairingError('REDEEM_NETWORK_ERROR', `Redemption response was not received: ${err.message}. The server may have committed; preserve local identity and use authorized recovery rather than reminting or replaying initialization.`);
878
945
  }
879
946
  if (res.status === 200 && res.data) {
880
947
  const r = res.data;
@@ -890,7 +957,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
890
957
  if (res.status === 410 && serverError === 'ENROLLMENT_ALREADY_USED') {
891
958
  throw new PairingError(
892
959
  'ENROLLMENT_ALREADY_USED',
893
- 'This enrollment was already redeemed (a previous run consumed it). Re-running resumes from the locally stored host credentials.'
960
+ 'The legacy server refused redemption. Preserve existing host/device identity and recover only through authorized existing credentials or management; no credentials were recovered by this attempt.'
894
961
  );
895
962
  }
896
963
  if (res.status === 410) {
@@ -900,7 +967,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
900
967
  );
901
968
  }
902
969
  if (res.status === 401) {
903
- throw new PairingError('REDEEM_UNAUTHORIZED', `Redemption was refused (${serverError || 'unauthorized'}). Re-run to request a fresh enrollment.`);
970
+ throw new PairingError('REDEEM_UNAUTHORIZED', `Redemption was refused (${serverError || 'unauthorized'}). No credentials or initialization were recovered. Preserve any existing identity and use authorized credential/management recovery; do not blindly remint or replay.`);
904
971
  }
905
972
  throw new PairingError('REDEEM_REJECTED', `Redemption failed (${serverError || `HTTP ${res.status}`}). Re-run to request a fresh enrollment.`);
906
973
  }
@@ -911,8 +978,9 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
911
978
  * uses). Polls until the identity is accepted or the timeout expires.
912
979
  * Success here proves the env credentials actually authenticate; only then
913
980
  * may the CLI print a success message.
914
- * Device-channel endpoints live on the agent edge (`apiBase`/`NC_API_URL`),
915
- * never on the auth host. `baseUrl` stays accepted for backward
981
+ * Device-channel endpoints use the supplied agent destination. Resume callers
982
+ * must supply the stored NC_SERVER_URL, not the fresh-enrollment API default.
983
+ * `baseUrl` stays accepted for backward
916
984
  * compatibility but an explicit `apiBase` (or `NC_API_URL`/default agent
917
985
  * origin when neither is given) wins.
918
986
  * @param {{ baseUrl?: string, apiBase?: string, hostId: string, hostToken: string, privateKey: import('node:crypto').KeyObject, timeoutMs?: number, intervalMs?: number }} options
@@ -976,11 +1044,11 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
976
1044
  * (`/releases/client/<version>/bootstrap.tar.gz`) stays anonymous. The
977
1045
  * proof binds method GET + the full request URL + the redemption-issued
978
1046
  * client token, exactly like redeemEnrollment.
979
- * @param {{ ncRoot?: string, updatesUrl?: string, serverUrl?: string, release?: Record<string, unknown>|null, installMode?: 'system'|'user', runInstaller?: (scriptPath: string, ncRoot: string) => void, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject }} options
1047
+ * @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 }} options
980
1048
  */
981
- export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl = undefined, serverUrl = undefined, release = undefined, installMode = undefined, runInstaller = undefined, hostId = undefined, hostToken = undefined, privateKey = undefined } = {}) {
1049
+ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl = undefined, serverUrl = undefined, release = undefined, installMode = undefined, rootConsent = false, runInstaller = undefined, hostId = undefined, hostToken = undefined, privateKey = undefined } = {}) {
982
1050
  ncRoot = ncRoot || (installMode ? getNcRootForMode(installMode) : getNcRoot());
983
- const modeEnv = installMode ? installerEnvForMode(installMode) : {};
1051
+ const modeEnv = { ...(installMode ? installerEnvForMode(installMode) : {}), NC_ROOT_COMMAND_CONSENT: rootConsent === true ? 'yes' : 'no' };
984
1052
  const invokeInstaller =
985
1053
  runInstaller || ((scriptPath, root) => runInstallerScript(scriptPath, root, modeEnv));
986
1054
  const root = ncRoot;
@@ -1075,6 +1143,21 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1075
1143
  tarballUrl = `${defaultBaseUrl}/releases/client/${encodeURIComponent(version)}/bootstrap.tar.gz`;
1076
1144
  }
1077
1145
 
1146
+ const expectedHash = activeRelease.bootstrap_sha256 ?? activeRelease.sha256;
1147
+ if (typeof expectedHash !== 'string' || expectedHash.length !== 64 || !/^[a-f0-9]{64}$/i.test(expectedHash)) {
1148
+ throw new PairingError('INSTALL_MANIFEST_ERROR', 'Agent release manifest must provide a valid sha256 before downloading or installing.');
1149
+ }
1150
+ const expectedSizeRaw = activeRelease.bootstrap_size_bytes ?? activeRelease.size_bytes;
1151
+ const expectedSize =
1152
+ typeof expectedSizeRaw === 'number'
1153
+ ? expectedSizeRaw
1154
+ : typeof expectedSizeRaw === 'string' && expectedSizeRaw.length > 0 && !/[^0-9]/.test(expectedSizeRaw)
1155
+ ? Number(expectedSizeRaw)
1156
+ : NaN;
1157
+ if (!Number.isSafeInteger(expectedSize) || expectedSize <= 0) {
1158
+ throw new PairingError('INSTALL_MANIFEST_ERROR', 'Agent release manifest must provide a valid positive integer size before downloading or installing.');
1159
+ }
1160
+
1078
1161
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nc-install-'));
1079
1162
  const archivePath = path.join(tempDir, 'bootstrap.tar.gz');
1080
1163
 
@@ -1102,35 +1185,25 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1102
1185
  throw new PairingError('INSTALL_DOWNLOAD_ERROR', `Could not download the agent package: ${err.message}`);
1103
1186
  }
1104
1187
 
1105
- const expectedHash = activeRelease.bootstrap_sha256 || activeRelease.sha256;
1106
- const expectedSizeRaw = activeRelease.bootstrap_size_bytes ?? activeRelease.size_bytes;
1107
- const expectedSize =
1108
- expectedSizeRaw === undefined || expectedSizeRaw === null ? null : Number(expectedSizeRaw);
1109
1188
  const archiveBytes = fs.readFileSync(archivePath);
1110
- if (expectedSize !== null && Number.isFinite(expectedSize)) {
1111
- if (archiveBytes.length !== expectedSize) {
1112
- try {
1113
- fs.unlinkSync(archivePath);
1114
- } catch {}
1115
- throw new PairingError(
1116
- 'INSTALL_MANIFEST_ERROR',
1117
- `Agent package integrity check failed: size mismatch for ${tarballUrl} (expected ${expectedSize} bytes, got ${archiveBytes.length}). The download was discarded; nothing was installed.`
1118
- );
1119
- }
1189
+ if (archiveBytes.length !== expectedSize) {
1190
+ try {
1191
+ fs.unlinkSync(archivePath);
1192
+ } catch {}
1193
+ throw new PairingError(
1194
+ 'INSTALL_MANIFEST_ERROR',
1195
+ `Agent package integrity check failed: size mismatch for ${tarballUrl} (expected ${expectedSize} bytes, got ${archiveBytes.length}). The download was discarded; nothing was installed.`
1196
+ );
1120
1197
  }
1121
- if (expectedHash && /^[a-f0-9]{64}$/i.test(String(expectedHash))) {
1122
- const actualHash = crypto.createHash('sha256').update(archiveBytes).digest('hex');
1123
- if (actualHash.toLowerCase() !== String(expectedHash).toLowerCase()) {
1124
- try {
1125
- fs.unlinkSync(archivePath);
1126
- } catch {}
1127
- throw new PairingError(
1128
- 'INSTALL_MANIFEST_ERROR',
1129
- `Agent package integrity check failed: sha256 mismatch for ${tarballUrl}. The download was discarded; nothing was installed.`
1130
- );
1131
- }
1132
- } else {
1133
- console.warn('⚠️ Release manifest lacks a valid sha256; skipping archive integrity check.');
1198
+ const actualHash = crypto.createHash('sha256').update(archiveBytes).digest('hex');
1199
+ if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) {
1200
+ try {
1201
+ fs.unlinkSync(archivePath);
1202
+ } catch {}
1203
+ throw new PairingError(
1204
+ 'INSTALL_MANIFEST_ERROR',
1205
+ `Agent package integrity check failed: sha256 mismatch for ${tarballUrl}. The download was discarded; nothing was installed.`
1206
+ );
1134
1207
  }
1135
1208
 
1136
1209
  console.log('📂 Extracting agent archive...');
@@ -1168,9 +1241,9 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1168
1241
  * directly. `installMode` is forwarded to the installer via NC_MODE.
1169
1242
  * Host split: enrollment (OAuth/redeem) uses the auth origin (`baseUrl` /
1170
1243
  * `NC_BASE_URL`); device-channel verification (`/api/clients/*`) uses the
1171
- * agent origin (`apiBase` / `NC_API_URL`). `NC_SERVER_URL` stays the agent
1172
- * server address and never selects the verify host.
1173
- * @param {{ baseUrl?: string, apiBase?: string, oauthToken?: string|null, enrollment?: { host_id: string, enrollment_token: string, server_url?: string }|null, ncRoot?: string, serverUrl?: string, deviceName?: string, platform?: string, installMode?: 'system'|'user', verifyTimeoutMs?: number, verifyIntervalMs?: number, hooks?: { installAgent?: (options: { ncRoot: string, serverUrl: string, release?: Record<string, unknown>|null, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject }) => Promise<void> } }} options
1244
+ * agent origin (`apiBase` / `NC_API_URL`) for fresh enrollment. Local resume
1245
+ * binds verification and installation requests to the stored NC_SERVER_URL.
1246
+ * @param {{ baseUrl?: string, apiBase?: string, oauthToken?: string|null, enrollment?: { host_id: string, enrollment_token: string, server_url?: string }|null, ncRoot?: string, serverUrl?: string, deviceName?: string, platform?: string, installMode?: 'system'|'user', rootConsent?: boolean|(() => Promise<boolean>), browserBrand?: string, verifyTimeoutMs?: number, verifyIntervalMs?: number, hooks?: { installAgent?: (options: { ncRoot: string, serverUrl: string, release?: Record<string, unknown>|null, hostId?: string, hostToken?: string, rootConsent?: boolean, privateKey?: import('node:crypto').KeyObject }) => Promise<void> } }} options
1174
1247
  */
1175
1248
  export async function pairDeviceAndInstall({
1176
1249
  baseUrl: baseUrlParam = undefined,
@@ -1182,6 +1255,8 @@ export async function pairDeviceAndInstall({
1182
1255
  deviceName = os.hostname(),
1183
1256
  platform = mapEnrollPlatform(),
1184
1257
  installMode = undefined,
1258
+ rootConsent = false,
1259
+ browserBrand = captureBrowserDefaultBrand(),
1185
1260
  verifyTimeoutMs = 45000,
1186
1261
  verifyIntervalMs = 2000,
1187
1262
  hooks = {}
@@ -1208,15 +1283,20 @@ export async function pairDeviceAndInstall({
1208
1283
  const installAgent =
1209
1284
  hooks.installAgent ||
1210
1285
  (async ({ ncRoot: installRoot, serverUrl: installServerUrl, release: installRelease = undefined, hostId: installHostId = undefined, hostToken: installHostToken = undefined, privateKey: installPrivateKey = undefined }) => {
1211
- await downloadAndInstallAgent({ ncRoot: installRoot, serverUrl: installServerUrl, installMode, release: installRelease, hostId: installHostId, hostToken: installHostToken, privateKey: installPrivateKey });
1286
+ await downloadAndInstallAgent({ ncRoot: installRoot, serverUrl: installServerUrl, installMode, rootConsent: selectedRootConsent, release: installRelease, hostId: installHostId, hostToken: installHostToken, privateKey: installPrivateKey });
1212
1287
  });
1288
+ let selectedRootConsent = false;
1213
1289
 
1214
1290
  // Fast path — idempotent resume: usable host credentials already on disk
1215
1291
  // are verified over the device channel and the installer is resumed. No
1216
1292
  // new enrollment is minted, so a re-run can never orphan a host or hit
1217
1293
  // ENROLLMENT_ALREADY_USED for a token it no longer holds.
1218
1294
  const localEnv = loadValidHostEnv(envPath);
1295
+ assertMatchingHostIdentity(localEnv, enrollment?.host_id);
1219
1296
  if (localEnv) {
1297
+ // Match the agent's HTTP base normalization without dropping a supported
1298
+ // path prefix or redirecting installed credentials to NC_API_URL.
1299
+ const storedServerUrl = localEnv.NC_SERVER_URL.replace(/\/$/, '');
1220
1300
  let localKey = null;
1221
1301
  try {
1222
1302
  localKey = loadDeviceKeyPair(keyPath);
@@ -1227,7 +1307,7 @@ export async function pairDeviceAndInstall({
1227
1307
  const prior = readPairState(statePath);
1228
1308
  try {
1229
1309
  await verifyHostRegistration({
1230
- apiBase: apiOrigin,
1310
+ apiBase: localEnv.NC_SERVER_URL,
1231
1311
  hostId: localEnv.NC_CLIENT_ID,
1232
1312
  hostToken: localEnv.NC_CLIENT_TOKEN,
1233
1313
  privateKey: localKey.privateKey,
@@ -1240,10 +1320,10 @@ export async function pairDeviceAndInstall({
1240
1320
  return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
1241
1321
  }
1242
1322
  console.log(`🔁 Resuming installation for host ${localEnv.NC_CLIENT_ID} (stored credentials verified)...`);
1243
- await installAgent({ ncRoot: root, serverUrl: localEnv.NC_SERVER_URL, hostId: localEnv.NC_CLIENT_ID, hostToken: localEnv.NC_CLIENT_TOKEN, privateKey: localKey.privateKey });
1323
+ await installAgent({ ncRoot: root, serverUrl: storedServerUrl, hostId: localEnv.NC_CLIENT_ID, hostToken: localEnv.NC_CLIENT_TOKEN, privateKey: localKey.privateKey });
1244
1324
  writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'installed' });
1245
1325
  await verifyHostRegistration({
1246
- apiBase: apiOrigin,
1326
+ apiBase: localEnv.NC_SERVER_URL,
1247
1327
  hostId: localEnv.NC_CLIENT_ID,
1248
1328
  hostToken: localEnv.NC_CLIENT_TOKEN,
1249
1329
  privateKey: localKey.privateKey,
@@ -1253,23 +1333,25 @@ export async function pairDeviceAndInstall({
1253
1333
  writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'verified' });
1254
1334
  return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
1255
1335
  } catch (err) {
1256
- // Only a definitive 404 (VERIFY_FAILED) falls through to a fresh
1257
- // enrollment. A timeout (VERIFY_TIMEOUT) rethrows with the timeout
1258
- // guidance: wiping stored credentials on a transient stall would
1259
- // orphan a healthy host identity. The caller reports the stored-
1260
- // credentials connectivity hint via suggestedActionFor.
1336
+ // Failure at the bound destination is not permission to enroll on
1337
+ // another server or replace this installed host identity.
1261
1338
  if (err instanceof PairingError && err.code === 'VERIFY_FAILED') {
1262
- // Stored credentials no longer authenticate (rotated, revoked, or
1263
- // server reset): fall through to a fresh enrollment, which
1264
- // overwrites the stale files below.
1265
- console.warn(`⚠️ Stored host credentials no longer verify (${err.code}); requesting a fresh enrollment...`);
1266
- } else {
1267
- throw err;
1339
+ throw new PairingError('VERIFY_FAILED', 'The existing host is not registered at its stored server. Preserve host/device credentials and use the existing installation maintenance flow.');
1268
1340
  }
1341
+ throw err;
1269
1342
  }
1270
1343
  }
1271
1344
  }
1272
1345
 
1346
+ const incomplete = readPairState(statePath);
1347
+ if (incomplete?.phase === 'enrolled' && HOST_ID_RE.test(String(incomplete.host_id || ''))) {
1348
+ throw new PairingError('REDEEM_RECOVERY_REQUIRED', 'A previous redemption may have committed without a saved response. Preserve local host/device identity; recover using authorized existing credentials or target management. No automatic remint or initialization replay was attempted.');
1349
+ }
1350
+ // Authentication/preflight precede this fresh-only question. Ask before
1351
+ // minting the five-minute token, or before redeeming an already running one.
1352
+ // Reuse this one literal answer for both signed intent and local installer.
1353
+ selectedRootConsent = (typeof rootConsent === 'function' ? await rootConsent() : rootConsent) === true;
1354
+
1273
1355
  // Fresh enrollment with the OAuth USER token (user authorization only),
1274
1356
  // or the caller-supplied enrollment from `setup <host_id> <token>`.
1275
1357
  let activeEnrollment = enrollment;
@@ -1291,14 +1373,17 @@ export async function pairDeviceAndInstall({
1291
1373
  let agentVersion = null;
1292
1374
  /** @type {Record<string, unknown>|null} */
1293
1375
  let redemptionRelease = null;
1294
- try {
1376
+ let executionInitialization = null;
1377
+ {
1295
1378
  const redemption = await redeemEnrollment({
1296
1379
  baseUrl: origin,
1297
1380
  hostId,
1298
1381
  enrollmentToken: activeEnrollment.enrollment_token,
1299
- keyPair
1382
+ keyPair,
1383
+ initialPermission: selectedRootConsent ? 'admin' : 'user'
1300
1384
  });
1301
1385
  hostToken = redemption.client_token;
1386
+ executionInitialization = redemption.execution_initialization ?? null;
1302
1387
  if (redemption.release && typeof redemption.release === 'object') {
1303
1388
  redemptionRelease = /** @type {Record<string, unknown>} */ (redemption.release);
1304
1389
  }
@@ -1306,30 +1391,16 @@ export async function pairDeviceAndInstall({
1306
1391
  agentVersion = redemption.release.version;
1307
1392
  console.log(`📦 Release: ${agentVersion}`);
1308
1393
  }
1309
- } catch (err) {
1310
- if (err instanceof PairingError && err.code === 'ENROLLMENT_ALREADY_USED') {
1311
- // A previous run consumed this token and stored the credentials:
1312
- // resume from them instead of failing on the single-use rule.
1313
- const again = loadValidHostEnv(envPath);
1314
- if (again && again.NC_CLIENT_ID === hostId) {
1315
- console.log('🔁 Enrollment was already redeemed by an earlier run — resuming from stored credentials...');
1316
- hostToken = again.NC_CLIENT_TOKEN;
1317
- } else {
1318
- throw err;
1319
- }
1320
- } else {
1321
- throw err;
1322
- }
1323
1394
  }
1324
1395
 
1325
1396
  // Host credentials ONLY — the OAuth token is never written here.
1326
1397
  console.log('📝 Creating configuration file...');
1327
- writeHostEnvFile(envPath, buildHostEnvContent({ serverUrl: enrollServerUrl, hostId, hostToken, clientName: deviceName }));
1398
+ writeHostEnvFile(envPath, buildHostEnvContent({ serverUrl: enrollServerUrl, hostId, hostToken, clientName: deviceName, browserBrand }));
1328
1399
  console.log(`📝 Configuration saved to ${envPath}`);
1329
1400
  writePairState(statePath, { host_id: hostId, phase: 'redeemed' });
1330
1401
 
1331
1402
  console.log('🚀 Installing agent...');
1332
- await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey });
1403
+ await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey, rootConsent: selectedRootConsent });
1333
1404
  writePairState(statePath, { host_id: hostId, phase: 'installed' });
1334
1405
 
1335
1406
  console.log('🔍 Verifying host registration...');
@@ -1342,7 +1413,8 @@ export async function pairDeviceAndInstall({
1342
1413
  intervalMs: verifyIntervalMs
1343
1414
  });
1344
1415
  writePairState(statePath, { host_id: hostId, phase: 'verified' });
1345
- return { hostId, serverUrl: enrollServerUrl, resumed: false, version: agentVersion };
1416
+ return { hostId, serverUrl: enrollServerUrl, resumed: false, version: agentVersion, rootConsent: selectedRootConsent,
1417
+ privilegeReadiness: selectedRootConsent ? 'pending' : 'unavailable', executionInitialization };
1346
1418
  }
1347
1419
 
1348
1420
  /**
@@ -1378,7 +1450,9 @@ function printCliHelp() {
1378
1450
  '',
1379
1451
  'Usage:',
1380
1452
  ' nodecommand pair this machine via the browser device flow',
1453
+ ' nodecommand <host_id> install with a dashboard host id (prompts for the token, hidden input)',
1381
1454
  ' nodecommand setup [host_id token] install the agent (auto system/user mode; no args = browser device flow)',
1455
+ ' nodecommand [--allow-root-commands] explicitly request privileged preparation (default No; independent binding required)',
1382
1456
  ' nodecommand uninstall [--yes] remove the agent from the canonical root (alias: remove)',
1383
1457
  ' nodecommand remove [--yes] uninstall the agent (asks for confirmation unless --yes)',
1384
1458
  ' nodecommand --diagnostics preview a redacted diagnostics bundle (dry-run, writes nothing)',
@@ -1404,8 +1478,8 @@ function printCliHelp() {
1404
1478
 
1405
1479
  /**
1406
1480
  * True when the invocation is exactly the bare enrollment form
1407
- * `nodecommand <host_id> <token>` — the shape emitted by the dashboard and
1408
- * the control-plane install_command (`npx node-command <host_id> <token>`).
1481
+ * `nodecommand <host_id> <token>` — the legacy positional shape accepted for
1482
+ * backward compatibility with already-published dashboards and docs.
1409
1483
  *
1410
1484
  * Exactly two non-flag arguments with plausible shapes route to the same path
1411
1485
  * as `setup <host_id> <token>`. The guards stay deliberately loose (host id
@@ -1425,6 +1499,25 @@ export function isBareEnrollmentInvocation(args = []) {
1425
1499
  return hostId.length >= 3 && token.length >= 8;
1426
1500
  }
1427
1501
 
1502
+ /**
1503
+ * True when the invocation is exactly the token-free enrollment form
1504
+ * `nodecommand <host_id>` — the canonical shape the control-plane
1505
+ * install_command emits. The single-use token is NOT accepted on the command
1506
+ * line for this form: runSetup prompts for it with masked (non-echoing)
1507
+ * input, so it never lands in argv, shell history, `ps`, terminal
1508
+ * recordings, or telemetry.
1509
+ *
1510
+ * Only a canonical host id (`nc_<64hex>`) qualifies: a stray single word
1511
+ * keeps its previous behavior (browser device flow) instead of being
1512
+ * misread as an enrollment.
1513
+ * @param {string[]} args
1514
+ */
1515
+ export function isBareHostIdInvocation(args = []) {
1516
+ if (!Array.isArray(args) || args.length !== 1) return false;
1517
+ const [hostId] = args;
1518
+ return typeof hostId === 'string' && !hostId.startsWith('-') && HOST_ID_RE.test(hostId);
1519
+ }
1520
+
1428
1521
  async function main() {
1429
1522
  const cliArgs = process.argv.slice(2);
1430
1523
  // Subcommands route FIRST: otherwise `setup --diagnostics` or a stray flag
@@ -1462,10 +1555,23 @@ async function main() {
1462
1555
  if (!result.ok) process.exit(1);
1463
1556
  return;
1464
1557
  }
1465
- // Bare enrollment: route `nodecommand <host_id> <token>` (the form the
1466
- // dashboard and install_command emit) to the `setup <host_id> <token>`
1467
- // path instead of silently ignoring the arguments in the device flow.
1468
- if (isBareEnrollmentInvocation(cliArgs)) {
1558
+ // Bare enrollment: route `nodecommand <host_id> <token>` (legacy positional
1559
+ // form the dashboard and install_command used to emit) to the `setup
1560
+ // <host_id> <token>` path instead of silently ignoring the arguments in the
1561
+ // device flow.
1562
+ const enrollmentArgs = cliArgs.filter(arg => arg !== '--allow-root-commands');
1563
+ if (isBareEnrollmentInvocation(enrollmentArgs)) {
1564
+ await runSetup(cliArgs);
1565
+ return;
1566
+ }
1567
+ // Canonical token-free form: the single-use token is typed at a masked
1568
+ // interactive prompt inside runSetup, so it never enters argv (shell
1569
+ // history, `ps`, recordings, telemetry).
1570
+ if (isBareHostIdInvocation(enrollmentArgs)) {
1571
+ await runSetup(cliArgs, { allowHostIdOnly: true });
1572
+ return;
1573
+ }
1574
+ if (enrollmentArgs.length === 0) {
1469
1575
  await runSetup(cliArgs);
1470
1576
  return;
1471
1577
  }
@@ -1480,6 +1586,8 @@ async function main() {
1480
1586
  */
1481
1587
  export function suggestedActionFor(code) {
1482
1588
  switch (code) {
1589
+ case 'HOST_ID_MISMATCH':
1590
+ return 'Preserve the existing credentials and use the existing host maintenance flow with its current mode/root. Check the requested host id and installation target before trying enrollment again.';
1483
1591
  case 'ENROLL_NO_OAUTH_TOKEN':
1484
1592
  return 'Re-run `nodecommand setup` without arguments and approve the browser device flow.';
1485
1593
  case 'ENROLL_UNAUTHORIZED':
@@ -1490,11 +1598,16 @@ export function suggestedActionFor(code) {
1490
1598
  case 'PLAN_HOST_LIMIT_EXCEEDED':
1491
1599
  return 'Upgrade the workspace plan or remove an old host, then re-run.';
1492
1600
  case 'ENROLLMENT_ALREADY_USED':
1493
- return 'Re-run `nodecommand setup` without arguments; it resumes from the stored host credentials.';
1601
+ case 'REDEEM_RECOVERY_REQUIRED':
1602
+ case 'REDEEM_NETWORK_ERROR':
1603
+ case 'REDEEM_UNAUTHORIZED':
1604
+ return 'Preserve host/device identity. Resume only with authorized stored host credentials; otherwise use authenticated target-management recovery. Do not automatically remint or replay initialization after an uncertain redemption.';
1494
1605
  case 'ENROLLMENT_EXPIRED':
1495
1606
  case 'INVALID_ENROLL_TOKEN':
1496
1607
  case 'INVALID_HOST_ID':
1497
1608
  return 'Re-run `nodecommand setup` without arguments for a fresh enrollment.';
1609
+ case 'ENROLL_TOKEN_REQUIRED':
1610
+ return 'Re-run in an interactive terminal so the token can be typed with hidden input, or pass the token as the second argument.';
1498
1611
  case 'INSTALL_MANIFEST_ERROR':
1499
1612
  return 'The update manifest is admin-only; run with an enrollment (setup <host_id> <token>) so the release ships with the redemption, or check network connectivity to the agent release host and re-run; stored credentials are reused.';
1500
1613
  case 'INSTALL_DOWNLOAD_ERROR':
@@ -1553,15 +1666,53 @@ function getCliVersion() {
1553
1666
  /**
1554
1667
  * Prints the one-line install summary required after a successful setup:
1555
1668
  * host id, install mode, CLI version, and verification status.
1556
- * @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null }} options
1669
+ * @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null, rootConsent?: boolean, ncRoot?: string, executionInitialization?: any }} options
1557
1670
  */
1558
- function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null }) {
1671
+ export function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null, rootConsent = false, ncRoot = undefined, executionInitialization = null }) {
1559
1672
  console.log(`\n✅ Setup complete for host ${hostId}${resumed ? ' (resumed)' : ''}.`);
1560
1673
  if (installMode) console.log(`installed as ${installMode}`);
1561
1674
  if (version) console.log(`agent version: ${version}`);
1562
1675
  console.log(`cli version: ${getCliVersion()}`);
1563
1676
  console.log(`server: ${serverUrl}`);
1564
1677
  console.log('status: agent credentials verified online');
1678
+ if (ncRoot) console.log(`installation root: ${ncRoot}`);
1679
+ console.log('OS execution identity: not verified by this credential check; native service/readiness verification required');
1680
+ console.log('agent runtime: installer-selected; not verified by this credential check (customer global Node/PATH unchanged)');
1681
+ if (resumed) {
1682
+ console.log('execution permission: existing choice preserved; no privilege maintenance performed');
1683
+ } else {
1684
+ console.log(`root command consent: ${rootConsent === true ? 'Yes' : 'No'}`);
1685
+ console.log(`local privilege readiness: ${rootConsent === true ? 'pending independent management approval and protected helper binding' : 'unavailable (not requested)'}`);
1686
+ const receipt = executionInitialization;
1687
+ const fields = ['outcome', 'reason', 'subject_user_id', 'permission'];
1688
+ const reasons = { requested: ['admin_requested'], preserved: ['existing_permission_preserved', 'existing_host_preserved'],
1689
+ defaulted: ['default_user', 'verified_origin_unavailable', 'origin_authority_changed', 'origin_not_owner', 'origin_inactive'] };
1690
+ const closed = receipt && typeof receipt === 'object' && !Array.isArray(receipt) && Object.keys(receipt).length === fields.length &&
1691
+ fields.every(field => Object.hasOwn(receipt, field)) && typeof receipt.outcome === 'string' && typeof receipt.reason === 'string' &&
1692
+ Object.hasOwn(reasons, receipt.outcome) && reasons[receipt.outcome].includes(receipt.reason) &&
1693
+ (receipt.subject_user_id === null || (typeof receipt.subject_user_id === 'string' && receipt.subject_user_id.length > 0)) &&
1694
+ (receipt.subject_user_id !== null || receipt.permission === null);
1695
+ const permission = closed ? receipt.permission : null;
1696
+ const projectionFields = ['requested_permission', 'applied_permission', 'permission_generation', 'permission_revision', 'applied_revision', 'enforcement_state', 'changed_by', 'updated_at'];
1697
+ const projected = permission && typeof permission === 'object' && !Array.isArray(permission) &&
1698
+ Object.keys(permission).length === projectionFields.length && projectionFields.every(field => Object.hasOwn(permission, field)) &&
1699
+ ['user', 'admin'].includes(permission.requested_permission) && [null, 'user', 'admin'].includes(permission.applied_permission) &&
1700
+ [permission.permission_revision, permission.applied_revision].every(value => value === null || (Number.isSafeInteger(value) && value >= 0)) &&
1701
+ [permission.permission_generation, permission.changed_by].every(value => value === null || typeof value === 'string') &&
1702
+ typeof permission.enforcement_state === 'string' && (permission.updated_at === null || typeof permission.updated_at === 'string' || Number.isFinite(permission.updated_at));
1703
+ if (!closed || (permission !== null && !projected)) {
1704
+ console.log('personal execution initialization: unknown (server receipt unavailable or invalid); no User/Admin initialization claimed');
1705
+ } else {
1706
+ console.log(`personal execution initialization: ${receipt.outcome} (${receipt.reason})`);
1707
+ if (!projected) console.log('personal execution permission: unknown (no verified applicable personal record reported)');
1708
+ else {
1709
+ const label = value => value === 'admin' ? 'Admin' : value === 'user' ? 'User' : 'unknown';
1710
+ const state = permission.enforcement_state === 'pending' ? 'pending' : permission.enforcement_state === 'applied' ? 'server-reported applied' : 'not verified';
1711
+ console.log(`personal execution permission: server requested ${label(permission.requested_permission)}; enforcement ${state}`);
1712
+ console.log(`server-reported applied personal permission: ${label(permission.applied_permission)}; local Root enforcement not verified by setup`);
1713
+ }
1714
+ }
1715
+ }
1565
1716
  }
1566
1717
 
1567
1718
  /**
@@ -1584,17 +1735,20 @@ const WINDOWS_ELEVATION_UNINSTALL_DECLINED =
1584
1735
  * console is visible) and returns the spawnSync result. Never throws: a
1585
1736
  * missing powershell or a declined prompt resolves to null/non-zero.
1586
1737
  */
1587
- function requestWindowsElevation() {
1588
- const args = [...process.argv.slice(1)];
1738
+ function requestWindowsElevation(browserBrand = undefined) {
1739
+ const originalArgs = [...process.argv.slice(1)];
1740
+ const args = browserBrand === undefined ? originalArgs : browserBrandReexecArgs(originalArgs, browserBrand);
1741
+ const literal = value => "'" + value.replace(/'/g, "''") + "'";
1742
+ const quote = value => '"' + String(value).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1') + '"';
1743
+ const script = `$p=Start-Process -FilePath ${literal(process.execPath)} -ArgumentList ${literal(args.map(quote).join(' '))} -Verb RunAs -Wait -PassThru; exit $p.ExitCode`;
1589
1744
  try {
1590
1745
  return child_process.spawnSync(
1591
- 'powershell',
1746
+ 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
1592
1747
  [
1593
1748
  '-NoProfile',
1594
- '-Command',
1595
- `Start-Process -FilePath "${process.execPath}" -ArgumentList ${args
1596
- .map((a) => `"${String(a).replace(/"/g, '`"')}"`)
1597
- .join(',')} -Verb RunAs -Wait`
1749
+ '-NonInteractive',
1750
+ '-EncodedCommand',
1751
+ Buffer.from(script, 'utf16le').toString('base64')
1598
1752
  ],
1599
1753
  { stdio: 'inherit' }
1600
1754
  );
@@ -1607,53 +1761,172 @@ function requestWindowsElevation() {
1607
1761
  * `nodecommand setup [host_id enrollment_token]`.
1608
1762
  *
1609
1763
  * Mode is automatic (never prompted): elevated processes install machine-wide.
1610
- * POSIX: a non-root admin requests sudo; a refusal falls back to a per-user
1764
+ * Fresh non-root macOS installs use the existing user LaunchAgent, including
1765
+ * administrator accounts; existing/conflicting state is checked first.
1766
+ * Linux: a non-root admin requests sudo; a refusal falls back to a per-user
1611
1767
  * install with a single-line capability notice. Windows: a standard shell
1612
1768
  * requests UAC elevation instead and hard-stops (exit 1) when elevation is
1613
1769
  * unavailable or declined — never a silent %LOCALAPPDATA% degradation.
1614
1770
  * Without arguments a fresh enrollment is requested via the browser device
1615
- * flow; with `host_id` + `token` (dashboard flow) the supplied enrollment is
1616
- * redeemed directly. Enrollment always goes through POST /api/enroll with the
1771
+ * flow; with `host_id` + `token` (legacy dashboard flow) the supplied
1772
+ * enrollment is redeemed directly; with a bare canonical `host_id`
1773
+ * (`allowHostIdOnly`, the canonical install_command form) the single-use
1774
+ * token is read from a masked interactive prompt and never touches argv.
1775
+ * Enrollment always goes through POST /api/enroll with the
1617
1776
  * OAuth user grant — the same tested chain as the default pairing flow — not
1618
1777
  * through the dashboard shell snippets, because this CLI already embeds
1619
1778
  * download, install, and device-auth verification.
1620
1779
  * @param {string[]} args
1780
+ * @param {{ allowHostIdOnly?: boolean, promptToken?: (io: { input: import('node:stream').Readable, output: import('node:stream').Writable }) => Promise<string> }} [options]
1621
1781
  */
1622
- export async function runSetup(args = []) {
1782
+ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken = promptEnrollmentToken } = {}) {
1623
1783
  const positionals = args.filter((a) => !a.startsWith('-'));
1624
1784
  if (positionals.includes('help') || args.includes('--help') || args.includes('-h')) {
1625
1785
  console.log(
1626
1786
  [
1627
1787
  'Usage:',
1628
1788
  ' nodecommand setup install via the browser device flow (auto system/user mode)',
1629
- ' nodecommand setup <host_id> <token> install with a dashboard enrollment token',
1789
+ ' nodecommand setup <host_id> <token> install with a dashboard enrollment token (legacy positional form)',
1790
+ '',
1791
+ ' nodecommand setup --maintain-identity <host_id> explicit root-only selected identity migration (NC_ROOT required)',
1792
+ ' nodecommand setup --configure-helper-binding <host_id> <management_response.json> <signed_challenge.json>',
1793
+ ' --activate-initial-binding explicitly activates only the local initial binding and restarts unsynchronized.',
1630
1794
  '',
1631
- 'The install mode is chosen automatically: administrator/root installs',
1632
- 'machine-wide (system), anything else installs per-user (user).'
1795
+ 'macOS: fresh non-root installs use the per-user LaunchAgent without sudo;',
1796
+ 'explicit root uses system mode. Existing roots/modes are preserved or setup stops.',
1797
+ 'Windows/Linux retain their existing automatic system/user elevation behavior.',
1798
+ 'Service installation authorization does not allow root commands through chat.',
1799
+ 'New setup asks: Allow root/administrator commands through Node Command? [y/N]',
1800
+ 'Noninteractive setup defaults to No; --allow-root-commands explicitly requests preparation.',
1801
+ 'Admin stays unavailable until protected helper binding and management approval are verified.'
1633
1802
  ].join('\n')
1634
1803
  );
1635
1804
  return;
1636
1805
  }
1637
- assertKnownFlags(args, ['--help', '-h'], 'setup');
1806
+ assertKnownFlags(args, ['--help', '-h', '--allow-root-commands', '--maintain-identity', '--configure-helper-binding', '--activate-initial-binding'], 'setup');
1807
+ if (args.includes('--configure-helper-binding')) {
1808
+ if (positionals.length !== 3 || !process.env.NC_ROOT || args.includes('--allow-root-commands') || args.includes('--maintain-identity'))
1809
+ throw new PairingError('INVALID_SETUP_ARGS', 'Selected helper binding maintenance needs NC_ROOT, existing host ID, protected management response and signed challenge.');
1810
+ const producer = await loadProtectedHelperProducer(process.env.NC_ROOT);
1811
+ const files = producer.protectedHelperPaths(process.env.NC_ROOT);
1812
+ const selected = JSON.parse(fs.readFileSync(files.configFile, 'utf8'));
1813
+ assertMatchingHostIdentity({ NC_CLIENT_ID: selected.client_id }, positionals[0]);
1814
+ const result = await producer.configureProtectedHelperBinding({ root: process.env.NC_ROOT, bindingFile: path.resolve(positionals[1]),
1815
+ challengeFile: path.resolve(positionals[2]), activateLocally: args.includes('--activate-initial-binding') });
1816
+ console.log(JSON.stringify(result));
1817
+ console.log('Local binding preparation is not execution readiness. The control plane must consume fresh proof, then complete signed sync and helper ACK.');
1818
+ return;
1819
+ }
1820
+ if (args.includes('--activate-initial-binding')) throw new PairingError('INVALID_SETUP_ARGS', 'Initial binding activation requires explicit --configure-helper-binding maintenance.');
1821
+ if (args.includes('--maintain-identity')) {
1822
+ if (positionals.length !== 1 || !process.env.NC_ROOT || args.includes('--allow-root-commands')) throw new PairingError('INVALID_SETUP_ARGS', 'Selected identity maintenance needs NC_ROOT and exactly one existing host ID; root-command consent is a separate operation.');
1823
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw new PairingError('INSTALL_FAILED', 'Selected identity maintenance requires an interactive operator to confirm exact-host reconnection in authenticated management; no account or service was changed.');
1824
+ console.log('Selected OS identity maintenance preserves the existing personal permission and does not enable root commands through chat.');
1825
+ const result = await maintainLinuxIdentity({ ncRoot: process.env.NC_ROOT, hostId: positionals[0], operatorUser: process.env.NC_OPERATOR_USER,
1826
+ authorizeMaintenance: async facts => {
1827
+ await verifyHostRegistration({ apiBase: facts.server_url, hostId: facts.client_id, hostToken: facts.hostToken, privateKey: facts.privateKey });
1828
+ console.log(`Selected target: ${facts.client_id}; issuer ${facts.server_url}; root ${facts.ncRoot}; device SPKI sha256:${facts.device_key_id}; current OS account ${facts.old_user} (${facts.uid}:${facts.gid}).`);
1829
+ return confirmPrompt('Confirm this exact target is authorized for identity maintenance and is NOT held, in your authenticated management session? [y/N] ');
1830
+ },
1831
+ verifyReconnection: async facts => {
1832
+ await verifyHostRegistration({ apiBase: facts.server_url, hostId: facts.client_id, hostToken: facts.hostToken, privateKey: facts.privateKey });
1833
+ console.log(`Verify host ${facts.client_id} on ${facts.server_url}; device SPKI sha256:${facts.device_key_id}; OS identity nodecommand (${facts.uid}:${facts.gid}); root ${facts.ncRoot}.`);
1834
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
1835
+ return confirmPrompt('Confirm this exact host reconnected in your authenticated management session as nodecommand? [y/N] ');
1836
+ } });
1837
+ console.log(`Identity maintenance verified: ${result.client_id}, ${result.ncRoot}, nodecommand (${result.uid}:${result.gid}). Existing personal permission preserved; privileged support unavailable pending independent maintenance/binding.`);
1838
+ if (result.workspace) console.log(`Writable workspace: ${result.workspace}. Additional data/workspace roots require explicit authorized signed policy changes; no login-home ACLs, services or credentials were inherited.`);
1839
+ return;
1840
+ }
1638
1841
  // Arity is validated BEFORE any mode/root output: an invalid invocation
1639
1842
  // must fail without printing an installation plan it will not execute.
1640
- if (positionals.length !== 0 && positionals.length !== 2) {
1843
+ // One positional (host id) is valid only for the bare `nodecommand
1844
+ // <host_id>` form; `setup <host_id>` stays a loud usage error.
1845
+ const onePositionalOk = allowHostIdOnly && positionals.length === 1;
1846
+ if (positionals.length !== 0 && positionals.length !== 2 && !onePositionalOk) {
1641
1847
  console.error('error: [INVALID_SETUP_ARGS] setup takes either no arguments or `<host_id> <token>`.');
1642
1848
  console.error('suggested_action: Re-run as `nodecommand setup` or `nodecommand setup <host_id> <token>`.');
1643
1849
  process.exit(1);
1644
1850
  }
1851
+ const browserBrand = captureBrowserDefaultBrand();
1645
1852
  let installMode = decideInstallMode();
1646
- // A non-root admin must not silently fall back to a per-user install: the
1647
- // install root, service scope and privileged helper all differ. POSIX
1648
- // requests sudo and re-runs elevated; Windows requests UAC (Start-Process
1853
+ let ncRoot;
1854
+ let existingTarget;
1855
+ let existingEnv;
1856
+ let existingKey;
1857
+ let rootManagedPair;
1858
+ let protectedKeyContext;
1859
+ {
1860
+ try {
1861
+ const target = setupTarget({ mode: installMode });
1862
+ ({ mode: installMode, ncRoot } = target);
1863
+ existingTarget = target.existing;
1864
+ rootManagedPair = target.rootManagedPair;
1865
+ protectedKeyContext = (process.getuid?.() === 0 || process.geteuid?.() === 0) ? target.protectedInstallation : null;
1866
+ if (target.existing) {
1867
+ const localEnv = loadValidHostEnv(getEnvFilePath(ncRoot));
1868
+ if (!localEnv) throw new Error(`Existing host configuration at ${ncRoot} is incomplete; refusing a new enrollment or credential overwrite.`);
1869
+ if (target.clientId && localEnv.NC_CLIENT_ID !== target.clientId) throw new Error('Existing ordinary host identity conflicts with the protected helper selection; explicit Root maintenance is required.');
1870
+ assertMatchingHostIdentity(localEnv, positionals[0]);
1871
+ // Missing/unreadable/invalid keys are not permission to regenerate an
1872
+ // existing device identity. The normal verified resume flow is kept.
1873
+ const keyPath = getDeviceKeyPath(ncRoot);
1874
+ if (!protectedKeyContext) {
1875
+ const keyInfo = fs.lstatSync(keyPath);
1876
+ if (!keyInfo.isFile() || keyInfo.isSymbolicLink() || keyInfo.size > 65536 ||
1877
+ (os.platform() !== 'win32' && installMode === 'user' && keyInfo.uid !== process.getuid())) {
1878
+ throw new Error(`Existing device key at ${ncRoot} has an unverifiable type/owner; refusing identity replacement.`);
1879
+ }
1880
+ }
1881
+ try {
1882
+ existingKey = protectedKeyContext ? loadRootManagedPairKey(ncRoot, protectedKeyContext.ordinaryUid) : loadDeviceKeyPair(keyPath);
1883
+ } catch {
1884
+ throw new Error(`Existing device key at ${ncRoot} cannot be verified; preserve it for selected-installation maintenance.`);
1885
+ }
1886
+ existingEnv = localEnv;
1887
+ const appInfo = fs.lstatSync(path.join(ncRoot, 'app'));
1888
+ if (!appInfo.isDirectory() || appInfo.isSymbolicLink()) throw new Error(`Existing application at ${ncRoot} is incomplete; use selected-installation maintenance.`);
1889
+ }
1890
+ } catch (err) {
1891
+ if (err instanceof PairingError && err.code === 'HOST_ID_MISMATCH') {
1892
+ reportSetupError(err);
1893
+ } else {
1894
+ console.error(`error: [INSTALL_FAILED] ${err instanceof Error ? err.message : 'Cannot verify installation state.'}`);
1895
+ console.error('suggested_action: Preserve the existing mode/root; resolve conflicting state through its existing maintenance flow, with explicit administrator rights for system installs.');
1896
+ }
1897
+ process.exit(1);
1898
+ return;
1899
+ }
1900
+ }
1901
+ if (existingTarget) {
1902
+ // An installed tree is verification-only here. In particular, a legacy
1903
+ // tree without pairing-state metadata must not rerun OS installers that
1904
+ // can replace shared services/helpers. Repair is selected maintenance.
1905
+ console.log(`Verifying existing ${rootManagedPair ? 'Root-managed paired' : installMode} installation at ${ncRoot}, host: ${existingEnv.NC_CLIENT_ID}.`);
1906
+ if (rootManagedPair) console.log(`Local management: Root; ordinary service scope: ${rootManagedPair.serviceScope}; selected UID: ${rootManagedPair.ordinaryUid} (credential verification only; no user impersonation).`);
1907
+ try {
1908
+ await verifyHostRegistration({ apiBase: existingEnv.NC_SERVER_URL, hostId: existingEnv.NC_CLIENT_ID, hostToken: existingEnv.NC_CLIENT_TOKEN, privateKey: existingKey.privateKey });
1909
+ printSetupSuccess({ hostId: existingEnv.NC_CLIENT_ID, serverUrl: existingEnv.NC_SERVER_URL, installMode: rootManagedPair ? undefined : installMode, ncRoot, resumed: true });
1910
+ if (args.includes('--allow-root-commands')) console.warn('Existing root consent unchanged. Enabling root requires explicit selected-installation privileged maintenance and independent management approval.');
1911
+ } catch (err) {
1912
+ const code = err instanceof PairingError ? err.code : 'INSTALL_FAILED';
1913
+ const detail = code === 'VERIFY_FAILED' ? 'The existing host is not registered on the server; stored identity is unchanged.' : err.message;
1914
+ console.error(`error: [${code}] ${detail}`);
1915
+ console.error(`suggested_action: Preserve host/device credentials at ${ncRoot}; use this installation's maintenance flow to repair registration or connectivity. No new enrollment was requested.`);
1916
+ process.exit(1);
1917
+ }
1918
+ return;
1919
+ }
1920
+ // macOS has already resolved its user/system boundary without automatic
1921
+ // elevation. Linux requests sudo; Windows requests UAC (Start-Process
1649
1922
  // -Verb RunAs) so the install lands machine-wide at the canonical system
1650
1923
  // root. A refused or unavailable Windows elevation is a hard stop: the CLI
1651
1924
  // never silently degrades a system install into %LOCALAPPDATA%.
1652
- if (installMode === 'user' && canElevate() && !process.env.NC_ELEVATED) {
1925
+ if (os.platform() !== 'darwin' && installMode === 'user' && canElevate() && !process.env.NC_ELEVATED) {
1653
1926
  if (os.platform() === 'win32') {
1654
1927
  if (process.stdin.isTTY) {
1655
1928
  console.log('Admin rights required for a system-wide install. Requesting UAC elevation…');
1656
- const elevated = requestWindowsElevation();
1929
+ const elevated = requestWindowsElevation(browserBrand);
1657
1930
  if (elevated && elevated.status === 0) process.exit(0);
1658
1931
  }
1659
1932
  console.error(WINDOWS_ELEVATION_INSTALL_DECLINED);
@@ -1666,16 +1939,43 @@ export async function runSetup(args = []) {
1666
1939
  { stdio: 'inherit', env: { ...process.env, NC_ELEVATED: '1' } }
1667
1940
  );
1668
1941
  if (elevated && elevated.status === 0) process.exit(0);
1942
+ try {
1943
+ // A failed elevated child may already have created an installation.
1944
+ // Recheck before falling back rather than creating a competing agent.
1945
+ const target = setupTarget({ mode: installMode });
1946
+ if (target.existing) throw new Error(`An installation now exists at ${target.ncRoot}; preserve it through its maintenance flow.`);
1947
+ ncRoot = target.ncRoot;
1948
+ } catch (err) {
1949
+ console.error(`error: [INSTALL_FAILED] ${err.message}`);
1950
+ console.error('suggested_action: Preserve existing roots and select their maintenance flow; declining elevation does not authorize a second installation.');
1951
+ process.exit(1);
1952
+ return;
1953
+ }
1669
1954
  console.warn('Elevation unavailable or declined; continuing with a per-user install.');
1670
1955
  }
1671
1956
  // Mode-aware root: on win32 a system install lives in %ProgramData%\NC,
1672
1957
  // not the %LOCALAPPDATA% tree getNcRoot() returns, so credentials must be
1673
1958
  // written where the system installer will actually read them.
1674
- const ncRoot = getNcRootForMode(installMode);
1959
+ ncRoot = ncRoot || getNcRootForMode(installMode);
1675
1960
  console.log(installMode === 'system' ? `Installing as system (machine-wide), root: ${ncRoot}.` : `Installing as user (per-user), root: ${ncRoot}.`);
1676
1961
  const warning = modeWarningFor(installMode);
1677
1962
  if (warning) console.warn(warning);
1678
1963
 
1964
+ // Canonical token-free form: prompt AFTER elevation so the token is typed
1965
+ // on the elevated terminal and never enters argv (a sudo/UAC re-exec
1966
+ // forwards only the host id). Masked input: the token is not echoed.
1967
+ if (onePositionalOk) {
1968
+ try {
1969
+ assertMatchingHostIdentity(loadValidHostEnv(getEnvFilePath(ncRoot)), positionals[0]);
1970
+ const enrollmentToken = await promptToken({ input: process.stdin, output: process.stderr });
1971
+ positionals.push(String(enrollmentToken || ''));
1972
+ } catch (err) {
1973
+ reportSetupError(err);
1974
+ process.exit(1);
1975
+ }
1976
+ }
1977
+
1978
+ const rootConsent = () => promptRootCommandConsent({ explicitOptIn: args.includes('--allow-root-commands') });
1679
1979
  if (positionals.length === 2) {
1680
1980
  const [hostId, enrollmentToken] = positionals;
1681
1981
  try {
@@ -1683,17 +1983,96 @@ export async function runSetup(args = []) {
1683
1983
  baseUrl,
1684
1984
  apiBase,
1685
1985
  enrollment: { host_id: hostId, enrollment_token: enrollmentToken },
1986
+ browserBrand,
1686
1987
  installMode,
1988
+ rootConsent,
1687
1989
  ncRoot
1688
1990
  });
1689
- printSetupSuccess({ hostId: result.hostId, serverUrl: result.serverUrl, installMode, resumed: result.resumed, version: result.version });
1991
+ printSetupSuccess({ ...result, installMode, ncRoot });
1690
1992
  } catch (err) {
1691
1993
  reportSetupError(err);
1692
1994
  process.exit(1);
1693
1995
  }
1694
1996
  return;
1695
1997
  }
1696
- await runDeviceFlow({ installMode, ncRoot });
1998
+ await runDeviceFlow({ installMode, ncRoot, browserBrand, rootConsent });
1999
+ }
2000
+
2001
+ export const ROOT_COMMAND_CONSENT_PROMPT = 'Allow root/administrator commands through Node Command? [y/N]';
2002
+
2003
+ export async function promptRootCommandConsent({ explicitOptIn = false, input = process.stdin, output = process.stdout, confirm = confirmPrompt } = {}) {
2004
+ if (explicitOptIn === true) return true;
2005
+ if (!input.isTTY || !output.isTTY) return false;
2006
+ return (await confirm(ROOT_COMMAND_CONSENT_PROMPT + ' ')) === true;
2007
+ }
2008
+
2009
+ /**
2010
+ * Prompt text for the masked enrollment-token prompt. Printed to stderr (the
2011
+ * CLI's human channel) so stdout stays clean for machine payloads.
2012
+ */
2013
+ export const ENROLLMENT_TOKEN_PROMPT = 'Enrollment token (input hidden): ';
2014
+
2015
+ /**
2016
+ * Reads a single-use enrollment token from an interactive terminal WITHOUT
2017
+ * echoing it. Node-stdlib only, no new deps: readline drives the input (line
2018
+ * editing, paste, Ctrl+C, raw mode) while its echo/cursor frames are written
2019
+ * into a sink stream, so the typed token never reaches the terminal,
2020
+ * scrollback, or a session recording.
2021
+ *
2022
+ * Fails closed (PairingError ENROLL_TOKEN_REQUIRED) instead of hanging when
2023
+ * stdin is not an interactive terminal, on EOF, on Ctrl+C, or on an empty
2024
+ * submission. The caller maps the code to a suggested_action line.
2025
+ * @param {{ input?: import('node:stream').Readable, output?: import('node:stream').Writable }} [streams]
2026
+ * @returns {Promise<string>} the typed token (trimmed)
2027
+ */
2028
+ export function promptEnrollmentToken({ input = process.stdin, output = process.stderr } = {}) {
2029
+ return new Promise((resolve, reject) => {
2030
+ if (!input || input.isTTY !== true) {
2031
+ reject(
2032
+ new PairingError(
2033
+ 'ENROLL_TOKEN_REQUIRED',
2034
+ 'No enrollment token was provided and stdin is not an interactive terminal. Re-run in a terminal so the token can be typed with hidden input, or pass the token as the second argument.'
2035
+ )
2036
+ );
2037
+ return;
2038
+ }
2039
+ output.write(ENROLLMENT_TOKEN_PROMPT);
2040
+ const sink = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
2041
+ sink.isTTY = true;
2042
+ sink.columns = 80;
2043
+ let settled = false;
2044
+ const rl = readline.createInterface({ input, output: sink, terminal: true });
2045
+ const close = () => {
2046
+ try {
2047
+ rl.close();
2048
+ } catch {}
2049
+ output.write('\n');
2050
+ };
2051
+ const finish = (token) => {
2052
+ if (settled) return;
2053
+ settled = true;
2054
+ close();
2055
+ resolve(token);
2056
+ };
2057
+ const abort = (message) => {
2058
+ if (settled) return;
2059
+ settled = true;
2060
+ close();
2061
+ reject(new PairingError('ENROLL_TOKEN_REQUIRED', message));
2062
+ };
2063
+ rl.on('SIGINT', () => abort('Enrollment token entry was cancelled.'));
2064
+ rl.on('close', () => {
2065
+ if (!settled) abort('No enrollment token was entered (terminal closed). Re-run in a terminal and paste the token from the dashboard.');
2066
+ });
2067
+ rl.question('', (answer) => {
2068
+ const token = String(answer ?? '').trim();
2069
+ if (!token) {
2070
+ abort('No enrollment token was entered. Re-run in a terminal and paste the token shown in the dashboard.');
2071
+ return;
2072
+ }
2073
+ finish(token);
2074
+ });
2075
+ });
1697
2076
  }
1698
2077
 
1699
2078
  /**
@@ -1733,8 +2112,8 @@ export function confirmPrompt(question, openInterface = null) {
1733
2112
  * tree is executed (never a downloaded copy) and its exit status is reported.
1734
2113
  * Target discovery is centered on the canonical root (candidateInstallRoots
1735
2114
  * lists it first) and treats an unreadable system tree as installed, so a
1736
- * non-root shell routes it to the elevated uninstaller instead of reporting
1737
- * "nothing to remove".
2115
+ * non-root shell reports trusted maintenance as required instead of falsely
2116
+ * reporting "nothing to remove". Discovery never authorizes root execution.
1738
2117
  * @param {string[]} args
1739
2118
  * @param {{ command: string, emptyMessage: string, elevateOnWindows?: boolean, completionMessage: string }} options
1740
2119
  */
@@ -1751,7 +2130,14 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
1751
2130
  process.exit(1);
1752
2131
  }
1753
2132
  const yes = args.includes('--yes') || args.includes('-y');
1754
- const { install, unreadable } = findRemovalTargets();
2133
+ let discovery;
2134
+ try { discovery = findRemovalTargets(); } catch (err) {
2135
+ console.error(`error: [TRUSTED_ROOT_MAINTENANCE_REQUIRED] ${err.message}`);
2136
+ console.error(uninstallFailureSuggestion());
2137
+ process.exit(1);
2138
+ return;
2139
+ }
2140
+ const { install, unreadable, paired = [] } = discovery;
1755
2141
  const targets = [...install, ...unreadable];
1756
2142
  if (targets.length === 0) {
1757
2143
  console.log(emptyMessage);
@@ -1760,7 +2146,7 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
1760
2146
  // Windows: a machine-wide tree at C:\ProgramData\NC cannot be removed from
1761
2147
  // a standard shell — request UAC (Start-Process -Verb RunAs) and re-run this
1762
2148
  // same command elevated. Declined or headless elevation is a hard stop.
1763
- if (elevateOnWindows && os.platform() === 'win32' && !isElevated()) {
2149
+ if (elevateOnWindows && os.platform() === 'win32' && targets.some((root) => !isUserInstallRoot(root)) && !isElevated()) {
1764
2150
  if (process.stdin.isTTY) {
1765
2151
  console.log('Admin rights required to remove the system-wide agent. Requesting UAC elevation…');
1766
2152
  const elevated = requestWindowsElevation();
@@ -1769,7 +2155,16 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
1769
2155
  console.error(WINDOWS_ELEVATION_UNINSTALL_DECLINED);
1770
2156
  process.exit(1);
1771
2157
  }
1772
- console.log(`Found Node Command installation(s): ${targets.join(', ')}`);
2158
+ console.log(`Found Node Command installation(s): ${targets.map(root => {
2159
+ const pair = paired.find(selected => selected.ncRoot === root);
2160
+ return pair ? pair.helperRoot === root ? `${root} (co-located protected helper)` : `${root} (paired protected helper: ${pair.helperRoot})` : root;
2161
+ }).join(', ')}`);
2162
+ if (paired.length && (os.platform() !== 'linux' || process.getuid?.() !== 0 || process.geteuid?.() !== 0)) {
2163
+ console.error('error: [TRUSTED_ROOT_MAINTENANCE_REQUIRED] Paired Linux removal requires explicitly invoked real/effective Root maintenance. No elevation or ordinary-script fallback was attempted; both roots and lifecycle records are preserved.');
2164
+ console.error(uninstallFailureSuggestion());
2165
+ process.exit(1);
2166
+ return;
2167
+ }
1773
2168
  if (unreadable.length > 0) {
1774
2169
  console.log(
1775
2170
  `Elevated removal needed for system install(s): ${unreadable.join(', ')} (not readable as the current user).`
@@ -1789,34 +2184,37 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
1789
2184
  }
1790
2185
  let failed = 0;
1791
2186
  for (const root of install) {
1792
- const script = uninstallerForRoot(root);
1793
- if (!fs.existsSync(script)) {
2187
+ const pair = paired.find(selected => selected.ncRoot === root);
2188
+ const script = pair ? LINUX_PAIRED_UNINSTALL_ENTRY : uninstallerForRoot(root);
2189
+ if (!pair && !fs.existsSync(script)) {
1794
2190
  console.error(`error: [UNINSTALLER_MISSING] uninstaller not found at ${script}; skipping ${root}.`);
1795
- console.error('suggested_action: Remove the directory manually after stopping the agent service.');
2191
+ console.error(uninstallFailureSuggestion());
1796
2192
  failed += 1;
1797
2193
  continue;
1798
2194
  }
1799
2195
  try {
1800
2196
  runUninstallerScript(script);
1801
- console.log(`Removed ${root} (exit 0).`);
2197
+ console.log(`Removed ${root}${pair && pair.helperRoot !== root ? ` and paired protected helper ${pair.helperRoot}` : ''} (exit 0).`);
1802
2198
  } catch (err) {
1803
2199
  const rc = typeof err?.status === 'number' ? err.status : 1;
1804
2200
  console.error(`error: [UNINSTALL_FAILED] uninstaller for ${root} exited with status ${rc}: ${err.message}`);
1805
2201
  console.error(uninstallFailureSuggestion());
2202
+ if (pair) {
2203
+ process.exit(rc || 1);
2204
+ return;
2205
+ }
1806
2206
  failed += 1;
1807
2207
  }
1808
2208
  }
1809
- // Unreadable (system) roots: the local tree cannot be stat'ed, so the
1810
- // uninstaller's presence is validated by the elevated run itself — the
1811
- // in-tree script refuses non-NC directories before deleting anything.
2209
+ // Unreadable system roots remain visible, but never trigger implicit sudo.
1812
2210
  for (const root of unreadable) {
1813
2211
  const script = uninstallerForRoot(root);
1814
2212
  try {
1815
2213
  runUninstallerScript(script);
1816
- console.log(`Removed ${root} (elevated, exit 0).`);
2214
+ console.log(`Removed ${root} (exit 0).`);
1817
2215
  } catch (err) {
1818
2216
  const rc = typeof err?.status === 'number' ? err.status : 1;
1819
- console.error(`error: [UNINSTALL_FAILED] elevated uninstaller for ${root} exited with status ${rc}: ${err.message}`);
2217
+ console.error(`error: [UNINSTALL_FAILED] uninstaller for ${root} exited with status ${rc}: ${err.message}`);
1820
2218
  console.error(uninstallFailureSuggestion());
1821
2219
  failed += 1;
1822
2220
  }
@@ -1833,6 +2231,8 @@ export async function runRemove(args = []) {
1833
2231
  return runRemoval(args, {
1834
2232
  command: 'remove',
1835
2233
  emptyMessage: 'No Node Command installation found (nothing to remove).',
2234
+ // remove is the legacy uninstall alias and must share its UAC path.
2235
+ elevateOnWindows: true,
1836
2236
  completionMessage: 'Remove complete.'
1837
2237
  });
1838
2238
  }
@@ -1861,10 +2261,10 @@ export async function runUninstall(args = []) {
1861
2261
  /**
1862
2262
  * Browser device flow + enroll/redeem/install/verify. Extracted verbatim from
1863
2263
  * the historical bare-invocation path; `setup` reuses it with an explicit
1864
- * install mode while bare `nodecommand` keeps the previous behavior.
1865
- * @param {{ installMode?: 'system'|'user' }} [options]
2264
+ * install mode; plain `nodecommand` now uses the same setup preflight.
2265
+ * @param {{ installMode?: 'system'|'user', ncRoot?: string, browserBrand?: string, rootConsent?: boolean|(() => Promise<boolean>) }} [options]
1866
2266
  */
1867
- async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {}) {
2267
+ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined, browserBrand = captureBrowserDefaultBrand(), rootConsent = false } = {}) {
1868
2268
  const deviceEndpoint = `${baseUrl}/oauth/device/code`;
1869
2269
  let initRes;
1870
2270
 
@@ -1903,7 +2303,9 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
1903
2303
  verification_uri_complete,
1904
2304
  verification_uri,
1905
2305
  interval = 3,
1906
- expires_in = 600
2306
+ // Platform contract: the server always returns expires_in and grants live
2307
+ // 300s. Keep the fallback at the mandated 300s TTL, never the old 600s.
2308
+ expires_in = 300
1907
2309
  } = initRes.data;
1908
2310
 
1909
2311
  const bold = '\x1b[1m';
@@ -1937,8 +2339,9 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
1937
2339
  let isPolling = false;
1938
2340
  let currentIntervalMs = pollIntervalMs;
1939
2341
  // Poll bounds (RFC 8628 §3.5): the device_code lives ~expires_in seconds
1940
- // server-side, and consecutive network failures must not spin forever.
1941
- const lifetimeSec = Number(expires_in) > 0 ? Number(expires_in) : 600;
2342
+ // server-side (300s mandated TTL), and consecutive network failures must
2343
+ // not spin forever. Fallback mirrors the same 300s contract.
2344
+ const lifetimeSec = Number(expires_in) > 0 ? Number(expires_in) : 300;
1942
2345
  const flowDeadline = Date.now() + lifetimeSec * 1000;
1943
2346
  let consecutiveNetErrors = 0;
1944
2347
  const MAX_NET_ERROR_RETRIES = 10;
@@ -1969,12 +2372,14 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
1969
2372
  const oauthToken = tokenRes.data.access_token;
1970
2373
  console.log('\n🔓 Browser approval received. Enrolling this machine...');
1971
2374
  try {
1972
- const result = await pairDeviceAndInstall({ baseUrl, apiBase, oauthToken, installMode, ncRoot });
2375
+ const result = await pairDeviceAndInstall({ baseUrl, apiBase, oauthToken, installMode, ncRoot, browserBrand, rootConsent });
1973
2376
  if (installMode) {
1974
2377
  // `setup` reports the structured success block (host, mode,
1975
2378
  // versions, online status); bare `nodecommand` keeps the legacy
1976
2379
  // pairing messages below.
1977
2380
  printSetupSuccess({
2381
+ ...result,
2382
+ ncRoot,
1978
2383
  hostId: result.hostId,
1979
2384
  serverUrl: result.serverUrl,
1980
2385
  installMode,