Skip to content

Commit d2486c6

Browse files
committed
fix(browser): close native execution races
1 parent e6e69db commit d2486c6

10 files changed

Lines changed: 867 additions & 67 deletions

File tree

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,4 +596,56 @@ describe('browser-agent screenshot capture', () => {
596596
expect(shot.viewport).toBeNull()
597597
expect(shot.imageSize).toEqual({ width: 1024, height: 512 })
598598
})
599+
600+
it.each([
601+
[
602+
'dimensions',
603+
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 } },
604+
{ cssLayoutViewport: { clientWidth: 1024, clientHeight: 512 } },
605+
],
606+
[
607+
'metric units',
608+
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 } },
609+
{ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } },
610+
],
611+
[
612+
'horizontal scroll offset',
613+
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024, pageX: 0, pageY: 20 } },
614+
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024, pageX: 10, pageY: 20 } },
615+
],
616+
[
617+
'vertical scroll offset',
618+
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024, pageX: 10, pageY: 20 } },
619+
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024, pageX: 10, pageY: 30 } },
620+
],
621+
[
622+
'offset validity',
623+
{ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024, pageX: 0, pageY: 0 } },
624+
{
625+
cssLayoutViewport: {
626+
clientWidth: 2048,
627+
clientHeight: 1024,
628+
pageX: 0,
629+
pageY: Number.NaN,
630+
},
631+
},
632+
],
633+
['availability', {}, {}],
634+
])(
635+
'rejects a capture when viewport %s change during CDP capture',
636+
async (_label, before, after) => {
637+
const { contents } = captureFixture({ width: 1024, height: 512 })
638+
let metricsRead = 0
639+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
640+
if (method === 'Page.getLayoutMetrics') {
641+
metricsRead++
642+
return Promise.resolve(metricsRead === 1 ? before : after)
643+
}
644+
if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' })
645+
return Promise.resolve(undefined)
646+
})
647+
648+
await expect(captureScreenshot(contents)).rejects.toThrow(/viewport changed/)
649+
}
650+
)
599651
})

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

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,14 @@ const SCREENSHOT_CAPTURE_QUALITY = 90
382382
interface CdpViewport {
383383
clientWidth: number
384384
clientHeight: number
385+
pageX?: number
386+
pageY?: number
387+
}
388+
389+
interface ScreenshotViewportMetrics extends ScreenshotSize {
390+
pageX: number | null
391+
pageY: number | null
392+
unit: 'css' | 'device'
385393
}
386394

