pi-chrome 0.15.11 → 0.15.13

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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable user-facing changes to `pi-chrome`.
4
4
 
5
+ ## 0.15.13 — 2026-05-14
6
+
7
+ - **Fix Chrome-side consent hang.** `/chrome authorize` now launches the browser consent page as a short command, then polls for the decision. This avoids holding one long extension command open while the user reads/clicks the page, which could leave Pi stuck at “Opening Chrome approval page…”.
8
+
9
+ ## 0.15.12 — 2026-05-14
10
+
11
+ - **Docs accuracy.** Clarified that the bundled Chrome extension currently polls `127.0.0.1:17318`; custom bridge ports are not supported without editing/reloading the extension source. Also softened the unpacked-extension rationale to avoid overstating Web Store limitations and fixed stale strict-CSP guidance for `chrome_evaluate`.
12
+
5
13
  ## 0.15.11 — 2026-05-14
6
14
 
7
15
  - **README cleanup.** Removed the Playwright/CDP/Selenium comparison table and low-signal Composes with / Contributing sections from the package page because they are noisy and easy to drift.
package/README.md CHANGED
@@ -237,17 +237,13 @@ If you build a competing tool, please open a PR with your scores. We benchmark i
237
237
 
238
238
  ## Security model & why unpacked
239
239
 
240
- **Unpacked on purpose.** A Web Store extension cannot talk to a local bridge controlled by another tool on the same machine so pi-chrome ships its bridge as an inspectable, MIT-licensed folder you load once with Developer Mode. Every line is yours to read in [`extensions/chrome-profile-bridge/browser-extension/`](./extensions/chrome-profile-bridge/browser-extension). `/chrome doctor` reports the loaded extension version and warns when it drifts from your installed `pi-chrome`.
240
+ **Unpacked on purpose.** pi-chrome ships as an inspectable, MIT-licensed extension folder you load once with Developer Mode, so the local bridge and browser permissions are easy to audit and update without a Web Store release cycle. Every line is yours to read in [`extensions/chrome-profile-bridge/browser-extension/`](./extensions/chrome-profile-bridge/browser-extension). `/chrome doctor` reports the loaded extension version and warns when it drifts from your installed `pi-chrome`.
241
241
 
242
242
  The companion extension runs in the Chrome profile where you install it and has broad tab/scripting permissions. Only install it from a package source you trust. Even after install, `chrome_*` tools stay locked until you run `/chrome authorize` in Pi and approve the browser-side consent page in Chrome. Use `/chrome revoke` to lock them again.
243
243
 
244
- The Pi side listens on `127.0.0.1:17318` by default and rejects browser-origin command requests; ordinary web pages cannot use CORS to drive the bridge. Override before starting Pi:
244
+ The Pi side listens on `127.0.0.1:17318` and rejects browser-origin command requests; ordinary web pages cannot use CORS to drive the bridge. The bundled Chrome extension currently polls that default port, so custom bridge ports are not supported without editing the extension source and reloading it.
245
245
 
246
- ```bash
247
- PI_CHROME_BRIDGE_PORT=17319 pi
248
- ```
249
-
250
- There is no network exposure; the bridge binds to loopback only.
246
+ There is no network exposure in the default configuration; the bridge binds to loopback only.
251
247
 
252
248
  ---
253
249
 
package/SECURITY.md CHANGED
@@ -29,11 +29,9 @@ The Chrome extension under `extensions/chrome-profile-bridge/browser-extension/`
29
29
  - Chrome control locked by default; `/chrome authorize` opens a Chrome consent page, approval unlocks current Pi session, `/chrome revoke` locks it again.
30
30
  - Run-in-background optional; tab/window focus is observable by default (the user can see Pi acting).
31
31
 
32
- ## Override the port
32
+ ## Custom ports
33
33
 
34
- ```bash
35
- PI_CHROME_BRIDGE_PORT=17319 pi
36
- ```
34
+ The bundled Chrome extension currently polls `127.0.0.1:17318`. Custom bridge ports are not supported without editing the extension source and reloading it.
37
35
 
