node-command 2.0.2 → 2.0.4

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.
package/bin/nc.mjs CHANGED
@@ -8,19 +8,32 @@ 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
+ import { Transform, Writable, pipeline } from 'node:stream';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import {
14
14
  canElevate,
15
+ canRepairUserApplication,
16
+ captureBrowserDefaultBrand,
17
+ normalizeBrowserBrand,
18
+ browserBrandReexecArgs,
15
19
  decideInstallMode,
16
20
  findRemovalTargets,
17
21
  getNcRootForMode,
18
22
  installerEnvForMode,
19
23
  isElevated,
24
+ isUserInstallRoot,
25
+ probeInstallRoot,
26
+ resolveExecutable,
27
+ setupTarget,
28
+ unreadableSystemInstallations,
29
+ maintainLinuxIdentity,
30
+ loadProtectedHelperProducer,
31
+ LINUX_PAIRED_UNINSTALL_ENTRY,
20
32
  modeWarningFor,
21
33
  runUninstallerScript,
22
34
  uninstallFailureSuggestion,
23
- uninstallerForRoot
35
+ uninstallerForRoot,
36
+ windowsExecutablePath
24
37
  } from '../lib/setup.mjs';
25
38
 
26
39
  const baseUrl = process.env.NC_BASE_URL || 'https://auth.nodecommand.app';
@@ -29,14 +42,16 @@ const baseUrl = process.env.NC_BASE_URL || 'https://auth.nodecommand.app';
29
42
  * downloads, and `/releases/*`. The auth host (`baseUrl` above) serves only
30
43
  * browser-facing OAuth/device flow and enrollment (`/oauth/*`, `/api/enroll*`);
31
44
  * 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.
45
+ * verification against the auth host answers 404. Fresh enrollment uses this
46
+ * configured API destination; installed credentials instead use their stored
47
+ * NC_SERVER_URL for authenticated HTTP, just like the agent runtime.
34
48
  */
35
49
  export const apiBase = process.env.NC_API_URL || 'https://agent.nodecommand.app';
36
50
  const clientId = 'nc-cli';
37
51
 
38
52
  export const DEFAULT_CLI_REQUEST_TIMEOUT_MS = 30_000;
39
53
  export const DEFAULT_CLI_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // 8 MiB
54
+ export const CLI_MAX_PACKAGE_BYTES = 512 * 1024 * 1024; // 512 MiB
40
55
 
41
56
  export function getCliRequestTimeoutMs() {
42
57
  const envVal = Number(process.env.NC_CLI_TIMEOUT_MS || process.env.NC_TIMEOUT_MS);
@@ -48,6 +63,81 @@ export function getCliMaxResponseBytes() {
48
63
  return Number.isFinite(envVal) && envVal > 0 ? envVal : DEFAULT_CLI_MAX_RESPONSE_BYTES;
49
64
  }
50
65
 
66
+ /**
67
+ * Hard ceiling for downloaded agent packages. Raised only through an explicit
68
+ * positive override so tests can exercise the streaming cap deterministically.
69
+ */
70
+ export function getCliMaxPackageBytes() {
71
+ const envVal = Number(process.env.NC_CLI_MAX_PACKAGE_BYTES);
72
+ return Number.isSafeInteger(envVal) && envVal > 0 ? envVal : CLI_MAX_PACKAGE_BYTES;
73
+ }
74
+
75
+ /** Package downloads are bounded separately from ordinary JSON requests. */
76
+ export const DEFAULT_CLI_DOWNLOAD_TIMEOUT_MS = 30 * 60_000; // 30 minutes
77
+ export const MIN_CLI_DOWNLOAD_TIMEOUT_MS = 30_000;
78
+ export const MAX_CLI_DOWNLOAD_TIMEOUT_MS = 60 * 60_000;
79
+
80
+ export function getCliDownloadTimeoutMs(env = process.env) {
81
+ const envVal = Number(env.NC_CLI_DOWNLOAD_TIMEOUT_MS);
82
+ if (!Number.isFinite(envVal) || envVal <= 0) return DEFAULT_CLI_DOWNLOAD_TIMEOUT_MS;
83
+ return Math.min(MAX_CLI_DOWNLOAD_TIMEOUT_MS, Math.max(MIN_CLI_DOWNLOAD_TIMEOUT_MS, Math.floor(envVal)));
84
+ }
85
+
86
+ /**
87
+ * Strips terminal control sequences and control characters from untrusted
88
+ * display text (server-provided versions, URLs, errors and messages) so a
89
+ * hostile field cannot forge logs or move the cursor. Line breaks collapse to
90
+ * spaces, keeping every rendered message on one line.
91
+ * @param {unknown} value
92
+ * @param {number} [maxLength]
93
+ */
94
+ export function sanitizeForDisplay(value, maxLength = 512) {
95
+ let text = typeof value === 'string' ? value : String(value ?? '');
96
+ text = text.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
97
+ text = text.replace(/\x1b[P^_X][\s\S]*?\x1b\\/g, '');
98
+ text = text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
99
+ text = text.replace(/\x1b[@-_]/g, '');
100
+ text = text.replace(/[\r\n\t]+/g, ' ');
101
+ text = text.replace(/[\x00-\x1f\x7f-\x9f]/g, '');
102
+ if (text.length > maxLength) text = `${text.slice(0, maxLength)}…`;
103
+ return text;
104
+ }
105
+
106
+ /** Explicit insecure-HTTP opt-in for local testing only. */
107
+ export const INSECURE_HTTP_OPT_IN_ENV = 'NC_ALLOW_INSECURE_HTTP';
108
+
109
+ export function allowsInsecureCredentialHttp(env = process.env) {
110
+ return String(env[INSECURE_HTTP_OPT_IN_ENV] ?? '').trim() === '1';
111
+ }
112
+
113
+ /**
114
+ * Refuses credential-bearing origins that are not HTTPS unless the explicit
115
+ * local-testing opt-in is set. Mirrors the stricter maintenance-config check.
116
+ * @param {string} targetUrl
117
+ * @param {{ env?: NodeJS.ProcessEnv, label?: string, action?: string }} [options]
118
+ */
119
+ export function assertCredentialOrigin(targetUrl, { env = process.env, label = 'server URL', action = 'send credentials' } = {}) {
120
+ let parsed;
121
+ try {
122
+ parsed = new URL(String(targetUrl));
123
+ } catch {
124
+ throw new PairingError('INVALID_SERVER_URL', `Refusing to ${action} with an invalid ${label}.`);
125
+ }
126
+ if (parsed.username || parsed.password) {
127
+ throw new PairingError('INVALID_SERVER_URL', `Refusing to ${action} to a ${label} that embeds user credentials.`);
128
+ }
129
+ if (parsed.protocol === 'http:' && !allowsInsecureCredentialHttp(env)) {
130
+ throw new PairingError(
131
+ 'INSECURE_SERVER_URL',
132
+ `Refusing to ${action} over plaintext http:// (${label}). Use an https:// URL; set ${INSECURE_HTTP_OPT_IN_ENV}=1 only for local testing.`
133
+ );
134
+ }
135
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
136
+ throw new PairingError('INVALID_SERVER_URL', `Refusing to ${action} to a non-HTTP(S) ${label}.`);
137
+ }
138
+ return parsed;
139
+ }
140
+
51
141
  /**
52
142
  * Sends a JSON HTTP request using node:https or node:http with timeout and response cap guards (NC-AUD-010).
53
143
  * @param {string} method
@@ -252,7 +342,19 @@ export function httpGet(targetUrl, maxRedirects = 5, extraHeaders = {}, options
252
342
  res.statusCode < 400 &&
253
343
  res.headers.location
254
344
  ) {
255
- const redirectUrl = new URL(res.headers.location, targetUrl).toString();
345
+ /** @type {string} */
346
+ let redirectUrl;
347
+ try {
348
+ redirectUrl = new URL(res.headers.location, targetUrl).toString();
349
+ } catch (err) {
350
+ // A malformed Location header must reject the request, never throw
351
+ // out of the response callback as an uncaughtException.
352
+ res.resume();
353
+ return reject(new PairingError(
354
+ 'INSTALL_DOWNLOAD_ERROR',
355
+ `The package response from ${targetUrl} contained an invalid redirect location: ${err instanceof Error ? err.message : String(err)}`
356
+ ));
357
+ }
256
358
  res.resume();
257
359
  const forwarded = isSameOrigin(targetUrl, redirectUrl) ? extraHeaders : {};
258
360
  return resolve(httpGet(redirectUrl, maxRedirects - 1, forwarded, { signal, timeoutMs }));
@@ -289,42 +391,111 @@ export async function fetchJson(targetUrl, extraHeaders = {}) {
289
391
  }
290
392
 
291
393
  /**
292
- * Downloads a remote file stream directly to disk.
394
+ * Downloads a remote file stream directly to disk with a hard byte ceiling and
395
+ * an incrementally computed SHA-256 digest. The archive is never loaded into
396
+ * the heap: bytes are hashed as they are written.
293
397
  * @param {string} targetUrl
294
398
  * @param {string} destinationPath
295
399
  * @param {Record<string, string>} [extraHeaders] Device headers for `/api/clients/...` package URLs; empty for public `/releases/...` URLs.
296
- * @param {{ timeoutMs?: number, signal?: AbortSignal }} [options]
297
- * @returns {Promise<string>}
400
+ * @param {{ timeoutMs?: number, signal?: AbortSignal, maxBytes?: number }} [options]
401
+ * @returns {Promise<{ path: string, bytes: number, sha256: string }>}
298
402
  */
