Skip to content

Commit cf28177

Browse files
committed
fix(browser): keep new markers out of find and time out a hung name claim
- browser_find collects its outline with markNew off, so a search never matches the `new` token and never consumes markers the next snapshot owes. - An element counts as shown only once its line is emitted. - The placeholder claim runs inside the allocation timeout, so a hung filesystem stops the download instead of leaving it paused with its name reserved; a late placeholder is removed when the claim settles. - A successful move drops the placeholder pointer, and an already-aborted click sends no input at all, not even the pointer move.
1 parent 4fd0b06 commit cf28177

8 files changed

Lines changed: 113 additions & 48 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ describe('browser-agent CDP instrumentation', () => {
282282
controller.abort()
283283

284284
await expect(
285-
clickAt(contents, 5, 6, false, PRIMARY_CLICK, controller.signal)
285+
clickAt(contents, 5, 6, true, PRIMARY_CLICK, controller.signal)
286286
).rejects.toMatchObject({ name: 'AbortError' })
287287
expect(contents.debugger.sendCommand).not.toHaveBeenCalled()
288288
})

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -984,7 +984,7 @@ export function clearAgentContextMenu(contents: WebContents): void {
984984
}
985985

986986
/**
987-
* Clicks at viewport coordinates. An already-aborted `signal` rejects before anything is pressed.
987+
* Clicks at viewport coordinates. An already-aborted `signal` rejects before any input is sent.
988988
* During a press-and-hold it ends the hold early: the click rejects with the abort reason and the
989989
* button is released at once, so a cancelled or timed-out click cannot stay held into the next
990990
* action. That release can still activate the control under the pointer.
@@ -997,6 +997,7 @@ export async function clickAt(
997997
click: PointerClick = PRIMARY_CLICK,
998998
signal?: AbortSignal
999999
): Promise<void> {
1000+
signal?.throwIfAborted()
10001001
if (moveBeforePress) await moveMouse(contents, x, y)
10011002
signal?.throwIfAborted()
10021003
const { button, clickCount, modifiers, holdMs } = click

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4661,9 +4661,12 @@ describe('credential protection', () => {
46614661
})
46624662
})
46634663

4664-
it.each(['browser_snapshot', 'browser_find'] as const)(
4665-
'omits an absent scope from the serialized %s page call',
4666-
async (tool) => {
4664+
it.each([
4665+
['browser_snapshot', 'true'],
4666+
['browser_find', 'false'],
4667+
] as const)(
4668+
'passes an absent scope as null to the serialized %s page call',
4669+
async (tool, markNew) => {
46674670
const contents = await openPage()
46684671
vi.mocked(contents.executeJavaScript).mockClear()
46694672
await driver.executeTool('chat-test', tool, { query: 'Continue' })
@@ -4672,13 +4675,16 @@ describe('credential protection', () => {
46724675
.mock.calls.map(([expression]) => expression)
46734676
.filter((expression) => isPageCall(expression, 'collectSnapshot'))
46744677
expect(expressions).toHaveLength(1)
4675-
expect(expressions[0]).toContain('.apply(null, [1])')
4678+
expect(expressions[0]).toContain(`.apply(null, [1,null,${markNew}])`)
46764679
}
46774680
)
46784681

4679-
it.each(['browser_snapshot', 'browser_find'] as const)(
4682+
it.each([
4683+
['browser_snapshot', 'true'],
4684+
['browser_find', 'false'],
4685+
] as const)(
46804686
'passes the current root ref to %s and invalidates previous refs',
4681-
async (tool) => {
4687+
async (tool, markNew) => {
46824688
const contents = await openPage()
46834689
respondWith(contents, {
46844690
collectSnapshot: {
@@ -4700,7 +4706,7 @@ describe('credential protection', () => {
47004706
.mock.calls.some(
47014707
([expression]) =>
47024708
isPageCall(expression, 'collectSnapshot') &&
4703-
expression.includes('.apply(null, [1,0])')
4709+
expression.includes(`.apply(null, [1,0,${markNew}])`)
47044710
)
47054711
).toBe(true)
47064712
const stale = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 })

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2285,12 +2285,14 @@ function validateSnapshotRefs(
22852285
* Captures the top page plus each cross-origin boundary frame in its own
22862286
* isolated world. CDP is the privileged bridge the top document's same-origin
22872287
* policy intentionally lacks; password redaction still runs inside every frame
2288-
* before any result crosses back to the driver.
2288+
* before any result crosses back to the driver. `markNew` is false for reads the
2289+
* model never sees as an outline, so they neither carry nor consume `new` markers.
22892290
*/
22902291
async function captureSnapshot(
22912292
contents: WebContents,
22922293
notAfter?: number,
2293-
elementId?: number
2294+
elementId?: number,
2295+
markNew = true
22942296
): Promise<unknown> {
22952297
const state = driverScopeState()
22962298
const tab = session.requireAutomationTab()
@@ -2326,7 +2328,7 @@ async function captureSnapshot(
23262328
await execInPage(
23272329
contents,
23282330
collectSnapshot,
2329-
elementId === undefined ? [mainStartingElementId] : [mainStartingElementId, elementId],
2331+
[mainStartingElementId, elementId ?? null, markNew],
23302332
false,
23312333
notAfter
23322334
)
@@ -2383,7 +2385,7 @@ async function captureSnapshot(
23832385
const frameSnapshot = await execInPage(
23842386
frame,
23852387
collectSnapshot,
2386-
[frameStartingElementId],
2388+
[frameStartingElementId, null, markNew],
23872389
false,
23882390
notAfter
23892391
)
@@ -2867,7 +2869,7 @@ async function executeToolInner(
28672869
const maxResults = Math.min(50, Math.max(1, Math.floor(requestedMax ?? 20)))
28682870
const contents = session.requireAutomationTab().view.webContents
28692871
const snapshot = toRecord(
2870-
await captureSnapshot(contents, executionDeadline, num(params, 'elementId'))
2872+
await captureSnapshot(contents, executionDeadline, num(params, 'elementId'), false)
28712873
)
28722874
const outline = typeof snapshot.outline === 'string' ? snapshot.outline : ''
28732875
const needle = query.toLowerCase()

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,20 @@ describe('collectSnapshot', () => {
768768
expect(outlineOf(collectSnapshot())).not.toContain(' new')
769769
})
770770

771+
it('leaves new markers out of an unmarked read without consuming them', () => {
772+
document.body.innerHTML = '<button>Compose</button>'
773+
visible(document.querySelector('button') as HTMLButtonElement)
774+
collectSnapshot()
775+
776+
const added = document.createElement('button')
777+
added.textContent = 'Send'
778+
document.body.append(visible(added))
779+
780+
expect(outlineOf(collectSnapshot(0, null, false))).not.toContain(' new')
781+
const lines = outlineOf(collectSnapshot()).split('\n')
782+
expect(lines.find((line) => line.includes('"Send"'))).toMatch(/ new$/)
783+
})
784+
771785
it('sanitizes a malicious role so it cannot forge a second snapshot line', () => {
772786
document.body.innerHTML = '<div tabindex="0" aria-label="Safe control"></div>'
773787
const control = visible(document.querySelector('div') as HTMLDivElement)

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

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,16 +128,22 @@ export function installPageHelpers(): void {
128128
* Builds the page snapshot: a structural outline (headings, landmarks) with
129129
* interactive elements carrying numeric ids, walking open shadow roots and
130130
* same-origin iframes. Rebuilds the element registry as a side effect.
131+
* `markNew` false leaves the `new` markers out and records nothing as shown,
132+
* for internal reads (such as a text search) whose outline the model never sees.
131133
*/
132-
export function collectSnapshot(startingElementId = 0, elementId?: number): unknown {
134+
export function collectSnapshot(
135+
startingElementId = 0,
136+
elementId: number | null = null,
137+
markNew = true
138+
): unknown {
133139
const resolver = window.__simAgentResolveElement
134140
const scopedRoot =
135-
elementId === undefined
141+
elementId === null
136142
? undefined
137143
: resolver
138144
? resolver(elementId, false)?.element
139145
: window.__simAgentElements?.[elementId]
140-
if (elementId !== undefined) {
146+
if (elementId !== null) {
141147
if (!scopedRoot?.isConnected) return { error: 'stale', reason: window.__simAgentStaleReason }
142148
if (scopedRoot.ownerDocument !== document) return { error: 'framed-snapshot' }
143149
}
@@ -238,9 +244,13 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn
238244
window.__simAgentElements = registry
239245
const previouslyShown = window.__simAgentShownElements
240246
const shown = previouslyShown ?? new WeakSet<Element>()
241-
window.__simAgentShownElements = shown
247+
if (markNew) window.__simAgentShownElements = shown
242248
/** Whether no earlier snapshot of this document listed the element; the first snapshot marks nothing. */
243-
const isNew = (el: Element): boolean => previouslyShown !== undefined && !shown.has(el)
249+
const isNew = (el: Element): boolean => markNew && previouslyShown !== undefined && !shown.has(el)
250+
/** Records an element whose line made it into the outline, so the next snapshot knows it. */
251+
const recordShown = (el: Element): void => {
252+
if (markNew) shown.add(el)
253+
}
244254
const lines: string[] = []
245255
let truncated = false
246256
let refCount = 0
@@ -585,11 +595,11 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn
585595
}
586596
}
587597
if (isNew(el)) parts.push('new')
588-
shown.add(el)
589598
const suffix = parts.length > 0 ? ` ${parts.join(' ')}` : ''
590599
const lineIndex = lines.length
591600
if (push(`${indent}- ${role} ${quote(name)} [ref=${id}]${suffix}`)) {
592601
refLineIndexes[id] = lineIndex
602+
recordShown(el)
593603
}
594604
}
595605

@@ -608,9 +618,11 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn
608618
const id = registerElement(el, roleFor(el), text)
609619
textLineCount++
610620
const marker = isNew(el) ? ' new' : ''
611-
shown.add(el)
612621
const lineIndex = lines.length
613-
if (push(`${indent}- text ${quote(text)} [ref=${id}]${marker}`)) refLineIndexes[id] = lineIndex
622+
if (push(`${indent}- text ${quote(text)} [ref=${id}]${marker}`)) {
623+
refLineIndexes[id] = lineIndex
624+
recordShown(el)
625+
}
614626
}
615627

616628
const headingLevel = (el: Element): number | null => {

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4221,6 +4221,39 @@ describe('browser-agent session', () => {
42214221
})
42224222
})
42234223

4224+
it('stops a download whose placeholder claim hangs and removes the late placeholder', async () => {
4225+
vi.useFakeTimers()
4226+
try {
4227+
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
4228+
const gate = deferred<void>()
4229+
const claimFile = vi.fn(async (path: string) => {
4230+
await gate.promise
4231+
writeFileSync(path, '', { flag: 'wx' })
4232+
})
4233+
session = freshSession(win, {}, undefined, {
4234+
getDirectory: () => directory,
4235+
getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
4236+
pathExists: () => false,
4237+
claimFile,
4238+
})
4239+
const contents = (session.ensureTab().view as unknown as MockView).webContents
4240+
const download = mockDownloadItem({ filename: 'hung-claim.bin', totalBytes: 100 })
4241+
4242+
startMockDownload(contents, download)
4243+
await vi.advanceTimersByTimeAsync(0)
4244+
expect(claimFile).toHaveBeenCalledOnce()
4245+
await vi.advanceTimersByTimeAsync(5_000)
4246+
4247+
expect(download.item.cancel).toHaveBeenCalledOnce()
4248+
expect(download.item.resume).not.toHaveBeenCalled()
4249+
download.emitDone('cancelled')
4250+
gate.resolve()
4251+
await vi.waitFor(() => expect(readdirSync(directory)).toEqual([]))
4252+
} finally {
4253+
vi.useRealTimers()
4254+
}
4255+
})
4256+
42244257
it('keeps a torn-down download name reserved until its pending claim settles', async () => {
42254258
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
42264259
const claims: Array<{ path: string; gate: ReturnType<typeof deferred<void>> }> = []

‎apps/desktop/src/main/browser-agent/session.ts‎

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,8 @@ async function moveStagedBrowserDownload(active: ActiveBrowserDownload): Promise
795795
for (let attempt = 1; ; attempt++) {
796796
try {
797797
await moveFile(active.stagingPath, destination)
798+
// The name now holds the finished file, which no cleanup may remove as a placeholder.
799+
active.placeholderPath = undefined
798800
return destination
799801
} catch (error) {
800802
const code = (error as NodeJS.ErrnoException).code
@@ -1737,27 +1739,33 @@ function configureBrowserDownloads(ses: Session): void {
17371739
})
17381740
})
17391741
let allocationExpired = false
1742+
const allocationLive = () =>
1743+
!allocationExpired &&
1744+
!active.terminal &&
1745+
!active.limitReason &&
1746+
activeBrowserDownloads.has(active)
17401747
const allocation = uniqueDownloadPath(directory, filename, {
1741-
isActive: () =>
1742-
!allocationExpired &&
1743-
!active.terminal &&
1744-
!active.limitReason &&
1745-
activeBrowserDownloads.has(active),
1748+
isActive: allocationLive,
17461749
pathExists: browserDownloadSettings?.pathExists,
17471750
reservePath: (candidate) => {
1748-
if (
1749-
allocationExpired ||
1750-
active.terminal ||
1751-
active.limitReason ||
1752-
!activeBrowserDownloads.has(active) ||
1753-
activeDownloadPaths.has(candidate)
1754-
) {
1755-
return false
1756-
}
1751+
if (!allocationLive() || activeDownloadPaths.has(candidate)) return false
17571752
activeDownloadPaths.set(candidate, active)
17581753
active.savePath = candidate
17591754
return true
17601755
},
1756+
}).then(async (savePath) => {
1757+
if (!savePath || !allocationLive()) return savePath
1758+
active.claimingDestination = true
1759+
try {
1760+
await claimBrowserDownloadDestination(active, savePath)
1761+
} finally {
1762+
active.claimingDestination = false
1763+
if (!allocationLive()) {
1764+
removeBrowserDownloadPlaceholder(active)
1765+
releaseActiveBrowserDownloadPath(active, savePath)
1766+
}
1767+
}
1768+
return savePath
17611769
})
17621770
active.destination = withBrowserDownloadTimeout(
17631771
allocation,
@@ -1767,7 +1775,7 @@ function configureBrowserDownloads(ses: Session): void {
17671775
allocationExpired = true
17681776
}
17691777
)
1770-
.then(async (savePath) => {
1778+
.then((savePath) => {
17711779
if (active.terminal || !activeBrowserDownloads.has(active)) {
17721780
releaseActiveBrowserDownloadPath(active, savePath ?? undefined)
17731781
return null
@@ -1780,17 +1788,6 @@ function configureBrowserDownloads(ses: Session): void {
17801788
publishActiveBrowserDownload(active)
17811789
return null
17821790
}
1783-
active.claimingDestination = true
1784-
try {
1785-
await claimBrowserDownloadDestination(active, savePath)
1786-
} finally {
1787-
active.claimingDestination = false
1788-
if (active.terminal || !activeBrowserDownloads.has(active)) {
1789-
removeBrowserDownloadPlaceholder(active)
1790-
releaseActiveBrowserDownloadPath(active, savePath)
1791-
}
1792-
}
1793-
if (active.terminal || !activeBrowserDownloads.has(active)) return null
17941791
checkBrowserDownloadDiskSpace(active, 'admission')
17951792
return savePath
17961793
})

0 commit comments

Comments
 (0)