@cfour-hi/dsh-plugin-devicex 0.0.0-stage → 1.5.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.
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/client.js +754 -0
- package/cordis.patch.yml +3 -0
- package/host/actions-darwin.js +1052 -0
- package/host/actions-win32.js +1299 -0
- package/host/actions.js +45 -0
- package/host/benchmark.js +413 -0
- package/host/cleanup-darwin.js +715 -0
- package/host/cleanup-win32.js +760 -0
- package/host/cleanup.js +62 -0
- package/host/collector-darwin.js +2995 -0
- package/host/collector-win32.js +2057 -0
- package/host/collector.js +71 -0
- package/host/proc.js +232 -0
- package/host/tool.js +254 -0
- package/host/transport.js +294 -0
- package/host/win/hardware.ps1 +501 -0
- package/host/win/metrics.ps1 +535 -0
- package/host/win/network.ps1 +322 -0
- package/host/win/processes.ps1 +169 -0
- package/host/win/security.ps1 +197 -0
- package/host/win/software.ps1 +119 -0
- package/host/win/system.ps1 +279 -0
- package/host/win.js +187 -0
- package/icon.svg +18 -0
- package/index.js +65 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
|
@@ -0,0 +1,1052 @@
|
|
|
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 { run } from './proc.js';
|
|
20
|
+
|
|
21
|
+
/** Action groups, in UI order. */
|
|
22
|
+
export const GROUPS = ['process', 'app', 'system', 'network', 'software', 'power'];
|
|
23
|
+
|
|
24
|
+
/** Maximum retained history entries. */
|
|
25
|
+
const HISTORY_LIMIT = 50;
|
|
26
|
+
|
|
27
|
+
/* -------------------------------------------------------------------------- */
|
|
28
|
+
/* Command paths */
|
|
29
|
+
/* -------------------------------------------------------------------------- */
|
|
30
|
+
|
|
31
|
+
const OSASCRIPT = '/usr/bin/osascript';
|
|
32
|
+
const OPEN = '/usr/bin/open';
|
|
33
|
+
const SCUTIL = '/usr/sbin/scutil';
|
|
34
|
+
const LAUNCHCTL = '/bin/launchctl';
|
|
35
|
+
const NETWORKSETUP = '/usr/sbin/networksetup';
|
|
36
|
+
const SOCKETFILTERFW = '/usr/libexec/ApplicationFirewall/socketfilterfw';
|
|
37
|
+
const DSCACHEUTIL = '/usr/bin/dscacheutil';
|
|
38
|
+
const PMSET = '/usr/bin/pmset';
|
|
39
|
+
const BREW_CANDIDATES = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew'];
|
|
40
|
+
|
|
41
|
+
/** AppleScript-visible System Settings anchors for `system.openSettings`. */
|
|
42
|
+
const SETTINGS_PANES = {
|
|
43
|
+
wifi: 'com.apple.wifi-settings-extension',
|
|
44
|
+
network: 'com.apple.network-settings-extension',
|
|
45
|
+
bluetooth: 'com.apple.BluetoothSettings',
|
|
46
|
+
sound: 'com.apple.Sound-Settings.extension',
|
|
47
|
+
display: 'com.apple.Displays-Settings.extension',
|
|
48
|
+
appearance: 'com.apple.Appearance-Settings.extension',
|
|
49
|
+
wallpaper: 'com.apple.Wallpaper-Settings.extension',
|
|
50
|
+
battery: 'com.apple.Battery-Settings.extension',
|
|
51
|
+
general: 'com.apple.General-Settings.extension',
|
|
52
|
+
privacy: 'com.apple.PrivacySecurity.extension',
|
|
53
|
+
users: 'com.apple.Users-Groups-Settings.extension',
|
|
54
|
+
keyboard: 'com.apple.Keyboard-Settings.extension',
|
|
55
|
+
mouse: 'com.apple.Mouse-Settings.extension',
|
|
56
|
+
trackpad: 'com.apple.Trackpad-Settings.extension',
|
|
57
|
+
notifications: 'com.apple.Notifications-Settings.extension',
|
|
58
|
+
softwareupdate: 'com.apple.Software-Update-Settings.extension',
|
|
59
|
+
storage: 'com.apple.Storage-Settings.extension',
|
|
60
|
+
datetime: 'com.apple.Date-Time-Settings.extension',
|
|
61
|
+
language: 'com.apple.Localization-Settings.extension',
|
|
62
|
+
sharing: 'com.apple.Sharing-Settings.extension',
|
|
63
|
+
loginitems: 'com.apple.LoginItems-Settings.extension',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Supported POSIX signals for `process.signal`. */
|
|
67
|
+
const SIGNAL_NAMES = new Map([
|
|
68
|
+
['TERM', 'SIGTERM'],
|
|
69
|
+
['KILL', 'SIGKILL'],
|
|
70
|
+
['INT', 'SIGINT'],
|
|
71
|
+
['STOP', 'SIGSTOP'],
|
|
72
|
+
['CONT', 'SIGCONT'],
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
76
|
+
const HOSTNAME_PATTERN = /^[A-Za-z0-9.-]{1,63}$/;
|
|
77
|
+
const BREW_TOKEN_PATTERN = /^[A-Za-z0-9@+._/-]+$/;
|
|
78
|
+
|
|
79
|
+
/* -------------------------------------------------------------------------- */
|
|
80
|
+
/* JSON safety */
|
|
81
|
+
/* -------------------------------------------------------------------------- */
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Deep JSON-safe clone (undefined → null, non-finite → null, cycles → null).
|
|
85
|
+
* @param {unknown} value
|
|
86
|
+
* @param {Set<object>} [seen]
|
|
87
|
+
* @returns {any}
|
|
88
|
+
*/
|
|
89
|
+
function jsonSafe(value, seen = new Set()) {
|
|
90
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
91
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
92
|
+
if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') return null;
|
|
93
|
+
if (typeof value !== 'object') return null;
|
|
94
|
+
if (seen.has(value)) return null;
|
|
95
|
+
seen.add(value);
|
|
96
|
+
let result;
|
|
97
|
+
if (Array.isArray(value)) {
|
|
98
|
+
result = value.map((item) => jsonSafe(item, seen));
|
|
99
|
+
} else if (value instanceof Date) {
|
|
100
|
+
const time = value.getTime();
|
|
101
|
+
result = Number.isFinite(time) ? time : null;
|
|
102
|
+
} else {
|
|
103
|
+
result = {};
|
|
104
|
+
for (const [key, item] of Object.entries(value)) result[key] = jsonSafe(item, seen);
|
|
105
|
+
}
|
|
106
|
+
seen.delete(value);
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* -------------------------------------------------------------------------- */
|
|
111
|
+
/* Action catalog (frozen order and ids) */
|
|
112
|
+
/* -------------------------------------------------------------------------- */
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @typedef {object} ActionParam
|
|
116
|
+
* @property {string} name
|
|
117
|
+
* @property {'number'|'string'|'boolean'|'select'} type
|
|
118
|
+
* @property {boolean} required
|
|
119
|
+
* @property {string[]} [options]
|
|
120
|
+
* @property {number} [min]
|
|
121
|
+
* @property {number} [max]
|
|
122
|
+
* @property {string} [placeholder]
|
|
123
|
+
* @property {boolean} [multiple] true when the client sends an array of values.
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build a parameter descriptor, dropping absent optional fields so the JSON
|
|
128
|
+
* shape stays exactly the contract's.
|
|
129
|
+
* @param {string} name
|
|
130
|
+
* @param {'number'|'string'|'boolean'|'select'} type
|
|
131
|
+
* @param {object} [options]
|
|
132
|
+
* @returns {ActionParam}
|
|
133
|
+
*/
|
|
134
|
+
function param(name, type, options = {}) {
|
|
135
|
+
/** @type {ActionParam} */
|
|
136
|
+
const descriptor = { name, type, required: options.required !== false };
|
|
137
|
+
if (Array.isArray(options.options)) descriptor.options = [...options.options];
|
|
138
|
+
if (typeof options.min === 'number') descriptor.min = options.min;
|
|
139
|
+
if (typeof options.max === 'number') descriptor.max = options.max;
|
|
140
|
+
if (typeof options.placeholder === 'string') descriptor.placeholder = options.placeholder;
|
|
141
|
+
if (options.multiple === true) descriptor.multiple = true;
|
|
142
|
+
return descriptor;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The frozen action table, in contract order. */
|
|
146
|
+
const SPECS = [
|
|
147
|
+
{
|
|
148
|
+
id: 'process.signal',
|
|
149
|
+
group: 'process',
|
|
150
|
+
danger: 'medium',
|
|
151
|
+
requiresAdmin: false,
|
|
152
|
+
params: [
|
|
153
|
+
param('pid', 'number', { min: 2, max: 4_194_304, placeholder: '1234' }),
|
|
154
|
+
param('signal', 'select', {
|
|
155
|
+
required: false,
|
|
156
|
+
options: [...SIGNAL_NAMES.keys()],
|
|
157
|
+
placeholder: 'TERM',
|
|
158
|
+
}),
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: 'app.quit',
|
|
163
|
+
group: 'app',
|
|
164
|
+
danger: 'low',
|
|
165
|
+
requiresAdmin: false,
|
|
166
|
+
params: [param('name', 'string', { placeholder: 'Safari' })],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: 'app.launch',
|
|
170
|
+
group: 'app',
|
|
171
|
+
danger: 'low',
|
|
172
|
+
requiresAdmin: false,
|
|
173
|
+
params: [param('path', 'string', { placeholder: '/Applications/Safari.app' })],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: 'app.reveal',
|
|
177
|
+
group: 'app',
|
|
178
|
+
danger: 'low',
|
|
179
|
+
requiresAdmin: false,
|
|
180
|
+
params: [param('path', 'string', { placeholder: '/Applications/Safari.app' })],
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'app.uninstall',
|
|
184
|
+
group: 'app',
|
|
185
|
+
danger: 'high',
|
|
186
|
+
requiresAdmin: false,
|
|
187
|
+
params: [
|
|
188
|
+
param('path', 'string', { placeholder: '/Applications/SomeApp.app' }),
|
|
189
|
+
param('mode', 'select', { required: false, options: ['trash', 'delete'], placeholder: 'trash' }),
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: 'system.setHostname',
|
|
194
|
+
group: 'system',
|
|
195
|
+
danger: 'medium',
|
|
196
|
+
requiresAdmin: true,
|
|
197
|
+
params: [
|
|
198
|
+
param('kind', 'select', { options: ['HostName', 'ComputerName', 'LocalHostName'], placeholder: 'ComputerName' }),
|
|
199
|
+
param('value', 'string', { placeholder: 'My-MacBook' }),
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: 'system.setVolume',
|
|
204
|
+
group: 'system',
|
|
205
|
+
danger: 'low',
|
|
206
|
+
requiresAdmin: false,
|
|
207
|
+
params: [param('percent', 'number', { min: 0, max: 100, placeholder: '50' })],
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: 'system.setDarkMode',
|
|
211
|
+
group: 'system',
|
|
212
|
+
danger: 'low',
|
|
213
|
+
requiresAdmin: false,
|
|
214
|
+
params: [param('enabled', 'boolean', { placeholder: 'true' })],
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: 'system.launchAgent',
|
|
218
|
+
group: 'system',
|
|
219
|
+
danger: 'medium',
|
|
220
|
+
requiresAdmin: true,
|
|
221
|
+
params: [
|
|
222
|
+
param('path', 'string', { placeholder: '~/Library/LaunchAgents/com.example.agent.plist' }),
|
|
223
|
+
param('enabled', 'boolean', { placeholder: 'true' }),
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
id: 'system.openSettings',
|
|
228
|
+
group: 'system',
|
|
229
|
+
danger: 'low',
|
|
230
|
+
requiresAdmin: false,
|
|
231
|
+
params: [param('pane', 'select', { options: Object.keys(SETTINGS_PANES), placeholder: 'wifi' })],
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: 'system.toggleFirewall',
|
|
235
|
+
group: 'system',
|
|
236
|
+
danger: 'medium',
|
|
237
|
+
requiresAdmin: true,
|
|
238
|
+
params: [param('enabled', 'boolean', { placeholder: 'true' })],
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: 'system.flushDNS',
|
|
242
|
+
group: 'system',
|
|
243
|
+
danger: 'low',
|
|
244
|
+
requiresAdmin: false,
|
|
245
|
+
params: [],
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
id: 'system.emptyTrash',
|
|
249
|
+
group: 'system',
|
|
250
|
+
danger: 'medium',
|
|
251
|
+
requiresAdmin: false,
|
|
252
|
+
params: [],
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: 'cleanup.delete',
|
|
256
|
+
group: 'system',
|
|
257
|
+
danger: 'high',
|
|
258
|
+
requiresAdmin: false,
|
|
259
|
+
params: [param('ids', 'string', { multiple: true, placeholder: 'user-caches, user-logs' })],
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: 'network.setWifiPower',
|
|
263
|
+
group: 'network',
|
|
264
|
+
danger: 'medium',
|
|
265
|
+
requiresAdmin: false,
|
|
266
|
+
params: [param('enabled', 'boolean', { placeholder: 'true' })],
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: 'network.setDns',
|
|
270
|
+
group: 'network',
|
|
271
|
+
danger: 'medium',
|
|
272
|
+
requiresAdmin: true,
|
|
273
|
+
params: [
|
|
274
|
+
param('service', 'string', { placeholder: 'Wi-Fi' }),
|
|
275
|
+
param('servers', 'string', { placeholder: '8.8.8.8, 1.1.1.1' }),
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: 'power.sleep',
|
|
280
|
+
group: 'power',
|
|
281
|
+
danger: 'low',
|
|
282
|
+
requiresAdmin: false,
|
|
283
|
+
params: [],
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
id: 'power.lock',
|
|
287
|
+
group: 'power',
|
|
288
|
+
danger: 'low',
|
|
289
|
+
requiresAdmin: false,
|
|
290
|
+
params: [],
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: 'software.brewUninstall',
|
|
294
|
+
group: 'software',
|
|
295
|
+
danger: 'medium',
|
|
296
|
+
requiresAdmin: false,
|
|
297
|
+
params: [param('name', 'string', { placeholder: 'wget' })],
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
const SPEC_BY_ID = new Map(SPECS.map((spec) => [spec.id, spec]));
|
|
302
|
+
|
|
303
|
+
/* -------------------------------------------------------------------------- */
|
|
304
|
+
/* History */
|
|
305
|
+
/* -------------------------------------------------------------------------- */
|
|
306
|
+
|
|
307
|
+
/** @type {Array<{ts: number, id: string, params: object, ok: boolean, message: string}>} */
|
|
308
|
+
const history = [];
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @param {{ts: number, id: string, params: object, ok: boolean, message: string}} entry
|
|
312
|
+
* @returns {void}
|
|
313
|
+
*/
|
|
314
|
+
function recordHistory(entry) {
|
|
315
|
+
history.unshift(entry);
|
|
316
|
+
if (history.length > HISTORY_LIMIT) history.length = HISTORY_LIMIT;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/* -------------------------------------------------------------------------- */
|
|
320
|
+
/* Result / validation helpers */
|
|
321
|
+
/* -------------------------------------------------------------------------- */
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* @param {string} message
|
|
325
|
+
* @param {string} [code]
|
|
326
|
+
* @returns {{ok: false, message: string, code?: string}}
|
|
327
|
+
*/
|
|
328
|
+
function failure(message, code) {
|
|
329
|
+
return code ? { ok: false, message, code } : { ok: false, message };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* @param {string} message
|
|
334
|
+
* @param {object} [data]
|
|
335
|
+
* @returns {{ok: true, message: string, data?: object}}
|
|
336
|
+
*/
|
|
337
|
+
function success(message, data) {
|
|
338
|
+
return data && typeof data === 'object' && Object.keys(data).length
|
|
339
|
+
? { ok: true, message, data: jsonSafe(data) }
|
|
340
|
+
: { ok: true, message };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Turn a failed command result into an ActionResult carrying stderr.
|
|
345
|
+
* @param {string} action
|
|
346
|
+
* @param {Awaited<ReturnType<typeof run>>} result
|
|
347
|
+
* @param {string} [code]
|
|
348
|
+
* @returns {{ok: false, message: string, code?: string}}
|
|
349
|
+
*/
|
|
350
|
+
function commandFailure(action, result, code) {
|
|
351
|
+
const detail = (result.stderr || result.error || `exit code ${String(result.code)}`).trim();
|
|
352
|
+
const suffix = result.timedOut ? ' (timed out)' : '';
|
|
353
|
+
return failure(`${action} failed: ${detail || 'unknown error'}${suffix}`, code ?? 'COMMAND_FAILED');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Read a required integer parameter.
|
|
358
|
+
* @param {Record<string, unknown>} params
|
|
359
|
+
* @param {string} name
|
|
360
|
+
* @param {{min?: number, max?: number}} [bounds]
|
|
361
|
+
* @returns {{ok: true, value: number} | {ok: false, message: string}}
|
|
362
|
+
*/
|
|
363
|
+
function readInteger(params, name, bounds = {}) {
|
|
364
|
+
const raw = params[name];
|
|
365
|
+
let value = null;
|
|
366
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) value = raw;
|
|
367
|
+
else if (typeof raw === 'string' && /^-?\d+$/.test(raw.trim())) value = Number(raw.trim());
|
|
368
|
+
if (value === null || !Number.isInteger(value)) {
|
|
369
|
+
return { ok: false, message: `Parameter "${name}" must be an integer.` };
|
|
370
|
+
}
|
|
371
|
+
const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = bounds;
|
|
372
|
+
if (value < min || value > max) {
|
|
373
|
+
return { ok: false, message: `Parameter "${name}" must be between ${min} and ${max}.` };
|
|
374
|
+
}
|
|
375
|
+
return { ok: true, value };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Read a required number parameter.
|
|
380
|
+
* @param {Record<string, unknown>} params
|
|
381
|
+
* @param {string} name
|
|
382
|
+
* @param {{min?: number, max?: number}} [bounds]
|
|
383
|
+
* @returns {{ok: true, value: number} | {ok: false, message: string}}
|
|
384
|
+
*/
|
|
385
|
+
function readNumber(params, name, bounds = {}) {
|
|
386
|
+
const raw = params[name];
|
|
387
|
+
let value = null;
|
|
388
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) value = raw;
|
|
389
|
+
else if (typeof raw === 'string' && raw.trim() !== '' && Number.isFinite(Number(raw))) value = Number(raw);
|
|
390
|
+
if (value === null) return { ok: false, message: `Parameter "${name}" must be a number.` };
|
|
391
|
+
const { min = -Number.MAX_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = bounds;
|
|
392
|
+
if (value < min || value > max) {
|
|
393
|
+
return { ok: false, message: `Parameter "${name}" must be between ${min} and ${max}.` };
|
|
394
|
+
}
|
|
395
|
+
return { ok: true, value };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Read a required string parameter.
|
|
400
|
+
* @param {Record<string, unknown>} params
|
|
401
|
+
* @param {string} name
|
|
402
|
+
* @param {{maxLength?: number, allowControlChars?: boolean}} [options]
|
|
403
|
+
* @returns {{ok: true, value: string} | {ok: false, message: string}}
|
|
404
|
+
*/
|
|
405
|
+
function readString(params, name, options = {}) {
|
|
406
|
+
const raw = params[name];
|
|
407
|
+
const { maxLength = 1_024, allowControlChars = false } = options;
|
|
408
|
+
if (typeof raw !== 'string') return { ok: false, message: `Parameter "${name}" must be a string.` };
|
|
409
|
+
const value = raw.trim();
|
|
410
|
+
if (value === '') return { ok: false, message: `Parameter "${name}" must not be empty.` };
|
|
411
|
+
if (value.length > maxLength) {
|
|
412
|
+
return { ok: false, message: `Parameter "${name}" must be at most ${maxLength} characters.` };
|
|
413
|
+
}
|
|
414
|
+
if (!allowControlChars && CONTROL_CHARS.test(value)) {
|
|
415
|
+
return { ok: false, message: `Parameter "${name}" must not contain control characters.` };
|
|
416
|
+
}
|
|
417
|
+
return { ok: true, value };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Read a required boolean parameter (accepts `true`/`false` and their string
|
|
422
|
+
* spellings from an HTML form).
|
|
423
|
+
* @param {Record<string, unknown>} params
|
|
424
|
+
* @param {string} name
|
|
425
|
+
* @returns {{ok: true, value: boolean} | {ok: false, message: string}}
|
|
426
|
+
*/
|
|
427
|
+
function readBoolean(params, name) {
|
|
428
|
+
const raw = params[name];
|
|
429
|
+
if (typeof raw === 'boolean') return { ok: true, value: raw };
|
|
430
|
+
if (raw === 'true' || raw === '1' || raw === 1) return { ok: true, value: true };
|
|
431
|
+
if (raw === 'false' || raw === '0' || raw === 0) return { ok: true, value: false };
|
|
432
|
+
return { ok: false, message: `Parameter "${name}" must be a boolean.` };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Read a required select parameter.
|
|
437
|
+
* @param {Record<string, unknown>} params
|
|
438
|
+
* @param {string} name
|
|
439
|
+
* @param {readonly string[]} options
|
|
440
|
+
* @param {string} [fallback] default when absent.
|
|
441
|
+
* @returns {{ok: true, value: string} | {ok: false, message: string}}
|
|
442
|
+
*/
|
|
443
|
+
function readSelect(params, name, options, fallback) {
|
|
444
|
+
const raw = params[name];
|
|
445
|
+
if ((raw === undefined || raw === null || raw === '') && fallback !== undefined) {
|
|
446
|
+
return { ok: true, value: fallback };
|
|
447
|
+
}
|
|
448
|
+
if (typeof raw !== 'string' || !options.includes(raw)) {
|
|
449
|
+
return { ok: false, message: `Parameter "${name}" must be one of: ${options.join(', ')}.` };
|
|
450
|
+
}
|
|
451
|
+
return { ok: true, value: raw };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Normalise the request's params object.
|
|
456
|
+
* @param {unknown} value
|
|
457
|
+
* @returns {Record<string, unknown>}
|
|
458
|
+
*/
|
|
459
|
+
function paramsObject(value) {
|
|
460
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return /** @type {Record<string, unknown>} */ (value);
|
|
461
|
+
return {};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Read the `ids` parameter of `cleanup.delete`: an array (or comma separated
|
|
466
|
+
* string) of 1-20 unique ids from the cleanup allowlist. Unknown ids, caller
|
|
467
|
+
* paths, duplicates and oversized batches are refused before any filesystem
|
|
468
|
+
* call.
|
|
469
|
+
* @param {Record<string, unknown>} params
|
|
470
|
+
* @returns {{ok: true, value: string[]} | {ok: false, message: string}}
|
|
471
|
+
*/
|
|
472
|
+
function readCleanupIds(params) {
|
|
473
|
+
const raw = params.ids;
|
|
474
|
+
/** @type {unknown[]} */
|
|
475
|
+
let values;
|
|
476
|
+
if (Array.isArray(raw)) {
|
|
477
|
+
values = raw;
|
|
478
|
+
} else if (typeof raw === 'string') {
|
|
479
|
+
values = raw.split(',');
|
|
480
|
+
} else {
|
|
481
|
+
return {
|
|
482
|
+
ok: false,
|
|
483
|
+
message: 'Parameter "ids" must be an array of cleanup item ids (or a comma separated string).',
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
/** @type {string[]} */
|
|
487
|
+
const ids = [];
|
|
488
|
+
for (const value of values) {
|
|
489
|
+
if (typeof value !== 'string') return { ok: false, message: 'Cleanup item ids must be strings.' };
|
|
490
|
+
const id = value.trim();
|
|
491
|
+
if (id !== '') ids.push(id);
|
|
492
|
+
}
|
|
493
|
+
if (ids.length < 1 || ids.length > 20) {
|
|
494
|
+
return { ok: false, message: 'Parameter "ids" must contain 1 to 20 cleanup item ids.' };
|
|
495
|
+
}
|
|
496
|
+
const known = new Set(cleanupItems().map((item) => item.id));
|
|
497
|
+
const seen = new Set();
|
|
498
|
+
for (const id of ids) {
|
|
499
|
+
if (seen.has(id)) return { ok: false, message: `Duplicate cleanup item id: ${id}` };
|
|
500
|
+
seen.add(id);
|
|
501
|
+
if (!known.has(id)) return { ok: false, message: `Unknown cleanup item id: ${id}` };
|
|
502
|
+
}
|
|
503
|
+
return { ok: true, value: ids };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/* -------------------------------------------------------------------------- */
|
|
507
|
+
/* Filesystem guards */
|
|
508
|
+
/* -------------------------------------------------------------------------- */
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Resolve an existing path through symlinks.
|
|
512
|
+
* @param {string} raw
|
|
513
|
+
* @returns {Promise<{ok: true, path: string, stat: fs.Stats} | {ok: false, message: string, code?: string}>}
|
|
514
|
+
*/
|
|
515
|
+
async function resolveExisting(raw) {
|
|
516
|
+
const resolved = path.resolve(raw);
|
|
517
|
+
try {
|
|
518
|
+
const real = await fsp.realpath(resolved);
|
|
519
|
+
const stat = await fsp.stat(real);
|
|
520
|
+
return { ok: true, path: real, stat };
|
|
521
|
+
} catch (err) {
|
|
522
|
+
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : 'ENOENT';
|
|
523
|
+
return failure(`Path does not exist: ${resolved}`, code);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* True when `candidate` is `root` or lives beneath it (both already resolved).
|
|
529
|
+
* @param {string} candidate
|
|
530
|
+
* @param {string} root
|
|
531
|
+
* @returns {boolean}
|
|
532
|
+
*/
|
|
533
|
+
function isInside(candidate, root) {
|
|
534
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The three directories allowed as uninstall roots.
|
|
539
|
+
* @returns {string[]}
|
|
540
|
+
*/
|
|
541
|
+
function uninstallRoots() {
|
|
542
|
+
return ['/Applications', path.join(os.homedir(), 'Applications'), '/System/Applications'];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Resolve and guard an `.app` bundle for uninstall.
|
|
547
|
+
* @param {string} raw
|
|
548
|
+
* @param {string} mode
|
|
549
|
+
* @returns {Promise<{ok: true, path: string} | {ok: false, message: string, code?: string}>}
|
|
550
|
+
*/
|
|
551
|
+
async function guardedAppBundle(raw, mode) {
|
|
552
|
+
const resolved = await resolveExisting(raw);
|
|
553
|
+
if (!resolved.ok) return resolved;
|
|
554
|
+
if (!resolved.stat.isDirectory()) {
|
|
555
|
+
return failure(`Not an application bundle: ${resolved.path}`, 'NOT_A_BUNDLE');
|
|
556
|
+
}
|
|
557
|
+
if (!/\.app$/i.test(resolved.path)) {
|
|
558
|
+
return failure(`Path must end in ".app" after resolution: ${resolved.path}`, 'NOT_A_BUNDLE');
|
|
559
|
+
}
|
|
560
|
+
const roots = uninstallRoots();
|
|
561
|
+
const insideRoot = roots.some((root) => isInside(resolved.path, root) && resolved.path !== root);
|
|
562
|
+
if (!insideRoot) {
|
|
563
|
+
return failure(
|
|
564
|
+
`Refusing to uninstall outside ${roots.join(', ')}: ${resolved.path}`,
|
|
565
|
+
'PATH_NOT_ALLOWED',
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
if (resolved.path.startsWith('/System/')) {
|
|
569
|
+
return failure(`Refusing to uninstall a system application: ${resolved.path}`, 'SYSTEM_APP');
|
|
570
|
+
}
|
|
571
|
+
if (mode === 'delete' && resolved.path === '/Applications') {
|
|
572
|
+
return failure('Refusing to delete the /Applications directory itself.', 'PATH_NOT_ALLOWED');
|
|
573
|
+
}
|
|
574
|
+
return { ok: true, path: resolved.path };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Move a bundle into the user's Trash with a unique name.
|
|
579
|
+
* @param {string} source resolved bundle path
|
|
580
|
+
* @returns {Promise<string>} the destination path
|
|
581
|
+
*/
|
|
582
|
+
async function moveToTrash(source) {
|
|
583
|
+
const trash = path.join(os.homedir(), '.Trash');
|
|
584
|
+
await fsp.mkdir(trash, { recursive: true });
|
|
585
|
+
const base = path.basename(source);
|
|
586
|
+
const stem = base.replace(/\.app$/i, '');
|
|
587
|
+
let destination = path.join(trash, `${stem}-${Date.now()}.app`);
|
|
588
|
+
let suffix = 1;
|
|
589
|
+
while (true) {
|
|
590
|
+
try {
|
|
591
|
+
await fsp.access(destination);
|
|
592
|
+
destination = path.join(trash, `${stem}-${Date.now()}-${suffix}.app`);
|
|
593
|
+
suffix += 1;
|
|
594
|
+
} catch {
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
await fsp.rename(source, destination);
|
|
600
|
+
return destination;
|
|
601
|
+
} catch (err) {
|
|
602
|
+
if (err && typeof err === 'object' && 'code' in err && err.code === 'EXDEV') {
|
|
603
|
+
await fsp.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
|
|
604
|
+
await fsp.rm(source, { recursive: true, force: false, maxRetries: 2, retryDelay: 100 });
|
|
605
|
+
return destination;
|
|
606
|
+
}
|
|
607
|
+
throw err;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/* -------------------------------------------------------------------------- */
|
|
612
|
+
/* Action runners */
|
|
613
|
+
/* -------------------------------------------------------------------------- */
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* @typedef {(params: Record<string, unknown>) => Promise<{ok: boolean, message: string, data?: object, code?: string}>} Runner
|
|
617
|
+
*/
|
|
618
|
+
|
|
619
|
+
/** @type {Record<string, Runner>} */
|
|
620
|
+
const RUNNERS = {
|
|
621
|
+
async 'process.signal'(params) {
|
|
622
|
+
const pidRead = readInteger(params, 'pid', { min: 2, max: 4_194_304 });
|
|
623
|
+
if (!pidRead.ok) return failure(pidRead.message, 'BAD_PARAMS');
|
|
624
|
+
const signalRead = readSelect(params, 'signal', [...SIGNAL_NAMES.keys()], 'TERM');
|
|
625
|
+
if (!signalRead.ok) return failure(signalRead.message, 'BAD_PARAMS');
|
|
626
|
+
const pid = pidRead.value;
|
|
627
|
+
if (pid === process.pid || pid === process.ppid) {
|
|
628
|
+
return failure(`Refusing to signal the Device X host process (pid ${pid}).`, 'PROTECTED_PID');
|
|
629
|
+
}
|
|
630
|
+
if (pid === 1) return failure('Refusing to signal launchd (pid 1).', 'PROTECTED_PID');
|
|
631
|
+
const signal = SIGNAL_NAMES.get(signalRead.value);
|
|
632
|
+
try {
|
|
633
|
+
process.kill(pid, signal);
|
|
634
|
+
return success(`Sent SIG${signalRead.value} to pid ${pid}.`, { pid, signal: signalRead.value });
|
|
635
|
+
} catch (err) {
|
|
636
|
+
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : 'ESRCH';
|
|
637
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
638
|
+
return failure(`Could not signal pid ${pid} (${code}): ${reason}`, code);
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
|
|
642
|
+
async 'app.quit'(params) {
|
|
643
|
+
const nameRead = readString(params, 'name', { maxLength: 200 });
|
|
644
|
+
if (!nameRead.ok) return failure(nameRead.message, 'BAD_PARAMS');
|
|
645
|
+
const escaped = nameRead.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
646
|
+
const result = await run(OSASCRIPT, ['-e', `tell application "${escaped}" to quit`], { timeout: 20_000 });
|
|
647
|
+
if (!result.ok) return commandFailure(`Quit "${nameRead.value}"`, result);
|
|
648
|
+
return success(`Asked "${nameRead.value}" to quit.`, { name: nameRead.value });
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
async 'app.launch'(params) {
|
|
652
|
+
const pathRead = readString(params, 'path', { maxLength: 1_024 });
|
|
653
|
+
if (!pathRead.ok) return failure(pathRead.message, 'BAD_PARAMS');
|
|
654
|
+
const resolved = await resolveExisting(pathRead.value);
|
|
655
|
+
if (!resolved.ok) return resolved;
|
|
656
|
+
const result = await run(OPEN, ['-a', resolved.path], { timeout: 15_000 });
|
|
657
|
+
if (!result.ok) return commandFailure(`Launch ${resolved.path}`, result);
|
|
658
|
+
return success(`Launched ${path.basename(resolved.path)}.`, { path: resolved.path });
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
async 'app.reveal'(params) {
|
|
662
|
+
const pathRead = readString(params, 'path', { maxLength: 1_024 });
|
|
663
|
+
if (!pathRead.ok) return failure(pathRead.message, 'BAD_PARAMS');
|
|
664
|
+
const resolved = await resolveExisting(pathRead.value);
|
|
665
|
+
if (!resolved.ok) return resolved;
|
|
666
|
+
const result = await run(OPEN, ['-R', resolved.path], { timeout: 15_000 });
|
|
667
|
+
if (!result.ok) return commandFailure(`Reveal ${resolved.path}`, result);
|
|
668
|
+
return success(`Revealed ${path.basename(resolved.path)} in Finder.`, { path: resolved.path });
|
|
669
|
+
},
|
|
670
|
+
|
|
671
|
+
async 'app.uninstall'(params) {
|
|
672
|
+
const pathRead = readString(params, 'path', { maxLength: 1_024 });
|
|
673
|
+
if (!pathRead.ok) return failure(pathRead.message, 'BAD_PARAMS');
|
|
674
|
+
const modeRead = readSelect(params, 'mode', ['trash', 'delete'], 'trash');
|
|
675
|
+
if (!modeRead.ok) return failure(modeRead.message, 'BAD_PARAMS');
|
|
676
|
+
const guarded = await guardedAppBundle(pathRead.value, modeRead.value);
|
|
677
|
+
if (!guarded.ok) return guarded;
|
|
678
|
+
const name = path.basename(guarded.path);
|
|
679
|
+
if (modeRead.value === 'trash') {
|
|
680
|
+
try {
|
|
681
|
+
const destination = await moveToTrash(guarded.path);
|
|
682
|
+
return success(`Moved ${name} to Trash as ${path.basename(destination)}.`, {
|
|
683
|
+
trashedPath: destination,
|
|
684
|
+
mode: 'trash',
|
|
685
|
+
});
|
|
686
|
+
} catch (err) {
|
|
687
|
+
return failure(
|
|
688
|
+
`Could not move ${name} to Trash: ${err instanceof Error ? err.message : String(err)}`,
|
|
689
|
+
'TRASH_FAILED',
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
await fsp.rm(guarded.path, { recursive: true, force: false, maxRetries: 3, retryDelay: 150 });
|
|
695
|
+
return success(`Deleted ${name}.`, { path: guarded.path, mode: 'delete' });
|
|
696
|
+
} catch (err) {
|
|
697
|
+
return failure(
|
|
698
|
+
`Could not delete ${name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
699
|
+
'DELETE_FAILED',
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
|
|
704
|
+
async 'system.setHostname'(params) {
|
|
705
|
+
const kindRead = readSelect(params, 'kind', ['HostName', 'ComputerName', 'LocalHostName']);
|
|
706
|
+
if (!kindRead.ok) return failure(kindRead.message, 'BAD_PARAMS');
|
|
707
|
+
const valueRead = readString(params, 'value', { maxLength: 63 });
|
|
708
|
+
if (!valueRead.ok) return failure(valueRead.message, 'BAD_PARAMS');
|
|
709
|
+
if (!HOSTNAME_PATTERN.test(valueRead.value)) {
|
|
710
|
+
return failure(
|
|
711
|
+
'Hostname value must be 1-63 letters, digits, dots or dashes (no spaces or underscores).',
|
|
712
|
+
'BAD_PARAMS',
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
const result = await run(SCUTIL, ['--set', kindRead.value, valueRead.value], { timeout: 10_000 });
|
|
716
|
+
if (!result.ok) return commandFailure(`Set ${kindRead.value}`, result);
|
|
717
|
+
return success(`Set ${kindRead.value} to "${valueRead.value}".`, {
|
|
718
|
+
kind: kindRead.value,
|
|
719
|
+
value: valueRead.value,
|
|
720
|
+
});
|
|
721
|
+
},
|
|
722
|
+
|
|
723
|
+
async 'system.setVolume'(params) {
|
|
724
|
+
const percentRead = readNumber(params, 'percent', { min: 0, max: 100 });
|
|
725
|
+
if (!percentRead.ok) return failure(percentRead.message, 'BAD_PARAMS');
|
|
726
|
+
const percent = Math.round(percentRead.value);
|
|
727
|
+
const result = await run(OSASCRIPT, ['-e', `set volume output volume ${percent}`], { timeout: 10_000 });
|
|
728
|
+
if (!result.ok) return commandFailure('Set volume', result);
|
|
729
|
+
return success(`Set output volume to ${percent}%.`, { percent });
|
|
730
|
+
},
|
|
731
|
+
|
|
732
|
+
async 'system.setDarkMode'(params) {
|
|
733
|
+
const enabledRead = readBoolean(params, 'enabled');
|
|
734
|
+
if (!enabledRead.ok) return failure(enabledRead.message, 'BAD_PARAMS');
|
|
735
|
+
const script =
|
|
736
|
+
'tell application "System Events" to tell appearance preferences to set dark mode to ' +
|
|
737
|
+
(enabledRead.value ? 'true' : 'false');
|
|
738
|
+
const result = await run(OSASCRIPT, ['-e', script], { timeout: 10_000 });
|
|
739
|
+
if (!result.ok) return commandFailure('Set dark mode', result);
|
|
740
|
+
return success(`Dark mode ${enabledRead.value ? 'enabled' : 'disabled'}.`, { enabled: enabledRead.value });
|
|
741
|
+
},
|
|
742
|
+
|
|
743
|
+
async 'system.launchAgent'(params) {
|
|
744
|
+
const pathRead = readString(params, 'path', { maxLength: 1_024 });
|
|
745
|
+
if (!pathRead.ok) return failure(pathRead.message, 'BAD_PARAMS');
|
|
746
|
+
const enabledRead = readBoolean(params, 'enabled');
|
|
747
|
+
if (!enabledRead.ok) return failure(enabledRead.message, 'BAD_PARAMS');
|
|
748
|
+
const resolved = await resolveExisting(pathRead.value);
|
|
749
|
+
if (!resolved.ok) return resolved;
|
|
750
|
+
if (!/\.plist$/i.test(resolved.path)) {
|
|
751
|
+
return failure(`Launch agent path must end in ".plist": ${resolved.path}`, 'NOT_A_PLIST');
|
|
752
|
+
}
|
|
753
|
+
const home = os.homedir();
|
|
754
|
+
const allowed = [
|
|
755
|
+
path.join(home, 'Library', 'LaunchAgents'),
|
|
756
|
+
'/Library/LaunchAgents',
|
|
757
|
+
'/Library/LaunchDaemons',
|
|
758
|
+
];
|
|
759
|
+
if (!allowed.some((root) => isInside(resolved.path, root) && resolved.path !== root)) {
|
|
760
|
+
return failure(
|
|
761
|
+
`Refusing to manage a launch agent outside ${allowed.join(', ')}: ${resolved.path}`,
|
|
762
|
+
'PATH_NOT_ALLOWED',
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
const daemon = resolved.path.startsWith('/Library/LaunchDaemons');
|
|
766
|
+
const domain = daemon ? 'system' : `gui/${typeof process.getuid === 'function' ? process.getuid() : 501}`;
|
|
767
|
+
const verb = enabledRead.value ? 'bootstrap' : 'bootout';
|
|
768
|
+
const result = await run(LAUNCHCTL, [verb, domain, resolved.path], { timeout: 20_000 });
|
|
769
|
+
if (!result.ok) return commandFailure(`${enabledRead.value ? 'Load' : 'Unload'} launch agent`, result);
|
|
770
|
+
return success(
|
|
771
|
+
`${enabledRead.value ? 'Loaded' : 'Unloaded'} launch agent ${path.basename(resolved.path)}.`,
|
|
772
|
+
{ path: resolved.path, domain, enabled: enabledRead.value },
|
|
773
|
+
);
|
|
774
|
+
},
|
|
775
|
+
|
|
776
|
+
async 'system.openSettings'(params) {
|
|
777
|
+
const paneRead = readSelect(params, 'pane', Object.keys(SETTINGS_PANES));
|
|
778
|
+
if (!paneRead.ok) return failure(paneRead.message, 'BAD_PARAMS');
|
|
779
|
+
const anchor = SETTINGS_PANES[paneRead.value];
|
|
780
|
+
const url = `x-apple.systempreferences:${anchor}`;
|
|
781
|
+
const result = await run(OPEN, [url], { timeout: 10_000 });
|
|
782
|
+
if (!result.ok) return commandFailure('Open System Settings', result);
|
|
783
|
+
return success(`Opened System Settings (${paneRead.value}).`, { pane: paneRead.value, url });
|
|
784
|
+
},
|
|
785
|
+
|
|
786
|
+
async 'system.toggleFirewall'(params) {
|
|
787
|
+
const enabledRead = readBoolean(params, 'enabled');
|
|
788
|
+
if (!enabledRead.ok) return failure(enabledRead.message, 'BAD_PARAMS');
|
|
789
|
+
const state = enabledRead.value ? 'on' : 'off';
|
|
790
|
+
const result = await run(SOCKETFILTERFW, ['--setglobalstate', state], { timeout: 15_000 });
|
|
791
|
+
if (!result.ok) return commandFailure(`Turn firewall ${state}`, result);
|
|
792
|
+
return success(`Requested firewall ${state}.`, { enabled: enabledRead.value });
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
async 'system.flushDNS'(_params) {
|
|
796
|
+
const result = await run(DSCACHEUTIL, ['-flushcache'], { timeout: 10_000 });
|
|
797
|
+
if (!result.ok) return commandFailure('Flush DNS cache', result);
|
|
798
|
+
return success(
|
|
799
|
+
'Flushed the DNS cache. If lookups still fail, restart mDNSResponder with: sudo killall -HUP mDNSResponder',
|
|
800
|
+
{ mDNSResponderRestarted: false },
|
|
801
|
+
);
|
|
802
|
+
},
|
|
803
|
+
|
|
804
|
+
async 'system.emptyTrash'(_params) {
|
|
805
|
+
const trash = path.join(os.homedir(), '.Trash');
|
|
806
|
+
let entries;
|
|
807
|
+
try {
|
|
808
|
+
entries = await fsp.readdir(trash);
|
|
809
|
+
} catch (err) {
|
|
810
|
+
return failure(
|
|
811
|
+
`Could not read Trash: ${err instanceof Error ? err.message : String(err)}`,
|
|
812
|
+
'TRASH_READ_FAILED',
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
let removed = 0;
|
|
816
|
+
/** @type {string[]} */
|
|
817
|
+
const failed = [];
|
|
818
|
+
for (const entry of entries) {
|
|
819
|
+
const target = path.join(trash, entry);
|
|
820
|
+
if (!isInside(target, trash)) continue;
|
|
821
|
+
try {
|
|
822
|
+
await fsp.rm(target, { recursive: true, force: true, maxRetries: 1, retryDelay: 100 });
|
|
823
|
+
removed += 1;
|
|
824
|
+
} catch (err) {
|
|
825
|
+
failed.push(`${entry}: ${err instanceof Error ? err.message : String(err)}`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (failed.length) {
|
|
829
|
+
return failure(
|
|
830
|
+
`Removed ${removed} of ${entries.length} item(s) from Trash. Failures: ${failed.slice(0, 3).join('; ')}`,
|
|
831
|
+
'TRASH_PARTIAL',
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
return success(`Removed ${removed} item(s) from Trash.`, { removed });
|
|
835
|
+
},
|
|
836
|
+
|
|
837
|
+
async 'cleanup.delete'(params) {
|
|
838
|
+
const idsRead = readCleanupIds(params);
|
|
839
|
+
if (!idsRead.ok) return failure(idsRead.message, 'BAD_PARAMS');
|
|
840
|
+
// The cleanup module owns the allowlist and the fixed absolute paths: the
|
|
841
|
+
// action passes ids only, never a path, and reports per-item outcomes.
|
|
842
|
+
return deleteCleanup(idsRead.value);
|
|
843
|
+
},
|
|
844
|
+
|
|
845
|
+
async 'network.setWifiPower'(params) {
|
|
846
|
+
const enabledRead = readBoolean(params, 'enabled');
|
|
847
|
+
if (!enabledRead.ok) return failure(enabledRead.message, 'BAD_PARAMS');
|
|
848
|
+
const ports = await run(NETWORKSETUP, ['-listallhardwareports'], { timeout: 10_000 });
|
|
849
|
+
let device = 'en0';
|
|
850
|
+
if (ports.ok) {
|
|
851
|
+
const blocks = ports.stdout.split(/\n\s*\n/);
|
|
852
|
+
for (const block of blocks) {
|
|
853
|
+
if (!/Hardware Port:\s*Wi-?Fi/i.test(block)) continue;
|
|
854
|
+
const match = /Device:\s*(\S+)/.exec(block);
|
|
855
|
+
if (match) {
|
|
856
|
+
device = match[1];
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
const state = enabledRead.value ? 'on' : 'off';
|
|
862
|
+
const result = await run(NETWORKSETUP, ['-setairportpower', device, state], { timeout: 15_000 });
|
|
863
|
+
if (!result.ok) return commandFailure(`Turn Wi-Fi ${state} (${device})`, result);
|
|
864
|
+
return success(`Requested Wi-Fi ${state} on ${device}.`, { device, enabled: enabledRead.value });
|
|
865
|
+
},
|
|
866
|
+
|
|
867
|
+
async 'network.setDns'(params) {
|
|
868
|
+
const serviceRead = readString(params, 'service', { maxLength: 64 });
|
|
869
|
+
if (!serviceRead.ok) return failure(serviceRead.message, 'BAD_PARAMS');
|
|
870
|
+
if (CONTROL_CHARS.test(serviceRead.value) || serviceRead.value.startsWith('-')) {
|
|
871
|
+
return failure('Parameter "service" is not a valid network service name.', 'BAD_PARAMS');
|
|
872
|
+
}
|
|
873
|
+
const rawServers = params.servers;
|
|
874
|
+
/** @type {string[]} */
|
|
875
|
+
let servers;
|
|
876
|
+
if (Array.isArray(rawServers)) {
|
|
877
|
+
servers = rawServers.map((item) => String(item).trim()).filter((item) => item !== '');
|
|
878
|
+
} else if (typeof rawServers === 'string') {
|
|
879
|
+
servers = rawServers
|
|
880
|
+
.split(/[,\s]+/)
|
|
881
|
+
.map((item) => item.trim())
|
|
882
|
+
.filter((item) => item !== '');
|
|
883
|
+
} else {
|
|
884
|
+
return failure('Parameter "servers" must be an array of IP addresses (or a comma separated string).', 'BAD_PARAMS');
|
|
885
|
+
}
|
|
886
|
+
if (servers.length > 4) return failure('At most 4 DNS servers are allowed.', 'BAD_PARAMS');
|
|
887
|
+
for (const server of servers) {
|
|
888
|
+
if (!net.isIP(server)) return failure(`Invalid IP address: ${server}`, 'BAD_PARAMS');
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// The page may name either the networksetup service ("Wi-Fi", a hardware
|
|
892
|
+
// port name) or the BSD interface device ("en0"); resolve to the service
|
|
893
|
+
// networksetup accepts before touching anything.
|
|
894
|
+
let serviceName = serviceRead.value;
|
|
895
|
+
const servicesRes = await run(NETWORKSETUP, ['-listallnetworkservices'], { timeout: 10_000 });
|
|
896
|
+
if (servicesRes.ok) {
|
|
897
|
+
const known = servicesRes.stdout
|
|
898
|
+
.split(/\r?\n/)
|
|
899
|
+
.map((line) => line.replace(/^\*/, '').trim())
|
|
900
|
+
.filter((line) => line && !line.startsWith('An asterisk'));
|
|
901
|
+
const exact =
|
|
902
|
+
known.find((name) => name === serviceName) ??
|
|
903
|
+
known.find((name) => name.toLowerCase() === serviceName.toLowerCase());
|
|
904
|
+
if (exact !== undefined) {
|
|
905
|
+
serviceName = exact;
|
|
906
|
+
} else {
|
|
907
|
+
let mapped;
|
|
908
|
+
const portsRes = await run(NETWORKSETUP, ['-listallhardwareports'], { timeout: 10_000 });
|
|
909
|
+
if (portsRes.ok) {
|
|
910
|
+
for (const block of portsRes.stdout.split(/\n\s*\n/)) {
|
|
911
|
+
const port = /Hardware Port:\s*(.+)/.exec(block);
|
|
912
|
+
const device = /Device:\s*(\S+)/.exec(block);
|
|
913
|
+
if (port === null || device === null) continue;
|
|
914
|
+
const portName = port[1].trim();
|
|
915
|
+
if (device[1] !== serviceName && portName.toLowerCase() !== serviceName.toLowerCase()) continue;
|
|
916
|
+
mapped = known.find((name) => name.toLowerCase() === portName.toLowerCase());
|
|
917
|
+
if (mapped !== undefined) break;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (mapped === undefined) {
|
|
921
|
+
return failure(
|
|
922
|
+
`Unknown network service: ${serviceName}. Known services: ${known.join(', ') || '(none)'}`,
|
|
923
|
+
'UNKNOWN_SERVICE',
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
serviceName = mapped;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
const args = ['-setdnsservers', serviceName, ...(servers.length ? servers : ['Empty'])];
|
|
930
|
+
const result = await run(NETWORKSETUP, args, { timeout: 20_000 });
|
|
931
|
+
if (!result.ok) return commandFailure('Set DNS servers', result);
|
|
932
|
+
return success(
|
|
933
|
+
servers.length
|
|
934
|
+
? `Set DNS servers for ${serviceName} to ${servers.join(', ')}.`
|
|
935
|
+
: `Cleared DNS servers for ${serviceName}.`,
|
|
936
|
+
{ service: serviceName, servers },
|
|
937
|
+
);
|
|
938
|
+
},
|
|
939
|
+
|
|
940
|
+
async 'power.sleep'(_params) {
|
|
941
|
+
const result = await run(PMSET, ['sleepnow'], { timeout: 10_000 });
|
|
942
|
+
if (!result.ok) return commandFailure('Sleep', result);
|
|
943
|
+
return success('Requested system sleep.');
|
|
944
|
+
},
|
|
945
|
+
|
|
946
|
+
async 'power.lock'(_params) {
|
|
947
|
+
const script =
|
|
948
|
+
'tell application "System Events" to keystroke "q" using {control down, command down}';
|
|
949
|
+
const result = await run(OSASCRIPT, ['-e', script], { timeout: 10_000 });
|
|
950
|
+
if (!result.ok) return commandFailure('Lock screen', result);
|
|
951
|
+
return success('Requested screen lock.');
|
|
952
|
+
},
|
|
953
|
+
|
|
954
|
+
async 'software.brewUninstall'(params) {
|
|
955
|
+
const nameRead = readString(params, 'name', { maxLength: 128 });
|
|
956
|
+
if (!nameRead.ok) return failure(nameRead.message, 'BAD_PARAMS');
|
|
957
|
+
const name = nameRead.value;
|
|
958
|
+
if (!BREW_TOKEN_PATTERN.test(name) || name.startsWith('-') || name.includes('..')) {
|
|
959
|
+
return failure(
|
|
960
|
+
'Brew formula name may only contain letters, digits and @ + . _ / - (no spaces or leading dash).',
|
|
961
|
+
'BAD_PARAMS',
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
let brewPath = null;
|
|
965
|
+
for (const candidate of BREW_CANDIDATES) {
|
|
966
|
+
try {
|
|
967
|
+
if (fs.existsSync(candidate)) {
|
|
968
|
+
brewPath = candidate;
|
|
969
|
+
break;
|
|
970
|
+
}
|
|
971
|
+
} catch {
|
|
972
|
+
/* keep looking */
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (!brewPath) return failure('Homebrew is not installed on this machine.', 'NO_BREW');
|
|
976
|
+
const result = await run(brewPath, ['uninstall', name], { timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
|
|
977
|
+
if (!result.ok) return commandFailure(`brew uninstall ${name}`, result);
|
|
978
|
+
return success(`Uninstalled Homebrew formula "${name}".`, { name });
|
|
979
|
+
},
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
/* -------------------------------------------------------------------------- */
|
|
983
|
+
/* Public API */
|
|
984
|
+
/* -------------------------------------------------------------------------- */
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* The frozen action catalog, in stable order.
|
|
988
|
+
* @returns {Array<{id: string, group: string, danger: string,
|
|
989
|
+
* requiresAdmin: boolean, params: ActionParam[]}>}
|
|
990
|
+
*/
|
|
991
|
+
export function actionCatalog() {
|
|
992
|
+
return SPECS.map((spec) => ({
|
|
993
|
+
id: spec.id,
|
|
994
|
+
group: spec.group,
|
|
995
|
+
danger: spec.danger,
|
|
996
|
+
requiresAdmin: spec.requiresAdmin,
|
|
997
|
+
params: spec.params.map((descriptor) => ({ ...descriptor, ...(descriptor.options ? { options: [...descriptor.options] } : {}) })),
|
|
998
|
+
}));
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Attempted actions, newest first, at most 50 entries.
|
|
1003
|
+
* @returns {Array<{ts: number, id: string, params: object, ok: boolean, message: string}>}
|
|
1004
|
+
*/
|
|
1005
|
+
export function actionHistory() {
|
|
1006
|
+
return history.map((entry) => ({
|
|
1007
|
+
ts: entry.ts,
|
|
1008
|
+
id: entry.id,
|
|
1009
|
+
params: jsonSafe(entry.params),
|
|
1010
|
+
ok: entry.ok,
|
|
1011
|
+
message: entry.message,
|
|
1012
|
+
}));
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Validate and execute one action. Never throws for bad input; every attempt
|
|
1017
|
+
* (success or failure) is appended to the history.
|
|
1018
|
+
*
|
|
1019
|
+
* @param {string} id action id from {@link actionCatalog}
|
|
1020
|
+
* @param {object} [params]
|
|
1021
|
+
* @returns {Promise<{ok: boolean, message: string, data?: object, code?: string}>}
|
|
1022
|
+
*/
|
|
1023
|
+
export async function runAction(id, params) {
|
|
1024
|
+
const ts = Date.now();
|
|
1025
|
+
const actionId = typeof id === 'string' ? id : String(id);
|
|
1026
|
+
const requestParams = paramsObject(jsonSafe(params));
|
|
1027
|
+
let result;
|
|
1028
|
+
try {
|
|
1029
|
+
const spec = SPEC_BY_ID.get(actionId);
|
|
1030
|
+
const runner = RUNNERS[actionId];
|
|
1031
|
+
if (!spec || !runner) {
|
|
1032
|
+
result = failure(`Unknown action: ${actionId}`, 'UNKNOWN_ACTION');
|
|
1033
|
+
} else {
|
|
1034
|
+
const candidate = await runner(requestParams);
|
|
1035
|
+
result =
|
|
1036
|
+
candidate && typeof candidate === 'object' && typeof candidate.ok === 'boolean'
|
|
1037
|
+
? { ok: candidate.ok === true, message: String(candidate.message ?? ''), ...(candidate.data ? { data: jsonSafe(candidate.data) } : {}), ...(candidate.code ? { code: String(candidate.code) } : {}) }
|
|
1038
|
+
: failure('Action returned an invalid result.', 'INTERNAL');
|
|
1039
|
+
}
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
result = failure(`Action failed unexpectedly: ${err instanceof Error ? err.message : String(err)}`, 'INTERNAL');
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
recordHistory({ ts, id: actionId, params: requestParams, ok: result.ok === true, message: result.message });
|
|
1045
|
+
return jsonSafe(result);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Test seam for `scripts/verify-cleanup.mjs`: the pure cleanup-id validator
|
|
1050
|
+
* (no filesystem access, never deletes anything).
|
|
1051
|
+
*/
|
|
1052
|
+
export const __test = { readCleanupIds };
|