@cfour-hi/dsh-plugin-devicex 0.0.0-stage → 1.7.0

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.
@@ -0,0 +1,1261 @@
1
+ /**
2
+ * Device X — whitelisted device action engine.
3
+ *
4
+ * Every operation is implemented with argv-only `execFile` / `process.kill` /
5
+ * `fs` calls (never a shell string), validates its parameters strictly, and
6
+ * returns a JSON-safe result. `runAction` never throws for bad input.
7
+ *
8
+ * Plain Node ESM; `node:*` imports only (plus the sibling process helper).
9
+ *
10
+ * @module @cfour-hi/dsh-plugin-devicex/host/actions
11
+ */
12
+ import * as fs from 'node:fs';
13
+ import * as fsp from 'node:fs/promises';
14
+ import * as net from 'node:net';
15
+ import * as os from 'node:os';
16
+ import * as path from 'node:path';
17
+
18
+ import { cleanupItems, deleteCleanup } from './cleanup-darwin.js';
19
+ import { consumeToken, resolveScanItems, scanFootprint } from './footprint.js';
20
+ import { runUninstallPipeline } from './uninstall.js';
21
+ import { run } from './proc.js';
22
+
23
+ /** Action groups, in UI order. */
24
+ export const GROUPS = ['process', 'app', 'system', 'network', 'software', 'power'];
25
+
26
+ /** Maximum retained history entries. */
27
+ const HISTORY_LIMIT = 50;
28
+
29
+ /* -------------------------------------------------------------------------- */
30
+ /* Command paths */
31
+ /* -------------------------------------------------------------------------- */
32
+
33
+ const OSASCRIPT = '/usr/bin/osascript';
34
+ const OPEN = '/usr/bin/open';
35
+ const SCUTIL = '/usr/sbin/scutil';
36
+ const LAUNCHCTL = '/bin/launchctl';
37
+ const NETWORKSETUP = '/usr/sbin/networksetup';
38
+ const SOCKETFILTERFW = '/usr/libexec/ApplicationFirewall/socketfilterfw';
39
+ const DSCACHEUTIL = '/usr/bin/dscacheutil';
40
+ const PMSET = '/usr/bin/pmset';
41
+ const BREW_CANDIDATES = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew'];
42
+
43
+ /** AppleScript-visible System Settings anchors for `system.openSettings`. */
44
+ const SETTINGS_PANES = {
45
+ wifi: 'com.apple.wifi-settings-extension',
46
+ network: 'com.apple.network-settings-extension',
47
+ bluetooth: 'com.apple.BluetoothSettings',
48
+ sound: 'com.apple.Sound-Settings.extension',
49
+ display: 'com.apple.Displays-Settings.extension',
50
+ appearance: 'com.apple.Appearance-Settings.extension',
51
+ wallpaper: 'com.apple.Wallpaper-Settings.extension',
52
+ battery: 'com.apple.Battery-Settings.extension',
53
+ general: 'com.apple.General-Settings.extension',
54
+ privacy: 'com.apple.PrivacySecurity.extension',
55
+ users: 'com.apple.Users-Groups-Settings.extension',
56
+ keyboard: 'com.apple.Keyboard-Settings.extension',
57
+ mouse: 'com.apple.Mouse-Settings.extension',
58
+ trackpad: 'com.apple.Trackpad-Settings.extension',
59
+ notifications: 'com.apple.Notifications-Settings.extension',
60
+ softwareupdate: 'com.apple.Software-Update-Settings.extension',
61
+ storage: 'com.apple.Storage-Settings.extension',
62
+ datetime: 'com.apple.Date-Time-Settings.extension',
63
+ language: 'com.apple.Localization-Settings.extension',
64
+ sharing: 'com.apple.Sharing-Settings.extension',
65
+ loginitems: 'com.apple.LoginItems-Settings.extension',
66
+ };
67
+
68
+ /** Supported POSIX signals for `process.signal`. */
69
+ const SIGNAL_NAMES = new Map([
70
+ ['TERM', 'SIGTERM'],
71
+ ['KILL', 'SIGKILL'],
72
+ ['INT', 'SIGINT'],
73
+ ['STOP', 'SIGSTOP'],
74
+ ['CONT', 'SIGCONT'],
75
+ ]);
76
+
77
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
78
+ const HOSTNAME_PATTERN = /^[A-Za-z0-9.-]{1,63}$/;
79
+ const BREW_TOKEN_PATTERN = /^[A-Za-z0-9@+._/-]+$/;
80
+
81
+ /* -------------------------------------------------------------------------- */
82
+ /* JSON safety */
83
+ /* -------------------------------------------------------------------------- */
84
+
85
+ /**
86
+ * Deep JSON-safe clone (undefined → null, non-finite → null, cycles → null).
87
+ * @param {unknown} value
88
+ * @param {Set<object>} [seen]
89
+ * @returns {any}
90
+ */
91
+ function jsonSafe(value, seen = new Set()) {
92
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
93
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
94
+ if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') return null;
95
+ if (typeof value !== 'object') return null;
96
+ if (seen.has(value)) return null;
97
+ seen.add(value);
98
+ let result;
99
+ if (Array.isArray(value)) {
100
+ result = value.map((item) => jsonSafe(item, seen));
101
+ } else if (value instanceof Date) {
102
+ const time = value.getTime();
103
+ result = Number.isFinite(time) ? time : null;
104
+ } else {
105
+ result = {};
106
+ for (const [key, item] of Object.entries(value)) result[key] = jsonSafe(item, seen);
107
+ }
108
+ seen.delete(value);
109
+ return result;
110
+ }
111
+
112
+ /* -------------------------------------------------------------------------- */
113
+ /* Action catalog (frozen order and ids) */
114
+ /* -------------------------------------------------------------------------- */
115
+
116
+ /**
117
+ * @typedef {object} ActionParam
118
+ * @property {string} name
119
+ * @property {'number'|'string'|'boolean'|'select'} type
120
+ * @property {boolean} required
121
+ * @property {string[]} [options]
122
+ * @property {number} [min]
123
+ * @property {number} [max]
124
+ * @property {string} [placeholder]
125
+ * @property {boolean} [multiple] true when the client sends an array of values.
126
+ */
127
+
128
+ /**
129
+ * Build a parameter descriptor, dropping absent optional fields so the JSON
130
+ * shape stays exactly the contract's.
131
+ * @param {string} name
132
+ * @param {'number'|'string'|'boolean'|'select'} type
133
+ * @param {object} [options]
134
+ * @returns {ActionParam}
135
+ */
136
+ function param(name, type, options = {}) {
137
+ /** @type {ActionParam} */
138
+ const descriptor = { name, type, required: options.required !== false };
139
+ if (Array.isArray(options.options)) descriptor.options = [...options.options];
140
+ if (typeof options.min === 'number') descriptor.min = options.min;
141
+ if (typeof options.max === 'number') descriptor.max = options.max;
142
+ if (typeof options.placeholder === 'string') descriptor.placeholder = options.placeholder;
143
+ if (options.multiple === true) descriptor.multiple = true;
144
+ return descriptor;
145
+ }
146
+
147
+ /** The frozen action table, in contract order. */
148
+ const SPECS = [
149
+ {
150
+ id: 'process.signal',
151
+ group: 'process',
152
+ danger: 'medium',
153
+ requiresAdmin: false,
154
+ params: [
155
+ param('pid', 'number', { min: 2, max: 4_194_304, placeholder: '1234' }),
156
+ param('signal', 'select', {
157
+ required: false,
158
+ options: [...SIGNAL_NAMES.keys()],
159
+ placeholder: 'TERM',
160
+ }),
161
+ ],
162
+ },
163
+ {
164
+ id: 'app.quit',
165
+ group: 'app',
166
+ danger: 'low',
167
+ requiresAdmin: false,
168
+ params: [param('name', 'string', { placeholder: 'Safari' })],
169
+ },
170
+ {
171
+ id: 'app.launch',
172
+ group: 'app',
173
+ danger: 'low',
174
+ requiresAdmin: false,
175
+ params: [param('path', 'string', { placeholder: '/Applications/Safari.app' })],
176
+ },
177
+ {
178
+ id: 'app.reveal',
179
+ group: 'app',
180
+ danger: 'low',
181
+ requiresAdmin: false,
182
+ params: [param('path', 'string', { placeholder: '/Applications/Safari.app' })],
183
+ },
184
+ {
185
+ id: 'app.uninstall',
186
+ group: 'app',
187
+ danger: 'high',
188
+ requiresAdmin: false,
189
+ params: [
190
+ param('path', 'string', { placeholder: '/Applications/SomeApp.app' }),
191
+ param('mode', 'select', { required: false, options: ['trash', 'delete'], placeholder: 'trash' }),
192
+ // 1.6: optional footprint cleanup. All three are omitted by the plain UI
193
+ // flow, which keeps the single-step behaviour exactly as before.
194
+ param('scanId', 'string', { required: false, placeholder: 'scan id from GET /devicex/api/footprint' }),
195
+ param('itemIds', 'string', { required: false, multiple: true, placeholder: 'fp_1a2b3c4d, fp_5e6f7a8b' }),
196
+ param('cleanup', 'select', { required: false, options: ['trash', 'none'], placeholder: 'trash' }),
197
+ ],
198
+ },
199
+ {
200
+ id: 'system.setHostname',
201
+ group: 'system',
202
+ danger: 'medium',
203
+ requiresAdmin: true,
204
+ params: [
205
+ param('kind', 'select', { options: ['HostName', 'ComputerName', 'LocalHostName'], placeholder: 'ComputerName' }),
206
+ param('value', 'string', { placeholder: 'My-MacBook' }),
207
+ ],
208
+ },
209
+ {
210
+ id: 'system.setVolume',
211
+ group: 'system',
212
+ danger: 'low',
213
+ requiresAdmin: false,
214
+ params: [param('percent', 'number', { min: 0, max: 100, placeholder: '50' })],
215
+ },
216
+ {
217
+ id: 'system.setDarkMode',
218
+ group: 'system',
219
+ danger: 'low',
220
+ requiresAdmin: false,
221
+ params: [param('enabled', 'boolean', { placeholder: 'true' })],
222
+ },
223
+ {
224
+ id: 'system.launchAgent',
225
+ group: 'system',
226
+ danger: 'medium',
227
+ requiresAdmin: true,
228
+ params: [
229
+ param('path', 'string', { placeholder: '~/Library/LaunchAgents/com.example.agent.plist' }),
230
+ param('enabled', 'boolean', { placeholder: 'true' }),
231
+ ],
232
+ },
233
+ {
234
+ id: 'system.openSettings',
235
+ group: 'system',
236
+ danger: 'low',
237
+ requiresAdmin: false,
238
+ params: [param('pane', 'select', { options: Object.keys(SETTINGS_PANES), placeholder: 'wifi' })],
239
+ },
240
+ {
241
+ id: 'system.toggleFirewall',
242
+ group: 'system',
243
+ danger: 'medium',
244
+ requiresAdmin: true,
245
+ params: [param('enabled', 'boolean', { placeholder: 'true' })],
246
+ },
247
+ {
248
+ id: 'system.flushDNS',
249
+ group: 'system',
250
+ danger: 'low',
251
+ requiresAdmin: false,
252
+ params: [],
253
+ },
254
+ {
255
+ id: 'system.emptyTrash',
256
+ group: 'system',
257
+ danger: 'medium',
258
+ requiresAdmin: false,
259
+ params: [],
260
+ },
261
+ {
262
+ id: 'cleanup.delete',
263
+ group: 'system',
264
+ danger: 'high',
265
+ requiresAdmin: false,
266
+ params: [param('ids', 'string', { multiple: true, placeholder: 'user-caches, user-logs' })],
267
+ },
268
+ {
269
+ id: 'network.setWifiPower',
270
+ group: 'network',
271
+ danger: 'medium',
272
+ requiresAdmin: false,
273
+ params: [param('enabled', 'boolean', { placeholder: 'true' })],
274
+ },
275
+ {
276
+ id: 'network.setDns',
277
+ group: 'network',
278
+ danger: 'medium',
279
+ requiresAdmin: true,
280
+ params: [
281
+ param('service', 'string', { placeholder: 'Wi-Fi' }),
282
+ param('servers', 'string', { placeholder: '8.8.8.8, 1.1.1.1' }),
283
+ ],
284
+ },
285
+ {
286
+ id: 'power.sleep',
287
+ group: 'power',
288
+ danger: 'low',
289
+ requiresAdmin: false,
290
+ params: [],
291
+ },
292
+ {
293
+ id: 'power.lock',
294
+ group: 'power',
295
+ danger: 'low',
296
+ requiresAdmin: false,
297
+ params: [],
298
+ },
299
+ {
300
+ id: 'software.brewUninstall',
301
+ group: 'software',
302
+ danger: 'medium',
303
+ requiresAdmin: false,
304
+ params: [param('name', 'string', { placeholder: 'wget' })],
305
+ },
306
+ ];
307
+
308
+ const SPEC_BY_ID = new Map(SPECS.map((spec) => [spec.id, spec]));
309
+
310
+ /* -------------------------------------------------------------------------- */
311
+ /* History */
312
+ /* -------------------------------------------------------------------------- */
313
+
314
+ /** @type {Array<{ts: number, id: string, params: object, ok: boolean, message: string}>} */
315
+ const history = [];
316
+
317
+ /**
318
+ * @param {{ts: number, id: string, params: object, ok: boolean, message: string}} entry
319
+ * @returns {void}
320
+ */
321
+ function recordHistory(entry) {
322
+ history.unshift(entry);
323
+ if (history.length > HISTORY_LIMIT) history.length = HISTORY_LIMIT;
324
+ }
325
+
326
+ /* -------------------------------------------------------------------------- */
327
+ /* Result / validation helpers */
328
+ /* -------------------------------------------------------------------------- */
329
+
330
+ /**
331
+ * @param {string} message
332
+ * @param {string} [code]
333
+ * @returns {{ok: false, message: string, code?: string}}
334
+ */
335
+ function failure(message, code) {
336
+ return code ? { ok: false, message, code } : { ok: false, message };
337
+ }
338
+
339
+ /**
340
+ * @param {string} message
341
+ * @param {object} [data]
342
+ * @returns {{ok: true, message: string, data?: object}}
343
+ */
344
+ function success(message, data) {
345
+ return data && typeof data === 'object' && Object.keys(data).length
346
+ ? { ok: true, message, data: jsonSafe(data) }
347
+ : { ok: true, message };
348
+ }
349
+
350
+ /**
351
+ * Turn a failed command result into an ActionResult carrying stderr.
352
+ * @param {string} action
353
+ * @param {Awaited<ReturnType<typeof run>>} result
354
+ * @param {string} [code]
355
+ * @returns {{ok: false, message: string, code?: string}}
356
+ */
357
+ function commandFailure(action, result, code) {
358
+ const detail = (result.stderr || result.error || `exit code ${String(result.code)}`).trim();
359
+ const suffix = result.timedOut ? ' (timed out)' : '';
360
+ return failure(`${action} failed: ${detail || 'unknown error'}${suffix}`, code ?? 'COMMAND_FAILED');
361
+ }
362
+
363
+ /**
364
+ * Read a required integer parameter.
365
+ * @param {Record<string, unknown>} params
366
+ * @param {string} name
367
+ * @param {{min?: number, max?: number}} [bounds]
368
+ * @returns {{ok: true, value: number} | {ok: false, message: string}}
369
+ */
370
+ function readInteger(params, name, bounds = {}) {
371
+ const raw = params[name];
372
+ let value = null;
373
+ if (typeof raw === 'number' && Number.isFinite(raw)) value = raw;
374
+ else if (typeof raw === 'string' && /^-?\d+$/.test(raw.trim())) value = Number(raw.trim());
375
+ if (value === null || !Number.isInteger(value)) {
376
+ return { ok: false, message: `Parameter "${name}" must be an integer.` };
377
+ }
378
+ const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = bounds;
379
+ if (value < min || value > max) {
380
+ return { ok: false, message: `Parameter "${name}" must be between ${min} and ${max}.` };
381
+ }
382
+ return { ok: true, value };
383
+ }
384
+
385
+ /**
386
+ * Read a required number parameter.
387
+ * @param {Record<string, unknown>} params
388
+ * @param {string} name
389
+ * @param {{min?: number, max?: number}} [bounds]
390
+ * @returns {{ok: true, value: number} | {ok: false, message: string}}
391
+ */
392
+ function readNumber(params, name, bounds = {}) {
393
+ const raw = params[name];
394
+ let value = null;
395
+ if (typeof raw === 'number' && Number.isFinite(raw)) value = raw;
396
+ else if (typeof raw === 'string' && raw.trim() !== '' && Number.isFinite(Number(raw))) value = Number(raw);
397
+ if (value === null) return { ok: false, message: `Parameter "${name}" must be a number.` };
398
+ const { min = -Number.MAX_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = bounds;
399
+ if (value < min || value > max) {
400
+ return { ok: false, message: `Parameter "${name}" must be between ${min} and ${max}.` };
401
+ }
402
+ return { ok: true, value };
403
+ }
404
+
405
+ /**
406
+ * Read a required string parameter.
407
+ * @param {Record<string, unknown>} params
408
+ * @param {string} name
409
+ * @param {{maxLength?: number, allowControlChars?: boolean}} [options]
410
+ * @returns {{ok: true, value: string} | {ok: false, message: string}}
411
+ */
412
+ function readString(params, name, options = {}) {
413
+ const raw = params[name];
414
+ const { maxLength = 1_024, allowControlChars = false } = options;
415
+ if (typeof raw !== 'string') return { ok: false, message: `Parameter "${name}" must be a string.` };
416
+ const value = raw.trim();
417
+ if (value === '') return { ok: false, message: `Parameter "${name}" must not be empty.` };
418
+ if (value.length > maxLength) {
419
+ return { ok: false, message: `Parameter "${name}" must be at most ${maxLength} characters.` };
420
+ }
421
+ if (!allowControlChars && CONTROL_CHARS.test(value)) {
422
+ return { ok: false, message: `Parameter "${name}" must not contain control characters.` };
423
+ }
424
+ return { ok: true, value };
425
+ }
426
+
427
+ /**
428
+ * Read a required boolean parameter (accepts `true`/`false` and their string
429
+ * spellings from an HTML form).
430
+ * @param {Record<string, unknown>} params
431
+ * @param {string} name
432
+ * @returns {{ok: true, value: boolean} | {ok: false, message: string}}
433
+ */
434
+ function readBoolean(params, name) {
435
+ const raw = params[name];
436
+ if (typeof raw === 'boolean') return { ok: true, value: raw };
437
+ if (raw === 'true' || raw === '1' || raw === 1) return { ok: true, value: true };
438
+ if (raw === 'false' || raw === '0' || raw === 0) return { ok: true, value: false };
439
+ return { ok: false, message: `Parameter "${name}" must be a boolean.` };
440
+ }
441
+
442
+ /**
443
+ * Read a required select parameter.
444
+ * @param {Record<string, unknown>} params
445
+ * @param {string} name
446
+ * @param {readonly string[]} options
447
+ * @param {string} [fallback] default when absent.
448
+ * @returns {{ok: true, value: string} | {ok: false, message: string}}
449
+ */
450
+ function readSelect(params, name, options, fallback) {
451
+ const raw = params[name];
452
+ if ((raw === undefined || raw === null || raw === '') && fallback !== undefined) {
453
+ return { ok: true, value: fallback };
454
+ }
455
+ if (typeof raw !== 'string' || !options.includes(raw)) {
456
+ return { ok: false, message: `Parameter "${name}" must be one of: ${options.join(', ')}.` };
457
+ }
458
+ return { ok: true, value: raw };
459
+ }
460
+
461
+ /**
462
+ * Normalise the request's params object.
463
+ * @param {unknown} value
464
+ * @returns {Record<string, unknown>}
465
+ */
466
+ function paramsObject(value) {
467
+ if (value && typeof value === 'object' && !Array.isArray(value)) return /** @type {Record<string, unknown>} */ (value);
468
+ return {};
469
+ }
470
+
471
+ /**
472
+ * Read the `ids` parameter of `cleanup.delete`: an array (or comma separated
473
+ * string) of 1-20 unique ids from the cleanup allowlist. Unknown ids, caller
474
+ * paths, duplicates and oversized batches are refused before any filesystem
475
+ * call.
476
+ * @param {Record<string, unknown>} params
477
+ * @returns {{ok: true, value: string[]} | {ok: false, message: string}}
478
+ */
479
+ function readCleanupIds(params) {
480
+ const raw = params.ids;
481
+ /** @type {unknown[]} */
482
+ let values;
483
+ if (Array.isArray(raw)) {
484
+ values = raw;
485
+ } else if (typeof raw === 'string') {
486
+ values = raw.split(',');
487
+ } else {
488
+ return {
489
+ ok: false,
490
+ message: 'Parameter "ids" must be an array of cleanup item ids (or a comma separated string).',
491
+ };
492
+ }
493
+ /** @type {string[]} */
494
+ const ids = [];
495
+ for (const value of values) {
496
+ if (typeof value !== 'string') return { ok: false, message: 'Cleanup item ids must be strings.' };
497
+ const id = value.trim();
498
+ if (id !== '') ids.push(id);
499
+ }
500
+ if (ids.length < 1 || ids.length > 20) {
501
+ return { ok: false, message: 'Parameter "ids" must contain 1 to 20 cleanup item ids.' };
502
+ }
503
+ const known = new Set(cleanupItems().map((item) => item.id));
504
+ const seen = new Set();
505
+ for (const id of ids) {
506
+ if (seen.has(id)) return { ok: false, message: `Duplicate cleanup item id: ${id}` };
507
+ seen.add(id);
508
+ if (!known.has(id)) return { ok: false, message: `Unknown cleanup item id: ${id}` };
509
+ }
510
+ return { ok: true, value: ids };
511
+ }
512
+
513
+ /* -------------------------------------------------------------------------- */
514
+ /* Filesystem guards */
515
+ /* -------------------------------------------------------------------------- */
516
+
517
+ /**
518
+ * Resolve an existing path through symlinks.
519
+ * @param {string} raw
520
+ * @returns {Promise<{ok: true, path: string, stat: fs.Stats} | {ok: false, message: string, code?: string}>}
521
+ */
522
+ async function resolveExisting(raw) {
523
+ const resolved = path.resolve(raw);
524
+ try {
525
+ const real = await fsp.realpath(resolved);
526
+ const stat = await fsp.stat(real);
527
+ return { ok: true, path: real, stat };
528
+ } catch (err) {
529
+ const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : 'ENOENT';
530
+ return failure(`Path does not exist: ${resolved}`, code);
531
+ }
532
+ }
533
+
534
+ /**
535
+ * True when `candidate` is `root` or lives beneath it (both already resolved).
536
+ * @param {string} candidate
537
+ * @param {string} root
538
+ * @returns {boolean}
539
+ */
540
+ function isInside(candidate, root) {
541
+ return candidate === root || candidate.startsWith(`${root}${path.sep}`);
542
+ }
543
+
544
+ /**
545
+ * The three directories allowed as uninstall roots.
546
+ * @returns {string[]}
547
+ */
548
+ function uninstallRoots() {
549
+ return ['/Applications', path.join(os.homedir(), 'Applications'), '/System/Applications'];
550
+ }
551
+
552
+ /**
553
+ * Resolve and guard an `.app` bundle for uninstall.
554
+ * @param {string} raw
555
+ * @param {string} mode
556
+ * @returns {Promise<{ok: true, path: string} | {ok: false, message: string, code?: string}>}
557
+ */
558
+ async function guardedAppBundle(raw, mode) {
559
+ const resolved = await resolveExisting(raw);
560
+ if (!resolved.ok) return resolved;
561
+ if (!resolved.stat.isDirectory()) {
562
+ return failure(`Not an application bundle: ${resolved.path}`, 'NOT_A_BUNDLE');
563
+ }
564
+ if (!/\.app$/i.test(resolved.path)) {
565
+ return failure(`Path must end in ".app" after resolution: ${resolved.path}`, 'NOT_A_BUNDLE');
566
+ }
567
+ const roots = uninstallRoots();
568
+ const insideRoot = roots.some((root) => isInside(resolved.path, root) && resolved.path !== root);
569
+ if (!insideRoot) {
570
+ return failure(
571
+ `Refusing to uninstall outside ${roots.join(', ')}: ${resolved.path}`,
572
+ 'PATH_NOT_ALLOWED',
573
+ );
574
+ }
575
+ if (resolved.path.startsWith('/System/')) {
576
+ return failure(`Refusing to uninstall a system application: ${resolved.path}`, 'SYSTEM_APP');
577
+ }
578
+ if (mode === 'delete' && resolved.path === '/Applications') {
579
+ return failure('Refusing to delete the /Applications directory itself.', 'PATH_NOT_ALLOWED');
580
+ }
581
+ return { ok: true, path: resolved.path };
582
+ }
583
+
584
+ /**
585
+ * Reserve a destination name inside the Trash atomically.
586
+ *
587
+ * `rename` overwrites an existing destination without complaint, so probing
588
+ * with `access()` first and then renaming is a race that can silently destroy
589
+ * whatever landed in between. Creating the destination ourselves first makes
590
+ * the reservation atomic: `mkdir` and `open('wx')` both fail with EEXIST when
591
+ * the name is taken, and the following `rename` only ever overwrites our own
592
+ * placeholder.
593
+ *
594
+ * @param {string} trash absolute Trash directory
595
+ * @param {string} stem desired base name
596
+ * @param {string} extension suffix to keep ('' for none, '.app' for a bundle)
597
+ * @param {boolean} isDirectory whether the source is a directory
598
+ * @returns {Promise<string>} the reserved absolute destination path
599
+ */
600
+ async function reserveTrashName(trash, stem, extension, isDirectory) {
601
+ let attempt = 0;
602
+ while (attempt < 100) {
603
+ const suffix = attempt === 0 ? '' : `-${attempt}`;
604
+ const destination = path.join(trash, `${stem}-${Date.now()}${suffix}${extension}`);
605
+ try {
606
+ if (isDirectory) {
607
+ await fsp.mkdir(destination);
608
+ } else {
609
+ const handle = await fsp.open(destination, 'wx');
610
+ await handle.close();
611
+ }
612
+ return destination;
613
+ } catch (error) {
614
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
615
+ if (code === 'EEXIST') {
616
+ attempt += 1;
617
+ continue;
618
+ }
619
+ throw error;
620
+ }
621
+ }
622
+ throw Object.assign(new Error('Could not reserve a unique name in the Trash.'), { code: 'EEXIST' });
623
+ }
624
+
625
+ /**
626
+ * Move a file or directory into the user's Trash under a unique name.
627
+ *
628
+ * Portable across bundle and data-directory sources: the previous version
629
+ * hard-coded a `.app` suffix, so trashing `com.example.app` produced
630
+ * `com.example-1712345678901.app` — a data directory wearing an application
631
+ * name.
632
+ *
633
+ * @param {string} source resolved absolute path
634
+ * @param {{trashDir?: string}} [options] `trashDir` exists only so offline tests
635
+ * can drive a sandbox instead of the real `~/.Trash`.
636
+ * @returns {Promise<string>} the destination path
637
+ */
638
+ async function moveToTrash(source, options = {}) {
639
+ const trash = options.trashDir ?? path.join(os.homedir(), '.Trash');
640
+ await fsp.mkdir(trash, { recursive: true });
641
+
642
+ const base = path.basename(source);
643
+ const stat = await fsp.lstat(source);
644
+ const isDirectory = stat.isDirectory();
645
+ const extension = /\.app$/i.test(base) ? '.app' : '';
646
+ const stem = extension === '' ? base : base.slice(0, -extension.length);
647
+
648
+ const destination = await reserveTrashName(trash, stem, extension, isDirectory);
649
+ try {
650
+ // `rename` onto our own placeholder is atomic and cannot clobber a third
651
+ // party: the name was reserved a moment ago and nothing else can claim it.
652
+ await fsp.rename(source, destination);
653
+ return destination;
654
+ } catch (error) {
655
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
656
+ if (code !== 'EXDEV') {
657
+ // Release the placeholder so a failed move does not litter the Trash.
658
+ await fsp.rm(destination, { recursive: true, force: true }).catch(() => {});
659
+ throw error;
660
+ }
661
+ // Cross-device: copy into the placeholder, then remove the source. The
662
+ // placeholder already exists, so `force: true` is safe here and keeps the
663
+ // partial copy from surviving a failure.
664
+ try {
665
+ await fsp.cp(source, destination, { recursive: true, force: true });
666
+ } catch (copyError) {
667
+ await fsp.rm(destination, { recursive: true, force: true }).catch(() => {});
668
+ throw copyError;
669
+ }
670
+ try {
671
+ await fsp.rm(source, { recursive: true, force: false, maxRetries: 2, retryDelay: 100 });
672
+ } catch (removeError) {
673
+ // The copy exists, so the data is safe; report the leftover so the caller
674
+ // can tell the user the original is still in place.
675
+ const reason = removeError instanceof Error ? removeError.message : String(removeError);
676
+ throw Object.assign(new Error(`Copied to Trash but could not remove the original: ${reason}`), {
677
+ code: 'PARTIAL_TRASH',
678
+ });
679
+ }
680
+ return destination;
681
+ }
682
+ }
683
+
684
+ /** Test seam: expose the Trash mover so offline tests can drive a sandbox. */
685
+ export const __trashTest = { moveToTrash, reserveTrashName };
686
+
687
+ /* -------------------------------------------------------------------------- */
688
+ /* Action runners */
689
+ /* -------------------------------------------------------------------------- */
690
+
691
+ /**
692
+ * @typedef {(params: Record<string, unknown>) => Promise<{ok: boolean, message: string, data?: object, code?: string}>} Runner
693
+ */
694
+
695
+ /**
696
+ * The 1.6 footprint pipeline adapter for macOS.
697
+ *
698
+ * **No vendor uninstaller is ever executed here**, by design. macOS apps are
699
+ * uninstalled by dragging them to the Trash; the two helpers that exist on a
700
+ * real host ship as opaque binaries or scripts whose argument contract cannot
701
+ * be verified, and running one with a guessed flag risks a half-finished
702
+ * uninstall. So `hasUninstaller` is always false: the bundle goes to the Trash
703
+ * and the leftover list is offered separately. `uninstall.js` still supports
704
+ * the uninstaller path for platforms where it is verified (Windows
705
+ * MSI/winget/registry).
706
+ *
707
+ * @param {Record<string, unknown>} params validated-ish request params
708
+ * @param {string} appPath resolved bundle path
709
+ * @returns {Promise<object>} an ActionResult
710
+ */
711
+ async function runDarwinUninstallPipeline(params, appPath) {
712
+ const scanId = typeof params.scanId === 'string' ? params.scanId : '';
713
+ const itemIds = params.itemIds;
714
+ const cleanup = params.cleanup === 'none' ? 'none' : 'trash';
715
+
716
+ let items = [];
717
+ if (cleanup === 'trash' && itemIds !== undefined && itemIds !== null) {
718
+ const resolved = resolveScanItems(scanId, itemIds);
719
+ if (resolved.ok !== true) return failure(resolved.message, resolved.code);
720
+ items = resolved.items;
721
+ if (resolved.appPath !== '' && resolved.appPath !== appPath) {
722
+ return failure('The scan belongs to a different application.', 'SCAN_MISMATCH');
723
+ }
724
+ } else if (cleanup === 'trash' && itemIds !== undefined) {
725
+ return failure('itemIds must be an array of scan item ids.', 'BAD_PARAMS');
726
+ }
727
+
728
+ // Refuse to touch a running app: its files would be recreated and the moves
729
+ // would fail half-way through.
730
+ const running = await isBundleRunning(appPath);
731
+ if (running !== null) {
732
+ return failure(
733
+ `"${running}" is still running. Quit it before uninstalling so its files are not recreated.`,
734
+ 'APP_RUNNING',
735
+ );
736
+ }
737
+
738
+ const report = await runUninstallPipeline(
739
+ {
740
+ appPath,
741
+ hasUninstaller: false,
742
+ cleanupApp: true,
743
+ items,
744
+ leftoverBytes: items.reduce((sum, item) => sum + (Number(item.bytes) || 0), 0),
745
+ },
746
+ {
747
+ async uninstallApp() {
748
+ // Unreachable: `hasUninstaller: false` above. Implemented as a refusal
749
+ // so a future change cannot quietly start executing a bundle helper.
750
+ return {
751
+ ok: false,
752
+ message: 'macOS uninstalls by moving the bundle to the Trash; no vendor uninstaller is executed.',
753
+ code: 'NO_UNINSTALLER',
754
+ };
755
+ },
756
+ async isStillInstalled(target) {
757
+ try {
758
+ await fsp.lstat(target);
759
+ return true;
760
+ } catch {
761
+ return false;
762
+ }
763
+ },
764
+ async trashApp(target) {
765
+ try {
766
+ const destination = await moveToTrash(target);
767
+ return { ok: true, message: `Moved to Trash as ${path.basename(destination)}.`, destination };
768
+ } catch (error) {
769
+ return {
770
+ ok: false,
771
+ message: error instanceof Error ? error.message : String(error),
772
+ code: error && typeof error === 'object' && 'code' in error ? String(error.code) : 'TRASH_FAILED',
773
+ };
774
+ }
775
+ },
776
+ async trashPath(target) {
777
+ try {
778
+ const destination = await moveToTrash(target);
779
+ return { ok: true, message: `Moved to Trash as ${path.basename(destination)}.`, destination };
780
+ } catch (error) {
781
+ return {
782
+ ok: false,
783
+ message: error instanceof Error ? error.message : String(error),
784
+ code: error && typeof error === 'object' && 'code' in error ? String(error.code) : 'TRASH_FAILED',
785
+ };
786
+ }
787
+ },
788
+ async rescan(target) {
789
+ return scanFootprint({ path: target, fresh: true });
790
+ },
791
+ },
792
+ );
793
+
794
+ if (report.ok === true) consumeToken(scanId);
795
+
796
+ const data = { ...report };
797
+ if (report.ok === true) return success(report.message, data);
798
+ return { ok: false, message: report.message, code: report.aborted ? 'ABORTED' : 'PARTIAL', data };
799
+ }
800
+
801
+ /**
802
+ * The display name of a running app whose bundle path matches, or null.
803
+ *
804
+ * Uses `ps` rather than `NSRunningApplication` because the whole host half is
805
+ * argv-only `execFile`; the bundle path appears in the process command line for
806
+ * every app launched by LaunchServices.
807
+ *
808
+ * @param {string} appPath
809
+ * @returns {Promise<string|null>}
810
+ */
811
+ async function isBundleRunning(appPath) {
812
+ const result = await run('/bin/ps', ['-Ao', 'command='], { timeout: 8_000, maxBuffer: 8 * 1024 * 1024 });
813
+ if (!result.ok) return null;
814
+ const needle = `${appPath}/Contents/MacOS/`;
815
+ for (const line of String(result.stdout ?? '').split('\n')) {
816
+ if (line.includes(needle)) return path.basename(appPath);
817
+ }
818
+ return null;
819
+ }
820
+
821
+ /** @type {Record<string, Runner>} */
822
+ const RUNNERS = {
823
+ async 'process.signal'(params) {
824
+ const pidRead = readInteger(params, 'pid', { min: 2, max: 4_194_304 });
825
+ if (!pidRead.ok) return failure(pidRead.message, 'BAD_PARAMS');
826
+ const signalRead = readSelect(params, 'signal', [...SIGNAL_NAMES.keys()], 'TERM');
827
+ if (!signalRead.ok) return failure(signalRead.message, 'BAD_PARAMS');
828
+ const pid = pidRead.value;
829
+ if (pid === process.pid || pid === process.ppid) {
830
+ return failure(`Refusing to signal the Device X host process (pid ${pid}).`, 'PROTECTED_PID');
831
+ }
832
+ if (pid === 1) return failure('Refusing to signal launchd (pid 1).', 'PROTECTED_PID');
833
+ const signal = SIGNAL_NAMES.get(signalRead.value);
834
+ try {
835
+ process.kill(pid, signal);
836
+ return success(`Sent SIG${signalRead.value} to pid ${pid}.`, { pid, signal: signalRead.value });
837
+ } catch (err) {
838
+ const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : 'ESRCH';
839
+ const reason = err instanceof Error ? err.message : String(err);
840
+ return failure(`Could not signal pid ${pid} (${code}): ${reason}`, code);
841
+ }
842
+ },
843
+
844
+ async 'app.quit'(params) {
845
+ const nameRead = readString(params, 'name', { maxLength: 200 });
846
+ if (!nameRead.ok) return failure(nameRead.message, 'BAD_PARAMS');
847
+ const escaped = nameRead.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
848
+ const result = await run(OSASCRIPT, ['-e', `tell application "${escaped}" to quit`], { timeout: 20_000 });
849
+ if (!result.ok) return commandFailure(`Quit "${nameRead.value}"`, result);
850
+ return success(`Asked "${nameRead.value}" to quit.`, { name: nameRead.value });
851
+ },
852
+
853
+ async 'app.launch'(params) {
854
+ const pathRead = readString(params, 'path', { maxLength: 1_024 });
855
+ if (!pathRead.ok) return failure(pathRead.message, 'BAD_PARAMS');
856
+ const resolved = await resolveExisting(pathRead.value);
857
+ if (!resolved.ok) return resolved;
858
+ const result = await run(OPEN, ['-a', resolved.path], { timeout: 15_000 });
859
+ if (!result.ok) return commandFailure(`Launch ${resolved.path}`, result);
860
+ return success(`Launched ${path.basename(resolved.path)}.`, { path: resolved.path });
861
+ },
862
+
863
+ async 'app.reveal'(params) {
864
+ const pathRead = readString(params, 'path', { maxLength: 1_024 });
865
+ if (!pathRead.ok) return failure(pathRead.message, 'BAD_PARAMS');
866
+ const resolved = await resolveExisting(pathRead.value);
867
+ if (!resolved.ok) return resolved;
868
+ const result = await run(OPEN, ['-R', resolved.path], { timeout: 15_000 });
869
+ if (!result.ok) return commandFailure(`Reveal ${resolved.path}`, result);
870
+ return success(`Revealed ${path.basename(resolved.path)} in Finder.`, { path: resolved.path });
871
+ },
872
+
873
+ async 'app.uninstall'(params) {
874
+ const pathRead = readString(params, 'path', { maxLength: 1_024 });
875
+ if (!pathRead.ok) return failure(pathRead.message, 'BAD_PARAMS');
876
+ const modeRead = readSelect(params, 'mode', ['trash', 'delete'], 'trash');
877
+ if (!modeRead.ok) return failure(modeRead.message, 'BAD_PARAMS');
878
+ const guarded = await guardedAppBundle(pathRead.value, modeRead.value);
879
+ if (!guarded.ok) return guarded;
880
+
881
+ // 1.6: when a scan id is present the caller wants the footprint pipeline.
882
+ // Without one, behaviour is byte-for-byte the pre-1.6 single step.
883
+ if (typeof params.scanId === 'string' && params.scanId !== '') {
884
+ return runDarwinUninstallPipeline(params, guarded.path);
885
+ }
886
+
887
+ const name = path.basename(guarded.path);
888
+ if (modeRead.value === 'trash') {
889
+ try {
890
+ const destination = await moveToTrash(guarded.path);
891
+ return success(`Moved ${name} to Trash as ${path.basename(destination)}.`, {
892
+ trashedPath: destination,
893
+ mode: 'trash',
894
+ });
895
+ } catch (err) {
896
+ return failure(
897
+ `Could not move ${name} to Trash: ${err instanceof Error ? err.message : String(err)}`,
898
+ 'TRASH_FAILED',
899
+ );
900
+ }
901
+ }
902
+ try {
903
+ await fsp.rm(guarded.path, { recursive: true, force: false, maxRetries: 3, retryDelay: 150 });
904
+ return success(`Deleted ${name}.`, { path: guarded.path, mode: 'delete' });
905
+ } catch (err) {
906
+ return failure(
907
+ `Could not delete ${name}: ${err instanceof Error ? err.message : String(err)}`,
908
+ 'DELETE_FAILED',
909
+ );
910
+ }
911
+ },
912
+
913
+ async 'system.setHostname'(params) {
914
+ const kindRead = readSelect(params, 'kind', ['HostName', 'ComputerName', 'LocalHostName']);
915
+ if (!kindRead.ok) return failure(kindRead.message, 'BAD_PARAMS');
916
+ const valueRead = readString(params, 'value', { maxLength: 63 });
917
+ if (!valueRead.ok) return failure(valueRead.message, 'BAD_PARAMS');
918
+ if (!HOSTNAME_PATTERN.test(valueRead.value)) {
919
+ return failure(
920
+ 'Hostname value must be 1-63 letters, digits, dots or dashes (no spaces or underscores).',
921
+ 'BAD_PARAMS',
922
+ );
923
+ }
924
+ const result = await run(SCUTIL, ['--set', kindRead.value, valueRead.value], { timeout: 10_000 });
925
+ if (!result.ok) return commandFailure(`Set ${kindRead.value}`, result);
926
+ return success(`Set ${kindRead.value} to "${valueRead.value}".`, {
927
+ kind: kindRead.value,
928
+ value: valueRead.value,
929
+ });
930
+ },
931
+
932
+ async 'system.setVolume'(params) {
933
+ const percentRead = readNumber(params, 'percent', { min: 0, max: 100 });
934
+ if (!percentRead.ok) return failure(percentRead.message, 'BAD_PARAMS');
935
+ const percent = Math.round(percentRead.value);
936
+ const result = await run(OSASCRIPT, ['-e', `set volume output volume ${percent}`], { timeout: 10_000 });
937
+ if (!result.ok) return commandFailure('Set volume', result);
938
+ return success(`Set output volume to ${percent}%.`, { percent });
939
+ },
940
+
941
+ async 'system.setDarkMode'(params) {
942
+ const enabledRead = readBoolean(params, 'enabled');
943
+ if (!enabledRead.ok) return failure(enabledRead.message, 'BAD_PARAMS');
944
+ const script =
945
+ 'tell application "System Events" to tell appearance preferences to set dark mode to ' +
946
+ (enabledRead.value ? 'true' : 'false');
947
+ const result = await run(OSASCRIPT, ['-e', script], { timeout: 10_000 });
948
+ if (!result.ok) return commandFailure('Set dark mode', result);
949
+ return success(`Dark mode ${enabledRead.value ? 'enabled' : 'disabled'}.`, { enabled: enabledRead.value });
950
+ },
951
+
952
+ async 'system.launchAgent'(params) {
953
+ const pathRead = readString(params, 'path', { maxLength: 1_024 });
954
+ if (!pathRead.ok) return failure(pathRead.message, 'BAD_PARAMS');
955
+ const enabledRead = readBoolean(params, 'enabled');
956
+ if (!enabledRead.ok) return failure(enabledRead.message, 'BAD_PARAMS');
957
+ const resolved = await resolveExisting(pathRead.value);
958
+ if (!resolved.ok) return resolved;
959
+ if (!/\.plist$/i.test(resolved.path)) {
960
+ return failure(`Launch agent path must end in ".plist": ${resolved.path}`, 'NOT_A_PLIST');
961
+ }
962
+ const home = os.homedir();
963
+ const allowed = [
964
+ path.join(home, 'Library', 'LaunchAgents'),
965
+ '/Library/LaunchAgents',
966
+ '/Library/LaunchDaemons',
967
+ ];
968
+ if (!allowed.some((root) => isInside(resolved.path, root) && resolved.path !== root)) {
969
+ return failure(
970
+ `Refusing to manage a launch agent outside ${allowed.join(', ')}: ${resolved.path}`,
971
+ 'PATH_NOT_ALLOWED',
972
+ );
973
+ }
974
+ const daemon = resolved.path.startsWith('/Library/LaunchDaemons');
975
+ const domain = daemon ? 'system' : `gui/${typeof process.getuid === 'function' ? process.getuid() : 501}`;
976
+ const verb = enabledRead.value ? 'bootstrap' : 'bootout';
977
+ const result = await run(LAUNCHCTL, [verb, domain, resolved.path], { timeout: 20_000 });
978
+ if (!result.ok) return commandFailure(`${enabledRead.value ? 'Load' : 'Unload'} launch agent`, result);
979
+ return success(
980
+ `${enabledRead.value ? 'Loaded' : 'Unloaded'} launch agent ${path.basename(resolved.path)}.`,
981
+ { path: resolved.path, domain, enabled: enabledRead.value },
982
+ );
983
+ },
984
+
985
+ async 'system.openSettings'(params) {
986
+ const paneRead = readSelect(params, 'pane', Object.keys(SETTINGS_PANES));
987
+ if (!paneRead.ok) return failure(paneRead.message, 'BAD_PARAMS');
988
+ const anchor = SETTINGS_PANES[paneRead.value];
989
+ const url = `x-apple.systempreferences:${anchor}`;
990
+ const result = await run(OPEN, [url], { timeout: 10_000 });
991
+ if (!result.ok) return commandFailure('Open System Settings', result);
992
+ return success(`Opened System Settings (${paneRead.value}).`, { pane: paneRead.value, url });
993
+ },
994
+
995
+ async 'system.toggleFirewall'(params) {
996
+ const enabledRead = readBoolean(params, 'enabled');
997
+ if (!enabledRead.ok) return failure(enabledRead.message, 'BAD_PARAMS');
998
+ const state = enabledRead.value ? 'on' : 'off';
999
+ const result = await run(SOCKETFILTERFW, ['--setglobalstate', state], { timeout: 15_000 });
1000
+ if (!result.ok) return commandFailure(`Turn firewall ${state}`, result);
1001
+ return success(`Requested firewall ${state}.`, { enabled: enabledRead.value });
1002
+ },
1003
+
1004
+ async 'system.flushDNS'(_params) {
1005
+ const result = await run(DSCACHEUTIL, ['-flushcache'], { timeout: 10_000 });
1006
+ if (!result.ok) return commandFailure('Flush DNS cache', result);
1007
+ return success(
1008
+ 'Flushed the DNS cache. If lookups still fail, restart mDNSResponder with: sudo killall -HUP mDNSResponder',
1009
+ { mDNSResponderRestarted: false },
1010
+ );
1011
+ },
1012
+
1013
+ async 'system.emptyTrash'(_params) {
1014
+ const trash = path.join(os.homedir(), '.Trash');
1015
+ let entries;
1016
+ try {
1017
+ entries = await fsp.readdir(trash);
1018
+ } catch (err) {
1019
+ return failure(
1020
+ `Could not read Trash: ${err instanceof Error ? err.message : String(err)}`,
1021
+ 'TRASH_READ_FAILED',
1022
+ );
1023
+ }
1024
+ let removed = 0;
1025
+ /** @type {string[]} */
1026
+ const failed = [];
1027
+ for (const entry of entries) {
1028
+ const target = path.join(trash, entry);
1029
+ if (!isInside(target, trash)) continue;
1030
+ try {
1031
+ await fsp.rm(target, { recursive: true, force: true, maxRetries: 1, retryDelay: 100 });
1032
+ removed += 1;
1033
+ } catch (err) {
1034
+ failed.push(`${entry}: ${err instanceof Error ? err.message : String(err)}`);
1035
+ }
1036
+ }
1037
+ if (failed.length) {
1038
+ return failure(
1039
+ `Removed ${removed} of ${entries.length} item(s) from Trash. Failures: ${failed.slice(0, 3).join('; ')}`,
1040
+ 'TRASH_PARTIAL',
1041
+ );
1042
+ }
1043
+ return success(`Removed ${removed} item(s) from Trash.`, { removed });
1044
+ },
1045
+
1046
+ async 'cleanup.delete'(params) {
1047
+ const idsRead = readCleanupIds(params);
1048
+ if (!idsRead.ok) return failure(idsRead.message, 'BAD_PARAMS');
1049
+ // The cleanup module owns the allowlist and the fixed absolute paths: the
1050
+ // action passes ids only, never a path, and reports per-item outcomes.
1051
+ return deleteCleanup(idsRead.value);
1052
+ },
1053
+
1054
+ async 'network.setWifiPower'(params) {
1055
+ const enabledRead = readBoolean(params, 'enabled');
1056
+ if (!enabledRead.ok) return failure(enabledRead.message, 'BAD_PARAMS');
1057
+ const ports = await run(NETWORKSETUP, ['-listallhardwareports'], { timeout: 10_000 });
1058
+ let device = 'en0';
1059
+ if (ports.ok) {
1060
+ const blocks = ports.stdout.split(/\n\s*\n/);
1061
+ for (const block of blocks) {
1062
+ if (!/Hardware Port:\s*Wi-?Fi/i.test(block)) continue;
1063
+ const match = /Device:\s*(\S+)/.exec(block);
1064
+ if (match) {
1065
+ device = match[1];
1066
+ break;
1067
+ }
1068
+ }
1069
+ }
1070
+ const state = enabledRead.value ? 'on' : 'off';
1071
+ const result = await run(NETWORKSETUP, ['-setairportpower', device, state], { timeout: 15_000 });
1072
+ if (!result.ok) return commandFailure(`Turn Wi-Fi ${state} (${device})`, result);
1073
+ return success(`Requested Wi-Fi ${state} on ${device}.`, { device, enabled: enabledRead.value });
1074
+ },
1075
+
1076
+ async 'network.setDns'(params) {
1077
+ const serviceRead = readString(params, 'service', { maxLength: 64 });
1078
+ if (!serviceRead.ok) return failure(serviceRead.message, 'BAD_PARAMS');
1079
+ if (CONTROL_CHARS.test(serviceRead.value) || serviceRead.value.startsWith('-')) {
1080
+ return failure('Parameter "service" is not a valid network service name.', 'BAD_PARAMS');
1081
+ }
1082
+ const rawServers = params.servers;
1083
+ /** @type {string[]} */
1084
+ let servers;
1085
+ if (Array.isArray(rawServers)) {
1086
+ servers = rawServers.map((item) => String(item).trim()).filter((item) => item !== '');
1087
+ } else if (typeof rawServers === 'string') {
1088
+ servers = rawServers
1089
+ .split(/[,\s]+/)
1090
+ .map((item) => item.trim())
1091
+ .filter((item) => item !== '');
1092
+ } else {
1093
+ return failure('Parameter "servers" must be an array of IP addresses (or a comma separated string).', 'BAD_PARAMS');
1094
+ }
1095
+ if (servers.length > 4) return failure('At most 4 DNS servers are allowed.', 'BAD_PARAMS');
1096
+ for (const server of servers) {
1097
+ if (!net.isIP(server)) return failure(`Invalid IP address: ${server}`, 'BAD_PARAMS');
1098
+ }
1099
+
1100
+ // The page may name either the networksetup service ("Wi-Fi", a hardware
1101
+ // port name) or the BSD interface device ("en0"); resolve to the service
1102
+ // networksetup accepts before touching anything.
1103
+ let serviceName = serviceRead.value;
1104
+ const servicesRes = await run(NETWORKSETUP, ['-listallnetworkservices'], { timeout: 10_000 });
1105
+ if (servicesRes.ok) {
1106
+ const known = servicesRes.stdout
1107
+ .split(/\r?\n/)
1108
+ .map((line) => line.replace(/^\*/, '').trim())
1109
+ .filter((line) => line && !line.startsWith('An asterisk'));
1110
+ const exact =
1111
+ known.find((name) => name === serviceName) ??
1112
+ known.find((name) => name.toLowerCase() === serviceName.toLowerCase());
1113
+ if (exact !== undefined) {
1114
+ serviceName = exact;
1115
+ } else {
1116
+ let mapped;
1117
+ const portsRes = await run(NETWORKSETUP, ['-listallhardwareports'], { timeout: 10_000 });
1118
+ if (portsRes.ok) {
1119
+ for (const block of portsRes.stdout.split(/\n\s*\n/)) {
1120
+ const port = /Hardware Port:\s*(.+)/.exec(block);
1121
+ const device = /Device:\s*(\S+)/.exec(block);
1122
+ if (port === null || device === null) continue;
1123
+ const portName = port[1].trim();
1124
+ if (device[1] !== serviceName && portName.toLowerCase() !== serviceName.toLowerCase()) continue;
1125
+ mapped = known.find((name) => name.toLowerCase() === portName.toLowerCase());
1126
+ if (mapped !== undefined) break;
1127
+ }
1128
+ }
1129
+ if (mapped === undefined) {
1130
+ return failure(
1131
+ `Unknown network service: ${serviceName}. Known services: ${known.join(', ') || '(none)'}`,
1132
+ 'UNKNOWN_SERVICE',
1133
+ );
1134
+ }
1135
+ serviceName = mapped;
1136
+ }
1137
+ }
1138
+ const args = ['-setdnsservers', serviceName, ...(servers.length ? servers : ['Empty'])];
1139
+ const result = await run(NETWORKSETUP, args, { timeout: 20_000 });
1140
+ if (!result.ok) return commandFailure('Set DNS servers', result);
1141
+ return success(
1142
+ servers.length
1143
+ ? `Set DNS servers for ${serviceName} to ${servers.join(', ')}.`
1144
+ : `Cleared DNS servers for ${serviceName}.`,
1145
+ { service: serviceName, servers },
1146
+ );
1147
+ },
1148
+
1149
+ async 'power.sleep'(_params) {
1150
+ const result = await run(PMSET, ['sleepnow'], { timeout: 10_000 });
1151
+ if (!result.ok) return commandFailure('Sleep', result);
1152
+ return success('Requested system sleep.');
1153
+ },
1154
+
1155
+ async 'power.lock'(_params) {
1156
+ const script =
1157
+ 'tell application "System Events" to keystroke "q" using {control down, command down}';
1158
+ const result = await run(OSASCRIPT, ['-e', script], { timeout: 10_000 });
1159
+ if (!result.ok) return commandFailure('Lock screen', result);
1160
+ return success('Requested screen lock.');
1161
+ },
1162
+
1163
+ async 'software.brewUninstall'(params) {
1164
+ const nameRead = readString(params, 'name', { maxLength: 128 });
1165
+ if (!nameRead.ok) return failure(nameRead.message, 'BAD_PARAMS');
1166
+ const name = nameRead.value;
1167
+ if (!BREW_TOKEN_PATTERN.test(name) || name.startsWith('-') || name.includes('..')) {
1168
+ return failure(
1169
+ 'Brew formula name may only contain letters, digits and @ + . _ / - (no spaces or leading dash).',
1170
+ 'BAD_PARAMS',
1171
+ );
1172
+ }
1173
+ let brewPath = null;
1174
+ for (const candidate of BREW_CANDIDATES) {
1175
+ try {
1176
+ if (fs.existsSync(candidate)) {
1177
+ brewPath = candidate;
1178
+ break;
1179
+ }
1180
+ } catch {
1181
+ /* keep looking */
1182
+ }
1183
+ }
1184
+ if (!brewPath) return failure('Homebrew is not installed on this machine.', 'NO_BREW');
1185
+ const result = await run(brewPath, ['uninstall', name], { timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
1186
+ if (!result.ok) return commandFailure(`brew uninstall ${name}`, result);
1187
+ return success(`Uninstalled Homebrew formula "${name}".`, { name });
1188
+ },
1189
+ };
1190
+
1191
+ /* -------------------------------------------------------------------------- */
1192
+ /* Public API */
1193
+ /* -------------------------------------------------------------------------- */
1194
+
1195
+ /**
1196
+ * The frozen action catalog, in stable order.
1197
+ * @returns {Array<{id: string, group: string, danger: string,
1198
+ * requiresAdmin: boolean, params: ActionParam[]}>}
1199
+ */
1200
+ export function actionCatalog() {
1201
+ return SPECS.map((spec) => ({
1202
+ id: spec.id,
1203
+ group: spec.group,
1204
+ danger: spec.danger,
1205
+ requiresAdmin: spec.requiresAdmin,
1206
+ params: spec.params.map((descriptor) => ({ ...descriptor, ...(descriptor.options ? { options: [...descriptor.options] } : {}) })),
1207
+ }));
1208
+ }
1209
+
1210
+ /**
1211
+ * Attempted actions, newest first, at most 50 entries.
1212
+ * @returns {Array<{ts: number, id: string, params: object, ok: boolean, message: string}>}
1213
+ */
1214
+ export function actionHistory() {
1215
+ return history.map((entry) => ({
1216
+ ts: entry.ts,
1217
+ id: entry.id,
1218
+ params: jsonSafe(entry.params),
1219
+ ok: entry.ok,
1220
+ message: entry.message,
1221
+ }));
1222
+ }
1223
+
1224
+ /**
1225
+ * Validate and execute one action. Never throws for bad input; every attempt
1226
+ * (success or failure) is appended to the history.
1227
+ *
1228
+ * @param {string} id action id from {@link actionCatalog}
1229
+ * @param {object} [params]
1230
+ * @returns {Promise<{ok: boolean, message: string, data?: object, code?: string}>}
1231
+ */
1232
+ export async function runAction(id, params) {
1233
+ const ts = Date.now();
1234
+ const actionId = typeof id === 'string' ? id : String(id);
1235
+ const requestParams = paramsObject(jsonSafe(params));
1236
+ let result;
1237
+ try {
1238
+ const spec = SPEC_BY_ID.get(actionId);
1239
+ const runner = RUNNERS[actionId];
1240
+ if (!spec || !runner) {
1241
+ result = failure(`Unknown action: ${actionId}`, 'UNKNOWN_ACTION');
1242
+ } else {
1243
+ const candidate = await runner(requestParams);
1244
+ result =
1245
+ candidate && typeof candidate === 'object' && typeof candidate.ok === 'boolean'
1246
+ ? { ok: candidate.ok === true, message: String(candidate.message ?? ''), ...(candidate.data ? { data: jsonSafe(candidate.data) } : {}), ...(candidate.code ? { code: String(candidate.code) } : {}) }
1247
+ : failure('Action returned an invalid result.', 'INTERNAL');
1248
+ }
1249
+ } catch (err) {
1250
+ result = failure(`Action failed unexpectedly: ${err instanceof Error ? err.message : String(err)}`, 'INTERNAL');
1251
+ }
1252
+
1253
+ recordHistory({ ts, id: actionId, params: requestParams, ok: result.ok === true, message: result.message });
1254
+ return jsonSafe(result);
1255
+ }
1256
+
1257
+ /**
1258
+ * Test seam for `scripts/verify-cleanup.mjs`: the pure cleanup-id validator
1259
+ * (no filesystem access, never deletes anything).
1260
+ */
1261
+ export const __test = { readCleanupIds };