aboutsummaryrefslogtreecommitdiff
path: root/extensions
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-09-26 16:37:25 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-09-26 16:37:25 -0400
commitc49dc15fa5c33d5b1d210b0185677ce29640e2d5 (patch)
treeb1f6ddcf387edb51213a6f9f1beb011fbe5c0bd3 /extensions
parent4811ee616d2bbd5ead669a7e2f33a0bb14bd43ef (diff)
Absorb fork fixes and features; release 0.15.53
Adapted from community forks (ardhiqii, kkunkunya, nihar-oracle, steimerbyte): - Abort stalled /next long polls after 45s; time out, retry, and single-post results. - Fall back to CDP Runtime.evaluate when chrome.scripting is refused (about:blank automation tabs). - Load pi-chrome in same-process subagent sessions as a bridge client; client chrome_launch reports owner status. - Add chrome_cdp (validated raw CDP passthrough, background-guarded, payload summarization) and chrome_cdp_targets. - chrome_type reports before/after values, insertedAt, splice/unchanged warnings; add replace=true. - includeSnapshot waits for navigations started by the action; tab.new reports the settled tab. - CDP timeouts surface as timeouts and are not re-sent after the cleanup detach. - Activate/deactivate chrome_find and chrome_inspect with the rest of the chrome_* tools. - New unit suites: bridge-resilience, cdp-passthrough, type-evidence.
Diffstat (limited to 'extensions')
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/manifest.json2
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/service_worker.js420
-rw-r--r--extensions/chrome-profile-bridge/index.ts195
3 files changed, 560 insertions, 57 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json
index cad26fa..952c9cc 100644
--- a/extensions/chrome-profile-bridge/browser-extension/manifest.json
+++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Pi Chrome Connector",
- "version": "0.15.51",
+ "version": "0.15.53",
"description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
"permissions": [
"tabs",
diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
index 8ba0e2d..4bfce34 100644
--- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js
+++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
@@ -1,6 +1,13 @@
const BRIDGE_URL = "http://127.0.0.1:17318";
const CLIENT_NAME = `Pi Chrome Connector ${chrome.runtime.id}`;
const POLL_ERROR_BACKOFF_MS = 2000;
+// /next is a server-side long poll held for up to 25s (see waitForCommand in index.ts). A fetch that
+// outlives this deadline means the socket is half-open (the owning Pi process died, the machine
+// slept, the network changed) and will never settle; abort it so pollLoop backs off and reconnects
+// instead of parking on a zombie connection until the extension is reloaded by hand.
+const POLL_REQUEST_TIMEOUT_MS = 45_000;
+const RESULT_REQUEST_TIMEOUT_MS = 15_000;
+const RESULT_POST_ATTEMPTS = 3;
const DEFAULT_GROUP_COLOR = "blue";
const PI_GROUP_RE = /^Pi(\b|\s*-)/i;
const VALID_GROUP_COLORS = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
@@ -8,6 +15,8 @@ const COMMAND_TIMEOUT_MS = 25_000;
const CDP_COMMAND_TIMEOUT_MS = 5_000;
const SCRIPTING_TIMEOUT_MS = 8_000;
const ATTACH_TIMEOUT_MS = 3_000;
+// Raw CDP methods that explicitly focus or activate a tab. Blocked by hard background mode.
+const CDP_FOCUS_METHODS = new Set(["Page.bringToFront", "Target.activateTarget"]);
let polling = false;
// =================== pi-chrome automation target ownership ===================
@@ -250,9 +259,12 @@ function withTimeout(promise, ms, label, onTimeout) {
return Promise.race([
Promise.resolve(promise).finally(() => clearTimeout(timer)),
new Promise((_, reject) => {
- timer = setTimeout(async () => {
- try { await onTimeout?.(); } catch {}
+ timer = setTimeout(() => {
+ // Settle with the timeout first. Cleanup (e.g. debugger detach) makes the pending command
+ // fail with "Detached while handling command"; if that won the race, cdp() would treat it as
+ // a stale session and re-send a command that may already have run.
reject(new Error(`${label} timed out after ${ms}ms`));
+ Promise.resolve().then(() => onTimeout?.()).catch(() => {});
}, ms);
}),
]);
@@ -435,14 +447,28 @@ setInterval(() => {
}
}, 5000);
-function cdpRaw(tabId, method, params) {
+// Deadline policy for cdp.call. Callers may widen the per-command CDP deadline via params.timeoutMs,
+// clamped below the MV3 service-worker lifetime so a stuck command cannot pin the debugger forever.
+const CDP_CALL_MAX_TIMEOUT_MS = 120_000;
+const CDP_CALL_GRACE_MS = 5_000;
+function cdpCallTimeoutMs(params) {
+ const requested = Number(params?.timeoutMs);
+ if (!Number.isFinite(requested) || requested <= 0) return CDP_COMMAND_TIMEOUT_MS;
+ return Math.min(Math.floor(requested), CDP_CALL_MAX_TIMEOUT_MS);
+}
+
+// `opts.timeoutMs` overrides the default per-command deadline. On timeout the session is detached
+// and forgotten so the next call re-attaches cleanly.
+function cdpRaw(tabId, method, params, opts) {
const debuggee = attachedTabs.get(tabId)?.debuggee || { tabId };
+ const requested = Number(opts?.timeoutMs);
+ const timeoutMs = Number.isFinite(requested) && requested > 0 ? requested : CDP_COMMAND_TIMEOUT_MS;
return withTimeout(new Promise((resolve, reject) => {
chrome.debugger.sendCommand(debuggee, method, params || {}, (result) => {
if (chrome.runtime.lastError) reject(new Error(`${method}: ${chrome.runtime.lastError.message}`));
else resolve(result);
});
- }), CDP_COMMAND_TIMEOUT_MS, `CDP ${method}`, async () => {
+ }), timeoutMs, `CDP ${method}`, async () => {
attachedTabs.delete(tabId);
try { await chrome.debugger.detach(debuggee); } catch {}
});
@@ -452,6 +478,51 @@ function executeScriptTimed(options, label) {
return withTimeout(chrome.scripting.executeScript(options), SCRIPTING_TIMEOUT_MS, label || "chrome.scripting.executeScript");
}
+// Chrome refuses chrome.scripting on pages outside the extension's host permissions. Notably a
+// top-level about:blank (pi-chrome's fresh automation tab) has an opaque origin that <all_urls>
+// does not cover, so snapshot/inspect/probe/console/click-resolution failed there with
+// "Cannot access contents of url \"about:blank\"". The debugger API still attaches to about:blank.
+function isScriptingAccessDenied(error) {
+ const message = String(error?.message || error);
+ return /Cannot access contents of url|Cannot access .+ at origin|Extension manifest must request permission|matchAboutBlank must be true/i.test(message);
+}
+
+// chrome.scripting.executeScript with a CDP Runtime.evaluate fallback when Chrome refuses scripting
+// for a permission reason. Scripting stays primary: it is what ordinary pages use and the only path
+// that works while DevTools holds the tab's debugger. The fallback runs in the same MAIN world and
+// covers both forms: `func` is stringified and invoked with its args; `files` are read from the
+// extension package and evaluated. Any other scripting failure surfaces unchanged.
+const packagedFileSources = new Map();
+async function executeScriptWithFallback(options, label) {
+ try {
+ return await executeScriptTimed(options, label);
+ } catch (error) {
+ 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 [{ 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:
// chrome.debugger.attach can stay cached in attachedTabs even after Chrome killed
// the session (tab nav, devtools opened/closed, etc). Recover by detaching the
@@ -494,9 +565,9 @@ async function dismissOverlayViaEscape(tabId) {
} catch {}
}
-async function cdp(tabId, method, params) {
+async function cdp(tabId, method, params, opts) {
try {
- return await cdpRaw(tabId, method, params);
+ return await cdpRaw(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);
@@ -508,7 +579,7 @@ async function cdp(tabId, method, params) {
recordAttachEvent({ kind: "foreign-ext-detected", tabId, method, foreignExtId: extractForeignExtId(before), targetCount: before.length });
await dismissOverlayViaEscape(tabId);
try {
- return await cdpRaw(tabId, method, params);
+ 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)) {
@@ -525,7 +596,7 @@ async function cdp(tabId, method, params) {
if (!isStale) throw error;
attachedTabs.delete(tabId);
await attachDebugger(tabId).catch(() => undefined);
- return cdpRaw(tabId, method, params);
+ return cdpRaw(tabId, method, params, opts);
}
}
@@ -563,7 +634,7 @@ function cdpIsSyntaxError(details) {
// Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab.
async function resolveTargetInTab(tabId, params) {
- const results = await executeScriptTimed({
+ const results = await executeScriptWithFallback({
target: { tabId, frameIds: [0] },
world: "MAIN",
func: (selector, uid, x, y) => {
@@ -721,7 +792,7 @@ async function cdpTypeChar(tabId, ch) {
}
async function domClickFallback(tabId, params, cause) {
- const results = await executeScriptTimed({
+ const results = await executeScriptWithFallback({
target: { tabId, frameIds: [0] },
world: "MAIN",
func: (selector, uid, x, y) => {
@@ -764,7 +835,7 @@ async function chromeInputClick(params) {
// focus can leave :focus-visible=true in Chromium, which trips heuristics that expect
// Reset focus styling after pointer click when possible.
if (params.selector || params.uid) {
- await executeScriptTimed({
+ await executeScriptWithFallback({
target: { tabId: tab.id, frameIds: [0] },
world: "MAIN",
func: (sel, uid) => {
@@ -840,7 +911,7 @@ async function chromeInputKey(params) {
// the requested editor's entire contents: triple-click only selects a paragraph.
// Selection uses the DOM; deletion and insertion still use Chrome's input layer.
async function contentEditableInTab(tabId, selectAllParams = null) {
- const results = await executeScriptTimed({
+ const results = await executeScriptWithFallback({
target: { tabId, frameIds: [0] },
world: "MAIN",
func: (selector, uid, selectAll) => {
@@ -894,14 +965,143 @@ async function chromeInputType(params) {
await sleep(rng(50, 120));
}
const text = String(params.text || "");
+ // chrome_type inserts at the caret. Read the field before and after so a splice into existing
+ // text is reported instead of hidden behind a bare character count. The "before" read precedes
+ // replace's select-all/delete. Reads are best-effort and never block typing.
+ const before = await readInputStateInTab(tab.id, params).catch(() => null);
+ if (params.replace === true) await selectAllAndDelete(tab.id);
const typing = await typeTextInTab(tab.id, text, params.perCharacter);
+ const after = await readInputStateInTab(tab.id, params).catch(() => null);
if (params.pressEnter) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" });
- return { input: "chrome", length: text.length, typing };
+ return {
+ input: "chrome",
+ length: text.length,
+ typing,
+ ...inputValueEvidence(before, after, { replaced: params.replace === true }),
+ };
+}
+
+// Select the focused field's whole contents with Chrome's editing command, then delete it with real
+// key events. The selectAll editing command is platform-neutral (Ctrl+A moves to line start on macOS).
+async function selectAllAndDelete(tabId) {
+ const mac = /Mac/i.test(String(globalThis.navigator?.platform || globalThis.navigator?.userAgent || ""));
+ const modifiers = mac ? 4 : 2;
+ const key = mac ? { key: "Meta", code: "MetaLeft", vk: 91 } : { key: "Control", code: "ControlLeft", vk: 17 };
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: key.key, code: key.code, windowsVirtualKeyCode: key.vk, modifiers });
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers, commands: ["selectAll"] });
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers });
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: key.key, code: key.code, windowsVirtualKeyCode: key.vk, modifiers: 0 });
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
+ await sleep(rng(20, 60));
+}
+
+// Read the typing target's value and caret. Sensitive fields (password/OTP/card/etc., matching the
+// snapshot redaction convention) report only valueRedacted + length, never contents. Displayed values
+// are truncated to 120 chars like snapshots; lengths and caret offsets are exact.
+async function readInputStateInTab(tabId, params) {
+ const results = await executeScriptWithFallback({
+ target: { tabId, frameIds: [0] },
+ world: "MAIN",
+ func: (selector, uid) => {
+ const state = window.__PI_CHROME_STATE__;
+ const carrierOf = (node) => {
+ if (!node) return null;
+ if ("value" in node && typeof node.value === "string" && /^(INPUT|TEXTAREA)$/.test(node.tagName)) return "value";
+ if (node.isContentEditable === true) return "contenteditable";
+ return null;
+ };
+ let el = uid ? (state && state.elements ? state.elements[uid] : null) : null;
+ if (uid && (!el || !el.isConnected)) el = null;
+ if (!el && selector) el = document.querySelector(selector);
+ const active = document.activeElement;
+ // The uid/selector can name a wrapper (role=textbox, label) rather than the element that
+ // receives text. Prefer the focused carrier in that case so we do not report a false "" -> "".
+ if (!carrierOf(el) && carrierOf(active)) el = active;
+ const carrier = carrierOf(el);
+ if (!carrier) return { found: false };
+ const raw = carrier === "value" ? el.value : String(el.textContent || "");
+ const attr = (name) => (el.getAttribute ? el.getAttribute(name) : null);
+ const type = String(el.type || attr("type") || "").toLowerCase();
+ const haystack = [type, el.name, attr("name"), el.id, attr("autocomplete"), attr("aria-label"), attr("placeholder"), attr("data-testid")].filter(Boolean).join(" ").toLowerCase();
+ const sensitive = type === "password" || /password|passwd|\bpwd\b|secret|token|bearer|api[-_ ]?key|access[-_ ]?key|auth[-_ ]?code|one[-_ ]?time|otp|2fa|mfa|verification[-_ ]?code|recovery[-_ ]?code|credit[-_ ]?card|card[-_ ]?number|cc-number|cc-csc|cvc|cvv|security[-_ ]?code|ssn|social[-_ ]?security/.test(haystack);
+ let selectionStart = null;
+ let selectionEnd = null;
+ try {
+ if (typeof el.selectionStart === "number") selectionStart = el.selectionStart;
+ if (typeof el.selectionEnd === "number") selectionEnd = el.selectionEnd;
+ } catch {}
+ let caretOffset = null;
+ if (carrier === "contenteditable") {
+ try {
+ const selection = window.getSelection && window.getSelection();
+ if (selection && selection.rangeCount > 0 && selection.anchorNode && el.contains(selection.anchorNode)) {
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ range.setEnd(selection.anchorNode, selection.anchorOffset);
+ caretOffset = range.toString().length;
+ if (!selection.isCollapsed) selectionEnd = caretOffset + String(selection.toString()).length;
+ }
+ } catch {}
+ }
+ return {
+ found: true,
+ carrier,
+ value: sensitive ? undefined : raw.slice(0, 120),
+ valueLength: raw.length,
+ valueRedacted: sensitive ? true : undefined,
+ selectionStart,
+ selectionEnd,
+ caretOffset,
+ };
+ },
+ args: [params.selector ?? null, params.uid ?? null],
+ }, `read input value in tab ${tabId}`);
+ const v = results?.[0]?.result;
+ return v && typeof v === "object" ? v : { found: false };
+}
+
+// Where typed text landed relative to the field's existing contents.
+function inputInsertPosition(before) {
+ const length = before.valueLength ?? 0;
+ if (length === 0) return "empty";
+ if (typeof before.selectionStart === "number" && typeof before.selectionEnd === "number") {
+ if (before.selectionEnd > before.selectionStart) {
+ return before.selectionStart === 0 && before.selectionEnd >= length ? "replaced-all" : "replaced-selection";
+ }
+ return before.selectionStart < length ? "caret-middle" : "caret-end";
+ }
+ if (typeof before.caretOffset === "number") {
+ if (typeof before.selectionEnd === "number" && before.selectionEnd > before.caretOffset) {
+ return before.caretOffset === 0 && before.selectionEnd >= length ? "replaced-all" : "replaced-selection";
+ }
+ return before.caretOffset < length ? "caret-middle" : "caret-end";
+ }
+ return undefined;
+}
+
+function inputValueEvidence(before, after, { replaced = false } = {}) {
+ const hasBefore = Boolean(before?.found);
+ const hasAfter = Boolean(after?.found);
+ if (!hasBefore && !hasAfter) return replaced ? { replaced: true } : {};
+ const redacted = Boolean(before?.valueRedacted || after?.valueRedacted);
+ const evidence = {
+ existingTextLengthBefore: hasBefore ? before.valueLength : undefined,
+ valueLengthAfter: hasAfter ? after.valueLength : undefined,
+ insertedAt: replaced ? "replaced-all" : hasBefore ? inputInsertPosition(before) : undefined,
+ };
+ if (redacted) evidence.valueRedacted = true;
+ else {
+ if (hasBefore) evidence.valueBefore = before.value;
+ if (hasAfter) evidence.valueAfter = after.value;
+ }
+ if (replaced) evidence.replaced = true;
+ return evidence;
}
async function domFillFallback(tabId, params, cause) {
if (!(params.selector || params.uid)) throw cause;
- const results = await executeScriptTimed({
+ const results = await executeScriptWithFallback({
target: { tabId, frameIds: [0] },
world: "MAIN",
func: async (selector, uid, text, submit) => {
@@ -1138,9 +1338,9 @@ async function pollLoop() {
polling = true;
try {
while (true) {
- const response = await fetch(`${BRIDGE_URL}/next?name=${encodeURIComponent(CLIENT_NAME)}`, {
+ const response = await fetchWithTimeout(`${BRIDGE_URL}/next?name=${encodeURIComponent(CLIENT_NAME)}`, {
cache: "no-store",
- });
+ }, POLL_REQUEST_TIMEOUT_MS, "bridge /next");
if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`);
const expected = response.headers.get("x-pi-chrome-version");
const ours = chrome.runtime.getManifest().version;
@@ -1159,26 +1359,70 @@ async function pollLoop() {
}
}
+// fetch() with an abort deadline that covers connecting and receiving response headers. The timer is
+// cleared as soon as headers arrive so a late abort can never fail an already-delivered response.
+async function fetchWithTimeout(url, options, timeoutMs, label) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ return await fetch(url, { ...(options || {}), signal: controller.signal });
+ } catch (error) {
+ if (error?.name === "AbortError") throw new Error(`${label || "fetch"} timed out after ${timeoutMs}ms`);
+ throw error;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+function commandTimeoutMs(action, params) {
+ if (action !== "cdp.call") return COMMAND_TIMEOUT_MS;
+ // Let the inner CDP deadline fire first so callers see "CDP <method> timed out" instead of the
+ // generic command wrapper timeout.
+ return Math.max(COMMAND_TIMEOUT_MS, cdpCallTimeoutMs(params) + CDP_CALL_GRACE_MS);
+}
+
async function handleCommand(command) {
+ let payload;
try {
const result = await withTimeout(
dispatch(command.action, command.params ?? {}),
- COMMAND_TIMEOUT_MS,
+ commandTimeoutMs(command.action, command.params ?? {}),
command.action || "Chrome command",
() => detachAll(),
);
- await postResult({ id: command.id, ok: true, result });
+ payload = { id: command.id, ok: true, result };
} catch (error) {
- await postResult({ id: command.id, ok: false, error: error?.message ?? String(error) });
+ payload = { id: command.id, ok: false, error: error?.message ?? String(error) };
+ }
+ // Post exactly one result. A failed success-post used to fall into the catch above and post a
+ // second (error) result for the same command id.
+ try {
+ await postResult(payload);
+ } catch (error) {
+ console.warn(`[pi-chrome] failed to post result for ${command.action}: ${error?.message ?? String(error)}`);
}
}
async function postResult(result) {
- await fetch(`${BRIDGE_URL}/result`, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(result),
- });
+ const body = JSON.stringify(result);
+ let lastError;
+ for (let attempt = 1; attempt <= RESULT_POST_ATTEMPTS; attempt++) {
+ try {
+ const response = await fetchWithTimeout(`${BRIDGE_URL}/result`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body,
+ }, RESULT_REQUEST_TIMEOUT_MS, "bridge /result");
+ // 4xx is final: the bridge no longer knows this command (it already timed out) or rejected
+ // the request. Retrying cannot help.
+ if (response.ok || (response.status >= 400 && response.status < 500)) return;
+ lastError = new Error(`bridge /result HTTP ${response.status}`);
+ } catch (error) {
+ lastError = error;
+ }
+ if (attempt < RESULT_POST_ATTEMPTS) await sleep(250 * attempt);
+ }
+ throw lastError;
}
function isVersionOlder(a, b) {
@@ -1285,7 +1529,19 @@ async function dispatch(action, params) {
await trackSessionTab(sessionKeyOf(params), tab.id, true);
try {
await bringToFront(tab, params);
- return await groupTab(tab, groupTitle, params.groupColor);
+ const grouped = await groupTab(tab, groupTitle, params.groupColor);
+ // chrome.tabs.create returns the t=0 tab ({url:"", status:"loading"}), which reads as "the
+ // URL never loaded". Wait (bounded, never throwing) and report the settled tab instead.
+ if (!params.url || params.url === "about:blank") return grouped;
+ const started = Date.now();
+ const settled = await waitForTabSettled(tab.id, SNAPSHOT_NAVIGATION_WAIT_MS);
+ const latest = await chrome.tabs.get(tab.id).catch(() => null);
+ return {
+ ...grouped,
+ tab: latest ? await formatTab(latest) : grouped.tab,
+ loadStatus: settled ? "complete" : "timedOut",
+ waitedMs: Date.now() - started,
+ };
} catch (error) {
if (typeof tab.id === "number") await chrome.tabs.remove(tab.id).catch(() => {});
throw error;
@@ -1395,6 +1651,43 @@ async function dispatch(action, params) {
}
case "page.screenshot":
return takeScreenshot(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
+ // command" failures. Never creates an automation window. Only the resolved tab's targets are
+ // echoed; targets on other tabs are counted, not listed.
+ const hasSelector = params.targetId !== undefined || Boolean(params.urlIncludes) || Boolean(params.titleIncludes);
+ const tab = await getTabByParams(params, { createOwnedTarget: false }).catch((error) => {
+ if (hasSelector) throw error;
+ return null;
+ });
+ const allTargets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))).catch(() => []);
+ const targets = tab ? allTargets.filter((t) => t.tabId === tab.id) : [];
+ return {
+ tab: tab ? { id: tab.id, windowId: tab.windowId, url: tab.url, title: tab.title } : null,
+ targets: targets.map((t) => ({ id: t.id, tabId: t.tabId, type: t.type, url: t.url, title: t.title, attached: t.attached, extensionId: t.extensionId })),
+ otherTabTargetCount: allTargets.filter((t) => typeof t.tabId === "number" && t.tabId !== tab?.id).length,
+ };
+ }
+ case "cdp.call": {
+ // Validate before touching the debugger: a clear error beats Chrome's opaque
+ // "Invalid parameters" for a missing method or non-object params.
+ if (typeof params.method !== "string" || !params.method.trim()) {
+ throw new Error('cdp.call requires a non-empty string "method" (for example "Runtime.evaluate" or "DOM.getDocument").');
+ }
+ if (params.params !== undefined && params.params !== null && (typeof params.params !== "object" || Array.isArray(params.params))) {
+ throw new Error('cdp.call "params" must be a plain object of CDP parameters when provided.');
+ }
+ const method = params.method.trim();
+ // Background mode is a hard policy. Raw CDP must not become a way around it.
+ if (!foregroundRequested(params) && CDP_FOCUS_METHODS.has(method)) {
+ throw new Error(`${method} is blocked by background mode because it focuses or activates a tab. Ask the user to run /chrome background off to allow foreground work.`);
+ }
+ const tab = await getTabByParams(params);
+ await bringToFront(tab, params);
+ await attachDebugger(tab.id);
+ return await cdp(tab.id, method, params.params ?? {}, { timeoutMs: cdpCallTimeoutMs(params) });
+ }
case "automation.status": {
// Report this session's owned automation target (ids only). Used for diagnostics/tests.
await hydrateAutomationTargets();
@@ -1557,7 +1850,7 @@ async function executeInTab(params, func, args) {
// Phase 2: run the action via chrome.scripting.executeScript. The `func:` form is
// injected by Chrome itself (not `new Function`), so it is CSP-safe, and it lets Chrome
// serialize the invocation args. The wrapper references window.__piAction defined above.
- const results = await executeScriptTimed({
+ const results = await executeScriptWithFallback({
target: { tabId: tab.id },
world: "MAIN",
func: async (invocationArgs) => {
@@ -1639,13 +1932,45 @@ async function evaluateInTab(params) {
return v;
}
+// Run an input action, then optionally snapshot. An action that starts a navigation (Enter submits a
+// form, a click follows a link) used to be snapshotted immediately, so the snapshot described the
+// OUTGOING document and read as "the action did nothing". Anchor the tab's url/status first; when
+// the action plausibly started a load, wait (bounded) for it before snapshotting and report
+// navigation {from, to, settled, waitedMs}. A timeout never fails the action. A tab that was already
+// loading with the same URL is reported but not waited on, so a hanging subresource cannot add a wait
+// to every action.
+const SNAPSHOT_NAVIGATION_WAIT_MS = 5_000;
async function withOptionalSnapshot(params, actionFn) {
- const result = await actionFn(params);
- if (params.includeSnapshot) {
- const snapshot = await snapshotInTab({ ...params, foreground: false });
- return { result, snapshot };
+ if (!params.includeSnapshot) return actionFn(params);
+ let anchor = null;
+ try {
+ const tab = await getTabByParams(params);
+ if (tab && typeof tab.id === "number") {
+ anchor = { tabId: tab.id, params: { ...params, targetId: String(tab.id) }, url: String(tab.url ?? ""), status: String(tab.status ?? "") };
+ }
+ } catch {
+ anchor = null; // Let the action surface the targeting error itself.
+ }
+ const result = await actionFn(anchor ? anchor.params : params);
+ let navigation;
+ if (anchor) {
+ const after = await chrome.tabs.get(anchor.tabId).catch(() => null);
+ const afterUrl = String(after?.url ?? "");
+ const urlChanged = Boolean(afterUrl && afterUrl !== anchor.url);
+ const loading = after?.status === "loading";
+ if (urlChanged || loading) {
+ const started = Date.now();
+ const startedLoading = loading && (urlChanged || anchor.status === "complete");
+ let settled = !loading;
+ if (startedLoading) {
+ settled = await waitForTabSettled(anchor.tabId, SNAPSHOT_NAVIGATION_WAIT_MS);
+ }
+ const finalTab = await chrome.tabs.get(anchor.tabId).catch(() => after);
+ navigation = { from: anchor.url, to: String(finalTab?.url ?? afterUrl), settled, waitedMs: Date.now() - started };
+ }
}
- return result;
+ const snapshot = await snapshotInTab({ ...(anchor ? anchor.params : params), foreground: false });
+ return navigation ? { result, snapshot, navigation } : { result, snapshot };
}
// Snapshot/inspect run from a packaged MAIN-world script (snapshot_injected.js) injected via
@@ -1664,12 +1989,12 @@ async function snapshotInTab(params) {
params.query ?? null,
params.maxTextChars ?? null,
];
- await executeScriptTimed({
+ await executeScriptWithFallback({
target: { tabId: tab.id, frameIds: [0] },
world: "MAIN",
files: ["snapshot_injected.js"],
}, `inject snapshot script in tab ${tab.id}`);
- const results = await executeScriptTimed({
+ const results = await executeScriptWithFallback({
target: { tabId: tab.id, frameIds: [0] },
world: "MAIN",
func: async (invocationArgs) => {
@@ -1700,12 +2025,12 @@ 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 executeScriptTimed({
+ await executeScriptWithFallback({
target: { tabId: tab.id, frameIds: [0] },
world: "MAIN",
files: ["snapshot_injected.js"],
}, `inject inspect script in tab ${tab.id}`);
- const results = await executeScriptTimed({
+ const results = await executeScriptWithFallback({
target: { tabId: tab.id, frameIds: [0] },
world: "MAIN",
func: async (invocationArgs) => {
@@ -1796,6 +2121,31 @@ function waitForTabComplete(tabId, timeoutMs) {
});
}
+// Like waitForTabComplete, but for a load that may already be underway (or already finished):
+// re-checks the tab after subscribing so a completion between the caller's read and the listener
+// registration is not missed. Resolves true when complete, false on timeout; never throws.
+function waitForTabSettled(tabId, timeoutMs) {
+ return new Promise((resolve) => {
+ let done = false;
+ const finish = (value) => {
+ if (done) return;
+ done = true;
+ clearTimeout(timer);
+ chrome.tabs.onUpdated.removeListener(listener);
+ resolve(value);
+ };
+ const timer = setTimeout(() => finish(false), timeoutMs);
+ const listener = (updatedTabId, changeInfo) => {
+ if (updatedTabId === tabId && changeInfo.status === "complete") finish(true);
+ };
+ chrome.tabs.onUpdated.addListener(listener);
+ chrome.tabs.get(tabId).then((tab) => {
+ if (!tab) finish(false);
+ else if (tab.status === "complete") finish(true);
+ }, () => finish(false));
+ });
+}
+
async function captureTabScreenshot(tabId, params) {
const format = params.format || "png";
try {
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index 1917956..46be3d9 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -173,8 +173,67 @@ function formatChromeSnapshot(snapshot: any): string {
}
function formatIncludedSnapshotText(raw: unknown, text: string): string {
- const snapshot = raw && typeof raw === "object" ? (raw as { snapshot?: unknown }).snapshot : undefined;
- return snapshot ? `${text}\n\n${formatChromeSnapshot(snapshot)}` : text;
+ const payload = raw && typeof raw === "object"
+ ? (raw as { snapshot?: unknown; navigation?: { settled?: boolean; from?: string; to?: string; waitedMs?: number } })
+ : undefined;
+ const snapshot = payload?.snapshot;
+ const navigation = payload?.navigation;
+ let body = text;
+ if (navigation) {
+ body += navigation.settled === false
+ ? `\n⚠ The action started a navigation that had not finished after ${navigation.waitedMs ?? "?"}ms (${navigation.from || "?"} → ${navigation.to || "?"}). The snapshot below may describe the page being replaced; re-check with chrome_snapshot.`
+ : `\nNavigated ${navigation.from || "?"} → ${navigation.to || "?"} (waited ${navigation.waitedMs ?? "?"}ms for load).`;
+ }
+ return snapshot ? `${body}\n\n${formatChromeSnapshot(snapshot)}` : body;
+}
+
+// Pi-side text for chrome_type's before/after evidence. The worker returns valueBefore/valueAfter
+// (or valueRedacted), existingTextLengthBefore, and insertedAt.
+function describeTypeEvidence(result: unknown, typedLength: number, pressEnter: boolean): string[] {
+ if (!result || typeof result !== "object") return [];
+ const r = result as Record<string, unknown>;
+ const lines: string[] = [];
+ if (r.valueRedacted === true) {
+ lines.push(`Field value [redacted] (${r.existingTextLengthBefore ?? "?"} → ${r.valueLengthAfter ?? "?"} chars; insertedAt=${r.insertedAt ?? "unknown"}).`);
+ } else if (typeof r.valueBefore === "string" || typeof r.valueAfter === "string") {
+ const before = typeof r.valueBefore === "string" ? JSON.stringify(r.valueBefore) : "?";
+ const after = typeof r.valueAfter === "string" ? JSON.stringify(r.valueAfter) : "?";
+ lines.push(`Field went from ${before} to ${after}${r.insertedAt ? ` (insertedAt=${r.insertedAt})` : ""}.`);
+ }
+ const unchanged = typedLength > 0 && r.replaced !== true
+ && typeof r.existingTextLengthBefore === "number" && r.existingTextLengthBefore === r.valueLengthAfter
+ && (r.valueRedacted === true || r.valueBefore === r.valueAfter);
+ if (unchanged) {
+ lines.push("\u26a0 The field value did not change: the keystrokes did not reach this field. It may not have keyboard focus (background/hidden tabs often cannot take focus); focus it with a uid/selector click, or ask the user to run /chrome background off, then verify.");
+ } else if (r.insertedAt === "caret-middle" && typedLength > 0) {
+ lines.push("⚠ Text was spliced into the middle of existing content; chrome_type does NOT replace. Use chrome_fill (or chrome_type replace=true) to replace a field's value.");
+ if (pressEnter) lines.push("⚠ If Enter submitted the form, it submitted the spliced value above, not just your text.");
+ } else if (r.insertedAt === "caret-end" && typedLength > 0) {
+ lines.push("Note: text was appended to existing content (chrome_type does not replace).");
+ }
+ return lines;
+}
+
+// Keep raw CDP payloads (screenshots, huge DOM dumps) out of the model context and transcript.
+const CDP_OVERSIZE_JSON_CHARS = 262_144;
+function formatCdpResult(method: string, value: unknown): ToolTextResult {
+ const data = value && typeof value === "object" ? (value as { data?: unknown }).data : undefined;
+ if (typeof data === "string" && (/captureScreenshot|printToPDF|screencast/i.test(method) || data.length >= CDP_OVERSIZE_JSON_CHARS)) {
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
+ const bytes = Math.floor((data.length / 4) * 3) - padding;
+ const fields = Object.keys(value as object).filter((key) => key !== "data");
+ const text = `CDP ${method} returned ~${bytes} bytes in its "data" field; the payload was omitted to protect the context window. Use chrome_screenshot to save images to disk.${fields.length ? ` Other fields: ${fields.join(", ")}.` : ""}`;
+ return { content: [{ type: "text", text }], details: { value: { omitted: "data-field", bytes, fields } } };
+ }
+ const text = value === undefined ? "undefined" : typeof value === "string" ? value : (safeJson(value) ?? "undefined");
+ if (text.length > CDP_OVERSIZE_JSON_CHARS) {
+ const fields = value && typeof value === "object" ? Object.keys(value as object) : [];
+ return {
+ content: [{ type: "text", text: `${truncateText(text)}\n\n[details omitted: ${text.length} chars of JSON]` }],
+ details: { value: { omitted: "oversized-result", chars: text.length, fields } },
+ };
+ }
+ return { content: [{ type: "text", text: truncateText(text) }], details: { value } };
}
function formatChromeInspect(inspect: any): string {
@@ -339,6 +398,31 @@ class ChromeProfileBridge {
await this.bindServerOrClient();
}
+ // A client-mode session (another Pi session or a subagent owns the port) never sees extension
+ // polls itself, so its local status always reads "not connected". Ask the owner instead.
+ async connectionStatus(): Promise<Record<string, unknown>> {
+ const local = this.status();
+ if (this.mode !== "client") return local;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 1_000);
+ try {
+ const response = await fetch(`${this.url}/status`, { signal: controller.signal });
+ if (!response.ok) return local;
+ const owner = (await response.json()) as Record<string, unknown>;
+ return {
+ ...local,
+ connected: owner.connected === true,
+ lastSeenAt: typeof owner.lastSeenAt === "number" ? owner.lastSeenAt : local.lastSeenAt,
+ clientName: typeof owner.clientName === "string" ? owner.clientName : local.clientName,
+ ownerMode: owner.mode,
+ };
+ } catch {
+ return local;
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+
// Try to own the bridge port. On success we are the server; on EADDRINUSE another Pi
// session owns it and we run as a client that forwards commands to that owner.
private async bindServerOrClient(): Promise<void> {
@@ -643,6 +727,8 @@ const CHROME_TOOL_NAMES = [
"chrome_launch",
"chrome_tab",
"chrome_snapshot",
+ "chrome_find",
+ "chrome_inspect",
"chrome_navigate",
"chrome_evaluate",
"chrome_click",
@@ -659,6 +745,8 @@ const CHROME_TOOL_NAMES = [
"chrome_tap",
"chrome_scroll",
"chrome_upload_file",
+ "chrome_cdp",
+ "chrome_cdp_targets",
] as const;
const CHROME_TOOL_NAME_SET = new Set<string>(CHROME_TOOL_NAMES);
@@ -674,16 +762,19 @@ export default function (pi: ExtensionAPI): void {
[PI_CHROME_AUTH_KEY]?: { until: number | "indefinite" };
};
const alreadyLoaded = globalState[PI_CHROME_GLOBAL_KEY];
- if (alreadyLoaded?.token || (alreadyLoaded && alreadyLoaded.root !== currentRoot)) {
+ // Only a *different* install root (two copies of pi-chrome) is a duplicate. A same-root re-entry is
+ // legitimate: subagent sessions (e.g. pi-subagents) load extensions into their own runner in this
+ // process, so this factory runs once per session. Skipping it left subagents with no chrome_* tools.
+ // Each instance gets its own bridge; the second binds as a client of the port owner (EADDRINUSE),
+ // so all sessions share the one Chrome connector. Stale flags from older releases (<=0.15.19) that
+ // point at this same root are harmless for the same reason.
+ if (alreadyLoaded && alreadyLoaded.root !== currentRoot) {
console.warn(
`pi-chrome already loaded from ${alreadyLoaded.root} (v${alreadyLoaded.version}); skipping duplicate from ${currentRoot}.`,
);
return;
}
- // pi-chrome <=0.15.19 set the singleton flag but did not clear it on reload.
- // If the stale flag points at this same extension root, replace it instead of
- // skipping the freshly reloaded extension.
- globalState[PI_CHROME_GLOBAL_KEY] = { version: PI_CHROME_VERSION, root: currentRoot, token: instanceToken };
+ if (!alreadyLoaded) globalState[PI_CHROME_GLOBAL_KEY] = { version: PI_CHROME_VERSION, root: currentRoot, token: instanceToken };
const bridge = new ChromeProfileBridge(DEFAULT_HOST, DEFAULT_PORT);
let backgroundEnabled = true;
@@ -904,11 +995,11 @@ export default function (pi: ExtensionAPI): void {
if ((action === "tab.new" || action === "tab.group") && sessionTitle !== undefined) {
wireParams = { ...wireParams, groupTitle: sessionTitle };
}
- // Any tab Pi *uses* (page.* interactions) should join this session's group, mirroring the
+ // Any tab Pi *uses* (page.* interactions and raw cdp.call) should join this session's group, mirroring the
// auto-grouping that tab.new already does. Tagging the wire params lets getTabByParams pull
- // the resolved tab into the session group on the service-worker side. We skip tab.* actions:
+ // the resolved tab into the session group on the service-worker side. We skip tab.* and cdp.targets actions:
// tab.new/group are forced above, and activate/close/ungroup/list must not group tabs.
- const shouldJoinGroup = action.startsWith("page.") && sessionTitle !== undefined && params.sessionGroupTitle === undefined;
+ const shouldJoinGroup = (action.startsWith("page.") || action === "cdp.call") && sessionTitle !== undefined && params.sessionGroupTitle === undefined;
if (shouldJoinGroup) {
wireParams = { ...wireParams, sessionGroupTitle: sessionTitle, joinSessionGroup: true };
}
@@ -974,7 +1065,9 @@ Capability model (important):
- Interactive controls (click/type/fill/key/hover/drag/scroll/tap) use Chrome's real input layer via chrome.debugger / CDP. Events satisfy normal user-activation gates.
- Input bypasses page CSP because it is injected at browser input layer, not page JavaScript. Chrome may show the “Pi Chrome Connector started debugging this browser” banner while attached.
- \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via **CDP \`Runtime.evaluate\`**, which is not subject to the page's Content-Security-Policy. They work even on strict-CSP pages (e.g. github.com, many bank/SaaS apps) that block \`'unsafe-eval'\`. \`chrome_navigate initScript\` likewise injects at document_start via CDP and bypasses CSP. \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input also work under any CSP.
-- 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.
+- 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\` inserts at the caret and never replaces; check its before/after report. Use \`chrome_fill\` to replace a field.
+- \`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.
Usage rules:
1. If a chrome_* tool says Chrome control is locked, ask the user to run \`/chrome authorize\` before retrying.
@@ -1311,9 +1404,10 @@ Usage rules:
headless: Type.Optional(Type.Boolean({ description: "Ignored." })),
}),
async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
- if (params.url && bridge.connected) {
+ const status = await bridge.connectionStatus();
+ if (params.url && status.connected === true) {
const result = await authorizedBridgeSend("tab.new", { url: params.url }, DEFAULT_TIMEOUT_MS, signal);
- return { content: [{ type: "text", text: `Chrome bridge connected; opened ${params.url}` }], details: { status: bridge.status(), result } };
+ return { content: [{ type: "text", text: `Chrome bridge connected; opened ${params.url}` }], details: { status, result } };
}
return {
content: [
@@ -1326,10 +1420,10 @@ Usage rules:
`2. Enable Developer mode.\n` +
`3. Click “Load unpacked”.\n` +
`4. Select: ${browserExtensionPath()}\n\n` +
- `Status: ${bridge.connected ? "connected" : "waiting for extension"}.`,
+ `Status: ${status.connected === true ? "connected" : "waiting for extension"}.`,
},
],
- details: { status: bridge.status(), extensionPath: browserExtensionPath() },
+ details: { status, extensionPath: browserExtensionPath() },
};
},
});
@@ -1560,12 +1654,13 @@ Usage rules:
name: "chrome_type",
label: "Chrome Type",
description:
- "Focus an optional snapshot uid or CSS selector, then type using Chrome's real input. Contenteditables use one native text insertion; other fields use key events. Set perCharacter=true for editors needing individual keydown events. Pass includeSnapshot=true to verify after typing.",
- promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.",
+ "Focus an optional snapshot uid or CSS selector, then type at the caret using Chrome's real input. It does NOT replace existing text: in a non-empty field the text is inserted wherever the caret lands after the focus click. Use chrome_fill (or replace=true) to replace a field's value. Contenteditables use one native text insertion; other fields use key events. Set perCharacter=true for editors needing individual keydown events. The result reports the field value before/after and insertedAt (empty|caret-end|caret-middle|replaced-selection|replaced-all), with a warning when text was spliced into existing content. Pass includeSnapshot=true to verify after typing; if the action starts a navigation, the snapshot waits (up to 5s) for it to load.",
+ promptSnippet: "Type text at the caret in Chrome (does not replace existing text; use chrome_fill or replace=true).",
parameters: Type.Object({
text: Type.String(),
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
selector: Type.Optional(Type.String({ description: "CSS selector to focus before typing." })),
+ replace: Type.Optional(Type.Boolean({ default: false, description: "If true, select all of the focused field's contents and delete them before typing (real key events). Reports replaced:true." })),
perCharacter: Type.Optional(Type.Boolean({ default: false, description: "Send individual key events even in contenteditables. Default: one native text insertion for contenteditables; key events for other fields." })),
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after typing." })),
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
@@ -1582,9 +1677,9 @@ Usage rules:
const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
const summary = summarizeActionResult(result);
const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
- const base = `Typed ${params.text.length} character(s)${into}.`;
- const text = summary ? `${base} (${summary})` : base;
- return { content: [{ type: "text", text: formatIncludedSnapshotText(raw, text) }], details: { result: raw as Json } };
+ const base = `Typed ${params.text.length} character(s)${into}${params.replace ? " (replacing existing contents)" : ""}.`;
+ const lines = [summary ? `${base} (${summary})` : base, ...describeTypeEvidence(result, params.text.length, params.pressEnter === true)];
+ return { content: [{ type: "text", text: formatIncludedSnapshotText(raw, lines.join("\n")) }], details: { result: raw as Json } };
},
});
@@ -1592,7 +1687,7 @@ Usage rules:
name: "chrome_fill",
label: "Chrome Fill",
description:
- "Set the full value of a text input, textarea, or contenteditable using Chrome click/select/delete/type input. Contenteditables use one native text insertion; perCharacter=true retains individual keydown events. Accepts a snapshot uid or CSS selector. Pass includeSnapshot=true to verify after filling.",
+ "Replace the whole value of a text input, textarea, or contenteditable using Chrome click/select/delete/type input. Unlike chrome_type, existing contents are cleared first. Contenteditables use one native text insertion; perCharacter=true retains individual keydown events. Accepts a snapshot uid or CSS selector. Pass includeSnapshot=true to verify after filling.",
promptSnippet: "Fill a Chrome form field by snapshot uid or selector, optionally returning a fresh snapshot.",
parameters: Type.Object({
text: Type.String(),
@@ -1910,6 +2005,64 @@ Usage rules:
return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}` }], details: { result: result as Json } };
},
});
+
+ pi.registerTool({
+ name: "chrome_cdp",
+ label: "Chrome CDP Call",
+ description:
+ "Low-level escape hatch: run one Chrome DevTools Protocol (CDP) method against a tab (no target = this session's automation tab), e.g. Emulation.setDeviceMetricsOverride, Network.getCookies, DOM.getDocument, Page.printToPDF, Accessibility.getFullAXTree. Put CDP fields inside params. Nothing is filtered against a safe list: destructive methods run as given. Prefer dedicated chrome_* tools when they cover the task. Background mode blocks Page.bringToFront and Target.activateTarget, but other methods can still change what the user sees. Screenshot/binary payloads and results over 256 KiB are summarised instead of returned in full. Domain events are not streamed back; only the method's direct result is returned.",
+ promptSnippet: "Run a raw Chrome DevTools Protocol method against a Chrome tab (low-level escape hatch).",
+ parameters: Type.Object({
+ method: Type.String({ description: "CDP method name, e.g. \"Runtime.evaluate\" or \"Emulation.setDeviceMetricsOverride\"." }),
+ params: Type.Optional(Type.Object({}, { additionalProperties: true, description: "CDP parameter object for the method." })),
+ timeoutMs: Type.Optional(Type.Number({ description: "Deadline for the CDP command in milliseconds. Default 5000, max 120000. On timeout the debugger session is detached and the next call re-attaches." })),
+ targetId: Type.Optional(Type.String()),
+ urlIncludes: Type.Optional(Type.String()),
+ titleIncludes: Type.Optional(Type.String()),
+ background: Type.Optional(Type.Boolean({ description: BACKGROUND_PARAM_DESCRIPTION })),
+ }),
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ // Raw CDP fields passed at the top level would reach Chrome as params:{} and fail with an
+ // opaque "Invalid parameters". Point the caller at params instead.
+ const knownKeys = new Set(["method", "params", "timeoutMs", "targetId", "urlIncludes", "titleIncludes", "background", "foreground", "host", "port"]);
+ const unknownKeys = Object.keys(params).filter((key) => !knownKeys.has(key));
+ if (unknownKeys.length > 0) {
+ throw new Error(`chrome_cdp received unknown top-level parameter(s): ${unknownKeys.join(", ")}. Put CDP fields inside "params", e.g. { method: "Runtime.evaluate", params: { expression: "1+1" } }.`);
+ }
+ // Keep the bridge deadline above the worker's (CDP deadline + 5s grace) so the precise
+ // "CDP <method> timed out" error wins.
+ const requested = Number(params.timeoutMs);
+ const bridgeTimeoutMs = Number.isFinite(requested) && requested > 0
+ ? Math.max(DEFAULT_TIMEOUT_MS, Math.min(Math.floor(requested), 120_000) + 10_000)
+ : DEFAULT_TIMEOUT_MS;
+ const value = await authorizedBridgeSend("cdp.call", params, bridgeTimeoutMs, signal);
+ return formatCdpResult(params.method, value);
+ },
+ });
+
+ pi.registerTool({
+ name: "chrome_cdp_targets",
+ label: "Chrome CDP Targets",
+ description:
+ "List Chrome DevTools Protocol targets attached to a tab (type, url, attached, extensionId). Use it to diagnose chrome_* / chrome_cdp failures such as \"Detached while handling command\": password-manager/autofill overlays and DevTools front-ends show up here. Targets on other tabs are only counted. Does not attach the debugger or create an automation tab; with no target it reports this session's automation tab if one exists.",
+ promptSnippet: "List CDP targets (including foreign extension overlays) attached to a Chrome tab.",
+ 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("cdp.targets", params, DEFAULT_TIMEOUT_MS, signal);
+ const result = value as { tab?: { id?: number; title?: string; url?: string } | null; targets?: Array<{ type?: string; attached?: boolean; url?: string; extensionId?: string }>; otherTabTargetCount?: number } | undefined;
+ const targets = result?.targets ?? [];
+ const text = [
+ result?.tab ? `Tab ${result.tab.id}: ${result.tab.title || "(untitled)"} \u2014 ${result.tab.url ?? ""}` : "No resolved tab (pass targetId/urlIncludes/titleIncludes, or run chrome_navigate first).",
+ `${targets.length} CDP target(s) on this tab${result?.otherTabTargetCount ? ` (${result.otherTabTargetCount} on other tabs, not listed)` : ""}:`,
+ ...targets.map((t) => `- ${t.type ?? "?"}\t${t.attached ? "attached" : "detached"}\t${t.extensionId ? `ext=${t.extensionId}\t` : ""}${t.url ?? ""}`),
+ ].join("\n");
+ return { content: [{ type: "text", text: truncateText(text) }], details: { value: (value ?? null) as Json } };
+ },
+ });
}
}