299
403
  export async function downloadToFile(targetUrl, destinationPath, extraHeaders = {}, options = {}) {
300
- const res = await httpGet(targetUrl, 5, extraHeaders, options);
404
+ const maxBytes = Number.isSafeInteger(options.maxBytes) && options.maxBytes > 0 ? options.maxBytes : getCliMaxPackageBytes();
405
+ const timeoutMs = options.timeoutMs ?? getCliDownloadTimeoutMs();
406
+ const controller = new AbortController();
407
+ let timedOut = false;
408
+ let timer;
409
+ if (timeoutMs > 0) {
410
+ timer = setTimeout(() => {
411
+ timedOut = true;
412
+ controller.abort();
413
+ }, timeoutMs);
414
+ timer.unref?.();
415
+ }
416
+ const clearTimer = () => {
417
+ if (timer !== undefined) {
418
+ clearTimeout(timer);
419
+ timer = undefined;
420
+ }
421
+ };
422
+ const timeoutError = () => Object.assign(
423
+ new Error(`Agent package download timed out after ${timeoutMs}ms.`),
424
+ { code: 'CLI_DOWNLOAD_TIMEOUT', name: 'TimeoutError' }
425
+ );
426
+ const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
427
+ let res;
428
+ try {
429
+ res = await httpGet(targetUrl, 5, extraHeaders, { ...options, timeoutMs: 0, signal });
430
+ } catch (err) {
431
+ clearTimer();
432
+ throw timedOut ? timeoutError() : err;
433
+ }
301
434
  return new Promise((resolve, reject) => {
302
- const fileStream = fs.createWriteStream(destinationPath);
303
- let resolved = false;
435
+ const tooLarge = () => Object.assign(
436
+ new Error(`Agent package exceeds the maximum supported size of ${maxBytes} bytes.`),
437
+ { code: 'CLI_PACKAGE_TOO_LARGE' }
438
+ );
439
+ const declaredLength = Number(res.headers?.['content-length']);
440
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
441
+ res.resume();
442
+ clearTimer();
443
+ reject(tooLarge());
444
+ return;
445
+ }
446
+
447
+ const fileStream = fs.createWriteStream(destinationPath, { mode: 0o600 });
448
+ const hash = crypto.createHash('sha256');
449
+ let bytes = 0;
450
+ let settled = false;
451
+
452
+ const meter = new Transform({
453
+ transform(chunk, encoding, callback) {
454
+ bytes += chunk.length;
455
+ if (bytes > maxBytes) {
456
+ callback(tooLarge());
457
+ return;
458
+ }
459
+ hash.update(chunk);
460
+ callback(null, chunk);
461
+ }
462
+ });
304
463
 
305
464
  const onError = (err) => {
306
- if (resolved) return;
307
- resolved = true;
465
+ if (settled) return;
466
+ settled = true;
467
+ clearTimer();
308
468
  fileStream.destroy();
309
469
  try {
310
470
  fs.unlinkSync(destinationPath);
311
471
  } catch {}
312
- reject(err);
472
+ reject(timedOut ? timeoutError() : err);
313
473
  };
314
474
 
315
475
  res.on('error', onError);
476
+ meter.on('error', onError);
316
477
  fileStream.on('error', onError);
317
478
 
318
- fileStream.on('finish', () => {
319
- if (resolved) return;
320
- resolved = true;
321
- fileStream.close(() => resolve(destinationPath));
479
+ pipeline(res, meter, fileStream, (err) => {
480
+ if (err) return onError(err);
481
+ if (settled) return;
482
+ settled = true;
483
+ clearTimer();
484
+ resolve({ path: destinationPath, bytes, sha256: hash.digest('hex') });
322
485
  });
323
-
324
- res.pipe(fileStream);
325
486
  });
326
487
  }
327
488
 
489
+ /** Distinguishes an aborted/slow transfer from a credential or manifest failure. */
490
+ function isCliTimeoutError(err) {
491
+ return Boolean(err) && (
492
+ err.name === 'TimeoutError' ||
493
+ err.name === 'AbortError' ||
494
+ err.code === 'ABORT_ERR' ||
495
+ err.code === 'CLI_DOWNLOAD_TIMEOUT'
496
+ );
497
+ }
498
+
328
499
  /**
329
500
  * Determines the canonical installation root (NC_ROOT) dynamically based on
330
501
  * OS:
@@ -361,14 +532,81 @@ export function getNcRoot({ platform = os.platform(), env = process.env } = {})
361
532
  * @returns {string}
362
533
  */
363
534
  function getTarCommand() {
364
- if (os.platform() === 'win32') {
365
- const sysTar = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe');
366
- if (fs.existsSync(sysTar)) {
367
- return sysTar;
535
+ return resolveExecutable('tar');
536
+ }
537
+
538
+ /** Only pass GNU confinement flags when this tar actually advertises them. */
539
+ function tarSupportsNoAbsoluteFilenames(tarCmd) {
540
+ try {
541
+ const help = child_process.execFileSync(tarCmd, ['--help'], {
542
+ encoding: 'utf8',
543
+ stdio: ['ignore', 'pipe', 'ignore'],
544
+ timeout: 5000,
545
+ maxBuffer: 256 * 1024,
546
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C' }
547
+ });
548
+ return /--no-absolute-filenames/.test(String(help));
549
+ } catch {
550
+ return false;
551
+ }
552
+ }
553
+
554
+ function tarInspectionError(tarCmd, err) {
555
+ const detail = err?.stderr ? err.stderr.toString().trim() : err?.message;
556
+ return new Error(
557
+ `Failed to inspect tarball (${tarCmd}): ${sanitizeForDisplay(detail, 300)}`,
558
+ { cause: err }
559
+ );
560
+ }
561
+
562
+ function rejectsTarEntry(name) {
563
+ const value = String(name).trim();
564
+ if (!value) return false;
565
+ if (value.startsWith('/') || value.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(value)) return true;
566
+ return value.split(/[\\/]+/).includes('..');
567
+ }
568
+
569
+ /**
570
+ * Rejects traversal, absolute and link entries before extraction. This is
571
+ * defense-in-depth on top of GNU tar's --no-absolute-filenames; the archive is
572
+ * fully listed first so a hostile entry cannot be extracted at all.
573
+ */
574
+ function assertSafeTarArchive(tarCmd, archivePath) {
575
+ let listing;
576
+ try {
577
+ listing = child_process.execFileSync(tarCmd, ['-tzf', archivePath], {
578
+ encoding: 'utf8',
579
+ stdio: ['ignore', 'pipe', 'pipe'],
580
+ timeout: 30000,
581
+ maxBuffer: 4 * 1024 * 1024
582
+ });
583
+ } catch (err) {
584
+ throw tarInspectionError(tarCmd, err);
585
+ }
586
+ for (const name of String(listing).split(/\r?\n/)) {
587
+ if (rejectsTarEntry(name)) {
588
+ throw new Error(`Unsafe archive entry escapes the extraction root: ${sanitizeForDisplay(name, 200)}`);
589
+ }
590
+ }
591
+
592
+ let verbose;
593
+ try {
594
+ verbose = child_process.execFileSync(tarCmd, ['-tvzf', archivePath], {
595
+ encoding: 'utf8',
596
+ stdio: ['ignore', 'pipe', 'pipe'],
597
+ timeout: 30000,
598
+ maxBuffer: 4 * 1024 * 1024
599
+ });
600
+ } catch (err) {
601
+ throw tarInspectionError(tarCmd, err);
602
+ }
603
+ for (const line of String(verbose).split(/\r?\n/)) {
604
+ // Symlink (l) and hardlink (h) entries can redirect later writes outside
605
+ // the extraction root even when their names look safe.
606
+ if (/^[lh][rwxStTs-]{9}/.test(line)) {
607
+ throw new Error(`Unsafe archive link entry is not permitted: ${sanitizeForDisplay(line, 200)}`);
368
608
  }
369
- return 'tar.exe';
370
609
  }
371
- return 'tar';
372
610
  }
373
611
 
374
612
  /**
@@ -377,14 +615,18 @@ function getTarCommand() {
377
615
  * @param {string} targetDir
378
616
  */
379
617
  function extractTarball(archivePath, targetDir) {
380
- const tarCmd = getTarCommand();
618
+ let tarCmd = 'tar';
381
619
  try {
382
- child_process.execFileSync(tarCmd, ['-xzf', archivePath, '-C', targetDir], {
620
+ tarCmd = getTarCommand();
621
+ assertSafeTarArchive(tarCmd, archivePath);
622
+ const args = ['-xzf', archivePath, '-C', targetDir];
623
+ if (tarSupportsNoAbsoluteFilenames(tarCmd)) args.unshift('--no-absolute-filenames');
624
+ child_process.execFileSync(tarCmd, args, {
383
625
  stdio: ['ignore', 'pipe', 'pipe']
384
626
  });
385
627
  } catch (err) {
386
628
  const details = err.stderr ? err.stderr.toString().trim() : err.message;
387
- throw new Error(`Failed to extract tarball (${tarCmd}): ${details}`, { cause: err });
629
+ throw new Error(`Failed to extract tarball (${tarCmd}): ${sanitizeForDisplay(details, 300)}`, { cause: err });
388
630
  }
389
631
  }
390
632
 
@@ -431,33 +673,39 @@ export function findInstallerScript(tempDir) {
431
673
  */
432
674
  export function runInstallerScript(scriptPath, ncRoot, extraEnv = {}) {
433
675
  const platform = os.platform();
434
- const installEnv = {
435
- ...process.env,
436
- NC_ROOT: ncRoot,
437
- ...extraEnv
438
- };
439
676
 
440
677
  if (platform === 'win32') {
441
678
  child_process.execFileSync(
442
- 'powershell.exe',
443
- ['-ExecutionPolicy', 'Bypass', '-File', scriptPath],
679
+ windowsExecutablePath('powershell'),
680
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
444
681
  {
445
- env: installEnv,
682
+ env: { ...process.env, NC_ROOT: ncRoot, ...extraEnv },
446
683
  stdio: 'inherit'
447
684
  }
448
685
  );
449
- } else if (platform === 'darwin') {
450
- child_process.execFileSync('bash', [scriptPath], {
451
- env: installEnv,
452
- stdio: 'inherit'
453
- });
454
- } else {
455
- // Linux and other POSIX
456
- child_process.execFileSync('bash', [scriptPath], {
457
- env: installEnv,
458
- stdio: 'inherit'
459
- });
686
+ return;
460
687
  }
688
+
689
+ // The OS installer runs with root privileges, so it never inherits the
690
+ // caller's environment. Only a fixed base plus NC_* installer knobs and
691
+ // explicit extras reach it; BASH_ENV/ENV/SHELLOPTS/LD_*/NODE_OPTIONS cannot.
692
+ const installEnv = {
693
+ PATH: '/usr/bin:/bin:/usr/sbin:/sbin',
694
+ HOME: os.userInfo().homedir,
695
+ LANG: 'C',
696
+ LC_ALL: 'C'
697
+ };
698
+ for (const [name, value] of Object.entries(process.env)) {
699
+ if (name.startsWith('NC_') && typeof value === 'string') installEnv[name] = value;
700
+ }
701
+ Object.assign(installEnv, { NC_ROOT: ncRoot }, extraEnv);
702
+ // `-p` (privileged mode) plus --noprofile/--norc ignores BASH_ENV/ENV startup
703
+ // files; cwd '/' keeps a user-writable directory out of the root installer.
704
+ child_process.execFileSync(resolveExecutable('bash'), ['--noprofile', '--norc', '-p', '--', scriptPath], {
705
+ env: installEnv,
706
+ stdio: 'inherit',
707
+ cwd: '/'
708
+ });
461
709
  }
462
710
 
463
711
  /**
@@ -526,17 +774,240 @@ export function getEnrollStatePath(ncRoot = getNcRoot()) {
526
774
  return path.join(ncRoot, 'config', 'nc-enroll-state.json');
527
775
  }
528
776
 
777
+ /**
778
+ * Serializes setup/install/uninstall for one installation root. The lock file
779
+ * lives under the root, is created with O_CREAT|O_EXCL and 0600 (O_EXCL
780
+ * refuses an existing file or symlink instead of following it), carries the
781
+ * owner PID, and is released by the exit handler as well as by the caller's
782
+ * finally block (so `process.exit` cannot leave it behind). A live owner is a
783
+ * clear SETUP_IN_PROGRESS refusal; a dead PID is stale and is reclaimed once.
784
+ * @param {string} ncRoot
785
+ * @returns {{ release: () => void }}
786
+ */
787
+ export function acquireInstallLock(ncRoot) {
788
+ const configDir = path.join(ncRoot, 'config');
789
+ const lockPath = path.join(configDir, '.nc-cli.lock');
790
+ const createdConfig = !fs.existsSync(configDir);
791
+ const createdRoot = createdConfig && !fs.existsSync(ncRoot);
792
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o750 });
793
+
794
+ const readOwnerPid = () => {
795
+ let fd;
796
+ try {
797
+ fd = fs.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
798
+ const info = fs.fstatSync(fd);
799
+ if (!info.isFile() || info.size > 32) return null;
800
+ const text = fs.readFileSync(fd, 'utf8').trim();
801
+ return /^\d+$/.test(text) ? Number(text) : null;
802
+ } catch {
803
+ return null;
804
+ } finally {
805
+ if (fd !== undefined) fs.closeSync(fd);
806
+ }
807
+ };
808
+
809
+ const pidIsAlive = (pid) => {
810
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
811
+ try {
812
+ process.kill(pid, 0);
813
+ return true;
814
+ } catch (err) {
815
+ return err?.code === 'EPERM';
816
+ }
817
+ };
818
+
819
+ let acquired = false;
820
+ for (let attempt = 0; attempt < 2 && !acquired; attempt += 1) {
821
+ try {
822
+ // `wx` is O_WRONLY|O_CREAT|O_EXCL: an existing file OR symlink at the
823
+ // lock path fails with EEXIST instead of being followed or truncated.
824
+ fs.writeFileSync(lockPath, `${process.pid}\n`, { flag: 'wx', mode: 0o600 });
825
+ if (process.platform !== 'win32') {
826
+ try {
827
+ fs.chmodSync(lockPath, 0o600);
828
+ } catch {}
829
+ }
830
+ acquired = true;
831
+ } catch (err) {
832
+ if (err?.code !== 'EEXIST') throw err;
833
+ const owner = readOwnerPid();
834
+ if (owner === null || pidIsAlive(owner)) {
835
+ throw new PairingError(
836
+ 'SETUP_IN_PROGRESS',
837
+ `Another Node Command setup, install or uninstall is already running for ${ncRoot}${owner === null ? '' : ` (pid ${owner})`}. Wait for it to finish; no configuration was changed.`
838
+ );
839
+ }
840
+ // Stale owner: reclaim the lock exactly once.
841
+ try {
842
+ fs.unlinkSync(lockPath);
843
+ } catch (unlinkErr) {
844
+ if (unlinkErr?.code !== 'ENOENT') throw unlinkErr;
845
+ }
846
+ }
847
+ }
848
+ if (!acquired) {
849
+ throw new PairingError('SETUP_IN_PROGRESS', `Could not acquire the setup lock for ${ncRoot}; no configuration was changed.`);
850
+ }
851
+
852
+ let released = false;
853
+ const release = () => {
854
+ if (released) return;
855
+ released = true;
856
+ try {
857
+ const owner = readOwnerPid();
858
+ if (owner === process.pid) fs.unlinkSync(lockPath);
859
+ } catch {}
860
+ // Remove empty directories created only for the lock so a failed setup
861
+ // does not leave a root that blocks the next attempt.
862
+ if (createdConfig) {
863
+ try {
864
+ fs.rmdirSync(configDir);
865
+ if (createdRoot) fs.rmdirSync(ncRoot);
866
+ } catch {}
867
+ }
868
+ };
869
+ process.once('exit', release);
870
+ return {
871
+ release: () => {
872
+ process.removeListener('exit', release);
873
+ release();
874
+ }
875
+ };
876
+ }
877
+
878
+ /**
879
+ * Prepares the token transport used for legacy `setup <host_id> <token>`
880
+ * elevation: the secret goes to a 0600 owner-only temp file and only its path
881
+ * enters sudo/UAC argv.
882
+ * @param {string} token
883
+ */
884
+ export function createEnrollmentTokenFile(token) {
885
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nc-enroll-'));
886
+ const filePath = path.join(dir, 'token');
887
+ fs.writeFileSync(filePath, String(token), { mode: 0o600, flag: 'wx' });
888
+ if (os.platform() !== 'win32') {
889
+ try {
890
+ fs.chmodSync(filePath, 0o600);
891
+ } catch {}
892
+ }
893
+ let cleaned = false;
894
+ const cleanup = () => {
895
+ if (cleaned) return;
896
+ cleaned = true;
897
+ try {
898
+ fs.rmSync(dir, { recursive: true, force: true });
899
+ } catch {}
900
+ };
901
+ // process.exit() skips finally blocks, so the exit hook guarantees the
902
+ // secret file is removed even on the declined-elevation hard stops.
903
+ process.once('exit', cleanup);
904
+ return {
905
+ filePath,
906
+ cleanup: () => {
907
+ process.removeListener('exit', cleanup);
908
+ cleanup();
909
+ }
910
+ };
911
+ }
912
+
913
+ /**
914
+ * Reads the elevated-invocation token file (no symlinks, owner-only) and
915
+ * removes it immediately after use.
916
+ * @param {string} filePath
917
+ */
918
+ function readEnrollmentTokenFile(filePath) {
919
+ if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) {
920
+ throw new PairingError('INVALID_ENROLL_TOKEN', 'The elevated enrollment token file path is invalid.');
921
+ }
922
+ let fd;
923
+ let token;
924
+ try {
925
+ try {
926
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
927
+ } catch {
928
+ throw new PairingError('INVALID_ENROLL_TOKEN', 'The elevated enrollment token file is missing, unreadable, or a symlink.');
929
+ }
930
+ const info = fs.fstatSync(fd);
931
+ if (!info.isFile() || info.size <= 0 || info.size > 1024) {
932
+ throw new PairingError('INVALID_ENROLL_TOKEN', 'The elevated enrollment token file is not a valid token file.');
933
+ }
934
+ if (os.platform() !== 'win32') {
935
+ if ((info.mode & 0o077) !== 0) {
936
+ throw new PairingError('INVALID_ENROLL_TOKEN', 'The elevated enrollment token file is not owner-only.');
937
+ }
938
+ const currentUid = typeof process.getuid === 'function' ? process.getuid() : null;
939
+ if (currentUid !== null) {
940
+ const allowedUids = new Set([currentUid]);
941
+ // `sudo` runs the elevated reader as root while the pre-sudo writer
942
+ // owned the file as the invoking user. Trust SUDO_UID only when the
943
+ // reader is actually root, and only as a validated numeric uid.
944
+ if (currentUid === 0 && typeof process.env.SUDO_UID === 'string' && /^\d+$/.test(process.env.SUDO_UID)) {
945
+ const sudoUid = Number(process.env.SUDO_UID);
946
+ if (Number.isSafeInteger(sudoUid) && sudoUid >= 0) allowedUids.add(sudoUid);
947
+ }
948
+ if (!allowedUids.has(info.uid)) {
949
+ throw new PairingError('INVALID_ENROLL_TOKEN', 'The elevated enrollment token file is not owned by the invoking user.');
950
+ }
951
+ }
952
+ }
953
+ token = fs.readFileSync(fd, 'utf8');
954
+ } finally {
955
+ if (fd !== undefined) fs.closeSync(fd);
956
+ try {
957
+ fs.unlinkSync(filePath);
958
+ const dir = path.dirname(filePath);
959
+ if (path.basename(dir).startsWith('nc-enroll-')) fs.rmdirSync(dir);
960
+ } catch {}
961
+ }
962
+ return token;
963
+ }
964
+
965
+ /**
966
+ * Replaces the legacy enrollment token in the elevated argv with a file path.
967
+ * Pure so the no-secret-in-argv property is directly testable.
968
+ * @param {string[]} nodeArgs process.argv.slice(1)
969
+ * @param {string[]} setupArgs subcommand args (without a literal `setup`)
970
+ * @param {number} tokenIndex index of the token in setupArgs
971
+ * @param {string} tokenFile
972
+ * @returns {string[]}
973
+ */
974
+ export function buildElevatedSetupArgs(nodeArgs, setupArgs, tokenIndex, tokenFile) {
975
+ if (!Array.isArray(nodeArgs) || !Array.isArray(setupArgs) || tokenIndex < 0 || tokenIndex >= setupArgs.length) return [...nodeArgs];
976
+ const prefixLength = nodeArgs.length - setupArgs.length;
977
+ const elevated = [...nodeArgs];
978
+ elevated.splice(prefixLength + tokenIndex, 1, '--enrollment-token-file', tokenFile);
979
+ // A bare `<host_id> <token>` invocation must route through the setup handler
980
+ // after elevation; adding the subcommand keeps dispatch unchanged.
981
+ if (prefixLength === 1) elevated.splice(1, 0, 'setup');
982
+ return elevated;
983
+ }
984
+
529
985
  /**
530
986
  * Builds the canonical agent env file content from HOST credentials only.
531
987
  * There is deliberately no parameter for the OAuth token: by construction it
532
988
  * cannot end up in the file. Host id/token shapes are enforced so a caller
533
989
  * mistake (e.g. passing an enrollment token as the client token) fails here
534
990
  * instead of producing a broken agent that crash-loops on 401.
535
- * @param {{ serverUrl: string, hostId: string, hostToken: string, clientName?: string }} options
991
+ * @param {{ serverUrl: string, hostId: string, hostToken: string, clientName?: string, browserBrand?: string }} options
536
992
  */
537
- export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName = os.hostname() }) {
538
- if (typeof serverUrl !== 'string' || !serverUrl) {
539
- throw new PairingError('INVALID_SERVER_URL', 'Refusing to write agent config without a server URL.');
993
+ export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName = os.hostname(), browserBrand = 'unknown' }) {
994
+ if (typeof serverUrl !== 'string' || !serverUrl || /[\x00-\x1f\x7f]/.test(serverUrl)) {
995
+ throw new PairingError('INVALID_SERVER_URL', 'Refusing to write agent config without a valid server URL.');
996
+ }
997
+ let parsedServerUrl;
998
+ try {
999
+ parsedServerUrl = new URL(serverUrl);
1000
+ } catch {
1001
+ throw new PairingError('INVALID_SERVER_URL', 'Refusing to write agent config: the server URL is not an absolute http(s) URL.');
1002
+ }
1003
+ if ((parsedServerUrl.protocol !== 'https:' && parsedServerUrl.protocol !== 'http:') || parsedServerUrl.username || parsedServerUrl.password) {
1004
+ throw new PairingError('INVALID_SERVER_URL', 'Refusing to write agent config: the server URL must be http(s) and must not embed credentials.');
1005
+ }
1006
+ if (parsedServerUrl.protocol === 'http:' && !allowsInsecureCredentialHttp()) {
1007
+ throw new PairingError('INVALID_SERVER_URL', `Refusing to write agent config: plaintext http:// is not allowed for a credential-bearing server URL; use https:// (set ${INSECURE_HTTP_OPT_IN_ENV}=1 only for local testing).`);
1008
+ }
1009
+ if (typeof clientName !== 'string' || /[\x00-\x1f\x7f]/.test(clientName)) {
1010
+ throw new PairingError('INVALID_CLIENT_NAME', 'Refusing to write agent config: the client name contains control characters.');
540
1011
  }
541
1012
  if (!HOST_ID_RE.test(String(hostId || ''))) {
542
1013
  throw new PairingError(
@@ -556,6 +1027,7 @@ export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName =
556
1027
  `NC_CLIENT_NAME=${clientName}`,
557
1028
  `NC_CLIENT_TOKEN=${hostToken}`,
558
1029
  'NC_REMOTE_EXEC_ENABLED=true',
1030
+ `NC_BROWSER_DEFAULT_BRAND=${normalizeBrowserBrand(browserBrand)}`,
559
1031
  'NC_SESSION_ENABLED=true'
560
1032
  ].join('\n') + '\n';
561
1033
  }
@@ -604,6 +1076,11 @@ export function loadValidHostEnv(envPath = getEnvFilePath()) {
604
1076
  } catch {
605
1077
  return null;
606
1078
  }
1079
+ // Duplicate required keys are ambiguous (a repeated line could shadow the
1080
+ // verified identity), so the loader refuses the file instead of last-wins.
1081
+ for (const name of ['NC_CLIENT_ID', 'NC_CLIENT_TOKEN', 'NC_SERVER_URL']) {
1082
+ if (text.split(/\r?\n/).filter((line) => new RegExp(`^\\s*${name}\\s*=`).test(line)).length > 1) return null;
1083
+ }
607
1084
  const env = parseEnvFile(text);
608
1085
  if (!HOST_ID_RE.test(String(env.NC_CLIENT_ID || ''))) return null;
609
1086
  if (!HOST_TOKEN_RE.test(String(env.NC_CLIENT_TOKEN || ''))) return null;
@@ -611,6 +1088,15 @@ export function loadValidHostEnv(envPath = getEnvFilePath()) {
611
1088
  return env;
612
1089
  }
613
1090
 
1091
+ function assertMatchingHostIdentity(localEnv, requestedHostId) {
1092
+ if (localEnv && requestedHostId && localEnv.NC_CLIENT_ID !== requestedHostId) {
1093
+ throw new PairingError(
1094
+ 'HOST_ID_MISMATCH',
1095
+ `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.`
1096
+ );
1097
+ }
1098
+ }
1099
+
614
1100
  /**
615
1101
  * Resume marker for idempotent re-runs. Holds the host id and lifecycle
616
1102
  * phase only — never any token or key material (those live in nc-client.env
@@ -716,6 +1202,52 @@ export function loadDeviceKeyPair(keyFile) {
716
1202
  return { privateKey: key, publicKey, thumbprint: deviceKeyThumbprintCli(publicKey) };
717
1203
  }
718
1204
 
1205
+ /** Read only the selected ordinary key for Root-managed paired verification. */
1206
+ function loadRootManagedPairKey(ncRoot, ordinaryUid) {
1207
+ const invalid = () => { throw new Error('Root-managed pair device key cannot be verified safely.'); };
1208
+ if (os.platform() !== 'linux' || process.getuid?.() !== 0 || process.geteuid?.() !== 0 ||
1209
+ !['/opt/nc', '/var/lib/nodecommand/nc'].includes(ncRoot) || !Number.isSafeInteger(ordinaryUid) || ordinaryUid <= 0) invalid();
1210
+ let directoryFd, keyFd;
1211
+ try {
1212
+ // Pin each directory before opening its child. NOFOLLOW on the key alone
1213
+ // would still follow a replaced ordinary root/config ancestor as Root.
1214
+ const components = ncRoot === '/opt/nc' ? ['', 'opt', 'nc', 'config'] : ['', 'var', 'lib', 'nodecommand', 'nc', 'config'];
1215
+ for (const component of components) {
1216
+ const next = fs.openSync(component ? `/proc/self/fd/${directoryFd}/${component}` : '/',
1217
+ fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
1218
+ if (directoryFd !== undefined) fs.closeSync(directoryFd);
1219
+ directoryFd = next;
1220
+ const info = fs.fstatSync(directoryFd);
1221
+ const ordinaryRoot = ncRoot === '/var/lib/nodecommand/nc' && component === 'nc';
1222
+ const protectedAncestor = !ordinaryRoot && component !== 'config';
1223
+ if (!info.isDirectory() || (ordinaryRoot ? ![0, ordinaryUid].includes(info.uid) : info.uid !== (protectedAncestor ? 0 : ordinaryUid)) ||
1224
+ (protectedAncestor && (info.gid !== 0 || (info.mode & 0o022)))) invalid();
1225
+ }
1226
+ keyFd = fs.openSync(`/proc/self/fd/${directoryFd}/nc-device-key.pem`,
1227
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
1228
+ const before = fs.fstatSync(keyFd);
1229
+ if (!before.isFile() || before.uid !== ordinaryUid || before.nlink !== 1 ||
1230
+ !Number.isSafeInteger(before.size) || before.size <= 0 || before.size > 65536) invalid();
1231
+ const buffer = Buffer.alloc(65537);
1232
+ let length = 0;
1233
+ while (length < buffer.length) {
1234
+ const count = fs.readSync(keyFd, buffer, length, buffer.length - length, null);
1235
+ if (count === 0) break;
1236
+ length += count;
1237
+ }
1238
+ const after = fs.fstatSync(keyFd);
1239
+ if (length !== before.size || length > 65536 || !after.isFile() ||
1240
+ ['dev', 'ino', 'uid', 'gid', 'mode', 'nlink', 'size', 'mtimeMs', 'ctimeMs'].some(field => before[field] !== after[field])) invalid();
1241
+ const privateKey = crypto.createPrivateKey(buffer.subarray(0, length));
1242
+ if (privateKey.asymmetricKeyType !== 'ed25519') invalid();
1243
+ const publicKey = publicRawFromPrivateCli(privateKey);
1244
+ return { privateKey, publicKey, thumbprint: deviceKeyThumbprintCli(publicKey) };
1245
+ } finally {
1246
+ if (keyFd !== undefined) fs.closeSync(keyFd);
1247
+ if (directoryFd !== undefined) fs.closeSync(directoryFd);
1248
+ }
1249
+ }
1250
+
719
1251
  /**
720
1252
  * Loads the device key, generating and persisting a fresh Ed25519 key when
721
1253
  * none exists (atomic tmp-write + rename, 0600). Mirrors
@@ -813,6 +1345,7 @@ export async function requestEnrollment({ baseUrl, oauthToken, platform = 'linux
813
1345
  throw new PairingError('ENROLL_NO_OAUTH_TOKEN', 'Cannot request enrollment without a sign-in token. Re-run the device flow.');
814
1346
  }
815
1347
  const origin = String(baseUrl || '').replace(/\/$/, '');
1348
+ assertCredentialOrigin(origin, { label: 'enrollment origin' });
816
1349
  const body = { platform, ...(name ? { name } : {}), ...(tenantId ? { tenant_id: tenantId } : {}) };
817
1350
  let res;
818
1351
  try {
@@ -826,8 +1359,8 @@ export async function requestEnrollment({ baseUrl, oauthToken, platform = 'linux
826
1359
  }
827
1360
  return res.data;
828
1361
  }
829
- const serverError = res.data?.error;
830
- const serverMessage = typeof res.data?.message === 'string' ? `: ${res.data.message}` : '';
1362
+ const serverError = sanitizeForDisplay(res.data?.error || '', 120);
1363
+ const serverMessage = typeof res.data?.message === 'string' ? `: ${sanitizeForDisplay(res.data.message)}` : '';
831
1364
  if (res.status === 401) {
832
1365
  throw new PairingError('ENROLL_UNAUTHORIZED', 'Sign-in expired or was revoked. Re-run the device flow to sign in again.');
833
1366
  }
@@ -852,14 +1385,16 @@ export async function requestEnrollment({ baseUrl, oauthToken, platform = 'linux
852
1385
  /**
853
1386
  * Redeems a one-time enrollment token with Device Proof v1, binding the
854
1387
  * 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
1388
+ * (client_id, client_token, release, optional execution_initialization). The
1389
+ * selected intent is covered by the unchanged proof's exact pathname/query.
1390
+ * Token authentication failures do not disclose whether redemption committed.
1391
+ * @param {{ baseUrl: string, hostId: string, enrollmentToken: string, initialPermission?: 'user'|'admin', keyPair: { privateKey: import('node:crypto').KeyObject, publicKey: string, thumbprint: string } }} options
859
1392
  */
860
- export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPair }) {
1393
+ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPair, initialPermission = 'user' }) {
1394
+ if (!['user', 'admin'].includes(initialPermission)) throw new PairingError('INVALID_INITIAL_PERMISSION', 'Initial execution intent must be user or admin.');
861
1395
  const origin = String(baseUrl || '').replace(/\/$/, '');
862
- const redeemPath = '/api/enroll/redeem';
1396
+ assertCredentialOrigin(origin, { label: 'redemption origin' });
1397
+ const redeemPath = `/api/enroll/redeem?initial_permission=${initialPermission}`;
863
1398
  const proof = createDeviceProofCli({
864
1399
  privateKey: keyPair.privateKey,
865
1400
  hostId,
@@ -875,7 +1410,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
875
1410
  { 'NC-Device-Proof': proof }
876
1411
  );
877
1412
  } catch (err) {
878
- throw new PairingError('REDEEM_NETWORK_ERROR', `Could not reach the redemption endpoint: ${err.message}`);
1413
+ 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
1414
  }
880
1415
  if (res.status === 200 && res.data) {
881
1416
  const r = res.data;
@@ -887,11 +1422,11 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
887
1422
  }
888
1423
  return r;
889
1424
  }
890
- const serverError = res.data?.error;
1425
+ const serverError = sanitizeForDisplay(res.data?.error || '', 120);
891
1426
  if (res.status === 410 && serverError === 'ENROLLMENT_ALREADY_USED') {
892
1427
  throw new PairingError(
893
1428
  'ENROLLMENT_ALREADY_USED',
894
- 'This enrollment was already redeemed (a previous run consumed it). Re-running resumes from the locally stored host credentials.'
1429
+ '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
1430
  );
896
1431
  }
897
1432
  if (res.status === 410) {
@@ -901,7 +1436,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
901
1436
  );
902
1437
  }
