diff options
| author | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-16 11:55:27 -0400 |
|---|---|---|
| committer | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-16 11:55:27 -0400 |
| commit | ad78b41140f9040094295a28c5ab6482483efa4f (patch) | |
| tree | ba36801d51069779d76fe12ed94c9e73c343cdaa /extensions | |
| parent | 7992115c6dd9884e1a1f2992db531342d2916fcd (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')
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); |
