Skip to content

Commit 3872566

Browse files
committed
feat(desktop): add gated macOS computer use
1 parent d70565c commit 3872566

65 files changed

Lines changed: 4031 additions & 44 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/desktop/README.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,56 @@ Raw local file bytes are never exposed through the preload bridge and cannot be
218218
## Electron upgrades
219219

220220
Cadence: Electron ships a major every ~8 weeks and supports the latest 3 — budget a bump every ~4–6 months and adopt security patches within ~2 weeks. Follow `docs/electron-upgrade-checklist.md`; the `desktop-e2e.yml` canary leg (electron@latest) is the early-warning signal.
221+
222+
223+
## Native Mac computer use
224+
225+
Mothership's `computer` tool is available on macOS 14+ when the global runtime flag
226+
`mothership-computer-use` is enabled and the user opts in under **Settings → Desktop → Computer Use**.
227+
The flag defaults off; local/self-hosted deployments use `MSHIP_COMPUTER_USE=true`, while hosted
228+
rollout uses the existing AppConfig feature-flags document. Older desktop builds omit this optional
229+
bridge and continue to work.
230+
231+
The user grants Accessibility and Screen Recording through macOS, then approves each app for the
232+
current task or persistently. Persistent app approvals are listed in Settings and can be revoked.
233+
Sign-out and deployment changes clear computer-use approval and opt-in. The conversation shows the
234+
active app/action and a Stop button; **⌘⇧Esc** also stops active work when the shortcut is available.
235+
Stop cancels queued actions, in-flight authorization, approval prompts and the native helper.
236+
Already completed input cannot be undone.
237+
238+
The tool lists installed/running apps, reads window accessibility trees, captures selected-window
239+
screenshots, activates apps explicitly, and performs clicks, text input, keyboard shortcuts,
240+
scrolling, dragging, value changes and accessibility actions. Semantic actions can run in the
241+
background. Coordinate mouse actions require explicit foreground activation and a fresh observation;
242+
they validate the target app/window before dispatch. Each mutation consumes its snapshot and the
243+
model observes again to verify results. Secure accessibility values are suppressed. See
244+
[`native/computer-use/README.md`](native/computer-use/README.md) for native bounds and compatibility
245+
limits; screenshots can still contain sensitive content visibly rendered by the approved app.
246+
247+
Every execution is authorized and claimed once by `/api/desktop/computer/authorize` using the
248+
stored tool arguments, current chat access, run/Stop state and current flag. Renderer-supplied
249+
arguments are not execution authority. The main process serializes native work and binds snapshots
250+
to the authorized chat. The Swift helper communicates over private stdio and is packaged outside
251+
ASAR as `Contents/Resources/Sim Computer Use.app`.
252+
253+
Distribution signing must cover the nested helper with the same stable Developer ID identity.
254+
Development ad-hoc builds can lose macOS grants when rebuilt. macOS can attribute permission
255+
requests to the responsible parent app (Sim, Electron, or the terminal/IDE that launched a test),
256+
so confirm the name in the system permission dialog. Test a Finder/LaunchServices-launched signed
257+
build for release acceptance; terminal-launched helper tests do not establish that app's TCC grants.
258+
259+
Validation includes desktop lifecycle/transport unit tests, the Electron authorization/Stop test
260+
in `e2e/computer-use.spec.ts`, the native protocol and live fixture tests, Sim authorization/stream
261+
regressions, and Mothership's opt-in `apps/server/scripts/computer-use-live.ts` real-model trial.
262+
The live trial uses only the disposable fixture and verifies mutations by fresh state; visual mode
263+
also asks the model to identify a drawn shape unavailable in accessibility text.
264+
265+
266+
September 24 acceptance: 1,909 desktop regression tests, the Electron canonical-authorization/
267+
replay/Stop test, native pure/protocol tests, and the live fixture GUI suite passed. A real native
268+
Calculator background test verified `2 + 3 = 5` without changing the foreground app. The signed
269+
arm64 test bundle and nested universal helper passed strict code-signature verification. Its
270+
LaunchServices-launched acceptance remained blocked on that app identity's Accessibility and
271+
Screen Recording grants; the separately granted development helper's tests do not satisfy this
272+
last release-onboarding check. The disposable packaged test follows the existing packaged-smoke
273+
suite's mock-keychain convention; native TCC permissions remain real.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { createServer, type Server } from 'node:http'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import { type ElectronApplication, _electron as electron, expect, test } from '@playwright/test'
7+
import type { SimDesktopApi } from '@sim/desktop-bridge'
8+
9+
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
10+
11+
test('computer use reaches the actual helper only with canonical one-shot authorization', async () => {
12+
test.skip(process.platform !== 'darwin', 'The native helper is macOS-only.')
13+
const root = mkdtempSync(join(tmpdir(), 'sim-computer-use-e2e-'))
14+
let server: Server | undefined
15+
let app: ElectronApplication | undefined
16+
let claimed = false
17+
let authorizationCount = 0
18+
let releaseAuthorization: () => void = () => {}
19+
let markAuthorizationStarted: () => void = () => {}
20+
const authorizationStarted = new Promise<void>((resolve) => {
21+
markAuthorizationStarted = resolve
22+
})
23+
const authorizationReleased = new Promise<void>((resolve) => {
24+
releaseAuthorization = resolve
25+
})
26+
try {
27+
server = createServer(async (request, response) => {
28+
const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
29+
if (path === '/api/auth/get-session') {
30+
response.writeHead(200, { 'Content-Type': 'application/json' }).end(
31+
JSON.stringify({
32+
user: { id: 'computer-test' },
33+
session: { id: 'computer-test-session' },
34+
})
35+
)
36+
return
37+
}
38+
if (path === '/api/desktop/computer/authorize') {
39+
authorizationCount += 1
40+
let body = ''
41+
for await (const chunk of request) body += chunk.toString()
42+
const input = JSON.parse(body)
43+
expect(Object.keys(input)).toEqual(['toolCallId'])
44+
if (input.toolCallId === 'delayed') {
45+
markAuthorizationStarted()
46+
await authorizationReleased
47+
} else if (input.toolCallId !== 'status-once' || claimed) {
48+
response.writeHead(403, { 'Content-Type': 'application/json' }).end('{}')
49+
return
50+
}
51+
claimed = true
52+
response.writeHead(200, { 'Content-Type': 'application/json' }).end(
53+
JSON.stringify({
54+
chatId: 'computer-chat',
55+
toolName: 'computer',
56+
args: { action: 'status' },
57+
})
58+
)
59+
return
60+
}
61+
response
62+
.writeHead(200, {
63+
'Content-Type': 'text/html',
64+
'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/',
65+
})
66+
.end(
67+
`<!doctype html><title>Computer Use fixture</title><h1>Computer Use</h1><button id="disable">Disable Computer Use</button><output id="status"></output><script>document.getElementById('disable').onclick=async()=>{const state=await window.simDesktop.computerUse.setEnabled(false);document.getElementById('status').textContent=String(state.enabled)}</script>`
68+
)
69+
})
70+
await new Promise<void>((resolve) => server?.listen(0, '127.0.0.1', resolve))
71+
const address = server.address()
72+
if (!address || typeof address === 'string') throw new Error('Missing local fixture port')
73+
const profile = join(root, 'profile')
74+
mkdirSync(profile)
75+
writeFileSync(
76+
join(profile, 'settings.json'),
77+
JSON.stringify({ origin: `http://127.0.0.1:${address.port}`, computerUseEnabled: true })
78+
)
79+
app = await electron.launch({
80+
args: ['.'],
81+
cwd: DESKTOP_DIR,
82+
env: {
83+
...process.env,
84+
SIM_DESKTOP_ORIGIN: `http://127.0.0.1:${address.port}`,
85+
SIM_DESKTOP_USER_DATA: profile,
86+
},
87+
})
88+
const window = await app.firstWindow()
89+
await expect(window.getByRole('heading')).toHaveText('Computer Use')
90+
const status = await window.evaluate(async () => {
91+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
92+
return api.computerUse?.getStatus()
93+
})
94+
expect(status).toMatchObject({ supported: true, enabled: true, activeAction: null })
95+
const result = await window.evaluate(async () => {
96+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
97+
return api.computerUse?.executeTool('status-once', {
98+
action: 'get_app_state',
99+
bundleId: 'com.apple.systempreferences',
100+
})
101+
})
102+
expect(result).toMatchObject({ kind: 'status', platform: 'darwin' })
103+
const replay = await window.evaluate(async () => {
104+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
105+
try {
106+
await api.computerUse?.executeTool('status-once', { action: 'status' })
107+
return 'unexpected success'
108+
} catch {
109+
return 'rejected'
110+
}
111+
})
112+
expect(replay).toBe('rejected')
113+
expect(authorizationCount).toBe(2)
114+
const delayed = window.evaluate(async () => {
115+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
116+
try {
117+
await api.computerUse?.executeTool('delayed', { action: 'status' })
118+
return 'unexpected success'
119+
} catch {
120+
return 'stopped'
121+
}
122+
})
123+
await authorizationStarted
124+
await window.evaluate(async () => {
125+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
126+
await api.computerUse?.cancel('delayed')
127+
})
128+
releaseAuthorization()
129+
expect(await delayed).toBe('stopped')
130+
expect(authorizationCount).toBe(3)
131+
await window.getByRole('button', { name: 'Disable Computer Use' }).click()
132+
await expect(window.locator('#status')).toHaveText('false')
133+
const disabled = await window.evaluate(async () => {
134+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
135+
try {
136+
await api.computerUse?.executeTool('disabled', { action: 'status' })
137+
return 'unexpected success'
138+
} catch {
139+
return 'rejected'
140+
}
141+
})
142+
expect(disabled).toBe('rejected')
143+
expect(authorizationCount).toBe(3)
144+
} finally {
145+
releaseAuthorization()
146+
await app?.close()
147+
await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()))
148+
rmSync(root, { recursive: true, force: true })
149+
}
150+
})

