use std::fs; use std::path::PathBuf; use std::time::Duration; #[cfg(windows)] use std::time::Instant; use chromiumoxide::async_process::{Child as BrowserChild, ChildStderr as BrowserChildStderr}; use chromiumoxide::browser::{Browser, BrowserConfig}; use chromiumoxide::handler::HandlerConfig; use futures::{AsyncBufReadExt, FutureExt, StreamExt}; use tempfile::{Builder as TempDirBuilder, TempDir}; use super::cdp::run_browser_validation; use super::discovery::discover_chrome_or_edge; use super::evidence::{ browser_validation_result_for_report, prepare_evidence_root, unix_time_ms, write_json_report, }; use super::model::{ BrowserIdentity, BrowserValidationInput, BrowserValidationResult, BROWSER_TIMEOUT, }; use super::network_policy::{preview_proxy_bypass_list, validate_input}; #[cfg(windows)] use crate::process_session::WindowsProcessJob; pub(super) const BROWSER_TEMP_PREFIX: &str = "ga-browser-"; pub(super) fn browser_process_temp_root() -> PathBuf { #[cfg(unix)] { PathBuf::from("/tmp") } #[cfg(not(unix))] { std::env::temp_dir() } } pub(super) fn create_browser_process_temp_dir() -> Result { TempDirBuilder::new() .prefix(BROWSER_TEMP_PREFIX) .tempdir_in(browser_process_temp_root()) .map_err(|error| format!("创建浏览器临时目录失败:{error}")) } fn browser_config( executable: &std::path::Path, temporary: &TempDir, bypass: &str, ) -> Result { let profile_path = temporary.path().join("profile"); fs::create_dir(&profile_path) .map_err(|error| format!("创建浏览器临时 Profile 失败:{error}"))?; BrowserConfig::builder() .chrome_executable(executable) .user_data_dir(profile_path) .env("TMPDIR", temporary.path().to_string_lossy().into_owned()) .new_headless_mode() .enable_request_intercept() .disable_cache() .disable_https_first() .request_timeout(BROWSER_TIMEOUT) .launch_timeout(BROWSER_TIMEOUT) .window_size(1280, 720) .arg(("proxy-server", "http://127.0.0.1:9")) .arg(("proxy-bypass-list", bypass)) .arg("block-new-web-contents") .arg("deny-permission-prompts") .arg("disable-notifications") .arg("disable-service-worker") .build() .map_err(|error| format!("构建浏览器配置失败:{error}")) } const BROWSER_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); const MAX_LAUNCH_STDERR_BYTES: usize = 64 * 1024; const MAX_WS_URL_LEN: usize = 512; /// 必须与 browser_config 的 builder 调用保持一致:request_timeout / /// enable_request_intercept / disable_cache 在这里逐项镜像;其余字段 /// (ignore_https_errors / ignore_invalid_messages / viewport)与 /// BrowserConfigBuilder 默认值相同,沿用 HandlerConfig::default()。 fn handler_config() -> HandlerConfig { let mut config = HandlerConfig::default(); config.request_timeout = BROWSER_TIMEOUT; config.request_intercept = true; config.cache_enabled = false; config } /// 从浏览器 stderr 行提取 DevTools ws 地址;只接受 loopback 端点, /// 避免伪造输出把 CDP 连接引导到任意地址。 fn parse_devtools_ws_url(line: &str) -> Option { let (_, ws) = line.rsplit_once("listening on ")?; let ws = ws.trim(); if ws.is_empty() || ws.len() > MAX_WS_URL_LEN || !ws.contains("devtools/browser") { return None; } let authority = ws.strip_prefix("ws://")?.split('/').next()?; let (host, port) = authority.rsplit_once(':')?; if !matches!(host, "127.0.0.1" | "localhost" | "[::1]") || port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) { return None; } Some(ws.to_string()) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OwnedBrowserLaunchError { SpawnFailed, WsTimeout, WsFailed, ConnectTimeout, ConnectFailed, } impl OwnedBrowserLaunchError { fn is_timeout(self) -> bool { matches!(self, Self::WsTimeout | Self::ConnectTimeout) } fn code(self) -> &'static str { match self { Self::SpawnFailed => "browser-spawn-failed", Self::WsTimeout => "browser-ws-timeout", Self::WsFailed => "browser-ws-failed", Self::ConnectTimeout => "browser-cdp-connect-timeout", Self::ConnectFailed => "browser-cdp-connect-failed", } } } type BrowserStderrReader = futures::io::BufReader; /// 复刻 chromiumoxide 私有 ws_url_from_output 的语义(读 stderr 直到 /// DevTools listening 行、提前退出即失败、整体有界),但 root 进程始终 /// 留在调用方手里,并把 stderr reader 交还给调用方持续排空。 async fn devtools_ws_url_from_stderr( child: &mut BrowserChild, timeout: Duration, ) -> Result<(String, BrowserStderrReader), OwnedBrowserLaunchError> { let stderr = child .stderr .take() .ok_or(OwnedBrowserLaunchError::SpawnFailed)?; let mut reader = futures::io::BufReader::new(stderr); let mut captured: Vec = Vec::new(); let mut exited = Box::pin(child.wait()).fuse(); let read = async { loop { let mut line = Vec::new(); futures::select! { _status = exited => return Err(OwnedBrowserLaunchError::WsFailed), result = reader.read_until(b'\n', &mut line).fuse() => { let count = result.map_err(|_| OwnedBrowserLaunchError::WsFailed)?; if count == 0 { return Err(OwnedBrowserLaunchError::WsFailed); } captured.extend_from_slice(&line); if captured.len() > MAX_LAUNCH_STDERR_BYTES { return Err(OwnedBrowserLaunchError::WsFailed); } let Ok(text) = std::str::from_utf8(&line) else { return Err(OwnedBrowserLaunchError::WsFailed); }; if let Some(url) = parse_devtools_ws_url(text) { return Ok(url); } } } } }; match tokio::time::timeout(timeout, read).await { Ok(Ok(url)) => Ok((url, reader)), Ok(Err(error)) => Err(error), Err(_) => Err(OwnedBrowserLaunchError::WsTimeout), } } fn spawn_stderr_drain(mut reader: BrowserStderrReader) -> tokio::task::JoinHandle<()> { // 持续排空 stderr:浏览器日志写满管道会整体 stall。内容有界、不留存。 tokio::spawn(async move { let mut buffer = Vec::with_capacity(4096); loop { buffer.clear(); match reader.read_until(b'\n', &mut buffer).await { Ok(0) | Err(_) => break, Ok(_) => {} } } }) } /// 自持浏览器进程:root 从 spawn 起归本结构所有;Windows 下整树挂进 /// KILL_ON_JOB_CLOSE 的 Job——失败路径、遗忘 shutdown、甚至宿主进程 /// 被杀,整棵树都会被回收,不再留下无父进程的无头浏览器。 struct BrowserProcessGuard { child: BrowserChild, #[cfg(windows)] job: Option, } impl BrowserProcessGuard { /// 整树收割:Job 终止全树,再 kill+wait root 兜底(Unix 与 Job /// 绑定前逸出的极早期子进程依赖 root 死亡的级联退出)。 async fn reap(&mut self) { #[cfg(windows)] if let Some(job) = &self.job { let _ = job.terminate(); } let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await; let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await; } async fn confirm_reaped(&self) -> Result<(), String> { #[cfg(windows)] if let Some(job) = &self.job { let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT; loop { if job .is_empty() .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? { return Ok(()); } if Instant::now() >= deadline { return Err("browser-tree-reap-unconfirmed".into()); } tokio::time::sleep(Duration::from_millis(20)).await; } } Ok(()) } } impl Drop for BrowserProcessGuard { fn drop(&mut self) { // Windows:Job 句柄关闭触发 KILL_ON_JOB_CLOSE 收掉整树; // Unix:tokio kill_on_drop 终止 root,子进程随 IPC 断开级联退出。 } } struct OwnedBrowser { browser: Browser, process: BrowserProcessGuard, handler_task: tokio::task::JoinHandle<()>, drain_task: tokio::task::JoinHandle<()>, } impl OwnedBrowser { fn browser(&self) -> &Browser { &self.browser } /// 优雅收束:CDP close → wait;失败则整树终止并确认收割。 async fn shutdown(mut self) -> Result<(), String> { let close = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.browser.close()).await; let waited = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.process.child.wait()).await; self.handler_task.abort(); self.drain_task.abort(); if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) { return Ok(()); } self.process.reap().await; self.process.confirm_reaped().await } } impl Drop for OwnedBrowser { fn drop(&mut self) { self.handler_task.abort(); self.drain_task.abort(); // 进程侧由 BrowserProcessGuard 的 Drop 兜底。 } } /// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出 /// 窗口:绑定前产生的子进程未入 Job,随 root 死亡级联退出;绑定之后 /// 的一切子进程由 Job 全覆盖。 async fn launch_owned_browser( config: BrowserConfig, executable: &std::path::Path, temp_root: &std::path::Path, ) -> Result { let child = config .launch() .map_err(|_| OwnedBrowserLaunchError::SpawnFailed)?; // 无法证明整树可收割时拒绝放行浏览器;child 随 drop 由 kill_on_drop 收尾。 #[cfg(windows)] let job = match WindowsProcessJob::assign_tokio(&child.inner) { Ok(job) => Some(job), Err(_) => return Err(OwnedBrowserLaunchError::SpawnFailed), }; let mut process = BrowserProcessGuard { child, #[cfg(windows)] job, }; // 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。 if let Some(pid) = process.child.inner.id() { super::sweep::write_browser_process_owner(temp_root, pid, executable); } let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await { Ok(pair) => pair, Err(error) => { process.reap().await; return Err(error); } }; let drain_task = spawn_stderr_drain(reader); let connected = tokio::time::timeout( BROWSER_TIMEOUT, Browser::connect_with_config(url, handler_config()), ) .await; let (browser, mut handler) = match connected { Ok(Ok(pair)) => pair, Ok(Err(_)) => { drain_task.abort(); process.reap().await; return Err(OwnedBrowserLaunchError::ConnectFailed); } Err(_) => { drain_task.abort(); process.reap().await; return Err(OwnedBrowserLaunchError::ConnectTimeout); } }; let handler_task = tokio::spawn(async move { while let Some(message) = handler.next().await { if message.is_err() { break; } } }); Ok(OwnedBrowser { browser, process, handler_task, drain_task, }) } /// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 /// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 pub(crate) async fn check_browser_health() -> Result { let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?; let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>") .map_err(|_| "browser-config-invalid")?; let owned = launch_owned_browser(config, &discovered.executable_path, temporary.path()) .await .map_err(|error| { if error.is_timeout() { "browser-start-timeout" } else { "browser-start-failed" } })?; let version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await; if owned.shutdown().await.is_err() { return Err("browser-cleanup-failed".into()); } let version = version .map_err(|_| "browser-cdp-timeout")? .map_err(|_| "browser-cdp-failed")?; if !safe_version_label(&version.product) || !safe_version_label(&version.protocol_version) { return Err("browser-version-invalid".into()); } Ok(BrowserIdentity { kind: discovered.kind, product: version.product, protocol_version: version.protocol_version, }) } fn safe_version_label(value: &str) -> bool { !value.is_empty() && value.len() <= 128 && value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'-' | b'_')) } pub async fn validate_local_preview_in_browser( input: BrowserValidationInput, ) -> Result { validate_local_preview_in_browser_with_interaction(input, false).await } /// Runs the same restricted Chromium validation, optionally adding one bounded, /// advisory click on a visible enabled control. The interaction is evidence for /// the direct Codex reviewer; it is not a fixed gameplay contract and never /// changes the deterministic browser safety checks. pub async fn validate_local_preview_in_browser_with_interaction( input: BrowserValidationInput, advisory_interaction: bool, ) -> Result { validate_local_preview_in_browser_with_cancellation(input, advisory_interaction, None).await } pub(crate) async fn validate_local_preview_in_browser_with_cancellation( input: BrowserValidationInput, advisory_interaction: bool, cancellation: Option>, ) -> Result { if cancellation .as_ref() .is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Acquire)) { return Err("宿主已停止本轮浏览器验证".into()); } let preview_url = validate_input(&input)?; prepare_evidence_root(&input.evidence_root)?; let browser_executable = discover_chrome_or_edge()?; let browser_temp = create_browser_process_temp_dir()?; let proxy_bypass_list = preview_proxy_bypass_list(&preview_url); let config = browser_config( &browser_executable.executable_path, &browser_temp, &proxy_bypass_list, )?; let owned = launch_owned_browser( config, &browser_executable.executable_path, browser_temp.path(), ) .await .map_err(|error| { if error.is_timeout() { "启动浏览器超时".to_string() } else { format!("启动浏览器失败:{}", error.code()) } })?; let work = run_browser_validation( owned.browser(), &browser_executable, &preview_url, &input, advisory_interaction, ); let cancelled = async { let Some(flag) = cancellation else { std::future::pending::<()>().await; return; }; while !flag.load(std::sync::atomic::Ordering::Acquire) { tokio::time::sleep(Duration::from_millis(50)).await; } }; let validation = tokio::select! { result=work => result, _=cancelled => Err("宿主已停止本轮浏览器验证".to_string()), }; if owned.shutdown().await.is_err() { return Err( "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(), ); } drop(browser_temp); let mut result = validation?; result.completed_at_unix_ms = unix_time_ms(); let persisted_result = browser_validation_result_for_report(&result)?; write_json_report(&result.evidence.report_path, &persisted_result)?; Ok(result) } #[cfg(test)] mod health_tests { use super::*; #[test] fn health_launch_uses_isolated_profiles_and_same_restricted_configuration() { let first = create_browser_process_temp_dir().unwrap(); let second = create_browser_process_temp_dir().unwrap(); let executable = std::env::current_exe().unwrap(); let first_config = browser_config(&executable, &first, "<-loopback>").unwrap(); let second_config = browser_config(&executable, &second, "<-loopback>").unwrap(); assert_ne!(first_config.user_data_dir, second_config.user_data_dir); assert!(first_config.user_data_dir.unwrap().is_dir()); assert!(!safe_version_label("Chrome/123\nTOKEN=secret")); assert!(safe_version_label("HeadlessChrome/140.0.1.2")); } #[test] fn devtools_ws_url_only_accepts_loopback_browser_endpoints() { assert_eq!( parse_devtools_ws_url( "DevTools listening on ws://127.0.0.1:53317/devtools/browser/abc-123\r\n" ) .as_deref(), Some("ws://127.0.0.1:53317/devtools/browser/abc-123") ); assert!(parse_devtools_ws_url( "DevTools listening on ws://localhost:9222/devtools/browser/x" ) .is_some()); assert!( parse_devtools_ws_url("DevTools listening on ws://[::1]:9222/devtools/browser/x") .is_some() ); // 非 loopback、非 ws 协议、非 browser 端点、非法端口一律拒绝。 assert!(parse_devtools_ws_url( "DevTools listening on ws://evil.example.com:9222/devtools/browser/x" ) .is_none()); assert!(parse_devtools_ws_url( "DevTools listening on http://127.0.0.1:9222/devtools/browser/x" ) .is_none()); assert!( parse_devtools_ws_url("DevTools listening on ws://127.0.0.1:9222/devtools/page/x") .is_none() ); assert!(parse_devtools_ws_url( "DevTools listening on ws://127.0.0.1:notaport/devtools/browser/x" ) .is_none()); assert!(parse_devtools_ws_url("random browser log line").is_none()); let overlong = format!( "DevTools listening on ws://127.0.0.1:9222/devtools/browser/{}", "a".repeat(MAX_WS_URL_LEN) ); assert!(parse_devtools_ws_url(&overlong).is_none()); } #[test] fn handler_config_mirrors_browser_config_builder() { let config = handler_config(); assert_eq!(config.request_timeout, BROWSER_TIMEOUT); assert!(config.request_intercept); assert!(!config.cache_enabled); // 其余字段必须与 BrowserConfigBuilder 默认值保持一致。 assert!(config.ignore_https_errors); assert!(config.ignore_invalid_messages); assert!(config.viewport.is_none()); } #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge; local CDP only"] async fn real_browser_health_checks_can_run_concurrently() { let (first, second) = tokio::join!(check_browser_health(), check_browser_health()); assert!(!first.unwrap().product.is_empty()); assert!(!second.unwrap().protocol_version.is_empty()); } #[tokio::test] #[ignore = "requires an installed browser; verifies cancellation after the real page is requested and cleanup completes"] async fn real_browser_budget_cancellation_closes_an_already_started_validation() { let page_requested = std::sync::Arc::new(tokio::sync::Notify::new()); let observed = std::sync::Arc::clone(&page_requested); let app=axum::Router::new().route("/",axum::routing::get(move || { let observed=std::sync::Arc::clone(&observed); async move { observed.notify_one(); axum::response::Html("") } })); let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) .await .unwrap(); let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let cancellation = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let flag = std::sync::Arc::clone(&cancellation); let cancel = tokio::spawn(async move { tokio::time::timeout(Duration::from_secs(20), page_requested.notified()) .await .expect("real browser requested page"); flag.store(true, std::sync::atomic::Ordering::Release); }); let temp = tempfile::tempdir().unwrap(); let result = validate_local_preview_in_browser_with_cancellation( BrowserValidationInput { url: format!("http://{address}/"), viewports: vec![ crate::browser::BrowserValidationViewport::Desktop, crate::browser::BrowserValidationViewport::Mobile, ], expected_text: Vec::new(), settle_ms: 4000, fail_on_console_error: true, playtest_scenario: None, evidence_root: temp.path().join("evidence"), }, false, Some(cancellation), ) .await; cancel.await.unwrap(); server.abort(); let _ = server.await; assert_eq!(result.unwrap_err(), "宿主已停止本轮浏览器验证"); } }