From 60ca05a358212ba36c098a05e1302c5ab3e1b30e Mon Sep 17 00:00:00 2001 From: Yishan Date: Wed, 12 Aug 2026 08:42:37 -0700 Subject: [PATCH 1/3] fix(browser): add cross-origin frame enumeration fallback --- extension/src/background.test.ts | 138 ++++++++++++++++++++++++++++++- extension/src/background.ts | 73 +++++++++++++++- extension/src/cdp.test.ts | 6 +- extension/src/cdp.ts | 19 +++++ src/browser/dom-snapshot.ts | 6 ++ 5 files changed, 234 insertions(+), 8 deletions(-) diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index e45614843..071233045 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -339,6 +339,142 @@ describe('background tab isolation', () => { ]); }); + it('falls back to iframe targets when the DOM sees a cross-origin frame missing from the frame tree', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { + result: { + value: [{ url: 'https://frame.example/reviews', name: 'app-iframe' }], + }, + }; + } + if (method === 'Page.getFrameTree') { + return { + frameTree: { + frame: { id: 'root', url: 'https://main.example/' }, + childFrames: [], + }, + }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'oopif-target', type: 'iframe', url: 'https://frame.example/reviews', title: 'app-iframe' }, + ], + }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-fallback', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual({ + id: 'frames-fallback', + ok: true, + data: [ + { index: 0, frameId: 'oopif-target', url: 'https://frame.example/reviews', name: 'app-iframe' }, + ], + }); + }); + + it('reports frame_enumeration_mismatch when the DOM frame has no frame tree or target match', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { + result: { + value: [{ url: 'https://missing.example/reviews', name: 'app-iframe' }], + }, + }; + } + if (method === 'Page.getFrameTree') { + return { + frameTree: { + frame: { id: 'root', url: 'https://main.example/' }, + childFrames: [], + }, + }; + } + if (method === 'Target.getTargets') return { targetInfos: [] }; + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-mismatch', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual(expect.objectContaining({ + id: 'frames-mismatch', + ok: false, + errorCode: 'frame_enumeration_mismatch', + })); + }); + + it('uses the same fallback frame for eval --frame after Page.getFrameTree omits it', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (target: { tabId?: number; targetId?: string }, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + if (target.targetId === 'oopif-target' && params?.expression === 'document.title') { + return { result: { value: 'Frame title' } }; + } + return { + result: { + value: [{ url: 'https://frame.example/reviews', name: 'app-iframe' }], + }, + }; + } + if (method === 'Page.getFrameTree') { + return { + frameTree: { + frame: { id: 'root', url: 'https://main.example/' }, + childFrames: [], + }, + }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'oopif-target', type: 'iframe', url: 'https://frame.example/reviews', title: 'app-iframe' }, + ], + }; + } + if (method === 'Target.setDiscoverTargets' || method === 'Target.setAutoAttach') return {}; + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(browserKey('judgeme'), 1); + + const result = await mod.__test__.handleCommand({ + id: 'frame-eval-fallback', + action: 'exec', + session: 'judgeme', + surface: 'browser', + frameIndex: 0, + code: 'document.title', + }); + + expect(result).toEqual(expect.objectContaining({ + id: 'frame-eval-fallback', + ok: true, + data: 'Frame title', + })); + }); + it('does not parse lease-key separators from command session fields', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); @@ -1173,7 +1309,7 @@ describe('background tab isolation', () => { // SW restart and can dodge idle expiry indefinitely. expect(scheduledWhen).toBeLessThan(now + 15_000); expect(scheduledWhen).toBeGreaterThan(now + 1_000); - expect(mod.__test__.getSession(adapterKey('twitter')).idleDeadlineAt).toBeLessThan(now + 15_000); + expect(mod.__test__.getSession(adapterKey('twitter'))!.idleDeadlineAt).toBeLessThan(now + 15_000); }); it('releases owned leases from the idle alarm path', async () => { diff --git a/extension/src/background.ts b/extension/src/background.ts index 4918894c2..67859a659 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1437,6 +1437,73 @@ function enumerateCrossOriginFrames(tree: any): Array<{ index: number; frameId: return frames; } +type CrossOriginFrame = { index: number; frameId: string; url: string; name: string }; +type DomCrossOriginFrame = { url: string; name: string }; + +const READ_SNAPSHOT_FRAMES_JS = `(() => { + const frames = window.__opencli_cross_origin_frames; + return Array.isArray(frames) ? frames : []; +})()`; + +function sameFrameReference(left: { url: string; name: string }, right: { url: string; name: string }): boolean { + if (left.url && right.url && left.url === right.url) return true; + return !!left.name && left.name === right.name; +} + +async function resolveCrossOriginFrames(tabId: number): Promise { + const tree = await executor.getFrameTree(tabId); + const treeFrames = enumerateCrossOriginFrames(tree); + const rawDomFrames = await executor.evaluateAsync(tabId, READ_SNAPSHOT_FRAMES_JS); + if (!Array.isArray(rawDomFrames) || rawDomFrames.length === 0) return treeFrames; + + const domFrames = rawDomFrames.filter((frame): frame is DomCrossOriginFrame => ( + !!frame && typeof frame === 'object' + && typeof (frame as DomCrossOriginFrame).url === 'string' + && typeof (frame as DomCrossOriginFrame).name === 'string' + )); + if (domFrames.length === 0) return treeFrames; + + const targets = await executor.getIframeTargets(tabId); + const unusedTreeFrames = new Set(treeFrames); + const unusedTargets = new Set(targets); + const resolved: CrossOriginFrame[] = []; + + for (const domFrame of domFrames) { + const treeFrame = [...unusedTreeFrames].find((frame) => sameFrameReference(domFrame, frame)); + if (treeFrame) { + unusedTreeFrames.delete(treeFrame); + resolved.push({ ...treeFrame, index: resolved.length, name: domFrame.name || treeFrame.name }); + continue; + } + + const target = [...unusedTargets].find((candidate) => sameFrameReference(domFrame, { + url: candidate.url, + name: candidate.title, + })); + if (target) { + unusedTargets.delete(target); + resolved.push({ + index: resolved.length, + frameId: target.targetId, + url: domFrame.url || target.url, + name: domFrame.name || target.title, + }); + continue; + } + + throw new CommandFailure( + 'frame_enumeration_mismatch', + `DOM snapshot frame ${resolved.length} (${domFrame.url || domFrame.name || 'unknown'}) was missing from Page.getFrameTree and Target.getTargets.`, + 'Refresh browser state and retry. If the mismatch persists, report the page URL and sanitized frame metadata.', + ); + } + + for (const frame of unusedTreeFrames) { + resolved.push({ ...frame, index: resolved.length }); + } + return resolved; +} + function setLeaseSession( leaseKey: string, session: Omit, @@ -1671,8 +1738,7 @@ async function handleExec(cmd: Command, leaseKey: string): Promise { try { const aggressive = getSurfaceFromKey(leaseKey) === 'browser'; if (cmd.frameIndex != null) { - const tree = await executor.getFrameTree(tabId); - const frames = enumerateCrossOriginFrames(tree); + const frames = await resolveCrossOriginFrames(tabId); if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) { return { id: cmd.id, ok: false, error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` }; } @@ -1690,8 +1756,7 @@ async function handleFrames(cmd: Command, leaseKey: string): Promise { const cmdTabId = await resolveCommandTabId(cmd); const tabId = await resolveTabId(cmdTabId, leaseKey); try { - const tree = await executor.getFrameTree(tabId); - return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) }; + return { id: cmd.id, ok: true, data: await resolveCrossOriginFrames(tabId) }; } catch (err) { return errorResult(cmd.id, err); } diff --git a/extension/src/cdp.test.ts b/extension/src/cdp.test.ts index 3d96fd7ad..75464f72a 100644 --- a/extension/src/cdp.test.ts +++ b/extension/src/cdp.test.ts @@ -476,7 +476,7 @@ describe('cdp network capture correctness', () => { }); function createNetworkMock() { - const onEventListeners = []; + const onEventListeners: Array<(source: { tabId?: number }, method: string, params: any) => void | Promise> = []; const debuggerApi = { attach: vi.fn(async () => {}), detach: vi.fn(async () => {}), @@ -493,7 +493,7 @@ describe('cdp network capture correctness', () => { onRemoved: { addListener: vi.fn() }, onUpdated: { addListener: vi.fn() }, }; - const fire = async (method, params) => { + const fire = async (method: string, params: any) => { for (const fn of onEventListeners) await fn({ tabId: 1 }, method, params); }; return { @@ -563,7 +563,7 @@ describe('cdp evaluateInFrame stale context fallback', () => { }); it('falls back to the frame target when the cached context id went stale', async () => { - const debuggerEventListeners = []; + const debuggerEventListeners: Array<(source: { tabId?: number }, method: string, params: any) => void> = []; const debuggerApi = { attach: vi.fn(async () => {}), detach: vi.fn(async () => {}), diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index c2ce05149..035b3fe76 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -650,6 +650,25 @@ export async function getFrameTree(tabId: number): Promise { return sendDebuggerCommand({ tabId }, 'Page.getFrameTree'); } +export type IframeTarget = { + targetId: string; + url: string; + title: string; +}; + +export async function getIframeTargets(tabId: number): Promise { + await ensureAttached(tabId); + const result = await sendDebuggerCommand<{ targetInfos?: Array<{ targetId?: string; id?: string; type?: string; url?: string; title?: string }> }>( + { tabId }, + 'Target.getTargets', + ); + return (result.targetInfos ?? []).flatMap((target) => { + const targetId = target.targetId || target.id; + if (target.type !== 'iframe' || !targetId) return []; + return [{ targetId, url: target.url || '', title: target.title || '' }]; + }); +} + export async function evaluateInFrame( tabId: number, expression: string, diff --git a/src/browser/dom-snapshot.ts b/src/browser/dom-snapshot.ts index e47518aed..e1c1fa791 100644 --- a/src/browser/dom-snapshot.ts +++ b/src/browser/dom-snapshot.ts @@ -663,6 +663,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { const compoundInfos = {}; let iframeCount = 0; let crossOriginIndex = 0; + const crossOriginFrames = []; function walk(el, depth, parentPropagatingRect) { if (depth > MAX_DEPTH) return false; @@ -861,6 +862,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { if (!doc || !doc.body) { const attrs = serializeAttrs(el); const frameLabel = '[F' + crossOriginIndex + ']'; + crossOriginFrames.push({ url: el.src || el.getAttribute('src') || '', name: el.name || el.title || '' }); lines.push(indent + '|iframe|' + frameLabel + ' (cross-origin, use: opencli browser frames + browser eval --frame )'); crossOriginIndex++; return false; @@ -876,6 +878,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { } catch { const attrs = serializeAttrs(el); const frameLabel = '[F' + crossOriginIndex + ']'; + crossOriginFrames.push({ url: el.src || el.getAttribute('src') || '', name: el.name || el.title || '' }); lines.push(indent + '|iframe|' + frameLabel + ' (blocked, use: opencli browser frames + browser eval --frame )'); crossOriginIndex++; return false; @@ -929,6 +932,9 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { try { window.__opencli_prev_hashes = JSON.stringify(currentHashes); } catch {} // Store ref identity map for stale-ref detection by target resolver try { window.__opencli_ref_identity = refIdentity; } catch {} + // Keep the DOM snapshot's [F#] inputs so browser frames can recover OOPIFs + // omitted by Page.getFrameTree without assigning a different frame order. + try { window.__opencli_cross_origin_frames = crossOriginFrames; } catch {} return lines.join('\\n'); })() From b342ef724fa5981bc8950ed0acb755dd0a54cc27 Mon Sep 17 00:00:00 2001 From: Yishan Date: Wed, 12 Aug 2026 08:42:37 -0700 Subject: [PATCH 2/3] fix(browser): add cross-origin frame enumeration fallback --- extension/src/background.test.ts | 299 ++++++++++++++++++++++++++++++- extension/src/background.ts | 116 +++++++++++- extension/src/cdp.test.ts | 36 +++- extension/src/cdp.ts | 55 ++++++ src/browser/dom-snapshot.ts | 13 ++ 5 files changed, 511 insertions(+), 8 deletions(-) diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index e45614843..82bb16157 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -339,6 +339,303 @@ describe('background tab isolation', () => { ]); }); + it('falls back to iframe targets when the DOM sees a cross-origin frame missing from the frame tree', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { + result: { + value: [{ url: 'https://frame.example/reviews', name: 'app-iframe' }], + }, + }; + } + if (method === 'Page.getFrameTree') { + return { + frameTree: { + frame: { id: 'root', url: 'https://main.example/' }, + childFrames: [], + }, + }; + } + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'root-target', type: 'page' } }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'oopif-target', parentId: 'root-target', type: 'iframe', url: 'https://frame.example/reviews', title: 'app-iframe' }, + ], + }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-fallback', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual({ + id: 'frames-fallback', + ok: true, + data: [ + { index: 0, frameId: 'oopif-target', url: 'https://frame.example/reviews', name: 'app-iframe' }, + ], + }); + }); + + it('does not require iframe targets when Page.getFrameTree already matches the DOM frame', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { + result: { + value: [{ url: 'https://frame.example/reviews', name: 'app-iframe' }], + }, + }; + } + if (method === 'Page.getFrameTree') { + return { + frameTree: { + frame: { id: 'root', url: 'https://main.example/' }, + childFrames: [ + { frame: { id: 'tree-frame', url: 'https://frame.example/reviews', name: 'app-iframe' } }, + ], + }, + }; + } + if (method === 'Target.getTargets') throw new Error('fallback should not run'); + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-tree-only', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual({ + id: 'frames-tree-only', + ok: true, + data: [ + { index: 0, frameId: 'tree-frame', url: 'https://frame.example/reviews', name: 'app-iframe' }, + ], + }); + }); + + it('reports frame_enumeration_mismatch when the DOM frame has no frame tree or target match', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { + result: { + value: [{ url: 'https://missing.example/reviews', name: 'app-iframe' }], + }, + }; + } + if (method === 'Page.getFrameTree') { + return { + frameTree: { + frame: { id: 'root', url: 'https://main.example/' }, + childFrames: [], + }, + }; + } + if (method === 'Target.getTargets') return { targetInfos: [] }; + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'root-target', type: 'page' } }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-mismatch', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual(expect.objectContaining({ + id: 'frames-mismatch', + ok: false, + errorCode: 'frame_enumeration_mismatch', + })); + }); + + it('does not match a stale DOM frame to a different target with the same name', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { + result: { + value: [{ url: 'https://old.example/reviews', name: 'app-iframe' }], + }, + }; + } + if (method === 'Page.getFrameTree') { + return { + frameTree: { + frame: { id: 'root', url: 'https://main.example/' }, + childFrames: [], + }, + }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'new-target', type: 'iframe', url: 'https://new.example/reviews', title: 'app-iframe' }, + ], + }; + } + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'root-target', type: 'page' } }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-stale', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual(expect.objectContaining({ + id: 'frames-stale', + ok: false, + errorCode: 'frame_enumeration_mismatch', + })); + }); + + it('reports frame_enumeration_mismatch when multiple iframe targets share the same URL', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { result: { value: [{ url: 'https://frame.example/reviews', name: '' }] } }; + } + if (method === 'Page.getFrameTree') { + return { frameTree: { frame: { id: 'root', url: 'https://main.example/' }, childFrames: [] } }; + } + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'root-target', type: 'page' } }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'iframe-a', parentId: 'root-target', type: 'iframe', url: 'https://frame.example/reviews' }, + { targetId: 'iframe-b', parentId: 'root-target', type: 'iframe', url: 'https://frame.example/reviews' }, + ], + }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-ambiguous', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual(expect.objectContaining({ + id: 'frames-ambiguous', + ok: false, + errorCode: 'frame_enumeration_mismatch', + })); + }); + + it('reports frame_enumeration_mismatch when snapshot iframe identity is stale', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { result: { value: { mismatch: true } } }; + } + if (method === 'Page.getFrameTree') { + return { frameTree: { frame: { id: 'root', url: 'https://main.example/' }, childFrames: [] } }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-stale-identity', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual(expect.objectContaining({ + id: 'frames-stale-identity', + ok: false, + errorCode: 'frame_enumeration_mismatch', + })); + }); + + it('uses the same fallback frame for eval --frame after Page.getFrameTree omits it', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (target: { tabId?: number; targetId?: string }, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + if (target.targetId === 'oopif-target' && params?.expression === 'document.title') { + return { result: { value: 'Frame title' } }; + } + return { + result: { + value: [{ url: 'https://frame.example/reviews', name: 'app-iframe' }], + }, + }; + } + if (method === 'Page.getFrameTree') { + return { + frameTree: { + frame: { id: 'root', url: 'https://main.example/' }, + childFrames: [], + }, + }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'oopif-target', parentId: 'root-target', type: 'iframe', url: 'https://frame.example/reviews', title: 'app-iframe' }, + ], + }; + } + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'root-target', type: 'page' } }; + } + if (method === 'Target.setDiscoverTargets' || method === 'Target.setAutoAttach') return {}; + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(browserKey('judgeme'), 1); + + const result = await mod.__test__.handleCommand({ + id: 'frame-eval-fallback', + action: 'exec', + session: 'judgeme', + surface: 'browser', + frameIndex: 0, + code: 'document.title', + }); + + expect(result).toEqual(expect.objectContaining({ + id: 'frame-eval-fallback', + ok: true, + data: 'Frame title', + })); + }); + it('does not parse lease-key separators from command session fields', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); @@ -1173,7 +1470,7 @@ describe('background tab isolation', () => { // SW restart and can dodge idle expiry indefinitely. expect(scheduledWhen).toBeLessThan(now + 15_000); expect(scheduledWhen).toBeGreaterThan(now + 1_000); - expect(mod.__test__.getSession(adapterKey('twitter')).idleDeadlineAt).toBeLessThan(now + 15_000); + expect(mod.__test__.getSession(adapterKey('twitter'))!.idleDeadlineAt).toBeLessThan(now + 15_000); }); it('releases owned leases from the idle alarm path', async () => { diff --git a/extension/src/background.ts b/extension/src/background.ts index 4918894c2..3766b435d 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1437,6 +1437,116 @@ function enumerateCrossOriginFrames(tree: any): Array<{ index: number; frameId: return frames; } +type CrossOriginFrame = { index: number; frameId: string; url: string; name: string }; +type DomCrossOriginFrame = { url: string; name: string }; + +const READ_SNAPSHOT_FRAMES_JS = `(() => { + const state = window.__opencli_cross_origin_frames; + if (!state || !Array.isArray(state.frames) || !Array.isArray(state.allElements)) return []; + const currentElements = Array.from(document.querySelectorAll('iframe')).filter((element) => { + try { + const doc = element.contentDocument; + return !doc || !doc.body; + } catch { + return true; + } + }); + const currentMatchesSnapshot = state.documentUrl === location.href + && currentElements.length === state.allElements.length + && currentElements.every((element, index) => element === state.allElements[index]); + const framesAreCurrent = state.frames.length === currentElements.length + && state.frames.every((frame, index) => frame.element?.isConnected + && frame.element === currentElements[index]); + if (!currentMatchesSnapshot || !framesAreCurrent) return { mismatch: true }; + return currentElements.map((element) => ({ + url: element.src || element.getAttribute('src') || '', + name: element.name || element.title || '', + })); +})()`; + +function sameFrameReference(left: { url: string; name: string }, right: { url: string; name: string }): boolean { + if (left.url && right.url) return left.url === right.url; + return !!left.name && !!right.name && left.name === right.name; +} + +async function resolveCrossOriginFrames(tabId: number): Promise { + const tree = await executor.getFrameTree(tabId); + const treeFrames = enumerateCrossOriginFrames(tree); + const rawDomFrames = await executor.evaluateAsync(tabId, READ_SNAPSHOT_FRAMES_JS); + if (rawDomFrames && typeof rawDomFrames === 'object' && !Array.isArray(rawDomFrames) + && (rawDomFrames as { mismatch?: boolean }).mismatch === true) { + throw new CommandFailure( + 'frame_enumeration_mismatch', + 'The DOM iframe set changed after the last snapshot, so its [F#] references are stale.', + 'Run browser state again before listing or evaluating frames.', + ); + } + if (!Array.isArray(rawDomFrames) || rawDomFrames.length === 0) return treeFrames; + + const domFrames = rawDomFrames.filter((frame): frame is DomCrossOriginFrame => ( + !!frame && typeof frame === 'object' + && typeof (frame as DomCrossOriginFrame).url === 'string' + && typeof (frame as DomCrossOriginFrame).name === 'string' + )); + if (domFrames.length === 0) return treeFrames; + + const unusedTreeFrames = new Set(treeFrames); + let unusedTargets: Set | null = null; + const resolved: CrossOriginFrame[] = []; + + for (const domFrame of domFrames) { + const treeMatches = [...unusedTreeFrames].filter((frame) => sameFrameReference(domFrame, frame)); + if (treeMatches.length === 1) { + const treeFrame = treeMatches[0]; + unusedTreeFrames.delete(treeFrame); + resolved.push({ ...treeFrame, index: resolved.length, name: domFrame.name || treeFrame.name }); + continue; + } + if (treeMatches.length > 1) { + throw new CommandFailure( + 'frame_enumeration_mismatch', + `DOM snapshot frame ${resolved.length} (${domFrame.url || domFrame.name || 'unknown'}) matched multiple Page.getFrameTree frames.`, + 'Use unique iframe URLs/names or refresh browser state before retrying.', + ); + } + + unusedTargets ??= new Set(await executor.getIframeTargets(tabId)); + const targetMatches = [...unusedTargets].filter((candidate) => sameFrameReference(domFrame, { + url: candidate.url, + name: candidate.title, + })); + if (targetMatches.length === 1) { + const target = targetMatches[0]; + unusedTargets.delete(target); + resolved.push({ + index: resolved.length, + frameId: target.targetId, + url: domFrame.url || target.url, + name: domFrame.name || target.title, + }); + continue; + } + if (targetMatches.length > 1) { + throw new CommandFailure( + 'frame_enumeration_mismatch', + `DOM snapshot frame ${resolved.length} (${domFrame.url || domFrame.name || 'unknown'}) matched multiple iframe targets.`, + 'Use unique iframe URLs/names or refresh browser state before retrying.', + ); + } + + throw new CommandFailure( + 'frame_enumeration_mismatch', + `DOM snapshot frame ${resolved.length} (${domFrame.url || domFrame.name || 'unknown'}) was missing from Page.getFrameTree and Target.getTargets.`, + 'Refresh browser state and retry. If the mismatch persists, report the page URL and sanitized frame metadata.', + ); + } + + for (const frame of unusedTreeFrames) { + resolved.push({ ...frame, index: resolved.length }); + } + return resolved; +} + function setLeaseSession( leaseKey: string, session: Omit, @@ -1671,8 +1781,7 @@ async function handleExec(cmd: Command, leaseKey: string): Promise { try { const aggressive = getSurfaceFromKey(leaseKey) === 'browser'; if (cmd.frameIndex != null) { - const tree = await executor.getFrameTree(tabId); - const frames = enumerateCrossOriginFrames(tree); + const frames = await resolveCrossOriginFrames(tabId); if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) { return { id: cmd.id, ok: false, error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` }; } @@ -1690,8 +1799,7 @@ async function handleFrames(cmd: Command, leaseKey: string): Promise { const cmdTabId = await resolveCommandTabId(cmd); const tabId = await resolveTabId(cmdTabId, leaseKey); try { - const tree = await executor.getFrameTree(tabId); - return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) }; + return { id: cmd.id, ok: true, data: await resolveCrossOriginFrames(tabId) }; } catch (err) { return errorResult(cmd.id, err); } diff --git a/extension/src/cdp.test.ts b/extension/src/cdp.test.ts index 3d96fd7ad..c31cb90be 100644 --- a/extension/src/cdp.test.ts +++ b/extension/src/cdp.test.ts @@ -122,6 +122,36 @@ describe('cdp attach recovery', () => { ); }); + it('returns only iframe targets whose parent chain reaches the current tab target', async () => { + const { chrome, debuggerApi } = createChromeMock(); + debuggerApi.sendCommand = vi.fn(async (_target: unknown, method: string): Promise => { + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'current-page', type: 'page' } }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'current-child', parentId: 'current-page', type: 'iframe', url: 'https://same.example/embed' }, + { targetId: 'nested-child', parentId: 'current-child', type: 'iframe', url: 'https://nested.example/embed' }, + { targetId: 'other-page', type: 'page', url: 'https://other.example/' }, + { targetId: 'other-child', parentId: 'other-page', type: 'iframe', url: 'https://same.example/embed' }, + { targetId: 'unowned-child', type: 'iframe', url: 'https://same.example/embed' }, + ], + }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + const result = await mod.getIframeTargets(1); + + expect(result).toEqual([ + { targetId: 'current-child', url: 'https://same.example/embed', title: '' }, + { targetId: 'nested-child', url: 'https://nested.example/embed', title: '' }, + ]); + }); + }); function chromeMockForScreenshot(content: { width: number; height: number } = { width: 1024, height: 2048 }) { @@ -476,7 +506,7 @@ describe('cdp network capture correctness', () => { }); function createNetworkMock() { - const onEventListeners = []; + const onEventListeners: Array<(source: { tabId?: number }, method: string, params: any) => void | Promise> = []; const debuggerApi = { attach: vi.fn(async () => {}), detach: vi.fn(async () => {}), @@ -493,7 +523,7 @@ describe('cdp network capture correctness', () => { onRemoved: { addListener: vi.fn() }, onUpdated: { addListener: vi.fn() }, }; - const fire = async (method, params) => { + const fire = async (method: string, params: any) => { for (const fn of onEventListeners) await fn({ tabId: 1 }, method, params); }; return { @@ -563,7 +593,7 @@ describe('cdp evaluateInFrame stale context fallback', () => { }); it('falls back to the frame target when the cached context id went stale', async () => { - const debuggerEventListeners = []; + const debuggerEventListeners: Array<(source: { tabId?: number }, method: string, params: any) => void> = []; const debuggerApi = { attach: vi.fn(async () => {}), detach: vi.fn(async () => {}), diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index c2ce05149..69e343cf5 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -650,6 +650,61 @@ export async function getFrameTree(tabId: number): Promise { return sendDebuggerCommand({ tabId }, 'Page.getFrameTree'); } +export type IframeTarget = { + targetId: string; + url: string; + title: string; +}; + +export async function getIframeTargets(tabId: number): Promise { + await ensureAttached(tabId); + type TargetInfo = { + targetId?: string; + id?: string; + type?: string; + url?: string; + title?: string; + parentId?: string; + }; + const [current, result] = await Promise.all([ + sendDebuggerCommand<{ targetInfo?: TargetInfo }>({ tabId }, 'Target.getTargetInfo'), + sendDebuggerCommand<{ targetInfos?: TargetInfo[] }>( + { tabId }, + 'Target.getTargets', + ), + ]); + const rootTargetId = current.targetInfo?.targetId || current.targetInfo?.id; + if (!rootTargetId) return []; + const targetInfos = result.targetInfos ?? []; + const targetsById = new Map(targetInfos.flatMap((target) => { + const targetId = target.targetId || target.id; + return targetId ? [[targetId, target] as const] : []; + })); + + function belongsToCurrentTarget(target: TargetInfo): boolean { + const visited = new Set(); + let parentId = target.parentId; + while (parentId && !visited.has(parentId)) { + if (parentId === rootTargetId) return true; + visited.add(parentId); + parentId = targetsById.get(parentId)?.parentId; + } + return false; + } + + return targetInfos.flatMap((target) => { + const targetId = target.targetId || target.id; + if (target.type !== 'iframe' || !targetId || !belongsToCurrentTarget(target)) return []; + // The resolver passes this targetId to evaluateInFrame. Pin that exact + // current-tab target so a later navigation cannot fall back by URL to a + // same-URL iframe in another tab. + const key = frameTargetKey(tabId, targetId); + frameTargets.set(key, targetId); + frameTargetKeys.set(targetId, key); + return [{ targetId, url: target.url || '', title: target.title || '' }]; + }); +} + export async function evaluateInFrame( tabId: number, expression: string, diff --git a/src/browser/dom-snapshot.ts b/src/browser/dom-snapshot.ts index e47518aed..66b4eef8a 100644 --- a/src/browser/dom-snapshot.ts +++ b/src/browser/dom-snapshot.ts @@ -663,6 +663,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { const compoundInfos = {}; let iframeCount = 0; let crossOriginIndex = 0; + const crossOriginFrames = []; function walk(el, depth, parentPropagatingRect) { if (depth > MAX_DEPTH) return false; @@ -861,6 +862,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { if (!doc || !doc.body) { const attrs = serializeAttrs(el); const frameLabel = '[F' + crossOriginIndex + ']'; + crossOriginFrames.push({ element: el, url: el.src || el.getAttribute('src') || '', name: el.name || el.title || '' }); lines.push(indent + '|iframe|' + frameLabel + ' (cross-origin, use: opencli browser frames + browser eval --frame )'); crossOriginIndex++; return false; @@ -876,6 +878,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { } catch { const attrs = serializeAttrs(el); const frameLabel = '[F' + crossOriginIndex + ']'; + crossOriginFrames.push({ element: el, url: el.src || el.getAttribute('src') || '', name: el.name || el.title || '' }); lines.push(indent + '|iframe|' + frameLabel + ' (blocked, use: opencli browser frames + browser eval --frame )'); crossOriginIndex++; return false; @@ -929,6 +932,16 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { try { window.__opencli_prev_hashes = JSON.stringify(currentHashes); } catch {} // Store ref identity map for stale-ref detection by target resolver try { window.__opencli_ref_identity = refIdentity; } catch {} + // Keep the DOM snapshot's [F#] inputs so browser frames can recover OOPIFs + // omitted by Page.getFrameTree without assigning a different frame order. + try { + const allElements = crossOriginFrames.map(function (frame) { return frame.element; }); + window.__opencli_cross_origin_frames = { + documentUrl: location.href, + allElements: allElements, + frames: crossOriginFrames, + }; + } catch {} return lines.join('\\n'); })() From fb733d0b8cf64a408c6106c15debe12f1350aed2 Mon Sep 17 00:00:00 2001 From: Yishan Date: Wed, 12 Aug 2026 09:03:25 -0700 Subject: [PATCH 3/3] fix(browser): validate fallback target lifecycle --- extension/src/background.ts | 20 ++++++------------- extension/src/cdp.test.ts | 33 ++++++++++++++++++++++++++++++++ extension/src/cdp.ts | 10 +++++++--- src/browser/dom-snapshot.test.ts | 8 ++++++++ src/browser/dom-snapshot.ts | 4 +++- 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/extension/src/background.ts b/extension/src/background.ts index 3766b435d..55582f370 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1443,24 +1443,16 @@ type DomCrossOriginFrame = { url: string; name: string }; const READ_SNAPSHOT_FRAMES_JS = `(() => { const state = window.__opencli_cross_origin_frames; if (!state || !Array.isArray(state.frames) || !Array.isArray(state.allElements)) return []; - const currentElements = Array.from(document.querySelectorAll('iframe')).filter((element) => { - try { - const doc = element.contentDocument; - return !doc || !doc.body; - } catch { - return true; - } - }); + const currentElements = state.allElements.filter((element) => element?.isConnected); const currentMatchesSnapshot = state.documentUrl === location.href && currentElements.length === state.allElements.length && currentElements.every((element, index) => element === state.allElements[index]); - const framesAreCurrent = state.frames.length === currentElements.length - && state.frames.every((frame, index) => frame.element?.isConnected - && frame.element === currentElements[index]); + const framesAreCurrent = state.frames.every((frame) => frame.element?.isConnected + && currentElements.includes(frame.element)); if (!currentMatchesSnapshot || !framesAreCurrent) return { mismatch: true }; - return currentElements.map((element) => ({ - url: element.src || element.getAttribute('src') || '', - name: element.name || element.title || '', + return state.frames.map((frame) => ({ + url: frame.element.src || frame.element.getAttribute('src') || '', + name: frame.element.name || frame.element.title || '', })); })()`; diff --git a/extension/src/cdp.test.ts b/extension/src/cdp.test.ts index c31cb90be..5f3403e9d 100644 --- a/extension/src/cdp.test.ts +++ b/extension/src/cdp.test.ts @@ -152,6 +152,39 @@ describe('cdp attach recovery', () => { ]); }); + it('attaches a verified iframe target before evaluating in it', async () => { + const { chrome, debuggerApi } = createChromeMock(); + let frameAttached = false; + debuggerApi.attach = vi.fn(async (target?: { targetId?: string }) => { + if (target?.targetId === 'current-child') frameAttached = true; + }); + debuggerApi.sendCommand = vi.fn(async (target: any, method: string): Promise => { + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'current-page', type: 'page' } }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'current-child', parentId: 'current-page', type: 'iframe', url: 'https://frame.test' }, + ], + }; + } + if (target?.targetId === 'current-child' && (method === 'Runtime.enable' || method === 'Runtime.evaluate')) { + if (!frameAttached) throw new Error('Debugger is not attached to the tab with id: current-child'); + if (method === 'Runtime.evaluate') return { result: { value: 'verified-frame-ok' } }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await mod.getIframeTargets(1); + const result = await mod.evaluateInFrame(1, 'document.title', 'current-child'); + + expect(result).toBe('verified-frame-ok'); + expect(debuggerApi.attach).toHaveBeenCalledWith({ targetId: 'current-child' }, '1.3'); + }); + }); function chromeMockForScreenshot(content: { width: number; height: number } = { width: 1024, height: 2048 }) { diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index 69e343cf5..094e4d707 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -11,6 +11,7 @@ const attached = new Set(); const tabFrameContexts = new Map>(); const frameTargets = new Map(); const frameTargetKeys = new Map(); +const verifiedFrameTargets = new Map(); let frameTargetCleanupRegistered = false; // Large cap so agents stop hitting silent JSON.parse failures on real API bodies. @@ -553,7 +554,8 @@ async function ensureFrameTarget( flatten: true, filter: [{ type: 'iframe', exclude: false }], }).catch(() => {}); - const targetId = await resolveFrameTargetId(tabId, frameId, targetUrl); + const targetId = verifiedFrameTargets.get(key) + || await resolveFrameTargetId(tabId, frameId, targetUrl); try { await chrome.debugger.attach({ targetId } as chrome.debugger.Debuggee, '1.3'); } catch (err) { @@ -699,8 +701,7 @@ export async function getIframeTargets(tabId: number): Promise { // current-tab target so a later navigation cannot fall back by URL to a // same-URL iframe in another tab. const key = frameTargetKey(tabId, targetId); - frameTargets.set(key, targetId); - frameTargetKeys.set(targetId, key); + verifiedFrameTargets.set(key, targetId); return [{ targetId, url: target.url || '', title: target.title || '' }]; }); } @@ -853,6 +854,9 @@ function clearFrameTargetsForTab(tabId: number): void { frameTargetKeys.delete(targetId); chrome.debugger.detach({ targetId } as chrome.debugger.Debuggee).catch(() => {}); } + for (const key of [...verifiedFrameTargets.keys()]) { + if (key.startsWith(`${tabId}:`)) verifiedFrameTargets.delete(key); + } } export async function detach(tabId: number): Promise { diff --git a/src/browser/dom-snapshot.test.ts b/src/browser/dom-snapshot.test.ts index f37cf2dbf..8e013d16c 100644 --- a/src/browser/dom-snapshot.test.ts +++ b/src/browser/dom-snapshot.test.ts @@ -19,6 +19,14 @@ describe('generateSnapshotJs', () => { expect(js.length).toBeGreaterThan(100); }); + it('stores iframe identity from the snapshot walker instead of a document-only selector', () => { + const js = generateSnapshotJs(); + + expect(js).toContain('iframeElements.push(el)'); + expect(js).toContain('const allElements = iframeElements'); + expect(js).not.toContain("const allElements = Array.from(document.querySelectorAll('iframe'))"); + }); + it('generates syntactically valid JS (can be parsed)', () => { const js = generateSnapshotJs(); expect(() => new Function(js)).not.toThrow(); diff --git a/src/browser/dom-snapshot.ts b/src/browser/dom-snapshot.ts index 66b4eef8a..ab271ae17 100644 --- a/src/browser/dom-snapshot.ts +++ b/src/browser/dom-snapshot.ts @@ -664,6 +664,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { let iframeCount = 0; let crossOriginIndex = 0; const crossOriginFrames = []; + const iframeElements = []; function walk(el, depth, parentPropagatingRect) { if (depth > MAX_DEPTH) return false; @@ -702,6 +703,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { // iframe handling if (tag === 'iframe' && INCLUDE_IFRAMES && iframeCount < MAX_IFRAMES) { + iframeElements.push(el); return walkIframe(el, depth); } @@ -935,7 +937,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { // Keep the DOM snapshot's [F#] inputs so browser frames can recover OOPIFs // omitted by Page.getFrameTree without assigning a different frame order. try { - const allElements = crossOriginFrames.map(function (frame) { return frame.element; }); + const allElements = iframeElements; window.__opencli_cross_origin_frames = { documentUrl: location.href, allElements: allElements,