Skip to main content

car_server_core/assistant/
browser_tools.rs

1//! Browser driving + session recording for the general assistant.
2//!
3//! Two capabilities, deliberately paired:
4//!
5//! - **Drive a real web app** — `car-browser`'s CDP automation (navigate,
6//!   observe, click, type, scroll, wait), already used elsewhere in CAR but
7//!   never reachable from the assistant.
8//! - **Record what that looked like** — `browser_record_start` /
9//!   `browser_record_stop` wrap CDP screencast and hand back an MP4.
10//!
11//! The pairing is the point. A screenshot shows a UI's final state; a recording
12//! shows it BEING USED — an answer streaming in, a table populating, a menu
13//! opening. Product demos, onboarding clips and training videos want the
14//! second, and a deck full of stills is the compromise you make when you can't
15//! record. A text-only agent can do neither.
16//!
17//! Same path-artifact contract as the other media providers: write a file under
18//! the working root and return its PATH.
19//!
20//! Chromium is launched LAZILY on first use — a browser process on every
21//! assistant session would be pure waste for the majority that never browse.
22
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicBool, Ordering};
25use std::sync::{Arc, OnceLock, Weak};
26use std::time::{Duration, Instant};
27
28use async_trait::async_trait;
29use car_browser::perception::vision::VisionPerceptionPipeline;
30use car_browser::{
31    BrowserBackend, BrowserToolExecutor, ChromiumBackend, FrameReceiver, HistoryStep, Modifier,
32    RecordingHandle, TabId, TabInfo,
33};
34use car_engine::ToolExecutor;
35use serde_json::{json, Value};
36use tokio::sync::{watch, Mutex};
37
38use super::browser_control::{
39    ControlEffect, ControlEvent, ControlOwner, Presentation, PresentationState,
40};
41use super::browser_stream::{FrameAudience, FrameFanout};
42use crate::browser_attention::{notify_signin_transition, SignInAttention};
43use crate::coder::policy::stays_under;
44
45/// What the agent is told whenever the privacy blackout stands between it and
46/// the page — whether the call never started or was cancelled mid-flight.
47/// One string on purpose: the two are the same situation from the model's
48/// side, and the honest answer to both is "wait, then retry".
49const BLACKOUT_HOLDS_THE_PAGE: &str =
50    "the user is signing in / driving the browser, so CAR cannot observe the page right now \
51     — wait for them to finish, then retry";
52
53/// Browsing reaches arbitrary network endpoints and can act on a logged-in
54/// session, so it sits at the same tier as the other egress tools.
55const BROWSER_TOOL_TIER: &str = "full_access";
56
57/// Frames-per-second the recording is encoded at. The screencast itself is
58/// change-driven (see `car_browser::recorder`), so this is the output rate the
59/// variable-duration frames are resampled to, not a capture rate.
60const OUTPUT_FPS: u32 = 24;
61
62/// Viewport the browser launches at, and the size frames are pinned to.
63const VIEWPORT_W: u32 = 1920;
64const VIEWPORT_H: u32 = 1080;
65
66/// How long a `browse_*` call waits at the tool boundary for the user to
67/// hand control back before giving up. Generous on purpose — the whole
68/// point of "Take control" is a human doing something (a CAPTCHA, a
69/// multi-step form, picking through a confusing menu) that can genuinely
70/// take a few minutes — but bounded, so a vanished user can't wedge the
71/// agent's turn forever. Matches `browser_await_signin`'s own upper clamp
72/// below.
73const AGENT_PAUSE_TIMEOUT_SECS: u64 = 1800;
74
75/// JPEG quality the live drawer stream captures at when no recording has
76/// asked for something else. Matches `browser_record_start`'s own default,
77/// so opening the drawer during a default-quality recording changes nothing.
78const DEFAULT_FRAME_QUALITY: i64 = 80;
79
80/// Task 7's sign-in fallback, for when the browser is headless (the drawer is
81/// its only face) and nobody is around to see the drawer. Exact intent per
82/// the plan's seam resolution: "a clear result telling the user to open
83/// CarHost — not a silent hang".
84const HOST_GONE_FOR_SIGNIN: &str =
85    "sign-in is needed, but this browser has no visible surface right now — no CAR \
86     app is connected to the daemon to show the drawer in. Open the CAR app to continue, \
87     then retry browser_await_signin.";
88
89/// Learns whether a CarHost host-client is connected to the daemon — the
90/// deciding signal for the headless/headed launch default (Task 7: "default
91/// the assistant browser to headless when a drawer subscriber can serve as
92/// the visible surface").
93///
94/// Three production sources, one per place a `BrowserTools` gets built:
95/// - The in-daemon assistant (`assistant_start`) — [`BrowserTools::daemon_host_connectivity`],
96///   backed by `ServerState`'s live session set. Genuinely live: no caching.
97/// - The standing session (opened only through `browser.view.*`, which is
98///   host-management-client-only) — [`AlwaysConnected`]: the call that
99///   triggers its lazy launch IS a host, by construction.
100/// - The supervised agent process (`car do --serve` / CAR Chat) —
101///   [`SharedHostConnected`], refreshed from `browser.producer.register`'s
102///   acknowledgment (see `assistant::browser_producer`).
103///
104/// `None` (every existing test, and any embedder that never installs one)
105/// means "no way to know" and is treated as "no host" — today's headed
106/// behavior, the safe default for a pure command-line session.
107#[async_trait]
108pub trait HostConnectivity: Send + Sync {
109    async fn any_host_connected(&self) -> bool;
110}
111
112/// The standing session's probe. `browser.view.*` — the only way to reach
113/// the standing session, and what triggers its lazy launch via
114/// `user_navigate` / `user_open_tab` — is host-management-client-only
115/// (Task 4's authorization rule, checked before any view is resolved), so
116/// whatever call is about to launch this browser is itself coming from a
117/// connected host. No live check needed; the caller reaching this code IS
118/// the host.
119pub struct AlwaysConnected;
120
121#[async_trait]
122impl HostConnectivity for AlwaysConnected {
123    async fn any_host_connected(&self) -> bool {
124        true
125    }
126}
127
128/// A [`HostConnectivity`] backed by a plain shared flag that some other
129/// owner updates. The supervised-agent relay path (`assistant::browser_producer`)
130/// is the one production user: it has no direct read of the daemon's session
131/// set, so it refreshes this from `browser.producer.register`'s
132/// acknowledgment instead — see that module for the freshness bound.
133pub struct SharedHostConnected(pub Arc<AtomicBool>);
134
135#[async_trait]
136impl HostConnectivity for SharedHostConnected {
137    async fn any_host_connected(&self) -> bool {
138        self.0.load(Ordering::Acquire)
139    }
140}
141
142/// A [`HostConnectivity`] backed by the daemon's own live session set — the
143/// in-daemon assistant path. Holds a WEAK reference on purpose: a
144/// registered `BrowserView` outlives the run that created it (Task 4's
145/// ruling) and stays in `ServerState.browser_views` indefinitely, so a
146/// strong handle here would be a genuine `ServerState` <-> `BrowserTools`
147/// reference cycle. An upgrade failure (the daemon is gone) reads as "no
148/// host" — the safe default.
149struct DaemonHostConnectivity(Weak<crate::session::ServerState>);
150
151#[async_trait]
152impl HostConnectivity for DaemonHostConnectivity {
153    async fn any_host_connected(&self) -> bool {
154        match self.0.upgrade() {
155            Some(state) => state.any_host_connected().await,
156            None => false,
157        }
158    }
159}
160
161/// The launch-time headless/headed decision (Task 7). Dispatch on what the
162/// request needs — not a runtime implementation toggle (convention #1a):
163/// the model never picks a backend, `CAR_BROWSER_HEADLESS` only ever relaxes
164/// or tightens the same default.
165///
166/// - `host_connected`: is a host-management client attached to the daemon
167///   right now? When one is, the drawer is a real visible surface, so the
168///   browser launches headless and the drawer becomes its only face — no
169///   separate Chrome window (the outcome this task exists to deliver). When
170///   none ever is (a pure CLI session), launch headed, exactly as before:
171///   `browse_await_signin` needs a visible surface, and there is no drawer
172///   to be one.
173/// - `headless_override`: the raw `CAR_BROWSER_HEADLESS` value, if set.
174///   Keeps its EXACT existing semantics — "0", unset, or empty is falsy,
175///   anything else is truthy — and always wins over the host-connected
176///   default, in either direction.
177///
178/// Pure, so the whole rule is unit-testable without a browser or a daemon.
179fn decide_headless(host_connected: bool, headless_override: Option<&str>) -> bool {
180    match headless_override {
181        Some(v) => v != "0" && !v.is_empty(),
182        None => host_connected,
183    }
184}
185
186/// What `run_await_signin_with`'s timeout error tells the user to go do,
187/// picked from the SAME two signals [`decide_headless`]/the host-gone gate
188/// already read — checked fresh at timeout rather than assumed headed. A
189/// browser launched headed always has its window (the mid-session rule:
190/// headed never becomes headless); one launched headless has only the
191/// drawer as a surface, and if no host is connected right now there is no
192/// surface at all — naming a "browser window" in either headless case would
193/// send the user looking for something that isn't there.
194fn signin_timeout_hint(headless: bool, host_connected: bool) -> &'static str {
195    match (headless, host_connected) {
196        (false, _) => "Ask the user to complete the login in the open browser window, then retry.",
197        (true, true) => {
198            "Ask the user to complete the login in the CAR app's browser drawer, then retry."
199        }
200        (true, false) => {
201            "No CAR app is connected to show the drawer right now — open the CAR app to \
202             continue, then retry browser_await_signin."
203        }
204    }
205}
206
207/// Who is driving and whether the model may look — the cheap read the
208/// `browser.view.*` input path makes on every call.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub struct ControlStatus {
211    pub owner: super::browser_control::ControlOwner,
212    /// A sign-in request is up: the human must be able to type into the
213    /// page even though the agent still nominally owns the browser.
214    pub signin_pending: bool,
215    pub blackout_active: bool,
216}
217
218pub struct BrowserTools {
219    root: PathBuf,
220    /// Which profile policy this browser launches against. Passed as a
221    /// launch OPTION — never via the process-global env var, which would
222    /// change every other browser in the daemon (`browser.run`'s included).
223    profile: BrowserProfile,
224    /// Lazily launched. `None` until the first browse call.
225    inner: Arc<Mutex<Option<Session>>>,
226    recording: Arc<Mutex<Option<RecordingHandle>>>,
227    /// A recording the RUN ended under: capture already stopped and the concat
228    /// manifest already written, so its frames end at run-end.
229    ///
230    /// The privacy rule this exists for: nothing tears the browser down at run
231    /// end (`browser_producer.rs`, `mcp_assistant.rs` both say so explicitly)
232    /// and `browser_view.rs` keeps admitting the person's input once
233    /// `owner == NoAgent` — so the page kept changing, the change-driven
234    /// screencast kept emitting, and a recording the agent never stopped kept
235    /// writing the person's OWN post-run browsing to disk. Run end stops it.
236    ///
237    /// Held rather than discarded so the model still gets the recording it
238    /// asked for: a later `browser_record_stop` encodes exactly these frames.
239    finished_recording: Arc<Mutex<Option<car_browser::recorder::Recording>>>,
240    /// The live-stream quality to restore when the current recording stops —
241    /// see `run_record_start_with`.
242    recording_restore_quality: Arc<Mutex<Option<i64>>>,
243    /// The control-ownership + presentation-state core (see
244    /// `assistant::browser_control`). Who's driving, the agent's current
245    /// action label, the pending sign-in strip, and the tab list the drawer
246    /// shows — independent of whether a browser has even launched yet.
247    presentation: Arc<Mutex<PresentationState>>,
248    /// Bumped every time something a drawer subscriber would want to see
249    /// changed — a control-state transition, or a browser launching. The
250    /// `browser.view.*` fan-out awaits this instead of polling
251    /// `presentation()`, whose tab refresh costs a CDP round trip per tab.
252    changes: watch::Sender<u64>,
253    /// Live screencast fan-out: the drawer and disk recording share ONE CDP
254    /// screencast, with the privacy blackout applied per consumer (R3).
255    frames: Arc<FrameFanout>,
256    /// Task 7's launch-time signal — see [`HostConnectivity`]. Installed
257    /// once by whichever producer builds this `BrowserTools`, always before
258    /// any browse call can possibly fire. `None` (every existing test, and
259    /// any caller that never installs one) means "no way to know" — treated
260    /// as "no host", i.e. today's headed default.
261    host_connectivity: OnceLock<Arc<dyn HostConnectivity>>,
262    /// Whether THIS instance's browser actually launched headless — set
263    /// once, inside `ensure_launched`, and fixed for this instance's
264    /// lifetime (mid-session rule: a browser that launched headed stays
265    /// headed; one that launched headless never becomes a visible window).
266    /// `OnceLock<bool>` rather than reading `inner`'s launch state so the
267    /// sign-in host-gone check ([`Self::effective_headless`]) needs no lock
268    /// and no live browser to answer "not yet launched".
269    launched_headless: OnceLock<bool>,
270    /// Where a pending-sign-in transition becomes an operator-facing
271    /// `host.event` — see [`crate::browser_attention`]. Same injection shape
272    /// as [`HostConnectivity`] above, and for the same reason: this is a
273    /// daemon capability a `BrowserTools` cannot build for itself, installed
274    /// by whichever producer constructs it. `None` (every test, `car do`, any
275    /// embedder with no daemon) means "no way to tell anyone", which is
276    /// today's behavior.
277    ///
278    /// Carries the view key with it because the conversation id is known at
279    /// the CONSTRUCTION site, not in here: a `BrowserTools` has no idea which
280    /// conversation it was registered under.
281    signin_attention: OnceLock<SignInAttentionBinding>,
282}
283
284/// A [`SignInAttention`] plus the conversation key to report it under, plus
285/// the last state the operator was actually told about.
286struct SignInAttentionBinding {
287    attention: Arc<dyn SignInAttention>,
288    /// `None` is the standing session — see
289    /// [`crate::browser_attention::SignInAttention`].
290    conversation_id: Option<String>,
291    /// The pending sign-in as last ANNOUNCED, which is not the same thing as
292    /// the reducer's current state.
293    ///
294    /// This is what makes the announcement order safe. `apply_control`
295    /// deliberately releases the presentation lock before broadcasting (the
296    /// broadcast awaits every `host.subscribe` subscriber's channel, and
297    /// holding that lock across it would freeze every drawer input), so two
298    /// concurrent transitions — a `HandBack` from the drawer racing the
299    /// sign-in tool's own `SignInRequested` — could otherwise reach the host
300    /// in the opposite order to the one the reducer landed them in, leaving a
301    /// badge asserting a wait that already ended. Comparing against the LIVE
302    /// state under this lock instead of against a value captured earlier
303    /// means whoever announces last announces the truth.
304    announced: Mutex<Option<String>>,
305    /// Serializes the announcements themselves, so [`Self::announced`] never
306    /// has to be.
307    ///
308    /// `record_event` awaits every `host.subscribe` socket in turn, each
309    /// bounded at 10s, and `apply_control` — the single mutator, so every
310    /// drawer input, Take control and Hand back — runs through the notify
311    /// path. Holding `announced` across that broadcast made N backpressured
312    /// host sockets a N x 10s stall on the next input, whether or not it had
313    /// anything to announce.
314    ///
315    /// Taken while `announced` is still held and released only after the
316    /// broadcast, so the ordering guarantee above survives intact: two
317    /// concurrent transitions still reach the host in the order they were
318    /// decided. Non-transitions — the overwhelming majority — compare and
319    /// return without ever touching it.
320    ///
321    /// **A known residual, deliberately kept.** A second concurrent transition
322    /// still holds the state lock while it queues here, so a third caller can
323    /// block on that state lock for the length of the first broadcast. Every
324    /// way of removing that — take a ticket under the state lock, wait for
325    /// your turn after releasing it — trades a bounded stall for an unbounded
326    /// hazard, because a task cancelled between taking the ticket and taking
327    /// its turn either wedges every later announcement for this producer (if
328    /// the queue only advances in turn) or breaks the ordering the queue
329    /// exists to provide (if it always advances on drop), and a WS session
330    /// task being dropped is exactly the cancellation this code lives with.
331    /// The one cheap ordered variant — first-polling the `lock()` future under
332    /// the state lock — depends on tokio enqueuing a semaphore waiter on first
333    /// poll, which is an implementation detail and not a documented contract.
334    /// A FIFO mutex is correct under cancellation by construction: the guard
335    /// drops, the next waiter proceeds, order holds. Do not "fix" this back.
336    announce_order: Mutex<()>,
337}
338
339struct Session {
340    backend: Arc<ChromiumBackend>,
341    /// Behind an `Arc` so a browse call can clone the handle out of
342    /// `inner`'s guard and DROP the lock before awaiting the tool — see the
343    /// `browse_` arm of [`BrowserTools::execute`]. Holding `inner` across
344    /// `execute()` froze every drawer input and every presentation snapshot
345    /// for the whole agent action.
346    exec: Arc<BrowserToolExecutor>,
347}
348
349/// Which Chromium profile policy a `BrowserTools` launches against.
350///
351/// Persistent purposes use separate directories; coder sessions are ephemeral.
352/// Chromium allows exactly ONE live
353/// instance per profile directory, and an agent browsing while the person
354/// uses the drawer's standing session is an ordinary situation, not an edge
355/// case. Sharing one directory made the second launch die on SingletonLock;
356/// nothing in the design requires the two to share cookies.
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358pub enum BrowserProfile {
359    /// `~/.car/browser-profile` — the agent's browser, locked decision 7's
360    /// one persistent profile, unchanged.
361    Agent,
362    /// `~/.car/browser-profile-user` — the drawer's standing session. Its
363    /// sign-ins persist across launches independently of any agent's.
364    StandingSession,
365    /// Fresh backend-owned profile; no cookies shared with another session.
366    Isolated,
367}
368
369impl BrowserProfile {
370    /// What to pass as `LaunchOptions.profile_dir`.
371    ///
372    /// `None` when the operator has set `CAR_BROWSER_PROFILE_DIR`, so
373    /// car-browser's own resolution order (`explicit → env → ephemeral`)
374    /// falls through to the env var and the knob relocates this browser, as
375    /// it did before per-purpose directories existed. The derived default
376    /// below is only the value used when the operator has expressed no
377    /// preference.
378    ///
379    /// CAR code still never WRITES that variable — the invariant the
380    /// grep test pins — it just stops shadowing it.
381    fn launch_dir(self) -> Option<PathBuf> {
382        if self == Self::Isolated {
383            return None;
384        }
385        if std::env::var("CAR_BROWSER_PROFILE_DIR")
386            .ok()
387            .is_some_and(|v| !v.is_empty())
388        {
389            return None;
390        }
391        self.dir()
392    }
393
394    /// The directory, or `None` when neither `CAR_HOME` nor a home directory
395    /// resolves — in which case the browser launches on car-browser's
396    /// throwaway per-instance profile, exactly as it did before persistence
397    /// existed.
398    fn dir(self) -> Option<PathBuf> {
399        let leaf = match self {
400            BrowserProfile::Agent => "browser-profile",
401            BrowserProfile::StandingSession => "browser-profile-user",
402            BrowserProfile::Isolated => return None,
403        };
404        car_home::root().map(|root| root.join(leaf))
405    }
406}
407
408impl BrowserTools {
409    /// The agent's browser — the persistent profile an assistant run drives.
410    pub fn new(root: PathBuf) -> Self {
411        Self::with_profile(root, BrowserProfile::Agent)
412    }
413
414    /// A coder session owns a fresh, ephemeral profile for its browser lifetime.
415    pub(crate) fn isolated(root: PathBuf) -> Self {
416        Self::with_profile(root, BrowserProfile::Isolated)
417    }
418
419    /// The drawer's standing session, on its own persistent profile.
420    pub fn standing_session(root: PathBuf) -> Self {
421        Self::with_profile(root, BrowserProfile::StandingSession)
422    }
423
424    fn with_profile(root: PathBuf, profile: BrowserProfile) -> Self {
425        Self {
426            root,
427            profile,
428            inner: Arc::new(Mutex::new(None)),
429            recording: Arc::new(Mutex::new(None)),
430            finished_recording: Arc::new(Mutex::new(None)),
431            recording_restore_quality: Arc::new(Mutex::new(None)),
432            presentation: Arc::new(Mutex::new(PresentationState::new())),
433            changes: watch::channel(0).0,
434            frames: Arc::new(FrameFanout::new(
435                VIEWPORT_W,
436                VIEWPORT_H,
437                DEFAULT_FRAME_QUALITY,
438            )),
439            host_connectivity: OnceLock::new(),
440            launched_headless: OnceLock::new(),
441            signin_attention: OnceLock::new(),
442        }
443    }
444
445    /// Build a [`HostConnectivity`] backed by `state`'s live session set —
446    /// the in-daemon producer's probe (`assistant_start` and anything else
447    /// that builds an `AssistantRuntime` inside the daemon). Install it via
448    /// [`Self::set_host_connectivity`] right after `build_assistant_runtime`
449    /// returns, before the run can reach its first browse call.
450    pub fn daemon_host_connectivity(
451        state: &Arc<crate::session::ServerState>,
452    ) -> Arc<dyn HostConnectivity> {
453        Arc::new(DaemonHostConnectivity(Arc::downgrade(state)))
454    }
455
456    /// Install the probe used to decide headless vs headed at launch, and to
457    /// notice a host disconnecting mid-run for the sign-in fallback.
458    /// `OnceLock`-backed: every production caller installs this exactly
459    /// once, before any browse call can possibly fire, so a second call
460    /// (unreachable in practice) is a silent no-op rather than a panic.
461    pub fn set_host_connectivity(&self, probe: Arc<dyn HostConnectivity>) {
462        let _ = self.host_connectivity.set(probe);
463    }
464
465    /// Install the operator-attention sink for this browser, under the
466    /// conversation key it is registered as (`None` = the standing session).
467    ///
468    /// `OnceLock`-backed for the same reason as
469    /// [`Self::set_host_connectivity`]: every production caller installs this
470    /// exactly once, before the run can reach a browse call, so a second call
471    /// is a silent no-op rather than a panic.
472    pub fn set_signin_attention(
473        &self,
474        attention: Arc<dyn SignInAttention>,
475        conversation_id: Option<String>,
476    ) {
477        let _ = self.signin_attention.set(SignInAttentionBinding {
478            attention,
479            conversation_id,
480            announced: Mutex::new(None),
481            announce_order: Mutex::new(()),
482        });
483    }
484
485    /// Is a host connected RIGHT NOW, per whatever probe was installed?
486    /// `false` when none was (a pure CLI embedding, or any test).
487    async fn any_host_connected(&self) -> bool {
488        match self.host_connectivity.get() {
489            Some(probe) => probe.any_host_connected().await,
490            None => false,
491        }
492    }
493
494    /// Whether the browser this call is about to (or already does) serve is
495    /// headless: the ACTUAL launch decision once one has been made, or the
496    /// decision `ensure_launched` would make right now otherwise. Reading
497    /// this needs no lock and no live Chromium — the sign-in host-gone check
498    /// calls it before ever touching a session.
499    fn effective_headless(&self, host_connected: bool) -> bool {
500        match self.launched_headless.get() {
501            Some(&headless) => headless,
502            None => decide_headless(
503                host_connected,
504                std::env::var("CAR_BROWSER_HEADLESS").ok().as_deref(),
505            ),
506        }
507    }
508
509    /// Advertised whenever a Chromium is plausibly launchable. Deliberately not
510    /// probing by launching a browser at prompt-build time — that would pay the
511    /// cost the lazy launch exists to avoid, on every session.
512    pub fn tool_defs(&self) -> Vec<Value> {
513        browser_tool_defs()
514    }
515
516    /// The agent's own entry point: launching here also ATTACHES the agent
517    /// (ownership becomes run-scoped from this moment — see
518    /// `browser_control::ControlState`).
519    async fn session(&self) -> Result<Arc<ChromiumBackend>, String> {
520        self.ensure_session(true).await
521    }
522
523    /// Launch (or reuse) the browser. `attach_agent` decides whether this
524    /// counts as the agent taking the wheel.
525    ///
526    /// The user's own navigation in the drawer must NOT attach an agent — a
527    /// standing session with no agent involved is "zero ceremony, no strip",
528    /// and firing `AgentAttached` there would put the drawer in watch-only
529    /// mode for a browser nobody is driving but the user.
530    ///
531    /// The attach is deliberately NOT tied to "did this call launch the
532    /// browser". It fires on the first AGENT use of this `BrowserTools`,
533    /// whoever launched Chromium. Tying it to the launch meant a user
534    /// navigating in the drawer before the agent's first browse call
535    /// permanently prevented the agent from ever attaching for that run: the
536    /// agent's `session()` found a browser already there and returned early,
537    /// so `owner` stayed `NoAgent` for the whole run — no strip, `take_control`
538    /// a no-op, and the user-control blackout unable to engage at all.
539    ///
540    /// It is also NOT unconditional. `AgentAttached` sets `owner = Agent`, so
541    /// firing it on every `session()` call would hand control straight back to
542    /// the agent the instant the user pressed Take control — defeating the
543    /// tool-boundary pause.
544    async fn ensure_session(&self, attach_agent: bool) -> Result<Arc<ChromiumBackend>, String> {
545        let backend = self.ensure_launched().await?;
546        if attach_agent {
547            self.maybe_attach_agent().await;
548        }
549        Ok(backend)
550    }
551
552    /// Fire `AgentAttached` when — and only when — nobody is driving:
553    /// `owner == NoAgent`. See [`BrowserTools::ensure_session`] for both
554    /// halves of why it is neither launch-tied nor unconditional.
555    ///
556    /// Derived from the control state rather than kept as a separate
557    /// once-per-`BrowserTools` flag, because "one `BrowserTools` is one run"
558    /// is FALSE on the shipped path: `car do --serve` hands the single
559    /// process-lifetime `asm.browser` to `BrowserProducer::install`, and
560    /// `TurnGuard` fires `RunEnded` on that same instance at the end of
561    /// EVERY turn. A latching flag therefore attached on turn 1 and never
562    /// again — so from turn 2 on, `RunEnded`'s `owner = NoAgent` was never
563    /// undone and every `browse_*` call sat in `wait_for_agent_turn` for the
564    /// full 30-minute bound before returning "the user is currently driving
565    /// the browser", with no user anywhere near it.
566    ///
567    /// `NoAgent` is exactly the set of states where attaching is right: the
568    /// initial state, after `RunEnded`, and after a hand-back that landed on
569    /// `NoAgent` because the run had ended. `Agent` means this run already
570    /// attached (a no-op), and `User` means a human is holding the wheel —
571    /// never take it from them here; `wait_for_agent_turn` is what waits.
572    async fn maybe_attach_agent(&self) {
573        let attach = {
574            let state = self.presentation.lock().await;
575            state.control().owner() == ControlOwner::NoAgent
576        };
577        if attach {
578            self.apply_control(ControlEvent::AgentAttached).await;
579        }
580    }
581
582    async fn ensure_launched(&self) -> Result<Arc<ChromiumBackend>, String> {
583        let mut guard = self.inner.lock().await;
584        if guard.is_none() {
585            // Persist cookies and localStorage across runs.
586            //
587            // `car-browser` defaults to a throwaway per-instance profile —
588            // correct for parallel scraping, since it avoids Chromium
589            // SingletonLock contention. For an ASSISTANT driving real web
590            // apps it is the wrong default: every run lands on a login page,
591            // so "record our app" or "check my dashboard" can never work. The
592            // user signs in once and the session persists.
593            //
594            // Passed as a launch OPTION, never by setting
595            // `CAR_BROWSER_PROFILE_DIR`. That env var is process-global: one
596            // component setting it silently repointed every OTHER browser in
597            // the daemon at the same directory, so opening the drawer and
598            // typing one URL made every later `browser.run` die on
599            // SingletonLock for the life of that daemon. CAR code never
600            // writes it.
601            //
602            // And the option supplies only the DEFAULT: `launch_dir` yields
603            // `None` when the operator has set `CAR_BROWSER_PROFILE_DIR`, so
604            // car-browser's own resolution reads it and the knob still
605            // relocates these browsers exactly as it did before this branch
606            // existed. Passing the derived directory unconditionally shadowed
607            // it — `LaunchOptions.profile_dir.or_else(env)` means an explicit
608            // value wins — which silently broke a documented operator knob.
609            let profile_dir = self.profile.launch_dir();
610            if let Some(dir) = &profile_dir {
611                let _ = std::fs::create_dir_all(dir);
612            }
613            // Task 7's decision rule: headless when a CarHost host-client is
614            // connected to the daemon right now, so the drawer is a real
615            // visible surface and can BE this browser's face — no separate
616            // Chrome window. Headed when no host has ever connected (a pure
617            // CLI session): a recording is FOOTAGE, it should show the app
618            // as a person sees it, headless also trips bot-detection on some
619            // login flows, and `browse_await_signin` needs a window to pop.
620            // `CAR_BROWSER_HEADLESS` keeps overriding either way — see
621            // `decide_headless`. The decision is made once, here, and fixed
622            // for this instance's lifetime (`launched_headless`): a browser
623            // does not change from headed to headless (or back) mid-run.
624            let host_connected = self.any_host_connected().await;
625            let headless = decide_headless(
626                host_connected,
627                std::env::var("CAR_BROWSER_HEADLESS").ok().as_deref(),
628            );
629            let options = car_browser::chromium::LaunchOptions {
630                width: VIEWPORT_W,
631                height: VIEWPORT_H,
632                headless,
633                extra_args: Vec::new(),
634                profile_dir,
635            };
636            let backend = if self.profile == BrowserProfile::Isolated {
637                ChromiumBackend::launch_isolated_with_options(options).await
638            } else {
639                ChromiumBackend::launch_with_options(options).await
640            }
641            .map_err(|e| format!("launch browser: {e}"))?;
642            // Latched only now the launch has SUCCEEDED. Setting it first
643            // meant a failed launch pinned the mode for the instance's whole
644            // life — a `OnceLock`, so the retry that actually starts a
645            // browser could never correct it, and every later
646            // `effective_headless` answer (which decides whether
647            // `browser_await_signin` reports a window or the drawer) came
648            // from a browser that never existed.
649            let _ = self.launched_headless.set(headless);
650            let backend = Arc::new(backend);
651            // A browser now exists: point the live fan-out at it (starting
652            // capture if the drawer is already subscribed) and wake any
653            // drawer subscriber so it re-reads the tab list.
654            self.frames.bind(Arc::clone(&backend)).await;
655            self.signal_change();
656            *guard = Some(Session {
657                backend: Arc::clone(&backend),
658                exec: Arc::new(BrowserToolExecutor::new(
659                    Arc::clone(&backend) as Arc<dyn car_browser::BrowserBackend>,
660                    // Vision-fused perception, NOT the bare accessibility
661                    // tree. The model driving the browser is text-only, so all
662                    // it ever gets from browse_observe is the ui_map — and a
663                    // polished custom SPA (Contrails, most React apps) exposes
664                    // a poor a11y tree, so the composer and Send button simply
665                    // don't appear and the agent clicks blind. VisionPerception
666                    // runs OCR (Apple Vision on macOS) over the screenshot and
667                    // fuses recovered labels onto the elements, so the text map
668                    // actually names the controls that are on screen. Degrades
669                    // to the plain tree when no OCR backend is present.
670                    Arc::new(VisionPerceptionPipeline::new()),
671                )),
672            });
673        }
674        Ok(Arc::clone(&guard.as_ref().expect("just set").backend))
675    }
676
677    /// Block until the page STOPS changing — i.e. an answer has finished
678    /// rendering — then return.
679    ///
680    /// This exists because `browse_observe` snapshots immediately: the model
681    /// cannot tell "still loading" from "done", so when told to record an app
682    /// answering a question it submits, waits a guessed couple of seconds, and
683    /// stops recording while the app is still thinking. (Observed live: 7 of 8
684    /// Contrails recordings captured the home screen because the answer hadn't
685    /// arrived yet.) Polling the rendered text length until it holds steady for
686    /// a few consecutive checks is a content-agnostic "it settled" signal that
687    /// works without knowing anything about the site.
688    async fn run_await_answer(&self, params: &Value) -> Result<Value, String> {
689        self.run_await_answer_with(
690            params,
691            Duration::from_secs(AGENT_PAUSE_TIMEOUT_SECS),
692            Duration::from_secs(1),
693        )
694        .await
695    }
696
697    /// `gate_timeout`/`gate_poll` parameterize the boundary wait below (see
698    /// `wait_while_blackout_with`) so tests can exercise the block path
699    /// without waiting `AGENT_PAUSE_TIMEOUT_SECS`.
700    async fn run_await_answer_with(
701        &self,
702        params: &Value,
703        gate_timeout: Duration,
704        gate_poll: Duration,
705    ) -> Result<Value, String> {
706        // This tool MEASURES THE PAGE — repeatedly, for up to ten minutes,
707        // returning `content_length` to the model. That is model-facing
708        // observation in every sense the blackout means (R3), so it gates
709        // like the other browser tools do, before touching a session. The
710        // blackout predicate rather than `user_holds_control` because it
711        // covers the sign-in case too, and is `false` for `NoAgent` — so a
712        // first call that hasn't attached yet is never blocked.
713        self.wait_while_blackout_with(gate_timeout, gate_poll)
714            .await?;
715        let backend = self.session().await?;
716        let page = backend
717            .page_handle()
718            .await
719            .map_err(|e| format!("no page: {e}"))?;
720        let timeout = params
721            .get("timeout_seconds")
722            .and_then(Value::as_u64)
723            // A real LLM-backed app answering a data question takes 1-2 minutes,
724            // so the default is generous. Observed on Contrails: a delayed-flights
725            // query sat on "Almost there…" for over 90s before the table rendered.
726            .unwrap_or(150)
727            .clamp(3, 600);
728        // Poll interval, and how long content must hold STEADY to count as done.
729        // A loading spinner animates its dots, so raw text length wobbles by a
730        // few chars while "generating"; requiring a longer steady hold and a
731        // tolerance band keeps that wobble from reading as "still growing"
732        // forever (the bug that stranded every recording on the spinner).
733        let poll = Duration::from_millis(1000);
734        let steady_hold = Duration::from_secs(6);
735
736        self.await_settled(Duration::from_secs(timeout), poll, steady_hold, || async {
737            page.evaluate("document.body ? document.body.innerText.length : 0")
738                .await
739                .ok()
740                .and_then(|v| v.into_value::<i64>().ok())
741                .unwrap_or(0)
742        })
743        .await
744    }
745
746    /// The settle loop `browser_await_answer` runs, with the page read behind
747    /// a `measure` closure.
748    ///
749    /// Extracted for one reason: the blackout re-check inside the loop is the
750    /// half of R3 that an entry gate cannot cover — the user can press Take
751    /// control, or a sign-in can appear, while this is already polling. With
752    /// `measure` injected, a test can prove the page is NEVER read during a
753    /// blackout without needing a live Chromium.
754    ///
755    /// While blacked out the loop PAUSES rather than exiting: the answer may
756    /// still be rendering, and the honest thing is to keep waiting out the
757    /// caller's own timeout rather than report a settled state nobody looked
758    /// at. The steady clock resets, so blackout time never counts as "held
759    /// steady", and the timeout path reports only values measured before the
760    /// blackout began — nothing observed during it reaches the model.
761    async fn await_settled<F, Fut>(
762        &self,
763        timeout: Duration,
764        poll: Duration,
765        steady_hold: Duration,
766        measure: F,
767    ) -> Result<Value, String>
768    where
769        F: Fn() -> Fut,
770        Fut: std::future::Future<Output = i64>,
771    {
772        // Length changes at or below this are treated as noise (spinner dots,
773        // a relative timestamp ticking), not real content growth.
774        let noise_band: i64 = 8;
775
776        let started = Instant::now();
777        let baseline = measure().await;
778        let mut last = baseline;
779        let mut steady_since: Option<Instant> = None;
780        let mut peak = baseline;
781        while started.elapsed() < timeout {
782            tokio::time::sleep(poll).await;
783            // The loop already sleeps every tick, so this check is free — and
784            // it is the only thing standing between the model and a page the
785            // user took control of (or is typing a password into) after this
786            // call started.
787            if self.blackout_active().await {
788                steady_since = None;
789                continue;
790            }
791            let now = measure().await;
792            peak = peak.max(now);
793            if (now - last).abs() > noise_band {
794                // Real change — reset the steady clock.
795                steady_since = None;
796                last = now;
797            } else {
798                // Within the noise band. Only start (or continue) counting as
799                // steady once content has meaningfully GROWN past where we
800                // began — otherwise a static page "settles" before the answer
801                // starts, and a spinner alone never trips it.
802                if peak - baseline > noise_band {
803                    let since = *steady_since.get_or_insert_with(Instant::now);
804                    if since.elapsed() >= steady_hold {
805                        return Ok(json!({
806                            "settled": true,
807                            "content_length": now,
808                            "waited_seconds": started.elapsed().as_secs(),
809                        }));
810                    }
811                }
812            }
813        }
814        Ok(json!({
815            "settled": false,
816            "content_length": last,
817            "grew": peak - baseline > noise_band,
818            "note": "timed out before the page held steady. If `grew` is true the answer was \
819                     still streaming at timeout — raise timeout_seconds. If false, the action \
820                     produced no visible change (the submit may not have registered).",
821        }))
822    }
823
824    /// Hand the browser to the human so they can sign in, then resume.
825    ///
826    /// An agent driving a real web app hits auth immediately, and it cannot
827    /// (and must not) type someone's password. The browser has a visible
828    /// surface for the user to complete the flow on — SSO, MFA, a device
829    /// prompt, whatever it is — either the drawer (browser launched
830    /// headless because a host was connected) or a headed Chrome window
831    /// (pure CLI session, no host ever connected) — see [`decide_headless`].
832    /// If the browser is headless and nobody is around to see the drawer
833    /// right now, there is no surface at all; this call fails clearly with
834    /// [`HOST_GONE_FOR_SIGNIN`] instead of polling for up to `timeout`
835    /// seconds against a window nobody can see (Task 7's seam resolution).
836    /// This tool is the handshake otherwise: navigate, surface the ask, and
837    /// block until the sign-in visibly succeeds.
838    ///
839    /// Completion is detected by the URL leaving the login flow, which is the
840    /// one signal that works across SSO redirects without knowing anything
841    /// about the site's markup.
842    async fn run_await_signin(&self, params: &Value) -> Result<Value, String> {
843        self.run_await_signin_with(
844            params,
845            Duration::from_secs(AGENT_PAUSE_TIMEOUT_SECS),
846            Duration::from_secs(1),
847        )
848        .await
849    }
850
851    /// `gate_timeout`/`gate_poll` parameterize the boundary wait below (see
852    /// `wait_while_user_holds_control_with`) so tests can exercise the
853    /// block path without waiting `AGENT_PAUSE_TIMEOUT_SECS`.
854    async fn run_await_signin_with(
855        &self,
856        params: &Value,
857        gate_timeout: Duration,
858        gate_poll: Duration,
859    ) -> Result<Value, String> {
860        // Must not navigate — or do anything else — while the user holds
861        // control: this call would otherwise yank the page out from under
862        // someone who just took it. Blocking here until hand-back is the
863        // resolution (per the task brief's approval-race semantics): the
864        // sign-in request then starts cleanly once the user is done
865        // driving, exactly as if it had been called fresh. Checked BEFORE
866        // `session()` — safe to do so because `user_holds_control` (unlike
867        // `may_agent_act`) is false for `NoAgent`, so a call that hasn't
868        // attached yet is never mistaken for "the user is driving" and
869        // never blocks on its own first invocation.
870        self.wait_while_user_holds_control_with(gate_timeout, gate_poll)
871            .await?;
872
873        // Task 7's host-gone fallback. A headless browser's only visible
874        // surface is the drawer; if no host is connected right now there is
875        // NOBODY who could complete a sign-in, so fail clearly instead of
876        // polling silently for up to `timeout` seconds. Checked BEFORE
877        // `session()` (so before ever touching Chromium, and testable
878        // without a live one): `effective_headless` reads the ACTUAL launch
879        // decision once a browser exists, or what `ensure_launched` would
880        // decide right now otherwise — either way, no live check requires
881        // this instance's browser to already be running.
882        let host_connected = self.any_host_connected().await;
883        if self.effective_headless(host_connected) && !host_connected {
884            return Err(HOST_GONE_FOR_SIGNIN.to_string());
885        }
886
887        let backend = self.session().await?;
888        let url_param = params
889            .get("url")
890            .and_then(|v| v.as_str())
891            .map(str::trim)
892            .filter(|s| !s.is_empty());
893
894        // Surface the drawer's orange strip (and start the privacy
895        // blackout) before navigating, not after — the request itself, not
896        // just the resulting page, is what must never reach the model.
897        let signin_message = match url_param {
898            Some(url) => format!("Sign in at {url}"),
899            None => "Sign in to continue in the browser window".to_string(),
900        };
901        self.apply_control(ControlEvent::SignInRequested(signin_message))
902            .await;
903
904        if let Some(url) = url_param {
905            if let Err(e) = backend.navigate(url).await {
906                // Don't leave the strip up for a sign-in that never starts.
907                self.apply_control(ControlEvent::SignInResolved { signed_in: false })
908                    .await;
909                return Err(format!("navigate to {url}: {e}"));
910            }
911        }
912        let expect = params
913            .get("success_url_contains")
914            .and_then(|v| v.as_str())
915            .map(str::to_string);
916        let timeout = params
917            .get("timeout_seconds")
918            .and_then(Value::as_u64)
919            .unwrap_or(300)
920            .clamp(10, 1800);
921
922        let started = Instant::now();
923        let mut last = String::new();
924        while started.elapsed() < Duration::from_secs(timeout) {
925            tokio::time::sleep(Duration::from_secs(2)).await;
926            // Honest resolution: "Hand back to CAR" already resolves a
927            // pending sign-in (see `ControlState::apply`'s HandBack branch)
928            // without our own detection loop knowing about it directly. If
929            // something else already cleared the strip, this loop is the
930            // only thing left with model-facing state, so it reports the
931            // same signed_in:false a genuine hand-back means.
932            if self
933                .presentation
934                .lock()
935                .await
936                .control()
937                .pending_signin()
938                .is_none()
939            {
940                return Ok(json!({
941                    "signed_in": false,
942                    "url": last,
943                    "note": "Control was handed back before sign-in was detected.",
944                }));
945            }
946            // The same host-gone predicate the entry gate uses, re-read.
947            // Checked only at entry, a host that quit mid-wait left the agent
948            // polling a headless page nobody could see for up to 1800s — the
949            // exact silent wait `HOST_GONE_FOR_SIGNIN` exists to replace.
950            // Unlike the entry gate this must also clear the strip: by now
951            // `SignInRequested` has been applied.
952            let host_now = self.any_host_connected().await;
953            if self.effective_headless(host_now) && !host_now {
954                self.apply_control(ControlEvent::SignInResolved { signed_in: false })
955                    .await;
956                return Err(HOST_GONE_FOR_SIGNIN.to_string());
957            }
958            // The user pressed Take control while this wait was running.
959            // `TakeControl` does not resolve the pending sign-in — by design,
960            // the strip stays up — so without this the loop kept reading the
961            // page's URL every two seconds and handed it to the model, for up
962            // to 1800s, with the blackout nominally up. Worse, the no-`expect`
963            // heuristic below treats any URL without login/auth in it as
964            // success, so a person navigating away mid-blackout would have
965            // declared the sign-in complete and handed the model that URL.
966            // Keep waiting; observe nothing.
967            if self
968                .presentation
969                .lock()
970                .await
971                .control()
972                .user_holds_control()
973            {
974                continue;
975            }
976            let current = backend.get_current_url().unwrap_or_default();
977            last = current.clone();
978            let done = match &expect {
979                Some(needle) => current.contains(needle.as_str()),
980                // With no explicit target, treat leaving the login/auth path
981                // as success — covers the common OAuth/SSO round trip.
982                None => {
983                    !current.is_empty()
984                        && !["login", "signin", "sign-in", "auth", "oauth", "sso"]
985                            .iter()
986                            .any(|p| current.to_ascii_lowercase().contains(p))
987                }
988            };
989            if done {
990                self.apply_control(ControlEvent::SignInResolved { signed_in: true })
991                    .await;
992                return Ok(json!({
993                    "signed_in": true,
994                    "url": current,
995                    "note": "Sign-in detected. The session persists in the browser profile, so \
996                             later runs won't need this again.",
997                }));
998            }
999        }
1000        // Resolved on timeout ONLY if nobody was ever here.
1001        //
1002        // Two rules that both have to hold, and this predicate is what lets
1003        // them. The acceptance contract: "letting the sign-in request time out
1004        // behaves as today — the strip clears, the agent receives the same
1005        // timeout result, and control returns to it." That is right when the
1006        // timer expired because nobody came: no strip anyone is reading, no
1007        // page anyone is typing into, and latching the blackout would wedge
1008        // every later browse call — on a one-shot `car do` run, forever, since
1009        // that path has no drawer, no Hand back, and no run-end signal reaching
1010        // this reducer at all.
1011        //
1012        // The privacy rule: a timer expiring is not evidence the person
1013        // finished. If somebody took control or drove the browser during this
1014        // window, clearing the strip lifts the blackout under them — the
1015        // model's page reads reopen and an in-flight recording (a
1016        // `FrameAudience::Model` consumer kept registered precisely so it
1017        // resumes) starts writing the login form to disk.
1018        //
1019        // So: engagement decides. Un-engaged, the contract's semantics
1020        // exactly. Engaged, the window persists until THEIR signal — hand-back,
1021        // run end, or the disconnect grace expiring — each of which resolves it
1022        // honestly as `signed_in: false`.
1023        let user_engaged = self.presentation.lock().await.control().user_engaged();
1024        if !user_engaged {
1025            self.apply_control(ControlEvent::SignInResolved { signed_in: false })
1026                .await;
1027        }
1028        let host_connected = self.any_host_connected().await;
1029        let headless = self.effective_headless(host_connected);
1030        // Naming the page is model-facing observation too. If the person is
1031        // still driving at the timeout, `last` is whatever was on screen
1032        // before they took over — stale, and not ours to report.
1033        //
1034        // Gated on ENGAGEMENT, not ownership, and on the value already computed
1035        // above rather than a second read. The ordinary sign-in flow never
1036        // involves Take control, so `user_holds_control()` is false for the
1037        // most common case there is — a person typing into the credential form
1038        // — and this branch handed the model the live page URL of exactly the
1039        // page the blackout above had just been kept up to hide. Same
1040        // asymmetry, same fix, as the `RunEnded` rule in `browser_control`.
1041        if user_engaged {
1042            return Err(format!(
1043                "timed out after {timeout}s waiting for sign-in — the user is still driving \
1044                 the browser. {}",
1045                signin_timeout_hint(headless, host_connected)
1046            ));
1047        }
1048        Err(format!(
1049            "timed out after {timeout}s waiting for sign-in — the browser is still at {last}. \
1050             {}",
1051            signin_timeout_hint(headless, host_connected)
1052        ))
1053    }
1054
1055    async fn run_record_start(&self, params: &Value) -> Result<Value, String> {
1056        self.run_record_start_with(
1057            params,
1058            Duration::from_secs(AGENT_PAUSE_TIMEOUT_SECS),
1059            Duration::from_secs(1),
1060        )
1061        .await
1062    }
1063
1064    /// `gate_timeout`/`gate_poll` parameterize the boundary wait below (see
1065    /// `wait_while_user_holds_control_with`) so tests can exercise the
1066    /// block path without waiting `AGENT_PAUSE_TIMEOUT_SECS`.
1067    async fn run_record_start_with(
1068        &self,
1069        params: &Value,
1070        gate_timeout: Duration,
1071        gate_poll: Duration,
1072    ) -> Result<Value, String> {
1073        // Same tier, same "agent-initiated mutating browser action" shape
1074        // as browse_*/browser_await_signin — pause here too, before ever
1075        // touching the session, rather than starting a recording (or
1076        // failing on a stale precondition) while the user is driving.
1077        self.wait_while_user_holds_control_with(gate_timeout, gate_poll)
1078            .await?;
1079        let mut rec = self.recording.lock().await;
1080        if rec.is_some() {
1081            return Err(
1082                "a recording is already in progress — call browser_record_stop first".to_string(),
1083            );
1084        }
1085        // A recording the previous run ended under, that nobody came back to
1086        // encode. Starting a new one supersedes it, so its frames are a build
1087        // artifact nothing will ever read — dropped here rather than left to
1088        // accumulate one directory per abandoned recording.
1089        if let Some(stale) = self.finished_recording.lock().await.take() {
1090            let _ = std::fs::remove_dir_all(&stale.dir);
1091        }
1092        let backend = self.session().await?;
1093        // Fail here, before any frame plumbing, exactly as before: a browser
1094        // with no page cannot be recorded.
1095        backend
1096            .page_handle()
1097            .await
1098            .map_err(|e| format!("no page to record: {e}"))?;
1099
1100        let quality = params
1101            .get("quality")
1102            .and_then(Value::as_i64)
1103            .unwrap_or(DEFAULT_FRAME_QUALITY)
1104            .clamp(1, 100);
1105        let dir = self.root.join(format!(
1106            ".car-recording-{}",
1107            std::time::SystemTime::now()
1108                .duration_since(std::time::UNIX_EPOCH)
1109                .map(|d| d.as_millis())
1110                .unwrap_or(0)
1111        ));
1112        // Disk recording is a consumer of the SAME screencast the drawer
1113        // watches (see `browser_stream`), registered as `Model` so the
1114        // privacy blackout keeps a user-control / sign-in window out of the
1115        // produced video while the drawer keeps streaming it (R3).
1116        // Remembered so `record_stop` can put it back. `set_quality` stores
1117        // into the atomic the SHARED pump reads and re-attaches it, and that
1118        // pump also serves every `FrameAudience::Viewer` — the person watching
1119        // the drawer. `quality` is a model-supplied tool argument, so
1120        // `browser_record_start { quality: 1 }` dropped the human's live view
1121        // to unreadable artifacts and left it there for the life of the
1122        // browser, across every later turn.
1123        let restore_quality = self.frames.quality();
1124        self.frames.set_quality(quality).await;
1125        let (incoming, epoch) = self.frames.subscribe(FrameAudience::Model).await;
1126        let started = car_browser::recorder::start_with_frames(incoming, epoch, &dir).await;
1127        let handle = match started {
1128            Ok(handle) => handle,
1129            Err(e) => {
1130                // Symmetric with `run_record_stop_with`: the consumer above is
1131                // registered, so a failure here owes the same release. Without
1132                // it a failed `start_with_frames` (an unwritable recording
1133                // directory is the realistic one) left a dead consumer keeping
1134                // the CDP screencast armed for the life of the browser. The
1135                // quality goes back for the same reason.
1136                self.release_frames().await;
1137                self.frames.set_quality(restore_quality).await;
1138                return Err(format!("start recording: {e}"));
1139            }
1140        };
1141        *rec = Some(handle);
1142        *self.recording_restore_quality.lock().await = Some(restore_quality);
1143        Ok(json!({
1144            "recording": true,
1145            "note": "Recording. Drive the app with the browse_* tools, then call \
1146                     browser_record_stop. Frames are only captured when the page \
1147                     CHANGES, so a static page produces nothing — make sure \
1148                     something actually happens on screen.",
1149        }))
1150    }
1151
1152    async fn run_record_stop(&self, params: &Value) -> Result<Value, String> {
1153        self.run_record_stop_with(
1154            params,
1155            Duration::from_secs(AGENT_PAUSE_TIMEOUT_SECS),
1156            Duration::from_secs(1),
1157        )
1158        .await
1159    }
1160
1161    /// `gate_timeout`/`gate_poll` parameterize the boundary wait below (see
1162    /// `wait_while_user_holds_control_with`) so tests can exercise the
1163    /// block path without waiting `AGENT_PAUSE_TIMEOUT_SECS`.
1164    /// Everything between taking the handle and having a finished recording —
1165    /// split out so its `?`s cannot skip the frame release its caller owes.
1166    #[allow(clippy::type_complexity)]
1167    async fn finish_recording(
1168        &self,
1169        handle: RecordingHandle,
1170        params: &Value,
1171    ) -> Result<(PathBuf, String, car_browser::recorder::Recording), String> {
1172        let (out, rel) = self.recording_output(params)?;
1173        let recording = handle
1174            .stop()
1175            .await
1176            .map_err(|e| format!("stop recording: {e}"))?;
1177        Ok((out, rel, recording))
1178    }
1179
1180    /// Resolve and prepare `output_path`. Split out from
1181    /// [`Self::finish_recording`] because a recording the RUN already stopped
1182    /// (see [`Self::stop_recording_at_run_end`]) still needs the destination
1183    /// but has nothing left to stop.
1184    fn recording_output(&self, params: &Value) -> Result<(PathBuf, String), String> {
1185        let rel = params
1186            .get("output_path")
1187            .and_then(|v| v.as_str())
1188            .filter(|s| !s.trim().is_empty())
1189            .unwrap_or("assets/recording.mp4")
1190            .to_string();
1191        if !stays_under(&self.root, &rel) {
1192            return Err(format!("output_path '{rel}' escapes the working directory"));
1193        }
1194        let out = self.root.join(&rel);
1195        if let Some(parent) = out.parent() {
1196            std::fs::create_dir_all(parent).map_err(|e| format!("create output dir: {e}"))?;
1197        }
1198        Ok((out, rel))
1199    }
1200
1201    /// The run ended with a recording still running: stop it here, so no frame
1202    /// captured after run-end can reach disk.
1203    ///
1204    /// Nothing else would. `self.recording` was taken only by `record_stop`
1205    /// and by `record_start`'s already-in-progress check; there is no `Drop`
1206    /// for `BrowserTools`, the browser is deliberately NOT torn down at run
1207    /// end, and the person keeps driving the page (`browser_view.rs` admits
1208    /// input once `owner == NoAgent`), so the change-driven screencast kept
1209    /// emitting into a `FrameAudience::Model` consumer that `browser_stream`
1210    /// deliberately keeps registered through a blackout. Their own browsing,
1211    /// written to the agent's recording directory, for the life of the process.
1212    ///
1213    /// The artifact is finalized rather than discarded: `handle.stop()` writes
1214    /// the concat manifest over the frames captured UP TO now, and the result
1215    /// is held so a later `browser_record_stop` still returns the model its
1216    /// video — one that ends where the run did.
1217    async fn stop_recording_at_run_end(&self) {
1218        let Some(handle) = self.recording.lock().await.take() else {
1219            return;
1220        };
1221        let finished = handle.stop().await;
1222        // The same two duties `record_stop` owes once the handle is out: the
1223        // Model consumer is this function's to release, and the drawer gets
1224        // its picture quality back.
1225        self.release_frames().await;
1226        if let Some(restore) = self.recording_restore_quality.lock().await.take() {
1227            self.frames.set_quality(restore).await;
1228        }
1229        match finished {
1230            Ok(recording) => {
1231                tracing::debug!(
1232                    frames = recording.frame_count,
1233                    "browser tools: the run ended with a recording running; stopped it there"
1234                );
1235                *self.finished_recording.lock().await = Some(recording);
1236            }
1237            // "screencast captured no frames" is the ordinary case for a page
1238            // that never changed — nothing to keep, and nothing to report to
1239            // an agent whose run is already over.
1240            Err(e) => tracing::debug!(
1241                error = %e,
1242                "browser tools: the recording the run ended under produced nothing"
1243            ),
1244        }
1245    }
1246
1247    async fn run_record_stop_with(
1248        &self,
1249        params: &Value,
1250        gate_timeout: Duration,
1251        gate_poll: Duration,
1252    ) -> Result<Value, String> {
1253        self.wait_while_user_holds_control_with(gate_timeout, gate_poll)
1254            .await?;
1255        let handle = self.recording.lock().await.take();
1256        let Some(handle) = handle else {
1257            // No live recording — but possibly one the RUN stopped. Run end
1258            // finalizes the frames and hands the encode to whoever asks next,
1259            // so the model still gets its video; it simply ends where the run
1260            // did. Capture, the frame consumer and the drawer's quality were
1261            // all already settled there, so all that is left is the encode.
1262            let mut finished = self.finished_recording.lock().await;
1263            if finished.is_none() {
1264                return Err(
1265                    "no recording in progress — call browser_record_start first".to_string()
1266                );
1267            }
1268            let (out, rel) = self.recording_output(params)?;
1269            let recording = finished.take().expect("checked immediately above");
1270            drop(finished);
1271            return self.encode_recording(&out, &rel, &recording);
1272        };
1273
1274        // The handle is OUT of `self.recording` now, so its Model consumer on
1275        // `self.frames` is this function's to release — on every exit, not
1276        // just the happy one. Three `?`s used to sit between here and the
1277        // release, and the third is the ordinary case: `RecordingHandle::stop`
1278        // errors with "screencast captured no frames" whenever the page never
1279        // changed, which `browser_record_start`'s own description warns the
1280        // model about. On a still page with no drawer subscriber no later
1281        // frame ever arrives to prune the dead consumer, so `consumers` stays
1282        // non-empty, the supervisor stays alive, and the CDP screencast stays
1283        // armed for the life of the browser — verbatim the regression
1284        // `record_stop_releases_its_frame_consumer` exists to prevent, reached
1285        // through the error path it did not cover.
1286        let recording = self.finish_recording(handle, params).await;
1287        // `release_frames` is a no-op for any consumer still alive (e.g. a
1288        // drawer subscriber watching the same browser).
1289        self.release_frames().await;
1290        // And the drawer gets its picture quality back, on every exit — the
1291        // recording's setting was never the human's choice.
1292        if let Some(restore) = self.recording_restore_quality.lock().await.take() {
1293            self.frames.set_quality(restore).await;
1294        }
1295        let (out, rel, recording) = recording?;
1296        self.encode_recording(&out, &rel, &recording)
1297    }
1298
1299    /// Encode a finished recording's frames to `out`. Shared by the ordinary
1300    /// stop and by the encode owed for a recording the run already stopped.
1301    fn encode_recording(
1302        &self,
1303        out: &Path,
1304        rel: &str,
1305        recording: &car_browser::recorder::Recording,
1306    ) -> Result<Value, String> {
1307        // Encode from the concat manifest so each frame is held for its REAL
1308        // duration — the screencast is change-driven, so a fixed rate would
1309        // compress every pause. `-vsync cfr` resamples that variable timeline
1310        // to a constant output rate players handle predictably.
1311        let status = std::process::Command::new("ffmpeg")
1312            .args([
1313                "-nostdin", "-v", "error", "-f", "concat", "-safe", "0", "-i",
1314            ])
1315            .arg(&recording.manifest)
1316            .args([
1317                "-vsync",
1318                "cfr",
1319                "-r",
1320                &OUTPUT_FPS.to_string(),
1321                "-pix_fmt",
1322                "yuv420p",
1323                "-c:v",
1324                "libx264",
1325                "-movflags",
1326                "+faststart",
1327            ])
1328            .arg(out)
1329            .arg("-y")
1330            .status()
1331            .map_err(|e| format!("run ffmpeg (is it installed?): {e}"))?;
1332        if !status.success() {
1333            return Err(format!("ffmpeg failed encoding the recording ({status})"));
1334        }
1335        // Frames are a build artifact; the MP4 is the deliverable.
1336        let _ = std::fs::remove_dir_all(&recording.dir);
1337
1338        let bytes = std::fs::metadata(out).map(|m| m.len()).unwrap_or(0);
1339        Ok(json!({
1340            "video_path": rel,
1341            "media_type": "video/mp4",
1342            "bytes": bytes,
1343            "frames": recording.frame_count,
1344            "duration_seconds": recording.duration_seconds,
1345            "note": format!(
1346                "Wrote a {:.1}s screen recording ({} frames) to {rel}.",
1347                recording.duration_seconds, recording.frame_count
1348            ),
1349        }))
1350    }
1351
1352    /// The agent-pause-at-tool-boundary check every `browse_*` call makes
1353    /// before it runs: block (poll) while the user holds control, erroring
1354    /// only if hand-back never comes within the bounded wait — the same
1355    /// block-then-timeout-then-error shape `run_await_signin` above already
1356    /// uses, not an immediate error the instant the user takes control.
1357    /// When no drawer/user-control is in play (the common case today, since
1358    /// nothing yet calls `take_control()`) `may_agent_act()` is already
1359    /// true, so this returns on the very first check — zero added latency,
1360    /// identical behavior to before this task.
1361    async fn wait_for_agent_turn(&self) -> Result<(), String> {
1362        self.wait_for_agent_turn_with(
1363            Duration::from_secs(AGENT_PAUSE_TIMEOUT_SECS),
1364            Duration::from_secs(1),
1365        )
1366        .await
1367    }
1368
1369    /// `timeout`/`poll` are parameterized so tests can exercise the block
1370    /// and unblock paths without actually waiting `AGENT_PAUSE_TIMEOUT_SECS`.
1371    ///
1372    /// Re-attempts the attach on every pass, rather than relying on the one
1373    /// `session()` made on the way in. A user holding control at the moment
1374    /// this turn started blocks that attach (never take the wheel from a
1375    /// person), and their hand-back lands on `NoAgent` when the previous run
1376    /// had already ended — so the moment the wheel frees up has to be the
1377    /// moment this run gets to attach, or the call waits out the full
1378    /// timeout against a browser nobody is driving.
1379    async fn wait_for_agent_turn_with(
1380        &self,
1381        timeout: Duration,
1382        poll: Duration,
1383    ) -> Result<(), String> {
1384        self.maybe_attach_agent().await;
1385        if self.presentation.lock().await.control().may_agent_act() {
1386            return Ok(());
1387        }
1388        let started = Instant::now();
1389        while started.elapsed() < timeout {
1390            tokio::time::sleep(poll).await;
1391            self.maybe_attach_agent().await;
1392            if self.presentation.lock().await.control().may_agent_act() {
1393                return Ok(());
1394            }
1395        }
1396        Err(
1397            "the user is currently driving the browser — ask them to hand back control \
1398             (or wait for them to finish), then retry"
1399                .to_string(),
1400        )
1401    }
1402
1403    /// Sibling of `wait_for_agent_turn` for the three tools that need to
1404    /// gate BEFORE they've necessarily established a session yet —
1405    /// `browser_await_signin`, `browser_record_start`, `browser_record_stop`
1406    /// — same `full_access`, agent-initiated, mutating tier as `browse_*`,
1407    /// so the same tool-boundary pause and approval-race re-check apply
1408    /// (the brief's pause/approval-race requirements carry no `browse_`-
1409    /// prefix qualifier). Uses `user_holds_control` rather than
1410    /// `may_agent_act`: see that method's doc comment for why — in short,
1411    /// it must NOT block a call that hasn't attached yet, only one where
1412    /// the user has explicitly taken control.
1413    ///
1414    /// `timeout`/`poll` are parameterized (each of the three call sites
1415    /// passes `AGENT_PAUSE_TIMEOUT_SECS`/1s in production) so tests can
1416    /// exercise the block and unblock paths without actually waiting that
1417    /// long — mirrors `wait_for_agent_turn`/`wait_for_agent_turn_with`.
1418    async fn wait_while_user_holds_control_with(
1419        &self,
1420        timeout: Duration,
1421        poll: Duration,
1422    ) -> Result<(), String> {
1423        if !self
1424            .presentation
1425            .lock()
1426            .await
1427            .control()
1428            .user_holds_control()
1429        {
1430            return Ok(());
1431        }
1432        let started = Instant::now();
1433        while started.elapsed() < timeout {
1434            tokio::time::sleep(poll).await;
1435            if !self
1436                .presentation
1437                .lock()
1438                .await
1439                .control()
1440                .user_holds_control()
1441            {
1442                return Ok(());
1443            }
1444        }
1445        Err(
1446            "the user is currently driving the browser — ask them to hand back control \
1447             (or wait for them to finish), then retry"
1448                .to_string(),
1449        )
1450    }
1451
1452    /// The blackout gate on the MODEL-facing side (controller ruling R3):
1453    /// while the user holds control or a sign-in is pending, no page
1454    /// observation may reach the model. `may_agent_act` already covers the
1455    /// user-control half (owner == User); this is what covers the other
1456    /// trigger — a pending sign-in, where the agent still nominally owns
1457    /// the browser but the human is typing a password into it.
1458    ///
1459    /// Blocks rather than erroring, for the same reason
1460    /// `wait_while_user_holds_control_with` does: a sign-in is a human-paced
1461    /// interruption, and the honest answer to "may I look at the page" is
1462    /// "not yet", not "the tool is broken".
1463    async fn wait_while_blackout_with(
1464        &self,
1465        timeout: Duration,
1466        poll: Duration,
1467    ) -> Result<(), String> {
1468        if !self.blackout_active().await {
1469            return Ok(());
1470        }
1471        let started = Instant::now();
1472        while started.elapsed() < timeout {
1473            tokio::time::sleep(poll).await;
1474            if !self.blackout_active().await {
1475                return Ok(());
1476            }
1477        }
1478        Err(BLACKOUT_HOLDS_THE_PAGE.to_string())
1479    }
1480
1481    /// Resolves the moment a blackout becomes active, and never otherwise.
1482    ///
1483    /// The mid-flight half of the R3 gate. `wait_while_blackout_with` only
1484    /// answers "may this call START", and `browse_wait` is not a fast tool —
1485    /// its `timeout_ms` is model-supplied and unclamped, and car-browser
1486    /// polls the full page accessibility tree every 100ms until the deadline.
1487    /// A user pressing Take control ten seconds into a five-minute
1488    /// `browse_wait` would otherwise have the whole page they just took over
1489    /// read back to the model when the call returned.
1490    ///
1491    /// Watches the same `changes` signal `apply_control` bumps on every
1492    /// transition, so there is no poll interval to lose the edge in.
1493    async fn blackout_starts(&self, changes: &mut watch::Receiver<u64>) {
1494        loop {
1495            if self.blackout_active().await {
1496                return;
1497            }
1498            if changes.changed().await.is_err() {
1499                // Nothing can signal a transition any more, so a blackout can
1500                // never start; park rather than spin, and let the other
1501                // `select!` arm decide the call.
1502                std::future::pending::<()>().await
1503            }
1504        }
1505    }
1506
1507    async fn blackout_active(&self) -> bool {
1508        self.presentation
1509            .lock()
1510            .await
1511            .control()
1512            .is_blackout_active()
1513    }
1514
1515    /// The one mutator. Applies the event, republishes the blackout flag the
1516    /// frame fan-out gates on, wakes every `browser.view.*` subscriber, and —
1517    /// on a pending-sign-in TRANSITION only — tells the operator through the
1518    /// daemon's always-on `host.event` channel.
1519    ///
1520    /// This is the local path's choke point on purpose. It is the single
1521    /// mutator of the control reducer, so every route that can start or end a
1522    /// sign-in wait passes through here exactly once: the tool's own
1523    /// `SignInRequested`/`SignInResolved`, hand-back, run end, the disconnect
1524    /// grace expiring, and the host-gone bail-out. Emitting from the sign-in
1525    /// tool instead would cover the request and miss four of the five ways it
1526    /// ends — and a notification that never clears is worse than none.
1527    async fn apply_control(&self, event: ControlEvent) -> Vec<ControlEffect> {
1528        let effects = {
1529            let mut state = self.presentation.lock().await;
1530            let effects = state.apply_control(event);
1531            // Published UNDER the lock that produced it. Computing the flag
1532            // here and storing it after the lock released let two concurrent
1533            // transitions publish out of order: the reducer runs serialized,
1534            // so the STATE is right, but whichever store landed last decided
1535            // the gate — and a `RunEnded` computed before a `SignInRequested`
1536            // could store `false` after it, leaving the recording gate open
1537            // for the whole sign-in with the reducer insisting the blackout
1538            // was up. `set_blackout` is a plain `AtomicBool::store` with no
1539            // await, which is exactly why `FrameFanout::blackout` is an
1540            // atomic and not a callback (see its doc): it is safe to set
1541            // while this lock is held.
1542            self.frames
1543                .set_blackout(state.control().is_blackout_active());
1544            effects
1545        };
1546        // Outside the presentation lock: `record_event` takes the host's own
1547        // locks and awaits every subscriber's channel, and holding this one
1548        // across that would freeze every drawer input and every snapshot for
1549        // the duration.
1550        self.notify_signin_transition().await;
1551        self.signal_change();
1552        effects
1553    }
1554
1555    /// Tell the operator if the pending sign-in has changed since the last
1556    /// time we told them — see [`crate::browser_attention`] for the
1557    /// transition rule, and `SignInAttentionBinding::announced` for why this
1558    /// re-reads the live state rather than taking a before/after pair from
1559    /// the caller.
1560    async fn notify_signin_transition(&self) {
1561        let Some(binding) = self.signin_attention.get() else {
1562            return;
1563        };
1564        // Taken BEFORE the presentation lock, and never the other way round:
1565        // `apply_control` releases that lock before calling this, so the two
1566        // are only ever acquired in this order.
1567        let mut announced = binding.announced.lock().await;
1568        let current = self
1569            .presentation
1570            .lock()
1571            .await
1572            .control()
1573            .pending_signin()
1574            .map(|p| p.message.clone());
1575        if *announced == current {
1576            return;
1577        }
1578        let before = std::mem::replace(&mut *announced, current.clone());
1579        // Queue behind any announcement still in flight BEFORE releasing the
1580        // decision lock — that is what preserves the order two concurrent
1581        // transitions were decided in — and then RELEASE it, so the broadcast
1582        // below does not hold every drawer input behind a backpressured host
1583        // socket. See `SignInAttentionBinding::announce_order`.
1584        let _order = binding.announce_order.lock().await;
1585        drop(announced);
1586        notify_signin_transition(
1587            &binding.attention,
1588            binding.conversation_id.as_deref(),
1589            before.as_deref(),
1590            current.as_deref(),
1591        )
1592        .await;
1593    }
1594
1595    /// Wake anything awaiting [`Self::subscribe_changes`].
1596    fn signal_change(&self) {
1597        self.changes.send_modify(|n| *n = n.wrapping_add(1));
1598    }
1599
1600    /// A signal that fires whenever the presentation may have changed — a
1601    /// control-state transition, or a browser launching. The `browser.view.*`
1602    /// fan-out awaits this and only then calls [`Self::presentation`], whose
1603    /// live tab refresh costs a CDP round trip per open tab.
1604    pub fn subscribe_changes(&self) -> watch::Receiver<u64> {
1605        self.changes.subscribe()
1606    }
1607
1608    /// Live screencast frames for a human watching the drawer. Never gated
1609    /// by the blackout — that is the whole point of it (the person driving
1610    /// keeps seeing the page; the model does not). Capture starts on the
1611    /// first subscription and stops when the last one is dropped.
1612    pub async fn subscribe_frames(&self) -> (FrameReceiver, Instant) {
1613        self.frames.subscribe(FrameAudience::Viewer).await
1614    }
1615
1616    /// Drop consumers whose receiver has gone away, stopping CDP capture if
1617    /// that was the last one. Called when the last drawer subscriber leaves,
1618    /// so a browser nobody is watching stops paying for a screencast without
1619    /// waiting for a frame to arrive and notice.
1620    pub async fn release_frames(&self) {
1621        self.frames.prune().await;
1622    }
1623
1624    /// The cheap read of who is driving — no CDP, unlike
1625    /// [`Self::presentation`], which refreshes the tab list. This is what
1626    /// the input path consults on every call.
1627    pub async fn control_status(&self) -> ControlStatus {
1628        let state = self.presentation.lock().await;
1629        ControlStatus {
1630            owner: state.control().owner(),
1631            signin_pending: state.control().pending_signin().is_some(),
1632            blackout_active: state.control().is_blackout_active(),
1633        }
1634    }
1635
1636    /// Tab open/close/switch/nav-state notifications from the live browser,
1637    /// or `None` when none has launched yet.
1638    pub async fn subscribe_tabs(&self) -> Option<watch::Receiver<car_browser::TabsSnapshot>> {
1639        self.inner
1640            .lock()
1641            .await
1642            .as_ref()
1643            .map(|s| s.backend.subscribe_tabs())
1644    }
1645
1646    /// The presentation snapshot — tabs (live-refreshed from the browser
1647    /// when one exists), control owner, current action, pending sign-in,
1648    /// blackout, and a monotonic revision. What the `browser.view.*` RPC
1649    /// surface serializes.
1650    pub async fn presentation(&self) -> Presentation {
1651        self.build_presentation_snapshot().await
1652    }
1653
1654    /// Pending prompt for the host reconnect snapshot, without the CDP tab
1655    /// refresh a full presentation requires.
1656    pub(crate) async fn pending_signin_message(&self) -> Option<String> {
1657        self.presentation
1658            .lock()
1659            .await
1660            .control()
1661            .pending_signin()
1662            .map(|pending| pending.message.clone())
1663    }
1664
1665    /// Resolve any operator attention before an unreachable view is dropped.
1666    ///
1667    /// `GracePeriodExpired` is the reducer's existing honest "the person no
1668    /// longer owns this browser" ending. Running it through the one mutator
1669    /// keeps the blackout, presentation revision, and `host.event` twin in
1670    /// lockstep instead of synthesizing a notification beside the state.
1671    pub(crate) async fn resolve_signin_attention_on_teardown(&self) {
1672        if self
1673            .presentation
1674            .lock()
1675            .await
1676            .control()
1677            .pending_signin()
1678            .is_some()
1679        {
1680            self.apply_control(ControlEvent::GracePeriodExpired).await;
1681        }
1682    }
1683
1684    /// User presses "Take control" in the drawer.
1685    pub async fn take_control(&self) -> (Presentation, Vec<ControlEffect>) {
1686        self.apply_and_snapshot(ControlEvent::TakeControl).await
1687    }
1688
1689    /// User presses "Hand back to CAR" — also resolves a pending sign-in,
1690    /// if one is up (see `ControlState::apply`).
1691    pub async fn hand_back(&self) -> (Presentation, Vec<ControlEffect>) {
1692        self.apply_and_snapshot(ControlEvent::HandBack).await
1693    }
1694
1695    /// The run this browser was attached to has ended: no ceremony, the
1696    /// user's browser again. Nothing in this crate calls this yet — the
1697    /// daemon owns "when does a run end" (see the R6 note in the task
1698    /// brief), this is the seam it hooks into.
1699    pub async fn note_run_ended(&self) -> (Presentation, Vec<ControlEffect>) {
1700        // BEFORE the transition, not after. The un-engaged `RunEnded` branch
1701        // clears a pending sign-in and lifts the blackout, and the blackout is
1702        // the only thing gating a `FrameAudience::Model` consumer — so applying
1703        // it first would open a window, however short, in which the recording
1704        // this is about to stop is capturing again.
1705        self.stop_recording_at_run_end().await;
1706        self.apply_and_snapshot(ControlEvent::RunEnded).await
1707    }
1708
1709    /// A person drove this browser. See `ControlState::user_engaged` — this is
1710    /// what a sign-in timeout consults before deciding it may end their
1711    /// window.
1712    pub async fn note_user_input(&self) {
1713        self.apply_control(ControlEvent::UserInput).await;
1714    }
1715
1716    /// The connection holding user control dropped. Nothing in this crate
1717    /// detects that condition yet (it lives at the transport layer); this
1718    /// is the seam a later task hooks a real disconnect signal into.
1719    pub async fn control_holder_disconnected(&self) -> (Presentation, Vec<ControlEffect>) {
1720        self.apply_and_snapshot(ControlEvent::ControlHolderDisconnected)
1721            .await
1722    }
1723
1724    /// The grace period started by `control_holder_disconnected` elapsed.
1725    /// Nothing in this crate owns the actual clock yet (see
1726    /// `ControlEffect::StartGracePeriod`'s doc comment); this is the seam.
1727    pub async fn grace_period_expired(&self) -> (Presentation, Vec<ControlEffect>) {
1728        self.apply_and_snapshot(ControlEvent::GracePeriodExpired)
1729            .await
1730    }
1731
1732    async fn apply_and_snapshot(&self, event: ControlEvent) -> (Presentation, Vec<ControlEffect>) {
1733        let effects = self.apply_control(event).await;
1734        (self.build_presentation_snapshot().await, effects)
1735    }
1736
1737    // ---- User-driven input, from the drawer ------------------------------
1738    //
1739    // These are the `browser.view.*` input RPCs' only route into the
1740    // browser. They are deliberately narrow — navigate/click/type/keypress/
1741    // scroll and the three tab operations, exactly what a person does with a
1742    // mouse and keyboard — so this surface adds NO perception of any kind:
1743    // no DOM reads, no screenshots, no accessibility tree. A human watching
1744    // the drawer sees the page; nothing here returns page content to a
1745    // caller.
1746    //
1747    // None of them consults the agent-turn gate: the gate exists to stop the
1748    // AGENT acting while a human drives, and these calls are the human. Who
1749    // may call them is decided one level up, by the `browser.view.*` control
1750    // owner check — a caller that does not hold control never reaches here.
1751
1752    /// Navigate the active tab. This is the one input that may LAUNCH the
1753    /// browser: the standing session starts empty and comes to life on the
1754    /// user's first navigation (controller ruling R6), without attaching an
1755    /// agent.
1756    pub async fn user_navigate(&self, url: &str) -> Result<(), String> {
1757        let url = url.trim();
1758        if url.is_empty() {
1759            return Err("navigate requires a non-empty `url`".to_string());
1760        }
1761        let backend = self.ensure_session(false).await?;
1762        backend
1763            .navigate(url)
1764            .await
1765            .map_err(|e| format!("navigate to {url}: {e}"))?;
1766        self.signal_change();
1767        Ok(())
1768    }
1769
1770    pub async fn user_click(&self, x: f64, y: f64) -> Result<(), String> {
1771        let backend = self.live_backend().await?;
1772        backend
1773            .inject_click(x, y)
1774            .await
1775            .map_err(|e| format!("click: {e}"))?;
1776        self.signal_change();
1777        Ok(())
1778    }
1779
1780    pub async fn user_type(&self, text: &str) -> Result<(), String> {
1781        let backend = self.live_backend().await?;
1782        backend
1783            .inject_text(text)
1784            .await
1785            .map_err(|e| format!("type: {e}"))
1786    }
1787
1788    /// Paste `text` at the caret, replacing the selection.
1789    ///
1790    /// The host reads its OWN pasteboard and sends the string, because a
1791    /// synthesised ⌘V cannot work: the clipboard belongs to the browser, not
1792    /// the page, and CDP's injected key events have no access to it.
1793    pub async fn user_paste(&self, text: &str) -> Result<(), String> {
1794        let backend = self.live_backend().await?;
1795        backend
1796            .insert_text(text)
1797            .await
1798            .map_err(|e| format!("paste: {e}"))
1799    }
1800
1801    pub async fn user_keypress(&self, key: &str, modifiers: &[Modifier]) -> Result<(), String> {
1802        let backend = self.live_backend().await?;
1803        backend
1804            .inject_keypress(key, modifiers)
1805            .await
1806            .map_err(|e| format!("keypress: {e}"))?;
1807        self.signal_change();
1808        Ok(())
1809    }
1810
1811    pub async fn user_scroll(&self, delta_y: i32) -> Result<(), String> {
1812        let backend = self.live_backend().await?;
1813        backend
1814            .inject_scroll(delta_y)
1815            .await
1816            .map_err(|e| format!("scroll: {e}"))
1817    }
1818
1819    /// The nav bar's Back button. Drives Chromium's real session history via
1820    /// CDP — a synthesised ⌘← keystroke does not, because the shortcut is
1821    /// browser chrome the input domain never reaches.
1822    pub async fn user_go_back(&self) -> Result<(), String> {
1823        self.step_history(HistoryStep::Back).await
1824    }
1825
1826    /// The nav bar's Forward button.
1827    pub async fn user_go_forward(&self) -> Result<(), String> {
1828        self.step_history(HistoryStep::Forward).await
1829    }
1830
1831    async fn step_history(&self, step: HistoryStep) -> Result<(), String> {
1832        let backend = self.live_backend().await?;
1833        backend
1834            .step_history(step)
1835            .await
1836            // The backend's message already names the direction that had
1837            // nowhere to go ("no page to go back to"), so it needs no prefix
1838            // of its own — unlike click/type/scroll, where the operation name
1839            // is the only thing identifying which call failed.
1840            .map_err(|e| e.to_string())?;
1841        self.signal_change();
1842        Ok(())
1843    }
1844
1845    /// The nav bar's Reload button. Errors on the empty state through
1846    /// [`Self::live_backend`], like every other input that needs a page to
1847    /// act on.
1848    pub async fn user_reload(&self) -> Result<(), String> {
1849        let backend = self.live_backend().await?;
1850        backend.reload().await.map_err(|e| format!("reload: {e}"))?;
1851        self.signal_change();
1852        Ok(())
1853    }
1854
1855    pub async fn user_tab_open(&self) -> Result<TabId, String> {
1856        let backend = self.ensure_session(false).await?;
1857        let id = backend
1858            .open_tab()
1859            .await
1860            .map_err(|e| format!("open tab: {e}"))?;
1861        self.signal_change();
1862        Ok(id)
1863    }
1864
1865    pub async fn user_tab_close(&self, id: TabId) -> Result<(), String> {
1866        let backend = self.live_backend().await?;
1867        backend
1868            .close_tab(id)
1869            .await
1870            .map_err(|e| format!("close tab: {e}"))?;
1871        self.signal_change();
1872        Ok(())
1873    }
1874
1875    pub async fn user_tab_switch(&self, id: TabId) -> Result<(), String> {
1876        let backend = self.live_backend().await?;
1877        backend
1878            .switch_tab(id)
1879            .await
1880            .map_err(|e| format!("switch tab: {e}"))?;
1881        self.signal_change();
1882        Ok(())
1883    }
1884
1885    /// Resolve a wire tab id (`TabId`'s own `Display` form, e.g. `tab-3`)
1886    /// against the open tabs. `TabId` is deliberately opaque outside
1887    /// car-browser — matching on the rendered id is what keeps it that way,
1888    /// and it makes a stale id from a closed tab a clean error rather than
1889    /// an operation on some other tab.
1890    pub async fn resolve_tab(&self, wire_id: &str) -> Result<TabId, String> {
1891        let tabs: Vec<TabInfo> = self
1892            .live_backend()
1893            .await?
1894            .list_tabs()
1895            .await
1896            .unwrap_or_default();
1897        tabs.into_iter()
1898            .find(|t| t.id.to_string() == wire_id)
1899            .map(|t| t.id)
1900            .ok_or_else(|| format!("no open tab '{wire_id}'"))
1901    }
1902
1903    /// The launched browser, or a clean error when there isn't one. Input
1904    /// that cannot sensibly launch a browser (a click needs a page to click
1905    /// on) fails here rather than starting Chromium for it.
1906    async fn live_backend(&self) -> Result<Arc<ChromiumBackend>, String> {
1907        self.inner
1908            .lock()
1909            .await
1910            .as_ref()
1911            .map(|s| Arc::clone(&s.backend))
1912            .ok_or_else(|| {
1913                "no browser is running for this view — navigate to a page first".to_string()
1914            })
1915    }
1916
1917    /// Drive the control reducer directly. Test-only: production code
1918    /// reaches these transitions through the tools and the `browser.view.*`
1919    /// surface, never by hand.
1920    #[cfg(test)]
1921    pub async fn apply_control_for_test(&self, event: ControlEvent) -> Vec<ControlEffect> {
1922        self.apply_control(event).await
1923    }
1924
1925    /// The agent's first (or Nth) tool call, minus the Chromium launch — the
1926    /// crate has no live-browser tests (see the browse-tool test suite's own
1927    /// constraint). Calls the SAME `maybe_attach_agent` production takes, so
1928    /// the once-per-run decision is exercised rather than re-stated.
1929    #[cfg(test)]
1930    pub async fn attach_agent_for_test(&self) {
1931        self.maybe_attach_agent().await;
1932    }
1933
1934    /// Read back whatever [`HostConnectivity`] probe is installed. Test-only
1935    /// window into Task 7's signal, so a cross-module test (the supervised
1936    /// relay's own `assistant::browser_producer` suite) can assert that a
1937    /// registration acknowledgment actually reached this `BrowserTools`
1938    /// without a live daemon round trip.
1939    #[cfg(test)]
1940    pub async fn host_connected_for_test(&self) -> bool {
1941        self.any_host_connected().await
1942    }
1943
1944    /// Live-refreshes the tab list from the browser (if one has launched)
1945    /// before building the snapshot, so a caller never sees a stale tab
1946    /// strip. Never holds `inner`'s lock and `presentation`'s lock at the
1947    /// same time — it reads the backend handle, drops that lock, awaits
1948    /// `list_tabs()` unlocked, then locks `presentation` only at the end.
1949    async fn build_presentation_snapshot(&self) -> Presentation {
1950        let backend = {
1951            self.inner
1952                .lock()
1953                .await
1954                .as_ref()
1955                .map(|s| Arc::clone(&s.backend))
1956        };
1957        let tabs = match backend {
1958            Some(backend) => backend.list_tabs().await.unwrap_or_default(),
1959            None => Vec::new(),
1960        };
1961        let mut presentation = self.presentation.lock().await;
1962        presentation.set_tabs(tabs);
1963        presentation.snapshot()
1964    }
1965}
1966
1967/// A plain-words label for the drawer's action strip, derived from the tool
1968/// name and its most descriptive parameter — not a per-tool lookup table, so
1969/// a new `browse_*` tool gets a sensible label automatically. Shown only to
1970/// the user who is already watching the same headed browser live, so this
1971/// intentionally does not avoid echoing a `text` param even though a
1972/// `browse_type` call could in principle carry one — nothing here reaches
1973/// the model (that boundary is the blackout, not this label).
1974fn describe_browse_action(tool: &str, params: &Value) -> String {
1975    let verb = tool
1976        .strip_prefix("browse_")
1977        .unwrap_or(tool)
1978        .replace('_', " ");
1979    let mut label = String::new();
1980    let mut chars = verb.chars();
1981    if let Some(first) = chars.next() {
1982        label.extend(first.to_uppercase());
1983    }
1984    label.push_str(chars.as_str());
1985    for key in ["url", "text", "key", "condition", "element_id"] {
1986        if let Some(v) = params.get(key).and_then(Value::as_str) {
1987            let v = v.trim();
1988            if !v.is_empty() {
1989                label.push(' ');
1990                label.push_str(v);
1991                break;
1992            }
1993        }
1994    }
1995    label
1996}
1997
1998pub(super) fn browser_tool_defs() -> Vec<Value> {
1999    let mut defs: Vec<Value> = BrowserToolExecutor::tool_schemas()
2000        .into_iter()
2001        .map(|s| {
2002            json!({
2003                "name": s.name,
2004                "description": s.description,
2005                "parameters": s.parameters,
2006                "mutating": !s.idempotent,
2007                "tier": BROWSER_TOOL_TIER,
2008            })
2009        })
2010        .collect();
2011
2012    defs.push(json!({
2013        "name": "browser_await_answer",
2014        "description": "After you submit a question or trigger an action in a web app, call this to \
2015            WAIT until the response has finished rendering, before you screenshot or stop a \
2016            recording. It polls the page and returns once the content stops changing. Use it every \
2017            time between submitting and observing/recording an answer — browse_observe does NOT \
2018            wait, so without this you capture the page mid-load (a blank or still-thinking state) \
2019            instead of the actual answer.",
2020        "parameters": {
2021            "type": "object",
2022            "properties": {
2023                "timeout_seconds": {
2024                    "type": "integer",
2025                    "description": "Max seconds to wait for the page to settle (default 45)."
2026                }
2027            },
2028            "required": []
2029        },
2030        "mutating": false,
2031        "tier": BROWSER_TOOL_TIER
2032    }));
2033    defs.push(json!({
2034        "name": "browser_await_signin",
2035        "description": "Ask the USER to sign in, on whatever surface this browser has, and wait \
2036            until they have. Use this the moment a site needs authentication — you cannot and must \
2037            not type someone's credentials, but the person can complete any flow (SSO, MFA, a \
2038            device prompt) themselves: in the CAR app's browser drawer when the app is running, or \
2039            in the browser window if one is open. TELL THE USER what to sign into before calling \
2040            this, and say to look in the CAR app if they do not see a browser window; it blocks \
2041            while they do it. The session persists in the browser profile, so this is a one-time \
2042            cost per site rather than per run.",
2043        "parameters": {
2044            "type": "object",
2045            "properties": {
2046                "url": {
2047                    "type": "string",
2048                    "description": "Optional page to navigate to first, e.g. the app's home or login URL."
2049                },
2050                "success_url_contains": {
2051                    "type": "string",
2052                    "description": "Optional substring identifying a signed-in URL. Omit to accept any URL that no longer looks like a login/SSO page."
2053                },
2054                "timeout_seconds": {
2055                    "type": "integer",
2056                    "description": "How long to wait for the user (default 300, max 1800)."
2057                }
2058            },
2059            "required": []
2060        },
2061        "mutating": true,
2062        "tier": BROWSER_TOOL_TIER
2063    }));
2064    defs.push(json!({
2065        "name": "browser_record_start",
2066        "description": "Start RECORDING the browser session to video. Pair it with the browse_* \
2067            tools: start recording, drive the app (navigate, type a real question, wait for the \
2068            answer), then call browser_record_stop to get an MP4. Use it whenever the ASK is a \
2069            product demo, an onboarding or training clip, a bug repro, or release notes — anything \
2070            where showing the app BEING USED beats a screenshot of its final state. Frames are \
2071            captured only when the page actually CHANGES, so make sure something happens on \
2072            screen; a static page records nothing.",
2073        "parameters": {
2074            "type": "object",
2075            "properties": {
2076                "quality": {"type": "integer", "description": "JPEG quality 1-100 (default 80)."}
2077            },
2078            "required": []
2079        },
2080        "mutating": true,
2081        "tier": BROWSER_TOOL_TIER
2082    }));
2083    defs.push(json!({
2084        "name": "browser_record_stop",
2085        "description": "Stop the recording started by browser_record_start and write an MP4 under \
2086            the working directory. Returns the path plus the real duration. Requires ffmpeg.",
2087        "parameters": {
2088            "type": "object",
2089            "properties": {
2090                "output_path": {
2091                    "type": "string",
2092                    "description": "Where to write the MP4, relative to the working directory (default assets/recording.mp4)."
2093                }
2094            },
2095            "required": []
2096        },
2097        "mutating": true,
2098        "tier": BROWSER_TOOL_TIER
2099    }));
2100    defs
2101}
2102
2103#[async_trait]
2104impl ToolExecutor for BrowserTools {
2105    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
2106        match tool {
2107            "browser_await_signin" => self.run_await_signin(params).await,
2108            "browser_await_answer" => self.run_await_answer(params).await,
2109            "browser_record_start" => self.run_record_start(params).await,
2110            "browser_record_stop" => self.run_record_stop(params).await,
2111            t if t.starts_with("browse_") => {
2112                // Ensure the browser exists (this is also where the agent
2113                // formally attaches for this run — see `session()` above).
2114                self.session().await?;
2115                // Pause at the tool boundary while the user holds control.
2116                // Re-checked HERE, right before delegating to execution —
2117                // not cached from whenever this call was proposed/approved
2118                // — which is what closes the approval race (see
2119                // `ControlState::may_agent_act`'s doc comment).
2120                self.wait_for_agent_turn().await?;
2121                // The other blackout trigger: a pending sign-in, where the
2122                // agent still owns the browser but a human is typing a
2123                // password into it. Nothing the agent does here may observe
2124                // the page until that resolves (R3).
2125                self.wait_while_blackout_with(
2126                    Duration::from_secs(AGENT_PAUSE_TIMEOUT_SECS),
2127                    Duration::from_secs(1),
2128                )
2129                .await?;
2130                self.apply_control(ControlEvent::AgentActionStarted(describe_browse_action(
2131                    tool, params,
2132                )))
2133                .await;
2134                // Delegate to car-browser's own executor so the automation
2135                // semantics live in one place.
2136                //
2137                // The handle is CLONED OUT and the lock DROPPED before the
2138                // await. Holding `inner` across `execute()` put every
2139                // concurrent reader of it behind the whole agent action:
2140                // `live_backend()` (every drawer click/type/paste/scroll/
2141                // keypress/tab op) and `build_presentation_snapshot()`
2142                // (`presentation`, `take_control`, `hand_back`,
2143                // `note_run_ended`). A model-supplied, unclamped
2144                // `browse_wait { timeout_ms }` therefore froze Take control
2145                // and every keystroke for as long as the model asked for.
2146                let exec = {
2147                    let guard = self.inner.lock().await;
2148                    Arc::clone(&guard.as_ref().ok_or("browser session unavailable")?.exec)
2149                };
2150                // Raced against a blackout STARTING, not just checked before
2151                // the call. `biased` so a blackout that lands in the same
2152                // scheduling turn as the tool's completion wins: the point of
2153                // R3 is that nothing the agent does may observe a page the
2154                // person has taken over, and a result computed from that page
2155                // is exactly such an observation. Dropping the future cancels
2156                // the tool, and its result is discarded either way.
2157                let mut changes = self.subscribe_changes();
2158                let outcome = tokio::select! {
2159                    biased;
2160                    () = self.blackout_starts(&mut changes) => None,
2161                    out = exec.execute(tool, params) => Some(out),
2162                };
2163                // Paired with the `AgentActionStarted` above, on every exit.
2164                // Nothing used to clear it, so the drawer's action strip
2165                // reported the last browse action as still running for the
2166                // rest of the turn.
2167                self.apply_control(ControlEvent::AgentActionFinished).await;
2168                match outcome {
2169                    Some(out) => out,
2170                    None => Err(BLACKOUT_HOLDS_THE_PAGE.to_string()),
2171                }
2172            }
2173            _ => Err(format!("unknown tool: {tool}")),
2174        }
2175    }
2176}
2177
2178#[cfg(test)]
2179mod tests {
2180    use super::*;
2181    use std::sync::atomic::AtomicUsize;
2182
2183    use crate::browser_attention::{
2184        RecordingAttention, BROWSER_SIGNIN_NEEDED, BROWSER_SIGNIN_RESOLVED,
2185    };
2186
2187    /// A `BrowserTools` wired to a recorder under `conv-1`, exactly as
2188    /// `mcp_assistant::start` wires the real one under the run's key.
2189    fn tools_watching_signin() -> (BrowserTools, Arc<RecordingAttention>) {
2190        let tools = BrowserTools::new(std::env::temp_dir());
2191        let recorder = Arc::new(RecordingAttention::default());
2192        tools.set_signin_attention(recorder.clone(), Some("conv-1".to_string()));
2193        (tools, recorder)
2194    }
2195
2196    /// The headline: an agent blocked at `browser_await_signin` reaches the
2197    /// operator through the always-on host channel, drawer or no drawer.
2198    #[tokio::test]
2199    async fn a_pending_sign_in_notifies_once_and_clears_once() {
2200        let (tools, recorder) = tools_watching_signin();
2201        tools.attach_agent_for_test().await;
2202
2203        tools
2204            .apply_control_for_test(ControlEvent::SignInRequested(
2205                "Sign in at https://example.com/login".into(),
2206            ))
2207            .await;
2208        assert_eq!(
2209            recorder.calls(),
2210            vec![(
2211                BROWSER_SIGNIN_NEEDED.to_string(),
2212                Some("conv-1".to_string()),
2213                Some("Sign in at https://example.com/login".to_string()),
2214            )],
2215            "the conversation key and the tool's own prompt both travel"
2216        );
2217
2218        tools
2219            .apply_control_for_test(ControlEvent::SignInResolved { signed_in: true })
2220            .await;
2221        assert_eq!(
2222            recorder.kinds(),
2223            vec![BROWSER_SIGNIN_NEEDED, BROWSER_SIGNIN_RESOLVED],
2224            "and the wait ending clears it"
2225        );
2226    }
2227
2228    /// `presentation_pump` republishes and the resync sweep re-registers, so
2229    /// an emitter keyed on the apply rather than the TRANSITION would banner
2230    /// the operator every few seconds for one sign-in.
2231    #[tokio::test]
2232    async fn a_second_sign_in_request_while_one_is_pending_says_nothing() {
2233        let (tools, recorder) = tools_watching_signin();
2234        tools.attach_agent_for_test().await;
2235
2236        for _ in 0..3 {
2237            tools
2238                .apply_control_for_test(ControlEvent::SignInRequested(
2239                    "Sign in at https://example.com/login".into(),
2240                ))
2241                .await;
2242        }
2243        assert_eq!(
2244            recorder.kinds(),
2245            vec![BROWSER_SIGNIN_NEEDED],
2246            "one wait is one notification"
2247        );
2248
2249        // Nor does an unrelated transition (the user driving, the agent
2250        // labelling an action) produce one.
2251        tools.note_user_input().await;
2252        tools
2253            .apply_control_for_test(ControlEvent::AgentActionStarted("Filling the form".into()))
2254            .await;
2255        assert_eq!(recorder.kinds(), vec![BROWSER_SIGNIN_NEEDED]);
2256    }
2257
2258    /// Every route that really clears a pending sign-in has to reach
2259    /// `resolved`. A badge that never clears is worse than no badge, and four
2260    /// of these five endings never touch the sign-in tool's own detection
2261    /// loop at all.
2262    #[tokio::test]
2263    async fn every_route_out_of_a_sign_in_reaches_resolved() {
2264        // `signed_in: true` — the detection loop saw the URL leave the login
2265        // flow. `signed_in: false` is also what the host-gone bail-out and a
2266        // failed navigate apply.
2267        for resolution in [
2268            ControlEvent::SignInResolved { signed_in: true },
2269            ControlEvent::SignInResolved { signed_in: false },
2270        ] {
2271            let (tools, recorder) = tools_watching_signin();
2272            tools.attach_agent_for_test().await;
2273            tools
2274                .apply_control_for_test(ControlEvent::SignInRequested("Sign in".into()))
2275                .await;
2276            tools.apply_control_for_test(resolution).await;
2277            assert_eq!(
2278                recorder.kinds(),
2279                vec![BROWSER_SIGNIN_NEEDED, BROWSER_SIGNIN_RESOLVED]
2280            );
2281        }
2282
2283        // "Hand back to CAR" — the drawer's own affordance, which resolves a
2284        // pending sign-in as a side effect without the tool being told.
2285        let (tools, recorder) = tools_watching_signin();
2286        tools.attach_agent_for_test().await;
2287        tools
2288            .apply_control_for_test(ControlEvent::SignInRequested("Sign in".into()))
2289            .await;
2290        tools.hand_back().await;
2291        assert_eq!(
2292            recorder.kinds(),
2293            vec![BROWSER_SIGNIN_NEEDED, BROWSER_SIGNIN_RESOLVED],
2294            "hand-back is a real ending"
2295        );
2296
2297        // The run ending under a sign-in nobody engaged with.
2298        let (tools, recorder) = tools_watching_signin();
2299        tools.attach_agent_for_test().await;
2300        tools
2301            .apply_control_for_test(ControlEvent::SignInRequested("Sign in".into()))
2302            .await;
2303        tools.note_run_ended().await;
2304        assert_eq!(
2305            recorder.kinds(),
2306            vec![BROWSER_SIGNIN_NEEDED, BROWSER_SIGNIN_RESOLVED],
2307            "a run ending must not leave a badge behind"
2308        );
2309
2310        // The person engaged and then vanished: the run end DEFERS, and the
2311        // disconnect grace expiring is what finally settles it. Exactly one
2312        // resolved, at the right moment.
2313        let (tools, recorder) = tools_watching_signin();
2314        tools.attach_agent_for_test().await;
2315        tools
2316            .apply_control_for_test(ControlEvent::SignInRequested("Sign in".into()))
2317            .await;
2318        tools.note_user_input().await;
2319        tools.note_run_ended().await;
2320        assert_eq!(
2321            recorder.kinds(),
2322            vec![BROWSER_SIGNIN_NEEDED],
2323            "a person at the credential form still has a live window"
2324        );
2325        tools
2326            .apply_control_for_test(ControlEvent::GracePeriodExpired)
2327            .await;
2328        assert_eq!(
2329            recorder.kinds(),
2330            vec![BROWSER_SIGNIN_NEEDED, BROWSER_SIGNIN_RESOLVED]
2331        );
2332    }
2333
2334    /// The `None` case: a `BrowserTools` with nowhere to report — `car do`,
2335    /// an embedder with no daemon, every other test in this file — behaves
2336    /// exactly as it did before, rather than panicking or blocking.
2337    #[tokio::test]
2338    async fn a_browser_with_no_attention_installed_still_works() {
2339        let tools = BrowserTools::new(std::env::temp_dir());
2340        tools.attach_agent_for_test().await;
2341        tools
2342            .apply_control_for_test(ControlEvent::SignInRequested("Sign in".into()))
2343            .await;
2344        assert!(tools.control_status().await.signin_pending);
2345        assert!(tools.control_status().await.blackout_active);
2346        tools.hand_back().await;
2347        assert!(!tools.control_status().await.signin_pending);
2348    }
2349
2350    #[test]
2351    fn every_browser_tool_is_full_access() {
2352        // Browsing carries network egress and acts on a logged-in session, so
2353        // none of these may quietly land in a lower tier.
2354        for def in browser_tool_defs() {
2355            assert_eq!(
2356                def["tier"], BROWSER_TOOL_TIER,
2357                "{} must be full_access",
2358                def["name"]
2359            );
2360        }
2361    }
2362
2363    #[test]
2364    fn record_tools_are_advertised_alongside_the_browse_tools() {
2365        let names: Vec<String> = browser_tool_defs()
2366            .iter()
2367            .filter_map(|d| d["name"].as_str().map(str::to_string))
2368            .collect();
2369        assert!(names.iter().any(|n| n == "browse_navigate"));
2370        assert!(names.iter().any(|n| n == "browser_record_start"));
2371        assert!(names.iter().any(|n| n == "browser_record_stop"));
2372    }
2373
2374    #[tokio::test]
2375    async fn record_stop_without_start_is_an_error_not_a_panic() {
2376        let tools = BrowserTools::new(std::env::temp_dir());
2377        let err = tools
2378            .execute("browser_record_stop", &json!({}))
2379            .await
2380            .unwrap_err();
2381        assert!(err.contains("no recording in progress"), "got: {err}");
2382    }
2383
2384    /// The regression this covers: `handle.stop()` succeeding used to leave
2385    /// the recording's Model consumer registered in `self.frames` — the CDP
2386    /// screencast stayed armed on a still page with no drawer open, until
2387    /// (if ever) some later page change happened to prune it. No live
2388    /// Chromium is needed: `frames.subscribe` only needs a browser BOUND to
2389    /// start CDP capture, not to register a consumer, and the recording is
2390    /// fed one real frame through its own channel rather than live capture.
2391    #[tokio::test]
2392    async fn record_stop_releases_its_frame_consumer() {
2393        // The encode step this test exercises for real shells out to ffmpeg
2394        // (see `run_record_stop_with`) — skip cleanly where it isn't
2395        // installed, rather than failing on tooling unrelated to this
2396        // regression.
2397        if std::process::Command::new("ffmpeg")
2398            .arg("-version")
2399            .output()
2400            .is_err()
2401        {
2402            eprintln!("skipping record_stop_releases_its_frame_consumer: ffmpeg not found");
2403            return;
2404        }
2405
2406        let dir = tempfile::tempdir().expect("tempdir");
2407        let tools = BrowserTools::new(dir.path().to_path_buf());
2408
2409        // Register a Model consumer on the same fan-out `run_record_start_with`
2410        // would, then simulate its owning task having ended — exactly what
2411        // `RecordingHandle::stop()`'s `self.task.abort()` does to the real
2412        // receiver in production. (The recording below is fed its frame
2413        // through a SEPARATE channel, since injecting a frame into this one
2414        // without live CDP capture isn't possible through the public API —
2415        // what matters for this test is that `self.frames` still shows this
2416        // consumer as registered-but-dead going into `run_record_stop_with`.)
2417        let (consumer_incoming, _epoch) = tools.frames.subscribe(FrameAudience::Model).await;
2418        assert_eq!(
2419            tools.frames.consumer_count_for_test().await,
2420            1,
2421            "precondition: the Model consumer is registered"
2422        );
2423        drop(consumer_incoming);
2424
2425        // Build a real `RecordingHandle` with one real frame, so
2426        // `handle.stop()` inside `run_record_stop_with` succeeds instead of
2427        // erroring on an empty recording.
2428        let jpeg_path = dir.path().join("fixture.jpg");
2429        let status = std::process::Command::new("ffmpeg")
2430            .args([
2431                "-y",
2432                "-f",
2433                "lavfi",
2434                "-i",
2435                "color=c=white:s=2x2",
2436                "-frames:v",
2437                "1",
2438                "-q:v",
2439                "5",
2440            ])
2441            .arg(&jpeg_path)
2442            .status()
2443            .expect("run ffmpeg to build the fixture frame");
2444        assert!(status.success(), "ffmpeg must produce the fixture frame");
2445        let jpeg = std::fs::read(&jpeg_path).expect("read fixture frame");
2446
2447        let (frame_tx, frame_rx) = tokio::sync::mpsc::channel(car_browser::FRAME_CHANNEL_CAP);
2448        frame_tx
2449            .try_send(car_browser::ScreencastFrame {
2450                jpeg: jpeg.into(),
2451                viewport: car_browser::Viewport {
2452                    width: 2,
2453                    height: 2,
2454                    device_pixel_ratio: 1.0,
2455                },
2456                captured_at: 0.0,
2457            })
2458            .expect("channel still open");
2459        drop(frame_tx);
2460
2461        let handle = car_browser::recorder::start_with_frames(
2462            frame_rx,
2463            Instant::now(),
2464            &dir.path().join("recording"),
2465        )
2466        .await
2467        .expect("start_with_frames");
2468        *tools.recording.lock().await = Some(handle);
2469        // Give the recorder's background task a chance to actually drain
2470        // and write the one buffered frame before asking it to stop.
2471        tokio::time::sleep(Duration::from_millis(100)).await;
2472
2473        let result = tools
2474            .run_record_stop_with(
2475                &json!({}),
2476                Duration::from_secs(5),
2477                Duration::from_millis(10),
2478            )
2479            .await;
2480        assert!(result.is_ok(), "record_stop should succeed: {result:?}");
2481
2482        assert_eq!(
2483            tools.frames.consumer_count_for_test().await,
2484            0,
2485            "record_stop must prune the dead Model consumer, not leave the CDP \
2486             screencast armed indefinitely on a still page with no drawer open"
2487        );
2488    }
2489
2490    /// The round-8 privacy blocker. A recording the agent never stopped kept
2491    /// writing after the run ended — and nothing tears the browser down at run
2492    /// end (deliberately), the person keeps driving the page (`browser_view`
2493    /// admits input once `owner == NoAgent`), and the recorder's Model consumer
2494    /// is deliberately kept registered so it RESUMES when a blackout lifts. So
2495    /// the frames still arriving were the person's own browsing, and they went
2496    /// to disk in the agent's recording directory.
2497    ///
2498    /// Run end stops it, and finalizes rather than discards: the model's later
2499    /// `browser_record_stop` still returns a video, one that ends where the run
2500    /// did.
2501    #[tokio::test]
2502    async fn a_run_ending_stops_the_recording_it_started() {
2503        if std::process::Command::new("ffmpeg")
2504            .arg("-version")
2505            .output()
2506            .is_err()
2507        {
2508            eprintln!("skipping a_run_ending_stops_the_recording_it_started: ffmpeg not found");
2509            return;
2510        }
2511
2512        let dir = tempfile::tempdir().expect("tempdir");
2513        let tools = BrowserTools::new(dir.path().to_path_buf());
2514        tools.attach_agent_for_test().await;
2515
2516        // The recording's own Model consumer, registered exactly as
2517        // `run_record_start_with` does, plus the quality override it installs.
2518        let (consumer_incoming, _epoch) = tools.frames.subscribe(FrameAudience::Model).await;
2519        let live_quality = tools.frames.quality();
2520        tools.frames.set_quality(1).await;
2521        *tools.recording_restore_quality.lock().await = Some(live_quality);
2522        drop(consumer_incoming);
2523
2524        let jpeg_path = dir.path().join("fixture.jpg");
2525        let status = std::process::Command::new("ffmpeg")
2526            .args([
2527                "-y",
2528                "-f",
2529                "lavfi",
2530                "-i",
2531                "color=c=white:s=2x2",
2532                "-frames:v",
2533                "1",
2534                "-q:v",
2535                "5",
2536            ])
2537            .arg(&jpeg_path)
2538            .status()
2539            .expect("run ffmpeg to build the fixture frame");
2540        assert!(status.success(), "ffmpeg must produce the fixture frame");
2541        let jpeg = std::fs::read(&jpeg_path).expect("read fixture frame");
2542
2543        let (frame_tx, frame_rx) = tokio::sync::mpsc::channel(car_browser::FRAME_CHANNEL_CAP);
2544        frame_tx
2545            .try_send(car_browser::ScreencastFrame {
2546                jpeg: jpeg.into(),
2547                viewport: car_browser::Viewport {
2548                    width: 2,
2549                    height: 2,
2550                    device_pixel_ratio: 1.0,
2551                },
2552                captured_at: 0.0,
2553            })
2554            .expect("channel still open");
2555        let handle = car_browser::recorder::start_with_frames(
2556            frame_rx,
2557            Instant::now(),
2558            &dir.path().join("recording"),
2559        )
2560        .await
2561        .expect("start_with_frames");
2562        *tools.recording.lock().await = Some(handle);
2563        tokio::time::sleep(Duration::from_millis(100)).await;
2564
2565        // The run ends. The agent never called browser_record_stop.
2566        tools.note_run_ended().await;
2567
2568        assert!(
2569            tools.recording.lock().await.is_none(),
2570            "no recording may still be running once the run that started it ended"
2571        );
2572        assert_eq!(
2573            tools.frames.consumer_count_for_test().await,
2574            0,
2575            "run end owes the same consumer release record_stop does"
2576        );
2577        assert_eq!(
2578            tools.frames.quality(),
2579            live_quality,
2580            "and the drawer gets its picture quality back"
2581        );
2582
2583        // The property this whole blocker is about: anything Chrome emits AFTER
2584        // run-end — the person's own browsing — reaches no disk.
2585        let frames_on_disk = |dir: &std::path::Path| {
2586            std::fs::read_dir(dir)
2587                .map(|entries| {
2588                    entries
2589                        .filter_map(Result::ok)
2590                        .filter(|e| e.file_name().to_string_lossy().starts_with("frame-"))
2591                        .count()
2592                })
2593                .unwrap_or(0)
2594        };
2595        let recording_dir = dir.path().join("recording");
2596        tokio::time::sleep(Duration::from_millis(50)).await;
2597        let before = frames_on_disk(&recording_dir);
2598        assert_eq!(before, 1, "the pre-run-end frame was captured");
2599        let _ = frame_tx
2600            .send(car_browser::ScreencastFrame {
2601                jpeg: vec![0xff, 0xd8].into(),
2602                viewport: car_browser::Viewport {
2603                    width: 2,
2604                    height: 2,
2605                    device_pixel_ratio: 1.0,
2606                },
2607                captured_at: 1.0,
2608            })
2609            .await;
2610        tokio::time::sleep(Duration::from_millis(150)).await;
2611        assert_eq!(
2612            frames_on_disk(&recording_dir),
2613            before,
2614            "a frame emitted after the run ended must not be written to disk"
2615        );
2616
2617        // Finalized, not discarded: the encode is still owed and still works.
2618        let result = tools
2619            .run_record_stop_with(
2620                &json!({}),
2621                Duration::from_secs(5),
2622                Duration::from_millis(10),
2623            )
2624            .await
2625            .expect("the frames captured up to run-end are still encodable");
2626        assert_eq!(
2627            result["frames"], 1,
2628            "one frame, the one from before run-end"
2629        );
2630
2631        // And it is consumed exactly once.
2632        let err = tools
2633            .run_record_stop_with(
2634                &json!({}),
2635                Duration::from_secs(5),
2636                Duration::from_millis(10),
2637            )
2638            .await
2639            .unwrap_err();
2640        assert!(err.contains("no recording in progress"), "got: {err}");
2641    }
2642
2643    #[test]
2644    fn describe_browse_action_humanizes_the_tool_name_and_leading_param() {
2645        assert_eq!(
2646            describe_browse_action("browse_navigate", &json!({"url": "https://x.com"})),
2647            "Navigate https://x.com"
2648        );
2649        assert_eq!(
2650            describe_browse_action(
2651                "browse_type",
2652                &json!({"element_id": "el_3", "text": "hello"})
2653            ),
2654            "Type hello"
2655        );
2656        // No recognized param present — still humanizes the tool name alone.
2657        assert_eq!(
2658            describe_browse_action("browse_scroll", &json!({})),
2659            "Scroll"
2660        );
2661    }
2662
2663    #[tokio::test]
2664    async fn presentation_before_any_browser_call_is_the_no_agent_zero_ceremony_state() {
2665        let tools = BrowserTools::new(std::env::temp_dir());
2666        let snap = tools.presentation().await;
2667        assert_eq!(
2668            snap.owner,
2669            crate::assistant::browser_control::ControlOwner::NoAgent
2670        );
2671        assert!(snap.tabs.is_empty());
2672        assert!(!snap.blackout_active);
2673    }
2674
2675    #[tokio::test]
2676    async fn take_control_without_an_agent_is_a_noop() {
2677        // "no ceremony" — nothing to take, matching the reducer's own
2678        // no-op behavior (see browser_control's take_control_is_a_noop_...
2679        // test). Exercised here through BrowserTools's own pass-through,
2680        // with no live Chromium involved.
2681        let tools = BrowserTools::new(std::env::temp_dir());
2682        let (snap, effects) = tools.take_control().await;
2683        assert_eq!(
2684            snap.owner,
2685            crate::assistant::browser_control::ControlOwner::NoAgent
2686        );
2687        assert!(effects.is_empty());
2688    }
2689
2690    #[tokio::test]
2691    async fn wait_for_agent_turn_returns_immediately_once_attached() {
2692        let tools = BrowserTools::new(std::env::temp_dir());
2693        tools
2694            .presentation
2695            .lock()
2696            .await
2697            .apply_control(ControlEvent::AgentAttached);
2698        // A large timeout that would fail the test if this actually blocked.
2699        tools
2700            .wait_for_agent_turn_with(Duration::from_secs(5), Duration::from_millis(10))
2701            .await
2702            .expect("agent owns control, should not block");
2703    }
2704
2705    #[tokio::test]
2706    async fn wait_for_agent_turn_times_out_while_the_user_holds_control() {
2707        let tools = BrowserTools::new(std::env::temp_dir());
2708        {
2709            let mut p = tools.presentation.lock().await;
2710            p.apply_control(ControlEvent::AgentAttached);
2711            p.apply_control(ControlEvent::TakeControl);
2712        }
2713        let err = tools
2714            .wait_for_agent_turn_with(Duration::from_millis(50), Duration::from_millis(10))
2715            .await
2716            .unwrap_err();
2717        assert!(err.contains("currently driving"), "got: {err}");
2718    }
2719
2720    #[tokio::test]
2721    async fn wait_for_agent_turn_unblocks_once_the_user_hands_back() {
2722        let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
2723        {
2724            let mut p = tools.presentation.lock().await;
2725            p.apply_control(ControlEvent::AgentAttached);
2726            p.apply_control(ControlEvent::TakeControl);
2727        }
2728        let waiter = {
2729            let tools = Arc::clone(&tools);
2730            tokio::spawn(async move {
2731                tools
2732                    .wait_for_agent_turn_with(Duration::from_secs(5), Duration::from_millis(10))
2733                    .await
2734            })
2735        };
2736        tokio::time::sleep(Duration::from_millis(30)).await;
2737        tools.hand_back().await;
2738        waiter
2739            .await
2740            .expect("task panicked")
2741            .expect("should unblock once handed back, not time out");
2742    }
2743
2744    // ---- profile isolation (FAIL 3/4/7) --------------------------------
2745
2746    /// The regression that broke `browser.run`: setting
2747    /// `CAR_BROWSER_PROFILE_DIR` is process-global, so the drawer launching
2748    /// its standing session silently repointed every OTHER browser in the
2749    /// daemon — `browser.run`'s per-connection browsers included — at the
2750    /// agent's persistent profile, after which they all died on Chromium's
2751    /// SingletonLock for the life of that daemon.
2752    ///
2753    /// A grep-level test on purpose: the property is "this crate contains no
2754    /// code that writes that variable", which no behavioural test can state
2755    /// as directly, and which a future edit could reintroduce anywhere.
2756    #[test]
2757    fn car_code_never_sets_the_process_global_profile_env_var() {
2758        let src = include_str!("browser_tools.rs");
2759        // The name appears in prose in this file (this test included), so
2760        // look for the mutation, not the mention.
2761        assert!(
2762            !src.contains("set_var(\"CAR_BROWSER_PROFILE_DIR"),
2763            "the profile directory must be passed as a launch OPTION; setting the env var \
2764             repoints every other browser in the process"
2765        );
2766        assert!(
2767            !src.contains("set_var(&\"CAR_BROWSER_PROFILE_DIR"),
2768            "same, via a reference"
2769        );
2770    }
2771
2772    #[test]
2773    fn the_agent_and_the_standing_session_use_different_persistent_profiles() {
2774        // Chromium allows exactly ONE live instance per profile directory,
2775        // so two browsers that can be alive at the same time must not share
2776        // one. This is the whole fix for "the standing session and an agent
2777        // browser cannot coexist".
2778        let agent = BrowserProfile::Agent.dir();
2779        let standing = BrowserProfile::StandingSession.dir();
2780        match (agent, standing) {
2781            (Some(a), Some(b)) => {
2782                assert_ne!(a, b, "two live browsers must not share one profile dir");
2783                assert_eq!(a.file_name().unwrap(), "browser-profile");
2784                assert_eq!(b.file_name().unwrap(), "browser-profile-user");
2785                assert_eq!(a.parent(), b.parent(), "siblings under the CAR state root");
2786            }
2787            // No CAR_HOME and no home directory: both fall back to
2788            // car-browser's throwaway per-instance profile, which cannot
2789            // collide with anything.
2790            (None, None) => {}
2791            other => panic!("profile dirs must resolve together or not at all: {other:?}"),
2792        }
2793    }
2794
2795    #[tokio::test]
2796    async fn constructors_pick_persistent_and_isolated_profiles() {
2797        let agent = BrowserTools::new(std::env::temp_dir());
2798        let standing = BrowserTools::standing_session(std::env::temp_dir());
2799        let coder = BrowserTools::isolated(std::env::temp_dir());
2800        let other_coder = BrowserTools::isolated(std::env::temp_dir());
2801        assert!(!Arc::ptr_eq(&coder.inner, &other_coder.inner));
2802        assert!(!Arc::ptr_eq(&coder.inner, &agent.inner));
2803        assert_eq!(other_coder.profile, BrowserProfile::Isolated);
2804        assert_eq!(coder.profile, BrowserProfile::Isolated);
2805        assert!(coder.profile.launch_dir().is_none());
2806        assert_eq!(agent.profile, BrowserProfile::Agent);
2807        assert_eq!(standing.profile, BrowserProfile::StandingSession);
2808    }
2809
2810    // ---- the agent attaches on first AGENT use, whoever launched ---------
2811
2812    /// The regression this exists for: a user navigating in the drawer before
2813    /// the agent's first browse call used to launch the browser, and the
2814    /// agent's own `session()` would then find one already there and return
2815    /// without ever attaching — `owner` stuck at `NoAgent` for the whole run,
2816    /// so no strip, `take_control` a no-op, and the user-control blackout
2817    /// unable to engage at all.
2818    #[tokio::test]
2819    async fn a_user_launch_does_not_stop_the_agent_attaching_later() {
2820        let tools = BrowserTools::new(std::env::temp_dir());
2821        // Stand in for the user's navigation having already launched the
2822        // browser: ownership is what `ensure_session(false)` leaves alone.
2823        assert_eq!(
2824            tools.control_status().await.owner,
2825            crate::assistant::browser_control::ControlOwner::NoAgent
2826        );
2827
2828        // The agent's first browse call, on a browser it did not launch.
2829        tools.attach_agent_for_test().await;
2830        assert_eq!(
2831            tools.control_status().await.owner,
2832            crate::assistant::browser_control::ControlOwner::Agent,
2833            "the agent must still attach on ITS first use"
2834        );
2835    }
2836
2837    /// The other half of the same decision: the attach is once per run, not
2838    /// once per call. Firing it on every `session()` would hand control
2839    /// straight back to the agent the instant the user pressed Take control.
2840    #[tokio::test]
2841    async fn attaching_again_after_take_control_does_not_steal_control_back() {
2842        let tools = BrowserTools::new(std::env::temp_dir());
2843        tools.attach_agent_for_test().await;
2844        tools.take_control().await;
2845        assert_eq!(
2846            tools.control_status().await.owner,
2847            crate::assistant::browser_control::ControlOwner::User
2848        );
2849
2850        // A second (and third) agent tool call in the same run.
2851        tools.attach_agent_for_test().await;
2852        tools.attach_agent_for_test().await;
2853        assert_eq!(
2854            tools.control_status().await.owner,
2855            crate::assistant::browser_control::ControlOwner::User,
2856            "the user keeps control until they hand it back"
2857        );
2858        assert!(tools.control_status().await.blackout_active);
2859    }
2860
2861    /// The blocker this file's attach rule was rewritten for. ONE
2862    /// `BrowserTools` serves every turn of a `car do --serve` process, and
2863    /// `TurnGuard` fires `RunEnded` at the end of each one. With a latching
2864    /// once-per-instance flag, turn 2's browse call found `owner == NoAgent`,
2865    /// never re-attached, and sat in `wait_for_agent_turn` for the full
2866    /// 30-minute bound before returning a "the user is currently driving"
2867    /// error with no user involved.
2868    #[tokio::test]
2869    async fn the_next_turn_reattaches_after_the_previous_run_ended() {
2870        use crate::assistant::browser_control::ControlOwner;
2871        let tools = BrowserTools::new(std::env::temp_dir());
2872
2873        // Turn 1.
2874        tools.attach_agent_for_test().await;
2875        assert_eq!(tools.control_status().await.owner, ControlOwner::Agent);
2876        tools.note_run_ended().await;
2877        assert_eq!(
2878            tools.control_status().await.owner,
2879            ControlOwner::NoAgent,
2880            "run end hands the browser back to the user, per the locked design"
2881        );
2882
2883        // Turn 2, on the SAME instance. A timeout large enough that a
2884        // non-attaching implementation fails here instead of passing slowly.
2885        tools
2886            .wait_for_agent_turn_with(Duration::from_secs(5), Duration::from_millis(10))
2887            .await
2888            .expect("the next turn must re-attach, not wait out the pause");
2889        assert_eq!(tools.control_status().await.owner, ControlOwner::Agent);
2890    }
2891
2892    /// The same boundary with a person holding the wheel across it: the run
2893    /// ends while the user has control (deferred by the reducer), so turn 2
2894    /// must NOT snatch it back — it waits, and hand-back is what lets it
2895    /// attach. Hand-back lands on `NoAgent` here (the previous run had
2896    /// ended), which is precisely the state the attach rule has to cover.
2897    #[tokio::test]
2898    async fn a_new_turn_waits_for_hand_back_instead_of_taking_the_wheel() {
2899        use crate::assistant::browser_control::ControlOwner;
2900        let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
2901        tools.attach_agent_for_test().await;
2902        tools.take_control().await;
2903        tools.note_run_ended().await;
2904        assert_eq!(
2905            tools.control_status().await.owner,
2906            ControlOwner::User,
2907            "a run ending under a person who is driving does not retract their control"
2908        );
2909
2910        let waiter = {
2911            let tools = Arc::clone(&tools);
2912            tokio::spawn(async move {
2913                tools
2914                    .wait_for_agent_turn_with(Duration::from_secs(5), Duration::from_millis(10))
2915                    .await
2916            })
2917        };
2918        tokio::time::sleep(Duration::from_millis(30)).await;
2919        assert_eq!(
2920            tools.control_status().await.owner,
2921            ControlOwner::User,
2922            "the new turn must not have attached over the user"
2923        );
2924        tools.hand_back().await;
2925        waiter
2926            .await
2927            .expect("task panicked")
2928            .expect("hand-back must release the new turn, not leave it waiting");
2929        assert_eq!(tools.control_status().await.owner, ControlOwner::Agent);
2930    }
2931
2932    // ---- blackout enforcement, model-facing half (R3) --------------------
2933
2934    /// The mid-flight half of R3. `wait_while_blackout_with` answers only
2935    /// "may this call start", and `browse_wait` runs for as long as the model
2936    /// asks (`timeout_ms` is unclamped, and car-browser polls the whole page
2937    /// accessibility tree every 100ms until the deadline) — so a Take control
2938    /// ten seconds in would otherwise have the page the person just took over
2939    /// read back to the model when the call finally returned.
2940    ///
2941    /// This is the arm the browse call races its tool against.
2942    #[tokio::test]
2943    async fn a_blackout_starting_mid_call_resolves_the_cancellation_arm() {
2944        let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
2945        tools.attach_agent_for_test().await;
2946        let mut changes = tools.subscribe_changes();
2947
2948        // No blackout: the arm must never resolve, or every browse call would
2949        // be cancelled the instant it started.
2950        assert!(
2951            tokio::time::timeout(
2952                Duration::from_millis(50),
2953                tools.blackout_starts(&mut changes)
2954            )
2955            .await
2956            .is_err(),
2957            "nothing is blacked out, so nothing may cancel"
2958        );
2959
2960        // The user takes control while the tool is in flight.
2961        let presser = {
2962            let tools = Arc::clone(&tools);
2963            tokio::spawn(async move {
2964                tokio::time::sleep(Duration::from_millis(20)).await;
2965                tools.take_control().await;
2966            })
2967        };
2968        tokio::time::timeout(Duration::from_secs(5), tools.blackout_starts(&mut changes))
2969            .await
2970            .expect("a blackout starting mid-call must cancel the in-flight observation");
2971        presser.await.expect("task panicked");
2972        assert!(tools.control_status().await.blackout_active);
2973    }
2974
2975    /// The action strip's label had no clear other than `RunEnded`, so it
2976    /// reported the last browse action as still running for the rest of the
2977    /// turn. The browse call now fires this on every exit — success, error,
2978    /// or blackout cancellation.
2979    #[tokio::test]
2980    async fn an_action_that_finished_stops_being_reported_as_current() {
2981        let tools = BrowserTools::new(std::env::temp_dir());
2982        tools.attach_agent_for_test().await;
2983        tools
2984            .apply_control(ControlEvent::AgentActionStarted("Opening x.test".into()))
2985            .await;
2986        assert_eq!(
2987            tools.presentation().await.current_action.as_deref(),
2988            Some("Opening x.test")
2989        );
2990
2991        tools.apply_control(ControlEvent::AgentActionFinished).await;
2992        assert!(
2993            tools.presentation().await.current_action.is_none(),
2994            "a finished action must not keep claiming the strip"
2995        );
2996    }
2997
2998    #[tokio::test]
2999    async fn a_pending_signin_suspends_model_facing_observation() {
3000        // The agent still nominally owns the browser here — `may_agent_act`
3001        // is true — so this gate is the ONLY thing standing between the
3002        // model and a page the user is typing a password into.
3003        let tools = BrowserTools::new(std::env::temp_dir());
3004        tools.apply_control(ControlEvent::AgentAttached).await;
3005        tools
3006            .apply_control(ControlEvent::SignInRequested("Sign in at x".into()))
3007            .await;
3008        assert!(
3009            tools.presentation.lock().await.control().may_agent_act(),
3010            "precondition: the agent owns control, so only the blackout gate applies"
3011        );
3012
3013        let err = tools
3014            .wait_while_blackout_with(Duration::from_millis(50), Duration::from_millis(10))
3015            .await
3016            .unwrap_err();
3017        assert!(err.contains("cannot observe the page"), "got: {err}");
3018    }
3019
3020    #[tokio::test]
3021    async fn observation_resumes_the_moment_the_signin_resolves() {
3022        let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3023        tools.apply_control(ControlEvent::AgentAttached).await;
3024        tools
3025            .apply_control(ControlEvent::SignInRequested("Sign in at x".into()))
3026            .await;
3027        let waiter = {
3028            let tools = Arc::clone(&tools);
3029            tokio::spawn(async move {
3030                tools
3031                    .wait_while_blackout_with(Duration::from_secs(5), Duration::from_millis(10))
3032                    .await
3033            })
3034        };
3035        tokio::time::sleep(Duration::from_millis(30)).await;
3036        tools
3037            .apply_control(ControlEvent::SignInResolved { signed_in: true })
3038            .await;
3039        waiter
3040            .await
3041            .expect("task panicked")
3042            .expect("should unblock once the sign-in resolved, not time out");
3043    }
3044
3045    #[tokio::test]
3046    async fn no_blackout_means_no_wait_at_all() {
3047        let tools = BrowserTools::new(std::env::temp_dir());
3048        tools.apply_control(ControlEvent::AgentAttached).await;
3049        // A timeout that would fail the test if this actually blocked.
3050        tools
3051            .wait_while_blackout_with(Duration::from_secs(5), Duration::from_millis(10))
3052            .await
3053            .expect("nothing is blacked out");
3054    }
3055
3056    /// `browser_await_answer` measures the page and hands `content_length`
3057    /// back to the model, so it is model-facing observation and gates like
3058    /// the rest — at entry, before it can touch a session.
3059    #[tokio::test]
3060    async fn await_answer_is_blocked_at_entry_while_the_user_is_driving() {
3061        let tools = BrowserTools::new(std::env::temp_dir());
3062        tools.attach_agent_for_test().await;
3063        tools.take_control().await;
3064
3065        let err = tools
3066            .run_await_answer_with(
3067                &json!({}),
3068                Duration::from_millis(50),
3069                Duration::from_millis(10),
3070            )
3071            .await
3072            .unwrap_err();
3073        // The gate's error, not a live-session one ("launch browser: …") —
3074        // proof it returned at the boundary and never reached session().
3075        assert!(err.contains("cannot observe the page"), "got: {err}");
3076        assert!(tools.inner.lock().await.is_none());
3077    }
3078
3079    #[tokio::test]
3080    async fn await_answer_is_blocked_at_entry_while_a_signin_is_pending() {
3081        let tools = BrowserTools::new(std::env::temp_dir());
3082        tools.attach_agent_for_test().await;
3083        tools
3084            .apply_control_for_test(ControlEvent::SignInRequested("Sign in at x".into()))
3085            .await;
3086
3087        let err = tools
3088            .run_await_answer_with(
3089                &json!({}),
3090                Duration::from_millis(50),
3091                Duration::from_millis(10),
3092            )
3093            .await
3094            .unwrap_err();
3095        assert!(err.contains("cannot observe the page"), "got: {err}");
3096    }
3097
3098    /// The half an entry gate cannot cover: the user takes control (or a
3099    /// sign-in appears) while this is ALREADY polling. The loop must stop
3100    /// reading the page — this asserts on the measurement count itself, so it
3101    /// fails if the check is ever removed from the loop body.
3102    #[tokio::test]
3103    async fn await_answer_stops_measuring_the_page_when_a_blackout_starts_mid_poll() {
3104        let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3105        tools.attach_agent_for_test().await;
3106        let reads = Arc::new(AtomicUsize::new(0));
3107
3108        let settle = {
3109            let tools = Arc::clone(&tools);
3110            let reads = Arc::clone(&reads);
3111            tokio::spawn(async move {
3112                tools
3113                    .await_settled(
3114                        Duration::from_millis(400),
3115                        Duration::from_millis(10),
3116                        Duration::from_secs(60),
3117                        move || {
3118                            let reads = Arc::clone(&reads);
3119                            async move {
3120                                reads.fetch_add(1, Ordering::SeqCst);
3121                                // Keep growing, so nothing else could end the
3122                                // loop early — only the blackout can.
3123                                reads.load(Ordering::SeqCst) as i64 * 100
3124                            }
3125                        },
3126                    )
3127                    .await
3128            })
3129        };
3130
3131        tokio::time::sleep(Duration::from_millis(80)).await;
3132        assert!(
3133            reads.load(Ordering::SeqCst) > 1,
3134            "precondition: it was actively measuring before the blackout"
3135        );
3136        tools.take_control().await;
3137        let at_blackout = reads.load(Ordering::SeqCst);
3138        tokio::time::sleep(Duration::from_millis(150)).await;
3139        assert_eq!(
3140            reads.load(Ordering::SeqCst),
3141            at_blackout,
3142            "not one page read may happen while the user is driving"
3143        );
3144
3145        // And it resumes on hand-back rather than dying.
3146        tools.hand_back().await;
3147        tokio::time::sleep(Duration::from_millis(80)).await;
3148        assert!(
3149            reads.load(Ordering::SeqCst) > at_blackout,
3150            "measurement resumes once the blackout lifts"
3151        );
3152        let out = settle
3153            .await
3154            .expect("task panicked")
3155            .expect("returns a value");
3156        assert_eq!(out["settled"], false, "it ran out its own timeout");
3157    }
3158
3159    /// The link that carries the reducer's blackout state to the frame
3160    /// fan-out — i.e. to `browser_record`'s disk output. Without it R3's
3161    /// recording half is unenforced no matter how correct the reducer is.
3162    #[tokio::test]
3163    async fn control_changes_republish_the_blackout_flag_to_the_frame_fanout() {
3164        let tools = BrowserTools::new(std::env::temp_dir());
3165        assert!(!tools.frames.blackout_active());
3166
3167        tools.apply_control(ControlEvent::AgentAttached).await;
3168        assert!(
3169            !tools.frames.blackout_active(),
3170            "the agent driving is not a blackout"
3171        );
3172
3173        tools.take_control().await;
3174        assert!(
3175            tools.frames.blackout_active(),
3176            "user control blacks out recording"
3177        );
3178
3179        tools.hand_back().await;
3180        assert!(!tools.frames.blackout_active());
3181
3182        tools
3183            .apply_control(ControlEvent::SignInRequested("Sign in".into()))
3184            .await;
3185        assert!(
3186            tools.frames.blackout_active(),
3187            "a pending sign-in blacks out too"
3188        );
3189
3190        tools
3191            .apply_control(ControlEvent::SignInResolved { signed_in: true })
3192            .await;
3193        assert!(!tools.frames.blackout_active());
3194    }
3195
3196    // ---- the drawer's change signal and user-input surface ---------------
3197
3198    #[tokio::test]
3199    async fn every_control_change_wakes_the_drawer_signal() {
3200        let tools = BrowserTools::new(std::env::temp_dir());
3201        let mut changes = tools.subscribe_changes();
3202        assert!(!changes.has_changed().unwrap(), "nothing has happened yet");
3203
3204        tools.apply_control(ControlEvent::AgentAttached).await;
3205        assert!(changes.has_changed().unwrap());
3206        changes.mark_unchanged();
3207
3208        tools.take_control().await;
3209        assert!(changes.has_changed().unwrap());
3210    }
3211
3212    #[tokio::test]
3213    async fn user_input_without_a_browser_is_a_clean_error_not_a_launch() {
3214        // Input that cannot sensibly launch a browser reports so, rather
3215        // than starting Chromium (or hanging) for a click with nothing to
3216        // click on.
3217        let tools = BrowserTools::new(std::env::temp_dir());
3218        for err in [
3219            tools.user_click(1.0, 2.0).await.unwrap_err(),
3220            tools.user_type("hi").await.unwrap_err(),
3221            tools.user_keypress("Enter", &[]).await.unwrap_err(),
3222            tools.user_scroll(10).await.unwrap_err(),
3223            tools.user_tab_close(fake_tab_id()).await.unwrap_err(),
3224            tools.user_tab_switch(fake_tab_id()).await.unwrap_err(),
3225            tools.resolve_tab("tab-0").await.unwrap_err(),
3226        ] {
3227            assert!(err.contains("no browser is running"), "got: {err}");
3228        }
3229        assert!(
3230            tools.inner.lock().await.is_none(),
3231            "none of those may have launched a browser"
3232        );
3233    }
3234
3235    #[tokio::test]
3236    async fn navigate_rejects_an_empty_url_before_launching_anything() {
3237        let tools = BrowserTools::new(std::env::temp_dir());
3238        let err = tools.user_navigate("   ").await.unwrap_err();
3239        assert!(err.contains("non-empty `url`"), "got: {err}");
3240        assert!(tools.inner.lock().await.is_none());
3241    }
3242
3243    /// A `TabId` the caller could plausibly have (the registry mints them),
3244    /// used only to reach the no-browser error path.
3245    fn fake_tab_id() -> car_browser::TabId {
3246        let (mut registry, _rx) = car_browser::tabs::TabRegistry::<&'static str>::new();
3247        registry.open("page", "about:blank", "")
3248    }
3249
3250    // ---- wait_while_user_holds_control: the boundary gate for
3251    // browser_await_signin/browser_record_start/browser_record_stop ----
3252
3253    #[tokio::test]
3254    async fn wait_while_user_holds_control_does_not_block_before_any_agent_attaches() {
3255        // The whole reason this is a DIFFERENT predicate from
3256        // may_agent_act: a call site gating before it has attached must
3257        // not mistake "nobody has attached yet" for "the user is driving."
3258        let tools = BrowserTools::new(std::env::temp_dir());
3259        tools
3260            .wait_while_user_holds_control_with(Duration::from_secs(5), Duration::from_millis(10))
3261            .await
3262            .expect("no agent attached yet, should not block");
3263    }
3264
3265    #[tokio::test]
3266    async fn wait_while_user_holds_control_times_out_while_the_user_holds_control() {
3267        let tools = BrowserTools::new(std::env::temp_dir());
3268        {
3269            let mut p = tools.presentation.lock().await;
3270            p.apply_control(ControlEvent::AgentAttached);
3271            p.apply_control(ControlEvent::TakeControl);
3272        }
3273        let err = tools
3274            .wait_while_user_holds_control_with(
3275                Duration::from_millis(50),
3276                Duration::from_millis(10),
3277            )
3278            .await
3279            .unwrap_err();
3280        assert!(err.contains("currently driving"), "got: {err}");
3281    }
3282
3283    #[tokio::test]
3284    async fn wait_while_user_holds_control_unblocks_once_the_user_hands_back() {
3285        let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3286        {
3287            let mut p = tools.presentation.lock().await;
3288            p.apply_control(ControlEvent::AgentAttached);
3289            p.apply_control(ControlEvent::TakeControl);
3290        }
3291        let waiter = {
3292            let tools = Arc::clone(&tools);
3293            tokio::spawn(async move {
3294                tools
3295                    .wait_while_user_holds_control_with(
3296                        Duration::from_secs(5),
3297                        Duration::from_millis(10),
3298                    )
3299                    .await
3300            })
3301        };
3302        tokio::time::sleep(Duration::from_millis(30)).await;
3303        tools.hand_back().await;
3304        waiter
3305            .await
3306            .expect("task panicked")
3307            .expect("should unblock once handed back, not time out");
3308    }
3309
3310    // ---- the actual call sites the reviewer flagged: browser_await_signin,
3311    // browser_record_start, browser_record_stop must consult the gate too,
3312    // not just browse_*. Each test drives the real method (via its `_with`
3313    // variant, so it doesn't have to wait AGENT_PAUSE_TIMEOUT_SECS) with the
3314    // user holding control and NO hand-back — proving the call site blocks
3315    // and then reports the boundary error, never reaching whatever it does
3316    // next (navigate / start a recording / touch a live session).
3317
3318    #[tokio::test]
3319    async fn browser_await_signin_never_navigates_while_the_user_holds_control() {
3320        let tools = BrowserTools::new(std::env::temp_dir());
3321        {
3322            let mut p = tools.presentation.lock().await;
3323            p.apply_control(ControlEvent::AgentAttached);
3324            p.apply_control(ControlEvent::TakeControl);
3325        }
3326        let err = tools
3327            .run_await_signin_with(
3328                &json!({"url": "https://example.com/login"}),
3329                Duration::from_millis(50),
3330                Duration::from_millis(10),
3331            )
3332            .await
3333            .unwrap_err();
3334        // The gate's error, not a live-session error ("launch browser: ...")
3335        // — proof this returned at the boundary, before ever calling
3336        // session()/navigate. A real navigate attempt would also have taken
3337        // much longer than this test's ~50ms budget.
3338        assert!(err.contains("currently driving"), "got: {err}");
3339    }
3340
3341    #[tokio::test]
3342    async fn browser_record_start_blocks_while_the_user_holds_control() {
3343        let tools = BrowserTools::new(std::env::temp_dir());
3344        {
3345            let mut p = tools.presentation.lock().await;
3346            p.apply_control(ControlEvent::AgentAttached);
3347            p.apply_control(ControlEvent::TakeControl);
3348        }
3349        let err = tools
3350            .run_record_start_with(
3351                &json!({}),
3352                Duration::from_millis(50),
3353                Duration::from_millis(10),
3354            )
3355            .await
3356            .unwrap_err();
3357        assert!(err.contains("currently driving"), "got: {err}");
3358    }
3359
3360    #[tokio::test]
3361    async fn browser_record_stop_blocks_while_the_user_holds_control_and_then_re_checks() {
3362        // Full round trip, live-Chromium-free: no recording was ever
3363        // started, so once the gate releases (hand-back), this call
3364        // correctly falls through to the SAME "no recording in progress"
3365        // error the pre-existing (owner-agnostic) test asserts — proving
3366        // both that the gate blocks AND that the call site's own logic
3367        // still runs correctly afterward, not just that it errors out.
3368        let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
3369        {
3370            let mut p = tools.presentation.lock().await;
3371            p.apply_control(ControlEvent::AgentAttached);
3372            p.apply_control(ControlEvent::TakeControl);
3373        }
3374        let waiter = {
3375            let tools = Arc::clone(&tools);
3376            tokio::spawn(async move {
3377                tools
3378                    .run_record_stop_with(
3379                        &json!({}),
3380                        Duration::from_secs(5),
3381                        Duration::from_millis(10),
3382                    )
3383                    .await
3384            })
3385        };
3386        tokio::time::sleep(Duration::from_millis(30)).await;
3387        tools.hand_back().await;
3388        let err = waiter
3389            .await
3390            .expect("task panicked")
3391            .expect_err("no recording was ever started");
3392        assert!(err.contains("no recording in progress"), "got: {err}");
3393    }
3394
3395    // ---- Task 7: the headless default flip + the sign-in-safe fallback --
3396    //
3397    // `decide_headless` is the whole rule as pure logic — no browser, no
3398    // daemon, no lock. Every other test below drives the real methods
3399    // (`effective_headless`, `run_await_signin_with`) but stops before
3400    // `session()` in every case, so none of these need a live Chromium
3401    // either.
3402
3403    #[test]
3404    fn decide_headless_defaults_to_headless_when_a_host_is_connected() {
3405        // The whole point of the flip: the drawer is a real surface, so no
3406        // separate Chrome window.
3407        assert!(decide_headless(true, None));
3408    }
3409
3410    #[test]
3411    fn decide_headless_defaults_to_headed_when_no_host_has_ever_connected() {
3412        // Pure CLI session — exactly today's behavior, unchanged.
3413        assert!(!decide_headless(false, None));
3414    }
3415
3416    #[test]
3417    fn decide_headless_override_forces_headless_even_with_no_host() {
3418        // CAR_BROWSER_HEADLESS keeps its exact existing semantics: any
3419        // non-"0", non-empty value is truthy, and it wins over the
3420        // host-connected default in either direction.
3421        assert!(decide_headless(false, Some("1")));
3422        assert!(decide_headless(false, Some("yes")));
3423    }
3424
3425    #[test]
3426    fn decide_headless_override_forces_headed_even_with_a_host_connected() {
3427        assert!(!decide_headless(true, Some("0")));
3428    }
3429
3430    #[test]
3431    fn decide_headless_override_of_empty_string_is_falsy_like_unset() {
3432        // Matches the ORIGINAL `.map(|v| v != "0" && !v.is_empty())` reading
3433        // of the env var precisely — an empty value was never truthy.
3434        assert!(!decide_headless(true, Some("")));
3435        assert!(!decide_headless(false, Some("")));
3436    }
3437
3438    // ---- signin_timeout_hint: naming a surface that actually exists ------
3439
3440    #[test]
3441    fn signin_timeout_hint_names_the_window_when_headed() {
3442        // Headed never becomes headless mid-session, so the window text is
3443        // always accurate here regardless of host state.
3444        assert_eq!(
3445            signin_timeout_hint(false, true),
3446            "Ask the user to complete the login in the open browser window, then retry."
3447        );
3448        assert_eq!(
3449            signin_timeout_hint(false, false),
3450            "Ask the user to complete the login in the open browser window, then retry."
3451        );
3452    }
3453
3454    #[test]
3455    fn signin_timeout_hint_names_the_drawer_when_headless_with_a_host() {
3456        assert_eq!(
3457            signin_timeout_hint(true, true),
3458            "Ask the user to complete the login in the CAR app's browser drawer, then retry."
3459        );
3460    }
3461
3462    #[test]
3463    fn signin_timeout_hint_says_no_surface_when_headless_with_no_host() {
3464        // Neither a window (headless) nor a drawer (nobody connected to show
3465        // it in) exists — the old fixed text pointed the user at a window
3466        // that was never there in this case.
3467        let hint = signin_timeout_hint(true, false);
3468        assert!(hint.contains("open the CAR app"), "got: {hint}");
3469        assert!(
3470            !hint.contains("browser window"),
3471            "must not claim a window exists: {hint}"
3472        );
3473    }
3474
3475    #[tokio::test]
3476    async fn effective_headless_before_any_launch_tracks_the_live_host_state() {
3477        let tools = BrowserTools::new(std::env::temp_dir());
3478        // No probe installed — "no way to know" reads as no host, matching
3479        // `any_host_connected`'s own default.
3480        assert!(!tools.effective_headless(false));
3481        assert!(tools.effective_headless(true));
3482    }
3483
3484    #[tokio::test]
3485    async fn effective_headless_after_launch_is_fixed_regardless_of_current_host_state() {
3486        let tools = BrowserTools::new(std::env::temp_dir());
3487        // Simulate "this instance already launched headless" without a live
3488        // Chromium — the mid-session case the brief rules on explicitly:
3489        // launched headless, host since disconnected. The decision must NOT
3490        // flip back just because `host_connected` reads false now.
3491        let _ = tools.launched_headless.set(true);
3492        assert!(tools.effective_headless(false));
3493        assert!(tools.effective_headless(true));
3494
3495        // And the other mid-session case: launched headed (no host at
3496        // launch), a host connects later. Stays headed for its lifetime.
3497        let headed = BrowserTools::new(std::env::temp_dir());
3498        let _ = headed.launched_headless.set(false);
3499        assert!(!headed.effective_headless(true));
3500    }
3501
3502    #[tokio::test]
3503    async fn any_host_connected_reflects_whatever_probe_is_installed() {
3504        let tools = BrowserTools::new(std::env::temp_dir());
3505        assert!(!tools.host_connected_for_test().await, "no probe = no host");
3506
3507        tools.set_host_connectivity(Arc::new(AlwaysConnected));
3508        assert!(tools.host_connected_for_test().await);
3509    }
3510
3511    #[tokio::test]
3512    async fn set_host_connectivity_is_a_once_lock_the_first_install_wins() {
3513        let tools = BrowserTools::new(std::env::temp_dir());
3514        tools.set_host_connectivity(Arc::new(AlwaysConnected));
3515        let flag = Arc::new(AtomicBool::new(false));
3516        tools.set_host_connectivity(Arc::new(SharedHostConnected(Arc::clone(&flag))));
3517        // The second install is silently ignored — matches every production
3518        // call site, which installs exactly once before any browse call.
3519        assert!(tools.host_connected_for_test().await);
3520    }
3521
3522    #[tokio::test]
3523    async fn shared_host_connected_reads_the_flag_live() {
3524        let flag = Arc::new(AtomicBool::new(false));
3525        let probe = SharedHostConnected(Arc::clone(&flag));
3526        assert!(!probe.any_host_connected().await);
3527        flag.store(true, Ordering::Release);
3528        assert!(probe.any_host_connected().await);
3529    }
3530
3531    #[tokio::test]
3532    async fn browser_await_signin_reports_the_host_gone_result_when_headless_with_no_host() {
3533        // The seam's headline case: launched headless (a host was connected
3534        // at launch), no host connected now. No live Chromium involved —
3535        // this returns before `session()` is ever called.
3536        let tools = BrowserTools::new(std::env::temp_dir());
3537        let _ = tools.launched_headless.set(true);
3538        tools.attach_agent_for_test().await;
3539
3540        let err = tools
3541            .run_await_signin_with(
3542                &json!({}),
3543                Duration::from_millis(50),
3544                Duration::from_millis(10),
3545            )
3546            .await
3547            .unwrap_err();
3548        assert_eq!(err, HOST_GONE_FOR_SIGNIN);
3549    }
3550
3551    #[tokio::test]
3552    async fn browser_await_signin_proceeds_past_the_gate_when_a_host_is_connected() {
3553        // Same headless instance, but a host IS connected — Task 4's drawer
3554        // event handles the sign-in from here, so this must NOT return the
3555        // host-gone result. (It still can't reach a live Chromium in this
3556        // suite, so this only proves the gate itself lets it through — the
3557        // error it hits next is `session()`'s launch failure, not this one.)
3558        let tools = BrowserTools::new(std::env::temp_dir());
3559        let _ = tools.launched_headless.set(true);
3560        tools.set_host_connectivity(Arc::new(AlwaysConnected));
3561        tools.attach_agent_for_test().await;
3562
3563        let host_connected = tools.any_host_connected().await;
3564        assert!(
3565            !tools.effective_headless(host_connected) || host_connected,
3566            "the gate must not fire while a host is connected"
3567        );
3568    }
3569
3570    #[tokio::test]
3571    async fn browser_await_signin_never_reports_host_gone_for_a_headed_browser() {
3572        // Pure-CLI browser (launched headed, no host ever connected): the
3573        // window itself is the visible surface regardless of host state —
3574        // the host-gone message must never fire for it.
3575        let tools = BrowserTools::new(std::env::temp_dir());
3576        let _ = tools.launched_headless.set(false);
3577        tools.attach_agent_for_test().await;
3578
3579        let host_connected = tools.any_host_connected().await;
3580        assert!(!tools.effective_headless(host_connected));
3581    }
3582}