aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-05-16 12:46:57 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-05-16 12:46:57 -0400
commit2ac665ec31fe156b372540e8ac38aa42765b3001 (patch)
treee21d16dd67e5f3d518a21cf6e7967fba964989e0
parentd9f2d027bda8cd2e2c42fa54a843c477fa18db05 (diff)
0.17.0: standalone daemon, no more owner-vs-client topology
The Chrome bridge is now a separate detached daemon at ~/.cache/pi-chrome/<version>/daemon.cjs. Every Pi session is a uniform client; daemon lifecycle is decoupled from any Pi session. Killing a Pi never breaks Chrome control, and pi update followed by any new Pi session auto-replaces an incompatible daemon. Architecture: - New extensions/chrome-profile-bridge/daemon.cjs: ~700 LOC, zero deps, runs detached. Owns the HTTP listener, BridgeAuth state, command queue, /pair handshake. Idle-shuts-down after 10 min of no activity. - ChromeProfileBridge on Pi side becomes a pure client: start() probes /status, spawns daemon if missing/incompatible, kills+respawns if version mismatch. send() always uses signed /command. No more server-mode branch. - New signed /admin RPC for pair-window/reset/status state mutations the daemon owns. Pi clients sign with broker key (same contract as /command). - New /heartbeat for daemon idle-timer keepalive. - daemon.log at ~/.cache/pi-chrome/<version>/daemon.log. /chrome pair, /chrome unpair, /chrome onboard now go through /admin RPCs instead of mutating in-process bridge state. Removed the foreign-owner takeover dance from /chrome onboard (auto-handled at session_start now). Wire protocol is byte-identical to 0.16.x; existing paired extensions don't need to re-pair. See docs/DESIGN-0.17-daemon.md for the full write-up.
-rw-r--r--CHANGELOG.md16
-rw-r--r--docs/DESIGN-0.17-daemon.md106
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/manifest.json2
-rw-r--r--extensions/chrome-profile-bridge/daemon.cjs696
-rw-r--r--extensions/chrome-profile-bridge/index.ts327
-rw-r--r--package.json2
6 files changed, 984 insertions, 165 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab7cf60..99af471 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
All notable user-facing changes to `pi-chrome`.
+## 0.17.0 — 2026-05-16
+
+### Standalone daemon (no more owner-vs-client topology)
+
+The Chrome bridge is now a separate, detached daemon process at `~/.cache/pi-chrome/<version>/daemon.cjs`. Every Pi session is a uniform client; the daemon's lifecycle is decoupled from any Pi session. Killing a Pi session never breaks Chrome control for other sessions, and `pi update` followed by starting any Pi session in a working directory automatically replaces an incompatible daemon with the new-version one.
+
+- **`ChromeProfileBridge` is now a pure client.** No more `mode: "server"` branch on Pi side. `start()` ensures the daemon is running (probes `/status`, spawns from the versioned cache dir, kills+respawns an incompatible owner). `send()` always goes through signed `/command`.
+- **Versioned daemon path.** Each pi-chrome version ships its own `daemon.cjs` and copies it to `~/.cache/pi-chrome/<version>/` on first use. Old versioned dirs coexist harmlessly; deleted by the daemon on a future release.
+- **New signed `/admin` RPC.** Pi clients call `arm-pair-window`, `reset`, and `status` on the daemon over a signed peer→owner envelope (same broker-key contract as `/command`). Pre-pairing, the first `/admin` call is allowed unauthenticated; once paired, the daemon requires the broker-key envelope. `/chrome pair`, `/chrome unpair`, and `/chrome onboard` go through `/admin` instead of mutating state in-process.
+- **`/heartbeat` endpoint** lets Pi clients keep the daemon's idle-shutdown timer alive between long gaps of chrome_* tool activity.
+- **Daemon log** at `~/.cache/pi-chrome/<version>/daemon.log`. Use to debug spawn failures.
+- **Idle shutdown.** Daemon exits after 10 minutes of no activity (no extension poll, no command). Next Pi session spawns it again.
+- **Backward compatibility.** Wire protocol is byte-identical to 0.16.x; existing paired extensions don't need to re-pair. A 0.16.x bridge running at session_start is auto-replaced.
+
+See [docs/DESIGN-0.17-daemon.md](./docs/DESIGN-0.17-daemon.md) for the architecture write-up.
+
## 0.16.1 — 2026-05-16
### Automatic stale-owner takeover at session_start
diff --git a/docs/DESIGN-0.17-daemon.md b/docs/DESIGN-0.17-daemon.md
new file mode 100644
index 0000000..2e7f115
--- /dev/null
+++ b/docs/DESIGN-0.17-daemon.md
@@ -0,0 +1,106 @@
+# pi-chrome 0.17 — standalone versioned daemon
+
+**Status:** design draft. Implementing in this same release branch.
+
+## Goals
+
+1. **Decouple the bridge from any Pi session lifecycle.** Killing a Pi session must never break Chrome control for other sessions.
+2. **Eliminate the "owner vs client" topology.** Every Pi session should be a uniform client.
+3. **Make upgrades self-healing.** When `pi update` lands a new pi-chrome version, the next session that starts must Just Work without any manual `kill` or `/chrome onboard` ritual.
+4. **No new runtime deps.** Keep the zero-deps promise.
+
+## Non-goals (deferred to 0.18)
+
+- Transport change. Stays HTTP + long-poll for 0.17. WebSocket lands separately on top of the daemon.
+- Native messaging. Not pursued; see SECURITY.md threat model rationale.
+
+## Architecture
+
+```
+ ┌─────────────┐ ┌────────────────────────────┐
+ │ Chrome ext │ HTTPS │ pi-chrome daemon │ ─────┐
+ │ (SW polls ├────────►│ ~/.cache/pi-chrome/ │ │
+ │ /next) │ │ v0.17.0/daemon.cjs │ │ stat()
+ └─────────────┘ │ Listens :17318 │ │ on each
+ │ Owns BridgeAuth + state │ │ accept
+ └─────────────┬──────────────┘ │
+ │ │
+ signed /command (HTTP) │
+ │ │
+ ┌─────────────┴──────────────┐ │
+ │ Pi session N (any version) │ ──────┘
+ │ extension/index.ts as │
+ │ daemon supervisor + client │
+ └────────────────────────────┘
+```
+
+### Lifecycle
+
+- **First Pi session in a pi-chrome version family** spawns the daemon if no compatible one is running. Spawn is detached (`spawn(node, [daemon.cjs], { detached: true, stdio: 'ignore' })`), the parent Pi doesn't own the daemon's process group, and `unref()`'s it so Pi can exit independently.
+- **Daemon stays alive** as long as it has work to do (active extension poll, or pending peer connections). After `IDLE_SHUTDOWN_MS` (default: 10 min) of zero activity, daemon exits cleanly.
+- **Subsequent Pi sessions** at `session_start` probe `/status`:
+ - Healthy + compatible version → use as-is.
+ - Unresponsive or incompatible version → kill it, spawn the right-version daemon.
+- **`pi update`** changes the on-disk pi-chrome version. Next Pi session sees mismatch, kills old daemon, spawns new. **No in-process hot reload** — the old daemon's in-flight commands are allowed to drain (or time out normally) and the next request to the new daemon retries fresh.
+
+### Versioned daemon path
+
+Daemon is copied to `~/.cache/pi-chrome/<version>/daemon.cjs` on Pi session_start (idempotent, only writes if absent or shasum mismatches). Pi spawns the daemon from that path, never directly from the npm install location:
+
+- Survives `nvm use` switches.
+- Multiple pi-chrome versions can coexist on disk (the active daemon corresponds to the most recent Pi session that started). Old version dirs are garbage-collected when the daemon detects them.
+- The daemon executable is a self-contained CommonJS bundle (built at publish time, or generated by a postinstall hook). No `require()` from anywhere except `node:` builtins.
+
+### Why not ESM?
+
+CommonJS keeps the daemon a single file with no entry-point ceremony, no `package.json` next to it, no resolver surprises across Node versions. The cost is irrelevant — the daemon has no third-party deps.
+
+## Protocol (unchanged from 0.16.1)
+
+The HTTP endpoints and signed-envelope contract are byte-identical to 0.16.1. The daemon is structurally a refactor of the existing `ChromeProfileBridge` class:
+
+- `/status` (now adds `daemonPid`, `daemonStartedAt`)
+- `/next` (long-poll, signed `bridge->ext` response)
+- `/result` (signed `ext->bridge` body)
+- `/command` (signed `peer->owner` / `owner->peer`)
+- `/pair` (invite window, signed response with invite secret)
+
+The only addition is a new `/heartbeat` endpoint (cheap GET, returns `{ok:true,ts}`) used by Pi clients to keep the daemon's idle timer alive and to detect daemon health without consuming a full command round-trip.
+
+### Configuration file unchanged
+
+`~/.config/pi/chrome-bridge.json` keeps the same shape (`{protocol, bridgeId, extensionId, extensionPairKey, brokerKey}`). The daemon owns the read/write of that file; Pi clients only consult it to derive `brokerKey` for signing peer→owner requests.
+
+The cross-process `fs.watch` + `refreshIfStale` machinery becomes daemon-local — `chrome-bridge.json` is now exclusively the daemon's data file. Pi clients receive pairing state via `/status` instead of reading the file directly.
+
+## Extension changes
+
+**None.** The extension still polls `127.0.0.1:17318`. From its perspective the only thing that changed is "the process behind that port restarts itself occasionally during pi-chrome upgrades."
+
+The extension's existing version-mismatch reload path (`x-pi-chrome-version` header → `chrome.runtime.reload()`) handles its own upgrade story independently.
+
+## Pi-side client surface
+
+`extensions/chrome-profile-bridge/index.ts` is split into:
+
+1. **`bridge-client.ts`** — Pi-side talker. `BridgeClient.send(action, params, timeoutMs)` signs and posts to `/command` of the local daemon, verifies the response signature, returns the result. No long-poll loop, no HTTP server. ~200 LOC.
+2. **`daemon-supervisor.ts`** — at `session_start`: ensures daemon is running, spawns if not, registers a graceful keepalive. ~100 LOC.
+3. **`daemon.cjs`** (built artifact) — the bridge process. Internally identical to today's `ChromeProfileBridge` minus the "client mode" branch. Single self-contained file shipped in npm tarball + copied to versioned cache dir on first use.
+4. **`index.ts`** — pi-extension entry. Imports `bridge-client` and `daemon-supervisor`, registers the chrome_* tools, wires `/chrome` commands. ~500 LOC (everything else moved out).
+
+## Open questions
+
+1. **Daemon log location.** Default: `~/.cache/pi-chrome/<version>/daemon.log` (rotated at 1 MiB). Exposed via `/chrome doctor --logs`.
+2. **What happens during a takeover with in-flight Pi commands?** Old daemon dies → its in-flight `/command` returns a TCP RST to the Pi client → Pi client retries against the new daemon. Implement explicit retry-once on connection-refused/RST inside `BridgeClient.send` with a budget (5 s) before surfacing the error.
+3. **Pairing across daemon restarts.** `~/.config/pi/chrome-bridge.json` persists. New daemon `load()`s it, picks up the same `extensionPairKey`/`brokerKey`. Extension never has to re-pair across a daemon restart.
+4. **Concurrent daemon spawn race.** Two Pi sessions starting simultaneously could each try to spawn a daemon. Mitigation: spawn-then-bind, second one hits EADDRINUSE inside the daemon itself and exits cleanly; supervisor probes `/status` after spawn to confirm one is up.
+5. **Garbage-collecting old versioned dirs.** Daemon at startup deletes `~/.cache/pi-chrome/v*/` directories older than 30 days.
+
+## Migration
+
+0.16.1 → 0.17:
+- First Pi session on 0.17 detects an existing 0.16.x bridge on `:17318`, version-mismatches, kills it, spawns the 0.17 daemon. Same as the existing auto-takeover code in 0.16.1.
+- Extension picks up new bridge on next poll, sees same `expectedExtensionVersion`, no reload.
+- Pairing state in `~/.config/pi/chrome-bridge.json` is preserved across the transition.
+
+User-visible: nothing. `pi update` → next Pi session → done.
diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json
index 94a79b9..dd7d19a 100644
--- a/extensions/chrome-profile-bridge/browser-extension/manifest.json
+++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Pi Chrome Connector",
- "version": "0.16.1",
+ "version": "0.17.0",
"description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
"permissions": [
"tabs",
diff --git a/extensions/chrome-profile-bridge/daemon.cjs b/extensions/chrome-profile-bridge/daemon.cjs
new file mode 100644
index 0000000..676344f
--- /dev/null
+++ b/extensions/chrome-profile-bridge/daemon.cjs
@@ -0,0 +1,696 @@
+#!/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;
+ 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; }
+ lastSeenAt = Date.now();
+ clientName = url.searchParams.get("name") ?? undefined;
+ 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 3e5c291..c20cf9a 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -4,7 +4,8 @@ import { existsSync, readFileSync, statSync, mkdirSync, writeFileSync, chmodSync
import { mkdir, writeFile, readFile, chmod } from "node:fs/promises";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
-import { spawnSync } from "node:child_process";
+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";
@@ -119,6 +120,72 @@ function sameMajorMinor(a: string, b: string): boolean {
return aMaj === bMaj && aMin === bMin;
}
+// 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"
+ && sameMajorMinor((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.
@@ -761,44 +828,48 @@ class ChromeProfileBridge {
};
}
+ // 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.server || this.mode === "client") return;
- await this.tryBind();
+ if (this.mode === "client") return;
+ await this.ensureDaemonRunning();
}
- private async tryBind(): Promise<void> {
- this.server = createServer((request, response) => {
- void this.handle(request, response).catch((error) => {
- sendJson(response, 500, { error: (error as Error).message });
- });
- });
- try {
- 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;
+ 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 ? sameMajorMinor(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. Without this, a stale 0.15.x Pi process holding :17318 would block every new
- // 0.16.x session from ever reaching the extension.
+ // 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 ? sameMajorMinor(ownerVersion, PI_CHROME_VERSION) : false;
if (probed && compatible) {
- // Healthy same-version owner. Share the bridge as a client; signed `/command` does
- // the rest.
this.mode = "client";
return;
}
@@ -821,17 +892,32 @@ class ChromeProfileBridge {
this.mode = "client";
return;
}
- // Port freed by the now-dead owner. Re-attempt the bind once. If it still fails (e.g.
- // a third process grabbed the port between kill and listen), give up to client mode.
- try {
- await this.tryBind();
- if (this.mode === "server") {
- console.warn(`pi-chrome: took over bridge port :${this.port} from PID ${pid}.`);
+ console.warn(`pi-chrome: killed PID ${pid}; will spawn the daemon next.`);
+ }
+
+ // 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 response = await fetch(`${this.url}/admin`, { method: "POST", headers, body: payload });
+ 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");
}
- } catch (error) {
- console.warn(`pi-chrome: re-bind after takeover failed (${(error as Error).message}); falling back to client mode.`);
- 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 {
@@ -854,8 +940,11 @@ class ChromeProfileBridge {
}
send(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> {
- if (this.mode === "client") return this.sendViaOwner(action, params, timeoutMs, signal);
- return this.sendLocal(action, params, timeoutMs, signal);
+ // 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);
}
private sendLocal(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> {
@@ -1351,8 +1440,7 @@ Usage rules:
ctx.ui.notify("Checking pi-chrome…", "info");
const lines: string[] = [`pi-chrome v${PI_CHROME_VERSION}`];
const status = bridge.status();
- const roleLabel = status.mode === "client" ? "sharing another pi session's connection" : "running the Chrome connection for this machine";
- lines.push(`• This pi session is ${roleLabel}.`);
+ 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 {
@@ -1493,24 +1581,26 @@ Usage rules:
// `/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.
+ // /chrome onboard which wraps this with installation guidance. The daemon owns the
+ // pair-window state; Pi just RPCs in.
const pairHandler = async (ctx: ExtensionContext) => {
- if (bridge.status().mode === "client") {
- ctx.ui.notify(
- "This Pi session is sharing another session's Chrome bridge. Run /chrome pair from the Pi session that owns the bridge (the first one started in this cwd).",
- "warning",
- );
- return;
- }
+ let replace = false;
if (bridge.bridgeAuth.paired) {
- const replace = await ctx.ui.confirm(
+ 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 (!replace) { ctx.ui.notify("Pairing cancelled.", "info"); return; }
- bridge.bridgeAuth.reset();
+ 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;
}
- const invite = bridge.bridgeAuth.startPairWindow();
await copyToClipboard(ctx, invite);
ctx.ui.notify(
[
@@ -1526,16 +1616,6 @@ Usage rules:
};
const unpairHandler = async (ctx: ExtensionContext) => {
- // Mirror of /chrome pair: only the bridge owner can authoritatively unpair. A peer that
- // rewrites the shared config file would leave the owner's in-memory keys intact, and
- // commands would keep flowing until the owner restarts.
- if (bridge.status().mode === "client") {
- ctx.ui.notify(
- "This Pi session is sharing another session's Chrome bridge. Run /chrome unpair from the owner Pi session (the first one started in this cwd).",
- "warning",
- );
- return;
- }
if (!bridge.bridgeAuth.paired) {
ctx.ui.notify("pi-chrome is not currently paired.", "info");
return;
@@ -1545,52 +1625,12 @@ Usage rules:
"This clears the stored extension ID + HMAC keys. Every Pi session will need to re-pair (and the Chrome extension popup will need to be re-armed).\n\nPress Enter to confirm, or Esc to cancel.",
);
if (!proceed) { ctx.ui.notify("Unpair cancelled.", "info"); return; }
- bridge.bridgeAuth.reset();
- ctx.ui.notify("pi-chrome unpaired. Run /chrome pair to start a new pairing.", "info");
- };
-
- // Locate the OS-level owner of the bridge listener via `lsof`. Used by /chrome onboard to
- // detect an older pi-chrome process still holding `:17318` that won't honor the new
- // pairing protocol. macOS + most Linux distros ship `lsof`; if it isn't available we
- // return undefined and the caller falls back to printing instructions for the user.
- const findBridgeOwnerPid = async (ctx: ExtensionContext): Promise<number | undefined> => {
- if (process.platform === "win32") return undefined;
try {
- const { stdout } = await pi.exec("sh", ["-lc", `lsof -nP -iTCP:${DEFAULT_PORT} -sTCP:LISTEN -t 2>/dev/null | head -1`], { cwd: workspaceCwd(ctx), timeout: 3_000 });
- const pid = Number(String(stdout).trim());
- return Number.isFinite(pid) && pid > 0 ? pid : undefined;
- } catch {
- return undefined;
- }
- };
-
- // Kill the foreign owner (TERM then KILL), wait for the port to actually free, then
- // take it over by stop()/start()ing the local bridge. Returns true on success.
- const takeOverBridgePort = async (ctx: ExtensionContext, ownerPid: number): Promise<boolean> => {
- await pi.exec("sh", ["-lc", `kill ${ownerPid} 2>/dev/null || true`], { cwd: workspaceCwd(ctx), timeout: 3_000 }).catch(() => undefined);
- // SIGTERM first; if the process hasn't released the port within ~3s, escalate.
- for (let i = 0; i < 12; i++) {
- const still = await findBridgeOwnerPid(ctx);
- if (!still || still !== ownerPid) break;
- await new Promise((r) => setTimeout(r, 250));
- }
- const stillAfterTerm = await findBridgeOwnerPid(ctx);
- if (stillAfterTerm === ownerPid) {
- await pi.exec("sh", ["-lc", `kill -9 ${ownerPid} 2>/dev/null || true`], { cwd: workspaceCwd(ctx), timeout: 3_000 }).catch(() => undefined);
- for (let i = 0; i < 12; i++) {
- const still = await findBridgeOwnerPid(ctx);
- if (!still || still !== ownerPid) break;
- await new Promise((r) => setTimeout(r, 250));
- }
+ 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 stillFinal = await findBridgeOwnerPid(ctx);
- if (stillFinal === ownerPid) return false;
- // Port should now be free (or a different process picked it up; either way our previous
- // owner is gone). Drop client-mode and re-bind as server. bridge.start() handles
- // EADDRINUSE again if some unrelated process grabbed the port between kill and listen.
- bridge.stop();
- await bridge.start();
- return bridge.status().mode === "server";
};
const copyToClipboard = async (ctx: ExtensionContext, text: string): Promise<void> => {
@@ -1644,65 +1684,19 @@ Usage rules:
return;
}
- // --- Step 0: deal with a foreign bridge owner. -----------------------------------
- // If another Pi process owns :17318, our local bridge is in client mode and forwards
- // every command through that owner. If the owner is on an older pi-chrome (no /pair,
- // no signed envelopes), the whole flow fails silently. Detect it and offer to kill
- // the foreign owner so this session can take over.
- const ownerPid = await findBridgeOwnerPid(ctx);
- if (bridge.status().mode === "client" && (!ownerPid || ownerPid === process.pid)) {
- // Fallback when `lsof` isn't available (e.g. minimal Linux container): we know we're
- // a client but can't locate the owner to kill it. Surface a clear instruction.
- ctx.ui.notify(
- [
- `This Pi session is in client mode (another Pi process owns :${DEFAULT_PORT}), but pi-chrome can't locate the owner via lsof on this platform.`,
- `Manually identify and kill it, then re-run /chrome onboard:`,
- ` lsof -nP -iTCP:${DEFAULT_PORT} -sTCP:LISTEN`,
- ` kill <pid>`,
- ].join("\n"),
- "warning",
- );
+ // --- 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 (ownerPid && ownerPid !== process.pid) {
- const kill = await ctx.ui.confirm(
- `Another Pi process owns the Chrome bridge (PID ${ownerPid})`,
- [
- `A different Pi process is currently listening on :${DEFAULT_PORT}. If it's running an older pi-chrome it doesn't know about pairing, and onboarding from this session won't reach the extension.`,
- ``,
- `pi-chrome will kill PID ${ownerPid} and have this Pi session take over the bridge.`,
- ``,
- `Press Enter to kill it, or Esc to abort.`,
- ].join("\n"),
- );
- if (!kill) {
- ctx.ui.notify("Cancelled. Re-run /chrome onboard when ready, or manually `kill` the older Pi process holding the port.", "info");
- return;
- }
- ctx.ui.notify(`Killing PID ${ownerPid} and taking over :${DEFAULT_PORT}…`, "info");
- const took = await takeOverBridgePort(ctx, ownerPid);
- if (!took) {
- ctx.ui.notify(
- [
- `✗ Could not take over the bridge from PID ${ownerPid}.`,
- ` Either the process didn't release the port, or another process grabbed it immediately.`,
- ` Inspect with: lsof -nP -iTCP:${DEFAULT_PORT} -sTCP:LISTEN`,
- ` Then re-run /chrome onboard.`,
- ].join("\n"),
- "warning",
- );
- return;
- }
- ctx.ui.notify(`✓ This Pi session now owns the Chrome bridge on :${DEFAULT_PORT}.`, "info");
- }
-
- // --- Step 1: reset Pi-side state. ------------------------------------------------
- // Always clears `~/.config/pi/chrome-bridge.json`. 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. The popup's auto-poll will surface 'unpaired'
- // immediately because of the fs.watch + refreshIfStale plumbing on the bridge.
- const hadPriorPairing = bridge.bridgeAuth.paired;
- bridge.bridgeAuth.reset();
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.",
@@ -1772,7 +1766,14 @@ Usage rules:
ctx.ui.notify("✓ Extension is polling.", "info");
// --- Step 3: mint invite, wait for the popup to complete the /pair handshake. ----
- const invite = bridge.bridgeAuth.startPairWindow();
+ 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(
[
diff --git a/package.json b/package.json
index 656f341..6b38254 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pi-chrome",
- "version": "0.16.1",
+ "version": "0.17.0",
"scripts": {
"version": "node scripts/sync-manifest-version.js",
"prepublishOnly": "node scripts/sync-manifest-version.js"