Skip to content

feat(browser): built-in browser tabs on native webviews (P1 + P2) - #723

Draft
xintaofei wants to merge 71 commits into
mainfrom
task/166
Draft

feat(browser): built-in browser tabs on native webviews (P1 + P2)#723
xintaofei wants to merge 71 commits into
mainfrom
task/166

Conversation

@xintaofei

Copy link
Copy Markdown
Owner

Links, dev servers and local HTML open in a codeg tab instead of leaving the app. Ten work packages (P1 + P2), three platforms, 65 commits.

Opened as a draft: the Linux half has never been run on a real machine, and neither have the most recent Windows fixes. Opening it mainly to put the branch through the three-platform matrix for the first time — see Verification below.

What a browser tab is

A native webview owned by Rust, not an iframe. Two surface kinds behind one BrowserSurface enum, dispatched by a per_surface! macro:

  • surface_child — a wry child webview embedded in the app window (macOS, Windows).
  • surface_window — an owned top-level tauri::WebviewWindow. The only surface on Linux, and available as a preference everywhere.

The frontend paints a hole and holds an occlusion lease; the surface follows that rectangle and freezes when an overlay covers it. Tabs survive restart, idle ones unload.

The page channel

Each surface installs a script in an isolated world that talks back over one primitive, __codegSend. channel.rs is the single enforcement point for every platform: hello and nav-state are accepted only from the main frame, while gesture and shortcut are deliberately accepted from subframes too, so a click inside an iframe still counts as a user gesture and ⌘F still works there.

Three shims implement the same contract against three engines:

  • macOSWKUserContentController worlds, WKWebsiteDataStore per profile.
  • Windows — CDP over CallDevToolsProtocolMethod: Runtime.addBinding{executionContextName} plus Page.addScriptToEvaluateOnNewDocument{worldName}, contexts keyed by (CDP session, id).
  • Linux — WebKitGTK script worlds, two message handlers (top frame / all frames) because the signal reports no frame information.

Roughly what is in here

Per-tab profiles and proxy following · typed load errors with a real error page · downloads · find in page · site rules · a codeg-doc: document guest for local HTML · a port bridge that puts dev servers into web-mode tabs · popup handling with gesture and opener rules · a settings section.

Verification — read this part

state
macOS Developed and run here. cargo test --features test-utils 3711 passed / 0 failed. Manual checklist run.
Windows The earlier batches were written and run on a real Windows machine. The four review fixes in 6eafbb9b and the shared fixes after it were written on macOS and have not been run there yet.
Linux Written entirely on macOS. Cross-compiled and clippy-clean. Never executed, not once.

Both remaining machines have a written checklist waiting.

Two caveats about what green CI would and would not mean:

  1. The Windows desktop cell is --no-run. It proves the test binaries link, not that anything works. Windows conclusions come from the physical machine only.
  2. This branch has never had a PR, so the matrix has never run on any of the 65 commits. Until recently it did not even compile on Linux (a missing type annotation in doc_open_core); that was found by hand, because the cell that would have caught it was never triggered.

The Linux code was written against a local cross-compilation setup (x86_64-unknown-linux-gnu with stub .pc files, x86_64-pc-windows-gnu with mingw). That checks compilation and clippy with fabricated pkg-config metadata — the ubuntu cell is the first time it meets real system headers.

Boundaries worth a reviewer's eye

  • Capability labels. browser-*, browser-popup-* and codeg-doc-* must never appear in the capability list. There is a guard for this.
  • Top-level navigation is limited to http(s), about:blank and blob: on http(s).
  • No automatic agent access. Agents cannot see or drive a browser tab; that is P3 and is not in this PR.
  • The port bridge pairs its grants per listener and judges requests without relying on Fetch Metadata, trusting forwarding headers only behind a declared proxy.
  • The document guest keeps its own addresses to its own host, and resolves every link on a document path before approving it.
  • Sign-in identity (a distinct UA presented to Google) is admitted only on allowed navigations, taken back when a navigation never commits, and is not offered on Linux at all — WebKitGTK's UA belongs to the whole webview and its navigation decision names no frame, so any iframe could put the borrowed identity on the top-level page's requests.

Deliberately not here

Favicons (the field exists and stays None) · download progress and cancel (wry gives no WKDownload handle) · a match counter on macOS find · background tab preloading · hard reload and a custom context menu · the document view on Linux (an owned window gets no per-webview custom protocol) · P3 (agents) and P4 (remote egress).

🤖 Generated with Claude Code

