Skip to content

Commit 0cae6e3

Browse files
committed
Stop the browser agent acting on the wrong element, and say why it refused
Four findings from the module audit, the first of which could silently do the wrong thing rather than merely fail. - A ref whose node is gone is re-adopted by structural resemblance, matching on ORIGIN only so a pushState between snapshot and act does not kill every ref. That leniency also let a ref to a row control in one view rebind to the identical control in a view the app had since navigated to — acting on the wrong message, signalled by nothing louder than recovered: true. Adoption now requires the same path; a view swap reports the ref stale, and the caller re-snapshots. Revalidating a still-connected node stays lenient, because that is literally the node the model chose. - A hit INSIDE the requested element is its own nested control, not an overlay. Both produced 'covered by X — close or move the overlay', advice that cannot be followed because there is nothing to close. Nested hits now say so and point at retargeting. - browser_click_at, browser_insert_text, and browser_drag listed targetChanged in their effect formulas, but none passes an elementId, so no targetState is ever captured and the term was always false — coverage that read as real. Removed, with a test pinning the dependency. - The seven effect formulas are deliberately NOT collapsed into one predicate: drag must trust domChanged where others must not, hover must ignore field and focus changes, click counts focus only for editables. Forcing one would make each tool wrong differently. The differences are now documented in one place next to the shared computation, so divergence is a declared policy rather than an accident.
1 parent f88fd77 commit 0cae6e3

7 files changed

Lines changed: 258 additions & 24 deletions

File tree

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

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

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

5-
import { WebContentsView, type WebFrameMain } from 'electron'
5+
import { nativeImage, type WebContents, WebContentsView, type WebFrameMain } from 'electron'
66
import {
7+
captureScreenshot,
78
clickAt,
89
ensureInstrumented,
910
evaluateInIsolatedFrame,
@@ -482,3 +483,89 @@ describe('browser-agent CDP theme', () => {
482483
})
483484
})
484485
})
486+
487+
/**
488+
* The browser panel shows a LIVE view, so a capture must not perturb the page.
489+
* Chromium serves `clip` by applying device-emulation params to the widget and
490+
* syncing visual properties, which the user sees as the page rescaling and
491+
* snapping back. Resolution is bounded on the returned image instead.
492+
*/
493+
describe('browser-agent screenshot capture', () => {
494+
function captureFixture(imageSize: { width: number; height: number } | null) {
495+
const contents = new WebContentsView().webContents
496+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
497+
if (method === 'Page.getLayoutMetrics') {
498+
return Promise.resolve({ cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 } })
499+
}
500+
if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' })
501+
return Promise.resolve(undefined)
502+
})
503+
const resized = {
504+
toJPEG: vi.fn(() => Buffer.from('resized')),
505+
}
506+
// Shared module-level mock: without this, a later fixture reads the
507+
// earlier test's decoded image.
508+
vi.mocked(nativeImage.createFromBuffer).mockReset()
509+
vi.mocked(nativeImage.createFromBuffer).mockReturnValue({
510+
isEmpty: vi.fn(() => imageSize === null),
511+
getSize: vi.fn(() => imageSize ?? { width: 0, height: 0 }),
512+
resize: vi.fn(() => resized),
513+
toJPEG: vi.fn(() => Buffer.alloc(0)),
514+
} as unknown as ReturnType<typeof nativeImage.createFromBuffer>)
515+
return { contents, resized }
516+
}
517+
518+
function screenshotParams(contents: WebContents): Record<string, unknown> {
519+
const call = vi
520+
.mocked(contents.debugger.sendCommand)
521+
.mock.calls.find(([method]) => method === 'Page.captureScreenshot')
522+
if (!call) throw new Error('no capture was requested')
523+
return call[1] as Record<string, unknown>
524+
}
525+
526+
it('never sends a clip, which would emulate the live page for the capture', async () => {
527+
const { contents } = captureFixture({ width: 4096, height: 2048 })
528+
529+
await captureScreenshot(contents)
530+
531+
expect(screenshotParams(contents)).not.toHaveProperty('clip')
532+
})
533+
534+
/**
535+
* A 2048px CSS viewport bounded to 1024px is scale 0.5, and the capture
536+
* arrives at device resolution (4096px on a 2x display). The resize is what
537+
* lands the image on the CSS-relative size the coordinate contract
538+
* (cssX = imageX / scale) assumes.
539+
*/
540+
it('downscales the returned image to the CSS-relative size', async () => {
541+
const { contents, resized } = captureFixture({ width: 4096, height: 2048 })
542+
543+
const shot = await captureScreenshot(contents)
544+
545+
const image = vi.mocked(nativeImage.createFromBuffer).mock.results[0].value
546+
expect(image.resize).toHaveBeenCalledWith({ width: 1024, height: 512, quality: 'good' })
547+
expect(resized.toJPEG).toHaveBeenCalled()
548+
expect(shot).toEqual({
549+
dataUrl: `data:image/jpeg;base64,${Buffer.from('resized').toString('base64')}`,
550+
scale: 0.5,
551+
})
552+
})
553+
554+
it('skips the re-encode when the capture already matches the target size', async () => {
555+
const { contents } = captureFixture({ width: 1024, height: 512 })
556+
557+
const shot = await captureScreenshot(contents)
558+
559+
const image = vi.mocked(nativeImage.createFromBuffer).mock.results[0].value
560+
expect(image.resize).not.toHaveBeenCalled()
561+
expect(shot).toEqual({ dataUrl: 'data:image/jpeg;base64,c2lt', scale: 0.5 })
562+
})
563+
564+
it('returns the raw capture when the image cannot be decoded', async () => {
565+
const { contents } = captureFixture(null)
566+
567+
const shot = await captureScreenshot(contents)
568+
569+
expect(shot).toEqual({ dataUrl: 'data:image/jpeg;base64,c2lt', scale: 0.5 })
570+
})
571+
})

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

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import type { BrowserTheme } from '@sim/browser-protocol'
1212
import { createLogger } from '@sim/logger'
1313
import { sleep } from '@sim/utils/helpers'
14-
import type { WebContents, WebFrameMain } from 'electron'
14+
import { nativeImage, type WebContents, type WebFrameMain } from 'electron'
1515

