Skip to main content

glass/browser/session/
mod.rs

1//! Browser session management.
2//!
3//! The [`BrowserSession`] struct orchestrates a single browser session:
4//! launching or attaching to Chrome, managing CDP connections, routing
5//! operations to the active page target and frame, and providing the
6//! high-level API for navigation, interaction, observation, and more.
7//!
8//! Submodules implement specific capabilities:
9//! - **action**: clicks, typing, keyboard, scroll, drag
10//! - **batch**: ordered batch operations with policy pre-flight
11//! - **checkpoint**: cross-process session checkpoint export/import
12//! - **clipboard**: system clipboard read/write
13//! - **diagnostic**: scoped diagnostic evidence collection
14//! - **dialog**: JavaScript dialog handling
15//! - **diff**: accessibility tree diffing
16//! - **download**: scoped download lifecycle management
17//! - **emulation**: PDF generation, geolocation, timezone override
18//! - **fill**: high-level multi-field form filling
19//! - **frame**: frame tree discovery and selection
20//! - **har**: network recording (HAR-like capture)
21//! - **intercept**: CDP Fetch domain request interception
22//! - **locator**: element resolution with fallback chains
23//! - **navigate**: page navigation and JavaScript evaluation
24//! - **observe**: compact page observation
25//! - **popup**: popup window click and witness tracking
26//! - **retry**: retry policies for transport/CDP errors
27//! - **storage**: cookies, localStorage, sessionStorage
28//! - **target**: page target discovery, selection, creation
29//! - **targets**: parallel multi-target operations
30//! - **visual**: screenshots, visual capture, screencast
31//! - **wait**: page wait conditions and lifecycle detection
32//! - **webauthn**: virtual WebAuthn authenticator
33
34use base64::Engine;
35use base64::engine::general_purpose::STANDARD;
36use serde::{Deserialize, Serialize};
37use serde_json::Value;
38use std::collections::{HashMap, HashSet, VecDeque};
39use std::error::Error;
40use std::path::{Path, PathBuf};
41use std::sync::{
42    Arc,
43    atomic::{AtomicU64, Ordering},
44};
45use std::time::Duration;
46use sysinfo::{Pid, ProcessesToUpdate, System};
47use tokio::sync::Mutex;
48
49use super::cdp::CdpClient;
50use super::chrome::{
51    ChromeProcess, PortLaunchLock, check_chrome_health, get_browser_ws_url, get_ws_url,
52    is_port_occupied, launch_chrome_with_options, resolve_chrome_path,
53};
54use super::dom::{
55    CompactInteractiveElement, CompactRanking, DomNode, parse_accessibility_tree, parse_dom_tree,
56};
57use super::mouse::{MouseEngine, Point};
58use super::policy::{BrowserPolicy, PolicyCapability, PolicyError, PolicyPreset};
59use super::profile::ProfileManager;
60
61mod types;
62pub use consent::ConsentDismissalOutcome;
63pub use diff::{AccessibilityDiff, DiffChange, DiffElement, diff_accessibility};
64pub use emulation::{GeoLocation, NetworkConditions, PdfOptions};
65pub use fill::{FillFieldResult, FillFormOutcome};
66pub use har::{NetworkEntry, NetworkRecorder, NetworkRecording};
67pub use identity::{AgentIdentity, SignedHttpRequest};
68pub use intercept::{InterceptGuard, RequestPattern};
69pub use retry::{RetryPolicy, RetryPredicate};
70pub use types::*;
71pub use webauthn::{WebAuthnGuard, WebAuthnOptions};
72mod action;
73mod batch;
74mod checkpoint;
75mod clipboard;
76mod consent;
77mod diagnostic;
78mod dialog;
79mod diff;
80mod download;
81mod emulation;
82mod fill;
83mod frame;
84mod har;
85mod identity;
86mod intercept;
87mod locator;
88mod navigate;
89mod observe;
90mod polite;
91mod popup;
92mod retry;
93pub mod storage;
94pub use storage::{Cookie, StorageEntry, StorageItems};
95mod target;
96mod targets;
97mod visual;
98mod wait;
99mod webauthn;
100#[allow(private_interfaces)]
101pub struct BrowserSession {
102    pub(crate) cdp: CdpClient,
103    pub(crate) chrome: Option<ChromeProcess>,
104    pub(crate) disposable_profile: Option<DisposableProfileDir>,
105    pub(crate) launched_incognito_context_id: Option<String>,
106    pub(crate) profile: String,
107    pub(crate) interaction_mode: InteractionMode,
108    pub(crate) user_agent_original: Mutex<Option<String>>,
109    pub(crate) polite_last_request: Mutex<Option<tokio::time::Instant>>,
110    pub(crate) mouse: MouseEngine,
111    pub(crate) pointer: Mutex<Option<Point>>,
112    pub(crate) page_revision: Arc<AtomicU64>,
113    pub(crate) observation_cache: Mutex<Option<CachedObservation>>,
114    pub(crate) observation_context: Arc<Mutex<Option<CachedObservationContext>>>,
115    pub(crate) network_wait_leases: Arc<Mutex<NetworkLeaseState>>,
116    pub(crate) diagnostic_leases: Arc<Mutex<DiagnosticLeaseState>>,
117    pub(crate) download_scope: Arc<Mutex<()>>,
118    pub(crate) topology: Arc<Mutex<TopologyRegistry>>,
119    pub(crate) popup_click_scope: Mutex<()>,
120    pub(crate) upload_root: PathBuf,
121    pub(crate) policy: BrowserPolicy,
122    pub(crate) policy_interception: Option<PolicyInterception>,
123    pub(crate) audit_log: std::sync::Mutex<VecDeque<AuditEntry>>,
124    pub(crate) audit_sequence: AtomicU64,
125    pub(crate) audit_enabled: bool,
126}
127
128struct CachedObservation {
129    revision: u64,
130    context: CompactPageContext,
131}
132
133struct CachedObservationContext {
134    target_id: String,
135    session_id: Option<String>,
136    frame_id: String,
137    context_id: i64,
138}
139
140type PausedPolicyRequests = Arc<Mutex<HashSet<(Option<String>, String)>>>;
141
142struct PolicyInterception {
143    cdp: CdpClient,
144    sessions: Arc<Mutex<HashSet<String>>>,
145    paused: PausedPolicyRequests,
146    last_denial: Arc<Mutex<Option<PolicyError>>>,
147    worker: tokio::task::JoinHandle<()>,
148}
149
150impl PolicyInterception {
151    async fn start(
152        cdp: CdpClient,
153        policy: BrowserPolicy,
154        initial_session: String,
155    ) -> BrowserResult<Self> {
156        let mut events = cdp.subscribe_events_with_params();
157        let sessions = Arc::new(Mutex::new(HashSet::from([initial_session.clone()])));
158        let paused = Arc::new(Mutex::new(HashSet::new()));
159        let last_denial = Arc::new(Mutex::new(None));
160        let worker_cdp = cdp.clone();
161        let worker_sessions = Arc::clone(&sessions);
162        let worker_paused = Arc::clone(&paused);
163        let worker_denial = Arc::clone(&last_denial);
164        let worker = tokio::spawn(async move {
165            loop {
166                let event = match events.recv().await {
167                    Ok(event) => event,
168                    Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
169                        *worker_denial.lock().await = Some(PolicyError::Denied {
170                            operation: "navigation".to_string(),
171                            reason: format!(
172                                "policy event stream lagged by {count}; paused requests remain blocked"
173                            ),
174                        });
175                        continue;
176                    }
177                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
178                };
179                if event.method == "Target.attachedToTarget" {
180                    if let Some(session_id) = event.params["sessionId"].as_str() {
181                        let session_id = session_id.to_string();
182                        if enable_fetch_for(&worker_cdp, &session_id).await.is_ok() {
183                            worker_sessions.lock().await.insert(session_id.clone());
184                            let _ = worker_cdp
185                                .send_to_session(
186                                    &session_id,
187                                    "Runtime.runIfWaitingForDebugger",
188                                    None,
189                                )
190                                .await;
191                        }
192                    }
193                    continue;
194                }
195                if event.method != "Fetch.requestPaused" {
196                    continue;
197                }
198                let Some(request_id) = event.params["requestId"].as_str() else {
199                    continue;
200                };
201                let request_id = request_id.to_string();
202                let key = (event.session_id.clone(), request_id.clone());
203                worker_paused.lock().await.insert(key.clone());
204                let url = event.params["request"]["url"].as_str().unwrap_or_default();
205                let decision = policy.require_url(url).await;
206                let (method, params) = match decision {
207                    Ok(_) => (
208                        "Fetch.continueRequest",
209                        serde_json::json!({"requestId": &request_id}),
210                    ),
211                    Err(error) => {
212                        *worker_denial.lock().await = Some(error);
213                        (
214                            "Fetch.failRequest",
215                            serde_json::json!({
216                                "requestId": &request_id,
217                                "errorReason": "BlockedByClient"
218                            }),
219                        )
220                    }
221                };
222                let _ = match event.session_id.as_deref() {
223                    Some(session_id) => {
224                        worker_cdp
225                            .send_to_session(session_id, method, Some(params))
226                            .await
227                    }
228                    None => worker_cdp.send(method, Some(params)).await,
229                };
230                worker_paused.lock().await.remove(&key);
231            }
232        });
233        if let Err(error) = enable_fetch_for(&cdp, &initial_session).await {
234            worker.abort();
235            return Err(error);
236        }
237        Ok(Self {
238            cdp,
239            sessions,
240            paused,
241            last_denial,
242            worker,
243        })
244    }
245
246    async fn take_denial(&self) -> Option<PolicyError> {
247        self.last_denial.lock().await.take()
248    }
249
250    async fn shutdown(self) {
251        for (session_id, request_id) in self.paused.lock().await.clone() {
252            let params = Some(serde_json::json!({
253                "requestId": request_id,
254                "errorReason": "Aborted"
255            }));
256            let _ = match session_id.as_deref() {
257                Some(session_id) => {
258                    self.cdp
259                        .send_to_session(session_id, "Fetch.failRequest", params)
260                        .await
261                }
262                None => self.cdp.send("Fetch.failRequest", params).await,
263            };
264        }
265        for session_id in self.sessions.lock().await.clone() {
266            let _ = disable_fetch_for(&self.cdp, Some(&session_id)).await;
267        }
268        self.worker.abort();
269    }
270}
271
272/// A unique user-data directory owned by an incognito Glass session.
273///
274/// Chrome still receives `--incognito`; the fresh directory also prevents it
275/// from inheriting a user's default browser profile or leaving state behind
276/// after a normal Glass shutdown.
277#[derive(Debug)]
278struct DisposableProfileDir {
279    path: PathBuf,
280}
281
282const DISPOSABLE_OWNER_FILE: &str = ".glass-owner.json";
283const DISPOSABLE_CLEANUP_BATCH: usize = 1024;
284
285#[derive(Debug, Serialize, Deserialize)]
286struct DisposableProfileOwner {
287    pid: u32,
288    process_start: u64,
289}
290
291impl DisposableProfileDir {
292    fn create() -> BrowserResult<Self> {
293        static NEXT_DISPOSABLE_PROFILE: AtomicU64 = AtomicU64::new(0);
294
295        let root = std::env::temp_dir().join("glass");
296        std::fs::create_dir_all(&root)?;
297        Self::cleanup_abandoned(&root)?;
298        let pid = std::process::id();
299        let process_start = process_start_identity(pid)
300            .ok_or("could not determine Glass process start identity")?;
301        for _ in 0..32 {
302            let sequence = NEXT_DISPOSABLE_PROFILE.fetch_add(1, Ordering::Relaxed);
303            let nonce = format!(
304                "{}-{}-{sequence}",
305                std::process::id(),
306                chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
307            );
308            let path = root.join(format!("incognito-{nonce}"));
309            match std::fs::create_dir(&path) {
310                Ok(()) => {
311                    let owner = DisposableProfileOwner { pid, process_start };
312                    let owner_json = serde_json::to_vec(&owner)?;
313                    if let Err(error) = std::fs::write(path.join(DISPOSABLE_OWNER_FILE), owner_json)
314                    {
315                        let _ = std::fs::remove_dir_all(&path);
316                        return Err(error.into());
317                    }
318                    return Ok(Self { path });
319                }
320                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
321                Err(error) => return Err(error.into()),
322            }
323        }
324        Err("could not allocate a unique incognito user-data directory".into())
325    }
326
327    fn path(&self) -> &Path {
328        &self.path
329    }
330
331    fn cleanup_abandoned(root: &Path) -> BrowserResult<()> {
332        let mut candidates = Vec::new();
333        for entry in std::fs::read_dir(root)? {
334            let entry = entry?;
335            if !entry.file_type()?.is_dir()
336                || !entry
337                    .file_name()
338                    .to_string_lossy()
339                    .starts_with("incognito-")
340            {
341                continue;
342            }
343            let bytes = match std::fs::read(entry.path().join(DISPOSABLE_OWNER_FILE)) {
344                Ok(bytes) => bytes,
345                Err(_) => continue,
346            };
347            let owner = match serde_json::from_slice::<DisposableProfileOwner>(&bytes) {
348                Ok(owner) if owner.pid != 0 && owner.process_start != 0 => owner,
349                _ => continue,
350            };
351            candidates.push((entry.path(), owner));
352            if candidates.len() == DISPOSABLE_CLEANUP_BATCH {
353                reap_disposable_candidates(&mut candidates)?;
354            }
355        }
356        reap_disposable_candidates(&mut candidates)
357    }
358}
359
360fn reap_disposable_candidates(
361    candidates: &mut Vec<(PathBuf, DisposableProfileOwner)>,
362) -> BrowserResult<()> {
363    if candidates.is_empty() {
364        return Ok(());
365    }
366    let pids = candidates
367        .iter()
368        .map(|(_, owner)| Pid::from_u32(owner.pid))
369        .collect::<Vec<_>>();
370    let mut system = System::new();
371    system.refresh_processes(ProcessesToUpdate::Some(&pids), true);
372    for (path, owner) in candidates.drain(..) {
373        let live_start = system
374            .process(Pid::from_u32(owner.pid))
375            .map(|process| process.start_time());
376        if live_start != Some(owner.process_start)
377            && let Err(error) = std::fs::remove_dir_all(path)
378            && error.kind() != std::io::ErrorKind::NotFound
379        {
380            return Err(error.into());
381        }
382    }
383    Ok(())
384}
385
386fn process_start_identity(pid: u32) -> Option<u64> {
387    let pid = Pid::from_u32(pid);
388    let mut system = System::new();
389    system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
390    system.process(pid).map(|process| process.start_time())
391}
392
393impl Drop for DisposableProfileDir {
394    fn drop(&mut self) {
395        if let Err(error) = std::fs::remove_dir_all(&self.path)
396            && error.kind() != std::io::ErrorKind::NotFound
397        {
398            tracing::warn!(path = %self.path.display(), %error, "could not remove disposable incognito profile");
399        }
400    }
401}
402
403impl BrowserSession {
404    /// PID of Chrome launched by this session, absent for attached sessions.
405    pub fn owned_chrome_pid(&self) -> Option<u32> {
406        self.chrome.as_ref().map(|chrome| chrome.pid)
407    }
408
409    /// Number of CDP commands issued by this session's page connection.
410    pub fn cdp_request_count(&self) -> u64 {
411        self.cdp.request_count()
412    }
413
414    /// Total time spent awaiting responses from the page's CDP connection.
415    pub fn cdp_wait_nanos(&self) -> u64 {
416        self.cdp.cdp_wait_nanos()
417    }
418
419    /// Measure CDP response wait time initiated by one async operation.
420    pub async fn measure_cdp_wait<F>(&self, future: F) -> (F::Output, u64)
421    where
422        F: std::future::Future,
423    {
424        self.cdp.measure_cdp_wait(future).await
425    }
426
427    pub async fn start(options: &SessionOptions) -> BrowserResult<Self> {
428        let policy = match &options.policy {
429            Some(policy) => policy.clone(),
430            None => BrowserPolicy::development(std::env::current_dir()?)?,
431        };
432        Self::start_with_policy(options, policy).await
433    }
434
435    pub async fn start_with_policy(
436        options: &SessionOptions,
437        mut policy: BrowserPolicy,
438    ) -> BrowserResult<Self> {
439        options.validate()?;
440        if options.attach {
441            policy.require(PolicyCapability::Attach)?;
442        }
443        if !options.attach && !options.incognito {
444            policy.require(PolicyCapability::PersistentProfile)?;
445        }
446        let resolver_rules = policy.prepare_hardened_session(options.attach).await?;
447        let profile_manager = ProfileManager::new();
448        let mut disposable_profile = None;
449        let mut chrome = None;
450
451        // Hold an OS-backed lock until the launched child has been verified
452        // and its CDP connection is established. A second Glass process that
453        // starts at the same time will re-check the port after this session
454        // owns it instead of accepting our endpoint as its own.
455        let _launch_lock = if options.attach {
456            None
457        } else {
458            Some(PortLaunchLock::acquire(options.port).await?)
459        };
460
461        if options.attach {
462            if !check_chrome_health(options.port).await {
463                return Err(format!(
464                    "cannot attach: no healthy Chrome CDP endpoint is listening on port {}; start Chrome with remote debugging or choose another --port",
465                    options.port
466                )
467                .into());
468            }
469        } else {
470            if is_port_occupied(options.port).await {
471                return Err(format!(
472                    "CDP port {} is already occupied; use --attach to connect to that Chrome endpoint or choose another --port",
473                    options.port
474                )
475                .into());
476            }
477
478            let chrome_path = resolve_chrome_path(options.chrome_path.clone())
479                .ok_or("Chrome/Chromium not found; run install-chromium or pass --chrome-path")?;
480            let profile_dir = if options.incognito {
481                let directory = DisposableProfileDir::create()?;
482                let path = directory.path().to_path_buf();
483                disposable_profile = Some(directory);
484                path
485            } else {
486                profile_manager.ensure_profile_dir(&options.profile)?
487            };
488            chrome = Some(
489                launch_chrome_with_options(
490                    &chrome_path,
491                    options.port,
492                    Some(&profile_dir),
493                    options.headed,
494                    options.incognito,
495                    resolver_rules.as_deref(),
496                )
497                .await?,
498            );
499        }
500
501        let ws_url = match if options.attach {
502            get_ws_url(options.port, options.target_id.as_deref()).await
503        } else {
504            wait_for_ws_url(options.port, options.target_id.as_deref()).await
505        } {
506            Ok(url) => url,
507            Err(error) => {
508                if let Some(process) = chrome.as_mut() {
509                    let _ = process.shutdown().await;
510                }
511                return Err(error);
512            }
513        };
514        let target_id = ws_url
515            .rsplit('/')
516            .next()
517            .filter(|id| !id.is_empty())
518            .ok_or("page WebSocket URL contained no target ID")?
519            .to_string();
520        let browser_ws_url = get_browser_ws_url(options.port).await?;
521        let cdp = match CdpClient::connect(&browser_ws_url).await {
522            Ok(cdp) => cdp,
523            Err(error) => {
524                if let Some(process) = chrome.as_mut() {
525                    let _ = process.shutdown().await;
526                }
527                return Err(error);
528            }
529        };
530        let launched_incognito_context_id = if !options.attach && options.incognito {
531            match target_browser_context_id(&cdp, &target_id, true).await {
532                Ok(context_id) => context_id,
533                Err(error) => {
534                    cdp.close().await;
535                    if let Some(process) = chrome.as_mut() {
536                        let _ = process.shutdown().await;
537                    }
538                    return Err(error.into());
539                }
540            }
541        } else {
542            None
543        };
544
545        cdp.send_browser(
546            "Target.setDiscoverTargets",
547            Some(serde_json::json!({"discover": true})),
548        )
549        .await?;
550        let attached = cdp
551            .send_browser(
552                "Target.attachToTarget",
553                Some(serde_json::json!({"targetId": target_id, "flatten": true})),
554            )
555            .await?;
556        let session_id = attached["sessionId"]
557            .as_str()
558            .ok_or("Target.attachToTarget returned no sessionId")?
559            .to_string();
560        cdp.set_active_target_route(
561            Some(target_id.clone()),
562            Some(session_id.clone()),
563            None,
564            None,
565        );
566
567        let setup = cdp.enable_observation_events().await;
568        if let Err(error) = setup {
569            cdp.close().await;
570            if let Some(process) = chrome.as_mut() {
571                let _ = process.shutdown().await;
572            }
573            return Err(Box::new(error));
574        }
575        let policy_interception = if matches!(
576            policy.preset(),
577            PolicyPreset::Hardened | PolicyPreset::UntrustedMcp
578        ) {
579            Some(PolicyInterception::start(cdp.clone(), policy.clone(), session_id.clone()).await?)
580        } else {
581            None
582        };
583
584        let page_revision = Arc::new(AtomicU64::new(1));
585        let observation_context = Arc::new(Mutex::new(None));
586        let mut events = cdp.subscribe_events();
587        let revision_for_events = Arc::clone(&page_revision);
588        let observation_context_for_events = Arc::clone(&observation_context);
589        tokio::spawn(async move {
590            while let Ok(event) = events.recv().await {
591                if context_event_invalidates_observation(&event.method) {
592                    revision_for_events.fetch_add(1, Ordering::Relaxed);
593                }
594                if observation_context_invalidates(&event.method) {
595                    observation_context_for_events.lock().await.take();
596                }
597            }
598        });
599
600        let topology = Arc::new(Mutex::new(TopologyRegistry {
601            active_target_id: Some(target_id.clone()),
602            active_target_session_id: Some(session_id.clone()),
603            active_session_id: Some(session_id.clone()),
604            ..TopologyRegistry::default()
605        }));
606        let mut topology_events = cdp.subscribe_events_with_params();
607        let topology_for_events = Arc::clone(&topology);
608        let cdp_for_events = cdp.clone();
609        tokio::spawn(async move {
610            loop {
611                match topology_events.recv().await {
612                    Ok(event) => {
613                        let mut topology = topology_for_events.lock().await;
614                        let selected_frame = topology.active_frame_id.clone();
615                        let selected_session = topology.active_session_id.clone();
616                        let selected_context_invalidated = event.method == "Page.frameNavigated"
617                            && event.params["frame"]["id"].as_str() == selected_frame.as_deref();
618                        if apply_topology_event(&mut topology, &event) {
619                            cdp_for_events.set_active_target_route(None, None, None, None);
620                        } else if selected_frame.is_some() && topology.active_frame_id.is_none() {
621                            cdp_for_events.set_active_route(
622                                topology.active_session_id.clone(),
623                                None,
624                                None,
625                            );
626                        } else if selected_session != topology.active_session_id
627                            || selected_context_invalidated
628                        {
629                            cdp_for_events.set_active_route(
630                                topology.active_session_id.clone(),
631                                topology.active_frame_id.clone(),
632                                None,
633                            );
634                        }
635                    }
636                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
637                        topology_for_events.lock().await.event_loss_count += 1;
638                        let _ = resync_topology(&cdp_for_events, &topology_for_events).await;
639                    }
640                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
641                }
642            }
643        });
644        cdp.send_browser(
645            "Target.setAutoAttach",
646            Some(serde_json::json!({
647                "autoAttach": true,
648                "waitForDebuggerOnStart": matches!(
649                    policy.preset(),
650                    PolicyPreset::Hardened | PolicyPreset::UntrustedMcp
651                ),
652                "flatten": true
653            })),
654        )
655        .await?;
656
657        let session = Self {
658            cdp,
659            chrome,
660            disposable_profile,
661            launched_incognito_context_id,
662            profile: options.profile.clone(),
663            interaction_mode: options.interaction_mode,
664            user_agent_original: Mutex::new(None),
665            polite_last_request: Mutex::new(None),
666            mouse: MouseEngine::new(),
667            pointer: Mutex::new(None),
668            page_revision,
669            observation_cache: Mutex::new(None),
670            observation_context,
671            network_wait_leases: Arc::new(Mutex::new(NetworkLeaseState::default())),
672            diagnostic_leases: Arc::new(Mutex::new(DiagnosticLeaseState::default())),
673            download_scope: Arc::new(Mutex::new(())),
674            topology,
675            popup_click_scope: Mutex::new(()),
676            upload_root: std::fs::canonicalize(std::env::current_dir()?)?,
677            policy: policy.clone(),
678            policy_interception,
679            audit_log: std::sync::Mutex::new(VecDeque::new()),
680            audit_sequence: AtomicU64::new(1),
681            audit_enabled: options.audit,
682        };
683        let initialize_frame = async {
684            let frame_id = match options.frame_id.as_deref() {
685                Some(frame_id) => frame_id.to_string(),
686                None => {
687                    session
688                        .list_frames()
689                        .await?
690                        .into_iter()
691                        .next()
692                        .ok_or("active target returned no main frame")?
693                        .id
694                }
695            };
696            session.select_frame(&frame_id).await?;
697            Ok::<(), Box<dyn Error>>(())
698        }
699        .await;
700        if let Err(error) = initialize_frame {
701            let _ = session.close().await;
702            return Err(error);
703        }
704        Ok(session)
705    }
706
707    /// Explicit privileged escape hatch for benchmark and protocol diagnostics.
708    /// Hardened sessions deny it unless `raw-cdp` is deliberately allowed.
709    pub fn raw_cdp(&self) -> BrowserResult<&CdpClient> {
710        self.policy.require(PolicyCapability::RawCdp)?;
711        Ok(&self.cdp)
712    }
713
714    pub fn profile_name(&self) -> &str {
715        &self.profile
716    }
717
718    pub fn policy(&self) -> &BrowserPolicy {
719        &self.policy
720    }
721
722    /// Whether the Chrome process was explicitly attached rather than launched
723    /// by this session.
724    pub fn is_attached(&self) -> bool {
725        self.chrome.is_none()
726    }
727
728    /// Whether this session owns the Chrome process and will stop it on close.
729    pub fn owns_chrome(&self) -> bool {
730        self.chrome.is_some()
731    }
732
733    /// Override the viewport metrics for device emulation.
734    ///
735    /// Uses CDP `Emulation.setDeviceMetricsOverride`. Reset on session close
736    /// or call with `width: 0, height: 0` to clear.
737    pub async fn set_viewport(
738        &self,
739        width: i64,
740        height: i64,
741        device_scale_factor: Option<f64>,
742        is_mobile: Option<bool>,
743    ) -> BrowserResult<()> {
744        if width == 0 && height == 0 {
745            self.cdp.clear_device_metrics_override().await?;
746        } else {
747            self.cdp
748                .set_device_metrics_override(
749                    width,
750                    height,
751                    device_scale_factor.unwrap_or(1.0),
752                    is_mobile.unwrap_or(false),
753                )
754                .await?;
755        }
756        Ok(())
757    }
758
759    /// Build a bounded failure-trace pack suitable for agent self-correction.
760    ///
761    /// The returned bundle includes the last compact observation, an action outcome,
762    /// an error message, and the active topology state. It is deliberately bounded
763    /// (≤ 8 KiB), redacted for secrets, and excludes DOM/expression/screenshot
764    /// payloads. Agents can use this to decide whether to re-observe, select a
765    /// different target, or escalate without a full DOM round-trip.
766    pub async fn failure_trace(
767        &self,
768        outcome: ActionOutcome,
769        error: impl Into<String>,
770    ) -> FailureTracePack {
771        const MAX_TRACE_BYTES: usize = 8192;
772        const MAX_ERROR_BYTES: usize = 512;
773
774        let mut error_text = redact_diagnostic_text(&error.into());
775        if error_text.len() > MAX_ERROR_BYTES {
776            let mut end = MAX_ERROR_BYTES;
777            while end > 0 && !error_text.is_char_boundary(end) {
778                end -= 1;
779            }
780            error_text.truncate(end);
781        }
782
783        let last_observation =
784            self.observation_cache
785                .lock()
786                .await
787                .as_ref()
788                .map(|cached| CompactObservationTrace {
789                    page: PageInfo {
790                        url: redact_diagnostic_url(&cached.context.page.url),
791                        ..cached.context.page.clone()
792                    },
793                    revision: cached.revision,
794                    interactive: cached.context.accessibility.interactive.clone(),
795                    completeness: cached.context.accessibility.completeness.clone(),
796                });
797
798        let topology = {
799            let t = self.topology.lock().await;
800            TopologyTrace {
801                sequence: t.sequence,
802                active_target_id: t.active_target_id.clone(),
803                active_frame_id: t.active_frame_id.clone(),
804                target_count: t.targets.len(),
805                frame_count: t.frames.len(),
806                event_loss_count: t.event_loss_count,
807            }
808        };
809
810        let pack = FailureTracePack {
811            outcome,
812            error: error_text,
813            last_observation,
814            topology,
815            trace_bytes: 0,
816        };
817
818        // Measure and cap at MAX_TRACE_BYTES by dropping observation if needed
819        let serialized = serde_json::to_string(&pack).unwrap_or_default();
820        if serialized.len() <= MAX_TRACE_BYTES {
821            FailureTracePack {
822                trace_bytes: serialized.len(),
823                ..pack
824            }
825        } else {
826            // Drop the observation to fit within budget
827            FailureTracePack {
828                last_observation: None,
829                trace_bytes: 0,
830                ..pack
831            }
832        }
833    }
834
835    /// Build a failure trace for an operation that did not produce an
836    /// [`ActionOutcome`], such as navigation, waiting, or invalid input.
837    pub async fn failure_trace_for(
838        &self,
839        action: ActionKind,
840        error: impl Into<String>,
841    ) -> FailureTracePack {
842        let (target_id, frame_id) = {
843            let topology = self.topology.lock().await;
844            (
845                topology.active_target_id.clone().unwrap_or_default(),
846                topology.active_frame_id.clone().unwrap_or_default(),
847            )
848        };
849        self.failure_trace(
850            ActionOutcome {
851                action,
852                target: None,
853                revision: self.page_revision.load(Ordering::Relaxed),
854                target_id,
855                frame_id,
856                evidence: None,
857            },
858            error,
859        )
860        .await
861    }
862
863    /// Return a snapshot of the current session audit log.
864    ///
865    /// The log records high-risk operations (navigate, evaluate, upload,
866    /// download, attach) with bounded, redacted detail. It is only populated
867    /// when `--audit` is set on session start. Entries are bounded to
868    /// [`MAX_AUDIT_ENTRIES`]; the oldest are dropped on overflow.
869    pub fn audit_log(&self) -> Vec<AuditEntry> {
870        self.audit_log
871            .lock()
872            .map(|log| log.iter().cloned().collect())
873            .unwrap_or_default()
874    }
875
876    pub(crate) fn record_audit(&self, operation: &str, detail: impl Into<String>) {
877        if !self.audit_enabled {
878            return;
879        }
880        let sequence = self.audit_sequence.fetch_add(1, Ordering::Relaxed);
881        let detail_text = detail.into();
882        const MAX_AUDIT_DETAIL_BYTES: usize = 256;
883        let detail_text = truncate_utf8_bytes(&detail_text, MAX_AUDIT_DETAIL_BYTES);
884        let preset = format!("{:?}", self.policy.preset());
885        if let Ok(mut log) = self.audit_log.lock() {
886            log.push_back(AuditEntry {
887                sequence,
888                operation: operation.to_string(),
889                detail: detail_text,
890                policy_preset: preset,
891            });
892            while log.len() > MAX_AUDIT_ENTRIES {
893                log.pop_front();
894            }
895        }
896    }
897
898    pub async fn close(mut self) -> BrowserResult<()> {
899        // Emulation overrides are session-scoped even when attaching to a
900        // caller-owned browser. Clear them before detaching so the next
901        // consumer never inherits Glass's test conditions.
902        let _ = self.set_user_agent(None, None, None).await;
903        let _ = self
904            .cdp
905            .send(
906                "Network.emulateNetworkConditions",
907                Some(serde_json::json!({
908                    "offline": false,
909                    "latency": 0,
910                    "downloadThroughput": -1,
911                    "uploadThroughput": -1,
912                    "connectionType": "none"
913                })),
914            )
915            .await;
916        let _ = self
917            .cdp
918            .send(
919                "Emulation.setCPUThrottlingRate",
920                Some(serde_json::json!({"rate": 1.0})),
921            )
922            .await;
923        let _ = self.cdp.clear_device_metrics_override().await;
924        if self.chrome.is_some() {
925            // `Browser.close` lets Chrome commit profile-backed storage before
926            // the owned child process falls back to termination below. A page
927            // target can close its websocket before replying, so this is best
928            // effort and intentionally bounded.
929            let _ =
930                tokio::time::timeout(OWNED_BROWSER_CLOSE_TIMEOUT, self.cdp.close_browser()).await;
931        }
932        if let Some(interception) = self.policy_interception.take() {
933            interception.shutdown().await;
934        }
935        self.cdp.close().await;
936        let shutdown_result = if let Some(process) = self.chrome.as_mut() {
937            process.shutdown().await
938        } else {
939            Ok(())
940        };
941        self.chrome = None;
942        // Drop after the owned child has stopped so Chrome no longer holds
943        // files in the disposable user-data directory.
944        drop(self.disposable_profile.take());
945        shutdown_result
946    }
947}
948
949#[cfg(test)]
950mod tests;