xintaofei and others added 30 commits September 7, 2026 14:57
Pins tauri to 2.11 (2.11.1 ships the remote-origin ACL and .localhost
origin fixes; 2.11.0 adds Webview::eval_with_callback and lets the
on_new_window handler run on the main thread without Sync, which the
built-in browser's popup routing relies on). tauri-runtime-wry 2.11.4
requires wry 0.55, so wry lands at 0.55.1. The JS side moves in step
to @tauri-apps/api 2.11.1 / @tauri-apps/cli 2.11.4.
… decision function

Moves the pure parsing helpers out of link-safety.tsx into
lib/link-classify.ts (behaviour unchanged, re-exported for existing
importers) and adds classifyLinkTarget as the single first step every
link entrance runs: local path, OS handler, http(s), or an unsupported
scheme that is still refused rather than handed to the OS.

Adds lib/resolve-link-action.ts, the synchronous decision behind the
built-in browser: classify, then terminal rules (host block rules and
the remote-workspace loopback override, neither invertible), then the
per-source preference or an explicit choice, then the modifier-key
inversion, then placement (file column vs viewer drawer). lib/browser
gains the URL helpers (loopback / private-network detection, dedupe
normalization) and the per-key localStorage preferences the decision
reads; nothing consumes the new modules yet.
Adds the src-tauri/src/browser module: wire types mirrored by the
frontend, the scheme allow-list policy, a tab registry, and two
surfaces — an embedded child webview for macOS / Windows and an owned
window for Linux and fallbacks — plus the first browser_* commands
(capabilities, open, close, bounds, visibility, navigate, reload,
state, list) and the browser://state / browser://closed events.

The embedded surface is built straight through tauri-runtime-wry's
re-exported wry (WebViewBuilder::build_as_child on the owner window)
rather than tauri's Window::add_child. A tauri child flips the owner's
is_webview_window() to false, after which get_webview_window("main")
returns None and every command taking a tauri::WebviewWindow argument
fails; wry-built children are invisible to tauri, so the workspace
window keeps behaving as before and no `unstable` feature is needed.
wry webviews are not Send, so instances live in a main-thread
thread-local behind a cloneable ChildHandle that hops to the main
thread per operation.

Also adds a dev-only puppet (feature browser-smoke) that executes
JSON commands from a control directory through the same _core
functions, used to verify the surface without UI automation.
…option

Every embedded tab now gets a helper script in a WebKit content world
named "codeg" plus a native message handler (objc2), installed before
the first document loads: the webview is built with no URL and
navigated only after the channel is in place, which also keeps
about:blank out of the tab's history. The helper reports SPA navigation
and document titles the engine does not surface, and records user
gestures (click / auxclick / keydown with the anchor found along
composedPath) for the popup policy. Page scripts cannot see the world,
and prototype tampering in the page world does not affect it.

The shim also provides world-scoped evaluation, PNG snapshots
(takeSnapshotWithConfiguration) and back / forward / stop, wired
through the surface enum and new browser_go_back / browser_go_forward /
browser_stop commands.

Page-initiated new windows go through wry's new_window_req_handler: a
request without a user gesture in the last second is denied (popup
blocker); everything else is answered with NewWindowResponse::Create
using a child webview built from the opener's WKWebViewConfiguration,
so window.opener, referrer and postMessage keep working while the host
only decides presentation — the popup is adopted as a tab next to its
opener and announced on browser://popup. A popup shares its opener's
user-content controller, so world installation is idempotent per
controller and messages are attributed to tabs by source webview.

The helper no longer opens middle-clicked links itself: WebKit already
treats a middle-button auxclick on an anchor as a link click and asks
for a new window, so doing it in script produced two tabs.
file-tab-id learns a `browser:<backend id>` kind, and the workspace tab
type becomes a discriminated union: file-like tabs keep their shape,
browser tabs carry only a seed (initial URL, opener) and never a path.
Live per-tab state (url, title, loading, history flags) lives in a
separate useSyncExternalStore-backed store fed by browser://state, so
page activity never churns the fileTabs slice.

openBrowserTab opens one tab per URL (fragment ignored) and re-activates
an existing one; adoptBrowserTab registers a popup the backend adopted,
inserted right after its opener. Closing a browser tab releases its
native surface. BrowserEventsBridge, mounted once in the workspace
layout, subscribes to browser://state, browser://popup and
browser://closed only when the backend reports a browser is available.

Also adds the TypeScript mirror of the Rust wire types and the
browser_* command wrappers.
…d viewer drawer

A browser tab now renders in the file pane: a toolbar (back / forward /
reload-stop / address bar / copy / open in system browser, with a
loading bar), notice bars for blocked popups and remote egress, and the
BrowserSurfaceHost placeholder the native webview is fitted to. The host
creates the surface once per tab record, keeps the webview's bounds equal
to its rect (ResizeObserver + rAF), and hides it whenever the rect is not
truly visible — the file column CSS-hidden in conversation mode, a
full-page route covering the workspace, a DOM error page replacing the
page — handing focus back to the main webview first.

Native views paint above the DOM, so overlays take occlusion leases:
Dialog, AlertDialog, Drawer, DropdownMenu and ContextMenu content hold
one while mounted (the browser viewer drawer opts out because it hosts a
surface itself), and a MutationObserver fallback catches lease-less open
dialogs and menus. Any lease hides every surface.

The transcript's side panel gains a browser viewer for links opened
under a full-page route, backed by the same workspace tab record. Tab
strip items show the page's live title and a globe icon. The backend can
ask a window to open a URL over browser://open-request (agent tools and
deep links later; the dev puppet now). Strings live under the new
Browser i18n namespace in all ten locales.
… surfaces

The shared Dialog / AlertDialog / DropdownMenu / ContextMenu / Drawer
content wrappers stay mounted while their overlay is closed — only the
primitive inside unmounts its DOM — so a lease taken on wrapper mount
was held by every closed overlay in the tree (30 leases with nothing
open) and every browser surface stayed hidden. The lease now lives on
the content element's callback ref: acquired when the node attaches,
released when it detaches, which is exactly when the overlay is really
open. Verified against a live dropdown: opening it hides the active
tab's webview, Escape brings it back.

BrowserEventsBridge closes any surface the backend still holds when it
first mounts: tab records are session-only, so after a document reload
those webviews would otherwise stay painted with nothing left to hide
them. The dev puppet gains a browser_debug op exposing the native
hidden flag and frame next to the registry's view.
…ecision

useOpenUrlTarget decides and executes where an http(s) (or mailto/tel)
address goes, inside the click's own call stack: resolveLinkAction over
a preferences snapshot and the current surface (built-in browser
available, file column visible, viewer host present, remote window),
then a built-in tab, the transcript's side-panel browser under a
full-page route, the system browser, or the OS handler. The first
built-in open shows a one-time toast with an "always use the system
browser" action.

link-safety's http(s)/mailto/tel branch now goes through it (local file
paths keep their existing route); the modifier state of the click rides
along even though Streamdown's link-safety contract only passes the URL.
Web links in the transcript gain a context menu: built-in, system, copy.
The terminal's WebLinksAddon gets a real handler, which also fixes the
dead link click in the desktop webview (xterm's default is a bare
window.open). The open primitives move to lib/link-open.ts, and
browserCapabilitiesSnapshot() gives synchronous access to the resolved
capabilities; until they resolve, links go to the system browser.
…pability labels

A ⌘/Ctrl-click on a plain anchor lets the engine navigate the tab in
place; the navigation handler now consumes the matching gesture from the
tab's ring (click, button 0, primary modifier, no target/download, same
href without fragment) and asks the frontend for a background tab
instead, cancelling the in-place load.

Also adds a test that no capability window pattern ever covers
browser-*/browser-popup-*/codeg-doc-* labels or uses a bare wildcard.
The open request now carries the originating tab; the workspace inserts
the new record next to it (and inherits its folder) instead of appending
at the end, matching how browsers place ⌘/Ctrl-clicked tabs.
…d clear browsing data

Adds the Built-in browser section to General settings: default target
per link source, web inspector switch, surface override and a confirmed
clear of cookies, caches and site storage (new browser_clear_data
command; on macOS straight at the shared WKWebsiteDataStore so it works
with no tab open).

The inspector and surface preferences existed but nothing read them: the
surface host now passes both when it creates a tab, the child builder
honours devtools per tab and popups inherit the opener's value.

Also: the pop-up notice bar can open the blocked address as a tab next
to its opener, and the file header shows a browser tab's live title.
The surface host drove an owned window's visibility from its placeholder
rect, and the tab view hides that placeholder — so the window was hidden
the moment it was created. Owned windows now follow only the tab-on-screen
signals and never receive bounds; showing one also focuses it.
wry 0.55.1 unwraps `WKWebView.URL`, which is nil until a document commits:
calling tauri's `WebviewWindow::url()` on a browser surface that never
navigated (or whose first navigation failed) panics the main thread and
takes the app with it. Child surfaces now read their URL through the
macOS shim and owned windows report theirs from the tab state; the dev
puppet skips `browser-*` windows.

