//! JSON-line IPC server: CLI ↔ daemon transport. //! //! On Unix the server binds a `SOCK_STREAM` UDS at //! [`paths::sock_path`](crate::daemon::paths::sock_path); on Windows it //! exposes a per-user named pipe from [`paths::pipe_name`]. //! //! Wire format per connection: //! * one frame per line (`\n`-terminated UTF-8 JSON); //! * frames are decoded as [`bsk_protocol::Frame::Request`] and dispatched //! to the [`RpcHandler`] callback; //! * the handler returns a [`ResponseBody`] which is written back as a //! single line. //! //! Methods served by the production handler: //! * `system.ping` / `system.status` (M2/M3 lifecycle metadata) //! * `session.start` / `session.stop` / `session.stop_all` / `session.list` (M5) //! * `browser.list` (M4) use std::future::Future; use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; use std::time::{Duration, Instant}; use bsk_protocol::system::{ BrowserListParams, BrowserStatusEntry, SessionStatusEntry, StatusParams, StatusResult, VersionSkewEntry, }; use bsk_protocol::tools::{ReturnFailure, WaitMsParams, WaitMsResult}; use bsk_protocol::{ CancelParams, CancelResult, ErrorCode, Method, PingResult, ResponseBody, RpcError, RpcId, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::sync::Notify; use tokio::task::JoinHandle; use tracing::{debug, warn}; use super::abort::AbortRegistry; use super::queue::{DEFAULT_TOOL_TIMEOUT, DispatchError}; use super::sessions::{ SessionId, StartSessionError, StopSessionError, snapshot_status_entries, start_session, stop_session, }; use super::state::{DAEMON_VERSION, DaemonState, PROTOCOL_VERSION}; /// Handler type: async fn(rpc_id, method, params) -> ResponseBody. /// /// The `rpc_id` is the wire correlation id the IPC connection /// allocated; daemon-side long-runners (M9.3 `tool.wait_ms`) register /// an `AbortToken` against it so a peer `cancel { rpc_id }` can trip /// them. Handlers that do not need it ignore the argument. pub type RpcHandler = Arc< dyn Fn(RpcId, Method, Value) -> Pin + Send>> + Send + Sync + 'static, >; const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(15); /// Upper bound on `wait_for_browser_ms` accepted over IPC. const MAX_BROWSER_WAIT: Duration = Duration::from_secs(60); // `session.stop` fast-fails while another tool is active; this budget // only needs to cover the stop RPC itself and IPC scheduling grace. const DEFAULT_SESSION_STOP_TIMEOUT: Duration = Duration::from_secs(DEFAULT_TOOL_TIMEOUT.as_secs() + DEFAULT_RPC_TIMEOUT.as_secs() + 5); /// Snapshot of daemon-side bookkeeping needed to answer `system.status`. /// /// The lifecycle / process metadata (pid, ws_port, sock_path, …) is owned /// here; the live browser + session lists are read out of [`DaemonState`] /// at request time so the snapshot never goes stale. #[derive(Debug, Clone)] pub struct DaemonStatus { pub started_at: Instant, pub ws_port: u16, pub sock_path: PathBuf, pub daemon_version: &'static str, pub protocol_version: &'static str, } impl DaemonStatus { /// Build a `StatusResult` snapshot WITHOUT touching the daemon state /// (used by the system-only handler in tests). Browsers and sessions /// fields are empty. pub fn snapshot(&self) -> StatusResult { StatusResult { daemon_version: self.daemon_version.to_string(), protocol_version: self.protocol_version.to_string(), pid: std::process::id(), uptime_secs: self.started_at.elapsed().as_secs(), ws_port: self.ws_port, sock_path: self.sock_path.to_string_lossy().into_owned(), browsers: Vec::new(), sessions: Vec::new(), version_skew_browsers: Vec::new(), } } /// Full snapshot including the current browser + session tables. pub fn snapshot_with(&self, state: &DaemonState) -> StatusResult { let mut version_skew_browsers: Vec = Vec::new(); let browsers: Vec = state .browsers .snapshot() .into_iter() .map(|client| { let count = state.sessions.count_for_browser(&client.id); if client.version_skew { version_skew_browsers.push(VersionSkewEntry { instance_id: client.id.0.clone(), browser_name: client.browser_name.clone(), label: client.label.clone(), server_version: self.daemon_version.to_string(), client_version: client.extension_version.clone(), server_protocol_version: self.protocol_version.to_string(), client_protocol_version: client.extension_protocol_version.clone(), }); } client.status_entry(count) }) .collect(); let sessions: Vec = state .sessions .snapshot() .into_iter() .map(|s| s.status_entry()) .collect(); StatusResult { daemon_version: self.daemon_version.to_string(), protocol_version: self.protocol_version.to_string(), pid: std::process::id(), uptime_secs: self.started_at.elapsed().as_secs(), ws_port: self.ws_port, sock_path: self.sock_path.to_string_lossy().into_owned(), browsers, sessions, version_skew_browsers, } } } /// Minimal handler answering only `system.ping`. Used by older tests /// that don't care about the status payload. pub fn default_ping_handler() -> RpcHandler { Arc::new(|_rpc_id, method, _params| { Box::pin(async move { match method { Method::SystemPing => { let result = PingResult { pong: true }; ResponseBody::Ok(serde_json::to_value(result).unwrap_or(Value::Null)) } other => ResponseBody::Err(RpcError { code: ErrorCode::UnknownMethod, message: format!("method not implemented yet: {other:?}"), data: None, }), } }) }) } /// Build a system-only handler bound to a daemon status snapshot. The /// browsers/sessions fields of the reply are empty (test helper for /// callers that don't carry a [`DaemonState`]). pub fn system_handler(status: DaemonStatus) -> RpcHandler { Arc::new(move |_rpc_id, method, _params| { let status = status.clone(); Box::pin(async move { match method { Method::SystemPing => { let result = PingResult { pong: true }; ResponseBody::Ok(serde_json::to_value(result).unwrap_or(Value::Null)) } Method::SystemStatus => { let result = status.snapshot(); ResponseBody::Ok(serde_json::to_value(result).unwrap_or(Value::Null)) } other => ResponseBody::Err(RpcError { code: ErrorCode::UnknownMethod, message: format!("method not implemented yet: {other:?}"), data: None, }), } }) }) } /// Build the production handler: `system.*` plus M4 (`browser.list`), /// M5 (`session.*`), M6–M9 tool methods, and the M9.3 daemon-local /// `tool.wait_ms` / `cancel` paths. pub fn full_handler(status: DaemonStatus, state: Arc) -> RpcHandler { Arc::new(move |rpc_id, method, params| { let status = status.clone(); let state = Arc::clone(&state); Box::pin(async move { match method { Method::SystemPing => { let result = PingResult { pong: true }; ResponseBody::Ok(serde_json::to_value(result).unwrap_or(Value::Null)) } Method::SystemStatus => match handle_status(&status, &state, params).await { Ok(v) => ResponseBody::Ok(v), Err(e) => ResponseBody::Err(e), }, Method::SessionStart => match handle_session_start(&state, params).await { Ok(v) => ResponseBody::Ok(v), Err(e) => ResponseBody::Err(e), }, Method::SessionStop => match handle_session_stop(&state, params).await { Ok(v) => ResponseBody::Ok(v), Err(e) => ResponseBody::Err(e), }, Method::SessionStopAll => match handle_session_stop_all(&state).await { Ok(v) => ResponseBody::Ok(v), Err(e) => ResponseBody::Err(e), }, Method::SessionList => handle_session_list(&state), Method::BrowserList => match handle_browser_list(&state, params).await { Ok(v) => ResponseBody::Ok(v), Err(e) => ResponseBody::Err(e), }, Method::ToolTabList | Method::ToolTabCreate | Method::ToolTabClose | Method::ToolTabSelect | Method::ToolTabBorrow | Method::ToolTabReturn | Method::ToolScreenshot | Method::ToolConsole | Method::ToolNetwork | Method::ToolSnapshot | Method::ToolObserve | Method::ToolGetHtml | Method::ToolNavigate | Method::ToolNavigateBack | Method::ToolNavigateForward | Method::ToolReload | Method::ToolClick | Method::ToolFill | Method::ToolPress | Method::ToolSelect | Method::ToolEvaluate | Method::ToolWaitForNavigation | Method::ToolRequestHelp | Method::ToolRecordStart | Method::ToolRecordStop | Method::ToolRecordAwait => { handle_tool_dispatch(&state, rpc_id, method, params).await } Method::ToolWaitMs => handle_wait_ms(&state.abort_registry, rpc_id, params).await, Method::Cancel => handle_cancel(&state, params), other => ResponseBody::Err(RpcError { code: ErrorCode::UnknownMethod, message: format!("method not implemented yet: {other:?}"), data: None, }), } }) }) } /// IPC entry point for `tool.*` RPCs (M6+). Looks up the per-session /// dispatch queue and forwards the call; never touches the /// BrowserSink directly. `tool.session_start` / `tool.session_stop` /// stay on the direct path because they manage the queue lifecycle /// itself. /// /// Registers a fresh [`super::inflight::ToolInflightEntry`] BEFORE /// the per-session queue takes over so a `cancel { rpc_id }` arriving /// while the job is still queued has something to trip — that's the /// fix for review C2 (queued cancels were previously invisible to /// `handle_cancel`). The same entry covers the in-flight phase too, /// so the worker can short-circuit via its cancel token instead of /// hand-rolling a second mechanism. async fn handle_tool_dispatch( state: &Arc, cli_rpc_id: RpcId, method: Method, params: Value, ) -> ResponseBody { let session_id = match params.get("session_id").and_then(|v| v.as_str()) { Some(s) if !s.is_empty() => SessionId(s.to_string()), _ => { return ResponseBody::Err(RpcError { code: ErrorCode::InvalidParams, message: "tool.* RPC requires non-empty session_id".into(), data: None, }); } }; // Pre-flight: if the user has clicked the agent-window mask's // stop button, every session carries a one-shot "pending // interrupt" marker. The marker is consumed by the next method // that dispatches browser/page input (which is rejected with // `UserAborted`). Passive reads and control-plane RPCs pass through // transparently — gating them would prevent the agent from observing // page state before asking the user, or from cleanly tearing down the // session. Classification lives on `Method::effect()` so adding a new // tool variant requires an explicit classification call. if method.requires_interrupt_gate() && state.session_interrupts.try_consume(&session_id) { return ResponseBody::Err(RpcError { code: ErrorCode::UserAborted, message: "tool dispatch rejected: pending user interrupt. The user explicitly requested to stop. Ask the user how to proceed before issuing further actions.".into(), data: None, }); } let timeout = match tool_dispatch_timeout(¶ms) { Ok(timeout) => timeout, Err(err) => return ResponseBody::Err(err), }; let inflight_guard = match state.tool_inflight.register(cli_rpc_id, session_id.clone()) { Ok(guard) => guard, Err(err) => { return ResponseBody::Err(RpcError { code: ErrorCode::ProtocolError, message: format!("inflight registration rejected: {err}"), data: None, }); } }; let entry = inflight_guard.entry(); // `record_stop` must reach the extension while `record_await` holds the // serial busy lock — finishing the recording unblocks await. let outcome = if method == Method::ToolRecordStop { state .tool_queues .dispatch_unlocked(&session_id, method, params, timeout, Some(entry)) .await } else { state .tool_queues .dispatch(&session_id, method, params, timeout, Some(entry)) .await }; drop(inflight_guard); match outcome { Ok(v) => ResponseBody::Ok(v), Err(err) => ResponseBody::Err(err.into_rpc()), } } /// Upper bound on `tool.wait_ms` (5 minutes). Larger values are /// rejected as `invalid_params` so a buggy agent cannot wedge a /// daemon-side sleep beyond a reasonable window. The cap matches the /// design-doc red-line that long waits should use the queue-based /// `wait_for_navigation` instead. pub const MAX_WAIT_MS: u64 = 5 * 60 * 1_000; /// Daemon-local handler for `tool.wait_ms` (M9.3). Does NOT go /// through `handle_tool_dispatch` / `tool_queues`: the sleep is /// answered entirely on this side of the WS link, so no extension /// hop and no session id required. async fn handle_wait_ms( registry: &Arc, rpc_id: RpcId, params: Value, ) -> ResponseBody { let params: WaitMsParams = match serde_json::from_value(params) { Ok(p) => p, Err(err) => { return ResponseBody::Err(RpcError { code: ErrorCode::InvalidParams, message: err.to_string(), data: None, }); } }; if params.duration_ms > MAX_WAIT_MS { return ResponseBody::Err(RpcError { code: ErrorCode::InvalidParams, message: format!( "wait_ms duration {} exceeds limit {}ms", params.duration_ms, MAX_WAIT_MS ), data: None, }); } if params.duration_ms == 0 { return ResponseBody::Ok( serde_json::to_value(WaitMsResult { waited_ms: 0 }).unwrap_or(Value::Null), ); } let guard = match registry.register(rpc_id) { Ok(guard) => guard, Err(err) => { return ResponseBody::Err(RpcError { code: ErrorCode::ProtocolError, message: format!("cannot register wait_ms cancellation token: {err:?}"), data: None, }); } }; let token = guard.token().clone(); let result = tokio::select! { _ = tokio::time::sleep(Duration::from_millis(params.duration_ms)) => { ResponseBody::Ok( serde_json::to_value(WaitMsResult { waited_ms: params.duration_ms }) .unwrap_or(Value::Null), ) } _ = token.cancelled() => { ResponseBody::Err(RpcError { code: ErrorCode::Cancelled, message: "wait_ms cancelled".into(), data: None, }) } }; drop(guard); result } /// Resolve a `cancel { rpc_id }` against the daemon's two /// cancellation surfaces (M10.2 + review C2): /// /// 1. [`AbortRegistry`] — answers daemon-local cancellable runners /// (currently `tool.wait_ms`). If a token is registered we trip it /// and stop. /// 2. [`super::inflight::ToolInflightRegistry`] — every IPC-tracked /// `tool.*` RPC, registered the moment the IPC handler accepts the /// request. Trips the entry's cancel token regardless of whether /// the per-session queue worker has dequeued the job yet: /// * **Queued** — the worker's pre-flight observes the cancelled /// token and short-circuits with `cancelled` before any WS /// frame leaves the daemon (review C2 fix). /// * **Forwarded** — the worker's `tokio::select!` returns /// `cancelled` immediately, AND we additionally push a WS-side /// `cancel { rpc_id: ws_rpc_id }` frame so the extension's /// dispatcher can trip its `AbortController`. /// /// Returns `{ cancelled }` reflecting whether either surface /// matched. The RPC itself never errors — a cancelled tool surfaces /// the `Cancelled` code in its own response. fn handle_cancel(state: &Arc, params: Value) -> ResponseBody { let params: CancelParams = match serde_json::from_value(params) { Ok(p) => p, Err(err) => { return ResponseBody::Err(RpcError { code: ErrorCode::InvalidParams, message: err.to_string(), data: None, }); } }; let local = state.abort_registry.cancel(¶ms.rpc_id); let mut cancelled = local; if !local && let Some(snap) = state.tool_inflight.cancel(¶ms.rpc_id) { cancelled = true; // Forward the WS-side cancel only when the worker has // already promoted the entry to "forwarded"; queued entries // short-circuit on their own pre-flight without ever // touching the extension. if let (Some(browser_id), Some(ws_rpc_id)) = (snap.browser_id, snap.ws_rpc_id) && let Err(err) = super::cancel_forward::forward_cancel_to_browser(state, &browser_id, &ws_rpc_id) { warn!( cli_rpc_id = %params.rpc_id, browser = %browser_id, ws_rpc_id = %ws_rpc_id, %err, "failed to forward cancel to extension" ); } } ResponseBody::Ok(serde_json::to_value(CancelResult { cancelled }).unwrap_or(Value::Null)) } /// Test-only re-export of the cancel handler: the system-only handler /// used by older tests doesn't carry `DaemonState`. Kept private so /// the production handler in [`full_handler`] is the canonical entry /// point. #[cfg(test)] fn handle_cancel_with_registry_only(registry: &Arc, params: Value) -> ResponseBody { let params: CancelParams = match serde_json::from_value(params) { Ok(p) => p, Err(err) => { return ResponseBody::Err(RpcError { code: ErrorCode::InvalidParams, message: err.to_string(), data: None, }); } }; let cancelled = registry.cancel(¶ms.rpc_id); ResponseBody::Ok(serde_json::to_value(CancelResult { cancelled }).unwrap_or(Value::Null)) } fn tool_dispatch_timeout(params: &Value) -> Result { let Some(raw) = params.get("timeout_ms") else { return Ok(DEFAULT_TOOL_TIMEOUT); }; let Some(ms) = raw.as_u64() else { return Err(RpcError { code: ErrorCode::InvalidParams, message: "timeout_ms must be a positive integer number of milliseconds".into(), data: None, }); }; if ms == 0 { return Err(RpcError { code: ErrorCode::InvalidParams, message: "timeout_ms must be greater than zero".into(), data: None, }); } let ms = u32::try_from(ms).map_err(|_| RpcError { code: ErrorCode::InvalidParams, message: "timeout_ms too large for u32 milliseconds".into(), data: None, })?; Ok(Duration::from_millis(u64::from(ms))) } // Local CLI-facing shapes. Intentionally distinct from // `bsk_protocol::tools::SessionStart*` (which describes the WS-facing // `tool.session_*` round-trip with the extension) so the two sides can // evolve independently. #[derive(Debug, Clone, Serialize, Deserialize)] struct CliSessionStartParams { #[serde(default)] pub browser_instance_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] struct CliSessionStartResult { pub session_id: String, pub browser_instance_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_window_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] struct CliSessionStopParams { #[serde(default)] pub session_id: Option, #[serde(default)] pub all: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] struct CliSessionStopResult { pub stopped: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub failed: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub returned_tab_ids: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub return_failures: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] struct CliSessionStopFailure { pub session_id: String, pub code: ErrorCode, pub message: String, } #[derive(Debug, Clone, Serialize, Deserialize)] struct SessionListResult { pub sessions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] struct BrowserListResult { pub browsers: Vec, } async fn handle_status( status: &DaemonStatus, state: &Arc, params: Value, ) -> Result { let params: StatusParams = parse_params_or_default(params)?; maybe_wait_for_browser(state, params.wait_for_browser_ms).await; Ok(serde_json::to_value(status.snapshot_with(state)).unwrap_or(Value::Null)) } fn parse_params_or_default(params: Value) -> Result where T: DeserializeOwned + Default, { if params.is_null() { Ok(T::default()) } else { serde_json::from_value(params).map_err(|err| RpcError { code: ErrorCode::InvalidParams, message: err.to_string(), data: None, }) } } async fn maybe_wait_for_browser(state: &Arc, wait_ms: Option) { if let Some(wait) = clamp_browser_wait(wait_ms) { state.browsers.wait_for_any_connected(wait).await; } } fn clamp_browser_wait(wait_ms: Option) -> Option { let ms = wait_ms?; if ms == 0 { return None; } Some(Duration::from_millis( ms.min(MAX_BROWSER_WAIT.as_millis() as u64), )) } async fn handle_session_start(state: &Arc, params: Value) -> Result { let params: CliSessionStartParams = if params.is_null() { CliSessionStartParams { browser_instance_id: None, } } else { serde_json::from_value(params).map_err(|err| RpcError { code: ErrorCode::InvalidParams, message: err.to_string(), data: None, })? }; match start_session( &state.browsers, &state.sessions, &state.tool_queues, params.browser_instance_id.as_deref(), state.config.extension_connect_wait, DEFAULT_RPC_TIMEOUT, ) .await { Ok(session) => { let result = CliSessionStartResult { session_id: session.id.0.clone(), browser_instance_id: session.browser_id.0.clone(), agent_window_id: session.agent_window_id, }; Ok(serde_json::to_value(result).unwrap_or(Value::Null)) } Err(err) => Err(map_start_error(err)), } } fn map_start_error(err: StartSessionError) -> RpcError { let code = match &err { StartSessionError::NoBrowserConnected => ErrorCode::NoBrowserConnected, StartSessionError::MultipleBrowsersOnline { .. } => ErrorCode::MultipleBrowsersOnline, StartSessionError::BrowserNotFound => ErrorCode::NotFound, StartSessionError::AmbiguousBrowserLabel { .. } => ErrorCode::InvalidParams, StartSessionError::IdExhausted => ErrorCode::ProtocolError, StartSessionError::Timeout => ErrorCode::Timeout, StartSessionError::TransportClosed => ErrorCode::ProtocolError, StartSessionError::ExtensionError(inner) => inner.code, }; let message = err.to_string(); let data = match &err { StartSessionError::MultipleBrowsersOnline { browsers } => { Some(serde_json::json!({ "browsers": browsers })) } StartSessionError::AmbiguousBrowserLabel { label, instance_ids, } => Some(serde_json::json!({ "label": label, "instance_ids": instance_ids, })), _ => None, }; RpcError { code, message, data, } } async fn handle_session_stop(state: &Arc, params: Value) -> Result { let params: CliSessionStopParams = if params.is_null() { CliSessionStopParams { session_id: None, all: false, } } else { serde_json::from_value(params).map_err(|err| RpcError { code: ErrorCode::InvalidParams, message: err.to_string(), data: None, })? }; if params.all { return handle_session_stop_all(state).await; } let session_id = match params.session_id { Some(s) => SessionId(s), None => { return Err(RpcError { code: ErrorCode::InvalidParams, message: "session.stop requires session_id or all=true".into(), data: None, }); } }; match stop_session( &state.browsers, &state.sessions, &state.tool_queues, &state.session_interrupts, &session_id, DEFAULT_SESSION_STOP_TIMEOUT, ) .await { Ok(stop) => { let result = CliSessionStopResult { stopped: vec![session_id.0], failed: Vec::new(), returned_tab_ids: stop.returned_tab_ids, return_failures: stop.return_failures, }; Ok(serde_json::to_value(result).unwrap_or(Value::Null)) } Err(StopSessionError::ReturnFailures(stop)) => { let message = format!( "failed to return borrowed tabs during session stop ({} failure(s)); session left running", stop.return_failures.len() ); let result = CliSessionStopResult { stopped: Vec::new(), failed: vec![CliSessionStopFailure { session_id: session_id.0, code: ErrorCode::CdpFailed, message, }], returned_tab_ids: stop.returned_tab_ids, return_failures: stop.return_failures, }; Ok(serde_json::to_value(result).unwrap_or(Value::Null)) } Err(err) => Err(map_stop_error(err)), } } fn map_stop_error(err: StopSessionError) -> RpcError { let code = match &err { StopSessionError::NotFound => ErrorCode::NotFound, StopSessionError::Stopping => ErrorCode::Timeout, StopSessionError::SessionBusy => return DispatchError::SessionBusy.into_rpc(), StopSessionError::BrowserGone => ErrorCode::NotFound, StopSessionError::Timeout => ErrorCode::Timeout, StopSessionError::TransportClosed => ErrorCode::ProtocolError, StopSessionError::ExtensionError(inner) => inner.code, StopSessionError::ReturnFailures(_) => ErrorCode::CdpFailed, }; RpcError { code, message: err.to_string(), data: None, } } async fn handle_session_stop_all(state: &Arc) -> Result { let ids: Vec = state .sessions .snapshot() .into_iter() .map(|s| s.id) .collect(); let mut stopped = Vec::new(); let mut failed = Vec::new(); let mut returned_tab_ids = Vec::new(); let mut return_failures = Vec::new(); for id in ids { match stop_session( &state.browsers, &state.sessions, &state.tool_queues, &state.session_interrupts, &id, DEFAULT_SESSION_STOP_TIMEOUT, ) .await { Ok(stop) => { stopped.push(id.0); returned_tab_ids.extend(stop.returned_tab_ids); return_failures.extend(stop.return_failures); } Err(StopSessionError::ReturnFailures(stop)) => { debug!( session = %id, failures = stop.return_failures.len(), "session.stop_all: borrowed tab return failure (leaving session running)" ); failed.push(CliSessionStopFailure { session_id: id.0, code: ErrorCode::CdpFailed, message: format!( "failed to return borrowed tabs during session stop ({} failure(s)); session left running", stop.return_failures.len() ), }); returned_tab_ids.extend(stop.returned_tab_ids); return_failures.extend(stop.return_failures); } Err(err) => { debug!(session = %id, ?err, "session.stop_all: failure (continuing)"); let rpc = map_stop_error(err); failed.push(CliSessionStopFailure { session_id: id.0, code: rpc.code, message: rpc.message, }); } } } let result = CliSessionStopResult { stopped, failed, returned_tab_ids, return_failures, }; Ok(serde_json::to_value(result).unwrap_or(Value::Null)) } fn handle_session_list(state: &Arc) -> ResponseBody { let sessions: Vec<_> = state .sessions .snapshot() .into_iter() .map(|s| s.status_entry()) .collect(); ResponseBody::Ok(serde_json::to_value(SessionListResult { sessions }).unwrap_or(Value::Null)) } async fn handle_browser_list(state: &Arc, params: Value) -> Result { let params: BrowserListParams = parse_params_or_default(params)?; maybe_wait_for_browser(state, params.wait_for_browser_ms).await; // Reuse the same helper that produces the // `multiple_browsers_online.error.data.browsers` payload so the // two surfaces always agree on order — sorted by `connected_at_ms` // ascending, with `instance_id` as a deterministic tiebreaker // (review I1). let browsers = snapshot_status_entries(&state.browsers, &state.sessions); Ok(serde_json::to_value(BrowserListResult { browsers }).unwrap_or(Value::Null)) } // ----- Test-helper IpcServer wrapper around the transport layer ----- /// Owning handle around a spawned IPC server task. Returned by /// [`IpcServer::bind`] and the public [`super::run`] helper. pub struct IpcHandle { pub sock_path: PathBuf, pub shutdown: Arc, pub task: JoinHandle<()>, } pub struct IpcServer { state: Arc, } impl IpcServer { pub fn new(state: Arc) -> Self { Self { state } } /// Bind `sock_path` and spawn the serve loop. The returned /// [`IpcHandle`] can be used to wait for / shut down the task. pub async fn bind(self, sock_path: PathBuf) -> anyhow::Result { let started_at = Instant::now(); let status = DaemonStatus { started_at, ws_port: 0, sock_path: sock_path.clone(), daemon_version: DAEMON_VERSION, protocol_version: PROTOCOL_VERSION, }; let handler = full_handler(status, Arc::clone(&self.state)); let listener = bind(&sock_path).await?; let shutdown = Arc::new(Notify::new()); let shutdown_signal = Arc::clone(&shutdown); let task = tokio::spawn(serve(listener, handler, || {}, || {}, || {}, async move { shutdown_signal.notified().await; })); Ok(IpcHandle { sock_path, shutdown, task, }) } } // ----- Transport: UDS / Named Pipe accept loops (M2/M3) ----- #[cfg(unix)] mod unix { use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result}; use bsk_protocol::{ErrorCode, Frame, RequestFrame, ResponseBody, RpcError}; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; use tracing::{debug, info, warn}; use super::RpcHandler; /// Bind the IPC server. Unbinds a stale socket if present (the daemon /// lock has already prevented two daemons running, so this is safe). pub async fn bind(path: &Path) -> Result { if path.exists() { let _ = std::fs::remove_file(path); } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("create {}", parent.display()))?; } let listener = UnixListener::bind(path).with_context(|| format!("bind UDS {}", path.display()))?; use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); Ok(listener) } /// Run the IPC server loop until `shutdown` resolves. pub async fn serve( listener: UnixListener, handler: RpcHandler, on_open: impl Fn() + Send + Sync + 'static, on_activity: impl Fn() + Send + Sync + 'static, on_close: impl Fn() + Send + Sync + 'static, shutdown: S, ) where S: std::future::Future + Send + 'static, { info!("ipc server listening"); let on_open = Arc::new(on_open); let on_activity = Arc::new(on_activity); let on_close = Arc::new(on_close); tokio::pin!(shutdown); loop { tokio::select! { _ = &mut shutdown => { info!("ipc server shutdown requested"); break; } accepted = listener.accept() => { match accepted { Ok((stream, _addr)) => { on_open(); let handler = handler.clone(); let on_act = on_activity.clone(); let on_done = on_close.clone(); tokio::spawn(async move { if let Err(err) = handle_connection(stream, handler, on_act).await { debug!(?err, "ipc connection ended with error"); } on_done(); }); } Err(err) => { warn!(?err, "ipc accept failed"); tokio::time::sleep(std::time::Duration::from_millis(50)).await; } } } } } } async fn handle_connection( stream: UnixStream, handler: RpcHandler, on_activity: Arc, ) -> Result<()> { let (read_half, mut write_half) = stream.into_split(); let mut reader = BufReader::new(read_half); let mut line = String::new(); loop { line.clear(); let n = reader.read_line(&mut line).await?; if n == 0 { break; } on_activity(); let trimmed = line.trim_end_matches(['\n', '\r']); if trimmed.is_empty() { continue; } let response = match serde_json::from_str::(trimmed) { Ok(Frame::Request(RequestFrame { id, method, params })) => { let params = params.unwrap_or(Value::Null); let body = (handler)(id.clone(), method, params).await; let frame = bsk_protocol::ResponseFrame { id, body }; serde_json::to_string(&Frame::Response(frame))? } Ok(other) => { debug!(?other, "ipc client sent non-request frame"); continue; } Err(err) => serde_json::to_string(&Frame::Response(bsk_protocol::ResponseFrame { id: "0".into(), body: ResponseBody::Err(RpcError { code: ErrorCode::ProtocolError, message: format!("invalid frame: {err}"), data: None, }), }))?, }; write_half.write_all(response.as_bytes()).await?; write_half.write_all(b"\n").await?; write_half.flush().await?; } Ok(()) } } #[cfg(unix)] pub use unix::{bind, serve}; #[cfg(windows)] mod windows { use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result}; use bsk_protocol::{ErrorCode, Frame, RequestFrame, ResponseBody, RpcError}; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions}; use tracing::{debug, info, warn}; use super::RpcHandler; pub struct NamedPipeListener { pipe_name: String, first: Option, } pub async fn bind(_path: &Path) -> Result { let pipe_name = crate::daemon::paths::pipe_name(); let first = ServerOptions::new() .first_pipe_instance(true) .access_inbound(true) .access_outbound(true) .create(&pipe_name) .with_context(|| format!("create first named-pipe instance {pipe_name}"))?; Ok(NamedPipeListener { pipe_name, first: Some(first), }) } pub async fn serve( mut listener: NamedPipeListener, handler: RpcHandler, on_open: impl Fn() + Send + Sync + 'static, on_activity: impl Fn() + Send + Sync + 'static, on_close: impl Fn() + Send + Sync + 'static, shutdown: S, ) where S: std::future::Future + Send + 'static, { info!(pipe = %listener.pipe_name, "ipc named-pipe server listening"); let on_open = Arc::new(on_open); let on_activity = Arc::new(on_activity); let on_close = Arc::new(on_close); tokio::pin!(shutdown); loop { let pipe = match listener.first.take() { Some(pipe) => pipe, None => match ServerOptions::new() .first_pipe_instance(false) .access_inbound(true) .access_outbound(true) .create(&listener.pipe_name) { Ok(pipe) => pipe, Err(err) => { warn!(?err, "create named-pipe instance failed"); tokio::time::sleep(std::time::Duration::from_millis(50)).await; continue; } }, }; tokio::select! { _ = &mut shutdown => { info!("ipc named-pipe server shutdown requested"); break; } connected = pipe.connect() => { match connected { Ok(()) => { on_open(); let handler = handler.clone(); let on_act = on_activity.clone(); let on_done = on_close.clone(); tokio::spawn(async move { if let Err(err) = handle_connection(pipe, handler, on_act).await { debug!(?err, "named-pipe connection ended with error"); } on_done(); }); } Err(err) => { warn!(?err, "named-pipe connect failed"); tokio::time::sleep(std::time::Duration::from_millis(50)).await; } } } } } } async fn handle_connection( pipe: NamedPipeServer, handler: RpcHandler, on_activity: Arc, ) -> Result<()> { let (read_half, mut write_half) = tokio::io::split(pipe); let mut reader = BufReader::new(read_half); let mut line = String::new(); loop { line.clear(); let n = reader.read_line(&mut line).await?; if n == 0 { break; } on_activity(); let trimmed = line.trim_end_matches(['\n', '\r']); if trimmed.is_empty() { continue; } let response = match serde_json::from_str::(trimmed) { Ok(Frame::Request(RequestFrame { id, method, params })) => { let params = params.unwrap_or(Value::Null); let body = (handler)(id.clone(), method, params).await; let frame = bsk_protocol::ResponseFrame { id, body }; serde_json::to_string(&Frame::Response(frame))? } Ok(other) => { debug!(?other, "named-pipe client sent non-request frame"); continue; } Err(err) => serde_json::to_string(&Frame::Response(bsk_protocol::ResponseFrame { id: "0".into(), body: ResponseBody::Err(RpcError { code: ErrorCode::ProtocolError, message: format!("invalid frame: {err}"), data: None, }), }))?, }; write_half.write_all(response.as_bytes()).await?; write_half.write_all(b"\n").await?; write_half.flush().await?; } Ok(()) } } #[cfg(windows)] pub use windows::{bind, serve}; #[cfg(all(test, unix))] mod tests { use super::*; use bsk_protocol::{Frame, Method, RequestFrame}; use tempfile::TempDir; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; #[test] fn session_stop_timeout_covers_stop_round_trip() { assert!(DEFAULT_SESSION_STOP_TIMEOUT > DEFAULT_TOOL_TIMEOUT + DEFAULT_RPC_TIMEOUT); } #[test] fn tool_dispatch_timeout_uses_params_timeout_ms() { let params = serde_json::json!({ "session_id": "abcd", "timeout_ms": 60_000, }); assert_eq!( tool_dispatch_timeout(¶ms).unwrap(), Duration::from_secs(60) ); } #[test] fn tool_dispatch_timeout_honours_request_help_long_timeout() { let params = serde_json::json!({ "session_id": "abcd", "prompt": "log in", "timeout_ms": 300_000, }); let got = tool_dispatch_timeout(¶ms).expect("timeout parses"); assert_eq!(got, std::time::Duration::from_millis(300_000)); } #[test] fn tool_dispatch_timeout_rejects_zero_timeout_ms() { let params = serde_json::json!({ "session_id": "abcd", "timeout_ms": 0, }); let err = tool_dispatch_timeout(¶ms).unwrap_err(); assert_eq!(err.code, ErrorCode::InvalidParams); } #[tokio::test] async fn wait_ms_zero_short_circuits_without_registering() { let registry = Arc::new(AbortRegistry::new()); let body = handle_wait_ms( ®istry, "rpc-zero".into(), serde_json::json!({"duration_ms": 0}), ) .await; match body { ResponseBody::Ok(v) => assert_eq!(v, serde_json::json!({"waited_ms": 0})), other => panic!("expected ok, got {other:?}"), } assert!( registry.is_empty(), "0ms wait must not leak a registry entry" ); } #[tokio::test] async fn wait_ms_rejects_durations_over_five_minutes() { let registry = Arc::new(AbortRegistry::new()); let body = handle_wait_ms( ®istry, "rpc-too-long".into(), serde_json::json!({"duration_ms": MAX_WAIT_MS + 1}), ) .await; match body { ResponseBody::Err(err) => { assert_eq!(err.code, ErrorCode::InvalidParams); assert!(err.message.contains("exceeds")); } other => panic!("expected err, got {other:?}"), } assert!(registry.is_empty()); } #[tokio::test] async fn wait_ms_completes_for_short_duration() { let registry = Arc::new(AbortRegistry::new()); let body = handle_wait_ms( ®istry, "rpc-50".into(), serde_json::json!({"duration_ms": 50}), ) .await; match body { ResponseBody::Ok(v) => assert_eq!(v, serde_json::json!({"waited_ms": 50})), other => panic!("expected ok, got {other:?}"), } assert!(registry.is_empty(), "guard must auto-clean on completion"); } #[tokio::test] async fn wait_ms_cancellation_returns_cancelled_and_unregisters() { let registry = Arc::new(AbortRegistry::new()); let reg_for_cancel = Arc::clone(®istry); let task = tokio::spawn(async move { handle_wait_ms( ®istry, "rpc-cancel".into(), serde_json::json!({"duration_ms": 5_000}), ) .await }); // Give the handler a moment to register before we cancel. tokio::time::sleep(Duration::from_millis(20)).await; assert_eq!(reg_for_cancel.len(), 1); assert!(reg_for_cancel.cancel(&"rpc-cancel".to_string())); let body = tokio::time::timeout(Duration::from_millis(500), task) .await .expect("cancellation should propagate quickly") .expect("task succeeds"); match body { ResponseBody::Err(err) => assert_eq!(err.code, ErrorCode::Cancelled), other => panic!("expected cancelled, got {other:?}"), } assert!(reg_for_cancel.is_empty(), "guard cleans entry on cancel"); } #[test] fn cancel_unknown_rpc_returns_false_flag() { let registry = Arc::new(AbortRegistry::new()); let body = handle_cancel_with_registry_only(®istry, serde_json::json!({"rpc_id": "ghost"})); match body { ResponseBody::Ok(v) => assert_eq!(v, serde_json::json!({"cancelled": false})), other => panic!("expected ok, got {other:?}"), } } #[tokio::test] async fn ping_round_trip() { let tmp = TempDir::new().unwrap(); let sock = tmp.path().join("daemon.sock"); let listener = bind(&sock).await.unwrap(); let handler = default_ping_handler(); let (tx, rx) = tokio::sync::oneshot::channel::<()>(); let server = tokio::spawn(serve(listener, handler, || {}, || {}, || {}, async move { let _ = rx.await; })); let stream = UnixStream::connect(&sock).await.expect("connect"); let (read, mut write) = stream.into_split(); let frame = Frame::Request(RequestFrame { id: "p1".into(), method: Method::SystemPing, params: None, }); let mut line = serde_json::to_string(&frame).unwrap(); line.push('\n'); write.write_all(line.as_bytes()).await.unwrap(); write.flush().await.unwrap(); let mut reader = BufReader::new(read); let mut buf = String::new(); reader.read_line(&mut buf).await.unwrap(); let frame: Frame = serde_json::from_str(buf.trim_end()).unwrap(); match frame { Frame::Response(resp) => { assert_eq!(resp.id, "p1"); match resp.body { ResponseBody::Ok(v) => { assert_eq!(v, serde_json::json!({ "pong": true })); } other => panic!("expected ok, got {other:?}"), } } other => panic!("unexpected frame {other:?}"), } drop(write); drop(reader); let _ = tx.send(()); let _ = server.await; } #[tokio::test] async fn unknown_method_returns_error() { let tmp = TempDir::new().unwrap(); let sock = tmp.path().join("daemon.sock"); let listener = bind(&sock).await.unwrap(); let handler = default_ping_handler(); let (tx, rx) = tokio::sync::oneshot::channel::<()>(); let server = tokio::spawn(serve(listener, handler, || {}, || {}, || {}, async move { let _ = rx.await; })); let stream = UnixStream::connect(&sock).await.expect("connect"); let (read, mut write) = stream.into_split(); let frame = Frame::Request(RequestFrame { id: "p2".into(), method: Method::SystemHandshake, params: None, }); let mut line = serde_json::to_string(&frame).unwrap(); line.push('\n'); write.write_all(line.as_bytes()).await.unwrap(); write.flush().await.unwrap(); let mut reader = BufReader::new(read); let mut buf = String::new(); reader.read_line(&mut buf).await.unwrap(); let frame: Frame = serde_json::from_str(buf.trim_end()).unwrap(); match frame { Frame::Response(resp) => match resp.body { ResponseBody::Err(e) => { assert_eq!(e.code, ErrorCode::UnknownMethod); } other => panic!("expected error, got {other:?}"), }, other => panic!("unexpected frame {other:?}"), } drop(write); drop(reader); let _ = tx.send(()); let _ = server.await; } }