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 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#[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 pub fn owned_chrome_pid(&self) -> Option<u32> {
485 self.chrome.as_ref().map(|chrome| chrome.pid)
486 }
487
488 pub fn cdp_request_count(&self) -> u64 {
490 self.cdp.request_count()
491 }
492
493 pub fn cdp_wait_nanos(&self) -> u64 {
495 self.cdp.cdp_wait_nanos()
496 }
497
498 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 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 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 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 pub fn is_attached(&self) -> bool {
818 self.chrome.is_none()
819 }
820
821 pub fn owns_chrome(&self) -> bool {
823 self.chrome.is_some()
824 }
825
826 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 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 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 FailureTracePack {
911 last_observation: None,
912 trace_bytes: 0,
913 ..pack
914 }
915 }
916 }
917
918 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 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 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 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(self.disposable_profile.take());
1033 shutdown_result
1034 }
1035}
1036
1037#[cfg(test)]
1038mod tests;