node-command 2.0.2 → 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 +406 -124
  2. package/lib/setup.mjs +730 -42
  3. package/package.json +11 -16
package/bin/nc.mjs CHANGED
@@ -12,11 +12,19 @@ import { Writable } from 'node:stream';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import {
14
14
  canElevate,
15
+ captureBrowserDefaultBrand,
16
+ normalizeBrowserBrand,
17
+ browserBrandReexecArgs,
15
18
  decideInstallMode,
16
19
  findRemovalTargets,
17
20
  getNcRootForMode,
18
21
  installerEnvForMode,
19
22
  isElevated,
23
+ isUserInstallRoot,
24
+ setupTarget,
25
+ maintainLinuxIdentity,
26
+ loadProtectedHelperProducer,
27
+ LINUX_PAIRED_UNINSTALL_ENTRY,
20
28
  modeWarningFor,
21
29
  runUninstallerScript,
22
30
  uninstallFailureSuggestion,
@@ -29,8 +37,9 @@ const baseUrl = process.env.NC_BASE_URL || 'https://auth.nodecommand.app';
29
37
  * downloads, and `/releases/*`. The auth host (`baseUrl` above) serves only
30
38
  * browser-facing OAuth/device flow and enrollment (`/oauth/*`, `/api/enroll*`);
31
39
  * Caddy routes `/api/clients*` solely on the agent (and dash) edge, so
32
- * verification against the auth host answers 404. `NC_SERVER_URL` is NOT
33
- * 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.
34
43
  */
35
44
  export const apiBase = process.env.NC_API_URL || 'https://agent.nodecommand.app';
36
45
  const clientId = 'nc-cli';
@@ -532,9 +541,9 @@ export function getEnrollStatePath(ncRoot = getNcRoot()) {
532
541
  * cannot end up in the file. Host id/token shapes are enforced so a caller
533
542
  * mistake (e.g. passing an enrollment token as the client token) fails here
534
543
  * instead of producing a broken agent that crash-loops on 401.
535
- * @param {{ serverUrl: string, hostId: string, hostToken: string, clientName?: string }} options
544
+ * @param {{ serverUrl: string, hostId: string, hostToken: string, clientName?: string, browserBrand?: string }} options
536
545
  */
537
- export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName = os.hostname() }) {
546
+ export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName = os.hostname(), browserBrand = 'unknown' }) {
538
547
  if (typeof serverUrl !== 'string' || !serverUrl) {
539
548
  throw new PairingError('INVALID_SERVER_URL', 'Refusing to write agent config without a server URL.');
540
549
  }
@@ -556,6 +565,7 @@ export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName =
556
565
  `NC_CLIENT_NAME=${clientName}`,
557
566
  `NC_CLIENT_TOKEN=${hostToken}`,
558
567
  'NC_REMOTE_EXEC_ENABLED=true',
568
+ `NC_BROWSER_DEFAULT_BRAND=${normalizeBrowserBrand(browserBrand)}`,
559
569
  'NC_SESSION_ENABLED=true'
560
570
  ].join('\n') + '\n';
561
571
  }
@@ -611,6 +621,15 @@ export function loadValidHostEnv(envPath = getEnvFilePath()) {
611
621
  return env;
612
622
  }
613
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
+
614
633
  /**
615
634
  * Resume marker for idempotent re-runs. Holds the host id and lifecycle
616
635
  * phase only — never any token or key material (those live in nc-client.env
@@ -716,6 +735,52 @@ export function loadDeviceKeyPair(keyFile) {
716
735
  return { privateKey: key, publicKey, thumbprint: deviceKeyThumbprintCli(publicKey) };
717
736
  }
718
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
+
719
784
  /**
720
785
  * Loads the device key, generating and persisting a fresh Ed25519 key when
721
786
  * none exists (atomic tmp-write + rename, 0600). Mirrors
@@ -852,14 +917,15 @@ export async function requestEnrollment({ baseUrl, oauthToken, platform = 'linux
852
917
  /**
853
918
  * Redeems a one-time enrollment token with Device Proof v1, binding the
854
919
  * local Ed25519 key to the host identity. Returns the redemption payload
855
- * (client_id, client_token, release). A 410 ENROLLMENT_ALREADY_USED means a
856
- * previous run already consumed this token — the caller resumes from local
857
- * credentials instead of retrying redemption.
858
- * @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
859
924
  */
