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/lib/setup.mjs CHANGED
@@ -8,6 +8,90 @@ import child_process from 'node:child_process';
8
8
  import fs from 'node:fs';
9
9
  import os from 'node:os';
10
10
  import path from 'node:path';
11
+ import crypto from 'node:crypto';
12
+ import { pathToFileURL, fileURLToPath } from 'node:url';
13
+
14
+ /** Enrollment metadata is a brand, never an executable, profile or command. */
15
+ export function normalizeBrowserBrand(value) {
16
+ return value === 'edge' || value === 'chrome' ? value : 'unknown';
17
+ }
18
+
19
+ /** Capture HKCU before UAC; forwarded unknown must not query another account.
20
+ * @param {{platform?: string, forwarded?: string, run?: typeof child_process.spawnSync}} [options]
21
+ */
22
+ export function captureBrowserDefaultBrand({ platform = os.platform(), forwarded = process.env.NC_BROWSER_ENROLL_BRAND, run = child_process.spawnSync } = {}) {
23
+ if (platform !== 'win32') return 'unknown';
24
+ if (forwarded !== undefined) return normalizeBrowserBrand(forwarded);
25
+ try {
26
+ const result = run('C:\\Windows\\System32\\reg.exe', [
27
+ 'query', 'HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\https\\UserChoice', '/v', 'ProgId'
28
+ ], { encoding: 'utf8', timeout: 1500, maxBuffer: 4096, windowsHide: true, shell: false });
29
+ if (result.error || result.status !== 0 || typeof result.stdout !== 'string' || Buffer.byteLength(result.stdout) > 4096) return 'unknown';
30
+ const values = [...result.stdout.matchAll(/^\s*ProgId\s+REG_SZ\s+(\S+)\s*$/gim)];
31
+ if (values.length !== 1) return 'unknown';
32
+ if (/^MSEdgeHTM$/i.test(values[0][1])) return 'edge';
33
+ if (/^ChromeHTML$/i.test(values[0][1])) return 'chrome';
34
+ } catch {}
35
+ return 'unknown';
36
+ }
37
+
38
+ /** Re-exec carries only a validated brand, including across credential UAC.
39
+ * Original CLI argv/dispatch are restored; no new public CLI flag is added.
40
+ * @param {string[]} args
41
+ * @param {string} brand
42
+ */
43
+ export function browserBrandReexecArgs(args, brand) {
44
+ const script = `process.env.NC_BROWSER_ENROLL_BRAND=${JSON.stringify(normalizeBrowserBrand(brand))};process.argv=[process.execPath,...${JSON.stringify(args)}];await import(${JSON.stringify(pathToFileURL(path.resolve(args[0])).href)});`;
45
+ return ['--input-type=module', '--eval', script];
46
+ }
47
+
48
+ /**
49
+ * Canonical absolute Windows executable path derived only from the trusted
50
+ * SystemRoot (never from the caller's PATH).
51
+ * @param {'powershell'|'tar'|'sc'|'sc.exe'|'net'|'net.exe'} name
52
+ * @param {NodeJS.ProcessEnv} [env]
53
+ */
54
+ export function windowsExecutablePath(name, env = process.env) {
55
+ const systemRoot = env.SystemRoot || env.windir || 'C:\\Windows';
56
+ if (name === 'powershell') return path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
57
+ if (name === 'tar') return path.join(systemRoot, 'System32', 'tar.exe');
58
+ if (name === 'sc' || name === 'sc.exe') return path.join(systemRoot, 'System32', 'sc.exe');
59
+ if (name === 'net' || name === 'net.exe') return path.join(systemRoot, 'System32', 'net.exe');
60
+ throw new Error(`No canonical Windows executable mapping for ${name}.`);
61
+ }
62
+
63
+ /**
64
+ * Resolves a required helper executable only from an absolute allowlist, so a
65
+ * caller-controlled PATH cannot redirect an elevation or install flow.
66
+ * @param {'bash'|'tar'|'sudo'|'id'|'powershell'|'systemctl'|'launchctl'|'sc'|'sc.exe'|'net'|'net.exe'} name
67
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv, existsSync?: (p: string) => boolean }} [options]
68
+ */
69
+ export function resolveExecutable(name, { platform = os.platform(), env = process.env, existsSync = fs.existsSync } = {}) {
70
+ const unavailable = (expected) => Object.assign(
71
+ new Error(`Required executable ${name} was not found at ${expected}; refusing to resolve it through PATH.`),
72
+ { code: 'CLI_EXECUTABLE_UNAVAILABLE' }
73
+ );
74
+ if (platform === 'win32') {
75
+ const candidate = windowsExecutablePath(name, env);
76
+ if (existsSync(candidate)) return candidate;
77
+ throw unavailable(candidate);
78
+ }
79
+ /** @type {Record<string, string[]>} */
80
+ const allowlist = {
81
+ bash: ['/bin/bash', '/usr/bin/bash'],
82
+ tar: ['/bin/tar', '/usr/bin/tar'],
83
+ sudo: ['/usr/bin/sudo', '/bin/sudo'],
84
+ id: ['/usr/bin/id', '/bin/id'],
85
+ systemctl: ['/usr/bin/systemctl', '/bin/systemctl', '/usr/sbin/systemctl', '/sbin/systemctl'],
86
+ launchctl: ['/bin/launchctl', '/usr/bin/launchctl']
87
+ };
88
+ const candidates = allowlist[name];
89
+ if (!candidates) throw new Error(`No POSIX executable allowlist for ${name}.`);
90
+ for (const candidate of candidates) {
91
+ if (existsSync(candidate)) return candidate;
92
+ }
93
+ throw unavailable(candidates.join(' or '));
94
+ }
11
95
 
12
96
  /** Single-line notice printed when setup falls back to user mode. */
13
97
  export const LIMITED_CAPS_WARNING =
@@ -15,11 +99,15 @@ export const LIMITED_CAPS_WARNING =
15
99
 
16
100
  /**
17
101
  * Detects Windows administrator rights via `net session` (exit 0 ⇔ admin).
18
- * Never throws: any failure means "not admin".
102
+ * The production path resolves the absolute `net.exe`; an injected runner
103
+ * keeps the stable `net` command name for existing fixtures. Never throws:
104
+ * any failure means "not admin".
19
105
  * @param {(cmd: string, args: string[]) => { status: number|null }} [run]
20
106
  */
