diff --git a/src/apps/desktop/src/api/browser_control_api.rs b/src/apps/desktop/src/api/browser_control_api.rs index 1a7731a4d5..209948a7fa 100644 --- a/src/apps/desktop/src/api/browser_control_api.rs +++ b/src/apps/desktop/src/api/browser_control_api.rs @@ -91,6 +91,8 @@ pub async fn browser_control_list_browsers() -> Result, pub port: u16, @@ -103,11 +105,40 @@ pub async fn browser_control_get_status( request: BrowserControlStatusRequest, ) -> Result { let port = request.port; - let available = BrowserLauncher::is_cdp_available(port).await; let configured_kind = selected_browser_kind().await?; + let default_cdp_supported = BrowserLauncher::supports_default_cdp(&configured_kind); + let default_cdp_enabled = BrowserLauncher::is_default_cdp_enabled(&configured_kind); + let user_profile_connection = + CdpClient::browser_connection_for_kind(port, &configured_kind).await; + let legacy_version = + if user_profile_connection.is_none() && BrowserLauncher::is_cdp_available(port).await { + CdpClient::get_version(port).await.ok() + } else { + None + }; + // Chrome and Edge share the logical 9222 slot in Settings. Do not report + // the selected browser as connected merely because the other one owns a + // legacy fixed-port endpoint left from an earlier selection. + let legacy_matches_selection = legacy_version.as_ref().is_some_and(|version| { + let detected = version + .browser + .as_deref() + .and_then(BrowserLauncher::browser_kind_from_cdp_version); + match &configured_kind { + BrowserKind::Chrome | BrowserKind::Edge => { + detected.map(|kind| kind == configured_kind).unwrap_or(true) + } + _ => true, + } + }); + let available = user_profile_connection.is_some() || legacy_matches_selection; let (version, page_count, actual_kind) = if available { - let ver_info = CdpClient::get_version(port).await.ok(); + let ver_info = if let Some(connection) = &user_profile_connection { + connection.client.browser_version().await.ok() + } else { + legacy_version + }; let ver = ver_info.as_ref().and_then(|v| v.browser.clone()); // Identify the actual browser from CDP version response. let kind = ver @@ -116,15 +147,17 @@ pub async fn browser_control_get_status( .unwrap_or_else(|| configured_kind.clone()); // Only count targets of type "page" (real browser tabs), // not service workers, browser targets, etc. - let pages = CdpClient::list_pages(port) - .await - .ok() - .map(|p| { - p.iter() - .filter(|t| t.page_type.as_deref() == Some("page")) - .count() - }) - .unwrap_or(0); + let pages = if let Some(connection) = &user_profile_connection { + connection.client.browser_pages().await.ok() + } else { + CdpClient::list_pages(port).await.ok() + } + .map(|p| { + p.iter() + .filter(|t| t.page_type.as_deref() == Some("page")) + .count() + }) + .unwrap_or(0); (ver, pages, kind) } else { (None, 0, configured_kind) @@ -132,6 +165,8 @@ pub async fn browser_control_get_status( Ok(BrowserControlStatusResponse { cdp_available: available, + default_cdp_supported, + default_cdp_enabled, browser_kind: actual_kind.to_string(), browser_version: version, port, @@ -153,6 +188,10 @@ pub struct BrowserControlLaunchResponse { pub status: String, pub message: Option, pub browser_kind: String, + /// Remote debugging settings URL, sent when the user has to open it + /// themselves because the platform cannot open a `chrome://` URL for them. + #[serde(skip_serializing_if = "Option::is_none")] + pub setup_url: Option, } fn to_launch_response(kind: &BrowserKind, result: LaunchResult) -> BrowserControlLaunchResponse { @@ -162,18 +201,47 @@ fn to_launch_response(kind: &BrowserKind, result: LaunchResult) -> BrowserContro status: "already_connected".into(), message: None, browser_kind: kind.to_string(), + setup_url: None, }, LaunchResult::Launched => BrowserControlLaunchResponse { success: true, status: "launched".into(), message: None, browser_kind: kind.to_string(), + setup_url: None, + }, + LaunchResult::UserProfileReady { .. } => BrowserControlLaunchResponse { + success: false, + status: "user_profile_ready".into(), + message: None, + browser_kind: kind.to_string(), + setup_url: None, + }, + LaunchResult::UserProfileSetupRequired { + instructions, + setup_url, + opened, + .. + } => BrowserControlLaunchResponse { + success: false, + // The two cases need different guidance: one asks the user to + // finish on a page that is already in front of them, the other + // asks them to open that page first. + status: if opened { + "requires_user_profile_setup".into() + } else { + "requires_manual_user_profile_setup".into() + }, + message: Some(instructions), + browser_kind: kind.to_string(), + setup_url: Some(setup_url), }, LaunchResult::LaunchedButCdpNotReady { message, .. } => BrowserControlLaunchResponse { success: false, status: "cdp_not_ready".into(), message: Some(message), browser_kind: kind.to_string(), + setup_url: None, }, LaunchResult::BrowserRunningWithoutCdp { instructions, .. } => { BrowserControlLaunchResponse { @@ -181,11 +249,47 @@ fn to_launch_response(kind: &BrowserKind, result: LaunchResult) -> BrowserContro status: "needs_restart".into(), message: Some(instructions), browser_kind: kind.to_string(), + setup_url: None, } } } } +async fn complete_launch( + kind: &BrowserKind, + logical_port: u16, + result: LaunchResult, +) -> Result { + match result { + LaunchResult::UserProfileReady { endpoint } => { + let connection = CdpClient::connect_user_profile_browser( + logical_port, + endpoint.port, + kind, + &endpoint.web_socket_url, + ) + .await; + if let Err(error) = connection { + return Ok(BrowserControlLaunchResponse { + success: false, + status: "user_profile_connection_failed".into(), + message: Some(error.to_string()), + browser_kind: kind.to_string(), + setup_url: None, + }); + } + Ok(BrowserControlLaunchResponse { + success: true, + status: "connected_user_profile".into(), + message: None, + browser_kind: kind.to_string(), + setup_url: None, + }) + } + other => Ok(to_launch_response(kind, other)), + } +} + /// Launch the user's default browser with CDP debug port. #[tauri::command] pub async fn browser_control_launch( @@ -194,11 +298,64 @@ pub async fn browser_control_launch( let port = request.port; let kind = selected_browser_kind().await?; + if CdpClient::browser_connection_for_kind(port, &kind) + .await + .is_some() + { + return Ok(to_launch_response(&kind, LaunchResult::AlreadyConnected)); + } + + // The logical port is shared across browser choices. Drop only the lookup + // entry when the user switches browsers; any already-attached page session + // keeps its transport alive, but new actions cannot accidentally reuse it. + if CdpClient::browser_connection(port).await.is_some() { + CdpClient::remove_browser_connection(port).await; + } + let result = BrowserLauncher::launch_with_cdp(&kind, port) .await .map_err(|e| e.to_string())?; - Ok(to_launch_response(&kind, result)) + complete_launch(&kind, port, result).await +} + +/// Open the selected browser's persistent guarded-CDP setting and wait for the +/// user-owned consent toggle. Once enabled, immediately request and retain the +/// real-profile connection so the Settings action is one continuous flow. +#[tauri::command] +pub async fn browser_control_enable_default_cdp( + request: BrowserControlLaunchRequest, +) -> Result { + let port = request.port; + let kind = selected_browser_kind().await?; + + if !BrowserLauncher::supports_default_cdp(&kind) { + return Ok(BrowserControlLaunchResponse { + success: false, + status: "default_cdp_unsupported".into(), + message: Some(format!( + "{} does not expose a supported persistent guarded-CDP setting", + kind + )), + browser_kind: kind.to_string(), + setup_url: None, + }); + } + + if CdpClient::browser_connection_for_kind(port, &kind) + .await + .is_some() + { + return Ok(to_launch_response(&kind, LaunchResult::AlreadyConnected)); + } + if CdpClient::browser_connection(port).await.is_some() { + CdpClient::remove_browser_connection(port).await; + } + + let result = BrowserLauncher::enable_default_cdp(&kind, port) + .await + .map_err(|e| e.to_string())?; + complete_launch(&kind, port, result).await } /// Restart the user's default browser with CDP debug port enabled. @@ -213,19 +370,5 @@ pub async fn browser_control_restart_with_cdp( .await .map_err(|e| e.to_string())?; - Ok(to_launch_response(&kind, result)) -} - -/// Create a macOS .app wrapper for the browser with CDP enabled. -#[tauri::command] -pub async fn browser_control_create_launcher() -> Result { - #[cfg(target_os = "macos")] - { - let kind = selected_browser_kind().await?; - BrowserLauncher::create_cdp_launcher_app(&kind, DEFAULT_CDP_PORT).map_err(|e| e.to_string()) - } - #[cfg(not(target_os = "macos"))] - { - Err("CDP launcher app creation is only supported on macOS".into()) - } + complete_launch(&kind, port, result).await } diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index a4842d365c..8ed42fb85d 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -216,7 +216,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("archive_session", RemoteWorkspacePolicy::LegacyUnaudited), ( - "browser_control_create_launcher", + "browser_control_enable_default_cdp", RemoteWorkspacePolicy::LocalOnly, ), ( diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index d239d6dffa..1118116a4b 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1803,8 +1803,8 @@ pub async fn run() { api::browser_control_api::browser_control_list_browsers, api::browser_control_api::browser_control_get_status, api::browser_control_api::browser_control_launch, + api::browser_control_api::browser_control_enable_default_cdp, api::browser_control_api::browser_control_restart_with_cdp, - api::browser_control_api::browser_control_create_launcher, // Insights API api::insights_api::generate_insights, api::insights_api::get_latest_insights, diff --git a/src/crates/assembly/agent-content/prompts/agents/agentic_mode.md b/src/crates/assembly/agent-content/prompts/agents/agentic_mode.md index 8311e884d8..d0d3ba88c3 100644 --- a/src/crates/assembly/agent-content/prompts/agents/agentic_mode.md +++ b/src/crates/assembly/agent-content/prompts/agents/agentic_mode.md @@ -91,7 +91,7 @@ The user will primarily request you perform software engineering tasks. This inc - When the user explicitly asks to complete work and review it carefully, finish the implementation first, then dispatch one independent read-only `CodeReview` Task. Do not run concurrent review tasks or fan out `CodeReview` into architecture, performance, security, product, or other invented dimensions: broader coverage belongs to the unified `/review` path, which selects bounded review lenses and owns cost confirmation. Do not launch review by default for every task. - Treat reviewer output as adversarial evidence. The reviewer never fixes its own findings. Apply accepted fixes in the implementation agent. If substantive fixes make the original verdict stale and the risk warrants another pass, request at most one fresh independent re-review. - When WebFetch reports a redirect, follow the redirect URL if it is relevant and safe for the user's request. -- For browser and web-page work, route in this order: (0) only opening or showing a URL for the user, with no page reading or interaction: use `ControlHub` with `domain: "browser"`, `action: "open_builtin"`; (1) reading page content that does not require the user's login state: use WebFetch; (2) pages that require the user's login state or JavaScript interaction: use `ControlHub` with `domain: "browser"` (connect, snapshot, then act through `@eN` refs) — `connect` drives BitFun's managed browser profile, which is separate from the user's everyday browser; it persists cookies and logins across runs, so if the page shows a login wall, ask the user to sign in once in that window instead of retrying navigation or entering credentials yourself; (3) non-Chromium browsers (Firefox/Safari) or native desktop apps, including Electron apps: use `ComputerUse` desktop actions only when `ComputerUse` appears in your current tool list; if it does not, tell the user the task needs the Computer Use mode (enabled via the Computer use setting) instead of guessing another path or calling an unavailable tool. `ControlHub` covers ordinary web pages. For a browser-only workflow that `ControlHub` explicitly cannot support, such as a compatible cloud-browser workflow, load `agent-browser` via `Skill(skill="agent-browser")` only when that skill is available; do not use it as a substitute for `ComputerUse` on native desktop apps. +- For browser and web-page work, route in this order: (0) only opening or showing a URL for the user, with no page reading or interaction: use `ControlHub` with `domain: "browser"`, `action: "open_builtin"`; (1) reading page content that does not require the user's login state: use WebFetch; (2) pages that require the user's login state or JavaScript interaction: use `ControlHub` with `domain: "browser"` (connect, snapshot, then act through `@eN` refs) — Chrome 144+ and Edge request access to the currently running real profile, preserving tabs and login state after the user clicks **Enable default CDP** in BitFun Settings > Browser control, enables Remote debugging in the browser-owned page, and approves BitFun; other supported Chromium browsers reuse a real-profile endpoint when available and otherwise use BitFun's persistent managed profile; (3) non-Chromium browsers (Firefox/Safari) or native desktop apps, including Electron apps: use `ComputerUse` desktop actions only when `ComputerUse` appears in your current tool list; if it does not, tell the user the task needs the Computer Use mode (enabled via the Computer use setting) instead of guessing another path or calling an unavailable tool. `ControlHub` covers ordinary web pages. For a browser-only workflow that `ControlHub` explicitly cannot support, such as a compatible cloud-browser workflow, load `agent-browser` via `Skill(skill="agent-browser")` only when that skill is available; do not use it as a substitute for `ComputerUse` on native desktop apps. - When multiple tool calls are independent, run them in parallel. Keep dependent operations sequential, and never use placeholders or guess missing parameters. - Use specialized tools for file reads, edits, searches, and deletions because they preserve workspace context and permissions. Use ExecCommand for commands that genuinely need a shell. Do not use shell commands only to communicate with the user. - For security-sensitive tasks, support defensive analysis and remediation only. Refuse malicious code, exploit workflows, credential harvesting, or instructions that would facilitate abuse. diff --git a/src/crates/assembly/agent-content/prompts/agents/claw_mode.md b/src/crates/assembly/agent-content/prompts/agents/claw_mode.md index 18eba15511..22f68f9ad3 100644 --- a/src/crates/assembly/agent-content/prompts/agents/claw_mode.md +++ b/src/crates/assembly/agent-content/prompts/agents/claw_mode.md @@ -16,7 +16,7 @@ When a first-class tool exists for an action, use the tool directly instead of a Use `ControlHub` for browser automation, terminal signalling, and routing/capability introspection only when it appears in your current tool list: -- `domain: "browser"` for websites and web apps in BitFun's managed browser profile through CDP. +- `domain: "browser"` for websites and web apps through CDP. Chrome 144+ and Edge connect to the user's current profile after explicit approval; other Chromium browsers reuse a real-profile endpoint when available or use BitFun's persistent managed profile. - `domain: "terminal"` for signalling existing terminal sessions, such as interrupting or killing them. - `domain: "meta"` for capability and route checks. @@ -24,7 +24,7 @@ For browser and web-page work, route in this order: 1. Only opening, showing, previewing, or displaying a URL for the user (no page reading, no interaction): use `ControlHub` with `domain: "browser"`, `action: "open_builtin"`, `params: { url }`. The page renders in BitFun's built-in right-side browser panel. Do not delegate this to a `ComputerUse` sub-agent and do not call `connect`/`navigate` for it. 2. Reading page content that does not require the user's login state: use `WebFetch`. -3. Pages that require the user's login state or JavaScript interaction: use `ControlHub` with `domain: "browser"` (connect, snapshot, then act through `@eN` refs). `connect` drives BitFun's managed browser profile, which is separate from the user's everyday browser; it persists cookies and logins across runs, so if the page shows a login wall, ask the user to sign in once in that window instead of retrying navigation or entering credentials yourself. +3. Pages that require the user's login state or JavaScript interaction: use `ControlHub` with `domain: "browser"` (connect, snapshot, then act through `@eN` refs). On Chrome 144+ and Edge, `connect` requests access to the currently running real profile; for one-time setup, ask the user to click **Enable default CDP** in BitFun Settings > Browser control, enable Remote debugging in the browser-owned page, and approve BitFun. Other supported Chromium browsers reuse a real-profile endpoint when available and otherwise use BitFun's persistent managed profile. 4. Non-Chromium browsers (Firefox/Safari) or native desktop apps: delegate to the `ComputerUse` sub-agent as described below. Do not use `ControlHub` for local computer, operating-system, or desktop UI work. Desktop and system actions have moved to the dedicated `ComputerUse` tool/agent. This includes screenshots, OCR, mouse, keyboard, app state, app launching, opening local files and non-http(s) URLs through the OS, clipboard access, OS facts, and local scripts. diff --git a/src/crates/assembly/agent-content/prompts/agents/computer_use_mode.md b/src/crates/assembly/agent-content/prompts/agents/computer_use_mode.md index 08f92ba671..f66a2f04ac 100644 --- a/src/crates/assembly/agent-content/prompts/agents/computer_use_mode.md +++ b/src/crates/assembly/agent-content/prompts/agents/computer_use_mode.md @@ -18,7 +18,7 @@ Work in a tight observe -> act -> verify loop. Before acting on a desktop UI, ob Prefer the smallest reliable control surface: -1. When `ControlHub` appears in your current tool list, use it with `domain: "browser"` for websites and web apps in BitFun's managed browser profile. +1. When `ControlHub` appears in your current tool list, use it with `domain: "browser"` for websites and web apps. Chrome 144+ and Edge can connect to the current real profile after explicit approval; other Chromium browsers reuse a real-profile endpoint when available or use BitFun's persistent managed profile. 2. Use `ComputerUse` for third-party desktop apps, OS dialogs, system-wide keyboard and mouse, accessibility, OCR, screenshots, app state, app/file opening, clipboard access, OS facts, and local scripts. Use it for URL opening only when the page must land in the system default browser; for display-only http(s) URLs prefer `ControlHub` `browser.open_builtin`. 3. Use `ExecCommand` for local shell commands when that is the clearest path and does not bypass desktop safety expectations. 4. When available, use `ControlHub` with `domain: "meta"` to inspect non-desktop control capabilities before long or uncertain automation flows. @@ -71,7 +71,7 @@ For websites and web apps, route in this order: 1. Only opening, showing, previewing, or displaying a URL for the user (no page reading, no interaction): use `ControlHub` with `domain: "browser"`, `action: "open_builtin"`, `params: { url }`. The page renders in BitFun's built-in right-side browser panel. Do not call `connect`/`navigate` for this. 2. Reading page content that does not require the user's login state: use `WebFetch` when it is available. -3. Pages that require the user's login state or JavaScript interaction: use `ControlHub` with `domain: "browser"` (connect, snapshot, then act through `@eN` refs). `connect` drives BitFun's managed browser profile, which is separate from the user's everyday browser; it persists cookies and logins across runs, so if the page shows a login wall, ask the user to sign in once in that window instead of retrying navigation or entering credentials yourself. +3. Pages that require the user's login state or JavaScript interaction: use `ControlHub` with `domain: "browser"` (connect, snapshot, then act through `@eN` refs). On Chrome 144+ and Edge, ask the user to click **Enable default CDP** in BitFun Settings > Browser control, enable Remote debugging in the browser-owned page, and approve BitFun if prompted; this preserves the current profile's tabs and login state. Other supported Chromium browsers reuse a real-profile endpoint when available and otherwise use BitFun's persistent managed profile. 4. Non-Chromium browsers (Firefox/Safari) or native desktop apps: use `ComputerUse` desktop actions. If `ControlHub` is unavailable, do not claim browser-domain automation; use `ComputerUse` only for browser chrome or OS-level interaction that it can actually observe and verify. diff --git a/src/crates/assembly/agent-content/prompts/agents/cowork_mode.md b/src/crates/assembly/agent-content/prompts/agents/cowork_mode.md index f520468517..fe25b81ca8 100644 --- a/src/crates/assembly/agent-content/prompts/agents/cowork_mode.md +++ b/src/crates/assembly/agent-content/prompts/agents/cowork_mode.md @@ -76,7 +76,7 @@ For browser and web-page work, route in this order: 1. Only opening, showing, previewing, or displaying a URL for the user (no page reading, no interaction): use `ControlHub` with `domain: "browser"`, `action: "open_builtin"`, `params: { url }`. 2. Reading page content that does not require the user's login state: use `WebFetch`. -3. Pages that require the user's login state or JavaScript interaction: use `ControlHub` with `domain: "browser"` (connect, snapshot, then act through `@eN` refs). `connect` drives BitFun's managed browser profile, which is separate from the user's everyday browser; it persists cookies and logins across runs, so if the page shows a login wall, ask the user to sign in once in that window instead of retrying navigation or entering credentials yourself. +3. Pages that require the user's login state or JavaScript interaction: use `ControlHub` with `domain: "browser"` (connect, snapshot, then act through `@eN` refs). On Chrome 144+ and Edge, ask the user to click **Enable default CDP** in BitFun Settings > Browser control, enable Remote debugging in the browser-owned page, and approve BitFun if prompted; this preserves the current profile's tabs and login state. Other supported Chromium browsers reuse a real-profile endpoint when available and otherwise use BitFun's persistent managed profile. 4. Non-Chromium browsers (Firefox/Safari) or native desktop apps: Cowork cannot drive these — explain the limitation and suggest Computer Use mode instead. Do not use `ControlHub` for local computer, operating-system, or desktop UI work, and do not substitute a browser-automation skill for it. diff --git a/src/crates/assembly/core/src/agentic/tools/browser_control/browser_launcher.rs b/src/crates/assembly/core/src/agentic/tools/browser_control/browser_launcher.rs index 5d0f36ecbd..a0fc78c112 100644 --- a/src/crates/assembly/core/src/agentic/tools/browser_control/browser_launcher.rs +++ b/src/crates/assembly/core/src/agentic/tools/browser_control/browser_launcher.rs @@ -7,7 +7,8 @@ use bitfun_services_integrations::browser_control::launcher as provider; pub use provider::{ - BrowserInfo, BrowserKind, BrowserLaunchOptions, LaunchResult, DEFAULT_CDP_PORT, + BrowserDebugEndpoint, BrowserInfo, BrowserKind, BrowserLaunchOptions, LaunchResult, + DEFAULT_CDP_PORT, }; use std::path::PathBuf; @@ -52,6 +53,14 @@ impl BrowserLauncher { provider::BrowserLauncher::browser_executable(kind) } + pub fn supports_default_cdp(kind: &BrowserKind) -> bool { + provider::BrowserLauncher::supports_default_cdp(kind) + } + + pub fn is_default_cdp_enabled(kind: &BrowserKind) -> bool { + provider::BrowserLauncher::is_default_cdp_enabled(kind) + } + pub async fn launch_with_cdp(kind: &BrowserKind, port: u16) -> BitFunResult { Ok(provider::BrowserLauncher::launch_with_cdp_options( kind, @@ -78,17 +87,20 @@ impl BrowserLauncher { Self::launch_with_cdp(kind, port).await } - #[cfg(target_os = "macos")] - pub fn create_cdp_launcher_app(kind: &BrowserKind, port: u16) -> BitFunResult { - Ok(provider::BrowserLauncher::create_cdp_launcher_app( - kind, port, - )?) + /// Explicit Settings flow: keep the browser settings page open long enough + /// for the user-owned consent toggle, then continue with the guarded + /// real-profile connection as soon as the endpoint appears. + pub async fn enable_default_cdp(kind: &BrowserKind, port: u16) -> BitFunResult { + let mut options = Self::launch_options(None); + options.wait_for_user_profile_setup = true; + Ok(provider::BrowserLauncher::launch_with_cdp_options(kind, port, options).await?) } fn launch_options(user_data_dir: Option<&str>) -> BrowserLaunchOptions { BrowserLaunchOptions { user_data_dir: user_data_dir.map(PathBuf::from), managed_profile_root: Some(get_path_manager_arc().user_data_dir()), + wait_for_user_profile_setup: false, } } } diff --git a/src/crates/assembly/core/src/agentic/tools/browser_control/cdp_client.rs b/src/crates/assembly/core/src/agentic/tools/browser_control/cdp_client.rs index 118d35adce..408233289a 100644 --- a/src/crates/assembly/core/src/agentic/tools/browser_control/cdp_client.rs +++ b/src/crates/assembly/core/src/agentic/tools/browser_control/cdp_client.rs @@ -8,15 +8,24 @@ use futures::{SinkExt, StreamExt}; use log::{debug, info, warn}; use serde_json::{json, Value}; use std::collections::HashMap; -use std::sync::atomic::{AtomicI64, Ordering}; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::sync::{Arc, OnceLock, Weak}; +use std::time::Duration; use tokio::net::TcpStream; use tokio::sync::{broadcast, Mutex, RwLock}; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; +use super::browser_launcher::BrowserKind; + type WsSink = SplitSink>, Message>; type WsStream = SplitStream>>; +type PendingResponses = Arc>>>; +type EventChannels = Arc, broadcast::Sender>>>; +type SessionStatuses = Arc>>>; + +const PAGE_CDP_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const USER_PROFILE_APPROVAL_TIMEOUT: Duration = Duration::from_secs(90); /// A single CDP event emitted by the browser (no `id`, has `method` + `params`). #[derive(Debug, Clone)] @@ -25,88 +34,231 @@ pub struct CdpEvent { pub params: Value, } -/// A CDP WebSocket client connected to a single page target. -pub struct CdpClient { +struct CdpTransport { sink: Arc>, - pending: Arc>>>, + pending: PendingResponses, next_id: AtomicI64, - /// Broadcast bus for unsolicited CDP events. Subscribers may filter by - /// `method` (e.g. `"Page.lifecycleEvent"`). + event_channels: EventChannels, + session_statuses: SessionStatuses, + alive: Arc, + reader_handle: tokio::task::JoinHandle<()>, +} + +impl Drop for CdpTransport { + fn drop(&mut self) { + self.reader_handle.abort(); + } +} + +/// A CDP client connected either directly to a page WebSocket or to a flattened +/// target session carried by a browser-level WebSocket. The latter is required +/// for user-approved real-profile connections because guarded endpoints do not +/// necessarily expose the legacy `/json` HTTP API. +pub struct CdpClient { + transport: Arc, + session_id: Option, events: broadcast::Sender, - _reader_handle: tokio::task::JoinHandle<()>, + session_alive: Option>, +} + +/// Process-wide browser connection retained after the user approves BitFun. +/// Keeping one browser WebSocket avoids repeated approval prompts and lets +/// settings commands and agent tools share the same live profile. +#[derive(Clone)] +pub struct CdpBrowserConnection { + pub actual_port: u16, + pub browser_kind: BrowserKind, + pub client: Arc, +} + +static BROWSER_CONNECTIONS: OnceLock>> = OnceLock::new(); + +fn browser_connections() -> &'static RwLock> { + BROWSER_CONNECTIONS.get_or_init(|| RwLock::new(HashMap::new())) } impl CdpClient { - /// Discover browser version on the given debug port. + /// Discover browser version on a legacy fixed debug port. pub async fn get_version(port: u16) -> BitFunResult { CdpEndpointProvider::get_version(port) .await .map_err(|error| BitFunError::tool(error.to_string())) } - /// List all pages/tabs on the given debug port. + /// List all pages/tabs on a legacy fixed debug port. pub async fn list_pages(port: u16) -> BitFunResult> { CdpEndpointProvider::list_pages(port) .await .map_err(|error| BitFunError::tool(error.to_string())) } - /// Create a new page/tab on the given debug port. + /// Create a new page/tab on a legacy fixed debug port. pub async fn create_page(port: u16, url: Option<&str>) -> BitFunResult { CdpEndpointProvider::create_page(port, url) .await .map_err(|error| BitFunError::tool(error.to_string())) } - /// Connect to a specific page by its WebSocket debugger URL. + /// Connect to a specific page by its legacy WebSocket debugger URL. pub async fn connect(ws_url: &str) -> BitFunResult { - info!("CDP connecting to {}", ws_url); - let (ws_stream, _) = connect_async(ws_url) + info!("CDP connecting to page WebSocket"); + Self::connect_with_timeout(ws_url, PAGE_CDP_CONNECT_TIMEOUT).await + } + + /// Connect to a guarded browser-level endpoint and retain it under the + /// logical port used by BitFun's browser tools. The WebSocket handshake + /// waits for the user to approve the request in their browser. + pub async fn connect_user_profile_browser( + logical_port: u16, + actual_port: u16, + browser_kind: &BrowserKind, + ws_url: &str, + ) -> BitFunResult { + if let Some(existing) = Self::browser_connection(logical_port).await { + if existing.actual_port == actual_port && existing.browser_kind == *browser_kind { + return Ok(existing); + } + } + + info!( + "Requesting user-approved browser profile connection on port {}", + actual_port + ); + let client = Arc::new( + Self::connect_with_timeout(ws_url, USER_PROFILE_APPROVAL_TIMEOUT) + .await + .map_err(|error| { + BitFunError::tool(format!( + "Could not connect to the current browser profile. Approve BitFun's remote debugging request in the browser, then try again: {}", + error + )) + })?, + ); + // Validate that this is a browser-level CDP endpoint before retaining + // it. This also fails quickly if the DevToolsActivePort file was stale. + client.browser_version().await?; + + let connection = CdpBrowserConnection { + actual_port, + browser_kind: browser_kind.clone(), + client, + }; + browser_connections() + .write() + .await + .insert(logical_port, connection.clone()); + Ok(connection) + } + + /// Return a healthy retained browser connection, pruning it if the browser + /// has closed the underlying WebSocket. + pub async fn browser_connection(logical_port: u16) -> Option { + let existing = browser_connections() + .read() + .await + .get(&logical_port) + .cloned(); + match existing { + Some(connection) if connection.client.is_connected() => Some(connection), + Some(_) => { + browser_connections().write().await.remove(&logical_port); + None + } + None => None, + } + } + + /// Return the retained connection only when it belongs to the browser the + /// caller selected. A logical tool port is shared by every browser option, + /// so blindly reusing it after a Chrome/Edge switch would control the wrong + /// profile. + pub async fn browser_connection_for_kind( + logical_port: u16, + browser_kind: &BrowserKind, + ) -> Option { + Self::browser_connection(logical_port) + .await + .filter(|connection| connection.browser_kind == *browser_kind) + } + + /// Forget the browser-level connection assigned to a logical tool port. + /// Existing page sessions retain their own transport references, while + /// subsequent browser actions resolve against the newly selected browser. + pub async fn remove_browser_connection(logical_port: u16) { + browser_connections().write().await.remove(&logical_port); + } + + async fn connect_with_timeout(ws_url: &str, timeout: Duration) -> BitFunResult { + let (ws_stream, _) = tokio::time::timeout(timeout, connect_async(ws_url)) .await - .map_err(|e| BitFunError::tool(format!("CDP WebSocket connect failed: {}", e)))?; + .map_err(|_| { + BitFunError::tool("Timed out waiting for the CDP WebSocket connection".to_string()) + })? + .map_err(|error| { + BitFunError::tool(format!("CDP WebSocket connect failed: {}", error)) + })?; let (sink, stream) = ws_stream.split(); let sink = Arc::new(Mutex::new(sink)); - let pending: Arc>>> = - Arc::new(RwLock::new(HashMap::new())); + let pending: PendingResponses = Arc::new(RwLock::new(HashMap::new())); + let event_channels: EventChannels = Arc::new(RwLock::new(HashMap::new())); + let session_statuses: SessionStatuses = Arc::new(RwLock::new(HashMap::new())); + let alive = Arc::new(AtomicBool::new(true)); - let pending_clone = pending.clone(); - // Buffer up to 256 events per subscriber. Lifecycle / network events - // arrive in bursts during page load; older entries can be dropped from - // a subscriber lagging behind without affecting the protocol. + // Buffer up to 256 events per target subscriber. Lifecycle / network + // events arrive in bursts during page load; older entries can be + // dropped from a lagging subscriber without affecting the protocol. let (events_tx, _) = broadcast::channel::(256); - let events_for_reader = events_tx.clone(); - let reader_handle = - tokio::spawn(Self::reader_loop(stream, pending_clone, events_for_reader)); + event_channels.write().await.insert(None, events_tx.clone()); + + let reader_handle = tokio::spawn(Self::reader_loop( + stream, + pending.clone(), + event_channels.clone(), + session_statuses.clone(), + alive.clone(), + )); Ok(Self { - sink, - pending, - next_id: AtomicI64::new(1), + transport: Arc::new(CdpTransport { + sink, + pending, + next_id: AtomicI64::new(1), + event_channels, + session_statuses, + alive, + reader_handle, + }), + session_id: None, events: events_tx, - _reader_handle: reader_handle, + session_alive: None, }) } - /// Subscribe to *all* CDP events. Filter on `method` at the call site. + /// Subscribe to events for this page session only. pub fn subscribe_events(&self) -> broadcast::Receiver { self.events.subscribe() } - /// Returns `true` while the WebSocket reader task is still running. - /// `BrowserSessionRegistry` uses this to evict sessions whose tab the - /// user closed out-of-band (without going through `browser.close`), - /// avoiding a 30-second `CDP timeout` on the next call. + /// Returns `true` while the underlying WebSocket and, for a flattened page + /// session, that specific target session are still alive. pub fn is_connected(&self) -> bool { - !self._reader_handle.is_finished() + self.transport.alive.load(Ordering::SeqCst) + && self + .session_alive + .as_ref() + .map(|alive| alive.load(Ordering::SeqCst)) + .unwrap_or(true) } - /// Connect to the first available page on a debug port. + /// Connect to the first available page on a legacy debug port. pub async fn connect_to_first_page(port: u16) -> BitFunResult { let pages = Self::list_pages(port).await?; let page = pages .iter() - .find(|p| p.page_type.as_deref() == Some("page") && p.web_socket_debugger_url.is_some()) + .find(|page| { + page.page_type.as_deref() == Some("page") && page.web_socket_debugger_url.is_some() + }) .or_else(|| pages.first()) .ok_or_else(|| BitFunError::tool("No browser pages found via CDP".to_string()))?; @@ -118,33 +270,182 @@ impl CdpClient { Self::connect(ws_url).await } + /// Query version metadata from a browser-level CDP connection. + pub async fn browser_version(&self) -> BitFunResult { + self.require_browser_connection()?; + let result = self.send("Browser.getVersion", None).await?; + Ok(CdpVersionInfo { + browser: result + .get("product") + .and_then(Value::as_str) + .map(str::to_string), + protocol_version: result + .get("protocolVersion") + .and_then(Value::as_str) + .map(str::to_string), + web_socket_debugger_url: None, + }) + } + + /// List targets through the browser WebSocket. This replaces `/json` for + /// an approval-only real-profile endpoint. + pub async fn browser_pages(&self) -> BitFunResult> { + self.require_browser_connection()?; + let result = self.send("Target.getTargets", None).await?; + Ok(Self::page_infos_from_target_result(&result)) + } + + /// Create a target through the browser WebSocket and return its metadata. + pub async fn create_browser_page(&self, url: Option<&str>) -> BitFunResult { + self.require_browser_connection()?; + let target_url = url.unwrap_or("about:blank"); + let result = self + .send("Target.createTarget", Some(json!({ "url": target_url }))) + .await?; + let target_id = result + .get("targetId") + .and_then(Value::as_str) + .ok_or_else(|| { + BitFunError::tool("Target.createTarget returned no target id".to_string()) + })? + .to_string(); + + for _ in 0..10 { + if let Some(page) = self + .browser_pages() + .await? + .into_iter() + .find(|page| page.id == target_id) + { + return Ok(page); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + Ok(CdpPageInfo { + id: target_id, + title: String::new(), + url: target_url.to_string(), + web_socket_debugger_url: None, + page_type: Some("page".to_string()), + }) + } + + /// Attach to one target using a flattened CDP session carried over the + /// retained browser WebSocket. All subsequent page commands are tagged with + /// the returned `sessionId`, while events are routed to this client only. + pub async fn attach_to_page(&self, target_id: &str) -> BitFunResult { + self.require_browser_connection()?; + let result = self + .send( + "Target.attachToTarget", + Some(json!({ "targetId": target_id, "flatten": true })), + ) + .await?; + let session_id = result + .get("sessionId") + .and_then(Value::as_str) + .ok_or_else(|| { + BitFunError::tool("Target.attachToTarget returned no session id".to_string()) + })? + .to_string(); + + let (events_tx, _) = broadcast::channel::(256); + self.transport + .event_channels + .write() + .await + .insert(Some(session_id.clone()), events_tx.clone()); + let session_alive = Arc::new(AtomicBool::new(true)); + self.transport + .session_statuses + .write() + .await + .insert(session_id.clone(), Arc::downgrade(&session_alive)); + + Ok(Self { + transport: self.transport.clone(), + session_id: Some(session_id), + events: events_tx, + session_alive: Some(session_alive), + }) + } + + fn require_browser_connection(&self) -> BitFunResult<()> { + if self.session_id.is_some() { + return Err(BitFunError::tool( + "This CDP operation requires the browser-level connection".to_string(), + )); + } + Ok(()) + } + + fn page_infos_from_target_result(result: &Value) -> Vec { + result + .get("targetInfos") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|target| { + let id = target.get("targetId")?.as_str()?.to_string(); + Some(CdpPageInfo { + id, + title: target + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + url: target + .get("url") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + web_socket_debugger_url: None, + page_type: target + .get("type") + .and_then(Value::as_str) + .map(str::to_string), + }) + }) + .collect() + } + /// Send a CDP method call and wait for the response. pub async fn send(&self, method: &str, params: Option) -> BitFunResult { - let id = self.next_id.fetch_add(1, Ordering::SeqCst); - let msg = json!({ + let id = self.transport.next_id.fetch_add(1, Ordering::SeqCst); + let mut msg = json!({ "id": id, "method": method, "params": params.unwrap_or(json!({})), }); + if let Some(session_id) = &self.session_id { + msg["sessionId"] = json!(session_id); + } let (tx, rx) = tokio::sync::oneshot::channel(); - { - let mut pending = self.pending.write().await; - pending.insert(id, tx); - } + self.transport.pending.write().await.insert(id, tx); debug!("CDP send id={} method={}", id, method); - { - let mut sink = self.sink.lock().await; - sink.send(Message::Text(msg.to_string().into())) - .await - .map_err(|e| BitFunError::tool(format!("CDP send failed: {}", e)))?; + let send_result = { + let mut sink = self.transport.sink.lock().await; + sink.send(Message::Text(msg.to_string().into())).await + }; + if let Err(error) = send_result { + self.transport.pending.write().await.remove(&id); + return Err(BitFunError::tool(format!("CDP send failed: {}", error))); } - let result = tokio::time::timeout(std::time::Duration::from_secs(30), rx) - .await - .map_err(|_| BitFunError::tool(format!("CDP timeout for method {}", method)))? - .map_err(|_| BitFunError::tool("CDP response channel closed".to_string()))?; + let result = match tokio::time::timeout(Duration::from_secs(30), rx).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => return Err(BitFunError::tool("CDP response channel closed".to_string())), + Err(_) => { + self.transport.pending.write().await.remove(&id); + return Err(BitFunError::tool(format!( + "CDP timeout for method {}", + method + ))); + } + }; if let Some(error) = result.get("error") { return Err(BitFunError::tool(format!("CDP error: {}", error))); @@ -155,31 +456,56 @@ impl CdpClient { async fn reader_loop( mut stream: WsStream, - pending: Arc>>>, - events: broadcast::Sender, + pending: PendingResponses, + event_channels: EventChannels, + session_statuses: SessionStatuses, + alive: Arc, ) { while let Some(msg_result) = stream.next().await { match msg_result { Ok(Message::Text(text)) => { - if let Ok(val) = serde_json::from_str::(&text) { - if let Some(id) = val.get("id").and_then(|v| v.as_i64()) { - let sender = { - let mut pending = pending.write().await; - pending.remove(&id) - }; - if let Some(tx) = sender { - let _ = tx.send(val); + if let Ok(value) = serde_json::from_str::(&text) { + if let Some(id) = value.get("id").and_then(Value::as_i64) { + let sender = pending.write().await.remove(&id); + if let Some(sender) = sender { + let _ = sender.send(value); } - } else if let Some(method) = val + continue; + } + + let Some(method) = value .get("method") - .and_then(|v| v.as_str()) + .and_then(Value::as_str) .map(str::to_string) - { - // Unsolicited CDP event — broadcast to subscribers - // (no-op if nobody is listening). Used by - // `BrowserActions::navigate` / `wait` to react - // to `Page.lifecycleEvent` instead of polling. - let params = val.get("params").cloned().unwrap_or(json!({})); + else { + continue; + }; + let params = value.get("params").cloned().unwrap_or(json!({})); + + if method == "Target.detachedFromTarget" { + if let Some(session_id) = + params.get("sessionId").and_then(Value::as_str) + { + if let Some(status) = session_statuses + .write() + .await + .remove(session_id) + .and_then(|status| status.upgrade()) + { + status.store(false, Ordering::SeqCst); + } + event_channels + .write() + .await + .remove(&Some(session_id.to_string())); + } + } + + let route = value + .get("sessionId") + .and_then(Value::as_str) + .map(str::to_string); + if let Some(events) = event_channels.read().await.get(&route).cloned() { let _ = events.send(CdpEvent { method, params }); } } @@ -188,18 +514,169 @@ impl CdpClient { debug!("CDP WebSocket closed by server"); break; } - Err(e) => { - warn!("CDP WebSocket read error: {}", e); + Err(error) => { + warn!("CDP WebSocket read error: {}", error); break; } _ => {} } } + + alive.store(false, Ordering::SeqCst); + pending.write().await.clear(); + for status in session_statuses + .write() + .await + .drain() + .map(|(_, status)| status) + { + if let Some(status) = status.upgrade() { + status.store(false, Ordering::SeqCst); + } + } } } -impl Drop for CdpClient { - fn drop(&mut self) { - self._reader_handle.abort(); +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn browser_target_metadata_maps_to_page_info() { + let pages = CdpClient::page_infos_from_target_result(&json!({ + "targetInfos": [ + { + "targetId": "page-1", + "type": "page", + "title": "Inbox", + "url": "https://mail.example.test/" + }, + { + "targetId": "worker-1", + "type": "service_worker", + "title": "Service Worker", + "url": "https://mail.example.test/sw.js" + } + ] + })); + + assert_eq!(pages.len(), 2); + assert_eq!(pages[0].id, "page-1"); + assert_eq!(pages[0].page_type.as_deref(), Some("page")); + assert_eq!(pages[0].web_socket_debugger_url, None); + } + + #[tokio::test] + async fn browser_websocket_flattens_commands_and_routes_page_events() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind mock CDP server"); + let address = listener.local_addr().expect("mock CDP address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept CDP client"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("accept WebSocket"); + + while let Some(message) = socket.next().await { + let Message::Text(text) = message.expect("read CDP command") else { + continue; + }; + let command: Value = serde_json::from_str(&text).expect("parse CDP command"); + let id = command + .get("id") + .and_then(Value::as_i64) + .expect("command id"); + let method = command + .get("method") + .and_then(Value::as_str) + .expect("command method"); + + let result = match method { + "Browser.getVersion" => json!({ + "product": "Chrome/151.0.0.0", + "protocolVersion": "1.3" + }), + "Target.getTargets" => json!({ + "targetInfos": [{ + "targetId": "page-1", + "type": "page", + "title": "Signed-in page", + "url": "https://example.test/" + }] + }), + "Target.attachToTarget" => { + assert_eq!(command["params"]["targetId"], "page-1"); + assert_eq!(command["params"]["flatten"], true); + json!({ "sessionId": "session-1" }) + } + "Runtime.enable" => { + assert_eq!(command["sessionId"], "session-1"); + socket + .send(Message::Text( + json!({ + "method": "Runtime.consoleAPICalled", + "sessionId": "session-1", + "params": { "type": "log" } + }) + .to_string() + .into(), + )) + .await + .expect("send flattened page event"); + json!({}) + } + other => panic!("unexpected CDP command: {other}"), + }; + + socket + .send(Message::Text( + json!({ "id": id, "result": result }).to_string().into(), + )) + .await + .expect("send CDP response"); + + if method == "Runtime.enable" { + break; + } + } + }); + + let browser = CdpClient::connect(&format!("ws://{address}")) + .await + .expect("connect browser WebSocket"); + assert_eq!( + browser + .browser_version() + .await + .expect("browser version") + .browser + .as_deref(), + Some("Chrome/151.0.0.0") + ); + let pages = browser.browser_pages().await.expect("browser targets"); + assert_eq!(pages.len(), 1); + + let mut browser_events = browser.subscribe_events(); + let page = browser + .attach_to_page(&pages[0].id) + .await + .expect("attach flattened page session"); + let mut page_events = page.subscribe_events(); + page.send("Runtime.enable", None) + .await + .expect("send flattened command"); + + let event = tokio::time::timeout(Duration::from_secs(1), page_events.recv()) + .await + .expect("page event timeout") + .expect("page event"); + assert_eq!(event.method, "Runtime.consoleAPICalled"); + assert!(matches!( + browser_events.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + + server.await.expect("mock CDP server"); } } diff --git a/src/crates/assembly/core/src/agentic/tools/browser_control/mod.rs b/src/crates/assembly/core/src/agentic/tools/browser_control/mod.rs index 596a5499c8..a20745e6a7 100644 --- a/src/crates/assembly/core/src/agentic/tools/browser_control/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/browser_control/mod.rs @@ -1,9 +1,10 @@ //! Browser control via Chrome DevTools Protocol (CDP). //! -//! Connects to the user's default browser (Chrome, Edge, etc.) over a -//! CDP WebSocket, enabling page navigation, DOM interaction, screenshots, -//! JS evaluation and more — all while preserving the user's existing -//! cookies, extensions, and login sessions. +//! Connects to a Chromium-family browser over CDP, enabling page navigation, +//! DOM interaction, screenshots, JS evaluation and more. Chrome 144+ and Edge +//! use user-approved live-profile endpoints so existing tabs, cookies, +//! extensions and login sessions are preserved. Other Chromium browsers reuse +//! a real-profile endpoint when available and retain a managed fallback. pub mod actions; pub mod browser_launcher; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs index f14275447a..93df273d75 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs @@ -160,10 +160,10 @@ impl ComputerUseActions { ) .with_hints([ "If your target is NOT the browser: the guard only looks at the app this action would drive, so switch focus with `key_chord` [\"alt\",\"tab\"] / [\"command\",\"tab\"] (never guarded) or `open_app`, or skip focus entirely and pass an explicit non-browser `app` selector ({pid|bundle_id|name}, from `list_apps`) to `app_click` / `app_type_text` / `app_scroll` / `app_key_chord`", - "Page content: call ControlHub browser.connect first — it starts/attaches BitFun's managed browser profile with CDP enabled — then drive the page with snapshot/click/fill/press_key", + "Page content: call ControlHub browser.connect first — Chrome 144+ and Edge use a user-approved connection to the current real profile; other supported Chromium browsers reuse a real-profile endpoint when available and otherwise fall back to BitFun's persistent managed profile — then drive the page with snapshot/click/fill/press_key", "Browser chrome (address bar, tabs, back/forward, reload, downloads): use browser.navigate / tab_new / switch_page / back / forward / reload / close instead of mouse+keyboard", "File picker or : do NOT drive the native dialog — use browser.set_file_input_files { selector, files: [\"/abs/path\"] }. For JS alert/confirm/prompt use browser.dialog", - "For login/cookies/extensions keep using the CDP browser path; do not ask the user to enable a debug port on their everyday browser profile", + "For Chrome or Edge login/cookies/extensions, keep using the guarded CDP path; for one-time setup, ask the user to click Enable default CDP in BitFun Settings > Browser control, enable Remote debugging in the browser-owned page, and approve BitFun", "For isolated project Web UI testing, use the headless browser flow instead of desktop automation", ]) } @@ -2008,12 +2008,14 @@ mod tests { /// The rejection must lead somewhere: a non-browser escape route, the /// ControlHub actions that own browser chrome / file pickers / dialogs, and - /// no contradiction with `browser.connect`'s "never ask for a debug port". + /// no contradiction with `browser.connect`'s guarded approval flow. #[test] fn browser_guard_hints_offer_an_executable_way_out() { let error = ComputerUseActions::desktop_browser_guard_error("click", None); assert!( - error.message.contains("not because your task is browser-related"), + error + .message + .contains("not because your task is browser-related"), "{}", error.message ); @@ -2026,7 +2028,7 @@ mod tests { assert!(hints.contains("app_click"), "{hints}"); assert!( !hints.contains("test port enabled") && !hints.contains("--remote-debugging-port"), - "must not contradict browser.connect's managed-profile rule: {hints}" + "must not teach the unsafe legacy default-profile debug-port flow: {hints}" ); } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs index 87735daa69..7dfd964cca 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -12,7 +12,7 @@ use crate::agentic::tools::browser_control::actions::BrowserActions; use crate::agentic::tools::browser_control::browser_launcher::{ BrowserKind, BrowserLauncher, LaunchResult, DEFAULT_CDP_PORT, }; -use crate::agentic::tools::browser_control::cdp_client::{CdpClient, CdpVersionInfo}; +use crate::agentic::tools::browser_control::cdp_client::{CdpClient, CdpPageInfo, CdpVersionInfo}; use crate::agentic::tools::browser_control::session_registry::{ BrowserSession, BrowserSessionRegistry, BrowserSessionState, DialogHandler, }; @@ -41,10 +41,10 @@ static BROWSER_SESSIONS: std::sync::OnceLock> = const OPEN_BUILT_IN_BROWSER_EVENT: &str = "agentic://open-built-in-browser"; /// `connect { mode: "headless" }` only attaches, it never launches. It must -/// therefore not default to the port the `default` mode's managed browser -/// occupies: otherwise a session that already ran `connect { mode: "default" }` -/// can never reach a headless browser, because `verify_headless_cdp_browser` -/// hard-rejects the headed browser sitting on that port. +/// therefore not default to the logical port used by the `default` mode: +/// otherwise a session that already connected the user's browser can never +/// reach a headless browser, because `verify_headless_cdp_browser` hard-rejects +/// the headed browser sitting on that port. const DEFAULT_HEADLESS_CDP_PORT: u16 = DEFAULT_CDP_PORT + 1; /// Computer Use is an independent switch from browser control (`ai.computer_use_enabled` @@ -80,15 +80,75 @@ impl ControlHubTool { } fn default_browser_connect_hints(kind: &BrowserKind, port: u16) -> Vec { - let exe = BrowserLauncher::browser_executable(kind); - vec![ - "Drive pages over CDP rather than desktop mouse/keyboard automation. Note this is BitFun's managed browser profile, not the user's everyday profile: it keeps its own cookies and logins across runs, so on a login wall ask the user to sign in once in that window instead of retrying or typing credentials.".to_string(), - format!( - "If CDP is not ready on test port {}, retry browser.connect — it starts \"{}\" against BitFun's managed profile with CDP enabled. Do not ask the user to enable a debug port on their everyday browser profile.", - port, exe - ), - "After the browser is listening on the test port, use browser.connect / snapshot / click / fill to drive the DOM directly.".to_string(), - ] + match kind { + BrowserKind::Chrome | BrowserKind::Edge => { + let setup_url = if matches!(kind, BrowserKind::Chrome) { + "chrome://inspect/#remote-debugging" + } else { + "edge://inspect/#remote-debugging" + }; + vec![ + format!( + "{} can connect BitFun to the current real profile, preserving its open tabs, cookies, extensions, and login state.", + kind + ), + format!( + "For one-time setup, ask the user to click Enable default CDP in BitFun Settings > Browser control. BitFun opens {}; enable Remote debugging there (the browser remembers this for normal future starts), then approve BitFun's connection dialog in {}.", + setup_url, kind + ), + "After approval, keep using browser.connect / snapshot / click / fill; BitFun retains one guarded browser connection to avoid repeated prompts.".to_string(), + ] + } + _ => { + let exe = BrowserLauncher::browser_executable(kind); + vec![ + format!( + "If {} already publishes DevToolsActivePort from its normal user-data directory, BitFun reuses that real profile automatically; otherwise it starts a persistent managed profile.", + kind + ), + format!( + "If CDP is not ready on test port {}, retry browser.connect — it starts \"{}\" with BitFun's managed profile.", + port, exe + ), + "After the browser is listening, use browser.connect / snapshot / click / fill to drive the DOM directly.".to_string(), + ] + } + } + } + + async fn browser_version(port: u16) -> BitFunResult { + if let Some(connection) = CdpClient::browser_connection(port).await { + connection.client.browser_version().await + } else { + CdpClient::get_version(port).await + } + } + + async fn browser_pages(port: u16) -> BitFunResult> { + if let Some(connection) = CdpClient::browser_connection(port).await { + connection.client.browser_pages().await + } else { + CdpClient::list_pages(port).await + } + } + + async fn create_browser_page(port: u16, url: Option<&str>) -> BitFunResult { + if let Some(connection) = CdpClient::browser_connection(port).await { + connection.client.create_browser_page(url).await + } else { + CdpClient::create_page(port, url).await + } + } + + async fn connect_page(port: u16, page: &CdpPageInfo) -> BitFunResult { + if let Some(connection) = CdpClient::browser_connection(port).await { + connection.client.attach_to_page(&page.id).await + } else { + let ws_url = page.web_socket_debugger_url.as_ref().ok_or_else(|| { + BitFunError::tool("Page has no WebSocket debugger URL".to_string()) + })?; + CdpClient::connect(ws_url).await + } } fn headless_browser_connect_hints(port: u16) -> Vec { @@ -134,7 +194,11 @@ impl ControlHubTool { if browser.to_ascii_lowercase().contains("headless") { return Ok(()); } - let reported = if browser.is_empty() { "unknown" } else { browser }; + let reported = if browser.is_empty() { + "unknown" + } else { + browser + }; Err(ControlHubError::new( ErrorCode::NotAvailable, format!( @@ -144,7 +208,7 @@ impl ControlHubTool { ) .with_hints(Self::headless_browser_connect_hints(port)) .with_hint( - "Use connect { mode: \"default\" } to drive the BitFun-managed browser profile instead.", + "Use connect { mode: \"default\" } for the user-approved current Chrome or Edge profile, or the compatible managed-profile fallback instead.", )) } @@ -198,8 +262,8 @@ Use this tool via `{ domain, action, params }` for browser automation, terminal * Do not call `connect`, `tab_new`, or `navigate` merely to display a URL. Use the CDP workflow only when the agent must read page content or interact with the DOM. - UI action: * `open_builtin { url, title?, replace_existing? }` — open an http(s) URL in BitFun's built-in right-side browser panel. This changes the BitFun UI only; it does not fetch page text for reasoning. The panel is display-only for the user — the agent cannot snapshot, read, or interact with it; use `connect` + `snapshot` when page content is needed. -- Automation modes (external managed browser): - * `connect { mode: "default" }` (default) — start or attach BitFun's managed browser profile with CDP enabled on port 9222. +- Automation modes (external browser): + * `connect { mode: "default" }` (default) — on Chrome 144+ and current Edge, request a user-approved connection to the currently running real profile so existing tabs and login state are preserved. Other supported Chromium browsers also reuse the real profile when it publishes DevToolsActivePort; otherwise BitFun starts or attaches its persistent managed profile on port 9222. * `connect { mode: "headless" }` — attach to an already-running headless browser on the headless test port 9223. This mode never starts a browser; when nothing is listening it returns `NOT_AVAILABLE` together with the exact launch command. * `params.port` overrides the CDP port for `connect` and for every other CDP action; after `connect`, actions reuse the connected session's port automatically. - Actions: open_builtin, connect, tab_new, navigate, back, forward, reload, snapshot, click, hover, fill, type, check, uncheck, select, press_key, scroll, auto_scroll, wait, get, get_text, get_url, get_title, get_html, screenshot, evaluate, fetch, cookies, set_cookies, set_file_input_files, cdp, network, console, errors, trace, dialog, read_article, close, list_pages, tab_query, switch_page, list_sessions. @@ -402,8 +466,8 @@ Branch on `ok` and `error.code`, not on English messages. // The value of a capability probe is entirely in the field // values, so the assistant-visible text must be the payload // itself — a one-line summary tells the model nothing. - let assistant = serde_json::to_string_pretty(&body) - .unwrap_or_else(|_| body.to_string()); + let assistant = + serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()); Ok(vec![ToolResult::ok(body, Some(assistant))]) } "route_hint" => { @@ -425,13 +489,14 @@ Branch on `ok` and `error.code`, not on English messages. // otherwise send an unroutable request. let mut suggestions: Vec<(&'static str, Option<&'static str>, u32, &'static str)> = vec![]; - let push = |s: &mut Vec<(&'static str, Option<&'static str>, u32, &'static str)>, - domain: &'static str, - tool: Option<&'static str>, - score: u32, - why: &'static str| { - s.push((domain, tool, score, why)); - }; + let push = + |s: &mut Vec<(&'static str, Option<&'static str>, u32, &'static str)>, + domain: &'static str, + tool: Option<&'static str>, + score: u32, + why: &'static str| { + s.push((domain, tool, score, why)); + }; let browser_kw = [ "http", @@ -738,10 +803,71 @@ Branch on `ok` and `error.code`, not on English messages. let user_data_dir = params.get("user_data_dir").and_then(|v| v.as_str()); let launch_result = if mode == "headless" { LaunchResult::AlreadyConnected + } else if user_data_dir.is_none() + && CdpClient::browser_connection_for_kind(port, &kind) + .await + .is_some() + { + LaunchResult::AlreadyConnected } else { + // Every browser shares the same logical tool port. When + // the selection or explicit profile changes, stop routing + // new actions through the previously retained browser. + if CdpClient::browser_connection(port).await.is_some() { + CdpClient::remove_browser_connection(port).await; + } BrowserLauncher::launch_with_cdp_opts(&kind, port, user_data_dir).await? }; + let uses_user_profile = match &launch_result { + LaunchResult::UserProfileReady { endpoint } => { + if let Err(error) = CdpClient::connect_user_profile_browser( + port, + endpoint.port, + &kind, + &endpoint.web_socket_url, + ) + .await + { + return Ok(err_response( + "browser", + "connect", + ControlHubError::new( + ErrorCode::NotAvailable, + format!( + "{} did not approve the connection to the current profile, or the approval request timed out.", + kind + ), + ) + .with_hint(error.to_string()) + .with_hints(Self::default_browser_connect_hints(&kind, port)), + )); + } + true + } + LaunchResult::UserProfileSetupRequired { + setup_url, + instructions, + .. + } => { + return Ok(err_response( + "browser", + "connect", + ControlHubError::new( + ErrorCode::NotAvailable, + format!( + "{} needs one-time setup before BitFun can use the current logged-in profile.", + kind + ), + ) + .with_hint(instructions) + .with_hint(format!("{} setup page: {setup_url}", kind)) + .with_hints(Self::default_browser_connect_hints(&kind, port)), + )); + } + _ => CdpClient::browser_connection(port).await.is_some(), + }; + // UX shortcut: a frequent flow is "drive my Gmail tab" / // "drive the GitHub PR I'm looking at". Without `target_*` // the model needed `connect` → `list_pages` → `switch_page` @@ -764,14 +890,16 @@ Branch on `ok` and `error.code`, not on English messages. .unwrap_or(true); match &launch_result { - LaunchResult::AlreadyConnected | LaunchResult::Launched => { - let version = CdpClient::get_version(port).await?; + LaunchResult::AlreadyConnected + | LaunchResult::Launched + | LaunchResult::UserProfileReady { .. } => { + let version = Self::browser_version(port).await?; if mode == "headless" { if let Err(error) = Self::verify_headless_cdp_browser(&version, port) { return Ok(err_response("browser", "connect", error)); } } - let pages = CdpClient::list_pages(port).await?; + let pages = Self::browser_pages(port).await?; let connected_browser = if mode == "headless" { "Headless test browser".to_string() } else { @@ -781,7 +909,7 @@ Branch on `ok` and `error.code`, not on English messages. // Selection: explicit target_* > first real page > first. let matched_by_target = if target_url.is_some() || target_title.is_some() { pages.iter().find(|p| { - if p.web_socket_debugger_url.is_none() { + if !uses_user_profile && p.web_socket_debugger_url.is_none() { return false; } let url_ok = target_url @@ -825,17 +953,15 @@ Branch on `ok` and `error.code`, not on English messages. .or_else(|| { pages.iter().find(|p| { p.page_type.as_deref() == Some("page") - && p.web_socket_debugger_url.is_some() + && (uses_user_profile + || p.web_socket_debugger_url.is_some()) }) }) .or_else(|| pages.first()) .ok_or_else(|| { BitFunError::tool("No browser pages found via CDP".to_string()) })?; - let ws_url = page.web_socket_debugger_url.as_ref().ok_or_else(|| { - BitFunError::tool("Page has no WebSocket debugger URL".to_string()) - })?; - let client = CdpClient::connect(ws_url).await?; + let client = Self::connect_page(port, page).await?; let session = BrowserSession { session_id: page.id.clone(), port, @@ -874,6 +1000,7 @@ Branch on `ok` and `error.code`, not on English messages. "success": true, "browser": connected_browser, "browser_mode": mode, + "browser_profile": if uses_user_profile { "current_user" } else { "managed" }, "browser_version": version.browser, "port": port, "session_id": session.session_id, @@ -883,6 +1010,8 @@ Branch on `ok` and `error.code`, not on English messages. "activated": activated, "status": if mode == "headless" { "attached" + } else if uses_user_profile { + "connected_user_profile" } else if matches!(launch_result, LaunchResult::AlreadyConnected) { "already_connected" } else { @@ -892,7 +1021,12 @@ Branch on `ok` and `error.code`, not on English messages. if let Some(w) = activate_warning { result["warning"] = json!(w); } - let summary = if targeted { + let summary = if uses_user_profile { + format!( + "Connected to the current {} profile via user-approved DOM/CDP (session {}, page '{}')", + connected_browser, session.session_id, page.title + ) + } else if targeted { format!( "Connected to {} via DOM/CDP (session {}, page '{}')", connected_browser, session.session_id, page.title @@ -905,6 +1039,24 @@ Branch on `ok` and `error.code`, not on English messages. }; Ok(vec![ToolResult::ok(result, Some(summary))]) } + LaunchResult::UserProfileSetupRequired { + setup_url, + instructions, + .. + } => Ok(err_response( + "browser", + "connect", + ControlHubError::new( + ErrorCode::NotAvailable, + format!( + "{} needs one-time setup before BitFun can use the current logged-in profile.", + kind + ), + ) + .with_hint(instructions) + .with_hint(format!("{} setup page: {setup_url}", kind)) + .with_hints(Self::default_browser_connect_hints(&kind, port)), + )), LaunchResult::LaunchedButCdpNotReady { message, .. } => Ok(err_response( "browser", "connect", @@ -925,7 +1077,7 @@ Branch on `ok` and `error.code`, not on English messages. } "list_pages" => { - let pages = CdpClient::list_pages(port).await?; + let pages = Self::browser_pages(port).await?; let default_id = browser_sessions().default_id().await; let summary: Vec = pages .iter() @@ -976,7 +1128,7 @@ Branch on `ok` and `error.code`, not on English messages. .unwrap_or(20) .max(1); - let pages = CdpClient::list_pages(port).await?; + let pages = Self::browser_pages(port).await?; let default_id = browser_sessions().default_id().await; let total = pages.len(); let filtered: Vec = pages @@ -1031,12 +1183,8 @@ Branch on `ok` and `error.code`, not on English messages. .get("activate") .and_then(|v| v.as_bool()) .unwrap_or(true); - let page = CdpClient::create_page(port, url).await?; - let ws_url = page - .web_socket_debugger_url - .as_ref() - .ok_or_else(|| BitFunError::tool("New tab has no WebSocket URL".to_string()))?; - let client = CdpClient::connect(ws_url).await?; + let page = Self::create_browser_page(port, url).await?; + let client = Self::connect_page(port, &page).await?; let session = BrowserSession { session_id: page.id.clone(), port, @@ -1089,14 +1237,11 @@ Branch on `ok` and `error.code`, not on English messages. reused = true; registry.get(Some(page_id)).await? } else { - let pages = CdpClient::list_pages(port).await?; + let pages = Self::browser_pages(port).await?; let page = pages.iter().find(|p| p.id == page_id).ok_or_else(|| { BitFunError::tool(format!("Page '{}' not found", page_id)) })?; - let ws_url = page.web_socket_debugger_url.as_ref().ok_or_else(|| { - BitFunError::tool("Page has no WebSocket URL".to_string()) - })?; - let client = CdpClient::connect(ws_url).await?; + let client = Self::connect_page(port, page).await?; let session = BrowserSession { session_id: page.id.clone(), port, @@ -2509,7 +2654,10 @@ mod control_hub_tests { .unwrap_or_default(); assert!(msg.contains("Unknown domain"), "got: {msg}"); for d in ["browser", "terminal", "meta"] { - assert!(msg.contains(d), "valid domain {d} missing from error: {msg}"); + assert!( + msg.contains(d), + "valid domain {d} missing from error: {msg}" + ); } // ComputerUse is a separate tool, not a ControlHub domain — listing it // as one sent models chasing a domain that never existed. @@ -2799,9 +2947,7 @@ mod control_hub_tests { .expect("open_builtin succeeds without a frontend emitter"); let payload = results.first().expect("one result").content(); assert_eq!( - payload - .get("observable_by_agent") - .and_then(|v| v.as_bool()), + payload.get("observable_by_agent").and_then(|v| v.as_bool()), Some(false), "open_builtin must state the panel is not agent-observable: {payload}" ); @@ -2920,7 +3066,7 @@ mod control_hub_tests { ); assert!( err.hints.iter().any(|h| h.contains("mode: \"default\"")), - "hints must offer the default managed-profile mode: {:?}", + "hints must offer the default interactive-browser mode: {:?}", err.hints ); } @@ -2944,12 +3090,14 @@ mod control_hub_tests { } #[test] - fn default_connect_hints_point_to_managed_profile_not_user_debug_port() { + fn default_connect_hints_point_to_guarded_user_profile_not_raw_debug_port() { let hints = ControlHubTool::default_browser_connect_hints(&BrowserKind::Chrome, 9222); let joined = hints.join(" | "); assert!( - joined.contains("managed profile"), - "hints must guide toward BitFun's managed profile launch: {joined}" + joined.contains("current real profile") + && joined.contains("chrome://inspect/#remote-debugging") + && joined.contains("approve"), + "hints must guide toward Chrome's guarded real-profile connection: {joined}" ); assert!( !joined.contains("--remote-debugging-port"), @@ -2957,6 +3105,19 @@ mod control_hub_tests { ); } + #[test] + fn edge_connect_hints_use_its_guarded_real_profile_setup() { + let hints = ControlHubTool::default_browser_connect_hints(&BrowserKind::Edge, 9222); + let joined = hints.join(" | "); + assert!(joined.contains("current real profile"), "{joined}"); + assert!( + joined.contains("edge://inspect/#remote-debugging"), + "{joined}" + ); + assert!(joined.contains("approve"), "{joined}"); + assert!(!joined.contains("--remote-debugging-port"), "{joined}"); + } + #[test] fn browser_open_builtin_normalizes_domain_url() { assert_eq!( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs index 04f847b940..c648d7b22c 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs @@ -40,7 +40,7 @@ Use this tool to: - Download readable content from web pages - Access online resources -Best for static pages that need no login. For pages requiring the user's login session or JavaScript rendering, use ControlHub domain="browser" instead: connect -> navigate -> snapshot / read_article. That drives BitFun's managed browser profile, which is separate from the user's everyday browser, so a first-time sign-in by the user may be required. (browser.fetch only works when a session is already connected and the current page is same-origin with the target URL — it runs inside that page and is subject to its CORS policy.) +Best for static pages that need no login. For pages requiring the user's login session or JavaScript rendering, use ControlHub domain="browser" instead: connect -> navigate -> snapshot / read_article. Chrome 144+ and Edge can connect to the user's current profile after explicit approval, preserving tabs and login state; other supported Chromium browsers reuse a real-profile endpoint when available and otherwise use BitFun's persistent managed profile. (browser.fetch only works when a session is already connected and the current page is same-origin with the target URL — it runs inside that page and is subject to its CORS policy.) Supports different output formats: - raw: Raw response content (original HTML or text) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs index 07ca1af81f..ee57ad1ad5 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs @@ -183,9 +183,10 @@ mod tests { assert!(description.contains("connect -> navigate -> snapshot")); assert!(description.contains("same-origin")); assert!(description.contains("CORS")); - // connect drives BitFun's managed profile, not the user's everyday - // browser, so the description must not promise their login state. - assert!(description.contains("managed browser profile")); + // Guarded Chrome/Edge connections preserve the current profile while + // other browsers may use a persistent managed profile. + assert!(description.contains("current profile")); + assert!(description.contains("managed profile")); } #[test] diff --git a/src/crates/services/services-integrations/src/browser_control/launcher.rs b/src/crates/services/services-integrations/src/browser_control/launcher.rs index b639a76305..554865db1c 100644 --- a/src/crates/services/services-integrations/src/browser_control/launcher.rs +++ b/src/crates/services/services-integrations/src/browser_control/launcher.rs @@ -53,6 +53,16 @@ pub struct BrowserInfo { pub cdp_available: bool, } +/// Browser-level CDP endpoint published by a Chromium browser's user-approved +/// remote debugging flow. Unlike the legacy fixed-port endpoint, this points +/// at the user's real browser profile and the WebSocket handshake requires an +/// explicit approval in the browser. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BrowserDebugEndpoint { + pub port: u16, + pub web_socket_url: String, +} + /// Cache for browser installation status to avoid repeated filesystem checks. /// The cache is valid for the lifetime of the process since browser installations /// don't change during a session. @@ -64,6 +74,11 @@ pub struct BrowserLauncher; pub struct BrowserLaunchOptions { pub user_data_dir: Option, pub managed_profile_root: Option, + /// Wait for the user to enable guarded remote debugging in the browser's + /// settings page. Product surfaces should opt into this only for an + /// explicit setup action; ordinary agent connects should return guidance + /// quickly instead of holding a tool call open. + pub wait_for_user_profile_setup: bool, } impl BrowserLauncher { @@ -325,6 +340,265 @@ impl BrowserLauncher { .join(Self::browser_profile_slug(kind)) } + /// Return the browser's normal user-data directory. Browsers that expose + /// approval-based remote debugging write `DevToolsActivePort` here. + pub fn user_profile_data_dir(kind: &BrowserKind) -> Option { + #[cfg(target_os = "macos")] + { + let home = dirs::home_dir()?; + let application_support = home.join("Library").join("Application Support"); + let relative = match kind { + BrowserKind::Chrome => Path::new("Google/Chrome"), + BrowserKind::Edge => Path::new("Microsoft Edge"), + BrowserKind::Chromium => Path::new("Chromium"), + BrowserKind::Brave => Path::new("BraveSoftware/Brave-Browser"), + BrowserKind::Arc => Path::new("Arc/User Data"), + BrowserKind::Unknown(_) => return None, + }; + return Some(application_support.join(relative)); + } + + #[cfg(target_os = "windows")] + { + let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?; + let relative = match kind { + BrowserKind::Chrome => Path::new("Google/Chrome/User Data"), + BrowserKind::Edge => Path::new("Microsoft/Edge/User Data"), + BrowserKind::Chromium => Path::new("Chromium/User Data"), + BrowserKind::Brave => Path::new("BraveSoftware/Brave-Browser/User Data"), + BrowserKind::Arc => Path::new("Arc/User Data"), + BrowserKind::Unknown(_) => return None, + }; + return Some(local_app_data.join(relative)); + } + + #[cfg(target_os = "linux")] + { + let config_root = std::env::var_os("CHROME_CONFIG_HOME") + .or_else(|| std::env::var_os("XDG_CONFIG_HOME")) + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".config")))?; + let relative = match kind { + BrowserKind::Chrome => Path::new("google-chrome"), + BrowserKind::Edge => Path::new("microsoft-edge"), + BrowserKind::Chromium => Path::new("chromium"), + BrowserKind::Brave => Path::new("BraveSoftware/Brave-Browser"), + BrowserKind::Arc => Path::new("arc"), + BrowserKind::Unknown(_) => return None, + }; + return Some(config_root.join(relative)); + } + } + + fn parse_devtools_active_port(contents: &str) -> Result { + let mut lines = contents + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()); + let raw_port = lines + .next() + .ok_or_else(|| anyhow!("DevToolsActivePort is missing its port"))?; + let web_socket_path = lines + .next() + .ok_or_else(|| anyhow!("DevToolsActivePort is missing its WebSocket path"))?; + let port = raw_port + .parse::() + .map_err(|_| anyhow!("DevToolsActivePort contains an invalid port"))?; + if port == 0 { + return Err(anyhow!("DevToolsActivePort contains port zero")); + } + if !web_socket_path.starts_with("/devtools/browser/") + || web_socket_path.chars().any(char::is_whitespace) + { + return Err(anyhow!( + "DevToolsActivePort contains an invalid browser WebSocket path" + )); + } + + Ok(BrowserDebugEndpoint { + port, + web_socket_url: format!("ws://127.0.0.1:{port}{web_socket_path}"), + }) + } + + /// Discover a browser-level endpoint for the real user profile. This works + /// for every supported Chromium browser that publishes `DevToolsActivePort` + /// in its normal user-data directory. Malformed or stale files are treated + /// as unavailable; the subsequent WebSocket connection is the source of truth. + pub fn user_profile_debug_endpoint(kind: &BrowserKind) -> Option { + let path = Self::user_profile_data_dir(kind)?.join("DevToolsActivePort"); + let contents = std::fs::read_to_string(&path).ok()?; + match Self::parse_devtools_active_port(&contents) { + Ok(endpoint) => { + let address = std::net::SocketAddr::from(([127, 0, 0, 1], endpoint.port)); + if std::net::TcpStream::connect_timeout(&address, Duration::from_millis(150)) + .is_ok() + { + Some(endpoint) + } else { + debug!( + "Ignoring stale browser DevToolsActivePort file at {}", + path.display() + ); + None + } + } + Err(error) => { + debug!( + "Ignoring invalid browser DevToolsActivePort file at {}: {}", + path.display(), + error + ); + None + } + } + } + + fn user_profile_debugging_setup_url(kind: &BrowserKind) -> Option<&'static str> { + match kind { + BrowserKind::Chrome => Some("chrome://inspect/#remote-debugging"), + BrowserKind::Edge => Some("edge://inspect/#remote-debugging"), + _ => None, + } + } + + pub fn supports_default_cdp(kind: &BrowserKind) -> bool { + // Chrome 144+ and current Edge document an inspect-page toggle that + // starts approval-based remote debugging for the normal user profile. + matches!(kind, BrowserKind::Chrome | BrowserKind::Edge) + } + + fn default_cdp_preference_enabled(contents: &str) -> bool { + serde_json::from_str::(contents) + .ok() + .and_then(|value| { + value + .pointer("/devtools/remote_debugging/user-enabled") + .and_then(serde_json::Value::as_bool) + }) + .unwrap_or(false) + } + + /// Whether the browser's persistent, approval-based CDP preference is on. + /// The endpoint is also accepted as proof because the browser may create it + /// before its Local State update has been flushed to disk. + pub fn is_default_cdp_enabled(kind: &BrowserKind) -> bool { + if !Self::supports_default_cdp(kind) { + return false; + } + if Self::user_profile_debug_endpoint(kind).is_some() { + return true; + } + let Some(path) = + Self::user_profile_data_dir(kind).map(|directory| directory.join("Local State")) + else { + return false; + }; + std::fs::read_to_string(path) + .ok() + .is_some_and(|contents| Self::default_cdp_preference_enabled(&contents)) + } + + /// Open the browser's Remote debugging settings page. + /// + /// Chromium drops `chrome://` URLs handed to it on the command line and + /// silently substitutes the New Tab Page, so spawning the executable with + /// the settings URL looks to the user like "the browser opened and nothing + /// happened". macOS can route the URL through the browser's own AppleScript + /// `open location` handler, which is not subject to that filter; other + /// platforms have no equivalent, so the caller must hand the URL to the + /// user instead. Returns whether the page was actually opened. + fn open_user_profile_debugging_setup(kind: &BrowserKind, setup_url: &str) -> bool { + #[cfg(target_os = "macos")] + { + let Some(app_name) = Self::launch_app_name(kind) else { + return false; + }; + let script = format!( + "tell application \"{}\" to open location \"{}\"", + app_name.replace('"', "\\\""), + setup_url + ); + match silent_command("osascript").args(["-e", &script]).output() { + Ok(output) if output.status.success() => true, + Ok(output) => { + debug!( + "Failed to open {} remote debugging settings: {}", + kind, + String::from_utf8_lossy(&output.stderr).trim() + ); + false + } + Err(error) => { + debug!( + "Failed to run osascript for {} remote debugging settings: {}", + kind, error + ); + false + } + } + } + + #[cfg(not(target_os = "macos"))] + { + let _ = setup_url; + // Start the browser when it is not up yet so the user has somewhere + // to paste the URL. Passing the URL itself would only reach the New + // Tab Page, which is what made this flow look broken. + if !Self::is_browser_running(kind) { + let exe = Self::browser_executable(kind); + if let Err(error) = silent_command(&exe).spawn() { + debug!( + "Failed to start {} for remote debugging setup: {}", + kind, error + ); + } + } + false + } + } + + async fn prepare_user_profile_connection( + kind: &BrowserKind, + wait_for_user_setup: bool, + ) -> Result { + if let Some(endpoint) = Self::user_profile_debug_endpoint(kind) { + return Ok(LaunchResult::UserProfileReady { endpoint }); + } + + let setup_url = Self::user_profile_debugging_setup_url(kind) + .ok_or_else(|| anyhow!("{} does not support guarded user-profile CDP", kind))?; + let opened = Self::open_user_profile_debugging_setup(kind, setup_url); + + // An explicit Settings action waits up to one minute so the user can + // tick the browser-owned consent checkbox; an ordinary agent connect + // only waits for the normal-start fast path before returning guidance. + let attempts = if wait_for_user_setup { 240 } else { 8 }; + for _ in 0..attempts { + tokio::time::sleep(Duration::from_millis(250)).await; + if let Some(endpoint) = Self::user_profile_debug_endpoint(kind) { + return Ok(LaunchResult::UserProfileReady { endpoint }); + } + } + + let instructions = if opened { + format!( + "{kind} opened its Remote debugging settings. Turn on \"Allow remote debugging for this browser instance\" there; the browser remembers this preference for normal future starts. Then connect again and approve BitFun's connection request. This guarded flow uses your current browser profile, including its existing tabs and login state." + ) + } else { + format!( + "Open {setup_url} in {kind} and turn on \"Allow remote debugging for this browser instance\"; the browser remembers this preference for normal future starts. Then connect again and approve BitFun's connection request. This guarded flow uses your current browser profile, including its existing tabs and login state." + ) + }; + + Ok(LaunchResult::UserProfileSetupRequired { + browser: kind.to_string(), + setup_url: setup_url.to_string(), + opened, + instructions, + }) + } + fn default_managed_profile_root() -> PathBuf { dirs::data_local_dir() .or_else(dirs::data_dir) @@ -522,6 +796,22 @@ impl BrowserLauncher { port: u16, options: BrowserLaunchOptions, ) -> Result { + if options.user_data_dir.is_none() { + // Opportunistically reuse the real profile for any Chromium browser + // that already publishes a browser-level endpoint. Chrome and Edge + // additionally get a first-class setup flow when it is not enabled. + if let Some(endpoint) = Self::user_profile_debug_endpoint(kind) { + return Ok(LaunchResult::UserProfileReady { endpoint }); + } + if Self::supports_default_cdp(kind) { + return Self::prepare_user_profile_connection( + kind, + options.wait_for_user_profile_setup, + ) + .await; + } + } + if Self::is_cdp_available(port).await { info!("CDP already available on port {} for {}", port, kind); return Ok(LaunchResult::AlreadyConnected); @@ -739,55 +1029,6 @@ impl BrowserLauncher { false } } - - /// Create a macOS `.app` wrapper that launches the browser with CDP enabled. - #[cfg(target_os = "macos")] - pub fn create_cdp_launcher_app(kind: &BrowserKind, port: u16) -> Result { - let app_name = format!("{} Debug", kind); - let app_dir = format!("/Applications/{}.app", app_name); - let macos_dir = format!("{}/Contents/MacOS", app_dir); - let script_path = format!("{}/launch", macos_dir); - let exe = Self::browser_executable(kind); - - std::fs::create_dir_all(&macos_dir) - .map_err(|e| anyhow!("Failed to create app bundle: {}", e))?; - - let script = format!( - "#!/bin/bash\nexec \"{}\" --remote-debugging-port={} \"$@\"\n", - exe, port - ); - std::fs::write(&script_path, &script) - .map_err(|e| anyhow!("Failed to write launcher script: {}", e))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)) - .map_err(|e| anyhow!("Failed to set executable permission: {}", e))?; - } - - let plist = format!( - r#" - - - - CFBundleName - {} - CFBundleExecutable - launch - CFBundleIdentifier - com.bitfun.browser-debug-launcher - -"#, - app_name - ); - - std::fs::write(format!("{}/Contents/Info.plist", app_dir), &plist) - .map_err(|e| anyhow!("Failed to write Info.plist: {}", e))?; - - info!("Created CDP launcher app at {}", app_dir); - Ok(app_dir) - } } /// Result of a browser launch attempt. @@ -795,6 +1036,17 @@ impl BrowserLauncher { pub enum LaunchResult { AlreadyConnected, Launched, + UserProfileReady { + endpoint: BrowserDebugEndpoint, + }, + UserProfileSetupRequired { + browser: String, + setup_url: String, + /// Whether the settings page could be opened for the user. Platforms + /// without a browser automation entry point can only show the URL. + opened: bool, + instructions: String, + }, LaunchedButCdpNotReady { port: u16, message: String, @@ -843,4 +1095,54 @@ mod tests { ); assert_eq!(dir, root.join("browser-control").join("custom-browser")); } + + #[test] + fn devtools_active_port_parser_accepts_guarded_browser_endpoint() { + let endpoint = BrowserLauncher::parse_devtools_active_port( + "62314\n/devtools/browser/598cf21d-ec63-45f3-abba-698f26a88807\n", + ) + .expect("valid endpoint"); + + assert_eq!(endpoint.port, 62314); + assert_eq!( + endpoint.web_socket_url, + "ws://127.0.0.1:62314/devtools/browser/598cf21d-ec63-45f3-abba-698f26a88807" + ); + } + + #[test] + fn devtools_active_port_parser_rejects_non_browser_paths() { + let error = BrowserLauncher::parse_devtools_active_port( + "9222\nhttp://attacker.example/devtools/browser/token\n", + ) + .expect_err("non-local path must be rejected"); + + assert!(error.to_string().contains("invalid browser WebSocket path")); + } + + #[test] + fn guarded_real_profile_setup_is_available_for_chrome_and_edge() { + assert!(BrowserLauncher::supports_default_cdp(&BrowserKind::Chrome)); + assert!(BrowserLauncher::supports_default_cdp(&BrowserKind::Edge)); + assert_eq!( + BrowserLauncher::user_profile_debugging_setup_url(&BrowserKind::Chrome), + Some("chrome://inspect/#remote-debugging") + ); + assert_eq!( + BrowserLauncher::user_profile_debugging_setup_url(&BrowserKind::Edge), + Some("edge://inspect/#remote-debugging") + ); + assert!(!BrowserLauncher::supports_default_cdp(&BrowserKind::Brave)); + } + + #[test] + fn default_cdp_preference_reads_chromium_local_state_shape() { + assert!(BrowserLauncher::default_cdp_preference_enabled( + r#"{"devtools":{"remote_debugging":{"user-enabled":true}}}"# + )); + assert!(!BrowserLauncher::default_cdp_preference_enabled( + r#"{"devtools":{"remote_debugging":{"user-enabled":false}}}"# + )); + assert!(!BrowserLauncher::default_cdp_preference_enabled("{}")); + } } diff --git a/src/crates/services/services-integrations/src/browser_control/mod.rs b/src/crates/services/services-integrations/src/browser_control/mod.rs index 930adab1ad..e9df814b57 100644 --- a/src/crates/services/services-integrations/src/browser_control/mod.rs +++ b/src/crates/services/services-integrations/src/browser_control/mod.rs @@ -10,5 +10,6 @@ pub mod launcher; pub use cdp::{CdpEndpointProvider, CdpPageInfo, CdpVersionInfo}; pub use launcher::{ - BrowserKind, BrowserLaunchOptions, BrowserLauncher, LaunchResult, DEFAULT_CDP_PORT, + BrowserDebugEndpoint, BrowserKind, BrowserLaunchOptions, BrowserLauncher, LaunchResult, + DEFAULT_CDP_PORT, }; diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index 3fecf2894f..ee951e4fd3 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -71,6 +71,7 @@ type BrowserControlLaunchResponse = { status: string; message: string | null; browserKind: string; + setupUrl?: string; }; type BrowserControlBrowserOption = { @@ -142,6 +143,8 @@ const SessionSettingsPanels: React.FC = ({ variant } // ── Browser control state ─────────────────────────────────────────────── const [browserCdpAvailable, setBrowserCdpAvailable] = useState(false); + const [browserDefaultCdpSupported, setBrowserDefaultCdpSupported] = useState(false); + const [browserDefaultCdpEnabled, setBrowserDefaultCdpEnabled] = useState(false); const [browserKind, setBrowserKind] = useState(''); const [browserVersion, setBrowserVersion] = useState(null); const [browserPageCount, setBrowserPageCount] = useState(0); @@ -186,6 +189,8 @@ const SessionSettingsPanels: React.FC = ({ variant } const [s, browsers] = await Promise.all([ invoke<{ cdpAvailable: boolean; + defaultCdpSupported: boolean; + defaultCdpEnabled: boolean; browserKind: string; browserVersion: string | null; port: number; @@ -194,6 +199,8 @@ const SessionSettingsPanels: React.FC = ({ variant } invoke<{ options: BrowserControlBrowserOption[] }>('browser_control_list_browsers'), ]); setBrowserCdpAvailable(s.cdpAvailable); + setBrowserDefaultCdpSupported(s.defaultCdpSupported); + setBrowserDefaultCdpEnabled(s.defaultCdpEnabled); setBrowserKind(s.browserKind); setBrowserVersion(s.browserVersion); setBrowserPageCount(s.pageCount); @@ -639,21 +646,45 @@ const SessionSettingsPanels: React.FC = ({ variant } } }; + const presentBrowserControlLaunchResult = (result: BrowserControlLaunchResponse) => { + if (result.success) { + notificationService.success( + t('browserControl.connectSuccess', { browser: result.browserKind }), + { duration: 3000 } + ); + } else if (result.status === 'requires_user_profile_setup') { + notificationService.info( + t('browserControl.userProfileSetupRequired', { browser: result.browserKind }), + { duration: 12000 } + ); + } else if (result.status === 'requires_manual_user_profile_setup') { + // The platform could not open the settings page, so the URL itself is + // the actionable part of the message. + notificationService.info( + t('browserControl.userProfileSetupManual', { + browser: result.browserKind, + url: result.setupUrl ?? '', + }), + { duration: 20000 } + ); + } else if (result.status === 'user_profile_connection_failed') { + notificationService.info( + t('browserControl.userProfileConnectionFailed', { browser: result.browserKind }), + { duration: 12000 } + ); + } else if (result.status === 'needs_restart') { + setBrowserRestartPrompt(result); + } else if (result.message) { + notificationService.info(result.message, { duration: 8000 }); + } + }; + const handleBrowserControlLaunch = async () => { setBrowserControlBusy(true); try { const { invoke } = await import('@tauri-apps/api/core'); const result = await invoke('browser_control_launch', { request: { port: 9222 } }); - if (result.success) { - notificationService.success( - t('browserControl.connectSuccess', { browser: result.browserKind }), - { duration: 3000 } - ); - } else if (result.status === 'needs_restart') { - setBrowserRestartPrompt(result); - } else if (result.message) { - notificationService.info(result.message, { duration: 8000 }); - } + presentBrowserControlLaunchResult(result); await refreshBrowserControlStatus(); } catch (error) { log.error('browser_control_launch failed', error); @@ -663,6 +694,33 @@ const SessionSettingsPanels: React.FC = ({ variant } } }; + const handleBrowserControlEnableDefaultCdp = async () => { + setBrowserControlBusy(true); + try { + notificationService.info( + t( + browserDefaultCdpEnabled + ? 'browserControl.defaultCdpConnectPrompt' + : 'browserControl.defaultCdpEnablePrompt', + { browser: browserKind }, + ), + { duration: 12000 }, + ); + const { invoke } = await import('@tauri-apps/api/core'); + const result = await invoke( + 'browser_control_enable_default_cdp', + { request: { port: 9222 } }, + ); + presentBrowserControlLaunchResult(result); + await refreshBrowserControlStatus(); + } catch (error) { + log.error('browser_control_enable_default_cdp failed', error); + notificationService.error(t('browserControl.connectFailed')); + } finally { + setBrowserControlBusy(false); + } + }; + const handleBrowserControlRestart = async () => { if (!browserRestartPrompt) return; setBrowserControlBusy(true); @@ -689,23 +747,6 @@ const SessionSettingsPanels: React.FC = ({ variant } } }; - const handleBrowserControlCreateLauncher = async () => { - setBrowserControlBusy(true); - try { - const { invoke } = await import('@tauri-apps/api/core'); - const path = await invoke('browser_control_create_launcher'); - notificationService.success( - t('browserControl.createLauncherSuccess', { path }), - { duration: 5000 } - ); - } catch (error) { - log.error('browser_control_create_launcher failed', error); - notificationService.error(t('browserControl.createLauncherFailed')); - } finally { - setBrowserControlBusy(false); - } - }; - const handleToolTimeoutChange = async (value: string) => { const configKey = 'ai.tool_execution_timeout_secs'; const trimmedValue = value.trim(); @@ -1439,6 +1480,47 @@ const SessionSettingsPanels: React.FC = ({ variant } )} + {browserDefaultCdpSupported && ( + +
+ + {t(browserDefaultCdpEnabled + ? 'browserControl.defaultCdpEnabled' + : 'browserControl.defaultCdpDisabled')} + + {!browserCdpAvailable && ( + + )} +
+
+ )} = ({ variant } - {!browserCdpAvailable && ( + {!browserCdpAvailable && !browserDefaultCdpSupported && ( - - - )} ) : null} diff --git a/src/web-ui/src/locales/en-US/settings/session-config.json b/src/web-ui/src/locales/en-US/settings/session-config.json index 782d69696a..823e5bf431 100644 --- a/src/web-ui/src/locales/en-US/settings/session-config.json +++ b/src/web-ui/src/locales/en-US/settings/session-config.json @@ -140,7 +140,7 @@ }, "browserControl": { "sectionTitle": "Browser control", - "sectionDescription": "Choose a browser and start CDP control.", + "sectionDescription": "Choose which browser BitFun controls. Chrome and Edge can use the window you already have open; other browsers get a separate BitFun window.", "desktopOnly": "Browser control is only available in the BitFun desktop app.", "preferredBrowser": "Browser", "preferredBrowserDesc": "Choose which browser BitFun controls through CDP. Default follows the system default browser.", @@ -150,8 +150,18 @@ "notConnected": "Not connected", "refreshStatus": "Refresh status", "connect": "Connect", + "defaultCdp": "Default CDP", + "defaultCdpDesc": "Let BitFun use the Chrome or Edge you already have open, with your existing tabs and signed-in accounts, instead of starting a separate empty browser. You grant this once in the browser, and the browser still asks you to confirm each connection.", + "defaultCdpEnabled": "Enabled", + "defaultCdpDisabled": "Not enabled", + "enableDefaultCdp": "Enable default CDP", + "defaultCdpEnablePrompt": "Opened {{browser}}'s Remote debugging page. Tick “Allow remote debugging for this browser instance”; BitFun will detect it and continue automatically, then choose “Allow” in the browser connection prompt.", + "defaultCdpConnectPrompt": "Connecting to your current {{browser}}. Choose “Allow” in the browser prompt.", "connectSuccess": "Connected to {{browser}}", "connectFailed": "Failed to connect to browser", + "userProfileSetupRequired": "{{browser}} opened its Remote debugging page, but the switch was not detected before the wait ended. Tick “Allow remote debugging for this browser instance”, then click Enable default CDP again; your current tabs and login state are preserved.", + "userProfileSetupManual": "Open {{url}} in {{browser}} and tick “Allow remote debugging for this browser instance”, then click Enable default CDP again; your current tabs and login state are preserved.", + "userProfileConnectionFailed": "{{browser}} did not approve the connection, or the request timed out. Make sure the browser is running and Remote debugging is enabled, then connect again and choose Allow in the browser.", "restartSuccess": "Restarted {{browser}} with debug mode enabled", "restartFailed": "Failed to restart browser with debug mode enabled", "restartModal": { @@ -162,10 +172,6 @@ "confirm": "Restart and enable debug", "restarting": "Restarting..." }, - "createLauncher": "Create launcher", - "createLauncherSuccess": "Launcher created at {{path}}", - "createLauncherFailed": "Failed to create launcher", - "createLauncherDesc": "Create a browser shortcut with the debug port enabled.", "tabs": "tabs" }, "common": { diff --git a/src/web-ui/src/locales/zh-CN/settings/session-config.json b/src/web-ui/src/locales/zh-CN/settings/session-config.json index e9a867620d..15331ce00b 100644 --- a/src/web-ui/src/locales/zh-CN/settings/session-config.json +++ b/src/web-ui/src/locales/zh-CN/settings/session-config.json @@ -140,7 +140,7 @@ }, "browserControl": { "sectionTitle": "浏览器控制", - "sectionDescription": "选择浏览器并启动 CDP 控制。", + "sectionDescription": "选择由 BitFun 控制的浏览器。Chrome 和 Edge 可以直接使用你正在用的窗口,其他浏览器会另开一个 BitFun 专用窗口。", "desktopOnly": "浏览器控制仅在 BitFun 桌面应用中可用。", "preferredBrowser": "浏览器", "preferredBrowserDesc": "选择要由 BitFun 通过 CDP 控制的浏览器;默认表示跟随系统默认浏览器。", @@ -150,8 +150,18 @@ "notConnected": "未连接", "refreshStatus": "刷新状态", "connect": "连接浏览器", + "defaultCdp": "默认 CDP", + "defaultCdpDesc": "让 BitFun 直接使用你正在用的 Chrome 或 Edge,保留已打开的标签页和登录状态,不必另开一个空白浏览器。首次需要在浏览器中授权,之后每次连接也会由浏览器向你确认。", + "defaultCdpEnabled": "已启用", + "defaultCdpDisabled": "未启用", + "enableDefaultCdp": "启用默认 CDP", + "defaultCdpEnablePrompt": "已打开 {{browser}} 的 Remote debugging 设置页(该页面仅有英文)。请勾选 “Allow remote debugging for this browser instance”;BitFun 检测到后会自动继续连接,再请在浏览器中选择“允许”。", + "defaultCdpConnectPrompt": "正在连接你当前的 {{browser}};请在浏览器弹窗中选择“允许”。", "connectSuccess": "已连接 {{browser}}", "connectFailed": "连接浏览器失败", + "userProfileSetupRequired": "{{browser}} 已打开 Remote debugging 设置页,但等待期间未检测到开关生效。请勾选 “Allow remote debugging for this browser instance” 后再次点击“启用默认 CDP”;当前标签页和登录状态会被保留。", + "userProfileSetupManual": "请在 {{browser}} 中打开 {{url}},勾选 “Allow remote debugging for this browser instance”,然后再次点击“启用默认 CDP”;当前标签页和登录状态会被保留。", + "userProfileConnectionFailed": "{{browser}} 未允许连接,或连接请求已超时。请确认浏览器正在运行且已启用远程调试,然后重新连接并在浏览器中选择“允许”。", "restartSuccess": "已重启 {{browser}} 并启用调试模式", "restartFailed": "重启浏览器并启用调试失败", "restartModal": { @@ -162,10 +172,6 @@ "confirm": "重启并启用调试", "restarting": "正在重启..." }, - "createLauncher": "创建启动器", - "createLauncherSuccess": "启动器已创建:{{path}}", - "createLauncherFailed": "创建启动器失败", - "createLauncherDesc": "创建带调试端口的浏览器快捷方式。", "tabs": "个标签页" }, "common": { diff --git a/src/web-ui/src/locales/zh-TW/settings/session-config.json b/src/web-ui/src/locales/zh-TW/settings/session-config.json index 2363f928ac..526b794b5b 100644 --- a/src/web-ui/src/locales/zh-TW/settings/session-config.json +++ b/src/web-ui/src/locales/zh-TW/settings/session-config.json @@ -140,15 +140,25 @@ }, "browserControl": { "sectionTitle": "瀏覽器控制", - "sectionDescription": "選擇瀏覽器並啟用 CDP 控制。", + "sectionDescription": "選擇由 BitFun 控制的瀏覽器。Chrome 和 Edge 可以直接使用你正在用的視窗,其他瀏覽器會另開一個 BitFun 專用視窗。", "desktopOnly": "瀏覽器控制僅在 BitFun 桌面應用中可用。", "status": "連接狀態", "statusDesc": "", "notConnected": "未連接", "refreshStatus": "重新整理狀態", "connect": "連接瀏覽器", + "defaultCdp": "預設 CDP", + "defaultCdpDesc": "讓 BitFun 直接使用你正在用的 Chrome 或 Edge,保留已開啟的分頁和登入狀態,不必另外開一個空白瀏覽器。首次需要在瀏覽器中授權,之後每次連線也會由瀏覽器向你確認。", + "defaultCdpEnabled": "已啟用", + "defaultCdpDisabled": "未啟用", + "enableDefaultCdp": "啟用預設 CDP", + "defaultCdpEnablePrompt": "已開啟 {{browser}} 的 Remote debugging 設定頁(該頁面僅有英文)。請勾選 “Allow remote debugging for this browser instance”;BitFun 偵測到後會自動繼續連線,再請在瀏覽器中選擇「允許」。", + "defaultCdpConnectPrompt": "正在連接你目前的 {{browser}};請在瀏覽器彈出視窗中選擇「允許」。", "connectSuccess": "已連接 {{browser}}", "connectFailed": "連接瀏覽器失敗", + "userProfileSetupRequired": "{{browser}} 已開啟 Remote debugging 設定頁,但等待期間未偵測到開關生效。請勾選 “Allow remote debugging for this browser instance” 後再次點擊「啟用預設 CDP」;目前的分頁和登入狀態會被保留。", + "userProfileSetupManual": "請在 {{browser}} 中開啟 {{url}},勾選 “Allow remote debugging for this browser instance”,然後再次點擊「啟用預設 CDP」;目前的分頁和登入狀態會被保留。", + "userProfileConnectionFailed": "{{browser}} 未允許連線,或連線要求已逾時。請確認瀏覽器正在執行且已啟用遠端偵錯,然後重新連線並在瀏覽器中選擇「允許」。", "restartSuccess": "已重啟 {{browser}} 並啟用調試模式", "restartFailed": "重啟瀏覽器並啟用調試失敗", "restartModal": { @@ -159,10 +169,6 @@ "confirm": "重啟並啟用調試", "restarting": "正在重啟..." }, - "createLauncher": "建立啟動器", - "createLauncherSuccess": "啟動器已建立:{{path}}", - "createLauncherFailed": "建立啟動器失敗", - "createLauncherDesc": "建立帶調試端口的瀏覽器快捷方式。", "tabs": "個標籤頁", "preferredBrowser": "瀏覽器", "preferredBrowserDesc": "選擇要由 BitFun 透過 CDP 控制的瀏覽器;預設表示跟隨系統預設瀏覽器。",