860
- 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.');
861
927
  const origin = String(baseUrl || '').replace(/\/$/, '');
862
- const redeemPath = '/api/enroll/redeem';
928
+ const redeemPath = `/api/enroll/redeem?initial_permission=${initialPermission}`;
863
929
  const proof = createDeviceProofCli({
864
930
  privateKey: keyPair.privateKey,
865
931
  hostId,
@@ -875,7 +941,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
875
941
  { 'NC-Device-Proof': proof }
876
942
  );
877
943
  } catch (err) {
878
- 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.`);
879
945
  }
880
946
  if (res.status === 200 && res.data) {
881
947
  const r = res.data;
@@ -891,7 +957,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
891
957
  if (res.status === 410 && serverError === 'ENROLLMENT_ALREADY_USED') {
892
958
  throw new PairingError(
893
959
  'ENROLLMENT_ALREADY_USED',
894
- '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.'
895
961
  );
896
962
  }
897
963
  if (res.status === 410) {
@@ -901,7 +967,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
901
967
  );
902
968
  }
903
969
  if (res.status === 401) {
904
- 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.`);
905
971
  }
906
972
  throw new PairingError('REDEEM_REJECTED', `Redemption failed (${serverError || `HTTP ${res.status}`}). Re-run to request a fresh enrollment.`);
907
973
  }
@@ -912,8 +978,9 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
912
978
  * uses). Polls until the identity is accepted or the timeout expires.
913
979
  * Success here proves the env credentials actually authenticate; only then
914
980
  * may the CLI print a success message.
915
- * Device-channel endpoints live on the agent edge (`apiBase`/`NC_API_URL`),
916
- * 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
917
984
  * compatibility but an explicit `apiBase` (or `NC_API_URL`/default agent
918
985
  * origin when neither is given) wins.
919
986
  * @param {{ baseUrl?: string, apiBase?: string, hostId: string, hostToken: string, privateKey: import('node:crypto').KeyObject, timeoutMs?: number, intervalMs?: number }} options
@@ -977,11 +1044,11 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
977
1044
  * (`/releases/client/<version>/bootstrap.tar.gz`) stays anonymous. The
978
1045
  * proof binds method GET + the full request URL + the redemption-issued
979
1046
  * client token, exactly like redeemEnrollment.
980
- * @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
981
1048
  */
982
- 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 } = {}) {
983
1050
  ncRoot = ncRoot || (installMode ? getNcRootForMode(installMode) : getNcRoot());
984
- const modeEnv = installMode ? installerEnvForMode(installMode) : {};
1051
+ const modeEnv = { ...(installMode ? installerEnvForMode(installMode) : {}), NC_ROOT_COMMAND_CONSENT: rootConsent === true ? 'yes' : 'no' };
985
1052
  const invokeInstaller =
986
1053
  runInstaller || ((scriptPath, root) => runInstallerScript(scriptPath, root, modeEnv));
987
1054
  const root = ncRoot;
@@ -1076,6 +1143,21 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1076
1143
  tarballUrl = `${defaultBaseUrl}/releases/client/${encodeURIComponent(version)}/bootstrap.tar.gz`;
1077
1144
  }
1078
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
+
1079
1161
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nc-install-'));
1080
1162
  const archivePath = path.join(tempDir, 'bootstrap.tar.gz');
1081
1163
 
@@ -1103,35 +1185,25 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1103
1185
  throw new PairingError('INSTALL_DOWNLOAD_ERROR', `Could not download the agent package: ${err.message}`);
1104
1186
  }
1105
1187
 
1106
- const expectedHash = activeRelease.bootstrap_sha256 || activeRelease.sha256;
1107
- const expectedSizeRaw = activeRelease.bootstrap_size_bytes ?? activeRelease.size_bytes;
1108
- const expectedSize =
1109
- expectedSizeRaw === undefined || expectedSizeRaw === null ? null : Number(expectedSizeRaw);
1110
1188
  const archiveBytes = fs.readFileSync(archivePath);