wry also has no navigation-failure callback and reports "load started"
at `didCommitNavigation`, so a host that never answers left the tab
spinning forever. Every navigation now arms a `load_seq`-guarded watcher
that polls `WKWebView.isLoading` and, once the engine gives up without a
document, raises `error: failed` with the requested URL. Timing follows
the engine (a refused connection ~10s, a black-holed one ~60s); nothing
is capped on our side. The wording is the status layer's, so the error
page reads in the user's language instead of an English backend string.
`WKWebView.reload()` has nothing to reload when the first navigation
failed before committing a document, so the error page's retry button
did nothing at all. `reload_core` now re-runs the requested navigation
when the surface has no document, which also re-arms the load watcher.
Browser tabs used to live in WebKit's default data store together with
the workspace webview, so "clear browsing data" wiped the app's own
localStorage along with the pages' cookies. Tabs now get a profile of
their own: a persistent `WKWebsiteDataStore` by identifier on macOS 14+,
a dedicated WebView2 user-data folder on Windows and a data directory on
Linux. Regular tabs are built from a configuration that carries that
store; popups keep inheriting their opener's. Clearing works on the
profile only (older macOS, where there is no separate store, removes
records per origin and spares the app's hosts).

That container is also where a proxy lives, so the built-in browser now
uses the app's "system proxy" setting — the same process environment the
app's own requests and agent processes see. macOS applies it live to the
profile store through `proxyConfigurations` (Network.framework resolved
at run time; the symbols exist only on macOS 14+), with loopback
excluded so local dev servers keep working; Windows freezes it in the
environment arguments of the first browser webview (a restart applies a
change); Linux passes it per window. `browser_capabilities` reports
`isolatedStorage` and the proxy status, and the settings section shows
a read-only "Network proxy" row.
The load watcher only raised an error when nothing had ever committed;
navigating an existing page to an address that fails just stopped the
spinner and left the old page, with no hint of what happened. Any load
that ends without the requested page now shows the error page for that
address (a failed reload of the page already showing still just stops
the spinner), reloads arm the watcher too, and retry re-navigates to the
failed address instead of reloading the document underneath.
A browser tab is one URL, so unlike a file tab it is worth bringing back:
the dev-server page next to the chat is exactly what a user looks for after
relaunching. Records are restored "not loaded" — the native surface of a tab
is created when it is first shown — so restoring twenty tabs costs twenty
small records and no webviews.

- `lib/browser/browser-tab-persistence.ts`: per-window-label localStorage
  list (url, title, folderId), versioned, sanitised on read so one corrupt
  entry cannot take the list with it. The stored address is the first web URL
  among the live one, the requested one and the record's.
- `BrowserTabsPersistence`: restores once per document, then debounces writes
  behind the tab store. Nothing is written before the restore has run, or the
  empty strip of a fresh document would erase the previous run's tabs.
- The surface ledger moves from the host component into the store, so a
  released tab is "not loaded" again and the same host resumes it.
- Optional background unload (off by default): a tab off screen for 30
  minutes has its surface released; its record keeps the page it was showing.
  The tab strip draws unloaded tabs faded.
- ⇧⌘T reopens a closed browser tab at the page it was showing.
P1 refused every download because there was no destination policy and no UI.
Both now exist: the engine transfers the file, the host decides only where it
may land and reports what happened.

- `browser/downloads.rs`: destination = the OS downloads folder (never the
  app's data dir). The name comes from the server (`Content-Disposition`), so
  only its last component is used, and only after `safe_file_name` strips
  separators, parent hops, drive letters, control characters and leading dots
  — a suggested name can only ever name a file directly in that folder. An
  existing file is never replaced: ` (1)`, ` (2)`… until the path is free.
- Records live in managed state and reach the frontend on `browser://download`
  when a transfer starts and when it ends. macOS reports no path on
  completion (WebKit API limit), so the destination chosen at request time is
  remembered and matched back by URL.
- Download bar per tab, hydrated from `browser_list_downloads` so a frontend
  reload does not lose a running transfer. It offers "show in folder" for a
  completed file and never opens anything.
- The settings section says where downloads land.
A navigation that turns into a download never commits a document, so the
load watcher saw "the requested address never arrived" and painted the error
page over the page the user was still looking at — found by downloading a
file from a directory listing in the dev app.

The tab now settles instead: the watcher is retired, loading and error are
cleared, and the requested address goes back to the document the tab is
showing. The download reports itself through its own bar.
The search is WebKit's own (`findString:withConfiguration:`), so the page
cannot see it, style it or break it, and the highlight is the engine's — the
bar only collects the query and reports whether the last step matched. No
match counter: the API answers "did this step find something", and a made-up
count would be worse than none.

⌘F reaches the bar from either side of the boundary. While the page has
keyboard focus the app's DOM never sees the keystroke, so the injected helper
reports it over the isolated-world channel as a `shortcut` message and the
host forwards `browser://shortcut`; the set of shortcut names is closed on
the host side, so whatever a page claims, only `find` can act. This is the
one place the helper cancels an event — the same claim every browser makes on
its own shortcut — and it leaves propagation alone.

Embedded surfaces only; an owned window is a plain WebviewWindow whose
WKWebView the host does not hold.
…and find

Independent review of the P2 batch, in three fenced slices. Every fix below
came with a concrete triggering sequence; the ones I pushed back on are noted
at the end.

Tab persistence and surfaces:
- `restoreBrowserTabs` merged instead of aborting. A tab opened before the
  restore ran (deep link, agent request — the capability probe is a round
  trip) made it skip the whole restore, and the next save then overwrote the
  stored set with that one tab. Now it appends what is not already open.
- Surface claims carry a token. `browser_open_tab` is a round trip: if the tab
  was closed while it was in flight, `browser_close` reached the backend
  first, did nothing (no registry entry yet), and the webview that arrived
  afterwards stayed painted over the workspace with no tab behind it. A holder
  whose token is stale now closes what it built.
- A mounted surface host re-creates its surface if the tab's live state
  disappears, and `suspendBrowserTab` refuses a tab that is on screen — the
  background unload could otherwise fire between a host's mount and its effect
  and leave the pane blank.

Downloads:
- Two downloads started at once could be handed the same destination: the
  free-path check cannot reserve on disk (the engines require a destination
  that does NOT exist), so the paths handed out are now held in memory until
  the transfer ends.
- A symlink in the way counted as "nothing here" (`exists()` follows it), so
  the engine would have written through it to wherever it pointed.
- `CON`, `NUL.txt`, `LPT1` and friends still name devices on Windows.
- The history cap could evict a RUNNING download, leaving its completion with
  nothing to update and the bar saying "downloading" for ever. Only finished
  records are evictable now, and the frontend store caps itself too.
- Dismissing one download's row dismissed every tab's rows.
- A download no longer retires the load watcher outright: it marks the load
  generation, and the watcher settles quietly only for that generation. The
  old version swallowed the failure of a LATER navigation whose load was in
  flight when a delayed download callback arrived.

Find in page:
- ⌘F was dead whenever the caret was inside an iframe: a keystroke only
  reaches the focused frame, and the handler required the main frame. The tab
  id comes from the source webview, not the payload, so a subframe still
  cannot speak for another tab.
- Searches are sequenced; a stale answer no longer relabels the current query.
- A timeout is an error, not "no match" — WebKit may highlight a match a
  moment later and the bar would have been saying the opposite of the screen.
- Pressing ⌘F with the bar already open re-focuses it.

Pushed back, with reasons: `BrowserTabView` is keyed by tab id at both mount
sites, so no state crosses tabs; and two downloads of the SAME url can still
swap their record labels, because the engine's completion callback carries
only the url — the files themselves land correctly.
Re-review of the previous fix found the race it left behind. A backend tab id
is reused across generations — a suspended tab is released and, when the user
comes back to it, created again under the same id — and both commands are
round trips the backend may run in either order. The close issued for
generation 1 could therefore arrive after generation 2 had registered and
destroy the live surface, leaving a tab that believes it is loaded showing
nothing.

Lifecycle calls for one id now go through a per-id promise chain, so they
reach the backend in the order they were issued. An idle chain still calls
straight through (closing a surface should not wait for a microtask), a failed
op does not stall what is queued behind it, and the chain is dropped once it
drains. The stale-token cleanup only closes when nobody holds the claim: if
the id was re-claimed, that owner's create is already queued behind us and a
close from here would land after it.

Also from the re-review: a download in an owned window left the tab loading
for ever. Owned windows have no load watcher — `is_loading` has no handle to
answer from — so nothing consumed the generation mark the previous commit
introduced. They settle immediately instead.

Verified while checking whether the download could be correlated by URL
instead: a navigation that 302s to a file reports the FINAL url, not the one
navigated to. Matching on the url would therefore put an error page over a
perfectly good page for every redirected download, so the generation mark
stays, with its known limit written down.
…overlays

Site rules (W2.5): a per-site table in Settings → Built-in browser — always
the built-in browser, always the system browser, or block — with the most
specific pattern winning (hostname, *.suffix, *, optional :port). The link
decision consults it; the backend enforces `block` on every navigation a tab
attempts, on pop-ups and on the open/navigate commands, showing a block page.
An administrator's policy file (`policy.json` in the machine-wide config
directory, or CODEG_POLICY_FILE) can fix rules and turn the feature off; its
rules show read-only in the settings section and are consulted first.

Load errors (W2.4): a wrapper around wry's WKNavigationDelegate reports
provisional starts and failures, so a failed load shows the right error page
(DNS / TLS / other, with the engine's own wording) the moment WebKit gives up
instead of after the load watcher's poll, and page-initiated navigations
update the requested address. A load WebKit refuses silently — a restricted
port commits an empty document instead of failing — is recognised by the
blank commit standing in for the page that was started. Refused top-level
navigations (a scheme not allowed in a tab, a blocked host) now show a notice
on the tab; a mailto:/tel: the page pointed at can be handed to the OS from
it. Frames may hold about:srcdoc, data: and blob: content, which the
top-level allow-list used to refuse.

Freeze frames (W2.4): hiding a surface under an overlay first captures the
page's last frame (JPEG through AppKit, a few milliseconds) and the
placeholder paints it until the surface shows again, so a dialog or menu
opens over the page rather than over a blank pane. A visibility request that
a newer one overtook while capturing is dropped instead of applied late.
…nd visibility

Site rules: a host is matched without its trailing dot and with IPv6
literals in the canonical form the URL parser uses, so `example.com.` and
`[0:0:0:0:0:0:0:1]` cannot slip past a rule for `example.com` / `[::1]`;
brackets in a pattern must hold an IPv6 literal; the frontend lower-cases
ASCII only, like the backend, so a Unicode fold cannot make a pattern one
side accepts and the other drops. A blocked address typed while another
page was still loading now stops that load and retires its watcher, or its
commit or failure would have landed on top of the block page.

Load state: a server redirect updates the address the tab is heading for
(the error page names the redirect target); a load the policy interrupted
— a refused redirect target, or a response that became a download — is
settled on the page the tab shows instead of being reported by the watcher
as an address that never arrived (measured: WebKit re-asks the navigation
policy for a 302 target, so a redirect to a blocked host is refused; it then
fails the load with WebKitErrorDomain 102, which is not a page failure). A
popup id that is already taken is skipped rather than replacing the live
surface behind it.

Visibility: hide/show requests for a tab now apply one at a time under a
per-tab lock in arrival order, so a show can no longer complete between a
freeze capture's sequence check and the hide it guarded (measured with 40
interleaved requests: the final state is the last request's).

Frontend: a click made in the first moments after launch, before the
backend has said what it can do and which hosts the administrator blocks,
is held until that answer is in instead of being routed on a guess (routed
to the system browser it would have slipped past a managed block); the
events bridge subscribes to preference changes before its first await, so a
rule written by the settings window during the capabilities round trip
reaches the backend, and a failed push is retried once; a stale show's
answer no longer wipes the frame a newer hide painted; the frame is dropped
when the error page takes the surface's place; the settings editor compares
patterns in normalized form and keys rows by position.
The cold-start deferral held every link until the backend had answered,
including mailto:, tel: and file paths, which do not depend on the answer
at all. Only an http(s) click waits now. The link-safety tests that click on
the desktop get an answered backend (no built-in browser), which is the
state the app is in whenever a user can click.
…hey match

Two spellings of one host (`[::1]` and `[0:0:0:0:0:0:0:1]`) can both sit in
the table; among equally specific rules the more restrictive action now
wins on both sides, and the settings editor recognises such duplicates by
what a pattern matches rather than by its text. A bracketed pattern must be
an IPv6 literal on both sides (`[*]` is not a wildcard).

The delegate wrapper now tells navigations apart by the WKNavigation object
WebKit hands to every callback of one navigation: a redirect, interruption
or failure reported for a superseded navigation — a download the previous
document started, an earlier load a newer one replaced — no longer settles
or fails the load in flight. A visibility request also remembers which
incarnation of a tab id it was made for, so a hide still capturing its frame
when the tab is closed and reopened under the same id cannot apply to the
new tab; a destroyed window's tabs drop their visibility locks with them.
…hanges atomic

`str::trim` and `String.prototype.trim` disagree on U+0085 and U+00A0; a
pattern one side parsed and the other rejected was a rule the frontend
silently ignored. Both sides now trim ASCII whitespace only.

A visibility request takes the surface and its stamp in one registry
operation and checks the stamp in the same operation that records the
change, so a close-and-reopen of the same tab id cannot pair an old surface
with the new tab's stamp; a tab's visibility lock is deleted while the tabs
lock is still held, in both removal paths, so a tab inserted under the same
id in between keeps its own lock.
Rust's is_ascii_whitespace does not include vertical tab; the frontend's
trim class now matches it character for character.
clippy's type_complexity on the (kind, length, port, restrictiveness)
tuple; behaviour unchanged.
Conflicts and how they were settled:

- link-safety.tsx — the branch moved the parsing helpers into
  `@/lib/link-classify`; main added `canOpenLinkOrFile` next to them. Kept
  the extraction and re-implemented `canOpenLinkOrFile` on the imported
  helpers, so the composer's right-click "Open link" row still has its
  gate.
- terminal-view.tsx — both sides landed on the same line: the branch's
  link-decision refs and main's `ignoreAppZoom` zoom derivation. Both kept.
- workspace-context — both sides added tab work at the same import and
  the same two `describe` blocks got interleaved. Split back apart.
- i18n × 10 — main's new canvas keys close the canvas section; the
  branch's `Browser` section follows it.

Integration the merge itself required: main's "a reopened tab goes back to
the slot it was closed from" now covers browser tabs too. They share the
file strip and the same reopen shortcut, so `ClosedBrowserTab` carries an
`index`, the three close paths pass the slot they already hold, and
`openBrowserTab` takes an `index` the way `openFilePreview` does.

Two tests moved with the code they cover: the composer's "Open link" row
lands on the system browser through the branch's link decision (web mode →
`window.open`) rather than main's direct `openUrl`, and the controller
source test counts four reopen call sites now that browser tabs are one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aring it

Third review round of the profile work.

- An admission is shared with the engine's completion callback on macOS,
  so a clear whose caller stops waiting still counts as in flight until
  WebKit is done; on Windows and Linux the surface API reports no
  completion, and the admission ends when the call returns (a deletion
  that follows at once may have to be retried, which the comment states).
- Frontend: a record whose surface is being created is not dormant and is
  left alone when its profile disappears; suspending a tab of a deleted
  profile drops the record when the default profile already shows that
  page; the viewer drawer keys its remembered record per body instance,
  forgets it on unmount, and once resolved shows that record or nothing.
  The popup-precedence test picks the default-profile opener explicitly.
Fourth review round of the profile work (frontend only).

- Suspending a tab of a deleted profile checks for a duplicate in the
  default profile inside the state updater, against the list as it is
  when the update applies, and moves the active-tab pointer to the
  neighbouring survivor the way closing a tab does.
- The viewer drawer's body is keyed by URL, so a drawer handed another
  address starts a fresh body and never shows the record it remembered
  for the previous one under the new header.
First build of the branch by a Windows compiler. `doc_open_core` left its
surface untyped on any platform but macOS (a diverging cfg block), and the
imports that only the macOS popup path uses were unconditional; the doc
comment of `build_child` had drifted onto the Windows web-context
thread-local. The document guest now compiles wherever the child surface
does (it stays refused at runtime by `doc_guest::supported`), and
`doc_guest`'s `Duration` import follows its unix-only use.
Every embedded-surface call that is not a plain wry one answered "not
implemented on this platform yet" here: no page channel (so no SPA address
sync, no gesture ring, and therefore every page-initiated window denied as
"no-gesture"), no history, no stop, no load state, no snapshots, no find, no
typed load failures, and no user-agent exception. `shim/windows.rs` fills them
in against WebView2, next to the macOS shim and behind the same contract.

