machine-bridge-mcp 0.9.0 → 0.11.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,400 @@
1
+ import { opendir, realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, extname, join } from "node:path";
4
+
5
+ const MAX_APPLICATIONS = 1000;
6
+ const MAX_APPLICATION_SCAN_ENTRIES = 20_000;
7
+ const MAX_APPLICATION_SCAN_DEPTH = 3;
8
+ const MAX_UI_ELEMENTS = 500;
9
+ const MAX_UI_DEPTH = 12;
10
+ const MAX_TEXT_CHARS = 4000;
11
+ const APP_NAME_PATTERN = /^[^\0\r\n]{1,300}$/;
12
+
13
+ export class AppAutomationManager {
14
+ constructor({ policy, displayPath, runProcess, readResourceText, throwIfCancelled = () => {}, platform = process.platform, home = homedir(), applicationRoots = null }) {
15
+ this.policy = policy || {};
16
+ this.displayPath = displayPath;
17
+ this.runProcess = runProcess;
18
+ this.readResourceText = readResourceText;
19
+ this.throwIfCancelled = throwIfCancelled;
20
+ this.platform = platform;
21
+ this.home = home;
22
+ this.applicationRoots = applicationRoots;
23
+ }
24
+
25
+ capabilities() {
26
+ return {
27
+ platform: this.platform,
28
+ discovery: true,
29
+ open: true,
30
+ accessibility_inspection: this.platform === "darwin",
31
+ structured_accessibility_actions: this.platform === "darwin",
32
+ arbitrary_script_execution: false,
33
+ permission_note: this.platform === "darwin"
34
+ ? "UI inspection and actions require macOS Accessibility permission for the Machine Bridge runtime."
35
+ : "Structured UI inspection currently requires macOS; application discovery and opening remain cross-platform.",
36
+ };
37
+ }
38
+
39
+ async listApplications(args = {}, context = {}) {
40
+ this.throwIfCancelled(context);
41
+ const query = String(args.query || "").trim().toLowerCase();
42
+ const limit = clampInt(args.max_results, 200, 1, MAX_APPLICATIONS);
43
+ const discovery = await this.discoverApplications(context);
44
+ const matched = discovery.applications
45
+ .filter((item) => !query || `${item.name}\n${item.id}\n${item.path}`.toLowerCase().includes(query))
46
+ .sort((left, right) => left.name.localeCompare(right.name));
47
+ const filtered = matched
48
+ .slice(0, limit)
49
+ .map((item) => ({ ...item, path: item.path ? this.displayPath(item.path) : "" }));
50
+ return {
51
+ platform: this.platform,
52
+ applications: filtered,
53
+ truncated: discovery.truncated || matched.length > limit,
54
+ scanned_entries: discovery.visitedEntries,
55
+ capabilities: this.capabilities(),
56
+ };
57
+ }
58
+
59
+ async discoverApplications(context = {}) {
60
+ if (this.platform === "darwin") {
61
+ return listMacApplications(this.applicationRoots || ["/Applications", "/System/Applications", join(this.home, "Applications")], context, this.throwIfCancelled);
62
+ }
63
+ if (this.platform === "win32") return listWindowsApplications(this.applicationRoots, this.home, context, this.throwIfCancelled);
64
+ return listLinuxApplications(this.applicationRoots, this.home, context, this.throwIfCancelled);
65
+ }
66
+
67
+ async resolveApplicationReference(application, context = {}) {
68
+ if (application.includes("/") || application.includes("\\")) return application;
69
+ const lower = application.toLowerCase();
70
+ const match = (await this.discoverApplications(context)).applications.find((item) => item.name.toLowerCase() === lower || item.id.toLowerCase() === lower);
71
+ return match?.id || application;
72
+ }
73
+
74
+ async openApplication(args = {}, context = {}) {
75
+ this.assertFull("open_local_application");
76
+ const application = requiredApplication(args.application);
77
+ const target = optionalString(args.target, "target", 32768);
78
+ this.throwIfCancelled(context);
79
+ const resolvedApplication = this.platform === "darwin" ? application : await this.resolveApplicationReference(application, context);
80
+ let command;
81
+ if (this.platform === "darwin") {
82
+ const argv = ["-a", application];
83
+ if (args.background === true) argv.push("-g");
84
+ if (target) argv.push(target);
85
+ command = { cmd: "open", argv };
86
+ } else if (this.platform === "win32") {
87
+ const escaped = resolvedApplication.replace(/'/g, "''");
88
+ const targetFragment = target ? ` -ArgumentList '${target.replace(/'/g, "''")}'` : "";
89
+ command = { cmd: "powershell.exe", argv: ["-NoProfile", "-NonInteractive", "-Command", `Start-Process -FilePath '${escaped}'${targetFragment}`] };
90
+ } else if (resolvedApplication.toLowerCase().endsWith(".desktop")) {
91
+ command = { cmd: "gio", argv: ["launch", resolvedApplication, ...(target ? [target] : [])] };
92
+ } else {
93
+ command = target
94
+ ? { cmd: resolvedApplication, argv: [target] }
95
+ : { cmd: resolvedApplication, argv: [] };
96
+ }
97
+ const result = await this.runProcess(command.cmd, command.argv, clampInt(args.timeout_seconds, 30, 1, 120) * 1000, false, 256 * 1024, context, undefined, null);
98
+ return { application, resolved_application: resolvedApplication, target, platform: this.platform, ...result };
99
+ }
100
+
101
+ async inspectApplication(args = {}, context = {}) {
102
+ this.assertFull("inspect_local_application");
103
+ assertMacAccessibility(this.platform);
104
+ const application = requiredApplication(args.application);
105
+ const processName = applicationProcessName(application);
106
+ const payload = {
107
+ operation: "inspect",
108
+ application: processName,
109
+ maxDepth: clampInt(args.max_depth, 6, 1, MAX_UI_DEPTH),
110
+ maxElements: clampInt(args.max_elements, 200, 1, MAX_UI_ELEMENTS),
111
+ includeValues: args.include_values === true,
112
+ };
113
+ const result = await this.runJxa(payload, clampInt(args.timeout_seconds, 30, 1, 120), context);
114
+ return {
115
+ application,
116
+ process_name: processName,
117
+ platform: this.platform,
118
+ accessibility_permission_required: true,
119
+ ...result,
120
+ };
121
+ }
122
+
123
+ async operateApplication(args = {}, context = {}) {
124
+ this.assertFull("operate_local_application");
125
+ assertMacAccessibility(this.platform);
126
+ const application = requiredApplication(args.application);
127
+ const processName = applicationProcessName(application);
128
+ const action = normalizeAppAction(args.action);
129
+ const selector = action === "activate" ? null : normalizeUiSelector(args.selector || {});
130
+ let value = args.value === undefined ? null : String(args.value);
131
+ if (args.value_resource !== undefined) {
132
+ if (value !== null) throw new Error("value and value_resource are mutually exclusive");
133
+ value = await this.readResourceText(requiredResourceName(args.value_resource));
134
+ }
135
+ if (value !== null && value.length > MAX_TEXT_CHARS) throw new Error(`application action value exceeds ${MAX_TEXT_CHARS} characters`);
136
+ const payload = {
137
+ operation: "act",
138
+ application: processName,
139
+ action,
140
+ selector,
141
+ value,
142
+ maxDepth: clampInt(args.max_depth, 8, 1, MAX_UI_DEPTH),
143
+ maxElements: MAX_UI_ELEMENTS,
144
+ };
145
+ const result = await this.runJxa(payload, clampInt(args.timeout_seconds, 30, 1, 120), context);
146
+ return {
147
+ application,
148
+ process_name: processName,
149
+ action,
150
+ selector,
151
+ value_source: args.value_resource !== undefined ? "local-resource" : value === null ? "none" : "mcp-argument",
152
+ value_exposed: false,
153
+ ...result,
154
+ };
155
+ }
156
+
157
+ async runJxa(payload, timeoutSeconds, context) {
158
+ const input = `${JSON.stringify(payload)}\n`;
159
+ const result = await this.runProcess(
160
+ "osascript",
161
+ ["-l", "JavaScript", "-e", MACOS_UI_JXA],
162
+ timeoutSeconds * 1000,
163
+ false,
164
+ 1024 * 1024,
165
+ context,
166
+ undefined,
167
+ input,
168
+ );
169
+ let parsed;
170
+ try {
171
+ parsed = JSON.parse(result.stdout.trim() || "{}");
172
+ } catch {
173
+ throw new Error("macOS accessibility helper returned invalid JSON");
174
+ }
175
+ if (parsed.error) throw new Error(String(parsed.error));
176
+ return parsed;
177
+ }
178
+
179
+ assertFull(tool) {
180
+ if (this.policy.profile !== "full" || this.policy.execMode !== "shell" || this.policy.unrestrictedPaths !== true) {
181
+ throw new Error(`${tool} requires the canonical full profile`);
182
+ }
183
+ }
184
+ }
185
+
186
+ async function listMacApplications(roots, context, throwIfCancelled) {
187
+ return scanApplicationRoots(roots, {
188
+ context,
189
+ throwIfCancelled,
190
+ match(entry) { return entry.isDirectory() && extname(entry.name).toLowerCase() === ".app"; },
191
+ descend(entry) { return entry.isDirectory() && extname(entry.name).toLowerCase() !== ".app"; },
192
+ makeItem(path, entry) { return { id: path, name: basename(entry.name, ".app"), path, kind: "application-bundle" }; },
193
+ });
194
+ }
195
+
196
+ async function listWindowsApplications(configuredRoots, home, context, throwIfCancelled) {
197
+ const roots = configuredRoots || [
198
+ join(process.env.APPDATA || home, "Microsoft", "Windows", "Start Menu", "Programs"),
199
+ process.env.ProgramData ? join(process.env.ProgramData, "Microsoft", "Windows", "Start Menu", "Programs") : "",
200
+ process.env.ProgramFiles,
201
+ process.env["ProgramFiles(x86)"],
202
+ ].filter(Boolean);
203
+ return listExecutableRoots(roots, new Set([".exe", ".lnk"]), context, throwIfCancelled);
204
+ }
205
+
206
+ async function listLinuxApplications(configuredRoots, home, context, throwIfCancelled) {
207
+ const roots = configuredRoots || ["/usr/share/applications", "/usr/local/share/applications", join(home, ".local", "share", "applications")];
208
+ return listExecutableRoots(roots, new Set([".desktop"]), context, throwIfCancelled);
209
+ }
210
+
211
+ async function listExecutableRoots(roots, extensions, context, throwIfCancelled) {
212
+ return scanApplicationRoots(roots, {
213
+ context,
214
+ throwIfCancelled,
215
+ match(entry) { return entry.isFile() && extensions.has(extname(entry.name).toLowerCase()); },
216
+ descend(entry) { return entry.isDirectory(); },
217
+ makeItem(path, entry) { return { id: path, name: basename(entry.name, extname(entry.name)), path, kind: "launcher" }; },
218
+ });
219
+ }
220
+
221
+ async function scanApplicationRoots(roots, { context, throwIfCancelled, match, descend, makeItem }) {
222
+ const results = [];
223
+ const seenRoots = new Set();
224
+ let visited = 0;
225
+ for (const inputRoot of roots) {
226
+ const root = await realpath(inputRoot).catch(() => "");
227
+ if (!root || seenRoots.has(root)) continue;
228
+ seenRoots.add(root);
229
+ const stack = [{ directory: root, depth: 0 }];
230
+ while (stack.length) {
231
+ const current = stack.pop();
232
+ const handle = await opendir(current.directory).catch(() => null);
233
+ if (!handle) continue;
234
+ for await (const entry of handle) {
235
+ throwIfCancelled(context);
236
+ visited += 1;
237
+ if (visited > MAX_APPLICATION_SCAN_ENTRIES) return { applications: results, truncated: true, visitedEntries: visited };
238
+ const path = join(current.directory, entry.name);
239
+ if (match(entry)) {
240
+ results.push(makeItem(path, entry));
241
+ if (results.length >= MAX_APPLICATIONS) return { applications: results, truncated: true, visitedEntries: visited };
242
+ } else if (current.depth < MAX_APPLICATION_SCAN_DEPTH && descend(entry)) {
243
+ stack.push({ directory: path, depth: current.depth + 1 });
244
+ }
245
+ }
246
+ }
247
+ }
248
+ return { applications: results, truncated: false, visitedEntries: visited };
249
+ }
250
+
251
+ function requiredApplication(value) {
252
+ const application = String(value || "").trim();
253
+ if (!APP_NAME_PATTERN.test(application)) throw new Error("application must be a non-empty name or path without control characters");
254
+ return application;
255
+ }
256
+
257
+ function applicationProcessName(application) {
258
+ const normalized = String(application).replace(/\\/g, "/");
259
+ const leaf = normalized.split("/").pop() || normalized;
260
+ return leaf.toLowerCase().endsWith(".app") ? leaf.slice(0, -4) : application;
261
+ }
262
+
263
+ function normalizeAppAction(value) {
264
+ const action = String(value || "").trim();
265
+ if (!["activate", "click", "set_value", "focus", "press", "keystroke"].includes(action)) {
266
+ throw new Error("action must be one of activate, click, set_value, focus, press, or keystroke");
267
+ }
268
+ return action;
269
+ }
270
+
271
+ function normalizeUiSelector(value) {
272
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("selector must be an object");
273
+ const allowed = new Set(["role", "subrole", "name", "title", "description", "identifier", "index"]);
274
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`unknown UI selector field: ${key}`);
275
+ const selector = {};
276
+ for (const key of ["role", "subrole", "name", "title", "description", "identifier"]) {
277
+ if (value[key] !== undefined) selector[key] = optionalString(value[key], `selector.${key}`, 500);
278
+ }
279
+ if (value.index !== undefined) selector.index = clampInt(value.index, 0, 0, MAX_UI_ELEMENTS - 1);
280
+ if (!Object.keys(selector).length) throw new Error("selector requires at least one field");
281
+ return selector;
282
+ }
283
+
284
+ function requiredResourceName(value) {
285
+ const name = String(value || "").trim();
286
+ if (!/^[a-z][a-z0-9._-]{0,63}$/.test(name)) throw new Error("value_resource is invalid");
287
+ return name;
288
+ }
289
+
290
+ function optionalString(value, label, maxLength) {
291
+ if (value === undefined || value === null || value === "") return "";
292
+ if (typeof value !== "string" || value.includes("\0") || value.length > maxLength) throw new Error(`${label} must be a string of at most ${maxLength} characters without NUL bytes`);
293
+ return value;
294
+ }
295
+
296
+ function assertMacAccessibility(platform) {
297
+ if (platform !== "darwin") throw new Error("structured application UI automation currently requires macOS");
298
+ }
299
+
300
+ function clampInt(value, fallback, min, max) {
301
+ if (value === undefined || value === null || value === "") return fallback;
302
+ const parsed = Number(value);
303
+ if (!Number.isInteger(parsed)) throw new Error(`expected an integer from ${min} to ${max}`);
304
+ return Math.min(Math.max(parsed, min), max);
305
+ }
306
+
307
+ const MACOS_UI_JXA = String.raw`
308
+ ObjC.import('Foundation');
309
+ function readPayload() {
310
+ const data = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile;
311
+ const text = ObjC.unwrap($.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding));
312
+ return JSON.parse(text);
313
+ }
314
+ function safe(fn, fallback) { try { const value = fn(); return value === undefined || value === null ? fallback : value; } catch (_) { return fallback; } }
315
+ function scalar(value) {
316
+ if (value === null || value === undefined) return null;
317
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
318
+ return String(value).slice(0, 1000);
319
+ }
320
+ function describe(element, index, includeValues) {
321
+ const item = {
322
+ index,
323
+ role: safe(() => element.role(), ''),
324
+ subrole: safe(() => element.subrole(), ''),
325
+ name: safe(() => element.name(), ''),
326
+ title: safe(() => element.title(), ''),
327
+ description: safe(() => element.description(), ''),
328
+ identifier: safe(() => element.attributes.byName('AXIdentifier').value(), ''),
329
+ enabled: safe(() => element.enabled(), null),
330
+ focused: safe(() => element.focused(), null)
331
+ };
332
+ const identity = [item.role, item.subrole, item.name, item.title, item.description, item.identifier].join(' ').toLowerCase();
333
+ item.sensitive = item.role === 'AXSecureTextField' || /(?:password|passwd|secret|token|api[-_ ]?key|otp|one[-_ ]?time|verification|cvc|cvv|security[-_ ]?code|card[-_ ]?number)/.test(identity);
334
+ if (includeValues && !item.sensitive) item.value = scalar(safe(() => element.value(), null));
335
+ return item;
336
+ }
337
+ function childrenOf(element) { return safe(() => element.uiElements(), []); }
338
+ function flatten(root, maxDepth, maxElements, includeValues) {
339
+ const output = [];
340
+ const elements = [];
341
+ const stack = [{ element: root, depth: 0 }];
342
+ while (stack.length && output.length < maxElements) {
343
+ const current = stack.pop();
344
+ const children = childrenOf(current.element);
345
+ for (let i = children.length - 1; i >= 0; i--) {
346
+ const child = children[i];
347
+ elements.push(child);
348
+ output.push(describe(child, output.length, includeValues));
349
+ if (output.length >= maxElements) break;
350
+ if (current.depth + 1 < maxDepth) stack.push({ element: child, depth: current.depth + 1 });
351
+ }
352
+ }
353
+ return { output, elements, truncated: stack.length > 0 || output.length >= maxElements };
354
+ }
355
+ function matches(item, selector) {
356
+ for (const key of ['role','subrole','name','title','description','identifier']) {
357
+ if (selector[key] !== undefined && String(item[key] || '').toLowerCase() !== String(selector[key]).toLowerCase()) return false;
358
+ }
359
+ return true;
360
+ }
361
+ function main() {
362
+ const payload = readPayload();
363
+ const se = Application('System Events');
364
+ const process = se.applicationProcesses.byName(payload.application);
365
+ if (!safe(() => process.exists(), false)) throw new Error('application process not found or Accessibility access denied');
366
+ if (payload.operation === 'inspect') {
367
+ const flattened = flatten(process, payload.maxDepth, payload.maxElements, payload.includeValues === true);
368
+ return { frontmost: safe(() => process.frontmost(), false), elements: flattened.output, truncated: flattened.truncated };
369
+ }
370
+ if (payload.action === 'activate') {
371
+ process.frontmost = true;
372
+ return { ok: true, matched: 1 };
373
+ }
374
+ const flattened = flatten(process, payload.maxDepth, payload.maxElements, true);
375
+ const matchesList = [];
376
+ for (let i = 0; i < flattened.output.length; i++) if (matches(flattened.output[i], payload.selector)) matchesList.push(i);
377
+ const chosen = payload.selector.index !== undefined ? matchesList[payload.selector.index] : matchesList[0];
378
+ if (chosen === undefined || chosen < 0 || chosen >= flattened.elements.length) throw new Error('no UI element matched selector');
379
+ const element = flattened.elements[chosen];
380
+ if (payload.action === 'click' || payload.action === 'press') {
381
+ const action = safe(() => element.actions.byName('AXPress'), null);
382
+ if (action && safe(() => action.exists(), false)) action.perform();
383
+ else element.click();
384
+ } else if (payload.action === 'set_value') {
385
+ if (payload.value === null) throw new Error('set_value requires value or value_resource');
386
+ element.value = payload.value;
387
+ } else if (payload.action === 'focus') {
388
+ element.focused = true;
389
+ } else if (payload.action === 'keystroke') {
390
+ process.frontmost = true;
391
+ element.focused = true;
392
+ if (payload.value === null) throw new Error('keystroke requires value or value_resource');
393
+ se.keystroke(payload.value);
394
+ } else {
395
+ throw new Error('unsupported action');
396
+ }
397
+ return { ok: true, matched: matchesList.length, selected_index: chosen, element: describe(element, chosen, false) };
398
+ }
399
+ try { console.log(JSON.stringify(main())); } catch (error) { console.log(JSON.stringify({ error: String(error.message || error) })); }
400
+ `;