node-command 2.0.3 → 2.0.5

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,10 +8,11 @@ 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,
15
16
  captureBrowserDefaultBrand,
16
17
  normalizeBrowserBrand,
17
18
  browserBrandReexecArgs,
@@ -21,14 +22,18 @@ import {
21
22
  installerEnvForMode,
22
23
  isElevated,
23
24
  isUserInstallRoot,
25
+ probeInstallRoot,
26
+ resolveExecutable,
24
27
  setupTarget,
28
+ unreadableSystemInstallations,
25
29
  maintainLinuxIdentity,
26
30
  loadProtectedHelperProducer,
27
31
  LINUX_PAIRED_UNINSTALL_ENTRY,
28
32
  modeWarningFor,
29
33
  runUninstallerScript,
30
34
  uninstallFailureSuggestion,
31
- uninstallerForRoot
35
+ uninstallerForRoot,
36
+ windowsExecutablePath
32
37
  } from '../lib/setup.mjs';
33
38
 
34
39
  const baseUrl = process.env.NC_BASE_URL || 'https://auth.nodecommand.app';
@@ -46,6 +51,7 @@ const clientId = 'nc-cli';
46
51
 
47
52
  export const DEFAULT_CLI_REQUEST_TIMEOUT_MS = 30_000;
48
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
49
55
 
50
56
  export function getCliRequestTimeoutMs() {
51
57
  const envVal = Number(process.env.NC_CLI_TIMEOUT_MS || process.env.NC_TIMEOUT_MS);
@@ -57,6 +63,81 @@ export function getCliMaxResponseBytes() {
57
63
  return Number.isFinite(envVal) && envVal > 0 ? envVal : DEFAULT_CLI_MAX_RESPONSE_BYTES;
58
64
  }
59
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
+
60
141
  /**
61
142
  * Sends a JSON HTTP request using node:https or node:http with timeout and response cap guards (NC-AUD-010).
62
143
  * @param {string} method
@@ -261,7 +342,19 @@ export function httpGet(targetUrl, maxRedirects = 5, extraHeaders = {}, options
261
342
  res.statusCode < 400 &&
262
343
  res.headers.location
263
344
  ) {
264
- 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
+ }
265
358
  res.resume();
266
359
  const forwarded = isSameOrigin(targetUrl, redirectUrl) ? extraHeaders : {};
267
360
  return resolve(httpGet(redirectUrl, maxRedirects - 1, forwarded, { signal, timeoutMs }));
@@ -298,42 +391,111 @@ export async function fetchJson(targetUrl, extraHeaders = {}) {
298
391
  }
299
392
 
300
393
  /**
301
- * 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.
302
397
  * @param {string} targetUrl
303
398
  * @param {string} destinationPath
304
399
  * @param {Record<string, string>} [extraHeaders] Device headers for `/api/clients/...` package URLs; empty for public `/releases/...` URLs.
305
- * @param {{ timeoutMs?: number, signal?: AbortSignal }} [options]
306
- * @returns {Promise<string>}
400
+ * @param {{ timeoutMs?: number, signal?: AbortSignal, maxBytes?: number }} [options]
401
+ * @returns {Promise<{ path: string, bytes: number, sha256: string }>}
307
402
  */
308
403
  export async function downloadToFile(targetUrl, destinationPath, extraHeaders = {}, options = {}) {
309
- 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
+ }
310
434
  return new Promise((resolve, reject) => {
311
- const fileStream = fs.createWriteStream(destinationPath);
312
- 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
+ });
313
463
 
314
464
  const onError = (err) => {
315
- if (resolved) return;
316
- resolved = true;
465
+ if (settled) return;
466
+ settled = true;
467
+ clearTimer();
317
468
  fileStream.destroy();
318
469
  try {
319
470
  fs.unlinkSync(destinationPath);
320
471
  } catch {}
321
- reject(err);
472
+ reject(timedOut ? timeoutError() : err);
322
473
  };
323
474
 
324
475
  res.on('error', onError);
476
+ meter.on('error', onError);
325
477
  fileStream.on('error', onError);
326
478
 
327
- fileStream.on('finish', () => {
328
- if (resolved) return;
329
- resolved = true;
330
- 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') });
331
485
  });
332
-
333
- res.pipe(fileStream);
334
486
  });
335
487
  }
336
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
+
337
499
  /**
338
500
  * Determines the canonical installation root (NC_ROOT) dynamically based on
339
501
  * OS:
@@ -370,14 +532,81 @@ export function getNcRoot({ platform = os.platform(), env = process.env } = {})
370
532
  * @returns {string}
371
533
  */
