Skip to content

cef agent-control P2: multi-tile CDP relay multiplex (+ mandatory token, P1 multi-view base)#3

Merged
wenkaifan0720 merged 7 commits into
mainfrom
resume/p2-multiview
Jun 19, 2026
Merged

cef agent-control P2: multi-tile CDP relay multiplex (+ mandatory token, P1 multi-view base)#3
wenkaifan0720 merged 7 commits into
mainfrom
resume/p2-multiview

Conversation

@wenkaifan0720

Copy link
Copy Markdown
Collaborator

Summary

Lands P2-step2 multi-tile agent-control: N cefWebview tiles sharing one
cef_host (one named profile) can each be agent-controlled concurrently,
each over its own token-gated CDP relay, all multiplexed over the single
browser-wide --remote-debugging-pipe. This completes the multi-view arc
(shared login across tiles + relaunch) while keeping hard per-tile isolation:
an agent holding tile A's token can neither observe nor drive tile B.

This branch is the full unmerged stack (intended to land together):

Commit What
59312f4 relay token mandatory on the ws upgrade (defeats localhost port-scan)
6da82a7 P2-step1: persistent-profile multi-view rendering (single-view guard lifted; N views share one host + cookie jar)
4d72f83 P2-step2: per-browserId CDP-relay multiplex (cdpRelays:[UInt32:CdpRelay], fan-out onCdpMessage, per-relay CDP-id rewrite for browser-level commands) + shutdownAllHosts on app quit
cdae93c multiplex isolation unit tests + a testable demuxPipeToClient seam
05357b1 live two-view isolation probe (example harness)
b377e61 docs reconciliation (README / CHANGELOG / comments / spec)
b8db708 concurrency + teardown hardening; epoch-guard the targetId resolve timer

How isolation holds

Each relay is pinned to one scopeTargetId. Inbound pipe traffic is scoped by
sessionId (deny-by-default, flatten-only); browser-level commands (no
sessionId — e.g. Playwright's connect handshake) are disambiguated by a
per-relay CDP-id rewrite (pipeId = relayId<<21 | localSeq, demuxed back to the
issuing relay). A sibling's response/event is dropped, Target.getTargets is
synthesized to only the own target, and browser-context-wide CDP stays refused.
On host quit every cef_host is SIGTERM-reaped (no orphan holding the profile
SingletonLock).

Verification

Staticflutter analyze clean · 121 Dart tests pass · CdpRelay
filter+multiplex tests (68 checks) pass · native plugin compiles.

Live (example/lib/multiview_probe.dart, ad-hoc and signed cef_host):
two views on one shared host → one cef_host; concurrent enableAgentControl
distinct ports+tokens; each relay's getTargets exposes only its own
target; tile A's token rejected on tile B's port; disable A kills only A's
grant while B keeps driving; graceful quit reaps the host.

Before merge / known items

  • Test H — notarized GPU gate. Under a signed (CEF_HOST_ADHOC=OFF)
    cef_host, Chromium 144's enforced Mach-port peer-validation -67030s the
    GPU→browser handoff unless the whole tree is notarized (not reproducible
    with a non-notarized local harness; Developer-ID signing alone is insufficient).
    Confirm two tiles both GPU-render on a notarized Codemagic macos-release
    build. Low risk: the shipped release already passes this validation for the
    existing single webview, and P2's 2nd browser shares the same GPU process.
  • Canvas-side integration in work_canvas (pin this branch; drive two
    cefWebview tiles via agent-browser) — also the right place to validate
    PLAN Test G (reader-stall isolation), which the bare probe can't faithfully
    reproduce.
  • Pre-existing (not a P2 regression): re-enabling agent-control on a tile
    after disabling it hangs — the 2nd per-browser Target.getTargetInfo resolve
    never returns. Root cause is in cef_host's targetId re-resolution; needs a
    focused fix. Recorded as a non-fatal probe diagnostic.

🤖 Generated with Claude Code

wenkaifan0720 and others added 7 commits June 17, 2026 20:46
…t port-scan)

The per-tile CDP relay's token was "validated if present" — a tokenless
connection was accepted, because vanilla agent-browser connects by bare
`--cdp <port>` and attaches no secret. That left the classic attack open:
malware scans 127.0.0.1, finds the ephemeral relay port, and connects to
drive the page.

