ucu-mcp 0.5.1 → 0.6.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.
@@ -2,13 +2,37 @@ import { execFileSync } from "node:child_process";
2
2
  import { click as inputClick, doubleClick as inputDoubleClick, move as inputMove, drag as inputDrag, scroll as inputScroll, typeText, pressShortcut } from "../../utils/input.js";
3
3
  import { PlatformError } from "../../util/errors.js";
4
4
  import { rethrowInputError, errorMessage } from "./helpers.js";
5
+ /**
6
+ * Process names that must NEVER receive per-process input injection (security).
7
+ * Per-process posting bypasses the frontmost/focus requirement, so without this
8
+ * denylist an attacker could deliver keystrokes/clicks directly into a password
9
+ * manager or auth dialog. These fall back to HID-tap (which at least requires
10
+ * the app to be frontmost / visible to the user).
11
+ */
12
+ const SENSITIVE_PROCESS_HINTS = [
13
+ "keychainaccess", "1password", "lastpass", "bitwarden", "securityagent",
14
+ "authd", "loginwindow", "applekeychain", "dashlane",
15
+ ];
16
+ /** Resolve the per-process event target from the active focus_app target (pid + windowNumber). */
17
+ function targetOf() {
18
+ const t = this.activeTarget;
19
+ if (!t || !t.pid || t.pid <= 0)
20
+ return undefined;
21
+ // Security: never route per-process events into sensitive apps (password managers / auth).
22
+ const name = (t.appName || "").toLowerCase();
23
+ if (SENSITIVE_PROCESS_HINTS.some((h) => name.includes(h))) {
24
+ return undefined; // forces HID-tap fallback (requires frontmost)
25
+ }
26
+ return { pid: t.pid, windowNumber: t.windowNumber };
27
+ }
5
28
  export async function click(x, y, button, doubleClick) {
6
29
  try {
30
+ const target = targetOf.call(this);
7
31
  if (doubleClick) {
8
- await inputDoubleClick(x, y, button);
32
+ return await inputDoubleClick(x, y, button, process.platform, target);
9
33
  }
10
34
  else {
11
- await inputClick(x, y, button);
35
+ return await inputClick(x, y, button, process.platform, target);
12
36
  }
13
37
  }
14
38
  catch (error) {
@@ -17,7 +41,7 @@ export async function click(x, y, button, doubleClick) {
17
41
  }
18
42
  export async function move(x, y) {
19
43
  try {
20
- await inputMove(x, y);
44
+ return await inputMove(x, y, process.platform, targetOf.call(this));
21
45
  }
22
46
  catch (error) {
23
47
  rethrowInputError(error, "move");
@@ -25,7 +49,7 @@ export async function move(x, y) {
25
49
  }
26
50
  export async function drag(startX, startY, endX, endY, button, duration) {
27
51
  try {
28
- await inputDrag(startX, startY, endX, endY, button, duration);
52
+ return await inputDrag(startX, startY, endX, endY, button, duration, process.platform, targetOf.call(this));
29
53
  }
30
54
  catch (error) {
31
55
  rethrowInputError(error, "drag");
@@ -33,7 +57,7 @@ export async function drag(startX, startY, endX, endY, button, duration) {
33
57
  }
34
58
  export async function scroll(x, y, deltaX, deltaY) {
35
59
  try {
36
- await inputScroll(x, y, deltaX, deltaY);
60
+ return await inputScroll(x, y, deltaX, deltaY, process.platform, targetOf.call(this));
37
61
  }
38
62
  catch (error) {
39
63
  rethrowInputError(error, "scroll");
@@ -55,8 +79,8 @@ export function getCursorPosition() {
55
79
  }
56
80
  }
57
81
  export async function type(text, delay) {
58
- await typeText(text, delay);
82
+ return await typeText(text, delay, process.platform, targetOf.call(this));
59
83
  }
60
84
  export async function key(keys) {
61
- await pressShortcut(keys);
85
+ return await pressShortcut(keys, process.platform, targetOf.call(this));
62
86
  }
@@ -2,5 +2,7 @@ import type { MacOSPlatform } from "./base.js";
2
2
  import type { AppInfo, AppTarget, WindowInfo, BrowserContext } from "../base.js";
3
3
  export declare function listApps(this: MacOSPlatform): Promise<AppInfo[]>;
4
4
  export declare function focusApp(this: MacOSPlatform, app: string): Promise<AppTarget>;
5
+ /** Call the skylight-helper keepAlive command for a pid (best-effort, non-throwing). */
6
+ export declare function keepTargetAxAlive(this: MacOSPlatform, pid: number): Promise<void>;
5
7
  export declare function getActiveBrowserContext(this: MacOSPlatform, app?: string): Promise<BrowserContext | undefined>;
6
8
  export declare function listWindows(this: MacOSPlatform, _includeMinimized?: boolean): Promise<WindowInfo[]>;
@@ -58,14 +58,20 @@ export async function focusApp(app) {
58
58
  try {
59
59
  const extras = await this.findMenuBarExtra(app);
60
60
  if (extras.length > 0) {
61
+ // Use the hosting process pid (SystemUIServer or app itself) so per-process
62
+ // event posting works for tray clicks (no global cursor move).
63
+ const trayPid = extras[0]?.pid || 0;
61
64
  this.activeTarget = {
62
65
  targetId: randomUUID(),
63
66
  appName: app,
64
- pid: 0,
67
+ pid: trayPid,
65
68
  windowId: "tray",
66
69
  title: "",
67
70
  capturedAt: new Date().toISOString(),
68
71
  };
72
+ if (trayPid > 0) {
73
+ this.keepTargetAxAlive(trayPid).catch(() => { });
74
+ }
69
75
  return this.activeTarget;
70
76
  }
71
77
  }
@@ -90,9 +96,29 @@ export async function focusApp(app) {
90
96
  windowId: target.id,
91
97
  title: target.title,
92
98
  capturedAt: new Date().toISOString(),
99
+ windowNumber: target.windowNumber,
93
100
  };
101
+ // AX keepalive: keep the target's accessibility tree live when occluded/background,
102
+ // so AXPress on Electron/Tauri controls doesn't silently no-op. Best-effort.
103
+ this.keepTargetAxAlive(target.pid).catch(() => { });
94
104
  return this.activeTarget;
95
105
  }
106
+ /** Call the skylight-helper keepAlive command for a pid (best-effort, non-throwing). */
107
+ export async function keepTargetAxAlive(pid) {
108
+ const helperPath = resolveNativeHelper.call(this, "skylight", "skylight-helper");
109
+ if (!helperPath)
110
+ return;
111
+ try {
112
+ execFileSync(helperPath, [], {
113
+ input: JSON.stringify({ command: "keepAlive", pid }),
114
+ encoding: "utf8",
115
+ timeout: 5000,
116
+ });
117
+ }
118
+ catch {
119
+ // keepAlive is best-effort; ignore failures (helper missing / SPI unavailable).
120
+ }
121
+ }
96
122
  export async function getActiveBrowserContext(app) {
97
123
  const appName = app || this.activeTarget?.appName;
98
124
  if (!appName)
@@ -201,6 +227,7 @@ function listWindowsNative() {
201
227
  bounds: w.bounds,
202
228
  isMinimized: !w.isOnScreen,
203
229
  isOnScreen: w.isOnScreen,
230
+ windowNumber: w.windowNumber,
204
231
  }));
205
232
  }
206
233
  catch {
@@ -9,14 +9,20 @@
9
9
  * Windows: Uses SendInput (stub).
10
10
  * Linux: Uses xdotool (stub).
11
11
  */
12
- export declare function click(x: number, y: number, button?: "left" | "right" | "middle", _platform?: string): Promise<void>;
13
- export declare function doubleClick(x: number, y: number, button?: "left" | "right" | "middle", _platform?: string): Promise<void>;
14
- export declare function move(x: number, y: number, _platform?: string): Promise<void>;
15
- export declare function drag(fromX: number, fromY: number, toX: number, toY: number, button?: "left" | "right" | "middle", duration?: number, _platform?: string): Promise<void>;
16
- export declare function scroll(x: number, y: number, deltaX: number, deltaY: number, _platform?: string): Promise<void>;
17
- export declare function typeText(text: string, delay?: number, _platform?: string): Promise<void>;
18
- export declare function pressKey(key: string, modifiers?: string[], _platform?: string): Promise<void>;
19
- export declare function pressShortcut(keys: string[], _platform?: string): Promise<void>;
12
+ import type { DispatchMethod } from "../platform/base.js";
13
+ /** Per-process event target. When pid > 0, input is routed via skylight-helper (no cursor move). */
14
+ export interface InputTarget {
15
+ pid?: number;
16
+ windowNumber?: number;
17
+ }
18
+ export declare function click(x: number, y: number, button?: "left" | "right" | "middle", _platform?: string, target?: InputTarget): Promise<DispatchMethod | void>;
19
+ export declare function doubleClick(x: number, y: number, button?: "left" | "right" | "middle", _platform?: string, target?: InputTarget): Promise<DispatchMethod | void>;
20
+ export declare function move(x: number, y: number, _platform?: string, target?: InputTarget): Promise<DispatchMethod | void>;
21
+ export declare function drag(fromX: number, fromY: number, toX: number, toY: number, button?: "left" | "right" | "middle", duration?: number, _platform?: string, target?: InputTarget): Promise<DispatchMethod | void>;
22
+ export declare function scroll(x: number, y: number, deltaX: number, deltaY: number, _platform?: string, target?: InputTarget): Promise<DispatchMethod | void>;
23
+ export declare function typeText(text: string, delay?: number, _platform?: string, target?: InputTarget): Promise<DispatchMethod | void>;
24
+ export declare function pressKey(key: string, modifiers?: string[], _platform?: string, target?: InputTarget): Promise<DispatchMethod | void>;
25
+ export declare function pressShortcut(keys: string[], _platform?: string, target?: InputTarget): Promise<DispatchMethod | void>;
20
26
  export declare function getCursorPosition(_platform?: string): Promise<{
21
27
  x: number;
22
28
  y: number;
@@ -18,33 +18,93 @@ const execFileAsync = promisify(execFile);
18
18
  // ── Native CGEvent helper (macOS) ──────────────────────────────────────
19
19
  // JXA (osascript -l JavaScript) cannot call CGEventPost without segfault.
20
20
  // We ship a small Swift binary that does native CGEvent injection instead.
21
+ //
22
+ // v0.6.0: two helpers. `skylight-helper` posts events per-process via
23
+ // SLEventPostToPid (private SkyLight SPI) so the global cursor does NOT move
24
+ // and the foreground is NOT stolen — matching Codex computer-use. `cgevent-helper`
25
+ // (legacy) posts to the HID event tap, which moves the cursor. We prefer
26
+ // skylight when a target pid is available; fall back to cgevent otherwise.
21
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
28
  // In dev: src/utils/input.ts → native/cgevent/cgevent-helper
23
29
  // In prod: dist/src/utils/input.js → dist/native/cgevent/cgevent-helper
24
- const nativeHelperPath = join(__dirname, "..", "..", "..", "native", "cgevent", "cgevent-helper");
25
- // Fallback: try from project root (dev mode)
26
- const nativeHelperPathAlt = join(__dirname, "..", "..", "native", "cgevent", "cgevent-helper");
30
+ const cgeventHelperPath = join(__dirname, "..", "..", "..", "native", "cgevent", "cgevent-helper");
31
+ const cgeventHelperPathAlt = join(__dirname, "..", "..", "native", "cgevent", "cgevent-helper");
32
+ const skylightHelperPath = join(__dirname, "..", "..", "..", "native", "skylight", "skylight-helper");
33
+ const skylightHelperPathAlt = join(__dirname, "..", "..", "native", "skylight", "skylight-helper");
27
34
  import { existsSync } from "node:fs";
28
- const resolvedNativePath = existsSync(nativeHelperPath) ? nativeHelperPath : nativeHelperPathAlt;
29
- let _nativeAvailable;
30
- function isNativeAvailable() {
31
- if (_nativeAvailable !== undefined)
32
- return _nativeAvailable;
35
+ const resolvedCgeventPath = existsSync(cgeventHelperPath) ? cgeventHelperPath : cgeventHelperPathAlt;
36
+ const resolvedSkylightPath = existsSync(skylightHelperPath) ? skylightHelperPath : skylightHelperPathAlt;
37
+ let _cgeventAvailable;
38
+ let _skylightAvailable;
39
+ function isCgeventAvailable() {
40
+ if (_cgeventAvailable !== undefined)
41
+ return _cgeventAvailable;
33
42
  try {
34
- const stdout = execFileSync(resolvedNativePath, [], {
43
+ const stdout = execFileSync(resolvedCgeventPath, [], {
35
44
  input: '{"command":"ping"}',
36
45
  encoding: "utf8",
37
46
  timeout: 3000,
38
47
  });
39
- _nativeAvailable = stdout.includes('"ok"');
48
+ _cgeventAvailable = stdout.includes('"ok"');
40
49
  }
41
50
  catch {
42
- _nativeAvailable = false;
51
+ _cgeventAvailable = false;
43
52
  }
44
- return _nativeAvailable;
53
+ return _cgeventAvailable;
45
54
  }
46
- function runNativeChecked(payload) {
47
- const raw = execFileSync(resolvedNativePath, [], {
55
+ function isSkylightAvailable() {
56
+ if (_skylightAvailable !== undefined)
57
+ return _skylightAvailable;
58
+ if (!existsSync(resolvedSkylightPath)) {
59
+ _skylightAvailable = false;
60
+ return false;
61
+ }
62
+ try {
63
+ const stdout = execFileSync(resolvedSkylightPath, [], {
64
+ input: '{"command":"ping"}',
65
+ encoding: "utf8",
66
+ timeout: 3000,
67
+ });
68
+ _skylightAvailable = stdout.includes('"ok"');
69
+ }
70
+ catch {
71
+ _skylightAvailable = false;
72
+ }
73
+ return _skylightAvailable;
74
+ }
75
+ /** @deprecated use isCgeventAvailable — kept for external callers/tests. */
76
+ function isNativeAvailable() {
77
+ return isCgeventAvailable();
78
+ }
79
+ /**
80
+ * Run an input command via the best available helper.
81
+ * - target.pid > 0 AND skylight available → skylight-helper (per-process, no cursor move). Returns "per-pid".
82
+ * - otherwise → cgevent-helper (HID tap, moves cursor). Returns "hid-tap".
83
+ * If skylight is chosen but errors at runtime, falls back to cgevent (HID-tap) rather than throwing.
84
+ * Throws only if BOTH helpers fail.
85
+ */
86
+ function runInputChecked(payload, target) {
87
+ const useSkylight = !!target?.pid && target.pid > 0 && isSkylightAvailable();
88
+ if (useSkylight) {
89
+ const fullPayload = { ...payload, pid: target.pid, windowNumber: target.windowNumber };
90
+ try {
91
+ const raw = execFileSync(resolvedSkylightPath, [], {
92
+ input: JSON.stringify(fullPayload),
93
+ encoding: "utf8",
94
+ timeout: 10000,
95
+ }).trim();
96
+ const resp = JSON.parse(raw);
97
+ if (!resp.error) {
98
+ return (resp.method === "per-pid") ? "per-pid" : "hid-tap";
99
+ }
100
+ // skylight errored at runtime (SPI filtered / event build failed) → fall through to cgevent.
101
+ }
102
+ catch {
103
+ // skylight crashed/timed out → fall through to cgevent.
104
+ }
105
+ }
106
+ // cgevent path (HID-tap) — the reliable fallback.
107
+ const raw = execFileSync(resolvedCgeventPath, [], {
48
108
  input: JSON.stringify(payload),
49
109
  encoding: "utf8",
50
110
  timeout: 10000,
@@ -53,6 +113,11 @@ function runNativeChecked(payload) {
53
113
  if (resp.error) {
54
114
  throw new Error(`native helper error: ${resp.error}`);
55
115
  }
116
+ return "hid-tap";
117
+ }
118
+ /** @deprecated use runInputChecked — kept for external callers/tests. */
119
+ function runNativeChecked(payload) {
120
+ runInputChecked(payload);
56
121
  }
57
122
  // ── Dry-run mode ──────────────────────────────────────────────────────────
58
123
  const isDryRun = () => process.env.UCU_DRY_RUN === "true";
@@ -112,15 +177,14 @@ async function runJXA(script, timeout = 5000) {
112
177
  return stdout.trim();
113
178
  }
114
179
  // ── Mouse operations (CGEvent — background, no focus steal) ───────────────
115
- export async function click(x, y, button = "left", _platform = process.platform) {
180
+ export async function click(x, y, button = "left", _platform = process.platform, target) {
116
181
  if (isDryRun()) {
117
182
  logDryRun("click", { x, y, button });
118
183
  return;
119
184
  }
120
185
  if (_platform === "darwin") {
121
- if (isNativeAvailable()) {
122
- runNativeChecked({ command: "click", x, y, button });
123
- return;
186
+ if (isCgeventAvailable() || isSkylightAvailable()) {
187
+ return runInputChecked({ command: "click", x, y, button }, target);
124
188
  }
125
189
  const btnType = { left: 0, right: 1, middle: 2 }[button];
126
190
  await runJXA(`
@@ -144,15 +208,14 @@ export async function click(x, y, button = "left", _platform = process.platform)
144
208
  // Windows
145
209
  throw new Error("click not implemented for Windows");
146
210
  }
147
- export async function doubleClick(x, y, button = "left", _platform = process.platform) {
211
+ export async function doubleClick(x, y, button = "left", _platform = process.platform, target) {
148
212
  if (isDryRun()) {
149
213
  logDryRun("doubleClick", { x, y, button });
150
214
  return;
151
215
  }
152
216
  if (_platform === "darwin") {
153
- if (isNativeAvailable()) {
154
- runNativeChecked({ command: "doubleClick", x, y, button });
155
- return;
217
+ if (isCgeventAvailable() || isSkylightAvailable()) {
218
+ return runInputChecked({ command: "doubleClick", x, y, button }, target);
156
219
  }
157
220
  const btnType = { left: 0, right: 1, middle: 2 }[button];
158
221
  await runJXA(`
@@ -182,15 +245,14 @@ export async function doubleClick(x, y, button = "left", _platform = process.pla
182
245
  await new Promise(r => setTimeout(r, 50));
183
246
  await click(x, y, button, _platform);
184
247
  }
185
- export async function move(x, y, _platform = process.platform) {
248
+ export async function move(x, y, _platform = process.platform, target) {
186
249
  if (isDryRun()) {
187
250
  logDryRun("move", { x, y });
188
251
  return;
189
252
  }
190
253
  if (_platform === "darwin") {
191
- if (isNativeAvailable()) {
192
- runNativeChecked({ command: "move", x, y });
193
- return;
254
+ if (isCgeventAvailable() || isSkylightAvailable()) {
255
+ return runInputChecked({ command: "move", x, y }, target);
194
256
  }
195
257
  await runJXA(`
196
258
  ObjC.import('CoreGraphics');
@@ -207,15 +269,14 @@ export async function move(x, y, _platform = process.platform) {
207
269
  }
208
270
  throw new Error("move not implemented for Windows");
209
271
  }
210
- export async function drag(fromX, fromY, toX, toY, button = "left", duration = 300, _platform = process.platform) {
272
+ export async function drag(fromX, fromY, toX, toY, button = "left", duration = 300, _platform = process.platform, target) {
211
273
  if (isDryRun()) {
212
274
  logDryRun("drag", { fromX, fromY, toX, toY, button, duration });
213
275
  return;
214
276
  }
215
277
  if (_platform === "darwin") {
216
- if (isNativeAvailable()) {
217
- runNativeChecked({ command: "drag", fromX, fromY, toX, toY, button, durationMs: duration });
218
- return;
278
+ if (isCgeventAvailable() || isSkylightAvailable()) {
279
+ return runInputChecked({ command: "drag", fromX, fromY, toX, toY, button, durationMs: duration }, target);
219
280
  }
220
281
  const btnType = { left: 0, right: 1, middle: 2 }[button];
221
282
  const steps = Math.max(2, Math.min(60, Math.ceil(duration / 16)));
@@ -257,15 +318,14 @@ export async function drag(fromX, fromY, toX, toY, button = "left", duration = 3
257
318
  }
258
319
  throw new Error("drag not implemented for Windows");
259
320
  }
260
- export async function scroll(x, y, deltaX, deltaY, _platform = process.platform) {
321
+ export async function scroll(x, y, deltaX, deltaY, _platform = process.platform, target) {
261
322
  if (isDryRun()) {
262
323
  logDryRun("scroll", { x, y, deltaX, deltaY });
263
324
  return;
264
325
  }
265
326
  if (_platform === "darwin") {
266
- if (isNativeAvailable()) {
267
- runNativeChecked({ command: "scroll", x, y, deltaX, deltaY });
268
- return;
327
+ if (isCgeventAvailable() || isSkylightAvailable()) {
328
+ return runInputChecked({ command: "scroll", x, y, deltaX, deltaY }, target);
269
329
  }
270
330
  const verticalDelta = -deltaY;
271
331
  const horizontalDelta = deltaX;
@@ -292,7 +352,7 @@ export async function scroll(x, y, deltaX, deltaY, _platform = process.platform)
292
352
  throw new Error("scroll not implemented for Windows");
293
353
  }
294
354
  // ── Keyboard operations (CGEvent — background) ────────────────────────────
295
- export async function typeText(text, delay = 20, _platform = process.platform) {
355
+ export async function typeText(text, delay = 20, _platform = process.platform, target) {
296
356
  if (isDryRun()) {
297
357
  logDryRun("typeText", { text: text.slice(0, 50), delay });
298
358
  return;
@@ -377,8 +437,8 @@ export async function typeText(text, delay = 20, _platform = process.platform) {
377
437
  // Process each batch
378
438
  for (const batch of batches) {
379
439
  if (batch.cgEvent && Array.isArray(batch.chars)) {
380
- if (isNativeAvailable()) {
381
- runNativeChecked({ command: "typeBatch", keys: batch.chars });
440
+ if (isCgeventAvailable() || isSkylightAvailable()) {
441
+ runInputChecked({ command: "typeBatch", keys: batch.chars }, target);
382
442
  }
383
443
  else {
384
444
  // Build a single JXA script that types all chars in this CGEvent batch
@@ -418,7 +478,7 @@ export async function typeText(text, delay = 20, _platform = process.platform) {
418
478
  }
419
479
  throw new Error("typeText not implemented for Windows");
420
480
  }
421
- export async function pressKey(key, modifiers = [], _platform = process.platform) {
481
+ export async function pressKey(key, modifiers = [], _platform = process.platform, target) {
422
482
  if (isDryRun()) {
423
483
  logDryRun("pressKey", { key, modifiers });
424
484
  return;
@@ -443,9 +503,8 @@ export async function pressKey(key, modifiers = [], _platform = process.platform
443
503
  }
444
504
  flags |= flag;
445
505
  }
446
- if (isNativeAvailable()) {
447
- runNativeChecked({ command: "pressKey", keyCode, flags });
448
- return;
506
+ if (isCgeventAvailable() || isSkylightAvailable()) {
507
+ return runInputChecked({ command: "pressKey", keyCode, flags }, target);
449
508
  }
450
509
  await runJXA(`
451
510
  ObjC.import('CoreGraphics');
@@ -468,7 +527,7 @@ export async function pressKey(key, modifiers = [], _platform = process.platform
468
527
  }
469
528
  throw new Error("pressKey not implemented for Windows");
470
529
  }
471
- export async function pressShortcut(keys, _platform = process.platform) {
530
+ export async function pressShortcut(keys, _platform = process.platform, target) {
472
531
  if (isDryRun()) {
473
532
  logDryRun("pressShortcut", { keys });
474
533
  return;
@@ -478,7 +537,7 @@ export async function pressShortcut(keys, _platform = process.platform) {
478
537
  }
479
538
  const modifiers = keys.slice(0, -1);
480
539
  const key = keys[keys.length - 1];
481
- await pressKey(key, modifiers, _platform);
540
+ return await pressKey(key, modifiers, _platform, target);
482
541
  }
483
542
  // ── Cursor position ───────────────────────────────────────────────────────
484
543
  export async function getCursorPosition(_platform = process.platform) {