38
36
  ## Supported versions
39
37
 
package/docs/FAQ.md CHANGED
@@ -51,7 +51,7 @@ The Pi-facing tools are thin wrappers around an HTTP bridge at `127.0.0.1:17318`
51
51
 
52
52
  ## Does `chrome_evaluate` work on strict-CSP pages?
53
53
 
54
- Yes. The handler compiles with `new Function(...)` in the MAIN world, which works under `script-src 'self'` without `'unsafe-eval'`. Multi-statement bodies are supported via a statement-mode fallback. Exceptions are surfaced to the agent.
54
+ Not always. `chrome_evaluate` and `chrome_snapshot` run in the page's MAIN world through the Function constructor, so pages whose CSP blocks `'unsafe-eval'` can reject them. `chrome_screenshot`, `chrome_navigate`, tab tools, and real Chrome input still work because they use extension/browser APIs rather than page JavaScript.
55
55
 
56
56
  ## Why does my click return `pageMutated=false`?
57
57
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.15.11",
4
+ "version": "0.15.13",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -4,7 +4,8 @@ const POLL_ERROR_BACKOFF_MS = 2000;
4
4
  const CONSENT_TIMEOUT_MS = 5 * 60 * 1000;
5
5
  let polling = false;
6
6
  let nextConsentRequestId = 1;
7
- const pendingConsentRequests = new Map(); // id -> { request, resolve, timer, tabId }
7
+ const pendingConsentRequests = new Map(); // id -> { request, timer, tabId }
8
+ const completedConsentRequests = new Map(); // id -> { approved, reason, id, decidedAt }
8
9
 
9
10
  // =================== Chrome input (CDP) layer ===================
10
11
  // Tracks which tabs we have attached chrome.debugger to.
@@ -607,35 +608,45 @@ function consentRequestSnapshot(id, request) {
607
608
  };
608
609
  }
609
610
 
611
+ function completeBrowserConsent(id, approved, reason, closeTab = true) {
612
+ const pending = pendingConsentRequests.get(id);
613
+ if (!pending) return completedConsentRequests.get(id) || null;
614
+ pendingConsentRequests.delete(id);
615
+ clearTimeout(pending.timer);
616
+ if (closeTab && pending.tabId) chrome.tabs.remove(pending.tabId).catch(() => undefined);
617
+ const result = { approved, reason, id, decidedAt: Date.now() };
618
+ completedConsentRequests.set(id, result);
619
+ setTimeout(() => completedConsentRequests.delete(id), 10 * 60 * 1000);
620
+ return result;
621
+ }
622
+
610
623
  async function requestBrowserConsent(params) {
611
624
  const id = String(nextConsentRequestId++);
612
625
  const request = {
613
626
  ...params,
614
627
  requestedAt: Date.now(),
615
628
  };
629
+ const timer = setTimeout(() => completeBrowserConsent(id, false, "timed out waiting for browser approval"), CONSENT_TIMEOUT_MS);
630
+ pendingConsentRequests.set(id, { request, timer, tabId: null });
616
631
  const url = chrome.runtime.getURL(`consent.html?id=${encodeURIComponent(id)}`);
617
- const decision = new Promise((resolve) => {
618
- const finish = (approved, reason) => {
619
- const pending = pendingConsentRequests.get(id);
620
- if (!pending) return;
621
- pendingConsentRequests.delete(id);
622
- clearTimeout(pending.timer);
623
- if (pending.tabId) chrome.tabs.remove(pending.tabId).catch(() => undefined);
624
- resolve({ approved, reason, id, decidedAt: Date.now() });
625
- };
626
- const timer = setTimeout(() => finish(false, "timed out waiting for browser approval"), CONSENT_TIMEOUT_MS);
627
- pendingConsentRequests.set(id, { request, resolve: finish, timer, tabId: null });
628
- });
629
632
  try {
630
633
  const tab = await chrome.tabs.create({ url, active: true });
631
634
  if (tab.windowId !== undefined) await chrome.windows.update(tab.windowId, { focused: true }).catch(() => undefined);
632
635
  const pending = pendingConsentRequests.get(id);
633
636
  if (pending) pending.tabId = tab.id;
634
637
  } catch (error) {
635
- const pending = pendingConsentRequests.get(id);
636
- if (pending) pending.resolve(false, `could not open consent tab: ${error?.message || error}`);
638
+ completeBrowserConsent(id, false, `could not open consent tab: ${error?.message || error}`, false);
637
639
  }
638
- return await decision;
640
+ return { id, opened: pendingConsentRequests.has(id), timeoutMs: CONSENT_TIMEOUT_MS };
641
+ }
642
+
643
+ function browserConsentStatus(params) {
644
+ const id = String(params?.id || "");
645
+ const completed = completedConsentRequests.get(id);
646
+ if (completed) return { state: "done", ...completed };
647
+ const pending = pendingConsentRequests.get(id);
648
+ if (pending) return { state: "pending", id, requestedAt: pending.request.requestedAt, timeoutMs: CONSENT_TIMEOUT_MS };
649
+ return { state: "missing", id, approved: false, reason: "consent request expired or not found" };
639
650
  }
