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}