372
534
  function getTarCommand() {
373
- if (os.platform() === 'win32') {
374
- const sysTar = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe');
375
- if (fs.existsSync(sysTar)) {
376
- 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)}`);
377
608
  }
378
- return 'tar.exe';
379
609
  }
380
- return 'tar';
381
610
  }
382
611
 
383
612
  /**
@@ -386,14 +615,18 @@ function getTarCommand() {
386
615
  * @param {string} targetDir
387
616
  */
388
617
  function extractTarball(archivePath, targetDir) {
389
- const tarCmd = getTarCommand();
618
+ let tarCmd = 'tar';
390
619
  try {
391
- 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, {
392
625
  stdio: ['ignore', 'pipe', 'pipe']
393
626
  });
394
627
  } catch (err) {
395
628
  const details = err.stderr ? err.stderr.toString().trim() : err.message;
396
- 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 });
397
630
  }
398
631
  }
399
632
 
@@ -440,33 +673,39 @@ export function findInstallerScript(tempDir) {
440
673
  */
441
674
  export function runInstallerScript(scriptPath, ncRoot, extraEnv = {}) {
442
675
  const platform = os.platform();
443
- const installEnv = {
444
- ...process.env,
445
- NC_ROOT: ncRoot,
446
- ...extraEnv
447
- };
448
676
 
449
677
  if (platform === 'win32') {
450
678
  child_process.execFileSync(
451
- 'powershell.exe',
452
- ['-ExecutionPolicy', 'Bypass', '-File', scriptPath],
679
+ windowsExecutablePath('powershell'),
680
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
453
681
  {
454
- env: installEnv,
682
+ env: { ...process.env, NC_ROOT: ncRoot, ...extraEnv },
455
683
  stdio: 'inherit'
456
684
  }
457
685
  );
458
- } else if (platform === 'darwin') {
459
- child_process.execFileSync('bash', [scriptPath], {
460
- env: installEnv,
461
- stdio: 'inherit'
462
- });
463
- } else {
464
- // Linux and other POSIX
465
- child_process.execFileSync('bash', [scriptPath], {
466
- env: installEnv,
467
- stdio: 'inherit'
468
- });
686
+ return;
469
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
+ });
470
709
  }
471
710
 
472
711
  /**
@@ -535,6 +774,214 @@ export function getEnrollStatePath(ncRoot = getNcRoot()) {
535
774
  return path.join(ncRoot, 'config', 'nc-enroll-state.json');
536
775
  }
537
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
+
538
985
  /**
539
986
  * Builds the canonical agent env file content from HOST credentials only.
540
987
  * There is deliberately no parameter for the OAuth token: by construction it
@@ -544,8 +991,23 @@ export function getEnrollStatePath(ncRoot = getNcRoot()) {
544
991
  * @param {{ serverUrl: string, hostId: string, hostToken: string, clientName?: string, browserBrand?: string }} options
545
992
  */
546
993
  export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName = os.hostname(), browserBrand = 'unknown' }) {
547
- if (typeof serverUrl !== 'string' || !serverUrl) {
548
- throw new PairingError('INVALID_SERVER_URL', 'Refusing to write agent config without a server URL.');
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.');
549
1011
  }
550
1012
  if (!HOST_ID_RE.test(String(hostId || ''))) {
551
1013
  throw new PairingError(
@@ -614,6 +1076,11 @@ export function loadValidHostEnv(envPath = getEnvFilePath()) {
614
1076
  } catch {
615
1077
  return null;
616
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
+ }
617
1084
  const env = parseEnvFile(text);
618
1085
  if (!HOST_ID_RE.test(String(env.NC_CLIENT_ID || ''))) return null;
619
1086
  if (!HOST_TOKEN_RE.test(String(env.NC_CLIENT_TOKEN || ''))) return null;
@@ -878,6 +1345,7 @@ export async function requestEnrollment({ baseUrl, oauthToken, platform = 'linux
878
1345
  throw new PairingError('ENROLL_NO_OAUTH_TOKEN', 'Cannot request enrollment without a sign-in token. Re-run the device flow.');
879
1346
  }
880
1347
  const origin = String(baseUrl || '').replace(/\/$/, '');
1348
+ assertCredentialOrigin(origin, { label: 'enrollment origin' });
881
1349
  const body = { platform, ...(name ? { name } : {}), ...(tenantId ? { tenant_id: tenantId } : {}) };
882
1350
  let res;
883
1351
  try {
@@ -891,8 +1359,8 @@ export async function requestEnrollment({ baseUrl, oauthToken, platform = 'linux
891
1359
  }
892
1360
  return res.data;
893
1361
  }
894
- const serverError = res.data?.error;
895
- 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)}` : '';
896
1364
  if (res.status === 401) {
897
1365
  throw new PairingError('ENROLL_UNAUTHORIZED', 'Sign-in expired or was revoked. Re-run the device flow to sign in again.');
898
1366
  }
@@ -925,6 +1393,7 @@ export async function requestEnrollment({ baseUrl, oauthToken, platform = 'linux
925
1393
  export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPair, initialPermission = 'user' }) {
926
1394
  if (!['user', 'admin'].includes(initialPermission)) throw new PairingError('INVALID_INITIAL_PERMISSION', 'Initial execution intent must be user or admin.');
927
1395
  const origin = String(baseUrl || '').replace(/\/$/, '');
1396
+ assertCredentialOrigin(origin, { label: 'redemption origin' });
928
1397
  const redeemPath = `/api/enroll/redeem?initial_permission=${initialPermission}`;
929
1398
  const proof = createDeviceProofCli({
930
1399
  privateKey: keyPair.privateKey,
@@ -953,7 +1422,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
953
1422
  }
954
1423
  return r;
955
1424
  }
956
- const serverError = res.data?.error;
1425
+ const serverError = sanitizeForDisplay(res.data?.error || '', 120);
957
1426
  if (res.status === 410 && serverError === 'ENROLLMENT_ALREADY_USED') {
958
1427
  throw new PairingError(
959
1428
  'ENROLLMENT_ALREADY_USED',
@@ -987,6 +1456,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
987
1456
  */
988
1457
  export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined, apiBase: apiBaseOpt = undefined, hostId, hostToken, privateKey, timeoutMs = 45000, intervalMs = 2000 }) {
989
1458
  const origin = String(apiBaseOpt ?? baseUrlOpt ?? process.env.NC_API_URL ?? apiBase).replace(/\/$/, '');
1459
+ assertCredentialOrigin(origin, { label: 'agent server origin' });
990
1460
  const target = `/api/clients/${encodeURIComponent(hostId)}/policy`;
991
1461
  const deadline = Date.now() + Math.max(1000, Number(timeoutMs) || 45000);
992
1462
  const step = Math.max(250, Math.min(Number(intervalMs) || 2000, deadline - Date.now()));
@@ -999,20 +1469,25 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
999
1469
  'NC-Device-Proof': proof
1000
1470
  });
1001
1471
  if (res.status === 200 && res.data?.policy) return { ok: true, hostId };
1002
- lastError = res.data?.error || `HTTP ${res.status}`;
1472
+ lastError = sanitizeForDisplay(res.data?.error || `HTTP ${res.status}`, 200);
1003
1473
  // A missing client row will never appear by waiting: fail fast so the
1004
1474
  // user gets the real cause instead of a timeout.
1005
1475
  if (res.status === 404) {
1006
- 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.`);
1007
1482
  }
1008
1483
  } catch (err) {
1009
1484
  if (err instanceof PairingError) throw err;
1010
- lastError = err.message;
1485
+ lastError = sanitizeForDisplay(err.message, 200);
1011
1486
  }
1012
1487
  if (Date.now() >= deadline) {
1013
1488
  throw new PairingError(
1014
1489
  'VERIFY_TIMEOUT',
1015
- `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.`
1016
1491
  );
1017
1492
  }
1018
1493
  await sleep(Math.min(step, Math.max(250, deadline - Date.now())));
@@ -1044,9 +1519,9 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
1044
1519
  * (`/releases/client/<version>/bootstrap.tar.gz`) stays anonymous. The
1045
1520
  * proof binds method GET + the full request URL + the redemption-issued
1046
1521
  * client token, exactly like redeemEnrollment.
1047
- * @param {{ ncRoot?: string, updatesUrl?: string, serverUrl?: string, release?: Record<string, unknown>|null, installMode?: 'system'|'user', rootConsent?: boolean, runInstaller?: (scriptPath: string, ncRoot: string) => void, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject }} options
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
1048
1523
  */
1049
- export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl = undefined, serverUrl = undefined, release = undefined, installMode = undefined, rootConsent = false, runInstaller = undefined, hostId = undefined, hostToken = undefined, privateKey = undefined } = {}) {
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 } = {}) {
1050
1525
  ncRoot = ncRoot || (installMode ? getNcRootForMode(installMode) : getNcRoot());
1051
1526
  const modeEnv = { ...(installMode ? installerEnvForMode(installMode) : {}), NC_ROOT_COMMAND_CONSENT: rootConsent === true ? 'yes' : 'no' };
1052
1527
  const invokeInstaller =
@@ -1060,28 +1535,33 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1060
1535
  // used here. NC_SERVER_URL remains the agent server address and is read
1061
1536
  // before the API origin so existing installs keep resolving as before.
1062
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
+ }
1063
1549
  /** @type {Record<string, any>} */
1064
1550
  let activeRelease;
1065
1551
  if (release && typeof release === 'object') {
1066
1552
  // Redemption release wins: skip the manifest entirely, so its URL is
1067
1553
  // never logged as a misleading signal.
1068
1554
  activeRelease = /** @type {Record<string, any>} */ (release);
1069
- const releaseVersion = activeRelease.version ? ` ${activeRelease.version}` : '';
1555
+ const releaseVersion = activeRelease.version ? ` ${sanitizeForDisplay(activeRelease.version, 120)}` : '';
1070
1556
  console.log(`📦 Using release${releaseVersion} from enrollment redemption; skipping update manifest.`);
1071
- } else if (
1072
- typeof hostId === 'string' &&
1073
- hostId &&
1074
- typeof hostToken === 'string' &&
1075
- hostToken &&
1076
- privateKey
1077
- ) {
1557
+ } else if (hasDeviceIdentity) {
1078
1558
  // Resume paths (verified stored credentials, already-redeemed enrollment)
1079
1559
  // carry no redemption release, but they DO hold device identity. Resolve
1080
1560
  // the release from the client-authenticated per-host manifest the agent
1081
1561
  // updater itself uses, never from the admin-only /api/updates manifest
1082
1562
  // (which can only answer 401/404 to a non-admin CLI).
1083
1563
  const clientManifestUrl = `${defaultBaseUrl}/api/clients/${encodeURIComponent(hostId)}/update/manifest`;
1084
- console.log(`📡 Fetching client update manifest from ${clientManifestUrl}...`);
1564
+ console.log(`📡 Fetching client update manifest from ${sanitizeForDisplay(clientManifestUrl, 256)}...`);
1085
1565
  let manifestData;
1086
1566
  try {
1087
1567
  manifestData = await fetchJson(
@@ -1092,14 +1572,17 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1092
1572
  const detail = err instanceof Error ? err.message : String(err);
1093
1573
  throw new PairingError(
1094
1574
  'INSTALL_MANIFEST_ERROR',
1095
- `Could not fetch the client update manifest: ${detail}`
1575
+ `Could not fetch the client update manifest: ${sanitizeForDisplay(detail, 300)}`
1096
1576
  );
1097
1577
  }
1098
1578
  activeRelease = manifestData?.release || manifestData || {};
1099
1579
  } else {
1100
1580
  const manifestOrigin = String(process.env.NC_API_URL || apiBase).replace(/\/$/, '');
1101
1581
  const manifestUrl = updatesUrl || process.env.NC_UPDATES_URL || `${manifestOrigin}/api/updates`;
1102
- 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)}...`);
1103
1586
  let manifestData;
1104
1587
  try {
1105
1588
  manifestData = await fetchJson(manifestUrl);
@@ -1108,10 +1591,10 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1108
1591
  if (/status 401\b/i.test(detail)) {
1109
1592
  throw new PairingError(
1110
1593
  'INSTALL_MANIFEST_ERROR',
1111
- `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.`
1112
1595
  );
1113
1596
  }
1114
- 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)}`);
1115
1598
  }
1116
1599
  if (manifestData && typeof manifestData === 'object' && manifestData.error === 'ADMIN_AUTH_REQUIRED') {
1117
1600
  throw new PairingError(
@@ -1136,6 +1619,15 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1136
1619
  if (!tarballUrl.startsWith('http://') && !tarballUrl.startsWith('https://')) {
1137
1620
  tarballUrl = new URL(tarballUrl, defaultBaseUrl).toString();
1138
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
+ }
1139
1631
  } else {
1140
1632
  if (!version) {
1141
1633
  throw new PairingError('INSTALL_MANIFEST_ERROR', 'Unable to determine agent version from update manifest.');
@@ -1157,6 +1649,13 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1157
1649
  if (!Number.isSafeInteger(expectedSize) || expectedSize <= 0) {
1158
1650
  throw new PairingError('INSTALL_MANIFEST_ERROR', 'Agent release manifest must provide a valid positive integer size before downloading or installing.');
1159
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
+ }
1160
1659
 
1161
1660
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nc-install-'));
1162
1661
  const archivePath = path.join(tempDir, 'bootstrap.tar.gz');
@@ -1169,40 +1668,58 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1169
1668
 
1170
1669
  // Identity decision per endpoint: device headers only for the
1171
1670
  // device-authenticated per-client URLs; public release URLs stay bare so
1172
- // 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.
1173
1674
  let downloadHeaders = {};
1174
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
+ }
1175
1682
  if (typeof hostId === 'string' && typeof hostToken === 'string' && hostId && hostToken && privateKey) {
1176
1683
  downloadHeaders = buildDeviceDownloadHeaders({ hostId, hostToken, privateKey }, tarballUrl);
1177
1684
  }
1178
1685
  }
1179
1686
 
1180
1687
  try {
1181
- console.log(`⬇️ Downloading agent package from ${tarballUrl}...`);
1688
+ console.log(`⬇️ Downloading agent package from ${sanitizeForDisplay(tarballUrl)}...`);
1689
+ const effectiveDownloadTimeoutMs = downloadTimeoutMs ?? getCliDownloadTimeoutMs();
1690
+ let download;
1182
1691
  try {
1183
- await downloadToFile(tarballUrl, archivePath, downloadHeaders);
1692
+ download = await downloadToFile(tarballUrl, archivePath, downloadHeaders, {
1693
+ maxBytes: getCliMaxPackageBytes(),
1694
+ timeoutMs: effectiveDownloadTimeoutMs
1695
+ });
1184
1696
  } catch (err) {
1185
- throw new PairingError('INSTALL_DOWNLOAD_ERROR', `Could not download the agent package: ${err.message}`);
1697
+ if (isCliTimeoutError(err)) {
1698
+ throw new PairingError(
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.`
1701
+ );
1702
+ }
1703
+ throw new PairingError('INSTALL_DOWNLOAD_ERROR', `Could not download the agent package: ${sanitizeForDisplay(err.message, 300)}. No archive was installed.`);
1186
1704
  }
