AGENTS.md@packages/browser-plugin/src/server/relay · git:20261018.4533be0 · 2026-10-18 · sha256 a8b15427ae4fa7a4
AGENTS.md@packages/browser-plugin/src/server/relay git:20261018.4533be0A
Immutable. This exact content never changes and is served at /api/v1/blob/a8b15427ae4fa7a4.
# DOX — packages/browser-plugin/src/server/relay
Files in this directory. One row per source file. See change: add-browser-relay (task 2.2, design D2).
| File | Purpose |
|------|---------|
| `vendor/` | Vendored playwright-core relay + shims — see `vendor/AGENTS.md`. NEVER edit anything under `vendor/playwright-core/` (hash test `../__tests__/vendor-integrity.test.ts` enforces); refresh = re-copy from upstream + regenerate the hash manifest. Adaptation (bypass `WSServer` transport, deny-list, audit hooks) happens in `relay-instance.ts` via wrapper, never by editing vendor files. |
| `deny-list.ts` | Pure policy: `ALWAYS_DENIED_METHODS` (Storage/Network cookie reads + `Browser.setDownloadBehavior`), `URL_POLICY_METHODS` (`Page.navigate`, `Target.createTarget`) fenced off `file:`/`javascript:`/`data:`/`blob:` and, when `allowedDomains` is non-empty, off host-less/outside-list URLs. `hostAllowed` = exact host, or leading-dot entry for bare host + subdomains, never a suffix lookalike. `denyError` → `{code:-32000, message:"Denied by dashboard relay policy: <m>"}`. Navigation GUARDRAIL, not a sandbox. E12–E14. `Browser.setDownloadBehavior` moved to `ACK_AND_DROP_METHODS`; `ALWAYS_DENIED_METHODS` = 3 cookie verbs. See change: add-browser-editor-pane-tab. `effectiveAllowedDomains(profileList, globalList)` — profile list wins when non-empty, else global; dedicated passes `[]`; `[]` result = any host. See change: add-dedicated-agent-browser. |
| `extension-socket.ts` | Protocol-v2 wire adapter (`{id,method,params}` commands → promise-mapped responses; events → `onmessage`). Exists because upstream's `ExtensionConnection` is private to a hash-pinned vendor file whose only exporter also owns the HTTP listener we must not use. Registers the pending callback BEFORE sending (a synchronously-delivering transport would otherwise lose the reply). |
| `fake-relay-instance.ts` | Test-only socket-less instance for `PI_BROWSER_RELAY_FAKE=1` (design D9): one `Fake` profile tab (`tabId: 1`), a 1×1 JPEG every 100 ms to subscribers (the tick RE-ARMS itself — a one-shot timer would emit one frame then go silent), viewer input echoed into the audit ring. Denied kinds (`evaluate` etc.) audit a `denied` row, mirroring the real viewer-input allowlist. `close(reason)` clears the timer/viewers then fires `deps.onClosed` (same contract as `RelayInstance`), so the manager drops the entry and the row shows Not connected. Env-gated at activation — deliberately NOT config-gated, so a real deployment cannot enable it. Task 2.10b / E28. See change: add-browser-relay. Parity: accepts+audits `resize`, redacts tabs like the real relay. See change: add-browser-editor-pane-tab. |
| `link-socket.ts` | `LinkSocket` = `RelaySocket` over the operator carrier. `send`→`browser_host_relay_data`, `close`→`browser_host_relay_close`, `bufferedAmount`=carrier's, `readyState` follows carrier. `feed(data)` (inbound `ext_data`; >`LINK_MAX_DATA` 16 MiB → close 1009), `remoteClose(code)` (inbound `ext_close`), carrier close → link close. Polls carrier `bufferedAmount`; >`CARRIER_CONGESTED_BYTES` 32 MiB for `CARRIER_CONGESTED_MS` 10 s → close 1013 `carrier-congested` (every link on the carrier polls the same value). Never sees the guid. Tasks 2.19–2.38 / 7.2. See change: add-operator-browser-relay. |
| `relay-instance.ts` | One guid = one extension socket + ≤1 CDP client. Handshake gating (CDP traffic held until `extension.initialized`), 30 s no-CDP-client close (audit `detach/no-cdp-client`), 30 s `Extension not connected` for a CDP-first client, deny-list + tap + audit hooks, `Target detached: devtools` after a `canceled_by_user` detach (recorded by SESSION id — the model drops the tab session — AND by tab id in `devtoolsDetachedTabs`, because the status view needs the tab after the session is gone), `All controlled tabs detached` on last tab. `tabList()` precedence: devtools → `no-session` → tap state → client-screencast-active → `live`; carries `reason` when detached. `_finalize` records the close reason BEFORE closing either socket, so the reciprocating handler cannot claim the wrong cause. Reads vendored `BrowserModel._tabSessions` through one narrow typed accessor (no public tabId→sessionId exists; vendor is uneditable) — pinned by test. Tasks 2.3, 3.2–3.4. `no-session` = no `sessionIdForTab`. `_checkViewability()` recomputes `lastViewableSig` (`tabId:hasSession`) after each CDP msg + extension event; `onStatusChange` only on change; skipped once closed; clears a `devtoolsDetachedTabs` entry once the tab has a NEW session (not in `devtoolsDetachedSessions`). Refused `subscribe` audits `viewer-subscribe-refused` detail `tab:<id> reason:<r>`. See change: fix-browser-live-view-subscribe-and-reopen. Connect page omitted from `tabList` and refused for subscribe/input (audit `viewer-subscribe-refused` reason `extension-page`), dropped from the CDP path; local answers for `Target.setDiscoverTargets` (idempotent announce, mirrors attach/detach, no child sessions), `Target.getTargets`, `Target.attachToTarget`, session-less `Target.getTargetInfo`; `Browser.setDownloadBehavior` ack-and-drop (audit `dropped`); `-32000` on unservable commands; tab title/url overlay via `Target.getTargetInfo` through the extension (per-tab in-flight guard) → `onTabMetaChange`; agent-emulation ownership (`agentEmulation` in tab view). See change: add-browser-editor-pane-tab. Dedicated additions: `globalAllowedDomains?()` live getter (read per command, no reconnect) fed through `effectiveAllowedDomains`; `lastActivity` stamped by client CDP commands + viewer input only (never events/responses; idle sweep D8); `onAgentTabCreated(tabId)` fires when the CLIENT's `Target.createTarget` succeeds (live view D7; throws swallowed); extension-side detach causes audited as `detach` `devtools` / `extension:<reason>` (plain tab close not audited, D9). See change: add-dedicated-agent-browser. |
| `relay-manager.ts` | Guid mint (128-bit) / claim / 60 s unclaimed expiry, `Map<guid>` + `Map<instanceId>` keyed by `profileDirectory`, connect flow (403 disabled / 503 no-desktop / 409 `not-installed` / 409 `busy` / 504 timeout), disconnect by `instanceId`, kill switch that resolves only after every instance closed, `seedFake()`. The kill switch closes the Fake too (its `onClosed` drops the entry); `setEnabled(true)` RE-SEEDS the Fake when one was seeded before (`fakeSeeded`), so a re-enable after a disable leaves the harness with a live instance again. `instanceId` is a non-secret public handle; the guid never reaches a client and there is deliberately no cdpUrl lookup endpoint. Tasks 2.4, 2.9, 2.10b / E9, E19–E21, X1, X10. See change: add-browser-relay. Deps `onTabMetaChange`, `onInstanceClosed`. See change: add-browser-editor-pane-tab. Operator hosts: `connect(profile, host?)` (omitted/`"dashboard"` = unchanged path; else `_connectHost`: `_hostPreflight` D6 table → mint guid+`linkId`+`requestId`, send `browser_host_connect` to the bound carrier only, 60 s consent wait then the 60 s handshake from `ok:true`, `connect_cancel` on every abandon, one `[browser-host] connect` log line per call). `Entry.hostLink` (phase consent→awaiting-open→open) keyed by `byLinkId`/`byRequestId`; `handleHostMessage` owns `connect_result`/`ext_open`/`ext_data`/`ext_close` (owner socket only; drops audited under `host:<hostId>` with the TYPE only; trailing messages for just-closed ids silently dropped). `instancesOn(host, dir?)`; busy keyed (host, profile). `resolve()` flags `operator`; public `attachExtension` refuses an operator guid. Owns `hosts: OperatorHostRegistry`; carrier close → `host-gone`. Tasks 7.3, 7.4, 7.7. See change: add-operator-browser-relay. Dedicated agent browser (D4/D11): `DedicatedDeps` `{chrome, hidden, availability}`; `connectDedicated({client?,holder?})` (single-flight Chrome start, ≤1 live instance under `@dedicated`, `busy` + holder, 30 s no-CDP-client timer), `openDedicated(url)` (hidden https tab, audits `open` itself when no instance), `stopDedicated`, `resetDedicated` (stopped only → 409 `not-stopped`), `dedicatedStatus()`/`dedicatedPolicyStatus()`, `liveDedicated()`, `activityOf(instanceId)`, live `globalAllowedDomains`, `onAgentTabCreated` hook, `resolve(guid)` gains `dedicated`. `closeAll`/kill switch also stops Chrome; Chrome exit closes the instance `closed:chrome-exited`. See change: add-dedicated-agent-browser. |
| `screencast-tap.ts` | One tap per `(instance, tabId)` with ≥1 viewer: `Page.startScreencast` on that tab's session, immediate unconditional ack (the extension throttles on acks), frames sent PER SOCKET (the only way `bufferedAmount` can skip one viewer), 2 s `no-frames`, refuse subscribe when the CDP client owns the screencast, stop on last unsubscribe/close. Framed events are filtered from the CDP-client stream by `handlesSession`. Tasks 3.2, 3.4, 3.5. `subscribe(viewer, tab, {local})`: `local` honoured only from `deps.tee.carrier` (else normal + audit `denied` `browser_host:local-ignored`); `localViewers` count as viewers but get unicast `browser_host_tee` on/off (mode transitions only) instead of frames; ack unchanged. Task 7.6 / D10. See change: add-operator-browser-relay. `agentEmulationActive` dep refuses viewer `resize`; tracks `view.resized`; best-effort `Emulation.clearDeviceMetricsOverride` on last unsubscribe / `closeAll`; `releaseRelayOverride`. See change: add-browser-editor-pane-tab. |
| `viewer-input.ts` | Pure viewer→CDP allowlist: `mouse`/`key`/`scroll`/`bringToFront` → `Input.dispatchMouseEvent`/`dispatchKeyEvent`/`synthesizeScrollGesture`/`Page.bringToFront`; nothing reaches `Runtime.*`. Coordinates are normalized `[0,1]` scaled by the LAST frame's device geometry; out-of-range is REFUSED, never clamped. `key`/`bringToFront` need no geometry. E23, E24. Input kind `resize {width,height}` → `Emulation.setDeviceMetricsOverride`, clamped 320–3840 × 240–2160 (`RESIZE_MIN/MAX`); non-numeric refused. See change: add-browser-editor-pane-tab. |
Files in `__tests__/`:
| File | Purpose |
|------|---------|
| `__tests__/fake-socket.ts` | In-process `RelaySocket` pair (`FakeSocket.sent` = frames it sent, `.received` = frames it received — the CDP client RECEIVES, so its assertions read `receivedJson()`) + `FakeExtension` speaking protocol v2 (`chrome.debugger.*`, `chrome.tabs.*`, `initialize()`, `detach()`, `tabRemoved()`, `emitFrame()`). Delivery is SYNCHRONOUS on purpose — that is what caught the publish-before-register bug. |
| `__tests__/fake-host.ts` | Operator-host doubles: `makeIdentity` (real Ed25519), `FakeCarrier` (observable `/ws` socket), `registerHost`, `completeConnect`, `attachLinkedExtension` (relay frames ⇄ `browser_host_ext_data`). See change: add-operator-browser-relay. |
| `__tests__/link-socket.test.ts` | `LinkSocket`: wire shapes, 16 MiB/+1 → 1009 (E21), carrier mirror, congestion 33 MiB → 1013 at 10 s not 9.9 s, every link closes, dip resets, no timer after close (P2, X8). See change: add-operator-browser-relay. |
| `__tests__/deny-list.test.ts` | E12 (four always-denied verbs, `-32000` + policy message, ordinary automation forwarded) + E13 (scheme fence on both verbs, fail-closed on missing/unparseable url) + E14 (exact vs leading-dot table, case-insensitivity, port/path ignored). E12: `effectiveAllowedDomains` table + dedicated global-only. See change: add-dedicated-agent-browser. |
| `__tests__/relay-activity.test.ts` | `RelayInstance` `lastActivity` stamped by client commands + viewer input only; `onAgentTabCreated` fires on client `Target.createTarget` success, not for extension-announced tabs. See change: add-dedicated-agent-browser. |
| `__tests__/relay-instance.test.ts` | The relay-core contract (29 tests): handshake gating + `Target.setAutoAttach`, the `tabId → pw-tab-N` mapping pin, socket exclusivity (E9/E11), deny-list on the CDP path (E12–E14), tap delivery/ack/stop/backpressure (3.2, P2), CDP-client screencast precedence (E26/E27), viewer input (E23/E24), DevTools detach (X7), and every close path (X3–X6). E12 (live global list, profile override), E13 (audit query stripping), E14 (detach causes), E15 (close). See change: add-dedicated-agent-browser. |
| `__tests__/screencast-tap.test.ts` | Local viewers E25–E28 (tee on/off unicast, no frames to a local viewer, ack kept, `local-ignored` audit, mode switch ordering, last-unsubscribe stops screencast). X13 (viewer drops mid-stream, survivors keep streaming), X8 (no-frames → live → no-frames + the status notify), P2 (skip only the over-threshold viewer; ack always), P1 (10 fps source sustained), subscribe refusals (E27 / detached), teardown. |
| `__tests__/viewer-input.test.ts` | E23 coordinate BVA (0/0.5/1 mapped, just-outside refused not clamped, no-geometry refused) + E24 kind allowlist, click as press+release, "never Runtime.*". |