Run in a VM, over SSH, or in a sandbox

Run Cua Driver inside a macOS Lume VM, drive a Windows desktop over SSH, or test a driver build in a disposable Linux sandbox.

Cua Driver always runs on the machine whose desktop it drives. To reach a VM or a remote computer, run the driver there and carry MCP over SSH. To test a driver build, use a disposable Linux sandbox.

macOS Lume VM#

On an Apple Silicon host with Lume and jq, pull the published Tahoe image (150 GB sparse disk; login and password lume):

IMAGE=macos-tahoe-cua:26.5.2
VM=cua-driver-dev-26.5.2
 
lume pull "$IMAGE" "$VM"
lume run "$VM"

The image has SIP disabled to match the maintainer test base; that grants no permissions, and Cua Driver works with SIP on too. In Terminal inside the VM display, install and grant consent:

/bin/bash -c "$(curl -fsSL https://cua.ai/driver/install.sh)"
/Users/lume/.local/bin/cua-driver permissions grant
/Users/lume/.local/bin/cua-driver list_apps '{}'
/Users/lume/.local/bin/cua-driver call get_desktop_state '{}' > /tmp/desktop.json

Approve three kinds of prompt: Accessibility and Screen Recording for CuaDriver, CuaDriver controlling System Events (from list_apps), and Tahoe's separate direct screen capture (from get_desktop_state). Rerun the last two commands; they must finish without prompts. Then confirm the daemon's grants:

/Users/lume/.local/bin/cua-driver permissions status --json

Expect accessibility: true, screen_recording: true, and source.attribution: "driver-daemon". unknown means no app-owned daemon answered: run cua-driver permissions grant, which starts it.

Run an agent inside the VM with the usual mcp-config, or from the host over SSH. Copy only your public key into the guest, then register the SSH command as a stdio server:

VM_IP="$(lume get "$VM" --format json | jq -r '.[0].ipAddress')"
cat ~/.ssh/id_ed25519.pub | ssh "lume@${VM_IP}" \
  'umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys'
 
codex mcp add cua-driver-vm -- \
  ssh -T -o BatchMode=yes "lume@${VM_IP}" /Users/lume/.local/bin/cua-driver mcp

The remote cua-driver mcp proxies to the daemon in the logged-in guest session, so screenshots and input keep the guest app's permission identity. Do initial consent and GUI checks in the VM display, not over SSH.

Save a reusable copy while stopped. Keep it local, with no tokens, private keys, or personal files inside:

lume stop "$VM"
lume clone "$VM" "${VM}-backup"
lume ls

A clone keeps its grants only while /Applications/CuaDriver.app keeps the release signature; an ad-hoc source build gets a new permission identity. Maintainers running the driver's macOS E2E suite in Lume follow libs/cua-driver/tests/runners/macos-lume/README.md in the repository.

Windows over SSH#

Windows OpenSSH runs in Session 0, which has no desktop, so window tools return empty results there. cua-driver doctor reports running in Session 0 (services). Run the daemon in your interactive session and let the SSH side proxy to it over the named pipe:

Session 1+ (RDP or console)   cua-driver serve (autostart task)
        ▲  \\.\pipe\cua-driver
Session 0 (SSH)               cua-driver call …  /  cua-driver mcp --socket \\.\pipe\cua-driver

1. From RDP or the local console, register and start the task:

cua-driver autostart enable
cua-driver autostart kick

query session must show your user as Active or Disc. If you have never signed in with RDP, do it once.

Sign in through SSH with the same Windows account that owns the interactive daemon. The named pipe is private to that account; another account can see the pipe but gets Access is denied. Run whoami /user in both sessions and compare the SIDs, which must match exactly.

2. From SSH, check the daemon and call tools:

cua-driver status
# Cua Driver daemon is running
#   socket: \\.\pipe\cua-driver
cua-driver call list_apps

If the endpoint exists but belongs to another Windows account, status reports it as not reachable and tells you to compare the two account SIDs.

3. Register the agent with an explicit socket:

claude mcp add --transport stdio cua-driver -- cua-driver.exe mcp --socket \\.\pipe\cua-driver

Bare cua-driver mcp owns its own runtime on Windows and fails in Session 0. Always pass --socket. If the interactive daemon is gone, MCP startup fails instead of acting from the wrong session.

Still empty? Check that whoami /user shows the same SID over SSH and on the desktop, that cua-driver --version matches over SSH, that cua-driver autostart status shows the task, and that cua-driver doctor from RDP reports session N has an attached interactive desktop.

Test a driver build in a sandbox#

A Cua sandbox gives a driver build a real Linux desktop (Xvfb, XFCE, AT-SPI) that you can throw away, on your machine or on a CI runner. Put your build into it with --overlay, then check that it is the one running. This is for testing Cua Driver itself. Inside a sandbox you do not install the driver to use it: the sandbox's guest service bundles its tools.

On your machine#

Build the driver for Linux, then create a sandbox with it:

cua sb create linux --name drv --wait desktop \
  --overlay cua-driver=./target/release/cua-driver

The overlay replaces /usr/local/bin/cua-driver in the guest, records its sha256 and stops any older copy still running. Check it:

cua doctor local:drv --no-host --only build --expect cua-driver=git:<commit sha>
cua sb exec local:drv cua-driver doctor --json

cua-driver doctor --json and the health_report tool report the running build under build:

  • git_sha: the commit it was built from;
  • exe_sha256: the executable that actually runs.

A process started before the file was replaced still reports its old hash.

Run your tests against the desktop, then delete the sandbox:

cua sb cp ./tests/smoke.sh drv:/tmp/smoke.sh
cua sb shell drv "set -a; . /run/cua-desktop/desktop.env; set +a; bash /tmp/smoke.sh"
cua sb rm local:drv --force

The driver inside cua-spacesd#

cua-spacesd links the driver core and serves its tools on /mcp. To test driver changes through spacesd, inject a cua-spacesd built from the same commit. The overlay restarts the daemon and waits until the new process runs it:

cua sb overlay drv cua-spacesd=./target/release/cua-spacesd
cua doctor local:drv --no-host --only build \
  --expect cua-spacesd=git:<commit sha> --expect cua-driver=git:<commit sha>

With both injected, cua-driver=git: must hold for the linked driver and the standalone binary.

In GitHub Actions#

      - uses: trycua/cua/.github/actions/cua-sandbox@main
        with:
          image: linux
          overlay: cua-driver=libs/cua-driver/rust/target/release/cua-driver
          expect: cua-driver=git:${{ github.sha }}
          guest-setup: apt-get update -qq && apt-get install -y -qq jq ffmpeg
          run: |
            cua sb cp scripts/ci/linux/preflight-rust-e2e.sh "$CUA_SANDBOX:/tmp/preflight.sh"
            cua sb shell "$CUA_SANDBOX" "set -a; . /run/cua-desktop/desktop.env; set +a;
              export CUA_E2E_SOURCE_SHA=${{ github.sha }} CUA_TEST_DRIVER_BIN=/usr/local/bin/cua-driver;
              bash /tmp/preflight.sh"

This is how the repository's own Linux driver preflight runs. The preflight fails with stale driver when the driver's embedded commit differs from CUA_E2E_SOURCE_SHA.

On the runner, the action puts a PATH first where cua-driver is the injected build, and cua-spacesd is a stub that fails. A copy installed on the runner is never picked up.

See Test in CI with Cua sandboxes for every input and output.