‎apps/desktop/electron-builder.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@ directories:
88

99
files:
1010
- dist/**
11+
- "!dist/native/Sim Computer Use.app/**"
1112
- static/**
1213
- package.json
1314
- from: ../sim/app/_styles/fonts/season
1415
to: static
1516
filter:
1617
- SeasonSansUprightsVF.woff2
1718

19+
extraResources:
20+
- from: dist/native/Sim Computer Use.app
21+
to: Sim Computer Use.app
22+
1823
asar: true
1924

2025
# Native modules cannot be dlopen'd from inside an asar. The Help-search addon
@@ -64,6 +69,7 @@ mac:
6469
# macOS refuses to show the microphone prompt at all — it kills the process —
6570
# unless the bundle declares why it wants the device.
6671
extendInfo:
72+
NSScreenCaptureUsageDescription: Mothership captures approved app windows to understand and verify computer-use actions.
6773
NSMicrophoneUsageDescription: Sim uses your microphone for voice input in Chat and for meetings you join in the built-in browser.
6874
NSCameraUsageDescription: Sim uses your camera for meetings you join in the built-in browser, such as Google Meet.
6975
NSBluetoothAlwaysUsageDescription: Sim uses Bluetooth to complete passkey sign-ins with a nearby phone in the built-in browser.

0 commit comments

Comments
 (0)