Skip to content

Commit fc9469f

Browse files
committed
fix(desktop): hand the panel occlusion frame only to a user-driven renderer
capturePanelSnapshot sent a JPEG of the agent browser's current page to the app renderer, which is content that renderer's own JS cannot otherwise read — the view is a separate process composited over the window. A compromised renderer can drive set-panel-bounds, panel-action navigate and set-panel-occluded itself, so it could aim the shared agent browser at a site with a persisted session and collect its pixels by script alone, bypassing the tool-call binding that guards browser_screenshot for exactly this reason. The frame is now withheld unless the main process has seen recent real input in that renderer, which is what an overlay opening actually represents. The flicker-prevention behaviour is untouched: an empty capture already returns before the send while `finally` still runs the occlusion, so "occlude without a placeholder" is a path the state machine already handles rather than a new one.
1 parent 7782a56 commit fc9469f

3 files changed

Lines changed: 95 additions & 1 deletion

File tree

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

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
22

33
vi.mock('electron', () => import('@/test/electron-mock'))
44

5+
import type { WebContents } from 'electron'
56
import { BrowserWindow, WebContentsView } from 'electron'
67
import * as panelModule from '@/main/browser-agent/panel'
8+
import { trackInputActivity } from '@/main/input-activity'
79

810
type PanelModule = typeof import('@/main/browser-agent/panel')
911

@@ -27,6 +29,29 @@ function freshPanel(): PanelModule {
2729

2830
const PANEL_RECT = { x: 400, y: 64, width: 600, height: 800 }
2931

32+
type InputListener = (event: unknown, input: { type: string }) => void
33+
34+
/**
35+
* Registers a window's renderer with the main-process input tracker and returns
36+
* a way to give it a real click. The occlusion snapshot is only handed over to a
37+
* renderer the user has recently driven, so a fixture has to say so explicitly.
38+
*/
39+
function trackWindowInput(win: BrowserWindow) {
40+
const listeners: InputListener[] = []
41+
const contents = win.webContents as unknown as {
42+
on: (channel: string, listener: InputListener) => void
43+
isDestroyed: () => boolean
44+
}
45+
contents.on = (channel, listener) => {
46+
if (channel === 'input-event') listeners.push(listener)
47+
}
48+
contents.isDestroyed = () => false
49+
trackInputActivity(win.webContents as WebContents)
50+
return () => {
51+
for (const listener of listeners) listener({}, { type: 'mouseDown' })
52+
}
53+
}
54+
3055
/** A panel showing one tab, which is the state occlusion applies to. */
3156
function showPanel(panel: PanelModule) {
3257
const win = new BrowserWindow()
@@ -38,13 +63,15 @@ function showPanel(panel: PanelModule) {
3863
ensureInitialTab: () => {},
3964
onViewDetached: () => {},
4065
})
66+
const press = trackWindowInput(win)
67+
press()
4168
panel.setPanelBounds(PANEL_RECT, win)
4269
/** Swaps in another tab's view, as switching tabs does. */
4370
const switchTab = (next: WebContentsView) => {
4471
active = { id: 'tab-2', view: next, pinned: false }
4572
panel.layout()
4673
}
47-
return { win, view, switchTab }
74+
return { win, view, switchTab, press }
4875
}
4976

5077
/** When the view was hidden, in the global mock invocation order. */
@@ -91,6 +118,38 @@ describe('panel occlusion', () => {
91118
expect(sent).toBeLessThan(hiddenAt(view) as number)
92119
})
93120

