diff --git a/.github/workflows/ci-cua-driver-contract-clients.yml b/.github/workflows/ci-cua-driver-contract-clients.yml index f8aeb8b23c..da983d99f7 100644 --- a/.github/workflows/ci-cua-driver-contract-clients.yml +++ b/.github/workflows/ci-cua-driver-contract-clients.yml @@ -76,6 +76,14 @@ jobs: - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 with: workspaces: "libs/cua-driver/rust -> target" + - name: Record Windows runner session + if: runner.os == 'Windows' + id: windows-session + shell: pwsh + run: | + $sessionId = (Get-Process -Id $PID).SessionId + "session_id=$sessionId" >> $env:GITHUB_OUTPUT + "Windows runner process session id: $sessionId" - name: Install Linux build dependencies if: runner.os == 'Linux' run: | @@ -84,10 +92,12 @@ jobs: clang pkg-config libdbus-1-dev libpipewire-0.3-dev libspa-0.2-dev \ libei-dev libxkbcommon-dev libx11-dev libxi-dev libxtst-dev libxext-dev - name: Prove portable contracts match the live registry + if: runner.os != 'Windows' || steps.windows-session.outputs.session_id != '0' working-directory: libs/cua-driver/rust run: cargo test --locked -p cua-driver --test schema_consistency_test portable_desktop_contracts_are_accepted_by_active_backend - name: Verify released CLI and MCP compatibility + if: runner.os != 'Windows' || steps.windows-session.outputs.session_id != '0' working-directory: libs/cua-driver/rust run: cargo test --locked -p cua-driver --test compatibility_contract_test @@ -95,10 +105,34 @@ jobs: working-directory: libs/cua-driver/rust run: cargo check --locked -p cua-driver-sdk + - name: Compile the frozen previous-release Rust application + run: cargo check --manifest-path libs/cua-driver/compat-fixtures/apps/rust/Cargo.toml + + - name: Run the frozen previous-release Rust application + if: runner.os == 'Linux' + run: cargo run --manifest-path libs/cua-driver/compat-fixtures/apps/rust/Cargo.toml + - name: Prove the embedded host serves SDK and MCP clients + if: runner.os != 'Windows' || steps.windows-session.outputs.session_id != '0' working-directory: libs/cua-driver/rust run: cargo test --locked -p cua-driver --test embedded_host_sdk_mcp_test + - name: Prove Windows Session 0 refusal and desktop-free metadata + if: runner.os == 'Windows' && steps.windows-session.outputs.session_id == '0' + working-directory: libs/cua-driver/rust + shell: pwsh + run: | + cargo test --locked -p platform-windows diagnostics::tests:: --lib + if ($LASTEXITCODE -ne 0) { throw "Windows diagnostics tests failed with exit $LASTEXITCODE" } + cargo test --locked -p cua-driver-sdk session_zero_refuses_runtime_creation_before_platform_dispatch --lib + if ($LASTEXITCODE -ne 0) { throw "Session 0 refusal test failed with exit $LASTEXITCODE" } + cargo run --locked -p cua-driver -- list-tools | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Session 0 list-tools failed with exit $LASTEXITCODE" } + cargo run --locked -p cua-driver -- describe click | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Session 0 describe failed with exit $LASTEXITCODE" } + cargo run --locked -p cua-driver -- dump-docs --type cli | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Session 0 dump-docs failed with exit $LASTEXITCODE" } + verify: name: Generated contract and SDK bindings runs-on: ubuntu-latest @@ -215,8 +249,10 @@ jobs: python -m build --wheel --outdir /tmp/cua-driver-wheels python unzip -l /tmp/cua-driver-wheels/*.whl | grep -F 'cua_driver/libcua_driver_sdk.so' python -m pip install --target /tmp/cua-driver-wheel-smoke /tmp/cua-driver-wheels/*.whl + PYTHONPATH=/tmp/cua-driver-wheel-smoke python \ + compat-fixtures/apps/python/app.py PYTHONPATH=/tmp/cua-driver-wheel-smoke python -c \ - "from cua_driver import CuaDriver, EmbeddedCuaDriverHost; print(CuaDriver.connect(None).socket_path(), EmbeddedCuaDriverHost)" + "from cua_driver import EmbeddedCuaDriverHost; print(EmbeddedCuaDriverHost)" - name: Verify Node native package contents working-directory: libs/cua-driver/typescript @@ -243,8 +279,10 @@ jobs: "/tmp/cua-driver-npm/trycua-cua-driver-$VERSION.tgz" \ "/tmp/cua-driver-npm/trycua-cua-driver-linux-x64-gnu-$VERSION.tgz" cd /tmp/cua-driver-npm/smoke + cp "$GITHUB_WORKSPACE/libs/cua-driver/compat-fixtures/apps/typescript/app.mjs" . + node app.mjs node --input-type=module -e \ - "const sdk = await import('@trycua/cua-driver'); const driver = sdk.CuaDriver.connect(undefined); console.log(driver.socketPath()); driver.uniffiDestroy(); const embedded = await import('@trycua/cua-driver/embedded'); if (embedded.EmbeddedCuaDriverHost !== sdk.EmbeddedCuaDriverHost) process.exit(1)" + "const sdk = await import('@trycua/cua-driver'); const embedded = await import('@trycua/cua-driver/embedded'); if (embedded.EmbeddedCuaDriverHost !== sdk.EmbeddedCuaDriverHost) process.exit(1)" - name: Install Python agent SDK example dependencies working-directory: libs/cua-driver/examples/agent-sdks diff --git a/.github/workflows/ci-rust-windows.yml b/.github/workflows/ci-rust-windows.yml index eb91cd14b1..44beb7457a 100644 --- a/.github/workflows/ci-rust-windows.yml +++ b/.github/workflows/ci-rust-windows.yml @@ -52,6 +52,13 @@ jobs: - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 with: workspaces: "libs/cua-driver/rust -> target" + - name: Record Windows runner session + id: runner-session + shell: pwsh + run: | + $sessionId = (Get-Process -Id $PID).SessionId + "session_id=$sessionId" >> $env:GITHUB_OUTPUT + "Windows runner process session id: $sessionId" - name: Run Windows Rust tests working-directory: libs/cua-driver/rust # Compile every Rust target without executing desktop-dependent integration @@ -61,11 +68,41 @@ jobs: working-directory: libs/cua-driver/rust run: | cargo test -p cua-driver-core session_authorization::tests:: --lib --locked - cargo test -p cua-driver-sdk --lib --locked + if ($LASTEXITCODE -ne 0) { throw "session authorization tests failed with exit $LASTEXITCODE" } + cargo test -p cua-driver-sdk abi::tests:: --lib --locked + if ($LASTEXITCODE -ne 0) { throw "SDK ABI tests failed with exit $LASTEXITCODE" } + cargo test -p cua-driver-sdk remote_ --lib --locked + if ($LASTEXITCODE -ne 0) { throw "remote SDK tests failed with exit $LASTEXITCODE" } cargo test -p cua-driver --bin cua-driver named_pipe_authentication_tests:: --locked + if ($LASTEXITCODE -ne 0) { throw "named-pipe authentication tests failed with exit $LASTEXITCODE" } + # The production binary embeds uiAccess=true and Windows correctly + # refuses to launch it unelevated. Run the same pure security policy + # from the manifest-free library test target. + cargo test -p cua-driver-uia --lib authorization_tests:: --locked + if ($LASTEXITCODE -ne 0) { throw "UIAccess authorization tests failed with exit $LASTEXITCODE" } + cargo test -p platform-windows diagnostics::tests:: --lib --locked + if ($LASTEXITCODE -ne 0) { throw "Windows desktop classification tests failed with exit $LASTEXITCODE" } + - name: Run full SDK unit suite in an interactive runner session + if: steps.runner-session.outputs.session_id != '0' + working-directory: libs/cua-driver/rust + run: cargo test -p cua-driver-sdk --lib --locked -- --test-threads=1 + - name: Prove Session 0 refusal + if: steps.runner-session.outputs.session_id == '0' + working-directory: libs/cua-driver/rust + run: cargo test -p cua-driver-sdk session_zero_refuses_runtime_creation_before_platform_dispatch --lib --locked + - name: Prove metadata commands remain desktop-free + working-directory: libs/cua-driver/rust + run: | + cargo run -p cua-driver --locked -- list-tools | Out-Null + if ($LASTEXITCODE -ne 0) { throw "list-tools failed with exit $LASTEXITCODE" } + cargo run -p cua-driver --locked -- describe click | Out-Null + if ($LASTEXITCODE -ne 0) { throw "describe failed with exit $LASTEXITCODE" } + cargo run -p cua-driver --locked -- dump-docs --type cli | Out-Null + if ($LASTEXITCODE -ne 0) { throw "dump-docs failed with exit $LASTEXITCODE" } - name: Run Windows browser platform unit tests working-directory: libs/cua-driver/rust - run: 'cargo test -p platform-windows browser_platform::tests:: --lib --locked' + run: "cargo test -p platform-windows browser_platform::tests:: --lib --locked" - name: Run Windows protocol schema contract + if: steps.runner-session.outputs.session_id != '0' working-directory: libs/cua-driver/rust run: cargo test -p cua-driver --test protocol_schema_test --locked diff --git a/.github/workflows/e2e-rust-windows.yml b/.github/workflows/e2e-rust-windows.yml index 05a2479d75..7e8d668eaf 100644 --- a/.github/workflows/e2e-rust-windows.yml +++ b/.github/workflows/e2e-rust-windows.yml @@ -178,6 +178,9 @@ jobs: with: ref: ${{ needs.source.outputs.sha }} - uses: dtolnay/rust-toolchain@stable + - name: Verify installer runner is interactive + shell: pwsh + run: .\scripts\ci\windows\verify-user-session.ps1 - name: Install into an isolated local namespace shell: pwsh env: diff --git a/docs/content/docs/concepts/browser-targeting-and-background-delivery.mdx b/docs/content/docs/concepts/browser-targeting-and-background-delivery.mdx index d87efa7433..1a21b57853 100644 --- a/docs/content/docs/concepts/browser-targeting-and-background-delivery.mdx +++ b/docs/content/docs/concepts/browser-targeting-and-background-delivery.mdx @@ -100,6 +100,20 @@ files, restarts, or terminates the selected process, and it reports its temporary-tab and setting effects. Remote-debugging arguments passed through `launch_app` are refused. +Current macOS Chrome can expose the native address field and selected internal +tab while withholding that page's web AX subtree. In that case, the macOS +adapter uses only the temporary tab that it created and navigated to the fixed +internal URL, requires the committed address value and expected selected-tab +title with no active omnibox edit, then requires one unique checkbox-shaped +control inside a bounded setup-page region. The click is PID-routed to the +revalidated unchanged browser window and the same control's state transition is +verified. Because macOS delivers that bounded pixel action through global input, +the driver may briefly foreground the exact approved window, then restore the +previous frontmost app. The result reports both the foreground and global-input +effects. Unsupported appearance, scale, zoom, window-size, or toolbar geometry +is refused without a click. It does not generalize that fallback to web pages or +arbitrary dialogs. + The setup transition and protocol attachment are separate proofs. A listener must be loopback-only, attributed to the approved pid, and either discoverable as DevTools or correlated with the exact approved checkbox transition. The diff --git a/docs/content/docs/concepts/how-permission-policies-work.mdx b/docs/content/docs/concepts/how-permission-policies-work.mdx index 8a42964105..ab8d915d29 100644 --- a/docs/content/docs/concepts/how-permission-policies-work.mdx +++ b/docs/content/docs/concepts/how-permission-policies-work.mdx @@ -5,13 +5,19 @@ description: How the Cua Driver permission policy engine evaluates YAML and Rego import { Callout } from 'fumadocs-ui/components/callout'; -Cua Driver's permission policy engine sits between daemon clients (MCP, CLI, or direct socket clients) and the tool implementation. Before the daemon executes any tool call it asks the policy engine whether the call is allowed. This page explains how the engine is structured, when it is active, and what guarantees it provides. +Cua Driver's permission policy engine sits at the native runtime dispatch +boundary, between every public caller and the tool implementation. Before a +direct SDK runtime, private worker, MCP process, or daemon executes a tool +call, it asks the same policy engine whether the call is allowed. ## The enforcement point -Every tool call reaches a running `cua-driver serve` daemon. A thin `cua-driver mcp` process proxies MCP calls over the local socket; `cua-driver call` sends its one request to the same daemon. - -The authorization coordinator is always invoked in the daemon before tool execution. It evaluates the built-in risk map, managed policy, user policy, and autonomous session manifest in order. The MCP proxy may repeat the user-policy check as an earlier defense-in-depth check. A denial at either point returns an error to the client; the tool implementation is never reached. +Every public path reaches the authorization coordinator before platform +dispatch. It evaluates the built-in risk map, managed policy, user policy, and +bounded session manifest in order. Adapters may repeat a check earlier as +defense in depth, but they cannot authorize a request the runtime denies. A +denial returns an error to the client and the tool implementation is never +reached. ## Deny-by-default @@ -19,19 +25,31 @@ The engine is deny-by-default. A tool that is not explicitly mentioned in the po The deny-by-default behavior applies within each configured policy. When `CUA_DRIVER_POLICY_FILE` is unset, that layer is absent for compatibility. The reviewed built-in tool and risk map still rejects unknown tools, and the default permission mode remains `standard`. -An explicitly configured policy path is an operator assertion that the layer must exist. If the path is missing, unreadable, empty, or invalid, the daemon exits before binding its action socket. +An explicitly configured policy path is an operator assertion that the layer +must exist. If the path is missing, unreadable, empty, or invalid, runtime +construction fails before tools are registered or a service binds its action +endpoint. ## Policy composition and modes -`CUA_DRIVER_MANAGED_POLICY_FILE` loads an administrator ceiling in the same YAML or Rego formats. A call must pass both the managed and user layers. The daemon hashes each immutable policy snapshot and includes those hashes in protected approval requests and status output. +`CUA_DRIVER_MANAGED_POLICY_FILE` loads an administrator ceiling in the same +YAML or Rego formats. A call must pass both the managed and user layers. The +runtime hashes each immutable policy snapshot and includes those hashes in +protected approval requests and status output. Permission mode is separate from capability policy. Policy answers whether a call is inside the allowed ceiling. Mode answers whether an allowed operation must stop for protected human approval. Approval and unrestricted mode cannot widen the policy ceiling. See [Permission modes and bounded autonomy](/reference/cua-driver/permission-modes). -## Process-lifetime snapshot +## Runtime-lifetime snapshot -The policy file is loaded once when the daemon starts. All subsequent calls share the same immutable policy object for the lifetime of that process. There is no reload endpoint and no hot-swap path. Changing the policy takes effect only after the daemon restarts. +The policy file is loaded once when the runtime starts. All subsequent calls +through that runtime generation share the same immutable policy object. There +is no reload endpoint and no hot-swap path. Changing the policy takes effect +only after a direct runtime, private worker, MCP process, or daemon is +restarted. -This makes the policy a reliable static contract: the same rule that was in effect when the daemon started will still be in effect when the last tool call of the session runs. +This makes the policy a reliable static contract: the same rule that was in +effect when the runtime started remains in effect through its last admitted +call. ## YAML evaluation @@ -62,13 +80,16 @@ At evaluation time, the driver: 3. Sets the input and evaluates `data.cua.policy.allow`. 4. Maps the result: `true` → Allow, `false` or `undefined` → Deny, error → Error. -Because Regorus runs inside the Cua Driver daemon and does not spawn a policy subprocess, there is no additional policy IPC per call. +Because Regorus runs inside the runtime owner and does not spawn a policy +subprocess, there is no additional policy IPC per call. ## Argument sanitization before evaluation Two transformations happen before the arguments reach the policy engine: -- **`_session_id` removal.** The daemon injects a `_session_id` field for session tracking. This is an internal implementation detail that is not relevant to policy decisions, so it is stripped before evaluation. +- **Internal session-field removal.** Runtime and transport adapters may inject + reserved session fields for lifecycle tracking. These are stripped before + evaluation so a caller-controlled label cannot change policy authority. - **Tool name canonicalization.** The deprecated `type_text_chars` alias is normalized to `type_text` before any rule is consulted, so policies written against the canonical name cover both forms automatically. ## What the engine does not cover @@ -84,13 +105,20 @@ A policy that allows `screenshot` permits an agent to take an unlimited number o ## Trust model -The policy is evaluated in the same process as the tool implementation. An agent that can replace or inject code into the daemon can bypass it. Local shell access alone does not turn ordinary MCP arguments, files, TTY input, or environment values into protected consent. Use a trusted launcher to own daemon startup settings and OS isolation to keep the agent from replacing the daemon. +The policy is evaluated in the same process as the tool implementation. An +agent that can replace or inject code into the runtime-owning process can +bypass it. Local shell access alone does not turn ordinary MCP arguments, +files, TTY input, or environment values into protected consent. Use a trusted +host to construct direct runtimes, or a trusted launcher and OS isolation for +workers and services. -For remote agents connecting through the daemon's network socket, the policy provides a meaningful boundary: the daemon will not execute a tool that the policy blocks, regardless of what the agent sends. +For remote agents connecting through an authenticated service, the policy +provides a meaningful boundary: the service runtime will not execute a tool +that the policy blocks, regardless of what the agent sends. ## Related - [Restrict tool access with permission policies](/how-to-guides/driver/restrict-tool-access): step-by-step setup guide - [Permission policies](/reference/cua-driver/permission-policies): YAML schema and Rego input interface - [Permission modes and bounded autonomy](/reference/cua-driver/permission-modes): how mode, consent grants, and policy layers compose -- [Process model](/reference/cua-driver/process-model): how CLI and MCP clients reach the daemon +- [Process model](/reference/cua-driver/process-model): direct, worker, MCP, and service ownership diff --git a/docs/content/docs/concepts/sdk-mcp-and-hosting.mdx b/docs/content/docs/concepts/sdk-mcp-and-hosting.mdx index e14365145b..fc63fdefb4 100644 --- a/docs/content/docs/concepts/sdk-mcp-and-hosting.mdx +++ b/docs/content/docs/concepts/sdk-mcp-and-hosting.mdx @@ -19,7 +19,7 @@ These dimensions form a grid: | Topology | Typed SDK | MCP / CLI | | --- | --- | --- | -| Same process | **Primary for client applications** | Default stdio MCP owner on Windows and Linux | +| Same process | **Primary for applications embedding Cua Driver** | Default stdio MCP owner on Windows and Linux; explicit `--direct` on macOS | | Private worker | `create_private_worker()` / `createPrivateWorker()` for per-host process isolation | Not reconnectable and not advertised as an MCP endpoint | | Daemon | Compatibility or app-shared runtime | Standalone macOS default and explicit `--socket` service mode | @@ -88,6 +88,12 @@ running default service on Windows or Linux. Use `cua-driver mcp --socket ` when an agent must share that service's sessions and resources. The explicit connection path preserves the released daemon protocol. +Standalone macOS keeps the opposite default because `CuaDriver.app` owns its +stable TCC identity. `cua-driver mcp --direct` is the explicit opt-in for a +host that wants the MCP process itself to own the runtime and accepts the +spawning application's TCC attribution. It cannot be combined with +`--socket`. + Private workers use the same generated SDK contract but exchange versioned request/response envelopes over child stdin/stdout. The host supplies the authorization ceiling before readiness, owns the only channel, and terminates @@ -98,7 +104,9 @@ daemon socket. Remote carriers use the same transport-free Rust envelope seam. The carrier must authenticate a principal, bind a connection generation, preserve request -IDs and deadlines, and return a separately bound channel for trusted sessions. +IDs and deadlines, negotiate a compatible envelope version and cancellation +support before dispatch, forward cancellation when an action future is +dropped, and return a separately bound channel for trusted sessions. The Cua Driver core does not depend on gRPC, HTTP/2, or another carrier, and no generated Python/TypeScript remote constructor is shipped yet. @@ -135,6 +143,14 @@ A second direct `CuaDriver.create()` returns the structured runtime-owner processes. Complete same-process multi-runtime support remains a later isolation gate; it cannot be enabled merely by removing the guard. +On macOS, the cursor overlay additionally requires an AppKit main-thread UI +owner. A direct runtime without a certified host adapter returns structured +`facility_unavailable` results for cursor-overlay operations; it does not +report success or start hidden AppKit work on an unsafe thread. A private +worker or service owns the required event loop. A headless service or worker +without Window Server graphic-session access returns the same refusal instead +of claiming an overlay that cannot render. + ## Service transport hardening The optional loopback HTTP MCP listener is disabled unless diff --git a/docs/content/docs/how-to-guides/driver/drive-a-web-page.mdx b/docs/content/docs/how-to-guides/driver/drive-a-web-page.mdx index b4d60c0d54..93222ae8fa 100644 --- a/docs/content/docs/how-to-guides/driver/drive-a-web-page.mdx +++ b/docs/content/docs/how-to-guides/driver/drive-a-web-page.mdx @@ -130,6 +130,19 @@ the structured refusal reports `detail.setup_side_effects`; a checkbox changed by that failed attempt is restored when its exact state can still be proven. Any ambiguous control or changed process/window identity is refused. +On macOS, semantic AX matching remains the first route. If Chrome withholds the +internal page's web AX subtree, Cua Driver opens and navigates its own temporary +tab, waits for the fixed address to be committed with the expected selected-tab +title and no omnibox edit in progress, then requires one unique +checkbox-shaped control in a bounded setup-page region. It revalidates the +unchanged target window, routes the click only to that browser PID, and verifies +the resulting state on the same control. Because macOS delivers this bounded +pixel action through global input, Cua Driver may briefly foreground the exact +approved window, restore the previous frontmost app, and report those effects in +`side_effects`. Unsupported appearance, scale, zoom, window-size, or toolbar +geometry is refused without a click. This fallback never applies to ordinary +web pages or generic security dialogs. + On Linux, launch the browser with `--force-renderer-accessibility` unless a screen reader already enables its complete AT-SPI tree. Native Wayland also requires a validated compositor route that can prove the exact process, diff --git a/docs/content/docs/how-to-guides/driver/use-sdk-in-process.mdx b/docs/content/docs/how-to-guides/driver/use-sdk-in-process.mdx index 2585378417..7d4b20a264 100644 --- a/docs/content/docs/how-to-guides/driver/use-sdk-in-process.mdx +++ b/docs/content/docs/how-to-guides/driver/use-sdk-in-process.mdx @@ -9,6 +9,13 @@ Use the same-process SDK when your application owns the desktop-control lifecycle. `CuaDriver.create()` loads the Rust runtime into the importing process; it does not launch `cua-driver serve` or use IPC. +The importing application owns OS permissions and permission UX. On macOS, +direct `check_permissions` calls are read-only even when `prompt` is requested; +relaunch the responsible host after changing TCC grants. Direct macOS runtimes +also return `facility_unavailable` for agent-cursor overlay operations unless +the host installs a certified AppKit main-thread adapter. Use a private worker +or explicit service when the overlay is required. + ## Install the SDK diff --git a/docs/content/docs/how-to-guides/driver/windows-ssh.mdx b/docs/content/docs/how-to-guides/driver/windows-ssh.mdx index c239e731b5..57befe06bb 100644 --- a/docs/content/docs/how-to-guides/driver/windows-ssh.mdx +++ b/docs/content/docs/how-to-guides/driver/windows-ssh.mdx @@ -30,7 +30,13 @@ cua-driver call list_windows ## The solution -Run a `cua-driver serve` daemon in your **interactive session**, Session 1 or higher, through an autostart Scheduled Task. The CLI running over SSH detects that daemon and proxies tool calls through its named pipe. The SSH process only moves protocol messages; the daemon performs the actual GUI work from a session with a desktop attached. +Run a `cua-driver serve` daemon in your **interactive session**, Session 1 or +higher, through an autostart Scheduled Task. The SSH-side CLI or MCP adapter +must use that daemon. MCP selects it explicitly with +`--socket \\.\pipe\cua-driver`; `cua-driver call` already resolves that default +pipe and accepts `--socket` when you want to make the endpoint explicit. The +SSH process only moves protocol messages; the daemon performs the actual GUI +work from a session with a desktop attached. ``` ┌───────────────────────────────────────────────────────────────┐ @@ -87,7 +93,9 @@ cua-driver status **3. Call tools from SSH:** ```powershell -cua-driver call list_apps # sees real GUI apps +cua-driver call list_apps +# Equivalent explicit form: +cua-driver call list_apps --socket \\.\pipe\cua-driver ``` ## Connect Claude Code over SSH @@ -96,11 +104,14 @@ After the daemon is running in the interactive session, register Claude Code the ```powershell # From inside your SSH session: -claude mcp add --transport stdio cua-driver -- cua-driver.exe mcp +claude mcp add --transport stdio cua-driver -- cua-driver.exe mcp --socket \\.\pipe\cua-driver claude ``` -Each MCP tool call starts `cua-driver mcp` on the SSH side. That process detects the daemon and proxies through it. Claude Code sees a normal stdio MCP server, while the daemon receives tool calls from the Session 1+ desktop context. +Claude Code starts `cua-driver mcp` on the SSH side. The explicit socket keeps +that process as a protocol proxy to the interactive daemon. Bare `cua-driver +mcp` owns a direct runtime on Windows and therefore fails closed in Session 0; +it never silently falls back to another session. ## Diagnose empty results @@ -110,6 +121,8 @@ Check these items before opening an issue: 2. Run `cua-driver status` from SSH and confirm it reports a running daemon. If it does not, use `cua-driver autostart status` to see whether the Scheduled Task is registered. 3. Run `query session` and confirm your user has a row in `Active` or `Disc` state. 4. Run `cua-driver doctor` from RDP and confirm it reports `[ok] interactive session: session N has an attached interactive desktop`. -5. Confirm that the MCP configuration points to the same daemon socket reported by `cua-driver status`. +5. Confirm that the MCP command includes `--socket \\.\pipe\cua-driver`, or the + exact endpoint reported by `cua-driver status`. -There is no in-process opt-out. If the interactive-session daemon is unavailable, MCP startup fails instead of attempting GUI work from the SSH session. +If the explicitly selected interactive-session daemon is unavailable, MCP +startup fails instead of attempting GUI work from the SSH session. diff --git a/docs/content/docs/reference/cua-driver/browser-profile-attachment.mdx b/docs/content/docs/reference/cua-driver/browser-profile-attachment.mdx index 6042464151..59843958dc 100644 --- a/docs/content/docs/reference/cua-driver/browser-profile-attachment.mdx +++ b/docs/content/docs/reference/cua-driver/browser-profile-attachment.mdx @@ -57,7 +57,10 @@ A successful call returns `action: "attached_existing_profile"` and an | `opened_setup_page` | A temporary tab was opened in the approved native window. | | `closed_setup_page` | That temporary setup tab was closed successfully. | | `focused_setup_address_field` | The temporary tab's address field received in-app focus for exact navigation. | -| `enabled_remote_debugging` | The exact per-instance Chrome checkbox was toggled from off to on. | +| `enabled_remote_debugging` | The exact per-instance Chrome checkbox was toggled from off to on and the same control's resulting state was verified. | +| `used_bounded_pixel_fallback` | macOS used its setup-page-only pixel route to read or change the checkbox after the web AX subtree was unavailable. | +| `foregrounded_window` | Setup temporarily foregrounded the exact approved browser window for a bounded local action. | +| `injected_global_input` | Setup delivered global mouse or keyboard input only after revalidating the approved browser process and window. | | `changed_preferences` | Mirrors `enabled_remote_debugging` for the generic prepare contract. | | `displayed_consent_prompt` | Chrome displayed its browser-owned connection-consent prompt. | @@ -70,6 +73,25 @@ the same setup fields and adds `restored_remote_debugging` when the driver had to reverse its own checkbox change. It never claims cleanup succeeded unless the exact checkbox returned to the off state. +On macOS, Cua Driver prefers the checkbox's semantic AX action. Current Chrome +versions can withhold the internal page's web AX subtree even while exposing +the native address field and selected setup tab. The bounded fallback runs only +in the temporary tab Cua Driver created and navigated, after the fixed internal +URL is committed, the expected title is selected, and no omnibox edit is in +progress. It requires one unique checkbox-shaped control in the setup-page +region, revalidates the unchanged window, sends the click only to the approved +browser PID, and verifies the visual state change. That bounded global-input +route may briefly foreground the exact approved window and then restores the +previous frontmost app; `foregrounded_window` and `injected_global_input` report +what occurred. Unsupported appearance, scale, zoom, window-size, or +browser-toolbar geometry—including layouts shifted by a bookmarks bar—is +refused without a click. It is not available for ordinary web content. + +On refusal, `restored_remote_debugging: true` means the exact semantic checkbox, +or the setup page's sole bounded pixel checkbox when web AX became unavailable, +was proven off during cleanup. A false value means cleanup could not prove the +state; callers must not infer that remote debugging is disabled. + ## Grant lifetime The grant is scoped to the daemon instance, public session, transport session, diff --git a/docs/content/docs/reference/cua-driver/cli-reference.mdx b/docs/content/docs/reference/cua-driver/cli-reference.mdx index a3338deb25..259a1dfc5c 100644 --- a/docs/content/docs/reference/cua-driver/cli-reference.mdx +++ b/docs/content/docs/reference/cua-driver/cli-reference.mdx @@ -64,7 +64,7 @@ Requires a Cua Driver daemon. JSON arguments may be passed as a positional JSON Run the stdio MCP server. -On Windows and Linux, bare cua-driver mcp owns its runtime directly and shuts it down on stdin EOF. On macOS it proxies to CuaDriver.app so desktop permissions retain the app identity. Pass --socket to select an explicit daemon endpoint. +On Windows and Linux, bare cua-driver mcp owns its runtime directly and shuts it down on stdin EOF. On macOS it proxies to CuaDriver.app so desktop permissions retain the app identity. Pass --direct to make the macOS MCP process own the runtime and TCC attribution, or --socket to select an explicit daemon endpoint. **Options:** @@ -77,8 +77,9 @@ On Windows and Linux, bare cua-driver mcp owns its runtime directly and shuts it | Name | Description | | ---- | ----------- | +| `--direct` | Own the runtime in this MCP process; mutually exclusive with --socket. | | `--claude-code-computer-use-compat` | Expose the Claude Code computer-use compatibility screenshot surface. | -| `--embedded` | Require a daemon spawned by the embedding host instead of auto-launching the standalone app. | +| `--embedded` | Declare embedding-host mode. Without --direct, require the host's private service through --socket instead of auto-launching the standalone app. | ### `cua-driver serve` diff --git a/docs/content/docs/reference/cua-driver/contracts.mdx b/docs/content/docs/reference/cua-driver/contracts.mdx index 58c5955182..5f8ac04559 100644 --- a/docs/content/docs/reference/cua-driver/contracts.mdx +++ b/docs/content/docs/reference/cua-driver/contracts.mdx @@ -5,34 +5,47 @@ description: "The contracts behind the CLI and MCP surfaces: transport state, co import { Callout } from 'fumadocs-ui/components/callout'; -The CLI (`cua-driver call …`) and the MCP server (`cua-driver serve` / `mcp`) run the same tool code, but they differ in **what state survives between calls**, **where configuration lands**, and **which parameters a call must carry**. This page is the contract map. For the *why* behind the process shapes, see [Process model](/reference/cua-driver/process-model); for the modality axes, see [Capture and delivery modalities](/concepts/capture-and-delivery-modalities). +The CLI (`cua-driver call …`) and MCP (`cua-driver mcp`) run the same +runtime contract, but they differ in **what state survives between calls**, +**where configuration lands**, and **which parameters a call must carry**. +MCP may own that runtime directly or select `cua-driver serve` explicitly. --- ## CLI versus MCP at a glance -| Dimension | CLI (`call`) | MCP (`serve` / `mcp`) | +| Dimension | CLI (`call`) | MCP (`mcp`) | |---|---|---| -| Lifetime | one process per action | one process, many actions | -| `element_index` cache | only when proxied to a running daemon | per connection, lives across calls | -| Identity | anonymous, unless proxied to a daemon | a minted `_session_id` per connection, or an explicit `session` | +| Lifetime | one client process per action; the selected service owns durable state | one process, many actions | +| `element_index` cache | owned by the selected service; survives until invalidation or service restart | owned by the MCP runtime; lives across calls | +| Identity | anonymous unless the call declares a public `session` | a minted transport session per connection, plus an optional public `session` | | Where `set_config` lands | the persisted global default on disk | an in-memory, session-scoped override | | Agent cursor | none | shown when a `session` is declared | -When a daemon is already listening, `cua-driver call` **proxies to it**. A freshly built binary's behavior appears through the CLI after that daemon restarts, because the call runs in the daemon process. Integration tests avoid this by spawning their own MCP server. +`cua-driver call` requires a service at the resolved default endpoint or at the +explicit `--socket` endpoint. A freshly built binary's behavior appears through +the CLI only after that service restarts, because the action runs in the service +process. Integration tests avoid this ambiguity by starting an isolated runtime. +Bare MCP owns its runtime on Windows/Linux and uses the signed app service on +macOS. `mcp --socket ` explicitly selects a service on every +platform; `mcp --direct` explicitly selects process ownership and is mutually +exclusive with `--socket`. + --- ## Where settings live -`set_config` resolves *where* a setting is written from whether a session is declared. The daemon mirrors the public `session` argument into the reserved `_session_id`. +`set_config` resolves *where* a setting is written from whether a public +`session` is declared. Runtime and transport adapters derive their reserved +internal session fields; caller-supplied reserved values do not grant authority. -| Caller | `_session_id` | Effect | +| Caller | Public `session` | Effect | |---|---|---| -| `cua-driver config set …`, one-shot `cua-driver call` | absent (anonymous) | writes the global `DriverConfig` and persists to `~/.cua-driver/config.json` | -| MCP call with a `session` | present | in-memory override for that session only; no disk write, no clobber of the default | +| `cua-driver config set …`, anonymous one-shot `cua-driver call` | absent | writes the global `DriverConfig` and persists to `~/.cua-driver/config.json` | +| MCP or CLI call with a `session` | present | in-memory override for that session only; no disk write, no clobber of the default | Every tool then reads the **effective** value with this precedence: diff --git a/docs/content/docs/reference/cua-driver/limits.mdx b/docs/content/docs/reference/cua-driver/limits.mdx index 7a45c5b32f..b9f48bd2bc 100644 --- a/docs/content/docs/reference/cua-driver/limits.mdx +++ b/docs/content/docs/reference/cua-driver/limits.mdx @@ -61,11 +61,20 @@ previous refs. `browser_prepare` can either create a separate driver-owned isolated profile or attach to an approved existing Chrome, Edge, or Chromium profile on a proven platform. Existing-profile setup may enable that browser instance's own -remote-debugging switch through one exact accessibility action, but it never -copies profile data, edits profile files, restarts, or terminates the selected -process. Endpoint ownership must resolve to the approved browser PID; wrapper -processes, ambiguous process trees, unsupported products, unrecognized UI -locales, and generic Wayland identities are refused. +remote-debugging switch through one exact accessibility action. When current +macOS Chrome withholds the internal page's web accessibility tree, Cua Driver +instead creates and navigates a temporary tab, proves the committed fixed +address and expected selected-tab title with no active omnibox edit, and +requires one unique checkbox-shaped control in a bounded setup-page region. It +revalidates the unchanged target window, PID-routes the click, and verifies the +visual state transition. Unsupported appearance, scale, or zoom geometry is +refused without a click. Unsupported window sizes or toolbar layouts, including +a bookmarks bar that moves the control outside the bounded region, are refused +the same way. It never copies profile data, edits profile files, restarts, or +terminates the selected process. +Endpoint ownership must resolve to the approved browser PID; wrapper processes, +ambiguous process trees, unsupported products, unrecognized UI locales, and +generic Wayland identities are refused. On generic GNOME or KDE Wayland sessions, browser state may be discoverable without enough compositor evidence to correlate native and DevTools geometry diff --git a/docs/content/docs/reference/cua-driver/macos-permissions.mdx b/docs/content/docs/reference/cua-driver/macos-permissions.mdx index f1303b5f37..68909157fe 100644 --- a/docs/content/docs/reference/cua-driver/macos-permissions.mdx +++ b/docs/content/docs/reference/cua-driver/macos-permissions.mdx @@ -9,6 +9,13 @@ description: The macOS-only `cua-driver permissions` command for inspecting and Inspect or request the macOS TCC grants the driver needs (Accessibility and Screen Recording). Embedded-mode hosts do not use `cua-driver permissions grant`; the host app requests these grants itself, as described in [Embedding](/reference/cua-driver/embedding). +The same rule applies to an in-process `CuaDriver.create()` runtime and to +`cua-driver mcp --direct`: `check_permissions` is read-only even if a caller +passes `{"prompt": true}`. It reports `source.attribution: "host"`, +`source.direct_runtime: true`, and leaves direct ScreenCaptureKit readiness as +`not_checked`. The responsible host owns permission prompts, Settings +navigation, and the restart flow. + ```bash cua-driver permissions status # report grant status; read-only, no prompt cua-driver permissions grant # launch CuaDriver via LaunchServices so the prompt attributes to the app @@ -45,6 +52,11 @@ Automation prompts can also appear when an explicitly requested browser or app operation uses Apple Events. Those grants are target-specific and remain outside the core `permissions status` payload. +After Accessibility or Screen Recording grants change, fully quit and relaunch +the responsible application before recreating its runtime. For standalone +service mode that application is `CuaDriver.app`; for direct SDK or direct MCP +mode it is the importing or spawning host. + For the complete prompt sequence in a Lume guest, see [Run Cua Driver in a macOS Lume VM](/how-to-guides/driver/run-in-macos-lume-vm). Source-built test seeds also need a stable certificate identity; follow [Run Cua Driver macOS diff --git a/docs/content/docs/reference/cua-driver/platform-support.mdx b/docs/content/docs/reference/cua-driver/platform-support.mdx index 24398dcbd7..e771513665 100644 --- a/docs/content/docs/reference/cua-driver/platform-support.mdx +++ b/docs/content/docs/reference/cua-driver/platform-support.mdx @@ -30,6 +30,12 @@ scope, see [Capture and delivery modalities](/concepts/capture-and-delivery-moda | Linux X11 | X11/EWMH, XTest, AT-SPI, and toolkit accessibility bridges | Supported with toolkit-specific limits. Foreground input and semantic background actions are broadly covered. Toolkits that reject synthetic background events receive an explicit refusal instead of a silent success. | | Linux Wayland | AT-SPI plus compositor-specific discovery, capture, activation, and portal input | Supported with compositor-specific limits. Semantic background actions work where the application exposes them. Raw input cannot generally be sent to an arbitrary occluded surface. | +Wayland portal grants belong to the compositor/runtime scope that issued +them. A direct runtime or private worker reports its resolved display and +portal scope, and a replacement runtime may prompt again. Do not treat a +successful portal grant as a durable credential that migrates to a later +process generation. + ## Browser-tool routes Browser mutation always starts from an exact native `(pid, window_id)` binding. diff --git a/docs/content/docs/reference/cua-driver/process-model.mdx b/docs/content/docs/reference/cua-driver/process-model.mdx index 91c2bbb0f3..55609921da 100644 --- a/docs/content/docs/reference/cua-driver/process-model.mdx +++ b/docs/content/docs/reference/cua-driver/process-model.mdx @@ -24,12 +24,24 @@ parallel desktop implementation. See The MCP stdio process is *client-owned*. The parent starts `cua-driver mcp`, keeps stdin and stdout connected, and sends MCP tool calls over that transport. On Windows and Linux it owns the runtime directly unless `--socket` is -specified. On macOS it proxies to the permission-owning app daemon. +specified. On macOS it proxies to the permission-owning app daemon unless the +caller explicitly passes `--direct` and accepts the spawning host's TCC +attribution. `--direct` and `--socket` are mutually exclusive. -The daemon shape is *machine-owned*. A single `cua-driver serve` process listens on a local IPC endpoint, such as a Unix socket or named pipe, and keeps driver state in memory while it lives. +The daemon shape is *machine-owned*. A single `cua-driver serve` process +listens on a local IPC endpoint, such as a mode-`0600` Unix socket or +same-user-authenticated named pipe, and keeps driver state in memory while it +lives. The one-shot CLI adapter is *call-owned*. `cua-driver call ` connects to the daemon, prints one result, and exits. If the daemon is unavailable, the command fails instead of executing the tool in the CLI process. +Finite inspection commands—`list-tools`, `describe`, and `dump-docs`—read the +canonical SDK tool inventory without creating an action-capable runtime. They +therefore remain available in non-interactive environments such as Windows +Session 0. Desktop-owning entry points (`serve`, direct MCP, and +`CuaDriver.create()`) still refuse before accepting actions when no interactive +desktop is attached. + These are transport roles around the same typed runtime that applications can create in process or in a directly supervised private worker. @@ -73,6 +85,11 @@ The daemon belongs in the interactive user session instead. It may be kept there A daemon drives one physical machine. Multiple MCP clients can connect at the same time, but they still share the same screen, keyboard, pointer, accessibility tree, and recording machinery. Session identity does not make concurrent control independent. Two agents clicking at once still contend for the same desktop. +On Windows and Linux, bare MCP processes each own a separate runtime. To +deliberately share one daemon across several MCP clients, start the daemon and +give every client the same explicit `cua-driver mcp --socket ` +command. Do not rely on ambient daemon discovery. + Session identity solves a narrower state problem. When a proxy starts, it mints a session identity and stamps forwarded calls with it. The daemon uses that identity to scope mutable state to one client lifetime. Recording ownership, per-session config overrides, and the agent-cursor overlay are all keyed by session. None: socket_path = str(Path(directory) / "driver.sock") listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) listener.bind(socket_path) - listener.listen(1) + listener.listen(2) captured: list[dict[str, object]] = [] def serve() -> None: - connection, _ = listener.accept() - with connection: - line = connection.makefile("r", encoding="utf-8").readline() - captured.append(json.loads(line)) - response = { - "ok": True, - "result": { - "content": [ - {"type": "text", "text": "python ffi"}, - { - "type": "image", - "mimeType": "image/png", - "data": "cG5n", - }, - ], - "structuredContent": {"verified": True}, - "isError": False, - }, - } - connection.sendall((json.dumps(response) + "\n").encode()) + for _ in range(2): + connection, _ = listener.accept() + with connection: + line = connection.makefile("r", encoding="utf-8").readline() + request = json.loads(line) + if request["method"] == "metadata": + result = { + "driver_version": "0.12.6", + "contract_version": "0.2.0", + "tools_list_schema_version": "1", + "capability_version": "1", + "mcp_protocol_version": "2025-06-18", + "pid": os.getpid(), + "embedded": False, + } + else: + captured.append(request) + result = { + "content": [ + {"type": "text", "text": "python ffi"}, + { + "type": "image", + "mimeType": "image/png", + "data": "cG5n", + }, + ], + "structuredContent": {"verified": True}, + "isError": False, + } + response = {"ok": True, "result": result} + connection.sendall((json.dumps(response) + "\n").encode()) server = threading.Thread(target=serve) server.start() diff --git a/libs/cua-driver/rust/Skills/cua-driver/BROWSER.md b/libs/cua-driver/rust/Skills/cua-driver/BROWSER.md index c1469ba592..5c57121b1c 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/BROWSER.md +++ b/libs/cua-driver/rust/Skills/cua-driver/BROWSER.md @@ -131,6 +131,14 @@ window, toggle its uniquely labelled per-instance checkbox, prove that the loopback endpoint belongs to the approved process, and close the temporary tab. The result reports all visible `side_effects`. Missing, localized, or ambiguous controls are refused; never click a similar-looking prompt yourself. +On current macOS Chrome, the internal page may omit its web AX subtree. The +driver's bounded fallback is limited to a temporary tab it created and +navigated. It requires the committed fixed URL, expected selected-tab title, +no active omnibox edit, one unique checkbox-shaped control in the setup-page +region, an unchanged target window, PID-routed input, and a verified state +transition on that same control. Unsupported appearance, scale, zoom, +window-size, or toolbar geometry refuses without a click; the fallback does not +authorize generic pixel interaction. The grant lives only in the daemon, is scoped and expiring, and is discarded when the daemon restarts. A bounded reconnect can reuse it only while the same diff --git a/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md b/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md index 3dbe96780c..1bf7fad747 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md +++ b/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md @@ -18,7 +18,7 @@ does not attribute Accessibility or Screen Recording to an executable path. It attributes them to the **responsible process**: the app at the top of the process's launch chain, as tracked by the kernel/LaunchServices. When your signed app spawns a child with `posix_spawn`, `NSTask`/`Process`, or plain -`fork`/`exec`, that child stays inside *your* responsibility chain — TCC +`fork`/`exec`, that child stays inside _your_ responsibility chain — TCC checks made by the child are answered with **your app's** grants, and any prompt it triggered would name **your app**. This is exactly the behavior embedding relies on: grant once to the host, and every well-behaved child @@ -26,9 +26,9 @@ inherits. (Apple documents the attribution chain; you can watch it live with `log stream --debug --predicate 'subsystem == "com.apple.TCC" AND eventMessage BEGINSWITH "AttributionChain"'`.) Two things break the chain, and both are things the embedded driver must -*not* do (and, in embedded mode, does not do). First, launching via +_not_ do (and, in embedded mode, does not do). First, launching via LaunchServices (`open -a …`, `NSWorkspace.open`) makes the launched app its -own responsible process. Second, a process can explicitly *disclaim* +own responsible process. Second, a process can explicitly _disclaim_ responsibility for a child (`responsibility_spawnattrs_setdisclaim`), making the child its own responsible process — standalone cua-driver does this on purpose so its permissions attach to a stable `com.trycua.driver` identity @@ -59,6 +59,17 @@ try { } ``` +The direct runtime never presents macOS permission UI. Even +`check_permissions({prompt: true})` is forced into a read-only check and +reports the host as the responsible permission owner. After the host changes +Accessibility or Screen Recording grants, fully relaunch the host before +creating a replacement runtime. + +The AppKit agent-cursor overlay is not available in an arbitrary direct +runtime. Until a host installs a certified main-thread UI adapter, overlay +methods return structured `facility_unavailable` results. Use the private +worker or daemon-backed host when the visible overlay is required. + Use the daemon-backed host below only when the application must also provide a stable MCP endpoint to an external agent, coordinate external clients, or keep the automation runtime isolated from the application process. @@ -134,7 +145,7 @@ import { CuaDriver, EmbeddedCuaDriverHost } from '@trycua/cua-driver'; const embedded = new EmbeddedCuaDriverHost( '/path/inside/YourApp.app/Contents/Resources/cua-driver', - 'com.example.your-app', + 'com.example.your-app' ); const connection = await embedded.start(); const driver = CuaDriver.connect(connection.socketPath); @@ -185,16 +196,16 @@ daemon child. ## What embedded mode changes (and what it doesn't) -| | Standalone | Embedded (`CUA_DRIVER_EMBEDDED=1`) | -| ------------------------------ | ----------------------------------- | ---------------------------------------- | -| Responsibility disclaim re-exec| ON (owns its TCC identity) | OFF (stays in the host's chain) | -| Tool execution process | `serve` daemon | host-spawned `serve --embedded` daemon | -| Daemon auto-relaunch via `open -a CuaDriver` | Yes, when installed | Never (would leave the host's chain) | -| TCC identity | `com.trycua.driver` | the host app | -| Permission prompts / startup gate | May prompt once | **Never prompts** | -| Settings → Privacy & Security entries | CuaDriver | your app only | -| `check_permissions` `source.attribution` | `driver-daemon` (or `caller`) | `host` | -| Overlay, background input, capture, all tools | full | full — identical | +| | Standalone | Embedded (`CUA_DRIVER_EMBEDDED=1`) | +| --------------------------------------------- | ----------------------------- | -------------------------------------- | +| Responsibility disclaim re-exec | ON (owns its TCC identity) | OFF (stays in the host's chain) | +| Tool execution process | `serve` daemon | host-spawned `serve --embedded` daemon | +| Daemon auto-relaunch via `open -a CuaDriver` | Yes, when installed | Never (would leave the host's chain) | +| TCC identity | `com.trycua.driver` | the host app | +| Permission prompts / startup gate | May prompt once | **Never prompts** | +| Settings → Privacy & Security entries | CuaDriver | your app only | +| `check_permissions` `source.attribution` | `driver-daemon` (or `caller`) | `host` | +| Overlay, background input, capture, all tools | full | full — identical | Everything else — the agent-cursor overlay, background (no-focus-steal) clicking and typing, AX tree reads, per-window screenshots — is unchanged. @@ -206,7 +217,7 @@ behavior is byte-for-byte what it was. The host must be the responsible process for the driver. That holds automatically when you spawn the `serve` daemon directly and embedded mode is on. If the daemon were allowed to disclaim (standalone behavior), macOS -would treat it as its own responsible process: your user would get a *second* prompt +would treat it as its own responsible process: your user would get a _second_ prompt attributed to the driver binary, a second Settings entry, and capture/AX would fail until that second grant — the exact experience embedding exists to eliminate. Embedded mode short-circuits the disclaim re-exec @@ -235,7 +246,7 @@ gateway / node daemon YourApp.app Note `check_permissions` cannot detect this: `source.attribution` reports `host` whenever `CUA_DRIVER_EMBEDDED=1` is set, even if a gateway spawned -the driver. The symptoms are grant booleans that track the *gateway's* TCC +the driver. The symptoms are grant booleans that track the _gateway's_ TCC state and prompts/Settings entries naming the gateway process; see Troubleshooting below. @@ -263,8 +274,8 @@ a dialog (the `prompt` argument is ignored) and returns: } ``` -- `accessibility` / `screen_recording` — the live TCC state *of your app's - grant*, answered from inside the driver process (which shares your +- `accessibility` / `screen_recording` — the live TCC state _of your app's + grant_, answered from inside the driver process (which shares your identity). If both are true, it is safe to drive the desktop. - `screen_recording_capturable` / `direct_capture_status` — embedded `check_permissions` is read-only and never runs Tahoe's prompt-capable @@ -284,7 +295,7 @@ it** (the two API calls above), then re-calls `check_permissions`. The driver will never pop its own dialog in embedded mode. Heads-up on grant timing: macOS caches TCC answers per process. If your app -requests/receives the grants *after* the driver child is already running, +requests/receives the grants _after_ the driver child is already running, restart the driver child so it re-queries with a fresh cache. ## Minimal host example (copy-paste) @@ -510,7 +521,7 @@ the driver child after any grant change — TCC answers are cached per process. **"The AX tree comes back empty / clicks do nothing."** `AXIsProcessTrusted()` is false for the effective identity. The host hasn't -been granted Accessibility, or was granted it *after* the driver child +been granted Accessibility, or was granted it _after_ the driver child started (per-process cache again — restart the child), or the app was re-signed/moved so the existing grant row no longer matches it (remove and re-add it in System Settings, or `tccutil reset Accessibility ` @@ -532,10 +543,11 @@ responsibility chain. Two known exceptions: -- **Windows, elevated / UWP targets**: injecting into higher-integrity - windows needs the uiAccess-signed worker (`cua-driver-uia`). An embedded - host that must drive elevated apps has to manage that worker and connect - clients to its named pipe. +- **Windows, elevated / UWP targets**: pixel or SendInput delivery into a + higher-integrity target requires an interactively launched High-IL daemon + (the installed autostart task uses `RunLevel=Highest`). The + `cua-driver-uia` pipe is a reserved, default-off daemon-internal boundary; + embedding hosts and other public clients must not launch or connect to it. - **Linux Wayland** (compositor-specific): capture goes through XDG desktop portals, which prompt per-session at capture time and cannot be pre-granted by the host. X11 has no portal gate. diff --git a/libs/cua-driver/rust/Skills/cua-driver/MACOS.md b/libs/cua-driver/rust/Skills/cua-driver/MACOS.md index 7eb092be0e..377968542a 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/MACOS.md +++ b/libs/cua-driver/rust/Skills/cua-driver/MACOS.md @@ -23,15 +23,15 @@ is therefore forbidden unless the user **explicitly** asked for frontmost state: - **Every form of the `open` CLI — `open -a `, `open -b - `, `open `, `open `, `open - ` — always activates.** macOS routes all forms through +`, `open `, `open `, `open +` — always activates.** macOS routes all forms through LaunchServices, which unhides and foregrounds the target regardless of whether you passed an app name, a bundle id, a document, a URL, or the bundle path itself. The activation happens even when the only intent was "start the process." **Never use `open` for any app launch.** This includes launching a just-built .app from a local build dir (e.g. `open - build/Build/Products/Debug/MyApp.app`) — resolve the +build/Build/Products/Debug/MyApp.app`) — resolve the `CFBundleIdentifier` from `Info.plist` and use `launch_app` with that id. See "The narrow carve-out" below for why `launch_app` is safe even when the app internally calls @@ -81,9 +81,9 @@ frontmost is true'`). Mutating it is not. **Corollary — the AXMenuBar rule.** `AXMenuBarItem` + AXPick dispatches at the AX layer regardless of which app is frontmost, but macOS's on-screen menu bar always belongs to the frontmost -app. If you drive a *backgrounded* app's menu bar, the AX call +app. If you drive a _backgrounded_ app's menu bar, the AX call succeeds but the viewer sees the dispatch rendered over the -*frontmost* app's menu bar — confusing in any observed session and +_frontmost_ app's menu bar — confusing in any observed session and routinely a silent no-op too, because action menu items go `DISABLED` when their owning app isn't the key window. **So: only use menu-bar navigation when the target is already frontmost.** For @@ -105,16 +105,16 @@ is safe even for apps that normally foreground on media-load ## Intent → tool mapping (macOS-specific) -| Intent | Use | Don't use | -|---|---|---| -| Open / launch an app | `launch_app({bundle_id})` or `launch_app({bundle_id, urls:[...]})` | `open -a`, `osascript 'tell app … to launch/activate/open'` | -| Find a pid | `list_apps` or `launch_app`'s return | `pgrep`, `ps`, `osascript frontmost` | -| Enumerate an app's windows | `list_windows({pid})` — or read the `windows` array `launch_app` already returns | `osascript 'every window of app …'` | -| Click / type / scroll / keys | `click`, `type_text`, `scroll`, `press_key`, `hotkey` | `osascript`, `cliclick`, raw `CGEvent`, `open ` | -| Drag / drag-and-drop / marquee select | `drag({pid, from_x, from_y, to_x, to_y})` (pixel-only — macOS AX has no semantic drag) | `cliclick dd:`, `osascript drag` | -| Screenshot | `screenshot` or the PNG in `get_window_state` | `screencapture` | -| Quit an app | ask the user first, then `hotkey({pid, keys:["cmd","q"]})` | `kill`, `killall`, `pkill` | -| Hand a file/URL to an app | `launch_app({bundle_id, urls:[]})` | `open -a `, `open ` | +| Intent | Use | Don't use | +| ------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| Open / launch an app | `launch_app({bundle_id})` or `launch_app({bundle_id, urls:[...]})` | `open -a`, `osascript 'tell app … to launch/activate/open'` | +| Find a pid | `list_apps` or `launch_app`'s return | `pgrep`, `ps`, `osascript frontmost` | +| Enumerate an app's windows | `list_windows({pid})` — or read the `windows` array `launch_app` already returns | `osascript 'every window of app …'` | +| Click / type / scroll / keys | `click`, `type_text`, `scroll`, `press_key`, `hotkey` | `osascript`, `cliclick`, raw `CGEvent`, `open ` | +| Drag / drag-and-drop / marquee select | `drag({pid, from_x, from_y, to_x, to_y})` (pixel-only — macOS AX has no semantic drag) | `cliclick dd:`, `osascript drag` | +| Screenshot | `screenshot` or the PNG in `get_window_state` | `screencapture` | +| Quit an app | ask the user first, then `hotkey({pid, keys:["cmd","q"]})` | `kill`, `killall`, `pkill` | +| Hand a file/URL to an app | `launch_app({bundle_id, urls:[]})` | `open -a `, `open ` | ### The narrow carve-out @@ -160,7 +160,7 @@ There is no `ax`/`vision` capture toggle. **Every `get_window_state` returns both the AX tree and a screenshot** (default), so verifying that an action **landed** never means "go grab a screenshot" — it means cross-check the tree diff against the pixels you already have in the same -response, and only switch *dispatch rung* on a real signal: +response, and only switch _dispatch rung_ on a real signal: 1. **Re-snapshot and read the tree diff** — a changed `AXValue`, a new element, a collapsed menu, a disabled button. If the tree shows the @@ -183,12 +183,13 @@ response, and only switch *dispatch rung* on a real signal: On these surfaces you read the result off the screenshot already in the response, then address the target by `x,y` — an **element px action**. `px` is your **conscious switch to the pixel addressing path**, not a -different capture: the screenshot was always there, you just change *how -you address* the target. The point is to catch the "type → AX-check +different capture: the screenshot was always there, you just change _how +you address_ the target. The point is to catch the "type → AX-check succeeds → believe the lie → find out three calls later" trap on exactly the surfaces that warrant it. Rule of thumb: + - **element ax action** (default) — the element lookup before a click AND the first verify after it; you address by `[N]` `element_index` and read the tree diff. @@ -308,16 +309,17 @@ hold the no-foreground contract without the flag. macOS-specific residuals worth knowing (the rest of the capture/dispatch/ addressing params are a shared cross-platform contract — see `SKILL.md` → -*Cross-platform parameter contract*): - -- **`check_permissions.prompt` is macOS-only.** `true` raises the TCC - Accessibility / Screen-Recording dialogs and runs the prompt-capable direct - ScreenCaptureKit probe. `false` is read-only and returns direct-capture - readiness as not checked. There is no Windows/Linux equivalent — TCC is a - macOS construct, so the param is intentionally absent from the shared - contract. +_Cross-platform parameter contract_): + +- **`check_permissions.prompt` is macOS-only.** In the signed standalone + service, `true` may raise the TCC Accessibility / Screen-Recording dialogs + and run the direct ScreenCaptureKit probe. In an in-process SDK runtime, + private embedded host, or `cua-driver mcp --direct`, the host owns permission + UX: the driver forces this request into a read-only check, reports + `source.attribution:"host"`, and leaves direct-capture readiness + `not_checked`. There is no Windows/Linux equivalent. - **`session` always worked on macOS;** the cross-platform change is that - Windows/Linux stopped *rejecting* it. No macOS-side change to how you + Windows/Linux stopped _rejecting_ it. No macOS-side change to how you pass it. - **`scope`** (`window` / `desktop`) selects the action form uniformly on all platforms. Pass `scope:"desktop"` with no pid/window_id for screen-absolute @@ -368,15 +370,15 @@ anything in `/Applications` that's actually `iOSAppOnMac.app`) and Linear), an AX `type_text` can't reach the rendered text view: the `AXSetAttribute(kAXSelectedText)` write succeeds on the AX shim, but the UIKit/Chromium view that owns the input never observes it — and on -Electron the shim *echoes the value straight back through `AXValue`*, +Electron the shim _echoes the value straight back through `AXValue`_, so a naive read-back "confirms" a value that isn't really there. The driver **detects Electron and refuses to trust that echo**: an -AX-path `type_text` on an Electron app returns `effect:"unverifiable"` -+ `escalation:{recommended:"px"}`, **never** a false `verified:true`. -(On Catalyst the AX value reads back unreadable, so it reports -unverified too.) Bottom line: on these surfaces **do not trust the AX -confirm — the screenshot in the same response is the only truth.** +AX-path `type_text` on an Electron app returns `effect:"unverifiable"` + +`escalation:{recommended:"px"}`, **never** a false `verified:true`. + (On Catalyst the AX value reads back unreadable, so it reports + unverified too.) Bottom line: on these surfaces **do not trust the AX + confirm — the screenshot in the same response is the only truth.** Fix — **one call**: `type_text({pid, window_id, x, y, text})`. Passing `x,y` (no `element_index`) is the **element px action** form of @@ -389,7 +391,7 @@ the one-call replacement for the old two-step "pixel-click then dance. 0. **If the control is CLOSED, open it first.** A px focus-click won't - reliably *open and focus* a closed control (a search button, a + reliably _open and focus_ a closed control (a search button, a collapsed field) — it lands on whatever is already focused (e.g. the message composer), so your text leaks there. **AX-press to open/activate the control first** (AX actions work in the @@ -397,7 +399,7 @@ dance. 1. **`type_text({pid, window_id, x, y, text})`** — focus + type in a single call. Re-snapshot and read the text off the screenshot to confirm; the AX value can still lag on Catalyst/Electron. -2. Only if the keystrokes *still* drop (a focus-polling app), escalate +2. Only if the keystrokes _still_ drop (a focus-polling app), escalate that one `type_text` with `delivery_mode:"foreground"`. The `x,y` (px) form is **mutually exclusive** with `element_index` @@ -417,12 +419,12 @@ functional and one perceptual: - **Functional:** menu items that touch document/playback/editor state go `DISABLED` when their owning app isn't the key window - (Preview rotate, IINA speed change, most editor commands). AXPick - + AXPress will dispatch successfully from the driver's side but - no-op at the target — you get a silent false-pass. + (Preview rotate, IINA speed change, most editor commands). + AXPick + AXPress will dispatch successfully from the driver's side but + no-op at the target — you get a silent false-pass. - **Perceptual (matters for demos, screen recordings, and anything the user watches live):** macOS's screen-rendered menu bar - always belongs to the *frontmost* app. AXPick on a backgrounded + always belongs to the _frontmost_ app. AXPick on a backgrounded app's `AXMenuBarItem` dispatches to that app's per-process menu at the AX layer, but any visible menu render happens over the frontmost app's menu bar — the viewer sees an IINA submenu @@ -432,9 +434,9 @@ functional and one perceptual: integrity bug even though it's not a correctness bug. **Good decision rule:** if the target is not already frontmost, do -not use `AXMenuBarItem` at all. For *reading* in-window state, +not use `AXMenuBarItem` at all. For _reading_ in-window state, snapshot the window AX tree — most apps expose the same state via -an in-window `AXStaticText`, title bar, or toolbar. For *dispatching* +an in-window `AXStaticText`, title bar, or toolbar. For _dispatching_ actions, use in-window `element_index` (buttons, toolbar items) or pixel clicks on in-window controls — both dispatch via AppKit's window-under-pointer hit-test and are **not** frontmost-gated. @@ -447,7 +449,7 @@ the canonical path for menus. Menu contents are a two-snapshot flow. Closed AXMenu subtrees are deliberately skipped during snapshot — otherwise every app's File / Edit / View hierarchy plus every Recent Items macOS has ever seen -would inflate the tree 10-100x. But once a menu is *open*, its +would inflate the tree 10-100x. But once a menu is _open_, its AXMenuItem children do receive `element_index` values so you can click them normally. @@ -505,7 +507,13 @@ Use `BROWSER.md` for the typed browser capability workflow. Chrome and Edge support exact native-window binding, page refs, navigation, typing, and an explicit synthetic DOM click. Existing-profile preparation is separately approved and may automate the exact product-specific remote-debugging control; -it does not depend on System Events or direct profile-file edits. +it does not depend on System Events or direct profile-file edits. When Chrome +withholds the setup page's web AX subtree, the adapter uses only a temporary +tab it created and navigated, proves the committed native URL and expected +selected internal-tab title with no omnibox edit in progress, revalidates the +window, and PID-routes its bounded checkbox-pixel fallback. The same control's +state must verify after mutation; unsupported appearance, scale, zoom, +window-size, or toolbar geometry refuses without a click. Standalone Chromium activates its window when CDP's trusted pointer route is used on macOS. The driver therefore returns @@ -533,11 +541,11 @@ starting point for new browser workflows. ## macOS common error patterns -| Error text | Meaning | Fix | -|---|---|---| -| macOS system-alert beep on `press_key` with no visible change | Target window is minimized; Return / Space / Tab commits don't establish real renderer focus on minimized windows | AX-click a clickable equivalent (Go button, Submit button, checkbox) instead of pressing the key; see "Keyboard commits on minimized windows" under the Browser section | -| `Accessibility permission not granted` | TCC not granted | Stop; tell user to grant in System Settings | -| `Screen Recording permission not granted` | TCC not granted for capture | Screenshots and pixel actions are unavailable. If the task is AX-completable, use `get_window_state({include_screenshot:false})` and element-indexed actions; otherwise stop and ask the user to run `cua-driver permissions grant` | +| Error text | Meaning | Fix | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| macOS system-alert beep on `press_key` with no visible change | Target window is minimized; Return / Space / Tab commits don't establish real renderer focus on minimized windows | AX-click a clickable equivalent (Go button, Submit button, checkbox) instead of pressing the key; see "Keyboard commits on minimized windows" under the Browser section | +| `Accessibility permission not granted` | TCC not granted | Stop; tell user to grant in System Settings | +| `Screen Recording permission not granted` | TCC not granted for capture | Screenshots and pixel actions are unavailable. If the task is AX-completable, use `get_window_state({include_screenshot:false})` and element-indexed actions; otherwise stop and ask the user to run `cua-driver permissions grant` | ## Example end-to-end task (macOS) @@ -553,6 +561,6 @@ starting point for new browser workflows. populated AX subtree (sidebar, list view, files). 3. Done. -If the user instead asks to navigate *within* an already-open Finder +If the user instead asks to navigate _within_ an already-open Finder window, use the menu-bar flow from "Navigating native menu bars" above (click Go → pick a menu item → re-snapshot → click it). diff --git a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md index 9cff897411..5960358ff4 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md +++ b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md @@ -131,11 +131,12 @@ Tool names are `snake_case`, management subcommands are `kebab-case` — no ambiguity. Tools invoked as `cua-driver ''`. Management subcommands: -- `cua-driver serve` — start the persistent daemon (**required for every - tool call**). CLI and MCP processes are adapters; the daemon owns policy, - platform identity, state, and the per-pid element cache. - macOS users: see `MACOS.md` for the LaunchServices-routed launch - form. +- `cua-driver serve` — start an explicit persistent service when short-lived + clients must share runtime state or a platform identity. Bare MCP owns its + runtime directly on Windows/Linux and uses the signed app service on macOS; + `cua-driver mcp --socket ` selects a service explicitly. + One-shot CLI tool calls still use the service path. macOS users: see + `MACOS.md` for the LaunchServices-routed launch form. - `cua-driver stop` / `status` - `cua-driver list-tools`, `describe ` - `cua-driver recording start|stop|status` — see `RECORDING.md` @@ -189,8 +190,12 @@ recording, do a pixel click (`click({pid,x,y})`) or a `move_cursor` first to put the cursor on-screen; subsequent AX actions then glide the full path normally. -Requires the daemon process's UI runloop, which `cua-driver serve` -bootstraps. One-shot CLI adapters do not own an overlay themselves. +Requires a suitable UI event loop. Service and private-worker runtimes provide +one. On macOS, a same-process SDK runtime or `cua-driver mcp --direct` without +a certified host main-thread adapter returns a structured +`facility_unavailable` result for overlay operations; do not treat that as a +successful cursor move. One-shot CLI adapters do not own an overlay +themselves. ## The core invariant — snapshot before AND after every action diff --git a/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md b/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md index feb20b8aec..0d04715e02 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md +++ b/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md @@ -28,16 +28,16 @@ optional `delivery_mode` field — this mirrors the macOS `delivery_mode` surface (same name, same two values). The default is `"background"` — strict no-foreground: -| `delivery_mode` | Behavior on Windows | -|---|---| -| `"background"` (DEFAULT) | Never fronts and **never raises/restacks** the target — macOS-aligned (mirrors CGEvent-to-pid). **Pixel clicks**: a UIA hit-test at the point first (accessibility-channel Invoke — works on UWP / WinUI3 / Win11 packaged apps, no flash); if that misses, coordinate-injected pen/touch, **but only when the target is the *visible* window at that point**; PostMessage for plain Win32. It returns a structured `background_unavailable` error — rather than raising or fronting — when the target is **occluded** at the point, or the event kind is known-dropped (Chromium DOM mouse + key-combos, GTK buttons, VCL/LibreOffice accelerators, terminal / WPF text with no `element_index`). **No foreground swap and no z-order raise, ever.** | -| `"foreground"` | SendInput with brief `SetForegroundWindow(target)` → restore. The explicit, agent-chosen rung where fronting IS allowed — required to reach occluded targets, Chromium DOM content, GTK buttons, VCL accelerators, WPF drag, terminals, and canvas / custom-drawn surfaces with no UIA peer. Implemented for **every** input tool — `type_text` (SendInput Unicode via `send_text_synthesized`) and `scroll` (SendInput wheel via `send_wheel_synthesized`) included. Flashes the target visible unless `bring_to_front` was called first. | +| `delivery_mode` | Behavior on Windows | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `"background"` (DEFAULT) | Never fronts and **never raises/restacks** the target — macOS-aligned (mirrors CGEvent-to-pid). **Pixel clicks**: a UIA hit-test at the point first (accessibility-channel Invoke — works on UWP / WinUI3 / Win11 packaged apps, no flash); if that misses, coordinate-injected pen/touch, **but only when the target is the _visible_ window at that point**; PostMessage for plain Win32. It returns a structured `background_unavailable` error — rather than raising or fronting — when the target is **occluded** at the point, or the event kind is known-dropped (Chromium DOM mouse + key-combos, GTK buttons, VCL/LibreOffice accelerators, terminal / WPF text with no `element_index`). **No foreground swap and no z-order raise, ever.** | +| `"foreground"` | SendInput with brief `SetForegroundWindow(target)` → restore. The explicit, agent-chosen rung where fronting IS allowed — required to reach occluded targets, Chromium DOM content, GTK buttons, VCL accelerators, WPF drag, terminals, and canvas / custom-drawn surfaces with no UIA peer. Implemented for **every** input tool — `type_text` (SendInput Unicode via `send_text_synthesized`) and `scroll` (SendInput wheel via `send_wheel_synthesized`) included. Flashes the target visible unless `bring_to_front` was called first. | > **macOS is the source of truth — `background` never alters the screen.** > Earlier Windows builds "cheated" in background with three tricks that this > pass **removed**: (1) a z-order raise (`ZorderGuard`) to win the pointer > hit-test on occluded windows, (2) a full focus-activate for WPF drags, and -> (3) a *cloaked* (hidden) focus-grab for keystrokes/text the target would +> (3) a _cloaked_ (hidden) focus-grab for keystrokes/text the target would > otherwise drop. macOS does none of these (pure CGEvent-to-pid + focus > suppression), so Windows now does none either: when strict no-front / > no-raise delivery can't land, the tool returns `background_unavailable` and @@ -48,7 +48,7 @@ strict no-foreground: > **Removed: the legacy `"auto"` mode.** Earlier builds had a third > Windows-only `dispatch:"auto"` mode (silent SendInput fallback on > known-problematic targets). It was removed in the macOS-alignment pass -> because it could front the target *without the caller opting in* — +> because it could front the target _without the caller opting in_ — > breaking the no-foreground contract macOS guarantees. Any unrecognised > value (including a stray `"auto"`) now resolves to `"background"`. If you > have notes/snippets that pass `dispatch:"auto"`, switch to an explicit @@ -106,7 +106,7 @@ The recommended flow when an agent gets that error: prior window back. **There is no "restore" tool** — you brought the target forward deliberately; restoring is your responsibility. -The `bring_to_front` tool uses an `AttachThreadInput` trick to *attempt* +The `bring_to_front` tool uses an `AttachThreadInput` trick to _attempt_ the foreground swap even when the daemon isn't at UIAccess integrity (the same trick that powers `send_key_synthesized`). Returns `{previous_fg_hwnd, now_fg_hwnd, landed_on_target}` — **check @@ -115,13 +115,14 @@ reject the swap (and a subsequent `delivery_mode:"foreground"` call will bail with the "Foreground swap … was rejected by Windows" diagnostic rather than landing input on the wrong window). When that happens the target genuinely cannot be driven by SendInput/keystrokes in this session: -spawn the `cua-driver-uia` worker (UIAccess-manifested PE), or — for tasks +use an interactively launched High-IL daemon. The reserved `cua-driver-uia` +worker is a daemon-internal, default-off service boundary and public clients +must never connect to its pipe directly. Alternatively, for tasks that produce a file — generate the document and `launch_app` it instead of driving the GUI (e.g. building a spreadsheet and opening it in LibreOffice Calc rather than typing into the grid, which is dropped on the VCL background path). - Before running any shell command, ask: **"does this raise, activate, foreground, or steal focus from any app?"** If yes, don't run it. Every one of the commands below activates the target on Windows and @@ -129,8 +130,8 @@ is therefore forbidden unless the user **explicitly** asked for frontmost state: - **`Start-Process ` / `Start-Process ` / `Start-Process - -FilePath ...`** — defaults to launching with `SW_SHOWNORMAL` which - *activates* the new window. Windows treats new processes as +-FilePath ...`** — defaults to launching with `SW_SHOWNORMAL` which + _activates_ the new window. Windows treats new processes as user-initiated foreground apps. The CmdLine flag `-WindowStyle Hidden` helps but does not block activation for apps that call `SetForegroundWindow` themselves on startup (Edge, most browsers, @@ -150,7 +151,7 @@ frontmost state: but still activates the new window before minimizing it (flash visible to the user). Forbidden for the same reason. - **`explorer.exe shell:AppsFolder\` / `explorer.exe ms-edge: - `** — these are the Windows-shell equivalents of `open -a` / +`** — these are the Windows-shell equivalents of `open -a` / `open ` on macOS. They go through `IApplicationActivationManager` with the wrong activation kind and foreground the target. Use `launch_app({aumid})` or `launch_app({urls})` instead — those route @@ -174,7 +175,7 @@ frontmost state: never touch the OS cursor. - **`SendInput(KEYBDINPUT)` with no target HWND** — same idea: goes to the focused window, not your target. Use `hotkey({pid, keys: - [...]})` which uses `PostMessage(WM_KEYDOWN/UP)` to the named pid's +[...]})` which uses `PostMessage(WM_KEYDOWN/UP)` to the named pid's focused window. - **Keyboard shortcuts that semantically mean "focus here" — Chromium / Edge / Firefox `Ctrl+L` (focus address bar), @@ -184,8 +185,8 @@ frontmost state: raises its window to be key. Even when delivered to a backgrounded pid via `hotkey`, the downstream app pulls focus. **For omnibox navigation specifically**, the correct path is `launch_app({path: - "...msedge.exe", urls: ["https://…"]})` (or `{aumid: - "Microsoft.MicrosoftEdge.Stable_…!App", urls: [...]}`) — no +"...msedge.exe", urls: ["https://…"]})` (or `{aumid: +"Microsoft.MicrosoftEdge.Stable_…!App", urls: [...]}`) — no omnibox dance, no `Ctrl+L`, no focus-steal. The browser opens the URL in a new window without activating it. - **Tab-switching shortcuts in browsers (`Ctrl+1..9`, `Ctrl+Tab`, @@ -205,6 +206,7 @@ frontmost state: interacted with via `element_index` without activating or switching anything. Tabs are a UX grouping for humans; cua-driver-rs workflows should default to windows. + - **Win+key shortcuts owned by the shell** — `Win+E` (Explorer), `Win+R` (Run), `Win+S` / `Win+Q` (Search), `Win+number` (taskbar pinned-app activation), `Win+Tab` (Task View), `Alt+Tab` (window @@ -267,11 +269,11 @@ SendInput-swap path (`send_key_synthesized`) remains the dispatch for classic Notepad) use `TranslateAccelerator` which requires the system modifier state updated, and PostMessage can't do that. -**`modifier` on a *background* click is a Windows residual.** A +**`modifier` on a _background_ click is a Windows residual.** A backgrounded click delivers through UIA `Invoke` or `PostMessage`, and neither carries live keyboard state — so a `modifier` (Ctrl/Shift/etc.) passed alongside a `delivery_mode:"background"` click **is not honored** -on Windows. The `modifier` *param* is part of the shared schema and is +on Windows. The `modifier` _param_ is part of the shared schema and is accepted everywhere; it only takes effect on the SendInput rung, i.e. a `delivery_mode:"foreground"` (or `bring_to_front`-then-foreground) click, where SendInput sets real modifier state. If you need a modifier-click on @@ -280,7 +282,7 @@ Windows, escalate that one action to `foreground`. ### Cross-platform schema residuals (Windows) The capture/dispatch/addressing params are a shared cross-platform -contract (see `SKILL.md` → *Cross-platform parameter contract*). Three +contract (see `SKILL.md` → _Cross-platform parameter contract_). Three Windows-relevant notes: - **`session` is now accepted on every action/cursor tool.** Earlier @@ -298,16 +300,16 @@ Windows-relevant notes: portable fallback. See the AUMID section below. **Chromium pixel-click foreground polling restore.** `click({pid, x, y})` -on a Chromium target falls through to `send_click_synthesized` (SendInput -+ brief foreground swap) because Chromium's input thread filters by -queue-origin and PostMessage-delivered clicks don't fire DOM events. The -synchronous restore inside `send_click_synthesized` covers the -immediate swap; an additional polling guard (same shape as `launch_app`'s -`FocusRestoreGuard`) catches the **asynchronous** Chromium re-activation -that can happen as the renderer's input handler processes the click -(focus().activate() / WebContents::Activate() — 100-500 ms later). The -guard is gated on `GetWindowThreadProcessId(fg_now) == pid` so user -Alt-Tabs are respected. +on a Chromium target falls through to `send_click_synthesized` +(SendInput + brief foreground swap) because Chromium's input thread filters by + queue-origin and PostMessage-delivered clicks don't fire DOM events. The + synchronous restore inside `send_click_synthesized` covers the + immediate swap; an additional polling guard (same shape as `launch_app`'s + `FocusRestoreGuard`) catches the **asynchronous** Chromium re-activation + that can happen as the renderer's input handler processes the click + (focus().activate() / WebContents::Activate() — 100-500 ms later). The + guard is gated on `GetWindowThreadProcessId(fg_now) == pid` so user + Alt-Tabs are respected. ## Defaults — always prefer cua-driver over shell shims @@ -350,18 +352,18 @@ Stdin is the only path immune to all PS quoting edge cases. Prefer it. If you find yourself reaching for the right column, something has gone wrong — re-read "The no-foreground contract" above. -| Intent | Use | Don't use | -|---|---|---| -| Open / launch a Win32 app | `launch_app({path: "C:\\Program Files\\…\\foo.exe"})` or `{name: "foo"}` | `Start-Process`, `cmd /c start`, `& "C:\\path\\foo.exe"` | -| Open / launch a UWP / packaged app | `launch_app({aumid: "Microsoft.Foo_8wekyb3d8bbwe!App"})` | `explorer.exe shell:AppsFolder\\`, Start Menu typing | -| Open a URL in the default browser | `launch_app({urls: ["https://example.com"]})` | `Start-Process "https://…"`, `explorer.exe ms-edge:…`, `cmd /c start "" "https://…"` | -| Find a pid | `list_apps` or `launch_app`'s return | `Get-Process`, `tasklist`, Win+S typing | -| Enumerate an app's windows | `list_windows({pid})` — or read the `windows` array `launch_app` already returns | `Get-Process \| Where-Object { $_.MainWindowHandle }` | -| Click / type / scroll / keys | `click`, `type_text`, `scroll`, `press_key`, `hotkey` | `SendInput`, `cliclick`-style C# add-types, AutoHotkey scripts | -| Drag / drag-and-drop | `drag({pid, from_x, from_y, to_x, to_y})` | `SendInput` with `MOUSEEVENTF_MOVE`, mouse_event | -| Screenshot | `screenshot` or the PNG in `get_window_state` | `[System.Windows.Forms.Screen]::CopyFromScreen`, `nircmd savescreenshot` | -| Quit an app | ask the user first, then `hotkey({pid, keys:["alt","f4"]})` | `taskkill /F`, `Stop-Process -Force`, `Get-Process \| Stop-Process` | -| Hand a file/URL to an app | `launch_app({urls:[]})` (default app) or `{path: "...exe", args:[]}` (specific app) | `& "app.exe" "file"`, `Invoke-Item`, shell associations | +| Intent | Use | Don't use | +| ---------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| Open / launch a Win32 app | `launch_app({path: "C:\\Program Files\\…\\foo.exe"})` or `{name: "foo"}` | `Start-Process`, `cmd /c start`, `& "C:\\path\\foo.exe"` | +| Open / launch a UWP / packaged app | `launch_app({aumid: "Microsoft.Foo_8wekyb3d8bbwe!App"})` | `explorer.exe shell:AppsFolder\\`, Start Menu typing | +| Open a URL in the default browser | `launch_app({urls: ["https://example.com"]})` | `Start-Process "https://…"`, `explorer.exe ms-edge:…`, `cmd /c start "" "https://…"` | +| Find a pid | `list_apps` or `launch_app`'s return | `Get-Process`, `tasklist`, Win+S typing | +| Enumerate an app's windows | `list_windows({pid})` — or read the `windows` array `launch_app` already returns | `Get-Process \| Where-Object { $_.MainWindowHandle }` | +| Click / type / scroll / keys | `click`, `type_text`, `scroll`, `press_key`, `hotkey` | `SendInput`, `cliclick`-style C# add-types, AutoHotkey scripts | +| Drag / drag-and-drop | `drag({pid, from_x, from_y, to_x, to_y})` | `SendInput` with `MOUSEEVENTF_MOVE`, mouse_event | +| Screenshot | `screenshot` or the PNG in `get_window_state` | `[System.Windows.Forms.Screen]::CopyFromScreen`, `nircmd savescreenshot` | +| Quit an app | ask the user first, then `hotkey({pid, keys:["alt","f4"]})` | `taskkill /F`, `Stop-Process -Force`, `Get-Process \| Stop-Process` | +| Hand a file/URL to an app | `launch_app({urls:[]})` (default app) or `{path: "...exe", args:[]}` (specific app) | `& "app.exe" "file"`, `Invoke-Item`, shell associations | ### The narrow carve-out @@ -431,7 +433,7 @@ run the self-check: translate to the cua-driver equivalent from the mapping table. 2. **Does this command move the user's real cursor?** (`SendInput`, `SetCursorPos` from inline C#, AutoHotkey scripts, `nircmd - sendmouse`.) If yes — stop; use `click({pid, x, y})` which routes +sendmouse`.) If yes — stop; use `click({pid, x, y})` which routes per-HWND via PostMessage / per-element via UIA Invoke and never warps the cursor. 3. **Does this command bypass cua-driver entirely?** (PowerShell @@ -455,15 +457,16 @@ your prior tool calls earned. irm https://cua.ai/driver/install.ps1 | iex ``` and stop. -2. **The daemon must run in an interactive session (Session 1+), - NOT Session 0.** Windows isolates services into Session 0 with no +2. **The runtime owner must run in an interactive session (Session 1+), + NOT Session 0.** This is the daemon for one-shot CLI/service mode and the + MCP process for bare stdio MCP. Windows isolates services into Session 0 with no desktop. UIA enumeration, screenshot via PrintWindow, and `IApplicationActivationManager` all silently return empty / timeout in Session 0. Check: ```powershell Get-Process cua-driver | Select Id,SessionId ``` - `SessionId == 0` is broken. The autostart Scheduled Task uses + `SessionId == 0` is refused before runtime actions. The autostart Scheduled Task uses `LogonType=Interactive` so the daemon lands in the user's logon session. If you started the daemon via SSH-into-Windows, that session is usually Session 0 — kick the autostart task instead: @@ -491,9 +494,9 @@ Tool names are `snake_case`, management subcommands are ` with JSON via stdin or positional arg. Management subcommands: -- **`cua-driver serve`** — start the persistent daemon (**required for every - tool call**). CLI and MCP processes are adapters; the daemon owns the - interactive-session identity, policy, and per-pid element cache. +- **`cua-driver serve`** — start the persistent daemon used by one-shot CLI + calls or by MCP clients that explicitly select it with `--socket`. Bare + `cua-driver mcp` owns its runtime directly on Windows. Normally not run manually — the autostart Scheduled Task fires it at every interactive logon. If you stopped it (`Stop-Process`), re-run with `schtasks /Run /TN cua-driver-serve`, not by spawning @@ -510,6 +513,9 @@ subcommands: Windows video uses ffmpeg with `gdigrab`; trajectory evidence continues without video when ffmpeg is unavailable. +Over SSH, never use bare `cua-driver mcp`: the direct runtime rejects Session 0. Start the daemon in the interactive user session and run `cua-driver mcp +--socket \\.\pipe\cua-driver` from SSH. + Canonical multi-step workflow: ```powershell @@ -558,13 +564,14 @@ Two click addressing modes, both gated by `pid`: ### `element_index` mode (preferred) ```json -{"pid": 6004, "window_id": 459672, "element_index": 22} +{ "pid": 6004, "window_id": 459672, "element_index": 22 } ``` Looks up the cached UIA element from the last `get_window_state`, fires `IUIAutomationInvokePattern::Invoke()` on it directly. Properties: + - **No mouse cursor moves.** The click is a UIA RPC, not an input event. The user's cursor stays where it is. - **No window activates.** UIA Invoke does not foreground the @@ -582,7 +589,7 @@ Properties: non-actionable elements). The fallback works for plain Win32 but silently no-ops on UWP. The success message tells you which path ran: `"✅ Performed UIA Invoke on [N] ..."` vs `"✅ Performed - PostMessage click on [N] ..."`. +PostMessage click on [N] ..."`. This is the right path for **any** "click button N" / "click menu item X" / "click checkbox Y" intent. @@ -590,7 +597,7 @@ item X" / "click checkbox Y" intent. ### `(x, y)` mode (element px action / pixel) ```json -{"pid": 6004, "window_id": 459672, "x": 446, "y": 671} +{ "pid": 6004, "window_id": 459672, "x": 446, "y": 671 } ``` Window-client coordinates (origin at the top-left of the screenshot @@ -610,6 +617,7 @@ the agent saw). The driver: native controls. Properties: + - **No real cursor movement.** The agent overlay glides + pulses for visual confirmation; the OS cursor is untouched. - **No focus steal.** Both UIA Invoke and PostMessage are async per- @@ -629,6 +637,7 @@ Apps with **no useful UIA tree** AND that **ignore `WM_LBUTTONDOWN`** on the HWND queue — primarily DirectX / OpenGL / Vulkan-rendered surfaces (games, custom renderers). The click chain falls all the way through and the click no-ops. For those, the only options are: + - Bring the window to top first (focus steal — ask the user before doing this, and document why), then synthesize input - Use the app's keyboard interface via `hotkey` if available @@ -751,29 +760,29 @@ typed browser tools yet. when a cell is in edit mode, so background `WM_CHAR` / key-combos are silently dropped. Two honesty mechanisms now cover this instead of a blind success: - - **`hotkey` / `press_key`** (keystroke + key-combo): `delivery_mode:"background"` - surfaces a `background_unavailable` error for VCL. - - **`type_text`** does a **UIA read-back** and returns a three-way `verify` - in structured output: `confirmed` (✅, value reflects the text), - `unchanged` (📨, read OK but value didn't change → likely dropped, retry - foreground), or `unreadable` (✅ "delivered, not verified"). **Pass an - `element_index`** for reliable verification: the read-back then reads - *that specific element* by handle (ValuePattern → TextPattern), which is - **focus-independent** — it reaches `confirmed`/`unchanged` whether or not - the target is foreground. (Verified live against the WPF harness: typed - via element_index, read back `confirmed`, value independently present in - the next snapshot — app never fronted.) **Without** an element_index it - falls back to system-wide `GetFocusedElement`, which on Windows only - resolves when the target is the **foreground** app (no per-app - `AXFocusedUIElement` like macOS); a backgrounded target then reads - `unreadable` even when the text actually landed — so `unreadable` is NOT a - failure signal, verify via screenshot if it matters. - Escalate to `delivery_mode:"foreground"` for both (SendInput Unicode / - accelerator). **But** foreground needs the swap to actually land — if the - daemon lacks UIAccess and `bring_to_front` returns `landed_on_target:false` - (or it reverts before the next call), you can't drive it by input at all: - produce the artifact and `launch_app` it (build the `.xlsx` / `.docx` and - open it) rather than typing into the GUI. + - **`hotkey` / `press_key`** (keystroke + key-combo): `delivery_mode:"background"` + surfaces a `background_unavailable` error for VCL. + - **`type_text`** does a **UIA read-back** and returns a three-way `verify` + in structured output: `confirmed` (✅, value reflects the text), + `unchanged` (📨, read OK but value didn't change → likely dropped, retry + foreground), or `unreadable` (✅ "delivered, not verified"). **Pass an + `element_index`** for reliable verification: the read-back then reads + _that specific element_ by handle (ValuePattern → TextPattern), which is + **focus-independent** — it reaches `confirmed`/`unchanged` whether or not + the target is foreground. (Verified live against the WPF harness: typed + via element_index, read back `confirmed`, value independently present in + the next snapshot — app never fronted.) **Without** an element_index it + falls back to system-wide `GetFocusedElement`, which on Windows only + resolves when the target is the **foreground** app (no per-app + `AXFocusedUIElement` like macOS); a backgrounded target then reads + `unreadable` even when the text actually landed — so `unreadable` is NOT a + failure signal, verify via screenshot if it matters. + Escalate to `delivery_mode:"foreground"` for both (SendInput Unicode / + accelerator). **But** foreground needs the swap to actually land — if the + daemon lacks UIAccess and `bring_to_front` returns `landed_on_target:false` + (or it reverts before the next call), you can't drive it by input at all: + produce the artifact and `launch_app` it (build the `.xlsx` / `.docx` and + open it) rather than typing into the GUI. - **Edge / Chrome shows tab switching even though I used pid-scoped hotkey** — `Ctrl+Tab` / `Ctrl+1..9` aren't pid-scopable; the receiver activates. Use the windows-per-URL pattern. diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/platform.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/platform.rs index fbc48e25cf..1a42d9c720 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/platform.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/platform.rs @@ -137,6 +137,7 @@ pub struct ExistingProfileSetupOutcome { pub opened_setup_page: bool, pub closed_setup_page: bool, pub enabled_remote_debugging: bool, + pub used_bounded_pixel_fallback: bool, pub focused_setup_address_field: bool, pub foregrounded_window: bool, pub injected_global_input: bool, @@ -203,6 +204,7 @@ pub struct PrepareSideEffects { pub opened_setup_page: bool, pub closed_setup_page: bool, pub enabled_remote_debugging: bool, + pub used_bounded_pixel_fallback: bool, pub focused_setup_address_field: bool, pub foregrounded_window: bool, pub injected_global_input: bool, @@ -265,6 +267,22 @@ pub trait BrowserPlatform: Send + Sync { pid: i64, ) -> Result, BrowserRefusal>; + /// Attest the exact private-profile endpoint emitted by a browser process + /// that core just spawned. The default keeps ordinary exact-pid ownership. + /// Platforms with launcher-stub handoffs may override this narrowly; they + /// must match `expected_ws_url` and retain the exact listener pid so core + /// can promote the live runtime identity. + async fn discover_spawned_endpoint( + &self, + pid: i64, + expected_ws_url: &str, + ) -> Result, BrowserRefusal> { + Ok(self + .discover_owned_endpoint(pid) + .await? + .filter(|endpoint| endpoint.ws_url == expected_ws_url)) + } + /// Discover an endpoint while handling an explicitly approved /// existing-profile request. The default is the ordinary side-effect-free /// discovery path. Platforms may additionally return a uniquely proven diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/prepare.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/prepare.rs index cde14f8dc1..db2b3aea1e 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/prepare.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/prepare.rs @@ -56,6 +56,7 @@ fn with_setup_side_effects( if !setup.opened_setup_page && !setup.closed_setup_page && !setup.enabled_remote_debugging + && !setup.used_bounded_pixel_fallback && !setup.focused_setup_address_field && !setup.foregrounded_window && !setup.injected_global_input @@ -69,6 +70,7 @@ fn with_setup_side_effects( "closed_setup_page": setup.closed_setup_page, "focused_setup_address_field": setup.focused_setup_address_field, "enabled_remote_debugging": setup.enabled_remote_debugging, + "used_bounded_pixel_fallback": setup.used_bounded_pixel_fallback, "foregrounded_window": setup.foregrounded_window, "injected_global_input": setup.injected_global_input, }, @@ -517,6 +519,7 @@ async fn wait_for_spawned_endpoint( ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::SpawnedByDriver, owner_pid: i64::from(child.id()), + listener_pid: None, detail: Some( "driver-spawned process and private profile port file" .to_owned(), @@ -548,17 +551,29 @@ async fn attest_spawned_endpoint( ) -> Result { let deadline = Instant::now() + Duration::from_secs(5); loop { - if let Some(live) = engine.platform.discover_owned_endpoint(child_pid).await? { + if let Some(live) = engine + .platform + .discover_spawned_endpoint(child_pid, &profile_endpoint.ws_url) + .await? + { if live.http_port == profile_endpoint.http_port && live.ws_url == profile_endpoint.ws_url { + let runtime_pid = spawned_runtime_pid(&live.ownership); return Ok(OwnedEndpoint { ws_url: live.ws_url, http_port: live.http_port, ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::SpawnedByDriver, - owner_pid: live.ownership.owner_pid, - detail: Some(if live.ownership.owner_pid == child_pid { + // The platform already proved the exact listener is in + // child_pid's process tree. Promote that live process + // to the prepared-browser identity so ARM64 launcher + // handoffs remain bindable and reapable. Later Windows + // reproof normalizes ownership to this stable pid while + // retaining any new exact listener separately. + owner_pid: runtime_pid, + listener_pid: live.ownership.listener_pid, + detail: Some(if runtime_pid == child_pid { "driver-owned profile port file plus live loopback socket owner" .to_owned() } else { @@ -579,6 +594,10 @@ async fn attest_spawned_endpoint( } } +fn spawned_runtime_pid(ownership: &EndpointOwnershipProof) -> i64 { + ownership.listener_pid.unwrap_or(ownership.owner_pid) +} + impl BrowserEngine { pub(crate) fn cleanup_prepared_session(&self, session: &str) { self.managed_browsers @@ -1115,6 +1134,7 @@ impl BrowserEngine { opened_setup_page: setup.opened_setup_page, closed_setup_page: setup.closed_setup_page, enabled_remote_debugging: setup.enabled_remote_debugging, + used_bounded_pixel_fallback: setup.used_bounded_pixel_fallback, focused_setup_address_field: setup.focused_setup_address_field, foregrounded_window: setup.foregrounded_window, injected_global_input: setup.injected_global_input, @@ -1180,6 +1200,7 @@ mod tests { opened_setup_page: true, closed_setup_page: true, enabled_remote_debugging: true, + used_bounded_pixel_fallback: true, focused_setup_address_field: true, foregrounded_window: true, injected_global_input: true, @@ -1192,6 +1213,10 @@ mod tests { detail["setup_side_effects"]["enabled_remote_debugging"], true ); + assert_eq!( + detail["setup_side_effects"]["used_bounded_pixel_fallback"], + true + ); assert_eq!(detail["cause"]["original"], true); } @@ -1276,6 +1301,24 @@ mod tests { ); } + #[test] + fn spawned_runtime_promotes_a_proven_listener_without_losing_the_root() { + let proof = EndpointOwnershipProof { + method: EndpointOwnershipMethod::ListeningSocketPid, + owner_pid: 42, + listener_pid: Some(43), + detail: Some("listener 43 proven inside process tree 42".to_owned()), + }; + assert_eq!(spawned_runtime_pid(&proof), 43); + assert_eq!(proof.owner_pid, 42); + + let root_owned = EndpointOwnershipProof { + listener_pid: None, + ..proof + }; + assert_eq!(spawned_runtime_pid(&root_owned), 42); + } + #[cfg(target_os = "linux")] #[test] fn isolated_launch_can_select_native_wayland_and_test_vm_sandbox_mode() { diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/types.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/types.rs index c7ed410cc7..08929029ba 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/types.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/types.rs @@ -148,10 +148,18 @@ pub enum EndpointOwnershipMethod { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EndpointOwnershipProof { pub method: EndpointOwnershipMethod, - /// Pid the endpoint was attributed to. Core refuses with - /// `browser_endpoint_owner_mismatch` when this does not equal the + /// Stable process identity the platform attributed the endpoint to. For a + /// platform-proven browser process tree this is the authorized tree root; + /// `listener_pid` may retain the exact child socket owner. Core refuses + /// with `browser_endpoint_owner_mismatch` when this does not equal the /// target pid. pub owner_pid: i64, + /// Exact process that owned the listening socket when the platform can + /// prove it separately from the stable authorization root. Isolated + /// browser launch uses this to follow a promoted runtime process without + /// weakening later endpoint authorization to exact-listener equality. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listener_pid: Option, pub detail: Option, } @@ -274,4 +282,30 @@ mod tests { let b = a.clone(); assert!(a.matches(&b)); } + + #[test] + fn endpoint_listener_pid_is_wire_compatible_and_optional() { + let legacy = serde_json::json!({ + "method": "listening_socket_pid", + "owner_pid": 42, + "detail": "legacy proof" + }); + let proof: EndpointOwnershipProof = + serde_json::from_value(legacy).expect("deserialize legacy endpoint proof"); + assert_eq!(proof.owner_pid, 42); + assert_eq!(proof.listener_pid, None); + assert!(serde_json::to_value(&proof) + .expect("serialize endpoint proof") + .get("listener_pid") + .is_none()); + + let with_listener = EndpointOwnershipProof { + listener_pid: Some(43), + ..proof + }; + assert_eq!( + serde_json::to_value(with_listener).expect("serialize listener proof")["listener_pid"], + 43 + ); + } } diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/v2_tests.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/v2_tests.rs index 2015f34fc1..e4636c3d5f 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/v2_tests.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/v2_tests.rs @@ -733,6 +733,7 @@ impl BrowserPlatform for FixturePlatform { ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::ListeningSocketPid, owner_pid: pid, + listener_pid: None, detail: None, }, })) @@ -752,6 +753,7 @@ impl BrowserPlatform for FixturePlatform { ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::ListeningSocketPid, owner_pid: pid, + listener_pid: None, detail: Some("fixture exact approved endpoint".to_owned()), }, })) @@ -766,6 +768,7 @@ impl BrowserPlatform for FixturePlatform { opened_setup_page: true, closed_setup_page: false, enabled_remote_debugging: true, + used_bounded_pixel_fallback: false, focused_setup_address_field: true, foregrounded_window: false, injected_global_input: false, @@ -775,6 +778,7 @@ impl BrowserPlatform for FixturePlatform { ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::ListeningSocketPid, owner_pid: 1, + listener_pid: None, detail: Some("fixture exact setup transition".to_owned()), }, }), diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/src/abi.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/src/abi.rs index a67f89a86c..fe56c6041b 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/src/abi.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/src/abi.rs @@ -9,7 +9,8 @@ use crate::runtime::{DriverRuntime, RuntimeCreateError, RuntimeOptions, RuntimeS use crate::{DriverError, DriverMetadata}; use cua_driver_core::{ authorization::{ - PermissionMode, DANGEROUS_BYPASS_ENV, DISABLE_UNRESTRICTED_ENV, PERMISSION_MODE_ENV, + PermissionMode, DANGEROUS_BYPASS_ENV, DISABLE_UNRESTRICTED_ENV, + LEGACY_EXISTING_PROFILE_APPROVAL_ENV, PERMISSION_MODE_ENV, }, session_authorization::{DelegatedSessionRequest, SessionModeCeiling}, session_manifest::{load_manifest, SESSION_POLICY_APPROVED_ENV, SESSION_POLICY_FILE_ENV}, @@ -184,6 +185,12 @@ struct AbiRuntimeAuthorizationOptions { fn validate_explicit_authorization_sources( authorization: &AbiRuntimeAuthorizationOptions, ) -> Result<(), AbiFailure> { + cua_driver_core::policy::validate_configured_policy().map_err(|error| { + AbiFailure::new( + CuaDriverStatus::InvalidArgument, + format!("configured policy is invalid: {error}"), + ) + })?; if std::env::var_os(PERMISSION_MODE_ENV).is_some() || std::env::var_os(DANGEROUS_BYPASS_ENV).is_some() { @@ -219,6 +226,13 @@ fn validate_explicit_authorization_sources( )); } + if environment_flag(LEGACY_EXISTING_PROFILE_APPROVAL_ENV) { + return Err(AbiFailure::new( + CuaDriverStatus::InvalidArgument, + "explicit runtime authorization conflicts with the legacy existing-profile approval escape hatch", + )); + } + let environment_manifest = std::env::var_os(SESSION_POLICY_FILE_ENV).map(std::path::PathBuf::from); if let Some(environment_manifest) = environment_manifest { @@ -1148,6 +1162,8 @@ impl NativeAbiDriver { pub(crate) fn create_configured_for_host( options: Value, cursor: cursor_overlay::CursorConfig, + host_owns_permission_ux: bool, + host_bundle_id: Option, prepare_desktop_environment: bool, register_host_tools: Option, ) -> Result { @@ -1160,6 +1176,8 @@ impl NativeAbiDriver { reason: error.message, })?; runtime_options.cursor = cursor; + runtime_options.host_owns_permission_ux = host_owns_permission_ux; + runtime_options.host_bundle_id = host_bundle_id; runtime_options.prepare_desktop_environment = prepare_desktop_environment; runtime_options.register_host_tools = register_host_tools; Self::create_for_host(runtime_options) @@ -1441,6 +1459,9 @@ fn runtime_create_failure(error: RuntimeCreateError) -> AbiFailure { RuntimeCreateError::Authorization(reason) => { AbiFailure::new(CuaDriverStatus::InvalidArgument, reason) } + RuntimeCreateError::Unavailable(reason) => { + AbiFailure::new(CuaDriverStatus::RuntimeUnavailable, reason) + } } } @@ -1448,6 +1469,7 @@ fn map_runtime_create_error(error: RuntimeCreateError) -> DriverError { match error { RuntimeCreateError::AlreadyExists => DriverError::RuntimeAlreadyExists, RuntimeCreateError::Authorization(reason) => DriverError::Configuration { reason }, + RuntimeCreateError::Unavailable(reason) => DriverError::Protocol { reason }, } } diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs index a474acf8f6..1e4d2a5300 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs @@ -1113,7 +1113,11 @@ mod tests { fn options(mode: EmbeddedPermissionMode) -> EmbeddedDriverHostOptions { EmbeddedDriverHostOptions { - binary_path: "/example/cua-driver".into(), + binary_path: std::env::current_dir() + .expect("test working directory") + .join("cua-driver") + .to_string_lossy() + .into_owned(), host_bundle_id: "com.example.host".into(), socket_path: None, startup_timeout_ms: None, @@ -1189,6 +1193,37 @@ mod tests { .any(|variable| variable.value == "forged-lock")); } + #[test] + fn interactive_linux_session_environment_is_inherited() { + let values = merge_safe_environment( + [ + ("WAYLAND_DISPLAY".into(), "wayland-7".into()), + ("XDG_RUNTIME_DIR".into(), "/run/user/1000".into()), + ("XDG_SESSION_TYPE".into(), "wayland".into()), + ( + "DBUS_SESSION_BUS_ADDRESS".into(), + "unix:path=/run/user/1000/bus".into(), + ), + ("AT_SPI_BUS_ADDRESS".into(), "must-not-leak".into()), + ], + &[], + ); + + for (name, value) in [ + ("WAYLAND_DISPLAY", "wayland-7"), + ("XDG_RUNTIME_DIR", "/run/user/1000"), + ("XDG_SESSION_TYPE", "wayland"), + ("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"), + ] { + assert!(values + .iter() + .any(|variable| variable.name == name && variable.value == value)); + } + assert!(!values + .iter() + .any(|variable| variable.name == "AT_SPI_BUS_ADDRESS")); + } + #[test] fn metadata_validation_requires_same_process_and_contract() { let metadata = DaemonMetadata { diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs index 0e5d3302f6..f8af40daf9 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs @@ -13,7 +13,7 @@ use cua_driver_contract::{ }; use cua_driver_core::daemon::{ is_daemon_listening, request_daemon_metadata, send_request, socket_path_for_namespace, - DaemonClientKind, DaemonRequest, ToolObservationOrigin, + DaemonClientKind, DaemonMetadata, DaemonRequest, ToolObservationOrigin, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -224,11 +224,75 @@ pub struct CuaDriver { enum DriverBackend { Embedded(Arc), - Daemon { socket_path: String }, + Daemon(Arc), PrivateWorker(Arc), Remote(Arc), } +struct DaemonBackend { + socket_path: String, +} + +impl DaemonBackend { + fn new(socket_path: String) -> Arc { + Arc::new(Self { socket_path }) + } + + async fn compatible_metadata(&self) -> Result { + // Local socket paths can be rebound by a replacement daemon between + // calls. Re-negotiate every action instead of caching a successful + // result from an earlier process generation. + let socket_path = self.socket_path.clone(); + let request_path = socket_path.clone(); + let metadata = tokio::task::spawn_blocking(move || request_daemon_metadata(&request_path)) + .await + .map_err(|error| DriverError::Protocol { + reason: format!("daemon compatibility task failed: {error}"), + })? + .map_err(|error| DriverError::Transport { + socket_path, + reason: error.to_string(), + })?; + validate_daemon_metadata(&metadata)?; + Ok(metadata) + } +} + +fn validate_daemon_metadata(metadata: &DaemonMetadata) -> Result<(), DriverError> { + let mismatch = if metadata.contract_version != cua_driver_contract::CONTRACT_VERSION { + Some(format!( + "contract version {} does not match SDK {}", + metadata.contract_version, + cua_driver_contract::CONTRACT_VERSION + )) + } else if metadata.tools_list_schema_version != cua_driver_contract::TOOLS_LIST_SCHEMA_VERSION { + Some(format!( + "tools-list schema version {} does not match SDK {}", + metadata.tools_list_schema_version, + cua_driver_contract::TOOLS_LIST_SCHEMA_VERSION + )) + } else if metadata.capability_version != cua_driver_contract::CAPABILITY_VERSION { + Some(format!( + "capability version {} does not match SDK {}", + metadata.capability_version, + cua_driver_contract::CAPABILITY_VERSION + )) + } else if metadata.mcp_protocol_version != cua_driver_contract::MCP_PROTOCOL_VERSION { + Some(format!( + "MCP protocol version {} does not match SDK {}", + metadata.mcp_protocol_version, + cua_driver_contract::MCP_PROTOCOL_VERSION + )) + } else { + None + }; + mismatch.map_or(Ok(()), |reason| { + Err(DriverError::Protocol { + reason: format!("incompatible daemon: {reason}"), + }) + }) +} + /// Process topology used by this SDK object. #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] pub enum DriverExecutionMode { @@ -416,6 +480,13 @@ pub struct PrivateWorkerOptions { /// bindings intentionally receive the smaller [`DriverOptions`] record. pub struct DriverHostOptions { pub cursor: cursor_overlay::CursorConfig, + /// The runtime owner is the embedding/direct host, so macOS permission + /// checks are status-only and the host owns all request/restart UX. + pub host_owns_permission_ux: bool, + /// Advisory host identity shown in macOS permission diagnostics. It never + /// grants authority and is carried as immutable runtime configuration so + /// private workers do not mutate process-global environment state. + pub host_bundle_id: Option, pub claude_code_compatibility: bool, pub prepare_desktop_environment: bool, /// Temporary compatibility hook for daemon-only administrative tools. @@ -593,7 +664,7 @@ impl CuaDriver { }); } Ok(Arc::new(Self { - backend: DriverBackend::Daemon { socket_path }, + backend: DriverBackend::Daemon(DaemonBackend::new(socket_path)), client_kind: DaemonClientKind::Unknown, })) } @@ -606,13 +677,11 @@ impl CuaDriver { client_kind: SdkClientKind, ) -> Result, DriverError> { let driver = Self::connect(socket_path)?; - let DriverBackend::Daemon { socket_path } = &driver.backend else { + let DriverBackend::Daemon(daemon) = &driver.backend else { unreachable!("connect always returns a daemon client") }; Ok(Arc::new(Self { - backend: DriverBackend::Daemon { - socket_path: socket_path.clone(), - }, + backend: DriverBackend::Daemon(daemon.clone()), client_kind: client_kind.into(), })) } @@ -640,7 +709,7 @@ impl CuaDriver { pub fn execution_mode(&self) -> DriverExecutionMode { match &self.backend { DriverBackend::Embedded(_) => DriverExecutionMode::Embedded, - DriverBackend::Daemon { .. } => DriverExecutionMode::Daemon, + DriverBackend::Daemon(_) => DriverExecutionMode::Daemon, DriverBackend::PrivateWorker(_) => DriverExecutionMode::PrivateWorker, DriverBackend::Remote(_) => DriverExecutionMode::Remote, } @@ -651,7 +720,7 @@ impl CuaDriver { pub fn socket_path(&self) -> String { match &self.backend { DriverBackend::Embedded(_) => String::new(), - DriverBackend::Daemon { socket_path } => socket_path.clone(), + DriverBackend::Daemon(daemon) => daemon.socket_path.clone(), DriverBackend::PrivateWorker(_) => String::new(), DriverBackend::Remote(_) => String::new(), } @@ -660,7 +729,7 @@ impl CuaDriver { pub fn is_available(&self) -> bool { match &self.backend { DriverBackend::Embedded(runtime) => runtime.is_available(), - DriverBackend::Daemon { socket_path } => is_daemon_listening(socket_path), + DriverBackend::Daemon(daemon) => is_daemon_listening(&daemon.socket_path), DriverBackend::PrivateWorker(worker) => worker.is_available(), DriverBackend::Remote(remote) => remote.is_available(), } @@ -687,6 +756,26 @@ fn create_private_worker_for_client( } impl CuaDriver { + /// Return the canonical platform tool inventory without creating an + /// action-capable runtime. + /// + /// Rust transport adapters use this only for finite metadata commands such + /// as `list-tools`, `describe`, and `dump-docs`. The returned definitions + /// cannot dispatch actions, own sessions, or bypass interactive-desktop + /// admission. + pub fn inspect_host_tools(options: DriverHostOptions) -> Value { + runtime::tool_inventory(RuntimeOptions { + cursor: options.cursor, + host_owns_permission_ux: options.host_owns_permission_ux, + host_bundle_id: options.host_bundle_id, + compatibility_mode: options.claude_code_compatibility, + prepare_desktop_environment: options.prepare_desktop_environment, + register_host_tools: options.register_host_tools, + authorization_ceiling: None, + compatibility_authorization: None, + }) + } + /// Rust-only constructor for a transport adapter that exchanges generated /// Driver envelopes over an authenticated asynchronous channel. pub fn connect_remote( @@ -729,9 +818,9 @@ impl CuaDriver { }, })) } - DriverBackend::Daemon { socket_path } => { + DriverBackend::Daemon(daemon) => { let client = ServiceSessionClient::connect_and_bind( - socket_path.clone(), + daemon.socket_path.clone(), options, self.client_kind, )?; @@ -783,6 +872,8 @@ impl CuaDriver { backend: DriverBackend::Embedded(Arc::new(NativeAbiDriver::create_for_host( RuntimeOptions { cursor: options.cursor, + host_owns_permission_ux: options.host_owns_permission_ux, + host_bundle_id: options.host_bundle_id, compatibility_mode: options.claude_code_compatibility, prepare_desktop_environment: options.prepare_desktop_environment, register_host_tools: options.register_host_tools, @@ -829,6 +920,8 @@ impl CuaDriver { backend: DriverBackend::Embedded(Arc::new(NativeAbiDriver::create_for_host( RuntimeOptions { cursor: options.cursor, + host_owns_permission_ux: options.host_owns_permission_ux, + host_bundle_id: options.host_bundle_id, compatibility_mode: options.claude_code_compatibility, prepare_desktop_environment: options.prepare_desktop_environment, register_host_tools: options.register_host_tools, @@ -870,6 +963,8 @@ impl CuaDriver { NativeAbiDriver::create_configured_for_host( native_options, host.cursor, + host.host_owns_permission_ux, + host.host_bundle_id, host.prepare_desktop_environment, host.register_host_tools, )?, @@ -899,19 +994,8 @@ impl CuaDriver { DriverBackend::Embedded(runtime) => runtime.metadata(), DriverBackend::PrivateWorker(worker) => worker.metadata().await, DriverBackend::Remote(remote) => remote.metadata().await, - DriverBackend::Daemon { socket_path } => { - let socket_path = socket_path.clone(); - let request_path = socket_path.clone(); - let metadata = - tokio::task::spawn_blocking(move || request_daemon_metadata(&request_path)) - .await - .map_err(|error| DriverError::Protocol { - reason: format!("metadata task failed: {error}"), - })? - .map_err(|error| DriverError::Transport { - socket_path, - reason: error.to_string(), - })?; + DriverBackend::Daemon(daemon) => { + let metadata = daemon.compatible_metadata().await?; Ok(DriverMetadata { driver_version: metadata.driver_version, contract_version: metadata.contract_version, @@ -945,8 +1029,9 @@ impl CuaDriver { DriverBackend::Embedded(runtime) => runtime.tools_list()?, DriverBackend::PrivateWorker(worker) => worker.list_tools().await?, DriverBackend::Remote(remote) => remote.list_tools().await?, - DriverBackend::Daemon { socket_path } => { - let socket_path = socket_path.clone(); + DriverBackend::Daemon(daemon) => { + daemon.compatible_metadata().await?; + let socket_path = daemon.socket_path.clone(); let request_path = socket_path.clone(); let request = DaemonRequest { method: "list".into(), @@ -1024,7 +1109,7 @@ impl CuaDriver { DriverBackend::Embedded(runtime) => runtime.shutdown().await?, DriverBackend::PrivateWorker(worker) => worker.shutdown().await?, DriverBackend::Remote(remote) => remote.shutdown().await?, - DriverBackend::Daemon { .. } => {} + DriverBackend::Daemon(_) => {} } Ok(()) } @@ -1185,8 +1270,9 @@ impl CuaDriver { DriverBackend::Embedded(runtime) => runtime.invoke(name, arguments).await?, DriverBackend::PrivateWorker(worker) => worker.invoke(name, arguments, None).await?, DriverBackend::Remote(remote) => remote.invoke(name, arguments).await?, - DriverBackend::Daemon { socket_path } => { - let socket_path = socket_path.clone(); + DriverBackend::Daemon(daemon) => { + daemon.compatible_metadata().await?; + let socket_path = daemon.socket_path.clone(); let request_path = socket_path.clone(); let request = DaemonRequest { method: "call".into(), @@ -1325,10 +1411,12 @@ fn normalize_result(raw: Value) -> Result { uniffi::setup_scaffolding!("cua_driver_sdk"); -#[cfg(all(test, unix))] +#[cfg(test)] mod tests { use super::*; + #[cfg(unix)] use std::io::{BufRead, BufReader, Write}; + #[cfg(unix)] use std::os::unix::net::UnixListener; #[test] @@ -1344,11 +1432,26 @@ mod tests { assert_eq!(exported, expected); } + #[cfg(unix)] fn serve_once(response: Value) -> (tempfile::TempDir, String, std::thread::JoinHandle) { let directory = tempfile::tempdir().unwrap(); let socket = directory.path().join("driver.sock"); let listener = UnixListener::bind(&socket).unwrap(); let handle = std::thread::spawn(move || { + let (metadata_stream, _) = listener.accept().unwrap(); + let mut metadata_line = String::new(); + BufReader::new(metadata_stream.try_clone().unwrap()) + .read_line(&mut metadata_line) + .unwrap(); + let metadata_request: Value = serde_json::from_str(&metadata_line).unwrap(); + assert_eq!(metadata_request["method"], "metadata"); + let metadata_response = serde_json::json!({ + "ok": true, + "result": cua_driver_core::daemon::current_daemon_metadata() + }); + let mut metadata_writer = metadata_stream; + writeln!(metadata_writer, "{}", metadata_response).unwrap(); + let (stream, _) = listener.accept().unwrap(); let mut line = String::new(); BufReader::new(stream.try_clone().unwrap()) @@ -1377,6 +1480,71 @@ mod tests { .unwrap() } + struct SlowHostTool; + + static SLOW_HOST_TOOL_DEF: std::sync::OnceLock = + std::sync::OnceLock::new(); + static SLOW_HOST_TOOL_STARTED: std::sync::OnceLock = + std::sync::OnceLock::new(); + static SLOW_HOST_TOOL_RELEASE: std::sync::OnceLock = + std::sync::OnceLock::new(); + + #[async_trait::async_trait] + impl cua_driver_core::tool::Tool for SlowHostTool { + fn def(&self) -> &cua_driver_core::tool::ToolDef { + SLOW_HOST_TOOL_DEF.get_or_init(|| cua_driver_core::tool::ToolDef { + // Replace a reviewed R0 operation so the test exercises + // lifecycle draining rather than the unknown-tool fail-closed + // authorization path. + name: "health_report".into(), + description: "test-only admitted-call drain probe".into(), + input_schema: serde_json::json!({"type": "object"}), + read_only: true, + destructive: false, + idempotent: true, + open_world: false, + }) + } + + async fn invoke(&self, _args: Value) -> cua_driver_core::protocol::ToolResult { + SLOW_HOST_TOOL_STARTED + .get_or_init(tokio::sync::Notify::new) + .notify_one(); + SLOW_HOST_TOOL_RELEASE + .get_or_init(tokio::sync::Notify::new) + .notified() + .await; + cua_driver_core::protocol::ToolResult::text("drained") + } + } + + fn register_slow_host_tool(registry: &mut cua_driver_core::tool::ToolRegistry) { + registry.register(Box::new(SlowHostTool)); + } + + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn host_tool_inspection_does_not_acquire_runtime_ownership() { + let _runtime_test = crate::runtime::TEST_RUNTIME_LOCK.lock().unwrap(); + let inventory = CuaDriver::inspect_host_tools(DriverHostOptions { + cursor: cursor_overlay::CursorConfig { + enabled: false, + ..cursor_overlay::CursorConfig::default() + }, + host_owns_permission_ux: false, + host_bundle_id: None, + claude_code_compatibility: false, + prepare_desktop_environment: false, + register_host_tools: None, + }); + assert!(inventory["tools"] + .as_array() + .is_some_and(|tools| tools.iter().any(|tool| tool["name"] == "click"))); + + let driver = CuaDriver::create(None).unwrap(); + driver.shutdown().await.unwrap(); + } + #[tokio::test] async fn embedded_runtime_owns_tools_without_daemon_ipc_and_shuts_down_idempotently() { let _runtime_test = crate::runtime::TEST_RUNTIME_LOCK.lock().unwrap(); @@ -1408,6 +1576,58 @@ mod tests { )); } + #[tokio::test] + async fn shutdown_drains_an_already_admitted_call() { + let _runtime_test = crate::runtime::TEST_RUNTIME_LOCK.lock().unwrap(); + let driver = CuaDriver::try_create_for_host(DriverHostOptions { + cursor: cursor_overlay::CursorConfig { + enabled: false, + ..cursor_overlay::CursorConfig::default() + }, + host_owns_permission_ux: false, + host_bundle_id: None, + claude_code_compatibility: false, + prepare_desktop_environment: false, + register_host_tools: Some(register_slow_host_tool), + }) + .unwrap(); + let action_driver = driver.clone(); + let action = tokio::spawn(async move { + action_driver + .call_tool("health_report".into(), "{}".into()) + .await + }); + SLOW_HOST_TOOL_STARTED + .get_or_init(tokio::sync::Notify::new) + .notified() + .await; + let shutdown_driver = driver.clone(); + let shutdown = tokio::spawn(async move { shutdown_driver.shutdown().await }); + tokio::task::yield_now().await; + assert!( + !shutdown.is_finished(), + "shutdown returned before the admitted call completed" + ); + SLOW_HOST_TOOL_RELEASE + .get_or_init(tokio::sync::Notify::new) + .notify_one(); + assert_eq!(action.await.unwrap().unwrap().text, "drained"); + shutdown.await.unwrap().unwrap(); + } + + #[cfg(target_os = "windows")] + #[test] + fn session_zero_refuses_runtime_creation_before_platform_dispatch() { + if platform_windows::diagnostics::current_session_id() != Some(0) { + return; + } + let error = match CuaDriver::create(None) { + Ok(_) => panic!("Session 0 unexpectedly created a desktop runtime"), + Err(error) => error, + }; + assert!(error.to_string().contains("Session 0")); + } + #[tokio::test] async fn embedded_runtime_enforces_authorization_before_platform_dispatch() { let _runtime_test = crate::runtime::TEST_RUNTIME_LOCK.lock().unwrap(); @@ -1614,6 +1834,7 @@ mod tests { } #[tokio::test] + #[cfg(unix)] async fn typed_desktop_call_serializes_contract_and_normalizes_result() { let response = serde_json::json!({ "ok": true, @@ -1649,6 +1870,7 @@ mod tests { } #[tokio::test] + #[cfg(unix)] async fn tool_discovery_uses_the_shared_direct_daemon_protocol() { let response = serde_json::json!({ "ok": true, @@ -1666,6 +1888,61 @@ mod tests { } #[tokio::test] + #[cfg(unix)] + async fn daemon_version_mismatch_is_refused_before_action_dispatch() { + let directory = tempfile::tempdir().unwrap(); + let socket = directory.path().join("incompatible.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let action_dispatched = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let observed_action = action_dispatched.clone(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut line) + .unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "metadata"); + let mut metadata = cua_driver_core::daemon::current_daemon_metadata(); + metadata.contract_version = "incompatible-test-contract".into(); + let mut writer = stream; + writeln!( + writer, + "{}", + serde_json::json!({"ok": true, "result": metadata}) + ) + .unwrap(); + + listener.set_nonblocking(true).unwrap(); + for _ in 0..20 { + match listener.accept() { + Ok(_) => { + observed_action.store(true, std::sync::atomic::Ordering::Release); + break; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(error) => panic!("accept after incompatible metadata: {error}"), + } + } + }); + let driver = CuaDriver::connect(Some(socket.to_string_lossy().into_owned())).unwrap(); + let error = driver + .call_tool("health_report".into(), "{}".into()) + .await + .unwrap_err(); + assert!(matches!(error, DriverError::Protocol { .. })); + assert!(error.to_string().contains("incompatible daemon")); + server.join().unwrap(); + assert!( + !action_dispatched.load(std::sync::atomic::Ordering::Acquire), + "tool request crossed the compatibility gate" + ); + } + + #[tokio::test] + #[cfg(unix)] async fn session_method_returns_the_canonical_typed_output() { let response = serde_json::json!({ "ok": true, @@ -1704,6 +1981,14 @@ mod tests { #[async_trait::async_trait] impl remote::DriverEnvelopeChannel for FakeRemoteChannel { + async fn negotiate(&self) -> Result { + Ok(remote::DriverChannelCapabilities { + minimum_envelope_version: remote::DRIVER_ENVELOPE_VERSION, + maximum_envelope_version: remote::DRIVER_ENVELOPE_VERSION, + supports_cancellation: true, + }) + } + async fn exchange( &self, request: remote::DriverRequestEnvelope, @@ -1751,6 +2036,10 @@ mod tests { Ok(()) } + async fn cancel(&self, _request_id: &str) -> Result<(), String> { + Ok(()) + } + fn authenticated_principal(&self) -> &str { "test-principal" } @@ -1803,6 +2092,14 @@ mod tests { #[async_trait::async_trait] impl remote::DriverEnvelopeChannel for UncertainRemoteChannel { + async fn negotiate(&self) -> Result { + Ok(remote::DriverChannelCapabilities { + minimum_envelope_version: remote::DRIVER_ENVELOPE_VERSION, + maximum_envelope_version: remote::DRIVER_ENVELOPE_VERSION, + supports_cancellation: true, + }) + } + async fn exchange( &self, request: remote::DriverRequestEnvelope, @@ -1835,6 +2132,10 @@ mod tests { Ok(()) } + async fn cancel(&self, _request_id: &str) -> Result<(), String> { + Ok(()) + } + fn authenticated_principal(&self) -> &str { "test-principal" } @@ -1861,4 +2162,234 @@ mod tests { )); } } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn direct_macos_runtime_reports_cursor_overlay_facility_unavailable() { + let _runtime_test = crate::runtime::TEST_RUNTIME_LOCK.lock().unwrap(); + let driver = CuaDriver::create(None).unwrap(); + for (tool, arguments) in [ + ( + "set_agent_cursor_enabled", + serde_json::json!({"enabled": true, "session": "direct-test"}), + ), + ( + "get_agent_cursor_state", + serde_json::json!({"session": "direct-test"}), + ), + ( + "move_cursor", + serde_json::json!({ + "x": 10, + "y": 20, + "scope": "window", + "session": "direct-test" + }), + ), + ] { + let result = driver + .call_tool(tool.into(), arguments.to_string()) + .await + .unwrap(); + assert!(result.is_error, "{tool} reported false success"); + assert_eq!( + result.error_code.as_deref(), + Some("facility_unavailable"), + "{tool} returned the wrong structured refusal" + ); + } + let permissions = driver + .call_tool( + "check_permissions".into(), + serde_json::json!({"prompt": true}).to_string(), + ) + .await + .unwrap(); + let structured: Value = + serde_json::from_str(permissions.structured_json.as_deref().unwrap()).unwrap(); + assert_eq!(structured["direct_capture_status"], "not_checked"); + assert_eq!(structured["screen_recording_capturable"], Value::Null); + assert_eq!(structured["source"]["attribution"], "host"); + assert_eq!(structured["source"]["direct_runtime"], true); + driver.shutdown().await.unwrap(); + } + + struct IncompatibleRemoteChannel; + + #[async_trait::async_trait] + impl remote::DriverEnvelopeChannel for IncompatibleRemoteChannel { + async fn negotiate(&self) -> Result { + Ok(remote::DriverChannelCapabilities { + minimum_envelope_version: remote::DRIVER_ENVELOPE_VERSION + 1, + maximum_envelope_version: remote::DRIVER_ENVELOPE_VERSION + 1, + supports_cancellation: true, + }) + } + + async fn exchange( + &self, + _request: remote::DriverRequestEnvelope, + ) -> Result { + panic!("incompatible carrier must be refused before envelope dispatch") + } + + async fn bind_session( + &self, + _options: TrustedSessionOptions, + ) -> Result, String> { + Err("not used".into()) + } + + async fn close(&self) -> Result<(), String> { + Ok(()) + } + + async fn cancel(&self, _request_id: &str) -> Result<(), String> { + Ok(()) + } + + fn authenticated_principal(&self) -> &str { + "test-principal" + } + + fn connection_generation(&self) -> &str { + "test-generation" + } + } + + #[tokio::test] + async fn remote_carrier_negotiates_before_dispatch() { + let driver = CuaDriver::connect_remote(Arc::new(IncompatibleRemoteChannel)).unwrap(); + let error = driver.metadata().await.unwrap_err(); + assert!(matches!(error, DriverError::Protocol { .. })); + assert!(error.to_string().contains("outside carrier range")); + let error = match driver + .create_remote_trusted_session(TrustedSessionOptions { + public_session: "must-not-bind".into(), + mode: SessionPermissionMode::Standard, + ttl_seconds: 60, + idle_ttl_seconds: 30, + bounded_manifest_path: None, + }) + .await + { + Ok(_) => panic!("incompatible carrier unexpectedly bound a session"), + Err(error) => error, + }; + assert!(matches!(error, DriverError::Protocol { .. })); + } + + struct LegacyRemoteChannel; + + #[async_trait::async_trait] + impl remote::DriverEnvelopeChannel for LegacyRemoteChannel { + async fn exchange( + &self, + _request: remote::DriverRequestEnvelope, + ) -> Result { + panic!("legacy carrier must be refused before envelope dispatch") + } + + async fn bind_session( + &self, + _options: TrustedSessionOptions, + ) -> Result, String> { + Err("not used".into()) + } + + async fn close(&self) -> Result<(), String> { + Ok(()) + } + + fn authenticated_principal(&self) -> &str { + "legacy-principal" + } + + fn connection_generation(&self) -> &str { + "legacy-generation" + } + } + + #[tokio::test] + async fn legacy_remote_trait_implementors_compile_but_fail_closed_before_dispatch() { + let driver = CuaDriver::connect_remote(Arc::new(LegacyRemoteChannel)).unwrap(); + let error = driver.metadata().await.unwrap_err(); + assert!(matches!(error, DriverError::Protocol { .. })); + assert!(error + .to_string() + .contains("does not support request cancellation")); + } + + struct CancellableRemoteChannel { + started: Arc, + cancelled: Arc, + } + + #[async_trait::async_trait] + impl remote::DriverEnvelopeChannel for CancellableRemoteChannel { + async fn negotiate(&self) -> Result { + Ok(remote::DriverChannelCapabilities { + minimum_envelope_version: remote::DRIVER_ENVELOPE_VERSION, + maximum_envelope_version: remote::DRIVER_ENVELOPE_VERSION, + supports_cancellation: true, + }) + } + + async fn exchange( + &self, + _request: remote::DriverRequestEnvelope, + ) -> Result { + self.started.notify_one(); + std::future::pending().await + } + + async fn bind_session( + &self, + _options: TrustedSessionOptions, + ) -> Result, String> { + Err("not used".into()) + } + + async fn close(&self) -> Result<(), String> { + Ok(()) + } + + async fn cancel(&self, _request_id: &str) -> Result<(), String> { + self.cancelled + .store(true, std::sync::atomic::Ordering::Release); + Ok(()) + } + + fn authenticated_principal(&self) -> &str { + "test-principal" + } + + fn connection_generation(&self) -> &str { + "test-generation" + } + } + + #[tokio::test] + async fn dropping_remote_action_future_cancels_its_request_identity() { + let started = Arc::new(tokio::sync::Notify::new()); + let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let driver = CuaDriver::connect_remote(Arc::new(CancellableRemoteChannel { + started: started.clone(), + cancelled: cancelled.clone(), + })) + .unwrap(); + let action_driver = driver.clone(); + let action = + tokio::spawn(async move { action_driver.call_tool("click".into(), "{}".into()).await }); + started.notified().await; + action.abort(); + let _ = action.await; + for _ in 0..50 { + if cancelled.load(std::sync::atomic::Ordering::Acquire) { + return; + } + tokio::task::yield_now().await; + } + panic!("remote request cancellation was not forwarded"); + } } diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/src/remote.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/src/remote.rs index 1e646263ac..48db07017d 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/src/remote.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/src/remote.rs @@ -44,6 +44,15 @@ pub struct DriverResponseEnvelope { pub completion_known: bool, } +/// Version and lifecycle features negotiated with an authenticated remote +/// carrier before any Driver envelope is dispatched. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DriverChannelCapabilities { + pub minimum_envelope_version: u32, + pub maximum_envelope_version: u32, + pub supports_cancellation: bool, +} + /// Authenticated asynchronous carrier for generated Driver envelopes. /// /// `bind_session` returns a new opaque channel already bound to the effective @@ -51,6 +60,20 @@ pub struct DriverResponseEnvelope { /// authority for the caller to replay on another connection. #[async_trait] pub trait DriverEnvelopeChannel: Send + Sync { + /// Report the carrier's compatible envelope range and lifecycle support. + /// + /// The default preserves source compatibility for carriers compiled + /// against the first public trait revision, but deliberately reports no + /// cancellation support so new action dispatch fails closed until that + /// carrier implements the lifecycle contract. + async fn negotiate(&self) -> Result { + Ok(DriverChannelCapabilities { + minimum_envelope_version: DRIVER_ENVELOPE_VERSION, + maximum_envelope_version: DRIVER_ENVELOPE_VERSION, + supports_cancellation: false, + }) + } + async fn exchange( &self, request: DriverRequestEnvelope, @@ -63,6 +86,13 @@ pub trait DriverEnvelopeChannel: Send + Sync { async fn close(&self) -> Result<(), String>; + /// Cancel one in-flight request by its opaque request identity. Carriers + /// must make this idempotent because local future destruction can race a + /// response already in transit. + async fn cancel(&self, _request_id: &str) -> Result<(), String> { + Err("remote Driver carrier does not implement request cancellation".into()) + } + fn authenticated_principal(&self) -> &str; fn connection_generation(&self) -> &str; } @@ -120,6 +150,7 @@ impl RemoteDriverClient { &self, options: TrustedSessionOptions, ) -> Result, DriverError> { + negotiate(&self.channel).await?; let channel = self .channel .bind_session(options) @@ -132,6 +163,7 @@ impl RemoteDriverClient { reason: "remote bound session changed principal or connection generation".into(), }); } + negotiate(&channel).await?; Ok(Arc::new(RemoteBoundSession { channel, closed: AtomicBool::new(false), @@ -196,7 +228,9 @@ async fn exchange( name: Option, arguments: Option, ) -> Result { + negotiate(channel).await?; let request_id = Uuid::new_v4().to_string(); + let mut cancellation = RemoteCancellationGuard::new(channel.clone(), request_id.clone()); let response = channel .exchange(DriverRequestEnvelope { envelope_version: DRIVER_ENVELOPE_VERSION, @@ -217,6 +251,7 @@ async fn exchange( DriverError::Remote { reason } } })?; + cancellation.disarm(); if response.envelope_version != DRIVER_ENVELOPE_VERSION || response.request_id != request_id { return Err(DriverError::Protocol { reason: "remote Driver response identity mismatch".into(), @@ -242,6 +277,72 @@ async fn exchange( Ok(response.result.unwrap_or(Value::Null)) } +async fn negotiate(channel: &Arc) -> Result<(), DriverError> { + let capabilities = channel + .negotiate() + .await + .map_err(|reason| DriverError::Remote { reason })?; + if capabilities.minimum_envelope_version > DRIVER_ENVELOPE_VERSION + || capabilities.maximum_envelope_version < DRIVER_ENVELOPE_VERSION + { + return Err(DriverError::Protocol { + reason: format!( + "remote Driver envelope version {} is outside carrier range {}..={}", + DRIVER_ENVELOPE_VERSION, + capabilities.minimum_envelope_version, + capabilities.maximum_envelope_version + ), + }); + } + if !capabilities.supports_cancellation { + return Err(DriverError::Protocol { + reason: "remote Driver carrier does not support request cancellation".into(), + }); + } + Ok(()) +} + +struct RemoteCancellationGuard { + channel: Arc, + request_id: Option, +} + +impl RemoteCancellationGuard { + fn new(channel: Arc, request_id: String) -> Self { + Self { + channel, + request_id: Some(request_id), + } + } + + fn disarm(&mut self) { + self.request_id = None; + } +} + +impl Drop for RemoteCancellationGuard { + fn drop(&mut self) { + let Some(request_id) = self.request_id.take() else { + return; + }; + let channel = self.channel.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let _ = channel.cancel(&request_id).await; + }); + } else { + std::thread::spawn(move || { + if let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + let _ = runtime.block_on(channel.cancel(&request_id)); + } + }); + } + } +} + fn now_unix_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/src/runtime.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/src/runtime.rs index 800f571282..b16a489cfa 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/src/runtime.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/src/runtime.rs @@ -36,6 +36,9 @@ pub(crate) enum RuntimeCreateError { AlreadyExists, #[error("invalid runtime authorization configuration: {0}")] Authorization(String), + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + #[error("runtime_unavailable: {0}")] + Unavailable(String), } struct RuntimeOwnershipGuard { @@ -68,6 +71,10 @@ impl Drop for RuntimeOwnershipGuard { #[derive(Debug, Clone)] pub(crate) struct RuntimeOptions { pub cursor: CursorConfig, + /// Whether the importing/embedding host owns macOS permission UX. Such a + /// runtime may inspect TCC state but must never raise Cua-owned prompts. + pub host_owns_permission_ux: bool, + pub host_bundle_id: Option, pub compatibility_mode: bool, #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub prepare_desktop_environment: bool, @@ -83,6 +90,8 @@ impl RuntimeOptions { enabled: false, ..CursorConfig::default() }, + host_owns_permission_ux: true, + host_bundle_id: None, compatibility_mode, prepare_desktop_environment: true, register_host_tools: None, @@ -183,6 +192,12 @@ pub(crate) struct DriverRuntime { impl DriverRuntime { pub(crate) fn create(options: RuntimeOptions) -> Result, RuntimeCreateError> { + #[cfg(target_os = "windows")] + if let Err(reason) = platform_windows::diagnostics::interactive_desktop_check() { + return Err(RuntimeCreateError::Unavailable(format!( + "Cua Driver requires an interactive Windows user session: {reason}" + ))); + } let authorization_registry = Arc::new(match options.authorization_ceiling.clone() { Some(ceiling) => SessionAuthorizationRegistry::with_ceiling(ceiling), None => SessionAuthorizationRegistry::process() @@ -384,6 +399,16 @@ fn register_recording_session_end_hook(registry: &Arc) { }); } +/// Build the canonical SDK tool inventory without acquiring runtime ownership. +/// +/// This metadata-only path cannot dispatch actions and therefore remains +/// available when the host has no interactive desktop (for example Windows +/// Session 0). Finite CLI inspection commands use it to preserve their +/// desktop-free compatibility contract without weakening runtime admission. +pub(crate) fn tool_inventory(options: RuntimeOptions) -> Value { + build_registry(&options).tools_list() +} + fn build_registry(options: &RuntimeOptions) -> ToolRegistry { #[cfg(target_os = "macos")] let mut registry = { @@ -391,6 +416,8 @@ fn build_registry(options: &RuntimeOptions) -> ToolRegistry { platform_macos::register_tools_with_cursor( options.cursor.clone(), options.compatibility_mode, + options.host_owns_permission_ux, + options.host_bundle_id.clone(), ) }; diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/src/service_session.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/src/service_session.rs index 2fbe21dc1e..18eefe3d87 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/src/service_session.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/src/service_session.rs @@ -36,6 +36,14 @@ impl ServiceSessionClient { options: TrustedSessionOptions, client_kind: DaemonClientKind, ) -> Result, DriverError> { + let metadata = + cua_driver_core::daemon::request_daemon_metadata(&socket_path).map_err(|error| { + DriverError::Transport { + socket_path: socket_path.clone(), + reason: format!("read service compatibility metadata: {error}"), + } + })?; + crate::validate_daemon_metadata(&metadata)?; let stream = connect(&socket_path)?; let writer = stream.try_clone().map_err(|error| DriverError::Transport { socket_path: socket_path.clone(), @@ -298,6 +306,25 @@ mod tests { use crate::{SessionPermissionMode, TrustedSessionOptions}; use std::os::unix::net::UnixListener; + fn serve_compatible_metadata(listener: &UnixListener) { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let request: DaemonRequest = serde_json::from_str(&line).unwrap(); + assert_eq!(request.method, "metadata"); + let mut writer = stream; + writeln!( + writer, + "{}", + serde_json::to_string(&DaemonResponse::ok( + serde_json::to_value(cua_driver_core::daemon::current_daemon_metadata()).unwrap() + )) + .unwrap() + ) + .unwrap(); + } + #[test] fn response_reader_preserves_partial_utf8_across_poll_timeouts() { let (reader, mut writer) = std::os::unix::net::UnixStream::pair().unwrap(); @@ -324,6 +351,7 @@ mod tests { let socket = directory.path().join("service.sock"); let listener = UnixListener::bind(&socket).unwrap(); let server = std::thread::spawn(move || { + serve_compatible_metadata(&listener); let (stream, _) = listener.accept().unwrap(); let mut reader = BufReader::new(stream.try_clone().unwrap()); let mut writer = stream; @@ -377,6 +405,7 @@ mod tests { let socket = directory.path().join("delayed-service.sock"); let listener = UnixListener::bind(&socket).unwrap(); let server = std::thread::spawn(move || { + serve_compatible_metadata(&listener); let (stream, _) = listener.accept().unwrap(); let mut reader = BufReader::new(stream.try_clone().unwrap()); let mut writer = stream; diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/tests/runtime_configuration.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/tests/runtime_configuration.rs index c547071038..2424953d7b 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/tests/runtime_configuration.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/tests/runtime_configuration.rs @@ -33,6 +33,9 @@ fn child_configuration_probe() { SessionPermissionMode::Standard, SessionPermissionMode::Unrestricted, ])), + "legacy_approval" => { + CuaDriver::create_configured(options(vec![SessionPermissionMode::Standard])) + } other => panic!("unknown probe case {other}"), }; @@ -42,7 +45,7 @@ fn child_configuration_probe() { panic!("matching environment was rejected: {error}"); } } - "conflicting_mode" | "managed_disable" => { + "conflicting_mode" | "managed_disable" | "legacy_approval" => { assert!(result.is_err(), "contradictory environment was accepted") } _ => unreachable!(), @@ -57,6 +60,7 @@ fn run_probe(case: &str, environment: &[(&str, &str)]) { .env_remove("CUA_DRIVER_PERMISSION_MODE") .env_remove("CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS") .env_remove("CUA_DRIVER_DISABLE_UNRESTRICTED") + .env_remove("CUA_DRIVER_ALLOW_LEGACY_EXISTING_PROFILE_APPROVAL") .env_remove("CUA_DRIVER_SESSION_POLICY_FILE") .env_remove("CUA_DRIVER_SESSION_POLICY_APPROVED"); for (name, value) in environment { @@ -85,4 +89,8 @@ fn explicit_runtime_configuration_rejects_contradictory_environment() { "managed_disable", &[("CUA_DRIVER_DISABLE_UNRESTRICTED", "1")], ); + run_probe( + "legacy_approval", + &[("CUA_DRIVER_ALLOW_LEGACY_EXISTING_PROFILE_APPROVAL", "1")], + ); } diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/raw.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/raw.rs index 6bc9fac859..6bce1b81f3 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/raw.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/raw.rs @@ -64,6 +64,18 @@ impl RawDriver { /// so this helper is available only where bare `mcp` owns its runtime. #[cfg(not(target_os = "macos"))] pub fn spawn_direct() -> Option { + Self::spawn_direct_with_args(&["mcp"]) + } + + /// Spawn the explicitly selected direct MCP runtime without a service. + /// + /// Unlike [`Self::spawn_direct`], this is available on macOS because the + /// caller has deliberately opted out of the signed app/service default. + pub fn spawn_explicit_direct() -> Option { + Self::spawn_direct_with_args(&["mcp", "--direct"]) + } + + fn spawn_direct_with_args(args: &[&str]) -> Option { let bin = driver_binary(); if !bin.exists() { eprintln!("[testkit] driver binary not built at {bin:?} — skipping"); @@ -72,7 +84,7 @@ impl RawDriver { let mut reaper = ChildReaper::new(); let mut command = Command::new(&bin); command - .arg("mcp") + .args(args) .env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "false") .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/libs/cua-driver/rust/crates/cua-driver-uia/src/lib.rs b/libs/cua-driver/rust/crates/cua-driver-uia/src/lib.rs new file mode 100644 index 0000000000..ab6a515508 --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver-uia/src/lib.rs @@ -0,0 +1,78 @@ +//! Testable authorization policy for the Windows UIAccess worker. +//! +//! The production binary carries a `uiAccess=true` manifest and therefore +//! cannot be launched by an unelevated test runner. Keeping the pure policy in +//! this library lets CI execute the security checks without weakening the +//! production manifest. + +#[cfg(target_os = "windows")] +pub fn authorized_parent_pid_from(args: I) -> anyhow::Result +where + I: IntoIterator, +{ + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + if arg == "--authorized-parent-pid" { + let value = args + .next() + .ok_or_else(|| anyhow::anyhow!("--authorized-parent-pid requires a value"))?; + let pid = value.parse::().map_err(|_| { + anyhow::anyhow!("--authorized-parent-pid must be a positive process id") + })?; + if pid == 0 { + anyhow::bail!("--authorized-parent-pid must be a positive process id"); + } + return Ok(pid); + } + } + anyhow::bail!( + "missing --authorized-parent-pid; the UIAccess worker may only be launched by cua-driver serve" + ) +} + +#[cfg(target_os = "windows")] +pub fn client_identity_is_authorized( + client_identity: Option<(u32, &str)>, + authorized_parent_pid: u32, + owner_sid: &str, +) -> bool { + client_identity == Some((authorized_parent_pid, owner_sid)) +} + +#[cfg(all(test, target_os = "windows"))] +mod authorization_tests { + use super::*; + + #[test] + fn launch_requires_explicit_parent_pid() { + assert!(authorized_parent_pid_from(Vec::::new()).is_err()); + assert!( + authorized_parent_pid_from(vec!["--authorized-parent-pid".into(), "0".into()]).is_err() + ); + assert_eq!( + authorized_parent_pid_from(vec!["--authorized-parent-pid".into(), "4242".into()]) + .unwrap(), + 4242 + ); + } + + #[test] + fn client_must_match_both_exact_parent_and_owner_sid() { + assert!(client_identity_is_authorized( + Some((4242, "S-1-5-21-123")), + 4242, + "S-1-5-21-123" + )); + assert!(!client_identity_is_authorized( + Some((4243, "S-1-5-21-123")), + 4242, + "S-1-5-21-123" + )); + assert!(!client_identity_is_authorized( + Some((4242, "S-1-5-21-999")), + 4242, + "S-1-5-21-123" + )); + assert!(!client_identity_is_authorized(None, 4242, "S-1-5-21-123")); + } +} diff --git a/libs/cua-driver/rust/crates/cua-driver-uia/src/main.rs b/libs/cua-driver/rust/crates/cua-driver-uia/src/main.rs index 87bfc87428..baaea6b888 100644 --- a/libs/cua-driver/rust/crates/cua-driver-uia/src/main.rs +++ b/libs/cua-driver/rust/crates/cua-driver-uia/src/main.rs @@ -1,8 +1,9 @@ // cua-driver-uia: Windows uiAccess-elevated tool worker. // // Listens on \\.\pipe\cua-driver-uia for line-delimited JSON requests with the -// same shape as cua-driver's daemon pipe (\\.\pipe\cua-driver), so cua-driver's -// CLI and MCP server can prefer this worker on Windows for UIPI-blocked ops. +// same shape as cua-driver's daemon pipe (\\.\pipe\cua-driver). This is a +// daemon-internal privilege boundary: public CLI/MCP clients must enter through +// the canonical daemon authorization path and cannot call this worker directly. // // Protocol (one JSON object per line, both directions): // request : {"method":"call","name":"","args":{...}} @@ -12,8 +13,9 @@ // response: {"ok":true,"result":...} // {"ok":false,"error":"...","exit_code":N} // -// The protocol is intentionally byte-identical to cua-driver/serve.rs so that -// the existing client code in cli.rs::run_call can talk to either pipe. +// The protocol is intentionally byte-identical to cua-driver/serve.rs for a +// future authorized parent-daemon forwarding path. No public client route is +// exposed while that forwarding path is unavailable. #[cfg(not(target_os = "windows"))] fn main() { @@ -214,7 +216,7 @@ unsafe fn current_user_sid_string() -> Option { } #[cfg(target_os = "windows")] -unsafe fn named_pipe_client_sid(pipe: *mut std::ffi::c_void) -> Option { +unsafe fn named_pipe_client_identity(pipe: *mut std::ffi::c_void) -> Option<(u32, String)> { #[link(name = "kernel32")] extern "system" { fn GetNamedPipeClientProcessId( @@ -253,7 +255,7 @@ unsafe fn named_pipe_client_sid(pipe: *mut std::ffi::c_void) -> Option { if opened == 0 || token.is_null() { return None; } - token_user_sid(token) + token_user_sid(token).map(|sid| (client_process_id, sid)) } #[cfg(target_os = "windows")] @@ -261,6 +263,42 @@ fn current_user_pipe_sddl(sid: &str) -> String { format!("D:P(A;;GA;;;{sid})S:(ML;;NW;;;LW)") } +#[cfg(target_os = "windows")] +fn authorized_parent_pid_from_args() -> anyhow::Result { + cua_driver_uia::authorized_parent_pid_from(std::env::args().skip(1)) +} + +#[cfg(target_os = "windows")] +unsafe fn exit_when_authorized_parent_exits(parent_pid: u32) -> anyhow::Result<()> { + #[link(name = "kernel32")] + extern "system" { + fn OpenProcess( + desired_access: u32, + inherit_handle: i32, + process_id: u32, + ) -> *mut std::ffi::c_void; + fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32; + fn CloseHandle(handle: *mut std::ffi::c_void) -> i32; + } + const SYNCHRONIZE: u32 = 0x0010_0000; + const INFINITE: u32 = 0xffff_ffff; + const WAIT_OBJECT_0: u32 = 0; + let parent = OpenProcess(SYNCHRONIZE, 0, parent_pid); + if parent.is_null() { + anyhow::bail!("open authorized parent process {parent_pid}"); + } + let parent_handle = parent as usize; + std::thread::spawn(move || { + let parent = parent_handle as *mut std::ffi::c_void; + let wait = unsafe { WaitForSingleObject(parent, INFINITE) }; + let _ = unsafe { CloseHandle(parent) }; + if wait == WAIT_OBJECT_0 { + std::process::exit(0); + } + }); + Ok(()) +} + #[cfg(target_os = "windows")] fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -271,14 +309,16 @@ fn main() -> anyhow::Result<()> { .with_writer(std::io::stderr) .init(); + let authorized_parent_pid = authorized_parent_pid_from_args()?; + unsafe { exit_when_authorized_parent_exits(authorized_parent_pid)? }; let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; - rt.block_on(async_main()) + rt.block_on(async_main(authorized_parent_pid)) } #[cfg(target_os = "windows")] -async fn async_main() -> anyhow::Result<()> { +async fn async_main(authorized_parent_pid: u32) -> anyhow::Result<()> { use std::os::windows::io::AsRawHandle as _; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::windows::named_pipe::ServerOptions; @@ -309,10 +349,20 @@ async fn async_main() -> anyhow::Result<()> { .await .map_err(|e| anyhow::anyhow!("named pipe connect: {e}"))?; - let client_sid = - unsafe { named_pipe_client_sid(server.as_raw_handle().cast::()) }; - if client_sid.as_deref() != Some(owner_sid.as_str()) { - tracing::warn!("UIAccess named-pipe connection rejected before request parsing"); + let client_identity = unsafe { + named_pipe_client_identity(server.as_raw_handle().cast::()) + }; + if !cua_driver_uia::client_identity_is_authorized( + client_identity + .as_ref() + .map(|(pid, sid)| (*pid, sid.as_str())), + authorized_parent_pid, + &owner_sid, + ) { + tracing::warn!( + expected_parent_pid = authorized_parent_pid, + "UIAccess named-pipe connection rejected before request parsing" + ); let _ = server.disconnect(); continue; } diff --git a/libs/cua-driver/rust/crates/cua-driver/src/autostart.rs b/libs/cua-driver/rust/crates/cua-driver/src/autostart.rs index 8e16d50fc4..63aebb60db 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/autostart.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/autostart.rs @@ -225,13 +225,10 @@ $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoi Unregister-ScheduledTask -TaskName $env:CUA_DRIVER_AS_TASK -Confirm:$false -ErrorAction SilentlyContinue Register-ScheduledTask -TaskName $env:CUA_DRIVER_AS_TASK -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description "${env:CUA_DRIVER_AS_CLI}: serve daemon, auto-start at interactive logon, RunLevel=Highest for UWP/AppContainer support" | Out-Null -# Note: the uiAccess'd worker (`cua-driver-uia.exe`) does NOT get its own -# scheduled task. uiAccess PEs can only be launched via ShellExecute, and -# Task Scheduler's PowerShell-wrapper Action path returns ERROR_NOT_LOGGED_ON -# (0x800710E0). Instead, `cua-driver serve` itself spawns the sibling worker -# via ShellExecuteEx at startup (see serve.rs `maybe_spawn_uia_worker`), -# which works because the spawn originates from a Session-2 process with an -# interactive desktop. See #1602. +# The uiAccess worker (`cua-driver-uia.exe`) has no public client route and is +# not started by this task. The High-IL daemon is the supported path for +# elevated/AppContainer pixel input. A future worker path must be forwarded by +# the authorized parent daemon rather than exposed to public clients. See #1602. "#; pub fn enable(exe: &str) -> Result<()> { @@ -317,8 +314,8 @@ Register-ScheduledTask -TaskName $env:CUA_DRIVER_AS_TASK -Action $action -Trigge pub fn disable() -> Result<()> { // Tear down the legacy release `cua-driver-uia` task only when // managing the release product. Older versions registered a separate - // task for the worker; current versions spawn it from serve.rs. Local - // management must never alter that release task. + // task for the worker; current versions retain no worker autostart. + // Local management must never alter that release task. if !crate::bundle::is_local_installation() { let _ = Command::new("schtasks") .args(["/Delete", "/TN", "cua-driver-uia", "/F"]) diff --git a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs index 87852b8c06..1b5c261773 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs @@ -21,6 +21,9 @@ pub enum Command { /// and Linux own a direct runtime while macOS uses the default app /// daemon to preserve TCC attribution. socket: Option, + /// Own the runtime in this MCP process. On macOS this is an explicit + /// TCC-attribution choice; it is mutually exclusive with `--socket`. + direct: bool, /// `--claude-code-computer-use-compat`: register the compat /// `screenshot` tool (window-scoped, JPEG @ 85%, pid + window_id /// both required) instead of the full-featured one. Used when @@ -491,9 +494,13 @@ pub fn parse_command() -> Command { println!(" Revocation is deny-only and never needs a token."); println!(); println!("mcp options:"); - println!(" --embedded Connect to a daemon spawned by the host app (also:"); - println!(" CUA_DRIVER_EMBEDDED=1). Embedded hosts must start"); - println!(" `cua-driver serve --embedded` before the MCP proxy."); + println!(" --direct Own the runtime in this MCP process. On macOS this"); + println!(" deliberately attributes TCC to the invoking host."); + println!(" Mutually exclusive with --socket."); + println!(" --embedded Declare embedding-host mode (also:"); + println!(" CUA_DRIVER_EMBEDDED=1). Without --direct, the host"); + println!(" must start `cua-driver serve --embedded` and pass"); + println!(" its private endpoint with --socket."); println!(" See Skills/cua-driver/EMBEDDING.md."); println!( " --host-bundle-id Advisory host bundle id label for check_permissions output." @@ -645,11 +652,13 @@ pub fn parse_command() -> Command { } Command::Mcp { socket: socket.clone(), + direct: args.iter().any(|a| a == "--direct"), claude_code_compat, } } Some("mcp") => Command::Mcp { socket: socket.clone(), + direct: args.iter().any(|a| a == "--direct"), claude_code_compat, }, Some("list-tools") => Command::ListTools, @@ -1252,27 +1261,10 @@ where F: FnOnce(McpDaemonStartup, bool), { let mut on_startup = Some(on_startup); - // Windows: prefer the uiAccess'd worker pipe over the regular daemon pipe - // when both are running, so MCP tool calls land in a process that can - // bypass UIPI for UWP apps. The protocol on both pipes is identical so - // the proxy doesn't need to know which one it's talking to. See #1602. - let socket_path = if let Some(s) = socket { - s - } else { - #[cfg(target_os = "windows")] - { - let uia = crate::serve::default_uia_pipe_path(); - if crate::serve::is_daemon_listening(&uia) { - uia - } else { - crate::serve::default_socket_path() - } - } - #[cfg(not(target_os = "windows"))] - { - crate::serve::default_socket_path() - } - }; + // The UIAccess helper is a daemon-internal privilege boundary. Public MCP + // clients always enter through the canonical service authorization path; + // they must never select the helper merely because its pipe exists. + let socket_path = socket.unwrap_or_else(crate::serve::default_socket_path); let already_running = crate::serve::is_daemon_listening(&socket_path); let mut daemon = McpDaemonStartup::AlreadyRunning; @@ -1402,8 +1394,9 @@ pub fn build_manifest() -> serde_json::Value { "description": "Run the MCP stdio server: direct runtime on Windows/Linux, app-daemon proxy on macOS, or explicit service with --socket.", "args": [ { "name": "--socket", "type": "string", "description": "Select an explicit daemon socket or named-pipe endpoint." }, + { "name": "--direct", "type": "flag", "description": "Own the runtime in the MCP process; on macOS this explicitly accepts host TCC attribution. Mutually exclusive with --socket." }, { "name": "--claude-code-computer-use-compat", "type": "flag", "description": "Select the Claude Code computer-use compat tool surface." }, - { "name": "--embedded", "type": "flag", "description": "Require a daemon spawned by the embedding host instead of auto-launching the standalone app." }, + { "name": "--embedded", "type": "flag", "description": "Declare embedding-host mode. Without --direct, requires the host's private service through --socket instead of auto-launching the standalone app." }, { "name": "--host-bundle-id", "type": "string", "description": "Advisory host bundle id label echoed in check_permissions output." } ] }, { "name": "serve", @@ -1745,41 +1738,78 @@ pub fn run_mcp_config(client: Option<&str>) { /// instead of emitted as base64 on stdout. /// /// `socket` — override the daemon socket path (from --socket flag). +fn ensure_compatible_daemon(socket_path: &str) -> Result<(), String> { + let driver = cua_driver_sdk::CuaDriver::connect(Some(socket_path.to_owned())) + .map_err(|error| error.to_string())?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("create compatibility runtime: {error}"))?; + runtime + .block_on(driver.metadata()) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn require_compatible_daemon(socket_path: &str) { + if let Err(error) = ensure_compatible_daemon(socket_path) { + eprintln!("Cua Driver daemon on {socket_path} is incompatible: {error}"); + process::exit(1); + } +} + +#[cfg(all(test, unix))] +mod daemon_compatibility_tests { + use super::ensure_compatible_daemon; + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixListener; + + #[test] + fn incompatible_daemon_is_refused_before_a_cli_action_can_dispatch() { + let directory = tempfile::tempdir().unwrap(); + let socket = directory.path().join("driver.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut request_line = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut request_line) + .unwrap(); + let request: serde_json::Value = serde_json::from_str(&request_line).unwrap(); + assert_eq!(request["method"], "metadata"); + + let mut metadata = cua_driver_core::daemon::current_daemon_metadata(); + metadata.contract_version = "incompatible-test-contract".into(); + let mut writer = stream; + writeln!( + writer, + "{}", + serde_json::json!({"ok": true, "result": metadata}) + ) + .unwrap(); + }); + + let error = ensure_compatible_daemon(socket.to_str().unwrap()).unwrap_err(); + assert!(error.contains("incompatible daemon"), "{error}"); + server.join().unwrap(); + } +} + pub fn run_call( tool: &str, json_args: Option, screenshot_out_file: Option, socket_override: Option, ) { - // All public tool execution is daemon-backed so policy, session state, + // One-shot public calls remain service-backed so policy, session state, // AppStateEngine caches, and platform identity have one enforcement point. - // - // On Windows, prefer the uiAccess-elevated worker (cua-driver-uia.exe) when - // present — it runs at UIAccess integrity and bypasses UIPI for UWP apps - // like Calculator / modern Notepad / Settings. The regular daemon at - // `\\.\pipe\cua-driver` is Medium integrity and gets ERROR_ACCESS_DENIED on - // SendInput into AppContainer'd processes. See #1602. + // The Windows UIAccess helper is daemon-internal: routing an untrusted CLI + // directly to it would bypass this authorization path. // // When `socket_override` is Some (i.e. caller passed `--socket `), - // route directly to that path and skip the platform default + uia worker - // search. Used by integration tests to drive a tempfile-socketed daemon. - let socket_path = if let Some(s) = socket_override { - s - } else { - #[cfg(target_os = "windows")] - { - let uia = crate::serve::default_uia_pipe_path(); - if crate::serve::is_daemon_listening(&uia) { - uia - } else { - crate::serve::default_socket_path() - } - } - #[cfg(not(target_os = "windows"))] - { - crate::serve::default_socket_path() - } - }; + // route directly to that path and skip the platform default. Used by + // integration tests to drive a tempfile-socketed daemon. + let socket_path = socket_override.unwrap_or_else(crate::serve::default_socket_path); if !crate::serve::is_daemon_listening(&socket_path) { eprintln!( "Cua Driver daemon is not running on {socket_path}.\n\ @@ -1787,6 +1817,7 @@ pub fn run_call( ); process::exit(1); } + require_compatible_daemon(&socket_path); { let mut args_for_daemon = json_args @@ -1928,6 +1959,7 @@ pub fn run_recording_cmd(subcommand: &str, args: &[String], socket: Option<&str> ); process::exit(1); } + require_compatible_daemon(&socket_path); match subcommand { "start" => { @@ -2741,15 +2773,16 @@ fn cli_docs_json() -> serde_json::Value { { "name": "mcp", "abstract": "Run the stdio MCP server.", - "discussion": "On Windows and Linux, bare cua-driver mcp owns its runtime directly and shuts it down on stdin EOF. On macOS it proxies to CuaDriver.app so desktop permissions retain the app identity. Pass --socket to select an explicit daemon endpoint.", + "discussion": "On Windows and Linux, bare cua-driver mcp owns its runtime directly and shuts it down on stdin EOF. On macOS it proxies to CuaDriver.app so desktop permissions retain the app identity. Pass --direct to make the macOS MCP process own the runtime and TCC attribution, or --socket to select an explicit daemon endpoint.", "arguments": no_args, "options": [ {"name":"socket","short_name":null,"help":"Select an explicit daemon socket or named-pipe endpoint.","type":"String","default_value":null,"is_optional":true}, {"name":"host-bundle-id","short_name":null,"help":"Advisory host bundle id label echoed in check_permissions output (embedded mode).","type":"String","default_value":null,"is_optional":true} ], "flags": [ + {"name":"direct","short_name":null,"help":"Own the runtime in this MCP process; mutually exclusive with --socket.","default_value":false}, {"name":"claude-code-computer-use-compat","short_name":null,"help":"Expose the Claude Code computer-use compatibility screenshot surface.","default_value":false}, - {"name":"embedded","short_name":null,"help":"Require a daemon spawned by the embedding host instead of auto-launching the standalone app.","default_value":false} + {"name":"embedded","short_name":null,"help":"Declare embedding-host mode. Without --direct, require the host's private service through --socket instead of auto-launching the standalone app.","default_value":false} ], "subcommands": no_subcommands }, @@ -3329,6 +3362,7 @@ pub fn run_config_cmd( ); process::exit(1); } + require_compatible_daemon(&socket_path); let call = |tool: &str, args: serde_json::Value| -> serde_json::Value { let req = crate::serve::DaemonRequest { diff --git a/libs/cua-driver/rust/crates/cua-driver/src/main.rs b/libs/cua-driver/rust/crates/cua-driver/src/main.rs index ba6a69802f..efcba682fc 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/main.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/main.rs @@ -252,16 +252,19 @@ fn maybe_init_pip() { fn build_driver( cursor: cursor_overlay::CursorConfig, compatibility_mode: bool, -) -> Arc { + host_owns_permission_ux: bool, +) -> Result, cua_driver_sdk::DriverError> { cua_driver_sdk::CuaDriver::try_create_service_for_host(cua_driver_sdk::DriverHostOptions { cursor, + host_owns_permission_ux, + host_bundle_id: std::env::var(cua_driver_core::HOST_BUNDLE_ID_ENV).ok(), claude_code_compatibility: compatibility_mode, prepare_desktop_environment: true, register_host_tools: Some(check_update_tool::register_into), }) - .expect("CLI host attempted to create a second Cua Driver runtime in one process") } +#[cfg(test)] fn build_driver_without_cursor() -> Arc { build_driver( cursor_overlay::CursorConfig { @@ -269,7 +272,28 @@ fn build_driver_without_cursor() -> Arc { ..cursor_overlay::CursorConfig::default() }, false, + false, ) + .expect("test host requires an available desktop runtime") +} + +/// Load the canonical SDK inventory without constructing an action runtime. +/// +/// Finite metadata commands remain usable from non-interactive Windows +/// sessions, while `serve`, MCP, and direct SDK creation still fail closed +/// before accepting desktop actions. +fn inspect_tools_without_runtime() -> serde_json::Value { + cua_driver_sdk::CuaDriver::inspect_host_tools(cua_driver_sdk::DriverHostOptions { + cursor: cursor_overlay::CursorConfig { + enabled: false, + ..cursor_overlay::CursorConfig::default() + }, + host_owns_permission_ux: false, + host_bundle_id: None, + claude_code_compatibility: false, + prepare_desktop_environment: false, + register_host_tools: Some(check_update_tool::register_into), + }) } #[cfg(test)] @@ -283,21 +307,31 @@ fn run_mcp_direct(compatibility_mode: bool) -> anyhow::Result<()> { // adapter repeats this check before reading stdin as defense in depth. cua_driver_core::authorization::validate_startup_authorization()?; cua_driver_core::policy::validate_configured_policy()?; - let driver = build_driver( - cursor_overlay::CursorConfig::from_args(), - compatibility_mode, - ); + let cursor = cursor_overlay::CursorConfig::from_args(); + // A plain stdio MCP process does not provide the certified AppKit + // main-thread host adapter. Explicit direct mode on macOS must therefore + // expose facility_unavailable instead of initializing an overlay that can + // report success without a usable UI owner. Private-worker and app-service + // hosts keep the full facility. + #[cfg(target_os = "macos")] + let cursor = { + let mut cursor = cursor; + cursor.enabled = false; + cursor + }; + let driver = build_driver(cursor, compatibility_mode, true)?; let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; runtime.block_on(proxy::run_direct(driver)) } -fn mcp_uses_direct_runtime(socket: Option<&str>) -> anyhow::Result { +fn mcp_uses_direct_runtime(socket: Option<&str>, direct: bool) -> anyhow::Result { mcp_uses_direct_runtime_for( cua_driver_core::embedded_mode(), socket, cfg!(target_os = "macos"), + direct, ) } @@ -305,7 +339,14 @@ fn mcp_uses_direct_runtime_for( embedded: bool, socket: Option<&str>, macos: bool, + direct: bool, ) -> anyhow::Result { + if direct && socket.is_some() { + anyhow::bail!("--direct and --socket are mutually exclusive"); + } + if direct { + return Ok(true); + } if embedded && socket.is_none() { anyhow::bail!("embedded hosts must provide their private service endpoint with --socket"); } @@ -317,38 +358,37 @@ fn mcp_uses_direct_runtime_for( } } -fn sdk_tool_inventory(driver: Arc) -> serde_json::Value { - match sdk_adapter::SdkAdapter::load_blocking(driver) { - Ok(sdk) => sdk.tools_list(), - Err(error) => { - eprintln!("Could not load Cua Driver SDK tool inventory: {error}"); - std::process::exit(1); - } - } -} - #[cfg(test)] mod mcp_runtime_selection_tests { use super::mcp_uses_direct_runtime_for; #[test] fn embedded_host_without_private_endpoint_fails_closed() { - let error = mcp_uses_direct_runtime_for(true, None, false).unwrap_err(); + let error = mcp_uses_direct_runtime_for(true, None, false, false).unwrap_err(); assert!(error.to_string().contains("--socket")); - let error = mcp_uses_direct_runtime_for(true, None, true).unwrap_err(); + let error = mcp_uses_direct_runtime_for(true, None, true, false).unwrap_err(); assert!(error.to_string().contains("--socket")); } #[test] fn normal_linux_and_windows_stdio_own_the_runtime() { - assert!(mcp_uses_direct_runtime_for(false, None, false).unwrap()); - assert!(!mcp_uses_direct_runtime_for(false, Some("service"), false).unwrap()); + assert!(mcp_uses_direct_runtime_for(false, None, false, false).unwrap()); + assert!(!mcp_uses_direct_runtime_for(false, Some("service"), false, false).unwrap()); } #[test] fn normal_macos_stdio_preserves_the_service_boundary() { - assert!(!mcp_uses_direct_runtime_for(false, None, true).unwrap()); - assert!(!mcp_uses_direct_runtime_for(false, Some("service"), true).unwrap()); + assert!(!mcp_uses_direct_runtime_for(false, None, true, false).unwrap()); + assert!(!mcp_uses_direct_runtime_for(false, Some("service"), true, false).unwrap()); + } + + #[test] + fn explicit_direct_owns_the_runtime_on_macos_and_in_embedded_hosts() { + assert!(mcp_uses_direct_runtime_for(false, None, true, true).unwrap()); + assert!(mcp_uses_direct_runtime_for(true, None, true, true).unwrap()); + assert!(mcp_uses_direct_runtime_for(true, None, false, true).unwrap()); + let error = mcp_uses_direct_runtime_for(false, Some("service"), true, true).unwrap_err(); + assert!(error.to_string().contains("mutually exclusive")); } } @@ -400,11 +440,11 @@ fn main() { run_telemetry_command(command); } cli::Command::ListTools => { - let tools = sdk_tool_inventory(build_driver_without_cursor()); + let tools = inspect_tools_without_runtime(); cli::run_list_tools(&tools); } cli::Command::Describe(name) => { - let tools = sdk_tool_inventory(build_driver_without_cursor()); + let tools = inspect_tools_without_runtime(); cli::run_describe(&tools, &name); } cli::Command::McpConfig { client } => { @@ -484,7 +524,17 @@ fn main() { // --claude-code-computer-use-compat`). The Serve arm is the daemon // the proxy talks to, so without this the proxy path always served // the full screenshot tool regardless of the client's request. - let driver = build_driver(cursor_cfg.clone(), claude_code_compat); + let driver = match build_driver( + cursor_cfg.clone(), + claude_code_compat, + cua_driver_core::embedded_mode(), + ) { + Ok(driver) => driver, + Err(error) => { + eprintln!("cua-driver: cannot create desktop runtime: {error}"); + std::process::exit(1); + } + }; let sp = socket.unwrap_or_else(serve::default_socket_path); let pid_path = serve::default_pid_file_path(); @@ -616,7 +666,7 @@ fn main() { cli::run_recording_cmd(&subcommand, &args, socket.as_deref()); } cli::Command::DumpDocs { pretty, doc_type } => { - let tools = sdk_tool_inventory(build_driver_without_cursor()); + let tools = inspect_tools_without_runtime(); cli::run_dump_docs_with_type(&tools, pretty, &doc_type); } cli::Command::Update { apply, json } => { @@ -679,13 +729,14 @@ fn main() { } cli::Command::Mcp { socket, + direct, claude_code_compat, } => { let startup_started = std::time::Instant::now(); // Long-running MCP proxy — kick off the background update check // before connecting to or launching the daemon. version_check::maybe_announce_update(); - let result = match mcp_uses_direct_runtime(socket.as_deref()) { + let result = match mcp_uses_direct_runtime(socket.as_deref(), direct) { Ok(true) => { telemetry::capture_mcp_startup_completed( "sdk_owned_runtime", @@ -750,12 +801,12 @@ fn main() -> anyhow::Result<()> { return Ok(()); } cli::Command::ListTools => { - let tools = sdk_tool_inventory(build_driver_without_cursor()); + let tools = inspect_tools_without_runtime(); cli::run_list_tools(&tools); return Ok(()); } cli::Command::Describe(name) => { - let tools = sdk_tool_inventory(build_driver_without_cursor()); + let tools = inspect_tools_without_runtime(); cli::run_describe(&tools, &name); return Ok(()); } @@ -810,7 +861,11 @@ fn main() -> anyhow::Result<()> { let _ = no_permissions_gate; // Serve mode needs the cursor overlay just like MCP mode. let cursor_cfg = cursor_overlay::CursorConfig::from_args(); - let driver = build_driver(cursor_cfg, claude_code_compat); + let driver = build_driver( + cursor_cfg, + claude_code_compat, + cua_driver_core::embedded_mode(), + )?; maybe_init_pip(); let sp = socket.unwrap_or_else(serve::default_socket_path); let pid_path = serve::default_pid_file_path(); @@ -851,7 +906,7 @@ fn main() -> anyhow::Result<()> { return Ok(()); } cli::Command::DumpDocs { pretty, doc_type } => { - let tools = sdk_tool_inventory(build_driver_without_cursor()); + let tools = inspect_tools_without_runtime(); cli::run_dump_docs_with_type(&tools, pretty, &doc_type); return Ok(()); } @@ -923,13 +978,14 @@ fn main() -> anyhow::Result<()> { } cli::Command::Mcp { socket, + direct, claude_code_compat, } => { let startup_started = std::time::Instant::now(); // Long-running MCP proxy — kick off the background update check // before connecting to the daemon. version_check::maybe_announce_update(); - let result = match mcp_uses_direct_runtime(socket.as_deref()) { + let result = match mcp_uses_direct_runtime(socket.as_deref(), direct) { Ok(true) => { telemetry::capture_mcp_startup_completed( "sdk_owned_runtime", diff --git a/libs/cua-driver/rust/crates/cua-driver/src/private_worker.rs b/libs/cua-driver/rust/crates/cua-driver/src/private_worker.rs index ab0f929cc6..d26c746f82 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/private_worker.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/private_worker.rs @@ -115,11 +115,12 @@ async fn run_async( )?; return Ok(()); } - let driver = match CuaDriver::try_create_configured_for_host( initialization.configured_driver, DriverHostOptions { cursor: cursor_overlay::CursorConfig::default(), + host_owns_permission_ux: true, + host_bundle_id: Some(initialization.host_bundle_id.clone()), claude_code_compatibility: false, prepare_desktop_environment: true, register_host_tools: Some(crate::check_update_tool::register_into), diff --git a/libs/cua-driver/rust/crates/cua-driver/src/proxy.rs b/libs/cua-driver/rust/crates/cua-driver/src/proxy.rs index 38ad0a3487..f2623bbcdd 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/proxy.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/proxy.rs @@ -149,6 +149,11 @@ pub async fn run_proxy(socket_path: String) -> anyhow::Result<()> { with `open -n -g -a CuaDriver --args serve` and retry." ); } + // A selected service may outlive the CLI package that launched this + // proxy. Refuse an incompatible contract before creating the control + // binding or forwarding any action. + let compatibility_client = cua_driver_sdk::CuaDriver::connect(Some(socket_path.clone()))?; + compatibility_client.metadata().await?; // Mint this MCP session's identity once at proxy startup. One proxy process // == one MCP session; the daemon outlives it. We stamp this id on every diff --git a/libs/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs b/libs/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs index 874dd5812e..a510270988 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs @@ -28,13 +28,6 @@ impl SdkAdapter { Ok(Arc::new(Self { driver, tools_list })) } - pub fn load_blocking(driver: Arc) -> anyhow::Result> { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - runtime.block_on(Self::load(driver)) - } - pub fn tools_list(&self) -> Value { self.tools_list.clone() } diff --git a/libs/cua-driver/rust/crates/cua-driver/src/serve.rs b/libs/cua-driver/rust/crates/cua-driver/src/serve.rs index c90f6bb5a1..602f1f3450 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/serve.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/serve.rs @@ -1,6 +1,7 @@ -//! Unix-socket daemon server and client for `cua-driver serve`/`stop`/`status`. +//! Local daemon server and client for `cua-driver serve`/`stop`/`status`. //! -//! Protocol: line-delimited JSON over a Unix domain socket. +//! Protocol: line-delimited JSON over a Unix domain socket or Windows named +//! pipe. //! //! Request shapes: //! {"method":"call","name":"","args":{...}} @@ -15,7 +16,10 @@ //! The socket file is at: //! macOS — ~/Library/Caches/cua-driver/cua-driver.sock //! Linux — ~/.cache/cua-driver/cua-driver.sock -//! Windows — \\.\pipe\cua-driver (TODO: use named pipe; stubs only for now) +//! Windows — \\.\pipe\cua-driver +//! +//! Source-installed local builds use the corresponding `cua-driver-local` +//! namespace on every platform. use std::collections::HashSet; use std::sync::{Mutex, OnceLock}; @@ -113,14 +117,12 @@ pub fn default_socket_path() -> String { socket_path_for_namespace(crate::bundle::state_namespace()) } -/// On Windows, returns the named-pipe path of the uiAccess-elevated worker -/// (`cua-driver-uia.exe`). The main CLI/MCP binary can prefer this pipe over the -/// regular daemon pipe for the one path that genuinely needs UIAccess integrity: -/// **synthetic input (SendInput / pixel clicks) into AppContainer (UWP) windows**, -/// which UIPI blocks from a Medium-IL process. The element-action path (UIA -/// Invoke / ValuePattern driven by `element_index`) does NOT need the worker — it -/// drives real UWP apps (verified: Calculator num5Button 0→5) as-is from the -/// Medium-IL daemon. See #1602 / the `cua-driver-uia` crate for the worker side. +/// On Windows, returns the reserved named-pipe path of the uiAccess-elevated +/// worker (`cua-driver-uia.exe`). Public CLI, MCP, and SDK clients never connect +/// to this endpoint. It remains available only for a future daemon-internal +/// forwarding path that authenticates the exact parent process. Until that path +/// exists, elevated/AppContainer pixel input uses an interactively launched +/// High-IL daemon. See #1602 / the `cua-driver-uia` crate for the worker side. #[cfg(target_os = "windows")] pub fn default_uia_pipe_path() -> String { if crate::bundle::is_local_installation() { @@ -369,14 +371,11 @@ fn remove_owned_socket(socket_path: &str, identity: SocketIdentity) { } #[cfg(unix)] -fn secure_embedded_socket(socket_path: &str, embedded: bool) -> anyhow::Result<()> { - if !embedded { - return Ok(()); - } +fn secure_local_socket(socket_path: &str) -> anyhow::Result<()> { use std::os::unix::fs::PermissionsExt as _; let permissions = std::fs::Permissions::from_mode(0o600); std::fs::set_permissions(socket_path, permissions) - .map_err(|e| anyhow::anyhow!("secure embedded daemon socket {socket_path}: {e}")) + .map_err(|e| anyhow::anyhow!("secure local daemon socket {socket_path}: {e}")) } #[cfg(unix)] @@ -522,7 +521,7 @@ pub async fn run_serve( let listener = UnixListener::bind(socket_path).map_err(|e| anyhow::anyhow!("bind {socket_path}: {e}"))?; - secure_embedded_socket(socket_path, embedded)?; + secure_local_socket(socket_path)?; let bound_socket = socket_identity(socket_path)?; eprintln!("Cua Driver daemon listening on {socket_path}"); @@ -861,123 +860,6 @@ pub async fn run_serve( Ok(()) } -/// On Windows, optionally spawn the sibling uiAccess'd worker -/// (`cua-driver-uia.exe`) via ShellExecute if it lives next to the main binary -/// AND we're at Medium IL AND the binary is opt-in via env var. -/// -/// History: the uia worker was the original answer to "send synthetic input -/// (SendInput / pixel clicks) into UWP / AppContainer windows from a Medium-IL -/// daemon" — UIPI blocks that cross-integrity input, so the worker carries -/// `uiAccess="true"` in its manifest and was meant to be Authenticode-signed -/// (EV cert per #1602) so Windows AIS would elevate it to UIAccess integrity at -/// launch. -/// -/// IMPORTANT (verified): the worker is NOT required to automate real UWP apps in -/// general. The element-action path — UIA Invoke / ValuePattern driven by -/// `element_index` — drives AppContainer apps as-is from the Medium-IL daemon -/// (Calculator num5Button 0→5, no worker). Only the pixel / SendInput path needs -/// the worker, and only against AppContainer (UWP) targets. -/// -/// With #1630 the canonical answer for that input path became "register the -/// autostart task at RunLevel=Highest so the main daemon is already at High IL", -/// which obviates the worker entirely for the vast majority of users. -/// -/// Current behavior: -/// -/// 1. If the main daemon is already at High IL (the RunLevel=Highest path), -/// skip the worker — it's redundant and, more importantly, attempting to -/// ShellExecute an unsigned uiAccess'd PE pops a Windows error dialog -/// ("A referral was returned from the server" = AIS refusing to elevate -/// an unsigned uiAccess binary). That dialog blocks the daemon's startup -/// and confuses users. -/// -/// 2. If the main daemon is at Medium IL (older installs without the -/// Highest task), AND `CUA_DRIVER_RS_SPAWN_UIA_WORKER=1` is set (opt-in), -/// AND a uiAccess'd worker is installed, spawn it. This path is kept for -/// the future EV-cert flow where the worker IS properly signed. -/// -/// 3. Otherwise: skip silently. The main daemon still serves requests, and -/// element_index UWP automation (UIA Invoke / ValuePattern) works without the -/// worker. Only pixel / SendInput into AppContainer (UWP) windows needs the -/// elevated path — re-run with the Highest autostart task or (when shipped) -/// the signed uia worker. See #1602. -#[cfg(target_os = "windows")] -fn maybe_spawn_uia_worker() { - // Skip when at High IL — main daemon already has the privileges the - // worker was supposed to provide. - if is_self_at_high_il() { - tracing::debug!("uia spawn skipped: main daemon already at High IL"); - return; - } - - // Opt-in for the future EV-cert flow. Default-off until the worker is - // actually signed and tested. - if !crate::bundle::is_env_truthy("CUA_DRIVER_RS_SPAWN_UIA_WORKER") { - tracing::debug!( - "uia spawn skipped: CUA_DRIVER_RS_SPAWN_UIA_WORKER not set (opt-in only \ - until the worker is EV-signed; see #1602)" - ); - return; - } - - let current = match std::env::current_exe() { - Ok(p) => p, - Err(e) => { - tracing::debug!("uia spawn skipped: current_exe failed: {e}"); - return; - } - }; - let uia = match current.parent() { - Some(dir) => dir.join(crate::bundle::uia_executable_name()), - None => return, - }; - if !uia.exists() { - tracing::debug!("uia spawn skipped: {} not present", uia.display()); - return; - } - let uia_str = uia.display().to_string(); - let cmd = - format!("(New-Object -ComObject Shell.Application).ShellExecute('{uia_str}','','','',0)"); - match std::process::Command::new("powershell.exe") - .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", &cmd]) - .spawn() - { - Ok(_child) => { - eprintln!("cua-driver: spawned uiAccess worker via {}", uia.display()); - } - Err(e) => { - tracing::warn!("uia spawn failed: {e}"); - } - } -} - -/// Returns true when the current process is at High IL (admin token). Checked -/// via a one-shot PowerShell call to `WindowsPrincipal.IsInRole(Administrator)` -/// — the standard managed equivalent of OpenProcessToken + GetTokenInformation. -/// -/// Done via PowerShell instead of the windows-crate Win32 API because cua-driver -/// doesn't depend on the `windows` crate directly (only platform-windows does), -/// and `serve.rs` runs only once at daemon start so the ~50ms PowerShell-spawn -/// cost is acceptable. -#[cfg(target_os = "windows")] -fn is_self_at_high_il() -> bool { - let out = std::process::Command::new("powershell.exe") - .args([ - "-NoProfile", - "-NonInteractive", - "-Command", - "([System.Security.Principal.WindowsPrincipal][System.Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)", - ]) - .output(); - match out { - Ok(o) => { - let s = String::from_utf8_lossy(&o.stdout); - s.trim().eq_ignore_ascii_case("True") - } - Err(_) => false, - } -} - #[cfg(target_os = "windows")] unsafe fn security_attrs_from_sddl( sddl: &str, @@ -1259,10 +1141,6 @@ pub async fn run_serve( None => std::ptr::null_mut(), }; - // Spawn the sibling uiAccess'd worker if it's installed. Best-effort — - // the main daemon still serves requests even if the worker fails to start. - maybe_spawn_uia_worker(); - // Write PID file. if let Some(pid_path) = pid_file_path { if let Some(dir) = std::path::Path::new(pid_path).parent() { @@ -1867,23 +1745,17 @@ pub fn run_revoke_cmd(socket_path: &str, session: Option<&str>, all: bool) { #[cfg(all(test, unix))] mod socket_tests { - use super::{remove_owned_socket, secure_embedded_socket, socket_identity}; + use super::{remove_owned_socket, secure_local_socket, socket_identity}; use std::os::unix::fs::PermissionsExt as _; #[test] - fn only_embedded_sockets_are_forced_private() { + fn every_local_service_socket_is_forced_private() { let directory = tempfile::tempdir().unwrap(); let socket = directory.path().join("driver.sock"); let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o770)).unwrap(); - secure_embedded_socket(socket.to_str().unwrap(), false).unwrap(); - assert_eq!( - std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777, - 0o770 - ); - - secure_embedded_socket(socket.to_str().unwrap(), true).unwrap(); + secure_local_socket(socket.to_str().unwrap()).unwrap(); assert_eq!( std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777, 0o600 @@ -1985,6 +1857,8 @@ mod gate_tests { enabled: false, ..cursor_overlay::CursorConfig::default() }, + host_owns_permission_ux: false, + host_bundle_id: None, claude_code_compatibility: false, prepare_desktop_environment: false, register_host_tools: Some(register_probe), diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/README.md b/libs/cua-driver/rust/crates/cua-driver/tests/README.md index 031bf26333..195a3c06d2 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/README.md +++ b/libs/cua-driver/rust/crates/cua-driver/tests/README.md @@ -143,5 +143,7 @@ desktop state outside the repo-local fixtures: handoff behavior. Run it through the cross-platform `scripts/ci/run-rust-standalone-browser-e2e.sh`, which stages the Electron foreground sentinel when needed and opts pure Wayland runs into the native - backend. macOS Lume maintainers can add it to the VM acceptance run with - `run-all.sh --standalone-browser`. + backend. Non-macOS runs prove that standard mode refuses existing-profile + attachment before running the authorized success rows with a disposable + unrestricted daemon. macOS Lume maintainers can add the success rows to the + VM acceptance run with `run-all.sh --standalone-browser`. diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/compatibility_contract_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/compatibility_contract_test.rs index 02456a3c40..5dd2892a14 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/compatibility_contract_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/compatibility_contract_test.rs @@ -102,6 +102,62 @@ fn direct_mcp_runtime_matches_the_released_protocol_contract() { assert_mcp_contract(&mut driver, &fixture); } +#[cfg(target_os = "macos")] +#[test] +fn explicit_direct_mcp_is_read_only_for_permissions_and_refuses_overlay_tools() { + let Some(mut driver) = RawDriver::spawn_explicit_direct() else { + panic!("compatibility test requires the built cua-driver binary"); + }; + + driver.send(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + })); + driver.recv(); + + driver.send(&json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "get_agent_cursor_state", + "arguments": {"session": "explicit-direct"} + } + })); + let overlay = driver.recv(); + assert_eq!(overlay["result"]["isError"], true, "{overlay:?}"); + assert_eq!( + overlay["result"]["structuredContent"]["refusal"]["code"], "facility_unavailable", + "{overlay:?}" + ); + + driver.send(&json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "check_permissions", + "arguments": {"prompt": true} + } + })); + let permissions = driver.recv(); + assert_ne!(permissions["result"]["isError"], true, "{permissions:?}"); + assert_eq!( + permissions["result"]["structuredContent"]["direct_capture_status"], "not_checked", + "{permissions:?}" + ); + assert_eq!( + permissions["result"]["structuredContent"]["source"]["attribution"], "host", + "{permissions:?}" + ); + assert_eq!( + permissions["result"]["structuredContent"]["source"]["direct_runtime"], true, + "{permissions:?}" + ); +} + fn assert_mcp_contract(driver: &mut RawDriver, fixture: &Value) { driver.send(&json!({ "jsonrpc": "2.0", diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/cross_platform_behavior_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/cross_platform_behavior_test.rs index e930d83fd6..28a20a4e1c 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/cross_platform_behavior_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/cross_platform_behavior_test.rs @@ -366,7 +366,18 @@ fn launch_host_with_evidence(spec: &HostSpec, scenario: &str, evidence: &mut Evi name: spec.name, journal, }; - let ax_deadline = Instant::now() + Duration::from_secs(10); + // A freshly provisioned platform webview can need more than + // the generic fixture budget to start its renderer and + // expose the remote accessibility subtree (observed for + // cold Windows WebView2 and macOS WKWebView helpers). Keep + // the extension Tauri-specific and bounded; every other + // harness still fails fast. + let ax_timeout = if spec.name == "tauri" { + Duration::from_secs(30) + } else { + Duration::from_secs(10) + }; + let ax_deadline = Instant::now() + ax_timeout; let mut last_tree = String::new(); while Instant::now() < ax_deadline { let state = snapshot(&mut fixture); diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/private_worker_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/private_worker_test.rs index e9e76dc154..9a664b0e39 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/private_worker_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/private_worker_test.rs @@ -73,6 +73,78 @@ async fn private_worker_owns_one_runtime_without_a_reconnect_endpoint() { assert!(!driver.is_available()); } +#[cfg(target_os = "macos")] +#[tokio::test] +async fn private_worker_owns_the_macos_cursor_overlay_facility() { + let driver = cua_driver_sdk::CuaDriver::create_private_worker(worker_options()).unwrap(); + let result = driver + .call_tool( + "get_agent_cursor_state".into(), + serde_json::json!({"session": "worker-overlay"}).to_string(), + ) + .await + .unwrap(); + assert_ne!( + result.error_code.as_deref(), + Some("facility_unavailable"), + "private worker did not install its AppKit main-thread adapter" + ); + let permissions = driver + .call_tool( + "check_permissions".into(), + serde_json::json!({"prompt": true}).to_string(), + ) + .await + .unwrap(); + let structured: serde_json::Value = + serde_json::from_str(permissions.structured_json.as_deref().unwrap()).unwrap(); + assert_eq!(structured["direct_capture_status"], "not_checked"); + assert_eq!(structured["source"]["attribution"], "host"); + assert_eq!( + structured["source"]["host_bundle_id"], + "com.trycua.private-worker-test" + ); + driver.shutdown().await.unwrap(); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn private_worker_inherits_the_interactive_linux_display_scope() { + if std::env::var("CUA_REQUIRE_GUI").as_deref() != Ok("1") { + return; + } + let has_x11 = std::env::var("DISPLAY").is_ok_and(|display| !display.is_empty()); + let has_wayland = std::env::var("WAYLAND_DISPLAY").is_ok_and(|display| !display.is_empty()); + assert!( + has_x11 || has_wayland, + "canonical GUI E2E requires DISPLAY or WAYLAND_DISPLAY" + ); + + let driver = cua_driver_sdk::CuaDriver::create_private_worker(worker_options()).unwrap(); + if has_x11 { + let desktop = driver + .call_tool("get_desktop_state".into(), "{}".into()) + .await + .unwrap(); + assert!( + !desktop.images.is_empty(), + "worker inherited no usable X11 capture scope: {}", + desktop.text + ); + } else { + let windows = driver + .call_tool("list_windows".into(), "{}".into()) + .await + .unwrap(); + assert!( + windows.error_code.is_none(), + "worker inherited no usable native Wayland session scope: {}", + windows.text + ); + } + driver.shutdown().await.unwrap(); +} + #[tokio::test] async fn dropping_the_host_closes_and_terminates_the_private_worker() { let driver = cua_driver_sdk::CuaDriver::create_private_worker(worker_options()).unwrap(); diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/standalone_browser_behavior_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/standalone_browser_behavior_test.rs index 16f6935328..48cc384323 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/standalone_browser_behavior_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/standalone_browser_behavior_test.rs @@ -28,10 +28,6 @@ use cua_driver_testkit::{spawn_in_job, BrowserFixtureServer, Driver, McpDriver, use futures_util::{SinkExt, StreamExt}; use tokio_tungstenite::tungstenite::Message; -use cua_driver_core::browser::approval::{ - mint_existing_profile_approval, ExistingProfileApprovalScope, -}; - const FIXTURE_HTML: &str = include_str!("../../../../tests/fixtures/shared/web/index.html"); static STANDALONE_BROWSER_TEST_LOCK: Mutex<()> = Mutex::new(()); @@ -708,15 +704,40 @@ fn spawn_driver(label: &str) -> McpDriver { let driver = if std::env::var("CUA_E2E_WAYLAND_SESSION").as_deref() == Ok("generic") { // Keep Sway IPC available to the out-of-band test oracle while the // product under test sees only standard/generic Wayland capabilities. - McpDriver::spawn_named_with_env(label, &[("SWAYSOCK", "/dev/null/cua-e2e-withheld")]) + McpDriver::spawn_named_with_env( + label, + &[ + ("SWAYSOCK", "/dev/null/cua-e2e-withheld"), + ("CUA_DRIVER_PERMISSION_MODE", "unrestricted"), + ("CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS", "1"), + ], + ) } else { - McpDriver::spawn_named(label) + McpDriver::spawn_named_with_env( + label, + &[ + ("CUA_DRIVER_PERMISSION_MODE", "unrestricted"), + ("CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS", "1"), + ], + ) }; #[cfg(all(not(target_os = "macos"), not(target_os = "linux")))] - let driver = McpDriver::spawn_named(label); + let driver = McpDriver::spawn_named_with_env( + label, + &[ + ("CUA_DRIVER_PERMISSION_MODE", "unrestricted"), + ("CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS", "1"), + ], + ); driver.expect("cua-driver binary/daemon is required for standalone browser E2E") } +#[cfg(not(target_os = "macos"))] +fn spawn_standard_driver(label: &str) -> McpDriver { + McpDriver::spawn_named(label) + .expect("cua-driver binary/daemon is required for standalone browser E2E") +} + #[cfg(target_os = "linux")] fn configure_linux_browser_command(command: &mut Command) { command.arg("--password-store=basic"); @@ -736,6 +757,8 @@ fn configure_test_browser_sandbox(command: &mut Command) { #[cfg(target_os = "windows")] const TEST_BROWSER_WINDOW_SIZE: &str = "900,640"; +#[cfg(target_os = "windows")] +const TEST_BROWSER_HIGH_DPI_WINDOW_SIZE: &str = "440,300"; #[cfg(not(target_os = "windows"))] const TEST_BROWSER_WINDOW_SIZE: &str = "980,760"; @@ -750,9 +773,34 @@ fn command_for_browser( cdp_port: u16, url: &str, position: (i32, i32), + _force_high_device_scale: bool, ) -> Command { let mut command = Command::new(&spec.executable); let output = browser_stderr(); + #[cfg(target_os = "windows")] + let window_size = if _force_high_device_scale { + // Chromium applies the forced scale to the native window as well as + // the page and enforces a scaled minimum outer width. Keep the + // resulting physical bounds inside the 1024x768 interactive runner so + // the full-desktop sentinel can occlude every sampled point during the + // strict background-action proof. + TEST_BROWSER_HIGH_DPI_WINDOW_SIZE + } else { + TEST_BROWSER_WINDOW_SIZE + }; + #[cfg(target_os = "windows")] + let window_position = if _force_high_device_scale { + // A scaled (40,40) origin plus Chromium's minimum high-DPI outer width + // can extend past the runner even when --window-size is smaller. + // Anchor this test-owned window at the display origin instead. + (0, 0) + } else { + position + }; + #[cfg(not(target_os = "windows"))] + let window_position = position; + #[cfg(not(target_os = "windows"))] + let window_size = TEST_BROWSER_WINDOW_SIZE; command .arg(format!("--remote-debugging-port={cdp_port}")) .arg(format!("--user-data-dir={}", profile.display())) @@ -764,9 +812,14 @@ fn command_for_browser( .arg("--disable-default-apps") .arg("--site-per-process") .arg("--new-window") - .arg(format!("--window-position={},{}", position.0, position.1)) - .arg(format!("--window-size={TEST_BROWSER_WINDOW_SIZE}")); + .arg(format!( + "--window-position={},{}", + window_position.0, window_position.1 + )) + .arg(format!("--window-size={window_size}")); configure_test_browser_sandbox(&mut command); + #[cfg(target_os = "macos")] + configure_macos_test_browser_command(&mut command); #[cfg(target_os = "linux")] configure_linux_browser_command(&mut command); command.arg(url).stdout(Stdio::null()).stderr(output); @@ -792,6 +845,8 @@ fn command_for_unprepared_browser( .arg(format!("--window-position={},{}", position.0, position.1)) .arg(format!("--window-size={TEST_BROWSER_WINDOW_SIZE}")); configure_test_browser_sandbox(&mut command); + #[cfg(target_os = "macos")] + configure_macos_test_browser_command(&mut command); #[cfg(target_os = "linux")] { configure_linux_browser_command(&mut command); @@ -808,6 +863,16 @@ fn command_for_unprepared_browser( command } +#[cfg(target_os = "macos")] +fn configure_macos_test_browser_command(command: &mut Command) { + // Chromium's own macOS build guidance disables MediaRouter for tests to + // prevent its unrelated local-network system prompt from covering the + // browser UI under test. Cua Driver must still fail closed around an + // unexpected native prompt; this keeps the setup-success row focused on + // the exact remote-debugging page instead of pre-answering OS consent. + command.arg("--disable-features=MediaRouter"); +} + fn browser_stderr() -> Stdio { if std::env::var_os("CUA_E2E_BROWSER_STDERR").is_some() { Stdio::inherit() @@ -816,6 +881,41 @@ fn browser_stderr() -> Stdio { } } +#[cfg(target_os = "macos")] +#[test] +fn macos_browser_commands_disable_unrelated_media_router_prompt() { + let spec = BrowserSpec { + name: "chrome".to_owned(), + executable: PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + }; + let profile = Path::new("/tmp/cua-browser-command-test"); + let prepared = command_for_browser( + &spec, + profile, + 9222, + "about:blank", + TEST_BROWSER_INITIAL_POSITION, + false, + ); + let unprepared = command_for_unprepared_browser( + &spec, + profile, + "about:blank", + TEST_BROWSER_INITIAL_POSITION, + ); + for command in [&prepared, &unprepared] { + let args = command + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect::>(); + assert!( + args.iter() + .any(|arg| arg == "--disable-features=MediaRouter"), + "{args:?}" + ); + } +} + fn window_ids(driver: &mut McpDriver) -> HashSet { driver .call("list_windows", serde_json::json!({})) @@ -989,8 +1089,19 @@ fn spawn_browser_command( cdp_port: u16, url: &str, position: (i32, i32), + force_high_device_scale: bool, ) { - let mut command = command_for_browser(spec, profile, cdp_port, url, position); + let mut command = command_for_browser( + spec, + profile, + cdp_port, + url, + position, + force_high_device_scale, + ); + if force_high_device_scale { + command.arg("--force-device-scale-factor=2"); + } let child = spawn_in_job(&mut command).expect("launch standalone browser"); eprintln!( "[standalone-browser] spawned {} pid={} profile={} cdp_port={cdp_port}", @@ -1006,7 +1117,25 @@ fn launch_browser(spec: &BrowserSpec, label: &str) -> BrowserFixture { } fn launch_browser_with_html(spec: &BrowserSpec, label: &str, html: String) -> BrowserFixture { - let mut driver = spawn_driver(label); + launch_browser_with_driver(spec, label, html, spawn_driver(label)) +} + +#[cfg(not(target_os = "macos"))] +fn launch_browser_in_standard_mode(spec: &BrowserSpec, label: &str) -> BrowserFixture { + launch_browser_with_driver( + spec, + label, + standalone_fixture_html(), + spawn_standard_driver(label), + ) +} + +fn launch_browser_with_driver( + spec: &BrowserSpec, + label: &str, + html: String, + mut driver: McpDriver, +) -> BrowserFixture { let server = BrowserFixtureServer::start(&html); let profile = tempfile::Builder::new() .prefix("cua-e2e-browser-") @@ -1021,6 +1150,7 @@ fn launch_browser_with_html(spec: &BrowserSpec, label: &str, html: String) -> Br cdp_port, "about:blank", TEST_BROWSER_INITIAL_POSITION, + label.contains("multi-tab"), ); navigate_initial_page(cdp_port, &server); record_browser_provenance(spec, cdp_port); @@ -2014,27 +2144,31 @@ fn run_prepare_isolated_launch(spec: &BrowserSpec) { ); } -fn run_existing_profile_attach(spec: &BrowserSpec) { +#[cfg(not(target_os = "macos"))] +fn run_existing_profile_standard_refusal(spec: &BrowserSpec) { let scenario = format!( - "{}-{}-standalone-existing-profile", + "{}-{}-standalone-existing-profile-standard-refusal", std::env::consts::OS, spec.name ); execute_case( - case(&spec.name, "browser_prepare_existing_profile"), + refusal_case( + &spec.name, + "browser_prepare_existing_profile_standard_refusal", + RefusalCode::BrowserConsentRequired, + ), |evidence| { - let mut fixture = launch_browser(spec, &scenario); + let mut fixture = launch_browser_in_standard_mode(spec, &scenario); *evidence = recording_evidence(fixture.driver.recording_dir()); run_with_background_oracles(&mut fixture, |fixture| { - let session = format!("standalone-existing-profile-{}", fixture.pid); + let session = format!("standalone-standard-refusal-{}", fixture.pid); let started = fixture .driver .call("start_session", serde_json::json!({ "session": session })); assert!(!started.is_error(), "start_session failed: {}", started.raw); - // A live MCP proxy proves transport provenance, not a person's - // approval to attach an authenticated profile. - let unapproved = fixture.driver.call( + fixture.driver.start_behavior_recording(); + let refused = fixture.driver.call( "browser_prepare", serde_json::json!({ "pid": fixture.pid as i64, @@ -2044,18 +2178,47 @@ fn run_existing_profile_attach(spec: &BrowserSpec) { }), ); assert_eq!( - unapproved.structured()["refusal"]["code"], + refused.structured()["refusal"]["code"], "browser_consent_required", "{}", - unapproved.raw + refused.raw ); + assert_eq!( + refused.structured()["refusal"]["detail"]["permission_mode"], + "standard", + "{}", + refused.raw + ); + wait_for_text(&fixture.server, "lbl-counter", "counter=0"); + Observation::refused( + RefusalCode::BrowserConsentRequired, + vec![OracleKind::FixtureState], + refused.text(), + Evidence::default(), + ) + }) + }, + ); +} + +fn run_existing_profile_attach(spec: &BrowserSpec) { + let scenario = format!( + "{}-{}-standalone-existing-profile", + std::env::consts::OS, + spec.name + ); + execute_case( + case(&spec.name, "browser_prepare_existing_profile"), + |evidence| { + let mut fixture = launch_browser(spec, &scenario); + *evidence = recording_evidence(fixture.driver.recording_dir()); + run_with_background_oracles(&mut fixture, |fixture| { + let session = format!("standalone-existing-profile-{}", fixture.pid); + let started = fixture + .driver + .call("start_session", serde_json::json!({ "session": session })); + assert!(!started.is_error(), "start_session failed: {}", started.raw); - let approval_token = mint_existing_profile_approval(ExistingProfileApprovalScope { - pid: fixture.pid as i64, - window_id: fixture.window_id, - session: session.clone(), - }) - .expect("mint exact existing-profile approval"); fixture.driver.start_behavior_recording(); let prepared = fixture.driver.call( "browser_prepare", @@ -2064,7 +2227,6 @@ fn run_existing_profile_attach(spec: &BrowserSpec) { "window_id": fixture.window_id, "session": session, "strategy": {"kind": "existing_profile"}, - "approval_token": approval_token, }), ); assert_eq!(prepared.structured()["status"], "ok", "{}", prepared.raw); @@ -2106,7 +2268,11 @@ fn run_existing_profile_attach(spec: &BrowserSpec) { "{}", prepared.raw ); - assert!(!public_result.contains(&approval_token), "{}", prepared.raw); + assert!( + !public_result.contains("approval_token"), + "{}", + prepared.raw + ); assert!( !public_result.contains(&fixture._profile.path().display().to_string()), "{}", @@ -2199,12 +2365,6 @@ fn run_existing_profile_setup(spec: &BrowserSpec) { .call("start_session", serde_json::json!({ "session": session })); assert!(!started.is_error(), "start_session failed: {}", started.raw); - let approval_token = mint_existing_profile_approval(ExistingProfileApprovalScope { - pid: fixture.pid as i64, - window_id: fixture.window_id, - session: session.clone(), - }) - .expect("mint exact existing-profile setup approval"); fixture.driver.start_behavior_recording(); let prepared = fixture.driver.call( "browser_prepare", @@ -2213,7 +2373,6 @@ fn run_existing_profile_setup(spec: &BrowserSpec) { "window_id": fixture.window_id, "session": session, "strategy": {"kind": "existing_profile"}, - "approval_token": approval_token, }), ); assert_eq!(prepared.structured()["status"], "ok", "{}", prepared.raw); @@ -2241,6 +2400,11 @@ fn run_existing_profile_setup(spec: &BrowserSpec) { "{}", prepared.raw ); + assert!( + prepared.structured()["side_effects"]["used_bounded_pixel_fallback"].is_boolean(), + "{}", + prepared.raw + ); assert_eq!( prepared.structured()["side_effects"]["launched_browser"], false @@ -2252,7 +2416,11 @@ fn run_existing_profile_setup(spec: &BrowserSpec) { let public_result = prepared.raw.to_string(); assert!(!public_result.contains("ws://"), "{}", prepared.raw); - assert!(!public_result.contains(&approval_token), "{}", prepared.raw); + assert!( + !public_result.contains("approval_token"), + "{}", + prepared.raw + ); assert!( !public_result.contains(&fixture._profile.path().display().to_string()), "{}", @@ -2409,13 +2577,6 @@ fn run_generic_wayland_existing_profile_refusal(spec: &BrowserSpec) { let started = driver.call("start_session", serde_json::json!({ "session": session })); assert!(!started.is_error(), "start_session failed: {}", started.raw); - let approval_token = - mint_existing_profile_approval(ExistingProfileApprovalScope { - pid: pid as i64, - window_id: opaque_unattested_window_id, - session: session.clone(), - }) - .expect("mint generic-Wayland adversarial approval"); let refused = driver.call( "browser_prepare", serde_json::json!({ @@ -2423,7 +2584,6 @@ fn run_generic_wayland_existing_profile_refusal(spec: &BrowserSpec) { "window_id": opaque_unattested_window_id, "session": session, "strategy": {"kind": "existing_profile"}, - "approval_token": approval_token, }), ); assert_eq!( @@ -2613,21 +2773,11 @@ fn run_multi_tab(spec: &BrowserSpec) { assert!(created["targetId"].is_string(), "{created}"); wait_for_observed(&second_server, "WEB_HARNESS_MARKER_v1"); - // Exercise screenshot/coordinate parity under a non-1 device scale. - // This is setup instrumentation and runs before the background sentinel; - // the driver action below must still leave the tab selected state and - // native foreground unchanged. + // The multi-tab fixture launches Chromium with a non-1 device scale so + // screenshot/coordinate parity is exercised consistently in headful + // Chrome and Edge. Verify the launch flag reached this exact tab before + // starting the background sentinel. let background_ws = cdp_page_websocket_for_url(fixture.cdp_port, second_server.page_url()); - harness_cdp_call_at_url( - &background_ws, - "Emulation.setDeviceMetricsOverride", - serde_json::json!({ - "width": 400, - "height": 300, - "deviceScaleFactor": 2, - "mobile": false, - }), - ); let device_scale = harness_cdp_call_at_url( &background_ws, "Runtime.evaluate", @@ -2778,8 +2928,11 @@ fn run_multi_tab(spec: &BrowserSpec) { let pixel_to_css_y = snapshot.structured()["screenshot"]["pixel_to_css_scale_y"] .as_f64() .expect("screenshot y scale"); - assert!((viewport_width - 400.0).abs() < 0.01, "{}", snapshot.raw); - assert!((viewport_height - 300.0).abs() < 0.01, "{}", snapshot.raw); + assert!( + viewport_width > 0.0 && viewport_height > 0.0, + "{}", + snapshot.raw + ); assert!( (pixel_to_css_x - viewport_width / png_width).abs() < 1e-9, "{}", @@ -2790,6 +2943,12 @@ fn run_multi_tab(spec: &BrowserSpec) { "{}", snapshot.raw ); + assert!( + (pixel_to_css_x - 1.0 / device_scale).abs() < 1e-9 + && (pixel_to_css_y - 1.0 / device_scale).abs() < 1e-9, + "{}", + snapshot.raw + ); assert!( snapshot.raw["result"]["content"] .as_array() @@ -3613,6 +3772,11 @@ standalone_browser_test!( standalone_browser_prepare_isolated, run_prepare_isolated_launch ); +#[cfg(not(target_os = "macos"))] +standalone_browser_test!( + standalone_browser_existing_profile_standard_refusal, + run_existing_profile_standard_refusal +); standalone_browser_test!( standalone_browser_existing_profile, run_existing_profile_attach diff --git a/libs/cua-driver/rust/crates/platform-linux/src/atspi/native.rs b/libs/cua-driver/rust/crates/platform-linux/src/atspi/native.rs index 7818534644..bb57660fd2 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/atspi/native.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/atspi/native.rs @@ -1380,6 +1380,13 @@ pub fn scroll_element(pid: u32, idx: usize, direction: &str, amount: usize) -> R /// Give an indexed element keyboard focus through AT-SPI Component.GrabFocus /// without activating or raising its toplevel window. +/// +/// `GrabFocus` acknowledges the request before Chromium/Electron necessarily +/// updates its renderer-owned focused control. Sending key events immediately +/// after the acknowledgement can therefore split one string between the old +/// and new controls. Wait for the target's Focused state to become observable; +/// if a toolkit does not publish that state, retain the historical successful +/// result after a bounded settling interval. pub fn focus_element(pid: u32, idx: usize) -> Result { bounded( async { @@ -1401,11 +1408,33 @@ pub fn focus_element(pid: u32, idx: usize) -> Result { .component() .await .map_err(|e| anyhow!("Component interface unavailable: {e}"))?; - match call(component.grab_focus()).await { - Some(Ok(focused)) => Ok(focused), - Some(Err(e)) => Err(anyhow!("Component.GrabFocus failed for element {idx}: {e}")), - None => Err(anyhow!("Component.GrabFocus timed out for element {idx}")), + let accepted = match call(component.grab_focus()).await { + Some(Ok(focused)) => focused, + Some(Err(e)) => { + return Err(anyhow!("Component.GrabFocus failed for element {idx}: {e}")) + } + None => return Err(anyhow!("Component.GrabFocus timed out for element {idx}")), + }; + if !accepted { + return Ok(false); + } + + let settle_deadline = + tokio::time::Instant::now() + std::time::Duration::from_millis(500); + while tokio::time::Instant::now() < settle_deadline { + match tokio::time::timeout( + std::time::Duration::from_millis(100), + target.acc.get_state(), + ) + .await + { + Ok(Ok(state)) if state.contains(State::Focused) => return Ok(true), + _ => { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } } + Ok(true) }, || Err(anyhow!("focus_element timed out for pid {pid}")), ) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/browser_platform.rs b/libs/cua-driver/rust/crates/platform-linux/src/browser_platform.rs index ddfb17f355..9e29eaa6a6 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/browser_platform.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/browser_platform.rs @@ -302,6 +302,7 @@ fn active_port_endpoint(pid: i64) -> Result, BrowserRefusa ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::DevtoolsActivePortsFile, owner_pid: pid, + listener_pid: None, detail: Some( "exact /proc argv profile port file plus loopback socket inode owner".to_owned(), ), @@ -659,6 +660,7 @@ impl BrowserPlatform for LinuxBrowserPlatform { ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::ListeningSocketPid, owner_pid: pid, + listener_pid: None, detail: Some("/proc socket inode owner".to_owned()), }, })); @@ -696,6 +698,7 @@ impl BrowserPlatform for LinuxBrowserPlatform { ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::ListeningSocketPid, owner_pid: pid, + listener_pid: None, detail: Some("/proc owner plus /json/version".to_owned()), }, })), @@ -734,6 +737,7 @@ impl BrowserPlatform for LinuxBrowserPlatform { ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::ListeningSocketPid, owner_pid: pid, + listener_pid: None, detail: Some("/proc owner of exact approved endpoint".to_owned()), }, })) @@ -858,6 +862,7 @@ impl BrowserPlatform for LinuxBrowserPlatform { ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::ListeningSocketPid, owner_pid: request.pid, + listener_pid: None, detail: Some((*detail).to_owned()), }, }) @@ -905,6 +910,7 @@ impl BrowserPlatform for LinuxBrowserPlatform { opened_setup_page, closed_setup_page: false, enabled_remote_debugging, + used_bounded_pixel_fallback: false, focused_setup_address_field, foregrounded_window, injected_global_input, diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs index 76a154762c..2383a1f3b2 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs @@ -1163,6 +1163,7 @@ pub fn activate_window_for_input_target( ) })?; inject_send(&[format!("f {pid}")])?; + remember_inject_focused_target(pid, window_id); std::thread::sleep(std::time::Duration::from_millis(60)); return Ok(()); } @@ -1439,6 +1440,9 @@ pub fn scroll_at( /// Scroll at a desktop-absolute point without activating a named toplevel. pub fn scroll_desktop(x: i32, y: i32, direction: &str, amount: u32) -> anyhow::Result<()> { + if is_inject_mode() { + return inject_scroll_desktop(x, y, direction, amount); + } let direction = direction.to_string(); with_libei_fallback( || scroll_vptr(None, Some((x, y)), &direction, amount), @@ -1756,6 +1760,10 @@ pub fn press_key(window_id: u64, key: &str) -> anyhow::Result<()> { /// Press one key while an outer exact-container focus guard is active. pub fn press_key_focused(key: &str) -> anyhow::Result<()> { + if is_inject_mode() { + let (pid, window_id) = inject_focused_target()?; + return inject_press_key(pid, window_id, key); + } let keysym = key_to_keysym(key); let result = std::process::Command::new("wtype") .args(["-k", "Shift_L", "-k", &keysym]) @@ -1815,6 +1823,10 @@ pub fn hotkey(window_id: u64, keys: &[String]) -> anyhow::Result<()> { /// Send a chord while an outer exact-container focus guard is active. pub fn hotkey_focused(keys: &[String]) -> anyhow::Result<()> { + if is_inject_mode() { + let (pid, window_id) = inject_focused_target()?; + return inject_hotkey(pid, window_id, keys); + } let (mods, final_key) = partition_modifiers(keys)?; if let Ok(()) = virtual_keyboard::hotkey(&mods, &final_key) { return Ok(()); @@ -2289,6 +2301,65 @@ pub fn is_inject_mode() -> bool { inject_socket_path().is_some() } +static INJECT_FOCUSED_TARGET: OnceLock>> = OnceLock::new(); + +fn remember_inject_focused_target(pid: u32, window_id: u64) { + let target = INJECT_FOCUSED_TARGET.get_or_init(|| Mutex::new(None)); + if let Ok(mut target) = target.lock() { + *target = Some((pid, window_id)); + } +} + +fn inject_focused_target() -> anyhow::Result<(u32, u64)> { + INJECT_FOCUSED_TARGET + .get_or_init(|| Mutex::new(None)) + .lock() + .ok() + .and_then(|target| *target) + .ok_or_else(|| { + anyhow::anyhow!( + "foreground_unavailable: cua-compositor has no verified foreground target; \ + call bring_to_front before desktop keyboard input" + ) + }) +} + +fn inject_scroll_desktop(x: i32, y: i32, direction: &str, amount: u32) -> anyhow::Result<()> { + let windows = crate::atspi::list_windows(None); + let target = windows + .iter() + .filter(|window| { + window.is_on_screen + && x >= window.x + && y >= window.y + && x < window.x.saturating_add(window.width as i32) + && y < window.y.saturating_add(window.height as i32) + }) + .max_by_key(|window| window.z_index.unwrap_or_default()) + .or_else(|| { + let (pid, _) = inject_focused_target().ok()?; + windows.iter().find(|window| window.pid == Some(pid)) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "foreground_unavailable: no cua-compositor window contains desktop point ({x},{y})" + ) + })?; + let pid = target.pid.ok_or_else(|| { + anyhow::anyhow!( + "foreground_unavailable: desktop point ({x},{y}) resolved to a window without a pid" + ) + })?; + inject_scroll( + pid, + target.xid, + f64::from(x.saturating_sub(target.x)), + f64::from(y.saturating_sub(target.y)), + direction, + amount, + ) +} + /// Reject any character the nested compositor cannot type before it reaches the /// wire. The compositor's chartab (`cua_init_keymap`) only covers printable /// ASCII (`0x20..=0x7E`) plus newline and tab; anything else — Unicode, other diff --git a/libs/cua-driver/rust/crates/platform-macos/src/browser/platform.rs b/libs/cua-driver/rust/crates/platform-macos/src/browser/platform.rs index f7ad2366ff..5380731e27 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/browser/platform.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/browser/platform.rs @@ -341,6 +341,7 @@ async fn active_port_endpoint( ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::DevtoolsActivePortsFile, owner_pid: pid, + listener_pid: None, detail: Some( "exact default-profile DevToolsActivePort path plus lsof loopback listener owner" .to_owned(), @@ -592,6 +593,7 @@ impl BrowserPlatform for MacOsBrowserPlatform { ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::ListeningSocketPid, owner_pid: pid, + listener_pid: None, detail: Some("lsof loopback listener owner".to_owned()), }, })); @@ -623,6 +625,7 @@ impl BrowserPlatform for MacOsBrowserPlatform { ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::ListeningSocketPid, owner_pid: pid, + listener_pid: None, detail: Some("lsof loopback listener owner plus /json/version".to_owned()), }, })); @@ -670,6 +673,7 @@ impl BrowserPlatform for MacOsBrowserPlatform { ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::ListeningSocketPid, owner_pid: pid, + listener_pid: None, detail: Some("lsof owner of exact approved endpoint".to_owned()), }, })) @@ -723,6 +727,7 @@ impl BrowserPlatform for MacOsBrowserPlatform { .await?; let opened_setup_page = handle.opened_setup_page; let enabled_remote_debugging = handle.enabled_remote_debugging; + let used_bounded_pixel_fallback = handle.used_bounded_pixel_fallback; let focused_setup_address_field = handle.focused_setup_address_field; let foregrounded_window = handle.foregrounded_window; let injected_global_input = handle.injected_global_input; @@ -774,6 +779,7 @@ impl BrowserPlatform for MacOsBrowserPlatform { ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::ListeningSocketPid, owner_pid: request.pid, + listener_pid: None, detail: Some((*detail).to_owned()), }, }) @@ -822,6 +828,7 @@ impl BrowserPlatform for MacOsBrowserPlatform { opened_setup_page, closed_setup_page: false, enabled_remote_debugging, + used_bounded_pixel_fallback, focused_setup_address_field, foregrounded_window, injected_global_input, diff --git a/libs/cua-driver/rust/crates/platform-macos/src/browser/setup_ui.rs b/libs/cua-driver/rust/crates/platform-macos/src/browser/setup_ui.rs index 5ad17f63ce..0cdf5dea1e 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/browser/setup_ui.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/browser/setup_ui.rs @@ -16,7 +16,7 @@ use crate::ax::bindings::{ copy_number_attr, copy_string_attr, element_screen_center, focused_element_of_pid, kAXErrorSuccess, perform_action, set_bool_attr_true, set_string_attr, AXUIElementRef, }; -use crate::ax::tree::{walk_tree, AXNode}; +use crate::ax::tree::{walk_tree, AXNode, TreeWalkResult}; fn refusal(code: BrowserRefusalCode, message: impl Into) -> BrowserRefusal { BrowserRefusal::new(code, message) @@ -87,10 +87,78 @@ fn setup_page_proven(nodes: &[AXNode], descriptor: &BrowserSetupDescriptor) -> b exact_url && exact_page && exact_heading } -fn exact_setup_checkbox( +fn native_setup_page_proven(nodes: &[AXNode], descriptor: &BrowserSetupDescriptor) -> bool { + let exact_urls = nodes + .iter() + .filter(|node| { + node.role == "AXTextField" + && field_equals(node, "Address and search bar") + && node + .value + .as_deref() + .is_some_and(|value| value.trim().eq_ignore_ascii_case(descriptor.setup_url)) + }) + .count(); + let exact_selected_tabs = nodes + .iter() + .filter(|node| { + node.role == "AXRadioButton" + && node.selected == Some(true) + && descriptor + .page_titles + .iter() + .any(|title| field_equals(node, title)) + }) + .count(); + let omnibox_popup_open = nodes + .iter() + .any(|node| node.role == "AXWebArea" && field_equals(node, "Omnibox Popup")); + exact_urls == 1 && exact_selected_tabs == 1 && !omnibox_popup_open +} + +fn native_setup_page_committed( + pid: i32, nodes: &[AXNode], descriptor: &BrowserSetupDescriptor, +) -> bool { + if !native_setup_page_proven(nodes, descriptor) { + return false; + } + let exact_omnibox = nodes.iter().find(|node| { + node.role == "AXTextField" + && node.element_index.is_some() + && field_equals(node, "Address and search bar") + && node + .value + .as_deref() + .is_some_and(|value| value.trim().eq_ignore_ascii_case(descriptor.setup_url)) + }); + let Some(exact_omnibox) = exact_omnibox else { + return false; + }; + let focused = unsafe { focused_element_of_pid(pid) }; + let omnibox_focused = focused.is_some_and(|element| { + let matches = is_same_element(element as usize, exact_omnibox.element_ptr); + unsafe { CFRelease(element as CFTypeRef) }; + matches + }); + !omnibox_focused +} + +fn exact_setup_checkbox( + tree: &TreeWalkResult, + descriptor: &BrowserSetupDescriptor, ) -> Result, BrowserRefusal> { + if tree.truncated { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "{}'s setup accessibility proof was truncated", + descriptor.product_name + ), + )); + } + let nodes = &tree.nodes; if !setup_page_proven(nodes, descriptor) { return Ok(None); } @@ -315,6 +383,498 @@ enum CheckboxState { On, } +#[derive(Clone, Copy, Debug, PartialEq)] +struct PixelCheckbox { + screen_x: f64, + screen_y: f64, + window_local_x: f64, + window_local_y: f64, + window_frame: [f64; 4], + state: CheckboxState, +} + +impl PixelCheckbox { + fn same_control_as(self, other: Self, tolerance: f64) -> bool { + (self.window_local_x - other.window_local_x).abs() <= tolerance + && (self.window_local_y - other.window_local_y).abs() <= tolerance + && frames_agree(self.window_frame, other.window_frame, tolerance) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct SetupGeometry { + search: (u32, u32, u32, u32), + scale_x: f64, + scale_y: f64, +} + +fn frames_agree(left: [f64; 4], right: [f64; 4], tolerance: f64) -> bool { + left.into_iter() + .zip(right) + .all(|(left, right)| (left - right).abs() <= tolerance) +} + +fn setup_geometry( + window_frame: [f64; 4], + omnibox_frame: [f64; 4], + screenshot_size: (u32, u32), +) -> Result { + if !window_frame.into_iter().all(f64::is_finite) + || !omnibox_frame.into_iter().all(f64::is_finite) + || window_frame[2] <= 0.0 + || window_frame[3] <= 0.0 + || screenshot_size.0 == 0 + || screenshot_size.1 == 0 + { + return Err("invalid native or screenshot geometry"); + } + let tolerance = 1.0; + if omnibox_frame[0] < window_frame[0] - tolerance + || omnibox_frame[1] < window_frame[1] - tolerance + || omnibox_frame[0] + omnibox_frame[2] > window_frame[0] + window_frame[2] + tolerance + || omnibox_frame[1] + omnibox_frame[3] > window_frame[1] + window_frame[3] + tolerance + { + return Err("address-field frame is outside the captured window"); + } + let scale_x = f64::from(screenshot_size.0) / window_frame[2]; + let scale_y = f64::from(screenshot_size.1) / window_frame[3]; + if !scale_x.is_finite() + || !scale_y.is_finite() + || scale_x <= 0.0 + || scale_y <= 0.0 + || (scale_x - scale_y).abs() > 0.15 + { + return Err("screenshot and native window scales disagree"); + } + let local_omnibox_bottom = omnibox_frame[1] + omnibox_frame[3] - window_frame[1]; + if local_omnibox_bottom < -tolerance || local_omnibox_bottom > window_frame[3] + tolerance { + return Err("address-field frame has invalid window-local geometry"); + } + let left = (window_frame[2] * 0.15 * scale_x).round().max(0.0) as u32; + let top = ((local_omnibox_bottom + 30.0) * scale_y) + .round() + .min(f64::from(screenshot_size.1)) as u32; + let right = (window_frame[2] * 0.40 * scale_x) + .round() + .clamp(0.0, f64::from(screenshot_size.0)) as u32; + let bottom = ((local_omnibox_bottom + 150.0) * scale_y) + .round() + .clamp(0.0, f64::from(screenshot_size.1)) as u32; + if left >= right || top >= bottom { + return Err("bounded setup search region is empty"); + } + Ok(SetupGeometry { + search: (left, top, right, bottom), + scale_x, + scale_y, + }) +} + +fn pixel_to_screen( + window_frame: [f64; 4], + pixel: (u32, u32), + geometry: SetupGeometry, +) -> (f64, f64, f64, f64) { + let local_x = f64::from(pixel.0) / geometry.scale_x; + let local_y = f64::from(pixel.1) / geometry.scale_y; + ( + window_frame[0] + local_x, + window_frame[1] + local_y, + local_x, + local_y, + ) +} + +fn unique_pixel_checkbox( + matches: &[(u32, u32, CheckboxState)], + window_frame: [f64; 4], + geometry: SetupGeometry, + descriptor: &BrowserSetupDescriptor, +) -> Result, BrowserRefusal> { + let [(center_x, center_y, state)] = matches else { + return match matches.len() { + 0 => Ok(None), + _ => Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "{}'s exact setup page exposed multiple checkbox-shaped controls in the bounded trusted region", + descriptor.product_name + ), + )), + }; + }; + let (screen_x, screen_y, window_local_x, window_local_y) = + pixel_to_screen(window_frame, (*center_x, *center_y), geometry); + Ok(Some(PixelCheckbox { + screen_x, + screen_y, + window_local_x, + window_local_y, + window_frame, + state: *state, + })) +} + +fn is_checkbox_edge_pixel(pixel: image::Rgba) -> bool { + let [red, green, blue, _] = pixel.0; + let max = red.max(green).max(blue); + let min = red.min(green).min(blue); + let neutral_outline = max.saturating_sub(min) <= 20 && (70..=225).contains(&max); + let chrome_blue = blue > 140 && blue > red.saturating_add(35) && blue > green; + neutral_outline || chrome_blue +} + +fn is_chrome_blue(pixel: image::Rgba) -> bool { + let [red, green, blue, _] = pixel.0; + blue > 140 && blue > red.saturating_add(35) && blue > green +} + +fn has_checkbox_border_coverage( + component: &[(u32, u32)], + bounds: (u32, u32, u32, u32), + scale: f64, +) -> bool { + let (min_x, min_y, max_x, max_y) = bounds; + let width = max_x - min_x + 1; + let height = max_y - min_y + 1; + let corner_inset = (2.0 * scale).round().max(2.0) as u32; + let doubled_inset = corner_inset.saturating_mul(2); + if width <= doubled_inset || height <= doubled_inset { + return false; + } + let horizontal_span = width - doubled_inset; + let vertical_span = height - doubled_inset; + let horizontal_edges = [min_y, max_y].into_iter().all(|sample_y| { + let edge_pixels = component + .iter() + .filter(|(sample_x, component_y)| { + *component_y == sample_y + && *sample_x >= min_x + corner_inset + && *sample_x <= max_x - corner_inset + }) + .count() as u32; + edge_pixels * 3 >= horizontal_span * 2 + }); + let vertical_edges = [min_x, max_x].into_iter().all(|sample_x| { + let edge_pixels = component + .iter() + .filter(|(component_x, sample_y)| { + *component_x == sample_x + && *sample_y >= min_y + corner_inset + && *sample_y <= max_y - corner_inset + }) + .count() as u32; + edge_pixels * 3 >= vertical_span * 2 + }); + horizontal_edges && vertical_edges +} + +fn detect_checkbox_pixels( + image: &image::RgbaImage, + search: (u32, u32, u32, u32), + scale: f64, +) -> Vec<(u32, u32, CheckboxState)> { + use std::collections::VecDeque; + + let (left, top, right, bottom) = search; + if left >= right || top >= bottom || right > image.width() || bottom > image.height() { + return Vec::new(); + } + let width = right - left; + let height = bottom - top; + let mut visited = vec![false; (width * height) as usize]; + let min_side = (9.0 * scale).round().max(7.0) as u32; + let max_side = (30.0 * scale).round().max(min_side as f64) as u32; + let mut candidates = Vec::new(); + + for y in top..bottom { + for x in left..right { + let local = ((y - top) * width + (x - left)) as usize; + if visited[local] || !is_checkbox_edge_pixel(*image.get_pixel(x, y)) { + continue; + } + visited[local] = true; + let mut queue = VecDeque::from([(x, y)]); + let mut component = Vec::new(); + let (mut min_x, mut max_x, mut min_y, mut max_y) = (x, x, y, y); + while let Some((current_x, current_y)) = queue.pop_front() { + component.push((current_x, current_y)); + min_x = min_x.min(current_x); + max_x = max_x.max(current_x); + min_y = min_y.min(current_y); + max_y = max_y.max(current_y); + for delta_y in -1i32..=1 { + for delta_x in -1i32..=1 { + if delta_x == 0 && delta_y == 0 { + continue; + } + let next_x = current_x as i32 + delta_x; + let next_y = current_y as i32 + delta_y; + if next_x < left as i32 + || next_x >= right as i32 + || next_y < top as i32 + || next_y >= bottom as i32 + { + continue; + } + let next_x = next_x as u32; + let next_y = next_y as u32; + let next_local = ((next_y - top) * width + (next_x - left)) as usize; + if !visited[next_local] + && is_checkbox_edge_pixel(*image.get_pixel(next_x, next_y)) + { + visited[next_local] = true; + queue.push_back((next_x, next_y)); + } + } + } + } + + let component_width = max_x - min_x + 1; + let component_height = max_y - min_y + 1; + let side_delta = component_width.abs_diff(component_height); + if component_width < min_side + || component_width > max_side + || component_height < min_side + || component_height > max_side + || side_delta > (3.0 * scale).round().max(2.0) as u32 + { + continue; + } + let perimeter = 2 * (component_width + component_height); + if component.len() < (perimeter / 3) as usize { + continue; + } + if !has_checkbox_border_coverage( + component.as_slice(), + (min_x, min_y, max_x, max_y), + scale, + ) { + continue; + } + let center_x = (min_x + max_x) / 2; + let center_y = (min_y + max_y) / 2; + let blue_pixels = (min_y..=max_y) + .flat_map(|sample_y| { + (min_x..=max_x).map(move |sample_x| *image.get_pixel(sample_x, sample_y)) + }) + .filter(|pixel| is_chrome_blue(*pixel)) + .count(); + let area = (component_width * component_height) as usize; + let state = if blue_pixels * 4 >= area { + CheckboxState::On + } else { + let center = *image.get_pixel(center_x, center_y); + let [red, green, blue, _] = center.0; + let light_center = red >= 235 && green >= 235 && blue >= 235; + let dark_center = red.max(green).max(blue) <= 100 + && red.max(green).max(blue) - red.min(green).min(blue) <= 18; + if !light_center && !dark_center { + continue; + } + CheckboxState::Off + }; + candidates.push((center_x, center_y, state)); + } + } + candidates +} + +fn exact_pixel_setup_checkbox( + pid: i32, + tree: &TreeWalkResult, + window_id: u32, + descriptor: &BrowserSetupDescriptor, + navigation_committed: bool, +) -> Result, BrowserRefusal> { + if !navigation_committed { + return Ok(None); + } + if tree.truncated { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "{}'s setup accessibility proof was truncated", + descriptor.product_name + ), + )); + } + let nodes = &tree.nodes; + if !native_setup_page_committed(pid, nodes, descriptor) { + return Ok(None); + } + let window_frames = nodes + .iter() + .filter(|node| node.role == "AXWindow") + .filter_map(|node| node.frame) + .collect::>(); + let [window_frame] = window_frames.as_slice() else { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "{}'s exact setup tab did not expose one native window frame", + descriptor.product_name + ), + )); + }; + let omnibox_frames = nodes + .iter() + .filter(|node| { + node.role == "AXTextField" + && node.element_index.is_some() + && field_equals(node, "Address and search bar") + && node + .value + .as_deref() + .is_some_and(|value| value.trim().eq_ignore_ascii_case(descriptor.setup_url)) + }) + .filter_map(|node| node.frame) + .collect::>(); + let [omnibox_frame] = omnibox_frames.as_slice() else { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "{}'s exact setup tab did not expose one address-field frame", + descriptor.product_name + ), + )); + }; + let capture_bounds = crate::windows::window_bounds_by_id(window_id).ok_or_else(|| { + refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "{}'s exact setup window disappeared before capture", + descriptor.product_name + ), + ) + })?; + let capture_frame = [ + capture_bounds.x, + capture_bounds.y, + capture_bounds.width, + capture_bounds.height, + ]; + if !frames_agree(*window_frame, capture_frame, 1.0) { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "{}'s native and captured setup-window bounds disagree", + descriptor.product_name + ), + )); + } + let png = crate::capture::screenshot_window_bytes(window_id).map_err(|error| { + refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + format!( + "could not capture {}'s exact setup window: {error}", + descriptor.product_name + ), + ) + })?; + let screenshot = image::load_from_memory(&png) + .map_err(|error| { + refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + format!( + "could not decode {}'s exact setup window: {error}", + descriptor.product_name + ), + ) + })? + .to_rgba8(); + let geometry = setup_geometry( + capture_frame, + *omnibox_frame, + (screenshot.width(), screenshot.height()), + ) + .map_err(|cause| { + refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "{}'s setup screenshot geometry was refused: {cause}", + descriptor.product_name, + ), + ) + })?; + let matches = detect_checkbox_pixels( + &screenshot, + geometry.search, + (geometry.scale_x + geometry.scale_y) / 2.0, + ); + unique_pixel_checkbox(matches.as_slice(), capture_frame, geometry, descriptor) +} + +fn press_pixel_checkbox( + pid: i32, + window_id: u32, + checkbox: PixelCheckbox, + descriptor: &BrowserSetupDescriptor, + navigation_committed: bool, +) -> anyhow::Result { + crate::input::skylight::with_foreground_assist(pid, window_id, || { + std::thread::sleep(Duration::from_millis(60)); + if crate::apps::frontmost_pid() != Some(pid) { + anyhow::bail!("the approved browser lost foreground before the setup click"); + } + let current = walk_tree(pid, Some(window_id), None); + let validation = (|| { + if current.truncated + || !navigation_committed + || !native_setup_page_committed(pid, ¤t.nodes, descriptor) + { + anyhow::bail!("the exact committed setup-page proof changed before the click"); + } + let frames = current + .nodes + .iter() + .filter(|node| node.role == "AXWindow") + .filter_map(|node| node.frame) + .collect::>(); + let [frame] = frames.as_slice() else { + anyhow::bail!("the exact setup window frame became ambiguous before the click"); + }; + if frame + .iter() + .zip(checkbox.window_frame) + .any(|(current, captured)| (*current - captured).abs() > 1.0) + { + anyhow::bail!("the exact setup window moved before the click"); + } + if checkbox.screen_x < frame[0] + || checkbox.screen_y < frame[1] + || checkbox.screen_x >= frame[0] + frame[2] + || checkbox.screen_y >= frame[1] + frame[3] + { + anyhow::bail!("the setup click point left the exact window"); + } + let refreshed = exact_pixel_setup_checkbox(pid, ¤t, window_id, descriptor, true) + .map_err(|error| anyhow::anyhow!(error.message))? + .ok_or_else(|| { + anyhow::anyhow!("the exact setup checkbox disappeared before the click") + })?; + if !checkbox.same_control_as(refreshed, 3.0) || checkbox.state != refreshed.state { + anyhow::bail!("the exact setup checkbox changed before the click"); + } + Ok(refreshed) + })(); + release_actionable_nodes(¤t.nodes); + let refreshed = validation?; + crate::input::mouse::click_at_xy_with_window_local( + pid, + refreshed.screen_x, + refreshed.screen_y, + refreshed.window_local_x, + refreshed.window_local_y, + window_id, + 1, + &[], + )?; + std::thread::sleep(Duration::from_millis(100)); + Ok(()) + }) +} + fn checkbox_state(value: Option) -> Result { match value { Some(value) if value.abs() < f64::EPSILON => Ok(CheckboxState::Off), @@ -331,16 +891,28 @@ pub struct SetupUiHandle { close_button: Option, enable_attempted: bool, trusted_checkbox_fallback_attempted: bool, + pixel_checkbox_fallback_attempted: bool, + setup_navigation_committed: bool, + remote_debugging_mutation_possible: bool, + pixel_checkbox: Option, pub opened_setup_page: bool, pub enabled_remote_debugging: bool, + pub used_bounded_pixel_fallback: bool, pub focused_setup_address_field: bool, pub foregrounded_window: bool, pub injected_global_input: bool, } +fn remote_debugging_cleanup_required(enabled: bool, mutation_possible: bool) -> bool { + enabled || mutation_possible +} + impl SetupUiHandle { fn rollback_remote_debugging(&mut self, pid: i32, window_id: u32) -> bool { - if !self.enabled_remote_debugging { + if !remote_debugging_cleanup_required( + self.enabled_remote_debugging, + self.remote_debugging_mutation_possible, + ) { return true; } let deadline = Instant::now() + Duration::from_secs(2); @@ -348,7 +920,7 @@ impl SetupUiHandle { let mut trusted_fallback_attempted = false; loop { let tree = walk_tree(pid, Some(window_id), None); - let checkbox = exact_setup_checkbox(&tree.nodes, self.descriptor); + let checkbox = exact_setup_checkbox(&tree, self.descriptor); let result = match checkbox { Ok(Some(element)) => { let value = unsafe { copy_number_attr(element as AXUIElementRef, "AXValue") }; @@ -406,13 +978,62 @@ impl SetupUiHandle { Err(_) => Some(false), } } - Ok(None) => None, + Ok(None) => { + let pixel_checkbox = exact_pixel_setup_checkbox( + pid, + &tree, + window_id, + self.descriptor, + self.setup_navigation_committed, + ); + if matches!(pixel_checkbox, Ok(Some(_))) { + self.used_bounded_pixel_fallback = true; + } + match pixel_checkbox { + Ok(Some( + checkbox @ PixelCheckbox { + state: CheckboxState::Off, + .. + }, + )) => Some( + self.pixel_checkbox + .is_none_or(|original| original.same_control_as(checkbox, 3.0)), + ), + Ok(Some(checkbox)) if !trusted_fallback_attempted => { + if self + .pixel_checkbox + .is_some_and(|original| !original.same_control_as(checkbox, 3.0)) + { + Some(false) + } else { + trusted_fallback_attempted = true; + release_actionable_nodes(&tree.nodes); + self.injected_global_input = true; + match press_pixel_checkbox( + pid, + window_id, + checkbox, + self.descriptor, + self.setup_navigation_committed, + ) { + Ok(fronted) => self.foregrounded_window |= fronted, + Err(_) => return false, + } + std::thread::sleep(Duration::from_millis(100)); + continue; + } + } + Ok(Some(_)) | Ok(None) => None, + Err(_) => Some(false), + } + } Err(_) => Some(false), }; release_actionable_nodes(&tree.nodes); if let Some(done) = result { if done { self.enabled_remote_debugging = false; + self.remote_debugging_mutation_possible = false; } return done; } @@ -424,8 +1045,10 @@ impl SetupUiHandle { } pub fn abort(mut self, pid: i32, window_id: u32, error: BrowserRefusal) -> BrowserRefusal { - let enabled_remote_debugging = self.enabled_remote_debugging; + let enabled_remote_debugging = + self.enabled_remote_debugging || self.remote_debugging_mutation_possible; let restored_remote_debugging = self.rollback_remote_debugging(pid, window_id); + let used_bounded_pixel_fallback = self.used_bounded_pixel_fallback; let opened_setup_page = self.opened_setup_page; let focused_setup_address_field = self.focused_setup_address_field; let foregrounded_window = self.foregrounded_window; @@ -439,6 +1062,7 @@ impl SetupUiHandle { "closed_setup_page": closed_setup_page, "focused_setup_address_field": focused_setup_address_field, "enabled_remote_debugging": enabled_remote_debugging, + "used_bounded_pixel_fallback": used_bounded_pixel_fallback, "foregrounded_window": foregrounded_window, "injected_global_input": injected_global_input, "restored_remote_debugging": restored_remote_debugging, @@ -550,15 +1174,20 @@ pub fn enable( descriptor: &'static BrowserSetupDescriptor, ) -> Result { let initial = walk_tree(pid, Some(window_id), None); - let initial_checkbox = exact_setup_checkbox(&initial.nodes, descriptor); + let initial_checkbox = exact_setup_checkbox(&initial, descriptor); let mut handle = match initial_checkbox { Ok(Some(_)) => SetupUiHandle { descriptor, close_button: None, enable_attempted: false, trusted_checkbox_fallback_attempted: false, + pixel_checkbox_fallback_attempted: false, + setup_navigation_committed: false, + remote_debugging_mutation_possible: false, + pixel_checkbox: None, opened_setup_page: false, enabled_remote_debugging: false, + used_bounded_pixel_fallback: false, focused_setup_address_field: false, foregrounded_window: false, injected_global_input: false, @@ -629,8 +1258,13 @@ pub fn enable( close_button: Some(close_button), enable_attempted: false, trusted_checkbox_fallback_attempted: false, + pixel_checkbox_fallback_attempted: false, + setup_navigation_committed: false, + remote_debugging_mutation_possible: false, + pixel_checkbox: None, opened_setup_page: true, enabled_remote_debugging: false, + used_bounded_pixel_fallback: false, focused_setup_address_field: false, foregrounded_window: false, injected_global_input: false, @@ -840,6 +1474,7 @@ pub fn enable( } } } + handle.setup_navigation_committed = true; handle } Err(error) => { @@ -854,13 +1489,13 @@ pub fn enable( let deadline = Instant::now() + EXISTING_PROFILE_SETUP_READY_TIMEOUT; loop { let tree = walk_tree(pid, Some(window_id), None); - let checkbox = exact_setup_checkbox(&tree.nodes, descriptor); + let checkbox = exact_setup_checkbox(&tree, descriptor); match checkbox { Ok(Some(element)) => { let value = unsafe { copy_number_attr(element as AXUIElementRef, "AXValue") }; match checkbox_state(value) { Ok(CheckboxState::On) => { - if handle.enable_attempted { + if handle.enable_attempted || handle.remote_debugging_mutation_possible { handle.enabled_remote_debugging = true; } release_actionable_nodes(&tree.nodes); @@ -869,6 +1504,7 @@ pub fn enable( Ok(CheckboxState::Off) => { if !handle.enable_attempted { handle.enable_attempted = true; + handle.remote_debugging_mutation_possible = true; let pressed = unsafe { perform_action(element as AXUIElementRef, "AXPress") }; release_actionable_nodes(&tree.nodes); @@ -947,7 +1583,80 @@ pub fn enable( } } } - Ok(None) => release_actionable_nodes(&tree.nodes), + Ok(None) => match exact_pixel_setup_checkbox( + pid, + &tree, + window_id, + descriptor, + handle.setup_navigation_committed, + ) { + Ok(Some( + checkbox @ PixelCheckbox { + state: CheckboxState::On, + .. + }, + )) => { + handle.used_bounded_pixel_fallback = true; + if handle + .pixel_checkbox + .is_some_and(|original| !original.same_control_as(checkbox, 3.0)) + { + release_actionable_nodes(&tree.nodes); + return Err(handle.abort( + pid, + window_id, + refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "the exact {} remote-debugging checkbox changed identity after the setup click", + descriptor.product_name + ), + ), + )); + } + if handle.remote_debugging_mutation_possible { + handle.enabled_remote_debugging = true; + } + release_actionable_nodes(&tree.nodes); + return Ok(handle); + } + Ok(Some(checkbox)) if !handle.pixel_checkbox_fallback_attempted => { + handle.pixel_checkbox_fallback_attempted = true; + handle.remote_debugging_mutation_possible = true; + handle.pixel_checkbox = Some(checkbox); + handle.used_bounded_pixel_fallback = true; + release_actionable_nodes(&tree.nodes); + handle.injected_global_input = true; + match press_pixel_checkbox( + pid, + window_id, + checkbox, + descriptor, + handle.setup_navigation_committed, + ) { + Ok(fronted) => handle.foregrounded_window |= fronted, + Err(error) => { + return Err(handle.abort( + pid, + window_id, + refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "could not toggle the exact {} remote-debugging checkbox: {error}", + descriptor.product_name + ), + ), + )); + } + } + continue; + } + Ok(Some(_)) | Ok(None) => release_actionable_nodes(&tree.nodes), + Err(error) => { + release_actionable_nodes(&tree.nodes); + return Err(handle.abort(pid, window_id, error)); + } + }, Err(error) => { release_actionable_nodes(&tree.nodes); return Err(handle.abort(pid, window_id, error)); @@ -1009,6 +1718,14 @@ mod tests { node } + fn tree(nodes: Vec) -> TreeWalkResult { + TreeWalkResult { + tree_markdown: String::new(), + nodes, + truncated: false, + } + } + #[test] fn checkbox_requires_exact_internal_page_proof() { let nodes = vec![ @@ -1027,11 +1744,17 @@ mod tests { &["AXPress"], ), ]; - assert_eq!(exact_setup_checkbox(&nodes, chrome()).unwrap(), Some(7)); + assert_eq!( + exact_setup_checkbox(&tree(nodes.clone()), chrome()).unwrap(), + Some(7) + ); let mut wrong_url = nodes.clone(); wrong_url[2].value = Some("https://example.test/".to_owned()); - assert_eq!(exact_setup_checkbox(&wrong_url, chrome()).unwrap(), None); + assert_eq!( + exact_setup_checkbox(&tree(wrong_url), chrome()).unwrap(), + None + ); } #[test] @@ -1059,11 +1782,431 @@ mod tests { ), ]; assert_eq!( - exact_setup_checkbox(&nodes, chrome()).unwrap_err().code, + exact_setup_checkbox(&tree(nodes), chrome()) + .unwrap_err() + .code, + BrowserRefusalCode::BrowserWrongTargetRefused + ); + } + + #[test] + fn pixel_fallback_requires_exact_url_and_selected_internal_tab() { + let omnibox = node( + "AXTextField", + Some("Address and search bar"), + Some(chrome().setup_url), + &["AXPress"], + ); + let mut selected_tab = node( + "AXRadioButton", + Some(chrome().page_titles[0]), + Some("1"), + &["AXPress"], + ); + selected_tab.selected = Some(true); + assert!(native_setup_page_proven( + &[omnibox.clone(), selected_tab.clone()], + chrome() + )); + + selected_tab.selected = Some(false); + assert!(!native_setup_page_proven( + &[omnibox.clone(), selected_tab], + chrome() + )); + let mut wrong_url = omnibox; + wrong_url.value = Some("https://example.test/".to_owned()); + assert!(!native_setup_page_proven(&[wrong_url], chrome())); + + let mut popup = node("AXWebArea", Some("Omnibox Popup"), None, &[]); + popup.depth = 1; + assert!(!native_setup_page_proven( + &[ + node( + "AXTextField", + Some("Address and search bar"), + Some(chrome().setup_url), + &["AXPress"], + ), + { + let mut tab = node( + "AXRadioButton", + Some(chrome().page_titles[0]), + Some("1"), + &["AXPress"], + ); + tab.selected = Some(true); + tab + }, + popup, + ], + chrome() + )); + } + + #[test] + fn pixel_fallback_requires_committed_navigation_and_complete_ax_proof() { + let truncated = TreeWalkResult { + tree_markdown: String::new(), + nodes: Vec::new(), + truncated: true, + }; + assert!( + exact_pixel_setup_checkbox(0, &truncated, 0, chrome(), false) + .unwrap() + .is_none() + ); + assert_eq!( + exact_pixel_setup_checkbox(0, &truncated, 0, chrome(), true) + .unwrap_err() + .code, + BrowserRefusalCode::BrowserWrongTargetRefused + ); + assert_eq!( + exact_setup_checkbox(&truncated, chrome()).unwrap_err().code, BrowserRefusalCode::BrowserWrongTargetRefused ); } + #[test] + fn pixel_checkbox_detector_distinguishes_off_and_on() { + let mut unchecked = + image::RgbaImage::from_pixel(400, 250, image::Rgba([255, 255, 255, 255])); + for coordinate in 100..=112 { + unchecked.put_pixel(coordinate, 80, image::Rgba([120, 120, 120, 255])); + unchecked.put_pixel(coordinate, 92, image::Rgba([120, 120, 120, 255])); + unchecked.put_pixel(100, coordinate - 20, image::Rgba([120, 120, 120, 255])); + unchecked.put_pixel(112, coordinate - 20, image::Rgba([120, 120, 120, 255])); + } + assert_eq!( + detect_checkbox_pixels(&unchecked, (50, 50, 200, 150), 1.0), + vec![(106, 86, CheckboxState::Off)] + ); + + let mut checked = image::RgbaImage::from_pixel(400, 250, image::Rgba([255, 255, 255, 255])); + for y in 80..=92 { + for x in 100..=112 { + checked.put_pixel(x, y, image::Rgba([26, 115, 232, 255])); + } + } + checked.put_pixel(104, 86, image::Rgba([255, 255, 255, 255])); + checked.put_pixel(105, 87, image::Rgba([255, 255, 255, 255])); + checked.put_pixel(106, 86, image::Rgba([255, 255, 255, 255])); + assert_eq!( + detect_checkbox_pixels(&checked, (50, 50, 200, 150), 1.0), + vec![(106, 86, CheckboxState::On)] + ); + } + + #[test] + fn pixel_checkbox_detector_refuses_non_square_noise() { + let mut image = image::RgbaImage::from_pixel(400, 250, image::Rgba([255, 255, 255, 255])); + for x in 80..=180 { + image.put_pixel(x, 90, image::Rgba([120, 120, 120, 255])); + } + assert!(detect_checkbox_pixels(&image, (50, 50, 200, 150), 1.0).is_empty()); + } + + #[test] + fn pixel_checkbox_detector_rejects_square_text_glyphs() { + let mut image = image::RgbaImage::from_pixel(400, 250, image::Rgba([255, 255, 255, 255])); + for x in 100..=112 { + image.put_pixel(x, 80, image::Rgba([120, 120, 120, 255])); + image.put_pixel(x, 92, image::Rgba([120, 120, 120, 255])); + image.put_pixel(100, x - 20, image::Rgba([120, 120, 120, 255])); + image.put_pixel(112, x - 20, image::Rgba([120, 120, 120, 255])); + } + + // One connected square-ish text glyph has an empty center and enough + // perimeter to pass the detector's pre-existing size, density, and + // state filters, but it does not have four substantially occupied + // straight edges. + for (x, y) in [ + (144, 80), + (145, 80), + (146, 80), + (142, 81), + (143, 81), + (141, 82), + (140, 83), + (140, 84), + (140, 85), + (140, 86), + (140, 87), + (141, 88), + (142, 89), + (143, 89), + (144, 90), + (145, 90), + (146, 90), + (147, 89), + (148, 89), + (149, 88), + (150, 87), + (150, 86), + (150, 85), + (150, 84), + (150, 83), + (149, 82), + (148, 81), + (147, 81), + ] { + image.put_pixel(x, y, image::Rgba([120, 120, 120, 255])); + } + + assert!(detect_checkbox_pixels(&image, (130, 70, 170, 110), 1.0).is_empty()); + assert_eq!( + detect_checkbox_pixels(&image, (50, 50, 200, 150), 1.0), + vec![(106, 86, CheckboxState::Off)] + ); + } + + #[test] + fn pixel_checkbox_detector_accepts_rounded_antialiased_outlines() { + fn rounded_outline( + image: &mut image::RgbaImage, + left: u32, + top: u32, + side: u32, + radius: u32, + border: image::Rgba, + antialias: image::Rgba, + ) { + let right = left + side - 1; + let bottom = top + side - 1; + for offset in radius..side - radius { + image.put_pixel(left + offset, top, border); + image.put_pixel(left + offset, bottom, border); + image.put_pixel(left, top + offset, border); + image.put_pixel(right, top + offset, border); + } + for offset in 1..radius { + image.put_pixel(left + radius - offset, top + offset, border); + image.put_pixel(right - radius + offset, top + offset, border); + image.put_pixel(left + radius - offset, bottom - offset, border); + image.put_pixel(right - radius + offset, bottom - offset, border); + } + for (x, y) in [ + (left + radius - 1, top), + (right - radius + 1, top), + (left + radius - 1, bottom), + (right - radius + 1, bottom), + ] { + image.put_pixel(x, y, antialias); + } + } + + let mut light = image::RgbaImage::from_pixel(400, 250, image::Rgba([255, 255, 255, 255])); + rounded_outline( + &mut light, + 100, + 80, + 13, + 2, + image::Rgba([120, 120, 120, 255]), + image::Rgba([238, 238, 238, 255]), + ); + assert_eq!( + detect_checkbox_pixels(&light, (50, 50, 200, 150), 1.0), + vec![(106, 86, CheckboxState::Off)] + ); + + let mut retina = image::RgbaImage::from_pixel(600, 400, image::Rgba([32, 33, 36, 255])); + rounded_outline( + &mut retina, + 100, + 120, + 26, + 4, + image::Rgba([154, 160, 166, 255]), + image::Rgba([55, 56, 59, 255]), + ); + assert_eq!( + detect_checkbox_pixels(&retina, (50, 80, 250, 220), 2.0), + vec![(112, 132, CheckboxState::Off)] + ); + } + + #[test] + fn pixel_checkbox_detector_preserves_ambiguity() { + let mut image = image::RgbaImage::from_pixel(400, 250, image::Rgba([255, 255, 255, 255])); + for left in [80, 140] { + for coordinate in 0..=12 { + image.put_pixel(left + coordinate, 80, image::Rgba([120, 120, 120, 255])); + image.put_pixel(left + coordinate, 92, image::Rgba([120, 120, 120, 255])); + image.put_pixel(left, 80 + coordinate, image::Rgba([120, 120, 120, 255])); + image.put_pixel( + left + 12, + 80 + coordinate, + image::Rgba([120, 120, 120, 255]), + ); + } + } + assert_eq!( + detect_checkbox_pixels(&image, (50, 50, 200, 150), 1.0).len(), + 2 + ); + } + + #[test] + fn pixel_checkbox_detector_supports_retina_dark_mode_and_focus_ring() { + let mut retina = image::RgbaImage::from_pixel(600, 400, image::Rgba([32, 33, 36, 255])); + for coordinate in 0..=25 { + retina.put_pixel(100 + coordinate, 120, image::Rgba([154, 160, 166, 255])); + retina.put_pixel(100 + coordinate, 145, image::Rgba([154, 160, 166, 255])); + retina.put_pixel(100, 120 + coordinate, image::Rgba([154, 160, 166, 255])); + retina.put_pixel(125, 120 + coordinate, image::Rgba([154, 160, 166, 255])); + } + assert_eq!( + detect_checkbox_pixels(&retina, (50, 80, 250, 220), 2.0), + vec![(112, 132, CheckboxState::Off)] + ); + + let mut focused = image::RgbaImage::from_pixel(400, 250, image::Rgba([255, 255, 255, 255])); + for coordinate in 0..=16 { + focused.put_pixel(98 + coordinate, 78, image::Rgba([120, 120, 120, 255])); + focused.put_pixel(98 + coordinate, 94, image::Rgba([120, 120, 120, 255])); + focused.put_pixel(98, 78 + coordinate, image::Rgba([120, 120, 120, 255])); + focused.put_pixel(114, 78 + coordinate, image::Rgba([120, 120, 120, 255])); + } + assert_eq!( + detect_checkbox_pixels(&focused, (50, 50, 200, 150), 1.0), + vec![(106, 86, CheckboxState::Off)] + ); + } + + #[test] + fn pixel_checkbox_detector_enforces_bounded_zoom_geometry() { + fn outlined_box(side: u32) -> image::RgbaImage { + let mut image = + image::RgbaImage::from_pixel(100, 100, image::Rgba([255, 255, 255, 255])); + for coordinate in 0..side { + image.put_pixel(20 + coordinate, 20, image::Rgba([120, 120, 120, 255])); + image.put_pixel( + 20 + coordinate, + 20 + side - 1, + image::Rgba([120, 120, 120, 255]), + ); + image.put_pixel(20, 20 + coordinate, image::Rgba([120, 120, 120, 255])); + image.put_pixel( + 20 + side - 1, + 20 + coordinate, + image::Rgba([120, 120, 120, 255]), + ); + } + image + } + + assert_eq!( + detect_checkbox_pixels(&outlined_box(20), (0, 0, 100, 100), 1.0).len(), + 1 + ); + assert!(detect_checkbox_pixels(&outlined_box(6), (0, 0, 100, 100), 1.0).is_empty()); + assert!(detect_checkbox_pixels(&outlined_box(40), (0, 0, 100, 100), 1.0).is_empty()); + } + + #[test] + fn setup_geometry_validates_scale_and_round_trips_retina_coordinates() { + let frame = [-100.0, 50.0, 400.0, 250.0]; + let omnibox = [-90.0, 60.0, 380.0, 30.0]; + let geometry = setup_geometry(frame, omnibox, (800, 500)).unwrap(); + assert_eq!(geometry.scale_x, 2.0); + assert_eq!(geometry.scale_y, 2.0); + assert_eq!(geometry.search, (120, 140, 320, 380)); + assert_eq!( + pixel_to_screen(frame, (212, 172), geometry), + (6.0, 136.0, 106.0, 86.0) + ); + + assert!(setup_geometry(frame, omnibox, (800, 400)).is_err()); + assert!(setup_geometry([0.0, 0.0, 0.0, 250.0], omnibox, (800, 500)).is_err()); + assert!(setup_geometry(frame, omnibox, (200, 125)).is_ok()); + assert!(setup_geometry(frame, [-110.0, 60.0, 380.0, 30.0], (800, 500)).is_err()); + assert!(setup_geometry(frame, [-90.0, 290.0, 380.0, 30.0], (800, 500)).is_err()); + } + + #[test] + fn unique_pixel_checkbox_preserves_ambiguity_and_control_identity() { + let geometry = SetupGeometry { + search: (0, 0, 400, 250), + scale_x: 1.0, + scale_y: 1.0, + }; + assert!( + unique_pixel_checkbox(&[], [0.0, 0.0, 400.0, 250.0], geometry, chrome()) + .unwrap() + .is_none() + ); + assert_eq!( + unique_pixel_checkbox( + &[(106, 86, CheckboxState::Off), (166, 86, CheckboxState::On)], + [0.0, 0.0, 400.0, 250.0], + geometry, + chrome(), + ) + .unwrap_err() + .code, + BrowserRefusalCode::BrowserWrongTargetRefused + ); + let original = unique_pixel_checkbox( + &[(106, 86, CheckboxState::Off)], + [0.0, 0.0, 400.0, 250.0], + geometry, + chrome(), + ) + .unwrap() + .unwrap(); + let wrong_on = unique_pixel_checkbox( + &[(130, 86, CheckboxState::On)], + [0.0, 0.0, 400.0, 250.0], + geometry, + chrome(), + ) + .unwrap() + .unwrap(); + assert!(!original.same_control_as(wrong_on, 3.0)); + } + + #[test] + fn pixel_control_correspondence_and_mutation_accounting_fail_closed() { + let original = PixelCheckbox { + screen_x: 106.0, + screen_y: 136.0, + window_local_x: 106.0, + window_local_y: 86.0, + window_frame: [0.0, 50.0, 400.0, 250.0], + state: CheckboxState::Off, + }; + let near = PixelCheckbox { + window_local_x: 108.0, + window_local_y: 84.0, + ..original + }; + let far = PixelCheckbox { + window_local_x: 130.0, + ..original + }; + let resized = PixelCheckbox { + window_frame: [0.0, 50.0, 430.0, 250.0], + ..original + }; + assert!(original.same_control_as(near, 3.0)); + assert!(!original.same_control_as(far, 3.0)); + assert!(!original.same_control_as(resized, 3.0)); + assert!(frames_agree( + [0.0, 50.0, 400.0, 250.0], + [0.5, 49.5, 400.5, 250.5], + 1.0 + )); + assert!(!frames_agree( + [0.0, 50.0, 400.0, 250.0], + [0.0, 50.0, 405.0, 250.0], + 1.0 + )); + assert!(remote_debugging_cleanup_required(false, true)); + assert!(!remote_debugging_cleanup_required(false, false)); + } + #[test] fn new_tab_cleanup_selects_only_the_new_tabs_close_control() { let before = vec![ diff --git a/libs/cua-driver/rust/crates/platform-macos/src/capture.rs b/libs/cua-driver/rust/crates/platform-macos/src/capture.rs index 80944336fc..9220b481f8 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/capture.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/capture.rs @@ -12,10 +12,38 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use std::process::Command; +struct SecureCapturePath { + directory: std::path::PathBuf, + file: std::path::PathBuf, +} + +impl SecureCapturePath { + fn new(file_name: &str) -> anyhow::Result { + use std::os::unix::fs::DirBuilderExt; + + let directory = std::env::temp_dir().join(format!( + "cua-driver-rs-capture-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::DirBuilder::new().mode(0o700).create(&directory)?; + let file = directory.join(file_name); + Ok(Self { directory, file }) + } +} + +impl Drop for SecureCapturePath { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.file); + let _ = std::fs::remove_dir(&self.directory); + } +} + /// Capture a window by its `window_id` (CGWindowID). /// Returns raw PNG bytes or an error. pub fn screenshot_window_bytes(window_id: u32) -> anyhow::Result> { - let tmp_path = format!("/tmp/cua-driver-rs-capture-{}.png", window_id); + let capture = SecureCapturePath::new("window.png")?; + let tmp_path = capture.file.to_string_lossy().into_owned(); let output = Command::new("screencapture") .args([ @@ -41,8 +69,7 @@ pub fn screenshot_window_bytes(window_id: u32) -> anyhow::Result> { ); } - let bytes = std::fs::read(&tmp_path)?; - let _ = std::fs::remove_file(&tmp_path); + let bytes = std::fs::read(&capture.file)?; if bytes.is_empty() { anyhow::bail!("screencapture produced empty output for window {window_id}"); @@ -62,11 +89,11 @@ pub fn screenshot_window(window_id: u32) -> anyhow::Result<(String, u32, u32)> { /// Capture the full main display. /// Returns raw PNG bytes or an error. pub fn screenshot_display_bytes() -> anyhow::Result> { - // Use a pid-unique path so concurrent cua-driver processes don't step on each other. - let tmp_path = format!("/tmp/cua-driver-rs-display-{}.png", std::process::id()); + let capture = SecureCapturePath::new("display.png")?; + let tmp_path = capture.file.to_string_lossy().into_owned(); let output = Command::new("screencapture") - .args(["-x", &*tmp_path]) + .args(["-x", &tmp_path]) .output()?; if !output.status.success() { @@ -83,8 +110,7 @@ pub fn screenshot_display_bytes() -> anyhow::Result> { ); } - let bytes = std::fs::read(&tmp_path)?; - let _ = std::fs::remove_file(&tmp_path); + let bytes = std::fs::read(&capture.file)?; if bytes.is_empty() { anyhow::bail!("screencapture produced empty output for main display"); diff --git a/libs/cua-driver/rust/crates/platform-macos/src/lib.rs b/libs/cua-driver/rust/crates/platform-macos/src/lib.rs index 489f68a99f..53ef7e45db 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/lib.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/lib.rs @@ -57,7 +57,7 @@ pub fn register_tools_with_compat(compat: bool) -> ToolRegistry { #[cfg(target_os = "macos")] { let mut r = ToolRegistry::new(); - tools::register_all(&mut r, compat); + tools::register_all(&mut r, compat, false, false, None); r } #[cfg(not(target_os = "macos"))] @@ -77,20 +77,53 @@ pub fn register_tools_with_compat(compat: bool) -> ToolRegistry { /// the regular `screenshot` tool is replaced by a window-scoped variant /// (pid + window_id required, JPEG @ 85%, text note pointing at pixel /// tools). See `tools::screenshot_compat`. -pub fn register_tools_with_cursor(cfg: cursor_overlay::CursorConfig, compat: bool) -> ToolRegistry { +pub fn register_tools_with_cursor( + cfg: cursor_overlay::CursorConfig, + compat: bool, + host_owns_permission_ux: bool, + host_bundle_id: Option, +) -> ToolRegistry { #[cfg(target_os = "macos")] { - if cfg.enabled { + let cursor_overlay_available = + cursor_overlay_facility_available(cfg.enabled, session::has_graphic_access()); + if cursor_overlay_available { cursor::overlay::init(cfg); } let mut r = ToolRegistry::new(); - tools::register_all(&mut r, compat); + tools::register_all( + &mut r, + compat, + cursor_overlay_available, + host_owns_permission_ux, + host_bundle_id, + ); r } #[cfg(not(target_os = "macos"))] { let _ = cfg; let _ = compat; + let _ = host_owns_permission_ux; + let _ = host_bundle_id; ToolRegistry::new() } } + +#[cfg(target_os = "macos")] +fn cursor_overlay_facility_available(enabled: bool, graphic_access: bool) -> bool { + enabled && graphic_access +} + +#[cfg(all(test, target_os = "macos"))] +mod cursor_overlay_host_tests { + use super::cursor_overlay_facility_available; + + #[test] + fn overlay_requires_both_host_enablement_and_graphic_session_access() { + assert!(cursor_overlay_facility_available(true, true)); + assert!(!cursor_overlay_facility_available(false, true)); + assert!(!cursor_overlay_facility_available(true, false)); + assert!(!cursor_overlay_facility_available(false, false)); + } +} diff --git a/libs/cua-driver/rust/crates/platform-macos/src/tools/check_permissions.rs b/libs/cua-driver/rust/crates/platform-macos/src/tools/check_permissions.rs index e513946c0b..e2894b2574 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/tools/check_permissions.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/tools/check_permissions.rs @@ -4,13 +4,23 @@ use cua_driver_core::{ tool::{Tool, ToolDef}, }; use serde_json::Value; +use std::sync::Arc; +use super::ToolState; use crate::permissions::status::{ accessibility_granted, request_accessibility, request_screen_recording, screen_recording_granted, }; -pub struct CheckPermissionsTool; +pub struct CheckPermissionsTool { + state: Arc, +} + +impl CheckPermissionsTool { + pub fn new(state: Arc) -> Self { + Self { state } + } +} fn driver_bundle_id_for_executable(executable: &str) -> Option<&'static str> { if executable.contains("/CuaDriverLocal.app/Contents/MacOS/") { @@ -46,6 +56,10 @@ fn should_probe_direct_capture( should_prompt && screen_recording && probe_direct_capture } +fn should_prompt_permissions(requested: bool, host_owns_permission_ux: bool) -> bool { + requested && !cua_driver_core::embedded_mode() && !host_owns_permission_ux +} + /// (B) Which TCC identity the booleans in this response reflect. /// /// macOS attributes Accessibility / Screen-Recording to the *responsible @@ -55,7 +69,10 @@ fn should_probe_direct_capture( /// its own responsible process — the real driver status. /// - the **embedding host** otherwise. That is intentional only when the /// host directly spawned `cua-driver serve --embedded`. -fn permission_source() -> serde_json::Value { +fn permission_source( + host_owns_permission_ux: bool, + configured_host_bundle_id: Option<&str>, +) -> serde_json::Value { let pid = unsafe { libc::getpid() }; let ppid = unsafe { libc::getppid() }; let exe = std::env::current_exe() @@ -69,12 +86,16 @@ fn permission_source() -> serde_json::Value { // This branch only ever downgrades attribution (host, never // driver-daemon), so the caller-controlled env var can't spoof an // elevated identity. `host_bundle_id` is advisory, not a trust signal. - if cua_driver_core::embedded_mode() { - let host_bundle_id = std::env::var(cua_driver_core::HOST_BUNDLE_ID_ENV).unwrap_or_default(); + if host_owns_permission_ux || cua_driver_core::embedded_mode() { + let host_bundle_id = configured_host_bundle_id + .map(str::to_owned) + .or_else(|| std::env::var(cua_driver_core::HOST_BUNDLE_ID_ENV).ok()) + .unwrap_or_default(); return serde_json::json!({ "attribution": "host", "host_bundle_id": host_bundle_id, - "embedded": true, + "embedded": cua_driver_core::embedded_mode(), + "direct_runtime": host_owns_permission_ux && !cua_driver_core::embedded_mode(), "pid": pid, "responsible_ppid": ppid, "executable": exe, @@ -189,7 +210,10 @@ impl Tool for CheckPermissionsTool { // host owns the grant flow). This and the startup gate are the only // `request_*` call sites, so both being gated makes prompts // unreachable when embedded. - let should_prompt = args.bool_or("prompt", true) && !cua_driver_core::embedded_mode(); + let should_prompt = should_prompt_permissions( + args.bool_or("prompt", true), + self.state.host_owns_permission_ux, + ); let probe_direct_capture = args.bool_or("probe_direct_capture", true); if should_prompt { let _ = request_accessibility(); @@ -217,7 +241,10 @@ impl Tool for CheckPermissionsTool { (None, "not_checked") }; // (B) Which identity the booleans above belong to. - let source = permission_source(); + let source = permission_source( + self.state.host_owns_permission_ux, + self.state.host_bundle_id.as_deref(), + ); let is_caller = source.get("attribution").and_then(|v| v.as_str()) == Some("caller"); // Text format mirrors Swift 1:1: @@ -331,6 +358,51 @@ mod tests { assert!(should_probe_direct_capture(true, true, true)); } + #[test] + fn direct_host_runtime_cannot_raise_permission_prompts() { + let _guard = env_lock(); + let original = swap_env(cua_driver_core::EMBEDDED_ENV, None); + assert!( + should_prompt_permissions(true, false), + "standalone Cua-owned runtime retains its explicit prompt path" + ); + assert!( + !should_prompt_permissions(true, true), + "direct host-owned runtime must force read-only permission checks" + ); + restore_env(cua_driver_core::EMBEDDED_ENV, original); + } + + #[test] + fn direct_runtime_reports_host_attribution() { + let _guard = env_lock(); + let original = swap_env(cua_driver_core::EMBEDDED_ENV, None); + let source = permission_source(true, None); + assert_eq!( + source.get("attribution").and_then(|value| value.as_str()), + Some("host") + ); + assert_eq!( + source + .get("direct_runtime") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + restore_env(cua_driver_core::EMBEDDED_ENV, original); + } + + #[test] + fn immutable_runtime_host_label_wins_over_process_environment() { + let _guard = env_lock(); + let original_host = swap_env( + cua_driver_core::HOST_BUNDLE_ID_ENV, + Some("com.example.stale"), + ); + let source = permission_source(true, Some("com.example.runtime")); + assert_eq!(source["host_bundle_id"], "com.example.runtime"); + restore_env(cua_driver_core::HOST_BUNDLE_ID_ENV, original_host); + } + #[test] fn staged_prompt_never_runs_the_direct_capture_probe() { assert!(!should_probe_direct_capture(true, false, false)); @@ -349,7 +421,7 @@ mod tests { let original = swap_env(name, Some("1")); let embedded = swap_env(cua_driver_core::EMBEDDED_ENV, None); - let source = permission_source(); + let source = permission_source(false, None); assert_eq!( source.get("attribution").and_then(|v| v.as_str()), Some("caller"), @@ -369,7 +441,7 @@ mod tests { Some("com.example.host"), ); - let source = permission_source(); + let source = permission_source(false, None); assert_eq!( source.get("attribution").and_then(|v| v.as_str()), Some("host"), @@ -392,7 +464,7 @@ mod tests { let embedded = swap_env(cua_driver_core::EMBEDDED_ENV, Some("1")); let disclaim = swap_env(cua_driver_core::RESPONSIBILITY_DISCLAIMED_ENV, Some("1")); - let source = permission_source(); + let source = permission_source(false, None); assert_eq!( source.get("attribution").and_then(|v| v.as_str()), Some("host"), @@ -406,7 +478,7 @@ mod tests { fn embedded_env_requires_exact_value_one() { let _guard = env_lock(); let embedded = swap_env(cua_driver_core::EMBEDDED_ENV, Some("true")); - let source = permission_source(); + let source = permission_source(false, None); assert_ne!( source.get("attribution").and_then(|v| v.as_str()), Some("host"), diff --git a/libs/cua-driver/rust/crates/platform-macos/src/tools/cursor_tools.rs b/libs/cua-driver/rust/crates/platform-macos/src/tools/cursor_tools.rs index 71aeeb1230..7768b29233 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/tools/cursor_tools.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/tools/cursor_tools.rs @@ -95,6 +95,9 @@ impl Tool for SetAgentCursorEnabledTool { } async fn invoke(&self, args: Value) -> ToolResult { + if !self.state.cursor_overlay_available { + return super::cursor_overlay_unavailable(); + } use cua_driver_core::tool_args::ArgsExt; let enabled = match args.require_bool("enabled") { Ok(v) => v, @@ -220,6 +223,9 @@ impl Tool for SetAgentCursorMotionTool { } async fn invoke(&self, args: Value) -> ToolResult { + if !self.state.cursor_overlay_available { + return super::cursor_overlay_unavailable(); + } use cua_driver_core::tool_args::ArgsExt; let cursor_id = resolve_cursor_key(&args); @@ -391,6 +397,9 @@ impl Tool for SetAgentCursorStyleTool { } async fn invoke(&self, args: Value) -> ToolResult { + if !self.state.cursor_overlay_available { + return super::cursor_overlay_unavailable(); + } let cursor_id = resolve_cursor_key(&args); // ── image_path ──────────────────────────────────────────────────────── @@ -575,6 +584,9 @@ impl Tool for GetAgentCursorStateTool { } async fn invoke(&self, args: Value) -> ToolResult { + if !self.state.cursor_overlay_available { + return super::cursor_overlay_unavailable(); + } // Scope to the CALLER's cursor (explicit cursor_id > injected // _session_id > "default"). Returning every session's cursors here was a // cross-session leak, and deriving the top-level `enabled` via diff --git a/libs/cua-driver/rust/crates/platform-macos/src/tools/mod.rs b/libs/cua-driver/rust/crates/platform-macos/src/tools/mod.rs index 19c4dfa63d..a472fb3f0e 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/tools/mod.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/tools/mod.rs @@ -458,10 +458,30 @@ pub struct ToolState { /// see `CdpSessionCache` for why (Chrome's "allow remote debugging" /// popup fires on every new connection, not once per session). pub cdp_sessions: Arc, + /// Whether the runtime owner installed the AppKit main-thread cursor + /// overlay facility. Imported SDK runtimes deliberately leave this false; + /// explicit cursor-overlay methods must refuse instead of reporting a + /// successful no-op. + pub cursor_overlay_available: bool, + /// Direct and embedded hosts own TCC request UX. Their runtime may inspect + /// permission state but must not raise Cua-owned prompts. + pub host_owns_permission_ux: bool, + /// Advisory host identity for permission diagnostics only. + pub host_bundle_id: Option, } impl Default for ToolState { fn default() -> Self { + Self::new(false, false, None) + } +} + +impl ToolState { + fn new( + cursor_overlay_available: bool, + host_owns_permission_ux: bool, + host_bundle_id: Option, + ) -> Self { Self { element_cache: Arc::new(ElementCache::new()), cursor_registry: Arc::new(CursorRegistry::new()), @@ -472,16 +492,43 @@ impl Default for ToolState { config: Arc::new(std::sync::RwLock::new(load_driver_config())), session_config: Arc::new(SessionConfigRegistry::new()), cdp_sessions: Arc::new(crate::browser::CdpSessionCache::new()), + cursor_overlay_available, + host_owns_permission_ux, + host_bundle_id, } } } +pub(crate) fn cursor_overlay_unavailable() -> cua_driver_core::protocol::ToolResult { + let message = "macOS agent cursor overlay is unavailable: this runtime owner has no certified \ + AppKit main-thread host adapter or no Window Server graphic-session access; \ + use a GUI private worker or standalone service for cursor-overlay controls"; + cua_driver_core::protocol::ToolResult::error(message).with_structured(serde_json::json!({ + "status": "refused", + "refusal": { + "code": "facility_unavailable", + "facility": "macos_cursor_overlay", + "message": message, + } + })) +} + /// Register all macOS tools into the registry. `compat=true` swaps the /// regular `screenshot` tool for the Claude Code computer-use compat /// variant — same name, stricter args, window-scoped JPEG @ 85% + a text /// note telling the caller to use pixel-addressed tools. -pub fn register_all(registry: &mut ToolRegistry, compat: bool) { - let state = Arc::new(ToolState::default()); +pub fn register_all( + registry: &mut ToolRegistry, + compat: bool, + cursor_overlay_available: bool, + host_owns_permission_ux: bool, + host_bundle_id: Option, +) { + let state = Arc::new(ToolState::new( + cursor_overlay_available, + host_owns_permission_ux, + host_bundle_id, + )); { let cursor_registry = state.cursor_registry.clone(); let _ = cua_driver_core::session::set_cursor_outcome_reader(std::sync::Arc::new( @@ -584,7 +631,9 @@ pub fn register_all(registry: &mut ToolRegistry, compat: bool) { registry.register(Box::new(cursor_tools::GetAgentCursorStateTool::new( state.clone(), ))); - registry.register(Box::new(check_permissions::CheckPermissionsTool)); + registry.register(Box::new(check_permissions::CheckPermissionsTool::new( + state.clone(), + ))); // `health_report` — single-call end-to-end diagnostics. Stable // schema_version="1" contract aimed at downstream consumers who must // not have to know cua-driver internals. Provider is platform-specific; tool plumbing is in @@ -667,6 +716,40 @@ mod session_config_guard_tests { } } +#[cfg(test)] +mod cursor_overlay_facility_tests { + use super::*; + use cua_driver_core::tool::Tool; + + fn assert_facility_unavailable(result: cua_driver_core::protocol::ToolResult) { + assert_eq!(result.is_error, Some(true)); + let refusal = result + .structured_content + .and_then(|value| value.get("refusal").cloned()) + .expect("structured refusal"); + assert_eq!(refusal["code"], "facility_unavailable"); + assert_eq!(refusal["facility"], "macos_cursor_overlay"); + } + + #[tokio::test] + async fn cursor_control_refuses_without_main_thread_host_facility() { + let state = Arc::new(ToolState::new(false, true, None)); + let result = cursor_tools::SetAgentCursorEnabledTool::new(state) + .invoke(serde_json::json!({"enabled": true, "session": "test"})) + .await; + assert_facility_unavailable(result); + } + + #[tokio::test] + async fn window_cursor_move_refuses_without_main_thread_host_facility() { + let state = Arc::new(ToolState::new(false, true, None)); + let result = move_cursor::MoveCursorTool::new(state) + .invoke(serde_json::json!({"x": 10, "y": 20, "session": "test"})) + .await; + assert_facility_unavailable(result); + } +} + // RecordingSession lives in cua-driver-core, but its `start()` pulls in the // macOS cursor sampler (CoreGraphics), so the start-guard test runs here in // platform-macos where build.rs links the frameworks — the core crate's test diff --git a/libs/cua-driver/rust/crates/platform-macos/src/tools/move_cursor.rs b/libs/cua-driver/rust/crates/platform-macos/src/tools/move_cursor.rs index 1ad762d71b..f47ca64245 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/tools/move_cursor.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/tools/move_cursor.rs @@ -81,6 +81,9 @@ impl Tool for MoveCursorTool { Err(error) => ToolResult::error(format!("desktop pointer task failed: {error}")), }; } + if !self.state.cursor_overlay_available { + return super::cursor_overlay_unavailable(); + } let x = match args.require_f64("x") { Ok(v) => v, Err(e) => return e, diff --git a/libs/cua-driver/rust/crates/platform-windows/Cargo.toml b/libs/cua-driver/rust/crates/platform-windows/Cargo.toml index 727570ad4d..b85ff0cbcb 100644 --- a/libs/cua-driver/rust/crates/platform-windows/Cargo.toml +++ b/libs/cua-driver/rust/crates/platform-windows/Cargo.toml @@ -29,6 +29,7 @@ windows = { version = "0.58", features = [ "Win32_UI_Accessibility", "Win32_UI_WindowsAndMessaging", "Win32_System_Threading", + "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_Graphics_Gdi", "Win32_Graphics_Dwm", diff --git a/libs/cua-driver/rust/crates/platform-windows/src/browser_platform.rs b/libs/cua-driver/rust/crates/platform-windows/src/browser_platform.rs index f7b0476fd2..c34fec286a 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/browser_platform.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/browser_platform.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::future::Future; +use std::path::PathBuf; use std::process::Stdio; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -21,6 +22,7 @@ use cua_driver_core::browser::types::{ }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use windows::Win32::Foundation::{CloseHandle, FILETIME, HWND, RECT}; +use windows::Win32::System::SystemInformation::GetSystemDirectoryW; use windows::Win32::System::Threading::{ GetProcessTimes, OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT, PROCESS_QUERY_LIMITED_INFORMATION, @@ -133,6 +135,83 @@ fn browser_product(name: &str) -> BrowserProduct { } } +fn allows_embedded_descendant_endpoint(executable_path: &str) -> bool { + let executable = executable_path + .rsplit(['/', '\\']) + .next() + .unwrap_or(executable_path); + browser_product(executable) == BrowserProduct::Other && !is_chromium(executable) +} + +fn is_embedded_webview_runtime(executable_path: &str) -> bool { + executable_path + .rsplit(['/', '\\']) + .next() + .unwrap_or(executable_path) + .eq_ignore_ascii_case("msedgewebview2.exe") +} + +fn listener_process_belongs_to_root_lifetime(root_started: u64, listener_started: u64) -> bool { + listener_started >= root_started +} + +#[derive(Debug)] +struct LifetimeScopedProcessTree { + pids: Vec, + started_at: HashMap, +} + +fn lifetime_scoped_descendants_from_processes( + root_pid: u32, + processes: &[crate::win32::ProcessInfo], + mut started_at: impl FnMut(u32) -> Option, +) -> Option { + let root_started = started_at(root_pid)?; + let mut pids = vec![root_pid]; + let mut starts = HashMap::from([(root_pid, root_started)]); + let mut frontier = vec![root_pid]; + while let Some(parent_pid) = frontier.pop() { + let parent_started = starts[&parent_pid]; + for process in processes { + if process.parent_pid != parent_pid || starts.contains_key(&process.pid) { + continue; + } + let Some(child_started) = started_at(process.pid) else { + continue; + }; + // Toolhelp parent ids are not lifetime-scoped. Validate every + // parent -> child edge so pid reuse anywhere in the transitive + // tree cannot graft an older unrelated process onto this root. + if !listener_process_belongs_to_root_lifetime(parent_started, child_started) { + continue; + } + pids.push(process.pid); + starts.insert(process.pid, child_started); + frontier.push(process.pid); + } + } + Some(LifetimeScopedProcessTree { + pids, + started_at: starts, + }) +} + +fn retain_identity_matched_listeners( + observed: Vec<(u16, u32)>, + expected_starts: &HashMap, + mut current_started_at: impl FnMut(u32) -> Option, +) -> Vec<(u16, u32)> { + observed + .into_iter() + .filter(|(_port, owner_pid)| { + expected_starts + .get(owner_pid) + .zip(current_started_at(*owner_pid)) + .is_some_and(|(expected, current)| *expected == current) + }) + .collect() +} + fn websocket_port_and_suffix<'a>(url: &'a str, prefix: &str) -> Option<(u16, &'a str)> { let remainder = url.strip_prefix(prefix)?; let path_start = remainder.find('/')?; @@ -267,19 +346,23 @@ fn parse_netstat_loopback_ports(text: &str, allowed_pids: &[u32]) -> Vec { ports } -async fn loopback_listeners_for_process_tree( - root_pid: u32, +fn system_netstat_path() -> Result { + let mut buffer = [0u16; 32768]; + let length = unsafe { GetSystemDirectoryW(Some(&mut buffer)) } as usize; + if length == 0 || length >= buffer.len() { + return Err(refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + "could not resolve the trusted Windows system directory", + )); + } + Ok(PathBuf::from(String::from_utf16_lossy(&buffer[..length])).join("netstat.exe")) +} + +async fn netstat_loopback_listeners( + allowed_pids: &[u32], ) -> Result, BrowserRefusal> { - let allowed_pids = - tokio::task::spawn_blocking(move || crate::win32::list_descendants(root_pid)) - .await - .map_err(|error| { - refusal( - BrowserRefusalCode::BrowserRouteUnavailable, - format!("could not inspect browser process tree: {error}"), - ) - })?; - let output = tokio::process::Command::new("netstat.exe") + let netstat = system_netstat_path()?; + let output = tokio::process::Command::new(netstat) .args(["-ano", "-p", "tcp"]) .stdin(Stdio::null()) .stderr(Stdio::null()) @@ -293,12 +376,121 @@ async fn loopback_listeners_for_process_tree( })?; Ok(parse_netstat_loopback_listeners( &String::from_utf8_lossy(&output.stdout), - &allowed_pids, + allowed_pids, )) } -async fn loopback_ports_for_process_tree(root_pid: u32) -> Result, BrowserRefusal> { - let mut ports = loopback_listeners_for_process_tree(root_pid) +async fn raw_loopback_listeners_for_process_tree( + root_pid: u32, +) -> Result, BrowserRefusal> { + let allowed_pids = + tokio::task::spawn_blocking(move || crate::win32::list_descendants(root_pid)) + .await + .map_err(|error| { + refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + format!("could not inspect browser process tree: {error}"), + ) + })?; + let observed = netstat_loopback_listeners(&allowed_pids).await?; + tokio::task::spawn_blocking(move || { + observed + .into_iter() + .filter(|(_port, owner_pid)| process_identity(*owner_pid).is_ok()) + .collect::>() + }) + .await + .map_err(|error| { + refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + format!("could not inspect spawned browser listener identities: {error}"), + ) + }) +} + +async fn loopback_listeners_for_process_tree( + root_pid: u32, +) -> Result, BrowserRefusal> { + let tree = tokio::task::spawn_blocking(move || { + let processes = crate::win32::list_processes(); + lifetime_scoped_descendants_from_processes(root_pid, &processes, |pid| { + process_identity(pid).ok().map(|identity| identity.0) + }) + }) + .await + .map_err(|error| { + refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + format!("could not inspect browser process lifetimes: {error}"), + ) + })? + .ok_or_else(|| { + refusal( + BrowserRefusalCode::BrowserBindingStale, + format!("browser process {root_pid} is no longer available"), + ) + })?; + + let observed = netstat_loopback_listeners(&tree.pids).await?; + let expected_starts = tree.started_at; + tokio::task::spawn_blocking(move || { + retain_identity_matched_listeners(observed, &expected_starts, |pid| { + process_identity(pid).ok().map(|identity| identity.0) + }) + }) + .await + .map_err(|error| { + refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + format!("could not reprove browser listener identities: {error}"), + ) + }) +} + +async fn loopback_listeners_for_exact_pid(pid: u32) -> Result, BrowserRefusal> { + let expected_started = + tokio::task::spawn_blocking(move || process_identity(pid).map(|identity| identity.0)) + .await + .map_err(|error| { + refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + format!("could not inspect browser process identity: {error}"), + ) + })??; + let observed = netstat_loopback_listeners(&[pid]).await?; + tokio::task::spawn_blocking(move || { + retain_identity_matched_listeners( + observed, + &HashMap::from([(pid, expected_started)]), + |candidate_pid| { + process_identity(candidate_pid) + .ok() + .map(|identity| identity.0) + }, + ) + }) + .await + .map_err(|error| { + refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + format!("could not reprove browser process identity: {error}"), + ) + }) +} + +async fn loopback_ports_for_exact_pid(pid: u32) -> Result, BrowserRefusal> { + let mut ports = loopback_listeners_for_exact_pid(pid) + .await? + .into_iter() + .map(|(port, _owner_pid)| port) + .collect::>(); + ports.sort_unstable(); + ports.dedup(); + Ok(ports) +} + +async fn unfiltered_loopback_ports_for_exact_pid(pid: u32) -> Result, BrowserRefusal> { + let mut ports = netstat_loopback_listeners(&[pid]) .await? .into_iter() .map(|(port, _owner_pid)| port) @@ -357,9 +549,22 @@ const ENDPOINT_DISCOVERY_RETRY_DELAY: Duration = Duration::from_millis(100); async fn browser_endpoints_once(pid: u32) -> Result, BrowserRefusal> { let mut endpoints = Vec::new(); - for (port, owner_pid) in loopback_listeners_for_process_tree(pid).await? { + for (port, owner_pid) in loopback_listeners_for_exact_pid(pid).await? { if let Some(ws_url) = browser_websocket_url(port).await { - endpoints.push((port, ws_url, owner_pid)); + // Re-read both the socket owner and process identity after the + // HTTP probe so pid recycling during discovery cannot become + // exact ownership evidence. + let reproved = loopback_listeners_for_exact_pid(pid).await?; + if reproved.contains(&(port, owner_pid)) { + endpoints.push((port, ws_url, owner_pid)); + } else { + tracing::debug!( + browser_pid = pid, + listener_pid = owner_pid, + port, + "discarding browser endpoint whose listener ownership changed during discovery" + ); + } } } Ok(endpoints) @@ -385,7 +590,9 @@ where unreachable!("the bounded endpoint-discovery loop always returns") } -async fn browser_endpoints_for_pid(pid: u32) -> Result, BrowserRefusal> { +async fn exact_browser_endpoints_for_pid( + pid: u32, +) -> Result, BrowserRefusal> { retry_empty_endpoint_discovery( ENDPOINT_DISCOVERY_ATTEMPTS, ENDPOINT_DISCOVERY_RETRY_DELAY, @@ -394,6 +601,179 @@ async fn browser_endpoints_for_pid(pid: u32) -> Result, .await } +async fn root_can_use_embedded_descendant_endpoint(pid: u32) -> Result { + tokio::task::spawn_blocking(move || { + let (_started, executable) = process_identity(pid)?; + Ok(executable.is_some_and(|path| allows_embedded_descendant_endpoint(&path))) + }) + .await + .map_err(|error| { + refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + format!("could not classify browser process identity: {error}"), + ) + })? +} + +async fn embedded_browser_endpoints_once( + pid: u32, +) -> Result, BrowserRefusal> { + let mut endpoints = Vec::new(); + for (port, listener_pid) in loopback_listeners_for_process_tree(pid).await? { + let is_webview_runtime = tokio::task::spawn_blocking(move || { + process_identity(listener_pid) + .ok() + .and_then(|identity| identity.1) + .is_some_and(|path| is_embedded_webview_runtime(&path)) + }) + .await + .map_err(|error| { + refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + format!("could not classify embedded browser listener: {error}"), + ) + })?; + if !is_webview_runtime { + continue; + } + if let Some(ws_url) = browser_websocket_url(port).await { + let reproved = loopback_listeners_for_process_tree(pid).await?; + if reproved.contains(&(port, listener_pid)) { + endpoints.push((port, ws_url, listener_pid)); + } + } + } + Ok(endpoints) +} + +async fn browser_endpoints_for_pid(pid: u32) -> Result, BrowserRefusal> { + let exact = exact_browser_endpoints_for_pid(pid).await?; + if !exact.is_empty() || !root_can_use_embedded_descendant_endpoint(pid).await? { + return Ok(exact); + } + // Native embedded hosts such as Tauri/WPF own the window while a + // WebView2 child owns DevTools. Standalone Chromium/Electron executables + // never enter this fallback: their endpoint must be owned by the exact + // approved pid. + retry_empty_endpoint_discovery( + ENDPOINT_DISCOVERY_ATTEMPTS, + ENDPOINT_DISCOVERY_RETRY_DELAY, + || embedded_browser_endpoints_once(pid), + ) + .await +} + +async fn loopback_listeners_for_spawned_tree( + root_pid: u32, +) -> Result, BrowserRefusal> { + match loopback_listeners_for_process_tree(root_pid).await { + Ok(listeners) => Ok(listeners), + // Edge on Windows ARM can transfer the browser role to a descendant + // and let its launcher exit. This fallback is used only while core + // attests the exact private-profile DevTools URL it just read from + // DevToolsActivePort; ordinary and existing-profile discovery never + // accept descendant-owned endpoints. + Err(error) if error.code == BrowserRefusalCode::BrowserBindingStale => { + raw_loopback_listeners_for_process_tree(root_pid).await + } + Err(error) => Err(error), + } +} + +async fn spawned_browser_endpoints_once( + root_pid: u32, + expected_ws_url: &str, +) -> Result, BrowserRefusal> { + let Some(expected_port) = literal_loopback_websocket_port(expected_ws_url) else { + return Err(refusal( + BrowserRefusalCode::BrowserEndpointOwnerMismatch, + "the driver-spawned browser endpoint is not loopback-only", + )); + }; + let mut endpoints = Vec::new(); + for (port, listener_pid) in loopback_listeners_for_spawned_tree(root_pid) + .await? + .into_iter() + .filter(|(port, _listener_pid)| *port == expected_port) + { + if browser_websocket_url(port).await.as_deref() != Some(expected_ws_url) { + continue; + } + let reproved = loopback_listeners_for_spawned_tree(root_pid).await?; + if reproved.contains(&(port, listener_pid)) { + endpoints.push((port, expected_ws_url.to_owned(), listener_pid)); + } + } + Ok(endpoints) +} + +async fn spawned_browser_endpoints_for_pid( + root_pid: u32, + expected_ws_url: &str, +) -> Result, BrowserRefusal> { + retry_empty_endpoint_discovery( + ENDPOINT_DISCOVERY_ATTEMPTS, + ENDPOINT_DISCOVERY_RETRY_DELAY, + || spawned_browser_endpoints_once(root_pid, expected_ws_url), + ) + .await +} + +fn owned_endpoint_from_listener( + root_pid: i64, + port: u16, + ws_url: String, + listener_pid: u32, + context: &str, +) -> OwnedEndpoint { + OwnedEndpoint { + ws_url, + http_port: Some(port), + ownership: EndpointOwnershipProof { + method: EndpointOwnershipMethod::ListeningSocketPid, + // The discovery route proved listener_pid under its documented + // ownership scope. Core authorizes and fingerprints root_pid; + // retain the exact socket owner separately for audit evidence and + // the narrow Windows launcher-handoff promotion path. + owner_pid: root_pid, + listener_pid: Some(i64::from(listener_pid)), + detail: Some(format!( + "{context}; exact loopback listener pid {listener_pid}" + )), + }, + } +} + +fn select_unique_owned_endpoint( + root_pid: i64, + discovered: Vec<(u16, String, u32)>, + context: &str, +) -> Result, BrowserRefusal> { + match discovered.as_slice() { + [] => Ok(None), + [(port, ws_url, listener_pid)] => Ok(Some(owned_endpoint_from_listener( + root_pid, + *port, + ws_url.clone(), + *listener_pid, + context, + ))), + _ => Err(refusal( + BrowserRefusalCode::BrowserBindingAmbiguous, + "multiple browser-level DevTools endpoints satisfy the approved ownership scope", + ) + .with_detail(serde_json::json!({ + "candidates": discovered + .iter() + .map(|(port, _ws_url, listener_pid)| serde_json::json!({ + "port": port, + "listener_pid": listener_pid, + })) + .collect::>(), + }))), + } +} + async fn loopback_port_is_owned_with_retry( pid: u32, expected_port: u16, @@ -402,7 +782,7 @@ async fn loopback_port_is_owned_with_retry( ENDPOINT_DISCOVERY_ATTEMPTS, ENDPOINT_DISCOVERY_RETRY_DELAY, expected_port, - || loopback_ports_for_process_tree(pid), + || loopback_ports_for_exact_pid(pid), ) .await } @@ -625,21 +1005,29 @@ impl BrowserPlatform for WindowsBrowserPlatform { format!("pid {pid} is outside the Windows process-id range"), ) })?; - Ok(browser_endpoints_for_pid(pid_u32) - .await? - .into_iter() - .next() - .map(|(port, ws_url, owner_pid)| OwnedEndpoint { - ws_url, - http_port: Some(port), - ownership: EndpointOwnershipProof { - method: EndpointOwnershipMethod::ListeningSocketPid, - owner_pid: i64::from(owner_pid), - detail: Some( - "netstat listener owned by the approved browser process tree; the exact listener owner is the stable browser pid".to_owned(), - ), - }, - })) + select_unique_owned_endpoint( + pid, + browser_endpoints_for_pid(pid_u32).await?, + "listener owned by the exact approved browser pid or its classified embedded webview tree", + ) + } + + async fn discover_spawned_endpoint( + &self, + pid: i64, + expected_ws_url: &str, + ) -> Result, BrowserRefusal> { + let pid_u32 = u32::try_from(pid).map_err(|_| { + refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!("pid {pid} is outside the Windows process-id range"), + ) + })?; + select_unique_owned_endpoint( + pid, + spawned_browser_endpoints_for_pid(pid_u32, expected_ws_url).await?, + "exact private-profile endpoint owned by the driver-spawned browser tree", + ) } async fn discover_existing_profile_endpoint( @@ -652,25 +1040,11 @@ impl BrowserPlatform for WindowsBrowserPlatform { format!("pid {pid} is outside the Windows process-id range"), ) })?; - let discovered = browser_endpoints_for_pid(pid_u32).await?; - match discovered.as_slice() { - [] => Ok(None), - [(port, ws_url, owner_pid)] => Ok(Some(OwnedEndpoint { - ws_url: ws_url.clone(), - http_port: Some(*port), - ownership: EndpointOwnershipProof { - method: EndpointOwnershipMethod::ListeningSocketPid, - owner_pid: i64::from(*owner_pid), - detail: Some( - "Windows browser process-tree listener plus /json/version".to_owned(), - ), - }, - })), - _ => Err(refusal( - BrowserRefusalCode::BrowserBindingAmbiguous, - "multiple browser-level DevTools endpoints are owned by the approved browser process tree", - )), - } + select_unique_owned_endpoint( + pid, + exact_browser_endpoints_for_pid(pid_u32).await?, + "Windows exact browser-pid listener plus /json/version", + ) } async fn reprove_existing_profile_endpoint( @@ -702,9 +1076,8 @@ impl BrowserPlatform for WindowsBrowserPlatform { ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::ListeningSocketPid, owner_pid: pid, - detail: Some( - "Windows browser process-tree owner of exact approved endpoint".to_owned(), - ), + listener_pid: None, + detail: Some("Windows exact browser-pid owner of approved endpoint".to_owned()), }, })) } @@ -729,7 +1102,10 @@ impl BrowserPlatform for WindowsBrowserPlatform { ) })?; let hwnd = request.window_id; - let listeners_before = loopback_ports_for_process_tree(pid_u32).await?; + // The subtraction baseline must remain a conservative superset: a + // transient identity-reproof failure must not make an old exact-pid + // listener appear newly created after the approved setup action. + let listeners_before = unfiltered_loopback_ports_for_exact_pid(pid_u32).await?; let handle = tokio::task::spawn_blocking(move || crate::browser_setup_ui::enable(hwnd, descriptor)) .await @@ -750,7 +1126,7 @@ impl BrowserPlatform for WindowsBrowserPlatform { let deadline = std::time::Instant::now() + Duration::from_secs(6); let endpoint_result = loop { - let ports = match loopback_ports_for_process_tree(pid_u32).await { + let ports = match loopback_ports_for_exact_pid(pid_u32).await { Ok(ports) => ports, Err(error) => break Err(error), }; @@ -760,7 +1136,7 @@ impl BrowserPlatform for WindowsBrowserPlatform { endpoints.push(( *port, ws_url, - "Windows browser process-tree owner plus /json/version", + "Windows exact browser-pid owner plus /json/version", )); } } @@ -774,13 +1150,13 @@ impl BrowserPlatform for WindowsBrowserPlatform { endpoints.push(( *port, format!("ws://127.0.0.1:{port}/devtools/browser"), - "new browser process-tree listener correlated with exact approved setup", + "new exact browser-pid listener correlated with approved setup", )); } else if correlated.len() > 1 { break Err(refusal( BrowserRefusalCode::BrowserBindingAmbiguous, format!( - "{} exposed multiple newly correlated browser process-tree listeners", + "{} exposed multiple newly correlated exact-pid listeners", descriptor.product_name ), )); @@ -794,6 +1170,7 @@ impl BrowserPlatform for WindowsBrowserPlatform { ownership: EndpointOwnershipProof { method: EndpointOwnershipMethod::ListeningSocketPid, owner_pid: request.pid, + listener_pid: None, detail: Some((*detail).to_owned()), }, }) @@ -805,7 +1182,7 @@ impl BrowserPlatform for WindowsBrowserPlatform { break Err(refusal( BrowserRefusalCode::BrowserRequiresSetup, format!( - "{} did not expose a uniquely process-tree-owned loopback endpoint after the exact setup action", + "{} did not expose a uniquely exact-pid-owned loopback endpoint after the exact setup action", descriptor.product_name ), )) @@ -814,7 +1191,7 @@ impl BrowserPlatform for WindowsBrowserPlatform { break Err(refusal( BrowserRefusalCode::BrowserBindingAmbiguous, format!( - "{} exposed multiple process-tree-owned endpoint candidates after the exact setup action", + "{} exposed multiple exact-pid-owned endpoint candidates after the exact setup action", descriptor.product_name ), )) @@ -841,6 +1218,7 @@ impl BrowserPlatform for WindowsBrowserPlatform { opened_setup_page, closed_setup_page: false, enabled_remote_debugging, + used_bounded_pixel_fallback: false, focused_setup_address_field, foregrounded_window, injected_global_input, @@ -997,6 +1375,79 @@ mod tests { ); } + #[test] + fn process_tree_endpoint_uses_the_authorized_root_and_retains_listener_evidence() { + let endpoint = owned_endpoint_from_listener( + 42, + 9222, + "ws://127.0.0.1:9222/devtools/browser/id".to_owned(), + 43, + "verified process tree", + ); + + assert_eq!(endpoint.ownership.owner_pid, 42); + assert_eq!(endpoint.ownership.listener_pid, Some(43)); + assert_eq!(endpoint.http_port, Some(9222)); + assert!(endpoint + .ownership + .detail + .as_deref() + .is_some_and(|detail| detail.contains("exact loopback listener pid 43"))); + } + + #[test] + fn owned_endpoint_selection_requires_exactly_one_lifetime_matched_listener() { + assert!( + select_unique_owned_endpoint(42, Vec::new(), "verified process tree") + .expect("empty discovery is not an error") + .is_none() + ); + + let selected = select_unique_owned_endpoint( + 42, + vec![( + 9222, + "ws://127.0.0.1:9222/devtools/browser/edge".to_owned(), + 43, + )], + "verified process tree", + ) + .expect("one lifetime-matched listener") + .expect("selected endpoint"); + assert_eq!(selected.http_port, Some(9222)); + assert_eq!(selected.ownership.listener_pid, Some(43)); + + let ambiguous = select_unique_owned_endpoint( + 42, + vec![ + ( + 9222, + "ws://127.0.0.1:9222/devtools/browser/a".to_owned(), + 43, + ), + ( + 9333, + "ws://127.0.0.1:9333/devtools/browser/b".to_owned(), + 44, + ), + ], + "verified process tree", + ) + .expect_err("multiple lifetime-matched listeners must be refused"); + assert_eq!(ambiguous.code, BrowserRefusalCode::BrowserBindingAmbiguous); + let candidates = ambiguous + .detail + .as_ref() + .and_then(|detail| detail.get("candidates")) + .and_then(serde_json::Value::as_array) + .expect("candidate detail"); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0]["port"], 9222); + assert_eq!(candidates[0]["listener_pid"], 43); + assert_eq!(candidates[1]["port"], 9333); + assert_eq!(candidates[1]["listener_pid"], 44); + } + #[test] fn classifier_covers_embedded_and_standalone_chromium() { assert!(is_chromium("CuaTestHarness.Electron.exe")); @@ -1006,6 +1457,124 @@ mod tests { assert!(!is_chromium("Knowledge.exe")); } + #[test] + fn only_native_embedded_hosts_may_use_descendant_owned_devtools() { + assert!(allows_embedded_descendant_endpoint( + r"D:\fixtures\CuaTestHarness.Tauri.exe" + )); + assert!(allows_embedded_descendant_endpoint( + r"D:\fixtures\CuaTestHarness.WebView.exe" + )); + assert!(!allows_embedded_descendant_endpoint( + r"C:\Program Files\Google\Chrome\Application\chrome.exe" + )); + assert!(!allows_embedded_descendant_endpoint( + r"D:\fixtures\CuaTestHarness.Electron.exe" + )); + assert!(is_embedded_webview_runtime( + r"C:\Program Files (x86)\Microsoft\EdgeWebView\Application\msedgewebview2.exe" + )); + assert!(!is_embedded_webview_runtime( + r"D:\fixtures\CuaTestHarness.Electron.exe" + )); + } + + #[test] + fn endpoint_listener_cannot_predate_the_authorized_browser_root() { + assert!(listener_process_belongs_to_root_lifetime(100, 100)); + assert!(listener_process_belongs_to_root_lifetime(100, 101)); + assert!(!listener_process_belongs_to_root_lifetime(100, 99)); + } + + #[test] + fn lifetime_scoped_tree_validates_every_parent_child_edge() { + let processes = vec![ + crate::win32::ProcessInfo { + pid: 42, + parent_pid: 1, + name: "msedge.exe".to_owned(), + }, + crate::win32::ProcessInfo { + pid: 43, + parent_pid: 42, + name: "msedge.exe".to_owned(), + }, + crate::win32::ProcessInfo { + pid: 44, + parent_pid: 43, + name: "CuaTestHarness.Electron.exe".to_owned(), + }, + crate::win32::ProcessInfo { + pid: 45, + parent_pid: 42, + name: "msedge.exe".to_owned(), + }, + ]; + let starts = HashMap::from([ + (42, 100), + // This pid was reused by a real child of the new browser root. + (43, 300), + // This unrelated process names pid 43 as its parent but predates + // that incarnation, so validating only against root 42 is unsafe. + (44, 200), + (45, 400), + ]); + + let tree = lifetime_scoped_descendants_from_processes(42, &processes, |pid| { + starts.get(&pid).copied() + }) + .expect("live root"); + assert_eq!(tree.pids, vec![42, 43, 45]); + assert!(!tree.pids.contains(&44)); + } + + #[test] + fn lifetime_scoped_tree_drops_unidentifiable_processes_and_their_children() { + let processes = vec![ + crate::win32::ProcessInfo { + pid: 42, + parent_pid: 1, + name: "chrome.exe".to_owned(), + }, + crate::win32::ProcessInfo { + pid: 43, + parent_pid: 42, + name: "chrome.exe".to_owned(), + }, + crate::win32::ProcessInfo { + pid: 44, + parent_pid: 43, + name: "chrome.exe".to_owned(), + }, + ]; + let starts = HashMap::from([(42, 100), (44, 300)]); + + let tree = lifetime_scoped_descendants_from_processes(42, &processes, |pid| { + starts.get(&pid).copied() + }) + .expect("live root"); + assert_eq!(tree.pids, vec![42]); + } + + #[test] + fn listener_identity_reproof_drops_recycled_and_vanished_pids() { + let observed = vec![(9222, 42), (9333, 43), (9444, 44)]; + let expected = HashMap::from([(42, 100), (43, 200), (44, 300)]); + let current = HashMap::from([ + (42, 100), + // pid 43 was recycled between the socket snapshot and reproof. + (43, 201), + // pid 44 vanished and is intentionally absent. + ]); + + assert_eq!( + retain_identity_matched_listeners(observed, &expected, |pid| { + current.get(&pid).copied() + }), + vec![(9222, 42)] + ); + } + #[test] fn firefox_classifier_uses_product_tokens() { assert!(is_firefox("firefox.exe")); diff --git a/libs/cua-driver/rust/crates/platform-windows/src/diagnostics.rs b/libs/cua-driver/rust/crates/platform-windows/src/diagnostics.rs index 6914ada1f1..46c38504fa 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/diagnostics.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/diagnostics.rs @@ -201,12 +201,25 @@ pub fn desktop_state() -> DesktopState { #[cfg(target_os = "windows")] pub fn interactive_desktop_check() -> Result { let state = desktop_state(); - if state.session_id == Some(0) { - return Err("running in Windows Session 0".to_owned()); + classify_interactive_desktop(&state) +} + +#[cfg(target_os = "windows")] +fn classify_interactive_desktop(state: &DesktopState) -> Result { + match state.session_id { + Some(0) => return Err("running in Windows Session 0".to_owned()), + Some(_) => {} + None => { + return Err( + "could not determine the Windows session id; refusing desktop runtime ownership" + .to_owned(), + ); + } } if !state.has_process_window_station { return Err(state .process_window_station_error + .clone() .unwrap_or_else(|| "GetProcessWindowStation returned null".to_owned())); } Ok(state.has_foreground_window()) @@ -313,6 +326,42 @@ pub fn is_non_interactive_error(err: &str) -> bool { mod tests { use super::*; + fn state(session_id: u32, attached: bool, foreground: bool) -> DesktopState { + DesktopState { + session_id: Some(session_id), + has_process_window_station: attached, + process_window_station_error: (!attached).then(|| "no station".into()), + thread_desktop_name: None, + thread_desktop_error: None, + input_desktop_name: None, + input_desktop_error: None, + foreground_hwnd: foreground.then_some(1), + } + } + + #[test] + fn session_zero_is_classified_as_runtime_unavailable() { + let error = classify_interactive_desktop(&state(0, true, true)).unwrap_err(); + assert!(error.contains("Session 0")); + assert_eq!( + classify_interactive_desktop(&state(2, true, true)), + Ok(true) + ); + assert_eq!( + classify_interactive_desktop(&state(2, true, false)), + Ok(false), + "a temporarily locked interactive session remains a valid owner" + ); + let mut unknown = state(2, true, true); + unknown.session_id = None; + assert!( + classify_interactive_desktop(&unknown) + .unwrap_err() + .contains("could not determine"), + "an unclassified process must not be allowed to claim an interactive runtime" + ); + } + #[test] fn current_session_id_returns_some_on_windows() { // ProcessIdToSessionId on the current process must always succeed. diff --git a/libs/cua-driver/scripts/install.ps1 b/libs/cua-driver/scripts/install.ps1 index 3845e3bebf..70d732bd16 100644 --- a/libs/cua-driver/scripts/install.ps1 +++ b/libs/cua-driver/scripts/install.ps1 @@ -1098,10 +1098,11 @@ if (-not $skipDownload) { New-Item -ItemType Directory -Force -Path $versionedDir | Out-Null Copy-Item -LiteralPath (Join-Path $stageDir $BinaryName) -Destination (Join-Path $versionedDir $BinaryName) -Force Write-Step "installed $versionedDir\$BinaryName (version $version, target $target)" - # Optional sibling: the uiAccess'd worker (cua-driver-uia.exe). Started - # shipping with cua-driver-rs-v0.2.8; absent in earlier releases. Copy - # it when present so `cua-driver autostart enable` can register the - # second ShellExecute-based scheduled task. See #1602. + # Optional sibling: the reserved uiAccess worker + # (cua-driver-uia.exe). It started shipping with + # cua-driver-rs-v0.2.8 and is absent in earlier releases. Copy it when + # present for a future authenticated daemon-internal forwarding path; + # current autostart does not launch it. See #1602. $uiaStage = Join-Path $stageDir 'cua-driver-uia.exe' if (Test-Path -LiteralPath $uiaStage) { Copy-Item -LiteralPath $uiaStage -Destination (Join-Path $versionedDir 'cua-driver-uia.exe') -Force diff --git a/libs/cua-driver/tests/fixtures/apps/cross-platform/tauri/build.sh b/libs/cua-driver/tests/fixtures/apps/cross-platform/tauri/build.sh index d4bd8389e0..4ff9aeeedd 100755 --- a/libs/cua-driver/tests/fixtures/apps/cross-platform/tauri/build.sh +++ b/libs/cua-driver/tests/fixtures/apps/cross-platform/tauri/build.sh @@ -59,6 +59,13 @@ if [ "$platform" = "Darwin" ]; then PLIST xattr -cr "$bundle" 2>/dev/null || true + # Cargo's Mach-O is linker-signed, but copying it into a hand-built bundle + # leaves the bundle without a valid CodeResources envelope. In the macOS E2E + # VM this Tauri bundle produced a blank window and errSecCSUnsigned (-67062) + # while launching its sandboxed content process. An ad-hoc deep signature is + # sufficient for this disposable test fixture; the driver under test retains + # its separate stable signing identity. + codesign --force --deep --sign - "$bundle" echo "[OK] Staged: $bundle" else srcBin="$tauriDir/src-tauri/target/release/cua-test-harness-tauri" diff --git a/libs/cua-driver/tests/fixtures/apps/cross-platform/tauri/src-tauri/src/main.rs b/libs/cua-driver/tests/fixtures/apps/cross-platform/tauri/src-tauri/src/main.rs index 0b051bab82..b776dfb390 100644 --- a/libs/cua-driver/tests/fixtures/apps/cross-platform/tauri/src-tauri/src/main.rs +++ b/libs/cua-driver/tests/fixtures/apps/cross-platform/tauri/src-tauri/src/main.rs @@ -49,7 +49,12 @@ fn main() { #[cfg(target_os = "windows")] let window = window.position(40.0, 40.0); - window.build()?; + let window = window.build()?; + // The fixture is spawned directly by the behavioral runner instead + // of LaunchServices. Explicit focus makes its foreground-input rows + // deterministic; background rows immediately replace this posture + // with their sentinel before recording begins. + window.set_focus()?; Ok(()) }) .run(tauri::generate_context!()) diff --git a/libs/cua-driver/tests/runners/macos-lume/README.md b/libs/cua-driver/tests/runners/macos-lume/README.md index 54de259a6e..13ad608b79 100644 --- a/libs/cua-driver/tests/runners/macos-lume/README.md +++ b/libs/cua-driver/tests/runners/macos-lume/README.md @@ -292,7 +292,20 @@ separate typed results and MP4 evidence under `artifacts/cua-driver/macos-standalone-browser/`. Missing external browsers are a hard failure for this option; they never shrink the reported matrix. On a repeat run, the entrypoint preserves the previous standalone-browser evidence -in a temporary archive before creating a fresh artifact directory. +in a temporary archive before creating a fresh artifact directory. The +entrypoint temporarily restarts the disposable worker daemon in unrestricted +mode for the authorized existing-profile success rows, then restores its +standard autostart daemon even when a browser row fails. + +On macOS Tahoe, first-use Chrome can present a native local-network discovery +prompt over `chrome://inspect/#remote-debugging`. The standalone-browser lane +uses loopback DevTools and does not need LAN discovery. Before freezing a seed +that will run this optional lane, launch Chrome on that exact page in the VM +display, choose **Don't Allow**, quit Chrome, then relaunch the page and require +that the prompt does not return. Do not answer or dismiss OS consent UI while +the behavior matrix is running. If an existing immutable seed lacks this +decision, clone it to a new versioned seed, complete this setup there, stop it, +and use that new seed for workers; never update the original seed in place. The entrypoint refuses the wrong OS, user session, SIP state, dirty or unidentified source, missing dependencies, ad-hoc signature, stale installed diff --git a/libs/cua-driver/tests/runners/macos-lume/run-all.sh b/libs/cua-driver/tests/runners/macos-lume/run-all.sh index 03dcc7a54b..5e5510f6b0 100755 --- a/libs/cua-driver/tests/runners/macos-lume/run-all.sh +++ b/libs/cua-driver/tests/runners/macos-lume/run-all.sh @@ -202,6 +202,28 @@ fi if [[ "${RUN_STANDALONE_BROWSER}" == 1 ]]; then echo "[E2E] Running the optional standalone browser matrix" + LOCAL_PLIST="${HOME}/Library/LaunchAgents/com.trycua.cua-driver-local.plist" + echo "[AUTHORIZATION] Restarting the disposable worker daemon in unrestricted mode" + launchctl unload "${LOCAL_PLIST}" 2>/dev/null || true + "${INSTALLED_BIN}" stop --socket "${CUA_E2E_MACOS_DAEMON_SOCKET}" >/dev/null 2>&1 || true + open -n -g /Applications/CuaDriverLocal.app --args \ + serve \ + --permission-mode unrestricted \ + --dangerously-bypass-approvals + UNRESTRICTED_READY=0 + for _ in 1 2 3 4 5 6 7 8 9 10; do + if "${INSTALLED_BIN}" status --socket "${CUA_E2E_MACOS_DAEMON_SOCKET}" \ + | grep -Fq "permission mode: unrestricted"; then + UNRESTRICTED_READY=1 + break + fi + sleep 1 + done + if [[ "${UNRESTRICTED_READY}" != 1 ]]; then + echo "The standalone-browser lane could not start its unrestricted worker daemon" >&2 + exit 1 + fi + BROWSER_ARTIFACT_DIR="${REPO_ROOT}/artifacts/cua-driver/macos-standalone-browser" if [[ -d "${BROWSER_ARTIFACT_DIR}" ]] \ && [[ -n "$(find "${BROWSER_ARTIFACT_DIR}" -mindepth 1 -print -quit)" ]]; then @@ -209,6 +231,16 @@ if [[ "${RUN_STANDALONE_BROWSER}" == 1 ]]; then mv "${BROWSER_ARTIFACT_DIR}" "${BROWSER_ARTIFACT_ARCHIVE}/macos-standalone-browser" echo "Previous standalone-browser evidence preserved at ${BROWSER_ARTIFACT_ARCHIVE}/macos-standalone-browser" fi + set +e CUA_E2E_ARTIFACT_DIR="${BROWSER_ARTIFACT_DIR}" \ "${REPO_ROOT}/scripts/ci/run-rust-standalone-browser-e2e.sh" + BROWSER_STATUS=$? + set -e + + echo "[AUTHORIZATION] Restoring the worker's standard autostart daemon" + "${INSTALLED_BIN}" stop --socket "${CUA_E2E_MACOS_DAEMON_SOCKET}" >/dev/null 2>&1 || true + launchctl load "${LOCAL_PLIST}" + if [[ "${BROWSER_STATUS}" != 0 ]]; then + exit "${BROWSER_STATUS}" + fi fi diff --git a/libs/cua-driver/typescript/test/native-daemon-fixture.mjs b/libs/cua-driver/typescript/test/native-daemon-fixture.mjs index 6d1cdb8960..8d1c591e50 100644 --- a/libs/cua-driver/typescript/test/native-daemon-fixture.mjs +++ b/libs/cua-driver/typescript/test/native-daemon-fixture.mjs @@ -11,6 +11,24 @@ const server = net.createServer((connection) => { const newline = buffer.indexOf("\n") if (newline < 0) return const request = JSON.parse(buffer.slice(0, newline)) + if (request.method === "metadata") { + connection.end( + `${JSON.stringify({ + ok: true, + result: { + driver_version: "0.12.6", + contract_version: "0.2.0", + tools_list_schema_version: "1", + capability_version: "1", + mcp_protocol_version: "2025-06-18", + pid: process.pid, + embedded: false, + host_bundle_id: null, + }, + })}\n`, + ) + return + } process.send?.({ request }) connection.end( `${JSON.stringify({ diff --git a/rfcs/2549-cua-driver-sdk-owned-runtime.md b/rfcs/2549-cua-driver-sdk-owned-runtime.md index b08ab19c30..8139d60133 100644 --- a/rfcs/2549-cua-driver-sdk-owned-runtime.md +++ b/rfcs/2549-cua-driver-sdk-owned-runtime.md @@ -1,6 +1,6 @@ --- rfc: 2549 -title: "Cua Driver: SDK-Owned Runtime and Optional Services" +title: 'Cua Driver: SDK-Owned Runtime and Optional Services' authors: - Cua maintainers created: 2026-07-24 @@ -8,7 +8,7 @@ last_updated: 2026-07-24 status: review discussion: https://github.com/trycua/cua/issues/2549 rfc_pr: https://github.com/trycua/cua/pull/2550 -implementation: +implementation: https://github.com/trycua/cua/pull/2561 supersedes: 2447 superseded_by: --- @@ -968,9 +968,11 @@ supported platform. ### Phase 5: Platform-aware MCP ownership -Make MCP stdio own its runtime on Windows, Linux, and embedded macOS paths. -Keep standalone installed macOS MCP connected to the signed CuaDriver.app -service by default. Preserve explicit direct and service connection modes. +Make MCP stdio own its runtime on Windows and Linux. On macOS, keep standalone +MCP connected to the signed CuaDriver.app service by default and require +`--direct` for an explicit MCP-process owner, including an embedded host that +chooses direct ownership instead of its private service endpoint. Preserve the +explicit service connection mode. ### Phase 6: Private worker @@ -1297,4 +1299,15 @@ The RFC is implemented when all of the following evidence passes. ## Decision record RFC 2549 supersedes RFC 2447 while preserving its typed-contract and native-ABI -decisions. Final disposition remains pending RFC discussion. +decisions. The implementation keeps CLI and MCP as the preferred standalone +agent integrations; "direct by default" applies to applications importing the +typed SDK and owning their automation lifecycle. + +The implementation makes one additive CLI choice: `cua-driver mcp --direct` +explicitly selects MCP-process ownership and, on macOS, accepts the spawning +host's TCC attribution. Bare MCP remains direct on Windows/Linux and +CuaDriver.app-backed on macOS. `--socket` remains the explicit service choice +and is mutually exclusive with `--direct`. + +PR #2561 is the implementation candidate. Final disposition remains pending +RFC and implementation review. diff --git a/scripts/ci/linux/run-rust-e2e.sh b/scripts/ci/linux/run-rust-e2e.sh index 40a72f12df..ee3fd87517 100755 --- a/scripts/ci/linux/run-rust-e2e.sh +++ b/scripts/ci/linux/run-rust-e2e.sh @@ -57,6 +57,9 @@ export CUA_TEST_APPS_ROOT="${RUST_ROOT}/test-apps" export CUA_TEST_REQUIRE_FIXTURES=1 export CUA_TEST_DRIVER_STDERR=1 export CUA_E2E_FORBID_SKIPS=1 +# This runner contract requires a real or virtual desktop. Make GUI-dependent +# lifecycle proofs fail instead of silently returning without evidence. +export CUA_REQUIRE_GUI=1 unset CUA_E2E_EXPECTED_MIN_CELLS if [[ ("${SUITE}" == shared || "${SUITE}" == all) \ && -z "${CUA_E2E_CELL_FILTER:-}" \ @@ -169,6 +172,12 @@ run_test() { } if [[ "${SUITE}" == shared || "${SUITE}" == all ]]; then + run_test sdk-runtime-contract \ + cargo test -p cua-driver-sdk --lib -- --test-threads=1 + run_test sdk-runtime-configuration \ + cargo test -p cua-driver-sdk --test runtime_configuration -- --test-threads=1 + run_test private-worker-lifecycle \ + cargo test -p cua-driver --test private_worker_test -- --test-threads=1 run_test shared-behavior-matrix \ cargo test -p cua-driver --test cross_platform_behavior_test -- \ --ignored --exact shared_web_action_matrix_is_state_verified \ diff --git a/scripts/ci/macos/run-rust-e2e.sh b/scripts/ci/macos/run-rust-e2e.sh index 51a81c4a8e..a998f642d5 100755 --- a/scripts/ci/macos/run-rust-e2e.sh +++ b/scripts/ci/macos/run-rust-e2e.sh @@ -174,6 +174,11 @@ run_test() { } if [[ "${SUITE}" == shared || "${SUITE}" == all ]]; then + run_test sdk-runtime-contract cargo test -p cua-driver-sdk --lib -- --test-threads=1 + run_test sdk-runtime-configuration cargo test -p cua-driver-sdk \ + --test runtime_configuration -- --test-threads=1 + run_test private-worker-lifecycle cargo test -p cua-driver \ + --test private_worker_test -- --test-threads=1 run_test shared-app-matrix cargo test -p cua-driver --test cross_platform_behavior_test -- \ --ignored --exact shared_web_action_matrix_is_state_verified \ --nocapture --test-threads=1 diff --git a/scripts/ci/run-rust-standalone-browser-e2e.sh b/scripts/ci/run-rust-standalone-browser-e2e.sh index be1cd1914e..1afb7f35f8 100755 --- a/scripts/ci/run-rust-standalone-browser-e2e.sh +++ b/scripts/ci/run-rust-standalone-browser-e2e.sh @@ -114,6 +114,11 @@ else fi tests+=( standalone_browser_download + ) + if [[ "${HOST_OS}" != Darwin ]]; then + tests+=(standalone_browser_existing_profile_standard_refusal) + fi + tests+=( standalone_browser_existing_profile standalone_browser_existing_profile_setup standalone_browser_frames diff --git a/scripts/ci/windows/run-rust-e2e.ps1 b/scripts/ci/windows/run-rust-e2e.ps1 index d3fd8e9a45..65416523eb 100644 --- a/scripts/ci/windows/run-rust-e2e.ps1 +++ b/scripts/ci/windows/run-rust-e2e.ps1 @@ -255,6 +255,17 @@ function Test-E2eRecordings { $script:FailureCount = 0 if ($suite -in @("shared", "all")) { + Invoke-CargoTest "SDK runtime contract" @( + "test", "-p", "cua-driver-sdk", "--lib", "--", "--test-threads=1" + ) + Invoke-CargoTest "SDK runtime configuration" @( + "test", "-p", "cua-driver-sdk", "--test", "runtime_configuration", "--", + "--test-threads=1" + ) + Invoke-CargoTest "private worker lifecycle" @( + "test", "-p", "cua-driver", "--test", "private_worker_test", "--", + "--test-threads=1" + ) Invoke-CargoTest "shared behavior matrix" @( "test", "-p", "cua-driver", "--test", "cross_platform_behavior_test", "--", "--ignored", "--exact", "shared_web_action_matrix_is_state_verified",