Skip to content

Commit e6e69db

Browse files
committed
fix(browser): preserve screenshot coordinate contract
1 parent e8208e3 commit e6e69db

9 files changed

Lines changed: 258 additions & 166 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@ describe('browser-agent screenshot capture', () => {
549549
dataUrl: `data:image/jpeg;base64,${Buffer.from('resized').toString('base64')}`,
550550
scale: 0.5,
551551
viewport: { width: 2048, height: 1024 },
552+
imageSize: { width: 1024, height: 512 },
552553
})
553554
})
554555

@@ -563,6 +564,7 @@ describe('browser-agent screenshot capture', () => {
563564
dataUrl: 'data:image/jpeg;base64,c2lt',
564565
scale: 0.5,
565566
viewport: { width: 2048, height: 1024 },
567+
imageSize: { width: 1024, height: 512 },
566568
})
567569
})
568570

@@ -575,6 +577,23 @@ describe('browser-agent screenshot capture', () => {
575577
dataUrl: 'data:image/jpeg;base64,c2lt',
576578
scale: 0.5,
577579
viewport: { width: 2048, height: 1024 },
580+
imageSize: null,
578581
})
579582
})
583+
584+
it('does not expose deprecated device-pixel metrics as a CSS viewport', async () => {
585+
const { contents } = captureFixture({ width: 1024, height: 512 })
586+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
587+
if (method === 'Page.getLayoutMetrics') {
588+
return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } })
589+
}
590+
if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' })
591+
return Promise.resolve(undefined)
592+
})
593+
594+
const shot = await captureScreenshot(contents)
595+
596+
expect(shot.viewport).toBeNull()
597+
expect(shot.imageSize).toEqual({ width: 1024, height: 512 })
598+
})
580599
})

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

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,18 @@ interface CdpViewport {
384384
clientHeight: number
385385
}
386386

387+
interface ScreenshotSize {
388+
width: number
389+
height: number
390+
}
391+
392+
export interface ScreenshotCapture {
393+
dataUrl: string
394+
scale: number
395+
viewport: ScreenshotSize | null
396+
imageSize: ScreenshotSize | null
397+
}
398+
387399
/**
388400
* Screenshot via CDP (works while the view is hidden), bounded in resolution.
389401
*
@@ -401,18 +413,18 @@ interface CdpViewport {
401413
* (cssX = imageX / scale) — including on a 2x display, where an unclipped
402414
* capture arrives at device resolution and this is what brings it back down.
403415
*/
404-
export async function captureScreenshot(
405-
contents: WebContents
406-
): Promise<{ dataUrl: string; scale: number; viewport: { width: number; height: number } | null }> {
416+
export async function captureScreenshot(contents: WebContents): Promise<ScreenshotCapture> {
407417
const metrics = await send<{
408418
cssLayoutViewport?: CdpViewport
409419
layoutViewport?: CdpViewport
410420
}>(contents, 'Page.getLayoutMetrics').catch(() => null)
411421

412-
const viewport = metrics?.cssLayoutViewport ?? metrics?.layoutViewport
413-
const width = viewport?.clientWidth ?? 0
414-
const height = viewport?.clientHeight ?? 0
415-
const cssViewport = width > 0 && height > 0 ? { width, height } : null
422+
const captureViewport = metrics?.cssLayoutViewport ?? metrics?.layoutViewport
423+
const width = captureViewport?.clientWidth ?? 0
424+
const height = captureViewport?.clientHeight ?? 0
425+
const cssWidth = metrics?.cssLayoutViewport?.clientWidth ?? 0
426+
const cssHeight = metrics?.cssLayoutViewport?.clientHeight ?? 0
427+
const cssViewport = cssWidth > 0 && cssHeight > 0 ? { width: cssWidth, height: cssHeight } : null
416428
const scale =
417429
width > 0 && height > 0 ? Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)) : 1
418430

