node-command 2.0.2 → 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.
Files changed (3) hide show
  1. package/bin/nc.mjs +406 -124
  2. package/lib/setup.mjs +730 -42
  3. package/package.json +11 -16
package/lib/setup.mjs CHANGED
@@ -8,6 +8,42 @@ 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
+ }
11
47
 
12
48
  /** Single-line notice printed when setup falls back to user mode. */
13
49
  export const LIMITED_CAPS_WARNING =
@@ -134,10 +170,10 @@ export function candidateInstallRoots({ platform = os.platform(), env = process.
134
170
  const localAppData = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
135
171
  roots.push(path.join(programData, 'NC'), path.join(localAppData, 'nc'));
136
172
  } else if (platform === 'darwin') {
137
- roots.push('/opt/nc', path.join(homedir, 'Library', 'Application Support', 'nc'));
173
+ roots.push('/Library/Application Support/nc', '/opt/nc', path.join(homedir, 'Library', 'Application Support', 'nc'));
138
174
  } else {
139
175
  const dataHome = env.XDG_DATA_HOME || path.join(homedir, '.local', 'share');
140
- roots.push('/opt/nc', path.join(dataHome, 'nc'));
176
+ roots.push('/opt/nc', path.join(dataHome, 'nc'), '/var/lib/nodecommand/nc');
141
177
  }
142
178
  return [...new Set(roots)];
143
179
  }
@@ -158,20 +194,119 @@ export function isInstallRoot(root, existsSync = fs.existsSync) {
158
194
  }
159
195
  }
160
196
 
197
+ /** Read-only recognition of the fixed protected single/dual-root producer.
198
+ * Protected records establish discovery, never permission to execute a tree.
199
+ */
200
+ function linuxPairedInstallation(io = fs) {
201
+ io = { ...fs, ...io };
202
+ const helperRoot = '/opt/nc', pairedRoot = '/var/lib/nodecommand/nc';
203
+ const app = helperRoot + '/libexec/privileged-helper';
204
+ const lstat = io.lstatSync || fs.lstatSync;
205
+ const present = file => {
206
+ try { lstat(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
207
+ };
208
+ 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' }); };
209
+ try {
210
+ if (!present(app)) {
211
+ if (present(helperRoot + '/libexec/nc-linux-peercred')) fail();
212
+ return null;
213
+ }
214
+ const check = (file, directory = false, protectedPath = true) => {
215
+ if (io.realpathSync(file) !== file) fail();
216
+ for (let current = file; ; current = path.dirname(current)) {
217
+ const st = lstat(current);
218
+ if (io.realpathSync(current) !== current || st.isSymbolicLink() || (protectedPath && (st.uid !== 0 || st.gid !== 0 || (st.mode & 0o022))) ||
219
+ (current === file && !directory ? !st.isFile() || st.nlink !== 1 : !st.isDirectory())) fail();
220
+ if (current === path.dirname(current)) return;
221
+ }
222
+ };
223
+ const read = (file, protectedPath = true, mode = 0o600) => {
224
+ check(file, false, protectedPath);
225
+ const fd = io.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
226
+ try {
227
+ const before = io.fstatSync(fd), named = lstat(file);
228
+ if (!before.isFile() || before.nlink !== 1 || before.dev !== named.dev || before.ino !== named.ino ||
229
+ !Number.isSafeInteger(before.size) || before.size <= 0 || before.size > 65536 ||
230
+ (protectedPath && (before.uid !== 0 || before.gid !== 0 || (before.mode & 0o7777) !== mode))) fail();
231
+ const text = io.readFileSync(fd, 'utf8'), after = io.fstatSync(fd);
232
+ if (Buffer.byteLength(text) !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) fail();
233
+ return text;
234
+ } finally { io.closeSync(fd); }
235
+ };
236
+ check(app, true);
237
+ if (io.readdirSync(helperRoot + '/libexec').sort().join('\n') !== 'nc-linux-peercred\nprivileged-helper') fail();
238
+ check(helperRoot + '/libexec/nc-linux-peercred');
239
+ const bootstrap = JSON.parse(read(app + '/helper-bootstrap.json'));
240
+ const selection = JSON.parse(read('/var/lib/nc-lifecycle/selected.json'));
241
+ const exact = (value, fields) => value && typeof value === 'object' && !Array.isArray(value) &&
242
+ Object.keys(value).length === fields.length && fields.every(field => Object.hasOwn(value, field));
243
+ const id = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,200}$/.test(value);
244
+ const controlPlaneId = value => typeof value === 'string' && value.length > 0 && value.length <= 256 &&
245
+ value.trim() === value && !/[\x00-\x1f\x7f]/.test(value);
246
+ const canonical = value => typeof value === 'string' && path.isAbsolute(value) && path.resolve(value) === value && !/[\x00-\x1f\x7f]/.test(value);
247
+ if (!exact(bootstrap, ['client_id', 'control_plane_id', 'tenant_id', 'root_consent', 'ack_private_key_file', 'helper_key_id',
248
+ 'helper_key_binding_id', 'binding_state', 'node_bin', 'ordinary_root', 'ordinary_service_scope', 'lifecycle_custody_state']) ||
249
+ !/^nc_[0-9a-f]{64}$/.test(bootstrap.client_id) || !controlPlaneId(bootstrap.control_plane_id) || !id(bootstrap.tenant_id) ||
250
+ !id(bootstrap.helper_key_id) || bootstrap.root_consent !== true || ![helperRoot, pairedRoot].includes(bootstrap.ordinary_root) || bootstrap.lifecycle_custody_state !== 'prepared' ||
251
+ !['system', 'user'].includes(bootstrap.ordinary_service_scope) || !canonical(bootstrap.node_bin) ||
252
+ bootstrap.ack_private_key_file !== app + '/privileged-helper-ack-key.pem' || bootstrap.helper_key_binding_id !== null || bootstrap.binding_state !== 'pending') fail();
253
+ const ncRoot = bootstrap.ordinary_root;
254
+ if (ncRoot === helperRoot) {
255
+ const ordinaryEntries = ['app', 'config', 'data', 'logs', 'releases', 'backups', 'tmp', 'tools', 'libexec', 'uninstall.sh', 'install-linux.sh'];
256
+ if (present(pairedRoot) || !io.readdirSync(ncRoot).every(entry => ordinaryEntries.includes(entry))) fail();
257
+ } else if (io.readdirSync(helperRoot).sort().join('\n') !== 'libexec') fail();
258
+ if (!exact(selection, ['version', 'control_plane_id', 'tenant_id', 'client_id', 'helper_key_binding_id', 'helper_key_id',
259
+ 'installation_root', 'ordinary_uid', 'ordinary_service_scope', 'ordinary_service_unit', 'artifacts', 'bundle']) || selection.version !== 1 ||
260
+ ['control_plane_id', 'tenant_id', 'client_id', 'helper_key_id'].some(field => selection[field] !== bootstrap[field]) ||
261
+ !id(selection.helper_key_binding_id) || selection.installation_root !== ncRoot || selection.ordinary_service_scope !== bootstrap.ordinary_service_scope ||
262
+ selection.ordinary_service_unit !== 'nc-client.service' || !Number.isSafeInteger(selection.ordinary_uid) || selection.ordinary_uid <= 0 || selection.ordinary_uid > 0xfffffffe) fail();
263
+ const serviceFile = '/etc/systemd/' + selection.ordinary_service_scope + '/nc-client.service';
264
+ 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();
265
+ 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'];
266
+ const validFile = (file, name, mode) => exact(file, ['path', 'sha256', 'size', 'mode']) && file.path === name && file.mode === mode &&
267
+ typeof file.sha256 === 'string' && /^[0-9a-f]{64}$/.test(file.sha256) && Number.isSafeInteger(file.size) && file.size > 0 && file.size <= 256 * 1024 * 1024;
268
+ if (!Array.isArray(selection.artifacts) || selection.artifacts.length !== artifacts.length ||
269
+ !selection.artifacts.every((file, index) => validFile(file, artifacts[index], 0o644)) ||
270
+ !Array.isArray(selection.bundle) || selection.bundle.length !== bundle.length ||
271
+ !selection.bundle.every((file, index) => validFile(file, bundle[index], ['node', 'nc-linux-managed-work'].includes(bundle[index]) ? 0o755 : 0o644))) fail();
272
+ const serviceArtifact = selection.artifacts.find(file => file.path === serviceFile);
273
+ const unit = read(serviceFile, true, 0o644);
274
+ const directories = [...unit.matchAll(/^\s*WorkingDirectory\s*=\s*(.+?)\s*$/gm)];
275
+ const launches = [...unit.matchAll(/^\s*ExecStart\s*=\s*(.+?)\s*$/gm)];
276
+ const launch = launches[0]?.[1].match(/^(?:"([^"\n]+)"|([^\s]+))\s+(?:"([^"\n]+)"|([^\s]+))$/);
277
+ if (directories.length !== 1 || ![ncRoot, ncRoot + '/app'].includes(directories[0][1]) || launches.length !== 1 ||
278
+ !launch || !canonical(launch[1] || launch[2]) || (launch[3] || launch[4]) !== ncRoot + '/app/launcher.mjs' ||
279
+ Buffer.byteLength(unit) !== serviceArtifact.size || crypto.createHash('sha256').update(unit).digest('hex') !== serviceArtifact.sha256) fail();
280
+ check(path.dirname(ncRoot), true);
281
+ check(ncRoot, true, ncRoot === helperRoot);
282
+ if (![0, selection.ordinary_uid].includes(lstat(ncRoot).uid)) fail();
283
+ check(ncRoot + '/config', true, false);
284
+ if (lstat(ncRoot + '/config').uid !== selection.ordinary_uid) fail();
285
+ const content = read(ncRoot + '/config/nc-client.env', false);
286
+ const ids = content.split(/\r?\n/).filter(line => /^\s*NC_CLIENT_ID\s*=/.test(line));
287
+ 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*$/);
288
+ if (ids.length !== 1 || !clientId || (clientId[1] || clientId[2] || clientId[3]) !== bootstrap.client_id) fail();
289
+ check(ncRoot + '/app/launcher.mjs', false, false);
290
+ return { ncRoot, helperRoot, clientId: bootstrap.client_id, ordinaryUid: selection.ordinary_uid,
291
+ serviceScope: selection.ordinary_service_scope, serviceFile, serviceArtifact };
292
+ } catch { fail(); }
293
+ }
294
+
161
295
  /**
162
- * Tri-state install-tree probe. A root-owned system tree (0750/0700) is
296
+ * Install-tree probe. A root-owned system tree (0750/0700) is
163
297
  * unreadable to a non-root caller: a plain boolean would report "nothing to
164
298
  * remove" for a machine-wide install that clearly exists, and the sudo path
165
299
  * would never be reached. Distinguishes:
166
300
  * - 'install' markers readable → normal removal path
167
301
  * - 'absent' root directory does not exist
168
302
  * - 'foreign' root exists and is traversable but carries no markers
303
+ * - 'paired-helper' protected metadata binds this helper to one ordinary root
169
304
  * - 'unreadable' root exists but markers cannot be stat'ed (EACCES/EPERM)
170
305
  * → needs elevation; the uninstaller's own safety guard
171
306
  * still refuses non-NC trees when run under sudo.
172
307
  * @param {string} root
173
308
  * @param {{ statSync?: (p: string) => { isDirectory(): boolean }, accessSync?: (p: string, mode?: number) => void }} [io]
174
- * @returns {'install'|'absent'|'foreign'|'unreadable'}
309
+ * @returns {'install'|'absent'|'foreign'|'unreadable'|'paired-helper'}
175
310
  */
