History

Remove outdated plans for shared HTTP MCP server and visible preview browser automation via CDP. Consolidate and refine the architecture and process models for the new preview automation framework, including updated contracts, server-side broker, and desktop integration. Introduce new WS methods for client communication and enhance security measures for token management. Ensure comprehensive testing strategies are in place for all components.

Julius Marminge committed 5 months ago

Commit cd91ec71 parent d4ba9e928 files · +0 −6147Browse files

Unified · Split

28 files
  1. .plans/browser-phase-0-5/006-renderer-failure-boundary.md +0 −35
  2. .plans/browser-phase-0-5/007-playwright-injected-runtime.md +0 −40
  3. .plans/browser-phase-0-5/008-recording-endurance.md +0 −32
  4. .plans/browser-phase-0-5/009-loopback-threat-model.md +0 −33
  5. .plans/browser-phase-0-5/010-human-input-interruption.md +0 −23
  6. .plans/browser-phase-0-5/README.md +0 −23
  7. .plans/browser-phase-0-5/findings.md +0 −27
  8. .plans/browser-phase-0/001-browser-host.md +0 −24
  9. .plans/browser-phase-0/002-automation-adapter.md +0 −33
  10. .plans/browser-phase-0/003-recording.md +0 −33
  11. .plans/browser-phase-0/004-remote-preview-tunnel.md +0 −38
  12. .plans/browser-phase-0/005-desktop-routing.md +0 −24
  13. .plans/browser-phase-0/README.md +0 −33
  14. .plans/browser-phase-0/findings.md +0 −156
  15. .plans/browser-phase-0/results/.gitignore +0 −3
  16. .plans/browser-phase-0/spikes/electron-webview-bootstrap.cjs +0 −16
  17. .plans/browser-phase-0/spikes/electron-webview-host.mjs +0 −736
  18. .plans/browser-phase-0/spikes/guest.html +0 −41
  19. .plans/browser-phase-0/spikes/host-preload.cjs +0 −51
  20. .plans/browser-phase-0/spikes/host.html +0 −12
  21. .plans/browser-phase-0/spikes/run-electron-spikes.mjs +0 −191
  22. .plans/browser-phase-0/spikes/run-gateway-spike.mjs +0 −243
  23. .plans/browser-phase-0/spikes/run-tunnel-security-spike.mjs +0 −98
  24. .plans/browser-phase-0/spikes/tunnel-security-electron.cjs +0 −16
  25. .plans/collaborative-browser-architecture.html +0 −1981
  26. .plans/collaborative-browser-runtime-next-iteration.md +0 −1015
  27. .plans/shared-http-mcp-server-with-preview-automation-revised.md +0 −520
  28. .plans/visible-preview-browser-automation-via-cdp-mcp.md +0 −670