640
651
 
641
652
  chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
@@ -648,12 +659,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
648
659
  }
649
660
  if (message.type === "piChromeConsentDecision") {
650
661
  const id = String(message.id || "");
651
- const pending = pendingConsentRequests.get(id);
652
- if (!pending) {
662
+ const result = completeBrowserConsent(id, message.approved === true, message.approved === true ? "approved in Chrome" : "denied in Chrome");
663
+ if (!result) {
653
664
  sendResponse({ ok: false, error: "Consent request expired or not found" });
654
665
  return true;
655
666
  }
656
- pending.resolve(message.approved === true, message.approved === true ? "approved in Chrome" : "denied in Chrome");
657
667
  sendResponse({ ok: true });
658
668
  return true;
659
669
  }
@@ -662,11 +672,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
662
672
 
663
673
  chrome.tabs.onRemoved.addListener((tabId) => {
664
674
  for (const [id, pending] of pendingConsentRequests) {
665
- if (pending.tabId === tabId) {
666
- pending.resolve(false, "consent tab closed");
667
- pendingConsentRequests.delete(id);
668
- clearTimeout(pending.timer);
669
- }
675
+ if (pending.tabId === tabId) completeBrowserConsent(id, false, "consent tab closed", false);
670
676
  }
671
677
  });
672
678
 
@@ -767,6 +773,8 @@ async function dispatch(action, params) {
767
773
  };
768
774
  case "consent.request":
769
775
  return requestBrowserConsent(params);
776
+ case "consent.status":
777
+ return browserConsentStatus(params);
770
778
  case "tab.list":
771
779
  return (await chrome.tabs.query({})).map(formatTab);
772
780
  case "tab.new": {
@@ -463,6 +463,8 @@ export default function (pi: ExtensionAPI): void {
463
463
  return bridge.send(action, params, timeoutMs);
464
464
  };
465
465
 
466
+ const sleep = (ms: number) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
467
+
466
468
  // Translate the public `background` parameter (default false = visible/foreground) into the
467
469
  // service worker's wire-level `foreground` flag, accepting legacy `foreground` as a fallback.
468
470
  const withBackground = <T extends Record<string, unknown>>(params: T): T => {
@@ -610,14 +612,14 @@ Usage rules:
610
612
 
611
613
  const authorizeFor = async (ctx: ExtensionContext, label: string, until: number | "indefinite") => {
612
614
  ctx.ui.notify("Opening Chrome approval page…", "info");
613
- let consent: { approved?: boolean; reason?: string };
615
+ let request: { id?: string; opened?: boolean; timeoutMs?: number };
614
616
  try {
615
- consent = (await bridge.send("consent.request", {
617
+ request = (await bridge.send("consent.request", {
616
618
  durationLabel: label,
617
619
  workspace: workspaceCwd(ctx),
618
620
  pid: process.pid,
619
621
  piChromeVersion: PI_CHROME_VERSION,
620
- }, 5 * 60_000 + 5_000)) as { approved?: boolean; reason?: string };
622
+ }, 10_000)) as { id?: string; opened?: boolean; timeoutMs?: number };
621
623
  } catch (error) {
622
624
  const message = (error as Error).message;
623
625
  const hint = message.includes("Unknown action: consent.request")
@@ -626,12 +628,36 @@ Usage rules:
626
628
  ctx.ui.notify(`Chrome approval failed: ${message}\n${hint}`, "warning");
627
629
  return;
628
630
  }
629
- if (!consent.approved) {
630
- ctx.ui.notify(`Chrome control remains locked${consent.reason ? ` (${consent.reason})` : ""}.`, "info");
631
+ if (!request.id) {
632
+ ctx.ui.notify("Chrome approval failed: companion extension did not return a consent request id.", "warning");
633
+ return;
634
+ }
635
+ if (!request.opened) {
636
+ ctx.ui.notify("Chrome approval failed: companion extension could not open the consent page.", "warning");
631
637
  return;
632
638
  }
633
- chromeAuthorizedUntil = until;
634
- ctx.ui.notify(`Chrome control authorized for ${label}.`, "info");
639
+
640
+ const deadline = Date.now() + (request.timeoutMs ?? 5 * 60_000);
641
+ while (Date.now() < deadline + 2_000) {
642
+ await sleep(700);
643
+ let status: { state?: string; approved?: boolean; reason?: string };
644
+ try {
645
+ status = (await bridge.send("consent.status", { id: request.id }, 5_000)) as { state?: string; approved?: boolean; reason?: string };
646
+ } catch (error) {
647
+ ctx.ui.notify(`Chrome approval failed: ${(error as Error).message}`, "warning");
648
+ return;
649
+ }
650
+ if (status.state !== "pending") {
651
+ if (!status.approved) {
652
+ ctx.ui.notify(`Chrome control remains locked${status.reason ? ` (${status.reason})` : ""}.`, "info");
653
+ return;
654
+ }
655
+ chromeAuthorizedUntil = until;
656
+ ctx.ui.notify(`Chrome control authorized for ${label}.`, "info");
657
+ return;
658
+ }
659
+ }
660
+ ctx.ui.notify("Chrome control remains locked (timed out waiting for browser approval).", "info");
635
661
  };
636
662
 
637
663
  const parseAuthorizeArg = (arg: string): { label: string; until: number | "indefinite" } | undefined => {
@@ -835,7 +861,7 @@ Usage rules:
835
861
  "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.",
836
862
  promptSnippet: "Show instructions for connecting Pi to the user's existing Chrome profile via the companion extension.",
837
863
  parameters: Type.Object({
838
- port: Type.Optional(Type.Number({ description: "Ignored unless PI_CHROME_BRIDGE_PORT is set before Pi starts." })),
864
+ port: Type.Optional(Type.Number({ description: "Ignored. The bundled Chrome extension polls 127.0.0.1:17318." })),
839
865
  url: Type.Optional(Type.String({ description: "Optional URL to open in the existing Chrome profile after the extension is connected." })),
840
866
  userDataDir: Type.Optional(Type.String({ description: "Ignored. This bridge intentionally uses the user's existing Chrome profile through the companion extension." })),
841
867
  useDefaultProfile: Type.Optional(Type.Boolean({ description: "Ignored; existing-profile access comes from the companion Chrome extension." })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.15.11",
3
+ "version": "0.15.13",
4
4
  "scripts": {
5
5
  "version": "node scripts/sync-manifest-version.js",
6
6
  "prepublishOnly": "node scripts/sync-manifest-version.js"