Skip to content

Commit e8208e3

Browse files
committed
fix(browser): harden desktop tool lifecycle
1 parent 5bb8977 commit e8208e3

25 files changed

Lines changed: 1274 additions & 239 deletions

File tree

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,7 @@ describe('browser-agent screenshot capture', () => {
548548
expect(shot).toEqual({
549549
dataUrl: `data:image/jpeg;base64,${Buffer.from('resized').toString('base64')}`,
550550
scale: 0.5,
551+
viewport: { width: 2048, height: 1024 },
551552
})
552553
})
553554

@@ -558,14 +559,22 @@ describe('browser-agent screenshot capture', () => {
558559

559560
const image = vi.mocked(nativeImage.createFromBuffer).mock.results[0].value
560561
expect(image.resize).not.toHaveBeenCalled()
561-
expect(shot).toEqual({ dataUrl: 'data:image/jpeg;base64,c2lt', scale: 0.5 })
562+
expect(shot).toEqual({
563+
dataUrl: 'data:image/jpeg;base64,c2lt',
564+
scale: 0.5,
565+
viewport: { width: 2048, height: 1024 },
566+
})
562567
})
563568

564569
it('returns the raw capture when the image cannot be decoded', async () => {
565570
const { contents } = captureFixture(null)
566571

567572
const shot = await captureScreenshot(contents)
568573

569-
expect(shot).toEqual({ dataUrl: 'data:image/jpeg;base64,c2lt', scale: 0.5 })
574+
expect(shot).toEqual({
575+
dataUrl: 'data:image/jpeg;base64,c2lt',
576+
scale: 0.5,
577+
viewport: { width: 2048, height: 1024 },
578+
})
570579
})
571580
})

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ interface CdpViewport {
403403
*/
404404
export async function captureScreenshot(
405405
contents: WebContents
406-
): Promise<{ dataUrl: string; scale: number }> {
406+
): Promise<{ dataUrl: string; scale: number; viewport: { width: number; height: number } | null }> {
407407
const metrics = await send<{
408408
cssLayoutViewport?: CdpViewport
409409
layoutViewport?: CdpViewport
@@ -412,6 +412,7 @@ export async function captureScreenshot(
412412
const viewport = metrics?.cssLayoutViewport ?? metrics?.layoutViewport
413413
const width = viewport?.clientWidth ?? 0
414414
const height = viewport?.clientHeight ?? 0
415+
const cssViewport = width > 0 && height > 0 ? { width, height } : null
415416
const scale =
416417
width > 0 && height > 0 ? Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)) : 1
417418

@@ -426,19 +427,24 @@ export async function captureScreenshot(
426427
// Without layout metrics there is no CSS frame of reference to resize
427428
// against, so the raw capture is the honest answer — the same fallback the
428429
// clipped path took.
429-
if (targetWidth <= 0 || targetHeight <= 0) return { dataUrl: captured, scale }
430+
if (targetWidth <= 0 || targetHeight <= 0) {
431+
return { dataUrl: captured, scale, viewport: cssViewport }
432+
}
430433

431434
const image = nativeImage.createFromBuffer(Buffer.from(result.data, 'base64'))
432435
const size = image.isEmpty() ? { width: 0, height: 0 } : image.getSize()
433-
if (size.width === 0 || size.height === 0) return { dataUrl: captured, scale }
436+
if (size.width === 0 || size.height === 0) {
437+
return { dataUrl: captured, scale, viewport: cssViewport }
438+
}
434439
if (size.width === targetWidth && size.height === targetHeight) {
435-
return { dataUrl: captured, scale }
440+
return { dataUrl: captured, scale, viewport: cssViewport }
436441
}
437442

438443
const resized = image.resize({ width: targetWidth, height: targetHeight, quality: 'good' })
439444
return {
440445
dataUrl: `data:image/jpeg;base64,${resized.toJPEG(SCREENSHOT_QUALITY).toString('base64')}`,
441446
scale,
447+
viewport: cssViewport,
442448
}
443449
}
444450

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

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,22 @@ describe('executeTool', () => {
10881088
})
10891089
})
10901090