.plans/browser-phase-0-5/006-renderer-failure-boundary.md+0−35
@@ -1,35 +0,0 @@
1−# ADR 006: Renderer Failure Boundary
2−
3−## Status
4−
5−Accepted, revising ADR 001 terminology and failure handling.
6−
7−## Decision
8−
9−Keep the renderer-hosted Electron `<webview>` for the next implementation iteration because it supports normal renderer composition, annotations, and the selected covered-surface recording strategy.
10−
11−Do not describe this as process-durable. It is:
12−
13−- durable across React unmounts, route changes, panel closure, and tab switching;
14−- not durable across host-renderer reload, host-renderer crash, window destruction, or application restart.
15−
16−The logical `BrowserSession` and `BrowserTab` records survive host loss. The live page process state does not. When the host disappears, the environment server marks the host unavailable and tabs as lost rather than pretending the DOM, JavaScript heap, sockets, and history can be recovered.
17−
18−## Evidence
19−
20−The `renderer-reload` spike created both a renderer `<webview>` and a main-owned `WebContentsView`, modified state in each, and reloaded the host renderer:
21−
22−- `<webview>` guest id changed from `2` to `4`, the first guest was destroyed, and count reset from `1` to `0`;
23−- `WebContentsView` retained id `3`, remained alive, and retained count `1`.
24−
25−## Product Requirements
26−
27−- Show an explicit "browser page was lost" state with last known URL, title, screenshot, and artifacts.
28−- Offer restart-and-restore as a new browser process, clearly reporting which state cannot be restored.
29−- Never silently bind a replacement guest to the old live-process identity.
30−- Bind every desktop browser host to one window id and define window-close as host loss.
31−- A future multi-window implementation must explicitly move or restart sessions; `<webview>` elements cannot be reparented across renderer documents.
32−
33−## Rejected for This Iteration
34−
35−Migrating the collaborative surface to `WebContentsView` solely for renderer-reload survival remains rejected because Phase 0 found hidden/detached recording and overlay composition unsuitable. The trade is now explicit: collaborative composition and recording are prioritized over host-renderer reload survival.
.plans/browser-phase-0-5/007-playwright-injected-runtime.md+0−40
@@ -1,40 +0,0 @@
1−# ADR 007: Playwright Injected Runtime
2−
3−## Status
4−
5−Accepted as the initial implementation direction; supersedes the bespoke interpretation of ADR 002.
6−
7−## Decision
8−
9−Load a version-pinned Playwright injected runtime into each guest frame through the persistent CDP connection. Use it for selector parsing, role/name/text/test-id resolution, shadow DOM traversal, element-state checks, hit-target checks, and locator re-resolution.
10−
11−T3 continues to own:
12−
13−- browser sessions, hosts, tabs, command queues, authorization, and cancellation;
14−- CDP target discovery and per-frame execution contexts;
15−- native input dispatch, navigation, dialogs, downloads, artifacts, and remote reachability;
16−- the stable T3 automation contract exposed to agents.
17−
18−Do not expose Playwright internal objects directly as the product API.
19−
20−## Evidence
21−
22−The `injected-runtime` spike extracted the runtime bundled with installed Playwright 1.60.0 and evaluated it inside the real Electron guest over CDP:
23−
24−- injected source size: `307,101` bytes;
25−- install time: approximately `6.5–8.5 ms` across the recorded runs;
26−- role/name locator matched the button;
27−- role/name locator pierced an open shadow root;
28−- the same locator re-resolved after the input element was replaced;
29−- same-origin frames still require a separate injected runtime and routing context.
30−
31−## Constraints
32−
33−- Playwright's injected runtime is internal and version-coupled. Pin its version and run compatibility fixtures on upgrades.
34−- OOPIFs remain separate CDP targets. The Browser Control Service must enable target auto-attach and maintain frame-to-session routing.
35−- Locators are durable query descriptions resolved at action time. Snapshot node references are optional, short-lived accelerators only.
36−- The T3 adapter must compare key behavior against real Playwright tests for actionability, shadow DOM, frames, rerenders, and strictness.
37−
38−## Fallback
39−
40−If a future Playwright release makes the injected runtime impractical to consume, retain the T3 contract and replace only the internal locator provider. Do not leak the vendored runtime across architecture boundaries.
.plans/browser-phase-0-5/008-recording-endurance.md+0−32
@@ -1,32 +0,0 @@
1−# ADR 008: Covered Recording Is Provisional
2−
3−## Status
4−
5−Accepted with concurrency and platform gates.
6−
7−## Decision
8−
9−Keep CDP screencast plus Chromium MediaRecorder and the covered full-size parking surface for the first recording implementation.
10−
11−Default to one actively recorded tab per desktop window until multi-tab budgets are measured. Hidden idle tabs remain offscreen; hidden recording tabs remain composited and covered.
12−
13−## Evidence
14−
15−The `recording-endurance` spike recorded a `1600×1200` covered webview for ten seconds at a requested 12 fps:
16−
17−- 111–112 sampled frames, approximately 11.1–11.2 fps across the recorded runs;
18−- 9.98-second seekable H.264 MP4;
19−- main-process CPU sample approximately 3.5–3.8%;
20−- observed renderer working sets approximately 146 MiB and 99 MiB, plus GPU working set approximately 149 MiB.
21−
22−This is stronger than the original 800×600 proof, but it is not a production concurrency budget.
23−
24−## Required Before Raising Concurrency
25−
26−- 30-minute soak with active timers, animation, HMR, and navigation;
27−- two and four simultaneous recording tabs;
28−- Retina device-scale-factor coverage;
29−- window resize, minimization, macOS Spaces, sleep/wake, and display changes;
30−- explicit `backgroundThrottling` policy and regression tests;
31−- proof that the cover cannot receive or leak pointer input to the parked guest;
32−- bounded frame queues with drop metrics and encoder-failure finalization.
.plans/browser-phase-0-5/009-loopback-threat-model.md+0−33
@@ -1,33 +0,0 @@
1−# ADR 009: Loopback Is Not an Authorization Boundary
2−
3−## Status
4−
5−Accepted, revising ADR 004.
6−
7−## Threat
8−
9−A listener bound to `127.0.0.1` can be reached by any local process. Browser content can also cause cross-origin requests to it even when CORS prevents reading the response. Possession of a loopback port therefore cannot authorize access to a remote environment.
10−
11−## Evidence
12−
13−The `run-tunnel-security-spike.mjs` probe loaded an unrelated page in Chromium and issued a `no-cors` request to another loopback port. The request reached the preview listener. The request did not include an `Origin` header, so origin-header validation alone would not reject it.
14−
15−The same probe confirmed:
16−
17−- local storage survives when the exact loopback authority remains stable;
18−- changing only the port creates a new origin and loses that storage;
19−- an absolute HTTPS redirect passes through unchanged and escapes the loopback authority.
20−
21−## Decision
22−
23−- Allocate one stable local authority per environment/project/target tuple for the lifetime of its origin grant.
24−- Treat the authority as a browser capability, not a random open port.
25−- The desktop browser host must restrict navigation to authorities granted to that browser session and prevent unrelated tabs from using preview authorities.
26−- Environment tunnel grants remain scoped to environment, session, target host, and target port, but server-side grants do not protect the desktop listener from local callers.
27−- Add a desktop-side connection broker that verifies the connecting browser guest belongs to the authorized session. A bare TCP listener without browser attribution is insufficient for the final design.
28−- Absolute redirects, OAuth callbacks, HTTPS upstreams, service workers, and configured public origins require explicit compatibility policy rather than a "no rewriting" guarantee.
29−- Replace one-WebSocket-per-TCP-stream before relay rollout unless load testing proves the relay limits acceptable. The target design is bounded multiplexing over dedicated authenticated tunnel connections.
30−
31−## Open Implementation Choice
32−
33−The browser-attribution mechanism needs a dedicated spike. Candidates include an Electron protocol/proxy integration, per-session proxy configuration, or another browser-mediated connection path that preserves ordinary application semantics while identifying the requesting guest. Port secrecy is explicitly rejected.
.plans/browser-phase-0-5/010-human-input-interruption.md+0−23
@@ -1,23 +0,0 @@
1−# ADR 010: Human Input Interruption
2−
3−## Status
4−
5−Accepted for the initial lease implementation, with regression coverage.
6−
7−## Decision
8−
9−Observe native user keyboard and pointer activity at the Electron guest/window boundary and revoke or pause the conflicting automation lease. Do not implement a timing-based suppression window unless a supported platform demonstrates that CDP-dispatched input is reported through the same native event path.
10−
11−## Evidence
12−
13−The `input-origin` spike attached Electron's `before-input-event` listener to the guest and dispatched keyboard input through CDP. No `before-input-event` was emitted.
14−
15−This contradicts the assumption that CDP keyboard input is necessarily indistinguishable from human input at this boundary. It does not prove every Electron version, platform, input method, or pointer path behaves identically.
16−
17−## Requirements
18−
19−- Cover keyboard, pointer, wheel, touch, IME, accessibility input, and DevTools interaction where available.
20−- Record the interruption reason and the command that was cancelled.
21−- Serialize lease transition and command cancellation so a late automation event cannot land after the user takes control.
22−- Keep a regression fixture for CDP keyboard and mouse dispatch on every supported desktop platform.
23−- Remove the undefined `shared` controller mode. Initial modes are `human`, `agent`, and `none`.
.plans/browser-phase-0-5/README.md+0−23
@@ -1,23 +0,0 @@
1−# Browser Phase 0.5
2−
3−Phase 0.5 closes the production-risk questions raised after the original browser spikes. It does not add application behavior. It expands the executable harness and revises the architecture decisions before session implementation begins.
4−
5−## Commands
6−
7−```bash
8−node .plans/browser-phase-0/spikes/run-electron-spikes.mjs renderer-reload
9−node .plans/browser-phase-0/spikes/run-electron-spikes.mjs injected-runtime
10−node .plans/browser-phase-0/spikes/run-electron-spikes.mjs recording-endurance
11−node .plans/browser-phase-0/spikes/run-electron-spikes.mjs input-origin
12−node .plans/browser-phase-0/spikes/run-tunnel-security-spike.mjs
13−```
14−
15−## Decisions
16−
17−- `006-renderer-failure-boundary.md`: keep `<webview>` for this iteration, but describe it as route-durable rather than process-durable and specify session-loss behavior.
18−- `007-playwright-injected-runtime.md`: use Playwright's injected runtime as the initial semantic locator/actionability substrate, behind a T3-owned adapter and compatibility tests.
19−- `008-recording-endurance.md`: retain covered parking provisionally, with a one-recording default and further soak/platform tests required before widening concurrency.
20−- `009-loopback-threat-model.md`: a plain loopback listener is not an authorization boundary; stable origins and a browser-enforced capability authority are required.
21−- `010-human-input-interruption.md`: Electron `before-input-event` did not observe CDP keyboard dispatch in the tested guest, so human interruption can start with native input observation without a dispatch suppression window, while retaining regression coverage.
22−
23−See `findings.md` for measured results and the resulting roadmap changes.
.plans/browser-phase-0-5/findings.md+0−27
@@ -1,27 +0,0 @@
1−# Phase 0.5 Findings
2−
3−## Executive Decisions
4−
5−1. The selected `<webview>` is route-durable, not process-durable. Renderer reload destroys its guest and state; `WebContentsView` survives that specific failure boundary.
6−2. Use Playwright's injected runtime behind the T3 Browser Control Service instead of implementing locator and actionability semantics from scratch.
7−3. Use locator descriptions that re-resolve at action time. Treat snapshot element references as ephemeral.
8−4. Covered recording remains viable at 1600×1200 for one tab, but concurrency and long-duration operation remain gated.
9−5. A loopback port is not an authorization capability. Stable origin, browser attribution, and bounded tunnel multiplexing are required before remote preview ships.
10−6. CDP keyboard dispatch did not trigger Electron `before-input-event` in the tested guest. Start with native input interruption and no timing suppression hack.
11−
12−## Roadmap Changes
13−
14−- Add host loss, window identity, session-loss UX, annotation preservation, port discovery, and guest hardening to the session foundation phase.
15−- Put persistent CDP, Playwright injected-runtime integration, console buffers, and network buffers in the automation phase.
16−- Add OOPIF target routing and per-frame injected runtimes to the automation acceptance criteria.
17−- Move remote preview behind a second browser-attribution spike; raw TCP transport feasibility alone is insufficient.
18−- Keep one active recording per window as the initial policy.
19−- Remove `shared` controller mode until concurrency semantics exist.
20−
21−## Remaining Open Questions
22−
23−- Browser-attributed local tunnel ingress that preserves application origin behavior.
24−- Multi-tab recording budgets and 30-minute platform soak results.
25−- Pointer/touch interruption behavior across macOS, Windows, and Linux.
26−- Recovery fidelity after renderer/window loss.
27−- Web-app viewing UX for separately hosted environment Chromium sessions.
.plans/browser-phase-0/001-browser-host.md+0−24
@@ -1,24 +0,0 @@
1−# ADR 001: Durable Renderer-Hosted Webview
2−
3−## Status
4−
5−Accepted with the failure-boundary amendment in `.plans/browser-phase-0-5/006-renderer-failure-boundary.md`.
6−
7−## Decision
8−
9−Keep Electron `<webview>` as the collaborative browser surface, but move it out of thread panel lifecycle into a route-durable app-level browser host. This does not survive host-renderer reload, renderer crash, owning-window close, or application restart.
10−
11−The host owns one mounted webview per live browser tab. The visible browser panel supplies bounds and visibility intent. Hiding, switching routes, closing a sheet, or unmounting a panel must not remove the webview element or close its guest `WebContents`.
12−
13−The desktop main process remains authoritative for navigation, CDP, recording, security policy, and guest `WebContents` validation. The renderer host is responsible only for maintaining the required DOM attachment and reporting presentation bounds.
14−
15−## Hidden Presentation Policy
16−
17−- Visible tab: place the webview at the panel bounds.
18−- Hidden idle tab: park it offscreen at its preserved viewport size; automation remains available but continuous screencast is not expected.
19−- Hidden recording tab: place it on a full-size in-window parking surface covered by an opaque application layer so Chromium continues producing compositor frames.
20−- Suspended tab: explicit lifecycle transition that may destroy and later reload the page; never perform silently while controlled or recording.
21−
22−## Rejected Alternative
23−
24−`WebContentsView` is not selected for this iteration. It is durable and Playwright-visible, but hidden/detached capture semantics failed the recording requirements and its native stacking model complicates overlays and clipping.
.plans/browser-phase-0/002-automation-adapter.md+0−33
@@ -1,33 +0,0 @@
1−# ADR 002: Browser-Client-Style Semantic CDP Adapter
2−
3−## Status
4−
5−Superseded in implementation detail by `.plans/browser-phase-0-5/007-playwright-injected-runtime.md`.
6−
7−## Decision
8−
9−Build the primary automation adapter over a persistent Electron debugger connection using CDP accessibility, DOM, runtime, page, network, and input domains.
10−
11−The adapter should offer Playwright-like concepts—role/name locators, text locators, frames, auto-waiting, stale references, dialogs, downloads, and actionability checks—but it must not depend on Playwright owning the browser or recognizing the guest as a `Page`.
12−
13−Use two layers:
14−
15−1. main-process CDP session and command/event transport
16−2. the version-pinned Playwright injected runtime behind a T3-owned compatibility adapter
17−
18−Locator descriptions re-resolve at action time. Snapshot-scoped backend-node references are optional and ephemeral.
19−
20−## Playwright Role
21−
22−Use Playwright as:
23−
24−- a behavior reference
25−- a test oracle against standalone test pages
26−- the initial injected selector/actionability runtime
27−- an optional adapter for future environment-hosted Chromium sessions
28−
29−Do not expose a production Electron remote-debugging port merely to attach Playwright.
30−
31−## agent-browser Role
32−
33−agent-browser may be supported through a compatible CLI or command adapter backed by the same Browser Control Service. It must not launch an unrelated browser for collaborative preview sessions.
.plans/browser-phase-0/003-recording.md+0−33
@@ -1,33 +0,0 @@
1−# ADR 003: CDP Capture with Chromium MediaRecorder
2−
3−## Status
4−
5−Accepted with the concurrency and soak gates in `.plans/browser-phase-0-5/008-recording-endurance.md`.
6−
7−## Decision
8−
9−Capture the collaborative guest page with `Page.startScreencast`, sample frames at the configured recording frame rate, draw them into an isolated recording canvas, and encode through Chromium MediaRecorder.
10−
11−Initial artifact format:
12−
13−- H.264 MP4
14−- `video/mp4;codecs=avc1.42E01E`
15−- default 12 fps
16−- default viewport resolution, bounded by recording policy
17−
18−Encoding runs in a dedicated hidden renderer or utility surface, not in the primary React render path. Frame transport must be bounded and may drop intermediate frames under pressure rather than accumulating memory.
19−
20−## Hidden Recording
21−
22−An offscreen webview does not continuously produce screencast frames. While recording a hidden tab, the browser host temporarily places the full-size webview on a covered parking surface. The user continues seeing the normal application UI, not the frame stream.
23−
24−## Artifact Semantics
25−
26−Video is paired with structured action events and timestamps. Video is evidence, not browser state and not the interactive UX.
27−
28−## Rejected Alternatives
29−
30−- external ffmpeg as a required encoder
31−- `desktopCapturer` capture of the whole application window
32−- JPEG/video streaming as the browser UI
33−- recording only when the preview panel is open
.plans/browser-phase-0/004-remote-preview-tunnel.md+0−38
@@ -1,38 +0,0 @@
1−# ADR 004: Raw TCP Preview Tunnel
2−
3−## Status
4−
5−Transport accepted; desktop ingress and authorization amended by `.plans/browser-phase-0-5/009-loopback-threat-model.md`.
6−
7−## Decision
8−
9−Remote environment ports are exposed to the desktop browser through:
10−
11−```text
12−browser webview
13− -> browser-attributed stable desktop authority
14− -> dedicated authenticated WebSocket tunnel
15− -> environment server tunnel endpoint
16− -> environment loopback TCP target
17−```
18−
19−Multiplex TCP streams over a bounded number of dedicated authenticated tunnel connections before relay rollout. The protocol must preserve backpressure, half-close, cancellation, and connection-specific errors.
20−
21−The browser navigates to a stable desktop authority, preserving normal origin and root-relative URL behavior. A bare loopback listener is not an authorization boundary, and absolute redirects, OAuth callbacks, and HTTPS upstreams require explicit policy.
22−
23−## Isolation
24−
25−Tunnel data uses a dedicated connection and priority class. It must not share the normal RPC/event WebSocket because large responses or stalled browser streams must not delay thread control traffic.
26−
27−## Authorization
28−
29−Each listener is backed by a short-lived grant scoped to:
30−
31−- environment
32−- browser session
33−- target loopback host
34−- target port
35−- protocol policy
36−- provider/thread authorization where agent-created
37−
38−The environment server validates the grant before opening any TCP target.
.plans/browser-phase-0/005-desktop-routing.md+0−24
@@ -1,24 +0,0 @@
1−# ADR 005: Retain Renderer Transport Relay Initially
2−
3−## Status
4−
5−Accepted for Phase 1; revisit after lifecycle migration.
6−
7−## Decision
8−
9−Do not add a second environment-server connection from desktop main solely to remove renderer IPC.
10−
11−The current renderer-mediated transport can remain temporarily:
12−
13−```text
14−environment server -> renderer connection -> typed IPC -> desktop Browser Control Service
15−```
16−
17−The renderer must no longer decide browser existence, visible-only eligibility, or ownership. It relays authenticated commands and browser-host events between transports.
18−
19−## Rationale
20−
21−Measured renderer IPC overhead was approximately 0.04 ms at the median and approximately 0.20 ms at p95 above direct CDP for a no-op command. That is negligible compared with page work and remote network latency.
22−
23−The architectural problem is lifecycle coupling and failure ownership, not local IPC performance. A direct desktop-main network connection may still be introduced later for reliability or background operation, but it is not required to begin the durable session migration.
24−
.plans/browser-phase-0/README.md+0−33
@@ -1,33 +0,0 @@
1−# Browser Phase 0 Spikes
2−
3−These spikes validate the architectural decisions recorded beside this file. They exercise Electron 41 against real browser surfaces rather than mocking browser lifecycle or CDP behavior.
4−
5−## Environment Used for Recorded Results
6−
7−- macOS 26.5.1, arm64
8−- Apple M4 Max, 64 GiB RAM
9−- Electron 41.5.0
10−- installed Playwright 1.60.0
11−- Node.js 25.8.2 orchestration process
12−
13−Results are documented in `findings.md`. Generated frames, videos, screenshots, logs, and transient JSON files under `results/` are intentionally ignored.
14−
15−## Commands
16−
17−```bash
18−node .plans/browser-phase-0/spikes/run-electron-spikes.mjs automation
19−node .plans/browser-phase-0/spikes/run-electron-spikes.mjs view-automation
20−node .plans/browser-phase-0/spikes/run-electron-spikes.mjs hidden
21−node .plans/browser-phase-0/spikes/run-electron-spikes.mjs offscreen-recording
22−node .plans/browser-phase-0/spikes/run-electron-spikes.mjs covered-recording
23−node .plans/browser-phase-0/spikes/run-electron-spikes.mjs media-recorder
24−node .plans/browser-phase-0/spikes/run-electron-spikes.mjs latency
25−node .plans/browser-phase-0/spikes/run-gateway-spike.mjs
26−```
27−
28−The Electron harness uses the repository's installed Electron and Playwright packages. The gateway harness uses the locked `ws` package already present in the pnpm installation.
29−
30−## Scope
31−
32−These are architectural probes, not production modules. They deliberately avoid changing application behavior. Production work must reimplement the selected patterns with typed contracts, Effect services, cancellation, security policy, tests, and resource cleanup.
33−
.plans/browser-phase-0/findings.md+0−156
@@ -1,156 +0,0 @@
1−# Browser Phase 0 Findings
2−
3−## Executive Result
4−
5−Phase 0 resolves the major architectural unknowns:
6−
7−1. Keep a real Electron `<webview>` as the collaborative browser surface for the next iteration.
8−2. Move that webview into a durable app-level host independent of panel and route mounting.
9−3. Build a browser-client-style semantic automation adapter over persistent CDP, not direct Playwright attachment.
10−4. Use CDP screencast for capture and Chromium MediaRecorder for seekable H.264 MP4 encoding.
11−5. Use a desktop loopback listener plus a dedicated authenticated raw-TCP-over-WebSocket tunnel for remote environment ports.
12−6. Keep the renderer as the desktop/server transport relay initially; remove its browser lifecycle authority, not the relay itself.
13−
14−## Automation Attachment
15−
16−### Existing `<webview>`
17−
18−Electron remote debugging exposed two targets:
19−
20−- host renderer: target type `page`
21−- guest browser: target type `webview`
22−
23−Playwright `chromium.connectOverCDP()` returned only the host renderer as a `Page`. Connecting directly to the guest target's debugger WebSocket succeeded at the transport level but produced zero Playwright pages.
24−
25−Conclusion: direct Playwright locators cannot drive the current guest webview through the supported `connectOverCDP` API.
26−
27−### Semantic CDP adapter
28−
29−The spike used:
30−
31−- `Accessibility.getFullAXTree`
32−- role and accessible-name matching
33−- `DOM.getBoxModel`
34−- `Input.dispatchMouseEvent`
35−- `DOM.resolveNode`
36−- `Runtime.callFunctionOn`
37−
38−It located and clicked a button by role/name and filled a textbox by role/name against the exact guest page. The resulting page state was correct.
39−
40−Conclusion: a browser-client-style adapter can provide semantic locators without Playwright owning or directly attaching to the guest page.
41−
42−### `WebContentsView` comparison
43−
44−A main-process-owned `WebContentsView` appears as a normal Playwright `Page`; Playwright role and textbox locators succeeded.
45−
46−However:
47−
48−- hidden or detached view capture was inconsistent or unavailable
49−- detached `capturePage()` reported no current display surface
50−- detached CDP screenshot could hang without an explicit timeout
51−- hidden/detached CDP screencast emitted zero frames
52−- native child views add clipping, stacking, and renderer-overlay integration costs
53−
54−Conclusion: do not migrate the collaborative browser to `WebContentsView` in this iteration merely to obtain direct Playwright attachment.
55−
56−## Hidden Lifecycle
57−
58−For the existing `<webview>`:
59−
60−- the window was hidden for 1.5 seconds
61−- the page timer advanced 15 ticks
62−- semantic automation incremented page state
63−- `capturePage()` returned a 1600×1200 Retina capture
64−- the page remained alive and controllable
65−
66−Observed process snapshot for the minimal two-renderer harness:
67−
68−- main-process private memory: approximately 43 MiB
69−- Electron Browser working set: approximately 172 MiB
70−- GPU process working set: approximately 100 MiB
71−- host renderer working set: approximately 88 MiB
72−- guest renderer working set: approximately 93 MiB
73−- measured main-process CPU during the 1.5-second hidden interval: approximately 0.04%
74−
75−These are development-machine snapshots, not production budgets. They prove lifecycle behavior and establish that each retained page has meaningful renderer memory cost.
76−
77−## Recording
78−
79−### CDP screencast behavior
80−
81−`Page.startScreencast` against the guest webview produced frames while:
82−
83−- the whole BrowserWindow was hidden
84−- the webview remained full-size and was covered by an opaque renderer element
85−
86−Moving the webview offscreen reduced the stream to one frame. Therefore hidden idle parking and hidden recording require different presentation policies.
87−
88−### External ffmpeg proof
89−
90−The initial proof sampled CDP frames at 12 fps and encoded H.264 MP4 with ffmpeg:
91−
92−- 28 frames
93−- 800×600
94−- 12 fps
95−- 2.33-second duration
96−- valid seekable MP4
97−
98−This proved the capture pipeline but is not the selected production encoder because shipping an external ffmpeg executable is unnecessary.
99−
100−### Selected Chromium-native encoder
101−
102−The final proof sent sampled CDP frames into an isolated canvas and used Chromium MediaRecorder:
103−
104−- MIME: `video/mp4;codecs=avc1.42E01E`
105−- codec: H.264
106−- 800×600
107−- approximately 11 fps from a requested 12 fps
108−- 2.4617-second duration
109−- 12,823-byte seekable MP4 artifact
110−- no external ffmpeg process used for encoding
111−
112−Conclusion: select CDP screencast plus Chromium MediaRecorder H.264 MP4 for the initial recording backend.
113−
114−## Remote Preview Tunnel
115−
116−Two proxy shapes were tested:
117−
118−1. HTTP-aware reverse proxy with WebSocket upgrade handling.
119−2. Desktop loopback TCP listener forwarding raw streams over a dedicated WebSocket to an environment-side TCP dialer.
120−
121−Both loaded the HTTP page and passed 100 WebSocket echo round trips. The raw TCP tunnel is selected because it preserves origin paths, root-relative assets, arbitrary HTTP semantics, HMR upgrades, and application WebSockets without rewriting response bodies.
122−
123−Five local runs of the raw TCP tunnel produced:
124−
125−- added HTTP median latency: 0.036–0.112 ms; median run 0.049 ms
126−- WebSocket median round-trip: 0.075–0.138 ms; median run 0.102 ms
127−- WebSocket p95 round-trip: 0.133–0.287 ms; median run 0.151 ms
128−
129−Network latency will dominate in real remote environments. The local result shows the framing/proxy architecture itself is not a meaningful latency source.
130−
131−## Desktop Routing Latency
132−
133−The spike compared 100 no-op CDP evaluations:
134−
135−- direct main-process CDP call
136−- renderer `ipcRenderer.invoke` relay to the same main-process CDP call
137−
138−Across five runs:
139−
140−- direct median: 0.058–0.066 ms; median run 0.060 ms
141−- renderer-relay median: 0.100 ms
142−- direct p95: 0.101–0.115 ms; median run 0.105 ms
143−- renderer-relay p95: approximately 0.300 ms
144−
145−Conclusion: renderer IPC overhead is negligible compared with browser work and remote networking. The renderer should lose lifecycle and ownership authority, but replacing the existing server-to-renderer transport relay is not a Phase 1 performance requirement.
146−
147−## Explicit Unsupported or Rejected Paths
148−
149−- Direct Playwright `connectOverCDP` to an Electron `<webview>` guest.
150−- Enabling an unauthenticated process-wide Chromium remote-debugging port in production.
151−- Switching to `WebContentsView` solely for Playwright compatibility.
152−- Recording from an offscreen or detached surface without temporarily restoring a composited parking surface.
153−- Using screenshots, JPEG frames, or video as the interactive browser presentation.
154−- HTTP path-prefix rewriting as the general remote preview solution.
155−- Sending preview tunnel data over the normal control/event WebSocket.
156−
.plans/browser-phase-0/results/.gitignore+0−3
@@ -1,3 +0,0 @@
1−*
2−!.gitignore
3−
.plans/browser-phase-0/spikes/electron-webview-bootstrap.cjs+0−16
@@ -1,16 +0,0 @@
1−const { app } = require("electron");
2−
3−const portArgument = process.argv.find((value) => value.startsWith("--port="));
4−if (portArgument) {
5− app.commandLine.appendSwitch("remote-debugging-address", "127.0.0.1");
6− app.commandLine.appendSwitch("remote-debugging-port", portArgument.slice("--port=".length));
7−}
8−app.commandLine.appendSwitch("disable-background-timer-throttling");
9−
10−app
11− .whenReady()
12− .then(() => import("./electron-webview-host.mjs"))
13− .catch((error) => {
14− console.error(error);
15− app.exit(1);
16− });
.plans/browser-phase-0/spikes/electron-webview-host.mjs+0−736
@@ -1,736 +0,0 @@
1−import { spawnSync } from "node:child_process";
2−import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3−import { dirname, join } from "node:path";
4−import { fileURLToPath } from "node:url";
5−
6−import electron from "electron";
7−
8−const { app, BrowserWindow, ipcMain, WebContentsView } = electron;
9−
10−const args = Object.fromEntries(
11− process.argv
12− .slice(2)
13− .filter((value) => value.startsWith("--"))
14− .map((value) => {
15− const [key, ...rest] = value.slice(2).split("=");
16− return [key, rest.join("=") || "true"];
17− }),
18−);
19−
20−const mode = args.mode ?? "automation";
21−const debugPort = Number(args.port ?? 0);
22−const outputDirectory = args.output;
23−const playwrightCoreBundle = args["playwright-core-bundle"];
24−
25−function debug(message) {
26− if (!outputDirectory) return;
27− mkdirSync(outputDirectory, { recursive: true });
28− appendFileSync(join(outputDirectory, "debug.log"), `${new Date().toISOString()} ${message}\n`);
29−}
30−
31−debug(`startup electron=${process.versions.electron ?? "missing"}`);
32−
33−const here = dirname(fileURLToPath(import.meta.url));
34−const guestUrl = new URL("./guest.html", import.meta.url).href;
35−
36−function emitResult(result) {
37− if (outputDirectory) {
38− mkdirSync(outputDirectory, { recursive: true });
39− writeFileSync(join(outputDirectory, "result.json"), JSON.stringify(result, null, 2));
40− }
41− process.stdout.write(`PHASE0_RESULT ${JSON.stringify(result)}\n`);
42−}
43−
44−function attachDebugger(contents) {
45− if (!contents.debugger.isAttached()) contents.debugger.attach("1.3");
46− return (method, params) => contents.debugger.sendCommand(method, params);
47−}
48−
49−async function evaluate(send, expression) {
50− const result = await send("Runtime.evaluate", {
51− expression,
52− awaitPromise: true,
53− returnByValue: true,
54− });
55− if (result.exceptionDetails) {
56− const description = result.exceptionDetails.exception?.description;
57− throw new Error(description ?? result.exceptionDetails.text ?? "Evaluation failed");
58− }
59− return result.result?.value;
60−}
61−
62−function axValue(node, key) {
63− return node[key]?.value ?? null;
64−}
65−
66−async function runSemanticProbe(contents) {
67− const send = attachDebugger(contents);
68− await Promise.all([send("Runtime.enable"), send("DOM.enable"), send("Accessibility.enable")]);
69− const tree = await send("Accessibility.getFullAXTree");
70− const buttonNode = tree.nodes.find(
71− (node) => axValue(node, "role") === "button" && axValue(node, "name") === "Increment count",
72− );
73− const inputNode = tree.nodes.find(
74− (node) => axValue(node, "role") === "textbox" && axValue(node, "name") === "Message",
75− );
76− if (!buttonNode?.backendDOMNodeId || !inputNode?.backendDOMNodeId) {
77− throw new Error("Semantic nodes were not found in the accessibility tree");
78− }
79− const box = await send("DOM.getBoxModel", { backendNodeId: buttonNode.backendDOMNodeId });
80− const quad = box.model.content;
81− const x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
82− const y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
83− await send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
84− await send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
85− const resolvedInput = await send("DOM.resolveNode", { backendNodeId: inputNode.backendDOMNodeId });
86− await send("Runtime.callFunctionOn", {
87− objectId: resolvedInput.object.objectId,
88− functionDeclaration: `function(value) {
89− this.focus();
90− const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
91− setter.call(this, value);
92− this.dispatchEvent(new Event('input', { bubbles: true }));
93− this.dispatchEvent(new Event('change', { bubbles: true }));
94− }`,
95− arguments: [{ value: "semantic CDP attached" }],
96− });
97− const state = await evaluate(
98− send,
99− `({ count: document.querySelector('#count').textContent, value: document.querySelector('#message').value })`,
100− );
101− return {
102− success: state.count === "1" && state.value === "semantic CDP attached",
103− button: { role: axValue(buttonNode, "role"), name: axValue(buttonNode, "name") },
104− input: { role: axValue(inputNode, "role"), name: axValue(inputNode, "name") },
105− state,
106− };
107−}
108−
109−function readPlaywrightInjectedSource() {
110− if (!playwrightCoreBundle) throw new Error("Playwright core bundle path is required");
111− const bundle = readFileSync(playwrightCoreBundle, "utf8");
112− const marker = "source3 = ";
113− const start = bundle.indexOf(marker);
114− const suffix = ";\n }\n});\n\n// packages/playwright-core/src/server/dom.ts";
115− const end = bundle.indexOf(suffix, start);
116− if (start === -1 || end === -1) throw new Error("Could not locate Playwright injected source");
117− const literal = bundle.slice(start + marker.length, end);
118− return Function(`"use strict"; return (${literal});`)();
119−}
120−
121−async function runInjectedRuntimeSpike(contents) {
122− const send = attachDebugger(contents);
123− await send("Runtime.enable");
124− const injectedSource = readPlaywrightInjectedSource();
125− const installStartedAt = performance.now();
126− const install = await evaluate(
127− send,
128− `(() => {
129− const module = {};
130− ${injectedSource}
131− const injected = new (module.exports.InjectedScript())(globalThis, {
132− isUnderTest: false,
133− sdkLanguage: "javascript",
134− testIdAttributeName: "data-testid",
135− stableRafCount: 2,
136− browserName: "chromium",
137− shouldPrependErrorPrefix: false,
138− isUtilityWorld: true,
139− customEngines: [],
140− });
141− globalThis.__phase05Injected = injected;
142− return {
143− methods: Object.getOwnPropertyNames(Object.getPrototypeOf(injected)).sort(),
144− bytes: ${Buffer.byteLength(injectedSource)},
145− };
146− })()`,
147− );
148− const installMs = performance.now() - installStartedAt;
149− const probe = await evaluate(
150− send,
151− `(async () => {
152− const injected = globalThis.__phase05Injected;
153− const resolve = (selector) => {
154− const parsed = injected.parseSelector(selector);
155− return injected.querySelectorAll(parsed, document.documentElement);
156− };
157− const roleSelector = 'internal:role=button[name="Increment count"i]';
158− const buttonBefore = resolve(roleSelector);
159− const shadow = resolve('internal:role=button[name="Shadow action"i]');
160− const inputLocator = 'internal:role=textbox[name="Message"i]';
161− const inputBefore = resolve(inputLocator)[0];
162− inputBefore.value = "before replacement";
163− document.querySelector("#replace").click();
164− const inputAfter = resolve(inputLocator)[0];
165− inputAfter.value = "after replacement";
166− const frame = document.querySelector("#same-origin-frame");
167− await new Promise((resolveReady) => {
168− if (frame.contentDocument?.readyState === "complete") resolveReady();
169− else frame.addEventListener("load", resolveReady, { once: true });
170− });
171− return {
172− roleMatches: buttonBefore.length,
173− shadowMatches: shadow.length,
174− locatorReresolvedAfterReplacement: inputBefore !== inputAfter && inputAfter.value === "after replacement",
175− sameOriginFrameRequiresSeparateRuntime: frame.contentDocument !== document,
176− };
177− })()`,
178− );
179− emitResult({ mode, install, installMs, probe });
180− app.quit();
181−}
182−
183−let rendererReloadStarted = false;
184−let rendererReloadNextGuestResolve;
185−
186−async function runRendererReloadSpike(window, firstGuest) {
187− rendererReloadStarted = true;
188− const firstSend = attachDebugger(firstGuest);
189− await firstSend("Runtime.enable");
190− await evaluate(firstSend, `document.querySelector("#increment").click()`);
191− const firstState = await evaluate(firstSend, `document.querySelector("#count").textContent`);
192− const firstGuestId = firstGuest.id;
193−
194− const nativeView = new WebContentsView({
195− webPreferences: { contextIsolation: true, nodeIntegration: false, partition: "persist:t3-phase05-view" },
196− });
197− nativeView.setBounds({ x: 0, y: 0, width: 1, height: 1 });
198− window.contentView.addChildView(nativeView);
199− await nativeView.webContents.loadFile(join(here, "guest.html"));
200− await nativeView.webContents.executeJavaScript(`document.querySelector("#increment").click()`);
201− const nativeId = nativeView.webContents.id;
202−
203− const nextGuest = new Promise((resolveGuest) => {
204− rendererReloadNextGuestResolve = resolveGuest;
205− });
206− await window.webContents.reload();
207− const secondGuest = await Promise.race([
208− nextGuest,
209− new Promise((_, reject) => setTimeout(() => reject(new Error("Replacement webview timed out")), 5_000)),
210− ]);
211− const secondSend = attachDebugger(secondGuest);
212− await secondSend("Runtime.enable");
213− const secondState = await evaluate(secondSend, `document.querySelector("#count").textContent`);
214− const nativeState = await nativeView.webContents.executeJavaScript(
215− `document.querySelector("#count").textContent`,
216− );
217− emitResult({
218− mode,
219− webview: {
220− firstGuestId,
221− secondGuestId: secondGuest.id,
222− firstState,
223− secondState,
224− firstDestroyed: firstGuest.isDestroyed(),
225− survivedRendererReload: firstGuestId === secondGuest.id,
226− },
227− webContentsView: {
228− idBefore: nativeId,
229− idAfter: nativeView.webContents.id,
230− stateAfter: nativeState,
231− destroyed: nativeView.webContents.isDestroyed(),
232− },
233− });
234− app.quit();
235−}
236−
237−async function runInputOriginSpike(contents) {
238− const send = attachDebugger(contents);
239− await send("Input.enable").catch(() => undefined);
240− const beforeInputEvents = [];
241− contents.on("before-input-event", (_event, input) => beforeInputEvents.push(input));
242− await send("Input.dispatchKeyEvent", { type: "keyDown", key: "a", code: "KeyA", text: "a" });
243− await send("Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA" });
244− await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
245− emitResult({
246− mode,
247− beforeInputEvents: beforeInputEvents.map(({ type, key, code, isAutoRepeat }) => ({
248− type,
249− key,
250− code,
251− isAutoRepeat,
252− })),
253− cdpDispatchVisibleAsBeforeInput: beforeInputEvents.length > 0,
254− });
255− app.quit();
256−}
257−
258−async function runHiddenSpike(window, contents) {
259− const send = attachDebugger(contents);
260− await send("Runtime.enable");
261− contents.setBackgroundThrottling(false);
262− const before = await evaluate(send, `({ ticks: Number(document.querySelector('#timer').textContent), count: Number(document.querySelector('#count').textContent) })`);
263− const cpuStart = process.getCPUUsage();
264− window.hide();
265− await new Promise((resolve) => setTimeout(resolve, 1_500));
266− await evaluate(send, `document.querySelector('#increment').click()`);
267− const after = await evaluate(send, `({ ticks: Number(document.querySelector('#timer').textContent), count: Number(document.querySelector('#count').textContent), hidden: document.hidden })`);
268− const image = await contents.capturePage();
269− const processMemory = await process.getProcessMemoryInfo();
270− const cpuUsage = process.getCPUUsage(cpuStart);
271− const metrics = app.getAppMetrics().map((metric) => ({
272− type: metric.type,
273− cpuPercent: metric.cpu.percentCPUUsage,
274− memoryKb: metric.memory.workingSetSize,
275− }));
276− if (outputDirectory) {
277− mkdirSync(outputDirectory, { recursive: true });
278− writeFileSync(join(outputDirectory, "hidden-capture.png"), image.toPNG());
279− }
280− emitResult({
281− mode,
282− before,
283− after,
284− captureSize: image.getSize(),
285− processPrivateKb: processMemory.private,
286− mainProcessCpuPercent: cpuUsage.percentCPUUsage,
287− metrics,
288− });
289− app.quit();
290−}
291−
292−async function runRecordingSpike(window, contents, hideWindow = true) {
293− if (!outputDirectory) throw new Error("recording mode requires --output");
294− mkdirSync(outputDirectory, { recursive: true });
295− const send = attachDebugger(contents);
296− await Promise.all([send("Runtime.enable"), send("Page.enable")]);
297− let frameIndex = 0;
298− const timestamps = [];
299− let lastWrittenTimestamp = Number.NEGATIVE_INFINITY;
300− const onMessage = async (_event, method, params) => {
301− if (method !== "Page.screencastFrame") return;
302− const timestamp = params.metadata?.timestamp ?? 0;
303− if (timestamp - lastWrittenTimestamp < 1 / 12) {
304− await send("Page.screencastFrameAck", { sessionId: params.sessionId });
305− return;
306− }
307− lastWrittenTimestamp = timestamp;
308− frameIndex += 1;
309− timestamps.push(timestamp);
310− writeFileSync(
311− join(outputDirectory, `frame-${String(frameIndex).padStart(4, "0")}.jpg`),
312− Buffer.from(params.data, "base64"),
313− );
314− await send("Page.screencastFrameAck", { sessionId: params.sessionId });
315− };
316− contents.debugger.on("message", onMessage);
317− await send("Page.startScreencast", {
318− format: "jpeg",
319− quality: 80,
320− maxWidth: 800,
321− maxHeight: 600,
322− everyNthFrame: 1,
323− });
324− if (hideWindow) window.hide();
325− await new Promise((resolve) => setTimeout(resolve, 2_500));
326− await send("Page.stopScreencast");
327− contents.debugger.off("message", onMessage);
328−
329− const videoPath = join(outputDirectory, "webview-recording.mp4");
330− const ffmpeg = spawnSync(
331− "ffmpeg",
332− [
333− "-y",
334− "-loglevel",
335− "error",
336− "-framerate",
337− "12",
338− "-i",
339− join(outputDirectory, "frame-%04d.jpg"),
340− "-c:v",
341− "libx264",
342− "-pix_fmt",
343− "yuv420p",
344− "-movflags",
345− "+faststart",
346− videoPath,
347− ],
348− { encoding: "utf8" },
349− );
350− const probe = spawnSync(
351− "ffprobe",
352− ["-v", "error", "-show_entries", "format=duration,size", "-of", "json", videoPath],
353− { encoding: "utf8" },
354− );
355− emitResult({
356− mode,
357− frames: frameIndex,
358− firstTimestamp: timestamps.at(0),
359− lastTimestamp: timestamps.at(-1),
360− ffmpegStatus: ffmpeg.status,
361− ffmpegError: ffmpeg.stderr.trim() || null,
362− probe: probe.status === 0 ? JSON.parse(probe.stdout) : { error: probe.stderr.trim() },
363− videoPath,
364− });
365− app.quit();
366−}
367−
368−async function runMediaRecorderSpike(window, contents) {
369− if (!outputDirectory) throw new Error("media-recorder mode requires --output");
370− mkdirSync(outputDirectory, { recursive: true });
371− await window.webContents.executeJavaScript(`(() => {
372− const guest = document.querySelector('webview');
373− guest.style.position = 'fixed';
374− guest.style.left = '0';
375− guest.style.top = '0';
376− guest.style.width = '800px';
377− guest.style.height = '600px';
378− guest.style.zIndex = '1';
379− const cover = document.createElement('div');
380− cover.style.position = 'fixed';
381− cover.style.inset = '0';
382− cover.style.background = '#111827';
383− cover.style.zIndex = '2';
384− document.body.appendChild(cover);
385− })()`);
386− const recordingInfo = await window.webContents.executeJavaScript(
387− `window.phase0.startRecording({ width: 800, height: 600, fps: 12 })`,
388− );
389− const send = attachDebugger(contents);
390− await send("Page.enable");
391− let frameCount = 0;
392− let lastSentTimestamp = Number.NEGATIVE_INFINITY;
393− const onMessage = async (_event, method, params) => {
394− if (method !== "Page.screencastFrame") return;
395− const timestamp = params.metadata?.timestamp ?? 0;
396− if (timestamp - lastSentTimestamp >= 1 / 12) {
397− lastSentTimestamp = timestamp;
398− frameCount += 1;
399− window.webContents.send("phase0:recording-frame", `data:image/jpeg;base64,${params.data}`);
400− }
401− await send("Page.screencastFrameAck", { sessionId: params.sessionId });
402− };
403− contents.debugger.on("message", onMessage);
404− await send("Page.startScreencast", {
405− format: "jpeg",
406− quality: 80,
407− maxWidth: 800,
408− maxHeight: 600,
409− everyNthFrame: 1,
410− });
411− await new Promise((resolve) => setTimeout(resolve, 2_500));
412− await send("Page.stopScreencast");
413− contents.debugger.off("message", onMessage);
414− await new Promise((resolve) => setTimeout(resolve, 250));
415− const recordingResult = await window.webContents.executeJavaScript("window.phase0.stopRecording()");
416− const videoPath = join(
417− outputDirectory,
418− recordingResult.mimeType.startsWith("video/mp4")
419− ? "webview-recording.mp4"
420− : "webview-recording.webm",
421− );
422− writeFileSync(videoPath, Buffer.from(recordingResult.bytes));
423− const probe = spawnSync(
424− "ffprobe",
425− ["-v", "error", "-show_entries", "format=duration,size", "-show_entries", "stream=codec_name,width,height,avg_frame_rate", "-of", "json", videoPath],
426− { encoding: "utf8" },
427− );
428− emitResult({
429− mode,
430− frameCount,
431− recordingInfo,
432− videoPath,
433− bytes: recordingResult.bytes.length,
434− probe: probe.status === 0 ? JSON.parse(probe.stdout) : { error: probe.stderr.trim() },
435− });
436− app.quit();
437−}
438−
439−async function runRecordingEnduranceSpike(window, contents) {
440− if (!outputDirectory) throw new Error("recording-endurance mode requires --output");
441− const width = 1600;
442− const height = 1200;
443− const fps = 12;
444− const durationMs = 10_000;
445− window.setSize(1700, 1300);
446− contents.setBackgroundThrottling(false);
447− await window.webContents.executeJavaScript(`(() => {
448− const guest = document.querySelector('webview');
449− guest.style.position = 'fixed';
450− guest.style.left = '0';
451− guest.style.top = '0';
452− guest.style.width = '${width}px';
453− guest.style.height = '${height}px';
454− guest.style.zIndex = '1';
455− const cover = document.createElement('div');
456− cover.style.position = 'fixed';
457− cover.style.inset = '0';
458− cover.style.background = '#111827';
459− cover.style.zIndex = '2';
460− document.body.appendChild(cover);
461− })()`);
462− const recordingInfo = await window.webContents.executeJavaScript(
463− `window.phase0.startRecording({ width: ${width}, height: ${height}, fps: ${fps} })`,
464− );
465− const send = attachDebugger(contents);
466− await send("Page.enable");
467− let frameCount = 0;
468− let lastSentTimestamp = Number.NEGATIVE_INFINITY;
469− const cpuStart = process.getCPUUsage();
470− const onMessage = async (_event, method, params) => {
471− if (method !== "Page.screencastFrame") return;
472− const timestamp = params.metadata?.timestamp ?? 0;
473− if (timestamp - lastSentTimestamp >= 1 / fps) {
474− lastSentTimestamp = timestamp;
475− frameCount += 1;
476− window.webContents.send("phase0:recording-frame", `data:image/jpeg;base64,${params.data}`);
477− }
478− await send("Page.screencastFrameAck", { sessionId: params.sessionId });
479− };
480− contents.debugger.on("message", onMessage);
481− await send("Page.startScreencast", {
482− format: "jpeg",
483− quality: 75,
484− maxWidth: width,
485− maxHeight: height,
486− everyNthFrame: 1,
487− });
488− await new Promise((resolveDelay) => setTimeout(resolveDelay, durationMs));
489− await send("Page.stopScreencast");
490− contents.debugger.off("message", onMessage);
491− await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
492− const recordingResult = await window.webContents.executeJavaScript("window.phase0.stopRecording()");
493− const videoPath = join(outputDirectory, "recording-endurance.mp4");
494− writeFileSync(videoPath, Buffer.from(recordingResult.bytes));
495− const probe = spawnSync(
496− "ffprobe",
497− ["-v", "error", "-show_entries", "format=duration,size", "-show_entries", "stream=codec_name,width,height,avg_frame_rate", "-of", "json", videoPath],
498− { encoding: "utf8" },
499− );
500− emitResult({
501− mode,
502− width,
503− height,
504− fps,
505− durationMs,
506− frameCount,
507− achievedFps: frameCount / (durationMs / 1_000),
508− recordingInfo,
509− bytes: recordingResult.bytes.length,
510− mainProcessCpu: process.getCPUUsage(cpuStart),
511− metrics: app.getAppMetrics().map((metric) => ({
512− type: metric.type,
513− cpuPercent: metric.cpu.percentCPUUsage,
514− memoryKb: metric.memory.workingSetSize,
515− })),
516− probe: probe.status === 0 ? JSON.parse(probe.stdout) : { error: probe.stderr.trim() },
517− });
518− app.quit();
519−}
520−
521−function summarizeDurations(values) {
522− const sorted = [...values].sort((left, right) => left - right);
523− const percentile = (fraction) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))];
524− return {
525− count: sorted.length,
526− minMs: sorted[0],
527− medianMs: percentile(0.5),
528− p95Ms: percentile(0.95),
529− maxMs: sorted.at(-1),
530− meanMs: sorted.reduce((sum, value) => sum + value, 0) / sorted.length,
531− };
532−}
533−
534−async function runLatencySpike(window, contents) {
535− const send = attachDebugger(contents);
536− await send("Runtime.enable");
537− const evaluateNoop = () => send("Runtime.evaluate", { expression: "1 + 1", returnByValue: true });
538− for (let index = 0; index < 10; index += 1) await evaluateNoop();
539− const direct = [];
540− for (let index = 0; index < 100; index += 1) {
541− const startedAt = performance.now();
542− await evaluateNoop();
543− direct.push(performance.now() - startedAt);
544− }
545− ipcMain.removeHandler("phase0:cdp-evaluate");
546− ipcMain.handle("phase0:cdp-evaluate", evaluateNoop);
547− const rendererRelay = await window.webContents.executeJavaScript(`(async () => {
548− const durations = [];
549− for (let index = 0; index < 100; index += 1) {
550− const startedAt = performance.now();
551− await window.phase0.invokeCdp();
552− durations.push(performance.now() - startedAt);
553− }
554− return durations;
555− })()`);
556− emitResult({
557− mode,
558− direct: summarizeDurations(direct),
559− rendererRelay: summarizeDurations(rendererRelay),
560− });
561− app.quit();
562−}
563−
564−async function runDetachedViewSpike(window, view, detach) {
565− const contents = view.webContents;
566− const send = attachDebugger(contents);
567− await send("Runtime.enable");
568− contents.setBackgroundThrottling(false);
569− const before = await evaluate(
570− send,
571− `({ ticks: Number(document.querySelector('#timer').textContent), count: Number(document.querySelector('#count').textContent) })`,
572− );
573− if (detach) window.contentView.removeChildView(view);
574− else view.setVisible(false);
575− await new Promise((resolve) => setTimeout(resolve, 1_500));
576− const semanticProbe = await runSemanticProbe(contents);
577− const after = await evaluate(
578− send,
579− `({ ticks: Number(document.querySelector('#timer').textContent), count: Number(document.querySelector('#count').textContent), hidden: document.hidden })`,
580− );
581− let capture;
582− try {
583− const image = await contents.capturePage();
584− if (outputDirectory) writeFileSync(join(outputDirectory, "detached-view-capture.png"), image.toPNG());
585− capture = { success: true, size: image.getSize() };
586− } catch (error) {
587− capture = { success: false, error: error instanceof Error ? error.message : String(error) };
588− }
589− let cdpScreenshot;
590− try {
591− await send("Page.enable");
592− const screenshot = await Promise.race([
593− send("Page.captureScreenshot", {
594− format: "png",
595− captureBeyondViewport: false,
596− fromSurface: false,
597− }),
598− new Promise((_, reject) => setTimeout(() => reject(new Error("CDP screenshot timed out")), 3_000)),
599− ]);
600− const bytes = Buffer.from(screenshot.data, "base64");
601− if (outputDirectory) writeFileSync(join(outputDirectory, "hidden-cdp-screenshot.png"), bytes);
602− cdpScreenshot = { success: true, bytes: bytes.length };
603− } catch (error) {
604− cdpScreenshot = { success: false, error: error instanceof Error ? error.message : String(error) };
605− }
606− emitResult({
607− mode,
608− detach,
609− before,
610− after,
611− semanticProbe,
612− capture,
613− cdpScreenshot,
614− destroyed: contents.isDestroyed(),
615− });
616− contents.close();
617− app.quit();
618−}
619−
620−await app.whenReady();
621−debug("app-ready");
622−
623−const window = new BrowserWindow({
624− width: 900,
625− height: 700,
626− show: true,
627− webPreferences: {
628− contextIsolation: true,
629− nodeIntegration: false,
630− preload: join(here, "host-preload.cjs"),
631− webviewTag: true,
632− },
633−});
634−
635−app.on("web-contents-created", (_event, contents) => {
636− debug(`web-contents-created type=${contents.getType()} id=${contents.id}`);
637− if (contents.getType() !== "webview") return;
638− contents.once("dom-ready", () => {
639− if (mode === "renderer-reload" && rendererReloadStarted) {
640− rendererReloadNextGuestResolve?.(contents);
641− rendererReloadNextGuestResolve = undefined;
642− return;
643− }
644− if (mode === "automation") {
645− runSemanticProbe(contents)
646− .then((semanticProbe) =>
647− emitResult({ mode, debugPort, webContentsId: contents.id, guestUrl, semanticProbe }),
648− )
649− .catch((error) => {
650− emitResult({ mode, error: error instanceof Error ? error.stack : String(error) });
651− app.exit(1);
652− });
653− return;
654− }
655− const operation =
656− mode === "hidden"
657− ? runHiddenSpike(window, contents)
658− : mode === "injected-runtime"
659− ? runInjectedRuntimeSpike(contents)
660− : mode === "renderer-reload"
661− ? runRendererReloadSpike(window, contents)
662− : mode === "input-origin"
663− ? runInputOriginSpike(contents)
664− : mode === "recording-endurance"
665− ? runRecordingEnduranceSpike(window, contents)
666− : mode === "recording"
667− ? runRecordingSpike(window, contents)
668− : mode === "offscreen-recording"
669− ? window.webContents
670− .executeJavaScript(`(() => {
671− const guest = document.querySelector('webview');
672− guest.style.position = 'fixed';
673− guest.style.left = '-10000px';
674− guest.style.top = '0';
675− guest.style.width = '800px';
676− guest.style.height = '600px';
677− })()`)
678− .then(() => runRecordingSpike(window, contents, false))
679− : mode === "covered-recording"
680− ? window.webContents
681− .executeJavaScript(`(() => {
682− const guest = document.querySelector('webview');
683− guest.style.position = 'fixed';
684− guest.style.left = '0';
685− guest.style.top = '0';
686− guest.style.width = '800px';
687− guest.style.height = '600px';
688− guest.style.zIndex = '1';
689− const cover = document.createElement('div');
690− cover.style.position = 'fixed';
691− cover.style.inset = '0';
692− cover.style.background = '#111827';
693− cover.style.zIndex = '2';
694− document.body.appendChild(cover);
695− })()`)
696− .then(() => runRecordingSpike(window, contents, false))
697− : mode === "media-recorder"
698− ? runMediaRecorderSpike(window, contents)
699− : runLatencySpike(window, contents);
700− operation.catch((error) => {
701− emitResult({ mode, error: error instanceof Error ? error.stack : String(error) });
702− app.exit(1);
703− });
704− });
705−});
706−
707−if (mode.startsWith("view-")) {
708− await window.loadURL("data:text/html,<title>Phase0 View Host</title>");
709− const view = new WebContentsView({
710− webPreferences: {
711− contextIsolation: true,
712− nodeIntegration: false,
713− partition: "persist:t3-phase0-view",
714− },
715− });
716− view.setBounds({ x: 0, y: 0, width: 800, height: 600 });
717− window.contentView.addChildView(view);
718− await view.webContents.loadFile(join(here, "guest.html"));
719− if (mode === "view-hidden" || mode === "view-detached") {
720− await runDetachedViewSpike(window, view, mode === "view-detached");
721− } else if (mode === "view-recording") {
722− await runRecordingSpike(window, view.webContents);
723− } else {
724− const semanticProbe = await runSemanticProbe(view.webContents);
725− emitResult({
726− mode,
727− debugPort,
728− webContentsId: view.webContents.id,
729− guestUrl,
730− semanticProbe,
731− });
732− }
733−} else {
734− await window.loadFile(join(here, "host.html"));
735− debug(`host-loaded url=${window.webContents.getURL()}`);
736−}
.plans/browser-phase-0/spikes/guest.html+0−41
@@ -1,41 +0,0 @@
1−<!doctype html>
2−<html>
3− <head>
4− <meta charset="utf-8" />
5− <title>Phase0 Guest</title>
6− <style>
7− body { font-family: sans-serif; margin: 24px; }
8− #box { width: 80px; height: 80px; background: #7c3aed; position: relative; animation: move 1s infinite alternate linear; }
9− @keyframes move { from { left: 0; } to { left: 260px; } }
10− </style>
11− </head>
12− <body>
13− <button id="increment" aria-label="Increment count">Count: <span id="count">0</span></button>
14− <label>Message <input id="message" aria-label="Message" /></label>
15− <button id="replace" aria-label="Replace controls">Replace controls</button>
16− <div id="shadow-host"></div>
17− <iframe id="same-origin-frame" title="Same origin frame" srcdoc='<button aria-label="Frame action">Frame action</button>'></iframe>
18− <div id="timer">0</div>
19− <div id="box"></div>
20− <script>
21− let count = 0;
22− let ticks = 0;
23− document.querySelector("#increment").addEventListener("click", () => {
24− count += 1;
25− document.querySelector("#count").textContent = String(count);
26− });
27− document.querySelector("#replace").addEventListener("click", () => {
28− const input = document.querySelector("#message");
29− const replacement = input.cloneNode();
30− replacement.value = input.value;
31− input.replaceWith(replacement);
32− });
33− const shadowRoot = document.querySelector("#shadow-host").attachShadow({ mode: "open" });
34− shadowRoot.innerHTML = '<button aria-label="Shadow action">Shadow action</button>';
35− setInterval(() => {
36− ticks += 1;
37− document.querySelector("#timer").textContent = String(ticks);
38− }, 100);
39− </script>
40− </body>
41−</html>
.plans/browser-phase-0/spikes/host-preload.cjs+0−51
@@ -1,51 +0,0 @@
1−const { contextBridge, ipcRenderer } = require("electron");
2−
3−let recording = null;
4−
5−ipcRenderer.on("phase0:recording-frame", async (_event, dataUrl) => {
6− if (!recording) return;
7− const response = await fetch(dataUrl);
8− const bitmap = await createImageBitmap(await response.blob());
9− recording.context.drawImage(bitmap, 0, 0, recording.canvas.width, recording.canvas.height);
10− bitmap.close();
11−});
12−
13−contextBridge.exposeInMainWorld("phase0", {
14− invokeCdp: () => ipcRenderer.invoke("phase0:cdp-evaluate"),
15− startRecording: ({ width, height, fps }) => {
16− const canvas = document.createElement("canvas");
17− canvas.width = width;
18− canvas.height = height;
19− const context = canvas.getContext("2d");
20− const stream = canvas.captureStream(fps);
21− const mimeTypes = [
22− "video/mp4;codecs=avc1.42E01E",
23− "video/mp4;codecs=h264",
24− "video/mp4",
25− "video/webm;codecs=vp9",
26− "video/webm;codecs=vp8",
27− "video/webm",
28− ];
29− const mimeType = mimeTypes.find((candidate) => MediaRecorder.isTypeSupported(candidate));
30− if (!mimeType) throw new Error("No supported WebM MediaRecorder codec");
31− const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 2_000_000 });
32− const chunks = [];
33− recorder.addEventListener("dataavailable", (event) => {
34− if (event.data.size > 0) chunks.push(event.data);
35− });
36− recorder.start(250);
37− recording = { canvas, context, recorder, chunks, mimeType };
38− return { mimeType };
39− },
40− stopRecording: async () => {
41− if (!recording) throw new Error("Recording was not started");
42− const activeRecording = recording;
43− recording = null;
44− await new Promise((resolve) => {
45− activeRecording.recorder.addEventListener("stop", resolve, { once: true });
46− activeRecording.recorder.stop();
47− });
48− const bytes = new Uint8Array(await new Blob(activeRecording.chunks).arrayBuffer());
49− return { mimeType: activeRecording.mimeType, bytes };
50− },
51−});
.plans/browser-phase-0/spikes/host.html+0−12
@@ -1,12 +0,0 @@
1−<!doctype html>
2−<html>
3− <head><meta charset="utf-8" /><title>Phase0 Host</title></head>
4− <body style="margin: 0">
5− <webview
6− id="guest"
7− src="./guest.html"
8− style="width: 800px; height: 600px"
9− partition="persist:t3-phase0"
10− ></webview>
11− </body>
12−</html>
.plans/browser-phase-0/spikes/run-electron-spikes.mjs+0−191
@@ -1,191 +0,0 @@
1−import { spawn } from "node:child_process";
2−import { createRequire } from "node:module";
3−import { createServer } from "node:net";
4−import { dirname, join, resolve } from "node:path";
5−import { fileURLToPath } from "node:url";
6−import { readFile, rm } from "node:fs/promises";
7−
8−const here = dirname(fileURLToPath(import.meta.url));
9−const repositoryRoot = resolve(here, "../../..");
10−const requireDesktop = createRequire(join(repositoryRoot, "apps/desktop/package.json"));
11−const requireWeb = createRequire(join(repositoryRoot, "apps/web/package.json"));
12−const electronBinary = requireDesktop("electron");
13−const { chromium } = requireWeb("playwright");
14−const playwrightPackage = requireWeb.resolve("playwright/package.json");
15−const playwrightVersion = JSON.parse(await readFile(playwrightPackage, "utf8")).version;
16−const playwrightCoreBundle = resolve(
17− dirname(playwrightPackage),
18− `../../../playwright-core@${playwrightVersion}/node_modules/playwright-core/lib/coreBundle.js`,
19−);
20−
21−async function reservePort() {
22− return new Promise((resolvePort, reject) => {
23− const server = createServer();
24− server.once("error", reject);
25− server.listen(0, "127.0.0.1", () => {
26− const address = server.address();
27− const port = typeof address === "object" && address ? address.port : 0;
28− server.close(() => resolvePort(port));
29− });
30− });
31−}
32−
33−function launchHost({ mode, port, output }) {
34− const childEnvironment = { ...process.env };
35− delete childEnvironment.ELECTRON_RUN_AS_NODE;
36− const command = {
37− electronPath: electronBinary,
38− args: [
39− join(here, "electron-webview-bootstrap.cjs"),
40− `--mode=${mode}`,
41− ...(port ? [`--port=${port}`] : []),
42− ...(output ? [`--output=${output}`] : []),
43− `--playwright-core-bundle=${playwrightCoreBundle}`,
44− ],
45− };
46− const child = spawn(
47− command.electronPath,
48− command.args,
49− { cwd: repositoryRoot, env: childEnvironment, stdio: ["ignore", "pipe", "pipe"] },
50− );
51− child.stderr.on("data", (chunk) => process.stderr.write(chunk));
52− return child;
53−}
54−
55−async function waitForResult(child, output, timeoutMs = 20_000) {
56− const resultPath = join(output, "result.json");
57− const deadline = Date.now() + timeoutMs;
58− while (Date.now() < deadline) {
59− try {
60− return JSON.parse(await readFile(resultPath, "utf8"));
61− } catch {
62− await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
63− }
64− }
65− child.kill("SIGTERM");
66− throw new Error(`Timed out waiting for ${resultPath}`);
67−}
68−
69−async function runAutomation(hostMode = "automation") {
70− const port = await reservePort();
71− const output = join(repositoryRoot, ".plans/browser-phase-0/results", hostMode);
72− await rm(output, { recursive: true, force: true });
73− const child = launchHost({ mode: hostMode, port, output });
74− const hostResult = await waitForResult(child, output);
75− let browser;
76− try {
77− const endpoint = `http://127.0.0.1:${port}`;
78− let lastError;
79− for (let attempt = 0; attempt < 30; attempt += 1) {
80− try {
81− browser = await chromium.connectOverCDP(endpoint);
82− break;
83− } catch (error) {
84− lastError = error;
85− await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
86− }
87− }
88− if (!browser) throw lastError;
89− const targets = await fetch(`${endpoint}/json/list`).then((response) => response.json());
90− const pages = browser.contexts().flatMap((context) => context.pages());
91− const pageMetadata = await Promise.all(
92− pages.map(async (page) => ({ url: page.url(), title: await page.title().catch(() => "") })),
93− );
94− const guestPage = pages.find((page, index) => pageMetadata[index]?.title === "Phase0 Guest");
95− if (!guestPage) {
96− const guestTarget = targets.find((target) => target.title === "Phase0 Guest");
97− let directTargetAttachment;
98− if (guestTarget?.webSocketDebuggerUrl) {
99− try {
100− const directBrowser = await chromium.connectOverCDP(guestTarget.webSocketDebuggerUrl);
101− const directPages = directBrowser.contexts().flatMap((context) => context.pages());
102− directTargetAttachment = {
103− success: true,
104− pages: await Promise.all(
105− directPages.map(async (page) => ({ url: page.url(), title: await page.title().catch(() => "") })),
106− ),
107− };
108− await directBrowser.close();
109− } catch (error) {
110− directTargetAttachment = {
111− success: false,
112− error: error instanceof Error ? error.message : String(error),
113− };
114− }
115− }
116− return {
117− hostResult,
118− targets: targets.map(({ id, title, type, url }) => ({ id, title, type, url })),
119− pageMetadata,
120− directTargetAttachment,
121− success: false,
122− reason: "Guest webview was not exposed as a Playwright Page",
123− };
124− }
125− const button = guestPage.getByRole("button", { name: "Increment count" });
126− await button.click();
127− const countAfterClick = await guestPage.locator("#count").textContent();
128− const input = guestPage.getByRole("textbox", { name: "Message" });
129− await input.fill("semantic locator attached");
130− const inputValue = await input.inputValue();
131− const expectedCount = String(Number(hostResult.semanticProbe?.state?.count ?? "0") + 1);
132− return {
133− hostResult,
134− pageMetadata,
135− success: countAfterClick === expectedCount && inputValue === "semantic locator attached",
136− countAfterClick,
137− inputValue,
138− };
139− } finally {
140− await browser?.close().catch(() => undefined);
141− child.kill("SIGTERM");
142− }
143−}
144−
145−async function runManagedMode(mode) {
146− const output = join(repositoryRoot, ".plans/browser-phase-0/results", mode);
147− await rm(output, { recursive: true, force: true });
148− const child = launchHost({ mode, output });
149− return waitForResult(child, output, mode === "recording" ? 30_000 : 20_000);
150−}
151−
152−const requestedMode = process.argv[2] ?? "all";
153−const result = {};
154−if (requestedMode === "all" || requestedMode === "automation") result.automation = await runAutomation();
155−if (requestedMode === "all" || requestedMode === "view-automation") {
156− result.viewAutomation = await runAutomation("view-automation");
157−}
158−if (requestedMode === "all" || requestedMode === "hidden") result.hidden = await runManagedMode("hidden");
159−if (requestedMode === "all" || requestedMode === "recording") result.recording = await runManagedMode("recording");
160−if (requestedMode === "all" || requestedMode === "offscreen-recording") {
161− result.offscreenRecording = await runManagedMode("offscreen-recording");
162−}
163−if (requestedMode === "all" || requestedMode === "covered-recording") {
164− result.coveredRecording = await runManagedMode("covered-recording");
165−}
166−if (requestedMode === "all" || requestedMode === "media-recorder") {
167− result.mediaRecorder = await runManagedMode("media-recorder");
168−}
169−if (requestedMode === "all" || requestedMode === "latency") result.latency = await runManagedMode("latency");
170−if (requestedMode === "all" || requestedMode === "injected-runtime") {
171− result.injectedRuntime = await runManagedMode("injected-runtime");
172−}
173−if (requestedMode === "all" || requestedMode === "renderer-reload") {
174− result.rendererReload = await runManagedMode("renderer-reload");
175−}
176−if (requestedMode === "all" || requestedMode === "input-origin") {
177− result.inputOrigin = await runManagedMode("input-origin");
178−}
179−if (requestedMode === "all" || requestedMode === "recording-endurance") {
180− result.recordingEndurance = await runManagedMode("recording-endurance");
181−}
182−if (requestedMode === "all" || requestedMode === "view-hidden") {
183− result.viewHidden = await runManagedMode("view-hidden");
184−}
185−if (requestedMode === "all" || requestedMode === "view-detached") {
186− result.viewDetached = await runManagedMode("view-detached");
187−}
188−if (requestedMode === "all" || requestedMode === "view-recording") {
189− result.viewRecording = await runManagedMode("view-recording");
190−}
191−process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
.plans/browser-phase-0/spikes/run-gateway-spike.mjs+0−243
@@ -1,243 +0,0 @@
1−import { createServer, request } from "node:http";
2−import { connect as connectTcp, createServer as createTcpServer } from "node:net";
3−import { createRequire } from "node:module";
4−import { readdirSync } from "node:fs";
5−import { join, resolve } from "node:path";
6−
7−const repositoryRoot = resolve(import.meta.dirname, "../../..");
8−const pnpmDirectory = join(repositoryRoot, "node_modules/.pnpm");
9−const wsPackageDirectory = readdirSync(pnpmDirectory)
10− .filter((name) => name.startsWith("ws@8."))
11− .sort()
12− .at(-1);
13−if (!wsPackageDirectory) throw new Error("The locked ws 8 package is not installed");
14−const requireWs = createRequire(join(pnpmDirectory, wsPackageDirectory, "node_modules/ws/package.json"));
15−const { WebSocket, WebSocketServer } = requireWs("ws");
16−
17−function listen(server) {
18− return new Promise((resolvePort, reject) => {
19− server.once("error", reject);
20− server.listen(0, "127.0.0.1", () => {
21− const address = server.address();
22− resolvePort(address.port);
23− });
24− });
25−}
26−
27−function listenTcp(server) {
28− return new Promise((resolvePort, reject) => {
29− server.once("error", reject);
30− server.listen(0, "127.0.0.1", () => resolvePort(server.address().port));
31− });
32−}
33−
34−function close(server) {
35− return new Promise((resolveClose, reject) => server.close((error) => (error ? reject(error) : resolveClose())));
36−}
37−
38−function summarize(values) {
39− const sorted = [...values].sort((left, right) => left - right);
40− const percentile = (fraction) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))];
41− return {
42− count: sorted.length,
43− medianMs: percentile(0.5),
44− p95Ms: percentile(0.95),
45− maxMs: sorted.at(-1),
46− meanMs: sorted.reduce((sum, value) => sum + value, 0) / sorted.length,
47− };
48−}
49−
50−const upstreamWebSockets = new WebSocketServer({ noServer: true });
51−upstreamWebSockets.on("connection", (socket) => {
52− socket.on("message", (message) => socket.send(`upstream:${message}`));
53−});
54−const upstream = createServer((incoming, response) => {
55− response.writeHead(200, {
56− "content-type": incoming.url === "/app" ? "text/html" : "application/json",
57− "x-upstream-host": incoming.headers.host ?? "",
58− });
59− response.end(
60− incoming.url === "/app"
61− ? "<!doctype html><title>Remote Preview</title><h1>remote-loopback-ok</h1>"
62− : JSON.stringify({ ok: true, url: incoming.url }),
63− );
64−});
65−upstream.on("upgrade", (incoming, socket, head) => {
66− upstreamWebSockets.handleUpgrade(incoming, socket, head, (webSocket) => {
67− upstreamWebSockets.emit("connection", webSocket, incoming);
68− });
69−});
70−const upstreamPort = await listen(upstream);
71−
72−const gatewayWebSockets = new WebSocketServer({ noServer: true });
73−gatewayWebSockets.on("connection", (clientSocket, incoming) => {
74− const upstreamSocket = new WebSocket(`ws://127.0.0.1:${upstreamPort}${incoming.url}`);
75− const pending = [];
76− clientSocket.on("message", (message) => {
77− if (upstreamSocket.readyState === WebSocket.OPEN) upstreamSocket.send(message);
78− else pending.push(message);
79− });
80− upstreamSocket.on("open", () => pending.splice(0).forEach((message) => upstreamSocket.send(message)));
81− upstreamSocket.on("message", (message) => clientSocket.send(message));
82− const closeBoth = () => {
83− if (clientSocket.readyState < WebSocket.CLOSING) clientSocket.close();
84− if (upstreamSocket.readyState < WebSocket.CLOSING) upstreamSocket.close();
85− };
86− clientSocket.on("close", closeBoth);
87− upstreamSocket.on("close", closeBoth);
88− upstreamSocket.on("error", closeBoth);
89−});
90−
91−const gateway = createServer((incoming, response) => {
92− const upstreamRequest = request(
93− {
94− hostname: "127.0.0.1",
95− port: upstreamPort,
96− path: incoming.url,
97− method: incoming.method,
98− headers: { ...incoming.headers, host: `127.0.0.1:${upstreamPort}` },
99− },
100− (upstreamResponse) => {
101− response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
102− upstreamResponse.pipe(response);
103− },
104− );
105− upstreamRequest.on("error", (error) => response.destroy(error));
106− incoming.pipe(upstreamRequest);
107−});
108−gateway.on("upgrade", (incoming, socket, head) => {
109− gatewayWebSockets.handleUpgrade(incoming, socket, head, (webSocket) => {
110− gatewayWebSockets.emit("connection", webSocket, incoming);
111− });
112−});
113−const gatewayPort = await listen(gateway);
114−
115−const tunnelWebSockets = new WebSocketServer({ noServer: true });
116−tunnelWebSockets.on("connection", (tunnelSocket) => {
117− const upstreamSocket = connectTcp({ host: "127.0.0.1", port: upstreamPort });
118− const pending = [];
119− tunnelSocket.on("message", (message) => {
120− const bytes = Buffer.from(message);
121− if (upstreamSocket.readyState === "open") upstreamSocket.write(bytes);
122− else pending.push(bytes);
123− });
124− upstreamSocket.on("connect", () => pending.splice(0).forEach((bytes) => upstreamSocket.write(bytes)));
125− upstreamSocket.on("data", (bytes) => {
126− if (tunnelSocket.readyState === WebSocket.OPEN) tunnelSocket.send(bytes, { binary: true });
127− });
128− const closeBoth = () => {
129− if (!upstreamSocket.destroyed) upstreamSocket.destroy();
130− if (tunnelSocket.readyState < WebSocket.CLOSING) tunnelSocket.close();
131− };
132− upstreamSocket.on("close", closeBoth);
133− upstreamSocket.on("error", closeBoth);
134− tunnelSocket.on("close", closeBoth);
135−});
136−const tunnelServer = createServer();
137−tunnelServer.on("upgrade", (incoming, socket, head) => {
138− tunnelWebSockets.handleUpgrade(incoming, socket, head, (webSocket) => {
139− tunnelWebSockets.emit("connection", webSocket, incoming);
140− });
141−});
142−const tunnelPort = await listen(tunnelServer);
143−
144−const desktopLoopback = createTcpServer((browserSocket) => {
145− const tunnelSocket = new WebSocket(`ws://127.0.0.1:${tunnelPort}/tcp`);
146− const pending = [];
147− browserSocket.on("data", (bytes) => {
148− if (tunnelSocket.readyState === WebSocket.OPEN) tunnelSocket.send(bytes, { binary: true });
149− else pending.push(bytes);
150− });
151− tunnelSocket.on("open", () => pending.splice(0).forEach((bytes) => tunnelSocket.send(bytes, { binary: true })));
152− tunnelSocket.on("message", (message) => browserSocket.write(Buffer.from(message)));
153− const closeBoth = () => {
154− if (!browserSocket.destroyed) browserSocket.destroy();
155− if (tunnelSocket.readyState < WebSocket.CLOSING) tunnelSocket.close();
156− };
157− browserSocket.on("close", closeBoth);
158− browserSocket.on("error", closeBoth);
159− tunnelSocket.on("close", closeBoth);
160− tunnelSocket.on("error", closeBoth);
161−});
162−const desktopLoopbackPort = await listenTcp(desktopLoopback);
163−
164−async function measureFetch(url, count) {
165− const durations = [];
166− for (let index = 0; index < count; index += 1) {
167− const startedAt = performance.now();
168− const response = await fetch(url);
169− await response.arrayBuffer();
170− durations.push(performance.now() - startedAt);
171− }
172− return summarize(durations);
173−}
174−
175−function websocketRoundTrips(url, count) {
176− return new Promise((resolveResult, reject) => {
177− const socket = new WebSocket(url);
178− const durations = [];
179− let startedAt = 0;
180− let completed = 0;
181− socket.on("open", () => {
182− startedAt = performance.now();
183− socket.send(String(completed));
184− });
185− socket.on("message", (message) => {
186− durations.push(performance.now() - startedAt);
187− if (String(message) !== `upstream:${completed}`) {
188− reject(new Error(`Unexpected WebSocket response: ${message}`));
189− return;
190− }
191− completed += 1;
192− if (completed === count) {
193− socket.close();
194− resolveResult(summarize(durations));
195− return;
196− }
197− startedAt = performance.now();
198− socket.send(String(completed));
199− });
200− socket.on("error", reject);
201− });
202−}
203−
204−try {
205− const appResponse = await fetch(`http://127.0.0.1:${gatewayPort}/app`);
206− const appBody = await appResponse.text();
207− const directHttp = await measureFetch(`http://127.0.0.1:${upstreamPort}/bench`, 100);
208− const gatewayHttp = await measureFetch(`http://127.0.0.1:${gatewayPort}/bench`, 100);
209− const gatewayWebSocket = await websocketRoundTrips(`ws://127.0.0.1:${gatewayPort}/hmr`, 100);
210− const tunnelAppResponse = await fetch(`http://127.0.0.1:${desktopLoopbackPort}/app`);
211− const tunnelAppBody = await tunnelAppResponse.text();
212− const rawTunnelHttp = await measureFetch(`http://127.0.0.1:${desktopLoopbackPort}/bench`, 100);
213− const rawTunnelWebSocket = await websocketRoundTrips(
214− `ws://127.0.0.1:${desktopLoopbackPort}/hmr`,
215− 100,
216− );
217− process.stdout.write(
218− `${JSON.stringify(
219− {
220− success: appBody.includes("remote-loopback-ok"),
221− upstreamPort,
222− gatewayPort,
223− responseHeaders: Object.fromEntries(appResponse.headers),
224− directHttp,
225− gatewayHttp,
226− addedHttpMedianMs: gatewayHttp.medianMs - directHttp.medianMs,
227− gatewayWebSocket,
228− rawTcpTunnel: {
229− success: tunnelAppBody.includes("remote-loopback-ok"),
230− environmentTunnelPort: tunnelPort,
231− desktopLoopbackPort,
232− http: rawTunnelHttp,
233− addedHttpMedianMs: rawTunnelHttp.medianMs - directHttp.medianMs,
234− webSocket: rawTunnelWebSocket,
235− },
236− },
237− null,
238− 2,
239− )}\n`,
240− );
241−} finally {
242− await Promise.all([close(desktopLoopback), close(tunnelServer), close(gateway), close(upstream)]);
243−}
.plans/browser-phase-0/spikes/run-tunnel-security-spike.mjs+0−98
@@ -1,98 +0,0 @@
1−import { spawn } from "node:child_process";
2−import { createServer } from "node:http";
3−import { createRequire } from "node:module";
4−import { join, resolve } from "node:path";
5−
6−const repositoryRoot = resolve(import.meta.dirname, "../../..");
7−const requireDesktop = createRequire(join(repositoryRoot, "apps/desktop/package.json"));
8−const electronBinary = requireDesktop("electron");
9−
10−function listen(server) {
11− return new Promise((resolvePort, reject) => {
12− server.once("error", reject);
13− server.listen(0, "127.0.0.1", () => resolvePort(server.address().port));
14− });
15−}
16−
17−function close(server) {
18− return new Promise((resolveClose, reject) =>
19− server.close((error) => (error ? reject(error) : resolveClose())),
20− );
21−}
22−
23−const observed = [];
24−const previewHandler = (request, response) => {
25− observed.push({ url: request.url, origin: request.headers.origin ?? null });
26− if (request.url === "/absolute-redirect") {
27− response.writeHead(302, { location: "https://remote.example.test/callback" });
28− response.end();
29− return;
30− }
31− response.writeHead(200, { "content-type": "text/html" });
32− response.end("<!doctype html><title>Preview target</title><h1>preview target</h1>");
33−};
34−const previewA = createServer(previewHandler);
35−const previewB = createServer(previewHandler);
36−const malicious = createServer((_request, response) => {
37− response.writeHead(200, { "content-type": "text/html" });
38− response.end("<!doctype html><title>Untrusted page</title><h1>untrusted page</h1>");
39−});
40−
41−const [previewAPort, previewBPort, maliciousPort] = await Promise.all([
42− listen(previewA),
43− listen(previewB),
44− listen(malicious),
45−]);
46−const previewAUrl = `http://127.0.0.1:${previewAPort}`;
47−const previewBUrl = `http://127.0.0.1:${previewBPort}`;
48−const maliciousUrl = `http://127.0.0.1:${maliciousPort}`;
49−
50−const childEnvironment = { ...process.env };
51−delete childEnvironment.ELECTRON_RUN_AS_NODE;
52−const child = spawn(
53− electronBinary,
54− [join(import.meta.dirname, "tunnel-security-electron.cjs"), maliciousUrl, previewAUrl, previewBUrl],
55− { cwd: repositoryRoot, env: childEnvironment, stdio: ["ignore", "pipe", "pipe"] },
56−);
57−let stdout = "";
58−let stderr = "";
59−child.stdout.on("data", (bytes) => {
60− stdout += bytes;
61−});
62−child.stderr.on("data", (bytes) => {
63− stderr += bytes;
64−});
65−const exitCode = await new Promise((resolveExit) => child.once("exit", resolveExit));
66−
67−try {
68− const resultLine = stdout
69− .split("\n")
70− .find((line) => line.startsWith("PHASE05_TUNNEL "));
71− if (!resultLine) throw new Error(`Electron probe did not emit a result: ${stderr || stdout}`);
72− const browserResult = JSON.parse(resultLine.slice("PHASE05_TUNNEL ".length));
73− const redirectResponse = await fetch(`${previewAUrl}/absolute-redirect`, { redirect: "manual" });
74− process.stdout.write(
75− `${JSON.stringify(
76− {
77− success: exitCode === 0,
78− browserResult,
79− untrustedPageCausedLoopbackRequest: observed.some((request) => request.url === "/secret"),
80− loopbackRequestOriginHeader: observed.find((request) => request.url === "/secret")?.origin ?? null,
81− observed,
82− originStickiness: {
83− sameAuthorityPreservedStorage: browserResult.originalPortValue === "sticky",
84− differentPortChangedOrigin: browserResult.otherPortValue === null,
85− },
86− absoluteRedirect: {
87− status: redirectResponse.status,
88− location: redirectResponse.headers.get("location"),
89− escapedLoopbackAuthority: redirectResponse.headers.get("location")?.startsWith("https://") ?? false,
90− },
91− },
92− null,
93− 2,
94− )}\n`,
95− );
96−} finally {
97− await Promise.all([close(previewA), close(previewB), close(malicious)]);
98−}
.plans/browser-phase-0/spikes/tunnel-security-electron.cjs+0−16
@@ -1,16 +0,0 @@
1−const { app, BrowserWindow } = require('electron');
2−const [maliciousUrl, previewA, previewB] = process.argv.slice(2);
3−app.whenReady().then(async () => {
4− const window = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true, nodeIntegration: false } });
5− await window.loadURL(maliciousUrl);
6− const maliciousFetch = await window.webContents.executeJavaScript(`fetch(${JSON.stringify(previewA + '/secret')}, { mode: 'no-cors' }).then(() => 'sent').catch(error => String(error))`);
7− await new Promise(resolve => setTimeout(resolve, 150));
8− await window.loadURL(previewA + '/storage');
9− await window.webContents.executeJavaScript(`localStorage.setItem('phase05', 'sticky')`);
10− await window.loadURL(previewB + '/storage');
11− const otherPortValue = await window.webContents.executeJavaScript(`localStorage.getItem('phase05')`);
12− await window.loadURL(previewA + '/storage');
13− const originalPortValue = await window.webContents.executeJavaScript(`localStorage.getItem('phase05')`);
14− process.stdout.write(`PHASE05_TUNNEL ${JSON.stringify({ maliciousFetch, otherPortValue, originalPortValue })}\n`);
15− app.quit();
16−}).catch(error => { console.error(error); app.exit(1); });
.plans/collaborative-browser-architecture.html+0−1981
@@ -1,1981 +0,0 @@
1−<!doctype html>
2−<html lang="en">
3− <head>
4− <meta charset="utf-8" />
5− <meta name="viewport" content="width=device-width, initial-scale=1" />
6− <title>T3 Collaborative Browser Architecture</title>
7− <style>
8− :root {
9− color-scheme: dark;
10− --ink: #f2eee4;
11− --muted: #aaa99f;
12− --dim: #74766f;
13− --paper: #0b0d0e;
14− --panel: #111416;
15− --panel-2: #171b1d;
16− --line: #303638;
17− --line-bright: #596164;
18− --signal: #ff5e3a;
19− --signal-soft: #ff8d74;
20− --cyan: #69d6d2;
21− --yellow: #e7c75b;
22− --green: #7bcf92;
23− --blue: #78a8e8;
24− --purple: #b19ae8;
25− --danger: #ff6b72;
26− --sidebar: 286px;
27− --radius: 3px;
28− --shadow: 0 18px 70px rgb(0 0 0 / 0.32);
29− }
30−
31− * {
32− box-sizing: border-box;
33− }
34−
35− html {
36− scroll-behavior: smooth;
37− background: var(--paper);
38− }
39−
40− body {
41− margin: 0;
42− color: var(--ink);
43− background:
44− linear-gradient(rgb(255 255 255 / 0.018) 1px, transparent 1px),
45− linear-gradient(90deg, rgb(255 255 255 / 0.018) 1px, transparent 1px),
46− radial-gradient(circle at 82% 4%, rgb(105 214 210 / 0.09), transparent 31rem),
47− radial-gradient(circle at 36% 0%, rgb(255 94 58 / 0.08), transparent 27rem),
48− var(--paper);
49− background-size: 32px 32px, 32px 32px, auto, auto, auto;
50− font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
51− line-height: 1.55;
52− }
53−
54− button,
55− input {
56− font: inherit;
57− }
58−
59− a {
60− color: inherit;
61− }
62−
63− .shell {
64− min-height: 100vh;
65− }
66−
67− .rail {
68− position: fixed;
69− inset: 0 auto 0 0;
70− z-index: 20;
71− width: var(--sidebar);
72− overflow: auto;
73− border-right: 1px solid var(--line);
74− background: rgb(11 13 14 / 0.94);
75− backdrop-filter: blur(18px);
76− }
77−
78− .rail-brand {
79− padding: 28px 24px 22px;
80− border-bottom: 1px solid var(--line);
81− }
82−
83− .eyebrow {
84− display: flex;
85− gap: 8px;
86− align-items: center;
87− margin: 0 0 10px;
88− color: var(--signal-soft);
89− font-size: 10px;
90− font-weight: 700;
91− letter-spacing: 0.16em;
92− text-transform: uppercase;
93− }
94−
95− .eyebrow::before {
96− width: 18px;
97− height: 2px;
98− background: var(--signal);
99− content: "";
100− }
101−
102− .rail h1 {
103− max-width: 220px;
104− margin: 0;
105− font-family: Georgia, "Times New Roman", serif;
106− font-size: 28px;
107− font-weight: 400;
108− line-height: 1.02;
109− letter-spacing: -0.035em;
110− }
111−
112− .rail-meta {
113− margin-top: 16px;
114− color: var(--muted);
115− font-size: 11px;
116− }
117−
118− .rail-meta strong {
119− color: var(--ink);
120− font-weight: 500;
121− }
122−
123− .nav {
124− padding: 18px 12px 40px;
125− }
126−
127− .nav-group {
128− margin-bottom: 22px;
129− }
130−
131− .nav-label {
132− padding: 0 12px 7px;
133− color: var(--dim);
134− font-size: 9px;
135− font-weight: 700;
136− letter-spacing: 0.16em;
137− text-transform: uppercase;
138− }
139−
140− .nav a {
141− display: grid;
142− grid-template-columns: 28px 1fr;
143− gap: 7px;
144− align-items: baseline;
145− padding: 7px 12px;
146− border-left: 2px solid transparent;
147− color: var(--muted);
148− font-size: 11px;
149− text-decoration: none;
150− transition: 120ms ease;
151− }
152−
153− .nav a:hover,
154− .nav a.active {
155− border-left-color: var(--signal);
156− color: var(--ink);
157− background: rgb(255 255 255 / 0.035);
158− }
159−
160− .nav-index {
161− color: var(--dim);
162− font-variant-numeric: tabular-nums;
163− }
164−
165− main {
166− margin-left: var(--sidebar);
167− }
168−
169− .hero {
170− position: relative;
171− min-height: 86vh;
172− display: grid;
173− grid-template-columns: minmax(0, 1.25fr) minmax(340px, 0.75fr);
174− gap: 60px;
175− align-items: end;
176− padding: 86px clamp(34px, 6vw, 96px) 68px;
177− overflow: hidden;
178− border-bottom: 1px solid var(--line);
179− }
180−
181− .hero::after {
182− position: absolute;
183− top: 10%;
184− right: -11%;
185− width: 48vw;
186− aspect-ratio: 1;
187− border: 1px solid rgb(105 214 210 / 0.24);
188− border-radius: 50%;
189− box-shadow:
190− 0 0 0 72px rgb(105 214 210 / 0.025),
191− 0 0 0 144px rgb(105 214 210 / 0.018);
192− content: "";
193− pointer-events: none;
194− }
195−
196− .hero-copy {
197− position: relative;
198− z-index: 1;
199− }
200−
201− .hero-kicker {
202− margin-bottom: 24px;
203− color: var(--cyan);
204− font-size: 11px;
205− letter-spacing: 0.18em;
206− text-transform: uppercase;
207− }
208−
209− .hero h2 {
210− max-width: 900px;
211− margin: 0;
212− font-family: Georgia, "Times New Roman", serif;
213− font-size: clamp(56px, 7.2vw, 116px);
214− font-weight: 400;
215− line-height: 0.88;
216− letter-spacing: -0.065em;
217− }
218−
219− .hero h2 em {
220− color: var(--signal);
221− font-weight: inherit;
222− }
223−
224− .hero-summary {
225− max-width: 760px;
226− margin: 34px 0 0;
227− color: #cbc8be;
228− font-family: Georgia, "Times New Roman", serif;
229− font-size: 19px;
230− line-height: 1.55;
231− }
232−
233− .hero-side {
234− position: relative;
235− z-index: 1;
236− display: grid;
237− gap: 12px;
238− }
239−
240− .status-card {
241− padding: 20px;
242− border: 1px solid var(--line-bright);
243− background: rgb(17 20 22 / 0.8);
244− box-shadow: var(--shadow);
245− }
246−
247− .status-card.primary {
248− border-color: var(--signal);
249− }
250−
251− .status-label {
252− color: var(--dim);
253− font-size: 9px;
254− letter-spacing: 0.16em;
255− text-transform: uppercase;
256− }
257−
258− .status-value {
259− margin-top: 8px;
260− font-family: Georgia, "Times New Roman", serif;
261− font-size: 24px;
262− line-height: 1.15;
263− }
264−
265− .status-detail {
266− margin-top: 10px;
267− color: var(--muted);
268− font-size: 11px;
269− }
270−
271− section {
272− padding: 88px clamp(34px, 6vw, 96px);
273− border-bottom: 1px solid var(--line);
274− scroll-margin-top: 24px;
275− }
276−
277− .section-head {
278− display: grid;
279− grid-template-columns: 110px minmax(0, 1fr);
280− gap: 22px;
281− margin-bottom: 42px;
282− }
283−
284− .section-no {
285− padding-top: 8px;
286− color: var(--signal);
287− font-size: 12px;
288− letter-spacing: 0.14em;
289− }
290−
291− .section-title h3 {
292− margin: 0;
293− font-family: Georgia, "Times New Roman", serif;
294− font-size: clamp(37px, 4vw, 64px);
295− font-weight: 400;
296− line-height: 1;
297− letter-spacing: -0.045em;
298− }
299−
300− .section-title p {
301− max-width: 790px;
302− margin: 16px 0 0;
303− color: var(--muted);
304− font-size: 13px;
305− }
306−
307− .callout {
308− display: grid;
309− grid-template-columns: auto 1fr;
310− gap: 16px;
311− padding: 18px 20px;
312− border: 1px solid var(--line);
313− border-left: 3px solid var(--cyan);
314− background: var(--panel);
315− }
316−
317− .callout.warning {
318− border-left-color: var(--yellow);
319− }
320−
321− .callout.signal {
322− border-left-color: var(--signal);
323− }
324−
325− .callout-mark {
326− color: var(--cyan);
327− font-size: 18px;
328− }
329−
330− .callout.warning .callout-mark {
331− color: var(--yellow);
332− }
333−
334− .callout.signal .callout-mark {
335− color: var(--signal);
336− }
337−
338− .callout strong {
339− display: block;
340− margin-bottom: 4px;
341− color: var(--ink);
342− font-size: 12px;
343− }
344−
345− .callout span {
346− color: var(--muted);
347− font-size: 11px;
348− }
349−
350− .system-map {
351− margin-top: 30px;
352− padding: 26px;
353− border: 1px solid var(--line-bright);
354− background: #0c0f10;
355− box-shadow: var(--shadow);
356− }
357−
358− .map-row {
359− display: grid;
360− grid-template-columns: repeat(5, minmax(130px, 1fr));
361− gap: 22px;
362− align-items: stretch;
363− }
364−
365− .map-node {
366− position: relative;
367− min-height: 136px;
368− padding: 17px;
369− border: 1px solid var(--line);
370− background: var(--panel);
371− }
372−
373− .map-node:not(:last-child)::after {
374− position: absolute;
375− top: 50%;
376− right: -18px;
377− width: 13px;
378− border-top: 1px solid var(--signal);
379− content: "";
380− }
381−
382− .map-node:not(:last-child)::before {
383− position: absolute;
384− top: calc(50% - 3px);
385− right: -19px;
386− width: 6px;
387− height: 6px;
388− border-top: 1px solid var(--signal);
389− border-right: 1px solid var(--signal);
390− transform: rotate(45deg);
391− content: "";
392− }
393−
394− .node-tag {
395− color: var(--dim);
396− font-size: 9px;
397− letter-spacing: 0.14em;
398− text-transform: uppercase;
399− }
400−
401− .map-node h4 {
402− margin: 10px 0 7px;
403− font-family: Georgia, "Times New Roman", serif;
404− font-size: 20px;
405− font-weight: 400;
406− line-height: 1.08;
407− }
408−
409− .map-node p {
410− margin: 0;
411− color: var(--muted);
412− font-size: 10px;
413− }
414−
415− .map-node[data-tone="signal"] {
416− border-color: var(--signal);
417− }
418−
419− .map-node[data-tone="cyan"] {
420− border-color: var(--cyan);
421− }
422−
423− .layer-grid {
424− display: grid;
425− grid-template-columns: repeat(3, minmax(0, 1fr));
426− gap: 15px;
427− }
428−
429− .layer-card {
430− position: relative;
431− min-height: 290px;
432− padding: 22px;
433− overflow: hidden;
434− border: 1px solid var(--line);
435− background: var(--panel);
436− transition: 160ms ease;
437− }
438−
439− .layer-card::before {
440− position: absolute;
441− inset: 0 auto 0 0;
442− width: 3px;
443− background: var(--accent, var(--signal));
444− content: "";
445− }
446−
447− .layer-card:hover {
448− border-color: var(--line-bright);
449− transform: translateY(-3px);
450− box-shadow: 0 14px 42px rgb(0 0 0 / 0.24);
451− }
452−
453− .layer-index {
454− color: var(--accent, var(--signal));
455− font-size: 10px;
456− }
457−
458− .layer-card h4 {
459− margin: 30px 0 9px;
460− font-family: Georgia, "Times New Roman", serif;
461− font-size: 25px;
462− font-weight: 400;
463− line-height: 1.05;
464− }
465−
466− .layer-card > p {
467− min-height: 54px;
468− margin: 0 0 18px;
469− color: var(--muted);
470− font-size: 11px;
471− }
472−
473− .compact-list {
474− display: grid;
475− gap: 7px;
476− margin: 0;
477− padding: 0;
478− list-style: none;
479− }
480−
481− .compact-list li {
482− position: relative;
483− padding-left: 14px;
484− color: #c8c7be;
485− font-size: 10px;
486− }
487−
488− .compact-list li::before {
489− position: absolute;
490− top: 0.65em;
491− left: 0;
492− width: 6px;
493− border-top: 1px solid var(--accent, var(--signal));
494− content: "";
495− }
496−
497− .responsibility-grid {
498− display: grid;
499− grid-template-columns: 1fr 1fr;
500− gap: 18px;
501− }
502−
503− .responsibility {
504− padding: 24px;
505− border: 1px solid var(--line);
506− background: var(--panel);
507− }
508−
509− .responsibility h4 {
510− margin: 0 0 16px;
511− color: var(--accent, var(--signal));
512− font-size: 11px;
513− letter-spacing: 0.12em;
514− text-transform: uppercase;
515− }
516−
517− .responsibility p {
518− margin: 0 0 14px;
519− color: var(--muted);
520− font-size: 11px;
521− }
522−
523− .flow {
524− display: grid;
525− gap: 1px;
526− border: 1px solid var(--line);
527− background: var(--line);
528− }
529−
530− .flow-step {
531− display: grid;
532− grid-template-columns: 90px minmax(170px, 0.75fr) minmax(0, 1.25fr);
533− gap: 18px;
534− align-items: center;
535− padding: 18px 20px;
536− background: var(--panel);
537− }
538−
539− .flow-step:hover {
540− background: var(--panel-2);
541− }
542−
543− .flow-seq {
544− color: var(--signal);
545− font-size: 10px;
546− letter-spacing: 0.12em;
547− }
548−
549− .flow-name {
550− font-family: Georgia, "Times New Roman", serif;
551− font-size: 18px;
552− }
553−
554− .flow-detail {
555− color: var(--muted);
556− font-size: 10px;
557− }
558−
559− .state-machine {
560− display: grid;
561− grid-template-columns: repeat(5, 1fr);
562− gap: 14px;
563− margin: 26px 0;
564− }
565−
566− .state {
567− position: relative;
568− padding: 20px 16px;
569− border: 1px solid var(--line);
570− text-align: center;
571− background: var(--panel);
572− }
573−
574− .state:not(:last-child)::after {
575− position: absolute;
576− top: 50%;
577− right: -11px;
578− width: 8px;
579− border-top: 1px solid var(--line-bright);
580− content: "";
581− }
582−
583− .state strong {
584− display: block;
585− font-family: Georgia, "Times New Roman", serif;
586− font-size: 17px;
587− font-weight: 400;
588− }
589−
590− .state span {
591− display: block;
592− margin-top: 6px;
593− color: var(--dim);
594− font-size: 9px;
595− }
596−
597− .state[data-active="true"] {
598− border-color: var(--cyan);
599− box-shadow: inset 0 -2px var(--cyan);
600− }
601−
602− .code-block {
603− overflow: auto;
604− padding: 20px;
605− border: 1px solid var(--line);
606− color: #d4d0c5;
607− background: #080a0b;
608− font-size: 10px;
609− line-height: 1.75;
610− white-space: pre;
611− }
612−
613− .code-block .key {
614− color: var(--cyan);
615− }
616−
617− .code-block .type {
618− color: var(--yellow);
619− }
620−
621− .code-block .comment {
622− color: var(--dim);
623− }
624−
625− .automation-stack {
626− display: grid;
627− grid-template-columns: minmax(0, 1fr) 340px;
628− gap: 20px;
629− }
630−
631− .stack-layers {
632− display: grid;
633− gap: 8px;
634− }
635−
636− .stack-layer {
637− display: grid;
638− grid-template-columns: 150px 1fr;
639− gap: 18px;
640− padding: 17px 19px;
641− border: 1px solid var(--line);
642− background: var(--panel);
643− }
644−
645− .stack-layer strong {
646− color: var(--accent, var(--signal));
647− font-size: 10px;
648− letter-spacing: 0.08em;
649− text-transform: uppercase;
650− }
651−
652− .stack-layer span {
653− color: var(--muted);
654− font-size: 10px;
655− }
656−
657− .evidence-card {
658− padding: 22px;
659− border: 1px solid var(--yellow);
660− background:
661− linear-gradient(135deg, rgb(231 199 91 / 0.08), transparent 60%),
662− var(--panel);
663− }
664−
665− .evidence-card h4 {
666− margin: 0 0 12px;
667− font-family: Georgia, "Times New Roman", serif;
668− font-size: 25px;
669− font-weight: 400;
670− }
671−
672− .evidence-card p {
673− color: var(--muted);
674− font-size: 10px;
675− }
676−
677− .recording-pipe {
678− display: grid;
679− gap: 8px;
680− margin-top: 18px;
681− }
682−
683− .pipe-item {
684− padding: 10px 12px;
685− border-left: 2px solid var(--yellow);
686− background: rgb(255 255 255 / 0.028);
687− color: #d8d4c8;
688− font-size: 9px;
689− }
690−
691− .matrix-wrap {
692− overflow-x: auto;
693− border: 1px solid var(--line);
694− }
695−
696− table {
697− width: 100%;
698− min-width: 920px;
699− border-collapse: collapse;
700− background: var(--panel);
701− font-size: 10px;
702− }
703−
704− th,
705− td {
706− padding: 14px 16px;
707− border-right: 1px solid var(--line);
708− border-bottom: 1px solid var(--line);
709− text-align: left;
710− vertical-align: top;
711− }
712−
713− th {
714− position: sticky;
715− top: 0;
716− color: var(--ink);
717− background: #191d1f;
718− font-size: 9px;
719− letter-spacing: 0.09em;
720− text-transform: uppercase;
721− }
722−
723− td {
724− color: var(--muted);
725− }
726−
727− td:first-child {
728− color: var(--ink);
729− font-weight: 700;
730− }
731−
732− .badge {
733− display: inline-block;
734− padding: 3px 7px;
735− border: 1px solid currentColor;
736− border-radius: 99px;
737− font-size: 8px;
738− font-weight: 700;
739− letter-spacing: 0.08em;
740− text-transform: uppercase;
741− }
742−
743− .badge.green {
744− color: var(--green);
745− }
746−
747− .badge.yellow {
748− color: var(--yellow);
749− }
750−
751− .badge.blue {
752− color: var(--blue);
753− }
754−
755− .badge.red {
756− color: var(--danger);
757− }
758−
759− .transport-diagram {
760− display: grid;
761− gap: 18px;
762− }
763−
764− .transport-lane {
765− display: grid;
766− grid-template-columns: 150px repeat(4, minmax(120px, 1fr));
767− gap: 12px;
768− align-items: center;
769− }
770−
771− .lane-label {
772− color: var(--accent, var(--signal));
773− font-size: 10px;
774− font-weight: 700;
775− letter-spacing: 0.09em;
776− text-transform: uppercase;
777− }
778−
779− .lane-hop {
780− position: relative;
781− min-height: 84px;
782− display: grid;
783− place-items: center;
784− padding: 12px;
785− border: 1px solid var(--line);
786− color: #d5d2c7;
787− text-align: center;
788− background: var(--panel);
789− font-size: 9px;
790− }
791−
792− .lane-hop:not(:last-child)::after {
793− position: absolute;
794− top: 50%;
795− right: -10px;
796− width: 8px;
797− border-top: 1px dashed var(--line-bright);
798− content: "";
799− }
800−
801− .ssh-note {
802− display: grid;
803− grid-template-columns: 1fr 1fr;
804− gap: 16px;
805− margin-top: 28px;
806− }
807−
808− .decision {
809− padding: 20px;
810− border: 1px solid var(--line);
811− background: var(--panel);
812− }
813−
814− .decision.selected {
815− border-color: var(--green);
816− }
817−
818− .decision h4 {
819− margin: 0 0 8px;
820− font-family: Georgia, "Times New Roman", serif;
821− font-size: 20px;
822− font-weight: 400;
823− }
824−
825− .decision p {
826− margin: 0;
827− color: var(--muted);
828− font-size: 10px;
829− }
830−
831− .security-grid {
832− display: grid;
833− grid-template-columns: repeat(4, 1fr);
834− gap: 14px;
835− }
836−
837− .security-card {
838− padding: 20px;
839− border-top: 2px solid var(--accent, var(--signal));
840− background: var(--panel);
841− }
842−
843− .security-card h4 {
844− margin: 0 0 12px;
845− font-size: 10px;
846− letter-spacing: 0.1em;
847− text-transform: uppercase;
848− }
849−
850− .security-card p {
851− margin: 0;
852− color: var(--muted);
853− font-size: 10px;
854− }
855−
856− .roadmap {
857− position: relative;
858− display: grid;
859− gap: 14px;
860− }
861−
862− .phase {
863− display: grid;
864− grid-template-columns: 110px minmax(200px, 0.7fr) minmax(0, 1.3fr);
865− gap: 20px;
866− padding: 22px;
867− border: 1px solid var(--line);
868− background: var(--panel);
869− }
870−
871− .phase-status {
872− color: var(--dim);
873− font-size: 9px;
874− letter-spacing: 0.1em;
875− text-transform: uppercase;
876− }
877−
878− .phase-status.done {
879− color: var(--green);
880− }
881−
882− .phase h4 {
883− margin: 0;
884− font-family: Georgia, "Times New Roman", serif;
885− font-size: 21px;
886− font-weight: 400;
887− }
888−
889− .phase p {
890− margin: 0;
891− color: var(--muted);
892− font-size: 10px;
893− }
894−
895− .decision-log {
896− display: grid;
897− grid-template-columns: repeat(2, minmax(0, 1fr));
898− gap: 12px;
899− }
900−
901− .decision-log article {
902− padding: 18px 20px;
903− border: 1px solid var(--line);
904− background: var(--panel);
905− }
906−
907− .decision-log strong {
908− display: block;
909− color: var(--green);
910− font-size: 9px;
911− letter-spacing: 0.11em;
912− text-transform: uppercase;
913− }
914−
915− .decision-log h4 {
916− margin: 11px 0 7px;
917− font-family: Georgia, "Times New Roman", serif;
918− font-size: 20px;
919− font-weight: 400;
920− }
921−
922− .decision-log p {
923− margin: 0;
924− color: var(--muted);
925− font-size: 10px;
926− }
927−
928− footer {
929− padding: 42px clamp(34px, 6vw, 96px) 70px;
930− color: var(--dim);
931− font-size: 10px;
932− }
933−
934− .mobile-menu {
935− display: none;
936− }
937−
938− .filter-bar {
939− display: flex;
940− flex-wrap: wrap;
941− gap: 8px;
942− margin-bottom: 22px;
943− }
944−
945− .filter-button {
946− padding: 7px 10px;
947− border: 1px solid var(--line);
948− color: var(--muted);
949− background: transparent;
950− cursor: pointer;
951− }
952−
953− .filter-button:hover,
954− .filter-button.active {
955− border-color: var(--signal);
956− color: var(--ink);
957− background: rgb(255 94 58 / 0.08);
958− }
959−
960− [data-filtered="true"] {
961− display: none !important;
962− }
963−
964− @media (max-width: 1180px) {
965− .map-row,
966− .layer-grid {
967− grid-template-columns: repeat(2, minmax(0, 1fr));
968− }
969−
970− .map-node::before,
971− .map-node::after {
972− display: none;
973− }
974−
975− .security-grid {
976− grid-template-columns: repeat(2, 1fr);
977− }
978−
979− .automation-stack {
980− grid-template-columns: 1fr;
981− }
982−
983− .transport-lane {
984− grid-template-columns: 120px repeat(4, minmax(105px, 1fr));
985− }
986− }
987−
988− @media (max-width: 860px) {
989− :root {
990− --sidebar: 0px;
991− }
992−
993− .rail {
994− display: none;
995− }
996−
997− main {
998− margin-left: 0;
999− }
1000−
1001− .mobile-menu {
1002− position: sticky;
1003− top: 0;
1004− z-index: 30;
1005− display: flex;
1006− justify-content: space-between;
1007− padding: 12px 18px;
1008− border-bottom: 1px solid var(--line);
1009− color: var(--muted);
1010− background: rgb(11 13 14 / 0.92);
1011− backdrop-filter: blur(14px);
1012− font-size: 10px;
1013− }
1014−
1015− .hero {
1016− min-height: auto;
1017− grid-template-columns: 1fr;
1018− padding-top: 72px;
1019− }
1020−
1021− .hero h2 {
1022− font-size: clamp(52px, 15vw, 84px);
1023− }
1024−
1025− .section-head {
1026− grid-template-columns: 1fr;
1027− gap: 8px;
1028− }
1029−
1030− .layer-grid,
1031− .responsibility-grid,
1032− .ssh-note,
1033− .decision-log {
1034− grid-template-columns: 1fr;
1035− }
1036−
1037− .map-row {
1038− grid-template-columns: 1fr;
1039− }
1040−
1041− .state-machine {
1042− grid-template-columns: 1fr 1fr;
1043− }
1044−
1045− .state::after {
1046− display: none;
1047− }
1048−
1049− .flow-step,
1050− .phase {
1051− grid-template-columns: 1fr;
1052− gap: 8px;
1053− }
1054−
1055− .transport-lane {
1056− grid-template-columns: 1fr 1fr;
1057− }
1058−
1059− .lane-label {
1060− grid-column: 1 / -1;
1061− }
1062−
1063− .security-grid {
1064− grid-template-columns: 1fr;
1065− }
1066− }
1067−
1068− @media print {
1069− :root {
1070− color-scheme: light;
1071− --ink: #121416;
1072− --muted: #4c5254;
1073− --dim: #747a7c;
1074− --paper: #fff;
1075− --panel: #f5f4ef;
1076− --panel-2: #eeece6;
1077− --line: #c8c9c4;
1078− --line-bright: #8b8f90;
1079− }
1080−
1081− body {
1082− background: white;
1083− }
1084−
1085− .rail,
1086− .mobile-menu,
1087− .filter-bar {
1088− display: none;
1089− }
1090−
1091− main {
1092− margin-left: 0;
1093− }
1094−
1095− .hero {
1096− min-height: auto;
1097− }
1098−
1099− section {
1100− break-inside: avoid;
1101− }
1102− }
1103− </style>
1104− </head>
1105− <body>
1106− <div class="shell">
1107− <aside class="rail">
1108− <div class="rail-brand">
1109− <p class="eyebrow">Architecture 01</p>
1110− <h1>Collaborative Browser Runtime</h1>
1111− <div class="rail-meta">
1112− <strong>Status:</strong> Phase 0 decisions pinned<br />
1113− <strong>Branch:</strong> codex/browser-preview-port<br />
1114− <strong>Updated:</strong> June 12, 2026
1115− </div>
1116− </div>
1117− <nav class="nav" aria-label="Architecture sections">
1118− <div class="nav-group">
1119− <div class="nav-label">System</div>
1120− <a href="#north-star"><span class="nav-index">00</span><span>North star</span></a>
1121− <a href="#system-map"><span class="nav-index">01</span><span>System map</span></a>
1122− <a href="#layers"><span class="nav-index">02</span><span>Layer model</span></a>
1123− </div>
1124− <div class="nav-group">
1125− <div class="nav-label">Browser</div>
1126− <a href="#session-model"><span class="nav-index">03</span><span>Session + tab model</span></a>
1127− <a href="#webview-host"><span class="nav-index">04</span><span>Webview host</span></a>
1128− <a href="#lifecycle"><span class="nav-index">05</span><span>Lifecycle</span></a>
1129− </div>
1130− <div class="nav-group">
1131− <div class="nav-label">Control</div>
1132− <a href="#automation"><span class="nav-index">06</span><span>Automation</span></a>
1133− <a href="#evidence"><span class="nav-index">07</span><span>Recording + evidence</span></a>
1134− <a href="#collaboration"><span class="nav-index">08</span><span>Human + agent</span></a>
1135− </div>
1136− <div class="nav-group">
1137− <div class="nav-label">Remote</div>
1138− <a href="#reachability"><span class="nav-index">09</span><span>Reachability matrix</span></a>
1139− <a href="#tunnel"><span class="nav-index">10</span><span>Preview tunnel</span></a>
1140− <a href="#ssh-relay"><span class="nav-index">11</span><span>SSH + T3 Connect</span></a>
1141− </div>
1142− <div class="nav-group">
1143− <div class="nav-label">Delivery</div>
1144− <a href="#security"><span class="nav-index">12</span><span>Security</span></a>
1145− <a href="#roadmap"><span class="nav-index">13</span><span>Roadmap</span></a>
1146− <a href="#decisions"><span class="nav-index">14</span><span>Pinned decisions</span></a>
1147− </div>
1148− </nav>
1149− </aside>
1150−
1151− <div class="mobile-menu"><span>T3 / Browser Architecture</span><span>2026.06.12</span></div>
1152−
1153− <main>
1154− <header class="hero" id="north-star">
1155− <div class="hero-copy">
1156− <div class="hero-kicker">One real browser / multiple actors / any environment</div>
1157− <h2>A browser that is <em>shared state</em>, not a preview.</h2>
1158− <p class="hero-summary">
1159− T3 owns a route-durable Chromium page that users and agents manipulate together. The page
1160− survives panel changes—not renderer or window death—accepts semantic programmatic control, records evidence, and
1161− reaches development services whether they are local, SSH-hosted, private-networked,
1162− or connected through T3 Connect.
1163− </p>
1164− </div>
1165− <div class="hero-side">
1166− <div class="status-card primary">
1167− <div class="status-label">Core invariant</div>
1168− <div class="status-value">One session. One page state.</div>
1169− <div class="status-detail">
1170− Human interaction, agent automation, recording, DevTools, console, and network
1171− inspection all target the same guest WebContents.
1172− </div>
1173− </div>
1174− <div class="status-card">
1175− <div class="status-label">Selected browser host</div>
1176− <div class="status-value">Route-durable renderer-hosted &lt;webview&gt;</div>
1177− <div class="status-detail">
1178− App-level lifetime; desktop-main control; explicit lost state after renderer reload, crash, or window close.
1179− </div>
1180− </div>
1181− <div class="status-card">
1182− <div class="status-label">Remote model</div>
1183− <div class="status-value">Environment stream gateway</div>
1184− <div class="status-detail">
1185− A stable control endpoint carries authorized streams to arbitrary environment-local
1186− ports. Preview ports do not become public endpoints.
1187− </div>
1188− </div>
1189− </div>
1190− </header>
1191−
1192− <section id="system-map">
1193− <div class="section-head">
1194− <div class="section-no">01 / SYSTEM</div>
1195− <div class="section-title">
1196− <h3>End-to-end ownership map</h3>
1197− <p>
1198− The internal browser API is session-oriented and transport-neutral. MCP, skills,
1199− CLI tools, and future adapters are clients of the control service—not alternate
1200− browser implementations.
1201− </p>
1202− </div>
1203− </div>
1204− <div class="callout signal">
1205− <div class="callout-mark">→</div>
1206− <div>
1207− <strong>Authority flows toward the real page; state flows back through revisions and events.</strong>
1208− <span>The renderer may relay transport, but it does not decide whether a browser exists or whether automation is permitted.</span>
1209− </div>
1210− </div>
1211− <div class="system-map" aria-label="End-to-end system map">
1212− <div class="map-row">
1213− <article class="map-node">
1214− <div class="node-tag">Clients</div>
1215− <h4>Agent + Human</h4>
1216− <p>MCP, skill client, CLI, direct UI input, DevTools, recordings.</p>
1217− </article>
1218− <article class="map-node" data-tone="signal">
1219− <div class="node-tag">Environment server</div>
1220− <h4>Browser Control Service</h4>
1221− <p>Authorization, sessions, routing, command queues, control leases, target resolution.</p>
1222− </article>
1223− <article class="map-node">
1224− <div class="node-tag">Desktop transport</div>
1225− <h4>Typed Relay + IPC</h4>
1226− <p>Environment connection enters renderer, then typed IPC reaches desktop main.</p>
1227− </article>
1228− <article class="map-node" data-tone="cyan">
1229− <div class="node-tag">Desktop host</div>
1230− <h4>Electron Browser Host</h4>
1231− <p>Persistent CDP, webview registry, presentation bounds, recording, guest security.</p>
1232− </article>
1233− <article class="map-node" data-tone="signal">
1234− <div class="node-tag">Shared state</div>
1235− <h4>Guest WebContents</h4>
1236− <p>The actual Chromium page both human and agent observe and manipulate.</p>
1237− </article>
1238− </div>
1239− </div>
1240− </section>
1241−
1242− <section id="layers">
1243− <div class="section-head">
1244− <div class="section-no">02 / LAYERS</div>
1245− <div class="section-title">
1246− <h3>Six boundaries, one browser product</h3>
1247− <p>
1248− Each layer has a narrow authority. This prevents React visibility, provider quirks,
1249− network reachability, and artifact storage from leaking into the browser core.
1250− </p>
1251− </div>
1252− </div>
1253− <div class="layer-grid">
1254− <article class="layer-card" style="--accent: var(--purple)">
1255− <div class="layer-index">L1 / ENTRY</div>
1256− <h4>Agent Adapters</h4>
1257− <p>Provider-neutral ways to ask for browser work.</p>
1258− <ul class="compact-list">
1259− <li>MCP toolkit</li>
1260− <li>Browser skill + Node helper</li>
1261− <li>CLI / agent-browser compatibility</li>
1262− <li>Internal TypeScript test client</li>
1263− </ul>
1264− </article>
1265− <article class="layer-card" style="--accent: var(--signal)">
1266− <div class="layer-index">L2 / CONTROL</div>
1267− <h4>Browser Control Service</h4>
1268− <p>The internal API and policy boundary.</p>
1269− <ul class="compact-list">
1270− <li>Invocation authorization</li>
1271− <li>Per-tab command serialization</li>
1272− <li>Cancellation + deadlines</li>
1273− <li>Control leases + interruption</li>
1274− </ul>
1275− </article>
1276− <article class="layer-card" style="--accent: var(--yellow)">
1277− <div class="layer-index">L3 / STATE</div>
1278− <h4>Session Registry</h4>
1279− <p>Authoritative logical lifecycle and recovery state.</p>
1280− <ul class="compact-list">
1281− <li>Stable session + tab IDs</li>
1282− <li>Host assignment</li>
1283− <li>Lifecycle revisions</li>
1284− <li>Recording + artifact references</li>
1285− </ul>
1286− </article>
1287− <article class="layer-card" style="--accent: var(--cyan)">
1288− <div class="layer-index">L4 / HOST</div>
1289− <h4>Electron Browser Host</h4>
1290− <p>Owns the relationship between durable DOM webviews and desktop main.</p>
1291− <ul class="compact-list">
1292− <li>App-level mounted webviews</li>
1293− <li>Visible/offscreen/covered parking</li>
1294− <li>Guest WebContents validation</li>
1295− <li>Persistent debugger connections</li>
1296− </ul>
1297− </article>
1298− <article class="layer-card" style="--accent: var(--green)">
1299− <div class="layer-index">L5 / REACH</div>
1300− <h4>Target + Tunnel Service</h4>
1301− <p>Resolves environment-relative targets into browser-reachable loopback URLs.</p>
1302− <ul class="compact-list">
1303− <li>Direct URL classification</li>
1304− <li>Environment-port grants</li>
1305− <li>Raw TCP stream tunnels</li>
1306− <li>SSH, relay, LAN, Tailscale transport</li>
1307− </ul>
1308− </article>
1309− <article class="layer-card" style="--accent: var(--blue)">
1310− <div class="layer-index">L6 / EVIDENCE</div>
1311− <h4>Artifact Service</h4>
1312− <p>Stores durable evidence without blocking control/event traffic.</p>
1313− <ul class="compact-list">
1314− <li>H.264 MP4 recordings</li>
1315− <li>Action timeline + screenshots</li>
1316− <li>Console, exceptions, network</li>
1317− <li>Downloads, HAR, traces</li>
1318− </ul>
1319− </article>
1320− </div>
1321− </section>
1322−
1323− <section id="session-model">
1324− <div class="section-head">
1325− <div class="section-no">03 / MODEL</div>
1326− <div class="section-title">
1327− <h3>Session, tab, view, and controller are separate</h3>
1328− <p>
1329− The old preview model conflates “panel mounted” with “browser exists.” The new model
1330− gives each concern its own state and lifecycle.
1331− </p>
1332− </div>
1333− </div>
1334− <div class="responsibility-grid">
1335− <article class="responsibility" style="--accent: var(--signal)">
1336− <h4>BrowserSession</h4>
1337− <p>Logical browser workspace bound to an environment, thread, host, storage partition, and artifact history.</p>
1338− <ul class="compact-list">
1339− <li>Outlives panel and route mounts</li>
1340− <li>Owns active tab selection</li>
1341− <li>Tracks recovery + host availability</li>
1342− <li>May be visible, hidden, detached, suspended, or closed</li>
1343− </ul>
1344− </article>
1345− <article class="responsibility" style="--accent: var(--cyan)">
1346− <h4>BrowserTab</h4>
1347− <p>One real Chromium page with origin, control, navigation, revision, and recording state.</p>
1348− <ul class="compact-list">
1349− <li>Created by human, agent, or system</li>
1350− <li>Can be adopted and handed off</li>
1351− <li>Has one host and one guest WebContents</li>
1352− <li>Element references expire with document revision</li>
1353− </ul>
1354− </article>
1355− </div>
1356− <div class="code-block" aria-label="Core contracts"><span class="comment">// contracts remain schema-only</span>
1357−<span class="key">BrowserSession</span> {
1358− id, environmentId, threadId, hostId, activeTabId,
1359− lifecycle: <span class="type">creating | ready | suspended | recovering | closed</span>,
1360− visibility: <span class="type">visible | hidden | detached</span>,
1361− controller: <span class="type">human | agent | none</span>,
1362− partitionId, recordingId, revision
1363−}
1364−
1365−<span class="key">BrowserTab</span> {
1366− id, sessionId, webContentsId, origin,
1367− state: <span class="type">opening | ready | closing | closed</span>,
1368− presentation: <span class="type">visible | parked-idle | parked-recording</span>,
1369− url, title, documentRevision, controller
1370−}</div>
1371− </section>
1372−
1373− <section id="webview-host">
1374− <div class="section-head">
1375− <div class="section-no">04 / HOST</div>
1376− <div class="section-title">
1377− <h3>The tab manager owns durable webviews</h3>
1378− <p>
1379− Keep the existing real Electron webview, but lift it out of `PreviewPanel` and thread
1380− route lifecycle. A window-level host maintains browser elements for every live tab.
1381− </p>
1382− </div>
1383− </div>
1384− <div class="flow">
1385− <div class="flow-step">
1386− <div class="flow-seq">HOST / 01</div>
1387− <div class="flow-name">Create tab</div>
1388− <div class="flow-detail">Session registry assigns tab ID and partition. App-level host mounts one webview and waits for `dom-ready`.</div>
1389− </div>
1390− <div class="flow-step">
1391− <div class="flow-seq">HOST / 02</div>
1392− <div class="flow-name">Register guest</div>
1393− <div class="flow-detail">Renderer reports `webContentsId`; desktop main verifies type=`webview` and correct host window before accepting it.</div>
1394− </div>
1395− <div class="flow-step">
1396− <div class="flow-seq">HOST / 03</div>
1397− <div class="flow-name">Attach control</div>
1398− <div class="flow-detail">Desktop main creates the persistent CDP connection, navigation listeners, console/network buffers, and recording controller.</div>
1399− </div>
1400− <div class="flow-step">
1401− <div class="flow-seq">HOST / 04</div>
1402− <div class="flow-name">Present surface</div>
1403− <div class="flow-detail">Panel reports bounds. Host places the same webview at visible bounds or an internal parking surface without recreating the page.</div>
1404− </div>
1405− <div class="flow-step">
1406− <div class="flow-seq">HOST / 05</div>
1407− <div class="flow-name">Explicit close</div>
1408− <div class="flow-detail">Only session/tab lifecycle commands close the guest. React unmounts, route changes, and panel closure never imply browser destruction.</div>
1409− </div>
1410− </div>
1411− <div class="ssh-note">
1412− <div class="decision selected">
1413− <span class="badge green">Selected</span>
1414− <h4>Renderer-hosted &lt;webview&gt;</h4>
1415− <p>Preserves the existing composited browser surface, overlays, clipping, capture behavior, and user interaction model.</p>
1416− </div>
1417− <div class="decision">
1418− <span class="badge red">Rejected for now</span>
1419− <h4>WebContentsView</h4>
1420− <p>Playwright-visible and main-owned, but hidden/detached capture failed and native view stacking complicates collaboration UI.</p>
1421− </div>
1422− </div>
1423− </section>
1424−
1425− <section id="lifecycle">
1426− <div class="section-head">
1427− <div class="section-no">05 / LIFE</div>
1428− <div class="section-title">
1429− <h3>Presentation state is not process state</h3>
1430− <p>
1431− Tabs remain live while hidden. Resource policy can explicitly suspend inactive tabs,
1432− but never silently evicts a controlled or recording session.
1433− </p>
1434− </div>
1435− </div>
1436− <div class="state-machine">
1437− <div class="state"><strong>Creating</strong><span>webview mounting</span></div>
1438− <div class="state" data-active="true"><strong>Ready</strong><span>visible or parked</span></div>
1439− <div class="state"><strong>Suspended</strong><span>explicit reload required</span></div>
1440− <div class="state"><strong>Recovering</strong><span>host reconnect</span></div>
1441− <div class="state"><strong>Closed</strong><span>terminal</span></div>
1442− </div>
1443− <div class="responsibility-grid">
1444− <article class="responsibility" style="--accent: var(--cyan)">
1445− <h4>Idle hidden tab</h4>
1446− <p>Park offscreen at the preserved viewport size. Timers, network, CDP, and semantic automation continue.</p>
1447− <ul class="compact-list">
1448− <li>Lowest practical compositor cost</li>
1449− <li>No continuous recording frames</li>
1450− <li>Snapshot/capture available when surface remains valid</li>
1451− </ul>
1452− </article>
1453− <article class="responsibility" style="--accent: var(--yellow)">
1454− <h4>Hidden recording tab</h4>
1455− <p>Move to a covered full-size parking surface inside the window. The user sees app chrome, not the browser, while Chromium keeps painting frames.</p>
1456− <ul class="compact-list">
1457− <li>CDP screencast remains active</li>
1458− <li>Recording does not force panel open</li>
1459− <li>Same page and viewport are preserved</li>
1460− </ul>
1461− </article>
1462− </div>
1463− </section>
1464−
1465− <section id="automation">
1466− <div class="section-head">
1467− <div class="section-no">06 / AUTO</div>
1468− <div class="section-title">
1469− <h3>Playwright semantics without Playwright ownership</h3>
1470− <p>
1471− Direct Playwright CDP attachment does not expose an Electron guest target as a
1472− `Page`. The selected adapter uses persistent CDP plus a versioned injected utility
1473− runtime to provide semantic, agent-friendly browser actions.
1474− </p>
1475− </div>
1476− </div>
1477− <div class="automation-stack">
1478− <div class="stack-layers">
1479− <div class="stack-layer" style="--accent: var(--purple)">
1480− <strong>Agent surface</strong>
1481− <span>`browser.open`, semantic locate, click, type, drag, wait, assert, record, console, network, tabs.</span>
1482− </div>
1483− <div class="stack-layer" style="--accent: var(--signal)">
1484− <strong>Control service</strong>
1485− <span>Capability checks, control lease, command ID, deadline, cancellation, page revision, result evidence.</span>
1486− </div>
1487− <div class="stack-layer" style="--accent: var(--yellow)">
1488− <strong>Semantic adapter</strong>
1489− <span>Version-pinned Playwright injected runtime, locator re-resolution, actionability, shadow DOM, and per-frame routing.</span>
1490− </div>
1491− <div class="stack-layer" style="--accent: var(--cyan)">
1492− <strong>Persistent CDP</strong>
1493− <span>Accessibility, DOM, Runtime, Input, Page, Network, Log; one serialized connection per tab.</span>
1494− </div>
1495− <div class="stack-layer" style="--accent: var(--green)">
1496− <strong>Guest page</strong>
1497− <span>The same live WebContents displayed to the user. No synchronized second browser.</span>
1498− </div>
1499− </div>
1500− <aside class="evidence-card">
1501− <span class="badge yellow">Phase 0.5 proof</span>
1502− <h4>Playwright's injected runtime works over CDP.</h4>
1503− <p>
1504− The 307 KB runtime installed in about 6.5–8.5 ms, resolved role/name through shadow DOM,
1505− and re-resolved a locator after its element was replaced. OOPIF routing remains T3-owned.
1506− </p>
1507− <div class="recording-pipe">
1508− <div class="pipe-item">locator description → fresh resolution</div>
1509− <div class="pipe-item">injected actionability → native dispatch</div>
1510− <div class="pipe-item">frame target → isolated runtime</div>
1511− <div class="pipe-item">ephemeral node reference → optional fast path</div>
1512− </div>
1513− </aside>
1514− </div>
1515− <div class="callout warning" style="margin-top: 22px">
1516− <div class="callout-mark">!</div>
1517− <div>
1518− <strong>Raw CDP is a privileged developer escape hatch.</strong>
1519− <span>Do not expose an unauthenticated process-wide Chromium debugging port in production. Normal agents use the Browser Control Service.</span>
1520− </div>
1521− </div>
1522− </section>
1523−
1524− <section id="evidence">
1525− <div class="section-head">
1526− <div class="section-no">07 / PROOF</div>
1527− <div class="section-title">
1528− <h3>Record the work, not the browser UX</h3>
1529− <p>
1530− Video and structured traces are review artifacts. The interactive experience remains
1531− the real browser; frames are sampled only for evidence generation.
1532− </p>
1533− </div>
1534− </div>
1535− <div class="system-map">
1536− <div class="map-row" style="grid-template-columns: repeat(4, minmax(150px, 1fr))">
1537− <article class="map-node" data-tone="cyan">
1538− <div class="node-tag">Capture</div>
1539− <h4>CDP Screencast</h4>
1540− <p>Guest-only compositor frames. Sample to a bounded target frame rate.</p>
1541− </article>
1542− <article class="map-node">
1543− <div class="node-tag">Worker surface</div>
1544− <h4>Recording Canvas</h4>
1545− <p>Isolated renderer draws frames and optional agent/human cursor overlays.</p>
1546− </article>
1547− <article class="map-node" data-tone="signal">
1548− <div class="node-tag">Encode</div>
1549− <h4>MediaRecorder</h4>
1550− <p>H.264 MP4, initial 12 fps, no external ffmpeg requirement.</p>
1551− </article>
1552− <article class="map-node">
1553− <div class="node-tag">Persist</div>
1554− <h4>Artifact Service</h4>
1555− <p>Video, timeline, screenshots, logs, network, downloads, traces.</p>
1556− </article>
1557− </div>
1558− </div>
1559− <div class="responsibility-grid" style="margin-top: 18px">
1560− <article class="responsibility" style="--accent: var(--yellow)">
1561− <h4>Structured action timeline</h4>
1562− <ul class="compact-list">
1563− <li>Actor, action, input summary, status</li>
1564− <li>Before/after document revisions</li>
1565− <li>Recording timestamp and screenshot refs</li>
1566− <li>Failure, interruption, or timeout reason</li>
1567− </ul>
1568− </article>
1569− <article class="responsibility" style="--accent: var(--blue)">
1570− <h4>Diagnostic evidence</h4>
1571− <ul class="compact-list">
1572− <li>Console and uncaught exceptions</li>
1573− <li>Failed requests and response summaries</li>
1574− <li>Optional HAR / deeper trace mode</li>
1575− <li>Secret redaction before persistence</li>
1576− </ul>
1577− </article>
1578− </div>
1579− </section>
1580−
1581− <section id="collaboration">
1582− <div class="section-head">
1583− <div class="section-no">08 / SHARE</div>
1584− <div class="section-title">
1585− <h3>Human interruption is a feature</h3>
1586− <p>
1587− The browser is collaborative, not locked. Explicit control leases make conflicting
1588− input predictable while allowing observation and immediate human takeover.
1589− </p>
1590− </div>
1591− </div>
1592− <div class="flow">
1593− <div class="flow-step">
1594− <div class="flow-seq">LEASE / READ</div>
1595− <div class="flow-name">Observe freely</div>
1596− <div class="flow-detail">Snapshots, URL, title, console, network, and action state can continue while another actor controls input.</div>
1597− </div>
1598− <div class="flow-step">
1599− <div class="flow-seq">LEASE / WRITE</div>
1600− <div class="flow-name">Serialize mutations</div>
1601− <div class="flow-detail">Clicks, typing, navigation, drag, and uploads run through the per-tab command queue with one active controller.</div>
1602− </div>
1603− <div class="flow-step">
1604− <div class="flow-seq">LEASE / BREAK</div>
1605− <div class="flow-name">Human interrupts</div>
1606− <div class="flow-detail">Pointer or keyboard input cancels the conflicting agent operation and returns a typed interruption result.</div>
1607− </div>
1608− <div class="flow-step">
1609− <div class="flow-seq">LEASE / HAND</div>
1610− <div class="flow-name">Adopt or hand off</div>
1611− <div class="flow-detail">Tabs carry human/agent origin and can be intentionally adopted, finalized, retained, or returned to the user.</div>
1612− </div>
1613− </div>
1614− </section>
1615−
1616− <section id="reachability">
1617− <div class="section-head">
1618− <div class="section-no">09 / REACH</div>
1619− <div class="section-title">
1620− <h3>Connection and reachability matrix</h3>
1621− <p>
1622− The browser should accept environment-relative targets. Resolution decides whether
1623− the browser loads directly or through a private loopback tunnel.
1624− </p>
1625− </div>
1626− </div>
1627− <div class="filter-bar" aria-label="Reachability filters">
1628− <button class="filter-button active" data-filter="all">All scenarios</button>
1629− <button class="filter-button" data-filter="direct">Direct reachability</button>
1630− <button class="filter-button" data-filter="tunnel">Tunneled reachability</button>
1631− <button class="filter-button" data-filter="public">Public sharing</button>
1632− </div>
1633− <div class="matrix-wrap">
1634− <table>
1635− <thead>
1636− <tr>
1637− <th>Environment</th>
1638− <th>Target example</th>
1639− <th>Browser path</th>
1640− <th>Transport</th>
1641− <th>Product behavior</th>
1642− </tr>
1643− </thead>
1644− <tbody>
1645− <tr data-kind="direct">
1646− <td>Same machine</td>
1647− <td>`localhost:5173`</td>
1648− <td>Direct local URL</td>
1649− <td><span class="badge green">Direct</span></td>
1650− <td>No tunnel. Preserve normal localhost browser semantics.</td>
1651− </tr>
1652− <tr data-kind="direct">
1653− <td>LAN / Tailscale</td>
1654− <td>`mac-mini.local:5173`</td>
1655− <td>Direct private-network URL when policy allows</td>
1656− <td><span class="badge blue">Private network</span></td>
1657− <td>Prefer direct reachability; fall back to environment stream if unavailable.</td>
1658− </tr>
1659− <tr data-kind="tunnel">
1660− <td>SSH environment</td>
1661− <td>remote `127.0.0.1:5173`</td>
1662− <td>desktop loopback listener → preview stream → SSH-forwarded T3 server</td>
1663− <td><span class="badge yellow">T3 stream over SSH</span></td>
1664− <td>Default path. Native `ssh -L` may be a later optimization.</td>
1665− </tr>
1666− <tr data-kind="tunnel">
1667− <td>T3 Connect relay</td>
1668− <td>environment `127.0.0.1:3000`</td>
1669− <td>desktop loopback listener → preview stream → existing environment hostname</td>
1670− <td><span class="badge yellow">T3 stream over relay</span></td>
1671− <td>One stable public control endpoint; preview ports remain private logical streams.</td>
1672− </tr>
1673− <tr data-kind="tunnel">
1674− <td>Docker / WSL / VM</td>
1675− <td>environment-local service</td>
1676− <td>environment-port target → environment stream gateway</td>
1677− <td><span class="badge yellow">T3 stream</span></td>
1678− <td>Agent never guesses host IPs. Environment resolves its own loopback target.</td>
1679− </tr>
1680− <tr data-kind="public">
1681− <td>Public app sharing</td>
1682− <td>share preview with external person</td>
1683− <td>dedicated published endpoint</td>
1684− <td><span class="badge red">Separate product</span></td>
1685− <td>Not the browser-preview tunnel. Requires explicit publication and separate access policy.</td>
1686− </tr>
1687− </tbody>
1688− </table>
1689− </div>
1690− </section>
1691−
1692− <section id="tunnel">
1693− <div class="section-head">
1694− <div class="section-no">10 / TUNNEL</div>
1695− <div class="section-title">
1696− <h3>One environment endpoint, many private streams</h3>
1697− <p>
1698− Do not provision a public Cloudflare route for every dev port. The existing T3 server
1699− endpoint carries authorized preview streams to environment-local TCP targets.
1700− </p>
1701− </div>
1702− </div>
1703− <div class="transport-diagram">
1704− <div class="transport-lane" style="--accent: var(--cyan)">
1705− <div class="lane-label">Browser side</div>
1706− <div class="lane-hop">Guest webview<br />`127.0.0.1:49101`</div>
1707− <div class="lane-hop">Desktop loopback<br />TCP listener</div>
1708− <div class="lane-hop">Authenticated<br />preview stream</div>
1709− <div class="lane-hop">Environment server<br />stream endpoint</div>
1710− </div>
1711− <div class="transport-lane" style="--accent: var(--green)">
1712− <div class="lane-label">Environment side</div>
1713− <div class="lane-hop">Grant validation</div>
1714− <div class="lane-hop">Target policy<br />loopback + port</div>
1715− <div class="lane-hop">TCP dial<br />`127.0.0.1:5173`</div>
1716− <div class="lane-hop">Vite / Next / app<br />HTTP + WebSocket</div>
1717− </div>
1718− </div>
1719− <div class="callout" style="margin-top: 24px">
1720− <div class="callout-mark">↔</div>
1721− <div>
1722− <strong>Raw TCP preserves browser behavior.</strong>
1723− <span>Root-relative assets, redirects, cookies, streaming responses, HMR upgrades, and application WebSockets pass without response rewriting.</span>
1724− </div>
1725− </div>
1726− <div class="responsibility-grid" style="margin-top: 18px">
1727− <article class="responsibility" style="--accent: var(--signal)">
1728− <h4>Initial stream model</h4>
1729− <ul class="compact-list">
1730− <li>One WebSocket per accepted browser TCP connection</li>
1731− <li>Dedicated from RPC/event traffic</li>
1732− <li>Backpressure, timeout, cancellation, half-close</li>
1733− <li>Easy to implement and diagnose</li>
1734− </ul>
1735− </article>
1736− <article class="responsibility" style="--accent: var(--purple)">
1737− <h4>Scale-up model</h4>
1738− <ul class="compact-list">
1739− <li>Multiplex streams per browser session</li>
1740− <li>Per-stream flow-control windows</li>
1741− <li>Bounded queues and concurrency</li>
1742− <li>Only after profiling real asset-heavy apps</li>
1743− </ul>
1744− </article>
1745− </div>
1746− </section>
1747−
1748− <section id="ssh-relay">
1749− <div class="section-head">
1750− <div class="section-no">11 / ROUTES</div>
1751− <div class="section-title">
1752− <h3>SSH and T3 Connect carry the same preview protocol</h3>
1753− <p>
1754− Reachability transport changes; browser control does not. This keeps lifecycle,
1755− grants, diagnostics, recording, and agent behavior consistent across environments.
1756− </p>
1757− </div>
1758− </div>
1759− <div class="transport-diagram">
1760− <div class="transport-lane" style="--accent: var(--blue)">
1761− <div class="lane-label">SSH</div>
1762− <div class="lane-hop">Browser loopback</div>
1763− <div class="lane-hop">Preview stream</div>
1764− <div class="lane-hop">Existing `ssh -L`<br />to T3 server</div>
1765− <div class="lane-hop">Remote target port</div>
1766− </div>
1767− <div class="transport-lane" style="--accent: var(--yellow)">
1768− <div class="lane-label">T3 Connect</div>
1769− <div class="lane-hop">Browser loopback</div>
1770− <div class="lane-hop">Preview stream</div>
1771− <div class="lane-hop">One managed<br />Cloudflare endpoint</div>
1772− <div class="lane-hop">Environment target port</div>
1773− </div>
1774− <div class="transport-lane" style="--accent: var(--green)">
1775− <div class="lane-label">LAN / Tailnet</div>
1776− <div class="lane-hop">Browser target</div>
1777− <div class="lane-hop">Direct URL or stream</div>
1778− <div class="lane-hop">Private network</div>
1779− <div class="lane-hop">Environment target port</div>
1780− </div>
1781− </div>
1782− <div class="ssh-note">
1783− <div class="decision selected">
1784− <span class="badge green">Default</span>
1785− <h4>Stream through T3 server</h4>
1786− <p>One protocol across SSH, relay, and direct environments. Reuses authentication, grants, diagnostics, and lifecycle.</p>
1787− </div>
1788− <div class="decision">
1789− <span class="badge blue">Later optimization</span>
1790− <h4>Native SSH port forward</h4>
1791− <p>Generalize the existing `ssh -L local:127.0.0.1:remote` manager only if direct forwarding materially improves performance or compatibility.</p>
1792− </div>
1793− </div>
1794− <div class="callout warning" style="margin-top: 22px">
1795− <div class="callout-mark">!</div>
1796− <div>
1797− <strong>Public port publishing is not preview reachability.</strong>
1798− <span>“Share port 5173 with the internet” requires explicit publication, separate access control, and possibly additional managed ingress. It should not happen automatically when an agent opens a browser.</span>
1799− </div>
1800− </div>
1801− </section>
1802−
1803− <section id="security">
1804− <div class="section-head">
1805− <div class="section-no">12 / TRUST</div>
1806− <div class="section-title">
1807− <h3>Capability, site, target, and artifact boundaries</h3>
1808− <p>Browser access is powerful. Authorization should be explicit but not turn every click into a dialog.</p>
1809− </div>
1810− </div>
1811− <div class="security-grid">
1812− <article class="security-card" style="--accent: var(--purple)">
1813− <h4>Invocation scope</h4>
1814− <p>`observe`, `interact`, `navigate`, `evaluate`, `record`, `network-inspect`, and privileged `raw-cdp` capabilities.</p>
1815− </article>
1816− <article class="security-card" style="--accent: var(--green)">
1817− <h4>Browser-attributed tunnel ingress</h4>
1818− <p>A loopback port is not authorization. Use a stable authority, identify the requesting guest, and scope the remote grant to one session and target.</p>
1819− </article>
1820− <article class="security-card" style="--accent: var(--signal)">
1821− <h4>Site policy</h4>
1822− <p>Origins, external navigation, auth pages, clipboard, uploads, downloads, permissions, evaluation, and raw CDP.</p>
1823− </article>
1824− <article class="security-card" style="--accent: var(--yellow)">
1825− <h4>Artifact policy</h4>
1826− <p>Retention, size bounds, redaction of cookies/auth headers, encrypted storage where required, explicit sharing.</p>
1827− </article>
1828− </div>
1829− <div class="callout signal" style="margin-top: 20px">
1830− <div class="callout-mark">×</div>
1831− <div>
1832− <strong>Normal tool input never supplies environment or thread identity.</strong>
1833− <span>Identity comes from the authenticated provider/session scope. Browser and tunnel commands cannot cross-control another thread by argument substitution.</span>
1834− </div>
1835− </div>
1836− <div class="callout warning" style="margin-top: 16px">
1837− <div class="callout-mark">!</div>
1838− <div>
1839− <strong>Phase 0.5 proved unrelated browser content can cause loopback requests.</strong>
1840− <span>Port secrecy and `Origin` checks are insufficient. Stable origins, browser attribution, bounded multiplexing, and explicit redirect/HTTPS policy gate the remote preview implementation.</span>
1841− </div>
1842− </div>
1843− </section>
1844−
1845− <section id="roadmap">
1846− <div class="section-head">
1847− <div class="section-no">13 / BUILD</div>
1848− <div class="section-title">
1849− <h3>Implementation sequence</h3>
1850− <p>Build the durable host first; everything else becomes simpler once panel visibility no longer owns browser lifetime.</p>
1851− </div>
1852− </div>
1853− <div class="roadmap">
1854− <article class="phase">
1855− <div class="phase-status done">Phase 0 / complete</div>
1856− <h4>Prove decisions</h4>
1857− <p>Semantic CDP, hidden lifecycle, H.264 recording, raw TCP preview streams, routing latency, and host comparison.</p>
1858− </article>
1859− <article class="phase">
1860− <div class="phase-status done">Phase 0.5 / complete</div>
1861− <h4>Close production risks</h4>
1862− <p>Renderer-death blast radius, Playwright injected runtime, 1600×1200 recording endurance at about 11 fps, loopback threat model, origin stability, and input interruption.</p>
1863− </article>
1864− <article class="phase">
1865− <div class="phase-status">Phase 1</div>
1866− <h4>Durable session core</h4>
1867− <p>Session/tab/window contracts, app-level webview host, host-loss UX, annotations, source attribution, discovered servers, guest hardening, and parking states.</p>
1868− </article>
1869− <article class="phase">
1870− <div class="phase-status">Phase 2</div>
1871− <h4>Browser control service</h4>
1872− <p>Persistent CDP, Playwright injected runtime, OOPIF routing, locator re-resolution, console/network buffers, interruption, and tab APIs.</p>
1873− </article>
1874− <article class="phase">
1875− <div class="phase-status">Phase 3</div>
1876− <h4>Remote reachability</h4>
1877− <p>Environment-port targets, stable authorities, browser-attributed ingress, multiplexed TCP streams, redirect/HTTPS policy, SSH, and relay integration.</p>
1878− </article>
1879− <article class="phase">
1880− <div class="phase-status">Phase 4</div>
1881− <h4>Evidence pipeline</h4>
1882− <p>One-recording-per-window MediaRecorder worker, action timeline, artifact upload, thread viewer, retention, redaction, and soak gates.</p>
1883− </article>
1884− <article class="phase">
1885− <div class="phase-status">Phase 5</div>
1886− <h4>Collaboration + unattended hosts</h4>
1887− <p>Tab adoption/handoff, agent cursor, pause/revoke, optional environment-hosted Chromium for CI as a separate session type.</p>
1888− </article>
1889− </div>
1890− </section>
1891−
1892− <section id="decisions">
1893− <div class="section-head">
1894− <div class="section-no">14 / LOCKED</div>
1895− <div class="section-title">
1896− <h3>Pinned architecture decisions</h3>
1897− <p>These are the working defaults until implementation evidence requires an explicit ADR replacement.</p>
1898− </div>
1899− </div>
1900− <div class="decision-log">
1901− <article>
1902− <strong>ADR 001 + 006</strong>
1903− <h4>Route-durable renderer-hosted webview</h4>
1904− <p>Keep the real guest webview in a window-level host. Renderer reload, crash, and window close are explicit live-page loss, not transparent recovery.</p>
1905− </article>
1906− <article>
1907− <strong>ADR 002 + 007</strong>
1908− <h4>Playwright-injected semantic adapter</h4>
1909− <p>Use persistent CDP plus the version-pinned Playwright injected runtime behind T3 contracts. Locators re-resolve at action time.</p>
1910− </article>
1911− <article>
1912− <strong>ADR 003 + 008</strong>
1913− <h4>Provisionally bounded recording</h4>
1914− <p>Capture guest frames and encode H.264 MP4 in Chromium. Start with one active recording per window and require soak/concurrency gates.</p>
1915− </article>
1916− <article>
1917− <strong>ADR 004 + 009</strong>
1918− <h4>Attributed, stable preview tunnel</h4>
1919− <p>Keep raw TCP remotely, but require browser-attributed local ingress, stable authorities, bounded multiplexing, and redirect/HTTPS policy.</p>
1920− </article>
1921− <article>
1922− <strong>ADR 005</strong>
1923− <h4>Keep renderer transport relay initially</h4>
1924− <p>IPC overhead is negligible. Remove renderer lifecycle authority before adding another desktop-main network connection.</p>
1925− </article>
1926− <article>
1927− <strong>ADR 010</strong>
1928− <h4>Native human interruption</h4>
1929− <p>CDP keyboard dispatch did not emit `before-input-event` in the spike. Start with native user-input interruption and retain platform regression fixtures.</p>
1930− </article>
1931− </div>
1932− <div class="callout warning" style="margin-top: 22px">
1933− <div class="callout-mark">≠</div>
1934− <div>
1935− <strong>Feasibility proof is not production proof.</strong>
1936− <span>Open gates remain: browser-attributed tunnel ingress, OOPIF routing, long-duration and multi-tab recording, platform input behavior, and recovery fidelity after host loss. Explicit non-goals still include browser-process survival, live migration, a synchronized second browser, and image-stream browser UX.</span>
1937− </div>
1938− </div>
1939− </section>
1940−
1941− <footer>
1942− Source plans: `collaborative-browser-runtime-next-iteration.md`, `browser-phase-0/*`, and `browser-phase-0-5/*`.
1943− This HTML document is the consolidated visual architecture reference.
1944− </footer>
1945− </main>
1946− </div>
1947−
1948− <script>
1949− const navLinks = [...document.querySelectorAll(".nav a")];
1950− const sections = navLinks
1951− .map((link) => document.querySelector(link.getAttribute("href")))
1952− .filter(Boolean);
1953−
1954− const observer = new IntersectionObserver(
1955− (entries) => {
1956− const visible = entries
1957− .filter((entry) => entry.isIntersecting)
1958− .sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
1959− if (!visible) return;
1960− navLinks.forEach((link) => {
1961− link.classList.toggle("active", link.getAttribute("href") === `#${visible.target.id}`);
1962− });
1963− },
1964− { rootMargin: "-20% 0px -65% 0px", threshold: [0.05, 0.25, 0.5] },
1965− );
1966− sections.forEach((section) => observer.observe(section));
1967−
1968− const filterButtons = [...document.querySelectorAll(".filter-button")];
1969− const reachabilityRows = [...document.querySelectorAll("#reachability tbody tr")];
1970− filterButtons.forEach((button) => {
1971− button.addEventListener("click", () => {
1972− const filter = button.dataset.filter;
1973− filterButtons.forEach((candidate) => candidate.classList.toggle("active", candidate === button));
1974− reachabilityRows.forEach((row) => {
1975− row.dataset.filtered = String(filter !== "all" && row.dataset.kind !== filter);
1976− });
1977− });
1978− });
1979− </script>
1980− </body>
1981−</html>
.plans/collaborative-browser-runtime-next-iteration.md+0−1015
@@ -1,1015 +0,0 @@
1−# Collaborative Browser Runtime — Next Iteration
2−
3−## Purpose
4−
5−Turn the preview browser into a route-durable, programmable collaboration surface shared by users and agents.
6−
7−The browser is not an image preview and automation is not a secondary mode. The product should own a real browser session that:
8−
9−- remains alive independently of whether its panel is visible, while explicitly reporting host-renderer or window loss
10−- can be driven programmatically with reliable, semantic browser primitives
11−- can be inspected and manipulated by the user at any time
12−- records actions, video, console, network, and page state as reviewable evidence
13−- works when the development environment is remote
14−- can support multiple agent providers without provider-specific browser implementations
15−
16−The Codex in-app browser is a strong implementation reference for shared browser ownership, hidden tab retention, browser-client ergonomics, and human/agent handoff. It is not the target product boundary. T3 should preserve its provider-neutral and remote-capable architecture and improve on Codex where those requirements demand a different shape.
17−
18−This plan supersedes assumptions in:
19−
20−- `.plans/visible-preview-browser-automation-via-cdp-mcp.md`
21−- `.plans/shared-http-mcp-server-with-preview-automation-revised.md`
22−
23−Those plans remain useful implementation history, but their React-owned lifecycle, visibility-gated routing, fixed MCP command surface, and direct-only remote URL assumptions should not constrain this iteration.
24−
25−The Phase 0.5 review and follow-up spikes are authoritative amendments to Phase 0:
26−
27−- `.plans/browser-phase-0-5/findings.md`
28−- `.plans/browser-phase-0-5/006-renderer-failure-boundary.md`
29−- `.plans/browser-phase-0-5/007-playwright-injected-runtime.md`
30−- `.plans/browser-phase-0-5/008-recording-endurance.md`
31−- `.plans/browser-phase-0-5/009-loopback-threat-model.md`
32−- `.plans/browser-phase-0-5/010-human-input-interruption.md`
33−
34−## Product Principles
35−
36−### 1. The browser session is the product primitive
37−
38−The primary object is a durable logical `BrowserSession`, not a React panel, webview component, MCP request, or screenshot. The logical record can survive host loss; the live Chromium page state cannot.
39−
40−The panel is one view onto the session. Agent automation, recording, DevTools, snapshots, and user interaction all target the same session.
41−
42−### 2. Human and agent use the same page
43−
44−When an agent clicks, types, scrolls, navigates, or changes page state, the user must see that state when the browser is visible. When the user interacts, the agent's next observation must include the resulting state.
45−
46−Do not run a hidden Playwright browser beside an unrelated visible preview and attempt to synchronize them.
47−
48−### 3. Visibility is not lifecycle
49−
50−Closing or switching away from the browser panel must not destroy the browser session. Visibility, focus, control ownership, process lifetime, and recording state are separate concepts.
51−
52−The selected Electron `<webview>` is route-durable, not process-durable. A host-renderer reload, renderer crash, owning-window close, or application restart destroys the guest and must transition the logical tab to an explicit lost state.
53−
54−### 4. Prefer existing browser automation ecosystems
55−
56−Do not grow a large bespoke click/type/wait/selector framework when Playwright, CDP, and accessibility-based tooling already solve those problems.
57−
58−T3 should provide session discovery, authorization, routing, collaboration state, remote access, and artifact storage. Automation adapters should translate established browser APIs onto the T3-owned session.
59−
60−### 5. Remote environments are normal
61−
62−A browser attached to a remote environment must be able to open services bound to that environment's loopback interface without requiring the user to manually expose a LAN address.
63−
64−Remote access cannot be an afterthought or a warning-only UX.
65−
66−### 6. Evidence is first-class
67−
68−An agent must be able to start and stop a recording, capture screenshots, inspect console and network activity, and produce a machine-readable action trace. Evidence should be attached to the thread and usable by both the agent and user.
69−
70−Video recording is an artifact and debugging tool. It is not the mechanism used to render the interactive browser UI.
71−
72−## Current Architecture and Constraints
73−
74−The current browser implementation already has the most important property: the human-visible Electron `<webview>` and agent automation target the same guest `WebContents`.
75−
76−Current path:
77−
78−```text
79−agent
80− -> shared HTTP MCP tool
81− -> environment server PreviewAutomationBroker
82− -> WebSocket request to renderer owner
83− -> renderer PreviewAutomationOwner
84− -> Electron IPC
85− -> PreviewViewManager
86− -> guest webview WebContents through CDP
87−```
88−
89−Current strengths:
90−
91−- real shared browser rather than a screenshot-driven duplicate
92−- provider-neutral MCP entry point
93−- environment and thread scoped authorization
94−- server-side routing compatible with remote T3 clients
95−- schema-validated contracts and tested broker behavior
96−- normal browser UX including navigation, history, zoom, DevTools, and annotations
97−
98−Current constraints to remove:
99−
100−- browser lifetime is anchored to React component mounting
101−- only three hidden preview threads are retained
102−- sheet and route behavior can unmount the browser
103−- most automation requires the owner to report `visible`
104−- ownership is inferred from a focused renderer rather than a durable browser host
105−- one global persistent preview partition is shared across projects and threads
106−- CDP attaches and detaches around individual operations
107−- the custom automation surface has limited selector and locator semantics
108−- requests take an avoidable renderer-mediated hop before reaching desktop browser control
109−- remote loopback URLs are not resolved to the remote environment
110−- recording, trace, console, network, and download artifacts are not modeled as one system
111−- a browser session cannot be intentionally handed between user and agent control modes
112−
113−The implementation may remove or replace these patterns rather than preserving compatibility internally. User-facing session state and existing thread behavior should be migrated deliberately.
114−
115−## Target Architecture
116−
117−Split the system into five boundaries:
118−
119−```text
120−Agent adapter / skill / MCP
121− |
122− v
123−Browser Control Service <----> Browser Artifact Service
124− |
125− v
126−Browser Session Registry
127− |
128− v
129−Browser Host Adapter <----> Environment Preview Gateway
130− |
131− v
132−Real Chromium page shown in T3
133−```
134−
135−### Browser Session Registry
136−
137−The environment server owns authoritative logical session metadata:
138−
139−```ts
140−interface BrowserSession {
141− id: BrowserSessionId;
142− environmentId: EnvironmentId;
143− threadId: ThreadId;
144− hostId: BrowserHostId | null;
145− activeTabId: BrowserTabId | null;
146− lifecycle: "creating" | "ready" | "suspended" | "recovering" | "closed";
147− visibility: "visible" | "hidden" | "detached";
148− controller: "human" | "agent" | "none";
149− partitionId: BrowserPartitionId;
150− recordingId: BrowserRecordingId | null;
151− revision: number;
152− createdAt: string;
153− updatedAt: string;
154−}
155−```
156−
157−The registry owns:
158−
159−- stable session and tab identities
160−- environment and thread association
161−- lifecycle and recovery state
162−- host assignment
163−- visibility and control state
164−- capabilities reported by the host
165−- recording and artifact references
166−- event revisions for reconnect and stale-command rejection
167−
168−The registry does not own Chromium directly. A browser host does.
169−
170−### Browser Host
171−
172−A `BrowserHost` is a process capable of owning and controlling real browser pages.
173−
174−Initial host adapter:
175−
176−- `ElectronBrowserHost`
177− - uses route-durable app-level renderer-hosted `<webview>` elements
178− - is mounted independently of thread routes, panel sheets, and preview components
179− - registers each guest `WebContents` with the desktop main process
180− - keeps desktop main authoritative for CDP, navigation, recording, and security
181− - moves tabs between visible panel bounds, offscreen idle parking, and covered recording parking
182− - binds each host to an owning window and reports renderer reload, renderer crash, or window close as host loss
183−
184−Future host adapter:
185−
186−- `EnvironmentChromiumHost`
187− - runs Chromium beside a remote/headless environment
188− - enables unattended testing and CI
189− - is not a second browser for the same active session
190− - creates a separate explicitly hosted session whose UI attachment strategy is a later phase
191−
192−Do not pretend a live browser process can migrate losslessly between hosts. A session is bound to one host. Migration requires an explicit restart/restore operation and must report what state cannot be preserved.
193−
194−### Browser Control Service
195−
196−The control service provides one internal session-oriented API regardless of the calling agent or host implementation.
197−
198−It owns:
199−
200−- authorization and invocation scope
201−- host discovery and routing
202−- command serialization per tab
203−- cancellation, deadlines, and stale revision checks
204−- automation adapter sessions
205−- control lease and user-interruption behavior
206−- structured event subscriptions
207−- artifact creation triggers
208−
209−It should expose a transport-neutral internal protocol. MCP is one adapter, not the core API.
210−
211−### Browser Artifact Service
212−
213−The artifact service stores and indexes:
214−
215−- screenshots
216−- video recordings
217−- action timelines
218−- console logs
219−- uncaught exceptions
220−- network request summaries
221−- HAR exports where supported
222−- DOM/accessibility snapshots
223−- downloads
224−- optional Playwright traces
225−
226−Artifacts are scoped to environment, thread, browser session, and provider session. Large payloads must not travel inline through the normal WebSocket event stream.
227−
228−### Environment Preview Gateway
229−
230−The gateway makes environment-local web services reachable by the browser host.
231−
232−Example:
233−
234−```text
235−remote environment localhost:5173
236− -> environment TCP target
237− -> dedicated authenticated WebSocket tunnel
238− -> desktop loopback TCP listener
239− -> real webview navigation to desktop loopback
240−```
241−
242−The browser may load a desktop-local authority so origin paths, root-relative assets, HMR, and application WebSockets retain normal semantics. Phase 0.5 proved that a bare loopback port is not an authorization boundary, changing ports loses origin storage, and absolute redirects can escape the authority. Browser-attributed ingress, stable authority assignment, and explicit redirect/HTTPS policy are required before the gateway is production-ready. The UI separately displays the environment-relative requested target.
243−
244−The gateway must support:
245−
246−- raw TCP forwarding with backpressure and half-close behavior
247−- HTTP, streaming responses, and WebSocket upgrades without application-level rewriting
248−- source maps, static assets, redirects, cookies, and arbitrary root-relative paths
249−- deterministic target identity
250−- connection loss and reconnect reporting
251−- explicit port authorization
252−- one dedicated tunnel connection per accepted TCP stream initially
253−- a separate priority class from normal RPC and event traffic
254−
255−The browser automation layer should receive both requested and resolved URLs.
256−
257−## Browser Ownership and UI Model
258−
259−### Move lifetime out of React
260−
261−Replace `usePreviewBridge` mount/unmount ownership with explicit commands:
262−
263−- `browserSession.create`
264−- `browserSession.attachView`
265−- `browserSession.detachView`
266−- `browserSession.setVisibility`
267−- `browserSession.close`
268−
269−The app-level browser host remains mounted for the desktop window lifetime. React panels report display bounds and visibility intent; they do not mount, reparent, or destroy browser elements.
270−
271−### Hidden browser behavior
272−
273−When hidden:
274−
275−- keep the webview and `WebContents` alive
276−- park idle tabs offscreen at their preserved viewport size to reduce compositor work
277−- move recording tabs to a full-size covered parking surface because offscreen tabs stop producing screencast frames
278−- preserve timers and network behavior by default
279−- allow automation and recording according to session policy
280−- expose an explicit low-resource suspension operation rather than silently evicting after an arbitrary count
281−
282−Resource pressure policy should be observable and deterministic:
283−
284−- warn before suspension
285−- store restorable metadata
286−- never silently destroy a session being controlled or recorded
287−- allow configurable limits by memory pressure and session activity
288−
289−### Tabs
290−
291−Make tabs first-class:
292−
293−```ts
294−interface BrowserTab {
295− id: BrowserTabId;
296− sessionId: BrowserSessionId;
297− origin: "human" | "agent" | "system";
298− state: "opening" | "ready" | "closing" | "closed";
299− url: string;
300− title: string;
301− active: boolean;
302− controller: "human" | "agent" | "none";
303−}
304−```
305−
306−Support:
307−
308−- agent-created tabs
309−- user-created tabs
310−- selecting and listing tabs
311−- adopting an existing user tab
312−- handing a tab back to the user
313−- closing only tabs owned by the current workflow when requested
314−- restoring tab metadata after client reconnect
315−
316−### Control and interruption
317−
318−Use an explicit control lease instead of assuming the most recently focused owner is always correct.
319−
320−Policy:
321−
322−- humans may always interrupt an agent action
323−- active user pointer or keyboard input cancels or pauses conflicting agent input
324−- automation receives `BrowserControlInterruptedError`
325−- read-only observations may continue during human control
326−- the UI shows when an agent controls a tab
327−- agent cursor and current action are visible but do not block normal browser input
328−- a user can pause, resume, or revoke automation for the session
329−
330−Avoid a heavyweight approval dialog for every action. Use session-level trust, site policy, and high-risk operation gates.
331−
332−## Programmatic Automation
333−
334−### Primary adapter decision
335−
336−Do not continue expanding a fixed set of hand-written CDP operations as the main agent interface.
337−
338−Implement an adapter boundary:
339−
340−```ts
341−interface BrowserAutomationAdapter {
342− connect(session: BrowserAutomationSession): Effect.Effect<BrowserAutomationConnection, BrowserError>;
343−}
344−```
345−
346−Phase 0.5 selected a **Playwright-injected semantic adapter behind a T3-owned browser-client boundary**:
347−
348−- persistent Electron debugger connection owned by desktop main
349−- the version-pinned Playwright injected runtime for selector parsing, semantic locators, shadow DOM, actionability, and hit-target behavior
350−- one injected runtime and execution context per frame/target, including explicit OOPIF target routing
351−- Playwright-like high-level semantics without Playwright owning the browser
352−- locator descriptions re-resolved at action time
353−- optional snapshot-scoped element references as ephemeral accelerators only
354−
355−Direct Playwright `connectOverCDP` does not expose Electron guest targets of type `webview` as Playwright pages. A `WebContentsView` does appear as a page, but it was rejected for this iteration because hidden/detached capture failed recording requirements and its native stacking model complicates collaboration UI.
356−
357−Playwright remains a behavior reference and test oracle. Its injected runtime is an internal, version-coupled dependency protected by compatibility fixtures and hidden behind T3 contracts.
358−
359−### Agent-facing API
360−
361−Offer multiple programmatic surfaces over the same control service:
362−
363−- MCP toolkit for providers that support MCP
364−- skill instructions and helper client for Codex-like runtimes
365−- CLI for debugging and providers that can execute shell commands
366−- internal TypeScript client for T3 features and tests
367−- optional raw CDP diagnostics only in explicitly trusted developer mode
368−
369−The default high-level surface should support:
370−
371−- create/open/list/close sessions and tabs
372−- navigate, reload, history, viewport, and zoom
373−- semantic locate, inspect, click, hover, drag, type, select, upload, and keyboard input
374−- frames, dialogs, popups, downloads, and new tabs
375−- wait for semantic conditions, requests, navigation, and page stability
376−- JavaScript evaluation with bounded results
377−- console and exception subscriptions
378−- request/response observation
379−- screenshots and recordings
380−- action groups and assertions
381−
382−Keep raw CDP as a privileged developer escape hatch, not the normal workflow.
383−
384−### Observation model
385−
386−Agents should not need a full screenshot after every action.
387−
388−Provide composable observations:
389−
390−- accessibility snapshot with stable element references
391−- semantic locator results
392−- bounded visible text
393−- viewport screenshot on demand
394−- current URL/title/loading state
395−- recent console and exception entries
396−- recent network failures
397−- DOM change summary since a known revision
398−- current control and recording state
399−
400−Locator descriptions are the primary durable handle and must re-resolve immediately before action. Snapshot references expire on navigation, document replacement, frame replacement, HMR, or node detachment; when the originating locator is available the adapter may retry through that locator.
401−
402−### Command execution
403−
404−Commands to a tab are serialized unless explicitly marked read-only and concurrent-safe.
405−
406−Each command includes:
407−
408−- command id
409−- browser session and tab id
410−- expected session/document revision where relevant
411−- provider and thread invocation scope
412−- deadline and cancellation token
413−- control requirement
414−- artifact/evidence policy
415−
416−Each result includes:
417−
418−- before and after revisions
419−- timing
420−- resulting URL and title
421−- structured error when applicable
422−- references to generated evidence
423−
424−## Recording, Tracing, and Debug Evidence
425−
426−### Recording requirements
427−
428−Agents and users can:
429−
430−- start recording the active tab or browser session
431−- stop recording and receive an artifact reference
432−- mark recording chapters around action groups
433−- capture a short rolling buffer after a failure
434−- attach recordings to thread messages or task completion evidence
435−- download or open recordings from the UI
436−
437−The browser remains a normal interactive webview while recording.
438−
439−### Selected capture pipeline
440−
441−Phase 0 selected:
442−
443−```text
444−guest webview Page.startScreencast
445− -> bounded frame sampler
446− -> isolated recording canvas
447− -> Chromium MediaRecorder
448− -> H.264 MP4 artifact
449−```
450−
451−Initial defaults:
452−
453−- `video/mp4;codecs=avc1.42E01E`
454−- 12 frames per second
455−- current browser viewport with policy bounds
456−- dropped intermediate frames under encoder pressure instead of an unbounded queue
457−
458−When a recorded tab is hidden, the durable browser host places it on a covered full-size parking surface. Moving it offscreen stops continuous screencast frames. No external ffmpeg executable is required.
459−
460−CDP frame capture is internal to evidence generation. It does not replace the real browser as the interactive UX.
461−
462−### Action timeline
463−
464−Every recording can be paired with structured events:
465−
466−```ts
467−interface BrowserActionEvent {
468− id: BrowserActionEventId;
469− sessionId: BrowserSessionId;
470− tabId: BrowserTabId;
471− actor: "human" | "agent" | "system";
472− action: string;
473− startedAt: string;
474− completedAt?: string;
475− status: "running" | "succeeded" | "failed" | "interrupted";
476− inputSummary: unknown;
477− pageRevisionBefore: number;
478− pageRevisionAfter?: number;
479− screenshotBeforeId?: BrowserArtifactId;
480− screenshotAfterId?: BrowserArtifactId;
481− recordingOffsetMs?: number;
482− error?: BrowserErrorSummary;
483−}
484−```
485−
486−This timeline is more useful to agents than video alone and allows the UI to jump from an action to the relevant recording timestamp.
487−
488−### Console and network capture
489−
490−Maintain bounded per-tab ring buffers for:
491−
492−- console entries
493−- uncaught exceptions
494−- failed requests
495−- response status summaries
496−- WebSocket connection failures
497−- navigation timing
498−
499−Allow explicit HAR/trace recording for deeper debugging. Redact authorization headers, cookies, and configured sensitive fields before persistence.
500−
501−## Remote-First Behavior
502−
503−### Required remote scenarios
504−
505−The design must support:
506−
507−1. Desktop UI and browser on a MacBook, environment and agent on a Mac mini.
508−2. Desktop UI and browser local, environment inside Docker, SSH, WSL, or a cloud VM.
509−3. Multiple desktop clients connected to one environment without ambiguous browser ownership.
510−4. Agent automation continuing when the browser panel is hidden.
511−5. Reconnecting a desktop client to an existing logical browser session after network interruption.
512−6. Unattended environment-side browser sessions for CI or automated verification in a later phase.
513−
514−### Preview target abstraction
515−
516−Agents should open an environment-relative target rather than constructing client-reachable URLs:
517−
518−```ts
519−type BrowserNavigationTarget =
520− | { kind: "url"; url: string }
521− | { kind: "environment-port"; port: number; protocol?: "http" | "https"; path?: string }
522− | { kind: "run-action"; actionId: string; path?: string };
523−```
524−
525−The environment resolves the target into:
526−
527−```ts
528−interface ResolvedBrowserTarget {
529− requested: BrowserNavigationTarget;
530− displayUrl: string;
531− browserUrl: string;
532− resolutionKind: "direct" | "environment-gateway" | "tunnel";
533− environmentId: EnvironmentId;
534− expiresAt?: string;
535−}
536−```
537−
538−This removes remote hostname guessing from prompts and agent logic.
539−
540−### Gateway security
541−
542−- do not treat loopback binding or port secrecy as authorization
543−- require browser-attributed ingress so unrelated pages and local processes cannot ride an authenticated environment tunnel
544−- keep the desktop authority stable for the granted environment/project/target tuple to preserve cookies, storage, service workers, and origin behavior
545−- scope gateway grants to environment, browser session, target host, and port
546−- use short-lived credentials inaccessible to page JavaScript where possible
547−- block cloud metadata and forbidden address ranges by policy
548−- require explicit configuration for non-loopback arbitrary upstream hosts
549−- validate redirects against the grant policy
550−- audit port openings and navigation resolutions
551−- do not place long-lived bearer tokens in visible URLs
552−- use a dedicated tunnel connection rather than the normal control/event WebSocket
553−- multiplex TCP streams over a bounded number of authenticated tunnel connections before T3 Connect rollout unless relay load tests prove per-stream connections safe
554−- define explicit compatibility behavior for absolute redirects, configured public origins, OAuth callbacks, and HTTPS upstreams
555−
556−### Reconnect behavior
557−
558−If the desktop connection drops:
559−
560−- mark the host unavailable without deleting the logical session
561−- fail or pause pending control commands deterministically
562−- retain artifact metadata and last-known tab state
563−- rebind when the same browser host reconnects and reports its session inventory
564−- report when the underlying `WebContents` was lost and recovery requires reload
565−
566−Do not claim browser process survival when the owning desktop application exited.
567−
568−## Security Model
569−
570−### Invocation scope
571−
572−Keep the existing session-scoped MCP authentication model, generalized to browser capabilities:
573−
574−- `browser:observe`
575−- `browser:interact`
576−- `browser:navigate`
577−- `browser:evaluate`
578−- `browser:record`
579−- `browser:network-inspect`
580−- `browser:raw-cdp`
581−
582−The agent cannot override environment, thread, or provider session identity in normal tool arguments.
583−
584−### Site policy
585−
586−Add environment/user policy for:
587−
588−- allowed origins and port ranges
589−- external internet navigation
590−- authentication pages
591−- file uploads and downloads
592−- clipboard access
593−- camera, microphone, geolocation, and notifications
594−- JavaScript evaluation
595−- raw CDP
596−
597−Local development origins may be trusted by default under a configurable policy. Sensitive external origins should require an explicit trust decision.
598−
599−### Browser partitions
600−
601−Replace the global `persist:t3code-preview` partition with deliberate isolation.
602−
603−Default proposal:
604−
605−- one persistent partition per environment and project
606−- optional isolated partition per browser session
607−- explicit user action to share authentication state between sessions
608−- clear partition lifecycle and storage deletion UX
609−
610−The final partition key must not contain raw secrets or unsafe filesystem characters.
611−
612−## Contract and Module Direction
613−
614−Likely contracts:
615−
616−```text
617−packages/contracts/src/browserSession.ts
618−packages/contracts/src/browserControl.ts
619−packages/contracts/src/browserArtifacts.ts
620−packages/contracts/src/browserGateway.ts
621−```
622−
623−Keep contracts schema-only.
624−
625−Likely runtime modules:
626−
627−```text
628−apps/server/src/browser/
629− Services/
630− BrowserSessionRegistry.ts
631− BrowserControlService.ts
632− BrowserArtifactService.ts
633− BrowserTargetResolver.ts
634− Layers/
635− Rpc/
636− Mcp/
637−
638−apps/desktop/src/browser/
639− ElectronBrowserHost.ts
640− ElectronBrowserTab.ts
641− CdpConnection.ts
642− RecordingController.ts
643− ArtifactUploader.ts
644−
645−apps/web/src/browser/
646− BrowserPanel.tsx
647− BrowserSessionProvider.tsx
648− BrowserTabs.tsx
649− BrowserControlIndicator.tsx
650− BrowserArtifactViewer.tsx
651−
652−packages/browser-client/
653− session client
654− locator/action adapter
655− CLI or Node helper
656−```
657−
658−Do not preserve `PreviewAutomationOwner` as the permanent routing boundary. Replace it with browser-host registration and session attachment protocols.
659−
660−## Migration Strategy
661−
662−### Phase 0: Technical Spikes and Decision Records
663−
664−**Status: completed.**
665−
666−Authoritative results and executable spikes:
667−
668−- `.plans/browser-phase-0/findings.md`
669−- `.plans/browser-phase-0/001-browser-host.md`
670−- `.plans/browser-phase-0/002-automation-adapter.md`
671−- `.plans/browser-phase-0/003-recording.md`
672−- `.plans/browser-phase-0/004-remote-preview-tunnel.md`
673−- `.plans/browser-phase-0/005-desktop-routing.md`
674−- `.plans/browser-phase-0/spikes/`
675−
676−Goals:
677−
678−- eliminate high-risk unknowns before reshaping contracts
679−- produce small executable prototypes rather than design-only conclusions
680−
681−Spikes:
682−
683−1. Attach Playwright or a Playwright-compatible locator runtime to the existing Electron guest webview.
684−2. Keep a guest `WebContents` alive and controllable while its panel is hidden and painting is reduced.
685−3. Record the guest content to a seekable video without replacing the interactive browser UX.
686−4. Proxy a remote environment's loopback HTTP and WebSocket dev server into the local desktop webview.
687−5. Measure CDP command latency through direct desktop routing versus the current renderer-mediated route.
688−
689−Deliverables:
690−
691−- decision record for automation adapter
692−- decision record for recording backend
693−- decision record for remote preview gateway transport
694−- measured CPU, memory, and latency results
695−- explicit unsupported cases
696−
697−Exit criteria:
698−
699−- completed: semantic role/name click and input worked against the real webview through CDP
700−- completed: Chromium MediaRecorder produced a seekable H.264 MP4 from covered webview capture
701−- completed: raw TCP tunneling carried HTTP and WebSocket traffic through a desktop loopback listener
702−
703−### Phase 0.5: Production-Risk Closure
704−
705−**Status: completed for the targeted desktop probes; browser-attributed tunnel ingress remains an implementation prerequisite.**
706−
707−Authoritative results:
708−
709−- `.plans/browser-phase-0-5/findings.md`
710−- `.plans/browser-phase-0-5/006-renderer-failure-boundary.md`
711−- `.plans/browser-phase-0-5/007-playwright-injected-runtime.md`
712−- `.plans/browser-phase-0-5/008-recording-endurance.md`
713−- `.plans/browser-phase-0-5/009-loopback-threat-model.md`
714−- `.plans/browser-phase-0-5/010-human-input-interruption.md`
715−
716−Confirmed:
717−
718−- host-renderer reload destroys the `<webview>` guest and live state, while a main-owned `WebContentsView` survives that specific reload
719−- Playwright's installed injected runtime can be evaluated in the real guest over CDP, resolves role/name locators through shadow DOM, and re-resolves after element replacement
720−- covered recording produced a seekable 1600×1200 H.264 MP4 for ten seconds at approximately 11.2 fps
721−- an unrelated browser page can cause a request to a loopback preview listener; loopback binding and port secrecy are not authorization
722−- changing the desktop port changes the browser origin and loses origin storage
723−- CDP keyboard dispatch did not emit Electron `before-input-event` in the tested guest
724−
725−Required carry-forward:
726−
727−- route-durable terminology and explicit host-loss UX
728−- version-pinned Playwright injected runtime with OOPIF routing and compatibility fixtures
729−- one-recording-per-window default until soak and concurrency budgets pass
730−- a browser-attributed desktop ingress design before remote preview implementation
731−- annotation/source attribution, port discovery, and guest isolation hardening in Phase 1
732−- console and network ring buffers in Phase 2
733−
734−### Phase 1: Durable Browser Session Core
735−
736−Goals:
737−
738−- separate browser lifetime from React
739−- introduce stable sessions, tabs, and host registration
740−
741−Tasks:
742−
743−1. Add browser session, tab, host, lifecycle, and capability schemas.
744−2. Add server `BrowserSessionRegistry` with revisioned events.
745−3. Add durable app-level `ElectronBrowserHost` plus desktop-main registration and heartbeat.
746−4. Move browser create/close ownership out of `usePreviewBridge` and panel components.
747−5. Add panel-bound reporting and visible/offscreen/covered parking transitions.
748−6. Remove the visibility requirement from control routing.
749−7. Replace hidden-thread count eviction with explicit resource policy.
750−8. Add per-environment/project partitioning.
751−9. Migrate existing preview open/close state to browser sessions.
752−10. Preserve the annotation/element-pick/source-attribution pipeline as a Browser Control Service capability.
753−11. Preserve discovered-server and run-action preview UX through environment-relative target contracts.
754−12. Harden guest isolation and document any narrowly required main-world injection.
755−13. Add owning-window identity, host-loss transitions, and explicit session-loss UX.
756−
757−Acceptance criteria:
758−
759−- hiding the panel does not destroy the page
760−- automation works while hidden
761−- reopening shows the exact current page state
762−- route changes do not accidentally close the session
763−- desktop reconnect reports and reconciles existing browser inventory
764−- browser closure is always an explicit lifecycle event
765−
766−### Phase 2: Control Service and Automation Adapter
767−
768−Goals:
769−
770−- replace fixed bespoke operations with a reusable automation connection
771−- preserve provider-neutral invocation
772−
773−Tasks:
774−
775−1. Add `BrowserControlService` with per-tab command queues and cancellation.
776−2. Add persistent CDP connection ownership in the desktop host.
777−3. Implement the selected Playwright or browser-client-style adapter.
778−4. Integrate the version-pinned Playwright injected runtime with locator re-resolution and compatibility fixtures.
779−5. Add target auto-attach, OOPIF routing, frames, popups, dialogs, downloads, drag, hover, and file upload support.
780−6. Add structured observation and incremental page revisions.
781−7. Add bounded console, exception, and network ring buffers.
782−8. Add a `packages/browser-client` helper and CLI.
783−9. Refactor MCP tools into a thin adapter over the control service.
784−10. Deprecate and remove redundant `PreviewAutomationOwner` operation dispatch.
785−
786−Acceptance criteria:
787−
788−- a provider can complete a multi-page workflow using semantic locators
789−- the same workflow can be driven through MCP and the browser client
790−- user interaction can interrupt an agent safely
791−- command failures identify stale page state, lost control, timeout, or host loss distinctly
792−- DevTools and automation coexist or fail with an explicit supported policy
793−
794−### Phase 3: Remote Preview Gateway
795−
796−Goals:
797−
798−- make remote loopback services first-class browser targets
799−
800−Tasks:
801−
802−1. Add environment-port and run-action target contracts.
803−2. Add target resolution, stable desktop authorities, and short-lived gateway grants.
804−3. Implement browser-attributed desktop ingress; reject a bare unauthenticated loopback listener.
805−4. Implement bounded multiplexed raw TCP streams over dedicated authenticated tunnel connections.
806−5. Add origin, cookie, service-worker, absolute-redirect, OAuth, HTTPS, and source-map compatibility tests.
807−6. Integrate run actions so agents can request the declared preview target directly.
808−7. Display requested and resolved target information in browser diagnostics.
809−8. Add reconnect and expired-grant recovery.
810−
811−Acceptance criteria:
812−
813−- a desktop browser can open `localhost` services from a remote environment without LAN exposure
814−- Vite HMR works through the gateway
815−- application WebSockets work through the gateway
816−- the agent never needs to guess the remote machine's IP address
817−- gateway permissions cannot be reused for unrelated ports or hosts
818−
819−### Phase 4: Recording and Evidence
820−
821−Goals:
822−
823−- make browser work inspectable, replayable, and attachable to tasks
824−
825−Tasks:
826−
827−1. Add browser artifact contracts and storage abstraction.
828−2. Add start/stop recording controls to agent and UI surfaces.
829−3. Add structured action timeline generation.
830−4. Add screenshot-before/after policies for action groups and failures.
831−5. Add artifact upload outside the normal WS payload path.
832−6. Add thread UI for video, screenshots, traces, and logs.
833−7. Add retention, cleanup, and size limits.
834−8. Add secret redaction for network and console artifacts.
835−9. Enforce the initial one-active-recording-per-window policy and expose encoder/drop health.
836−
837−Acceptance criteria:
838−
839−- an agent can record a test flow and return an artifact reference
840−- the user can watch the recording and jump to failed actions
841−- recordings work while the panel is hidden
842−- failed requests and console exceptions are included in the evidence bundle
843−- large artifacts do not block normal thread event delivery
844−
845−### Phase 5: Multi-Tab Collaboration and Unattended Hosts
846−
847−Goals:
848−
849−- mature collaboration UX
850−- add optional environment-side automation without compromising shared-session semantics
851−
852−Tasks:
853−
854−1. Add human/agent tab origin and ownership UX.
855−2. Add tab adoption, handoff, finalization, and workflow cleanup policies.
856−3. Add visible agent cursor and current-action overlays.
857−4. Add session pause/revoke controls.
858−5. Implement `EnvironmentChromiumHost` for CI and unattended verification.
859−6. Define how a remote-hosted session is viewed or attached without claiming it is the local Electron webview.
860−7. Add capability-based host selection.
861−8. Add test-report integrations using browser artifacts.
862−
863−Acceptance criteria:
864−
865−- agent-created tabs are identifiable and cleanly handed to the user
866−- user-created tabs can be intentionally adopted
867−- unattended browser workflows use a separately identified environment-hosted session
868−- no workflow silently operates a second browser while presenting another as the controlled browser
869−
870−## Testing Strategy
871−
872−### Contract tests
873−
874−- session, tab, host, control, artifact, and target schemas
875−- version and revision validation
876−- capability authorization
877−- invalid lifecycle transitions
878−- stale command rejection
879−
880−### Server tests
881−
882−- session registry lifecycle and event replay
883−- host assignment and reconnect
884−- per-tab command serialization
885−- cancellation and timeout behavior
886−- control lease interruption
887−- provider/thread isolation
888−- artifact metadata and cleanup
889−- gateway grant scoping
890−
891−### Desktop tests
892−
893−- webview registration cannot target unrelated `WebContents`
894−- hidden session remains navigable and controllable
895−- persistent CDP connection reconnects after navigation or target loss
896−- multiple tabs remain isolated
897−- recording starts, survives navigation, and finalizes
898−- resource policy never evicts active or recording sessions
899−
900−### Gateway integration tests
901−
902−- remote HTTP app
903−- Vite HMR WebSocket
904−- application WebSocket
905−- redirects
906−- cookies
907−- source maps
908−- upstream failure and reconnect
909−- forbidden host/port rejection
910−
911−### End-to-end tests
912−
913−1. Start a remote test environment and dev server bound only to loopback.
914−2. Open it through an environment-port target in the desktop browser.
915−3. Hide the panel.
916−4. Drive a semantic browser workflow through MCP or browser-client.
917−5. Record the workflow.
918−6. Reopen the panel and verify the same final page state.
919−7. Inspect the recording, action timeline, console, and network artifacts.
920−
921−Additional scenarios:
922−
923−- user interrupts agent typing
924−- desktop disconnects during navigation
925−- HMR reload occurs during locator interaction
926−- two desktop clients connect to the same environment
927−- two provider sessions cannot cross-control threads
928−- one provider loses authorization while another continues
929−
930−Do not mock core lifecycle, routing, or command-serialization logic. External Chromium, media encoding, filesystem artifact storage, and network transport boundaries may use test layers where necessary.
931−
932−## Performance and Reliability Budgets
933−
934−Measure and enforce budgets rather than treating performance as qualitative.
935−
936−Initial targets to validate during spikes:
937−
938−- control routing overhead excluding page work: p95 under 50 ms locally, under 150 ms over a typical remote T3 connection
939−- hidden idle session CPU: near-zero absent page activity
940−- hidden session memory: observable with configurable pressure policy
941−- screenshot capture: under 500 ms for a normal 1280px viewport
942−- semantic snapshot: under 300 ms for typical application pages
943−- recording overhead: under 15% CPU on supported desktop hardware at the selected resolution/frame rate
944−- browser host reconnect detection: under 5 seconds
945−- no unbounded console, network, screenshot, or action buffers
946−
947−Budgets may be adjusted after measurement, but the final values must be documented and covered by benchmarks or diagnostics.
948−
949−## Observability
950−
951−Add structured diagnostics for:
952−
953−- browser session and host lifecycle transitions
954−- tab creation, closure, and target changes
955−- command queue length and duration
956−- CDP connection state
957−- control lease changes and interruptions
958−- gateway resolution and proxy failures
959−- recording start, encoder health, and finalization
960−- artifact sizes and upload duration
961−- dropped or redacted evidence
962−
963−Provide a user-accessible browser diagnostics view or export so remote failures do not require reading opaque server logs.
964−
965−## Explicit Non-Goals
966−
967−- synchronizing two independent browsers and presenting them as one session
968−- replacing the interactive browser UI with JPEG or video streaming
969−- building a full custom Playwright clone
970−- preserving current internal APIs solely for compatibility
971−- guaranteeing browser process survival after the owning host exits
972−- transparent live migration of an active browser process between hosts
973−- unrestricted proxying to arbitrary private-network targets
974−- storing unlimited recordings or raw network bodies
975−- making Codex's browser architecture the permanent product boundary
976−
977−## Pinned Phase 0 + 0.5 Decisions
978−
979−1. Keep a route-durable renderer-hosted Electron `<webview>`; do not migrate to `WebContentsView` in this iteration. Explicitly model host-renderer reload, crash, and window close as live-page loss.
980−2. Use a T3-owned semantic CDP adapter backed initially by the version-pinned Playwright injected runtime. Do not depend on direct Playwright attachment and do not build locator/actionability semantics from scratch.
981−3. Record with CDP screencast plus Chromium MediaRecorder H.264 MP4 encoding. Default to one active recording per window until soak and concurrency budgets pass.
982−4. Keep raw TCP as the environment transport, but do not ship a bare loopback listener as the desktop authorization boundary. Require stable authorities, browser-attributed ingress, bounded multiplexing, and explicit redirect/HTTPS policy.
983−5. Keep the renderer as the environment transport relay initially because measured IPC overhead is negligible; remove its lifecycle authority.
984−6. Default browser partition scope is one persistent partition per environment and project, with optional isolated sessions.
985−7. Capability and site policy gates remain as defined in the Security Model section; guest isolation and the annotation/source-attribution pipeline are explicit Phase 1 requirements.
986−8. Artifact bytes use a dedicated storage/upload path; deployment-specific backends implement the shared artifact interface.
987−9. Locator descriptions re-resolve at action time. Snapshot node references are optional and ephemeral.
988−10. Initial controller modes are `human`, `agent`, and `none`; undefined shared control is removed.
989−
990−## Recommended Implementation Order
991−
992−1. Introduce session, tab, window, and host contracts from the completed Phase 0 and 0.5 ADRs.
993−2. Add the durable app-level webview host and desktop-main registry.
994−3. Move browser lifetime out of preview panels and `usePreviewBridge`.
995−4. Remove visibility-gated automation.
996−5. Add persistent CDP connections and command queues.
997−6. Integrate the Playwright injected runtime, per-frame routing, and semantic automation adapter.
998−7. Add console and network diagnostics.
999−8. Complete browser-attributed ingress and then add the multiplexed raw TCP environment preview tunnel.
1000−9. Add the selected MediaRecorder evidence pipeline under the one-recording policy.
1001−10. Add collaboration, tab handoff, and cursor UX.
1002−11. Add optional environment-hosted Chromium sessions.
1003−12. Remove superseded preview automation code while preserving annotations, source attribution, and discovered-server UX.
1004−
1005−## Completion Gates
1006−
1007−Each implementation phase must include tests for backend changes and must pass:
1008−
1009−- `vp check`
1010−- `vp run typecheck`
1011−- `vp test`
1012−
1013−Run `vp run lint:mobile` only when native mobile code changes.
1014−
1015−The next iteration is not complete merely when an agent can click the page. It is complete when the same durable browser session can be controlled by an agent, inspected and interrupted by a user, reached from remote environments, and reviewed through reliable evidence artifacts.
.plans/shared-http-mcp-server-with-preview-automation-revised.md+0−520
@@ -1,520 +0,0 @@
1−# Shared HTTP MCP Server with Preview Automation
2−
3−## Summary
4−
5−Embed one reusable HTTP MCP server in each T3 environment server. Every agent session connects to that shared MCP endpoint using a session-scoped bearer token.
6−
7−Preview browser automation is the first MCP toolkit, not the purpose or boundary of the MCP server. Future T3 toolkits register with the same server.
8−
9−Architecture:
10−
11−`agent session` -> `shared T3 HTTP MCP server` -> `tool dispatcher` -> `preview broker` -> `focused desktop client` -> `Electron webview via CDP`
12−
13−No per-thread MCP process, stdio transport, headless browser, or automatic remote port forwarding.
14−
15−## Process Model
16−
17−Each `apps/server` process owns exactly one MCP server instance.
18−
19−- MCP transport: HTTP.
20−- MCP endpoint: `/mcp`.
21−- MCP lifetime: environment server lifetime.
22−- Agent sessions create MCP protocol sessions/connections, not OS processes.
23−- Toolkit registration happens once during server startup.
24−- Provider session termination revokes only its scoped credential.
25−
26−Implement the endpoint with:
27−
28−```ts
29−McpServer.layerHttp({
30− name: "T3 Code",
31− version,
32− path: "/mcp",
33−});
34−```
35−
36−Use the API from:
37−
38−`effect/unstable/ai/McpServer`
39−
40−Reference source:
41−
42−`.repos/effect-smol/packages/effect/src/unstable/ai/McpServer.ts`
43−
44−Do not implement MCP framing, initialization, session management, or JSON-RPC manually.
45−
46−## Invocation Identity
47−
48−MCP `tools/call` does not include T3 thread identity. Bind identity to the MCP connection through authentication.
49−
50−When starting or resuming a provider session, issue an opaque bearer token associated internally with:
51−
52−```ts
53−interface McpInvocationScope {
54− environmentId: EnvironmentId;
55− threadId: ThreadId;
56− providerSessionId: string;
57− providerInstanceId: ProviderInstanceId;
58− allowedCapabilities: ReadonlySet<string>;
59− issuedAt: string;
60− expiresAt: string;
61−}
62−```
63−
64−Provider MCP configuration:
65−
66−```ts
67−{
68− type: "http",
69− name: "t3",
70− url: `${environmentHttpBaseUrl}/mcp`,
71− headers: [
72− {
73− name: "Authorization",
74− value: `Bearer ${token}`,
75− },
76− ],
77−}
78−```
79−
80−Requirements:
81−
82−- Agents never receive or pass `threadId` as a tool argument.
83−- Tool handlers obtain `McpInvocationScope` from request authentication middleware.
84−- Tokens are cryptographically random opaque values.
85−- Store only a hash of each token server-side.
86−- Revoke tokens when the provider session stops.
87−- Expire tokens after inactivity and at a fixed maximum lifetime.
88−- Server restart invalidates all tokens.
89−- Resuming a provider session issues a fresh token.
90−
91−## General MCP Architecture
92−
93−Add server modules such as:
94−
95−```text
96−apps/server/src/mcp/
97− Services/
98− McpSessionRegistry.ts
99− McpInvocationContext.ts
100− Layers/
101− McpSessionRegistry.ts
102− McpHttpServer.ts
103− toolkits/
104− preview/
105− tools.ts
106− handlers.ts
107− layer.ts
108−```
109−
110−### Toolkit Registration
111−
112−Define capabilities with Effect AI:
113−
114−```ts
115−import { McpServer, Tool, Toolkit } from "effect/unstable/ai";
116−```
117−
118−Each capability family owns:
119−
120−- `Tool.make` definitions
121−- a `Toolkit.make` collection
122−- handler services/layers
123−- an MCP registration layer
124−
125−Server startup merges all registration layers:
126−
127−```ts
128−const T3McpToolkits = Layer.mergeAll(
129− PreviewToolkitRegistration,
130− // Future toolkit registrations
131−);
132−```
133−
134−Future filesystem, terminal, source-control, or environment tools must not require changes to MCP transport or authentication.
135−
136−### Naming
137−
138−Use stable capability-prefixed names:
139−
140−- `preview_status`
141−- `preview_open`
142−- `preview_navigate`
143−- `preview_snapshot`
144−- `preview_click`
145−- `preview_type`
146−- `preview_press`
147−- `preview_scroll`
148−- `preview_evaluate`
149−- `preview_wait_for`
150−
151−## MCP Authentication
152−
153−Integrate bearer authentication into the HTTP MCP route before MCP request handling.
154−
155−Authentication flow:
156−
157−1. Read `Authorization: Bearer <token>`.
158−2. Hash token and resolve it through `McpSessionRegistry`.
159−3. Verify expiration and provider-session liveness.
160−4. Provide `McpInvocationContext` to MCP toolkit handlers.
161−5. Reject invalid credentials without invoking MCP tools.
162−6. Update token activity timestamp after authenticated requests.
163−
164−Capability authorization occurs inside handlers or common middleware:
165−
166−```ts
167−yield* McpInvocationContext.requireCapability("preview");
168−```
169−
170−## Preview Automation Contracts
171−
172−Add `packages/contracts/src/previewAutomation.ts`.
173−
174−Define schemas for:
175−
176−- preview status
177−- opening/showing preview
178−- navigation
179−- page snapshot
180−- selector or coordinate click
181−- text entry
182−- key press
183−- scrolling
184−- JavaScript evaluation
185−- waiting for selector, text, or URL
186−
187−Define tagged errors:
188−
189−- `PreviewAutomationUnavailableError`
190−- `PreviewAutomationNoFocusedOwnerError`
191−- `PreviewAutomationUnsupportedClientError`
192−- `PreviewAutomationTabNotFoundError`
193−- `PreviewAutomationTimeoutError`
194−- `PreviewAutomationExecutionError`
195−- `PreviewAutomationInvalidSelectorError`
196−- `PreviewAutomationResultTooLargeError`
197−
198−## Preview Toolkit
199−
200−Implement preview tools as an independent Effect AI toolkit.
201−
202−Apply annotations:
203−
204−- `preview_status` and `preview_snapshot`: read-only
205−- `preview_status`: idempotent
206−- navigation and page interaction tools: open-world
207−- browser operations: non-destructive
208−- all tools: human-readable title and precise description
209−
210−### `preview_open`
211−
212−Input:
213−
214−```ts
215−{
216− url?: string;
217− show?: boolean;
218− reuseExistingTab?: boolean;
219−}
220−```
221−
222−Defaults:
223−
224−- `show: true`
225−- `reuseExistingTab: true`
226−
227−Behavior:
228−
229−- Show the preview panel for the scoped thread.
230−- Reuse its active preview tab when available.
231−- Create and mount a tab otherwise.
232−- Navigate when `url` is supplied.
233−- Wait until the webview has registered before returning.
234−
235−### `preview_snapshot`
236−
237−Return:
238−
239−- current URL, title, loading state
240−- bounded visible text
241−- up to 200 interactive elements
242−- accessibility tree
243−- PNG screenshot scaled to a maximum width of 1280 pixels
244−
245−Expose the screenshot as MCP image content and metadata as structured content.
246−
247−### Browser Controls
248−
249−- `preview_click`: selector or viewport coordinates
250−- `preview_type`: optionally focus selector and clear existing value
251−- `preview_press`: common keys and modifiers
252−- `preview_scroll`: viewport or selector target
253−- `preview_evaluate`: execute bounded JavaScript
254−- `preview_wait_for`: selector, visible text, or URL substring
255−- `preview_navigate`: navigate and wait for selected readiness condition
256−
257−Default operation timeout: 15 seconds.
258−
259−Maximum serialized evaluation result: 64 KB.
260−
261−Maximum visible text: 20 KB.
262−
263−## Preview Broker
264−
265−Add `PreviewAutomationBroker` to `apps/server`.
266−
267−Responsibilities:
268−
269−- Track automation-capable desktop clients.
270−- Track preview ownership by environment and thread.
271−- Route operations to the correct desktop client.
272−- Correlate requests and responses.
273−- Enforce timeouts.
274−- Fail pending calls when clients disconnect.
275−
276−Owner state:
277−
278−```ts
279−interface PreviewAutomationOwner {
280− clientId: string;
281− environmentId: EnvironmentId;
282− threadId: ThreadId;
283− tabId: PreviewTabId | null;
284− visible: boolean;
285− supportsAutomation: boolean;
286− focusedAt: string;
287−}
288−```
289−
290−Routing policy:
291−
292−- Use the most recently focused Electron window displaying the scoped thread.
293−- Never accept environment or thread overrides from tool arguments.
294−- Return `PreviewAutomationNoFocusedOwnerError` when no valid owner exists.
295−- Do not switch the UI to a different thread automatically.
296−
297−## Server-to-Desktop Protocol
298−
299−Add WS RPCs:
300−
301−- `previewAutomation.connect`
302−- `previewAutomation.respond`
303−- `previewAutomation.reportOwner`
304−- `previewAutomation.clearOwner`
305−
306−`connect` is a long-lived stream from the environment server to the desktop client.
307−
308−Request:
309−
310−```ts
311−{
312− requestId: string;
313− threadId: ThreadId;
314− tabId?: PreviewTabId;
315− operation: PreviewAutomationOperation;
316− input: unknown;
317− timeoutMs: number;
318−}
319−```
320−
321−Response:
322−
323−```ts
324−{
325− requestId: string;
326− ok: boolean;
327− result?: unknown;
328− error?: {
329− _tag: string;
330− message: string;
331− detail?: unknown;
332− };
333−}
334−```
335−
336−## Desktop Automation
337−
338−Extend `PreviewViewManager` using Electron `webContents.debugger`.
339−
340−Use CDP domains:
341−
342−- `Runtime`
343−- `DOM`
344−- `Page`
345−- `Accessibility`
346−- `Input`
347−
348−Use `webContents.capturePage()` for screenshots.
349−
350−Create a scoped CDP helper that:
351−
352−1. Resolves the tab’s webContents.
353−2. Attaches lazily.
354−3. Enables required domains.
355−4. Executes one bounded operation.
356−5. Detaches in finalization.
357−6. Maps protocol failures to typed errors.
358−
359−If DevTools or another debugger owns the target, return a typed automation error rather than disrupting it.
360−
361−Place pure logic in separate modules:
362−
363−- DOM summary extraction
364−- selector generation
365−- result clamping
366−- key mapping
367−- CDP response parsing
368−
369−## Desktop IPC
370−
371−Extend `DesktopPreviewBridge` with:
372−
373−```ts
374−automation: {
375− status(...): Promise<...>;
376− snapshot(...): Promise<...>;
377− click(...): Promise<...>;
378− type(...): Promise<...>;
379− press(...): Promise<...>;
380− scroll(...): Promise<...>;
381− evaluate(...): Promise<...>;
382− waitFor(...): Promise<...>;
383−}
384−```
385−
386−Add schema-validated IPC channels and handlers.
387−
388−## Web Client
389−
390−Add a preview ownership hook mounted with `PreviewView`.
391−
392−Report changes to:
393−
394−- active environment/thread
395−- tab id
396−- panel visibility
397−- window focus
398−- Electron automation availability
399−
400−Handle broker requests:
401−
402−- `preview_open` opens the right panel for the active scoped thread.
403−- Create a preview session and tab if needed.
404−- Wait for webview registration.
405−- Other operations invoke desktop automation IPC.
406−- Always send a correlated success or failure response.
407−
408−Clear ownership on unmount, thread change, panel close, or desktop disconnect.
409−
410−## Provider Integration
411−
412−Add `McpSessionRegistry` integration to provider lifecycle.
413−
414−For each provider session:
415−
416−1. Issue a scoped MCP bearer token.
417−2. Add the shared HTTP MCP configuration to session startup.
418−3. Start or resume the agent session.
419−4. Revoke the token during provider-session finalization.
420−
421−ACP providers use their existing HTTP MCP configuration fields.
422−
423−Codex uses its supported MCP/config override mechanism to register the same shared HTTP endpoint.
424−
425−Assume every supported provider can use HTTP MCP. Do not implement stdio fallback.
426−
427−## Remote Environment Behavior
428−
429−For a Mac mini environment viewed from a MacBook:
430−
431−1. Mac mini runs the T3 environment server and shared MCP endpoint.
432−2. Agent session connects to that endpoint locally/remotely using its scoped token.
433−3. Preview tool calls enter the Mac mini preview broker.
434−4. Broker routes them over the existing T3 connection to the focused MacBook desktop.
435−5. MacBook controls its visible Electron webview.
436−
437−URLs must already be reachable from the MacBook, such as:
438−
439−- `http://mac-mini.local:5173`
440−- `http://192.168.1.42:5173`
441−
442−Warn when a remote environment opens `localhost`, `127.0.0.1`, or `::1`, because loopback resolves on the preview client.
443−
444−Preserve URL-resolution metadata:
445−
446−```ts
447−{
448− requestedUrl: string;
449− resolvedUrl: string;
450− resolutionKind: "direct";
451− environmentId: EnvironmentId;
452−}
453−```
454−
455−This leaves room for future SSH, relay, or Tailscale resolution.
456−
457−## Tests
458−
459−### MCP Server
460−
461−- one MCP server layer starts per environment server
462−- multiple authenticated MCP clients share the same server instance
463−- each client receives its own invocation scope
464−- toolkit registration is independent of transport
465−- a mock future toolkit can register without changing server runtime
466−- malformed parameters are rejected by Effect schemas
467−- tool annotations appear in `tools/list`
468−
469−### Authentication
470−
471−- valid token resolves correct thread and provider session
472−- concurrent tokens remain isolated
473−- revoked and expired tokens fail
474−- token cannot call unauthorized capability family
475−- thread identity cannot be overridden in arguments
476−- server restart invalidates tokens
477−
478−### Preview Broker
479−
480−- focused owner receives operation
481−- most recently focused client wins
482−- wrong-thread client is never selected
483−- no owner returns typed error
484−- disconnect fails pending calls
485−- stale responses are ignored
486−
487−### Desktop and Web
488−
489−- agent can show and open preview
490−- webview registration is awaited
491−- CDP click, typing, key press, scroll, evaluation, and wait work
492−- snapshot bounds screenshot, text, and interactive elements
493−- ownership updates on focus and visibility changes
494−- background threads are not automatically activated
495−
496−### End-to-End Integration
497−
498−Run two mocked agent sessions against one HTTP MCP server:
499−
500−1. Bind each bearer token to a different thread.
501−2. Call `preview_status` concurrently.
502−3. Verify each request routes to its own focused preview owner.
503−4. Verify no MCP child process is spawned.
504−5. Revoke one provider session and confirm only its MCP access fails.
505−
506−## Validation
507−
508−- `vp check`
509−- `vp run typecheck`
510−- `vp test`
511−
512−## Assumptions
513−
514−- HTTP MCP is supported by every target provider.
515−- One MCP server is embedded in each T3 environment server.
516−- MCP connection authentication supplies invocation identity.
517−- Agents never know or pass T3 thread IDs.
518−- Preview automation is the first of multiple future MCP toolkits.
519−- Only Electron desktop preview clients support browser automation in v1.
520−- No headless browser, stdio fallback, or automatic tunnel management is included.
.plans/visible-preview-browser-automation-via-cdp-mcp.md+0−670
@@ -1,670 +0,0 @@
1−# Visible Preview Browser Automation via CDP + MCP
2−
3−## Summary
4−
5−Implement agent control of the user-visible T3 preview browser only. Do not add headless browser support. Do not add SSH/relay/private forwarding in v1; preview URLs must already be reachable from the desktop client, such as a Mac mini private IP URL opened on a MacBook.
6−
7−Architecture:
8−
9−`agent` -> `stdio MCP server in environment` -> `private T3 server bridge` -> `focused desktop client/window` -> `Electron preview webview via CDP`
10−
11−The stdio MCP server is the agent-facing integration because all target agents can speak MCP. The MCP server is intentionally thin: it does not automate Chromium directly. It calls the T3 environment server, which routes commands to the focused desktop client that owns the visible preview.
12−
13−## Explicit Non-Goals
14−
15−- No headless browser runner.
16−- No Playwright-managed browser.
17−- No arbitrary SSH dev-port forwarding in v1.
18−- No Cloudflare/Tailscale/relay URL rewriting in v1.
19−- No automation of browser/web clients that do not have Electron preview support.
20−- No per-action user approval prompts in v1.
21−
22−## Key Decisions
23−
24−- **Transport to agents:** stdio MCP.
25−- **Browser being controlled:** the actual integrated Electron preview webview.
26−- **Remote URL handling:** manual reachable URLs only. Agents may open `http://mac-mini.local:5173` or `http://192.168.x.y:5173`; T3 will not tunnel `127.0.0.1:5173` from remote to local yet.
27−- **Client routing:** route tool requests to the most recently focused desktop window/client for the agent’s thread. Return a typed error if no focused desktop preview owner is available.
28−- **Scope:** full control of preview browser, including opening/showing the preview panel.
29−- **Primary automation engine:** Chrome DevTools Protocol through Electron `webContents.debugger`, with `webContents.capturePage()` where it is simpler and more reliable.
30−
31−## New Contracts
32−
33−Add `packages/contracts/src/previewAutomation.ts`.
34−
35−### Branded IDs
36−
37−- `PreviewAutomationRequestId`
38−- `PreviewAutomationClientId`
39−- `PreviewAutomationOwnerId`
40−
41−### Tool Input/Output Schemas
42−
43−Add Effect schemas for these operations:
44−
45−- `PreviewAutomationOpenInput`
46− - `url?: string`
47− - `show?: boolean` default `true`
48− - `reuseExistingTab?: boolean` default `true`
49−- `PreviewAutomationNavigateInput`
50− - `url: string`
51− - `waitUntil?: "load" | "domcontentloaded" | "network-idle" | "none"` default `"load"`
52− - `timeoutMs?: number` default `15000`
53−- `PreviewAutomationSnapshotInput`
54− - `includeScreenshot?: boolean` default `true`
55− - `includeDomSummary?: boolean` default `true`
56− - `includeAccessibilityTree?: boolean` default `true`
57− - `screenshotMaxWidth?: number` default `1280`
58−- `PreviewAutomationClickInput`
59− - one of:
60− - `{ selector: string }`
61− - `{ x: number; y: number }`
62− - `button?: "left" | "middle" | "right"` default `"left"`
63− - `clickCount?: number` default `1`
64−- `PreviewAutomationTypeInput`
65− - `text: string`
66− - `selector?: string`
67− - `clearFirst?: boolean` default `false`
68−- `PreviewAutomationPressInput`
69− - `key: string`
70− - `modifiers?: readonly ("alt" | "control" | "meta" | "shift")[]`
71−- `PreviewAutomationScrollInput`
72− - `deltaX?: number`
73− - `deltaY?: number`
74− - optional target `{ selector: string }`
75−- `PreviewAutomationEvaluateInput`
76− - `expression: string`
77− - `awaitPromise?: boolean` default `true`
78− - `returnByValue?: boolean` default `true`
79−- `PreviewAutomationWaitForInput`
80− - one of:
81− - `{ selector: string }`
82− - `{ text: string }`
83− - `{ urlIncludes: string }`
84− - `timeoutMs?: number` default `10000`
85−- `PreviewAutomationStatusResult`
86− - `available: boolean`
87− - `visible: boolean`
88− - `threadId`
89− - `tabId: string | null`
90− - `url: string | null`
91− - `title: string | null`
92− - `loading: boolean`
93− - `ownerClientId: string | null`
94−
95−### Result Shape
96−
97−Every mutating or stateful operation returns:
98−
99−```ts
100−{
101− ok: boolean;
102− status: PreviewAutomationStatusResult;
103− message?: string;
104−}
105−```
106−
107−`snapshot` additionally returns:
108−
109−```ts
110−{
111− status: PreviewAutomationStatusResult;
112− screenshot?: {
113− mimeType: "image/png";
114− dataBase64: string;
115− width: number;
116− height: number;
117− };
118− domSummary?: {
119− url: string;
120− title: string;
121− activeElement: string | null;
122− text: string;
123− interactiveElements: readonly {
124− index: number;
125− tag: string;
126− role: string | null;
127− name: string;
128− text: string;
129− selector: string | null;
130− rect: { x: number; y: number; width: number; height: number } | null;
131− }[];
132− };
133− accessibilityTree?: unknown;
134−}
135−```
136−
137−### Error Types
138−
139−Add tagged errors:
140−
141−- `PreviewAutomationUnavailableError`
142−- `PreviewAutomationNoFocusedOwnerError`
143−- `PreviewAutomationUnsupportedClientError`
144−- `PreviewAutomationTabNotFoundError`
145−- `PreviewAutomationTimeoutError`
146−- `PreviewAutomationExecutionError`
147−- `PreviewAutomationInvalidSelectorError`
148−
149−## Server-Side Broker
150−
151−Add `apps/server/src/previewAutomation/Services/PreviewAutomationBroker.ts`.
152−
153−Responsibilities:
154−
155−- Track connected desktop automation clients.
156−- Track focus ownership by `(environmentId, threadId)`.
157−- Accept tool calls from MCP/stdin proxy.
158−- Route each call to the focused owner for the thread.
159−- Enforce timeouts and cleanup pending requests on disconnect.
160−- Return typed failures when no client/window is available.
161−
162−### Owner State
163−
164−Store:
165−
166−```ts
167−{
168− clientId: PreviewAutomationClientId;
169− environmentId: EnvironmentId;
170− threadId: ThreadId;
171− tabId: PreviewTabId | null;
172− focusedAt: string;
173− visible: boolean;
174− supportsAutomation: boolean;
175−}
176−```
177−
178−Ownership updates come from the web/desktop client when:
179−
180−- route/thread changes,
181−- preview panel opens/closes,
182−- window focus changes,
183−- tab id changes,
184−- desktop bridge availability changes.
185−
186−## WS Bridge: Server to Desktop Client
187−
188−The current preview RPCs are client-to-server plus server event streams. Add a request/response bridge using stream-style RPCs to avoid introducing bidirectional RPC infrastructure.
189−
190−### New WS Methods
191−
192−Add to `packages/contracts/src/rpc.ts`:
193−
194−- `previewAutomation.connect`
195− - client calls this as a long-lived stream
196− - input:
197− - `clientId`
198− - `capabilities`
199− - stream output:
200− - `PreviewAutomationClientRequest`
201−- `previewAutomation.respond`
202− - client sends response for a request id
203−- `previewAutomation.reportOwner`
204− - client reports focus/visibility/thread ownership
205−- `previewAutomation.clearOwner`
206− - client clears stale ownership on unmount/disconnect
207−
208−### Request Shape
209−
210−```ts
211−{
212− requestId: string;
213− threadId: string;
214− tabId?: string;
215− operation:
216− | "open"
217− | "navigate"
218− | "snapshot"
219− | "click"
220− | "type"
221− | "press"
222− | "scroll"
223− | "evaluate"
224− | "waitFor"
225− | "status";
226− input: unknown;
227− timeoutMs: number;
228−}
229−```
230−
231−### Response Shape
232−
233−```ts
234−{
235− requestId: string;
236− ok: boolean;
237− result?: unknown;
238− error?: {
239− _tag: string;
240− message: string;
241− detail?: unknown;
242− };
243−}
244−```
245−
246−## Desktop Preview Automation
247−
248−Extend `apps/desktop/src/preview-view-manager.ts`.
249−
250−### Add Methods
251−
252−- `getAutomationStatus(tabId)`
253−- `captureSnapshot(tabId, options)`
254−- `click(tabId, input)`
255−- `type(tabId, input)`
256−- `press(tabId, input)`
257−- `scroll(tabId, input)`
258−- `evaluate(tabId, input)`
259−- `waitFor(tabId, input)`
260−
261−### CDP Session Handling
262−
263−Add a small helper inside desktop preview code:
264−
265−- Attach `webContents.debugger` lazily per operation.
266−- Do not keep debugger attached forever unless needed.
267−- If already attached by DevTools or another debugger, return `PreviewAutomationExecutionError`.
268−- Use CDP domains:
269− - `Runtime.evaluate`
270− - `DOM.getDocument`
271− - `DOM.querySelector`
272− - `DOM.getBoxModel`
273− - `Accessibility.getFullAXTree`
274− - `Input.dispatchMouseEvent`
275− - `Input.dispatchKeyEvent`
276− - optionally `Page.captureScreenshot` if `webContents.capturePage()` is insufficient
277−
278−For screenshots, prefer `webContents.capturePage()` first because it is already used safely for annotations.
279−
280−### DOM Summary Script
281−
282−Use `Runtime.evaluate` with a bounded page script that returns:
283−
284−- `document.URL`
285−- `document.title`
286−- active element summary
287−- visible text truncated to a fixed limit, e.g. 20k chars
288−- up to 200 interactive elements:
289− - buttons
290− - links
291− - inputs
292− - selects
293− - textareas
294− - elements with roles
295− - elements with click handlers where detectable
296−- stable-ish CSS selectors generated in page context
297−- bounding rects
298−
299−Do not return full HTML by default.
300−
301−### Input Behavior
302−
303−- `click(selector)`:
304− - resolve selector in page
305− - scroll into view
306− - compute center of bounding box
307− - dispatch mouse move/down/up through CDP
308−- `click(x, y)`:
309− - dispatch at viewport coordinates
310−- `type(selector, text)`:
311− - focus selector if provided
312− - optionally clear existing value with platform shortcut
313− - dispatch text via CDP keyboard events or `Input.insertText`
314−- `press(key)`:
315− - map common key names: Enter, Escape, Tab, Backspace, Arrow keys
316− - support modifiers
317−- `scroll`:
318− - use CDP mouse wheel or page `scrollBy` fallback
319−
320−## Web Client Changes
321−
322−Update `apps/web/src/components/preview`.
323−
324−### Ownership Reporting
325−
326−Add a hook, likely `usePreviewAutomationOwner`, mounted near `PreviewView`.
327−
328−It reports owner state when:
329−
330−- the current route thread ref changes,
331−- preview panel visibility changes,
332−- browser window focus/blur changes,
333−- tab id changes,
334−- desktop preview bridge exists.
335−
336−Policy:
337−
338−- Only Electron desktop clients with `window.desktopBridge.preview` can report `supportsAutomation: true`.
339−- A visible preview panel gets ownership.
340−- The most recently focused window wins.
341−- On unmount or preview close, clear ownership.
342−
343−### Handling Server Requests
344−
345−Add a client-side subscriber using the new `previewAutomation.connect` stream.
346−
347−When a request arrives:
348−
349−- if operation is `open`, ensure preview panel is visible for the thread, call `api.preview.open` if needed, mount/create desktop tab, then navigate if URL is provided.
350−- for browser operations, call new `desktopBridge.preview.automation.*` methods.
351−- send `previewAutomation.respond`.
352−
353−Opening behavior:
354−
355−- If the right panel is closed, open it to preview.
356−- If the thread is not currently active in the UI, do not navigate the whole app in v1. Return `PreviewAutomationNoFocusedOwnerError`.
357−- If preview is supported but no tab exists and the agent calls `open`, create one.
358−- If no tab exists and the agent calls `snapshot/click/type/...`, return a clear “preview not open” error suggesting `preview_open`.
359−
360−## Desktop IPC / Preload
361−
362−Extend `packages/contracts/src/ipc.ts` `DesktopPreviewBridge`.
363−
364−Add:
365−
366−```ts
367−automation: {
368− status(tabId: string): Promise<PreviewAutomationStatusResult>;
369− snapshot(tabId: string, input: PreviewAutomationSnapshotInput): Promise<PreviewAutomationSnapshotResult>;
370− click(tabId: string, input: PreviewAutomationClickInput): Promise<PreviewAutomationActionResult>;
371− type(tabId: string, input: PreviewAutomationTypeInput): Promise<PreviewAutomationActionResult>;
372− press(tabId: string, input: PreviewAutomationPressInput): Promise<PreviewAutomationActionResult>;
373− scroll(tabId: string, input: PreviewAutomationScrollInput): Promise<PreviewAutomationActionResult>;
374− evaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<PreviewAutomationEvaluateResult>;
375− waitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<PreviewAutomationActionResult>;
376−}
377−```
378−
379−Add IPC channels in `apps/desktop/src/ipc/channels.ts` and handlers in `apps/desktop/src/ipc/methods/preview.ts`.
380−
381−## Stdio MCP Server
382−
383−Add package: `packages/preview-mcp`.
384−
385−Purpose:
386−
387−- Implements the MCP stdio protocol.
388−- Exposes T3 preview tools.
389−- Calls a private loopback endpoint or local JSON-RPC bridge on the environment server.
390−- Does not know Electron/CDP details.
391−
392−### Binary
393−
394−Expose bin:
395−
396−```json
397−{
398− "bin": {
399− "t3-preview-mcp": "./dist/index.js"
400− }
401−}
402−```
403−
404−During local dev, provider config can call the source runner via workspace package script; production package uses built JS.
405−
406−### MCP Environment Variables
407−
408−Provider sessions launch the MCP server with:
409−
410−- `T3_PREVIEW_MCP_SERVER_URL`
411− - environment server loopback URL
412−- `T3_PREVIEW_MCP_TOKEN`
413− - short-lived token scoped to the provider session/thread
414−- `T3_PREVIEW_ENVIRONMENT_ID`
415−- `T3_PREVIEW_THREAD_ID`
416−
417−The token must be generated by the environment server and expire when the provider session ends.
418−
419−### MCP Tools
420−
421−Expose these tools:
422−
423−- `preview_status`
424−- `preview_open`
425−- `preview_navigate`
426−- `preview_snapshot`
427−- `preview_click`
428−- `preview_type`
429−- `preview_press`
430−- `preview_scroll`
431−- `preview_evaluate`
432−- `preview_wait_for`
433−
434−Tool descriptions must explicitly say they operate the visible T3 preview browser for the current thread.
435−
436−### MCP Output
437−
438−- Text results include concise status and URL/title.
439−- `preview_snapshot` returns:
440− - text summary
441− - MCP image content for screenshot when available
442−- Errors are MCP tool errors with the tagged T3 error message included.
443−
444−## Private MCP Bridge Endpoint
445−
446−Add an internal server route under `apps/server`, not public app UI:
447−
448−- `POST /internal/preview-automation/tool`
449−- Auth: bearer `T3_PREVIEW_MCP_TOKEN`
450−- Body:
451− - `{ tool: string, input: unknown }`
452−- Response:
453− - `{ ok: true, result } | { ok: false, error }`
454−
455−This endpoint is only for the stdio MCP proxy. It calls `PreviewAutomationBroker`.
456−
457−Bind it to the same host/port as the environment server, but require the short-lived token. The endpoint must reject requests without a token or with an expired/stale thread/session.
458−
459−## Provider Integration
460−
461−### Codex
462−
463−When starting a Codex provider session, add the T3 preview MCP server to Codex configuration if the app-server config path supports per-thread MCP injection.
464−
465−Implementation path:
466−
467−1. Add `PreviewMcpSessionService` in `apps/server`.
468−2. On provider session start:
469− - create scoped MCP token for `(environmentId, threadId, providerSessionId)`.
470− - build MCP server config:
471− - name: `t3-preview`
472− - command: `t3-preview-mcp`
473− - env vars listed above
474−3. Thread/session start passes the MCP server config through the provider’s supported config field.
475−4. If Codex app-server cannot accept injected MCP servers through typed params, use its `config` override field with Codex-compatible MCP config.
476−
477−### ACP Providers
478−
479−ACP session creation already passes `mcpServers: []`. Replace that with the same `t3-preview` MCP server config for providers that support MCP.
480−
481−### Provider Fallback
482−
483−If a provider does not support MCP injection yet, do not add provider-specific native tools in v1. The shared MCP server remains available for later provider wiring.
484−
485−## Remote Environment Behavior
486−
487−### Mac mini dev server viewed from MacBook
488−
489−Expected v1 workflow:
490−
491−1. Agent runs dev server on Mac mini.
492−2. Agent or user opens a reachable URL, e.g.:
493− - `http://mac-mini.local:5173`
494− - `http://192.168.1.42:5173`
495−3. T3 desktop on MacBook opens that URL in the local Electron preview.
496−4. Agent uses MCP tools.
497−5. T3 routes tool calls from Mac mini environment server to the focused MacBook preview client.
498−
499−### `localhost` Caveat
500−
501−In v1, if an agent opens `http://localhost:5173` from a remote environment, the MacBook preview will interpret that as MacBook localhost. The tool result should include a warning when:
502−
503−- environment is not the primary/local environment, and
504−- URL hostname is `localhost`, `127.0.0.1`, or `::1`.
505−
506−Warning text:
507−
508−`This URL is loopback on the preview client, not necessarily the remote environment. Use a client-reachable host/IP for remote dev servers.`
509−
510−### Future-Proofing
511−
512−Do not bake manual URL assumptions into the automation layer. Represent opened URLs as:
513−
514−```ts
515−{
516− displayUrl: string;
517− requestedUrl: string;
518− resolutionKind: "direct";
519− environmentId: string;
520−}
521−```
522−
523−Later `resolutionKind` can add:
524−
525−- `ssh-forward`
526−- `relay`
527−- `tailscale`
528−- `cloudflare-tunnel`
529−
530−## Security and Safety
531−
532−- MCP tokens are scoped to one provider session/thread.
533−- MCP tokens expire on provider session stop and server restart.
534−- Browser automation only routes to focused desktop owner for that same thread.
535−- Do not allow MCP input to specify arbitrary `environmentId` or `threadId`; infer both from token/session.
536−- `preview_evaluate` is powerful. Keep it enabled in v1 because the user requested full control, but:
537− - limit result serialization size, e.g. 64 KB
538− - timeout evaluation
539− - return by value by default
540− - document that it executes in the preview page context
541−- Screenshot output should be bounded:
542− - max dimensions
543− - max base64 size
544− - return error if too large after scaling attempts
545−- Clear pending broker requests when desktop disconnects.
546−- CDP operations must timeout and detach debugger in `finally`.
547−
548−## Failure Modes
549−
550−Return typed errors for:
551−
552−- no desktop client connected
553−- desktop client connected but preview unsupported
554−- no focused owner for thread
555−- preview panel not open and operation is not `preview_open`
556−- webview not initialized yet
557−- navigation timeout
558−- selector not found
559−- CDP debugger unavailable
560−- page execution error
561−- screenshot too large
562−- stale request id or response after timeout
563−
564−## Tests
565−
566−### Contracts
567−
568−Add tests for:
569−
570−- schema decoding for every preview automation input/result
571−- error schema decoding
572−- invalid selector/click union inputs rejected
573−- snapshot options defaulting
574−
575−### Desktop Unit Tests
576−
577−Add tests around `PreviewViewManager` helpers:
578−
579−- owner-independent status when tab exists/does not exist
580−- selector summary script clamps output
581−- selector generation handles ids/classes/nth-child fallback
582−- error mapping for missing webContents
583−- screenshot result shape
584−
585−Where Electron/CDP is hard to unit test, isolate pure helpers and cover IPC handler validation.
586−
587−### Server Broker Tests
588−
589−Add tests for:
590−
591−- registering clients
592−- ownership updates
593−- focused owner wins
594−- request routed to focused owner
595−- request timeout
596−- client disconnect fails pending requests
597−- response with unknown request id ignored/rejected
598−- token scoped to thread/session
599−- remote loopback URL warning generated
600−
601−### Web Client Tests
602−
603−Add tests for:
604−
605−- ownership report sent only in Electron preview-supported runtime
606−- opening preview panel on `preview_open`
607−- no route switch for background thread
608−- clear ownership on unmount
609−- response sent for successful and failed desktop bridge calls
610−
611−### MCP Tests
612−
613−Add tests for:
614−
615−- tool list includes all expected preview tools
616−- each tool maps to internal bridge request
617−- token missing/invalid returns MCP error
618−- snapshot maps screenshot to MCP image content
619−- tool errors preserve tagged T3 error message
620−
621−### Integration Tests
622−
623−Add a focused integration test using mocked desktop client:
624−
625−1. Start server broker.
626−2. Register fake desktop automation client.
627−3. Mark it focused for thread.
628−4. Invoke MCP `preview_open`.
629−5. Assert fake client received open request.
630−6. Respond success.
631−7. Assert MCP response is successful.
632−
633−Add a second integration test:
634−
635−1. No focused owner.
636−2. Invoke `preview_snapshot`.
637−3. Assert `PreviewAutomationNoFocusedOwnerError`.
638−
639−## Validation Commands
640−
641−Before completion:
642−
643−- `vp check`
644−- `vp run typecheck`
645−- `vp test`
646−
647−No `vp run lint:mobile` required unless mobile code is changed.
648−
649−## Implementation Order
650−
651−1. Add contracts for preview automation schemas and WS methods.
652−2. Add `PreviewAutomationBroker` server service.
653−3. Add WS stream bridge for desktop clients.
654−4. Add desktop IPC and `PreviewViewManager` CDP automation methods.
655−5. Add web ownership reporting and request handling.
656−6. Add private `/internal/preview-automation/tool` endpoint with scoped token auth.
657−7. Add `packages/preview-mcp` stdio MCP server.
658−8. Wire provider sessions to launch/register `t3-preview` MCP server where provider protocols support MCP config.
659−9. Add remote loopback URL warning.
660−10. Add tests.
661−11. Run validation commands.
662−
663−## Assumptions
664−
665−- v1 only supports Electron desktop preview clients.
666−- The active/focused thread preview is the right target for agent control.
667−- Manual client-reachable URLs are acceptable for remote dev servers.
668−- Full browser control includes `evaluate`.
669−- Stdio MCP is the agent-facing transport.
670−- A private server bridge behind the stdio MCP server is acceptable and necessary because the browser lives on the desktop client, not beside the remote agent.

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t