Skip to content

Commit d01ebf8

Browse files
committed
Merge remote-tracking branch 'origin/staging' into feat/desktop-windows-v2
2 parents bccb75d + b97449a commit d01ebf8

36 files changed

Lines changed: 1261 additions & 191 deletions

‎apps/desktop/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,8 @@ group with the **Computer Use** restriction. Older desktop builds omit this opti
267267
continue to work.
268268

269269
The user grants Accessibility and Screen Recording through macOS; every app is then available except
270-
Sim, System Settings, password managers and terminals (shell access has its own Terminal control),
270+
Sim, System Settings, password managers, terminals and scripting apps (shell access has its own
271+
Terminal control),
271272
which the helper refuses. With **Ask before using a new
272273
app** on, each app's first use is approved for the task or persistently, and persistent approvals are
273274
listed in Settings and can be revoked. Sign-out and deployment changes clear opt-in, the ask choice

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,15 +142,18 @@ func elementRect(_ element: AXUIElement) -> CGRect? {
142142
AXValueGetValue(unsafeBitCast(rawSize, to: AXValue.self), .cgSize, &size) else { return nil }
143143
return CGRect(origin: position, size: size)
144144
}
145-
/// Apps computer use never operates, lowercased: macOS security settings, password managers, and
146-
/// terminals, whose shell access has its own Terminal control.
145+
/// Apps computer use never operates, lowercased: macOS security settings, password managers, and apps
146+
/// whose primary purpose is running shell commands or scripts (terminals, Script Editor, Automator,
147+
/// Shortcuts), since shell access goes through the Terminal tool's own opt-in. Apps that merely embed a
148+
/// terminal, such as editors, stay usable.
147149
let PROTECTED_APPS: Set<String> = [
148150
"com.apple.systempreferences", "com.apple.keychainaccess", "com.apple.passwords",
149151
"com.1password.1password", "com.agilebits.onepassword7", "com.agilebits.onepassword-osx", "com.bitwarden.desktop",
150152
"org.keepassxc.keepassxc", "com.lastpass.lastpass", "in.sinew.enpass-desktop", "me.proton.pass.electron",
151153
"com.markmcguill.strongbox", "com.markmcguill.strongbox.mac",
152154
"com.apple.terminal", "com.googlecode.iterm2", "dev.warp.warp-stable", "com.mitchellh.ghostty",
153155
"net.kovidgoyal.kitty", "org.alacritty", "com.github.wez.wezterm", "co.zeit.hyper", "org.tabby",
156+
"com.apple.scripteditor2", "com.apple.automator", "com.apple.shortcuts",
154157
]
155158
/// Whether computer use refuses an app: Sim's own apps and `PROTECTED_APPS`. Bundle identifiers match
156159
/// case-insensitively in Launch Services.
@@ -161,7 +164,7 @@ func isProtectedApp(_ bundle: String) -> Bool {
161164
func appFor(_ bundleId: String?, launch: Bool = false) async throws -> NSRunningApplication {
162165
let bundle = try required(bundleId, "bundleId")
163166
guard !isProtectedApp(bundle) else {
164-
throw ComputerError("protected_app", "Computer use does not operate Sim, System Settings, password managers, or terminals; use the terminal tool for shell work.")
167+
throw ComputerError("protected_app", "Computer use does not operate Sim, System Settings, password managers, terminals, or scripting apps; use the terminal tool for shell work.")
165168
}
166169
var apps = NSRunningApplication.runningApplications(withBundleIdentifier: bundle).filter { !$0.isTerminated }
167170
if apps.isEmpty && launch {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Persistent newline-delimited JSON over stdin/stdout: `{id,method,params}` return
1010

1111
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. The menu bar and menus are read in place, so an open menu's items and their submenus come first; items of closed menus are read last, capped at 80 menu 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. An Accessibility failure counts as sent unless Accessibility rejected the request before the app saw it (unsupported attribute or action, illegal argument). 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

13-
Coordinates in action inputs are window-local points; node frames are global screen points. Coordinate input without a `windowId` targets the window the snapshot selected, and an element target uses its own window; `press_key` with an `elementId` focuses that element first. 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, System Settings, password managers and terminals are protected targets (`PROTECTED_APPS`, matched case-insensitively); shell work belongs to the separate Terminal control. `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`.
13+
Coordinates in action inputs are window-local points; node frames are global screen points. Coordinate input without a `windowId` targets the window the snapshot selected, and an element target uses its own window; `press_key` with an `elementId` focuses that element first. 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, System Settings, password managers, terminals and scripting apps (Script Editor, Automator, Shortcuts) are protected targets (`PROTECTED_APPS`, matched case-insensitively); shell work belongs to the separate Terminal control. `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

1515
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 hit on a window's own background counts as that window. Background tabs and minimized windows share bounds with the window in front, so they take no coordinate input. An action that opens UI (AXPress, AXPick, AXShowMenu) and times out while the app shows a sheet, modal or menu (`cannotComplete`) counts as dispatched; any other action that times out fails as uncertain. A menu-item press that leaves the item's own menu open with the app highlighting that item (Calendar on macOS 26) is completed with Return. Sheets, including Go to Folder and out-of-process open and save panels whose contents report no window, count as the window they are attached to. 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

‎apps/desktop/native/computer-use/tests/NativeTests.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import AppKit
2626
expectError("unsupported_key") { _ = try parseKey("F99") }
2727
expectError("invalid_arguments") { _ = try parseKey("Hyper+A") }
2828
expectError("invalid_arguments") { _ = try required(Optional<String>.none, "example") }
29-
for bundle in ["com.apple.systempreferences", "com.apple.Terminal", "COM.GOOGLECODE.ITERM2", "net.kovidgoyal.kitty", "com.1password.1password", "com.apple.Passwords", "com.bitwarden.desktop", "ai.sim.desktop", "com.simstudio.computer-use"] {
29+
for bundle in ["com.apple.systempreferences", "com.apple.Terminal", "COM.GOOGLECODE.ITERM2", "net.kovidgoyal.kitty", "com.1password.1password", "com.apple.Passwords", "com.bitwarden.desktop", "ai.sim.desktop", "com.simstudio.computer-use", "com.apple.ScriptEditor2", "com.apple.Automator", "com.apple.shortcuts"] {
3030
precondition(isProtectedApp(bundle), bundle)
3131
}
3232
for bundle in ["com.apple.TextEdit", "com.apple.finder", "com.apple.Safari"] { precondition(!isProtectedApp(bundle), bundle) }

‎apps/desktop/src/main/browser-agent/driver.test.ts‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1894,6 +1894,7 @@ describe('credential protection', () => {
18941894
activeElementSecrecy: 'safe',
18951895
readActiveElementState: {},
18961896
readPageActionState: {},
1897+
readDomQuiet: { quietMs: 1_000, loading: false },
18971898
})
18981899
const pageCall = vi.mocked(contents.executeJavaScript).getMockImplementation()
18991900
let releaseObservation: (value: unknown) => void = () => {}
@@ -3212,6 +3213,113 @@ describe('credential protection', () => {
32123213
})
32133214
})
32143215

3216+
it.each([
3217+
['still rendering', [0, 40, 90, 200], 4],
3218+
['already quiet', [400], 1],
3219+
['never settling', Array.from({ length: 200 }, () => 0), undefined],
3220+
] as const)(
3221+
'observes after an action only once a page %s has gone quiet, within the settle cap',
3222+
async (_label, quietReadings, expectedProbes) => {
3223+
const contents = await openPage()
3224+
const calls: string[] = []
3225+
let probe = 0
3226+
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
3227+
if (isPageCall(expression, 'readDomQuiet')) {
3228+
calls.push('probe')
3229+
const quietMs = quietReadings[Math.min(probe++, quietReadings.length - 1)]
3230+
return Promise.resolve({ quietMs, loading: false })
3231+
}
3232+
if (isPageCall(expression, 'collectSnapshot')) {
3233+
calls.push('snapshot')
3234+
return Promise.resolve({
3235+
url: 'https://example.com/',
3236+
title: 'Example',
3237+
outline: '- heading "Results"',
3238+
truncated: false,
3239+
refIds: [],
3240+
refLineIndexes: {},
3241+
nextElementId: 1_000,
3242+
})
3243+
}
3244+
if (isPageCall(expression, 'clickElement')) return Promise.resolve(CLICK_TARGET)
3245+
return Promise.resolve(undefined)
3246+
})
3247+
vi.useFakeTimers()
3248+
try {
3249+
const clicking = driver.executeTool('chat-test', 'browser_click', {
3250+
elementId: 0,
3251+
observe: {},
3252+
})
3253+
await vi.advanceTimersByTimeAsync(5_000)
3254+
3255+
expect(await clicking).toMatchObject({ ok: true, result: { observation: { ok: true } } })
3256+
const probesBeforeSnapshot = calls.indexOf('snapshot')
3257+
expect(probesBeforeSnapshot).toBeGreaterThan(0)
3258+
if (expectedProbes !== undefined) expect(probesBeforeSnapshot).toBe(expectedProbes)
3259+
} finally {
3260+
vi.useRealTimers()
3261+
}
3262+
}
3263+
)
3264+
3265+
it('keeps settling through failed quiet probes, and never observes once cancelled', async () => {
3266+
const contents = await openPage()
3267+
const calls: string[] = []
3268+
let failing = 2
3269+
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
3270+
if (isPageCall(expression, 'readDomQuiet')) {
3271+
calls.push('probe')
3272+
if (failing > 0) {
3273+
failing--
3274+
vi.mocked(contents.isLoading).mockReturnValue(true)
3275+
return Promise.reject(new Error('Execution context was destroyed'))
3276+
}
3277+
vi.mocked(contents.isLoading).mockReturnValue(false)
3278+
return Promise.resolve({ quietMs: failing === -1 ? 0 : 400, loading: false })
3279+
}
3280+
if (isPageCall(expression, 'collectSnapshot')) {
3281+
calls.push('snapshot')
3282+
return Promise.resolve({
3283+
url: 'https://example.com/',
3284+
title: 'Example',
3285+
outline: '- heading "Results"',
3286+
truncated: false,
3287+
refIds: [],
3288+
refLineIndexes: {},
3289+
nextElementId: 1_000,
3290+
})
3291+
}
3292+
if (isPageCall(expression, 'clickElement')) return Promise.resolve(CLICK_TARGET)
3293+
return Promise.resolve(undefined)
3294+
})
3295+
vi.useFakeTimers()
3296+
try {
3297+
const settled = driver.executeTool('chat-test', 'browser_click', {
3298+
elementId: 0,
3299+
observe: {},
3300+
})
3301+
await vi.advanceTimersByTimeAsync(5_000)
3302+
expect(await settled).toMatchObject({ ok: true, result: { observation: { ok: true } } })
3303+
expect(calls.indexOf('snapshot')).toBe(3)
3304+
3305+
calls.length = 0
3306+
failing = -1
3307+
const cancelled = driver.executeTool(
3308+
'chat-test',
3309+
'browser_click',
3310+
{ elementId: 0, observe: { query: 'Results' } },
3311+
'click-cancelled'
3312+
)
3313+
await vi.advanceTimersByTimeAsync(300)
3314+
expect(driver.cancelTool('chat-test', 'click-cancelled')).toBe(true)
3315+
await vi.advanceTimersByTimeAsync(5_000)
3316+
await cancelled
3317+
expect(calls).not.toContain('snapshot')
3318+
} finally {
3319+
vi.useRealTimers()
3320+
}
3321+
})
3322+
32153323
it('acts on a ref an earlier snapshot of the same page issued', async () => {
32163324
const contents = await openPage()
32173325
respondWith(contents, {

‎apps/desktop/src/main/browser-agent/driver.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {
7070
readActiveElementState,
7171
readCheckableElementState,
7272
readChildFrameElementState,
73+
readDomQuiet,
7374
readFormFieldState,
7475
readPageActionState,
7576
readPageText,
@@ -2065,6 +2066,9 @@ async function prepareTypingSurface(
20652066
const TRANSIENT_PROBE_ERRORS = new Set(['stale', 'not-visible', 'not-editable'])
20662067
const SETTLE_GRACE_MS = 1_000
20672068
const SETTLE_PROBE_INTERVAL_MS = 250
2069+
/** Structural quiet that counts as settled, and the most an observation waits for it. */
2070+
const DOM_QUIET_MS = 150
2071+
const DOM_SETTLE_MAX_MS = 2_000
20682072

20692073
/**
20702074
* First-touch page probe with a short settle grace. A live view re-rendering
@@ -2099,6 +2103,33 @@ async function execInPageWithSettleGrace<Args extends unknown[], Result>(
20992103
}
21002104
}
21012105

2106+
/**
2107+
* Lets a page finish rendering what an action started (a route change, a fetched list) before it
2108+
* is observed, so the observation is not a half-rendered page. A page already quiet costs one probe.
2109+
*/
2110+
async function waitForDomQuiet(
2111+
target: PageExecutionTarget,
2112+
signal: AbortSignal,
2113+
executionDeadline?: number
2114+
) {
2115+
const until = Math.min(
2116+
Date.now() + DOM_SETTLE_MAX_MS,
2117+
executionDeadline ?? Number.POSITIVE_INFINITY
2118+
)
2119+
while (!signal.aborted && Date.now() + 25 < until) {
2120+
const probe = await execInPage(target, readDomQuiet, [], false, executionDeadline).catch(
2121+
() => null
2122+
)
2123+
// A failed probe while the tab loads is a document swapping mid-navigation, so keep waiting;
2124+
// one that fails on a loaded tab (a page that cannot be scripted) has nothing to settle.
2125+
if (probe === null && !('isLoading' in target && target.isLoading())) return
2126+
const state = toRecord(probe)
2127+
const quietMs = typeof state.quietMs === 'number' ? state.quietMs : 0
2128+
if (quietMs >= DOM_QUIET_MS && state.loading === false) return
2129+
await interruptibleSleep(Math.min(100, Math.max(25, DOM_QUIET_MS - quietMs)), signal)
2130+
}
2131+
}
2132+
21022133
function pointFromPrepared(prepared: Record<string, unknown>): { x: number; y: number } {
21032134
return { x: prepared.x as number, y: prepared.y as number }
21042135
}
@@ -5463,15 +5494,27 @@ export async function executeTool(
54635494
}
54645495
return result
54655496
},
5466-
(query) =>
5467-
executeToolInner(
5497+
async (query) => {
5498+
await waitForDomQuiet(
5499+
session.requireAutomationTab().view.webContents,
5500+
executionController.signal,
5501+
executionDeadline
5502+
)
5503+
assertCurrentExecution()
5504+
if (executionController.signal.aborted) {
5505+
throw new ToolError(
5506+
'This browser action was cancelled before it could observe the page.'
5507+
)
5508+
}
5509+
return executeToolInner(
54685510
query === undefined ? 'browser_snapshot' : 'browser_find',
54695511
query === undefined ? {} : { query },
54705512
assertCurrentExecution,
54715513
executionDeadline,
54725514
invocationEpoch,
54735515
executionController.signal
5474-
),
5516+
)
5517+
},
54755518
assertCurrentExecution
54765519
)
54775520
const cancellableExecution = Promise.race([execution, cancellation])

‎apps/desktop/src/main/browser-agent/page-functions.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
readActiveElementState,
1818
readCheckableElementState,
1919
readChildFrameElementState,
20+
readDomQuiet,
2021
readPageActionState,
2122
readPageText,
2223
readSelectElementState,
@@ -526,6 +527,28 @@ describe('collectSnapshot', () => {
526527
expect(outline).not.toContain('button "x" [ref=999]')
527528
})
528529

530+
it('counts changes inside a shadow root the snapshot walked as page activity', async () => {
531+
vi.useFakeTimers({ toFake: ['performance'] })
532+
try {
533+
document.body.innerHTML = '<div id="host"></div>'
534+
const host = visible(document.querySelector('#host') as HTMLDivElement)
535+
const shadow = host.attachShadow({ mode: 'open' })
536+
shadow.innerHTML = '<button>Load</button>'
537+
visible(shadow.querySelector('button') as HTMLButtonElement)
538+
collectSnapshot()
539+
await Promise.resolve()
540+
vi.advanceTimersByTime(500)
541+
expect((readDomQuiet() as { quietMs: number }).quietMs).toBeGreaterThanOrEqual(500)
542+
543+
shadow.append(document.createElement('ul'))
544+
await Promise.resolve()
545+
546+
expect((readDomQuiet() as { quietMs: number }).quietMs).toBeLessThan(500)
547+
} finally {
548+
vi.useRealTimers()
549+
}
550+
})
551+
529552
it('marks elements that appeared since the previous snapshot as new', () => {
530553
document.body.innerHTML = '<button>Compose</button>'
531554
visible(document.querySelector('button') as HTMLButtonElement)

0 commit comments

Comments
 (0)