pi-chrome 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,6 +65,25 @@ pi-chrome v<version>
65
65
  ✓ Companion Chrome extension responding (ID: <chrome-extension-id>, ext v<version>)
66
66
  ```
67
67
 
68
+ ## Trusted-input mode (CDP)
69
+
70
+ By default, `chrome_*` clicks and keystrokes are **synthetic** DOM events (`event.isTrusted === false`). They drive React/Vue/Angular state correctly but **do not** satisfy Chrome's user-activation gates: clipboard write, fullscreen, file picker, and autoplay all need a real user gesture.
71
+
72
+ pi-chrome can optionally route input through `chrome.debugger` (CDP `Input.dispatchMouseEvent` / `Input.dispatchKeyEvent`) so each event arrives as `isTrusted=true`, satisfies user-activation, and bypasses site bot-detection that filters synthetic events. The tradeoff: Chrome pins a yellow *"Pi Existing Chrome Profile Bridge started debugging this browser"* banner to the top of any debugged tab.
73
+
74
+ Usage:
75
+
76
+ ```text
77
+ /chrome-trusted on # all chrome_* tools dispatch via CDP
78
+ /chrome-trusted off # synthetic events only (default)
79
+ /chrome-trusted auto # CDP only when a tool passes trusted=true
80
+ /chrome-trusted status # show current mode and attached tabs
81
+ ```
82
+
83
+ For a single call, pass `trusted: true` on `chrome_click`, `chrome_type`, `chrome_fill`, `chrome_key`, `chrome_hover`, `chrome_drag`, or `chrome_scroll`. The per-call value always wins over the session toggle.
84
+
85
+ First time you upgrade to a pi-chrome that uses trusted input, Chrome will prompt to re-approve the extension's new `debugger` permission — do that once in `chrome://extensions`.
86
+
68
87
  ## Background mode
69
88
 
70
89
  By default, `chrome_*` tools focus Chrome and activate the target tab so you can watch the agent work — great for demos, pair-driving, debugging, and first-time confidence that things are happening.
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Existing Chrome Profile Bridge",
4
- "version": "0.9.1",
4
+ "version": "0.10.1",
5
5
  "description": "Lets Pi control tabs in this existing Chrome profile via a local bridge at 127.0.0.1.",
6
- "permissions": ["tabs", "scripting", "storage", "activeTab", "alarms", "webNavigation"],
6
+ "permissions": ["tabs", "scripting", "storage", "activeTab", "alarms", "webNavigation", "debugger"],
7
7
  "host_permissions": ["<all_urls>", "http://127.0.0.1:17318/*"],