176
311
  export function probeInstallRoot(root, io = {}) {
177
312
  const statSync = io.statSync || fs.statSync;
@@ -190,25 +325,35 @@ export function probeInstallRoot(root, io = {}) {
190
325
  if (err && (err.code === 'EACCES' || err.code === 'EPERM')) return 'unreadable';
191
326
  }
192
327
  }
328
+ if (os.platform() === 'linux' && root === '/opt/nc' && linuxPairedInstallation(io)) return 'paired-helper';
193
329
  return 'foreign';
194
330
  }
195
331
 
196
332
  /**
197
333
  * Removal targets split by accessibility. `install` roots are removed by the
198
334
  * local flow; `unreadable` roots (system installs seen from a non-root shell)
199
- * are routed to the elevated runUninstallerScript path instead of being
335
+ * report explicit trusted-maintenance requirements instead of being
200
336
  * silently reported as "nothing to remove".
201
337
  * @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, io?: object }} [options]
202
- * @returns {{ install: string[], unreadable: string[] }}
338
+ * @returns {{ install: string[], unreadable: string[], paired?: { ncRoot: string, helperRoot: string, clientId: string }[] }}
203
339
  */
204
340
  export function findRemovalTargets({ io = {}, ...rest } = {}) {
205
341
  const install = [];
206
342
  const unreadable = [];
207
- for (const root of candidateInstallRoots(rest)) {
343
+ const pair = (rest.platform || os.platform()) === 'linux' ? linuxPairedInstallation(io) : null;
344
+ const env = rest.env || process.env;
345
+ 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.');
346
+ const roots = candidateInstallRoots(pair ? { ...rest, env: { ...env, NC_ROOT: undefined } } : rest);
347
+ for (const root of pair ? [...new Set([...roots, pair.ncRoot])] : roots) {
348
+ if (root === pair?.helperRoot && root !== pair.ncRoot) continue;
208
349
  const status = probeInstallRoot(root, io);
209
350
  if (status === 'install') install.push(root);
210
351
  else if (status === 'unreadable') unreadable.push(root);
211
352
  }
353
+ if (pair) {
354
+ if (install.length !== 1 || install[0] !== pair.ncRoot || unreadable.length) throw new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: conflicting paired installation inventory; preserve all roots.');
355
+ return { install, unreadable, paired: [pair] };
356
+ }
212
357
  return { install, unreadable };
213
358
  }
214
359
 
@@ -217,6 +362,7 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
217
362
  * @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, existsSync?: (p: string) => boolean }} [options]
218
363
  */
219
364
  export function findInstallations({ existsSync = fs.existsSync, ...rest } = {}) {
365
+ if ((rest.platform || os.platform()) === 'linux' && linuxPairedInstallation(rest.io || fs)) return findRemovalTargets(rest).install;
220
366
  return candidateInstallRoots(rest).filter((r) => isInstallRoot(r, existsSync));
221
367
  }
222
368
 
@@ -236,12 +382,526 @@ export function getNcRootForMode(mode, { platform = os.platform(), env = process
236
382
  const localAppData = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
237
383
  return path.join(localAppData, 'nc');
238
384
  }
239
- if (mode === 'system') return '/opt/nc';
385
+ if (mode === 'system') return platform === 'darwin' ? '/Library/Application Support/nc' : '/opt/nc';
240
386
  if (platform === 'darwin') return path.join(homedir, 'Library', 'Application Support', 'nc');
241
387
  const dataHome = env.XDG_DATA_HOME || path.join(homedir, '.local', 'share');
242
388
  return path.join(dataHome, 'nc');
243
389
  }
244
390
 