@@ -424,27 +436,28 @@ export async function captureScreenshot(
424436

425437
const targetWidth = Math.round(width * scale)
426438
const targetHeight = Math.round(height * scale)
427-
// Without layout metrics there is no CSS frame of reference to resize
428-
// against, so the raw capture is the honest answer — the same fallback the
429-
// clipped path took.
430-
if (targetWidth <= 0 || targetHeight <= 0) {
431-
return { dataUrl: captured, scale, viewport: cssViewport }
432-
}
433-
434439
const image = nativeImage.createFromBuffer(Buffer.from(result.data, 'base64'))
435440
const size = image.isEmpty() ? { width: 0, height: 0 } : image.getSize()
436441
if (size.width === 0 || size.height === 0) {
437-
return { dataUrl: captured, scale, viewport: cssViewport }
442+
return { dataUrl: captured, scale, viewport: cssViewport, imageSize: null }
443+
}
444+
// Without layout metrics there is no CSS frame of reference to resize
445+
// against, so the raw capture is the honest answer — the same fallback the
446+
// clipped path took. Its decoded size still lets the driver establish the
447+
// coordinate scale after obtaining the CSS viewport in-page.
448+
if (targetWidth <= 0 || targetHeight <= 0) {
449+
return { dataUrl: captured, scale, viewport: cssViewport, imageSize: size }
438450
}
439451
if (size.width === targetWidth && size.height === targetHeight) {
440-
return { dataUrl: captured, scale, viewport: cssViewport }
452+
return { dataUrl: captured, scale, viewport: cssViewport, imageSize: size }
441453
}
442454

443455
const resized = image.resize({ width: targetWidth, height: targetHeight, quality: 'good' })
444456
return {
445457
dataUrl: `data:image/jpeg;base64,${resized.toJPEG(SCREENSHOT_QUALITY).toString('base64')}`,
446458
scale,
447459
viewport: cssViewport,
460+
imageSize: { width: targetWidth, height: targetHeight },
448461
}
449462
}
450463

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

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
33

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

