aboutsummaryrefslogtreecommitdiff
path: root/extensions
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-05-14 22:09:07 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-05-14 22:09:07 -0400
commita13b6e4b51daf687e0cb16ef8fbc7040fde415a9 (patch)
tree56309e6cf89c98b428fa173bd785ef97bab5b9be /extensions
parent815fd19c72f7a5a57f87eb0debe50194588e0202 (diff)
Restore terminal Chrome authorization
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.js230
-rw-r--r--extensions/chrome-profile-bridge/index.ts504
3 files changed, 339 insertions, 397 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json
index d998d19..4e0d8ac 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.1",
+ "version": "0.15.15",
"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 36e5b20..08d712b 100644
--- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js
+++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
@@ -3,64 +3,23 @@ const CLIENT_NAME = `Pi Chrome Connector ${chrome.runtime.id}`;
const POLL_ERROR_BACKOFF_MS = 2000;
let polling = false;
-// =================== Trusted-input (CDP) layer ===================
-// Tracks which tabs we have attached chrome.debugger to, plus session-level mode.
+// =================== Chrome input (CDP) layer ===================
+// Tracks which tabs we have attached chrome.debugger to.
const attachedTabs = new Map(); // tabId -> { detachAt: number, pointer: {x,y} }
-let TRUSTED_MODE = "auto"; // "off" | "on" | "auto" (default: smart retry only)
-const TRUSTED_IDLE_DETACH_MS = 15_000;
+const INPUT_IDLE_DETACH_MS = 15_000;
const CDP_VERSION = "1.3";
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
function rng(min, max) { return min + Math.random() * (max - min); }
-async function wantsTrusted(params) {
- if (params && params.trusted === false) return false;
- if (params && params.trusted === true) return true;
- return TRUSTED_MODE === "on";
-}
-
-function setTrustedMode(mode) {
- const next = String(mode || "").toLowerCase();
- if (!["off", "on", "auto"].includes(next)) throw new Error(`bad trusted mode: ${next}`);
- TRUSTED_MODE = next;
- if (next === "off") void detachAll();
- return { mode: TRUSTED_MODE };
-}
-
-function trustedStatus() {
+function inputStatus() {
return {
- mode: TRUSTED_MODE,
attachedTabs: Array.from(attachedTabs.keys()),
permissionGranted: typeof chrome !== "undefined" && !!chrome.debugger,
};
}
-// Auto-upgrade: if synthetic result carries suggestTrusted=true, the bridge mode is "auto"
-// (default) or "on", and the caller didn't explicitly opt out, retry once with trusted CDP
-// path. Surfaces both results so callers can see what happened.
-async function maybeUpgradeToTrusted(kind, params, syntheticResult, trustedFn) {
- if (!syntheticResult || !syntheticResult.suggestTrusted) return syntheticResult;
- if (params && params.trusted === false) return syntheticResult;
- if (TRUSTED_MODE === "off") return syntheticResult;
- if (!chrome.debugger) return syntheticResult;
- try {
- const trustedResult = await trustedFn();
- return {
- ...trustedResult,
- autoRetried: true,
- autoRetryReason: syntheticResult.suggestReason || `${kind} produced no mutation`,
- syntheticAttempt: { pageMutated: syntheticResult.pageMutated, suggestReason: syntheticResult.suggestReason },
- };
- } catch (error) {
- return {
- ...syntheticResult,
- autoRetryAttempted: true,
- autoRetryError: error?.message || String(error),
- };
- }
-}
-
-// Last few attach failures, kept for /chrome doctor + trusted.debug diagnostics.
+// Last few attach failures, kept for diagnostics.
const attachDebugLog = [];
function recordAttachEvent(entry) {
attachDebugLog.push({ ...entry, t: Date.now() });
@@ -71,7 +30,7 @@ async function attachDebugger(tabId) {
if (!chrome.debugger) throw new Error("chrome.debugger API unavailable; reload the extension to grant the new permission");
if (attachedTabs.has(tabId)) {
const entry = attachedTabs.get(tabId);
- entry.detachAt = Date.now() + TRUSTED_IDLE_DETACH_MS;
+ entry.detachAt = Date.now() + INPUT_IDLE_DETACH_MS;
return entry;
}
// Before each attach, force-detach any stale CDP target this extension owns on the tab.
@@ -123,19 +82,18 @@ async function attachDebugger(tabId) {
}
recordAttachEvent({ kind: "attached", tabId });
// Seed pointer in a plausible "just left the address bar" location.
- const entry = { detachAt: Date.now() + TRUSTED_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } };
+ const entry = { detachAt: Date.now() + INPUT_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } };
attachedTabs.set(tabId, entry);
return entry;
}
-async function trustedDebug(params) {
+async function inputDebug(params) {
const tab = params?.targetId ? await chrome.tabs.get(Number(params.targetId)).catch(() => null) : null;
let targets = [];
try { targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))); } catch {}
return {
extensionVersion: chrome.runtime.getManifest().version,
extensionId: chrome.runtime.id,
- trustedMode: TRUSTED_MODE,
attachedTabs: Array.from(attachedTabs.keys()),
requestedTab: tab ? { id: tab.id, url: tab.url, status: tab.status, title: tab.title } : null,
cdpTargets: targets,
@@ -158,7 +116,7 @@ if (chrome.debugger && chrome.debugger.onDetach) {
chrome.debugger.onDetach.addListener(({ tabId }, reason) => {
if (tabId !== undefined) attachedTabs.delete(tabId);
if (reason === "canceled_by_user") {
- console.warn(`[pi-chrome] debugger canceled by user on tab ${tabId}; trusted mode will reattach on next call`);
+ console.warn(`[pi-chrome] debugger canceled by user on tab ${tabId}; Chrome input will reattach on next call`);
}
});
}
@@ -166,7 +124,7 @@ if (chrome.debugger && chrome.debugger.onDetach) {
setInterval(() => {
const now = Date.now();
for (const [tabId, entry] of attachedTabs) {
- if (entry.detachAt && entry.detachAt < now && TRUSTED_MODE !== "on") {
+ if (entry.detachAt && entry.detachAt < now) {
void detachDebugger(tabId);
}
}
@@ -245,7 +203,7 @@ async function cdp(tabId, method, params) {
const id = extractForeignExtId(after) || extractForeignExtId(before) || "unknown";
throw new Error(
`Another Chrome extension (${id}) has an input overlay on this page (e.g. a password manager / autofill popup). \n` +
- `pi-chrome tried to dismiss it with Escape but it reappeared. Disable that extension on this page, focus the field via Tab instead of clicking, or run /chrome quiet off so the agent uses synthetic input here.`,
+ `pi-chrome tried to dismiss it with Escape but it reappeared. Disable that extension on this page, close its popup, or focus the field via Tab instead of clicking.`,
);
}
throw retryErr;
@@ -254,7 +212,7 @@ async function cdp(tabId, method, params) {
if (!isStale) throw error;
attachedTabs.delete(tabId);
await chrome.debugger.attach({ tabId }, CDP_VERSION).catch(() => undefined);
- attachedTabs.set(tabId, { detachAt: Date.now() + TRUSTED_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } });
+ attachedTabs.set(tabId, { detachAt: Date.now() + INPUT_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } });
return cdpRaw(tabId, method, params);
}
}
@@ -280,7 +238,7 @@ async function resolveTargetInTab(tabId, params) {
args: [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null],
});
const v = results?.[0]?.result;
- if (!v || !v.found) throw new Error("Could not resolve target element for trusted action");
+ if (!v || !v.found) throw new Error("Could not resolve target element for Chrome input");
return v;
}
@@ -378,7 +336,7 @@ async function cdpTypeChar(tabId, ch) {
await sleep(rng(35, 130));
}
-async function trustedClick(params) {
+async function chromeInputClick(params) {
const tab = await getTabByParams(params);
if (params.foreground) await bringToFront(tab);
await attachDebugger(tab.id);
@@ -390,7 +348,7 @@ async function trustedClick(params) {
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
// Reset :focus-visible if the click landed on a focusable element. CDP-driven pointer
// focus can leave :focus-visible=true in Chromium, which trips heuristics that expect
- // pointer focus to suppress the focus ring (synthetic clicks naturally land on false).
+ // Reset focus styling after pointer click when possible.
if (params.selector || params.uid) {
await chrome.scripting.executeScript({
target: { tabId: tab.id, frameIds: [0] },
@@ -407,10 +365,10 @@ async function trustedClick(params) {
args: [params.selector ?? null, params.uid ?? null],
}).catch(() => undefined);
}
- return { trusted: true, x: point.x, y: point.y, tag: resolved.tag };
+ return { input: "chrome", x: point.x, y: point.y, tag: resolved.tag };
}
-async function trustedHover(params) {
+async function chromeInputHover(params) {
const tab = await getTabByParams(params);
if (params.foreground) await bringToFront(tab);
await attachDebugger(tab.id);
@@ -418,15 +376,15 @@ async function trustedHover(params) {
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
await cdpMoveTo(tab.id, point.x, point.y);
await sleep(rng(80, 220));
- return { trusted: true, x: point.x, y: point.y, tag: resolved.tag };
+ return { input: "chrome", x: point.x, y: point.y, tag: resolved.tag };
}
-async function trustedKey(params) {
+async function chromeInputKey(params) {
const tab = await getTabByParams(params);
if (params.foreground) await bringToFront(tab);
await attachDebugger(tab.id);
const key = String(params.key || "");
- if (!key) throw new Error("trusted.key: missing key");
+ if (!key) throw new Error("chrome.key: missing key");
const mods = params.modifiers || {};
const modBits = cdpModifiersFor(mods);
// Press modifiers in standard order, then key, then release in reverse.
@@ -456,10 +414,10 @@ async function trustedKey(params) {
await sleep(rng(5, 18));
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: m.key, code: m.code, windowsVirtualKeyCode: m.vk, modifiers: 0 });
}
- return { trusted: true, key: info.key, modifiers: mods };
+ return { input: "chrome", key: info.key, modifiers: mods };
}
-async function trustedType(params) {
+async function chromeInputType(params) {
const tab = await getTabByParams(params);
if (params.foreground) await bringToFront(tab);
await attachDebugger(tab.id);
@@ -477,16 +435,16 @@ async function trustedType(params) {
for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
if (params.pressEnter) {
await cdpTypeChar(tab.id, "\r").catch(() => undefined);
- await trustedKey({ ...params, key: "Enter" });
+ await chromeInputKey({ ...params, key: "Enter" });
}
- return { trusted: true, length: text.length };
+ return { input: "chrome", length: text.length };
}
-async function trustedFill(params) {
+async function chromeInputFill(params) {
const tab = await getTabByParams(params);
if (params.foreground) await bringToFront(tab);
await attachDebugger(tab.id);
- if (!(params.selector || params.uid)) throw new Error("trusted.fill: selector or uid required");
+ if (!(params.selector || params.uid)) throw new Error("chrome.fill: selector or uid required");
const resolved = await resolveTargetInTab(tab.id, params);
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
await cdpMoveTo(tab.id, point.x, point.y);
@@ -503,11 +461,11 @@ async function trustedFill(params) {
await sleep(rng(20, 60));
const text = String(params.text || "");
for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
- if (params.submit) await trustedKey({ ...params, key: "Enter" });
- return { trusted: true, length: text.length };
+ if (params.submit) await chromeInputKey({ ...params, key: "Enter" });
+ return { input: "chrome", length: text.length };
}
-async function trustedScroll(params) {
+async function chromeInputScroll(params) {
const tab = await getTabByParams(params);
if (params.foreground) await bringToFront(tab);
await attachDebugger(tab.id);
@@ -554,26 +512,26 @@ async function trustedScroll(params) {
// Sleep one+ frame so IntersectionObserver / rAF samples can run between events.
await sleep(rng(22, 48));
}
- return { trusted: true, deltaX: totalX, deltaY: totalY, steps: n };
+ return { input: "chrome", deltaX: totalX, deltaY: totalY, steps: n };
}
-async function trustedTap(params) {
+async function chromeInputTap(params) {
const tab = await getTabByParams(params);
if (params.foreground) await bringToFront(tab);
await attachDebugger(tab.id);
const resolved = (params.selector || params.uid || (typeof params.x === "number" && typeof params.y === "number"))
? await resolveTargetInTab(tab.id, params)
: null;
- if (!resolved || !resolved.found) throw new Error("trusted.tap: target not found");
+ if (!resolved || !resolved.found) throw new Error("chrome.tap: target not found");
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
const tp = { x: point.x, y: point.y, radiusX: 8, radiusY: 8, rotationAngle: 0, force: 0.5, id: 1 };
await cdp(tab.id, "Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [tp] });
await sleep(rng(40, 110));
await cdp(tab.id, "Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
- return { trusted: true, x: point.x, y: point.y, tag: resolved.tag };
+ return { input: "chrome", x: point.x, y: point.y, tag: resolved.tag };
}
-async function trustedDrag(params) {
+async function chromeInputDrag(params) {
const tab = await getTabByParams(params);
if (params.foreground) await bringToFront(tab);
await attachDebugger(tab.id);
@@ -595,7 +553,40 @@ async function trustedDrag(params) {
await sleep(rng(10, 26));
}
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: tp.x, y: tp.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
- return { trusted: true, from: fp, to: tp, steps };
+ return { input: "chrome", from: fp, to: tp, steps };
+}
+
+async function chromeInputUpload(params) {
+ const tab = await getTabByParams(params);
+ if (params.foreground) await bringToFront(tab);
+ await attachDebugger(tab.id);
+ if (!(params.selector || params.uid)) throw new Error("chrome.upload: selector or uid required");
+ const paths = Array.isArray(params.paths) ? params.paths.map(String) : [];
+ if (!paths.length) throw new Error("chrome.upload: no file paths provided");
+ const expression = `(() => {
+ const selector = ${JSON.stringify(params.selector ?? null)};
+ const uid = ${JSON.stringify(params.uid ?? null)};
+ const state = window.__PI_CHROME_STATE__;
+ const el = uid && state && state.elements ? state.elements[uid] : (selector ? document.querySelector(selector) : null);
+ if (!el || el.tagName !== "INPUT" || el.type !== "file") throw new Error("Target must be <input type=file>");
+ el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
+ return el;
+ })()`;
+ const evaluated = await cdp(tab.id, "Runtime.evaluate", { expression, objectGroup: "pi-chrome-upload", includeCommandLineAPI: false, returnByValue: false });
+ if (evaluated.exceptionDetails) throw new Error(evaluated.exceptionDetails.text || "Could not resolve file input");
+ const objectId = evaluated.result?.objectId;
+ if (!objectId) throw new Error("Could not resolve file input object");
+ await cdp(tab.id, "DOM.enable", {}).catch(() => undefined);
+ const requested = await cdp(tab.id, "DOM.requestNode", { objectId });
+ if (!requested.nodeId) throw new Error("Could not resolve file input node");
+ await cdp(tab.id, "DOM.setFileInputFiles", { nodeId: requested.nodeId, files: paths });
+ await cdp(tab.id, "Runtime.callFunctionOn", {
+ objectId,
+ functionDeclaration: `function() { this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return this.files ? this.files.length : 0; }`,
+ returnByValue: true,
+ }).catch(() => undefined);
+ await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined);
+ return { input: "chrome", uploaded: paths.map((path) => ({ path })) };
}
// ===============================================================
@@ -720,41 +711,28 @@ async function dispatch(action, params) {
]);
case "page.evaluate":
return evaluateInTab(params);
- case "page.click": {
- if (await wantsTrusted(params)) return trustedClick(params);
- const synth = await executeActionInTab(params, clickPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
- return await maybeUpgradeToTrusted("click", params, synth, () => trustedClick(params));
- }
+ case "page.click":
+ return chromeInputClick(params);
case "page.hover":
- if (await wantsTrusted(params)) return trustedHover(params);
- return executeActionInTab(params, hoverPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
+ return chromeInputHover(params);
case "page.drag":
- if (await wantsTrusted(params)) return trustedDrag(params);
- return executeActionInTab(params, dragPage, [params.fromUid ?? null, params.fromSelector ?? null, params.fromX ?? null, params.fromY ?? null, params.toUid ?? null, params.toSelector ?? null, params.toX ?? null, params.toY ?? null, params.steps ?? 12]);
+ return chromeInputDrag(params);
case "page.upload":
- return executeActionInTab(params, uploadFiles, [params.selector ?? null, params.uid ?? null, params.files || []]);
- case "page.type": {
- if (await wantsTrusted(params)) return trustedType(params);
- const synth = await executeActionInTab(params, typeIntoPage, [params.selector ?? null, params.uid ?? null, params.text || "", Boolean(params.pressEnter)]);
- return await maybeUpgradeToTrusted("type", params, synth, () => trustedType(params));
- }
+ return chromeInputUpload(params);
+ case "page.type":
+ return chromeInputType(params);
case "page.fill":
- if (await wantsTrusted(params)) return trustedFill(params);
- return executeActionInTab(params, fillPage, [params.selector ?? null, params.uid ?? null, params.text || "", params.submit === true]);
+ return chromeInputFill(params);
case "page.key":
- if (await wantsTrusted(params)) return trustedKey(params);
- return executeActionInTab(params, pressKeyInPage, [params.key]);
+ return chromeInputKey(params);
case "page.scroll":
- if (await wantsTrusted(params)) return trustedScroll(params);
- return executeActionInTab(params, scrollPage, [params.selector ?? null, params.uid ?? null, params.deltaY ?? 0, params.deltaX ?? 0, params.steps ?? null]);
+ return chromeInputScroll(params);
case "page.tap":
- return trustedTap(params);
- case "trusted.mode":
- return setTrustedMode(params.mode);
- case "trusted.status":
- return trustedStatus();
- case "trusted.debug":
- return trustedDebug(params);
+ return chromeInputTap(params);
+ case "input.status":
+ return inputStatus();
+ case "input.debug":
+ return inputDebug(params);
case "page.console.list":
return executeInTab(params, listConsoleMessages, [params.clear === true]);
case "page.network.list":
@@ -1503,31 +1481,31 @@ async function clickPage(selector, uid, x, y) {
defaultPrevented = dispatchPointerLikeEvent(point.element, "click", point.x, point.y, prevX, prevY) || defaultPrevented;
state.pointer = { x: point.x, y: point.y, t: performance.now() };
// Heuristic: if the clicked thing looks like a media play affordance and the page has paused
- // audio/video, the synthetic click may not unlock autoplay. Surface a warning.
+ // audio/video, the DOM-event click may not unlock autoplay. Surface a warning.
let autoplayHint;
const labelRaw = (point.element.getAttribute("aria-label") || point.element.textContent || "").trim();
const label = labelRaw.toLowerCase();
if (/^(play|start|begin|next|continue|unmute)/.test(label)) {
const idleMedia = Array.from(document.querySelectorAll("audio,video")).some((m) => m.paused);
- if (idleMedia) autoplayHint = "This element looks like a media affordance and the page has paused media. Synthetic clicks do not satisfy user-activation gates; audio/video may not start.";
+ if (idleMedia) autoplayHint = "This element looks like a media affordance and the page has paused media. DOM-event clicks do not satisfy user-activation gates; audio/video may not start.";
}
const pageMutated = pageHash() !== before;
- // Smart-auto retry hint: only set when synthetic produced no observable change AND the
+ // Smart-auto retry hint: only set when DOM-event path produced no observable change AND the
// element looks gated, OR the page just emitted a user-activation rejection. The dispatcher
- // uses this to decide whether to retry with trusted mode.
- let suggestTrusted = false;
+ // uses this to decide whether to retry with Chrome input.
+ let suggestChromeInput = false;
let suggestReason;
if (!pageMutated) {
- if (autoplayHint) { suggestTrusted = true; suggestReason = "play/media affordance + idle media"; }
+ if (autoplayHint) { suggestChromeInput = true; suggestReason = "play/media affordance + idle media"; }
else if (/copy(\s|$)|paste|share|download|fullscreen|sign in with|continue with|allow|enable/i.test(label)) {
- suggestTrusted = true; suggestReason = `label '${labelRaw.slice(0, 40)}' looks gated`;
+ suggestChromeInput = true; suggestReason = `label '${labelRaw.slice(0, 40)}' looks gated`;
} else {
// Inspect recent console errors for activation-gate rejections.
const recent = (state.console || []).slice(-8);
const hit = recent.find((e) => /NotAllowedError|Document is not focused|requires transient activation|gesture is required/.test(
(e.args || []).map((a) => typeof a === "string" ? a : (a && a.message) || JSON.stringify(a)).join(" ")
));
- if (hit) { suggestTrusted = true; suggestReason = "recent console error indicates user-activation gate"; }
+ if (hit) { suggestChromeInput = true; suggestReason = "recent console error indicates user-activation gate"; }
}
}
return {
@@ -1537,13 +1515,13 @@ async function clickPage(selector, uid, x, y) {
uid,
tag: point.element.tagName,
label: labelRaw.slice(0, 80) || undefined,
- isTrusted: false,
+ input: "dom",
defaultPrevented,
elementVisible: visible,
occludedBy: occluded || undefined,
pageMutated,
autoplayHint,
- suggestTrusted: suggestTrusted || undefined,
+ suggestChromeInput: suggestChromeInput || undefined,
suggestReason,
};
}
@@ -1561,7 +1539,7 @@ async function hoverPage(selector, uid, x, y) {
}
// Small dwell so hover-intent handlers fire.
await sleepPage(rand(80, 220));
- return { x: point.x, y: point.y, selector, uid, tag: point.element.tagName, defaultPrevented, isTrusted: false };
+ return { x: point.x, y: point.y, selector, uid, tag: point.element.tagName, defaultPrevented, input: "dom" };
}
async function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector, toX, toY, steps) {
@@ -1627,7 +1605,7 @@ async function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector,
to: { x: to.x, y: to.y },
steps: n,
pageMutated: pageHash() !== before,
- note: "Synthetic drag with HTML5 DragEvent + shared DataTransfer. isTrusted is still false.",
+ note: "DOM-event drag with HTML5 DragEvent + shared DataTransfer.",
};
}
@@ -1677,7 +1655,7 @@ async function scrollPage(selector, uid, deltaY, deltaX, steps) {
deltaX: movedX, deltaY: movedY, steps: n,
scrollTop: target.scrollTop, scrollLeft: target.scrollLeft,
pageMutated: pageHash() !== before,
- isTrusted: false,
+ input: "dom",
};
}
@@ -1800,18 +1778,18 @@ async function typeIntoPage(selector, uid, text, pressEnter) {
const finalValue = "value" in element ? element.value : element.textContent;
const valueMatches = "value" in element ? element.value.includes(text) : (element.textContent || "").includes(text);
const pageMutated = pageHash() !== before;
- // Smart-auto retry hint when typing didn't land at all (e.g., editor blocks synthetic input).
- let suggestTrusted = false, suggestReason;
+ // Smart-auto retry hint when typing didn't land at all (e.g., editor blocks DOM-event input).
+ let suggestChromeInput = false, suggestReason;
if (text.length > 0 && initialValue === finalValue) {
- suggestTrusted = true;
- suggestReason = "value did not change — editor likely rejects synthetic input";
+ suggestChromeInput = true;
+ suggestReason = "value did not change — editor likely rejects DOM-event input";
}
return {
selector, uid, length: text.length, pressEnter,
- isTrusted: false,
+ input: "dom",
valueMatches,
pageMutated,
- suggestTrusted: suggestTrusted || undefined,
+ suggestChromeInput: suggestChromeInput || undefined,
suggestReason,
};
}
@@ -1836,7 +1814,7 @@ function fillPage(selector, uid, text, submit) {
if (submit) pressKeyInPage("Enter");
return {
selector, uid, length: String(text).length, submit,
- isTrusted: false,
+ input: "dom",
valueMatches: "value" in element ? element.value === String(text) : undefined,
pageMutated: pageHash() !== before,
};
@@ -1890,7 +1868,7 @@ async function pressKeyInPage(key) {
}
return {
key: normalized,
- isTrusted: false,
+ input: "dom",
defaultPrevented: down.defaultPrevented || up.defaultPrevented,
pageMutated: pageHash() !== before,
};
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index 16320bf..6763ad6 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -1,6 +1,4 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
-import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
-import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { existsSync, readFileSync, statSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
@@ -114,7 +112,7 @@ function summarizeActionResult(result: unknown): string | undefined {
parts.push(`occluded by <${o.tag ?? "?"}${o.id ? "#" + o.id : ""}>`);
}
if (r.valueMatches === false) parts.push("input value did not stick");
- if (r.autoplayHint) parts.push("autoplay-gated affordance — synthetic click may not start media");
+ if (r.autoplayHint) parts.push("autoplay-gated affordance");
return parts.length ? parts.join("; ") : undefined;
}
@@ -127,12 +125,32 @@ function readRequestBody(request: IncomingMessage): Promise<string> {
});
}
+function corsHeadersFor(request: IncomingMessage): Record<string, string> {
+ const origin = String(request.headers.origin ?? "");
+ if (!origin.startsWith("chrome-extension://")) return {};
+ return {
+ "access-control-allow-origin": origin,
+ "access-control-allow-methods": "GET,POST,OPTIONS",
+ "access-control-allow-headers": "content-type",
+ "access-control-expose-headers": "x-pi-chrome-version",
+ "vary": "origin",
+ };
+}
+
+function isBrowserOriginAllowed(request: IncomingMessage): boolean {
+ const origin = String(request.headers.origin ?? "");
+ if (origin) return origin.startsWith("chrome-extension://");
+ const secFetchSite = String(request.headers["sec-fetch-site"] ?? "");
+ return !secFetchSite || secFetchSite === "none" || secFetchSite === "same-origin";
+}
+
+function isLocalProcessRequest(request: IncomingMessage): boolean {
+ return !request.headers.origin && !request.headers["sec-fetch-site"];
+}
+
function sendJson(response: ServerResponse, status: number, body: unknown, extraHeaders?: Record<string, string>): void {
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
- "access-control-allow-origin": "*",
- "access-control-allow-methods": "GET,POST,OPTIONS",
- "access-control-allow-headers": "content-type",
"cache-control": "no-store",
...(extraHeaders ?? {}),
});
@@ -159,7 +177,7 @@ class ChromeProfileBridge {
get connected(): boolean {
// MV3 service workers can pause between polls/alarms. Treat a recent poll as
- // connected without sending a synthetic command; real chrome_* tool calls are
+ // connected without sending a probe command; real chrome_* tool calls are
// the authoritative end-to-end health check.
return this.lastSeenAt !== undefined && Date.now() - this.lastSeenAt < 5 * 60_000;
}
@@ -278,16 +296,25 @@ class ChromeProfileBridge {
}
private async handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
+ const url = new URL(request.url ?? "/", this.url);
+ const corsHeaders = corsHeadersFor(request);
if (request.method === "OPTIONS") {
- sendJson(response, 200, { ok: true });
+ if (!isBrowserOriginAllowed(request)) {
+ sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
+ return;
+ }
+ sendJson(response, 200, { ok: true }, corsHeaders);
return;
}
- const url = new URL(request.url ?? "/", this.url);
if (request.method === "GET" && url.pathname === "/status") {
sendJson(response, 200, this.status());
return;
}
if (request.method === "POST" && url.pathname === "/command") {
+ if (!isLocalProcessRequest(request)) {
+ sendJson(response, 403, { ok: false, error: "Chrome commands are accepted only from local Pi processes" });
+ return;
+ }
const body = JSON.parse(await readRequestBody(request)) as {
action?: string;
params?: Record<string, unknown>;
@@ -306,6 +333,10 @@ class ChromeProfileBridge {
return;
}
if (request.method === "GET" && url.pathname === "/next") {
+ if (!isBrowserOriginAllowed(request)) {
+ sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
+ return;
+ }
this.lastSeenAt = Date.now();
this.clientName = url.searchParams.get("name") ?? undefined;
let aborted = false;
@@ -334,23 +365,27 @@ class ChromeProfileBridge {
command
? { type: "command", command, expectedExtensionVersion: currentVersion }
: { type: "none", expectedExtensionVersion: currentVersion },
- { "x-pi-chrome-version": currentVersion },
+ { ...corsHeaders, "x-pi-chrome-version": currentVersion },
);
return;
}
if (request.method === "POST" && url.pathname === "/result") {
+ if (!isBrowserOriginAllowed(request)) {
+ sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
+ return;
+ }
this.lastSeenAt = Date.now();
const result = JSON.parse(await readRequestBody(request)) as BridgeResult;
const pending = this.pending.get(result.id);
if (!pending) {
- sendJson(response, 404, { ok: false, error: "unknown command id" });
+ sendJson(response, 404, { ok: false, error: "unknown command id" }, corsHeaders);
return;
}
clearTimeout(pending.timer);
this.pending.delete(result.id);
if (result.ok) pending.resolve(result.result);
else pending.reject(new Error(result.error ?? "Chrome extension command failed"));
- sendJson(response, 200, { ok: true });
+ sendJson(response, 200, { ok: true }, corsHeaders);
return;
}
sendJson(response, 404, { error: "not found" });
@@ -399,6 +434,34 @@ export default function (pi: ExtensionAPI): void {
const bridge = new ChromeProfileBridge(DEFAULT_HOST, DEFAULT_PORT);
let backgroundDefault = false;
+ let chromeAuthorizedUntil: number | "indefinite" | undefined;
+
+ const authSummary = (): string => {
+ if (chromeAuthorizedUntil === "indefinite") return "authorized indefinitely";
+ if (typeof chromeAuthorizedUntil === "number") {
+ const remainingMs = chromeAuthorizedUntil - Date.now();
+ if (remainingMs > 0) return `authorized for ~${Math.ceil(remainingMs / 60_000)}m`;
+ }
+ return "locked";
+ };
+
+ const chromeControlAuthorized = (): boolean => {
+ if (chromeAuthorizedUntil === "indefinite") return true;
+ if (typeof chromeAuthorizedUntil === "number" && chromeAuthorizedUntil > Date.now()) return true;
+ chromeAuthorizedUntil = undefined;
+ return false;
+ };
+
+ const requireChromeControlAuthorized = (): void => {
+ if (!chromeControlAuthorized()) {
+ throw new Error("Chrome control locked. Ask the user to run /chrome authorize before using chrome_* tools.");
+ }
+ };
+
+ const authorizedBridgeSend = (action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> => {
+ requireChromeControlAuthorized();
+ return bridge.send(action, params, timeoutMs);
+ };
// Translate the public `background` parameter (default false = visible/foreground) into the
// service worker's wire-level `foreground` flag, accepting legacy `foreground` as a fallback.
@@ -420,8 +483,8 @@ export default function (pi: ExtensionAPI): void {
ctx.ui.setStatus("chrome", `Chrome bridge :${DEFAULT_PORT}`);
ctx.ui.notify(
status.mode === "client"
- ? `pi-chrome connected (sharing the Chrome connection an earlier pi session opened).`
- : `pi-chrome is ready and waiting for the Chrome companion to connect. Run /chrome onboard to install it.`,
+ ? `pi-chrome connected (sharing the Chrome connection an earlier pi session opened). Run /chrome authorize before using chrome_* tools.`
+ : `pi-chrome is ready and waiting for the Chrome companion to connect. Run /chrome onboard to install it, then /chrome authorize to allow chrome_* tools.`,
"info",
);
});
@@ -436,19 +499,20 @@ export default function (pi: ExtensionAPI): void {
Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile. Tools target the existing signed-in profile, no CDP, no throwaway profile.
Capability model (important):
-- Default input path is **synthetic DOM events** (\`isTrusted=false\`). Synthetic events drive React/Vue/Angular state fine, but they do NOT satisfy Chrome's user-activation gates: audio/video autoplay, clipboard write, file pickers, fullscreen, and Web Push prompts will NOT open from a synthetic chrome_click.
-- **Trusted escape hatch**: chrome_click / chrome_type / chrome_key / chrome_fill / chrome_hover / chrome_drag / chrome_scroll all accept \`trusted: true\`, which dispatches through chrome.debugger / CDP. Trusted events are browser-trusted (\`isTrusted=true\`) and **bypass page CSP entirely** because they're injected at the input layer, not via JS. Default mode is \`auto\`: synthetic first, silent CDP retry only when the click looks gated. If a synthetic click/type produced no \`pageMutated\` or you got a CSP/eval error from chrome_evaluate, escalate to \`trusted: true\` yourself — don't ask the user.
-- \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via the **Function constructor**, which requires \`'unsafe-eval'\` in the page CSP. Pages with strict CSP (e.g. github.com, many bank/SaaS apps) will throw \`EvalError: ... 'unsafe-eval' is not an allowed source of script\` and chrome_snapshot will return empty. On those pages, drive the page with \`chrome_screenshot\` (extension API, not gated by CSP) + \`chrome_click\`/\`chrome_type\`/\`chrome_key\` with \`trusted: true\` and viewport coordinates. \`chrome_navigate\`, \`chrome_screenshot\`, \`chrome_tab\`, and trusted input all keep working under any CSP.
-- Tool results include \`pageMutated\`, \`defaultPrevented\`, \`elementVisible\`, \`occludedBy\`, and (for type/fill) \`valueMatches\`. If \`pageMutated\` is false after a click that should have changed something, the click likely didn't take effect — do NOT just retry the same way; either escalate to \`trusted: true\` or check the snapshot for occlusion.
+- 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 the **Function constructor**, which requires \`'unsafe-eval'\` in the page CSP. Pages with strict CSP (e.g. github.com, many bank/SaaS apps) will throw \`EvalError: ... 'unsafe-eval' is not an allowed source of script\` and chrome_snapshot will return empty. On those pages, drive the page with \`chrome_screenshot\` + viewport-coordinate \`chrome_click\`/\`chrome_type\`/\`chrome_key\`. \`chrome_navigate\`, \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input all keep working under any CSP.
+- Tool results include \`pageMutated\`, \`defaultPrevented\`, \`elementVisible\`, \`occludedBy\`, and (for type/fill) \`valueMatches\`. If an action result indicates no page change or occlusion, inspect current page state instead of repeating blindly.
Usage rules:
-1. \`chrome_snapshot\` before clicking/typing; pass \`uid\` over \`selector\`.
-2. \`includeSnapshot=true\` on click/type/fill to verify in one round trip.
-3. If \`chrome_evaluate\` returns null when you expected a value, the expression evaluated to null/undefined in the page; surface the value via \`JSON.stringify\` to confirm.
-4. \`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).
-5. By default chrome_* tools focus Chrome so the user can watch; pass \`background=true\` or run /chrome quiet to silence the whole session.
-6. If you hit an autoplay/clipboard/file-picker gate, tell the user; this bridge cannot satisfy it. (Generic clicks/typing/CSP gates are fine — escalate to \`trusted: true\`.)
-7. Run /chrome doctor when in doubt about connectivity or capabilities.
+1. If a chrome_* tool says Chrome control is locked, ask the user to run \`/chrome authorize\` before retrying.
+2. \`chrome_snapshot\` before clicking/typing; pass \`uid\` over \`selector\`.
+3. \`includeSnapshot=true\` on click/type/fill to verify in one round trip.
+4. If \`chrome_evaluate\` returns null when you expected a value, the expression evaluated to null/undefined in the page; surface the value via \`JSON.stringify\` to confirm.
+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. By default chrome_* tools focus Chrome so the user can watch; pass \`background=true\` or run /chrome background on for session-wide background execution.
+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.
</chrome-profile-bridge>`;
return { systemPrompt: event.systemPrompt + primer };
});
@@ -514,105 +578,12 @@ Usage rules:
lines.push(`… Skipped the remaining checks until you reload the Chrome extension.`);
}
- // Real-input mode probe (plain English for the user).
- if (extensionAlive && !versionMismatch) {
- try {
- const status = (await bridge.send("trusted.status", {}, 5_000)) as {
- mode?: string;
- attachedTabs?: number[];
- permissionGranted?: boolean;
- };
- if (status.permissionGranted) {
- const banner = status.attachedTabs && status.attachedTabs.length ? ` (‘Pi Chrome Connector started debugging this browser’ banner up on ${status.attachedTabs.length} tab(s))` : "";
- const note =
- status.mode === "auto"
- ? " Clicks/keys are quiet by default; if a site rejects a quiet click, pi-chrome retries it once with a real-looking click. The Chrome banner shows only when that retry happens."
- : status.mode === "on"
- ? " Every click and keystroke uses a real-looking event. The Chrome banner stays up on every tab pi-chrome touches."
- : " All clicks are quiet, no banner. Some sites (sign-ins, copy buttons, file pickers, paywalls) may silently ignore them. Run /chrome clicks if a site isn’t responding.";
- const label = status.mode === "auto" ? "auto (smart upgrade)" : status.mode === "on" ? "on (always real-looking)" : "off (always quiet)";
- lines.push(`✓ Click mode: ${label}${banner}.${note}`);
- } else {
- lines.push(`⚠ Can't send real-looking clicks yet — the companion extension is missing a permission. Open chrome://extensions, click reload on 'Pi Chrome Connector', and accept the new permission prompt.`);
- }
- } catch (error) {
- lines.push(`⚠ Couldn't check click mode: ${(error as Error).message}`);
- }
- }
-
ctx.ui.notify(lines.join("\n"), "info");
};
- // Click realism handler. With no args, cycles auto → on → off → auto. Explicit args jump
- // directly. 'status' prints the current mode without changing it.
- const CLICKS_CYCLE = ["auto", "on", "off"] as const;
- const CLICKS_DESC: Record<string, string> = {
- auto: "Quiet by default; pi-chrome retries once with a real-looking click if a site rejects the quiet one. The Chrome banner appears only when that retry happens.",
- off: "All clicks are quiet, no banner. Some sites (sign-ins, copy buttons, file pickers, paywalls) may silently ignore these clicks.",
- on: "Every click and keystroke looks real to websites. Chrome shows a 'Pi Chrome Connector started debugging this browser' banner on every tab pi-chrome touches.",
- };
- const CLICKS_LABEL: Record<string, string> = {
- auto: "auto (smart upgrade)",
- off: "off (always quiet)",
- on: "on (always real-looking)",
- };
-
- const trustedHandler = async (ctx: ExtensionContext, args: string) => {
- const rawArg = (args || "").trim().toLowerCase();
-
- let status: { mode: string; attachedTabs: number[]; permissionGranted: boolean } | undefined;
- try {
- status = (await bridge.send("trusted.status", {}, 5_000)) as typeof status;
- } catch (error) {
- ctx.ui.notify(`Couldn't check current click mode: ${(error as Error).message}`, "warning");
- return;
- }
- if (!status) return;
-
- if (!status.permissionGranted) {
- ctx.ui.notify(
- "pi-chrome can't drive real-looking clicks yet — the companion extension is missing a permission. Open chrome://extensions, click reload on 'Pi Chrome Connector', and accept the new permission prompt that appears.",
- "warning",
- );
- return;
- }
-
- const current = status.mode;
- const attached = status.attachedTabs?.length ? ` (banner up on ${status.attachedTabs.length} tab(s))` : "";
-
- if (rawArg === "status") {
- ctx.ui.notify(`Click mode is ${CLICKS_LABEL[current] ?? current}${attached}. ${CLICKS_DESC[current] ?? ""}`, "info");
- return;
- }
-
- // No argument = cycle to the next mode.
- let target = rawArg;
- if (!target) {
- const idx = CLICKS_CYCLE.indexOf(current as typeof CLICKS_CYCLE[number]);
- target = CLICKS_CYCLE[(idx + 1 + CLICKS_CYCLE.length) % CLICKS_CYCLE.length];
- }
-
- if (!["on", "off", "auto"].includes(target)) {
- ctx.ui.notify(`Unknown click mode '${rawArg}'. Pick one of: auto | off | on | status.`, "warning");
- return;
- }
-
- if (target === current) {
- ctx.ui.notify(`Click mode is already ${CLICKS_LABEL[current] ?? current}.`, "info");
- return;
- }
-
- try {
- await bridge.send("trusted.mode", { mode: target }, 5_000);
- ctx.ui.notify(`Click mode → ${CLICKS_LABEL[target] ?? target}. ${CLICKS_DESC[target] ?? ""}`, "info");
- } catch (error) {
- ctx.ui.notify(`Couldn't switch click mode: ${(error as Error).message}`, "warning");
- }
- };
-
- // Quiet (Chrome focus) handler. No args = toggle. Explicit on/off/status.
- const QUIET_DESC: Record<string, string> = {
- on: "pi-chrome works in the background; Chrome won't pop up or steal focus.",
+ // Run-in-background (Chrome focus) handler. No args = toggle. Explicit on/off/status.
+ const BACKGROUND_DESC: Record<string, string> = {
+ on: "pi-chrome runs in the background; Chrome won't pop up or steal focus.",
off: "Chrome pops to the front and switches tabs so you can watch what pi-chrome is doing.",
};
@@ -621,7 +592,7 @@ Usage rules:
const currentLabel = backgroundDefault ? "on" : "off";
if (arg === "status") {
- ctx.ui.notify(`Quiet mode is ${currentLabel}. ${QUIET_DESC[currentLabel]}`, "info");
+ ctx.ui.notify(`Run in background is ${currentLabel}. ${BACKGROUND_DESC[currentLabel]}`, "info");
return;
}
@@ -629,12 +600,47 @@ Usage rules:
else if (arg === "off" || arg === "false" || arg === "0") backgroundDefault = false;
else if (arg === "toggle" || arg === "") backgroundDefault = !backgroundDefault;
else {
- ctx.ui.notify(`Unknown quiet mode '${arg}'. Pick one of: on | off | toggle | status.`, "warning");
+ ctx.ui.notify(`Unknown background setting '${arg}'. Pick one of: on | off | toggle | status.`, "warning");
return;
}
const nextLabel = backgroundDefault ? "on" : "off";
- ctx.ui.notify(`Quiet mode → ${nextLabel}. ${QUIET_DESC[nextLabel]}`, "info");
+ ctx.ui.notify(`Run in background → ${nextLabel}. ${BACKGROUND_DESC[nextLabel]}`, "info");
+ };
+
+ const authorizeFor = async (ctx: ExtensionContext, label: string, until: number | "indefinite") => {
+ const ok = await ctx.ui.confirm(
+ "Authorize pi-chrome control?",
+ `This Pi session will be allowed to inspect and control your existing Chrome profile for ${label}.\n\nChrome actions use your signed-in browser state and real input. Only approve if you trust the current agent/task.`,
+ );
+ if (!ok) {
+ ctx.ui.notify("Chrome control remains locked.", "info");
+ return;
+ }
+ chromeAuthorizedUntil = until;
+ ctx.ui.notify(`Chrome control authorized for ${label}.`, "info");
+ };
+
+ const parseAuthorizeArg = (arg: string): { label: string; until: number | "indefinite" } | undefined => {
+ const normalized = arg.trim().toLowerCase() || "15m";
+ if (normalized === "indefinite" || normalized === "forever") return { label: "indefinitely", until: "indefinite" };
+ const minutes = normalized.endsWith("m") ? Number(normalized.slice(0, -1)) : Number(normalized);
+ if (!Number.isFinite(minutes) || minutes <= 0) return undefined;
+ return { label: `${minutes} minutes`, until: Date.now() + minutes * 60_000 };
+ };
+
+ const authorizeHandler = async (ctx: ExtensionContext, args: string) => {
+ const grant = parseAuthorizeArg(args);
+ if (!grant) {
+ ctx.ui.notify("Unknown authorize duration. Use minutes (15m, 30m, 45) or indefinite.", "warning");
+ return;
+ }
+ return authorizeFor(ctx, grant.label, grant.until);
+ };
+
+ const revokeHandler = (ctx: ExtensionContext) => {
+ chromeAuthorizedUntil = undefined;
+ ctx.ui.notify("Chrome control locked. Run /chrome authorize to allow chrome_* tools again.", "info");
};
const onboardHandler = async (ctx: ExtensionContext) => {
@@ -672,12 +678,8 @@ Usage rules:
} catch {
parts.push(`✗ Chrome not responding`);
}
- try {
- const t = (await bridge.send("trusted.status", {}, 3_000)) as { mode?: string; attachedTabs?: number[] };
- const banner = t.attachedTabs?.length ? `, banner on ${t.attachedTabs.length} tab(s)` : "";
- parts.push(`clicks: ${t.mode ?? "?"}${banner}`);
- } catch {}
- parts.push(`quiet: ${backgroundDefault ? "on" : "off"}`);
+ parts.push(`auth: ${authSummary()}`);
+ parts.push(`background: ${backgroundDefault ? "on" : "off"}`);
return parts.join(" · ");
};
@@ -685,84 +687,63 @@ Usage rules:
ctx.ui.notify(await statusSummary(), "info");
};
- // Interactive dialog: each row is a setting whose value cycles with Space/Enter. Enter on
- // the last value also saves; Esc / 'q' closes. The description below changes with the
- // current value so users always see what the active setting means.
- const openSettingsDialog = async (ctx: ExtensionContext): Promise<void> => {
- // Read current click mode (might fail if extension permission missing).
- let clicksMode: string = "auto";
- let permissionGranted = false;
- try {
- const t = (await bridge.send("trusted.status", {}, 5_000)) as { mode?: string; permissionGranted?: boolean };
- clicksMode = t.mode ?? "auto";
- permissionGranted = !!t.permissionGranted;
- } catch {}
-
- const clicksItem: SettingItem = {
- id: "clicks",
- label: "Click realism",
- currentValue: clicksMode,
- values: ["auto", "on", "off"],
- description: permissionGranted
- ? (CLICKS_DESC[clicksMode] ?? "")
- : "Real-looking clicks unavailable: reload the Chrome extension in chrome://extensions and accept the new permission prompt.",
- };
- const quietItem: SettingItem = {
- id: "quiet",
- label: "Quiet mode",
- currentValue: backgroundDefault ? "on" : "off",
- values: ["on", "off"],
- description: QUIET_DESC[backgroundDefault ? "on" : "off"] ?? "",
- };
- const items: SettingItem[] = [clicksItem, quietItem];
-
- await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
- const container = new Container();
- container.addChild(new Text(theme.fg("accent", theme.bold("pi-chrome settings")), 1, 1));
- container.addChild(new Text(theme.fg("muted", "\u2191\u2193 navigate · space/enter cycle · esc close"), 1, 0));
-
- let list: SettingsList;
- list = new SettingsList(
- items,
- Math.min(items.length + 2, 8),
- getSettingsListTheme(),
- (id, newValue) => {
- if (id === "clicks") {
- if (!permissionGranted) {
- ctx.ui.notify("Click mode locked: reload the Chrome extension first.", "warning");
- // Revert by snapping back to the previous value.
- list.updateValue("clicks", clicksItem.currentValue);
- return;
- }
- // Mutate description so the help text matches the new value.
- clicksItem.currentValue = newValue;
- clicksItem.description = CLICKS_DESC[newValue] ?? "";
- list.invalidate();
- void bridge.send("trusted.mode", { mode: newValue }, 5_000).catch((err) => {
- ctx.ui.notify(`Couldn't switch click mode: ${(err as Error).message}`, "warning");
- });
- } else if (id === "quiet") {
- backgroundDefault = newValue === "on";
- quietItem.currentValue = newValue;
- quietItem.description = QUIET_DESC[newValue] ?? "";
- list.invalidate();
- }
- },
- () => done(undefined),
- );
- container.addChild(list);
+ const openAuthorizeMenu = async (ctx: ExtensionContext): Promise<void> => {
+ while (true) {
+ const choice = await ctx.ui.select("Authorize Chrome control", [
+ "15 minutes",
+ "30 minutes",
+ "Indefinite",
+ "Custom minutes",
+ ]);
+ if (!choice) return;
+ switch (choice) {
+ case "15 minutes": return authorizeHandler(ctx, "15m");
+ case "30 minutes": return authorizeHandler(ctx, "30m");
+ case "Indefinite": return authorizeHandler(ctx, "indefinite");
+ case "Custom minutes": {
+ const value = await ctx.ui.input("Authorize for how many minutes?", "45");
+ if (!value) continue;
+ return authorizeHandler(ctx, value);
+ }
+ }
+ }
+ };
- return {
- render: (w) => container.render(w),
- invalidate: () => container.invalidate(),
- handleInput: (data: string) => list.handleInput(data),
- };
- });
+ const openBackgroundMenu = async (ctx: ExtensionContext): Promise<void> => {
+ const choice = await ctx.ui.select("Background / watch mode", [
+ "Use Chrome in background",
+ "Use Chrome in foreground",
+ ]);
+ if (!choice) return;
+ switch (choice) {
+ case "Use Chrome in background": return backgroundHandler(ctx, "on");
+ case "Use Chrome in foreground": return backgroundHandler(ctx, "off");
+ }
+ };
+
+ const openCommandMenu = async (ctx: ExtensionContext): Promise<void> => {
+ while (true) {
+ const choice = await ctx.ui.select(`pi-chrome\n${await statusSummary()}`, [
+ "Authorize Chrome control…",
+ "Lock Chrome control",
+ "Doctor / troubleshoot",
+ "Background / watch mode…",
+ "Install / onboard extension",
+ ]);
+ if (!choice) return;
+ switch (choice) {
+ case "Authorize Chrome control…": await openAuthorizeMenu(ctx); continue;
+ case "Lock Chrome control": return revokeHandler(ctx);
+ case "Doctor / troubleshoot": return doctorHandler(ctx);
+ case "Background / watch mode…": await openBackgroundMenu(ctx); continue;
+ case "Install / onboard extension": return onboardHandler(ctx);
+ }
+ }
};
pi.registerCommand("chrome", {
description:
- "All pi-chrome controls in one place.\n /chrome status — one-line snapshot of connection + current modes.\n /chrome doctor — full health check.\n /chrome onboard — install the Chrome companion extension.\n /chrome clicks [auto|off|on|status] — how realistic should pi-chrome's clicks be.\n /chrome quiet [on|off|status|toggle] — whether Chrome pops to the front when pi-chrome acts.\nRun with no arguments for an interactive picker that shows current state.",
+ "All pi-chrome controls in one place.\n /chrome authorize [15m|30m|<minutes>|indefinite] — allow this Pi session to use chrome_* tools.\n /chrome revoke — lock Chrome control.\n /chrome status — one-line snapshot of connection, auth, and background setting.\n /chrome doctor — full health check.\n /chrome onboard — install the Chrome companion extension.\n /chrome background [on|off|status|toggle] — whether pi-chrome runs without focusing Chrome.\nRun with no arguments for an interactive picker that shows current state.",
getArgumentCompletions: (prefix) => {
const raw = prefix;
const trimmedRight = raw.replace(/\s+$/, "");
@@ -779,25 +760,25 @@ Usage rules:
let candidates: Item[] = [];
if (path.length === 0) {
candidates = [
- { fullValue: "status", label: "status", description: "One-line summary: connection + click mode + quiet mode." },
+ { fullValue: "authorize", label: "authorize", description: "Allow this Pi session to use chrome_* tools." },
+ { fullValue: "revoke", label: "revoke", description: "Lock Chrome control for this Pi session." },
+ { fullValue: "status", label: "status", description: "One-line summary: connection, auth, and background setting." },
{ fullValue: "doctor", label: "doctor", description: "Full health check. Tells you if Chrome is connected and what's wrong if it isn't." },
{ fullValue: "onboard", label: "onboard", description: "Install the Chrome companion extension (first-time setup)." },
- { fullValue: "clicks", label: "clicks", description: "How realistic should pi-chrome's clicks be? auto / off / on." },
- { fullValue: "quiet", label: "quiet", description: "Should Chrome pop to the front when pi-chrome acts, or work silently?" },
+ { fullValue: "background", label: "background", description: "Run pi-chrome in the background without focusing Chrome?" },
];
- } else if (path[0] === "clicks" && path.length === 1) {
+ } else if (path[0] === "authorize" && path.length === 1) {
candidates = [
- { fullValue: "clicks auto", label: "auto", description: "Default. Quiet clicks; upgrade to real-looking ones only when a site rejects them." },
- { fullValue: "clicks off", label: "off", description: "Always quiet. No banner. Some sites won't accept the clicks." },
- { fullValue: "clicks on", label: "on", description: "Always real-looking. Chrome shows a banner. Best for stubborn sites." },
- { fullValue: "clicks status", label: "status", description: "Show the current click mode." },
+ { fullValue: "authorize 15m", label: "15m", description: "Authorize Chrome control for 15 minutes." },
+ { fullValue: "authorize 30m", label: "30m", description: "Authorize Chrome control for 30 minutes." },
+ { fullValue: "authorize indefinite", label: "indefinite", description: "Authorize Chrome control until revoked or Pi exits." },
];
- } else if (path[0] === "quiet" && path.length === 1) {
+ } else if (path[0] === "background" && path.length === 1) {
candidates = [
- { fullValue: "quiet on", label: "on", description: "Work silently. Chrome stays in the background. Your editor keeps focus." },
- { fullValue: "quiet off", label: "off", description: "Bring Chrome to the front so you can watch (default)." },
- { fullValue: "quiet toggle", label: "toggle", description: "Flip whichever way it's currently set." },
- { fullValue: "quiet status", label: "status", description: "Show the current setting." },
+ { fullValue: "background on", label: "on", description: "Run in background. Chrome stays in the background. Your editor keeps focus." },
+ { fullValue: "background off", label: "off", description: "Bring Chrome to the front so you can watch (default)." },
+ { fullValue: "background toggle", label: "toggle", description: "Flip whichever way it's currently set." },
+ { fullValue: "background status", label: "status", description: "Show the current setting." },
];
}
if (candidates.length === 0) return null;
@@ -808,31 +789,28 @@ Usage rules:
handler: async (args, ctx) => {
const tokens = (args || "").trim().split(/\s+/).filter(Boolean);
if (tokens.length === 0) {
- await openSettingsDialog(ctx);
+ await openCommandMenu(ctx);
return;
}
const [head, ...rest] = tokens;
const subArgs = rest.join(" ");
switch (head) {
+ case "authorize": return authorizeHandler(ctx, subArgs);
+ case "revoke": return revokeHandler(ctx);
case "status": return statusHandler(ctx);
case "doctor": return doctorHandler(ctx);
case "onboard": return onboardHandler(ctx);
- case "clicks":
- case "trusted": // legacy alias
- return trustedHandler(ctx, subArgs);
- case "quiet":
- case "background": // legacy alias
+ case "background":
return backgroundHandler(ctx, subArgs);
case "settings": {
- // Legacy nested form: /chrome settings background ... or /chrome settings trusted ...
+ // Legacy nested form: /chrome settings background ...
const [setting, ...settingArgs] = rest;
if (setting === "background") return backgroundHandler(ctx, settingArgs.join(" "));
- if (setting === "trusted") return trustedHandler(ctx, settingArgs.join(" "));
- ctx.ui.notify(`'/chrome settings' was removed. Use /chrome clicks or /chrome quiet directly.`, "warning");
+ ctx.ui.notify(`'/chrome settings' was removed. Use /chrome background directly.`, "warning");
return;
}
default:
- ctx.ui.notify(`Unknown subcommand '${head}'. Try: /chrome status | doctor | onboard | clicks | quiet.`, "warning");
+ ctx.ui.notify(`Unknown subcommand '${head}'. Try: /chrome authorize | revoke | status | doctor | onboard | background.`, "warning");
}
},
});
@@ -844,7 +822,7 @@ Usage rules:
"Start/check the local bridge used by the companion Chrome extension. This does not launch a separate Chrome profile; install the unpacked Chrome extension in your existing Chrome profile to connect.",
promptSnippet: "Show instructions for connecting Pi to the user's existing Chrome profile via the companion extension.",
parameters: Type.Object({
- port: Type.Optional(Type.Number({ description: "Ignored unless PI_CHROME_BRIDGE_PORT is set before Pi starts." })),
+ port: Type.Optional(Type.Number({ description: "Ignored. The bundled Chrome extension polls 127.0.0.1:17318." })),
url: Type.Optional(Type.String({ description: "Optional URL to open in the existing Chrome profile after the extension is connected." })),
userDataDir: Type.Optional(Type.String({ description: "Ignored. This bridge intentionally uses the user's existing Chrome profile through the companion extension." })),
useDefaultProfile: Type.Optional(Type.Boolean({ description: "Ignored; existing-profile access comes from the companion Chrome extension." })),
@@ -852,7 +830,7 @@ Usage rules:
}),
async execute(_id, params, _signal, _onUpdate, ctx): Promise<ToolTextResult> {
if (params.url && bridge.connected) {
- const result = await bridge.send("tab.new", { url: params.url }, DEFAULT_TIMEOUT_MS);
+ const result = await authorizedBridgeSend("tab.new", { url: params.url }, DEFAULT_TIMEOUT_MS);
return { content: [{ type: "text", text: `Chrome bridge connected; opened ${params.url}` }], details: { status: bridge.status(), result } };
}
return {
@@ -887,7 +865,7 @@ Usage rules:
port: Type.Optional(Type.Number()),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const result = await bridge.send(`tab.${params.action}`, params, DEFAULT_TIMEOUT_MS);
+ const result = await authorizedBridgeSend(`tab.${params.action}`, params, DEFAULT_TIMEOUT_MS);
if (params.action === "list") {
const tabs = result as Array<{ id: number; title: string; url: string; active: boolean; windowId: number }>;
const text = tabs.map((tab) => `${tab.id}\t${tab.active ? "*" : " "}\t${tab.title || "(untitled)"}\t${tab.url}`).join("\n") || "No tabs.";
@@ -918,7 +896,7 @@ Usage rules:
port: Type.Optional(Type.Number()),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const snapshot = await bridge.send(
+ const snapshot = await authorizedBridgeSend(
"page.snapshot",
withBackground({ ...params, maxElements: params.maxElements ?? MAX_ELEMENTS }),
DEFAULT_TIMEOUT_MS,
@@ -948,7 +926,7 @@ Usage rules:
port: Type.Optional(Type.Number()),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const result = await bridge.send("page.navigate", withBackground(params), (params.timeoutMs ?? 15_000) + 2_000);
+ const result = await authorizedBridgeSend("page.navigate", withBackground(params), (params.timeoutMs ?? 15_000) + 2_000);
return { content: [{ type: "text", text: `Navigated to ${params.url}${params.initScript ? " (with initScript)" : ""}` }], details: { result: result as Json } };
},
});
@@ -972,7 +950,7 @@ Usage rules:
port: Type.Optional(Type.Number()),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const value = await bridge.send("page.evaluate", withBackground(params), DEFAULT_TIMEOUT_MS);
+ const value = await authorizedBridgeSend("page.evaluate", withBackground(params), DEFAULT_TIMEOUT_MS);
const text = value === undefined
? "undefined"
: typeof value === "string"
@@ -986,7 +964,7 @@ Usage rules:
name: "chrome_click",
label: "Chrome Click",
description:
- "Click a snapshot uid, CSS selector, or viewport coordinate. Default 'auto' mode runs synthetic DOM events first and silently retries with trusted CDP only when the click looks gated (no page change + affordance label matches play/copy/share/sign-in/etc, or a recent NotAllowedError). The 'started debugging' banner appears only when the retry actually happens. Pass trusted=true to force CDP for this call (banner appears immediately). Pass trusted=false to skip retry. Pass includeSnapshot=true to return a fresh snapshot after the click.",
+ "Click a snapshot uid, CSS selector, or viewport coordinate using Chrome's real input layer. Pass includeSnapshot=true to return a fresh snapshot after the click.",
promptSnippet: "Click page elements in Chrome by snapshot uid, selector, or viewport coordinate.",
parameters: Type.Object({
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot. Prefer uid over selector after taking a snapshot." })),
@@ -1001,12 +979,11 @@ Usage rules:
background: Type.Optional(
Type.Boolean({ description: "If true, click silently without focusing Chrome. Default false." }),
),
- trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so the event is browser-trusted (isTrusted=true, user-activation satisfied). Triggers Chrome's 'started debugging this browser' banner." })),
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const raw = await bridge.send("page.click", withBackground(params), DEFAULT_TIMEOUT_MS);
+ const raw = await authorizedBridgeSend("page.click", withBackground(params), DEFAULT_TIMEOUT_MS);
const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
const summary = summarizeActionResult(result);
const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
@@ -1019,7 +996,7 @@ Usage rules:
name: "chrome_type",
label: "Chrome Type",
description:
- "Focus an optional snapshot uid or CSS selector, then type text. Default 'auto' mode runs synthetic per-character keydown/beforeinput/input/keyup first; if the input value doesn't change at all (editor rejected synthetic input) the call is silently retried through chrome.debugger so each keystroke is browser-trusted (isTrusted=true). Pass trusted=true to force CDP for this call. Pass trusted=false to skip retry. Pass includeSnapshot=true to return a fresh snapshot after typing.",
+ "Focus an optional snapshot uid or CSS selector, then type text using Chrome's real keyboard input. Pass includeSnapshot=true to return a fresh snapshot after typing.",
promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.",
parameters: Type.Object({
text: Type.String(),
@@ -1034,12 +1011,11 @@ Usage rules:
background: Type.Optional(
Type.Boolean({ description: "If true, type silently without focusing Chrome. Default false." }),
),
- trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so each keystroke is browser-trusted. Triggers Chrome's debugger banner." })),
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const raw = await bridge.send("page.type", withBackground(params), DEFAULT_TIMEOUT_MS);
+ const raw = await authorizedBridgeSend("page.type", withBackground(params), DEFAULT_TIMEOUT_MS);
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}` : "";
@@ -1052,7 +1028,7 @@ Usage rules:
name: "chrome_fill",
label: "Chrome Fill",
description:
- "Set the full value of a text input, textarea, or contenteditable element using framework-aware native value setters and input/change events. Accepts a snapshot uid or CSS selector. Pass includeSnapshot=true to verify after filling.",
+ "Set the full value of a text input, textarea, or contenteditable element using Chrome click/select/delete/type input. 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(),
@@ -1067,12 +1043,11 @@ Usage rules:
background: Type.Optional(
Type.Boolean({ description: "If true, fill silently without focusing Chrome. Default false." }),
),
- trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP for browser-trusted input. Triggers Chrome's debugger banner." })),
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const raw = await bridge.send("page.fill", withBackground(params), DEFAULT_TIMEOUT_MS);
+ const raw = await authorizedBridgeSend("page.fill", withBackground(params), DEFAULT_TIMEOUT_MS);
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}` : "";
@@ -1094,7 +1069,7 @@ Usage rules:
ctrlKey: Type.Optional(Type.Boolean()),
altKey: Type.Optional(Type.Boolean()),
metaKey: Type.Optional(Type.Boolean()),
- }, { description: "Modifier keys to hold while pressing the key (chord). Only honoured for trusted-mode presses; synthetic path ignores." })),
+ }, { description: "Modifier keys to hold while pressing the key (chord)." })),
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the keypress." })),
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
targetId: Type.Optional(Type.String()),
@@ -1103,12 +1078,11 @@ Usage rules:
background: Type.Optional(
Type.Boolean({ description: "If true, send the key silently without focusing Chrome. Default false." }),
),
- trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so the keystroke is browser-trusted." })),
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const raw = await bridge.send("page.key", withBackground(params), DEFAULT_TIMEOUT_MS);
+ const raw = await authorizedBridgeSend("page.key", withBackground(params), DEFAULT_TIMEOUT_MS);
const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
const summary = summarizeActionResult(result);
const base = `Pressed ${params.key}.`;
@@ -1133,7 +1107,7 @@ Usage rules:
port: Type.Optional(Type.Number()),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const result = await bridge.send("page.waitFor", params, (params.timeoutMs ?? 10_000) + 2_000);
+ const result = await authorizedBridgeSend("page.waitFor", params, (params.timeoutMs ?? 10_000) + 2_000);
return { content: [{ type: "text", text: `Observed ${params.kind}: ${params.value}` }], details: { result: result as Json } };
},
});
@@ -1154,7 +1128,7 @@ Usage rules:
port: Type.Optional(Type.Number()),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const result = await bridge.send("page.console.list", withBackground(params), DEFAULT_TIMEOUT_MS);
+ const result = await authorizedBridgeSend("page.console.list", withBackground(params), DEFAULT_TIMEOUT_MS);
return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
},
});
@@ -1176,7 +1150,7 @@ Usage rules:
port: Type.Optional(Type.Number()),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const result = await bridge.send("page.network.list", withBackground(params), DEFAULT_TIMEOUT_MS);
+ const result = await authorizedBridgeSend("page.network.list", withBackground(params), DEFAULT_TIMEOUT_MS);
return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
},
});
@@ -1196,7 +1170,7 @@ Usage rules:
port: Type.Optional(Type.Number()),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const result = await bridge.send("page.network.get", withBackground(params), DEFAULT_TIMEOUT_MS);
+ const result = await authorizedBridgeSend("page.network.get", withBackground(params), DEFAULT_TIMEOUT_MS);
return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
},
});
@@ -1226,7 +1200,7 @@ Usage rules:
const cwd = workspaceCwd(ctx);
const defaultPath = join(cwd, ".pi", "chrome-screenshots", `${new Date().toISOString().replace(/[:.]/g, "-")}.${format}`);
const outputPath = params.path ? resolve(cwd, params.path) : defaultPath;
- const result = (await bridge.send("page.screenshot", withBackground(params), params.fullPage ? 120_000 : DEFAULT_TIMEOUT_MS)) as {
+ const result = (await authorizedBridgeSend("page.screenshot", withBackground(params), params.fullPage ? 120_000 : DEFAULT_TIMEOUT_MS)) as {
dataUrl?: string;
tab?: unknown;
fullPage?: boolean;
@@ -1263,7 +1237,7 @@ Usage rules:
pi.registerTool({
name: "chrome_hover",
label: "Chrome Hover",
- description: "Hover over an element (synthetic pointerover/mouseover/pointermove) by uid, selector, or x/y. Triggers CSS :hover state and any JS hover handlers; isTrusted is false.",
+ description: "Hover over an element by uid, selector, or x/y using Chrome pointer movement.",
promptSnippet: "Hover a Chrome element to trigger :hover / mouseover handlers.",
parameters: Type.Object({
uid: Type.Optional(Type.String()),
@@ -1274,10 +1248,9 @@ Usage rules:
urlIncludes: Type.Optional(Type.String()),
titleIncludes: Type.Optional(Type.String()),
background: Type.Optional(Type.Boolean()),
- trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP for browser-trusted hover." })),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const result = await bridge.send("page.hover", withBackground(params), DEFAULT_TIMEOUT_MS);
+ const result = await authorizedBridgeSend("page.hover", withBackground(params), DEFAULT_TIMEOUT_MS);
return { content: [{ type: "text", text: `Hovered ${params.uid ?? params.selector ?? `${params.x},${params.y}`}` }], details: { result: result as Json } };
},
});
@@ -1285,7 +1258,7 @@ Usage rules:
pi.registerTool({
name: "chrome_drag",
label: "Chrome Drag",
- description: "Synthetic drag from one uid/selector/point to another. Dispatches pointerdown → humanised pointermove path → dragstart/drag/dragenter/dragover/dragleave/drop/dragend with a shared HTML5 DataTransfer, then pointerup. isTrusted=false.",
+ description: "Drag from one uid/selector/point to another using Chrome pointer input.",
promptSnippet: "Drag a Chrome element from one point to another.",
parameters: Type.Object({
fromUid: Type.Optional(Type.String()),
@@ -1301,10 +1274,9 @@ Usage rules:
urlIncludes: Type.Optional(Type.String()),
titleIncludes: Type.Optional(Type.String()),
background: Type.Optional(Type.Boolean()),
- trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so the drag is browser-trusted (real HTML5 dragstart/drop with native DataTransfer)." })),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const result = await bridge.send("page.drag", withBackground(params), DEFAULT_TIMEOUT_MS);
+ const result = await authorizedBridgeSend("page.drag", withBackground(params), DEFAULT_TIMEOUT_MS);
return { content: [{ type: "text", text: `Dragged from ${params.fromUid ?? params.fromSelector} to ${params.toUid ?? params.toSelector}` }], details: { result: result as Json } };
},
});
@@ -1313,7 +1285,7 @@ Usage rules:
name: "chrome_tap",
label: "Chrome Tap (Touch)",
description:
- "Dispatch a real browser-trusted touchstart/touchend tap via chrome.debugger (CDP Input.dispatchTouchEvent). Use for sites that gate on TouchEvent rather than MouseEvent (mobile-first PWAs, swipe carousels). Always uses the trusted CDP path — the 'started debugging' banner appears.",
+ "Dispatch a real touchstart/touchend tap through Chrome's input layer. Use for sites that gate on TouchEvent rather than MouseEvent (mobile-first PWAs, swipe carousels). Chrome may show its debugging banner while attached.",
promptSnippet: "Tap (real touch) a Chrome element by snapshot uid, selector, or coordinate.",
parameters: Type.Object({
uid: Type.Optional(Type.String()),
@@ -1326,7 +1298,7 @@ Usage rules:
background: Type.Optional(Type.Boolean()),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const result = await bridge.send("page.tap", withBackground(params), DEFAULT_TIMEOUT_MS);
+ const result = await authorizedBridgeSend("page.tap", withBackground(params), DEFAULT_TIMEOUT_MS);
const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
return { content: [{ type: "text", text: `Tapped ${target} (touch)` }], details: { result: result as Json } };
},
@@ -1347,10 +1319,9 @@ Usage rules:
urlIncludes: Type.Optional(Type.String()),
titleIncludes: Type.Optional(Type.String()),
background: Type.Optional(Type.Boolean()),
- trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch wheel events through chrome.debugger / CDP for browser-trusted scrolling." })),
}),
async execute(_id, params): Promise<ToolTextResult> {
- const result = await bridge.send("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS);
+ const result = await authorizedBridgeSend("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS);
return { content: [{ type: "text", text: `Scrolled dy=${params.deltaY ?? 0} dx=${params.deltaX ?? 0}` }], details: { result: result as Json } };
},
});
@@ -1358,7 +1329,7 @@ Usage rules:
pi.registerTool({
name: "chrome_upload_file",
label: "Chrome Upload File",
- description: "Programmatically set the files of an <input type=file> element from local file paths. Uses DataTransfer to populate input.files and dispatches input+change events. Does NOT open the native file picker; works with React/Vue/Angular controlled inputs.",
+ description: "Attach local files to an <input type=file> element using Chrome DevTools file-input control. Does NOT open the native file picker; works with React/Vue/Angular controlled inputs.",
promptSnippet: "Attach local files to a Chrome <input type=file> without opening the native file picker.",
parameters: Type.Object({
uid: Type.Optional(Type.String()),
@@ -1370,17 +1341,10 @@ Usage rules:
background: Type.Optional(Type.Boolean()),
}),
async execute(_id, params, _signal, _onUpdate, ctx): Promise<ToolTextResult> {
- const { readFile } = await import("node:fs/promises");
- const { basename } = await import("node:path");
const cwd = workspaceCwd(ctx);
- const files: Array<{ name: string; type: string; base64: string }> = [];
- for (const p of params.paths) {
- const abs = resolve(cwd, p);
- const buf = await readFile(abs);
- files.push({ name: basename(abs), type: "application/octet-stream", base64: buf.toString("base64") });
- }
- const result = await bridge.send("page.upload", withBackground({ ...params, files }), DEFAULT_TIMEOUT_MS);
- return { content: [{ type: "text", text: `Uploaded ${files.length} file(s) to ${params.uid ?? params.selector}` }], details: { result: result as Json } };
+ const paths = params.paths.map((p) => resolve(cwd, p));
+ const result = await authorizedBridgeSend("page.upload", withBackground({ ...params, paths }), DEFAULT_TIMEOUT_MS);
+ return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}` }], details: { result: result as Json } };
},
});
}