Make the token MANDATORY: the ws upgrade is rejected (401) without a valid
`Authorization: Bearer <token>` (a `?token=` query is an accepted fallback).
Playwright forwards request headers on the upgrade, so the integrator's CDP
client presents it via connectOverCDP({ headers }). Discovery (/json/*) stays
token-free, so a port-scanner learns the ws-url but can't upgrade — it never
sees the token. The integrator (Campus) delivers the token to its CDP client
in memory (never disk/argv/env), which also closes the same-UID case.

- tokenAcceptable now takes the header map; accepts Authorization: Bearer
  (preferred) or ?token= (fallback); rejects absent/empty/wrong; constant-time.
- readRequestHead rejects a 16 KiB-truncated head instead of parsing a partial.
- Filter test suite: +18 token cases (header/query precedence, last-token-wins,
  empty/bare/lookalike query keys, tab-vs-space, Bearer case/empty) — all green.
- Docs (README / CHANGELOG / agent-control PLAN) updated to the mandatory-token
  model (the old "validated if present / Playwright can't attach one" notes were
  superseded — connectOverCDP({ headers }) works).

NOTE: this must land in Campus together with the agent-browser-broker change
(Campus presents the token); a Campus that hasn't updated would be rejected.

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

Two changes that unblock Campus's shared-login web tiles:

1. cef_host: in agent-control (remote-debugging-pipe) mode, also pass
   --disable-blink-features=AutomationControlled so navigator.webdriver is
   false. Chromium flips it on under remote debugging, which makes Google's
   OAuth (and similar) refuse human sign-in ("this browser may not be secure").
   We drive over CDP, never WebDriver, so suppressing it costs nothing.

2. plugin: lift the single-view-per-named-profile guard. resolveOrSpawnHost
   already de-dups the host by profile key + serializes early creates via
   pendingCreates, and cef_host already multiplexes N browsers (Slot/wire_id)
   over one process sharing one cookie jar — so N views on a named profile now
   share ONE cef_host (all render, one shared login) instead of colliding on
   Chromium's SingletonLock. Agent-control stays single-relay per host for now
   (2nd concurrent agent tile gets a clear "already active" error); concurrent
   multi-tile agent-control follows with the per-target CDP relay demux.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…relay multiplex

Lifts the one-agent-tile-per-host limit: N tiles sharing one cef_host (one
named profile) can each be agent-controlled concurrently, each over its own
per-target CDP relay, all multiplexed over the single browser-wide
remote-debugging-pipe.

CefProfileHost: replace the scalar cdpRelay/relayBrowserId with
cdpRelays:[UInt32:CdpRelay] keyed by browserId; onCdpMessage fans every pipe
line out to all relays (deliverCdpToRelays — snapshot then deliver outside the
lock); enable/disable/removeBrowser/shutdown are per-browserId; assert
browserIds are strictly monotonic / never reused (the relayId<<21 id space
relies on it).

CdpRelay: per-relay CDP-id rewrite so browser-level commands (no sessionId —
Playwright's connect handshake) from sibling relays don't collide in the
shared pipe id space. pipeId = (relayId<<21)|localSeq; responses demuxed back
to the owning relay by pipeId, siblings' dropped. Session-routed traffic still
demuxes by sessionId via the existing scope filter.

FlutterCefPlugin: route disableAgentControl by the session's browserId; add
shutdownAllHosts on NSApplication.willTerminate (the disposeAll orphan-check —
SIGTERM+reap every live cef_host on app quit so none orphans a profile's
Chromium SingletonLock).

Deviation from specs/cef-multiview/PLAN.md §3.2: the plan expected sessionId
demux to suffice ("no CDP id-namespacing"); browser-level commands forced a
per-relay id rewrite. Documented inline.

WIP — recovered from lost session 31a9d1e2; not yet build-verified on a signed
CEF_HOST_ADHOC=OFF GPU build (PLAN Phase 5 / Test H).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the standalone CdpRelay filter tests (cef-multiview PLAN Test I): two
relays sharing one browser-wide pipe, asserting per-relay CDP-id namespacing
(pipeId = relayId<<21|localSeq — same client id on two relays yields different
pipe ids, no collision), response demux routing each reply only to its issuing
relay (the sibling drops it — including the browser-level no-sessionId response
that the id-rewrite exists to disambiguate, the documented PLAN §3.2 fix), and
event scope-filtering. 15 new checks, all green alongside the existing 53.

To make the multiplex unit-testable without a socket, extract the pure decision
out of deliverToClient into demuxPipeToClient(_:)->String? (returns the client
bytes, or nil to drop; deliverToClient is now a thin send wrapper — behavior
identical) and make rewriteOutgoingId internal.

Run: packages/flutter_cef_macos/test/run_filter_tests.sh

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auto-running, headless-friendly verification of the multi-tile agent-control
gate (cef-multiview PLAN Tests A/D/E), run as an alternate example entrypoint:

  flutter build macos --debug -t lib/multiview_probe.dart
  FLUTTER_CEF_HOST=<cef_host> FLUTTER_CEF_ALLOW_INSECURE_PROFILE=1 <built-binary>

Mounts two CefWebViews on one isolated named profile ('p2probe') with
agentControl, then with no user interaction enables agent-control on both and
drives a real CDP check over the two brokered relays, writing results to
/tmp/cef_multiview_probe.json. Validated live this session (all PASS):
  A — both views create on ONE shared cef_host (pgrep == 1 host)
  D — two grants with distinct relay ports + tokens
  E — each relay's Target.getTargets exposes only its OWN target (A can't see
      B), and tile A's token is rejected on tile B's port
Plus: graceful quit reaps the host (no orphan holding the profile lock —
shutdownAllHosts / willTerminate).

Note: the ad-hoc cef_host refuses a persistent named profile unless
FLUTTER_CEF_ALLOW_INSECURE_PROFILE=1 (mock keychain). The signed-build GPU
peer-validation gate (PLAN Phase 5 / Test H, CEF_HOST_ADHOC=OFF) is the one
piece still unverified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mments

The agent-control surface was documented as "one agent-controlled tile per
cef_host process" — superseded by P2-step2 (N tiles per shared host, one
per-target relay each, multiplexed over the shared pipe). Update the
user-facing + design surfaces to match the shipped behavior:

- README "Limits, by design": replace the single-tile note with the multi-view
  relay-multiplex description (sessionId scoping + per-relay CDP-id rewrite).
- CHANGELOG 0.2.0 (unreleased): same, plus the on-quit SIGTERM-reap of hosts.
- FlutterCefPlugin.resolveOrSpawnHost doc: a named profile is MULTI-view since
  P2; an agent-control create() resolving to a pre-existing host is the normal
  2nd+-tile path, not an anomaly.
- specs/agent-control/PLAN.md: mark the deferred "multi-grant (per-tile relays +
  CDP id remapping)" as implemented in P2-step2.
- multiview_probe.dart: use the null-aware map entry (`'params': ?params`) so
  `flutter analyze` is clean (no issues).

No code-behavior change. Verified: flutter analyze clean, 121 dart tests pass,
CdpRelay filter+multiplex tests (68 checks) pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…targetId timer

Edge-harden the multi-tile agent-control paths (cef-multiview PLAN Tests F-ish),
extending the live probe and fixing a latent timer bug surfaced along the way.

multiview_probe.dart:
- F (concurrency): enable agent-control on BOTH views CONCURRENTLY (Future.wait,
  not sequentially) — the P1 scalar relay/relayBrowserId would lose this race; the
  per-browserId dict + cdpHandlerLock must bring up two isolated relays. PASS.
- F (lifecycle): disabling A frees ONLY A's relay — A's old endpoint goes dead
  while sibling B keeps driving its own target untouched. PASS.
- Documented why PLAN Test G (reader-stall / SO_SNDTIMEO reaping) isn't faithfully
  reproducible from the bare probe (synthesized getTargets bypasses the shared
  reader; stalling precludes the handshake) — it belongs on the canvas side under a
  real agent-browser driver.

CefProfileHost.resolveTargetId: epoch-guard the 5s timeout. An early kOpTargetId
response did not cancel the timer, so a later resolve for the SAME browser (within
5s) could be clobbered and fulfilled with nil. Each resolve now carries an epoch;
its timeout only fulfills if still current. Correct latent-bug fix.

Discovered (NOT a P2-step2 regression; recorded as a non-fatal probe diagnostic):
re-enabling agent-control on a tile AFTER disabling it hangs — the 2nd per-browser
Target.getTargetInfo resolve never returns kOpTargetId. Root cause is in cef_host's
targetId re-resolution (untouched by P2-step2; the epoch fix above is necessary but
not sufficient), tracked for a separate cef_host fix.

Verified: flutter analyze clean, CdpRelay filter+multiplex tests pass, live probe
suite green (concurrency + teardown), example app compiles with the epoch fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@wenkaifan0720 wenkaifan0720 merged commit c7cc05f into main Jun 19, 2026
1 check failed
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