diff options
| author | Igor Soarez <igor@soarez.org> | 2026-11-08 15:19:35 +0000 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-11-08 15:19:35 +0000 |
| commit | 4d353df1a83e36b4e6b2edb0ba64190423e4a7f3 (patch) | |
| tree | 035e1f80347c5664105c265338e80ebfd2193af4 | |
| parent | 9004966da6491b368d12abeeb175256eedb78a2a (diff) | |
overlay: tell the agent about a blocking password-manager prompt; chrome_clear_overlay closes it on request
Another extension's frame (1Password's inline prompt) makes Chrome refuse the
debugger on the tab. The old path sent Escape on its own, which cannot reach
that frame and would close a prompt the user may be using. Now nothing is closed
automatically: the error says what is blocking and tells the agent to ask the
user; chrome_clear_overlay removes the frame, blurs the field, sends Escape and
reports whether the tab is usable again.
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/service_worker.js | 89 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/index.ts | 28 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | test-suite/unit/overlay.test.mjs | 91 |
4 files changed, 178 insertions, 32 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index 3dcb0f4..4aecf68 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -453,6 +453,7 @@ async function attachDebugger(tabId) { if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry3"); if (err) { recordAttachEvent({ kind: "attach-retry2-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url }); + if (FOREIGN_EXTENSION_BLOCK.test(String(err.message || err))) throw await overlayBlockedError(tabId); const meta = await describeInputTarget(tabId); throw new Error(`Chrome debugger attach failed for tab ${tabId}: ${String(err.message || err)}${targetMetaSuffix(meta)}`); } @@ -690,14 +691,62 @@ function extractForeignExtId(targets) { return null; } -async function dismissOverlayViaEscape(tabId) { - // Esc routes through key dispatcher (target-by-focus), not by mouse coordinates, so it - // works even when a foreign chrome-extension popup is intercepting pointer events. +const FOREIGN_EXTENSION_BLOCK = /Cannot access a chrome-extension:\/\/ URL of different extension/i; + +// Another extension's frame on the page (a password manager's inline prompt, an autofill menu) +// makes Chrome refuse the debugger on that tab. The user may be using that prompt this very +// moment, so we never close it on our own: the error tells the agent to ask, and the agent can +// then call overlay.clear (chrome_clear_overlay). +async function overlayBlockedError(tabId) { + const targets = (await findForeignExtensionTargets()).filter((t) => t.tabId === tabId); + const url = String(targets[0]?.url || "").replace(/[?#].*$/, ""); + const id = extractForeignExtId(targets) || "unknown"; + return new Error( + `Another extension (${id}${url ? `, ${url}` : ""}) has a prompt open on this tab, probably a password manager such as 1Password, and Chrome will not let the debugger work on the tab while it is there. ` + + `The user may be using it right now, so do not close it on your own: ask the user, or wait for them to finish. ` + + `When the user says to go ahead, or the tab is yours and idle, call chrome_clear_overlay for this tab. Navigating the tab away also works.`, + ); +} + +// Injected into the page by overlay.clear. Removes other extensions' iframes (including inside +// open shadow roots), blurs the focused field and sends Escape to the page. +function clearForeignOverlays(ownId) { + const removed = []; + const visit = (root) => { + for (const el of Array.from(root.querySelectorAll("*"))) { + if (el.tagName === "IFRAME" && /^chrome-extension:\/\//.test(el.src) && !el.src.startsWith(`chrome-extension://${ownId}/`)) { + removed.push(el.src.replace(/[?#].*$/, "")); + el.remove(); + } else if (el.shadowRoot) { + visit(el.shadowRoot); + } + } + }; + visit(document); + const active = document.activeElement; + if (active && active !== document.body && typeof active.blur === "function") active.blur(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true })); + return { removed }; +} + +async function clearOverlay(params) { + const tab = await getTabByParams(params, { createOwnedTarget: false }); + let injected = { removed: [] }; try { - await cdpRaw(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); - await cdpRaw(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); - await sleep(120); - } catch {} + const [first] = await executeScriptTimed({ target: { tabId: tab.id }, func: clearForeignOverlays, args: [chrome.runtime.id] }, "chrome_clear_overlay"); + injected = first?.result ?? injected; + } catch (error) { + throw new Error(`Could not run the clean-up on tab ${tab.id}: ${error?.message || error}`); + } + await sleep(250); + const remaining = (await findForeignExtensionTargets()).filter((t) => t.tabId === tab.id).map((t) => String(t.url || "").replace(/[?#].*$/, "")); + let debuggerAttachable = true; + try { + await attachDebugger(tab.id); + } catch { + debuggerAttachable = false; + } + return { tab: { id: tab.id, url: tab.url, title: tab.title }, removedFrames: injected.removed, remainingForeignTargets: remaining, debuggerAttachable, cleared: debuggerAttachable && remaining.length === 0 }; } async function cdp(tabId, method, params, opts) { @@ -706,28 +755,8 @@ async function cdp(tabId, method, params, opts) { } catch (error) { const msg = String(error?.message || error); const isStale = /Debugger is not attached|Detached while|Target closed|No tab with id/i.test(msg); - const isForeignExtBlock = /Cannot access a chrome-extension:\/\/ URL of different extension/i.test(msg); - if (isForeignExtBlock && /Input\./.test(method)) { - // Foreign chrome-extension popup (autofill, password manager) is hijacking input. - // Try once: dismiss via Esc, then retry. - const before = await findForeignExtensionTargets(); - recordAttachEvent({ kind: "foreign-ext-detected", tabId, method, foreignExtId: extractForeignExtId(before), targetCount: before.length }); - await dismissOverlayViaEscape(tabId); - try { - return await cdpRaw(tabId, method, params, opts); - } catch (retryErr) { - const retryMsg = String(retryErr?.message || retryErr); - if (/Cannot access a chrome-extension:\/\/ URL of different extension/i.test(retryMsg)) { - const after = await findForeignExtensionTargets(); - const id = extractForeignExtId(after) || extractForeignExtId(before) || "unknown"; - throw new Error( - `Another Chrome extension (${id}) has an input overlay on this page (e.g. a password manager / autofill popup). \n` + - `pi-chrome tried to dismiss it with Escape but it reappeared. Disable that extension on this page, close its popup, or focus the field via Tab instead of clicking.`, - ); - } - throw retryErr; - } - } + const isForeignExtBlock = FOREIGN_EXTENSION_BLOCK.test(msg); + if (isForeignExtBlock) throw await overlayBlockedError(tabId); if (!isStale) throw error; attachedTabs.delete(tabId); await attachDebugger(tabId).catch(() => undefined); @@ -2021,6 +2050,8 @@ async function dispatch(action, params) { } case "page.screenshot": return takeScreenshot(params); + case "overlay.clear": + return clearOverlay(params); case "cdp.targets": { // Diagnostics: CDP targets anchored to the resolved tab, including foreign overlay targets // (password managers, autofill, DevTools front-ends) that explain "Detached while handling diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts index 32ae79f..1b770dd 100644 --- a/extensions/chrome-profile-bridge/index.ts +++ b/extensions/chrome-profile-bridge/index.ts @@ -878,6 +878,7 @@ const CHROME_TOOL_NAMES = [ "chrome_upload_file", "chrome_cdp", "chrome_cdp_targets", + "chrome_clear_overlay", ] as const; const CHROME_TOOL_NAME_SET = new Set<string>(CHROME_TOOL_NAMES); const CHROME_TOOL_CHANGE_ENTRY = "pi-chrome-tool-change"; @@ -1265,7 +1266,7 @@ Capability model (important): - Input tools return structured details and support \`includeSnapshot=true\` on click/type/fill/key. Use the fresh snapshot to verify state instead of repeating blindly. If the action started a navigation, the snapshot waits (up to 5s) for the new page. - \`chrome_type\` never replaces: with a uid/selector it appends at the end of the field; without one it types at the current caret. Check its before/after report. Use \`chrome_fill\` to replace a field. - Chrome ignores real input to hidden pages (inactive tabs, minimized windows, windows behind a macOS full-screen window). Input tools report this instead of silently doing nothing; retry with background:false only when bringing the tab forward is acceptable. -- \`chrome_cdp\` runs any raw CDP method on a tab (emulation, cookies, PDF, accessibility tree, etc.) when no dedicated chrome_* tool fits; \`chrome_cdp_targets\` diagnoses debugger/overlay conflicts. +- \`chrome_cdp\` runs any raw CDP method on a tab (emulation, cookies, PDF, accessibility tree, etc.) when no dedicated chrome_* tool fits; \`chrome_cdp_targets\` diagnoses debugger/overlay conflicts, and \`chrome_clear_overlay\` closes a password manager prompt that blocks a tab (ask the user first). Usage rules: 1. If a chrome_* tool says Chrome control is locked, ask the user to run \`/chrome authorize\` before retrying. @@ -1277,7 +1278,8 @@ Usage rules: 7. If you hit a native file-picker or privileged browser prompt gate, tell the user; generic clicks/typing/CSP gates are handled by Chrome input. 8. Run /chrome doctor when in doubt about connectivity or capabilities. 9. A page can open dialogs you cannot see: alert, confirm, prompt, and the \"Leave site? Changes you made may not be saved\" prompt of a page with unsaved changes. They are dismissed for you (stay, cancel). \`chrome_navigate\` reports one by failing, and \`leaveIfAsked: true\` leaves anyway; use that only when you know nothing unsaved matters, and ask the user when unsure. If a click or key press that should have navigated did nothing, a Leave-site prompt may have stopped it: check the page, then ask the user before discarding anything. -10. All agent sessions share one browser window; your tabs sit in your own colored tab group inside it. Reuse a tab you already have (navigate it) rather than opening new ones, and open a new tab only when you need two pages at once. Do not open new windows unless the user asks.`; +10. All agent sessions share one browser window; your tabs sit in your own colored tab group inside it. Reuse a tab you already have (navigate it) rather than opening new ones, and open a new tab only when you need two pages at once. Do not open new windows unless the user asks. +11. If a tool fails saying another extension (a password manager such as 1Password) has a prompt open on the tab, the user may be using it right now, perhaps logging in or approving something for you. Do not close it on your own: tell the user and wait, or ask. Only when they say to go ahead, or the tab is yours and idle, call \`chrome_clear_overlay\`. Navigating the tab away also frees it.`; // Shared handlers, dispatched by the unified /chrome command below. const doctorHandler = async (ctx: ExtensionContext) => { @@ -2281,6 +2283,28 @@ Usage rules: }); pi.registerTool({ + name: "chrome_clear_overlay", + label: "Chrome Clear Overlay", + description: + "Close another extension's prompt that is blocking a tab, usually a password manager's (1Password) inline sign-in or autofill prompt. While such a prompt is open Chrome refuses the debugger on that tab, so screenshots, clicks, typing and evaluate fail with \"Another extension ... has a prompt open on this tab\". The prompt may belong to the user, who is often using 1Password on the page right now (logging in, approving something). So never call this on your own: ask the user first, or call it only when they say to go ahead, or when the tab is yours and nobody is working in it. It removes the other extension's frame, blurs the focused field and sends Escape, then reports whether the tab is usable again. It does not touch the page's own content, and it cannot close 1Password's browser-toolbar popup or the 1Password desktop app (if the result says the tab is still blocked, tell the user). Navigating the tab away also frees it.", + promptSnippet: "Close a password manager prompt that blocks a tab, after asking the user.", + parameters: Type.Object({ + targetId: Type.Optional(Type.String()), + urlIncludes: Type.Optional(Type.String()), + titleIncludes: Type.Optional(Type.String()), + }), + async execute(_id, params, signal): Promise<ToolTextResult> { + const value = await authorizedBridgeSend("overlay.clear", params, DEFAULT_TIMEOUT_MS, signal); + const result = value as { tab?: { id?: number; url?: string }; removedFrames?: string[]; remainingForeignTargets?: string[]; debuggerAttachable?: boolean; cleared?: boolean } | undefined; + const removed = result?.removedFrames ?? []; + const text = result?.cleared + ? `Cleared${removed.length ? `: removed ${removed.join(", ")}` : " (nothing to remove, the prompt had already gone)"}. Tab ${result.tab?.id} is usable again.` + : `The tab is still blocked${result?.remainingForeignTargets?.length ? ` by ${result.remainingForeignTargets.join(", ")}` : ""}${removed.length ? ` (removed ${removed.join(", ")} but it is still there or came back)` : ""}. The prompt may be the password manager's toolbar popup or desktop app, which this tool cannot close: tell the user, or navigate the tab away.`; + return { content: [{ type: "text", text }], details: { value: (value ?? null) as Json } }; + }, + }); + + pi.registerTool({ name: "chrome_cdp_targets", label: "Chrome CDP Targets", description: diff --git a/package.json b/package.json index c9b422b..cc7ef4c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "pi-chrome", "version": "0.15.57", "scripts": { - "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs && node test-suite/unit/console-capture.test.mjs && node test-suite/unit/prompt-section.test.mjs && node test-suite/unit/upload-guard.test.mjs && node test-suite/unit/grant-scope.test.mjs && node test-suite/unit/leave-site-prompt.test.mjs && node test-suite/unit/shared-window.test.mjs", + "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs && node test-suite/unit/console-capture.test.mjs && node test-suite/unit/prompt-section.test.mjs && node test-suite/unit/upload-guard.test.mjs && node test-suite/unit/grant-scope.test.mjs && node test-suite/unit/leave-site-prompt.test.mjs && node test-suite/unit/shared-window.test.mjs && node test-suite/unit/overlay.test.mjs", "version": "node scripts/sync-manifest-version.js", "prepublishOnly": "node scripts/sync-manifest-version.js" }, diff --git a/test-suite/unit/overlay.test.mjs b/test-suite/unit/overlay.test.mjs new file mode 100644 index 0000000..de18da8 --- /dev/null +++ b/test-suite/unit/overlay.test.mjs @@ -0,0 +1,91 @@ +// Another extension's prompt (1Password) blocks the debugger on a tab. We never close it on our +// own, because the user may be using it; the agent is told, and chrome_clear_overlay closes it +// when asked. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; +import { test } from "node:test"; + +const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8"); +const indexSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8"); + +const BLOCK = "Cannot access a chrome-extension:// URL of different extension"; +const frame = { id: "F", tabId: 2, type: "other", url: "chrome-extension://aeblf/inline/universal-sign-on/x.html?tabId=2#t", extensionId: undefined }; + +function worker({ targets = [frame], clears = true, scriptResult = { removed: ["chrome-extension://aeblf/inline/universal-sign-on/x.html"] } } = {}) { + const calls = []; + const listener = { addListener() {}, removeListener() {} }; + let cleared = false; + const w = { + console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); }, + chrome: { + runtime: { id: "ours", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener }, + alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener }, + debugger: { onDetach: listener, onEvent: listener, getTargets: (cb) => cb(cleared ? [] : targets) }, + storage: { session: { get: async () => ({}), set: async () => {} } }, + scripting: { executeScript: async (options) => { calls.push({ method: "scripting", options }); if (clears) cleared = true; return [{ result: scriptResult }]; } }, + tabs: { get: async () => ({ id: 2, url: "https://news.ycombinator.com/login", title: "login" }), query: async () => [{ id: 2, url: "https://news.ycombinator.com/login", title: "login" }] }, + }, + }; + w.self = w; + vm.runInNewContext(workerSource, w); + w.sleep = async () => {}; + w.getTabByParams = async () => ({ id: 2, url: "https://news.ycombinator.com/login", title: "login" }); + w.attachDebugger = async () => { if (!cleared) throw new Error(BLOCK); }; + return { w, calls, isCleared: () => cleared }; +} + +test("a blocked input is not dismissed with Escape: the agent is told to ask the user", async () => { + const { w, calls } = worker(); + w.cdpRaw = async (_tab, method) => { calls.push({ method }); throw new Error(`${method}: ${BLOCK}`); }; + await assert.rejects( + () => w.cdp(2, "Input.dispatchMouseEvent", {}), + (e) => /may be using it right now/.test(e.message) && /ask the user/.test(e.message) && /chrome_clear_overlay/.test(e.message) && /aeblf/.test(e.message), + ); + assert.deepEqual(calls.map((c) => c.method), ["Input.dispatchMouseEvent"]); +}); + +test("overlay.clear removes the frame and reports the tab usable", async () => { + const { w, calls } = worker(); + const result = await w.dispatch("overlay.clear", { targetId: "2" }); + assert.equal(result.cleared, true); + assert.deepEqual(result.removedFrames, ["chrome-extension://aeblf/inline/universal-sign-on/x.html"]); + assert.equal(result.debuggerAttachable, true); + assert.equal(calls[0].options.args[0], "ours"); +}); + +test("overlay.clear says so when the tab is still blocked (toolbar popup, desktop app)", async () => { + const { w } = worker({ clears: false, scriptResult: { removed: [] } }); + const result = await w.dispatch("overlay.clear", { targetId: "2" }); + assert.equal(result.cleared, false); + assert.equal(result.debuggerAttachable, false); + assert.deepEqual(result.remainingForeignTargets, ["chrome-extension://aeblf/inline/universal-sign-on/x.html"]); +}); + +test("the injected clean-up removes foreign extension iframes, not ours, and blurs the field", () => { + const { w } = worker(); + const mk = (src) => ({ tagName: "IFRAME", src, removed: false, remove() { this.removed = true; } }); + const foreign = mk("chrome-extension://aeblf/inline/a.html?x=1"); + const ours = mk("chrome-extension://ours/panel.html"); + const page = mk("https://example.com/embed"); + const shadowFrame = mk("chrome-extension://aeblf/inline/b.html"); + const host = { tagName: "DIV", shadowRoot: { querySelectorAll: () => [shadowFrame] } }; + let blurred = false, escaped = false; + const sandbox = { + document: { querySelectorAll: () => [foreign, ours, page, host], activeElement: { blur: () => { blurred = true; } }, body: {}, dispatchEvent: (e) => { escaped = e.key === "Escape"; } }, + KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } }, + Array, + }; + const result = vm.runInNewContext(`(${w.clearForeignOverlays.toString()})("ours")`, sandbox); + assert.deepEqual([...result.removed], ["chrome-extension://aeblf/inline/a.html", "chrome-extension://aeblf/inline/b.html"]); + assert.equal(foreign.removed && shadowFrame.removed, true); + assert.equal(ours.removed || page.removed, false); + assert.ok(blurred && escaped); +}); + +test("the tool tells the agent not to call it on its own, and the primer says the same", () => { + assert.match(indexSource, /name: "chrome_clear_overlay"/); + assert.match(indexSource, /So never call this on your own: ask the user first/); + assert.match(indexSource, /11\. If a tool fails saying another extension/); + assert.match(indexSource, /"chrome_clear_overlay",\n\] as const/); +}); |
