# DOX — packages/browser-plugin/src/server

Files in this directory. One row per source file. See change: add-browser-relay.

| File | Purpose |
|------|---------|
| `agent-browser-types.ts` | Dependency-free types for the agent-browser policy modules. `DEDICATED_TARGET`=`@dedicated`, `HEADLESS_TARGET`=`@headless`, `OnUnavailable`, `ConnectPolicyConfig`, `ProfileView`, `DedicatedStatusView`, `ConnectPolicyDeps`, `BrowserConnectResult`. See change: add-dedicated-agent-browser. |
| `await-state.ts` | `AwaitTracker` — `browser/await-state` lane handler. Bridge sends `{instanceId,pending}` around `browser_await_human`; idle sweep holds the instance while any session is pending. Session from lane `meta` only. Bounded: `AWAIT_MAX_HOLD_MS` (2 h) stale cutoff, 256 entries, `forget(instanceId)` on close. See change: add-dedicated-agent-browser. |
| `connect-handler.ts` | `createConnectHandler(deps)` — `browser/connect` lane handler behind `browser_connect`. `sessionId` from lane `meta` only; only `target` read from payload (spoofed `sessionId` ignored). Delegates to `resolveBrowserConnect`. See change: add-dedicated-agent-browser. |
| `connect-policy.ts` | `resolveBrowserConnect({target},deps)` — pure `browser_connect` selection. First target = arg, else `defaultBrowser` (default `@dedicated`). `onUnavailable` `next` walks `@dedicated`→`realProfile`→`@headless`; `ask`/`stop` stop at first failure (`options`/`stopped`). Live dedicated held by caller → close + reconnect; other holder → `busy`. Never routes to Panerelay, never re-sends a `cdpUrl`. Arms live view when `openLiveViewOnConnect`. See change: add-dedicated-agent-browser. |
| `dedicated-service.ts` | `createDedicatedService(manager)` → `browser.dedicated` service (`status`/`connect`/`open`/`focus`/`tabOrigin`/`disconnect`) + `unload()`. Thin adapter over `RelayManager`; refusals are `{ok:false,status,reason,holder?,instanceId?}` values, never throws. After `unload()` (server `onShutdown`, only stop signal a plugin gets): `{503,"unloaded"}`; `tabOrigin`→`null`, `disconnect`→no-op, `status()`→`enabled:false,available:false`. Shape declared locally, mirrors `packages/gmail-plugin/src/server/setup/browser-service.ts`; no cross-plugin import. See change: add-gmail-auto-setup. |
| `dedicated/` | Dashboard-owned Chrome (dedicated agent browser): pipe-driven `DedicatedChrome` supervisor, `DedicatedInstance` CDP proxy, deny set, hidden targets — see `dedicated/AGENTS.md`. See change: add-dedicated-agent-browser. |
| `idle-sweep.ts` | `IdleSweeper` — 60 s interval (`IDLE_SWEEP_INTERVAL_MS`) closing relay + dedicated instances idle > `idleDisconnectMinutes` (0 = off). Injected `activityOf`/`awaitPending`/`isRegistered`/`close`; pending await holds + restarts clock. No-op while plugin disabled. After `close(id,"idle")` sweeper appends audit `detach` detail `idle` (close must not). Timer `unref`ed. See change: add-dedicated-agent-browser. |
| `index.ts` | Server entry `registerPlugin` — composition root. Builds ONE `AuditRing`, ONE `RelayManager` (deps: unredacted `ctx.getPluginConfig`, lazy `ctx.fastify.server.address().port`, real `canOpenChrome()`/`listChromeProfiles()`, `onStatusChange → status.broadcastNow`), ONE `BrowserRelayStatus`, then mounts WS routes + gateway handlers + REST routes. `PI_BROWSER_RELAY_FAKE=1` seeds the `Fake` instance (env-only, never config). `ctx.onShutdown` disposes the status timer + `manager.closeAll`. Plugin-disable teardown needs no hook: `teardownPlugin` closes tracked sockets 1001 → `RelayInstance` finalizes → manager entry dropped. Tasks 2.1, 2.10b, 2.11, 3.6. Wires operator hosts: `operatorHosts.pinsPath` = `<dashboard config dir>/browser-plugin/operator-hosts.json`, `getServerId` = memoised loopback `GET /api/health` `instanceId`, `getServerLabel` = `hostname:port`; `status` gets `hosts: manager`. See change: add-operator-browser-relay. Wires `OpenHandler` (`registerPiRequestHandler("browser/open")`, `ctx.openEditorTab`), `onTabMetaChange` → `status.schedule()`, `onInstanceClosed`. See change: add-browser-editor-pane-tab. Dedicated agent browser wiring: ONE `DedicatedChrome` + ONE `HiddenTargets` → `RelayManager` deps `dedicated`. Registers `browser/connect` (`createConnectHandler`) + `browser/await-state` (`AwaitTracker`) via `ctx.registerPiRequestHandler`; `LiveViewOnConnect` → `OpenHandler.openAuto`; `IdleSweeper` runs from activation to `onShutdown` (e2e `Fake` instance never swept). Shutdown/kill switch → `manager.closeAll` also stops Chrome. See change: add-dedicated-agent-browser. Provides `browser.dedicated` (`ctx.provide?.`, `createDedicatedService(manager)`); `onShutdown` calls `unload()`. See change: add-gmail-auto-setup. |
| `audit.ts` | `AuditRing` (cap `AUDIT_CAPACITY`=500) + `AuditKind`. Entries `{ts, profileDirectory, instanceId, kind, detail}`; `detail` string-only so a payload/guid/token cannot leak (coerced, never stored as object). `auditSeq` is a monotonic COUNTER, not `entries.length` — the client's audit-refresh signal. `list(profile?)` newest-first. `setOnAppend(fn)` observes every append (status broadcaster coalesces off it; observer throw swallowed). Task 2.6 / E15, E16. `AuditKind` gains `viewer-subscribe-refused`. See change: fix-browser-live-view-subscribe-and-reopen. Kinds `open` and `dropped`; `append` redacts `chrome-extension:` URLs in `detail`. See change: add-browser-editor-pane-tab. `stripUrlQueryAndFragment(detail)` strips `?query`/`#fragment` from `scheme://`/`about:` details (OAuth `code=`/`state=`) for every profile before storage; non-URL details untouched. New kinds `dropped`, `open`, `viewer-subscribe-refused`. See change: add-dedicated-agent-browser. |
| `capability.ts` | `canOpenChrome(deps)` = `computeSystemOpen()` (shared) AND Chrome user-data dir exists; `chromeUserDataDir(platform, env)`; `buildChromeOpenCommand` → macOS `open -na "Google Chrome" --args --profile-directory=<dir> <url>` (plain `open <url>` would ignore the profile and hit the default browser). Task 2.10. `resolveChromeBinary(deps)` (darwin `/Applications`+`~/Applications`, win32 `ProgramFiles*`/`LOCALAPPDATA`, linux `google-chrome`/`google-chrome-stable` on PATH; no Flatpak/Snap/Chromium). `dedicatedAvailability(deps)` → `{available, reason?: "no-chrome"|"no-display"}`; `no-chrome` checked first, then display probe (`computeSystemOpen`); does NOT need Chrome default user-data dir. See change: add-dedicated-agent-browser. |
| `connect.ts` | `PLAYWRIGHT_EXTENSION_ID` (`mmlmfjhmonkocbjadbfplnigmagldckm`), `buildConnectUrl` (connect.html + `mcpRelayUrl=ws://127.0.0.1:<port>/ws/browser-ext/<guid>` + `protocolVersion=2`, `token` ONLY when `zeroDialog`), `openChromeProfile` (detached spawn via shared `platform/exec`). Token mismatch/Reject/no-answer are deliberately indistinguishable → one 504. Task 2.8 / E18. |
| `live-view.ts` | `LiveViewOnConnect` — remembers calling session per instance (`remember`), first agent-created tab (`onTabCreated`) fires injected `open` (wired to `OpenHandler.openAuto`). Once per `remember`; refused open keeps arming, retries next tab. `forget` on close. Agent tabs only (not connect page, not hidden sign-in tabs). See change: add-dedicated-agent-browser. |
| `open-handler.ts` | `OpenHandler` — `browser/open` request-lane handler behind `browser_show_in_pane` / `browser_await_human`. Session id from lane `meta` only; resolves tab (default = most recent non-connect-page; `no-tab`); `ctx.openEditorTab`; 5 s rate limit per (session, instance) for `show` only (pruned each call, dropped on instance close); every call audited kind `open`. Errors `disabled`/`unknown-instance`/`unknown-tab`/`no-tab`/`rate-limited`/`host-refused:*`. See change: add-browser-editor-pane-tab. `openAuto(sessionId,instanceId,tabId)` — live-view open: `show` exempt from the 5 s limiter (neither checks nor consumes it), audit `open` detail exactly `auto`; refusals `auto refused …`. Separate method so the lane can never request the exemption. See change: add-dedicated-agent-browser. |
| `operator-hosts.ts` | `OperatorHostRegistry`: hello → challenge{nonce, own `/api/health` instanceId} → register (Ed25519 over `"pi-browser-host-v1\n"+nonce+"\n"+serverId`) → `browser_host_registered`. `hostId` must derive from key; first verified key TOFU-pinned in `operator-hosts.json` (prune >90 d, cap 256, atomic write 0600). One host per socket; `host-active` refusal while links/pending exist, else re-bind; D2 bounds (label ≤128, ≤64 profiles, dir ≤128 no separators, email ≤254). Refusals silent on wire, audited `host:<hostId>` + logged (`[browser-host] register … pin=new|ok|mismatch`). `list()` online only; `deletePin`; `denied()`; carrier close → `onHostGone`. Never logs nonce/signature/key. Tasks 7.1, 7.7. See change: add-operator-browser-relay. |
| `profiles.ts` | `listChromeProfiles(deps)` reads `Local State → profile.info_cache`, keys rows by `profileDirectory` (labels duplicate), `installed` via vendored `isExtensionInstalledInProfile`. Missing/corrupt/empty cache → one synthetic `Default` row + `warning` naming the path (200, not an error). Task 2.7 / E17, X9. `PASSWORD_MANAGER_IDS` (LastPass, iCloud Passwords), `detectPasswordManagers(profileDir, exists)`; rows carry `passwordManagers: string[]`. See change: add-dedicated-agent-browser. |
| `redact.ts` | `redactExtensionUrl(url)` (strip `?`/`#` of `chrome-extension:` URLs), `isRelayConnectPage(url)` (the extension `connect.html`). Applied at every viewer egress: tab lists, `/api/browser/profiles`, status, audit detail. See change: add-browser-editor-pane-tab. |
| `settings.ts` | "Agent browsing" settings. `effectiveSettings(config)` → `status.settings` with defaults (`@dedicated`, `ask`, 30 min, one instance/profile, live view on). `validateSettingsPatch(body, installedProfiles)` → partial config patch or 400 message (nothing stored on failure). `globalAllowedDomains`, `IDLE_MINUTES_CHOICES` `[0,5,30,120]`, `BUILTIN_TARGETS`. Caps: 200 domains, 253 chars each. See change: add-dedicated-agent-browser. |
| `status.ts` | `BrowserRelayStatus` — `browser_relay_status` composer + the three relay gateway handlers. `message()` maps manager instances (`statusState()` + `tabList()`) + `auditSeq`. `broadcastNow()` immediate (instance/tab change, via manager `onStatusChange`), `schedule()` coalesced ≤1 per `STATUS_COALESCE_MS`=500 on audit append (via `audit.setOnAppend`). `registerHandlers(ctx)` owns `browser_relay_subscribe|unsubscribe|input`, keyed `{instanceId, tabId}` — malformed/unknown refs dropped + audited (`browser_relay_*` detail), socket close = `unsubscribeAll` on every live instance (`_trackClose` once per socket). Frames bypass this module (per-socket in the tap). Tasks 2.11, 3.4, 3.6 / E22, P3, X12. Registers the six browser→server `browser_host_*` types → `deps.hosts.handleHostMessage`; subscribe forwards `local:true` only when exactly `true`; status instances carry `host` (`inst.host ?? "dashboard"`). See change: add-operator-browser-relay. Tab rows carry `agentEmulation`; connect page filtered + extension URLs redacted at egress. See change: add-browser-editor-pane-tab. |
| `routes.ts` | Twelve `/api/browser/*` routes on `ctx.fastify`: `GET status` (`{enabled, canOpenChrome, operatorHosts:true}`), `GET profiles` (rows keyed by `profileDirectory` + `installed`/`hasToken` + `instances[].tabs[]`; optional `?profileDirectory=`; carries the synthetic-`Default` `warning`), `POST connect` (`{profileDirectory}` → 200 `{cdpUrl, instanceId}` / 400 / 403 disabled / 409 not-installed|busy / 503 / 504), `POST disconnect?instanceId=` (400 missing / 404 unknown / 403 disabled), `GET audit?profile=` (newest-first), `PUT enabled` (kill switch; 400 non-boolean). Reads always answer; WRITES 403 while disabled; PUT is the way back on. TOKEN SAFETY: `getPluginConfig()` is unredacted in-plugin, so a row carries ONLY `hasToken` — never the config. Task 2.9. `POST connect` takes optional `host` (400 on non-string/empty/>128); `GET profiles` adds `operatorHosts[]` (per-profile `instances`, no `hasToken`/`zeroDialog`; dashboard rows use `instancesOn("dashboard")`); `DELETE operator-hosts/:hostId` resets a pin (400 bad id). Tasks 7.5. See change: add-operator-browser-relay. `/api/browser/profiles` tab rows: connect page filtered, extension URLs redacted, `agentEmulation`. See change: add-browser-editor-pane-tab. Connect socket-timeout bypass pinned by real-socket tests (#X1–#X5). See change: fix-browser-relay-pane-input. `PUT /api/browser/settings` (partial, whole-body validated, 400 writes nothing, `@`-prefixed profile keys rejected). `POST /api/browser/dedicated/connect|open|stop|reset` (`DEFAULT_SIGN_IN_URL`; open = hidden https tab; reset only when stopped → 409). `GET /status` adds `dedicated` (when wired) + `settings`; `profiles` rows add `passwordManagers`. Same universal guard; connect/open disable the per-socket timeout (cold Chrome start). See change: add-dedicated-agent-browser. |
| `ws-routes.ts` | `registerBrowserWsRoutes(ctx, manager)` registers both relay scopes via `ctx.registerWsRoute` during activation. `browser-ext` (`/ws/browser-ext/<guid>`, `admitOrigins=[CHROME_EXTENSION_ORIGIN]` pinned exact; an operator-targeted guid → 404, its extension arrives as a carrier link — E17), `browser-cdp` (`/ws/browser-cdp/<guid>`, empty `admitOrigins` + handler refuses ANY `Origin` header). `handleUpgrade` extracts guid → `manager.resolve` (404 unknown/expired/malformed/nested), completes handshake via `WebSocketServer({noServer:true})`, then `meta.trackSocket` + `manager.attachExtension|attachCdp`. Core gates (host/origin/local-peer/ticket) run BEFORE delegation — group 1. `RelayAttacher` narrow seam. Task 2.5 / E10. Dedicated `cdpUrl` shares the `browser-cdp` scope (no extra route; core loopback gate + `Origin` refusal apply); a dedicated guid on the ext scope → 404 (`resolve()` gains `dedicated`). See change: add-dedicated-agent-browser. |
| `relay/` | Relay + tap machinery — see `relay/AGENTS.md` and `relay/vendor/AGENTS.md` (never-edit rule, shims, refresh policy). |

Files in `__tests__/`:

| File | Purpose |
|------|---------|
| `__tests__/audit.test.ts` | E15 (cap/drop-oldest across 499/500/501/600, `auditSeq` strictly increasing past the cap, newest-first, secret-free JSON) + E16 (detail is URL/method/kind, non-string coerced). E13: URL query/fragment stripping (relay + dedicated), chrome-extension still redacted, non-URL details untouched. See change: add-dedicated-agent-browser. |
| `__tests__/await-state.test.ts` | `AwaitTracker`: pending/clear, stale cutoff, entry cap, malformed payload throws, `forget`. #R16a input. See change: add-dedicated-agent-browser. |
| `__tests__/capability.test.ts` | 2.10 verify: false on `PI_DASHBOARD_SYSTEM_OPEN=0` / missing dir / display-less Linux; true on override+dir; per-OS user-data dir; D4 argv incl. a space-bearing directory as ONE element. E1/E2: `resolveChromeBinary` per OS/PATH/Flatpak-only; `dedicatedAvailability` (available while `canOpenChrome` false, `no-chrome` wins over `no-display`). See change: add-dedicated-agent-browser. |
| `__tests__/connect-handler.test.ts` | #E20 server half: payload `sessionId` ignored, session from lane `meta`. See change: add-dedicated-agent-browser. |
| `__tests__/connect-policy.test.ts` | `resolveBrowserConnect`: D6 table, `onUnavailable` ask/next/stop, holder close+reconnect, `busy`. #E18 #E19 #R14. See change: add-dedicated-agent-browser. |
| `__tests__/connect.test.ts` | E18: pinned extension host, `protocolVersion=2`, loopback `mcpRelayUrl`, `token` only with `zeroDialog`, argv elements. |
| `__tests__/dedicated-service.test.ts` | Service over real manager + fake spawn. #E12 connect table (free/busy/kill switch/unloaded), takeover/private/ephemeral forwarding. #E13 `tabOrigin` origin-only → `null` once closed, `focus`, `open` refusals. See change: add-gmail-auto-setup. |
| `__tests__/dedicated-manager-rig.ts` | Rig: REAL `DedicatedChrome` (temp config dir, fake spawn/process listing/kill) under a real `RelayManager`. Shared by manager/route/entry dedicated tests. See change: add-dedicated-agent-browser. Option `onInstanceClosed` runs inside the manager hook (reentrancy). Behavior `autoContexts`. See change: add-gmail-auto-setup. |
| `__tests__/fake-relay-instance.test.ts` | 2.10b / E28: one tab, `connected`; immediate frame then ≥5 frames/s under fake timers; refuses a foreign tab; input echoed to the audit; malformed input → `denied`; silent after `close`. |
| `__tests__/idle-sweep.test.ts` | `IdleSweeper`: threshold, 0 = off, await hold + clock restart, disabled no-op, re-check before close, audit `detach idle`. #R15 #R16. See change: add-dedicated-agent-browser. |
| `__tests__/index-dedicated.test.ts` | Entry wiring: `browser/connect` + `browser/await-state` registered, `GET /status` carries `dedicated` + `settings`, idle sweeper runs and stops. Never reaches a real Chrome. See change: add-dedicated-agent-browser. Block "browser.dedicated service wiring": provided once, `status()`, kill switch 403 nothing launched, shutdown → 503 `unloaded`. See change: add-gmail-auto-setup. |
| `__tests__/index.test.ts` | Wiring: both WS scopes + three handlers + REST routes mounted once; `onShutdown` registered; `PI_BROWSER_RELAY_FAKE` unset → no `Fake` row, set → one `Fake` row with a tab. |
| `__tests__/live-view.test.ts` | `LiveViewOnConnect`: first agent tab once, refused open retries, `forget`. #E21. See change: add-dedicated-agent-browser. |
| `__tests__/open-handler.test.ts` | `OpenHandler`: `openAuto` live-view open — audit `auto`, 5 s limiter exempt, refusals `auto refused …`, payload cannot request the exemption. #E21. See change: add-dedicated-agent-browser. |
| `__tests__/operator-connect.test.ts` | Host-targeted connect + link stream against a real `RelayManager`/`RelayInstance` and a `FakeCarrier`: E12–E24 (outcome table, busy/host-busy, foreign-socket drops, 16 MiB, deny-list, unicast), X1–X6, X8, X9 (one connect line, no secrets), host-active/re-bind. See change: add-operator-browser-relay. |
| `__tests__/operator-hosts.test.ts` | Registry: E1–E7, E10, E11, X7 (real keys; derivation, pin, serverId binding, host-active, bounds, pin file prune/cap/reset, no secrets in logs/audit). See change: add-operator-browser-relay. |
| `__tests__/profiles.test.ts` | E52 plugin↔shared parity on 3 on-disk fixtures; E17 (3 profiles incl. duplicate labels + one email, `installed` exactly once, label fallback) + X9 (dir absent / `{not json` / empty cache → one `Default` row + warning naming the path; synthetic row still checks `Default/Extensions`). E16: `passwordManagers` per profile from fixture tree, injected `exists` seam, synthetic `Default` row. See change: add-dedicated-agent-browser. |
| `__tests__/relay-manager-dedicated.test.ts` | `RelayManager.connectDedicated` + lifecycle at manager level over fake spawn: single-flight start, one instance, holder/busy, stop/reset, kill switch, Chrome exit. #R1–#R11 #R13 #R17. See change: add-dedicated-agent-browser. #R14 atomic takeover (agent reconnect inside holder close → busy). #R15 private tab absent from status + subscribe audited. Ephemeral connect: context + downloads dir + dispose on close. `open({instanceId})`. See change: add-gmail-auto-setup. |
| `__tests__/relay-manager.test.ts` | E9 (guid regex BVA, resolve, claim-once, second ext socket 1000), E19 (409 `not-installed` / `busy`), E20 (disconnect param), E21 (kill switch), E28 (`seedFake`), X1 (504 + guid dropped), X10 (503), the both-paths 2.2b decision (`allowMultipleInstancesPerProfile`), and the 2.11 open + connect-latency log lines. |
| `__tests__/routes-dedicated.test.ts` | Fastify `inject` over real manager + `DedicatedChrome` (fake spawn): `dedicated/*` + `PUT /settings` status codes, pipe traffic, audit. #E1 #E3 #E17 #E22–#E24 #R1 #R8 #R10 #R13 #R17. See change: add-dedicated-agent-browser. |
| `__tests__/routes.test.ts` | Task 2.9 + operator hosts over REST (E28/E30, `host` forwarding/validation, DELETE pin). Fastify `inject`: status payload; profiles keyed rows + `hasToken` (token never in the body) + `?profileDirectory=` + `warning`; connect 200/400/403/409(×2)/503/504; disconnect 200/400/404/403; audit newest-first + `?profile=`; `PUT enabled` persists + kill switch + 400 non-boolean. `status` carries `settings`; audit URL assertion now query-stripped (D9). See change: add-dedicated-agent-browser. |
| `__tests__/status.test.ts` | E22 (composed instances/tabs 5+9, no `guid`/`token` keys, `auditSeq`), P3 (100 audit appends → exactly 1 broadcast per 500 ms ending on the last seq; re-arm after a batch; `broadcastNow` cancels a pending emit), 3.4 devtools `reason` passthrough, 3.6 (handler registration, valid/unknown/malformed refs, remote-address forwarding, close → `unsubscribeAll` on every instance). |
| `__tests__/vendor-integrity.test.ts` | Scenario X14/7.57. SHA-256 every file under `relay/vendor/playwright-core/` against `vendor-hashes.json` (set-equality: no edits, no additions); NOTICE carries upstream commit. Also pins shim contracts: `CDPRelayServer` constructs inert, `start()` rejects `not supported — transport is supplied by relay-instance.ts`; registry shim throws on launch; `ManualPromise` real (resolve/reject/isDone). node env. |
| `__tests__/ws-routes-dedicated.test.ts` | Dedicated `cdpUrl` over the real WS routes: no second scope, no gate bypass, `attachCdp` last mile. #R12. See change: add-dedicated-agent-browser. |
| `__tests__/ws-routes.test.ts` | Task 2.5 / E10. Real `http.Server` + `ws` pair: scope/prefix/admitOrigins registration shape; ext live guid → 101 + `attachExtension` + `trackSocket`; ext unknown/empty/malformed/nested guid → 404; cdp `Origin`-bearing → 403 even for live guid; cdp header-less live guid → 101 + `attachCdp`; cdp unclaimed-but-live → 101 (client may arrive first). Core gates NOT re-simulated — see `packages/server/src/__tests__/plugin-ws-route.test.ts`. |
| `__tests__/vendor-hashes.json` | Recorded hashes + `upstreamCommit` (`d1ead3ecca23182f2d06d761c28e3d4edafb6595`, 2026-09-11). Regenerate on refresh — see the embedded `$comment` recipe. |