8
8
  "background": {
9
9
  "service_worker": "service_worker.js"
@@ -3,6 +3,368 @@ const CLIENT_NAME = `Pi Chrome Bridge ${chrome.runtime.id}`;
3
3
  const POLL_ERROR_BACKOFF_MS = 2000;
4
4
  let polling = false;
5
5
 
6
+ // =================== Trusted-input (CDP) layer ===================
7
+ // Tracks which tabs we have attached chrome.debugger to, plus session-level mode.
8
+ const attachedTabs = new Map(); // tabId -> { detachAt: number, pointer: {x,y} }
9
+ let TRUSTED_MODE = "off"; // "off" | "on" | "auto"
10
+ const TRUSTED_IDLE_DETACH_MS = 15_000;
11
+ const CDP_VERSION = "1.3";
12
+
13
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
14
+ function rng(min, max) { return min + Math.random() * (max - min); }
15
+
16
+ async function wantsTrusted(params) {
17
+ if (params && params.trusted === false) return false;
18
+ if (params && params.trusted === true) return true;
19
+ return TRUSTED_MODE === "on";
20
+ }
21
+
22
+ function setTrustedMode(mode) {
23
+ const next = String(mode || "").toLowerCase();
24
+ if (!["off", "on", "auto"].includes(next)) throw new Error(`bad trusted mode: ${next}`);
25
+ TRUSTED_MODE = next;
26
+ if (next === "off") void detachAll();
27
+ return { mode: TRUSTED_MODE };
28
+ }
29
+
30
+ function trustedStatus() {
31
+ return {
32
+ mode: TRUSTED_MODE,
33
+ attachedTabs: Array.from(attachedTabs.keys()),
34
+ permissionGranted: typeof chrome !== "undefined" && !!chrome.debugger,
35
+ };
36
+ }
37
+
38
+ async function attachDebugger(tabId) {
39
+ if (!chrome.debugger) throw new Error("chrome.debugger API unavailable; reload the extension to grant the new permission");
40
+ if (attachedTabs.has(tabId)) {
41
+ const entry = attachedTabs.get(tabId);
42
+ entry.detachAt = Date.now() + TRUSTED_IDLE_DETACH_MS;
43
+ return entry;
44
+ }
45
+ await chrome.debugger.attach({ tabId }, CDP_VERSION);
46
+ // Seed pointer in a plausible "just left the address bar" location.
47
+ const entry = { detachAt: Date.now() + TRUSTED_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } };
48
+ attachedTabs.set(tabId, entry);
49
+ return entry;
50
+ }
51
+
52
+ async function detachDebugger(tabId) {
53
+ if (!attachedTabs.has(tabId)) return;
54
+ attachedTabs.delete(tabId);
55
+ try { await chrome.debugger.detach({ tabId }); } catch {}
56
+ }
57
+
58
+ async function detachAll() {
59
+ const ids = Array.from(attachedTabs.keys());
60
+ await Promise.all(ids.map(detachDebugger));
61
+ }
62
+
63
+ if (chrome.debugger && chrome.debugger.onDetach) {
64
+ chrome.debugger.onDetach.addListener(({ tabId }, reason) => {
65
+ if (tabId !== undefined) attachedTabs.delete(tabId);
66
+ if (reason === "canceled_by_user") {
67
+ console.warn(`[pi-chrome] debugger canceled by user on tab ${tabId}; trusted mode will reattach on next call`);
68
+ }
69
+ });
70
+ }
71
+
72
+ setInterval(() => {
73
+ const now = Date.now();
74
+ for (const [tabId, entry] of attachedTabs) {
75
+ if (entry.detachAt && entry.detachAt < now && TRUSTED_MODE !== "on") {
76
+ void detachDebugger(tabId);
77
+ }
78
+ }
79
+ }, 5000);
80
+
81
+ function cdp(tabId, method, params) {
82
+ return new Promise((resolve, reject) => {
83
+ chrome.debugger.sendCommand({ tabId }, method, params || {}, (result) => {
84
+ if (chrome.runtime.lastError) reject(new Error(`${method}: ${chrome.runtime.lastError.message}`));
85
+ else resolve(result);
86
+ });
87
+ });
88
+ }
89
+
90
+ // Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab.
91
+ async function resolveTargetInTab(tabId, params) {
92
+ const results = await chrome.scripting.executeScript({
93
+ target: { tabId, frameIds: [0] },
94
+ world: "MAIN",
95
+ func: (selector, uid, x, y) => {
96
+ const state = window.__PI_CHROME_STATE__;
97
+ let el = null;
98
+ if (uid && state && state.elements && state.elements[uid]) el = state.elements[uid];
99
+ else if (selector) el = document.querySelector(selector);
100
+ if (el) {
101
+ el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
102
+ const r = el.getBoundingClientRect();
103
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2, rect: { left: r.left, top: r.top, width: r.width, height: r.height }, tag: el.tagName, found: true };
104
+ }
105
+ if (typeof x === "number" && typeof y === "number") return { x, y, rect: null, tag: null, found: true };
106
+ return { found: false };
107
+ },
108
+ args: [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null],
109
+ });
110
+ const v = results?.[0]?.result;
111
+ if (!v || !v.found) throw new Error("Could not resolve target element for trusted action");
112
+ return v;
113
+ }
114
+
115
+ function pickInsideRect(rect) {
116
+ if (!rect) return null;
117
+ const insetX = Math.min(rect.width * 0.35, Math.max(2, rect.width / 2 - 1));
118
+ const insetY = Math.min(rect.height * 0.35, Math.max(2, rect.height / 2 - 1));
119
+ return {
120
+ x: rect.left + rect.width / 2 + rng(-insetX, insetX),
121
+ y: rect.top + rect.height / 2 + rng(-insetY, insetY),
122
+ };
123
+ }
124
+
125
+ async function cdpMoveTo(tabId, x, y) {
126
+ const entry = attachedTabs.get(tabId);
127
+ const startX = entry?.pointer?.x ?? Math.max(20, Math.min(400, x - 200));
128
+ const startY = entry?.pointer?.y ?? Math.max(20, Math.min(400, y - 200));
129
+ const n = Math.max(10, Math.min(36, Math.round(Math.hypot(x - startX, y - startY) / 20)));
130
+ for (let i = 1; i <= n; i++) {
131
+ const t = i / n;
132
+ const ease = t * t * (3 - 2 * t);
133
+ const wobble = Math.sin(t * Math.PI) * 8;
134
+ const px = startX + (x - startX) * ease + rng(-wobble, wobble);
135
+ const py = startY + (y - startY) * ease + rng(-wobble, wobble);
136
+ await cdp(tabId, "Input.dispatchMouseEvent", {
137
+ type: "mouseMoved", x: px, y: py, button: "none", buttons: 0, pointerType: "mouse",
138
+ });
139
+ await sleep(rng(5, 16));
140
+ }
141
+ if (entry) entry.pointer = { x, y };
142
+ }
143
+
144
+ function cdpModifiersFor(mods) {
145
+ let m = 0;
146
+ if (mods?.altKey) m |= 1;
147
+ if (mods?.ctrlKey) m |= 2;
148
+ if (mods?.metaKey) m |= 4;
149
+ if (mods?.shiftKey) m |= 8;
150
+ return m;
151
+ }
152
+
153
+ function cdpKeyInfo(key, shifted) {
154
+ // Map common keys to CDP key event init fields. Returns { code, key, windowsVirtualKeyCode, text }.
155
+ const SPECIAL = {
156
+ Enter: { code: "Enter", windowsVirtualKeyCode: 13, text: "\r" },
157
+ Tab: { code: "Tab", windowsVirtualKeyCode: 9, text: "\t" },
158
+ Backspace: { code: "Backspace", windowsVirtualKeyCode: 8, text: "" },
159
+ Delete: { code: "Delete", windowsVirtualKeyCode: 46, text: "" },
160
+ Escape: { code: "Escape", windowsVirtualKeyCode: 27, text: "" },
161
+ ArrowLeft: { code: "ArrowLeft", windowsVirtualKeyCode: 37, text: "" },
162
+ ArrowUp: { code: "ArrowUp", windowsVirtualKeyCode: 38, text: "" },
163
+ ArrowRight: { code: "ArrowRight", windowsVirtualKeyCode: 39, text: "" },
164
+ ArrowDown: { code: "ArrowDown", windowsVirtualKeyCode: 40, text: "" },
165
+ Shift: { code: "ShiftLeft", windowsVirtualKeyCode: 16, text: "" },
166
+ Control: { code: "ControlLeft", windowsVirtualKeyCode: 17, text: "" },
167
+ Alt: { code: "AltLeft", windowsVirtualKeyCode: 18, text: "" },
168
+ Meta: { code: "MetaLeft", windowsVirtualKeyCode: 91, text: "" },
169
+ " ": { code: "Space", windowsVirtualKeyCode: 32, text: " " },
170
+ };
171
+ if (SPECIAL[key]) return { key, ...SPECIAL[key] };
172
+ if (key.length === 1) {
173
+ const ch = key;
174
+ let code, vk;
175
+ if (/^[a-zA-Z]$/.test(ch)) { code = `Key${ch.toUpperCase()}`; vk = ch.toUpperCase().charCodeAt(0); }
176
+ else if (/^[0-9]$/.test(ch)) { code = `Digit${ch}`; vk = ch.charCodeAt(0); }
177
+ else { code = ch; vk = ch.charCodeAt(0); }
178
+ return { key: ch, code, windowsVirtualKeyCode: vk, text: ch };
179
+ }
180
+ return { key, code: key, windowsVirtualKeyCode: 0, text: "" };
181
+ }
182
+
183
+ async function cdpTypeChar(tabId, ch) {
184
+ const needShift = /^[A-Z]$/.test(ch) || "~!@#$%^&*()_+{}|:\"<>?".includes(ch);
185
+ let modifiers = 0;
186
+ if (needShift) {
187
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: "Shift", code: "ShiftLeft", windowsVirtualKeyCode: 16, modifiers: 8 });
188
+ modifiers = 8;
189
+ await sleep(rng(8, 22));
190
+ }
191
+ const info = cdpKeyInfo(ch);
192
+ await cdp(tabId, "Input.dispatchKeyEvent", {
193
+ type: "keyDown", key: info.key, code: info.code,
194
+ windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode,
195
+ text: info.text, unmodifiedText: info.text, modifiers,
196
+ });
197
+ await sleep(rng(25, 90));
198
+ await cdp(tabId, "Input.dispatchKeyEvent", {
199
+ type: "keyUp", key: info.key, code: info.code,
200
+ windowsVirtualKeyCode: info.windowsVirtualKeyCode, modifiers,
201
+ });
202
+ if (needShift) {
203
+ await sleep(rng(5, 18));
204
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "Shift", code: "ShiftLeft", windowsVirtualKeyCode: 16, modifiers: 0 });
205
+ }
206
+ await sleep(rng(35, 130));
207
+ }
208
+
209
+ async function trustedClick(params) {
210
+ const tab = await getTabByParams(params);
211
+ if (params.foreground) await bringToFront(tab);
212
+ await attachDebugger(tab.id);
213
+ const resolved = await resolveTargetInTab(tab.id, params);
214
+ const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
215
+ await cdpMoveTo(tab.id, point.x, point.y);
216
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" });
217
+ await sleep(rng(45, 140));
218
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
219
+ return { trusted: true, x: point.x, y: point.y, tag: resolved.tag };
220
+ }
221
+
222
+ async function trustedHover(params) {
223
+ const tab = await getTabByParams(params);
224
+ if (params.foreground) await bringToFront(tab);
225
+ await attachDebugger(tab.id);
226
+ const resolved = await resolveTargetInTab(tab.id, params);
227
+ const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
228
+ await cdpMoveTo(tab.id, point.x, point.y);
229
+ await sleep(rng(80, 220));
230
+ return { trusted: true, x: point.x, y: point.y, tag: resolved.tag };
231
+ }
232
+
233
+ async function trustedKey(params) {
234
+ const tab = await getTabByParams(params);
235
+ if (params.foreground) await bringToFront(tab);
236
+ await attachDebugger(tab.id);
237
+ const key = String(params.key || "");
238
+ if (!key) throw new Error("trusted.key: missing key");
239
+ const mods = params.modifiers || {};
240
+ const modBits = cdpModifiersFor(mods);
241
+ // Press modifiers in standard order, then key, then release in reverse.
242
+ const modOrder = [];
243
+ if (mods.metaKey) modOrder.push({ key: "Meta", code: "MetaLeft", vk: 91 });
244
+ if (mods.ctrlKey) modOrder.push({ key: "Control", code: "ControlLeft", vk: 17 });
245
+ if (mods.altKey) modOrder.push({ key: "Alt", code: "AltLeft", vk: 18 });
246
+ if (mods.shiftKey) modOrder.push({ key: "Shift", code: "ShiftLeft", vk: 16 });
247
+ for (const m of modOrder) {
248
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: m.key, code: m.code, windowsVirtualKeyCode: m.vk, modifiers: modBits });
249
+ await sleep(rng(6, 18));
250
+ }
251
+ const info = cdpKeyInfo(key);
252
+ // When modifiers are active, browsers usually emit "rawKeyDown" (no text) so chords like Cmd+V don't insert the literal char.
253
+ const downType = modBits ? "rawKeyDown" : "keyDown";
254
+ await cdp(tab.id, "Input.dispatchKeyEvent", {
255
+ type: downType, key: info.key, code: info.code,
256
+ windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode,
257
+ text: modBits ? "" : info.text, unmodifiedText: modBits ? "" : info.text, modifiers: modBits,
258
+ });
259
+ await sleep(rng(25, 90));
260
+ await cdp(tab.id, "Input.dispatchKeyEvent", {
261
+ type: "keyUp", key: info.key, code: info.code,
262
+ windowsVirtualKeyCode: info.windowsVirtualKeyCode, modifiers: modBits,
263
+ });
264
+ for (const m of modOrder.reverse()) {
265
+ await sleep(rng(5, 18));
266
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: m.key, code: m.code, windowsVirtualKeyCode: m.vk, modifiers: 0 });
267
+ }
268
+ return { trusted: true, key: info.key, modifiers: mods };
269
+ }
270
+
271
+ async function trustedType(params) {
272
+ const tab = await getTabByParams(params);
273
+ if (params.foreground) await bringToFront(tab);
274
+ await attachDebugger(tab.id);
275
+ if (params.selector || params.uid) {
276
+ // Focus target by clicking it first.
277
+ const resolved = await resolveTargetInTab(tab.id, params);
278
+ const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
279
+ await cdpMoveTo(tab.id, point.x, point.y);
280
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" });
281
+ await sleep(rng(45, 110));
282
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
283
+ await sleep(rng(50, 120));
284
+ }
285
+ const text = String(params.text || "");
286
+ for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
287
+ if (params.pressEnter) {
288
+ await cdpTypeChar(tab.id, "\r").catch(() => undefined);
289
+ await trustedKey({ ...params, key: "Enter" });
290
+ }
291
+ return { trusted: true, length: text.length };
292
+ }
293
+
294
+ async function trustedFill(params) {
295
+ const tab = await getTabByParams(params);
296
+ if (params.foreground) await bringToFront(tab);
297
+ await attachDebugger(tab.id);
298
+ if (!(params.selector || params.uid)) throw new Error("trusted.fill: selector or uid required");
299
+ const resolved = await resolveTargetInTab(tab.id, params);
300
+ const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
301
+ await cdpMoveTo(tab.id, point.x, point.y);
302
+ // Triple-click selects all in input fields.
303
+ for (let i = 1; i <= 3; i++) {
304
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: i, pointerType: "mouse" });
305
+ await sleep(rng(20, 60));
306
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" });
307
+ await sleep(rng(20, 60));
308
+ }
309
+ // Delete selection.
310
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
311
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
312
+ await sleep(rng(20, 60));
313
+ const text = String(params.text || "");
314
+ for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
315
+ if (params.submit) await trustedKey({ ...params, key: "Enter" });
316
+ return { trusted: true, length: text.length };
317
+ }
318
+
319
+ async function trustedScroll(params) {
320
+ const tab = await getTabByParams(params);
321
+ if (params.foreground) await bringToFront(tab);
322
+ await attachDebugger(tab.id);
323
+ const resolved = (params.selector || params.uid) ? await resolveTargetInTab(tab.id, params) : { x: 100, y: 100, rect: null };
324
+ const x = resolved.rect ? resolved.rect.left + Math.min(resolved.rect.width, 800) / 2 : resolved.x;
325
+ const y = resolved.rect ? resolved.rect.top + Math.min(resolved.rect.height, 600) / 2 : resolved.y;
326
+ const totalY = params.deltaY || 0, totalX = params.deltaX || 0;
327
+ const n = Math.max(3, Math.min(20, params.steps || Math.ceil(Math.abs(totalY) / 120)));
328
+ // momentum-shaped front-loaded weights
329
+ const w = []; for (let i = 1; i <= n; i++) w.push(1 / i);
330
+ const sumW = w.reduce((a, b) => a + b, 0);
331
+ for (let i = 0; i < n; i++) {
332
+ const dy = totalY * (w[i] / sumW), dx = totalX * (w[i] / sumW);
333
+ await cdp(tab.id, "Input.dispatchMouseEvent", {
334
+ type: "mouseWheel", x, y, deltaX: dx, deltaY: dy, pointerType: "mouse",
335
+ });
336
+ await sleep(rng(14, 32));
337
+ }
338
+ return { trusted: true, deltaX: totalX, deltaY: totalY, steps: n };
339
+ }
340
+
341
+ async function trustedDrag(params) {
342
+ const tab = await getTabByParams(params);
343
+ if (params.foreground) await bringToFront(tab);
344
+ await attachDebugger(tab.id);
345
+ const from = await resolveTargetInTab(tab.id, { selector: params.fromSelector ?? null, uid: params.fromUid ?? null, x: params.fromX ?? null, y: params.fromY ?? null });
346
+ const to = await resolveTargetInTab(tab.id, { selector: params.toSelector ?? null, uid: params.toUid ?? null, x: params.toX ?? null, y: params.toY ?? null });
347
+ const fp = from.rect ? pickInsideRect(from.rect) : { x: from.x, y: from.y };
348
+ const tp = to.rect ? pickInsideRect(to.rect) : { x: to.x, y: to.y };
349
+ await cdpMoveTo(tab.id, fp.x, fp.y);
350
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: fp.x, y: fp.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" });
351
+ await sleep(rng(60, 140));
352
+ const steps = params.steps || 20;
353
+ for (let i = 1; i <= steps; i++) {
354
+ const t = i / steps;
355
+ const ease = t * t * (3 - 2 * t);
356
+ const wobble = Math.sin(t * Math.PI) * 6;
357
+ const x = fp.x + (tp.x - fp.x) * ease + rng(-wobble, wobble);
358
+ const y = fp.y + (tp.y - fp.y) * ease + rng(-wobble, wobble);
359
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "left", buttons: 1, pointerType: "mouse" });
360
+ await sleep(rng(10, 26));
361
+ }
362
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: tp.x, y: tp.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
363
+ return { trusted: true, from: fp, to: tp, steps };
364
+ }
365
+ // ===============================================================
366
+
367
+
6
368
  function armKeepaliveAlarm() {
7
369
  chrome.alarms.create("pi-bridge-keepalive", { periodInMinutes: 0.5 });
8
370
  }
