fix(browser): agent typing into a cross-site frame no longer borrows your keyboard (#29000)

* fix(browser): agent typing into a cross-site frame no longer borrows the user's focus

Keys and text for a focused cross-site iframe went through the focus-borrowing
fallback, because widget input reaches only the guest's own renderer. They now
go to that frame's own CDP target session, found by following the page's focus
through frame elements, which reaches the frame without touching the user's
focus. The proxy keeps those Orca-owned sessions' events from the agent's client.

* fix(browser): retry frame focus emulation after a failed enable

* fix(browser): retry a failed frame focus-emulation enable before sending input
This commit is contained in:
Neil
2026-10-14 23:30:13 -07:00
committed by GitHub
parent c3b9212906
commit cbc357f547
12 changed files with 830 additions and 67 deletions
@@ -24,6 +24,7 @@ that tree's focus, not by which debugger the command arrived on. A hidden-window
| `webContents.sendInputEvent` key / `char` on the guest | guest | kept |
| CDP key / `insertText` on a cross-site iframe's own (auto-attached) session | that iframe | kept |
| `webContents.insertText` with focus in a cross-site iframe | **guest renderer crashed** | — |
| `sendInputEvent` key with focus in a same-site iframe | that iframe | kept |
| any mouse press (CDP mouse, touch, tap gesture, `sendInputEvent`) | guest | **moved to the webview** |
| `inert` / `tabindex=-1` on the `<webview>` during the press | guest | **moved** |
| press + synchronous host restore on the `<webview>` `focus` event | guest | restored, next user key lands in the host |
@@ -58,11 +59,16 @@ popup and copies every frame. It stays an experiment, not the fix.
2. **Keys and text go straight to the guest.** For an embedded guest, main-session
`Input.dispatchKeyEvent` / `Input.insertText` (from the proxy) and Orca's own `keypress` become
`sendInputEvent` key / `char` events with `skipIfUnhandled`, followed by a page round trip so
the reply comes after the page handled them. Offscreen and headless pages have no host frame
tree and keep CDP.
the reply comes after the page handled them. When the page's focus sits in a cross-site
frame, keys and text go to that frame's own CDP target session instead: main follows
`document.activeElement` through frame elements (`DOM.describeNode` gives the frame id, which
is the frame's target id), attaches with `Target.attachToTarget {flatten:true}`, enables focus
emulation there and sends the input on that session. A same-site frame shares the guest's
renderer, so widget events already reach it. The proxy hides these Orca-owned sessions' events
from the agent's client. Offscreen and headless pages have no host frame tree and keep CDP.
3. **Agent input that needs focus borrows it.** Before a press, or before input only CDP can
express (focus in a frame element, text with control characters, unmapped keys, root-session
IME composition), main enables focus emulation and asks the hosting pane to note the user's
express (a frame that cannot be reached, text with control characters, unmapped keys,
root-session IME composition), main enables focus emulation and asks the hosting pane to note the user's
focused element, waiting for its answer. When the input ends the pane gives focus back, and
checks again 50 ms later in case Chromium's focus move arrived after the end message. Nothing is
restored when the user already had focus in that page.
@@ -90,4 +96,4 @@ refuses that input. Only offscreen rendering removes the first gap.
| Presses and CDP-only input borrow focus and give it back; focus emulation | stack 3/3 | in review |
| Regression spec: user typing in a terminal while an agent clicks, types and presses keys | stack 2/3, 3/3 | in review |
| Visible-window spec (`@headful`, isolated display only): native focus stays on Orca's window | stack 3/3 | in review |
| Route main-session keys to a focused cross-site iframe's own session instead of borrowing focus | follow-up | not started |
| Main-session keys and text for a focused cross-site iframe go to its own session, no focus borrow | follow-up PR | in review |
@@ -15,6 +15,7 @@ import { WAIT_PROCESS_TIMEOUT_GRACE_MS } from './agent-browser-bridge-types'
import { acquireElectronDebugger } from './electron-debugger-lease'
import { parseCdpKeyEvent, imeFallbackKeyEvent } from './cdp-keyboard-us-layout'
import { dispatchPageKeyEvent, type GuestCdpSender } from './guest-direct-key-input'
import { sendGuestCdpCommand } from './guest-cdp-command'
import { AgentBrowserBridgeCaptureCommands } from './agent-browser-bridge-capture-commands'
export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowserBridgeCaptureCommands {
@@ -203,7 +204,8 @@ export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowser
let releaseDebugger = (): void => {}
try {
releaseDebugger = acquireElectronDebugger(wc).release
const send: GuestCdpSender = (method, params) => wc.debugger.sendCommand(method, params)
const send: GuestCdpSender = (method, params, sessionId) =>
sendGuestCdpCommand(wc, method, params, sessionId)
const guardFocus = (): Promise<() => void> =>
this.browserManager.guardUserFocusForAgentInput(target.webContentsId)
await dispatchPageKeyEvent(
+6
View File
@@ -4,6 +4,7 @@ import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-
import type { CdpClientResponseWriter } from './cdp-client-response-writer'
import type { CdpSyntheticSessionRegistry } from './cdp-synthetic-session-registry'
import { sendGuestCdpCommand } from './guest-cdp-command'
import { isOrcaFrameSessionTraffic } from './guest-focused-frame-route'
/**
* The IO boundary with webContents.debugger: lease-based attach, event fan-out to
@@ -46,6 +47,11 @@ export class CdpDebuggerChannel {
Record<string, unknown>,
string | undefined
]
// Why: Orca's own frame sessions (focused cross-site frame input) were never asked for by
// the agent's client; their events would describe sessions it does not know.
if (isOrcaFrameSessionTraffic(this.webContents, method, params, sessionId || undefined)) {
return
}
const client = this.getClient()
if (!client || client.readyState !== WebSocket.OPEN) {
return
+2 -2
View File
@@ -113,8 +113,8 @@ export class CdpPageInputCommands {
)
}
private readonly sendRoot: GuestCdpSender = (method, params) =>
this.debuggerChannel.sendDebuggerCommand(method, params)
private readonly sendRoot: GuestCdpSender = (method, params, sessionId) =>
this.debuggerChannel.sendDebuggerCommand(method, params, sessionId)
// Why: delivery awaits focus probes and focus guards; one queue keeps a pipelined
// keyDown/char/keyUp/insertText or press/move/release sequence in order.
@@ -27,11 +27,33 @@ function fakePage(type: string) {
return { page, webContents: page as unknown as WebContents }
}
function fakeSender(focusInSubframe = false) {
const send = vi.fn<GuestCdpSender>(async (method) =>
method === 'Runtime.evaluate' ? { result: { value: focusInSubframe } } : {}
function fakeSender() {
return vi.fn<GuestCdpSender>(async (method) =>
method === 'Runtime.evaluate' ? { result: { type: 'object', subtype: 'null' } } : {}
)
return send
}
// Why: the root document's focus is on a cross-site frame (target F1); inside it, on a field.
function crossSiteFrameSender(options: { attachable: boolean }) {
return vi.fn<GuestCdpSender>(async (method, _params, sessionId) => {
switch (method) {
case 'Runtime.evaluate':
return sessionId
? { result: { type: 'object', subtype: 'null' } }
: { result: { objectId: 'frame-element' } }
case 'DOM.describeNode':
return { node: { frameId: 'F1' } }
case 'Target.getTargets':
return { targetInfos: [{ targetId: 'F1', type: 'iframe' }] }
case 'Target.attachToTarget':
if (!options.attachable) {
throw new Error('No target with given id')
}
return { sessionId: 'S1' }
default:
return {}
}
})
}
describe('planCdpKeyEvent', () => {
@@ -204,9 +226,22 @@ describe('dispatchPageKeyEvent', () => {
expect(page.selectAll).toHaveBeenCalledTimes(1)
})
it('borrows focus for CDP when focus sits in a child frame, then gives it back', async () => {
it('types into a focused cross-site frame on its own session, without focusing the guest', async () => {
const { page, webContents } = fakePage('webview')
const send = fakeSender(true)
const send = crossSiteFrameSender({ attachable: true })
const params = { type: 'keyDown', key: 'z', text: 'z' }
await dispatchPageKeyEvent(webContents, params, send, async () => () => {})
expect(page.focus).not.toHaveBeenCalled()
expect(page.sendInputEvent).not.toHaveBeenCalled()
expect(send).toHaveBeenCalledWith('Emulation.setFocusEmulationEnabled', { enabled: true }, 'S1')
expect(send).toHaveBeenLastCalledWith('Input.dispatchKeyEvent', params, 'S1')
})
it('borrows focus for CDP when the focused frame cannot be reached, then gives it back', async () => {
const { page, webContents } = fakePage('webview')
const send = crossSiteFrameSender({ attachable: false })
const release = vi.fn()
const params = { type: 'keyDown', key: 'z', text: 'z' }
@@ -260,9 +295,23 @@ describe('routePageTextInsertion', () => {
// Why: the caller releases, so an insert abandoned before delivery still gives focus back.
expect(route.release).toBe(release)
expect(page.sendInputEvent).not.toHaveBeenCalled()
expect(send.mock.calls).toEqual([
['Emulation.setFocusEmulationEnabled', { enabled: true }],
['Input.insertText', { text: 'a\nb' }]
expect(send.mock.calls.map(([method]) => method)).toEqual([
'Runtime.evaluate',
'Emulation.setFocusEmulationEnabled',
'Input.insertText'
])
})
})
describe('routePageTextInsertion into a cross-site frame', () => {
it('delivers any text, newlines included, on the frame’s session', async () => {
const { page, webContents } = fakePage('webview')
const send = crossSiteFrameSender({ attachable: true })
const route = await routePageTextInsertion(webContents, { text: 'a\nb' }, send)
await route.deliver({ text: 'a\nb' })
expect(page.focus).not.toHaveBeenCalled()
expect(send).toHaveBeenLastCalledWith('Input.insertText', { text: 'a\nb' }, 'S1')
})
})
+51 -45
View File
@@ -1,6 +1,12 @@
import type { InputEvent, KeyboardInputEvent, WebContents } from 'electron'
import { enablePageFocusEmulation, type AgentInputFocusGuard } from './agent-input-focus-guard'
import { withAgentInputInjection } from './agent-input-injection'
import {
resolveFocusedFrameRoute,
settleWithin,
type GuestSessionCdpSender
} from './guest-focused-frame-route'
import { sendFrameSessionInput } from './guest-frame-input-sessions'
// Why: Chromium routes main-session CDP key and text input on a <webview> guest by the
// window's focus, so it lands in whatever the user is typing into (#20595). Events sent to
@@ -22,7 +28,7 @@ type GuestKeyPlan = {
commands: GuestEditCommand[]
}
export type GuestCdpSender = (method: string, params: Record<string, unknown>) => Promise<unknown>
export type GuestCdpSender = GuestSessionCdpSender
const CDP_MODIFIER_NAMES: [number, ElectronModifier][] = [
[1, 'alt'],
@@ -174,46 +180,6 @@ export function planInsertText(text: unknown): GuestKeyboardEvent[] | null {
return typeableText(text) ? charEvents(text, []) : null
}
// Why: walks open shadow roots too, so a frame inside a web component still counts.
const SUBFRAME_FOCUS_EXPRESSION = `(() => {
let element = document.activeElement
while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement
const tag = element?.tagName
return tag === 'IFRAME' || tag === 'FRAME' || tag === 'OBJECT' || tag === 'EMBED' || tag === 'FENCEDFRAME'
})()`
// Why: a page paused in a debugger or blocked on a dialog never answers; input must not wait on it.
const PAGE_PROBE_TIMEOUT_MS = 500
async function settleWithin<T>(probe: Promise<T>, fallback: T): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
probe.catch(() => fallback),
new Promise<T>((resolve) => {
timer = setTimeout(() => resolve(fallback), PAGE_PROBE_TIMEOUT_MS)
})
])
} finally {
clearTimeout(timer)
}
}
// Why: widget events reach the guest's main frame only. Focus inside a child frame (often a
// separate process) needs the fallback, and webContents.insertText crashes the guest there.
// A probe that fails (navigation tore down the context) means the main frame.
async function isFocusInSubframe(send: GuestCdpSender): Promise<boolean> {
const response = await settleWithin(
send('Runtime.evaluate', { expression: SUBFRAME_FOCUS_EXPRESSION, returnByValue: true }),
null
)
if (response === null || typeof response !== 'object' || !('result' in response)) {
return false
}
const { result } = response
return result !== null && typeof result === 'object' && 'value' in result && result.value === true
}
// Why: sendInputEvent returns before the page handles the event, but CDP input replies only after.
// A task-queue round trip in the page lets callers read the result of what they just typed.
async function waitForPageToHandleInput(send: GuestCdpSender): Promise<void> {
@@ -268,9 +234,22 @@ async function focusGuestForCdpInput(
return release
}
// Why: CDP input on a cross-site frame's own session reaches that frame without the user's focus.
async function sendToFrameSession(
webContents: WebContents,
send: GuestCdpSender,
sessionId: string,
method: string,
params: Record<string, unknown>
): Promise<unknown> {
return await withAgentInputInjection(webContents, () =>
sendFrameSessionInput(webContents, send, sessionId, method, params)
)
}
/**
* Delivers a CDP key event to a page. Embedded guests get widget events; anything those
* cannot express keeps the CDP path while borrowing focus, so it still reaches the page.
* Delivers a CDP key event to a page. Embedded guests get widget events, or CDP on a focused
* cross-site frame's own session; anything else keeps the CDP path while borrowing focus.
*/
export async function dispatchPageKeyEvent(
webContents: WebContents,
@@ -281,8 +260,26 @@ export async function dispatchPageKeyEvent(
if (!isEmbeddedGuest(webContents)) {
return await send('Input.dispatchKeyEvent', params)
}
const route = await resolveFocusedFrameRoute(webContents, send)
if (route.kind === 'session') {
// Why: Tab from a frame's last field can move focus out of the guest into Orca's window.
const isTabDown =
params.key === 'Tab' && (params.type === 'keyDown' || params.type === 'rawKeyDown')
const release = isTabDown ? await guardFocus() : () => {}
try {
return await sendToFrameSession(
webContents,
send,
route.sessionId,
'Input.dispatchKeyEvent',
params
)
} finally {
release()
}
}
const plan = planCdpKeyEvent(params)
if (plan && !(await isFocusInSubframe(send))) {
if (plan && route.kind === 'widget') {
// Why: Tab from the page's last field moves focus out of the guest into Orca's window.
const release = plan.events.some((event) => event.type === 'keyDown' && event.keyCode === 'Tab')
? await guardFocus()
@@ -335,8 +332,17 @@ export async function routePageTextInsertion(
webContents.focus()
return { deliver: sendText, release: () => {} }
}
const route = await resolveFocusedFrameRoute(webContents, send)
if (route.kind === 'session') {
const { sessionId } = route
return {
deliver: (insertParams) =>
sendToFrameSession(webContents, send, sessionId, 'Input.insertText', insertParams),
release: () => {}
}
}
const events = planInsertText(params.text)
if (events && !(await isFocusInSubframe(send))) {
if (events && route.kind === 'widget') {
return {
deliver: () =>
withAgentInputInjection(webContents, async () => {
@@ -0,0 +1,237 @@
import { describe, expect, it, vi } from 'vitest'
import type { WebContents } from 'electron'
import {
isOrcaFrameSessionTraffic,
resolveFocusedFrameRoute,
type GuestSessionCdpSender
} from './guest-focused-frame-route'
import { sendFrameSessionInput } from './guest-frame-input-sessions'
function fakeGuest(): WebContents {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the route only uses the guest as a cache key.
return {} as WebContents
}
type FrameModel = {
// Frame id focused in each document: '' is the root, a session id is a cross-site frame's
// document, and 'frame:<id>' is a same-site frame's document reached through an isolated world.
focusedFrameBySession: Record<string, string | null>
crossSiteTargets: string[]
sessionForTarget: Record<string, string>
deadSessions?: Set<string>
attachDelayMs?: number
}
function frameSender(model: FrameModel) {
let nextObject = 0
let nextContext = 0
const frameByObject = new Map<string, string>()
const frameByContext = new Map<number, string>()
return vi.fn<GuestSessionCdpSender>(async (method, params, sessionId) => {
const session = sessionId ?? ''
if (model.deadSessions?.has(session)) {
throw new Error('Session with given id not found')
}
switch (method) {
case 'Runtime.evaluate': {
const contextFrame =
typeof params.contextId === 'number' ? frameByContext.get(params.contextId) : undefined
const documentKey = contextFrame ? `frame:${contextFrame}` : session
const frameId = model.focusedFrameBySession[documentKey] ?? null
if (!frameId) {
return { result: { type: 'object', subtype: 'null' } }
}
const objectId = `object-${(nextObject += 1)}`
frameByObject.set(objectId, frameId)
return { result: { objectId } }
}
case 'DOM.describeNode':
return { node: { frameId: frameByObject.get(String(params.objectId)) } }
case 'Page.createIsolatedWorld': {
const contextId = (nextContext += 1)
frameByContext.set(contextId, String(params.frameId))
return { executionContextId: contextId }
}
case 'Target.getTargets':
return {
targetInfos: model.crossSiteTargets.map((targetId) => ({ targetId, type: 'iframe' }))
}
case 'Target.attachToTarget':
if (model.attachDelayMs) {
await new Promise((resolve) => setTimeout(resolve, model.attachDelayMs))
}
return { sessionId: model.sessionForTarget[String(params.targetId)] }
default:
return {}
}
})
}
describe('resolveFocusedFrameRoute', () => {
it('uses widget input when focus is in the main frame or a same-site frame', async () => {
const guest = fakeGuest()
expect(
await resolveFocusedFrameRoute(
guest,
frameSender({ focusedFrameBySession: {}, crossSiteTargets: [], sessionForTarget: {} })
)
).toEqual({ kind: 'widget' })
expect(
await resolveFocusedFrameRoute(
guest,
frameSender({
focusedFrameBySession: { '': 'SAME-SITE' },
crossSiteTargets: ['OTHER'],
sessionForTarget: {}
})
)
).toEqual({ kind: 'widget' })
})
it('follows focus through a same-site frame into a cross-site frame inside it', async () => {
const send = frameSender({
focusedFrameBySession: { '': 'SAME-SITE', 'frame:SAME-SITE': 'CROSS' },
crossSiteTargets: ['CROSS'],
sessionForTarget: { CROSS: 'S1' }
})
expect(await resolveFocusedFrameRoute(fakeGuest(), send)).toEqual({
kind: 'session',
sessionId: 'S1'
})
})
it('shares one attach between concurrent keys', async () => {
const guest = fakeGuest()
const send = frameSender({
focusedFrameBySession: { '': 'F1' },
crossSiteTargets: ['F1'],
sessionForTarget: { F1: 'S1' },
attachDelayMs: 20
})
const routes = await Promise.all([
resolveFocusedFrameRoute(guest, send),
resolveFocusedFrameRoute(guest, send)
])
expect(routes).toEqual([
{ kind: 'session', sessionId: 'S1' },
{ kind: 'session', sessionId: 'S1' }
])
expect(send.mock.calls.filter(([method]) => method === 'Target.attachToTarget')).toHaveLength(1)
})
it('keeps hiding a session whose attach answered after the caller gave up', async () => {
vi.useFakeTimers()
try {
const guest = fakeGuest()
const send = frameSender({
focusedFrameBySession: { '': 'F1' },
crossSiteTargets: ['F1'],
sessionForTarget: { F1: 'LATE' },
attachDelayMs: 2_000
})
const route = resolveFocusedFrameRoute(guest, send)
await vi.advanceTimersByTimeAsync(600)
expect(await route).toEqual({ kind: 'unknown' })
await vi.advanceTimersByTimeAsync(2_000)
expect(isOrcaFrameSessionTraffic(guest, 'Runtime.consoleAPICalled', {}, 'LATE')).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('follows focus into nested cross-site frames and reuses their sessions', async () => {
const guest = fakeGuest()
const send = frameSender({
focusedFrameBySession: { '': 'OUTER', S1: 'INNER' },
crossSiteTargets: ['OUTER', 'INNER'],
sessionForTarget: { OUTER: 'S1', INNER: 'S2' }
})
expect(await resolveFocusedFrameRoute(guest, send)).toEqual({
kind: 'session',
sessionId: 'S2'
})
await resolveFocusedFrameRoute(guest, send)
expect(send.mock.calls.filter(([method]) => method === 'Target.attachToTarget')).toHaveLength(2)
})
it('attaches again when a cached frame session has died', async () => {
const guest = fakeGuest()
const model: FrameModel = {
focusedFrameBySession: { '': 'F1' },
crossSiteTargets: ['F1'],
sessionForTarget: { F1: 'OLD' }
}
await resolveFocusedFrameRoute(guest, frameSender(model))
model.deadSessions = new Set(['OLD'])
model.sessionForTarget.F1 = 'NEW'
expect(await resolveFocusedFrameRoute(guest, frameSender(model))).toEqual({
kind: 'session',
sessionId: 'NEW'
})
})
it('treats an unreadable main frame as main-frame focus', async () => {
const send = vi.fn<GuestSessionCdpSender>(async () => {
throw new Error('Execution context was destroyed')
})
expect(await resolveFocusedFrameRoute(fakeGuest(), send)).toEqual({ kind: 'widget' })
})
})
describe('isOrcaFrameSessionTraffic', () => {
it('hides only the sessions Orca attached, and forgets them when they detach', async () => {
const guest = fakeGuest()
await resolveFocusedFrameRoute(
guest,
frameSender({
focusedFrameBySession: { '': 'F1' },
crossSiteTargets: ['F1'],
sessionForTarget: { F1: 'S1' }
})
)
expect(isOrcaFrameSessionTraffic(guest, 'Runtime.consoleAPICalled', {}, 'S1')).toBe(true)
expect(isOrcaFrameSessionTraffic(guest, 'Runtime.consoleAPICalled', {}, 'AGENT')).toBe(false)
expect(isOrcaFrameSessionTraffic(guest, 'Page.loadEventFired', {}, undefined)).toBe(false)
expect(
isOrcaFrameSessionTraffic(guest, 'Target.detachedFromTarget', { sessionId: 'S1' }, undefined)
).toBe(true)
})
})
describe('sendFrameSessionInput', () => {
it('enables focus emulation once, retrying a failed enable before sending', async () => {
const guest = fakeGuest()
let failEmulation = true
const send = vi.fn<GuestSessionCdpSender>(async (method) => {
if (method === 'Emulation.setFocusEmulationEnabled' && failEmulation) {
failEmulation = false
throw new Error('Inspected target navigated or closed')
}
return {}
})
for (let i = 0; i < 3; i += 1) {
await sendFrameSessionInput(guest, send, 'S1', 'Input.insertText', { text: 'x' })
}
expect(send.mock.calls.slice(0, 3).map(([method]) => method)).toEqual([
'Emulation.setFocusEmulationEnabled',
'Emulation.setFocusEmulationEnabled',
'Input.insertText'
])
expect(
send.mock.calls.filter(([method]) => method === 'Emulation.setFocusEmulationEnabled')
).toHaveLength(2)
expect(send.mock.calls.filter(([method]) => method === 'Input.insertText')).toHaveLength(3)
})
})
@@ -0,0 +1,158 @@
import type { WebContents } from 'electron'
import {
crossSiteFrameTargets,
forgetFrameSession,
frameSessionFor,
readRecord,
readString,
settle,
type GuestSessionCdpSender
} from './guest-frame-input-sessions'
// Why: widget input (sendInputEvent) reaches only the guest's own renderer, so keys meant for a
// focused cross-site iframe would go to the main frame instead. CDP input on that iframe's own
// target session reaches it without moving the user's focus (measured; #20595 follow-up).
// See docs/reference/agent-browser-focus-isolation.md.
export { isOrcaFrameSessionTraffic, settleWithin } from './guest-frame-input-sessions'
export type { GuestSessionCdpSender } from './guest-frame-input-sessions'
/** Where keys for the page's focused element should go. */
export type FocusedFrameRoute =
| { kind: 'widget' }
| { kind: 'session'; sessionId: string }
| { kind: 'unknown' }
// Why: bounds the walk through nested frames; deeper focus takes the fallback.
const MAX_FRAME_DEPTH = 6
const PROBE_OBJECT_GROUP = 'orca-focused-frame-probe'
const PROBE_WORLD_NAME = 'orca-focused-frame-probe'
// Why: returns the focused frame owner itself (through open shadow roots), or null when focus is
// not on one, so its frame id can be read without a second evaluation.
const FOCUSED_FRAME_ELEMENT_EXPRESSION = `(() => {
let element = document.activeElement
while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement
const tag = element?.tagName
const owner = tag === 'IFRAME' || tag === 'FRAME' || tag === 'FENCEDFRAME' || tag === 'OBJECT' || tag === 'EMBED'
return owner ? element : null
})()`
type ProbeSite = { sessionId?: string; contextId?: number; targetId?: string }
type FocusProbe = { outcome: 'ok'; frameId: string | null } | { outcome: 'failed' | 'timeout' }
/** Frame id of the focused frame owner in one document (a session, or a frame's context in it). */
async function probeFocusedFrame(send: GuestSessionCdpSender, at: ProbeSite): Promise<FocusProbe> {
const evaluated = await settle(
send(
'Runtime.evaluate',
{
expression: FOCUSED_FRAME_ELEMENT_EXPRESSION,
objectGroup: PROBE_OBJECT_GROUP,
...(at.contextId === undefined ? {} : { contextId: at.contextId })
},
at.sessionId
)
)
if (evaluated.outcome !== 'ok') {
return evaluated
}
const result = readRecord(evaluated.value, 'result')
if (!result) {
return { outcome: 'failed' }
}
const objectId = readString(result, 'objectId')
if (!objectId) {
return { outcome: 'ok', frameId: null }
}
try {
const described = await settle(send('DOM.describeNode', { objectId }, at.sessionId))
return described.outcome === 'ok'
? { outcome: 'ok', frameId: readString(readRecord(described.value, 'node'), 'frameId') }
: described
} finally {
void send(
'Runtime.releaseObjectGroup',
{ objectGroup: PROBE_OBJECT_GROUP },
at.sessionId
).catch(() => undefined)
}
}
/** An execution context in a same-site frame, so focus inside it can be read. */
async function frameContext(
send: GuestSessionCdpSender,
sessionId: string | undefined,
frameId: string
): Promise<number | null> {
const created = await settle(
send('Page.createIsolatedWorld', { frameId, worldName: PROBE_WORLD_NAME }, sessionId)
)
if (created.outcome !== 'ok' || created.value === null || typeof created.value !== 'object') {
return null
}
const contextId: unknown = Object.getOwnPropertyDescriptor(
created.value,
'executionContextId'
)?.value
return typeof contextId === 'number' ? contextId : null
}
function routeFor(at: ProbeSite): FocusedFrameRoute {
return at.sessionId ? { kind: 'session', sessionId: at.sessionId } : { kind: 'widget' }
}
/**
* Follows the page's focus through frames. A same-site frame shares its parent's renderer, so
* the parent's route reaches it unless focus continues into a cross-site frame, which gets its
* own session. Anything this cannot follow is `unknown` and takes the focus-borrowing fallback.
*/
export async function resolveFocusedFrameRoute(
webContents: WebContents,
send: GuestSessionCdpSender
): Promise<FocusedFrameRoute> {
let at: ProbeSite = {}
let reattached = false
for (let depth = 0; depth < MAX_FRAME_DEPTH; depth += 1) {
const probe = await probeFocusedFrame(send, at)
if (probe.outcome !== 'ok') {
const deadSession = probe.outcome === 'failed' && at.contextId === undefined
if (deadSession && at.sessionId && at.targetId && !reattached) {
// Why: a cached session dies when the debugger re-attaches; attach once more and retry.
forgetFrameSession(webContents, at.sessionId)
reattached = true
const fresh = await frameSessionFor(webContents, send, at.targetId)
if (!fresh) {
return { kind: 'unknown' }
}
at = { sessionId: fresh, targetId: at.targetId }
continue
}
// Why: an unreadable main frame (navigation tore down the context) means main-frame focus.
return at.sessionId || at.contextId !== undefined ? { kind: 'unknown' } : { kind: 'widget' }
}
if (!probe.frameId) {
return routeFor(at)
}
let crossSite = await crossSiteFrameTargets(webContents, send, { refresh: false })
if (!crossSite.has(probe.frameId)) {
// Why: a frame that just navigated cross-site is missing from a cached list.
crossSite = await crossSiteFrameTargets(webContents, send, { refresh: true })
}
if (crossSite.has(probe.frameId)) {
const sessionId = await frameSessionFor(webContents, send, probe.frameId)
if (!sessionId) {
return { kind: 'unknown' }
}
at = { sessionId, targetId: probe.frameId }
reattached = false
continue
}
const contextId = await frameContext(send, at.sessionId, probe.frameId)
if (contextId === null) {
return { kind: 'unknown' }
}
at = { ...at, contextId }
}
return { kind: 'unknown' }
}
@@ -0,0 +1,214 @@
import type { WebContents } from 'electron'
// Why: Orca attaches its own CDP sessions to a guest's cross-site frames to deliver agent input
// there (see guest-focused-frame-route.ts). This file owns those sessions: one per frame target,
// adopted even when the attach answers late, and hidden from the agent's CDP client.
export type GuestSessionCdpSender = (
method: string,
params: Record<string, unknown>,
sessionId?: string
) => Promise<unknown>
// Why: a page paused in a debugger or blocked on a dialog never answers; input must not wait on it.
const PAGE_PROBE_TIMEOUT_MS = 500
// Why: the target list changes only when frames come and go; one lookup serves a burst of keys.
const TARGET_LIST_TTL_MS = 1_000
type FrameSessions = {
sessionIdByTargetId: Map<string, string>
ownedSessionIds: Set<string>
emulatedSessionIds: Set<string>
attachByTargetId: Map<string, Promise<string | null>>
crossSiteTargets: { ids: Set<string>; at: number } | null
}
const frameSessionsByGuest = new WeakMap<WebContents, FrameSessions>()
function frameSessionsFor(webContents: WebContents): FrameSessions {
let sessions = frameSessionsByGuest.get(webContents)
if (!sessions) {
sessions = {
sessionIdByTargetId: new Map(),
ownedSessionIds: new Set(),
emulatedSessionIds: new Set(),
attachByTargetId: new Map(),
crossSiteTargets: null
}
frameSessionsByGuest.set(webContents, sessions)
}
return sessions
}
export function readRecord(value: unknown, key: string): Record<string, unknown> | null {
if (value === null || typeof value !== 'object' || !(key in value)) {
return null
}
const field: unknown = Object.getOwnPropertyDescriptor(value, key)?.value
return field !== null && typeof field === 'object' && !Array.isArray(field)
? Object.fromEntries(Object.entries(field))
: null
}
export function readString(value: Record<string, unknown> | null, key: string): string | null {
const field = value?.[key]
return typeof field === 'string' ? field : null
}
export type Settled<T> = { outcome: 'ok'; value: T } | { outcome: 'failed' | 'timeout' }
/** Settles a page request within the probe timeout, telling a rejection from a slow page. */
export async function settle<T>(request: Promise<T>): Promise<Settled<T>> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
request.then(
(value): Settled<T> => ({ outcome: 'ok', value }),
(): Settled<T> => ({ outcome: 'failed' })
),
new Promise<Settled<T>>((resolve) => {
timer = setTimeout(() => resolve({ outcome: 'timeout' }), PAGE_PROBE_TIMEOUT_MS)
})
])
} finally {
clearTimeout(timer)
}
}
/** Resolves to `fallback` when the page rejects or does not answer within the probe timeout. */
export async function settleWithin<T>(request: Promise<T>, fallback: T): Promise<T> {
const settled = await settle(request)
return settled.outcome === 'ok' ? settled.value : fallback
}
/**
* True for debugger traffic that belongs to Orca's own frame sessions; the CDP proxy keeps it
* from the agent's client, which never asked for those sessions.
*/
export function isOrcaFrameSessionTraffic(
webContents: WebContents,
method: string,
params: Record<string, unknown> | undefined,
sessionId: string | undefined
): boolean {
const sessions = frameSessionsByGuest.get(webContents)
if (!sessions) {
return false
}
if (sessionId && sessions.ownedSessionIds.has(sessionId)) {
return true
}
if (method !== 'Target.attachedToTarget' && method !== 'Target.detachedFromTarget') {
return false
}
const eventSessionId = readString(params ?? null, 'sessionId')
const targetId = readString(readRecord(params, 'targetInfo'), 'targetId')
const owned = eventSessionId !== null && sessions.ownedSessionIds.has(eventSessionId)
if (method === 'Target.detachedFromTarget' && eventSessionId) {
forgetFrameSession(webContents, eventSessionId)
sessions.ownedSessionIds.delete(eventSessionId)
}
// Why: Chromium reports Orca's attach before answering it, while the attach is still in flight.
return owned || (targetId !== null && sessions.attachByTargetId.has(targetId))
}
export function forgetFrameSession(webContents: WebContents, sessionId: string): void {
const sessions = frameSessionsFor(webContents)
sessions.emulatedSessionIds.delete(sessionId)
for (const [targetId, cached] of sessions.sessionIdByTargetId) {
if (cached === sessionId) {
sessions.sessionIdByTargetId.delete(targetId)
}
}
}
/** Orca's session on a frame target; concurrent callers share one attach, and a late one is kept. */
export async function frameSessionFor(
webContents: WebContents,
send: GuestSessionCdpSender,
targetId: string
): Promise<string | null> {
const sessions = frameSessionsFor(webContents)
const cached = sessions.sessionIdByTargetId.get(targetId)
if (cached) {
return cached
}
let attach = sessions.attachByTargetId.get(targetId)
if (!attach) {
attach = send('Target.attachToTarget', { targetId, flatten: true })
.then((attached) => {
const sessionId = readString(
attached !== null && typeof attached === 'object'
? Object.fromEntries(Object.entries(attached))
: null,
'sessionId'
)
if (sessionId) {
// Why: owned even if this caller gave up waiting, so its events stay hidden.
sessions.ownedSessionIds.add(sessionId)
sessions.sessionIdByTargetId.set(targetId, sessionId)
}
return sessionId
})
.catch(() => null)
.finally(() => sessions.attachByTargetId.delete(targetId))
sessions.attachByTargetId.set(targetId, attach)
}
return await settleWithin(attach, null)
}
/** Cross-site frame target ids, from a briefly cached target list. */
export async function crossSiteFrameTargets(
webContents: WebContents,
send: GuestSessionCdpSender,
{ refresh }: { refresh: boolean }
): Promise<Set<string>> {
const sessions = frameSessionsFor(webContents)
const cached = sessions.crossSiteTargets
if (!refresh && cached && Date.now() - cached.at < TARGET_LIST_TTL_MS) {
return cached.ids
}
const response = await settleWithin(send('Target.getTargets', {}), null)
const infos =
response !== null && typeof response === 'object' && 'targetInfos' in response
? Object.getOwnPropertyDescriptor(response, 'targetInfos')?.value
: null
const ids = new Set<string>()
if (Array.isArray(infos)) {
for (const info of infos) {
if (info !== null && typeof info === 'object') {
const record = Object.fromEntries(Object.entries(info))
const targetId = readString(record, 'targetId')
if (record.type === 'iframe' && targetId) {
ids.add(targetId)
}
}
}
}
sessions.crossSiteTargets = { ids, at: Date.now() }
return ids
}
/** Sends input on a frame session with focus emulation on, enabled once per session. */
export async function sendFrameSessionInput(
webContents: WebContents,
send: GuestSessionCdpSender,
sessionId: string,
method: string,
params: Record<string, unknown>
): Promise<unknown> {
const sessions = frameSessionsFor(webContents)
if (!sessions.emulatedSessionIds.has(sessionId)) {
// Why: editors that check document.hasFocus() need it. Retried once and recorded only on
// success; input is sent either way, as a frame failing twice is navigating and loses it anyway.
const enable = (): Promise<boolean> =>
send('Emulation.setFocusEmulationEnabled', { enabled: true }, sessionId).then(
() => true,
() => false
)
if ((await enable()) || (await enable())) {
sessions.emulatedSessionIds.add(sessionId)
}
}
return await send(method, params, sessionId)
}
@@ -13,6 +13,7 @@ import {
evalInPage,
readHost,
runAgentClicksScenario,
runAgentCrossSiteFrameScenario,
runAgentKeysScenario,
runAgentShortcutsScenario,
type AfterAgentInput
@@ -46,7 +47,8 @@ function realKeystrokeReachesTerminal(
const scenarios = [
['agent keys and text', runAgentKeysScenario],
['agent clicks and presses', runAgentClicksScenario],
['agent shortcut keys', runAgentShortcutsScenario]
['agent shortcut keys', runAgentShortcutsScenario],
['agent keys in a cross-site frame', runAgentCrossSiteFrameScenario]
] as const
for (const [name, run] of scenarios) {
@@ -6,6 +6,7 @@
import { test } from './helpers/orca-app'
import {
runAgentClicksScenario,
runAgentCrossSiteFrameScenario,
runAgentKeysScenario,
runAgentShortcutsScenario
} from './helpers/browser-agent-focus-scenarios'
@@ -30,3 +31,10 @@ test('agent shortcut keys stay in the page instead of running Orca shortcuts', a
test.setTimeout(120_000)
await runAgentShortcutsScenario(orcaPage)
})
test('agent keys and text reach a focused cross-site frame without moving the user’s focus', async ({
orcaPage
}) => {
test.setTimeout(120_000)
await runAgentCrossSiteFrameScenario(orcaPage)
})
@@ -27,7 +27,16 @@ const PAGE_HTML = `<!doctype html>
<form id="form"><input id="probe" /></form>
<textarea id="notes"></textarea>
<button id="target" style="position:absolute;left:40px;top:80px;width:200px;height:60px">target</button>
<iframe id="frame" src="__FRAME_URL__" style="position:absolute;left:40px;top:200px;width:300px;height:80px"></iframe>
<script>
window.__frameValue = ''
window.__frameKeys = []
window.__frameReady = false
addEventListener('message', (event) => {
if (typeof event.data?.frameValue === 'string') window.__frameValue = event.data.frameValue
if (typeof event.data?.frameKey === 'string') window.__frameKeys.push(event.data.frameKey)
if (event.data?.frameReady) window.__frameReady = true
})
window.__keys = []
window.__submits = 0
window.__clicks = 0
@@ -43,10 +52,40 @@ const PAGE_HTML = `<!doctype html>
</body>
</html>`
// Why: served from localhost while the page is on 127.0.0.1, so it is cross-site and Chromium puts
// it in its own process, the case widget input cannot reach.
const FRAME_HTML = `<!doctype html>
<html>
<body>
<input id="field" style="position:absolute;left:0;top:0;width:280px;height:60px" />
<script>
const field = document.getElementById('field')
// Why: a cross-site frame cannot focus itself without user activation, so the field takes
// focus on the first key the frame receives.
addEventListener(
'keydown',
(event) => {
if (document.activeElement !== field) field.focus()
parent.postMessage({ frameKey: event.key }, '*')
},
true
)
field.addEventListener('input', () => parent.postMessage({ frameValue: field.value }, '*'))
parent.postMessage({ frameReady: true }, '*')
</script>
</body>
</html>`
async function startProbeServer(): Promise<{ url: string; close: () => Promise<void> }> {
const server: Server = createServer((_request, response) => {
const server: Server = createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
response.end(PAGE_HTML)
const address = server.address()
const port = address !== null && typeof address === 'object' ? address.port : 0
response.end(
request.url === '/frame'
? FRAME_HTML
: PAGE_HTML.replace('__FRAME_URL__', `http://localhost:${port}/frame`)
)
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
@@ -56,9 +95,12 @@ async function startProbeServer(): Promise<{ url: string; close: () => Promise<v
return {
url: `http://127.0.0.1:${address.port}/`,
close: () =>
new Promise<void>((resolve, reject) =>
new Promise<void>((resolve, reject) => {
// Why: the page and its cross-site frame hold keep-alive sockets; on Windows close()
// otherwise waits for them until the test times out.
server.closeAllConnections()
server.close((error) => (error ? reject(error) : resolve()))
)
})
}
}
@@ -273,3 +315,36 @@ export async function runAgentShortcutsScenario(
await server.close()
}
}
export async function runAgentCrossSiteFrameScenario(
page: Page,
afterAgentInput?: AfterAgentInput
): Promise<void> {
const server = await startProbeServer()
try {
const pageId = await openBackgroundProbeTab(page, server.url)
await focusTerminalAndWatchHost(page)
await expect.poll(() => evalInPage(page, pageId, 'String(window.__frameReady)')).toBe('true')
await evalInPage(page, pageId, `document.getElementById('frame').focus()`)
await expect
.poll(() => evalInPage(page, pageId, 'document.activeElement && document.activeElement.id'))
.toBe('frame')
const hostLogBeforeTyping = (await readHost(page)).log.length
// Why: keys and text for a focused cross-site frame go to its own session, so the user's
// focus never moves; before, they borrowed it.
await rpc(page, 'browser.keypress', { page: pageId, key: 'Z' })
await expect
.poll(() => evalInPage(page, pageId, 'JSON.stringify(window.__frameKeys)'))
.toBe('["Z"]')
await rpc(page, 'browser.type', { page: pageId, input: 'qr' })
await expect.poll(() => evalInPage(page, pageId, 'window.__frameValue')).toContain('qr')
const host = await readHost(page)
expect(host.log.slice(hostLogBeforeTyping)).toEqual([])
expect(host.terminalFocused).toBe(true)
await afterAgentInput?.(pageId)
} finally {
await server.close()
}
}