packages/cua-driver/docs/macos-background-input-v1-plan.md
Status: Proposed implementation plan
Base: origin/main at d21e3447f9b08c761c090946648d5aca5e6c9cf1
Date: 2026-08-04
Let an agent operate ordinary macOS applications in the background without moving the real pointer, changing the user's active application, or silently acting on another window owned by the same process.
V1 is deliberately conservative. It expands the routes for which Cua Driver can
prove an exact (pid, CGWindowID) destination and returns a structured refusal
for every other route. A refused action is preferable to a process-scoped key
event that appears successful after mutating a sibling window.
V1 is complete when all of the following are true:
(pid, CGWindowID) from target resolution through route selection, dispatch, and
post-action verification.confirmed only from evidence belonging to that exact
window or to an exact browser target bound to it. State from another
same-process window is never accepted as evidence.AXConfirm, an
exact AXPress, or an exact browser action when one exists.AXWindows is
observation-only. Cua Driver does not use another window's AX tree or send
process-scoped input while the target is unresolved.BrowserWindow mapping, bounded fresh-tree acquisition, delayed effect
verification, and explicit two-window safety coverage.ActionResult wire shape
remain compatible.V1 does not attempt to provide:
backgroundThrottling.The existing caller-selected delivery_mode: "foreground" remains a separate
last-resort behavior. This plan does not remove it, but the background router
must never enter it implicitly.
ax/window_scope.rs
already defines Matched, NotFound, OwnerPidMismatch, and AxUnresolved.
Its structural invariant is the right foundation: every non-Matched result
walks no elements.
ax/tree.rs unions
AXChildren and AXWindows, maps top-level AX windows to CG window IDs through
_AXUIElementGetWindow, and bounds the default walk at 2,000 nodes and depth
25. get_window_state.rs
preflights WindowServer ownership, empties the element cache when the requested
window does not resolve, and still returns an exact-window screenshot for the
AxUnresolved degradation.
These checks prevent an unresolved request from returning a sibling window's tree. Mutation needs to carry the same invariant farther than it does today.
window_id does not make PID keyboard delivery window-scopedwindow_target.rs
correctly promotes a unique PID-only target and refuses a PID with multiple
eligible top-level windows. Explicit window_id and element-token requests,
however, pass through that cardinality guard unchanged.
On macOS, keyboard.rs
posts through SLEventPostToPid with a public CGEvent::post_to_pid fallback.
Both transports are process-scoped. press_key.rs
best-effort focuses an element and then posts the key to the PID. Its pixel form
first invokes the click path to focus (x, y) and then posts the same
process-scoped key, so that combined pointer-plus-key branch needs one exact
route decision rather than bypassing keyboard safety after the focus click.
type_text.rs has a
second exactness gap: when no explicit element pointer is supplied,
read_axvalue(pid, None) reads focused_element_of_pid(pid). Its before/after
verification can therefore observe whichever same-process field is focused,
not necessarily a descendant of the requested window_id. Passing an explicit
window ID does not change that readback.
The required same-PID regression test follows directly from these source semantics: unresolved target window A, focused sibling window B, process-routed key, and PID-global focused-element readback. V1 must refuse before the key is posted and prove from fixture journals that neither window changed.
ax_actions.rs
supports AXPress, AXConfirm, selection, focus, and enabled-state checks.
set_value.rs already
does native AX value readback and correctly distrusts AXValue echo from an
AXWebArea.
mouse.rs has a routed
pointer primitive that stamps window-local location plus the target PID and
CGWindowID fields before SkyLight/PID posting. It does not move the real
pointer. The same file also contains private no-raise activation and
Chromium-specific event recipes. These are implementation candidates, not by
themselves proof that an action reached the requested window. V1 must gate them
on exact target facts and exact post-action evidence.
click.rs already prefers
AX, can perform window-local pointer fallback for specific selection controls,
and distinguishes verified AX selection from an unverified dispatch. The new
router should consolidate those decisions rather than add a second hidden
fallback ladder.
ax/bindings.rs writes
AXManualAccessibility and falls back to AXEnhancedUserInterface only when
the modern attribute is unsupported. The tree walker enables it once per PID,
waits 500 ms when accepted, and then walks the bounded tree.
The current cache is a set of PIDs. V1 should associate enablement with the observed process lifetime so PID reuse cannot inherit a stale "enabled" decision. Electron mutation must reacquire an exact bounded tree rather than using title order, the first AX window, or stale process-global focus.
browser/binding.rs
contains the pure native-window-to-CDP correlation. browser/engine.rs
checks native ownership, process fingerprint, endpoint ownership, CDP window
geometry/cardinality, tab identity, and generation again before every
mutation. browser/tools.rs
uses those exact capabilities for typed browser actions.
V1 should reuse this rung rather than creating a macOS-specific browser
shortcut. The bounded Electron fallback remains exact only when the endpoint
exposes one page and the owner has exactly one native window. Two Electron
BrowserWindows must not use that fallback; they need a proven CDP-window
mapping or a refusal. WKWebView mutation remains unsupported.
capture.rs currently obtains
a one-shot window image with screencapture -l <CGWindowID> -x -o. The
ScreenCaptureKit implementation is display-scoped recording, not a
window-scoped live stream. V1 keeps the current one-shot path and does not
claim frame freshness from API success.
list_windows.rs
describes minimized, hidden, and off-Space enumeration, but the current macOS
records do not reliably populate current_space_id, on_current_space, or
space_ids. V1 must therefore use exact AX-window presence as its input safety
gate instead of inventing a complete Spaces model.
outputs.rs defines the
closed ActionResult: effect, route, delivery, evidence, and escalation.
action_record.rs is the
functional core that conservatively projects native attempts to that public
shape. action-result-contract.md explicitly
excludes echoed selectors, coordinates, and request targets.
V1 keeps that public result shape. Exact target facts belong to the request, the internal execution record, a read-only capability report, and structured refusals. Any future proposal to add optional result fields must update the contract and every generated strict SDK together; it is not part of this work.
The canonical target key is the requested (pid, CGWindowID). It is carried
unchanged through planning, execution, and verification.
The following rules are non-negotiable:
_AXUIElementGetWindow is the requested CGWindowID.An explicit window_id is an address supplied by the caller, not proof that a
process-scoped actuator will honor it.
Add a small pure background-input decision module in
cua-driver-core. It owns no macOS objects and performs no I/O. Its inputs are
facts collected by a platform shell:
matched, not_found, owner_mismatch, unresolved);visible, occluded, offscreen, minimized, hidden, or
unknown where macOS cannot distinguish safely);The pure output is one route with prerequisites and a verification plan, or a typed refusal reason. Keep names internal, but model at least:
ExactWindowTarget(pid, window_id)
TargetResolution
BackgroundRoute
VerificationPlan
BackgroundInputDecision = Execute(...) | Refuse(...)
Use a pure truth table for route choice and result classification. Extend the
existing ActionExecutionRecord projection rather than adding macOS wire
semantics. Platform adapters translate a chosen internal route to the existing
public ActionRoute values.
The macOS imperative shell:
The core never says "try all routes." The shell cannot improvise a second actuator after a failed proof. A caller may take a newly reported route after fresh state, preserving the existing agent-owned action ladder.
The decision order is:
| Order | Route | Required exactness | V1 behavior |
|---|---|---|---|
| 1 | AX semantic action/value | Exact AX window and exact element ancestry | Prefer AXPress, AXConfirm, selection, or value mutation with target-bound readback. AX API acceptance alone is unverified. |
| 2 | Browser/CDP | Existing exact native-window, endpoint, CDP-window, tab, generation, and ref proof | Reuse typed browser tools. Do not guess or auto-convert an AX token into a DOM ref. |
| 3 | Window-local routed pointer | Exact owner, AX-window presence, validated pixel frame, stamped CGWindowID, supported target state, and exact postcondition | Never move the real pointer. Private SPI absence or failed revalidation refuses. |
| 4 | PID keyboard | Exact target element/window, no competing same-PID keyboard destination, and an exact verifier available before posting | Native single-window text insertion may qualify. Generic keys, multi-window apps, minimized/hidden windows, and unresolved off-Space windows refuse. |
| 5 | Refusal | No safe route | Return the exact reason and available alternatives without sending input. |
The ladder preserves action semantics. It must not silently replace a trusted browser click with a synthetic DOM event, infer a submit button from a label, or turn a desktop request into a browser request without an exact capability. Where request shapes cannot be translated exactly, the result advises the explicit tool the caller may use next.
Global HID is not in this table. Only an explicit foreground request may use the existing guarded foreground/HID path.
For a token/index action, revalidate the retained element's ancestry against the requested top-level AX window. A cached element under the right cache key is not sufficient after a lifecycle or Space transition.
Safe commit behavior is explicit:
AXConfirm on the exact field when available;AXPress on an explicitly addressed commit button;Return for minimized, hidden, unresolved,
or multi-window targets.Do not heuristically find the first default button or press another window's
button. Do not turn press_key(Return) into AXPress unless the request itself
identifies the semantic target.
Native AXValue equality can confirm a value operation on the exact retained
element. AX values under AXWebArea remain untrusted because the accessibility
shim can echo a write that the renderer did not consume. Electron/web content
requires browser/DOM readback or another exact application effect.
Electron support is part of v1, not a generic Chromium exception.
For each observed process lifetime:
AXManualAccessibility=true.AXEnhancedUserInterface=true only when the modern attribute
returns kAXErrorAttributeUnsupported; transient failures do not trigger a
second private write or a success claim.BrowserWindow map only from _AXUIElementGetWindow.
Window title, array order, focused-window status, and first-match selection
are not identity proofs.AXWindows before native
mutation. An absent target returns no elements and no input route.unverifiable or a proven delivery failure,
never a fabricated confirmation.The signed fixture launches once without --force-renderer-accessibility to
prove the driver's per-PID enablement works for a normal BrowserWindow, and
once with the existing fixture flag as a diagnostic control. It also covers
default backgroundThrottling and backgroundThrottling:false. The setting
may affect changing pixels, but it must not weaken exact-target input policy or
become a driver prerequisite.
For minimized or application-hidden targets:
This preserves foreground state by construction. A no-activate restore transaction can be investigated as a later opt-in mode, but it is not a v1 fallback.
Observation is allowed: list_windows, exact one-shot capture, and any honest
degradation metadata may still be returned.
For mutation, query a fresh application AXWindows and map every candidate via
_AXUIElementGetWindow:
off_space_or_ax_unresolved reason. Do not inspect or act on a sibling
window, even if it is on the current Space.on_current_space
without a real source.Explicit switch/restore, temporarily adding a window to the current Space, and sticky-window behavior require a later opt-in design with its own visible-state and cleanup contract. V1 adds no SkyLight Spaces manipulation.
The existing window-local pointer implementation may be selected only when:
Use the existing stamped local coordinate and CGWindowID fields. Treat private no-raise activation as part of the actuator, not as delivery proof. If it changes the user's frontmost application, pointer, or Space, mark the route failed, restore only state owned by the driver when that is safe, and disable the route for the remainder of the process session.
The v1 router does not use unverified pixel input for a mutation whose only
result is "event posted." It may remain available through current APIs as
unverifiable outside the new exact capability, but it must not be advertised
as exact background input.
PID-routed keyboard is a last background rung because macOS accepts a process, not a CGWindowID.
Before posting text, require all of:
AXWebArea echo surface.Post the event only after those facts pass, then poll the same retained target
or a freshly reacquired equivalent inside the exact requested tree. If any
precondition cannot be proven, refuse before input. Generic press_key and
hotkeys normally lack a safe target-specific postcondition and therefore
refuse in background exact mode unless a future action-specific verifier is
added.
The singleton rule is a conservative v1 proof, not a claim that
SLEventPostToPid is window-addressed. It prevents the known class of
same-process sibling delivery while semantic and browser routes cover most
ordinary controls.
Add an additive background_input section to the read-only exact-window state
output. It should be produced from the same pure decision facts and contain no
window titles, control values, or private symbol addresses. A representative
shape is:
{
"background_input": {
"exact_window": {
"status": "matched",
"pid": 123,
"window_id": 456,
"ax_window": "matched"
},
"routes": [
{"route": "accessibility", "status": "available"},
{
"route": "pid_keyboard",
"status": "refused",
"reason": "same_pid_sibling_windows"
}
],
"observation": {
"one_shot_capture": "available",
"frame_freshness": "unknown"
}
}
}
The capability route and status strings above are illustrative and are not new
values for the closed public ActionRoute enum.
The exact enums and generated types should be reviewed with the implementation, but the semantics are fixed:
available means route prerequisites are currently proven, not that a
future call is guaranteed to succeed;Keep successful action outputs on the current ActionResult contract:
route uses the existing enum, including accessibility, synthetic events,
system API, DOM, and trusted input for relevant background actions;delivery.mode reports what actually occurred;confirmed requires publishable exact-target readback;unverifiable means the route ran but exact effect is unknown;refused means no actuator ran; andStructured refusals should include code, effect: "refused", the requested
pid and window_id, the failed exactness fact, and the safe next route when
one exists. Initial codes should distinguish at least ownership mismatch,
AX-window unresolved/off-Space, same-PID keyboard ambiguity, unavailable
verification, and unavailable private route.
Do not report delivery_failed merely because the driver lacks evidence.
Use it only when exact negative evidence proves delivery did not occur. A
missing/unreadable postcondition is unverifiable with
effect_unconfirmed; an unchanged readable exact target after the bounded
delivery window may be suspected_noop or delivery failure according to the
existing action's semantics. API acceptance is never confirmation.
This is the required decision policy, not a claim that every route already has native evidence:
| Target state | Observe | AX semantic | Exact browser | Window-local pointer | PID keyboard |
|---|---|---|---|---|---|
| Frontmost | Current state/capture | Yes when exact | Yes when exact | Yes when exact and verified | Only with singleton exact element and verifier |
| Background visible | Current state/capture | Yes when exact | Yes when exact | Yes when exact and verified | Same conservative gate |
| Fully occluded | One-shot capture; freshness honest | Yes when exact | Yes when exact | Yes only after signed proof and exact verifier | Same conservative gate |
| Fully offscreen on current Space | One-shot capture when valid | Yes when exact | Yes when exact | Signed-evidence gated; no geometry guess | Refuse in v1 |
| Minimized | One-shot capture, freshness unknown | Yes when exact | Yes only if exact binding revalidates | Refuse | Refuse; semantic commit instead |
| Application hidden | One-shot capture, freshness unknown | Yes when exact | Yes only if exact binding revalidates | Refuse | Refuse; semantic commit instead |
| Other Space, exact AX window present | Observation allowed | Yes when exact | Yes when exact | Refuse in v1 | Refuse in v1 |
| Other Space/AX window absent | Observation only | Refuse | Refuse | Refuse | Refuse |
| Owner mismatch/stale window | Refuse stale target; report owner when safe | Refuse | Refuse | Refuse | Refuse |
These are route expectations to certify, not unsupported parity claims:
| Surface | V1 expectation | Evidence required |
|---|---|---|
| AppKit | Primary supported lane: exact AX actions/value, verified window-local pointer where semantic AX is unavailable, narrowly gated native text | Runtime AppKit fixture, including two windows under one PID |
| SwiftUI | Exact AX on the current Space; truthful refusal when an off-Space window disappears from AXWindows | Runtime SwiftUI fixture on current and another Space |
| Electron DOM | AX enablement and exact BrowserWindow tree; exact CDP route when bound; no multi-window PID keys | Runtime two-BrowserWindow fixture with per-window DOM journals |
| Mac Catalyst | Semantic AX or verified pointer only; raw keyboard remains gated | Source-supported candidate; runtime row required before advertising |
| Qt/Java | Use exposed exact AX semantics; refuse absent/custom AX and raw-key ambiguity | Source-based until dedicated signed fixtures exist |
| Browser canvas/WebGL | Exact browser action when a supported ref/route exists; otherwise no semantic claim | Source-based browser binding plus route-specific runtime tests |
| Games/Metal/custom event loops | No v1 background guarantee | Explicitly unsupported until an exact app-owned route exists |
| Secure/protected surfaces and secure input | No capture or input bypass | Refusal/permission tests; never weaken platform protection |
| System UI and out-of-process panels | Require actual WindowServer owner and exact AX window; never follow a PID silently | Existing owner-mismatch tests plus signed panel coverage |
backgroundThrottling:false may preserve Electron painting in cases where the
default renderer throttles. That is an application compatibility option, not a
Cua Driver route guarantee. Canvas/WebGL, trusted-event filters, and custom
event loops can still behave differently from ordinary DOM controls.
(pid, window_id) and requires a refusal
before dispatch; both journals and both field values must remain unchanged.Do not refactor routes until this failing behavioral shape and pure regression are both captured.
window_id" distinct from "exact delivery proven."ActionResult shape.AXWindows mapping.AxUnresolved input advice from unconditional pixel mutation to
observation-only/refusal when exact input cannot be proven.AXPress, AXConfirm, selection, and native value mutations
first.confirmed, unverifiable, suspected_noop, and proven delivery
failure through the functional core.press_key's existing pixel-focus-then-key form through the same pure
decision. A successful focus click cannot waive the exact AX-window,
singleton destination, target-bound verifier, or state gates for the key.ActionResult schemas compatible; regenerate/check SDK
artifacts only if the read-only output has a generated typed surface.Run these on every implementation pull request without a GUI or TCC grants:
ActionResult invariants and generated SDK parity; andThe native harness must use only repository fixtures and a fixture-owned occlusion sentinel. Each action records pre/post foreground PID, real pointer position, requested CGWindowID, AX window IDs, route, result, exact fixture journal entry, and cleanup state.
Required rows:
AXWindows.backgroundThrottling:false; prove exact AX window mapping, AXPress,
semantic commit, exact browser type/click, delayed readback, and raw-key
refusal for minimized/unresolved targets. Every effect must appear in the
addressed window's own fixture journal and not its sibling's.The other-Space setup belongs to the disposable runner image or a test-only harness. Product code must not create, join, or switch Spaces. The test records its starting Space and always restores it in a trap/finally path.
The repository's canonical signed/TCC acceptance remains
tests/runners/macos-lume/README.md
and run-all.sh. It provides the
logged-in Aqua session, certificate-backed CuaDriverLocal.app, normal TCC
grant flow, and exact-source checks.
After focused Namespace evidence passes, add the new fixture rows to the Lume entrypoint and run once on the final candidate SHA. Preserve its existing requirements:
install-local.sh --release --autostart --require-stable-signing;TCC.db;Namespace validation complements this gate; it does not replace the Lume golden-image contract.
The planning checkout was inspected on 2026-08-04:
.amp/plugins was absent;.amp/plugins was absent;~/.config/amp/plugins contained
namespace-mac-runners.ts and namespace-mac-runners.js;namespace_mac_runner.The user plugin is not versioned with this repository, so a later agent must
repeat discovery before use. Check project .amp/plugins, user
~/.config/amp/plugins, the active workspace plugin location, Amp plugin
documentation, and the loaded plugin source. If the tool is absent, record
those checks and make plugin discovery/setup step one; do not guess an API.
The discovered tool accepts:
action: list | spawn | destroy
runner_id: optional hostname-compatible ID for spawn
repository_url: optional HTTPS github.com URL for spawn
duration_minutes: optional integer from 5 through 720
instance_id: required for destroy
The discovered implementation defaults to macOS 26.x, arm64, 6 vCPU,
14,336 MB, and a 60-minute TTL. It requires nsc login. Spawn creates billable
infrastructure immediately without confirmation, clones into
$HOME/workspace/repository through Amp's GitHub credential helper, starts
amp --no-tui --runner-id "$AMP_RUNNER_ID" --remote-control-terminal in tmux,
and waits for Registered. Create threads. Failed provisioning attempts to
destroy the instance it created. Destroy accepts only plugin-managed instance
IDs and asks for confirmation. These details must be re-read from the installed
plugin because it can change independently of this plan.
Re-run the plugin discovery above and record the resolved source/config.
Call namespace_mac_runner with {"action":"list"}. Do not modify or
destroy unrelated runners.
Obtain explicit approval for the billable spawn. The approval for this plan PR does not authorize a later infrastructure charge.
Push the implementation candidate and record its full SHA. Spawn with a unique hostname-compatible ID and bounded TTL, for example:
{
"action": "spawn",
"runner_id": "cua-bg-input-<unique-suffix>",
"repository_url": "https://github.com/trycua/cua.git",
"duration_minutes": 180
}
Record the returned runner ID, Namespace instance ID, URL, resolved macOS selector, repository URL, and deadline. TTL is a backstop, not cleanup.
Call Amp's list_runners immediately before create_thread. Start a child
with executor: "runner" and that exact live runner ID; do not silently use
an orb. Instruct it to fetch and detach-checkout the exact candidate SHA,
because the plugin's shallow clone starts from repository default HEAD.
On the Namespace Mac, record sw_vers, OS build, architecture, SIP status,
Xcode/Rust/Node versions, exact git SHA, and a clean worktree before testing.
Use repository-supported CuaDriverLocal.app setup only. Follow
scripts/README.md, use an ephemeral
certificate-backed local signing identity, and install with
--require-stable-signing. Never copy a maintainer private key or signing
keychain to Namespace. Never edit TCC.db or bypass TCC.
Grant Accessibility and Screen Recording only through normal macOS UI if the
disposable Namespace environment and explicit test approval support it.
Record permissions status --json and the codesign -d -r- designated
requirement. The Namespace plugin provisions neither a signing identity nor
TCC. If stable signing, interactive UI, or either grant is unavailable,
mark the signed native lane blocked and run only pure/build tests; do not
claim native behavior passed.
Use only fixed-title repository fixtures and run-owned artifact paths. Capture the matrix evidence listed above, including exact fixture journal effects, requested and sibling window IDs, AX counts/mapping, result payloads, foreground PID, real pointer, Space posture, and action timing. Do not collect unrelated application state, window titles, or user data.
In a finally/trap path, stop fixture and driver processes, close fixture windows, restore fixture focus/pointer/Space state, remove run-owned temporary files, and write a cleanup assertion. Retrieve only sanitized evidence needed for review.
From the controlling thread, call
namespace_mac_runner with
{"action":"destroy","instance_id":"<recorded-instance-id>"} and confirm
destruction. Call {"action":"list"} again and require the instance to be
absent.
_AXUIElementGetWindow, AXManualAccessibility,
AXEnhancedUserInterface, SkyLight event posting, private event fields, and
no-raise activation may change between macOS releases. Every lookup must be
bounded and fail closed. Missing symbols remove a route; they never cause a
global or PID-only fallback.
V1 adds no private Spaces API. Space membership remains incomplete, and the policy deliberately refuses when AX cannot prove the exact target.
Accessibility and capture evidence is valid only for the process identity that
performed it. Ad-hoc rebuilds can invalidate grants. Native acceptance requires
the certificate-backed CuaDriverLocal.app flow and normal permission UI.
Neither CI mocks nor a successful build substitutes for this evidence.
AX API success may be a no-op, Chromium AXValue may be an echo, and custom renderers may reject synthetic input. Confirmation always comes from an independent exact-target effect. Unsupported applications receive narrower capabilities, not a misleading parity claim.
Windows can close, change owner, add a modal, or restart between observation and input. Per-PID serialization and immediate revalidation reduce the window; they do not make stale capabilities permanent. Any changed fact invalidates the decision and requires fresh state.
Request schemas and successful action results remain stable. Some calls that currently post best-effort PID keys will begin refusing when exact delivery is not provable. That is an intentional safety correction. Refusals include a recoverable semantic/browser/foreground alternative when one actually exists.
Roll out one route at a time after its fixture lane passes. The rollback is to disable the new route decision while retaining the same-PID/unresolved-target guard. Do not restore the unsafe process-scoped fallback to improve success rates.
Telemetry and retained artifacts may contain route, refusal reason, OS build, target PID/window ID, timing, and boolean invariants. They must not contain window titles, field values, typed text, screenshots of unrelated apps, browser URLs, or fixture logs outside the fixed test vocabulary.
window_id makes post_to_pid exact. The keyboard
transport has no window parameter.ActionResult. Callers already own the
request; a wire change would require coordinated strict-SDK migration without
improving route safety.Resolve these during Stage 0/1 review, before enabling a route:
background_input capability in every SDK without duplicating native
window state?