@@ -77,10 +439,6 @@ async function postResult(result) {
77
439
  });
78
440
  }
79
441
 
80
- function sleep(ms) {
81
- return new Promise((resolve) => setTimeout(resolve, ms));
82
- }
83
-
84
442
  function isVersionOlder(a, b) {
85
443
  const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
86
444
  const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
@@ -128,21 +486,32 @@ async function dispatch(action, params) {
128
486
  case "page.evaluate":
129
487
  return evaluateInTab(params);
130
488
  case "page.click":
489
+ if (await wantsTrusted(params)) return trustedClick(params);
131
490
  return executeActionInTab(params, clickPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
132
491
  case "page.hover":
492
+ if (await wantsTrusted(params)) return trustedHover(params);
133
493
  return executeActionInTab(params, hoverPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
134
494
  case "page.drag":
495
+ if (await wantsTrusted(params)) return trustedDrag(params);
135
496
  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]);
136
497
  case "page.upload":
137
498
  return executeActionInTab(params, uploadFiles, [params.selector ?? null, params.uid ?? null, params.files || []]);
138
499
  case "page.type":
500
+ if (await wantsTrusted(params)) return trustedType(params);
139
501
  return executeActionInTab(params, typeIntoPage, [params.selector ?? null, params.uid ?? null, params.text || "", Boolean(params.pressEnter)]);
140
502
  case "page.fill":
503
+ if (await wantsTrusted(params)) return trustedFill(params);
141
504
  return executeActionInTab(params, fillPage, [params.selector ?? null, params.uid ?? null, params.text || "", params.submit === true]);
142
505
  case "page.key":
506
+ if (await wantsTrusted(params)) return trustedKey(params);
143
507
  return executeActionInTab(params, pressKeyInPage, [params.key]);
144
508
  case "page.scroll":
509
+ if (await wantsTrusted(params)) return trustedScroll(params);
145
510
  return executeActionInTab(params, scrollPage, [params.selector ?? null, params.uid ?? null, params.deltaY ?? 0, params.deltaX ?? 0, params.steps ?? null]);
511
+ case "trusted.mode":
512
+ return setTrustedMode(params.mode);
513
+ case "trusted.status":
514
+ return trustedStatus();
146
515
  case "page.console.list":
147
516
  return executeInTab(params, listConsoleMessages, [params.clear === true]);
148
517
  case "page.network.list":
@@ -509,26 +509,77 @@ Usage rules:
509
509
  lines.push(`… Skipped MAIN-world capability checks because the loaded extension is stale.`);
510
510
  }
511
511
 
512
- // CDP availability hint.
513
- try {
514
- const controller = new AbortController();
515
- const timer = setTimeout(() => controller.abort(), 250);
516
- const response = await fetch("http://127.0.0.1:9222/json/version", { signal: controller.signal }).catch(() => undefined);
517
- clearTimeout(timer);
518
- if (response && response.ok) {
519
- const info = (await response.json().catch(() => ({}))) as { Browser?: string };
520
- lines.push(`✓ CDP endpoint reachable at 127.0.0.1:9222 (${info.Browser ?? "unknown"}). Trusted input via CDP is not yet wired into pi-chrome — reserved for a future release.`);
521
- } else {
522
- lines.push(`• CDP not available (no listener on 127.0.0.1:9222). Synthetic input only; autoplay/clipboard/file-picker gates cannot be satisfied. Future pi-chrome versions will use CDP for trusted input when this port is enabled.`);
512
+ // Trusted-input (chrome.debugger) probe.
513
+ if (extensionAlive && !versionMismatch) {
514
+ try {
515
+ const status = (await bridge.send("trusted.status", {}, 5_000)) as {
516
+ mode?: string;
517
+ attachedTabs?: number[];
518
+ permissionGranted?: boolean;
519
+ };
520
+ if (status.permissionGranted) {
521
+ lines.push(`✓ Trusted-input mode available via chrome.debugger (current: ${status.mode ?? "off"}${status.attachedTabs && status.attachedTabs.length ? `; attached to tab ${status.attachedTabs.join(",")}` : ""}). Pass trusted=true on chrome_click/type/etc, or run /chrome-trusted on, to satisfy isTrusted + user-activation gates.`);
522
+ } else {
523
+ lines.push(`⚠ chrome.debugger API unavailable. The extension is missing the "debugger" permission — reload the extension in chrome://extensions and accept the new permission prompt.`);
524
+ }
525
+ } catch (error) {
526
+ lines.push(`⚠ trusted.status probe failed: ${(error as Error).message}`);
523
527
  }
524
- } catch {
525
- lines.push(`• CDP probe inconclusive.`);
526
528
  }
527
529
 
528
530
  ctx.ui.notify(lines.join("\n"), "info");
529
531
  },
530
532
  });
