diff options
Diffstat (limited to 'extensions')
6 files changed, 136 insertions, 2563 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json index b3fcd7a..0d33b58 100644 --- a/extensions/chrome-profile-bridge/browser-extension/manifest.json +++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json @@ -1,12 +1,13 @@ { "manifest_version": 3, "name": "Pi Chrome Connector", - "version": "0.17.3", + "version": "0.15.20", "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.", "permissions": [ "tabs", "scripting", "storage", + "activeTab", "alarms", "webNavigation", "debugger" @@ -19,7 +20,6 @@ "service_worker": "service_worker.js" }, "action": { - "default_title": "Pi Chrome Connector", - "default_popup": "popup.html" + "default_title": "Pi Chrome Connector" } } diff --git a/extensions/chrome-profile-bridge/browser-extension/popup.html b/extensions/chrome-profile-bridge/browser-extension/popup.html deleted file mode 100644 index 636fd63..0000000 --- a/extensions/chrome-profile-bridge/browser-extension/popup.html +++ /dev/null @@ -1,42 +0,0 @@ -<!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 deleted file mode 100644 index cc890c0..0000000 --- a/extensions/chrome-profile-bridge/browser-extension/popup.js +++ /dev/null @@ -1,46 +0,0 @@ -// 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 36d9276..88391a7 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -3,215 +3,6 @@ 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} } @@ -836,16 +627,10 @@ 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, }); - // Even unpaired we honor version-mismatch reload so older extensions auto-update. + if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`); const expected = response.headers.get("x-pi-chrome-version"); const ours = chrome.runtime.getManifest().version; if (expected && expected !== ours && isVersionOlder(ours, expected)) { @@ -853,60 +638,8 @@ async function pollLoop() { try { chrome.runtime.reload(); } catch {} return; } - 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); - } + const payload = await response.json(); + if (payload.type === "command") await handleCommand(payload.command); } } catch (error) { await sleep(POLL_ERROR_BACKOFF_MS); @@ -915,22 +648,20 @@ async function pollLoop() { } } -async function handleCommand(command, auth) { +async function handleCommand(command) { try { const result = await dispatch(command.action, command.params ?? {}); - await postResult({ id: command.id, ok: true, result }, auth); + await postResult({ id: command.id, ok: true, result }); } catch (error) { - await postResult({ id: command.id, ok: false, error: error?.message ?? String(error) }, auth); + await postResult({ id: command.id, ok: false, error: error?.message ?? String(error) }); } } -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); +async function postResult(result) { await fetch(`${BRIDGE_URL}/result`, { method: "POST", - headers: { "content-type": "application/json", [AUTH_HEADER]: header }, - body, + headers: { "content-type": "application/json" }, + body: JSON.stringify(result), }); } @@ -1017,58 +748,17 @@ 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. 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. + // Register a one-shot document_start content script. We register, navigate, wait, then unregister. await registerInitScript(tab.id, params.initScript); } - // 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); - } + 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 }); 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) { - 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?.(); - } + if (params.initScript) await unregisterInitScript(tab.id).catch(() => undefined); } + return formatTab(await chrome.tabs.get(updated.id)); } case "page.screenshot": return takeScreenshot(params); @@ -1266,9 +956,6 @@ 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", @@ -1277,139 +964,6 @@ 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/daemon.cjs b/extensions/chrome-profile-bridge/daemon.cjs deleted file mode 100644 index bcd2b68..0000000 --- a/extensions/chrome-profile-bridge/daemon.cjs +++ /dev/null @@ -1,702 +0,0 @@ -#!/usr/bin/env node -// -// pi-chrome bridge daemon — standalone version of the Chrome bridge. -// -// Runs as a detached process spawned by a Pi session at session_start. Owns: -// - the TCP listener on :17318 -// - the BridgeAuth state (~/.config/pi/chrome-bridge.json mode 0600) -// - the command queue between Pi clients and the Chrome extension service worker -// -// Pi sessions talk to the daemon via signed /command HTTP requests; the Chrome extension -// long-polls /next. Both sides use the v1 signed-envelope protocol documented in SECURITY.md. -// -// This file is shipped in the npm tarball and copied to ~/.cache/pi-chrome/<version>/daemon.cjs -// by the supervisor on first use. It must remain dependency-free (only node: builtins) so -// it can run anywhere Node ≥18 is installed regardless of node_modules state. - -"use strict"; - -const { createServer } = require("node:http"); -const { createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } = require("node:crypto"); -const { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, statSync, watch: fsWatch } = require("node:fs"); -const { homedir } = require("node:os"); -const { dirname, join } = require("node:path"); - -// --------------------------------------------------------------------------------------------- -// Configuration + constants -// --------------------------------------------------------------------------------------------- - -const ARGS = (() => { - const out = { host: "127.0.0.1", port: 17318, version: "0.0.0-dev", idleShutdownMs: 10 * 60_000, logPath: undefined }; - for (let i = 2; i < process.argv.length; i++) { - const arg = process.argv[i]; - const [k, v] = arg.includes("=") ? arg.split("=", 2) : [arg, process.argv[++i]]; - switch (k) { - case "--host": out.host = v; break; - case "--port": out.port = Number(v); break; - case "--version": out.version = v; break; - case "--idle-ms": out.idleShutdownMs = Number(v); break; - case "--log": out.logPath = v; break; - } - } - return out; -})(); - -const DEFAULT_TIMEOUT_MS = 30_000; -const MAX_REQUEST_BODY_BYTES = 1 * 1024 * 1024; -const MAX_QUEUE = 256; -const MAX_PENDING = 256; -const MAX_WAITERS = 4; - -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 logLine(level, msg) { - const line = `[${new Date().toISOString()}] [${level}] ${msg}\n`; - process.stderr.write(line); -} - -// --------------------------------------------------------------------------------------------- -// Signed-envelope auth (mirrors index.ts BridgeAuth; see SECURITY.md for spec). -// --------------------------------------------------------------------------------------------- - -function configPath() { - const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); - return join(base, "pi", "chrome-bridge.json"); -} -function b64url(buf) { return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } -function b64urlDecode(text) { - const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (text.length % 4)) % 4); - return Buffer.from(padded, "base64"); -} -function sha256Hex(input) { return createHash("sha256").update(input).digest("hex"); } -function hmacSign(key, message) { return createHmac("sha256", key).update(message).digest(); } -function safeBufferEqual(a, b) { - if (a.length !== b.length) return false; - try { return timingSafeEqual(a, b); } catch { return false; } -} -function safeStringEqual(a, b) { - const aBuf = Buffer.from(a, "utf8"); - const bBuf = Buffer.from(b, "utf8"); - return safeBufferEqual(aBuf, bBuf); -} -function canonicalString(direction, method, path, extensionId, bridgeId, ts, nonce, body) { - return [AUTH_PROTOCOL, direction, method.toUpperCase(), path, extensionId, bridgeId, String(ts), nonce, sha256Hex(body)].join("\n"); -} -function buildAuthHeader(key, direction, method, path, extensionId, bridgeId, body) { - 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) { - 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 = {}; - 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 }; -} - -class NonceCache { - constructor() { this.entries = new Map(); } - has(nonce) { return this.entries.has(nonce); } - store(nonce, ts) { - const now = Date.now(); - for (const [k, t] of this.entries) { - if (now - t > NONCE_TTL_MS) this.entries.delete(k); else break; - } - 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); - } -} - -class BridgeAuth { - constructor() { - this.state = undefined; - this.pendingInvite = undefined; - this.extNonces = new NonceCache(); - this.peerNonces = new NonceCache(); - this.ownerRespNonces = new NonceCache(); - this.lastStateMtimeMs = 0; - this.watcher = undefined; - this.load(); - this.startWatching(); - } - get paired() { this.refreshIfStale(); return Boolean(this.state); } - get bridgeId() { this.refreshIfStale(); return this.state?.bridgeId ?? ""; } - get pinnedExtensionId() { this.refreshIfStale(); return this.state?.extensionId ?? ""; } - load() { - 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; - } else { - this.state = undefined; - } - } catch { - this.state = undefined; - } - } - persist() { - 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) { - logLine("warn", `failed to persist auth state at ${path}: ${error.message}`); - } - } - startWatching() { - 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) { - logLine("warn", `fs.watch on ${dir} failed: ${error.message}`); - } - } - stop() { try { this.watcher?.close(); } catch {} this.watcher = undefined; } - refreshIfStale() { - const path = configPath(); - let mtimeMs = 0; - try { mtimeMs = statSync(path).mtimeMs; } catch { mtimeMs = -1; } - if (mtimeMs === this.lastStateMtimeMs) return; - this.lastStateMtimeMs = mtimeMs; - const prevExt = this.state?.extensionId; - const prevBridge = this.state?.bridgeId; - this.load(); - if (this.state?.extensionId !== prevExt || this.state?.bridgeId !== prevBridge) { - this.extNonces = new NonceCache(); - this.peerNonces = new NonceCache(); - this.ownerRespNonces = new NonceCache(); - } - } - startPairWindow() { - const secret = randomBytes(32); - this.pendingInvite = { secret, expiresAt: Date.now() + PAIR_WINDOW_MS }; - return `pcp_${b64url(secret)}`; - } - pairWindowActive() { - if (!this.pendingInvite) return false; - if (this.pendingInvite.expiresAt < Date.now()) { this.pendingInvite = undefined; return false; } - return true; - } - reset() { - 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 {} - } - completePairing(body) { - if (!this.pairWindowActive()) throw new Error("no active pairing window"); - if (!body.extensionId || !body.extensionNonce || !body.mac) throw new Error("pair: missing fields"); - 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; - const response = { - 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 }; - } - verifyEnvelope(key, cache, direction, method, path, extensionId, bridgeId, header, body) { - const parsed = parseAuthHeader(header); - if (!parsed) return false; - if (Math.abs(Date.now() - parsed.ts) > MAX_CLOCK_SKEW_MS) return false; - if (cache.has(parsed.nonce)) return false; - const expected = hmacSign(key, canonicalString(direction, method, path, extensionId, bridgeId, parsed.ts, parsed.nonce, body)); - let supplied; - try { supplied = b64urlDecode(parsed.sig); } catch { return false; } - if (!safeBufferEqual(expected, supplied)) return false; - cache.store(parsed.nonce, parsed.ts); - return true; - } - verifyExtensionRequest(method, path, header, originExtensionId, body) { - 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, path, body) { - 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, path, header, body) { - 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); - } - signOwnerResponse(method, path, body) { - 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; - } -} - -// --------------------------------------------------------------------------------------------- -// HTTP utilities -// --------------------------------------------------------------------------------------------- - -function isLoopback(addr) { - return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1"; -} -function corsFor(req) { - const origin = String(req.headers.origin ?? ""); - if (!origin.startsWith("chrome-extension://")) return {}; - return { - "access-control-allow-origin": origin, - "access-control-allow-methods": "GET,POST,OPTIONS", - "access-control-allow-headers": `content-type, ${AUTH_HEADER}`, - "access-control-expose-headers": `x-pi-chrome-version, ${AUTH_HEADER}`, - "vary": "origin", - }; -} -function isBrowserOriginAllowed(req) { - const origin = String(req.headers.origin ?? ""); - if (origin) return origin.startsWith("chrome-extension://"); - const secFetchSite = String(req.headers["sec-fetch-site"] ?? ""); - return !secFetchSite || secFetchSite === "none" || secFetchSite === "same-origin"; -} -function isLocalProcessRequest(req) { - return !req.headers.origin && !req.headers["sec-fetch-site"]; -} -function sendJson(res, status, body, extra) { - res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...(extra ?? {}) }); - res.end(JSON.stringify(body)); -} -function extensionIdFromOrigin(req) { - const origin = String(req.headers.origin ?? ""); - const match = origin.match(/^chrome-extension:\/\/([a-p]{32})$/); - return match ? match[1] : undefined; -} - -class RequestBodyTooLargeError extends Error { constructor() { super("request body too large"); this.name = "RequestBodyTooLargeError"; } } -function readBody(req, maxBytes = MAX_REQUEST_BODY_BYTES) { - return new Promise((resolve, reject) => { - const declared = Number(req.headers["content-length"]); - if (Number.isFinite(declared) && declared > maxBytes) { reject(new RequestBodyTooLargeError()); req.resume(); return; } - const chunks = []; - let size = 0; - req.on("data", (chunk) => { - const buf = Buffer.from(chunk); - size += buf.length; - if (size > maxBytes) { reject(new RequestBodyTooLargeError()); req.destroy(); return; } - chunks.push(buf); - }); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - req.on("error", reject); - }); -} -async function readJsonObject(req, res, extraHeaders) { - let raw; - try { raw = await readBody(req); } - catch (error) { - if (error instanceof RequestBodyTooLargeError) sendJson(res, 413, { ok: false, error: "request body too large" }, extraHeaders); - else sendJson(res, 400, { ok: false, error: `failed to read body: ${error.message}` }, extraHeaders); - return undefined; - } - let parsed; - try { parsed = JSON.parse(raw || "{}"); } - catch (error) { sendJson(res, 400, { ok: false, error: `invalid JSON: ${error.message}` }, extraHeaders); return undefined; } - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - sendJson(res, 400, { ok: false, error: "expected JSON object" }, extraHeaders); - return undefined; - } - return parsed; -} - -// --------------------------------------------------------------------------------------------- -// Bridge daemon -// --------------------------------------------------------------------------------------------- - -const startedAt = Date.now(); -let lastActivityAt = Date.now(); - -const auth = new BridgeAuth(); -const pending = new Map(); -let queue = []; -let waiters = []; -let lastSeenAt; -let clientName; - -function touchActivity() { lastActivityAt = Date.now(); } - -function enqueue(command) { - const w = waiters.shift(); - if (w) { w(command); return; } - if (queue.length >= MAX_QUEUE) { - const p = pending.get(command.id); - if (p) { clearTimeout(p.timer); pending.delete(command.id); p.reject(new Error("queue full")); } - return; - } - queue.push(command); -} - -function sendCommandLocal(action, params, timeoutMs = DEFAULT_TIMEOUT_MS) { - const id = randomUUID(); - const command = { id, action, params }; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pending.delete(id); - queue = queue.filter((q) => q.id !== id); - reject(new Error(`Timed out waiting for Chrome extension after ${timeoutMs}ms.`)); - }, timeoutMs); - if (pending.size >= MAX_PENDING) { clearTimeout(timer); reject(new Error("too many in-flight commands")); return; } - pending.set(id, { command, resolve, reject, timer }); - enqueue(command); - }); -} - -function waitForCommand(timeoutMs, register) { - return new Promise((resolve) => { - let settled = false; - const w = (cmd) => { if (settled) return; settled = true; clearTimeout(t); waiters = waiters.filter((e) => e !== w); resolve(cmd); }; - const t = setTimeout(() => w(undefined), timeoutMs); - waiters.push(w); - register?.(w); - }); -} - -async function handle(req, res) { - touchActivity(); - const url = new URL(req.url ?? "/", `http://${ARGS.host}:${ARGS.port}`); - const corsHeaders = corsFor(req); - - if (!isLoopback(req.socket.remoteAddress)) { - sendJson(res, 403, { ok: false, error: "bridge accepts only loopback connections" }); - return; - } - const declaredLen = Number(req.headers["content-length"]); - if (Number.isFinite(declaredLen) && declaredLen > MAX_REQUEST_BODY_BYTES) { - sendJson(res, 413, { ok: false, error: "request body too large" }); - req.resume(); - return; - } - - if (req.method === "OPTIONS") { - if (!isBrowserOriginAllowed(req)) { sendJson(res, 403, { ok: false, error: "browser origin not allowed" }); return; } - sendJson(res, 200, { ok: true }, corsHeaders); - return; - } - - if (req.method === "GET" && url.pathname === "/status") { - sendJson(res, 200, { - url: `http://${ARGS.host}:${ARGS.port}`, - mode: "daemon", - bridgeVersion: ARGS.version, - daemonPid: process.pid, - daemonStartedAt: startedAt, - connected: lastSeenAt !== undefined && Date.now() - lastSeenAt < 5 * 60_000, - lastSeenAt, - clientName, - queuedCommands: queue.length, - pendingCommands: pending.size, - paired: auth.paired, - }); - return; - } - - if (req.method === "GET" && url.pathname === "/heartbeat") { - // Pi clients hit this to keep the daemon's idle timer alive between long gaps of - // chrome_* tool activity. Loopback-only, no auth needed. - sendJson(res, 200, { ok: true, ts: Date.now() }); - return; - } - - if (req.method === "POST" && url.pathname === "/pair") { - if (!isBrowserOriginAllowed(req)) { sendJson(res, 403, { ok: false, error: "browser origin required" }, corsHeaders); return; } - if (!auth.pairWindowActive()) { - sendJson(res, 403, { ok: false, error: "no active pairing window; run /chrome pair in Pi first" }, corsHeaders); - return; - } - const body = await readJsonObject(req, res, corsHeaders); - if (!body) return; - const originExt = extensionIdFromOrigin(req); - if (!originExt) { sendJson(res, 403, { ok: false, error: "chrome-extension origin required" }, corsHeaders); return; } - if (body.extensionId && body.extensionId !== originExt) { sendJson(res, 400, { ok: false, error: "extensionId mismatch with Origin" }, corsHeaders); return; } - try { - const { response, signedHeader } = auth.completePairing({ ...body, extensionId: originExt }); - sendJson(res, 200, response, { ...corsHeaders, [AUTH_HEADER]: signedHeader }); - } catch (error) { - sendJson(res, 401, { ok: false, error: error.message }, corsHeaders); - } - return; - } - - if (req.method === "POST" && url.pathname === "/admin") { - // Admin RPC for Pi clients: arm-pair-window, reset, etc. Requires a signed - // peer->owner envelope (same broker key used for /command). - if (!isLocalProcessRequest(req)) { sendJson(res, 403, { ok: false, error: "admin endpoint local-only" }); return; } - let rawBody; - try { rawBody = await readBody(req); } - catch (error) { - if (error instanceof RequestBodyTooLargeError) sendJson(res, 413, { ok: false, error: "body too large" }); - else sendJson(res, 400, { ok: false, error: `body read: ${error.message}` }); - return; - } - if (auth.paired) { - // Once paired, admin calls must be authenticated. Pre-pairing, the first /admin - // call is `arm-pair-window` from the local Pi user and is allowed unauthenticated - // (loopback-only + isLocalProcessRequest already gate it). - if (!auth.verifyPeerRequest("POST", "/admin", req.headers[AUTH_HEADER], rawBody)) { - sendJson(res, 401, { ok: false, error: "missing or invalid admin auth" }); - return; - } - } - let body; - try { body = JSON.parse(rawBody || "{}"); } - catch (error) { sendJson(res, 400, { ok: false, error: `invalid JSON: ${error.message}` }); return; } - const op = body && body.op; - const respond = (status, payload) => { - const text = JSON.stringify(payload); - const sig = auth.signOwnerResponse("POST", "/admin", text); - res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...(sig ? { [AUTH_HEADER]: sig } : {}) }); - res.end(text); - }; - try { - switch (op) { - case "arm-pair-window": { - if (auth.paired && !body.replace) { - respond(409, { ok: false, error: "already paired; pass replace:true to re-pair" }); - return; - } - if (body.replace) auth.reset(); - const invite = auth.startPairWindow(); - respond(200, { ok: true, invite, windowMs: PAIR_WINDOW_MS }); - return; - } - case "reset": { - auth.reset(); - respond(200, { ok: true }); - return; - } - case "status": { - respond(200, { ok: true, paired: auth.paired, bridgeId: auth.bridgeId, pinnedExtensionId: auth.pinnedExtensionId }); - return; - } - default: - respond(400, { ok: false, error: `unknown admin op: ${op}` }); - } - } catch (error) { - respond(500, { ok: false, error: error.message }); - } - return; - } - - if (req.method === "POST" && url.pathname === "/command") { - if (!isLocalProcessRequest(req)) { sendJson(res, 403, { ok: false, error: "/command local-only" }); return; } - if (!auth.paired) { sendJson(res, 401, { ok: false, error: "bridge not paired; run /chrome pair" }); return; } - let rawBody; - try { rawBody = await readBody(req); } - catch (error) { - if (error instanceof RequestBodyTooLargeError) sendJson(res, 413, { ok: false, error: "body too large" }); - else sendJson(res, 400, { ok: false, error: `body read: ${error.message}` }); - return; - } - if (!auth.verifyPeerRequest("POST", "/command", req.headers[AUTH_HEADER], rawBody)) { - sendJson(res, 401, { ok: false, error: "missing or invalid bridge auth header" }); - return; - } - let body; - try { body = JSON.parse(rawBody || "{}"); } - catch (error) { sendJson(res, 400, { ok: false, error: `invalid JSON: ${error.message}` }); return; } - if (!body.action || typeof body.action !== "string") { sendJson(res, 400, { ok: false, error: "missing action" }); return; } - const timeoutMs = Math.min(Math.max(Number(body.timeoutMs) || DEFAULT_TIMEOUT_MS, 1_000), 5 * 60_000); - const respond = (status, payload) => { - const text = JSON.stringify(payload); - const sig = auth.signOwnerResponse("POST", "/command", text); - res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...(sig ? { [AUTH_HEADER]: sig } : {}) }); - res.end(text); - }; - try { - const result = await sendCommandLocal(body.action, body.params ?? {}, timeoutMs); - respond(200, { ok: true, result }); - } catch (error) { - respond(504, { ok: false, error: error.message }); - } - return; - } - - if (req.method === "GET" && url.pathname === "/next") { - if (!isBrowserOriginAllowed(req)) { sendJson(res, 403, { ok: false, error: "browser origin not allowed" }); return; } - const originExt = extensionIdFromOrigin(req); - const currentVersion = ARGS.version; - // Any /next from a chrome-extension origin counts as 'extension is alive'. We record - // lastSeenAt here — before the auth/pair checks below — so that /chrome onboard's - // connect-wait loop can detect a polling-but-unpaired extension (which is precisely - // the state during the pair step). - if (originExt) { - lastSeenAt = Date.now(); - clientName = url.searchParams.get("name") ?? undefined; - } - if (!auth.paired) { - sendJson(res, 200, { type: "none", needsPairing: true, expectedExtensionVersion: currentVersion }, - { ...corsHeaders, "x-pi-chrome-version": currentVersion }); - return; - } - const hasAuthHeader = Boolean(req.headers[AUTH_HEADER]); - if (!originExt || originExt !== auth.pinnedExtensionId) { - sendJson(res, 200, { type: "none", needsPairing: true, expectedExtensionVersion: currentVersion }, - { ...corsHeaders, "x-pi-chrome-version": currentVersion }); - return; - } - if (!hasAuthHeader) { - sendJson(res, 200, { type: "none", needsPairing: true, expectedExtensionVersion: currentVersion }, - { ...corsHeaders, "x-pi-chrome-version": currentVersion }); - return; - } - if (!auth.verifyExtensionRequest("GET", "/next", req.headers[AUTH_HEADER], originExt, "")) { - sendJson(res, 401, { ok: false, error: "invalid /next auth" }, { ...corsHeaders, "x-pi-chrome-version": currentVersion }); - return; - } - if (waiters.length >= MAX_WAITERS) { sendJson(res, 429, { ok: false, error: "too many pollers" }, corsHeaders); return; } - let aborted = false; - let active; - req.once("close", () => { aborted = true; if (active) waiters = waiters.filter((e) => e !== active); }); - let command = queue.shift(); - if (!command) command = await waitForCommand(25_000, (w) => { active = w; }); - if (aborted) { if (command) queue.unshift(command); return; } - const payload = command - ? { type: "command", command, expectedExtensionVersion: currentVersion } - : { type: "none", expectedExtensionVersion: currentVersion }; - const respBody = JSON.stringify(payload); - const sig = auth.signBridgeResponse("GET", "/next", respBody); - res.writeHead(200, { - "content-type": "application/json; charset=utf-8", - "cache-control": "no-store", - ...corsHeaders, - "x-pi-chrome-version": currentVersion, - ...(sig ? { [AUTH_HEADER]: sig } : {}), - }); - res.end(respBody); - return; - } - - if (req.method === "POST" && url.pathname === "/result") { - if (!isBrowserOriginAllowed(req)) { sendJson(res, 403, { ok: false, error: "browser origin not allowed" }); return; } - if (!auth.paired) { sendJson(res, 401, { ok: false, error: "bridge not paired" }, corsHeaders); return; } - const originExt = extensionIdFromOrigin(req); - if (!originExt || originExt !== auth.pinnedExtensionId) { - sendJson(res, 403, { ok: false, error: "extension origin not pinned" }, corsHeaders); - return; - } - lastSeenAt = Date.now(); - let rawBody; - try { rawBody = await readBody(req); } - catch (error) { - if (error instanceof RequestBodyTooLargeError) sendJson(res, 413, { ok: false, error: "body too large" }, corsHeaders); - else sendJson(res, 400, { ok: false, error: `body read: ${error.message}` }, corsHeaders); - return; - } - if (!auth.verifyExtensionRequest("POST", "/result", req.headers[AUTH_HEADER], originExt, rawBody)) { - sendJson(res, 401, { ok: false, error: "invalid /result auth" }, corsHeaders); - return; - } - let result; - try { result = JSON.parse(rawBody || "{}"); } - catch (error) { sendJson(res, 400, { ok: false, error: `invalid JSON: ${error.message}` }, corsHeaders); return; } - if (!result.id || typeof result.id !== "string") { sendJson(res, 400, { ok: false, error: "missing command id" }, corsHeaders); return; } - const p = pending.get(result.id); - if (!p) { sendJson(res, 404, { ok: false, error: "unknown command id" }, corsHeaders); return; } - clearTimeout(p.timer); - pending.delete(result.id); - if (result.ok) p.resolve(result.result); - else p.reject(new Error(result.error ?? "Chrome extension command failed")); - sendJson(res, 200, { ok: true }, corsHeaders); - return; - } - - sendJson(res, 404, { error: "not found" }); -} - -// --------------------------------------------------------------------------------------------- -// Process lifecycle -// --------------------------------------------------------------------------------------------- - -const server = createServer((req, res) => { - handle(req, res).catch((error) => { - try { sendJson(res, 500, { error: error?.message ?? String(error) }); } catch {} - }); -}); - -server.on("error", (error) => { - if (error.code === "EADDRINUSE") { - logLine("info", `port ${ARGS.port} already in use; exiting cleanly so the existing daemon wins`); - process.exit(0); - } - logLine("error", `server error: ${error.message}`); - process.exit(1); -}); - -server.listen(ARGS.port, ARGS.host, () => { - logLine("info", `pi-chrome daemon v${ARGS.version} listening on http://${ARGS.host}:${ARGS.port} (pid ${process.pid})`); -}); - -// Idle shutdown: exit if no activity (extension poll or command) for IDLE_SHUTDOWN_MS. -setInterval(() => { - const idle = Date.now() - lastActivityAt; - if (idle > ARGS.idleShutdownMs) { - logLine("info", `idle for ${Math.round(idle / 1000)}s; shutting down`); - auth.stop(); - server.close(() => process.exit(0)); - setTimeout(() => process.exit(0), 2_000).unref(); - } -}, 30_000).unref(); - -// Graceful signal handling. -for (const sig of ["SIGTERM", "SIGINT"]) { - process.on(sig, () => { - logLine("info", `received ${sig}; shutting down`); - auth.stop(); - server.close(() => process.exit(0)); - setTimeout(() => process.exit(0), 2_000).unref(); - }); -} diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts index 718012a..48b726f 100644 --- a/extensions/chrome-profile-bridge/index.ts +++ b/extensions/chrome-profile-bridge/index.ts @@ -1,12 +1,8 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; -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 { existsSync, readFileSync, statSync } from "node:fs"; +import { mkdir, writeFile } 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 { spawn, spawnSync } from "node:child_process"; -import { copyFileSync, openSync } from "node:fs"; -import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; /** @@ -58,581 +54,9 @@ function readPiChromeVersion(): string { } const PI_CHROME_VERSION = readPiChromeVersion(); const PI_CHROME_GLOBAL_KEY = "__piChromeProfileBridgeLoaded__"; -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_HOST = process.env.PI_CHROME_BRIDGE_HOST ?? "127.0.0.1"; 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"; -} - -// Locate the OS-level owner of `:port` via `lsof`. Returns undefined when lsof isn't on -// PATH (minimal Linux containers, Windows). Used by ChromeProfileBridge to decide whether -// to auto-takeover an incompatible existing owner at session_start. -function findPortOwnerPid(port: number): number | undefined { - try { - const result = spawnSync("sh", ["-lc", `lsof -nP -iTCP:${port} -sTCP:LISTEN -t 2>/dev/null | head -1`], { timeout: 3_000, encoding: "utf8" }); - if (result.status !== 0) return undefined; - const pid = Number(String(result.stdout).trim()); - return Number.isFinite(pid) && pid > 0 ? pid : undefined; - } catch { - return undefined; - } -} - -// Probe the owner's /status with a 1s timeout. Returns the parsed status object, or undefined -// when the owner is unresponsive (likely dead/wedged). Synchronous fetch via a child process -// avoids importing a fetch shim before the main event loop is up. -function probeBridgeStatus(host: string, port: number): { bridgeVersion?: string } | undefined { - try { - const result = spawnSync("curl", ["-sS", "--max-time", "1", `http://${host}:${port}/status`], { timeout: 2_000, encoding: "utf8" }); - if (result.status !== 0) return undefined; - return JSON.parse(String(result.stdout || "").trim() || "{}"); - } catch { - return undefined; - } -} - -// Daemon compat is checked at exact version match — not major.minor — so that even patch -// releases trigger an automatic kill+respawn. Patch releases routinely fix bugs in the daemon -// itself (e.g. /next lastSeenAt handling); without exact matching, a new Pi client joins an -// old buggy daemon and the fix never lands. -function daemonVersionCompatible(daemonVersion: string, clientVersion: string): boolean { - return daemonVersion === clientVersion; -} - -// Ensure the bundled daemon.cjs is copied to a versioned cache dir so each pi-chrome -// version has a stable on-disk daemon binary independent of nvm/npm install location. -function ensureVersionedDaemon(): string { - const target = join(homedir(), ".cache", "pi-chrome", PI_CHROME_VERSION, "daemon.cjs"); - const source = join(extensionRoot(), "daemon.cjs"); - try { - mkdirSync(dirname(target), { recursive: true, mode: 0o755 }); - // Copy idempotently: if shasum already matches, skip. Cheap because daemon.cjs is small. - let needCopy = true; - if (existsSync(target)) { - try { - const a = readFileSync(target); - const b = readFileSync(source); - needCopy = a.length !== b.length || createHash("sha256").update(a).digest("hex") !== createHash("sha256").update(b).digest("hex"); - } catch { needCopy = true; } - } - if (needCopy) copyFileSync(source, target); - } catch (error) { - console.warn(`pi-chrome: failed to stage daemon at ${target} (${(error as Error).message}); falling back to in-tree source.`); - return source; - } - return target; -} - -function daemonLogPath(): string { - return join(homedir(), ".cache", "pi-chrome", PI_CHROME_VERSION, "daemon.log"); -} - -// Spawn the daemon as a detached child so it survives the parent Pi exiting. The child is -// unref()'d immediately; from the Pi's perspective it's fire-and-forget. Returns the child's -// PID for diagnostic purposes only. -function spawnDaemon(host: string, port: number): number { - const daemonPath = ensureVersionedDaemon(); - const logPath = daemonLogPath(); - try { mkdirSync(dirname(logPath), { recursive: true }); } catch {} - let logFd: number; - try { logFd = openSync(logPath, "a"); } catch { logFd = openSync("/dev/null", "a"); } - const child = spawn(process.execPath, [ - daemonPath, - "--host", host, - "--port", String(port), - "--version", PI_CHROME_VERSION, - "--log", logPath, - ], { - detached: true, - stdio: ["ignore", logFd, logFd], - }); - child.unref(); - return child.pid ?? -1; -} - -// Wait up to `timeoutMs` for /status to respond with the expected version. Returns the -// observed status (or undefined on timeout). -function waitForDaemonReady(host: string, port: number, timeoutMs = 4_000): Record<string, unknown> | undefined { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const probed = probeBridgeStatus(host, port); - if (probed && typeof (probed as { bridgeVersion?: string }).bridgeVersion === "string" - && daemonVersionCompatible((probed as { bridgeVersion: string }).bridgeVersion, PI_CHROME_VERSION)) { - return probed; - } - spawnSync("sleep", ["0.1"]); - } - return undefined; -} - -function killProcessGracefully(pid: number): boolean { - try { process.kill(pid, "SIGTERM"); } catch { return false; } - // Spin briefly waiting for the port to free; escalate to SIGKILL if SIGTERM is ignored. - const deadline = Date.now() + 3_000; - while (Date.now() < deadline) { - try { process.kill(pid, 0); } catch { return true; } // kill(pid, 0) throws ESRCH when gone - spawnSync("sleep", ["0.2"]); - } - try { process.kill(pid, "SIGKILL"); } catch {} - const secondDeadline = Date.now() + 2_000; - while (Date.now() < secondDeadline) { - try { process.kill(pid, 0); } catch { return true; } - spawnSync("sleep", ["0.2"]); - } - return false; -} - -// ========================================================================================= -// 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; @@ -691,59 +115,14 @@ function summarizeActionResult(result: unknown): string | undefined { return parts.length ? parts.join("; ") : undefined; } -class RequestBodyTooLargeError extends Error { - constructor() { super("request body too large"); this.name = "RequestBodyTooLargeError"; } -} -function readRequestBody(request: IncomingMessage, maxBytes = MAX_REQUEST_BODY_BYTES): Promise<string> { +function readRequestBody(request: IncomingMessage): 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[] = []; - 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("data", (chunk) => chunks.push(Buffer.from(chunk))); 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 ?? ""); @@ -751,8 +130,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, ${AUTH_HEADER}`, - "access-control-expose-headers": `x-pi-chrome-version, ${AUTH_HEADER}`, + "access-control-allow-headers": "content-type", + "access-control-expose-headers": "x-pi-chrome-version", "vary": "origin", }; } @@ -785,23 +164,11 @@ 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}`; @@ -823,129 +190,33 @@ class ChromeProfileBridge { clientName: this.clientName, queuedCommands: this.queue.length, pendingCommands: this.pending.size, - // Version is broadcast in /status so peer Pi sessions can decide at session_start - // whether to join as client (compatible same major.minor) or to auto-takeover an - // older/wedged owner. - bridgeVersion: PI_CHROME_VERSION, }; } - // 0.17+ daemon model: pi-chrome no longer hosts the HTTP server in this Pi process. The - // bridge runs as a detached daemon (extensions/chrome-profile-bridge/daemon.cjs, staged at - // ~/.cache/pi-chrome/<version>/daemon.cjs) and Pi sessions are uniform clients that POST - // signed /command envelopes. `start()` ensures the daemon is running and up-to-date; - // `send()` continues to go through the same sendViaOwner path used by previous releases' - // client mode. async start(): Promise<void> { - if (this.mode === "client") return; - await this.ensureDaemonRunning(); - } - - private async ensureDaemonRunning(): Promise<void> { - const probed = probeBridgeStatus(this.host, this.port); - const ownerVersion = typeof (probed as { bridgeVersion?: string })?.bridgeVersion === "string" - ? (probed as { bridgeVersion: string }).bridgeVersion - : undefined; - const compatible = ownerVersion ? daemonVersionCompatible(ownerVersion, PI_CHROME_VERSION) : false; - if (probed && compatible) { - this.mode = "client"; - return; - } - if (probed && !compatible) { - // Incompatible owner; auto-takeover (kill + replace with our-version daemon). - await this.handleEaddrinuse(); - if (this.mode === "client") return; - // fall through to spawn - } - const pid = spawnDaemon(this.host, this.port); - const ready = waitForDaemonReady(this.host, this.port, 4_000); - if (!ready) { - console.warn(`pi-chrome: daemon spawn (pid ${pid}) did not become ready within 4s; check ${daemonLogPath()}`); - } - this.mode = "client"; - } - - // Auto-takeover when the existing owner is unresponsive or running an incompatible - // version. After kill, a fresh daemon is spawned by `ensureDaemonRunning`. - private async handleEaddrinuse(): Promise<void> { - const probed = probeBridgeStatus(this.host, this.port); - const ownerVersion = typeof probed?.bridgeVersion === "string" ? probed.bridgeVersion : undefined; - const compatible = ownerVersion ? daemonVersionCompatible(ownerVersion, PI_CHROME_VERSION) : false; - if (probed && compatible) { - this.mode = "client"; - return; - } - // Either no response at all, or the owner is on an incompatible (older or future) - // version that won't honor the same wire protocol. Try to take over. - const pid = findPortOwnerPid(this.port); - const reason = !probed - ? "unresponsive" - : ownerVersion - ? `version ${ownerVersion} incompatible with ${PI_CHROME_VERSION}` - : "unknown version"; - if (!pid || pid === process.pid) { - console.warn(`pi-chrome: bridge port :${this.port} held by ${reason} owner; cannot identify PID via lsof. Falling back to client mode (commands may fail).`); - this.mode = "client"; - return; - } - console.warn(`pi-chrome: bridge port :${this.port} held by PID ${pid} (${reason}); attempting takeover.`); - if (!killProcessGracefully(pid)) { - console.warn(`pi-chrome: failed to kill PID ${pid}; falling back to client mode.`); - this.mode = "client"; - return; - } - console.warn(`pi-chrome: killed PID ${pid}; will spawn the daemon next.`); - } - - // Probe the daemon's /status and return its view (which is the only source of truth for - // `connected` / `lastSeenAt` in 0.17+, since the extension polls the daemon, not Pi). - async daemonStatus(timeoutMs = 1_000): Promise<Record<string, unknown> | undefined> { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + if (this.server || this.mode === "client") return; + this.server = createServer((request, response) => { + void this.handle(request, response).catch((error) => { + sendJson(response, 500, { error: (error as Error).message }); + }); + }); try { - const res = await fetch(`${this.url}/status`, { signal: controller.signal }); - if (!res.ok) return undefined; - return (await res.json()) as Record<string, unknown>; - } catch { - return undefined; - } finally { - clearTimeout(timer); - } - } - - // Signed admin RPC to the daemon. Used for state mutations the daemon owns: arm-pair-window, - // reset, status. Pre-pair (when the daemon has no state file), unauthenticated; once paired, - // the daemon requires the broker-key envelope (same signature as /command). - async admin(op: string, body: Record<string, unknown> = {}): Promise<Record<string, unknown>> { - const payload = JSON.stringify({ op, ...body }); - const headers: Record<string, string> = { "content-type": "application/json" }; - if (this.auth.paired) { - const sig = this.auth.signPeerRequest("POST", "/admin", payload); - if (sig) headers[AUTH_HEADER] = sig; - } - const doFetch = () => fetch(`${this.url}/admin`, { method: "POST", headers, body: payload }); - let response: Response; - try { response = await doFetch(); } - catch (error) { - // Daemon isn't reachable. Self-heal by re-running the spawn path, then retry once. - // Covers the common case where the daemon idle-shut-down since the Pi session started - // (or was killed manually) but the Pi-side bridge still thinks mode === "client". - console.warn(`pi-chrome: /admin fetch failed (${(error as Error).message}); re-spawning daemon and retrying`); - await this.ensureDaemonRunning(); - response = await doFetch(); - } - const respText = await response.text(); - // Verify owner sig when present (paired state). For pre-pair calls, no sig to verify. - if (this.auth.paired) { - const respAuth = response.headers.get(AUTH_HEADER) ?? undefined; - if (!this.auth.verifyOwnerResponse("POST", "/admin", respAuth, respText)) { - throw new Error("daemon /admin response signature invalid"); - } + await new Promise<void>((resolveStart, rejectStart) => { + this.server!.once("error", rejectStart); + this.server!.listen(this.port, this.host, () => { + this.server!.off("error", rejectStart); + resolveStart(); + }); + }); + this.mode = "server"; + } catch (error) { + this.server.close(); + this.server = undefined; + if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error; + // Another Pi session already owns the bridge port. Use it as the shared + // machine-local broker so multiple Pi sessions can control Chrome at once. + this.mode = "client"; } - let parsed: Record<string, unknown>; - try { parsed = JSON.parse(respText); } catch { throw new Error(`daemon /admin returned non-JSON: ${respText.slice(0, 200)}`); } - if (!response.ok || parsed.ok === false) throw new Error(String(parsed.error ?? `daemon /admin HTTP ${response.status}`)); - return parsed; } stop(): void { @@ -953,7 +224,6 @@ 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")); @@ -968,15 +238,12 @@ class ChromeProfileBridge { } send(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> { - // 0.17+: all sends go through the daemon over signed /command (sendViaOwner). The - // previous in-process queue path (sendLocal) is dead code retained only to support - // the legacy ChromeProfileBridge.handle() HTTP server below, which is itself dead in - // 0.17 (the daemon owns the listener). - return this.sendViaOwner(action, params, timeoutMs, signal); + if (this.mode === "client") return this.sendViaOwner(action, params, timeoutMs, signal); + return this.sendLocal(action, params, timeoutMs, signal); } private sendLocal(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> { - const id = randomUUID(); + const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; const command = { id, action, params }; return new Promise((resolveCommand, rejectCommand) => { if (signal?.aborted) { @@ -1003,12 +270,6 @@ 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); }, @@ -1021,16 +282,6 @@ class ChromeProfileBridge { } private async sendViaOwner(action: string, params: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<unknown> { - // Self-heal: re-spawn the daemon if it's gone. Cheap when daemon is up (a no-op since - // the version probe inside ensureDaemonRunning returns compatible immediately). - try { - const probed = probeBridgeStatus(this.host, this.port); - if (!probed) await this.ensureDaemonRunning(); - } catch { /* fall through to the fetch — will surface a clearer error if anything is - actually broken. */ } - 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(); @@ -1039,33 +290,18 @@ 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", [AUTH_HEADER]: sigHeader }, - body, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action, params, timeoutMs }), signal: controller.signal, }); - 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. + const payload = (await response.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; error?: string }; 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) { @@ -1080,45 +316,15 @@ class ChromeProfileBridge { } } - - private enqueue(command: BridgeCommand): void { const waiter = this.waiters.shift(); - 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); + if (waiter) waiter(command); + else 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" }); @@ -1131,78 +337,25 @@ 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; } - 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") { + const body = JSON.parse(await readRequestBody(request)) as { + action?: string; + params?: Record<string, unknown>; + timeoutMs?: number; + }; + if (!body.action) { 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 ?? {}, 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); + const result = await this.sendLocal(body.action, body.params ?? {}, body.timeoutMs ?? DEFAULT_TIMEOUT_MS); + sendJson(response, 200, { ok: true, result }); } catch (error) { - 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); + sendJson(response, 504, { ok: false, error: (error as Error).message }); } return; } @@ -1211,46 +364,6 @@ 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; @@ -1266,22 +379,21 @@ 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; } - 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); + // 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 }, + ); return; } if (request.method === "POST" && url.pathname === "/result") { @@ -1289,35 +401,8 @@ 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(); - // 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 result = JSON.parse(await readRequestBody(request)) as BridgeResult; const pending = this.pending.get(result.id); if (!pending) { sendJson(response, 404, { ok: false, error: "unknown command id" }, corsHeaders); @@ -1401,16 +486,6 @@ 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); @@ -1432,11 +507,14 @@ export default function (pi: ExtensionAPI): void { pi.on("session_start", async (_event, ctx) => { await bridge.start(); - // 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); + 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", + ); }); pi.on("session_shutdown", () => { @@ -1458,7 +536,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 or the bridge is not paired, ask the user to run \`/chrome pair\` (one-time) and \`/chrome authorize\` before retrying. +1. If a chrome_* tool says Chrome control is locked, ask the user to run \`/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. @@ -1475,53 +553,37 @@ Usage rules: ctx.ui.notify("Checking pi-chrome…", "info"); const lines: string[] = [`pi-chrome v${PI_CHROME_VERSION}`]; const status = bridge.status(); - lines.push(`• This pi session talks to the pi-chrome daemon (every session is a peer in 0.17+).`); - 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."); - } + 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}.`); let extensionAlive = false; let versionMismatch = false; - // Short-circuit when the bridge can't possibly round-trip: unpaired (commands never - // delivered) or no recent /next poll (extension absent/asleep). Otherwise we'd block - // the doctor command for the full 35s waiting for a response that will never come. - if (!bridge.bridgeAuth.paired) { - lines.push("✗ Skipping extension probe — bridge isn't paired yet. Run /chrome onboard."); - } else if (!(await bridge.daemonStatus(1_500))?.connected) { - lines.push( - "✗ Chrome extension hasn't polled the bridge recently.", - " Fix: load the extension via /chrome onboard, keep that Chrome window open, then re-run /chrome doctor.", - ); - } else { - try { - const started = Date.now(); - const version = (await bridge.send("tab.version", {}, 6_000)) as { - extensionId?: string; - extensionVersion?: string; - bridgeUrl?: string; - }; - const latencyMs = Date.now() - started; - extensionAlive = true; - if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) { - versionMismatch = true; - lines.push( - `✗ The Chrome companion extension is on an old version (${version.extensionVersion}); this pi-chrome is ${PI_CHROME_VERSION}.`, - ` Every Chrome action will run with the old code until you reload the extension.`, - ` Fix: open chrome://extensions and click the refresh icon on 'Pi Chrome Connector'.`, - ` (After this one-time fix, future updates reload automatically.)`, - ); - } else { - lines.push(`✓ Chrome is connected (companion extension v${version.extensionVersion ?? "?"}, responded in ${latencyMs}ms).`); - } - } catch (error) { - const message = (error as Error).message; - lines.push(`✗ Chrome isn't responding: ${message}`); - if (message.includes("older pi-chrome without multi-session")) { - lines.push(" Fix: quit and restart the pi session that first opened the Chrome connection (it was on an older pi-chrome)."); - } else { - lines.push(" Fix: re-run /chrome onboard, or reload 'Pi Chrome Connector' at chrome://extensions and keep that Chrome window open."); - } + try { + const started = Date.now(); + const version = (await bridge.send("tab.version", {}, 35_000)) as { + extensionId?: string; + extensionVersion?: string; + bridgeUrl?: string; + }; + const latencyMs = Date.now() - started; + extensionAlive = true; + if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) { + versionMismatch = true; + lines.push( + `✗ The Chrome companion extension is on an old version (${version.extensionVersion}); this pi-chrome is ${PI_CHROME_VERSION}.`, + ` Every Chrome action will run with the old code until you reload the extension.`, + ` Fix: open chrome://extensions and click the refresh icon on 'Pi Chrome Connector'.`, + ` (After this one-time fix, future updates reload automatically.)`, + ); + } else { + lines.push(`✓ Chrome is connected (companion extension v${version.extensionVersion ?? "?"}, responded in ${latencyMs}ms).`); + } + } catch (error) { + const message = (error as Error).message; + lines.push(`✗ Chrome isn't responding: ${message}`); + if (message.includes("older pi-chrome without multi-session")) { + lines.push(" Fix: quit and restart the pi session that first opened the Chrome connection (it was on an older pi-chrome)."); + } else { + lines.push(" Fix: run /chrome onboard to install the Chrome companion extension, then keep that Chrome window open."); } } @@ -1588,7 +650,6 @@ 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 => { @@ -1610,293 +671,47 @@ 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. The daemon owns the - // pair-window state; Pi just RPCs in. - const pairHandler = async (ctx: ExtensionContext) => { - let replace = false; - if (bridge.bridgeAuth.paired) { - const ok = 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 (!ok) { ctx.ui.notify("Pairing cancelled.", "info"); return; } - replace = true; - } - let invite: string; - try { - const resp = await bridge.admin("arm-pair-window", { replace }); - invite = String(resp.invite); - } catch (error) { - ctx.ui.notify(`Failed to arm pair window: ${(error as Error).message}`, "warning"); - return; - } - 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) => { - 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; } - try { - await bridge.admin("reset"); - ctx.ui.notify("pi-chrome unpaired. Run /chrome pair to start a new pairing.", "info"); - } catch (error) { - ctx.ui.notify(`Failed to unpair: ${(error as Error).message}`, "warning"); - } - }; - - 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` (aliased as `/chrome install`) is the idempotent recovery path. It can - // be re-run any number of times to bring a broken machine back to a known-good state, - // regardless of what older versions of pi-chrome left behind: - // - // * It always resets the Pi-side pairing record before issuing a new invite, so a stale - // `~/.config/pi/chrome-bridge.json` (e.g. paired against an old extension instance whose - // keys were never persisted in Chrome's storage) cannot silently keep the bridge in a - // half-paired state. - // * It opens chrome://extensions + the extension folder and tells the user to either Load - // unpacked (first install) OR click the reload icon on the existing card (covers the - // v0.15.x → 0.16.x migration where the SW must be re-loaded to gain the /pair logic). - // * It then **actively verifies** progress at each step rather than trusting confirm() - // clicks: it polls `bridge.status().connected` to confirm the extension is polling - // `/next`, polls `bridge.bridgeAuth.paired` to confirm the popup successfully completed - // the /pair handshake, and finally round-trips a real `tab.version` call to confirm the - // end-to-end signed-envelope path works. - // - // If any of those steps fail, it prints a concrete fix and stops; rerunning is always safe. const onboardHandler = async (ctx: ExtensionContext) => { - // Note: we deliberately do NOT early-return on client mode here. Onboarding is precisely - // the recovery path for the case where a foreign Pi process owns the bridge — the Step 0 - // foreign-owner takeover below will kill that process and re-bind locally. const extensionPath = browserExtensionPath(); - const proceed = await ctx.ui.confirm( - "pi-chrome onboard", - [ - "This will set up Chrome control from scratch:", - " 1. Reset any stale pairing state on the Pi side.", - " 2. Walk you through (re-)loading the Chrome extension.", - " 3. Mint a fresh pairing invite and wait for you to paste it into the popup.", - " 4. Verify a real Chrome round-trip works.", - "", - "Safe to re-run any time; nothing else changes.", - "", - "Press Enter to continue, or Esc to cancel.", - ].join("\n"), + "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.`, ); if (!proceed) { - ctx.ui.notify("Cancelled. Run /chrome onboard again whenever you're ready.", "info"); + ctx.ui.notify("Cancelled. You can run /chrome onboard again whenever you're ready.", "info"); return; } - - // --- Step 1: reset daemon-side pairing state. ------------------------------------ - // In 0.17 the daemon is automatically spawned/upgraded at session_start (see - // ChromeProfileBridge.ensureDaemonRunning), so /chrome onboard no longer needs to - // detect-and-kill stale owners. We just admin-reset the pairing record and proceed. - // Daemon clears `~/.config/pi/chrome-bridge.json` on its side. Any cached extension keys - // in chrome.storage.local will become invalid, but that's fine because we're about to - // mint a fresh invite and re-pair. fs.watch + refreshIfStale on the daemon's BridgeAuth - // also propagates the reset to in-memory state immediately. - const hadPriorPairing = bridge.bridgeAuth.paired; - try { await bridge.admin("reset"); } catch (error) { - ctx.ui.notify(`Failed to reset prior pairing: ${(error as Error).message}`, "warning"); - return; - } - if (hadPriorPairing) { - ctx.ui.notify( - "Cleared the previous pairing record. The extension popup may still show 'Paired' until it polls again; that's fine — we'll re-pair below.", - "info", - ); - } - - // --- Step 2: install or reload the Chrome extension. ----------------------------- 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); } - await copyToClipboard(ctx, extensionPath); ctx.ui.notify( - [ - "Step 1 of 3 — install or reload the Chrome extension", - "", - "Open chrome://extensions. Two cases:", - "", - " A. If 'Pi Chrome Connector' is NOT listed:", - " • Toggle Developer mode (top-right).", - " • Click 'Load unpacked'.", - ` • Pick the folder that just opened in Finder, or paste this path (it's on your clipboard):`, - ` ${extensionPath}`, - "", - " B. If 'Pi Chrome Connector' IS listed:", - " • Click the refresh (↻) icon on its card so the service worker reloads with the current code.", - "", - "Press Enter once the extension card shows 'Pi Chrome Connector' (recent load time).", - ].join("\n"), - "info", - ); - const ready = await ctx.ui.confirm( - "Extension loaded / reloaded?", - "Confirm once Pi Chrome Connector is listed and active. Press Esc to abort.", - ); - if (!ready) { - ctx.ui.notify("Cancelled before pairing. Re-run /chrome onboard whenever ready.", "info"); - return; - } - - // --- Step 2.5: wait for the SW to start polling /next. --------------------------- - // Without this gate, a paused service worker (or an extension that didn't actually - // reload) lets the user proceed to step 3 only to have pairing silently fail. Active - // poll = lastSeenAt updates = `bridge.status().connected` flips true. - ctx.ui.notify("Waiting for the extension to wake up and poll the bridge…", "info"); - const connectStart = Date.now(); - let connected = false; - while (Date.now() - connectStart < 15_000) { - const ds = await bridge.daemonStatus(1_500); - if (ds && ds.connected === true) { connected = true; break; } - await new Promise((r) => setTimeout(r, 500)); - } - if (!connected) { - ctx.ui.notify( - [ - "✗ The Chrome extension didn't poll the bridge within 15 s.", - " Common causes:", - " • Service worker is suspended. Open chrome://extensions → 'service worker' link on the Pi Chrome Connector card to wake it.", - " • The extension didn't load (folder permission, wrong path). Try Load unpacked again with the path on your clipboard.", - " • The extension is disabled. Toggle it on.", - "", - " Fix and re-run /chrome onboard — it's idempotent.", - ].join("\n"), - "warning", - ); - return; - } - ctx.ui.notify("✓ Extension is polling.", "info"); - - // --- Step 3: mint invite, wait for the popup to complete the /pair handshake. ---- - let invite: string; - try { - const resp = await bridge.admin("arm-pair-window", { replace: true }); - invite = String(resp.invite); - } catch (error) { - ctx.ui.notify(`Failed to arm pair window: ${(error as Error).message}`, "warning"); - return; - } - await copyToClipboard(ctx, invite); - ctx.ui.notify( - [ - "Step 2 of 3 — pair the extension with this bridge", - "", - " 1. Click the Pi Chrome Connector icon in Chrome's toolbar (puzzle-piece menu, pin it if needed).", - " 2. Paste the invite below into the popup and click 'Pair'.", - "", - ` Invite (also on your clipboard): ${invite}`, - "", - ` Window: ${Math.round(PAIR_WINDOW_MS / 60_000)} min. Pi will detect the pair automatically; you don't need to press anything here.`, - ].join("\n"), - "info", - ); - const pairStart = Date.now(); - while (Date.now() - pairStart < PAIR_WINDOW_MS) { - if (bridge.bridgeAuth.paired) break; - await new Promise((r) => setTimeout(r, 500)); - } - if (!bridge.bridgeAuth.paired) { - ctx.ui.notify("✗ Pairing window expired with no /pair from the extension. Re-run /chrome onboard.", "warning"); - return; - } - ctx.ui.notify("✓ Bridge paired with the extension.", "info"); - - // --- Step 4: verify a real round-trip. ------------------------------------------- - try { - const version = (await bridge.send("tab.version", {}, 5_000)) as { extensionVersion?: string }; - ctx.ui.notify(`✓ Step 3 of 3 — Chrome round-trip OK (extension v${version.extensionVersion ?? "?"}).`, "info"); - } catch (error) { - ctx.ui.notify( - [ - `⚠ Pairing succeeded but a test round-trip failed: ${(error as Error).message}`, - " Possible causes:", - " • Chrome was switched to a chrome:// or chrome-extension:// tab; pi-chrome can't run there. Switch to a normal http(s) tab and run /chrome doctor.", - " • Service worker went back to sleep between pair and probe. Click the extension icon once and re-run /chrome doctor.", - ].join("\n"), - "warning", - ); - return; - } - - ctx.ui.notify( - "All set. Run /chrome authorize 15m (or longer) before using chrome_* tools.", + "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.", "info", ); }; - // `probeExtension` short-circuits before issuing a bridge.send when we already know the - // command can't round-trip, so /chrome status / doctor / onboard don't block for the full - // timeout. Returns a single line summarising the result. - // - // Order of checks: - // 1. Bridge unpaired — no command will ever be delivered (/next returns idle), so don't - // bother sending. Tell the user to run /chrome onboard. - // 2. Extension hasn't polled /next recently — the bridge has no live connection. Skip the - // send and report disconnected immediately. - // 3. Otherwise probe with a short timeout (paired + recent /next means a round-trip - // should complete in <1s; 3s is plenty). - const probeExtension = async (timeoutMs = 3_000): Promise<string> => { - if (!bridge.bridgeAuth.paired) { - return "✗ Chrome bridge not paired (run /chrome onboard)"; - } - const ds = await bridge.daemonStatus(1_000); - if (!ds?.connected) { - return "✗ Chrome extension not polling (load extension via /chrome onboard, keep Chrome open)"; - } + // One-line snapshot of pi-chrome's current state. Used as a header in the bare-/chrome + // picker and as the body of /chrome status. + const statusSummary = async (): Promise<string> => { + const parts: string[] = []; try { - const version = (await bridge.send("tab.version", {}, timeoutMs)) as { extensionVersion?: string }; + const version = (await bridge.send("tab.version", {}, 5_000)) as { extensionVersion?: string }; if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) { - return `⚠ Chrome extension v${version.extensionVersion} (pi-chrome v${PI_CHROME_VERSION}, reload extension)`; + parts.push(`⚠ Chrome extension v${version.extensionVersion} (pi-chrome v${PI_CHROME_VERSION}, reload extension)`); + } else { + parts.push(`✓ Chrome connected`); } - return `✓ Chrome connected`; } catch { - return `✗ Chrome not responding within ${timeoutMs}ms`; + parts.push(`✗ Chrome not responding`); } - }; - - // One-line snapshot of pi-chrome's current state. Used as a header in the bare-/chrome - // picker and as the body of /chrome status. - const statusSummary = async (): Promise<string> => { - return [ - await probeExtension(2_000), - `auth: ${authSummary()}`, - `background: ${backgroundDefault ? "on" : "off"}`, - ].join(" · "); + parts.push(`auth: ${authSummary()}`); + parts.push(`background: ${backgroundDefault ? "on" : "off"}`); + return parts.join(" · "); }; const statusHandler = async (ctx: ExtensionContext) => { @@ -1961,7 +776,7 @@ Usage rules: pi.registerCommand("chrome", { description: - "All pi-chrome controls in one place.\n\n First-time setup (do these once, in this order):\n /chrome onboard — idempotent: install/reload the Chrome extension AND pair it with this Pi bridge. Safe to re-run any time — it'll bring a broken machine back to a known-good state.\n /chrome install — alias for /chrome onboard.\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.", + "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.", getArgumentCompletions: (prefix) => { const raw = prefix; const trimmedRight = raw.replace(/\s+$/, ""); @@ -1978,14 +793,11 @@ Usage rules: let candidates: Item[] = []; if (path.length === 0) { candidates = [ - { fullValue: "onboard", label: "onboard", description: "Idempotent recovery flow: install/reload extension + (re-)pair + verify round-trip. Safe to re-run anytime." }, - { fullValue: "install", label: "install", description: "Alias for /chrome onboard." }, - { fullValue: "authorize", label: "authorize", description: "Unlock chrome_* tools for this Pi session." }, - { fullValue: "revoke", label: "revoke", description: "Lock chrome_* tools again." }, + { 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: "status", label: "status", description: "One-line summary: connection, auth, and background setting." }, - { 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: "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: "background", label: "background", description: "Run pi-chrome in the background without focusing Chrome?" }, ]; } else if (path[0] === "authorize" && path.length === 1) { @@ -2016,9 +828,6 @@ Usage rules: const [head, ...rest] = tokens; const subArgs = rest.join(" "); switch (head) { - case "pair": return pairHandler(ctx); - case "unpair": return unpairHandler(ctx); - case "install": return onboardHandler(ctx); case "authorize": return authorizeHandler(ctx, subArgs); case "revoke": return revokeHandler(ctx); case "status": return statusHandler(ctx); |