1187
1705
 
1188
- const archiveBytes = fs.readFileSync(archivePath);
1189
- if (archiveBytes.length !== expectedSize) {
1706
+ if (download.bytes !== expectedSize) {
1190
1707
  try {
1191
1708
  fs.unlinkSync(archivePath);
1192
1709
  } catch {}
1193
1710
  throw new PairingError(
1194
1711
  'INSTALL_MANIFEST_ERROR',
1195
- `Agent package integrity check failed: size mismatch for ${tarballUrl} (expected ${expectedSize} bytes, got ${archiveBytes.length}). The download was discarded; nothing was installed.`
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.`
1196
1713
  );
1197
1714
  }
1198
- const actualHash = crypto.createHash('sha256').update(archiveBytes).digest('hex');
1715
+ const actualHash = download.sha256;
1199
1716
  if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) {
1200
1717
  try {
1201
1718
  fs.unlinkSync(archivePath);
1202
1719
  } catch {}
1203
1720
  throw new PairingError(
1204
1721
  'INSTALL_MANIFEST_ERROR',
1205
- `Agent package integrity check failed: sha256 mismatch for ${tarballUrl}. The download was discarded; nothing was installed.`
1722
+ `Agent package integrity check failed: sha256 mismatch for ${sanitizeForDisplay(tarballUrl, 256)}. The download was discarded; nothing was installed.`
1206
1723
  );
1207
1724
  }
1208
1725
 
@@ -1210,7 +1727,7 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1210
1727
  try {
1211
1728
  extractTarball(archivePath, tempDir);
1212
1729
  } catch (err) {
1213
- 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)}`);
1214
1731
  }
1215
1732
 
1216
1733
  console.log('🚀 Running installation script...');
@@ -1223,7 +1740,7 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1223
1740
  invokeInstaller(scriptPath, root);
1224
1741
  } catch (err) {
1225
1742
  if (err instanceof PairingError) throw err;
1226
- 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.`);
1227
1744
  }
1228
1745
  } finally {
1229
1746
  console.log('🧹 Cleaning up temporary directory...');
@@ -1240,9 +1757,10 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1240
1757
  * the OAuth enrollment step is skipped and the supplied token is redeemed
1241
1758
  * directly. `installMode` is forwarded to the installer via NC_MODE.
1242
1759
  * Host split: enrollment (OAuth/redeem) uses the auth origin (`baseUrl` /
1243
- * `NC_BASE_URL`); device-channel verification (`/api/clients/*`) uses the
1244
- * agent origin (`apiBase` / `NC_API_URL`) for fresh enrollment. Local resume
1245
- * binds verification and installation requests to the stored NC_SERVER_URL.
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.
1246
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
1247
1765
  */
1248
1766
  export async function pairDeviceAndInstall({
@@ -1274,7 +1792,6 @@ export async function pairDeviceAndInstall({
1274
1792
  }
1275
1793
  const root = ncRoot || getNcRoot();
1276
1794
  const origin = String(baseUrlParam ?? process.env.NC_BASE_URL ?? baseUrl).replace(/\/$/, '');
1277
- const apiOrigin = String(apiBaseParam ?? process.env.NC_API_URL ?? apiBase).replace(/\/$/, '');
1278
1795
  const defaultServerUrl = serverUrl || process.env.NC_SERVER_URL || 'https://agent.nodecommand.app';
1279
1796
  const envPath = getEnvFilePath(root);
1280
1797
  const keyPath = getDeviceKeyPath(root);
@@ -1389,7 +1906,7 @@ export async function pairDeviceAndInstall({
1389
1906
  }
1390
1907
  if (redemption.release?.version) {
1391
1908
  agentVersion = redemption.release.version;
1392
- console.log(`📦 Release: ${agentVersion}`);
1909
+ console.log(`📦 Release: ${sanitizeForDisplay(agentVersion, 120)}`);
1393
1910
  }
1394
1911
  }
1395
1912
 
@@ -1403,9 +1920,12 @@ export async function pairDeviceAndInstall({
1403
1920
  await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey, rootConsent: selectedRootConsent });
1404
1921
  writePairState(statePath, { host_id: hostId, phase: 'installed' });
1405
1922
 
1406
- 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.
1407
1927
  await verifyHostRegistration({
1408
- apiBase: apiOrigin,
1928
+ apiBase: enrollServerUrl,
1409
1929
  hostId,
1410
1930
  hostToken,
1411
1931
  privateKey: keyPair.privateKey,
@@ -1452,6 +1972,8 @@ function printCliHelp() {
1452
1972
  ' nodecommand pair this machine via the browser device flow',
1453
1973
  ' nodecommand <host_id> install with a dashboard host id (prompts for the token, hidden input)',
1454
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`',
1455
1977
  ' nodecommand [--allow-root-commands] explicitly request privileged preparation (default No; independent binding required)',
1456
1978
  ' nodecommand uninstall [--yes] remove the agent from the canonical root (alias: remove)',
1457
1979
  ' nodecommand remove [--yes] uninstall the agent (asks for confirmation unless --yes)',
@@ -1559,7 +2081,7 @@ async function main() {
1559
2081
  // form the dashboard and install_command used to emit) to the `setup
1560
2082
  // <host_id> <token>` path instead of silently ignoring the arguments in the
1561
2083
  // device flow.
1562
- const enrollmentArgs = cliArgs.filter(arg => arg !== '--allow-root-commands');
2084
+ const enrollmentArgs = cliArgs.filter(arg => arg !== '--allow-root-commands' && arg !== '--user');
1563
2085
  if (isBareEnrollmentInvocation(enrollmentArgs)) {
1564
2086
  await runSetup(cliArgs);
1565
2087
  return;
@@ -1612,12 +2134,20 @@ export function suggestedActionFor(code) {
1612
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.';
1613
2135
  case 'INSTALL_DOWNLOAD_ERROR':
1614
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.';
1615
2141
  case 'INSTALL_FAILED':
1616
2142
  case 'INSTALL_EXTRACT_ERROR':
1617
2143
  case 'INSTALL_INTEGRITY_ERROR':
1618
2144
  return 'The agent install did not complete; check network connectivity to the agent release host and re-run; stored credentials are reused.';
1619
2145
  case 'VERIFY_TIMEOUT':
1620
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.';
1621
2151
  default:
1622
2152
  return 'Re-run the command; use `nodecommand --help` for usage.';
1623
2153
  }
@@ -1669,11 +2199,11 @@ function getCliVersion() {
1669
2199
  * @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null, rootConsent?: boolean, ncRoot?: string, executionInitialization?: any }} options
1670
2200
  */
1671
2201
  export function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null, rootConsent = false, ncRoot = undefined, executionInitialization = null }) {
1672
- console.log(`\n✅ Setup complete for host ${hostId}${resumed ? ' (resumed)' : ''}.`);
2202
+ console.log(`\n✅ Setup complete for host ${sanitizeForDisplay(hostId, 128)}${resumed ? ' (resumed)' : ''}.`);
1673
2203
  if (installMode) console.log(`installed as ${installMode}`);
1674
- if (version) console.log(`agent version: ${version}`);
2204
+ if (version) console.log(`agent version: ${sanitizeForDisplay(version, 120)}`);
1675
2205
  console.log(`cli version: ${getCliVersion()}`);
1676
- console.log(`server: ${serverUrl}`);
2206
+ console.log(`server: ${sanitizeForDisplay(serverUrl, 256)}`);
1677
2207
  console.log('status: agent credentials verified online');
1678
2208
  if (ncRoot) console.log(`installation root: ${ncRoot}`);
1679
2209
  console.log('OS execution identity: not verified by this credential check; native service/readiness verification required');
@@ -1715,13 +2245,6 @@ export function printSetupSuccess({ hostId, serverUrl, installMode = undefined,
1715
2245
  }
1716
2246
  }
1717
2247
 
1718
- /**
1719
- * Guidance printed when Windows UAC elevation for a system-wide install is
1720
- * unavailable (headless shell) or was declined by the operator.
1721
- */
1722
- const WINDOWS_ELEVATION_INSTALL_DECLINED =
1723
- 'Elevation unavailable or declined. Please run as Administrator in an elevated shell for a system-wide install at C:\\ProgramData\\NC.';
1724
-
1725
2248
  /**
1726
2249
  * Guidance printed when Windows UAC elevation for uninstallation is
1727
2250
  * unavailable (headless shell) or was declined by the operator.
@@ -1735,15 +2258,15 @@ const WINDOWS_ELEVATION_UNINSTALL_DECLINED =
1735
2258
  * console is visible) and returns the spawnSync result. Never throws: a
1736
2259
  * missing powershell or a declined prompt resolves to null/non-zero.
1737
2260
  */
1738
- function requestWindowsElevation(browserBrand = undefined) {
1739
- const originalArgs = [...process.argv.slice(1)];
2261
+ function requestWindowsElevation(browserBrand = undefined, elevatedArgs = undefined) {
2262
+ const originalArgs = elevatedArgs ? [...elevatedArgs] : [...process.argv.slice(1)];
1740
2263
  const args = browserBrand === undefined ? originalArgs : browserBrandReexecArgs(originalArgs, browserBrand);
1741
2264
  const literal = value => "'" + value.replace(/'/g, "''") + "'";
1742
2265
  const quote = value => '"' + String(value).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1') + '"';
1743
2266
  const script = `$p=Start-Process -FilePath ${literal(process.execPath)} -ArgumentList ${literal(args.map(quote).join(' '))} -Verb RunAs -Wait -PassThru; exit $p.ExitCode`;
1744
2267
  try {
1745
2268
  return child_process.spawnSync(
1746
- 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
2269
+ windowsExecutablePath('powershell'),
1747
2270
  [
1748
2271
  '-NoProfile',
1749
2272
  '-NonInteractive',
@@ -1780,7 +2303,23 @@ function requestWindowsElevation(browserBrand = undefined) {
1780
2303
  * @param {{ allowHostIdOnly?: boolean, promptToken?: (io: { input: import('node:stream').Readable, output: import('node:stream').Writable }) => Promise<string> }} [options]
1781
2304
  */
1782
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
+ }
1783
2320
  const positionals = args.filter((a) => !a.startsWith('-'));
2321
+ if (elevatedToken !== undefined) positionals.push(elevatedToken);
2322
+ const positionalTokenInArgv = elevatedToken === undefined && positionals.length === 2;
1784
2323
  if (positionals.includes('help') || args.includes('--help') || args.includes('-h')) {
1785
2324
  console.log(
1786
2325
  [
@@ -1795,6 +2334,7 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
1795
2334
  'macOS: fresh non-root installs use the per-user LaunchAgent without sudo;',
1796
2335
  'explicit root uses system mode. Existing roots/modes are preserved or setup stops.',
1797
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.',
1798
2338
  'Service installation authorization does not allow root commands through chat.',
1799
2339
  'New setup asks: Allow root/administrator commands through Node Command? [y/N]',
1800
2340
  'Noninteractive setup defaults to No; --allow-root-commands explicitly requests preparation.',
@@ -1803,19 +2343,40 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
1803
2343
  );
1804
2344
  return;
1805
2345
  }
1806
- assertKnownFlags(args, ['--help', '-h', '--allow-root-commands', '--maintain-identity', '--configure-helper-binding', '--activate-initial-binding'], '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
+ }
1807
2353
  if (args.includes('--configure-helper-binding')) {
1808
2354
  if (positionals.length !== 3 || !process.env.NC_ROOT || args.includes('--allow-root-commands') || args.includes('--maintain-identity'))
1809
2355
  throw new PairingError('INVALID_SETUP_ARGS', 'Selected helper binding maintenance needs NC_ROOT, existing host ID, protected management response and signed challenge.');
1810
- const producer = await loadProtectedHelperProducer(process.env.NC_ROOT);
1811
- const files = producer.protectedHelperPaths(process.env.NC_ROOT);
1812
- const selected = JSON.parse(fs.readFileSync(files.configFile, 'utf8'));
1813
- assertMatchingHostIdentity({ NC_CLIENT_ID: selected.client_id }, positionals[0]);
1814
- const result = await producer.configureProtectedHelperBinding({ root: process.env.NC_ROOT, bindingFile: path.resolve(positionals[1]),
1815
- challengeFile: path.resolve(positionals[2]), activateLocally: args.includes('--activate-initial-binding') });
1816
- console.log(JSON.stringify(result));
1817
- console.log('Local binding preparation is not execution readiness. The control plane must consume fresh proof, then complete signed sync and helper ACK.');
1818
- return;
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
+ }
1819
2380
  }
1820
2381
  if (args.includes('--activate-initial-binding')) throw new PairingError('INVALID_SETUP_ARGS', 'Initial binding activation requires explicit --configure-helper-binding maintenance.');
1821
2382
  if (args.includes('--maintain-identity')) {
@@ -1848,72 +2409,152 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
1848
2409
  console.error('suggested_action: Re-run as `nodecommand setup` or `nodecommand setup <host_id> <token>`.');
1849
2410
  process.exit(1);
1850
2411
  }
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
+ }
1851
2415
  const browserBrand = captureBrowserDefaultBrand();
1852
- let installMode = decideInstallMode();
2416
+ let installMode = forceUser ? 'user' : decideInstallMode();
1853
2417
  let ncRoot;
1854
2418
  let existingTarget;
1855
2419
  let existingEnv;
1856
2420
  let existingKey;
1857
2421
  let rootManagedPair;
1858
2422
  let protectedKeyContext;
1859
- {
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.`);
2447
+ }
2448
+ }
1860
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) {
1861
2476
  const target = setupTarget({ mode: installMode });
1862
2477
  ({ mode: installMode, ncRoot } = target);
1863
2478
  existingTarget = target.existing;
1864
2479
  rootManagedPair = target.rootManagedPair;
1865
2480
  protectedKeyContext = (process.getuid?.() === 0 || process.geteuid?.() === 0) ? target.protectedInstallation : null;
1866
- if (target.existing) {
1867
- const localEnv = loadValidHostEnv(getEnvFilePath(ncRoot));
1868
- if (!localEnv) throw new Error(`Existing host configuration at ${ncRoot} is incomplete; refusing a new enrollment or credential overwrite.`);
1869
- if (target.clientId && localEnv.NC_CLIENT_ID !== target.clientId) throw new Error('Existing ordinary host identity conflicts with the protected helper selection; explicit Root maintenance is required.');
1870
- assertMatchingHostIdentity(localEnv, positionals[0]);
1871
- // Missing/unreadable/invalid keys are not permission to regenerate an
1872
- // existing device identity. The normal verified resume flow is kept.
1873
- const keyPath = getDeviceKeyPath(ncRoot);
1874
- if (!protectedKeyContext) {
1875
- const keyInfo = fs.lstatSync(keyPath);
1876
- if (!keyInfo.isFile() || keyInfo.isSymbolicLink() || keyInfo.size > 65536 ||
1877
- (os.platform() !== 'win32' && installMode === 'user' && keyInfo.uid !== process.getuid())) {
1878
- throw new Error(`Existing device key at ${ncRoot} has an unverifiable type/owner; refusing identity replacement.`);
1879
- }
1880
- }
1881
- try {
1882
- existingKey = protectedKeyContext ? loadRootManagedPairKey(ncRoot, protectedKeyContext.ordinaryUid) : loadDeviceKeyPair(keyPath);
1883
- } catch {
1884
- throw new Error(`Existing device key at ${ncRoot} cannot be verified; preserve it for selected-installation maintenance.`);
1885
- }
1886
- existingEnv = localEnv;
1887
- const appInfo = fs.lstatSync(path.join(ncRoot, 'app'));
1888
- if (!appInfo.isDirectory() || appInfo.isSymbolicLink()) throw new Error(`Existing application at ${ncRoot} is incomplete; use selected-installation maintenance.`);
1889
- }
1890
- } catch (err) {
1891
- if (err instanceof PairingError && err.code === 'HOST_ID_MISMATCH') {
1892
- reportSetupError(err);
1893
- } else {
1894
- console.error(`error: [INSTALL_FAILED] ${err instanceof Error ? err.message : 'Cannot verify installation state.'}`);
1895
- console.error('suggested_action: Preserve the existing mode/root; resolve conflicting state through its existing maintenance flow, with explicit administrator rights for system installs.');
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' };
1896
2491
  }
1897
- process.exit(1);
1898
- return;
1899
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;
1900
2502
  }
1901
2503
  if (existingTarget) {
1902
- // An installed tree is verification-only here. In particular, a legacy
1903
- // tree without pairing-state metadata must not rerun OS installers that
1904
- // can replace shared services/helpers. Repair is selected maintenance.
1905
- console.log(`Verifying existing ${rootManagedPair ? 'Root-managed paired' : installMode} installation at ${ncRoot}, host: ${existingEnv.NC_CLIENT_ID}.`);
1906
- if (rootManagedPair) console.log(`Local management: Root; ordinary service scope: ${rootManagedPair.serviceScope}; selected UID: ${rootManagedPair.ordinaryUid} (credential verification only; no user impersonation).`);
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;
1907
2512
  try {
1908
- await verifyHostRegistration({ apiBase: existingEnv.NC_SERVER_URL, hostId: existingEnv.NC_CLIENT_ID, hostToken: existingEnv.NC_CLIENT_TOKEN, privateKey: existingKey.privateKey });
1909
- printSetupSuccess({ hostId: existingEnv.NC_CLIENT_ID, serverUrl: existingEnv.NC_SERVER_URL, installMode: rootManagedPair ? undefined : installMode, ncRoot, resumed: true });
1910
- if (args.includes('--allow-root-commands')) console.warn('Existing root consent unchanged. Enabling root requires explicit selected-installation privileged maintenance and independent management approval.');
2513
+ lock = acquireInstallLock(ncRoot);
1911
2514
  } catch (err) {
1912
- const code = err instanceof PairingError ? err.code : 'INSTALL_FAILED';
1913
- const detail = code === 'VERIFY_FAILED' ? 'The existing host is not registered on the server; stored identity is unchanged.' : err.message;
1914
- console.error(`error: [${code}] ${detail}`);
1915
- console.error(`suggested_action: Preserve host/device credentials at ${ncRoot}; use this installation's maintenance flow to repair registration or connectivity. No new enrollment was requested.`);
2515
+ reportSetupError(err);
1916
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();
1917
2558
  }
1918
2559
  return;
1919
2560
  }
@@ -1921,81 +2562,142 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
1921
2562
  // elevation. Linux requests sudo; Windows requests UAC (Start-Process
1922
2563
  // -Verb RunAs) so the install lands machine-wide at the canonical system
1923
2564
  // root. A refused or unavailable Windows elevation is a hard stop: the CLI
1924
- // never silently degrades a system install into %LOCALAPPDATA%.
1925
- if (os.platform() !== 'darwin' && installMode === 'user' && canElevate() && !process.env.NC_ELEVATED) {
1926
- if (os.platform() === 'win32') {
1927
- if (process.stdin.isTTY) {
1928
- console.log('Admin rights required for a system-wide install. Requesting UAC elevation…');
1929
- const elevated = requestWindowsElevation(browserBrand);
1930
- if (elevated && elevated.status === 0) process.exit(0);
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;
2583
+ try {
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);
1931
2622
  }
1932
- console.error(WINDOWS_ELEVATION_INSTALL_DECLINED);
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.');
1933
2629
  process.exit(1);
1934
2630
  }
1935
- console.log('Admin rights required for a system-wide install. Requesting sudo…');
1936
- const elevated = child_process.spawnSync(
1937
- 'sudo',
1938
- ['-E', process.execPath, ...process.argv.slice(1)],
1939
- { stdio: 'inherit', env: { ...process.env, NC_ELEVATED: '1' } }
1940
- );
1941
- if (elevated && elevated.status === 0) process.exit(0);
1942
2631
  try {
1943
2632
  // A failed elevated child may already have created an installation.
1944
- // Recheck before falling back rather than creating a competing agent.
2633
+ // Recheck before reporting the failure rather than creating a competing
2634
+ // agent; a partially completed system install must be preserved.
1945
2635
  const target = setupTarget({ mode: installMode });
1946
2636
  if (target.existing) throw new Error(`An installation now exists at ${target.ncRoot}; preserve it through its maintenance flow.`);
1947
- ncRoot = target.ncRoot;
1948
2637
  } catch (err) {
1949
2638
  console.error(`error: [INSTALL_FAILED] ${err.message}`);
1950
2639
  console.error('suggested_action: Preserve existing roots and select their maintenance flow; declining elevation does not authorize a second installation.');
1951
2640
  process.exit(1);
1952
2641
  return;
1953
2642
  }
1954
- console.warn('Elevation unavailable or declined; continuing with a per-user install.');
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);
2649
+ return;
1955
2650
  }
1956
2651
  // Mode-aware root: on win32 a system install lives in %ProgramData%\NC,
1957
2652
  // not the %LOCALAPPDATA% tree getNcRoot() returns, so credentials must be
1958
2653
  // written where the system installer will actually read them.
1959
2654
  ncRoot = ncRoot || getNcRootForMode(installMode);
1960
- console.log(installMode === 'system' ? `Installing as system (machine-wide), root: ${ncRoot}.` : `Installing as user (per-user), root: ${ncRoot}.`);
1961
- const warning = modeWarningFor(installMode);
1962
- if (warning) console.warn(warning);
1963
-
1964
- // Canonical token-free form: prompt AFTER elevation so the token is typed
1965
- // on the elevated terminal and never enters argv (a sudo/UAC re-exec
1966
- // forwards only the host id). Masked input: the token is not echoed.
1967
- if (onePositionalOk) {
1968
- try {
1969
- assertMatchingHostIdentity(loadValidHostEnv(getEnvFilePath(ncRoot)), positionals[0]);
1970
- const enrollmentToken = await promptToken({ input: process.stdin, output: process.stderr });
1971
- positionals.push(String(enrollmentToken || ''));
1972
- } catch (err) {
1973
- reportSetupError(err);
1974
- process.exit(1);
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
+ }
1975
2675
  }
1976
- }
1977
2676
 
1978
- const rootConsent = () => promptRootCommandConsent({ explicitOptIn: args.includes('--allow-root-commands') });
1979
- if (positionals.length === 2) {
1980
- const [hostId, enrollmentToken] = positionals;
1981
- try {
1982
- const result = await pairDeviceAndInstall({
1983
- baseUrl,
1984
- apiBase,
1985
- enrollment: { host_id: hostId, enrollment_token: enrollmentToken },
1986
- browserBrand,
1987
- installMode,
1988
- rootConsent,
1989
- ncRoot
1990
- });
1991
- printSetupSuccess({ ...result, installMode, ncRoot });
1992
- } catch (err) {
1993
- reportSetupError(err);
1994
- process.exit(1);
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;
1995
2696
  }
1996
- return;
2697
+ await runDeviceFlow({ installMode, ncRoot, browserBrand, rootConsent });
2698
+ } finally {
2699
+ lock.release();
1997
2700
  }
1998
- await runDeviceFlow({ installMode, ncRoot, browserBrand, rootConsent });
1999
2701
  }
2000
2702
 
2001
2703
  export const ROOT_COMMAND_CONSENT_PROMPT = 'Allow root/administrator commands through Node Command? [y/N]';
@@ -2036,6 +2738,9 @@ export function promptEnrollmentToken({ input = process.stdin, output = process.
2036
2738
  );
2037
2739
  return;
2038
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');
2039
2744
  output.write(ENROLLMENT_TOKEN_PROMPT);
2040
2745
  const sink = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
2041
2746
  sink.isTTY = true;
@@ -2060,16 +2765,19 @@ export function promptEnrollmentToken({ input = process.stdin, output = process.
2060
2765
  close();
2061
2766
  reject(new PairingError('ENROLL_TOKEN_REQUIRED', message));
2062
2767
  };
2063
- rl.on('SIGINT', () => abort('Enrollment token entry was cancelled.'));
2768
+ rl.on('SIGINT', () => abort('Enrollment token entry was cancelled; nothing was submitted.'));
2064
2769
  rl.on('close', () => {
2065
- if (!settled) abort('No enrollment token was entered (terminal closed). Re-run in a terminal and paste the token from the dashboard.');
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.');
2066
2771
  });
2067
2772
  rl.question('', (answer) => {
2068
2773
  const token = String(answer ?? '').trim();
2069
2774
  if (!token) {
2070
- 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.');
2071
2776
  return;
2072
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`);
2073
2781
  finish(token);
2074
2782
  });
2075
2783
  });
@@ -2125,7 +2833,7 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2125
2833
  assertKnownFlags(args, ['--yes', '-y', '--help', '-h'], command);
2126
2834
  const stray = args.filter((a) => !a.startsWith('-') && a !== 'help');
2127
2835
  if (stray.length > 0) {
2128
- 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}).`);
2129
2837
  console.error(`suggested_action: Run \`nodecommand ${command} --yes\` (or plain \`nodecommand ${command}\` to be asked).`);
2130
2838
  process.exit(1);
2131
2839
  }
@@ -2167,7 +2875,7 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2167
2875
  }
2168
2876
  if (unreadable.length > 0) {
2169
2877
  console.log(
2170
- `Elevated removal needed for system install(s): ${unreadable.join(', ')} (not readable as the current user).`
2878
+ `Explicit root maintenance required for system install(s): ${unreadable.join(', ')} (not readable as the current user).`
2171
2879
  );
2172
2880
  }
2173
2881
  if (!yes) {
@@ -2192,6 +2900,14 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2192
2900
  failed += 1;
2193
2901
  continue;
2194
2902
  }
2903
+ let lock;
2904
+ try {
2905
+ lock = acquireInstallLock(root);
2906
+ } catch (err) {
2907
+ reportSetupError(err);
2908
+ failed += 1;
2909
+ continue;
2910
+ }
2195
2911
  try {
2196
2912
  runUninstallerScript(script);
2197
2913
  console.log(`Removed ${root}${pair && pair.helperRoot !== root ? ` and paired protected helper ${pair.helperRoot}` : ''} (exit 0).`);
@@ -2204,20 +2920,17 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2204
2920
  return;
2205
2921
  }
2206
2922
  failed += 1;
2923
+ } finally {
2924
+ lock.release();
2207
2925
  }
2208
2926
  }
2209
- // Unreadable system roots remain visible, but never trigger implicit sudo.
2927
+ // Unreadable system roots remain visible, but are never executed: an
2928
+ // ordinary caller cannot verify the tree, so removal is explicit root
2929
+ // maintenance. This deliberately adds no implicit sudo.
2210
2930
  for (const root of unreadable) {
2211
- const script = uninstallerForRoot(root);
2212
- try {
2213
- runUninstallerScript(script);
2214
- console.log(`Removed ${root} (exit 0).`);
2215
- } catch (err) {
2216
- const rc = typeof err?.status === 'number' ? err.status : 1;
2217
- console.error(`error: [UNINSTALL_FAILED] uninstaller for ${root} exited with status ${rc}: ${err.message}`);
2218
- console.error(uninstallFailureSuggestion());
2219
- failed += 1;
2220
- }
2931
+ console.error(`error: [TRUSTED_ROOT_MAINTENANCE_REQUIRED] The system installation at ${root} is not readable by the current user; it was not modified.`);
2932
+ console.error('suggested_action: Run `sudo npx --yes node-command@latest uninstall --yes` to remove this trusted root installation explicitly.');
2933
+ failed += 1;
2221
2934
  }
2222
2935
  if (failed > 0) process.exit(1);
2223
2936
  console.log(completionMessage);
@@ -2265,6 +2978,7 @@ export async function runUninstall(args = []) {
2265
2978
  * @param {{ installMode?: 'system'|'user', ncRoot?: string, browserBrand?: string, rootConsent?: boolean|(() => Promise<boolean>) }} [options]
2266
2979
  */
2267
2980
  async function runDeviceFlow({ installMode = undefined, ncRoot = undefined, browserBrand = captureBrowserDefaultBrand(), rootConsent = false } = {}) {
2981
+ assertCredentialOrigin(baseUrl, { label: 'NC_BASE_URL' });
2268
2982
  const deviceEndpoint = `${baseUrl}/oauth/device/code`;
2269
2983
  let initRes;
2270
2984
 
@@ -2281,17 +2995,20 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined, brow
2281
2995
  try {
2282
2996
  initRes = await postJson(deviceEndpoint, {
2283
2997
  client_id: clientId,
2998
+ // B4: scope is an explicit client request, never a server default.
2999
+ // nc-cli enrollment/install needs the write/admin surface.
3000
+ scope: 'mcp:all',
2284
3001
  code_challenge: deviceCodeChallenge,
2285
3002
  code_challenge_method: 'S256'
2286
3003
  });
2287
3004
  } catch (err) {
2288
- console.error(`error: [DEVICE_FLOW_CONNECT] Could not connect to ${deviceEndpoint}: ${err.message}`);
3005
+ console.error(`error: [DEVICE_FLOW_CONNECT] Could not connect to ${sanitizeForDisplay(deviceEndpoint, 256)}: ${sanitizeForDisplay(err.message, 300)}`);
2289
3006
  console.error('suggested_action: Check network access to the control plane (or NC_BASE_URL), then re-run.');
2290
3007
  process.exit(1);
2291
3008
  }
2292
3009
 
2293
3010
  if (initRes.status !== 200 || !initRes.data || !initRes.data.device_code) {
2294
- const err = initRes.data?.error_description || initRes.data?.error || `HTTP ${initRes.status}`;
3011
+ const err = sanitizeForDisplay(initRes.data?.error_description || initRes.data?.error || `HTTP ${initRes.status}`, 300);
2295
3012
  console.error(`error: [DEVICE_FLOW_START] Failed to start device flow: ${err}`);
2296
3013
  console.error('suggested_action: Re-run the command; if it persists, verify the control plane is reachable and NC_BASE_URL is correct.');
2297
3014
  process.exit(1);
@@ -2319,7 +3036,8 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined, brow
2319
3036
  // has a manual 8-character code input; the deep-link form is useless here
2320
3037
  // because the login redirect strips the query (return_to=/device only), so
2321
3038
  // the code would never auto-fill after sign-in anyway.
2322
- const verifyUrl = verification_uri || `${baseUrl}/device`;
3039
+ const verifyUrl = sanitizeForDisplay(verification_uri || `${baseUrl}/device`, 512);
3040
+ const displayUserCode = sanitizeForDisplay(user_code, 64);
2323
3041
 
2324
3042
  printBox([
2325
3043
  { text: `${bold}Node Command — Device Authorization${reset}`, rawLength: 'Node Command — Device Authorization'.length },
@@ -2327,7 +3045,7 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined, brow
2327
3045
  { text: 'To complete pairing, open this URL in your browser:', rawLength: 'To complete pairing, open this URL in your browser:'.length },
2328
3046
  { text: `${cyan}${bold}${verifyUrl}${reset}`, rawLength: verifyUrl.length },
2329
3047
  { text: '', rawLength: 0 },
2330
- { text: `Then enter this code on the page: ${yellow}${bold}${user_code}${reset}`, rawLength: `Then enter this code on the page: ${user_code}`.length },
3048
+ { text: `Then enter this code on the page: ${yellow}${bold}${displayUserCode}${reset}`, rawLength: `Then enter this code on the page: ${displayUserCode}`.length },
2331
3049
  { text: '', rawLength: 0 },
2332
3050
  { text: `${dim}Waiting for approval in browser...${reset}`, rawLength: 'Waiting for approval in browser...'.length }
2333
3051
  ]);
@@ -2408,7 +3126,7 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined, brow
2408
3126
  currentIntervalMs += 5000;
2409
3127
  } else {
2410
3128
  // Any fatal error (expired_token, access_denied, invalid_grant, etc.)
2411
- const errMsg = tokenRes.data?.error_description || tokenRes.data?.error || `HTTP ${tokenRes.status}`;
3129
+ const errMsg = sanitizeForDisplay(tokenRes.data?.error_description || tokenRes.data?.error || `HTTP ${tokenRes.status}`, 300);
2412
3130
  console.error(`\nerror: [DEVICE_FLOW_AUTH_DENIED] Authorization failed: ${errMsg}`);
2413
3131
  console.error('suggested_action: Re-run the command and approve the prompt in the browser.');
2414
3132
  process.exit(1);
@@ -2419,7 +3137,7 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined, brow
2419
3137
  consecutiveNetErrors += 1;
2420
3138
  if (consecutiveNetErrors >= MAX_NET_ERROR_RETRIES) {
2421
3139
  const detail = pollErr instanceof Error ? pollErr.message : String(pollErr);
2422
- console.error(`\nerror: [DEVICE_FLOW_CONNECT] Could not reach ${tokenEndpoint} after ${consecutiveNetErrors} attempts: ${detail}`);
3140
+ console.error(`\nerror: [DEVICE_FLOW_CONNECT] Could not reach ${sanitizeForDisplay(tokenEndpoint, 256)} after ${consecutiveNetErrors} attempts: ${sanitizeForDisplay(detail, 300)}`);
2423
3141
  console.error('suggested_action: Check network access to the control plane (or NC_BASE_URL), then re-run.');
2424
3142
  process.exit(1);
2425
3143
  }
@@ -2442,5 +3160,8 @@ const isMainModule = () => {
2442
3160
  };
2443
3161
 
2444
3162
  if (isMainModule()) {
2445
- main();
3163
+ main().catch((err) => {
3164
+ reportSetupError(err);
3165
+ process.exitCode = 1;
3166
+ });
2446
3167
  }