Skip to content

Commit fda495d

Browse files
authored
fix(desktop): make computer use act on the app the user sees (#9084)
* fix(desktop): make computer use act on the app the user sees - Hit-test coordinate input with Accessibility: Notification Center and the Dock keep full-screen click-through windows, so the window-list check rejected every click - Count an AX press that times out while a sheet or modal opens as dispatched - Keep the snapshot when nothing reached the app, in the helper and the service - Type into any focusable element (terminals, combo boxes), send keys to sheets and popovers, accept common key aliases - Complete menu presses that only highlight their item, observe open menus and submenus first, and show menu shortcuts in labels - Fit the model projection under Mothership's computer result budget * test(desktop): computer-use snapshots survive actions that sent nothing * fix(desktop): refuse unverifiable clicks, stop menu completion on cancel, scope press_key - Refuse coordinate input when Accessibility cannot say which app owns the point - Check cancellation before the menu-completion Return - press_key goes to the observed window or a dialog, panel or popover above it, never another document window - Only presses treat an Accessibility timeout as delivered; a value or selection change that times out fails as uncertain * fix(desktop): accept press_key without a window and screenshot shorthand on actions Mirrors the worker contract: press_key may omit windowId (the key goes to the focused observed window, or a dialog above it), and mutations accept includeScreenshot as shorthand for observeAfter.includeScreenshot. * fix(desktop): type the computer-use params omit over the whole input union * fix(desktop): confirm the click window and the highlighted menu item before input - A coordinate click must land in the observed window, or in a sheet, popover, panel or menu above it; another document window of the same app is refused - The menu-completion Return is sent only when the pressed item reports itself highlighted * fix(desktop): refuse input when the receiving window cannot be confirmed Clicks and keys go only to the observed window, or to one of the app's dialogs or floating panels above it. A missing window attribute is allowed only for menus, and an unreadable subrole is refused rather than treated as auxiliary. * fix(desktop): revalidate the editor's window before every keystroke type_text and input_sequence check, before each key, that the editor still sits in an observed window (or a dialog above it) at its observed frame. * fix(desktop): compare the observed window's identity, not only its bounds Each observed windowId keeps its Accessibility window, and click, key and editor checks require that exact window at its observed frame (or a dialog or floating panel), so a second document window with identical bounds is refused. * fix(desktop): refuse input to windows whose identity is ambiguous Window-list entries match their Accessibility windows only by bounds; when two windows share bounds neither gets an identity, so input aimed at either is refused instead of possibly reaching the other. * fix(desktop): read editor focus after activateFirst brings the app forward
1 parent ae6d45b commit fda495d

8 files changed

Lines changed: 258 additions & 56 deletions

File tree

‎apps/desktop/native/computer-use/ComputerUse.swift‎

Lines changed: 180 additions & 38 deletions
Large diffs are not rendered by default.

‎apps/desktop/native/computer-use/README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,19 @@
22

33
Independent native implementation using public Accessibility, Core Graphics and ScreenCaptureKit APIs. Requires macOS 14+. Package `ComputerUse.swift` as `Sim Computer Use.app/Contents/MacOS/SimComputerUse` with this directory's `Info.plist`; sign the nested app with the desktop distribution identity so TCC grants survive upgrades.
44

5-
Compile each architecture with `swiftc -parse-as-library -O -target arm64-apple-macosx14.0 ComputerUse.swift -o SimComputerUse` (repeat for `x86_64`, combine with `lipo`). No Apple Events, shell execution, private APIs, global event listeners or clipboard access is used. Coordinate mouse input posts public foreground events only after checking the active app, observed window geometry and topmost target point.
5+
Compile each architecture with `swiftc -parse-as-library -O -target arm64-apple-macosx14.0 ComputerUse.swift -o SimComputerUse` (repeat for `x86_64`, combine with `lipo`). No Apple Events, shell execution, private APIs, global event listeners or clipboard access is used. Coordinate mouse input posts public foreground events only after checking the active app, observed window geometry and that Accessibility hit-testing routes the point to the target app.
66

77
## Protocol
88

99
Persistent newline-delimited JSON over stdin/stdout: `{id,method,params}` returns `{id,result}` or `{id,error:{code,message,dispatchState?}}`. The producer contract is `worker/packages/contracts/src/computer-use.ts` in mothership, copied into the desktop bridge by contract sync. `request_permission` is a local onboarding method, never a model tool; params are `{permission:"accessibility"|"screenCapture"}`. Status/preflight never triggers prompts. Errors go through the protocol, and stdout contains no logs.
1010

11-
The helper observes up to 2000 accessibility nodes (depth 64, queue 4000, traversal deadline 8 seconds, each AX call timeout 250ms). Selected-window and focused-element ancestry are prioritized; menu traversal is deferred and capped at 80 nodes. Electron accessibility is enabled using its documented `AXManualAccessibility` attribute. The helper observes 100 windows and 1000 apps. Requests are bounded to 128 KiB during ingestion. Text/value input is capped at 32000 UTF-16 units. State references are per-process, per-app, expire after 60 seconds, and are consumed by every attempted action. Restarts invalidate all references. Screen captures exclude window shadows, preserve the selected window’s coordinate geometry, and are capped to 1600 pixels on the longest side and 8 MiB encoded. Complete JSON replies are capped at 16 MiB: oversized observations omit screenshots first, then truncate accessibility nodes, while preserving action outcomes. Screenshots are optional and failure is reported in `screenshotError` without discarding accessibility state. macOS 26 uses the screenshot-specific capture API; the macOS 14/15 API path includes only the selected window in a display filter and crops its exact frame. That older path requires the window to fit within one display and reports `capture_geometry_unavailable` otherwise.
11+
The helper observes up to 2000 accessibility nodes (depth 64, queue 4000, traversal deadline 8 seconds, each AX call timeout 250ms). Selected-window and focused-element ancestry are prioritized; menu traversal is deferred and capped at 80 nodes. Electron accessibility is enabled using its documented `AXManualAccessibility` attribute. The helper observes 100 windows and 1000 apps. Requests are bounded to 128 KiB during ingestion. Text/value input is capped at 32000 UTF-16 units. State references are per-process, per-app, expire after 60 seconds, and are consumed by every action that reaches the app; a request that fails before sending anything reports `dispatchState: "not_started"` and keeps them valid. Restarts invalidate all references. Screen captures exclude window shadows, preserve the selected window’s coordinate geometry, and are capped to 1600 pixels on the longest side and 8 MiB encoded. Complete JSON replies are capped at 16 MiB: oversized observations omit screenshots first, then truncate accessibility nodes, while preserving action outcomes. Screenshots are optional and failure is reported in `screenshotError` without discarding accessibility state. macOS 26 uses the screenshot-specific capture API; the macOS 14/15 API path includes only the selected window in a display filter and crops its exact frame. That older path requires the window to fit within one display and reports `capture_geometry_unavailable` otherwise.
1212

1313
Coordinates in action inputs are window-local points; node frames are global screen points. Window bounds must still match the observed snapshot. App PID and launch time must match. Secure fields and descendants are unavailable as action targets; secure values and labels are suppressed. Sim's own apps and System Settings are protected targets. `get_app_state` can open an installed bundle ID without activating it. `list_apps` discovers installed apps under `/Applications`, `/System/Applications`, and `~/Applications` (bounded to 5000 filesystem entries and depth 3), merging running regular apps; installed apps omit `pid`.
1414

15-
AXPress and AXSetValue are direct semantic operations. Keyboard events target the app PID. Cmd+A uses the focused editor's writable accessibility selection range where supported. Coordinate mouse input requires the target app in front; `activate_app` makes that focus change explicit and confirms it. Click/drag/scroll validate the foreground process, unchanged window geometry and topmost window at the point. Some apps ignore background keyboard events. `dispatched:true,verified:false` means only dispatch succeeded; the agent must observe again to establish the outcome. No foreground activation fallback is hidden inside the helper. Native key codes currently assume the US layout for shortcuts; literal text uses Unicode events. Unsupported shortcuts fail explicitly.
15+
AXPress and AXSetValue are direct semantic operations. Keyboard events target the app PID. Cmd+A uses the focused editor's writable accessibility selection range where supported. Coordinate mouse input requires the target app in front; `activate_app` makes that focus change explicit and confirms it. Click/drag/scroll validate the foreground process, unchanged window geometry and the app that receives the point; the topmost window-list entry is not the click target because Notification Center and the Dock keep full-screen click-through windows. A press that times out while the app opens a sheet or modal (`cannotComplete`) counts as dispatched. A menu-item press that leaves its menu open with the item highlighted (Calendar on macOS 26) is completed with Return. Open menus and their submenus are observed first, and menu item labels carry their shortcuts (for example ⇧⌘N). Some apps ignore background keyboard events. `dispatched:true,verified:false` means only dispatch succeeded; the agent must observe again to establish the outcome. No foreground activation fallback is hidden inside the helper. Native key codes currently assume the US layout for shortcuts; literal text uses Unicode events. Unsupported shortcuts fail explicitly.
1616

17-
`input_sequence` executes 1–32 key/text steps against one observed editable element. It validates every step before dispatch and checks the same editor, window, and secure-input ancestry between steps. Enter can continue into another step when the same editor and window retain focus. Actual focus loss, Tab, or navigation shortcuts end the sequence before later input. Explicit `activateFirst: true` activates the target app inside the batch before input; it never enables automatic refocusing between steps. Partial execution reports completed steps and an error; callers must observe before retrying. Every mutation is followed by fresh state under the same authorization and queue entry; `observeAfter` optionally requests a screenshot. Observation failure is reported separately so the mutation is never replayed. The model receives a compact, bounded projection of the native snapshot; omitted nodes are reported explicitly.
17+
`type_text` and `input_sequence` target any observed element that can take keyboard focus (text fields, terminals, combo boxes, web editors). `input_sequence` executes 1–32 key/text steps against one such element. It validates every step before dispatch and checks the same editor, window, and secure-input ancestry between steps. Enter can continue into another step when the same editor and window retain focus. Actual focus loss, Tab, or navigation shortcuts end the sequence before later input. Explicit `activateFirst: true` activates the target app inside the batch before input; it never enables automatic refocusing between steps. Partial execution reports completed steps and an error; callers must observe before retrying. Every mutation is followed by fresh state under the same authorization and queue entry; `observeAfter` optionally requests a screenshot. Observation failure is reported separately so the mutation is never replayed. The model receives a compact, bounded projection of the native snapshot; omitted nodes are reported explicitly.
1818

1919
## Cancellation
2020

‎apps/desktop/src/main/computer-use/service.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,28 @@ describe('native computer use authority and lifecycle', () => {
296296
await expect(click('s1', 'chat', 'repeat')).rejects.toThrow('stale')
297297
})
298298

299+
it('keeps a snapshot usable after an action that sent nothing to the app', async () => {
300+
const { state, native, click } = setup()
301+
await state()
302+
native.request.mockRejectedValueOnce(
303+
new ComputerUseError({
304+
code: 'window_occluded',
305+
message: 'Covered.',
306+
dispatchState: 'not_started',
307+
})
308+
)
309+
await expect(click('s1', 'chat', 'refused')).rejects.toThrow('Covered.')
310+
await expect(click('s1', 'chat', 'retry')).resolves.toMatchObject({ dispatched: true })
311+
})
312+
313+
it('consumes a snapshot after an action whose dispatch is unknown', async () => {
314+
const { state, native, click } = setup()
315+
await state()
316+
native.request.mockRejectedValueOnce(new Error('Computer Use helper exited.'))
317+
await expect(click('s1', 'chat', 'lost')).rejects.toThrow('exited')
318+
await expect(click('s1', 'chat', 'repeat')).rejects.toThrow('stale')
319+
})
320+
299321
it.each(['app', 'window'])('rejects a follow-up snapshot from a different %s', async (target) => {
300322
const { state, service, native, click } = setup()
301323
await state()

‎apps/desktop/src/main/computer-use/service.ts‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type {
2-
ComputerUseActivity,
3-
ComputerUseAppPermission,
4-
ComputerUseStatus,
1+
import {
2+
type ComputerUseActivity,
3+
type ComputerUseAppPermission,
4+
ComputerUseError,
5+
type ComputerUseStatus,
56
} from '@sim/desktop-bridge'
67
import {
78
type ComputerUseInput,
@@ -234,9 +235,32 @@ export class ComputerUseService {
234235
? (owner?.elementWindows.get(input.elementId) ?? owner?.windowId)
235236
: undefined
236237
if ('snapshotId' in input) this.snapshots.delete(input.snapshotId)
237-
const params =
238-
'observeAfter' in input ? omit(input, ['action', 'observeAfter']) : omit(input, ['action'])
239-
const result = await this.deps.native.request(input.action, params)
238+
/** Mutations accept includeScreenshot as shorthand for observeAfter.includeScreenshot. */
239+
const isMutation = input.action !== 'get_app_state'
240+
const observeAfter = {
241+
...('observeAfter' in input ? input.observeAfter : {}),
242+
...(isMutation && 'includeScreenshot' in input && input.includeScreenshot !== undefined
243+
? { includeScreenshot: input.includeScreenshot }
244+
: {}),
245+
}
246+
const fields: Record<string, unknown> = input
247+
const params = isMutation
248+
? omit(fields, ['action', 'observeAfter', 'includeScreenshot'])
249+
: omit(fields, ['action'])
250+
let result: ComputerUseResult
251+
try {
252+
result = await this.deps.native.request(input.action, params)
253+
} catch (error) {
254+
/** The helper keeps a snapshot nothing was sent through, so its ownership stays too. */
255+
if (
256+
owner &&
257+
'snapshotId' in input &&
258+
error instanceof ComputerUseError &&
259+
error.details.dispatchState === 'not_started'
260+
)
261+
this.snapshots.set(input.snapshotId, owner)
262+
throw error
263+
}
240264
this.check(work)
241265
if (result.kind === 'state') this.rememberSnapshot(work, result, windowId)
242266
if (result.kind === 'action') {
@@ -250,7 +274,7 @@ export class ComputerUseService {
250274
const observation = await this.deps.native.request('get_app_state', {
251275
bundleId: input.bundleId,
252276
...(windowId ? { windowId } : {}),
253-
...('observeAfter' in input ? input.observeAfter : {}),
277+
...observeAfter,
254278
})
255279
this.check(work)
256280
if (observation.kind !== 'state')

‎apps/sim/lib/mothership/generated/computer-use.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const ObserveAfter = {
2525
.describe(
2626
"Every mutation returns fresh app state by default. Set includeScreenshot to true to include an image; otherwise no screenshot is captured. Observation failure never retries the action.",
2727
),
28+
includeScreenshot: z.boolean().optional().describe("Same as observeAfter.includeScreenshot."),
2829
};
2930
const InputSequenceStep = z.discriminatedUnion("action", [
3031
z.strictObject({ action: z.literal("type_text"), text: z.string().max(32_000) }),
@@ -75,7 +76,11 @@ export const ComputerUseSchema = z.union([
7576
z.strictObject({
7677
action: z.literal("press_key"),
7778
...ObserveAfter,
78-
...WindowTarget,
79+
bundleId: BundleId,
80+
snapshotId: SnapshotId,
81+
windowId: WindowId.optional().describe(
82+
"Window reference from that snapshot. Omit to send the key to the app's focused window.",
83+
),
7984
key: z.string().min(1).max(128).describe("One key or chord, such as Enter, Tab, Escape, or Cmd+A."),
8085
}),
8186
z.strictObject({ action: z.literal("scroll"), ...ElementTarget, ...ObserveAfter, ...ScrollOffsets }),

‎apps/sim/lib/mothership/tools/client/computer-tool-result.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ describe('native computer model projection', () => {
117117
const result = computerToolResultForModel(nested ? sequence(state) : state)
118118
const textResult = { ...result, observations: undefined }
119119
const serialized = JSON.stringify(textResult)
120-
expect(new TextEncoder().encode(serialized).length).toBeLessThan(44 * 1024)
120+
expect(new TextEncoder().encode(serialized).length).toBeLessThan(22 * 1024)
121121
expect(serialized).toContain('editor-late AXTextArea parent=e782')
122122
expect(serialized).toContain('e782 AXStaticText parent=e0')
123123
expect(serialized).toContain('send-late AXButton parent=e782')
@@ -193,7 +193,7 @@ describe('native computer model projection', () => {
193193
expect(ids.size).toBeLessThan(state.nodes.length)
194194
for (const match of tree.matchAll(/\bparent=(\S+)/g)) expect(ids.has(match[1])).toBe(true)
195195
expect(Buffer.byteLength(JSON.stringify({ ...result, observations: undefined }))).toBeLessThan(
196-
44 * 1024
196+
22 * 1024
197197
)
198198
expect(result).toMatchObject({ dispatched: true, sequence: { completedSteps: 1 } })
199199
})

‎apps/sim/lib/mothership/tools/client/computer-tool-result.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { truncateAtCodePoint } from '@sim/utils/string'
33

44
// Keep encoded pixels below the 10 MiB completion body cap, with room for state and its envelope.
55
const MAX_SCREENSHOT_BASE64_BYTES = 9 * 1024 * 1024
6-
const MAX_STATE_BYTES = 44 * 1024
6+
// Mothership stores a computer result over 24,000 characters and shows the model a preview cut
7+
// through the middle of the tree; staying under it keeps the prioritized rows the model needs.
8+
const MAX_STATE_BYTES = 22 * 1024
79
const encoder = new TextEncoder()
810

911
/** Screenshots are image observations; a bounded readable tree keeps actionable references inline. */
@@ -105,8 +107,10 @@ function snapshotForModel(result: ComputerUseSnapshot, envelopeBytes = 0) {
105107
const primaryAction = node.actions.some(
106108
(action) => action === 'AXPress' || action === 'AXConfirm' || action === 'AXPick'
107109
)
110+
/** Items of an open menu have an on-screen frame; closed menus report none. */
111+
const openMenuItem = node.role === 'AXMenuItem' && (node.height ?? 0) > 0
108112
return (
109-
(node.focused
113+
(node.focused || openMenuItem
110114
? 100
111115
: node.editable
112116
? 80

‎packages/desktop-bridge/src/computer-use.generated.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const ObserveAfter = {
2525
.describe(
2626
"Every mutation returns fresh app state by default. Set includeScreenshot to true to include an image; otherwise no screenshot is captured. Observation failure never retries the action.",
2727
),
28+
includeScreenshot: z.boolean().optional().describe("Same as observeAfter.includeScreenshot."),
2829
};
2930
const InputSequenceStep = z.discriminatedUnion("action", [
3031
z.strictObject({ action: z.literal("type_text"), text: z.string().max(32_000) }),
@@ -75,7 +76,11 @@ export const ComputerUseSchema = z.union([
7576
z.strictObject({
7677
action: z.literal("press_key"),
7778
...ObserveAfter,
78-
...WindowTarget,
79+
bundleId: BundleId,
80+
snapshotId: SnapshotId,
81+
windowId: WindowId.optional().describe(
82+
"Window reference from that snapshot. Omit to send the key to the app's focused window.",
83+
),
7984
key: z.string().min(1).max(128).describe("One key or chord, such as Enter, Tab, Escape, or Cmd+A."),
8085
}),
8186
z.strictObject({ action: z.literal("scroll"), ...ElementTarget, ...ObserveAfter, ...ScrollOffsets }),

0 commit comments

Comments
 (0)