The page-to-host channel is a CDP isolated world named `codeg`:
`Page.addScriptToEvaluateOnNewDocument {worldName}` injects the helper and
`Runtime.addBinding {executionContextName}` puts the send primitive in the same
world, so Windows needs no prefix script. Every step is awaited before the
caller navigates, and the message loop is pumped without the surface map held,
because a tab closing on that pump would find it locked. Which frame spoke is
decided from `executionContextCreated.auxData.frameId` against the top-level
frame id -- never from the envelope's own `top` field, which the page's frame
controls, and an unknown context is not the main frame.

An adopted popup's first document is already on its way when the engine hands
the webview over, so the injection cannot reach it and that one document would
have no channel for its whole life. It gets its world from
`Page.createIsolatedWorld` once it has settled; a binding registered by world
name is in it.

Popups are adopted the way they are on macOS -- the engine navigates the
webview it was handed, so `window.opener`, `Referer` and `noopener` are the
page's own -- by building the child in the opener's environment, which is what
`NewWindowResponse::Create` requires.

Load failures are the engine's `NavigationCompleted` status, with one Windows
judgement on top: the status says "connection aborted" both for a load that
broke and for a navigation that was abandoned because it became a download or
was superseded. Chromium commits its own error page for the first and nothing
for the second, so a failure is only reported when the address that failed is
the one now showing. A stop the host asked for is marked and never reported.
Without this the tab reported a perfectly successful load of an error page the
user never asked for, or covered a page they were reading with an error page
because they clicked a download link.

