diff --git a/Cargo.lock b/Cargo.lock index a1b9471..16904ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1465,7 +1465,7 @@ dependencies = [ [[package]] name = "ras-agent" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "chrono", @@ -1497,7 +1497,7 @@ dependencies = [ [[package]] name = "ras-browser" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-cdp", @@ -1515,7 +1515,7 @@ dependencies = [ [[package]] name = "ras-cdp" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "chromiumoxide", @@ -1534,7 +1534,7 @@ dependencies = [ [[package]] name = "ras-cli" -version = "3.1.0" +version = "3.2.0" dependencies = [ "anyhow", "clap", @@ -1564,7 +1564,7 @@ dependencies = [ [[package]] name = "ras-cloud" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1581,7 +1581,7 @@ dependencies = [ [[package]] name = "ras-config" -version = "3.1.0" +version = "3.2.0" dependencies = [ "dotenvy", "once_cell", @@ -1595,7 +1595,7 @@ dependencies = [ [[package]] name = "ras-cosmium" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-cdp", @@ -1612,7 +1612,7 @@ dependencies = [ [[package]] name = "ras-daemon" -version = "3.1.0" +version = "3.2.0" dependencies = [ "anyhow", "dotenvy", @@ -1632,7 +1632,7 @@ dependencies = [ [[package]] name = "ras-dom" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "chromiumoxide", @@ -1652,7 +1652,7 @@ dependencies = [ [[package]] name = "ras-errors" -version = "3.1.0" +version = "3.2.0" dependencies = [ "serde", "thiserror", @@ -1660,7 +1660,7 @@ dependencies = [ [[package]] name = "ras-events" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-broadcast", "async-trait", @@ -1678,7 +1678,7 @@ dependencies = [ [[package]] name = "ras-filesystem" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1693,7 +1693,7 @@ dependencies = [ [[package]] name = "ras-judge" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "image", @@ -1708,7 +1708,7 @@ dependencies = [ [[package]] name = "ras-llm" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1722,7 +1722,7 @@ dependencies = [ [[package]] name = "ras-llm-anthropic" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "chrono", @@ -1745,7 +1745,7 @@ dependencies = [ [[package]] name = "ras-llm-bedrock" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1762,7 +1762,7 @@ dependencies = [ [[package]] name = "ras-llm-cerebras" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1780,7 +1780,7 @@ dependencies = [ [[package]] name = "ras-llm-cloud" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "ras-llm-deepseek" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1815,7 +1815,7 @@ dependencies = [ [[package]] name = "ras-llm-google" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1832,7 +1832,7 @@ dependencies = [ [[package]] name = "ras-llm-groq" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "ras-llm-langchain" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1867,7 +1867,7 @@ dependencies = [ [[package]] name = "ras-llm-mistral" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1885,7 +1885,7 @@ dependencies = [ [[package]] name = "ras-llm-oci" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1902,7 +1902,7 @@ dependencies = [ [[package]] name = "ras-llm-ollama" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1919,7 +1919,7 @@ dependencies = [ [[package]] name = "ras-llm-openai" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1936,7 +1936,7 @@ dependencies = [ [[package]] name = "ras-llm-openrouter" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1954,7 +1954,7 @@ dependencies = [ [[package]] name = "ras-llm-vercel" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1972,7 +1972,7 @@ dependencies = [ [[package]] name = "ras-mcp" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -1989,7 +1989,7 @@ dependencies = [ [[package]] name = "ras-recording" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "image", @@ -2004,7 +2004,7 @@ dependencies = [ [[package]] name = "ras-sandbox" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -2017,7 +2017,7 @@ dependencies = [ [[package]] name = "ras-skills" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -2034,7 +2034,7 @@ dependencies = [ [[package]] name = "ras-telemetry" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -2048,7 +2048,7 @@ dependencies = [ [[package]] name = "ras-tokens" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "chrono", @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "ras-tools" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "indexmap", @@ -2087,7 +2087,7 @@ dependencies = [ [[package]] name = "ras-types" -version = "3.1.0" +version = "3.2.0" dependencies = [ "chrono", "indexmap", @@ -2103,7 +2103,7 @@ dependencies = [ [[package]] name = "ras-validation" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-errors", @@ -2117,7 +2117,7 @@ dependencies = [ [[package]] name = "ras-watchdogs" -version = "3.1.0" +version = "3.2.0" dependencies = [ "async-trait", "ras-browser", diff --git a/Cargo.toml b/Cargo.toml index 3118313..eab840b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ exclude = ["examples", "tests", "docs", "scripts"] default-members = ["ras-cli", "ras-daemon"] [workspace.package] -version = "3.1.0" +version = "3.2.0" edition = "2024" rust-version = "1.95.0" license = "MIT" diff --git a/ras-cdp/Cargo.toml b/ras-cdp/Cargo.toml index d520f35..1770aee 100644 --- a/ras-cdp/Cargo.toml +++ b/ras-cdp/Cargo.toml @@ -27,4 +27,7 @@ tracing = { workspace = true } url = { workspace = true } [dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +url = { workspace = true } +serde_json = { workspace = true } +ras-types = { workspace = true } diff --git a/ras-cdp/src/domain/repository.rs b/ras-cdp/src/domain/repository.rs index c8e75f6..00b4e58 100644 --- a/ras-cdp/src/domain/repository.rs +++ b/ras-cdp/src/domain/repository.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; use ras_errors::AppError; -use ras_types::{BackendNodeId, TargetId}; +use ras_types::{BackendNodeId, ContextId, TargetId}; use serde::{Deserialize, Serialize}; use url::Url; @@ -64,4 +64,32 @@ pub trait BrowserPort: Send + Sync + 'static { async fn clear_cookies(&self, target: &TargetId, origin: &str) -> Result<(), AppError>; async fn close_target(&self, target: &TargetId) -> Result<(), AppError>; async fn create_target(&self, url: &Url) -> Result; + + /// Create a fresh isolated browser context (separate cookies/storage). + async fn create_context(&self) -> Result { + Err(AppError::ActionFailed( + "create_context not supported by this BrowserPort".into(), + )) + } + + /// Dispose a context, freeing its cookies/storage/tabs. + async fn close_context(&self, _ctx: &ContextId) -> Result<(), AppError> { + Err(AppError::ActionFailed( + "close_context not supported by this BrowserPort".into(), + )) + } + + /// Open a new tab inside a specific context. + async fn new_target_in(&self, _ctx: &ContextId, _url: &Url) -> Result { + Err(AppError::ActionFailed( + "new_target_in not supported by this BrowserPort".into(), + )) + } + + /// List only the tabs belonging to a specific context. + async fn list_targets_in(&self, _ctx: &ContextId) -> Result, AppError> { + Err(AppError::ActionFailed( + "list_targets_in not supported by this BrowserPort".into(), + )) + } } diff --git a/ras-cdp/src/infrastructure/chromiumoxide_adapter.rs b/ras-cdp/src/infrastructure/chromiumoxide_adapter.rs index 0120941..c8eae14 100644 --- a/ras-cdp/src/infrastructure/chromiumoxide_adapter.rs +++ b/ras-cdp/src/infrastructure/chromiumoxide_adapter.rs @@ -6,7 +6,7 @@ use chromiumoxide::Browser; use chromiumoxide::handler::HandlerConfig; use futures::StreamExt; use ras_errors::AppError; -use ras_types::{BackendNodeId, TargetId}; +use ras_types::{BackendNodeId, ContextId, TargetId}; use tokio::sync::Mutex; use tracing::warn; use url::Url; @@ -19,6 +19,9 @@ use crate::infrastructure::cdp_ext::{ }; use crate::infrastructure::chromiumoxide_helpers::{list_target_ids, new_target, page_for}; use crate::infrastructure::chromiumoxide_input::{click_backend_node, type_chars}; +use crate::infrastructure::context_ops::{ + create_context, dispose_context, list_targets_in, new_target_in, +}; use crate::infrastructure::mouse_input::{ dispatch_mouse_hold, dispatch_mouse_move, dispatch_mouse_press, dispatch_mouse_release, }; @@ -182,4 +185,16 @@ impl BrowserPort for ChromiumoxideAdapter { async fn create_target(&self, url: &Url) -> Result { new_target(&self.browser, url).await } + async fn create_context(&self) -> Result { + create_context(&self.browser).await + } + async fn close_context(&self, ctx: &ContextId) -> Result<(), AppError> { + dispose_context(&self.browser, ctx).await + } + async fn new_target_in(&self, ctx: &ContextId, url: &Url) -> Result { + new_target_in(&self.browser, ctx, url).await + } + async fn list_targets_in(&self, ctx: &ContextId) -> Result, AppError> { + list_targets_in(&self.browser, ctx).await + } } diff --git a/ras-cdp/src/infrastructure/context_ops.rs b/ras-cdp/src/infrastructure/context_ops.rs new file mode 100644 index 0000000..ec1fa35 --- /dev/null +++ b/ras-cdp/src/infrastructure/context_ops.rs @@ -0,0 +1,117 @@ +use std::sync::Arc; + +use chromiumoxide::Browser; +use chromiumoxide::cdp::browser_protocol::browser::{ + BrowserContextId, SetDownloadBehaviorBehavior, SetDownloadBehaviorParams, +}; +use chromiumoxide::cdp::browser_protocol::storage::ClearCookiesParams; +use chromiumoxide::cdp::browser_protocol::target::{ + CreateBrowserContextParams, CreateTargetParams, GetTargetsParams, +}; +use ras_errors::AppError; +use ras_types::{ContextId, TargetId}; +use tokio::sync::Mutex; +use url::Url; + +fn to_cdp(ctx: &ContextId) -> BrowserContextId { + BrowserContextId::new(ctx.0.to_string()) +} + +/// Create an isolated context and point its downloads at a per-context directory. +pub(crate) async fn create_context(browser: &Arc>) -> Result { + let guard = browser.lock().await; + let id = guard + .create_browser_context(CreateBrowserContextParams::default()) + .await + .map_err(|e| AppError::ActionFailed(format!("createBrowserContext: {e}")))?; + let ctx = ContextId(id.inner().as_str().into()); + drop(guard); + if let Err(e) = set_context_download_dir(browser, &ctx).await { + let _ = dispose_context(browser, &ctx).await; + return Err(e); + } + Ok(ctx) +} + +pub(crate) async fn dispose_context( + browser: &Arc>, + ctx: &ContextId, +) -> Result<(), AppError> { + let guard = browser.lock().await; + guard + .dispose_browser_context(to_cdp(ctx)) + .await + .map_err(|e| AppError::ActionFailed(format!("disposeBrowserContext: {e}"))) +} + +pub(crate) async fn new_target_in( + browser: &Arc>, + ctx: &ContextId, + url: &Url, +) -> Result { + let mut params = CreateTargetParams::new(url.as_str()); + params.browser_context_id = Some(to_cdp(ctx)); + let guard = browser.lock().await; + let page = guard + .new_page(params) + .await + .map_err(|e| AppError::ActionFailed(format!("new_page in context: {e}")))?; + Ok(TargetId(page.target_id().as_ref().into())) +} + +pub(crate) async fn list_targets_in( + browser: &Arc>, + ctx: &ContextId, +) -> Result, AppError> { + let wanted = to_cdp(ctx); + let guard = browser.lock().await; + let resp = guard + .execute(GetTargetsParams::default()) + .await + .map_err(|e| AppError::BrowserDisconnected(format!("getTargets: {e}")))?; + Ok(resp + .result + .target_infos + .iter() + .filter(|t| t.browser_context_id.as_ref() == Some(&wanted)) + .filter(|t| t.r#type == "page") + .map(|t| TargetId(t.target_id.as_ref().into())) + .collect()) +} + +#[allow(dead_code)] +pub(crate) async fn clear_context_cookies( + browser: &Arc>, + ctx: &ContextId, +) -> Result<(), AppError> { + let params = ClearCookiesParams::builder() + .browser_context_id(to_cdp(ctx)) + .build(); + let guard = browser.lock().await; + guard + .execute(params) + .await + .map_err(|e| AppError::ActionFailed(format!("storage.clearCookies: {e}")))?; + Ok(()) +} + +async fn set_context_download_dir( + browser: &Arc>, + ctx: &ContextId, +) -> Result<(), AppError> { + let dir = std::env::temp_dir() + .join("ras-downloads") + .join(ctx.0.as_str()); + tokio::fs::create_dir_all(&dir) + .await + .map_err(|e| AppError::ActionFailed(format!("download dir: {e}")))?; + let mut params = SetDownloadBehaviorParams::new(SetDownloadBehaviorBehavior::AllowAndName); + params.browser_context_id = Some(to_cdp(ctx)); + params.download_path = Some(dir.to_string_lossy().into_owned()); + let guard = browser.lock().await; + guard + .execute(params) + .await + .map_err(|e| AppError::ActionFailed(format!("setDownloadBehavior: {e}")))?; + Ok(()) +} diff --git a/ras-cdp/src/infrastructure/mod.rs b/ras-cdp/src/infrastructure/mod.rs index ffd5c7f..b302e3a 100644 --- a/ras-cdp/src/infrastructure/mod.rs +++ b/ras-cdp/src/infrastructure/mod.rs @@ -2,5 +2,6 @@ pub mod cdp_ext; pub mod chromiumoxide_adapter; pub mod chromiumoxide_helpers; pub mod chromiumoxide_input; +pub mod context_ops; pub mod mouse_input; pub mod timeout; diff --git a/ras-cdp/tests/e2e_context_isolation.rs b/ras-cdp/tests/e2e_context_isolation.rs new file mode 100644 index 0000000..c754d8e --- /dev/null +++ b/ras-cdp/tests/e2e_context_isolation.rs @@ -0,0 +1,89 @@ +use std::time::Duration; + +use ras_cdp::BrowserPort; +use ras_cdp::infrastructure::chromiumoxide_adapter::ChromiumoxideAdapter; +use ras_types::{ContextId, TargetId}; +use url::Url; + +fn cdp_url() -> Option { + std::env::var("CDP_URL").ok()?.parse().ok() +} + +fn test_url() -> Url { + std::env::var("TEST_URL") + .unwrap_or_else(|_| "http://127.0.0.1:8731/".to_string()) + .parse() + .expect("TEST_URL parse") +} + +async fn adapter() -> ChromiumoxideAdapter { + let url = cdp_url().expect("set CDP_URL to a running chrome --remote-debugging-port"); + ChromiumoxideAdapter::connect(url, Duration::from_secs(30)) + .await + .expect("connect") +} + +async fn cookie_len(a: &ChromiumoxideAdapter, target: &TargetId) -> i64 { + let v = a + .evaluate(target, "document.cookie.length") + .await + .expect("eval cookie len"); + v.as_i64().unwrap_or(-1) +} + +#[tokio::test] +#[ignore] +async fn contexts_isolate_cookies() { + let a = adapter().await; + let ctx_a: ContextId = a.create_context().await.expect("ctx a"); + let ctx_b: ContextId = a.create_context().await.expect("ctx b"); + let url = test_url(); + + let tab_a = a.new_target_in(&ctx_a, &url).await.expect("tab a"); + let tab_b = a.new_target_in(&ctx_b, &url).await.expect("tab b"); + tokio::time::sleep(Duration::from_millis(900)).await; + + a.evaluate(&tab_a, "document.cookie = 'ras_iso=1; path=/'") + .await + .expect("set cookie in A"); + + assert!( + cookie_len(&a, &tab_a).await > 0, + "A should see its own cookie" + ); + assert_eq!(cookie_len(&a, &tab_b).await, 0, "B must NOT see A's cookie"); + + a.close_context(&ctx_a).await.expect("close a"); + let after = a.list_targets_in(&ctx_a).await.unwrap_or_default(); + assert!( + !after.contains(&tab_a), + "closed context must no longer list its tab" + ); + a.close_context(&ctx_b).await.expect("close b"); +} + +#[tokio::test] +#[ignore] +async fn list_targets_in_is_context_scoped() { + let a = adapter().await; + let ctx_a = a.create_context().await.expect("ctx a"); + let ctx_b = a.create_context().await.expect("ctx b"); + let url = test_url(); + let tab_a = a.new_target_in(&ctx_a, &url).await.expect("tab a"); + let tab_b = a.new_target_in(&ctx_b, &url).await.expect("tab b"); + + let in_a = a.list_targets_in(&ctx_a).await.expect("list a"); + let in_b = a.list_targets_in(&ctx_b).await.expect("list b"); + + assert!( + in_a.contains(&tab_a) && !in_a.contains(&tab_b), + "A lists only A's tab" + ); + assert!( + in_b.contains(&tab_b) && !in_b.contains(&tab_a), + "B lists only B's tab" + ); + + a.close_context(&ctx_a).await.expect("close a"); + a.close_context(&ctx_b).await.expect("close b"); +} diff --git a/ras-types/src/domain/ids.rs b/ras-types/src/domain/ids.rs index 8fd7f91..4f30a26 100644 --- a/ras-types/src/domain/ids.rs +++ b/ras-types/src/domain/ids.rs @@ -38,3 +38,20 @@ pub struct BackendNodeId(pub i64); #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct ActionName(pub SmolStr); + +/// Identifies an isolated CDP browser context (one per tenant session). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ContextId(pub SmolStr); + +#[cfg(test)] +mod context_id_tests { + use super::ContextId; + + #[test] + fn context_id_roundtrips_through_string() { + let c = ContextId("ABC123".into()); + assert_eq!(c.0.as_str(), "ABC123"); + assert_eq!(c.clone(), c); + } +} diff --git a/ras-types/src/lib.rs b/ras-types/src/lib.rs index 222d852..a9265e6 100644 --- a/ras-types/src/lib.rs +++ b/ras-types/src/lib.rs @@ -3,7 +3,7 @@ pub mod domain; pub mod infrastructure; pub use domain::action_result::ActionResult; -pub use domain::ids::{ActionName, AgentId, BackendNodeId, SessionId, StepId, TargetId}; +pub use domain::ids::{ActionName, AgentId, BackendNodeId, ContextId, SessionId, StepId, TargetId}; pub use domain::responses::{ListResponse, PaginationMeta, SingleResponse}; pub use domain::timing::{ActionTimeout, ActionTimeoutError}; pub use domain::url_pattern::{DomainPattern, DomainPatternError, MatchLevel};