387395
interface ScreenshotSize {
@@ -396,6 +404,51 @@ export interface ScreenshotCapture {
396404
imageSize: ScreenshotSize | null
397405
}
398406

407+
function screenshotViewportMetrics(
408+
metrics: {
409+
cssLayoutViewport?: CdpViewport
410+
layoutViewport?: CdpViewport
411+
} | null
412+
): ScreenshotViewportMetrics | null {
413+
const viewport = metrics?.cssLayoutViewport ?? metrics?.layoutViewport
414+
const width = viewport?.clientWidth ?? 0
415+
const height = viewport?.clientHeight ?? 0
416+
if (width <= 0 || height <= 0) return null
417+
const pageX = viewport?.pageX
418+
const pageY = viewport?.pageY
419+
const hasPagePosition = pageX !== undefined || pageY !== undefined
420+
if (
421+
hasPagePosition &&
422+
(pageX === undefined ||
423+
pageY === undefined ||
424+
!Number.isFinite(pageX) ||
425+
!Number.isFinite(pageY))
426+
) {
427+
return null
428+
}
429+
return {
430+
width,
431+
height,
432+
pageX: pageX ?? null,
433+
pageY: pageY ?? null,
434+
unit: metrics?.cssLayoutViewport ? 'css' : 'device',
435+
}
436+
}
437+
438+
function sameScreenshotViewport(
439+
before: ScreenshotViewportMetrics | null,
440+
after: ScreenshotViewportMetrics | null
441+
): boolean {
442+
if (!before || !after) return false
443+
return (
444+
before.unit === after.unit &&
445+
before.width === after.width &&
446+
before.height === after.height &&
447+
before.pageX === after.pageX &&
448+
before.pageY === after.pageY
449+
)
450+
}
451+
399452
/**
400453
* Screenshot via CDP (works while the view is hidden), bounded in resolution.
401454
*
@@ -419,9 +472,9 @@ export async function captureScreenshot(contents: WebContents): Promise<Screensh
419472
layoutViewport?: CdpViewport
420473
}>(contents, 'Page.getLayoutMetrics').catch(() => null)
421474

422-
const captureViewport = metrics?.cssLayoutViewport ?? metrics?.layoutViewport
423-
const width = captureViewport?.clientWidth ?? 0
424-
const height = captureViewport?.clientHeight ?? 0
475+
const captureViewport = screenshotViewportMetrics(metrics)
476+
const width = captureViewport?.width ?? 0
477+
const height = captureViewport?.height ?? 0
425478
const cssWidth = metrics?.cssLayoutViewport?.clientWidth ?? 0
426479
const cssHeight = metrics?.cssLayoutViewport?.clientHeight ?? 0
427480
const cssViewport = cssWidth > 0 && cssHeight > 0 ? { width: cssWidth, height: cssHeight } : null
@@ -432,6 +485,13 @@ export async function captureScreenshot(contents: WebContents): Promise<Screensh
432485
format: 'jpeg',
433486
quality: SCREENSHOT_CAPTURE_QUALITY,
434487
})
488+
const metricsAfterCapture = await send<{
489+
cssLayoutViewport?: CdpViewport
490+
layoutViewport?: CdpViewport
491+
}>(contents, 'Page.getLayoutMetrics').catch(() => null)
492+
if (!sameScreenshotViewport(captureViewport, screenshotViewportMetrics(metricsAfterCapture))) {
493+
throw new Error('The page viewport changed or could not be verified during screenshot capture')
494+
}
435495
const captured = `data:image/jpeg;base64,${result.data}`
436496

437497
const targetWidth = Math.round(width * scale)

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

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2481,6 +2481,45 @@ describe('credential protection', () => {
24812481
).toBe(true)
24822482
})
24832483

2484+
it('accepts stable truncated page identity with deprecated device metrics', async () => {
2485+
const contents = await openPage()
2486+
const fullUrl = `https://example.com/${'u'.repeat(5000)}`
2487+
const fullTitle = `Example ${'t'.repeat(600)}`
2488+
vi.mocked(contents.getURL).mockReturnValue(fullUrl)
2489+
vi.mocked(contents.getTitle).mockReturnValue(fullTitle)
2490+
mockScreenshotImage({ width: 1024, height: 512 })
2491+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
2492+
if (method === 'Page.getLayoutMetrics') {
2493+
return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } })
2494+
}
2495+
if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' })
2496+
return Promise.resolve(undefined)
2497+
})
2498+
respondWith(contents, {
2499+
getViewportInfo: {
2500+
url: fullUrl.slice(0, 4096),
2501+
title: fullTitle.slice(0, 500),
2502+
width: 1024,
2503+
height: 512,
2504+
},
2505+
})
2506+
2507+
const result = await driver.executeTool('chat-test', 'browser_screenshot', {})
2508+
2509+
expect(result).toMatchObject({
2510+
ok: true,
2511+
result: {
2512+
scale: 1,
2513+
viewport: {
2514+
url: fullUrl.slice(0, 4096),
2515+
title: fullTitle.slice(0, 500),
2516+
width: 1024,
2517+
height: 512,
2518+
},
2519+
},
2520+
})
2521+
})
2522+
24842523
it('rejects an undecodable screenshot instead of returning an unverified scale', async () => {
24852524
const contents = await openPage()
24862525
mockScreenshotImage(null)
@@ -2522,7 +2561,9 @@ describe('credential protection', () => {
25222561
const contents = await openPage()
25232562
mockScreenshotImage({ width: 1024, height: 256 })
25242563
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
2525-
if (method === 'Page.getLayoutMetrics') return Promise.resolve({})
2564+
if (method === 'Page.getLayoutMetrics') {
2565+
return Promise.resolve({ layoutViewport: { clientWidth: 1024, clientHeight: 256 } })
2566+
}
25262567
if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' })
25272568
return Promise.resolve(undefined)
25282569
})
@@ -2540,4 +2581,58 @@ describe('credential protection', () => {
25402581
expect(result.ok).toBe(false)
25412582
expect(result.error).toMatch(/viewport changed while the screenshot was captured/)
25422583
})
2584+
2585+
it('rejects a screenshot when the document navigates during capture', async () => {
2586+
const contents = await openPage()
2587+
mockScreenshotImage({ width: 1024, height: 512 })
2588+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
2589+
if (method === 'Page.getLayoutMetrics') {
2590+
return Promise.resolve({
2591+
cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 },
2592+
})
2593+
}
2594+
if (method === 'Page.captureScreenshot') {
2595+
emitContentsEvent(contents, 'did-navigate')
2596+
return Promise.resolve({ data: 'c2lt' })
2597+
}
2598+
return Promise.resolve(undefined)
2599+
})
2600+
2601+
const result = await driver.executeTool('chat-test', 'browser_screenshot', {})
2602+
2603+
expect(result.ok).toBe(false)
2604+
expect(result.error).toMatch(/page changed while its screenshot was being captured/)
2605+
})
2606+
2607+
it.each(['url', 'title'] as const)(
2608+
'rejects a screenshot when the page %s changes during capture',
2609+
async (identityField) => {
2610+
const contents = await openPage()
2611+
mockScreenshotImage({ width: 1024, height: 512 })
2612+
const initialUrl = contents.getURL()
2613+
const initialTitle = contents.getTitle()
2614+
let currentUrl = initialUrl
2615+
let currentTitle = initialTitle
2616+
vi.mocked(contents.getURL).mockImplementation(() => currentUrl)
2617+
vi.mocked(contents.getTitle).mockImplementation(() => currentTitle)
2618+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
2619+
if (method === 'Page.getLayoutMetrics') {
2620+
return Promise.resolve({
2621+
cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 },
2622+
})
2623+
}
2624+
if (method === 'Page.captureScreenshot') {
2625+
if (identityField === 'url') currentUrl = 'https://example.com/changed'
2626+
else currentTitle = 'Changed title'
2627+
return Promise.resolve({ data: 'c2lt' })
2628+
}
2629+
return Promise.resolve(undefined)
2630+
})
2631+
2632+
const result = await driver.executeTool('chat-test', 'browser_screenshot', {})
2633+
2634+
expect(result.ok).toBe(false)
2635+
expect(result.error).toMatch(/page changed while its screenshot was being captured/)
2636+
}
2637+
)
25432638
})

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

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2127,13 +2127,37 @@ async function executeToolInner(
21272127
}
21282128

21292129
case 'browser_screenshot': {
2130-
const contents = session.requireAutomationTab().view.webContents
2130+
const capturedTab = session.requireAutomationTab()
2131+
const contents = capturedTab.view.webContents
2132+
const capturedNavigationEpoch = navigationEpoch(contents)
2133+
const capturedUrl = contents.getURL()
2134+
const capturedTitle = contents.getTitle()
2135+
const capturedViewportUrl = capturedUrl.slice(0, 4096)
2136+
const capturedViewportTitle = capturedTitle.slice(0, 500)
2137+
const captureIsCurrent = (): boolean => {
2138+
const activeTab = session.automationTab()
2139+
return (
2140+
activeTab?.id === capturedTab.id &&
2141+
activeTab.view.webContents === contents &&
2142+
!contents.isDestroyed() &&
2143+
navigationEpoch(contents) === capturedNavigationEpoch &&
2144+
contents.getURL() === capturedUrl &&
2145+
contents.getTitle() === capturedTitle
2146+
)
2147+
}
2148+
const assertCaptureIsCurrent = (): void => {
2149+
if (captureIsCurrent()) return
2150+
throw new ToolError(
2151+
'The page changed while its screenshot was being captured. Retry browser_screenshot before using image coordinates.'
2152+
)
2153+
}
21312154
const shot = await cdp.captureScreenshot(contents).catch(() => null)
21322155
if (shot === null) {
21332156
throw new ToolError(
21342157
'Could not capture the page. Use browser_snapshot or browser_read_text instead.'
21352158
)
21362159
}
2160+
assertCaptureIsCurrent()
21372161
if (shot.dataUrl.length > 8_000_000) {
21382162
throw new ToolError(
21392163
'The screenshot result was too large to return safely. Use browser_snapshot or browser_read_text instead.'
@@ -2146,11 +2170,21 @@ async function executeToolInner(
21462170
}
21472171
const viewport = shot.viewport
21482172
? {
2149-
url: contents.getURL().slice(0, 4096),
2150-
title: contents.getTitle().slice(0, 500),
2173+
url: capturedViewportUrl,
2174+
title: capturedViewportTitle,
21512175
...shot.viewport,
21522176
}
21532177
: await execInPage(contents, getViewportInfo, []).catch(() => null)
2178+
assertCaptureIsCurrent()
2179+
if (
2180+
!shot.viewport &&
2181+
isRecordLike(viewport) &&
2182+
(viewport.url !== capturedViewportUrl || viewport.title !== capturedViewportTitle)
2183+
) {
2184+
throw new ToolError(
2185+
'The page changed while its screenshot viewport was being verified. Retry browser_screenshot before using image coordinates.'
2186+
)
2187+
}
21542188
let scale = shot.scale
21552189
const viewportWidth =
21562190
isRecordLike(viewport) && typeof viewport.width === 'number' ? viewport.width : 0

0 commit comments

Comments
 (0)