Skip to content

feat(cua-driver): WKWebView/Tauri AX fallback for get_text and query_dom - #1389

Merged
f-trycua merged 4 commits into
mainfrom
feat/cua-driver-wkwebview-tauri-ax
Apr 26, 2026
Merged

feat(cua-driver): WKWebView/Tauri AX fallback for get_text and query_dom#1389
f-trycua merged 4 commits into
mainfrom
feat/cua-driver-wkwebview-tauri-ax

Conversation

@f-trycua

@f-trycua f-trycua commented Apr 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • AXPageReader: extract page text and DOM elements from WKWebView's AX tree — used as a JS-free fallback for Tauri apps where webinspectord is blocked by the com.apple.private.webinspector.remote-inspection-debugger private entitlement
  • WebInspectorXPC: detect WKWebView apps (excludes Electron); stubs the full Mach IPC protocol for future use
  • CDPClient: shared CDP HTTP+WebSocket evaluator extracted from ElectronJS
  • WebKitJS: scan GTK/WPE WebKit TCP inspector ports (Linux/fallback path)
  • ElectronJS: refactored to use CDPClient; no behaviour change
  • PageTool: get_text and query_dom route through AX tree for WKWebView/Tauri apps; execute_javascript returns a clear error pointing to the AX alternatives
  • LaunchAppTool: webkit_inspector_port launches Tauri with TAURI_WEBVIEW_AUTOMATION=1; electron_debugging_port launches Electron with --remote-debugging-port=N
  • Permissions: fix SCShareableContent.currentexcludingDesktopWindows(false, onScreenWindowsOnly: true) — avoids 6s hang on machines with many off-screen windows (fixes Performance regression with using SCShareableContent.current #1371)
  • test_webkit_js: new integration tests covering AX fallback path with Conductor
  • test_browser_js: fix 4s timing gap before querying page title; fix isWKWebViewApp false-positive for Chrome/Brave/Edge

Test plan

  • scripts/test.sh test_webkit_js — all non-inspector tests pass (10 pass, 8 skip)
  • scripts/test.sh test_browser_js — all 11 pass

Closes #1371

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced browser page tool supporting JavaScript execution, text extraction, and DOM querying across Chrome, Safari, Electron, and other web frameworks.
    • Extended web app support for JavaScript-based automation across varied rendering engines.
  • Documentation

    • Added guidance on using JavaScript patterns to bypass sparse accessibility trees in web-rendered applications.

@vercel

vercel Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Apr 26, 2026 7:50am

Request Review

@coderabbitai

coderabbitai Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9649d8d8-08dc-4563-b0f8-918e1f039985

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR introduces a comprehensive JavaScript execution infrastructure for the CUA driver, adding support for executing JavaScript across multiple browser and app types (Chrome/Safari via AppleScript, Electron via CDP and SIGUSR1, WebKit via TCP inspector). It includes new backend modules, a unified PageTool for browser page operations, accessibility tree parsing without JavaScript, and integration tests validating the functionality.

Changes

Cohort / File(s) Summary
Core Browser JavaScript Execution Backends
BrowserJS.swift, CDPClient.swift, ElectronJS.swift, WebKitJS.swift
Four new execution backends: AppleScript-based execution for Chromium/Safari, CDP client for Chrome DevTools Protocol evaluation, SIGUSR1-triggered V8 inspector activation for Electron apps, and TCP/remote-inspector WebKit support. Includes error mapping, process/window lookup, and port probing logic.
Browser Detection & AX Tree Utilities
WebInspectorXPC.swift, AXPageReader.swift
Detection of WKWebView/Tauri apps via bundle inspection and otool analysis. JavaScript-free AX tree parsing that extracts visible text, parses interactive element indices, and supports simplified CSS selectors mapped to accessibility roles.
Unified Tool Integration
PageTool.swift
New MCP tool dispatching browser page primitives (execute_javascript, get_text, query_dom) across all execution backends, with dynamic backend selection by app type and graceful AX tree fallbacks for WKWebView/Tauri apps.
Performance & Documentation
Permissions.swift, SKILL.md, CuaDriver.entitlements
Fixed screen-recording permission probe by using on-screen-only shareable content filtering. Added documentation on bypassing sparse AX trees via JavaScript tools. Noted entitlements limitation for third-party web inspector access.
Integration Tests
test_browser_js.py, test_webkit_js.py
End-to-end test suites validating JavaScript execution across Chrome, get_text/query_dom primitives, fallback AX tree behavior for WKWebView apps, and error reporting for unsupported app types.

Sequence Diagrams

sequenceDiagram
    participant Client
    participant PageTool
    participant AppDetector as App Detection<br/>(WebInspectorXPC)
    participant AXReader as AX Reader
    participant BrowserJS
    participant ElectronJS
    participant CDPClient
    participant Process as Target App

    Client->>PageTool: page(action, pid, window_id)
    PageTool->>AppDetector: Detect app type<br/>(isElectron, isWKWebViewApp)
    AppDetector->>Process: Check bundle, frameworks
    Process-->>AppDetector: App type result

    alt Electron App
        PageTool->>ElectronJS: execute(javascript, pid)
        ElectronJS->>Process: SIGUSR1 (activate V8)
        ElectronJS->>CDPClient: Evaluate on inspector port
        CDPClient->>Process: WebSocket CDP request
        Process-->>CDPClient: Result
        CDPClient-->>ElectronJS: Return value
        ElectronJS-->>PageTool: JavaScript output
    else Chrome/Safari
        PageTool->>BrowserJS: execute(javascript, bundleId, windowId)
        BrowserJS->>Process: Build/execute AppleScript
        Process-->>BrowserJS: JavaScript result
        BrowserJS-->>PageTool: Return value
    else WKWebView/Tauri
        PageTool->>AXReader: Extract text or query
        AXReader->>PageTool: AX tree results
    end

    PageTool-->>Client: Result (data/error)
Loading
sequenceDiagram
    participant Script as AppleScript
    participant osascript as /usr/bin/osascript
    participant Browser as Browser Process
    participant Tab as Active Tab

    Script->>osascript: Execute browser-specific JS script
    osascript->>Browser: Activate & inject script
    Browser->>Tab: Run JavaScript in context
    Tab-->>Browser: Evaluate result
    Browser-->>osascript: Result string (or error)
    osascript-->>Script: stdout/stderr mapping
    Script->>Script: Parse errors<br/>(javascriptNotEnabled,<br/>executionFailed)
Loading
sequenceDiagram
    participant PageTool
    participant snapshot as AppStateRegistry<br/>.engine.snapshot
    participant AXTree as Accessibility<br/>Tree Markdown
    participant Parser as AXPageReader

    PageTool->>snapshot: Fetch current AX tree
    snapshot-->>AXTree: treeMarkdown string
    
    alt get_text action
        PageTool->>Parser: extractText(from:)
        Parser->>AXTree: Parse each line
        Parser->>Parser: Filter roles<br/>(headings, static text,<br/>interactive values)
        Parser->>Parser: De-duplicate<br/>consecutive lines
        Parser-->>PageTool: Visible text string
    else query_dom action
        PageTool->>Parser: query(selector, from:)
        Parser->>Parser: Map CSS selector<br/>to AX role
        Parser->>AXTree: Scan for role matches
        Parser->>Parser: Extract title, value,<br/>index, description
        Parser-->>PageTool: [Element] array
    end

    PageTool-->>Client: Result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • ddupont808
  • r33drichards

Poem

🐰 A rabbit's ode to JavaScript adventures:

Through AppleScript and CDP we leap,
Where Electrons and WebKit keep,
The inspector ports where secrets sleep,
While AX trees our fallback reap,
From sparse trees, one bound helps us sweep! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.34% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding WKWebView/Tauri AX fallback support for get_text and query_dom, which is the primary feature introduced in this PR.
Linked Issues check ✅ Passed The PR addresses issue #1371 by replacing SCShareableContent.current with excludingDesktopWindows(false, onScreenWindowsOnly: true) in Permissions.swift to fix the performance regression.
Out of Scope Changes check ✅ Passed All code changes align with stated objectives: WKWebView/Tauri AX fallback implementation, supporting infrastructure (CDPClient, WebKitJS, ElectronJS refactor), PageTool routing updates, and the permission fix for #1371.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cua-driver-wkwebview-tauri-ax

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (12)
libs/cua-driver/Sources/CuaDriverCore/Permissions/Permissions.swift (1)

25-28: Update the stale doc comment to match the new probe API.

The doc comment on currentStatus() still describes the probe as using SCShareableContent.current, but the implementation now uses excludingDesktopWindows(false, onScreenWindowsOnly: true). Worth updating so readers don't get misled, and to record the rationale (avoiding the multi-second hang from off-screen window enumeration, per #1371).

📝 Proposed doc update
     /// Accurate TCC status for both grants.
     ///
     /// Accessibility uses `AXIsProcessTrusted()` — reliable.
     ///
-    /// Screen Recording does a real probe via `SCShareableContent.current`
-    /// rather than `CGPreflightScreenCaptureAccess()` — the latter returns
-    /// false negatives for subprocess-launched apps, even when the grant is
-    /// active. The probe costs ~100-300ms but returns the truth.
+    /// Screen Recording does a real probe via
+    /// `SCShareableContent.excludingDesktopWindows(_:onScreenWindowsOnly:)`
+    /// rather than `CGPreflightScreenCaptureAccess()` — the latter returns
+    /// false negatives for subprocess-launched apps, even when the grant is
+    /// active. We restrict to on-screen windows to avoid multi-second hangs
+    /// when the window server has many off-screen entries (see `#1371`).
+    /// The probe costs ~100-300ms but returns the truth.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Permissions/Permissions.swift` around
lines 25 - 28, The doc comment for currentStatus() is stale—update it to
describe that the probe now uses the CGWindowList API with
excludingDesktopWindows(false, onScreenWindowsOnly: true) instead of
SCShareableContent.current; mention the rationale that this avoids the
multi-second hang caused by enumerating off-screen windows (per `#1371`) while
still accurately probing screen-recording permission, and adjust wording to note
the ~100–300ms probe cost if still applicable.
libs/cua-driver/Sources/CuaDriverCore/Browser/CDPClient.swift (2)

83-87: Force-unwrap of String(data:encoding:) is safe but an avoidable smell.

JSONSerialization.data(withJSONObject:) always produces valid UTF-8, so the ! won't trigger in practice — but it's the kind of construct that makes audits flag the file. A simple guard let with a thrown Error.connectionFailed("payload encoding") keeps the contract explicit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/CDPClient.swift` around lines
83 - 87, Replace the force-unwrap when creating payloadStr: instead of using
String(data: try JSONSerialization.data(withJSONObject: payload), encoding:
.utf8)!, use a guard let on String(data: ..., encoding: .utf8) to safely unwrap
and, on failure, throw Error.connectionFailed("payload encoding"); update the
code at the payloadStr creation site in CDPClient.swift (look for the payloadStr
variable and the JSONSerialization.data(withJSONObject:) call) so the method's
contract is explicit and avoids the force-unwrap smell.

88-112: No bounded timeout on the WebSocket evaluate path.

evaluate has no explicit deadline around ws.send / ws.receive. If the inspector hangs (target paused at a breakpoint, network blip, frozen renderer), the continuation never resumes and the calling task is wedged indefinitely. Inspector hangs are real failure modes (Electron page targets paused on debugger;, Tauri/WebKit targets stuck during navigation).

Wrap the await in withTimeout (or use Task.sleep + race) and surface as Error.evaluationFailed("timeout after Ns").

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/CDPClient.swift` around lines
88 - 112, The evaluate path currently awaits a WebSocket send/receive inside
withCheckedThrowingContinuation with no deadline, so if the inspector hangs the
continuation is never resumed; modify the evaluate implementation (the async
method that creates the URLSession.webSocketTask, calls ws.send and ws.receive
and resumes the continuation) to impose a bounded timeout by racing the
continuation against a timeout Task (or using your withTimeout helper), and if
the timeout wins call continuation.resume(throwing:
Error.evaluationFailed("timeout after Ns")) and cancel the webSocketTask; ensure
all existing resume paths (ws send error, receive failure/success, and the new
timeout branch) still only resume once and that ws.cancel() is invoked when
timing out.
libs/cua-driver/Sources/CuaDriverCore/Browser/WebInspectorXPC.swift (2)

45-48: bundlePath.contains("tauri") is a fragile string heuristic.

Any app whose install path or bundle name happens to include "tauri" (case-insensitive — comment quotes only tauri while the path has been lowercased, so e.g. /Applications/Tauri Tools/SomeNonTauriApp.app matches) gets force-classified as WKWebView and short-circuits the WebKit-linkage check below. The comment itself acknowledges "no good heuristic beyond bundle name."

In practice the executable WebKit-linkage probe (otool -L) is the reliable signal, so the tauri shortcut mainly serves to skip an otool call. Either remove it and rely on the linkage check, or tighten it (e.g., look for a Contents/MacOS/<name> binary linked against libwry/tauri symbols) to avoid surprising false positives.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/WebInspectorXPC.swift` around
lines 45 - 48, The current fragile heuristic using bundlePath.contains("tauri")
in WebInspectorXPC.swift incorrectly classifies many apps as Tauri; remove this
short-circuit and rely on the existing WebKit-linkage probe (the otool -L check)
for accurate detection, or if you must keep a fast path, tighten it by resolving
the app bundle's Contents/MacOS/<executable> and checking that binary's
linkage/symbols for known Tauri/libwry identifiers before returning true (refer
to bundleURL, bundlePath.contains("tauri") and the WebKit-linkage probe logic to
locate and update the decision point).

55-69: /usr/bin/otool -L runs synchronously on every isWKWebViewApp call — consider caching by bundle URL.

PageTool calls this on every get_text / query_dom / execute_javascript to gate the dispatch. Each call forks otool, which is non-trivial overhead and adds end-to-end latency before the snapshot work even starts. The result is stable for the lifetime of an app's executable, so caching keyed by (bundleURL, executableURL) (or simply by bundle id) would eliminate the repeat cost.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/WebInspectorXPC.swift` around
lines 55 - 69, The isWKWebViewApp flow currently calls isLinkedToWebKit (which
runs runProcess("/usr/bin/otool", ...)) on every check, so add a static cache
(e.g., [URL: Bool] or [String: Bool] keyed by bundleURL or bundle identifier)
inside WebInspectorXPC to remember results per app; update isWKWebViewApp to
first consult this cache and return the cached Bool if present, otherwise call
isLinkedToWebKit, store the result in the cache, and return it; ensure
thread-safety around the cache (use a serial DispatchQueue or lock) and keep
references keyed by bundle/executable URL (or bundle id) to avoid rerunning the
expensive otool subprocess repeatedly.
libs/cua-driver/Tests/integration/test_webkit_js.py (2)

178-183: Hardcoded time.sleep(3) after launch is brittle.

3 s is a magic number that will be flaky on CI under load and slow on first launch (cold cache, gatekeeper scan). Consider polling _find_conductor_pid with a short timeout instead, so the test fails fast when the launch genuinely doesn't complete and isn't slowed unnecessarily on warm runs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Tests/integration/test_webkit_js.py` around lines 178 - 183,
Replace the brittle fixed sleep after calling "launch_app" with a short polling
loop that repeatedly calls _find_conductor_pid until it returns a PID or a
configurable timeout elapses; specifically, after cls.client.call("launch_app",
...) remove time.sleep(3) and instead poll _find_conductor_pid(cls.client) with
a small interval (e.g. 0.1–0.5s) and a total timeout (e.g. a few seconds) and
set cls.pid when found, raising/asserting a clear failure if the timeout elapses
so the test fails fast on genuine launch failures.

137-148: if window_id: treats 0 as missing.

CGWindowID is unsigned and the kernel can in principle return 0 (it's kCGNullWindowID, but parsing id=0 from list_windows text would still hit this). More importantly the same idiom is used twice (here and in _get_window_id inner loop) — using is not None makes the intent explicit and avoids the falsy-zero gotcha if the id ever legitimately surfaces.

-            if window_id:
+            if window_id is not None:
                 break
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Tests/integration/test_webkit_js.py` around lines 137 - 148,
The code incorrectly checks the parsed window_id with a truthy test which treats
0 as missing; update the conditionals that check window_id (both the outer check
here and the inner loop in _get_window_id) to use explicit "is not None"
comparisons so 0 is treated as a valid CGWindowID and the intent is clear;
ensure you only call self.skipTest("No Chrome window found") when window_id is
None.
libs/cua-driver/Sources/CuaDriverCore/Browser/ElectronJS.swift (1)

189-206: runProcess doesn't drain stderr; stdout read is also lazy.

proc.standardError = Pipe() is set but never read — for a verbose process this could fill the kernel pipe buffer (~16-64 KB) and block the child indefinitely. For lsof calls used here, output is small enough this is unlikely to bite, but it's a footgun if this helper is reused.

A small refactor to read both pipes (or redirect stderr to /dev/null since callers don't consume it) would harden it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/ElectronJS.swift` around lines
189 - 206, The runProcess helper (function runProcess) sets proc.standardError =
Pipe() but never reads it and only reads stdout lazily in the
terminationHandler, which can deadlock if stderr fills; fix by creating a
separate Pipe for stderr (e.g., let errPipe = Pipe()), read both
pipe.fileHandleForReading and errPipe.fileHandleForReading (collect
dataToEndOfFile) concurrently before/respecting termination, then combine or
discard stderr (or redirect to /dev/null) as callers expect, and ensure
continuation.resume is called exactly once with the aggregated stdout (and
optionally include stderr in the returned string or log it) while catching
proc.run() errors and cleaning up handlers (update references to proc, pipe,
standardError, terminationHandler, and runProcess).
libs/cua-driver/Sources/CuaDriverCore/Browser/WebKitJS.swift (1)

9-11: Doc-comment "near-instant" fall-through is closer to ~1.5 s.

With 3 ports at 0.5 s timeout each, an all-miss probe takes up to 1.5 s — not "near-instant". Worth noting since this runs on every page action dispatch on macOS where the WebKit TCP inspector is never present.

If WebKitJS.isAvailable() is on the hot path, consider caching a negative result for the lifetime of the process or short-circuiting based on platform/isWKWebViewApp detection.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/WebKitJS.swift` around lines 9
- 11, Update the doc-comment to stop saying "near-instant" and state the
aggregate timeout (~1.5s for 3 ports at 0.5s each) and that probes can be slow
on macOS; then change the hot-path probe logic inside WebKitJS.isAvailable() to
avoid repeated full probes by caching a negative result for the process lifetime
(e.g. a static/cached Optional Bool used on subsequent calls) and/or
short-circuit early using platform or isWKWebViewApp detection before performing
the per-port timeouts so repeated page dispatches don't incur the 1.5s cost.
libs/cua-driver/Sources/CuaDriverCore/Browser/BrowserJS.swift (1)

198-204: Substring matching on error messages is potentially locale-fragile, but no documented error code alternative exists.

The check err.contains("turned off") relies on osascript's stderr output, which could theoretically be localized. However, web search found no documented numeric error code for the "Allow JavaScript from Apple Events" disabled condition—Chrome returns a descriptive English message, and no standard numeric error codes are tied to this specific setting. The suggestion to check terminationStatus or match on error codes like (-2740) / (-1753) lacks supporting documentation.

If this becomes a problem in practice (i.e., if the error message is actually localized on non-English systems), consider detecting this condition more robustly—for example, by checking for specific app bundles before attempting execution, or by improving error message detection to handle variants. For now, this remains a known limitation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/BrowserJS.swift` around lines
198 - 204, The current error detection uses fragile substring matching on err to
decide when to throw Error.javascriptNotEnabled(appName, bundleId); change the
check so it normalizes err (lowercase and trimmed) and does a case-insensitive
contains match (e.g., on "turned off" and "applescript is turned off") to reduce
locale/format fragility, and add a short TODO comment near the
p.terminationStatus / continuation.resume block noting the known limitation and
recommending future improvements (pre-checking target app bundle permissions or
a more robust AppleEvent/TCC check) if localization issues arise.
libs/cua-driver/Tests/integration/test_browser_js.py (1)

283-284: Fragile regex with unclear intent.

r"elements.*turn" is opaque — it presumably matches AX tree summary text like "N elements... return" but the intent isn't documented and the pattern is brittle to copy changes. Consider asserting on a specific, stable AX tree marker (e.g., the section header that get_window_state always emits) and add a comment describing what content is expected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Tests/integration/test_browser_js.py` around lines 283 - 284,
The current fragile regex self.assertRegex(text, r"elements.*turn",
re.IGNORECASE) in test_browser_js.py is opaque and brittle; replace it with an
assertion that targets a stable AX tree marker emitted by get_window_state (for
example the fixed section header string that get_window_state always includes,
e.g., "AX Tree" or the exact header used in get_window_state), and add a
one-line comment above the assertion describing the expected AX tree content and
why that header is stable; update the assertion to use
self.assertIn(stable_header, text) or a precise regex matching that header to
make the test stable and readable.
libs/cua-driver/Sources/CuaDriverCore/Browser/AXPageReader.swift (1)

95-137: Unrecognized tag selectors silently match everything.

cssToAXRoles returns [] for *, empty, class/id-only, and for unrecognized tags (default case). In query, empty roles becomes matchAll = true, so a typo like query(selector: "xyzzy", …) returns the entire AX tree instead of zero elements. CSS-wise, an unknown tag would normally match nothing.

Consider distinguishing "explicit wildcard" from "unrecognized" so that callers can get an empty result for typos/unsupported selectors.

♻️ Possible refactor
 private static func cssToAXRoles(_ selector: String) -> Set<String> {
-    // Strip pseudo-classes, attribute selectors, combinators for simplicity.
+    // Strip pseudo-classes, attribute selectors, combinators for simplicity.
     let cleaned = selector
         .components(separatedBy: CharacterSet(charactersIn: ":>+~["))
         .first?
         .trimmingCharacters(in: .whitespaces) ?? selector

-    // If it's purely a class (.foo) or id (`#foo`) selector we can't map it.
-    if cleaned.hasPrefix(".") || cleaned.hasPrefix("#") || cleaned == "*" || cleaned.isEmpty {
-        return []
-    }
+    // Wildcard / class / id only → match everything (caller convention).
+    if cleaned == "*" || cleaned.isEmpty
+        || cleaned.hasPrefix(".") || cleaned.hasPrefix("#") {
+        return []
+    }
     ...
-    default:                          return []
+    default:                          return ["__AX_NO_MATCH__"]   // sentinel that no role will equal
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/AXPageReader.swift` around
lines 95 - 137, cssToAXRoles currently conflates explicit wildcards (like "*" or
empty/class/id-only selectors) with unrecognized tag names by returning an empty
Set, causing the query(...) caller to treat typos as "match all". Change
cssToAXRoles to distinguish these cases: have it return an Optional Set (or a
small enum) where nil (or a MatchAll sentinel) means explicit
wildcard/class-or-id-only (preserve existing match-all behavior) but return an
actual empty Set for truly unrecognized tags so callers get zero matches; then
update query(...) to treat only the nil/MatchAll sentinel as matchAll and treat
an empty Set as matchNone. Ensure references: cssToAXRoles and query are
adjusted accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@libs/cua-driver/Skills/cua-driver/SKILL.md`:
- Around line 773-775: The fenced code block containing get_window_state({pid,
window_id, javascript: "document.title"}) is missing a language tag which
triggers markdownlint MD040; update the triple-backtick opener to include a
language (e.g., text or javascript) so it becomes ```text (or ```javascript) and
keep the block content unchanged to provide proper syntax highlighting and lint
compliance in SKILL.md.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/BrowserJS.swift`:
- Around line 226-242: The current 5s polling loop using MainActor.run over
NSWorkspace.shared.runningApplications (filtering by bundleId) can exit while
the browser is still running and then proceed to JSONSerialization.data, leading
to a race; change the logic so that after calling app.terminate() you either (a)
if any apps still exist after the deadline, throw an enable-failed error (e.g.,
throw .enableFailed(...)) to fail fast, or (b) extend the wait and for any
remaining apps call app.forceTerminate() and then wait until
NSWorkspace.shared.runningApplications no longer contains bundleId before
proceeding to write Preferences; ensure you reference the same bundleId, use
app.terminate()/app.forceTerminate(), and only continue to
JSONSerialization.data once no runningApplications contain the bundleId.
- Around line 221-305: The enableJavaScriptAppleEvents flow incorrectly treats
Safari as unsupported because profilesDirectory(for:) returns nil; update
enableJavaScriptAppleEvents to handle bundleId == "com.apple.Safari" specially
(or surface a clear Safari-specific error) by either: (A) implementing the
Safari path — run the appropriate defaults write for key
"AllowJavaScriptFromAppleEvents" and ensure the Develop menu requirement is
documented before returning, or (B) immediately throw a descriptive Error (e.g.
in enableJavaScriptAppleEvents when bundleId == "com.apple.Safari") that tells
callers Safari must be enabled via the Develop menu and the defaults write
command; also update javascriptNotEnabledMessage to include the Safari-specific
manual steps when bundleId == "com.apple.Safari". Use the symbols
enableJavaScriptAppleEvents(bundleId:), profilesDirectory(for:),
javascriptNotEnabledMessage and the Error enum to locate and implement the
change.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/CDPClient.swift`:
- Around line 88-111: The receive path may consume an unsolicited event frame
because it reads exactly one frame and passes it to parseResult; change
CDPClient's request/response flow to loop-reading ws.receive until a frame with
a matching "id" is found (or a bounded timeout occurs) instead of assuming the
first frame is the response. Implement a helper (e.g.,
receiveMatchingResponse(webSocket: URLSessionWebSocketTask, expectedId: Int,
timeout: TimeInterval)) that receives frames, parses each string payload to
JSON, inspects obj["id"] and returns the string for the matching id (or throws
Error.connectionFailed on timeout/parse failure), then call parseResult only
with that matched frame; ensure the original continuation usage in
withCheckedThrowingContinuation remains but uses this helper instead of a single
ws.receive.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/ElectronJS.swift`:
- Around line 96-122: The polling loop currently calls scanInspectorPorts() on
every iteration which can massively exceed the documented 2s budget; change the
flow so the loop only polls listeningPorts(pid:) and checks isInspectorPort(_:)
for newPorts for up to 10 iterations, and if inspectorPort is still nil after
the loop run scanInspectorPorts() exactly once as a final fallback (using its
result to set inspectorPort before the guard/throw). Update references: remove
the scanInspectorPorts() call from inside the for _ in 0..<10 loop and add a
single call to scanInspectorPorts() after the loop that assigns inspectorPort if
non-nil.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/WebInspectorXPC.swift`:
- Around line 34-61: The method isWKWebViewApp accesses
NSWorkspace.shared.runningApplications from a non-main-actor context which is
unsafe; update the function to be main-actor safe by either (A) annotating
isWKWebViewApp with `@MainActor` and making callers await the new async function,
or (B) keep it synchronous but wrap the NSWorkspace access in await
MainActor.run { ... } to fetch runningApplications and app.bundleURL before
continuing; ensure the check that uses isLinkedToWebKit(executableURL:) is
invoked only after the MainActor-sourced values are captured so you do not
access AppKit objects off the main actor.

In `@libs/cua-driver/Sources/CuaDriverServer/Tools/PageTool.swift`:
- Around line 200-220: Attribute names in attrs are interpolated directly into
attrJS without escaping, which can break the generated JS or allow injection;
update the attrJS construction to run each attribute name through the existing
jsonString helper (the same way selector is escaped) before joining them so that
attrJS uses the safely-escaped values. Specifically, transform the attrs ->
attrJS mapping to use jsonString(...) for each element (referencing attrs,
attrJS and jsonString in PageTool.swift) so attribute names containing quotes,
backslashes, or newlines are properly escaped.
- Around line 165-231: For the WKWebView/Tauri code paths in the get_text and
query_dom cases, stop falling through to executeJS when
WebInspectorXPC.isWKWebViewApp(pid:) is true; if axGetText(...) or
axQueryDom(...) returns nil because the snapshot succeeded but had no matches,
treat that as a valid empty result (return okResult with "## Page text (via AX
tree)\n\n\(axText)" for get_text and for query_dom return okResult with an empty
JSON array e.g. "[]"); update the query_dom branch to coalesce axQueryDom(...)
to "[]" (or change axQueryDom to return "[]" when snapshot succeeded with zero
matches and only return nil for true snapshot failures) and ensure executeJS is
only attempted when not a WKWebView app.
- Around line 165-199: The get_text and query_dom branches incorrectly route
Safari to the AX-tree fallback because they test
WebInspectorXPC.isWKWebViewApp(pid:) before checking JavaScript support; update
both cases to first call BrowserJS.supports(bundleId:) (same guard used by
executeJS) and, only if JS is unsupported, then fall back to
WebInspectorXPC.isWKWebViewApp(pid:) and the axGetText/axQueryDom paths; keep
executeJS/axGetText/axQueryDom calls and error handling unchanged so
WKWebView/Tauri apps without JS support still use the AX-tree fallback.

In `@libs/cua-driver/Tests/integration/test_browser_js.py`:
- Around line 69-79: The prefs write is non-atomic and can corrupt Chrome if
interrupted; update the block that opens prefs_path to write to a temp file in
the same directory (use tempfile.NamedTemporaryFile or create prefs_path +
".tmp" in os.path.dirname(prefs_path)), json.dump the data to that temp file,
fsync and close it, then atomically replace the original with
os.replace(temp_path, prefs_path); ensure you only replace after successful dump
and consider preserving permissions if needed.

In `@libs/cua-driver/Tests/integration/test_webkit_js.py`:
- Around line 242-277: The tests test_query_dom_buttons_via_ax_tree and
test_query_dom_links_via_ax_tree only assert the output is not a crash but don't
verify the AX-fallback actually returned data; change each test to assert the
call result is not an error (use _is_error(result) is False) and that the
payload contains at least one element (non-empty list/field returned by the
query_dom response), and if the page legitimately has no matching elements on
first launch then call self.skipTest with an explanatory message; locate the
checks inside those two test methods (they already build result via
self.client.call("page", {...}) and compute text = _tool_text(result)) and add
the _is_error and non-empty payload assertions immediately after obtaining
result/text, keeping the existing crash assertions as secondary safeguards.

---

Nitpick comments:
In `@libs/cua-driver/Sources/CuaDriverCore/Browser/AXPageReader.swift`:
- Around line 95-137: cssToAXRoles currently conflates explicit wildcards (like
"*" or empty/class/id-only selectors) with unrecognized tag names by returning
an empty Set, causing the query(...) caller to treat typos as "match all".
Change cssToAXRoles to distinguish these cases: have it return an Optional Set
(or a small enum) where nil (or a MatchAll sentinel) means explicit
wildcard/class-or-id-only (preserve existing match-all behavior) but return an
actual empty Set for truly unrecognized tags so callers get zero matches; then
update query(...) to treat only the nil/MatchAll sentinel as matchAll and treat
an empty Set as matchNone. Ensure references: cssToAXRoles and query are
adjusted accordingly.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/BrowserJS.swift`:
- Around line 198-204: The current error detection uses fragile substring
matching on err to decide when to throw Error.javascriptNotEnabled(appName,
bundleId); change the check so it normalizes err (lowercase and trimmed) and
does a case-insensitive contains match (e.g., on "turned off" and "applescript
is turned off") to reduce locale/format fragility, and add a short TODO comment
near the p.terminationStatus / continuation.resume block noting the known
limitation and recommending future improvements (pre-checking target app bundle
permissions or a more robust AppleEvent/TCC check) if localization issues arise.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/CDPClient.swift`:
- Around line 83-87: Replace the force-unwrap when creating payloadStr: instead
of using String(data: try JSONSerialization.data(withJSONObject: payload),
encoding: .utf8)!, use a guard let on String(data: ..., encoding: .utf8) to
safely unwrap and, on failure, throw Error.connectionFailed("payload encoding");
update the code at the payloadStr creation site in CDPClient.swift (look for the
payloadStr variable and the JSONSerialization.data(withJSONObject:) call) so the
method's contract is explicit and avoids the force-unwrap smell.
- Around line 88-112: The evaluate path currently awaits a WebSocket
send/receive inside withCheckedThrowingContinuation with no deadline, so if the
inspector hangs the continuation is never resumed; modify the evaluate
implementation (the async method that creates the URLSession.webSocketTask,
calls ws.send and ws.receive and resumes the continuation) to impose a bounded
timeout by racing the continuation against a timeout Task (or using your
withTimeout helper), and if the timeout wins call continuation.resume(throwing:
Error.evaluationFailed("timeout after Ns")) and cancel the webSocketTask; ensure
all existing resume paths (ws send error, receive failure/success, and the new
timeout branch) still only resume once and that ws.cancel() is invoked when
timing out.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/ElectronJS.swift`:
- Around line 189-206: The runProcess helper (function runProcess) sets
proc.standardError = Pipe() but never reads it and only reads stdout lazily in
the terminationHandler, which can deadlock if stderr fills; fix by creating a
separate Pipe for stderr (e.g., let errPipe = Pipe()), read both
pipe.fileHandleForReading and errPipe.fileHandleForReading (collect
dataToEndOfFile) concurrently before/respecting termination, then combine or
discard stderr (or redirect to /dev/null) as callers expect, and ensure
continuation.resume is called exactly once with the aggregated stdout (and
optionally include stderr in the returned string or log it) while catching
proc.run() errors and cleaning up handlers (update references to proc, pipe,
standardError, terminationHandler, and runProcess).

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/WebInspectorXPC.swift`:
- Around line 45-48: The current fragile heuristic using
bundlePath.contains("tauri") in WebInspectorXPC.swift incorrectly classifies
many apps as Tauri; remove this short-circuit and rely on the existing
WebKit-linkage probe (the otool -L check) for accurate detection, or if you must
keep a fast path, tighten it by resolving the app bundle's
Contents/MacOS/<executable> and checking that binary's linkage/symbols for known
Tauri/libwry identifiers before returning true (refer to bundleURL,
bundlePath.contains("tauri") and the WebKit-linkage probe logic to locate and
update the decision point).
- Around line 55-69: The isWKWebViewApp flow currently calls isLinkedToWebKit
(which runs runProcess("/usr/bin/otool", ...)) on every check, so add a static
cache (e.g., [URL: Bool] or [String: Bool] keyed by bundleURL or bundle
identifier) inside WebInspectorXPC to remember results per app; update
isWKWebViewApp to first consult this cache and return the cached Bool if
present, otherwise call isLinkedToWebKit, store the result in the cache, and
return it; ensure thread-safety around the cache (use a serial DispatchQueue or
lock) and keep references keyed by bundle/executable URL (or bundle id) to avoid
rerunning the expensive otool subprocess repeatedly.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/WebKitJS.swift`:
- Around line 9-11: Update the doc-comment to stop saying "near-instant" and
state the aggregate timeout (~1.5s for 3 ports at 0.5s each) and that probes can
be slow on macOS; then change the hot-path probe logic inside
WebKitJS.isAvailable() to avoid repeated full probes by caching a negative
result for the process lifetime (e.g. a static/cached Optional Bool used on
subsequent calls) and/or short-circuit early using platform or isWKWebViewApp
detection before performing the per-port timeouts so repeated page dispatches
don't incur the 1.5s cost.

In `@libs/cua-driver/Sources/CuaDriverCore/Permissions/Permissions.swift`:
- Around line 25-28: The doc comment for currentStatus() is stale—update it to
describe that the probe now uses the CGWindowList API with
excludingDesktopWindows(false, onScreenWindowsOnly: true) instead of
SCShareableContent.current; mention the rationale that this avoids the
multi-second hang caused by enumerating off-screen windows (per `#1371`) while
still accurately probing screen-recording permission, and adjust wording to note
the ~100–300ms probe cost if still applicable.

In `@libs/cua-driver/Tests/integration/test_browser_js.py`:
- Around line 283-284: The current fragile regex self.assertRegex(text,
r"elements.*turn", re.IGNORECASE) in test_browser_js.py is opaque and brittle;
replace it with an assertion that targets a stable AX tree marker emitted by
get_window_state (for example the fixed section header string that
get_window_state always includes, e.g., "AX Tree" or the exact header used in
get_window_state), and add a one-line comment above the assertion describing the
expected AX tree content and why that header is stable; update the assertion to
use self.assertIn(stable_header, text) or a precise regex matching that header
to make the test stable and readable.

In `@libs/cua-driver/Tests/integration/test_webkit_js.py`:
- Around line 178-183: Replace the brittle fixed sleep after calling
"launch_app" with a short polling loop that repeatedly calls _find_conductor_pid
until it returns a PID or a configurable timeout elapses; specifically, after
cls.client.call("launch_app", ...) remove time.sleep(3) and instead poll
_find_conductor_pid(cls.client) with a small interval (e.g. 0.1–0.5s) and a
total timeout (e.g. a few seconds) and set cls.pid when found, raising/asserting
a clear failure if the timeout elapses so the test fails fast on genuine launch
failures.
- Around line 137-148: The code incorrectly checks the parsed window_id with a
truthy test which treats 0 as missing; update the conditionals that check
window_id (both the outer check here and the inner loop in _get_window_id) to
use explicit "is not None" comparisons so 0 is treated as a valid CGWindowID and
the intent is clear; ensure you only call self.skipTest("No Chrome window
found") when window_id is None.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3a6e4ec6-1a5b-4de7-b8ff-02510f949e3f

📥 Commits

Reviewing files that changed from the base of the PR and between 66bed0d and ac0b79d.

📒 Files selected for processing (12)
  • libs/cua-driver/Skills/cua-driver/SKILL.md
  • libs/cua-driver/Sources/CuaDriverCore/Browser/AXPageReader.swift
  • libs/cua-driver/Sources/CuaDriverCore/Browser/BrowserJS.swift
  • libs/cua-driver/Sources/CuaDriverCore/Browser/CDPClient.swift
  • libs/cua-driver/Sources/CuaDriverCore/Browser/ElectronJS.swift
  • libs/cua-driver/Sources/CuaDriverCore/Browser/WebInspectorXPC.swift
  • libs/cua-driver/Sources/CuaDriverCore/Browser/WebKitJS.swift
  • libs/cua-driver/Sources/CuaDriverCore/Permissions/Permissions.swift
  • libs/cua-driver/Sources/CuaDriverServer/Tools/PageTool.swift
  • libs/cua-driver/Tests/integration/test_browser_js.py
  • libs/cua-driver/Tests/integration/test_webkit_js.py
  • libs/cua-driver/scripts/CuaDriver.entitlements

Comment on lines +773 to +775
```
get_window_state({pid, window_id, javascript: "document.title"})
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language to the fenced code block.

Static analysis (markdownlint MD040) flags the bare ``` opener at line 773. Use a language tag for syntax highlighting and lint compliance.

📝 Suggested fix
-```
+```text
 get_window_state({pid, window_id, javascript: "document.title"})

</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 773-773: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Skills/cua-driver/SKILL.md` around lines 773 - 775, The
fenced code block containing get_window_state({pid, window_id, javascript:
"document.title"}) is missing a language tag which triggers markdownlint MD040;
update the triple-backtick opener to include a language (e.g., text or
javascript) so it becomes ```text (or ```javascript) and keep the block content
unchanged to provide proper syntax highlighting and lint compliance in SKILL.md.

Comment on lines +221 to +305
public static func enableJavaScriptAppleEvents(bundleId: String) async throws {
guard let profilesDir = profilesDirectory(for: bundleId) else {
throw Error.unsupportedBrowser(bundleId)
}

// Terminate all running instances of the browser.
let running = await MainActor.run {
NSWorkspace.shared.runningApplications
.filter { $0.bundleIdentifier == bundleId }
}
for app in running { app.terminate() }

// Wait up to 5 s for the browser to quit (it writes Preferences on exit).
let deadline = Date().addingTimeInterval(5)
while Date() < deadline {
let still = await MainActor.run {
NSWorkspace.shared.runningApplications
.contains { $0.bundleIdentifier == bundleId }
}
if !still { break }
try await Task.sleep(nanoseconds: 200_000_000)
}

// Find all profile Preferences files and patch them.
let fm = FileManager.default
let profilesDirURL = URL(fileURLWithPath: profilesDir)
let profileDirs = (try? fm.contentsOfDirectory(
at: profilesDirURL, includingPropertiesForKeys: [.isDirectoryKey])) ?? []
var patched = 0
for dir in profileDirs {
let prefsURL = dir.appendingPathComponent("Preferences")
guard fm.fileExists(atPath: prefsURL.path),
let data = fm.contents(atPath: prefsURL.path),
var prefs = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { continue }

var browser = prefs["browser"] as? [String: Any] ?? [:]
browser["allow_javascript_apple_events"] = true
prefs["browser"] = browser

// Chrome syncs this key under account_values.browser as well.
if bundleId == "com.google.Chrome" {
var acct = prefs["account_values"] as? [String: Any] ?? [:]
var acctBrowser = acct["browser"] as? [String: Any] ?? [:]
acctBrowser["allow_javascript_apple_events"] = true
acct["browser"] = acctBrowser
prefs["account_values"] = acct
}

guard let newData = try? JSONSerialization.data(
withJSONObject: prefs, options: [.prettyPrinted, .sortedKeys])
else { continue }
try newData.write(to: prefsURL, options: .atomic)
patched += 1
}

if patched == 0 {
throw Error.enableFailed(
"No Preferences files found under \(profilesDirURL.path). "
+ "Ensure the browser has been launched at least once.")
}

// Relaunch the browser.
if let appURL = await MainActor.run(resultType: URL?.self, body: {
NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId)
}) {
let cfg = NSWorkspace.OpenConfiguration()
try await NSWorkspace.shared.openApplication(at: appURL, configuration: cfg)
}
}

/// Returns the directory that contains per-profile subdirectories for this browser.
private static func profilesDirectory(for bundleId: String) -> String? {
let home = FileManager.default.homeDirectoryForCurrentUser.path
switch bundleId {
case "com.google.Chrome":
return "\(home)/Library/Application Support/Google/Chrome"
case "com.brave.Browser":
return "\(home)/Library/Application Support/BraveSoftware/Brave-Browser"
case "com.microsoft.edgemac":
return "\(home)/Library/Application Support/Microsoft Edge"
default:
return nil
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Safari is supports() == true but enableJavaScriptAppleEvents always throws for it.

supports("com.apple.Safari") returns true (via safariSpec()), but profilesDirectory(for: "com.apple.Safari") returns nil, so enableJavaScriptAppleEvents immediately throws .unsupportedBrowser("com.apple.Safari"). Safari's "Allow JavaScript from Apple Events" lives under Develop menu / com.apple.Safari defaults (AllowJavaScriptFromAppleEvents), not in a Preferences JSON file.

This is a discoverability hazard for callers — the consent message in javascriptNotEnabledMessage instructs the agent to call enable_javascript_apple_events with the bundle id, which will fail unhelpfully for Safari.

Either implement the Safari enable path (defaults write + Develop menu requirement) or have enableJavaScriptAppleEvents throw a clearer Safari-specific error explaining the manual steps. At minimum document the limitation in the consent message when bundleId == "com.apple.Safari".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/BrowserJS.swift` around lines
221 - 305, The enableJavaScriptAppleEvents flow incorrectly treats Safari as
unsupported because profilesDirectory(for:) returns nil; update
enableJavaScriptAppleEvents to handle bundleId == "com.apple.Safari" specially
(or surface a clear Safari-specific error) by either: (A) implementing the
Safari path — run the appropriate defaults write for key
"AllowJavaScriptFromAppleEvents" and ensure the Develop menu requirement is
documented before returning, or (B) immediately throw a descriptive Error (e.g.
in enableJavaScriptAppleEvents when bundleId == "com.apple.Safari") that tells
callers Safari must be enabled via the Develop menu and the defaults write
command; also update javascriptNotEnabledMessage to include the Safari-specific
manual steps when bundleId == "com.apple.Safari". Use the symbols
enableJavaScriptAppleEvents(bundleId:), profilesDirectory(for:),
javascriptNotEnabledMessage and the Error enum to locate and implement the
change.

Comment on lines +226 to +242
// Terminate all running instances of the browser.
let running = await MainActor.run {
NSWorkspace.shared.runningApplications
.filter { $0.bundleIdentifier == bundleId }
}
for app in running { app.terminate() }

// Wait up to 5 s for the browser to quit (it writes Preferences on exit).
let deadline = Date().addingTimeInterval(5)
while Date() < deadline {
let still = await MainActor.run {
NSWorkspace.shared.runningApplications
.contains { $0.bundleIdentifier == bundleId }
}
if !still { break }
try await Task.sleep(nanoseconds: 200_000_000)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

5 s deadline-poll for browser quit can race Preferences write.

If the browser doesn't terminate within 5 s (e.g., a "Save unsaved tabs?" dialog blocks shutdown), the loop exits with the browser still running, then JSONSerialization.data overwrites Preferences while Chrome may also write to it on actual exit — last-writer-wins on a JSON file the browser cares about. Worst case Chrome corrupts the file or wipes the patched flag.

Worth either failing fast (throw .enableFailed(...)) when the browser is still running after the deadline, or extending the wait and force-killing remaining instances after a polite terminate().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/BrowserJS.swift` around lines
226 - 242, The current 5s polling loop using MainActor.run over
NSWorkspace.shared.runningApplications (filtering by bundleId) can exit while
the browser is still running and then proceed to JSONSerialization.data, leading
to a race; change the logic so that after calling app.terminate() you either (a)
if any apps still exist after the deadline, throw an enable-failed error (e.g.,
throw .enableFailed(...)) to fail fast, or (b) extend the wait and for any
remaining apps call app.forceTerminate() and then wait until
NSWorkspace.shared.runningApplications no longer contains bundleId before
proceeding to write Preferences; ensure you reference the same bundleId, use
app.terminate()/app.forceTerminate(), and only continue to
JSONSerialization.data once no runningApplications contain the bundleId.

Comment on lines +88 to +111
return try await withCheckedThrowingContinuation { continuation in
let ws = URLSession.shared.webSocketTask(with: wsURL)
ws.resume()
ws.send(.string(payloadStr)) { sendError in
if let err = sendError {
ws.cancel()
continuation.resume(throwing: Error.connectionFailed(err.localizedDescription))
return
}
ws.receive { result in
ws.cancel()
switch result {
case .failure(let err):
continuation.resume(throwing: Error.connectionFailed(err.localizedDescription))
case .success(let message):
guard case .string(let str) = message else {
continuation.resume(throwing: Error.connectionFailed("binary WebSocket frame"))
return
}
continuation.resume(with: Result { try Self.parseResult(str) })
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Does Chrome DevTools Protocol guarantee that command response frames precede unsolicited event frames on a fresh WebSocket connection?

💡 Result:

No, the Chrome DevTools Protocol does not guarantee that command response frames precede unsolicited event frames on a fresh WebSocket connection.

Citations:


ws.receive reads exactly one frame without filtering by id — risks matching the wrong message.

Chrome DevTools Protocol does not guarantee that command response frames precede unsolicited event frames on a fresh WebSocket connection. The current code sends one message and reads exactly one frame, then feeds it to parseResult without verifying obj["id"] == 1.

If a notification arrives before the response—or on a target that auto-emits lifecycle events—parseResult will either fail with a confusing error or return wrong data. While the practical impact is low here (since Runtime.enable is not called and no event subscriptions are active), the receive loop should filter by id for correctness and resilience to future changes.

♻️ Suggested fix: loop until id matches
-                ws.receive { result in
-                    ws.cancel()
-                    switch result {
-                    case .failure(let err):
-                        continuation.resume(throwing: Error.connectionFailed(err.localizedDescription))
-                    case .success(let message):
-                        guard case .string(let str) = message else {
-                            continuation.resume(throwing: Error.connectionFailed("binary WebSocket frame"))
-                            return
-                        }
-                        continuation.resume(with: Result { try Self.parseResult(str) })
-                    }
-                }
+                Self.receiveMatching(ws: ws, id: 1, continuation: continuation)

…with a helper that re-issues ws.receive while the incoming frame's id does not match, bounded by a timeout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/CDPClient.swift` around lines
88 - 111, The receive path may consume an unsolicited event frame because it
reads exactly one frame and passes it to parseResult; change CDPClient's
request/response flow to loop-reading ws.receive until a frame with a matching
"id" is found (or a bounded timeout occurs) instead of assuming the first frame
is the response. Implement a helper (e.g., receiveMatchingResponse(webSocket:
URLSessionWebSocketTask, expectedId: Int, timeout: TimeInterval)) that receives
frames, parses each string payload to JSON, inspects obj["id"] and returns the
string for the matching id (or throws Error.connectionFailed on timeout/parse
failure), then call parseResult only with that matched frame; ensure the
original continuation usage in withCheckedThrowingContinuation remains but uses
this helper instead of a single ws.receive.

Comment on lines +96 to +122
// Poll up to 2 s for a new LISTEN port to appear.
var inspectorPort: Int?
for _ in 0..<10 {
try await Task.sleep(for: .milliseconds(200))
let portsAfter = await listeningPorts(pid: pid)
let newPorts = portsAfter.subtracting(portsBefore)
for port in newPorts {
if await isInspectorPort(port) {
inspectorPort = port
break
}
}
if inspectorPort != nil { break }

// Fallback: scan the common Node inspector range in case lsof
// didn't resolve the pid association in time.
if let port = await scanInspectorPorts() {
inspectorPort = port
break
}
}

guard let port = inspectorPort else {
throw Error.inspectorNotAvailable(
"no CDP endpoint appeared on localhost within 2 s after SIGUSR1"
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Polling-loop fallback scan can blow past the documented 2 s budget.

scanInspectorPorts() is invoked on every iteration of the 10-step poll loop, regardless of whether lsof produced new ports. Each call walks 9229...9249 sequentially via CDPClient.isAvailable (0.5 s timeout each) — worst case 21 × 0.5 s = ~10.5 s per iteration when nothing answers, multiplied by up to 10 iterations. That contradicts the doc-comment claim of "Poll up to 2 s" and the error message "no CDP endpoint appeared on localhost within 2 s after SIGUSR1".

Consider running the fallback scan once after the polling loop fails, not on every iteration. The lsof-diff path is the primary signal; the scan is only a defensive backstop.

♻️ Suggested restructure
-        // Poll up to 2 s for a new LISTEN port to appear.
+        // Poll up to 2 s for a new LISTEN port to appear via lsof diff.
         var inspectorPort: Int?
         for _ in 0..<10 {
             try await Task.sleep(for: .milliseconds(200))
             let portsAfter = await listeningPorts(pid: pid)
             let newPorts = portsAfter.subtracting(portsBefore)
             for port in newPorts {
                 if await isInspectorPort(port) {
                     inspectorPort = port
                     break
                 }
             }
             if inspectorPort != nil { break }
-
-            // Fallback: scan the common Node inspector range in case lsof
-            // didn't resolve the pid association in time.
-            if let port = await scanInspectorPorts() {
-                inspectorPort = port
-                break
-            }
+        }
+
+        // Fallback: lsof may not have associated the new port to the pid in time.
+        if inspectorPort == nil {
+            inspectorPort = await scanInspectorPorts()
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Poll up to 2 s for a new LISTEN port to appear.
var inspectorPort: Int?
for _ in 0..<10 {
try await Task.sleep(for: .milliseconds(200))
let portsAfter = await listeningPorts(pid: pid)
let newPorts = portsAfter.subtracting(portsBefore)
for port in newPorts {
if await isInspectorPort(port) {
inspectorPort = port
break
}
}
if inspectorPort != nil { break }
// Fallback: scan the common Node inspector range in case lsof
// didn't resolve the pid association in time.
if let port = await scanInspectorPorts() {
inspectorPort = port
break
}
}
guard let port = inspectorPort else {
throw Error.inspectorNotAvailable(
"no CDP endpoint appeared on localhost within 2 s after SIGUSR1"
)
}
// Poll up to 2 s for a new LISTEN port to appear via lsof diff.
var inspectorPort: Int?
for _ in 0..<10 {
try await Task.sleep(for: .milliseconds(200))
let portsAfter = await listeningPorts(pid: pid)
let newPorts = portsAfter.subtracting(portsBefore)
for port in newPorts {
if await isInspectorPort(port) {
inspectorPort = port
break
}
}
if inspectorPort != nil { break }
}
// Fallback: lsof may not have associated the new port to the pid in time.
if inspectorPort == nil {
inspectorPort = await scanInspectorPorts()
}
guard let port = inspectorPort else {
throw Error.inspectorNotAvailable(
"no CDP endpoint appeared on localhost within 2 s after SIGUSR1"
)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Browser/ElectronJS.swift` around lines
96 - 122, The polling loop currently calls scanInspectorPorts() on every
iteration which can massively exceed the documented 2s budget; change the flow
so the loop only polls listeningPorts(pid:) and checks isInspectorPort(_:) for
newPorts for up to 10 iterations, and if inspectorPort is still nil after the
loop run scanInspectorPorts() exactly once as a final fallback (using its result
to set inspectorPort before the guard/throw). Update references: remove the
scanInspectorPorts() call from inside the for _ in 0..<10 loop and add a single
call to scanInspectorPorts() after the loop that assigns inspectorPort if
non-nil.

Comment on lines +165 to +231
case "get_text":
// For WKWebView/Tauri apps, skip JS injection and read the AX tree
// directly — avoids the entitlement-blocked inspector timeout.
if WebInspectorXPC.isWKWebViewApp(pid: pid) {
if let axText = await axGetText(pid: pid, windowId: windowId) {
return okResult("## Page text (via AX tree)\n\n\(axText)")
}
}
do {
let result = try await executeJS(
"document.body.innerText",
bundleId: bundleId, pid: pid, windowId: windowId)
return okResult(result)
} catch {
if let axText = await axGetText(pid: pid, windowId: windowId) {
return okResult("## Page text (via AX tree)\n\n\(axText)")
}
return errorResult("\(error)")
}

case "query_dom":
guard let selector = arguments?["css_selector"]?.stringValue,
!selector.isEmpty
else {
return errorResult(
"action=query_dom requires a non-empty css_selector field.")
}
// For WKWebView/Tauri apps, go straight to AX role query.
if WebInspectorXPC.isWKWebViewApp(pid: pid) {
if let axResult = await axQueryDom(
selector: selector, pid: pid, windowId: windowId) {
return okResult(
"## AX query: `\(selector)` (via accessibility tree)\n\n```json\n\(axResult)\n```")
}
}
let attrs: [String]
if let rawAttrs = arguments?["attributes"]?.arrayValue {
attrs = rawAttrs.compactMap { $0.stringValue }
} else {
attrs = []
}
let attrJS = attrs.isEmpty
? "[]"
: "[\(attrs.map { "\"\($0)\"" }.joined(separator: ", "))]"
let js = """
(() => {
const attrs = \(attrJS);
return JSON.stringify(
Array.from(document.querySelectorAll(\(jsonString(selector)))).map(el => {
const obj = { tag: el.tagName.toLowerCase(), text: el.innerText?.trim() };
for (const a of attrs) obj[a] = el.getAttribute(a);
return obj;
})
);
})()
"""
do {
let result = try await executeJS(js, bundleId: bundleId, pid: pid, windowId: windowId)
return okResult("## DOM query: `\(selector)`\n\n```json\n\(result)\n```")
} catch {
if let axResult = await axQueryDom(
selector: selector, pid: pid, windowId: windowId) {
return okResult(
"## AX query: `\(selector)` (via accessibility tree)\n\n```json\n\(axResult)\n```")
}
return errorResult("\(error)")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

WKWebView fall-through emits a misleading "execute_javascript not available" error for get_text/query_dom.

For a WKWebView/Tauri PID, when the AX helper returns nil (empty page extraction, or — for query_dom — zero matches because axQueryDom returns nil instead of an empty array), control falls through to executeJS which throws WKWebViewJSUnavailableError. The user invoked get_text / query_dom, but the surfaced error talks about execute_javascript and points them away from the very tool they just used.

Most impactful for query_dom: a selector with zero matches is a normal outcome that should produce an empty JSON array, not an error.

Two issues to fix:

  1. Don't fall through to JS for known-WKWebView apps — once the AX path is chosen it's the only viable backend.
  2. Disambiguate axQueryDom "snapshot failed" from "no matches" so the latter returns an empty array.
🛡️ Proposed fix
             case "get_text":
                 if WebInspectorXPC.isWKWebViewApp(pid: pid) {
-                    if let axText = await axGetText(pid: pid, windowId: windowId) {
-                        return okResult("## Page text (via AX tree)\n\n\(axText)")
-                    }
+                    let axText = await axGetText(pid: pid, windowId: windowId) ?? ""
+                    return okResult("## Page text (via AX tree)\n\n\(axText)")
                 }
                 ...

             case "query_dom":
                 ...
                 if WebInspectorXPC.isWKWebViewApp(pid: pid) {
-                    if let axResult = await axQueryDom(
-                        selector: selector, pid: pid, windowId: windowId) {
-                        return okResult(
-                            "## AX query: `\(selector)` (via accessibility tree)\n\n```json\n\(axResult)\n```")
-                    }
+                    let axResult = await axQueryDom(
+                        selector: selector, pid: pid, windowId: windowId) ?? "[]"
+                    return okResult(
+                        "## AX query: `\(selector)` (via accessibility tree)\n\n```json\n\(axResult)\n```")
                 }

…and have axQueryDom return "[]" (or distinguish nil snapshot vs. no matches) instead of nil when the snapshot succeeded but no elements matched.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case "get_text":
// For WKWebView/Tauri apps, skip JS injection and read the AX tree
// directly — avoids the entitlement-blocked inspector timeout.
if WebInspectorXPC.isWKWebViewApp(pid: pid) {
if let axText = await axGetText(pid: pid, windowId: windowId) {
return okResult("## Page text (via AX tree)\n\n\(axText)")
}
}
do {
let result = try await executeJS(
"document.body.innerText",
bundleId: bundleId, pid: pid, windowId: windowId)
return okResult(result)
} catch {
if let axText = await axGetText(pid: pid, windowId: windowId) {
return okResult("## Page text (via AX tree)\n\n\(axText)")
}
return errorResult("\(error)")
}
case "query_dom":
guard let selector = arguments?["css_selector"]?.stringValue,
!selector.isEmpty
else {
return errorResult(
"action=query_dom requires a non-empty css_selector field.")
}
// For WKWebView/Tauri apps, go straight to AX role query.
if WebInspectorXPC.isWKWebViewApp(pid: pid) {
if let axResult = await axQueryDom(
selector: selector, pid: pid, windowId: windowId) {
return okResult(
"## AX query: `\(selector)` (via accessibility tree)\n\n```json\n\(axResult)\n```")
}
}
let attrs: [String]
if let rawAttrs = arguments?["attributes"]?.arrayValue {
attrs = rawAttrs.compactMap { $0.stringValue }
} else {
attrs = []
}
let attrJS = attrs.isEmpty
? "[]"
: "[\(attrs.map { "\"\($0)\"" }.joined(separator: ", "))]"
let js = """
(() => {
const attrs = \(attrJS);
return JSON.stringify(
Array.from(document.querySelectorAll(\(jsonString(selector)))).map(el => {
const obj = { tag: el.tagName.toLowerCase(), text: el.innerText?.trim() };
for (const a of attrs) obj[a] = el.getAttribute(a);
return obj;
})
);
})()
"""
do {
let result = try await executeJS(js, bundleId: bundleId, pid: pid, windowId: windowId)
return okResult("## DOM query: `\(selector)`\n\n```json\n\(result)\n```")
} catch {
if let axResult = await axQueryDom(
selector: selector, pid: pid, windowId: windowId) {
return okResult(
"## AX query: `\(selector)` (via accessibility tree)\n\n```json\n\(axResult)\n```")
}
return errorResult("\(error)")
}
case "get_text":
// For WKWebView/Tauri apps, skip JS injection and read the AX tree
// directly — avoids the entitlement-blocked inspector timeout.
if WebInspectorXPC.isWKWebViewApp(pid: pid) {
let axText = await axGetText(pid: pid, windowId: windowId) ?? ""
return okResult("## Page text (via AX tree)\n\n\(axText)")
}
do {
let result = try await executeJS(
"document.body.innerText",
bundleId: bundleId, pid: pid, windowId: windowId)
return okResult(result)
} catch {
if let axText = await axGetText(pid: pid, windowId: windowId) {
return okResult("## Page text (via AX tree)\n\n\(axText)")
}
return errorResult("\(error)")
}
case "query_dom":
guard let selector = arguments?["css_selector"]?.stringValue,
!selector.isEmpty
else {
return errorResult(
"action=query_dom requires a non-empty css_selector field.")
}
// For WKWebView/Tauri apps, go straight to AX role query.
if WebInspectorXPC.isWKWebViewApp(pid: pid) {
let axResult = await axQueryDom(
selector: selector, pid: pid, windowId: windowId) ?? "[]"
return okResult(
"## AX query: `\(selector)` (via accessibility tree)\n\n
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverServer/Tools/PageTool.swift` around lines
165 - 231, For the WKWebView/Tauri code paths in the get_text and query_dom
cases, stop falling through to executeJS when
WebInspectorXPC.isWKWebViewApp(pid:) is true; if axGetText(...) or
axQueryDom(...) returns nil because the snapshot succeeded but had no matches,
treat that as a valid empty result (return okResult with "## Page text (via AX
tree)\n\n\(axText)" for get_text and for query_dom return okResult with an empty
JSON array e.g. "[]"); update the query_dom branch to coalesce axQueryDom(...)
to "[]" (or change axQueryDom to return "[]" when snapshot succeeded with zero
matches and only return nil for true snapshot failures) and ensure executeJS is
only attempted when not a WKWebView app.

Comment on lines +165 to +199
case "get_text":
// For WKWebView/Tauri apps, skip JS injection and read the AX tree
// directly — avoids the entitlement-blocked inspector timeout.
if WebInspectorXPC.isWKWebViewApp(pid: pid) {
if let axText = await axGetText(pid: pid, windowId: windowId) {
return okResult("## Page text (via AX tree)\n\n\(axText)")
}
}
do {
let result = try await executeJS(
"document.body.innerText",
bundleId: bundleId, pid: pid, windowId: windowId)
return okResult(result)
} catch {
if let axText = await axGetText(pid: pid, windowId: windowId) {
return okResult("## Page text (via AX tree)\n\n\(axText)")
}
return errorResult("\(error)")
}

case "query_dom":
guard let selector = arguments?["css_selector"]?.stringValue,
!selector.isEmpty
else {
return errorResult(
"action=query_dom requires a non-empty css_selector field.")
}
// For WKWebView/Tauri apps, go straight to AX role query.
if WebInspectorXPC.isWKWebViewApp(pid: pid) {
if let axResult = await axQueryDom(
selector: selector, pid: pid, windowId: windowId) {
return okResult(
"## AX query: `\(selector)` (via accessibility tree)\n\n```json\n\(axResult)\n```")
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate isLinkedToWebKit implementation and the full isWKWebViewApp flow.
fd -t f WebInspectorXPC.swift
ast-grep --pattern 'func isLinkedToWebKit($$$) { $$$ }'
ast-grep --pattern 'public static func isWKWebViewApp($$$) -> Bool { $$$ }'
# And confirm BrowserJS.supports list to identify candidates that should bypass the WKWebView gate.
ast-grep --pattern 'public static func supports(bundleId: String) -> Bool { $$$ }'

Repository: trycua/cua

Length of output: 3839


🏁 Script executed:

# Read the full WebInspectorXPC.swift file to see isLinkedToWebKit implementation
cat -n libs/cua-driver/Sources/CuaDriverCore/Browser/WebInspectorXPC.swift | head -100

Repository: trycua/cua

Length of output: 4315


🏁 Script executed:

# Check BrowserJS.swift for Safari handling and browserSpec
rg -A 20 "func browserSpec" libs/cua-driver/Sources/CuaDriverCore/Browser/BrowserJS.swift
rg "safari\|Safari" libs/cua-driver/Sources/CuaDriverCore/Browser/BrowserJS.swift -i

Repository: trycua/cua

Length of output: 948


🏁 Script executed:

# Check the execute_javascript case in PageTool.swift to see how it handles Safari
rg -B 5 -A 20 'case "execute_javascript"' libs/cua-driver/Sources/CuaDriverServer/Tools/PageTool.swift

Repository: trycua/cua

Length of output: 1307


🏁 Script executed:

# Also check if there's any Safari-specific logic elsewhere in PageTool
rg -i "safari\|com\.apple\.safari" libs/cua-driver/Sources/CuaDriverServer/Tools/PageTool.swift

Repository: trycua/cua

Length of output: 36


🏁 Script executed:

# Check the executeJS function to see if it validates against BrowserJS.supports
rg -B 5 -A 30 "func executeJS" libs/cua-driver/Sources/CuaDriverServer/Tools/PageTool.swift

Repository: trycua/cua

Length of output: 2040


🏁 Script executed:

# Also verify Safari's bundle ID is indeed com.apple.Safari
rg "com\.apple\.Safari" libs/cua-driver/Sources/CuaDriverCore/Browser/

Repository: trycua/cua

Length of output: 292


🏁 Script executed:

# Check if there's any comment or code that explicitly addresses Safari in get_text or query_dom
rg -B 5 -A 5 "get_text\|query_dom" libs/cua-driver/Sources/CuaDriverServer/Tools/PageTool.swift | grep -A 5 -B 5 -i "safari\|browserjs\|supports"

Repository: trycua/cua

Length of output: 36


🏁 Script executed:

# Verify Safari bundle ID matches what BrowserJS expects
rg "com\.apple\.Safari" libs/cua-driver/Sources/ -r

Repository: trycua/cua

Length of output: 468


🏁 Script executed:

# Search for Safari references in the codebase
rg "Safari" libs/cua-driver/Sources/CuaDriverCore/Browser/

Repository: trycua/cua

Length of output: 817


🏁 Script executed:

# Let me check what Safari's executable would report with otool
# Since we can't actually run otool, let's verify the logic more carefully.
# Check if there are any comments or documentation about Safari handling
rg -i "safari" libs/cua-driver/Sources/CuaDriverServer/Tools/PageTool.swift

Repository: trycua/cua

Length of output: 358


Safari will be incorrectly routed to the AX-tree fallback for get_text and query_dom.

Safari's executable is dynamically linked to WebKit (a system framework on macOS), so WebInspectorXPC.isWKWebViewApp(pid:) returns true at lines 168 and 193. Unlike execute_javascript which checks BrowserJS.supports(bundleId:) first, the get_text and query_dom cases evaluate the WKWebView gate before JS injection, causing Safari users to receive:

  • get_text: AX-tree extraction instead of document.body.innerText (different content and markdown header).
  • query_dom: { role, text, element_index } rows instead of { tag, text, …attrs } — breaking the documented schema that clients rely on.

Add a BrowserJS.supports(bundleId:) check ahead of the WKWebView gate (or exempt Safari from isWKWebViewApp) so Safari uses the JS path consistently across all three actions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverServer/Tools/PageTool.swift` around lines
165 - 199, The get_text and query_dom branches incorrectly route Safari to the
AX-tree fallback because they test WebInspectorXPC.isWKWebViewApp(pid:) before
checking JavaScript support; update both cases to first call
BrowserJS.supports(bundleId:) (same guard used by executeJS) and, only if JS is
unsupported, then fall back to WebInspectorXPC.isWKWebViewApp(pid:) and the
axGetText/axQueryDom paths; keep executeJS/axGetText/axQueryDom calls and error
handling unchanged so WKWebView/Tauri apps without JS support still use the
AX-tree fallback.

Comment on lines +200 to +220
let attrs: [String]
if let rawAttrs = arguments?["attributes"]?.arrayValue {
attrs = rawAttrs.compactMap { $0.stringValue }
} else {
attrs = []
}
let attrJS = attrs.isEmpty
? "[]"
: "[\(attrs.map { "\"\($0)\"" }.joined(separator: ", "))]"
let js = """
(() => {
const attrs = \(attrJS);
return JSON.stringify(
Array.from(document.querySelectorAll(\(jsonString(selector)))).map(el => {
const obj = { tag: el.tagName.toLowerCase(), text: el.innerText?.trim() };
for (const a of attrs) obj[a] = el.getAttribute(a);
return obj;
})
);
})()
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Attribute names are interpolated into JS without escaping.

selector is correctly escaped via jsonString on line 213, but attribute names on line 208 are wrapped in literal quotes only:

"[\(attrs.map { "\"\($0)\"" }.joined(separator: ", "))]"

Any attribute name containing ", \, or a newline breaks the generated JS (and, since attributes is caller-supplied via the MCP arguments, an LLM-driven prompt-injection could craft strings that change the meaning of the snippet). Run each attribute name through the same jsonString helper used for the selector.

🛡️ Proposed fix
-                let attrJS = attrs.isEmpty
-                    ? "[]"
-                    : "[\(attrs.map { "\"\($0)\"" }.joined(separator: ", "))]"
+                let attrJS = "[\(attrs.map { jsonString($0) }.joined(separator: ", "))]"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverServer/Tools/PageTool.swift` around lines
200 - 220, Attribute names in attrs are interpolated directly into attrJS
without escaping, which can break the generated JS or allow injection; update
the attrJS construction to run each attribute name through the existing
jsonString helper (the same way selector is escaped) before joining them so that
attrJS uses the safely-escaped values. Specifically, transform the attrs ->
attrJS mapping to use jsonString(...) for each element (referencing attrs,
attrJS and jsonString in PageTool.swift) so attribute names containing quotes,
backslashes, or newlines are properly escaped.

Comment on lines +69 to +79
try:
with open(prefs_path) as f:
data = json.load(f)
data.setdefault("browser", {})["allow_javascript_apple_events"] = True
data.setdefault("account_values", {}).setdefault("browser", {})[
"allow_javascript_apple_events"
] = True
with open(prefs_path, "w") as f:
json.dump(data, f)
except Exception as e:
print(f" skipped {profile}: {e}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Non-atomic Preferences write can corrupt Chrome state.

Opening prefs_path with mode "w" truncates the file before json.dump writes anything. If the test is interrupted (Ctrl-C, OOM, crash) between the open and a successful dump, the user's Chrome Preferences file is left empty or partial and the profile may fail to start.

Write to a temp file in the same directory and atomically rename:

🛡️ Suggested fix
-            with open(prefs_path, "w") as f:
-                json.dump(data, f)
+            tmp = prefs_path + ".tmp"
+            with open(tmp, "w", encoding="utf-8") as f:
+                json.dump(data, f, ensure_ascii=False)
+            os.replace(tmp, prefs_path)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Tests/integration/test_browser_js.py` around lines 69 - 79,
The prefs write is non-atomic and can corrupt Chrome if interrupted; update the
block that opens prefs_path to write to a temp file in the same directory (use
tempfile.NamedTemporaryFile or create prefs_path + ".tmp" in
os.path.dirname(prefs_path)), json.dump the data to that temp file, fsync and
close it, then atomically replace the original with os.replace(temp_path,
prefs_path); ensure you only replace after successful dump and consider
preserving permissions if needed.

Comment on lines +242 to +277
@unittest.skipUnless(_conductor_installed(), "Conductor not installed")
def test_query_dom_buttons_via_ax_tree(self):
"""query_dom(button) on Conductor returns AX buttons."""
window_id = self._get_window_id()
if window_id is None:
self.skipTest("Conductor window not found")
result = self.client.call("page", {
"pid": self.pid,
"window_id": window_id,
"action": "query_dom",
"css_selector": "button",
})
text = _tool_text(result)
# Either returns buttons or returns a well-formed error — not a crash.
self.assertFalse(
"Traceback" in text or "fatal" in text.lower(),
f"Unexpected crash output: {text}"
)

@unittest.skipUnless(_conductor_installed(), "Conductor not installed")
def test_query_dom_links_via_ax_tree(self):
"""query_dom(a) on Conductor returns AX links."""
window_id = self._get_window_id()
if window_id is None:
self.skipTest("Conductor window not found")
result = self.client.call("page", {
"pid": self.pid,
"window_id": window_id,
"action": "query_dom",
"css_selector": "a",
})
text = _tool_text(result)
self.assertFalse(
"Traceback" in text or "fatal" in text.lower(),
f"Unexpected crash output: {text}"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

query_dom tests don't actually verify AX-fallback returned any data.

Both test_query_dom_buttons_via_ax_tree and test_query_dom_links_via_ax_tree only assert that the output does not contain "Traceback" or "fatal" — they pass if the tool returns an empty result, an error message, or anything else non-crashy. Per the docstring ("returns AX buttons") the intent looks stronger than the actual coverage.

If the AX fallback regresses to silently returning nothing, these tests still pass. Consider asserting _is_error(result) is False plus a non-empty payload, with a skipTest escape if Conductor genuinely renders no buttons/links on first launch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Tests/integration/test_webkit_js.py` around lines 242 - 277,
The tests test_query_dom_buttons_via_ax_tree and
test_query_dom_links_via_ax_tree only assert the output is not a crash but don't
verify the AX-fallback actually returned data; change each test to assert the
call result is not an error (use _is_error(result) is False) and that the
payload contains at least one element (non-empty list/field returned by the
query_dom response), and if the page legitimately has no matching elements on
first launch then call self.skipTest with an explanatory message; locate the
checks inside those two test methods (they already build result via
self.client.call("page", {...}) and compute text = _tool_text(result)) and add
the _is_error and non-empty payload assertions immediately after obtaining
result/text, keeping the existing crash assertions as secondary safeguards.

f-trycua and others added 4 commits April 26, 2026 00:48
- AXPageReader: extract page text and query DOM from WKWebView AX tree,
  used as JS-free backend for Tauri apps where the inspector is blocked
- WebInspectorXPC: detect WKWebView apps; stub Mach IPC client for future
  use once the private entitlement becomes available
- CDPClient: shared CDP HTTP+WebSocket eval extracted from ElectronJS
- WebKitJS: probe GTK/WPE WebKit TCP inspector ports (Linux/fallback path)
- ElectronJS: refactored to delegate to CDPClient
- PageTool: route get_text/query_dom through AX tree for WKWebView apps;
  execute_javascript returns a clear error with AX-based alternatives
- LaunchAppTool: add webkit_inspector_port and electron_debugging_port params
- Permissions: fix SCShareableContent.current → excludingDesktopWindows
  (avoids 6s hang on machines with many ghost windows, fixes #1371)
- test_webkit_js: new integration tests for WKWebView/Tauri AX path
- test_browser_js: fix timing (4s wait for page load) and fix
  isWKWebViewApp false-positive for Chrome

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AXPageReader.extractText: add AXWebArea to text-bearing roles; use
  description as tertiary fallback (title → value → description) since
  WebKit/Tauri AX trees often store text content in AXDescription.

- PageTool.get_text / query_dom: check BrowserJS.supports() before
  routing to AX fallback so Safari (which links WebKit.framework) is
  NOT misidentified as a WKWebView app and incorrectly given AX output
  instead of document.body.innerText / querySelectorAll results.

- PageTool.axGetText: fall back to raw treeMarkdown when extractText
  finds nothing, so WKWebView apps with limited AX exposure still return
  useful content instead of dropping through to executeJS (which always
  fails for WKWebView apps).

- PageTool.query_dom: use jsonString() to escape attribute names before
  injecting them into the generated JS array literal, preventing XSS via
  malformed attribute values like `data-foo"; alert(1);//`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ebView features

- mcp-tools.mdx: add 'page' tool section (Browser category) documenting
  execute_javascript, get_text, query_dom, and enable_javascript_apple_events
  actions including the AX fallback for WKWebView/Tauri apps.

- mcp-tools.mdx: update launch_app docs to include creates_new_application_instance
  and additional_arguments parameters added in PR 1388.

- installation.mdx: mark the LaunchAgent uninstall step as legacy (≤ v0.0.5)
  since the auto-updater LaunchAgent was removed in PR 1388.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A single ws.receive assumed the next WebSocket frame was the response to
the request. Chrome DevTools Protocol sends unsolicited event frames
(Runtime.executionContextCreated, etc.) that arrive before responses,
causing hangs or wrong-frame parses.

Replace with a receive loop that discards frames with a "method" key
(CDP events) and only resumes when a frame with a matching "id" arrives.
Also adds a 10-second timeout to prevent indefinite hangs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@f-trycua
f-trycua force-pushed the feat/cua-driver-wkwebview-tauri-ax branch from 7899e32 to f01e89f Compare April 26, 2026 07:48
@f-trycua
f-trycua merged commit 287bb61 into main Apr 26, 2026
1 check was pending
@f-trycua
f-trycua deleted the feat/cua-driver-wkwebview-tauri-ax branch April 26, 2026 07:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance regression with using SCShareableContent.current

1 participant