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 +8 -0
- package/README.md +3 -7
- package/SECURITY.md +2 -4
- package/docs/FAQ.md +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +32 -24
- package/extensions/chrome-profile-bridge/index.ts +34 -8
- package/package.json +1 -1
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.**
|
|
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`
|
|
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
|
-
|
|
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
|
-
##
|
|
32
|
+
## Custom ports
|
|
33
33
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
|
652
|
-
if (!
|
|
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
|
|
615
|
+
let request: { id?: string; opened?: boolean; timeoutMs?: number };
|
|
614
616
|
try {
|
|
615
|
-
|
|
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
|
-
},
|
|
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 (!
|
|
630
|
-
ctx.ui.notify(
|
|
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
|
-
|
|
634
|
-
|
|
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
|
|
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." })),
|