1111
- if (expectedSize !== null && Number.isFinite(expectedSize)) {
1112
- if (archiveBytes.length !== expectedSize) {
1113
- try {
1114
- fs.unlinkSync(archivePath);
1115
- } catch {}
1116
- throw new PairingError(
1117
- 'INSTALL_MANIFEST_ERROR',
1118
- `Agent package integrity check failed: size mismatch for ${tarballUrl} (expected ${expectedSize} bytes, got ${archiveBytes.length}). The download was discarded; nothing was installed.`
1119
- );
1120
- }
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
+ );
1121
1197
  }
1122
- if (expectedHash && /^[a-f0-9]{64}$/i.test(String(expectedHash))) {
1123
- const actualHash = crypto.createHash('sha256').update(archiveBytes).digest('hex');
1124
- if (actualHash.toLowerCase() !== String(expectedHash).toLowerCase()) {
1125
- try {
1126
- fs.unlinkSync(archivePath);
1127
- } catch {}
1128
- throw new PairingError(
1129
- 'INSTALL_MANIFEST_ERROR',
1130
- `Agent package integrity check failed: sha256 mismatch for ${tarballUrl}. The download was discarded; nothing was installed.`
1131
- );
1132
- }
1133
- } else {
1134
- 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
+ );
1135
1207
  }
1136
1208
 
1137
1209
  console.log('📂 Extracting agent archive...');
@@ -1169,9 +1241,9 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1169
1241
  * directly. `installMode` is forwarded to the installer via NC_MODE.
1170
1242
  * Host split: enrollment (OAuth/redeem) uses the auth origin (`baseUrl` /
1171
1243
  * `NC_BASE_URL`); device-channel verification (`/api/clients/*`) uses the
1172
- * agent origin (`apiBase` / `NC_API_URL`). `NC_SERVER_URL` stays the agent
1173
- * server address and never selects the verify host.
1174
- * @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
1175
1247
  */