Subframes get their own fence: `NavigationStarting` is main-frame only, so an
iframe was outside the scheme list and the `block` site rules altogether. Every
frame is caught as it is created, frames of frames included, and asked the same
question the main frame's handler answers.

The sign-in identity takes two calls, because they answer different questions:
`put_UserAgent` is what the page sees and does reach the document being
navigated to, but not the request already in flight, and
`NavigationStarting`'s request headers are read-only. What goes on the wire is
written per document request in `WebResourceRequested`, in both directions, so
a navigation away from a sign-in host does not carry the borrowed identity out
with it.
A tab whose isolated-world helper never got installed still opens,
navigates and renders — it just quietly stops blocking nothing and
opening nothing: pop-ups the page asks for are all refused as
`no-gesture`, the address bar stops following in-page navigation, and
the find shortcut pressed inside the page goes nowhere. Until now the
only trace was a `tracing::warn!` nobody reads.

`BrowserTabState` carries the engine's own words for the failure in
`channelError`, filled at each of the three installs (tab, document
guest, adopted pop-up) and cleared when the helper's `hello` proves the
round trip. The notice bar says what the tab lost, once the page has
finished loading and the channel has stayed down for a moment — every
tab is degraded until that first `hello`, so the wait is what keeps the
bar off an ordinary open. The engine's words go in the tooltip, for a
bug report rather than for reading.

`browser_capabilities` stops answering `degraded` with "page channel not
installed yet": it now reports what a new tab will be given, which is
the native channel wherever the embedded surface is compiled.
`listeners_close_when_released_and_idle` measured two stretches against
one `Instant`, and between them it waits on a port that was just closed.
macOS refuses that connection in milliseconds; Windows takes about two
seconds, which is more than the one second of margin the last sweep
leaves — so the held listener looked one second too young to close and
the test failed on Windows every time.
The second download a page starts raises a WebView2 permission request,
and until it is answered the engine draws its own bubble over the page
and holds `DownloadStarting` back. Inside an embedded tab that bubble
belongs to nobody: it is not in codeg's design language, it lands
wherever the page happens to be, and codeg never hears about it — so a
user who clicked a download link saw nothing happen at all, and the
download bar stayed empty.

The host answers it now, for that one permission kind only; a camera or
a microphone is the user's to grant and the engine's prompt is the right
place to grant it. The test is the popup blocker's: a tab that was
clicked recently is doing what the user asked, and every file it takes
shows up in the download bar. One that was not is refused and says so in
the notice bar. The window is longer than the popup's second because a
"download all" button fires its requests over the time the server takes
to answer each one, and the engine only asks once the second download is
already on its way.

The answer is deliberately not saved in the profile. The engine would
otherwise write the first one down and stop asking, freezing whichever
way one page happened to be treated — and what this weighs is the click
that came before the request, not the site.
The guest's code path was written for both platforms and had never run
on Windows, where it sat on `about:blank`: WebView2 has no custom
schemes, so wry serves ours from `http://codeg-doc.<host>/…` — a rewrite
it applies to a webview's INITIAL url only, and a guest is built empty
and navigated once its grant is bound. The engine was handed a scheme it
does not know. `engine_url` rewrites the one address that goes to the
engine; everything else — `is_document_url`, the CSP's `'self'`, the
handler reverting the request it receives — already read both spellings.

With that, everything the guest promises holds there: relative
stylesheets, images and `fetch` resolve on its own origin, safe mode
runs no script, dynamic mode does, a path that climbs out of the root is
not served, and the network is refused by `connect-src 'self'`. Its
storage is a private one, apart from the browsing profile.