1091+
describe('browserToolWatchdogMs', () => {
1092+
it.each([
1093+
['number', 30_000, 35_000],
1094+
['numeric string', '30000', 35_000],
1095+
['absent', undefined, 15_000],
1096+
['non-numeric', 'soon', 15_000],
1097+
['zero', 0, 15_000],
1098+
['negative', -5_000, 15_000],
1099+
['above the wait clamp', 500_000, 125_000],
1100+
])('normalizes browser_wait_for timeout (%s)', (_label, timeoutMs, expected) => {
1101+
const params = timeoutMs === undefined ? {} : { timeoutMs }
1102+
1103+
expect(driverModule.browserToolWatchdogMs('browser_wait_for', params)).toBe(expected)
1104+
})
1105+
})
1106+
10911107
/**
10921108
* Trusted CDP input never enters the page, so a focused credential field can
10931109
* only be ruled out in the driver. These cover that seam; the page-side
@@ -1167,6 +1183,8 @@ describe('credential protection', () => {
11671183

11681184
expect(result.ok).toBe(false)
11691185
expect(result.error).toMatch(/Refusing to act on a password field/)
1186+
expect(result.error).toMatch(/visible browser/)
1187+
expect(result.error).not.toContain('browser_request_takeover')
11701188
expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(0)
11711189
})
11721190

@@ -1532,6 +1550,24 @@ describe('credential protection', () => {
15321550
})
15331551
})
15341552

1553+
it('rejects an unsupported browser_scroll direction instead of treating it as down', async () => {
1554+
const contents = await openPage()
1555+
1556+
const result = await driver.executeTool('chat-test', 'browser_scroll', {
1557+
direction: 'sideways',
1558+
})
1559+
1560+
expect(result).toMatchObject({
1561+
ok: false,
1562+
error: 'Scroll direction must be "up" or "down".',
1563+
})
1564+
expect(
1565+
vi
1566+
.mocked(contents.executeJavaScript)
1567+
.mock.calls.some(([expression]) => isPageCall(String(expression), 'scrollPage'))
1568+
).toBe(false)
1569+
})
1570+
15351571
it('confirms a click when the requested target changes semantic state', async () => {
15361572
const contents = await openPage()
15371573
let actionReads = 0
@@ -2179,6 +2215,83 @@ describe('credential protection', () => {
21792215
expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(1)
21802216
})
21812217

2218+
it('reports top-page effects observed after inserting text in a child frame', async () => {
2219+
const contents = await openPage()
2220+
const mainFrame = {
2221+
frameTreeNodeId: 1,
2222+
detached: false,
2223+
isDestroyed: vi.fn(() => false),
2224+
origin: 'https://example.com',
2225+
parent: null,
2226+
framesInSubtree: [] as unknown[],
2227+
}
2228+
const childFrame = {
2229+
frameTreeNodeId: 2,
2230+
detached: false,
2231+
isDestroyed: vi.fn(() => false),
2232+
origin: 'https://mail-widget.example',
2233+
parent: mainFrame,
2234+
url: 'https://mail-widget.example/compose',
2235+
}
2236+
mainFrame.framesInSubtree = [mainFrame, childFrame]
2237+
Object.defineProperty(contents, 'mainFrame', { configurable: true, value: mainFrame })
2238+
Object.defineProperty(contents, 'focusedFrame', { configurable: true, value: childFrame })
2239+
let topPageReads = 0
2240+
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
2241+
if (isPageCall(expression, 'readPageActionState')) {
2242+
topPageReads++
2243+
return Promise.resolve({
2244+
url:
2245+
topPageReads === 1 ? 'https://example.com/compose' : 'https://example.com/message/sent',
2246+
title: 'Mail',
2247+
focus: 'iframe',
2248+
mutationRevision: topPageReads,
2249+
dialogs: [],
2250+
scroll: [0],
2251+
})
2252+
}
2253+
return Promise.resolve(undefined)
2254+
})
2255+
const isolatedFrameEval = vi
2256+
.spyOn(cdp, 'evaluateInIsolatedFrame')
2257+
.mockImplementation((_contents, _frame, expression) => {
2258+
if (isPageCall(expression, 'activeElementSecrecy')) return Promise.resolve('safe')
2259+
if (isPageCall(expression, 'describeFocusedEditable')) {
2260+
return Promise.resolve({ editable: true, kind: 'input' })
2261+
}
2262+
if (isPageCall(expression, 'readActiveElementState')) {
2263+
return Promise.resolve({ activeElement: 'input', valueLength: 4 })
2264+
}
2265+
if (isPageCall(expression, 'readPageActionState')) {
2266+
return Promise.resolve({
2267+
url: 'https://mail-widget.example/compose',
2268+
title: 'Compose',
2269+
focus: 'input',
2270+
mutationRevision: 0,
2271+
dialogs: [],
2272+
scroll: [0],
2273+
})
2274+
}
2275+
return Promise.resolve(undefined)
2276+
})
2277+
2278+
try {
2279+
const result = await driver.executeTool('chat-test', 'browser_insert_text', { text: 'sent' })
2280+
2281+
expect(result.ok, result.error).toBe(true)
2282+
expect(result).toMatchObject({
2283+
ok: true,
2284+
result: {
2285+
effectObserved: true,
2286+
possibleEffectObserved: true,
2287+
effect: { urlChanged: true },
2288+
},
2289+
})
2290+
} finally {
2291+
isolatedFrameEval.mockRestore()
2292+
}
2293+
})
2294+
21822295
it('refuses insertion when nothing editable holds focus', async () => {
21832296
const contents = await openPage()
21842297
respondWith(contents, {
@@ -2287,6 +2400,14 @@ describe('credential protection', () => {
22872400

22882401
const result = await driver.executeTool('chat-test', 'browser_screenshot', {})
22892402

2290-
expect(result).toMatchObject({ ok: true, result: { scale: 0.5 } })
2403+
expect(result).toMatchObject({
2404+
ok: true,
2405+
result: { scale: 0.5, viewport: { width: 2048, height: 1024 } },
2406+
})
2407+
expect(
2408+
vi
2409+
.mocked(contents.executeJavaScript)
2410+
.mock.calls.some(([expression]) => isPageCall(String(expression), 'getViewportInfo'))
2411+
).toBe(false)
22912412
})
22922413
})

0 commit comments

Comments
 (0)