car_server_core/browser_view.rs
1//! The `browser.view.*` JSON-RPC surface — the browser drawer's window onto
2//! a CAR browser.
3//!
4//! Built on the `runs.subscribe` / `runs.trace.event` contract, point for
5//! point:
6//!
7//! - **Snapshot + register is atomic.** [`BrowserView::subscribe`] reads the
8//! presentation AND inserts the subscriber under the same [`ViewFanout`]
9//! lock every emitter holds, so the snapshot covers exactly the events
10//! through `cursor` — no gap, no duplicate, at the boundary.
11//! - **Cursor monotonicity is gap detection.** Every event — a presentation
12//! delta or a screencast frame — is stamped with the next cursor. A
13//! subscriber at `n` expects `n+1`; a jump means the daemon dropped
14//! something and the fix is to re-subscribe, which backfills from a fresh
15//! snapshot.
16//! - **Bounded channel, drain task per subscriber.** The producer only
17//! `try_send`s ([`BrowserViewSubscriber::push`]); one dedicated task owns
18//! the WS write. A full channel drops the event — frames are large and a
19//! wedged drawer must never stall the browser, the agent, or the daemon.
20//! - **Explicit fanout.** Each `(view, connection)` is its own subscriber;
21//! two Command Decks on one browser both get every event.
22//! - **Reconnect-durable.** Nothing fails because a subscriber dropped;
23//! disconnect removes only that connection's subscriptions, and the host
24//! re-subscribes on its new connection.
25//!
26//! ## Which browser a call talks to
27//!
28//! `conversation_id` is optional on every method. Omit it (or pass `null`)
29//! for the **standing session** — the one shared browser every conversation
30//! without an agent-attached browser shows (controller ruling R6). Pass it to
31//! reach the browser an agent attached for that conversation/agent-session.
32//!
33//! ## Who produces a view
34//!
35//! Two kinds of producer, and this surface cannot tell them apart (see
36//! [`ViewBrowser`]): a browser the DAEMON owns in this process, and one a
37//! SUPERVISED AGENT PROCESS owns, relayed over the WS session that process
38//! already holds (see [`crate::browser_relay`]). Every rule below —
39//! authorization, cursors, snapshots, the blackout, the control machine —
40//! applies identically to both.
41//!
42//! ## Authorization
43//!
44//! Every method requires the **host-management client** (`session.auth
45//! { host_token }`). This is deliberately stricter than `runs.subscribe`,
46//! which also admits the agent that owns the run: frames ARE page
47//! screenshots and the input methods drive a logged-in browser, so admitting
48//! an agent here would hand it both perception and actuation outside the
49//! `full_access` `browse_*` tool tier — and would let it read a page during
50//! the privacy blackout that exists to keep it out. Agents browse through
51//! their tools; this surface belongs to the person.
52
53use std::collections::{HashMap, HashSet};
54use std::path::PathBuf;
55use std::sync::Arc;
56use std::time::Duration;
57
58use base64::engine::general_purpose::STANDARD as BASE64;
59use base64::Engine as _;
60use car_browser::{Modifier, ScreencastFrame};
61use futures::SinkExt;
62use serde::{Deserialize, Serialize};
63use serde_json::{json, Value};
64use tokio::sync::Mutex;
65use tokio_tungstenite::tungstenite::Message;
66
67use crate::assistant::browser_control::{ControlEffect, ControlOwner, Presentation};
68use crate::assistant::browser_tools::{AlwaysConnected, BrowserTools, ControlStatus};
69use crate::browser_attention::{BrowserSignInSnapshot, SignInAttention};
70use crate::browser_relay::{ProducerRegistry, RelayProducer};
71use crate::handler::JsonRpcMessage;
72use crate::session::{ClientSession, ServerState, WsChannel};
73
74/// Capacity of each drawer subscriber's bounded channel. Much smaller than
75/// the run-trace equivalent on purpose: a slot here can hold a full-viewport
76/// JPEG, so a generous buffer is megabytes of memory held for a subscriber
77/// that has already fallen behind. A drawer that cannot keep up with 32
78/// frames is not going to catch up; dropping and letting it re-subscribe is
79/// both cheaper and more honest.
80pub const BROWSER_VIEW_CHANNEL_CAP: usize = 32;
81
82/// What every gate answers when the agent is driving. One constant because
83/// the check happens TWICE on the relay path — once in the daemon against its
84/// cached view of the control state, once in the process that owns the browser
85/// immediately before it injects — and a person must not be able to tell the
86/// two apart.
87pub const AGENT_HOLDS_CONTROL: &str =
88 "the agent holds control of this browser — call browser.view.take_control first";
89
90/// How long control stays with a connection that dropped while holding it,
91/// before it reverts to the agent. Long enough to cover an app restart or a
92/// flaky socket; short enough that an agent is never parked forever behind a
93/// controller who is not coming back.
94pub const CONTROL_GRACE: Duration = Duration::from_secs(30);
95
96/// What a `ControlEffect::StartGracePeriod` coming back from the reducer means
97/// at a given [`BrowserView::apply_effects`] call site.
98///
99/// The reducer runs in the agent process and only ever ASKS for a clock; the
100/// daemon owns it. Whether that ask is a fresh clock depends on the caller.
101#[derive(Clone, Copy, Debug)]
102enum GraceArming {
103 /// Start a clock now, with these watcher semantics (see
104 /// [`BrowserView::spawn_grace_timer_inner`]).
105 Arm { require_unwatched: bool },
106 /// The caller already armed this disconnect's clock before relaying, so
107 /// the effect is the reducer AGREEING rather than a second clock.
108 ///
109 /// Arming again would do two wrong things. It would bump
110 /// `control.generation` a second time — and on the relay path this runs
111 /// DETACHED, up to 2×`RELAY_CALL_TIMEOUT` later, so that second bump can
112 /// land after a legitimate `take_control`, capturing the re-taker's
113 /// generation and turning the stale-expiry check into a no-op. And it
114 /// would re-arm with the holder variant, discarding the `require_unwatched`
115 /// semantics the disconnect chose from `was_watching`.
116 AlreadyArmed,
117}
118
119// ---------------------------------------------------------------------------
120// Wire types
121// ---------------------------------------------------------------------------
122
123/// One tab, as the drawer's tab strip renders it. `id` is the tab's opaque
124/// id rendered as a string (`tab-3`); pass it straight back to
125/// `browser.view.tab_close` / `tab_switch`.
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct WireTab {
128 pub id: String,
129 pub url: String,
130 pub title: String,
131 pub active: bool,
132 pub can_go_back: bool,
133 pub can_go_forward: bool,
134}
135
136/// Who is driving. Serialized as `"none"` / `"agent"` / `"user"`.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "snake_case")]
139pub enum WireOwner {
140 /// No agent has attached — the user's own browser, no strip, no
141 /// ceremony.
142 None,
143 Agent,
144 User,
145}
146
147impl From<ControlOwner> for WireOwner {
148 fn from(owner: ControlOwner) -> Self {
149 // Exhaustive on purpose: a new owner state must not silently
150 // serialize as an existing one.
151 match owner {
152 ControlOwner::NoAgent => WireOwner::None,
153 ControlOwner::Agent => WireOwner::Agent,
154 ControlOwner::User => WireOwner::User,
155 }
156 }
157}
158
159/// The way back, for a view whose control state arrives over the wire from a
160/// supervised agent process rather than from a reducer in this process.
161impl From<WireOwner> for ControlOwner {
162 fn from(owner: WireOwner) -> Self {
163 match owner {
164 WireOwner::None => ControlOwner::NoAgent,
165 WireOwner::Agent => ControlOwner::Agent,
166 WireOwner::User => ControlOwner::User,
167 }
168 }
169}
170
171/// Everything the drawer renders except the picture itself.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct WirePresentation {
174 /// Advances only when something actually changed (see
175 /// `browser_control::PresentationState`). Distinct from the subscription
176 /// `cursor`, which counts EVENTS including frames.
177 pub revision: u64,
178 pub owner: WireOwner,
179 /// What the agent is doing right now, in plain words, or `null`.
180 pub current_action: Option<String>,
181 /// The sign-in strip's plain-words prompt while one is pending.
182 pub pending_signin: Option<String>,
183 /// While true, no page observation reaches the model and no frame
184 /// reaches `browser_record`'s output — the drawer keeps streaming.
185 pub blackout_active: bool,
186 pub tabs: Vec<WireTab>,
187 /// Convenience projections of the active tab, so a client does not have
188 /// to scan `tabs` to render the nav bar.
189 pub active_tab: Option<String>,
190 pub url: Option<String>,
191 pub title: Option<String>,
192}
193
194impl WirePresentation {
195 pub(crate) fn empty() -> Self {
196 Self {
197 revision: 0,
198 owner: WireOwner::None,
199 current_action: None,
200 pending_signin: None,
201 blackout_active: false,
202 tabs: Vec::new(),
203 active_tab: None,
204 url: None,
205 title: None,
206 }
207 }
208}
209
210impl From<&Presentation> for WirePresentation {
211 fn from(p: &Presentation) -> Self {
212 let tabs: Vec<WireTab> = p
213 .tabs
214 .iter()
215 .map(|t| WireTab {
216 id: t.id.to_string(),
217 url: t.url.clone(),
218 title: t.title.clone(),
219 active: t.active,
220 can_go_back: t.can_go_back,
221 can_go_forward: t.can_go_forward,
222 })
223 .collect();
224 let active = tabs.iter().find(|t| t.active);
225 Self {
226 revision: p.revision,
227 owner: p.owner.into(),
228 current_action: p.current_action.clone(),
229 pending_signin: p.pending_signin.as_ref().map(|s| s.message.clone()),
230 blackout_active: p.blackout_active,
231 active_tab: active.map(|t| t.id.clone()),
232 url: active.map(|t| t.url.clone()),
233 title: active.map(|t| t.title.clone()),
234 tabs,
235 }
236 }
237}
238
239/// One screencast frame: base64 JPEG plus the viewport it was captured at,
240/// so a client can map its own pointer coordinates back onto the page
241/// without asking.
242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
243pub struct WireFrame {
244 pub jpeg_base64: String,
245 pub width: u32,
246 pub height: u32,
247 pub device_pixel_ratio: f64,
248 /// Wall-clock seconds since this subscription started. Frames are
249 /// change-driven, not fixed-rate.
250 pub captured_at: f64,
251}
252
253impl From<ScreencastFrame> for WireFrame {
254 fn from(frame: ScreencastFrame) -> Self {
255 Self {
256 jpeg_base64: BASE64.encode(&frame.jpeg),
257 width: frame.viewport.width,
258 height: frame.viewport.height,
259 device_pixel_ratio: frame.viewport.device_pixel_ratio,
260 captured_at: frame.captured_at,
261 }
262 }
263}
264
265/// What one `browser.view.event` carries.
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
267#[serde(tag = "kind", rename_all = "snake_case")]
268pub enum BrowserViewPayload {
269 Presentation { presentation: WirePresentation },
270 Frame { frame: WireFrame },
271}
272
273/// One pushed `browser.view.event` notification.
274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
275pub struct BrowserViewEvent {
276 /// The view this belongs to; `null` for the standing session.
277 pub conversation_id: Option<String>,
278 /// Monotonic per view. A subscriber at `n` expects `n+1`.
279 pub cursor: u64,
280 #[serde(flatten)]
281 pub payload: BrowserViewPayload,
282}
283
284// ---------------------------------------------------------------------------
285// Subscriber
286// ---------------------------------------------------------------------------
287
288/// One live drawer subscriber — the producer side of a bounded channel whose
289/// drain task writes `browser.view.event` frames to that connection's
290/// WebSocket. Modeled on [`crate::host::RunTraceSubscriber`]; the producer
291/// never touches the socket.
292/// Hands out [`BrowserViewSubscriber::epoch`] values, PROCESS-WIDE.
293///
294/// It was per-view, which is the obvious place for it and was wrong for the
295/// one path that matters: [`BrowserView::adopt`] moves subscribers ACROSS
296/// views, so a per-view counter makes the identity unique only WITHIN the view
297/// that issued it. Both counters start at 0, so a subscriber inherited from the
298/// previous view and a `subscribe` that landed on the successor inside the
299/// register/adopt window collide exactly — `or_insert` drops the inherited one,
300/// its drain task exits, and `unsubscribe_epoch` matches the LIVE registration's
301/// epoch and removes it. The drawer then holds a successful reply with a cursor
302/// that never advances, reporting `.live` over a frozen frame, with no event
303/// arriving to trip the cursor-gap recovery. That is the exact failure the epoch
304/// was added to prevent, reintroduced one level up.
305static NEXT_SUBSCRIBER_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
306
307pub struct BrowserViewSubscriber {
308 tx: tokio::sync::mpsc::Sender<BrowserViewEvent>,
309 /// The view this subscriber is registered on — SHARED with its drain task,
310 /// so `adopt` can re-point it.
311 ///
312 /// `adopt` moves subscriber structs to a replacement view but cannot move
313 /// a spawned task's captured state. With the handle captured by value the
314 /// inherited task kept deregistering against the OLD view, whose last
315 /// strong reference drops the moment `register` returns — so `upgrade()`
316 /// failed and it deregistered from nothing. Its entry stayed in the NEW
317 /// view's fanout with a closed channel forever: `subscribers.is_empty()`
318 /// never true, so capture never stopped and `release_if_idle` never
319 /// evicted the view or its browser — the exact leak that path exists to
320 /// close.
321 view: Arc<std::sync::Mutex<std::sync::Weak<BrowserView>>>,
322 /// Which registration this is, for the fanout's key.
323 ///
324 /// A re-subscribe on the SAME connection replaces the entry, which drops
325 /// the old subscriber, closes its channel, and ends its drain task — and
326 /// that task then deregisters. Without an identity check it deregistered
327 /// by `client_id` alone, removing the entry the NEW subscribe had just
328 /// installed. The trigger is this surface's own documented recovery: a
329 /// cursor gap makes the drawer re-subscribe the same key on the same
330 /// connection, so ONE dropped frame killed the drawer permanently — the
331 /// subscribe returned success with a fresh cursor, and nothing was ever
332 /// delivered again.
333 ///
334 /// Drawn from [`NEXT_SUBSCRIBER_EPOCH`], which is process-wide and not
335 /// per-view — the identity has to be unique across the boundary
336 /// [`BrowserView::adopt`] moves subscribers over. See that static.
337 epoch: u64,
338}
339
340impl BrowserViewSubscriber {
341 /// How many CONSECUTIVE write stalls a subscriber survives before its
342 /// drain task gives up.
343 ///
344 /// A single stall is a busy socket, not a death — exiting on the first
345 /// one killed a subscription permanently. But continuing forever is not
346 /// backpressure either, and the previous comment here claimed a drop that
347 /// does not happen: `SinkExt::send` is poll_ready + start_send +
348 /// poll_flush, and tokio-tungstenite's `start_send` treats `WouldBlock` as
349 /// "queued, not an error". By the time the deadline cancels the future it
350 /// is cancelling the FLUSH — the frame is already inside tungstenite's
351 /// write buffer, which this workspace never configures, so it is
352 /// `usize::MAX`. Looping past a permanent stall therefore fed an unbounded
353 /// buffer full-viewport JPEGs, and the 32-slot channel in front of it —
354 /// sized small precisely because a slot holds one — provided no bound at
355 /// all, because the bound it protects sits downstream of the queue.
356 ///
357 /// So: tolerate a transient stall, and treat a persistent one as the dead
358 /// socket it almost certainly is.
359 const MAX_CONSECUTIVE_STALLS: u32 = 3;
360
361 /// Re-point this subscriber's drain task at the view that now holds it.
362 fn rebind(&self, view: std::sync::Weak<BrowserView>) {
363 match self.view.lock() {
364 Ok(mut slot) => *slot = view,
365 Err(poisoned) => *poisoned.into_inner() = view,
366 }
367 }
368
369 pub fn spawn(
370 view: std::sync::Weak<BrowserView>,
371 client_id: String,
372 epoch: u64,
373 channel: Arc<WsChannel>,
374 ) -> Self {
375 let (tx, mut rx) = tokio::sync::mpsc::channel::<BrowserViewEvent>(BROWSER_VIEW_CHANNEL_CAP);
376 let view = Arc::new(std::sync::Mutex::new(view));
377 let task_view = Arc::clone(&view);
378 tokio::spawn(async move {
379 let mut stalls = 0u32;
380 while let Some(event) = rx.recv().await {
381 let Ok(json) = serde_json::to_string(&json!({
382 "jsonrpc": "2.0",
383 "method": "browser.view.event",
384 "params": event,
385 })) else {
386 continue;
387 };
388 let mut guard = channel.write.lock().await;
389 let send = tokio::time::timeout(
390 Duration::from_secs(10),
391 guard.send(Message::Text(json.into())),
392 )
393 .await;
394 drop(guard);
395 match send {
396 Ok(Ok(())) => stalls = 0,
397 // A write ERROR means the socket is gone.
398 Ok(Err(_)) => break,
399 Err(_) => {
400 stalls += 1;
401 if stalls >= Self::MAX_CONSECUTIVE_STALLS {
402 tracing::debug!(
403 client_id,
404 "browser view: subscriber dropped after {} consecutive write stalls",
405 stalls
406 );
407 break;
408 }
409 tracing::debug!(
410 client_id,
411 "browser view: a stalled socket did not accept an event in time"
412 );
413 }
414 }
415 }
416 // Deregister on the way out, whichever way that was. Nothing
417 // re-spawns this task, so a subscriber left in the fanout after it
418 // ends is a watcher the daemon counts (keeping capture armed) and
419 // never serves. Removing it lets `stop_streamer_if_unwatched` stop
420 // the screencast, and the host's next `browser.view.*` call
421 // re-establishes a working subscription.
422 // Read at EXIT, not captured at spawn: `adopt` may have re-pointed
423 // this subscriber at a replacement view in the meantime, and the
424 // entry to remove is the one in whichever view holds it now.
425 let current = match task_view.lock() {
426 Ok(view) => view.upgrade(),
427 Err(poisoned) => poisoned.into_inner().upgrade(),
428 };
429 if let Some(view) = current {
430 view.unsubscribe_epoch(&client_id, epoch).await;
431 }
432 });
433 Self { tx, epoch, view }
434 }
435
436 /// Non-blocking push. `false` when the channel is full (a wedged or slow
437 /// drawer) — the event is DROPPED rather than blocking the producer, and
438 /// the client detects the resulting cursor gap and re-subscribes.
439 pub fn push(&self, event: BrowserViewEvent) -> bool {
440 self.tx.try_send(event).is_ok()
441 }
442}
443
444// ---------------------------------------------------------------------------
445// One view
446// ---------------------------------------------------------------------------
447
448/// Snapshot + subscriber registry, under one lock so registration and
449/// emission are serialized (invariant #1).
450struct ViewFanout {
451 cursor: u64,
452 /// The most recently emitted presentation — what a fresh subscriber's
453 /// snapshot is taken from, so the snapshot and the cursor it is paired
454 /// with are read under the same lock that stamps events.
455 last: WirePresentation,
456 subscribers: HashMap<String, BrowserViewSubscriber>,
457}
458
459/// Who holds user control, and a generation that invalidates a grace timer
460/// whose window was overtaken by a later take/hand-back.
461#[derive(Default)]
462struct ControlHolder {
463 holder: Option<String>,
464 generation: u64,
465}
466
467/// One control transition, as a view drives it. Deliberately narrower than
468/// [`crate::assistant::browser_control::ControlEvent`]: these five are the
469/// only transitions the DAEMON originates, and they are the ones that have to
470/// cross a process boundary when the browser is a supervised agent's.
471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
472pub enum ViewControl {
473 TakeControl,
474 HandBack,
475 RunEnded,
476 HolderDisconnected,
477 GraceExpired,
478}
479
480/// One user-driven input, as a view hands it to whatever browser backs it.
481///
482/// The whole surface a person has: navigate/click/type/keypress/scroll/paste,
483/// the three nav-bar history buttons, and the three tab operations. NO perception
484/// of any kind — nothing here returns page content, which is what keeps this
485/// surface from being a browsing path around the `full_access` `browse_*`
486/// tools.
487#[derive(Debug, Clone, PartialEq)]
488pub enum ViewInput {
489 Navigate {
490 url: String,
491 },
492 Click {
493 x: f64,
494 y: f64,
495 },
496 Type {
497 text: String,
498 },
499 Keypress {
500 key: String,
501 modifiers: Vec<Modifier>,
502 },
503 Scroll {
504 delta_y: i32,
505 },
506 /// Paste at the caret, replacing the selection. Carries the TEXT because
507 /// the clipboard belongs to the host's OS, not to the page: CDP's
508 /// injected key events cannot reach a clipboard, so a synthesised ⌘V
509 /// delivers a key event and nothing arrives. The host reads its own
510 /// pasteboard and sends the string.
511 Paste {
512 text: String,
513 },
514 /// The nav bar's Back / Forward / Reload buttons. Real history moves
515 /// through CDP, not synthesised ⌘←/⌘→ keystrokes — those are browser
516 /// chrome the input domain never reaches, so they inject into the PAGE
517 /// and the history never moves.
518 Back,
519 Forward,
520 Reload,
521 TabOpen,
522 TabClose {
523 tab_id: String,
524 },
525 TabSwitch {
526 tab_id: String,
527 },
528}
529
530impl ViewControl {
531 /// Drive this transition against a browser in THIS process.
532 ///
533 /// One definition, two callers: [`ViewBrowser::Local`] here in the daemon,
534 /// and the supervised agent process's own `agent.browser.control` handler
535 /// (see [`crate::assistant::browser_producer`]). The two paths cannot
536 /// diverge because there is only one of them.
537 pub async fn apply(self, tools: &BrowserTools) -> (Presentation, Vec<ControlEffect>) {
538 match self {
539 ViewControl::TakeControl => tools.take_control().await,
540 ViewControl::HandBack => tools.hand_back().await,
541 ViewControl::RunEnded => tools.note_run_ended().await,
542 ViewControl::HolderDisconnected => tools.control_holder_disconnected().await,
543 ViewControl::GraceExpired => tools.grace_period_expired().await,
544 }
545 }
546}
547
548impl ViewInput {
549 /// Execute this input against a browser in THIS process. `Ok(Some(id))` is
550 /// the newly opened tab's id; every other input answers `Ok(None)`.
551 ///
552 /// Shared by the in-daemon path and the supervised agent process's
553 /// `agent.browser.input` handler for the same reason [`ViewControl::apply`]
554 /// is: the drawer must behave identically whichever process the browser
555 /// happens to live in, and error strings included, that is only guaranteed
556 /// if it is literally the same code.
557 pub async fn apply(self, tools: &BrowserTools) -> Result<Option<String>, String> {
558 // Re-check the control state IN THE PROCESS THAT OWNS THE BROWSER,
559 // immediately before injecting. The `browser.view.*` gate ran against
560 // the daemon's view of the world; on the relay path that view is a
561 // cached presentation and the decision is separated from the injection
562 // by a WS round trip, so the agent can legitimately have resumed
563 // driving in between. Without this, a user's click could land while
564 // the agent is acting, and the blackout would be one hop late — which
565 // is the whole property it exists to hold.
566 //
567 // The predicate matches `BrowserView::require_control`'s exactly
568 // (minus the per-connection holder, which only the daemon knows), so
569 // the local path re-affirms its own decision rather than changing it.
570 let status = tools.control_status().await;
571 if status.owner == ControlOwner::Agent && !status.signin_pending {
572 return Err(AGENT_HOLDS_CONTROL.to_string());
573 }
574 // Somebody is demonstrably AT this browser. Recorded here — the one
575 // place both the in-daemon and the relayed input paths pass through —
576 // because it is what decides whether a sign-in TIMEOUT may end their
577 // window: a person who never pressed Take control but is typing into a
578 // credential form is exactly the case the ownership flag cannot see.
579 tools.note_user_input().await;
580 match self {
581 ViewInput::Navigate { url } => {
582 tools.user_navigate(&url).await?;
583 Ok(None)
584 }
585 ViewInput::Click { x, y } => {
586 tools.user_click(x, y).await?;
587 Ok(None)
588 }
589 ViewInput::Type { text } => {
590 tools.user_type(&text).await?;
591 Ok(None)
592 }
593 ViewInput::Keypress { key, modifiers } => {
594 tools.user_keypress(&key, &modifiers).await?;
595 Ok(None)
596 }
597 ViewInput::Scroll { delta_y } => {
598 tools.user_scroll(delta_y).await?;
599 Ok(None)
600 }
601 ViewInput::Paste { text } => {
602 tools.user_paste(&text).await?;
603 Ok(None)
604 }
605 ViewInput::Back => {
606 tools.user_go_back().await?;
607 Ok(None)
608 }
609 ViewInput::Forward => {
610 tools.user_go_forward().await?;
611 Ok(None)
612 }
613 ViewInput::Reload => {
614 tools.user_reload().await?;
615 Ok(None)
616 }
617 ViewInput::TabOpen => Ok(Some(tools.user_tab_open().await?.to_string())),
618 ViewInput::TabClose { tab_id } => {
619 let id = tools.resolve_tab(&tab_id).await?;
620 tools.user_tab_close(id).await?;
621 Ok(None)
622 }
623 ViewInput::TabSwitch { tab_id } => {
624 let id = tools.resolve_tab(&tab_id).await?;
625 tools.user_tab_switch(id).await?;
626 Ok(None)
627 }
628 }
629 }
630}
631
632/// What a view drives.
633///
634/// An enum rather than a trait object on purpose: both producers live in this
635/// crate, every operation below is matched exhaustively, and a third producer
636/// would be a compile error at each site rather than a silently-defaulted arm.
637pub enum ViewBrowser {
638 /// A browser this process owns: the standing session, or an in-daemon
639 /// assistant runtime's own `BrowserTools`.
640 Local(Arc<BrowserTools>),
641 /// A supervised agent process's browser, relayed over the WS session that
642 /// process already holds with the daemon.
643 Relay(Arc<RelayProducer>),
644}
645
646impl ViewBrowser {
647 async fn presentation(&self) -> WirePresentation {
648 match self {
649 Self::Local(tools) => WirePresentation::from(&tools.presentation().await),
650 Self::Relay(producer) => producer.presentation().await,
651 }
652 }
653
654 /// The cheap "who is driving" read the input path makes on every call —
655 /// no CDP locally, no round trip remotely.
656 async fn control_status(&self) -> ControlStatus {
657 match self {
658 Self::Local(tools) => tools.control_status().await,
659 Self::Relay(producer) => producer.control_status().await,
660 }
661 }
662
663 /// Drive the control reducer — which lives WITH the browser — and return
664 /// what it asked the caller to do.
665 ///
666 /// Fallible because a relayed transition can fail to reach the process at
667 /// all. A local one never fails: the reducer is a pure function on state
668 /// this process owns.
669 /// Returns the owner the transition ACTUALLY landed on, alongside the
670 /// effects. Both come from the same answer, so no caller has to re-read
671 /// state that a concurrent writer can overwrite in between — see
672 /// [`BrowserView::take_control`].
673 async fn control(
674 &self,
675 control: ViewControl,
676 ) -> Result<(ControlOwner, Vec<ControlEffect>), String> {
677 match self {
678 Self::Local(tools) => {
679 let (presentation, effects) = control.apply(tools).await;
680 Ok((presentation.owner, effects))
681 }
682 Self::Relay(producer) => producer.control(control).await,
683 }
684 }
685
686 /// [`Self::control`] for the transitions the DAEMON originates on its own
687 /// (run end, holder disconnect, grace expiry). There is nobody to report a
688 /// failure to and nothing to undo: the transition is the daemon telling
689 /// the browser about something that already happened. Logged, not
690 /// propagated.
691 async fn control_best_effort(&self, control: ViewControl) -> Vec<ControlEffect> {
692 match self.control(control).await {
693 Ok((_, effects)) => effects,
694 Err(error) => {
695 tracing::warn!(
696 ?control,
697 %error,
698 "browser view: a daemon-originated control transition did not land"
699 );
700 Vec::new()
701 }
702 }
703 }
704
705 /// Execute one user input. `Ok(Some(id))` is the newly opened tab's id;
706 /// every other input answers `Ok(None)`.
707 async fn input(&self, input: ViewInput) -> Result<Option<String>, String> {
708 match self {
709 Self::Local(tools) => input.apply(tools).await,
710 Self::Relay(producer) => producer.input(input).await,
711 }
712 }
713
714 /// Start delivering presentation changes and frames into `view`.
715 ///
716 /// A local browser needs a daemon-side pump, so this returns its task
717 /// handle. A relayed one pushes on its own — the daemon only has to say
718 /// that somebody is watching — so it returns `None`.
719 async fn start_capture(
720 &self,
721 view: std::sync::Weak<BrowserView>,
722 ) -> Option<tokio::task::JoinHandle<()>> {
723 match self {
724 Self::Local(tools) => {
725 let tools = Arc::clone(tools);
726 Some(tokio::spawn(async move { stream(view, tools).await }))
727 }
728 Self::Relay(producer) => {
729 producer.start_capture();
730 None
731 }
732 }
733 }
734
735 /// Nobody is watching any more: stop paying for capture.
736 async fn stop_capture(&self) {
737 match self {
738 Self::Local(tools) => tools.release_frames().await,
739 Self::Relay(producer) => producer.stop_capture(),
740 }
741 }
742}
743
744/// Whether capture is running for this view, and the daemon-side pump task
745/// when the producer needs one.
746#[derive(Default)]
747struct CaptureState {
748 active: bool,
749 task: Option<tokio::task::JoinHandle<()>>,
750}
751
752/// One browser, plus everyone watching it.
753pub struct BrowserView {
754 /// `None` for the standing session.
755 key: Option<String>,
756 browser: ViewBrowser,
757 fanout: Mutex<ViewFanout>,
758 control: Mutex<ControlHolder>,
759 /// Turns presentation changes and screencast frames into cursor-stamped
760 /// events. Runs only while somebody is subscribed.
761 capture: Mutex<CaptureState>,
762 /// Whether the run that this view was registered for has ended.
763 ///
764 /// Half of the eviction condition (`run_ended && no subscribers`) —
765 /// see [`BrowserViewRegistry::release_if_idle`]. A plain atomic rather
766 /// than a read of the control reducer because a relayed view's reducer
767 /// lives in another process, and because the eviction decision must be
768 /// answerable without a round trip to a process that may be gone.
769 run_ended: std::sync::atomic::AtomicBool,
770}
771
772impl BrowserView {
773 /// The conversation key this view is registered under; `None` is the
774 /// standing session. Read by [`crate::browser_relay::RelayProducer`] when
775 /// it has to decide whether a retiring view was the last one it served.
776 pub(crate) fn key(&self) -> Option<&str> {
777 self.key.as_deref()
778 }
779
780 fn new(key: Option<String>, tools: Arc<BrowserTools>) -> Self {
781 Self::with_browser(key, ViewBrowser::Local(tools))
782 }
783
784 fn with_browser(key: Option<String>, browser: ViewBrowser) -> Self {
785 Self {
786 key,
787 browser,
788 fanout: Mutex::new(ViewFanout {
789 cursor: 0,
790 last: WirePresentation::empty(),
791 subscribers: HashMap::new(),
792 }),
793 control: Mutex::new(ControlHolder::default()),
794 capture: Mutex::new(CaptureState::default()),
795 run_ended: std::sync::atomic::AtomicBool::new(false),
796 }
797 }
798
799 /// Is this view served by that supervised process?
800 fn is_served_by(&self, producer: &Arc<RelayProducer>) -> bool {
801 match &self.browser {
802 ViewBrowser::Local(_) => false,
803 ViewBrowser::Relay(mine) => Arc::ptr_eq(mine, producer),
804 }
805 }
806
807 /// The daemon-owned browser behind this view. Test-only sugar: production
808 /// code goes through [`ViewBrowser`], which serves both kinds of producer.
809 #[cfg(test)]
810 pub(crate) fn tools(&self) -> &Arc<BrowserTools> {
811 match &self.browser {
812 ViewBrowser::Local(tools) => tools,
813 ViewBrowser::Relay(_) => panic!("this view is served by an agent process"),
814 }
815 }
816
817 /// Atomically snapshot the presentation AND register `client_id` as a
818 /// subscriber (invariant #1).
819 ///
820 /// The refresh happens BEFORE the lock so a first subscriber to a
821 /// browser that has been running for a while gets the page the agent is
822 /// actually on, not the empty state — and so the CDP tab read never
823 /// happens under the lock emitters need.
824 async fn subscribe(
825 self: &Arc<Self>,
826 client_id: &str,
827 channel: Arc<WsChannel>,
828 ) -> (WirePresentation, u64) {
829 self.refresh_presentation().await;
830 let epoch = NEXT_SUBSCRIBER_EPOCH.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
831 let subscriber = BrowserViewSubscriber::spawn(
832 Arc::downgrade(self),
833 client_id.to_string(),
834 epoch,
835 channel,
836 );
837 let mut fanout = self.fanout.lock().await;
838 // A re-subscribe replaces the prior entry; dropping the old
839 // subscriber drops its sender, ending its drain task.
840 fanout.subscribers.insert(client_id.to_string(), subscriber);
841 let snapshot = (fanout.last.clone(), fanout.cursor);
842 drop(fanout);
843 self.ensure_streamer().await;
844 snapshot
845 }
846
847 /// Take over from the view this one replaces for the same conversation:
848 /// stop its capture, release its browser, and inherit its subscribers and
849 /// event cursor.
850 ///
851 /// Inheriting the cursor is not a nicety. A drawer watching this
852 /// conversation is mid-stream at some cursor `n`; restarting at 1 on the
853 /// new browser would send it a cursor that moves BACKWARDS, which no
854 /// gap-detecting client can interpret. Continuing from `n` keeps the
855 /// contract intact — the client sees the next event, carrying the new
856 /// browser's presentation, exactly as if the conversation's browser had
857 /// changed underneath it, which is what happened.
858 async fn adopt(self: &Arc<Self>, previous: &Arc<BrowserView>) {
859 // Stop the old browser's capture FIRST, so it cannot emit into the
860 // subscribers we are about to move (which would race the cursor).
861 previous.stop_streamer().await;
862 let (cursor, subscribers) = {
863 let mut old = previous.fanout.lock().await;
864 (old.cursor, std::mem::take(&mut old.subscribers))
865 };
866 let has_subscribers = {
867 let mut fanout = self.fanout.lock().await;
868 fanout.cursor = fanout.cursor.max(cursor);
869 // MERGE, never assign. `register` inserts this view into the
870 // registry and only then calls `adopt`, so a `subscribe` for the
871 // same key can land on this view inside that window — and its
872 // caller already got a success reply with a cursor. Assigning the
873 // previous view's map over the top would deregister that client
874 // silently: it would receive nothing forever, and its cursor would
875 // never advance, so it could not even detect the gap and recover.
876 //
877 // `or_insert` rather than `insert`, for the same client landing on
878 // both sides: the entry registered on THIS view is the newer one
879 // and is what the client's own reply was stamped against.
880 for (client_id, subscriber) in subscribers {
881 // Re-point BEFORE inserting: from here on this subscriber's
882 // drain task must deregister from THIS view, not the one it
883 // was born on (which is about to lose its last strong
884 // reference).
885 subscriber.rebind(Arc::downgrade(self));
886 fanout.subscribers.entry(client_id).or_insert(subscriber);
887 }
888 !fanout.subscribers.is_empty()
889 };
890 // Keyed on the MERGED map, not on what came across: a client that
891 // subscribed inside the window needs the stream started for it just
892 // as much as an inherited one does, and it is the case where the
893 // previous view had no subscribers at all that would otherwise leave
894 // it with a registration and no streamer.
895 if has_subscribers {
896 // Tell them what they are now looking at, then start streaming
897 // the new browser.
898 self.refresh_presentation().await;
899 self.ensure_streamer().await;
900 }
901 }
902
903 /// Drop one connection's subscription. Returns whether there was one.
904 ///
905 /// Unconditional by `client_id`, which is right for the CALLER-driven
906 /// paths — an explicit `browser.view.unsubscribe`, the disconnect sweep —
907 /// where the intent is "this connection is done with this view, whatever
908 /// registration it currently holds".
909 async fn unsubscribe(&self, client_id: &str) -> bool {
910 self.remove_subscriber(client_id, None).await
911 }
912
913 /// Drop a subscription only if it is still THIS registration.
914 ///
915 /// The drain task's own exit path. A newer `subscribe` from the same
916 /// connection replaces the entry and ends the old task, so removing by
917 /// `client_id` alone would delete the live registration that replaced it.
918 async fn unsubscribe_epoch(&self, client_id: &str, epoch: u64) -> bool {
919 self.remove_subscriber(client_id, Some(epoch)).await
920 }
921
922 async fn remove_subscriber(&self, client_id: &str, epoch: Option<u64>) -> bool {
923 let (removed, empty) = {
924 let mut fanout = self.fanout.lock().await;
925 let matches = fanout
926 .subscribers
927 .get(client_id)
928 .is_some_and(|s| epoch.is_none_or(|e| s.epoch == e));
929 let removed = matches && fanout.subscribers.remove(client_id).is_some();
930 (removed, fanout.subscribers.is_empty())
931 };
932 if empty {
933 self.stop_streamer_if_unwatched().await;
934 }
935 removed
936 }
937
938 /// Is anybody watching this view? The producer's frame fan-out asks before
939 /// paying for a clone — see [`RelayProducer::push_frame`].
940 pub(crate) async fn has_subscribers(&self) -> bool {
941 !self.fanout.lock().await.subscribers.is_empty()
942 }
943
944 #[cfg(test)]
945 async fn subscriber_count(&self) -> usize {
946 self.fanout.lock().await.subscribers.len()
947 }
948
949 /// [`Self::subscribe`] / [`Self::subscriber_count`], for tests in sibling
950 /// modules driving a view without going through the wire handlers.
951 /// [`Self::take_control`] / the control-holder record / the grace
952 /// generation, for tests in sibling modules driving a RELAY-backed view —
953 /// the only path where a control transition can genuinely fail.
954 #[cfg(test)]
955 pub(crate) async fn take_control_for_test(
956 self: &Arc<Self>,
957 client_id: &str,
958 ) -> Result<(WirePresentation, u64), String> {
959 self.take_control(client_id).await
960 }
961
962 #[cfg(test)]
963 pub(crate) async fn control_holder_for_test(&self) -> Option<String> {
964 self.control.lock().await.holder.clone()
965 }
966
967 #[cfg(test)]
968 pub(crate) async fn grace_generation_for_test(&self) -> u64 {
969 self.control.lock().await.generation
970 }
971
972 #[cfg(test)]
973 pub(crate) async fn subscribe_for_test(
974 self: &Arc<Self>,
975 client_id: &str,
976 channel: Arc<WsChannel>,
977 ) -> (WirePresentation, u64) {
978 self.subscribe(client_id, channel).await
979 }
980
981 #[cfg(test)]
982 pub(crate) async fn subscriber_count_for_test(&self) -> usize {
983 self.subscriber_count().await
984 }
985
986 /// Re-read the presentation and emit a delta if it actually changed.
987 ///
988 /// `pub(crate)` because a relayed producer drives it from the other side:
989 /// the agent process pushes a presentation, the producer caches it, and
990 /// this is what turns that into an event for every subscriber.
991 pub(crate) async fn refresh_presentation(&self) {
992 let wire = self.browser.presentation().await;
993 self.publish_presentation(wire).await;
994 }
995
996 /// Install a presentation read and emit a delta if it actually changed —
997 /// unless it is OLDER than what is already published.
998 ///
999 /// The read happens OUTSIDE the fanout lock and is not cheap: for a local
1000 /// view it is a live CDP `list_tabs` round trip. Two concurrent refreshes
1001 /// therefore have no ordering guarantee, and the colliding callers are
1002 /// ordinary — the streamer loop, `subscribe`, `snapshot` via
1003 /// take_control/hand_back, `note_disconnect`, `note_run_ended`, the grace
1004 /// timer, and a relayed producer's presentation push.
1005 ///
1006 /// Without the guard the LOSER of that race wins the lock second and
1007 /// publishes its older read at a HIGHER cursor: owner, URL, tab strip and
1008 /// blackout_active all regress on the drawer, `fanout.last` (the snapshot
1009 /// handed to every later subscriber) goes stale with them, and because
1010 /// the cursors stay contiguous the client's gap detection cannot fire. On
1011 /// an idle browser nothing re-emits, so it stays wrong.
1012 ///
1013 /// `revision` only advances when something actually changed, so equal
1014 /// revisions are content-identical and fall through to the equality check.
1015 /// Safe across `adopt`: a replacement view inherits the cursor and the
1016 /// subscriber map, never `last`, so it starts at revision 0 against its
1017 /// own browser's revisions.
1018 async fn publish_presentation(&self, wire: WirePresentation) {
1019 {
1020 let mut fanout = self.fanout.lock().await;
1021 if wire.revision < fanout.last.revision || fanout.last == wire {
1022 return;
1023 }
1024 fanout.last = wire.clone();
1025 fanout.cursor += 1;
1026 let event = BrowserViewEvent {
1027 conversation_id: self.key.clone(),
1028 cursor: fanout.cursor,
1029 payload: BrowserViewPayload::Presentation { presentation: wire },
1030 };
1031 fanout_locked(&fanout.subscribers, &event);
1032 }
1033 }
1034
1035 /// End any attention route before this view becomes unreachable.
1036 async fn resolve_signin_attention_on_teardown(&self) {
1037 match &self.browser {
1038 ViewBrowser::Local(tools) => tools.resolve_signin_attention_on_teardown().await,
1039 ViewBrowser::Relay(producer) => {
1040 producer.detach_signin_attention(self.key.as_deref()).await
1041 }
1042 }
1043 }
1044
1045 /// [`Self::publish_presentation`] with the read supplied, so the
1046 /// ordering guard can be exercised without racing a real CDP round trip.
1047 #[cfg(test)]
1048 async fn publish_presentation_for_test(&self, wire: WirePresentation) {
1049 self.publish_presentation(wire).await;
1050 }
1051
1052 async fn emit_frame(&self, frame: ScreencastFrame) {
1053 self.emit_wire_frame(WireFrame::from(frame)).await;
1054 }
1055
1056 /// Stamp and fan out one frame. `pub(crate)` for the same reason
1057 /// [`Self::refresh_presentation`] is: a relayed producer's frames arrive
1058 /// already in wire form, straight off the agent's WS session.
1059 pub(crate) async fn emit_wire_frame(&self, wire: WireFrame) {
1060 let mut fanout = self.fanout.lock().await;
1061 fanout.cursor += 1;
1062 let event = BrowserViewEvent {
1063 conversation_id: self.key.clone(),
1064 cursor: fanout.cursor,
1065 payload: BrowserViewPayload::Frame { frame: wire },
1066 };
1067 fanout_locked(&fanout.subscribers, &event);
1068 }
1069
1070 /// The current snapshot as a subscriber would see it, without emitting.
1071 async fn snapshot(&self) -> (WirePresentation, u64) {
1072 self.refresh_presentation().await;
1073 let fanout = self.fanout.lock().await;
1074 (fanout.last.clone(), fanout.cursor)
1075 }
1076
1077 /// [`Self::snapshot`], for tests in sibling modules that need to assert
1078 /// what a subscriber would see without going through the wire handlers.
1079 #[cfg(test)]
1080 pub(crate) async fn snapshot_for_test(&self) -> (WirePresentation, u64) {
1081 self.snapshot().await
1082 }
1083
1084 // ---- control ------------------------------------------------------
1085
1086 /// Is `client_id` allowed to drive right now?
1087 ///
1088 /// - **No agent involved** — anyone authorized may drive. That is the
1089 /// plan's "zero ceremony": the standing session is just a browser.
1090 /// - **A sign-in is pending** — the human must be able to type into the
1091 /// credential fields; that is the entire point of the request.
1092 /// - **The user holds control** — only the connection that took it, or
1093 /// any authorized connection when no connection holds it (the holder
1094 /// disconnected; see [`Self::note_disconnect`]). "A person has control
1095 /// but we do not know which connection" must not mean *nobody* may
1096 /// drive — that would leave the browser inert for the whole grace
1097 /// period, with the drawer's own Hand back refused too.
1098 /// - **The agent holds control** — nobody, until Take control.
1099 async fn require_control(&self, client_id: &str) -> Result<(), String> {
1100 let status = self.browser.control_status().await;
1101 match status.owner {
1102 ControlOwner::NoAgent => Ok(()),
1103 // A pending sign-in opens the AGENT's browser to the person
1104 // without a Take control press — the orange strip is the
1105 // affordance, and typing credentials is the whole point. It does
1106 // NOT open a browser somebody else already took: `TakeControl`
1107 // deliberately leaves `pending_signin` set, so
1108 // `owner == User && signin_pending` is exactly the
1109 // credential-entry window, and a blanket bypass admitted every
1110 // other authorized connection into it — a second Command Deck's
1111 // keystrokes interleaving into the password field, or a navigate
1112 // taking the page away mid-sign-in. Scoped to the arm that needs
1113 // it; the `User` arm keeps answering on the holder, as it does
1114 // for every other input.
1115 ControlOwner::Agent if status.signin_pending => Ok(()),
1116 ControlOwner::Agent => Err(AGENT_HOLDS_CONTROL.to_string()),
1117 ControlOwner::User => {
1118 if self.holder_admits(client_id).await {
1119 Ok(())
1120 } else {
1121 Err(
1122 "another connection holds control of this browser — input is accepted \
1123 only from the control holder"
1124 .to_string(),
1125 )
1126 }
1127 }
1128 }
1129 }
1130
1131 /// The browser is DRIVEN FIRST, and only a transition that actually landed
1132 /// moves the daemon's record of who holds control.
1133 ///
1134 /// The order matters on the relay path and is the whole fix for a wedged
1135 /// process: if the call never reaches it, the daemon must not end up
1136 /// believing the host is driving a browser whose own reducer still says
1137 /// the agent is — that combination refuses every subsequent input, and it
1138 /// tells the person the blackout is up when the process never entered it.
1139 /// Failing honestly leaves both sides agreeing, and the drawer can retry.
1140 /// Gated on the holder, like `hand_back` and every input method.
1141 ///
1142 /// Without this the hand-back gate did not fire in the scenario it was
1143 /// written for: a second host connection could simply `take_control`
1144 /// first — overwriting the holder — and then `hand_back`, reverting the
1145 /// browser to the agent and lifting the privacy blackout under whoever
1146 /// was mid-sign-in. Two calls instead of one is not a defence.
1147 async fn take_control(
1148 self: &Arc<Self>,
1149 client_id: &str,
1150 ) -> Result<(WirePresentation, u64), String> {
1151 if !self.holder_admits(client_id).await {
1152 return Err(
1153 "another connection holds control of this browser — it must hand back before \
1154 another can take control"
1155 .to_string(),
1156 );
1157 }
1158 // The owner comes back WITH the effects, from the same answer.
1159 //
1160 // The reducer only moves ownership when the AGENT held it: on the
1161 // standing session (`NoAgent`) `TakeControl` is a documented no-op,
1162 // and recording a holder there made `holder` mean "the last
1163 // connection that pressed the button" rather than "who is driving",
1164 // which the gate above would then enforce against everyone else for a
1165 // browser nobody had actually taken.
1166 //
1167 // Deciding that from a SECOND, independent `control_status()` read
1168 // was worse than the bug it fixed. On the relay path `control_status`
1169 // projects the producer's CACHED presentation, which the agent's own
1170 // presentation pump also writes, unordered against the transition —
1171 // so a push carrying a pre-take snapshot landing in the window
1172 // between the two calls made `now_user` false, left the holder
1173 // unrecorded, and still returned Ok. The daemon then believed nobody
1174 // held a browser a person had just taken, `holder_admits` admitted
1175 // every connection, and the gate added directly above was reachable
1176 // through the recording side instead of the check side.
1177 let (owner, effects) = self.browser.control(ViewControl::TakeControl).await?;
1178 let now_user = owner == ControlOwner::User;
1179 {
1180 let mut control = self.control.lock().await;
1181 if now_user {
1182 control.holder = Some(client_id.to_string());
1183 }
1184 control.generation += 1;
1185 }
1186 self.apply_effects(
1187 effects,
1188 GraceArming::Arm {
1189 require_unwatched: false,
1190 },
1191 )
1192 .await;
1193 Ok(self.snapshot().await)
1194 }
1195
1196 /// Whether `client_id` may act on the current user-control holder: it IS
1197 /// the holder, or nothing holds it.
1198 async fn holder_admits(&self, client_id: &str) -> bool {
1199 match self.control.lock().await.holder.as_deref() {
1200 None => true,
1201 Some(holder) => holder == client_id,
1202 }
1203 }
1204
1205 /// Gated the same way every input method is. `take_control` records the
1206 /// holder and `require_control` enforces it for input; hand-back enforced
1207 /// nothing, so a second host connection could revert the browser to the
1208 /// agent — resolving the holder's pending sign-in and lifting the privacy
1209 /// blackout — while that person was still typing a password into the
1210 /// page. Both connections carry the host token, so this is an ownership
1211 /// defect rather than a boundary crossing, but the asymmetry with the
1212 /// input path was not intended.
1213 async fn hand_back(
1214 self: &Arc<Self>,
1215 client_id: &str,
1216 ) -> Result<(WirePresentation, u64), String> {
1217 if !self.holder_admits(client_id).await {
1218 return Err(
1219 "another connection holds control of this browser — only the control holder \
1220 may hand it back"
1221 .to_string(),
1222 );
1223 }
1224 let (_, effects) = self.browser.control(ViewControl::HandBack).await?;
1225 {
1226 let mut control = self.control.lock().await;
1227 control.holder = None;
1228 control.generation += 1;
1229 }
1230 self.apply_effects(
1231 effects,
1232 GraceArming::Arm {
1233 require_unwatched: false,
1234 },
1235 )
1236 .await;
1237 Ok(self.snapshot().await)
1238 }
1239
1240 /// The connection holding control dropped. Starts the stated grace
1241 /// period, after which control reverts to the agent — an agent is never
1242 /// parked forever behind a controller who is not coming back.
1243 ///
1244 /// Returns whether this connection actually held control, i.e. whether
1245 /// this call took ownership of the disconnect clock. The caller uses that
1246 /// to keep [`Self::note_watcher_disconnect`] off a view whose holder just
1247 /// left: both arm a grace timer, with DIFFERENT `require_unwatched`
1248 /// semantics (see [`Self::spawn_grace_timer_inner`]), and each bumps
1249 /// `control.generation`, retiring the other's timer. Exactly one of the
1250 /// two owns any given disconnect.
1251 ///
1252 /// `was_watching` is why collapsing the two calls does not silently pick
1253 /// the holder semantics for everybody. The ordinary drawer is the holder
1254 /// AND the only subscriber, and for it the WATCHER semantics are the
1255 /// load-bearing ones: a drawer that comes back inside the window is
1256 /// precisely what says the person's sign-in window is still theirs. So the
1257 /// single surviving timer takes `require_unwatched` from whether this
1258 /// connection was the last drawer watching — see the arming site below.
1259 pub async fn note_disconnect(self: &Arc<Self>, client_id: &str, was_watching: bool) -> bool {
1260 let held = {
1261 let mut control = self.control.lock().await;
1262 let held = control.holder.as_deref() == Some(client_id);
1263 if held {
1264 // Cleared HERE, not left to the grace timer. That connection
1265 // is provably gone, and the timer is not guaranteed to run:
1266 // `control_best_effort` swallows a failed transition and
1267 // returns NO effects — an agent process that stopped
1268 // answering and hit `RELAY_CALL_TIMEOUT` is the ordinary
1269 // case — so `StartGracePeriod` never arrives, no timer is
1270 // spawned, and `holder` names a dead `client_id` forever.
1271 // The timer still owns the other half, reverting ownership.
1272 control.holder = None;
1273 control.generation += 1;
1274 }
1275 held
1276 };
1277 if !held {
1278 return false;
1279 }
1280 // Arm from what the daemon already KNOWS — the holder disconnected —
1281 // before asking the browser's reducer. Besides covering a failed relay,
1282 // this makes the timer's generation precede any legitimate re-take.
1283 //
1284 // `require_unwatched` — refuse to expire while a drawer is watching —
1285 // belongs to this disconnect only when this connection was the LAST
1286 // drawer watching. Then a drawer arriving inside the window is the
1287 // same person coming back, and their window is still theirs; expiring
1288 // under them resolves a pending sign-in as `signed_in: false` and
1289 // hands the page to the agent mid-credential-entry.
1290 //
1291 // It does NOT belong when other connections are still subscribed: a
1292 // DIFFERENT connection watching says nothing about a holder who left,
1293 // and suppressing the expiry there would park the agent behind them
1294 // forever. That is the line `note_watcher_disconnect` drew with its
1295 // own `subscribers.is_empty()` gate before it armed the `true`
1296 // variant last, and collapsing the two calls must not redraw it.
1297 //
1298 // One condition of that old gate is deliberately NOT reproduced:
1299 // `note_watcher_disconnect` also required the relay to answer with
1300 // non-empty effects before arming. Arming ahead of the reducer is the
1301 // point of this call site — a silent or dead relay must still leave a
1302 // recovery timer behind — so on that path we now arm where `main`
1303 // would have armed nothing at all. That is a widening, and it is the
1304 // direction we want: the failure it removes is a holder who never
1305 // gets their grace period because the agent process went quiet.
1306 let require_unwatched = was_watching && self.fanout.lock().await.subscribers.is_empty();
1307 self.spawn_grace_timer_inner(require_unwatched).await;
1308 if matches!(&self.browser, ViewBrowser::Relay(_)) {
1309 // A silent supervised process costs up to TWICE
1310 // `RELAY_CALL_TIMEOUT` here — ~60s, not 30: `call_agent` bounds the
1311 // write (`browser_relay.rs` :344) and the wait for the reply
1312 // (:371) with two separate timeouts. Disconnect teardown has
1313 // already cleared the dead holder and armed recovery, so it must
1314 // not wait for that process. The view remains alive for this
1315 // bounded best-effort reconciliation.
1316 let view = Arc::clone(self);
1317 tokio::spawn(async move { view.finish_holder_disconnect().await });
1318 } else {
1319 // The local reducer has no transport round trip and its effects are
1320 // deterministic, so preserve synchronous completion for that path.
1321 Arc::clone(self).finish_holder_disconnect().await;
1322 }
1323 true
1324 }
1325
1326 async fn finish_holder_disconnect(self: Arc<Self>) {
1327 let effects = self
1328 .browser
1329 .control_best_effort(ViewControl::HolderDisconnected)
1330 .await;
1331 // `GraceArming::AlreadyArmed`: `note_disconnect` armed the clock before
1332 // relaying, so a `StartGracePeriod` coming back here is the reducer
1333 // AGREEING, not a second clock to start.
1334 self.apply_effects(effects, GraceArming::AlreadyArmed).await;
1335 self.refresh_presentation().await;
1336 }
1337
1338 /// The run that owned this browser ended: no ceremony, the user's
1339 /// browser again.
1340 ///
1341 /// Deliberately a method on the VIEW, not a registry lookup by key. The
1342 /// run-end signal is asynchronous (see `mcp_assistant::BrowserViewGuard`),
1343 /// so re-resolving the key at fire time can land on whatever view holds it
1344 /// by then — clearing the strip and re-opening input to everyone on a
1345 /// SUCCESSOR run's browser while its agent is actively driving. Holding
1346 /// the `Arc` makes the signal act on the identity it was created for, and
1347 /// a late arrival for a replaced view is then simply inert.
1348 pub async fn note_run_ended(self: &Arc<Self>) {
1349 self.run_ended
1350 .store(true, std::sync::atomic::Ordering::Release);
1351 let effects = self
1352 .browser
1353 .control_best_effort(ViewControl::RunEnded)
1354 .await;
1355 self.apply_effects(
1356 effects,
1357 GraceArming::Arm {
1358 require_unwatched: false,
1359 },
1360 )
1361 .await;
1362 {
1363 // Only when the run end actually released ownership. A run
1364 // ending UNDER a person who is driving leaves them driving (the
1365 // reducer defers it to hand-back), and clearing the holder there
1366 // would lock that person out of the very page they hold control
1367 // of — `require_control`'s `User` arm answers on the holder.
1368 let still_user_driven = self.browser.control_status().await.owner == ControlOwner::User;
1369 let mut control = self.control.lock().await;
1370 if !still_user_driven {
1371 control.holder = None;
1372 // Bumped in the SAME branch that cleared the holder, not
1373 // unconditionally. The generation is the only thing the grace
1374 // timer checks before acting, so bumping it here invalidated
1375 // an in-flight timer that nothing re-arms — and in the
1376 // deferred case (the run ends while a person still holds
1377 // control) that timer is the one thing that would ever return
1378 // the view from `owner: user` + blackout to `none`. A
1379 // disconnect followed by a run end stranded it there.
1380 control.generation += 1;
1381 }
1382 }
1383 self.refresh_presentation().await;
1384 }
1385
1386 /// Act on what the control reducer asked for. Exhaustive by design — a
1387 /// new effect must be a compile error here, not a silently ignored one.
1388 ///
1389 /// `grace` says what a [`ControlEffect::StartGracePeriod`] means at THIS
1390 /// call site; see [`GraceArming`].
1391 async fn apply_effects(self: &Arc<Self>, effects: Vec<ControlEffect>, grace: GraceArming) {
1392 for effect in effects {
1393 match effect {
1394 ControlEffect::StartGracePeriod => match grace {
1395 GraceArming::Arm { require_unwatched } => {
1396 self.spawn_grace_timer_inner(require_unwatched).await
1397 }
1398 GraceArming::AlreadyArmed => {}
1399 },
1400 ControlEffect::SignInResolved { signed_in } => {
1401 // Nothing to do here: `browser_await_signin` polls its
1402 // own pending state and returns this same answer to the
1403 // agent (see `BrowserTools::run_await_signin`). Logged
1404 // so the honest-resolution path is traceable.
1405 tracing::debug!(
1406 view = ?self.key,
1407 signed_in,
1408 "browser view: pending sign-in resolved as a side effect"
1409 );
1410 }
1411 }
1412 }
1413 }
1414
1415 /// A connection that was WATCHING this view went away.
1416 ///
1417 /// Distinct from [`Self::note_disconnect`], which is about the control
1418 /// HOLDER — and the distinction is the whole point. The ordinary sign-in
1419 /// flow never involves Take control (the strip IS the affordance, and
1420 /// `require_control` admits input while a sign-in is pending so the person
1421 /// can type without pressing anything), so their window has no holder for
1422 /// `note_disconnect` to match. Since round 8 that window OUTLIVES the run:
1423 /// an agent-side ending may not clear a strip somebody is standing at. So
1424 /// this is the only signal left that says they are not coming back, and
1425 /// without it a person who closed the laptop mid-sign-in would leave the
1426 /// blackout latched for the daemon's life — wedging every later run's
1427 /// browse call behind it.
1428 ///
1429 /// Armed only when nobody is watching any more: another connection still
1430 /// subscribed means the drawer is still there. Re-checked at expiry too.
1431 ///
1432 /// Never called for a connection that held control: the registry keys this
1433 /// off `note_disconnect`'s return value, because the ordinary drawer is
1434 /// BOTH holder and watcher and the two arm grace timers with different
1435 /// semantics. The `holder.is_some()` guard below covers the other shape —
1436 /// someone ELSE is driving — and cannot see this connection's own holder
1437 /// record, which `note_disconnect` clears before returning.
1438 pub async fn note_watcher_disconnect(self: &Arc<Self>) {
1439 {
1440 let control = self.control.lock().await;
1441 if control.holder.is_some() {
1442 // Somebody else is driving: their `note_disconnect` owns the
1443 // clock. Arming here too would race it.
1444 return;
1445 }
1446 }
1447 if !self.fanout.lock().await.subscribers.is_empty() {
1448 return;
1449 }
1450 if matches!(&self.browser, ViewBrowser::Relay(_)) {
1451 // Same bound and same reason as `note_disconnect`: a silent
1452 // supervised process costs up to 2×`RELAY_CALL_TIMEOUT` (~60s),
1453 // and disconnect teardown must not inherit it. Nothing else arms a
1454 // timer on this view for this disconnect, so detaching the
1455 // reconciliation costs no determinism.
1456 let view = Arc::clone(self);
1457 tokio::spawn(async move { view.finish_watcher_disconnect().await });
1458 } else {
1459 // The local reducer has no transport round trip, so keep the
1460 // synchronous completion the local tests are written against.
1461 Arc::clone(self).finish_watcher_disconnect().await;
1462 }
1463 }
1464
1465 async fn finish_watcher_disconnect(self: Arc<Self>) {
1466 let effects = self
1467 .browser
1468 .control_best_effort(ViewControl::HolderDisconnected)
1469 .await;
1470 // The reducer arms only for a person-facing window (`owner == User`,
1471 // or a pending sign-in), so a view with nothing open is untouched.
1472 if effects.is_empty() {
1473 return;
1474 }
1475 self.spawn_grace_timer_inner(true).await;
1476 }
1477
1478 /// `require_unwatched` is the watcher-disconnect variant: it refuses to
1479 /// fire if a drawer came back inside the window, because the person
1480 /// returning is precisely what says their sign-in window is still theirs.
1481 /// The holder variant does NOT take that check — a different connection
1482 /// watching says nothing about a holder who left, and skipping the expiry
1483 /// there would park the agent behind them forever.
1484 async fn spawn_grace_timer_inner(self: &Arc<Self>, require_unwatched: bool) {
1485 let generation = {
1486 let mut control = self.control.lock().await;
1487 control.generation += 1;
1488 control.generation
1489 };
1490 let view = Arc::downgrade(self);
1491 tokio::spawn(async move {
1492 tokio::time::sleep(CONTROL_GRACE).await;
1493 let Some(view) = view.upgrade() else { return };
1494 {
1495 let mut control = view.control.lock().await;
1496 // Somebody took or handed back control inside the window —
1497 // this expiry is stale and reverting now would yank control
1498 // from whoever holds it legitimately.
1499 //
1500 // The holder check is defense in depth alongside generation:
1501 // `note_disconnect` clears the old holder before arming, so a
1502 // holder being present here necessarily took control later.
1503 //
1504 // Generation is only load-bearing because a disconnect arms
1505 // EXACTLY ONCE — see `GraceArming::AlreadyArmed`. A second
1506 // arming from the detached relay reply would capture the
1507 // re-taker's generation and leave the holder check as the only
1508 // thing standing between a legitimate re-take and revocation.
1509 if control.generation != generation || control.holder.is_some() {
1510 return;
1511 }
1512 control.holder = None;
1513 }
1514 // Control lock released above, deliberately: this is the only
1515 // place that would nest control inside fanout, and the check
1516 // needs no atomicity with the generation read — a drawer that
1517 // arrives after it simply gets the window ended a moment early,
1518 // which is what the un-engaged contract does anyway.
1519 if require_unwatched && !view.fanout.lock().await.subscribers.is_empty() {
1520 return;
1521 }
1522 view.browser
1523 .control_best_effort(ViewControl::GraceExpired)
1524 .await;
1525 view.refresh_presentation().await;
1526 });
1527 }
1528
1529 // ---- the streamer -------------------------------------------------
1530
1531 async fn ensure_streamer(self: &Arc<Self>) {
1532 let mut capture = self.capture.lock().await;
1533 // A local producer's pump can die (its browser went away); a relayed
1534 // one has no daemon-side task at all, so "no task" is not "not
1535 // running" — `active` is what says whether capture is on.
1536 let pump_died = match capture.task.as_ref() {
1537 Some(task) => task.is_finished(),
1538 None => false,
1539 };
1540 if capture.active && !pump_died {
1541 return;
1542 }
1543 if let Some(task) = capture.task.take() {
1544 task.abort();
1545 }
1546 capture.task = self.browser.start_capture(Arc::downgrade(self)).await;
1547 capture.active = true;
1548 }
1549
1550 /// Stop capture unconditionally — the replaced-view teardown
1551 /// (`adopt`) and the eviction path, both of which are retiring this view
1552 /// whatever it still holds.
1553 async fn stop_streamer(&self) {
1554 self.stop_streamer_inner(false).await;
1555 }
1556
1557 /// Stop capture ONLY if nobody is subscribed, re-checked under the
1558 /// fanout lock rather than on the caller's stale reading.
1559 ///
1560 /// `unsubscribe` computes "empty" under the fanout lock and then
1561 /// RELEASES it before deciding to stop, so a `subscribe` can land in that
1562 /// window — and `ensure_streamer` returns early for it, seeing a live
1563 /// pump. Stopping anyway left that subscriber holding a successful reply
1564 /// with a snapshot and a cursor that never advanced again: zero events
1565 /// means the cursor-gap contract it would recover through can never fire,
1566 /// so the drawer freezes on one frame indefinitely.
1567 async fn stop_streamer_if_unwatched(&self) {
1568 self.stop_streamer_inner(true).await;
1569 }
1570
1571 /// `capture` is taken before `fanout` here. That is the only order any
1572 /// path takes — `subscribe`, `adopt` and `unsubscribe` all drop `fanout`
1573 /// before touching `capture` — so the re-check adds no cycle.
1574 async fn stop_streamer_inner(&self, only_if_unwatched: bool) {
1575 let mut capture = self.capture.lock().await;
1576 if !capture.active {
1577 return;
1578 }
1579 if only_if_unwatched && !self.fanout.lock().await.subscribers.is_empty() {
1580 return;
1581 }
1582 capture.active = false;
1583 if let Some(task) = capture.task.take() {
1584 task.abort();
1585 // Await the cancellation so the streamer's frame receiver is
1586 // actually dropped before we ask the fan-out to prune — without
1587 // this, CDP capture would keep running until the next frame
1588 // happened to arrive.
1589 let _ = task.await;
1590 }
1591 drop(capture);
1592 self.browser.stop_capture().await;
1593 }
1594}
1595
1596/// Push one event to every subscriber of this view. Best-effort: a wedged
1597/// subscriber's full channel drops the event rather than stalling the
1598/// producer; the client detects the cursor gap and re-subscribes.
1599fn fanout_locked(subscribers: &HashMap<String, BrowserViewSubscriber>, event: &BrowserViewEvent) {
1600 for (client_id, subscriber) in subscribers.iter() {
1601 if !subscriber.push(event.clone()) {
1602 tracing::debug!(
1603 client_id,
1604 cursor = event.cursor,
1605 "browser view: dropped event for a slow subscriber (channel full)"
1606 );
1607 }
1608 }
1609}
1610
1611/// Turn presentation changes and screencast frames into cursor-stamped
1612/// events for as long as anyone is subscribed. The DAEMON-owned browser's
1613/// pump; a relayed producer pushes instead (see [`crate::browser_relay`]).
1614///
1615/// Holds only a `Weak` to the view, so a view nobody references any more
1616/// ends this task instead of keeping the browser alive.
1617async fn stream(view: std::sync::Weak<BrowserView>, tools: Arc<BrowserTools>) {
1618 let mut changes = tools.subscribe_changes();
1619 let (mut frames, _epoch) = tools.subscribe_frames().await;
1620 let mut tabs = tools.subscribe_tabs().await;
1621
1622 loop {
1623 let Some(view) = view.upgrade() else { return };
1624 tokio::select! {
1625 changed = changes.changed() => {
1626 if changed.is_err() {
1627 return;
1628 }
1629 // A browser may have just launched — bind the tab watch if
1630 // we could not before.
1631 if tabs.is_none() {
1632 tabs = tools.subscribe_tabs().await;
1633 }
1634 view.refresh_presentation().await;
1635 }
1636 // Only polled when a browser exists; `tabs` is rebound above
1637 // when one appears.
1638 tabs_changed = async {
1639 match tabs.as_mut() {
1640 Some(rx) => rx.changed().await.is_ok(),
1641 None => std::future::pending().await,
1642 }
1643 } => {
1644 if !tabs_changed {
1645 tabs = None;
1646 continue;
1647 }
1648 view.refresh_presentation().await;
1649 }
1650 frame = frames.recv() => {
1651 match frame {
1652 Some(frame) => view.emit_frame(frame).await,
1653 // The fan-out dropped our consumer (a pump generation
1654 // ended with nobody listening). Re-register rather than
1655 // spinning on a closed channel.
1656 None => {
1657 let (rx, _epoch) = tools.subscribe_frames().await;
1658 frames = rx;
1659 }
1660 }
1661 }
1662 }
1663 }
1664}
1665
1666// ---------------------------------------------------------------------------
1667// Registry
1668// ---------------------------------------------------------------------------
1669
1670/// Every browser the drawer can reach, keyed by conversation/agent-session —
1671/// plus the one standing session shared by every conversation that has no
1672/// agent-attached browser of its own (controller ruling R6).
1673pub struct BrowserViewRegistry {
1674 views: Mutex<HashMap<Option<String>, Arc<BrowserView>>>,
1675 /// Supervised agent processes currently publishing a browser, keyed by
1676 /// their WS connection. Lives here because producers and views are torn
1677 /// down on the same disconnect boundary.
1678 producers: ProducerRegistry,
1679 /// Working root for the standing session's `BrowserTools`. Only its
1680 /// recording output would land here, and the standing session has no
1681 /// agent to start one — it exists because `BrowserTools` requires one.
1682 root: PathBuf,
1683 /// Where a RELAYED browser's pending-sign-in transition becomes an
1684 /// operator-facing `host.event` — see [`crate::browser_attention`].
1685 /// Installed once by `ServerState`, which owns the `HostState` this
1686 /// broadcasts on; absent in every test that stands up a bare registry,
1687 /// which then behaves exactly as before.
1688 signin_attention: std::sync::OnceLock<Arc<dyn SignInAttention>>,
1689}
1690
1691impl BrowserViewRegistry {
1692 pub fn new(root: PathBuf) -> Self {
1693 Self {
1694 views: Mutex::new(HashMap::new()),
1695 producers: ProducerRegistry::default(),
1696 root,
1697 signin_attention: std::sync::OnceLock::new(),
1698 }
1699 }
1700
1701 /// Install the operator-attention sink relayed views report sign-in waits
1702 /// through. Called once, by `ServerState::with_config`; a second call is a
1703 /// silent no-op.
1704 pub fn set_signin_attention(&self, attention: Arc<dyn SignInAttention>) {
1705 let _ = self.signin_attention.set(attention);
1706 }
1707
1708 /// The installed sink, for a view that is about to be created.
1709 fn signin_attention(&self) -> Option<Arc<dyn SignInAttention>> {
1710 self.signin_attention.get().cloned()
1711 }
1712
1713 /// The standing user session, created on first reference.
1714 ///
1715 /// Creating it does NOT launch Chromium — `BrowserTools` launches
1716 /// lazily, and for this view the trigger is the user's first navigation
1717 /// (see `BrowserTools::user_navigate`). Opening the drawer on an empty
1718 /// standing session therefore costs nothing and shows the empty state,
1719 /// which is exactly what the design asks for.
1720 pub async fn standing(&self) -> Arc<BrowserView> {
1721 let mut views = self.views.lock().await;
1722 Arc::clone(views.entry(None).or_insert_with(|| {
1723 // Its OWN persistent profile (`~/.car/browser-profile-user`),
1724 // not the agent's. Chromium allows exactly one live instance per
1725 // profile directory, and "an agent browses while the person uses
1726 // the drawer" is an ordinary situation — sharing one directory
1727 // made whichever launched second die on SingletonLock. Nothing in
1728 // the design requires the two to share cookies; the standing
1729 // session's own sign-ins persist across launches independently.
1730 let tools = BrowserTools::standing_session(self.root.clone());
1731 // Task 7's decision rule: this browser is reachable ONLY through
1732 // browser.view.*, which every method on this surface requires the
1733 // host-management client for (checked before any view is even
1734 // resolved — see this module's own authorization doc comment).
1735 // Whatever call is about to trigger its lazy launch
1736 // (user_navigate / user_open_tab) is therefore ITSELF coming
1737 // from a connected host, by construction — no live probe
1738 // needed, unlike the in-daemon agent path.
1739 tools.set_host_connectivity(Arc::new(AlwaysConnected));
1740 // No sign-in attention, deliberately: this browser is reachable
1741 // only through `browser.view.*` user input, never through an
1742 // agent's tools, so `browser_await_signin` can never run against
1743 // it and a pending sign-in can never arise here. Nothing to
1744 // announce — and announcing the user's own sign-ins would be
1745 // noise about something they are already looking at.
1746 Arc::new(BrowserView::new(None, Arc::new(tools)))
1747 }))
1748 }
1749
1750 /// Publish an agent-attached browser under a conversation/agent-session
1751 /// key, so the drawer can watch that conversation specifically.
1752 ///
1753 /// **This is the producer API.** Anything that builds an assistant
1754 /// runtime hands the runtime's own `BrowserTools`
1755 /// (`AssistantRuntime::browser`) in here; the registry never creates an
1756 /// agent's browser itself. Registering costs nothing for a run that never
1757 /// browses — `BrowserTools` launches Chromium lazily.
1758 ///
1759 /// **Replacement is the lifetime bound.** A conversation's view outlives
1760 /// the run that created it (see [`BrowserView::note_run_ended`]), so the thing that
1761 /// eventually releases the old browser is a NEW run registering for the
1762 /// SAME key. That makes the standing cost one idle Chromium per
1763 /// conversation whose agent actually browsed, not one per run.
1764 ///
1765 /// Subscribers and the event cursor are handed over to the new view, so a
1766 /// drawer watching this conversation follows it to the new browser
1767 /// without re-subscribing — and, critically, without the cursor going
1768 /// backwards, which would break gap detection far worse than a gap does.
1769 pub async fn register(
1770 &self,
1771 conversation_id: impl Into<String>,
1772 tools: Arc<BrowserTools>,
1773 ) -> Arc<BrowserView> {
1774 let key = Some(conversation_id.into());
1775 let view = Arc::new(BrowserView::new(key.clone(), tools));
1776 let previous = self.views.lock().await.insert(key, Arc::clone(&view));
1777 if let Some(previous) = previous {
1778 view.adopt(&previous).await;
1779 previous.resolve_signin_attention_on_teardown().await;
1780 }
1781 view
1782 }
1783
1784 /// Publish a SUPERVISED AGENT PROCESS's browser under a conversation key.
1785 ///
1786 /// The relay twin of [`Self::register`], with one difference that matters:
1787 /// **the same process re-claiming its own conversation is a no-op.** A
1788 /// supervised agent registers on every turn, and churning the view each
1789 /// time would reset its cursor and drop the drawer's stream for no reason.
1790 /// A DIFFERENT process claiming the key — the supervisor restarted it —
1791 /// replaces the view through the ordinary `adopt` path, so a drawer that
1792 /// never unsubscribed follows the agent across without the cursor moving
1793 /// backwards.
1794 pub async fn register_relay(
1795 &self,
1796 conversation_id: impl Into<String>,
1797 producer: Arc<RelayProducer>,
1798 ) -> Arc<BrowserView> {
1799 let key = Some(conversation_id.into());
1800 if let Some(existing) = self.views.lock().await.get(&key) {
1801 if existing.is_served_by(&producer) {
1802 return Arc::clone(existing);
1803 }
1804 }
1805 let view = Arc::new(BrowserView::with_browser(
1806 key.clone(),
1807 ViewBrowser::Relay(Arc::clone(&producer)),
1808 ));
1809 // Attach BEFORE the map insert so a push that lands in the window
1810 // reaches the view a concurrent subscriber may already hold.
1811 producer.attach_view(&view).await;
1812 let previous = self.views.lock().await.insert(key, Arc::clone(&view));
1813 if let Some(previous) = previous {
1814 view.adopt(&previous).await;
1815 previous.resolve_signin_attention_on_teardown().await;
1816 }
1817 // The relay path's operator attention belongs to the producer: one
1818 // process has one browser even though it can back many per-turn views.
1819 producer
1820 .set_signin_attention(self.signin_attention(), view.key.clone())
1821 .await;
1822 self.retire_views_past_the_cap(&producer).await;
1823 view
1824 }
1825
1826 /// A registration replaces this producer's oldest views — see
1827 /// [`crate::browser_relay::MAX_VIEWS_PER_PRODUCER`] for why the cap is
1828 /// where it is and why "retire" is not "delete".
1829 async fn retire_views_past_the_cap(&self, producer: &Arc<RelayProducer>) {
1830 for view in producer.views_past_the_cap().await {
1831 // The atomic only, NOT `note_run_ended`: that relays a run-end
1832 // transition to the process, which is still serving its other
1833 // conversations. This is the eviction flag, exactly as
1834 // `note_producer_disconnected` sets it.
1835 view.run_ended
1836 .store(true, std::sync::atomic::Ordering::Release);
1837 self.release_if_idle(&view).await;
1838 // The binding goes with it, released or not. It exists to entitle
1839 // a re-registration between turns, and past this cap there is no
1840 // re-registration to entitle: the agent's own `known` deque has
1841 // already forgotten this conversation. Left in place it was the
1842 // one per-turn entry `note_producer_disconnected` deliberately
1843 // does NOT reclaim, growing for the daemon's life.
1844 if let Some(key) = view.key.as_deref() {
1845 self.producers.forget_binding(key).await;
1846 }
1847 }
1848 }
1849
1850 /// The producer for an agent connection, created on its first
1851 /// registration.
1852 pub async fn producer_for(
1853 &self,
1854 client_id: &str,
1855 agent_id: &str,
1856 channel: &Arc<WsChannel>,
1857 ) -> Arc<RelayProducer> {
1858 self.producers
1859 .get_or_create(client_id, agent_id, channel)
1860 .await
1861 }
1862
1863 /// Tell every supervised agent process that host connectivity changed.
1864 ///
1865 /// They cache the answer — a supervised process has no read of the
1866 /// daemon's session set — and it decides whether `browser_await_signin`
1867 /// points the user at the drawer or tells them to open the CAR app.
1868 pub async fn broadcast_host_connected(&self, connected: bool) {
1869 self.producers.broadcast_host_connected(connected).await;
1870 }
1871
1872 /// The producer a given connection registered, if any. The gate on
1873 /// inbound pushes: a connection that never registered has none.
1874 pub async fn producer(&self, client_id: &str) -> Option<Arc<RelayProducer>> {
1875 self.producers.get(client_id).await
1876 }
1877
1878 /// The agent entitled to publish a conversation, and how that gets
1879 /// recorded. See `browser_relay::authorize_conversation_claim`.
1880 pub async fn conversation_owner(&self, conversation_id: &str) -> Option<String> {
1881 self.producers.conversation_owner(conversation_id).await
1882 }
1883
1884 pub async fn bind_conversation(&self, conversation_id: &str, agent_id: &str) {
1885 self.producers
1886 .bind_conversation(conversation_id, agent_id)
1887 .await;
1888 }
1889
1890 /// An agent connection dropped — its browser went with the process. Its
1891 /// views stay registered, reporting an empty browser, so a restarted
1892 /// process can replace them and carry the drawer across.
1893 pub async fn note_producer_disconnected(&self, client_id: &str) {
1894 let producer = self.producers.get(client_id).await;
1895 self.producers.note_disconnected(client_id).await;
1896 let Some(producer) = producer else { return };
1897
1898 // The producer's process is gone, so every view it served has, in the
1899 // only sense that matters here, had its run end. Marking that is what
1900 // makes those views EVICTABLE at all: `release_if_idle` returns early
1901 // on `!run_ended`, and `run_ended` was set only by the in-daemon
1902 // run-end guard — so a relay view could never be released by any path,
1903 // and each retired conversation permanently retained a map entry, its
1904 // `RelayProducer`, and that producer's `Arc<WsChannel>` — the write
1905 // half of a dead socket, whose file descriptor could then never close.
1906 //
1907 // Releasing only the UNWATCHED ones preserves the restart-adoption
1908 // behaviour this deliberately kept: a view a drawer is still
1909 // subscribed to stays registered, so a restarted process replaces it
1910 // in place and the drawer follows across without re-subscribing. What
1911 // goes is a view for a conversation nobody is watching and no process
1912 // is serving.
1913 let views: Vec<Arc<BrowserView>> = self
1914 .views
1915 .lock()
1916 .await
1917 .values()
1918 .filter(|view| view.is_served_by(&producer))
1919 .cloned()
1920 .collect();
1921 for view in views {
1922 view.run_ended
1923 .store(true, std::sync::atomic::Ordering::Release);
1924 self.release_if_idle(&view).await;
1925 }
1926 }
1927
1928 /// Release a view whose run has ended and that **nobody is subscribed
1929 /// to**: drop it from the map (and with it the last reference to the
1930 /// browser it was serving) and stop its capture. Reports whether it went.
1931 ///
1932 /// This is the eviction path the lifetime bound needs. Replacement —
1933 /// "a NEW run registering for the SAME key" — is the documented bound,
1934 /// and it is genuinely unreachable for a producer that mints a fresh key
1935 /// per run: the in-daemon `assistant_start` path keys on
1936 /// `mcp-run-<uuid>`, so before this, every assistant run that browsed
1937 /// left one idle Chromium registered for the daemon's whole lifetime.
1938 ///
1939 /// It does NOT weaken "a view outlives the run that created it". A
1940 /// subscribed view is never released, so the drawer still keeps showing
1941 /// the last page exactly as the agent left it, with agent-opened tabs
1942 /// usable, for as long as anything is actually watching. What goes is a
1943 /// finished run's browser that no drawer ever attached to — which nobody
1944 /// can observe, and which is precisely the leak.
1945 ///
1946 /// Identity-checked, never key-checked: a successor may already hold the
1947 /// key by the time an asynchronous run-end signal arrives, and removing
1948 /// by key would take a LIVE run's browser down. Same reasoning as
1949 /// [`BrowserView::note_run_ended`] being a method on the view.
1950 pub async fn release_if_idle(&self, view: &Arc<BrowserView>) -> bool {
1951 if !view.run_ended.load(std::sync::atomic::Ordering::Acquire) {
1952 return false;
1953 }
1954 // The emptiness decision and the map removal happen under ONE HELD
1955 // fanout lock. Reading `is_idle()` and then removing would repeat the
1956 // hazard `stop_streamer_if_unwatched` exists for: `subscribe` does a
1957 // CDP tab read (`refresh_presentation`) BEFORE it inserts, so the
1958 // window between "observed empty" and "removed" is round trips wide,
1959 // and a subscriber landing inside it gets a successful reply, a
1960 // cursor, and then no event ever again — zero events means the
1961 // cursor-gap recovery can never fire.
1962 //
1963 // `fanout` then `views` is the only nesting this crate has: `get`,
1964 // `register` and `drop_subscriptions_for_client` all release the
1965 // registry lock before touching a view's fanout, so there is no cycle.
1966 let released = {
1967 let fanout = view.fanout.lock().await;
1968 if !fanout.subscribers.is_empty() {
1969 return false;
1970 }
1971 let mut views = self.views.lock().await;
1972 match views.get(&view.key) {
1973 Some(current) if Arc::ptr_eq(current, view) => views.remove(&view.key).is_some(),
1974 _ => false,
1975 }
1976 };
1977 if !released {
1978 return false;
1979 }
1980 if !self.stop_or_restore(view).await {
1981 return false;
1982 }
1983 view.resolve_signin_attention_on_teardown().await;
1984 true
1985 }
1986
1987 /// Durable state returned by `host.subscribe`, independent of the bounded
1988 /// host-event backlog. Relay producers are deduplicated because every view
1989 /// they back shows the same one process-owned browser.
1990 pub async fn pending_signins(&self) -> Vec<BrowserSignInSnapshot> {
1991 let views: Vec<Arc<BrowserView>> = self.views.lock().await.values().cloned().collect();
1992 let mut seen_producers = HashSet::new();
1993 let mut pending = Vec::new();
1994 for view in views {
1995 match &view.browser {
1996 ViewBrowser::Local(tools) => {
1997 if let Some(message) = tools.pending_signin_message().await {
1998 pending.push(BrowserSignInSnapshot::new(view.key.as_deref(), message));
1999 }
2000 }
2001 ViewBrowser::Relay(producer) => {
2002 if seen_producers.insert(producer.client_id().to_string()) {
2003 if let Some(signin) = producer.signin_snapshot().await {
2004 pending.push(signin);
2005 }
2006 }
2007 }
2008 }
2009 }
2010 pending.sort_by(|a, b| a.conversation_id.cmp(&b.conversation_id));
2011 pending
2012 }
2013
2014 /// The second half of [`Self::release_if_idle`]: the view is out of the
2015 /// registry, so either stop its stream or undo the removal.
2016 ///
2017 /// A subscriber can still arrive between the removal and this call — it
2018 /// took its `Arc` from the registry BEFORE the entry went, which no lock
2019 /// can undo. The guarded stop refuses in that case, leaving the stream
2020 /// running, and the view is then put BACK: a live stream on a key that no
2021 /// longer resolves would render frames while every input and control call
2022 /// answered "no browser view for conversation".
2023 async fn stop_or_restore(&self, view: &Arc<BrowserView>) -> bool {
2024 view.stop_streamer_if_unwatched().await;
2025 if !view.fanout.lock().await.subscribers.is_empty() {
2026 let mut views = self.views.lock().await;
2027 views
2028 .entry(view.key.clone())
2029 .or_insert_with(|| Arc::clone(view));
2030 return false;
2031 }
2032 true
2033 }
2034
2035 pub async fn get(&self, conversation_id: Option<&str>) -> Option<Arc<BrowserView>> {
2036 match conversation_id {
2037 None => Some(self.standing().await),
2038 Some(id) => self
2039 .views
2040 .lock()
2041 .await
2042 .get(&Some(id.to_string()))
2043 .map(Arc::clone),
2044 }
2045 }
2046
2047 /// Disconnect cleanup: drop this connection's subscriptions everywhere,
2048 /// and start the control grace period on any view it was driving.
2049 pub async fn drop_subscriptions_for_client(&self, client_id: &str) {
2050 let views: Vec<Arc<BrowserView>> = self.views.lock().await.values().cloned().collect();
2051 // Concurrently, not one after another. Both relay-backed cleanups
2052 // settle local state synchronously and then detach the process
2053 // reconciliation, so disconnect teardown never inherits the relay's
2054 // bound — which is up to 2×`RELAY_CALL_TIMEOUT` (~60s), a bounded
2055 // write plus a bounded wait for the reply, not 30s.
2056 futures::future::join_all(views.into_iter().map(|view| async move {
2057 let was_watching = view.unsubscribe(client_id).await;
2058 let held_control = view.note_disconnect(client_id, was_watching).await;
2059 // A person mid-sign-in who never pressed Take control has no
2060 // holder record, so `note_disconnect` above is inert for them —
2061 // and since round 8 their window survives the run ending. This is
2062 // what gives it an exit.
2063 //
2064 // Skipped when this same connection held control, which is the
2065 // ordinary drawer shape: the holder is also a subscriber, so
2066 // `was_watching` alone would fire both. Two timers then retire
2067 // each other by generation, and which semantics survives is
2068 // decided by whichever relay reply lands last. Exactly one path
2069 // owns each disconnect — and `was_watching` is handed to
2070 // `note_disconnect` above precisely so the surviving timer still
2071 // carries the WATCHER semantics for that shape.
2072 if was_watching && !held_control {
2073 view.note_watcher_disconnect().await;
2074 }
2075 // The second eviction trigger (the first is the run ending): a
2076 // view whose run already ended and whose last watcher just went
2077 // away has nothing left to show anyone.
2078 self.release_if_idle(&view).await;
2079 }))
2080 .await;
2081 }
2082}
2083
2084impl Default for BrowserViewRegistry {
2085 fn default() -> Self {
2086 Self::new(car_home::root().unwrap_or_else(std::env::temp_dir))
2087 }
2088}
2089
2090// ---------------------------------------------------------------------------
2091// Handlers
2092// ---------------------------------------------------------------------------
2093
2094#[derive(Debug, Default, Deserialize)]
2095struct ViewParams {
2096 #[serde(default)]
2097 conversation_id: Option<String>,
2098}
2099
2100/// Parse the shared `{ conversation_id? }` envelope. Absent params are the
2101/// standing session, so a bare `{}` — or no params at all — is valid.
2102fn view_params(req: &JsonRpcMessage) -> Result<ViewParams, String> {
2103 if req.params.is_null() {
2104 return Ok(ViewParams::default());
2105 }
2106 serde_json::from_value(req.params.clone())
2107 .map_err(|e| format!("browser.view.* takes an optional {{ conversation_id }}: {e}"))
2108}
2109
2110/// Every `browser.view.*` method requires the host-management client. See
2111/// the module docs for why this is stricter than `runs.subscribe`.
2112fn authorize(session: &ClientSession) -> Result<(), String> {
2113 if session.is_host.load(std::sync::atomic::Ordering::Acquire) {
2114 return Ok(());
2115 }
2116 tracing::debug!(
2117 client_id = %session.client_id,
2118 "browser.view.* denied: connection is not the host management client"
2119 );
2120 Err(
2121 "not authorized to use browser.view.*: this connection is not the host management \
2122 client (session.auth { host_token })"
2123 .to_string(),
2124 )
2125}
2126
2127async fn resolve(
2128 state: &Arc<ServerState>,
2129 conversation_id: Option<&str>,
2130) -> Result<Arc<BrowserView>, String> {
2131 match state.browser_views.get(conversation_id).await {
2132 Some(view) => Ok(view),
2133 None => Err(format!(
2134 "no browser view for conversation '{}' — that conversation has no agent-attached \
2135 browser; omit `conversation_id` for the standing session",
2136 conversation_id.unwrap_or_default()
2137 )),
2138 }
2139}
2140
2141fn snapshot_value(conversation_id: Option<&str>, snapshot: (WirePresentation, u64)) -> Value {
2142 let (presentation, cursor) = snapshot;
2143 json!({
2144 "conversation_id": conversation_id,
2145 "standing_session": conversation_id.is_none(),
2146 "cursor": cursor,
2147 "presentation": presentation,
2148 })
2149}
2150
2151/// `browser.view.subscribe { conversation_id? }` — snapshot + cursor, then
2152/// pushed `browser.view.event` notifications.
2153pub async fn handle_subscribe(
2154 req: &JsonRpcMessage,
2155 session: &ClientSession,
2156 state: &Arc<ServerState>,
2157) -> Result<Value, String> {
2158 authorize(session)?;
2159 let params = view_params(req)?;
2160 let view = resolve(state, params.conversation_id.as_deref()).await?;
2161 let snapshot = view
2162 .subscribe(&session.client_id, session.channel.clone())
2163 .await;
2164 Ok(snapshot_value(params.conversation_id.as_deref(), snapshot))
2165}
2166
2167/// `browser.view.unsubscribe { conversation_id? }` — idempotent.
2168pub async fn handle_unsubscribe(
2169 req: &JsonRpcMessage,
2170 session: &ClientSession,
2171 state: &Arc<ServerState>,
2172) -> Result<Value, String> {
2173 authorize(session)?;
2174 let params = view_params(req)?;
2175 let view = resolve(state, params.conversation_id.as_deref()).await?;
2176 let removed = view.unsubscribe(&session.client_id).await;
2177 // The ORDINARY teardown path, and it was the one that never released.
2178 // `release_if_idle` was wired to the run-end guard and to the disconnect
2179 // sweep — but a disconnect is the exceptional route; closing the drawer or
2180 // switching conversations is this plain RPC over a live socket. A view
2181 // whose run had already ended therefore kept its `Arc<BrowserTools>`, and
2182 // its Chromium, for the daemon's lifetime — once per watched run, with the
2183 // documented replacement bound unable to fire because the key carries a
2184 // fresh uuid.
2185 //
2186 // Unconditional is safe: it returns early unless the run has ended, and
2187 // re-checks emptiness under the fanout lock before removing anything.
2188 state.browser_views.release_if_idle(&view).await;
2189 Ok(json!({
2190 "conversation_id": params.conversation_id,
2191 "removed": removed,
2192 }))
2193}
2194
2195/// `browser.view.take_control { conversation_id? }`.
2196pub async fn handle_take_control(
2197 req: &JsonRpcMessage,
2198 session: &ClientSession,
2199 state: &Arc<ServerState>,
2200) -> Result<Value, String> {
2201 authorize(session)?;
2202 let params = view_params(req)?;
2203 let view = resolve(state, params.conversation_id.as_deref()).await?;
2204 let snapshot = view.take_control(&session.client_id).await?;
2205 Ok(snapshot_value(params.conversation_id.as_deref(), snapshot))
2206}
2207
2208/// `browser.view.hand_back { conversation_id? }`.
2209pub async fn handle_hand_back(
2210 req: &JsonRpcMessage,
2211 session: &ClientSession,
2212 state: &Arc<ServerState>,
2213) -> Result<Value, String> {
2214 authorize(session)?;
2215 let params = view_params(req)?;
2216 let view = resolve(state, params.conversation_id.as_deref()).await?;
2217 let snapshot = view.hand_back(&session.client_id).await?;
2218 Ok(snapshot_value(params.conversation_id.as_deref(), snapshot))
2219}
2220
2221/// The input methods. One enum + one handler keeps the authorization,
2222/// view resolution and control check in exactly one place — the thing that
2223/// must never differ between navigate and click.
2224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2225pub enum InputOp {
2226 Navigate,
2227 Click,
2228 Type,
2229 Keypress,
2230 Scroll,
2231 Paste,
2232 Back,
2233 Forward,
2234 Reload,
2235 TabOpen,
2236 TabClose,
2237 TabSwitch,
2238}
2239
2240#[derive(Debug, Deserialize)]
2241struct InputParams {
2242 #[serde(default)]
2243 conversation_id: Option<String>,
2244 #[serde(default)]
2245 url: Option<String>,
2246 #[serde(default)]
2247 x: Option<f64>,
2248 #[serde(default)]
2249 y: Option<f64>,
2250 #[serde(default)]
2251 text: Option<String>,
2252 #[serde(default)]
2253 key: Option<String>,
2254 #[serde(default)]
2255 modifiers: Vec<String>,
2256 #[serde(default)]
2257 delta_y: Option<i32>,
2258 #[serde(default)]
2259 tab_id: Option<String>,
2260}
2261
2262/// Map a wire modifier name onto car-browser's own enum. Unknown names are
2263/// rejected rather than ignored — a typo'd modifier silently dropping is how
2264/// a paste shortcut turns into a stray keystroke in a page.
2265pub(crate) fn parse_modifier(name: &str) -> Result<Modifier, String> {
2266 match name.to_ascii_lowercase().as_str() {
2267 "alt" | "option" => Ok(Modifier::Alt),
2268 "control" | "ctrl" => Ok(Modifier::Control),
2269 "meta" | "command" | "cmd" => Ok(Modifier::Meta),
2270 "shift" => Ok(Modifier::Shift),
2271 other => Err(format!(
2272 "unknown modifier '{other}' — use alt, control, meta, or shift"
2273 )),
2274 }
2275}
2276
2277/// `browser.view.{navigate,click,type,keypress,scroll,tab_open,tab_close,tab_switch}`.
2278pub async fn handle_input(
2279 op: InputOp,
2280 req: &JsonRpcMessage,
2281 session: &ClientSession,
2282 state: &Arc<ServerState>,
2283) -> Result<Value, String> {
2284 authorize(session)?;
2285 let params: InputParams = if req.params.is_null() {
2286 serde_json::from_value(json!({})).map_err(|e| e.to_string())?
2287 } else {
2288 serde_json::from_value(req.params.clone())
2289 .map_err(|e| format!("invalid browser.view input params: {e}"))?
2290 };
2291 let view = resolve(state, params.conversation_id.as_deref()).await?;
2292 view.require_control(&session.client_id).await?;
2293
2294 // Params are validated HERE, before the view is touched — so a malformed
2295 // call is refused identically whether the browser is in this process or
2296 // in a supervised agent's, and a relayed one never pays a round trip for
2297 // a request that could never have worked.
2298 let input = match op {
2299 InputOp::Navigate => ViewInput::Navigate {
2300 url: params.url.ok_or("browser.view.navigate requires { url }")?,
2301 },
2302 InputOp::Click => match (params.x, params.y) {
2303 (Some(x), Some(y)) => ViewInput::Click { x, y },
2304 _ => return Err("browser.view.click requires { x, y }".to_string()),
2305 },
2306 InputOp::Type => ViewInput::Type {
2307 text: params.text.ok_or("browser.view.type requires { text }")?,
2308 },
2309 InputOp::Keypress => ViewInput::Keypress {
2310 key: params.key.ok_or("browser.view.keypress requires { key }")?,
2311 modifiers: params
2312 .modifiers
2313 .iter()
2314 .map(|m| parse_modifier(m))
2315 .collect::<Result<Vec<_>, _>>()?,
2316 },
2317 InputOp::Scroll => ViewInput::Scroll {
2318 delta_y: params
2319 .delta_y
2320 .ok_or("browser.view.scroll requires { delta_y }")?,
2321 },
2322 InputOp::Paste => ViewInput::Paste {
2323 text: params.text.ok_or("browser.view.paste requires { text }")?,
2324 },
2325 // No params of their own: which page Back/Forward/Reload act on is
2326 // the active tab's history, which the browser already knows.
2327 InputOp::Back => ViewInput::Back,
2328 InputOp::Forward => ViewInput::Forward,
2329 InputOp::Reload => ViewInput::Reload,
2330 InputOp::TabOpen => ViewInput::TabOpen,
2331 InputOp::TabClose => ViewInput::TabClose {
2332 tab_id: params
2333 .tab_id
2334 .ok_or("browser.view.tab_close requires { tab_id }")?,
2335 },
2336 InputOp::TabSwitch => ViewInput::TabSwitch {
2337 tab_id: params
2338 .tab_id
2339 .ok_or("browser.view.tab_switch requires { tab_id }")?,
2340 },
2341 };
2342 let opened_tab = view.browser.input(input).await?;
2343
2344 // No explicit refresh here on purpose. Every input that can change what
2345 // the drawer renders (navigate, click, keypress, the tab operations)
2346 // signals the change from inside `BrowserTools`, and the streamer
2347 // coalesces those into one presentation event — whereas refreshing here
2348 // would cost a CDP sweep per open tab on EVERY call, including the ones
2349 // that cannot change the tab strip at all (type, scroll).
2350 let mut out = json!({
2351 "ok": true,
2352 "conversation_id": params.conversation_id,
2353 });
2354 if let (Some(out), Some(tab_id)) = (out.as_object_mut(), opened_tab) {
2355 out.insert("tab_id".to_string(), json!(tab_id));
2356 }
2357 Ok(out)
2358}
2359
2360#[cfg(test)]
2361mod tests {
2362 use super::*;
2363 use crate::assistant::browser_control::ControlEvent;
2364 use crate::session::{ServerStateConfig, WsSink};
2365 use futures::StreamExt;
2366
2367 fn capture_channel() -> (
2368 Arc<WsChannel>,
2369 futures::channel::mpsc::UnboundedReceiver<Message>,
2370 ) {
2371 use futures::sink::SinkExt as _;
2372 let (tx, rx) = futures::channel::mpsc::unbounded::<Message>();
2373 let sink: WsSink =
2374 Box::pin(tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed));
2375 let channel = Arc::new(WsChannel {
2376 write: Mutex::new(sink),
2377 pending: Mutex::new(HashMap::new()),
2378 active_actions: Mutex::new(HashMap::new()),
2379 next_id: std::sync::atomic::AtomicU64::new(0),
2380 });
2381 (channel, rx)
2382 }
2383
2384 /// A channel whose sink accepts ONE message and then blocks forever —
2385 /// the drain task parks on its second write, exactly like a half-open
2386 /// socket whose TCP buffer is full.
2387 fn wedged_channel() -> (Arc<WsChannel>, futures::channel::mpsc::Receiver<Message>) {
2388 use futures::sink::SinkExt as _;
2389 let (tx, rx) = futures::channel::mpsc::channel::<Message>(1);
2390 let sink: WsSink =
2391 Box::pin(tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed));
2392 let channel = Arc::new(WsChannel {
2393 write: Mutex::new(sink),
2394 pending: Mutex::new(HashMap::new()),
2395 active_actions: Mutex::new(HashMap::new()),
2396 next_id: std::sync::atomic::AtomicU64::new(0),
2397 });
2398 (channel, rx)
2399 }
2400
2401 fn test_view() -> Arc<BrowserView> {
2402 Arc::new(BrowserView::new(
2403 None,
2404 Arc::new(BrowserTools::new(std::env::temp_dir())),
2405 ))
2406 }
2407
2408 async fn next_event(
2409 rx: &mut futures::channel::mpsc::UnboundedReceiver<Message>,
2410 ) -> BrowserViewEvent {
2411 let frame = tokio::time::timeout(Duration::from_secs(2), rx.next())
2412 .await
2413 .expect("an event within the deadline")
2414 .expect("a frame");
2415 let text = match frame {
2416 Message::Text(t) => t.to_string(),
2417 other => panic!("expected a text frame, got {other:?}"),
2418 };
2419 let json: Value = serde_json::from_str(&text).unwrap();
2420 assert_eq!(json["method"], "browser.view.event");
2421 serde_json::from_value(json["params"].clone()).expect("a browser.view.event payload")
2422 }
2423
2424 // ---- snapshot + cursor consistency ---------------------------------
2425
2426 #[tokio::test]
2427 async fn subscribe_returns_a_snapshot_and_cursor_and_events_advance_it_by_one() {
2428 let view = test_view();
2429 view.tools().attach_agent_for_test().await;
2430 let (channel, mut rx) = capture_channel();
2431 let (snapshot, cursor) = view.subscribe("host-1", channel).await;
2432 assert_eq!(snapshot.owner, WireOwner::Agent);
2433 assert!(snapshot.tabs.is_empty());
2434 assert!(!snapshot.blackout_active);
2435
2436 // Three state changes → three events, cursors strictly +1.
2437 view.tools().take_control().await;
2438 view.refresh_presentation().await;
2439 let first = next_event(&mut rx).await;
2440 assert_eq!(first.cursor, cursor + 1);
2441
2442 view.tools().hand_back().await;
2443 view.refresh_presentation().await;
2444 let second = next_event(&mut rx).await;
2445 assert_eq!(second.cursor, cursor + 2);
2446
2447 view.emit_frame(test_frame(3)).await;
2448 let third = next_event(&mut rx).await;
2449 assert_eq!(third.cursor, cursor + 3);
2450 match third.payload {
2451 BrowserViewPayload::Frame { frame } => {
2452 assert_eq!(BASE64.decode(frame.jpeg_base64).unwrap(), vec![3]);
2453 assert_eq!(frame.width, 1920);
2454 }
2455 BrowserViewPayload::Presentation { .. } => panic!("expected a frame event"),
2456 }
2457 }
2458
2459 /// Take control of WHAT? With no agent attached there is no ceremony to
2460 /// enter, so `take_control` records nothing and the snapshot still reports
2461 /// `WireOwner::None`.
2462 ///
2463 /// Standalone because the reconnect/resume rewrite of
2464 /// `re_subscribing_yields_a_fresh_snapshot_at_the_current_cursor` took this
2465 /// assertion with it, and every other `take_control` call site in this
2466 /// module attaches an agent first. What it guards: a change that let
2467 /// ownership be recorded with no agent attached would put the drawer into
2468 /// a privacy blackout for a browser nobody is driving, with no hand-back
2469 /// affordance pointing at anyone.
2470 #[tokio::test]
2471 async fn take_control_with_no_agent_attached_records_no_owner() {
2472 let view = test_view();
2473 view.tools().take_control().await;
2474
2475 let (channel, _rx) = capture_channel();
2476 let (snapshot, _) = view.subscribe("host-1", channel).await;
2477 assert_eq!(snapshot.owner, WireOwner::None);
2478 assert!(
2479 !snapshot.blackout_active,
2480 "and no blackout for a browser nobody is driving"
2481 );
2482 }
2483
2484 #[tokio::test]
2485 async fn an_unchanged_presentation_emits_nothing_and_does_not_burn_a_cursor() {
2486 let view = test_view();
2487 let (channel, mut rx) = capture_channel();
2488 let (_, cursor) = view.subscribe("host-1", channel).await;
2489 view.refresh_presentation().await;
2490 view.refresh_presentation().await;
2491 assert_eq!(view.fanout.lock().await.cursor, cursor);
2492 assert!(rx.try_recv().is_err(), "nothing changed, nothing emitted");
2493 }
2494
2495 #[tokio::test]
2496 async fn re_subscribing_yields_a_fresh_snapshot_at_the_current_cursor() {
2497 let view = test_view();
2498 let (channel, mut rx) = capture_channel();
2499 view.subscribe("host-1", channel).await;
2500 view.emit_frame(test_frame(1)).await;
2501 assert_eq!(next_event(&mut rx).await.cursor, 1);
2502 assert!(view.unsubscribe("host-1").await);
2503
2504 // Advance while disconnected. The reconnect must snapshot this missed
2505 // cursor, then resume with the immediately following event.
2506 view.emit_frame(test_frame(2)).await;
2507
2508 let (channel2, mut rx2) = capture_channel();
2509 let (snapshot, cursor) = view.subscribe("host-1", channel2).await;
2510 assert_eq!(snapshot.owner, WireOwner::None);
2511 assert_eq!(cursor, 2, "the reconnect snapshots the event it missed");
2512 assert_eq!(cursor, view.fanout.lock().await.cursor);
2513
2514 view.emit_frame(test_frame(3)).await;
2515 assert_eq!(next_event(&mut rx2).await.cursor, 3);
2516 }
2517
2518 // ---- explicit fanout, and the slow subscriber ----------------------
2519
2520 #[tokio::test]
2521 async fn two_subscribers_both_get_every_event_and_dropping_one_leaves_the_other_streaming() {
2522 let view = test_view();
2523 let (channel_a, mut rx_a) = capture_channel();
2524 let (channel_b, mut rx_b) = capture_channel();
2525 view.subscribe("host-a", channel_a).await;
2526 view.subscribe("host-b", channel_b).await;
2527
2528 view.emit_frame(test_frame(1)).await;
2529 assert_eq!(next_event(&mut rx_a).await.cursor, 1);
2530 assert_eq!(next_event(&mut rx_b).await.cursor, 1);
2531
2532 assert!(view.unsubscribe("host-a").await);
2533 view.emit_frame(test_frame(2)).await;
2534 assert_eq!(
2535 next_event(&mut rx_b).await.cursor,
2536 2,
2537 "the surviving subscriber keeps streaming"
2538 );
2539 assert!(
2540 rx_a.try_recv().is_err(),
2541 "the dropped subscriber receives nothing further"
2542 );
2543 }
2544
2545 #[tokio::test]
2546 async fn unsubscribing_twice_is_idempotent() {
2547 let view = test_view();
2548 let (channel, _rx) = capture_channel();
2549 view.subscribe("host-1", channel).await;
2550 assert!(view.unsubscribe("host-1").await);
2551 assert!(!view.unsubscribe("host-1").await);
2552 assert_eq!(view.subscriber_count().await, 0);
2553 }
2554
2555 /// The bounded-fanout property: a subscriber that stops draining is
2556 /// dropped from the stream rather than blocking the producer. Here the
2557 /// WS sink is never read, so the drain task parks on its first write and
2558 /// the channel fills; every push past the cap is DROPPED, and the
2559 /// producer keeps returning promptly.
2560 #[tokio::test]
2561 async fn a_slow_subscriber_loses_events_instead_of_blocking_the_producer() {
2562 let view = test_view();
2563 // A wedged socket: a capacity-1 sink nobody reads. The drain task
2564 // parks on its second write, the bounded channel behind it fills,
2565 // and every push past the cap is dropped.
2566 let (channel, _rx) = wedged_channel();
2567 view.subscribe("host-slow", channel).await;
2568
2569 let pushes = BROWSER_VIEW_CHANNEL_CAP * 4;
2570 let start = std::time::Instant::now();
2571 for i in 0..pushes {
2572 view.emit_frame(test_frame(i as u8)).await;
2573 }
2574 assert!(
2575 start.elapsed() < Duration::from_secs(2),
2576 "the producer must never park behind a wedged subscriber"
2577 );
2578 assert_eq!(
2579 view.fanout.lock().await.cursor,
2580 pushes as u64,
2581 "every event was stamped; the wedged subscriber simply lost most of them"
2582 );
2583
2584 // And the view is still healthy for everyone else.
2585 let (good, mut good_rx) = capture_channel();
2586 view.subscribe("host-ok", good).await;
2587 view.emit_frame(test_frame(0)).await;
2588 assert_eq!(next_event(&mut good_rx).await.cursor, pushes as u64 + 1);
2589 }
2590
2591 // ---- control ownership at the wire level ---------------------------
2592
2593 #[tokio::test]
2594 async fn with_no_agent_involved_anyone_may_drive() {
2595 let view = test_view();
2596 view.require_control("host-1")
2597 .await
2598 .expect("zero ceremony: the standing session is just a browser");
2599 }
2600
2601 #[tokio::test]
2602 async fn while_the_agent_drives_nobody_may_input() {
2603 let view = test_view();
2604 view.tools().attach_agent_for_test().await;
2605 let err = view.require_control("host-1").await.unwrap_err();
2606 assert!(err.contains("take_control"), "got: {err}");
2607 }
2608
2609 #[tokio::test]
2610 async fn take_control_moves_input_rights_to_that_connection_and_hand_back_returns_them() {
2611 let view = test_view();
2612 view.tools().attach_agent_for_test().await;
2613
2614 let (snapshot, _) = view
2615 .take_control("host-1")
2616 .await
2617 .expect("a local view never fails");
2618 assert_eq!(snapshot.owner, WireOwner::User);
2619 assert!(
2620 snapshot.blackout_active,
2621 "user control blacks the model out"
2622 );
2623 view.require_control("host-1")
2624 .await
2625 .expect("the control holder may drive");
2626 let err = view.require_control("host-2").await.unwrap_err();
2627 assert!(
2628 err.contains("another connection holds control"),
2629 "got: {err}"
2630 );
2631
2632 let (snapshot, _) = view
2633 .hand_back("host-1")
2634 .await
2635 .expect("a local view never fails");
2636 assert_eq!(snapshot.owner, WireOwner::Agent);
2637 assert!(!snapshot.blackout_active);
2638 assert!(view.require_control("host-1").await.is_err());
2639 }
2640
2641 /// The sign-in strip hands the page to the human without a Take control
2642 /// press — the credential fields have to accept their typing.
2643 #[tokio::test]
2644 async fn a_pending_signin_lets_the_user_type_without_taking_control() {
2645 let view = test_view();
2646 view.tools().attach_agent_for_test().await;
2647 assert!(view.require_control("host-1").await.is_err());
2648
2649 view.tools()
2650 .apply_control_for_test(ControlEvent::SignInRequested("Sign in at x".into()))
2651 .await;
2652 view.require_control("host-1")
2653 .await
2654 .expect("the human must be able to type their password");
2655
2656 let (snapshot, _) = view.snapshot().await;
2657 assert_eq!(snapshot.pending_signin.as_deref(), Some("Sign in at x"));
2658 assert!(snapshot.blackout_active);
2659 }
2660
2661 /// Sign-in lifecycle, hand-back path: the strip clears, control returns
2662 /// to the agent, and the blackout lifts — all visible on the wire.
2663 #[tokio::test]
2664 async fn hand_back_resolves_a_pending_signin_on_the_wire() {
2665 let view = test_view();
2666 let (channel, mut rx) = capture_channel();
2667 view.tools().attach_agent_for_test().await;
2668 view.subscribe("host-1", channel).await;
2669
2670 view.tools()
2671 .apply_control_for_test(ControlEvent::SignInRequested("Sign in at x".into()))
2672 .await;
2673 view.refresh_presentation().await;
2674 let pending = next_event(&mut rx).await;
2675 match pending.payload {
2676 BrowserViewPayload::Presentation { presentation } => {
2677 assert_eq!(presentation.pending_signin.as_deref(), Some("Sign in at x"));
2678 assert!(presentation.blackout_active);
2679 }
2680 BrowserViewPayload::Frame { .. } => panic!("expected a presentation event"),
2681 }
2682
2683 let (snapshot, _) = view
2684 .hand_back("host-1")
2685 .await
2686 .expect("a local view never fails");
2687 assert_eq!(snapshot.pending_signin, None, "the strip clears");
2688 assert_eq!(snapshot.owner, WireOwner::Agent);
2689 assert!(!snapshot.blackout_active);
2690 }
2691
2692 /// Sign-in lifecycle, timeout path: `browser_await_signin`'s own timeout
2693 /// resolves the strip with nobody handing anything back, and the drawer
2694 /// sees exactly that.
2695 #[tokio::test]
2696 async fn a_timed_out_signin_clears_the_strip_and_leaves_the_agent_driving() {
2697 let view = test_view();
2698 view.tools().attach_agent_for_test().await;
2699 view.tools()
2700 .apply_control_for_test(ControlEvent::SignInRequested("Sign in at x".into()))
2701 .await;
2702 view.tools()
2703 .apply_control_for_test(ControlEvent::SignInResolved { signed_in: false })
2704 .await;
2705
2706 let (snapshot, _) = view.snapshot().await;
2707 assert_eq!(snapshot.pending_signin, None);
2708 assert_eq!(
2709 snapshot.owner,
2710 WireOwner::Agent,
2711 "a timeout returns control to the agent, unchanged from today"
2712 );
2713 assert!(!snapshot.blackout_active);
2714 }
2715
2716 // ---- disconnect + grace period -------------------------------------
2717
2718 #[tokio::test(start_paused = true)]
2719 async fn control_reverts_to_the_agent_after_the_grace_period() {
2720 let view = test_view();
2721 view.tools().attach_agent_for_test().await;
2722 view.take_control("host-1").await.unwrap();
2723
2724 view.note_disconnect("host-1", false).await;
2725 assert_eq!(
2726 view.snapshot().await.0.owner,
2727 WireOwner::User,
2728 "still the user's during the grace window"
2729 );
2730
2731 tokio::time::sleep(CONTROL_GRACE + Duration::from_secs(1)).await;
2732 assert_eq!(view.snapshot().await.0.owner, WireOwner::Agent);
2733 assert!(view.control.lock().await.holder.is_none());
2734 }
2735
2736 #[tokio::test(start_paused = true)]
2737 async fn a_disconnect_by_a_connection_that_never_held_control_starts_no_timer() {
2738 let view = test_view();
2739 view.tools().attach_agent_for_test().await;
2740 view.take_control("host-1").await.unwrap();
2741
2742 view.note_disconnect("host-2", false).await;
2743 tokio::time::sleep(CONTROL_GRACE + Duration::from_secs(1)).await;
2744 assert_eq!(
2745 view.snapshot().await.0.owner,
2746 WireOwner::User,
2747 "host-1 still holds control; host-2 leaving is irrelevant"
2748 );
2749 }
2750
2751 #[tokio::test(start_paused = true)]
2752 async fn taking_control_again_inside_the_window_survives_the_stale_expiry() {
2753 let view = test_view();
2754 view.tools().attach_agent_for_test().await;
2755 view.take_control("host-1").await.unwrap();
2756 view.note_disconnect("host-1", false).await;
2757
2758 // The host reconnects and takes control again before the window
2759 // closes; the in-flight timer must not yank it away afterwards.
2760 tokio::time::sleep(Duration::from_secs(1)).await;
2761 view.take_control("host-2").await.unwrap();
2762 tokio::time::sleep(CONTROL_GRACE + Duration::from_secs(1)).await;
2763
2764 assert_eq!(view.snapshot().await.0.owner, WireOwner::User);
2765 assert_eq!(view.control.lock().await.holder.as_deref(), Some("host-2"));
2766 }
2767
2768 // ---- run lifecycle --------------------------------------------------
2769
2770 #[tokio::test]
2771 async fn a_run_ending_returns_the_browser_to_the_user_with_no_ceremony() {
2772 // The agent still held control when its run ended, so there is
2773 // nothing to hand back: the strip disappears and every control is
2774 // live immediately, with no Take control click.
2775 let view = test_view();
2776 view.tools().attach_agent_for_test().await;
2777
2778 view.note_run_ended().await;
2779 let (snapshot, _) = view.snapshot().await;
2780 assert_eq!(snapshot.owner, WireOwner::None);
2781 assert_eq!(snapshot.current_action, None);
2782 assert!(!snapshot.blackout_active);
2783 view.require_control("host-1")
2784 .await
2785 .expect("every control accepts input immediately after a run ends");
2786 }
2787
2788 /// The other half of the same bullet pair, on the wire: a run ending
2789 /// while the USER holds control does not reclaim it, and does not lift
2790 /// the blackout — the drawer keeps saying the user is driving until they
2791 /// hand back. (Live verification caught the drawer reporting
2792 /// `owner=none blackout=false` with the person still at the keyboard.)
2793 #[tokio::test]
2794 async fn a_run_ending_while_the_user_drives_leaves_control_and_the_blackout_alone() {
2795 let view = test_view();
2796 view.tools().attach_agent_for_test().await;
2797 view.take_control("host-1").await.unwrap();
2798
2799 view.note_run_ended().await;
2800 let (snapshot, _) = view.snapshot().await;
2801 assert_eq!(snapshot.owner, WireOwner::User, "still the user's");
2802 assert!(
2803 snapshot.blackout_active,
2804 "and the model still cannot see the screen"
2805 );
2806 assert!(
2807 view.require_control("host-2").await.is_err(),
2808 "nor does it re-open input to every connection"
2809 );
2810 view.require_control("host-1")
2811 .await
2812 .expect("the person who holds control must still be able to type");
2813
2814 // Hand-back is what ends the ceremony — and with the run gone there
2815 // is no agent to hand back to, so it lands on the no-agent state.
2816 let (snapshot, _) = view.hand_back("host-1").await.unwrap();
2817 assert_eq!(snapshot.owner, WireOwner::None);
2818 assert!(!snapshot.blackout_active);
2819 view.require_control("host-1").await.expect("user-drivable");
2820 }
2821
2822 /// The interleaving the disconnect sweep produces on an ordinary app
2823 /// reconnect: `unsubscribe` computes "empty" under the fanout lock and
2824 /// releases it, the reconnected Command Deck subscribes on a NEW
2825 /// `client_id` (and `ensure_streamer` returns early, seeing a live pump),
2826 /// and only then does the sweep's stop land. Stopping there left the new
2827 /// subscriber with a successful reply, a cursor, and no event ever again
2828 /// — and because ZERO events arrive, the cursor-gap recovery this module
2829 /// relies on can never fire, so the drawer freezes on one frame.
2830 #[tokio::test]
2831 async fn a_stop_that_lands_after_a_new_subscriber_arrived_leaves_the_stream_running() {
2832 let view = test_view();
2833 let (channel_a, _rx_a) = capture_channel();
2834 view.subscribe("host-a", channel_a).await;
2835 assert!(
2836 view.capture.lock().await.active,
2837 "the first subscriber arms capture"
2838 );
2839
2840 // The sweep has already decided "empty" for host-a. Before its stop
2841 // lands, the reconnected connection subscribes.
2842 let (channel_b, _rx_b) = capture_channel();
2843 view.subscribe("host-b", channel_b).await;
2844
2845 // The late stop.
2846 view.stop_streamer_if_unwatched().await;
2847 assert!(
2848 view.capture.lock().await.active,
2849 "a subscriber reappeared, so the stop must not land"
2850 );
2851
2852 // And the unconditional form still tears a replaced view down.
2853 view.stop_streamer().await;
2854 assert!(!view.capture.lock().await.active);
2855 }
2856
2857 /// `hand_back` was the one control method that enforced nothing, so a
2858 /// second host connection could revert the browser to the agent —
2859 /// resolving the holder's pending sign-in and lifting the privacy
2860 /// blackout — while that person was mid-sign-in.
2861 #[tokio::test]
2862 async fn hand_back_is_refused_from_a_connection_that_does_not_hold_control() {
2863 let view = test_view();
2864 view.tools().attach_agent_for_test().await;
2865 view.take_control("host-1").await.unwrap();
2866
2867 let err = view.hand_back("host-2").await.unwrap_err();
2868 assert!(
2869 err.contains("another connection holds control"),
2870 "got: {err}"
2871 );
2872 assert_eq!(
2873 view.snapshot().await.0.owner,
2874 WireOwner::User,
2875 "the holder keeps control"
2876 );
2877
2878 view.hand_back("host-1")
2879 .await
2880 .expect("the holder may hand back");
2881 assert_eq!(view.snapshot().await.0.owner, WireOwner::Agent);
2882 }
2883
2884 /// The presentation is read OUTSIDE the fanout lock (for a local view
2885 /// that read is a live CDP round trip), so two concurrent refreshes have
2886 /// no ordering guarantee — and the loser used to win the lock second and
2887 /// publish its OLDER state at a HIGHER cursor. Owner, URL, tabs and
2888 /// blackout all regress on the drawer, `fanout.last` goes stale with
2889 /// them, and contiguous cursors mean gap detection cannot fire.
2890 #[tokio::test]
2891 async fn an_older_concurrent_read_never_overwrites_a_newer_published_one() {
2892 let view = test_view();
2893 let (channel, mut rx) = capture_channel();
2894 view.subscribe("host-1", channel).await;
2895
2896 // The newer read lands first: the agent attaches, and that is
2897 // published.
2898 view.tools().attach_agent_for_test().await;
2899 view.refresh_presentation().await;
2900 let newer = next_event(&mut rx).await;
2901 let BrowserViewPayload::Presentation { presentation } = newer.payload else {
2902 panic!("expected a presentation event")
2903 };
2904 assert_eq!(presentation.owner, WireOwner::Agent);
2905 let published_revision = presentation.revision;
2906
2907 // Now the older in-flight read completes. Feeding it directly is the
2908 // whole point: this is the state a slower `browser.presentation()`
2909 // call started before the transition above.
2910 let stale = WirePresentation {
2911 revision: published_revision - 1,
2912 owner: WireOwner::None,
2913 ..presentation.clone()
2914 };
2915 view.publish_presentation_for_test(stale).await;
2916
2917 assert!(
2918 tokio::time::timeout(Duration::from_millis(50), rx.next())
2919 .await
2920 .is_err(),
2921 "an older read must not be emitted after a newer one"
2922 );
2923 assert_eq!(
2924 view.fanout.lock().await.last.owner,
2925 WireOwner::Agent,
2926 "and the cached snapshot every later subscriber gets must not go stale"
2927 );
2928 }
2929
2930 /// The sign-in bypass opens the AGENT's browser to the person without a
2931 /// Take control press. It must not also open a browser somebody else
2932 /// already took: `TakeControl` leaves `pending_signin` set, so
2933 /// `owner == User && signin_pending` is the credential-entry window, and a
2934 /// blanket bypass admitted every other authorized connection into it.
2935 #[tokio::test]
2936 async fn a_pending_signin_does_not_admit_input_from_a_non_holder() {
2937 let view = test_view();
2938 view.tools().attach_agent_for_test().await;
2939 view.tools()
2940 .apply_control_for_test(
2941 crate::assistant::browser_control::ControlEvent::SignInRequested(
2942 "Sign in at x".into(),
2943 ),
2944 )
2945 .await;
2946
2947 // Before anyone takes control the bypass still does its job: the
2948 // person types into the agent's browser with no ceremony.
2949 view.require_control("host-1")
2950 .await
2951 .expect("the sign-in strip IS the affordance");
2952
2953 view.take_control("host-1").await.unwrap();
2954 view.require_control("host-1")
2955 .await
2956 .expect("the holder keeps typing");
2957 let err = view.require_control("host-2").await.unwrap_err();
2958 assert!(
2959 err.contains("another connection holds control"),
2960 "a second connection must not interleave into the password field: {err}"
2961 );
2962 }
2963
2964 /// The drain task deregisters on exit — and a re-subscribe on the SAME
2965 /// connection is what ends the old one, by replacing its entry and
2966 /// closing its channel. Removing by `client_id` alone therefore deleted
2967 /// the registration the new subscribe had just installed, and the trigger
2968 /// is this surface's own documented recovery: a cursor gap makes the
2969 /// drawer re-subscribe the same key on the same connection. One dropped
2970 /// frame killed the drawer permanently.
2971 #[tokio::test]
2972 async fn a_re_subscribe_on_the_same_connection_survives_the_old_drain_task_exiting() {
2973 let view = test_view();
2974 let (first, _rx1) = capture_channel();
2975 view.subscribe("host-1", first).await;
2976 let (second, mut rx2) = capture_channel();
2977 view.subscribe("host-1", second).await;
2978
2979 // Let the replaced subscriber's drain task notice its closed channel
2980 // and run its exit path.
2981 for _ in 0..200 {
2982 tokio::task::yield_now().await;
2983 }
2984
2985 assert_eq!(
2986 view.subscriber_count().await,
2987 1,
2988 "the live registration must survive the replaced one's teardown"
2989 );
2990 // And it is the SECOND channel that is still being served.
2991 view.emit_frame(test_frame(7)).await;
2992 let event = next_event(&mut rx2).await;
2993 match event.payload {
2994 BrowserViewPayload::Frame { .. } => {}
2995 BrowserViewPayload::Presentation { .. } => panic!("expected a frame"),
2996 }
2997 }
2998
2999 /// Round 1 fixed this race on `unsubscribe`'s stop path and left the
3000 /// twin open on `release_if_idle`, which read `is_idle()`, released the
3001 /// fanout lock, removed the view, and then took the UNGUARDED stop. The
3002 /// window is round trips wide: `subscribe` does a CDP tab read before it
3003 /// inserts.
3004 #[tokio::test]
3005 async fn a_release_that_races_a_new_subscriber_keeps_both_the_stream_and_the_view() {
3006 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3007 let view = registry
3008 .register("conv-1", Arc::new(BrowserTools::new(std::env::temp_dir())))
3009 .await;
3010 view.note_run_ended().await;
3011
3012 // The window itself: the release has already removed the entry, and
3013 // the subscriber lands holding the `Arc` it took from the registry
3014 // beforehand — which no lock can undo, so this is the state the
3015 // second half has to cope with rather than prevent.
3016 registry.views.lock().await.remove(&view.key);
3017 let (channel, _rx) = capture_channel();
3018 view.subscribe("host-late", channel).await;
3019
3020 assert!(
3021 !registry.stop_or_restore(&view).await,
3022 "a subscriber arrived, so the release must not complete"
3023 );
3024 assert!(
3025 view.capture.lock().await.active,
3026 "its stream must not be stopped underneath that subscriber — zero events \
3027 means the cursor-gap recovery can never fire, so the drawer would freeze"
3028 );
3029 assert!(
3030 registry
3031 .get(Some("conv-1"))
3032 .await
3033 .is_some_and(|v| Arc::ptr_eq(&v, &view)),
3034 "and the key must resolve again, or every input and control call would \
3035 answer 'no browser view for conversation' while frames kept arriving"
3036 );
3037
3038 // Once it really is unwatched, the release goes through.
3039 view.unsubscribe("host-late").await;
3040 assert!(registry.release_if_idle(&view).await);
3041 assert!(registry.get(Some("conv-1")).await.is_none());
3042 }
3043
3044 /// The hand-back gate did not fire in the scenario its own doc comment
3045 /// describes: `take_control` overwrote the holder unconditionally, so a
3046 /// second connection reached the same end state in two calls instead of
3047 /// one.
3048 #[tokio::test]
3049 async fn take_control_is_refused_from_a_connection_that_does_not_hold_control() {
3050 let view = test_view();
3051 view.tools().attach_agent_for_test().await;
3052 view.take_control("host-1").await.unwrap();
3053
3054 let err = view.take_control("host-2").await.unwrap_err();
3055 assert!(
3056 err.contains("another connection holds control"),
3057 "got: {err}"
3058 );
3059 assert_eq!(
3060 view.control.lock().await.holder.as_deref(),
3061 Some("host-1"),
3062 "the holder is unchanged, so hand_back still refuses host-2 too"
3063 );
3064 assert!(view.hand_back("host-2").await.is_err());
3065 }
3066
3067 /// `TakeControl` is a documented no-op on the standing session (`NoAgent`
3068 /// — nobody to take it from), but the holder was recorded anyway. With
3069 /// the gate above that stale holder would then lock every other
3070 /// connection out of a browser nobody had actually taken.
3071 #[tokio::test]
3072 async fn take_control_records_no_holder_when_the_reducer_did_not_move_ownership() {
3073 let view = test_view();
3074 let (snapshot, _) = view.take_control("host-1").await.unwrap();
3075 assert_eq!(snapshot.owner, WireOwner::None, "documented no-op");
3076 assert!(
3077 view.control.lock().await.holder.is_none(),
3078 "nobody took control, so nobody holds it"
3079 );
3080 view.take_control("host-2")
3081 .await
3082 .expect("and another connection is not locked out");
3083 }
3084
3085 /// Disconnect-then-run-end. `note_run_ended` bumped the control
3086 /// generation unconditionally, which is the ONLY thing the grace timer
3087 /// checks — so the timer armed by the disconnect became stale and never
3088 /// fired, and because the run end is DEFERRED while a person holds
3089 /// control, nothing else ever cleared `owner: user` or the blackout.
3090 #[tokio::test(start_paused = true)]
3091 async fn a_run_ending_after_the_holder_disconnected_still_reverts_when_grace_expires() {
3092 let view = test_view();
3093 view.tools().attach_agent_for_test().await;
3094 view.take_control("host-1").await.unwrap();
3095 view.note_disconnect("host-1", false).await;
3096
3097 // The run ends inside the grace window, with the user still nominally
3098 // driving — so the reducer defers, and the timer is the only way out.
3099 tokio::time::sleep(Duration::from_secs(1)).await;
3100 view.note_run_ended().await;
3101 assert_eq!(
3102 view.snapshot().await.0.owner,
3103 WireOwner::User,
3104 "deferred, as designed"
3105 );
3106
3107 tokio::time::sleep(CONTROL_GRACE + Duration::from_secs(1)).await;
3108 let (snapshot, _) = view.snapshot().await;
3109 assert_eq!(
3110 snapshot.owner,
3111 WireOwner::None,
3112 "the grace timer must still fire — nothing else re-arms it"
3113 );
3114 assert!(
3115 !snapshot.blackout_active,
3116 "and the blackout must not outlive the vanished controller"
3117 );
3118 }
3119
3120 /// `note_disconnect` used to leave `holder` set and rely on
3121 /// `ControlEffect::StartGracePeriod` to clear it — but
3122 /// `control_best_effort` swallows a failed transition and returns NO
3123 /// effects (a wedged agent process hitting `RELAY_CALL_TIMEOUT` is the
3124 /// ordinary case), so no timer was spawned and `holder` named a dead
3125 /// connection forever, with `require_control` refusing every other
3126 /// connection's input.
3127 #[tokio::test]
3128 async fn a_disconnect_clears_the_holder_even_when_the_reducer_asks_for_no_grace_period() {
3129 let view = test_view();
3130 view.tools().attach_agent_for_test().await;
3131 view.take_control("host-1").await.unwrap();
3132 assert!(
3133 view.require_control("host-2").await.is_err(),
3134 "host-1 is driving"
3135 );
3136
3137 view.note_disconnect("host-1", false).await;
3138 assert!(
3139 view.control.lock().await.holder.is_none(),
3140 "the connection is provably gone"
3141 );
3142 view.require_control("host-2")
3143 .await
3144 .expect("a reconnected drawer must be able to drive, and to hand back");
3145 view.hand_back("host-2")
3146 .await
3147 .expect("nobody holds control, so hand-back is admitted");
3148 }
3149
3150 // ---- registry + error paths ----------------------------------------
3151
3152 #[tokio::test]
3153 async fn the_standing_session_is_one_shared_view_and_launches_nothing() {
3154 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3155 let a = registry.standing().await;
3156 let b = registry.get(None).await.expect("always resolvable");
3157 assert!(
3158 Arc::ptr_eq(&a, &b),
3159 "every conversation without an agent browser shares ONE standing session"
3160 );
3161 assert!(
3162 a.snapshot().await.0.tabs.is_empty(),
3163 "opening the drawer must not launch Chromium"
3164 );
3165 }
3166
3167 #[tokio::test]
3168 async fn an_unknown_conversation_resolves_to_nothing() {
3169 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3170 assert!(registry.get(Some("nope")).await.is_none());
3171 }
3172
3173 #[tokio::test]
3174 async fn a_registered_conversation_is_its_own_view() {
3175 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3176 let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3177 let view = registry.register("conv-1", Arc::clone(&tools)).await;
3178 tools.attach_agent_for_test().await;
3179
3180 let found = registry.get(Some("conv-1")).await.expect("registered");
3181 assert!(Arc::ptr_eq(&view, &found));
3182 assert_eq!(found.snapshot().await.0.owner, WireOwner::Agent);
3183 assert!(
3184 !Arc::ptr_eq(&found, ®istry.standing().await),
3185 "an agent's browser is not the standing session"
3186 );
3187 }
3188
3189 /// The browser OUTLIVES its run: when the run ends the strip disappears,
3190 /// every control accepts input immediately, and the view is still there
3191 /// to be subscribed and driven — the last page exactly as the agent left
3192 /// it, agent-opened tabs still usable.
3193 #[tokio::test]
3194 async fn a_run_ending_leaves_the_browser_registered_subscribable_and_drivable() {
3195 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3196 let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3197 let view = registry.register("conv-1", Arc::clone(&tools)).await;
3198 tools.attach_agent_for_test().await;
3199 let (channel, mut rx) = capture_channel();
3200 view.subscribe("host-1", channel).await;
3201
3202 view.note_run_ended().await;
3203
3204 // Still registered, and reachable by the same key.
3205 let found = registry
3206 .get(Some("conv-1"))
3207 .await
3208 .expect("the browser outlives the run that opened it");
3209 assert!(Arc::ptr_eq(&view, &found));
3210
3211 // No ceremony: no strip, no blackout, and input accepted with no
3212 // Take control click.
3213 let (snapshot, cursor) = found.snapshot().await;
3214 assert_eq!(snapshot.owner, WireOwner::None);
3215 assert_eq!(snapshot.current_action, None);
3216 assert!(!snapshot.blackout_active);
3217 found
3218 .require_control("host-1")
3219 .await
3220 .expect("user-drivable");
3221
3222 // The subscriber saw the transition, and the stream is still live.
3223 let event = next_event(&mut rx).await;
3224 match event.payload {
3225 BrowserViewPayload::Presentation { presentation } => {
3226 assert_eq!(presentation.owner, WireOwner::None);
3227 }
3228 BrowserViewPayload::Frame { .. } => panic!("expected a presentation event"),
3229 }
3230 found.emit_frame(test_frame(1)).await;
3231 assert_eq!(next_event(&mut rx).await.cursor, cursor + 1);
3232 }
3233
3234 /// The lifetime bound: a NEW run for the same conversation replaces the
3235 /// view and RELEASES the previous browser, so the standing cost is one
3236 /// idle Chromium per conversation, not per run. Subscribers and the
3237 /// cursor come across, because a cursor that moved backwards would break
3238 /// gap detection worse than a gap does.
3239 #[tokio::test]
3240 async fn a_new_run_for_the_same_conversation_replaces_and_releases_the_previous_browser() {
3241 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3242
3243 let first_tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3244 let released = Arc::downgrade(&first_tools);
3245 let first = registry.register("conv-1", Arc::clone(&first_tools)).await;
3246 first_tools.attach_agent_for_test().await;
3247 drop(first_tools);
3248
3249 let (channel, mut rx) = capture_channel();
3250 first.subscribe("host-1", channel).await;
3251 first.emit_frame(test_frame(1)).await;
3252 let before = next_event(&mut rx).await.cursor;
3253
3254 // A second run starts for the same conversation.
3255 let second_tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3256 let second = registry.register("conv-1", Arc::clone(&second_tools)).await;
3257 second_tools.attach_agent_for_test().await;
3258
3259 let found = registry.get(Some("conv-1")).await.expect("registered");
3260 assert!(
3261 Arc::ptr_eq(&found, &second),
3262 "the key now resolves to the new run's browser"
3263 );
3264
3265 // The drawer came across without re-subscribing, and its cursor only
3266 // ever moved forward.
3267 assert_eq!(first.subscriber_count().await, 0);
3268 assert_eq!(second.subscriber_count().await, 1);
3269 second.emit_frame(test_frame(2)).await;
3270 let mut seen = next_event(&mut rx).await.cursor;
3271 // The handover itself may emit a presentation delta first; either
3272 // way, every cursor the client sees is strictly increasing.
3273 while seen <= before {
3274 seen = next_event(&mut rx).await.cursor;
3275 }
3276 assert!(
3277 seen > before,
3278 "cursor never goes backwards across a handover"
3279 );
3280
3281 // And the previous run's browser is released once nothing points at
3282 // it — the whole reason replacement is the lifetime bound.
3283 drop(first);
3284 drop(found);
3285 for _ in 0..100 {
3286 if released.upgrade().is_none() {
3287 break;
3288 }
3289 tokio::task::yield_now().await;
3290 }
3291 assert!(
3292 released.upgrade().is_none(),
3293 "the replaced run's browser must be released, not accumulated"
3294 );
3295 }
3296
3297 #[tokio::test]
3298 async fn replacing_a_local_view_resolves_its_pending_attention() {
3299 use crate::assistant::browser_control::ControlEvent;
3300
3301 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3302 let recorder = Arc::new(crate::browser_attention::RecordingAttention::default());
3303 let first_tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3304 first_tools.set_signin_attention(recorder.clone(), Some("conv-1".to_string()));
3305 registry.register("conv-1", Arc::clone(&first_tools)).await;
3306 first_tools
3307 .apply_control_for_test(ControlEvent::SignInRequested("Sign in".into()))
3308 .await;
3309 assert_eq!(registry.pending_signins().await[0].message, "Sign in");
3310
3311 registry
3312 .register("conv-1", Arc::new(BrowserTools::new(std::env::temp_dir())))
3313 .await;
3314
3315 assert_eq!(
3316 recorder.kinds(),
3317 vec![
3318 crate::browser_attention::BROWSER_SIGNIN_NEEDED,
3319 crate::browser_attention::BROWSER_SIGNIN_RESOLVED,
3320 ],
3321 "an unreachable predecessor cannot strand its badge"
3322 );
3323 }
3324
3325 /// The local twin of `browser_relay`'s stalled-broadcast test.
3326 ///
3327 /// `apply_control` is the SINGLE mutator of the reducer, so every drawer
3328 /// input, Take control and Hand back runs through the notify path.
3329 /// `HostState::record_event` awaits each `host.subscribe` socket in turn,
3330 /// bounded at 10s apiece, so holding `announced` across it made N
3331 /// backpressured hosts an N x 10s stall on the very next keystroke — a
3332 /// call with nothing to announce.
3333 #[tokio::test]
3334 async fn a_stalled_signin_broadcast_does_not_block_the_next_drawer_input() {
3335 use crate::assistant::browser_control::ControlEvent;
3336 use crate::browser_attention::SignInAttention;
3337
3338 struct BlockingAttention {
3339 entered: Arc<tokio::sync::Notify>,
3340 release: Arc<tokio::sync::Notify>,
3341 }
3342
3343 #[async_trait::async_trait]
3344 impl SignInAttention for BlockingAttention {
3345 async fn signin_needed(&self, _conversation_id: Option<&str>, _message: &str) {
3346 self.entered.notify_one();
3347 self.release.notified().await;
3348 }
3349 async fn signin_resolved(&self, _conversation_id: Option<&str>) {}
3350 }
3351
3352 let entered = Arc::new(tokio::sync::Notify::new());
3353 let release = Arc::new(tokio::sync::Notify::new());
3354 let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3355 tools.set_signin_attention(
3356 Arc::new(BlockingAttention {
3357 entered: Arc::clone(&entered),
3358 release: Arc::clone(&release),
3359 }),
3360 Some("conv-1".to_string()),
3361 );
3362
3363 let blocked = tokio::spawn({
3364 let tools = Arc::clone(&tools);
3365 async move {
3366 tools
3367 .apply_control_for_test(ControlEvent::SignInRequested("Sign in".into()))
3368 .await;
3369 }
3370 });
3371 // The announcement is now in flight and wedged on a host socket.
3372 entered.notified().await;
3373
3374 // A click in the drawer. Not a sign-in transition, so it must settle
3375 // without waiting on that broadcast.
3376 tokio::time::timeout(
3377 std::time::Duration::from_secs(5),
3378 tools.apply_control_for_test(ControlEvent::UserInput),
3379 )
3380 .await
3381 .expect("a drawer input must not queue behind a stalled sign-in broadcast");
3382
3383 release.notify_one();
3384 blocked.await.unwrap();
3385 }
3386
3387 /// `register` inserts the successor and only THEN calls `adopt`, so a
3388 /// `subscribe` for the same key can land on the successor inside that
3389 /// window — with a success reply and a cursor already in the client's
3390 /// hands. `adopt` used to assign the predecessor's map over the top,
3391 /// silently deregistering that client: it would receive nothing forever,
3392 /// and its cursor would never advance, so it could not even detect a gap
3393 /// and re-subscribe.
3394 #[tokio::test]
3395 async fn a_subscriber_that_lands_during_the_handover_window_survives_it() {
3396 let previous = test_view();
3397 let (channel_old, mut rx_old) = capture_channel();
3398 previous.subscribe("host-old", channel_old).await;
3399
3400 // The successor exists (register inserted it) but has not adopted yet.
3401 let successor = test_view();
3402 let (channel_new, mut rx_new) = capture_channel();
3403 successor.subscribe("host-new", channel_new).await;
3404
3405 successor.adopt(&previous).await;
3406
3407 assert_eq!(
3408 successor.subscriber_count().await,
3409 2,
3410 "both the inherited and the concurrent subscriber are registered"
3411 );
3412 successor.emit_frame(test_frame(1)).await;
3413 // Drain to the frame: adopt emits a presentation delta first.
3414 let mut new_kinds = 0;
3415 loop {
3416 match next_event(&mut rx_new).await.payload {
3417 BrowserViewPayload::Frame { .. } => break,
3418 BrowserViewPayload::Presentation { .. } => {
3419 new_kinds += 1;
3420 assert!(new_kinds < 4, "expected a frame within a few events");
3421 }
3422 }
3423 }
3424 let mut old_kinds = 0;
3425 loop {
3426 match next_event(&mut rx_old).await.payload {
3427 BrowserViewPayload::Frame { .. } => break,
3428 BrowserViewPayload::Presentation { .. } => {
3429 old_kinds += 1;
3430 assert!(old_kinds < 4, "expected a frame within a few events");
3431 }
3432 }
3433 }
3434 }
3435
3436 /// The exit the round-8 engaged-ending rule owes. A run ending under a
3437 /// person mid-sign-in leaves their strip and blackout up — and they never
3438 /// pressed Take control, so there is no holder for `note_disconnect` to
3439 /// match and it is inert for them. Without this the window had exactly one
3440 /// exit (hand-back), so a person who closed the laptop mid-sign-in left the
3441 /// blackout latched for the daemon's life, wedging every later run's browse
3442 /// call behind it.
3443 #[tokio::test]
3444 async fn a_watcher_leaving_mid_signin_starts_the_clock_that_ends_their_window() {
3445 use crate::assistant::browser_control::ControlEvent;
3446
3447 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3448 let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3449 let view = registry.register("conv-1", Arc::clone(&tools)).await;
3450 let (channel, _rx) = capture_channel();
3451 view.subscribe_for_test("host-1", channel).await;
3452
3453 tools
3454 .apply_control_for_test(ControlEvent::AgentAttached)
3455 .await;
3456 tools
3457 .apply_control_for_test(ControlEvent::SignInRequested("Sign in at x".into()))
3458 .await;
3459 // Typed into the credential form; never pressed Take control.
3460 tools.apply_control_for_test(ControlEvent::UserInput).await;
3461 view.note_run_ended().await;
3462 assert!(
3463 tools.control_status().await.blackout_active,
3464 "precondition: the engaged window survived the run ending"
3465 );
3466 assert!(
3467 view.control_holder_for_test().await.is_none(),
3468 "precondition: nothing holds control, so note_disconnect is inert here"
3469 );
3470
3471 let before = view.grace_generation_for_test().await;
3472 registry.drop_subscriptions_for_client("host-1").await;
3473 assert!(
3474 view.grace_generation_for_test().await > before,
3475 "the watcher going away must arm the clock that settles their sign-in"
3476 );
3477 }
3478
3479 /// The other side: a connection leaving a view with nothing person-facing
3480 /// open must not arm anything.
3481 #[tokio::test]
3482 async fn a_watcher_leaving_an_ordinary_agent_view_arms_nothing() {
3483 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3484 let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3485 let view = registry.register("conv-1", Arc::clone(&tools)).await;
3486 let (channel, _rx) = capture_channel();
3487 view.subscribe_for_test("host-1", channel).await;
3488 tools.attach_agent_for_test().await;
3489
3490 let before = view.grace_generation_for_test().await;
3491 registry.drop_subscriptions_for_client("host-1").await;
3492 assert_eq!(view.grace_generation_for_test().await, before);
3493 }
3494
3495 /// The SAME client id on both sides of the handover, which the test above
3496 /// cannot reach: it uses `host-old`/`host-new`, so `or_insert` is always
3497 /// Vacant, nothing is dropped, and no epoch is ever compared.
3498 ///
3499 /// One connection is the real shape — the drawer re-subscribing its key on
3500 /// the same socket while a restarted process re-registers it. The inherited
3501 /// subscriber loses the `or_insert` race and is dropped, which ends its
3502 /// drain task, which deregisters by (client_id, epoch). With a per-VIEW
3503 /// epoch counter both sides start at 0, so it matched the LIVE
3504 /// registration and removed it: the drawer held a successful reply with a
3505 /// cursor that never advanced again, rendering a frozen frame as `.live`
3506 /// with no event arriving to trip the cursor-gap recovery.
3507 #[tokio::test]
3508 async fn one_connection_on_both_sides_of_a_handover_keeps_its_subscription() {
3509 let previous = test_view();
3510 let (channel_old, _rx_old) = capture_channel();
3511 previous.subscribe("host-1", channel_old).await;
3512
3513 let successor = test_view();
3514 let (channel_new, mut rx_new) = capture_channel();
3515 successor.subscribe("host-1", channel_new).await;
3516
3517 successor.adopt(&previous).await;
3518
3519 // The inherited subscriber lost the `or_insert` race and was dropped,
3520 // so its drain task's `recv()` has already returned `None`. Give it
3521 // room to actually reach its deregistration — the count is 1 either
3522 // way the instant `adopt` returns (one HashMap key), so asserting
3523 // before the task runs would pass against the bug too.
3524 for _ in 0..50 {
3525 tokio::task::yield_now().await;
3526 }
3527 tokio::time::sleep(Duration::from_millis(50)).await;
3528 assert_eq!(
3529 successor.subscriber_count().await,
3530 1,
3531 "the live registration must survive the inherited one's deregistration"
3532 );
3533
3534 // And it is still SERVED, not merely counted.
3535 successor.emit_frame(test_frame(1)).await;
3536 let mut seen = 0;
3537 loop {
3538 match next_event(&mut rx_new).await.payload {
3539 BrowserViewPayload::Frame { .. } => break,
3540 BrowserViewPayload::Presentation { .. } => {
3541 seen += 1;
3542 assert!(seen < 4, "expected a frame within a few events");
3543 }
3544 }
3545 }
3546 }
3547
3548 /// The other half: a concurrent subscriber must get the STREAM started
3549 /// for it too. Keying that on "did anything come across" left a client
3550 /// registered with no streamer whenever the predecessor had none.
3551 #[tokio::test]
3552 async fn a_handover_from_a_view_nobody_watched_still_starts_the_stream() {
3553 let previous = test_view();
3554 let successor = test_view();
3555 let (channel, _rx) = capture_channel();
3556 successor.subscribe("host-new", channel).await;
3557
3558 successor.adopt(&previous).await;
3559
3560 assert_eq!(successor.subscriber_count().await, 1);
3561 let capture = successor.capture.lock().await;
3562 assert!(
3563 capture.active && capture.task.as_ref().is_some_and(|t| !t.is_finished()),
3564 "the concurrent subscriber's stream must be running"
3565 );
3566 }
3567
3568 #[tokio::test]
3569 async fn a_disconnect_drops_only_that_connection_s_subscriptions() {
3570 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3571 let view = registry.standing().await;
3572 let (channel_a, _rx_a) = capture_channel();
3573 let (channel_b, mut rx_b) = capture_channel();
3574 view.subscribe("host-a", channel_a).await;
3575 view.subscribe("host-b", channel_b).await;
3576
3577 registry.drop_subscriptions_for_client("host-a").await;
3578 assert_eq!(view.subscriber_count().await, 1);
3579
3580 view.emit_frame(test_frame(9)).await;
3581 assert_eq!(next_event(&mut rx_b).await.cursor, 1);
3582 }
3583
3584 #[tokio::test]
3585 async fn input_where_no_browser_exists_is_a_clean_error() {
3586 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3587 let view = registry.standing().await;
3588 let err = view.tools().user_click(10.0, 10.0).await.unwrap_err();
3589 assert!(err.contains("no browser is running"), "got: {err}");
3590 }
3591
3592 #[tokio::test]
3593 async fn unknown_modifiers_are_rejected_rather_than_silently_dropped() {
3594 assert_eq!(parse_modifier("Shift").unwrap(), Modifier::Shift);
3595 assert_eq!(parse_modifier("cmd").unwrap(), Modifier::Meta);
3596 let err = parse_modifier("hyper").unwrap_err();
3597 assert!(err.contains("unknown modifier"), "got: {err}");
3598 }
3599
3600 // ---- wire shapes ----------------------------------------------------
3601
3602 #[test]
3603 fn the_event_wire_shape_is_tagged_and_flat() {
3604 let event = BrowserViewEvent {
3605 conversation_id: Some("conv-1".into()),
3606 cursor: 7,
3607 payload: BrowserViewPayload::Presentation {
3608 presentation: WirePresentation::empty(),
3609 },
3610 };
3611 let json = serde_json::to_value(&event).unwrap();
3612 assert_eq!(json["conversation_id"], "conv-1");
3613 assert_eq!(json["cursor"], 7);
3614 assert_eq!(json["kind"], "presentation");
3615 assert_eq!(json["presentation"]["owner"], "none");
3616 // Round trips, so a binding generated from this shape can decode it.
3617 let back: BrowserViewEvent = serde_json::from_value(json).unwrap();
3618 assert_eq!(back, event);
3619 }
3620
3621 #[test]
3622 fn every_owner_state_has_its_own_wire_name() {
3623 for (owner, name) in [
3624 (ControlOwner::NoAgent, "none"),
3625 (ControlOwner::Agent, "agent"),
3626 (ControlOwner::User, "user"),
3627 ] {
3628 let wire: WireOwner = owner.into();
3629 assert_eq!(serde_json::to_value(wire).unwrap(), name);
3630 }
3631 }
3632
3633 // ---- the dispatcher-facing handlers, end to end --------------------
3634
3635 async fn host_session(
3636 state: &Arc<ServerState>,
3637 client_id: &str,
3638 ) -> (
3639 Arc<ClientSession>,
3640 futures::channel::mpsc::UnboundedReceiver<Message>,
3641 ) {
3642 let (channel, rx) = capture_channel();
3643 let session = state.create_session(client_id, channel).await.unwrap();
3644 session
3645 .is_host
3646 .store(true, std::sync::atomic::Ordering::Release);
3647 (session, rx)
3648 }
3649
3650 fn request(method: &str, params: Value) -> JsonRpcMessage {
3651 JsonRpcMessage {
3652 jsonrpc: "2.0".to_string(),
3653 id: json!(1),
3654 method: Some(method.to_string()),
3655 params,
3656 result: None,
3657 error: None,
3658 }
3659 }
3660
3661 async fn test_state() -> (Arc<ServerState>, tempfile::TempDir) {
3662 let temp = tempfile::tempdir().unwrap();
3663 let state = Arc::new(ServerState::with_config(ServerStateConfig::new(
3664 temp.path().to_path_buf(),
3665 )));
3666 (state, temp)
3667 }
3668
3669 #[tokio::test]
3670 async fn subscribe_answers_the_standing_session_with_a_snapshot_and_cursor() {
3671 let (state, _temp) = test_state().await;
3672 let (session, _rx) = host_session(&state, "host-1").await;
3673
3674 let out = handle_subscribe(
3675 &request("browser.view.subscribe", json!({})),
3676 &session,
3677 &state,
3678 )
3679 .await
3680 .expect("the standing session is always subscribable");
3681 assert_eq!(out["standing_session"], true);
3682 assert_eq!(out["conversation_id"], Value::Null);
3683 assert_eq!(out["cursor"], 0);
3684 assert_eq!(out["presentation"]["owner"], "none");
3685 assert_eq!(out["presentation"]["tabs"], json!([]));
3686
3687 let out = handle_unsubscribe(
3688 &request("browser.view.unsubscribe", json!({})),
3689 &session,
3690 &state,
3691 )
3692 .await
3693 .unwrap();
3694 assert_eq!(out["removed"], true);
3695 }
3696
3697 /// The ORDINARY teardown route, driven through the real RPC.
3698 ///
3699 /// `release_if_idle` was wired to the run-end guard and the disconnect
3700 /// sweep — but a disconnect is the exceptional route. Closing the drawer
3701 /// or switching conversations is this plain `browser.view.unsubscribe`
3702 /// over a live socket, and it released nothing: a finished run's browser
3703 /// stayed registered, and alive, for the daemon's lifetime, once per
3704 /// watched run. The documented replacement bound cannot save it either —
3705 /// the key carries a fresh uuid every run.
3706 #[tokio::test]
3707 async fn unsubscribing_the_last_watcher_releases_a_finished_run_s_browser() {
3708 let (state, _temp) = test_state().await;
3709 let (session, _rx) = host_session(&state, "host-1").await;
3710 let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3711 let weak = Arc::downgrade(&tools);
3712 let view = state
3713 .browser_views
3714 .register("mcp-run-abc", Arc::clone(&tools))
3715 .await;
3716 drop(tools);
3717
3718 handle_subscribe(
3719 &request(
3720 "browser.view.subscribe",
3721 json!({ "conversation_id": "mcp-run-abc" }),
3722 ),
3723 &session,
3724 &state,
3725 )
3726 .await
3727 .expect("subscribed");
3728 view.note_run_ended().await;
3729 assert!(
3730 state.browser_views.get(Some("mcp-run-abc")).await.is_some(),
3731 "a watched view outlives its run, by ruling"
3732 );
3733 drop(view);
3734
3735 // The user closes the drawer.
3736 handle_unsubscribe(
3737 &request(
3738 "browser.view.unsubscribe",
3739 json!({ "conversation_id": "mcp-run-abc" }),
3740 ),
3741 &session,
3742 &state,
3743 )
3744 .await
3745 .expect("unsubscribed");
3746
3747 assert!(
3748 state.browser_views.get(Some("mcp-run-abc")).await.is_none(),
3749 "the ordinary close path must release a finished run's view"
3750 );
3751 assert!(weak.upgrade().is_none(), "and its browser with it");
3752 }
3753
3754 #[tokio::test]
3755 async fn a_connection_that_is_not_the_host_client_is_refused_everywhere() {
3756 let (state, _temp) = test_state().await;
3757 let (channel, _rx) = capture_channel();
3758 let session = state.create_session("not-host", channel).await.unwrap();
3759 // No `is_host` — an ordinary authenticated connection.
3760
3761 // EVERY method, not a sample of three. `authorize(session)` as the
3762 // first line of each handler is the single control that makes this
3763 // surface host-only, and a handler added later that forgets it is
3764 // exactly what this must catch — so the input arm is driven from the
3765 // `InputOp` enum itself, which cannot gain a variant silently.
3766 let mut results = vec![
3767 handle_subscribe(
3768 &request("browser.view.subscribe", json!({})),
3769 &session,
3770 &state,
3771 )
3772 .await,
3773 handle_unsubscribe(
3774 &request("browser.view.unsubscribe", json!({})),
3775 &session,
3776 &state,
3777 )
3778 .await,
3779 handle_take_control(
3780 &request("browser.view.take_control", json!({})),
3781 &session,
3782 &state,
3783 )
3784 .await,
3785 handle_hand_back(
3786 &request("browser.view.hand_back", json!({})),
3787 &session,
3788 &state,
3789 )
3790 .await,
3791 ];
3792 for op in [
3793 InputOp::Navigate,
3794 InputOp::Click,
3795 InputOp::Type,
3796 InputOp::Keypress,
3797 InputOp::Scroll,
3798 InputOp::Paste,
3799 InputOp::Back,
3800 InputOp::Forward,
3801 InputOp::Reload,
3802 InputOp::TabOpen,
3803 InputOp::TabClose,
3804 InputOp::TabSwitch,
3805 ] {
3806 results.push(
3807 handle_input(
3808 op,
3809 &request(
3810 "browser.view.input",
3811 json!({
3812 "url": "https://x.test",
3813 "x": 1.0, "y": 2.0,
3814 "text": "x", "key": "Enter",
3815 "delta_y": 1, "tab_id": "1",
3816 }),
3817 ),
3818 &session,
3819 &state,
3820 )
3821 .await,
3822 );
3823 }
3824 assert_eq!(results.len(), 16, "every dispatched method must be covered");
3825 for result in results {
3826 let err = result.unwrap_err();
3827 assert!(err.contains("not authorized"), "got: {err}");
3828 }
3829 // And nothing was created as a side effect of a refused call.
3830 assert_eq!(
3831 state
3832 .browser_views
3833 .standing()
3834 .await
3835 .subscriber_count()
3836 .await,
3837 0
3838 );
3839 }
3840
3841 #[tokio::test]
3842 async fn an_unknown_conversation_is_a_clean_error_not_a_hang_or_an_empty_success() {
3843 let (state, _temp) = test_state().await;
3844 let (session, _rx) = host_session(&state, "host-1").await;
3845
3846 let err = handle_subscribe(
3847 &request(
3848 "browser.view.subscribe",
3849 json!({"conversation_id": "ghost"}),
3850 ),
3851 &session,
3852 &state,
3853 )
3854 .await
3855 .unwrap_err();
3856 assert!(
3857 err.contains("no browser view for conversation 'ghost'"),
3858 "got: {err}"
3859 );
3860 assert!(
3861 err.contains("standing session"),
3862 "and it says what to do instead"
3863 );
3864 }
3865
3866 #[tokio::test]
3867 async fn input_is_refused_while_the_agent_drives_and_accepted_after_take_control() {
3868 let (state, _temp) = test_state().await;
3869 let (session, _rx) = host_session(&state, "host-1").await;
3870 let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3871 state
3872 .browser_views
3873 .register("conv-1", Arc::clone(&tools))
3874 .await;
3875 tools.attach_agent_for_test().await;
3876
3877 let navigate = request(
3878 "browser.view.navigate",
3879 json!({ "conversation_id": "conv-1", "url": "https://x.test" }),
3880 );
3881 let err = handle_input(InputOp::Navigate, &navigate, &session, &state)
3882 .await
3883 .unwrap_err();
3884 assert!(err.contains("take_control"), "got: {err}");
3885
3886 let out = handle_take_control(
3887 &request(
3888 "browser.view.take_control",
3889 json!({ "conversation_id": "conv-1" }),
3890 ),
3891 &session,
3892 &state,
3893 )
3894 .await
3895 .unwrap();
3896 assert_eq!(out["presentation"]["owner"], "user");
3897
3898 // Now the control check passes and the call reaches the browser —
3899 // which does not exist in this test, so it fails there instead of
3900 // at the gate. That IS the assertion: a different, later error.
3901 let err = handle_input(InputOp::Navigate, &navigate, &session, &state)
3902 .await
3903 .unwrap_err();
3904 assert!(
3905 !err.contains("take_control") && !err.contains("holds control"),
3906 "the control gate must be satisfied now; got: {err}"
3907 );
3908 }
3909
3910 #[tokio::test]
3911 async fn input_from_a_connection_that_does_not_hold_control_is_refused() {
3912 let (state, _temp) = test_state().await;
3913 let (holder, _rx_a) = host_session(&state, "host-holder").await;
3914 let (other, _rx_b) = host_session(&state, "host-other").await;
3915 let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3916 state
3917 .browser_views
3918 .register("conv-1", Arc::clone(&tools))
3919 .await;
3920 tools.attach_agent_for_test().await;
3921
3922 handle_take_control(
3923 &request(
3924 "browser.view.take_control",
3925 json!({ "conversation_id": "conv-1" }),
3926 ),
3927 &holder,
3928 &state,
3929 )
3930 .await
3931 .unwrap();
3932
3933 let err = handle_input(
3934 InputOp::Click,
3935 &request(
3936 "browser.view.click",
3937 json!({ "conversation_id": "conv-1", "x": 1.0, "y": 2.0 }),
3938 ),
3939 &other,
3940 &state,
3941 )
3942 .await
3943 .unwrap_err();
3944 assert!(
3945 err.contains("another connection holds control"),
3946 "got: {err}"
3947 );
3948
3949 // Hand back, and neither connection may drive — the agent has it.
3950 handle_hand_back(
3951 &request(
3952 "browser.view.hand_back",
3953 json!({ "conversation_id": "conv-1" }),
3954 ),
3955 &holder,
3956 &state,
3957 )
3958 .await
3959 .unwrap();
3960 let err = handle_input(
3961 InputOp::Click,
3962 &request(
3963 "browser.view.click",
3964 json!({ "conversation_id": "conv-1", "x": 1.0, "y": 2.0 }),
3965 ),
3966 &holder,
3967 &state,
3968 )
3969 .await
3970 .unwrap_err();
3971 assert!(err.contains("take_control"), "got: {err}");
3972 }
3973
3974 #[tokio::test]
3975 async fn malformed_input_params_are_rejected_before_touching_the_browser() {
3976 let (state, _temp) = test_state().await;
3977 let (session, _rx) = host_session(&state, "host-1").await;
3978
3979 for (op, params, needle) in [
3980 (InputOp::Navigate, json!({}), "requires { url }"),
3981 (InputOp::Click, json!({ "x": 1.0 }), "requires { x, y }"),
3982 (InputOp::Type, json!({}), "requires { text }"),
3983 (InputOp::Keypress, json!({}), "requires { key }"),
3984 (InputOp::Scroll, json!({}), "requires { delta_y }"),
3985 (InputOp::Paste, json!({}), "requires { text }"),
3986 (InputOp::TabClose, json!({}), "requires { tab_id }"),
3987 (InputOp::TabSwitch, json!({}), "requires { tab_id }"),
3988 ] {
3989 let err = handle_input(op, &request("browser.view.x", params), &session, &state)
3990 .await
3991 .unwrap_err();
3992 assert!(err.contains(needle), "{op:?}: got {err}");
3993 }
3994 }
3995
3996 // ---- the nav bar's history buttons ---------------------------------
3997
3998 /// Back/Forward/Reload are input like any other: refused while the agent
3999 /// drives, accepted from the control holder. The gate lives in
4000 /// `ViewInput::apply` BEFORE the op match, so this holds for every op by
4001 /// construction — this pins that it actually does for the new three.
4002 #[tokio::test]
4003 async fn history_ops_are_refused_while_the_agent_drives() {
4004 let (state, _temp) = test_state().await;
4005 let (session, _rx) = host_session(&state, "host-1").await;
4006 let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
4007 state
4008 .browser_views
4009 .register("conv-1", Arc::clone(&tools))
4010 .await;
4011 tools.attach_agent_for_test().await;
4012
4013 for op in [InputOp::Back, InputOp::Forward, InputOp::Reload] {
4014 let err = handle_input(
4015 op,
4016 &request("browser.view.x", json!({ "conversation_id": "conv-1" })),
4017 &session,
4018 &state,
4019 )
4020 .await
4021 .unwrap_err();
4022 assert!(err.contains("take_control"), "{op:?}: got {err}");
4023 }
4024 }
4025
4026 /// Paste is input like any other — the same control gate, refused while
4027 /// the agent drives. It carries the TEXT because CDP key events cannot
4028 /// reach a clipboard, so the host reads its own pasteboard and sends the
4029 /// string; that means the surface must gate it exactly like typing.
4030 #[tokio::test]
4031 async fn paste_is_refused_while_the_agent_drives() {
4032 let (state, _temp) = test_state().await;
4033 let (session, _rx) = host_session(&state, "host-1").await;
4034 let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
4035 state
4036 .browser_views
4037 .register("conv-1", Arc::clone(&tools))
4038 .await;
4039 tools.attach_agent_for_test().await;
4040
4041 let err = handle_input(
4042 InputOp::Paste,
4043 &request(
4044 "browser.view.paste",
4045 json!({ "conversation_id": "conv-1", "text": "secret" }),
4046 ),
4047 &session,
4048 &state,
4049 )
4050 .await
4051 .unwrap_err();
4052 assert!(err.contains("take_control"), "got: {err}");
4053 }
4054
4055 #[tokio::test]
4056 async fn paste_on_an_empty_state_reports_that_there_is_no_browser() {
4057 let (state, _temp) = test_state().await;
4058 let (session, _rx) = host_session(&state, "host-1").await;
4059 let err = handle_input(
4060 InputOp::Paste,
4061 &request("browser.view.paste", json!({ "text": "hello" })),
4062 &session,
4063 &state,
4064 )
4065 .await
4066 .unwrap_err();
4067 assert!(err.contains("no browser is running"), "got: {err}");
4068 }
4069
4070 #[tokio::test]
4071 async fn history_ops_are_refused_from_a_connection_that_does_not_hold_control() {
4072 let (state, _temp) = test_state().await;
4073 let (holder, _rx_a) = host_session(&state, "host-holder").await;
4074 let (other, _rx_b) = host_session(&state, "host-other").await;
4075 let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
4076 state
4077 .browser_views
4078 .register("conv-1", Arc::clone(&tools))
4079 .await;
4080 tools.attach_agent_for_test().await;
4081 handle_take_control(
4082 &request(
4083 "browser.view.take_control",
4084 json!({ "conversation_id": "conv-1" }),
4085 ),
4086 &holder,
4087 &state,
4088 )
4089 .await
4090 .unwrap();
4091
4092 for op in [InputOp::Back, InputOp::Forward, InputOp::Reload] {
4093 let err = handle_input(
4094 op,
4095 &request("browser.view.x", json!({ "conversation_id": "conv-1" })),
4096 &other,
4097 &state,
4098 )
4099 .await
4100 .unwrap_err();
4101 assert!(
4102 err.contains("another connection holds control"),
4103 "{op:?}: got {err}"
4104 );
4105 }
4106 }
4107
4108 /// Once the control gate is satisfied they reach the browser — which does
4109 /// not exist here, so they land on the empty-state error rather than the
4110 /// gate's. Reload on no page is exactly the outcomes file's "inactive on
4111 /// the empty state", answered as a clean error rather than a hang.
4112 #[tokio::test]
4113 async fn history_ops_on_an_empty_state_report_that_there_is_no_browser() {
4114 let (state, _temp) = test_state().await;
4115 let (session, _rx) = host_session(&state, "host-1").await;
4116
4117 for op in [InputOp::Back, InputOp::Forward, InputOp::Reload] {
4118 let err = handle_input(op, &request("browser.view.x", json!({})), &session, &state)
4119 .await
4120 .unwrap_err();
4121 assert!(err.contains("no browser is running"), "{op:?}: got {err}");
4122 }
4123 }
4124
4125 /// They take no params of their own — which page they act on is the
4126 /// active tab's own history — so a bare call is valid, unlike every other
4127 /// input op except `tab_open`.
4128 #[tokio::test]
4129 async fn history_ops_need_no_params_of_their_own() {
4130 let (state, _temp) = test_state().await;
4131 let (session, _rx) = host_session(&state, "host-1").await;
4132
4133 for op in [InputOp::Back, InputOp::Forward, InputOp::Reload] {
4134 let err = handle_input(
4135 op,
4136 &request("browser.view.x", Value::Null),
4137 &session,
4138 &state,
4139 )
4140 .await
4141 .unwrap_err();
4142 assert!(
4143 !err.contains("requires"),
4144 "{op:?} must not demand params; got {err}"
4145 );
4146 }
4147 }
4148
4149 #[tokio::test]
4150 async fn input_where_no_browser_exists_reports_that_rather_than_succeeding_emptily() {
4151 let (state, _temp) = test_state().await;
4152 let (session, _rx) = host_session(&state, "host-1").await;
4153
4154 let err = handle_input(
4155 InputOp::Click,
4156 &request("browser.view.click", json!({ "x": 1.0, "y": 2.0 })),
4157 &session,
4158 &state,
4159 )
4160 .await
4161 .unwrap_err();
4162 assert!(err.contains("no browser is running"), "got: {err}");
4163 }
4164
4165 #[tokio::test]
4166 async fn disconnect_cleanup_drops_that_connection_s_subscription() {
4167 let (state, _temp) = test_state().await;
4168 let (session, _rx) = host_session(&state, "host-1").await;
4169 handle_subscribe(
4170 &request("browser.view.subscribe", json!({})),
4171 &session,
4172 &state,
4173 )
4174 .await
4175 .unwrap();
4176 assert_eq!(
4177 state
4178 .browser_views
4179 .standing()
4180 .await
4181 .subscriber_count()
4182 .await,
4183 1
4184 );
4185
4186 state.remove_session("host-1").await;
4187 assert_eq!(
4188 state
4189 .browser_views
4190 .standing()
4191 .await
4192 .subscriber_count()
4193 .await,
4194 0
4195 );
4196 }
4197
4198 fn test_frame(byte: u8) -> ScreencastFrame {
4199 ScreencastFrame {
4200 jpeg: vec![byte].into(),
4201 viewport: car_browser::Viewport {
4202 width: 1920,
4203 height: 1080,
4204 device_pixel_ratio: 1.0,
4205 },
4206 captured_at: 0.5,
4207 }
4208 }
4209}