903
1438
  if (res.status === 401) {
904
- throw new PairingError('REDEEM_UNAUTHORIZED', `Redemption was refused (${serverError || 'unauthorized'}). Re-run to request a fresh enrollment.`);
1439
+ 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
1440
  }
906
1441
  throw new PairingError('REDEEM_REJECTED', `Redemption failed (${serverError || `HTTP ${res.status}`}). Re-run to request a fresh enrollment.`);
907
1442
  }
@@ -912,14 +1447,16 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
912
1447
  * uses). Polls until the identity is accepted or the timeout expires.
913
1448
  * Success here proves the env credentials actually authenticate; only then
914
1449
  * 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
1450
+ * Device-channel endpoints use the supplied agent destination. Resume callers
1451
+ * must supply the stored NC_SERVER_URL, not the fresh-enrollment API default.
1452
+ * `baseUrl` stays accepted for backward
917
1453
  * compatibility but an explicit `apiBase` (or `NC_API_URL`/default agent
918
1454
  * origin when neither is given) wins.
919
1455
  * @param {{ baseUrl?: string, apiBase?: string, hostId: string, hostToken: string, privateKey: import('node:crypto').KeyObject, timeoutMs?: number, intervalMs?: number }} options
920
1456
  */
921
1457
  export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined, apiBase: apiBaseOpt = undefined, hostId, hostToken, privateKey, timeoutMs = 45000, intervalMs = 2000 }) {
922
1458
  const origin = String(apiBaseOpt ?? baseUrlOpt ?? process.env.NC_API_URL ?? apiBase).replace(/\/$/, '');
1459
+ assertCredentialOrigin(origin, { label: 'agent server origin' });
923
1460
  const target = `/api/clients/${encodeURIComponent(hostId)}/policy`;
924
1461
  const deadline = Date.now() + Math.max(1000, Number(timeoutMs) || 45000);
925
1462
  const step = Math.max(250, Math.min(Number(intervalMs) || 2000, deadline - Date.now()));
@@ -932,20 +1469,25 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
932
1469
  'NC-Device-Proof': proof
933
1470
  });
934
1471
  if (res.status === 200 && res.data?.policy) return { ok: true, hostId };
935
- lastError = res.data?.error || `HTTP ${res.status}`;
1472
+ lastError = sanitizeForDisplay(res.data?.error || `HTTP ${res.status}`, 200);
936
1473
  // A missing client row will never appear by waiting: fail fast so the
937
1474
  // user gets the real cause instead of a timeout.
938
1475
  if (res.status === 404) {
939
- throw new PairingError('VERIFY_FAILED', `Host ${hostId} is not registered on the server (${lastError}). Re-run to enroll again.`);
1476
+ throw new PairingError('VERIFY_FAILED', `Host ${sanitizeForDisplay(hostId, 128)} is not registered on the server (${lastError}). Re-run to enroll again.`);
1477
+ }
1478
+ // A refused device credential cannot become valid by waiting: fail fast
1479
+ // instead of polling until the verification timeout.
1480
+ if (res.status === 401 || res.status === 403) {
1481
+ throw new PairingError('VERIFY_FAILED', `Host ${sanitizeForDisplay(hostId, 128)} was refused at ${sanitizeForDisplay(origin, 256)} (${lastError}). The stored device credential is not accepted; re-run to enroll again.`);
940
1482
  }
941
1483
  } catch (err) {
942
1484
  if (err instanceof PairingError) throw err;
943
- lastError = err.message;
1485
+ lastError = sanitizeForDisplay(err.message, 200);
944
1486
  }
945
1487
  if (Date.now() >= deadline) {
946
1488
  throw new PairingError(
947
1489
  'VERIFY_TIMEOUT',
948
- `Could not confirm host ${hostId} on the server in time (last: ${lastError}). The credentials are stored; check connectivity and re-run to verify.`
1490
+ `Could not confirm host ${sanitizeForDisplay(hostId, 128)} on the server in time (last: ${lastError}). The credentials are stored; check connectivity and re-run to verify.`
949
1491
  );
950
1492
  }
951
1493
  await sleep(Math.min(step, Math.max(250, deadline - Date.now())));
@@ -977,11 +1519,11 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
977
1519
  * (`/releases/client/<version>/bootstrap.tar.gz`) stays anonymous. The
978
1520
  * proof binds method GET + the full request URL + the redemption-issued
979
1521
  * 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
1522
+ * @param {{ ncRoot?: string, updatesUrl?: string, serverUrl?: string, release?: Record<string, unknown>|null, installMode?: 'system'|'user', rootConsent?: boolean, runInstaller?: (scriptPath: string, ncRoot: string) => void, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject, downloadTimeoutMs?: number }} options
981
1523
  */
