node-command 2.0.1 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nc.mjs +541 -136
- package/lib/setup.mjs +730 -42
- package/package.json +11 -16
package/bin/nc.mjs
CHANGED
|
@@ -8,14 +8,23 @@ 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
12
|
import { fileURLToPath } from 'node:url';
|
|
12
13
|
import {
|
|
13
14
|
canElevate,
|
|
15
|
+
captureBrowserDefaultBrand,
|
|
16
|
+
normalizeBrowserBrand,
|
|
17
|
+
browserBrandReexecArgs,
|
|
14
18
|
decideInstallMode,
|
|
15
19
|
findRemovalTargets,
|
|
16
20
|
getNcRootForMode,
|
|
17
21
|
installerEnvForMode,
|
|
18
22
|
isElevated,
|
|
23
|
+
isUserInstallRoot,
|
|
24
|
+
setupTarget,
|
|
25
|
+
maintainLinuxIdentity,
|
|
26
|
+
loadProtectedHelperProducer,
|
|
27
|
+
LINUX_PAIRED_UNINSTALL_ENTRY,
|
|
19
28
|
modeWarningFor,
|
|
20
29
|
runUninstallerScript,
|
|
21
30
|
uninstallFailureSuggestion,
|
|
@@ -28,8 +37,9 @@ const baseUrl = process.env.NC_BASE_URL || 'https://auth.nodecommand.app';
|
|
|
28
37
|
* downloads, and `/releases/*`. The auth host (`baseUrl` above) serves only
|
|
29
38
|
* browser-facing OAuth/device flow and enrollment (`/oauth/*`, `/api/enroll*`);
|
|
30
39
|
* Caddy routes `/api/clients*` solely on the agent (and dash) edge, so
|
|
31
|
-
* verification against the auth host answers 404.
|
|
32
|
-
*
|
|
40
|
+
* verification against the auth host answers 404. Fresh enrollment uses this
|
|
41
|
+
* configured API destination; installed credentials instead use their stored
|
|
42
|
+
* NC_SERVER_URL for authenticated HTTP, just like the agent runtime.
|
|
33
43
|
*/
|
|
34
44
|
export const apiBase = process.env.NC_API_URL || 'https://agent.nodecommand.app';
|
|
35
45
|
const clientId = 'nc-cli';
|
|
@@ -531,9 +541,9 @@ export function getEnrollStatePath(ncRoot = getNcRoot()) {
|
|
|
531
541
|
* cannot end up in the file. Host id/token shapes are enforced so a caller
|
|
532
542
|
* mistake (e.g. passing an enrollment token as the client token) fails here
|
|
533
543
|
* instead of producing a broken agent that crash-loops on 401.
|
|
534
|
-
* @param {{ serverUrl: string, hostId: string, hostToken: string, clientName?: string }} options
|
|
544
|
+
* @param {{ serverUrl: string, hostId: string, hostToken: string, clientName?: string, browserBrand?: string }} options
|
|
535
545
|
*/
|
|
536
|
-
export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName = os.hostname() }) {
|
|
546
|
+
export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName = os.hostname(), browserBrand = 'unknown' }) {
|
|
537
547
|
if (typeof serverUrl !== 'string' || !serverUrl) {
|
|
538
548
|
throw new PairingError('INVALID_SERVER_URL', 'Refusing to write agent config without a server URL.');
|
|
539
549
|
}
|
|
@@ -555,6 +565,7 @@ export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName =
|
|
|
555
565
|
`NC_CLIENT_NAME=${clientName}`,
|
|
556
566
|
`NC_CLIENT_TOKEN=${hostToken}`,
|
|
557
567
|
'NC_REMOTE_EXEC_ENABLED=true',
|
|
568
|
+
`NC_BROWSER_DEFAULT_BRAND=${normalizeBrowserBrand(browserBrand)}`,
|
|
558
569
|
'NC_SESSION_ENABLED=true'
|
|
559
570
|
].join('\n') + '\n';
|
|
560
571
|
}
|
|
@@ -610,6 +621,15 @@ export function loadValidHostEnv(envPath = getEnvFilePath()) {
|
|
|
610
621
|
return env;
|
|
611
622
|
}
|
|
612
623
|
|
|
624
|
+
function assertMatchingHostIdentity(localEnv, requestedHostId) {
|
|
625
|
+
if (localEnv && requestedHostId && localEnv.NC_CLIENT_ID !== requestedHostId) {
|
|
626
|
+
throw new PairingError(
|
|
627
|
+
'HOST_ID_MISMATCH',
|
|
628
|
+
`This installation belongs to host ${localEnv.NC_CLIENT_ID}, but host ${requestedHostId} was requested. Stored credentials are unchanged; the supplied enrollment token has not been redeemed.`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
613
633
|
/**
|
|
614
634
|
* Resume marker for idempotent re-runs. Holds the host id and lifecycle
|
|
615
635
|
* phase only — never any token or key material (those live in nc-client.env
|
|
@@ -715,6 +735,52 @@ export function loadDeviceKeyPair(keyFile) {
|
|
|
715
735
|
return { privateKey: key, publicKey, thumbprint: deviceKeyThumbprintCli(publicKey) };
|
|
716
736
|
}
|
|
717
737
|
|
|
738
|
+
/** Read only the selected ordinary key for Root-managed paired verification. */
|
|
739
|
+
function loadRootManagedPairKey(ncRoot, ordinaryUid) {
|
|
740
|
+
const invalid = () => { throw new Error('Root-managed pair device key cannot be verified safely.'); };
|
|
741
|
+
if (os.platform() !== 'linux' || process.getuid?.() !== 0 || process.geteuid?.() !== 0 ||
|
|
742
|
+
!['/opt/nc', '/var/lib/nodecommand/nc'].includes(ncRoot) || !Number.isSafeInteger(ordinaryUid) || ordinaryUid <= 0) invalid();
|
|
743
|
+
let directoryFd, keyFd;
|
|
744
|
+
try {
|
|
745
|
+
// Pin each directory before opening its child. NOFOLLOW on the key alone
|
|
746
|
+
// would still follow a replaced ordinary root/config ancestor as Root.
|
|
747
|
+
const components = ncRoot === '/opt/nc' ? ['', 'opt', 'nc', 'config'] : ['', 'var', 'lib', 'nodecommand', 'nc', 'config'];
|
|
748
|
+
for (const component of components) {
|
|
749
|
+
const next = fs.openSync(component ? `/proc/self/fd/${directoryFd}/${component}` : '/',
|
|
750
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
751
|
+
if (directoryFd !== undefined) fs.closeSync(directoryFd);
|
|
752
|
+
directoryFd = next;
|
|
753
|
+
const info = fs.fstatSync(directoryFd);
|
|
754
|
+
const ordinaryRoot = ncRoot === '/var/lib/nodecommand/nc' && component === 'nc';
|
|
755
|
+
const protectedAncestor = !ordinaryRoot && component !== 'config';
|
|
756
|
+
if (!info.isDirectory() || (ordinaryRoot ? ![0, ordinaryUid].includes(info.uid) : info.uid !== (protectedAncestor ? 0 : ordinaryUid)) ||
|
|
757
|
+
(protectedAncestor && (info.gid !== 0 || (info.mode & 0o022)))) invalid();
|
|
758
|
+
}
|
|
759
|
+
keyFd = fs.openSync(`/proc/self/fd/${directoryFd}/nc-device-key.pem`,
|
|
760
|
+
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
761
|
+
const before = fs.fstatSync(keyFd);
|
|
762
|
+
if (!before.isFile() || before.uid !== ordinaryUid || before.nlink !== 1 ||
|
|
763
|
+
!Number.isSafeInteger(before.size) || before.size <= 0 || before.size > 65536) invalid();
|
|
764
|
+
const buffer = Buffer.alloc(65537);
|
|
765
|
+
let length = 0;
|
|
766
|
+
while (length < buffer.length) {
|
|
767
|
+
const count = fs.readSync(keyFd, buffer, length, buffer.length - length, null);
|
|
768
|
+
if (count === 0) break;
|
|
769
|
+
length += count;
|
|
770
|
+
}
|
|
771
|
+
const after = fs.fstatSync(keyFd);
|
|
772
|
+
if (length !== before.size || length > 65536 || !after.isFile() ||
|
|
773
|
+
['dev', 'ino', 'uid', 'gid', 'mode', 'nlink', 'size', 'mtimeMs', 'ctimeMs'].some(field => before[field] !== after[field])) invalid();
|
|
774
|
+
const privateKey = crypto.createPrivateKey(buffer.subarray(0, length));
|
|
775
|
+
if (privateKey.asymmetricKeyType !== 'ed25519') invalid();
|
|
776
|
+
const publicKey = publicRawFromPrivateCli(privateKey);
|
|
777
|
+
return { privateKey, publicKey, thumbprint: deviceKeyThumbprintCli(publicKey) };
|
|
778
|
+
} finally {
|
|
779
|
+
if (keyFd !== undefined) fs.closeSync(keyFd);
|
|
780
|
+
if (directoryFd !== undefined) fs.closeSync(directoryFd);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
718
784
|
/**
|
|
719
785
|
* Loads the device key, generating and persisting a fresh Ed25519 key when
|
|
720
786
|
* none exists (atomic tmp-write + rename, 0600). Mirrors
|
|
@@ -851,14 +917,15 @@ export async function requestEnrollment({ baseUrl, oauthToken, platform = 'linux
|
|
|
851
917
|
/**
|
|
852
918
|
* Redeems a one-time enrollment token with Device Proof v1, binding the
|
|
853
919
|
* local Ed25519 key to the host identity. Returns the redemption payload
|
|
854
|
-
* (client_id, client_token, release).
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
* @param {{ baseUrl: string, hostId: string, enrollmentToken: string, keyPair: { privateKey: import('node:crypto').KeyObject, publicKey: string, thumbprint: string } }} options
|
|
920
|
+
* (client_id, client_token, release, optional execution_initialization). The
|
|
921
|
+
* selected intent is covered by the unchanged proof's exact pathname/query.
|
|
922
|
+
* Token authentication failures do not disclose whether redemption committed.
|
|
923
|
+
* @param {{ baseUrl: string, hostId: string, enrollmentToken: string, initialPermission?: 'user'|'admin', keyPair: { privateKey: import('node:crypto').KeyObject, publicKey: string, thumbprint: string } }} options
|
|
858
924
|
*/
|
|
859
|
-
export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPair }) {
|
|
925
|
+
export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPair, initialPermission = 'user' }) {
|
|
926
|
+
if (!['user', 'admin'].includes(initialPermission)) throw new PairingError('INVALID_INITIAL_PERMISSION', 'Initial execution intent must be user or admin.');
|
|
860
927
|
const origin = String(baseUrl || '').replace(/\/$/, '');
|
|
861
|
-
const redeemPath =
|
|
928
|
+
const redeemPath = `/api/enroll/redeem?initial_permission=${initialPermission}`;
|
|
862
929
|
const proof = createDeviceProofCli({
|
|
863
930
|
privateKey: keyPair.privateKey,
|
|
864
931
|
hostId,
|
|
@@ -874,7 +941,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
|
|
|
874
941
|
{ 'NC-Device-Proof': proof }
|
|
875
942
|
);
|
|
876
943
|
} catch (err) {
|
|
877
|
-
throw new PairingError('REDEEM_NETWORK_ERROR', `
|
|
944
|
+
throw new PairingError('REDEEM_NETWORK_ERROR', `Redemption response was not received: ${err.message}. The server may have committed; preserve local identity and use authorized recovery rather than reminting or replaying initialization.`);
|
|
878
945
|
}
|
|
879
946
|
if (res.status === 200 && res.data) {
|
|
880
947
|
const r = res.data;
|
|
@@ -890,7 +957,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
|
|
|
890
957
|
if (res.status === 410 && serverError === 'ENROLLMENT_ALREADY_USED') {
|
|
891
958
|
throw new PairingError(
|
|
892
959
|
'ENROLLMENT_ALREADY_USED',
|
|
893
|
-
'
|
|
960
|
+
'The legacy server refused redemption. Preserve existing host/device identity and recover only through authorized existing credentials or management; no credentials were recovered by this attempt.'
|
|
894
961
|
);
|
|
895
962
|
}
|
|
896
963
|
if (res.status === 410) {
|
|
@@ -900,7 +967,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
|
|
|
900
967
|
);
|
|
901
968
|
}
|
|
902
969
|
if (res.status === 401) {
|
|
903
|
-
throw new PairingError('REDEEM_UNAUTHORIZED', `Redemption was refused (${serverError || 'unauthorized'}).
|
|
970
|
+
throw new PairingError('REDEEM_UNAUTHORIZED', `Redemption was refused (${serverError || 'unauthorized'}). No credentials or initialization were recovered. Preserve any existing identity and use authorized credential/management recovery; do not blindly remint or replay.`);
|
|
904
971
|
}
|
|
905
972
|
throw new PairingError('REDEEM_REJECTED', `Redemption failed (${serverError || `HTTP ${res.status}`}). Re-run to request a fresh enrollment.`);
|
|
906
973
|
}
|
|
@@ -911,8 +978,9 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
|
|
|
911
978
|
* uses). Polls until the identity is accepted or the timeout expires.
|
|
912
979
|
* Success here proves the env credentials actually authenticate; only then
|
|
913
980
|
* may the CLI print a success message.
|
|
914
|
-
* Device-channel endpoints
|
|
915
|
-
*
|
|
981
|
+
* Device-channel endpoints use the supplied agent destination. Resume callers
|
|
982
|
+
* must supply the stored NC_SERVER_URL, not the fresh-enrollment API default.
|
|
983
|
+
* `baseUrl` stays accepted for backward
|
|
916
984
|
* compatibility but an explicit `apiBase` (or `NC_API_URL`/default agent
|
|
917
985
|
* origin when neither is given) wins.
|
|
918
986
|
* @param {{ baseUrl?: string, apiBase?: string, hostId: string, hostToken: string, privateKey: import('node:crypto').KeyObject, timeoutMs?: number, intervalMs?: number }} options
|
|
@@ -976,11 +1044,11 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
|
|
|
976
1044
|
* (`/releases/client/<version>/bootstrap.tar.gz`) stays anonymous. The
|
|
977
1045
|
* proof binds method GET + the full request URL + the redemption-issued
|
|
978
1046
|
* client token, exactly like redeemEnrollment.
|
|
979
|
-
* @param {{ ncRoot?: string, updatesUrl?: string, serverUrl?: string, release?: Record<string, unknown>|null, installMode?: 'system'|'user', runInstaller?: (scriptPath: string, ncRoot: string) => void, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject }} options
|
|
1047
|
+
* @param {{ ncRoot?: string, updatesUrl?: string, serverUrl?: string, release?: Record<string, unknown>|null, installMode?: 'system'|'user', rootConsent?: boolean, runInstaller?: (scriptPath: string, ncRoot: string) => void, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject }} options
|
|
980
1048
|
*/
|
|
981
|
-
export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl = undefined, serverUrl = undefined, release = undefined, installMode = undefined, runInstaller = undefined, hostId = undefined, hostToken = undefined, privateKey = undefined } = {}) {
|
|
1049
|
+
export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl = undefined, serverUrl = undefined, release = undefined, installMode = undefined, rootConsent = false, runInstaller = undefined, hostId = undefined, hostToken = undefined, privateKey = undefined } = {}) {
|
|
982
1050
|
ncRoot = ncRoot || (installMode ? getNcRootForMode(installMode) : getNcRoot());
|
|
983
|
-
const modeEnv = installMode ? installerEnvForMode(installMode) : {};
|
|
1051
|
+
const modeEnv = { ...(installMode ? installerEnvForMode(installMode) : {}), NC_ROOT_COMMAND_CONSENT: rootConsent === true ? 'yes' : 'no' };
|
|
984
1052
|
const invokeInstaller =
|
|
985
1053
|
runInstaller || ((scriptPath, root) => runInstallerScript(scriptPath, root, modeEnv));
|
|
986
1054
|
const root = ncRoot;
|
|
@@ -1075,6 +1143,21 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
|
|
|
1075
1143
|
tarballUrl = `${defaultBaseUrl}/releases/client/${encodeURIComponent(version)}/bootstrap.tar.gz`;
|
|
1076
1144
|
}
|
|
1077
1145
|
|
|
1146
|
+
const expectedHash = activeRelease.bootstrap_sha256 ?? activeRelease.sha256;
|
|
1147
|
+
if (typeof expectedHash !== 'string' || expectedHash.length !== 64 || !/^[a-f0-9]{64}$/i.test(expectedHash)) {
|
|
1148
|
+
throw new PairingError('INSTALL_MANIFEST_ERROR', 'Agent release manifest must provide a valid sha256 before downloading or installing.');
|
|
1149
|
+
}
|
|
1150
|
+
const expectedSizeRaw = activeRelease.bootstrap_size_bytes ?? activeRelease.size_bytes;
|
|
1151
|
+
const expectedSize =
|
|
1152
|
+
typeof expectedSizeRaw === 'number'
|
|
1153
|
+
? expectedSizeRaw
|
|
1154
|
+
: typeof expectedSizeRaw === 'string' && expectedSizeRaw.length > 0 && !/[^0-9]/.test(expectedSizeRaw)
|
|
1155
|
+
? Number(expectedSizeRaw)
|
|
1156
|
+
: NaN;
|
|
1157
|
+
if (!Number.isSafeInteger(expectedSize) || expectedSize <= 0) {
|
|
1158
|
+
throw new PairingError('INSTALL_MANIFEST_ERROR', 'Agent release manifest must provide a valid positive integer size before downloading or installing.');
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1078
1161
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nc-install-'));
|
|
1079
1162
|
const archivePath = path.join(tempDir, 'bootstrap.tar.gz');
|
|
1080
1163
|
|
|
@@ -1102,35 +1185,25 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
|
|
|
1102
1185
|
throw new PairingError('INSTALL_DOWNLOAD_ERROR', `Could not download the agent package: ${err.message}`);
|
|
1103
1186
|
}
|
|
1104
1187
|
|
|
1105
|
-
const expectedHash = activeRelease.bootstrap_sha256 || activeRelease.sha256;
|
|
1106
|
-
const expectedSizeRaw = activeRelease.bootstrap_size_bytes ?? activeRelease.size_bytes;
|
|
1107
|
-
const expectedSize =
|
|
1108
|
-
expectedSizeRaw === undefined || expectedSizeRaw === null ? null : Number(expectedSizeRaw);
|
|
1109
1188
|
const archiveBytes = fs.readFileSync(archivePath);
|
|
1110
|
-
if (
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
);
|
|
1119
|
-
}
|
|
1189
|
+
if (archiveBytes.length !== expectedSize) {
|
|
1190
|
+
try {
|
|
1191
|
+
fs.unlinkSync(archivePath);
|
|
1192
|
+
} catch {}
|
|
1193
|
+
throw new PairingError(
|
|
1194
|
+
'INSTALL_MANIFEST_ERROR',
|
|
1195
|
+
`Agent package integrity check failed: size mismatch for ${tarballUrl} (expected ${expectedSize} bytes, got ${archiveBytes.length}). The download was discarded; nothing was installed.`
|
|
1196
|
+
);
|
|
1120
1197
|
}
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
);
|
|
1131
|
-
}
|
|
1132
|
-
} else {
|
|
1133
|
-
console.warn('⚠️ Release manifest lacks a valid sha256; skipping archive integrity check.');
|
|
1198
|
+
const actualHash = crypto.createHash('sha256').update(archiveBytes).digest('hex');
|
|
1199
|
+
if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) {
|
|
1200
|
+
try {
|
|
1201
|
+
fs.unlinkSync(archivePath);
|
|
1202
|
+
} catch {}
|
|
1203
|
+
throw new PairingError(
|
|
1204
|
+
'INSTALL_MANIFEST_ERROR',
|
|
1205
|
+
`Agent package integrity check failed: sha256 mismatch for ${tarballUrl}. The download was discarded; nothing was installed.`
|
|
1206
|
+
);
|
|
1134
1207
|
}
|
|
1135
1208
|
|
|
1136
1209
|
console.log('📂 Extracting agent archive...');
|
|
@@ -1168,9 +1241,9 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
|
|
|
1168
1241
|
* directly. `installMode` is forwarded to the installer via NC_MODE.
|
|
1169
1242
|
* Host split: enrollment (OAuth/redeem) uses the auth origin (`baseUrl` /
|
|
1170
1243
|
* `NC_BASE_URL`); device-channel verification (`/api/clients/*`) uses the
|
|
1171
|
-
* agent origin (`apiBase` / `NC_API_URL`)
|
|
1172
|
-
*
|
|
1173
|
-
* @param {{ baseUrl?: string, apiBase?: string, oauthToken?: string|null, enrollment?: { host_id: string, enrollment_token: string, server_url?: string }|null, ncRoot?: string, serverUrl?: string, deviceName?: string, platform?: string, installMode?: 'system'|'user', verifyTimeoutMs?: number, verifyIntervalMs?: number, hooks?: { installAgent?: (options: { ncRoot: string, serverUrl: string, release?: Record<string, unknown>|null, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject }) => Promise<void> } }} options
|
|
1244
|
+
* agent origin (`apiBase` / `NC_API_URL`) for fresh enrollment. Local resume
|
|
1245
|
+
* binds verification and installation requests to the stored NC_SERVER_URL.
|
|
1246
|
+
* @param {{ baseUrl?: string, apiBase?: string, oauthToken?: string|null, enrollment?: { host_id: string, enrollment_token: string, server_url?: string }|null, ncRoot?: string, serverUrl?: string, deviceName?: string, platform?: string, installMode?: 'system'|'user', rootConsent?: boolean|(() => Promise<boolean>), browserBrand?: string, verifyTimeoutMs?: number, verifyIntervalMs?: number, hooks?: { installAgent?: (options: { ncRoot: string, serverUrl: string, release?: Record<string, unknown>|null, hostId?: string, hostToken?: string, rootConsent?: boolean, privateKey?: import('node:crypto').KeyObject }) => Promise<void> } }} options
|
|
1174
1247
|
*/
|
|
1175
1248
|
export async function pairDeviceAndInstall({
|
|
1176
1249
|
baseUrl: baseUrlParam = undefined,
|
|
@@ -1182,6 +1255,8 @@ export async function pairDeviceAndInstall({
|
|
|
1182
1255
|
deviceName = os.hostname(),
|
|
1183
1256
|
platform = mapEnrollPlatform(),
|
|
1184
1257
|
installMode = undefined,
|
|
1258
|
+
rootConsent = false,
|
|
1259
|
+
browserBrand = captureBrowserDefaultBrand(),
|
|
1185
1260
|
verifyTimeoutMs = 45000,
|
|
1186
1261
|
verifyIntervalMs = 2000,
|
|
1187
1262
|
hooks = {}
|
|
@@ -1208,15 +1283,20 @@ export async function pairDeviceAndInstall({
|
|
|
1208
1283
|
const installAgent =
|
|
1209
1284
|
hooks.installAgent ||
|
|
1210
1285
|
(async ({ ncRoot: installRoot, serverUrl: installServerUrl, release: installRelease = undefined, hostId: installHostId = undefined, hostToken: installHostToken = undefined, privateKey: installPrivateKey = undefined }) => {
|
|
1211
|
-
await downloadAndInstallAgent({ ncRoot: installRoot, serverUrl: installServerUrl, installMode, release: installRelease, hostId: installHostId, hostToken: installHostToken, privateKey: installPrivateKey });
|
|
1286
|
+
await downloadAndInstallAgent({ ncRoot: installRoot, serverUrl: installServerUrl, installMode, rootConsent: selectedRootConsent, release: installRelease, hostId: installHostId, hostToken: installHostToken, privateKey: installPrivateKey });
|
|
1212
1287
|
});
|
|
1288
|
+
let selectedRootConsent = false;
|
|
1213
1289
|
|
|
1214
1290
|
// Fast path — idempotent resume: usable host credentials already on disk
|
|
1215
1291
|
// are verified over the device channel and the installer is resumed. No
|
|
1216
1292
|
// new enrollment is minted, so a re-run can never orphan a host or hit
|
|
1217
1293
|
// ENROLLMENT_ALREADY_USED for a token it no longer holds.
|
|
1218
1294
|
const localEnv = loadValidHostEnv(envPath);
|
|
1295
|
+
assertMatchingHostIdentity(localEnv, enrollment?.host_id);
|
|
1219
1296
|
if (localEnv) {
|
|
1297
|
+
// Match the agent's HTTP base normalization without dropping a supported
|
|
1298
|
+
// path prefix or redirecting installed credentials to NC_API_URL.
|
|
1299
|
+
const storedServerUrl = localEnv.NC_SERVER_URL.replace(/\/$/, '');
|
|
1220
1300
|
let localKey = null;
|
|
1221
1301
|
try {
|
|
1222
1302
|
localKey = loadDeviceKeyPair(keyPath);
|
|
@@ -1227,7 +1307,7 @@ export async function pairDeviceAndInstall({
|
|
|
1227
1307
|
const prior = readPairState(statePath);
|
|
1228
1308
|
try {
|
|
1229
1309
|
await verifyHostRegistration({
|
|
1230
|
-
apiBase:
|
|
1310
|
+
apiBase: localEnv.NC_SERVER_URL,
|
|
1231
1311
|
hostId: localEnv.NC_CLIENT_ID,
|
|
1232
1312
|
hostToken: localEnv.NC_CLIENT_TOKEN,
|
|
1233
1313
|
privateKey: localKey.privateKey,
|
|
@@ -1240,10 +1320,10 @@ export async function pairDeviceAndInstall({
|
|
|
1240
1320
|
return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
|
|
1241
1321
|
}
|
|
1242
1322
|
console.log(`🔁 Resuming installation for host ${localEnv.NC_CLIENT_ID} (stored credentials verified)...`);
|
|
1243
|
-
await installAgent({ ncRoot: root, serverUrl:
|
|
1323
|
+
await installAgent({ ncRoot: root, serverUrl: storedServerUrl, hostId: localEnv.NC_CLIENT_ID, hostToken: localEnv.NC_CLIENT_TOKEN, privateKey: localKey.privateKey });
|
|
1244
1324
|
writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'installed' });
|
|
1245
1325
|
await verifyHostRegistration({
|
|
1246
|
-
apiBase:
|
|
1326
|
+
apiBase: localEnv.NC_SERVER_URL,
|
|
1247
1327
|
hostId: localEnv.NC_CLIENT_ID,
|
|
1248
1328
|
hostToken: localEnv.NC_CLIENT_TOKEN,
|
|
1249
1329
|
privateKey: localKey.privateKey,
|
|
@@ -1253,23 +1333,25 @@ export async function pairDeviceAndInstall({
|
|
|
1253
1333
|
writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'verified' });
|
|
1254
1334
|
return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
|
|
1255
1335
|
} catch (err) {
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
// guidance: wiping stored credentials on a transient stall would
|
|
1259
|
-
// orphan a healthy host identity. The caller reports the stored-
|
|
1260
|
-
// credentials connectivity hint via suggestedActionFor.
|
|
1336
|
+
// Failure at the bound destination is not permission to enroll on
|
|
1337
|
+
// another server or replace this installed host identity.
|
|
1261
1338
|
if (err instanceof PairingError && err.code === 'VERIFY_FAILED') {
|
|
1262
|
-
|
|
1263
|
-
// server reset): fall through to a fresh enrollment, which
|
|
1264
|
-
// overwrites the stale files below.
|
|
1265
|
-
console.warn(`⚠️ Stored host credentials no longer verify (${err.code}); requesting a fresh enrollment...`);
|
|
1266
|
-
} else {
|
|
1267
|
-
throw err;
|
|
1339
|
+
throw new PairingError('VERIFY_FAILED', 'The existing host is not registered at its stored server. Preserve host/device credentials and use the existing installation maintenance flow.');
|
|
1268
1340
|
}
|
|
1341
|
+
throw err;
|
|
1269
1342
|
}
|
|
1270
1343
|
}
|
|
1271
1344
|
}
|
|
1272
1345
|
|
|
1346
|
+
const incomplete = readPairState(statePath);
|
|
1347
|
+
if (incomplete?.phase === 'enrolled' && HOST_ID_RE.test(String(incomplete.host_id || ''))) {
|
|
1348
|
+
throw new PairingError('REDEEM_RECOVERY_REQUIRED', 'A previous redemption may have committed without a saved response. Preserve local host/device identity; recover using authorized existing credentials or target management. No automatic remint or initialization replay was attempted.');
|
|
1349
|
+
}
|
|
1350
|
+
// Authentication/preflight precede this fresh-only question. Ask before
|
|
1351
|
+
// minting the five-minute token, or before redeeming an already running one.
|
|
1352
|
+
// Reuse this one literal answer for both signed intent and local installer.
|
|
1353
|
+
selectedRootConsent = (typeof rootConsent === 'function' ? await rootConsent() : rootConsent) === true;
|
|
1354
|
+
|
|
1273
1355
|
// Fresh enrollment with the OAuth USER token (user authorization only),
|
|
1274
1356
|
// or the caller-supplied enrollment from `setup <host_id> <token>`.
|
|
1275
1357
|
let activeEnrollment = enrollment;
|
|
@@ -1291,14 +1373,17 @@ export async function pairDeviceAndInstall({
|
|
|
1291
1373
|
let agentVersion = null;
|
|
1292
1374
|
/** @type {Record<string, unknown>|null} */
|
|
1293
1375
|
let redemptionRelease = null;
|
|
1294
|
-
|
|
1376
|
+
let executionInitialization = null;
|
|
1377
|
+
{
|
|
1295
1378
|
const redemption = await redeemEnrollment({
|
|
1296
1379
|
baseUrl: origin,
|
|
1297
1380
|
hostId,
|
|
1298
1381
|
enrollmentToken: activeEnrollment.enrollment_token,
|
|
1299
|
-
keyPair
|
|
1382
|
+
keyPair,
|
|
1383
|
+
initialPermission: selectedRootConsent ? 'admin' : 'user'
|
|
1300
1384
|
});
|
|
1301
1385
|
hostToken = redemption.client_token;
|
|
1386
|
+
executionInitialization = redemption.execution_initialization ?? null;
|
|
1302
1387
|
if (redemption.release && typeof redemption.release === 'object') {
|
|
1303
1388
|
redemptionRelease = /** @type {Record<string, unknown>} */ (redemption.release);
|
|
1304
1389
|
}
|
|
@@ -1306,30 +1391,16 @@ export async function pairDeviceAndInstall({
|
|
|
1306
1391
|
agentVersion = redemption.release.version;
|
|
1307
1392
|
console.log(`📦 Release: ${agentVersion}`);
|
|
1308
1393
|
}
|
|
1309
|
-
} catch (err) {
|
|
1310
|
-
if (err instanceof PairingError && err.code === 'ENROLLMENT_ALREADY_USED') {
|
|
1311
|
-
// A previous run consumed this token and stored the credentials:
|
|
1312
|
-
// resume from them instead of failing on the single-use rule.
|
|
1313
|
-
const again = loadValidHostEnv(envPath);
|
|
1314
|
-
if (again && again.NC_CLIENT_ID === hostId) {
|
|
1315
|
-
console.log('🔁 Enrollment was already redeemed by an earlier run — resuming from stored credentials...');
|
|
1316
|
-
hostToken = again.NC_CLIENT_TOKEN;
|
|
1317
|
-
} else {
|
|
1318
|
-
throw err;
|
|
1319
|
-
}
|
|
1320
|
-
} else {
|
|
1321
|
-
throw err;
|
|
1322
|
-
}
|
|
1323
1394
|
}
|
|
1324
1395
|
|
|
1325
1396
|
// Host credentials ONLY — the OAuth token is never written here.
|
|
1326
1397
|
console.log('📝 Creating configuration file...');
|
|
1327
|
-
writeHostEnvFile(envPath, buildHostEnvContent({ serverUrl: enrollServerUrl, hostId, hostToken, clientName: deviceName }));
|
|
1398
|
+
writeHostEnvFile(envPath, buildHostEnvContent({ serverUrl: enrollServerUrl, hostId, hostToken, clientName: deviceName, browserBrand }));
|
|
1328
1399
|
console.log(`📝 Configuration saved to ${envPath}`);
|
|
1329
1400
|
writePairState(statePath, { host_id: hostId, phase: 'redeemed' });
|
|
1330
1401
|
|
|
1331
1402
|
console.log('🚀 Installing agent...');
|
|
1332
|
-
await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey });
|
|
1403
|
+
await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey, rootConsent: selectedRootConsent });
|
|
1333
1404
|
writePairState(statePath, { host_id: hostId, phase: 'installed' });
|
|
1334
1405
|
|
|
1335
1406
|
console.log('🔍 Verifying host registration...');
|
|
@@ -1342,7 +1413,8 @@ export async function pairDeviceAndInstall({
|
|
|
1342
1413
|
intervalMs: verifyIntervalMs
|
|
1343
1414
|
});
|
|
1344
1415
|
writePairState(statePath, { host_id: hostId, phase: 'verified' });
|
|
1345
|
-
return { hostId, serverUrl: enrollServerUrl, resumed: false, version: agentVersion
|
|
1416
|
+
return { hostId, serverUrl: enrollServerUrl, resumed: false, version: agentVersion, rootConsent: selectedRootConsent,
|
|
1417
|
+
privilegeReadiness: selectedRootConsent ? 'pending' : 'unavailable', executionInitialization };
|
|
1346
1418
|
}
|
|
1347
1419
|
|
|
1348
1420
|
/**
|
|
@@ -1378,7 +1450,9 @@ function printCliHelp() {
|
|
|
1378
1450
|
'',
|
|
1379
1451
|
'Usage:',
|
|
1380
1452
|
' nodecommand pair this machine via the browser device flow',
|
|
1453
|
+
' nodecommand <host_id> install with a dashboard host id (prompts for the token, hidden input)',
|
|
1381
1454
|
' nodecommand setup [host_id token] install the agent (auto system/user mode; no args = browser device flow)',
|
|
1455
|
+
' nodecommand [--allow-root-commands] explicitly request privileged preparation (default No; independent binding required)',
|
|
1382
1456
|
' nodecommand uninstall [--yes] remove the agent from the canonical root (alias: remove)',
|
|
1383
1457
|
' nodecommand remove [--yes] uninstall the agent (asks for confirmation unless --yes)',
|
|
1384
1458
|
' nodecommand --diagnostics preview a redacted diagnostics bundle (dry-run, writes nothing)',
|
|
@@ -1404,8 +1478,8 @@ function printCliHelp() {
|
|
|
1404
1478
|
|
|
1405
1479
|
/**
|
|
1406
1480
|
* True when the invocation is exactly the bare enrollment form
|
|
1407
|
-
* `nodecommand <host_id> <token>` — the
|
|
1408
|
-
*
|
|
1481
|
+
* `nodecommand <host_id> <token>` — the legacy positional shape accepted for
|
|
1482
|
+
* backward compatibility with already-published dashboards and docs.
|
|
1409
1483
|
*
|
|
1410
1484
|
* Exactly two non-flag arguments with plausible shapes route to the same path
|
|
1411
1485
|
* as `setup <host_id> <token>`. The guards stay deliberately loose (host id
|
|
@@ -1425,6 +1499,25 @@ export function isBareEnrollmentInvocation(args = []) {
|
|
|
1425
1499
|
return hostId.length >= 3 && token.length >= 8;
|
|
1426
1500
|
}
|
|
1427
1501
|
|
|
1502
|
+
/**
|
|
1503
|
+
* True when the invocation is exactly the token-free enrollment form
|
|
1504
|
+
* `nodecommand <host_id>` — the canonical shape the control-plane
|
|
1505
|
+
* install_command emits. The single-use token is NOT accepted on the command
|
|
1506
|
+
* line for this form: runSetup prompts for it with masked (non-echoing)
|
|
1507
|
+
* input, so it never lands in argv, shell history, `ps`, terminal
|
|
1508
|
+
* recordings, or telemetry.
|
|
1509
|
+
*
|
|
1510
|
+
* Only a canonical host id (`nc_<64hex>`) qualifies: a stray single word
|
|
1511
|
+
* keeps its previous behavior (browser device flow) instead of being
|
|
1512
|
+
* misread as an enrollment.
|
|
1513
|
+
* @param {string[]} args
|
|
1514
|
+
*/
|
|
1515
|
+
export function isBareHostIdInvocation(args = []) {
|
|
1516
|
+
if (!Array.isArray(args) || args.length !== 1) return false;
|
|
1517
|
+
const [hostId] = args;
|
|
1518
|
+
return typeof hostId === 'string' && !hostId.startsWith('-') && HOST_ID_RE.test(hostId);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1428
1521
|
async function main() {
|
|
1429
1522
|
const cliArgs = process.argv.slice(2);
|
|
1430
1523
|
// Subcommands route FIRST: otherwise `setup --diagnostics` or a stray flag
|
|
@@ -1462,10 +1555,23 @@ async function main() {
|
|
|
1462
1555
|
if (!result.ok) process.exit(1);
|
|
1463
1556
|
return;
|
|
1464
1557
|
}
|
|
1465
|
-
// Bare enrollment: route `nodecommand <host_id> <token>` (
|
|
1466
|
-
// dashboard and install_command emit) to the `setup
|
|
1467
|
-
// path instead of silently ignoring the arguments in the
|
|
1468
|
-
|
|
1558
|
+
// Bare enrollment: route `nodecommand <host_id> <token>` (legacy positional
|
|
1559
|
+
// form the dashboard and install_command used to emit) to the `setup
|
|
1560
|
+
// <host_id> <token>` path instead of silently ignoring the arguments in the
|
|
1561
|
+
// device flow.
|
|
1562
|
+
const enrollmentArgs = cliArgs.filter(arg => arg !== '--allow-root-commands');
|
|
1563
|
+
if (isBareEnrollmentInvocation(enrollmentArgs)) {
|
|
1564
|
+
await runSetup(cliArgs);
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
// Canonical token-free form: the single-use token is typed at a masked
|
|
1568
|
+
// interactive prompt inside runSetup, so it never enters argv (shell
|
|
1569
|
+
// history, `ps`, recordings, telemetry).
|
|
1570
|
+
if (isBareHostIdInvocation(enrollmentArgs)) {
|
|
1571
|
+
await runSetup(cliArgs, { allowHostIdOnly: true });
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
if (enrollmentArgs.length === 0) {
|
|
1469
1575
|
await runSetup(cliArgs);
|
|
1470
1576
|
return;
|
|
1471
1577
|
}
|
|
@@ -1480,6 +1586,8 @@ async function main() {
|
|
|
1480
1586
|
*/
|
|
1481
1587
|
export function suggestedActionFor(code) {
|
|
1482
1588
|
switch (code) {
|
|
1589
|
+
case 'HOST_ID_MISMATCH':
|
|
1590
|
+
return 'Preserve the existing credentials and use the existing host maintenance flow with its current mode/root. Check the requested host id and installation target before trying enrollment again.';
|
|
1483
1591
|
case 'ENROLL_NO_OAUTH_TOKEN':
|
|
1484
1592
|
return 'Re-run `nodecommand setup` without arguments and approve the browser device flow.';
|
|
1485
1593
|
case 'ENROLL_UNAUTHORIZED':
|
|
@@ -1490,11 +1598,16 @@ export function suggestedActionFor(code) {
|
|
|
1490
1598
|
case 'PLAN_HOST_LIMIT_EXCEEDED':
|
|
1491
1599
|
return 'Upgrade the workspace plan or remove an old host, then re-run.';
|
|
1492
1600
|
case 'ENROLLMENT_ALREADY_USED':
|
|
1493
|
-
|
|
1601
|
+
case 'REDEEM_RECOVERY_REQUIRED':
|
|
1602
|
+
case 'REDEEM_NETWORK_ERROR':
|
|
1603
|
+
case 'REDEEM_UNAUTHORIZED':
|
|
1604
|
+
return 'Preserve host/device identity. Resume only with authorized stored host credentials; otherwise use authenticated target-management recovery. Do not automatically remint or replay initialization after an uncertain redemption.';
|
|
1494
1605
|
case 'ENROLLMENT_EXPIRED':
|
|
1495
1606
|
case 'INVALID_ENROLL_TOKEN':
|
|
1496
1607
|
case 'INVALID_HOST_ID':
|
|
1497
1608
|
return 'Re-run `nodecommand setup` without arguments for a fresh enrollment.';
|
|
1609
|
+
case 'ENROLL_TOKEN_REQUIRED':
|
|
1610
|
+
return 'Re-run in an interactive terminal so the token can be typed with hidden input, or pass the token as the second argument.';
|
|
1498
1611
|
case 'INSTALL_MANIFEST_ERROR':
|
|
1499
1612
|
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.';
|
|
1500
1613
|
case 'INSTALL_DOWNLOAD_ERROR':
|
|
@@ -1553,15 +1666,53 @@ function getCliVersion() {
|
|
|
1553
1666
|
/**
|
|
1554
1667
|
* Prints the one-line install summary required after a successful setup:
|
|
1555
1668
|
* host id, install mode, CLI version, and verification status.
|
|
1556
|
-
* @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null }} options
|
|
1669
|
+
* @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null, rootConsent?: boolean, ncRoot?: string, executionInitialization?: any }} options
|
|
1557
1670
|
*/
|
|
1558
|
-
function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null }) {
|
|
1671
|
+
export function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null, rootConsent = false, ncRoot = undefined, executionInitialization = null }) {
|
|
1559
1672
|
console.log(`\n✅ Setup complete for host ${hostId}${resumed ? ' (resumed)' : ''}.`);
|
|
1560
1673
|
if (installMode) console.log(`installed as ${installMode}`);
|
|
1561
1674
|
if (version) console.log(`agent version: ${version}`);
|
|
1562
1675
|
console.log(`cli version: ${getCliVersion()}`);
|
|
1563
1676
|
console.log(`server: ${serverUrl}`);
|
|
1564
1677
|
console.log('status: agent credentials verified online');
|
|
1678
|
+
if (ncRoot) console.log(`installation root: ${ncRoot}`);
|
|
1679
|
+
console.log('OS execution identity: not verified by this credential check; native service/readiness verification required');
|
|
1680
|
+
console.log('agent runtime: installer-selected; not verified by this credential check (customer global Node/PATH unchanged)');
|
|
1681
|
+
if (resumed) {
|
|
1682
|
+
console.log('execution permission: existing choice preserved; no privilege maintenance performed');
|
|
1683
|
+
} else {
|
|
1684
|
+
console.log(`root command consent: ${rootConsent === true ? 'Yes' : 'No'}`);
|
|
1685
|
+
console.log(`local privilege readiness: ${rootConsent === true ? 'pending independent management approval and protected helper binding' : 'unavailable (not requested)'}`);
|
|
1686
|
+
const receipt = executionInitialization;
|
|
1687
|
+
const fields = ['outcome', 'reason', 'subject_user_id', 'permission'];
|
|
1688
|
+
const reasons = { requested: ['admin_requested'], preserved: ['existing_permission_preserved', 'existing_host_preserved'],
|
|
1689
|
+
defaulted: ['default_user', 'verified_origin_unavailable', 'origin_authority_changed', 'origin_not_owner', 'origin_inactive'] };
|
|
1690
|
+
const closed = receipt && typeof receipt === 'object' && !Array.isArray(receipt) && Object.keys(receipt).length === fields.length &&
|
|
1691
|
+
fields.every(field => Object.hasOwn(receipt, field)) && typeof receipt.outcome === 'string' && typeof receipt.reason === 'string' &&
|
|
1692
|
+
Object.hasOwn(reasons, receipt.outcome) && reasons[receipt.outcome].includes(receipt.reason) &&
|
|
1693
|
+
(receipt.subject_user_id === null || (typeof receipt.subject_user_id === 'string' && receipt.subject_user_id.length > 0)) &&
|
|
1694
|
+
(receipt.subject_user_id !== null || receipt.permission === null);
|
|
1695
|
+
const permission = closed ? receipt.permission : null;
|
|
1696
|
+
const projectionFields = ['requested_permission', 'applied_permission', 'permission_generation', 'permission_revision', 'applied_revision', 'enforcement_state', 'changed_by', 'updated_at'];
|
|
1697
|
+
const projected = permission && typeof permission === 'object' && !Array.isArray(permission) &&
|
|
1698
|
+
Object.keys(permission).length === projectionFields.length && projectionFields.every(field => Object.hasOwn(permission, field)) &&
|
|
1699
|
+
['user', 'admin'].includes(permission.requested_permission) && [null, 'user', 'admin'].includes(permission.applied_permission) &&
|
|
1700
|
+
[permission.permission_revision, permission.applied_revision].every(value => value === null || (Number.isSafeInteger(value) && value >= 0)) &&
|
|
1701
|
+
[permission.permission_generation, permission.changed_by].every(value => value === null || typeof value === 'string') &&
|
|
1702
|
+
typeof permission.enforcement_state === 'string' && (permission.updated_at === null || typeof permission.updated_at === 'string' || Number.isFinite(permission.updated_at));
|
|
1703
|
+
if (!closed || (permission !== null && !projected)) {
|
|
1704
|
+
console.log('personal execution initialization: unknown (server receipt unavailable or invalid); no User/Admin initialization claimed');
|
|
1705
|
+
} else {
|
|
1706
|
+
console.log(`personal execution initialization: ${receipt.outcome} (${receipt.reason})`);
|
|
1707
|
+
if (!projected) console.log('personal execution permission: unknown (no verified applicable personal record reported)');
|
|
1708
|
+
else {
|
|
1709
|
+
const label = value => value === 'admin' ? 'Admin' : value === 'user' ? 'User' : 'unknown';
|
|
1710
|
+
const state = permission.enforcement_state === 'pending' ? 'pending' : permission.enforcement_state === 'applied' ? 'server-reported applied' : 'not verified';
|
|
1711
|
+
console.log(`personal execution permission: server requested ${label(permission.requested_permission)}; enforcement ${state}`);
|
|
1712
|
+
console.log(`server-reported applied personal permission: ${label(permission.applied_permission)}; local Root enforcement not verified by setup`);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1565
1716
|
}
|
|
1566
1717
|
|
|
1567
1718
|
/**
|
|
@@ -1584,17 +1735,20 @@ const WINDOWS_ELEVATION_UNINSTALL_DECLINED =
|
|
|
1584
1735
|
* console is visible) and returns the spawnSync result. Never throws: a
|
|
1585
1736
|
* missing powershell or a declined prompt resolves to null/non-zero.
|
|
1586
1737
|
*/
|
|
1587
|
-
function requestWindowsElevation() {
|
|
1588
|
-
const
|
|
1738
|
+
function requestWindowsElevation(browserBrand = undefined) {
|
|
1739
|
+
const originalArgs = [...process.argv.slice(1)];
|
|
1740
|
+
const args = browserBrand === undefined ? originalArgs : browserBrandReexecArgs(originalArgs, browserBrand);
|
|
1741
|
+
const literal = value => "'" + value.replace(/'/g, "''") + "'";
|
|
1742
|
+
const quote = value => '"' + String(value).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1') + '"';
|
|
1743
|
+
const script = `$p=Start-Process -FilePath ${literal(process.execPath)} -ArgumentList ${literal(args.map(quote).join(' '))} -Verb RunAs -Wait -PassThru; exit $p.ExitCode`;
|
|
1589
1744
|
try {
|
|
1590
1745
|
return child_process.spawnSync(
|
|
1591
|
-
'powershell',
|
|
1746
|
+
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
|
1592
1747
|
[
|
|
1593
1748
|
'-NoProfile',
|
|
1594
|
-
'-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
.join(',')} -Verb RunAs -Wait`
|
|
1749
|
+
'-NonInteractive',
|
|
1750
|
+
'-EncodedCommand',
|
|
1751
|
+
Buffer.from(script, 'utf16le').toString('base64')
|
|
1598
1752
|
],
|
|
1599
1753
|
{ stdio: 'inherit' }
|
|
1600
1754
|
);
|
|
@@ -1607,53 +1761,172 @@ function requestWindowsElevation() {
|
|
|
1607
1761
|
* `nodecommand setup [host_id enrollment_token]`.
|
|
1608
1762
|
*
|
|
1609
1763
|
* Mode is automatic (never prompted): elevated processes install machine-wide.
|
|
1610
|
-
*
|
|
1764
|
+
* Fresh non-root macOS installs use the existing user LaunchAgent, including
|
|
1765
|
+
* administrator accounts; existing/conflicting state is checked first.
|
|
1766
|
+
* Linux: a non-root admin requests sudo; a refusal falls back to a per-user
|
|
1611
1767
|
* install with a single-line capability notice. Windows: a standard shell
|
|
1612
1768
|
* requests UAC elevation instead and hard-stops (exit 1) when elevation is
|
|
1613
1769
|
* unavailable or declined — never a silent %LOCALAPPDATA% degradation.
|
|
1614
1770
|
* Without arguments a fresh enrollment is requested via the browser device
|
|
1615
|
-
* flow; with `host_id` + `token` (dashboard flow) the supplied
|
|
1616
|
-
* redeemed directly
|
|
1771
|
+
* flow; with `host_id` + `token` (legacy dashboard flow) the supplied
|
|
1772
|
+
* enrollment is redeemed directly; with a bare canonical `host_id`
|
|
1773
|
+
* (`allowHostIdOnly`, the canonical install_command form) the single-use
|
|
1774
|
+
* token is read from a masked interactive prompt and never touches argv.
|
|
1775
|
+
* Enrollment always goes through POST /api/enroll with the
|
|
1617
1776
|
* OAuth user grant — the same tested chain as the default pairing flow — not
|
|
1618
1777
|
* through the dashboard shell snippets, because this CLI already embeds
|
|
1619
1778
|
* download, install, and device-auth verification.
|
|
1620
1779
|
* @param {string[]} args
|
|
1780
|
+
* @param {{ allowHostIdOnly?: boolean, promptToken?: (io: { input: import('node:stream').Readable, output: import('node:stream').Writable }) => Promise<string> }} [options]
|
|
1621
1781
|
*/
|
|
1622
|
-
export async function runSetup(args = []) {
|
|
1782
|
+
export async function runSetup(args = [], { allowHostIdOnly = false, promptToken = promptEnrollmentToken } = {}) {
|
|
1623
1783
|
const positionals = args.filter((a) => !a.startsWith('-'));
|
|
1624
1784
|
if (positionals.includes('help') || args.includes('--help') || args.includes('-h')) {
|
|
1625
1785
|
console.log(
|
|
1626
1786
|
[
|
|
1627
1787
|
'Usage:',
|
|
1628
1788
|
' nodecommand setup install via the browser device flow (auto system/user mode)',
|
|
1629
|
-
' nodecommand setup <host_id> <token> install with a dashboard enrollment token',
|
|
1789
|
+
' nodecommand setup <host_id> <token> install with a dashboard enrollment token (legacy positional form)',
|
|
1790
|
+
'',
|
|
1791
|
+
' nodecommand setup --maintain-identity <host_id> explicit root-only selected identity migration (NC_ROOT required)',
|
|
1792
|
+
' nodecommand setup --configure-helper-binding <host_id> <management_response.json> <signed_challenge.json>',
|
|
1793
|
+
' --activate-initial-binding explicitly activates only the local initial binding and restarts unsynchronized.',
|
|
1630
1794
|
'',
|
|
1631
|
-
'
|
|
1632
|
-
'
|
|
1795
|
+
'macOS: fresh non-root installs use the per-user LaunchAgent without sudo;',
|
|
1796
|
+
'explicit root uses system mode. Existing roots/modes are preserved or setup stops.',
|
|
1797
|
+
'Windows/Linux retain their existing automatic system/user elevation behavior.',
|
|
1798
|
+
'Service installation authorization does not allow root commands through chat.',
|
|
1799
|
+
'New setup asks: Allow root/administrator commands through Node Command? [y/N]',
|
|
1800
|
+
'Noninteractive setup defaults to No; --allow-root-commands explicitly requests preparation.',
|
|
1801
|
+
'Admin stays unavailable until protected helper binding and management approval are verified.'
|
|
1633
1802
|
].join('\n')
|
|
1634
1803
|
);
|
|
1635
1804
|
return;
|
|
1636
1805
|
}
|
|
1637
|
-
assertKnownFlags(args, ['--help', '-h'], 'setup');
|
|
1806
|
+
assertKnownFlags(args, ['--help', '-h', '--allow-root-commands', '--maintain-identity', '--configure-helper-binding', '--activate-initial-binding'], 'setup');
|
|
1807
|
+
if (args.includes('--configure-helper-binding')) {
|
|
1808
|
+
if (positionals.length !== 3 || !process.env.NC_ROOT || args.includes('--allow-root-commands') || args.includes('--maintain-identity'))
|
|
1809
|
+
throw new PairingError('INVALID_SETUP_ARGS', 'Selected helper binding maintenance needs NC_ROOT, existing host ID, protected management response and signed challenge.');
|
|
1810
|
+
const producer = await loadProtectedHelperProducer(process.env.NC_ROOT);
|
|
1811
|
+
const files = producer.protectedHelperPaths(process.env.NC_ROOT);
|
|
1812
|
+
const selected = JSON.parse(fs.readFileSync(files.configFile, 'utf8'));
|
|
1813
|
+
assertMatchingHostIdentity({ NC_CLIENT_ID: selected.client_id }, positionals[0]);
|
|
1814
|
+
const result = await producer.configureProtectedHelperBinding({ root: process.env.NC_ROOT, bindingFile: path.resolve(positionals[1]),
|
|
1815
|
+
challengeFile: path.resolve(positionals[2]), activateLocally: args.includes('--activate-initial-binding') });
|
|
1816
|
+
console.log(JSON.stringify(result));
|
|
1817
|
+
console.log('Local binding preparation is not execution readiness. The control plane must consume fresh proof, then complete signed sync and helper ACK.');
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
if (args.includes('--activate-initial-binding')) throw new PairingError('INVALID_SETUP_ARGS', 'Initial binding activation requires explicit --configure-helper-binding maintenance.');
|
|
1821
|
+
if (args.includes('--maintain-identity')) {
|
|
1822
|
+
if (positionals.length !== 1 || !process.env.NC_ROOT || args.includes('--allow-root-commands')) throw new PairingError('INVALID_SETUP_ARGS', 'Selected identity maintenance needs NC_ROOT and exactly one existing host ID; root-command consent is a separate operation.');
|
|
1823
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new PairingError('INSTALL_FAILED', 'Selected identity maintenance requires an interactive operator to confirm exact-host reconnection in authenticated management; no account or service was changed.');
|
|
1824
|
+
console.log('Selected OS identity maintenance preserves the existing personal permission and does not enable root commands through chat.');
|
|
1825
|
+
const result = await maintainLinuxIdentity({ ncRoot: process.env.NC_ROOT, hostId: positionals[0], operatorUser: process.env.NC_OPERATOR_USER,
|
|
1826
|
+
authorizeMaintenance: async facts => {
|
|
1827
|
+
await verifyHostRegistration({ apiBase: facts.server_url, hostId: facts.client_id, hostToken: facts.hostToken, privateKey: facts.privateKey });
|
|
1828
|
+
console.log(`Selected target: ${facts.client_id}; issuer ${facts.server_url}; root ${facts.ncRoot}; device SPKI sha256:${facts.device_key_id}; current OS account ${facts.old_user} (${facts.uid}:${facts.gid}).`);
|
|
1829
|
+
return confirmPrompt('Confirm this exact target is authorized for identity maintenance and is NOT held, in your authenticated management session? [y/N] ');
|
|
1830
|
+
},
|
|
1831
|
+
verifyReconnection: async facts => {
|
|
1832
|
+
await verifyHostRegistration({ apiBase: facts.server_url, hostId: facts.client_id, hostToken: facts.hostToken, privateKey: facts.privateKey });
|
|
1833
|
+
console.log(`Verify host ${facts.client_id} on ${facts.server_url}; device SPKI sha256:${facts.device_key_id}; OS identity nodecommand (${facts.uid}:${facts.gid}); root ${facts.ncRoot}.`);
|
|
1834
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
1835
|
+
return confirmPrompt('Confirm this exact host reconnected in your authenticated management session as nodecommand? [y/N] ');
|
|
1836
|
+
} });
|
|
1837
|
+
console.log(`Identity maintenance verified: ${result.client_id}, ${result.ncRoot}, nodecommand (${result.uid}:${result.gid}). Existing personal permission preserved; privileged support unavailable pending independent maintenance/binding.`);
|
|
1838
|
+
if (result.workspace) console.log(`Writable workspace: ${result.workspace}. Additional data/workspace roots require explicit authorized signed policy changes; no login-home ACLs, services or credentials were inherited.`);
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1638
1841
|
// Arity is validated BEFORE any mode/root output: an invalid invocation
|
|
1639
1842
|
// must fail without printing an installation plan it will not execute.
|
|
1640
|
-
|
|
1843
|
+
// One positional (host id) is valid only for the bare `nodecommand
|
|
1844
|
+
// <host_id>` form; `setup <host_id>` stays a loud usage error.
|
|
1845
|
+
const onePositionalOk = allowHostIdOnly && positionals.length === 1;
|
|
1846
|
+
if (positionals.length !== 0 && positionals.length !== 2 && !onePositionalOk) {
|
|
1641
1847
|
console.error('error: [INVALID_SETUP_ARGS] setup takes either no arguments or `<host_id> <token>`.');
|
|
1642
1848
|
console.error('suggested_action: Re-run as `nodecommand setup` or `nodecommand setup <host_id> <token>`.');
|
|
1643
1849
|
process.exit(1);
|
|
1644
1850
|
}
|
|
1851
|
+
const browserBrand = captureBrowserDefaultBrand();
|
|
1645
1852
|
let installMode = decideInstallMode();
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1853
|
+
let ncRoot;
|
|
1854
|
+
let existingTarget;
|
|
1855
|
+
let existingEnv;
|
|
1856
|
+
let existingKey;
|
|
1857
|
+
let rootManagedPair;
|
|
1858
|
+
let protectedKeyContext;
|
|
1859
|
+
{
|
|
1860
|
+
try {
|
|
1861
|
+
const target = setupTarget({ mode: installMode });
|
|
1862
|
+
({ mode: installMode, ncRoot } = target);
|
|
1863
|
+
existingTarget = target.existing;
|
|
1864
|
+
rootManagedPair = target.rootManagedPair;
|
|
1865
|
+
protectedKeyContext = (process.getuid?.() === 0 || process.geteuid?.() === 0) ? target.protectedInstallation : null;
|
|
1866
|
+
if (target.existing) {
|
|
1867
|
+
const localEnv = loadValidHostEnv(getEnvFilePath(ncRoot));
|
|
1868
|
+
if (!localEnv) throw new Error(`Existing host configuration at ${ncRoot} is incomplete; refusing a new enrollment or credential overwrite.`);
|
|
1869
|
+
if (target.clientId && localEnv.NC_CLIENT_ID !== target.clientId) throw new Error('Existing ordinary host identity conflicts with the protected helper selection; explicit Root maintenance is required.');
|
|
1870
|
+
assertMatchingHostIdentity(localEnv, positionals[0]);
|
|
1871
|
+
// Missing/unreadable/invalid keys are not permission to regenerate an
|
|
1872
|
+
// existing device identity. The normal verified resume flow is kept.
|
|
1873
|
+
const keyPath = getDeviceKeyPath(ncRoot);
|
|
1874
|
+
if (!protectedKeyContext) {
|
|
1875
|
+
const keyInfo = fs.lstatSync(keyPath);
|
|
1876
|
+
if (!keyInfo.isFile() || keyInfo.isSymbolicLink() || keyInfo.size > 65536 ||
|
|
1877
|
+
(os.platform() !== 'win32' && installMode === 'user' && keyInfo.uid !== process.getuid())) {
|
|
1878
|
+
throw new Error(`Existing device key at ${ncRoot} has an unverifiable type/owner; refusing identity replacement.`);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
try {
|
|
1882
|
+
existingKey = protectedKeyContext ? loadRootManagedPairKey(ncRoot, protectedKeyContext.ordinaryUid) : loadDeviceKeyPair(keyPath);
|
|
1883
|
+
} catch {
|
|
1884
|
+
throw new Error(`Existing device key at ${ncRoot} cannot be verified; preserve it for selected-installation maintenance.`);
|
|
1885
|
+
}
|
|
1886
|
+
existingEnv = localEnv;
|
|
1887
|
+
const appInfo = fs.lstatSync(path.join(ncRoot, 'app'));
|
|
1888
|
+
if (!appInfo.isDirectory() || appInfo.isSymbolicLink()) throw new Error(`Existing application at ${ncRoot} is incomplete; use selected-installation maintenance.`);
|
|
1889
|
+
}
|
|
1890
|
+
} catch (err) {
|
|
1891
|
+
if (err instanceof PairingError && err.code === 'HOST_ID_MISMATCH') {
|
|
1892
|
+
reportSetupError(err);
|
|
1893
|
+
} else {
|
|
1894
|
+
console.error(`error: [INSTALL_FAILED] ${err instanceof Error ? err.message : 'Cannot verify installation state.'}`);
|
|
1895
|
+
console.error('suggested_action: Preserve the existing mode/root; resolve conflicting state through its existing maintenance flow, with explicit administrator rights for system installs.');
|
|
1896
|
+
}
|
|
1897
|
+
process.exit(1);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
if (existingTarget) {
|
|
1902
|
+
// An installed tree is verification-only here. In particular, a legacy
|
|
1903
|
+
// tree without pairing-state metadata must not rerun OS installers that
|
|
1904
|
+
// can replace shared services/helpers. Repair is selected maintenance.
|
|
1905
|
+
console.log(`Verifying existing ${rootManagedPair ? 'Root-managed paired' : installMode} installation at ${ncRoot}, host: ${existingEnv.NC_CLIENT_ID}.`);
|
|
1906
|
+
if (rootManagedPair) console.log(`Local management: Root; ordinary service scope: ${rootManagedPair.serviceScope}; selected UID: ${rootManagedPair.ordinaryUid} (credential verification only; no user impersonation).`);
|
|
1907
|
+
try {
|
|
1908
|
+
await verifyHostRegistration({ apiBase: existingEnv.NC_SERVER_URL, hostId: existingEnv.NC_CLIENT_ID, hostToken: existingEnv.NC_CLIENT_TOKEN, privateKey: existingKey.privateKey });
|
|
1909
|
+
printSetupSuccess({ hostId: existingEnv.NC_CLIENT_ID, serverUrl: existingEnv.NC_SERVER_URL, installMode: rootManagedPair ? undefined : installMode, ncRoot, resumed: true });
|
|
1910
|
+
if (args.includes('--allow-root-commands')) console.warn('Existing root consent unchanged. Enabling root requires explicit selected-installation privileged maintenance and independent management approval.');
|
|
1911
|
+
} catch (err) {
|
|
1912
|
+
const code = err instanceof PairingError ? err.code : 'INSTALL_FAILED';
|
|
1913
|
+
const detail = code === 'VERIFY_FAILED' ? 'The existing host is not registered on the server; stored identity is unchanged.' : err.message;
|
|
1914
|
+
console.error(`error: [${code}] ${detail}`);
|
|
1915
|
+
console.error(`suggested_action: Preserve host/device credentials at ${ncRoot}; use this installation's maintenance flow to repair registration or connectivity. No new enrollment was requested.`);
|
|
1916
|
+
process.exit(1);
|
|
1917
|
+
}
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
// macOS has already resolved its user/system boundary without automatic
|
|
1921
|
+
// elevation. Linux requests sudo; Windows requests UAC (Start-Process
|
|
1649
1922
|
// -Verb RunAs) so the install lands machine-wide at the canonical system
|
|
1650
1923
|
// root. A refused or unavailable Windows elevation is a hard stop: the CLI
|
|
1651
1924
|
// never silently degrades a system install into %LOCALAPPDATA%.
|
|
1652
|
-
if (installMode === 'user' && canElevate() && !process.env.NC_ELEVATED) {
|
|
1925
|
+
if (os.platform() !== 'darwin' && installMode === 'user' && canElevate() && !process.env.NC_ELEVATED) {
|
|
1653
1926
|
if (os.platform() === 'win32') {
|
|
1654
1927
|
if (process.stdin.isTTY) {
|
|
1655
1928
|
console.log('Admin rights required for a system-wide install. Requesting UAC elevation…');
|
|
1656
|
-
const elevated = requestWindowsElevation();
|
|
1929
|
+
const elevated = requestWindowsElevation(browserBrand);
|
|
1657
1930
|
if (elevated && elevated.status === 0) process.exit(0);
|
|
1658
1931
|
}
|
|
1659
1932
|
console.error(WINDOWS_ELEVATION_INSTALL_DECLINED);
|
|
@@ -1666,16 +1939,43 @@ export async function runSetup(args = []) {
|
|
|
1666
1939
|
{ stdio: 'inherit', env: { ...process.env, NC_ELEVATED: '1' } }
|
|
1667
1940
|
);
|
|
1668
1941
|
if (elevated && elevated.status === 0) process.exit(0);
|
|
1942
|
+
try {
|
|
1943
|
+
// A failed elevated child may already have created an installation.
|
|
1944
|
+
// Recheck before falling back rather than creating a competing agent.
|
|
1945
|
+
const target = setupTarget({ mode: installMode });
|
|
1946
|
+
if (target.existing) throw new Error(`An installation now exists at ${target.ncRoot}; preserve it through its maintenance flow.`);
|
|
1947
|
+
ncRoot = target.ncRoot;
|
|
1948
|
+
} catch (err) {
|
|
1949
|
+
console.error(`error: [INSTALL_FAILED] ${err.message}`);
|
|
1950
|
+
console.error('suggested_action: Preserve existing roots and select their maintenance flow; declining elevation does not authorize a second installation.');
|
|
1951
|
+
process.exit(1);
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1669
1954
|
console.warn('Elevation unavailable or declined; continuing with a per-user install.');
|
|
1670
1955
|
}
|
|
1671
1956
|
// Mode-aware root: on win32 a system install lives in %ProgramData%\NC,
|
|
1672
1957
|
// not the %LOCALAPPDATA% tree getNcRoot() returns, so credentials must be
|
|
1673
1958
|
// written where the system installer will actually read them.
|
|
1674
|
-
|
|
1959
|
+
ncRoot = ncRoot || getNcRootForMode(installMode);
|
|
1675
1960
|
console.log(installMode === 'system' ? `Installing as system (machine-wide), root: ${ncRoot}.` : `Installing as user (per-user), root: ${ncRoot}.`);
|
|
1676
1961
|
const warning = modeWarningFor(installMode);
|
|
1677
1962
|
if (warning) console.warn(warning);
|
|
1678
1963
|
|
|
1964
|
+
// Canonical token-free form: prompt AFTER elevation so the token is typed
|
|
1965
|
+
// on the elevated terminal and never enters argv (a sudo/UAC re-exec
|
|
1966
|
+
// forwards only the host id). Masked input: the token is not echoed.
|
|
1967
|
+
if (onePositionalOk) {
|
|
1968
|
+
try {
|
|
1969
|
+
assertMatchingHostIdentity(loadValidHostEnv(getEnvFilePath(ncRoot)), positionals[0]);
|
|
1970
|
+
const enrollmentToken = await promptToken({ input: process.stdin, output: process.stderr });
|
|
1971
|
+
positionals.push(String(enrollmentToken || ''));
|
|
1972
|
+
} catch (err) {
|
|
1973
|
+
reportSetupError(err);
|
|
1974
|
+
process.exit(1);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
const rootConsent = () => promptRootCommandConsent({ explicitOptIn: args.includes('--allow-root-commands') });
|
|
1679
1979
|
if (positionals.length === 2) {
|
|
1680
1980
|
const [hostId, enrollmentToken] = positionals;
|
|
1681
1981
|
try {
|
|
@@ -1683,17 +1983,96 @@ export async function runSetup(args = []) {
|
|
|
1683
1983
|
baseUrl,
|
|
1684
1984
|
apiBase,
|
|
1685
1985
|
enrollment: { host_id: hostId, enrollment_token: enrollmentToken },
|
|
1986
|
+
browserBrand,
|
|
1686
1987
|
installMode,
|
|
1988
|
+
rootConsent,
|
|
1687
1989
|
ncRoot
|
|
1688
1990
|
});
|
|
1689
|
-
printSetupSuccess({
|
|
1991
|
+
printSetupSuccess({ ...result, installMode, ncRoot });
|
|
1690
1992
|
} catch (err) {
|
|
1691
1993
|
reportSetupError(err);
|
|
1692
1994
|
process.exit(1);
|
|
1693
1995
|
}
|
|
1694
1996
|
return;
|
|
1695
1997
|
}
|
|
1696
|
-
await runDeviceFlow({ installMode, ncRoot });
|
|
1998
|
+
await runDeviceFlow({ installMode, ncRoot, browserBrand, rootConsent });
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
export const ROOT_COMMAND_CONSENT_PROMPT = 'Allow root/administrator commands through Node Command? [y/N]';
|
|
2002
|
+
|
|
2003
|
+
export async function promptRootCommandConsent({ explicitOptIn = false, input = process.stdin, output = process.stdout, confirm = confirmPrompt } = {}) {
|
|
2004
|
+
if (explicitOptIn === true) return true;
|
|
2005
|
+
if (!input.isTTY || !output.isTTY) return false;
|
|
2006
|
+
return (await confirm(ROOT_COMMAND_CONSENT_PROMPT + ' ')) === true;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* Prompt text for the masked enrollment-token prompt. Printed to stderr (the
|
|
2011
|
+
* CLI's human channel) so stdout stays clean for machine payloads.
|
|
2012
|
+
*/
|
|
2013
|
+
export const ENROLLMENT_TOKEN_PROMPT = 'Enrollment token (input hidden): ';
|
|
2014
|
+
|
|
2015
|
+
/**
|
|
2016
|
+
* Reads a single-use enrollment token from an interactive terminal WITHOUT
|
|
2017
|
+
* echoing it. Node-stdlib only, no new deps: readline drives the input (line
|
|
2018
|
+
* editing, paste, Ctrl+C, raw mode) while its echo/cursor frames are written
|
|
2019
|
+
* into a sink stream, so the typed token never reaches the terminal,
|
|
2020
|
+
* scrollback, or a session recording.
|
|
2021
|
+
*
|
|
2022
|
+
* Fails closed (PairingError ENROLL_TOKEN_REQUIRED) instead of hanging when
|
|
2023
|
+
* stdin is not an interactive terminal, on EOF, on Ctrl+C, or on an empty
|
|
2024
|
+
* submission. The caller maps the code to a suggested_action line.
|
|
2025
|
+
* @param {{ input?: import('node:stream').Readable, output?: import('node:stream').Writable }} [streams]
|
|
2026
|
+
* @returns {Promise<string>} the typed token (trimmed)
|
|
2027
|
+
*/
|
|
2028
|
+
export function promptEnrollmentToken({ input = process.stdin, output = process.stderr } = {}) {
|
|
2029
|
+
return new Promise((resolve, reject) => {
|
|
2030
|
+
if (!input || input.isTTY !== true) {
|
|
2031
|
+
reject(
|
|
2032
|
+
new PairingError(
|
|
2033
|
+
'ENROLL_TOKEN_REQUIRED',
|
|
2034
|
+
'No enrollment token was provided and stdin is not an interactive terminal. Re-run in a terminal so the token can be typed with hidden input, or pass the token as the second argument.'
|
|
2035
|
+
)
|
|
2036
|
+
);
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
output.write(ENROLLMENT_TOKEN_PROMPT);
|
|
2040
|
+
const sink = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
|
|
2041
|
+
sink.isTTY = true;
|
|
2042
|
+
sink.columns = 80;
|
|
2043
|
+
let settled = false;
|
|
2044
|
+
const rl = readline.createInterface({ input, output: sink, terminal: true });
|
|
2045
|
+
const close = () => {
|
|
2046
|
+
try {
|
|
2047
|
+
rl.close();
|
|
2048
|
+
} catch {}
|
|
2049
|
+
output.write('\n');
|
|
2050
|
+
};
|
|
2051
|
+
const finish = (token) => {
|
|
2052
|
+
if (settled) return;
|
|
2053
|
+
settled = true;
|
|
2054
|
+
close();
|
|
2055
|
+
resolve(token);
|
|
2056
|
+
};
|
|
2057
|
+
const abort = (message) => {
|
|
2058
|
+
if (settled) return;
|
|
2059
|
+
settled = true;
|
|
2060
|
+
close();
|
|
2061
|
+
reject(new PairingError('ENROLL_TOKEN_REQUIRED', message));
|
|
2062
|
+
};
|
|
2063
|
+
rl.on('SIGINT', () => abort('Enrollment token entry was cancelled.'));
|
|
2064
|
+
rl.on('close', () => {
|
|
2065
|
+
if (!settled) abort('No enrollment token was entered (terminal closed). Re-run in a terminal and paste the token from the dashboard.');
|
|
2066
|
+
});
|
|
2067
|
+
rl.question('', (answer) => {
|
|
2068
|
+
const token = String(answer ?? '').trim();
|
|
2069
|
+
if (!token) {
|
|
2070
|
+
abort('No enrollment token was entered. Re-run in a terminal and paste the token shown in the dashboard.');
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
finish(token);
|
|
2074
|
+
});
|
|
2075
|
+
});
|
|
1697
2076
|
}
|
|
1698
2077
|
|
|
1699
2078
|
/**
|
|
@@ -1733,8 +2112,8 @@ export function confirmPrompt(question, openInterface = null) {
|
|
|
1733
2112
|
* tree is executed (never a downloaded copy) and its exit status is reported.
|
|
1734
2113
|
* Target discovery is centered on the canonical root (candidateInstallRoots
|
|
1735
2114
|
* lists it first) and treats an unreadable system tree as installed, so a
|
|
1736
|
-
* non-root shell
|
|
1737
|
-
* "nothing to remove".
|
|
2115
|
+
* non-root shell reports trusted maintenance as required instead of falsely
|
|
2116
|
+
* reporting "nothing to remove". Discovery never authorizes root execution.
|
|
1738
2117
|
* @param {string[]} args
|
|
1739
2118
|
* @param {{ command: string, emptyMessage: string, elevateOnWindows?: boolean, completionMessage: string }} options
|
|
1740
2119
|
*/
|
|
@@ -1751,7 +2130,14 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
|
|
|
1751
2130
|
process.exit(1);
|
|
1752
2131
|
}
|
|
1753
2132
|
const yes = args.includes('--yes') || args.includes('-y');
|
|
1754
|
-
|
|
2133
|
+
let discovery;
|
|
2134
|
+
try { discovery = findRemovalTargets(); } catch (err) {
|
|
2135
|
+
console.error(`error: [TRUSTED_ROOT_MAINTENANCE_REQUIRED] ${err.message}`);
|
|
2136
|
+
console.error(uninstallFailureSuggestion());
|
|
2137
|
+
process.exit(1);
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
const { install, unreadable, paired = [] } = discovery;
|
|
1755
2141
|
const targets = [...install, ...unreadable];
|
|
1756
2142
|
if (targets.length === 0) {
|
|
1757
2143
|
console.log(emptyMessage);
|
|
@@ -1760,7 +2146,7 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
|
|
|
1760
2146
|
// Windows: a machine-wide tree at C:\ProgramData\NC cannot be removed from
|
|
1761
2147
|
// a standard shell — request UAC (Start-Process -Verb RunAs) and re-run this
|
|
1762
2148
|
// same command elevated. Declined or headless elevation is a hard stop.
|
|
1763
|
-
if (elevateOnWindows && os.platform() === 'win32' && !isElevated()) {
|
|
2149
|
+
if (elevateOnWindows && os.platform() === 'win32' && targets.some((root) => !isUserInstallRoot(root)) && !isElevated()) {
|
|
1764
2150
|
if (process.stdin.isTTY) {
|
|
1765
2151
|
console.log('Admin rights required to remove the system-wide agent. Requesting UAC elevation…');
|
|
1766
2152
|
const elevated = requestWindowsElevation();
|
|
@@ -1769,7 +2155,16 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
|
|
|
1769
2155
|
console.error(WINDOWS_ELEVATION_UNINSTALL_DECLINED);
|
|
1770
2156
|
process.exit(1);
|
|
1771
2157
|
}
|
|
1772
|
-
console.log(`Found Node Command installation(s): ${targets.
|
|
2158
|
+
console.log(`Found Node Command installation(s): ${targets.map(root => {
|
|
2159
|
+
const pair = paired.find(selected => selected.ncRoot === root);
|
|
2160
|
+
return pair ? pair.helperRoot === root ? `${root} (co-located protected helper)` : `${root} (paired protected helper: ${pair.helperRoot})` : root;
|
|
2161
|
+
}).join(', ')}`);
|
|
2162
|
+
if (paired.length && (os.platform() !== 'linux' || process.getuid?.() !== 0 || process.geteuid?.() !== 0)) {
|
|
2163
|
+
console.error('error: [TRUSTED_ROOT_MAINTENANCE_REQUIRED] Paired Linux removal requires explicitly invoked real/effective Root maintenance. No elevation or ordinary-script fallback was attempted; both roots and lifecycle records are preserved.');
|
|
2164
|
+
console.error(uninstallFailureSuggestion());
|
|
2165
|
+
process.exit(1);
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
1773
2168
|
if (unreadable.length > 0) {
|
|
1774
2169
|
console.log(
|
|
1775
2170
|
`Elevated removal needed for system install(s): ${unreadable.join(', ')} (not readable as the current user).`
|
|
@@ -1789,34 +2184,37 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
|
|
|
1789
2184
|
}
|
|
1790
2185
|
let failed = 0;
|
|
1791
2186
|
for (const root of install) {
|
|
1792
|
-
const
|
|
1793
|
-
|
|
2187
|
+
const pair = paired.find(selected => selected.ncRoot === root);
|
|
2188
|
+
const script = pair ? LINUX_PAIRED_UNINSTALL_ENTRY : uninstallerForRoot(root);
|
|
2189
|
+
if (!pair && !fs.existsSync(script)) {
|
|
1794
2190
|
console.error(`error: [UNINSTALLER_MISSING] uninstaller not found at ${script}; skipping ${root}.`);
|
|
1795
|
-
console.error(
|
|
2191
|
+
console.error(uninstallFailureSuggestion());
|
|
1796
2192
|
failed += 1;
|
|
1797
2193
|
continue;
|
|
1798
2194
|
}
|
|
1799
2195
|
try {
|
|
1800
2196
|
runUninstallerScript(script);
|
|
1801
|
-
console.log(`Removed ${root} (exit 0).`);
|
|
2197
|
+
console.log(`Removed ${root}${pair && pair.helperRoot !== root ? ` and paired protected helper ${pair.helperRoot}` : ''} (exit 0).`);
|
|
1802
2198
|
} catch (err) {
|
|
1803
2199
|
const rc = typeof err?.status === 'number' ? err.status : 1;
|
|
1804
2200
|
console.error(`error: [UNINSTALL_FAILED] uninstaller for ${root} exited with status ${rc}: ${err.message}`);
|
|
1805
2201
|
console.error(uninstallFailureSuggestion());
|
|
2202
|
+
if (pair) {
|
|
2203
|
+
process.exit(rc || 1);
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
1806
2206
|
failed += 1;
|
|
1807
2207
|
}
|
|
1808
2208
|
}
|
|
1809
|
-
// Unreadable
|
|
1810
|
-
// uninstaller's presence is validated by the elevated run itself — the
|
|
1811
|
-
// in-tree script refuses non-NC directories before deleting anything.
|
|
2209
|
+
// Unreadable system roots remain visible, but never trigger implicit sudo.
|
|
1812
2210
|
for (const root of unreadable) {
|
|
1813
2211
|
const script = uninstallerForRoot(root);
|
|
1814
2212
|
try {
|
|
1815
2213
|
runUninstallerScript(script);
|
|
1816
|
-
console.log(`Removed ${root} (
|
|
2214
|
+
console.log(`Removed ${root} (exit 0).`);
|
|
1817
2215
|
} catch (err) {
|
|
1818
2216
|
const rc = typeof err?.status === 'number' ? err.status : 1;
|
|
1819
|
-
console.error(`error: [UNINSTALL_FAILED]
|
|
2217
|
+
console.error(`error: [UNINSTALL_FAILED] uninstaller for ${root} exited with status ${rc}: ${err.message}`);
|
|
1820
2218
|
console.error(uninstallFailureSuggestion());
|
|
1821
2219
|
failed += 1;
|
|
1822
2220
|
}
|
|
@@ -1833,6 +2231,8 @@ export async function runRemove(args = []) {
|
|
|
1833
2231
|
return runRemoval(args, {
|
|
1834
2232
|
command: 'remove',
|
|
1835
2233
|
emptyMessage: 'No Node Command installation found (nothing to remove).',
|
|
2234
|
+
// remove is the legacy uninstall alias and must share its UAC path.
|
|
2235
|
+
elevateOnWindows: true,
|
|
1836
2236
|
completionMessage: 'Remove complete.'
|
|
1837
2237
|
});
|
|
1838
2238
|
}
|
|
@@ -1861,10 +2261,10 @@ export async function runUninstall(args = []) {
|
|
|
1861
2261
|
/**
|
|
1862
2262
|
* Browser device flow + enroll/redeem/install/verify. Extracted verbatim from
|
|
1863
2263
|
* the historical bare-invocation path; `setup` reuses it with an explicit
|
|
1864
|
-
* install mode
|
|
1865
|
-
* @param {{ installMode?: 'system'|'user' }} [options]
|
|
2264
|
+
* install mode; plain `nodecommand` now uses the same setup preflight.
|
|
2265
|
+
* @param {{ installMode?: 'system'|'user', ncRoot?: string, browserBrand?: string, rootConsent?: boolean|(() => Promise<boolean>) }} [options]
|
|
1866
2266
|
*/
|
|
1867
|
-
async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {}) {
|
|
2267
|
+
async function runDeviceFlow({ installMode = undefined, ncRoot = undefined, browserBrand = captureBrowserDefaultBrand(), rootConsent = false } = {}) {
|
|
1868
2268
|
const deviceEndpoint = `${baseUrl}/oauth/device/code`;
|
|
1869
2269
|
let initRes;
|
|
1870
2270
|
|
|
@@ -1903,7 +2303,9 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
|
|
|
1903
2303
|
verification_uri_complete,
|
|
1904
2304
|
verification_uri,
|
|
1905
2305
|
interval = 3,
|
|
1906
|
-
expires_in
|
|
2306
|
+
// Platform contract: the server always returns expires_in and grants live
|
|
2307
|
+
// 300s. Keep the fallback at the mandated 300s TTL, never the old 600s.
|
|
2308
|
+
expires_in = 300
|
|
1907
2309
|
} = initRes.data;
|
|
1908
2310
|
|
|
1909
2311
|
const bold = '\x1b[1m';
|
|
@@ -1937,8 +2339,9 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
|
|
|
1937
2339
|
let isPolling = false;
|
|
1938
2340
|
let currentIntervalMs = pollIntervalMs;
|
|
1939
2341
|
// Poll bounds (RFC 8628 §3.5): the device_code lives ~expires_in seconds
|
|
1940
|
-
// server-side, and consecutive network failures must
|
|
1941
|
-
|
|
2342
|
+
// server-side (300s mandated TTL), and consecutive network failures must
|
|
2343
|
+
// not spin forever. Fallback mirrors the same 300s contract.
|
|
2344
|
+
const lifetimeSec = Number(expires_in) > 0 ? Number(expires_in) : 300;
|
|
1942
2345
|
const flowDeadline = Date.now() + lifetimeSec * 1000;
|
|
1943
2346
|
let consecutiveNetErrors = 0;
|
|
1944
2347
|
const MAX_NET_ERROR_RETRIES = 10;
|
|
@@ -1969,12 +2372,14 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
|
|
|
1969
2372
|
const oauthToken = tokenRes.data.access_token;
|
|
1970
2373
|
console.log('\n🔓 Browser approval received. Enrolling this machine...');
|
|
1971
2374
|
try {
|
|
1972
|
-
const result = await pairDeviceAndInstall({ baseUrl, apiBase, oauthToken, installMode, ncRoot });
|
|
2375
|
+
const result = await pairDeviceAndInstall({ baseUrl, apiBase, oauthToken, installMode, ncRoot, browserBrand, rootConsent });
|
|
1973
2376
|
if (installMode) {
|
|
1974
2377
|
// `setup` reports the structured success block (host, mode,
|
|
1975
2378
|
// versions, online status); bare `nodecommand` keeps the legacy
|
|
1976
2379
|
// pairing messages below.
|
|
1977
2380
|
printSetupSuccess({
|
|
2381
|
+
...result,
|
|
2382
|
+
ncRoot,
|
|
1978
2383
|
hostId: result.hostId,
|
|
1979
2384
|
serverUrl: result.serverUrl,
|
|
1980
2385
|
installMode,
|