aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/service_worker.js134
-rw-r--r--extensions/chrome-profile-bridge/index.ts31
-rw-r--r--package.json2
-rw-r--r--test-suite/unit/background-policy.test.mjs12
-rw-r--r--test-suite/unit/grant-scope.test.mjs30
-rw-r--r--test-suite/unit/sandboxed-snapshot.test.mjs108
-rw-r--r--test-suite/unit/shared-window.test.mjs45
7 files changed, 245 insertions, 117 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
index 0ceabda..9ac247c 100644
--- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js
+++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
@@ -226,26 +226,12 @@ function isPiChromeOwnedTarget(tabId, sessionKey) {
return false;
}
-// The window agents share. Every session has its own tab group and a group cannot span windows,
-// but sessions do not need their own windows: new tabs go to the window that already holds any
-// "Pi Session:" group, so agents stay together instead of opening one window each. Returns
-// undefined when no such window exists (the first session then creates one).
-async function sharedPiWindowId() {
- if (!chrome.tabGroups || typeof chrome.windows?.get !== "function") return undefined;
- const groups = await chrome.tabGroups.query({}).catch(() => []);
- for (const group of groups) {
- if (!/^pi session:/i.test((group.title || "").trim()) || typeof group.windowId !== "number") continue;
- const win = await chrome.windows.get(group.windowId).catch(() => null);
- if (win && (win.type === undefined || win.type === "normal")) return win.id ?? group.windowId;
- }
- return undefined;
-}
-
// Create a fresh automation target for `sessionKey`. If this session already has a tab group,
// create the tab inside that group's window so one Pi session keeps one Chrome tab group (Chrome
-// groups cannot span windows). If it has none, use the shared Pi window; only when there is no
-// Pi window yet, create one; fall back to a tab. When the tab is created in a pre-existing window,
-// leave windowId unset so cleanup only closes our tab, never that whole window.
+// groups cannot span windows). If it has none, give the session its own window: a window shows
+// only one tab, so sessions sharing a window would hide each other's tabs, and a hidden tab gets no
+// real input and blank screenshots (measured 2026-11-08). Fall back to a tab. When the tab is
+// created in a pre-existing window, leave windowId unset so cleanup only closes our tab.
async function createAutomationTarget(sessionKey, groupTitle) {
const existingGroup = groupTitle ? await findGroupRecordByTitle(groupTitle) : null;
if (existingGroup && typeof existingGroup.windowId === "number") {
@@ -254,14 +240,6 @@ async function createAutomationTarget(sessionKey, groupTitle) {
await persistAutomationTargets();
return tab;
}
- const sharedWindowId = await sharedPiWindowId();
- if (sharedWindowId !== undefined) {
- // Someone else's window: leave windowId unset so cleanup only ever closes our tab.
- const tab = await chrome.tabs.create({ url: "about:blank", active: false, windowId: sharedWindowId });
- automationTargets.set(sessionKey, { windowId: undefined, tabId: typeof tab.id === "number" ? tab.id : undefined });
- await persistAutomationTargets();
- return tab;
- }
if (chrome.windows && typeof chrome.windows.create === "function") {
try {
const win = await chrome.windows.create({ url: "about:blank", focused: false });
@@ -636,27 +614,36 @@ async function executeScriptWithFallback(options, label) {
if (!isScriptingAccessDenied(error) || !chrome.debugger) throw error;
const tabId = options?.target?.tabId;
recordAttachEvent({ kind: "scripting-denied-cdp-fallback", tabId, files: options?.files || null, message: String(error?.message || error).slice(0, 300) });
- if (Array.isArray(options?.files) && options.files.length) {
- for (const file of options.files) {
- if (!packagedFileSources.has(file)) {
- const response = await fetch(chrome.runtime.getURL(file));
- if (!response.ok) throw new Error(`Could not read ${file} for CDP injection (HTTP ${response.status})`);
- packagedFileSources.set(file, await response.text());
- }
- const injected = await cdpEval(tabId, packagedFileSources.get(file));
- if (injected.exceptionDetails) {
- throw new Error(`${label || file}: ${cdpExceptionText(injected.exceptionDetails) || "CDP injection failed"}`);
- }
+ return executeScriptViaCdp(options, label);
+ }
+}
+
+// The CDP half of executeScriptWithFallback, also used directly when scripting silently runs
+// nothing: a document served with CSP `sandbox` (no allow-scripts), such as
+// raw.githubusercontent.com, makes chrome.scripting.executeScript resolve with a null result
+// instead of throwing, while Runtime.evaluate still works there.
+async function executeScriptViaCdp(options, label) {
+ const tabId = options?.target?.tabId;
+ if (Array.isArray(options?.files) && options.files.length) {
+ for (const file of options.files) {
+ if (!packagedFileSources.has(file)) {
+ const response = await fetch(chrome.runtime.getURL(file));
+ if (!response.ok) throw new Error(`Could not read ${file} for CDP injection (HTTP ${response.status})`);
+ packagedFileSources.set(file, await response.text());
+ }
+ const injected = await cdpEval(tabId, packagedFileSources.get(file));
+ if (injected.exceptionDetails) {
+ throw new Error(`${label || file}: ${cdpExceptionText(injected.exceptionDetails) || "CDP injection failed"}`);
}
- return [{ result: undefined }];
- }
- const expression = `(${options.func.toString()})(...${JSON.stringify(Array.isArray(options.args) ? options.args : [])})`;
- const result = await cdpEval(tabId, expression);
- if (result.exceptionDetails) {
- throw new Error(`${label || "page script"}: ${cdpExceptionText(result.exceptionDetails) || "evaluation failed"}`);
}
- return [{ result: result.result?.value }];
+ return [{ result: undefined }];
}
+ const expression = `(${options.func.toString()})(...${JSON.stringify(Array.isArray(options.args) ? options.args : [])})`;
+ const result = await cdpEval(tabId, expression);
+ if (result.exceptionDetails) {
+ throw new Error(`${label || "page script"}: ${cdpExceptionText(result.exceptionDetails) || "evaluation failed"}`);
+ }
+ return [{ result: result.result?.value }];
}
// Wraps cdpRaw with one auto-recover on detached/closed sessions:
@@ -1911,15 +1898,20 @@ async function dispatch(action, params) {
const existingGroup = await findGroupRecordByTitle(groupTitle);
const createParams = { url: params.url || "about:blank", active: foregroundRequested(params) };
if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
- else {
- // No group of this session yet: use the shared Pi window, never the user's focused one.
- const sharedWindowId = await sharedPiWindowId();
- if (sharedWindowId !== undefined) createParams.windowId = sharedWindowId;
+ // No group of this session yet: use the window of its automation tab if it has one, else open
+ // the tab in a window of its own. Never the user's focused window, where Chrome would put it.
+ let ownWindow = false;
+ if (createParams.windowId === undefined) {
+ const home = await resolveOwnedAutomationTarget(sessionKeyOf(params)).catch(() => null);
+ if (home && typeof home.windowId === "number") createParams.windowId = home.windowId;
+ else ownWindow = typeof chrome.windows?.create === "function";
}
// Register the creation as in flight until the tab is tracked, so early capture (which only
// runs in tracked tabs) waits for it instead of skipping the new tab's first page load.
const creation = (async () => {
- const created = await chrome.tabs.create(createParams);
+ const created = ownWindow
+ ? (await chrome.windows.create({ url: createParams.url, focused: false })).tabs[0]
+ : await chrome.tabs.create(createParams);
await trackSessionTab(sessionKeyOf(params), created.id, true);
return created;
})();
@@ -2429,6 +2421,28 @@ async function withOptionalSnapshot(params, actionFn) {
// chrome.scripting.executeScript({ files }). That file is free of eval/new Function, so it works
// on strict-CSP pages, and it installs globalThis.__piChromeSnapshotPage / __piChromeInspectTarget.
// It shares window.__PI_CHROME_STATE__ (same el- uid scheme) with the CDP-injected input helpers.
+// Injects snapshot_injected.js and runs `scriptOptions.func` (which must return an
+// { ok, value | error } envelope) in the tab's top frame. chrome.scripting is primary; if it
+// resolves without an envelope, the page ran no extension script (CSP `sandbox` without
+// allow-scripts, e.g. raw.githubusercontent.com), so both steps are redone over CDP.
+async function runSnapshotInjected(tab, kind, scriptOptions) {
+ const inject = { target: { tabId: tab.id, frameIds: [0] }, world: "MAIN", files: ["snapshot_injected.js"] };
+ const run = { target: { tabId: tab.id, frameIds: [0] }, world: "MAIN", ...scriptOptions };
+ const injectLabel = `inject ${kind} script in tab ${tab.id}`;
+ const runLabel = `run ${kind} script in tab ${tab.id}`;
+ await executeScriptWithFallback(inject, injectLabel);
+ const results = await executeScriptWithFallback(run, runLabel);
+ const first = results?.[0];
+ if (first?.error || first?.result != null || !chrome.debugger) return results;
+ recordAttachEvent({ kind: "scripting-no-result-cdp-fallback", tabId: tab.id, files: inject.files });
+ await executeScriptViaCdp(inject, injectLabel);
+ const viaCdp = await executeScriptViaCdp(run, runLabel);
+ if (viaCdp?.[0]?.result == null) {
+ throw new Error(`Chrome ${kind} script returned no result in tab ${tab.id} (${tab.url || "unknown url"}); the page may block script execution.`);
+ }
+ return viaCdp;
+}
+
async function snapshotInTab(params) {
const tab = await getTabByParams(params);
await bringToFront(tab, params);
@@ -2441,14 +2455,7 @@ async function snapshotInTab(params) {
params.query ?? null,
params.maxTextChars ?? null,
];
- await executeScriptWithFallback({
- target: { tabId: tab.id, frameIds: [0] },
- world: "MAIN",
- files: ["snapshot_injected.js"],
- }, `inject snapshot script in tab ${tab.id}`);
- const results = await executeScriptWithFallback({
- target: { tabId: tab.id, frameIds: [0] },
- world: "MAIN",
+ const results = await runSnapshotInjected(tab, "snapshot", {
func: async (invocationArgs) => {
try {
const snapshotPage = globalThis.__piChromeSnapshotPage;
@@ -2459,7 +2466,7 @@ async function snapshotInTab(params) {
}
},
args: [args],
- }, `run snapshot script in tab ${tab.id}`);
+ });
const first = results?.[0];
if (first?.error) {
const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
@@ -2477,14 +2484,7 @@ async function inspectInTab(params) {
const tab = await getTabByParams(params);
await bringToFront(tab, params);
const args = [params.uid ?? null, params.selector ?? null, params.scrollIntoView === true];
- await executeScriptWithFallback({
- target: { tabId: tab.id, frameIds: [0] },
- world: "MAIN",
- files: ["snapshot_injected.js"],
- }, `inject inspect script in tab ${tab.id}`);
- const results = await executeScriptWithFallback({
- target: { tabId: tab.id, frameIds: [0] },
- world: "MAIN",
+ const results = await runSnapshotInjected(tab, "inspect", {
func: async (invocationArgs) => {
try {
const inspectTarget = globalThis.__piChromeInspectTarget;
@@ -2495,7 +2495,7 @@ async function inspectInTab(params) {
}
},
args: [args],
- }, `run inspect script in tab ${tab.id}`);
+ });
const first = results?.[0];
if (first?.error) {
const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index cf8671d..97b9f33 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -133,8 +133,11 @@ function truncateText(text: string, maxChars = MAX_TEXT_CHARS): string {
return `${text.slice(0, maxChars)}\n\n[truncated ${text.length - maxChars} characters]`;
}
+// JSON.stringify returns undefined (not a string) for undefined, functions, and symbols. A tool
+// result block with `text: undefined` is persisted to the session and makes every later turn
+// fail with "Cannot read properties of undefined (reading 'length')", so always return a string.
function safeJson(value: unknown): string {
- return JSON.stringify(value, null, 2);
+ return JSON.stringify(value, null, 2) ?? String(value);
}
const snapshotModeValues = ["auto", "interactive", "forms", "pageMap", "text", "changes", "full"] as const;
@@ -150,7 +153,8 @@ function rectText(rect: any): string {
}
function formatChromeSnapshot(snapshot: any): string {
- if (!snapshot || typeof snapshot !== "object") return safeJson(snapshot);
+ if (snapshot == null) throw new Error("Chrome returned no snapshot for this page; it may block extension scripts. Try chrome_evaluate instead.");
+ if (typeof snapshot !== "object") return safeJson(snapshot);
if (snapshot.mode === "full") return truncateText(safeJson(snapshot));
const lines: string[] = [];
lines.push(`# Chrome snapshot${snapshot.mode ? ` (${snapshot.mode})` : ""}`);
@@ -351,7 +355,8 @@ async function pruneScreenshotDir(dir: string, retentionDays?: number): Promise<
// End screenshot pruning.
function formatChromeInspect(inspect: any): string {
- if (!inspect || typeof inspect !== "object") return safeJson(inspect);
+ if (inspect == null) throw new Error("Chrome returned no inspect result for this page; it may block extension scripts. Try chrome_evaluate instead.");
+ if (typeof inspect !== "object") return safeJson(inspect);
const t = inspect.target || {};
const lines: string[] = [];
lines.push(`# Chrome inspect ${t.uid || ""}`.trim());
@@ -919,9 +924,6 @@ export default function (pi: ExtensionAPI): void {
const bridge = new ChromeProfileBridge(DEFAULT_HOST, DEFAULT_PORT);
let backgroundEnabled = true;
let chromeAuthorizedUntil: number | "indefinite" | undefined;
- // Whether the current grant stood in from PI_CHROME_AUTHORIZE rather than being given by
- // the user this session. The footer announces only the latter.
- let envGranted = false;
// Restore an authorization that survived a /reload. Drop it if it already expired.
const persistedAuth = globalState[PI_CHROME_AUTH_KEY];
if (persistedAuth) {
@@ -1021,7 +1023,6 @@ export default function (pi: ExtensionAPI): void {
chromeToolsUsable = false;
if (logAction && wasUsable) logChromeToolChange(logAction, { authorizedUntil: undefined });
chromeAuthorizedUntil = undefined;
- envGranted = false;
persistAuth();
// Revoking control ends pi-chrome's automation for this session; tidy up the target we own.
void cleanupAutomationTargetBestEffort();
@@ -1078,8 +1079,16 @@ export default function (pi: ExtensionAPI): void {
return typeof id === "string" && id ? `session:${id}` : undefined;
};
+ // FORK: with PI_CHROME_AUTHORIZE set, Chrome control is standing configuration, not session
+ // state, so the footer never shows. Read from the environment on every update, not from any
+ // saved state, so no grant restored across /reload or a session switch can bring it back.
+ const envAuthorizes = (): boolean => {
+ const value = process.env.PI_CHROME_AUTHORIZE?.trim();
+ return !!value && parseAuthorizeArg(value) !== undefined;
+ };
+
const updateChromeStatus = (ctx: ExtensionContext): void => {
- if (chromeControlAuthorized() && !envGranted) {
+ if (chromeControlAuthorized() && !envAuthorizes()) {
ctx.ui.setStatus("chrome", ctx.ui.theme.fg("success", "●") + " Chrome Bridge" + authCountdownLabel());
} else {
ctx.ui.setStatus("chrome", undefined);
@@ -1175,7 +1184,6 @@ export default function (pi: ExtensionAPI): void {
const grant = parseAuthorizeArg(autoGrant);
if (grant) {
chromeAuthorizedUntil = grant.until;
- envGranted = true;
} else {
ctx.ui.notify(`Ignoring PI_CHROME_AUTHORIZE="${autoGrant}": use minutes (15m, 30, 45) or indefinite.`, "warning");
}
@@ -1278,7 +1286,7 @@ 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. Your session has its own browser window, with your tabs in your own colored tab group. Only the active tab of a window is visible, and a hidden tab gets no real input and blank screenshots, so reuse your tab (navigate it) rather than opening new ones; open a second tab only when you need two pages at once, and expect only the active one to take input. 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, decide whether it is in your way or part of the task. If it is just in your way, call \`chrome_clear_overlay\` for the tab. If you need the user to sign in, authorize or approve something with it, leave it alone and tell the user what you are waiting for. Navigating the tab away also frees it.`;
// Shared handlers, dispatched by the unified /chrome command below.
@@ -1410,7 +1418,6 @@ Usage rules:
}
const wasUsable = chromeToolsUsable;
chromeAuthorizedUntil = until;
- envGranted = false;
persistAuth();
activateChromeTools();
chromeToolsUsable = true;
@@ -1654,7 +1661,7 @@ Usage rules:
pi.registerTool({
name: "chrome_tab",
label: "Chrome Tab",
- description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension. New/grouped tabs always use this session's Pi tab group, inside the one window all agent sessions share; prefer navigating a tab you already have to opening another. Background mode keeps new tabs inactive and blocks activate; ask the user to run /chrome background off for foreground/watch mode. activate/close/group/ungroup require a target (targetId/urlIncludes/titleIncludes); with no target they act on this session's pi-chrome automation tab if one exists, and otherwise error rather than touching the user's active tab.",
+ description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension. New/grouped tabs always use this session's Pi tab group, in the session's own window; prefer navigating a tab you already have to opening another, since only a window's active tab is visible. Background mode keeps new tabs inactive and blocks activate; ask the user to run /chrome background off for foreground/watch mode. activate/close/group/ungroup require a target (targetId/urlIncludes/titleIncludes); with no target they act on this session's pi-chrome automation tab if one exists, and otherwise error rather than touching the user's active tab.",
promptSnippet: "List/open/activate/close/group existing Chrome tabs through the companion extension.",
parameters: Type.Object({
action: StringEnum(tabActionValues),
diff --git a/package.json b/package.json
index cc7ef4c..7f7e204 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 && node test-suite/unit/overlay.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 && node test-suite/unit/sandboxed-snapshot.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 efbca78..ea08be0 100644
--- a/test-suite/unit/background-policy.test.mjs
+++ b/test-suite/unit/background-policy.test.mjs
@@ -297,6 +297,14 @@ function workerHarness({ withWindows = true } = {}) {
};
return h;
}
+// A new Pi tab must not take over the user's window: it is either inactive, or the only tab of a
+// new, unfocused window of the session's own (so it is visible to the agent without stealing focus).
+function assertNewTabStaysOutOfTheWay(h, tab) {
+ if (tab.active === false) return;
+ assert.notEqual(tab.windowId, 1, "an active new tab must not be in the user's window");
+ assert.ok(h.calls.some((c) => c.action === "windows.create" && c.params.focused === false), "its window was created unfocused");
+}
+
function assertNoFocus(h) {
assert.equal(h.focusedWindow(), 1);
assert.equal(h.tabs.get(1).active, true);
@@ -325,7 +333,7 @@ test("worker advertises support, keeps new tabs inactive, and rejects activation
assert.equal((await h.w.dispatch("tab.version", {})).capabilities.hardBackground, true);
for (const flags of [{}, { foreground: false }, { background: true, foreground: true }]) {
const result = await h.w.dispatch("tab.new", { ...flags, sessionKey: "alpha", groupTitle: "Pi Session: alpha" });
- assert.equal(result.tab.active, false);
+ assertNewTabStaysOutOfTheWay(h, result.tab);
await assert.rejects(h.w.dispatch("tab.activate", { ...flags, targetId: "missing" }), /background mode/);
}
assertNoFocus(h);
@@ -339,7 +347,7 @@ test("worker advertises support, keeps new tabs inactive, and rejects activation
test("background-only worker aliases force safe behavior even with foreground flags", async () => {
const h = workerHarness();
const opened = await h.w.dispatch("tab.new.background", { foreground: true, background: false });
- assert.equal(opened.tab.active, false);
+ assertNewTabStaysOutOfTheWay(h, opened.tab);
const shot = await h.w.dispatch("page.screenshot.background", { targetId: "2", foreground: true, background: false });
assert.equal(shot.method, "cdp");
assertNoFocus(h);
diff --git a/test-suite/unit/grant-scope.test.mjs b/test-suite/unit/grant-scope.test.mjs
index e7ce044..7717131 100644
--- a/test-suite/unit/grant-scope.test.mjs
+++ b/test-suite/unit/grant-scope.test.mjs
@@ -24,6 +24,8 @@ function piProcess(env) {
setTimeout, clearTimeout, setInterval, clearInterval,
});
let runtime;
+ // A grant an earlier runtime left on globalThis, e.g. a build that saved the env grant there.
+ const seedGrant = (until) => vm.runInContext(`globalThis.__piChromeProfileBridgeAuth__ = ${JSON.stringify({ until })}`, context);
// Evaluate the module again and run its factory, as pi does at startup, on /reload and on a
// session switch.
const load = () => {
@@ -65,7 +67,7 @@ function piProcess(env) {
return next;
};
// Stops the bridge and timers of the live runtime, so a failed assertion cannot hang the run.
- return { load, replace, end: () => runtime?.shutdown("quit") };
+ return { load, replace, seedGrant, end: () => runtime?.shutdown("quit") };
}
test("PI_CHROME_AUTHORIZE grants every runtime without announcing it", async (t) => {
@@ -104,18 +106,38 @@ test("/chrome authorize survives /reload and ends with its session", async (t) =
}
});
-test("/chrome authorize over PI_CHROME_AUTHORIZE is announced, and a switch falls back to the env grant", async (t) => {
+test("with PI_CHROME_AUTHORIZE set, /chrome authorize does not bring the footer, and a switch falls back to the env grant", async (t) => {
const pi = piProcess({ PI_CHROME_AUTHORIZE: "indefinite" });
t.after(() => pi.end());
const first = pi.load();
await first.start();
await first.chrome("authorize 15m");
- assert.match(first.footer(), /^● Chrome Bridge \(\d+m\)$/);
+ assert.equal(first.chromeTools(), true);
+ assert.equal(first.footer(), undefined);
const reloaded = await pi.replace("reload");
- assert.match(reloaded.footer(), /^● Chrome Bridge \(\d+m\)$/);
+ assert.equal(reloaded.footer(), undefined);
const next = await pi.replace("new");
assert.equal(next.chromeTools(), true);
assert.equal(next.footer(), undefined);
});
+
+test("with PI_CHROME_AUTHORIZE set, a grant left on globalThis by an older build never shows the footer", async (t) => {
+ for (const until of ["indefinite", Date.now() + 3_600_000]) {
+ const pi = piProcess({ PI_CHROME_AUTHORIZE: "indefinite" });
+ t.after(() => pi.end());
+ pi.seedGrant(until);
+ const first = pi.load();
+ await first.start();
+ assert.equal(first.chromeTools(), true);
+ assert.equal(first.footer(), undefined, `footer at start, saved until ${until}`);
+ for (const reason of ["reload", "new", "resume", "fork"]) {
+ pi.seedGrant(until);
+ const next = await pi.replace(reason);
+ assert.equal(next.chromeTools(), true, `tools after ${reason}`);
+ assert.equal(next.footer(), undefined, `footer after ${reason}, saved until ${until}`);
+ }
+ await pi.end();
+ }
+});
diff --git a/test-suite/unit/sandboxed-snapshot.test.mjs b/test-suite/unit/sandboxed-snapshot.test.mjs
new file mode 100644
index 0000000..9774f21
--- /dev/null
+++ b/test-suite/unit/sandboxed-snapshot.test.mjs
@@ -0,0 +1,108 @@
+// chrome_snapshot and chrome_inspect on a page served with CSP `sandbox` (no allow-scripts), such as
+// raw.githubusercontent.com. There chrome.scripting.executeScript resolves with a null result
+// instead of throwing, so the access-denied CDP fallback never ran and the snapshot came back
+// undefined. The worker now redoes the injection over CDP Runtime.evaluate, which such pages allow.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import vm from "node:vm";
+import { test } from "node:test";
+
+const workerUrl = new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url);
+const src = fs.readFileSync(workerUrl, "utf8");
+
+// Stands in for snapshot_injected.js when the worker reads it from the package for CDP injection.
+const INJECTED_STUB = `
+globalThis.__piChromeSnapshotPage = async (...args) => ({ mode: args[4], title: "raw page" });
+globalThis.__piChromeInspectTarget = async (uid) => ({ target: { uid } });
+`;
+
+function loadWorker({ scriptingRuns, cdpReturnsNothing = false }) {
+ const pageWorld = vm.createContext({ Promise, Error, String, JSON });
+ pageWorld.globalThis = pageWorld;
+ const calls = { scripting: 0, cdp: 0 };
+ const noop = () => {};
+ const listener = { addListener: noop, removeListener: noop };
+ const sandbox = {
+ console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
+ Error, TypeError, Map, Set, Symbol, structuredClone,
+ setTimeout, clearTimeout, setInterval: () => 0, clearInterval: noop,
+ fetch: async (url) => {
+ assert.match(String(url), /snapshot_injected\.js$/);
+ return { ok: true, text: async () => INJECTED_STUB };
+ },
+ navigator: { userAgent: "unit-test" },
+ WebSocket: function () {},
+ chrome: {
+ runtime: {
+ id: "unittestextension", getManifest: () => ({ version: "0.0.0" }),
+ getURL: (file) => `chrome-extension://unittestextension/${file}`,
+ onInstalled: listener, onStartup: listener, lastError: null,
+ },
+ alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
+ action: { onClicked: listener },
+ debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]) },
+ scripting: {
+ // A sandboxed document runs no extension script and reports a null result.
+ executeScript: async ({ files, func, args }) => {
+ calls.scripting++;
+ if (!scriptingRuns) return [{ frameId: 0, result: null }];
+ if (files) {
+ vm.runInContext(INJECTED_STUB, pageWorld);
+ return [{ frameId: 0, result: undefined }];
+ }
+ const fn = vm.runInContext(`(${func.toString()})`, pageWorld);
+ return [{ frameId: 0, result: await fn(...(args || [])) }];
+ },
+ },
+ tabs: { query: async () => [], get: async () => ({}), create: async () => ({}), update: async () => ({}), remove: async () => {} },
+ windows: { update: async () => {} },
+ webNavigation: { onCommitted: listener },
+ },
+ };
+ sandbox.globalThis = sandbox;
+ sandbox.self = sandbox;
+ vm.createContext(sandbox);
+ vm.runInContext(src, sandbox);
+ sandbox.attachDebugger = async () => ({});
+ sandbox.bringToFront = async () => {};
+ sandbox.getTabByParams = async () => ({ id: 7, windowId: 1, url: "https://raw.githubusercontent.com/o/r/main/f.swift" });
+ sandbox.cdp = async (_tabId, method, params) => {
+ assert.equal(method, "Runtime.evaluate");
+ calls.cdp++;
+ if (cdpReturnsNothing) return { result: { type: "undefined" } };
+ // returnByValue: JSON-able values come back as `value`; functions and undefined only as a type.
+ const value = await vm.runInContext(params.expression, pageWorld);
+ const json = JSON.stringify(value);
+ return json === undefined
+ ? { result: { type: typeof value } }
+ : { result: { type: "object", value: JSON.parse(json) } };
+ };
+ return { dispatch: sandbox.dispatch, calls };
+}
+
+test("snapshot of a sandboxed page falls back to CDP", async () => {
+ const { dispatch, calls } = loadWorker({ scriptingRuns: false });
+ const snapshot = await dispatch("page.snapshot", { mode: "text" });
+ assert.equal(snapshot?.title, "raw page");
+ assert.equal(snapshot?.mode, "text");
+ assert.equal(calls.scripting, 2, "scripting is tried first for both the injection and the call");
+ assert.equal(calls.cdp, 2, "both are redone over CDP");
+});
+
+test("inspect of a sandboxed page falls back to CDP", async () => {
+ const { dispatch } = loadWorker({ scriptingRuns: false });
+ const inspect = await dispatch("page.inspect", { uid: "e12" });
+ assert.equal(inspect?.target?.uid, "e12");
+});
+
+test("snapshot of an ordinary page does not touch CDP", async () => {
+ const { dispatch, calls } = loadWorker({ scriptingRuns: true });
+ const snapshot = await dispatch("page.snapshot", { mode: "auto" });
+ assert.equal(snapshot?.title, "raw page");
+ assert.equal(calls.cdp, 0);
+});
+
+test("a page that runs neither path reports an error instead of an empty snapshot", async () => {
+ const { dispatch } = loadWorker({ scriptingRuns: false, cdpReturnsNothing: true });
+ await assert.rejects(dispatch("page.snapshot", { mode: "text" }), /snapshot script returned no result in tab 7 \(https:\/\/raw\.githubusercontent\.com/);
+});
diff --git a/test-suite/unit/shared-window.test.mjs b/test-suite/unit/shared-window.test.mjs
index 7afeb77..66041f4 100644
--- a/test-suite/unit/shared-window.test.mjs
+++ b/test-suite/unit/shared-window.test.mjs
@@ -1,5 +1,5 @@
-// Agent sessions share one window: a new session's tab goes into the window that already holds a
-// "Pi Session:" group, and a window is created only when there is none.
+// Each session has its own window: a window shows only one tab, so sessions sharing one hid each
+// other's tabs (no real input, blank screenshots). A session's extra tabs go into its own window.
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
@@ -35,44 +35,27 @@ function worker({ groups = [], windows = { 5: { id: 5, type: "normal" } } } = {}
}
const piGroup = (windowId, name = "Pi Session: claude-code 1 other") => ({ id: 1, windowId, title: name });
+const ops = (calls) => calls.filter((c) => c.method !== "storage.set").map((c) => c.method);
-test("a new session's tab goes into the window that already holds another Pi session", async () => {
+test("a new session gets its own window even when another session's window exists", async () => {
const { w, calls } = worker({ groups: [piGroup(5)] });
await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
- assert.deepEqual(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["tabs.create"]);
- assert.equal(calls[0].props.windowId, 5);
+ assert.deepEqual(ops(calls), ["windows.create"]);
});
-test("with no Pi window yet, the first session creates one", async () => {
- const { w, calls } = worker({ groups: [] });
+test("a session that already has a group adds its tab to that group's window", async () => {
+ const { w, calls } = worker({ groups: [piGroup(5, "Pi Session: claude-code 2 mine")] });
await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
- assert.deepEqual(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["windows.create"]);
+ assert.deepEqual(ops(calls), ["tabs.create"]);
+ assert.equal(calls.find((c) => c.method === "tabs.create").props.windowId, 5);
});
-test("groups that are not Pi sessions do not attract agent tabs", async () => {
- const { w, calls } = worker({ groups: [{ id: 2, windowId: 5, title: "Work" }] });
- await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
- assert.deepEqual(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["windows.create"]);
-});
-
-test("a Pi group whose window is gone, or is not a normal window, is skipped", async () => {
- const { w, calls } = worker({ groups: [piGroup(7), piGroup(8)], windows: { 8: { id: 8, type: "popup" } } });
- await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
- assert.deepEqual(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["windows.create"]);
-});
-
-test("the shared window is never recorded as ours, so cleanup cannot close it", async () => {
- const { w, calls } = worker({ groups: [piGroup(5)] });
- await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
- const stored = calls.filter((c) => c.method === "storage.set").map((c) => c.json).join("");
- assert.doesNotMatch(stored, /"windowId":5/);
-});
-
-test("chrome_tab new for a session with no group uses the shared window, not the focused one", async () => {
+test("chrome_tab new for a session with no group goes to the session's own window, not the focused one", async () => {
const { w, calls } = worker({ groups: [piGroup(5)] });
w.groupTab = async (tab) => tab;
w.bringToFront = async () => {};
- await w.dispatch("tab.new", { url: "about:blank", groupTitle: "Pi Session: claude-code 2 mine", sessionKey: "claude-code 2 mine" });
- const created = calls.find((c) => c.method === "tabs.create");
- assert.equal(created.props.windowId, 5);
+ await w.dispatch("tab.new", { url: "https://x.test/", groupTitle: "Pi Session: claude-code 2 mine", sessionKey: "claude-code 2 mine" });
+ assert.deepEqual(ops(calls), ["windows.create"]);
+ assert.equal(calls[0].props.url, "https://x.test/");
+ assert.equal(calls[0].props.focused, false);
});