From c8a0569e5042ad7f55c3d1b008f7bdb2b8a9ecd4 Mon Sep 17 00:00:00 2001 From: Dang Zitou Date: Mon, 28 Sep 2026 01:52:07 +0800 Subject: [PATCH] feat(cli): add `bsk preflight` conflict grading for parallel tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two automation tasks sharing one browser interfere invisibly: sessions share the profile's login state, so two tasks automating the same logged-in site can overwrite each other's page state with no error; and when the number of running Agent Windows no longer matches the number of parallel tasks, tasks share one session and tab refs bleed between them. `bsk preflight --url … [--expected-parallel ]` lists sessions and their agent tabs through the existing `system.session_list` / `tool.tab_list` RPCs and grades what it sees: - P0 (exit 2): a target host is already automated by an active session. - P1 (exit 1): expected-parallel count exceeds the active sessions. - P2 (exit 0): no conflict. Read-only: no state is created or changed, so a wrong answer costs at most a redundant task. The tab-contention check from the issue is left out for now — an agent rarely knows the tab id it will borrow before running — and the "how to resolve a detected conflict" policy stays an open issue question. --- crates/bsk-cli/src/cli/mod.rs | 6 + crates/bsk-cli/src/cli/preflight.rs | 234 ++++++++++++++++++++++++++++ crates/bsk-cli/src/main.rs | 1 + 3 files changed, 241 insertions(+) create mode 100644 crates/bsk-cli/src/cli/preflight.rs diff --git a/crates/bsk-cli/src/cli/mod.rs b/crates/bsk-cli/src/cli/mod.rs index 32afe05d..57696cb5 100644 --- a/crates/bsk-cli/src/cli/mod.rs +++ b/crates/bsk-cli/src/cli/mod.rs @@ -25,6 +25,7 @@ pub mod logs; pub mod navigate; pub mod network; pub mod observe; +pub mod preflight; pub mod record; pub mod record_recovery; pub mod record_state; @@ -57,6 +58,7 @@ use crate::cli::interaction::{ use crate::cli::navigate::{NavigateCommand, NavigateHistoryArgs, ReloadArgs}; use crate::cli::network::NetworkArgs; use crate::cli::observe::ObserveArgs; +use crate::cli::preflight::PreflightArgs; use crate::cli::record::RecordCmd; use crate::cli::screenshot::ScreenshotArgs; use crate::cli::scroll::ScrollToArgs; @@ -157,6 +159,10 @@ pub enum Command { /// Read buffered network responses / failures. Network(NetworkArgs), + /// Check for cross-task browser conflicts before starting another + /// parallel automation in the same browser. + Preflight(PreflightArgs), + /// Opt-in website debugging: requests, page context, and recording export. Debug(Box), diff --git a/crates/bsk-cli/src/cli/preflight.rs b/crates/bsk-cli/src/cli/preflight.rs new file mode 100644 index 00000000..ea509c0d --- /dev/null +++ b/crates/bsk-cli/src/cli/preflight.rs @@ -0,0 +1,234 @@ +//! `bsk preflight` — detect browser-automation conflicts before +//! starting another parallel task in the same browser. +//! +//! Two tasks sharing one browser interfere in ways a *tool* cannot +//! diagnose afterwards: +//! +//! 1. **Same-domain login state** — every session in one browser +//! shares the profile's cookies, so two tasks automating the same +//! logged-in site can overwrite each other's page state with no +//! visible error. +//! 2. **Session mixing** — when the number of running Agent Windows no +//! longer matches the number of parallel tasks, tasks share one +//! session and tab refs (`@eN`) bleed between them. +//! +//! The check is read-only: it lists sessions and tabs through the usual +//! RPCs, grades what it sees, and exits with the grade. + +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use bsk_protocol::Method; +use bsk_protocol::system::SessionStatusEntry; +use bsk_protocol::tools::{TabInfo, TabListParams, TabListResult, TabScope}; +use serde::Serialize; + +use crate::cli::business_rpc; +use crate::cli::ensure_daemon::ensure_daemon; +use crate::cli::error::{CliError, Format}; + +/// IPC read budget for the preflight queries. These are cheap +/// enumerations; the daemon answers them without waiting on a browser. +const IPC_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, clap::Args)] +pub struct PreflightArgs { + /// URL the task is about to automate. Repeatable. + #[arg(long, required = true)] + pub url: Vec, + + /// Number of parallel tasks the caller intends to run. When given, + /// a mismatch with the active session count is reported. + #[arg(long)] + pub expected_parallel: Option, +} + +/// Conflict grades, from harmless to blocking. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Grade { + /// No conflict detected. + P2, + /// Worth a warning: session mixing. + P1, + /// Blocking: same-domain login state is already in use. + P0, +} + +impl Grade { + fn exit_code(self) -> u8 { + match self { + Grade::P2 => 0, + Grade::P1 => 1, + Grade::P0 => 2, + } + } + + fn label(self) -> &'static str { + match self { + Grade::P0 => "P0", + Grade::P1 => "P1", + Grade::P2 => "P2", + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Conflict { + pub grade: Grade, + pub kind: &'static str, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PreflightReport { + pub grade: Grade, + pub target_hosts: Vec, + pub active_sessions: usize, + pub conflicts: Vec, +} + +pub fn dispatch(args: PreflightArgs, format: Format) -> Result<(), CliError> { + let info = ensure_daemon().context("ensure daemon is running")?; + let report = check(info.sock_path, &args)?; + match format { + Format::Human => render_human(&report), + Format::Json => { + println!( + "{}", + serde_json::to_string_pretty(&report) + .map_err(|e| CliError::Local(anyhow::anyhow!(e)))? + ); + } + } + if report.grade == Grade::P2 { + Ok(()) + } else { + // The report is the deliverable; its grade is the exit status. + Err(CliError::RenderedExit { + exit_code: report.grade.exit_code(), + }) + } +} + +/// Collect state through the usual RPCs and grade it. Read-only: the +/// worst outcome of a wrong answer is a redundant task, never a broken +/// one, so every source degrades to "unknown" on error. +fn check(sock: PathBuf, args: &PreflightArgs) -> Result { + let target_hosts: Vec = args + .url + .iter() + .map(|url| { + host_of(url).ok_or_else(|| { + CliError::Local(anyhow::anyhow!("--url {url} is not a URL with a host")) + }) + }) + .collect::>()?; + + let sessions: Vec = { + #[derive(serde::Deserialize)] + struct ListReply { + sessions: Vec, + } + business_rpc::call::<(), ListReply>( + sock.clone(), + "preflight", + Method::SessionList, + None, + IPC_TIMEOUT, + )? + .sessions + }; + + let mut conflicts = Vec::new(); + for host in &target_hosts { + for session in &sessions { + for tab in agent_tabs(&sock, &session.session_id)? { + let Some(tab_host) = tab.url.as_deref().and_then(host_of) else { + continue; + }; + if tab_host.eq_ignore_ascii_case(host) { + conflicts.push(Conflict { + grade: Grade::P0, + kind: "same_domain", + detail: format!( + "host {host} is already automated by session {} (tab {}); \ + sessions share one browser profile's login state", + session.session_id, tab.tab_id + ), + }); + } + } + } + } + + if let Some(expected) = args.expected_parallel { + if expected > sessions.len() { + conflicts.push(Conflict { + grade: Grade::P1, + kind: "session_mixup", + detail: format!( + "expected {expected} parallel tasks but {n} session(s) are active; \ + tasks may be sharing one Agent Window and its tab refs", + n = sessions.len() + ), + }); + } + } + + let grade = conflicts.iter().map(|c| c.grade).max().unwrap_or(Grade::P2); + Ok(PreflightReport { + grade, + target_hosts, + active_sessions: sessions.len(), + conflicts, + }) +} + +fn agent_tabs(sock: &std::path::Path, session_id: &str) -> Result, CliError> { + let params = TabListParams { + session_id: session_id.to_string(), + scope: TabScope::Agent, + }; + let reply: TabListResult = business_rpc::call( + sock.to_path_buf(), + "preflight", + Method::ToolTabList, + Some(params), + IPC_TIMEOUT, + )?; + Ok(reply.tabs) +} + +/// Host of a URL, URL-normalized (lowercase) so both sides of the +/// domain comparison are case-stable. +fn host_of(url: &str) -> Option { + reqwest::Url::parse(url) + .ok() + .and_then(|u| u.host_str().map(String::from)) +} + +fn render_human(report: &PreflightReport) { + println!( + "preflight: {} ({} active session{})", + report.grade.label(), + report.active_sessions, + if report.active_sessions == 1 { "" } else { "s" } + ); + for host in &report.target_hosts { + println!(" target: {host}"); + } + if report.conflicts.is_empty() { + println!(" no conflicts detected"); + return; + } + for conflict in &report.conflicts { + println!( + " {} [{}]: {}", + conflict.grade.label(), + conflict.kind, + conflict.detail + ); + } +} diff --git a/crates/bsk-cli/src/main.rs b/crates/bsk-cli/src/main.rs index 529f3da7..076a5b97 100644 --- a/crates/bsk-cli/src/main.rs +++ b/crates/bsk-cli/src/main.rs @@ -88,6 +88,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> { Command::Console(args) => cli::console::dispatch(args, format), Command::Debug(args) => cli::debug::dispatch(*args, format), Command::Network(args) => cli::network::dispatch(args, format), + Command::Preflight(args) => cli::preflight::dispatch(args, format), Command::GetHtml(args) => cli::get_html::dispatch(args, format), Command::Navigate(args) => cli::navigate::dispatch_navigate_command(args, format), Command::NavigateBack(args) => cli::navigate::dispatch_navigate_back(args, format),