aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/service_worker.js61
-rw-r--r--extensions/chrome-profile-bridge/index.ts8
-rw-r--r--package.json2
-rw-r--r--test-suite/unit/background-policy.test.mjs2
-rw-r--r--test-suite/unit/leave-site-prompt.test.mjs101
5 files changed, 165 insertions, 9 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
index 1f178e4..c62aff9 100644
--- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js
+++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
@@ -439,6 +439,8 @@ async function attachDebugger(tabId) {
// Seed pointer in a plausible "just left the address bar" location.
const entry = { detachAt: Date.now() + INPUT_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 }, debuggee: attachedDebuggee || { tabId } };
attachedTabs.set(tabId, entry);
+ // Page events are what tell us a page opened a dialog; without them nobody answers it.
+ await cdpRaw(tabId, "Page.enable", {}).catch(() => undefined);
return entry;
}
@@ -1402,7 +1404,35 @@ function fileChooserTabIdForSource(source) {
return undefined;
}
+// Dialogs a page opens (alert, confirm, prompt, and the "Leave site?" prompt of a beforeunload
+// handler) block the tab until someone answers, and the agent cannot see them. We answer them:
+// "stay"/cancel, unless page.navigate was told leaveIfAsked and it is a beforeunload prompt.
+// page.navigate waits on dialogWaiters to report a dismissed one instead of timing out.
+const dialogPolicy = new Map(); // tabId -> { leave: boolean }, only while page.navigate runs
+const dialogWaiters = new Map(); // tabId -> resolve(dialog), only while page.navigate runs
+
+function describeDialog(dialog) {
+ if (dialog.type === "beforeunload") return 'a "Leave site? Changes you made may not be saved" prompt (the page has a beforeunload handler)';
+ return `a JavaScript ${dialog.type} dialog saying "${dialog.message}"`;
+}
+
+async function handleDialogOpening(tabId, params) {
+ const leave = params?.type === "beforeunload" && dialogPolicy.get(tabId)?.leave === true;
+ const dialog = { type: params?.type, message: params?.message ?? "", url: params?.url, accepted: leave };
+ try {
+ await cdp(tabId, "Page.handleJavaScriptDialog", { accept: leave });
+ } catch (error) {
+ console.warn(`[pi-chrome] could not answer a ${dialog.type} dialog on tab ${tabId}: ${error?.message || error}`);
+ }
+ if (!leave) dialogWaiters.get(tabId)?.(dialog);
+}
+
function handleDebuggerEvent(source, method, params) {
+ if (method === "Page.javascriptDialogOpening") {
+ const dialogTabId = fileChooserTabIdForSource(source);
+ if (dialogTabId !== undefined) void handleDialogOpening(dialogTabId, params);
+ return;
+ }
if (method !== "Page.fileChooserOpened") return;
const tabId = fileChooserTabIdForSource(source);
const entry = tabId === undefined ? undefined : pendingFileChoosers.get(tabId);
@@ -1922,14 +1952,37 @@ async function dispatch(action, params) {
// Register a one-shot document_start content script. We register, navigate, wait, then unregister.
await registerInitScript(tab.id, params.initScript);
}
- const wait = params.waitUntilLoad !== false ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined);
- const updated = await chrome.tabs.update(tab.id, { url: params.url });
+ // Attach first: the debugger is how we hear about, and answer, a "Leave site?" prompt.
+ await attachDebugger(tab.id);
+ await cdp(tab.id, "Page.enable", {}).catch(() => undefined);
+ dialogPolicy.set(tab.id, { leave: params.leaveIfAsked === true });
+ const dialogDismissed = new Promise((resolve) => dialogWaiters.set(tab.id, resolve));
+ const waitsForLoad = params.waitUntilLoad !== false;
+ const wait = waitsForLoad ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined);
+ wait.catch(() => undefined); // if the dialog wins the race, a later load timeout is not an error
try {
- await wait;
+ const updated = await chrome.tabs.update(tab.id, { url: params.url });
+ // Without waiting for the load, still give a prompt a moment to show up.
+ const outcome = await Promise.race([
+ wait.then(() => ({ loaded: true })),
+ dialogDismissed.then((dialog) => ({ dialog })),
+ ...(waitsForLoad ? [] : [new Promise((resolve) => setTimeout(() => resolve({ loaded: true }), 700))]),
+ ]);
+ if (outcome.dialog) {
+ throw new Error(
+ `Navigation to ${params.url} did not happen: the page at ${outcome.dialog.url || tab.url} opened ${describeDialog(outcome.dialog)}, ` +
+ `and it was dismissed, so the tab stayed where it was. ` +
+ (outcome.dialog.type === "beforeunload"
+ ? "Nothing was lost, but the page may hold unsaved changes. If you know nothing unsaved matters here (you just saved or submitted, or this is a throwaway tab), call chrome_navigate again with leaveIfAsked: true. Otherwise ask the user."
+ : "Look at the page to see what it was asking."),
+ );
+ }
+ return await formatTab(await chrome.tabs.get(updated.id));
} finally {
+ dialogPolicy.delete(tab.id);
+ dialogWaiters.delete(tab.id);
if (params.initScript) await unregisterInitScript(tab.id).catch(() => undefined);
}
- return await formatTab(await chrome.tabs.get(updated.id));
}
case "page.screenshot":
return takeScreenshot(params);
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index 158584d..90f080e 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -1275,7 +1275,8 @@ Usage rules:
5. \`chrome_navigate\` supports an optional \`initScript\` that runs at document_start in MAIN world for the next navigation (good for seeding localStorage or stubbing Date.now).
6. /chrome background on (default) is a hard policy: per-call \`background=false\` cannot override it, new tabs stay inactive, and \`chrome_tab activate\` is blocked. Ask the user to run /chrome background off when they want foreground/watch mode. With background off, per-call \`background=true\` still avoids explicit focus/tab activation. Screenshots use CDP without activating background tabs; debugger failures never fall back to tab activation. Page scripts, trusted input, native prompts, and Chrome/OS behavior can still affect focus.
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.`;
+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.`;
// Shared handlers, dispatched by the unified /chrome command below.
const doctorHandler = async (ctx: ExtensionContext) => {
@@ -1791,8 +1792,8 @@ Usage rules:
name: "chrome_navigate",
label: "Chrome Navigate",
description:
- "Navigate a Chrome tab to a URL via the companion extension. With no target, navigation goes to pi-chrome's own dedicated automation window/tab — it never replaces the user's active tab. Pass targetId/urlIncludes/titleIncludes only to act on a specific existing tab. Background mode (default) blocks explicit focus/tab activation even with background=false; /chrome background off allows foreground/watch mode. Optionally waits for load completion.",
- promptSnippet: "Navigate a Chrome tab in the user's existing profile.",
+ "Navigate a Chrome tab to a URL via the companion extension. With no target, navigation goes to pi-chrome's own dedicated automation window/tab — it never replaces the user's active tab. Pass targetId/urlIncludes/titleIncludes only to act on a specific existing tab. Background mode (default) blocks explicit focus/tab activation even with background=false; /chrome background off allows foreground/watch mode. Optionally waits for load completion. Some pages ask \"Leave site? Changes you made may not be saved\" before navigating away; you cannot see that prompt, so it is answered for you: the default is to stay, and the call then fails at once saying so, with the tab unchanged and nothing lost. Retry with leaveIfAsked: true only if you know nothing unsaved matters (you just saved or submitted, or the tab is a throwaway you opened); if you are not sure, ask the user, because leaving discards unsaved work for good. leaveIfAsked applies to this one call only. Any other JavaScript dialog (alert, confirm, prompt) that appears during the navigation is dismissed and reported the same way.",
+ promptSnippet: "Navigate a Chrome tab in the user's existing profile; a page's \"Leave site?\" prompt makes the call fail unless leaveIfAsked is true.",
parameters: Type.Object({
url: Type.String(),
targetId: Type.Optional(Type.String()),
@@ -1800,6 +1801,7 @@ Usage rules:
titleIncludes: Type.Optional(Type.String()),
waitUntilLoad: Type.Optional(Type.Boolean({ default: true })),
timeoutMs: Type.Optional(Type.Number({ default: 15_000 })),
+ leaveIfAsked: Type.Optional(Type.Boolean({ description: "Leave the page even if it asks \"Leave site? Changes you made may not be saved\". Default false: the navigation fails and the tab stays. Set it only when you know nothing unsaved matters; otherwise ask the user. This call only." })),
initScript: Type.Optional(Type.String({ description: "Optional JavaScript source to run in MAIN world at document_start of the next navigation. Useful for seeding localStorage, stubbing Date.now(), or defining navigator.webdriver=undefined. Requires the companion extension's webNavigation permission." })),
background: Type.Optional(Type.Boolean({ description: BACKGROUND_PARAM_DESCRIPTION })),
host: Type.Optional(Type.String()),
diff --git a/package.json b/package.json
index 527b255..de5cabb 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",
+ "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",
"version": "node scripts/sync-manifest-version.js",
"prepublishOnly": "node scripts/sync-manifest-version.js"
},
diff --git a/test-suite/unit/background-policy.test.mjs b/test-suite/unit/background-policy.test.mjs
index 2388d78..efbca78 100644
--- a/test-suite/unit/background-policy.test.mjs
+++ b/test-suite/unit/background-policy.test.mjs
@@ -477,7 +477,7 @@ test("full-page tiles remain CDP-only, pin the resolved target, and restore both
scrolls.push(clone(args));
};
let captures = 0;
- h.cdpResult = () => { if (++captures === 2 && fail) throw new Error("tile failed"); return { data: "dGlsZQ==" }; };
+ h.cdpResult = (method) => { if (method === "Page.captureScreenshot" && ++captures === 2 && fail) throw new Error("tile failed"); return { data: "dGlsZQ==" }; };
const shot = h.w.dispatch("page.screenshot", { urlIncludes: "target.test", fullPage: true, background: true, foreground: true });
if (fail) await assert.rejects(shot, /tile failed/);
else {
diff --git a/test-suite/unit/leave-site-prompt.test.mjs b/test-suite/unit/leave-site-prompt.test.mjs
new file mode 100644
index 0000000..417d0b0
--- /dev/null
+++ b/test-suite/unit/leave-site-prompt.test.mjs
@@ -0,0 +1,101 @@
+// A page's "Leave site?" prompt (beforeunload) and other JavaScript dialogs block the tab and the
+// agent cannot see them. page.navigate answers them: stay by default, leave with leaveIfAsked.
+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");
+
+// dialog: what the page opens after the navigation starts, or undefined for a clean load.
+function worker(dialog) {
+ const calls = [];
+ const listener = { addListener() {}, removeListener() {} };
+ let onUpdated;
+ const tab = { id: 2, windowId: 1, active: false, url: "https://app.test/form", status: "complete" };
+ const w = {
+ console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); },
+ chrome: {
+ runtime: { id: "test", 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([]) },
+ storage: { session: { get: async () => ({}), set: async () => {} } },
+ tabs: {
+ get: async () => ({ ...tab }),
+ query: async () => [{ ...tab }],
+ onUpdated: { addListener: (fn) => { onUpdated = fn; }, removeListener() {} },
+ update: async (_id, props) => {
+ calls.push({ method: "tabs.update", props });
+ setTimeout(() => {
+ if (dialog) w.handleDebuggerEvent({ tabId: 2 }, "Page.javascriptDialogOpening", dialog);
+ else onUpdated?.(2, { status: "complete" });
+ }, 5);
+ return { ...tab };
+ },
+ },
+ },
+ };
+ w.self = w;
+ vm.runInNewContext(workerSource, w);
+ w.getTabByParams = async () => ({ ...tab });
+ w.bringToFront = async () => {};
+ w.attachDebugger = async () => {};
+ w.formatTab = async (t) => ({ id: t.id, url: t.url });
+ w.cdp = async (_tabId, method, params = {}) => {
+ calls.push({ method, params });
+ // Accepting the leave prompt lets the navigation finish.
+ if (method === "Page.handleJavaScriptDialog" && params.accept) setTimeout(() => onUpdated?.(2, { status: "complete" }), 5);
+ };
+ return { w, calls };
+}
+
+const leavePrompt = { type: "beforeunload", message: "", url: "https://app.test/form" };
+const answers = (calls) => calls.filter((c) => c.method === "Page.handleJavaScriptDialog").map((c) => c.params.accept);
+
+test("by default a Leave-site prompt is dismissed and the call says so at once", async () => {
+ const { w, calls } = worker(leavePrompt);
+ await assert.rejects(
+ () => w.dispatch("page.navigate", { url: "https://other.test/", timeoutMs: 5000 }),
+ (error) => /did not happen/.test(error.message) && /Leave site/.test(error.message) && /leaveIfAsked: true/.test(error.message) && /ask the user/.test(error.message),
+ );
+ assert.deepEqual(answers(calls), [false]);
+});
+
+test("leaveIfAsked accepts the prompt and the navigation completes", async () => {
+ const { w, calls } = worker(leavePrompt);
+ const result = await w.dispatch("page.navigate", { url: "https://other.test/", leaveIfAsked: true, timeoutMs: 5000 });
+ assert.equal(result.id, 2);
+ assert.deepEqual(answers(calls), [true]);
+});
+
+test("leaveIfAsked does not accept an alert or confirm", async () => {
+ const { w, calls } = worker({ type: "confirm", message: "Delete everything?", url: "https://app.test/form" });
+ await assert.rejects(
+ () => w.dispatch("page.navigate", { url: "https://other.test/", leaveIfAsked: true, timeoutMs: 5000 }),
+ (error) => /confirm dialog saying "Delete everything\?"/.test(error.message),
+ );
+ assert.deepEqual(answers(calls), [false]);
+});
+
+test("a page without a prompt navigates as before and no dialog is answered", async () => {
+ const { w, calls } = worker(undefined);
+ const result = await w.dispatch("page.navigate", { url: "https://other.test/", timeoutMs: 5000 });
+ assert.equal(result.id, 2);
+ assert.deepEqual(answers(calls), []);
+});
+
+test("leaveIfAsked from one call does not carry over to the next", async () => {
+ const { w, calls } = worker(leavePrompt);
+ await w.dispatch("page.navigate", { url: "https://other.test/", leaveIfAsked: true, timeoutMs: 5000 });
+ await assert.rejects(() => w.dispatch("page.navigate", { url: "https://other.test/", timeoutMs: 5000 }), /did not happen/);
+ assert.deepEqual(answers(calls), [true, false]);
+});
+
+test("the tool and the primer tell the agent what the prompt is and when to leave", () => {
+ const src = indexSource;
+ assert.match(src, /leaveIfAsked: Type\.Optional/);
+ assert.match(src, /Leave site\? Changes you made may not be saved/);
+ assert.match(src, /ask the user, because leaving discards unsaved work for good/);
+ assert.match(src, /9\. A page can open dialogs you cannot see/);
+});