diff options
| author | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-16 12:46:57 -0400 |
|---|---|---|
| committer | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-16 12:46:57 -0400 |
| commit | 2ac665ec31fe156b372540e8ac38aa42765b3001 (patch) | |
| tree | e21d16dd67e5f3d518a21cf6e7967fba964989e0 | |
| parent | d9f2d027bda8cd2e2c42fa54a843c477fa18db05 (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.md | 16 | ||||
| -rw-r--r-- | docs/DESIGN-0.17-daemon.md | 106 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/manifest.json | 2 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/daemon.cjs | 696 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/index.ts | 327 | ||||
| -rw-r--r-- | package.json | 2 |
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" |
