aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-05-16 11:55:27 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-05-16 11:55:27 -0400
commitad78b41140f9040094295a28c5ab6482483efa4f (patch)
treeba36801d51069779d76fe12ed94c9e73c343cdaa
parent7992115c6dd9884e1a1f2992db531342d2916fcd (diff)
0.16.0: mandatory pairing + signed envelopes + cross-process state coherence
Major security hardening of the Chrome bridge driven by red-team findings. - Mandatory pairing via /chrome onboard (one-time per machine). Bridge pins exact chrome-extension:// origin and derives HMAC keys via HKDF. Until paired, the bridge returns idle responses and refuses to deliver commands. - Signed envelopes on /next, /result, /command. HMAC-SHA256 over protocol version, direction, method, path, extension ID, bridge ID, timestamp, fresh nonce, and SHA-256 of the body. +/-30s clock window; 5-min LRU nonce replay defense on both request and response directions; constant-time MAC compare. - Peer Pi sessions sharing the bridge sign each /command with a broker HKDF key (no bearer token transmitted) and verify the owner's signed response. - Global loopback enforcement. PI_CHROME_BRIDGE_HOST=0.0.0.0 ignored unless PI_CHROME_BRIDGE_DANGEROUS_REMOTE=1; loopback check still applies per-endpoint. - Cross-process state coherence: fs.watch + per-request mtime check on ~/.config/pi/chrome-bridge.json. A peer Pi's unpair (or any external rm/edit) propagates to the owner's in-memory state before the next privileged verify; replay caches reset on key rotation. - Body cap 1 MiB -> 413. Queue/pending/waiter caps. Malformed JSON -> 400. Non-object JSON bodies -> 400 instead of being trusted. - page.navigate initScript: try/finally around tabs.update, per-call onCommitted listener with bounded commit-wait window, one-shot delete in global onCommitted. No more stale init scripts firing on later unrelated navigations. - crypto.randomUUID() command IDs. - Manifest diet (dropped activeTab); added popup.html/popup.js for the pair UI. UX: - /chrome onboard runs install + pair in one interactive flow. - /chrome help reorganized into First-time / Day-to-day / Maintenance. - Status bar lights up as green dot only when authorized. - Early console/network capture at document_start so boot-time React errors and initial API calls aren't dropped (ported from DaniBedz/pi-chrome fork). Docs: - SECURITY.md threat model rewritten with in-scope / out-of-scope sections. - README rewritten with the install-then-pair flow front and center. - CHANGELOG 0.16.0 entry with full patch list.
-rw-r--r--CHANGELOG.md47
-rw-r--r--README.md42
-rw-r--r--SECURITY.md38
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/manifest.json6
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/popup.html42
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/popup.js46
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/service_worker.js474
-rw-r--r--extensions/chrome-profile-bridge/index.ts983
-rw-r--r--package.json2
-rwxr-xr-xscripts/security-smoke.sh98
10 files changed, 1685 insertions, 93 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 60db118..383df6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,53 @@
All notable user-facing changes to `pi-chrome`.
+## 0.16.0 — 2026-05-16
+
+### Onboarding UX (same release)
+
+- **`/chrome onboard` now runs both install + pair in one flow.** Two-step interactive walkthrough: (1) install/reload the Chrome extension (with auto-open of `chrome://extensions` + Finder + clipboard on macOS), (2) mint an invite, copy to clipboard, and wait for the user to paste into the extension popup. Returning users with an already-connected extension can skip step 1; users with an existing pairing get an explicit re-pair prompt. Pairing is no longer a separate step users have to remember.
+- **`/chrome` command help reorganized** into First-time setup / Day-to-day / Maintenance groups so `onboard → authorize` is the obvious path.
+- **Linux clipboard support** in the onboard/pair flow (tries `xclip` → `xsel` → `wl-copy`). macOS still uses `pbcopy`. Other platforms surface the invite text only; user copies manually.
+- **README rewritten** with the install-then-pair flow front and center, a new "Why pairing?" section explaining the threat model in plain English, and a command-reference table.
+
+### Ported from fork DaniBedz/pi-chrome (same release)
+
+- **Early console/network capture at document_start.** Previously `chrome_list_console_messages` and `chrome_list_network_requests` returned empty for errors and requests fired during initial page load, because instrumentation only installed lazily when a chrome_* tool first ran. A new `chrome.webNavigation.onCommitted` listener now injects a self-contained `installEarlyCapture()` into MAIN world with `injectImmediately:true`, wrapping `console.{debug,log,info,warn,error}`, `fetch`, and `XMLHttpRequest` plus `error`/`unhandledrejection` listeners before the page's own code runs. Same `window.__PI_CHROME_STATE__` schema as the lazy path; idempotent via `__piChromeWrapped`. Boot-time React errors and initial API calls now appear in the console/network logs.
+- **Quieter startup + dynamic status bar.** Removed the `session_start` info notify and the always-on `Chrome bridge :17318` status entry. The status bar now lights up as a green ● only after `/chrome authorize`, and clears on `/chrome revoke`. Onboarding (`/chrome onboard`) and pairing (`/chrome pair`) still print actionable instructions when invoked.
+
+### Test-driven follow-ups (same release)
+
+- **Paired `/next` distinguishes attack signal from migration.** With a paired bridge, a pinned origin that sends a present-but-invalid auth header (bad sig, replayed nonce, or stale ts) now gets a hard `401` instead of `200 needsPairing`. Missing-header from any chrome-extension origin still returns the soft migration response (so older unsigned extensions can read the version header and auto-reload). SW handles the new 401 by surfacing the pair badge and backing off; no exception throw, no auto-clear of keys.
+- **Owner re-reads pairing state on every privileged request.** `BridgeAuth.refreshIfStale()` stats `~/.config/pi/chrome-bridge.json` and reloads if mtime changed (microseconds when unchanged), so cross-process mutations of the file — e.g. a peer running `/chrome unpair`, or an external `rm` — propagate immediately to the owner's in-memory state. Replay caches are dropped on key rotation. An `fs.watch` on the config directory is also installed as a prompt secondary signal; the per-request mtime check guarantees correctness even if the watch is missed.
+
+### Red-team review follow-ups (same release)
+
+- **CORS now allows/exposes the auth header** (`x-pi-chrome-auth`). Without this, some `fetch` implementations strip it on cross-origin extension responses.
+- **Renamed header from `x-pi-pi-auth` to `x-pi-chrome-auth`.** Internal-only rename before any 0.16.x release.
+- **`brokerKey` no longer leaves the Pi-side config.** The `/pair` response only returns `extensionPairKey`. Compromised extensions therefore can't extract the broker key used for peer-Pi `/command`.
+- **Replay defense on response paths.** Added per-direction LRU nonce caches for `bridge->ext` (extension side) and `owner->peer` (peer-Pi side). The signature spec already covered these directions; the caches now actually enforce single-use nonces.
+- **`reset()` and `persist()` chmod existing files.** `writeFileSync(mode)` is a no-op on an existing file; we now `chmodSync` to `0600` (and parent dir `0700`) on every write.
+- **`readJsonBody` rejects non-object JSON** (null/array/string) with 400 instead of letting it through.
+- **Top-level Content-Length precheck.** Oversized requests are 413'd before any auth/per-endpoint logic runs.
+- **Stale-auth handling on the extension side.** When `/chrome unpair` is run on the Pi and the extension still has cached keys, the SW no longer loops on "bad /next signature". The unsigned-idle `{type:"none", needsPairing:true}` shape is accepted without a signature *only* when the bridge sends no auth header (never with a command), so the pair badge is surfaced and the user re-pairs from the popup.
+
+### Original 0.16.0 changes
+
+### Security hardening (red-team driven)
+
+- **Mandatory pairing.** New `/chrome pair` issues a one-time invite the user pastes into the Chrome extension popup. Pairing pins the exact `chrome-extension://<id>` origin and derives HMAC keys via HKDF. Until paired, the bridge returns idle responses and refuses to deliver commands. `/chrome unpair` resets.
+- **Signed envelopes on every privileged endpoint.** `/next`, `/result`, and `/command` now require an `X-Pi-Pi-Auth: v1 ts=... nonce=... sig=...` header signing protocol version, direction, method, path, extension ID, bridge ID, timestamp, nonce, and SHA-256 of the body. ±30 s clock window, 5-min LRU nonce replay defense, constant-time MAC compare. Bridge responses are signed too; the extension and peer Pi sessions verify before accepting commands or results.
+- **Peer Pi sessions no longer ship a bearer token.** `/command` between Pi processes uses per-request signatures keyed by the broker HKDF key, and the owner's response is signed for verification. A pre-bind impostor without the pairing secret cannot impersonate or extract the keys.
+- **Global loopback enforcement.** Every endpoint rejects non-loopback `remoteAddress`. `PI_CHROME_BRIDGE_HOST=0.0.0.0` is ignored unless `PI_CHROME_BRIDGE_DANGEROUS_REMOTE=1` is set, and even then the loopback check still applies.
+- **Resource caps.** 1 MiB body cap (413), 256 queued/pending commands, 4 concurrent `/next` long-pollers (429). Malformed JSON now returns 400 instead of 500.
+- **`crypto.randomUUID()` for command IDs** (was `Date.now()+Math.random()`).
+- **`page.navigate` initScript** is now wrapped in `try/finally` around `chrome.tabs.update` and deleted in `onCommitted` *before* injection, so an aborted navigation cannot leave a stale init script armed for the next URL the user visits in that tab.
+- **Manifest diet.** Dropped unused `activeTab` permission. Added popup UI (`popup.html` / `popup.js`) for pasting the pairing invite.
+
+### Migration
+
+Existing installations must run `/chrome pair` once after upgrading. Old extensions in the field receive a benign idle response plus the `x-pi-chrome-version` header, which still triggers the existing auto-reload path; the reloaded extension then surfaces a `pair` action badge.
+
## 0.15.20 — 2026-05-15
- **Interruptible `chrome_*` tools.** All `chrome_*` tools now honor the agent harness `AbortSignal`, so pressing Esc aborts in-flight bridge calls (including the long-polling `chrome_wait_for`) immediately instead of waiting out the full `timeoutMs`.
diff --git a/README.md b/README.md
index 03cdb3b..ca58945 100644
--- a/README.md
+++ b/README.md
@@ -21,28 +21,56 @@ You: [keeps coding — agent never asked you to log in]
pi install npm:pi-chrome
```
-Then in Pi:
+Then in Pi (do this **once per machine**):
```text
/chrome onboard
```
-On macOS this opens `chrome://extensions`, reveals the bundled `browser-extension/` folder in Finder, and copies its path to your clipboard. In Chrome: **Developer mode** → **Load unpacked** → paste the path. Done.
+Walks you through a two-step interactive flow:
-Verify, then authorize current Pi session from the terminal:
+1. **Install the Chrome extension.** On macOS, pi-chrome opens `chrome://extensions`, reveals the bundled `browser-extension/` folder in Finder, and copies its path to your clipboard. In Chrome: **Developer mode** → **Load unpacked** → paste the path. Press Enter in Pi when the extension shows up.
+2. **Pair the extension with this Pi bridge.** Pi mints a one-time `pcp_…` invite (also copied to clipboard). Click the **Pi Chrome Connector** toolbar icon in Chrome, paste the invite into the popup, and click *Pair*. Press Enter in Pi to confirm.
+
+Pairing binds your specific Chrome extension instance to this Pi config via an HMAC handshake. Without it the bridge cannot tell your extension apart from any other Chrome extension on the same browser, so it refuses to deliver any commands at all. See [Why pairing?](#why-pairing) below.
+
+Then, in any Pi session where you want to use chrome_* tools:
+
+```text
+/chrome authorize 15m # or 30m, or indefinite
+```
+
+Verify anytime:
```text
/chrome doctor
-/chrome authorize
```
```text
-Performing Chrome bridge health check
pi-chrome v<version>
-• Local bridge: mode=server, url=http://127.0.0.1:17318
-✓ Companion Chrome extension responding (ID: <chrome-extension-id>, ext v<version>)
+• This pi session is running the Chrome connection for this machine.
+✓ Bridge paired with extension <chrome-extension-id> (bridgeId <id>).
+✓ Chrome is connected (companion extension v<version>, responded in <ms>ms).
+✓ pi-chrome can run code in the active Chrome tab.
```
+### Why pairing?
+
+The bridge listens on `127.0.0.1:17318`. Any Chrome extension with localhost host permissions — not just pi-chrome — can `fetch()` that port; Chrome's origin model blocks ordinary web pages but not extension code. Without an explicit pairing step, an unrelated extension could poll for queued commands (which include selectors, typed text, eval source, file-upload paths) or post forged results. `/chrome onboard` fixes that by deriving a shared HMAC key during the popup paste step and pinning the exact extension ID. After that, every `/next` / `/result` / `/command` request must be signed; replays and mismatched origins are rejected. Same-user malware that can read `~/.config/pi/chrome-bridge.json` is explicitly out of scope; see [SECURITY.md](./SECURITY.md).
+
+### Day-to-day commands
+
+| Command | When to use |
+|---|---|
+| `/chrome authorize [15m\|30m\|<min>\|indefinite]` | Per Pi session, unlock chrome_* tools. |
+| `/chrome revoke` | Lock chrome_* tools again. |
+| `/chrome status` | One-line summary: connection / auth / background mode. |
+| `/chrome doctor` | Full health check; tells you exactly what's missing. |
+| `/chrome onboard` | First-time setup, or re-install + re-pair after a Chrome profile move. |
+| `/chrome pair` | Re-pair an already-installed extension (subset of `onboard`). |
+| `/chrome unpair` | Clear pairing keys; every Pi session will need to re-pair. |
+| `/chrome background [on\|off\|status\|toggle]` | Run without focusing Chrome. |
+
---
## Try this in 30 seconds after install
diff --git a/SECURITY.md b/SECURITY.md
index ffc2c06..943352b 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -6,17 +6,39 @@ Open a GitHub issue prefixed with `[security]` at https://github.com/tianrendong
## Threat model
-`pi-chrome` is a developer tool you install knowingly. It is **not** designed to defend against:
+`pi-chrome` is a developer tool you install knowingly.
-- Hostile pages running in your Chrome trying to detect or escape automation. (Standard browser security boundaries still apply, but a hostile page that already runs in your tab can do anything that page can already do.)
-- Other processes on your local machine. The bridge binds to `127.0.0.1:17318` (loopback only) and chrome_* tools require `/chrome authorize` inside Pi, but the bridge does not authenticate arbitrary non-browser local callers. If your threat model includes hostile local processes running as you, run pi-chrome on a separate user account.
+### In scope (designed to defend against)
-`pi-chrome` **is** designed to:
+- **Ordinary web pages.** The bridge rejects browser-origin command requests; CORS cannot be used to drive Chrome.
+- **Drive-by local processes / curl without the pairing secret.** Every privileged endpoint (`/next`, `/result`, `/command`) requires a signed envelope with an HMAC key established during explicit pairing. A random local process that has not been paired cannot poll commands, post results, or invoke `/command`.
+- **Malicious Chrome extensions on the same browser.** The bridge pins the exact `chrome-extension://<id>` origin established at pairing time. Any other extension polling `/next` receives only an unsigned idle response (`type:"none"` + `needsPairing:true`); it never sees queued commands. An attacker who somehow learns a command ID still cannot forge a `/result` without the extension's HMAC key. The unsigned idle path exists purely so older or unrelated extensions can read the `x-pi-chrome-version` header and trigger the auto-reload migration path.
+- **Pre-bind localhost impostors.** Pairing establishes mutual HMAC keys before any command is delivered. The Pi peer that talks to the bridge owner via `/command` signs every request and verifies the owner's signed response, so a process that binds `:17318` first cannot impersonate either side without the keys.
+- **Replay / interleaving.** Every signed envelope binds protocol version, direction, method, path, extension ID, bridge ID, timestamp, fresh nonce, and SHA-256 of the body. Timestamps must be within ±30 s and nonces are rejected on reuse.
+- **`/chrome authorize` bypass.** Even an attacker who can reach the bridge cannot drive Chrome from chrome_* tools unless the current Pi session also authorized control.
-- Never exfiltrate page state to the network. All communication is loopback (`127.0.0.1`).
-- Surface every action with an honest result envelope so the agent can't silently do the wrong thing.
-- Keep Chrome control locked until the user explicitly runs `/chrome authorize` in the current Pi session.
-- Reject browser-origin command requests to the loopback bridge so ordinary web pages cannot use CORS to drive Chrome.
+### Out of scope (cannot defend against)
+
+- **Same-user malware.** A process running under your user account can read `~/.config/pi/chrome-bridge.json` (mode 0600), replace the Chrome extension, attach a debugger to Pi or Chrome, or use OS automation APIs. If your threat model includes hostile local processes running as you, isolate pi-chrome on a separate user account or VM.
+- **Hostile pages running in your tabs.** Standard browser security boundaries apply. A page that already runs in a tab can read its own DOM. MAIN-world helpers (`window.__PI_CHROME_STATE__`) are page-tamperable; treat snapshot UIDs as advisory for dangerous actions on untrusted origins.
+- **Network adversaries.** All communication is loopback. There is no network path to defend.
+
+### Properties (always-on)
+
+- **Loopback only.** Every endpoint rejects non-loopback `remoteAddress`. `PI_CHROME_BRIDGE_HOST=0.0.0.0` is ignored unless `PI_CHROME_BRIDGE_DANGEROUS_REMOTE=1` is set, and even then the per-endpoint loopback check still rejects remote callers.
+- **Resource caps.** 1 MiB request body cap (413), 256 queued/pending commands, 4 concurrent long-pollers.
+- **No telemetry.** All state stays on disk under your config dir or in the extension's `chrome.storage.local`.
+- **Fail-closed.** Missing or invalid auth headers, expired timestamps, replayed nonces, and origin mismatches all return errors with no side effects.
+
+## Pairing flow
+
+1. Run `/chrome pair` in Pi. Pi generates a 32-byte invite, prints `pcp_<base64url>` (also copied to clipboard on macOS), and arms `/pair` for 10 minutes.
+2. Open the **Pi Chrome Connector** extension popup in Chrome, paste the invite, click *Pair*.
+3. The extension signs its identity with the invite key. The bridge verifies, derives two HKDF keys (one for extension↔bridge `/next`/`/result`, one for peer Pi↔owner Pi `/command`), pins the exact extension ID, and returns *only* the `extensionPairKey` over a response signed with the invite secret (so the extension proves it's talking to the same Pi process that issued the invite). The `brokerKey` stays Pi-side, persisted in `~/.config/pi/chrome-bridge.json`, and is never sent to the browser.
+4. Both sides persist their copies: Pi at `~/.config/pi/chrome-bridge.json` mode 0600; extension in `chrome.storage.local`.
+5. The invite is destroyed.
+
+Run `/chrome unpair` to clear pairing keys; every Pi session and the Chrome extension popup will then need to re-pair.
## The companion extension
diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json
index 0d33b58..81a9ade 100644
--- a/extensions/chrome-profile-bridge/browser-extension/manifest.json
+++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json
@@ -1,13 +1,12 @@
{
"manifest_version": 3,
"name": "Pi Chrome Connector",
- "version": "0.15.20",
+ "version": "0.16.0",
"description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
"permissions": [
"tabs",
"scripting",
"storage",
- "activeTab",
"alarms",
"webNavigation",
"debugger"
@@ -20,6 +19,7 @@
"service_worker": "service_worker.js"
},
"action": {
- "default_title": "Pi Chrome Connector"
+ "default_title": "Pi Chrome Connector",
+ "default_popup": "popup.html"
}
}
diff --git a/extensions/chrome-profile-bridge/browser-extension/popup.html b/extensions/chrome-profile-bridge/browser-extension/popup.html
new file mode 100644
index 0000000..636fd63
--- /dev/null
+++ b/extensions/chrome-profile-bridge/browser-extension/popup.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8" />
+ <title>Pi Chrome Connector</title>
+ <style>
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; width: 320px; margin: 0; padding: 12px; }
+ h1 { font-size: 14px; margin: 0 0 8px; }
+ .row { margin: 8px 0; }
+ code { font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
+ input[type=text] { width: 100%; box-sizing: border-box; padding: 6px; font-family: ui-monospace, Menlo, monospace; }
+ button { padding: 6px 10px; cursor: pointer; }
+ .ok { color: #16a34a; }
+ .warn { color: #d97706; }
+ .err { color: #dc2626; }
+ .muted { color: #6b7280; font-size: 11px; }
+ </style>
+</head>
+<body>
+ <h1>Pi Chrome Connector</h1>
+ <div id="status" class="row muted">Loading…</div>
+ <div id="paired-info" class="row" hidden>
+ <div>Paired ✓</div>
+ <div class="muted">extension: <code id="ext-id"></code></div>
+ <div class="muted">bridge: <code id="bridge-id"></code></div>
+ <div class="row"><button id="unpair-btn">Unpair</button></div>
+ </div>
+ <div id="pair-form">
+ <div class="row">
+ Run <code>/chrome pair</code> in your Pi terminal to get an invite, then paste it here:
+ </div>
+ <div class="row">
+ <input id="invite" type="text" placeholder="pcp_..." autofocus />
+ </div>
+ <div class="row">
+ <button id="pair-btn">Pair</button>
+ </div>
+ <div id="result" class="row"></div>
+ </div>
+ <script src="popup.js"></script>
+</body>
+</html>
diff --git a/extensions/chrome-profile-bridge/browser-extension/popup.js b/extensions/chrome-profile-bridge/browser-extension/popup.js
new file mode 100644
index 0000000..cc890c0
--- /dev/null
+++ b/extensions/chrome-profile-bridge/browser-extension/popup.js
@@ -0,0 +1,46 @@
+// Popup UI for pi-chrome pairing.
+//
+// Talks to the service worker via chrome.runtime.sendMessage. The service worker performs the
+// HTTP /pair handshake against the local Pi bridge and stores the resulting HMAC keys in
+// chrome.storage.local. This popup never sees raw keys; it only sees pair success/failure and
+// the paired bridgeId.
+
+const $ = (id) => document.getElementById(id);
+
+async function refresh() {
+ const reply = await chrome.runtime.sendMessage({ type: "pi-chrome-status" });
+ if (!reply?.ok) return;
+ $("status").textContent = "";
+ if (reply.paired) {
+ $("paired-info").hidden = false;
+ $("pair-form").hidden = true;
+ $("ext-id").textContent = reply.extensionId;
+ $("bridge-id").textContent = reply.bridgeId;
+ } else {
+ $("paired-info").hidden = true;
+ $("pair-form").hidden = false;
+ }
+}
+
+$("pair-btn").addEventListener("click", async () => {
+ const invite = $("invite").value.trim();
+ if (!invite) return;
+ $("result").className = "row muted";
+ $("result").textContent = "Pairing…";
+ const reply = await chrome.runtime.sendMessage({ type: "pi-chrome-pair", invite });
+ if (reply?.ok) {
+ $("result").className = "row ok";
+ $("result").textContent = `Paired with bridge ${reply.info.bridgeId}.`;
+ setTimeout(refresh, 500);
+ } else {
+ $("result").className = "row err";
+ $("result").textContent = reply?.error ?? "Pairing failed.";
+ }
+});
+
+$("unpair-btn").addEventListener("click", async () => {
+ await chrome.runtime.sendMessage({ type: "pi-chrome-unpair" });
+ await refresh();
+});
+
+refresh();
diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
index 88391a7..36d9276 100644
--- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js
+++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
@@ -3,6 +3,215 @@ const CLIENT_NAME = `Pi Chrome Connector ${chrome.runtime.id}`;
const POLL_ERROR_BACKOFF_MS = 2000;
let polling = false;
+// =================== Bridge auth (signed envelopes v1) ===================
+//
+// Persisted in chrome.storage.local under PI_CHROME_AUTH_KEY:
+// { protocol:"v1", bridgeId, extensionId, extensionPairKey }
+// extensionPairKey is a base64url-encoded 32-byte HMAC-SHA256 secret used to sign /next and
+// /result. The brokerKey (used between peer Pi sessions) lives only in the Pi-side config and
+// is intentionally never sent to the browser.
+//
+// Wire format and canonical signing string match the Pi-side BridgeAuth contract in
+// extensions/chrome-profile-bridge/index.ts. Until the extension is paired, the service
+// worker only contacts /next (which returns {needsPairing:true}) and the popup-driven /pair
+// endpoint; it never dispatches commands.
+
+const PI_CHROME_AUTH_KEY = "piChromeAuthV1";
+const AUTH_HEADER = "x-pi-chrome-auth";
+let authStateCache = null; // in-memory mirror to avoid awaiting storage on every poll
+
+async function loadAuthState() {
+ if (authStateCache) return authStateCache;
+ try {
+ const stored = await chrome.storage.local.get(PI_CHROME_AUTH_KEY);
+ const value = stored[PI_CHROME_AUTH_KEY];
+ if (value && value.protocol === "v1" && value.bridgeId && value.extensionId && value.extensionPairKey) {
+ authStateCache = value;
+ return value;
+ }
+ } catch (error) {
+ console.warn("[pi-chrome] failed to read auth state:", error);
+ }
+ return null;
+}
+async function saveAuthState(state) {
+ authStateCache = state;
+ await chrome.storage.local.set({ [PI_CHROME_AUTH_KEY]: state });
+}
+async function clearAuthState() {
+ authStateCache = null;
+ await chrome.storage.local.remove(PI_CHROME_AUTH_KEY);
+}
+function b64urlEncode(bytes) {
+ let s = "";
+ for (const b of bytes) s += String.fromCharCode(b);
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+function b64urlDecode(text) {
+ const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (text.length % 4)) % 4);
+ const bin = atob(padded);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+}
+async function sha256Hex(text) {
+ const buf = new TextEncoder().encode(text);
+ const digest = await crypto.subtle.digest("SHA-256", buf);
+ const bytes = new Uint8Array(digest);
+ let hex = "";
+ for (const b of bytes) hex += b.toString(16).padStart(2, "0");
+ return hex;
+}
+async function importHmacKey(b64urlKey) {
+ return crypto.subtle.importKey("raw", b64urlDecode(b64urlKey), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
+}
+async function hmacSignBytes(key, message) {
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
+ return new Uint8Array(sig);
+}
+function canonicalString(direction, method, path, extensionId, bridgeId, ts, nonce, bodyHash) {
+ return ["v1", direction, method.toUpperCase(), path, extensionId, bridgeId, String(ts), nonce, bodyHash].join("\n");
+}
+async function buildAuthHeader(keyB64, direction, method, path, extensionId, bridgeId, body) {
+ const ts = Date.now();
+ const nonceBytes = crypto.getRandomValues(new Uint8Array(16));
+ const nonce = b64urlEncode(nonceBytes);
+ const bodyHash = await sha256Hex(body);
+ const key = await importHmacKey(keyB64);
+ const sig = await hmacSignBytes(key, canonicalString(direction, method, path, extensionId, bridgeId, ts, nonce, bodyHash));
+ return `v1 ts=${ts} nonce=${nonce} sig=${b64urlEncode(sig)}`;
+}
+function parseAuthHeader(value) {
+ if (!value || typeof value !== "string") return null;
+ const parts = value.trim().split(/\s+/);
+ if (parts.length !== 4 || parts[0] !== "v1") return null;
+ const kv = {};
+ for (const p of parts.slice(1)) {
+ const eq = p.indexOf("=");
+ if (eq <= 0) return null;
+ kv[p.slice(0, eq)] = p.slice(eq + 1);
+ }
+ const ts = Number(kv.ts);
+ if (!Number.isFinite(ts) || !kv.nonce || !kv.sig) return null;
+ return { ts, nonce: kv.nonce, sig: kv.sig };
+}
+// Bounded LRU nonce cache for bridge->ext responses (captured /next cannot be replayed at
+// this extension). Map iteration order = insertion order; evict the oldest entry on overflow
+// regardless of staleness.
+const responseNonceCache = new Map();
+const RESPONSE_NONCE_TTL_MS = 5 * 60_000;
+const RESPONSE_NONCE_MAX = 4096;
+function hasResponseNonce(nonce) { return responseNonceCache.has(nonce); }
+function storeResponseNonce(nonce, ts) {
+ const now = Date.now();
+ for (const [k, t] of responseNonceCache) {
+ if (now - t > RESPONSE_NONCE_TTL_MS) responseNonceCache.delete(k); else break;
+ }
+ while (responseNonceCache.size >= RESPONSE_NONCE_MAX) {
+ const oldest = responseNonceCache.keys().next().value;
+ if (oldest === undefined) break;
+ responseNonceCache.delete(oldest);
+ }
+ responseNonceCache.set(nonce, ts);
+}
+async function verifyBridgeResponse(keyB64, direction, method, path, extensionId, bridgeId, headerValue, body) {
+ const parsed = parseAuthHeader(headerValue);
+ if (!parsed) return false;
+ if (Math.abs(Date.now() - parsed.ts) > 30_000) return false;
+ if (hasResponseNonce(parsed.nonce)) return false;
+ const bodyHash = await sha256Hex(body);
+ const key = await importHmacKey(keyB64);
+ const ok = await crypto.subtle.verify(
+ "HMAC",
+ key,
+ b64urlDecode(parsed.sig),
+ new TextEncoder().encode(canonicalString(direction, method, path, extensionId, bridgeId, parsed.ts, parsed.nonce, bodyHash)),
+ );
+ if (!ok) return false;
+ storeResponseNonce(parsed.nonce, parsed.ts);
+ return true;
+}
+
+// Popup-driven pairing entrypoint. Receives the user-pasted invite, derives the MAC, posts
+// /pair, then stores the keys returned by the bridge. Fails closed.
+async function performPairing(inviteToken) {
+ if (!inviteToken || !inviteToken.startsWith("pcp_")) throw new Error("invite must look like pcp_...");
+ const inviteRaw = inviteToken.slice(4);
+ let inviteKey;
+ try { inviteKey = await importHmacKey(inviteRaw); }
+ catch { throw new Error("invite token is not valid base64url"); }
+ const extensionId = chrome.runtime.id;
+ const extensionNonceBytes = crypto.getRandomValues(new Uint8Array(16));
+ const extensionNonce = b64urlEncode(extensionNonceBytes);
+ const macBytes = await hmacSignBytes(inviteKey, `pair-v1|${extensionId}|${extensionNonce}`);
+ const body = JSON.stringify({ extensionId, extensionNonce, mac: b64urlEncode(macBytes) });
+ const response = await fetch(`${BRIDGE_URL}/pair`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body,
+ });
+ const text = await response.text();
+ if (!response.ok) {
+ let msg = `pair failed: HTTP ${response.status}`;
+ try { msg = `pair failed: ${JSON.parse(text).error || msg}`; } catch {}
+ throw new Error(msg);
+ }
+ // Verify the bridge signed its response with the invite secret — proves we're talking to
+ // the same Pi process that issued the invite, not a localhost impostor.
+ const authHeader = response.headers.get(AUTH_HEADER);
+ const parsed = parseAuthHeader(authHeader);
+ if (!parsed) throw new Error("bridge response missing or invalid auth header");
+ // Clock skew check on the pair response too, for consistency with the rest of the protocol.
+ if (Math.abs(Date.now() - parsed.ts) > 30_000) throw new Error("bridge response timestamp outside ±30 s window");
+ const bodyHash = await sha256Hex(text);
+ const ok = await crypto.subtle.verify(
+ "HMAC",
+ inviteKey,
+ b64urlDecode(parsed.sig),
+ new TextEncoder().encode(canonicalString("bridge->ext", "POST", "/pair", extensionId, JSON.parse(text).bridgeId, parsed.ts, parsed.nonce, bodyHash)),
+ );
+ if (!ok) throw new Error("bridge response signature did not verify");
+ const payload = JSON.parse(text);
+ if (!payload.ok || !payload.bridgeId || !payload.extensionPairKey) {
+ throw new Error("pair response missing fields");
+ }
+ // Intentionally NOT storing brokerKey — the bridge no longer returns it. The extension
+ // only needs extensionPairKey for /next and /result.
+ await saveAuthState({
+ protocol: "v1",
+ bridgeId: payload.bridgeId,
+ extensionId,
+ extensionPairKey: payload.extensionPairKey,
+ });
+ return { bridgeId: payload.bridgeId, extensionId };
+}
+
+// Message bridge for popup.html.
+chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
+ (async () => {
+ if (message?.type === "pi-chrome-pair") {
+ try {
+ const info = await performPairing(message.invite);
+ sendResponse({ ok: true, info });
+ } catch (error) {
+ sendResponse({ ok: false, error: error?.message ?? String(error) });
+ }
+ return;
+ }
+ if (message?.type === "pi-chrome-status") {
+ const state = await loadAuthState();
+ sendResponse({ ok: true, paired: Boolean(state), extensionId: chrome.runtime.id, bridgeId: state?.bridgeId ?? null });
+ return;
+ }
+ if (message?.type === "pi-chrome-unpair") {
+ await clearAuthState();
+ sendResponse({ ok: true });
+ return;
+ }
+ })();
+ return true; // async response
+});
+
// =================== Chrome input (CDP) layer ===================
// Tracks which tabs we have attached chrome.debugger to.
const attachedTabs = new Map(); // tabId -> { detachAt: number, pointer: {x,y} }
@@ -627,10 +836,16 @@ async function pollLoop() {
polling = true;
try {
while (true) {
+ const auth = await loadAuthState();
+ const headers = { "cache-control": "no-store" };
+ if (auth) {
+ headers[AUTH_HEADER] = await buildAuthHeader(auth.extensionPairKey, "ext->bridge", "GET", "/next", auth.extensionId, auth.bridgeId, "");
+ }
const response = await fetch(`${BRIDGE_URL}/next?name=${encodeURIComponent(CLIENT_NAME)}`, {
cache: "no-store",
+ headers,
});
- if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`);
+ // Even unpaired we honor version-mismatch reload so older extensions auto-update.
const expected = response.headers.get("x-pi-chrome-version");
const ours = chrome.runtime.getManifest().version;
if (expected && expected !== ours && isVersionOlder(ours, expected)) {
@@ -638,8 +853,60 @@ async function pollLoop() {
try { chrome.runtime.reload(); } catch {}
return;
}
- const payload = await response.json();
- if (payload.type === "command") await handleCommand(payload.command);
+ if (response.status === 401) {
+ // Paired-but-stale: our stored extensionPairKey no longer matches what the bridge
+ // expects (most likely the user ran /chrome unpair then /chrome pair again with a
+ // different extension instance or invite). Surface the pair badge and back off,
+ // identical to the bridge-returned needsPairing path. Do NOT auto-clear stored
+ // auth — user must explicitly unpair via the popup.
+ try { chrome.action.setBadgeText({ text: "pair" }); chrome.action.setBadgeBackgroundColor({ color: "#d97706" }); } catch {}
+ await sleep(POLL_ERROR_BACKOFF_MS);
+ continue;
+ }
+ if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`);
+ const text = await response.text();
+ let payload;
+ try { payload = JSON.parse(text); } catch { throw new Error("bad /next JSON"); }
+ // Unsigned-idle path: the bridge always returns an unsigned `{type:"none", needsPairing:true}`
+ // when it has no pinned extension or no matching auth. Even a paired-but-stale extension
+ // (e.g. user ran `/chrome unpair` on the Pi side) must keep polling and surface the
+ // pair badge instead of looping on "bad signature". Only accept this shape unsigned.
+ const sigHeader = response.headers.get(AUTH_HEADER);
+ const unsignedIdle = !sigHeader && payload.type === "none" && payload.needsPairing === true;
+ if (auth && !unsignedIdle) {
+ const okSig = await verifyBridgeResponse(
+ auth.extensionPairKey,
+ "bridge->ext",
+ "GET",
+ "/next",
+ auth.extensionId,
+ auth.bridgeId,
+ sigHeader,
+ text,
+ );
+ if (!okSig) {
+ console.warn("[pi-chrome] /next response signature invalid; refusing command and dropping connection");
+ throw new Error("bad /next signature");
+ }
+ }
+ if (payload.needsPairing) {
+ // Bridge has not been paired (or our stored keys no longer match). Surface via badge
+ // so the user opens the popup; back off so we don't spin. Do NOT auto-clear stored
+ // auth — user must explicitly unpair via the popup.
+ try { chrome.action.setBadgeText({ text: "pair" }); chrome.action.setBadgeBackgroundColor({ color: "#d97706" }); } catch {}
+ await sleep(POLL_ERROR_BACKOFF_MS);
+ continue;
+ } else {
+ try { chrome.action.setBadgeText({ text: "" }); } catch {}
+ }
+ if (payload.type === "command") {
+ if (!auth) {
+ // Bridge sent a command but we have no key to verify or sign /result. Refuse.
+ console.warn("[pi-chrome] command received but extension is not paired; refusing");
+ continue;
+ }
+ await handleCommand(payload.command, auth);
+ }
}
} catch (error) {
await sleep(POLL_ERROR_BACKOFF_MS);
@@ -648,20 +915,22 @@ async function pollLoop() {
}
}
-async function handleCommand(command) {
+async function handleCommand(command, auth) {
try {
const result = await dispatch(command.action, command.params ?? {});
- await postResult({ id: command.id, ok: true, result });
+ await postResult({ id: command.id, ok: true, result }, auth);
} catch (error) {
- await postResult({ id: command.id, ok: false, error: error?.message ?? String(error) });
+ await postResult({ id: command.id, ok: false, error: error?.message ?? String(error) }, auth);
}
}
-async function postResult(result) {
+async function postResult(result, auth) {
+ const body = JSON.stringify(result);
+ const header = await buildAuthHeader(auth.extensionPairKey, "ext->bridge", "POST", "/result", auth.extensionId, auth.bridgeId, body);
await fetch(`${BRIDGE_URL}/result`, {
method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(result),
+ headers: { "content-type": "application/json", [AUTH_HEADER]: header },
+ body,
});
}
@@ -748,17 +1017,58 @@ async function dispatch(action, params) {
const tab = await getTabByParams(params);
if (params.foreground) await bringToFront(tab);
if (params.initScript) {
- // Register a one-shot document_start content script. We register, navigate, wait, then unregister.
+ // Register a one-shot document_start content script. The onCommitted listener clears
+ // the slot the moment it fires. The finally below only fires the safety-net cleanup
+ // when the navigation aborts before commit, so that:
+ // - aborted navigation: slot cleared so it can't be picked up by a later URL the
+ // user navigates to in this tab;
+ // - waitUntilLoad=false success: onCommitted fires asynchronously *after* our
+ // await wait already resolved; we must NOT clear in finally before it has a
+ // chance to fire, otherwise the init script never injects.
await registerInitScript(tab.id, params.initScript);
}
- const wait = params.waitUntilLoad !== false ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined);
- const updated = await chrome.tabs.update(tab.id, { url: params.url });
+ // Track onCommitted for *this* tab so we know whether the navigation actually reached
+ // document_start (and thus injected the init script). The global onCommitted listener
+ // clears `initScriptIds` immediately; this local one only flips a flag we use to
+ // decide whether the finally block needs to do safety-net cleanup.
+ let committed = false;
+ let resolveCommitted;
+ const committedPromise = new Promise((r) => { resolveCommitted = r; });
+ let cleanupCommittedListener;
+ if (params.initScript) {
+ const listener = (details) => {
+ if (details.tabId === tab.id && details.frameId === 0) { committed = true; resolveCommitted(); }
+ };
+ chrome.webNavigation.onCommitted.addListener(listener);
+ cleanupCommittedListener = () => chrome.webNavigation.onCommitted.removeListener(listener);
+ }
try {
+ const wait = params.waitUntilLoad !== false ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined);
+ const updated = await chrome.tabs.update(tab.id, { url: params.url });
await wait;
+ // With waitUntilLoad:false, `wait` resolves before onCommitted fires. We still need
+ // the init script to inject, so wait up to a short window for commit before letting
+ // finally decide whether to clear the slot. With waitUntilLoad:true this is almost
+ // always a no-op (commit precedes load complete).
+ if (params.initScript && !committed) {
+ const commitWindowMs = Math.min(params.timeoutMs || 15000, 5000);
+ await Promise.race([
+ committedPromise,
+ new Promise((r) => setTimeout(r, commitWindowMs)),
+ ]);
+ }
+ return formatTab(await chrome.tabs.get(updated.id));
} finally {
- if (params.initScript) await unregisterInitScript(tab.id).catch(() => undefined);
+ if (params.initScript) {
+ if (!committed) {
+ // Navigation never committed (e.g. tabs.update threw, tab closed, or commit
+ // didn't happen within the window). Clear so the armed init script can't fire
+ // on a later unrelated navigation.
+ await unregisterInitScript(tab.id).catch(() => undefined);
+ }
+ cleanupCommittedListener?.();
+ }
}
- return formatTab(await chrome.tabs.get(updated.id));
}
case "page.screenshot":
return takeScreenshot(params);
@@ -956,6 +1266,9 @@ if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
if (details.frameId !== 0) return;
const source = initScriptIds.get(details.tabId);
if (!source) return;
+ // One-shot: clear immediately so any subsequent navigation (redirect, history,
+ // user-initiated) does not re-fire the same init script.
+ initScriptIds.delete(details.tabId);
chrome.scripting.executeScript({
target: { tabId: details.tabId, frameIds: [0] },
world: "MAIN",
@@ -964,6 +1277,139 @@ if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
args: [source],
}).catch(() => undefined);
});
+
+ // Early capture: inject console/network instrumentation at document_start of every top-frame
+ // navigation. Without this, errors fired during initial React render or boot-time fetches
+ // are lost before the lazy `installPiChromeInstrumentation()` path (run by
+ // chrome_snapshot/chrome_evaluate/page.console.list/page.network.list) gets a chance to wrap
+ // the globals. The injected function is self-contained (no closure refs) since
+ // chrome.scripting.executeScript serializes it. It is idempotent via __piChromeWrapped and
+ // updates the same window.__PI_CHROME_STATE__ schema as the post-hoc path, so subsequent
+ // calls to installPiChromeInstrumentation() are no-ops.
+ chrome.webNavigation.onCommitted.addListener((details) => {
+ if (details.frameId !== 0) return;
+ chrome.scripting.executeScript({
+ target: { tabId: details.tabId, frameIds: [0] },
+ world: "MAIN",
+ injectImmediately: true,
+ func: installEarlyCapture,
+ }).catch(() => undefined);
+ });
+}
+
+// Self-contained boot-time instrumentation. Must not close over anything outside its body
+// because chrome.scripting.executeScript({func}) serializes it via toString and rebinds in
+// the target page's MAIN world.
+function installEarlyCapture() {
+ const state = window.__PI_CHROME_STATE__ || {
+ nextElementUid: 1,
+ elements: {},
+ console: [],
+ network: [],
+ nextRequestId: 1,
+ instrumentationInstalled: false,
+ };
+ window.__PI_CHROME_STATE__ = state;
+ if (state.instrumentationInstalled) return;
+ state.instrumentationInstalled = true;
+ const pushConsole = (level, args) => {
+ state.console.push({
+ id: state.console.length + 1,
+ level,
+ timestamp: Date.now(),
+ url: location.href,
+ args: Array.from(args).map((arg) => {
+ try {
+ if (typeof arg === "string") return arg;
+ if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
+ return JSON.parse(JSON.stringify(arg));
+ } catch {
+ return String(arg);
+ }
+ }),
+ });
+ if (state.console.length > 500) state.console.splice(0, state.console.length - 500);
+ };
+ for (const level of ["debug", "log", "info", "warn", "error"]) {
+ const original = console[level];
+ if (typeof original !== "function" || original.__piChromeWrapped) continue;
+ const wrapped = function (...args) {
+ pushConsole(level, args);
+ return original.apply(this, args);
+ };
+ wrapped.__piChromeWrapped = true;
+ console[level] = wrapped;
+ }
+ window.addEventListener("error", (event) => pushConsole("pageerror", [event.message, event.filename + ":" + event.lineno + ":" + event.colno]));
+ window.addEventListener("unhandledrejection", (event) => pushConsole("unhandledrejection", [event.reason]));
+ const trimBody = (text) => typeof text === "string" && text.length > 200000 ? text.slice(0, 200000) + `\n[truncated ${text.length - 200000} chars]` : text;
+ const record = (entry) => {
+ state.network.push(entry);
+ if (state.network.length > 1000) state.network.splice(0, state.network.length - 1000);
+ return entry;
+ };
+ if (window.fetch && !window.fetch.__piChromeWrapped) {
+ const originalFetch = window.fetch.bind(window);
+ const wrappedFetch = async (...args) => {
+ const id = "req-" + state.nextRequestId++;
+ const startedAt = Date.now();
+ const input = args[0];
+ const init = args[1] || {};
+ const url = typeof input === "string" ? input : input?.url;
+ const method = (init.method || input?.method || "GET").toUpperCase();
+ const entry = record({ id, type: "fetch", method, url: String(url || ""), startedAt, pageUrl: location.href, status: "pending" });
+ try {
+ const response = await originalFetch(...args);
+ entry.status = response.status;
+ entry.statusText = response.statusText;
+ entry.ok = response.ok;
+ entry.responseUrl = response.url;
+ entry.durationMs = Date.now() - startedAt;
+ entry.responseHeaders = Array.from(response.headers.entries());
+ response.clone().text().then((text) => {
+ entry.responseBody = trimBody(text);
+ entry.responseBodyTruncated = typeof text === "string" && text.length > 200000;
+ }).catch((error) => { entry.responseBodyError = error?.message || String(error); });
+ return response;
+ } catch (error) {
+ entry.error = error?.message || String(error);
+ entry.durationMs = Date.now() - startedAt;
+ throw error;
+ }
+ };
+ wrappedFetch.__piChromeWrapped = true;
+ window.fetch = wrappedFetch;
+ }
+ if (window.XMLHttpRequest && !XMLHttpRequest.prototype.open.__piChromeWrapped) {
+ const originalOpen = XMLHttpRequest.prototype.open;
+ const originalSend = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function (method, url, ...rest) {
+ this.__piChromeRequest = { method: String(method || "GET").toUpperCase(), url: String(url || "") };
+ return originalOpen.call(this, method, url, ...rest);
+ };
+ XMLHttpRequest.prototype.open.__piChromeWrapped = true;
+ XMLHttpRequest.prototype.send = function (body) {
+ const id = "req-" + state.nextRequestId++;
+ const startedAt = Date.now();
+ const info = this.__piChromeRequest || {};
+ const entry = record({ id, type: "xhr", method: info.method || "GET", url: info.url || "", startedAt, pageUrl: location.href, status: "pending" });
+ this.addEventListener("loadend", () => {
+ entry.status = this.status;
+ entry.statusText = this.statusText;
+ entry.responseUrl = this.responseURL;
+ entry.durationMs = Date.now() - startedAt;
+ try { entry.responseHeadersText = this.getAllResponseHeaders(); } catch {}
+ try {
+ if (typeof this.responseText === "string") {
+ entry.responseBody = trimBody(this.responseText);
+ entry.responseBodyTruncated = this.responseText.length > 200000;
+ }
+ } catch (error) { entry.responseBodyError = error?.message || String(error); }
+ });
+ this.addEventListener("error", () => { entry.error = "XMLHttpRequest error"; entry.durationMs = Date.now() - startedAt; });
+ return originalSend.call(this, body);
+ };
+ }
}
async function bringToFront(tab) {
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index 48b726f..6bc1385 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -1,8 +1,10 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
-import { existsSync, readFileSync, statSync } from "node:fs";
-import { mkdir, writeFile } from "node:fs/promises";
+import { existsSync, readFileSync, statSync, mkdirSync, writeFileSync, chmodSync, watch as fsWatch, type FSWatcher } from "node:fs";
+import { mkdir, writeFile, readFile, chmod } from "node:fs/promises";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
+import { createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
+import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
/**
@@ -54,9 +56,463 @@ function readPiChromeVersion(): string {
}
const PI_CHROME_VERSION = readPiChromeVersion();
const PI_CHROME_GLOBAL_KEY = "__piChromeProfileBridgeLoaded__";
-const DEFAULT_HOST = process.env.PI_CHROME_BRIDGE_HOST ?? "127.0.0.1";
+const DANGEROUS_REMOTE_ENV = "PI_CHROME_BRIDGE_DANGEROUS_REMOTE";
+const RAW_HOST_ENV = process.env.PI_CHROME_BRIDGE_HOST;
+const DANGEROUS_REMOTE_ENABLED = process.env[DANGEROUS_REMOTE_ENV] === "1";
+function resolveDefaultHost(): string {
+ if (!RAW_HOST_ENV) return "127.0.0.1";
+ const hostIsLoopback = RAW_HOST_ENV === "127.0.0.1" || RAW_HOST_ENV === "::1" || RAW_HOST_ENV === "localhost";
+ if (hostIsLoopback) return RAW_HOST_ENV;
+ if (!DANGEROUS_REMOTE_ENABLED) {
+ console.warn(
+ `pi-chrome: ignoring PI_CHROME_BRIDGE_HOST=${RAW_HOST_ENV} (non-loopback). Set ${DANGEROUS_REMOTE_ENV}=1 to opt in explicitly.`,
+ );
+ return "127.0.0.1";
+ }
+ console.warn(`pi-chrome: ${DANGEROUS_REMOTE_ENV}=1 — binding bridge to ${RAW_HOST_ENV}. Bridge endpoints still require loopback or paired auth.`);
+ return RAW_HOST_ENV;
+}
+const DEFAULT_HOST = resolveDefaultHost();
const DEFAULT_PORT = Number(process.env.PI_CHROME_BRIDGE_PORT ?? "17318");
const DEFAULT_TIMEOUT_MS = 30_000;
+const MAX_REQUEST_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB hard cap on inbound HTTP bodies
+const MAX_QUEUE = 256;
+const MAX_PENDING = 256;
+const MAX_WAITERS = 4;
+
+function isLoopbackAddress(address: string | undefined): boolean {
+ if (!address) return false;
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
+}
+
+// =========================================================================================
+// Pairing + signed-envelope auth.
+//
+// Protocol v1.
+//
+// Threat model: protect against (a) ordinary web pages, (b) drive-by local processes / curl
+// without the pairing secret, (c) malicious Chrome extensions stealing the command channel.
+// Out of scope: same-user malware that can read $XDG_CONFIG_HOME, replace native messaging
+// hosts, or attach a debugger to Pi/Chrome.
+//
+// Pairing flow:
+// 1. User runs `/chrome pair` in Pi. Pi generates a 32-byte invite secret, presents it as
+// `pcp_<base64url>`, copies to clipboard, and arms `/pair` for `PAIR_WINDOW_MS`.
+// 2. User pastes the invite into the Chrome extension popup. Extension POSTs `/pair` with
+// {extensionId, extensionNonce, mac(=HMAC(invite, "pair-v1|" + extensionId + "|" + extensionNonce))}.
+// 3. Bridge verifies MAC, mints a `bridgeId` + derives two 32-byte HKDF keys:
+// - extensionPairKey: signs/verifies /next and /result envelopes.
+// - brokerKey: signs/verifies /command envelopes between peer Pi processes.
+// Bridge replies signed with the invite secret so the extension knows it's talking to
+// the same Pi process that issued the invite. Invite is destroyed.
+// 4. Both sides persist their copies. Pi keeps the master record at
+// ~/.config/pi/chrome-bridge.json mode 0600; extension keeps mirror in
+// chrome.storage.local.
+//
+// Wire format on signed requests/responses:
+// x-pi-chrome-auth: v1 ts=<ms> nonce=<base64url-16B> sig=<base64url-32B>
+// where sig = HMAC-SHA256(key, canonicalSigningString).
+//
+// Canonical signing string (newline-separated):
+// v1\n<direction>\n<method>\n<path>\n<extensionId>\n<bridgeId>\n<ts>\n<nonce>\n<sha256-hex(body)>
+//
+// direction ∈ { "ext->bridge", "bridge->ext", "peer->owner", "owner->peer" }.
+// For empty bodies (GET /next), body = "".
+//
+// Replay defense: receiver requires |now - ts| <= MAX_CLOCK_SKEW_MS and rejects re-used
+// nonces via a per-direction LRU.
+
+const AUTH_PROTOCOL = "v1";
+const AUTH_HEADER = "x-pi-chrome-auth";
+const PAIR_WINDOW_MS = 10 * 60_000;
+const MAX_CLOCK_SKEW_MS = 30_000;
+const NONCE_TTL_MS = 5 * 60_000;
+const NONCE_CACHE_MAX = 4096;
+
+function configPath(): string {
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
+ return join(base, "pi", "chrome-bridge.json");
+}
+function b64url(buf: Buffer): string {
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+function b64urlDecode(text: string): Buffer {
+ const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (text.length % 4)) % 4);
+ return Buffer.from(padded, "base64");
+}
+function sha256Hex(input: string | Buffer): string {
+ return createHash("sha256").update(input).digest("hex");
+}
+function hmacSign(key: Buffer, message: string): Buffer {
+ return createHmac("sha256", key).update(message).digest();
+}
+function safeStringEqual(a: string, b: string): boolean {
+ const aBuf = Buffer.from(a, "utf8");
+ const bBuf = Buffer.from(b, "utf8");
+ if (aBuf.length !== bBuf.length) return false;
+ try { return timingSafeEqual(aBuf, bBuf); } catch { return false; }
+}
+function safeBufferEqual(a: Buffer, b: Buffer): boolean {
+ if (a.length !== b.length) return false;
+ try { return timingSafeEqual(a, b); } catch { return false; }
+}
+
+type PairDirection = "ext->bridge" | "bridge->ext" | "peer->owner" | "owner->peer";
+
+function canonicalString(
+ direction: PairDirection,
+ method: string,
+ path: string,
+ extensionId: string,
+ bridgeId: string,
+ ts: number,
+ nonce: string,
+ body: string,
+): string {
+ return [AUTH_PROTOCOL, direction, method.toUpperCase(), path, extensionId, bridgeId, String(ts), nonce, sha256Hex(body)].join("\n");
+}
+
+function buildAuthHeader(key: Buffer, direction: PairDirection, method: string, path: string, extensionId: string, bridgeId: string, body: string): { header: string; ts: number; nonce: string } {
+ const ts = Date.now();
+ const nonce = b64url(randomBytes(16));
+ const sig = b64url(hmacSign(key, canonicalString(direction, method, path, extensionId, bridgeId, ts, nonce, body)));
+ return { header: `${AUTH_PROTOCOL} ts=${ts} nonce=${nonce} sig=${sig}`, ts, nonce };
+}
+
+function parseAuthHeader(raw: string | string[] | undefined): { ts: number; nonce: string; sig: string } | undefined {
+ const value = Array.isArray(raw) ? raw[0] : raw;
+ if (!value || typeof value !== "string") return undefined;
+ const parts = value.trim().split(/\s+/);
+ if (parts.length !== 4 || parts[0] !== AUTH_PROTOCOL) return undefined;
+ const kv: Record<string, string> = {};
+ for (const p of parts.slice(1)) {
+ const eq = p.indexOf("=");
+ if (eq <= 0) return undefined;
+ kv[p.slice(0, eq)] = p.slice(eq + 1);
+ }
+ const ts = Number(kv.ts);
+ if (!Number.isFinite(ts) || !kv.nonce || !kv.sig) return undefined;
+ return { ts, nonce: kv.nonce, sig: kv.sig };
+}
+
+// Bounded LRU of (nonce -> ts) per direction for replay defense. Map iteration order is
+// insertion order, so evicting `keys().next()` removes the oldest entry. Two callers:
+//
+// - `has(nonce)` checks duplicates *before* MAC verification (cheap; lets us reject obvious
+// replays without wasting an HMAC compute).
+// - `store(nonce, ts)` records the nonce *after* MAC verification succeeds, so an attacker
+// sending fresh-nonce bogus signatures cannot pin memory or pollute the cache.
+class NonceCache {
+ private readonly entries = new Map<string, number>();
+ has(nonce: string): boolean { return this.entries.has(nonce); }
+ store(nonce: string, ts: number): void {
+ const now = Date.now();
+ // Drop stale entries opportunistically.
+ for (const [k, t] of this.entries) {
+ if (now - t > NONCE_TTL_MS) this.entries.delete(k); else break;
+ }
+ // Hard cap: evict oldest insertions until we're under the limit, even if not stale.
+ while (this.entries.size >= NONCE_CACHE_MAX) {
+ const oldest = this.entries.keys().next().value;
+ if (oldest === undefined) break;
+ this.entries.delete(oldest);
+ }
+ this.entries.set(nonce, ts);
+ }
+}
+
+type PersistedAuthState = {
+ protocol: "v1";
+ bridgeId: string;
+ extensionId: string;
+ extensionPairKey: string; // base64url, shared with the Chrome extension
+ brokerKey: string; // base64url, Pi-side only — never sent to the extension
+};
+
+class BridgeAuth {
+ private state: PersistedAuthState | undefined;
+ private pendingInvite: { secret: Buffer; expiresAt: number } | undefined;
+ private extNonces = new NonceCache(); // request: ext->bridge
+ private peerNonces = new NonceCache(); // request: peer->owner
+ private ownerRespNonces = new NonceCache(); // response: owner->peer (verified peer-side)
+ private lastStateMtimeMs: number = 0;
+ private watcher: FSWatcher | undefined;
+
+ constructor() {
+ this.load();
+ this.startWatching();
+ }
+
+ private startWatching(): void {
+ // fs.watch the config file so cross-process changes (e.g. another Pi running `/chrome
+ // unpair`, or an external `rm` of the file) immediately propagate to this owner's
+ // in-memory state. Without this, an owner that already cached `state` keeps verifying
+ // signatures with stale keys long after the file is gone. fs.watch is best-effort
+ // across platforms; we also re-check mtime synchronously on every privileged request
+ // (see refreshIfStale) so a missed watch event can never silently desync.
+ const path = configPath();
+ const dir = dirname(path);
+ try { mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch {}
+ try {
+ this.watcher = fsWatch(dir, { persistent: false }, (_event, filename) => {
+ if (!filename || filename.toString() !== "chrome-bridge.json") return;
+ this.refreshIfStale();
+ });
+ } catch (error) {
+ console.warn(`pi-chrome: fs.watch on ${dir} failed (${(error as Error).message}); falling back to per-request mtime checks.`);
+ }
+ }
+
+ stop(): void {
+ try { this.watcher?.close(); } catch {}
+ this.watcher = undefined;
+ }
+
+ // Synchronously re-stat the config file and reload if anything changed on disk. Called
+ // before every privileged verification so that cross-process mutations (peer unpair,
+ // external file edits) can never let stale in-memory keys validate a request.
+ refreshIfStale(): void {
+ const path = configPath();
+ let mtimeMs = 0;
+ try { mtimeMs = statSync(path).mtimeMs; } catch { mtimeMs = -1; }
+ if (mtimeMs === this.lastStateMtimeMs) return;
+ this.lastStateMtimeMs = mtimeMs;
+ const prevExtId = this.state?.extensionId;
+ const prevBridgeId = this.state?.bridgeId;
+ this.load();
+ const rotated = (this.state?.extensionId !== prevExtId) || (this.state?.bridgeId !== prevBridgeId);
+ if (rotated) {
+ // Keys rotated (or pairing cleared). Drop replay caches; old nonces signed under the
+ // previous keys are meaningless under the new ones and would otherwise needlessly
+ // occupy cache slots.
+ this.extNonces = new NonceCache();
+ this.peerNonces = new NonceCache();
+ this.ownerRespNonces = new NonceCache();
+ }
+ }
+
+ get paired(): boolean { this.refreshIfStale(); return Boolean(this.state); }
+ get bridgeId(): string { this.refreshIfStale(); return this.state?.bridgeId ?? ""; }
+ get pinnedExtensionId(): string { this.refreshIfStale(); return this.state?.extensionId ?? ""; }
+
+ private load(): void {
+ try {
+ const raw = readFileSync(configPath(), "utf8");
+ const parsed = JSON.parse(raw);
+ if (parsed?.protocol === "v1" && parsed.bridgeId && parsed.extensionId && parsed.extensionPairKey && parsed.brokerKey) {
+ this.state = parsed as PersistedAuthState;
+ } else {
+ // File exists but doesn't carry a valid v1 record (e.g. peer reset wrote `{}`).
+ // Drop in-memory state so this owner stops accepting signatures keyed by the
+ // now-deleted record.
+ this.state = undefined;
+ }
+ } catch {
+ // File missing or unreadable: treat as unpaired.
+ this.state = undefined;
+ }
+ }
+
+ private persist(): void {
+ if (!this.state) return;
+ const path = configPath();
+ try {
+ const dir = dirname(path);
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
+ writeFileSync(path, JSON.stringify(this.state), { mode: 0o600 });
+ try { chmodSync(path, 0o600); } catch {}
+ try { chmodSync(dir, 0o700); } catch {}
+ try { this.lastStateMtimeMs = statSync(path).mtimeMs; } catch {}
+ } catch (error) {
+ console.warn(`pi-chrome: failed to persist auth state at ${path}: ${(error as Error).message}`);
+ }
+ }
+
+ // `/chrome pair` calls this; returns the invite to display to the user.
+ startPairWindow(): string {
+ const secret = randomBytes(32);
+ this.pendingInvite = { secret, expiresAt: Date.now() + PAIR_WINDOW_MS };
+ return `pcp_${b64url(secret)}`;
+ }
+
+ cancelPairWindow(): void { this.pendingInvite = undefined; }
+
+ pairWindowActive(): boolean {
+ if (!this.pendingInvite) return false;
+ if (this.pendingInvite.expiresAt < Date.now()) { this.pendingInvite = undefined; return false; }
+ return true;
+ }
+
+ reset(): void {
+ this.state = undefined;
+ this.pendingInvite = undefined;
+ const path = configPath();
+ try { writeFileSync(path, JSON.stringify({}), { mode: 0o600 }); } catch {}
+ try { chmodSync(path, 0o600); } catch {}
+ try { chmodSync(dirname(path), 0o700); } catch {}
+ }
+
+ // Verify extension's /pair POST. Returns the response payload + signed header on success.
+ completePairing(body: { extensionId?: string; extensionNonce?: string; mac?: string }): { response: Record<string, unknown>; signedHeader: string } {
+ if (!this.pairWindowActive()) throw new Error("no active pairing window; run /chrome pair first");
+ if (!body.extensionId || !body.extensionNonce || !body.mac) throw new Error("pair: missing fields");
+ // Chrome extension IDs are 32 lowercase a-p characters. Reject uppercase or mixed case
+ // so a malicious caller can't smuggle a value that looks like one ID and matches another
+ // after case folding.
+ if (!/^[a-p]{32}$/.test(body.extensionId)) throw new Error("pair: invalid extensionId");
+ const invite = this.pendingInvite!.secret;
+ const expected = hmacSign(invite, `pair-v1|${body.extensionId}|${body.extensionNonce}`);
+ const supplied = b64urlDecode(body.mac);
+ if (!safeBufferEqual(expected, supplied)) throw new Error("pair: bad mac");
+ const bridgeId = b64url(randomBytes(16));
+ const extensionPairKey = Buffer.from(hkdfSync("sha256", invite, Buffer.from("pi-chrome-pair-salt"), Buffer.from("ext-pair-key:" + bridgeId), 32));
+ const brokerKey = Buffer.from(hkdfSync("sha256", invite, Buffer.from("pi-chrome-pair-salt"), Buffer.from("broker-key:" + bridgeId), 32));
+ this.state = {
+ protocol: "v1",
+ bridgeId,
+ extensionId: body.extensionId,
+ extensionPairKey: b64url(extensionPairKey),
+ brokerKey: b64url(brokerKey),
+ };
+ this.persist();
+ this.pendingInvite = undefined;
+ // Sign response with the invite (extension still has it) so it can verify before storing
+ // the long-lived keys returned in the body.
+ //
+ // IMPORTANT: do NOT include brokerKey in the response. The extension never uses it;
+ // it is exclusively for peer Pi sessions reading `~/.config/pi/chrome-bridge.json`.
+ // Leaking it into the browser would broaden blast radius if the extension is later
+ // compromised.
+ const response: Record<string, unknown> = {
+ ok: true,
+ bridgeId,
+ extensionId: body.extensionId,
+ extensionPairKey: this.state.extensionPairKey,
+ protocol: AUTH_PROTOCOL,
+ };
+ const bodyText = JSON.stringify(response);
+ const { header } = buildAuthHeader(invite, "bridge->ext", "POST", "/pair", body.extensionId, bridgeId, bodyText);
+ return { response, signedHeader: header };
+ }
+
+ private verifyEnvelope(
+ key: Buffer,
+ cache: NonceCache,
+ direction: PairDirection,
+ method: string,
+ path: string,
+ extensionId: string,
+ bridgeId: string,
+ header: string | string[] | undefined,
+ body: string,
+ ): boolean {
+ const parsed = parseAuthHeader(header);
+ if (!parsed) return false;
+ if (Math.abs(Date.now() - parsed.ts) > MAX_CLOCK_SKEW_MS) return false;
+ // Cheap replay check BEFORE the HMAC compute: reject duplicates immediately.
+ if (cache.has(parsed.nonce)) return false;
+ const expected = hmacSign(key, canonicalString(direction, method, path, extensionId, bridgeId, parsed.ts, parsed.nonce, body));
+ let supplied: Buffer;
+ try { supplied = b64urlDecode(parsed.sig); } catch { return false; }
+ if (!safeBufferEqual(expected, supplied)) return false;
+ // Only record AFTER MAC verifies so bogus-signature spam cannot bloat the cache.
+ cache.store(parsed.nonce, parsed.ts);
+ return true;
+ }
+
+ verifyExtensionRequest(method: string, path: string, header: string | string[] | undefined, originExtensionId: string, body: string): boolean {
+ this.refreshIfStale();
+ if (!this.state) return false;
+ if (!safeStringEqual(originExtensionId, this.state.extensionId)) return false;
+ return this.verifyEnvelope(
+ b64urlDecode(this.state.extensionPairKey),
+ this.extNonces,
+ "ext->bridge",
+ method,
+ path,
+ this.state.extensionId,
+ this.state.bridgeId,
+ header,
+ body,
+ );
+ }
+
+ signBridgeResponse(method: string, path: string, body: string): string | undefined {
+ if (!this.state) return undefined;
+ const { header } = buildAuthHeader(
+ b64urlDecode(this.state.extensionPairKey),
+ "bridge->ext",
+ method,
+ path,
+ this.state.extensionId,
+ this.state.bridgeId,
+ body,
+ );
+ return header;
+ }
+
+ verifyPeerRequest(method: string, path: string, header: string | string[] | undefined, body: string): boolean {
+ this.refreshIfStale();
+ if (!this.state) return false;
+ return this.verifyEnvelope(
+ b64urlDecode(this.state.brokerKey),
+ this.peerNonces,
+ "peer->owner",
+ method,
+ path,
+ this.state.extensionId,
+ this.state.bridgeId,
+ header,
+ body,
+ );
+ }
+
+ signPeerRequest(method: string, path: string, body: string): string | undefined {
+ if (!this.state) return undefined;
+ const { header } = buildAuthHeader(
+ b64urlDecode(this.state.brokerKey),
+ "peer->owner",
+ method,
+ path,
+ this.state.extensionId,
+ this.state.bridgeId,
+ body,
+ );
+ return header;
+ }
+
+ signOwnerResponse(method: string, path: string, body: string): string | undefined {
+ if (!this.state) return undefined;
+ const { header } = buildAuthHeader(
+ b64urlDecode(this.state.brokerKey),
+ "owner->peer",
+ method,
+ path,
+ this.state.extensionId,
+ this.state.bridgeId,
+ body,
+ );
+ return header;
+ }
+
+ verifyOwnerResponse(method: string, path: string, header: string | string[] | undefined, body: string): boolean {
+ this.refreshIfStale();
+ if (!this.state) return false;
+ const parsed = parseAuthHeader(header);
+ if (!parsed) return false;
+ if (Math.abs(Date.now() - parsed.ts) > MAX_CLOCK_SKEW_MS) return false;
+ if (this.ownerRespNonces.has(parsed.nonce)) return false;
+ const expected = hmacSign(
+ b64urlDecode(this.state.brokerKey),
+ canonicalString("owner->peer", method, path, this.state.extensionId, this.state.bridgeId, parsed.ts, parsed.nonce, body),
+ );
+ let supplied: Buffer;
+ try { supplied = b64urlDecode(parsed.sig); } catch { return false; }
+ if (!safeBufferEqual(expected, supplied)) return false;
+ this.ownerRespNonces.store(parsed.nonce, parsed.ts);
+ return true;
+ }
+}
const MAX_TEXT_CHARS = 30_000;
const MAX_ELEMENTS = 80;
@@ -115,14 +571,59 @@ function summarizeActionResult(result: unknown): string | undefined {
return parts.length ? parts.join("; ") : undefined;
}
-function readRequestBody(request: IncomingMessage): Promise<string> {
+class RequestBodyTooLargeError extends Error {
+ constructor() { super("request body too large"); this.name = "RequestBodyTooLargeError"; }
+}
+function readRequestBody(request: IncomingMessage, maxBytes = MAX_REQUEST_BODY_BYTES): Promise<string> {
return new Promise((resolveBody, rejectBody) => {
+ // Reject too-large bodies up front when Content-Length advertises it.
+ const declared = Number(request.headers["content-length"]);
+ if (Number.isFinite(declared) && declared > maxBytes) {
+ rejectBody(new RequestBodyTooLargeError());
+ request.resume();
+ return;
+ }
const chunks: Buffer[] = [];
- request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
+ let size = 0;
+ request.on("data", (chunk) => {
+ const buf = Buffer.from(chunk);
+ size += buf.length;
+ if (size > maxBytes) {
+ rejectBody(new RequestBodyTooLargeError());
+ request.destroy();
+ return;
+ }
+ chunks.push(buf);
+ });
request.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8")));
request.on("error", rejectBody);
});
}
+async function readJsonBody<T = unknown>(request: IncomingMessage, response: ServerResponse, extraHeaders?: Record<string, string>): Promise<T | undefined> {
+ let raw: string;
+ try {
+ raw = await readRequestBody(request);
+ } catch (error) {
+ if (error instanceof RequestBodyTooLargeError) {
+ sendJson(response, 413, { ok: false, error: "request body too large" }, extraHeaders);
+ } else {
+ sendJson(response, 400, { ok: false, error: `failed to read request body: ${(error as Error).message}` }, extraHeaders);
+ }
+ return undefined;
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw || "{}");
+ } catch (error) {
+ sendJson(response, 400, { ok: false, error: `invalid JSON body: ${(error as Error).message}` }, extraHeaders);
+ return undefined;
+ }
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
+ sendJson(response, 400, { ok: false, error: "expected JSON object" }, extraHeaders);
+ return undefined;
+ }
+ return parsed as T;
+}
function corsHeadersFor(request: IncomingMessage): Record<string, string> {
const origin = String(request.headers.origin ?? "");
@@ -130,8 +631,8 @@ function corsHeadersFor(request: IncomingMessage): Record<string, string> {
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",
+ "access-control-allow-headers": `content-type, ${AUTH_HEADER}`,
+ "access-control-expose-headers": `x-pi-chrome-version, ${AUTH_HEADER}`,
"vary": "origin",
};
}
@@ -164,11 +665,23 @@ class ChromeProfileBridge {
private lastSeenAt: number | undefined;
private clientName: string | undefined;
private mode: "server" | "client" | undefined;
+ private readonly auth: BridgeAuth;
constructor(
private readonly host: string,
private readonly port: number,
- ) {}
+ ) {
+ this.auth = new BridgeAuth();
+ }
+
+ get bridgeAuth(): BridgeAuth { return this.auth; }
+
+ private extensionIdFromOrigin(request: IncomingMessage): string | undefined {
+ const origin = String(request.headers.origin ?? "");
+ // Match Chrome's own format strictly: exact lowercase a-p, no trailing slash or path.
+ const match = origin.match(/^chrome-extension:\/\/([a-p]{32})$/);
+ return match ? match[1] : undefined;
+ }
get url(): string {
return `http://${this.host}:${this.port}`;
@@ -224,6 +737,7 @@ class ChromeProfileBridge {
this.mode = undefined;
return;
}
+ this.auth.stop();
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(new Error("Chrome profile bridge stopped"));
@@ -243,7 +757,7 @@ class ChromeProfileBridge {
}
private sendLocal(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> {
- const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
+ const id = randomUUID();
const command = { id, action, params };
return new Promise((resolveCommand, rejectCommand) => {
if (signal?.aborted) {
@@ -270,6 +784,12 @@ class ChromeProfileBridge {
),
);
}, timeoutMs);
+ if (this.pending.size >= MAX_PENDING) {
+ clearTimeout(timer);
+ cleanupAbort();
+ rejectCommand(new Error("Chrome bridge has too many in-flight commands; retry shortly"));
+ return;
+ }
this.pending.set(id, {
command,
resolve: (value) => { cleanupAbort(); resolveCommand(value); },
@@ -282,6 +802,9 @@ class ChromeProfileBridge {
}
private async sendViaOwner(action: string, params: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<unknown> {
+ if (!this.auth.paired) {
+ throw new Error("Chrome bridge not paired with this Pi config. Run /chrome pair in the Pi session that owns the bridge first.");
+ }
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs + 2_000);
const forwardAbort = () => controller.abort();
@@ -290,18 +813,33 @@ class ChromeProfileBridge {
else signal.addEventListener("abort", forwardAbort, { once: true });
}
try {
+ // Per-request signed envelope, keyed by brokerKey. No bearer token transmitted.
+ const body = JSON.stringify({ action, params, timeoutMs });
+ const sigHeader = this.auth.signPeerRequest("POST", "/command", body);
+ if (!sigHeader) throw new Error("Chrome bridge auth not available");
const response = await fetch(`${this.url}/command`, {
method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ action, params, timeoutMs }),
+ headers: { "content-type": "application/json", [AUTH_HEADER]: sigHeader },
+ body,
signal: controller.signal,
});
- const payload = (await response.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; error?: string };
+ const respText = await response.text();
+ // Legacy detection: an older pi-chrome bridge owner returns 404 for /command before
+ // signed envelopes existed. Match that BEFORE signature verification (the legacy
+ // owner has no key to sign with), since this is an upgrade hint, not a data path.
if (response.status === 404) {
throw new Error(
"A running Pi session owns the Chrome bridge but is using an older pi-chrome without multi-session support. Restart that Pi session after `pi update`, then retry.",
);
}
+ // Verify the owner's signed response BEFORE trusting any field in the body, including
+ // error strings. A pre-bind impostor without the broker key cannot produce a valid
+ // signature, so we refuse to surface its arbitrary error text.
+ const respAuth = response.headers.get(AUTH_HEADER) ?? undefined;
+ if (!this.auth.verifyOwnerResponse("POST", "/command", respAuth, respText)) {
+ throw new Error("Chrome bridge owner response signature invalid; refusing result");
+ }
+ const payload = (() => { try { return JSON.parse(respText); } catch { return {}; } })() as { ok?: boolean; result?: unknown; error?: string };
if (!response.ok || !payload.ok) throw new Error(payload.error ?? `Chrome bridge owner HTTP ${response.status}`);
return payload.result;
} catch (error) {
@@ -316,15 +854,45 @@ class ChromeProfileBridge {
}
}
+
+
private enqueue(command: BridgeCommand): void {
const waiter = this.waiters.shift();
- if (waiter) waiter(command);
- else this.queue.push(command);
+ if (waiter) { waiter(command); return; }
+ if (this.queue.length >= MAX_QUEUE) {
+ // Reject the just-enqueued command via its pending entry instead of silently
+ // dropping. Caller's promise rejects through the regular timer cleanup.
+ const pending = this.pending.get(command.id);
+ if (pending) {
+ clearTimeout(pending.timer);
+ this.pending.delete(command.id);
+ pending.reject(new Error("Chrome bridge queue full; refusing new command"));
+ }
+ return;
+ }
+ this.queue.push(command);
}
private async handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
const url = new URL(request.url ?? "/", this.url);
const corsHeaders = corsHeadersFor(request);
+ // Loopback enforcement applies to every endpoint, regardless of how the listening host was
+ // configured. Even with PI_CHROME_BRIDGE_DANGEROUS_REMOTE=1 we never accept non-loopback
+ // connections on /next, /result, /command, or /status without future authenticated
+ // transports.
+ if (!isLoopbackAddress(request.socket.remoteAddress ?? undefined)) {
+ sendJson(response, 403, { ok: false, error: "bridge accepts only loopback connections" });
+ return;
+ }
+ // Top-level Content-Length precheck so oversized bodies are 413'd before any auth or
+ // per-endpoint logic runs. Streaming size enforcement in readRequestBody still applies
+ // for chunked requests that omit Content-Length.
+ const declaredLen = Number(request.headers["content-length"]);
+ if (Number.isFinite(declaredLen) && declaredLen > MAX_REQUEST_BODY_BYTES) {
+ sendJson(response, 413, { ok: false, error: "request body too large" });
+ request.resume();
+ return;
+ }
if (request.method === "OPTIONS") {
if (!isBrowserOriginAllowed(request)) {
sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
@@ -337,25 +905,78 @@ class ChromeProfileBridge {
sendJson(response, 200, this.status());
return;
}
+ if (request.method === "POST" && url.pathname === "/pair") {
+ // /pair is browser-originated (extension popup posts here). Loopback already enforced.
+ if (!isBrowserOriginAllowed(request)) {
+ sendJson(response, 403, { ok: false, error: "browser origin required" }, corsHeaders);
+ return;
+ }
+ if (!this.auth.pairWindowActive()) {
+ sendJson(response, 403, { ok: false, error: "no active pairing window; run /chrome pair in Pi first" }, corsHeaders);
+ return;
+ }
+ const body = await readJsonBody<{ extensionId?: string; extensionNonce?: string; mac?: string }>(request, response, corsHeaders);
+ if (!body) return;
+ // Pin the extension ID to the one the request originates from, not the body claim.
+ const originExt = this.extensionIdFromOrigin(request);
+ if (!originExt) {
+ sendJson(response, 403, { ok: false, error: "chrome-extension origin required for pair" }, corsHeaders);
+ return;
+ }
+ if (body.extensionId && body.extensionId !== originExt) {
+ sendJson(response, 400, { ok: false, error: "extensionId mismatch with Origin" }, corsHeaders);
+ return;
+ }
+ try {
+ const { response: payload, signedHeader } = this.auth.completePairing({ ...body, extensionId: originExt });
+ sendJson(response, 200, payload, { ...corsHeaders, [AUTH_HEADER]: signedHeader });
+ } catch (error) {
+ sendJson(response, 401, { ok: false, error: (error as Error).message }, corsHeaders);
+ }
+ 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>;
- timeoutMs?: number;
- };
- if (!body.action) {
+ if (!this.auth.paired) {
+ sendJson(response, 401, { ok: false, error: "bridge not paired; run /chrome pair" });
+ return;
+ }
+ // Read body first so we can verify the MAC over the exact payload received.
+ const rawBody = await (async () => {
+ try { return await readRequestBody(request); }
+ catch (error) {
+ if (error instanceof RequestBodyTooLargeError) sendJson(response, 413, { ok: false, error: "request body too large" });
+ else sendJson(response, 400, { ok: false, error: `failed to read body: ${(error as Error).message}` });
+ return undefined;
+ }
+ })();
+ if (rawBody === undefined) return;
+ if (!this.auth.verifyPeerRequest("POST", "/command", request.headers[AUTH_HEADER], rawBody)) {
+ sendJson(response, 401, { ok: false, error: "missing or invalid bridge auth header" });
+ return;
+ }
+ let body: { action?: string; params?: Record<string, unknown>; timeoutMs?: number };
+ try { body = JSON.parse(rawBody || "{}"); }
+ catch (error) { sendJson(response, 400, { ok: false, error: `invalid JSON body: ${(error as Error).message}` }); return; }
+ if (!body.action || typeof body.action !== "string") {
sendJson(response, 400, { ok: false, error: "Missing command action" });
return;
}
+ const timeoutMs = Math.min(Math.max(Number(body.timeoutMs) || DEFAULT_TIMEOUT_MS, 1_000), 5 * 60_000);
try {
- const result = await this.sendLocal(body.action, body.params ?? {}, body.timeoutMs ?? DEFAULT_TIMEOUT_MS);
- sendJson(response, 200, { ok: true, result });
+ const result = await this.sendLocal(body.action, body.params ?? {}, timeoutMs);
+ const respBody = JSON.stringify({ ok: true, result });
+ const sigHeader = this.auth.signOwnerResponse("POST", "/command", respBody);
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...(sigHeader ? { [AUTH_HEADER]: sigHeader } : {}) });
+ response.end(respBody);
} catch (error) {
- sendJson(response, 504, { ok: false, error: (error as Error).message });
+ const respBody = JSON.stringify({ ok: false, error: (error as Error).message });
+ const sigHeader = this.auth.signOwnerResponse("POST", "/command", respBody);
+ response.writeHead(504, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...(sigHeader ? { [AUTH_HEADER]: sigHeader } : {}) });
+ response.end(respBody);
}
return;
}
@@ -364,6 +985,46 @@ class ChromeProfileBridge {
sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
return;
}
+ const originExt = this.extensionIdFromOrigin(request);
+ const currentVersion = readPiChromeVersion();
+ // Pre-pairing path: return a benign idle response so old/unpaired extensions can still
+ // see the version-mismatch reload hint, but never deliver commands.
+ if (!this.auth.paired) {
+ sendJson(response, 200, { type: "none", needsPairing: true, expectedExtensionVersion: currentVersion },
+ { ...corsHeaders, "x-pi-chrome-version": currentVersion });
+ return;
+ }
+ // Paired: pin the request origin to the paired extension and require signed envelope.
+ //
+ // We distinguish two failure modes:
+ // 1. Origin not pinned, OR origin pinned but auth header absent. Return 200 idle +
+ // version header so older extensions that pre-date signed envelopes (no header)
+ // can still see the version mismatch and auto-reload. They never receive
+ // commands; this is purely a migration helper.
+ // 2. Pinned origin sent an auth header but it failed verification (bad sig, replayed
+ // nonce, ts skew). That is an attack signal or a stale-key paired extension; we
+ // return a hard 401 with the version header so the caller backs off and the user
+ // sees a clear failure in logs.
+ const hasAuthHeader = Boolean(request.headers[AUTH_HEADER]);
+ if (!originExt || originExt !== this.auth.pinnedExtensionId) {
+ sendJson(response, 200, { type: "none", needsPairing: true, expectedExtensionVersion: currentVersion },
+ { ...corsHeaders, "x-pi-chrome-version": currentVersion });
+ return;
+ }
+ if (!hasAuthHeader) {
+ sendJson(response, 200, { type: "none", needsPairing: true, expectedExtensionVersion: currentVersion },
+ { ...corsHeaders, "x-pi-chrome-version": currentVersion });
+ return;
+ }
+ if (!this.auth.verifyExtensionRequest("GET", "/next", request.headers[AUTH_HEADER], originExt, "")) {
+ sendJson(response, 401, { ok: false, error: "invalid /next auth (bad sig, replayed nonce, or stale ts)" },
+ { ...corsHeaders, "x-pi-chrome-version": currentVersion });
+ return;
+ }
+ if (this.waiters.length >= MAX_WAITERS) {
+ sendJson(response, 429, { ok: false, error: "too many concurrent /next pollers" }, corsHeaders);
+ return;
+ }
this.lastSeenAt = Date.now();
this.clientName = url.searchParams.get("name") ?? undefined;
let aborted = false;
@@ -379,21 +1040,22 @@ class ChromeProfileBridge {
});
}
if (aborted) {
- // Long-poll connection died before we could deliver. Requeue any command we pulled
- // so the next live /next picks it up instead of dropping it on the floor.
if (command) this.queue.unshift(command);
return;
}
- // Re-read version on every /next so bumping package.json takes effect without pi restart.
- const currentVersion = readPiChromeVersion();
- sendJson(
- response,
- 200,
- command
- ? { type: "command", command, expectedExtensionVersion: currentVersion }
- : { type: "none", expectedExtensionVersion: currentVersion },
- { ...corsHeaders, "x-pi-chrome-version": currentVersion },
- );
+ const payload = command
+ ? { type: "command", command, expectedExtensionVersion: currentVersion }
+ : { type: "none", expectedExtensionVersion: currentVersion };
+ const respBody = JSON.stringify(payload);
+ const sigHeader = this.auth.signBridgeResponse("GET", "/next", respBody);
+ response.writeHead(200, {
+ "content-type": "application/json; charset=utf-8",
+ "cache-control": "no-store",
+ ...corsHeaders,
+ "x-pi-chrome-version": currentVersion,
+ ...(sigHeader ? { [AUTH_HEADER]: sigHeader } : {}),
+ });
+ response.end(respBody);
return;
}
if (request.method === "POST" && url.pathname === "/result") {
@@ -401,8 +1063,35 @@ class ChromeProfileBridge {
sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
return;
}
+ if (!this.auth.paired) {
+ sendJson(response, 401, { ok: false, error: "bridge not paired" }, corsHeaders);
+ return;
+ }
+ const originExt = this.extensionIdFromOrigin(request);
+ if (!originExt || originExt !== this.auth.pinnedExtensionId) {
+ sendJson(response, 403, { ok: false, error: "extension origin not pinned" }, corsHeaders);
+ return;
+ }
this.lastSeenAt = Date.now();
- const result = JSON.parse(await readRequestBody(request)) as BridgeResult;
+ // Read raw body, verify MAC over it, then parse.
+ let rawBody: string;
+ try { rawBody = await readRequestBody(request); }
+ catch (error) {
+ if (error instanceof RequestBodyTooLargeError) sendJson(response, 413, { ok: false, error: "request body too large" }, corsHeaders);
+ else sendJson(response, 400, { ok: false, error: `failed to read body: ${(error as Error).message}` }, corsHeaders);
+ return;
+ }
+ if (!this.auth.verifyExtensionRequest("POST", "/result", request.headers[AUTH_HEADER], originExt, rawBody)) {
+ sendJson(response, 401, { ok: false, error: "invalid /result auth" }, corsHeaders);
+ return;
+ }
+ let result: BridgeResult;
+ try { result = JSON.parse(rawBody || "{}") as BridgeResult; }
+ catch (error) { sendJson(response, 400, { ok: false, error: `invalid JSON: ${(error as Error).message}` }, corsHeaders); return; }
+ if (!result.id || typeof result.id !== "string") {
+ sendJson(response, 400, { ok: false, error: "missing command id" }, corsHeaders);
+ return;
+ }
const pending = this.pending.get(result.id);
if (!pending) {
sendJson(response, 404, { ok: false, error: "unknown command id" }, corsHeaders);
@@ -486,6 +1175,16 @@ export default function (pi: ExtensionAPI): void {
}
};
+ // Status bar reflects whether chrome_* tools are currently usable for this session. We only
+ // surface the indicator when authorized so an idle pi-chrome doesn't take up footer space.
+ const updateChromeStatus = (ctx: ExtensionContext): void => {
+ if (chromeControlAuthorized()) {
+ ctx.ui.setStatus("chrome", `${ctx.ui.theme.fg("success", "●")} Chrome Bridge`);
+ } else {
+ ctx.ui.setStatus("chrome", undefined);
+ }
+ };
+
const authorizedBridgeSend = (action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> => {
requireChromeControlAuthorized();
return bridge.send(action, params, timeoutMs, signal);
@@ -507,14 +1206,11 @@ export default function (pi: ExtensionAPI): void {
pi.on("session_start", async (_event, ctx) => {
await bridge.start();
- const status = bridge.status();
- 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). 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",
- );
+ // No setup notify or always-on status entry. Onboarding (/chrome onboard) and pairing
+ // (/chrome pair) print actionable instructions when invoked; surfacing them at every
+ // session_start was noisy for users who weren't using chrome_* tools that session. The
+ // status bar lights up only after /chrome authorize.
+ updateChromeStatus(ctx);
});
pi.on("session_shutdown", () => {
@@ -536,7 +1232,7 @@ Capability model (important):
- Input tools return structured details and support \`includeSnapshot=true\` on click/type/fill/key. Use the fresh snapshot to verify state instead of repeating blindly.
Usage rules:
-1. If a chrome_* tool says Chrome control is locked, ask the user to run \`/chrome authorize\` before retrying.
+1. If a chrome_* tool says Chrome control is locked or the bridge is not paired, ask the user to run \`/chrome pair\` (one-time) and \`/chrome authorize\` before retrying.
2. \`chrome_snapshot\` before clicking/typing; pass \`uid\` over \`selector\`.
3. \`includeSnapshot=true\` on click/type/fill/key 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.
@@ -555,6 +1251,11 @@ Usage rules:
const status = bridge.status();
const roleLabel = status.mode === "client" ? "sharing another pi session's connection" : "running the Chrome connection for this machine";
lines.push(`• This pi session is ${roleLabel}.`);
+ if (bridge.bridgeAuth.paired) {
+ lines.push(`✓ Bridge paired with extension ${bridge.bridgeAuth.pinnedExtensionId} (bridgeId ${bridge.bridgeAuth.bridgeId}).`);
+ } else {
+ lines.push("✗ Bridge not yet paired with the Chrome extension. Run /chrome pair, paste the invite into the extension popup, then re-run /chrome doctor.");
+ }
let extensionAlive = false;
let versionMismatch = false;
try {
@@ -650,6 +1351,7 @@ Usage rules:
}
chromeAuthorizedUntil = until;
ctx.ui.notify(`Chrome control authorized for ${label}.`, "info");
+ updateChromeStatus(ctx);
};
const parseAuthorizeArg = (arg: string): { label: string; until: number | "indefinite" } | undefined => {
@@ -671,26 +1373,183 @@ Usage rules:
const revokeHandler = (ctx: ExtensionContext) => {
chromeAuthorizedUntil = undefined;
+ updateChromeStatus(ctx);
ctx.ui.notify("Chrome control locked. Run /chrome authorize to allow chrome_* tools again.", "info");
};
+ // `/chrome pair` is the bare-bones pairing step, useful when the extension is already
+ // installed and you just need to re-arm or re-pair. First-time setup should use
+ // /chrome onboard which wraps this with installation guidance.
+ const pairHandler = async (ctx: ExtensionContext) => {
+ if (bridge.status().mode === "client") {
+ ctx.ui.notify(
+ "This Pi session is sharing another session's Chrome bridge. Run /chrome pair from the Pi session that owns the bridge (the first one started in this cwd).",
+ "warning",
+ );
+ return;
+ }
+ if (bridge.bridgeAuth.paired) {
+ const replace = await ctx.ui.confirm(
+ "Re-pair Chrome bridge?",
+ "The bridge is already paired with a Chrome extension. Re-pairing will invalidate the current keys and you'll need to re-paste the new invite into the extension popup.\n\nPress Enter to continue, or Esc to cancel.",
+ );
+ if (!replace) { ctx.ui.notify("Pairing cancelled.", "info"); return; }
+ bridge.bridgeAuth.reset();
+ }
+ const invite = bridge.bridgeAuth.startPairWindow();
+ await copyToClipboard(ctx, invite);
+ ctx.ui.notify(
+ [
+ "Pairing invite (also copied to clipboard):",
+ "",
+ ` ${invite}`,
+ "",
+ "In Chrome, click the Pi Chrome Connector toolbar icon, paste this invite, and click 'Pair'.",
+ `Invite expires in ${Math.round(PAIR_WINDOW_MS / 60_000)} minutes. After pairing, run /chrome authorize to allow chrome_* tools.`,
+ ].join("\n"),
+ "info",
+ );
+ };
+
+ const unpairHandler = async (ctx: ExtensionContext) => {
+ // Mirror of /chrome pair: only the bridge owner can authoritatively unpair. A peer that
+ // rewrites the shared config file would leave the owner's in-memory keys intact, and
+ // commands would keep flowing until the owner restarts.
+ if (bridge.status().mode === "client") {
+ ctx.ui.notify(
+ "This Pi session is sharing another session's Chrome bridge. Run /chrome unpair from the owner Pi session (the first one started in this cwd).",
+ "warning",
+ );
+ return;
+ }
+ if (!bridge.bridgeAuth.paired) {
+ ctx.ui.notify("pi-chrome is not currently paired.", "info");
+ return;
+ }
+ const proceed = await ctx.ui.confirm(
+ "Unpair Chrome bridge?",
+ "This clears the stored extension ID + HMAC keys. Every Pi session will need to re-pair (and the Chrome extension popup will need to be re-armed).\n\nPress Enter to confirm, or Esc to cancel.",
+ );
+ if (!proceed) { ctx.ui.notify("Unpair cancelled.", "info"); return; }
+ bridge.bridgeAuth.reset();
+ ctx.ui.notify("pi-chrome unpaired. Run /chrome pair to start a new pairing.", "info");
+ };
+
+ const copyToClipboard = async (ctx: ExtensionContext, text: string): Promise<void> => {
+ if (process.platform === "darwin") {
+ await pi.exec("sh", ["-lc", `printf %s ${JSON.stringify(text)} | pbcopy`], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined);
+ } else if (process.platform === "linux") {
+ await pi.exec("sh", ["-lc", `printf %s ${JSON.stringify(text)} | (xclip -selection clipboard 2>/dev/null || xsel --clipboard --input 2>/dev/null || wl-copy 2>/dev/null) || true`], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined);
+ }
+ };
+
+ // `/chrome onboard` walks the user end-to-end through the only setup that matters: install
+ // the Chrome extension, then pair it with this Pi bridge. Pairing was previously a separate
+ // `/chrome pair` step that users frequently forgot, so we inline it here. Returning users
+ // who only need to re-install the extension can skip the pair step when prompted; users who
+ // only need to re-pair an already-installed extension can call `/chrome pair` directly.
const onboardHandler = async (ctx: ExtensionContext) => {
+ if (bridge.status().mode === "client") {
+ ctx.ui.notify(
+ "This Pi session is sharing another session's Chrome bridge. Run /chrome onboard from the Pi session that owns the bridge (the first one started in this cwd).",
+ "warning",
+ );
+ return;
+ }
const extensionPath = browserExtensionPath();
- const proceed = await ctx.ui.confirm(
- "Install the pi-chrome Chrome extension?",
- `This opens Chrome's extensions page and reveals the folder pi-chrome needs you to load.\n\nWhen the windows open, in Chrome:\n 1. Turn on 'Developer mode' (top-right toggle).\n 2. Click 'Load unpacked' and choose the folder that just opened in Finder, or paste this path:\n ${extensionPath}\n\nPress Enter to continue, or Esc to cancel.`,
+
+ // --- Step 1: install / reload the Chrome extension. -------------------------------
+ const alreadyConnected = await bridge.send("tab.version", {}, 3_000).then(() => true).catch(() => false);
+ let installStep: "install" | "skip" = "install";
+ if (alreadyConnected) {
+ const keep = await ctx.ui.confirm(
+ "Chrome extension already connected",
+ "Skip the install step and jump straight to pairing? (Press Esc to re-install the extension instead.)",
+ );
+ if (keep) installStep = "skip";
+ }
+
+ if (installStep === "install") {
+ const proceed = await ctx.ui.confirm(
+ "Step 1 of 2: install the Chrome extension",
+ [
+ `pi-chrome will open Chrome's extensions page and reveal the extension folder in Finder.`,
+ ``,
+ `In Chrome:`,
+ ` 1. Turn on 'Developer mode' (top-right toggle).`,
+ ` 2. Click 'Load unpacked' and pick the folder that opens in Finder, or paste this path (it's on your clipboard):`,
+ ` ${extensionPath}`,
+ ``,
+ `Press Enter to continue, or Esc to cancel.`,
+ ].join("\n"),
+ );
+ if (!proceed) {
+ ctx.ui.notify("Cancelled. Run /chrome onboard again whenever you're ready.", "info");
+ return;
+ }
+ if (process.platform === "darwin") {
+ await pi.exec("open", ["-a", "Google Chrome", "chrome://extensions"], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined);
+ await pi.exec("open", ["-R", extensionPath], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined);
+ }
+ await copyToClipboard(ctx, extensionPath);
+ ctx.ui.notify(
+ "Chrome and Finder should be open; the extension folder path is on your clipboard. After Chrome shows 'Pi Chrome Connector', press Enter to continue.",
+ "info",
+ );
+ const ready = await ctx.ui.confirm(
+ "Extension loaded?",
+ "Confirm once 'Pi Chrome Connector' appears in chrome://extensions. Press Esc to abort if it didn't load.",
+ );
+ if (!ready) {
+ ctx.ui.notify("Cancelled before pairing. Run /chrome onboard again, or /chrome pair if you load the extension later.", "info");
+ return;
+ }
+ }
+
+ // --- Step 2: pair. ---------------------------------------------------------------
+ if (bridge.bridgeAuth.paired) {
+ const rePair = await ctx.ui.confirm(
+ "Bridge is already paired",
+ "Re-pair with this freshly-installed extension? The previous pairing will be invalidated and any other Pi sessions sharing this bridge will need /chrome authorize again.\n\nPress Enter to re-pair, Esc to keep the existing pairing.",
+ );
+ if (!rePair) {
+ ctx.ui.notify("Kept existing pairing. Run /chrome authorize to allow chrome_* tools.", "info");
+ return;
+ }
+ bridge.bridgeAuth.reset();
+ }
+ const invite = bridge.bridgeAuth.startPairWindow();
+ await copyToClipboard(ctx, invite);
+ ctx.ui.notify(
+ [
+ "Step 2 of 2: pair the extension with this Pi bridge.",
+ "",
+ " 1. Click the Pi Chrome Connector icon in Chrome's toolbar (the puzzle piece, then pin it if needed).",
+ " 2. Paste the invite below into the popup and click 'Pair'.",
+ "",
+ ` Invite (also on your clipboard): ${invite}`,
+ "",
+ ` Expires in ${Math.round(PAIR_WINDOW_MS / 60_000)} minutes.`,
+ ].join("\n"),
+ "info",
+ );
+ const paired = await ctx.ui.confirm(
+ "Wait for pairing",
+ "Press Enter once the extension popup says 'Paired with bridge …'. Press Esc to abort — invite stays armed for the rest of the window so you can call /chrome pair later.",
);
- if (!proceed) {
- ctx.ui.notify("Cancelled. You can run /chrome onboard again whenever you're ready.", "info");
+ if (!paired) {
+ ctx.ui.notify("Onboarding paused. Invite is still armed; run /chrome pair or paste again later.", "info");
return;
}
- if (process.platform === "darwin") {
- await pi.exec("open", ["-a", "Google Chrome", "chrome://extensions"], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined);
- await pi.exec("open", ["-R", extensionPath], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined);
- await pi.exec("sh", ["-lc", `printf %s ${JSON.stringify(extensionPath)} | pbcopy`], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined);
+ if (!bridge.bridgeAuth.paired) {
+ ctx.ui.notify(
+ "Pi side still shows the bridge as unpaired. The invite may not have been pasted yet, or the extension may not be loaded. Re-run /chrome onboard, or run /chrome doctor for diagnostics.",
+ "warning",
+ );
+ return;
}
ctx.ui.notify(
- "Chrome and Finder should be open. The extension folder path is on your clipboard. After you click 'Load unpacked' and pick it, run /chrome doctor to confirm everything is connected.",
+ "✓ Pairing complete. Next step: run /chrome authorize 15m (or longer) before using chrome_* tools.",
"info",
);
};
@@ -776,7 +1635,7 @@ Usage rules:
pi.registerCommand("chrome", {
description:
- "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.",
+ "All pi-chrome controls in one place.\n\n First-time setup (do these once, in this order):\n /chrome onboard — install the Chrome extension AND pair it with this Pi bridge.\n /chrome authorize [15m|30m|<minutes>|indefinite] — unlock chrome_* tools for this Pi session.\n\n Day-to-day:\n /chrome authorize — unlock chrome_* tools (per session).\n /chrome revoke — lock chrome_* tools again.\n /chrome status — one-line snapshot of connection, auth, and background setting.\n /chrome doctor — full health check; tells you exactly what's missing.\n\n Maintenance (rare):\n /chrome pair — (re-)pair the extension when only pairing is missing.\n /chrome unpair — clear pairing keys; every Pi session will need to re-pair.\n /chrome background [on|off|status|toggle] — run without focusing Chrome.\n\nRun with no arguments for an interactive picker that shows current state.",
getArgumentCompletions: (prefix) => {
const raw = prefix;
const trimmedRight = raw.replace(/\s+$/, "");
@@ -793,11 +1652,13 @@ Usage rules:
let candidates: Item[] = [];
if (path.length === 0) {
candidates = [
- { 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: "onboard", label: "onboard", description: "First-time setup: install the Chrome extension + pair it (run this once, then /chrome authorize)." },
+ { fullValue: "authorize", label: "authorize", description: "Unlock chrome_* tools for this Pi session." },
+ { fullValue: "revoke", label: "revoke", description: "Lock chrome_* tools again." },
{ 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: "doctor", label: "doctor", description: "Full health check. Tells you exactly what's missing if chrome_* isn't working." },
+ { fullValue: "pair", label: "pair", description: "Re-pair an already-installed extension (use /chrome onboard for first-time setup)." },
+ { fullValue: "unpair", label: "unpair", description: "Clear pairing keys (forces every Pi session to re-pair)." },
{ fullValue: "background", label: "background", description: "Run pi-chrome in the background without focusing Chrome?" },
];
} else if (path[0] === "authorize" && path.length === 1) {
@@ -828,6 +1689,8 @@ Usage rules:
const [head, ...rest] = tokens;
const subArgs = rest.join(" ");
switch (head) {
+ case "pair": return pairHandler(ctx);
+ case "unpair": return unpairHandler(ctx);
case "authorize": return authorizeHandler(ctx, subArgs);
case "revoke": return revokeHandler(ctx);
case "status": return statusHandler(ctx);
diff --git a/package.json b/package.json
index 3e5f4e1..fe49e5b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pi-chrome",
- "version": "0.15.20",
+ "version": "0.16.0",
"scripts": {
"version": "node scripts/sync-manifest-version.js",
"prepublishOnly": "node scripts/sync-manifest-version.js"
diff --git a/scripts/security-smoke.sh b/scripts/security-smoke.sh
new file mode 100755
index 0000000..a8cabbd
--- /dev/null
+++ b/scripts/security-smoke.sh
@@ -0,0 +1,98 @@
+#!/usr/bin/env bash
+#
+# Negative-path smoke tests for the hardened pi-chrome bridge.
+#
+# Run against a live bridge (Pi session must be running with this pi-chrome version).
+# These tests do NOT require an extension to be paired; they assert that unauthenticated
+# callers cannot drive Chrome through the bridge.
+#
+# usage: ./scripts/security-smoke.sh
+#
+# Each test is independent. Failures print FAIL and a non-zero exit at the end.
+
+set -u
+
+BRIDGE_URL="${PI_CHROME_BRIDGE_URL:-http://127.0.0.1:17318}"
+FAILED=0
+pass() { echo "PASS $1"; }
+fail() { echo "FAIL $1 (got: $2)"; FAILED=$((FAILED+1)); }
+
+check_status() {
+ local desc="$1" expected="$2" actual="$3"
+ if [[ "$actual" == "$expected" ]]; then pass "$desc"; else fail "$desc — expected HTTP $expected" "$actual"; fi
+}
+
+http_status() {
+ curl -sS -o /dev/null -w "%{http_code}" "$@"
+}
+
+echo "Bridge URL: $BRIDGE_URL"
+
+# 1. /command without auth header -> 401 (broker auth required)
+status=$(http_status -X POST "$BRIDGE_URL/command" -H 'content-type: application/json' --data '{"action":"tab.list","params":{}}')
+check_status "/command without auth -> 401" 401 "$status"
+
+# 2. /next from random chrome-extension origin returns 200 idle and never delivers a command.
+# Until the bridge is paired, the response includes {"needsPairing":true}. After pairing,
+# a non-pinned origin still gets the same idle payload (never the queued command). Either
+# way: type=none, no commands.
+body=$(curl -sS -H 'Origin: chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' "$BRIDGE_URL/next?name=smoke")
+if echo "$body" | grep -q '"type":"none"' && ! echo "$body" | grep -q '"type":"command"'; then
+ pass "/next from unpinned chrome-extension origin is idle, never command"
+else
+ fail "/next from unpinned chrome-extension origin" "$body"
+fi
+
+# 3. /next with wrong-shaped Origin (real web origin) -> 403
+status=$(http_status -H 'Origin: https://example.com' "$BRIDGE_URL/next")
+check_status "/next from web origin -> 403" 403 "$status"
+
+# 4. Oversized body to /command -> 413. Stream via stdin so argv stays sane on macOS.
+status=$(head -c $((2*1024*1024)) /dev/zero | tr '\0' 'x' \
+ | curl -sS -o /dev/null -w "%{http_code}" -X POST "$BRIDGE_URL/command" \
+ -H 'content-type: application/json' --data-binary @-)
+check_status "/command oversized body -> 413" 413 "$status"
+
+# 5. Malformed JSON to /result without auth: must be rejected (401/403) and must not crash
+# the server. Verify by hitting /status afterwards.
+status=$(http_status -X POST "$BRIDGE_URL/result" \
+ -H 'content-type: application/json' \
+ -H 'Origin: chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
+ --data 'not json')
+case "$status" in
+ 401|403) pass "/result malformed JSON without auth rejected ($status)";;
+ *) fail "/result malformed JSON without auth" "$status";;
+esac
+# Confirm server stayed responsive after the parse-error attempt.
+status=$(http_status "$BRIDGE_URL/status")
+check_status "/status still responsive after malformed POST" 200 "$status"
+
+# 6. /status is OK (loopback only, no secrets) -> 200
+status=$(http_status "$BRIDGE_URL/status")
+check_status "/status -> 200" 200 "$status"
+
+# 7. /command with bogus auth header -> 401
+status=$(http_status -X POST "$BRIDGE_URL/command" \
+ -H 'content-type: application/json' \
+ -H 'x-pi-chrome-auth: v1 ts=1 nonce=AAAA sig=AAAA' \
+ --data '{"action":"tab.list","params":{}}')
+check_status "/command with bogus auth -> 401" 401 "$status"
+
+# 8. /pair without active pairing window -> 403
+status=$(http_status -X POST "$BRIDGE_URL/pair" \
+ -H 'content-type: application/json' \
+ -H 'Origin: chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
+ --data '{"extensionId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","extensionNonce":"AAAA","mac":"AAAA"}')
+case "$status" in
+ 401|403) pass "/pair without active window rejected ($status)";;
+ *) fail "/pair without active window" "$status";;
+esac
+
+echo
+if [[ $FAILED -eq 0 ]]; then
+ echo "ALL SMOKE TESTS PASSED"
+ exit 0
+else
+ echo "$FAILED test(s) failed"
+ exit 1
+fi