121+
it('withholds the frame from a renderer the user has not driven', async () => {
122+
const panel = freshPanel()
123+
const { win, view } = showPanel(panel)
124+
// A script-only caller: no real OS input has reached this renderer inside
125+
// the recency window, so the pixels of the agent page are not handed over.
126+
vi.useFakeTimers()
127+
try {
128+
vi.advanceTimersByTime(5_000)
129+
panel.setPanelOccluded(true, win)
130+
await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
131+
} finally {
132+
vi.useRealTimers()
133+
}
134+
135+
expect(snapshotSentAt(win)).toBeUndefined()
136+
})
137+
138+
it('still occludes when the frame is withheld, so nothing is left half-applied', async () => {
139+
const panel = freshPanel()
140+
const { win, view } = showPanel(panel)
141+
vi.useFakeTimers()
142+
try {
143+
vi.advanceTimersByTime(5_000)
144+
panel.setPanelOccluded(true, win)
145+
// Hiding must not depend on the snapshot: an empty capture already
146+
// reaches the same path, so this is an existing outcome, not a new one.
147+
await vi.waitFor(() => expect(hiddenAt(view)).toBeDefined())
148+
} finally {
149+
vi.useRealTimers()
150+
}
151+
})
152+
94153
it('stays visible when the overlay closes while the frame is being taken', async () => {
95154
const { win, view } = showPanel(panel)
96155

apps/desktop/src/main/browser-agent/panel.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { createLogger } from '@sim/logger'
2020
import { getErrorMessage } from '@sim/utils/errors'
2121
import type { BrowserWindow, WebContentsView } from 'electron'
2222
import type { AgentTab } from '@/main/browser-agent/session'
23+
import { hasRecentDeliberateInput } from '@/main/input-activity'
2324

2425
const logger = createLogger('BrowserAgentPanel')
2526

@@ -333,6 +334,22 @@ function capturePanelSnapshot(onSettled?: () => void): void {
333334
// picture of the page, so it goes to the window still showing the
334335
// browser or nowhere at all.
335336
if (panelWindow() !== win || win.isDestroyed()) return
337+
// A frame of the agent browser is content the renderer's own JS cannot
338+
// otherwise read — the view is a separate process composited over the
339+
// window. It is handed over so an overlay can show a placeholder instead
340+
// of a blank gap, and an overlay opens because the user did something. A
341+
// compromised renderer can drive set-panel-bounds, panel-action navigate
342+
// and set-panel-occluded on its own, so without this the pixels of an
343+
// authenticated page could be collected by script alone, bypassing the
344+
// tool-call binding that guards browser_screenshot for exactly this.
345+
//
346+
// Skipping the send is an already-supported outcome: an empty capture
347+
// returns here too, and `finally` still runs the occlusion, so no new
348+
// state is introduced — at worst a placeholder is missing for one frame.
349+
if (!hasRecentDeliberateInput(win.webContents)) {
350+
logger.warn('Withheld browser panel snapshot with no recent user input')
351+
return
352+
}
336353
// Downscale and JPEG-encode before crossing IPC. capturePage returns a
337354
// device-pixel PNG — on a retina half-window that is millions of pixels,
338355
// and toDataURL's PNG encode is synchronous on the main process, so a

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ vi.mock('electron', () => import('@/test/electron-mock'))
44

55
import { MAX_BROWSER_TABS } from '@sim/browser-protocol'
66
import { sleep } from '@sim/utils/helpers'
7+
import type { WebContents } from 'electron'
78
import { BrowserWindow, session as electronSession } from 'electron'
89
import * as panel from '@/main/browser-agent/panel'
910
import * as sessionModule from '@/main/browser-agent/session'
11+
import { trackInputActivity } from '@/main/input-activity'
1012

1113
type SessionModule = typeof import('@/main/browser-agent/session')
1214

@@ -34,6 +36,8 @@ interface MockView {
3436
setVisible: ReturnType<typeof vi.fn>
3537
}
3638

39+
type InputListener = (event: unknown, input: { type: string }) => void
40+
3741
function mainWindowMock() {
3842
const win = new BrowserWindow() as unknown as {
3943
contentView: {
@@ -43,6 +47,20 @@ function mainWindowMock() {
4347
webContents: { getZoomFactor?: ReturnType<typeof vi.fn> }
4448
}
4549
win.webContents.getZoomFactor = vi.fn(() => 1)
50+
// The occlusion snapshot is only handed to a renderer the user has recently
51+
// driven, so these fixtures — which stand in for a user with the panel open —
52+
// register with the input tracker and report one real click.
53+
const listeners: InputListener[] = []
54+
const contents = win.webContents as unknown as {
55+
on: (channel: string, listener: InputListener) => void
56+
isDestroyed: () => boolean
57+
}
58+
contents.on = (channel, listener) => {
59+
if (channel === 'input-event') listeners.push(listener)
60+
}
61+
contents.isDestroyed = () => false
62+
trackInputActivity(win.webContents as unknown as WebContents)
63+
for (const listener of listeners) listener({}, { type: 'mouseDown' })
4664
return win as unknown as BrowserWindow
4765
}
4866

0 commit comments

Comments
 (0)