6-
import { BrowserWindow, Menu } from 'electron'
6+
import { BrowserWindow, Menu, nativeImage } from 'electron'
77
import * as cdp from '@/main/browser-agent/cdp'
88
import * as driverModule from '@/main/browser-agent/driver'
99
import * as session from '@/main/browser-agent/session'
@@ -1175,6 +1175,15 @@ describe('credential protection', () => {
11751175
.mock.calls.filter(([called]) => called === method)
11761176
}
11771177

1178+
function mockScreenshotImage(size: { width: number; height: number } | null): void {
1179+
vi.mocked(nativeImage.createFromBuffer).mockReturnValueOnce({
1180+
isEmpty: vi.fn(() => size === null),
1181+
getSize: vi.fn(() => size ?? { width: 0, height: 0 }),
1182+
resize: vi.fn(() => ({ toJPEG: vi.fn(() => Buffer.from('resized')) })),
1183+
toJPEG: vi.fn(() => Buffer.alloc(0)),
1184+
} as unknown as ReturnType<typeof nativeImage.createFromBuffer>)
1185+
}
1186+
11781187
it('refuses a keystroke while a password field holds focus', async () => {
11791188
const contents = await openPage()
11801189
respondWith(contents, { activeElementSecrecy: 'secret' })
@@ -2385,6 +2394,7 @@ describe('credential protection', () => {
23852394

23862395
it('returns the screenshot scale for coordinate mapping', async () => {
23872396
const contents = await openPage()
2397+
mockScreenshotImage({ width: 1024, height: 512 })
23882398
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
23892399
if (method === 'Page.getLayoutMetrics') {
23902400
return Promise.resolve({
@@ -2402,12 +2412,132 @@ describe('credential protection', () => {
24022412

24032413
expect(result).toMatchObject({
24042414
ok: true,
2405-
result: { scale: 0.5, viewport: { width: 2048, height: 1024 } },
2415+
result: {
2416+
scale: 0.5,
2417+
viewport: {
2418+
url: 'https://example.com/login',
2419+
title: 'Example',
2420+
width: 2048,
2421+
height: 1024,
2422+
},
2423+
},
24062424
})
24072425
expect(
24082426
vi
24092427
.mocked(contents.executeJavaScript)
24102428
.mock.calls.some(([expression]) => isPageCall(String(expression), 'getViewportInfo'))
24112429
).toBe(false)
24122430
})
2431+
2432+
it('uses the in-page CSS viewport when CDP exposes only deprecated device metrics', async () => {
2433+
const contents = await openPage()
2434+
mockScreenshotImage({ width: 1024, height: 512 })
2435+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
2436+
if (method === 'Page.getLayoutMetrics') {
2437+
return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } })
2438+
}
2439+
if (method === 'Page.captureScreenshot') {
2440+
return Promise.resolve({ data: 'c2lt' })
2441+
}
2442+
return Promise.resolve(undefined)
2443+
})
2444+
respondWith(contents, {
2445+
getViewportInfo: {
2446+
url: 'https://example.com/login',
2447+
title: 'Example',
2448+
width: 1024,
2449+
height: 512,
2450+
},
2451+
})
2452+
2453+
const result = await driver.executeTool('chat-test', 'browser_screenshot', {})
2454+
2455+
expect(result).toMatchObject({
2456+
ok: true,
2457+
result: {
2458+
scale: 1,
2459+
viewport: {
2460+
url: 'https://example.com/login',
2461+
title: 'Example',
2462+
width: 1024,
2463+
height: 512,
2464+
},
2465+
},
2466+
})
2467+
if (
2468+
!result.ok ||
2469+
typeof result.result !== 'object' ||
2470+
result.result === null ||
2471+
!('scale' in result.result) ||
2472+
typeof result.result.scale !== 'number'
2473+
) {
2474+
throw new Error('browser_screenshot did not return a numeric coordinate scale')
2475+
}
2476+
expect(1024 / result.result.scale).toBe(1024)
2477+
expect(
2478+
vi
2479+
.mocked(contents.executeJavaScript)
2480+
.mock.calls.some(([expression]) => isPageCall(String(expression), 'getViewportInfo'))
2481+
).toBe(true)
2482+
})
2483+
2484+
it('rejects an undecodable screenshot instead of returning an unverified scale', async () => {
2485+
const contents = await openPage()
2486+
mockScreenshotImage(null)
2487+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
2488+
if (method === 'Page.getLayoutMetrics') {
2489+
return Promise.resolve({
2490+
cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 },
2491+
})
2492+
}
2493+
if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' })
2494+
return Promise.resolve(undefined)
2495+
})
2496+
2497+
const result = await driver.executeTool('chat-test', 'browser_screenshot', {})
2498+
2499+
expect(result.ok).toBe(false)
2500+
expect(result.error).toMatch(/verify the screenshot dimensions/)
2501+
})
2502+
2503+
it('rejects a screenshot when no CSS viewport can be established', async () => {
2504+
const contents = await openPage()
2505+
mockScreenshotImage({ width: 1024, height: 512 })
2506+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
2507+
if (method === 'Page.getLayoutMetrics') {
2508+
return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } })
2509+
}
2510+
if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' })
2511+
return Promise.resolve(undefined)
2512+
})
2513+
respondWith(contents, { getViewportInfo: null })
2514+
2515+
const result = await driver.executeTool('chat-test', 'browser_screenshot', {})
2516+
2517+
expect(result.ok).toBe(false)
2518+
expect(result.error).toMatch(/verify the page viewport/)
2519+
})
2520+
2521+
it('rejects coordinate mapping when the viewport changes during capture', async () => {
2522+
const contents = await openPage()
2523+
mockScreenshotImage({ width: 1024, height: 256 })
2524+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
2525+
if (method === 'Page.getLayoutMetrics') return Promise.resolve({})
2526+
if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' })
2527+
return Promise.resolve(undefined)
2528+
})
2529+
respondWith(contents, {
2530+
getViewportInfo: {
2531+
url: 'https://example.com/login',
2532+
title: 'Example',
2533+
width: 1024,
2534+
height: 512,
2535+
},
2536+
})
2537+
2538+
const result = await driver.executeTool('chat-test', 'browser_screenshot', {})
2539+
2540+
expect(result.ok).toBe(false)
2541+
expect(result.error).toMatch(/viewport changed while the screenshot was captured/)
2542+
})
24132543
})

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

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2139,11 +2139,53 @@ async function executeToolInner(
21392139
'The screenshot result was too large to return safely. Use browser_snapshot or browser_read_text instead.'
21402140
)
21412141
}
2142-
const viewport =
2143-
shot.viewport ?? (await execInPage(contents, getViewportInfo, []).catch(() => null))
2142+
if (!shot.imageSize) {
2143+
throw new ToolError(
2144+
'Could not verify the screenshot dimensions. Retry browser_screenshot or use browser_snapshot instead.'
2145+
)
2146+
}
2147+
const viewport = shot.viewport
2148+
? {
2149+
url: contents.getURL().slice(0, 4096),
2150+
title: contents.getTitle().slice(0, 500),
2151+
...shot.viewport,
2152+
}
2153+
: await execInPage(contents, getViewportInfo, []).catch(() => null)
2154+
let scale = shot.scale
2155+
const viewportWidth =
2156+
isRecordLike(viewport) && typeof viewport.width === 'number' ? viewport.width : 0
2157+
const viewportHeight =
2158+
isRecordLike(viewport) && typeof viewport.height === 'number' ? viewport.height : 0
2159+
if (
2160+
!Number.isFinite(viewportWidth) ||
2161+
!Number.isFinite(viewportHeight) ||
2162+
viewportWidth <= 0 ||
2163+
viewportHeight <= 0
2164+
) {
2165+
throw new ToolError(
2166+
'Could not verify the page viewport for this screenshot. Retry browser_screenshot or use browser_snapshot instead.'
2167+
)
2168+
}
2169+
if (!shot.viewport) {
2170+
const widthScale = shot.imageSize.width / viewportWidth
2171+
const heightScale = shot.imageSize.height / viewportHeight
2172+
const scaleDelta = Math.abs(widthScale - heightScale)
2173+
if (
2174+
!Number.isFinite(widthScale) ||
2175+
!Number.isFinite(heightScale) ||
2176+
widthScale <= 0 ||
2177+
heightScale <= 0 ||
2178+
scaleDelta > Math.max(widthScale, heightScale) * 0.02
2179+
) {
2180+
throw new ToolError(
2181+
'The page viewport changed while the screenshot was captured. Retry browser_screenshot before using image coordinates.'
2182+
)
2183+
}
2184+
scale = widthScale
2185+
}
21442186
// scale maps image pixels back to CSS viewport pixels for the
21452187
// coordinate tools: cssX = imageX / scale.
2146-
return { dataUrl: shot.dataUrl, viewport, scale: shot.scale }
2188+
return { dataUrl: shot.dataUrl, viewport, scale }
21472189
}
21482190

