Skip to content

Commit da30c26

Browse files
committed
fix(desktop): close browser review gaps
1 parent 328c31b commit da30c26

14 files changed

Lines changed: 742 additions & 123 deletions

File tree

apps/desktop/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ Raw local file bytes are never exposed through the preload bridge and cannot be
187187

188188
## Known caveats
189189

190-
- The hosted Sim renderer cannot access microphone or camera. A page in the isolated agent browser may request them only from its main frame after a recent native user gesture; Sim then requires an explicit document-scoped prompt and the operating-system grant.
190+
- The hosted Sim renderer may request microphone access for voice input from the configured app origin; camera access remains denied. On macOS the shell also requires the operating-system microphone grant. Separately, a page in the isolated agent browser may request microphone or camera only from its main frame after a recent native user gesture; Sim then requires an explicit document-scoped prompt and the operating-system grant where applicable.
191191
- The built-in agent browser is not a general-purpose download manager. Its dedicated partition applies the same bounded policy to every download, including one started by a direct user click: at most 2 GiB per file, two active downloads per task, six app-wide, and a 1 GiB free-disk reserve. A rejected download appears in the browser's downloads menu; use a normal browser for an intentionally larger transfer.
192192
- Default Electron ships H.264/AAC/MP3 — do not swap in the codec-free ffmpeg build.
193193
- Third-party web analytics (GTM/GA) are blocked at the network layer by default (`blockThirdPartyAnalytics`); first-party PostHog `/ingest` is untouched.

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

Lines changed: 246 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS } from '@sim/browser-protocol'
12
import type { MenuItemConstructorOptions } from 'electron'
23
import { beforeEach, describe, expect, it, vi } from 'vitest'
34

@@ -33,6 +34,29 @@ function freshDriver(): DriverModule {
3334
return driverModule
3435
}
3536

