1use anyhow::{Context, Result};
8use serde_json::{json, Value};
9use std::collections::HashMap;
10use std::sync::Arc;
11use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
12use tokio::sync::{Mutex, RwLock};
13
14use crate::cli::env_resolver::ResolvedBrowser;
15use crate::session::backend::TabBackend;
16
17pub type BidiCache = Arc<Mutex<Option<Arc<crate::bidi::BidiClient>>>>;
22
23#[derive(Clone)]
35pub struct ServerState {
36 pub browser: Arc<RwLock<ResolvedBrowser>>,
37 pub bidi: BidiCache,
38 pub bidi_lock: Arc<Mutex<BidiLockState>>,
45 pub backend: Arc<Mutex<Option<TabBackend>>>,
50 pub active_target_id: Arc<Mutex<Option<String>>>,
55 pub origin_target_ids: Arc<Mutex<HashMap<String, String>>>,
61 pub sidecar: Arc<Mutex<Option<crate::sidecar::Sidecar>>>,
68 pub sidecar_config: crate::sidecar::SidecarConfig,
71 pub refs: Arc<Mutex<HashMap<String, crate::a11y::RefTable>>>,
77 pub capture: Arc<crate::session::capture::CaptureHub>,
80 pub downloads: Arc<crate::session::downloads::DownloadHub>,
84 pub download_dir: Option<std::path::PathBuf>,
88 pub op_barrier: Arc<RwLock<()>>,
98}
99
100#[derive(Default)]
104pub enum BidiLockState {
105 #[default]
106 Pending,
107 Acquired(crate::registry::BidiLockGuard),
108 NotApplicable,
109}
110
111impl ServerState {
112 pub fn new(browser: ResolvedBrowser) -> Self {
113 Self::with_sidecar_config(browser, crate::sidecar::SidecarConfig::default())
114 }
115
116 pub fn with_sidecar_config(
119 browser: ResolvedBrowser,
120 sidecar_config: crate::sidecar::SidecarConfig,
121 ) -> Self {
122 Self {
123 browser: Arc::new(RwLock::new(browser)),
124 bidi: Arc::new(Mutex::new(None)),
125 bidi_lock: Arc::new(Mutex::new(BidiLockState::Pending)),
126 backend: Arc::new(Mutex::new(None)),
127 active_target_id: Arc::new(Mutex::new(None)),
128 origin_target_ids: Arc::new(Mutex::new(HashMap::new())),
129 sidecar: Arc::new(Mutex::new(None)),
130 sidecar_config,
131 refs: Arc::new(Mutex::new(HashMap::new())),
132 capture: Arc::new(crate::session::capture::CaptureHub::new()),
133 downloads: Arc::new(crate::session::downloads::DownloadHub::new()),
134 download_dir: None,
135 op_barrier: Arc::new(RwLock::new(())),
136 }
137 }
138
139 pub async fn ensure_body_capture_supported(&self, tool_name: &str) -> Result<()> {
143 self.ensure_cdp_engine(tool_name, crate::session::capture::BIDI_NO_BODIES_HINT)
144 .await
145 }
146
147 pub async fn ensure_cdp_engine(&self, tool_name: &str, hint: &'static str) -> Result<()> {
150 self.ensure_active_browser_alive().await?;
151 let resolved = self.browser_snapshot().await;
152 if resolved.engine != crate::detect::Engine::Cdp {
153 return Err(crate::errors::SessionError::EngineUnsupported {
154 tool: tool_name.to_string(),
155 required_engine: "Chromium (CDP)".into(),
156 current_engine: format!("{:?}", resolved.engine),
157 hint,
158 }
159 .into());
160 }
161 Ok(())
162 }
163
164 pub async fn ensure_native_ready(&self, _tool_name: &str) -> Result<()> {
170 self.ensure_active_browser_alive().await
171 }
172
173 pub async fn ensure_sidecar(&self, tool_name: &str) -> Result<crate::sidecar::Sidecar> {
180 self.ensure_sidecar_supported(tool_name).await?;
181 let resolved = self.browser_snapshot().await;
182 let mut guard = self.sidecar.lock().await;
183 if let Some(sc) = guard.as_ref() {
184 return Ok(sc.clone());
185 }
186 let sc = crate::sidecar::Sidecar::start(self.sidecar_config.clone()).await?;
187 sc.connect(&resolved.endpoint).await?;
188 *guard = Some(sc.clone());
189 drop(guard);
190 let backend = self.backend.lock().await.clone();
194 if let Some(b) = backend {
195 self.ensure_downloads(&b, true).await;
196 }
197 Ok(sc)
198 }
199
200 pub async fn ensure_sidecar_supported(&self, tool_name: &str) -> Result<()> {
203 self.ensure_cdp_engine(
204 tool_name,
205 "use engine-agnostic tools such as browser_get_html, browser_fetch, browser_take_screenshot, or switch to a Chromium browser via browser_select",
206 )
207 .await
208 }
209
210 pub async fn reset_sidecar(&self) {
214 let mut sidecar = self.sidecar.lock().await;
215 let _ = sidecar.take();
216 self.downloads.invalidate();
219 }
220
221 pub async fn browser_snapshot(&self) -> ResolvedBrowser {
223 self.browser.read().await.clone()
224 }
225
226 pub async fn ensure_bidi_lock(&self) -> Result<()> {
230 use crate::cli::env_resolver::Source;
231 use crate::cli::mcp::acquire_bidi_lock_if_needed;
232 use crate::detect::Engine;
233 use crate::registry::Registry;
234 let mut guard = self.bidi_lock.lock().await;
235 if matches!(*guard, BidiLockState::Pending) {
236 let resolved = self.browser_snapshot().await;
237 if resolved.engine != Engine::Bidi || matches!(resolved.source, Source::External) {
238 *guard = BidiLockState::NotApplicable;
239 return Ok(());
240 }
241 let acquired = tokio::task::spawn_blocking(move || {
247 let registry = Registry::open()?;
248 acquire_bidi_lock_if_needed(®istry, &resolved)
249 })
250 .await??;
251 *guard = match acquired {
252 Some(lock) => BidiLockState::Acquired(lock),
253 None => BidiLockState::NotApplicable,
254 };
255 }
256 Ok(())
257 }
258
259 pub async fn ensure_backend(&self) -> Result<TabBackend> {
264 self.ensure_active_browser_alive().await?;
265 self.ensure_bidi_lock().await?;
266 let b = {
267 let mut guard = self.backend.lock().await;
268 match guard.as_ref() {
269 Some(b) => b.clone(),
270 None => {
271 let resolved = self.browser_snapshot().await;
272 let b =
273 crate::session::backend::open_backend(&resolved.endpoint, resolved.engine)
274 .await?;
275 *guard = Some(b.clone());
276 b
277 }
278 }
279 };
280 self.ensure_downloads(&b, false).await;
281 Ok(b)
282 }
283
284 pub fn touch_tab(&self, backend: &TabBackend, target_id: &str) {
287 self.downloads.touch(target_id);
288 self.capture.touch(backend, target_id);
289 }
290
291 pub async fn ensure_downloads(&self, backend: &TabBackend, force: bool) {
297 use crate::cli::env_resolver::Source;
298 use crate::session::downloads::{Mode, UNMANAGED_ATTACHED};
299 if self.downloads.mode_for(backend).is_none() {
300 let resolved = self.browser_snapshot().await;
301 let mode = match resolved.source {
302 Source::External => Mode::Unmanaged {
303 reason: UNMANAGED_ATTACHED,
304 },
305 Source::Registered { name } => match sync_registry_op(move |reg| {
306 let Some(row) = reg.get_by_name(&name)? else {
307 return Ok(Mode::Unmanaged {
308 reason: UNMANAGED_ATTACHED,
309 });
310 };
311 if !crate::paths::is_owned_profile(&row.profile_dir) {
312 return Ok(Mode::Unmanaged {
313 reason: UNMANAGED_ATTACHED,
314 });
315 }
316 Ok(Mode::Managed {
317 staging: crate::paths::downloads_staging_dir(row.kind)?,
318 })
319 })
320 .await
321 {
322 Ok(mode) => mode,
323 Err(e) => {
324 tracing::debug!(error = %e, "resolving download mode failed");
328 return;
329 }
330 },
331 };
332 self.downloads.set_mode(backend, mode);
333 }
334 self.downloads.apply(backend, force).await;
335 }
336
337 pub async fn ensure_active_browser_alive(&self) -> Result<()> {
345 use crate::cli::env_resolver::Source;
346 use crate::registry::BrowserLiveness;
347
348 let resolved = self.browser_snapshot().await;
349 let Source::Registered { name } = resolved.source else {
350 return Ok(());
351 };
352 sync_registry_op(move |registry| {
353 let Some(row) = registry
354 .get_by_name(&name)
355 .with_context(|| format!("checking active browser `{name}`"))?
356 else {
357 let hint = "call `browser_start` to start a browser, or `browser_select` to switch to another live browser";
358 let kind_hint = generated_kind_hint(&name);
359 anyhow::bail!(
360 "active browser `{name}` is no longer registered{kind_hint}; {hint}"
361 );
362 };
363 match crate::registry::liveness(&row) {
364 BrowserLiveness::Alive => Ok(()),
365 BrowserLiveness::DeadPid => {
366 registry
367 .delete(&row.name)
368 .with_context(|| format!("pruning terminated browser {}", row.name))?;
369 anyhow::bail!(
370 "active browser `{}` has exited (pid {}); call `browser_start` with `{}` to launch/reuse a browser, or `browser_select` another live browser",
371 row.name,
372 row.pid,
373 row.kind.as_str()
374 );
375 }
376 BrowserLiveness::EndpointUnreachable => {
377 anyhow::bail!(
378 "active browser `{}` is not reachable at {} (pid {} still exists); retry, call `browser_start` with `{}` to launch/reuse a browser, or `browser_select` another live browser",
379 row.name,
380 row.endpoint,
381 row.pid,
382 row.kind.as_str()
383 );
384 }
385 }
386 })
387 .await
388 }
389
390 pub async fn current_tab(&self) -> Result<(TabBackend, String)> {
409 let backend = self.ensure_backend().await?;
410 let mut pointer = self.active_target_id.lock().await;
411 if let Some(tid) = pointer.as_ref() {
412 let live = backend.live_target_ids().await?;
413 if live.contains(tid) {
414 self.touch_tab(&backend, tid);
415 return Ok((backend, tid.clone()));
416 }
417 }
419 let new_tid = backend.create_tab("about:blank").await?;
420 *pointer = Some(new_tid.clone());
421 self.touch_tab(&backend, &new_tid);
422 Ok((backend, new_tid))
423 }
424
425 pub async fn resolve_or_create_for_origin(&self, url: &str) -> Result<(TabBackend, String)> {
434 let want =
435 url::Url::parse(url).map_err(|e| anyhow::anyhow!("invalid fetch URL `{url}`: {e}"))?;
436 let origin_root = crate::session::attach::origin_root_url(&want);
437 let backend = self.ensure_backend().await?;
438 let mut origin_targets = self.origin_target_ids.lock().await;
439 let live_targets = backend.live_targets().await?;
440 let live_ids: std::collections::HashSet<&str> =
441 live_targets.iter().map(|t| t.id.as_str()).collect();
442
443 if let Some(cached) = origin_targets.get(&origin_root) {
444 if live_ids.contains(cached.as_str()) {
445 self.touch_tab(&backend, cached);
446 return Ok((backend, cached.clone()));
447 }
448 origin_targets.remove(&origin_root);
449 }
450
451 if let Some(existing) = live_targets.iter().find(|t| {
452 url::Url::parse(&t.url)
453 .map(|parsed| crate::session::attach::same_origin(&parsed, &want))
454 .unwrap_or(false)
455 }) {
456 origin_targets.insert(origin_root, existing.id.clone());
457 self.touch_tab(&backend, &existing.id);
458 return Ok((backend, existing.id.clone()));
459 }
460
461 let new_tid = backend.create_tab(&origin_root).await?;
462 origin_targets.insert(origin_root, new_tid.clone());
463 self.touch_tab(&backend, &new_tid);
464 Ok((backend, new_tid))
465 }
466
467 pub async fn resolve_target_for_args(&self, args: &Value) -> Result<(TabBackend, String)> {
482 let tab = args.get("tab").and_then(|v| v.as_str()).map(String::from);
483 let target = args
484 .get("target")
485 .and_then(|v| v.as_str())
486 .map(String::from);
487 match (tab, target) {
488 (Some(_), Some(_)) => Err(anyhow::anyhow!("`tab` and `target` are mutually exclusive")),
489 (Some(name), None) => {
490 let backend = self.ensure_backend().await?;
491 let browser_name = self.registered_browser_name().await?;
492 let bn = browser_name.clone();
496 let n = name.clone();
497 let row = sync_registry_op(move |reg| reg.tab_get(&bn, &n))
498 .await?
499 .ok_or_else(|| crate::errors::SessionError::TabNotFound {
500 browser: browser_name.clone(),
501 name: name.clone(),
502 })?;
503 let live = backend.live_target_ids().await?;
504 if !live.contains(&row.target_id) {
505 let bn = browser_name.clone();
507 let n = name.clone();
508 sync_registry_op(move |reg| reg.tab_delete(&bn, &n)).await?;
509 return Err(crate::errors::SessionError::TabNotFound {
510 browser: browser_name,
511 name,
512 }
513 .into());
514 }
515 let bn = browser_name.clone();
516 let n = name.clone();
517 sync_registry_op(move |reg| reg.tab_touch(&bn, &n)).await?;
518 self.touch_tab(&backend, &row.target_id);
519 Ok((backend, row.target_id))
520 }
521 (None, Some(regex)) => {
522 let backend = self.ensure_backend().await?;
523 let target_id = resolve_target_by_regex(&backend, ®ex).await?;
524 self.touch_tab(&backend, &target_id);
525 Ok((backend, target_id))
526 }
527 (None, None) => self.current_tab().await,
528 }
529 }
530
531 pub async fn registered_browser_name(&self) -> Result<String> {
534 use crate::cli::env_resolver::Source;
535 let resolved = self.browser_snapshot().await;
536 match resolved.source {
537 Source::Registered { name } => Ok(name),
538 Source::External => Err(anyhow::anyhow!(
539 "operation requires a registered browser; external URL endpoints \
540 don't have a stable identity"
541 )),
542 }
543 }
544
545 pub async fn switch_browser(&self, new_browser: ResolvedBrowser) -> Result<()> {
557 self.capture.reset();
560 if self.browser_snapshot().await.endpoint != new_browser.endpoint {
563 self.downloads.reset();
564 }
565 {
567 let mut bidi = self.bidi.lock().await;
568 if let Some(client) = bidi.take() {
569 let _ = client.session_end().await;
570 }
571 }
572 {
575 let old = self.backend.lock().await.take();
576 if let Some(b) = old {
577 b.shutdown().await;
578 }
579 }
580 {
582 let mut lock = self.bidi_lock.lock().await;
583 *lock = BidiLockState::Pending;
584 }
585 {
587 let mut pointer = self.active_target_id.lock().await;
588 *pointer = None;
589 }
590 {
592 let mut origins = self.origin_target_ids.lock().await;
593 origins.clear();
594 }
595 {
597 let mut refs = self.refs.lock().await;
598 refs.clear();
599 }
600 {
604 let mut sidecar = self.sidecar.lock().await;
605 if let Some(sc) = sidecar.take() {
606 let _ = sc.call("dispose", serde_json::json!({})).await;
607 drop(sc);
609 }
610 }
611 {
613 let mut br = self.browser.write().await;
614 *br = new_browser;
615 }
616 self.ensure_bidi_lock().await?;
619 Ok(())
620 }
621}
622
623pub(crate) async fn sync_registry_op<T, F>(f: F) -> Result<T>
634where
635 F: FnOnce(&crate::registry::Registry) -> Result<T> + Send + 'static,
636 T: Send + 'static,
637{
638 tokio::task::spawn_blocking(move || {
639 let reg = crate::registry::Registry::open()?;
640 f(®)
641 })
642 .await?
643}
644
645fn generated_kind_hint(name: &str) -> String {
646 let Some((prefix, _)) = name.split_once('-') else {
647 return String::new();
648 };
649 if crate::detect::Kind::parse(prefix).is_some() {
650 format!("; it looks like a generated `{prefix}` browser name")
651 } else {
652 String::new()
653 }
654}
655
656pub(crate) async fn resolve_browser_send(
662 selector: crate::cli::env_resolver::BrowserSelector,
663) -> Result<ResolvedBrowser> {
664 use crate::cli::env_resolver::{BrowserSelector, DefaultResolver, Resolver};
665 let startable_kind = crate::cli::mcp::startable_kind_from_selector(&selector);
666 let resolved = match selector {
667 BrowserSelector::Url(u) => match u.scheme() {
668 "ws" | "wss" => Ok(ResolvedBrowser {
669 engine: if u.path().contains("/session") {
670 crate::detect::Engine::Bidi
671 } else {
672 crate::detect::Engine::Cdp
673 },
674 endpoint: u.to_string(),
675 source: crate::cli::env_resolver::Source::External,
676 }),
677 "http" | "https" => {
678 let base = u.as_str().trim_end_matches('/').to_string();
679 let ws = DefaultResolver.fetch_version(&base).await?;
680 let ws_url = url::Url::parse(&ws)?;
681 Ok(ResolvedBrowser {
682 engine: if ws_url.path().contains("/session") {
683 crate::detect::Engine::Bidi
684 } else {
685 crate::detect::Engine::Cdp
686 },
687 endpoint: ws,
688 source: crate::cli::env_resolver::Source::External,
689 })
690 }
691 other => anyhow::bail!("unsupported URL scheme: {other}"),
692 },
693 other => {
694 tokio::task::spawn_blocking(move || {
695 let reg = crate::registry::Registry::open()?;
696 let rt = tokio::runtime::Builder::new_current_thread().build()?;
701 rt.block_on(crate::cli::env_resolver::resolve_with(
702 other,
703 ®,
704 &DefaultResolver,
705 ))
706 })
707 .await?
708 }
709 };
710 match resolved {
711 Ok(browser) => Ok(browser),
712 Err(resolve_err) => {
713 if let Some(kind) = startable_kind {
714 crate::cli::mcp::start_and_resolve(Some(kind.as_str().to_string()), false, 30)
715 .await
716 .with_context(|| {
717 format!(
718 "browser selector failed to resolve ({resolve_err:#}); also failed to start {}",
719 kind.as_str()
720 )
721 })
722 } else {
723 Err(resolve_err)
724 }
725 }
726 }
727}
728
729async fn resolve_target_by_regex(backend: &TabBackend, regex: &str) -> Result<String> {
736 use crate::errors::SessionError;
737 use regex::Regex;
738 use std::time::Duration;
739 const PROBE: Duration = Duration::from_millis(500);
740
741 let re = Regex::new(regex)?;
742 let targets = backend.live_targets().await?;
743 let matches: Vec<_> = targets.iter().filter(|t| re.is_match(&t.url)).collect();
744 if matches.is_empty() {
745 return Err(anyhow::anyhow!("no target matched URL regex `{regex}`"));
746 }
747 let mut last_id: Option<String> = None;
748 let mut last_url: Option<String> = None;
749 for t in &matches {
750 last_id = Some(t.id.clone());
751 last_url = Some(t.url.clone());
752 let ok = matches!(
753 tokio::time::timeout(PROBE, backend.evaluate(&t.id, "1", false, PROBE)).await,
754 Ok(Ok(_))
755 );
756 if ok {
757 return Ok(t.id.clone());
758 }
759 }
760 Err(SessionError::TabHung {
761 target_id: last_id,
762 url: last_url,
763 timeout_ms: PROBE.as_millis() as u64,
764 hint: "all-matches-hung",
765 }
766 .into())
767}
768
769impl std::fmt::Debug for ServerState {
770 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
771 f.debug_struct("ServerState").finish()
772 }
773}
774
775pub type ToolHandler = std::sync::Arc<
777 dyn Fn(ServerState, Value) -> futures_util::future::BoxFuture<'static, Result<Value>>
778 + Send
779 + Sync,
780>;
781
782pub struct RegisteredTool {
783 pub name: String,
784 pub description: String,
785 pub input_schema: Value,
786 pub handler: ToolHandler,
787}
788
789#[derive(Clone, Default)]
790pub struct ToolRegistry {
791 inner: std::sync::Arc<std::sync::Mutex<Vec<RegisteredTool>>>,
792}
793
794impl ToolRegistry {
795 pub fn new() -> Self {
796 Self::default()
797 }
798
799 pub fn register(&self, t: RegisteredTool) {
800 self.inner.lock().unwrap().push(t);
801 }
802
803 pub fn list(&self) -> Vec<Value> {
804 self.inner
805 .lock()
806 .unwrap()
807 .iter()
808 .map(|t| {
809 json!({
810 "name": t.name,
811 "description": t.description,
812 "inputSchema": t.input_schema,
813 })
814 })
815 .collect()
816 }
817
818 pub fn handler(&self, name: &str) -> Option<ToolHandler> {
819 self.inner
820 .lock()
821 .unwrap()
822 .iter()
823 .find(|t| t.name == name)
824 .map(|t| t.handler.clone())
825 }
826}
827
828pub async fn run(state: ServerState, tools: ToolRegistry) -> Result<()> {
830 run_with_streams(state, tools, tokio::io::stdin(), tokio::io::stdout()).await
831}
832
833pub async fn run_with_streams<I, O>(
847 state: ServerState,
848 tools: ToolRegistry,
849 stdin: I,
850 mut stdout: O,
851) -> Result<()>
852where
853 I: tokio::io::AsyncRead + Unpin,
854 O: tokio::io::AsyncWrite + Unpin + Send + 'static,
855{
856 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
860 let writer = tokio::spawn(async move {
861 while let Some(frame) = rx.recv().await {
862 if stdout.write_all(&frame).await.is_err() {
863 break;
864 }
865 if stdout.flush().await.is_err() {
866 break;
867 }
868 }
869 });
870
871 let mut lines = BufReader::new(stdin).lines();
872 while let Some(line) = lines.next_line().await? {
873 if line.trim().is_empty() {
874 continue;
875 }
876 let req: Value = match serde_json::from_str(&line) {
877 Ok(v) => v,
878 Err(e) => {
879 let _ = tx.send(error_frame(
880 Value::Null,
881 -32700,
882 &format!("parse error: {e}"),
883 ));
884 continue;
885 }
886 };
887 let id = req.get("id").cloned().unwrap_or(Value::Null);
888 let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
889 let params = req.get("params").cloned().unwrap_or(Value::Null);
890
891 if id.is_null() && method == "notifications/initialized" {
893 continue;
894 }
895
896 match method {
901 "initialize" => {
902 let _ = tx.send(handle_initialize(id));
903 }
904 "ping" => {
905 let _ = tx.send(handle_ping(id));
906 }
907 "tools/list" => {
908 let _ = tx.send(handle_tools_list(id, &tools));
909 }
910 "tools/call" => {
911 handle_tools_call(id, ¶ms, &state, &tools, &tx);
912 }
913 _ => {
914 let _ = tx.send(error_frame(
915 id,
916 -32601,
917 &format!("method not found: {method}"),
918 ));
919 }
920 }
921 }
922 {
927 let _exclusive = state.op_barrier.write().await;
928 let backend = state.backend.lock().await.take();
929 if let Some(b) = backend {
930 b.shutdown().await;
931 }
932 }
933 drop(tx);
934 let _ = writer.await;
935 Ok(())
936}
937
938fn handle_initialize(id: Value) -> Vec<u8> {
941 result_frame(
942 id,
943 json!({
944 "protocolVersion": "2024-11-05",
945 "capabilities": {"tools": {}},
946 "serverInfo": {
947 "name": "browser-control",
948 "version": env!("CARGO_PKG_VERSION"),
949 },
950 }),
951 )
952}
953
954fn handle_ping(id: Value) -> Vec<u8> {
956 result_frame(id, json!({}))
957}
958
959fn handle_tools_list(id: Value, tools: &ToolRegistry) -> Vec<u8> {
961 result_frame(id, json!({"tools": tools.list()}))
962}
963
964fn handle_tools_call(
975 id: Value,
976 params: &Value,
977 state: &ServerState,
978 tools: &ToolRegistry,
979 tx: &tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
980) {
981 let name = params
982 .get("name")
983 .and_then(|v| v.as_str())
984 .unwrap_or("")
985 .to_string();
986 let args = params.get("arguments").cloned().unwrap_or(Value::Null);
987 let handler = tools.handler(&name);
988 let state = state.clone();
989 let tx = tx.clone();
990 let barrier = state.op_barrier.clone();
991 let is_exclusive = name == "browser_select";
992 tokio::spawn(async move {
993 let frame = match handler {
994 None => error_frame(id, -32602, &format!("tool not found: {name}")),
997 Some(h) => {
998 if is_exclusive {
999 let _guard = barrier.write().await;
1004 match h(state, args).await {
1005 Ok(v) => result_frame(id, v),
1006 Err(e) => tool_error_frame(id, &e),
1007 }
1008 } else {
1009 let _guard = barrier.read().await;
1010 let mark = is_download_action(&name).then(|| state.downloads.begin_action());
1011 let downloads = state.downloads.clone();
1012 let outcome = h(state, args).await;
1013 if mark.is_some() {
1014 downloads.end_action();
1015 }
1016 match outcome {
1017 Ok(v) => result_frame(id, annotate_downloads(v, mark, &downloads)),
1018 Err(e) => {
1019 let note = mark.and_then(|m| {
1024 crate::session::downloads::action_note(&downloads.since(m))
1025 });
1026 match note {
1027 Some(n) => tool_error_text_frame(
1028 id,
1029 format!("{}\n{n}", tool_error_message(&e)),
1030 ),
1031 None => tool_error_frame(id, &e),
1032 }
1033 }
1034 }
1035 }
1036 }
1037 };
1038 let _ = tx.send(frame);
1039 });
1040}
1041
1042fn is_download_action(name: &str) -> bool {
1046 matches!(
1047 name,
1048 "browser_click"
1049 | "browser_type"
1050 | "browser_press_key"
1051 | "browser_navigate"
1052 | "browser_eval"
1053 | "browser_drag"
1054 | "browser_hover"
1055 | "browser_tab_new"
1056 | "browser_select_element"
1057 )
1058}
1059
1060fn annotate_downloads(
1062 mut v: Value,
1063 mark: Option<u64>,
1064 downloads: &crate::session::downloads::DownloadHub,
1065) -> Value {
1066 let Some(mark) = mark else {
1067 return v;
1068 };
1069 let Some(note) = crate::session::downloads::action_note(&downloads.since(mark)) else {
1070 return v;
1071 };
1072 if let Some(content) = v.get_mut("content").and_then(Value::as_array_mut) {
1073 content.push(json!({ "type": "text", "text": note }));
1074 }
1075 v
1076}
1077
1078fn result_frame(id: Value, result: Value) -> Vec<u8> {
1080 let resp = json!({"jsonrpc": "2.0", "id": id, "result": result});
1081 let mut s = serde_json::to_vec(&resp).unwrap_or_else(|_| b"{}".to_vec());
1082 s.push(b'\n');
1083 s
1084}
1085
1086fn tool_error_frame(id: Value, err: &anyhow::Error) -> Vec<u8> {
1092 tool_error_text_frame(id, tool_error_message(err))
1093}
1094
1095fn tool_error_text_frame(id: Value, message: String) -> Vec<u8> {
1096 result_frame(
1097 id,
1098 json!({
1099 "content": [{ "type": "text", "text": message }],
1100 "isError": true,
1101 }),
1102 )
1103}
1104
1105fn tool_error_message(err: &anyhow::Error) -> String {
1110 use crate::errors::SessionError;
1111 use crate::registry::bidi_lock::BidiLockBusy;
1112
1113 if let Some(se) = err.downcast_ref::<SessionError>() {
1114 return se.to_string();
1117 }
1118 if let Some(busy) = err.downcast_ref::<BidiLockBusy>() {
1119 return busy.to_string();
1120 }
1121 format!("{err:#}")
1124}
1125
1126fn error_frame(id: Value, code: i64, message: &str) -> Vec<u8> {
1128 let resp = json!({
1129 "jsonrpc": "2.0",
1130 "id": id,
1131 "error": {"code": code, "message": message},
1132 });
1133 let mut s = serde_json::to_vec(&resp).unwrap_or_else(|_| b"{}".to_vec());
1134 s.push(b'\n');
1135 s
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::*;
1141 use crate::cli::env_resolver::Source;
1142 use crate::detect::Engine;
1143 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
1144
1145 fn dummy_resolved() -> ResolvedBrowser {
1146 ResolvedBrowser {
1147 endpoint: "ws://localhost:9999".into(),
1148 engine: Engine::Cdp,
1149 source: Source::External,
1150 }
1151 }
1152
1153 fn dummy_state() -> ServerState {
1154 ServerState::new(dummy_resolved())
1155 }
1156
1157 async fn send_recv(tools: ToolRegistry, requests: &[Value]) -> Vec<Value> {
1158 let (mut client_w, server_r) = tokio::io::duplex(8192);
1159 let (server_w, client_r) = tokio::io::duplex(8192);
1160 let state = dummy_state();
1161 let join = tokio::spawn(async move {
1162 let _ = run_with_streams(state, tools, server_r, server_w).await;
1163 });
1164
1165 for req in requests {
1166 let mut s = serde_json::to_vec(req).unwrap();
1167 s.push(b'\n');
1168 client_w.write_all(&s).await.unwrap();
1169 }
1170 drop(client_w);
1172
1173 let mut reader = BufReader::new(client_r);
1174 let mut responses = Vec::new();
1175 loop {
1176 let mut line = String::new();
1177 let n = reader.read_line(&mut line).await.unwrap();
1178 if n == 0 {
1179 break;
1180 }
1181 responses.push(serde_json::from_str(line.trim()).unwrap());
1182 }
1183 let _ = join.await;
1184 responses
1185 }
1186
1187 fn echo_tool() -> RegisteredTool {
1188 RegisteredTool {
1189 name: "echo".to_string(),
1190 description: "Echo arguments back".to_string(),
1191 input_schema: json!({"type": "object"}),
1192 handler: std::sync::Arc::new(|_state, args| {
1193 Box::pin(async move { Ok(json!({"echoed": args})) })
1194 }),
1195 }
1196 }
1197
1198 fn failing_tool() -> RegisteredTool {
1199 RegisteredTool {
1200 name: "boom".to_string(),
1201 description: "Always fails with a typed SessionError".to_string(),
1202 input_schema: json!({"type": "object"}),
1203 handler: std::sync::Arc::new(|_state, _args| {
1204 Box::pin(async move {
1205 Err(anyhow::Error::new(crate::errors::SessionError::TabHung {
1206 target_id: Some("T1".into()),
1207 url: Some("https://example.test".into()),
1208 timeout_ms: 20_000,
1209 hint: "renderer wedged",
1210 }))
1211 })
1212 }),
1213 }
1214 }
1215
1216 #[tokio::test]
1217 async fn initialize_round_trip() {
1218 let resp = send_recv(
1219 ToolRegistry::new(),
1220 &[json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}})],
1221 )
1222 .await;
1223 assert_eq!(resp.len(), 1);
1224 assert_eq!(resp[0]["id"], 1);
1225 assert_eq!(resp[0]["result"]["protocolVersion"], "2024-11-05");
1226 assert_eq!(resp[0]["result"]["serverInfo"]["name"], "browser-control");
1227 }
1228
1229 #[tokio::test]
1230 async fn tools_list_empty() {
1231 let resp = send_recv(
1232 ToolRegistry::new(),
1233 &[json!({"jsonrpc":"2.0","id":2,"method":"tools/list"})],
1234 )
1235 .await;
1236 assert_eq!(resp[0]["result"]["tools"], json!([]));
1237 }
1238
1239 #[tokio::test]
1240 async fn tools_list_after_register() {
1241 let tools = ToolRegistry::new();
1242 tools.register(echo_tool());
1243 let resp = send_recv(
1244 tools,
1245 &[json!({"jsonrpc":"2.0","id":3,"method":"tools/list"})],
1246 )
1247 .await;
1248 let list = resp[0]["result"]["tools"].as_array().unwrap();
1249 assert_eq!(list.len(), 1);
1250 assert_eq!(list[0]["name"], "echo");
1251 }
1252
1253 #[tokio::test]
1254 async fn tools_call_unknown_errors() {
1255 let resp = send_recv(
1256 ToolRegistry::new(),
1257 &[json!({
1258 "jsonrpc":"2.0","id":4,"method":"tools/call",
1259 "params":{"name":"nope","arguments":{}}
1260 })],
1261 )
1262 .await;
1263 assert_eq!(resp[0]["error"]["code"], -32602);
1265 assert!(resp[0]["error"]["message"]
1266 .as_str()
1267 .unwrap()
1268 .contains("nope"));
1269 }
1270
1271 #[tokio::test]
1272 async fn tool_failure_returns_iserror_result_not_protocol_error() {
1273 let tools = ToolRegistry::new();
1274 tools.register(failing_tool());
1275 let resp = send_recv(
1276 tools,
1277 &[json!({
1278 "jsonrpc":"2.0","id":7,"method":"tools/call",
1279 "params":{"name":"boom","arguments":{}}
1280 })],
1281 )
1282 .await;
1283 assert!(resp[0]["error"].is_null());
1286 assert_eq!(resp[0]["result"]["isError"], true);
1287 let text = resp[0]["result"]["content"][0]["text"].as_str().unwrap();
1288 assert!(text.contains("tab hung"), "got: {text}");
1290 assert!(text.contains("renderer wedged"), "got: {text}");
1291 assert!(text.contains("T1"), "got: {text}");
1292 }
1293
1294 #[tokio::test]
1295 async fn tools_call_registered_returns_result() {
1296 let tools = ToolRegistry::new();
1297 tools.register(echo_tool());
1298 let resp = send_recv(
1299 tools,
1300 &[json!({
1301 "jsonrpc":"2.0","id":5,"method":"tools/call",
1302 "params":{"name":"echo","arguments":{"hello":"world"}}
1303 })],
1304 )
1305 .await;
1306 assert_eq!(resp[0]["result"]["echoed"], json!({"hello":"world"}));
1307 }
1308
1309 #[tokio::test]
1310 async fn unknown_method_returns_minus_32601() {
1311 let resp = send_recv(
1312 ToolRegistry::new(),
1313 &[json!({"jsonrpc":"2.0","id":6,"method":"bogus"})],
1314 )
1315 .await;
1316 assert_eq!(resp[0]["error"]["code"], -32601);
1317 }
1318
1319 #[tokio::test]
1320 async fn ping_returns_empty_object() {
1321 let resp = send_recv(
1322 ToolRegistry::new(),
1323 &[json!({"jsonrpc":"2.0","id":7,"method":"ping"})],
1324 )
1325 .await;
1326 assert_eq!(resp[0]["result"], json!({}));
1327 }
1328
1329 use futures_util::{SinkExt, StreamExt};
1338 use std::sync::atomic::{AtomicUsize, Ordering};
1339 use tokio_tungstenite::tungstenite::Message;
1340
1341 async fn spawn_counting_cdp_mock(
1345 live: Vec<String>,
1346 ) -> (String, Arc<AtomicUsize>, tokio::sync::oneshot::Sender<()>) {
1347 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1348 let addr = listener.local_addr().unwrap();
1349 let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
1350 let conns = Arc::new(AtomicUsize::new(0));
1351 let conns_srv = conns.clone();
1352 tokio::spawn(async move {
1353 loop {
1354 let accept = tokio::select! {
1355 _ = &mut stop_rx => break,
1356 a = listener.accept() => a,
1357 };
1358 let (stream, _) = match accept {
1359 Ok(s) => s,
1360 Err(_) => break,
1361 };
1362 conns_srv.fetch_add(1, Ordering::SeqCst);
1363 let live = live.clone();
1364 tokio::spawn(async move {
1365 let mut ws = match tokio_tungstenite::accept_async(stream).await {
1366 Ok(w) => w,
1367 Err(_) => return,
1368 };
1369 while let Some(Ok(msg)) = ws.next().await {
1370 if let Message::Text(t) = msg {
1371 let req: Value = match serde_json::from_str(&t) {
1372 Ok(v) => v,
1373 Err(_) => continue,
1374 };
1375 let id = req["id"].as_u64().unwrap_or(0);
1376 let method = req["method"].as_str().unwrap_or("");
1377 let result = match method {
1378 "Target.getTargets" => {
1379 let infos: Vec<Value> = live
1380 .iter()
1381 .map(|tid| {
1382 json!({"targetId": tid, "type": "page", "url": ""})
1383 })
1384 .collect();
1385 json!({"targetInfos": infos})
1386 }
1387 "Target.attachToTarget" => json!({"sessionId": "S1"}),
1388 "Runtime.evaluate" => json!({"result": {"value": 1}}),
1389 _ => json!({}),
1390 };
1391 let resp = json!({"id": id, "result": result});
1392 if ws.send(Message::Text(resp.to_string())).await.is_err() {
1393 break;
1394 }
1395 }
1396 }
1397 });
1398 }
1399 });
1400 (format!("ws://{addr}"), conns, stop_tx)
1401 }
1402
1403 fn registered_state(name: &str, endpoint: &str) -> ServerState {
1406 ServerState::new(ResolvedBrowser {
1407 endpoint: endpoint.to_string(),
1408 engine: Engine::Cdp,
1409 source: Source::Registered { name: name.into() },
1410 })
1411 }
1412
1413 fn register_browser_row(name: &str, endpoint: &str) {
1414 let port: u16 = endpoint
1415 .strip_prefix("ws://127.0.0.1:")
1416 .and_then(|s| s.split('/').next())
1417 .and_then(|s| s.parse().ok())
1418 .expect("parse mock port");
1419 let reg = crate::registry::Registry::open().unwrap();
1420 reg.insert(&crate::registry::BrowserRow {
1421 name: name.to_string(),
1422 kind: crate::detect::Kind::Chrome,
1423 engine: Engine::Cdp,
1424 pid: std::process::id(),
1425 endpoint: endpoint.to_string(),
1426 port,
1427 profile_dir: std::path::PathBuf::from("/tmp/profiles/bx"),
1428 executable: std::path::PathBuf::from("/usr/bin/example"),
1429 headless: false,
1430 started_at: "2024-01-01T00:00:00Z".into(),
1431 })
1432 .unwrap();
1433 }
1434
1435 #[allow(clippy::await_holding_lock)]
1438 #[tokio::test]
1439 async fn resolve_named_tab_live_resolves_and_touches() {
1440 let _g = crate::test_support::ENV_LOCK
1441 .lock()
1442 .unwrap_or_else(|e| e.into_inner());
1443 let tmp = tempfile::TempDir::new().unwrap();
1444 std::env::set_var("BROWSER_CONTROL_DATA_DIR", tmp.path());
1445
1446 {
1448 let reg = crate::registry::Registry::open().unwrap();
1449 reg.tab_upsert("bx", "work", "T1", "about:blank", true)
1450 .unwrap();
1451 }
1452
1453 let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1454 register_browser_row("bx", &url);
1455 let state = registered_state("bx", &url);
1456
1457 let target_id = match state.resolve_target_for_args(&json!({"tab": "work"})).await {
1458 Ok((_backend, tid)) => tid,
1459 Err(e) => panic!("named tab should resolve: {e:#}"),
1460 };
1461 assert_eq!(target_id, "T1");
1462
1463 std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1464 }
1465
1466 #[allow(clippy::await_holding_lock)]
1468 #[tokio::test]
1469 async fn resolve_named_tab_stale_sweeps_and_errors() {
1470 let _g = crate::test_support::ENV_LOCK
1471 .lock()
1472 .unwrap_or_else(|e| e.into_inner());
1473 let tmp = tempfile::TempDir::new().unwrap();
1474 std::env::set_var("BROWSER_CONTROL_DATA_DIR", tmp.path());
1475
1476 {
1480 let reg = crate::registry::Registry::open().unwrap();
1481 reg.tab_upsert("bx", "gone", "T_DEAD", "about:blank", true)
1482 .unwrap();
1483 }
1484
1485 let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1486 register_browser_row("bx", &url);
1487 let state = registered_state("bx", &url);
1488
1489 let err = match state.resolve_target_for_args(&json!({"tab": "gone"})).await {
1490 Ok(_) => panic!("stale named tab must error"),
1491 Err(e) => e,
1492 };
1493 let typed = err
1494 .downcast_ref::<crate::errors::SessionError>()
1495 .expect("typed SessionError");
1496 assert!(
1497 matches!(typed, crate::errors::SessionError::TabNotFound { .. }),
1498 "expected TabNotFound, got {typed:?}"
1499 );
1500
1501 let reg = crate::registry::Registry::open().unwrap();
1503 assert!(
1504 reg.tab_get("bx", "gone").unwrap().is_none(),
1505 "row not swept"
1506 );
1507
1508 std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1509 }
1510
1511 #[allow(clippy::await_holding_lock)]
1513 #[tokio::test]
1514 async fn resolve_named_tab_missing_row_errors() {
1515 let _g = crate::test_support::ENV_LOCK
1516 .lock()
1517 .unwrap_or_else(|e| e.into_inner());
1518 let tmp = tempfile::TempDir::new().unwrap();
1519 std::env::set_var("BROWSER_CONTROL_DATA_DIR", tmp.path());
1520
1521 let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1522 register_browser_row("bx", &url);
1523 let state = registered_state("bx", &url);
1524
1525 let err = match state.resolve_target_for_args(&json!({"tab": "nope"})).await {
1526 Ok(_) => panic!("unknown named tab must error"),
1527 Err(e) => e,
1528 };
1529 let typed = err
1530 .downcast_ref::<crate::errors::SessionError>()
1531 .expect("typed SessionError");
1532 assert!(
1533 matches!(typed, crate::errors::SessionError::TabNotFound { .. }),
1534 "expected TabNotFound, got {typed:?}"
1535 );
1536
1537 std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1538 }
1539
1540 #[tokio::test]
1541 async fn resolve_tab_and_target_mutually_exclusive() {
1542 let state = dummy_state();
1544 let err = match state
1545 .resolve_target_for_args(&json!({"tab": "a", "target": "b"}))
1546 .await
1547 {
1548 Ok(_) => panic!("tab+target must error"),
1549 Err(e) => e,
1550 };
1551 assert!(
1552 err.to_string().contains("mutually exclusive"),
1553 "got: {err:#}"
1554 );
1555 }
1556
1557 #[tokio::test]
1558 async fn concurrent_ensure_backend_opens_one_backend() {
1559 let (url, conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1563 let state = ServerState::new(ResolvedBrowser {
1564 endpoint: url,
1565 engine: Engine::Cdp,
1566 source: Source::External,
1567 });
1568
1569 let mut handles = Vec::new();
1570 for _ in 0..8 {
1571 let s = state.clone();
1572 handles.push(tokio::spawn(async move { s.ensure_backend().await }));
1573 }
1574 for h in handles {
1575 h.await.unwrap().expect("ensure_backend should succeed");
1576 }
1577 assert_eq!(
1578 conns.load(Ordering::SeqCst),
1579 1,
1580 "expected exactly one backend (one WS connection) under concurrency"
1581 );
1582 }
1583
1584 #[allow(clippy::await_holding_lock)]
1585 #[tokio::test]
1586 async fn external_cdp_backend_does_not_open_registry() {
1587 let _g = crate::test_support::ENV_LOCK
1588 .lock()
1589 .unwrap_or_else(|e| e.into_inner());
1590 let stale_path = {
1591 let tmp = tempfile::TempDir::new().unwrap();
1592 tmp.path().to_path_buf()
1593 };
1594 std::env::set_var("BROWSER_CONTROL_DATA_DIR", &stale_path);
1595
1596 let (url, _conns, _stop) = spawn_counting_cdp_mock(vec!["T1".into()]).await;
1597 let state = ServerState::new(ResolvedBrowser {
1598 endpoint: url,
1599 engine: Engine::Cdp,
1600 source: Source::External,
1601 });
1602
1603 let result = state.ensure_backend().await;
1604
1605 std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
1606 result.expect("external CDP backend must not depend on registry env");
1607 }
1608
1609 #[tokio::test]
1610 async fn initialized_notification_is_silently_ignored() {
1611 let resp = send_recv(
1614 ToolRegistry::new(),
1615 &[
1616 json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
1617 json!({"jsonrpc":"2.0","id":8,"method":"ping"}),
1618 ],
1619 )
1620 .await;
1621 assert_eq!(resp.len(), 1);
1622 assert_eq!(resp[0]["id"], 8);
1623 }
1624}