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