diff --git a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts index c901e499..8f99c94e 100644 --- a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts +++ b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts @@ -955,3 +955,146 @@ describe("document-bound references", () => { cdp.dispose(); }); }); +describe("controlled background execution", () => { + it("does not emulate passive reads and releases control while retaining passive attachments", async () => { + const { api } = fakeApi(); + const cdp = new ChromiumCdp(api); + cdp.trackSessionTab("reader", 4); + await cdp.send(4, "Runtime.evaluate", {}); + expect(api.sendCommand).not.toHaveBeenCalledWith( + { tabId: 4 }, + "Emulation.setFocusEmulationEnabled", + expect.anything(), + ); + await cdp.acquireBackgroundExecution("agent", 4); + await cdp.acquireBackgroundExecution("agent", 4); + await cdp.releaseSessionTab("agent", 4); + expect(api.sendCommand).toHaveBeenCalledWith( + { tabId: 4 }, + "Emulation.setFocusEmulationEnabled", + { enabled: false }, + ); + expect(api.detach).not.toHaveBeenCalled(); + await cdp.detachSession("reader"); + expect(api.detach).toHaveBeenCalledOnce(); + cdp.dispose(); + }); + + it("restores desired execution on reattach before sending page commands", async () => { + const { api, onDetach } = fakeApi(); + const cdp = new ChromiumCdp(api); + await cdp.acquireBackgroundExecution("agent", 4); + onDetach.fire({ tabId: 4 }, "canceled_by_user"); + vi.mocked(api.sendCommand).mockClear(); + await cdp.send(4, "Runtime.evaluate", {}); + const calls = vi.mocked(api.sendCommand).mock.calls.map((call) => call[1]); + expect(calls.indexOf("Emulation.setFocusEmulationEnabled")).toBeLessThan( + calls.indexOf("Runtime.evaluate"), + ); + await cdp.detachSession("agent"); + expect(api.detach).toHaveBeenCalled(); + cdp.dispose(); + }); + + it("does not disable another controller and never applies to a different tab", async () => { + const { api } = fakeApi(); + const cdp = new ChromiumCdp(api); + await cdp.acquireBackgroundExecution("one", 4); + await cdp.acquireBackgroundExecution("two", 4); + await cdp.send(5, "Runtime.evaluate", {}); + await cdp.releaseSessionTab("one", 4); + expect(api.sendCommand).not.toHaveBeenCalledWith( + { tabId: 4 }, + "Emulation.setFocusEmulationEnabled", + { enabled: false }, + ); + expect(api.sendCommand).not.toHaveBeenCalledWith( + { tabId: 5 }, + "Emulation.setFocusEmulationEnabled", + expect.anything(), + ); + await cdp.releaseSessionTab("two", 4); + expect(api.sendCommand).toHaveBeenCalledWith( + { tabId: 4 }, + "Emulation.setFocusEmulationEnabled", + { enabled: false }, + ); + cdp.dispose(); + }); + + it("undoes an in-flight enable when stop wins the race", async () => { + const { api } = fakeApi(); + const cdp = new ChromiumCdp(api); + let finish!: () => void; + vi.mocked(api.sendCommand).mockImplementation(async (_target, method, params) => { + if ( + method === "Emulation.setFocusEmulationEnabled" && + (params as { enabled: boolean }).enabled + ) { + await new Promise((resolve) => { + finish = resolve; + }); + } + return {}; + }); + const acquiring = cdp.acquireBackgroundExecution("agent", 4); + const rejected = expect(acquiring).rejects.toThrow("released"); + await vi.waitFor(() => expect(finish).toBeTypeOf("function")); + const releasing = cdp.detachSession("agent"); + finish(); + await rejected; + await releasing; + expect(api.sendCommand).toHaveBeenCalledWith( + { tabId: 4 }, + "Emulation.setFocusEmulationEnabled", + { enabled: false }, + ); + expect(cdp.isAttached(4)).toBe(false); + cdp.dispose(); + }); + + it("reports unsupported emulation and leaves no desired policy to restore", async () => { + const { api, onDetach } = fakeApi(); + const cdp = new ChromiumCdp(api); + vi.mocked(api.sendCommand).mockImplementation(async (_target, method) => { + if (method === "Emulation.setFocusEmulationEnabled") throw new Error("Method not found"); + return {}; + }); + await expect(cdp.acquireBackgroundExecution("agent", 4)).rejects.toThrow("Method not found"); + onDetach.fire({ tabId: 4 }, "canceled_by_user"); + vi.mocked(api.sendCommand).mockClear(); + await cdp.send(4, "Runtime.evaluate", {}); + expect(api.sendCommand).not.toHaveBeenCalledWith( + { tabId: 4 }, + "Emulation.setFocusEmulationEnabled", + expect.anything(), + ); + await cdp.detachSession("agent"); + cdp.dispose(); + }); +}); + +it("detaches on failed policy release even when a passive reader remains", async () => { + const { api } = fakeApi(); + const cdp = new ChromiumCdp(api); + await cdp.acquireBackgroundExecution("agent", 4); + cdp.trackSessionTab("reader", 4); + vi.mocked(api.sendCommand).mockImplementation(async (_target, method, params) => { + if ( + method === "Emulation.setFocusEmulationEnabled" && + !(params as { enabled: boolean }).enabled + ) + throw new Error("disable failed"); + return {}; + }); + await expect(cdp.releaseSessionTab("agent", 4)).rejects.toThrow("disable failed"); + expect(api.detach).toHaveBeenCalledWith({ tabId: 4 }); + vi.mocked(api.sendCommand).mockClear(); + await cdp.send(4, "Runtime.evaluate", {}); + expect(api.sendCommand).not.toHaveBeenCalledWith( + { tabId: 4 }, + "Emulation.setFocusEmulationEnabled", + expect.anything(), + ); + cdp.dispose(); +}); diff --git a/apps/extension/src/browser-driver/background-execution.ts b/apps/extension/src/browser-driver/background-execution.ts new file mode 100644 index 00000000..e7ad0116 --- /dev/null +++ b/apps/extension/src/browser-driver/background-execution.ts @@ -0,0 +1,70 @@ +/** Desired control is independent of a debugger attachment (which Chrome can drop). + * Serialize toggles per tab so a late enable cannot outlive its last owner. */ +export class BackgroundExecution { + private readonly owners = new Map>(); + private readonly applied = new Map(); + private readonly pending = new Map>(); + + constructor( + private readonly attachment: (tabId: number) => string | undefined, + private readonly toggle: (tabId: number, enabled: boolean) => Promise, + ) {} + + retain(sessionId: string, tabId: number): void { + const owners = this.owners.get(tabId) ?? new Set(); + owners.add(sessionId); + this.owners.set(tabId, owners); + } + + has(sessionId: string, tabId: number): boolean { + return this.owners.get(tabId)?.has(sessionId) ?? false; + } + + release(sessionId: string, tabId: number): void { + const owners = this.owners.get(tabId); + owners?.delete(sessionId); + if (owners?.size === 0) this.owners.delete(tabId); + } + + forget(tabId: number): void { + this.owners.delete(tabId); + this.applied.delete(tabId); + } + + invalidate(tabId: number): void { + this.applied.delete(tabId); + } + + clear(): void { + this.owners.clear(); + this.applied.clear(); + } + + async synchronize(tabId: number): Promise { + // Join the preceding toggle, but retry a failed toggle on a subsequent call. + const previous = this.pending.get(tabId); + const next = (async () => { + await previous?.catch(() => {}); + for (;;) { + const attachment = this.attachment(tabId); + if (!attachment) return; + const enabled = this.owners.has(tabId); + const applied = this.applied.get(tabId); + if (applied?.attachment === attachment && applied.enabled === enabled) return; + if (!enabled && applied?.attachment !== attachment) return; + await this.toggle(tabId, enabled); + if (this.attachment(tabId) !== attachment) { + throw new Error("Background execution attachment changed during setup"); + } + this.applied.set(tabId, { attachment, enabled }); + // A release may have arrived while Chrome processed the toggle. + } + })(); + this.pending.set(tabId, next); + try { + await next; + } finally { + if (this.pending.get(tabId) === next) this.pending.delete(tabId); + } + } +} diff --git a/apps/extension/src/browser-driver/chromium-cdp.ts b/apps/extension/src/browser-driver/chromium-cdp.ts index d40325c4..2779da70 100644 --- a/apps/extension/src/browser-driver/chromium-cdp.ts +++ b/apps/extension/src/browser-driver/chromium-cdp.ts @@ -31,6 +31,7 @@ import type { NetworkEntryKind, NetworkResult, } from "@/transport/types"; +import { BackgroundExecution } from "./background-execution"; import { buildFrameGraph, type CdpFrameGraph, @@ -167,6 +168,11 @@ export class ChromiumCdp { private readonly attachmentIds = new Map(); private readonly attachInFlight = new Map>(); private readonly detachInFlight = new Map>(); + private readonly backgroundExecution = new BackgroundExecution( + (tabId) => this.attachmentIds.get(tabId), + (tabId, enabled) => + this.api.sendCommand({ tabId }, "Emulation.setFocusEmulationEnabled", { enabled }), + ); private readonly tabOwners = new Map>(); private readonly dialogBuffers = new Map(); private readonly dialogSequences = new Map(); @@ -208,6 +214,30 @@ export class ChromiumCdp { /** Attach to `tabId` if we haven't already in this driver. */ async ensureAttached(tabId: number): Promise { + await this.ensureRawAttached(tabId); + await this.backgroundExecution.synchronize(tabId); + } + + /** Only explicit automation control may retain the focus/visibility override. */ + async acquireBackgroundExecution(sessionId: string, tabId: number): Promise { + const retained = this.backgroundExecution.has(sessionId, tabId); + this.trackSessionTab(sessionId, tabId); + this.backgroundExecution.retain(sessionId, tabId); + try { + await this.ensureAttached(tabId); + if (!this.backgroundExecution.has(sessionId, tabId)) { + throw new Error("Background execution was released during setup"); + } + } catch (error) { + if (!retained) { + this.backgroundExecution.release(sessionId, tabId); + await this.backgroundExecution.synchronize(tabId).catch(() => {}); + } + throw error; + } + } + + private async ensureRawAttached(tabId: number): Promise { // Returning a tab clears the cache before Chrome finishes detaching. // New observers must wait before opening the next connection to that tab. const detaching = this.detachInFlight.get(tabId); @@ -263,9 +293,7 @@ export class ChromiumCdp { * `chrome.runtime.lastError`. */ async send(tabId: number, method: string, params?: object): Promise { - if (!this.attachedTabs.has(tabId)) { - await this.ensureAttached(tabId); - } + await this.ensureAttached(tabId); try { const result = await this.api.sendCommand({ tabId }, method, params ?? {}); return result as T; @@ -275,9 +303,7 @@ export class ChromiumCdp { } async sendToTarget(target: CdpTarget, method: string, params?: object): Promise { - if (!this.attachedTabs.has(target.tabId)) { - await this.ensureAttached(target.tabId); - } + await this.ensureAttached(target.tabId); try { return (await this.api.sendCommand(target, method, params ?? {})) as T; } catch (err) { @@ -455,6 +481,7 @@ export class ChromiumCdp { this.attachedTabs.delete(tabId); this.attachmentIds.delete(tabId); this.options.onDocumentChanged?.(tabId); + this.backgroundExecution.invalidate(tabId); this.clearDialogState(tabId); this.clearConsoleState(tabId); this.clearNetworkState(tabId); @@ -488,13 +515,23 @@ export class ChromiumCdp { /** Release one session's claim, preserving attachments still used by another. */ async releaseSessionTab(sessionId: string, tabId: number): Promise { + this.backgroundExecution.release(sessionId, tabId); const owners = this.tabOwners.get(tabId); - if (!owners?.delete(sessionId) || owners.size > 0) return; - this.tabOwners.delete(tabId); - // An observation may still be attaching when the tab is returned. Wait - // for it so detach cannot miss the attachment or remove a new owner's claim. + owners?.delete(sessionId); + if (owners?.size === 0) this.tabOwners.delete(tabId); + // Remove the old claim before yielding: a new acquisition must survive this + // cleanup, including when it uses the same session id. await this.attachInFlight.get(tabId)?.catch(() => {}); - if (!this.tabOwners.has(tabId)) await this.detach(tabId); + try { + await this.backgroundExecution.synchronize(tabId); + } catch (error) { + // A failed disable must not leave a returned user page emulated just + // because a passive reader still owns the debugger. Readers can reattach. + await this.detach(tabId); + throw error; + } finally { + if (!this.tabOwners.has(tabId)) await this.detach(tabId); + } } /** Subscribe to all CDP events. Returned disposable removes the listener. */ @@ -512,6 +549,7 @@ export class ChromiumCdp { const tabs = Array.from(this.attachedTabs); this.attachInFlight.clear(); this.tabOwners.clear(); + this.backgroundExecution.clear(); this.attachedTabs.clear(); this.attachmentIds.clear(); for (const tabId of tabs) this.options.onDocumentChanged?.(tabId); @@ -877,7 +915,11 @@ export class ChromiumCdp { this.attachedTabs.delete(source.tabId); this.attachmentIds.delete(source.tabId); this.attachInFlight.delete(source.tabId); - this.tabOwners.delete(source.tabId); + this.backgroundExecution.invalidate(source.tabId); + if (_reason === "target_closed") { + this.tabOwners.delete(source.tabId); + this.backgroundExecution.forget(source.tabId); + } this.clearDialogState(source.tabId); this.clearConsoleState(source.tabId); this.clearNetworkState(source.tabId); diff --git a/apps/extension/src/tools/__tests__/background-execution.browser.test.ts b/apps/extension/src/tools/__tests__/background-execution.browser.test.ts new file mode 100644 index 00000000..4155fd29 --- /dev/null +++ b/apps/extension/src/tools/__tests__/background-execution.browser.test.ts @@ -0,0 +1,222 @@ +// @vitest-environment node +// BSK_BACKGROUND_CHROME=/path/to/chrome runs against an isolated headed browser. +import { createServer } from "node:http"; +import { describe, expect, it } from "vitest"; +import { type CdpDebuggerApi, ChromiumCdp } from "@/browser-driver/chromium-cdp"; +import { SessionManager } from "@/session-manager/manager"; +import { prepareBackgroundExecution } from "../background-execution"; +import { handleClick } from "../interaction"; +import { handleSnapshot } from "../observation"; + +type Send = >( + method: string, + params?: object, + sessionId?: string, +) => Promise; +const html = `Background fixture + +`; + +// The adapter uses the real production driver over an isolated CDP transport. +// chrome.tabs.active/window focus assertions still require the extension suite. +describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)( + "background automation browser regression", + () => { + it("recovers an existing hidden page and completes snapshot/input/snapshot without selecting it", async () => { + const server = createServer((_req, res) => { + res.setHeader("content-type", "text/html"); + res.end(html); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing fixture address"); + const url = `http://127.0.0.1:${address.port}`; + try { + const { withChrome } = await import( + new URL( + "../../../../../evals/browser/cases/regression/snapshot-coordinates/chrome.mjs", + import.meta.url, + ).href + ); + await withChrome( + { + executable: process.env.BSK_BACKGROUND_CHROME, + deviceScale: 1, + zoom: 1, + headless: false, + }, + async (send: Send) => { + const { targetInfos } = await send<{ + targetInfos: { targetId: string; type: string }[]; + }>("Target.getTargets"); + const control = targetInfos.find((target) => target.type === "page")!; + const controlSession = ( + await send<{ sessionId: string }>("Target.attachToTarget", { + targetId: control.targetId, + flatten: true, + }) + ).sessionId; + const { targetId } = await send<{ targetId: string }>("Target.createTarget", { + url, + background: true, + }); + let sessionId = ""; + const calls: string[] = []; + const listeners = new Set<(source: chrome.debugger.Debuggee, reason: string) => void>(); + const api: CdpDebuggerApi = { + attach: async () => { + sessionId = ( + await send<{ sessionId: string }>("Target.attachToTarget", { + targetId, + flatten: true, + }) + ).sessionId; + }, + detach: async () => { + await send("Target.detachFromTarget", { sessionId }); + for (const listener of listeners) listener({ tabId: 7 }, "canceled_by_user"); + }, + sendCommand: async (_target, method, params) => { + calls.push(method); + return send(method, params, sessionId); + }, + onEvent: { + addListener: () => {}, + removeListener: () => {}, + } as unknown as CdpDebuggerApi["onEvent"], + onDetach: { + addListener: (fn: (source: chrome.debugger.Debuggee, reason: string) => void) => + listeners.add(fn), + removeListener: (fn: (source: chrome.debugger.Debuggee, reason: string) => void) => + listeners.delete(fn), + } as unknown as CdpDebuggerApi["onDetach"], + }; + const cdp = new ChromiumCdp(api); + const evaluate = async (expression: string, sid = sessionId) => { + const reply = await send<{ result: { value: unknown }; exceptionDetails?: unknown }>( + "Runtime.evaluate", + { expression, returnByValue: true }, + sid, + ); + expect(reply.exceptionDetails).toBeUndefined(); + return reply.result.value; + }; + const waitFor = async (predicate: () => Promise) => { + const deadline = Date.now() + 5000; + while (!(await predicate())) { + if (Date.now() > deadline) throw new Error("Fixture condition timed out"); + await new Promise((resolve) => setTimeout(resolve, 30)); + } + }; + try { + await cdp.ensureAttached(7); + await waitFor(async () => (await evaluate("document.readyState")) === "complete"); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(await evaluate("({hidden:document.hidden, frames:window.framesRun})")).toEqual( + { hidden: true, frames: 0 }, + ); + const controlBefore = await evaluate( + "({hidden:document.hidden,focus:document.hasFocus()})", + controlSession, + ); + const manager = new SessionManager({ + agentWindow: { + create: async () => 100, + remove: async () => {}, + ensureActiveTab: async () => 1, + }, + }); + const ctx = await manager.start("agent"); + ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 1 }); + const tabsApi = { + get: async () => ({ id: 7, windowId: 100, active: false, url }) as chrome.tabs.Tab, + query: async () => [] as chrome.tabs.Tab[], + }; + expect( + await prepareBackgroundExecution( + manager, + { id: "r", method: "tool.snapshot", params: { session_id: "agent", tab_id: 7 } }, + cdp, + tabsApi, + new AbortController().signal, + ), + ).toBeUndefined(); + await waitFor( + async () => (await evaluate("document.querySelector('#run').disabled")) === false, + ); + const snapshot = await handleSnapshot( + manager, + { session_id: "agent", tab_id: 7 }, + { cdp, tabsApi }, + ); + expect(snapshot).not.toHaveProperty("code"); + expect(JSON.stringify(snapshot)).toContain("Run background task"); + const click = await handleClick( + manager, + { session_id: "agent", tab_id: 7, selector: "#run" }, + { cdp, tabsApi }, + ); + expect(click).not.toHaveProperty("code"); + await waitFor( + async () => + (await evaluate("document.querySelector('#result').textContent")) === + "Background task complete", + ); + expect( + JSON.stringify( + await handleSnapshot( + manager, + { session_id: "agent", tab_id: 7 }, + { cdp, tabsApi }, + ), + ), + ).toContain("Background task complete"); + // Reattach must restore policy; subsequent navigation starts visible. + await cdp.detach(7); + await cdp.send(7, "Page.navigate", { url: `${url}/next` }); + await waitFor( + async () => + (await evaluate( + "document.readyState === 'complete' && location.pathname === '/next'", + )) === true, + ); + expect(await evaluate("window.initialHidden")).toBe(false); + expect( + await evaluate( + "({hidden:document.hidden,focus:document.hasFocus()})", + controlSession, + ), + ).toEqual(controlBefore); + expect(calls).not.toContain("Page.bringToFront"); + cdp.trackSessionTab("reader", 7); + await cdp.releaseSessionTab("agent", 7); + expect(await evaluate("document.hidden")).toBe(true); + const frames = await evaluate("window.framesRun"); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(await evaluate("window.framesRun")).toBe(frames); + } finally { + await cdp.detachAll(); + cdp.dispose(); + } + }, + ); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }, 45_000); + }, +); diff --git a/apps/extension/src/tools/__tests__/background-execution.test.ts b/apps/extension/src/tools/__tests__/background-execution.test.ts new file mode 100644 index 00000000..0afb2472 --- /dev/null +++ b/apps/extension/src/tools/__tests__/background-execution.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vitest"; +import { SessionManager } from "@/session-manager/manager"; +import type { RequestFrame } from "@/transport/types"; +import { prepareBackgroundExecution } from "../background-execution"; + +async function fixture() { + const manager = new SessionManager({ + agentWindow: { + create: async () => 100, + remove: async () => {}, + ensureActiveTab: async () => 1, + }, + }); + const ctx = await manager.start("agent"); + const tabs = { + get: vi.fn( + async (id: number) => + ({ id, windowId: 100, url: "https://fixture.test", active: false }) as chrome.tabs.Tab, + ), + query: vi.fn( + async () => + [{ id: 1, windowId: 100, url: "https://fixture.test", active: true }] as chrome.tabs.Tab[], + ), + }; + const cdp = { + send: vi.fn(), + acquireBackgroundExecution: vi.fn(async () => {}), + releaseSessionTab: vi.fn(async () => {}), + }; + return { manager, ctx, tabs, cdp }; +} + +describe("background execution request boundary", () => { + it.each([ + "tool.snapshot", + "tool.observe", + "tool.click", + "tool.wait_for_navigation", + ])("prepares an explicit inactive controlled target before %s", async (method) => { + const f = await fixture(); + f.ctx.agentCreatedTabs.add(7); + const req: RequestFrame = { id: "r", method, params: { session_id: "agent", tab_id: 7 } }; + expect( + await prepareBackgroundExecution(f.manager, req, f.cdp, f.tabs, new AbortController().signal), + ).toBeUndefined(); + expect(f.cdp.acquireBackgroundExecution).toHaveBeenCalledWith("agent", 7); + expect(f.tabs.query).not.toHaveBeenCalled(); + }); + + it.each([ + "tool.navigate", + "tool.reload", + "tool.navigate_back", + "tool.navigate_forward", + ])("pins %s but delegates preparation to the navigation handler", async (method) => { + const f = await fixture(); + f.ctx.agentCreatedTabs.add(7); + f.cdp.acquireBackgroundExecution.mockRejectedValue(new Error("access denied")); + const request = { id: "r", method, params: { session_id: "agent", tab_id: 7 } }; + expect( + await prepareBackgroundExecution( + f.manager, + request, + f.cdp, + f.tabs, + new AbortController().signal, + ), + ).toBeUndefined(); + expect(request.params.tab_id).toBe(7); + expect(f.cdp.acquireBackgroundExecution).not.toHaveBeenCalled(); + }); + + it("does not infer control from same-window passive access", async () => { + const f = await fixture(); + await prepareBackgroundExecution( + f.manager, + { id: "r", method: "tool.snapshot", params: { session_id: "agent", tab_id: 7 } }, + f.cdp, + f.tabs, + new AbortController().signal, + ); + expect(f.cdp.acquireBackgroundExecution).not.toHaveBeenCalled(); + }); + + it("releases a target returned while setup was pending", async () => { + const f = await fixture(); + f.ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 1 }); + f.cdp.acquireBackgroundExecution.mockImplementation(async () => { + f.ctx.borrowedTabs.delete(7); + }); + expect( + await prepareBackgroundExecution( + f.manager, + { id: "r", method: "tool.observe", params: { session_id: "agent", tab_id: 7 } }, + f.cdp, + f.tabs, + new AbortController().signal, + ), + ).toMatchObject({ code: "cancelled" }); + expect(f.cdp.releaseSessionTab).toHaveBeenCalledWith("agent", 7); + }); + + it("pins default targeting and reports setup failure instead of waiting for readiness", async () => { + const f = await fixture(); + f.ctx.agentCreatedTabs.add(1); + f.cdp.acquireBackgroundExecution.mockRejectedValue(new Error("unsupported")); + const req: RequestFrame = { id: "r", method: "tool.observe", params: { session_id: "agent" } }; + expect( + await prepareBackgroundExecution(f.manager, req, f.cdp, f.tabs, new AbortController().signal), + ).toMatchObject({ code: "cdp_failed", message: expect.stringContaining("unsupported") }); + expect(req.params).toMatchObject({ tab_id: 1 }); + }); +}); diff --git a/apps/extension/src/tools/__tests__/dispatcher.test.ts b/apps/extension/src/tools/__tests__/dispatcher.test.ts index 3ffdc675..5446d64b 100644 --- a/apps/extension/src/tools/__tests__/dispatcher.test.ts +++ b/apps/extension/src/tools/__tests__/dispatcher.test.ts @@ -1324,3 +1324,42 @@ async function flushMicrotasks() { // pushes the required-turn count past the original 4). for (let i = 0; i < 16; i += 1) await Promise.resolve(); } + +describe("background execution dispatch integration", () => { + afterEach(() => vi.unstubAllGlobals()); + it("does not enter the observation handler when controlled-target preparation fails", async () => { + const { transport, sent, deliver } = fakeTransport(); + const sessions = new SessionManager({ + agentWindow: { + create: async () => 100, + remove: async () => {}, + ensureActiveTab: async () => 1, + }, + }); + const ctx = await sessions.start("agent"); + ctx.agentCreatedTabs.add(7); + vi.stubGlobal("chrome", { + tabs: { + get: async () => ({ id: 7, windowId: 100, active: false, url: "https://fixture.test" }), + }, + }); + const cdp = { + send: vi.fn(), + acquireBackgroundExecution: vi.fn(async () => { + throw new Error("simulation unavailable"); + }), + } as unknown as TestDispatcherCdp; + const dispatcher = new ToolDispatcher({ transport, sessions, cdp }); + dispatcher.start(); + try { + deliver(makeRequest("tool.snapshot", { session_id: "agent", tab_id: 7 })); + await vi.waitFor(() => expect(sent.some((frame) => "error" in frame)).toBe(true)); + expect(sent.find((frame) => "error" in frame)).toMatchObject({ + error: { code: "cdp_failed", message: expect.stringContaining("simulation unavailable") }, + }); + expect(cdp.send).not.toHaveBeenCalled(); + } finally { + dispatcher.stop(); + } + }); +}); diff --git a/apps/extension/src/tools/__tests__/navigation-recovery.test.ts b/apps/extension/src/tools/__tests__/navigation-recovery.test.ts index fbc463ed..442d0f22 100644 --- a/apps/extension/src/tools/__tests__/navigation-recovery.test.ts +++ b/apps/extension/src/tools/__tests__/navigation-recovery.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import { SessionManager } from "@/session-manager/manager"; -import type { BrowserNavigationApi } from "../browser-navigation"; -import { handleNavigate, handleReload } from "../navigation"; +import { type BrowserNavigationApi, navigateWithBrowserApi } from "../browser-navigation"; +import { + handleNavigate, + handleNavigateBack, + handleNavigateForward, + handleReload, +} from "../navigation"; import type { CdpRunner } from "../shared"; const denied = "Cannot access a chrome-extension:// URL of different extension"; @@ -30,10 +35,13 @@ async function fixture() { await manager.start("test"); type Listener = Parameters[0]; const events = { + onBeforeNavigate: event(), onCommitted: event(), onDOMContentLoaded: event(), onCompleted: event(), onErrorOccurred: event(), + onReferenceFragmentUpdated: event(), + onHistoryStateUpdated: event(), }; const cdpEvents = event>[0]>(); const state = { blocked: true, destinationBlocked: false, emitIdle: true }; @@ -60,22 +68,39 @@ async function fixture() { return { dispose: () => cdpEvents.removeListener(listener) }; }, }; - const main = { tabId: 4, frameId: 0, documentId: "new-document" }; + const main = { tabId: 4, frameId: 0, documentId: "new-document", url }; const navigate = async () => { state.blocked = state.destinationBlocked; + tab.url = url; + events.onBeforeNavigate.fire(main); events.onCommitted.fire(main); events.onDOMContentLoaded.fire(main); events.onCompleted.fire(main); }; - const browserNavigation = { ...events, update: vi.fn(navigate), reload: vi.fn(navigate) }; + const browserNavigation = { + ...events, + update: vi.fn(navigate), + reload: vi.fn(navigate), + goBack: vi.fn(navigate), + goForward: vi.fn(navigate), + getFrame: vi + .fn(async () => ({ documentId: main.documentId })) + .mockResolvedValueOnce({ documentId: "old-document" }), + }; const tab = { id: 4, windowId: 100, active: true, url } as chrome.tabs.Tab; const tabsApi = { get: vi.fn(async () => tab), query: vi.fn(async () => [tab]) }; - const deps = { cdp, tabsApi, browserNavigation, defaultTimeoutMs: 1000 }; + const deps = { + cdp, + tabsApi, + browserNavigation, + defaultTimeoutMs: 1000, + backgroundExecution: true, + }; const expectCleanedUp = () => { for (const entry of Object.values(events)) expect(entry.listeners.size).toBe(0); expect(cdpEvents.listeners.size).toBe(0); }; - return { manager, deps, state, send, events, main, cdpEvents, expectCleanedUp }; + return { manager, deps, state, send, events, main, cdpEvents, expectCleanedUp, tab }; } describe("navigation after Chrome denies extension-frame access", () => { @@ -184,6 +209,7 @@ describe("navigation after Chrome denies extension-frame access", () => { f.events.onCompleted.fire(f.main); await Promise.resolve(); expect(settled).toBe(false); + f.events.onBeforeNavigate.fire(f.main); f.events.onCommitted.fire(f.main); f.events.onCompleted.fire({ ...f.main, documentId: "old-document" }); await Promise.resolve(); @@ -257,6 +283,7 @@ describe("navigation after Chrome denies extension-frame access", () => { it("reports browser navigation errors and releases listeners", async () => { const f = await fixture(); f.deps.browserNavigation.update.mockImplementation(async () => { + f.events.onBeforeNavigate.fire(f.main); f.events.onErrorOccurred.fire({ ...f.main, error: "net::ERR_NAME_NOT_RESOLVED" }); }); const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps); @@ -264,3 +291,487 @@ describe("navigation after Chrome denies extension-frame access", () => { f.expectCleanedUp(); }); }); + +describe("restricted source navigation handoff", () => { + it.each([ + "chrome://newtab/", + "edge://newtab/", + ])("leaves %s without CDP preflight or tab selection", async (source) => { + const f = await fixture(); + f.tab.url = source; + f.tab.active = false; + const acquire = vi.fn(async () => { + expect(f.state.blocked).toBe(false); + }); + f.deps.cdp.acquireBackgroundExecution = acquire; + const navigate = f.deps.browserNavigation.update.getMockImplementation()!; + f.deps.browserNavigation.update.mockImplementation(async () => { + expect(f.send).not.toHaveBeenCalled(); + expect(acquire).not.toHaveBeenCalled(); + await navigate(); + }); + expect(await handleNavigate(f.manager, { session_id: "test", url }, f.deps)).toMatchObject({ + reached: "load", + }); + expect(acquire).toHaveBeenCalledExactlyOnceWith("test", 4); + expect(f.deps.browserNavigation.update).toHaveBeenCalledExactlyOnceWith(4, { url }); + expect(f.tab.active).toBe(false); + f.expectCleanedUp(); + }); + + it.each([ + "commit", + "domcontentloaded", + "load", + "networkidle", + ] as const)("prepares at commit before waiting for %s, including a pending browser action", async (wait_until) => { + const f = await fixture(); + f.tab.url = "chrome://newtab/"; + let releaseAction!: () => void; + f.deps.browserNavigation.update.mockImplementation(async () => { + f.state.blocked = false; + f.tab.url = url; + f.events.onBeforeNavigate.fire(f.main); + f.events.onCommitted.fire(f.main); + await new Promise((resolve) => { + releaseAction = resolve; + }); + }); + f.deps.cdp.acquireBackgroundExecution = vi.fn(async () => { + // This simulates load blocked on rAF until execution is established. + f.events.onDOMContentLoaded.fire(f.main); + f.events.onCompleted.fire(f.main); + releaseAction(); + }); + expect( + await handleNavigate(f.manager, { session_id: "test", url, wait_until }, f.deps), + ).toMatchObject({ reached: wait_until }); + expect(f.deps.cdp.acquireBackgroundExecution).toHaveBeenCalledOnce(); + f.expectCleanedUp(); + }); + + it.each([ + handleReload, + handleNavigateBack, + handleNavigateForward, + ])("recovers reload/history from a restricted source", async (handler) => { + const f = await fixture(); + f.tab.url = "chrome://newtab/"; + f.tab.active = false; + f.deps.cdp.acquireBackgroundExecution = vi.fn(async () => {}); + expect(await handler(f.manager, { session_id: "test" }, f.deps)).toMatchObject({ + reached: "load", + previous_url: "chrome://newtab/", + }); + const action = + handler === handleReload + ? f.deps.browserNavigation.reload + : handler === handleNavigateBack + ? f.deps.browserNavigation.goBack + : f.deps.browserNavigation.goForward; + expect(action).toHaveBeenCalledOnce(); + expect(f.deps.cdp.acquireBackgroundExecution).toHaveBeenCalledOnce(); + f.expectCleanedUp(); + }); + + it("keeps a fast load pending until execution preparation completes", async () => { + const f = await fixture(); + f.tab.url = "chrome://newtab/"; + let release!: () => void; + f.deps.cdp.acquireBackgroundExecution = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + let done = false; + const work = handleNavigate(f.manager, { session_id: "test", url }, f.deps).then((r) => { + done = true; + return r; + }); + await vi.waitFor(() => expect(f.deps.cdp.acquireBackgroundExecution).toHaveBeenCalledOnce()); + expect(done).toBe(false); + release(); + expect(await work).toMatchObject({ reached: "load" }); + f.expectCleanedUp(); + }); + + it("routes preparation access denial into recovery without replaying CDP navigation", async () => { + const f = await fixture(); + f.deps.cdp.acquireBackgroundExecution = vi.fn(async () => { + if (f.state.blocked) throw new Error(denied); + }); + expect(await handleNavigate(f.manager, { session_id: "test", url }, f.deps)).toMatchObject({ + reached: "load", + }); + expect(f.deps.cdp.acquireBackgroundExecution).toHaveBeenCalledTimes(2); + expect(f.send.mock.calls.some(([, method]) => method === "Page.navigate")).toBe(false); + f.expectCleanedUp(); + }); + + it("cleans up on timeout while commit handoff is stalled", async () => { + const f = await fixture(); + f.tab.url = "chrome://newtab/"; + let release!: () => void; + f.deps.cdp.acquireBackgroundExecution = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const result = await handleNavigate( + f.manager, + { session_id: "test", url, timeout_ms: 20 }, + f.deps, + ); + expect(result).toMatchObject({ reached: "timeout" }); + f.expectCleanedUp(); + release(); + await Promise.resolve(); + await Promise.resolve(); + expect(f.send).not.toHaveBeenCalled(); + }); + + it("waits rather than acquiring for an obsolete commit without its successor event", async () => { + const f = await fixture(); + f.tab.url = "chrome://newtab/"; + f.deps.browserNavigation.getFrame.mockResolvedValue({ documentId: "replacement" }); + f.deps.cdp.acquireBackgroundExecution = vi.fn(async () => {}); + expect( + await handleNavigate(f.manager, { session_id: "test", url, timeout_ms: 20 }, f.deps), + ).toMatchObject({ + reached: "timeout", + }); + expect(f.deps.cdp.acquireBackgroundExecution).not.toHaveBeenCalled(); + f.expectCleanedUp(); + }); +}); + +it("releases execution when control ends during commit preparation", async () => { + const f = await fixture(); + f.tab.url = "chrome://newtab/"; + f.deps.cdp.releaseSessionTab = vi.fn(async () => {}); + f.deps.cdp.acquireBackgroundExecution = vi.fn(async () => { + f.manager.get("test")!.agentCreatedTabs.clear(); + }); + expect(await handleNavigate(f.manager, { session_id: "test", url }, f.deps)).toMatchObject({ + code: "cancelled", + }); + expect(f.deps.cdp.releaseSessionTab).toHaveBeenCalledExactlyOnceWith("test", 4); + expect(f.send).not.toHaveBeenCalled(); + f.expectCleanedUp(); +}); + +it("does not bypass unrelated execution preparation failures", async () => { + const f = await fixture(); + f.deps.cdp.acquireBackgroundExecution = vi.fn(async () => { + throw new Error("Another debugger is already attached"); + }); + expect(await handleNavigate(f.manager, { session_id: "test", url }, f.deps)).toMatchObject({ + code: "cdp_failed", + }); + expect(f.deps.browserNavigation.update).not.toHaveBeenCalled(); + f.expectCleanedUp(); +}); + +it("ignores the departing page's aborted load before the new navigation starts", async () => { + const f = await fixture(); + f.tab.url = "chrome://newtab/"; + const navigate = f.deps.browserNavigation.update.getMockImplementation()!; + f.deps.browserNavigation.update.mockImplementation(async () => { + f.events.onErrorOccurred.fire({ tabId: 4, frameId: 0, error: "net::ERR_ABORTED" }); + await navigate(); + }); + expect(await handleNavigate(f.manager, { session_id: "test", url }, f.deps)).toMatchObject({ + reached: "load", + }); + f.expectCleanedUp(); +}); + +it("leaves recording callers' execution policy unchanged", async () => { + const f = await fixture(); + f.state.blocked = false; + f.deps.backgroundExecution = false; + f.deps.cdp.acquireBackgroundExecution = vi.fn(async () => {}); + await handleNavigate(f.manager, { session_id: "test", url, timeout_ms: 1 }, f.deps); + expect(f.deps.cdp.acquireBackgroundExecution).not.toHaveBeenCalled(); + expect(f.send.mock.calls.some(([, method]) => method === "Page.navigate")).toBe(true); +}); + +it.each([ + "", + "about:blank", +])("routes a pending restricted source with URL %j through browser navigation", async (source) => { + const f = await fixture(); + f.tab.url = source; + f.tab.pendingUrl = "chrome://newtab/"; + f.deps.cdp.acquireBackgroundExecution = vi.fn(async () => { + expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce(); + }); + expect(await handleNavigate(f.manager, { session_id: "test", url }, f.deps)).toMatchObject({ + reached: "load", + }); + expect(f.deps.cdp.acquireBackgroundExecution).toHaveBeenCalledOnce(); +}); + +it.each([ + "before", + "after", +])("ignores the unfinished source commit %s the requested navigation starts", async (order) => { + const f = await fixture(); + f.tab.url = "chrome://newtab/"; + f.deps.cdp.acquireBackgroundExecution = vi.fn(async () => { + expect(f.state.blocked).toBe(false); + }); + f.deps.browserNavigation.update.mockImplementation(async () => { + const old = { ...f.main, documentId: "late-source-document", url: "chrome://new-tab-page/" }; + if (order === "after") f.events.onBeforeNavigate.fire(f.main); + f.events.onCommitted.fire(old); + if (order === "before") f.events.onBeforeNavigate.fire(f.main); + f.events.onErrorOccurred.fire({ ...old, error: "net::ERR_ABORTED" }); + f.state.blocked = false; + f.tab.url = url; + f.events.onCommitted.fire(f.main); + f.events.onCompleted.fire(f.main); + }); + expect(await handleNavigate(f.manager, { session_id: "test", url }, f.deps)).toMatchObject({ + reached: "load", + }); + expect(f.deps.cdp.acquireBackgroundExecution).toHaveBeenCalledOnce(); + f.expectCleanedUp(); +}); + +it("accepts the committed document after a server redirect", async () => { + const f = await fixture(); + f.tab.url = "chrome://newtab/"; + f.deps.browserNavigation.update.mockImplementation(async () => { + f.events.onBeforeNavigate.fire(f.main); + f.state.blocked = false; + f.tab.url = `${url}/redirected`; + const redirected = { ...f.main, url: f.tab.url, transitionQualifiers: ["server_redirect"] }; + f.events.onCommitted.fire(redirected); + f.events.onCompleted.fire(redirected); + }); + expect(await handleNavigate(f.manager, { session_id: "test", url }, f.deps)).toMatchObject({ + reached: "load", + final_url: `${url}/redirected`, + }); + f.expectCleanedUp(); +}); + +it("still reports an aborted requested navigation", async () => { + const f = await fixture(); + f.tab.url = "chrome://newtab/"; + f.deps.browserNavigation.update.mockImplementation(async () => { + f.events.onBeforeNavigate.fire(f.main); + f.events.onErrorOccurred.fire({ ...f.main, error: "net::ERR_ABORTED" }); + }); + expect(await handleNavigate(f.manager, { session_id: "test", url }, f.deps)).toMatchObject({ + code: "cdp_failed", + message: "net::ERR_ABORTED", + }); + f.expectCleanedUp(); +}); + +it("follows a successor while ignoring a rejected obsolete handoff", async () => { + const f = await fixture(); + f.tab.url = "chrome://newtab/"; + let current = "old"; + f.deps.browserNavigation.getFrame + .mockReset() + .mockImplementation(async () => ({ documentId: current })); + const first = { ...f.main, documentId: "first" }; + const second = { ...f.main, documentId: "second", url: `${url}/second` }; + let rejectFirst!: (error: Error) => void; + f.deps.cdp.acquireBackgroundExecution = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFirst = reject; + }), + ) + .mockResolvedValue(undefined); + f.deps.browserNavigation.update.mockImplementation(async () => { + f.state.blocked = false; + f.tab.url = url; + current = first.documentId; + f.events.onBeforeNavigate.fire(first); + f.events.onCommitted.fire(first); + }); + const work = handleNavigate(f.manager, { session_id: "test", url }, f.deps); + await vi.waitFor(() => expect(rejectFirst).toBeDefined()); + f.events.onBeforeNavigate.fire(second); + current = second.documentId; + f.tab.url = second.url; + f.events.onCommitted.fire(second); + f.events.onErrorOccurred.fire({ ...first, error: "net::ERR_ABORTED" }); + f.events.onCompleted.fire(first); + rejectFirst(new Error("old attachment disappeared")); + f.events.onCompleted.fire(second); + expect(await work).toMatchObject({ reached: "load", final_url: second.url }); + expect(f.deps.cdp.acquireBackgroundExecution).toHaveBeenCalledTimes(2); + f.expectCleanedUp(); +}); + +it("keeps native succession active throughout network-idle waiting", async () => { + const f = await fixture(); + f.tab.url = "chrome://newtab/"; + f.state.emitIdle = false; + let current = "old"; + f.deps.browserNavigation.getFrame + .mockReset() + .mockImplementation(async () => ({ documentId: current })); + const send = f.send.getMockImplementation()!; + f.send.mockImplementation(async (tabId, method) => + method === "Page.getFrameTree" + ? { frameTree: { frame: { id: "main", loaderId: current } } } + : send(tabId, method), + ); + const first = { ...f.main, documentId: "first" }; + const second = { ...f.main, documentId: "second", url: `${url}/second` }; + f.deps.browserNavigation.update.mockImplementation(async () => { + f.state.blocked = false; + f.tab.url = url; + current = first.documentId; + f.events.onBeforeNavigate.fire(first); + f.events.onCommitted.fire(first); + }); + let settled = false; + const work = handleNavigate( + f.manager, + { session_id: "test", url, wait_until: "networkidle" }, + f.deps, + ).then((result) => { + settled = true; + return result; + }); + await vi.waitFor(() => expect(f.cdpEvents.listeners.size).toBe(1)); + f.events.onBeforeNavigate.fire(second); + current = second.documentId; + f.tab.url = second.url; + f.events.onCommitted.fire(second); + await vi.waitFor(() => + expect( + f.send.mock.calls.filter(([, method]) => method === "Page.setLifecycleEventsEnabled"), + ).toHaveLength(2), + ); + f.cdpEvents.fire({ tabId: 4 }, "Page.lifecycleEvent", { + frameId: "main", + loaderId: "first", + name: "networkIdle", + }); + await Promise.resolve(); + expect(settled).toBe(false); + f.cdpEvents.fire({ tabId: 4 }, "Page.lifecycleEvent", { + frameId: "main", + loaderId: "second", + name: "networkIdle", + }); + expect(await work).toMatchObject({ reached: "networkidle", final_url: second.url }); + f.expectCleanedUp(); +}); + +it("keeps one deadline across multiple document handoffs", async () => { + const f = await fixture(); + vi.useFakeTimers(); + try { + const work = navigateWithBrowserApi( + f.deps.browserNavigation, + 4, + () => f.deps.browserNavigation.update(), + "load", + 60, + undefined, + () => new Promise(() => {}), + url, + ); + await vi.advanceTimersByTimeAsync(40); + const second = { ...f.main, documentId: "second", url: `${url}/second` }; + f.events.onBeforeNavigate.fire(second); + f.events.onCommitted.fire(second); + f.events.onCompleted.fire(second); + await vi.advanceTimersByTimeAsync(20); + expect(await work).toMatchObject({ reached: "timeout" }); + f.expectCleanedUp(); + } finally { + vi.useRealTimers(); + } +}); + +it.each([ + "abort", + "fragment", + "history", +] as const)("resumes the committed document after a successor ends without commit (%s)", async (kind) => { + const f = await fixture(); + let settled = false; + const attempt = { ...f.main, documentId: undefined, url: `${url}/cancelled` }; + const work = navigateWithBrowserApi( + f.deps.browserNavigation, + 4, + async () => { + f.events.onBeforeNavigate.fire(f.main); + f.events.onCommitted.fire(f.main); + f.events.onBeforeNavigate.fire(attempt); + f.events.onCompleted.fire(f.main); + }, + "load", + 1000, + ).then((result) => { + settled = true; + return result; + }); + await vi.waitFor(() => expect(f.deps.browserNavigation.getFrame).toHaveBeenCalledOnce()); + await Promise.resolve(); + expect(settled).toBe(false); + if (kind === "abort") f.events.onErrorOccurred.fire({ ...attempt, error: "net::ERR_ABORTED" }); + else if (kind === "fragment") f.events.onReferenceFragmentUpdated.fire(f.main); + else f.events.onHistoryStateUpdated.fire(f.main); + expect(await work).toMatchObject({ reached: "match", lastLifecycle: "load" }); + f.expectCleanedUp(); +}); + +it("does not let a late cancellation probe complete a successor document", async () => { + const f = await fixture(); + let resolveFrame!: (value: { documentId: string }) => void; + f.deps.browserNavigation.getFrame + .mockReset() + .mockImplementationOnce(async () => ({ documentId: "old-document" })) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFrame = resolve; + }), + ); + const attempt = { ...f.main, documentId: undefined, url: `${url}/attempt` }; + let settled = false; + const work = navigateWithBrowserApi( + f.deps.browserNavigation, + 4, + async () => { + f.events.onBeforeNavigate.fire(f.main); + f.events.onCommitted.fire(f.main); + f.events.onBeforeNavigate.fire(attempt); + f.events.onCompleted.fire(f.main); + f.events.onErrorOccurred.fire({ ...attempt, error: "net::ERR_ABORTED" }); + }, + "load", + 1000, + ).then((result) => { + settled = true; + return result; + }); + await vi.waitFor(() => expect(resolveFrame).toBeDefined()); + const next = { ...f.main, documentId: "successor", url: `${url}/successor` }; + f.events.onBeforeNavigate.fire(next); + f.events.onCommitted.fire(next); + resolveFrame({ documentId: f.main.documentId }); + f.events.onCompleted.fire(f.main); + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + f.events.onCompleted.fire(next); + expect(await work).toMatchObject({ reached: "match", url: next.url }); + f.expectCleanedUp(); +}); diff --git a/apps/extension/src/tools/__tests__/navigation.test.ts b/apps/extension/src/tools/__tests__/navigation.test.ts index 921771e2..fef78586 100644 --- a/apps/extension/src/tools/__tests__/navigation.test.ts +++ b/apps/extension/src/tools/__tests__/navigation.test.ts @@ -64,6 +64,7 @@ function makeFakeCdp(opts?: { const sent: Array<{ tabId: number; method: string; params?: object }> = []; const methodHandlers: Record object> = { "Page.enable": () => ({}), + "Network.enable": () => ({}), "Page.setLifecycleEventsEnabled": () => ({}), "Page.navigate": () => { if (opts?.fireLifecycleDuringNavigate) { @@ -644,3 +645,239 @@ describe("handleReload", () => { expect(reloadCall?.params).toEqual({ ignoreCache: true }); }); }); + +it.each([ + "load", + "networkidle", +] as const)("follows client document succession for %s instead of the initial loader", async (phase) => { + const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await manager.start("aa11"); + const fake = makeFakeCdp(); + let settled = false; + const work = handleNavigate( + manager, + { session_id: "aa11", url: "https://example.com/", wait_until: phase }, + { cdp: fake.cdp, tabsApi: fake.tabsApi }, + ).then((result) => { + settled = true; + return result; + }); + await vi.waitFor(() => expect(fake.sent.some((s) => s.method === "Page.navigate")).toBe(true)); + fake.fireFrameNavigated(); + for (const listener of [...fake.listeners]) + listener({ tabId: 4 }, "Page.frameRequestedNavigation", { + frameId: "frame-1", + disposition: "currentTab", + }); + fake.fireLifecycle(phase === "load" ? "load" : "networkIdle"); + await Promise.resolve(); + expect(settled).toBe(false); + fake.fireFrameNavigated("frame-1", "second-loader"); + fake.fireLifecycle(phase === "load" ? "load" : "networkIdle", "frame-1", "loader-after"); + await Promise.resolve(); + expect(settled).toBe(false); + fake.fireLifecycle(phase === "load" ? "load" : "networkIdle", "frame-1", "second-loader"); + expect(await work).toMatchObject({ reached: phase }); + expect(fake.listeners).toHaveLength(0); +}); + +it("does not overwrite a successor observed before Page.navigate resolves with its initial loader", async () => { + const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await manager.start("aa11"); + const fake = makeFakeCdp(); + const send = fake.cdp.send.bind(fake.cdp); + fake.cdp.send = (async (tabId, method, params) => { + const result = await send(tabId, method, params); + if (method === "Page.navigate") { + fake.fireFrameNavigated(); + for (const listener of [...fake.listeners]) + listener({ tabId: 4 }, "Page.frameRequestedNavigation", { + frameId: "frame-1", + disposition: "currentTab", + }); + fake.fireLifecycle("load"); + expect(fake.listeners.length).toBeGreaterThan(0); + fake.fireFrameNavigated("frame-1", "second-loader"); + fake.fireLifecycle("load", "frame-1", "second-loader"); + } + return result; + }) as CdpRunner["send"]; + expect( + await handleNavigate( + manager, + { session_id: "aa11", url: "https://example.com/", timeout_ms: 100 }, + { cdp: fake.cdp, tabsApi: fake.tabsApi }, + ), + ).toMatchObject({ reached: "load" }); + expect(fake.listeners).toHaveLength(0); +}); + +it.each([ + false, + true, +])("resumes a cancelled successor without retiring the current loader (request=%s)", async (networkRequest) => { + const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await manager.start("aa11"); + const fake = makeFakeCdp(); + const send = fake.cdp.send.bind(fake.cdp); + let current = "loader-before"; + fake.cdp.send = (async (tabId, method, params) => { + if (method === "Page.getFrameTree") + return { frameTree: { frame: { id: "frame-1", loaderId: current } } }; + return send(tabId, method, params); + }) as CdpRunner["send"]; + const fire = (method: string, params: Record) => { + for (const listener of [...fake.listeners]) listener({ tabId: 4 }, method, params); + }; + let settled = false; + const work = handleNavigate( + manager, + { session_id: "aa11", url: "https://example.com/", wait_until: "load", timeout_ms: 1000 }, + { cdp: fake.cdp, tabsApi: fake.tabsApi }, + ).then((result) => { + settled = true; + return result; + }); + await vi.waitFor(() => expect(fake.sent.some((s) => s.method === "Page.navigate")).toBe(true)); + current = "loader-after"; + fake.fireFrameNavigated(); + fire("Page.frameRequestedNavigation", { frameId: "frame-1", disposition: "currentTab" }); + if (networkRequest) + fire("Network.requestWillBeSent", { + frameId: "frame-1", + type: "Document", + loaderId: "attempt-loader", + requestId: "attempt", + }); + fake.fireLifecycle("load"); + if (networkRequest) { + await Promise.resolve(); + expect(settled).toBe(false); + fire("Network.loadingFailed", { + requestId: "attempt", + canceled: true, + errorText: "net::ERR_ABORTED", + }); + } + expect(await work).toMatchObject({ reached: "load" }); + expect(fake.listeners).toHaveLength(0); +}); + +it.each([ + ["domcontentloaded", "DOMContentLoaded"], + ["load", "load"], + ["networkidle", "networkIdle"], +] as const)("preserves buffered %s through unrelated lifecycle events and cancellation", async (phase, lifecycle) => { + const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await manager.start("aa11"); + const fake = makeFakeCdp(); + const send = fake.cdp.send.bind(fake.cdp); + let current = "loader-before"; + fake.cdp.send = (async (tabId, method, params) => { + if (method === "Page.getFrameTree") + return { frameTree: { frame: { id: "frame-1", loaderId: current } } }; + return send(tabId, method, params); + }) as CdpRunner["send"]; + const fire = (method: string, params: Record) => { + for (const listener of [...fake.listeners]) listener({ tabId: 4 }, method, params); + }; + let settled = false; + const work = handleNavigate( + manager, + { session_id: "aa11", url: "https://example.com/", wait_until: phase, timeout_ms: 1000 }, + { cdp: fake.cdp, tabsApi: fake.tabsApi }, + ).then((result) => { + settled = true; + return result; + }); + await vi.waitFor(() => expect(fake.sent.some((s) => s.method === "Page.navigate")).toBe(true)); + current = "loader-after"; + fake.fireFrameNavigated(); + fire("Page.frameRequestedNavigation", { frameId: "frame-1", disposition: "currentTab" }); + fire("Network.requestWillBeSent", { + frameId: "frame-1", + type: "Document", + loaderId: "attempt-loader", + requestId: "attempt", + }); + fake.fireLifecycle(lifecycle); + for (const name of ["networkAlmostIdle", "firstMeaningfulPaint", "InteractiveTime"]) + fake.fireLifecycle(name); + await Promise.resolve(); + expect(settled).toBe(false); + fire("Network.loadingFailed", { + requestId: "attempt", + canceled: true, + errorText: "net::ERR_ABORTED", + }); + expect(await work).toMatchObject({ reached: phase }); + expect(fake.listeners).toHaveLength(0); +}); + +it.each([ + ["load", "DOMContentLoaded", "load"], + ["networkidle", "load", "networkIdle"], +] as const)("does not reuse a predecessor's buffered %s after the successor commits", async (phase, earlierLifecycle, lifecycle) => { + const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await manager.start("aa11"); + const fake = makeFakeCdp(); + const send = fake.cdp.send.bind(fake.cdp); + let current = "loader-before"; + let frameReads = 0; + fake.cdp.send = (async (tabId, method, params) => { + if (method === "Page.getFrameTree") { + frameReads += 1; + return { frameTree: { frame: { id: "frame-1", loaderId: current } } }; + } + return send(tabId, method, params); + }) as CdpRunner["send"]; + const fire = (method: string, params: Record) => { + for (const listener of [...fake.listeners]) listener({ tabId: 4 }, method, params); + }; + const begin = (loaderId: string) => { + fire("Page.frameRequestedNavigation", { frameId: "frame-1", disposition: "currentTab" }); + fire("Network.requestWillBeSent", { + frameId: "frame-1", + type: "Document", + loaderId, + requestId: loaderId, + }); + }; + let settled = false; + const work = handleNavigate( + manager, + { session_id: "aa11", url: "https://example.com/", wait_until: phase, timeout_ms: 1000 }, + { cdp: fake.cdp, tabsApi: fake.tabsApi }, + ).then((result) => { + settled = true; + return result; + }); + await vi.waitFor(() => expect(fake.sent.some((s) => s.method === "Page.navigate")).toBe(true)); + current = "loader-after"; + fake.fireFrameNavigated(); + begin("second-loader"); + fake.fireLifecycle(lifecycle); + fake.fireLifecycle("firstMeaningfulPaint"); + + current = "second-loader"; + fake.fireFrameNavigated("frame-1", current); + begin("third-loader"); + fake.fireLifecycle(earlierLifecycle, "frame-1", current); + fake.fireLifecycle("firstMeaningfulPaint", "frame-1", current); + const beforeCancellation = frameReads; + fire("Network.loadingFailed", { + requestId: "third-loader", + canceled: true, + errorText: "net::ERR_ABORTED", + }); + await vi.waitFor(() => expect(frameReads).toBeGreaterThan(beforeCancellation)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(settled).toBe(false); + + fake.fireLifecycle(lifecycle, "frame-1", "loader-after"); + await Promise.resolve(); + expect(settled).toBe(false); + fake.fireLifecycle(lifecycle, "frame-1", current); + expect(await work).toMatchObject({ reached: phase }); + expect(fake.listeners).toHaveLength(0); +}); diff --git a/apps/extension/src/tools/__tests__/record-steps.test.ts b/apps/extension/src/tools/__tests__/record-steps.test.ts index 7d3ae2f6..143ca87e 100644 --- a/apps/extension/src/tools/__tests__/record-steps.test.ts +++ b/apps/extension/src/tools/__tests__/record-steps.test.ts @@ -135,6 +135,7 @@ function makeFakeCdp( let busyUntil = 0; const handlers: Record unknown> = { "Page.enable": () => ({}), + "Network.enable": () => ({}), "Page.setLifecycleEventsEnabled": () => ({}), "Page.getFrameTree": () => ({ frameTree: { frame: { id: "frame-1", loaderId: "loader-before" } }, diff --git a/apps/extension/src/tools/__tests__/restricted-navigation.live.test.ts b/apps/extension/src/tools/__tests__/restricted-navigation.live.test.ts new file mode 100644 index 00000000..f54bb3b9 --- /dev/null +++ b/apps/extension/src/tools/__tests__/restricted-navigation.live.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment node +// Explicit opt-in against a matching, reloaded extension: +// BSK_LIVE_NAVIGATION_CLI=/absolute/path/to/bsk vitest run restricted-navigation.live +// Creates and stops its own unfocused session; does not borrow existing tabs. +// Uses real chrome.debugger permissions, not an unrestricted remote-CDP adapter. +import { execFile } from "node:child_process"; +import { createServer, type ServerResponse } from "node:http"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +const exec = promisify(execFile); + +describe.skipIf(!process.env.BSK_LIVE_NAVIGATION_CLI)( + "restricted background navigation (live extension)", + () => { + it("follows background navigation and redirects to rAF-dependent readiness without selecting tabs", async () => { + const waiting = new Map(); + const ready = new Set(); + const requests = new Set(); + const pixel = Buffer.from( + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + "base64", + ); + const finishImage = (response: ServerResponse) => { + response.setHeader("Content-Type", "image/gif"); + response.end(pixel); + }; + const server = createServer((req, res) => { + const url = new URL(req.url!, "http://fixture.test"); + const key = url.searchParams.get("case") ?? ""; + if (url.pathname === "/gate") { + if (ready.has(key)) finishImage(res); + else waiting.set(key, res); + } else if (url.pathname === "/ready") { + ready.add(key); + const image = waiting.get(key); + if (image) { + waiting.delete(key); + finishImage(image); + } + res.end("ok"); + } else if (url.pathname === "/source") { + res.end("

Source

"); + } else if (url.pathname === "/page" && url.searchParams.get("mode") === "server") { + requests.add(key); + res.writeHead(302, { Location: `/final?case=${key}` }); + res.end(); + } else if ( + (url.pathname === "/page" && + ["sync", "dcl", "chain"].includes(url.searchParams.get("mode") ?? "")) || + url.pathname === "/hop" + ) { + requests.add(key); + const mode = url.searchParams.get("mode"); + const next = mode === "chain" ? `/hop?case=${key}` : `/final?case=${key}`; + const redirect = `location.replace(${JSON.stringify(next)})`; + res.setHeader("Content-Type", "text/html"); + res.end( + `

Redirecting

`, + ); + } else { + requests.add(key); + res.setHeader("Content-Type", "text/html"); + res.end(`Restricted navigation regression +

Waiting for animation frame

+ `); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/page`; + const run = async (args: string[]): Promise => { + try { + const { stdout } = await exec(process.env.BSK_LIVE_NAVIGATION_CLI!, [...args, "--json"], { + timeout: 45_000, + maxBuffer: 1024 * 1024, + }); + return JSON.parse(stdout) as T; + } catch (error) { + const failure = error as Error & { stdout?: string; stderr?: string }; + throw new Error( + `${args[0]} failed (server requests: ${[...requests].join(", ")}): ${failure.stdout ?? failure.message} ${failure.stderr ?? ""}`, + ); + } + }; + let session: string | undefined; + try { + session = ( + await run<{ session_id: string }>([ + "session", + "start", + "--no-focus", + ...(process.env.BSK_LIVE_NAVIGATION_BROWSER + ? ["--browser", process.env.BSK_LIVE_NAVIGATION_BROWSER] + : []), + "--name", + "restricted-navigation-regression", + ]) + ).session_id; + for (const source of ["restricted", "http"]) { + for (const mode of ["direct", "server", "sync", "dcl", "chain"]) { + const phases = + mode === "direct" || mode === "server" + ? ["load", "networkidle", "commit", "domcontentloaded"] + : ["load", "networkidle"]; + for (const phase of phases) { + const key = `${source}-${mode}-${phase}`; + const destination = `${url}?case=${key}&mode=${mode}`; + const finalUrl = + mode === "direct" ? destination : `${url.replace("/page", "/final")}?case=${key}`; + const { tab_id } = await run<{ tab_id: number }>([ + "tab", + "create", + "--session", + session, + "--no-active", + "--url", + source === "restricted" ? "chrome://newtab/" : url.replace("/page", "/source"), + ]); + const scope = ["--session", session, "--tab-id", String(tab_id)]; + const checkInactive = async () => { + const { tabs } = await run<{ tabs: { tab_id: number; active: boolean }[] }>([ + "tab", + "list", + "--session", + session!, + "--scope", + "agent", + ]); + expect(tabs.find((tab) => tab.tab_id === tab_id)?.active).toBe(false); + }; + await checkInactive(); + const result = await run<{ reached: string; final_url: string }>([ + "navigate", + destination, + ...scope, + "--wait-until", + phase, + "--timeout", + "20s", + ]); + expect(result.reached, key).toBe(phase); + expect(result.final_url, key).toBe(finalUrl); + expect(requests.has(key)).toBe(true); + // For load/networkidle, navigation itself cannot finish until rAF has run. + if (phase === "load" || phase === "networkidle") expect(ready.has(key)).toBe(true); + const snapshot = await run<{ text: string }>(["snapshot", ...scope]); + expect(snapshot.text).toContain("Background ready"); + await checkInactive(); + await run(["tab", "close", String(tab_id), "--session", session]); + } + } + } + } finally { + try { + if (session) await run(["session", "stop", session]); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + } + }, 180_000); + }, +); diff --git a/apps/extension/src/tools/__tests__/tabs.test.ts b/apps/extension/src/tools/__tests__/tabs.test.ts index dc9fdb56..5a7239e3 100644 --- a/apps/extension/src/tools/__tests__/tabs.test.ts +++ b/apps/extension/src/tools/__tests__/tabs.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { type CdpDebuggerApi, ChromiumCdp } from "@/browser-driver/chromium-cdp"; import { SessionManager } from "@/session-manager/manager"; import { handleHover } from "../interaction"; +import { resolveTargetTab } from "../shared"; import { type AgentOverlayResetApi, type ChromeWindowsApi, @@ -1249,3 +1250,213 @@ describe("handleTabReturn", () => { expect(spies.move).toHaveBeenLastCalledWith(7, { windowId: 777, index: 0 }); }); }); + +describe("background tab initialization", () => { + it("establishes execution before the destination can start without selecting the tab", async () => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + const ctx = await sm.start("aa11"); + const state: FakeTabState = { tabs: new Map(), nextTabId: 50, windowsClosed: new Set() }; + const { api, spies } = makeTabMutationApi(state); + const acquireBackgroundExecution = vi.fn(async (session: string, tabId: number) => { + expect(session).toBe("aa11"); + expect(ctx.agentCreatedTabs.has(tabId)).toBe(true); + expect(state.tabs.get(tabId)).toMatchObject({ url: "about:blank", active: false }); + expect(spies.update).not.toHaveBeenCalled(); + }); + expect( + await handleTabCreate( + sm, + { session_id: "aa11", url: "https://fixture.test", active: false }, + { tabs: api, cdp: { acquireBackgroundExecution } }, + ), + ).toMatchObject({ tab_id: 50, url: "https://fixture.test" }); + expect(acquireBackgroundExecution).toHaveBeenCalledOnce(); + expect(spies.update).toHaveBeenCalledExactlyOnceWith(50, { url: "https://fixture.test" }); + }); + + it.each([ + false, + true, + ])("rolls back failed or cancelled initialization (cancel=%s)", async (cancel) => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + const ctx = await sm.start("aa11"); + const state: FakeTabState = { tabs: new Map(), nextTabId: 50, windowsClosed: new Set() }; + const { api, spies } = makeTabMutationApi(state); + const controller = new AbortController(); + const cdp = { + acquireBackgroundExecution: vi.fn(async () => { + if (cancel) controller.abort(); + else throw new Error("unsupported"); + }), + releaseSessionTab: vi.fn(async () => {}), + }; + expect( + await handleTabCreate( + sm, + { session_id: "aa11", url: "https://fixture.test", active: false }, + { tabs: api, cdp, signal: controller.signal }, + ), + ).toMatchObject({ code: cancel ? "cancelled" : "cdp_failed" }); + expect(cdp.releaseSessionTab).toHaveBeenCalledWith("aa11", 50); + expect(spies.update).not.toHaveBeenCalled(); + expect(state.tabs.has(50)).toBe(false); + expect(ctx.agentCreatedTabs.has(50)).toBe(false); + }); +}); + +it("creates a CDP-ready blank default tab so later navigation starts under the policy", async () => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await sm.start("aa11"); + const state: FakeTabState = { tabs: new Map(), nextTabId: 50, windowsClosed: new Set() }; + const { api, spies } = makeTabMutationApi(state); + const acquireBackgroundExecution = vi.fn(async () => {}); + expect( + await handleTabCreate( + sm, + { session_id: "aa11", active: false }, + { tabs: api, cdp: { acquireBackgroundExecution } }, + ), + ).toMatchObject({ url: "about:blank" }); + expect(acquireBackgroundExecution).toHaveBeenCalledWith("aa11", 50); + expect(spies.update).not.toHaveBeenCalled(); +}); + +it("returns a borrowed page without activating it when execution setup fails", async () => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + const ctx = await sm.start("aa11"); + const state: FakeTabState = { + tabs: new Map([ + [ + 7, + { + id: 7, + windowId: 200, + index: 4, + active: false, + url: "https://fixture.test", + } as chrome.tabs.Tab, + ], + ]), + nextTabId: 50, + windowsClosed: new Set(), + }; + const { api, spies } = makeTabMutationApi(state); + const cdp = { + acquireBackgroundExecution: vi.fn(async () => { + throw new Error("unsupported"); + }), + releaseSessionTab: vi.fn(async () => {}), + }; + expect( + await handleTabBorrow( + sm, + { session_id: "aa11", tab_id: 7 }, + { + tabs: api, + windows: makeWindowsApi(state).api, + cdp, + approveBorrow: async () => true, + agentOverlayReset: { resetAgentOverlays: async () => {} }, + }, + ), + ).toMatchObject({ code: "cdp_failed" }); + expect(state.tabs.get(7)).toMatchObject({ windowId: 200, index: 4, active: false }); + expect(ctx.borrowedTabs.has(7)).toBe(false); + expect(cdp.releaseSessionTab).toHaveBeenCalledWith("aa11", 7); + expect(spies.update).not.toHaveBeenCalled(); +}); + +it.each([ + [true, false], + [true, true], + [false, true], +])("independently releases and closes failed creation (release=%s, close=%s)", async (releaseFails, closeFails) => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + const ctx = await sm.start("aa11"); + const state: FakeTabState = { tabs: new Map(), nextTabId: 50, windowsClosed: new Set() }; + const { api, spies } = makeTabMutationApi(state); + if (closeFails) spies.remove.mockRejectedValueOnce(new Error("close failed")); + const cdp = { + acquireBackgroundExecution: vi.fn(async () => { + throw new Error("setup failed"); + }), + releaseSessionTab: vi.fn(async () => { + if (releaseFails) throw new Error("release failed"); + }), + }; + const result = await handleTabCreate( + sm, + { session_id: "aa11", active: false }, + { tabs: api, cdp }, + ); + expect(result).toMatchObject({ code: "protocol_error" }); + expect(JSON.stringify(result)).toContain("setup failed"); + if (releaseFails) expect(JSON.stringify(result)).toContain("release failed"); + if (closeFails) expect(JSON.stringify(result)).toContain("close failed"); + expect(cdp.releaseSessionTab).toHaveBeenCalledWith("aa11", 50); + expect(spies.remove).toHaveBeenCalledWith(50); + expect(state.tabs.has(50)).toBe(closeFails); + expect(ctx.agentCreatedTabs.has(50)).toBe(closeFails); +}); + +it("keeps borrowed tabs as the default target and preserves explicit targeting", async () => { + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + const ctx = await sm.start("aa11"); + const state: FakeTabState = { + tabs: new Map([ + [ + 7, + { + id: 7, + windowId: 200, + index: 3, + active: false, + url: "https://borrow.test", + } as chrome.tabs.Tab, + ], + [8, { id: 8, windowId: 100, active: true } as chrome.tabs.Tab], + ]), + nextTabId: 50, + windowsClosed: new Set(), + }; + const { api, spies } = makeTabMutationApi(state); + const update = spies.update.getMockImplementation() as ( + tabId: number, + props: chrome.tabs.UpdateProperties, + ) => Promise; + spies.update.mockImplementation(async (id, props) => { + const tab = await update(id, props); + if (props.active) + for (const other of state.tabs.values()) { + if (other.id !== id && other.windowId === tab.windowId) other.active = false; + } + return tab; + }); + const tabsApi = { + get: api.get, + query: vi.fn(async (q: chrome.tabs.QueryInfo) => + [...state.tabs.values()].filter((t) => t.windowId === q.windowId && (!q.active || t.active)), + ), + }; + const cdp = { + acquireBackgroundExecution: vi.fn(async () => {}), + releaseSessionTab: vi.fn(async () => {}), + }; + expect( + await handleTabBorrow( + sm, + { session_id: "aa11", tab_id: 7 }, + { tabs: api, cdp, approveBorrow: async () => true }, + ), + ).toMatchObject({ tab_id: 7 }); + expect(await resolveTargetTab(sm, ctx, undefined, tabsApi)).toMatchObject({ tabId: 7 }); + expect(await resolveTargetTab(sm, ctx, 8, tabsApi)).toMatchObject({ tabId: 8 }); + expect(cdp.acquireBackgroundExecution).toHaveBeenCalledWith("aa11", 7); + const { api: windows } = makeWindowsApi(state); + expect( + await handleTabReturn(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api, windows, cdp }), + ).not.toHaveProperty("code"); + expect(state.tabs.get(7)).toMatchObject({ windowId: 200, index: 3 }); + expect(ctx.borrowedTabs.has(7)).toBe(false); + expect(cdp.releaseSessionTab).toHaveBeenCalledWith("aa11", 7); +}); diff --git a/apps/extension/src/tools/background-execution.ts b/apps/extension/src/tools/background-execution.ts new file mode 100644 index 00000000..710c1640 --- /dev/null +++ b/apps/extension/src/tools/background-execution.ts @@ -0,0 +1,91 @@ +import { isAgentControlledTab, type SessionManager } from "@/session-manager/manager"; +import type { RequestFrame, RpcError } from "@/transport/types"; +import { + type CdpRunner, + type ChromeTabsApi, + cdpBlockedUrlReason, + isRpcError, + resolveCdpAccessibleTargetTab, + resolveTargetTab, +} from "./shared"; + +const reads = new Set([ + "tool.snapshot", + "tool.observe", + "tool.screenshot", + "tool.get_html", + "tool.evaluate", + "tool.console", + "tool.network", +]); +const pageTools = new Set([ + ...reads, + "tool.navigate", + "tool.navigate_back", + "tool.navigate_forward", + "tool.reload", + "tool.click", + "tool.hover", + "tool.wheel", + "tool.scroll_to", + "tool.focus", + "tool.blur", + "tool.fill", + "tool.press", + "tool.select", + "tool.upload", + "tool.download", + "tool.emulate", + "tool.wait_for_navigation", + "tool.screenshot_full_page", +]); + +/** Prepare only explicitly controlled targets, before page reads or readiness waits. + * Resolve once and pin the request to that tab; UI activation is never targeting. */ +export async function prepareBackgroundExecution( + manager: SessionManager, + request: RequestFrame, + cdp: CdpRunner | undefined, + tabs: ChromeTabsApi, + signal: AbortSignal, +): Promise { + if (!cdp?.acquireBackgroundExecution || !pageTools.has(request.method)) return; + const params = request.params as { session_id?: string; tab_id?: number } | undefined; + if (!params?.session_id) return; + const ctx = manager.get(params.session_id); + if (!ctx) return; + const target = await (reads.has(request.method) + ? resolveCdpAccessibleTargetTab(manager, ctx, params.tab_id, tabs, request.method) + : resolveTargetTab(manager, ctx, params.tab_id, tabs)); + if (isRpcError(target)) return target; + request.params = { ...params, tab_id: target.tabId }; + // Navigation owns preparation: an inaccessible source document must still + // be able to leave through browser navigation before CDP becomes available. + if ( + ["tool.navigate", "tool.reload", "tool.navigate_back", "tool.navigate_forward"].includes( + request.method, + ) + ) + return; + if (!isAgentControlledTab(ctx, target.tabId) || target.windowId !== ctx.agentWindowId) return; + // Other page tools cannot establish execution on browser-internal documents. + if (cdpBlockedUrlReason(target.url)) return; + if (signal.aborted) return { code: "cancelled", message: "Background execution setup cancelled" }; + try { + await cdp.acquireBackgroundExecution(ctx.sessionId, target.tabId); + if (manager.get(ctx.sessionId) !== ctx || !isAgentControlledTab(ctx, target.tabId)) { + await cdp.releaseSessionTab?.(ctx.sessionId, target.tabId); + return { + code: "cancelled", + message: "Target control ended during background execution setup", + }; + } + if (signal.aborted) + return { code: "cancelled", message: "Background execution setup cancelled" }; + } catch (error) { + return { + code: "cdp_failed", + message: `Could not establish background execution for tab ${target.tabId}: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} diff --git a/apps/extension/src/tools/browser-navigation.ts b/apps/extension/src/tools/browser-navigation.ts index cdf181f6..946a38b4 100644 --- a/apps/extension/src/tools/browser-navigation.ts +++ b/apps/extension/src/tools/browser-navigation.ts @@ -1,11 +1,14 @@ import type { RpcError, WaitUntil } from "@/transport/types"; import { cdpError } from "./errors"; +import { NavigationDocument } from "./navigation-document"; interface NavigationEvent { tabId: number; frameId: number; documentId?: string; error?: string; + url?: string; + transitionQualifiers?: string[]; } interface NavigationEvents { @@ -17,15 +20,27 @@ interface NavigationEvents { export interface BrowserNavigationApi { update(tabId: number, props: { url: string }): Promise; reload(tabId: number, props: { bypassCache: boolean }): Promise; + goBack(tabId: number): Promise; + goForward(tabId: number): Promise; + getFrame(tabId: number): Promise<{ documentId?: string } | null>; + onBeforeNavigate: NavigationEvents; onCommitted: NavigationEvents; onDOMContentLoaded: NavigationEvents; onCompleted: NavigationEvents; onErrorOccurred: NavigationEvents; + onReferenceFragmentUpdated?: NavigationEvents; + onHistoryStateUpdated?: NavigationEvents; } export const chromeBrowserNavigationApi: BrowserNavigationApi = { update: (tabId, props) => chrome.tabs.update(tabId, props), reload: (tabId, props) => chrome.tabs.reload(tabId, props), + goBack: (tabId) => chrome.tabs.goBack(tabId), + goForward: (tabId) => chrome.tabs.goForward(tabId), + getFrame: (tabId) => chrome.webNavigation.getFrame({ tabId, frameId: 0 }), + get onBeforeNavigate() { + return chrome.webNavigation.onBeforeNavigate; + }, get onCommitted() { return chrome.webNavigation.onCommitted; }, @@ -35,13 +50,19 @@ export const chromeBrowserNavigationApi: BrowserNavigationApi = { get onCompleted() { return chrome.webNavigation.onCompleted; }, + get onReferenceFragmentUpdated() { + return chrome.webNavigation.onReferenceFragmentUpdated; + }, + get onHistoryStateUpdated() { + return chrome.webNavigation.onHistoryStateUpdated; + }, get onErrorOccurred() { return chrome.webNavigation.onErrorOccurred; }, }; export type NavigationOutcome = - | { reached: "match" | "timeout" | "cancelled"; lastLifecycle?: string } + | { reached: "match" | "timeout" | "cancelled"; lastLifecycle?: string; url?: string } | { reached: "failed"; error: RpcError }; /** Subscribe before dispatch and ignore the departing document's load events. */ @@ -49,62 +70,212 @@ export async function navigateWithBrowserApi( api: BrowserNavigationApi, tabId: number, action: () => Promise, - waitUntil: Exclude, + waitUntil: WaitUntil, timeoutMs: number, signal?: AbortSignal, + afterCommit?: (event: NavigationEvent, signal: AbortSignal) => Promise, + requestedUrl?: string, ): Promise { if (signal?.aborted) return { reached: "cancelled" }; if (timeoutMs <= 0) return { reached: "timeout" }; - let committed = false; - let documentId: string | undefined; + const controller = new AbortController(); + let started = false; + const document = new NavigationDocument(); + let handoff = new AbortController(); + let acceptedCommit = false; + let pendingUrl: string | undefined; + let navigationRevision = 0; + let committedUrl: string | undefined; + // An explicit navigate identifies its start URL. A committed server redirect + // still belongs to that navigation; an unfinished source commit does not. + const expectedUrl = requestedUrl === undefined ? undefined : new URL(requestedUrl).href; + const matchesStart = (details: NavigationEvent) => + expectedUrl === undefined || details.url === expectedUrl; + let lastLifecycle: string | undefined; - let finish!: (outcome: NavigationOutcome) => void; - const outcome = new Promise((resolve) => { - finish = resolve; + let prepared = false; + let actionDone = false; + let matched = false; + let settled = false; + let resolve!: (outcome: NavigationOutcome) => void; + const outcome = new Promise((done) => { + resolve = done; }); + const finish = (result: NavigationOutcome) => { + if (settled) return; + settled = true; + controller.abort(); + handoff.abort(); + resolve(result); + }; + const tryFinish = () => { + if (!document.pending && prepared && actionDone && matched) + finish({ reached: "match", lastLifecycle, url: committedUrl }); + }; const isMainFrame = (details: NavigationEvent) => details.tabId === tabId && details.frameId === 0; + const isDocument = (details: NavigationEvent) => + isMainFrame(details) && !!document.id && details.documentId === document.id; + const resetReadiness = () => { + handoff.abort(); + handoff = new AbortController(); + prepared = false; + matched = false; + lastLifecycle = undefined; + }; + const onBeforeNavigate = (details: NavigationEvent) => { + if (!isMainFrame(details) || settled) return; + if (!acceptedCommit) { + if (matchesStart(details)) started = true; + return; + } + document.begin(); + pendingUrl = details.url; + navigationRevision += 1; + }; const onCommitted = (details: NavigationEvent) => { - if (!isMainFrame(details)) return; - committed = true; - documentId = details.documentId; + if (!isMainFrame(details) || settled || document.isRetired(details.documentId)) return; + if ( + !acceptedCommit && + (!started || + (!matchesStart(details) && !details.transitionQualifiers?.includes("server_redirect"))) + ) { + document.retire(details.documentId); + return; + } + if (!document.commit(details.documentId)) return; + acceptedCommit = true; + pendingUrl = undefined; + navigationRevision += 1; + resetReadiness(); + committedUrl = details.url; lastLifecycle = "commit"; - if (waitUntil === "commit") finish({ reached: "match", lastLifecycle }); + matched = waitUntil === "commit" || waitUntil === "networkidle"; + const version = document.version; + const signal = handoff.signal; + // A successor invalidates this handoff without ending the operation. + // Keep native listeners active even during CDP network-idle waiting. + void Promise.resolve() + .then(() => { + signal.throwIfAborted(); + return afterCommit?.(details, signal); + }) + .then( + (ready) => { + if (settled || !document.isCurrent(version) || ready === false) return; + prepared = true; + tryFinish(); + }, + (error) => { + if (settled || !document.isCurrent(version)) return; + finish( + error instanceof Error && error.name === "AbortError" + ? { reached: "cancelled", lastLifecycle } + : { reached: "failed", error: cdpError(error) }, + ); + }, + ); }; const onDOMContentLoaded = (details: NavigationEvent) => { - if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId)) - return; + if (!isDocument(details)) return; lastLifecycle = "DOMContentLoaded"; - if (waitUntil === "domcontentloaded") finish({ reached: "match", lastLifecycle }); + matched ||= waitUntil === "domcontentloaded"; + tryFinish(); }; const onCompleted = (details: NavigationEvent) => { - if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId)) - return; - finish({ reached: "match", lastLifecycle: "load" }); + if (!isDocument(details)) return; + lastLifecycle = "load"; + matched ||= waitUntil !== "networkidle"; + tryFinish(); + }; + const resumeCurrentDocument = async () => { + const version = document.version; + const revision = navigationRevision; + try { + const current = await api.getFrame(tabId); + if ( + settled || + !document.isCurrent(version) || + navigationRevision !== revision || + !document.id || + current?.documentId !== document.id + ) + return; + document.cancelPending(); + pendingUrl = undefined; + tryFinish(); + } catch { + // An unreadable/replaced document cannot confirm a cancelled successor. + } + }; + const onSameDocument = (details: NavigationEvent) => { + if (isDocument(details) && document.pending) void resumeCurrentDocument(); }; const onError = (details: NavigationEvent) => { - if (!isMainFrame(details)) return; + // A cancelled successor does not invalidate an already committed page. + // Match its attempted URL, then confirm the current document before resuming. + if ( + isMainFrame(details) && + document.pending && + pendingUrl && + details.url === pendingUrl && + details.error === "net::ERR_ABORTED" && + !document.isRetired(details.documentId) + ) { + void resumeCurrentDocument(); + return; + } + if ( + !isMainFrame(details) || + !started || + document.isRetired(details.documentId) || + (!!document.id && !!details.documentId && details.documentId !== document.id) + ) + return; finish({ reached: "failed", error: cdpError(details.error ?? "browser navigation failed") }); }; const onAbort = () => finish({ reached: "cancelled", lastLifecycle }); const subscriptions = [ + [api.onBeforeNavigate, onBeforeNavigate], [api.onCommitted, onCommitted], [api.onDOMContentLoaded, onDOMContentLoaded], [api.onCompleted, onCompleted], [api.onErrorOccurred, onError], + [api.onReferenceFragmentUpdated, onSameDocument], + [api.onHistoryStateUpdated, onSameDocument], ] as const; - for (const [event, listener] of subscriptions) event.addListener(listener); + for (const [event, listener] of subscriptions) event?.addListener(listener); signal?.addEventListener("abort", onAbort, { once: true }); const timer = setTimeout(() => finish({ reached: "timeout", lastLifecycle }), timeoutMs); try { if (signal?.aborted) return { reached: "cancelled" }; - await action(); + // The deadline also bounds an unresponsive browser action or handoff. + void Promise.resolve() + .then(async () => { + const departing = await api.getFrame(tabId); + document.retire(departing?.documentId); + controller.signal.throwIfAborted(); + return action(); + }) + .then( + () => { + actionDone = true; + tryFinish(); + }, + (error) => { + finish( + error instanceof Error && error.name === "AbortError" + ? { reached: "cancelled", lastLifecycle } + : { reached: "failed", error: cdpError(error) }, + ); + }, + ); return await outcome; - } catch (error) { - return { reached: "failed", error: cdpError(error) }; } finally { + controller.abort(); + handoff.abort(); clearTimeout(timer); signal?.removeEventListener("abort", onAbort); - for (const [event, listener] of subscriptions) event.removeListener(listener); + for (const [event, listener] of subscriptions) event?.removeListener(listener); } } diff --git a/apps/extension/src/tools/dispatcher.ts b/apps/extension/src/tools/dispatcher.ts index fdac85dd..b713f2fa 100644 --- a/apps/extension/src/tools/dispatcher.ts +++ b/apps/extension/src/tools/dispatcher.ts @@ -43,6 +43,7 @@ import type { } from "@/transport/types"; import { isRequestFrame } from "@/transport/types"; import { auditContext } from "./audit-context"; +import { prepareBackgroundExecution } from "./background-execution"; import { handleConsole } from "./console"; import { handleDownload } from "./download"; import { type EmulateCdpRunner, handleEmulate } from "./emulate"; @@ -351,6 +352,14 @@ export class ToolDispatcher { message: "Remote connections do not support upload or download", }; } + const preparationError = await prepareBackgroundExecution( + this.sessions, + req, + this.cdp, + chromeTabsApi, + signal, + ); + if (preparationError) return preparationError; switch (req.method) { case "tool.session_start": return handleSessionStart(this.sessions, req.params as SessionStartParams, { @@ -375,6 +384,7 @@ export class ToolDispatcher { case "tool.tab_create": { const result = await handleTabCreate(this.sessions, req.params as TabCreateParams, { signal, + cdp: this.cdp, }); if (!isRpcError(result)) { this.onAgentTabClaimed?.(result.tab_id, result.window_id); @@ -393,6 +403,7 @@ export class ToolDispatcher { const result = await handleTabBorrow(this.sessions, req.params as TabBorrowParams, { signal, approveBorrow: this.approveBorrow, + cdp: this.cdp, }); if (!isRpcError(result)) { this.onAgentTabClaimed?.(result.tab_id, result.agent_window_id); @@ -515,7 +526,9 @@ export class ToolDispatcher { handleNavigate( this.sessions, req.params as NavigateParams, - this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + this.cdp + ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal, backgroundExecution: true } + : undefined, ), signal, ); @@ -526,7 +539,9 @@ export class ToolDispatcher { handleNavigateBack( this.sessions, req.params as NavigateBackParams, - this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + this.cdp + ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal, backgroundExecution: true } + : undefined, ), signal, ); @@ -537,7 +552,9 @@ export class ToolDispatcher { handleNavigateForward( this.sessions, req.params as NavigateForwardParams, - this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + this.cdp + ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal, backgroundExecution: true } + : undefined, ), signal, ); @@ -548,7 +565,9 @@ export class ToolDispatcher { handleReload( this.sessions, req.params as ReloadParams, - this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + this.cdp + ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal, backgroundExecution: true } + : undefined, ), signal, ); diff --git a/apps/extension/src/tools/navigation-document.ts b/apps/extension/src/tools/navigation-document.ts new file mode 100644 index 00000000..e87aae1c --- /dev/null +++ b/apps/extension/src/tools/navigation-document.ts @@ -0,0 +1,37 @@ +/** Operation-local identity. Navigation may replace documents, never the target tab. */ +export class NavigationDocument { + private readonly retired = new Set(); + id: string | undefined; + version = 0; + pending = false; + + retire(id: string | undefined): void { + if (id) this.retired.add(id); + } + + isRetired(id: string | undefined): boolean { + return !!id && this.retired.has(id); + } + + begin(): void { + // A request can be cancelled without replacing the committed document. + this.pending = true; + } + + cancelPending(): void { + this.pending = false; + } + + commit(id: string | undefined): boolean { + if (!id || this.isRetired(id) || this.id === id) return false; + this.retire(this.id); + this.id = id; + this.pending = false; + this.version += 1; + return true; + } + + isCurrent(version: number): boolean { + return this.version === version; + } +} diff --git a/apps/extension/src/tools/navigation.ts b/apps/extension/src/tools/navigation.ts index e40d7241..f954ba9e 100644 --- a/apps/extension/src/tools/navigation.ts +++ b/apps/extension/src/tools/navigation.ts @@ -16,7 +16,11 @@ // `NavigateBackResult` / `NavigateForwardResult` / `ReloadResult`. import { ChromiumCdp } from "@/browser-driver/chromium-cdp"; -import type { SessionManager } from "@/session-manager/manager"; +import { + isAgentControlledTab, + type SessionContext, + type SessionManager, +} from "@/session-manager/manager"; import type { NavigateBackParams, NavigateForwardParams, @@ -35,13 +39,16 @@ import { } from "./browser-navigation"; import { attachDialogs, markDialogCursor } from "./dialogs"; import { cdpError, isCdpExtensionAccessDenied } from "./errors"; +import { NavigationDocument } from "./navigation-document"; import { type CdpRunner, type ChromeTabsApi, + cdpBlockedUrlReason, chromeTabsApi, enforceAgentWindow, isRpcError, lookupSession, + type ResolvedTargetTab, resolveTargetTab, } from "./shared"; @@ -49,6 +56,8 @@ export interface NavigationDeps { cdp: CdpRunner; tabsApi: ChromeTabsApi; browserNavigation?: BrowserNavigationApi; + /** Agent requests opt in; recording also reuses navigation without changing execution policy. */ + backgroundExecution?: boolean; /** Optional AbortSignal — M7 abort hook (M10.2 will wire the full chain). */ signal?: AbortSignal; /** Override default timeout when the caller omits `timeout_ms`. */ @@ -167,12 +176,13 @@ export function shouldTrustReadyStateProbe( return true; } -let defaultDeps: { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } | null = null; -function getDefaultDeps(): { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } { +let defaultDeps: NavigationDeps | null = null; +function getDefaultDeps(): NavigationDeps { if (!defaultDeps) { defaultDeps = { cdp: new ChromiumCdp(), tabsApi: chromeTabsApi, + backgroundExecution: true, }; } return defaultDeps; @@ -200,6 +210,7 @@ interface LifecycleWait { export interface LifecycleWaitGuard { loaderId?: string | (() => string | null | undefined); beforeLoaderId?: string | null; + followNavigations?: boolean; } function currentLoaderId(guard: LifecycleWaitGuard | undefined): string { @@ -237,12 +248,18 @@ function startLifecycleWait( let settled = false; let sawRelevantLifecycle = false; let lastLifecycle: string | undefined; + const document = new NavigationDocument(); + document.retire(guard?.beforeLoaderId ?? undefined); + const pendingRequests = new Set(); + let navigationRevision = 0; let pendingLifecycle: { name: string; frameId?: string; loaderId?: string } | null = null; let listenerSub: { dispose(): void } | null = null; let timer: ReturnType | null = null; let abortHandler: (() => void) | null = null; - const currentFrameId = () => (typeof frameId === "function" ? (frameId() ?? "") : frameId); + let observedMainFrameId = ""; + const currentFrameId = () => + (typeof frameId === "function" ? frameId() : frameId) || observedMainFrameId; const noteRelevantLifecycle = (name: string) => { sawRelevantLifecycle = true; @@ -260,7 +277,9 @@ function startLifecycleWait( eventFrameId?: string, eventLoaderId?: string, ): boolean => { - if (!eventLoaderIsRelevant(eventLoaderId, guard)) return false; + if (guard?.followNavigations && (document.id || document.pending)) { + if (document.pending || eventLoaderId !== document.id) return false; + } else if (!eventLoaderIsRelevant(eventLoaderId, guard)) return false; if (!lifecycleEventMatchesFrame(eventFrameId)) return false; noteRelevantLifecycle(name); return true; @@ -307,7 +326,9 @@ function startLifecycleWait( tryProbe = async (mode: LifecycleProbeMode, beforeReadyState: string | null = null) => { if (settled) return; + const version = document.version; const readyState = await probeMainFrameReadyState(cdp, expectedTabId); + if (guard?.followNavigations && (!document.isCurrent(version) || document.pending)) return; if (readyState === null) return; if ( shouldTrustReadyStateProbe(readyState, targetName, { @@ -320,6 +341,25 @@ function startLifecycleWait( } }; + // Request intent is not a commit. Reconcile buffered current-document + // events after cancellation, without accepting a probe from an older turn. + const reconcilePending = async () => { + if (settled || !document.pending || pendingRequests.size) return; + const version = document.version; + const revision = navigationRevision; + const frame = await readMainFrameInfo(cdp, expectedTabId); + if ( + settled || + !document.isCurrent(version) || + revision !== navigationRevision || + pendingRequests.size + ) + return; + if (frame.frameId !== currentFrameId() || frame.loaderId !== document.id) return; + document.cancelPending(); + maybeFinishPending(); + }; + if (signal?.aborted) { finish({ reached: "cancelled", lastLifecycle }); return; @@ -331,6 +371,72 @@ function startLifecycleWait( if (settled) return; if (source.tabId !== expectedTabId) return; + if (guard?.followNavigations) { + if (method === "Page.frameRequestedNavigation") { + const p = params as { frameId?: string; disposition?: string }; + if ( + currentFrameId() && + p.frameId === currentFrameId() && + (!p.disposition || p.disposition === "currentTab") + ) { + document.begin(); + navigationRevision += 1; + } + return; + } + if (method === "Network.requestWillBeSent") { + const p = params as { + requestId?: string; + frameId?: string; + loaderId?: string; + type?: string; + }; + if ( + p.type === "Document" && + p.frameId === currentFrameId() && + p.requestId && + document.id && + p.loaderId !== document.id && + !document.isRetired(p.loaderId) + ) { + document.begin(); + navigationRevision += 1; + pendingRequests.add(p.requestId); + } + return; + } + if (method === "Network.loadingFailed" || method === "Network.loadingFinished") { + const p = params as { requestId?: string }; + if (p.requestId && pendingRequests.delete(p.requestId)) { + navigationRevision += 1; + void reconcilePending(); + } + return; + } + if ( + method === "Page.frameStoppedLoading" || + method === "Page.navigatedWithinDocument" + ) { + const p = params as { frameId?: string }; + if (p.frameId === currentFrameId()) void reconcilePending(); + return; + } + if (method === "Page.frameNavigated") { + const p = params as { frame?: { id?: string; parentId?: string; loaderId?: string } }; + if ( + !p.frame?.parentId && + lifecycleEventMatchesFrame(p.frame?.id) && + document.commit(p.frame?.loaderId) + ) { + observedMainFrameId = p.frame?.id ?? ""; + navigationRevision += 1; + pendingRequests.clear(); + pendingLifecycle = null; + sawRelevantLifecycle = false; + lastLifecycle = undefined; + } + } + } if (targetName === "commit" && method === "Page.frameNavigated") { const p = params as { frame?: { id?: string; parentId?: string; loaderId?: string } }; const expectedFrameId = currentFrameId(); @@ -347,6 +453,20 @@ function startLifecycleWait( if (method !== "Page.lifecycleEvent") return; const p = params as { name?: string; frameId?: string; loaderId?: string }; if (!p?.name) return; + if ( + guard?.followNavigations && + document.pending && + p.loaderId === document.id && + lifecycleEventMatchesFrame(p.frameId) + ) { + // Keep evidence that this document met the wait condition until the + // pending navigation is cancelled or a successor commits and clears it. + if (!pendingLifecycle || !lifecycleMeetsOrExceeds(pendingLifecycle.name, targetName)) { + pendingLifecycle = { name: p.name, frameId: p.frameId, loaderId: p.loaderId }; + } + void reconcilePending(); + return; + } if (currentFrameId().length === 0) { // A static empty `frameId` means the caller does not know // which frame to filter on (M9.2 `wait_for_navigation` @@ -362,7 +482,9 @@ function startLifecycleWait( } return; } - if (!eventLoaderIsRelevant(p.loaderId, guard)) return; + if (guard?.followNavigations && (document.id || document.pending)) { + if (document.pending || p.loaderId !== document.id) return; + } else if (!eventLoaderIsRelevant(p.loaderId, guard)) return; if (!lifecycleEventMatchesFrame(p.frameId)) return; pendingLifecycle = { name: p.name, frameId: p.frameId, loaderId: p.loaderId }; return; @@ -486,64 +608,154 @@ async function readTabUrl(api: ChromeTabsApi, tabId: number): Promise { + signal?.throwIfAborted(); + if ( + !deps.backgroundExecution || + !deps.cdp.acquireBackgroundExecution || + !isAgentControlledTab(ctx, tabId) + ) + return; + if (manager.get(ctx.sessionId) !== ctx) throw new DOMException("Session ended", "AbortError"); + await deps.cdp.acquireBackgroundExecution?.(ctx.sessionId, tabId); + if (manager.get(ctx.sessionId) !== ctx || !isAgentControlledTab(ctx, tabId)) { + await deps.cdp.releaseSessionTab?.(ctx.sessionId, tabId); + throw new DOMException("Target control ended during navigation setup", "AbortError"); + } + signal?.throwIfAborted(); +} + +async function prepareNavigation( + manager: SessionManager, + ctx: SessionContext, + target: ResolvedTargetTab, + deps: NavigationDeps, +): Promise<"browser" | "cdp"> { + deps.signal?.throwIfAborted(); + deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); + if (cdpBlockedUrlReason(target.url) || cdpBlockedUrlReason(target.pendingUrl)) return "browser"; + try { + await acquireNavigationExecution(manager, ctx, target.tabId, deps); + await ensureCdpReady(deps.cdp, target.tabId); + // Successor request termination is part of navigation waiting, even if + // optional network capture was unavailable during debugger attachment. + await deps.cdp.send(target.tabId, "Network.enable", {}); + return "cdp"; + } catch (error) { + // Only preflight access denial permits fallback. Never replay a sent action. + if (!isCdpExtensionAccessDenied(error)) throw error; + return "browser"; + } +} + +function navigationError(error: unknown): RpcError { + return error instanceof Error && error.name === "AbortError" + ? { code: "cancelled", message: "Navigation cancelled" } + : cdpError(error); +} + /** Recover only a failed preflight; never replay an already-dispatched navigation. */ async function recoverBrowserNavigation( deps: NavigationDeps, - tabId: number, + manager: SessionManager, + ctx: SessionContext, + target: ResolvedTargetTab, action: (api: BrowserNavigationApi) => Promise, waitUntil: WaitUntil, timeoutMs: number, + requestedUrl?: string, ): Promise { + const tabId = target.tabId; + const controlled = isAgentControlledTab(ctx, tabId); const abort = linkedAbortSignal(deps.signal); const deadline = Date.now() + timeoutMs; const api = deps.browserNavigation ?? chromeBrowserNavigationApi; + const checkControl = async (signal: AbortSignal) => { + signal.throwIfAborted(); + const tab = await deps.tabsApi.get(tabId); + signal.throwIfAborted(); + if ( + manager.get(ctx.sessionId) !== ctx || + tab.windowId !== target.windowId || + (controlled && !isAgentControlledTab(ctx, tabId)) + ) + throw new DOMException("Target control ended during navigation recovery", "AbortError"); + return tab; + }; try { - let outcome = await navigateWithBrowserApi( + const outcome = await navigateWithBrowserApi( api, tabId, () => action(api), - waitUntil === "networkidle" ? "commit" : waitUntil, + waitUntil, timeoutMs, abort.signal, + async (event, signal) => { + // Chrome metadata may advance before its corresponding event arrives. + // An obsolete handoff waits for that event; it is not a navigation error. + const isCurrent = async () => { + await checkControl(signal); + const frame = await api.getFrame(tabId); + signal.throwIfAborted(); + return !!event.documentId && frame?.documentId === event.documentId; + }; + try { + if (!(await isCurrent())) return false; + const tab = await checkControl(signal); + if (cdpBlockedUrlReason(event.url ?? tab.url)) + throw new Error("Navigation remains on a restricted page"); + await acquireNavigationExecution(manager, ctx, tabId, deps, signal); + if (!(await isCurrent())) return false; + await deps.cdp.send(tabId, "Page.enable", {}); + if (!(await isCurrent())) return false; + if (waitUntil === "networkidle") { + const frame = await readMainFrameInfo(deps.cdp, tabId); + if (!(await isCurrent())) return false; + if (!frame.frameId) throw new Error("No main frame after navigation"); + const remaining = deadline - Date.now(); + if (remaining <= 0) return false; + const waiting = linkedAbortSignal(signal); + const wait = startLifecycleWait( + deps.cdp, + tabId, + frame.frameId, + "networkIdle", + remaining, + waiting.signal, + { loaderId: frame.loaderId ?? undefined }, + ); + try { + await deps.cdp.send(tabId, "Page.setLifecycleEventsEnabled", { enabled: true }); + if ((await wait.promise).reached !== "match") return false; + } finally { + waiting.abort(); + waiting.cleanup(); + } + } + return await isCurrent(); + } catch (error) { + // Do not hide ownership loss or failures of the current document. + if (signal.aborted) throw error; + if (!(await isCurrent())) return false; + throw error; + } + }, + requestedUrl, ); if (outcome.reached === "failed") return outcome.error; - if (outcome.reached === "cancelled" || abort.signal.aborted) { - return { code: "cancelled", message: "navigation recovery aborted" }; - } - if (outcome.reached === "match") { - // A completed reload is not a recovery if the restricted frame remains. - await deps.cdp.send(tabId, "Page.enable", {}); - if (abort.signal.aborted) - return { code: "cancelled", message: "navigation recovery aborted" }; - if (waitUntil === "networkidle") { - // webNavigation cannot prove network idle. Subscribe on the new - // document before enabling CDP lifecycle events after its commit. - const frame = await readMainFrameInfo(deps.cdp, tabId); - if (!frame.frameId) return cdpError("no main frame after navigation"); - const remaining = deadline - Date.now(); - if (remaining <= 0) { - outcome = { reached: "timeout", lastLifecycle: "commit" }; - } else { - const wait = startLifecycleWait( - deps.cdp, - tabId, - frame.frameId, - "networkIdle", - remaining, - abort.signal, - { loaderId: frame.loaderId ?? undefined }, - ); - await deps.cdp.send(tabId, "Page.setLifecycleEventsEnabled", { enabled: true }); - outcome = await wait.promise; - } - } - } - if (outcome.reached === "cancelled" || abort.signal.aborted) { + if (outcome.reached === "cancelled" || abort.signal.aborted) return { code: "cancelled", message: "navigation recovery aborted" }; - } + const tab = await checkControl(abort.signal); return { tab_id: tabId, - final_url: await readTabUrl(deps.tabsApi, tabId), + final_url: outcome.url ?? tab.url, reached: outcome.reached === "match" ? waitUntil : "timeout", ...(outcome.reached === "timeout" ? { @@ -554,7 +766,7 @@ async function recoverBrowserNavigation( } catch (error) { return abort.signal.aborted ? { code: "cancelled", message: "navigation recovery aborted" } - : cdpError(error); + : navigationError(error); } finally { abort.abort(); abort.cleanup(); @@ -586,17 +798,16 @@ export async function handleNavigate( const timeoutMs = params.timeout_ms ?? deps.defaultTimeoutMs ?? DEFAULT_NAV_TIMEOUT_MS; try { - deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); - try { - await ensureCdpReady(deps.cdp, target.tabId); - } catch (error) { - if (!isCdpExtensionAccessDenied(error)) throw error; + if ((await prepareNavigation(manager, ctx, target, deps)) === "browser") { const recovered = await recoverBrowserNavigation( deps, - target.tabId, + manager, + ctx, + target, (api) => api.update(target.tabId, { url: params.url }), waitUntil, timeoutMs, + params.url, ); if (isRpcError(recovered)) return recovered; return attachDialogs(deps.cdp, target.tabId, dialogCursor, { ...recovered, url: params.url }); @@ -617,6 +828,7 @@ export async function handleNavigate( { loaderId: () => loaderId, beforeLoaderId: beforeFrame.loaderId, + followNavigations: true, }, ); const waitPromise = wait.promise; @@ -669,7 +881,7 @@ export async function handleNavigate( }`, }); } catch (err) { - return cdpError(err); + return navigationError(err); } } @@ -703,8 +915,22 @@ async function handleHistory( const timeoutMs = params.timeout_ms ?? deps.defaultTimeoutMs ?? DEFAULT_HISTORY_TIMEOUT_MS; try { - deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); - await ensureCdpReady(deps.cdp, target.tabId); + if ((await prepareNavigation(manager, ctx, target, deps)) === "browser") { + const recovered = await recoverBrowserNavigation( + deps, + manager, + ctx, + target, + (api) => (direction === "back" ? api.goBack(target.tabId) : api.goForward(target.tabId)), + waitUntil, + timeoutMs, + ); + if (isRpcError(recovered)) return recovered; + return attachDialogs(deps.cdp, target.tabId, dialogCursor, { + ...recovered, + previous_url: target.url, + }); + } const history = await deps.cdp.send<{ currentIndex: number; entries: HistoryEntry[] }>( target.tabId, "Page.getNavigationHistory", @@ -736,7 +962,7 @@ async function handleHistory( expected, timeoutMs, waitAbort.signal, - { beforeLoaderId: beforeFrame.loaderId }, + { beforeLoaderId: beforeFrame.loaderId, followNavigations: true }, ); const waitPromise = wait.promise; try { @@ -772,10 +998,7 @@ async function handleHistory( }`, }); } catch (err) { - return { - code: "cdp_failed", - message: err instanceof Error ? err.message : String(err), - }; + return navigationError(err); } } @@ -818,15 +1041,13 @@ export async function handleReload( const ignoreCache = params.hard === true; try { - deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); - try { - await ensureCdpReady(deps.cdp, target.tabId); - } catch (error) { - if (!isCdpExtensionAccessDenied(error)) throw error; + if ((await prepareNavigation(manager, ctx, target, deps)) === "browser") { const previousUrl = await readTabUrl(deps.tabsApi, target.tabId); const recovered = await recoverBrowserNavigation( deps, - target.tabId, + manager, + ctx, + target, (api) => api.reload(target.tabId, { bypassCache: ignoreCache }), waitUntil, timeoutMs, @@ -849,7 +1070,7 @@ export async function handleReload( expected, timeoutMs, waitAbort.signal, - { beforeLoaderId: beforeFrame.loaderId }, + { beforeLoaderId: beforeFrame.loaderId, followNavigations: true }, ); const waitPromise = wait.promise; try { @@ -885,7 +1106,7 @@ export async function handleReload( }`, }); } catch (err) { - return cdpError(err); + return navigationError(err); } } diff --git a/apps/extension/src/tools/shared.ts b/apps/extension/src/tools/shared.ts index e061ad87..da6b2472 100644 --- a/apps/extension/src/tools/shared.ts +++ b/apps/extension/src/tools/shared.ts @@ -42,6 +42,7 @@ export interface ResolvedTargetTab { windowId: number; active: boolean; url?: string; + pendingUrl?: string; } export type { DialogCursor }; @@ -61,6 +62,7 @@ export interface CdpRunner { getFrameGraph?(tabId: number): Promise; getAttachmentId?(tabId: number): string | undefined; ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise; + acquireBackgroundExecution?(sessionId: string, tabId: number): Promise; trackSessionTab?(sessionId: string, tabId: number): void; releaseSessionTab?(sessionId: string, tabId: number): Promise; onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): { @@ -229,7 +231,13 @@ async function resolveVisibleTargetTab( message: `tab ${tabId} not found in session scope`, }; } - return { tabId: tab.id, windowId: tab.windowId, active: tab.active === true, url: tab.url }; + return { + tabId: tab.id, + windowId: tab.windowId, + active: tab.active === true, + url: tab.url, + pendingUrl: tab.pendingUrl, + }; } const tabs = await api.query({ active: true, windowId: ctx.agentWindowId }); const first = tabs.find((t) => typeof t.id === "number"); @@ -244,6 +252,7 @@ async function resolveVisibleTargetTab( windowId: ctx.agentWindowId, active: first.active === true, url: first.url, + pendingUrl: first.pendingUrl, }; } diff --git a/apps/extension/src/tools/tabs.ts b/apps/extension/src/tools/tabs.ts index 5e90e6bb..4130ca45 100644 --- a/apps/extension/src/tools/tabs.ts +++ b/apps/extension/src/tools/tabs.ts @@ -14,7 +14,7 @@ import { } from "@/session-manager/manager"; import type { RpcError } from "@/transport/types"; import { rpcError } from "./errors"; -import { type CdpRunner, isRpcError, lookupSession } from "./shared"; +import { type CdpRunner, cdpBlockedUrlReason, isRpcError, lookupSession } from "./shared"; export type TabScope = "user" | "agent" | "all"; @@ -290,7 +290,7 @@ export interface TabManagementDeps { /** Clears Agent-scoped overlays after a borrowed tab is returned. */ agentOverlayReset?: AgentOverlayResetApi; /** Releases this session's CDP claim after a borrowed tab is returned. */ - cdp?: Pick; + cdp?: Pick; /** Runs after tab_return validation, before moving the borrowed tab. */ beforeReturn?: (sessionId: string, tabId: number) => Promise; /** @@ -436,13 +436,64 @@ export async function handleTabCreate( const paramErr = validateTabCreateParams(params); if (paramErr) return paramErr; - const tab = await createTabAndCleanup(ctx, deps, buildCreateProps(ctx, params)); + const props = buildCreateProps(ctx, params); + // Match the session home: an unspecified automation URL is a navigable blank + // document, not Chrome's restricted New Tab page. + if (deps.cdp?.acquireBackgroundExecution && params.url === undefined) props.url = "about:blank"; + const prepare = deps.cdp?.acquireBackgroundExecution && !cdpBlockedUrlReason(props.url); + const tab = await createTabAndCleanup( + ctx, + deps, + prepare ? { ...props, url: "about:blank" } : props, + ); if (isRpcError(tab)) return tab; + if (prepare) { + try { + await deps.cdp!.acquireBackgroundExecution!(ctx.sessionId, tab.id); + if ( + deps.signal?.aborted || + manager.get(ctx.sessionId) !== ctx || + !isAgentControlledTab(ctx, tab.id) + ) { + throw new Error("Tab creation cancelled during background execution setup"); + } + // Preserve create's no-load-wait contract. The destination script cannot run + // before the blank document's execution policy has been acknowledged. + if (props.url !== "about:blank") await getTabsApi(deps).update(tab.id, { url: props.url }); + if (deps.signal?.aborted) throw new Error("Tab creation cancelled during navigation"); + } catch (error) { + const cleanupErrors: string[] = []; + try { + await deps.cdp?.releaseSessionTab?.(ctx.sessionId, tab.id); + } catch (cleanupError) { + cleanupErrors.push(`release: ${describeError(cleanupError)}`); + } + try { + await getTabsApi(deps).remove(tab.id); + ctx.agentCreatedTabs.delete(tab.id); + } catch (cleanupError) { + // Keep the claim only when closing fails, so session cleanup can retry. + cleanupErrors.push(`close: ${describeError(cleanupError)}`); + } + if (cleanupErrors.length) { + return rpcError( + "protocol_error", + "cleanup_failed", + `Background tab initialization failed: ${describeError(error)}; cleanup failed: ${cleanupErrors.join("; ")}`, + { resource_type: "tab", resource_id: tab.id }, + ); + } + return { + code: deps.signal?.aborted ? "cancelled" : "cdp_failed", + message: describeError(error), + }; + } + } return { tab_id: tab.id, window_id: ctx.agentWindowId, - url: tab.url ?? tab.pendingUrl ?? "", + url: prepare ? (props.url ?? "about:blank") : (tab.url ?? tab.pendingUrl ?? ""), }; } @@ -894,11 +945,33 @@ export async function handleTabBorrow( message: `tab_borrow claim could not be committed: ${describeError(err)}`, }; } - // Activate after commit so overlay/event observers see the tab as claimed. + // Preserve borrow's default-target contract: subsequent commands without a + // tab_id operate on the borrowed page. Background execution survives later + // user tab switches; selecting a tab does not focus its window. try { + const tab = await tabsApi.get(params.tab_id); + if (!cdpBlockedUrlReason(tab.url)) { + await deps.cdp?.acquireBackgroundExecution?.(ctx.sessionId, params.tab_id); + } + if (deps.signal?.aborted || manager.get(ctx.sessionId) !== ctx) { + throw new Error("Borrow cancelled during background execution setup"); + } await tabsApi.update(params.tab_id, { active: true }); - } catch (err) { - console.debug("[bsk tab_borrow] activate after move failed", err); + if (deps.signal?.aborted || manager.get(ctx.sessionId) !== ctx) { + throw new Error("Borrow cancelled during background execution setup"); + } + } catch (error) { + const rollback = await returnBorrowedTab(ctx, params.tab_id, { + ...deps, + signal: undefined, + isAgentWindowId: (id) => manager.findByWindowId(id) !== null, + }); + if (isRpcError(rollback)) return rollback; + ctx.borrowedTabs.delete(params.tab_id); + return { + code: deps.signal?.aborted ? "cancelled" : "cdp_failed", + message: describeError(error), + }; } return { tab_id: params.tab_id, diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md index 51221b6d..7b40c46c 100644 --- a/crates/bsk-cli/skill/SKILL.md +++ b/crates/bsk-cli/skill/SKILL.md @@ -135,6 +135,16 @@ bsk tab borrow --session bsk tab return --session ``` +Borrowing selects the borrowed tab within the Agent Window, preserving the default +for subsequent commands without `--tab-id`. It does not additionally focus the +window. For a background-created tab (`tab create --no-active`), retain the returned +`tab_id` and pass `--tab-id ` to observation, navigation and input commands. +Created and borrowed web pages continue running while controlled even after they +move into the background. A default created tab starts at `about:blank`. +Ordinary viewport and full-page screenshots still require an active tab; do not +activate a background task just to work around that limitation. Prefer semantic +observation, and report the limitation when an image is required. + Never invent tab IDs or keep a user tab across unrelated work. Do not repeat pending, denied or timed-out borrows. For `borrow_outcome_unknown`, inspect tab/ session state first: the tab may already have moved. Do not bypass an outcome diff --git a/skill/SKILL.md b/skill/SKILL.md index 51221b6d..7b40c46c 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -135,6 +135,16 @@ bsk tab borrow --session bsk tab return --session ``` +Borrowing selects the borrowed tab within the Agent Window, preserving the default +for subsequent commands without `--tab-id`. It does not additionally focus the +window. For a background-created tab (`tab create --no-active`), retain the returned +`tab_id` and pass `--tab-id ` to observation, navigation and input commands. +Created and borrowed web pages continue running while controlled even after they +move into the background. A default created tab starts at `about:blank`. +Ordinary viewport and full-page screenshots still require an active tab; do not +activate a background task just to work around that limitation. Prefer semantic +observation, and report the limitation when an image is required. + Never invent tab IDs or keep a user tab across unrelated work. Do not repeat pending, denied or timed-out borrows. For `borrow_outcome_unknown`, inspect tab/ session state first: the tab may already have moved. Do not bypass an outcome