1176
1248
  export async function pairDeviceAndInstall({
1177
1249
  baseUrl: baseUrlParam = undefined,
@@ -1183,6 +1255,8 @@ export async function pairDeviceAndInstall({
1183
1255
  deviceName = os.hostname(),
1184
1256
  platform = mapEnrollPlatform(),
1185
1257
  installMode = undefined,
1258
+ rootConsent = false,
1259
+ browserBrand = captureBrowserDefaultBrand(),
1186
1260
  verifyTimeoutMs = 45000,
1187
1261
  verifyIntervalMs = 2000,
1188
1262
  hooks = {}
@@ -1209,15 +1283,20 @@ export async function pairDeviceAndInstall({
1209
1283
  const installAgent =
1210
1284
  hooks.installAgent ||
1211
1285
  (async ({ ncRoot: installRoot, serverUrl: installServerUrl, release: installRelease = undefined, hostId: installHostId = undefined, hostToken: installHostToken = undefined, privateKey: installPrivateKey = undefined }) => {
1212
- 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 });
1213
1287
  });
1288
+ let selectedRootConsent = false;
1214
1289
 
1215
1290
  // Fast path — idempotent resume: usable host credentials already on disk
1216
1291
  // are verified over the device channel and the installer is resumed. No
1217
1292
  // new enrollment is minted, so a re-run can never orphan a host or hit
1218
1293
  // ENROLLMENT_ALREADY_USED for a token it no longer holds.
1219
1294
  const localEnv = loadValidHostEnv(envPath);
1295
+ assertMatchingHostIdentity(localEnv, enrollment?.host_id);
1220
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(/\/$/, '');
1221
1300
  let localKey = null;
1222
1301
  try {
1223
1302
  localKey = loadDeviceKeyPair(keyPath);
@@ -1228,7 +1307,7 @@ export async function pairDeviceAndInstall({
1228
1307
  const prior = readPairState(statePath);
1229
1308
  try {
1230
1309
  await verifyHostRegistration({
1231
- apiBase: apiOrigin,
1310
+ apiBase: localEnv.NC_SERVER_URL,
1232
1311
  hostId: localEnv.NC_CLIENT_ID,
1233
1312
  hostToken: localEnv.NC_CLIENT_TOKEN,
1234
1313
  privateKey: localKey.privateKey,
@@ -1241,10 +1320,10 @@ export async function pairDeviceAndInstall({
1241
1320
  return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
1242
1321
  }
1243
1322
  console.log(`🔁 Resuming installation for host ${localEnv.NC_CLIENT_ID} (stored credentials verified)...`);
1244
- 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 });
1245
1324
  writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'installed' });
1246
1325
  await verifyHostRegistration({
1247
- apiBase: apiOrigin,
1326
+ apiBase: localEnv.NC_SERVER_URL,
1248
1327
  hostId: localEnv.NC_CLIENT_ID,
1249
1328
  hostToken: localEnv.NC_CLIENT_TOKEN,
1250
1329
  privateKey: localKey.privateKey,
@@ -1254,23 +1333,25 @@ export async function pairDeviceAndInstall({
1254
1333
  writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'verified' });
1255
1334
  return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
1256
1335
  } catch (err) {
1257
- // Only a definitive 404 (VERIFY_FAILED) falls through to a fresh
1258
- // enrollment. A timeout (VERIFY_TIMEOUT) rethrows with the timeout
1259
- // guidance: wiping stored credentials on a transient stall would
1260
- // orphan a healthy host identity. The caller reports the stored-
1261
- // 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.
1262
1338
  if (err instanceof PairingError && err.code === 'VERIFY_FAILED') {
1263
- // Stored credentials no longer authenticate (rotated, revoked, or
1264
- // server reset): fall through to a fresh enrollment, which
1265
- // overwrites the stale files below.
1266
- console.warn(`⚠️ Stored host credentials no longer verify (${err.code}); requesting a fresh enrollment...`);
1267
- } else {
1268
- 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.');
1269
1340
  }
1341
+ throw err;
1270
1342
  }
1271
1343
  }
1272
1344
  }
1273
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
+
1274
1355
  // Fresh enrollment with the OAuth USER token (user authorization only),
1275
1356
  // or the caller-supplied enrollment from `setup <host_id> <token>`.
1276
1357
  let activeEnrollment = enrollment;
@@ -1292,14 +1373,17 @@ export async function pairDeviceAndInstall({
1292
1373
  let agentVersion = null;
1293
1374
  /** @type {Record<string, unknown>|null} */
1294
1375
  let redemptionRelease = null;
1295
- try {
1376
+ let executionInitialization = null;
1377
+ {
1296
1378
  const redemption = await redeemEnrollment({
1297
1379
  baseUrl: origin,
1298
1380
  hostId,
1299
1381
  enrollmentToken: activeEnrollment.enrollment_token,
1300
- keyPair
1382
+ keyPair,
1383
+ initialPermission: selectedRootConsent ? 'admin' : 'user'
1301
1384
  });
1302
1385
  hostToken = redemption.client_token;
1386
+ executionInitialization = redemption.execution_initialization ?? null;
1303
1387
  if (redemption.release && typeof redemption.release === 'object') {
1304
1388
  redemptionRelease = /** @type {Record<string, unknown>} */ (redemption.release);
1305
1389
  }
@@ -1307,30 +1391,16 @@ export async function pairDeviceAndInstall({
1307
1391
  agentVersion = redemption.release.version;
1308
1392
  console.log(`📦 Release: ${agentVersion}`);
1309
1393
  }
1310
- } catch (err) {
1311
- if (err instanceof PairingError && err.code === 'ENROLLMENT_ALREADY_USED') {
1312
- // A previous run consumed this token and stored the credentials:
1313
- // resume from them instead of failing on the single-use rule.
1314
- const again = loadValidHostEnv(envPath);
1315
- if (again && again.NC_CLIENT_ID === hostId) {
1316
- console.log('🔁 Enrollment was already redeemed by an earlier run — resuming from stored credentials...');
1317
- hostToken = again.NC_CLIENT_TOKEN;
1318
- } else {
1319
- throw err;
1320
- }
1321
- } else {
1322
- throw err;
1323
- }
1324
1394
  }
1325
1395
 
1326
1396
  // Host credentials ONLY — the OAuth token is never written here.
1327
1397
  console.log('📝 Creating configuration file...');
1328
- writeHostEnvFile(envPath, buildHostEnvContent({ serverUrl: enrollServerUrl, hostId, hostToken, clientName: deviceName }));
1398
+ writeHostEnvFile(envPath, buildHostEnvContent({ serverUrl: enrollServerUrl, hostId, hostToken, clientName: deviceName, browserBrand }));
1329
1399
  console.log(`📝 Configuration saved to ${envPath}`);
1330
1400
  writePairState(statePath, { host_id: hostId, phase: 'redeemed' });
1331
1401
 
1332
1402
  console.log('🚀 Installing agent...');
1333
- 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 });
1334
1404
  writePairState(statePath, { host_id: hostId, phase: 'installed' });
1335
1405
 
1336
1406
  console.log('🔍 Verifying host registration...');
@@ -1343,7 +1413,8 @@ export async function pairDeviceAndInstall({
1343
1413
  intervalMs: verifyIntervalMs
1344
1414
  });
1345
1415
  writePairState(statePath, { host_id: hostId, phase: 'verified' });
1346
- 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 };
1347
1418
  }
1348
1419
 
1349
1420
  /**
@@ -1381,6 +1452,7 @@ function printCliHelp() {
1381
1452
  ' nodecommand pair this machine via the browser device flow',
1382
1453
  ' nodecommand <host_id> install with a dashboard host id (prompts for the token, hidden input)',
1383
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)',
1384
1456
  ' nodecommand uninstall [--yes] remove the agent from the canonical root (alias: remove)',
1385
1457
  ' nodecommand remove [--yes] uninstall the agent (asks for confirmation unless --yes)',
1386
1458
  ' nodecommand --diagnostics preview a redacted diagnostics bundle (dry-run, writes nothing)',
@@ -1487,17 +1559,22 @@ async function main() {
1487
1559
  // form the dashboard and install_command used to emit) to the `setup
1488
1560
  // <host_id> <token>` path instead of silently ignoring the arguments in the
1489
1561
  // device flow.
1490
- if (isBareEnrollmentInvocation(cliArgs)) {
1562
+ const enrollmentArgs = cliArgs.filter(arg => arg !== '--allow-root-commands');
1563
+ if (isBareEnrollmentInvocation(enrollmentArgs)) {
1491
1564
  await runSetup(cliArgs);
1492
1565
  return;
1493
1566
  }
1494
1567
  // Canonical token-free form: the single-use token is typed at a masked
1495
1568
  // interactive prompt inside runSetup, so it never enters argv (shell
1496
1569
  // history, `ps`, recordings, telemetry).
1497
- if (isBareHostIdInvocation(cliArgs)) {
1570
+ if (isBareHostIdInvocation(enrollmentArgs)) {
1498
1571
  await runSetup(cliArgs, { allowHostIdOnly: true });
1499
1572
  return;
1500
1573
  }
1574
+ if (enrollmentArgs.length === 0) {
1575
+ await runSetup(cliArgs);
1576
+ return;
1577
+ }
1501
1578
  await runDeviceFlow();
1502
1579
  }
1503
1580
 
@@ -1509,6 +1586,8 @@ async function main() {
1509
1586
  */
1510
1587
  export function suggestedActionFor(code) {
1511
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.';
1512
1591
  case 'ENROLL_NO_OAUTH_TOKEN':
1513
1592
  return 'Re-run `nodecommand setup` without arguments and approve the browser device flow.';
1514
1593
  case 'ENROLL_UNAUTHORIZED':
@@ -1519,7 +1598,10 @@ export function suggestedActionFor(code) {
1519
1598
  case 'PLAN_HOST_LIMIT_EXCEEDED':
1520
1599
  return 'Upgrade the workspace plan or remove an old host, then re-run.';
1521
1600
  case 'ENROLLMENT_ALREADY_USED':
1522
- 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.';
1523
1605
  case 'ENROLLMENT_EXPIRED':
1524
1606
  case 'INVALID_ENROLL_TOKEN':
1525
1607
  case 'INVALID_HOST_ID':
@@ -1584,15 +1666,53 @@ function getCliVersion() {
1584
1666
  /**
1585
1667
  * Prints the one-line install summary required after a successful setup:
1586
1668
  * host id, install mode, CLI version, and verification status.
1587
- * @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
1588
1670
  */
1589
- 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 }) {
1590
1672
  console.log(`\n✅ Setup complete for host ${hostId}${resumed ? ' (resumed)' : ''}.`);
1591
1673
  if (installMode) console.log(`installed as ${installMode}`);
1592
1674
  if (version) console.log(`agent version: ${version}`);
1593
1675
  console.log(`cli version: ${getCliVersion()}`);
1594
1676
  console.log(`server: ${serverUrl}`);
1595
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
+ }
1596
1716
  }
1597
1717
 
1598
1718
  /**
@@ -1615,17 +1735,20 @@ const WINDOWS_ELEVATION_UNINSTALL_DECLINED =
1615
1735
  * console is visible) and returns the spawnSync result. Never throws: a
1616
1736
  * missing powershell or a declined prompt resolves to null/non-zero.
1617
1737
  */
1618
- function requestWindowsElevation() {
1619
- 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`;
1620
1744
  try {
1621
1745
  return child_process.spawnSync(
1622
- 'powershell',
1746
+ 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
1623
1747
  [
1624
1748
  '-NoProfile',
1625
- '-Command',
1626
- `Start-Process -FilePath "${process.execPath}" -ArgumentList ${args
1627
- .map((a) => `"${String(a).replace(/"/g, '`"')}"`)
1628
- .join(',')} -Verb RunAs -Wait`
1749
+ '-NonInteractive',
1750
+ '-EncodedCommand',
1751
+ Buffer.from(script, 'utf16le').toString('base64')
1629
1752
  ],
1630
1753
  { stdio: 'inherit' }
1631
1754
  );
@@ -1638,7 +1761,9 @@ function requestWindowsElevation() {
1638
1761
  * `nodecommand setup [host_id enrollment_token]`.
1639
1762
  *
1640
1763
  * Mode is automatic (never prompted): elevated processes install machine-wide.
1641
- * 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
1642
1767
  * install with a single-line capability notice. Windows: a standard shell
1643
1768
  * requests UAC elevation instead and hard-stops (exit 1) when elevation is
1644
1769
  * unavailable or declined — never a silent %LOCALAPPDATA% degradation.
@@ -1663,13 +1788,56 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
1663
1788
  ' nodecommand setup install via the browser device flow (auto system/user mode)',
1664
1789
  ' nodecommand setup <host_id> <token> install with a dashboard enrollment token (legacy positional form)',
1665
1790
  '',
1666
- 'The install mode is chosen automatically: administrator/root installs',
1667
- 'machine-wide (system), anything else installs per-user (user).'
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.',
1794
+ '',
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.'
1668
1802
  ].join('\n')
1669
1803
  );
1670
1804
  return;
1671
1805
  }
1672
- 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
+ }
1673
1841
  // Arity is validated BEFORE any mode/root output: an invalid invocation
1674
1842
  // must fail without printing an installation plan it will not execute.
1675
1843
  // One positional (host id) is valid only for the bare `nodecommand
@@ -1680,18 +1848,85 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
1680
1848
  console.error('suggested_action: Re-run as `nodecommand setup` or `nodecommand setup <host_id> <token>`.');
1681
1849
  process.exit(1);
1682
1850
  }
1851
+ const browserBrand = captureBrowserDefaultBrand();
1683
1852
  let installMode = decideInstallMode();
1684
- // A non-root admin must not silently fall back to a per-user install: the
1685
- // install root, service scope and privileged helper all differ. POSIX
1686
- // 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
1687
1922
  // -Verb RunAs) so the install lands machine-wide at the canonical system
1688
1923
  // root. A refused or unavailable Windows elevation is a hard stop: the CLI
1689
1924
  // never silently degrades a system install into %LOCALAPPDATA%.
1690
- if (installMode === 'user' && canElevate() && !process.env.NC_ELEVATED) {
1925
+ if (os.platform() !== 'darwin' && installMode === 'user' && canElevate() && !process.env.NC_ELEVATED) {
1691
1926
  if (os.platform() === 'win32') {
1692
1927
  if (process.stdin.isTTY) {
1693
1928
  console.log('Admin rights required for a system-wide install. Requesting UAC elevation…');
1694
- const elevated = requestWindowsElevation();
1929
+ const elevated = requestWindowsElevation(browserBrand);
1695
1930
  if (elevated && elevated.status === 0) process.exit(0);
1696
1931
  }
1697
1932
  console.error(WINDOWS_ELEVATION_INSTALL_DECLINED);
@@ -1704,12 +1939,24 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
1704
1939
  { stdio: 'inherit', env: { ...process.env, NC_ELEVATED: '1' } }
1705
1940
  );
1706
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
+ }
1707
1954
  console.warn('Elevation unavailable or declined; continuing with a per-user install.');
1708
1955
  }
1709
1956
  // Mode-aware root: on win32 a system install lives in %ProgramData%\NC,
1710
1957
  // not the %LOCALAPPDATA% tree getNcRoot() returns, so credentials must be
1711
1958
  // written where the system installer will actually read them.
1712
- const ncRoot = getNcRootForMode(installMode);
1959
+ ncRoot = ncRoot || getNcRootForMode(installMode);
1713
1960
  console.log(installMode === 'system' ? `Installing as system (machine-wide), root: ${ncRoot}.` : `Installing as user (per-user), root: ${ncRoot}.`);
1714
1961
  const warning = modeWarningFor(installMode);
1715
1962
  if (warning) console.warn(warning);
@@ -1719,6 +1966,7 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
1719
1966
  // forwards only the host id). Masked input: the token is not echoed.
1720
1967
  if (onePositionalOk) {
1721
1968
  try {
1969
+ assertMatchingHostIdentity(loadValidHostEnv(getEnvFilePath(ncRoot)), positionals[0]);
1722
1970
  const enrollmentToken = await promptToken({ input: process.stdin, output: process.stderr });
1723
1971
  positionals.push(String(enrollmentToken || ''));
1724
1972
  } catch (err) {
@@ -1727,6 +1975,7 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
1727
1975
  }
1728
1976
  }
1729
1977
 
1978
+ const rootConsent = () => promptRootCommandConsent({ explicitOptIn: args.includes('--allow-root-commands') });
1730
1979
  if (positionals.length === 2) {
1731
1980
  const [hostId, enrollmentToken] = positionals;
1732
1981
  try {
@@ -1734,17 +1983,27 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
1734
1983
  baseUrl,
1735
1984
  apiBase,
1736
1985
  enrollment: { host_id: hostId, enrollment_token: enrollmentToken },
1986
+ browserBrand,
1737
1987
  installMode,
1988
+ rootConsent,
1738
1989
  ncRoot
1739
1990
  });
1740
- printSetupSuccess({ hostId: result.hostId, serverUrl: result.serverUrl, installMode, resumed: result.resumed, version: result.version });
1991
+ printSetupSuccess({ ...result, installMode, ncRoot });
1741
1992
  } catch (err) {
1742
1993
  reportSetupError(err);
1743
1994
  process.exit(1);
1744
1995
  }
1745
1996
  return;
1746
1997
  }
1747
- 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;
1748
2007
  }