531
533
 
534
+ pi.registerCommand("chrome-trusted", {
535
+ description:
536
+ "Toggle trusted-input mode for chrome_* tools. ON: all clicks/types/etc go through chrome.debugger (CDP) so events are browser-trusted (isTrusted=true) and satisfy user-activation gates (clipboard, fullscreen, autoplay, file picker). Tradeoff: Chrome pins a yellow 'started debugging this browser' banner to the top of any tab in use. OFF (default): synthetic DOM events. AUTO: attach on demand only when a per-call trusted=true is passed. STATUS: print current mode and attached tabs.",
537
+ getArgumentCompletions: (prefix) => {
538
+ const items = [
539
+ { value: "on", label: "on", description: "All chrome_* tools dispatch via CDP. Yellow debugger banner appears." },
540
+ { value: "off", label: "off", description: "Synthetic events only (default)." },
541
+ { value: "auto", label: "auto", description: "Use CDP only when a tool passes trusted=true." },
542
+ { value: "status", label: "status", description: "Show current trusted mode and any attached tabs." },
543
+ ];
544
+ const lowered = prefix.toLowerCase();
545
+ const matches = items.filter((item) => item.value.startsWith(lowered));
546
+ return matches.length > 0 ? matches : null;
547
+ },
548
+ handler: async (args, ctx) => {
549
+ const arg = (args || "").trim().toLowerCase();
550
+ if (arg === "status" || arg === "") {
551
+ try {
552
+ const status = (await bridge.send("trusted.status", {}, 5_000)) as { mode: string; attachedTabs: number[]; permissionGranted: boolean };
553
+ const attached = status.attachedTabs?.length ? ` (attached to tab ${status.attachedTabs.join(",")})` : "";
554
+ const perm = status.permissionGranted ? "" : " — chrome.debugger API unavailable; reload the extension and accept the new permission.";
555
+ ctx.ui.notify(`Trusted-input mode: ${status.mode}${attached}${perm}`, "info");
556
+ } catch (error) {
557
+ ctx.ui.notify(`Failed to read trusted mode: ${(error as Error).message}`, "warning");
558
+ }
559
+ return;
560
+ }
561
+ if (!["on", "off", "auto"].includes(arg)) {
562
+ ctx.ui.notify(`Unknown argument '${arg}'. Use: on | off | auto | status`, "warning");
563
+ return;
564
+ }
565
+ try {
566
+ const result = (await bridge.send("trusted.mode", { mode: arg }, 5_000)) as { mode: string };
567
+ if (result.mode === "on") {
568
+ ctx.ui.notify(
569
+ "Trusted-input mode ON. All chrome_* tools now dispatch through chrome.debugger (CDP). Chrome will show a yellow 'started debugging this browser' banner. Events arrive as isTrusted=true and satisfy user-activation gates.",
570
+ "info",
571
+ );
572
+ } else if (result.mode === "off") {
573
+ ctx.ui.notify("Trusted-input mode OFF. Synthetic events only. Any attached debugger sessions detached.", "info");
574
+ } else {
575
+ ctx.ui.notify("Trusted-input mode AUTO. CDP attaches only when a tool passes trusted=true.", "info");
576
+ }
577
+ } catch (error) {
578
+ ctx.ui.notify(`Failed to set trusted mode: ${(error as Error).message}`, "warning");
579
+ }
580
+ },
581
+ });
582
+
532
583
  pi.registerCommand("chrome-background", {
533
584
  description:
534
585
  "Toggle silent/background mode for chrome_* tools. Background ON: chrome_* tools act silently — your editor/terminal keeps focus, Chrome does not pop up, your workflow is not interrupted. Background OFF (default): Chrome focuses and activates the target tab so you can watch the agent work, useful for demos, pair-driving, and debugging — tradeoff: Chrome pops up and steals focus. Pass `on` / `off` to set explicitly, or no argument to toggle.",
@@ -728,7 +779,7 @@ Usage rules:
728
779
  name: "chrome_click",
729
780
  label: "Chrome Click",
730
781
  description:
731
- "Click a snapshot uid, CSS selector, or viewport coordinate in an existing Chrome tab through the companion extension. The click is dispatched as a synthetic DOM event; by default Chrome is focused so the user can watch, pass background=true to click silently. Pass includeSnapshot=true to return a fresh snapshot after the click.",
782
+ "Click a snapshot uid, CSS selector, or viewport coordinate in an existing Chrome tab through the companion extension. Defaults to synthetic DOM events (isTrusted=false). Pass trusted=true (or run /chrome-trusted on) to route through chrome.debugger so events arrive as browser-trusted and satisfy user-activation gates Chrome shows a yellow 'started debugging' banner while attached. Pass includeSnapshot=true to return a fresh snapshot after the click.",
732
783
  promptSnippet: "Click page elements in Chrome by snapshot uid, selector, or viewport coordinate.",
733
784
  parameters: Type.Object({
734
785
  uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot. Prefer uid over selector after taking a snapshot." })),
@@ -743,6 +794,7 @@ Usage rules:
743
794
  background: Type.Optional(
744
795
  Type.Boolean({ description: "If true, click silently without focusing Chrome. Default false." }),
745
796
  ),
797
+ 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." })),
746
798
  host: Type.Optional(Type.String()),
747
799
  port: Type.Optional(Type.Number()),
748
800
  }),
@@ -760,7 +812,7 @@ Usage rules:
760
812
  name: "chrome_type",
761
813
  label: "Chrome Type",
762
814
  description:
763
- "Focus an optional snapshot uid or CSS selector, then type text into an existing Chrome tab through the companion extension. By default focuses Chrome and activates the tab so the user can watch; pass background=true to type silently. Pass includeSnapshot=true to return a fresh snapshot after typing.",
815
+ "Focus an optional snapshot uid or CSS selector, then type text into an existing Chrome tab. Defaults to synthetic per-character keydown/beforeinput/input/keyup sequence. Pass trusted=true (or run /chrome-trusted on) to route through chrome.debugger so each keystroke is browser-trusted (isTrusted=true). Pass includeSnapshot=true to return a fresh snapshot after typing.",
764
816
  promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.",
765
817
  parameters: Type.Object({
766
818
  text: Type.String(),
@@ -775,6 +827,7 @@ Usage rules:
775
827
  background: Type.Optional(
776
828
  Type.Boolean({ description: "If true, type silently without focusing Chrome. Default false." }),
777
829
  ),
830
+ trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so each keystroke is browser-trusted. Triggers Chrome's debugger banner." })),
778
831
  host: Type.Optional(Type.String()),
779
832
  port: Type.Optional(Type.Number()),
780
833
  }),
@@ -807,6 +860,7 @@ Usage rules:
807
860
  background: Type.Optional(
808
861
  Type.Boolean({ description: "If true, fill silently without focusing Chrome. Default false." }),
809
862
  ),
863
+ trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP for browser-trusted input. Triggers Chrome's debugger banner." })),
810
864
  host: Type.Optional(Type.String()),
811
865
  port: Type.Optional(Type.Number()),
812
866
  }),