37+
type BrowserToolQueueBoundary = NonNullable<
38+
ReturnType<DriverModule['captureBrowserToolQueueBoundary']>
39+
>
40+
41+
function capturePendingAuthorizations(
42+
driver: DriverModule,
43+
scopeId: string,
44+
count: number = driver.BROWSER_TOOL_ADMISSION_LIMITS.perScope
45+
): BrowserToolQueueBoundary[] {
46+
const boundaries = Array.from({ length: count }, () =>
47+
driver.captureBrowserToolQueueBoundary(scopeId)
48+
)
49+
expect(boundaries.every((boundary) => boundary !== null)).toBe(true)
50+
return boundaries.filter((boundary): boundary is BrowserToolQueueBoundary => boundary !== null)
51+
}
52+
53+
function releasePendingAuthorizations(
54+
driver: DriverModule,
55+
boundaries: readonly BrowserToolQueueBoundary[]
56+
): void {
57+
for (const boundary of boundaries) driver.releaseBrowserToolQueueBoundary(boundary)
58+
}
59+
3660
/** Match the serialized function invocation, not comments or helper names in its body. */
3761
function isPageCall(expression: string, fnName: string): boolean {
3862
return expression.includes(`function ${fnName}(`)
@@ -53,13 +77,15 @@ describe('executeTool', () => {
5377
})
5478

5579
it('validates navigation URLs before touching the session', async () => {
80+
const grant = vi.spyOn(session, 'grantSiteOriginForAgentNavigation')
5681
const result = await driver.executeTool('chat-test', 'browser_navigate', {
5782
url: 'file:///etc/passwd',
5883
})
5984
expect(result).toEqual({
6085
ok: false,
6186
error: 'URL must be absolute and start with http:// or https://',
6287
})
88+
expect(grant).not.toHaveBeenCalled()
6389
})
6490

6591
it('reports missing required parameters by name', async () => {
@@ -68,6 +94,23 @@ describe('executeTool', () => {
6894
expect(result.error).toMatch(/Missing required parameter "url"/)
6995
})
7096

97+
it('grants only SSRF-checked agent navigation destinations before loading them', async () => {
98+
const grant = vi.spyOn(session, 'grantSiteOriginForAgentNavigation')
99+
const navigations = [
100+
['browser_navigate', 'http://127.0.0.1:4011/navigate'],
101+
['browser_open_url', 'http://127.0.0.1:4012/open'],
102+
['browser_open_tab', 'http://127.0.0.1:4013/tab'],
103+
] as const
104+
105+
for (const [tool, url] of navigations) {
106+
await expect(driver.executeTool('chat-test', tool, { url })).resolves.toMatchObject({
107+
ok: true,
108+
})
109+
expect(grant).toHaveBeenCalledWith(expect.anything(), url)
110+
}
111+
expect(grant).toHaveBeenCalledTimes(navigations.length)
112+
})
113+
71114
it('reports an aborted navigation when Chromium never leaves the current URL', async () => {
72115
vi.useFakeTimers()
73116
try {
@@ -578,17 +621,80 @@ describe('executeTool', () => {
578621
})
579622

580623
it('bounds pending authorizations without materializing their scopes', () => {
581-
const boundaries = Array.from({ length: driver.BROWSER_TOOL_ADMISSION_LIMITS.perScope }, () =>
582-
driver.captureBrowserToolQueueBoundary('chat-pending-authorization')
583-
)
624+
const boundaries = capturePendingAuthorizations(driver, 'chat-pending-authorization')
584625

585626
expect(boundaries.every((boundary) => boundary?.generation === null)).toBe(true)
586627
expect(driver.captureBrowserToolQueueBoundary('chat-pending-authorization')).toBeNull()
587628

588-
for (const boundary of boundaries) {
589-
if (boundary) driver.releaseBrowserToolQueueBoundary(boundary)
590-
}
591-
expect(driver.captureBrowserToolQueueBoundary('chat-pending-authorization')).not.toBeNull()
629+
releasePendingAuthorizations(driver, boundaries)
630+
const replacement = driver.captureBrowserToolQueueBoundary('chat-pending-authorization')
631+
expect(replacement).not.toBeNull()
632+
if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
633+
})
634+
635+
it('retains cancelled authorization admissions until their fetches settle', () => {
636+
const boundaries = capturePendingAuthorizations(driver, 'chat-test')
637+
638+
expect(driver.cancelActiveTool('chat-test')).toBe(true)
639+
expect(boundaries.every((boundary) => boundary.cancelled)).toBe(true)
640+
expect(driver.captureBrowserToolQueueBoundary('chat-test')).toBeNull()
641+
642+
releasePendingAuthorizations(driver, boundaries)
643+
const replacement = driver.captureBrowserToolQueueBoundary('chat-test')
644+
expect(replacement).not.toBeNull()
645+
if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
646+
})
647+
648+
it('retains disposed-scope authorization admissions until their fetches settle', () => {
649+
const boundaries = capturePendingAuthorizations(driver, 'chat-disposed-authorizations')
650+
651+
driver.disposeBrowserScope('chat-disposed-authorizations')
652+
expect(boundaries.every((boundary) => boundary.cancelled)).toBe(true)
653+
expect(driver.captureBrowserToolQueueBoundary('chat-disposed-authorizations')).toBeNull()
654+
655+
releasePendingAuthorizations(driver, boundaries)
656+
const replacement = driver.captureBrowserToolQueueBoundary('chat-disposed-authorizations')
657+
expect(replacement).not.toBeNull()
658+
if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
659+
})
660+
661+
it('retains suspended-scope authorization admissions until their fetches settle', () => {
662+
const boundaries = capturePendingAuthorizations(driver, 'chat-suspended-authorizations')
663+
664+
expect(driver.suspendBrowserScope('chat-suspended-authorizations')).toBe(true)
665+
expect(boundaries.every((boundary) => boundary.cancelled)).toBe(true)
666+
expect(driver.captureBrowserToolQueueBoundary('chat-suspended-authorizations')).toBeNull()
667+
668+
releasePendingAuthorizations(driver, boundaries)
669+
const replacement = driver.captureBrowserToolQueueBoundary('chat-suspended-authorizations')
670+
expect(replacement).not.toBeNull()
671+
if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
672+
})
673+
674+
it('retains process-wide authorization admissions across driver reinitialization', () => {
675+
const boundaries = ['chat-auth-a', 'chat-auth-b', 'chat-auth-c', 'chat-auth-d'].flatMap(
676+
(scopeId) => capturePendingAuthorizations(driver, scopeId)
677+
)
678+
expect(boundaries).toHaveLength(driver.BROWSER_TOOL_ADMISSION_LIMITS.process)
679+
680+
driver.initDriver(
681+
{
682+
onPageState: vi.fn(),
683+
onTabsState: vi.fn(),
684+
onSessionStatus: vi.fn(),
685+
onFillAvailability: vi.fn(),
686+
},
687+
() => null
688+
)
689+
690+
expect(boundaries.every((boundary) => boundary.cancelled)).toBe(true)
691+
expect(driver.captureBrowserToolQueueBoundary('chat-after-reinit')).toBeNull()
692+
693+
driver.releaseBrowserToolQueueBoundary(boundaries[0])
694+
const replacement = driver.captureBrowserToolQueueBoundary('chat-after-reinit')
695+
expect(replacement).not.toBeNull()
696+
if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
697+
releasePendingAuthorizations(driver, boundaries.slice(1))
592698
})
593699

594700
it('honors cancellation that arrives before the authorized tool invocation', async () => {
@@ -1066,6 +1172,104 @@ describe('executeTool', () => {
10661172
expect(driver.migrateBrowserScope('pending:other-chat', 'chat-occupied')).toBe(false)
10671173
})
10681174

1175+
it('cancels only the replaced destination authorizations during migration', async () => {
1176+
await driver.executeTool('pending:new-chat', 'browser_open_tab', {})
1177+
driver.activateBrowserScope('chat-real')
1178+
const sourceBoundary = driver.captureBrowserToolQueueBoundary('pending:new-chat')
1179+
const destinationBoundary = driver.captureBrowserToolQueueBoundary('chat-real')
1180+
const otherBoundary = driver.captureBrowserToolQueueBoundary('chat-other')
1181+
expect(sourceBoundary).not.toBeNull()
1182+
expect(destinationBoundary).not.toBeNull()
1183+
expect(otherBoundary).not.toBeNull()
1184+
if (!sourceBoundary || !destinationBoundary || !otherBoundary) {
1185+
throw new Error('Expected browser tool authorization admissions')
1186+
}
1187+
1188+
expect(driver.migrateBrowserScope('pending:new-chat', 'chat-real')).toBe(true)
1189+
1190+
await expect(
1191+
driver.executeTool(
1192+
'chat-real',
1193+
'browser_list_tabs',
1194+
{},
1195+
'tool-destination-before-migration',
1196+
destinationBoundary
1197+
)
1198+
).resolves.toMatchObject({
1199+
ok: false,
1200+
error: expect.stringContaining('cancelled before it started'),
1201+
})
1202+
await expect(
1203+
driver.executeTool(
1204+
'chat-real',
1205+
'browser_list_tabs',
1206+
{},
1207+
'tool-source-before-migration',
1208+
sourceBoundary
1209+
)
1210+
).resolves.toMatchObject({ ok: true })
1211+
await expect(
1212+
driver.executeTool(
1213+
'chat-other',
1214+
'browser_list_tabs',
1215+
{},
1216+
'tool-other-during-migration',
1217+
otherBoundary
1218+
)
1219+
).resolves.toMatchObject({ ok: true })
1220+
})
1221+
1222+
it('retains replaced destination admissions until their authorization fetches settle', async () => {
1223+
await driver.executeTool('pending:new-chat', 'browser_open_tab', {})
1224+
driver.activateBrowserScope('chat-real')
1225+
const sourceBoundary = driver.captureBrowserToolQueueBoundary('pending:new-chat')
1226+
const destinationBoundaries = capturePendingAuthorizations(
1227+
driver,
1228+
'chat-real',
1229+
driver.BROWSER_TOOL_ADMISSION_LIMITS.perScope - 1
1230+
)
1231+
expect(sourceBoundary).not.toBeNull()
1232+
if (!sourceBoundary) throw new Error('Expected source authorization admission')
1233+
1234+
expect(driver.migrateBrowserScope('pending:new-chat', 'chat-real')).toBe(true)
1235+
1236+
expect(destinationBoundaries.every((boundary) => boundary.cancelled)).toBe(true)
1237+
expect(sourceBoundary.cancelled).toBe(false)
1238+
expect(driver.captureBrowserToolQueueBoundary('chat-real')).toBeNull()
1239+
1240+
releasePendingAuthorizations(driver, destinationBoundaries)
1241+
await expect(
1242+
driver.executeTool(
1243+
'chat-real',
1244+
'browser_list_tabs',
1245+
{},
1246+
'tool-source-after-destination-settlement',
1247+
sourceBoundary
1248+
)
1249+
).resolves.toMatchObject({ ok: true })
1250+
const replacement = driver.captureBrowserToolQueueBoundary('chat-real')
1251+
expect(replacement).not.toBeNull()
1252+
if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
1253+
})
1254+
1255+
it('keeps migrated source admissions charged to the durable scope after disposal', () => {
1256+
driver.activateBrowserScope('pending:new-chat')
1257+
const sourceBoundaries = capturePendingAuthorizations(driver, 'pending:new-chat')
1258+
1259+
expect(driver.migrateBrowserScope('pending:new-chat', 'chat-real')).toBe(true)
1260+
expect(sourceBoundaries.every((boundary) => boundary.scopeId === 'chat-real')).toBe(true)
1261+
1262+
driver.disposeBrowserScope('chat-real')
1263+
driver.activateBrowserScope('chat-real')
1264+
expect(sourceBoundaries.every((boundary) => boundary.cancelled)).toBe(true)
1265+
expect(driver.captureBrowserToolQueueBoundary('chat-real')).toBeNull()
1266+
1267+
releasePendingAuthorizations(driver, sourceBoundaries)
1268+
const replacement = driver.captureBrowserToolQueueBoundary('chat-real')
1269+
expect(replacement).not.toBeNull()
1270+
if (replacement) driver.releaseBrowserToolQueueBoundary(replacement)
1271+
})
1272+
10691273
it('retains a migrated provisional alias for callbacks until durable disposal', async () => {
10701274
await driver.executeTool('pending:new-chat', 'browser_open_tab', {})
10711275
const tab = session.withBrowserScope('pending:new-chat', () => session.requireTab())
@@ -1215,6 +1419,41 @@ describe('executeTool', () => {
12151419
}
12161420
})
12171421

1422+
it('expires a bounded queue wait without running the stale action later', async () => {
1423+
await driver.executeTool('chat-test', 'browser_open_tab', {})
1424+
const contents = session.requireTab().view.webContents
1425+
vi.mocked(contents.loadURL).mockClear()
1426+
vi.useFakeTimers()
1427+
try {
1428+
const waiting = driver.executeTool(
1429+
'chat-test',
1430+
'browser_wait_for',
1431+
{ timeoutMs: 120_000 },
1432+
'tool-queue-head'
1433+
)
1434+
await vi.advanceTimersByTimeAsync(0)
1435+
const queued = driver.executeTool(
1436+
'chat-test',
1437+
'browser_navigate',
1438+
{ url: 'http://127.0.0.1/expired' },
1439+
'tool-queue-expired'
1440+
)
1441+
1442+
await vi.advanceTimersByTimeAsync(BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS)
1443+
await expect(queued).resolves.toMatchObject({
1444+
ok: false,
1445+
error: expect.stringContaining('waited too long for earlier browser work'),
1446+
})
1447+
1448+
expect(driver.cancelTool('chat-test', 'tool-queue-head')).toBe(true)
1449+
await vi.advanceTimersByTimeAsync(0)
1450+
await expect(waiting).resolves.toMatchObject({ ok: false })
1451+
expect(contents.loadURL).not.toHaveBeenCalledWith('http://127.0.0.1/expired')
1452+
} finally {
1453+
vi.useRealTimers()
1454+
}
1455+
})
1456+
12181457
it('bounds one scope queue and admits new work after the held head is cancelled', async () => {
12191458
vi.useFakeTimers()
12201459
try {

0 commit comments

Comments
 (0)