diff --git a/docs/content/docs/concepts/how-permission-policies-work.mdx b/docs/content/docs/concepts/how-permission-policies-work.mdx index fc6cc9d433..4a84a94af9 100644 --- a/docs/content/docs/concepts/how-permission-policies-work.mdx +++ b/docs/content/docs/concepts/how-permission-policies-work.mdx @@ -5,16 +5,13 @@ 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 an MCP client and the tool implementation. Before the driver 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 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. ## The enforcement point -Every MCP tool call passes through one of two dispatch paths: +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. -- **In-process dispatch.** The MCP server receives the call directly, normalizes arguments, and executes the tool in the same process. -- **Daemon-proxy dispatch.** A thin `cua-driver mcp` process proxies the call to a running `cua-driver serve` daemon over a Unix socket. - -The policy engine is invoked at the in-process MCP handler and at the proxy layer: once in the proxy before it forwards the call, and once in the daemon before it executes it. A policy denial at either point returns an error to the client; the tool implementation is never reached. +The policy engine is always invoked in the daemon before tool execution. The MCP proxy also evaluates the policy when `CUA_DRIVER_POLICY_FILE` is present in its environment, providing an earlier defense-in-depth check. A denial at either point returns an error to the client; the tool implementation is never reached. ## Deny-by-default @@ -24,7 +21,7 @@ The deny-by-default behavior applies only when a policy is active. When `CUA_DRI ## Process-lifetime snapshot -The policy file is loaded once when the daemon or in-process server 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 process restarts. +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. 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. @@ -57,7 +54,7 @@ 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 fully in-process and does not spawn a subprocess, there is no IPC overhead for each call. +Because Regorus runs inside the Cua Driver daemon and does not spawn a policy subprocess, there is no additional policy IPC per call. ## Argument sanitization before evaluation @@ -87,4 +84,4 @@ For remote agents connecting through the daemon's network socket, the policy pro - [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 -- [Process model](/reference/cua-driver/process-model): how the daemon and in-process MCP server relate +- [Process model](/reference/cua-driver/process-model): how CLI and MCP clients reach the daemon diff --git a/docs/content/docs/how-to-guides/driver/keep-running.mdx b/docs/content/docs/how-to-guides/driver/keep-running.mdx index 80912a2911..ef678c63e6 100644 --- a/docs/content/docs/how-to-guides/driver/keep-running.mdx +++ b/docs/content/docs/how-to-guides/driver/keep-running.mdx @@ -6,7 +6,7 @@ description: Register Cua Driver as a persistent daemon that starts automaticall import { Tabs, Tab } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; -Use a **persistent daemon** for element-indexed workflows. The per-pid element cache lives inside the process, so one-shot CLI invocations *drop that cache between calls*. The daemon also gives macOS the right TCC attribution and gives Windows an interactive-session proxy. +Cua Driver requires a daemon for tool execution. The daemon owns the per-pid element cache, permission policy, recording and configuration state, macOS TCC attribution, and the Windows interactive-session context. MCP and one-shot CLI calls fail if they cannot reach it. diff --git a/docs/content/docs/how-to-guides/driver/personalize-cursor.mdx b/docs/content/docs/how-to-guides/driver/personalize-cursor.mdx index 0a162bb9e3..c033e9a959 100644 --- a/docs/content/docs/how-to-guides/driver/personalize-cursor.mdx +++ b/docs/content/docs/how-to-guides/driver/personalize-cursor.mdx @@ -19,7 +19,7 @@ cua-driver serve --cursor-shape teardrop # explicit; same as the default cua-driver serve --cursor-shape arrow # opt into the procedural arrow ``` -`--cursor-shape` is parsed on `serve` and `mcp`. It has no effect on one-shot CLI calls like `cua-driver call`, because those do not keep the long-lived UI runloop the overlay needs. +`--cursor-shape` is a daemon startup option. Passing it to an MCP proxy or one-shot CLI adapter does not change the already-running daemon's overlay; set it on `cua-driver serve`. `--cursor-icon ` always wins over `--cursor-shape`: if you pass both, the custom file is what renders. 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 0c9c5e4a04..c239e731b5 100644 --- a/docs/content/docs/how-to-guides/driver/windows-ssh.mdx +++ b/docs/content/docs/how-to-guides/driver/windows-ssh.mdx @@ -110,15 +110,6 @@ 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 you did not pass `--no-daemon-relaunch` and that `CUA_DRIVER_RS_MCP_NO_RELAUNCH` is unset. +5. Confirm that the MCP configuration points to the same daemon socket reported by `cua-driver status`. -## Opt out of proxying - -To keep `cua-driver mcp` in the current process, such as in a CI runner that already owns an interactive session, use either opt-out: - -```powershell -cua-driver mcp --no-daemon-relaunch # per-invocation flag -$env:CUA_DRIVER_RS_MCP_NO_RELAUNCH = "1" # per-shell env var -``` - -With either form, tool calls run directly against the current session. +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. diff --git a/docs/content/docs/reference/cua-driver/cli-reference.mdx b/docs/content/docs/reference/cua-driver/cli-reference.mdx index 71cb12cabd..2128605236 100644 --- a/docs/content/docs/reference/cua-driver/cli-reference.mdx +++ b/docs/content/docs/reference/cua-driver/cli-reference.mdx @@ -42,7 +42,7 @@ Print a tool's full description and JSON input schema. Invoke an MCP tool directly from the shell. -Runs the same handler the MCP server uses. JSON arguments may be passed as a positional JSON object or through stdin. +Sends the tool request to the required Cua Driver daemon. JSON arguments may be passed as a positional JSON object or through stdin. If the daemon is unavailable, the command fails; it never executes the tool in the CLI process. **Arguments:** @@ -64,22 +64,21 @@ Runs the same handler the MCP server uses. JSON arguments may be passed as a pos Run the stdio MCP server. -On macOS, shell-spawned MCP processes can auto-launch and proxy through a CuaDriver.app daemon so TCC grants attach to the bundle. On Windows and Linux, MCP proxies through an already-running daemon when one is listening. +Every MCP process is a stdio proxy to a Cua Driver daemon. On macOS it can auto-launch the CuaDriver.app daemon so TCC grants attach to the bundle. On Windows and Linux, the daemon must already be running. **Options:** | Name | Type | Default | Description | | ---- | ---- | ------- | ----------- | -| `--socket` | String | — | Override the daemon socket or named-pipe path used by the proxy fallback. | +| `--socket` | String | — | Override the required daemon socket or named-pipe path. | | `--host-bundle-id` | String | — | Advisory host bundle id label echoed in check_permissions output (embedded mode). | **Flags:** | Name | Description | | ---- | ----------- | -| `--no-daemon-relaunch` | Stay in-process instead of proxying through a daemon. | | `--claude-code-computer-use-compat` | Expose the Claude Code computer-use compatibility screenshot surface. | -| `--embedded` | Run embedded inside a host app: inherit the host's TCC grants, never prompt or relaunch. Also CUA_DRIVER_EMBEDDED=1. | +| `--embedded` | Require a daemon spawned by the embedding host instead of auto-launching the standalone app. | ### `cua-driver serve` @@ -141,7 +140,7 @@ Supported clients include claude, codex, cursor, antigravity, openclaw, opencode Control trajectory recording on a running daemon. -Recording state lives in-process, so use a daemon for multi-call sessions. +Recording state lives in the daemon and is shared across daemon-backed clients. **Options:** diff --git a/docs/content/docs/reference/cua-driver/embedding.mdx b/docs/content/docs/reference/cua-driver/embedding.mdx index 02bd8d7c6b..f32e51804d 100644 --- a/docs/content/docs/reference/cua-driver/embedding.mdx +++ b/docs/content/docs/reference/cua-driver/embedding.mdx @@ -1,56 +1,58 @@ --- title: Embedding -description: Run cua-driver as a direct child of your host app instead of handing off to a standalone daemon. +description: Run a Cua Driver daemon as a direct child of your host app and connect an MCP proxy to it. --- -Embedding runs `cua-driver` as a direct child of your host app instead of relaunching or proxying through a standalone daemon. On macOS, this also lets the driver inherit the host app's Accessibility and Screen Recording grants, so users only approve your app. On Windows and Linux, embedded mode keeps driver execution inside the host process tree and avoids daemon handoff. +Embedding runs a dedicated `cua-driver serve` daemon as a direct child of your host app instead of launching the standalone `CuaDriver.app`. On macOS, the daemon inherits the host app's Accessibility and Screen Recording grants, so users only approve your app. A second `cua-driver mcp` child proxies stdio MCP traffic to that daemon; it never executes tools itself. A complete macOS reference host and demo live in the repo at `libs/cua-driver/rust/examples/embedded-host-macos`. ## Launch embedded -Set embedded mode on the driver process your host spawns: +Choose a private socket and start the embedded daemon first: ```sh CUA_DRIVER_EMBEDDED=1 \ CUA_DRIVER_HOST_BUNDLE_ID=com.yourco.yourapp \ -cua-driver mcp +cua-driver serve --socket /tmp/yourapp-cua.sock ``` -Or use the equivalent flags: +Then start the MCP proxy against that socket: ```sh -cua-driver mcp --embedded --host-bundle-id com.yourco.yourapp +cua-driver mcp --embedded --socket /tmp/yourapp-cua.sock \ + --host-bundle-id com.yourco.yourapp ``` -Only the exact value `CUA_DRIVER_EMBEDDED=1` enables embedded mode. The host bundle id is an advisory label echoed in `check_permissions`; trust still comes from macOS's responsibility chain. +You can pass `--embedded --host-bundle-id com.yourco.yourapp` to `serve` instead of the environment variables. Only the exact value `CUA_DRIVER_EMBEDDED=1` enables environment-based embedded mode. The host bundle id is an advisory label echoed in `check_permissions`; trust still comes from macOS's responsibility chain. ## Host requirements -- Spawn `cua-driver` directly from your app, for example with `Process` / `NSTask`, `posix_spawn`, or `fork` / `exec`. -- Speak MCP over the child's stdin/stdout. +- Spawn `cua-driver serve --embedded` directly from your app, for example with `Process` / `NSTask`, `posix_spawn`, or `fork` / `exec`. +- Wait for its private socket to become ready, then spawn `cua-driver mcp --embedded --socket ` and speak MCP over the proxy's stdin/stdout. - On macOS, do not launch the driver with `open(1)` or `NSWorkspace.open`; LaunchServices makes the launched app its own responsible process and breaks permission inheritance. - On macOS, request Accessibility and Screen Recording from the host app with `AXIsProcessTrustedWithOptions` and `CGRequestScreenCaptureAccess`. -If macOS grants are added after the driver child has started, restart the child so TCC is re-queried with a fresh per-process cache. +If macOS grants are added after the daemon has started, restart the daemon so TCC is re-queried with a fresh per-process cache. ## App + gateway architectures -`--embedded` does not transfer a GUI app's grants to the driver; it only keeps the driver inside its spawner's macOS responsibility chain. If a separate gateway, daemon, or Node process spawns MCP servers, registering `cua-driver mcp --embedded` there makes the driver inherit the gateway's identity, not the app's. Spawn the driver from the app process, or bridge MCP from the gateway to an app-spawned child. +`--embedded` does not transfer a GUI app's grants to the driver; it only keeps the daemon inside its spawner's macOS responsibility chain. If a separate gateway or Node process spawns the daemon, the daemon inherits the gateway's identity, not the app's. Spawn `cua-driver serve --embedded` from the app process. ```text Wrong (inherits the gateway's identity): Right: gateway / node daemon YourApp.app - └─ cua-driver --embedded └─ cua-driver --embedded + └─ cua-driver serve --embedded ├─ cua-driver serve --embedded + └─ cua-driver mcp --socket ``` ## What changes | Behavior | Standalone | Embedded | | --- | --- | --- | -| Process model | May proxy through a daemon | Direct child / in-process path | -| Daemon relaunch | May proxy through app daemon | Disabled | +| Process model | Standalone daemon + proxy | Host-spawned daemon + proxy | +| Daemon launch | May auto-launch CuaDriver.app | Host starts private daemon | | macOS TCC identity | `com.trycua.driver` or caller | Host app | | macOS permission prompts | Driver may prompt | Driver never prompts | | macOS Settings entries | CuaDriver | Host app only | @@ -60,7 +62,7 @@ Driver tools, screenshots, AX tree reads, background input, and the agent cursor ## macOS permission check -Call the `check_permissions` MCP tool after starting the embedded driver. On macOS, embedded mode ignores prompt requests and should return `source.attribution: "host"`: +Call the `check_permissions` MCP tool after the proxy connects to the embedded daemon. On macOS, embedded mode ignores prompt requests and should return `source.attribution: "host"`: ```json { @@ -75,6 +77,6 @@ Call the `check_permissions` MCP tool after starting the embedded driver. On mac } ``` -If `source.attribution` is not `host` on macOS, embedded mode is not active for the process handling your MCP calls. Check that `CUA_DRIVER_EMBEDDED=1` is passed to the child, that the child was spawned directly, and that you are not accidentally talking to an old standalone daemon. +If `source.attribution` is not `host` on macOS, embedded mode is not active in the daemon handling your MCP calls. Check that `CUA_DRIVER_EMBEDDED=1` is passed to the `serve` child, that the daemon was spawned directly, and that the proxy uses the intended private socket. -`source.attribution: "host"` means the driver process is running in embedded mode; it does not prove that your GUI app is the responsible process. If a gateway, daemon, or Node process spawned the child, the reported grant state still belongs to that spawner. Spawn `cua-driver` from the app process that owns the macOS grants, or bridge MCP to an app-spawned child. +`source.attribution: "host"` means the daemon is running in embedded mode; it does not prove that your GUI app is the responsible process. If a gateway or Node process spawned the daemon, the reported grant state still belongs to that spawner. diff --git a/docs/content/docs/reference/cua-driver/mcp-tools.mdx b/docs/content/docs/reference/cua-driver/mcp-tools.mdx index f6afe0c5bc..0e3725b503 100644 --- a/docs/content/docs/reference/cua-driver/mcp-tools.mdx +++ b/docs/content/docs/reference/cua-driver/mcp-tools.mdx @@ -23,7 +23,7 @@ For the cross-cutting parameter contract (shared parameters, required-parameter - **TCC auto-delegation.** When an MCP client spawns `cua-driver mcp` from an IDE terminal (Claude Code, Cursor, VS Code, Warp), macOS attributes the subprocess to the parent terminal — not `CuaDriver.app` — so AX probes fail against the wrong bundle id. `mcp` detects this and auto-launches a `cua-driver serve` daemon via `open -n -g -a CuaDriver --args serve`, then proxies every tool call through the daemon's Unix socket. Tool semantics are identical to the in-process path; no Python bridge is needed. Pass `--no-daemon-relaunch` (or set `CUA_DRIVER_MCP_NO_RELAUNCH=1`) to force in-process execution. See the [process model](/reference/cua-driver/process-model) for the full lifecycle, failure modes, and wrapper-author guidance. + **Daemon delegation.** `cua-driver mcp` is always a stdio proxy to a `cua-driver serve` daemon. On macOS it can auto-launch the daemon via `open -n -g -a CuaDriver --args serve` so AX and Screen Recording grants attach to the app bundle. On Windows and Linux the daemon must already be running. See the [process model](/reference/cua-driver/process-model) for the full lifecycle and wrapper-author guidance. ## Inspection tools @@ -520,7 +520,7 @@ Turn folders are named `turn-00001/`, `turn-00002/`, etc. Turn numbering restar **Video is off by default.** Pass `record_video: true` to also capture the main display to `/recording.mp4` (H.264 / 30 fps) for the lifetime of the session. The recording is torn down automatically when the MCP client disconnects. -**macOS uses native ScreenCaptureKit** (in-process SCStream + SCRecordingOutput) so video inherits Cua Driver's own Screen Recording grant — no extra TCC prompt, no ffmpeg subprocess. Requires macOS 15.0+. +**macOS uses native ScreenCaptureKit** (daemon-owned SCStream + SCRecordingOutput) so video inherits the daemon's Screen Recording grant — no extra TCC prompt, no ffmpeg subprocess. Requires macOS 15.0+. **Windows + Linux use an ffmpeg subprocess** (`gdigrab` / `x11grab` + libx264). Requires ffmpeg on PATH (winget install Gyan.FFmpeg / apt install ffmpeg); when ffmpeg is missing or fails on startup the per-turn capture (screenshots + action.json) still runs and the session's `last_error` field carries the diagnostic. diff --git a/docs/content/docs/reference/cua-driver/permission-policies.mdx b/docs/content/docs/reference/cua-driver/permission-policies.mdx index d532a758fd..eae5d29f21 100644 --- a/docs/content/docs/reference/cua-driver/permission-policies.mdx +++ b/docs/content/docs/reference/cua-driver/permission-policies.mdx @@ -5,7 +5,7 @@ description: YAML and Rego permission policy schema, environment variable, evalu import { Callout } from 'fumadocs-ui/components/callout'; -Cua Driver evaluates a permission policy for every MCP tool call when `CUA_DRIVER_POLICY_FILE` is set. The policy is loaded once at process startup and applies to both in-process MCP dispatch and daemon-proxy dispatch. This page is the reference for the file format, evaluation rules, and Rego interface. +Cua Driver evaluates a permission policy for every daemon tool call when `CUA_DRIVER_POLICY_FILE` is set. The daemon loads the policy once at process startup; the MCP proxy also evaluates the same policy when it is configured in the proxy environment. CLI and MCP calls therefore share the daemon enforcement point. This page is the reference for the file format, evaluation rules, and Rego interface. --- diff --git a/docs/content/docs/reference/cua-driver/process-model.mdx b/docs/content/docs/reference/cua-driver/process-model.mdx index 759a1104e0..15eb76926c 100644 --- a/docs/content/docs/reference/cua-driver/process-model.mdx +++ b/docs/content/docs/reference/cua-driver/process-model.mdx @@ -5,17 +5,17 @@ description: "How Cua Driver maps one tool surface onto CLI, MCP, and daemon-bac Cua Driver has several process shapes, but they are adapters around one driver surface: list windows, read accessibility, capture the screen, click, type, record, configure, and report state. The process model places that surface in the operating-system context that can perform it. -Most users meet Cua Driver through an MCP stdio server spawned by an AI coding assistant such as Claude Code or Cursor. The same driver can also run as a long-lived daemon through `cua-driver serve`, or answer a one-shot CLI call. All three expose the same operations; the calling tool usually chooses the shape. +All Cua Driver tool execution happens in a long-lived `cua-driver serve` daemon. MCP stdio and one-shot CLI calls are client adapters: they send requests to the daemon and never execute tools locally. -## Three invocation shapes +## Three process roles -The MCP stdio shape is *client-owned*. The parent process starts `cua-driver`, keeps stdin and stdout connected, and sends MCP tool calls over that transport. +The MCP stdio proxy is *client-owned*. The parent process starts `cua-driver mcp`, keeps stdin and stdout connected, and sends MCP tool calls over that transport. The proxy forwards every call to the daemon. 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 one-shot CLI shape is *call-owned*. A command starts, performs one operation, prints a result, and exits. +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. -These are invocation shapes, not separate implementations. +These are transport roles around one daemon-owned implementation. ## Why a daemon proxy exists @@ -25,12 +25,12 @@ On macOS, the root issue is **TCC**, Transparency Consent and Control. Accessibi ## Supported macOS launch modes -Use one of two supported identities: +Use one of two supported daemon identities: - **Standalone:** grant permissions to the installed `CuaDriver.app` and launch its daemon through LaunchServices. See [macOS permissions](/reference/cua-driver/macos-permissions). -- **Embedded:** have the macOS app that owns the grants spawn `cua-driver` directly with `CUA_DRIVER_EMBEDDED=1` or `--embedded`. See [Embedding](/reference/cua-driver/embedding). +- **Embedded:** have the macOS app that owns the grants spawn `cua-driver serve --embedded` directly, then connect an MCP proxy to its socket. See [Embedding](/reference/cua-driver/embedding). -A raw `cua-driver` binary launched outside `CuaDriver.app` without embedded mode has no stable TCC identity and is unsupported. Do not grant permissions to arbitrary binary paths or use that configuration in production. +A raw daemon launched outside `CuaDriver.app` without embedded mode has no stable TCC identity and is unsupported. Do not grant permissions to arbitrary binary paths or use that configuration in production. If an IDE terminal starts `cua-driver` directly, macOS attributes that subprocess to the terminal app's bundle, not to `CuaDriver.app`. The binary is right, but the privacy identity is wrong. @@ -63,8 +63,6 @@ Cleanup follows the proxy connection, not a final message. The proxy keeps a lon ## Lifetimes and memory -In-process MCP mode is simple: one process speaks MCP and performs the driver work directly. It lives for the stdio session. When the MCP client closes the transport, the process exits, and in-memory state exits with it. Element-index caches, active recording state, temporary config, and cursor state are process-local. +The proxy and one-shot CLI processes may come and go, but the daemon owns element-index caches, active recordings, configuration, policy, and cursor state. This makes the execution identity and permission-policy boundary stable across client reconnects. -Daemon-proxy mode gives the driver a longer lifetime than the MCP client. The proxy may come and go as an assistant restarts its MCP transport, but the daemon can keep running. Element-index caches can remain warm, and daemon-held configuration can continue to apply after a tool client reconnects. - -In-process mode is direct and short-lived. Daemon-proxy mode adds a local IPC hop, but it puts GUI work in the operating-system context that can perform it, and gives shared state a machine-level lifetime rather than a stdio subprocess lifetime. +If the daemon disappears, clients fail closed. They do not construct a fresh tool registry or continue with partial state. diff --git a/docs/content/docs/reference/cua-driver/telemetry.mdx b/docs/content/docs/reference/cua-driver/telemetry.mdx index 506a0eee80..302552104f 100644 --- a/docs/content/docs/reference/cua-driver/telemetry.mdx +++ b/docs/content/docs/reference/cua-driver/telemetry.mdx @@ -177,7 +177,7 @@ Routine telemetry excludes task text, prompts, tool arguments, tool response bod Tool-completion events retain only coarse result shape: text, image, mixed, empty, or unknown; a size bucket; a duration bucket; a fixed error class; and, for reviewed structured browser refusals, the fixed code above. The content itself never crosses the telemetry observer boundary. A proxy and daemon negotiate one completion-event owner. Mixed-version pairs retain proxy ownership, which prevents duplicate events during upgrades. -A CLI `call` handled in-process reports `transport=cli`. When the same call is delegated to a running daemon, the daemon owns the completion event and reports `transport=daemon`; the event is still emitted only once. +A CLI `call` is always delegated to the daemon. The daemon owns the completion event and reports `transport=daemon`, so the event is emitted only once. ## Region and deletion controls diff --git a/libs/cua-driver/README.md b/libs/cua-driver/README.md index 3a29e60e4e..1fad5d536d 100644 --- a/libs/cua-driver/README.md +++ b/libs/cua-driver/README.md @@ -49,9 +49,9 @@ Use MCP for this Claude Code vision/computer-use-style path. CLI screenshots sti macOS attributes Accessibility and Screen Recording grants to a responsible app identity, not simply to an executable path. Use one of these supported launch modes: - **Standalone:** install `CuaDriver.app`, grant permissions to it, and start its daemon with `open -n -g -a CuaDriver --args serve`. The installed `cua-driver mcp` CLI may proxy through this daemon automatically. -- **Embedded:** have the macOS app that owns the grants spawn the driver directly with `CUA_DRIVER_EMBEDDED=1` (or `--embedded`). This keeps the driver in that app's responsibility chain, so it inherits the app's grants. A gateway, terminal, or unrelated helper must not spawn it on the app's behalf. +- **Embedded:** have the macOS app that owns the grants spawn `cua-driver serve --embedded` directly, wait for its private socket, then spawn `cua-driver mcp --embedded --socket ` as the stdio proxy. The daemon stays in the app's responsibility chain and inherits its grants. A gateway, terminal, or unrelated helper must not spawn the daemon on the app's behalf. -Directly spawning a raw `cua-driver` binary outside `CuaDriver.app` without embedded mode is unsupported: it has no stable bundle identity for TCC attribution. Do not grant permissions to arbitrary binary paths or rely on that configuration in production. See [`rust/Skills/cua-driver/EMBEDDING.md`](rust/Skills/cua-driver/EMBEDDING.md) for the embedding contract and examples. +Directly spawning a raw `cua-driver serve` outside `CuaDriver.app` without embedded mode is unsupported: it has no stable bundle identity for TCC attribution. Do not grant permissions to arbitrary binary paths or rely on that configuration in production. See [`rust/Skills/cua-driver/EMBEDDING.md`](rust/Skills/cua-driver/EMBEDDING.md) for the embedding contract and examples. ## Publishing the agent skill to ClawHub diff --git a/libs/cua-driver/rust/README.md b/libs/cua-driver/rust/README.md index be6cf795f0..fe0d7cc703 100644 --- a/libs/cua-driver/rust/README.md +++ b/libs/cua-driver/rust/README.md @@ -74,8 +74,8 @@ See `crates/cua-driver/tests/README.md` for the test matrix. ## Permission Policies -Set `CUA_DRIVER_POLICY_FILE` to enforce a deny-by-default policy on MCP -`tools/call` requests. If the variable is unset, or points to a path that does +Set `CUA_DRIVER_POLICY_FILE` on the daemon to enforce a deny-by-default policy +on all tool calls from MCP and CLI clients. If the variable is unset, or points to a path that does not exist, the driver keeps its backward-compatible behavior with no policy enforcement. Supported paths are: @@ -107,4 +107,4 @@ deny: Rego policies must expose a boolean rule at `data.cua.policy.allow`. The input shape is `{ "server": "cua-driver", "tool": , "arguments": }`. -Regorus evaluates policies in-process; no OPA service is required. +Regorus evaluates policies inside the daemon; no OPA service is required. diff --git a/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md b/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md index 44150dbc02..653f811404 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md +++ b/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md @@ -44,20 +44,23 @@ embedded mode does not address. ```sh # env var form — set by the host on the child process -CUA_DRIVER_EMBEDDED=1 CUA_DRIVER_HOST_BUNDLE_ID=com.yourco.yourapp cua-driver mcp +CUA_DRIVER_EMBEDDED=1 CUA_DRIVER_HOST_BUNDLE_ID=com.yourco.yourapp \ + cua-driver serve --socket /tmp/yourapp-cua.sock -# flag form — equivalent (the flags just set the env vars) -cua-driver mcp --embedded --host-bundle-id com.yourco.yourapp +# after the daemon socket is ready, start the stdio MCP proxy +CUA_DRIVER_EMBEDDED=1 cua-driver mcp --socket /tmp/yourapp-cua.sock ``` Requirements on the host side: -- **Spawn the driver directly** as a child process (`Process`/`NSTask`, - `posix_spawn`, `exec` from your own code). Do **not** launch it via - `open(1)` or `NSWorkspace` — that hands it to LaunchServices and breaks - inheritance. -- Speak MCP over the child's stdin/stdout (line-delimited JSON-RPC, the - driver's native transport). The reference host does exactly this. +- **Spawn `cua-driver serve --embedded` directly** as a child process + (`Process`/`NSTask`, `posix_spawn`, `exec` from your own code). Do + **not** launch the daemon via `open(1)` or `NSWorkspace` — that hands it + to LaunchServices and breaks inheritance. +- Give the daemon a private socket and wait until it is accepting connections. +- Spawn `cua-driver mcp --embedded --socket ` and speak MCP over that + proxy's stdin/stdout (line-delimited JSON-RPC). The proxy never executes + tools; the host-owned daemon does. - Request Accessibility and Screen Recording **from your app** before (or after — the driver just reports "not granted" until then) starting the driver, using `AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt: true])` @@ -74,6 +77,7 @@ setting it. | | 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** | @@ -89,14 +93,15 @@ behavior is byte-for-byte what it was. ## The responsibility-chain requirement, exactly The host must be the responsible process for the driver. That holds -automatically when you spawn the driver directly and embedded mode is on. If -the driver were allowed to disclaim (standalone behavior), macOS would treat -it as its own responsible process: your user would get a *second* prompt +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 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 -(`responsibility.rs`) and the `open -a CuaDriver` daemon relaunch, which are -the only two places the driver would otherwise leave your chain. +(`responsibility.rs`) and the `open -a CuaDriver` daemon relaunch. MCP is +always a proxy, so the embedded daemon remains the single process that checks +TCC and executes tools. ### App + gateway architectures @@ -104,15 +109,17 @@ the only two places the driver would otherwise leave your chain. only keeps the driver inside its **spawner's** TCC responsibility chain. If your product has a GUI app that owns the macOS grants and a separate gateway, daemon, or Node process that spawns MCP servers, registering -`cua-driver mcp --embedded` with the gateway makes the driver inherit the -gateway's identity, not the app's. Spawn the driver from the GUI app itself, -or bridge MCP from the gateway to an app-spawned child. +`cua-driver serve --embedded` with the gateway makes the daemon inherit the +gateway's identity, not the app's. Spawn the daemon from the GUI app itself; +gateways may connect an MCP proxy to the app-owned private socket. ```text Wrong (inherits the gateway's identity): Right: gateway / node daemon YourApp.app - └─ cua-driver --embedded └─ cua-driver --embedded + └─ cua-driver serve --embedded ├─ cua-driver serve --embedded + └─ cua-driver mcp --embedded + --socket ``` Note `check_permissions` cannot detect this: `source.attribution` reports @@ -173,8 +180,8 @@ The file below is the complete reference host — mirrored verbatim from `libs/cua-driver/rust/examples/embedded-host-macos/ExampleAgentHarness.swift` in the cua repo (which also has a build-and-run `demo.sh` covering the TCC-reset flow). -It requests the two grants as the host, spawns cua-driver embedded, and -runs the whole demo sequence: attribution check, background screenshot, +It requests the two grants as the host, spawns an embedded daemon plus an MCP +proxy, and runs the whole demo sequence: attribution check, background screenshot, background AX read, agent-cursor glide. `ExampleAgentHarness.swift`: @@ -190,8 +197,8 @@ background AX read, agent-cursor glide. // Runs the one-grant demo sequence from EMBEDDING.md end to end: // 1. Requests Accessibility + Screen Recording AS THE HOST (the only // prompts the user ever sees), then -// 2. spawns cua-driver as a direct child in embedded mode and, over -// stdio MCP, verifies attribution, takes a background screenshot, +// 2. spawns an embedded cua-driver daemon plus its stdio MCP proxy and +// verifies attribution, takes a background screenshot, // reads a background app's window state, and glides the agent-cursor // overlay — with zero driver-side prompts. // @@ -224,16 +231,36 @@ if !ax || !sr { log("after this run: grant the missing item(s) in System Settings, then re-run") } -// 2. Spawn cua-driver as a DIRECT child (never via `open`/NSWorkspace — -// that breaks responsibility inheritance) in embedded mode. +// 2. Spawn the daemon as a DIRECT child (never via `open`/NSWorkspace — +// that breaks responsibility inheritance), then attach an MCP proxy. let driverPath = ProcessInfo.processInfo.environment["CUA_DRIVER_PATH"] ?? "/usr/local/bin/cua-driver" -let driver = Process() -driver.executableURL = URL(fileURLWithPath: driverPath) -driver.arguments = ["mcp"] +let socketPath = "/tmp/cua-embedded-\(ProcessInfo.processInfo.processIdentifier).sock" var env = ProcessInfo.processInfo.environment env["CUA_DRIVER_EMBEDDED"] = "1" env["CUA_DRIVER_HOST_BUNDLE_ID"] = Bundle.main.bundleIdentifier ?? "" + +let daemon = Process() +daemon.executableURL = URL(fileURLWithPath: driverPath) +daemon.arguments = ["serve", "--embedded", "--socket", socketPath] +daemon.environment = env +daemon.standardOutput = logFile +daemon.standardError = logFile +try daemon.run() + +let deadline = Date().addingTimeInterval(10) +while !FileManager.default.fileExists(atPath: socketPath) && Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) +} +guard FileManager.default.fileExists(atPath: socketPath) else { + log("embedded daemon did not create \(socketPath)") + daemon.terminate() + exit(1) +} + +let driver = Process() +driver.executableURL = URL(fileURLWithPath: driverPath) +driver.arguments = ["mcp", "--embedded", "--socket", socketPath] driver.environment = env let toDriver = Pipe(), fromDriver = Pipe() driver.standardInput = toDriver @@ -282,7 +309,7 @@ send(["jsonrpc": "2.0", "id": nextId, "method": "initialize", "params": [ "clientInfo": ["name": "ExampleAgentHarness", "version": "0.1"]]]) _ = readMessage() send(["jsonrpc": "2.0", "method": "notifications/initialized"]) -log("embedded cua-driver started (\(driverPath)) — no driver prompt should have appeared") +log("embedded cua-driver daemon + proxy started (\(driverPath)) — no driver prompt should have appeared") // 4. check_permissions must report attribution "host" and never prompt. let perms = call("check_permissions") @@ -323,6 +350,7 @@ log("move_cursor — \(cursorOk ? "ok" : "FAILED")") let pass = attribution == "host" && !images.isEmpty && hasTree && cursorOk log(pass ? "DEMO COMPLETE: PASS" : "DEMO COMPLETE: FAIL") driver.terminate() +daemon.terminate() exit(pass ? 0 : 1) ``` @@ -351,12 +379,11 @@ tail -f /tmp/cua-embedded-demo.log **"I still get a second permission prompt / a second Settings entry."** Embedded mode is not in effect for the process doing the TCC check. Causes, in order of likelihood: (a) `CUA_DRIVER_EMBEDDED` is not exactly `1`, or was -set on your app but not passed into the child's environment; (b) the driver +set on your app but not passed into the daemon child's environment; (b) the daemon was launched via `open(1)` / `NSWorkspace` instead of spawned directly, so -it is its own responsible process; (c) an old standalone `CuaDriver.app` -daemon is running and your MCP calls are being answered by *it* — check -`check_permissions` → `source.attribution` (must be `host`) and stop the -daemon (`cua-driver stop`). To see exactly which identity macOS is charging, +it is its own responsible process; (c) the MCP proxy connected to an old standalone `CuaDriver.app` daemon — check +`check_permissions` → `source.attribution` (must be `host`) and verify +that the proxy uses the host's private socket. To see exactly which identity macOS is charging, run: `log stream --debug --predicate 'subsystem == "com.apple.TCC" AND eventMessage BEGINSWITH "AttributionChain"'` and trigger the action again. @@ -385,23 +412,19 @@ change can orphan the old row. Reset and re-grant: ## Platform notes (Windows / Linux) -Embedding already works on Windows and Linux (X11) with **no permission -ceremony at all**: neither platform gates screen capture, tree reads, or -input injection behind per-app grants, so a directly-spawned driver child -inherits everything relevant (session, desktop, integrity level) by plain -process inheritance. Set the flag anyway — it keeps the driver answering -in-process as the host's child instead of proxying to an out-of-session -daemon, and it makes intent explicit in `check_permissions` output. The -one-grant inheritance story this guide describes is macOS-specific because -macOS is the only platform where a grant exists to inherit. +Embedding also works on Windows and Linux (X11) with **no per-app permission +ceremony**. The host still spawns a daemon in the intended interactive session +or desktop, then points MCP and CLI adapters at its private socket. The +one-grant inheritance story in this guide is macOS-specific because macOS is +the platform where Accessibility and Screen Recording grants follow the +responsibility chain. Two known exceptions: - **Windows, elevated / UWP targets**: injecting into higher-integrity - windows needs the uiAccess-signed worker (`cua-driver-uia`). Embedded - mode forces in-process execution, so it does NOT route through a running - uia worker — an embedded host that must drive elevated apps has to manage - that worker itself. + 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. - **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/RECORDING.md b/libs/cua-driver/rust/Skills/cua-driver/RECORDING.md index d205613fc0..9fcd1362a4 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/RECORDING.md +++ b/libs/cua-driver/rust/Skills/cua-driver/RECORDING.md @@ -22,9 +22,9 @@ display to `/recording.mp4` (H.264 / 30 fps) for the lifetime of the session. The mp4 is finalized on `stop_recording`. Opt out with `record_video: false` when you don't want video. -**macOS — native ScreenCaptureKit, zero-config.** On macOS the -recorder uses an in-process `SCStream` + `SCRecordingOutput`, so it -inherits cua-driver's own Screen Recording grant — no separate +**macOS — native ScreenCaptureKit, zero-config.** On macOS the daemon's +recorder uses `SCStream` + `SCRecordingOutput`, so it inherits the daemon's +Screen Recording grant — no separate subprocess prompt, no fast-fail, no second TCC dance. Requires macOS 15.0+ (SCRecordingOutput introduced in macOS 15). No ffmpeg needed. diff --git a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md index 8ea15095fe..0c5823b556 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md +++ b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md @@ -128,9 +128,9 @@ Tool names are `snake_case`, management subcommands are `kebab-case` — no ambiguity. Tools invoked as `cua-driver ''`. Management subcommands: -- `cua-driver serve` — start persistent daemon (**required** for - `element_index` workflows; without it each CLI invocation spawns a - fresh process and the per-pid element cache dies between calls). +- `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 stop` / `status` @@ -186,8 +186,8 @@ 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` / -`mcp` bootstraps. One-shot CLI invocations skip the overlay entirely. +Requires the daemon process's UI runloop, which `cua-driver serve` +bootstraps. 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 dd89080938..e40a02967e 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md +++ b/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md @@ -491,9 +491,9 @@ Tool names are `snake_case`, management subcommands are ` with JSON via stdin or positional arg. Management subcommands: -- **`cua-driver serve`** — start persistent daemon (**required** for - `element_index` workflows; without it each CLI invocation spawns a - fresh process and the per-pid element cache dies between calls). +- **`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. 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 diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/lib.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/lib.rs index 95e6c544a6..d58a64ef7f 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/lib.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/lib.rs @@ -12,9 +12,9 @@ pub const RESPONSIBILITY_DISCLAIMED_ENV: &str = "CUA_DRIVER_RS_RESPONSIBILITY_DISCLAIMED"; -/// Embedded mode (`CUA_DRIVER_EMBEDDED=1` / `--embedded`): the driver runs -/// as a direct child of a host app and stays in its TCC responsibility -/// chain — no disclaim re-exec, no daemon relaunch, no permission prompts. +/// Embedded mode (`CUA_DRIVER_EMBEDDED=1` / `--embedded`): the daemon runs as +/// a direct child of a host app and stays in its TCC responsibility chain — +/// no disclaim re-exec, standalone-app relaunch, or permission prompts. /// See `Skills/cua-driver/EMBEDDING.md`. /// /// Caller-controlled, which is safe only because embedded mode strictly diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/recording_tools.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/recording_tools.rs index 1add10b4e0..468769f832 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/recording_tools.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/recording_tools.rs @@ -79,8 +79,8 @@ impl Tool for StartRecordingTool { capture the main display to `/recording.mp4` (H.264 / \ 30 fps) for the lifetime of the session. The recording is torn \ down automatically when the MCP client disconnects.\n\n\ - **macOS uses native ScreenCaptureKit** (in-process SCStream + \ - SCRecordingOutput) so video inherits Cua Driver's own Screen \ + **macOS uses native ScreenCaptureKit** (daemon-owned SCStream + \ + SCRecordingOutput) so video inherits the daemon's Screen \ Recording grant — no extra TCC prompt, no ffmpeg subprocess. \ Requires macOS 15.0+.\n\n\ **Windows + Linux use an ffmpeg subprocess** (`gdigrab` / \ @@ -89,7 +89,7 @@ impl Tool for StartRecordingTool { fails on startup the per-turn capture (screenshots + \ action.json) still runs and the session's `last_error` field \ carries the diagnostic.\n\n\ - State persists for the life of the daemon / MCP session; a restart \ + State persists for the life of the daemon; a restart \ resets to disabled with no on-disk state. Call `stop_recording` to \ disable + finalize the mp4." .into(), diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/server.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/server.rs index 60c3161ad1..c1a4a9de0e 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/server.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/server.rs @@ -1,9 +1,8 @@ -//! Async MCP stdio server loop. +//! Shared MCP request dispatch and privacy-bounded observations. use std::sync::{Arc, OnceLock}; use std::time::Instant; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tracing::{debug, error, warn}; +use tracing::warn; use crate::policy::{configured_policy, PolicyDecision}; use crate::protocol::{initialize_result, InitializeMetadata, Request, Response, ResponseBody}; @@ -456,16 +455,16 @@ pub fn is_computer_action(tool_name: &str, operation: ToolOperation) -> bool { }) } -/// Which stdio execution path produced a response. This only affects the +/// Which daemon transport path produced a response. This only affects the /// classification of JSON-RPC internal errors and is not emitted directly. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StdioExecutionPath { - InProcess, + DirectDaemon, DaemonProxy, } -/// Start-state for one tool call. Public so the daemon-proxy transport can use -/// exactly the same privacy and bucketing logic as the in-process transport. +/// Start-state for one tool call. Public so direct daemon and daemon-proxy +/// transports use exactly the same privacy and bucketing logic. pub struct ToolObservationTimer { tool_name: String, operation: ToolOperation, @@ -514,97 +513,15 @@ impl ToolObservationTimer { } /// Notify the registered observer about a proxy-path initialize request. -/// Direct stdio calls this internally from [`run`]. pub fn observe_proxy_session_started(metadata: InitializeMetadata) { notify_session_started(metadata); } /// Notify the registered observer about a proxy-path tool result. -/// Direct stdio calls this internally from [`run`]. pub fn observe_proxy_tool_completed(outcome: ToolCompletionObservation) { notify_tool_completed(outcome); } -/// Run the MCP server, reading JSON-RPC lines from stdin and writing -/// responses to stdout. Exits when stdin reaches EOF or a fatal I/O -/// error occurs. -pub async fn run(registry: Arc) -> anyhow::Result<()> { - configured_policy().map_err(anyhow::Error::msg)?; - let stdin = tokio::io::stdin(); - let stdout = tokio::io::stdout(); - let mut reader = BufReader::new(stdin); - let mut writer = tokio::io::BufWriter::new(stdout); - let mut line = String::new(); - let mut session_observed = false; - - loop { - line.clear(); - let n = reader.read_line(&mut line).await?; - if n == 0 { - // EOF - break; - } - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - debug!(raw = trimmed, "→ request"); - - let response = match serde_json::from_str::(trimmed) { - Err(e) => { - error!("JSON parse error: {e}"); - Response::parse_error() - } - Ok(req) if req.is_notification() => { - // Notifications are silently dropped. - continue; - } - Ok(req) => { - let initialize_metadata = (!session_observed) - .then(|| req.initialize_metadata()) - .flatten(); - let session_context = session_tool_context( - &req, - ®istry, - crate::session::SessionTransport::McpStdio, - ); - let tool_timer = tool_observation_timer( - &req, - |name| name == "type_text_chars" || registry.get_def(name).is_some(), - StdioExecutionPath::InProcess, - ); - let id = req.id.clone().unwrap_or(serde_json::Value::Null); - let response = handle_request(req, id, ®istry).await; - if let Some(metadata) = initialize_metadata { - // A parsed initialize request always receives the static - // initialize result from handle_request. Mark it once only - // after that successful dispatch. - notify_session_started(metadata); - session_observed = true; - } - if let Some(timer) = tool_timer { - let outcome = timer.finish(&response); - if let Some(context) = session_context { - context.complete(&outcome); - } - notify_tool_completed(outcome); - } - response - } - }; - - let serialized = serde_json::to_string(&response) - .unwrap_or_else(|e| format!(r#"{{"jsonrpc":"2.0","id":null,"error":{{"code":-32603,"message":"serialize error: {e}"}}}}"#)); - debug!(raw = %serialized, "← response"); - - writer.write_all(serialized.as_bytes()).await?; - writer.write_all(b"\n").await?; - writer.flush().await?; - } - - Ok(()) -} - /// Build a completion timer from a `tools/call` request while extracting only /// the declared tool name and validity/allowlist booleans. Tool arguments are /// neither cloned nor retained. @@ -976,7 +893,7 @@ mod observation_tests { "structuredContent": {"private": "result body"} }), ); - let observation = timer(true, true, StdioExecutionPath::InProcess).finish(&response); + let observation = timer(true, true, StdioExecutionPath::DirectDaemon).finish(&response); assert!(observation.success); assert_eq!(observation.error_class, ToolErrorClass::None); assert_eq!(observation.output_type, OutputType::Mixed); @@ -1170,7 +1087,7 @@ mod observation_tests { ToolOperation::BrowserClickTrusted, true, true, - StdioExecutionPath::InProcess, + StdioExecutionPath::DirectDaemon, ) .finish(&response); assert!(observation.success); @@ -1225,7 +1142,7 @@ mod observation_tests { "browser_click".to_owned(), true, true, - StdioExecutionPath::InProcess, + StdioExecutionPath::DirectDaemon, ) .finish(&unknown) .refusal_code, @@ -1237,7 +1154,7 @@ mod observation_tests { fn invalid_params_and_unknown_tool_are_distinct() { let invalid = Response::error(serde_json::json!(1), -32602, "private invalid params"); let invalid_observation = - timer(false, false, StdioExecutionPath::InProcess).finish(&invalid); + timer(false, false, StdioExecutionPath::DirectDaemon).finish(&invalid); assert!(!invalid_observation.success); assert_eq!( invalid_observation.error_class, @@ -1253,7 +1170,7 @@ mod observation_tests { }), ); let unknown_observation = - timer(false, true, StdioExecutionPath::InProcess).finish(&unknown); + timer(false, true, StdioExecutionPath::DirectDaemon).finish(&unknown); assert_eq!(unknown_observation.error_class, ToolErrorClass::UnknownTool); assert!(!format!("{unknown_observation:?}").contains("private unknown-tool error")); } @@ -1262,7 +1179,7 @@ mod observation_tests { fn proxy_internal_rpc_error_is_transport_error() { let response = Response::error(serde_json::json!(1), -32603, "private daemon failure"); let proxy = timer(true, true, StdioExecutionPath::DaemonProxy).finish(&response); - let direct = timer(true, true, StdioExecutionPath::InProcess).finish(&response); + let direct = timer(true, true, StdioExecutionPath::DirectDaemon).finish(&response); assert_eq!(proxy.error_class, ToolErrorClass::TransportError); assert_eq!(direct.error_class, ToolErrorClass::InternalError); assert!(!format!("{proxy:?}").contains("private daemon failure")); @@ -1286,7 +1203,7 @@ mod observation_tests { "structuredContent": {"code": code, "detail": "private detail"} }), ); - let observation = timer(true, true, StdioExecutionPath::InProcess).finish(&response); + let observation = timer(true, true, StdioExecutionPath::DirectDaemon).finish(&response); assert_eq!(observation.error_class, expected, "code={code}"); let debug = format!("{observation:?}"); assert!(!debug.contains("private prose")); @@ -1310,7 +1227,7 @@ mod observation_tests { let timer = tool_observation_timer( &req, |name| name == "type_text", - StdioExecutionPath::InProcess, + StdioExecutionPath::DirectDaemon, ) .unwrap(); assert_eq!(timer.tool_name, "type_text"); diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/cli.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/cli.rs index 37a920c4ae..4e64aec584 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/cli.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/cli.rs @@ -1,7 +1,8 @@ -//! CLI transport: a stateless `cua-driver call ` process per action. +//! CLI transport: one `cua-driver call ` process per action, all backed +//! by a shared test-owned daemon. //! -//! Each call is its own process — no state carries between calls (the property -//! that makes `set_config` disk-persistence observable here but not over MCP). +//! Each shell call is its own process, while tool state and enforcement live in +//! the daemon just as they do in production. //! Args are piped via **stdin** rather than a positional arg, which the CLI //! accepts and which dodges PowerShell 5.1's quote-stripping on JSON (see #1637). @@ -10,25 +11,41 @@ use std::process::{Command, Stdio}; use serde_json::Value; +use crate::daemon::TestDaemon; use crate::driver::Driver; use crate::paths::driver_binary; +use crate::reaper::ChildReaper; use crate::response::ToolResponse; /// Drives cua-driver over the stateless CLI surface. pub struct CliDriver { bin: std::path::PathBuf, + _reaper: Option, + daemon: Option, } impl CliDriver { pub fn new() -> Self { + let bin = driver_binary(); + if !bin.exists() { + return CliDriver { + bin, + _reaper: None, + daemon: None, + }; + } + let mut reaper = ChildReaper::new(); + let daemon = TestDaemon::spawn(&bin, &mut reaper, &[]); CliDriver { - bin: driver_binary(), + bin, + _reaper: Some(reaper), + daemon, } } /// Whether the driver binary exists (caller should skip the test if not). pub fn available(&self) -> bool { - self.bin.exists() + self.bin.exists() && self.daemon.is_some() } } @@ -40,9 +57,18 @@ impl Default for CliDriver { impl Driver for CliDriver { fn call(&mut self, tool: &str, args: Value) -> ToolResponse { + let Some(daemon) = &self.daemon else { + return ToolResponse::new( + "test daemon unavailable".into(), + Value::Null, + true, + Value::Null, + ); + }; let mut child = match Command::new(&self.bin) .arg("call") .arg(tool) + .args(["--socket", &daemon.socket]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/daemon.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/daemon.rs new file mode 100644 index 0000000000..9fa5c6c196 --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/daemon.rs @@ -0,0 +1,109 @@ +//! Per-test daemon lifecycle for CLI and MCP transport fixtures. + +use std::path::Path; +use std::process::{Command, Stdio}; +#[cfg(not(unix))] +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use crate::reaper::ChildReaper; + +#[cfg(not(unix))] +static DAEMON_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +/// Keeps the temporary socket directory alive for the daemon lifetime. +pub(crate) struct TestDaemon { + pub(crate) socket: String, + #[cfg(unix)] + _socket_dir: tempfile::TempDir, +} + +impl TestDaemon { + pub(crate) fn spawn( + binary: &Path, + reaper: &mut ChildReaper, + env: &[(&str, &str)], + ) -> Option { + #[cfg(not(unix))] + let sequence = DAEMON_SEQUENCE.fetch_add(1, Ordering::Relaxed); + + #[cfg(unix)] + let (socket, socket_dir) = { + // Unix-domain socket paths are short on macOS, so keep the test + // directory directly under /tmp with a compact filename. + let dir = tempfile::Builder::new() + .prefix("cua-") + .tempdir_in("/tmp") + .inspect_err(|error| { + eprintln!("[testkit] create daemon socket directory failed: {error}") + }) + .ok()?; + (dir.path().join("d.sock").display().to_string(), dir) + }; + + #[cfg(target_os = "windows")] + let socket = format!( + r"\\.\pipe\cua-driver-test-{}-{sequence}", + std::process::id() + ); + + #[cfg(not(any(unix, target_os = "windows")))] + let socket = format!("cua-driver-test-{}-{sequence}", std::process::id()); + + let stderr = if std::env::var_os("CUA_TEST_DRIVER_STDERR").is_some() { + Stdio::inherit() + } else { + Stdio::null() + }; + let mut command = Command::new(binary); + command + .args([ + "serve", + "--socket", + &socket, + "--no-permissions-gate", + "--no-overlay", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(stderr) + .env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "false"); + for (key, value) in env { + command.env(key, value); + } + reaper + .spawn(&mut command) + .inspect_err(|error| eprintln!("[testkit] daemon spawn failed: {error}")) + .ok()?; + + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if daemon_is_listening(binary, &socket) { + return Some(Self { + socket, + #[cfg(unix)] + _socket_dir: socket_dir, + }); + } + std::thread::sleep(Duration::from_millis(50)); + } + + eprintln!("[testkit] daemon did not become ready on {socket}"); + None + } +} + +#[cfg(unix)] +fn daemon_is_listening(_binary: &Path, socket: &str) -> bool { + std::os::unix::net::UnixStream::connect(socket).is_ok() +} + +#[cfg(not(unix))] +fn daemon_is_listening(binary: &Path, socket: &str) -> bool { + Command::new(binary) + .args(["status", "--socket", socket]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/driver.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/driver.rs index eae7a18d48..f0c21717e9 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/driver.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/driver.rs @@ -4,7 +4,7 @@ use crate::response::ToolResponse; use serde_json::Value; /// A way to invoke cua-driver tools. Implemented by [`crate::McpDriver`] -/// (long-lived server) and [`crate::CliDriver`] (stateless per-call process). +/// (long-lived proxy) and [`crate::CliDriver`] (one shell process per call). /// /// Write scenarios against `Driver` to run them over either transport — the one /// behavior that only surfaces across both is config persistence (`set_config` diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/lib.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/lib.rs index 9e33605ba4..5ee658cf10 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/lib.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/lib.rs @@ -8,12 +8,12 @@ //! //! ## Two transports, one shape //! cua-driver is driven two ways, and a test should be able to target either: -//! - **MCP** ([`McpDriver`]) — one long-lived `cua-driver` server over stdio -//! JSON-RPC. State (e.g. `set_config`) persists for the connection. +//! - **MCP** ([`McpDriver`]) — one long-lived `cua-driver` stdio proxy backed +//! by a test-owned daemon. State persists in the daemon. //! Returns the `{"result":{"content",…,"structuredContent"}}` envelope. -//! - **CLI** ([`CliDriver`]) — a stateless `cua-driver call ` -//! process per action. Prints `structuredContent` (or text) directly, NOT -//! the JSON-RPC envelope. +//! - **CLI** ([`CliDriver`]) — a fresh `cua-driver call ` process +//! per action, backed by the same test-owned daemon. Prints +//! `structuredContent` (or text) directly, NOT the JSON-RPC envelope. //! //! Both implement [`Driver`] and normalize their differing payloads into one //! [`ToolResponse`], so a scenario reads `resp.text()` / `resp.structured()` / @@ -36,6 +36,7 @@ pub mod ax; mod browser_fixture; mod cli; +mod daemon; mod driver; pub mod e2e; mod journal; diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs index c9ea0c8a6e..e1da659180 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs @@ -9,6 +9,7 @@ use std::time::{Duration, Instant}; use serde_json::Value; +use crate::daemon::TestDaemon; use crate::driver::{BehaviorRecording, Driver}; use crate::paths::driver_binary; use crate::reaper::{spawn_in_job, ChildReaper}; @@ -23,6 +24,7 @@ use crate::CALL_TIMEOUT; /// [`reaper`]: McpDriver::reaper pub struct McpDriver { reaper: ChildReaper, + _daemon: Option, stdin: ChildStdin, rx: Receiver, next_id: u32, @@ -67,6 +69,13 @@ impl McpDriver { } let mut reaper = ChildReaper::new(); + let daemon = if args.is_empty() { + // Environment that changes tool behavior belongs on the daemon, + // because the stdio process is now only a transport proxy. + Some(TestDaemon::spawn(&bin, &mut reaper, env)?) + } else { + None + }; let mut cmd = Command::new(&bin); let stderr = if std::env::var_os("CUA_TEST_DRIVER_STDERR").is_some() { Stdio::inherit() @@ -81,9 +90,11 @@ impl McpDriver { // A test that exercises telemetry can explicitly override this // through `spawn_with_env` / `spawn_named_with_env` below. .env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "false"); - cmd.args(args); - #[cfg(not(target_os = "macos"))] - cmd.env("CUA_DRIVER_RS_MCP_NO_RELAUNCH", "1"); + if let Some(daemon) = &daemon { + cmd.args(["mcp", "--socket", &daemon.socket]); + } else { + cmd.args(args); + } for (key, value) in env { cmd.env(key, value); } @@ -113,6 +124,7 @@ impl McpDriver { let mut d = McpDriver { reaper, + _daemon: daemon, stdin, rx, next_id: 2, @@ -154,11 +166,7 @@ impl McpDriver { ); return None; } - Self::spawn_internal( - &[("CUA_DRIVER_RS_MCP_FORCE_PROXY", "1")], - &["mcp", "--socket", &socket], - recording_label, - ) + Self::spawn_internal(&[], &["mcp", "--socket", &socket], recording_label) } fn initialize(&mut self) { 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 9e37fe0f87..f922e3cf5d 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 @@ -9,16 +9,19 @@ //! don't each re-implement `send_request`/`read_response`. use std::io::{BufRead, BufReader, Write}; -use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::process::{ChildStdin, ChildStdout, Command, Stdio}; use serde_json::Value; +use crate::daemon::TestDaemon; use crate::paths::driver_binary; +use crate::reaper::{spawn_in_job, ChildReaper}; /// A spawned cua-driver with raw stdio access and no handshake performed. /// Killed on drop. pub struct RawDriver { - child: Child, + _reaper: ChildReaper, + _daemon: TestDaemon, stdin: ChildStdin, stdout: BufReader, } @@ -33,17 +36,23 @@ impl RawDriver { eprintln!("[testkit] driver binary not built at {bin:?} — skipping"); return None; } - let mut child = Command::new(&bin) + let mut reaper = ChildReaper::new(); + let daemon = TestDaemon::spawn(&bin, &mut reaper, &[])?; + let mut command = Command::new(&bin); + command + .args(["mcp", "--socket", &daemon.socket]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() + .stderr(Stdio::null()); + let mut child = spawn_in_job(&mut command) .inspect_err(|e| eprintln!("[testkit] driver spawn failed: {e}")) .ok()?; let stdin = child.stdin.take().unwrap(); let stdout = BufReader::new(child.stdout.take().unwrap()); + reaper.push(child); Some(RawDriver { - child, + _reaper: reaper, + _daemon: daemon, stdin, stdout, }) @@ -65,10 +74,3 @@ impl RawDriver { serde_json::from_str(line.trim()).expect("parse JSON response") } } - -impl Drop for RawDriver { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} diff --git a/libs/cua-driver/rust/crates/cua-driver/src/bundle.rs b/libs/cua-driver/rust/crates/cua-driver/src/bundle.rs index 1dd1d79fe6..b3fdc0e4c9 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/bundle.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/bundle.rs @@ -1,146 +1,45 @@ -//! macOS bundle-context detection for the TCC auto-relaunch path. -//! -//! Mirrors `libs/cua-driver/Sources/CuaDriverCLI/BundleHelpers.swift`'s -//! `isExecutableInsideCuaDriverApp()` — the heuristic that decides -//! whether `cua-driver-rs mcp` was spawned from an IDE terminal as a -//! bare CLI symlinked into our .app bundle. When true and the parent -//! isn't launchd, we re-launch the daemon via `open -n -g -a -//! CuaDriver --args serve` so it picks up the bundle's TCC grants, -//! then proxy stdio MCP traffic through the daemon's Unix socket. -//! -//! Non-macOS targets compile to no-ops so the cross-platform call -//! sites stay tidy. +//! Small platform helpers shared by daemon startup paths. -/// Returns `true` when the currently-running binary resolves into an -/// installed `CuaDriver.app` bundle (Rust port). The check is the -/// same shape as the Swift driver's `isExecutableInsideCuaDriverApp` -/// (`/CuaDriver.app/Contents/MacOS/`) but keyed on the Rust port's -/// distinct bundle name so the two installs don't collide. -/// -/// `false` for raw `cargo run` / `target/release/cua-driver` dev -/// invocations — there's no installed bundle to relaunch into, so the -/// caller should stay in-process. -/// -/// Implementation: -/// 1. Resolve `std::env::current_exe()` (preferred; absolute path -/// to the running image). -/// 2. Walk symlinks via `std::fs::canonicalize` — the install layout -/// is `~/.local/bin/cua-driver` → `/Applications/CuaDriver.app/ -/// Contents/MacOS/cua-driver`, so without the canonicalize step -/// we'd see the bare symlink path and miss the bundle. -/// 3. Substring-match the canonical path for the bundle marker. +/// Return whether the current executable lives in the installed macOS app +/// bundle. A bundled daemon already has its stable TCC responsibility identity +/// and must not disclaim it during startup. #[cfg(target_os = "macos")] pub fn is_executable_inside_cuadriver_app() -> bool { - let exe = match std::env::current_exe() { - Ok(p) => p, - Err(_) => return false, - }; - let canonical = match std::fs::canonicalize(&exe) { - Ok(p) => p, - Err(_) => return false, - }; - let s = match canonical.to_str() { - Some(s) => s, - None => return false, - }; - s.contains("/CuaDriver.app/Contents/MacOS/") -} - -#[cfg(not(target_os = "macos"))] -#[allow(dead_code)] // Non-macOS stub kept for API symmetry — see module header. -pub fn is_executable_inside_cuadriver_app() -> bool { - false -} - -/// Returns `true` when the parent process is *not* `launchd` (pid 1). -/// Combined with [`is_executable_inside_cuadriver_app`], a `true` -/// here means the binary was spawned from a shell / IDE terminal that -/// inherits the wrong TCC responsibility — i.e. the case we want to -/// auto-relaunch from. -/// -/// `ppid == 1` means launchd reparented us (we're already running as -/// the LaunchServices-spawned daemon). In that case we stay -/// in-process: TCC grants are already correct, and relaunching would -/// fork-bomb the daemon back into existence on every `mcp` startup. -/// -/// Mirrors Swift's `if getppid() == 1 { return false }` gate in -/// `MCPCommand.shouldUseDaemonProxy()`. -#[cfg(unix)] -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] -pub fn parent_is_not_launchd() -> bool { - // SAFETY: `libc::getppid` is a thread-safe POSIX getter that - // takes no args and returns the parent pid. No invariants to - // uphold, no UB to risk. - let ppid = unsafe { libc::getppid() }; - ppid != 1 -} - -#[cfg(not(unix))] -#[allow(dead_code)] // Non-unix stub kept for API symmetry — see module header. -pub fn parent_is_not_launchd() -> bool { - // No launchd on non-Unix; the heuristic is macOS-only anyway. - // Returning false keeps the caller in-process on unsupported - // platforms (same effective outcome as the macOS check failing). - false + std::env::current_exe() + .ok() + .and_then(|path| std::fs::canonicalize(path).ok()) + .and_then(|path| path.to_str().map(str::to_owned)) + .is_some_and(|path| path.contains("/CuaDriver.app/Contents/MacOS/")) } /// Returns `true` when the env var is one of `1|true|yes|on` /// (case-insensitive). Anything else, including unset, is falsy. -/// -/// Mirrors Swift's `isEnvTruthy` helper on `MCPCommand`. +#[cfg(target_os = "windows")] pub fn is_env_truthy(name: &str) -> bool { match std::env::var(name) { - Ok(v) => matches!( - v.trim().to_ascii_lowercase().as_str(), + Ok(value) => matches!( + value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" ), Err(_) => false, } } -// ─── Tests ─────────────────────────────────────────────────────────────────── - -#[cfg(test)] +#[cfg(all(test, target_os = "windows"))] mod tests { use super::*; #[test] - fn cargo_run_is_not_inside_bundle() { - // The unit-test runner image lives under `target// - // deps/`, never inside a .app bundle. Should always return - // false in CI / local dev, which is exactly the behavior we - // want so `cargo run` callers stay in-process. - assert!(!is_executable_inside_cuadriver_app()); - } - - #[test] - fn unset_env_is_falsy() { - // Use a deliberately unlikely name so we don't depend on the - // surrounding shell environment. - std::env::remove_var("CUA_DRIVER_RS_TEST_UNSET_NAME"); - assert!(!is_env_truthy("CUA_DRIVER_RS_TEST_UNSET_NAME")); - } - - #[test] - fn truthy_env_values_recognized() { + fn env_truthiness_is_strict() { let name = "CUA_DRIVER_RS_TEST_TRUTHY"; - for v in ["1", "true", "TRUE", "Yes", "on", " 1 "] { - std::env::set_var(name, v); - assert!(is_env_truthy(name), "expected truthy for {v:?}"); + for value in ["1", "true", "TRUE", "Yes", "on", " 1 "] { + std::env::set_var(name, value); + assert!(is_env_truthy(name), "expected truthy for {value:?}"); } - for v in ["0", "false", "no", "off", ""] { - std::env::set_var(name, v); - assert!(!is_env_truthy(name), "expected falsy for {v:?}"); + for value in ["0", "false", "no", "off", ""] { + std::env::set_var(name, value); + assert!(!is_env_truthy(name), "expected falsy for {value:?}"); } std::env::remove_var(name); } - - #[test] - #[cfg(unix)] - fn parent_is_not_launchd_in_tests() { - // The cargo test harness is reparented under whatever - // launched it (cargo / IDE / shell), not directly under - // launchd. The helper should report true. - assert!(parent_is_not_launchd()); - } } 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 0011faf3c8..3a285fe970 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs @@ -12,22 +12,14 @@ //! Cursor-overlay flags (--cursor-id, --no-overlay, etc.) are consumed by //! `CursorConfig::from_args()` and are ignored here. -use cua_driver_core::{protocol::Content, tool::ToolRegistry}; +use cua_driver_core::tool::ToolRegistry; use std::process; /// Which CLI command was requested. pub enum Command { Mcp { - /// Force in-process MCP execution — skip the TCC auto-relaunch - /// path that would spawn a daemon via `open -n -g -a CuaDriver - /// --args serve` and proxy stdio MCP requests through its Unix - /// socket. Useful when the calling context already has the right - /// TCC grants (CuaDriver.app launched us directly), or when - /// diagnosing in-process failures. Also toggleable via - /// `CUA_DRIVER_RS_MCP_NO_RELAUNCH=1`. - no_daemon_relaunch: bool, /// Override the daemon Unix socket path used by the proxy - /// fallback. Defaults to `serve::default_socket_path()`. + /// transport. Defaults to `serve::default_socket_path()`. socket: Option, /// `--claude-code-computer-use-compat`: register the compat /// `screenshot` tool (window-scoped, JPEG @ 85%, pid + window_id @@ -42,13 +34,9 @@ pub enum Command { tool: String, json_args: Option, screenshot_out_file: Option, - /// Override the daemon socket/pipe path used by the in-process - /// forwarding fallback (matches `--socket` semantics for `serve` / - /// `status` / `stop`). Defaults to `serve::default_socket_path()` - /// when None — i.e. `cua-driver call X` looks for the user's - /// default-path daemon. Surfaced to make integration tests able - /// to spin up a tempfile-socketed daemon and route calls - /// through it. + /// Override the required daemon socket/pipe path (matches `--socket` + /// semantics for `serve` / `status` / `stop`). Defaults to + /// `serve::default_socket_path()` when None. socket: Option, }, McpConfig { @@ -62,11 +50,9 @@ pub enum Command { /// two opt-out signals. no_permissions_gate: bool, /// True when `--claude-code-computer-use-compat` is on argv. The MCP - /// proxy forwards this flag to the daemon it auto-launches (see - /// `launch_daemon_and_wait`) so the proxy path registers the compat - /// `screenshot` surface, not just the in-process path. Without it the - /// flag was a no-op for `cua-driver mcp --claude-code-computer-use-compat`, - /// which always routes through the proxy on an installed bundle. + /// proxy forwards this flag to a daemon it auto-launches (see + /// `launch_daemon_and_wait`) so that daemon registers the requested + /// compatibility surface. claude_code_compat: bool, }, Stop { @@ -457,19 +443,16 @@ pub fn parse_command() -> Command { println!(" --window-id --session "); println!(" Approve attachment to one exact existing browser request."); println!(); - println!("mcp options (macOS):"); - println!( - " --no-daemon-relaunch Stay in-process; skip auto-launching the CuaDriver daemon." - ); - println!(" Also: CUA_DRIVER_RS_MCP_NO_RELAUNCH=1"); - println!(" --embedded Run embedded inside a host app (also: CUA_DRIVER_EMBEDDED=1)."); - println!(" Inherits the host's TCC grants; never prompts or relaunches."); + 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!(" See Skills/cua-driver/EMBEDDING.md."); println!( " --host-bundle-id Advisory host bundle id label for check_permissions output." ); println!( - " --socket Override the daemon UDS path used by the proxy fallback." + " --socket Override the required daemon socket used by the proxy." ); println!(" --claude-code-computer-use-compat"); println!(" Select the Claude Code computer-use compat surface."); @@ -488,7 +471,7 @@ pub fn parse_command() -> Command { ); println!(" for any future compat-gated tool."); println!(); - println!("agent cursor overlay (serve / mcp only — needs the daemon UI runloop):"); + println!("agent cursor overlay (serve only — needs the daemon UI runloop):"); println!(" The overlay is ON by default: every MCP session automatically gets its own"); println!( " cursor (keyed by session id) that shows where the agent acts without moving the" @@ -513,8 +496,8 @@ pub fn parse_command() -> Command { ); println!(" diamond). Same vocabulary as MCP `cursor_icon`."); println!(" --cursor-palette Pick a built-in colour palette for the cursor."); - println!(" (These are no-ops for one-shot CLI calls like `cua-driver call` — the overlay"); - println!(" needs the long-lived AppKit runloop that only `serve` / `mcp` keep alive.)"); + println!(" Set these on `cua-driver serve`; MCP and one-shot CLI processes are clients"); + println!(" and do not own the daemon's overlay configuration or UI runloop."); println!(); println!("manifest options:"); println!(" cua-driver manifest Emit a stable JSON description of this CLI's surface"); @@ -580,7 +563,6 @@ pub fn parse_command() -> Command { } } - let no_daemon_relaunch = args.iter().any(|a| a == "--no-daemon-relaunch"); let claude_code_compat = args .iter() .any(|a| a == "--claude-code-computer-use-compat"); @@ -610,13 +592,11 @@ pub fn parse_command() -> Command { std::process::exit(0); } Command::Mcp { - no_daemon_relaunch, socket: socket.clone(), claude_code_compat, } } Some("mcp") => Command::Mcp { - no_daemon_relaunch, socket: socket.clone(), claude_code_compat, }, @@ -1005,104 +985,6 @@ pub fn run_describe(registry: &ToolRegistry, name: &str) { } } -/// Decide whether `mcp` should auto-launch a daemon and proxy MCP -/// requests through its Unix socket instead of running in-process. -/// -/// Mirrors Swift `MCPCommand.shouldUseDaemonProxy` in spirit: -/// the trigger is "shell-spawned bare binary that resolves into an -/// installed `CuaDriver.app` bundle, with a non-launchd parent". -/// When any of those conditions fails — explicit opt-out, dev-mode -/// `cargo run` invocation, already-relaunched-via-launchd — we stay -/// in-process. The proxy path is purely additive. -/// -/// `false` on non-macOS targets: TCC is a macOS-only concern and -/// there's no `open -a` equivalent on Linux / Windows. -#[cfg(target_os = "macos")] -pub fn should_use_daemon_proxy(no_daemon_relaunch: bool) -> bool { - use crate::bundle::{is_env_truthy, is_executable_inside_cuadriver_app, parent_is_not_launchd}; - // Embedded mode stays in-process: relaunching via `open -a CuaDriver` - // would leave the host's TCC responsibility chain and could prompt - // for com.trycua.driver. - if cua_driver_core::embedded_mode() { - return false; - } - if no_daemon_relaunch { - return false; - } - if is_env_truthy("CUA_DRIVER_RS_MCP_NO_RELAUNCH") { - return false; - } - // Hidden test/escape hook: force proxy mode without requiring the - // executable to live inside CuaDriver.app. Used by the - // integration test (which spawns a daemon manually) and by users - // who've wrapped the binary in a custom bundle. Skips the - // launch_daemon_and_wait `open -a` step too — caller is expected - // to have a daemon already running on the chosen socket. - if is_env_truthy("CUA_DRIVER_RS_MCP_FORCE_PROXY") { - return true; - } - if !is_executable_inside_cuadriver_app() { - // Raw `cargo run` / dev binary — no installed bundle to land - // in, so relaunching would fail. Stay in-process. - return false; - } - if !parent_is_not_launchd() { - // ppid == 1 — already running as the LaunchServices-spawned - // daemon. TCC context is already correct. - return false; - } - true -} - -/// Non-macOS targets don't have TCC, but they DO have the equivalent -/// problem of session attribution on Windows (Session 0 vs the user's -/// interactive Session 1+). When the CLI is spawned via SSH or a -/// Windows service, it lands in Session 0 where the desktop, window -/// APIs, and UI Automation return empty. A daemon running in the -/// interactive session (via `cua-driver autostart enable && kick`, -/// or any other Session-1+ launch) can answer tool calls correctly — -/// so when one is up, we proxy through it. -/// -/// Behaviour: -/// * `--no-daemon-relaunch` or `CUA_DRIVER_RS_MCP_NO_RELAUNCH=1` -/// forces in-process (matches macOS opt-out). -/// * `CUA_DRIVER_RS_MCP_FORCE_PROXY=1` always proxies, even with -/// no daemon up — the caller is responsible for having one -/// already. -/// * Otherwise we probe `is_daemon_listening` on the default -/// socket: a live daemon means proxy through it; nothing -/// listening means run in-process (no autospawn equivalent on -/// Linux/Windows — there's no `open -a CuaDriver` analog). -#[cfg(not(target_os = "macos"))] -pub fn should_use_daemon_proxy(no_daemon_relaunch: bool) -> bool { - use crate::bundle::is_env_truthy; - // Same rule as macOS: an embedded driver answers in-process. - if cua_driver_core::embedded_mode() { - return false; - } - if no_daemon_relaunch { - return false; - } - if is_env_truthy("CUA_DRIVER_RS_MCP_NO_RELAUNCH") { - return false; - } - if is_env_truthy("CUA_DRIVER_RS_MCP_FORCE_PROXY") { - return true; - } - // Either the regular daemon (`\\.\pipe\cua-driver`) OR the uiAccess'd - // worker (`\\.\pipe\cua-driver-uia`) is a valid proxy target on Windows: - // both speak the same line-delimited JSON protocol. Preferring proxy mode - // when only the uia worker is up means MCP tool calls land in a process - // that bypasses UIPI for UWP apps. See #1602 / the cua-driver-uia crate. - #[cfg(target_os = "windows")] - { - if crate::serve::is_daemon_listening(&crate::serve::default_uia_pipe_path()) { - return true; - } - } - crate::serve::is_daemon_listening(&crate::serve::default_socket_path()) -} - /// Spawn `/usr/bin/open -n -g -a CuaDriver --args serve` to launch /// the daemon under `LaunchServices` (so it inherits the bundle's /// TCC attribution), then poll the socket for up to `timeout_secs` @@ -1187,9 +1069,7 @@ pub fn launch_daemon_and_wait( let status = status.map_err(|error| LaunchDaemonError { kind: LaunchDaemonErrorKind::Failed, - message: format!( - "failed to exec `/usr/bin/open`: {error}. Pass --no-daemon-relaunch to bypass." - ), + message: format!("failed to exec `/usr/bin/open`: {error}"), })?; if !status.success() { @@ -1197,8 +1077,7 @@ pub fn launch_daemon_and_wait( kind: LaunchDaemonErrorKind::Failed, message: format!( "`open -n -g -a CuaDriver --args serve{}` exited {:?}. \ - Check that `/Applications/CuaDriver.app` is installed, or \ - pass --no-daemon-relaunch to bypass.", + Check that `/Applications/CuaDriver.app` is installed.", if pass_socket { format!(" --socket {socket_path}") } else { @@ -1224,8 +1103,7 @@ pub fn launch_daemon_and_wait( message: format!( "daemon did not appear on {socket_path} within {timeout_secs}s. If this \ is the first launch, grant Accessibility + Screen Recording to \ - CuaDriver.app in System Settings and retry. Pass --no-daemon-relaunch \ - to stay in-process." + CuaDriver.app in System Settings and retry." ), }) } @@ -1292,19 +1170,16 @@ where let already_running = crate::serve::is_daemon_listening(&socket_path); let mut daemon = McpDaemonStartup::AlreadyRunning; if !already_running { - // CUA_DRIVER_RS_MCP_FORCE_PROXY callers (test harness, custom - // bundle setups) supply their own daemon — skip the auto- - // launch step, since they don't have an installed - // CuaDriver.app to relaunch into. Fail fast if no daemon is - // up at this point. - if crate::bundle::is_env_truthy("CUA_DRIVER_RS_MCP_FORCE_PROXY") { + // Never replace an embedded host's TCC identity by launching the + // standalone CuaDriver.app daemon. + if cua_driver_core::embedded_mode() { if let Some(on_startup) = on_startup.take() { on_startup(McpDaemonStartup::Unreachable, false); } anyhow::bail!( - "CUA_DRIVER_RS_MCP_FORCE_PROXY=1 but no daemon listening on \ - {socket_path}. Start one with `cua-driver serve --socket {socket_path}` \ - and retry." + "no Cua Driver daemon listening on {socket_path}. Start one with \ + `cua-driver serve --socket {socket_path}` and retry. Embedded hosts \ + must spawn `cua-driver serve --embedded` before starting the MCP proxy." ); } #[cfg(target_os = "macos")] @@ -1317,7 +1192,7 @@ where eprintln!( "cua-driver-rs: mcp launched without CuaDriver.app's TCC grants; \ auto-launching the daemon via `open -n -g -a CuaDriver --args serve{socket_suffix}` \ - and proxying MCP requests through it. Pass --no-daemon-relaunch to stay in-process." + and proxying MCP requests through it." ); if let Err(error) = launch_daemon_and_wait(&socket_path, 10, claude_code_compat) { if let Some(on_startup) = on_startup.take() { @@ -1338,12 +1213,8 @@ where let _ = claude_code_compat; // On Linux / Windows there's no equivalent `open -a CuaDriver` // mechanism to spawn a daemon attributed to the user's - // interactive session. The caller is expected to have one - // running already (e.g. via `cua-driver autostart enable && kick` - // on Windows). Bail with an actionable error rather than - // silently falling back to an in-process server that would - // be attributed to whatever session spawned us (typically - // Session 0 over SSH). + // interactive session. The caller is expected to have one running + // already (e.g. via `cua-driver autostart enable && kick` on Windows). #[cfg(not(target_os = "macos"))] { if let Some(on_startup) = on_startup.take() { @@ -1354,9 +1225,7 @@ where your interactive session — on Windows run \ `cua-driver autostart enable && cua-driver autostart kick`; \ on Linux run `cua-driver serve &` in the user's session. \ - Then re-run `cua-driver mcp`. To skip the proxy and run \ - in-process anyway (Session 0 attribution, GUI tools will \ - return empty), pass --no-daemon-relaunch." + Then re-run `cua-driver mcp`." ); } } @@ -1421,12 +1290,11 @@ pub fn build_manifest() -> serde_json::Value { // consumer can render uniformly. "subcommands": [ { "name": "mcp", - "description": "Run the MCP JSON-RPC server over stdio (the default invocation).", + "description": "Run the MCP stdio proxy backed by the required Cua Driver daemon (the default invocation).", "args": [ - { "name": "--no-daemon-relaunch", "type": "flag", "description": "Skip the bundle-based TCC auto-relaunch and stay in-process." }, - { "name": "--socket", "type": "string", "description": "Override the daemon proxy UDS path." }, + { "name": "--socket", "type": "string", "description": "Override the required daemon socket path." }, { "name": "--claude-code-computer-use-compat", "type": "flag", "description": "Select the Claude Code computer-use compat tool surface." }, - { "name": "--embedded", "type": "flag", "description": "Run embedded inside a host app: inherit the host's TCC grants, never prompt or relaunch. Also CUA_DRIVER_EMBEDDED=1." }, + { "name": "--embedded", "type": "flag", "description": "Require a daemon spawned by the embedding host 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", @@ -1451,12 +1319,12 @@ pub fn build_manifest() -> serde_json::Value { "description": "Print a single tool's full description + JSON input schema.", "args": [ { "name": "tool", "type": "positional-string", "description": "Tool name." } ] }, { "name": "call", - "description": "Invoke a single tool one-shot — proxies to a running daemon when one is up, otherwise runs in-process.", + "description": "Invoke a single tool through the required running daemon.", "args": [ { "name": "tool", "type": "positional-string", "description": "Tool name." }, { "name": "json-args", "type": "positional-json", "description": "Tool input JSON (or read from stdin)." }, { "name": "--screenshot-out-file", "type": "string", "description": "Write image content to this path instead of emitting base64." }, - { "name": "--socket", "type": "string", "description": "Override the daemon socket path used by the in-process forwarding fallback." } + { "name": "--socket", "type": "string", "description": "Override the required daemon socket path." } ] }, { "name": "mcp-config", "description": "Print the MCP server config snippet or a client-specific install command.", @@ -1749,22 +1617,21 @@ pub fn run_mcp_config(client: Option<&str>) { } } -/// Invoke a tool, forwarding to a running daemon if one is reachable; -/// otherwise runs in-process. Prints result to stdout on success, error -/// to stderr on failure. Exits 1 if the tool returned an error result. +/// Invoke a tool through the required running daemon. Prints result to stdout +/// on success and stderr on failure. Exits non-zero when the daemon is absent, +/// unreachable, or the tool returns an error. /// When `screenshot_out_file` is provided, image content is written there /// instead of emitted as base64 on stdout. /// /// `socket` — override the daemon socket path (from --socket flag). pub fn run_call( - registry: std::sync::Arc, tool: &str, json_args: Option, screenshot_out_file: Option, socket_override: Option, ) { - // Daemon forwarding: if a daemon is listening, proxy the request - // through it so AppStateEngine's element_index cache is shared. + // All public tool execution is daemon-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 @@ -1792,42 +1659,15 @@ pub fn run_call( crate::serve::default_socket_path() } }; - // macOS: `check_permissions` with prompt:true raises a TCC dialog. Run - // in-process from a terminal, that dialog attributes to the *terminal* - // (LaunchServices' "responsible" process), not to com.trycua.driver — - // so the grant lands on the wrong app and never sticks for the driver. - // When we're a bundle CLI spawned from a terminal (should_use_daemon_proxy) - // and there's no daemon to route through, DON'T raise the mis-attributed - // prompt: degrade to report-only and tell the user the one launch that - // grants correctly (`open … CuaDriver --args serve`, which raises the - // dialog as CuaDriver and waits for the grant). We deliberately do NOT - // auto-spawn that daemon here — a `call` shouldn't leave a background - // daemon behind, and the first-launch gate can lag socket creation. - #[cfg(target_os = "macos")] - let json_args = { - let mut effective = json_args; - let wants_prompt = effective - .as_ref() - .and_then(|v| v.get("prompt")) - .and_then(|v| v.as_bool()) - .unwrap_or(true); // check_permissions defaults prompt:true - if tool == "check_permissions" - && wants_prompt - && !crate::serve::is_daemon_listening(&socket_path) - && should_use_daemon_proxy(false) - { - eprintln!( - "cua-driver-rs: reporting permission status only. A prompt raised from \ - this terminal would attribute to the terminal, not CuaDriver, so the \ - grant wouldn't apply to the driver. To grant correctly, launch the \ - driver as its own app:\n open -n -g -a CuaDriver --args serve\n\ - then approve the CuaDriver dialog in System Settings." - ); - effective = Some(serde_json::json!({ "prompt": false })); - } - effective - }; - if crate::serve::is_daemon_listening(&socket_path) { + if !crate::serve::is_daemon_listening(&socket_path) { + eprintln!( + "Cua Driver daemon is not running on {socket_path}.\n\ + Start it first with: cua-driver serve --socket {socket_path}" + ); + process::exit(1); + } + + { let mut args_for_daemon = json_args .clone() .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); @@ -1885,9 +1725,8 @@ pub fn run_call( } } if let Some(sc) = result.get("structuredContent") { - // Merge image data into the structured payload - // (matches in-process behaviour at the bottom of - // this fn) so `cua-driver call screenshot` over + // Merge image data into the structured payload so + // `cua-driver call screenshot` over // the daemon socket still emits // `screenshot_png_b64`. Previously this path // dropped the image entirely when no @@ -1935,136 +1774,10 @@ pub fn run_call( } } Err(e) => { - // Daemon became unreachable mid-call — fall through to in-process. - // Promoted from `tracing::debug!` to `eprintln!` so callers see - // the degradation: in-process execution gets a FRESH ToolState, - // which means state-dependent tools (`click`, `type_text`, - // `set_value` — anything that reads the element_index cache) - // will fail with "Element N not in cache" even when a prior - // `get_window_state` populated the daemon's cache, because the - // daemon's cache and the in-process cache are different. - eprintln!( - "[cua-driver] WARNING: daemon proxy to {socket_path} failed ({e}); \ - running '{tool}' in-process. State-dependent tools may misbehave." - ); - } - } - } - if registry.get_def(tool).is_none() { - eprintln!("Unknown tool: {tool}"); - eprintln!("Run `cua-driver list-tools` to see available tools."); - process::exit(64); - } - - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("tokio runtime"); - - let mut args = json_args.unwrap_or(serde_json::Value::Object(serde_json::Map::new())); - cua_driver_core::tool_args::sanitize_reserved_args(&mut args); - let session_context = cua_driver_core::session::begin_tool_call( - tool, - &args, - true, - cua_driver_core::session::SessionTransport::Cli, - ); - let operation = cua_driver_core::server::tool_operation(tool, Some(&args)); - let observation_timer = cua_driver_core::server::ToolObservationTimer::start_with_operation( - tool.to_owned(), - operation, - true, - true, - cua_driver_core::server::StdioExecutionPath::InProcess, - ); - let tool_name = tool.to_string(); - let out_path = screenshot_out_file; - let is_error = rt.block_on(async move { - let result = registry.invoke(&tool_name, args).await; - let is_err = result.is_error.unwrap_or(false); - if let Ok(value) = serde_json::to_value(&result) { - let response = cua_driver_core::protocol::Response::ok(serde_json::Value::Null, value); - let outcome = observation_timer.finish(&response); - if let Some(context) = session_context { - context.complete(&outcome); - } - crate::telemetry::capture_tool_completed(outcome, crate::telemetry::Transport::Cli); - } - - // Emit content. - let mut has_printed = false; - let mut image_b64: Option<(String, String)> = None; // (base64, mime) - for item in &result.content { - match item { - Content::Text { text, .. } => { - if is_err { - eprintln!("{text}"); - } else { - // Only print text when there is no structuredContent - // (structuredContent path prints below). - if result.structured_content.is_none() { - println!("{text}"); - has_printed = true; - } - } - } - Content::Image { - data, mime_type, .. - } => { - image_b64 = Some((data.clone(), mime_type.clone())); - } - } - } - - // If --screenshot-out-file was provided, write the image there - // and suppress it from the JSON output (same as Swift reference). - if let Some(ref path) = out_path { - if let Some((b64, _mime)) = image_b64.take() { - use base64::Engine as _; - match base64::engine::general_purpose::STANDARD.decode(&b64) { - Ok(bytes) => { - if let Err(e) = std::fs::write(path, &bytes) { - eprintln!("--screenshot-out-file: failed to write {path}: {e}"); - } - } - Err(e) => { - eprintln!("--screenshot-out-file: base64 decode failed: {e}"); - } - } - } else { - eprintln!( - "--screenshot-out-file: no image content in tool response; file not written" - ); - } - } - - // If there's structuredContent, print it as JSON (with image merged in if no out_path). - if let Some(sc) = &result.structured_content { - if !is_err { - let mut obj = sc.clone(); - if out_path.is_none() { - if let Some((b64, mime)) = image_b64 { - if let serde_json::Value::Object(ref mut map) = obj { - map.insert("screenshot_png_b64".into(), serde_json::Value::String(b64)); - map.insert( - "screenshot_mime_type".into(), - serde_json::Value::String(mime), - ); - } - } - } - let pretty = serde_json::to_string_pretty(&obj).unwrap_or_else(|_| obj.to_string()); - println!("{pretty}"); - has_printed = true; + eprintln!("Cua Driver daemon request on {socket_path} failed: {e}"); + process::exit(1); } } - - let _ = has_printed; - is_err - }); - - if is_error { - process::exit(1); } } @@ -2073,7 +1786,7 @@ pub fn run_call( /// on the running daemon. /// /// Requires a running daemon (`cua-driver serve`) because recording -/// state lives in-process. +/// state lives in the daemon. pub fn run_recording_cmd(subcommand: &str, args: &[String], socket: Option<&str>) { // `render` is pure file-to-file work that doesn't need the daemon; // dispatch it before the daemon-running check so it works without @@ -2418,7 +2131,7 @@ pub fn run_update_cmd(apply: bool, json: bool) { } /// `cua-driver permissions status|grant`. -pub fn run_permissions_cmd(_registry: std::sync::Arc, subcommand: &str, json: bool) { +pub fn run_permissions_cmd(subcommand: &str, json: bool) { match subcommand { "status" => run_permissions_status(json), "grant" => run_permissions_grant(), @@ -2709,17 +2422,16 @@ fn cli_docs_json() -> serde_json::Value { "commands": [ { "name": "mcp", - "abstract": "Run the stdio MCP server.", - "discussion": "On macOS, shell-spawned MCP processes can auto-launch and proxy through a CuaDriver.app daemon so TCC grants attach to the bundle. On Windows and Linux, MCP proxies through an already-running daemon when one is listening.", + "abstract": "Run the daemon-backed stdio MCP proxy.", + "discussion": "Every MCP tool call is forwarded to a Cua Driver daemon. On macOS the proxy can auto-launch CuaDriver.app; on Windows and Linux the daemon must already be running.", "arguments": no_args, "options": [ - {"name":"socket","short_name":null,"help":"Override the daemon socket or named-pipe path used by the proxy fallback.","type":"String","default_value":null,"is_optional":true}, + {"name":"socket","short_name":null,"help":"Override the required daemon socket or named-pipe path.","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":"no-daemon-relaunch","short_name":null,"help":"Stay in-process instead of proxying through a daemon.","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":"Run embedded inside a host app: inherit the host's TCC grants, never prompt or relaunch. Also CUA_DRIVER_EMBEDDED=1.","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} ], "subcommands": no_subcommands }, @@ -2743,8 +2455,8 @@ fn cli_docs_json() -> serde_json::Value { }, { "name": "call", - "abstract": "Invoke an MCP tool directly from the shell.", - "discussion": "Runs the same handler the MCP server uses. JSON arguments may be passed as a positional JSON object or through stdin.", + "abstract": "Invoke an MCP tool through the running daemon.", + "discussion": "Requires a Cua Driver daemon. JSON arguments may be passed as a positional JSON object or through stdin.", "arguments": [ {"name":"tool-name","help":"Name of the MCP tool to invoke.","type":"String","is_optional":false}, {"name":"json-args","help":"JSON object for the tool input schema. If omitted, stdin is read when piped.","type":"String","is_optional":true} @@ -2805,7 +2517,7 @@ fn cli_docs_json() -> serde_json::Value { { "name": "recording", "abstract": "Control trajectory recording on a running daemon.", - "discussion": "Recording state lives in-process, so use a daemon for multi-call sessions.", + "discussion": "Recording state lives in the required daemon and survives client reconnects.", "arguments": no_args, "options": [{"name":"socket","short_name":null,"help":"Override the daemon socket or named-pipe path.","type":"String","default_value":null,"is_optional":true}], "flags": no_flags, @@ -3014,11 +2726,11 @@ pub fn run_dump_docs_with_type(registry: &ToolRegistry, pretty: bool, doc_type: /// - install layout (/Applications/CuaDriver.app, ~/.local/bin/cua-driver) /// - TCC DB rows for com.trycua.driver (sqlite3, best-effort) /// - config + state paths with existence booleans -pub fn run_diagnose_cmd(registry: std::sync::Arc) { +pub fn run_diagnose_cmd() { let sections = [ diagnose_runtime_section(), diagnose_signature_section(), - diagnose_tcc_section(registry), + diagnose_tcc_section(), diagnose_install_layout_section(), diagnose_tcc_db_section(), diagnose_config_paths_section(), @@ -3084,43 +2796,36 @@ fn diagnose_signature_section() -> String { ) } -fn diagnose_tcc_section(registry: std::sync::Arc) -> String { - // Call check_permissions in-process (quick, read-only). - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build(); - - let (ax, sr) = if let Ok(rt) = rt { - rt.block_on(async { - let result = registry - .invoke( - "check_permissions", - serde_json::Value::Object(Default::default()), - ) - .await; - if let Some(sc) = &result.structured_content { - let ax = sc - .get("accessibility") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let sr = sc - .get("screen_recording") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - (ax, sr) - } else { - (false, false) - } +fn diagnose_tcc_section() -> String { + let socket = crate::serve::default_socket_path(); + let status = crate::serve::is_daemon_listening(&socket) + .then(|| crate::serve::DaemonRequest { + method: "call".into(), + name: Some("check_permissions".into()), + args: Some(serde_json::json!({ "prompt": false })), + session_id: None, + observation_origin: Some(crate::serve::ToolObservationOrigin::Direct), }) - } else { - (false, false) + .and_then(|request| crate::serve::send_request(&socket, &request).ok()) + .filter(|response| response.ok) + .and_then(|response| response.result) + .and_then(|result| result.get("structuredContent").cloned()); + let display = |key: &str| { + status + .as_ref() + .and_then(|value| value.get(key)) + .and_then(serde_json::Value::as_bool) + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown (daemon unavailable)".to_owned()) }; + let ax = display("accessibility"); + let sr = display("screen_recording"); format!( - "## tcc probes (live process)\n\ + "## tcc probes (daemon)\n\ accessibility (AXIsProcessTrusted): {ax}\n\ screen recording (SCShareableContent): {sr}\n\n\ - if the UI disagrees with these booleans the live process is fine —\n\ + if the UI disagrees with these booleans the daemon is fine —\n\ the issue is elsewhere (wrong bundle granted, stale cdhash, etc)." ) } @@ -3244,42 +2949,10 @@ fn diagnose_config_paths_section() -> String { lines.join("\n") } -/// Path to the persistent JSON config file (`~/.cua-driver/config.json`). -fn config_file_path() -> std::path::PathBuf { - let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); - std::path::PathBuf::from(format!("{home}/.cua-driver/config.json")) -} - -/// Read persisted config from disk. Returns an empty object if absent/unreadable. -fn read_config_file() -> serde_json::Value { - let path = config_file_path(); - std::fs::read_to_string(&path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_else(|| serde_json::json!({})) -} - -/// Write a single key/value into the persisted config file. -fn write_config_file(key: &str, value: &serde_json::Value) { - let path = config_file_path(); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let mut cfg = read_config_file(); - if let serde_json::Value::Object(ref mut map) = cfg { - map.insert(key.to_owned(), value.clone()); - } - if let Ok(json) = serde_json::to_string_pretty(&cfg) { - let _ = std::fs::write(&path, json); - } -} - /// `cua-driver config [show|get|set|reset] [key] [value]` /// -/// Thin wrapper around the `get_config` / `set_config` MCP tools. -/// Forwards to the daemon when one is reachable, otherwise runs in-process. +/// Thin daemon-only wrapper around the `get_config` / `set_config` tools. pub fn run_config_cmd( - registry: std::sync::Arc, subcommand: Option<&str>, key: Option<&str>, value: Option<&str>, @@ -3289,25 +2962,56 @@ pub fn run_config_cmd( .map(str::to_owned) .unwrap_or_else(crate::serve::default_socket_path); - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("tokio runtime"); + if !crate::serve::is_daemon_listening(&socket_path) { + eprintln!( + "Cua Driver daemon is not running on {socket_path}.\n\ + Start it first with: cua-driver serve --socket {socket_path}" + ); + process::exit(1); + } + + let call = |tool: &str, args: serde_json::Value| -> serde_json::Value { + let req = crate::serve::DaemonRequest { + method: "call".into(), + name: Some(tool.to_owned()), + args: Some(args), + session_id: None, + observation_origin: Some(crate::serve::ToolObservationOrigin::Direct), + }; + let response = crate::serve::send_request(&socket_path, &req).unwrap_or_else(|error| { + eprintln!("Cua Driver daemon request on {socket_path} failed: {error}"); + process::exit(1); + }); + if !response.ok { + eprintln!( + "{}", + response + .error + .unwrap_or_else(|| format!("daemon rejected {tool}")) + ); + process::exit(response.exit_code.unwrap_or(1)); + } + response.result.unwrap_or_else(|| { + eprintln!("{tool}: daemon returned no result"); + process::exit(1); + }) + }; + + let get_config = || -> serde_json::Value { + let result = call("get_config", serde_json::json!({})); + result.get("structuredContent").cloned().unwrap_or_else(|| { + eprintln!("get_config: no structured content returned"); + process::exit(1); + }) + }; match subcommand.unwrap_or("show") { "show" | "" => { - // Print full config as pretty JSON. - let config = - rt.block_on(async { registry.invoke("get_config", serde_json::json!({})).await }); - if let Some(sc) = config.structured_content { - println!( - "{}", - serde_json::to_string_pretty(&sc).unwrap_or_else(|_| sc.to_string()) - ); - } else { - eprintln!("get_config: no structured content returned"); - process::exit(1); - } + let config = get_config(); + println!( + "{}", + serde_json::to_string_pretty(&config).unwrap_or_else(|_| config.to_string()) + ); } "get" => { @@ -3325,58 +3029,18 @@ pub fn run_config_cmd( ); process::exit(64); } - // Try daemon first. - if crate::serve::is_daemon_listening(&socket_path) { - let req = crate::serve::DaemonRequest { - method: "call".into(), - name: Some("get_config".into()), - args: Some(serde_json::json!({})), - // CLI `config get` reads the persisted global (anonymous). - session_id: None, - observation_origin: Some(crate::serve::ToolObservationOrigin::Direct), - }; - if let Ok(resp) = crate::serve::send_request(&socket_path, &req) { - if resp.ok { - if let Some(result) = resp.result { - if let Some(sc) = result.get("structuredContent") { - if let Some(v) = sc.get(key) { - println!("{v}"); - return; - } - } - } - } - } - } - // In-process: merge persisted file config over in-memory defaults. - let config = - rt.block_on(async { registry.invoke("get_config", serde_json::json!({})).await }); - let mut sc = match config.structured_content { - Some(v) => v, - None => { - eprintln!("get_config: no structured content returned"); - process::exit(1); - } - }; - // Overlay persisted values from the config file. - let file_cfg = read_config_file(); - if let (serde_json::Value::Object(sc_map), serde_json::Value::Object(file_map)) = - (&mut sc, file_cfg) - { - for (k, v) in file_map { - if k != "capture_scope" { - sc_map.insert(k, v); - } - } - } + let config = get_config(); // Support dotted key paths like "agent_cursor.enabled". let v = if key.contains('.') { let mut parts = key.splitn(2, '.'); let parent = parts.next().unwrap(); let child = parts.next().unwrap(); - sc.get(parent).and_then(|obj| obj.get(child)).cloned() + config + .get(parent) + .and_then(|object| object.get(child)) + .cloned() } else { - sc.get(key).cloned() + config.get(key).cloned() }; if let Some(v) = v { println!( @@ -3417,68 +3081,13 @@ pub fn run_config_cmd( // Parse value: try JSON, fall back to string. let parsed_value: serde_json::Value = serde_json::from_str(value) .unwrap_or_else(|_| serde_json::Value::String(value.to_owned())); - let args = serde_json::json!({ key: parsed_value }); - - // Try daemon first. - if crate::serve::is_daemon_listening(&socket_path) { - let req = crate::serve::DaemonRequest { - method: "call".into(), - name: Some("set_config".into()), - args: Some(args.clone()), - // CLI `config set` is anonymous → writes the persisted - // global default (the only writer of the on-disk config). - session_id: None, - observation_origin: Some(crate::serve::ToolObservationOrigin::Direct), - }; - if let Ok(resp) = crate::serve::send_request(&socket_path, &req) { - if resp.ok { - println!("Config updated."); - // Show current state. - let req2 = crate::serve::DaemonRequest { - method: "call".into(), - name: Some("get_config".into()), - args: Some(serde_json::json!({})), - session_id: None, - observation_origin: Some(crate::serve::ToolObservationOrigin::Direct), - }; - if let Ok(r2) = crate::serve::send_request(&socket_path, &req2) { - if let Some(result) = r2.result { - if let Some(sc) = result.get("structuredContent") { - println!( - "{}", - serde_json::to_string_pretty(sc).unwrap_or_default() - ); - } - } - } - return; - } else if let Some(e) = resp.error { - eprintln!("{e}"); - process::exit(1); - } - } - } - // In-process. - let result = rt.block_on(async { registry.invoke("set_config", args).await }); - if result.is_error.unwrap_or(false) { - for item in &result.content { - if let cua_driver_core::protocol::Content::Text { text, .. } = item { - eprintln!("{text}"); - } - } - process::exit(1); - } - // Persist the value to disk so future CLI invocations can read it. - write_config_file(key, &parsed_value); - // Print updated config. - let config = - rt.block_on(async { registry.invoke("get_config", serde_json::json!({})).await }); - if let Some(sc) = config.structured_content { - println!( - "{}", - serde_json::to_string_pretty(&sc).unwrap_or_else(|_| sc.to_string()) - ); - } + call("set_config", serde_json::json!({ key: parsed_value })); + println!("Config updated."); + let config = get_config(); + println!( + "{}", + serde_json::to_string_pretty(&config).unwrap_or_else(|_| config.to_string()) + ); } "reset" => { @@ -3489,20 +3098,13 @@ pub fn run_config_cmd( "capture_mode": "ax", "max_image_dimension": 0 }); - let result = rt.block_on(async { registry.invoke("set_config", defaults).await }); - if result.is_error.unwrap_or(false) { - eprintln!("config reset failed"); - process::exit(1); - } + call("set_config", defaults); println!("Config reset to defaults."); - let config = - rt.block_on(async { registry.invoke("get_config", serde_json::json!({})).await }); - if let Some(sc) = config.structured_content { - println!( - "{}", - serde_json::to_string_pretty(&sc).unwrap_or_else(|_| sc.to_string()) - ); - } + let config = get_config(); + println!( + "{}", + serde_json::to_string_pretty(&config).unwrap_or_else(|_| config.to_string()) + ); } other => { 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 ee0b0d1145..fd466c6973 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/main.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/main.rs @@ -1,7 +1,7 @@ //! cua-driver-rs — cross-platform background computer-use automation daemon. //! -//! Runs as a MCP JSON-RPC 2.0 server over stdio. The platform backend is -//! selected at compile time via conditional compilation. +//! Runs a daemon-backed MCP JSON-RPC 2.0 proxy over stdio. The platform +//! backend lives in the `serve` daemon selected at compile time. //! //! Extra CLI flags (consumed here, not by MCP): //! --cursor-icon custom cursor shape @@ -12,17 +12,9 @@ //! --dwell-ms post-click dwell override //! --idle-hide-ms idle-hide timeout override //! -//! ## macOS threading model -//! -//! AppKit requires the main thread. On macOS the entry-point is a plain -//! `fn main()` that: -//! 1. Initialises the cursor overlay channel synchronously (so -//! `run_on_main_thread` always finds it ready). -//! 2. Spawns a background tokio thread for the MCP server. -//! 3. Calls `platform_macos::cursor::overlay::run_on_main_thread()` which -//! starts `NSApplication.run()` and the 60 fps render loop. -//! -//! On all other platforms `#[tokio::main]` is used directly. +//! On macOS, `serve` keeps AppKit work on the main thread while its socket loop +//! runs in the background. MCP and CLI client processes never initialize the +//! platform tool registry. mod autostart; mod bundle; @@ -38,17 +30,8 @@ mod telemetry; mod updater; mod version_check; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -/// Set by the `Command::Mcp` arm when `--claude-code-computer-use-compat` -/// is on argv. Read by `build_registry` / `build_registry_no_cursor` to -/// pick which `screenshot` tool variant to register. Static keeps the -/// thread of dependency arrows pointed away from the platform crates — -/// they take `compat: bool` directly, but the binary crate decides what -/// to pass without making every Command variant carry the flag. -static CLAUDE_CODE_COMPAT: AtomicBool = AtomicBool::new(false); - fn init_logging() { use tracing_subscriber::EnvFilter; tracing_subscriber::fmt() @@ -276,33 +259,7 @@ fn main() { screenshot_out_file, socket, } => { - // Register callbacks (needed if the tool does screenshots/recording). - cua_driver_core::recording::set_screenshot_fn(|window_id, pid| { - if let Some(wid) = window_id { - platform_macos::capture::screenshot_window_bytes(wid as u32).ok() - } else if let Some(p) = pid { - platform_macos::windows::resolve_main_window_id(p as i32) - .ok() - .and_then(|wid| platform_macos::capture::screenshot_window_bytes(wid).ok()) - } else { - platform_macos::capture::screenshot_display_bytes().ok() - } - }); - cua_driver_core::recording::set_click_marker_fn(|png_bytes, cx, cy| { - platform_macos::capture::crosshair_png_bytes(png_bytes, cx, cy).ok() - }); - cua_driver_core::recording::set_ax_snapshot_fn(|window_id, pid| { - platform_macos::recording_hooks::app_state_json_for(window_id, pid) - }); - cua_driver_core::recording::set_element_bounds_fn(|wid, pid, idx| { - platform_macos::recording_hooks::element_window_local_xy(wid, pid, idx) - }); - cua_driver_core::video::set_video_backend_factory(Box::new( - platform_macos::video_sckit::SckitVideoBackendFactory, - )); - let reg = Arc::new(build_macos_registry()); - reg.init_self_weak(); - cli::run_call(reg, &tool, json_args, screenshot_out_file, socket); + cli::run_call(&tool, json_args, screenshot_out_file, socket); return; } cli::Command::Serve { @@ -364,13 +321,9 @@ fn main() { // Agent-cursor overlay. The DAEMON is the process that actually // performs clicks / AX presses, so the overlay NSWindow + render - // loop must run HERE — not only in the in-process `mcp` arm. In the - // daemon-proxy setup (`mcp` relaunches `open -n -g … serve` and - // proxies to it), the proxy never renders and, before this, neither - // did the daemon — so every cursor command was a silent no-op and - // the agent cursor never appeared. Init the channel before spawning - // the serve thread so `run_on_main_thread()` always finds it ready - // (mirrors the Mcp arm). + // loop must run HERE. The MCP proxy never renders, so the daemon + // owns every cursor command and window. Init the channel before spawning + // the serve thread so `run_on_main_thread()` always finds it ready. let cursor_cfg = cursor_overlay::CursorConfig::from_args(); if cursor_cfg.enabled { platform_macos::cursor::overlay::init(cursor_cfg.clone()); @@ -534,13 +487,11 @@ fn main() { return; } cli::Command::Diagnose => { - let reg = Arc::new(build_macos_registry()); - cli::run_diagnose_cmd(reg); + cli::run_diagnose_cmd(); return; } cli::Command::Permissions { subcommand, json } => { - let reg = Arc::new(build_macos_registry()); - cli::run_permissions_cmd(reg, &subcommand, json); + cli::run_permissions_cmd(&subcommand, json); return; } cli::Command::Autostart { subcommand } => { @@ -575,10 +526,7 @@ fn main() { value, socket, } => { - let reg = Arc::new(build_macos_registry()); - reg.init_self_weak(); cli::run_config_cmd( - reg, subcommand.as_deref(), key.as_deref(), value.as_deref(), @@ -587,135 +535,30 @@ fn main() { return; } cli::Command::Mcp { - no_daemon_relaunch, socket, claude_code_compat, } => { let startup_started = std::time::Instant::now(); - CLAUDE_CODE_COMPAT.store(claude_code_compat, Ordering::SeqCst); - // Long-running MCP server — kick off the background update - // check before any TCC / daemon-proxy decisions so the - // banner can land on stderr in either dispatch path. + // Long-running MCP proxy — kick off the background update check + // before connecting to or launching the daemon. version_check::maybe_announce_update(); - // TCC sidestep: if we're a shell-spawned bare binary that - // resolves into /Applications/CuaDriver.app, run the - // proxy path instead of the in-process MCP server. The - // proxy ensures a daemon is up under the bundle's TCC - // attribution and forwards stdio MCP through its socket. - // Issue #1525 / mirror of Swift PR #1479. - if cli::should_use_daemon_proxy(no_daemon_relaunch) { - if let Err(e) = - cli::run_mcp_via_daemon_proxy(socket, claude_code_compat, |daemon, success| { - telemetry::capture_mcp_startup_completed( - "daemon_proxy", - daemon.telemetry_value(), - success, - startup_started.elapsed(), - ) - }) - { - eprintln!("cua-driver-rs: {e}"); - telemetry::flush_pending(std::time::Duration::from_millis(750)); - std::process::exit(1); - } + if let Err(e) = + cli::run_mcp_via_daemon_proxy(socket, claude_code_compat, |daemon, success| { + telemetry::capture_mcp_startup_completed( + "daemon_proxy", + daemon.telemetry_value(), + success, + startup_started.elapsed(), + ) + }) + { + eprintln!("cua-driver-rs: {e}"); telemetry::flush_pending(std::time::Duration::from_millis(750)); - return; + std::process::exit(1); } - // Fall through to the in-process MCP server below. The - // `socket` flag is daemon-proxy-only; it has no meaning - // in the in-process path, so we drop it on the floor. - let _ = socket; - telemetry::capture_mcp_startup_completed( - "in_process", - "not_applicable", - true, - startup_started.elapsed(), - ); - } - } - - let cursor_cfg = cursor_overlay::CursorConfig::from_args(); - - tracing::info!( - version = env!("CARGO_PKG_VERSION"), - cursor_id = %cursor_cfg.cursor_id, - overlay_enabled = cursor_cfg.enabled, - has_custom_icon = cursor_cfg.shape.is_some(), - "cua-driver-rs starting (macOS)" - ); - - let enabled = cursor_cfg.enabled; - - // Initialise overlay channel synchronously BEFORE spawning background - // thread. This eliminates a race where run_on_main_thread() could be - // called before init() and find an empty channel. - if enabled { - platform_macos::cursor::overlay::init(cursor_cfg.clone()); - } - - // Spawn tokio + MCP server on a background thread so the main thread - // is free for AppKit. - // Register screenshot callback for recording (post-action screenshots). - cua_driver_core::recording::set_screenshot_fn(|window_id, pid| { - if let Some(wid) = window_id { - platform_macos::capture::screenshot_window_bytes(wid as u32).ok() - } else if let Some(p) = pid { - platform_macos::windows::resolve_main_window_id(p as i32) - .ok() - .and_then(|wid| platform_macos::capture::screenshot_window_bytes(wid).ok()) - } else { - platform_macos::capture::screenshot_display_bytes().ok() - } - }); - - // Register click-marker callback for recording (click.png with red crosshair). - cua_driver_core::recording::set_click_marker_fn(|png_bytes, cx, cy| { - platform_macos::capture::crosshair_png_bytes(png_bytes, cx, cy).ok() - }); - cua_driver_core::recording::set_ax_snapshot_fn(|window_id, pid| { - platform_macos::recording_hooks::app_state_json_for(window_id, pid) - }); - cua_driver_core::recording::set_element_bounds_fn(|wid, pid, idx| { - platform_macos::recording_hooks::element_window_local_xy(wid, pid, idx) - }); - cua_driver_core::video::set_video_backend_factory(Box::new( - platform_macos::video_sckit::SckitVideoBackendFactory, - )); - maybe_init_pip(); - - std::thread::Builder::new() - .name("cua-mcp".into()) - .spawn(move || { - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("tokio runtime"); - let compat = CLAUDE_CODE_COMPAT.load(Ordering::SeqCst); - rt.block_on(async move { - // Register tools; overlay init has already happened above. - let registry = Arc::new(build_macos_registry_with_compat(compat)); - // Wire up replay tool's back-reference to the registry. - registry.init_self_weak(); - if let Err(e) = cua_driver_core::server::run(registry).await { - tracing::error!("MCP server error: {e}"); - } - }); - // MCP server exited (stdin closed / client disconnected). - // The main thread is blocked in NSApplication.run() and won't - // exit on its own — force-exit the process cleanly. telemetry::flush_pending(std::time::Duration::from_millis(750)); - std::process::exit(0); - }) - .expect("spawn mcp thread"); - - // Main thread: AppKit overlay (blocks until the process exits). - if enabled { - platform_macos::cursor::overlay::run_on_main_thread(); - } - // Overlay disabled: park the main thread while the MCP background thread - // keeps running. - loop { - std::thread::park(); + return; + } } } @@ -771,14 +614,7 @@ fn main() -> anyhow::Result<()> { screenshot_out_file, socket, } => { - let reg = Arc::new(build_registry_no_cursor()); - reg.init_self_weak(); - // run_call builds its own tokio runtime; must run on a fresh thread. - std::thread::spawn(move || { - cli::run_call(reg, &tool, json_args, screenshot_out_file, socket); - }) - .join() - .ok(); + cli::run_call(&tool, json_args, screenshot_out_file, socket); return Ok(()); } cli::Command::Serve { @@ -797,13 +633,11 @@ fn main() -> anyhow::Result<()> { // The Rust permissions gate is macOS-only (TCC concept). // On Windows / Linux the flag is silently accepted for // CLI uniformity and ignored. The Claude-Code compat screenshot - // surface is likewise macOS-only (register_tools_with_compat), - // so the flag is accepted-and-ignored here for CLI uniformity. + // surface is accepted on every platform for CLI uniformity. let _ = no_permissions_gate; - let _ = claude_code_compat; // Serve mode needs the cursor overlay just like MCP mode. let cursor_cfg = cursor_overlay::CursorConfig::from_args(); - let reg = Arc::new(build_registry(cursor_cfg)); + let reg = Arc::new(build_registry(cursor_cfg, claude_code_compat)); reg.init_self_weak(); maybe_init_pip(); let sp = socket.unwrap_or_else(serve::default_socket_path); @@ -859,13 +693,11 @@ fn main() -> anyhow::Result<()> { return Ok(()); } cli::Command::Diagnose => { - let reg = Arc::new(build_registry_no_cursor()); - cli::run_diagnose_cmd(reg); + cli::run_diagnose_cmd(); return Ok(()); } cli::Command::Permissions { subcommand, json } => { - let reg = Arc::new(build_registry_no_cursor()); - cli::run_permissions_cmd(reg, &subcommand, json); + cli::run_permissions_cmd(&subcommand, json); return Ok(()); } cli::Command::Autostart { subcommand } => { @@ -900,121 +732,49 @@ fn main() -> anyhow::Result<()> { value, socket, } => { - let reg = Arc::new(build_registry_no_cursor()); - reg.init_self_weak(); - std::thread::spawn(move || { - cli::run_config_cmd( - reg, - subcommand.as_deref(), - key.as_deref(), - value.as_deref(), - socket.as_deref(), - ); - }) - .join() - .ok(); + cli::run_config_cmd( + subcommand.as_deref(), + key.as_deref(), + value.as_deref(), + socket.as_deref(), + ); return Ok(()); } cli::Command::Mcp { - no_daemon_relaunch, socket, claude_code_compat, } => { let startup_started = std::time::Instant::now(); - CLAUDE_CODE_COMPAT.store(claude_code_compat, Ordering::SeqCst); - // Long-running MCP server — kick off the background update - // check before any daemon-proxy decisions. + // Long-running MCP proxy — kick off the background update check + // before connecting to the daemon. version_check::maybe_announce_update(); - // Daemon-proxy sidestep for Windows Session 0 attribution - // (and equivalent on Linux when a daemon is up): if a - // daemon is listening on the default socket, forward - // stdio MCP through it instead of running the server - // in-process. The proxy preserves the daemon's session - // identity (typically Session 1+ on Windows) so window / - // UIA / screen tools see the user's actual desktop — - // without this, an `cua-driver mcp` spawned by Claude - // Code over SSH lands in Session 0 and every desktop - // tool returns empty. See `cli::should_use_daemon_proxy`. - if cli::should_use_daemon_proxy(no_daemon_relaunch) { - if let Err(e) = - cli::run_mcp_via_daemon_proxy(socket, claude_code_compat, |daemon, success| { - telemetry::capture_mcp_startup_completed( - "daemon_proxy", - daemon.telemetry_value(), - success, - startup_started.elapsed(), - ) - }) - { - eprintln!("cua-driver-rs: {e}"); - telemetry::flush_pending(std::time::Duration::from_millis(750)); - std::process::exit(1); - } + if let Err(e) = + cli::run_mcp_via_daemon_proxy(socket, claude_code_compat, |daemon, success| { + telemetry::capture_mcp_startup_completed( + "daemon_proxy", + daemon.telemetry_value(), + success, + startup_started.elapsed(), + ) + }) + { + eprintln!("cua-driver-rs: {e}"); telemetry::flush_pending(std::time::Duration::from_millis(750)); - return Ok(()); + std::process::exit(1); } - // Fall through to the in-process MCP server below. The - // `socket` flag is daemon-proxy-only; ignored on the - // in-process path (mirrors the macOS arm's drop-on-floor - // behaviour). - let _ = socket; - telemetry::capture_mcp_startup_completed( - "in_process", - "not_applicable", - true, - startup_started.elapsed(), - ); + telemetry::flush_pending(std::time::Duration::from_millis(750)); + return Ok(()); } } - - // MCP server mode: this needs a full async tokio runtime. - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("tokio runtime"); - rt.block_on(async_main())?; - Ok(()) -} - -#[cfg(not(target_os = "macos"))] -async fn async_main() -> anyhow::Result<()> { - let cursor_cfg = cursor_overlay::CursorConfig::from_args(); - - tracing::info!( - version = env!("CARGO_PKG_VERSION"), - os = std::env::consts::OS, - cursor_id = %cursor_cfg.cursor_id, - overlay_enabled = cursor_cfg.enabled, - has_custom_icon = cursor_cfg.shape.is_some(), - "cua-driver-rs starting" - ); - - let registry = Arc::new(build_registry(cursor_cfg)); - registry.init_self_weak(); - maybe_init_pip(); - let result = cua_driver_core::server::run(registry).await; - if let Err(e) = &result { - tracing::error!("MCP server error: {e}"); - } - - // The stdio MCP server loop has ended — the client disconnected (stdin - // EOF) or a fatal I/O error occurred. The cursor overlay runs on its own - // detached thread with an independent Win32 message loop (and we raised the - // multimedia timer resolution via `timeBeginPeriod`), so simply returning - // is not guaranteed to tear it down promptly: that thread is never joined - // and would otherwise keep its render loop alive as an orphan, accumulating - // CPU after the client is gone (issue #1808). Force a clean process exit so - // the overlay thread dies with us the moment the transport closes — mirrors - // the macOS `std::process::exit(0)` after `server::run`. - telemetry::flush_pending(std::time::Duration::from_millis(750)); - std::process::exit(if result.is_ok() { 0 } else { 1 }); } // ── Registry builder (non-macOS) ────────────────────────────────────────── #[cfg(not(target_os = "macos"))] -fn build_registry(cursor_cfg: cursor_overlay::CursorConfig) -> cua_driver_core::tool::ToolRegistry { - let compat = CLAUDE_CODE_COMPAT.load(Ordering::SeqCst); +fn build_registry( + cursor_cfg: cursor_overlay::CursorConfig, + compat: bool, +) -> cua_driver_core::tool::ToolRegistry { #[cfg(target_os = "windows")] { cua_driver_core::recording::set_classified_screenshot_fn(|window_id, pid| { @@ -1108,7 +868,7 @@ fn build_registry(cursor_cfg: cursor_overlay::CursorConfig) -> cua_driver_core:: /// Used by CLI subcommands (list-tools / describe / call) that don't need the overlay. #[cfg(not(target_os = "macos"))] fn build_registry_no_cursor() -> cua_driver_core::tool::ToolRegistry { - let compat = CLAUDE_CODE_COMPAT.load(Ordering::SeqCst); + let compat = false; #[cfg(target_os = "windows")] { cua_driver_core::recording::set_classified_screenshot_fn(|window_id, pid| { diff --git a/libs/cua-driver/rust/crates/cua-driver/src/mcp_http.rs b/libs/cua-driver/rust/crates/cua-driver/src/mcp_http.rs index 4075598eae..0bb5d33ba7 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/mcp_http.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/mcp_http.rs @@ -147,7 +147,7 @@ fn http_tool_observation_timer( tool_observation_timer( &req, |name| name == "type_text_chars" || registry.get_def(name).is_some(), - StdioExecutionPath::InProcess, + StdioExecutionPath::DirectDaemon, ) } 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 3a48ace21c..c2d2357627 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/proxy.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/proxy.rs @@ -1,24 +1,18 @@ //! Stdio MCP proxy that forwards `tools/list` and `tools/call` through //! the running `cua-driver-rs serve` daemon over its Unix socket. //! -//! This is the runtime half of the TCC auto-relaunch path (issue #1525, -//! mirror of Swift PR #1479). When `cua-driver-rs mcp` is invoked from -//! an IDE terminal — Claude Code, Cursor, VS Code, Warp — macOS TCC -//! attributes the process to the calling terminal, not to -//! `CuaDriver.app`. The MCP client side sees a normal stdio server, -//! but every AX probe silently fails because the binary is running -//! against the wrong bundle id. +//! This is the only MCP execution path. The client side sees a normal stdio +//! server, while the daemon remains the single owner of tool state, policy, +//! and platform permission identity. //! -//! The fix: detect that context (see `crate::bundle`), ensure a daemon -//! is running under `LaunchServices` (which gives it the right TCC -//! attribution), then proxy every MCP request through the daemon's -//! socket. The MCP client never sees the redirection — same JSON-RPC -//! envelope, same tool semantics. +//! On macOS the CLI can ensure a daemon is running under `LaunchServices` +//! (which gives it the right TCC attribution). Embedded hosts and other +//! platforms start the daemon explicitly. The MCP client never sees that +//! boundary — it receives the standard JSON-RPC envelope. //! //! Why this lives in `cua-driver` and not `mcp-server`: -//! `cua_driver_core::server::run` already speaks JSON-RPC over stdio -//! against an in-process `ToolRegistry`. The proxy speaks the same -//! protocol on the client side but the server side is the daemon's +//! `cua_driver_core::server` defines the shared JSON-RPC protocol. The +//! proxy speaks that protocol on the client side, while the server side is the daemon's //! line-delimited JSON UDS protocol, owned by `crate::serve`. //! Putting the proxy here avoids `mcp-server → cua-driver` reverse //! coupling. @@ -41,9 +35,8 @@ use crate::serve::{is_daemon_listening, send_request, DaemonRequest, ToolObserva /// `socket_path`, and writes the daemon's response back as a proper /// JSON-RPC envelope. /// -/// Mirrors `cua_driver_core::server::run`'s control flow exactly — same -/// EOF + parse-error + notification handling — only the per-method -/// branches change. +/// Implements the core protocol's EOF, parse-error, notification, and +/// response rules while forwarding method dispatch to the daemon. /// /// Fails fast if the daemon isn't reachable, so MCP clients see a /// clear startup error instead of a "successful" handshake that @@ -131,7 +124,7 @@ pub async fn run_proxy(socket_path: String) -> anyhow::Result<()> { Response::parse_error() } Ok(req) if req.is_notification() => { - // Notifications get dropped, same as `server::run`. + // Notifications are intentionally dropped by the stdio adapter. continue; } Ok(req) => { @@ -402,8 +395,7 @@ fn fetch_tools_list_from_daemon( // Reshape the daemon's `{name, description, input_schema, read_only, // ..., capabilities}` envelope into MCP's `{name, description, // inputSchema, annotations: {...}, capabilities}` shape. Same - // translation `ToolDef::to_list_entry` does for the in-process - // path so MCP clients see identical tools/list output either way. + // translation `ToolDef::to_list_entry` defines for the core protocol. // // `capabilities` is passed through verbatim when the daemon // provides it; older daemons that don't emit the field fall back @@ -504,12 +496,12 @@ fn daemon_owns_tool_observation(result: &serde_json::Value) -> bool { /// JSON-RPC method dispatcher for the proxy. Mirrors /// `cua_driver_core::server::handle_request`: /// - `initialize` → static `initialize_result()` (same envelope -/// the in-process path returns; the daemon's +/// as the core protocol server; the daemon's /// identity is hidden from the MCP client). /// - `tools/list` → return the cached daemon tool list. /// - `tools/call` → forward to the daemon and reshape the /// response into MCP's `CallTool.Result`. -/// - other → method-not-found, same as in-process. +/// - other → method-not-found. async fn handle_proxy_request( req: Request, id: serde_json::Value, @@ -579,8 +571,7 @@ async fn handle_proxy_request( /// Error mapping: /// - Tool ran and reported failure (`!resp.ok`, including unknown /// tool / bad params) → JSON-RPC success with `result.isError = -/// true`. Mirrors the in-process `cua_driver_core::server` path so -/// MCP clients see identical envelopes either way. +/// true`. Mirrors the core protocol's tool-error envelope. /// - Transport failure (UDS unreachable, decode error, blocking /// task panic) → JSON-RPC error (`-32603`), because the MCP /// client really does need to distinguish "tool said no" from @@ -640,10 +631,8 @@ async fn forward_tool_call( // A non-`ok` daemon response means the tool call reached the // daemon and the daemon decided the tool returned an error // (or rejected the call). That's tool-level, not transport- - // level, so the in-process `cua_driver_core::server` would surface - // it as `Response::ok` with `isError: true`. Mirror that - // shape here so MCP clients see identical envelopes either - // way — CodeRabbit #2. + // level, so the core protocol surfaces it as `Response::ok` with + // `isError: true`. Mirror that shape here — CodeRabbit #2. let msg = resp .error .unwrap_or_else(|| "daemon reported failure".into()); @@ -669,7 +658,7 @@ async fn forward_tool_call( // // Unit-test only the JSON shape of the proxy's tool-error envelope. // The full proxy loop is exercised by the macOS integration test -// (the CUA_DRIVER_RS_MCP_FORCE_PROXY harness); these tests just lock +// (the daemon-backed integration harness); these tests just lock // in the per-branch reshape so a // regression to `Response::error` for tool-level failures would fail // fast in CI on every platform. diff --git a/libs/cua-driver/rust/crates/cua-driver/src/responsibility.rs b/libs/cua-driver/rust/crates/cua-driver/src/responsibility.rs index c09a89bfb9..7baf4cfa48 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/responsibility.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/responsibility.rs @@ -136,7 +136,7 @@ pub fn reexec_disclaimed_if_needed() { // The disclaimed child is the real `serve` process. Block on it and mirror // its exit status so the launch keeps its original foreground semantics: - // `serve` ran in-process before, so callers that wait on it, forward + // callers that wait on `serve`, forward // terminal signals to the foreground process group, or read `$?` still see // the same behavior. The child shares our process group (no // POSIX_SPAWN_SETPGROUP), so Ctrl-C reaches it directly. 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 ab79140ab0..0c48b97b95 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/serve.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/serve.rs @@ -450,7 +450,7 @@ async fn invoke_daemon_tool( operation, known_tool, true, - cua_driver_core::server::StdioExecutionPath::InProcess, + cua_driver_core::server::StdioExecutionPath::DirectDaemon, ), transport, ) @@ -550,10 +550,9 @@ async fn invoke_daemon_tool( /// whether a pipe instance is available **without consuming one**. The /// previous design (sending a `list` request) opened a pipe instance, /// then the immediately-following real `send_request` had to wait for the -/// daemon to spin up its NEXT instance — that race caused real tool calls +/// daemon to spin up its NEXT instance — that race made real tool calls /// (especially state-dependent ones like `click` that need the daemon's -/// element_index cache) to fall through to the in-process path with a -/// fresh empty cache. See the conversation around the +/// element_index cache) miss the shared daemon state. See the conversation around the /// "Element 3 not in cache" bug for the diagnosis. /// /// `WaitNamedPipeW(name, 1)`: @@ -837,8 +836,8 @@ pub async fn run_serve( // // `capabilities` is sourced from the centralised // `cua_driver_core::tool::default_capabilities_for` - // name → tokens map so the daemon and in-process - // paths emit identical capability arrays. + // name → tokens map so daemon responses match the + // core MCP capability contract. let tools: Vec = reg.iter_defs() .map(|(name, def)| { let caps = cua_driver_core::tool::default_capabilities_for(name); @@ -1141,8 +1140,8 @@ fn is_self_at_high_il() -> bool { /// #1630). Without this, the default UIPI rule "no write-up across IL /// boundaries" makes the High-IL daemon's pipe unreachable from a normal /// Medium-IL user shell — every `cua-driver ` call from the CLI -/// silently falls through to in-process execution with a fresh, empty -/// `ToolState`, which breaks the element_index cache invariant +/// fails to reach the daemon and its shared `ToolState`, which breaks the +/// element_index cache invariant /// (`get_window_state` → `click(element_index)` stops working because /// the two calls land in different ToolState instances). /// @@ -1216,8 +1215,8 @@ pub async fn run_serve( if security_attrs.is_none() { eprintln!( "cua-driver: failed to build cross-IL SECURITY_ATTRIBUTES; pipe will be \ - High-IL exclusive. CLI calls from Medium-IL shells will fall through \ - to in-process and break state-dependent tool sequences." + High-IL exclusive. CLI and MCP clients from Medium-IL processes \ + will be unable to reach the daemon." ); } // Hold the SD pointer alive for the lifetime of run_serve. We never diff --git a/libs/cua-driver/rust/crates/cua-driver/src/telemetry.rs b/libs/cua-driver/rust/crates/cua-driver/src/telemetry.rs index 6aa0f74fab..33b2fc36e4 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/telemetry.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/telemetry.rs @@ -266,8 +266,8 @@ pub fn inspect_event(event_name: &str) -> Result { ("execution_mode", Value::String(execution_mode().into())), ]), event::MCP_STARTUP_COMPLETED => bounded_properties(&[ - ("path", Value::String("in_process".into())), - ("daemon", Value::String("not_applicable".into())), + ("path", Value::String("daemon_proxy".into())), + ("daemon", Value::String("already_running".into())), ("success", Value::Bool(true)), ("duration_bucket", Value::String("lt_100ms".into())), ("execution_mode", Value::String(execution_mode().into())), @@ -870,7 +870,6 @@ pub(crate) fn capture_mcp_startup_completed( elapsed: Duration, ) { let path = match path { - "in_process" => "in_process", "daemon_proxy" => "daemon_proxy", _ => "unknown", }; @@ -2700,7 +2699,7 @@ mod tests { )); let mcp_start = inspect_event(event::MCP_STARTUP_COMPLETED).unwrap(); assert_eq!(mcp_start["properties"]["transport"], "mcp_stdio"); - assert_eq!(mcp_start["properties"]["path"], "in_process"); + assert_eq!(mcp_start["properties"]["path"], "daemon_proxy"); assert_eq!( mcp_start["properties"]["execution_mode"], tool["properties"]["execution_mode"] diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs new file mode 100644 index 0000000000..254fdad13f --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs @@ -0,0 +1,71 @@ +//! Public tool transports must fail closed when no daemon is reachable. + +use std::process::Command; + +use cua_driver_testkit::{CliDriver, Driver}; + +fn missing_socket() -> (String, Option) { + #[cfg(unix)] + { + let directory = tempfile::Builder::new() + .prefix("cua-missing-") + .tempdir_in("/tmp") + .expect("temporary socket directory"); + let socket = directory.path().join("missing.sock").display().to_string(); + (socket, Some(directory)) + } + #[cfg(target_os = "windows")] + { + ( + format!(r"\\.\pipe\cua-driver-missing-{}", std::process::id()), + None, + ) + } + #[cfg(not(any(unix, target_os = "windows")))] + { + (format!("cua-driver-missing-{}", std::process::id()), None) + } +} + +#[test] +fn cli_call_does_not_execute_without_daemon() { + let (socket, _directory) = missing_socket(); + let output = Command::new(env!("CARGO_BIN_EXE_cua-driver")) + .args(["call", "list_apps", "--socket", &socket]) + .output() + .expect("run cua-driver call"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("daemon is not running"), + "unexpected stderr: {stderr}" + ); +} + +#[test] +fn embedded_mcp_does_not_fall_back_without_daemon() { + let (socket, _directory) = missing_socket(); + let output = Command::new(env!("CARGO_BIN_EXE_cua-driver")) + .args(["mcp", "--embedded", "--socket", &socket]) + .env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "false") + .output() + .expect("run cua-driver mcp"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("no Cua Driver daemon listening"), + "unexpected stderr: {stderr}" + ); +} + +#[test] +fn cli_call_succeeds_through_test_owned_daemon() { + let mut driver = CliDriver::new(); + assert!(driver.available(), "test daemon failed to start"); + + let response = driver.call("get_config", serde_json::json!({})); + assert!(!response.is_error(), "CLI call failed: {}", response.text()); + assert!(response.structured().is_object()); +} diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/transport_config_persistence_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/transport_config_persistence_test.rs index 925c7ccc52..4ee00c9d6f 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/transport_config_persistence_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/transport_config_persistence_test.rs @@ -1,14 +1,12 @@ -//! Transport axis: `set_config` persistence across the CLI vs MCP transports. +//! Transport axis: `set_config` visibility across the CLI vs MCP transports. //! //! This is the one behavior that only shows up when a test covers BOTH //! transports, so it lives on the shared testkit `Driver` abstraction: //! -//! - **CLI** (`CliDriver`) is stateless — each `cua-driver call` is its own -//! process. For a `set_config` to be visible to the *next* invocation it -//! must persist to **disk**. #2034 made that true on Windows + Linux (macOS -//! already did); this test guards against a regression. -//! - **MCP** (`McpDriver`) is one long-lived connection — a `set_config` is -//! visible to later calls on the SAME driver within the session. +//! - **CLI** (`CliDriver`) starts a fresh shell process for each call, but all +//! calls go through one test-owned daemon. +//! - **MCP** (`McpDriver`) is one long-lived proxy connection to its own +//! test-owned daemon. //! //! Uses `max_image_dimension` as the persisted key. `capture_mode` / //! `capture_scope` are NO LONGER settings (`capture_scope` is per-session), so @@ -29,12 +27,11 @@ fn config_max_dim(structured: &serde_json::Value) -> Option { structured[KEY].as_u64() } -/// CLI: a `set_config` in one process is observed by a *separate* `get_config` -/// process — i.e. it persisted to disk (#2034). The two `cli.call(...)`s below -/// are independent `cua-driver call` invocations. +/// CLI: a `set_config` in one shell process is observed by a separate +/// `get_config` process through their shared daemon. #[test] #[ignore] -fn cli_set_config_persists_to_disk_across_invocations() { +fn cli_set_config_visible_across_daemon_backed_invocations() { let mut cli = CliDriver::new(); if !cli.available() { eprintln!("[transport] driver binary not built — skipping"); @@ -50,12 +47,12 @@ fn cli_set_config_persists_to_disk_across_invocations() { ); assert!(!set.is_error(), "CLI set_config errored: {}", set.text()); - // A fresh process must see the persisted value. + // A fresh shell process must see the daemon-owned value. let after = cli.call("get_config", serde_json::json!({})); assert_eq!( config_max_dim(after.structured()), Some(PROBE), - "CLI set_config did NOT persist to disk across invocations (#2034 regression): {}", + "CLI set_config was not visible across daemon-backed invocations: {}", after.text() ); 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 d81304ec3d..7820e2a3c2 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 @@ -32,13 +32,11 @@ fn screen_recording_capturable() -> bool { /// /// macOS attributes Accessibility / Screen-Recording to the *responsible /// process* (the LaunchServices launching app), not the executable path. -/// So `check_permissions` answered in-process reflects: +/// So `check_permissions` answered by the daemon reflects: /// - the **CuaDriver daemon** (`com.trycua.driver`) when this process is /// its own responsible process — the real driver status. -/// - the **calling app** otherwise — e.g. the terminal/IDE that spawned -/// `cua-driver call …`. That grant is NOT the driver's, which is why a -/// standalone check can read `true` while `tccutil … com.trycua.driver` -/// reports no record. +/// - the **embedding host** otherwise. That is intentional only when the +/// host directly spawned `cua-driver serve --embedded`. fn permission_source() -> serde_json::Value { let pid = unsafe { libc::getpid() }; let ppid = unsafe { libc::getppid() }; diff --git a/libs/cua-driver/rust/examples/embedded-host-macos/ExampleAgentHarness.swift b/libs/cua-driver/rust/examples/embedded-host-macos/ExampleAgentHarness.swift index 4522ad2240..9cb660a41b 100644 --- a/libs/cua-driver/rust/examples/embedded-host-macos/ExampleAgentHarness.swift +++ b/libs/cua-driver/rust/examples/embedded-host-macos/ExampleAgentHarness.swift @@ -8,8 +8,8 @@ // Runs the one-grant demo sequence from EMBEDDING.md end to end: // 1. Requests Accessibility + Screen Recording AS THE HOST (the only // prompts the user ever sees), then -// 2. spawns cua-driver as a direct child in embedded mode and, over -// stdio MCP, verifies attribution, takes a background screenshot, +// 2. spawns an embedded cua-driver daemon plus its stdio MCP proxy and +// verifies attribution, takes a background screenshot, // reads a background app's window state, and glides the agent-cursor // overlay — with zero driver-side prompts. // @@ -42,16 +42,36 @@ if !ax || !sr { log("after this run: grant the missing item(s) in System Settings, then re-run") } -// 2. Spawn cua-driver as a DIRECT child (never via `open`/NSWorkspace — -// that breaks responsibility inheritance) in embedded mode. +// 2. Spawn the daemon as a DIRECT child (never via `open`/NSWorkspace — +// that breaks responsibility inheritance), then attach an MCP proxy. let driverPath = ProcessInfo.processInfo.environment["CUA_DRIVER_PATH"] ?? "/usr/local/bin/cua-driver" -let driver = Process() -driver.executableURL = URL(fileURLWithPath: driverPath) -driver.arguments = ["mcp"] +let socketPath = "/tmp/cua-embedded-\(ProcessInfo.processInfo.processIdentifier).sock" var env = ProcessInfo.processInfo.environment env["CUA_DRIVER_EMBEDDED"] = "1" env["CUA_DRIVER_HOST_BUNDLE_ID"] = Bundle.main.bundleIdentifier ?? "" + +let daemon = Process() +daemon.executableURL = URL(fileURLWithPath: driverPath) +daemon.arguments = ["serve", "--embedded", "--socket", socketPath] +daemon.environment = env +daemon.standardOutput = logFile +daemon.standardError = logFile +try daemon.run() + +let deadline = Date().addingTimeInterval(10) +while !FileManager.default.fileExists(atPath: socketPath) && Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) +} +guard FileManager.default.fileExists(atPath: socketPath) else { + log("embedded daemon did not create \(socketPath)") + daemon.terminate() + exit(1) +} + +let driver = Process() +driver.executableURL = URL(fileURLWithPath: driverPath) +driver.arguments = ["mcp", "--embedded", "--socket", socketPath] driver.environment = env let toDriver = Pipe(), fromDriver = Pipe() driver.standardInput = toDriver @@ -100,7 +120,7 @@ send(["jsonrpc": "2.0", "id": nextId, "method": "initialize", "params": [ "clientInfo": ["name": "ExampleAgentHarness", "version": "0.1"]]]) _ = readMessage() send(["jsonrpc": "2.0", "method": "notifications/initialized"]) -log("embedded cua-driver started (\(driverPath)) — no driver prompt should have appeared") +log("embedded cua-driver daemon + proxy started (\(driverPath)) — no driver prompt should have appeared") // 4. check_permissions must report attribution "host" and never prompt. let perms = call("check_permissions") @@ -141,4 +161,5 @@ log("move_cursor — \(cursorOk ? "ok" : "FAILED")") let pass = attribution == "host" && !images.isEmpty && hasTree && cursorOk log(pass ? "DEMO COMPLETE: PASS" : "DEMO COMPLETE: FAIL") driver.terminate() +daemon.terminate() exit(pass ? 0 : 1) diff --git a/libs/cua-driver/scripts/install.ps1 b/libs/cua-driver/scripts/install.ps1 index 3bf912ba1d..ec92ab9654 100644 --- a/libs/cua-driver/scripts/install.ps1 +++ b/libs/cua-driver/scripts/install.ps1 @@ -79,7 +79,7 @@ param( # Default-on: cua-driver-serve is what makes the agent flow work # across logon / reboot. Without the scheduled task the user has # to remember to run `cua-driver autostart kick` every time, and - # MCP-style flows go silently in-process. Opt out with + # CLI and MCP tool calls fail when no daemon is available. Opt out with # `-AutoStart:$false` or `-NoAutoStart` for CI / sandbox installs # that specifically don't want a scheduled task registered. [switch]$AutoStart = $true, diff --git a/nix/cua-driver/tests/policy-rego.nix b/nix/cua-driver/tests/policy-rego.nix index 25d6131a3b..cd2331247d 100644 --- a/nix/cua-driver/tests/policy-rego.nix +++ b/nix/cua-driver/tests/policy-rego.nix @@ -28,13 +28,24 @@ let } POLICY + socket=/tmp/cua-driver-policy-rego.sock + env \ + CUA_DRIVER_POLICY_FILE=/tmp/policy \ + CUA_DRIVER_RS_TELEMETRY_ENABLED=false \ + cua-driver serve --socket "$socket" --no-permissions-gate --no-overlay \ + >/tmp/daemon.log 2>&1 & + daemon_pid=$! + trap 'kill "$daemon_pid" 2>/dev/null || true; if [[ -n "''${DRIVER_PID:-}" ]]; then kill "$DRIVER_PID" 2>/dev/null || true; fi' EXIT + for _ in $(seq 1 200); do + cua-driver status --socket "$socket" >/dev/null 2>&1 && break + sleep 0.05 + done + cua-driver status --socket "$socket" >/dev/null + coproc DRIVER { - env \ - CUA_DRIVER_POLICY_FILE=/tmp/policy \ - CUA_DRIVER_RS_TELEMETRY_ENABLED=false \ - cua-driver mcp --no-daemon-relaunch 2>/tmp/driver.log + env CUA_DRIVER_RS_TELEMETRY_ENABLED=false \ + cua-driver mcp --socket "$socket" 2>/tmp/driver.log } - trap 'kill "$DRIVER_PID" 2>/dev/null || true' EXIT exec 3>&"''${DRIVER[1]}" exec 4<&"''${DRIVER[0]}" diff --git a/nix/cua-driver/tests/policy-yaml.nix b/nix/cua-driver/tests/policy-yaml.nix index a76238de17..9c45543764 100644 --- a/nix/cua-driver/tests/policy-yaml.nix +++ b/nix/cua-driver/tests/policy-yaml.nix @@ -22,13 +22,24 @@ let - shell_execute POLICY + socket=/tmp/cua-driver-policy-yaml.sock + env \ + CUA_DRIVER_POLICY_FILE=/tmp/policy.yaml \ + CUA_DRIVER_RS_TELEMETRY_ENABLED=false \ + cua-driver serve --socket "$socket" --no-permissions-gate --no-overlay \ + >/tmp/daemon.log 2>&1 & + daemon_pid=$! + trap 'kill "$daemon_pid" 2>/dev/null || true; if [[ -n "''${DRIVER_PID:-}" ]]; then kill "$DRIVER_PID" 2>/dev/null || true; fi' EXIT + for _ in $(seq 1 200); do + cua-driver status --socket "$socket" >/dev/null 2>&1 && break + sleep 0.05 + done + cua-driver status --socket "$socket" >/dev/null + coproc DRIVER { - env \ - CUA_DRIVER_POLICY_FILE=/tmp/policy.yaml \ - CUA_DRIVER_RS_TELEMETRY_ENABLED=false \ - cua-driver mcp --no-daemon-relaunch 2>/tmp/driver.log + env CUA_DRIVER_RS_TELEMETRY_ENABLED=false \ + cua-driver mcp --socket "$socket" 2>/tmp/driver.log } - trap 'kill "$DRIVER_PID" 2>/dev/null || true' EXIT exec 3>&"''${DRIVER[1]}" exec 4<&"''${DRIVER[0]}" diff --git a/scripts/docs-generators/cua-driver.ts b/scripts/docs-generators/cua-driver.ts index 59ae169cc3..c0982f1beb 100644 --- a/scripts/docs-generators/cua-driver.ts +++ b/scripts/docs-generators/cua-driver.ts @@ -595,7 +595,7 @@ export function generateMCPToolsMDX(docs: MCPDocumentation, releasedVersion: str lines.push(''); lines.push(''); lines.push( - " **TCC auto-delegation.** When an MCP client spawns `cua-driver mcp` from an IDE terminal (Claude Code, Cursor, VS Code, Warp), macOS attributes the subprocess to the parent terminal — not `CuaDriver.app` — so AX probes fail against the wrong bundle id. `mcp` detects this and auto-launches a `cua-driver serve` daemon via `open -n -g -a CuaDriver --args serve`, then proxies every tool call through the daemon's Unix socket. Tool semantics are identical to the in-process path; no Python bridge is needed. Pass `--no-daemon-relaunch` (or set `CUA_DRIVER_MCP_NO_RELAUNCH=1`) to force in-process execution. See the [process model](/reference/cua-driver/process-model) for the full lifecycle, failure modes, and wrapper-author guidance." + " **Daemon delegation.** `cua-driver mcp` is always a stdio proxy to a `cua-driver serve` daemon. On macOS it can auto-launch the daemon via `open -n -g -a CuaDriver --args serve` so AX and Screen Recording grants attach to the app bundle. On Windows and Linux the daemon must already be running. See the [process model](/reference/cua-driver/process-model) for the full lifecycle and wrapper-author guidance." ); lines.push(''); lines.push('');