1use 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#[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 pub fn owned_chrome_pid(&self) -> Option<u32> {
406 self.chrome.as_ref().map(|chrome| chrome.pid)
407 }
408
409 pub fn cdp_request_count(&self) -> u64 {
411 self.cdp.request_count()
412 }
413
414 pub fn cdp_wait_nanos(&self) -> u64 {
416 self.cdp.cdp_wait_nanos()
417 }
418
419 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 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 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 pub fn is_attached(&self) -> bool {
725 self.chrome.is_none()
726 }
727
728 pub fn owns_chrome(&self) -> bool {
730 self.chrome.is_some()
731 }
732
733 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 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 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 FailureTracePack {
828 last_observation: None,
829 trace_bytes: 0,
830 ..pack
831 }
832 }
833 }
834
835 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 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 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 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(self.disposable_profile.take());
945 shutdown_result
946 }
947}
948
949#[cfg(test)]
950mod tests;