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 +955 -234
- package/lib/diagnostics-bundle.mjs +38 -10
- package/lib/setup.mjs +175 -8
- package/package.json +4 -18
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
|
-
|
|
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
|
|
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
|
|
312
|
-
|
|
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 (
|
|
316
|
-
|
|
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
|
-
|
|
328
|
-
if (
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
618
|
+
let tarCmd = 'tar';
|
|
390
619
|
try {
|
|
391
|
-
|
|
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
|
|
452
|
-
['-ExecutionPolicy', 'Bypass', '-File', scriptPath],
|
|
679
|
+
windowsExecutablePath('powershell'),
|
|
680
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
|
|
453
681
|
{
|
|
454
|
-
env:
|
|
682
|
+
env: { ...process.env, NC_ROOT: ncRoot, ...extraEnv },
|
|
455
683
|
stdio: 'inherit'
|
|
456
684
|
}
|
|
457
685
|
);
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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 =
|
|
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`);
|
|
1244
|
-
*
|
|
1245
|
-
*
|
|
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(
|
|
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:
|
|
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
|
-
'
|
|
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
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
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
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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
|
|
1903
|
-
//
|
|
1904
|
-
//
|
|
1905
|
-
|
|
1906
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
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
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
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}${
|
|
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
|
}
|