1use 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#[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 pub fn owned_chrome_pid(&self) -> Option<u32> {
475 self.chrome.as_ref().map(|chrome| chrome.pid)
476 }
477
478 pub fn cdp_request_count(&self) -> u64 {
480 self.cdp.request_count()
481 }
482
483 pub fn cdp_wait_nanos(&self) -> u64 {
485 self.cdp.cdp_wait_nanos()
486 }
487
488 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 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 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 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 pub fn is_attached(&self) -> bool {
808 self.chrome.is_none()
809 }
810
811 pub fn owns_chrome(&self) -> bool {
813 self.chrome.is_some()
814 }
815
816 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 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 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 FailureTracePack {
901 last_observation: None,
902 trace_bytes: 0,
903 ..pack
904 }
905 }
906 }
907
908 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 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 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 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(self.disposable_profile.take());
1023 shutdown_result
1024 }
1025}
1026
1027#[cfg(test)]
1028mod tests;