aboutsummaryrefslogtreecommitdiff
path: root/extensions
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 /extensions
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.
Diffstat (limited to 'extensions')
-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
5 files changed, 1474 insertions, 77 deletions
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);