diff options
| author | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-16 13:13:32 -0400 |
|---|---|---|
| committer | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-16 13:13:48 -0400 |
| commit | b2ca8f8c343281c0263bda4c043cbe7f0d7d442e (patch) | |
| tree | c37f274c112a202af86144fd6034a8a54d7933eb | |
| parent | ea567819d835d4704d9fb38cd114e0e5b231759e (diff) | |
Revert 0.16.x and 0.17.x: restore tree to v0.15.20 state
Reverts the pairing + signed-envelopes work (0.16.0-0.16.1), the
standalone daemon refactor (0.17.0-0.17.3), and assorted patches.
These were unpublished from npm; this commit restores the working
tree to match the v0.15.20 release so the branch tip and the npm
'latest' dist-tag are consistent again.
Tags v0.16.0 through v0.17.3 are preserved as recovery anchors;
the work is reachable via 'git checkout v0.17.3' if ever needed.
Tree-equivalent to v0.15.20.
| -rw-r--r-- | CHANGELOG.md | 116 | ||||
| -rw-r--r-- | README.md | 42 | ||||
| -rw-r--r-- | SECURITY.md | 38 | ||||
| -rw-r--r-- | docs/DESIGN-0.17-daemon.md | 106 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/manifest.json | 6 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/popup.html | 42 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/popup.js | 46 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/service_worker.js | 474 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/daemon.cjs | 702 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/index.ts | 1429 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rwxr-xr-x | scripts/security-smoke.sh | 98 |
12 files changed, 152 insertions, 2949 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a9de5e..60db118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,122 +2,6 @@ All notable user-facing changes to `pi-chrome`. -## 0.17.3 — 2026-05-16 - -### Daemon self-heal - -0.17.x makes the Pi process a client of a separately-spawned daemon. If the daemon idle-shuts-down (after 10 min of inactivity) or is killed externally while a Pi session is running, the next chrome_* tool call or `/chrome onboard` would surface `fetch failed` instead of recovering. - -- **`bridge.admin()`** catches the failed fetch, re-runs `ensureDaemonRunning()` (which probes, kills incompatible, or spawns fresh), then retries the request once. -- **`bridge.sendViaOwner()`** pre-probes `/status`; if no daemon is reachable, it spawns one before issuing the signed `/command`. - -Net effect: a missing daemon transparently respawns on the next Pi-side request instead of bubbling `fetch failed` up to the user. - -## 0.17.2 — 2026-05-16 - -### Fixes for the daemon model surfaced during 0.17.0/0.17.1 testing - -- **Daemon: record `lastSeenAt` on *every* `/next` from a chrome-extension origin**, not only after the paired-auth checks. Pre-pairing the daemon returns `needsPairing: true` early; before this fix it never updated `lastSeenAt`, so the daemon's `connected` flag stayed `false` even when the extension was happily polling. `/chrome onboard` would loop forever on "Waiting for the extension to wake up…" even with a perfectly healthy extension. -- **Daemon compatibility is now checked at exact version match**, not just major.minor. Without this, a 0.17.x patch release that fixes a daemon bug couldn't actually replace the running 0.17.0 daemon — the new Pi client would `sameMajorMinor`-match the old daemon and join as client, never triggering a respawn. Patch releases now auto-kill+respawn the daemon, so daemon bug fixes ship cleanly via `pi update`. - -## 0.17.1 — 2026-05-16 - -### Fix: `/chrome onboard` stuck on "Waiting for the extension to wake up…" - -0.17.0 made the daemon (not the Pi process) own the HTTP listener, but the Pi client's -`bridge.status().connected` still reflected the *local Pi*'s `lastSeenAt`, which never gets -updated in the daemon model. Result: onboard's connect-wait loop spun forever even when the -extension was happily polling the daemon. - -- New `ChromeProfileBridge.daemonStatus()` async method: fetches the daemon's `/status` and - returns its view (the only correct source of truth in 0.17+). -- `/chrome onboard`, `/chrome doctor`, `/chrome status` now query `daemonStatus()` for the - `connected` flag instead of reading stale Pi-side `lastSeenAt`. - -## 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 - -- **`bridge.start()` auto-takes over when the existing owner is unresponsive or running an incompatible major.minor version.** Probes `/status` with a 1s timeout; if the response is missing or carries a `bridgeVersion` that doesn't match this pi-chrome's major.minor, locates the owner PID via `lsof`, kills it (SIGTERM → SIGKILL escalation), and re-binds. Same-version owners are still joined as clients (multi-Pi sharing). This makes pi-chrome upgrades self-healing: just `pi update` then start a new Pi session, no manual `kill` required. -- **`/status` now includes `bridgeVersion`** so peer Pi sessions and tooling can make this decision deterministically. - -### Idempotent `/chrome onboard` recovery flow - -- **Foreign bridge-owner takeover.** When another Pi process (often an older pi-chrome that doesn't know about pairing) is holding `:17318`, onboard now detects it via `lsof`, confirms with the user, then `kill`s the foreign owner (SIGTERM → SIGKILL escalation), waits for the port to free, and re-binds the local bridge as server. Without this, onboarding from a peer Pi session would route through a stale owner forever. - - -Real-world failure: users on machines with an older pi-chrome extension installed couldn't get back to a working state. Onboard trusted confirm() clicks without verifying anything, the bridge could be in a half-paired state if the extension was reloaded with empty `chrome.storage.local`, and `/chrome status` / `doctor` blocked for up to 35 s waiting for a response from an extension that wasn't polling. - -- **`/chrome onboard` (alias: `/chrome install`) is now safe to re-run any number of times.** Resets Pi-side pairing state first, walks the user through install OR reload (covering the v0.15.x → 0.16.x migration where the service worker must reload to gain the /pair logic), then **actively verifies** every step: - - polls `bridge.status().connected` (15 s window) to confirm the extension is actually polling `/next` before issuing a pair invite, - - polls `bridge.bridgeAuth.paired` after the user pastes the invite (no more "press Enter once it says paired" — detection is automatic), - - round-trips a real `tab.version` command to confirm the end-to-end signed-envelope path works. - - Any failure prints a concrete fix (wake the SW, re-load unpacked, switch to a normal tab, etc.). -- **`/chrome status` / `doctor` / `onboard` no longer hang.** New `probeExtension()` short-circuits before any `bridge.send()` when the bridge is unpaired or the extension hasn't polled recently; commands return immediately with an actionable message instead of waiting the full 5–35 s timeout. - -## 0.16.0 — 2026-05-16 - -### Onboarding UX (same release) - -- **`/chrome onboard` now runs both install + pair in one flow.** Two-step interactive walkthrough: (1) install/reload the Chrome extension (with auto-open of `chrome://extensions` + Finder + clipboard on macOS), (2) mint an invite, copy to clipboard, and wait for the user to paste into the extension popup. Returning users with an already-connected extension can skip step 1; users with an existing pairing get an explicit re-pair prompt. Pairing is no longer a separate step users have to remember. -- **`/chrome` command help reorganized** into First-time setup / Day-to-day / Maintenance groups so `onboard → authorize` is the obvious path. -- **Linux clipboard support** in the onboard/pair flow (tries `xclip` → `xsel` → `wl-copy`). macOS still uses `pbcopy`. Other platforms surface the invite text only; user copies manually. -- **README rewritten** with the install-then-pair flow front and center, a new "Why pairing?" section explaining the threat model in plain English, and a command-reference table. - -### Ported from fork DaniBedz/pi-chrome (same release) - -- **Early console/network capture at document_start.** Previously `chrome_list_console_messages` and `chrome_list_network_requests` returned empty for errors and requests fired during initial page load, because instrumentation only installed lazily when a chrome_* tool first ran. A new `chrome.webNavigation.onCommitted` listener now injects a self-contained `installEarlyCapture()` into MAIN world with `injectImmediately:true`, wrapping `console.{debug,log,info,warn,error}`, `fetch`, and `XMLHttpRequest` plus `error`/`unhandledrejection` listeners before the page's own code runs. Same `window.__PI_CHROME_STATE__` schema as the lazy path; idempotent via `__piChromeWrapped`. Boot-time React errors and initial API calls now appear in the console/network logs. -- **Quieter startup + dynamic status bar.** Removed the `session_start` info notify and the always-on `Chrome bridge :17318` status entry. The status bar now lights up as a green ● only after `/chrome authorize`, and clears on `/chrome revoke`. Onboarding (`/chrome onboard`) and pairing (`/chrome pair`) still print actionable instructions when invoked. - -### Test-driven follow-ups (same release) - -- **Paired `/next` distinguishes attack signal from migration.** With a paired bridge, a pinned origin that sends a present-but-invalid auth header (bad sig, replayed nonce, or stale ts) now gets a hard `401` instead of `200 needsPairing`. Missing-header from any chrome-extension origin still returns the soft migration response (so older unsigned extensions can read the version header and auto-reload). SW handles the new 401 by surfacing the pair badge and backing off; no exception throw, no auto-clear of keys. -- **Owner re-reads pairing state on every privileged request.** `BridgeAuth.refreshIfStale()` stats `~/.config/pi/chrome-bridge.json` and reloads if mtime changed (microseconds when unchanged), so cross-process mutations of the file — e.g. a peer running `/chrome unpair`, or an external `rm` — propagate immediately to the owner's in-memory state. Replay caches are dropped on key rotation. An `fs.watch` on the config directory is also installed as a prompt secondary signal; the per-request mtime check guarantees correctness even if the watch is missed. - -### Red-team review follow-ups (same release) - -- **CORS now allows/exposes the auth header** (`x-pi-chrome-auth`). Without this, some `fetch` implementations strip it on cross-origin extension responses. -- **Renamed header from `x-pi-pi-auth` to `x-pi-chrome-auth`.** Internal-only rename before any 0.16.x release. -- **`brokerKey` no longer leaves the Pi-side config.** The `/pair` response only returns `extensionPairKey`. Compromised extensions therefore can't extract the broker key used for peer-Pi `/command`. -- **Replay defense on response paths.** Added per-direction LRU nonce caches for `bridge->ext` (extension side) and `owner->peer` (peer-Pi side). The signature spec already covered these directions; the caches now actually enforce single-use nonces. -- **`reset()` and `persist()` chmod existing files.** `writeFileSync(mode)` is a no-op on an existing file; we now `chmodSync` to `0600` (and parent dir `0700`) on every write. -- **`readJsonBody` rejects non-object JSON** (null/array/string) with 400 instead of letting it through. -- **Top-level Content-Length precheck.** Oversized requests are 413'd before any auth/per-endpoint logic runs. -- **Stale-auth handling on the extension side.** When `/chrome unpair` is run on the Pi and the extension still has cached keys, the SW no longer loops on "bad /next signature". The unsigned-idle `{type:"none", needsPairing:true}` shape is accepted without a signature *only* when the bridge sends no auth header (never with a command), so the pair badge is surfaced and the user re-pairs from the popup. - -### Original 0.16.0 changes - -### Security hardening (red-team driven) - -- **Mandatory pairing.** New `/chrome pair` issues a one-time invite the user pastes into the Chrome extension popup. Pairing pins the exact `chrome-extension://<id>` origin and derives HMAC keys via HKDF. Until paired, the bridge returns idle responses and refuses to deliver commands. `/chrome unpair` resets. -- **Signed envelopes on every privileged endpoint.** `/next`, `/result`, and `/command` now require an `X-Pi-Pi-Auth: v1 ts=... nonce=... sig=...` header signing protocol version, direction, method, path, extension ID, bridge ID, timestamp, nonce, and SHA-256 of the body. ±30 s clock window, 5-min LRU nonce replay defense, constant-time MAC compare. Bridge responses are signed too; the extension and peer Pi sessions verify before accepting commands or results. -- **Peer Pi sessions no longer ship a bearer token.** `/command` between Pi processes uses per-request signatures keyed by the broker HKDF key, and the owner's response is signed for verification. A pre-bind impostor without the pairing secret cannot impersonate or extract the keys. -- **Global loopback enforcement.** Every endpoint rejects non-loopback `remoteAddress`. `PI_CHROME_BRIDGE_HOST=0.0.0.0` is ignored unless `PI_CHROME_BRIDGE_DANGEROUS_REMOTE=1` is set, and even then the loopback check still applies. -- **Resource caps.** 1 MiB body cap (413), 256 queued/pending commands, 4 concurrent `/next` long-pollers (429). Malformed JSON now returns 400 instead of 500. -- **`crypto.randomUUID()` for command IDs** (was `Date.now()+Math.random()`). -- **`page.navigate` initScript** is now wrapped in `try/finally` around `chrome.tabs.update` and deleted in `onCommitted` *before* injection, so an aborted navigation cannot leave a stale init script armed for the next URL the user visits in that tab. -- **Manifest diet.** Dropped unused `activeTab` permission. Added popup UI (`popup.html` / `popup.js`) for pasting the pairing invite. - -### Migration - -Existing installations must run `/chrome pair` once after upgrading. Old extensions in the field receive a benign idle response plus the `x-pi-chrome-version` header, which still triggers the existing auto-reload path; the reloaded extension then surfaces a `pair` action badge. - ## 0.15.20 — 2026-05-15 - **Interruptible `chrome_*` tools.** All `chrome_*` tools now honor the agent harness `AbortSignal`, so pressing Esc aborts in-flight bridge calls (including the long-polling `chrome_wait_for`) immediately instead of waiting out the full `timeoutMs`. @@ -21,56 +21,28 @@ You: [keeps coding — agent never asked you to log in] pi install npm:pi-chrome ``` -Then in Pi (do this **once per machine**): +Then in Pi: ```text /chrome onboard ``` -Walks you through a two-step interactive flow: +On macOS this opens `chrome://extensions`, reveals the bundled `browser-extension/` folder in Finder, and copies its path to your clipboard. In Chrome: **Developer mode** → **Load unpacked** → paste the path. Done. -1. **Install the Chrome extension.** On macOS, pi-chrome opens `chrome://extensions`, reveals the bundled `browser-extension/` folder in Finder, and copies its path to your clipboard. In Chrome: **Developer mode** → **Load unpacked** → paste the path. Press Enter in Pi when the extension shows up. -2. **Pair the extension with this Pi bridge.** Pi mints a one-time `pcp_…` invite (also copied to clipboard). Click the **Pi Chrome Connector** toolbar icon in Chrome, paste the invite into the popup, and click *Pair*. Press Enter in Pi to confirm. - -Pairing binds your specific Chrome extension instance to this Pi config via an HMAC handshake. Without it the bridge cannot tell your extension apart from any other Chrome extension on the same browser, so it refuses to deliver any commands at all. See [Why pairing?](#why-pairing) below. - -Then, in any Pi session where you want to use chrome_* tools: - -```text -/chrome authorize 15m # or 30m, or indefinite -``` - -Verify anytime: +Verify, then authorize current Pi session from the terminal: ```text /chrome doctor +/chrome authorize ``` ```text +Performing Chrome bridge health check pi-chrome v<version> -• This pi session is running the Chrome connection for this machine. -✓ Bridge paired with extension <chrome-extension-id> (bridgeId <id>). -✓ Chrome is connected (companion extension v<version>, responded in <ms>ms). -✓ pi-chrome can run code in the active Chrome tab. +• Local bridge: mode=server, url=http://127.0.0.1:17318 +✓ Companion Chrome extension responding (ID: <chrome-extension-id>, ext v<version>) ``` -### Why pairing? - -The bridge listens on `127.0.0.1:17318`. Any Chrome extension with localhost host permissions — not just pi-chrome — can `fetch()` that port; Chrome's origin model blocks ordinary web pages but not extension code. Without an explicit pairing step, an unrelated extension could poll for queued commands (which include selectors, typed text, eval source, file-upload paths) or post forged results. `/chrome onboard` fixes that by deriving a shared HMAC key during the popup paste step and pinning the exact extension ID. After that, every `/next` / `/result` / `/command` request must be signed; replays and mismatched origins are rejected. Same-user malware that can read `~/.config/pi/chrome-bridge.json` is explicitly out of scope; see [SECURITY.md](./SECURITY.md). - -### Day-to-day commands - -| Command | When to use | -|---|---| -| `/chrome authorize [15m\|30m\|<min>\|indefinite]` | Per Pi session, unlock chrome_* tools. | -| `/chrome revoke` | Lock chrome_* tools again. | -| `/chrome status` | One-line summary: connection / auth / background mode. | -| `/chrome doctor` | Full health check; tells you exactly what's missing. | -| `/chrome onboard` | First-time setup, or re-install + re-pair after a Chrome profile move. | -| `/chrome pair` | Re-pair an already-installed extension (subset of `onboard`). | -| `/chrome unpair` | Clear pairing keys; every Pi session will need to re-pair. | -| `/chrome background [on\|off\|status\|toggle]` | Run without focusing Chrome. | - --- ## Try this in 30 seconds after install diff --git a/SECURITY.md b/SECURITY.md index 943352b..ffc2c06 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,39 +6,17 @@ Open a GitHub issue prefixed with `[security]` at https://github.com/tianrendong ## Threat model -`pi-chrome` is a developer tool you install knowingly. +`pi-chrome` is a developer tool you install knowingly. It is **not** designed to defend against: -### In scope (designed to defend against) +- Hostile pages running in your Chrome trying to detect or escape automation. (Standard browser security boundaries still apply, but a hostile page that already runs in your tab can do anything that page can already do.) +- Other processes on your local machine. The bridge binds to `127.0.0.1:17318` (loopback only) and chrome_* tools require `/chrome authorize` inside Pi, but the bridge does not authenticate arbitrary non-browser local callers. If your threat model includes hostile local processes running as you, run pi-chrome on a separate user account. -- **Ordinary web pages.** The bridge rejects browser-origin command requests; CORS cannot be used to drive Chrome. -- **Drive-by local processes / curl without the pairing secret.** Every privileged endpoint (`/next`, `/result`, `/command`) requires a signed envelope with an HMAC key established during explicit pairing. A random local process that has not been paired cannot poll commands, post results, or invoke `/command`. -- **Malicious Chrome extensions on the same browser.** The bridge pins the exact `chrome-extension://<id>` origin established at pairing time. Any other extension polling `/next` receives only an unsigned idle response (`type:"none"` + `needsPairing:true`); it never sees queued commands. An attacker who somehow learns a command ID still cannot forge a `/result` without the extension's HMAC key. The unsigned idle path exists purely so older or unrelated extensions can read the `x-pi-chrome-version` header and trigger the auto-reload migration path. -- **Pre-bind localhost impostors.** Pairing establishes mutual HMAC keys before any command is delivered. The Pi peer that talks to the bridge owner via `/command` signs every request and verifies the owner's signed response, so a process that binds `:17318` first cannot impersonate either side without the keys. -- **Replay / interleaving.** Every signed envelope binds protocol version, direction, method, path, extension ID, bridge ID, timestamp, fresh nonce, and SHA-256 of the body. Timestamps must be within ±30 s and nonces are rejected on reuse. -- **`/chrome authorize` bypass.** Even an attacker who can reach the bridge cannot drive Chrome from chrome_* tools unless the current Pi session also authorized control. +`pi-chrome` **is** designed to: -### Out of scope (cannot defend against) - -- **Same-user malware.** A process running under your user account can read `~/.config/pi/chrome-bridge.json` (mode 0600), replace the Chrome extension, attach a debugger to Pi or Chrome, or use OS automation APIs. If your threat model includes hostile local processes running as you, isolate pi-chrome on a separate user account or VM. -- **Hostile pages running in your tabs.** Standard browser security boundaries apply. A page that already runs in a tab can read its own DOM. MAIN-world helpers (`window.__PI_CHROME_STATE__`) are page-tamperable; treat snapshot UIDs as advisory for dangerous actions on untrusted origins. -- **Network adversaries.** All communication is loopback. There is no network path to defend. - -### Properties (always-on) - -- **Loopback only.** Every endpoint rejects non-loopback `remoteAddress`. `PI_CHROME_BRIDGE_HOST=0.0.0.0` is ignored unless `PI_CHROME_BRIDGE_DANGEROUS_REMOTE=1` is set, and even then the per-endpoint loopback check still rejects remote callers. -- **Resource caps.** 1 MiB request body cap (413), 256 queued/pending commands, 4 concurrent long-pollers. -- **No telemetry.** All state stays on disk under your config dir or in the extension's `chrome.storage.local`. -- **Fail-closed.** Missing or invalid auth headers, expired timestamps, replayed nonces, and origin mismatches all return errors with no side effects. - -## Pairing flow - -1. Run `/chrome pair` in Pi. Pi generates a 32-byte invite, prints `pcp_<base64url>` (also copied to clipboard on macOS), and arms `/pair` for 10 minutes. -2. Open the **Pi Chrome Connector** extension popup in Chrome, paste the invite, click *Pair*. -3. The extension signs its identity with the invite key. The bridge verifies, derives two HKDF keys (one for extension↔bridge `/next`/`/result`, one for peer Pi↔owner Pi `/command`), pins the exact extension ID, and returns *only* the `extensionPairKey` over a response signed with the invite secret (so the extension proves it's talking to the same Pi process that issued the invite). The `brokerKey` stays Pi-side, persisted in `~/.config/pi/chrome-bridge.json`, and is never sent to the browser. -4. Both sides persist their copies: Pi at `~/.config/pi/chrome-bridge.json` mode 0600; extension in `chrome.storage.local`. -5. The invite is destroyed. - -Run `/chrome unpair` to clear pairing keys; every Pi session and the Chrome extension popup will then need to re-pair. +- Never exfiltrate page state to the network. All communication is loopback (`127.0.0.1`). +- Surface every action with an honest result envelope so the agent can't silently do the wrong thing. +- Keep Chrome control locked until the user explicitly runs `/chrome authorize` in the current Pi session. +- Reject browser-origin command requests to the loopback bridge so ordinary web pages cannot use CORS to drive Chrome. ## The companion extension diff --git a/docs/DESIGN-0.17-daemon.md b/docs/DESIGN-0.17-daemon.md deleted file mode 100644 index 2e7f115..0000000 --- a/docs/DESIGN-0.17-daemon.md +++ /dev/null @@ -1,106 +0,0 @@ -# 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 b3fcd7a..0d33b58 100644 --- a/extensions/chrome-profile-bridge/browser-extension/manifest.json +++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json @@ -1,12 +1,13 @@ { "manifest_version": 3, "name": "Pi Chrome Connector", - "version": "0.17.3", + "version": "0.15.20", "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.", "permissions": [ "tabs", "scripting", "storage", + "activeTab", "alarms", "webNavigation", "debugger" @@ -19,7 +20,6 @@ "service_worker": "service_worker.js" }, "action": { - "default_title": "Pi Chrome Connector", - "default_popup": "popup.html" + "default_title": "Pi Chrome Connector" } } diff --git a/extensions/chrome-profile-bridge/browser-extension/popup.html b/extensions/chrome-profile-bridge/browser-extension/popup.html deleted file mode 100644 index 636fd63..0000000 --- a/extensions/chrome-profile-bridge/browser-extension/popup.html +++ /dev/null @@ -1,42 +0,0 @@ -<!DOCTYPE html> -<html> -<head> - <meta charset="utf-8" /> - <title>Pi Chrome Connector</title> - <style> - body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; width: 320px; margin: 0; padding: 12px; } - h1 { font-size: 14px; margin: 0 0 8px; } - .row { margin: 8px 0; } - code { font-family: ui-monospace, Menlo, monospace; font-size: 11px; } - input[type=text] { width: 100%; box-sizing: border-box; padding: 6px; font-family: ui-monospace, Menlo, monospace; } - button { padding: 6px 10px; cursor: pointer; } - .ok { color: #16a34a; } - .warn { color: #d97706; } - .err { color: #dc2626; } - .muted { color: #6b7280; font-size: 11px; } - </style> -</head> -<body> - <h1>Pi Chrome Connector</h1> - <div id="status" class="row muted">Loading…</div> - <div id="paired-info" class="row" hidden> - <div>Paired ✓</div> - <div class="muted">extension: <code id="ext-id"></code></div> - <div class="muted">bridge: <code id="bridge-id"></code></div> - <div class="row"><button id="unpair-btn">Unpair</button></div> - </div> - <div id="pair-form"> - <div class="row"> - Run <code>/chrome pair</code> in your Pi terminal to get an invite, then paste it here: - </div> - <div class="row"> - <input id="invite" type="text" placeholder="pcp_..." autofocus /> - </div> - <div class="row"> - <button id="pair-btn">Pair</button> - </div> - <div id="result" class="row"></div> - </div> - <script src="popup.js"></script> -</body> -</html> diff --git a/extensions/chrome-profile-bridge/browser-extension/popup.js b/extensions/chrome-profile-bridge/browser-extension/popup.js deleted file mode 100644 index cc890c0..0000000 --- a/extensions/chrome-profile-bridge/browser-extension/popup.js +++ /dev/null @@ -1,46 +0,0 @@ -// Popup UI for pi-chrome pairing. -// -// Talks to the service worker via chrome.runtime.sendMessage. The service worker performs the -// HTTP /pair handshake against the local Pi bridge and stores the resulting HMAC keys in -// chrome.storage.local. This popup never sees raw keys; it only sees pair success/failure and -// the paired bridgeId. - -const $ = (id) => document.getElementById(id); - -async function refresh() { - const reply = await chrome.runtime.sendMessage({ type: "pi-chrome-status" }); - if (!reply?.ok) return; - $("status").textContent = ""; - if (reply.paired) { - $("paired-info").hidden = false; - $("pair-form").hidden = true; - $("ext-id").textContent = reply.extensionId; - $("bridge-id").textContent = reply.bridgeId; - } else { - $("paired-info").hidden = true; - $("pair-form").hidden = false; - } -} - -$("pair-btn").addEventListener("click", async () => { - const invite = $("invite").value.trim(); - if (!invite) return; - $("result").className = "row muted"; - $("result").textContent = "Pairing…"; - const reply = await chrome.runtime.sendMessage({ type: "pi-chrome-pair", invite }); - if (reply?.ok) { - $("result").className = "row ok"; - $("result").textContent = `Paired with bridge ${reply.info.bridgeId}.`; - setTimeout(refresh, 500); - } else { - $("result").className = "row err"; - $("result").textContent = reply?.error ?? "Pairing failed."; - } -}); - -$("unpair-btn").addEventListener("click", async () => { - await chrome.runtime.sendMessage({ type: "pi-chrome-unpair" }); - await refresh(); -}); - -refresh(); diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index 36d9276..88391a7 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -3,215 +3,6 @@ const CLIENT_NAME = `Pi Chrome Connector ${chrome.runtime.id}`; const POLL_ERROR_BACKOFF_MS = 2000; let polling = false; -// =================== Bridge auth (signed envelopes v1) =================== -// -// Persisted in chrome.storage.local under PI_CHROME_AUTH_KEY: -// { protocol:"v1", bridgeId, extensionId, extensionPairKey } -// extensionPairKey is a base64url-encoded 32-byte HMAC-SHA256 secret used to sign /next and -// /result. The brokerKey (used between peer Pi sessions) lives only in the Pi-side config and -// is intentionally never sent to the browser. -// -// Wire format and canonical signing string match the Pi-side BridgeAuth contract in -// extensions/chrome-profile-bridge/index.ts. Until the extension is paired, the service -// worker only contacts /next (which returns {needsPairing:true}) and the popup-driven /pair -// endpoint; it never dispatches commands. - -const PI_CHROME_AUTH_KEY = "piChromeAuthV1"; -const AUTH_HEADER = "x-pi-chrome-auth"; -let authStateCache = null; // in-memory mirror to avoid awaiting storage on every poll - -async function loadAuthState() { - if (authStateCache) return authStateCache; - try { - const stored = await chrome.storage.local.get(PI_CHROME_AUTH_KEY); - const value = stored[PI_CHROME_AUTH_KEY]; - if (value && value.protocol === "v1" && value.bridgeId && value.extensionId && value.extensionPairKey) { - authStateCache = value; - return value; - } - } catch (error) { - console.warn("[pi-chrome] failed to read auth state:", error); - } - return null; -} -async function saveAuthState(state) { - authStateCache = state; - await chrome.storage.local.set({ [PI_CHROME_AUTH_KEY]: state }); -} -async function clearAuthState() { - authStateCache = null; - await chrome.storage.local.remove(PI_CHROME_AUTH_KEY); -} -function b64urlEncode(bytes) { - let s = ""; - for (const b of bytes) s += String.fromCharCode(b); - return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} -function b64urlDecode(text) { - const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (text.length % 4)) % 4); - const bin = atob(padded); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} -async function sha256Hex(text) { - const buf = new TextEncoder().encode(text); - const digest = await crypto.subtle.digest("SHA-256", buf); - const bytes = new Uint8Array(digest); - let hex = ""; - for (const b of bytes) hex += b.toString(16).padStart(2, "0"); - return hex; -} -async function importHmacKey(b64urlKey) { - return crypto.subtle.importKey("raw", b64urlDecode(b64urlKey), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); -} -async function hmacSignBytes(key, message) { - const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)); - return new Uint8Array(sig); -} -function canonicalString(direction, method, path, extensionId, bridgeId, ts, nonce, bodyHash) { - return ["v1", direction, method.toUpperCase(), path, extensionId, bridgeId, String(ts), nonce, bodyHash].join("\n"); -} -async function buildAuthHeader(keyB64, direction, method, path, extensionId, bridgeId, body) { - const ts = Date.now(); - const nonceBytes = crypto.getRandomValues(new Uint8Array(16)); - const nonce = b64urlEncode(nonceBytes); - const bodyHash = await sha256Hex(body); - const key = await importHmacKey(keyB64); - const sig = await hmacSignBytes(key, canonicalString(direction, method, path, extensionId, bridgeId, ts, nonce, bodyHash)); - return `v1 ts=${ts} nonce=${nonce} sig=${b64urlEncode(sig)}`; -} -function parseAuthHeader(value) { - if (!value || typeof value !== "string") return null; - const parts = value.trim().split(/\s+/); - if (parts.length !== 4 || parts[0] !== "v1") return null; - const kv = {}; - for (const p of parts.slice(1)) { - const eq = p.indexOf("="); - if (eq <= 0) return null; - kv[p.slice(0, eq)] = p.slice(eq + 1); - } - const ts = Number(kv.ts); - if (!Number.isFinite(ts) || !kv.nonce || !kv.sig) return null; - return { ts, nonce: kv.nonce, sig: kv.sig }; -} -// Bounded LRU nonce cache for bridge->ext responses (captured /next cannot be replayed at -// this extension). Map iteration order = insertion order; evict the oldest entry on overflow -// regardless of staleness. -const responseNonceCache = new Map(); -const RESPONSE_NONCE_TTL_MS = 5 * 60_000; -const RESPONSE_NONCE_MAX = 4096; -function hasResponseNonce(nonce) { return responseNonceCache.has(nonce); } -function storeResponseNonce(nonce, ts) { - const now = Date.now(); - for (const [k, t] of responseNonceCache) { - if (now - t > RESPONSE_NONCE_TTL_MS) responseNonceCache.delete(k); else break; - } - while (responseNonceCache.size >= RESPONSE_NONCE_MAX) { - const oldest = responseNonceCache.keys().next().value; - if (oldest === undefined) break; - responseNonceCache.delete(oldest); - } - responseNonceCache.set(nonce, ts); -} -async function verifyBridgeResponse(keyB64, direction, method, path, extensionId, bridgeId, headerValue, body) { - const parsed = parseAuthHeader(headerValue); - if (!parsed) return false; - if (Math.abs(Date.now() - parsed.ts) > 30_000) return false; - if (hasResponseNonce(parsed.nonce)) return false; - const bodyHash = await sha256Hex(body); - const key = await importHmacKey(keyB64); - const ok = await crypto.subtle.verify( - "HMAC", - key, - b64urlDecode(parsed.sig), - new TextEncoder().encode(canonicalString(direction, method, path, extensionId, bridgeId, parsed.ts, parsed.nonce, bodyHash)), - ); - if (!ok) return false; - storeResponseNonce(parsed.nonce, parsed.ts); - return true; -} - -// Popup-driven pairing entrypoint. Receives the user-pasted invite, derives the MAC, posts -// /pair, then stores the keys returned by the bridge. Fails closed. -async function performPairing(inviteToken) { - if (!inviteToken || !inviteToken.startsWith("pcp_")) throw new Error("invite must look like pcp_..."); - const inviteRaw = inviteToken.slice(4); - let inviteKey; - try { inviteKey = await importHmacKey(inviteRaw); } - catch { throw new Error("invite token is not valid base64url"); } - const extensionId = chrome.runtime.id; - const extensionNonceBytes = crypto.getRandomValues(new Uint8Array(16)); - const extensionNonce = b64urlEncode(extensionNonceBytes); - const macBytes = await hmacSignBytes(inviteKey, `pair-v1|${extensionId}|${extensionNonce}`); - const body = JSON.stringify({ extensionId, extensionNonce, mac: b64urlEncode(macBytes) }); - const response = await fetch(`${BRIDGE_URL}/pair`, { - method: "POST", - headers: { "content-type": "application/json" }, - body, - }); - const text = await response.text(); - if (!response.ok) { - let msg = `pair failed: HTTP ${response.status}`; - try { msg = `pair failed: ${JSON.parse(text).error || msg}`; } catch {} - throw new Error(msg); - } - // Verify the bridge signed its response with the invite secret — proves we're talking to - // the same Pi process that issued the invite, not a localhost impostor. - const authHeader = response.headers.get(AUTH_HEADER); - const parsed = parseAuthHeader(authHeader); - if (!parsed) throw new Error("bridge response missing or invalid auth header"); - // Clock skew check on the pair response too, for consistency with the rest of the protocol. - if (Math.abs(Date.now() - parsed.ts) > 30_000) throw new Error("bridge response timestamp outside ±30 s window"); - const bodyHash = await sha256Hex(text); - const ok = await crypto.subtle.verify( - "HMAC", - inviteKey, - b64urlDecode(parsed.sig), - new TextEncoder().encode(canonicalString("bridge->ext", "POST", "/pair", extensionId, JSON.parse(text).bridgeId, parsed.ts, parsed.nonce, bodyHash)), - ); - if (!ok) throw new Error("bridge response signature did not verify"); - const payload = JSON.parse(text); - if (!payload.ok || !payload.bridgeId || !payload.extensionPairKey) { - throw new Error("pair response missing fields"); - } - // Intentionally NOT storing brokerKey — the bridge no longer returns it. The extension - // only needs extensionPairKey for /next and /result. - await saveAuthState({ - protocol: "v1", - bridgeId: payload.bridgeId, - extensionId, - extensionPairKey: payload.extensionPairKey, - }); - return { bridgeId: payload.bridgeId, extensionId }; -} - -// Message bridge for popup.html. -chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { - (async () => { - if (message?.type === "pi-chrome-pair") { - try { - const info = await performPairing(message.invite); - sendResponse({ ok: true, info }); - } catch (error) { - sendResponse({ ok: false, error: error?.message ?? String(error) }); - } - return; - } - if (message?.type === "pi-chrome-status") { - const state = await loadAuthState(); - sendResponse({ ok: true, paired: Boolean(state), extensionId: chrome.runtime.id, bridgeId: state?.bridgeId ?? null }); - return; - } - if (message?.type === "pi-chrome-unpair") { - await clearAuthState(); - sendResponse({ ok: true }); - return; - } - })(); - return true; // async response -}); - // =================== Chrome input (CDP) layer =================== // Tracks which tabs we have attached chrome.debugger to. const attachedTabs = new Map(); // tabId -> { detachAt: number, pointer: {x,y} } @@ -836,16 +627,10 @@ async function pollLoop() { polling = true; try { while (true) { - const auth = await loadAuthState(); - const headers = { "cache-control": "no-store" }; - if (auth) { - headers[AUTH_HEADER] = await buildAuthHeader(auth.extensionPairKey, "ext->bridge", "GET", "/next", auth.extensionId, auth.bridgeId, ""); - } const response = await fetch(`${BRIDGE_URL}/next?name=${encodeURIComponent(CLIENT_NAME)}`, { cache: "no-store", - headers, }); - // Even unpaired we honor version-mismatch reload so older extensions auto-update. + if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`); const expected = response.headers.get("x-pi-chrome-version"); const ours = chrome.runtime.getManifest().version; if (expected && expected !== ours && isVersionOlder(ours, expected)) { @@ -853,60 +638,8 @@ async function pollLoop() { try { chrome.runtime.reload(); } catch {} return; } - if (response.status === 401) { - // Paired-but-stale: our stored extensionPairKey no longer matches what the bridge - // expects (most likely the user ran /chrome unpair then /chrome pair again with a - // different extension instance or invite). Surface the pair badge and back off, - // identical to the bridge-returned needsPairing path. Do NOT auto-clear stored - // auth — user must explicitly unpair via the popup. - try { chrome.action.setBadgeText({ text: "pair" }); chrome.action.setBadgeBackgroundColor({ color: "#d97706" }); } catch {} - await sleep(POLL_ERROR_BACKOFF_MS); - continue; - } - if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`); - const text = await response.text(); - let payload; - try { payload = JSON.parse(text); } catch { throw new Error("bad /next JSON"); } - // Unsigned-idle path: the bridge always returns an unsigned `{type:"none", needsPairing:true}` - // when it has no pinned extension or no matching auth. Even a paired-but-stale extension - // (e.g. user ran `/chrome unpair` on the Pi side) must keep polling and surface the - // pair badge instead of looping on "bad signature". Only accept this shape unsigned. - const sigHeader = response.headers.get(AUTH_HEADER); - const unsignedIdle = !sigHeader && payload.type === "none" && payload.needsPairing === true; - if (auth && !unsignedIdle) { - const okSig = await verifyBridgeResponse( - auth.extensionPairKey, - "bridge->ext", - "GET", - "/next", - auth.extensionId, - auth.bridgeId, - sigHeader, - text, - ); - if (!okSig) { - console.warn("[pi-chrome] /next response signature invalid; refusing command and dropping connection"); - throw new Error("bad /next signature"); - } - } - if (payload.needsPairing) { - // Bridge has not been paired (or our stored keys no longer match). Surface via badge - // so the user opens the popup; back off so we don't spin. Do NOT auto-clear stored - // auth — user must explicitly unpair via the popup. - try { chrome.action.setBadgeText({ text: "pair" }); chrome.action.setBadgeBackgroundColor({ color: "#d97706" }); } catch {} - await sleep(POLL_ERROR_BACKOFF_MS); - continue; - } else { - try { chrome.action.setBadgeText({ text: "" }); } catch {} - } - if (payload.type === "command") { - if (!auth) { - // Bridge sent a command but we have no key to verify or sign /result. Refuse. - console.warn("[pi-chrome] command received but extension is not paired; refusing"); - continue; - } - await handleCommand(payload.command, auth); - } + const payload = await response.json(); + if (payload.type === "command") await handleCommand(payload.command); } } catch (error) { await sleep(POLL_ERROR_BACKOFF_MS); @@ -915,22 +648,20 @@ async function pollLoop() { } } -async function handleCommand(command, auth) { +async function handleCommand(command) { try { const result = await dispatch(command.action, command.params ?? {}); - await postResult({ id: command.id, ok: true, result }, auth); + await postResult({ id: command.id, ok: true, result }); } catch (error) { - await postResult({ id: command.id, ok: false, error: error?.message ?? String(error) }, auth); + await postResult({ id: command.id, ok: false, error: error?.message ?? String(error) }); } } -async function postResult(result, auth) { - const body = JSON.stringify(result); - const header = await buildAuthHeader(auth.extensionPairKey, "ext->bridge", "POST", "/result", auth.extensionId, auth.bridgeId, body); +async function postResult(result) { await fetch(`${BRIDGE_URL}/result`, { method: "POST", - headers: { "content-type": "application/json", [AUTH_HEADER]: header }, - body, + headers: { "content-type": "application/json" }, + body: JSON.stringify(result), }); } @@ -1017,58 +748,17 @@ async function dispatch(action, params) { const tab = await getTabByParams(params); if (params.foreground) await bringToFront(tab); if (params.initScript) { - // Register a one-shot document_start content script. The onCommitted listener clears - // the slot the moment it fires. The finally below only fires the safety-net cleanup - // when the navigation aborts before commit, so that: - // - aborted navigation: slot cleared so it can't be picked up by a later URL the - // user navigates to in this tab; - // - waitUntilLoad=false success: onCommitted fires asynchronously *after* our - // await wait already resolved; we must NOT clear in finally before it has a - // chance to fire, otherwise the init script never injects. + // Register a one-shot document_start content script. We register, navigate, wait, then unregister. await registerInitScript(tab.id, params.initScript); } - // Track onCommitted for *this* tab so we know whether the navigation actually reached - // document_start (and thus injected the init script). The global onCommitted listener - // clears `initScriptIds` immediately; this local one only flips a flag we use to - // decide whether the finally block needs to do safety-net cleanup. - let committed = false; - let resolveCommitted; - const committedPromise = new Promise((r) => { resolveCommitted = r; }); - let cleanupCommittedListener; - if (params.initScript) { - const listener = (details) => { - if (details.tabId === tab.id && details.frameId === 0) { committed = true; resolveCommitted(); } - }; - chrome.webNavigation.onCommitted.addListener(listener); - cleanupCommittedListener = () => chrome.webNavigation.onCommitted.removeListener(listener); - } + const wait = params.waitUntilLoad !== false ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined); + const updated = await chrome.tabs.update(tab.id, { url: params.url }); try { - const wait = params.waitUntilLoad !== false ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined); - const updated = await chrome.tabs.update(tab.id, { url: params.url }); await wait; - // With waitUntilLoad:false, `wait` resolves before onCommitted fires. We still need - // the init script to inject, so wait up to a short window for commit before letting - // finally decide whether to clear the slot. With waitUntilLoad:true this is almost - // always a no-op (commit precedes load complete). - if (params.initScript && !committed) { - const commitWindowMs = Math.min(params.timeoutMs || 15000, 5000); - await Promise.race([ - committedPromise, - new Promise((r) => setTimeout(r, commitWindowMs)), - ]); - } - return formatTab(await chrome.tabs.get(updated.id)); } finally { - if (params.initScript) { - if (!committed) { - // Navigation never committed (e.g. tabs.update threw, tab closed, or commit - // didn't happen within the window). Clear so the armed init script can't fire - // on a later unrelated navigation. - await unregisterInitScript(tab.id).catch(() => undefined); - } - cleanupCommittedListener?.(); - } + if (params.initScript) await unregisterInitScript(tab.id).catch(() => undefined); } + return formatTab(await chrome.tabs.get(updated.id)); } case "page.screenshot": return takeScreenshot(params); @@ -1266,9 +956,6 @@ if (chrome.webNavigation && chrome.webNavigation.onCommitted) { if (details.frameId !== 0) return; const source = initScriptIds.get(details.tabId); if (!source) return; - // One-shot: clear immediately so any subsequent navigation (redirect, history, - // user-initiated) does not re-fire the same init script. - initScriptIds.delete(details.tabId); chrome.scripting.executeScript({ target: { tabId: details.tabId, frameIds: [0] }, world: "MAIN", @@ -1277,139 +964,6 @@ if (chrome.webNavigation && chrome.webNavigation.onCommitted) { args: [source], }).catch(() => undefined); }); - - // Early capture: inject console/network instrumentation at document_start of every top-frame - // navigation. Without this, errors fired during initial React render or boot-time fetches - // are lost before the lazy `installPiChromeInstrumentation()` path (run by - // chrome_snapshot/chrome_evaluate/page.console.list/page.network.list) gets a chance to wrap - // the globals. The injected function is self-contained (no closure refs) since - // chrome.scripting.executeScript serializes it. It is idempotent via __piChromeWrapped and - // updates the same window.__PI_CHROME_STATE__ schema as the post-hoc path, so subsequent - // calls to installPiChromeInstrumentation() are no-ops. - chrome.webNavigation.onCommitted.addListener((details) => { - if (details.frameId !== 0) return; - chrome.scripting.executeScript({ - target: { tabId: details.tabId, frameIds: [0] }, - world: "MAIN", - injectImmediately: true, - func: installEarlyCapture, - }).catch(() => undefined); - }); -} - -// Self-contained boot-time instrumentation. Must not close over anything outside its body -// because chrome.scripting.executeScript({func}) serializes it via toString and rebinds in -// the target page's MAIN world. -function installEarlyCapture() { - const state = window.__PI_CHROME_STATE__ || { - nextElementUid: 1, - elements: {}, - console: [], - network: [], - nextRequestId: 1, - instrumentationInstalled: false, - }; - window.__PI_CHROME_STATE__ = state; - if (state.instrumentationInstalled) return; - state.instrumentationInstalled = true; - const pushConsole = (level, args) => { - state.console.push({ - id: state.console.length + 1, - level, - timestamp: Date.now(), - url: location.href, - args: Array.from(args).map((arg) => { - try { - if (typeof arg === "string") return arg; - if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack }; - return JSON.parse(JSON.stringify(arg)); - } catch { - return String(arg); - } - }), - }); - if (state.console.length > 500) state.console.splice(0, state.console.length - 500); - }; - for (const level of ["debug", "log", "info", "warn", "error"]) { - const original = console[level]; - if (typeof original !== "function" || original.__piChromeWrapped) continue; - const wrapped = function (...args) { - pushConsole(level, args); - return original.apply(this, args); - }; - wrapped.__piChromeWrapped = true; - console[level] = wrapped; - } - window.addEventListener("error", (event) => pushConsole("pageerror", [event.message, event.filename + ":" + event.lineno + ":" + event.colno])); - window.addEventListener("unhandledrejection", (event) => pushConsole("unhandledrejection", [event.reason])); - const trimBody = (text) => typeof text === "string" && text.length > 200000 ? text.slice(0, 200000) + `\n[truncated ${text.length - 200000} chars]` : text; - const record = (entry) => { - state.network.push(entry); - if (state.network.length > 1000) state.network.splice(0, state.network.length - 1000); - return entry; - }; - if (window.fetch && !window.fetch.__piChromeWrapped) { - const originalFetch = window.fetch.bind(window); - const wrappedFetch = async (...args) => { - const id = "req-" + state.nextRequestId++; - const startedAt = Date.now(); - const input = args[0]; - const init = args[1] || {}; - const url = typeof input === "string" ? input : input?.url; - const method = (init.method || input?.method || "GET").toUpperCase(); - const entry = record({ id, type: "fetch", method, url: String(url || ""), startedAt, pageUrl: location.href, status: "pending" }); - try { - const response = await originalFetch(...args); - entry.status = response.status; - entry.statusText = response.statusText; - entry.ok = response.ok; - entry.responseUrl = response.url; - entry.durationMs = Date.now() - startedAt; - entry.responseHeaders = Array.from(response.headers.entries()); - response.clone().text().then((text) => { - entry.responseBody = trimBody(text); - entry.responseBodyTruncated = typeof text === "string" && text.length > 200000; - }).catch((error) => { entry.responseBodyError = error?.message || String(error); }); - return response; - } catch (error) { - entry.error = error?.message || String(error); - entry.durationMs = Date.now() - startedAt; - throw error; - } - }; - wrappedFetch.__piChromeWrapped = true; - window.fetch = wrappedFetch; - } - if (window.XMLHttpRequest && !XMLHttpRequest.prototype.open.__piChromeWrapped) { - const originalOpen = XMLHttpRequest.prototype.open; - const originalSend = XMLHttpRequest.prototype.send; - XMLHttpRequest.prototype.open = function (method, url, ...rest) { - this.__piChromeRequest = { method: String(method || "GET").toUpperCase(), url: String(url || "") }; - return originalOpen.call(this, method, url, ...rest); - }; - XMLHttpRequest.prototype.open.__piChromeWrapped = true; - XMLHttpRequest.prototype.send = function (body) { - const id = "req-" + state.nextRequestId++; - const startedAt = Date.now(); - const info = this.__piChromeRequest || {}; - const entry = record({ id, type: "xhr", method: info.method || "GET", url: info.url || "", startedAt, pageUrl: location.href, status: "pending" }); - this.addEventListener("loadend", () => { - entry.status = this.status; - entry.statusText = this.statusText; - entry.responseUrl = this.responseURL; - entry.durationMs = Date.now() - startedAt; - try { entry.responseHeadersText = this.getAllResponseHeaders(); } catch {} - try { - if (typeof this.responseText === "string") { - entry.responseBody = trimBody(this.responseText); - entry.responseBodyTruncated = this.responseText.length > 200000; - } - } catch (error) { entry.responseBodyError = error?.message || String(error); } - }); - this.addEventListener("error", () => { entry.error = "XMLHttpRequest error"; entry.durationMs = Date.now() - startedAt; }); - return originalSend.call(this, body); - }; - } } async function bringToFront(tab) { diff --git a/extensions/chrome-profile-bridge/daemon.cjs b/extensions/chrome-profile-bridge/daemon.cjs deleted file mode 100644 index bcd2b68..0000000 --- a/extensions/chrome-profile-bridge/daemon.cjs +++ /dev/null @@ -1,702 +0,0 @@ -#!/usr/bin/env node -// -// pi-chrome bridge daemon — standalone version of the Chrome bridge. -// -// Runs as a detached process spawned by a Pi session at session_start. Owns: -// - the TCP listener on :17318 -// - the BridgeAuth state (~/.config/pi/chrome-bridge.json mode 0600) -// - the command queue between Pi clients and the Chrome extension service worker -// -// Pi sessions talk to the daemon via signed /command HTTP requests; the Chrome extension -// long-polls /next. Both sides use the v1 signed-envelope protocol documented in SECURITY.md. -// -// This file is shipped in the npm tarball and copied to ~/.cache/pi-chrome/<version>/daemon.cjs -// by the supervisor on first use. It must remain dependency-free (only node: builtins) so -// it can run anywhere Node ≥18 is installed regardless of node_modules state. - -"use strict"; - -const { createServer } = require("node:http"); -const { createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } = require("node:crypto"); -const { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, statSync, watch: fsWatch } = require("node:fs"); -const { homedir } = require("node:os"); -const { dirname, join } = require("node:path"); - -// --------------------------------------------------------------------------------------------- -// Configuration + constants -// --------------------------------------------------------------------------------------------- - -const ARGS = (() => { - const out = { host: "127.0.0.1", port: 17318, version: "0.0.0-dev", idleShutdownMs: 10 * 60_000, logPath: undefined }; - for (let i = 2; i < process.argv.length; i++) { - const arg = process.argv[i]; - const [k, v] = arg.includes("=") ? arg.split("=", 2) : [arg, process.argv[++i]]; - switch (k) { - case "--host": out.host = v; break; - case "--port": out.port = Number(v); break; - case "--version": out.version = v; break; - case "--idle-ms": out.idleShutdownMs = Number(v); break; - case "--log": out.logPath = v; break; - } - } - return out; -})(); - -const DEFAULT_TIMEOUT_MS = 30_000; -const MAX_REQUEST_BODY_BYTES = 1 * 1024 * 1024; -const MAX_QUEUE = 256; -const MAX_PENDING = 256; -const MAX_WAITERS = 4; - -const AUTH_PROTOCOL = "v1"; -const AUTH_HEADER = "x-pi-chrome-auth"; -const PAIR_WINDOW_MS = 10 * 60_000; -const MAX_CLOCK_SKEW_MS = 30_000; -const NONCE_TTL_MS = 5 * 60_000; -const NONCE_CACHE_MAX = 4096; - -function logLine(level, msg) { - const line = `[${new Date().toISOString()}] [${level}] ${msg}\n`; - process.stderr.write(line); -} - -// --------------------------------------------------------------------------------------------- -// Signed-envelope auth (mirrors index.ts BridgeAuth; see SECURITY.md for spec). -// --------------------------------------------------------------------------------------------- - -function configPath() { - const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); - return join(base, "pi", "chrome-bridge.json"); -} -function b64url(buf) { return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } -function b64urlDecode(text) { - const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (text.length % 4)) % 4); - return Buffer.from(padded, "base64"); -} -function sha256Hex(input) { return createHash("sha256").update(input).digest("hex"); } -function hmacSign(key, message) { return createHmac("sha256", key).update(message).digest(); } -function safeBufferEqual(a, b) { - if (a.length !== b.length) return false; - try { return timingSafeEqual(a, b); } catch { return false; } -} -function safeStringEqual(a, b) { - const aBuf = Buffer.from(a, "utf8"); - const bBuf = Buffer.from(b, "utf8"); - return safeBufferEqual(aBuf, bBuf); -} -function canonicalString(direction, method, path, extensionId, bridgeId, ts, nonce, body) { - return [AUTH_PROTOCOL, direction, method.toUpperCase(), path, extensionId, bridgeId, String(ts), nonce, sha256Hex(body)].join("\n"); -} -function buildAuthHeader(key, direction, method, path, extensionId, bridgeId, body) { - const ts = Date.now(); - const nonce = b64url(randomBytes(16)); - const sig = b64url(hmacSign(key, canonicalString(direction, method, path, extensionId, bridgeId, ts, nonce, body))); - return { header: `${AUTH_PROTOCOL} ts=${ts} nonce=${nonce} sig=${sig}`, ts, nonce }; -} -function parseAuthHeader(raw) { - const value = Array.isArray(raw) ? raw[0] : raw; - if (!value || typeof value !== "string") return undefined; - const parts = value.trim().split(/\s+/); - if (parts.length !== 4 || parts[0] !== AUTH_PROTOCOL) return undefined; - const kv = {}; - for (const p of parts.slice(1)) { - const eq = p.indexOf("="); - if (eq <= 0) return undefined; - kv[p.slice(0, eq)] = p.slice(eq + 1); - } - const ts = Number(kv.ts); - if (!Number.isFinite(ts) || !kv.nonce || !kv.sig) return undefined; - return { ts, nonce: kv.nonce, sig: kv.sig }; -} - -class NonceCache { - constructor() { this.entries = new Map(); } - has(nonce) { return this.entries.has(nonce); } - store(nonce, ts) { - const now = Date.now(); - for (const [k, t] of this.entries) { - if (now - t > NONCE_TTL_MS) this.entries.delete(k); else break; - } - while (this.entries.size >= NONCE_CACHE_MAX) { - const oldest = this.entries.keys().next().value; - if (oldest === undefined) break; - this.entries.delete(oldest); - } - this.entries.set(nonce, ts); - } -} - -class BridgeAuth { - constructor() { - this.state = undefined; - this.pendingInvite = undefined; - this.extNonces = new NonceCache(); - this.peerNonces = new NonceCache(); - this.ownerRespNonces = new NonceCache(); - this.lastStateMtimeMs = 0; - this.watcher = undefined; - this.load(); - this.startWatching(); - } - get paired() { this.refreshIfStale(); return Boolean(this.state); } - get bridgeId() { this.refreshIfStale(); return this.state?.bridgeId ?? ""; } - get pinnedExtensionId() { this.refreshIfStale(); return this.state?.extensionId ?? ""; } - load() { - try { - const raw = readFileSync(configPath(), "utf8"); - const parsed = JSON.parse(raw); - if (parsed?.protocol === "v1" && parsed.bridgeId && parsed.extensionId && parsed.extensionPairKey && parsed.brokerKey) { - this.state = parsed; - } else { - this.state = undefined; - } - } catch { - this.state = undefined; - } - } - persist() { - if (!this.state) return; - const path = configPath(); - try { - const dir = dirname(path); - mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(path, JSON.stringify(this.state), { mode: 0o600 }); - try { chmodSync(path, 0o600); } catch {} - try { chmodSync(dir, 0o700); } catch {} - try { this.lastStateMtimeMs = statSync(path).mtimeMs; } catch {} - } catch (error) { - logLine("warn", `failed to persist auth state at ${path}: ${error.message}`); - } - } - startWatching() { - const path = configPath(); - const dir = dirname(path); - try { mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch {} - try { - this.watcher = fsWatch(dir, { persistent: false }, (_event, filename) => { - if (!filename || filename.toString() !== "chrome-bridge.json") return; - this.refreshIfStale(); - }); - } catch (error) { - logLine("warn", `fs.watch on ${dir} failed: ${error.message}`); - } - } - stop() { try { this.watcher?.close(); } catch {} this.watcher = undefined; } - refreshIfStale() { - const path = configPath(); - let mtimeMs = 0; - try { mtimeMs = statSync(path).mtimeMs; } catch { mtimeMs = -1; } - if (mtimeMs === this.lastStateMtimeMs) return; - this.lastStateMtimeMs = mtimeMs; - const prevExt = this.state?.extensionId; - const prevBridge = this.state?.bridgeId; - this.load(); - if (this.state?.extensionId !== prevExt || this.state?.bridgeId !== prevBridge) { - this.extNonces = new NonceCache(); - this.peerNonces = new NonceCache(); - this.ownerRespNonces = new NonceCache(); - } - } - startPairWindow() { - const secret = randomBytes(32); - this.pendingInvite = { secret, expiresAt: Date.now() + PAIR_WINDOW_MS }; - return `pcp_${b64url(secret)}`; - } - pairWindowActive() { - if (!this.pendingInvite) return false; - if (this.pendingInvite.expiresAt < Date.now()) { this.pendingInvite = undefined; return false; } - return true; - } - reset() { - this.state = undefined; - this.pendingInvite = undefined; - const path = configPath(); - try { writeFileSync(path, JSON.stringify({}), { mode: 0o600 }); } catch {} - try { chmodSync(path, 0o600); } catch {} - try { chmodSync(dirname(path), 0o700); } catch {} - } - completePairing(body) { - if (!this.pairWindowActive()) throw new Error("no active pairing window"); - if (!body.extensionId || !body.extensionNonce || !body.mac) throw new Error("pair: missing fields"); - if (!/^[a-p]{32}$/.test(body.extensionId)) throw new Error("pair: invalid extensionId"); - const invite = this.pendingInvite.secret; - const expected = hmacSign(invite, `pair-v1|${body.extensionId}|${body.extensionNonce}`); - const supplied = b64urlDecode(body.mac); - if (!safeBufferEqual(expected, supplied)) throw new Error("pair: bad mac"); - const bridgeId = b64url(randomBytes(16)); - const extensionPairKey = Buffer.from(hkdfSync("sha256", invite, Buffer.from("pi-chrome-pair-salt"), Buffer.from("ext-pair-key:" + bridgeId), 32)); - const brokerKey = Buffer.from(hkdfSync("sha256", invite, Buffer.from("pi-chrome-pair-salt"), Buffer.from("broker-key:" + bridgeId), 32)); - this.state = { - protocol: "v1", - bridgeId, - extensionId: body.extensionId, - extensionPairKey: b64url(extensionPairKey), - brokerKey: b64url(brokerKey), - }; - this.persist(); - this.pendingInvite = undefined; - const response = { - ok: true, - bridgeId, - extensionId: body.extensionId, - extensionPairKey: this.state.extensionPairKey, - protocol: AUTH_PROTOCOL, - }; - const bodyText = JSON.stringify(response); - const { header } = buildAuthHeader(invite, "bridge->ext", "POST", "/pair", body.extensionId, bridgeId, bodyText); - return { response, signedHeader: header }; - } - verifyEnvelope(key, cache, direction, method, path, extensionId, bridgeId, header, body) { - const parsed = parseAuthHeader(header); - if (!parsed) return false; - if (Math.abs(Date.now() - parsed.ts) > MAX_CLOCK_SKEW_MS) return false; - if (cache.has(parsed.nonce)) return false; - const expected = hmacSign(key, canonicalString(direction, method, path, extensionId, bridgeId, parsed.ts, parsed.nonce, body)); - let supplied; - try { supplied = b64urlDecode(parsed.sig); } catch { return false; } - if (!safeBufferEqual(expected, supplied)) return false; - cache.store(parsed.nonce, parsed.ts); - return true; - } - verifyExtensionRequest(method, path, header, originExtensionId, body) { - this.refreshIfStale(); - if (!this.state) return false; - if (!safeStringEqual(originExtensionId, this.state.extensionId)) return false; - return this.verifyEnvelope(b64urlDecode(this.state.extensionPairKey), this.extNonces, "ext->bridge", method, path, this.state.extensionId, this.state.bridgeId, header, body); - } - signBridgeResponse(method, path, body) { - if (!this.state) return undefined; - const { header } = buildAuthHeader(b64urlDecode(this.state.extensionPairKey), "bridge->ext", method, path, this.state.extensionId, this.state.bridgeId, body); - return header; - } - verifyPeerRequest(method, path, header, body) { - this.refreshIfStale(); - if (!this.state) return false; - return this.verifyEnvelope(b64urlDecode(this.state.brokerKey), this.peerNonces, "peer->owner", method, path, this.state.extensionId, this.state.bridgeId, header, body); - } - signOwnerResponse(method, path, body) { - if (!this.state) return undefined; - const { header } = buildAuthHeader(b64urlDecode(this.state.brokerKey), "owner->peer", method, path, this.state.extensionId, this.state.bridgeId, body); - return header; - } -} - -// --------------------------------------------------------------------------------------------- -// HTTP utilities -// --------------------------------------------------------------------------------------------- - -function isLoopback(addr) { - return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1"; -} -function corsFor(req) { - const origin = String(req.headers.origin ?? ""); - if (!origin.startsWith("chrome-extension://")) return {}; - return { - "access-control-allow-origin": origin, - "access-control-allow-methods": "GET,POST,OPTIONS", - "access-control-allow-headers": `content-type, ${AUTH_HEADER}`, - "access-control-expose-headers": `x-pi-chrome-version, ${AUTH_HEADER}`, - "vary": "origin", - }; -} -function isBrowserOriginAllowed(req) { - const origin = String(req.headers.origin ?? ""); - if (origin) return origin.startsWith("chrome-extension://"); - const secFetchSite = String(req.headers["sec-fetch-site"] ?? ""); - return !secFetchSite || secFetchSite === "none" || secFetchSite === "same-origin"; -} -function isLocalProcessRequest(req) { - return !req.headers.origin && !req.headers["sec-fetch-site"]; -} -function sendJson(res, status, body, extra) { - res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...(extra ?? {}) }); - res.end(JSON.stringify(body)); -} -function extensionIdFromOrigin(req) { - const origin = String(req.headers.origin ?? ""); - const match = origin.match(/^chrome-extension:\/\/([a-p]{32})$/); - return match ? match[1] : undefined; -} - -class RequestBodyTooLargeError extends Error { constructor() { super("request body too large"); this.name = "RequestBodyTooLargeError"; } } -function readBody(req, maxBytes = MAX_REQUEST_BODY_BYTES) { - return new Promise((resolve, reject) => { - const declared = Number(req.headers["content-length"]); - if (Number.isFinite(declared) && declared > maxBytes) { reject(new RequestBodyTooLargeError()); req.resume(); return; } - const chunks = []; - let size = 0; - req.on("data", (chunk) => { - const buf = Buffer.from(chunk); - size += buf.length; - if (size > maxBytes) { reject(new RequestBodyTooLargeError()); req.destroy(); return; } - chunks.push(buf); - }); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - req.on("error", reject); - }); -} -async function readJsonObject(req, res, extraHeaders) { - let raw; - try { raw = await readBody(req); } - catch (error) { - if (error instanceof RequestBodyTooLargeError) sendJson(res, 413, { ok: false, error: "request body too large" }, extraHeaders); - else sendJson(res, 400, { ok: false, error: `failed to read body: ${error.message}` }, extraHeaders); - return undefined; - } - let parsed; - try { parsed = JSON.parse(raw || "{}"); } - catch (error) { sendJson(res, 400, { ok: false, error: `invalid JSON: ${error.message}` }, extraHeaders); return undefined; } - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - sendJson(res, 400, { ok: false, error: "expected JSON object" }, extraHeaders); - return undefined; - } - return parsed; -} - -// --------------------------------------------------------------------------------------------- -// Bridge daemon -// --------------------------------------------------------------------------------------------- - -const startedAt = Date.now(); -let lastActivityAt = Date.now(); - -const auth = new BridgeAuth(); -const pending = new Map(); -let queue = []; -let waiters = []; -let lastSeenAt; -let clientName; - -function touchActivity() { lastActivityAt = Date.now(); } - -function enqueue(command) { - const w = waiters.shift(); - if (w) { w(command); return; } - if (queue.length >= MAX_QUEUE) { - const p = pending.get(command.id); - if (p) { clearTimeout(p.timer); pending.delete(command.id); p.reject(new Error("queue full")); } - return; - } - queue.push(command); -} - -function sendCommandLocal(action, params, timeoutMs = DEFAULT_TIMEOUT_MS) { - const id = randomUUID(); - const command = { id, action, params }; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pending.delete(id); - queue = queue.filter((q) => q.id !== id); - reject(new Error(`Timed out waiting for Chrome extension after ${timeoutMs}ms.`)); - }, timeoutMs); - if (pending.size >= MAX_PENDING) { clearTimeout(timer); reject(new Error("too many in-flight commands")); return; } - pending.set(id, { command, resolve, reject, timer }); - enqueue(command); - }); -} - -function waitForCommand(timeoutMs, register) { - return new Promise((resolve) => { - let settled = false; - const w = (cmd) => { if (settled) return; settled = true; clearTimeout(t); waiters = waiters.filter((e) => e !== w); resolve(cmd); }; - const t = setTimeout(() => w(undefined), timeoutMs); - waiters.push(w); - register?.(w); - }); -} - -async function handle(req, res) { - touchActivity(); - const url = new URL(req.url ?? "/", `http://${ARGS.host}:${ARGS.port}`); - const corsHeaders = corsFor(req); - - if (!isLoopback(req.socket.remoteAddress)) { - sendJson(res, 403, { ok: false, error: "bridge accepts only loopback connections" }); - return; - } - const declaredLen = Number(req.headers["content-length"]); - if (Number.isFinite(declaredLen) && declaredLen > MAX_REQUEST_BODY_BYTES) { - sendJson(res, 413, { ok: false, error: "request body too large" }); - req.resume(); - return; - } - - if (req.method === "OPTIONS") { - if (!isBrowserOriginAllowed(req)) { sendJson(res, 403, { ok: false, error: "browser origin not allowed" }); return; } - sendJson(res, 200, { ok: true }, corsHeaders); - return; - } - - if (req.method === "GET" && url.pathname === "/status") { - sendJson(res, 200, { - url: `http://${ARGS.host}:${ARGS.port}`, - mode: "daemon", - bridgeVersion: ARGS.version, - daemonPid: process.pid, - daemonStartedAt: startedAt, - connected: lastSeenAt !== undefined && Date.now() - lastSeenAt < 5 * 60_000, - lastSeenAt, - clientName, - queuedCommands: queue.length, - pendingCommands: pending.size, - paired: auth.paired, - }); - return; - } - - if (req.method === "GET" && url.pathname === "/heartbeat") { - // Pi clients hit this to keep the daemon's idle timer alive between long gaps of - // chrome_* tool activity. Loopback-only, no auth needed. - sendJson(res, 200, { ok: true, ts: Date.now() }); - return; - } - - if (req.method === "POST" && url.pathname === "/pair") { - if (!isBrowserOriginAllowed(req)) { sendJson(res, 403, { ok: false, error: "browser origin required" }, corsHeaders); return; } - if (!auth.pairWindowActive()) { - sendJson(res, 403, { ok: false, error: "no active pairing window; run /chrome pair in Pi first" }, corsHeaders); - return; - } - const body = await readJsonObject(req, res, corsHeaders); - if (!body) return; - const originExt = extensionIdFromOrigin(req); - if (!originExt) { sendJson(res, 403, { ok: false, error: "chrome-extension origin required" }, corsHeaders); return; } - if (body.extensionId && body.extensionId !== originExt) { sendJson(res, 400, { ok: false, error: "extensionId mismatch with Origin" }, corsHeaders); return; } - try { - const { response, signedHeader } = auth.completePairing({ ...body, extensionId: originExt }); - sendJson(res, 200, response, { ...corsHeaders, [AUTH_HEADER]: signedHeader }); - } catch (error) { - sendJson(res, 401, { ok: false, error: error.message }, corsHeaders); - } - return; - } - - if (req.method === "POST" && url.pathname === "/admin") { - // Admin RPC for Pi clients: arm-pair-window, reset, etc. Requires a signed - // peer->owner envelope (same broker key used for /command). - if (!isLocalProcessRequest(req)) { sendJson(res, 403, { ok: false, error: "admin endpoint local-only" }); return; } - let rawBody; - try { rawBody = await readBody(req); } - catch (error) { - if (error instanceof RequestBodyTooLargeError) sendJson(res, 413, { ok: false, error: "body too large" }); - else sendJson(res, 400, { ok: false, error: `body read: ${error.message}` }); - return; - } - if (auth.paired) { - // Once paired, admin calls must be authenticated. Pre-pairing, the first /admin - // call is `arm-pair-window` from the local Pi user and is allowed unauthenticated - // (loopback-only + isLocalProcessRequest already gate it). - if (!auth.verifyPeerRequest("POST", "/admin", req.headers[AUTH_HEADER], rawBody)) { - sendJson(res, 401, { ok: false, error: "missing or invalid admin auth" }); - return; - } - } - let body; - try { body = JSON.parse(rawBody || "{}"); } - catch (error) { sendJson(res, 400, { ok: false, error: `invalid JSON: ${error.message}` }); return; } - const op = body && body.op; - const respond = (status, payload) => { - const text = JSON.stringify(payload); - const sig = auth.signOwnerResponse("POST", "/admin", text); - res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...(sig ? { [AUTH_HEADER]: sig } : {}) }); - res.end(text); - }; - try { - switch (op) { - case "arm-pair-window": { - if (auth.paired && !body.replace) { - respond(409, { ok: false, error: "already paired; pass replace:true to re-pair" }); - return; - } - if (body.replace) auth.reset(); - const invite = auth.startPairWindow(); - respond(200, { ok: true, invite, windowMs: PAIR_WINDOW_MS }); - return; - } - case "reset": { - auth.reset(); - respond(200, { ok: true }); - return; - } - case "status": { - respond(200, { ok: true, paired: auth.paired, bridgeId: auth.bridgeId, pinnedExtensionId: auth.pinnedExtensionId }); - return; - } - default: - respond(400, { ok: false, error: `unknown admin op: ${op}` }); - } - } catch (error) { - respond(500, { ok: false, error: error.message }); - } - return; - } - - if (req.method === "POST" && url.pathname === "/command") { - if (!isLocalProcessRequest(req)) { sendJson(res, 403, { ok: false, error: "/command local-only" }); return; } - if (!auth.paired) { sendJson(res, 401, { ok: false, error: "bridge not paired; run /chrome pair" }); return; } - let rawBody; - try { rawBody = await readBody(req); } - catch (error) { - if (error instanceof RequestBodyTooLargeError) sendJson(res, 413, { ok: false, error: "body too large" }); - else sendJson(res, 400, { ok: false, error: `body read: ${error.message}` }); - return; - } - if (!auth.verifyPeerRequest("POST", "/command", req.headers[AUTH_HEADER], rawBody)) { - sendJson(res, 401, { ok: false, error: "missing or invalid bridge auth header" }); - return; - } - let body; - try { body = JSON.parse(rawBody || "{}"); } - catch (error) { sendJson(res, 400, { ok: false, error: `invalid JSON: ${error.message}` }); return; } - if (!body.action || typeof body.action !== "string") { sendJson(res, 400, { ok: false, error: "missing action" }); return; } - const timeoutMs = Math.min(Math.max(Number(body.timeoutMs) || DEFAULT_TIMEOUT_MS, 1_000), 5 * 60_000); - const respond = (status, payload) => { - const text = JSON.stringify(payload); - const sig = auth.signOwnerResponse("POST", "/command", text); - res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...(sig ? { [AUTH_HEADER]: sig } : {}) }); - res.end(text); - }; - try { - const result = await sendCommandLocal(body.action, body.params ?? {}, timeoutMs); - respond(200, { ok: true, result }); - } catch (error) { - respond(504, { ok: false, error: error.message }); - } - return; - } - - if (req.method === "GET" && url.pathname === "/next") { - if (!isBrowserOriginAllowed(req)) { sendJson(res, 403, { ok: false, error: "browser origin not allowed" }); return; } - const originExt = extensionIdFromOrigin(req); - const currentVersion = ARGS.version; - // Any /next from a chrome-extension origin counts as 'extension is alive'. We record - // lastSeenAt here — before the auth/pair checks below — so that /chrome onboard's - // connect-wait loop can detect a polling-but-unpaired extension (which is precisely - // the state during the pair step). - if (originExt) { - lastSeenAt = Date.now(); - clientName = url.searchParams.get("name") ?? undefined; - } - if (!auth.paired) { - sendJson(res, 200, { type: "none", needsPairing: true, expectedExtensionVersion: currentVersion }, - { ...corsHeaders, "x-pi-chrome-version": currentVersion }); - return; - } - const hasAuthHeader = Boolean(req.headers[AUTH_HEADER]); - if (!originExt || originExt !== auth.pinnedExtensionId) { - sendJson(res, 200, { type: "none", needsPairing: true, expectedExtensionVersion: currentVersion }, - { ...corsHeaders, "x-pi-chrome-version": currentVersion }); - return; - } - if (!hasAuthHeader) { - sendJson(res, 200, { type: "none", needsPairing: true, expectedExtensionVersion: currentVersion }, - { ...corsHeaders, "x-pi-chrome-version": currentVersion }); - return; - } - if (!auth.verifyExtensionRequest("GET", "/next", req.headers[AUTH_HEADER], originExt, "")) { - sendJson(res, 401, { ok: false, error: "invalid /next auth" }, { ...corsHeaders, "x-pi-chrome-version": currentVersion }); - return; - } - if (waiters.length >= MAX_WAITERS) { sendJson(res, 429, { ok: false, error: "too many pollers" }, corsHeaders); return; } - let aborted = false; - let active; - req.once("close", () => { aborted = true; if (active) waiters = waiters.filter((e) => e !== active); }); - let command = queue.shift(); - if (!command) command = await waitForCommand(25_000, (w) => { active = w; }); - if (aborted) { if (command) queue.unshift(command); return; } - const payload = command - ? { type: "command", command, expectedExtensionVersion: currentVersion } - : { type: "none", expectedExtensionVersion: currentVersion }; - const respBody = JSON.stringify(payload); - const sig = auth.signBridgeResponse("GET", "/next", respBody); - res.writeHead(200, { - "content-type": "application/json; charset=utf-8", - "cache-control": "no-store", - ...corsHeaders, - "x-pi-chrome-version": currentVersion, - ...(sig ? { [AUTH_HEADER]: sig } : {}), - }); - res.end(respBody); - return; - } - - if (req.method === "POST" && url.pathname === "/result") { - if (!isBrowserOriginAllowed(req)) { sendJson(res, 403, { ok: false, error: "browser origin not allowed" }); return; } - if (!auth.paired) { sendJson(res, 401, { ok: false, error: "bridge not paired" }, corsHeaders); return; } - const originExt = extensionIdFromOrigin(req); - if (!originExt || originExt !== auth.pinnedExtensionId) { - sendJson(res, 403, { ok: false, error: "extension origin not pinned" }, corsHeaders); - return; - } - lastSeenAt = Date.now(); - let rawBody; - try { rawBody = await readBody(req); } - catch (error) { - if (error instanceof RequestBodyTooLargeError) sendJson(res, 413, { ok: false, error: "body too large" }, corsHeaders); - else sendJson(res, 400, { ok: false, error: `body read: ${error.message}` }, corsHeaders); - return; - } - if (!auth.verifyExtensionRequest("POST", "/result", req.headers[AUTH_HEADER], originExt, rawBody)) { - sendJson(res, 401, { ok: false, error: "invalid /result auth" }, corsHeaders); - return; - } - let result; - try { result = JSON.parse(rawBody || "{}"); } - catch (error) { sendJson(res, 400, { ok: false, error: `invalid JSON: ${error.message}` }, corsHeaders); return; } - if (!result.id || typeof result.id !== "string") { sendJson(res, 400, { ok: false, error: "missing command id" }, corsHeaders); return; } - const p = pending.get(result.id); - if (!p) { sendJson(res, 404, { ok: false, error: "unknown command id" }, corsHeaders); return; } - clearTimeout(p.timer); - pending.delete(result.id); - if (result.ok) p.resolve(result.result); - else p.reject(new Error(result.error ?? "Chrome extension command failed")); - sendJson(res, 200, { ok: true }, corsHeaders); - return; - } - - sendJson(res, 404, { error: "not found" }); -} - -// --------------------------------------------------------------------------------------------- -// Process lifecycle -// --------------------------------------------------------------------------------------------- - -const server = createServer((req, res) => { - handle(req, res).catch((error) => { - try { sendJson(res, 500, { error: error?.message ?? String(error) }); } catch {} - }); -}); - -server.on("error", (error) => { - if (error.code === "EADDRINUSE") { - logLine("info", `port ${ARGS.port} already in use; exiting cleanly so the existing daemon wins`); - process.exit(0); - } - logLine("error", `server error: ${error.message}`); - process.exit(1); -}); - -server.listen(ARGS.port, ARGS.host, () => { - logLine("info", `pi-chrome daemon v${ARGS.version} listening on http://${ARGS.host}:${ARGS.port} (pid ${process.pid})`); -}); - -// Idle shutdown: exit if no activity (extension poll or command) for IDLE_SHUTDOWN_MS. -setInterval(() => { - const idle = Date.now() - lastActivityAt; - if (idle > ARGS.idleShutdownMs) { - logLine("info", `idle for ${Math.round(idle / 1000)}s; shutting down`); - auth.stop(); - server.close(() => process.exit(0)); - setTimeout(() => process.exit(0), 2_000).unref(); - } -}, 30_000).unref(); - -// Graceful signal handling. -for (const sig of ["SIGTERM", "SIGINT"]) { - process.on(sig, () => { - logLine("info", `received ${sig}; shutting down`); - auth.stop(); - server.close(() => process.exit(0)); - setTimeout(() => process.exit(0), 2_000).unref(); - }); -} diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts index 718012a..48b726f 100644 --- a/extensions/chrome-profile-bridge/index.ts +++ b/extensions/chrome-profile-bridge/index.ts @@ -1,12 +1,8 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; -import { existsSync, readFileSync, statSync, mkdirSync, writeFileSync, chmodSync, watch as fsWatch, type FSWatcher } from "node:fs"; -import { mkdir, writeFile, readFile, chmod } from "node:fs/promises"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import { createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; -import { spawn, spawnSync } from "node:child_process"; -import { copyFileSync, openSync } from "node:fs"; -import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; /** @@ -58,581 +54,9 @@ function readPiChromeVersion(): string { } const PI_CHROME_VERSION = readPiChromeVersion(); const PI_CHROME_GLOBAL_KEY = "__piChromeProfileBridgeLoaded__"; -const DANGEROUS_REMOTE_ENV = "PI_CHROME_BRIDGE_DANGEROUS_REMOTE"; -const RAW_HOST_ENV = process.env.PI_CHROME_BRIDGE_HOST; -const DANGEROUS_REMOTE_ENABLED = process.env[DANGEROUS_REMOTE_ENV] === "1"; -function resolveDefaultHost(): string { - if (!RAW_HOST_ENV) return "127.0.0.1"; - const hostIsLoopback = RAW_HOST_ENV === "127.0.0.1" || RAW_HOST_ENV === "::1" || RAW_HOST_ENV === "localhost"; - if (hostIsLoopback) return RAW_HOST_ENV; - if (!DANGEROUS_REMOTE_ENABLED) { - console.warn( - `pi-chrome: ignoring PI_CHROME_BRIDGE_HOST=${RAW_HOST_ENV} (non-loopback). Set ${DANGEROUS_REMOTE_ENV}=1 to opt in explicitly.`, - ); - return "127.0.0.1"; - } - console.warn(`pi-chrome: ${DANGEROUS_REMOTE_ENV}=1 — binding bridge to ${RAW_HOST_ENV}. Bridge endpoints still require loopback or paired auth.`); - return RAW_HOST_ENV; -} -const DEFAULT_HOST = resolveDefaultHost(); +const DEFAULT_HOST = process.env.PI_CHROME_BRIDGE_HOST ?? "127.0.0.1"; const DEFAULT_PORT = Number(process.env.PI_CHROME_BRIDGE_PORT ?? "17318"); const DEFAULT_TIMEOUT_MS = 30_000; -const MAX_REQUEST_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB hard cap on inbound HTTP bodies -const MAX_QUEUE = 256; -const MAX_PENDING = 256; -const MAX_WAITERS = 4; - -function isLoopbackAddress(address: string | undefined): boolean { - if (!address) return false; - return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1"; -} - -// Locate the OS-level owner of `:port` via `lsof`. Returns undefined when lsof isn't on -// PATH (minimal Linux containers, Windows). Used by ChromeProfileBridge to decide whether -// to auto-takeover an incompatible existing owner at session_start. -function findPortOwnerPid(port: number): number | undefined { - try { - const result = spawnSync("sh", ["-lc", `lsof -nP -iTCP:${port} -sTCP:LISTEN -t 2>/dev/null | head -1`], { timeout: 3_000, encoding: "utf8" }); - if (result.status !== 0) return undefined; - const pid = Number(String(result.stdout).trim()); - return Number.isFinite(pid) && pid > 0 ? pid : undefined; - } catch { - return undefined; - } -} - -// Probe the owner's /status with a 1s timeout. Returns the parsed status object, or undefined -// when the owner is unresponsive (likely dead/wedged). Synchronous fetch via a child process -// avoids importing a fetch shim before the main event loop is up. -function probeBridgeStatus(host: string, port: number): { bridgeVersion?: string } | undefined { - try { - const result = spawnSync("curl", ["-sS", "--max-time", "1", `http://${host}:${port}/status`], { timeout: 2_000, encoding: "utf8" }); - if (result.status !== 0) return undefined; - return JSON.parse(String(result.stdout || "").trim() || "{}"); - } catch { - return undefined; - } -} - -// Daemon compat is checked at exact version match — not major.minor — so that even patch -// releases trigger an automatic kill+respawn. Patch releases routinely fix bugs in the daemon -// itself (e.g. /next lastSeenAt handling); without exact matching, a new Pi client joins an -// old buggy daemon and the fix never lands. -function daemonVersionCompatible(daemonVersion: string, clientVersion: string): boolean { - return daemonVersion === clientVersion; -} - -// Ensure the bundled daemon.cjs is copied to a versioned cache dir so each pi-chrome -// version has a stable on-disk daemon binary independent of nvm/npm install location. -function ensureVersionedDaemon(): string { - const target = join(homedir(), ".cache", "pi-chrome", PI_CHROME_VERSION, "daemon.cjs"); - const source = join(extensionRoot(), "daemon.cjs"); - try { - mkdirSync(dirname(target), { recursive: true, mode: 0o755 }); - // Copy idempotently: if shasum already matches, skip. Cheap because daemon.cjs is small. - let needCopy = true; - if (existsSync(target)) { - try { - const a = readFileSync(target); - const b = readFileSync(source); - needCopy = a.length !== b.length || createHash("sha256").update(a).digest("hex") !== createHash("sha256").update(b).digest("hex"); - } catch { needCopy = true; } - } - if (needCopy) copyFileSync(source, target); - } catch (error) { - console.warn(`pi-chrome: failed to stage daemon at ${target} (${(error as Error).message}); falling back to in-tree source.`); - return source; - } - return target; -} - -function daemonLogPath(): string { - return join(homedir(), ".cache", "pi-chrome", PI_CHROME_VERSION, "daemon.log"); -} - -// Spawn the daemon as a detached child so it survives the parent Pi exiting. The child is -// unref()'d immediately; from the Pi's perspective it's fire-and-forget. Returns the child's -// PID for diagnostic purposes only. -function spawnDaemon(host: string, port: number): number { - const daemonPath = ensureVersionedDaemon(); - const logPath = daemonLogPath(); - try { mkdirSync(dirname(logPath), { recursive: true }); } catch {} - let logFd: number; - try { logFd = openSync(logPath, "a"); } catch { logFd = openSync("/dev/null", "a"); } - const child = spawn(process.execPath, [ - daemonPath, - "--host", host, - "--port", String(port), - "--version", PI_CHROME_VERSION, - "--log", logPath, - ], { - detached: true, - stdio: ["ignore", logFd, logFd], - }); - child.unref(); - return child.pid ?? -1; -} - -// Wait up to `timeoutMs` for /status to respond with the expected version. Returns the -// observed status (or undefined on timeout). -function waitForDaemonReady(host: string, port: number, timeoutMs = 4_000): Record<string, unknown> | undefined { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const probed = probeBridgeStatus(host, port); - if (probed && typeof (probed as { bridgeVersion?: string }).bridgeVersion === "string" - && daemonVersionCompatible((probed as { bridgeVersion: string }).bridgeVersion, PI_CHROME_VERSION)) { - return probed; - } - spawnSync("sleep", ["0.1"]); - } - return undefined; -} - -function killProcessGracefully(pid: number): boolean { - try { process.kill(pid, "SIGTERM"); } catch { return false; } - // Spin briefly waiting for the port to free; escalate to SIGKILL if SIGTERM is ignored. - const deadline = Date.now() + 3_000; - while (Date.now() < deadline) { - try { process.kill(pid, 0); } catch { return true; } // kill(pid, 0) throws ESRCH when gone - spawnSync("sleep", ["0.2"]); - } - try { process.kill(pid, "SIGKILL"); } catch {} - const secondDeadline = Date.now() + 2_000; - while (Date.now() < secondDeadline) { - try { process.kill(pid, 0); } catch { return true; } - spawnSync("sleep", ["0.2"]); - } - return false; -} - -// ========================================================================================= -// Pairing + signed-envelope auth. -// -// Protocol v1. -// -// Threat model: protect against (a) ordinary web pages, (b) drive-by local processes / curl -// without the pairing secret, (c) malicious Chrome extensions stealing the command channel. -// Out of scope: same-user malware that can read $XDG_CONFIG_HOME, replace native messaging -// hosts, or attach a debugger to Pi/Chrome. -// -// Pairing flow: -// 1. User runs `/chrome pair` in Pi. Pi generates a 32-byte invite secret, presents it as -// `pcp_<base64url>`, copies to clipboard, and arms `/pair` for `PAIR_WINDOW_MS`. -// 2. User pastes the invite into the Chrome extension popup. Extension POSTs `/pair` with -// {extensionId, extensionNonce, mac(=HMAC(invite, "pair-v1|" + extensionId + "|" + extensionNonce))}. -// 3. Bridge verifies MAC, mints a `bridgeId` + derives two 32-byte HKDF keys: -// - extensionPairKey: signs/verifies /next and /result envelopes. -// - brokerKey: signs/verifies /command envelopes between peer Pi processes. -// Bridge replies signed with the invite secret so the extension knows it's talking to -// the same Pi process that issued the invite. Invite is destroyed. -// 4. Both sides persist their copies. Pi keeps the master record at -// ~/.config/pi/chrome-bridge.json mode 0600; extension keeps mirror in -// chrome.storage.local. -// -// Wire format on signed requests/responses: -// x-pi-chrome-auth: v1 ts=<ms> nonce=<base64url-16B> sig=<base64url-32B> -// where sig = HMAC-SHA256(key, canonicalSigningString). -// -// Canonical signing string (newline-separated): -// v1\n<direction>\n<method>\n<path>\n<extensionId>\n<bridgeId>\n<ts>\n<nonce>\n<sha256-hex(body)> -// -// direction ∈ { "ext->bridge", "bridge->ext", "peer->owner", "owner->peer" }. -// For empty bodies (GET /next), body = "". -// -// Replay defense: receiver requires |now - ts| <= MAX_CLOCK_SKEW_MS and rejects re-used -// nonces via a per-direction LRU. - -const AUTH_PROTOCOL = "v1"; -const AUTH_HEADER = "x-pi-chrome-auth"; -const PAIR_WINDOW_MS = 10 * 60_000; -const MAX_CLOCK_SKEW_MS = 30_000; -const NONCE_TTL_MS = 5 * 60_000; -const NONCE_CACHE_MAX = 4096; - -function configPath(): string { - const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); - return join(base, "pi", "chrome-bridge.json"); -} -function b64url(buf: Buffer): string { - return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} -function b64urlDecode(text: string): Buffer { - const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (text.length % 4)) % 4); - return Buffer.from(padded, "base64"); -} -function sha256Hex(input: string | Buffer): string { - return createHash("sha256").update(input).digest("hex"); -} -function hmacSign(key: Buffer, message: string): Buffer { - return createHmac("sha256", key).update(message).digest(); -} -function safeStringEqual(a: string, b: string): boolean { - const aBuf = Buffer.from(a, "utf8"); - const bBuf = Buffer.from(b, "utf8"); - if (aBuf.length !== bBuf.length) return false; - try { return timingSafeEqual(aBuf, bBuf); } catch { return false; } -} -function safeBufferEqual(a: Buffer, b: Buffer): boolean { - if (a.length !== b.length) return false; - try { return timingSafeEqual(a, b); } catch { return false; } -} - -type PairDirection = "ext->bridge" | "bridge->ext" | "peer->owner" | "owner->peer"; - -function canonicalString( - direction: PairDirection, - method: string, - path: string, - extensionId: string, - bridgeId: string, - ts: number, - nonce: string, - body: string, -): string { - return [AUTH_PROTOCOL, direction, method.toUpperCase(), path, extensionId, bridgeId, String(ts), nonce, sha256Hex(body)].join("\n"); -} - -function buildAuthHeader(key: Buffer, direction: PairDirection, method: string, path: string, extensionId: string, bridgeId: string, body: string): { header: string; ts: number; nonce: string } { - const ts = Date.now(); - const nonce = b64url(randomBytes(16)); - const sig = b64url(hmacSign(key, canonicalString(direction, method, path, extensionId, bridgeId, ts, nonce, body))); - return { header: `${AUTH_PROTOCOL} ts=${ts} nonce=${nonce} sig=${sig}`, ts, nonce }; -} - -function parseAuthHeader(raw: string | string[] | undefined): { ts: number; nonce: string; sig: string } | undefined { - const value = Array.isArray(raw) ? raw[0] : raw; - if (!value || typeof value !== "string") return undefined; - const parts = value.trim().split(/\s+/); - if (parts.length !== 4 || parts[0] !== AUTH_PROTOCOL) return undefined; - const kv: Record<string, string> = {}; - for (const p of parts.slice(1)) { - const eq = p.indexOf("="); - if (eq <= 0) return undefined; - kv[p.slice(0, eq)] = p.slice(eq + 1); - } - const ts = Number(kv.ts); - if (!Number.isFinite(ts) || !kv.nonce || !kv.sig) return undefined; - return { ts, nonce: kv.nonce, sig: kv.sig }; -} - -// Bounded LRU of (nonce -> ts) per direction for replay defense. Map iteration order is -// insertion order, so evicting `keys().next()` removes the oldest entry. Two callers: -// -// - `has(nonce)` checks duplicates *before* MAC verification (cheap; lets us reject obvious -// replays without wasting an HMAC compute). -// - `store(nonce, ts)` records the nonce *after* MAC verification succeeds, so an attacker -// sending fresh-nonce bogus signatures cannot pin memory or pollute the cache. -class NonceCache { - private readonly entries = new Map<string, number>(); - has(nonce: string): boolean { return this.entries.has(nonce); } - store(nonce: string, ts: number): void { - const now = Date.now(); - // Drop stale entries opportunistically. - for (const [k, t] of this.entries) { - if (now - t > NONCE_TTL_MS) this.entries.delete(k); else break; - } - // Hard cap: evict oldest insertions until we're under the limit, even if not stale. - while (this.entries.size >= NONCE_CACHE_MAX) { - const oldest = this.entries.keys().next().value; - if (oldest === undefined) break; - this.entries.delete(oldest); - } - this.entries.set(nonce, ts); - } -} - -type PersistedAuthState = { - protocol: "v1"; - bridgeId: string; - extensionId: string; - extensionPairKey: string; // base64url, shared with the Chrome extension - brokerKey: string; // base64url, Pi-side only — never sent to the extension -}; - -class BridgeAuth { - private state: PersistedAuthState | undefined; - private pendingInvite: { secret: Buffer; expiresAt: number } | undefined; - private extNonces = new NonceCache(); // request: ext->bridge - private peerNonces = new NonceCache(); // request: peer->owner - private ownerRespNonces = new NonceCache(); // response: owner->peer (verified peer-side) - private lastStateMtimeMs: number = 0; - private watcher: FSWatcher | undefined; - - constructor() { - this.load(); - this.startWatching(); - } - - private startWatching(): void { - // fs.watch the config file so cross-process changes (e.g. another Pi running `/chrome - // unpair`, or an external `rm` of the file) immediately propagate to this owner's - // in-memory state. Without this, an owner that already cached `state` keeps verifying - // signatures with stale keys long after the file is gone. fs.watch is best-effort - // across platforms; we also re-check mtime synchronously on every privileged request - // (see refreshIfStale) so a missed watch event can never silently desync. - const path = configPath(); - const dir = dirname(path); - try { mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch {} - try { - this.watcher = fsWatch(dir, { persistent: false }, (_event, filename) => { - if (!filename || filename.toString() !== "chrome-bridge.json") return; - this.refreshIfStale(); - }); - } catch (error) { - console.warn(`pi-chrome: fs.watch on ${dir} failed (${(error as Error).message}); falling back to per-request mtime checks.`); - } - } - - stop(): void { - try { this.watcher?.close(); } catch {} - this.watcher = undefined; - } - - // Synchronously re-stat the config file and reload if anything changed on disk. Called - // before every privileged verification so that cross-process mutations (peer unpair, - // external file edits) can never let stale in-memory keys validate a request. - refreshIfStale(): void { - const path = configPath(); - let mtimeMs = 0; - try { mtimeMs = statSync(path).mtimeMs; } catch { mtimeMs = -1; } - if (mtimeMs === this.lastStateMtimeMs) return; - this.lastStateMtimeMs = mtimeMs; - const prevExtId = this.state?.extensionId; - const prevBridgeId = this.state?.bridgeId; - this.load(); - const rotated = (this.state?.extensionId !== prevExtId) || (this.state?.bridgeId !== prevBridgeId); - if (rotated) { - // Keys rotated (or pairing cleared). Drop replay caches; old nonces signed under the - // previous keys are meaningless under the new ones and would otherwise needlessly - // occupy cache slots. - this.extNonces = new NonceCache(); - this.peerNonces = new NonceCache(); - this.ownerRespNonces = new NonceCache(); - } - } - - get paired(): boolean { this.refreshIfStale(); return Boolean(this.state); } - get bridgeId(): string { this.refreshIfStale(); return this.state?.bridgeId ?? ""; } - get pinnedExtensionId(): string { this.refreshIfStale(); return this.state?.extensionId ?? ""; } - - private load(): void { - try { - const raw = readFileSync(configPath(), "utf8"); - const parsed = JSON.parse(raw); - if (parsed?.protocol === "v1" && parsed.bridgeId && parsed.extensionId && parsed.extensionPairKey && parsed.brokerKey) { - this.state = parsed as PersistedAuthState; - } else { - // File exists but doesn't carry a valid v1 record (e.g. peer reset wrote `{}`). - // Drop in-memory state so this owner stops accepting signatures keyed by the - // now-deleted record. - this.state = undefined; - } - } catch { - // File missing or unreadable: treat as unpaired. - this.state = undefined; - } - } - - private persist(): void { - if (!this.state) return; - const path = configPath(); - try { - const dir = dirname(path); - mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(path, JSON.stringify(this.state), { mode: 0o600 }); - try { chmodSync(path, 0o600); } catch {} - try { chmodSync(dir, 0o700); } catch {} - try { this.lastStateMtimeMs = statSync(path).mtimeMs; } catch {} - } catch (error) { - console.warn(`pi-chrome: failed to persist auth state at ${path}: ${(error as Error).message}`); - } - } - - // `/chrome pair` calls this; returns the invite to display to the user. - startPairWindow(): string { - const secret = randomBytes(32); - this.pendingInvite = { secret, expiresAt: Date.now() + PAIR_WINDOW_MS }; - return `pcp_${b64url(secret)}`; - } - - cancelPairWindow(): void { this.pendingInvite = undefined; } - - pairWindowActive(): boolean { - if (!this.pendingInvite) return false; - if (this.pendingInvite.expiresAt < Date.now()) { this.pendingInvite = undefined; return false; } - return true; - } - - reset(): void { - this.state = undefined; - this.pendingInvite = undefined; - const path = configPath(); - try { writeFileSync(path, JSON.stringify({}), { mode: 0o600 }); } catch {} - try { chmodSync(path, 0o600); } catch {} - try { chmodSync(dirname(path), 0o700); } catch {} - } - - // Verify extension's /pair POST. Returns the response payload + signed header on success. - completePairing(body: { extensionId?: string; extensionNonce?: string; mac?: string }): { response: Record<string, unknown>; signedHeader: string } { - if (!this.pairWindowActive()) throw new Error("no active pairing window; run /chrome pair first"); - if (!body.extensionId || !body.extensionNonce || !body.mac) throw new Error("pair: missing fields"); - // Chrome extension IDs are 32 lowercase a-p characters. Reject uppercase or mixed case - // so a malicious caller can't smuggle a value that looks like one ID and matches another - // after case folding. - if (!/^[a-p]{32}$/.test(body.extensionId)) throw new Error("pair: invalid extensionId"); - const invite = this.pendingInvite!.secret; - const expected = hmacSign(invite, `pair-v1|${body.extensionId}|${body.extensionNonce}`); - const supplied = b64urlDecode(body.mac); - if (!safeBufferEqual(expected, supplied)) throw new Error("pair: bad mac"); - const bridgeId = b64url(randomBytes(16)); - const extensionPairKey = Buffer.from(hkdfSync("sha256", invite, Buffer.from("pi-chrome-pair-salt"), Buffer.from("ext-pair-key:" + bridgeId), 32)); - const brokerKey = Buffer.from(hkdfSync("sha256", invite, Buffer.from("pi-chrome-pair-salt"), Buffer.from("broker-key:" + bridgeId), 32)); - this.state = { - protocol: "v1", - bridgeId, - extensionId: body.extensionId, - extensionPairKey: b64url(extensionPairKey), - brokerKey: b64url(brokerKey), - }; - this.persist(); - this.pendingInvite = undefined; - // Sign response with the invite (extension still has it) so it can verify before storing - // the long-lived keys returned in the body. - // - // IMPORTANT: do NOT include brokerKey in the response. The extension never uses it; - // it is exclusively for peer Pi sessions reading `~/.config/pi/chrome-bridge.json`. - // Leaking it into the browser would broaden blast radius if the extension is later - // compromised. - const response: Record<string, unknown> = { - ok: true, - bridgeId, - extensionId: body.extensionId, - extensionPairKey: this.state.extensionPairKey, - protocol: AUTH_PROTOCOL, - }; - const bodyText = JSON.stringify(response); - const { header } = buildAuthHeader(invite, "bridge->ext", "POST", "/pair", body.extensionId, bridgeId, bodyText); - return { response, signedHeader: header }; - } - - private verifyEnvelope( - key: Buffer, - cache: NonceCache, - direction: PairDirection, - method: string, - path: string, - extensionId: string, - bridgeId: string, - header: string | string[] | undefined, - body: string, - ): boolean { - const parsed = parseAuthHeader(header); - if (!parsed) return false; - if (Math.abs(Date.now() - parsed.ts) > MAX_CLOCK_SKEW_MS) return false; - // Cheap replay check BEFORE the HMAC compute: reject duplicates immediately. - if (cache.has(parsed.nonce)) return false; - const expected = hmacSign(key, canonicalString(direction, method, path, extensionId, bridgeId, parsed.ts, parsed.nonce, body)); - let supplied: Buffer; - try { supplied = b64urlDecode(parsed.sig); } catch { return false; } - if (!safeBufferEqual(expected, supplied)) return false; - // Only record AFTER MAC verifies so bogus-signature spam cannot bloat the cache. - cache.store(parsed.nonce, parsed.ts); - return true; - } - - verifyExtensionRequest(method: string, path: string, header: string | string[] | undefined, originExtensionId: string, body: string): boolean { - this.refreshIfStale(); - if (!this.state) return false; - if (!safeStringEqual(originExtensionId, this.state.extensionId)) return false; - return this.verifyEnvelope( - b64urlDecode(this.state.extensionPairKey), - this.extNonces, - "ext->bridge", - method, - path, - this.state.extensionId, - this.state.bridgeId, - header, - body, - ); - } - - signBridgeResponse(method: string, path: string, body: string): string | undefined { - if (!this.state) return undefined; - const { header } = buildAuthHeader( - b64urlDecode(this.state.extensionPairKey), - "bridge->ext", - method, - path, - this.state.extensionId, - this.state.bridgeId, - body, - ); - return header; - } - - verifyPeerRequest(method: string, path: string, header: string | string[] | undefined, body: string): boolean { - this.refreshIfStale(); - if (!this.state) return false; - return this.verifyEnvelope( - b64urlDecode(this.state.brokerKey), - this.peerNonces, - "peer->owner", - method, - path, - this.state.extensionId, - this.state.bridgeId, - header, - body, - ); - } - - signPeerRequest(method: string, path: string, body: string): string | undefined { - if (!this.state) return undefined; - const { header } = buildAuthHeader( - b64urlDecode(this.state.brokerKey), - "peer->owner", - method, - path, - this.state.extensionId, - this.state.bridgeId, - body, - ); - return header; - } - - signOwnerResponse(method: string, path: string, body: string): string | undefined { - if (!this.state) return undefined; - const { header } = buildAuthHeader( - b64urlDecode(this.state.brokerKey), - "owner->peer", - method, - path, - this.state.extensionId, - this.state.bridgeId, - body, - ); - return header; - } - - verifyOwnerResponse(method: string, path: string, header: string | string[] | undefined, body: string): boolean { - this.refreshIfStale(); - if (!this.state) return false; - const parsed = parseAuthHeader(header); - if (!parsed) return false; - if (Math.abs(Date.now() - parsed.ts) > MAX_CLOCK_SKEW_MS) return false; - if (this.ownerRespNonces.has(parsed.nonce)) return false; - const expected = hmacSign( - b64urlDecode(this.state.brokerKey), - canonicalString("owner->peer", method, path, this.state.extensionId, this.state.bridgeId, parsed.ts, parsed.nonce, body), - ); - let supplied: Buffer; - try { supplied = b64urlDecode(parsed.sig); } catch { return false; } - if (!safeBufferEqual(expected, supplied)) return false; - this.ownerRespNonces.store(parsed.nonce, parsed.ts); - return true; - } -} const MAX_TEXT_CHARS = 30_000; const MAX_ELEMENTS = 80; @@ -691,59 +115,14 @@ function summarizeActionResult(result: unknown): string | undefined { return parts.length ? parts.join("; ") : undefined; } -class RequestBodyTooLargeError extends Error { - constructor() { super("request body too large"); this.name = "RequestBodyTooLargeError"; } -} -function readRequestBody(request: IncomingMessage, maxBytes = MAX_REQUEST_BODY_BYTES): Promise<string> { +function readRequestBody(request: IncomingMessage): Promise<string> { return new Promise((resolveBody, rejectBody) => { - // Reject too-large bodies up front when Content-Length advertises it. - const declared = Number(request.headers["content-length"]); - if (Number.isFinite(declared) && declared > maxBytes) { - rejectBody(new RequestBodyTooLargeError()); - request.resume(); - return; - } const chunks: Buffer[] = []; - let size = 0; - request.on("data", (chunk) => { - const buf = Buffer.from(chunk); - size += buf.length; - if (size > maxBytes) { - rejectBody(new RequestBodyTooLargeError()); - request.destroy(); - return; - } - chunks.push(buf); - }); + request.on("data", (chunk) => chunks.push(Buffer.from(chunk))); request.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8"))); request.on("error", rejectBody); }); } -async function readJsonBody<T = unknown>(request: IncomingMessage, response: ServerResponse, extraHeaders?: Record<string, string>): Promise<T | undefined> { - let raw: string; - try { - raw = await readRequestBody(request); - } catch (error) { - if (error instanceof RequestBodyTooLargeError) { - sendJson(response, 413, { ok: false, error: "request body too large" }, extraHeaders); - } else { - sendJson(response, 400, { ok: false, error: `failed to read request body: ${(error as Error).message}` }, extraHeaders); - } - return undefined; - } - let parsed: unknown; - try { - parsed = JSON.parse(raw || "{}"); - } catch (error) { - sendJson(response, 400, { ok: false, error: `invalid JSON body: ${(error as Error).message}` }, extraHeaders); - return undefined; - } - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - sendJson(response, 400, { ok: false, error: "expected JSON object" }, extraHeaders); - return undefined; - } - return parsed as T; -} function corsHeadersFor(request: IncomingMessage): Record<string, string> { const origin = String(request.headers.origin ?? ""); @@ -751,8 +130,8 @@ function corsHeadersFor(request: IncomingMessage): Record<string, string> { return { "access-control-allow-origin": origin, "access-control-allow-methods": "GET,POST,OPTIONS", - "access-control-allow-headers": `content-type, ${AUTH_HEADER}`, - "access-control-expose-headers": `x-pi-chrome-version, ${AUTH_HEADER}`, + "access-control-allow-headers": "content-type", + "access-control-expose-headers": "x-pi-chrome-version", "vary": "origin", }; } @@ -785,23 +164,11 @@ class ChromeProfileBridge { private lastSeenAt: number | undefined; private clientName: string | undefined; private mode: "server" | "client" | undefined; - private readonly auth: BridgeAuth; constructor( private readonly host: string, private readonly port: number, - ) { - this.auth = new BridgeAuth(); - } - - get bridgeAuth(): BridgeAuth { return this.auth; } - - private extensionIdFromOrigin(request: IncomingMessage): string | undefined { - const origin = String(request.headers.origin ?? ""); - // Match Chrome's own format strictly: exact lowercase a-p, no trailing slash or path. - const match = origin.match(/^chrome-extension:\/\/([a-p]{32})$/); - return match ? match[1] : undefined; - } + ) {} get url(): string { return `http://${this.host}:${this.port}`; @@ -823,129 +190,33 @@ class ChromeProfileBridge { clientName: this.clientName, queuedCommands: this.queue.length, pendingCommands: this.pending.size, - // Version is broadcast in /status so peer Pi sessions can decide at session_start - // whether to join as client (compatible same major.minor) or to auto-takeover an - // older/wedged owner. - bridgeVersion: PI_CHROME_VERSION, }; } - // 0.17+ daemon model: pi-chrome no longer hosts the HTTP server in this Pi process. The - // bridge runs as a detached daemon (extensions/chrome-profile-bridge/daemon.cjs, staged at - // ~/.cache/pi-chrome/<version>/daemon.cjs) and Pi sessions are uniform clients that POST - // signed /command envelopes. `start()` ensures the daemon is running and up-to-date; - // `send()` continues to go through the same sendViaOwner path used by previous releases' - // client mode. async start(): Promise<void> { - if (this.mode === "client") return; - await this.ensureDaemonRunning(); - } - - private async ensureDaemonRunning(): Promise<void> { - const probed = probeBridgeStatus(this.host, this.port); - const ownerVersion = typeof (probed as { bridgeVersion?: string })?.bridgeVersion === "string" - ? (probed as { bridgeVersion: string }).bridgeVersion - : undefined; - const compatible = ownerVersion ? daemonVersionCompatible(ownerVersion, PI_CHROME_VERSION) : false; - if (probed && compatible) { - this.mode = "client"; - return; - } - if (probed && !compatible) { - // Incompatible owner; auto-takeover (kill + replace with our-version daemon). - await this.handleEaddrinuse(); - if (this.mode === "client") return; - // fall through to spawn - } - const pid = spawnDaemon(this.host, this.port); - const ready = waitForDaemonReady(this.host, this.port, 4_000); - if (!ready) { - console.warn(`pi-chrome: daemon spawn (pid ${pid}) did not become ready within 4s; check ${daemonLogPath()}`); - } - this.mode = "client"; - } - - // Auto-takeover when the existing owner is unresponsive or running an incompatible - // version. After kill, a fresh daemon is spawned by `ensureDaemonRunning`. - private async handleEaddrinuse(): Promise<void> { - const probed = probeBridgeStatus(this.host, this.port); - const ownerVersion = typeof probed?.bridgeVersion === "string" ? probed.bridgeVersion : undefined; - const compatible = ownerVersion ? daemonVersionCompatible(ownerVersion, PI_CHROME_VERSION) : false; - if (probed && compatible) { - this.mode = "client"; - return; - } - // Either no response at all, or the owner is on an incompatible (older or future) - // version that won't honor the same wire protocol. Try to take over. - const pid = findPortOwnerPid(this.port); - const reason = !probed - ? "unresponsive" - : ownerVersion - ? `version ${ownerVersion} incompatible with ${PI_CHROME_VERSION}` - : "unknown version"; - if (!pid || pid === process.pid) { - console.warn(`pi-chrome: bridge port :${this.port} held by ${reason} owner; cannot identify PID via lsof. Falling back to client mode (commands may fail).`); - this.mode = "client"; - return; - } - console.warn(`pi-chrome: bridge port :${this.port} held by PID ${pid} (${reason}); attempting takeover.`); - if (!killProcessGracefully(pid)) { - console.warn(`pi-chrome: failed to kill PID ${pid}; falling back to client mode.`); - this.mode = "client"; - return; - } - console.warn(`pi-chrome: killed PID ${pid}; will spawn the daemon next.`); - } - - // Probe the daemon's /status and return its view (which is the only source of truth for - // `connected` / `lastSeenAt` in 0.17+, since the extension polls the daemon, not Pi). - async daemonStatus(timeoutMs = 1_000): Promise<Record<string, unknown> | undefined> { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + if (this.server || this.mode === "client") return; + this.server = createServer((request, response) => { + void this.handle(request, response).catch((error) => { + sendJson(response, 500, { error: (error as Error).message }); + }); + }); try { - const res = await fetch(`${this.url}/status`, { signal: controller.signal }); - if (!res.ok) return undefined; - return (await res.json()) as Record<string, unknown>; - } catch { - return undefined; - } finally { - clearTimeout(timer); - } - } - - // Signed admin RPC to the daemon. Used for state mutations the daemon owns: arm-pair-window, - // reset, status. Pre-pair (when the daemon has no state file), unauthenticated; once paired, - // the daemon requires the broker-key envelope (same signature as /command). - async admin(op: string, body: Record<string, unknown> = {}): Promise<Record<string, unknown>> { - const payload = JSON.stringify({ op, ...body }); - const headers: Record<string, string> = { "content-type": "application/json" }; - if (this.auth.paired) { - const sig = this.auth.signPeerRequest("POST", "/admin", payload); - if (sig) headers[AUTH_HEADER] = sig; - } - const doFetch = () => fetch(`${this.url}/admin`, { method: "POST", headers, body: payload }); - let response: Response; - try { response = await doFetch(); } - catch (error) { - // Daemon isn't reachable. Self-heal by re-running the spawn path, then retry once. - // Covers the common case where the daemon idle-shut-down since the Pi session started - // (or was killed manually) but the Pi-side bridge still thinks mode === "client". - console.warn(`pi-chrome: /admin fetch failed (${(error as Error).message}); re-spawning daemon and retrying`); - await this.ensureDaemonRunning(); - response = await doFetch(); - } - const respText = await response.text(); - // Verify owner sig when present (paired state). For pre-pair calls, no sig to verify. - if (this.auth.paired) { - const respAuth = response.headers.get(AUTH_HEADER) ?? undefined; - if (!this.auth.verifyOwnerResponse("POST", "/admin", respAuth, respText)) { - throw new Error("daemon /admin response signature invalid"); - } + await new Promise<void>((resolveStart, rejectStart) => { + this.server!.once("error", rejectStart); + this.server!.listen(this.port, this.host, () => { + this.server!.off("error", rejectStart); + resolveStart(); + }); + }); + this.mode = "server"; + } catch (error) { + this.server.close(); + this.server = undefined; + if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error; + // Another Pi session already owns the bridge port. Use it as the shared + // machine-local broker so multiple Pi sessions can control Chrome at once. + this.mode = "client"; } - let parsed: Record<string, unknown>; - try { parsed = JSON.parse(respText); } catch { throw new Error(`daemon /admin returned non-JSON: ${respText.slice(0, 200)}`); } - if (!response.ok || parsed.ok === false) throw new Error(String(parsed.error ?? `daemon /admin HTTP ${response.status}`)); - return parsed; } stop(): void { @@ -953,7 +224,6 @@ class ChromeProfileBridge { this.mode = undefined; return; } - this.auth.stop(); for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("Chrome profile bridge stopped")); @@ -968,15 +238,12 @@ class ChromeProfileBridge { } send(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> { - // 0.17+: all sends go through the daemon over signed /command (sendViaOwner). The - // previous in-process queue path (sendLocal) is dead code retained only to support - // the legacy ChromeProfileBridge.handle() HTTP server below, which is itself dead in - // 0.17 (the daemon owns the listener). - return this.sendViaOwner(action, params, timeoutMs, signal); + if (this.mode === "client") return this.sendViaOwner(action, params, timeoutMs, signal); + return this.sendLocal(action, params, timeoutMs, signal); } private sendLocal(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> { - const id = randomUUID(); + const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; const command = { id, action, params }; return new Promise((resolveCommand, rejectCommand) => { if (signal?.aborted) { @@ -1003,12 +270,6 @@ class ChromeProfileBridge { ), ); }, timeoutMs); - if (this.pending.size >= MAX_PENDING) { - clearTimeout(timer); - cleanupAbort(); - rejectCommand(new Error("Chrome bridge has too many in-flight commands; retry shortly")); - return; - } this.pending.set(id, { command, resolve: (value) => { cleanupAbort(); resolveCommand(value); }, @@ -1021,16 +282,6 @@ class ChromeProfileBridge { } private async sendViaOwner(action: string, params: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<unknown> { - // Self-heal: re-spawn the daemon if it's gone. Cheap when daemon is up (a no-op since - // the version probe inside ensureDaemonRunning returns compatible immediately). - try { - const probed = probeBridgeStatus(this.host, this.port); - if (!probed) await this.ensureDaemonRunning(); - } catch { /* fall through to the fetch — will surface a clearer error if anything is - actually broken. */ } - if (!this.auth.paired) { - throw new Error("Chrome bridge not paired with this Pi config. Run /chrome pair in the Pi session that owns the bridge first."); - } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs + 2_000); const forwardAbort = () => controller.abort(); @@ -1039,33 +290,18 @@ class ChromeProfileBridge { else signal.addEventListener("abort", forwardAbort, { once: true }); } try { - // Per-request signed envelope, keyed by brokerKey. No bearer token transmitted. - const body = JSON.stringify({ action, params, timeoutMs }); - const sigHeader = this.auth.signPeerRequest("POST", "/command", body); - if (!sigHeader) throw new Error("Chrome bridge auth not available"); const response = await fetch(`${this.url}/command`, { method: "POST", - headers: { "content-type": "application/json", [AUTH_HEADER]: sigHeader }, - body, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action, params, timeoutMs }), signal: controller.signal, }); - const respText = await response.text(); - // Legacy detection: an older pi-chrome bridge owner returns 404 for /command before - // signed envelopes existed. Match that BEFORE signature verification (the legacy - // owner has no key to sign with), since this is an upgrade hint, not a data path. + const payload = (await response.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; error?: string }; if (response.status === 404) { throw new Error( "A running Pi session owns the Chrome bridge but is using an older pi-chrome without multi-session support. Restart that Pi session after `pi update`, then retry.", ); } - // Verify the owner's signed response BEFORE trusting any field in the body, including - // error strings. A pre-bind impostor without the broker key cannot produce a valid - // signature, so we refuse to surface its arbitrary error text. - const respAuth = response.headers.get(AUTH_HEADER) ?? undefined; - if (!this.auth.verifyOwnerResponse("POST", "/command", respAuth, respText)) { - throw new Error("Chrome bridge owner response signature invalid; refusing result"); - } - const payload = (() => { try { return JSON.parse(respText); } catch { return {}; } })() as { ok?: boolean; result?: unknown; error?: string }; if (!response.ok || !payload.ok) throw new Error(payload.error ?? `Chrome bridge owner HTTP ${response.status}`); return payload.result; } catch (error) { @@ -1080,45 +316,15 @@ class ChromeProfileBridge { } } - - private enqueue(command: BridgeCommand): void { const waiter = this.waiters.shift(); - if (waiter) { waiter(command); return; } - if (this.queue.length >= MAX_QUEUE) { - // Reject the just-enqueued command via its pending entry instead of silently - // dropping. Caller's promise rejects through the regular timer cleanup. - const pending = this.pending.get(command.id); - if (pending) { - clearTimeout(pending.timer); - this.pending.delete(command.id); - pending.reject(new Error("Chrome bridge queue full; refusing new command")); - } - return; - } - this.queue.push(command); + if (waiter) waiter(command); + else this.queue.push(command); } private async handle(request: IncomingMessage, response: ServerResponse): Promise<void> { const url = new URL(request.url ?? "/", this.url); const corsHeaders = corsHeadersFor(request); - // Loopback enforcement applies to every endpoint, regardless of how the listening host was - // configured. Even with PI_CHROME_BRIDGE_DANGEROUS_REMOTE=1 we never accept non-loopback - // connections on /next, /result, /command, or /status without future authenticated - // transports. - if (!isLoopbackAddress(request.socket.remoteAddress ?? undefined)) { - sendJson(response, 403, { ok: false, error: "bridge accepts only loopback connections" }); - return; - } - // Top-level Content-Length precheck so oversized bodies are 413'd before any auth or - // per-endpoint logic runs. Streaming size enforcement in readRequestBody still applies - // for chunked requests that omit Content-Length. - const declaredLen = Number(request.headers["content-length"]); - if (Number.isFinite(declaredLen) && declaredLen > MAX_REQUEST_BODY_BYTES) { - sendJson(response, 413, { ok: false, error: "request body too large" }); - request.resume(); - return; - } if (request.method === "OPTIONS") { if (!isBrowserOriginAllowed(request)) { sendJson(response, 403, { ok: false, error: "browser origin not allowed" }); @@ -1131,78 +337,25 @@ class ChromeProfileBridge { sendJson(response, 200, this.status()); return; } - if (request.method === "POST" && url.pathname === "/pair") { - // /pair is browser-originated (extension popup posts here). Loopback already enforced. - if (!isBrowserOriginAllowed(request)) { - sendJson(response, 403, { ok: false, error: "browser origin required" }, corsHeaders); - return; - } - if (!this.auth.pairWindowActive()) { - sendJson(response, 403, { ok: false, error: "no active pairing window; run /chrome pair in Pi first" }, corsHeaders); - return; - } - const body = await readJsonBody<{ extensionId?: string; extensionNonce?: string; mac?: string }>(request, response, corsHeaders); - if (!body) return; - // Pin the extension ID to the one the request originates from, not the body claim. - const originExt = this.extensionIdFromOrigin(request); - if (!originExt) { - sendJson(response, 403, { ok: false, error: "chrome-extension origin required for pair" }, corsHeaders); - return; - } - if (body.extensionId && body.extensionId !== originExt) { - sendJson(response, 400, { ok: false, error: "extensionId mismatch with Origin" }, corsHeaders); - return; - } - try { - const { response: payload, signedHeader } = this.auth.completePairing({ ...body, extensionId: originExt }); - sendJson(response, 200, payload, { ...corsHeaders, [AUTH_HEADER]: signedHeader }); - } catch (error) { - sendJson(response, 401, { ok: false, error: (error as Error).message }, corsHeaders); - } - return; - } if (request.method === "POST" && url.pathname === "/command") { if (!isLocalProcessRequest(request)) { sendJson(response, 403, { ok: false, error: "Chrome commands are accepted only from local Pi processes" }); return; } - if (!this.auth.paired) { - sendJson(response, 401, { ok: false, error: "bridge not paired; run /chrome pair" }); - return; - } - // Read body first so we can verify the MAC over the exact payload received. - const rawBody = await (async () => { - try { return await readRequestBody(request); } - catch (error) { - if (error instanceof RequestBodyTooLargeError) sendJson(response, 413, { ok: false, error: "request body too large" }); - else sendJson(response, 400, { ok: false, error: `failed to read body: ${(error as Error).message}` }); - return undefined; - } - })(); - if (rawBody === undefined) return; - if (!this.auth.verifyPeerRequest("POST", "/command", request.headers[AUTH_HEADER], rawBody)) { - sendJson(response, 401, { ok: false, error: "missing or invalid bridge auth header" }); - return; - } - let body: { action?: string; params?: Record<string, unknown>; timeoutMs?: number }; - try { body = JSON.parse(rawBody || "{}"); } - catch (error) { sendJson(response, 400, { ok: false, error: `invalid JSON body: ${(error as Error).message}` }); return; } - if (!body.action || typeof body.action !== "string") { + const body = JSON.parse(await readRequestBody(request)) as { + action?: string; + params?: Record<string, unknown>; + timeoutMs?: number; + }; + if (!body.action) { sendJson(response, 400, { ok: false, error: "Missing command action" }); return; } - const timeoutMs = Math.min(Math.max(Number(body.timeoutMs) || DEFAULT_TIMEOUT_MS, 1_000), 5 * 60_000); try { - const result = await this.sendLocal(body.action, body.params ?? {}, timeoutMs); - const respBody = JSON.stringify({ ok: true, result }); - const sigHeader = this.auth.signOwnerResponse("POST", "/command", respBody); - response.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...(sigHeader ? { [AUTH_HEADER]: sigHeader } : {}) }); - response.end(respBody); + const result = await this.sendLocal(body.action, body.params ?? {}, body.timeoutMs ?? DEFAULT_TIMEOUT_MS); + sendJson(response, 200, { ok: true, result }); } catch (error) { - const respBody = JSON.stringify({ ok: false, error: (error as Error).message }); - const sigHeader = this.auth.signOwnerResponse("POST", "/command", respBody); - response.writeHead(504, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...(sigHeader ? { [AUTH_HEADER]: sigHeader } : {}) }); - response.end(respBody); + sendJson(response, 504, { ok: false, error: (error as Error).message }); } return; } @@ -1211,46 +364,6 @@ class ChromeProfileBridge { sendJson(response, 403, { ok: false, error: "browser origin not allowed" }); return; } - const originExt = this.extensionIdFromOrigin(request); - const currentVersion = readPiChromeVersion(); - // Pre-pairing path: return a benign idle response so old/unpaired extensions can still - // see the version-mismatch reload hint, but never deliver commands. - if (!this.auth.paired) { - sendJson(response, 200, { type: "none", needsPairing: true, expectedExtensionVersion: currentVersion }, - { ...corsHeaders, "x-pi-chrome-version": currentVersion }); - return; - } - // Paired: pin the request origin to the paired extension and require signed envelope. - // - // We distinguish two failure modes: - // 1. Origin not pinned, OR origin pinned but auth header absent. Return 200 idle + - // version header so older extensions that pre-date signed envelopes (no header) - // can still see the version mismatch and auto-reload. They never receive - // commands; this is purely a migration helper. - // 2. Pinned origin sent an auth header but it failed verification (bad sig, replayed - // nonce, ts skew). That is an attack signal or a stale-key paired extension; we - // return a hard 401 with the version header so the caller backs off and the user - // sees a clear failure in logs. - const hasAuthHeader = Boolean(request.headers[AUTH_HEADER]); - if (!originExt || originExt !== this.auth.pinnedExtensionId) { - sendJson(response, 200, { type: "none", needsPairing: true, expectedExtensionVersion: currentVersion }, - { ...corsHeaders, "x-pi-chrome-version": currentVersion }); - return; - } - if (!hasAuthHeader) { - sendJson(response, 200, { type: "none", needsPairing: true, expectedExtensionVersion: currentVersion }, - { ...corsHeaders, "x-pi-chrome-version": currentVersion }); - return; - } - if (!this.auth.verifyExtensionRequest("GET", "/next", request.headers[AUTH_HEADER], originExt, "")) { - sendJson(response, 401, { ok: false, error: "invalid /next auth (bad sig, replayed nonce, or stale ts)" }, - { ...corsHeaders, "x-pi-chrome-version": currentVersion }); - return; - } - if (this.waiters.length >= MAX_WAITERS) { - sendJson(response, 429, { ok: false, error: "too many concurrent /next pollers" }, corsHeaders); - return; - } this.lastSeenAt = Date.now(); this.clientName = url.searchParams.get("name") ?? undefined; let aborted = false; @@ -1266,22 +379,21 @@ class ChromeProfileBridge { }); } if (aborted) { + // Long-poll connection died before we could deliver. Requeue any command we pulled + // so the next live /next picks it up instead of dropping it on the floor. if (command) this.queue.unshift(command); return; } - const payload = command - ? { type: "command", command, expectedExtensionVersion: currentVersion } - : { type: "none", expectedExtensionVersion: currentVersion }; - const respBody = JSON.stringify(payload); - const sigHeader = this.auth.signBridgeResponse("GET", "/next", respBody); - response.writeHead(200, { - "content-type": "application/json; charset=utf-8", - "cache-control": "no-store", - ...corsHeaders, - "x-pi-chrome-version": currentVersion, - ...(sigHeader ? { [AUTH_HEADER]: sigHeader } : {}), - }); - response.end(respBody); + // Re-read version on every /next so bumping package.json takes effect without pi restart. + const currentVersion = readPiChromeVersion(); + sendJson( + response, + 200, + command + ? { type: "command", command, expectedExtensionVersion: currentVersion } + : { type: "none", expectedExtensionVersion: currentVersion }, + { ...corsHeaders, "x-pi-chrome-version": currentVersion }, + ); return; } if (request.method === "POST" && url.pathname === "/result") { @@ -1289,35 +401,8 @@ class ChromeProfileBridge { sendJson(response, 403, { ok: false, error: "browser origin not allowed" }); return; } - if (!this.auth.paired) { - sendJson(response, 401, { ok: false, error: "bridge not paired" }, corsHeaders); - return; - } - const originExt = this.extensionIdFromOrigin(request); - if (!originExt || originExt !== this.auth.pinnedExtensionId) { - sendJson(response, 403, { ok: false, error: "extension origin not pinned" }, corsHeaders); - return; - } this.lastSeenAt = Date.now(); - // Read raw body, verify MAC over it, then parse. - let rawBody: string; - try { rawBody = await readRequestBody(request); } - catch (error) { - if (error instanceof RequestBodyTooLargeError) sendJson(response, 413, { ok: false, error: "request body too large" }, corsHeaders); - else sendJson(response, 400, { ok: false, error: `failed to read body: ${(error as Error).message}` }, corsHeaders); - return; - } - if (!this.auth.verifyExtensionRequest("POST", "/result", request.headers[AUTH_HEADER], originExt, rawBody)) { - sendJson(response, 401, { ok: false, error: "invalid /result auth" }, corsHeaders); - return; - } - let result: BridgeResult; - try { result = JSON.parse(rawBody || "{}") as BridgeResult; } - catch (error) { sendJson(response, 400, { ok: false, error: `invalid JSON: ${(error as Error).message}` }, corsHeaders); return; } - if (!result.id || typeof result.id !== "string") { - sendJson(response, 400, { ok: false, error: "missing command id" }, corsHeaders); - return; - } + const result = JSON.parse(await readRequestBody(request)) as BridgeResult; const pending = this.pending.get(result.id); if (!pending) { sendJson(response, 404, { ok: false, error: "unknown command id" }, corsHeaders); @@ -1401,16 +486,6 @@ export default function (pi: ExtensionAPI): void { } }; - // Status bar reflects whether chrome_* tools are currently usable for this session. We only - // surface the indicator when authorized so an idle pi-chrome doesn't take up footer space. - const updateChromeStatus = (ctx: ExtensionContext): void => { - if (chromeControlAuthorized()) { - ctx.ui.setStatus("chrome", `${ctx.ui.theme.fg("success", "●")} Chrome Bridge`); - } else { - ctx.ui.setStatus("chrome", undefined); - } - }; - const authorizedBridgeSend = (action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> => { requireChromeControlAuthorized(); return bridge.send(action, params, timeoutMs, signal); @@ -1432,11 +507,14 @@ export default function (pi: ExtensionAPI): void { pi.on("session_start", async (_event, ctx) => { await bridge.start(); - // No setup notify or always-on status entry. Onboarding (/chrome onboard) and pairing - // (/chrome pair) print actionable instructions when invoked; surfacing them at every - // session_start was noisy for users who weren't using chrome_* tools that session. The - // status bar lights up only after /chrome authorize. - updateChromeStatus(ctx); + const status = bridge.status(); + ctx.ui.setStatus("chrome", `Chrome bridge :${DEFAULT_PORT}`); + ctx.ui.notify( + status.mode === "client" + ? `pi-chrome connected (sharing the Chrome connection an earlier pi session opened). Run /chrome authorize before using chrome_* tools.` + : `pi-chrome is ready and waiting for the Chrome companion to connect. Run /chrome onboard to install it, then /chrome authorize to allow chrome_* tools.`, + "info", + ); }); pi.on("session_shutdown", () => { @@ -1458,7 +536,7 @@ Capability model (important): - Input tools return structured details and support \`includeSnapshot=true\` on click/type/fill/key. Use the fresh snapshot to verify state instead of repeating blindly. Usage rules: -1. If a chrome_* tool says Chrome control is locked or the bridge is not paired, ask the user to run \`/chrome pair\` (one-time) and \`/chrome authorize\` before retrying. +1. If a chrome_* tool says Chrome control is locked, ask the user to run \`/chrome authorize\` before retrying. 2. \`chrome_snapshot\` before clicking/typing; pass \`uid\` over \`selector\`. 3. \`includeSnapshot=true\` on click/type/fill/key to verify in one round trip. 4. If \`chrome_evaluate\` returns null when you expected a value, the expression evaluated to null/undefined in the page; surface the value via \`JSON.stringify\` to confirm. @@ -1475,53 +553,37 @@ Usage rules: ctx.ui.notify("Checking pi-chrome…", "info"); const lines: string[] = [`pi-chrome v${PI_CHROME_VERSION}`]; const status = bridge.status(); - lines.push(`• This pi session talks to the pi-chrome daemon (every session is a peer in 0.17+).`); - if (bridge.bridgeAuth.paired) { - lines.push(`✓ Bridge paired with extension ${bridge.bridgeAuth.pinnedExtensionId} (bridgeId ${bridge.bridgeAuth.bridgeId}).`); - } else { - lines.push("✗ Bridge not yet paired with the Chrome extension. Run /chrome pair, paste the invite into the extension popup, then re-run /chrome doctor."); - } + const roleLabel = status.mode === "client" ? "sharing another pi session's connection" : "running the Chrome connection for this machine"; + lines.push(`• This pi session is ${roleLabel}.`); let extensionAlive = false; let versionMismatch = false; - // Short-circuit when the bridge can't possibly round-trip: unpaired (commands never - // delivered) or no recent /next poll (extension absent/asleep). Otherwise we'd block - // the doctor command for the full 35s waiting for a response that will never come. - if (!bridge.bridgeAuth.paired) { - lines.push("✗ Skipping extension probe — bridge isn't paired yet. Run /chrome onboard."); - } else if (!(await bridge.daemonStatus(1_500))?.connected) { - lines.push( - "✗ Chrome extension hasn't polled the bridge recently.", - " Fix: load the extension via /chrome onboard, keep that Chrome window open, then re-run /chrome doctor.", - ); - } else { - try { - const started = Date.now(); - const version = (await bridge.send("tab.version", {}, 6_000)) as { - extensionId?: string; - extensionVersion?: string; - bridgeUrl?: string; - }; - const latencyMs = Date.now() - started; - extensionAlive = true; - if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) { - versionMismatch = true; - lines.push( - `✗ The Chrome companion extension is on an old version (${version.extensionVersion}); this pi-chrome is ${PI_CHROME_VERSION}.`, - ` Every Chrome action will run with the old code until you reload the extension.`, - ` Fix: open chrome://extensions and click the refresh icon on 'Pi Chrome Connector'.`, - ` (After this one-time fix, future updates reload automatically.)`, - ); - } else { - lines.push(`✓ Chrome is connected (companion extension v${version.extensionVersion ?? "?"}, responded in ${latencyMs}ms).`); - } - } catch (error) { - const message = (error as Error).message; - lines.push(`✗ Chrome isn't responding: ${message}`); - if (message.includes("older pi-chrome without multi-session")) { - lines.push(" Fix: quit and restart the pi session that first opened the Chrome connection (it was on an older pi-chrome)."); - } else { - lines.push(" Fix: re-run /chrome onboard, or reload 'Pi Chrome Connector' at chrome://extensions and keep that Chrome window open."); - } + try { + const started = Date.now(); + const version = (await bridge.send("tab.version", {}, 35_000)) as { + extensionId?: string; + extensionVersion?: string; + bridgeUrl?: string; + }; + const latencyMs = Date.now() - started; + extensionAlive = true; + if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) { + versionMismatch = true; + lines.push( + `✗ The Chrome companion extension is on an old version (${version.extensionVersion}); this pi-chrome is ${PI_CHROME_VERSION}.`, + ` Every Chrome action will run with the old code until you reload the extension.`, + ` Fix: open chrome://extensions and click the refresh icon on 'Pi Chrome Connector'.`, + ` (After this one-time fix, future updates reload automatically.)`, + ); + } else { + lines.push(`✓ Chrome is connected (companion extension v${version.extensionVersion ?? "?"}, responded in ${latencyMs}ms).`); + } + } catch (error) { + const message = (error as Error).message; + lines.push(`✗ Chrome isn't responding: ${message}`); + if (message.includes("older pi-chrome without multi-session")) { + lines.push(" Fix: quit and restart the pi session that first opened the Chrome connection (it was on an older pi-chrome)."); + } else { + lines.push(" Fix: run /chrome onboard to install the Chrome companion extension, then keep that Chrome window open."); } } @@ -1588,7 +650,6 @@ Usage rules: } chromeAuthorizedUntil = until; ctx.ui.notify(`Chrome control authorized for ${label}.`, "info"); - updateChromeStatus(ctx); }; const parseAuthorizeArg = (arg: string): { label: string; until: number | "indefinite" } | undefined => { @@ -1610,293 +671,47 @@ Usage rules: const revokeHandler = (ctx: ExtensionContext) => { chromeAuthorizedUntil = undefined; - updateChromeStatus(ctx); ctx.ui.notify("Chrome control locked. Run /chrome authorize to allow chrome_* tools again.", "info"); }; - // `/chrome pair` is the bare-bones pairing step, useful when the extension is already - // installed and you just need to re-arm or re-pair. First-time setup should use - // /chrome onboard which wraps this with installation guidance. The daemon owns the - // pair-window state; Pi just RPCs in. - const pairHandler = async (ctx: ExtensionContext) => { - let replace = false; - if (bridge.bridgeAuth.paired) { - const ok = await ctx.ui.confirm( - "Re-pair Chrome bridge?", - "The bridge is already paired with a Chrome extension. Re-pairing will invalidate the current keys and you'll need to re-paste the new invite into the extension popup.\n\nPress Enter to continue, or Esc to cancel.", - ); - if (!ok) { ctx.ui.notify("Pairing cancelled.", "info"); return; } - replace = true; - } - let invite: string; - try { - const resp = await bridge.admin("arm-pair-window", { replace }); - invite = String(resp.invite); - } catch (error) { - ctx.ui.notify(`Failed to arm pair window: ${(error as Error).message}`, "warning"); - return; - } - await copyToClipboard(ctx, invite); - ctx.ui.notify( - [ - "Pairing invite (also copied to clipboard):", - "", - ` ${invite}`, - "", - "In Chrome, click the Pi Chrome Connector toolbar icon, paste this invite, and click 'Pair'.", - `Invite expires in ${Math.round(PAIR_WINDOW_MS / 60_000)} minutes. After pairing, run /chrome authorize to allow chrome_* tools.`, - ].join("\n"), - "info", - ); - }; - - const unpairHandler = async (ctx: ExtensionContext) => { - if (!bridge.bridgeAuth.paired) { - ctx.ui.notify("pi-chrome is not currently paired.", "info"); - return; - } - const proceed = await ctx.ui.confirm( - "Unpair Chrome bridge?", - "This clears the stored extension ID + HMAC keys. Every Pi session will need to re-pair (and the Chrome extension popup will need to be re-armed).\n\nPress Enter to confirm, or Esc to cancel.", - ); - if (!proceed) { ctx.ui.notify("Unpair cancelled.", "info"); return; } - try { - await bridge.admin("reset"); - ctx.ui.notify("pi-chrome unpaired. Run /chrome pair to start a new pairing.", "info"); - } catch (error) { - ctx.ui.notify(`Failed to unpair: ${(error as Error).message}`, "warning"); - } - }; - - const copyToClipboard = async (ctx: ExtensionContext, text: string): Promise<void> => { - if (process.platform === "darwin") { - await pi.exec("sh", ["-lc", `printf %s ${JSON.stringify(text)} | pbcopy`], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined); - } else if (process.platform === "linux") { - await pi.exec("sh", ["-lc", `printf %s ${JSON.stringify(text)} | (xclip -selection clipboard 2>/dev/null || xsel --clipboard --input 2>/dev/null || wl-copy 2>/dev/null) || true`], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined); - } - }; - - // `/chrome onboard` (aliased as `/chrome install`) is the idempotent recovery path. It can - // be re-run any number of times to bring a broken machine back to a known-good state, - // regardless of what older versions of pi-chrome left behind: - // - // * It always resets the Pi-side pairing record before issuing a new invite, so a stale - // `~/.config/pi/chrome-bridge.json` (e.g. paired against an old extension instance whose - // keys were never persisted in Chrome's storage) cannot silently keep the bridge in a - // half-paired state. - // * It opens chrome://extensions + the extension folder and tells the user to either Load - // unpacked (first install) OR click the reload icon on the existing card (covers the - // v0.15.x → 0.16.x migration where the SW must be re-loaded to gain the /pair logic). - // * It then **actively verifies** progress at each step rather than trusting confirm() - // clicks: it polls `bridge.status().connected` to confirm the extension is polling - // `/next`, polls `bridge.bridgeAuth.paired` to confirm the popup successfully completed - // the /pair handshake, and finally round-trips a real `tab.version` call to confirm the - // end-to-end signed-envelope path works. - // - // If any of those steps fail, it prints a concrete fix and stops; rerunning is always safe. const onboardHandler = async (ctx: ExtensionContext) => { - // Note: we deliberately do NOT early-return on client mode here. Onboarding is precisely - // the recovery path for the case where a foreign Pi process owns the bridge — the Step 0 - // foreign-owner takeover below will kill that process and re-bind locally. const extensionPath = browserExtensionPath(); - const proceed = await ctx.ui.confirm( - "pi-chrome onboard", - [ - "This will set up Chrome control from scratch:", - " 1. Reset any stale pairing state on the Pi side.", - " 2. Walk you through (re-)loading the Chrome extension.", - " 3. Mint a fresh pairing invite and wait for you to paste it into the popup.", - " 4. Verify a real Chrome round-trip works.", - "", - "Safe to re-run any time; nothing else changes.", - "", - "Press Enter to continue, or Esc to cancel.", - ].join("\n"), + "Install the pi-chrome Chrome extension?", + `This opens Chrome's extensions page and reveals the folder pi-chrome needs you to load.\n\nWhen the windows open, in Chrome:\n 1. Turn on 'Developer mode' (top-right toggle).\n 2. Click 'Load unpacked' and choose the folder that just opened in Finder, or paste this path:\n ${extensionPath}\n\nPress Enter to continue, or Esc to cancel.`, ); if (!proceed) { - ctx.ui.notify("Cancelled. Run /chrome onboard again whenever you're ready.", "info"); + ctx.ui.notify("Cancelled. You can run /chrome onboard again whenever you're ready.", "info"); return; } - - // --- Step 1: reset daemon-side pairing state. ------------------------------------ - // In 0.17 the daemon is automatically spawned/upgraded at session_start (see - // ChromeProfileBridge.ensureDaemonRunning), so /chrome onboard no longer needs to - // detect-and-kill stale owners. We just admin-reset the pairing record and proceed. - // Daemon clears `~/.config/pi/chrome-bridge.json` on its side. Any cached extension keys - // in chrome.storage.local will become invalid, but that's fine because we're about to - // mint a fresh invite and re-pair. fs.watch + refreshIfStale on the daemon's BridgeAuth - // also propagates the reset to in-memory state immediately. - const hadPriorPairing = bridge.bridgeAuth.paired; - try { await bridge.admin("reset"); } catch (error) { - ctx.ui.notify(`Failed to reset prior pairing: ${(error as Error).message}`, "warning"); - return; - } - if (hadPriorPairing) { - ctx.ui.notify( - "Cleared the previous pairing record. The extension popup may still show 'Paired' until it polls again; that's fine — we'll re-pair below.", - "info", - ); - } - - // --- Step 2: install or reload the Chrome extension. ----------------------------- if (process.platform === "darwin") { await pi.exec("open", ["-a", "Google Chrome", "chrome://extensions"], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined); await pi.exec("open", ["-R", extensionPath], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined); + await pi.exec("sh", ["-lc", `printf %s ${JSON.stringify(extensionPath)} | pbcopy`], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined); } - await copyToClipboard(ctx, extensionPath); ctx.ui.notify( - [ - "Step 1 of 3 — install or reload the Chrome extension", - "", - "Open chrome://extensions. Two cases:", - "", - " A. If 'Pi Chrome Connector' is NOT listed:", - " • Toggle Developer mode (top-right).", - " • Click 'Load unpacked'.", - ` • Pick the folder that just opened in Finder, or paste this path (it's on your clipboard):`, - ` ${extensionPath}`, - "", - " B. If 'Pi Chrome Connector' IS listed:", - " • Click the refresh (↻) icon on its card so the service worker reloads with the current code.", - "", - "Press Enter once the extension card shows 'Pi Chrome Connector' (recent load time).", - ].join("\n"), - "info", - ); - const ready = await ctx.ui.confirm( - "Extension loaded / reloaded?", - "Confirm once Pi Chrome Connector is listed and active. Press Esc to abort.", - ); - if (!ready) { - ctx.ui.notify("Cancelled before pairing. Re-run /chrome onboard whenever ready.", "info"); - return; - } - - // --- Step 2.5: wait for the SW to start polling /next. --------------------------- - // Without this gate, a paused service worker (or an extension that didn't actually - // reload) lets the user proceed to step 3 only to have pairing silently fail. Active - // poll = lastSeenAt updates = `bridge.status().connected` flips true. - ctx.ui.notify("Waiting for the extension to wake up and poll the bridge…", "info"); - const connectStart = Date.now(); - let connected = false; - while (Date.now() - connectStart < 15_000) { - const ds = await bridge.daemonStatus(1_500); - if (ds && ds.connected === true) { connected = true; break; } - await new Promise((r) => setTimeout(r, 500)); - } - if (!connected) { - ctx.ui.notify( - [ - "✗ The Chrome extension didn't poll the bridge within 15 s.", - " Common causes:", - " • Service worker is suspended. Open chrome://extensions → 'service worker' link on the Pi Chrome Connector card to wake it.", - " • The extension didn't load (folder permission, wrong path). Try Load unpacked again with the path on your clipboard.", - " • The extension is disabled. Toggle it on.", - "", - " Fix and re-run /chrome onboard — it's idempotent.", - ].join("\n"), - "warning", - ); - return; - } - ctx.ui.notify("✓ Extension is polling.", "info"); - - // --- Step 3: mint invite, wait for the popup to complete the /pair handshake. ---- - let invite: string; - try { - const resp = await bridge.admin("arm-pair-window", { replace: true }); - invite = String(resp.invite); - } catch (error) { - ctx.ui.notify(`Failed to arm pair window: ${(error as Error).message}`, "warning"); - return; - } - await copyToClipboard(ctx, invite); - ctx.ui.notify( - [ - "Step 2 of 3 — pair the extension with this bridge", - "", - " 1. Click the Pi Chrome Connector icon in Chrome's toolbar (puzzle-piece menu, pin it if needed).", - " 2. Paste the invite below into the popup and click 'Pair'.", - "", - ` Invite (also on your clipboard): ${invite}`, - "", - ` Window: ${Math.round(PAIR_WINDOW_MS / 60_000)} min. Pi will detect the pair automatically; you don't need to press anything here.`, - ].join("\n"), - "info", - ); - const pairStart = Date.now(); - while (Date.now() - pairStart < PAIR_WINDOW_MS) { - if (bridge.bridgeAuth.paired) break; - await new Promise((r) => setTimeout(r, 500)); - } - if (!bridge.bridgeAuth.paired) { - ctx.ui.notify("✗ Pairing window expired with no /pair from the extension. Re-run /chrome onboard.", "warning"); - return; - } - ctx.ui.notify("✓ Bridge paired with the extension.", "info"); - - // --- Step 4: verify a real round-trip. ------------------------------------------- - try { - const version = (await bridge.send("tab.version", {}, 5_000)) as { extensionVersion?: string }; - ctx.ui.notify(`✓ Step 3 of 3 — Chrome round-trip OK (extension v${version.extensionVersion ?? "?"}).`, "info"); - } catch (error) { - ctx.ui.notify( - [ - `⚠ Pairing succeeded but a test round-trip failed: ${(error as Error).message}`, - " Possible causes:", - " • Chrome was switched to a chrome:// or chrome-extension:// tab; pi-chrome can't run there. Switch to a normal http(s) tab and run /chrome doctor.", - " • Service worker went back to sleep between pair and probe. Click the extension icon once and re-run /chrome doctor.", - ].join("\n"), - "warning", - ); - return; - } - - ctx.ui.notify( - "All set. Run /chrome authorize 15m (or longer) before using chrome_* tools.", + "Chrome and Finder should be open. The extension folder path is on your clipboard. After you click 'Load unpacked' and pick it, run /chrome doctor to confirm everything is connected.", "info", ); }; - // `probeExtension` short-circuits before issuing a bridge.send when we already know the - // command can't round-trip, so /chrome status / doctor / onboard don't block for the full - // timeout. Returns a single line summarising the result. - // - // Order of checks: - // 1. Bridge unpaired — no command will ever be delivered (/next returns idle), so don't - // bother sending. Tell the user to run /chrome onboard. - // 2. Extension hasn't polled /next recently — the bridge has no live connection. Skip the - // send and report disconnected immediately. - // 3. Otherwise probe with a short timeout (paired + recent /next means a round-trip - // should complete in <1s; 3s is plenty). - const probeExtension = async (timeoutMs = 3_000): Promise<string> => { - if (!bridge.bridgeAuth.paired) { - return "✗ Chrome bridge not paired (run /chrome onboard)"; - } - const ds = await bridge.daemonStatus(1_000); - if (!ds?.connected) { - return "✗ Chrome extension not polling (load extension via /chrome onboard, keep Chrome open)"; - } + // One-line snapshot of pi-chrome's current state. Used as a header in the bare-/chrome + // picker and as the body of /chrome status. + const statusSummary = async (): Promise<string> => { + const parts: string[] = []; try { - const version = (await bridge.send("tab.version", {}, timeoutMs)) as { extensionVersion?: string }; + const version = (await bridge.send("tab.version", {}, 5_000)) as { extensionVersion?: string }; if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) { - return `⚠ Chrome extension v${version.extensionVersion} (pi-chrome v${PI_CHROME_VERSION}, reload extension)`; + parts.push(`⚠ Chrome extension v${version.extensionVersion} (pi-chrome v${PI_CHROME_VERSION}, reload extension)`); + } else { + parts.push(`✓ Chrome connected`); } - return `✓ Chrome connected`; } catch { - return `✗ Chrome not responding within ${timeoutMs}ms`; + parts.push(`✗ Chrome not responding`); } - }; - - // One-line snapshot of pi-chrome's current state. Used as a header in the bare-/chrome - // picker and as the body of /chrome status. - const statusSummary = async (): Promise<string> => { - return [ - await probeExtension(2_000), - `auth: ${authSummary()}`, - `background: ${backgroundDefault ? "on" : "off"}`, - ].join(" · "); + parts.push(`auth: ${authSummary()}`); + parts.push(`background: ${backgroundDefault ? "on" : "off"}`); + return parts.join(" · "); }; const statusHandler = async (ctx: ExtensionContext) => { @@ -1961,7 +776,7 @@ Usage rules: pi.registerCommand("chrome", { description: - "All pi-chrome controls in one place.\n\n First-time setup (do these once, in this order):\n /chrome onboard — idempotent: install/reload the Chrome extension AND pair it with this Pi bridge. Safe to re-run any time — it'll bring a broken machine back to a known-good state.\n /chrome install — alias for /chrome onboard.\n /chrome authorize [15m|30m|<minutes>|indefinite] — unlock chrome_* tools for this Pi session.\n\n Day-to-day:\n /chrome authorize — unlock chrome_* tools (per session).\n /chrome revoke — lock chrome_* tools again.\n /chrome status — one-line snapshot of connection, auth, and background setting.\n /chrome doctor — full health check; tells you exactly what's missing.\n\n Maintenance (rare):\n /chrome pair — (re-)pair the extension when only pairing is missing.\n /chrome unpair — clear pairing keys; every Pi session will need to re-pair.\n /chrome background [on|off|status|toggle] — run without focusing Chrome.\n\nRun with no arguments for an interactive picker that shows current state.", + "All pi-chrome controls in one place.\n /chrome authorize [15m|30m|<minutes>|indefinite] — allow this Pi session to use chrome_* tools.\n /chrome revoke — lock Chrome control.\n /chrome status — one-line snapshot of connection, auth, and background setting.\n /chrome doctor — full health check.\n /chrome onboard — install the Chrome companion extension.\n /chrome background [on|off|status|toggle] — whether pi-chrome runs without focusing Chrome.\nRun with no arguments for an interactive picker that shows current state.", getArgumentCompletions: (prefix) => { const raw = prefix; const trimmedRight = raw.replace(/\s+$/, ""); @@ -1978,14 +793,11 @@ Usage rules: let candidates: Item[] = []; if (path.length === 0) { candidates = [ - { fullValue: "onboard", label: "onboard", description: "Idempotent recovery flow: install/reload extension + (re-)pair + verify round-trip. Safe to re-run anytime." }, - { fullValue: "install", label: "install", description: "Alias for /chrome onboard." }, - { fullValue: "authorize", label: "authorize", description: "Unlock chrome_* tools for this Pi session." }, - { fullValue: "revoke", label: "revoke", description: "Lock chrome_* tools again." }, + { fullValue: "authorize", label: "authorize", description: "Allow this Pi session to use chrome_* tools." }, + { fullValue: "revoke", label: "revoke", description: "Lock Chrome control for this Pi session." }, { fullValue: "status", label: "status", description: "One-line summary: connection, auth, and background setting." }, - { fullValue: "doctor", label: "doctor", description: "Full health check. Tells you exactly what's missing if chrome_* isn't working." }, - { fullValue: "pair", label: "pair", description: "Re-pair an already-installed extension (use /chrome onboard for first-time setup)." }, - { fullValue: "unpair", label: "unpair", description: "Clear pairing keys (forces every Pi session to re-pair)." }, + { fullValue: "doctor", label: "doctor", description: "Full health check. Tells you if Chrome is connected and what's wrong if it isn't." }, + { fullValue: "onboard", label: "onboard", description: "Install the Chrome companion extension (first-time setup)." }, { fullValue: "background", label: "background", description: "Run pi-chrome in the background without focusing Chrome?" }, ]; } else if (path[0] === "authorize" && path.length === 1) { @@ -2016,9 +828,6 @@ Usage rules: const [head, ...rest] = tokens; const subArgs = rest.join(" "); switch (head) { - case "pair": return pairHandler(ctx); - case "unpair": return unpairHandler(ctx); - case "install": return onboardHandler(ctx); case "authorize": return authorizeHandler(ctx, subArgs); case "revoke": return revokeHandler(ctx); case "status": return statusHandler(ctx); diff --git a/package.json b/package.json index 5f2ebaa..3e5f4e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-chrome", - "version": "0.17.3", + "version": "0.15.20", "scripts": { "version": "node scripts/sync-manifest-version.js", "prepublishOnly": "node scripts/sync-manifest-version.js" diff --git a/scripts/security-smoke.sh b/scripts/security-smoke.sh deleted file mode 100755 index a8cabbd..0000000 --- a/scripts/security-smoke.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -# -# Negative-path smoke tests for the hardened pi-chrome bridge. -# -# Run against a live bridge (Pi session must be running with this pi-chrome version). -# These tests do NOT require an extension to be paired; they assert that unauthenticated -# callers cannot drive Chrome through the bridge. -# -# usage: ./scripts/security-smoke.sh -# -# Each test is independent. Failures print FAIL and a non-zero exit at the end. - -set -u - -BRIDGE_URL="${PI_CHROME_BRIDGE_URL:-http://127.0.0.1:17318}" -FAILED=0 -pass() { echo "PASS $1"; } -fail() { echo "FAIL $1 (got: $2)"; FAILED=$((FAILED+1)); } - -check_status() { - local desc="$1" expected="$2" actual="$3" - if [[ "$actual" == "$expected" ]]; then pass "$desc"; else fail "$desc — expected HTTP $expected" "$actual"; fi -} - -http_status() { - curl -sS -o /dev/null -w "%{http_code}" "$@" -} - -echo "Bridge URL: $BRIDGE_URL" - -# 1. /command without auth header -> 401 (broker auth required) -status=$(http_status -X POST "$BRIDGE_URL/command" -H 'content-type: application/json' --data '{"action":"tab.list","params":{}}') -check_status "/command without auth -> 401" 401 "$status" - -# 2. /next from random chrome-extension origin returns 200 idle and never delivers a command. -# Until the bridge is paired, the response includes {"needsPairing":true}. After pairing, -# a non-pinned origin still gets the same idle payload (never the queued command). Either -# way: type=none, no commands. -body=$(curl -sS -H 'Origin: chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' "$BRIDGE_URL/next?name=smoke") -if echo "$body" | grep -q '"type":"none"' && ! echo "$body" | grep -q '"type":"command"'; then - pass "/next from unpinned chrome-extension origin is idle, never command" -else - fail "/next from unpinned chrome-extension origin" "$body" -fi - -# 3. /next with wrong-shaped Origin (real web origin) -> 403 -status=$(http_status -H 'Origin: https://example.com' "$BRIDGE_URL/next") -check_status "/next from web origin -> 403" 403 "$status" - -# 4. Oversized body to /command -> 413. Stream via stdin so argv stays sane on macOS. -status=$(head -c $((2*1024*1024)) /dev/zero | tr '\0' 'x' \ - | curl -sS -o /dev/null -w "%{http_code}" -X POST "$BRIDGE_URL/command" \ - -H 'content-type: application/json' --data-binary @-) -check_status "/command oversized body -> 413" 413 "$status" - -# 5. Malformed JSON to /result without auth: must be rejected (401/403) and must not crash -# the server. Verify by hitting /status afterwards. -status=$(http_status -X POST "$BRIDGE_URL/result" \ - -H 'content-type: application/json' \ - -H 'Origin: chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ - --data 'not json') -case "$status" in - 401|403) pass "/result malformed JSON without auth rejected ($status)";; - *) fail "/result malformed JSON without auth" "$status";; -esac -# Confirm server stayed responsive after the parse-error attempt. -status=$(http_status "$BRIDGE_URL/status") -check_status "/status still responsive after malformed POST" 200 "$status" - -# 6. /status is OK (loopback only, no secrets) -> 200 -status=$(http_status "$BRIDGE_URL/status") -check_status "/status -> 200" 200 "$status" - -# 7. /command with bogus auth header -> 401 -status=$(http_status -X POST "$BRIDGE_URL/command" \ - -H 'content-type: application/json' \ - -H 'x-pi-chrome-auth: v1 ts=1 nonce=AAAA sig=AAAA' \ - --data '{"action":"tab.list","params":{}}') -check_status "/command with bogus auth -> 401" 401 "$status" - -# 8. /pair without active pairing window -> 403 -status=$(http_status -X POST "$BRIDGE_URL/pair" \ - -H 'content-type: application/json' \ - -H 'Origin: chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \ - --data '{"extensionId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","extensionNonce":"AAAA","mac":"AAAA"}') -case "$status" in - 401|403) pass "/pair without active window rejected ($status)";; - *) fail "/pair without active window" "$status";; -esac - -echo -if [[ $FAILED -eq 0 ]]; then - echo "ALL SMOKE TESTS PASSED" - exit 0 -else - echo "$FAILED test(s) failed" - exit 1 -fi |