@@ -828,6 +882,12 @@ Usage rules:
828
882
  promptSnippet: "Press keys in Chrome through the companion extension.",
829
883
  parameters: Type.Object({
830
884
  key: Type.String(),
885
+ modifiers: Type.Optional(Type.Object({
886
+ shiftKey: Type.Optional(Type.Boolean()),
887
+ ctrlKey: Type.Optional(Type.Boolean()),
888
+ altKey: Type.Optional(Type.Boolean()),
889
+ metaKey: Type.Optional(Type.Boolean()),
890
+ }, { description: "Modifier keys to hold while pressing the key (chord). Only honoured for trusted-mode presses; synthetic path ignores." })),
831
891
  includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the keypress." })),
832
892
  maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
833
893
  targetId: Type.Optional(Type.String()),
@@ -836,6 +896,7 @@ Usage rules:
836
896
  background: Type.Optional(
837
897
  Type.Boolean({ description: "If true, send the key silently without focusing Chrome. Default false." }),
838
898
  ),
899
+ trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so the keystroke is browser-trusted." })),
839
900
  host: Type.Optional(Type.String()),
840
901
  port: Type.Optional(Type.Number()),
841
902
  }),
@@ -1006,6 +1067,7 @@ Usage rules:
1006
1067
  urlIncludes: Type.Optional(Type.String()),
