Skip to main content

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, &registry.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}