982
- export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl = undefined, serverUrl = undefined, release = undefined, installMode = undefined, runInstaller = undefined, hostId = undefined, hostToken = undefined, privateKey = undefined } = {}) {
1524
+ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl = undefined, serverUrl = undefined, release = undefined, installMode = undefined, rootConsent = false, runInstaller = undefined, hostId = undefined, hostToken = undefined, privateKey = undefined, downloadTimeoutMs = undefined } = {}) {
983
1525
  ncRoot = ncRoot || (installMode ? getNcRootForMode(installMode) : getNcRoot());
984
- const modeEnv = installMode ? installerEnvForMode(installMode) : {};
1526
+ const modeEnv = { ...(installMode ? installerEnvForMode(installMode) : {}), NC_ROOT_COMMAND_CONSENT: rootConsent === true ? 'yes' : 'no' };
985
1527
  const invokeInstaller =
986
1528
  runInstaller || ((scriptPath, root) => runInstallerScript(scriptPath, root, modeEnv));
987
1529
  const root = ncRoot;
@@ -993,28 +1535,33 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
993
1535
  // used here. NC_SERVER_URL remains the agent server address and is read
994
1536
  // before the API origin so existing installs keep resolving as before.
995
1537
  const defaultBaseUrl = serverUrl || process.env.NC_SERVER_URL || process.env.NC_API_URL || apiBase;
1538
+ const hasDeviceIdentity =
1539
+ typeof hostId === 'string' &&
1540
+ hostId &&
1541
+ typeof hostToken === 'string' &&
1542
+ hostToken &&
1543
+ privateKey;
1544
+ // Device identity must never travel over plaintext HTTP, including the
1545
+ // fresh-enrollment resume paths that skip the manifest above.
1546
+ if (hasDeviceIdentity) {
1547
+ assertCredentialOrigin(defaultBaseUrl, { label: 'agent server origin' });
1548
+ }
996
1549
  /** @type {Record<string, any>} */
997
1550
  let activeRelease;
998
1551
  if (release && typeof release === 'object') {
999
1552
  // Redemption release wins: skip the manifest entirely, so its URL is
1000
1553
  // never logged as a misleading signal.
1001
1554
  activeRelease = /** @type {Record<string, any>} */ (release);
1002
- const releaseVersion = activeRelease.version ? ` ${activeRelease.version}` : '';
1555
+ const releaseVersion = activeRelease.version ? ` ${sanitizeForDisplay(activeRelease.version, 120)}` : '';
1003
1556
  console.log(`📦 Using release${releaseVersion} from enrollment redemption; skipping update manifest.`);
1004
- } else if (
1005
- typeof hostId === 'string' &&
1006
- hostId &&
1007
- typeof hostToken === 'string' &&
1008
- hostToken &&
1009
- privateKey
1010
- ) {
1557
+ } else if (hasDeviceIdentity) {
1011
1558
  // Resume paths (verified stored credentials, already-redeemed enrollment)
1012
1559
  // carry no redemption release, but they DO hold device identity. Resolve
1013
1560
  // the release from the client-authenticated per-host manifest the agent
1014
1561
  // updater itself uses, never from the admin-only /api/updates manifest
1015
1562
  // (which can only answer 401/404 to a non-admin CLI).
1016
1563
  const clientManifestUrl = `${defaultBaseUrl}/api/clients/${encodeURIComponent(hostId)}/update/manifest`;
1017
- console.log(`📡 Fetching client update manifest from ${clientManifestUrl}...`);
1564
+ console.log(`📡 Fetching client update manifest from ${sanitizeForDisplay(clientManifestUrl, 256)}...`);
1018
1565
  let manifestData;
1019
1566
  try {
1020
1567
  manifestData = await fetchJson(
@@ -1025,14 +1572,17 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1025
1572
  const detail = err instanceof Error ? err.message : String(err);
1026
1573
  throw new PairingError(
1027
1574
  'INSTALL_MANIFEST_ERROR',
1028
- `Could not fetch the client update manifest: ${detail}`
1575
+ `Could not fetch the client update manifest: ${sanitizeForDisplay(detail, 300)}`
1029
1576
  );
1030
1577
  }
1031
1578
  activeRelease = manifestData?.release || manifestData || {};
1032
1579
  } else {
1033
1580
  const manifestOrigin = String(process.env.NC_API_URL || apiBase).replace(/\/$/, '');
1034
1581
  const manifestUrl = updatesUrl || process.env.NC_UPDATES_URL || `${manifestOrigin}/api/updates`;
1035
- console.log(`📡 Fetching update manifest from ${manifestUrl}...`);
1582
+ // The anonymous manifest controls sha256, size and package URLs; a MITM
1583
+ // over plaintext HTTP could rewrite all three.
1584
+ assertCredentialOrigin(manifestUrl, { label: 'update manifest URL', action: 'fetch' });
1585
+ console.log(`📡 Fetching update manifest from ${sanitizeForDisplay(manifestUrl, 256)}...`);
1036
1586
  let manifestData;
1037
1587
  try {
1038
1588
  manifestData = await fetchJson(manifestUrl);
@@ -1041,10 +1591,10 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1041
1591
  if (/status 401\b/i.test(detail)) {
1042
1592
  throw new PairingError(
1043
1593
  'INSTALL_MANIFEST_ERROR',
1044
- `Update manifest refused with ADMIN_AUTH_REQUIRED (${detail}). The manifest endpoint is admin-only; run with an enrollment (setup <host_id> <token>) so the release ships with the redemption, then re-run.`
1594
+ `Update manifest refused with ADMIN_AUTH_REQUIRED (${sanitizeForDisplay(detail, 300)}). The manifest endpoint is admin-only; run with an enrollment (setup <host_id> <token>) so the release ships with the redemption, then re-run.`
1045
1595
  );
1046
1596
  }
1047
- throw new PairingError('INSTALL_MANIFEST_ERROR', `Could not fetch the agent release manifest: ${detail}`);
1597
+ throw new PairingError('INSTALL_MANIFEST_ERROR', `Could not fetch the agent release manifest: ${sanitizeForDisplay(detail, 300)}`);
1048
1598
  }
1049
1599
  if (manifestData && typeof manifestData === 'object' && manifestData.error === 'ADMIN_AUTH_REQUIRED') {
1050
1600
  throw new PairingError(
@@ -1069,6 +1619,15 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1069
1619
  if (!tarballUrl.startsWith('http://') && !tarballUrl.startsWith('https://')) {
1070
1620
  tarballUrl = new URL(tarballUrl, defaultBaseUrl).toString();
1071
1621
  }
1622
+ let parsedTarballUrl;
1623
+ try {
1624
+ parsedTarballUrl = new URL(tarballUrl);
1625
+ } catch {
1626
+ throw new PairingError('INSTALL_MANIFEST_ERROR', 'Agent package URL is not a valid absolute URL.');
1627
+ }
1628
+ if (parsedTarballUrl.username || parsedTarballUrl.password) {
1629
+ throw new PairingError('INSTALL_MANIFEST_ERROR', 'Agent package URL must not embed user credentials.');
1630
+ }
1072
1631
  } else {
1073
1632
  if (!version) {
1074
1633
  throw new PairingError('INSTALL_MANIFEST_ERROR', 'Unable to determine agent version from update manifest.');
@@ -1076,6 +1635,28 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1076
1635
  tarballUrl = `${defaultBaseUrl}/releases/client/${encodeURIComponent(version)}/bootstrap.tar.gz`;
1077
1636
  }
1078
1637
 
1638
+ const expectedHash = activeRelease.bootstrap_sha256 ?? activeRelease.sha256;
1639
+ if (typeof expectedHash !== 'string' || expectedHash.length !== 64 || !/^[a-f0-9]{64}$/i.test(expectedHash)) {
1640
+ throw new PairingError('INSTALL_MANIFEST_ERROR', 'Agent release manifest must provide a valid sha256 before downloading or installing.');
1641
+ }
1642
+ const expectedSizeRaw = activeRelease.bootstrap_size_bytes ?? activeRelease.size_bytes;
1643
+ const expectedSize =
1644
+ typeof expectedSizeRaw === 'number'
1645
+ ? expectedSizeRaw
1646
+ : typeof expectedSizeRaw === 'string' && expectedSizeRaw.length > 0 && !/[^0-9]/.test(expectedSizeRaw)
1647
+ ? Number(expectedSizeRaw)
1648
+ : NaN;
1649
+ if (!Number.isSafeInteger(expectedSize) || expectedSize <= 0) {
1650
+ throw new PairingError('INSTALL_MANIFEST_ERROR', 'Agent release manifest must provide a valid positive integer size before downloading or installing.');
1651
+ }
1652
+ const maxPackageBytes = getCliMaxPackageBytes();
1653
+ if (expectedSize > maxPackageBytes) {
1654
+ throw new PairingError(
1655
+ 'INSTALL_MANIFEST_ERROR',
1656
+ `Agent package size ${expectedSize} bytes exceeds the maximum supported ${maxPackageBytes} bytes; nothing was downloaded or installed.`
1657
+ );
1658
+ }
1659
+
1079
1660
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nc-install-'));
1080
1661
  const archivePath = path.join(tempDir, 'bootstrap.tar.gz');
1081
1662
 
@@ -1087,58 +1668,66 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1087
1668
 
1088
1669
  // Identity decision per endpoint: device headers only for the
1089
1670
  // device-authenticated per-client URLs; public release URLs stay bare so
1090
- // no credential is ever sent where it is not required (or valid).
1671
+ // no credential is ever sent where it is not required (or valid). A
1672
+ // device-authenticated URL that left the trusted origin is refused before
1673
+ // any request, so a hostile manifest cannot redirect the credential.
1091
1674
  let downloadHeaders = {};
1092
1675
  if (isDeviceAuthenticatedPackageUrl(tarballUrl)) {
1676
+ if (!isSameOrigin(tarballUrl, defaultBaseUrl)) {
1677
+ throw new PairingError(
1678
+ 'INSTALL_MANIFEST_ERROR',
1679
+ `Agent package URL ${sanitizeForDisplay(tarballUrl, 256)} is not on the trusted origin ${sanitizeForDisplay(defaultBaseUrl, 256)}; refusing to send device credentials.`
1680
+ );
1681
+ }
1093
1682
  if (typeof hostId === 'string' && typeof hostToken === 'string' && hostId && hostToken && privateKey) {
1094
1683
  downloadHeaders = buildDeviceDownloadHeaders({ hostId, hostToken, privateKey }, tarballUrl);
1095
1684
  }
1096
1685
  }
1097
1686
 
1098
1687
  try {
1099
- console.log(`⬇️ Downloading agent package from ${tarballUrl}...`);
1688
+ console.log(`⬇️ Downloading agent package from ${sanitizeForDisplay(tarballUrl)}...`);
1689
+ const effectiveDownloadTimeoutMs = downloadTimeoutMs ?? getCliDownloadTimeoutMs();
1690
+ let download;
1100
1691
  try {
1101
- await downloadToFile(tarballUrl, archivePath, downloadHeaders);
1692
+ download = await downloadToFile(tarballUrl, archivePath, downloadHeaders, {
1693
+ maxBytes: getCliMaxPackageBytes(),
1694
+ timeoutMs: effectiveDownloadTimeoutMs
1695
+ });
1102
1696
  } catch (err) {
1103
- throw new PairingError('INSTALL_DOWNLOAD_ERROR', `Could not download the agent package: ${err.message}`);
1104
- }
1105
-
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
- 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 {}
1697
+ if (isCliTimeoutError(err)) {
1116
1698
  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.`
1699
+ 'INSTALL_DOWNLOAD_TIMEOUT',
1700
+ `The agent package download from ${sanitizeForDisplay(tarballUrl)} timed out after ${effectiveDownloadTimeoutMs}ms. No archive was installed; check network speed or raise NC_CLI_DOWNLOAD_TIMEOUT_MS (${MIN_CLI_DOWNLOAD_TIMEOUT_MS}-${MAX_CLI_DOWNLOAD_TIMEOUT_MS}), then re-run.`
1119
1701
  );
1120
1702
  }
1703
+ throw new PairingError('INSTALL_DOWNLOAD_ERROR', `Could not download the agent package: ${sanitizeForDisplay(err.message, 300)}. No archive was installed.`);
1121
1704
  }
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.');
1705
+
1706
+ if (download.bytes !== expectedSize) {
1707
+ try {
1708
+ fs.unlinkSync(archivePath);
1709
+ } catch {}
1710
+ throw new PairingError(
1711
+ 'INSTALL_MANIFEST_ERROR',
1712
+ `Agent package integrity check failed: size mismatch for ${sanitizeForDisplay(tarballUrl, 256)} (expected ${expectedSize} bytes, got ${download.bytes}). The download was discarded; nothing was installed.`
1713
+ );
1714
+ }
1715
+ const actualHash = download.sha256;
1716
+ if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) {
1717
+ try {
1718
+ fs.unlinkSync(archivePath);
1719
+ } catch {}
1720
+ throw new PairingError(
1721
+ 'INSTALL_MANIFEST_ERROR',
1722
+ `Agent package integrity check failed: sha256 mismatch for ${sanitizeForDisplay(tarballUrl, 256)}. The download was discarded; nothing was installed.`
1723
+ );
1135
1724
  }
1136
1725
 
1137
1726
  console.log('📂 Extracting agent archive...');
1138
1727
  try {
1139
1728
  extractTarball(archivePath, tempDir);
1140
1729
  } catch (err) {
1141
- throw new PairingError('INSTALL_EXTRACT_ERROR', `Could not extract the agent package: ${err.message}`);
1730
+ throw new PairingError('INSTALL_EXTRACT_ERROR', `Could not extract the agent package: ${sanitizeForDisplay(err.message, 300)}`);
1142
1731
  }
1143
1732
 
1144
1733
  console.log('🚀 Running installation script...');
@@ -1151,7 +1740,7 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1151
1740
  invokeInstaller(scriptPath, root);
1152
1741
  } catch (err) {
1153
1742
  if (err instanceof PairingError) throw err;
1154
- throw new PairingError('INSTALL_FAILED', `Agent installer failed: ${err.message}. Re-running resumes from the stored host credentials.`);
1743
+ throw new PairingError('INSTALL_FAILED', `Agent installer failed: ${sanitizeForDisplay(err.message, 300)}. Re-running resumes from the stored host credentials.`);
1155
1744
  }
1156
1745
  } finally {
1157
1746
  console.log('🧹 Cleaning up temporary directory...');
@@ -1168,10 +1757,11 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1168
1757
  * the OAuth enrollment step is skipped and the supplied token is redeemed
1169
1758
  * directly. `installMode` is forwarded to the installer via NC_MODE.
1170
1759
  * Host split: enrollment (OAuth/redeem) uses the auth origin (`baseUrl` /
1171
- * `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
1760
+ * `NC_BASE_URL`); fresh-enrollment verification, installation and the stored
1761
+ * env bind to the enrollment `server_url` (the agent destination the host
1762
+ * credential will actually use), never to the separate NC_API_URL/baseUrl.
1763
+ * Local resume binds verification and installation to the stored NC_SERVER_URL.
1764
+ * @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
1765
  */
1176
1766
  export async function pairDeviceAndInstall({
1177
1767
  baseUrl: baseUrlParam = undefined,
@@ -1183,6 +1773,8 @@ export async function pairDeviceAndInstall({
1183
1773
  deviceName = os.hostname(),
1184
1774
  platform = mapEnrollPlatform(),
1185
1775
  installMode = undefined,
1776
+ rootConsent = false,
1777
+ browserBrand = captureBrowserDefaultBrand(),
1186
1778
  verifyTimeoutMs = 45000,
1187
1779
  verifyIntervalMs = 2000,
1188
1780
  hooks = {}
@@ -1200,7 +1792,6 @@ export async function pairDeviceAndInstall({
1200
1792
  }
1201
1793
  const root = ncRoot || getNcRoot();
1202
1794
  const origin = String(baseUrlParam ?? process.env.NC_BASE_URL ?? baseUrl).replace(/\/$/, '');
1203
- const apiOrigin = String(apiBaseParam ?? process.env.NC_API_URL ?? apiBase).replace(/\/$/, '');
1204
1795
  const defaultServerUrl = serverUrl || process.env.NC_SERVER_URL || 'https://agent.nodecommand.app';
1205
1796
  const envPath = getEnvFilePath(root);
1206
1797
  const keyPath = getDeviceKeyPath(root);
@@ -1209,15 +1800,20 @@ export async function pairDeviceAndInstall({
1209
1800
  const installAgent =
1210
1801
  hooks.installAgent ||
1211
1802
  (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 });
1803
+ await downloadAndInstallAgent({ ncRoot: installRoot, serverUrl: installServerUrl, installMode, rootConsent: selectedRootConsent, release: installRelease, hostId: installHostId, hostToken: installHostToken, privateKey: installPrivateKey });
1213
1804
  });
1805
+ let selectedRootConsent = false;
1214
1806
 
1215
1807
  // Fast path — idempotent resume: usable host credentials already on disk
1216
1808
  // are verified over the device channel and the installer is resumed. No
1217
1809
  // new enrollment is minted, so a re-run can never orphan a host or hit
1218
1810
  // ENROLLMENT_ALREADY_USED for a token it no longer holds.
1219
1811
  const localEnv = loadValidHostEnv(envPath);
1812
+ assertMatchingHostIdentity(localEnv, enrollment?.host_id);
1220
1813
  if (localEnv) {
1814
+ // Match the agent's HTTP base normalization without dropping a supported
1815
+ // path prefix or redirecting installed credentials to NC_API_URL.
1816
+ const storedServerUrl = localEnv.NC_SERVER_URL.replace(/\/$/, '');
1221
1817
  let localKey = null;
1222
1818
  try {
1223
1819
  localKey = loadDeviceKeyPair(keyPath);
@@ -1228,7 +1824,7 @@ export async function pairDeviceAndInstall({
1228
1824
  const prior = readPairState(statePath);
1229
1825
  try {
1230
1826
  await verifyHostRegistration({
1231
- apiBase: apiOrigin,
1827
+ apiBase: localEnv.NC_SERVER_URL,
1232
1828
  hostId: localEnv.NC_CLIENT_ID,
1233
1829
  hostToken: localEnv.NC_CLIENT_TOKEN,
1234
1830
  privateKey: localKey.privateKey,
@@ -1241,10 +1837,10 @@ export async function pairDeviceAndInstall({
1241
1837
  return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
1242
1838
  }
1243
1839
  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 });
1840
+ await installAgent({ ncRoot: root, serverUrl: storedServerUrl, hostId: localEnv.NC_CLIENT_ID, hostToken: localEnv.NC_CLIENT_TOKEN, privateKey: localKey.privateKey });
1245
1841
  writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'installed' });
1246
1842
  await verifyHostRegistration({
1247
- apiBase: apiOrigin,
1843
+ apiBase: localEnv.NC_SERVER_URL,
1248
1844
  hostId: localEnv.NC_CLIENT_ID,
1249
1845
  hostToken: localEnv.NC_CLIENT_TOKEN,
1250
1846
  privateKey: localKey.privateKey,
@@ -1254,23 +1850,25 @@ export async function pairDeviceAndInstall({
1254
1850
  writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'verified' });
1255
1851
  return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
1256
1852
  } 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.
1853
+ // Failure at the bound destination is not permission to enroll on
1854
+ // another server or replace this installed host identity.
1262
1855
  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;
1856
+ 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
1857
  }
1858
+ throw err;
1270
1859
  }
1271
1860
  }
1272
1861
  }
1273
1862
 
1863
+ const incomplete = readPairState(statePath);
1864
+ if (incomplete?.phase === 'enrolled' && HOST_ID_RE.test(String(incomplete.host_id || ''))) {
1865
+ 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.');
1866
+ }
1867
+ // Authentication/preflight precede this fresh-only question. Ask before
1868
+ // minting the five-minute token, or before redeeming an already running one.
1869
+ // Reuse this one literal answer for both signed intent and local installer.
1870
+ selectedRootConsent = (typeof rootConsent === 'function' ? await rootConsent() : rootConsent) === true;
1871
+
1274
1872
  // Fresh enrollment with the OAuth USER token (user authorization only),
1275
1873
  // or the caller-supplied enrollment from `setup <host_id> <token>`.
1276
1874
  let activeEnrollment = enrollment;
@@ -1292,50 +1890,42 @@ export async function pairDeviceAndInstall({
1292
1890
  let agentVersion = null;
1293
1891
  /** @type {Record<string, unknown>|null} */
1294
1892
  let redemptionRelease = null;
1295
- try {
1893
+ let executionInitialization = null;
1894
+ {
1296
1895
  const redemption = await redeemEnrollment({
1297
1896
  baseUrl: origin,
1298
1897
  hostId,
1299
1898
  enrollmentToken: activeEnrollment.enrollment_token,
1300
- keyPair
1899
+ keyPair,
1900
+ initialPermission: selectedRootConsent ? 'admin' : 'user'
1301
1901
  });
1302
1902
  hostToken = redemption.client_token;
1903
+ executionInitialization = redemption.execution_initialization ?? null;
1303
1904
  if (redemption.release && typeof redemption.release === 'object') {
1304
1905
  redemptionRelease = /** @type {Record<string, unknown>} */ (redemption.release);
1305
1906
  }
1306
1907
  if (redemption.release?.version) {
1307
1908
  agentVersion = redemption.release.version;
1308
- console.log(`📦 Release: ${agentVersion}`);
1309
- }
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;
1909
+ console.log(`📦 Release: ${sanitizeForDisplay(agentVersion, 120)}`);
1323
1910
  }
1324
1911
  }
1325
1912
 
1326
1913
  // Host credentials ONLY — the OAuth token is never written here.
1327
1914
  console.log('📝 Creating configuration file...');
1328
- writeHostEnvFile(envPath, buildHostEnvContent({ serverUrl: enrollServerUrl, hostId, hostToken, clientName: deviceName }));
1915
+ writeHostEnvFile(envPath, buildHostEnvContent({ serverUrl: enrollServerUrl, hostId, hostToken, clientName: deviceName, browserBrand }));
1329
1916
  console.log(`📝 Configuration saved to ${envPath}`);
1330
1917
  writePairState(statePath, { host_id: hostId, phase: 'redeemed' });
1331
1918
 
1332
1919
  console.log('🚀 Installing agent...');
1333
- await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey });
1920
+ await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey, rootConsent: selectedRootConsent });
1334
1921
  writePairState(statePath, { host_id: hostId, phase: 'installed' });
1335
1922
 
1336
- console.log('🔍 Verifying host registration...');
1923
+ console.log(`🔍 Verifying host registration at ${sanitizeForDisplay(enrollServerUrl, 256)}...`);
1924
+ // Verify against the origin stored in the env file and used by the installer:
1925
+ // NC_API_URL/baseUrl only authenticated enrollment and is not the destination
1926
+ // this host credential was bound to.
1337
1927
  await verifyHostRegistration({
1338
- apiBase: apiOrigin,
1928
+ apiBase: enrollServerUrl,
1339
1929
  hostId,
1340
1930
  hostToken,
1341
1931
  privateKey: keyPair.privateKey,
@@ -1343,7 +1933,8 @@ export async function pairDeviceAndInstall({
1343
1933
  intervalMs: verifyIntervalMs
1344
1934
  });
1345
1935
  writePairState(statePath, { host_id: hostId, phase: 'verified' });
1346
- return { hostId, serverUrl: enrollServerUrl, resumed: false, version: agentVersion };
1936
+ return { hostId, serverUrl: enrollServerUrl, resumed: false, version: agentVersion, rootConsent: selectedRootConsent,
1937
+ privilegeReadiness: selectedRootConsent ? 'pending' : 'unavailable', executionInitialization };
1347
1938
  }
1348
1939
 
1349
1940
  /**
@@ -1381,6 +1972,9 @@ function printCliHelp() {
1381
1972
  ' nodecommand pair this machine via the browser device flow',
1382
1973
  ' nodecommand <host_id> install with a dashboard host id (prompts for the token, hidden input)',
1383
1974
  ' nodecommand setup [host_id token] install the agent (auto system/user mode; no args = browser device flow)',
1975
+ ' nodecommand setup --user force a per-user install (never requests sudo/UAC)',
1976
+ ' nodecommand --user same as `setup --user`',
1977
+ ' nodecommand [--allow-root-commands] explicitly request privileged preparation (default No; independent binding required)',
1384
1978
  ' nodecommand uninstall [--yes] remove the agent from the canonical root (alias: remove)',
1385
1979
  ' nodecommand remove [--yes] uninstall the agent (asks for confirmation unless --yes)',
1386
1980
  ' nodecommand --diagnostics preview a redacted diagnostics bundle (dry-run, writes nothing)',
@@ -1487,17 +2081,22 @@ async function main() {
1487
2081
  // form the dashboard and install_command used to emit) to the `setup
1488
2082
  // <host_id> <token>` path instead of silently ignoring the arguments in the
1489
2083
  // device flow.
1490
- if (isBareEnrollmentInvocation(cliArgs)) {
2084
+ const enrollmentArgs = cliArgs.filter(arg => arg !== '--allow-root-commands' && arg !== '--user');
2085
+ if (isBareEnrollmentInvocation(enrollmentArgs)) {
1491
2086
  await runSetup(cliArgs);
1492
2087
  return;
1493
2088
  }
1494
2089
  // Canonical token-free form: the single-use token is typed at a masked
1495
2090
  // interactive prompt inside runSetup, so it never enters argv (shell
1496
2091
  // history, `ps`, recordings, telemetry).
1497
- if (isBareHostIdInvocation(cliArgs)) {
2092
+ if (isBareHostIdInvocation(enrollmentArgs)) {
1498
2093
  await runSetup(cliArgs, { allowHostIdOnly: true });
1499
2094
  return;
1500
2095
  }
2096
+ if (enrollmentArgs.length === 0) {
2097
+ await runSetup(cliArgs);
2098
+ return;
2099
+ }
1501
2100
  await runDeviceFlow();
1502
2101
  }
1503
2102
 
@@ -1509,6 +2108,8 @@ async function main() {
1509
2108
  */
1510
2109
  export function suggestedActionFor(code) {
1511
2110
  switch (code) {
2111
+ case 'HOST_ID_MISMATCH':
2112
+ 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
2113
  case 'ENROLL_NO_OAUTH_TOKEN':
1513
2114
  return 'Re-run `nodecommand setup` without arguments and approve the browser device flow.';
1514
2115
  case 'ENROLL_UNAUTHORIZED':
@@ -1519,7 +2120,10 @@ export function suggestedActionFor(code) {
1519
2120
  case 'PLAN_HOST_LIMIT_EXCEEDED':
1520
2121
  return 'Upgrade the workspace plan or remove an old host, then re-run.';
1521
2122
  case 'ENROLLMENT_ALREADY_USED':
1522
- return 'Re-run `nodecommand setup` without arguments; it resumes from the stored host credentials.';
2123
+ case 'REDEEM_RECOVERY_REQUIRED':
2124
+ case 'REDEEM_NETWORK_ERROR':
2125
+ case 'REDEEM_UNAUTHORIZED':
2126
+ 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
2127
  case 'ENROLLMENT_EXPIRED':
1524
2128
  case 'INVALID_ENROLL_TOKEN':
1525
2129
  case 'INVALID_HOST_ID':
@@ -1530,12 +2134,20 @@ export function suggestedActionFor(code) {
1530
2134
  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.';
1531
2135
  case 'INSTALL_DOWNLOAD_ERROR':
1532
2136
  return 'The agent package endpoint requires the device credential issued at redemption; re-run `nodecommand setup <host_id> <token>` to redeem a fresh enrollment, then re-run; stored credentials are reused.';
2137
+ case 'INSTALL_DOWNLOAD_TIMEOUT':
2138
+ return 'The agent package download did not finish within the configured timeout. Check network speed/connectivity or raise NC_CLI_DOWNLOAD_TIMEOUT_MS (30000-3600000), then re-run; stored credentials are reused.';
2139
+ case 'INSECURE_SERVER_URL':
2140
+ return 'Use an https:// server URL. Set NC_ALLOW_INSECURE_HTTP=1 only for an isolated local test.';
1533
2141
  case 'INSTALL_FAILED':
1534
2142
  case 'INSTALL_EXTRACT_ERROR':
1535
2143
  case 'INSTALL_INTEGRITY_ERROR':
1536
2144
  return 'The agent install did not complete; check network connectivity to the agent release host and re-run; stored credentials are reused.';
1537
2145
  case 'VERIFY_TIMEOUT':
1538
2146
  return 'Check connectivity to the server and re-run to verify; credentials are already stored.';
2147
+ case 'SETUP_IN_PROGRESS':
2148
+ return 'Another setup, install or uninstall owns this installation root. Wait for it to finish (or remove a stale lock after confirming no setup is running) and re-run.';
2149
+ case 'HELPER_BINDING_FAILED':
2150
+ return 'Verify NC_ROOT, the protected management response and the signed challenge file, then re-run the binding maintenance; no binding state was changed.';
1539
2151
  default:
1540
2152
  return 'Re-run the command; use `nodecommand --help` for usage.';
1541
2153
  }
@@ -1584,24 +2196,55 @@ function getCliVersion() {
1584
2196
  /**
1585
2197
  * Prints the one-line install summary required after a successful setup:
1586
2198
  * host id, install mode, CLI version, and verification status.
1587
- * @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null }} options
2199
+ * @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null, rootConsent?: boolean, ncRoot?: string, executionInitialization?: any }} options
1588
2200
  */
1589
- function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null }) {
1590
- console.log(`\n✅ Setup complete for host ${hostId}${resumed ? ' (resumed)' : ''}.`);
2201
+ export function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null, rootConsent = false, ncRoot = undefined, executionInitialization = null }) {
2202
+ console.log(`\n✅ Setup complete for host ${sanitizeForDisplay(hostId, 128)}${resumed ? ' (resumed)' : ''}.`);
1591
2203
  if (installMode) console.log(`installed as ${installMode}`);
1592
- if (version) console.log(`agent version: ${version}`);
2204
+ if (version) console.log(`agent version: ${sanitizeForDisplay(version, 120)}`);
1593
2205
  console.log(`cli version: ${getCliVersion()}`);
1594
- console.log(`server: ${serverUrl}`);
2206
+ console.log(`server: ${sanitizeForDisplay(serverUrl, 256)}`);
1595
2207
  console.log('status: agent credentials verified online');
2208
+ if (ncRoot) console.log(`installation root: ${ncRoot}`);
2209
+ console.log('OS execution identity: not verified by this credential check; native service/readiness verification required');
2210
+ console.log('agent runtime: installer-selected; not verified by this credential check (customer global Node/PATH unchanged)');
2211
+ if (resumed) {
2212
+ console.log('execution permission: existing choice preserved; no privilege maintenance performed');
2213
+ } else {
2214
+ console.log(`root command consent: ${rootConsent === true ? 'Yes' : 'No'}`);
2215
+ console.log(`local privilege readiness: ${rootConsent === true ? 'pending independent management approval and protected helper binding' : 'unavailable (not requested)'}`);
2216
+ const receipt = executionInitialization;
2217
+ const fields = ['outcome', 'reason', 'subject_user_id', 'permission'];
2218
+ const reasons = { requested: ['admin_requested'], preserved: ['existing_permission_preserved', 'existing_host_preserved'],
2219
+ defaulted: ['default_user', 'verified_origin_unavailable', 'origin_authority_changed', 'origin_not_owner', 'origin_inactive'] };
2220
+ const closed = receipt && typeof receipt === 'object' && !Array.isArray(receipt) && Object.keys(receipt).length === fields.length &&
2221
+ fields.every(field => Object.hasOwn(receipt, field)) && typeof receipt.outcome === 'string' && typeof receipt.reason === 'string' &&
2222
+ Object.hasOwn(reasons, receipt.outcome) && reasons[receipt.outcome].includes(receipt.reason) &&
2223
+ (receipt.subject_user_id === null || (typeof receipt.subject_user_id === 'string' && receipt.subject_user_id.length > 0)) &&
2224
+ (receipt.subject_user_id !== null || receipt.permission === null);
2225
+ const permission = closed ? receipt.permission : null;
2226
+ const projectionFields = ['requested_permission', 'applied_permission', 'permission_generation', 'permission_revision', 'applied_revision', 'enforcement_state', 'changed_by', 'updated_at'];
2227
+ const projected = permission && typeof permission === 'object' && !Array.isArray(permission) &&
2228
+ Object.keys(permission).length === projectionFields.length && projectionFields.every(field => Object.hasOwn(permission, field)) &&
2229
+ ['user', 'admin'].includes(permission.requested_permission) && [null, 'user', 'admin'].includes(permission.applied_permission) &&
2230
+ [permission.permission_revision, permission.applied_revision].every(value => value === null || (Number.isSafeInteger(value) && value >= 0)) &&
2231
+ [permission.permission_generation, permission.changed_by].every(value => value === null || typeof value === 'string') &&
2232
+ typeof permission.enforcement_state === 'string' && (permission.updated_at === null || typeof permission.updated_at === 'string' || Number.isFinite(permission.updated_at));
2233
+ if (!closed || (permission !== null && !projected)) {
2234
+ console.log('personal execution initialization: unknown (server receipt unavailable or invalid); no User/Admin initialization claimed');
2235
+ } else {
2236
+ console.log(`personal execution initialization: ${receipt.outcome} (${receipt.reason})`);
2237
+ if (!projected) console.log('personal execution permission: unknown (no verified applicable personal record reported)');
2238
+ else {
2239
+ const label = value => value === 'admin' ? 'Admin' : value === 'user' ? 'User' : 'unknown';
2240
+ const state = permission.enforcement_state === 'pending' ? 'pending' : permission.enforcement_state === 'applied' ? 'server-reported applied' : 'not verified';
2241
+ console.log(`personal execution permission: server requested ${label(permission.requested_permission)}; enforcement ${state}`);
2242
+ console.log(`server-reported applied personal permission: ${label(permission.applied_permission)}; local Root enforcement not verified by setup`);
2243
+ }
2244
+ }
2245
+ }
1596
2246
  }
1597
2247
 
1598
- /**
1599
- * Guidance printed when Windows UAC elevation for a system-wide install is
1600
- * unavailable (headless shell) or was declined by the operator.
1601
- */
1602
- const WINDOWS_ELEVATION_INSTALL_DECLINED =
1603
- 'Elevation unavailable or declined. Please run as Administrator in an elevated shell for a system-wide install at C:\\ProgramData\\NC.';
1604
-
1605
2248
  /**
1606
2249
  * Guidance printed when Windows UAC elevation for uninstallation is
1607
2250
  * unavailable (headless shell) or was declined by the operator.
@@ -1615,17 +2258,20 @@ const WINDOWS_ELEVATION_UNINSTALL_DECLINED =
1615
2258
  * console is visible) and returns the spawnSync result. Never throws: a
1616
2259
  * missing powershell or a declined prompt resolves to null/non-zero.
1617
2260
  */
1618
- function requestWindowsElevation() {
1619
- const args = [...process.argv.slice(1)];
2261
+ function requestWindowsElevation(browserBrand = undefined, elevatedArgs = undefined) {
2262
+ const originalArgs = elevatedArgs ? [...elevatedArgs] : [...process.argv.slice(1)];
2263
+ const args = browserBrand === undefined ? originalArgs : browserBrandReexecArgs(originalArgs, browserBrand);
2264
+ const literal = value => "'" + value.replace(/'/g, "''") + "'";
2265
+ const quote = value => '"' + String(value).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1') + '"';
2266
+ const script = `$p=Start-Process -FilePath ${literal(process.execPath)} -ArgumentList ${literal(args.map(quote).join(' '))} -Verb RunAs -Wait -PassThru; exit $p.ExitCode`;
1620
2267
  try {
1621
2268
  return child_process.spawnSync(
1622
- 'powershell',
2269
+ windowsExecutablePath('powershell'),
1623
2270
  [
1624
2271
  '-NoProfile',
1625
- '-Command',
1626
- `Start-Process -FilePath "${process.execPath}" -ArgumentList ${args
1627
- .map((a) => `"${String(a).replace(/"/g, '`"')}"`)
1628
- .join(',')} -Verb RunAs -Wait`
2272
+ '-NonInteractive',
2273
+ '-EncodedCommand',
2274
+ Buffer.from(script, 'utf16le').toString('base64')
1629
2275
  ],
1630
2276
  { stdio: 'inherit' }
1631
2277
  );
@@ -1638,7 +2284,9 @@ function requestWindowsElevation() {
1638
2284
  * `nodecommand setup [host_id enrollment_token]`.
1639
2285
  *
1640
2286
  * 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
2287
+ * Fresh non-root macOS installs use the existing user LaunchAgent, including
2288
+ * administrator accounts; existing/conflicting state is checked first.
2289
+ * Linux: a non-root admin requests sudo; a refusal falls back to a per-user
1642
2290
  * install with a single-line capability notice. Windows: a standard shell
1643
2291
  * requests UAC elevation instead and hard-stops (exit 1) when elevation is
1644
2292
  * unavailable or declined — never a silent %LOCALAPPDATA% degradation.
@@ -1655,7 +2303,23 @@ function requestWindowsElevation() {
1655
2303
  * @param {{ allowHostIdOnly?: boolean, promptToken?: (io: { input: import('node:stream').Readable, output: import('node:stream').Writable }) => Promise<string> }} [options]
1656
2304
  */
1657
2305
  export async function runSetup(args = [], { allowHostIdOnly = false, promptToken = promptEnrollmentToken } = {}) {
2306
+ // Elevated legacy enrollments receive the single-use token from an
2307
+ // owner-only temp file, never through sudo/UAC argv.
2308
+ let elevatedToken;
2309
+ const tokenFileFlagIndex = args.indexOf('--enrollment-token-file');
2310
+ if (tokenFileFlagIndex >= 0) {
2311
+ const tokenFile = args[tokenFileFlagIndex + 1];
2312
+ if (typeof tokenFile !== 'string' || tokenFile.startsWith('-')) {
2313
+ reportSetupError(new PairingError('INVALID_ENROLL_TOKEN', 'The elevated enrollment token file argument is missing.'));
2314
+ process.exit(1);
2315
+ return;
2316
+ }
2317
+ elevatedToken = readEnrollmentTokenFile(tokenFile);
2318
+ args = args.filter((_, index) => index !== tokenFileFlagIndex && index !== tokenFileFlagIndex + 1);
2319
+ }
1658
2320
  const positionals = args.filter((a) => !a.startsWith('-'));
2321
+ if (elevatedToken !== undefined) positionals.push(elevatedToken);
2322
+ const positionalTokenInArgv = elevatedToken === undefined && positionals.length === 2;
1659
2323
  if (positionals.includes('help') || args.includes('--help') || args.includes('-h')) {
1660
2324
  console.log(
1661
2325
  [
@@ -1663,13 +2327,78 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
1663
2327
  ' nodecommand setup install via the browser device flow (auto system/user mode)',
1664
2328
  ' nodecommand setup <host_id> <token> install with a dashboard enrollment token (legacy positional form)',
1665
2329
  '',
1666
- 'The install mode is chosen automatically: administrator/root installs',
1667
- 'machine-wide (system), anything else installs per-user (user).'
2330
+ ' nodecommand setup --maintain-identity <host_id> explicit root-only selected identity migration (NC_ROOT required)',
2331
+ ' nodecommand setup --configure-helper-binding <host_id> <management_response.json> <signed_challenge.json>',
2332
+ ' --activate-initial-binding explicitly activates only the local initial binding and restarts unsynchronized.',
2333
+ '',
2334
+ 'macOS: fresh non-root installs use the per-user LaunchAgent without sudo;',
2335
+ 'explicit root uses system mode. Existing roots/modes are preserved or setup stops.',
2336
+ 'Windows/Linux retain their existing automatic system/user elevation behavior.',
2337
+ 'Use the per-user switch documented by `nodecommand --help` to force a per-user install without sudo/UAC.',
2338
+ 'Service installation authorization does not allow root commands through chat.',
2339
+ 'New setup asks: Allow root/administrator commands through Node Command? [y/N]',
2340
+ 'Noninteractive setup defaults to No; --allow-root-commands explicitly requests preparation.',
2341
+ 'Admin stays unavailable until protected helper binding and management approval are verified.'
1668
2342
  ].join('\n')
1669
2343
  );
1670
2344
  return;
1671
2345
  }
1672
- assertKnownFlags(args, ['--help', '-h'], 'setup');
2346
+ assertKnownFlags(args, ['--help', '-h', '--allow-root-commands', '--user', '--maintain-identity', '--configure-helper-binding', '--activate-initial-binding'], 'setup');
2347
+ // Explicit per-user mode: never elevate, and never degrade silently into a
2348
+ // competing user install beside a system tree the caller cannot inspect.
2349
+ const forceUser = args.includes('--user');
2350
+ if (forceUser && (args.includes('--maintain-identity') || args.includes('--configure-helper-binding'))) {
2351
+ throw new PairingError('INVALID_SETUP_ARGS', '--user selects a per-user installation and cannot be combined with selected-installation maintenance.');
2352
+ }
2353
+ if (args.includes('--configure-helper-binding')) {
2354
+ if (positionals.length !== 3 || !process.env.NC_ROOT || args.includes('--allow-root-commands') || args.includes('--maintain-identity'))
2355
+ throw new PairingError('INVALID_SETUP_ARGS', 'Selected helper binding maintenance needs NC_ROOT, existing host ID, protected management response and signed challenge.');
2356
+ try {
2357
+ const producer = await loadProtectedHelperProducer(process.env.NC_ROOT);
2358
+ const files = producer.protectedHelperPaths(process.env.NC_ROOT);
2359
+ const selected = JSON.parse(fs.readFileSync(files.configFile, 'utf8'));
2360
+ assertMatchingHostIdentity({ NC_CLIENT_ID: selected.client_id }, positionals[0]);
2361
+ const result = await producer.configureProtectedHelperBinding({ root: process.env.NC_ROOT, bindingFile: path.resolve(positionals[1]),
2362
+ challengeFile: path.resolve(positionals[2]), activateLocally: args.includes('--activate-initial-binding') });
2363
+ console.log(JSON.stringify(result));
2364
+ console.log('Local binding preparation is not execution readiness. The control plane must consume fresh proof, then complete signed sync and helper ACK.');
2365
+ return;
2366
+ } catch (err) {
2367
+ // Never surface a raw stack (or JSON.parse input snippets) from helper
2368
+ // binding maintenance; callers get the stable setup error shape.
2369
+ if (err instanceof PairingError) {
2370
+ reportSetupError(err);
2371
+ } else {
2372
+ const detail = err instanceof SyntaxError
2373
+ ? 'the selected protected configuration is not valid JSON'
2374
+ : err instanceof Error ? err.message : String(err);
2375
+ reportSetupError(new PairingError('HELPER_BINDING_FAILED', `Protected helper binding maintenance failed: ${detail}. Existing binding state is unchanged.`));
2376
+ }
2377
+ process.exit(1);
2378
+ return;
2379
+ }
2380
+ }
2381
+ if (args.includes('--activate-initial-binding')) throw new PairingError('INVALID_SETUP_ARGS', 'Initial binding activation requires explicit --configure-helper-binding maintenance.');
2382
+ if (args.includes('--maintain-identity')) {
2383
+ 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.');
2384
+ 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.');
2385
+ console.log('Selected OS identity maintenance preserves the existing personal permission and does not enable root commands through chat.');
2386
+ const result = await maintainLinuxIdentity({ ncRoot: process.env.NC_ROOT, hostId: positionals[0], operatorUser: process.env.NC_OPERATOR_USER,
2387
+ authorizeMaintenance: async facts => {
2388
+ await verifyHostRegistration({ apiBase: facts.server_url, hostId: facts.client_id, hostToken: facts.hostToken, privateKey: facts.privateKey });
2389
+ 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}).`);
2390
+ return confirmPrompt('Confirm this exact target is authorized for identity maintenance and is NOT held, in your authenticated management session? [y/N] ');
2391
+ },
2392
+ verifyReconnection: async facts => {
2393
+ await verifyHostRegistration({ apiBase: facts.server_url, hostId: facts.client_id, hostToken: facts.hostToken, privateKey: facts.privateKey });
2394
+ 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}.`);
2395
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
2396
+ return confirmPrompt('Confirm this exact host reconnected in your authenticated management session as nodecommand? [y/N] ');
2397
+ } });
2398
+ 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.`);
2399
+ 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.`);
2400
+ return;
2401
+ }
1673
2402
  // Arity is validated BEFORE any mode/root output: an invalid invocation
1674
2403
  // must fail without printing an installation plan it will not execute.
1675
2404
  // One positional (host id) is valid only for the bare `nodecommand
@@ -1680,71 +2409,303 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
1680
2409
  console.error('suggested_action: Re-run as `nodecommand setup` or `nodecommand setup <host_id> <token>`.');
1681
2410
  process.exit(1);
1682
2411
  }
1683
- 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
1687
- // -Verb RunAs) so the install lands machine-wide at the canonical system
1688
- // root. A refused or unavailable Windows elevation is a hard stop: the CLI
1689
- // never silently degrades a system install into %LOCALAPPDATA%.
1690
- if (installMode === 'user' && canElevate() && !process.env.NC_ELEVATED) {
1691
- if (os.platform() === 'win32') {
1692
- if (process.stdin.isTTY) {
1693
- console.log('Admin rights required for a system-wide install. Requesting UAC elevation…');
1694
- const elevated = requestWindowsElevation();
1695
- if (elevated && elevated.status === 0) process.exit(0);
2412
+ if (positionalTokenInArgv) {
2413
+ console.warn('warning: [ENROLL_TOKEN_IN_ARGV] The positional enrollment token is visible in shell history and process listings. Re-run without the token to use the masked prompt; continuing with the supplied token.');
2414
+ }
2415
+ const browserBrand = captureBrowserDefaultBrand();
2416
+ let installMode = forceUser ? 'user' : decideInstallMode();
2417
+ let ncRoot;
2418
+ let existingTarget;
2419
+ let existingEnv;
2420
+ let existingKey;
2421
+ let rootManagedPair;
2422
+ let protectedKeyContext;
2423
+ let needsUserAppRepair = false;
2424
+ // A non-root caller cannot stat a root-owned 0750 system tree. Escalate
2425
+ // before setupTarget's fail-closed inspection so the elevated child inspects
2426
+ // the real installation; an unreadable root must never look like "absent".
2427
+ const unreadableSystemRoot = os.platform() === 'linux' && installMode === 'user' && !process.env.NC_ELEVATED
2428
+ ? unreadableSystemInstallations()[0]
2429
+ : undefined;
2430
+ // config + device key alone are not a working agent: an empty/missing
2431
+ // launcher is repaired for a per-user tree from stored credentials, and
2432
+ // otherwise fails closed instead of being reported as an online
2433
+ // installation. Stored credentials are preserved for repair.
2434
+ const verifyExistingTarget = (root, { clientId = null, protectedContext = null, repairUserApplication = false } = {}) => {
2435
+ const localEnv = loadValidHostEnv(getEnvFilePath(root));
2436
+ if (!localEnv) throw new Error(`Existing host configuration at ${root} is incomplete; refusing a new enrollment or credential overwrite.`);
2437
+ if (clientId && localEnv.NC_CLIENT_ID !== clientId) throw new Error('Existing ordinary host identity conflicts with the protected helper selection; explicit Root maintenance is required.');
2438
+ assertMatchingHostIdentity(localEnv, positionals[0]);
2439
+ // Missing/unreadable/invalid keys are not permission to regenerate an
2440
+ // existing device identity. The normal verified resume flow is kept.
2441
+ const keyPath = getDeviceKeyPath(root);
2442
+ if (!protectedContext) {
2443
+ const keyInfo = fs.lstatSync(keyPath);
2444
+ if (!keyInfo.isFile() || keyInfo.isSymbolicLink() || keyInfo.size > 65536 ||
2445
+ (os.platform() !== 'win32' && installMode === 'user' && keyInfo.uid !== process.getuid())) {
2446
+ throw new Error(`Existing device key at ${root} has an unverifiable type/owner; refusing identity replacement.`);
1696
2447
  }
1697
- console.error(WINDOWS_ELEVATION_INSTALL_DECLINED);
1698
- process.exit(1);
1699
2448
  }
1700
- console.log('Admin rights required for a system-wide install. Requesting sudo…');
1701
- const elevated = child_process.spawnSync(
1702
- 'sudo',
1703
- ['-E', process.execPath, ...process.argv.slice(1)],
1704
- { stdio: 'inherit', env: { ...process.env, NC_ELEVATED: '1' } }
1705
- );
1706
- if (elevated && elevated.status === 0) process.exit(0);
1707
- console.warn('Elevation unavailable or declined; continuing with a per-user install.');
2449
+ try {
2450
+ existingKey = protectedContext ? loadRootManagedPairKey(root, protectedContext.ordinaryUid) : loadDeviceKeyPair(keyPath);
2451
+ } catch {
2452
+ throw new Error(`Existing device key at ${root} cannot be verified; preserve it for selected-installation maintenance.`);
2453
+ }
2454
+ existingEnv = localEnv;
2455
+ const appInfo = fs.lstatSync(path.join(root, 'app'));
2456
+ if (!appInfo.isDirectory() || appInfo.isSymbolicLink()) throw new Error(`Existing application at ${root} is incomplete; use selected-installation maintenance.`);
2457
+ const launcherPath = path.join(root, 'app', 'launcher.mjs');
2458
+ let launcherInfo;
2459
+ try {
2460
+ launcherInfo = fs.lstatSync(launcherPath);
2461
+ } catch (err) {
2462
+ if (err?.code !== 'ENOENT') throw new Error(`Existing application at ${root} cannot be verified; use selected-installation maintenance.`, { cause: err });
2463
+ }
2464
+ if (!launcherInfo || !launcherInfo.isFile() || launcherInfo.isSymbolicLink() || launcherInfo.size === 0) {
2465
+ // A partial per-user tree is repairable in place from the stored host
2466
+ // identity; protected/system roots stay fail-closed for maintenance.
2467
+ if (repairUserApplication && canRepairUserApplication({ mode: installMode, protectedContext })) {
2468
+ needsUserAppRepair = true;
2469
+ return;
2470
+ }
2471
+ throw new Error(`Existing application at ${root} is incomplete: app/launcher.mjs is missing or empty. Repair or reinstall the application from the existing root (config/nc-client.env and config/nc-device-key.pem are preserved); no new enrollment was attempted.`);
2472
+ }
2473
+ };
2474
+ try {
2475
+ if (!unreadableSystemRoot) {
2476
+ const target = setupTarget({ mode: installMode });
2477
+ ({ mode: installMode, ncRoot } = target);
2478
+ existingTarget = target.existing;
2479
+ rootManagedPair = target.rootManagedPair;
2480
+ protectedKeyContext = (process.getuid?.() === 0 || process.geteuid?.() === 0) ? target.protectedInstallation : null;
2481
+ if (target.existing) verifyExistingTarget(ncRoot, { clientId: target.clientId, protectedContext: protectedKeyContext, repairUserApplication: installMode === 'user' && !target.rootManagedPair });
2482
+ } else {
2483
+ // A valid per-user installation is still usable beside an unreadable
2484
+ // system tree: verify it as this user instead of demanding elevation.
2485
+ const userRoot = path.resolve(getNcRootForMode('user'));
2486
+ if (probeInstallRoot(userRoot) === 'install') {
2487
+ verifyExistingTarget(userRoot);
2488
+ installMode = 'user';
2489
+ ncRoot = userRoot;
2490
+ existingTarget = { existing: true, ncRoot: userRoot, mode: 'user' };
2491
+ }
2492
+ }
2493
+ } catch (err) {
2494
+ if (err instanceof PairingError && err.code === 'HOST_ID_MISMATCH') {
2495
+ reportSetupError(err);
2496
+ } else {
2497
+ console.error(`error: [INSTALL_FAILED] ${err instanceof Error ? err.message : 'Cannot verify installation state.'}`);
2498
+ console.error('suggested_action: Preserve the existing mode/root; resolve conflicting state through its existing maintenance flow, with explicit administrator rights for system installs.');
2499
+ }
2500
+ process.exit(1);
2501
+ return;
1708
2502
  }
1709
- // Mode-aware root: on win32 a system install lives in %ProgramData%\NC,
1710
- // not the %LOCALAPPDATA% tree getNcRoot() returns, so credentials must be
1711
- // written where the system installer will actually read them.
1712
- const ncRoot = getNcRootForMode(installMode);
1713
- console.log(installMode === 'system' ? `Installing as system (machine-wide), root: ${ncRoot}.` : `Installing as user (per-user), root: ${ncRoot}.`);
1714
- const warning = modeWarningFor(installMode);
1715
- if (warning) console.warn(warning);
1716
-
1717
- // Canonical token-free form: prompt AFTER elevation so the token is typed
1718
- // on the elevated terminal and never enters argv (a sudo/UAC re-exec
1719
- // forwards only the host id). Masked input: the token is not echoed.
1720
- if (onePositionalOk) {
2503
+ if (existingTarget) {
2504
+ // An installed tree is verification-only here, except that a partial
2505
+ // per-user application is repaired from its stored host identity. In
2506
+ // particular, a legacy tree without pairing-state metadata must not rerun
2507
+ // OS installers that can replace shared services/helpers; system/protected
2508
+ // repairs remain selected maintenance. The lock keeps a concurrent
2509
+ // uninstall from removing the tree while this verification reads its
2510
+ // credentials and keys.
2511
+ let lock;
1721
2512
  try {
1722
- const enrollmentToken = await promptToken({ input: process.stdin, output: process.stderr });
1723
- positionals.push(String(enrollmentToken || ''));
2513
+ lock = acquireInstallLock(ncRoot);
1724
2514
  } catch (err) {
1725
2515
  reportSetupError(err);
1726
2516
  process.exit(1);
2517
+ return;
2518
+ }
2519
+ try {
2520
+ if (needsUserAppRepair) {
2521
+ // Repair only the application payload from the stored host identity:
2522
+ // no OAuth, no enrollment token and no new device key are involved.
2523
+ console.log(`Repairing the existing user application at ${ncRoot} from stored host credentials (no new enrollment).`);
2524
+ try {
2525
+ await downloadAndInstallAgent({
2526
+ ncRoot,
2527
+ serverUrl: existingEnv.NC_SERVER_URL,
2528
+ installMode: 'user',
2529
+ rootConsent: false,
2530
+ hostId: existingEnv.NC_CLIENT_ID,
2531
+ hostToken: existingEnv.NC_CLIENT_TOKEN,
2532
+ privateKey: existingKey.privateKey
2533
+ });
2534
+ } catch (err) {
2535
+ const detail = err instanceof Error ? err.message : String(err);
2536
+ console.error(`error: [INSTALL_FAILED] Repair of the existing user installation at ${ncRoot} failed: ${detail}`);
2537
+ console.error(`suggested_action: Stored host/device credentials were preserved at ${ncRoot}; resolve the reported download/install problem and re-run setup. No new enrollment was requested.`);
2538
+ process.exit(1);
2539
+ return;
2540
+ }
2541
+ }
2542
+ console.log(`Verifying existing ${rootManagedPair ? 'Root-managed paired' : installMode} installation at ${ncRoot}, host: ${existingEnv.NC_CLIENT_ID}.`);
2543
+ if (rootManagedPair) console.log(`Local management: Root; ordinary service scope: ${rootManagedPair.serviceScope}; selected UID: ${rootManagedPair.ordinaryUid} (credential verification only; no user impersonation).`);
2544
+ try {
2545
+ await verifyHostRegistration({ apiBase: existingEnv.NC_SERVER_URL, hostId: existingEnv.NC_CLIENT_ID, hostToken: existingEnv.NC_CLIENT_TOKEN, privateKey: existingKey.privateKey });
2546
+ printSetupSuccess({ hostId: existingEnv.NC_CLIENT_ID, serverUrl: existingEnv.NC_SERVER_URL, installMode: rootManagedPair ? undefined : installMode, ncRoot, resumed: true });
2547
+ if (needsUserAppRepair) console.log('repair: application reinstalled from stored host credentials; host identity preserved.');
2548
+ if (args.includes('--allow-root-commands')) console.warn('Existing root consent unchanged. Enabling root requires explicit selected-installation privileged maintenance and independent management approval.');
2549
+ } catch (err) {
2550
+ const code = err instanceof PairingError ? err.code : 'INSTALL_FAILED';
2551
+ const detail = code === 'VERIFY_FAILED' ? 'The existing host is not registered on the server; stored identity is unchanged.' : err.message;
2552
+ console.error(`error: [${code}] ${detail}`);
2553
+ 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.`);
2554
+ process.exit(1);
2555
+ }
2556
+ } finally {
2557
+ lock.release();
1727
2558
  }
2559
+ return;
1728
2560
  }
1729
-
1730
- if (positionals.length === 2) {
1731
- const [hostId, enrollmentToken] = positionals;
2561
+ // macOS has already resolved its user/system boundary without automatic
2562
+ // elevation. Linux requests sudo; Windows requests UAC (Start-Process
2563
+ // -Verb RunAs) so the install lands machine-wide at the canonical system
2564
+ // root. A refused or unavailable Windows elevation is a hard stop: the CLI
2565
+ // never silently degrades a system install into %LOCALAPPDATA%. An
2566
+ // unreadable canonical system installation also forces this branch: without
2567
+ // elevation it is unknowable and must not become a per-user competitor.
2568
+ if (unreadableSystemRoot && (forceUser || !canElevate())) {
2569
+ console.error(`error: [INSTALL_FAILED] An existing system installation at ${unreadableSystemRoot} cannot be inspected without administrator rights.`);
2570
+ console.error(forceUser
2571
+ ? 'suggested_action: --user cannot create a per-user installation beside it; re-run without --user with Administrator/root rights so the existing installation can be inspected. No per-user installation was created.'
2572
+ : 'suggested_action: Re-run this setup with Administrator/root rights so the existing installation can be inspected; no per-user installation was created beside it.');
2573
+ process.exit(1);
2574
+ return;
2575
+ }
2576
+ if (!forceUser && os.platform() !== 'darwin' && installMode === 'user' && canElevate() && !process.env.NC_ELEVATED) {
2577
+ // A legacy positional token must never enter sudo/UAC argv: it travels in
2578
+ // a 0600 owner-only temp file whose path alone is re-executed.
2579
+ const tokenFile = positionals.length === 2 ? createEnrollmentTokenFile(positionals[1]) : null;
2580
+ const elevatedArgs = tokenFile
2581
+ ? buildElevatedSetupArgs(process.argv.slice(1), args, args.lastIndexOf(positionals[1]), tokenFile.filePath)
2582
+ : undefined;
1732
2583
  try {
1733
- const result = await pairDeviceAndInstall({
1734
- baseUrl,
1735
- apiBase,
1736
- enrollment: { host_id: hostId, enrollment_token: enrollmentToken },
1737
- installMode,
1738
- ncRoot
1739
- });
1740
- printSetupSuccess({ hostId: result.hostId, serverUrl: result.serverUrl, installMode, resumed: result.resumed, version: result.version });
2584
+ if (os.platform() === 'win32') {
2585
+ if (process.stdin.isTTY) {
2586
+ console.log('Admin rights required for a system-wide install. Requesting UAC elevation…');
2587
+ const elevated = requestWindowsElevation(browserBrand, elevatedArgs);
2588
+ if (elevated && elevated.status === 0) {
2589
+ tokenFile?.cleanup();
2590
+ process.exit(0);
2591
+ }
2592
+ }
2593
+ console.error('error: [INSTALL_FAILED] Elevation was unavailable or declined; no system-wide installation was performed and no per-user fallback was attempted.');
2594
+ console.error('suggested_action: Re-run `nodecommand setup --user` for a per-user installation, or run as Administrator in an elevated shell for a system-wide install at C:\\ProgramData\\NC.');
2595
+ process.exit(1);
2596
+ }
2597
+ console.log('Admin rights required for a system-wide install. Requesting sudo…');
2598
+ // Preserve only the single signal the elevated re-exec depends on; sudo
2599
+ // resets the rest of the environment instead of forwarding it wholesale.
2600
+ // The caller's PATH is never forwarded into the privileged re-exec.
2601
+ const elevationEnv = {
2602
+ PATH: '/usr/bin:/bin:/usr/sbin:/sbin',
2603
+ NC_ELEVATED: '1'
2604
+ };
2605
+ if (process.env.TERM) elevationEnv.TERM = process.env.TERM;
2606
+ let sudo;
2607
+ try {
2608
+ sudo = resolveExecutable('sudo');
2609
+ } catch (err) {
2610
+ console.error(`error: [INSTALL_FAILED] ${err.message}`);
2611
+ console.error('suggested_action: Install an absolute sudo binary or run the system installer explicitly with administrator rights; no per-user fallback was attempted for an unsafe elevation path.');
2612
+ process.exit(1);
2613
+ }
2614
+ const elevated = child_process.spawnSync(
2615
+ sudo,
2616
+ ['--preserve-env=NC_ELEVATED', process.execPath, ...(elevatedArgs ?? process.argv.slice(1))],
2617
+ { stdio: 'inherit', env: elevationEnv }
2618
+ );
2619
+ if (elevated && elevated.status === 0) {
2620
+ tokenFile?.cleanup();
2621
+ process.exit(0);
2622
+ }
2623
+ } finally {
2624
+ tokenFile?.cleanup();
2625
+ }
2626
+ if (unreadableSystemRoot) {
2627
+ console.error(`error: [INSTALL_FAILED] The elevated setup did not inspect or repair the existing system installation at ${unreadableSystemRoot}.`);
2628
+ console.error('suggested_action: Preserve the existing system tree and re-run the system installer with explicit administrator rights; no per-user installation was attempted.');
2629
+ process.exit(1);
2630
+ }
2631
+ try {
2632
+ // A failed elevated child may already have created an installation.
2633
+ // Recheck before reporting the failure rather than creating a competing
2634
+ // agent; a partially completed system install must be preserved.
2635
+ const target = setupTarget({ mode: installMode });
2636
+ if (target.existing) throw new Error(`An installation now exists at ${target.ncRoot}; preserve it through its maintenance flow.`);
1741
2637
  } catch (err) {
1742
- reportSetupError(err);
2638
+ console.error(`error: [INSTALL_FAILED] ${err.message}`);
2639
+ console.error('suggested_action: Preserve existing roots and select their maintenance flow; declining elevation does not authorize a second installation.');
1743
2640
  process.exit(1);
2641
+ return;
1744
2642
  }
2643
+ // No silent fallback after an attempted elevation: a per-user install here
2644
+ // would compete with the expected system install and could prompt for a
2645
+ // second enrollment token in the same run. Fail closed instead.
2646
+ console.error('error: [INSTALL_FAILED] Elevation was unavailable or declined; no system-wide installation was performed and no per-user fallback was attempted.');
2647
+ console.error('suggested_action: Re-run `nodecommand setup --user` for a per-user installation, or provide administrator/root rights for a machine-wide installation.');
2648
+ process.exit(1);
1745
2649
  return;
1746
2650
  }
1747
- await runDeviceFlow({ installMode, ncRoot });
2651
+ // Mode-aware root: on win32 a system install lives in %ProgramData%\NC,
2652
+ // not the %LOCALAPPDATA% tree getNcRoot() returns, so credentials must be
2653
+ // written where the system installer will actually read them.
2654
+ ncRoot = ncRoot || getNcRootForMode(installMode);
2655
+ // Serialize credential/key writes and the installer against any concurrent
2656
+ // setup or uninstall for this root.
2657
+ const lock = acquireInstallLock(ncRoot);
2658
+ try {
2659
+ console.log(installMode === 'system' ? `Installing as system (machine-wide), root: ${ncRoot}.` : `Installing as user (per-user), root: ${ncRoot}.`);
2660
+ const warning = modeWarningFor(installMode);
2661
+ if (warning) console.warn(warning);
2662
+
2663
+ // Canonical token-free form: prompt AFTER elevation so the token is typed
2664
+ // on the elevated terminal and never enters argv (a sudo/UAC re-exec
2665
+ // forwards only the host id). Masked input: the token is not echoed.
2666
+ if (onePositionalOk) {
2667
+ try {
2668
+ assertMatchingHostIdentity(loadValidHostEnv(getEnvFilePath(ncRoot)), positionals[0]);
2669
+ const enrollmentToken = await promptToken({ input: process.stdin, output: process.stderr });
2670
+ positionals.push(String(enrollmentToken || ''));
2671
+ } catch (err) {
2672
+ reportSetupError(err);
2673
+ process.exit(1);
2674
+ }
2675
+ }
2676
+
2677
+ const rootConsent = () => promptRootCommandConsent({ explicitOptIn: args.includes('--allow-root-commands') });
2678
+ if (positionals.length === 2) {
2679
+ const [hostId, enrollmentToken] = positionals;
2680
+ try {
2681
+ const result = await pairDeviceAndInstall({
2682
+ baseUrl,
2683
+ apiBase,
2684
+ enrollment: { host_id: hostId, enrollment_token: enrollmentToken },
2685
+ browserBrand,
2686
+ installMode,
2687
+ rootConsent,
2688
+ ncRoot
2689
+ });
2690
+ printSetupSuccess({ ...result, installMode, ncRoot });
2691
+ } catch (err) {
2692
+ reportSetupError(err);
2693
+ process.exit(1);
2694
+ }
2695
+ return;
2696
+ }
2697
+ await runDeviceFlow({ installMode, ncRoot, browserBrand, rootConsent });
2698
+ } finally {
2699
+ lock.release();
2700
+ }
2701
+ }
2702
+
2703
+ export const ROOT_COMMAND_CONSENT_PROMPT = 'Allow root/administrator commands through Node Command? [y/N]';
2704
+
2705
+ export async function promptRootCommandConsent({ explicitOptIn = false, input = process.stdin, output = process.stdout, confirm = confirmPrompt } = {}) {
2706
+ if (explicitOptIn === true) return true;
2707
+ if (!input.isTTY || !output.isTTY) return false;
2708
+ return (await confirm(ROOT_COMMAND_CONSENT_PROMPT + ' ')) === true;
1748
2709
  }
1749
2710
 
1750
2711
  /**
@@ -1777,6 +2738,9 @@ export function promptEnrollmentToken({ input = process.stdin, output = process.
1777
2738
  );
1778
2739
  return;
1779
2740
  }
2741
+ // Non-secret guidance: the operator must know paste/Enter are honored even
2742
+ // though no character is echoed. The token itself is never rendered.
2743
+ output.write('Paste the enrollment token from the dashboard. Input is hidden (nothing is echoed); press Enter to submit.\n');
1780
2744
  output.write(ENROLLMENT_TOKEN_PROMPT);
1781
2745
  const sink = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
1782
2746
  sink.isTTY = true;
@@ -1801,16 +2765,19 @@ export function promptEnrollmentToken({ input = process.stdin, output = process.
1801
2765
  close();
1802
2766
  reject(new PairingError('ENROLL_TOKEN_REQUIRED', message));
1803
2767
  };
1804
- rl.on('SIGINT', () => abort('Enrollment token entry was cancelled.'));
2768
+ rl.on('SIGINT', () => abort('Enrollment token entry was cancelled; nothing was submitted.'));
1805
2769
  rl.on('close', () => {
1806
- if (!settled) abort('No enrollment token was entered (terminal closed). Re-run in a terminal and paste the token from the dashboard.');
2770
+ if (!settled) abort('No enrollment token was entered (terminal closed); nothing was submitted. Re-run in a terminal and paste the token from the dashboard.');
1807
2771
  });
1808
2772
  rl.question('', (answer) => {
1809
2773
  const token = String(answer ?? '').trim();
1810
2774
  if (!token) {
1811
- abort('No enrollment token was entered. Re-run in a terminal and paste the token shown in the dashboard.');
2775
+ abort('No enrollment token was entered; nothing was submitted. Re-run in a terminal and paste the token shown in the dashboard.');
1812
2776
  return;
1813
2777
  }
2778
+ // Length-only acknowledgement: confirms the paste/Enter pair was received
2779
+ // without revealing any token byte.
2780
+ output.write(`Enrollment token received (${token.length} characters). Validating...\n`);
1814
2781
  finish(token);
1815
2782
  });
1816
2783
  });
@@ -1853,8 +2820,8 @@ export function confirmPrompt(question, openInterface = null) {
1853
2820
  * tree is executed (never a downloaded copy) and its exit status is reported.
1854
2821
  * Target discovery is centered on the canonical root (candidateInstallRoots
1855
2822
  * 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".
2823
+ * non-root shell reports trusted maintenance as required instead of falsely
2824
+ * reporting "nothing to remove". Discovery never authorizes root execution.
1858
2825
  * @param {string[]} args
1859
2826
  * @param {{ command: string, emptyMessage: string, elevateOnWindows?: boolean, completionMessage: string }} options
1860
2827
  */
@@ -1866,12 +2833,19 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
1866
2833
  assertKnownFlags(args, ['--yes', '-y', '--help', '-h'], command);
1867
2834
  const stray = args.filter((a) => !a.startsWith('-') && a !== 'help');
1868
2835
  if (stray.length > 0) {
1869
- console.error(`error: [UNKNOWN_ARGUMENT] \`${command}\` takes no positional arguments (got: ${stray.join(', ')}).`);
2836
+ console.error(`error: [UNKNOWN_ARGUMENT] \`${command}\` takes no positional arguments (got ${stray.length}).`);
1870
2837
  console.error(`suggested_action: Run \`nodecommand ${command} --yes\` (or plain \`nodecommand ${command}\` to be asked).`);
1871
2838
  process.exit(1);
1872
2839
  }
1873
2840
  const yes = args.includes('--yes') || args.includes('-y');
1874
- const { install, unreadable } = findRemovalTargets();
2841
+ let discovery;
2842
+ try { discovery = findRemovalTargets(); } catch (err) {
2843
+ console.error(`error: [TRUSTED_ROOT_MAINTENANCE_REQUIRED] ${err.message}`);
2844
+ console.error(uninstallFailureSuggestion());
2845
+ process.exit(1);
2846
+ return;
2847
+ }
2848
+ const { install, unreadable, paired = [] } = discovery;
1875
2849
  const targets = [...install, ...unreadable];
1876
2850
  if (targets.length === 0) {
1877
2851
  console.log(emptyMessage);
@@ -1880,7 +2854,7 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
1880
2854
  // Windows: a machine-wide tree at C:\ProgramData\NC cannot be removed from
1881
2855
  // a standard shell — request UAC (Start-Process -Verb RunAs) and re-run this
1882
2856
  // same command elevated. Declined or headless elevation is a hard stop.
1883
- if (elevateOnWindows && os.platform() === 'win32' && !isElevated()) {
2857
+ if (elevateOnWindows && os.platform() === 'win32' && targets.some((root) => !isUserInstallRoot(root)) && !isElevated()) {
1884
2858
  if (process.stdin.isTTY) {
1885
2859
  console.log('Admin rights required to remove the system-wide agent. Requesting UAC elevation…');
1886
2860
  const elevated = requestWindowsElevation();
@@ -1889,7 +2863,16 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
1889
2863
  console.error(WINDOWS_ELEVATION_UNINSTALL_DECLINED);
1890
2864
  process.exit(1);
1891
2865
  }
1892
- console.log(`Found Node Command installation(s): ${targets.join(', ')}`);
2866
+ console.log(`Found Node Command installation(s): ${targets.map(root => {
2867
+ const pair = paired.find(selected => selected.ncRoot === root);
2868
+ return pair ? pair.helperRoot === root ? `${root} (co-located protected helper)` : `${root} (paired protected helper: ${pair.helperRoot})` : root;
2869
+ }).join(', ')}`);
2870
+ if (paired.length && (os.platform() !== 'linux' || process.getuid?.() !== 0 || process.geteuid?.() !== 0)) {
2871
+ 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.');
2872
+ console.error(uninstallFailureSuggestion());
2873
+ process.exit(1);
2874
+ return;
2875
+ }
1893
2876
  if (unreadable.length > 0) {
1894
2877
  console.log(
1895
2878
  `Elevated removal needed for system install(s): ${unreadable.join(', ')} (not readable as the current user).`
@@ -1909,36 +2892,64 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
1909
2892
  }
1910
2893
  let failed = 0;
1911
2894
  for (const root of install) {
1912
- const script = uninstallerForRoot(root);
1913
- if (!fs.existsSync(script)) {
2895
+ const pair = paired.find(selected => selected.ncRoot === root);
2896
+ const script = pair ? LINUX_PAIRED_UNINSTALL_ENTRY : uninstallerForRoot(root);
2897
+ if (!pair && !fs.existsSync(script)) {
1914
2898
  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.');
2899
+ console.error(uninstallFailureSuggestion());
2900
+ failed += 1;
2901
+ continue;
2902
+ }
2903
+ let lock;
2904
+ try {
2905
+ lock = acquireInstallLock(root);
2906
+ } catch (err) {
2907
+ reportSetupError(err);
1916
2908
  failed += 1;
1917
2909
  continue;
1918
2910
  }
1919
2911
  try {
1920
2912
  runUninstallerScript(script);
1921
- console.log(`Removed ${root} (exit 0).`);
2913
+ console.log(`Removed ${root}${pair && pair.helperRoot !== root ? ` and paired protected helper ${pair.helperRoot}` : ''} (exit 0).`);
1922
2914
  } catch (err) {
1923
2915
  const rc = typeof err?.status === 'number' ? err.status : 1;
1924
2916
  console.error(`error: [UNINSTALL_FAILED] uninstaller for ${root} exited with status ${rc}: ${err.message}`);
1925
2917
  console.error(uninstallFailureSuggestion());
2918
+ if (pair) {
2919
+ process.exit(rc || 1);
2920
+ return;
2921
+ }
1926
2922
  failed += 1;
2923
+ } finally {
2924
+ lock.release();
1927
2925
  }
1928
2926
  }
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.
2927
+ // Unreadable system roots remain visible, but never trigger implicit sudo.
1932
2928
  for (const root of unreadable) {
1933
2929
  const script = uninstallerForRoot(root);
2930
+ let lock = null;
2931
+ try {
2932
+ lock = acquireInstallLock(root);
2933
+ } catch (err) {
2934
+ if (err instanceof PairingError && err.code === 'SETUP_IN_PROGRESS') {
2935
+ reportSetupError(err);
2936
+ failed += 1;
2937
+ continue;
2938
+ }
2939
+ // Best effort: an unreadable system root usually cannot host a writable
2940
+ // lock from an ordinary caller. The privileged uninstaller remains the
2941
+ // authority; do not turn a lock-permission failure into a false refusal.
2942
+ }
1934
2943
  try {
1935
2944
  runUninstallerScript(script);
1936
- console.log(`Removed ${root} (elevated, exit 0).`);
2945
+ console.log(`Removed ${root} (exit 0).`);
1937
2946
  } catch (err) {
1938
2947
  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}`);
2948
+ console.error(`error: [UNINSTALL_FAILED] uninstaller for ${root} exited with status ${rc}: ${sanitizeForDisplay(err.message, 300)}`);
1940
2949
  console.error(uninstallFailureSuggestion());
1941
2950
  failed += 1;
2951
+ } finally {
2952
+ lock?.release();
1942
2953
  }
1943
2954
  }
1944
2955
  if (failed > 0) process.exit(1);
@@ -1953,6 +2964,8 @@ export async function runRemove(args = []) {
1953
2964
  return runRemoval(args, {
1954
2965
  command: 'remove',
1955
2966
  emptyMessage: 'No Node Command installation found (nothing to remove).',
2967
+ // remove is the legacy uninstall alias and must share its UAC path.
2968
+ elevateOnWindows: true,
1956
2969
  completionMessage: 'Remove complete.'
1957
2970
  });
1958
2971
  }
@@ -1981,10 +2994,11 @@ export async function runUninstall(args = []) {
1981
2994
  /**
1982
2995
  * Browser device flow + enroll/redeem/install/verify. Extracted verbatim from
1983
2996
  * 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]
2997
+ * install mode; plain `nodecommand` now uses the same setup preflight.
2998
+ * @param {{ installMode?: 'system'|'user', ncRoot?: string, browserBrand?: string, rootConsent?: boolean|(() => Promise<boolean>) }} [options]
1986
2999
  */
1987
- async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {}) {
3000
+ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined, browserBrand = captureBrowserDefaultBrand(), rootConsent = false } = {}) {
3001
+ assertCredentialOrigin(baseUrl, { label: 'NC_BASE_URL' });
1988
3002
  const deviceEndpoint = `${baseUrl}/oauth/device/code`;
1989
3003
  let initRes;
1990
3004
 
@@ -2001,17 +3015,20 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
2001
3015
  try {
2002
3016
  initRes = await postJson(deviceEndpoint, {
2003
3017
  client_id: clientId,
3018
+ // B4: scope is an explicit client request, never a server default.
3019
+ // nc-cli enrollment/install needs the write/admin surface.
3020
+ scope: 'mcp:all',
2004
3021
  code_challenge: deviceCodeChallenge,
2005
3022
  code_challenge_method: 'S256'
2006
3023
  });
2007
3024
  } catch (err) {
2008
- console.error(`error: [DEVICE_FLOW_CONNECT] Could not connect to ${deviceEndpoint}: ${err.message}`);
3025
+ console.error(`error: [DEVICE_FLOW_CONNECT] Could not connect to ${sanitizeForDisplay(deviceEndpoint, 256)}: ${sanitizeForDisplay(err.message, 300)}`);
2009
3026
  console.error('suggested_action: Check network access to the control plane (or NC_BASE_URL), then re-run.');
2010
3027
  process.exit(1);
2011
3028
  }
2012
3029
 
2013
3030
  if (initRes.status !== 200 || !initRes.data || !initRes.data.device_code) {
2014
- const err = initRes.data?.error_description || initRes.data?.error || `HTTP ${initRes.status}`;
3031
+ const err = sanitizeForDisplay(initRes.data?.error_description || initRes.data?.error || `HTTP ${initRes.status}`, 300);
2015
3032
  console.error(`error: [DEVICE_FLOW_START] Failed to start device flow: ${err}`);
2016
3033
  console.error('suggested_action: Re-run the command; if it persists, verify the control plane is reachable and NC_BASE_URL is correct.');
2017
3034
  process.exit(1);
@@ -2039,7 +3056,8 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
2039
3056
  // has a manual 8-character code input; the deep-link form is useless here
2040
3057
  // because the login redirect strips the query (return_to=/device only), so
2041
3058
  // the code would never auto-fill after sign-in anyway.
2042
- const verifyUrl = verification_uri || `${baseUrl}/device`;
3059
+ const verifyUrl = sanitizeForDisplay(verification_uri || `${baseUrl}/device`, 512);
3060
+ const displayUserCode = sanitizeForDisplay(user_code, 64);
2043
3061
 
2044
3062
  printBox([
2045
3063
  { text: `${bold}Node Command — Device Authorization${reset}`, rawLength: 'Node Command — Device Authorization'.length },
@@ -2047,7 +3065,7 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
2047
3065
  { text: 'To complete pairing, open this URL in your browser:', rawLength: 'To complete pairing, open this URL in your browser:'.length },
2048
3066
  { text: `${cyan}${bold}${verifyUrl}${reset}`, rawLength: verifyUrl.length },
2049
3067
  { text: '', rawLength: 0 },
2050
- { text: `Then enter this code on the page: ${yellow}${bold}${user_code}${reset}`, rawLength: `Then enter this code on the page: ${user_code}`.length },
3068
+ { text: `Then enter this code on the page: ${yellow}${bold}${displayUserCode}${reset}`, rawLength: `Then enter this code on the page: ${displayUserCode}`.length },
2051
3069
  { text: '', rawLength: 0 },
2052
3070
  { text: `${dim}Waiting for approval in browser...${reset}`, rawLength: 'Waiting for approval in browser...'.length }
2053
3071
  ]);
@@ -2092,12 +3110,14 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
2092
3110
  const oauthToken = tokenRes.data.access_token;
2093
3111
  console.log('\n🔓 Browser approval received. Enrolling this machine...');
2094
3112
  try {
2095
- const result = await pairDeviceAndInstall({ baseUrl, apiBase, oauthToken, installMode, ncRoot });
3113
+ const result = await pairDeviceAndInstall({ baseUrl, apiBase, oauthToken, installMode, ncRoot, browserBrand, rootConsent });
2096
3114
  if (installMode) {
2097
3115
  // `setup` reports the structured success block (host, mode,
2098
3116
  // versions, online status); bare `nodecommand` keeps the legacy
2099
3117
  // pairing messages below.
2100
3118
  printSetupSuccess({
3119
+ ...result,
3120
+ ncRoot,
2101
3121
  hostId: result.hostId,
2102
3122
  serverUrl: result.serverUrl,
2103
3123
  installMode,
@@ -2126,7 +3146,7 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
2126
3146
  currentIntervalMs += 5000;
2127
3147
  } else {
2128
3148
  // Any fatal error (expired_token, access_denied, invalid_grant, etc.)
2129
- const errMsg = tokenRes.data?.error_description || tokenRes.data?.error || `HTTP ${tokenRes.status}`;
3149
+ const errMsg = sanitizeForDisplay(tokenRes.data?.error_description || tokenRes.data?.error || `HTTP ${tokenRes.status}`, 300);
2130
3150
  console.error(`\nerror: [DEVICE_FLOW_AUTH_DENIED] Authorization failed: ${errMsg}`);
2131
3151
  console.error('suggested_action: Re-run the command and approve the prompt in the browser.');
2132
3152
  process.exit(1);
@@ -2137,7 +3157,7 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
2137
3157
  consecutiveNetErrors += 1;
2138
3158
  if (consecutiveNetErrors >= MAX_NET_ERROR_RETRIES) {
2139
3159
  const detail = pollErr instanceof Error ? pollErr.message : String(pollErr);
2140
- console.error(`\nerror: [DEVICE_FLOW_CONNECT] Could not reach ${tokenEndpoint} after ${consecutiveNetErrors} attempts: ${detail}`);
3160
+ console.error(`\nerror: [DEVICE_FLOW_CONNECT] Could not reach ${sanitizeForDisplay(tokenEndpoint, 256)} after ${consecutiveNetErrors} attempts: ${sanitizeForDisplay(detail, 300)}`);
2141
3161
  console.error('suggested_action: Check network access to the control plane (or NC_BASE_URL), then re-run.');
2142
3162
  process.exit(1);
2143
3163
  }
@@ -2160,5 +3180,8 @@ const isMainModule = () => {
2160
3180
  };
2161
3181
 
2162
3182
  if (isMainModule()) {
2163
- main();
3183
+ main().catch((err) => {
3184
+ reportSetupError(err);
3185
+ process.exitCode = 1;
3186
+ });
2164
3187
  }