One difference from macOS is worth knowing: two guests alive in the same
run share that private storage, because Windows partitions it per
environment while macOS gives each guest a data store of its own. It
takes dynamic mode on both sides to matter, and the fence around the
network and the filesystem is unaffected — but a per-guest document host
would settle it on both platforms and is the better fix when someone can
test the macOS half.
"Show in folder" did nothing at all when the download folder is a share
or a redirected location: the opener plugin resolves the path before
handing it to the shell, and on Windows that produces the extended
`\?\UNC\…` form, which `ILCreateFromPath` refuses. Nothing was reported
either — the click just went nowhere.

Falling back in the frontend alone does not reach: opening the
containing folder is scoped to `$HOME/**`, and the paths that need the
fallback are exactly the ones outside it. So the fallback asks the
backend, which reveals the path IT recorded for that download — the
caller names a download, not a path, so nothing new becomes reachable
through it.

Two Windows details are load-bearing. `/select,` wants the path quoted
inside its one argument, a shape `Command::arg` cannot produce, hence
the raw command line. And Explorer does not follow `/select,` into a UNC
path at all — it drops the argument and opens a default folder — so a
network location gets its folder opened instead of the file selected,
which is the whole errand minus the selection.
A guest may navigate to its own addresses, and on Windows those are
spelled as an ordinary http host, so the check accepted anything that
began with that prefix — `https://codeg-doc.example.com/` included.
Any registrable domain could therefore be a guest address, which is how
a document the user allowed scripts for would send what it read to
whoever wrote it. It is the one mapped host now.

Two smaller ones from the same review: the quote that a hand-built
Windows command line cannot take is refused on Windows alone, where a
downloaded name cannot contain one anyway, rather than on platforms
where a quote is an ordinary character and the argument is passed
structurally; and a backslash only ends a path component where no slash
does, so a unix file named `report\\2026.pdf` falls back to the folder
it is really in. The degraded-channel bar no longer says navigation
inside the page is limited — it works, it is the address bar that does
not follow it.
…its own

The first cut of this fallback took the last separator of either kind,
which mistakes a backslash in a unix file name for a folder boundary;
the second took backslashes only when the path had no forward slash at
all, which loses the last component of a Windows path that mixes them
(Windows accepts both). A path that announces itself as Windows — a
drive or a UNC share — is now cut at whichever separator comes last,
and any other path only at a slash.
Reaping a watch child queued kill + wait on a detached task. The
callers who most need the kill are the ones whose runtime is about to
go away — a shutdown, a test returning — and a queued task is dropped
unpolled there, so the child outlived the reap. It showed up as `sleep
600` processes surviving `cargo test`; because they inherit the
runner's stdout, `cargo test | tail` then never saw EOF and looked
like a hung suite. The real watch child was covered anyway by
kill_on_drop, which is a second net and was doing the first net's job.

The signal now goes out on the calling thread and only the `wait()`
that keeps a killed child from lingering as a zombie stays on a task,
where losing it at shutdown costs nothing. The test sleepers are
kill_on_drop too, like the real one, so a test that panics before it
removes its entry cannot leave one behind either.
Installing the page channel was injecting the helper first and registering
`__codegSend` second, and every step of the install waits for WebView2 by
pumping the message loop. An adopted popup's first document is already on its
way when the engine hands the webview over, so it can commit inside that pump
— and with the injection registered first it commits into a `codeg` world that
has nothing to send through. That document then has no channel for the whole
of its life: the world exists, so the recovery that builds one for a document
the injection did not reach never fires, and the tab sits silently degraded.

`Runtime.addBinding` takes a world NAME and does not need the world to exist,
which is also how the recovery path already gets its binding. Registered
first, a document that commits in the window has no `codeg` world at all —
which is exactly the state the recovery knows how to fix.
…commits

The identity a page is shown is set as its navigation starts, on the address
it is heading for — the only moment early enough to reach the document it
commits. A navigation that commits nothing leaves that setting behind on an
identity meant for a page that never arrived, and the document still on
screen goes on answering `navigator.userAgent` with it and sending it with
everything it asks for afterwards: a sign-in host's borrowed identity left
over an ordinary site, because the user pressed stop or the response turned
out to be a download.

Each of the three ways a navigation ends without replacing the page now
decides the identity again, from the document that is actually showing.

A load replaced while still in flight is told apart by the engine's own
navigation id. The newer navigation owns the load flag, the address in
flight, and the identity — the superseded one may not take any of them back
on its way out, nor report a failure for an address that is no longer the
one being loaded.
Whether a message came from the main frame is decided from the execution
context the engine reports it in, and that is the one thing about a message
the page cannot influence — so the map from context to frame has to be right.
CDP hands out context ids per renderer process, each starting over, and the
map only ever lost an entry when the engine said so. An entry nobody retired
could therefore be answered for by an id that now belongs to some other
frame.

A frame holds one `codeg` world at a time, so recording a new one retires
whatever older entry named that frame: its document was replaced, or the
renderer that owed the destruction notice went away without sending it.
Either way it is gone. This also settles which context the main frame's
world is, where two entries for one frame used to be picked between by hash
order.
…t there

The recovery that builds a `codeg` world by hand for a document the
injection could not reach was gated on `channel_installed`, which
`install_world` only sets at the very end — so through every message pump
the install waits on, the one thing that could rescue a document arriving
in that window was switched off. The gate also sat below the check that
drops a superseded navigation's completion, and that check is about the
navigation, not about the page: a load replaced while in flight leaves on
screen the document it did not replace, which then keeps no channel at
all if its own completion was the superseded one.

Both are now one question — does the document on screen have a world —
asked where the answer can have changed: at the end of the install, and
at every completion before anything navigation-specific is decided. What
it must not do is build a world for a surface that has simply never
navigated, so the top frame's first commit is recorded and a webview
still on the document it was created with is left alone.

A context is also keyed by the CDP session that reported it. Ids are
handed out per renderer and start over in each, so an id alone did not
name a context, and a frame in a renderer of its own could hold the
number the page's world has — in a map that decides whether a message
came from the main frame.
Linux has no embedded surface — a child webview cannot be positioned
there — so a tab is an owned top-level window, and until now that was a
window with nothing behind it: no page channel, and therefore no gesture
ring, no address bar that follows the page, no shortcut the page had
focus for; no history, no stop, no find, no snapshot, no freeze frame,
no sign-in identity; and one wording for every load failure, because wry
forwards no failure at all and WebKitGTK quietly replaces the document
with an error page of its own.

The window now carries the shim itself. The helper and the primitive it
posts through go into a WebKitGTK script world named `codeg`, which is
the same arrangement macOS has and spells the send primitive the same
way, so both share one prefix script. What differs is how a message is
known to be the page's own: a `script-message-received` reports a value
and nothing about the frame that sent it, so there is no frame to check
against. The helper is therefore injected into the top frame ALONE, and
the handler it posts through is reachable from the `codeg` world alone —
a subframe has no sender rather than an unverifiable one.

The engine webview is taken hold of once, at creation, because tauri
hands it out on the main thread only and it cannot cross threads; two
callers need it synchronously from that thread, one of them the
navigation decision, which is where the sign-in identity has to be set
for it to reach the request.

Two things the engine does not make possible here, rather than pretend
otherwise: it reports no frame for a navigation decision, so the
top-level scheme list applies to every frame (a `data:` frame is refused
— quietly, since a refusal a frame would have been allowed is not worth
a notice, and widening the list would let a page put `data:` in the
address bar); and a name that does not resolve is only told apart from
any other transport failure when the resolver's own error survives.

