Skip to content

Commit a9f19d4

Browse files
authored
Merge pull request #5928 from Hmbown/fix/cu-darwin-permission-probe-5917
fix(computer-use): truthful macOS permission probe, working CGEvent input, and frontmost-guarded keystrokes
2 parents 2b490a4 + 46552c6 commit a9f19d4

3 files changed

Lines changed: 316 additions & 29 deletions

File tree

‎crates/tui/plugins/computer-use/src/backends/darwin.mjs‎

Lines changed: 166 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,19 @@ export function create({ exec }) {
4747
fs.writeFileSync(file, script);
4848
// Payload travels as a plain argv string: osascript is spawned without a
4949
// shell, so JSON content can never become script syntax.
50+
// Input and accessibility scripts need the Accessibility grant. When the
51+
// last probe saw it denied, refuse here with the remedy instead of
52+
// letting osascript hang on a TCC prompt nobody can answer (#5917).
53+
if (state.tcc?.accessibility === false && /CGEventPost|System Events/.test(script)) {
54+
throw new ExecError(`accessibility permission is not granted to ${await grantTarget()}: ${TCC_FIX.accessibility}`);
55+
}
5056
const r = await runL("osascript", ["-l", "JavaScript", file, JSON.stringify(payload)], { timeoutMs });
51-
if (r.timedOut) throw new ExecError("osascript timed out", r);
57+
if (r.timedOut) {
58+
const hint = state.tcc?.accessibility === true
59+
? ""
60+
: ` (if macOS is showing a permission prompt, grant Accessibility to ${await grantTarget()}: ${TCC_FIX.accessibility})`;
61+
throw new ExecError(`osascript timed out${hint}`, r);
62+
}
5263
if (r.code !== 0) {
5364
const msg = (r.stderr || r.stdout).trim().split("\n")[0] || "osascript failed";
5465
throw new ExecError(/(not allowed assistive|assistive access|250)/i.test(r.stderr || "") || /(-25211|-1719|not allowed)/i.test(msg)
@@ -223,11 +234,16 @@ export function create({ exec }) {
223234
}
224235

225236
// ---------- raw input via CGEvent ----------
226-
async function cg(script, timeoutMs = 10_000) {
237+
// Every caller hands its values as `payload`; the script reads them as `P`.
238+
// The old `(script, timeoutMs)` shape silently swallowed the payload (so
239+
// `P.code`, `P.x`, `P.text` were undefined) and coerced the object to a
240+
// zero timeout, which is why every CGEvent input on macOS reported
241+
// "osascript timed out" instantly (#5917).
242+
async function cg(script, payload = {}, timeoutMs = 10_000) {
227243
return jxa(`ObjC.import('CoreGraphics');
228244
function run(argv){ var P = JSON.parse(argv[0]);
229245
${script}
230-
}`, {}, timeoutMs);
246+
}`, payload, timeoutMs);
231247
}
232248

233249
async function postMouseEvent(type, x, y, button, clickState) {
@@ -326,7 +342,16 @@ export function create({ exec }) {
326342
}
327343
args.push(file);
328344
const r = await runL("screencapture", args, { timeoutMs: 20_000 });
329-
if (r.code !== 0) throw new ExecError(`screencapture exited ${r.code}: ${r.stderr.trim().slice(0, 300)}`, r);
345+
if (r.code !== 0) {
346+
const detail = r.stderr.trim().slice(0, 300);
347+
// This is what screencapture says when the display is locked, asleep, or
348+
// the session is not the console user — not a permission problem
349+
// (without the Screen Recording grant it exits 0 and omits windows).
350+
const remedy = /could not create image/i.test(detail)
351+
? " — the display is locked, asleep, or this session is not at the console; unlock or wake it and retry"
352+
: "";
353+
throw new ExecError(`screencapture exited ${r.code}: ${detail}${remedy}`, r);
354+
}
330355
const stat = fs.statSync(file);
331356
const displays = await displayInfo();
332357
const d = displays.find((x) => x.index === (disp === "all" ? 1 : disp)) ?? displays[0];
@@ -442,6 +467,33 @@ export function create({ exec }) {
442467
}`, {}, 25_000);
443468
}
444469

470+
// Which app owns the keyboard right now. Raw CGEvents go to it no matter
471+
// what the caller meant (#5927), so every input receipt names it.
472+
async function frontmostApp() {
473+
const r = await jxa(`${JXA_PRELUDE}
474+
var se = Application('System Events');
475+
var list = se.applicationProcesses.whose({ frontmost: true })();
476+
if (!list.length) return JSON.stringify({ found: false });
477+
var p = list[0];
478+
return JSON.stringify({ found: true, name: g(function(){ return String(p.name()); }), pid: num(function(){ return p.unixId(); }),
479+
bundle_id: g(function(){ var b = p.bundleIdentifier(); return b ? String(b) : null; }) });
480+
}`, { probe: "frontmost-app" }, 8_000);
481+
return r && r.found ? { name: r.name, pid: r.pid, bundle_id: r.bundle_id } : null;
482+
}
483+
484+
// Refuse to post keystrokes when the app the caller named is not the one
485+
// that would receive them. Returns the frontmost app for the receipt.
486+
async function guardInput(appRef) {
487+
const front = await frontmostApp().catch(() => null);
488+
if (!appRef) return front;
489+
const target = await findProcess(appRef);
490+
if (!target.found) throw new ExecError("application not found — call list_apps for exact names/pids");
491+
if (!front || front.pid !== target.pid) {
492+
throw new ExecError(`refusing to send input: "${target.name}" is not frontmost${front ? ` ("${front.name}" is)` : ""}; bring it forward first with open_application { activate: true } or click into it`);
493+
}
494+
return front;
495+
}
496+
445497
async function listWindows(appRef) {
446498
const p = await findProcess(appRef ?? {});
447499
if (!p.found) throw new ExecError("application not found — call list_apps for exact names/pids");
@@ -456,11 +508,25 @@ export function create({ exec }) {
456508
if (activate) args.unshift("-F");
457509
const r = await runL("open", args, { timeoutMs: 25_000 });
458510
if (r.code !== 0) throw new ExecError(`open failed: ${r.stderr.trim().slice(0, 200)}`, r);
459-
await new Promise((res) => setTimeout(res, 600));
460511
const find = {};
461512
if (bid) find.bundle_id = bid; else if (pid) find.pid = pid; else find.name = String(name).replace(/\.app$/, "");
462-
const p = await findProcess(find).catch(() => null);
463-
return { launched: true, activate, url: urlArg ?? null, resolved: p?.found ? { name: p.name, pid: p.pid, bundle_id: p.bundle_id, frontmost: p.frontmost } : null };
513+
// `open -F` returns before the app is in front. Wait for the process to
514+
// exist and, when activation was asked for, to actually be frontmost;
515+
// otherwise the next keystroke lands in whatever app is (#5927).
516+
let p = null;
517+
for (let attempt = 0; attempt < 10; attempt++) {
518+
await new Promise((res) => setTimeout(res, 300));
519+
p = await findProcess(find).catch(() => null);
520+
if (p?.found && (!activate || p.frontmost)) break;
521+
}
522+
const resolved = p?.found ? { name: p.name, pid: p.pid, bundle_id: p.bundle_id, frontmost: !!p.frontmost } : null;
523+
const frontmost = !!resolved?.frontmost;
524+
const note = activate && !frontmost
525+
? (resolved
526+
? `"${resolved.name}" is running but did not come to the front within 3 s; keystrokes would go to another app — retry activation or click into its window before typing`
527+
: `the app did not appear within 3 s of \`open\`; call list_apps to see what is running`)
528+
: undefined;
529+
return { launched: true, activate, frontmost, pid: resolved?.pid ?? null, url: urlArg ?? null, resolved, ...(note ? { note } : {}) };
464530
}
465531

466532
// ---------- clipboard / cursor / waits ----------
@@ -493,21 +559,94 @@ print('{\"x\": %d, \"y\": %d}' % (l.x, l.y))`;
493559
}
494560

495561
// ---------- probe ----------
562+
const TCC_FIX = {
563+
accessibility: "System Settings → Privacy & Security → Accessibility → enable the host app, then relaunch it",
564+
screen_recording: "System Settings → Privacy & Security → Screen & System Audio Recording → enable the host app, then relaunch it",
565+
};
566+
// TCC attributes grants to the .app that owns this process tree (the
567+
// terminal or IDE hosting the engine), never to node or osascript. Name it so
568+
// the remedy says which row to flip.
569+
async function hostAppName() {
570+
if (state.hostApp !== undefined) return state.hostApp;
571+
// Keep the outermost bundle: framework binaries also live inside an .app
572+
// (python3 runs from Python.app), but TCC holds the launching app
573+
// responsible for everything under it.
574+
let pid = process.ppid;
575+
let found = null;
576+
for (let depth = 0; depth < 12 && pid > 1; depth++) {
577+
const r = await runL("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { timeoutMs: 4_000 });
578+
if (r.code !== 0) break;
579+
const m = /^\s*(\d+)\s+(.*)$/.exec(r.stdout.trim());
580+
if (!m) break;
581+
const app = /([^/]+)\.app\//.exec(m[2]);
582+
if (app) found = app[1];
583+
pid = Number(m[1]);
584+
}
585+
state.hostApp = found;
586+
return found;
587+
}
588+
async function grantTarget() {
589+
const host = await hostAppName().catch(() => null);
590+
return host ? `"${host}"` : "the app hosting the Codewhale engine (your terminal)";
591+
}
592+
// Ask TCC instead of guessing from tool presence: screencapture exits 0
593+
// without the grant (it just omits windows) and osascript hangs on the
594+
// prompt, so probing by running them proves nothing.
595+
// The JXA bridge does not expose CGPreflightScreenCaptureAccess, so the
596+
// Screen Recording state is read the way TCC enforces it: without the grant,
597+
// CGWindowListCopyWindowInfo strips kCGWindowName from every other
598+
// process's window. No other windows on screen means the answer is unknown.
599+
async function tccState() {
600+
const r = await jxa(`ObjC.import('ApplicationServices'); ObjC.import('CoreGraphics'); ObjC.import('Foundation');
601+
function run(){
602+
const out = { accessibility: !!$.AXIsProcessTrusted(), screen_recording: null };
603+
const me = $.NSProcessInfo.processInfo.processIdentifier;
604+
const list = $.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements, $.kCGNullWindowID);
605+
const n = Number($.CFArrayGetCount(list));
606+
let others = 0, named = 0;
607+
for (let i = 0; i < n; i++) {
608+
const d = ObjC.deepUnwrap(ObjC.castRefToObject($.CFArrayGetValueAtIndex(list, i)));
609+
if (!d || d.kCGWindowOwnerPID === me || d.kCGWindowLayer !== 0) continue;
610+
others++;
611+
if (typeof d.kCGWindowName === 'string' && d.kCGWindowName.length) named++;
612+
}
613+
if (others > 0) out.screen_recording = named > 0;
614+
return JSON.stringify(out);
615+
}`, {}, 8_000);
616+
return r && typeof r === "object" ? r : {};
617+
}
496618
async function probe() {
497-
const caps = { screenshot: true, recording: true, accessibility_tree: true, clipboard: true, displays: true };
619+
const caps = { screenshot: true, recording: true, accessibility_tree: true, raw_input: true, clipboard: true, displays: true };
498620
const perms = {};
499-
try { await jxa(`function run(argv){ return JSON.stringify({n: Application('System Events').applicationProcesses.length}); }`, {}, 8_000); perms.accessibility = "granted"; }
500-
catch (e) { perms.accessibility = "denied_or_unavailable"; caps.accessibility_tree = false; caps.raw_input = "unreliable"; }
501-
try {
502-
const t = os.tmpdir() + `/cu-probe-${crypto.randomBytes(3).toString("hex")}.png`;
503-
const r = await runL("screencapture", ["-x", "-R0,0,2,2", "-t", "png", t], { timeoutMs: 8_000 });
504-
perms.screen_capture = r.code === 0 ? "ok" : "failed";
505-
try { fs.rmSync(t, { force: true }); } catch {}
506-
} catch { perms.screen_capture = "failed"; }
507-
const hasRecording = fs.existsSync("/usr/sbin/screencapture");
508-
return { platform: "darwin", capabilities: caps, permissions: perms, note: "macOS does not expose Screen-Recording TCC state to CLI; a black/empty screenshot means Screen Recording permission is missing. Raw pointer/keyboard events go to whatever is frontmost at the target point — activate the app first for click-type actions." };
621+
const missing = [];
622+
let tcc = {};
623+
try { tcc = await tccState(); } catch (e) { perms.probe_error = String(e?.message || e).slice(0, 200); }
624+
state.tcc = tcc;
625+
const target = await grantTarget();
626+
if (tcc.accessibility === false) {
627+
perms.accessibility = "denied";
628+
caps.accessibility_tree = false;
629+
caps.raw_input = false;
630+
missing.push("accessibility");
631+
} else {
632+
perms.accessibility = tcc.accessibility === true ? "granted" : "unknown";
633+
}
634+
if (tcc.screen_recording === false) {
635+
perms.screen_recording = "denied";
636+
caps.screenshot = false;
637+
caps.recording = false;
638+
missing.push("screen_recording");
639+
} else {
640+
perms.screen_recording = tcc.screen_recording === true ? "granted" : "unknown";
641+
}
642+
const how_to_fix = Object.fromEntries(missing.map((m) => [m, `${TCC_FIX[m]} — grant it to ${target}`]));
643+
const note = missing.length
644+
? `Missing: ${missing.join(", ")}. Grants belong to ${target}, not to node or osascript. ${Object.values(how_to_fix).join(" ")}`
645+
: "Raw pointer/keyboard events go to whatever is frontmost at the target point — activate the app first for click-type actions.";
646+
return { platform: "darwin", capabilities: caps, permissions: perms, missing, how_to_fix, host_app: state.hostApp ?? null, note };
509647
}
510648

649+
511650
return {
512651
platform: "darwin",
513652
probe,
@@ -565,33 +704,36 @@ print('{\"x\": %d, \"y\": %d}' % (l.x, l.y))`;
565704
$.CGEventPost($.kCGHIDEventTap, ev);
566705
return JSON.stringify({ ok: true });`, { dx, dy }).then(() => ({ action_sent: true, direction, amount }));
567706
},
568-
type: async ({ text }) => {
707+
type: async ({ text, app_ref }) => {
569708
if (!text) return { action_sent: false, note: "empty text" };
709+
const frontmost_app = await guardInput(app_ref);
570710
const r = await cg(`var ev = $.CGEventCreateKeyboardEvent($(), 0, true);
571711
$.CGEventKeyboardSetUnicodeString(ev, P.text.length, P.text);
572712
$.CGEventPost($.kCGHIDEventTap, ev);
573713
var ev2 = $.CGEventCreateKeyboardEvent($(), 0, false);
574714
$.CGEventKeyboardSetUnicodeString(ev2, P.text.length, P.text);
575715
$.CGEventPost($.kCGHIDEventTap, ev2);
576716
return JSON.stringify({ ok: true, chars: P.text.length });`, { text }, 15_000);
577-
return { action_sent: true, chars: text.length, strategy: "unicode-events" };
717+
return { action_sent: true, chars: text.length, strategy: "unicode-events", frontmost_app };
578718
},
579-
key: async ({ text, repeat = 1 }) => {
719+
key: async ({ text, repeat = 1, app_ref }) => {
580720
const { flags, code, key } = parseChord(text);
721+
const frontmost_app = await guardInput(app_ref);
581722
for (let i = 0; i < Math.max(1, Math.min(100, repeat)); i++) {
582723
await keyEvent(code, flags, true);
583724
await keyEvent(code, flags, false);
584725
if (i < repeat - 1) await new Promise((r) => setTimeout(r, 30));
585726
}
586-
return { action_sent: true, key, code, repeat: Math.max(1, Math.min(100, repeat)) };
727+
return { action_sent: true, key, code, repeat: Math.max(1, Math.min(100, repeat)), frontmost_app };
587728
},
588-
hold_key: async ({ text, duration }) => {
729+
hold_key: async ({ text, duration, app_ref }) => {
589730
const { flags, code, key } = parseChord(text);
731+
const frontmost_app = await guardInput(app_ref);
590732
const d = Math.max(0.05, Math.min(30, Number(duration) || 1));
591733
await keyEvent(code, flags, true);
592734
await new Promise((r) => setTimeout(r, d * 1000));
593735
await keyEvent(code, flags, false);
594-
return { action_sent: true, key, heldSec: d };
736+
return { action_sent: true, key, heldSec: d, frontmost_app };
595737
},
596738
set_value: async ({ target, value }) => {
597739
const el = await elementAction(target.app_ref, target.windowIndex, target.path, { kind: "set_value", value });

‎crates/tui/plugins/computer-use/src/tools.mjs‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ const computerParam = {
66
description: "Computer id to act on. Defaults to the active computer. Providing a different registered id switches to it first (sticky).",
77
};
88

9+
// Optional guard for keystroke tools: the app that must be frontmost.
10+
const inputAppRef = {
11+
type: "object",
12+
description: "Refuse to send the keystrokes unless this app is frontmost (name, bundle_id, or pid).",
13+
properties: { pid: { type: "integer" }, name: { type: "string" }, bundle_id: { type: "string" } },
14+
additionalProperties: false,
15+
};
16+
917
const targetSchema = {
1018
oneOf: [
1119
{
@@ -211,16 +219,16 @@ export const TOOLS = [
211219
},
212220
// ---- text & keyboard ----
213221
{
214-
name: "type", description: "Type text into the focused control (unicode). Focus the field first (click/element action).",
215-
inputSchema: { type: "object", required: ["text"], properties: { text: { type: "string" }, computer: computerParam }, additionalProperties: false },
222+
name: "type", description: "Type text into the focused control (unicode). Focus the field first (click/element action). Pass app_ref to refuse unless that app is frontmost; the receipt names frontmost_app either way.",
223+
inputSchema: { type: "object", required: ["text"], properties: { text: { type: "string" }, app_ref: inputAppRef, computer: computerParam }, additionalProperties: false },
216224
},
217225
{
218-
name: "key", description: "Press a key or chord, e.g. 'return', 'cmd+c' (macOS), 'ctrl+c' (Linux/Windows). Repeat with `repeat`.",
219-
inputSchema: { type: "object", required: ["text"], properties: { text: { type: "string" }, repeat: { type: "integer", minimum: 1, maximum: 100 }, computer: computerParam }, additionalProperties: false },
226+
name: "key", description: "Press a key or chord, e.g. 'return', 'cmd+c' (macOS), 'ctrl+c' (Linux/Windows). Repeat with `repeat`. Pass app_ref to refuse unless that app is frontmost; the receipt names frontmost_app either way.",
227+
inputSchema: { type: "object", required: ["text"], properties: { text: { type: "string" }, repeat: { type: "integer", minimum: 1, maximum: 100 }, app_ref: inputAppRef, computer: computerParam }, additionalProperties: false },
220228
},
221229
{
222230
name: "hold_key", description: "Hold a key for `duration` seconds (0.05..30).",
223-
inputSchema: { type: "object", required: ["text", "duration"], properties: { text: { type: "string" }, duration: { type: "number", minimum: 0.05, maximum: 30 }, computer: computerParam }, additionalProperties: false },
231+
inputSchema: { type: "object", required: ["text", "duration"], properties: { text: { type: "string" }, duration: { type: "number", minimum: 0.05, maximum: 30 }, app_ref: inputAppRef, computer: computerParam }, additionalProperties: false },
224232
},
225233
{
226234
name: "set_value", description: "Set an editable element's value through the accessibility layer (background-safe, no keystrokes). Element targets only.",

0 commit comments

Comments
 (0)