diff options
| author | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-16 13:13:32 -0400 |
|---|---|---|
| committer | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-16 13:13:48 -0400 |
| commit | b2ca8f8c343281c0263bda4c043cbe7f0d7d442e (patch) | |
| tree | c37f274c112a202af86144fd6034a8a54d7933eb /extensions/chrome-profile-bridge/index.ts | |
| parent | ea567819d835d4704d9fb38cd114e0e5b231759e (diff) | |
Revert 0.16.x and 0.17.x: restore tree to v0.15.20 state
Reverts the pairing + signed-envelopes work (0.16.0-0.16.1), the
standalone daemon refactor (0.17.0-0.17.3), and assorted patches.
These were unpublished from npm; this commit restores the working
tree to match the v0.15.20 release so the branch tip and the npm
'latest' dist-tag are consistent again.
Tags v0.16.0 through v0.17.3 are preserved as recovery anchors;
the work is reachable via 'git checkout v0.17.3' if ever needed.
Tree-equivalent to v0.15.20.
Diffstat (limited to 'extensions/chrome-profile-bridge/index.ts')
| -rw-r--r-- | extensions/chrome-profile-bridge/index.ts | 1429 |
1 files changed, 119 insertions, 1310 deletions
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); |
