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