1007
1068
  titleIncludes: Type.Optional(Type.String()),
1008
1069
  background: Type.Optional(Type.Boolean()),
1070
+ trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP for browser-trusted hover." })),
1009
1071
  }),
1010
1072
  async execute(_id, params): Promise<ToolTextResult> {
1011
1073
  const result = await bridge.send("page.hover", withBackground(params), DEFAULT_TIMEOUT_MS);
@@ -1032,6 +1094,7 @@ Usage rules:
1032
1094
  urlIncludes: Type.Optional(Type.String()),
1033
1095
  titleIncludes: Type.Optional(Type.String()),
1034
1096
  background: Type.Optional(Type.Boolean()),
1097
+ 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)." })),
1035
1098
  }),
1036
1099
  async execute(_id, params): Promise<ToolTextResult> {
1037
1100
  const result = await bridge.send("page.drag", withBackground(params), DEFAULT_TIMEOUT_MS);
@@ -1054,6 +1117,7 @@ Usage rules:
1054
1117
  urlIncludes: Type.Optional(Type.String()),
1055
1118
  titleIncludes: Type.Optional(Type.String()),
1056
1119
  background: Type.Optional(Type.Boolean()),
1120
+ trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch wheel events through chrome.debugger / CDP for browser-trusted scrolling." })),
1057
1121
  }),
1058
1122
  async execute(_id, params): Promise<ToolTextResult> {
1059
1123
  const result = await bridge.send("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "description": "Drive your existing logged-in Chrome from Pi — no re-login, no throwaway profile, watch the agent work in real time (or toggle quiet background mode).",
5
5
  "keywords": [
6
6
  "pi-package",