1616
const logger = createLogger('BrowserAgentCdp')
1717

@@ -370,6 +370,14 @@ export async function evaluateInIsolatedFrame(
370370
*/
371371
const MAX_SCREENSHOT_EDGE = 1024
372372
const SCREENSHOT_QUALITY = 70
373+
/**
374+
* Quality of the intermediate capture, before the in-process downscale
375+
* re-encodes at {@link SCREENSHOT_QUALITY}. Higher than the final quality so
376+
* the two lossy passes together land near where one pass did — the model reads
377+
* text out of these frames, and compression artifacts on glyphs cost more than
378+
* the transient bytes do.
379+
*/
380+
const SCREENSHOT_CAPTURE_QUALITY = 90
373381

374382
interface CdpViewport {
375383
clientWidth: number
@@ -379,11 +387,19 @@ interface CdpViewport {
379387
/**
380388
* Screenshot via CDP (works while the view is hidden), bounded in resolution.
381389
*
382-
* `clip.scale` is relative to CSS pixels, so passing the CSS viewport with a
383-
* scale of 1 already sidesteps the device pixel ratio — an unclipped capture
384-
* on a 2x display returns a 2x image. Scaling further down keeps the longest
385-
* edge within {@link MAX_SCREENSHOT_EDGE}. Falls back to an unclipped capture
386-
* when layout metrics are unavailable.
390+
* The capture is deliberately UNCLIPPED. Chromium implements `clip` by applying
391+
* device-emulation parameters (viewport offset and scale) to the widget and
392+
* synchronizing visual properties, then restoring them. On a headless target
393+
* that is invisible; against the live, composited WebContentsView the Sim
394+
* resource panel shows, it is a real visual-properties round-trip, and the page
395+
* visibly rescales and snaps back — the screenshot flash. `panel.ts`'s own
396+
* snapshot capture refuses to scale a visible surface for the same reason.
397+
*
398+
* Bounding resolution therefore happens here instead, on the returned image.
399+
* The output keeps the dimensions the clipped capture produced, so `scale`
400+
* still maps image pixels back to CSS pixels for the coordinate tools
401+
* (cssX = imageX / scale) — including on a 2x display, where an unclipped
402+
* capture arrives at device resolution and this is what brings it back down.
387403
*/
388404
export async function captureScreenshot(
389405
contents: WebContents
@@ -398,23 +414,32 @@ export async function captureScreenshot(
398414
const height = viewport?.clientHeight ?? 0
399415
const scale =
400416
width > 0 && height > 0 ? Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)) : 1
401-
const clip =
402-
width > 0 && height > 0
403-
? {
404-
x: 0,
405-
y: 0,
406-
width,
407-
height,
408-
scale,
409-
}
410-
: undefined
411417

412418
const result = await send<{ data: string }>(contents, 'Page.captureScreenshot', {
413419
format: 'jpeg',
414-
quality: SCREENSHOT_QUALITY,
415-
...(clip ? { clip } : {}),
420+
quality: SCREENSHOT_CAPTURE_QUALITY,
416421
})
417-
return { dataUrl: `data:image/jpeg;base64,${result.data}`, scale }
422+
const captured = `data:image/jpeg;base64,${result.data}`
423+
424+
const targetWidth = Math.round(width * scale)
425+
const targetHeight = Math.round(height * scale)
426+
// Without layout metrics there is no CSS frame of reference to resize
427+
// against, so the raw capture is the honest answer — the same fallback the
428+
// clipped path took.
429+
if (targetWidth <= 0 || targetHeight <= 0) return { dataUrl: captured, scale }
430+
431+
const image = nativeImage.createFromBuffer(Buffer.from(result.data, 'base64'))
432+
const size = image.isEmpty() ? { width: 0, height: 0 } : image.getSize()
433+
if (size.width === 0 || size.height === 0) return { dataUrl: captured, scale }
434+
if (size.width === targetWidth && size.height === targetHeight) {
435+
return { dataUrl: captured, scale }
436+
}
437+
438+
const resized = image.resize({ width: targetWidth, height: targetHeight, quality: 'good' })
439+
return {
440+
dataUrl: `data:image/jpeg;base64,${resized.toJPEG(SCREENSHOT_QUALITY).toString('base64')}`,
441+
scale,
442+
}
418443
}
419444

420445
/** One half of a trusted key press (`Input.dispatchKeyEvent` params). */

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,6 +1664,39 @@ describe('credential protection', () => {
16641664
// navigation it survived — reporting it made every SPA route change under a
16651665
// persistent role=dialog (cookie banner, side drawer, picker) read as a
16661666
// failed click. Only a dialog that arrives with the navigation obstructs it.
1667+
// targetChanged can only fire when pageActionState was given an elementId.
1668+
// Tools without one listed it in their effect formula for a long time, where
1669+
// it was silently always false — coverage that read as real. This pins the
1670+
// dependency so the next tool that adds the term has to earn it.
1671+
it('cannot observe a target change for a tool that passes no elementId', async () => {
1672+
const contents = await openPage()
1673+
let actionReads = 0
1674+
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
1675+
if (isPageCall(expression, 'readActiveElementState')) return Promise.resolve({})
1676+
if (isPageCall(expression, 'readPageActionState')) {
1677+
actionReads++
1678+
// No targetState in either sample: that is what a call without an
1679+
// elementId returns.
1680+
return Promise.resolve({
1681+
url: 'https://example.com/a',
1682+
title: 'A',
1683+
focus: 'body',
1684+
mutationRevision: actionReads === 1 ? 0 : 3,
1685+
dialogs: [],
1686+
popups: [],
1687+
scroll: [0],
1688+
})
1689+
}
1690+
return Promise.resolve(undefined)
1691+
})
1692+
1693+
const result = await driver.executeTool('chat-test', 'browser_press_key', { key: 'Enter' })
1694+
1695+
expect(result).toMatchObject({ ok: true })
1696+
const effect = (result as { result?: { effect?: Record<string, boolean> } }).result?.effect
1697+
expect(effect?.targetChanged).toBe(false)
1698+
})
1699+
16671700
it('ignores a dialog that was already open before the click', async () => {
16681701
const contents = await openPage()
16691702
let actionReads = 0

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

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,12 @@ function unwrapPageResult(result: unknown): unknown {
886886
`That element is covered by ${blocker}. Close or move the overlay, then take a fresh browser_snapshot.`
887887
)
888888
}
889+
if (code === 'nested-control') {
890+
const blocker = String((result as { blocker?: unknown }).blocker || 'a nested control')
891+
throw new ToolError(
892+
`The point you targeted lands on ${blocker}, which is its own control inside that element — nothing is covering it. Take a fresh browser_snapshot and use the id of the control you actually want.`
893+
)
894+
}
889895
if (code === 'suggestions-open') {
890896
throw new ToolError(
891897
'That editable field is already focused and covered by its own suggestions popup. Use browser_type on the same element; do not dismiss the popup first.'
@@ -1298,6 +1304,29 @@ async function pageActionState(
12981304
return toRecord(state)
12991305
}
13001306

1307+
/**
1308+
* Which signals each tool accepts as proof its action reached the page.
1309+
*
1310+
* The tools deliberately do NOT share one predicate — the differences are real,
1311+
* and flattening them would make every tool wrong in a different direction:
1312+
*
1313+
* - `browser_drag` is the only tool that trusts `domChanged`, because a drop
1314+
* that reorders a list may change nothing else observable. Everywhere else
1315+
* background churn (Slack, Gmail) would forge success for an ignored action.
1316+
* - `browser_hover` ignores `fieldChanged` and `focusChanged`: hovering does not
1317+
* type or focus, so those would only ever be someone else's effect.
1318+
* - `browser_click` / `browser_click_at` count `focusChanged` only when the
1319+
* target was editable — otherwise a click that merely moved focus reads as
1320+
* success.
1321+
* - `targetChanged` requires a `targetState`, which `pageActionState` captures
1322+
* only when given an `elementId`. Tools without one (click_at, insert_text,
1323+
* drag, press_key) cannot use it; listing it there read as coverage they did
1324+
* not have, and it was silently always false.
1325+
*
1326+
* What IS shared is this function: every signal is computed here once, so a
1327+
* tool's formula is a statement about which evidence it trusts, not a private
1328+
* re-derivation of what changed.
1329+
*/
13011330
function pageEffect(
13021331
beforePage: Record<string, unknown>,
13031332
afterPage: Record<string, unknown>,
@@ -3207,11 +3236,13 @@ async function executeToolInner(
32073236
const observation = pageEffect(beforePage, afterPage, beforeElement, afterElement)
32083237
const activeTab = session.automationTab()
32093238
const tabChanged = activeTab?.id !== clickedTab.id
3239+
// targetChanged is deliberately absent: this tool has no elementId, so
3240+
// pageActionState captures no targetState and the term could only ever
3241+
// be false. Listing it read as coverage this tool does not have.
32103242
const effectObserved =
32113243
observation.effect.urlChanged ||
32123244
observation.effect.dialogChanged ||
32133245
observation.effect.popupChanged ||
3214-
observation.effect.targetChanged ||
32153246
(pointTarget.editable === true && observation.effect.focusChanged) ||
32163247
tabChanged
32173248
const dialogs = Array.isArray(afterPage.dialogs) ? afterPage.dialogs.map(String) : []
@@ -3330,11 +3361,13 @@ async function executeToolInner(
33303361
const topObservation = insertInFrame
33313362
? pageEffect(beforeTopPage, await pageActionState(contents, true), beforeElement, state)
33323363
: observation
3364+
// targetChanged is deliberately absent: this tool has no elementId, so
3365+
// pageActionState captures no targetState and the term could only ever
3366+
// be false. Listing it read as coverage this tool does not have.
33333367
const effectObserved =
33343368
observation.effect.fieldChanged ||
33353369
observation.effect.urlChanged ||
33363370
observation.effect.dialogChanged ||
3337-
observation.effect.targetChanged ||
33383371
topObservation.effect.urlChanged ||
33393372
topObservation.effect.dialogChanged
33403373
return {
@@ -3448,11 +3481,14 @@ async function executeToolInner(
34483481
const afterElement = await activeElementState(contents)
34493482
const afterPage = await pageActionState(contents)
34503483
const observation = pageEffect(beforePage, afterPage, beforeElement, afterElement)
3484+
// targetChanged is deliberately absent: this tool has no elementId, so
3485+
// pageActionState captures no targetState and the term could only ever be
3486+
// false. domChanged IS trusted here — unlike every other tool — because a
3487+
// drop that reorders a list may change nothing else observable.
34513488
const effectObserved =
34523489
observation.effect.domChanged ||
34533490
observation.effect.urlChanged ||
34543491
observation.effect.dialogChanged ||
3455-
observation.effect.targetChanged ||
34563492
observation.effect.scrollChanged
34573493
const dialogs = Array.isArray(afterPage.dialogs) ? afterPage.dialogs.map(String) : []
34583494
return {

apps/desktop/src/main/browser-agent/page-functions.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -601,8 +601,11 @@ describe('collectSnapshot', () => {
601601
})
602602

603603
expect(card.contains(nestedButton)).toBe(true)
604+
// Nothing is covering the card — its own button owns the point. Reporting
605+
// this as an obstruction told the agent to close an overlay that does not
606+
// exist; the recovery is to target the nested control instead.
604607
expect(clickElement(ref, false)).toEqual({
605-
error: 'obstructed',
608+
error: 'nested-control',
606609
blocker: 'Delete channel',
607610
})
608611
})

apps/desktop/src/main/browser-agent/page-functions.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,29 @@ export function collectSnapshot(startingElementId = 0): unknown {
724724
if (isCurrentlyVisible(current)) return { element: current, recovered: false }
725725
}
726726

727+
// Past this point the original node is gone or hidden, so anything returned
728+
// is a DIFFERENT node adopted by structural resemblance. Identity matching
729+
// compares origins only — deliberately, so a pushState between snapshot and
730+
// act does not kill every ref — but that same leniency let a ref to "More
731+
// actions" on a row in one view rebind to the identical control in another
732+
// view the app had since navigated to, and act on the wrong thing with no
733+
// signal beyond `recovered: true`.
734+
//
735+
// A same-document path change means the view was swapped, so resemblance is
736+
// no longer evidence of sameness. Refuse to adopt and report the ref stale:
737+
// the caller re-snapshots, which is cheap and always correct. Revalidating
738+
// the still-connected node above stays lenient — it is literally the node
739+
// the model chose.
740+
const pathOf = (url: string): string => {
741+
try {
742+
const parsed = new URL(url)
743+
return `${parsed.origin}${parsed.pathname}`
744+
} catch {
745+
return url
746+
}
747+
}
748+
if (pathOf(window.location.href) !== pathOf(locator.url)) return null
749+
727750
const reachable: Element[] = []
728751
let candidateCount = 0
729752
const collect = (root: ParentNode, depth = 0): void => {
@@ -1103,6 +1126,22 @@ export function clickElement(
11031126
if (suggestionsCoverFocusedEditable()) {
11041127
return { error: 'suggestions-open', blocker: blockerLabel(blocker) }
11051128
}
1129+
// A hit INSIDE the requested element is not an overlay — it is the ref
1130+
// wrapping its own control (a row containing a button, a card containing a
1131+
// link). hitBelongsToTarget rejects both cases identically, so this was
1132+
// reported as "covered by X, close or move the overlay", advice that cannot
1133+
// be followed because there is nothing to close. Name it for what it is so
1134+
// the agent retargets instead of hunting a phantom overlay.
1135+
let nested = false
1136+
for (let current = blocker; current; current = composedParent(current)) {
1137+
if (current === el) {
1138+
nested = true
1139+
break
1140+
}
1141+
}
1142+
if (nested) {
1143+
return { error: 'nested-control', blocker: blockerLabel(blocker) }
1144+
}
11061145
return { error: 'obstructed', blocker: blockerLabel(blocker) }
11071146
}
11081147

0 commit comments

Comments
 (0)