391
+ /**
392
+ * Resolve setup state before enrollment. Markers can prevent setup
393
+ * or preserve a root; they never authorize elevation or another user's mode.
394
+ * An explicit override does not hide an existing canonical tree or service.
395
+ * @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]
396
+ * @returns {{ mode: 'system'|'user', ncRoot: string, existing: boolean, clientId?: string, protectedInstallation?: { ordinaryUid: number, serviceScope: string }, rootManagedPair?: { ordinaryUid: number, serviceScope: string } }}
397
+ */
398
+ export function setupTarget({ platform = os.platform(), mode = decideInstallMode({ platform }), env = process.env, homedir = os.homedir(), uid = process.getuid?.(), io = fs, run = child_process.spawnSync } = {}) {
399
+ const stop = (reason) => { throw new Error(`${platform === 'darwin' ? 'macOS' : platform} setup stopped: ${reason}`); };
400
+ if (platform !== 'win32' && (!Number.isInteger(uid) || uid < 0)) stop('cannot verify the invoking user.');
401
+ const canonicalEnv = { ...env };
402
+ delete canonicalEnv.NC_ROOT;
403
+ const systemRoot = path.resolve(getNcRootForMode('system', { platform, env: canonicalEnv, homedir }));
404
+ const legacySystemRoot = platform === 'darwin' ? '/opt/nc' : null;
405
+ const userRoot = path.resolve(getNcRootForMode('user', { platform, env: canonicalEnv, homedir }));
406
+ if (env.NC_ROOT && !path.isAbsolute(env.NC_ROOT)) stop('NC_ROOT must be an absolute existing or new installation path.');
407
+ const explicitRoot = env.NC_ROOT ? path.resolve(env.NC_ROOT) : null;
408
+ const roots = [...new Set([systemRoot, legacySystemRoot, userRoot, ...(platform === 'linux' ? ['/var/lib/nodecommand/nc'] : []), explicitRoot].filter(Boolean))];
409
+ const pair = platform === 'linux' ? linuxPairedInstallation(io) : null;
410
+ const stat = (file) => {
411
+ try { return io.lstatSync(file); } catch (err) {
412
+ if (err?.code === 'ENOENT') return null;
413
+ stop(`existing installation state is unreadable or unverifiable at ${file}.`);
414
+ }
415
+ };
416
+ const read = (file) => {
417
+ const info = stat(file);
418
+ if (!info) return null;
419
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 65536) stop('installation metadata is ambiguous.');
420
+ try {
421
+ const content = io.readFileSync(file, { encoding: 'utf8', flag: fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW });
422
+ if (Buffer.byteLength(content) > 65536) stop('installation metadata exceeds the supported bound.');
423
+ return content;
424
+ } catch { stop('existing installation metadata cannot be read safely.'); }
425
+ };
426
+ const installs = [];
427
+ for (const root of roots) {
428
+ if (root === pair?.helperRoot && root !== pair.ncRoot) continue;
429
+ const info = stat(root);
430
+ if (!info) continue;
431
+ if (!info.isDirectory() || info.isSymbolicLink()) stop('an installation root is not a regular directory.');
432
+ const config = stat(path.join(root, 'config'));
433
+ if (!config?.isDirectory() || config.isSymbolicLink()) stop('an existing root has no verifiable NC configuration.');
434
+ const content = read(path.join(root, 'config', 'nc-client.env'));
435
+ if (content === null) stop('an existing root has no verifiable NC configuration.');
436
+ const entries = content.split(/\r?\n/).filter(line => /^\s*NC_SERVICE_MODE\s*=/.test(line));
437
+ const match = entries[0]?.match(/^\s*NC_SERVICE_MODE\s*=\s*(?:"(user|system)"|'(user|system)'|(user|system))\s*$/);
438
+ if (entries.length > 1 || (entries.length && !match)) stop('stored installation mode is ambiguous.');
439
+ const storedMode = match ? match[1] || match[2] || match[3] : null;
440
+ // Legacy canonical trees retain their installer ownership/layout. Linux
441
+ // custom trees can also identify their mode by owner; other custom trees
442
+ // require the persisted mode rather than guessing from current privilege.
443
+ const detectedMode = storedMode || (root === pair?.ncRoot ? info.uid === 0 ? 'system' : info.uid === uid && uid !== 0 ? 'user' : null : platform === 'win32'
444
+ ? root === userRoot ? 'user' : root === systemRoot ? 'system' : null
445
+ : root === userRoot && info.uid === uid && uid !== 0 ? 'user' : (root === systemRoot || root === legacySystemRoot) && info.uid === 0 ? 'system'
446
+ : platform === 'linux' && root === explicitRoot ? info.uid === 0 ? 'system' : info.uid === uid ? 'user' : null : null);
447
+ // Root may verify this independently selected restricted pair, not adopt
448
+ // its user identity or execute a user installer. Management mode stays system.
449
+ const rootManagedPair = root === pair?.ncRoot && uid === 0 && process.getuid?.() === 0 && process.geteuid?.() === 0 && mode === 'system' &&
450
+ storedMode === 'user' && pair.serviceScope === 'user' && info.uid === pair.ordinaryUid && config.uid === pair.ordinaryUid;
451
+ 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}.`);
452
+ if (((root === systemRoot || root === legacySystemRoot) && detectedMode !== 'system') || (root === userRoot && detectedMode !== 'user')) stop(`stored mode conflicts with the installation root ${root}.`);
453
+ for (const name of ['NC_CLIENT_ID', 'NC_CLIENT_TOKEN', 'NC_SERVER_URL']) {
454
+ if (content.split(/\r?\n/).filter(line => new RegExp(`^\\s*${name}\\s*=`).test(line)).length > 1) stop(`duplicate host metadata at ${root}.`);
455
+ }
456
+ installs.push({ mode: detectedMode, ncRoot: root, rootManagedPair });
457
+ }
458
+ const services = [];
459
+ for (const [file, serviceMode, owner] of platform === 'darwin' ? [
460
+ ['/Library/LaunchDaemons/com.nc.client.plist', 'system', 0],
461
+ [path.join(homedir, 'Library', 'LaunchAgents', 'com.nc.client.plist'), 'user', uid]
462
+ ] : []) {
463
+ const info = stat(file);
464
+ if (!info) continue;
465
+ const content = read(file);
466
+ const labels = [...content.matchAll(/<key>Label<\/key>\s*<string>([^<]*)<\/string>/g)];
467
+ const directories = [...content.matchAll(/<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/g)];
468
+ if (info.uid !== owner || labels.length !== 1 || labels[0][1] !== 'com.nc.client' || directories.length !== 1) stop('existing service metadata is ambiguous.');
469
+ const directory = directories[0][1].replace(/&(amp|lt|gt|quot|apos);/g, (_, entity) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" })[entity]);
470
+ if (!path.isAbsolute(directory) || path.basename(directory) !== 'app') stop('existing service root cannot be verified.');
471
+ const serviceRoot = path.dirname(path.resolve(directory));
472
+ 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.');
473
+ services.push(serviceRoot);
474
+ }
475
+ if (platform === 'linux') {
476
+ for (const [file, serviceMode] of [
477
+ ['/etc/systemd/system/nc-client.service', 'system'],
478
+ ['/etc/systemd/user/nc-client.service', 'user'],
479
+ [path.join(homedir, '.config/systemd/user/nc-client.service'), 'user'],
480
+ ...(env.XDG_CONFIG_HOME ? [[path.join(env.XDG_CONFIG_HOME, 'systemd/user/nc-client.service'), 'user']] : [])
481
+ ]) {
482
+ const content = read(file);
483
+ if (content === null) continue;
484
+ if (pair && file !== pair.serviceFile) stop(`another ordinary service exists at ${file}; preserve the paired installation through explicit Root maintenance.`);
485
+ const directories = [...content.matchAll(/^\s*WorkingDirectory\s*=\s*(.+?)\s*$/gm)];
486
+ const directory = directories[0]?.[1];
487
+ if (directories.length !== 1 || !path.isAbsolute(directory)) stop(`existing service metadata is ambiguous at ${file}.`);
488
+ const pairedService = pair && file === pair.serviceFile;
489
+ const serviceRoot = pairedService && directory === pair.ncRoot ? pair.ncRoot : path.basename(directory) === 'app' ? path.dirname(path.resolve(directory)) : null;
490
+ if (pairedService) {
491
+ 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.`);
492
+ }
493
+ 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.`);
494
+ services.push(serviceRoot);
495
+ }
496
+ if (pair && !services.includes(pair.ncRoot)) stop('paired ordinary service is missing; explicit Root maintenance is required.');
497
+ }
498
+ if (platform === 'win32') {
499
+ // Discovery only: never start/stop tasks or services to identify a target.
500
+ 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}";
501
+ let state;
502
+ try {
503
+ const result = run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', timeout: 10000, maxBuffer: 65536, windowsHide: true });
504
+ if (result.status !== 0) stop('Windows service/task state is unreadable; inspect NCClient and the NC Client task through administrator maintenance.');
505
+ state = JSON.parse(String(result.stdout));
506
+ if (!Array.isArray(state.services) || !Array.isArray(state.tasks)) throw new Error('invalid service state');
507
+ } catch { stop('Windows service/task state cannot be verified; inspect NCClient and the NC Client task through administrator maintenance.'); }
508
+ for (const service of state.services) {
509
+ const executable = service.PathName?.match(/^"([^"]+\\service\\nc-service\.exe)"\s/i)?.[1];
510
+ const root = executable && path.resolve(path.dirname(path.dirname(executable)));
511
+ 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.');
512
+ services.push(root);
513
+ }
514
+ for (const task of state.tasks) {
515
+ const root = typeof task.WorkingDirectory === 'string' && path.isAbsolute(task.WorkingDirectory) ? path.resolve(task.WorkingDirectory) : null;
516
+ 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.');
517
+ services.push(root);
518
+ }
519
+ }
520
+ 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.`);
521
+ const existing = installs[0];
522
+ if (existing && explicitRoot && explicitRoot !== existing.ncRoot) stop('NC_ROOT would replace an existing installation root.');
523
+ 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.');
524
+ 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.');
525
+ return { mode, ncRoot: existing?.ncRoot || explicitRoot || (mode === 'system' ? systemRoot : userRoot), existing: !!existing,
526
+ ...(pair ? { clientId: pair.clientId, protectedInstallation: { ordinaryUid: pair.ordinaryUid, serviceScope: pair.serviceScope } } : {}),
527
+ ...(existing?.rootManagedPair ? { rootManagedPair: { ordinaryUid: pair.ordinaryUid, serviceScope: pair.serviceScope } } : {}) };
528
+ }
529
+
530
+ /** Preserve the macOS entry used by existing callers and fixtures. */
531
+ export function macosSetupTarget({ uid = process.getuid?.(), ...options } = {}) {
532
+ return setupTarget({ ...options, platform: 'darwin', uid, mode: uid === 0 ? 'system' : 'user' });
533
+ }
534
+
535
+ /** Parse installation metadata as bounded data, NEVER as a root shell script. */
536
+ export function parseMaintenanceEnv(text) {
537
+ if (Buffer.byteLength(text) > 65536) throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
538
+ const result = Object.create(null);
539
+ for (const line of text.split(/\r?\n/)) {
540
+ if (!line.trim() || /^\s*#/.test(line)) continue;
541
+ const match = line.match(/^(NC_[A-Z0-9_]+)=(.*)$/);
542
+ if (!match || Object.hasOwn(result, match[1]) || /[\x00-\x1f]/.test(match[2])) throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
543
+ let value = match[2];
544
+ if (/^["']/.test(value)) {
545
+ if (value.at(-1) !== value[0]) throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
546
+ value = value.slice(1, -1);
547
+ }
548
+ result[match[1]] = value;
549
+ }
550
+ if (!/^nc_[0-9a-f]{64}$/.test(result.NC_CLIENT_ID || '') || !result.NC_CLIENT_TOKEN ||
551
+ new URL(result.NC_SERVER_URL).protocol !== 'https:') throw new Error('IDENTITY_MAINTENANCE_CONFIG_INVALID');
552
+ return result;
553
+ }
554
+
555
+ /** Import only an already provisioned root-protected producer. Neither npx
556
+ * ordinary cache code nor an agent-writable helper module may hold ACK keys.
557
+ */
558
+ export async function loadProtectedHelperProducer(root) {
559
+ if (process.geteuid?.() !== 0 || !path.isAbsolute(root) || process.env.NODE_OPTIONS || process.env.NODE_PATH)
560
+ throw new Error('PRIVILEGED_HELPER_ROOT_MAINTENANCE_REQUIRED');
561
+ const check = file => {
562
+ for (let p = file; ; p = path.dirname(p)) {
563
+ const st = fs.lstatSync(p);
564
+ if (st.isSymbolicLink() || st.uid !== 0 || (st.mode & 0o022)) throw new Error('PRIVILEGED_HELPER_UNPROTECTED_PATH');
565
+ if (path.dirname(p) === p) break;
566
+ }
567
+ };
568
+ check(fs.realpathSync(process.execPath)); check(fileURLToPath(import.meta.url)); check(fs.realpathSync(process.argv[1]));
569
+ const app = path.join(root, 'libexec/privileged-helper');
570
+ const visit = file => {
571
+ check(file); const st = fs.lstatSync(file);
572
+ if (st.isDirectory()) for (const name of fs.readdirSync(file)) visit(path.join(file, name));
573
+ else if (!st.isFile()) throw new Error('PRIVILEGED_HELPER_UNPROTECTED_PATH');
574
+ };
575
+ visit(app);
576
+ return import(pathToFileURL(path.join(app, 'privileged-helper-provision.mjs')).href);
577
+ }
578
+
579
+ /**
580
+ * Selected Linux identity maintenance. All OS mutation is behind complete
581
+ * target/identity/rollback preflight. The caller is explicitly privileged;
582
+ * verifyReconnection must independently confirm the same target in normal
583
+ * authenticated management, not merely accept a local agent status claim.
584
+ * home/io/run are injectable solely for the existing disposable fixtures.
585
+ */
586
+ export async function maintainLinuxIdentity({ ncRoot, hostId, operatorUser, authorizeMaintenance, verifyReconnection,
587
+ home = '/var/lib/nodecommand', systemUnit = '/etc/systemd/system/nc-client.service', io = fs, run = child_process.spawnSync, uid = process.geteuid?.() } = {}) {
588
+ if (uid !== 0 || os.platform() !== 'linux') throw new Error('IDENTITY_MAINTENANCE_ROOT_REQUIRED');
589
+ const fail = reason => { throw new Error(`IDENTITY_MAINTENANCE_REFUSED:${reason}`); };
590
+ if (!/^nc_[0-9a-f]{64}$/.test(hostId || '') || !ncRoot || path.resolve(ncRoot) !== ncRoot ||
591
+ !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');
592
+ const command = (exe, args, { optional = false } = {}) => {
593
+ const result = run(exe, args, { encoding: 'utf8', shell: false, timeout: 60000, maxBuffer: 65536,
594
+ env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' } });
595
+ 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`); }
596
+ return String(result.stdout || '');
597
+ };
598
+ const exists = file => { try { return io.lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
599
+ const directoryFd = directory => {
600
+ let fd = io.openSync('/', fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
601
+ try {
602
+ for (const component of path.resolve(directory).split('/').filter(Boolean)) {
603
+ const next = io.openSync(`/proc/self/fd/${fd}/${component}`, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
604
+ io.closeSync(fd); fd = next;
605
+ }
606
+ return fd;
607
+ } catch (error) { io.closeSync(fd); throw error; }
608
+ };
609
+ const read = file => {
610
+ const parent = directoryFd(path.dirname(file));
611
+ let fd;
612
+ try { fd = io.openSync(`/proc/self/fd/${parent}/${path.basename(file)}`, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); }
613
+ finally { io.closeSync(parent); }
614
+ try { const st = io.fstatSync(fd); if (!st.isFile() || st.size > 65536) fail('unsafe installation metadata'); return io.readFileSync(fd, 'utf8'); }
615
+ finally { io.closeSync(fd); }
616
+ };
617
+ const write = (file, text, mode) => {
618
+ const parent = directoryFd(path.dirname(file)); let fd;
619
+ try {
620
+ fd = io.openSync(`/proc/self/fd/${parent}/${path.basename(file)}`, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, mode);
621
+ const st = io.fstatSync(fd);
622
+ if (!st.isFile() || st.nlink !== 1) fail('maintenance destination is not a single regular file');
623
+ io.ftruncateSync(fd, 0); io.writeFileSync(fd, text); io.fsyncSync(fd);
624
+ } finally { if (fd !== undefined) io.closeSync(fd); io.closeSync(parent); }
625
+ };
626
+ const envPath = path.join(ncRoot, 'config/nc-client.env');
627
+ const envText = read(envPath), env = parseMaintenanceEnv(envText);
628
+ if (env.NC_CLIENT_ID !== hostId) fail('host ID mismatch; no token was consumed');
629
+ const identity = root => {
630
+ const data = parseMaintenanceEnv(read(path.join(root, 'config/nc-client.env')));
631
+ const key = crypto.createPrivateKey(read(path.join(root, 'config/nc-device-key.pem')));
632
+ return { client_id: data.NC_CLIENT_ID, server_url: data.NC_SERVER_URL,
633
+ device_key_id: crypto.createHash('sha256').update(crypto.createPublicKey(key).export({ type: 'spki', format: 'der' })).digest('hex') };
634
+ };
635
+ const protectedDirectory = directory => {
636
+ for (let current = directory; ; current = path.dirname(current)) {
637
+ const st = io.lstatSync(current);
638
+ if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== 0 || (st.mode & 0o022)) fail('maintenance/rollback ancestry is not root-protected');
639
+ if (path.dirname(current) === current) break;
640
+ }
641
+ };
642
+ const rootInfo = io.lstatSync(ncRoot);
643
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) fail('source root is not a directory');
644
+ const loginMove = env.NC_SERVICE_MODE === 'user';
645
+ const oldUser = loginMove ? operatorUser : '_nc';
646
+ if (!/^[a-z_][a-z0-9_-]*[$]?$/.test(oldUser || '') || oldUser === 'root' || oldUser === 'nodecommand') fail('select the existing ordinary account explicitly');
647
+ const account = command('/usr/bin/getent', ['passwd', oldUser]).trim().split(':');
648
+ if (account.length !== 7 || account[0] !== oldUser || !/^\d+$/.test(account[2]) || !/^\d+$/.test(account[3])) fail('account inventory is ambiguous');
649
+ const oldUid = Number(account[2]), oldGid = Number(account[3]), oldHome = account[5];
650
+ if (oldUid === 0 || oldGid === 0 || (!loginMove && !/\/(?:nologin|false)$/.test(account[6]))) fail('selected account is not a dedicated non-root identity');
651
+ if (command('/usr/bin/getent', ['passwd', 'nodecommand'], { optional: true }) !== null ||
652
+ command('/usr/bin/getent', ['group', 'nodecommand'], { optional: true }) !== null) fail('nodecommand already exists; never merge an unrelated identity');
653
+ if (loginMove && (ncRoot === '/opt/nc' || !ncRoot.startsWith(oldHome + '/') || rootInfo.uid !== oldUid)) fail('the selected user tree does not belong to the selected login identity');
654
+ if (!loginMove && rootInfo.uid !== 0) fail('legacy dedicated system root is not protected');
655
+ const keyFdParent = directoryFd(path.join(ncRoot, 'config'));
656
+ let keyFd, selectedPrivateKey;
657
+ try {
658
+ keyFd = io.openSync(`/proc/self/fd/${keyFdParent}/nc-device-key.pem`, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
659
+ const st = io.fstatSync(keyFd);
660
+ if (!st.isFile() || st.uid !== oldUid || st.size > 65536) fail('device key is not owned by the selected ordinary identity');
661
+ selectedPrivateKey = crypto.createPrivateKey(io.readFileSync(keyFd, 'utf8'));
662
+ } finally { if (keyFd !== undefined) io.closeSync(keyFd); io.closeSync(keyFdParent); }
663
+ const originalIdentity = { client_id: env.NC_CLIENT_ID, server_url: env.NC_SERVER_URL,
664
+ device_key_id: crypto.createHash('sha256').update(crypto.createPublicKey(selectedPrivateKey).export({ type: 'spki', format: 'der' })).digest('hex') };
665
+ if (!loginMove) protectedDirectory(ncRoot);
666
+ protectedDirectory(path.dirname(home));
667
+ const unit = loginMove ? path.join(oldHome, '.config/systemd/user/nc-client.service') : systemUnit;
668
+ if (!loginMove) {
669
+ protectedDirectory(path.dirname(unit));
670
+ const st = io.lstatSync(unit);
671
+ if (!st.isFile() || st.isSymbolicLink() || st.uid !== 0 || st.nlink !== 1 || (st.mode & 0o022)) fail('selected system unit is not root-protected');
672
+ }
673
+ const unitText = read(unit);
674
+ if (!unitText.split(/\r?\n/).includes(`WorkingDirectory=${ncRoot}/app`) ||
675
+ (!loginMove && (!unitText.split(/\r?\n/).includes('User=_nc') || !unitText.split(/\r?\n/).includes('Group=_nc')))) fail('selected service does not match this root/account');
676
+ if (loginMove && exists(systemUnit)) {
677
+ // A held global instance is inventory only. Never stop, adopt or rewrite it.
678
+ if (read(systemUnit).includes(`WorkingDirectory=${ncRoot}/app`)) fail('both managers claim this selected tree; repair the ambiguity first');
679
+ }
680
+ if (!loginMove) {
681
+ const aliases = command('/usr/bin/getent', ['passwd']).trim().split('\n').filter(line => Number(line.split(':')[2]) === oldUid);
682
+ if (aliases.length !== 1 || aliases[0].split(':')[0] !== '_nc') fail('legacy UID is shared; explicit identity reconciliation is required');
683
+ if (/^\s*(?:AmbientCapabilities|SupplementaryGroups|CapabilityBoundingSet)\s*=\s*\S/m.test(unitText)) fail('legacy service has additional privilege channels; repair explicitly first');
684
+ const group = command('/usr/bin/getent', ['group', '_nc']).trim().split(':');
685
+ if (Number(group[2]) !== oldGid) fail('legacy primary group mismatch');
686
+ const groupAliases = command('/usr/bin/getent', ['group']).trim().split('\n').filter(line => Number(line.split(':')[2]) === oldGid);
687
+ if (groupAliases.length !== 1 || groupAliases[0].split(':')[0] !== '_nc') fail('legacy GID is shared; no identity rename performed');
688
+ const journal = command('/usr/bin/getent', ['group', 'systemd-journal'], { optional: true });
689
+ const allowed = [oldGid, ...(journal ? [Number(journal.trim().split(':')[2])] : [])];
690
+ if (command('/usr/bin/id', ['-G', '_nc']).trim().split(/\s+/).some(gid => !allowed.includes(Number(gid)))) fail('legacy account has unapproved supplementary authority');
691
+ }
692
+ const helperEdits = [];
693
+ if (!loginMove && exists('/etc/systemd/system/nc-privileged-helper.socket')) {
694
+ const helperService = read('/etc/systemd/system/nc-privileged-helper.service');
695
+ if (!helperService.includes(`${ncRoot}/libexec/privileged-helper/privileged-helper-linux-daemon.mjs`)) fail('helper resources belong to another or unprotected installation');
696
+ const helperConfigPath = path.join(ncRoot, 'libexec/privileged-helper/privileged-helper.json');
697
+ protectedDirectory(path.dirname(helperConfigPath));
698
+ const helperConfig = JSON.parse(read(helperConfigPath));
699
+ if (helperConfig.client_id !== hostId || helperConfig.service_uid !== oldUid || helperConfig.service_gid !== oldGid) fail('helper peer binding is not this selected identity');
700
+ for (const file of ['/etc/systemd/system/nc-privileged-helper.socket', '/etc/tmpfiles.d/nc-privileged-helper.conf']) {
701
+ if (exists(file)) {
702
+ protectedDirectory(path.dirname(file));
703
+ const text = read(file);
704
+ 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') });
705
+ }
706
+ }
707
+ }
708
+ const destination = loginMove ? path.join(home, 'nc') : ncRoot;
709
+ if (exists(home)) fail('restricted home/destination already occupied');
710
+ if (exists(unit + '.identity-backup')) fail('an old service recovery copy is already present');
711
+ if (loginMove && exists(envPath + '.identity-backup')) fail('an old configuration recovery copy is already present');
712
+ const runtimeMatch = unitText.match(/^ExecStart=(?:"([^"\n]+)"|([^\s]+))\s+(?:"([^"\n]+)"|([^\s]+))\s*$/m);
713
+ const oldNode = runtimeMatch?.[1] || runtimeMatch?.[2];
714
+ if (!oldNode || (runtimeMatch[3] || runtimeMatch[4]) !== `${ncRoot}/app/launcher.mjs` || !path.isAbsolute(oldNode)) fail('runtime launch cannot be preserved safely');
715
+ const node = loginMove && oldNode.startsWith(ncRoot + '/') ? destination + oldNode.slice(ncRoot.length) : oldNode;
716
+ if (loginMove && oldNode.startsWith(oldHome + '/') && !oldNode.startsWith(ncRoot + '/')) fail('runtime belongs to the former login home; select an isolated runtime before migration');
717
+ const sudo = run('/usr/bin/sudo', ['-n', '-l', '-U', oldUser], { encoding: 'utf8', shell: false, timeout: 10000,
718
+ env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' } });
719
+ if (!loginMove && !sudo.error && (sudo.status === 0 || sudo.status !== 1)) fail('legacy account has sudo authority or its sudo rules cannot be verified');
720
+ if (!loginMove && sudo.error && sudo.error.code !== 'ENOENT') fail('legacy sudo authority is unknown');
721
+ const manager = (user, userId, userHome, args) => command('/usr/sbin/runuser', ['-u', user, '--', '/usr/bin/env', '-i',
722
+ 'PATH=/usr/sbin:/usr/bin:/sbin:/bin', `HOME=${userHome}`, `XDG_RUNTIME_DIR=/run/user/${userId}`,
723
+ `DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${userId}/bus`, '/usr/bin/systemctl', '--user', ...args]);
724
+ const oldManager = args => loginMove ? manager(oldUser, oldUid, oldHome, args) : command('/usr/bin/systemctl', args);
725
+ oldManager(['is-active', 'nc-client.service']);
726
+ // Check all rollback tools and manager reachability BEFORE stopping anything.
727
+ 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']);
728
+ if (loginMove) {
729
+ const before = command('/usr/bin/loginctl', ['show-user', oldUser, '--property=Linger', '--value']).trim();
730
+ if (!['yes', 'no'].includes(before)) fail('old linger inventory is unknown');
731
+ } else if (exists(`/var/lib/systemd/linger/${oldUser}`)) fail('legacy user-manager/linger requires selected reconciliation before rename');
732
+ if (await authorizeMaintenance({ ...originalIdentity, ncRoot, old_user: oldUser, uid: oldUid, gid: oldGid,
733
+ privateKey: selectedPrivateKey, hostToken: env.NC_CLIENT_TOKEN }) !== true) fail('selected target maintenance was not authorized; held installations remain untouched');
734
+ const recovery = loginMove ? home : path.join(ncRoot, 'libexec');
735
+ if (!loginMove) {
736
+ io.mkdirSync(recovery, { recursive: true, mode: 0o755 });
737
+ const st = io.lstatSync(recovery); if (st.uid !== 0 || st.isSymbolicLink() || (st.mode & 0o022)) fail('rollback directory is not protected');
738
+ } else io.mkdirSync(home, { mode: 0o750 });
739
+ const journalPath = path.join(recovery, '.identity-maintenance.json');
740
+ if (exists(journalPath)) fail('an earlier identity maintenance needs recovery first');
741
+ const recoveryRecord = { host_id: hostId, source: ncRoot, destination, old_user: oldUser,
742
+ old_home: oldHome, uid: oldUid, gid: oldGid, identity: originalIdentity, unit, original_unit: unitText, original_env: envText, helper_edits: helperEdits };
743
+ const recordRecovery = (record, initial = false) => {
744
+ const target = initial ? journalPath : journalPath + '.next';
745
+ const fd = io.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
746
+ try { io.writeFileSync(fd, JSON.stringify(record) + '\n'); io.fsyncSync(fd); } finally { io.closeSync(fd); }
747
+ if (!initial) io.renameSync(target, journalPath);
748
+ const directory = directoryFd(recovery);
749
+ try { io.fsyncSync(directory); } finally { io.closeSync(directory); }
750
+ };
751
+ recordRecovery({ ...recoveryRecord, state: 'prepared' }, true);
752
+ let stopped = false, accountCreated = false, groupCreated = false, renamedUser = false, renamedGroup = false, newStarted = false, managerStarted = false, retiredUnit = false, retiredEnv = false, changedHome = false;
753
+ const newUnit = loginMove ? path.join(home, '.config/systemd/user/nc-client.service') : unit;
754
+ let newUid = oldUid, newGid = oldGid;
755
+ try {
756
+ stopped = true; oldManager(['stop', 'nc-client.service']);
757
+ if (JSON.stringify(identity(ncRoot)) !== JSON.stringify(originalIdentity)) fail('source identity changed during preflight');
758
+ if (loginMove) {
759
+ // Open every source component with O_NOFOLLOW; root never follows a
760
+ // login-user replacement/symlink into another home or credential file.
761
+ const copy = (sourceFd, target, relative = '') => {
762
+ io.mkdirSync(target, { mode: 0o700 });
763
+ for (const name of io.readdirSync(`/proc/self/fd/${sourceFd}`)) {
764
+ if (!relative && !['app', 'config', 'data', 'logs', 'releases', 'backups', 'tmp', 'tools', 'install.sh', 'uninstall.sh'].includes(name)) continue;
765
+ const source = `/proc/self/fd/${sourceFd}/${name}`, dest = path.join(target, name);
766
+ const st = io.lstatSync(source);
767
+ if (st.isSymbolicLink()) {
768
+ const link = io.readlinkSync(source);
769
+ if (path.isAbsolute(link) || !path.resolve(ncRoot, relative, link).startsWith(ncRoot + '/')) fail('external source symlink; explicitly export the selected dependency first');
770
+ io.symlinkSync(link, dest); continue;
771
+ }
772
+ const fd = io.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
773
+ try {
774
+ const before = io.fstatSync(fd);
775
+ if (before.uid !== oldUid && !(before.uid === 0 && (before.mode & 0o004))) fail('unrelated private source ownership');
776
+ if (before.isDirectory()) copy(fd, dest, path.join(relative, name));
777
+ else if (before.isFile()) {
778
+ const output = io.openSync(dest, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, before.mode & 0o777);
779
+ try {
780
+ const buffer = Buffer.alloc(65536); let count;
781
+ while ((count = io.readSync(fd, buffer, 0, buffer.length, null)) > 0) {
782
+ let offset = 0; while (offset < count) offset += io.writeSync(output, buffer, offset, count - offset);
783
+ }
784
+ } finally { io.closeSync(output); }
785
+ const after = io.fstatSync(fd);
786
+ if (before.size !== after.size || before.ctimeMs !== after.ctimeMs) fail('source changed while copying');
787
+ } else fail('special files cannot be relocated');
788
+ } finally { io.closeSync(fd); }
789
+ }
790
+ };
791
+ const sourceFd = directoryFd(ncRoot);
792
+ const pinned = io.fstatSync(sourceFd);
793
+ if (pinned.ino !== rootInfo.ino || pinned.dev !== rootInfo.dev) { io.closeSync(sourceFd); fail('selected source root was replaced'); }
794
+ try { copy(sourceFd, destination); } finally { io.closeSync(sourceFd); }
795
+ if (JSON.stringify(identity(destination)) !== JSON.stringify(originalIdentity)) fail('relocation identity mismatch');
796
+ command('/usr/sbin/groupadd', ['--system', 'nodecommand']); groupCreated = true;
797
+ command('/usr/sbin/useradd', ['--system', '--gid', 'nodecommand', '--home-dir', home, '--shell', '/usr/sbin/nologin', 'nodecommand']); accountCreated = true;
798
+ const created = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
799
+ newUid = Number(created[2]); newGid = Number(created[3]);
800
+ if (!Number.isSafeInteger(newUid) || newUid <= 0 || !Number.isSafeInteger(newGid) || newGid <= 0 || created[5] !== home) fail('new account inventory mismatch');
801
+ const rewritten = { ...parseMaintenanceEnv(read(path.join(destination, 'config/nc-client.env'))), NC_SERVICE_MODE: 'user',
802
+ NC_ROOT: destination, NC_SERVICE_UNIT: 'nc-client.service', NC_OPERATOR_USER: 'nodecommand', NC_SERVICE_USER: 'nodecommand', NC_SERVICE_GROUP: 'nodecommand' };
803
+ for (const name of ['NC_ENV_FILE', 'NC_LOG_FILE', 'NC_DEVICE_KEY_FILE', 'NC_NODE_BIN']) {
804
+ if (rewritten[name]?.startsWith(ncRoot + '/')) rewritten[name] = destination + rewritten[name].slice(ncRoot.length);
805
+ }
806
+ // Keep server, host, token, device key, private runtime and policy roots.
807
+ write(path.join(destination, 'config/nc-client.env'), Object.entries(rewritten).map(([key, value]) => `${key}=${value}`).join('\n') + '\n', 0o600);
808
+ const own = dir => { for (const entry of io.readdirSync(dir)) { const file = path.join(dir, entry); const st = io.lstatSync(file);
809
+ if (st.isSymbolicLink()) { const actual = io.realpathSync(file); if (!actual.startsWith(destination + '/')) fail('relocated symlink escapes installation'); io.lchownSync(file, newUid, newGid); }
810
+ else { if (st.isDirectory()) own(file); io.chownSync(file, newUid, newGid); } } };
811
+ own(destination); io.chownSync(destination, newUid, newGid);
812
+ io.chownSync(home, 0, newGid);
813
+ io.mkdirSync(path.join(home, 'workspace'), { mode: 0o700 }); io.chownSync(path.join(home, 'workspace'), newUid, newGid);
814
+ for (const relative of ['.config', '.config/systemd', '.config/systemd/user', '.cache', '.local', '.local/share']) {
815
+ const directory = path.join(home, relative); io.mkdirSync(directory, { mode: 0o700 }); io.chownSync(directory, newUid, newGid);
816
+ }
817
+ 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 });
818
+ io.chownSync(newUnit, newUid, newGid);
819
+ 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']) {
820
+ if (!exists(socket)) continue;
821
+ const access = run('/usr/sbin/runuser', ['-u', 'nodecommand', '--', '/usr/bin/test', '-w', socket], {
822
+ encoding: 'utf8', shell: false, timeout: 10000, env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin' } });
823
+ if (access.error || access.status !== 1) fail('restricted identity can access a root-equivalent IPC endpoint or its access cannot be verified');
824
+ }
825
+ command('/usr/bin/loginctl', ['enable-linger', 'nodecommand']);
826
+ managerStarted = true;
827
+ command('/usr/bin/systemctl', ['start', `user@${newUid}.service`]);
828
+ } else {
829
+ const processes = run('/usr/bin/ps', ['-u', String(oldUid), '-o', 'pid='], { encoding: 'utf8', shell: false, timeout: 10000,
830
+ env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' } });
831
+ 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');
832
+ command('/usr/sbin/usermod', ['--login', 'nodecommand', '_nc']); renamedUser = true;
833
+ command('/usr/sbin/groupmod', ['--new-name', 'nodecommand', '_nc']); renamedGroup = true;
834
+ const renamed = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
835
+ if (renamed[0] !== 'nodecommand' || Number(renamed[2]) !== oldUid || Number(renamed[3]) !== oldGid ||
836
+ Number(command('/usr/bin/getent', ['group', 'nodecommand']).trim().split(':')[2]) !== oldGid) fail('account rename did not preserve UID/GID');
837
+ io.mkdirSync(home, { mode: 0o750 }); changedHome = true; io.chownSync(home, 0, oldGid);
838
+ // Change only passwd home metadata; never move an arbitrary former
839
+ // account home or recursively change its files/permissions.
840
+ command('/usr/sbin/usermod', ['--home', home, 'nodecommand']);
841
+ const relocated = command('/usr/bin/getent', ['passwd', 'nodecommand']).trim().split(':');
842
+ if (relocated[5] !== home || Number(relocated[2]) !== oldUid || Number(relocated[3]) !== oldGid) fail('new home metadata or stable UID/GID could not be verified');
843
+ for (const relative of ['workspace', '.config', '.config/systemd', '.config/systemd/user', '.cache', '.local', '.local/share']) {
844
+ const directory = path.join(home, relative); io.mkdirSync(directory, { mode: 0o700 }); io.chownSync(directory, oldUid, oldGid);
845
+ }
846
+ // Regenerate the ordinary service rather than retaining root-capable
847
+ // ExecStartPre/credential hooks or an old login environment.
848
+ 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);
849
+ write(envPath, envText.replace(/^(NC_SERVICE_USER|NC_SERVICE_GROUP|NC_OPERATOR_USER)=(?:"_nc"|'_nc'|_nc)$/gm, '$1=nodecommand'), 0o600);
850
+ for (const edit of helperEdits) write(edit.file, edit.replacement, 0o644);
851
+ command('/usr/bin/loginctl', ['enable-linger', 'nodecommand']);
852
+ managerStarted = true;
853
+ command('/usr/bin/systemctl', ['start', `user@${newUid}.service`]);
854
+ }
855
+ const newManager = args => loginMove ? manager('nodecommand', newUid, home, args) : command('/usr/bin/systemctl', args);
856
+ newManager(['daemon-reload']); newStarted = true; newManager(['enable', '--now', 'nc-client.service']);
857
+ newManager(['is-active', 'nc-client.service']);
858
+ const currentIdentity = identity(destination);
859
+ if (JSON.stringify(currentIdentity) !== JSON.stringify(originalIdentity) || await verifyReconnection({ ...currentIdentity, ncRoot: destination, service_user: 'nodecommand', uid: newUid, gid: newGid,
860
+ privateKey: selectedPrivateKey, hostToken: env.NC_CLIENT_TOKEN }) !== true) fail('same-host reconnection not independently confirmed');
861
+ if (loginMove) {
862
+ oldManager(['disable', 'nc-client.service']);
863
+ const retire = (file, text) => {
864
+ const parent = directoryFd(path.dirname(file)); let fd;
865
+ try {
866
+ 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);
867
+ io.writeFileSync(fd, text); io.fsyncSync(fd);
868
+ io.unlinkSync(`/proc/self/fd/${parent}/${path.basename(file)}`);
869
+ } finally { if (fd !== undefined) io.closeSync(fd); io.closeSync(parent); }
870
+ };
871
+ retire(unit, unitText); retiredUnit = true;
872
+ retire(envPath, envText); retiredEnv = true;
873
+ }
874
+ recordRecovery({ ...recoveryRecord, state: 'verified', new_uid: newUid, new_gid: newGid });
875
+ return { ...currentIdentity, ncRoot: destination, service_user: 'nodecommand', uid: newUid, gid: newGid,
876
+ workspace: path.join(home, 'workspace'), helper_readiness: 'unavailable', existing_permission: 'preserved', rollback_journal: journalPath };
877
+ } catch (error) {
878
+ try {
879
+ if (newStarted) {
880
+ if (loginMove) manager('nodecommand', newUid, home, ['disable', '--now', 'nc-client.service']);
881
+ else command('/usr/bin/systemctl', ['stop', 'nc-client.service']);
882
+ }
883
+ if (loginMove) {
884
+ if (accountCreated) { command('/usr/bin/loginctl', ['disable-linger', 'nodecommand']); command('/usr/bin/systemctl', ['stop', `user@${newUid}.service`]); command('/usr/sbin/userdel', ['nodecommand']); }
885
+ if (groupCreated) command('/usr/sbin/groupdel', ['nodecommand']);
886
+ io.rmSync(home, { recursive: true });
887
+ if (retiredUnit) { write(unit, unitText, 0o600); io.chownSync(unit, oldUid, oldGid); io.unlinkSync(unit + '.identity-backup'); }
888
+ if (retiredEnv) { write(envPath, envText, 0o600); io.chownSync(envPath, oldUid, oldGid); io.unlinkSync(envPath + '.identity-backup'); }
889
+ } else {
890
+ if (managerStarted) { command('/usr/bin/loginctl', ['disable-linger', 'nodecommand']); command('/usr/bin/systemctl', ['stop', `user@${oldUid}.service`]); }
891
+ if (changedHome) { command('/usr/sbin/usermod', ['--home', oldHome, 'nodecommand']); io.rmSync(home, { recursive: true }); }
892
+ if (renamedGroup) command('/usr/sbin/groupmod', ['--new-name', '_nc', 'nodecommand']);
893
+ if (renamedUser) command('/usr/sbin/usermod', ['--login', '_nc', 'nodecommand']);
894
+ write(unit, unitText, 0o644); write(envPath, envText, 0o600);
895
+ for (const edit of helperEdits) write(edit.file, edit.text, 0o644);
896
+ command('/usr/bin/systemctl', ['daemon-reload']);
897
+ }
898
+ if (stopped) oldManager(['start', 'nc-client.service']);
899
+ if (!loginMove) io.rmSync(journalPath);
900
+ } catch { throw new Error('IDENTITY_MAINTENANCE_ROLLBACK_REQUIRED: preserve the protected recovery journal; do not rerun installation or replace credentials', { cause: error }); }
901
+ throw error;
902
+ }
903
+ }
904
+
245
905
  /**
246
906
  * Uninstaller script living inside an installed tree. The scripts are
247
907
  * self-locating ($ROOT derived from their own path), so no root argument is
@@ -255,8 +915,8 @@ export function uninstallerForRoot(root, platform = os.platform()) {
255
915
  }
256
916
 
257
917
  /**
258
- * Suggested-action line printed when an uninstaller fails. The unix text
259
- * names the direct recovery command; win32 keeps the generic wording because
918
+ * Suggested-action line printed when an uninstaller fails. The Unix text
919
+ * requires trusted maintenance, never implicit sudo; win32 keeps the generic wording because
260
920
  * the .ps1 uninstaller already runs with the caller's own rights (no sudo
261
921
  * equivalent is attempted there).
262
922
  * @param {string} [platform]
@@ -264,31 +924,28 @@ export function uninstallerForRoot(root, platform = os.platform()) {
264
924
  export function uninstallFailureSuggestion(platform = os.platform()) {
265
925
  const base = 'Re-run with administrator/root rights, then retry.';
266
926
  if (platform === 'win32') return `suggested_action: ${base}`;
267
- return `suggested_action: ${base} (or run: sudo nodecommand remove --yes)`;
927
+ 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.';
268
928
  }
269
929
 
930
+ /** User-mode authority comes from the selected tree, not the caller's UID. */
931
+ export function isUserInstallRoot(root) {
932
+ try {
933
+ return /^NC_SERVICE_MODE=user\r?$/m.test(fs.readFileSync(path.join(root, 'config', 'nc-client.env'), 'utf8').replace(/^\uFEFF/, ''));
934
+ } catch {
935
+ return false;
936
+ }
937
+ }
938
+
939
+ export const LINUX_PAIRED_UNINSTALL_ENTRY = '/opt/nc/libexec/privileged-helper/install/uninstall-linux.sh';
940
+
270
941
  /**
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.
942
+ * Discovery markers are not execution trust. POSIX removal never elevates:
943
+ * an ordinary caller may execute only its own user tree; root may execute
944
+ * only a protected system script through a protected absolute interpreter.
945
+ * Never forward startup hooks or loader/environment authority to that shell.
289
946
  * @param {string} scriptPath
290
947
  * @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]
948
+ * @param {{ execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null, geteuid?: (() => number)|null, io?: typeof fs }} [options]
292
949
  */
293
950
  export function runUninstallerScript(scriptPath, platform = os.platform(), options = {}) {
294
951
  let opts = options || {};
@@ -309,7 +966,7 @@ export function runUninstallerScript(scriptPath, platform = os.platform(), optio
309
966
  typeof opts.getuid === 'function'
310
967
  ? opts.getuid
311
968
  : typeof process.getuid === 'function'
312
- ? () => process.getuid()
969
+ ? () => process.geteuid?.() ?? process.getuid()
313
970
  : null;
314
971
  let uid = null;
315
972
  try {
@@ -317,17 +974,48 @@ export function runUninstallerScript(scriptPath, platform = os.platform(), optio
317
974
  } catch {
318
975
  uid = null;
319
976
  }
320
- if (uid === 0) {
321
- execFile('bash', [scriptPath], { stdio: 'inherit' });
322
- return;
323
- }
977
+ const io = opts.io || fs;
978
+ 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' });
979
+ const pairedEntry = platform === 'linux' && scriptPath === LINUX_PAIRED_UNINSTALL_ENTRY;
980
+ if (!Number.isSafeInteger(uid) || uid < 0 || !path.isAbsolute(scriptPath) || path.resolve(scriptPath) !== scriptPath || (!pairedEntry && path.basename(scriptPath) !== 'uninstall.sh')) throw denied();
981
+ const root = path.dirname(scriptPath);
982
+ let userMode = false;
983
+ if (!pairedEntry) try { userMode = /^NC_SERVICE_MODE=user\r?$/m.test(io.readFileSync(path.join(root, 'config/nc-client.env'), 'utf8').replace(/^\uFEFF/, '')); } catch {}
984
+ const env = { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'C', LC_ALL: 'C' };
985
+ const protectedPath = (file, executable = false, checkAcl = true) => {
986
+ if (io.realpathSync(file) !== file) throw denied();
987
+ for (let current = file; ; current = path.dirname(current)) {
988
+ const st = io.lstatSync(current);
989
+ // Linux POSIX ACL write grants are bounded by the group-class mask.
990
+ if (st.isSymbolicLink() || st.uid !== 0 || (pairedEntry && st.gid !== 0) || (st.mode & 0o022) ||
991
+ (current === file ? !st.isFile() || st.nlink !== 1 || (executable && !(st.mode & 0o111)) : !st.isDirectory())) throw denied();
992
+ if (platform === 'darwin' && checkAcl) {
993
+ const ls = io.realpathSync('/bin/ls');
994
+ protectedPath(ls, true, false);
995
+ const acl = execFile(ls, ['-lde', current], { env, encoding: 'utf8' });
996
+ if (typeof acl !== 'string' || !acl.trim() || acl.trim().split(/\r?\n/).length !== 1) throw denied();
997
+ }
998
+ if (current === path.dirname(current)) break;
999
+ }
1000
+ };
324
1001
  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' });
1002
+ if (pairedEntry && ((opts.getuid || process.getuid)?.() !== 0 || (opts.geteuid || process.geteuid)?.() !== 0)) throw denied();
1003
+ if (uid === 0) {
1004
+ if (userMode) throw denied();
1005
+ protectedPath(scriptPath);
1006
+ } else {
1007
+ if (!userMode || io.realpathSync(root) !== root) throw denied();
1008
+ for (const [file, directory] of [[root, true], [scriptPath, false]]) {
1009
+ const st = io.lstatSync(file);
1010
+ if (st.isSymbolicLink() || st.uid !== uid || (directory ? !st.isDirectory() : !st.isFile() || st.nlink !== 1)) throw denied();
1011
+ }
1012
+ env.HOME = os.userInfo().homedir;
1013
+ }
1014
+ const bash = io.realpathSync('/bin/bash');
1015
+ protectedPath(bash, true);
1016
+ execFile(bash, ['--noprofile', '--norc', '-p', '--', scriptPath], { stdio: 'inherit', env, cwd: '/' });
1017
+ } catch (error) {
1018
+ if (error?.status !== undefined || error?.code === 'TRUSTED_ROOT_MAINTENANCE_REQUIRED') throw error;
1019
+ throw denied();
332
1020
  }
333
1021
  }