diff --git a/apps/extension/src/tools/__tests__/dispatcher.test.ts b/apps/extension/src/tools/__tests__/dispatcher.test.ts index ee566f5..dffdeaa 100644 --- a/apps/extension/src/tools/__tests__/dispatcher.test.ts +++ b/apps/extension/src/tools/__tests__/dispatcher.test.ts @@ -49,6 +49,47 @@ describe("ToolDispatcher", () => { vi.unstubAllGlobals(); }); + it.each([ + "tool.snapshot", + "tool.evaluate", + "tool.get_html", + "tool.console", + ])("annotates Chrome extension-access failures from %s", async (method) => { + const tab = { id: 7, windowId: 4242, active: true, url: "https://example.test" }; + vi.stubGlobal("chrome", { + tabs: { + get: vi.fn(async () => tab), + query: vi.fn(async () => [tab]), + }, + }); + const { transport, sent, deliver } = fakeTransport(); + const sessions = new SessionManager({ + agentWindow: { + create: vi.fn(async () => 4242), + remove: vi.fn(), + ensureActiveTab: vi.fn(), + }, + }); + await sessions.start("test"); + const error = new Error("Cannot access a chrome-extension:// URL of different extension"); + const cdp = { + send: vi.fn().mockRejectedValue(error), + ensureConsoleCapture: vi.fn().mockRejectedValue(error), + consoleEntriesSince: vi.fn(), + } as unknown as TestDispatcherCdp; + const dispatcher = new ToolDispatcher({ transport, sessions, cdp }); + dispatcher.start(); + deliver(makeRequest(method, { session_id: "test", expression: "document.title" })); + await vi.waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]).toMatchObject({ + error: { + code: "cdp_failed", + data: { reason: "cdp_extension_access_denied" }, + }, + }); + dispatcher.stop(); + }); + it("uses the configured recording runtime for start and stop", async () => { const { transport, sent, deliver } = fakeTransport(); const sessions = new SessionManager({ diff --git a/apps/extension/src/tools/__tests__/errors.test.ts b/apps/extension/src/tools/__tests__/errors.test.ts new file mode 100644 index 0000000..14be97a --- /dev/null +++ b/apps/extension/src/tools/__tests__/errors.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { RpcError } from "@/transport/types"; +import { cdpError, classifyCdpError } from "../errors"; + +const denied = "Cannot access a chrome-extension:// URL of different extension"; + +describe("CDP extension access errors", () => { + it("classifies both Chrome Error instances and string failures", () => { + for (const error of [new Error(denied), denied]) { + expect(cdpError(error)).toEqual({ + code: "cdp_failed", + message: denied, + data: { reason: "cdp_extension_access_denied" }, + }); + } + }); + + it("preserves transfer outcome and cleanup instructions when CDP was denied", () => { + const error: RpcError = { + code: "cdp_failed", + message: denied, + data: { + reason: "transfer_outcome_unknown", + effect_state: "unknown", + cleanup_state: "failed", + }, + }; + expect(classifyCdpError(error)).toBe(error); + }); + + it("leaves other error codes and unrelated debugger failures unchanged", () => { + for (const error of [ + { code: "permission_denied", message: denied }, + { code: "cdp_failed", message: "Another debugger is already attached" }, + ] satisfies RpcError[]) { + expect(classifyCdpError(error)).toBe(error); + } + }); +}); diff --git a/apps/extension/src/tools/__tests__/navigation-recovery.test.ts b/apps/extension/src/tools/__tests__/navigation-recovery.test.ts new file mode 100644 index 0000000..3d9602b --- /dev/null +++ b/apps/extension/src/tools/__tests__/navigation-recovery.test.ts @@ -0,0 +1,266 @@ +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 { CdpRunner } from "../shared"; + +const denied = "Cannot access a chrome-extension:// URL of different extension"; +const url = "https://example.test/recovered"; + +function event void>() { + const listeners = new Set(); + return { + listeners, + addListener: (listener: T) => listeners.add(listener), + removeListener: (listener: T) => listeners.delete(listener), + fire: (...args: Parameters) => { + for (const listener of [...listeners]) listener(...args); + }, + }; +} + +async function fixture() { + const manager = new SessionManager({ + agentWindow: { + create: vi.fn(async () => 100), + remove: vi.fn(async () => {}), + ensureActiveTab: vi.fn(async () => {}), + }, + }); + await manager.start("test"); + type Listener = Parameters[0]; + const events = { + onCommitted: event(), + onDOMContentLoaded: event(), + onCompleted: event(), + onErrorOccurred: event(), + }; + const cdpEvents = event>[0]>(); + const state = { blocked: true, destinationBlocked: false, emitIdle: true }; + const send = vi.fn(async (_tabId: number, method: string) => { + if (state.blocked) throw new Error(denied); + if (method === "Page.getFrameTree") { + return { frameTree: { frame: { id: "main", loaderId: "new-loader" } } }; + } + if (method === "Runtime.evaluate") return { result: { value: "complete" } }; + if (method === "Page.setLifecycleEventsEnabled" && state.emitIdle) { + cdpEvents.fire({ tabId: 4 }, "Page.lifecycleEvent", { + frameId: "main", + loaderId: "new-loader", + name: "networkIdle", + }); + } + if (method === "Page.navigate") return { frameId: "main", loaderId: "new-loader" }; + return {}; + }); + const cdp: CdpRunner = { + send: send as CdpRunner["send"], + onEvent: (listener) => { + cdpEvents.addListener(listener); + return { dispose: () => cdpEvents.removeListener(listener) }; + }, + }; + const main = { tabId: 4, frameId: 0, documentId: "new-document" }; + const navigate = async () => { + state.blocked = state.destinationBlocked; + events.onCommitted.fire(main); + events.onDOMContentLoaded.fire(main); + events.onCompleted.fire(main); + }; + const browserNavigation = { ...events, update: vi.fn(navigate), reload: vi.fn(navigate) }; + 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 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 }; +} + +describe("navigation after Chrome denies extension-frame access", () => { + it.each([ + "load", + "commit", + "domcontentloaded", + "networkidle", + ] as const)("recovers the same tab with the requested %s checkpoint", async (waitUntil) => { + const f = await fixture(); + const result = await handleNavigate( + f.manager, + { + session_id: "test", + url, + wait_until: waitUntil, + }, + f.deps, + ); + + expect(result).toMatchObject({ tab_id: 4, final_url: url, reached: waitUntil }); + expect(f.deps.browserNavigation.update).toHaveBeenCalledExactlyOnceWith(4, { url }); + expect(f.send.mock.calls.some(([, method]) => method === "Page.navigate")).toBe(false); + expect(f.send).toHaveBeenCalledWith(4, "Page.enable", {}); + f.expectCleanedUp(); + }); + + it("recovers reload through tabs.reload and preserves hard reload", async () => { + const f = await fixture(); + const result = await handleReload(f.manager, { session_id: "test", hard: true }, f.deps); + expect(result).toMatchObject({ tab_id: 4, reached: "load" }); + expect(f.deps.browserNavigation.reload).toHaveBeenCalledExactlyOnceWith(4, { + bypassCache: true, + }); + expect(f.deps.browserNavigation.update).not.toHaveBeenCalled(); + f.expectCleanedUp(); + }); + + it("reports the restriction when reload leaves the conflicting frame present", async () => { + const f = await fixture(); + f.state.destinationBlocked = true; + const result = await handleReload(f.manager, { session_id: "test" }, f.deps); + expect(result).toMatchObject({ + code: "cdp_failed", + data: { reason: "cdp_extension_access_denied" }, + }); + expect(f.deps.browserNavigation.reload).toHaveBeenCalledOnce(); + f.expectCleanedUp(); + }); + + it("does not fall back for unrelated debugger failures", async () => { + const f = await fixture(); + f.send.mockRejectedValue(new Error("Another debugger is already attached")); + const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps); + expect(result).toMatchObject({ + code: "cdp_failed", + message: "Another debugger is already attached", + }); + expect(f.deps.browserNavigation.update).not.toHaveBeenCalled(); + f.expectCleanedUp(); + }); + + it("does not replay navigation when a dispatched CDP command fails", async () => { + const f = await fixture(); + f.state.blocked = false; + const send = f.send.getMockImplementation()!; + f.send.mockImplementation(async (tabId, method) => { + if (method === "Page.navigate") throw new Error(denied); + return send(tabId, method); + }); + const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps); + expect(result).toMatchObject({ + code: "cdp_failed", + data: { reason: "cdp_extension_access_denied" }, + }); + expect(f.deps.browserNavigation.update).not.toHaveBeenCalled(); + f.expectCleanedUp(); + }); + + it("keeps the Agent Window guard in front of the recovery", async () => { + const f = await fixture(); + f.deps.tabsApi.get.mockResolvedValue({ + id: 4, + windowId: 200, + active: true, + url, + } as chrome.tabs.Tab); + const result = await handleNavigate(f.manager, { session_id: "test", tab_id: 4, url }, f.deps); + expect(result).toMatchObject({ code: "permission_denied" }); + expect(f.send).not.toHaveBeenCalled(); + expect(f.deps.browserNavigation.update).not.toHaveBeenCalled(); + }); + + it("ignores old-document, subframe, and other-tab completion events", async () => { + const f = await fixture(); + f.deps.browserNavigation.update.mockImplementation(async () => {}); + let settled = false; + const work = handleNavigate(f.manager, { session_id: "test", url }, f.deps).then((result) => { + settled = true; + return result; + }); + await vi.waitFor(() => expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce()); + f.events.onCompleted.fire(f.main); + f.events.onCommitted.fire({ ...f.main, tabId: 9 }); + f.events.onCommitted.fire({ ...f.main, frameId: 1 }); + f.events.onCompleted.fire(f.main); + await Promise.resolve(); + expect(settled).toBe(false); + f.events.onCommitted.fire(f.main); + f.events.onCompleted.fire({ ...f.main, documentId: "old-document" }); + await Promise.resolve(); + expect(settled).toBe(false); + f.state.blocked = false; + f.events.onCompleted.fire(f.main); + expect(await work).toMatchObject({ reached: "load" }); + f.expectCleanedUp(); + }); + + it("does not infer network idle from a browser load event", async () => { + const f = await fixture(); + f.state.emitIdle = false; + const result = await handleNavigate( + f.manager, + { + session_id: "test", + url, + wait_until: "networkidle", + timeout_ms: 10, + }, + f.deps, + ); + expect(result).toMatchObject({ reached: "timeout" }); + f.expectCleanedUp(); + }); + + it("cleans up browser listeners on timeout", async () => { + const f = await fixture(); + f.deps.browserNavigation.update.mockImplementation(async () => {}); + const result = await handleNavigate( + f.manager, + { session_id: "test", url, timeout_ms: 10 }, + f.deps, + ); + expect(result).toMatchObject({ reached: "timeout" }); + f.expectCleanedUp(); + }); + + it("does not navigate an already-cancelled request", async () => { + const f = await fixture(); + const result = await handleNavigate( + f.manager, + { session_id: "test", url }, + { + ...f.deps, + signal: AbortSignal.abort(), + }, + ); + expect(result).toMatchObject({ code: "cancelled" }); + expect(f.deps.browserNavigation.update).not.toHaveBeenCalled(); + f.expectCleanedUp(); + }); + + it("cleans up browser listeners when cancelled after dispatch", async () => { + const f = await fixture(); + const ac = new AbortController(); + f.deps.browserNavigation.update.mockImplementation(async () => { + ac.abort(); + }); + const result = await handleNavigate( + f.manager, + { session_id: "test", url }, + { ...f.deps, signal: ac.signal }, + ); + expect(result).toMatchObject({ code: "cancelled" }); + expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce(); + f.expectCleanedUp(); + }); + + it("reports browser navigation errors and releases listeners", async () => { + const f = await fixture(); + f.deps.browserNavigation.update.mockImplementation(async () => { + f.events.onErrorOccurred.fire({ ...f.main, error: "net::ERR_NAME_NOT_RESOLVED" }); + }); + const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps); + expect(result).toMatchObject({ code: "cdp_failed", message: "net::ERR_NAME_NOT_RESOLVED" }); + f.expectCleanedUp(); + }); +}); diff --git a/apps/extension/src/tools/browser-navigation.ts b/apps/extension/src/tools/browser-navigation.ts new file mode 100644 index 0000000..cdf181f --- /dev/null +++ b/apps/extension/src/tools/browser-navigation.ts @@ -0,0 +1,110 @@ +import type { RpcError, WaitUntil } from "@/transport/types"; +import { cdpError } from "./errors"; + +interface NavigationEvent { + tabId: number; + frameId: number; + documentId?: string; + error?: string; +} + +interface NavigationEvents { + addListener(listener: (details: NavigationEvent) => void): void; + removeListener(listener: (details: NavigationEvent) => void): void; +} + +/** Normal browser navigation remains available when chrome.debugger is denied. */ +export interface BrowserNavigationApi { + update(tabId: number, props: { url: string }): Promise; + reload(tabId: number, props: { bypassCache: boolean }): Promise; + onCommitted: NavigationEvents; + onDOMContentLoaded: NavigationEvents; + onCompleted: NavigationEvents; + onErrorOccurred: NavigationEvents; +} + +export const chromeBrowserNavigationApi: BrowserNavigationApi = { + update: (tabId, props) => chrome.tabs.update(tabId, props), + reload: (tabId, props) => chrome.tabs.reload(tabId, props), + get onCommitted() { + return chrome.webNavigation.onCommitted; + }, + get onDOMContentLoaded() { + return chrome.webNavigation.onDOMContentLoaded; + }, + get onCompleted() { + return chrome.webNavigation.onCompleted; + }, + get onErrorOccurred() { + return chrome.webNavigation.onErrorOccurred; + }, +}; + +export type NavigationOutcome = + | { reached: "match" | "timeout" | "cancelled"; lastLifecycle?: string } + | { reached: "failed"; error: RpcError }; + +/** Subscribe before dispatch and ignore the departing document's load events. */ +export async function navigateWithBrowserApi( + api: BrowserNavigationApi, + tabId: number, + action: () => Promise, + waitUntil: Exclude, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) return { reached: "cancelled" }; + if (timeoutMs <= 0) return { reached: "timeout" }; + let committed = false; + let documentId: string | undefined; + let lastLifecycle: string | undefined; + let finish!: (outcome: NavigationOutcome) => void; + const outcome = new Promise((resolve) => { + finish = resolve; + }); + const isMainFrame = (details: NavigationEvent) => + details.tabId === tabId && details.frameId === 0; + const onCommitted = (details: NavigationEvent) => { + if (!isMainFrame(details)) return; + committed = true; + documentId = details.documentId; + lastLifecycle = "commit"; + if (waitUntil === "commit") finish({ reached: "match", lastLifecycle }); + }; + const onDOMContentLoaded = (details: NavigationEvent) => { + if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId)) + return; + lastLifecycle = "DOMContentLoaded"; + if (waitUntil === "domcontentloaded") finish({ reached: "match", lastLifecycle }); + }; + const onCompleted = (details: NavigationEvent) => { + if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId)) + return; + finish({ reached: "match", lastLifecycle: "load" }); + }; + const onError = (details: NavigationEvent) => { + if (!isMainFrame(details)) return; + finish({ reached: "failed", error: cdpError(details.error ?? "browser navigation failed") }); + }; + const onAbort = () => finish({ reached: "cancelled", lastLifecycle }); + const subscriptions = [ + [api.onCommitted, onCommitted], + [api.onDOMContentLoaded, onDOMContentLoaded], + [api.onCompleted, onCompleted], + [api.onErrorOccurred, onError], + ] as const; + 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(); + return await outcome; + } catch (error) { + return { reached: "failed", error: cdpError(error) }; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + 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 4c7f15f..da1cf8c 100644 --- a/apps/extension/src/tools/dispatcher.ts +++ b/apps/extension/src/tools/dispatcher.ts @@ -36,6 +36,7 @@ import { isRequestFrame } from "@/transport/types"; import { handleConsole } from "./console"; import { handleDownload } from "./download"; import { type EmulateCdpRunner, handleEmulate } from "./emulate"; +import { classifyCdpError } from "./errors"; import { handleEvaluate } from "./evaluate"; import { handleRequestHelp } from "./human-loop"; import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "./interaction"; @@ -234,7 +235,7 @@ export class ToolDispatcher { if (sessionId) this.onBrowserControlResumed?.(sessionId); const result = await this.invoke(req, ac.signal); if (isRpcError(result)) { - body = { id: req.id, error: result }; + body = { id: req.id, error: classifyCdpError(result) }; } else { body = { id: req.id, result }; if (req.method === "tool.session_start") { diff --git a/apps/extension/src/tools/errors.ts b/apps/extension/src/tools/errors.ts index 800a765..8954d0f 100644 --- a/apps/extension/src/tools/errors.ts +++ b/apps/extension/src/tools/errors.ts @@ -13,6 +13,34 @@ import type { export type { RpcErrorData, RpcErrorReason }; +/** Chrome checks the whole tab's frame tree, including other extensions' frames. */ +export function isCdpExtensionAccessDenied(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("Cannot access a chrome-extension:// URL of different extension"); +} + +/** Preserve more specific reasons, especially file-transfer outcome/cleanup details. */ +export function classifyCdpError(error: RpcError): RpcError { + if ( + error.code !== "cdp_failed" || + error.data?.reason || + !isCdpExtensionAccessDenied(error.message) + ) { + return error; + } + return { + ...error, + data: { ...error.data, reason: "cdp_extension_access_denied" }, + }; +} + +export function cdpError(error: unknown): RpcError { + return classifyCdpError({ + code: "cdp_failed", + message: error instanceof Error ? error.message : String(error), + }); +} + export interface TransferErrorOptions { effectState: TransferEffectState; phase: string; diff --git a/apps/extension/src/tools/navigation.ts b/apps/extension/src/tools/navigation.ts index 5032e48..e40d724 100644 --- a/apps/extension/src/tools/navigation.ts +++ b/apps/extension/src/tools/navigation.ts @@ -28,7 +28,13 @@ import type { RpcError, WaitUntil, } from "@/transport/types"; +import { + type BrowserNavigationApi, + chromeBrowserNavigationApi, + navigateWithBrowserApi, +} from "./browser-navigation"; import { attachDialogs, markDialogCursor } from "./dialogs"; +import { cdpError, isCdpExtensionAccessDenied } from "./errors"; import { type CdpRunner, type ChromeTabsApi, @@ -42,6 +48,7 @@ import { export interface NavigationDeps { cdp: CdpRunner; tabsApi: ChromeTabsApi; + browserNavigation?: BrowserNavigationApi; /** Optional AbortSignal — M7 abort hook (M10.2 will wire the full chain). */ signal?: AbortSignal; /** Override default timeout when the caller omits `timeout_ms`. */ @@ -479,6 +486,81 @@ async function readTabUrl(api: ChromeTabsApi, tabId: number): Promise Promise, + waitUntil: WaitUntil, + timeoutMs: number, +): Promise { + const abort = linkedAbortSignal(deps.signal); + const deadline = Date.now() + timeoutMs; + const api = deps.browserNavigation ?? chromeBrowserNavigationApi; + try { + let outcome = await navigateWithBrowserApi( + api, + tabId, + () => action(api), + waitUntil === "networkidle" ? "commit" : waitUntil, + timeoutMs, + abort.signal, + ); + 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) { + return { code: "cancelled", message: "navigation recovery aborted" }; + } + return { + tab_id: tabId, + final_url: await readTabUrl(deps.tabsApi, tabId), + reached: outcome.reached === "match" ? waitUntil : "timeout", + ...(outcome.reached === "timeout" + ? { + error_text: `timed out waiting for ${waitUntil} during navigation recovery after ${timeoutMs}ms`, + } + : {}), + }; + } catch (error) { + return abort.signal.aborted + ? { code: "cancelled", message: "navigation recovery aborted" } + : cdpError(error); + } finally { + abort.abort(); + abort.cleanup(); + } +} + // --------------------------------------------------------------------------- // tool.navigate // --------------------------------------------------------------------------- @@ -505,7 +587,20 @@ export async function handleNavigate( try { deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); - await ensureCdpReady(deps.cdp, target.tabId); + try { + await ensureCdpReady(deps.cdp, target.tabId); + } catch (error) { + if (!isCdpExtensionAccessDenied(error)) throw error; + const recovered = await recoverBrowserNavigation( + deps, + target.tabId, + (api) => api.update(target.tabId, { url: params.url }), + waitUntil, + timeoutMs, + ); + if (isRpcError(recovered)) return recovered; + return attachDialogs(deps.cdp, target.tabId, dialogCursor, { ...recovered, url: params.url }); + } const expected = cdpLifecycleName(waitUntil); const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId); const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId); @@ -574,10 +669,7 @@ export async function handleNavigate( }`, }); } catch (err) { - return { - code: "cdp_failed", - message: err instanceof Error ? err.message : String(err), - }; + return cdpError(err); } } @@ -727,7 +819,24 @@ export async function handleReload( try { deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); - await ensureCdpReady(deps.cdp, target.tabId); + try { + await ensureCdpReady(deps.cdp, target.tabId); + } catch (error) { + if (!isCdpExtensionAccessDenied(error)) throw error; + const previousUrl = await readTabUrl(deps.tabsApi, target.tabId); + const recovered = await recoverBrowserNavigation( + deps, + target.tabId, + (api) => api.reload(target.tabId, { bypassCache: ignoreCache }), + waitUntil, + timeoutMs, + ); + if (isRpcError(recovered)) return recovered; + return attachDialogs(deps.cdp, target.tabId, dialogCursor, { + ...recovered, + previous_url: previousUrl, + }); + } const previousUrl = await readTabUrl(deps.tabsApi, target.tabId); const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId); const expected = cdpLifecycleName(waitUntil); @@ -776,10 +885,7 @@ export async function handleReload( }`, }); } catch (err) { - return { - code: "cdp_failed", - message: err instanceof Error ? err.message : String(err), - }; + return cdpError(err); } } diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index fc9968a..bc31412 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -31,6 +31,7 @@ export type RpcErrorReason = | "single_select_value_count" | "tab_not_active" | "restricted_tab_url" + | "cdp_extension_access_denied" | "borrow_conflict" | "screenshot_capture_failed" | "file_input_probe_failed" diff --git a/crates/bsk-cli/src/cli/render_error.rs b/crates/bsk-cli/src/cli/render_error.rs index 01f80dd..0362f51 100644 --- a/crates/bsk-cli/src/cli/render_error.rs +++ b/crates/bsk-cli/src/cli/render_error.rs @@ -47,6 +47,7 @@ pub mod reason { pub const OPTION_NOT_FOUND: &str = "option_not_found"; pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count"; pub const TAB_NOT_ACTIVE: &str = "tab_not_active"; + pub const CDP_EXTENSION_ACCESS_DENIED: &str = "cdp_extension_access_denied"; pub const BORROW_CONFLICT: &str = "borrow_conflict"; pub const SCREENSHOT_CAPTURE_FAILED: &str = "screenshot_capture_failed"; pub const FILE_INPUT_PROBE_FAILED: &str = "file_input_probe_failed"; @@ -206,6 +207,13 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend return base; }; match (code, reason) { + (ErrorCode::CdpFailed, reason::CDP_EXTENSION_ACCESS_DENIED) => RenderInfo { + summary: "Chrome blocked CDP access to another extension's content in this tab", + hint: Some( + "a web page can contain a restricted extension frame; disable the conflicting extension and reload, or use `bsk navigate ` to leave this page; reconnecting BrowserSkill alone does not remove the restriction", + ), + exit_code: base.exit_code, + }, (_, reason::TRANSFER_OUTCOME_UNKNOWN) => RenderInfo { summary: "the file transfer outcome could not be confirmed", hint: Some( @@ -454,6 +462,18 @@ mod tests { assert!(!info.summary.contains("sandbox")); } + #[test] + fn extension_access_denied_explains_the_frame_restriction_and_navigation_recovery() { + let data = serde_json::json!({ "reason": reason::CDP_EXTENSION_ACCESS_DENIED }); + let info = info_for_error(ErrorCode::CdpFailed, Some(&data)); + assert!(info.summary.contains("another extension")); + let hint = info.hint.unwrap(); + assert!(hint.contains("restricted extension frame")); + assert!(hint.contains("bsk navigate ")); + assert!(hint.contains("disable the conflicting extension")); + assert_eq!(info.exit_code, 3); + } + #[test] fn session_busy_overrides_timeout_copy() { let data = serde_json::json!({ "reason": reason::SESSION_BUSY });