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