Skip to content

Commit 2a54297

Browse files
committed
feat(browser): file transfer, popups, PDFs, click gestures, and dialog answers for the browser agent
- Upload workspace, chat-upload, or granted local files into a page's file input (DOM.setFileInputFiles on the input a ref, label, or drop zone resolves to), staged privately and bound to the claimed tool call; save completed downloads into workspace files. New /api/desktop/tool/file route with application use cases. - Same-session popups adopt Chromium's WebContents so window.opener works (sign-in and connect flows); a page that closes itself leaves cleanly and agent work returns to its opener. - PDFs render: enable Electron's internal PDF plugin and let its packaged viewer resources past the network guard. - Clicks take button, clickCount, and modifiers; agent right-clicks never open the native menu. Actions answer confirm/alert dialogs via dialog: {accept}. press_key gains F1-F12, Insert, and repeat; scroll and hover accept post-action observation.
1 parent 4d8f61a commit 2a54297

34 files changed

Lines changed: 2751 additions & 210 deletions

‎apps/desktop/e2e/browser-tools.spec.ts‎

Lines changed: 164 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,27 @@ const CLICK_FIXTURE = `<!doctype html><title>Click fixture</title>
5252
});
5353
</script>`
5454

55+
const CAPABILITIES_FIXTURE = `<!doctype html><title>Capabilities fixture</title>
56+
<button id="delete" onclick="document.body.dataset.deleted = confirm('Delete the report?')">Delete report</button>
57+
<div id="row" oncontextmenu="event.preventDefault(); document.body.dataset.menu = event.button + ':' + event.shiftKey">Report row</div>
58+
<div id="zone" role="button" aria-label="Attach receipt">Drop a receipt<input id="receipt" type="file" hidden onchange="this.files[0].text().then(text => document.body.dataset.upload = this.files[0].name + ':' + text)"></div>
59+
<button onclick="window.open(new URLSearchParams(location.search).get('popup'), 'auth', 'width=400,height=400')">Connect</button>
60+
<script>addEventListener('message', (event) => { document.body.dataset.connected = event.data })</script>`
61+
62+
const POPUP_FIXTURE = `<!doctype html><title>Authorize fixture</title>
63+
<button onclick="window.opener.postMessage('granted', '*'); window.close()">Allow</button>`
64+
5565
test.describe('browser tools', () => {
5666
const calls = new Map<
5767
string,
5868
{ chatId: string; toolName: BrowserToolName | 'terminal'; args: Record<string, unknown> }
5969
>()
70+
const claimed = new Map<string, { args: Record<string, unknown> }>()
6071
let server: Server
72+
let popupServer: Server
6173
let origin: string
74+
let site: string
75+
let popupOrigin: string
6276
let app: ElectronApplication
6377
let window: Page
6478
let callCount = 0
@@ -109,11 +123,32 @@ test.describe('browser tools', () => {
109123
)
110124
return
111125
}
126+
if (path === '/api/desktop/tool/file') {
127+
let body = ''
128+
for await (const chunk of request) body += chunk.toString()
129+
const { toolCallId, index } = JSON.parse(body)
130+
const reference = claimed.get(toolCallId)?.args.paths
131+
const found = Array.isArray(reference) && reference[index] === 'files/receipt.txt'
132+
response.writeHead(found ? 200 : 404, {
133+
'Content-Type': found ? 'application/octet-stream' : 'application/json',
134+
...(found ? { 'Content-Disposition': 'attachment; filename="receipt.txt"' } : {}),
135+
})
136+
response.end(found ? 'receipt-bytes' : JSON.stringify({ error: 'File not found' }))
137+
return
138+
}
139+
if (path === '/doc.pdf') {
140+
response.writeHead(200, { 'Content-Type': 'application/pdf' })
141+
response.end(
142+
'%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n'
143+
)
144+
return
145+
}
112146
if (path === '/api/desktop/tool/authorize') {
113147
let body = ''
114148
for await (const chunk of request) body += chunk.toString()
115149
const authorization = calls.get(JSON.parse(body).toolCallId)
116150
calls.delete(JSON.parse(body).toolCallId)
151+
if (authorization) claimed.set(JSON.parse(body).toolCallId, authorization)
117152
response.writeHead(authorization ? 200 : 403, { 'Content-Type': 'application/json' })
118153
response.end(JSON.stringify(authorization ?? {}))
119154
return
@@ -127,15 +162,27 @@ test.describe('browser tools', () => {
127162
response.end(
128163
path === '/click'
129164
? CLICK_FIXTURE
130-
: path === '/form'
131-
? FORM
132-
: '<!doctype html><title>Sim fixture</title><h1>Browser tools fixture</h1>'
165+
: path === '/capabilities'
166+
? CAPABILITIES_FIXTURE
167+
: path === '/form'
168+
? FORM
169+
: '<!doctype html><title>Sim fixture</title><h1>Browser tools fixture</h1>'
133170
)
134171
})
135172
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
136173
const address = server.address()
137174
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
138175
origin = `http://127.0.0.1:${address.port}`
176+
/** Pages outside the app origin browse in the agent partition, like any third-party site. */
177+
site = origin.replace('127.0.0.1', 'localhost')
178+
popupServer = createServer((_request, response) => {
179+
response.writeHead(200, { 'Content-Type': 'text/html' })
180+
response.end(POPUP_FIXTURE)
181+
})
182+
await new Promise<void>((resolve) => popupServer.listen(0, '127.0.0.1', resolve))
183+
const popupAddress = popupServer.address()
184+
if (!popupAddress || typeof popupAddress === 'string') throw new Error('Missing popup address')
185+
popupOrigin = `http://localhost:${popupAddress.port}`
139186
})
140187

141188
test.beforeEach(async () => {
@@ -148,6 +195,11 @@ test.describe('browser tools', () => {
148195
SIM_DESKTOP_USER_DATA: mkdtempSync(join(tmpdir(), 'sim-browser-tools-e2e-')),
149196
},
150197
})
198+
// A dialog listener stops Playwright auto-dismissing page dialogs, so the desktop's own CDP
199+
// dialog handling decides their outcome exactly as it does in production.
200+
const leaveDialogsToDesktop = (page: Page) => page.on('dialog', () => {})
201+
app.context().pages().forEach(leaveDialogsToDesktop)
202+
app.context().on('page', leaveDialogsToDesktop)
151203
window = await app.firstWindow()
152204
await app.evaluate(({ app, BrowserWindow }) => {
153205
const host = BrowserWindow.getAllWindows()[0]
@@ -176,12 +228,15 @@ test.describe('browser tools', () => {
176228
test.afterEach(async () => {
177229
await app?.close()
178230
calls.clear()
231+
claimed.clear()
179232
})
180233

181234
test.afterAll(async () => {
182-
await new Promise<void>((resolve, reject) =>
183-
server.close((error) => (error ? reject(error) : resolve()))
184-
)
235+
for (const listener of [server, popupServer]) {
236+
await new Promise<void>((resolve, reject) =>
237+
listener.close((error) => (error ? reject(error) : resolve()))
238+
)
239+
}
185240
})
186241

187242
async function execute(tool: BrowserToolName, args: Record<string, unknown>) {
@@ -211,6 +266,109 @@ test.describe('browser tools', () => {
211266
}
212267
}
213268

269+
async function openCapabilities() {
270+
const url = `${site}/capabilities?popup=${encodeURIComponent(`${popupOrigin}/authorize`)}`
271+
const response = await execute('browser_open_url', { url })
272+
expect(response.ok, response.error).toBe(true)
273+
const outline = (response.result as { snapshot: { outline: string } }).snapshot.outline
274+
const ref = (name: string) => {
275+
const match = outline
276+
.split('\n')
277+
.find((line) => line.includes(`"${name}"`) && /\[ref=\d+\]/.test(line))
278+
?.match(/\[ref=(\d+)\]/)
279+
if (!match) throw new Error(`No reference for ${name}: ${outline}`)
280+
return Number(match[1])
281+
}
282+
const dataset = () =>
283+
app.evaluate(
284+
({ webContents }, url) =>
285+
webContents
286+
.getAllWebContents()
287+
.find((contents) => contents.getURL() === url)
288+
?.executeJavaScript('({ ...document.body.dataset })'),
289+
url
290+
)
291+
return { ref, dataset }
292+
}
293+
294+
test('answers confirm dialogs only when the action asks and right-clicks reach the page', async () => {
295+
const { ref, dataset } = await openCapabilities()
296+
297+
const dismissed = await execute('browser_click', { elementId: ref('Delete report') })
298+
expect(JSON.stringify(dismissed.result)).toContain('which was dismissed')
299+
expect(await dataset()).toMatchObject({ deleted: 'false' })
300+
301+
const accepted = await execute('browser_click', {
302+
elementId: ref('Delete report'),
303+
dialog: { accept: true },
304+
})
305+
expect(JSON.stringify(accepted.result)).toContain('accepted as requested')
306+
expect(await dataset()).toMatchObject({ deleted: 'true' })
307+
308+
const menu = await execute('browser_click', {
309+
elementId: ref('Report row'),
310+
button: 'right',
311+
modifiers: ['Shift'],
312+
})
313+
expect(menu.ok, menu.error).toBe(true)
314+
expect(await dataset()).toMatchObject({ menu: '2:true' })
315+
})
316+
317+
test('uploads a workspace file into the hidden input behind a drop zone', async () => {
318+
const { ref, dataset } = await openCapabilities()
319+
320+
const upload = await execute('browser_upload_file', {
321+
elementId: ref('Attach receipt'),
322+
paths: ['files/receipt.txt'],
323+
})
324+
325+
expect(upload.ok, upload.error).toBe(true)
326+
expect(upload.result).toMatchObject({
327+
uploaded: [{ name: 'receipt.txt', size: 13 }],
328+
effectObserved: true,
329+
})
330+
await expect.poll(dataset).toMatchObject({ upload: 'receipt.txt:receipt-bytes' })
331+
})
332+
333+
test('keeps window.opener for page popups and returns to the opener when they close', async () => {
334+
const { ref, dataset } = await openCapabilities()
335+
336+
expect((await execute('browser_click', { elementId: ref('Connect') })).ok).toBe(true)
337+
const popup = await execute('browser_snapshot', {})
338+
const allow = (popup.result as { outline: string }).outline.match(
339+
/button "Allow" \[ref=(\d+)\]/
340+
)?.[1]
341+
expect(allow, JSON.stringify(popup.result)).toBeTruthy()
342+
expect((await execute('browser_click', { elementId: Number(allow) })).ok).toBe(true)
343+
344+
await expect.poll(dataset).toMatchObject({ connected: 'granted' })
345+
await expect
346+
.poll(
347+
async () => ((await execute('browser_list_tabs', {})).result as { tabs: unknown[] }).tabs
348+
)
349+
.toHaveLength(1)
350+
})
351+
352+
test('renders PDFs in the built-in viewer', async () => {
353+
const response = await execute('browser_open_url', { url: `${site}/doc.pdf` })
354+
expect(response.ok, response.error).toBe(true)
355+
356+
await expect
357+
.poll(() =>
358+
app.evaluate(
359+
({ webContents }, url) =>
360+
webContents
361+
.getAllWebContents()
362+
.find((contents) => contents.getURL() === url)
363+
?.mainFrame.framesInSubtree.some((frame) =>
364+
frame.url.startsWith('chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/')
365+
),
366+
`${site}/doc.pdf`
367+
)
368+
)
369+
.toBe(true)
370+
})
371+
214372
test('shares desktop authentication for private HTML previews and deployed chats', async () => {
215373
for (const path of ['/private-preview', '/private-chat']) {
216374
const result = await execute('browser_open_url', { url: `${origin}${path}` })

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

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ describe('browser-agent CDP instrumentation', () => {
5959
it('leaves file chooser dialogs native so users can upload files', async () => {
6060
const contents = new WebContentsView().webContents
6161

62-
await ensureInstrumented(contents, { onDialog: vi.fn() })
62+
await ensureInstrumented(contents, { onDialog: vi.fn(), dialogResponse: () => null })
6363

6464
expect(contents.debugger.sendCommand).toHaveBeenCalledWith('Page.enable', undefined)
6565
expect(contents.debugger.sendCommand).not.toHaveBeenCalledWith(
@@ -79,18 +79,20 @@ describe('browser-agent CDP instrumentation', () => {
7979
return Promise.resolve({})
8080
})
8181

82-
await expect(ensureInstrumented(contents, { onDialog: vi.fn() })).rejects.toThrow(
83-
'setup acknowledgement lost'
84-
)
85-
await expect(ensureInstrumented(contents, { onDialog: vi.fn() })).resolves.toBeUndefined()
82+
await expect(
83+
ensureInstrumented(contents, { onDialog: vi.fn(), dialogResponse: () => null })
84+
).rejects.toThrow('setup acknowledgement lost')
85+
await expect(
86+
ensureInstrumented(contents, { onDialog: vi.fn(), dialogResponse: () => null })
87+
).resolves.toBeUndefined()
8688

8789
expect(autoAttachAttempts).toBe(2)
8890
})
8991

9092
it('dismisses an OOPIF dialog on the flattened child session', async () => {
9193
const contents = new WebContentsView().webContents
9294
const onDialog = vi.fn()
93-
await ensureInstrumented(contents, { onDialog })
95+
await ensureInstrumented(contents, { onDialog, dialogResponse: () => null })
9496
const listener = vi
9597
.mocked(contents.debugger.on)
9698
.mock.calls.find(([event]) => event === 'message')?.[1] as
@@ -112,13 +114,18 @@ describe('browser-agent CDP instrumentation', () => {
112114
{ accept: false },
113115
'child-session'
114116
)
115-
expect(onDialog).toHaveBeenCalledWith({ type: 'alert', message: 'Hello', handled: true })
117+
expect(onDialog).toHaveBeenCalledWith({
118+
type: 'alert',
119+
message: 'Hello',
120+
handled: true,
121+
accepted: false,
122+
})
116123
})
117124

118125
it('accepts an OOPIF beforeunload dialog on the flattened child session', async () => {
119126
const contents = new WebContentsView().webContents
120127
const onDialog = vi.fn()
121-
await ensureInstrumented(contents, { onDialog })
128+
await ensureInstrumented(contents, { onDialog, dialogResponse: () => null })
122129
const listener = vi
123130
.mocked(contents.debugger.on)
124131
.mock.calls.find(([event]) => event === 'message')?.[1] as
@@ -144,13 +151,43 @@ describe('browser-agent CDP instrumentation', () => {
144151
type: 'beforeunload',
145152
message: 'Leave this page?',
146153
handled: true,
154+
accepted: true,
155+
})
156+
})
157+
158+
it('answers dialogs with the running action requested response', async () => {
159+
const contents = new WebContentsView().webContents
160+
const onDialog = vi.fn()
161+
const dialogResponse = vi.fn(() => ({ accept: true }))
162+
await ensureInstrumented(contents, { onDialog, dialogResponse })
163+
const listener = vi
164+
.mocked(contents.debugger.on)
165+
.mock.calls.find(([event]) => event === 'message')?.[1] as
166+
| ((event: unknown, method: string, params: unknown, sessionId?: string) => void)
167+
| undefined
168+
vi.mocked(contents.debugger.sendCommand).mockClear()
169+
170+
listener?.({}, 'Page.javascriptDialogOpening', { type: 'alert', message: 'Saved' })
171+
await vi.waitFor(() => expect(onDialog).toHaveBeenCalledTimes(1))
172+
listener?.({}, 'Page.javascriptDialogOpening', { type: 'confirm', message: 'Delete?' })
173+
await vi.waitFor(() => expect(onDialog).toHaveBeenCalledTimes(2))
174+
175+
expect(vi.mocked(contents.debugger.sendCommand).mock.calls).toEqual([
176+
['Page.handleJavaScriptDialog', { accept: true }],
177+
['Page.handleJavaScriptDialog', { accept: true }],
178+
])
179+
expect(onDialog).toHaveBeenLastCalledWith({
180+
type: 'confirm',
181+
message: 'Delete?',
182+
handled: true,
183+
accepted: true,
147184
})
148185
})
149186

150187
it('reports an OOPIF dialog as unhandled when child and root commands fail', async () => {
151188
const contents = new WebContentsView().webContents
152189
const onDialog = vi.fn()
153-
await ensureInstrumented(contents, { onDialog })
190+
await ensureInstrumented(contents, { onDialog, dialogResponse: () => null })
154191
const listener = vi
155192
.mocked(contents.debugger.on)
156193
.mock.calls.find(([event]) => event === 'message')?.[1] as
@@ -176,6 +213,7 @@ describe('browser-agent CDP instrumentation', () => {
176213
type: 'confirm',
177214
message: 'Continue?',
178215
handled: false,
216+
accepted: false,
179217
})
180218
})
181219

@@ -194,6 +232,7 @@ describe('browser-agent CDP instrumentation', () => {
194232
y: 240,
195233
button: 'left',
196234
buttons: 1,
235+
modifiers: 0,
197236
clickCount: 1,
198237
},
199238
],
@@ -205,6 +244,7 @@ describe('browser-agent CDP instrumentation', () => {
205244
y: 240,
206245
button: 'left',
207246
buttons: 0,
247+
modifiers: 0,
208248
clickCount: 1,
209249
},
210250
],
@@ -229,6 +269,7 @@ describe('browser-agent CDP instrumentation', () => {
229269
y: 24,
230270
button: 'left',
231271
buttons: 0,
272+
modifiers: 0,
232273
clickCount: 1,
233274
},
234275
])
@@ -253,6 +294,7 @@ describe('browser-agent CDP instrumentation', () => {
253294
y: 48,
254295
button: 'left',
255296
buttons: 1,
297+
modifiers: 0,
256298
clickCount: 1,
257299
},
258300
],
@@ -264,6 +306,7 @@ describe('browser-agent CDP instrumentation', () => {
264306
y: 48,
265307
button: 'left',
266308
buttons: 0,
309+
modifiers: 0,
267310
clickCount: 1,
268311
},
269312
],
@@ -316,7 +359,7 @@ describe('browser-agent CDP instrumentation', () => {
316359
async (treeKind) => {
317360
const contents = new WebContentsView().webContents
318361
const { child, frameTree } = createOopifFrameFixture()
319-
await ensureInstrumented(contents, { onDialog: vi.fn() })
362+
await ensureInstrumented(contents, { onDialog: vi.fn(), dialogResponse: () => null })
320363
const listener = vi
321364
.mocked(contents.debugger.on)
322365
.mock.calls.find(([event]) => event === 'message')?.[1] as
@@ -419,7 +462,7 @@ describe('browser-agent CDP instrumentation', () => {
419462
it('falls back to the root target when OOPIF isolated-world creation fails', async () => {
420463
const contents = new WebContentsView().webContents
421464
const { child, frameTree } = createOopifFrameFixture()
422-
await ensureInstrumented(contents, { onDialog: vi.fn() })
465+
await ensureInstrumented(contents, { onDialog: vi.fn(), dialogResponse: () => null })
423466
const listener = vi
424467
.mocked(contents.debugger.on)
425468
.mock.calls.find(([event]) => event === 'message')?.[1] as

0 commit comments

Comments
 (0)