21492191
case 'browser_extract': {

apps/sim/app/api/copilot/confirm/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,15 +263,15 @@ export const POST = withRouteHandler((req: NextRequest) => {
263263
)
264264
}
265265

266-
const isUnboundTerminalWorkflowOutcome =
266+
const isErrorOrCancelledOutcome =
267267
status === ASYNC_TOOL_CONFIRMATION_STATUS.error ||
268268
status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled
269269
const isNativeClientTool =
270270
isBrowserToolName(existing.toolName) || isTerminalToolName(existing.toolName)
271271
const isPreclaimNativeTerminalOutcome =
272272
isNativeClientTool &&
273273
existing.status === ASYNC_TOOL_STATUS.pending &&
274-
isUnboundTerminalWorkflowOutcome
274+
isErrorOrCancelledOutcome
275275
const isMutableClientToolCall = isWorkflowTool
276276
? isWorkflowToolExecutionClaimable(existing.status, existing.permissionDecision)
277277
: existing.status === ASYNC_TOOL_STATUS.running || isPreclaimNativeTerminalOutcome
@@ -315,7 +315,7 @@ export const POST = withRouteHandler((req: NextRequest) => {
315315
if (status !== ASYNC_TOOL_CONFIRMATION_STATUS.background) {
316316
if (trustedExecution) {
317317
effectiveStatus = getWorkflowToolConfirmationStatus(trustedExecution.status)
318-
} else if (!isUnboundTerminalWorkflowOutcome) {
318+
} else if (!isErrorOrCancelledOutcome) {
319319
span.setAttribute(
320320
TraceAttr.CopilotConfirmOutcome,
321321
CopilotConfirmOutcome.ToolCallNotFound
@@ -328,7 +328,7 @@ export const POST = withRouteHandler((req: NextRequest) => {
328328
} else if (trustedExecution) {
329329
executionId = trustedExecution.executionId
330330
effectiveStatus = getWorkflowToolConfirmationStatus(trustedExecution.status)
331-
} else if (!isUnboundTerminalWorkflowOutcome) {
331+
} else if (!isErrorOrCancelledOutcome) {
332332
effectiveStatus = ASYNC_TOOL_CONFIRMATION_STATUS.error
333333
executionId = undefined
334334
} else {

0 commit comments

Comments
 (0)