The branch did not compile for Linux at all before this: the document
command's surface block is nothing but a `return` there, and a diverging
block tells the compiler nothing about what the rest of the function is
holding.
…verywhere else

`window.open` did nothing at all on an owned window: nobody answered the
engine, so the request was dropped without a word — and with no channel
there was no gesture to judge it by anyway. Both are in place now, so the
answer is the one the embedded surfaces give: the scheme list, the site
rules, then a user gesture the page can point at within the same second,
and only then a window of its own, registered as a tab beside the one
that asked and reported as adopted or denied with a reason.

The window is built RELATED to the opener, which WebKitGTK insists on for
a page-initiated window and which is also what carries `window.opener`
across — so the engine navigates it itself and `Referer` and `noopener`
stay the page's own business. A sign-in flow that hands off to a popup
works on Linux for the first time.

The gesture window moves to `policy`, where both surfaces can ask it.
…ge be searched

`browser_capabilities` described an owned window by what the macOS and
Windows fallback can do, and the find bar was closed for one on every
platform. On Linux the owned window is the surface the shim is written
for: it answers find, history, stop and snapshots, and its page talks to
the host. The frontend now asks rather than assumes, and ⌘F in a Linux
tab opens a bar that searches the page in the window it is showing in.

The sign-in identity's capability follows the same correction: what it
needs is a navigation hook, which the owned window has on Linux.
…ad the mark

A navigation that turns into a download leaves a mark the failed-load
watcher reads, so it does not paint an error over a page the user is
still on. Where no watcher runs the mark is never read, and the tab has
to be settled on the spot or it spins for ever — which the code decided
by asking whether the surface was embedded.

That is no longer the same question. An owned window on Linux answers a
load state now, so its watcher stays alive and consumes the mark, while
the same window on macOS and Windows still cannot. Asking the surface
for the load state asks the thing that actually decides.
Review of the Linux surface, and of the Windows fix before it. Six
things, the first of which made most of the Linux work unreachable.

- A tab installed the page channel only when its surface was embedded,
  which used to be the same question and no longer is: the owned window
  carries the channel on Linux. Every ordinary Linux tab was therefore
  left degraded — no gesture ring, so every `window.open` denied for
  want of a gesture, no address bar following the page, no shortcut. The
  surface is now asked whether it can carry a channel, and the same
  answer feeds the capability the frontend reads.

- The sign-in identity is withdrawn on Linux. WebKitGTK's user agent
  belongs to the whole webview and its navigation decision does not say
  which frame is asking, so any iframe — one a hostile page adds on
  purpose, or an ordinary embed — could put the borrowed identity on the
  top-level page's own requests, or take it off in the middle of a
  sign-in. There is no main-frame hook to hang it on, so it is not
  offered rather than offered wrong.

- The helper now goes into every frame as well as the top one, posting
  through a second handler the host reports as not-the-main-frame. The
  top-frame-only injection was sound but cost what macOS deliberately
  keeps: a keystroke reaches only the frame that has focus and a click
  inside an iframe never reaches the top document, so find was dead and
  every popup an embedded page asked for was denied. `channel.rs` gates
  the second handler exactly as it gates macOS's subframes.

- A failure reported once the document had committed was marked
  provisional, which paints an error page over the page the user is
  reading when a connection drops mid-body.

- The window's destruction hook is attached before the engine webview is
  taken hold of, and an entry replaced under a label lets go of its
  webview's state — that state is keyed by a pointer the allocator can
  hand out again.

- A popup recorded devtools as off however it was built, so its own
  popups inherited the wrong setting.

On the Windows side: recovery is held back until `Runtime.addBinding`
has run, because a world built before that has no send primitive in it —
the helper gives up, and the empty world it leaves behind is one nothing
would ever build again; a look turned away while an attempt is in flight
is taken once that attempt fails; an adopted popup is told it is one,
since `window.open()` with no address commits `about:blank` and nothing
else can tell that from a webview that never navigated; and an event
whose CDP session cannot be read is dropped rather than counted as the
page's own.
…hich script ran first

Both injected copies run in the top frame, and the one meant for
subframes kept the privileged primitive only because it happened to be
registered second. Which of them the engine runs first is not ours to
promise, and if it ever ran the other way the page's own frame would
report itself as a subframe: no address bar following it, no hello, a
tab degraded for good. It now asks whether it is the top frame.
A recovery answers with the world the engine built when it ran the
command, not when the answer arrived — so if the document was replaced
in between, that world belongs to a document that is gone. The look
turned away while the attempt was in flight then consumed its one retry
against a map that still names the dead context: the engine answers
commands on one channel and reports a context's death on another, and
they need not arrive in that order.

That one look no longer trusts "the main frame already has a world".
Building a second time asks for a world of the same name, which is the
same world, and a helper that has already run is one its own guard turns
away — so the cost of being wrong is a round trip, and the cost of being
right is a tab that talks.
`[target.'cfg(target_os = "linux")'.dependencies]` is unconditional on
features, so the webkit2gtk / gtk / javascriptcore-rs entries added for the
Linux shim applied to `--no-default-features` too. `src/browser` is gated on
`tauri-runtime` and compiles nothing there, but cargo still ran the gtk-sys,
gdk-sys and atk-sys build scripts, and those need `.pc` files:

    The system library `atk` required by crate `atk-sys` was not found.

Neither place that builds codeg-server installs GTK — the CI cell's apt step
is gated on `matrix.mode == 'desktop'`, and the Dockerfile's backend stage
takes only pkg-config and libssl-dev. So this broke the server cell and the
Docker image, on a dependency line for code that build cannot reach.

Optional, and named in `tauri-runtime` beside the other desktop-only
dependencies. Verified by checking the Linux server target against a
pkg-config directory holding openssl and zlib alone: no GTK probe happens.

The comment also claimed these matched "the feature set wry 0.55 uses".
It does not: tauri-runtime-wry turns on wry's `linux-body`, which enables
`webkit2gtk/v2_40`, two steps above the `v2_38` declared here.
…form it is

`capabilities_report_the_page_channel_the_surface_brings` still encoded the
rule that a page channel travels with the *embedded* surface. That stopped
being true when the Linux owned window learned to install the world itself:
on Linux `CHILD_SURFACE_COMPILED` is false, so the test took its else branch
and demanded `Degraded` from a build that correctly answers `Native`.

This is the same stale equivalence review caught in `commands/browser.rs`
one round earlier — "is embedded" and "has a channel" used to be one
question. The test kept the old one.

`capabilities` asked `cfg!(target_os = "linux")` for the same fact that
`surface_window::HAS_CHANNEL` already states, and that `owned_window_controls`
reports to the frontend three lines below. Both now read the constant, so the
knowledge of which surfaces carry a channel lives in one place.
`v2_38` was picked to "match wry" and matched nothing: tauri-runtime-wry
enables wry's `linux-body`, which turns on `webkit2gtk/v2_40`. Cargo unifies
features, so every build already compiled the shim against 2.40 while the
manifest claimed two steps lower.