1749
2008
 
1750
2009
  /**
@@ -1853,8 +2112,8 @@ export function confirmPrompt(question, openInterface = null) {
1853
2112
  * tree is executed (never a downloaded copy) and its exit status is reported.
1854
2113
  * Target discovery is centered on the canonical root (candidateInstallRoots
1855
2114
  * lists it first) and treats an unreadable system tree as installed, so a
1856
- * non-root shell routes it to the elevated uninstaller instead of reporting
1857
- * "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.
1858
2117
  * @param {string[]} args
1859
2118
  * @param {{ command: string, emptyMessage: string, elevateOnWindows?: boolean, completionMessage: string }} options
1860
2119
  */
@@ -1871,7 +2130,14 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
1871
2130
  process.exit(1);
1872
2131
  }
1873
2132
  const yes = args.includes('--yes') || args.includes('-y');
1874
- 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;
1875
2141
  const targets = [...install, ...unreadable];
1876
2142
  if (targets.length === 0) {
1877
2143
  console.log(emptyMessage);
@@ -1880,7 +2146,7 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
1880
2146
  // Windows: a machine-wide tree at C:\ProgramData\NC cannot be removed from
1881
2147
  // a standard shell — request UAC (Start-Process -Verb RunAs) and re-run this
1882
2148
  // same command elevated. Declined or headless elevation is a hard stop.
1883
- if (elevateOnWindows && os.platform() === 'win32' && !isElevated()) {
2149
+ if (elevateOnWindows && os.platform() === 'win32' && targets.some((root) => !isUserInstallRoot(root)) && !isElevated()) {
1884
2150
  if (process.stdin.isTTY) {
1885
2151
  console.log('Admin rights required to remove the system-wide agent. Requesting UAC elevation…');
1886
2152
  const elevated = requestWindowsElevation();
@@ -1889,7 +2155,16 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
1889
2155
  console.error(WINDOWS_ELEVATION_UNINSTALL_DECLINED);
1890
2156
  process.exit(1);
1891
2157
  }
1892
- 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
+ }
1893
2168
  if (unreadable.length > 0) {
1894
2169
  console.log(
1895
2170
  `Elevated removal needed for system install(s): ${unreadable.join(', ')} (not readable as the current user).`
@@ -1909,34 +2184,37 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
1909
2184
  }
1910
2185
  let failed = 0;
1911
2186
  for (const root of install) {
1912
- const script = uninstallerForRoot(root);
1913
- 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)) {
1914
2190
  console.error(`error: [UNINSTALLER_MISSING] uninstaller not found at ${script}; skipping ${root}.`);
1915
- console.error('suggested_action: Remove the directory manually after stopping the agent service.');
2191
+ console.error(uninstallFailureSuggestion());
1916
2192
  failed += 1;
1917
2193
  continue;
1918
2194
  }
1919
2195
  try {
1920
2196
  runUninstallerScript(script);
1921
- console.log(`Removed ${root} (exit 0).`);
2197
+ console.log(`Removed ${root}${pair && pair.helperRoot !== root ? ` and paired protected helper ${pair.helperRoot}` : ''} (exit 0).`);
1922
2198
  } catch (err) {
1923
2199
  const rc = typeof err?.status === 'number' ? err.status : 1;
1924
2200
  console.error(`error: [UNINSTALL_FAILED] uninstaller for ${root} exited with status ${rc}: ${err.message}`);
1925
2201
  console.error(uninstallFailureSuggestion());
2202
+ if (pair) {
2203
+ process.exit(rc || 1);
2204
+ return;
2205
+ }
1926
2206
  failed += 1;
1927
2207
  }
1928
2208
  }
1929
- // Unreadable (system) roots: the local tree cannot be stat'ed, so the
1930
- // uninstaller's presence is validated by the elevated run itself — the
1931
- // in-tree script refuses non-NC directories before deleting anything.
2209
+ // Unreadable system roots remain visible, but never trigger implicit sudo.
1932
2210
  for (const root of unreadable) {
1933
2211
  const script = uninstallerForRoot(root);
1934
2212
  try {
1935
2213
  runUninstallerScript(script);
1936
- console.log(`Removed ${root} (elevated, exit 0).`);
2214
+ console.log(`Removed ${root} (exit 0).`);
1937
2215
  } catch (err) {
1938
2216
  const rc = typeof err?.status === 'number' ? err.status : 1;
1939
- 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}`);
1940
2218
  console.error(uninstallFailureSuggestion());
1941
2219
  failed += 1;
1942
2220
  }
@@ -1953,6 +2231,8 @@ export async function runRemove(args = []) {
1953
2231
  return runRemoval(args, {
1954
2232
  command: 'remove',
1955
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,
1956
2236
  completionMessage: 'Remove complete.'
1957
2237
  });
1958
2238
  }
@@ -1981,10 +2261,10 @@ export async function runUninstall(args = []) {
1981
2261
  /**
1982
2262
  * Browser device flow + enroll/redeem/install/verify. Extracted verbatim from
1983
2263
  * the historical bare-invocation path; `setup` reuses it with an explicit
1984
- * install mode while bare `nodecommand` keeps the previous behavior.
1985
- * @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]
1986
2266
  */
1987
- async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {}) {
2267
+ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined, browserBrand = captureBrowserDefaultBrand(), rootConsent = false } = {}) {
1988
2268
  const deviceEndpoint = `${baseUrl}/oauth/device/code`;
1989
2269
  let initRes;
1990
2270
 
@@ -2092,12 +2372,14 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
2092
2372
  const oauthToken = tokenRes.data.access_token;
2093
2373
  console.log('\n🔓 Browser approval received. Enrolling this machine...');
2094
2374
  try {
2095
- const result = await pairDeviceAndInstall({ baseUrl, apiBase, oauthToken, installMode, ncRoot });
2375
+ const result = await pairDeviceAndInstall({ baseUrl, apiBase, oauthToken, installMode, ncRoot, browserBrand, rootConsent });
2096
2376
  if (installMode) {
2097
2377
  // `setup` reports the structured success block (host, mode,
2098
2378
  // versions, online status); bare `nodecommand` keeps the legacy
2099
2379
  // pairing messages below.
2100
2380
  printSetupSuccess({
2381
+ ...result,
2382
+ ncRoot,
2101
2383
  hostId: result.hostId,
2102
2384
  serverUrl: result.serverUrl,
2103
2385
  installMode,