21
107
  export function detectWindowsAdmin(run = null) {
22
108
  try {
109
+ if (!run && os.platform() !== 'win32') return false;
110
+ const command = run ? 'net' : windowsExecutablePath('net');
23
111
  const runner =
24
112
  run ||
25
113
  ((cmd, args) => {
@@ -30,7 +118,7 @@ export function detectWindowsAdmin(run = null) {
30
118
  return { status: 1 };
31
119
  }
32
120
  });
33
- return runner('net', ['session']).status === 0;
121
+ return runner(command, ['session']).status === 0;
34
122
  } catch {
35
123
  return false;
36
124
  }
@@ -72,6 +160,16 @@ export function decideInstallMode(options = {}) {
72
160
  return isElevated(options) ? 'system' : 'user';
73
161
  }
74
162
 
163
+ /**
164
+ * A partial per-user application (valid config + device key, missing or empty
165
+ * app/launcher.mjs) may be repaired in place from the stored host identity.
166
+ * Protected or root-managed system trees always require explicit maintenance.
167
+ * @param {{ mode?: 'system'|'user', protectedContext?: unknown, rootManagedPair?: boolean }} [options]
168
+ */
169
+ export function canRepairUserApplication({ mode, protectedContext = null, rootManagedPair = false } = {}) {
170
+ return mode === 'user' && !protectedContext && rootManagedPair !== true;
171
+ }
172
+
75
173
  /**
76
174
  * True when the current user CAN elevate to a machine-wide install even when
77
175
  * the process is not root yet: an already-elevated process, a Windows
@@ -88,7 +186,7 @@ export function canElevate(options = {}) {
88
186
  // UAC prompt, so elevation is available even when not administrator.
89
187
  if (platform === 'win32') return true;
90
188
  try {
91
- const r = child_process.spawnSync('id', ['-nG'], { encoding: 'utf8' });
189
+ const r = child_process.spawnSync(resolveExecutable('id', { platform }), ['-nG'], { encoding: 'utf8' });
92
190
  const groups = r && typeof r.stdout === 'string' ? r.stdout : '';
93
191
  return /\b(sudo|admin|wheel)\b/.test(groups);
94
192
  } catch {
@@ -134,10 +232,10 @@ export function candidateInstallRoots({ platform = os.platform(), env = process.
134
232
  const localAppData = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
135
233
  roots.push(path.join(programData, 'NC'), path.join(localAppData, 'nc'));
136
234
  } else if (platform === 'darwin') {
137
- roots.push('/opt/nc', path.join(homedir, 'Library', 'Application Support', 'nc'));
235
+ roots.push('/Library/Application Support/nc', '/opt/nc', path.join(homedir, 'Library', 'Application Support', 'nc'));
138
236
  } else {
139
237
  const dataHome = env.XDG_DATA_HOME || path.join(homedir, '.local', 'share');
140
- roots.push('/opt/nc', path.join(dataHome, 'nc'));
238
+ roots.push('/opt/nc', path.join(dataHome, 'nc'), '/var/lib/nodecommand/nc');
141
239
  }
142
240
  return [...new Set(roots)];
143
241
  }
@@ -158,20 +256,119 @@ export function isInstallRoot(root, existsSync = fs.existsSync) {
158
256
  }
159
257
  }
160
258
 
259
+ /** Read-only recognition of the fixed protected single/dual-root producer.
260
+ * Protected records establish discovery, never permission to execute a tree.
261
+ */
262
+ function linuxPairedInstallation(io = fs) {
263
+ io = { ...fs, ...io };
264
+ const helperRoot = '/opt/nc', pairedRoot = '/var/lib/nodecommand/nc';
265
+ const app = helperRoot + '/libexec/privileged-helper';
266
+ const lstat = io.lstatSync || fs.lstatSync;
267
+ const present = file => {
268
+ try { lstat(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
269
+ };
270
+ const fail = () => { throw Object.assign(new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: paired Linux installation metadata is missing, unreadable, conflicting or unprotected; preserve both roots for independently authorized local Root maintenance.'), { code: 'TRUSTED_ROOT_MAINTENANCE_REQUIRED' }); };
271
+ try {
272
+ if (!present(app)) {
273
+ if (present(helperRoot + '/libexec/nc-linux-peercred')) fail();
274
+ return null;
275
+ }
276
+ const check = (file, directory = false, protectedPath = true) => {
277
+ if (io.realpathSync(file) !== file) fail();
278
+ for (let current = file; ; current = path.dirname(current)) {
279
+ const st = lstat(current);
280
+ if (io.realpathSync(current) !== current || st.isSymbolicLink() || (protectedPath && (st.uid !== 0 || st.gid !== 0 || (st.mode & 0o022))) ||
281
+ (current === file && !directory ? !st.isFile() || st.nlink !== 1 : !st.isDirectory())) fail();
282
+ if (current === path.dirname(current)) return;
283
+ }
284
+ };
285
+ const read = (file, protectedPath = true, mode = 0o600) => {
286
+ check(file, false, protectedPath);
287
+ const fd = io.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
288
+ try {
289
+ const before = io.fstatSync(fd), named = lstat(file);
290
+ if (!before.isFile() || before.nlink !== 1 || before.dev !== named.dev || before.ino !== named.ino ||
291
+ !Number.isSafeInteger(before.size) || before.size <= 0 || before.size > 65536 ||
292
+ (protectedPath && (before.uid !== 0 || before.gid !== 0 || (before.mode & 0o7777) !== mode))) fail();
293
+ const text = io.readFileSync(fd, 'utf8'), after = io.fstatSync(fd);
294
+ if (Buffer.byteLength(text) !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) fail();
295
+ return text;
296
+ } finally { io.closeSync(fd); }
297
+ };
298
+ check(app, true);
299
+ if (io.readdirSync(helperRoot + '/libexec').sort().join('\n') !== 'nc-linux-peercred\nprivileged-helper') fail();
300
+ check(helperRoot + '/libexec/nc-linux-peercred');
301
+ const bootstrap = JSON.parse(read(app + '/helper-bootstrap.json'));
302
+ const selection = JSON.parse(read('/var/lib/nc-lifecycle/selected.json'));
303
+ const exact = (value, fields) => value && typeof value === 'object' && !Array.isArray(value) &&
304
+ Object.keys(value).length === fields.length && fields.every(field => Object.hasOwn(value, field));
305
+ const id = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,200}$/.test(value);
306
+ const controlPlaneId = value => typeof value === 'string' && value.length > 0 && value.length <= 256 &&
307
+ value.trim() === value && !/[\x00-\x1f\x7f]/.test(value);
308
+ const canonical = value => typeof value === 'string' && path.isAbsolute(value) && path.resolve(value) === value && !/[\x00-\x1f\x7f]/.test(value);
309
+ if (!exact(bootstrap, ['client_id', 'control_plane_id', 'tenant_id', 'root_consent', 'ack_private_key_file', 'helper_key_id',
310
+ 'helper_key_binding_id', 'binding_state', 'node_bin', 'ordinary_root', 'ordinary_service_scope', 'lifecycle_custody_state']) ||
311
+ !/^nc_[0-9a-f]{64}$/.test(bootstrap.client_id) || !controlPlaneId(bootstrap.control_plane_id) || !id(bootstrap.tenant_id) ||
312
+ !id(bootstrap.helper_key_id) || bootstrap.root_consent !== true || ![helperRoot, pairedRoot].includes(bootstrap.ordinary_root) || bootstrap.lifecycle_custody_state !== 'prepared' ||
313
+ !['system', 'user'].includes(bootstrap.ordinary_service_scope) || !canonical(bootstrap.node_bin) ||
314
+ bootstrap.ack_private_key_file !== app + '/privileged-helper-ack-key.pem' || bootstrap.helper_key_binding_id !== null || bootstrap.binding_state !== 'pending') fail();
315
+ const ncRoot = bootstrap.ordinary_root;
316
+ if (ncRoot === helperRoot) {
317
+ const ordinaryEntries = ['app', 'config', 'data', 'logs', 'releases', 'backups', 'tmp', 'tools', 'libexec', 'uninstall.sh', 'install-linux.sh'];
318
+ if (present(pairedRoot) || !io.readdirSync(ncRoot).every(entry => ordinaryEntries.includes(entry))) fail();
319
+ } else if (io.readdirSync(helperRoot).sort().join('\n') !== 'libexec') fail();
320
+ if (!exact(selection, ['version', 'control_plane_id', 'tenant_id', 'client_id', 'helper_key_binding_id', 'helper_key_id',
321
+ 'installation_root', 'ordinary_uid', 'ordinary_service_scope', 'ordinary_service_unit', 'artifacts', 'bundle']) || selection.version !== 1 ||
322
+ ['control_plane_id', 'tenant_id', 'client_id', 'helper_key_id'].some(field => selection[field] !== bootstrap[field]) ||
323
+ !id(selection.helper_key_binding_id) || selection.installation_root !== ncRoot || selection.ordinary_service_scope !== bootstrap.ordinary_service_scope ||
324
+ selection.ordinary_service_unit !== 'nc-client.service' || !Number.isSafeInteger(selection.ordinary_uid) || selection.ordinary_uid <= 0 || selection.ordinary_uid > 0xfffffffe) fail();
325
+ const serviceFile = '/etc/systemd/' + selection.ordinary_service_scope + '/nc-client.service';
326
+ const artifacts = [serviceFile, '/etc/systemd/system/nc-privileged-helper.service', '/etc/systemd/system/nc-privileged-helper.socket', '/etc/tmpfiles.d/nc-privileged-helper.conf'].sort();
327
+ const bundle = ['finalize-linux-uninstall.mjs', 'nc-linux-managed-work', 'node', 'privileged-helper-contract.mjs', 'privileged-helper-linux-managed-work.mjs', 'privileged-helper-protected-config.mjs'];
328
+ const validFile = (file, name, mode) => exact(file, ['path', 'sha256', 'size', 'mode']) && file.path === name && file.mode === mode &&
329
+ typeof file.sha256 === 'string' && /^[0-9a-f]{64}$/.test(file.sha256) && Number.isSafeInteger(file.size) && file.size > 0 && file.size <= 256 * 1024 * 1024;
330
+ if (!Array.isArray(selection.artifacts) || selection.artifacts.length !== artifacts.length ||
331
+ !selection.artifacts.every((file, index) => validFile(file, artifacts[index], 0o644)) ||
332
+ !Array.isArray(selection.bundle) || selection.bundle.length !== bundle.length ||
333
+ !selection.bundle.every((file, index) => validFile(file, bundle[index], ['node', 'nc-linux-managed-work'].includes(bundle[index]) ? 0o755 : 0o644))) fail();
334
+ const serviceArtifact = selection.artifacts.find(file => file.path === serviceFile);
335
+ const unit = read(serviceFile, true, 0o644);
336
+ const directories = [...unit.matchAll(/^\s*WorkingDirectory\s*=\s*(.+?)\s*$/gm)];
337
+ const launches = [...unit.matchAll(/^\s*ExecStart\s*=\s*(.+?)\s*$/gm)];
338
+ const launch = launches[0]?.[1].match(/^(?:"([^"\n]+)"|([^\s]+))\s+(?:"([^"\n]+)"|([^\s]+))$/);
339
+ if (directories.length !== 1 || ![ncRoot, ncRoot + '/app'].includes(directories[0][1]) || launches.length !== 1 ||
340
+ !launch || !canonical(launch[1] || launch[2]) || (launch[3] || launch[4]) !== ncRoot + '/app/launcher.mjs' ||
341
+ Buffer.byteLength(unit) !== serviceArtifact.size || crypto.createHash('sha256').update(unit).digest('hex') !== serviceArtifact.sha256) fail();
342
+ check(path.dirname(ncRoot), true);
343
+ check(ncRoot, true, ncRoot === helperRoot);
344
+ if (![0, selection.ordinary_uid].includes(lstat(ncRoot).uid)) fail();
345
+ check(ncRoot + '/config', true, false);
346
+ if (lstat(ncRoot + '/config').uid !== selection.ordinary_uid) fail();
347
+ const content = read(ncRoot + '/config/nc-client.env', false);
348
+ const ids = content.split(/\r?\n/).filter(line => /^\s*NC_CLIENT_ID\s*=/.test(line));
349
+ const clientId = ids[0]?.match(/^\s*NC_CLIENT_ID\s*=\s*(?:"(nc_[0-9a-f]{64})"|'(nc_[0-9a-f]{64})'|(nc_[0-9a-f]{64}))\s*$/);
350
+ if (ids.length !== 1 || !clientId || (clientId[1] || clientId[2] || clientId[3]) !== bootstrap.client_id) fail();
351
+ check(ncRoot + '/app/launcher.mjs', false, false);
352
+ return { ncRoot, helperRoot, clientId: bootstrap.client_id, ordinaryUid: selection.ordinary_uid,
353
+ serviceScope: selection.ordinary_service_scope, serviceFile, serviceArtifact };
354
+ } catch { fail(); }
355
+ }
356
+
161
357
  /**
162
- * Tri-state install-tree probe. A root-owned system tree (0750/0700) is
358
+ * Install-tree probe. A root-owned system tree (0750/0700) is
163
359
  * unreadable to a non-root caller: a plain boolean would report "nothing to
164
360
  * remove" for a machine-wide install that clearly exists, and the sudo path
165
361
  * would never be reached. Distinguishes:
166
362
  * - 'install' markers readable → normal removal path
167
363
  * - 'absent' root directory does not exist
168
364
  * - 'foreign' root exists and is traversable but carries no markers
365
+ * - 'paired-helper' protected metadata binds this helper to one ordinary root
169
366
  * - 'unreadable' root exists but markers cannot be stat'ed (EACCES/EPERM)
170
367
  * → needs elevation; the uninstaller's own safety guard
171
368
  * still refuses non-NC trees when run under sudo.
172
369
  * @param {string} root
173
370
  * @param {{ statSync?: (p: string) => { isDirectory(): boolean }, accessSync?: (p: string, mode?: number) => void }} [io]
174
- * @returns {'install'|'absent'|'foreign'|'unreadable'}
371
+ * @returns {'install'|'absent'|'foreign'|'unreadable'|'paired-helper'}
175
372
  */
176
373
  export function probeInstallRoot(root, io = {}) {
177
374
  const statSync = io.statSync || fs.statSync;
@@ -190,25 +387,51 @@ export function probeInstallRoot(root, io = {}) {
190
387
  if (err && (err.code === 'EACCES' || err.code === 'EPERM')) return 'unreadable';
191
388
  }
192
389
  }
390
+ if (os.platform() === 'linux' && root === '/opt/nc' && linuxPairedInstallation(io)) return 'paired-helper';
193
391
  return 'foreign';
194
392
  }
195
393
 
394
+ /**
395
+ * Canonical machine-wide roots that exist but cannot be inspected by the
396
+ * current unprivileged caller. Setup must escalate before deciding anything
397
+ * about them: treating an unreadable system tree as absent would create a
398
+ * competing per-user agent beside a real system installation.
399
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, io?: object }} [options]
400
+ * @returns {string[]}
401
+ */
402
+ export function unreadableSystemInstallations({ platform = os.platform(), env = process.env, homedir = os.homedir(), io = {} } = {}) {
403
+ if (platform !== 'linux') return [];
404
+ const envWithoutRoot = { ...env };
405
+ delete envWithoutRoot.NC_ROOT;
406
+ const root = getNcRootForMode('system', { platform, env: envWithoutRoot, homedir });
407
+ return probeInstallRoot(root, io) === 'unreadable' ? [root] : [];
408
+ }
409
+
196
410
  /**
197
411
  * Removal targets split by accessibility. `install` roots are removed by the
198
412
  * local flow; `unreadable` roots (system installs seen from a non-root shell)
199
- * are routed to the elevated runUninstallerScript path instead of being
413
+ * report explicit trusted-maintenance requirements instead of being
200
414
  * silently reported as "nothing to remove".
201
415
  * @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, io?: object }} [options]
202
- * @returns {{ install: string[], unreadable: string[] }}
416
+ * @returns {{ install: string[], unreadable: string[], paired?: { ncRoot: string, helperRoot: string, clientId: string }[] }}
203
417
  */
204
418
  export function findRemovalTargets({ io = {}, ...rest } = {}) {
205
419
  const install = [];
206
420
  const unreadable = [];
207
- for (const root of candidateInstallRoots(rest)) {
421
+ const pair = (rest.platform || os.platform()) === 'linux' ? linuxPairedInstallation(io) : null;
422
+ const env = rest.env || process.env;
423
+ if (pair && env.NC_ROOT && ![pair.ncRoot, pair.helperRoot].includes(env.NC_ROOT)) throw new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: NC_ROOT conflicts with the protected paired installation.');
424
+ const roots = candidateInstallRoots(pair ? { ...rest, env: { ...env, NC_ROOT: undefined } } : rest);
425
+ for (const root of pair ? [...new Set([...roots, pair.ncRoot])] : roots) {
426
+ if (root === pair?.helperRoot && root !== pair.ncRoot) continue;
208
427
  const status = probeInstallRoot(root, io);
209
428
  if (status === 'install') install.push(root);
210
429
  else if (status === 'unreadable') unreadable.push(root);
211
430
  }
431
+ if (pair) {
432
+ if (install.length !== 1 || install[0] !== pair.ncRoot || unreadable.length) throw new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: conflicting paired installation inventory; preserve all roots.');
433
+ return { install, unreadable, paired: [pair] };
434
+ }
212
435
  return { install, unreadable };
213
436
  }
214
437
 
@@ -217,6 +440,7 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
217
440
  * @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, existsSync?: (p: string) => boolean }} [options]
218
441
  */
219
442
  export function findInstallations({ existsSync = fs.existsSync, ...rest } = {}) {
443
+ if ((rest.platform || os.platform()) === 'linux' && linuxPairedInstallation(rest.io || fs)) return findRemovalTargets(rest).install;
220
444
  return candidateInstallRoots(rest).filter((r) => isInstallRoot(r, existsSync));
221
445
  }
222
446
 
@@ -236,12 +460,545 @@ export function getNcRootForMode(mode, { platform = os.platform(), env = process
236
460
  const localAppData = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
237
461
  return path.join(localAppData, 'nc');
238
462
  }
239
- if (mode === 'system') return '/opt/nc';
463
+ if (mode === 'system') return platform === 'darwin' ? '/Library/Application Support/nc' : '/opt/nc';
240
464
  if (platform === 'darwin') return path.join(homedir, 'Library', 'Application Support', 'nc');
241
465
  const dataHome = env.XDG_DATA_HOME || path.join(homedir, '.local', 'share');
242
466
  return path.join(dataHome, 'nc');
243
467
  }
244
468
 
469
+ /**
470
+ * Resolve setup state before enrollment. Markers can prevent setup
471
+ * or preserve a root; they never authorize elevation or another user's mode.
472
+ * An explicit override does not hide an existing canonical tree or service.
473
+ * @param {{ platform?: string, mode?: 'system'|'user', env?: NodeJS.ProcessEnv, homedir?: string, uid?: number, io?: Pick<typeof fs, 'lstatSync'|'readFileSync'>, run?: typeof child_process.spawnSync }} [options]
474
+ * @returns {{ mode: 'system'|'user', ncRoot: string, existing: boolean, clientId?: string, protectedInstallation?: { ordinaryUid: number, serviceScope: string }, rootManagedPair?: { ordinaryUid: number, serviceScope: string } }}
475
+ */
476
+ export function setupTarget({ platform = os.platform(), mode = decideInstallMode({ platform }), env = process.env, homedir = os.homedir(), uid = process.getuid?.(), io = fs, run = child_process.spawnSync } = {}) {
477
+ const stop = (reason) => { throw new Error(`${platform === 'darwin' ? 'macOS' : platform} setup stopped: ${reason}`); };
478
+ if (platform !== 'win32' && (!Number.isInteger(uid) || uid < 0)) stop('cannot verify the invoking user.');
479
+ const canonicalEnv = { ...env };
480
+ delete canonicalEnv.NC_ROOT;
481
+ const systemRoot = path.resolve(getNcRootForMode('system', { platform, env: canonicalEnv, homedir }));
482
+ const legacySystemRoot = platform === 'darwin' ? '/opt/nc' : null;
483
+ const userRoot = path.resolve(getNcRootForMode('user', { platform, env: canonicalEnv, homedir }));
484
+ if (env.NC_ROOT && !path.isAbsolute(env.NC_ROOT)) stop('NC_ROOT must be an absolute existing or new installation path.');
485
+ const explicitRoot = env.NC_ROOT ? path.resolve(env.NC_ROOT) : null;
486
+ const roots = [...new Set([systemRoot, legacySystemRoot, userRoot, ...(platform === 'linux' ? ['/var/lib/nodecommand/nc'] : []), explicitRoot].filter(Boolean))];
487
+ const pair = platform === 'linux' ? linuxPairedInstallation(io) : null;
488
+ const stat = (file) => {
489
+ try { return io.lstatSync(file); } catch (err) {
490
+ if (err?.code === 'ENOENT') return null;
491
+ stop(`existing installation state is unreadable or unverifiable at ${file}.`);
492
+ }
493
+ };
494
+ const read = (file) => {
495
+ const info = stat(file);
496
+ if (!info) return null;
497
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 65536) stop('installation metadata is ambiguous.');
498
+ try {
499
+ const content = io.readFileSync(file, { encoding: 'utf8', flag: fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW });
500
+ if (Buffer.byteLength(content) > 65536) stop('installation metadata exceeds the supported bound.');
501
+ return content;
502
+ } catch { stop('existing installation metadata cannot be read safely.'); }
503
+ };
504
+ const installs = [];
505
+ for (const root of roots) {
506
+ if (root === pair?.helperRoot && root !== pair.ncRoot) continue;
507
+ const info = stat(root);
508
+ if (!info) continue;
509
+ if (!info.isDirectory() || info.isSymbolicLink()) stop('an installation root is not a regular directory.');
510
+ const config = stat(path.join(root, 'config'));
511
+ if (!config?.isDirectory() || config.isSymbolicLink()) stop('an existing root has no verifiable NC configuration.');
512
+ const content = read(path.join(root, 'config', 'nc-client.env'));
513
+ if (content === null) stop('an existing root has no verifiable NC configuration.');
514
+ const entries = content.split(/\r?\n/).filter(line => /^\s*NC_SERVICE_MODE\s*=/.test(line));
515
+ const match = entries[0]?.match(/^\s*NC_SERVICE_MODE\s*=\s*(?:"(user|system)"|'(user|system)'|(user|system))\s*$/);
516
+ if (entries.length > 1 || (entries.length && !match)) stop('stored installation mode is ambiguous.');
517
+ const storedMode = match ? match[1] || match[2] || match[3] : null;
518
+ // Legacy canonical trees retain their installer ownership/layout. Linux
519
+ // custom trees can also identify their mode by owner; other custom trees
520
+ // require the persisted mode rather than guessing from current privilege.
521
+ const detectedMode = storedMode || (root === pair?.ncRoot ? info.uid === 0 ? 'system' : info.uid === uid && uid !== 0 ? 'user' : null : platform === 'win32'
522
+ ? root === userRoot ? 'user' : root === systemRoot ? 'system' : null
523
+ : root === userRoot && info.uid === uid && uid !== 0 ? 'user' : (root === systemRoot || root === legacySystemRoot) && info.uid === 0 ? 'system'
524
+ : platform === 'linux' && root === explicitRoot ? info.uid === 0 ? 'system' : info.uid === uid ? 'user' : null : null);
525
+ // Root may verify this independently selected restricted pair, not adopt
526
+ // its user identity or execute a user installer. Management mode stays system.
527
+ const rootManagedPair = root === pair?.ncRoot && uid === 0 && process.getuid?.() === 0 && process.geteuid?.() === 0 && mode === 'system' &&
528
+ storedMode === 'user' && pair.serviceScope === 'user' && info.uid === pair.ordinaryUid && config.uid === pair.ordinaryUid;
529
+ if (!detectedMode || (!rootManagedPair && platform !== 'win32' && (detectedMode === 'user' ? info.uid !== uid || uid === 0 || config.uid !== uid : info.uid !== 0))) stop(`existing mode/ownership cannot be preserved safely at ${root}.`);
530
+ if (((root === systemRoot || root === legacySystemRoot) && detectedMode !== 'system') || (root === userRoot && detectedMode !== 'user')) stop(`stored mode conflicts with the installation root ${root}.`);
531
+ for (const name of ['NC_CLIENT_ID', 'NC_CLIENT_TOKEN', 'NC_SERVER_URL']) {
532
+ if (content.split(/\r?\n/).filter(line => new RegExp(`^\\s*${name}\\s*=`).test(line)).length > 1) stop(`duplicate host metadata at ${root}.`);
533
+ }
534
+ installs.push({ mode: detectedMode, ncRoot: root, rootManagedPair });
535
+ }
536
+ const services = [];
537
+ for (const [file, serviceMode, owner] of platform === 'darwin' ? [
538
+ ['/Library/LaunchDaemons/com.nc.client.plist', 'system', 0],
539
+ [path.join(homedir, 'Library', 'LaunchAgents', 'com.nc.client.plist'), 'user', uid]
540
+ ] : []) {
541
+ const info = stat(file);
542
+ if (!info) continue;
543
+ const content = read(file);
544
+ const labels = [...content.matchAll(/<key>Label<\/key>\s*<string>([^<]*)<\/string>/g)];
545
+ const directories = [...content.matchAll(/<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/g)];
546
+ if (info.uid !== owner || labels.length !== 1 || labels[0][1] !== 'com.nc.client' || directories.length !== 1) stop('existing service metadata is ambiguous.');
547
+ const directory = directories[0][1].replace(/&(amp|lt|gt|quot|apos);/g, (_, entity) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" })[entity]);
548
+ if (!path.isAbsolute(directory) || path.basename(directory) !== 'app') stop('existing service root cannot be verified.');
549
+ const serviceRoot = path.dirname(path.resolve(directory));
550
+ if (!installs.some(install => install.ncRoot === serviceRoot && install.mode === serviceMode)) stop('an existing service targets a different or unverifiable root. Preserve it through its maintenance flow.');
551
+ services.push(serviceRoot);
552
+ }
553
+ if (platform === 'linux') {
554
+ for (const [file, serviceMode] of [
555
+ ['/etc/systemd/system/nc-client.service', 'system'],
556
+ ['/etc/systemd/user/nc-client.service', 'user'],
557
+ [path.join(homedir, '.config/systemd/user/nc-client.service'), 'user'],
558
+ ...(env.XDG_CONFIG_HOME ? [[path.join(env.XDG_CONFIG_HOME, 'systemd/user/nc-client.service'), 'user']] : [])
559
+ ]) {
560
+ const content = read(file);
561
+ if (content === null) continue;
562
+ if (pair && file !== pair.serviceFile) stop(`another ordinary service exists at ${file}; preserve the paired installation through explicit Root maintenance.`);
563
+ const directories = [...content.matchAll(/^\s*WorkingDirectory\s*=\s*(.+?)\s*$/gm)];
564
+ const directory = directories[0]?.[1];
565
+ if (directories.length !== 1 || !path.isAbsolute(directory)) stop(`existing service metadata is ambiguous at ${file}.`);
566
+ const pairedService = pair && file === pair.serviceFile;
567
+ const serviceRoot = pairedService && directory === pair.ncRoot ? pair.ncRoot : path.basename(directory) === 'app' ? path.dirname(path.resolve(directory)) : null;
568
+ if (pairedService) {
569
+ if (Buffer.byteLength(content) !== pair.serviceArtifact.size || crypto.createHash('sha256').update(content).digest('hex') !== pair.serviceArtifact.sha256) stop(`paired service ${file} changed; explicit Root maintenance is required.`);
570
+ }
571
+ if (!installs.some(install => install.ncRoot === serviceRoot && (pairedService || install.mode === serviceMode))) stop(`existing service ${file} targets a different or unverifiable root; use its maintenance flow.`);
572
+ services.push(serviceRoot);
573
+ }
574
+ if (pair && !services.includes(pair.ncRoot)) stop('paired ordinary service is missing; explicit Root maintenance is required.');
575
+ }
576
+ if (platform === 'win32') {
577
+ // Discovery only: never start/stop tasks or services to identify a target.
578
+ const script = "$ErrorActionPreference='Stop'; $s=@(Get-CimInstance Win32_Service -Filter \"Name='NCClient'\" | Select-Object PathName); $t=@(Get-ScheduledTask -TaskPath '\\' | Where-Object { $_.TaskName -eq 'NC Client' } | ForEach-Object { $_.Actions | Select-Object WorkingDirectory }); ConvertTo-Json -Compress -Depth 4 @{services=$s;tasks=$t}";
579
+ let state;
580
+ try {
581
+ const result = run(windowsExecutablePath('powershell'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', timeout: 10000, maxBuffer: 65536, windowsHide: true });
582
+ if (result.status !== 0) stop('Windows service/task state is unreadable; inspect NCClient and the NC Client task through administrator maintenance.');
583
+ state = JSON.parse(String(result.stdout));
584
+ if (!Array.isArray(state.services) || !Array.isArray(state.tasks)) throw new Error('invalid service state');
585
+ } catch { stop('Windows service/task state cannot be verified; inspect NCClient and the NC Client task through administrator maintenance.'); }
586
+ for (const service of state.services) {
587
+ const executable = service.PathName?.match(/^"([^"]+\\service\\nc-service\.exe)"\s/i)?.[1];
588
+ const root = executable && path.resolve(path.dirname(path.dirname(executable)));
589
+ if (!root || !installs.some(install => install.ncRoot.toLowerCase() === root.toLowerCase() && install.mode === 'system')) stop('NCClient targets a different or unverifiable root; use its maintenance flow.');
590
+ services.push(root);
591
+ }
592
+ for (const task of state.tasks) {
593
+ const root = typeof task.WorkingDirectory === 'string' && path.isAbsolute(task.WorkingDirectory) ? path.resolve(task.WorkingDirectory) : null;
594
+ if (!root || !installs.some(install => install.ncRoot.toLowerCase() === root.toLowerCase())) stop('the NC Client task targets a different or unverifiable root; use its maintenance flow.');
595
+ services.push(root);
596
+ }
597
+ }
598
+ if (installs.length > 1 || new Set(services.map(root => platform === 'win32' ? root.toLowerCase() : root)).size > 1) stop(`conflicting installations/services exist at ${installs.map(install => install.ncRoot).join(', ')}; select a maintenance target; no competing agent will be created.`);
599
+ const existing = installs[0];
600
+ if (existing && explicitRoot && explicitRoot !== existing.ncRoot) stop('NC_ROOT would replace an existing installation root.');
601
+ if (existing && existing.mode !== mode && !existing.rootManagedPair) stop('preserve the existing mode/root; system maintenance requires an explicitly elevated invocation, not a second user install or user impersonation.');
602
+ if ((env.NC_MODE && env.NC_MODE !== mode) || (env.NC_USER_INSTALL === '1' && mode !== 'user') || (env.NC_SYSTEM_INSTALL === '1' && mode !== 'system')) stop('installer environment conflicts with the invoking privilege/mode.');
603
+ return { mode, ncRoot: existing?.ncRoot || explicitRoot || (mode === 'system' ? systemRoot : userRoot), existing: !!existing,
604
+ ...(pair ? { clientId: pair.clientId, protectedInstallation: { ordinaryUid: pair.ordinaryUid, serviceScope: pair.serviceScope } } : {}),
605
+ ...(existing?.rootManagedPair ? { rootManagedPair: { ordinaryUid: pair.ordinaryUid, serviceScope: pair.serviceScope } } : {}) };
606
+ }
607
+
608
+ /** Preserve the macOS entry used by existing callers and fixtures. */
609
+ export function macosSetupTarget({ uid = process.getuid?.(), ...options } = {}) {
610
+ return setupTarget({ ...options, platform: 'darwin', uid, mode: uid === 0 ? 'system' : 'user' });
611
+ }
612
+
613
+ /** Parse installation metadata as bounded data, NEVER as a root shell script. */
614
+ export function parseMaintenanceEnv(text) {
615
+ if (Buffer.byteLength(text) > 65536) throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
616
+ const result = Object.create(null);
617
+ for (const line of text.split(/\r?\n/)) {
618
+ if (!line.trim() || /^\s*#/.test(line)) continue;
619
+ const match = line.match(/^(NC_[A-Z0-9_]+)=(.*)$/);
620
+ if (!match || Object.hasOwn(result, match[1]) || /[\x00-\x1f]/.test(match[2])) throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
621
+ let value = match[2];
622
+ if (/^["']/.test(value)) {
623
+ if (value.at(-1) !== value[0]) throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
624
+ value = value.slice(1, -1);
625
+ }
626
+ result[match[1]] = value;
627
+ }
628
+ if (!/^nc_[0-9a-f]{64}$/.test(result.NC_CLIENT_ID || '') || !result.NC_CLIENT_TOKEN ||
629
+ new URL(result.NC_SERVER_URL).protocol !== 'https:') throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
630
+ return result;
631
+ }
632
+
633
+ /** Import only an already provisioned root-protected producer. Neither npx
634
+ * ordinary cache code nor an agent-writable helper module may hold ACK keys.
635
+ */
636
+ export async function loadProtectedHelperProducer(root) {
637
+ if (process.geteuid?.() !== 0 || !path.isAbsolute(root) || process.env.NODE_OPTIONS || process.env.NODE_PATH)
638
+ throw new Error('PRIVILEGED_HELPER_ROOT_MAINTENANCE_REQUIRED');
639
+ const check = file => {
640
+ for (let p = file; ; p = path.dirname(p)) {
641
+ const st = fs.lstatSync(p);
642
+ if (st.isSymbolicLink() || st.uid !== 0 || (st.mode & 0o022)) throw new Error('PRIVILEGED_HELPER_UNPROTECTED_PATH');
643
+ if (path.dirname(p) === p) break;
644
+ }
645
+ };
646
+ check(fs.realpathSync(process.execPath)); check(fileURLToPath(import.meta.url)); check(fs.realpathSync(process.argv[1]));
647
+ const app = path.join(root, 'libexec/privileged-helper');
648
+ const visit = file => {
649
+ check(file); const st = fs.lstatSync(file);
650
+ if (st.isDirectory()) for (const name of fs.readdirSync(file)) visit(path.join(file, name));
651
+ else if (!st.isFile()) throw new Error('PRIVILEGED_HELPER_UNPROTECTED_PATH');
652
+ };
653
+ visit(app);
654
+ return import(pathToFileURL(path.join(app, 'privileged-helper-provision.mjs')).href);
655
+ }
656
+
657
+ /**
658
+ * Selected Linux identity maintenance. All OS mutation is behind complete
659
+ * target/identity/rollback preflight. The caller is explicitly privileged;
660
+ * verifyReconnection must independently confirm the same target in normal
661
+ * authenticated management, not merely accept a local agent status claim.
662
+ * home/io/run are injectable solely for the existing disposable fixtures.
663
+ */
664
+ export async function maintainLinuxIdentity({ ncRoot, hostId, operatorUser, authorizeMaintenance, verifyReconnection,
665
+ home = '/var/lib/nodecommand', systemUnit = '/etc/systemd/system/nc-client.service', io = fs, run = child_process.spawnSync, uid = process.geteuid?.() } = {}) {
666
+ if (uid !== 0 || os.platform() !== 'linux') throw new Error('IDENTITY_MAINTENANCE_ROOT_REQUIRED');
667
+ const fail = reason => { throw new Error(`IDENTITY_MAINTENANCE_REFUSED:${reason}`); };
668
+ if (!/^nc_[0-9a-f]{64}$/.test(hostId || '') || !ncRoot || path.resolve(ncRoot) !== ncRoot ||
669
+ !path.isAbsolute(home) || /[\n\r\x00$%"\\]/.test(ncRoot + home) || typeof authorizeMaintenance !== 'function' || typeof verifyReconnection !== 'function') fail('select the exact root, host and authenticated maintenance/reconnection verification first');
670
+ const command = (exe, args, { optional = false } = {}) => {
671
+ const result = run(exe, args, { encoding: 'utf8', shell: false, timeout: 60000, maxBuffer: 65536,
672
+ env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' } });
673
+ if (result.error || result.status !== 0) { if (optional && !result.error && result.status === 2) return null; fail(`${path.basename(exe)} failed; preserve this installation and repair explicitly`); }
674
+ return String(result.stdout || '');
675
+ };
676
+ const exists = file => { try { return io.lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
677
+ const directoryFd = directory => {
678
+ let fd = io.openSync('/', fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
679
+ try {
680
+ for (const component of path.resolve(directory).split('/').filter(Boolean)) {
681
+ const next = io.openSync(`/proc/self/fd/${fd}/${component}`, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
682
+ io.closeSync(fd); fd = next;
683
+ }
684
+ return fd;
685
+ } catch (error) { io.closeSync(fd); throw error; }
686
+ };
687
+ const read = file => {
688
+ const parent = directoryFd(path.dirname(file));
689
+ let fd;
690
+ try { fd = io.openSync(`/proc/self/fd/${parent}/${path.basename(file)}`, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); }
691
+ finally { io.closeSync(parent); }
692
+ try { const st = io.fstatSync(fd); if (!st.isFile() || st.size > 65536) fail('unsafe installation metadata'); return io.readFileSync(fd, 'utf8'); }
693
+ finally { io.closeSync(fd); }
694
+ };
695
+ const write = (file, text, mode) => {
696
+ const parent = directoryFd(path.dirname(file)); let fd;
697
+ try {
698
+ fd = io.openSync(`/proc/self/fd/${parent}/${path.basename(file)}`, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, mode);
699
+ const st = io.fstatSync(fd);
700
+ if (!st.isFile() || st.nlink !== 1) fail('maintenance destination is not a single regular file');
701
+ io.ftruncateSync(fd, 0); io.writeFileSync(fd, text); io.fsyncSync(fd);
702
+ } finally { if (fd !== undefined) io.closeSync(fd); io.closeSync(parent); }
703
+ };
704
+ const envPath = path.join(ncRoot, 'config/nc-client.env');
705
+ let envText;
706
+ try { envText = read(envPath); } catch (error) {
707
+ if (error?.code !== 'ENOENT') throw error;
708
+ // A completed login-user migration retires the source configuration; a
709
+ // second run must not surface a raw filesystem error or repeat OS work.
710
+ if (exists(envPath + '.identity-backup'))
711
+ fail('this tree was already migrated to the restricted nodecommand identity; no account or service was changed');
712
+ fail('the selected root has no readable installation configuration; preserve it and repair explicitly');
713
+ }
714
+ const env = parseMaintenanceEnv(envText);
715
+ if (env.NC_CLIENT_ID !== hostId) fail('host ID mismatch; no token was consumed');
716
+ const identity = root => {
717
+ const data = parseMaintenanceEnv(read(path.join(root, 'config/nc-client.env')));
718
+ const key = crypto.createPrivateKey(read(path.join(root, 'config/nc-device-key.pem')));
719
+ return { client_id: data.NC_CLIENT_ID, server_url: data.NC_SERVER_URL,
720
+ device_key_id: crypto.createHash('sha256').update(crypto.createPublicKey(key).export({ type: 'spki', format: 'der' })).digest('hex') };
721
+ };
722
+ const protectedDirectory = directory => {
723
+ for (let current = directory; ; current = path.dirname(current)) {
724
+ const st = io.lstatSync(current);
725
+ if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== 0 || (st.mode & 0o022)) fail('maintenance/rollback ancestry is not root-protected');
726
+ if (path.dirname(current) === current) break;
727
+ }
728
+ };
729
+ const rootInfo = io.lstatSync(ncRoot);
730
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) fail('source root is not a directory');
731
+ const loginMove = env.NC_SERVICE_MODE === 'user';
732
+ const oldUser = loginMove ? operatorUser : '_nc';
733
+ // Re-selecting the already-migrated restricted installation is a no-op that
734
+ // must fail closed with its own reason instead of an account message.
735
+ if (loginMove && operatorUser === 'nodecommand')
736
+ fail('this installation already runs as the restricted nodecommand identity; no account or service was changed');
737
+ if (!/^[a-z_][a-z0-9_-]*[$]?$/.test(oldUser || '') || oldUser === 'root' || oldUser === 'nodecommand') fail('select the existing ordinary account explicitly');
738
+ const account = command('/usr/bin/getent', ['passwd', oldUser]).trim().split(':');
739
+ if (account.length !== 7 || account[0] !== oldUser || !/^\d+$/.test(account[2]) || !/^\d+$/.test(account[3])) fail('account inventory is ambiguous');
740
+ const oldUid = Number(account[2]), oldGid = Number(account[3]), oldHome = account[5];
741
+ if (oldUid === 0 || oldGid === 0 || (!loginMove && !/\/(?:nologin|false)$/.test(account[6]))) fail('selected account is not a dedicated non-root identity');
742
+ if (command('/usr/bin/getent', ['passwd', 'nodecommand'], { optional: true }) !== null ||
743
+ command('/usr/bin/getent', ['group', 'nodecommand'], { optional: true }) !== null) fail('nodecommand already exists; never merge an unrelated identity');
744
+ if (loginMove && (ncRoot === '/opt/nc' || !ncRoot.startsWith(oldHome + '/') || rootInfo.uid !== oldUid)) fail('the selected user tree does not belong to the selected login identity');
745
+ if (!loginMove && rootInfo.uid !== 0) fail('legacy dedicated system root is not protected');
746
+ const keyFdParent = directoryFd(path.join(ncRoot, 'config'));
747
+ let keyFd, selectedPrivateKey;
748
+ try {
749
+ keyFd = io.openSync(`/proc/self/fd/${keyFdParent}/nc-device-key.pem`, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
750
+ const st = io.fstatSync(keyFd);
751
+ if (!st.isFile() || st.uid !== oldUid || st.size > 65536) fail('device key is not owned by the selected ordinary identity');
752
+ selectedPrivateKey = crypto.createPrivateKey(io.readFileSync(keyFd, 'utf8'));
753
+ } finally { if (keyFd !== undefined) io.closeSync(keyFd); io.closeSync(keyFdParent); }
754
+ const originalIdentity = { client_id: env.NC_CLIENT_ID, server_url: env.NC_SERVER_URL,
755
+ device_key_id: crypto.createHash('sha256').update(crypto.createPublicKey(selectedPrivateKey).export({ type: 'spki', format: 'der' })).digest('hex') };
756
+ if (!loginMove) protectedDirectory(ncRoot);
757
+ protectedDirectory(path.dirname(home));
758
+ const unit = loginMove ? path.join(oldHome, '.config/systemd/user/nc-client.service') : systemUnit;
759
+ if (!loginMove) {
760
+ protectedDirectory(path.dirname(unit));
761
+ const st = io.lstatSync(unit);
762
+ if (!st.isFile() || st.isSymbolicLink() || st.uid !== 0 || st.nlink !== 1 || (st.mode & 0o022)) fail('selected system unit is not root-protected');
763
+ }
764
+ const unitText = read(unit);
765
+ if (!unitText.split(/\r?\n/).includes(`WorkingDirectory=${ncRoot}/app`) ||
766
+ (!loginMove && (!unitText.split(/\r?\n/).includes('User=_nc') || !unitText.split(/\r?\n/).includes('Group=_nc')))) fail('selected service does not match this root/account');
767
+ if (loginMove && exists(systemUnit)) {
768
+ // A held global instance is inventory only. Never stop, adopt or rewrite it.
769
+ if (read(systemUnit).includes(`WorkingDirectory=${ncRoot}/app`)) fail('both managers claim this selected tree; repair the ambiguity first');
770
+ }
771
+ if (!loginMove) {
772
+ const aliases = command('/usr/bin/getent', ['passwd']).trim().split('\n').filter(line => Number(line.split(':')[2]) === oldUid);
773
+ if (aliases.length !== 1 || aliases[0].split(':')[0] !== '_nc') fail('legacy UID is shared; explicit identity reconciliation is required');
774
+ if (/^\s*(?:AmbientCapabilities|SupplementaryGroups|CapabilityBoundingSet)\s*=\s*\S/m.test(unitText)) fail('legacy service has additional privilege channels; repair explicitly first');
775
+ const group = command('/usr/bin/getent', ['group', '_nc']).trim().split(':');
776
+ if (Number(group[2]) !== oldGid) fail('legacy primary group mismatch');
777
+ const groupAliases = command('/usr/bin/getent', ['group']).trim().split('\n').filter(line => Number(line.split(':')[2]) === oldGid);
778
+ if (groupAliases.length !== 1 || groupAliases[0].split(':')[0] !== '_nc') fail('legacy GID is shared; no identity rename performed');
779
+ const journal = command('/usr/bin/getent', ['group', 'systemd-journal'], { optional: true });
780
+ const allowed = [oldGid, ...(journal ? [Number(journal.trim().split(':')[2])] : [])];
781
+ if (command('/usr/bin/id', ['-G', '_nc']).trim().split(/\s+/).some(gid => !allowed.includes(Number(gid)))) fail('legacy account has unapproved supplementary authority');
782
+ }
783
+ const helperEdits = [];
784
+ if (!loginMove && exists('/etc/systemd/system/nc-privileged-helper.socket')) {
785
+ const helperService = read('/etc/systemd/system/nc-privileged-helper.service');
786
+ if (!helperService.includes(`${ncRoot}/libexec/privileged-helper/privileged-helper-linux-daemon.mjs`)) fail('helper resources belong to another or unprotected installation');
787
+ const helperConfigPath = path.join(ncRoot, 'libexec/privileged-helper/privileged-helper.json');
788
+ protectedDirectory(path.dirname(helperConfigPath));
789
+ const helperConfig = JSON.parse(read(helperConfigPath));
790
+ if (helperConfig.client_id !== hostId || helperConfig.service_uid !== oldUid || helperConfig.service_gid !== oldGid) fail('helper peer binding is not this selected identity');
791
+ for (const file of ['/etc/systemd/system/nc-privileged-helper.socket', '/etc/tmpfiles.d/nc-privileged-helper.conf']) {
792
+ if (exists(file)) {
793
+ protectedDirectory(path.dirname(file));
794
+ const text = read(file);
795
+ helperEdits.push({ file, text, replacement: text.replace(/^SocketGroup=_nc$/m, 'SocketGroup=nodecommand').replace(/^(d\s+\/run\/nc\s+\S+\s+root\s+)_nc(\s)/m, '$1nodecommand$2') });
796
+ }
797
+ }
798
+ }
799
+ const destination = loginMove ? path.join(home, 'nc') : ncRoot;
800
+ if (exists(home)) fail('restricted home/destination already occupied');
801
+ if (exists(unit + '.identity-backup')) fail('an old service recovery copy is already present');
802
+ if (loginMove && exists(envPath + '.identity-backup')) fail('an old configuration recovery copy is already present');
803
+ const runtimeMatch = unitText.match(/^ExecStart=(?:"([^"\n]+)"|([^\s]+))\s+(?:"([^"\n]+)"|([^\s]+))\s*$/m);
804
+ const oldNode = runtimeMatch?.[1] || runtimeMatch?.[2];
805
+ if (!oldNode || (runtimeMatch[3] || runtimeMatch[4]) !== `${ncRoot}/app/launcher.mjs` || !path.isAbsolute(oldNode)) fail('runtime launch cannot be preserved safely');
806
+ const node = loginMove && oldNode.startsWith(ncRoot + '/') ? destination + oldNode.slice(ncRoot.length) : oldNode;
807
+ if (loginMove && oldNode.startsWith(oldHome + '/') && !oldNode.startsWith(ncRoot + '/')) fail('runtime belongs to the former login home; select an isolated runtime before migration');
808
+ const sudo = run('/usr/bin/sudo', ['-n', '-l', '-U', oldUser], { encoding: 'utf8', shell: false, timeout: 10000,
809
+ env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' } });
810
+ if (!loginMove && !sudo.error && (sudo.status === 0 || sudo.status !== 1)) fail('legacy account has sudo authority or its sudo rules cannot be verified');
811
+ if (!loginMove && sudo.error && sudo.error.code !== 'ENOENT') fail('legacy sudo authority is unknown');
812
+ const manager = (user, userId, userHome, args) => command('/usr/sbin/runuser', ['-u', user, '--', '/usr/bin/env', '-i',
813
+ 'PATH=/usr/sbin:/usr/bin:/sbin:/bin', `HOME=${userHome}`, `XDG_RUNTIME_DIR=/run/user/${userId}`,
814
+ `DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${userId}/bus`, '/usr/bin/systemctl', '--user', ...args]);
815
+ const oldManager = args => loginMove ? manager(oldUser, oldUid, oldHome, args) : command('/usr/bin/systemctl', args);
816
+ oldManager(['is-active', 'nc-client.service']);
817
+ // Check all rollback tools and manager reachability BEFORE stopping anything.
818
+ for (const exe of loginMove ? ['/usr/sbin/useradd', '/usr/sbin/userdel', '/usr/sbin/groupadd', '/usr/sbin/groupdel'] : ['/usr/sbin/usermod', '/usr/sbin/groupmod']) command(exe, ['--help']);
819
+ if (loginMove) {
820
+ const before = command('/usr/bin/loginctl', ['show-user', oldUser, '--property=Linger', '--value']).trim();
821
+ if (!['yes', 'no'].includes(before)) fail('old linger inventory is unknown');
822
+ } else if (exists(`/var/lib/systemd/linger/${oldUser}`)) fail('legacy user-manager/linger requires selected reconciliation before rename');
823
+ if (await authorizeMaintenance({ ...originalIdentity, ncRoot, old_user: oldUser, uid: oldUid, gid: oldGid,
824
+ privateKey: selectedPrivateKey, hostToken: env.NC_CLIENT_TOKEN }) !== true) fail('selected target maintenance was not authorized; held installations remain untouched');
825
+ const recovery = loginMove ? home : path.join(ncRoot, 'libexec');
826
+ if (!loginMove) {
827
+ io.mkdirSync(recovery, { recursive: true, mode: 0o755 });
828
+ const st = io.lstatSync(recovery); if (st.uid !== 0 || st.isSymbolicLink() || (st.mode & 0o022)) fail('rollback directory is not protected');
829
+ } else io.mkdirSync(home, { mode: 0o750 });
830
+ const journalPath = path.join(recovery, '.identity-maintenance.json');
831
+ if (exists(journalPath)) fail('an earlier identity maintenance needs recovery first');
832
+ const recoveryRecord = { host_id: hostId, source: ncRoot, destination, old_user: oldUser,
833
+ old_home: oldHome, uid: oldUid, gid: oldGid, identity: originalIdentity, unit, original_unit: unitText, original_env: envText, helper_edits: helperEdits };
834
+ const recordRecovery = (record, initial = false) => {
835
+ const target = initial ? journalPath : journalPath + '.next';
836
+ const fd = io.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
837
+ try { io.writeFileSync(fd, JSON.stringify(record) + '\n'); io.fsyncSync(fd); } finally { io.closeSync(fd); }
838
+ if (!initial) io.renameSync(target, journalPath);
839
+ const directory = directoryFd(recovery);
840
+ try { io.fsyncSync(directory); } finally { io.closeSync(directory); }
841
+ };
842
+ recordRecovery({ ...recoveryRecord, state: 'prepared' }, true);
843
+ let stopped = false, accountCreated = false, groupCreated = false, renamedUser = false, renamedGroup = false, newStarted = false, managerStarted = false, retiredUnit = false, retiredEnv = false, changedHome = false;
844
+ const newUnit = loginMove ? path.join(home, '.config/systemd/user/nc-client.service') : unit;
845
+ let newUid = oldUid, newGid = oldGid;
846
+ try {
847
+ stopped = true; oldManager(['stop', 'nc-client.service']);
848
+ if (JSON.stringify(identity(ncRoot)) !== JSON.stringify(originalIdentity)) fail('source identity changed during preflight');
849
+ if (loginMove) {
850
+ // Open every source component with O_NOFOLLOW; root never follows a
851
+ // login-user replacement/symlink into another home or credential file.
852
+ const copy = (sourceFd, target, relative = '') => {
853
+ io.mkdirSync(target, { mode: 0o700 });
854
+ for (const name of io.readdirSync(`/proc/self/fd/${sourceFd}`)) {
855
+ if (!relative && !['app', 'config', 'data', 'logs', 'releases', 'backups', 'tmp', 'tools', 'install.sh', 'uninstall.sh'].includes(name)) continue;
856
+ const source = `/proc/self/fd/${sourceFd}/${name}`, dest = path.join(target, name);
857
+ const st = io.lstatSync(source);
858
+ if (st.isSymbolicLink()) {
859
+ const link = io.readlinkSync(source);
860
+ if (path.isAbsolute(link) || !path.resolve(ncRoot, relative, link).startsWith(ncRoot + '/')) fail('external source symlink; explicitly export the selected dependency first');
861
+ io.symlinkSync(link, dest); continue;
862
+ }
863
+ const fd = io.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
864
+ try {
865
+ const before = io.fstatSync(fd);
866
+ if (before.uid !== oldUid && !(before.uid === 0 && (before.mode & 0o004))) fail('unrelated private source ownership');
867
+ if (before.isDirectory()) copy(fd, dest, path.join(relative, name));
868
+ else if (before.isFile()) {
869
+ const output = io.openSync(dest, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, before.mode & 0o777);
870
+ try {
871
+ const buffer = Buffer.alloc(65536); let count;
872
+ while ((count = io.readSync(fd, buffer, 0, buffer.length, null)) > 0) {
873
+ let offset = 0; while (offset < count) offset += io.writeSync(output, buffer, offset, count - offset);
874
+ }
875
+ } finally { io.closeSync(output); }
876
+ const after = io.fstatSync(fd);
877
+ if (before.size !== after.size || before.ctimeMs !== after.ctimeMs) fail('source changed while copying');
878
+ } else fail('special files cannot be relocated');
879
+ } finally { io.closeSync(fd); }
880
+ }
881
+ };
882
+ const sourceFd = directoryFd(ncRoot);
883
+ const pinned = io.fstatSync(sourceFd);
884
+ if (pinned.ino !== rootInfo.ino || pinned.dev !== rootInfo.dev) { io.closeSync(sourceFd); fail('selected source root was replaced'); }
885
+ try { copy(sourceFd, destination); } finally { io.closeSync(sourceFd); }
886
+ if (JSON.stringify(identity(destination)) !== JSON.stringify(originalIdentity)) fail('relocation identity mismatch');
887
+ command('/usr/sbin/groupadd', ['--system', 'nodecommand']); groupCreated = true;
888
+ command('/usr/sbin/useradd', ['--system', '--gid', 'nodecommand', '--home-dir', home, '--shell', '/usr/sbin/nologin', 'nodecommand']); accountCreated = true;
889
+ const created = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
890
+ newUid = Number(created[2]); newGid = Number(created[3]);
891
+ if (!Number.isSafeInteger(newUid) || newUid <= 0 || !Number.isSafeInteger(newGid) || newGid <= 0 || created[5] !== home) fail('new account inventory mismatch');
892
+ // The created identity must itself be restricted: non-login shell and no
893
+ // supplementary groups (never a reused login or elevated account).
894
+ const createdGroups = command('/usr/bin/id', ['-nG', 'nodecommand']).trim().split(/\s+/).filter(Boolean);
895
+ if (createdGroups.length !== 1 || createdGroups[0] !== 'nodecommand' ||
896
+ !['/usr/sbin/nologin', '/sbin/nologin', '/bin/false', '/usr/bin/false'].includes(created[6]))
897
+ fail('new restricted account is not a dedicated non-login identity; repair explicitly');
898
+ const rewritten = { ...parseMaintenanceEnv(read(path.join(destination, 'config/nc-client.env'))), NC_SERVICE_MODE: 'user',
899
+ NC_ROOT: destination, NC_SERVICE_UNIT: 'nc-client.service', NC_OPERATOR_USER: 'nodecommand', NC_SERVICE_USER: 'nodecommand', NC_SERVICE_GROUP: 'nodecommand' };
900
+ for (const name of ['NC_ENV_FILE', 'NC_LOG_FILE', 'NC_DEVICE_KEY_FILE', 'NC_NODE_BIN']) {
901
+ if (rewritten[name]?.startsWith(ncRoot + '/')) rewritten[name] = destination + rewritten[name].slice(ncRoot.length);
902
+ }
903
+ // Keep server, host, token, device key, private runtime and policy roots.
904
+ write(path.join(destination, 'config/nc-client.env'), Object.entries(rewritten).map(([key, value]) => `${key}=${value}`).join('\n') + '\n', 0o600);
905
+ const own = dir => { for (const entry of io.readdirSync(dir)) { const file = path.join(dir, entry); const st = io.lstatSync(file);
906
+ if (st.isSymbolicLink()) { const actual = io.realpathSync(file); if (!actual.startsWith(destination + '/')) fail('relocated symlink escapes installation'); io.lchownSync(file, newUid, newGid); }
907
+ else { if (st.isDirectory()) own(file); io.chownSync(file, newUid, newGid); } } };
908
+ own(destination); io.chownSync(destination, newUid, newGid);
909
+ io.chownSync(home, 0, newGid);
910
+ io.mkdirSync(path.join(home, 'workspace'), { mode: 0o700 }); io.chownSync(path.join(home, 'workspace'), newUid, newGid);
911
+ for (const relative of ['.config', '.config/systemd', '.config/systemd/user', '.cache', '.local', '.local/share']) {
912
+ const directory = path.join(home, relative); io.mkdirSync(directory, { mode: 0o700 }); io.chownSync(directory, newUid, newGid);
913
+ }
914
+ io.writeFileSync(newUnit, `[Unit]\nDescription=Node Command Agent\nAfter=network-online.target\n[Service]\nType=simple\nWorkingDirectory=${destination}/app\nExecStart="${node}" "${destination}/app/launcher.mjs"\nEnvironmentFile=${destination}/config/nc-client.env\nEnvironment=HOME=${home}\nEnvironment=XDG_RUNTIME_DIR=/run/user/${newUid}\nEnvironment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${newUid}/bus\nUnsetEnvironment=SSH_AUTH_SOCK SSH_AGENT_PID SUDO_ASKPASS NODE_OPTIONS NODE_PATH LD_PRELOAD LD_LIBRARY_PATH\nNoNewPrivileges=yes\nRestart=always\nRestartSec=5\n[Install]\nWantedBy=default.target\n`, { mode: 0o600 });
915
+ io.chownSync(newUnit, newUid, newGid);
916
+ for (const socket of ['/var/run/docker.sock', '/run/podman/podman.sock', '/run/containerd/containerd.sock', '/var/lib/lxd/unix.socket', '/run/libvirt/libvirt-sock']) {
917
+ if (!exists(socket)) continue;
918
+ const access = run('/usr/sbin/runuser', ['-u', 'nodecommand', '--', '/usr/bin/test', '-w', socket], {
919
+ encoding: 'utf8', shell: false, timeout: 10000, env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin' } });
920
+ if (access.error || access.status !== 1) fail('restricted identity can access a root-equivalent IPC endpoint or its access cannot be verified');
921
+ }
922
+ command('/usr/bin/loginctl', ['enable-linger', 'nodecommand']);
923
+ managerStarted = true;
924
+ command('/usr/bin/systemctl', ['start', `user@${newUid}.service`]);
925
+ } else {
926
+ const processes = run('/usr/bin/ps', ['-u', String(oldUid), '-o', 'pid='], { encoding: 'utf8', shell: false, timeout: 10000,
927
+ env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' } });
928
+ if (processes.error || ![0, 1].includes(processes.status) || String(processes.stdout || '').trim() || String(processes.stderr || '').trim()) fail('other legacy-account processes remain or inventory is unknown; no account rename performed');
929
+ command('/usr/sbin/usermod', ['--login', 'nodecommand', '_nc']); renamedUser = true;
930
+ command('/usr/sbin/groupmod', ['--new-name', 'nodecommand', '_nc']); renamedGroup = true;
931
+ const renamed = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
932
+ if (renamed[0] !== 'nodecommand' || Number(renamed[2]) !== oldUid || Number(renamed[3]) !== oldGid ||
933
+ Number(command('/usr/bin/getent', ['group', 'nodecommand']).trim().split(':')[2]) !== oldGid) fail('account rename did not preserve UID/GID');
934
+ io.mkdirSync(home, { mode: 0o750 }); changedHome = true; io.chownSync(home, 0, oldGid);
935
+ // Change only passwd home metadata; never move an arbitrary former
936
+ // account home or recursively change its files/permissions.
937
+ command('/usr/sbin/usermod', ['--home', home, 'nodecommand']);
938
+ const relocated = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
939
+ if (relocated[5] !== home || Number(relocated[2]) !== oldUid || Number(relocated[3]) !== oldGid) fail('new home metadata or stable UID/GID could not be verified');
940
+ for (const relative of ['workspace', '.config', '.config/systemd', '.config/systemd/user', '.cache', '.local', '.local/share']) {
941
+ const directory = path.join(home, relative); io.mkdirSync(directory, { mode: 0o700 }); io.chownSync(directory, oldUid, oldGid);
942
+ }
943
+ // Regenerate the ordinary service rather than retaining root-capable
944
+ // ExecStartPre/credential hooks or an old login environment.
945
+ write(unit, `[Unit]\nDescription=Node Command Agent\nAfter=network-online.target\n[Service]\nType=simple\nUser=nodecommand\nGroup=nodecommand\nWorkingDirectory=${ncRoot}/app\nEnvironmentFile=${envPath}\nEnvironment=HOME=${home}\nEnvironment=XDG_RUNTIME_DIR=/run/user/${oldUid}\nEnvironment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${oldUid}/bus\nUnsetEnvironment=SSH_AUTH_SOCK SSH_AGENT_PID SUDO_ASKPASS NODE_OPTIONS NODE_PATH LD_PRELOAD LD_LIBRARY_PATH\nExecStart="${node}" "${ncRoot}/app/launcher.mjs"\nNoNewPrivileges=yes\nCapabilityBoundingSet=\nAmbientCapabilities=\nRestart=always\nRestartSec=5\n[Install]\nWantedBy=multi-user.target\n`, 0o644);
946
+ write(envPath, envText.replace(/^(NC_SERVICE_USER|NC_SERVICE_GROUP|NC_OPERATOR_USER)=(?:"_nc"|'_nc'|_nc)$/gm, '$1=nodecommand'), 0o600);
947
+ for (const edit of helperEdits) write(edit.file, edit.replacement, 0o644);
948
+ command('/usr/bin/loginctl', ['enable-linger', 'nodecommand']);
949
+ managerStarted = true;
950
+ command('/usr/bin/systemctl', ['start', `user@${newUid}.service`]);
951
+ }
952
+ const newManager = args => loginMove ? manager('nodecommand', newUid, home, args) : command('/usr/bin/systemctl', args);
953
+ newManager(['daemon-reload']); newStarted = true; newManager(['enable', '--now', 'nc-client.service']);
954
+ newManager(['is-active', 'nc-client.service']);
955
+ const currentIdentity = identity(destination);
956
+ if (JSON.stringify(currentIdentity) !== JSON.stringify(originalIdentity) || await verifyReconnection({ ...currentIdentity, ncRoot: destination, service_user: 'nodecommand', uid: newUid, gid: newGid,
957
+ privateKey: selectedPrivateKey, hostToken: env.NC_CLIENT_TOKEN }) !== true) fail('same-host reconnection not independently confirmed');
958
+ if (loginMove) {
959
+ oldManager(['disable', 'nc-client.service']);
960
+ const retire = (file, text) => {
961
+ const parent = directoryFd(path.dirname(file)); let fd;
962
+ try {
963
+ fd = io.openSync(`/proc/self/fd/${parent}/${path.basename(file)}.identity-backup`, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
964
+ io.writeFileSync(fd, text); io.fsyncSync(fd);
965
+ io.unlinkSync(`/proc/self/fd/${parent}/${path.basename(file)}`);
966
+ } finally { if (fd !== undefined) io.closeSync(fd); io.closeSync(parent); }
967
+ };
968
+ retire(unit, unitText); retiredUnit = true;
969
+ retire(envPath, envText); retiredEnv = true;
970
+ }
971
+ recordRecovery({ ...recoveryRecord, state: 'verified', new_uid: newUid, new_gid: newGid });
972
+ return { ...currentIdentity, ncRoot: destination, service_user: 'nodecommand', uid: newUid, gid: newGid,
973
+ workspace: path.join(home, 'workspace'), helper_readiness: 'unavailable', existing_permission: 'preserved', rollback_journal: journalPath };
974
+ } catch (error) {
975
+ try {
976
+ if (newStarted) {
977
+ if (loginMove) manager('nodecommand', newUid, home, ['disable', '--now', 'nc-client.service']);
978
+ else command('/usr/bin/systemctl', ['stop', 'nc-client.service']);
979
+ }
980
+ if (loginMove) {
981
+ if (accountCreated) { command('/usr/bin/loginctl', ['disable-linger', 'nodecommand']); command('/usr/bin/systemctl', ['stop', `user@${newUid}.service`]); command('/usr/sbin/userdel', ['nodecommand']); }
982
+ if (groupCreated) command('/usr/sbin/groupdel', ['nodecommand']);
983
+ io.rmSync(home, { recursive: true });
984
+ if (retiredUnit) { write(unit, unitText, 0o600); io.chownSync(unit, oldUid, oldGid); io.unlinkSync(unit + '.identity-backup'); }
985
+ if (retiredEnv) { write(envPath, envText, 0o600); io.chownSync(envPath, oldUid, oldGid); io.unlinkSync(envPath + '.identity-backup'); }
986
+ } else {
987
+ if (managerStarted) { command('/usr/bin/loginctl', ['disable-linger', 'nodecommand']); command('/usr/bin/systemctl', ['stop', `user@${oldUid}.service`]); }
988
+ if (changedHome) { command('/usr/sbin/usermod', ['--home', oldHome, 'nodecommand']); io.rmSync(home, { recursive: true }); }
989
+ if (renamedGroup) command('/usr/sbin/groupmod', ['--new-name', '_nc', 'nodecommand']);
990
+ if (renamedUser) command('/usr/sbin/usermod', ['--login', '_nc', 'nodecommand']);
991
+ write(unit, unitText, 0o644); write(envPath, envText, 0o600);
992
+ for (const edit of helperEdits) write(edit.file, edit.text, 0o644);
993
+ command('/usr/bin/systemctl', ['daemon-reload']);
994
+ }
995
+ if (stopped) oldManager(['start', 'nc-client.service']);
996
+ if (!loginMove) io.rmSync(journalPath);
997
+ } catch { throw new Error('IDENTITY_MAINTENANCE_ROLLBACK_REQUIRED: preserve the protected recovery journal; do not rerun installation or replace credentials', { cause: error }); }
998
+ throw error;
999
+ }
1000
+ }
1001
+
245
1002
  /**
246
1003
  * Uninstaller script living inside an installed tree. The scripts are
247
1004
  * self-locating ($ROOT derived from their own path), so no root argument is
@@ -255,8 +1012,8 @@ export function uninstallerForRoot(root, platform = os.platform()) {
255
1012
  }
256
1013
 
257
1014
  /**
258
- * Suggested-action line printed when an uninstaller fails. The unix text
259
- * names the direct recovery command; win32 keeps the generic wording because
1015
+ * Suggested-action line printed when an uninstaller fails. The Unix text
1016
+ * requires trusted maintenance, never implicit sudo; win32 keeps the generic wording because
260
1017
  * the .ps1 uninstaller already runs with the caller's own rights (no sudo
261
1018
  * equivalent is attempted there).
262
1019
  * @param {string} [platform]
@@ -264,31 +1021,28 @@ export function uninstallerForRoot(root, platform = os.platform()) {
264
1021
  export function uninstallFailureSuggestion(platform = os.platform()) {
265
1022
  const base = 'Re-run with administrator/root rights, then retry.';
266
1023
  if (platform === 'win32') return `suggested_action: ${base}`;
267
- return `suggested_action: ${base} (or run: sudo nodecommand remove --yes)`;
1024
+ return 'suggested_action: Use explicitly trusted root maintenance for system removal. Do not sudo an ordinary-writable CLI or uninstall script; preserve the protected tree/journal until managed-work cleanup is verified.';
1025
+ }
1026
+
1027
+ /** User-mode authority comes from the selected tree, not the caller's UID. */
1028
+ export function isUserInstallRoot(root) {
1029
+ try {
1030
+ return /^NC_SERVICE_MODE=user\r?$/m.test(fs.readFileSync(path.join(root, 'config', 'nc-client.env'), 'utf8').replace(/^\uFEFF/, ''));
1031
+ } catch {
1032
+ return false;
1033
+ }
268
1034
  }
269
1035
 
1036
+ export const LINUX_PAIRED_UNINSTALL_ENTRY = '/opt/nc/libexec/privileged-helper/install/uninstall-linux.sh';
1037
+
270
1038
  /**
271
- * Runs one uninstaller script, forwarding the inherited environment.
272
- *
273
- * Unix elevation: a system-wide tree is installed by root, so its
274
- * uninstaller is root-owned (mode 0700) and a non-root caller cannot even
275
- * read it — plain `bash <script>` always fails there with status 126. When
276
- * the caller is not root the script is therefore run via `sudo -E bash`,
277
- * keeping `stdio: 'inherit'` so the sudo password prompt stays visible.
278
- * When sudo itself is missing (ENOENT) the direct call is retried once so
279
- * user-owned trees stay removable without sudo; any other sudo failure
280
- * propagates unchanged and the caller reports the usual [UNINSTALL_FAILED]
281
- * guidance — behavior never gets worse, it only starts working when sudo
282
- * exists. Already-root callers run the script directly (no sudo).
283
- *
284
- * Security boundary: the caller (runRemove) only ever passes the
285
- * uninstallerForRoot() path of a tree that already passed the
286
- * isInstallRoot() check inside findInstallations() — elevation is never
287
- * applied to an arbitrary path. Windows is untouched: the .ps1 uninstaller
288
- * runs with the caller's own rights, so no elevation is attempted there.
1039
+ * Discovery markers are not execution trust. POSIX removal never elevates:
1040
+ * an ordinary caller may execute only its own user tree; root may execute
1041
+ * only a protected system script through a protected absolute interpreter.
1042
+ * Never forward startup hooks or loader/environment authority to that shell.
289
1043
  * @param {string} scriptPath
290
1044
  * @param {string|{ platform?: string, execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null }} [platform]
291
- * @param {{ execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null }} [options]
1045
+ * @param {{ execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null, geteuid?: (() => number)|null, io?: typeof fs, env?: NodeJS.ProcessEnv, homedir?: string }} [options]
292
1046
  */
293
1047
  export function runUninstallerScript(scriptPath, platform = os.platform(), options = {}) {
294
1048
  let opts = options || {};
@@ -298,8 +1052,40 @@ export function runUninstallerScript(scriptPath, platform = os.platform(), optio
298
1052
  }
299
1053
  const execFile = opts.execFile || child_process.execFileSync;
300
1054
  if (platform === 'win32') {
1055
+ // NC-SRC-02: only the packaged uninstaller of a known installation root (or
1056
+ // the Administrators/SYSTEM-only copy the installer registers for the SYSTEM
1057
+ // task) may execute — never an arbitrary path from the caller.
1058
+ const winpath = path.win32;
1059
+ const denied = () => Object.assign(new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: refusing untrusted uninstall execution; preserve the selected tree and use explicitly trusted maintenance.'), { code: 'TRUSTED_ROOT_MAINTENANCE_REQUIRED' });
1060
+ const env = opts.env || process.env;
1061
+ const homedir = opts.homedir || os.homedir();
1062
+ const programData = env.ProgramData || 'C:\\ProgramData';
1063
+ const expected = new Set([
1064
+ ...candidateInstallRoots({ platform: 'win32', env, homedir }).map(root => winpath.join(root, 'uninstall.ps1')),
1065
+ winpath.join(programData, 'NC Protected', 'uninstall.ps1')
1066
+ ].map(file => file.toLowerCase()));
1067
+ if (typeof scriptPath !== 'string' || !winpath.isAbsolute(scriptPath) ||
1068
+ winpath.normalize(scriptPath) !== scriptPath || /["`\x00-\x1F\x7F]/.test(scriptPath) ||
1069
+ winpath.basename(scriptPath).toLowerCase() !== 'uninstall.ps1' || !expected.has(scriptPath.toLowerCase())) throw denied();
1070
+ // Discovery is not execution trust: the packaged file itself must be the
1071
+ // regular (non-reparse) file at that path, with no redirected ancestry.
1072
+ const io = opts.io || fs;
1073
+ let st;
1074
+ try {
1075
+ st = io.lstatSync(scriptPath);
1076
+ } catch {
1077
+ throw denied();
1078
+ }
1079
+ if (st.isSymbolicLink() || !st.isFile()) throw denied();
1080
+ let real;
1081
+ try {
1082
+ real = io.realpathSync(scriptPath);
1083
+ } catch {
1084
+ throw denied();
1085
+ }
1086
+ if (winpath.normalize(real).toLowerCase() !== scriptPath.toLowerCase()) throw denied();
301
1087
  execFile(
302
- 'powershell.exe',
1088
+ windowsExecutablePath('powershell', env),
303
1089
  ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
304
1090
  { stdio: 'inherit' }
305
1091
  );
@@ -309,7 +1095,7 @@ export function runUninstallerScript(scriptPath, platform = os.platform(), optio
309
1095
  typeof opts.getuid === 'function'
310
1096
  ? opts.getuid
311
1097
  : typeof process.getuid === 'function'
312
- ? () => process.getuid()
1098
+ ? () => process.geteuid?.() ?? process.getuid()
313
1099
  : null;
314
1100
  let uid = null;
315
1101
  try {
@@ -317,17 +1103,48 @@ export function runUninstallerScript(scriptPath, platform = os.platform(), optio
317
1103
  } catch {
318
1104
  uid = null;
319
1105
  }
320
- if (uid === 0) {
321
- execFile('bash', [scriptPath], { stdio: 'inherit' });
322
- return;
323
- }
1106
+ const io = opts.io || fs;
1107
+ const denied = () => Object.assign(new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: refusing untrusted uninstall execution; preserve the selected tree and use explicitly trusted maintenance.'), { code: 'TRUSTED_ROOT_MAINTENANCE_REQUIRED' });
1108
+ const pairedEntry = platform === 'linux' && scriptPath === LINUX_PAIRED_UNINSTALL_ENTRY;
1109
+ if (!Number.isSafeInteger(uid) || uid < 0 || !path.isAbsolute(scriptPath) || path.resolve(scriptPath) !== scriptPath || (!pairedEntry && path.basename(scriptPath) !== 'uninstall.sh')) throw denied();
1110
+ const root = path.dirname(scriptPath);
1111
+ let userMode = false;
1112
+ if (!pairedEntry) try { userMode = /^NC_SERVICE_MODE=user\r?$/m.test(io.readFileSync(path.join(root, 'config/nc-client.env'), 'utf8').replace(/^\uFEFF/, '')); } catch {}
1113
+ const env = { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'C', LC_ALL: 'C' };
1114
+ const protectedPath = (file, executable = false, checkAcl = true) => {
1115
+ if (io.realpathSync(file) !== file) throw denied();
1116
+ for (let current = file; ; current = path.dirname(current)) {
1117
+ const st = io.lstatSync(current);
1118
+ // Linux POSIX ACL write grants are bounded by the group-class mask.
1119
+ if (st.isSymbolicLink() || st.uid !== 0 || (pairedEntry && st.gid !== 0) || (st.mode & 0o022) ||
1120
+ (current === file ? !st.isFile() || st.nlink !== 1 || (executable && !(st.mode & 0o111)) : !st.isDirectory())) throw denied();
1121
+ if (platform === 'darwin' && checkAcl) {
1122
+ const ls = io.realpathSync('/bin/ls');
1123
+ protectedPath(ls, true, false);
1124
+ const acl = execFile(ls, ['-lde', current], { env, encoding: 'utf8' });
1125
+ if (typeof acl !== 'string' || !acl.trim() || acl.trim().split(/\r?\n/).length !== 1) throw denied();
1126
+ }
1127
+ if (current === path.dirname(current)) break;
1128
+ }
1129
+ };
324
1130
  try {
325
- execFile('sudo', ['-E', 'bash', scriptPath], { stdio: 'inherit' });
326
- } catch (err) {
327
- // Without a sudo binary there is nothing to elevate with: retry the
328
- // historical direct call so user-owned trees stay removable. Any other
329
- // sudo failure (denied, wrong password, script error) propagates.
330
- if (!err || err.code !== 'ENOENT') throw err;
331
- execFile('bash', [scriptPath], { stdio: 'inherit' });
1131
+ if (pairedEntry && ((opts.getuid || process.getuid)?.() !== 0 || (opts.geteuid || process.geteuid)?.() !== 0)) throw denied();
1132
+ if (uid === 0) {
1133
+ if (userMode) throw denied();
1134
+ protectedPath(scriptPath);
1135
+ } else {
1136
+ if (!userMode || io.realpathSync(root) !== root) throw denied();
1137
+ for (const [file, directory] of [[root, true], [scriptPath, false]]) {
1138
+ const st = io.lstatSync(file);
1139
+ if (st.isSymbolicLink() || st.uid !== uid || (directory ? !st.isDirectory() : !st.isFile() || st.nlink !== 1)) throw denied();
1140
+ }
1141
+ env.HOME = os.userInfo().homedir;
1142
+ }
1143
+ const bash = io.realpathSync('/bin/bash');
1144
+ protectedPath(bash, true);
1145
+ execFile(bash, ['--noprofile', '--norc', '-p', '--', scriptPath], { stdio: 'inherit', env, cwd: '/' });
1146
+ } catch (error) {
1147
+ if (error?.status !== undefined || error?.code === 'TRUSTED_ROOT_MAINTENANCE_REQUIRED') throw error;
1148
+ throw denied();
332
1149
  }
333
1150
  }