Naming the real level costs nothing today — no distribution that can build
codeg at all is excluded, because wry has been asking for 2.40 the whole
time. What it buys is a comprehensible failure later: if wry stops enabling
`linux-body`, this is a pkg-config version error naming webkit2gtk rather
than a missing symbol somewhere in `shim/linux.rs`.
W3.1 of the built-in browser plan: the snapshot engine, as a bundle for the
isolated world. Nothing reads it yet — see "what is not here" below.

`browser-agent/vendor/playwright/` is Playwright's aria tree at v1.63.0, the
nine files that make up the import closure of `injected/ariaSnapshot.ts`,
copied byte for byte. `src/index.ts` calls it in `ai` mode — the mode
Playwright MCP uses — so an agent reading a codeg tab reads the shape it
already knows, and puts `snapshot` / `elementForRef` on `__codegAgent` for
world-scoped eval to reach.

Not one vendored line is edited, which makes an update a copy and a diff
rather than a merge. Three things buy that: esbuild transpiles without
typechecking, so Playwright's sources need not answer to our compiler
settings; the bundler resolves their `@isomorphic/…` alias instead of us
rewriting their imports; and `browser-agent/` is excluded from the app's
tsconfig and from eslint's view of the repo.

The plan budgeted a second DOM pass here, to promote `cursor: pointer` /
`onclick` / `tabindex` elements that carry no ARIA role. It is not needed:
`ai` mode refs every element that is visible and receives pointer events, so
a roleless clickable div is already namable and already carries
`[cursor=pointer]`. The probe asserts exactly that, so the day upstream
changes its mind we find out from a failing check.

Two things it does that upstream does not:

- **Refs survive being wrong.** Playwright's counter lives in the module, so
  each document starts again at `e1` and two pages name different elements
  the same. Every world draws a generation and reports it with the snapshot;
  a ref quoting an older one is refused, not resolved onto whatever is now
  `e1`. Navigation destroys the world, so this is automatic.
- **The tree can be capped.** `maxChars` cuts on a line boundary — a
  half-written node reads as a real one — and says `truncated` so the caller
  knows the page did not simply end.

Verified in a real engine, not jsdom: under jsdom every box is zero-sized, so
`ai` mode yields a tree with no refs at all and proves nothing. `pnpm
browser:agent:probe` drives the committed bundle in headless Chrome over CDP
and checks nine things, including that the page's own world cannot see
`__codegAgent`. The unit tests cover the part that is ours and pure.

The bundle is committed so a cargo build never needs node, and CI checks it
against its source — nothing else in the build would notice a stale artifact,
since the Rust side will happily embed last week's bundle.

**What is not here:** any way to reach it. The Rust seam is where
authorization lives — `none / read / control`, per tab and origin, no
automatic grants — and a page-reading path that predates the grant model is
exactly the hole the red lines exist to prevent. The bundle and the grants
land together, in the package that adds the `browser_*` tools.
Review found the snapshot's refs outliving a same-document navigation. The
generation only changes with the document, and `pushState`, `replaceState` and
a hash change leave the document, the world and the module exactly where they
were while the page becomes a different page. That is a route change in a
single-page app — most of what a dev server serves — and the nodes a framework
keeps across one, a header and its buttons, are exactly the ones still
`isConnected` afterwards. An agent could act on `e8` from the page it read
while looking at the page it did not.

A snapshot now records the address it was taken at, and a ref is refused once
`location.href` has moved.

That is a floor, not the contract, and the difference is worth stating because
it decides where the rest belongs. An address is not an identity: a route that
goes A → B → A comes back to a string that matches. Nor can this world learn
that it happened — patching `History.prototype` in an isolated world patches
that world's prototype while the page calls a different function object, the
same isolation that keeps `__codegAgent` out of the page's reach. The probe
now measures this rather than asserting it: it patches `pushState` in the
world, has the *page* navigate, and checks the patch never fired while the
address moved anyway.

So `snapshot({ epoch })` takes an opaque host token into the generation an
agent echoes back. The host is the only party that sees these transitions, so
deciding when refs die is the host's, and enforcing it is split: the host
refuses on the spot by comparing the epoch it embedded, this world refuses
from the next snapshot on, because until then it has learned nothing. The
comment says exactly that — an earlier draft claimed the world invalidates on
an epoch change alone, which it cannot.

Also from review, both smaller:

- The probe put `cursor:pointer` and `onclick` on one element, so it could not
  show either mattered on its own — and "upstream already names these" is the
  whole reason there is no promotion pass here. Three separate roleless divs
  now, each asserted namable alone, plus that `[cursor=pointer]` is rendered
  on the pointer one and not on the handler one.
- `truncate`'s comment and a test name said the first line is kept whole when
  it alone exceeds the cap. The code cuts it, which is right — the cap is the
  caller's bound — so the words changed, not the code.
- An empty `epoch` was read as no epoch. It is a value the host chose; only an
  absent one is absent.
The snapshot engine has had no consumer since it landed, deliberately:
the seam where it would be called is the seam where authorization lives,
and a page-reading path that predates the grant model is the hole the
rule exists to prevent. This adds the model, and the one read it allows.

Nothing is granted automatically. Not by address class — a private
address says nothing about whether the page behind it is signed in. Not
by which process is listening — a `socat` in front of the real server
owns the port just as convincingly. Not by an allow-list — a same-origin
substitution is invisible to any re-check. A tab the agent opened itself
is no different from one the user opened. Reading needs a grant as much
as acting does: the pages worth protecting are the ones a tree would
describe in the most detail.

The grant lives on the tab rather than in a store of its own, so that
the code which learns a tab changed origin is the code that revokes,
under the lock it already holds. `revoke_if_departed` is called from
both places that write `state.origin`. That is complete because the
engine refuses a cross-origin `pushState`: the one class of navigation
the host learns about late is the one class that cannot cross the
boundary a grant is bound to.

`agent_snapshot_core` checks the grant twice. The first check is a
refusal before the page is touched at all. The second is the one that
decides, because a read is not instantaneous — the user can revoke, the
tab can be closed and reopened under the same id, the page can go
anywhere — so the tree is handed over only if the grant as it stands now
still covers the address the world reports having walked. Not the
address the host last heard about, which is the one that can be stale.

The bundle is evaluated into a document lazily, and only after the grant
check passes, so a tab nobody has shared contains no page-reading code
at all. It goes in wrapped in a function body: the shim evaluates an
expression, and a hundred kilobytes of generated program is not one.

Verified end to end against WKWebView through the smoke puppet: a read
refused before sharing; a share bound to `http://127.0.0.1:8790`; a
four-ref tree in 6 ms; the page's own `pushState` moving the host epoch
and the world refusing the ref from before it; a same-origin navigation
keeping the grant while the world's generation changes; `127.0.0.1` to
`localhost` on the same server — a different origin — revoking it and
refusing the next read; a reopened tab id starting with nothing.

The epoch narrows the window on a route that leaves and returns; it does
not close it. The host hears about same-document navigation only through
the helper's poll. Closing it belongs to the world, which can watch
`history.length` and `popstate`, and to the package that acts on refs,
where a stale one costs a wrong click rather than a confusing tree.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant