Files
donutbrowser/src-tauri/src/mcp_remote.rs
T
2026-10-10 01:11:04 +04:00

2339 lines
92 KiB
Rust

//! The remote-control bridge: one outbound socket that lets Donut cloud drive
//! this installation's MCP tools.
//!
//! The tool engine lives in [`crate::mcp_server`]. Remote control reaches it
//! without inverting the trust: nothing dials in to the desktop. The app dials
//! OUT to `wss://api.donutbrowser.com/api/mcp-bridge`, proves who it
//! is with the same cloud access token every other cloud call uses, and then
//! answers JSON-RPC that arrives down that socket.
//!
//! That direction is the whole security argument. There is no listening port to
//! find, no inbound firewall hole, no credential parked on a server that could
//! drive a customer's browser if it leaked, the desktop can hang up at any
//! time and the capability disappears with it.
//!
//! ## The wire
//!
//! Text frames of JSON, versioned by [`BRIDGE_PROTOCOL`]. Server to app:
//!
//! ```jsonc
//! {"t":"hello","protocol":"donut-mcp-bridge/1","instanceId":"…"}
//! {"t":"rpc","cid":"…","sessionId":"…|null","payload":{ /* JSON-RPC */ }}
//! {"t":"endSession","sessionId":"…"}
//! ```
//!
//! App to server, one `result` per `rpc`, correlated by `cid`:
//!
//! ```jsonc
//! {"t":"result","cid":"…","status":"ok","sessionId":"…|null","payload":{…}}
//! {"t":"result","cid":"…","status":"accepted"} // a notification
//! {"t":"result","cid":"…","status":"unknownSession"}
//! {"t":"result","cid":"…","status":"badRequest"}
//! {"t":"result","cid":"…","status":"rateLimited","retryAfter":42}
//! {"t":"result","cid":"…","status":"busy"}
//! {"t":"result","cid":"…","status":"tooLarge"}
//! ```
//!
//! The statuses are [`McpOutcome`]'s variants PLUS the two this transport
//! produces on its own, `busy` when the in-flight cap is spent, and
//! `tooLarge` when an answer exceeds the frame budget. The relay turns each
//! status into the HTTP status a streamable HTTP MCP client expects.
//!
//! Liveness is protocol-level: the server PINGs, we PONG, and silence past
//! [`IDLE_TIMEOUT`] is treated as a dead socket. No JSON heartbeat is injected
//! into the stream, because a frame an MCP client did not ask for is a frame it
//! has to be taught to ignore.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde::Serialize;
use tauri::AppHandle;
use tokio::sync::{mpsc, Semaphore};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::handshake::client::Request as WsRequest;
use tokio_tungstenite::tungstenite::Message;
use crate::cloud_auth::{CLOUD_API_URL, CLOUD_AUTH};
use crate::mcp_server::{McpOutcome, McpServer};
/// The wire contract's version. Bumped only for a change a current desktop
/// could not understand, so the relay can refuse a build it cannot talk to
/// instead of failing one frame at a time.
pub const BRIDGE_PROTOCOL: &str = "donut-mcp-bridge/1";
/// Path on the cloud API. Absolute rather than derived, and paired with the
/// protocol string above so the two can never be changed apart.
const BRIDGE_PATH: &str = "/api/mcp-bridge";
/// Emitted whenever the bridge's connection state changes.
pub const EVENT_MCP_REMOTE_STATUS: &str = "mcp-remote-status";
/// Silence that means the socket is gone.
///
/// Matches the idle budget of the endpoint: several missed protocol pings mean
/// a dead peer rather than a slow one, and an idle WebSocket is closed upstream
/// well before this, which is exactly what the pings prevent.
const IDLE_TIMEOUT: Duration = Duration::from_secs(90);
/// How many tool calls may be in flight at once.
///
/// A cap, not a queue: the relay is ours and paces itself, but a bug on either
/// side must not be able to spawn unbounded work inside a customer's app. Past
/// this the app answers `busy`, which the caller can retry, rather than
/// accepting work it will not get to.
const MAX_IN_FLIGHT: usize = 8;
/// The in-flight budget, owned for the life of the bridge rather than per
/// socket.
///
/// `pump` used to create its own. But a socket teardown DELIBERATELY abandons
/// in-flight calls rather than aborting them, half of them are launching
/// browsers, so those tasks keep holding permits from the semaphore their old
/// socket owned. A reconnect then handed the new socket a fresh full budget,
/// and across a flapping connection the cap that exists to protect the
/// customer's machine stopped bounding anything. Held here, an abandoned call
/// still occupies its slot until it finishes, which is the whole point.
static BRIDGE_PERMITS: std::sync::LazyLock<Arc<Semaphore>> =
std::sync::LazyLock::new(|| Arc::new(Semaphore::new(MAX_IN_FLIGHT)));
/// The maximum a single `result` frame may be.
///
/// A screenshot is base64 and genuinely large, so this is generous. It exists so
/// a runaway `get_page_content` cannot try to push an unbounded frame through a
/// socket whose far end will drop it anyway.
const MAX_RESULT_BYTES: usize = 8 * 1024 * 1024;
/// How long a dead socket's writer may take to drain before it is abandoned.
///
/// Long enough for a queued result to reach a socket that is merely slow, short
/// enough that reconnecting is never held up by a tool call the answer of which
/// nobody can receive any more.
const WRITER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
static BRIDGE_RUNNING: AtomicBool = AtomicBool::new(false);
static BRIDGE_TASK: Mutex<Option<tauri::async_runtime::JoinHandle<()>>> = Mutex::new(None);
static BRIDGE_CONNECTED: AtomicBool = AtomicBool::new(false);
static BRIDGE_ANNOUNCED: AtomicBool = AtomicBool::new(false);
static BRIDGE_LINK: crate::log_streak::Streak =
crate::log_streak::Streak::new(module_path!(), "Remote control bridge");
static LAST_ERROR: Mutex<Option<String>> = Mutex::new(None);
/// What the Integrations page shows about remote control.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct McpRemoteStatus {
/// Whether the bridge task is meant to be up. Distinct from `connected`: a
/// bridge that is enabled but reconnecting is a different thing to say than
/// one that is switched off.
pub enabled: bool,
pub connected: bool,
pub instance_id: String,
/// Why the last attempt failed, as a `{"code": …}` envelope the UI resolves
/// through `translateBackendError`. Cleared on a good connect.
///
/// A code rather than a message: the underlying reasons are English, and some
/// are written by the server, so neither can be shown to a customer reading
/// the app in one of the other nine languages.
pub last_error: Option<String>,
}
/// Why a connection attempt ended, which decides how hard to back off.
#[derive(Debug)]
enum BridgeError {
/// The credential was refused. Retrying fast fixes nothing.
Unauthorized(String),
/// Another instance of this account holds the single bridge slot.
SlotTaken(String),
/// The plan does not include remote control.
NotEntitled(String),
/// Anything transient: DNS, TLS, a restarting backend.
Unreachable(String),
}
impl std::fmt::Display for BridgeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unauthorized(reason)
| Self::SlotTaken(reason)
| Self::NotEntitled(reason)
| Self::Unreachable(reason) => write!(f, "{reason}"),
}
}
}
impl BridgeError {
/// The stable code the UI translates this into.
///
/// The variants carry English prose, some of it the relay's own close
/// reason, and that prose is for the log file, where a support engineer
/// reads it. It must never reach the screen: the Integrations page is
/// localised into ten languages, and rendering a server-authored English
/// sentence under a Japanese UI is exactly what the translation rule exists
/// to stop. So the status carries a code and the words are chosen locally.
fn code(&self) -> &'static str {
match self {
Self::Unauthorized(_) => "MCP_REMOTE_UNAUTHORIZED",
Self::SlotTaken(_) => "MCP_REMOTE_SLOT_TAKEN",
Self::NotEntitled(_) => "MCP_REMOTE_NOT_ENTITLED",
Self::Unreachable(_) => "MCP_REMOTE_UNREACHABLE",
}
}
/// Whether a fast retry could plausibly succeed. It cannot for any refusal
/// the server will keep repeating, and a client that retries one of those on
/// a one-second timer is a battery bug wearing a reconnect loop.
fn is_terminal_refusal(&self) -> bool {
matches!(
self,
Self::Unauthorized(_) | Self::SlotTaken(_) | Self::NotEntitled(_)
)
}
}
/// The instance-id shape the bridge endpoint accepts.
///
/// Checked HERE rather than relying on the server to complain: a persisted id
/// that fails the shape is refused at the upgrade with "an instance id is
/// required", and the desktop can only guess what that means. Regenerating a
/// malformed one removes the condition instead of improving the error message
/// for it.
fn is_valid_instance_id(value: &str) -> bool {
(8..=64).contains(&value.len()) && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
/// This installation's stable identity on the bridge.
///
/// Stable across restarts on purpose. The bridge slot is granted per instance
/// id, and a reconnect after a network blip must be able to take its OWN slot
/// back rather than be refused until the stale one is cleaned up. A fresh id
/// per process would make every dropped socket a minutes-long outage.
///
/// Not a secret and not a credential: it names a machine, it does not
/// authenticate one. The access token does that.
pub fn instance_id() -> String {
static CACHED: std::sync::OnceLock<String> = std::sync::OnceLock::new();
CACHED
.get_or_init(|| read_or_create_instance_id(&instance_id_path()))
.clone()
}
/// Where the id lives, a temp path under `cfg(test)`, the real settings
/// directory otherwise.
///
/// Structural, not per-test discipline. Announcing the connection now happens
/// inside `dispatch`'s hello branch, which reaches `publish_state` -> `status`
/// -> `instance_id`, and that CREATES the file when it is absent. Four tests
/// feed a real `hello` through the real `pump`, so `cargo test` began writing
/// into the developer's own DonutBrowserDev settings directory. Making each of
/// those tests seed a cache would work only until the fifth test forgot; making
/// the PATH itself test-aware cannot be forgotten.
fn instance_id_path() -> std::path::PathBuf {
#[cfg(test)]
{
std::env::temp_dir().join("donut-mcp-instance-id-test")
}
#[cfg(not(test))]
{
crate::app_dirs::settings_dir().join("mcp_instance_id")
}
}
/// The body of [`instance_id`], taking its path so it can be tested.
///
/// `instance_id` caches in a `OnceLock`, so a test can only ever observe the
/// first call in the process; keeping the logic here is what lets the
/// regenerate-a-malformed-id behaviour actually be exercised rather than only
/// its helper.
fn read_or_create_instance_id(path: &std::path::Path) -> String {
if let Ok(existing) = std::fs::read_to_string(path) {
let trimmed = existing.trim();
if is_valid_instance_id(trimmed) {
return trimmed.to_string();
}
if !trimmed.is_empty() {
log::warn!("Remote control instance id malformed, regenerated");
}
}
let fresh = uuid::Uuid::new_v4().to_string();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(path, &fresh) {
// A machine that cannot persist this still works; it just loses the
// reclaim-my-own-slot property until the write succeeds.
log::warn!("Remote control instance id not saved err=\"{e}\"");
}
fresh
}
pub fn is_running() -> bool {
BRIDGE_RUNNING.load(Ordering::SeqCst)
}
pub fn is_connected() -> bool {
BRIDGE_CONNECTED.load(Ordering::SeqCst)
}
pub fn status() -> McpRemoteStatus {
McpRemoteStatus {
enabled: is_running(),
connected: is_connected(),
instance_id: instance_id(),
last_error: LAST_ERROR
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(),
}
}
/// Record the state and tell the screen, WITHOUT needing an `AppHandle`.
///
/// `stop(None)` is how both credential teardowns hang up, logout and the
/// automatic `invalidate_session`, and neither has a handle to pass. Emitting
/// only when one was supplied left the Integrations page reading
/// "Connected as <instance-id>" over a bridge that had already been torn down,
/// until the dialog happened to be reopened.
fn publish_state(connected: bool, error: Option<String>) {
// A bridge that is not running cannot be connected, so a late "connected"
// is downgraded rather than believed. `stop` flips BRIDGE_RUNNING first,
// and cancellation only takes effect at an await point, so the read loop can
// still be part-way through a frame when the stop lands. Without this the
// greeting published microseconds later would overwrite the teardown and
// leave the Integrations page reading "Connected as <instance-id>" over a
// socket that was hung up and a credential logout had already deleted -
// exactly the stale-state bug this function was introduced to fix.
let connected = connected && BRIDGE_RUNNING.load(Ordering::SeqCst);
BRIDGE_CONNECTED.store(connected, Ordering::SeqCst);
{
let mut slot = LAST_ERROR
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*slot = error;
}
let _ = crate::events::emit(EVENT_MCP_REMOTE_STATUS, status());
}
/// Start the bridge. Idempotent: a second call while it is up is a no-op rather
/// than a second socket racing the first for the same slot.
pub fn start(app: AppHandle) {
// The handle is still taken so callers keep a single obvious entry point,
// and so a future need for it does not churn every call site.
let _ = app;
// The task lock is held across BOTH the flag flip and the handle store.
// With the flag flipped first and the handle stored afterwards, a `stop`
// landing in between saw the flag, found no handle to abort, and returned;
// the spawn then parked its handle in the slot with the flag already false,
// leaving a reconnect loop nobody could stop until the next start replaced
// the handle. Under the lock a stop either runs before this (and the start
// proceeds) or after it (and finds the handle).
let mut slot = BRIDGE_TASK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if BRIDGE_RUNNING.swap(true, Ordering::SeqCst) {
return;
}
*slot = Some(tauri::async_runtime::spawn(async move {
run().await;
}));
}
/// Stop the bridge. Safe to call when nothing is running.
pub fn stop(app: Option<&AppHandle>) {
// Same lock, same reason as `start`: the flag and the handle change together.
let mut slot = BRIDGE_TASK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !BRIDGE_RUNNING.swap(false, Ordering::SeqCst) {
return;
}
if let Some(handle) = slot.take() {
handle.abort();
}
drop(slot);
// Unconditionally, not only when there is a handle to notify: a stopped
// bridge has no last error by definition, and the screen has to learn it was
// stopped even when the caller is a teardown path with no handle.
publish_state(false, None);
// The `app` parameter is kept because callers that HAVE a handle read as
// clearer at the call site, but nothing needs it any more: the global
// emitter is a `TauriEmitter` installed during setup, so it reaches the
// frontend identically. Emitting through both sent every status change
// twice.
let _ = app;
}
/// The reconnect loop.
///
/// Takes no `AppHandle`: status reaches the screen through the global emitter
/// installed at startup, which is the same `TauriEmitter`. Threading a handle
/// through here as well meant every state change was emitted twice, and left
/// the two teardown paths, which have no handle, unable to emit at all.
async fn run() {
let mut attempt = 0u32;
// Whether the current credential has already had its one refresh.
//
// Reset only when we have been GREETED, never merely on a handshake: the
// upgrade completes before the credential is judged, so a completed handshake
// says nothing about it. Resetting there made the bound inert for the only
// path that can produce a credential refusal, a close frame, so a desktop
// that keeps being refused rotated its refresh token on every cycle instead
// of exactly once.
let mut refreshed = false;
let greeted = Arc::new(AtomicBool::new(false));
BRIDGE_ANNOUNCED.store(false, Ordering::SeqCst);
while BRIDGE_RUNNING.load(Ordering::SeqCst) {
// `dialled_with` is the access token the socket was dialled with, so a
// refusal can be compared against what is on disk NOW. None when the dial
// itself failed before a credential was read.
let (failure, dialled_with) = match connect().await {
Ok((stream, token)) => {
greeted.store(false, Ordering::SeqCst);
log::debug!("Remote control bridge dialled instance={}", instance_id());
// The connection is announced by `dispatch`, synchronously, the moment
// the `hello` is read, NOT here, and no longer from a task of its own.
// The upgrade completes before the credential is judged, so a completed
// handshake is not a connection, it is a question that has not been
// answered yet; announcing on it made a refused customer (an
// unentitled plan, a taken slot) watch the tab flash connected and then
// fail once a minute, for ever.
//
// Announcing from a spawned task was the previous shape and it was
// wrong twice. The task waited on a `Notify` and `stop` could not reach
// it, `BRIDGE_TASK` holds only this loop's handle, and dropping a
// JoinHandle detaches rather than aborts, so a stop between dialling
// and greeting parked it on a signal nobody could ever fire again, one
// leaked task per stop. And `abort` cannot cancel a task that has
// already passed its last await, so on a greet-then-close socket the
// announcement could land AFTER the teardown below and leave the screen
// reading connected with no error. Publishing on this task removes both:
// there is one publisher, its order is the order of the loop, and a
// cancellation takes the announcement with it.
let opened_at = std::time::Instant::now();
let outcome = pump(stream, Arc::clone(&greeted)).await;
let lasted = opened_at.elapsed();
// A `hello` is the first frame that arrives AFTER the credential has
// been accepted, so it is the only evidence here of that, and therefore
// the only thing that should clear either counter. Clearing them on the
// handshake meant a bridge that upgrades and is then refused every time
// never backed off at all: the upgrade completes before the credential
// is judged, so `connect()` succeeds on every cycle no matter how
// hopeless the credential is.
if greeted.load(Ordering::SeqCst) {
// The credential was accepted, so it may spend another refresh.
refreshed = false;
// The BACKOFF is a separate question, and needs more than a
// greeting. The `hello` arrives as soon as the socket is registered,
// before anything has been proved, so a connection that lived five
// milliseconds reset the counter exactly like one that lived a day,
// and a close that classifies as non-terminal (an idle 1011, say)
// then retried at 1 Hz for ever. Only a connection that actually held
// resets it.
if lasted >= MIN_UPTIME_FOR_BACKOFF_RESET {
attempt = 0;
}
}
match &outcome {
Ok(()) => log::debug!("Remote control bridge closed by the backend"),
Err(e) => BRIDGE_LINK.failed(e),
}
// The code for the screen, the prose for the log above.
publish_state(
false,
outcome
.as_ref()
.err()
.map(|e| crate::backend_error(e.code())),
);
(outcome.err(), Some(token))
}
Err(e) => {
BRIDGE_LINK.failed(&e);
publish_state(false, Some(crate::backend_error(e.code())));
(Some(e), None)
}
};
if let Some(error) = &failure {
// An expired access token is the one "unauthorized" that fixes itself,
// and it MUST be handled here rather than at the dial.
//
// A refused credential never arrives as an HTTP 401: the upgrade
// completes first, and the refusal arrives as a close frame on an
// already-open socket. A refresh-and-retry hung off the dial therefore
// never runs, and the desktop sits in the terminal backoff band
// redialling the same dead token until an unrelated ten-minute loop
// happens to renew it: minutes of "sign out and sign in again" for a
// condition that needed neither.
//
// First, though: is the refused token still the one on disk? The
// ten-minute loop and every `api_call_with_retry` also rotate it, and a
// socket that lived for hours was dialled with a token that has almost
// certainly been replaced since. Refreshing in that case spends the
// one-shot on a token that is already gone, and the rotation itself is
// what was refused. So a stale token is simply redialled with the current
// one, and the refresh is reserved for a token that was refused while it
// was still current.
let rotated = matches!(error, BridgeError::Unauthorized(_))
&& credential_rotated_since(
dialled_with.as_deref(),
crate::remote_session::access_token_for_cdp()
.ok()
.as_deref(),
);
if rotated {
log::debug!("Remote control bridge redialling with the already rotated token");
attempt = 0;
} else if should_refresh_credential(error, refreshed) {
match CLOUD_AUTH.refresh_access_token().await {
Ok(()) => {
log::debug!("Remote control bridge retrying with a refreshed token");
// Spent only NOW. Marking it before the attempt meant a refresh
// that FAILED, a transient network blip at exactly the wrong
// moment, permanently disarmed the retry: the reset needs a
// `hello`, which never arrives for a credential that keeps being
// refused. The bridge then sat on a dead token until the app
// restarted.
refreshed = true;
attempt = 0;
}
Err(e) => {
log::warn!("Remote control bridge token refresh failed err=\"{e}\"");
attempt = attempt.max(TERMINAL_BACKOFF_ATTEMPT);
}
}
} else if error.is_terminal_refusal() {
// Everything the server will keep saying no to, a slot conflict, an
// unentitled plan, a credential a refresh did not fix. Without this a
// second copy of Donut reconnects on a one-second timer for as long as
// both are open.
attempt = attempt.max(TERMINAL_BACKOFF_ATTEMPT);
}
}
if !BRIDGE_RUNNING.load(Ordering::SeqCst) {
break;
}
let delay = crate::remote_session::jittered(crate::remote_session::reconnect_delay(attempt));
attempt = attempt.saturating_add(1);
sleep_unless_stopped(delay).await;
}
BRIDGE_CONNECTED.store(false, Ordering::SeqCst);
log::info!("Remote control bridge stopped");
}
/// Whether this failure is worth one token refresh and an immediate retry.
///
/// Only `Unauthorized`, and only once per credential. A slot conflict or an
/// unentitled plan is not something a new token fixes, and retrying either on a
/// fast timer is how a background task becomes a battery bug.
fn should_refresh_credential(error: &BridgeError, already_refreshed: bool) -> bool {
!already_refreshed && matches!(error, BridgeError::Unauthorized(_))
}
/// Whether the token on disk is no longer the one that was refused.
///
/// Only a KNOWN refused token that differs from a KNOWN current one counts.
/// With nothing to compare (the dial failed before reading a credential, or
/// there is no credential now) the answer is no, and the refusal takes the
/// refresh path or the terminal backoff as it always did.
fn credential_rotated_since(refused: Option<&str>, current: Option<&str>) -> bool {
match (refused, current) {
(Some(refused), Some(current)) => refused != current,
_ => false,
}
}
/// Where the backoff restarts after a refusal the server will keep repeating.
/// Shares the SSE stream's constant so one deployment has one answer to "how
/// long before we ask again".
const TERMINAL_BACKOFF_ATTEMPT: u32 = 6;
/// How long a connection must hold before it counts as "working" for backoff.
///
/// Shorter than the idle timeout upstream, so a healthy but quiet bridge still
/// resets, and far longer than the greet-then-close cycle that made the retry
/// loop spin.
const MIN_UPTIME_FOR_BACKOFF_RESET: Duration = Duration::from_secs(60);
/// Granularity of the cancellable sleep, so switching remote control off is not
/// held up by a minute-long backoff.
const SHUTDOWN_POLL: Duration = Duration::from_millis(250);
async fn sleep_unless_stopped(total: Duration) {
let mut slept = Duration::ZERO;
while slept < total && BRIDGE_RUNNING.load(Ordering::SeqCst) {
let step = SHUTDOWN_POLL.min(total - slept);
tokio::time::sleep(step).await;
slept += step;
}
}
type BridgeStream =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
fn bridge_url() -> String {
format!(
"{}{BRIDGE_PATH}",
CLOUD_API_URL.replacen("https://", "wss://", 1)
)
}
/// Build the upgrade request.
///
/// The credential is a header, never a query parameter: a URL that grants
/// control of a browser must not reach a proxy access log. The instance id and
/// build details ride alongside so one machine can be told from another and the
/// account page can name the device that holds the slot.
fn bridge_request(bearer: &str) -> Result<WsRequest, BridgeError> {
let mut request = bridge_url()
.into_client_request()
.map_err(|e| BridgeError::Unreachable(format!("invalid bridge endpoint: {e}")))?;
let header = |value: &str| {
value.parse().map_err(|_| {
BridgeError::Unauthorized("a bridge header value is not valid ASCII".to_string())
})
};
let headers = request.headers_mut();
headers.insert(
tokio_tungstenite::tungstenite::http::header::AUTHORIZATION,
header(&format!("Bearer {bearer}"))?,
);
headers.insert("x-donut-instance", header(&instance_id())?);
headers.insert("x-donut-protocol", header(BRIDGE_PROTOCOL)?);
headers.insert("x-donut-client-version", header(env!("CARGO_PKG_VERSION"))?);
headers.insert("x-donut-platform", header(std::env::consts::OS)?);
Ok(request)
}
/// Dial the bridge endpoint with the stored access token.
///
/// Deliberately does NOT refresh on refusal. The credential is judged AFTER the
/// handshake, so a bad one never reaches this function as an error. It arrives
/// later as a close frame, and `run` is the only place that can see it. A
/// refresh here would be dead code that reads like a safety net.
///
/// Returns the token alongside the socket so `run` can later tell whether a
/// refusal was for THIS token or for one that has since been replaced.
async fn connect() -> Result<(BridgeStream, String), BridgeError> {
let token = crate::remote_session::access_token_for_cdp()
.map_err(|e| BridgeError::Unauthorized(e.to_string()))?;
let stream = dial(&token).await?;
Ok((stream, token))
}
/// How long the upgrade may take before it counts as unreachable.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
async fn dial(bearer: &str) -> Result<BridgeStream, BridgeError> {
let request = bridge_request(bearer)?;
let connect = tokio_tungstenite::connect_async(request);
match tokio::time::timeout(CONNECT_TIMEOUT, connect).await {
Err(_) => Err(BridgeError::Unreachable(format!(
"the bridge did not answer within {}s",
CONNECT_TIMEOUT.as_secs()
))),
Ok(Ok((stream, _response))) => Ok(stream),
Ok(Err(tokio_tungstenite::tungstenite::Error::Http(response))) => {
Err(classify_status(response.status().as_u16()))
}
Ok(Err(e)) => Err(BridgeError::Unreachable(e.to_string())),
}
}
/// Turn a refused upgrade into the reason it means.
///
/// The three refusals a user can act on are told apart on purpose: sign in
/// again, close the other copy of Donut, or upgrade the plan. Collapsing them
/// into "connection failed" is what makes a feature look broken when it is
/// merely saying no.
fn classify_status(status: u16) -> BridgeError {
match status {
401 => BridgeError::Unauthorized(format!("the bridge refused the credential (HTTP {status})")),
402 | 403 => BridgeError::NotEntitled(
"this plan does not include remote control of the desktop app".to_string(),
),
409 => BridgeError::SlotTaken(
"another Donut instance on this account already holds the remote-control slot".to_string(),
),
other => BridgeError::Unreachable(format!("the bridge answered HTTP {other}")),
}
}
/// Map a close frame onto the same vocabulary as a refused handshake.
///
/// A 409 cannot answer a socket that has already been upgraded, so a slot taken
/// *after* connect arrives as a 1008 with a reason. Reading only the code would
/// report a plan problem and a slot conflict identically.
fn classify_close(code: u16, reason: &str) -> BridgeError {
let lowered = reason.to_ascii_lowercase();
match code {
// Matched on "slot", not on the bare word "instance": the malformed-header
// refusal is "an instance id is required", which shares that noun while
// meaning something completely different. Reading it as a conflict told the
// customer to close a second Donut that was not running, and never showed
// the real cause.
1008 if lowered.contains("slot") => BridgeError::SlotTaken(reason.to_string()),
1008 if lowered.contains("entitle") || lowered.contains("plan") => {
BridgeError::NotEntitled(reason.to_string())
}
1008 => BridgeError::Unauthorized(if reason.is_empty() {
"the bridge revoked this connection".to_string()
} else {
reason.to_string()
}),
// Evicted because another socket took the slot. This arrives as a normal
// close with a replacement reason, because being replaced by yourself after
// a reconnect is not an error, but two installs that share a copied data
// directory carry the SAME instance id, so each looks like the other
// reconnecting and they evict each other. Read as `Unreachable` this
// retried at once and never settled: a permanent one-per-second flip in
// which whichever machine won the last flip served the account's tool
// calls. It is a slot conflict whatever code carries it, and naming it one
// puts it in the terminal backoff band the comment on
// `is_terminal_refusal` already promises.
1000 if lowered.contains("replaced") || lowered.contains("newer connection") => {
BridgeError::SlotTaken(reason.to_string())
}
_ if reason.is_empty() => BridgeError::Unreachable(format!("the bridge closed ({code})")),
_ => BridgeError::Unreachable(format!("the bridge closed ({code}: {reason})")),
}
}
/// Carry frames until the socket dies.
///
/// Reads and writes are split so a long tool call never blocks the pong that
/// keeps the connection alive: work is spawned, and its answer is pushed onto a
/// channel that a dedicated writer drains.
async fn pump(stream: BridgeStream, greeted: Arc<AtomicBool>) -> Result<(), BridgeError> {
let (mut sink, mut source) = stream.split();
let (tx, mut rx) = mpsc::channel::<Message>(MAX_IN_FLIGHT * 2 + 8);
let permits = Arc::clone(&BRIDGE_PERMITS);
let writer = tokio::spawn(async move {
while let Some(message) = rx.recv().await {
if sink.send(message).await.is_err() {
break;
}
}
let _ = sink.close().await;
});
let result = loop {
let next = match tokio::time::timeout(IDLE_TIMEOUT, source.next()).await {
Err(_) => {
break Err(BridgeError::Unreachable(format!(
"no frame from the bridge in {}s",
IDLE_TIMEOUT.as_secs()
)))
}
Ok(None) => break Ok(()),
Ok(Some(Err(e))) => break Err(BridgeError::Unreachable(e.to_string())),
Ok(Some(Ok(message))) => message,
};
match next {
Message::Text(text) => {
if let Some(frame) = parse_frame(&text) {
dispatch(frame, &tx, &permits, &greeted);
}
}
Message::Ping(payload) => {
// Answered through OUR writer, rather than relying on tungstenite's
// automatic pong.
//
// The library does currently put its own pong on the wire, so this is
// belt and braces and a test cannot tell the two apart, deleting this
// line leaves `the_desktop_answers_the_relays_keepalive_ping` green,
// because that test asserts the property that actually matters (a pong
// reaches the peer) rather than which code path produced it. It stays
// because the alternative is depending on when a third-party library
// chooses to flush, and the cost of being wrong about that is every
// desktop being dropped upstream at the idle timeout.
if tx.send(Message::Pong(payload)).await.is_err() {
break Ok(());
}
}
Message::Close(frame) => {
break match frame {
Some(frame) => Err(classify_close(u16::from(frame.code), &frame.reason)),
None => Ok(()),
}
}
// Binary and pong frames carry nothing this protocol defines.
_ => {}
}
};
// Dropping this sender does NOT close the channel: `dispatch` clones it into
// every spawned tool call, and the writer only stops when the LAST sender is
// gone. So the join below is bounded and then abandoned.
//
// Without the bound, a single stuck tool call outlives the socket and pins
// this function forever: the idle timeout fires, `result` is already decided,
// and `run` still never reaches its reconnect sleep, so `BRIDGE_CONNECTED`
// stays true, the Integrations page reports a healthy bridge, and remote
// control is dead until the app restarts. That defeats the exact failure the
// idle timeout exists to catch.
//
// The in-flight calls themselves are deliberately left running rather than
// aborted. Their answers have nowhere to go, but half of them are launching
// browsers, and cancelling one mid-launch leaves a profile in a worse state
// than letting it finish into a closed channel.
drop(tx);
drain_writer(writer, WRITER_DRAIN_TIMEOUT).await;
result
}
/// Wait for the writer to finish, then give up on it.
///
/// Split out so the bound can be tested at a cadence a test can wait for. The
/// bound is the whole point: without it a caller can wait forever, and the
/// only thing that would notice is the customer.
async fn drain_writer(writer: tokio::task::JoinHandle<()>, budget: Duration) {
let mut writer = writer;
if tokio::time::timeout(budget, &mut writer).await.is_err() {
log::warn!(
"Remote control relayed call outlived its socket, writer abandoned after_s={}",
budget.as_secs()
);
writer.abort();
}
}
/// One decoded server frame.
enum BridgeFrame {
Hello {
protocol: String,
},
Rpc {
cid: String,
session_id: Option<String>,
payload: Vec<u8>,
},
EndSession {
session_id: String,
},
}
fn parse_frame(text: &str) -> Option<BridgeFrame> {
let value: serde_json::Value = serde_json::from_str(text).ok()?;
match value.get("t").and_then(serde_json::Value::as_str)? {
"hello" => Some(BridgeFrame::Hello {
protocol: value
.get("protocol")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
}),
"rpc" => {
// Non-empty, not merely present. A relayed call is correlated ONLY by
// `cid`, so an empty one is as unanswerable as a missing one: the reply
// would match nothing at the relay and the caller would wait out its full
// timeout rather than learn anything.
let cid = value
.get("cid")
.and_then(serde_json::Value::as_str)
.filter(|cid| !cid.is_empty())?;
let payload = value.get("payload")?;
Some(BridgeFrame::Rpc {
cid: cid.to_string(),
session_id: value
.get("sessionId")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
payload: serde_json::to_vec(payload).ok()?,
})
}
"endSession" => Some(BridgeFrame::EndSession {
// Likewise non-empty: forgetting the session named "" is a no-op the
// relay would read as a successful teardown.
session_id: value
.get("sessionId")
.and_then(serde_json::Value::as_str)
.filter(|id| !id.is_empty())?
.to_string(),
}),
other => {
// Forward compatibility: a newer relay may add frames this build has
// never heard of, and dropping one must not take the socket down.
log::debug!("Remote control bridge frame unknown, ignored frame={other}");
None
}
}
}
fn dispatch(
frame: BridgeFrame,
tx: &mpsc::Sender<Message>,
permits: &Arc<Semaphore>,
greeted: &AtomicBool,
) {
match frame {
BridgeFrame::Hello { protocol } => {
// The relay sends this only once it has accepted the credential, which
// is what makes it the signal `run` uses to allow another refresh and to
// tell the screen the bridge is actually up.
greeted.store(true, Ordering::SeqCst);
BRIDGE_LINK.succeeded();
if BRIDGE_ANNOUNCED.swap(true, Ordering::SeqCst) {
log::debug!("Remote control bridge connected instance={}", instance_id());
} else {
log::info!("Remote control bridge connected instance={}", instance_id());
}
publish_state(true, None);
if protocol != BRIDGE_PROTOCOL {
log::warn!(
"Remote control bridge protocol mismatch relay={protocol} build={BRIDGE_PROTOCOL}"
);
}
}
BridgeFrame::EndSession { session_id } => {
tokio::spawn(async move {
McpServer::instance().end_session(&session_id).await;
});
}
BridgeFrame::Rpc {
cid,
session_id,
payload,
} => {
let Ok(permit) = Arc::clone(permits).try_acquire_owned() else {
log::warn!("Remote control relayed call refused: busy in_flight={MAX_IN_FLIGHT}");
let _ = tx.try_send(encode(&serde_json::json!({
"t": "result",
"cid": cid,
"status": "busy",
})));
return;
};
let tx = tx.clone();
tokio::spawn(async move {
let outcome = McpServer::instance()
.handle_message(session_id.as_deref(), &payload)
.await;
drop(permit);
let _ = tx.send(encode(&result_frame(&cid, outcome))).await;
});
}
}
}
fn encode(value: &serde_json::Value) -> Message {
Message::Text(value.to_string().into())
}
fn result_frame(cid: &str, outcome: McpOutcome) -> serde_json::Value {
match outcome {
McpOutcome::Body {
body,
new_session_id,
} => {
let encoded = body.to_string();
if encoded.len() > MAX_RESULT_BYTES {
log::warn!(
"Remote control result too large bytes={} max={MAX_RESULT_BYTES}",
encoded.len()
);
return serde_json::json!({
"t": "result",
"cid": cid,
"status": "tooLarge",
});
}
serde_json::json!({
"t": "result",
"cid": cid,
"status": "ok",
"sessionId": new_session_id,
"payload": body,
})
}
McpOutcome::Accepted => serde_json::json!({
"t": "result", "cid": cid, "status": "accepted",
}),
McpOutcome::UnknownSession => serde_json::json!({
"t": "result", "cid": cid, "status": "unknownSession",
}),
McpOutcome::BadRequest => serde_json::json!({
"t": "result", "cid": cid, "status": "badRequest",
}),
McpOutcome::RateLimited { retry_after_secs } => serde_json::json!({
"t": "result", "cid": cid, "status": "rateLimited", "retryAfter": retry_after_secs,
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bridge_url_is_the_websocket_scheme_of_the_cloud_api() {
assert_eq!(bridge_url(), "wss://api.donutbrowser.com/api/mcp-bridge");
}
#[test]
fn credentials_never_reach_the_url() {
let request = bridge_request("secret-token").expect("request");
assert!(!request.uri().to_string().contains("secret-token"));
assert_eq!(
request
.headers()
.get(tokio_tungstenite::tungstenite::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok()),
Some("Bearer secret-token")
);
}
#[test]
fn handshake_statuses_map_onto_actionable_reasons() {
assert!(matches!(classify_status(401), BridgeError::Unauthorized(_)));
assert!(matches!(classify_status(402), BridgeError::NotEntitled(_)));
assert!(matches!(classify_status(403), BridgeError::NotEntitled(_)));
assert!(matches!(classify_status(409), BridgeError::SlotTaken(_)));
assert!(matches!(classify_status(500), BridgeError::Unreachable(_)));
}
#[test]
fn a_slot_conflict_after_connect_is_not_read_as_a_credential_problem() {
assert!(matches!(
classify_close(1008, "another instance already holds the slot"),
BridgeError::SlotTaken(_)
));
assert!(matches!(
classify_close(1008, "remote control is not included in this plan"),
BridgeError::NotEntitled(_)
));
assert!(matches!(
classify_close(1008, "credential revoked"),
BridgeError::Unauthorized(_)
));
assert!(matches!(
classify_close(1011, ""),
BridgeError::Unreachable(_)
));
}
#[tokio::test]
async fn the_screen_says_connected_only_after_the_relay_greets_us() {
// The upgrade completes BEFORE the credential is judged, so a completed
// handshake is a question, not a connection. Announcing "Connected as" on
// it made a refused customer watch the tab flash connected and fail once a
// minute for ever, which reads as a flapping network rather than as the
// refusal it is.
// Observed through `greeted`, which is per-dial state owned by this test,
// NOT through BRIDGE_CONNECTED. That global is shared with every sibling
// test on cargo's parallel threads, and asserting on it would couple
// this test to whatever they happened to publish. `greeted` is the same
// flag `run` itself reads to decide whether the credential was accepted,
// and the source assertion at the end pins the announcement to it.
let greeted = Arc::new(AtomicBool::new(false));
// Refused right after the upgrade: nothing to announce.
let (url, server) = fake_relay(vec![], 0).await;
let stream = dial_fake(&url).await;
let _ = pump(stream, Arc::clone(&greeted)).await;
let _ = server.await;
assert!(
!greeted.load(Ordering::SeqCst),
"a bare handshake must not announce a connection"
);
// A frame that is NOT a hello must not announce either: the relay sends
// rpc and endSession over a socket it has already accepted, but reading
// any of them as acceptance would put the "only hello" rule back to
// "anything at all", which is what it replaced.
let (url, server) = fake_relay(
vec![
serde_json::json!({
"t": "rpc",
"cid": "cid-x",
"sessionId": serde_json::Value::Null,
"payload": { "jsonrpc": "2.0", "id": 1, "method": "ping" },
}),
serde_json::json!({ "t": "endSession", "sessionId": "some-session" }),
],
1,
)
.await;
let stream = dial_fake(&url).await;
let _ = pump(stream, Arc::clone(&greeted)).await;
let _ = server.await;
assert!(
!greeted.load(Ordering::SeqCst),
"only the relay's hello may announce a connection"
);
// Greeted: now the screen may say connected.
let (url, server) = fake_relay(
vec![serde_json::json!({ "t": "hello", "protocol": BRIDGE_PROTOCOL })],
0,
)
.await;
let stream = dial_fake(&url).await;
let _ = pump(stream, Arc::clone(&greeted)).await;
let _ = server.await;
assert!(
greeted.load(Ordering::SeqCst),
"the relay's hello must announce the connection"
);
// And the announcement is wired to exactly that branch. Without this, the
// behavioural half above would still pass if `publish_state(true, ..)` were
// moved somewhere reached before the greeting, which is the bug the whole
// test exists to prevent.
let source = include_str!("mcp_remote.rs");
let production = source
.split_once("\n#[cfg(test)]")
.map_or(source, |(code, _)| code);
let announcements: Vec<&str> = production
.match_indices("publish_state(true")
.map(|(at, _)| {
let start = production[..at].rfind("\nfn ").unwrap_or(0);
production[start..].lines().nth(1).unwrap_or("").trim()
})
.collect();
assert_eq!(
announcements.len(),
1,
"exactly one place may announce a connection; found {announcements:?}"
);
let hello = production
.split("BridgeFrame::Hello { protocol } => {")
.nth(1)
.expect("the hello branch must exist");
assert!(
hello[..hello.find("\n }").unwrap_or(hello.len())].contains("publish_state(true, None)"),
"the one announcement must sit in the hello branch"
);
}
#[test]
fn a_teardown_with_no_app_handle_still_corrects_the_screen() {
// Both credential teardowns, `logout` and the automatic
// `invalidate_session`, call `stop(None)`, because neither holds an
// AppHandle. Emitting the new state only when one was supplied left the
// Integrations page reading "Connected as <instance-id>" over a bridge
// that had already been hung up.
let source = include_str!("mcp_remote.rs");
let publish = source
.split("fn publish_state(")
.nth(1)
.expect("publish_state must exist");
// Bounded by the function's own end, not by a character count: the window
// used to be 700 characters and a later comment pushed the emit past it,
// so the assertion stopped reading the code it was written to guard.
let body = &publish[..publish.find("\n}").unwrap_or(publish.len())];
assert!(
body.contains("crate::events::emit(EVENT_MCP_REMOTE_STATUS"),
"the state must be published through the handle-free global emitter"
);
let stop = source
.split("pub fn stop(app: Option<&AppHandle>) {")
.nth(1)
.expect("stop must exist");
let stop = &stop[..stop.len().min(700)];
assert!(
stop.contains("publish_state(false, None);"),
"stop must publish, not only notify a handle it may not have been given"
);
}
#[test]
fn a_failed_refresh_does_not_spend_the_one_shot() {
// `refreshed = true` before the attempt meant a refresh that FAILED, a
// blip at the wrong moment, permanently disarmed the retry, because the
// reset needs the relay's `hello` and it will never greet a credential it
// keeps refusing. The bridge then sat on a dead token until restart.
let source = include_str!("mcp_remote.rs");
let block = source
.split("if should_refresh_credential(error, refreshed) {")
.nth(1)
.expect("the refresh block must exist");
let block = &block[..block.len().min(900)];
let ok_arm = block.find("Ok(()) =>").expect("an Ok arm");
let spend = block
.find("refreshed = true;")
.expect("the one-shot must be spent somewhere");
assert!(
spend > ok_arm,
"the one-shot may only be spent AFTER a refresh succeeds, not before the attempt"
);
let err_arm = block.find("Err(e) =>").expect("an Err arm");
assert!(spend < err_arm, "the spend belongs inside the success arm");
}
#[test]
fn backoff_resets_only_after_a_connection_that_actually_held() {
// The `hello` arrives as soon as the socket is registered, so a connection
// that lived milliseconds greeted exactly like one that lived a day.
// Resetting `attempt` on the greeting alone turned any non-terminal close,
// an idle 1011 for instance, into a 1 Hz retry loop that never backed off.
assert!(
MIN_UPTIME_FOR_BACKOFF_RESET >= Duration::from_secs(30),
"a threshold this short would not outlast a greet-then-close cycle"
);
assert!(
MIN_UPTIME_FOR_BACKOFF_RESET < IDLE_TIMEOUT,
"must be under the idle reap, or a healthy but quiet bridge never resets"
);
let source = include_str!("mcp_remote.rs");
let block = source
.split("if greeted.load(Ordering::SeqCst) {")
.nth(1)
.expect("the greeting block must exist");
let block = &block[..block.len().min(900)];
assert!(
block.contains("if lasted >= MIN_UPTIME_FOR_BACKOFF_RESET {"),
"the backoff reset must be gated on real uptime, not on the greeting alone"
);
let reset = block.find("attempt = 0;").expect("the reset must exist");
let gate = block
.find("if lasted >= MIN_UPTIME_FOR_BACKOFF_RESET {")
.expect("the gate must exist");
assert!(reset > gate, "the reset must sit inside the uptime gate");
}
#[tokio::test]
async fn only_a_greeting_re_arms_the_credential_refresh() {
// The upgrade completes BEFORE the credential is judged, so a completed
// handshake is no evidence it was accepted, `hello` is the first frame that
// follows that decision. Resetting the one-refresh bound on the handshake
// made the bound inert for the only path that produces a credential refusal
// (a close frame), so a desktop that keeps being refused rotated its
// refresh token on every cycle instead of exactly once.
let greeted = Arc::new(AtomicBool::new(false));
// Refused before any greeting: the flag stays clear, so `run` keeps
// `refreshed` set and does not refresh a second time.
let (url, server) = fake_relay(vec![], 0).await;
let stream = dial_fake(&url).await;
let _ = pump(stream, Arc::clone(&greeted)).await;
let _ = server.await;
assert!(
!greeted.load(Ordering::SeqCst),
"a bare handshake must not count as the credential being accepted"
);
// Greeted: the credential was accepted, so a later refusal may refresh.
let (url, server) = fake_relay(
vec![serde_json::json!({ "t": "hello", "protocol": BRIDGE_PROTOCOL })],
0,
)
.await;
let stream = dial_fake(&url).await;
let _ = pump(stream, Arc::clone(&greeted)).await;
let _ = server.await;
assert!(
greeted.load(Ordering::SeqCst),
"the relay's hello is what proves the credential was accepted"
);
}
#[test]
fn nothing_announces_a_connection_from_a_task_of_its_own() {
// The connection used to be announced by a task parked on a `Notify`, and
// that shape was wrong in two independent ways.
//
// `stop` could not reach it. BRIDGE_TASK holds only the reconnect loop's
// handle, and dropping a JoinHandle DETACHES rather than aborts, so a stop
// between dialling and greeting left the announcer waiting on a signal
// whose only other holder had just been dropped, it could never be woken
// and never finished. One leaked task per stop-during-connect, for the life
// of the process.
//
// And `abort` could not cancel it once it mattered. The task's only await
// was the wait for the greeting, so once that fired it ran straight through
// to publishing; on a greet-then-close socket the announcement could land
// after the teardown, leaving the screen reading connected with no error
// and nothing to correct it until the next dial, up to a minute later in
// the terminal backoff band.
//
// Publishing on the read loop's own task removes both, so the property to
// hold is that no announcer is ever spawned again.
let source = include_str!("mcp_remote.rs");
let production = source
.split_once("\n#[cfg(test)]")
.map_or(source, |(code, _)| code);
assert!(
!production.contains("notified().await"),
"the connection must not be announced from a task waiting on a signal: \
stop cannot reach such a task and abort cannot cancel it once the \
signal has fired"
);
assert!(
!production.contains("Notify::new()"),
"the greeting signal is gone; a new one would reintroduce the detached \
announcer it existed to feed"
);
// The residual window is closed by an invariant rather than by ordering:
// cancellation only takes effect at an await point, so the read loop can be
// part-way through a frame when a stop lands. Asserted from the source
// because BRIDGE_RUNNING and BRIDGE_CONNECTED are process-wide and cargo
// runs these tests on parallel threads, a test that drove them would race
// every sibling that publishes state.
let publish = production
.split("fn publish_state(")
.nth(1)
.expect("publish_state must exist");
let body = &publish[..publish.find("\n}").unwrap_or(publish.len())];
assert!(
body.contains("connected && BRIDGE_RUNNING.load(Ordering::SeqCst)"),
"a bridge that is not running must never be published as connected"
);
}
#[test]
fn the_in_flight_budget_outlives_the_socket_that_spent_it() {
// A socket teardown deliberately abandons in-flight calls rather than
// aborting them, half of them are launching browsers. Those tasks keep
// their permits, so a per-socket semaphore handed each reconnect a fresh
// full budget and the cap stopped bounding anything across a flapping
// connection. The budget therefore belongs to the bridge, not the socket.
let source = include_str!("mcp_remote.rs");
let pump = source
.split("async fn pump(")
.nth(1)
.expect("pump must exist");
let body = &pump[..pump.len().min(1200)];
assert!(
!body.contains("Semaphore::new("),
"pump must not mint its own budget; abandoned calls would keep permits \
from a semaphore nobody is counting any more"
);
assert!(
body.contains("Arc::clone(&BRIDGE_PERMITS)"),
"pump must share the bridge-lifetime budget"
);
// And nowhere else mints one either. Scanning the whole of the production
// half is the part that generalises: the check above only reads pump's
// first 1200 characters, so a budget re-minted in any other helper on the
// relayed-call path would pass it while re-creating the exact bug.
//
// This replaced a runtime check that counted permits on BRIDGE_PERMITS,
// which was wrong twice over. It was VACUOUS, `first` and `second` were
// two clones of one static, so comparing their counts compared an object
// with itself and held whatever the code did. And it was RACY: a dozen
// tests drive pump, whose relayed calls take global permits on spawned
// tasks that outlive the test that started them, so a sibling holding one
// made `assert_eq!(available, MAX_IN_FLIGHT)` fail intermittently. It went
// green six runs in a row before failing, which is precisely why a count
// over shared mutable state is not evidence of anything here.
let production = source
.split_once("\n#[cfg(test)]")
.map_or(source, |(before, _)| before);
// Each mint is reported with the declaration it belongs to, so a failure
// names the offending site instead of just a count.
let mints: Vec<&str> = production
.match_indices("Semaphore::new(")
.map(|(at, _)| {
let stmt = production[..at].rfind(';').map_or(0, |n| n + 1);
production[stmt..at].trim()
})
.collect();
assert_eq!(
mints.len(),
1,
"the bridge budget must be minted exactly once for the process; \
minted by: {mints:?}"
);
assert!(
mints[0].contains("BRIDGE_PERMITS"),
"the one Semaphore must be the BRIDGE_PERMITS static, not {:?}",
mints[0]
);
}
#[test]
fn every_reason_the_relay_can_close_with_is_classified_correctly() {
// These are the literal close reasons the bridge endpoint sends.
// Classifying them is not cosmetic: each one produces a different sentence
// on the Integrations page telling the customer what to do about it.
for (reason, expect_slot, expect_plan) in [
(
"another Donut instance on this account holds the remote-control slot",
true,
false,
),
("remote control is not included in this plan", false, true),
("this plan no longer includes remote control", false, true),
] {
let error = classify_close(1008, reason);
assert_eq!(
matches!(error, BridgeError::SlotTaken(_)),
expect_slot,
"slot classification wrong for {reason:?}"
);
assert_eq!(
matches!(error, BridgeError::NotEntitled(_)),
expect_plan,
"plan classification wrong for {reason:?}"
);
}
// The malformed-header refusal shares the word "instance" with the slot
// conflict and means something entirely different. Read as a conflict it
// told the customer to close a second Donut that was not running.
assert!(
!matches!(
classify_close(1008, "an instance id is required"),
BridgeError::SlotTaken(_)
),
"a malformed instance id is not a second copy of Donut"
);
for reason in [
"unauthorized",
"credential no longer valid",
"credential revoked",
] {
assert!(
matches!(classify_close(1008, reason), BridgeError::Unauthorized(_)),
"{reason:?} should read as a credential problem"
);
}
}
#[test]
fn a_malformed_persisted_instance_id_is_not_sent_to_be_rejected() {
// An instance id outside the accepted shape is refused at the upgrade.
// Sending a value that cannot pass wastes a dial and produces a refusal the
// desktop can only guess at.
assert!(is_valid_instance_id(&uuid::Uuid::new_v4().to_string()));
assert!(is_valid_instance_id("abcd1234"));
assert!(is_valid_instance_id(&"a".repeat(64)));
assert!(!is_valid_instance_id(""), "empty");
assert!(!is_valid_instance_id("short7"), "under 8 characters");
assert!(!is_valid_instance_id(&"a".repeat(65)), "over 64 characters");
assert!(!is_valid_instance_id("has spaces here"), "space");
assert!(!is_valid_instance_id("has_underscore1"), "underscore");
assert!(!is_valid_instance_id("café-instance"), "non-ascii");
assert!(!is_valid_instance_id("newline\n1234"), "control character");
}
#[test]
fn a_malformed_id_on_disk_is_replaced_rather_than_reused() {
// Exercises the real read-and-decide path, not just the shape helper: an
// earlier version of this test asserted only `is_valid_instance_id`, so
// deleting its call site from the reader changed nothing and the test
// still passed.
let dir = std::env::temp_dir().join(format!("donut-iid-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("mcp_instance_id");
// A good id is kept exactly as written.
let good = uuid::Uuid::new_v4().to_string();
std::fs::write(&path, &good).expect("write");
assert_eq!(read_or_create_instance_id(&path), good);
// A malformed one is replaced, persisted, and stable from then on.
std::fs::write(&path, "not a valid id!").expect("write");
let replaced = read_or_create_instance_id(&path);
assert!(is_valid_instance_id(&replaced), "{replaced:?}");
assert_eq!(
std::fs::read_to_string(&path).expect("read").trim(),
replaced,
"the replacement must be written back, or every launch regenerates"
);
assert_eq!(read_or_create_instance_id(&path), replaced);
// A missing file mints one.
std::fs::remove_file(&path).expect("remove");
assert!(is_valid_instance_id(&read_or_create_instance_id(&path)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn being_evicted_from_the_slot_backs_off_instead_of_racing() {
// A superseded socket is retired with 1000 and this exact reason. Two
// installs sharing a copied data directory carry the same instance id,
// so each reads as the other reconnecting and they evict each other; read
// as a plain close this reconnected immediately and flapped forever.
let evicted = classify_close(1000, "replaced by a newer connection");
assert!(matches!(evicted, BridgeError::SlotTaken(_)));
assert!(
evicted.is_terminal_refusal(),
"an evicted desktop must land in the terminal backoff band, not retry at once"
);
// A normal close still is one; only the replacement reason is special.
assert!(matches!(
classify_close(1000, ""),
BridgeError::Unreachable(_)
));
assert!(matches!(
classify_close(1000, "going away"),
BridgeError::Unreachable(_)
));
}
#[test]
fn the_status_carries_a_translatable_code_never_server_authored_prose() {
// The Integrations page ships in ten languages, and these reasons are
// English, some of them written by the relay and echoed in a close frame.
// Rendering one verbatim puts an English sentence under a Japanese UI, so
// the status carries a code and the words are chosen in the locale files.
// Every code here has a `backendErrors.*` key in all ten of them and a
// `case` in src/lib/backend-errors.ts.
let cases = [
(
BridgeError::Unauthorized("the bridge refused the credential".into()),
"MCP_REMOTE_UNAUTHORIZED",
),
(
BridgeError::SlotTaken("another Donut instance holds the slot".into()),
"MCP_REMOTE_SLOT_TAKEN",
),
(
BridgeError::NotEntitled("this plan does not include it".into()),
"MCP_REMOTE_NOT_ENTITLED",
),
(
BridgeError::Unreachable("dns failure".into()),
"MCP_REMOTE_UNREACHABLE",
),
];
let switch = std::fs::read_to_string("../src/lib/backend-errors.ts")
.expect("the frontend error translator must exist");
for (error, code) in cases {
assert_eq!(error.code(), code);
let envelope = crate::backend_error(error.code());
// Exactly the shape `translateBackendError` parses.
let parsed: serde_json::Value =
serde_json::from_str(&envelope).expect("the status must be a JSON envelope");
assert_eq!(parsed["code"], code);
// And the prose is nowhere near it.
assert!(!envelope.contains("bridge refused"));
assert!(
switch.contains(&format!("case \"{code}\":")),
"{code} has no case in translateBackendError, so it would render raw"
);
}
}
#[test]
fn an_expired_token_is_refreshed_and_retried_at_once() {
// The one "unauthorized" that fixes itself. An access token eventually
// expires, so every long-running desktop hits this once per token: the
// socket is closed with 1008, and without this the desktop drops into the
// terminal backoff band redialling the SAME dead token, showing "sign out
// and sign in again" for a condition that needed neither.
//
// This lives here rather than at the dial because the credential is judged
// AFTER the handshake, so a refused one is never an HTTP 401 the dial could
// see.
assert!(should_refresh_credential(
&BridgeError::Unauthorized("credential no longer valid".into()),
false
));
// Once per credential. A token the server genuinely rejects must not send
// the desktop round a refresh loop.
assert!(!should_refresh_credential(
&BridgeError::Unauthorized("credential revoked".into()),
true
));
// Nothing else is fixed by a new token, and retrying these fast is how a
// background task becomes a battery bug.
for error in [
BridgeError::SlotTaken("another instance".into()),
BridgeError::NotEntitled("not on this plan".into()),
BridgeError::Unreachable("dns".into()),
] {
assert!(
!should_refresh_credential(&error, false),
"{error:?} is not something a token refresh fixes"
);
}
}
#[test]
fn a_refusal_of_a_token_that_was_already_replaced_redials_instead_of_refreshing() {
// The decision itself.
assert!(credential_rotated_since(Some("old"), Some("new")));
assert!(!credential_rotated_since(Some("same"), Some("same")));
// Nothing to compare: the dial failed before reading a credential, or the
// account signed out meanwhile. Neither is a rotation.
assert!(!credential_rotated_since(None, Some("new")));
assert!(!credential_rotated_since(Some("old"), None));
assert!(!credential_rotated_since(None, None));
// And it sits AHEAD of the refresh in `run`, guarded on Unauthorized: a
// slot conflict on a rotated token is still a slot conflict.
let source = include_str!("mcp_remote.rs");
let production = source
.split_once("\n#[cfg(test)]")
.map_or(source, |(code, _)| code);
let run = production
.split("async fn run() {")
.nth(1)
.expect("run must exist");
let rotated = run
.find("credential_rotated_since(")
.expect("run must compare the refused token with the one on disk");
let refresh = run
.find("should_refresh_credential(error, refreshed)")
.expect("run must still refresh");
assert!(
rotated < refresh,
"the rotation check must come first, or the refresh is spent on a token that is already gone"
);
let guard = &run[..rotated];
assert!(
guard
.rfind("matches!(error, BridgeError::Unauthorized(_))")
.is_some_and(|at| at > guard.len().saturating_sub(200)),
"the redial must be limited to an Unauthorized refusal"
);
// The token is remembered from the dial that produced the socket, and
// forgotten when the dial fails before reading one.
assert!(run.contains("(outcome.err(), Some(token))"));
assert!(run.contains("(Some(e), None)"));
}
#[test]
fn start_and_stop_change_the_flag_and_the_handle_under_one_lock() {
// Flag first and handle second, with no lock across them, let a `stop`
// between the two see the flag, find no handle, and return, after which
// the start stored a handle for a loop nobody could stop any more.
let source = include_str!("mcp_remote.rs");
let production = source
.split_once("\n#[cfg(test)]")
.map_or(source, |(code, _)| code);
for (name, header) in [
("start", "pub fn start(app: AppHandle) {"),
("stop", "pub fn stop(app: Option<&AppHandle>) {"),
] {
let body = production
.split(header)
.nth(1)
.unwrap_or_else(|| panic!("{name} must exist"));
let body = &body[..body.find("\n}").unwrap_or(body.len())];
let lock = body
.find("BRIDGE_TASK\n .lock()")
.unwrap_or_else(|| panic!("{name} must take the task lock"));
let flip = body
.find("BRIDGE_RUNNING.swap(")
.unwrap_or_else(|| panic!("{name} must flip the running flag"));
assert!(
lock < flip,
"{name} must hold the task lock before it flips the running flag"
);
assert_eq!(
body.matches("BRIDGE_TASK").count(),
1,
"{name} must touch the slot once, under the lock it already holds"
);
}
}
#[test]
fn the_dial_no_longer_pretends_to_recover_a_credential_it_never_sees() {
// Guards the reason the refresh moved. `connect` must not grow a
// refresh-and-retry again: the upgrade completes before the credential is
// judged, so `dial` cannot observe a rejected one, and a retry there would
// be dead code that reads like a safety net.
let source = std::fs::read_to_string("src/mcp_remote.rs").expect("mcp_remote.rs");
let start = source
.find("async fn connect()")
.expect("connect must exist");
let body = &source[start..start + 400];
assert!(
!body.contains("refresh_access_token"),
"connect() must not refresh; the relay's refusals arrive as close frames, not dial errors"
);
}
#[test]
fn every_refusal_the_server_will_repeat_backs_off_slowly() {
assert!(BridgeError::Unauthorized(String::new()).is_terminal_refusal());
assert!(BridgeError::SlotTaken(String::new()).is_terminal_refusal());
assert!(BridgeError::NotEntitled(String::new()).is_terminal_refusal());
assert!(!BridgeError::Unreachable(String::new()).is_terminal_refusal());
}
#[test]
fn rpc_frames_decode_with_and_without_a_session() {
let with = parse_frame(
r#"{"t":"rpc","cid":"c1","sessionId":"s1","payload":{"jsonrpc":"2.0","id":1,"method":"ping"}}"#,
)
.expect("frame");
match with {
BridgeFrame::Rpc {
cid,
session_id,
payload,
} => {
assert_eq!(cid, "c1");
assert_eq!(session_id.as_deref(), Some("s1"));
let parsed: serde_json::Value = serde_json::from_slice(&payload).expect("payload");
assert_eq!(parsed["method"], "ping");
}
_ => panic!("expected an rpc frame"),
}
let without =
parse_frame(r#"{"t":"rpc","cid":"c2","payload":{"jsonrpc":"2.0","id":2,"method":"ping"}}"#)
.expect("frame");
match without {
BridgeFrame::Rpc { session_id, .. } => assert!(session_id.is_none()),
_ => panic!("expected an rpc frame"),
}
}
#[test]
fn a_frame_missing_the_field_that_makes_it_answerable_is_refused() {
// A relayed call is correlated ONLY by `cid`. A frame without one cannot be
// answered, the reply would carry an empty correlation id, the relay would
// match it to nothing, and the caller would sit until its 90s timeout with
// no idea why. Rejecting it here at least leaves the socket healthy.
assert!(parse_frame(r#"{"t":"rpc","payload":{"jsonrpc":"2.0","id":1}}"#).is_none());
assert!(parse_frame(r#"{"t":"rpc","cid":"","payload":{"jsonrpc":"2.0"}}"#).is_none());
// Same for the one field `endSession` exists to carry. Accepting it empty
// would make the desktop forget the session named "", which is every
// session it does not have, a silent no-op the relay reads as success.
assert!(parse_frame(r#"{"t":"endSession"}"#).is_none());
// The empty case specifically: `?` already rejects a MISSING field, so
// without this line the filter that rejects an empty one is unguarded.
assert!(parse_frame(r#"{"t":"endSession","sessionId":""}"#).is_none());
assert!(parse_frame(r#"{"t":"endSession","sessionId":"s1"}"#).is_some());
}
#[tokio::test]
async fn a_relay_that_outruns_the_in_flight_cap_is_told_to_retry() {
// The cap is what stops a bug on either side spawning unbounded work inside
// a customer's app. Past it the desktop must ANSWER `busy`, a caller that
// gets no frame at all waits out the call timeout upstream and then sees
// the same refusal it could have had immediately.
let (tx, mut rx) = mpsc::channel::<Message>(4);
let permits = Arc::new(Semaphore::new(MAX_IN_FLIGHT));
let held: Vec<_> = (0..MAX_IN_FLIGHT)
.map(|_| {
Arc::clone(&permits)
.try_acquire_owned()
.expect("a free permit")
})
.collect();
dispatch(
BridgeFrame::Rpc {
cid: "cid-over-cap".to_string(),
session_id: None,
payload: br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#.to_vec(),
},
&tx,
&permits,
&AtomicBool::new(false),
);
let Message::Text(text) = rx.try_recv().expect("a refusal, not silence") else {
panic!("expected a text frame");
};
let frame: serde_json::Value = serde_json::from_str(&text).expect("json");
assert_eq!(frame["status"], "busy");
assert_eq!(frame["cid"], "cid-over-cap");
drop(held);
}
#[test]
fn an_unknown_frame_is_ignored_rather_than_fatal() {
assert!(parse_frame(r#"{"t":"somethingNewer","x":1}"#).is_none());
assert!(parse_frame("not json").is_none());
assert!(parse_frame(r#"{"t":"rpc","cid":"c1"}"#).is_none());
}
#[test]
fn result_frames_carry_the_outcome_the_relay_has_to_render() {
let ok = result_frame(
"c1",
McpOutcome::Body {
body: serde_json::json!({"jsonrpc":"2.0","id":1,"result":{}}),
new_session_id: Some("s9".to_string()),
},
);
assert_eq!(ok["status"], "ok");
assert_eq!(ok["sessionId"], "s9");
assert_eq!(ok["payload"]["id"], 1);
assert_eq!(
result_frame("c2", McpOutcome::Accepted)["status"],
"accepted"
);
assert_eq!(
result_frame("c3", McpOutcome::UnknownSession)["status"],
"unknownSession"
);
assert_eq!(
result_frame("c4", McpOutcome::BadRequest)["status"],
"badRequest"
);
let limited = result_frame(
"c5",
McpOutcome::RateLimited {
retry_after_secs: 42,
},
);
assert_eq!(limited["status"], "rateLimited");
assert_eq!(limited["retryAfter"], 42);
}
#[test]
fn an_oversized_result_is_refused_rather_than_sent() {
let huge = "x".repeat(MAX_RESULT_BYTES + 1);
let frame = result_frame(
"c1",
McpOutcome::Body {
body: serde_json::json!({ "text": huge }),
new_session_id: None,
},
);
assert_eq!(frame["status"], "tooLarge");
}
#[test]
fn the_instance_id_is_stable_within_a_process() {
// Against a temp path, not `instance_id()`. That reads
// `app_dirs::settings_dir()`, which `cargo test` does not redirect, so the
// old version of this test MINTED AND WROTE an id into the developer's
// real DonutBrowserDev data directory every time the suite ran, a test
// reaching outside its sandbox to touch state the app itself owns.
let dir = std::env::temp_dir().join(format!("donut-iid-stable-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("mcp_instance_id");
let first = read_or_create_instance_id(&path);
assert!(!first.is_empty());
assert!(is_valid_instance_id(&first));
// Stability is the property that matters: the slot is granted per instance
// id, so one that changed between reads could never reclaim it.
assert_eq!(read_or_create_instance_id(&path), first);
assert_eq!(read_or_create_instance_id(&path), first);
let _ = std::fs::remove_dir_all(&dir);
}
// ---------------------------------------------------------------------
// The transport, end to end.
//
// Everything above tests one function against a literal. These drive the
// REAL `pump` over a REAL WebSocket against a server that speaks the frames
// the bridge endpoint actually sends, and assert the frames it actually
// parses come back. That join is the part neither side's own tests can
// cover: each was written against its own idea of the contract.
//
// The canonical frames below match the wire form observed from the endpoint:
// a null `sessionId` stays on the wire rather than being omitted, so that is
// what is sent here, a parser that only handled the absent case would pass a
// friendlier fixture and fail in production.
// ---------------------------------------------------------------------
/// A server that speaks the relay's half of `donut-mcp-bridge/1`.
///
/// Returns the URL to dial and a handle yielding every `result` frame the
/// desktop sent back, in order.
async fn fake_relay(
script: Vec<serde_json::Value>,
expected_results: usize,
) -> (String, tokio::task::JoinHandle<Vec<serde_json::Value>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("the fake relay must bind");
let port = listener.local_addr().expect("a bound port").port();
let handle = tokio::spawn(async move {
let mut results = Vec::new();
let Ok((socket, _)) = listener.accept().await else {
return results;
};
let Ok(mut stream) = tokio_tungstenite::accept_async(socket).await else {
return results;
};
for frame in script {
if stream
.send(Message::Text(frame.to_string().into()))
.await
.is_err()
{
return results;
}
}
while results.len() < expected_results {
match stream.next().await {
Some(Ok(Message::Text(text))) => {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
results.push(value);
}
}
Some(Ok(_)) => continue,
_ => break,
}
}
let _ = stream.close(None).await;
results
});
(format!("ws://127.0.0.1:{port}"), handle)
}
async fn dial_fake(url: &str) -> BridgeStream {
let (stream, _) = tokio_tungstenite::connect_async(url)
.await
.expect("the desktop must reach the fake relay");
stream
}
#[tokio::test]
async fn a_relayed_tools_list_reaches_the_engine_and_the_answer_comes_back() {
McpServer::instance().mark_engine_ready_for_tests();
let (url, server) = fake_relay(
vec![
serde_json::json!({
"t": "hello",
"protocol": BRIDGE_PROTOCOL,
"instanceId": "instance-under-test",
}),
// Exactly what the relay puts on the wire, null sessionId and all.
serde_json::json!({
"t": "rpc",
"cid": "cid-1",
"sessionId": serde_json::Value::Null,
"payload": { "jsonrpc": "2.0", "id": 7, "method": "tools/list" },
}),
],
1,
)
.await;
let stream = dial_fake(&url).await;
let _ = pump(stream, Arc::new(AtomicBool::new(false))).await;
let results = server.await.expect("the fake relay must finish");
assert_eq!(results.len(), 1, "one result per rpc");
let frame = &results[0];
assert_eq!(frame["t"], "result");
assert_eq!(frame["cid"], "cid-1");
assert_eq!(frame["status"], "ok");
assert!(frame["sessionId"].is_null());
// The real tool list, carried whole. This is the assertion that proves the
// bridge is a transport and not a second implementation: the relay adds no
// schema of its own, so whatever this build exposes is what an agent sees.
let payload = &frame["payload"];
assert_eq!(payload["jsonrpc"], "2.0");
assert_eq!(payload["id"], 7);
let tools = payload["result"]["tools"]
.as_array()
.expect("tools/list must return an array");
assert_eq!(tools.len(), McpServer::instance().get_tools().len());
assert!(tools.iter().any(|tool| tool["name"] == "navigate"));
assert!(tools
.iter()
.all(|tool| tool["inputSchema"].is_object() && tool["name"].is_string()));
}
#[tokio::test]
async fn initialize_mints_a_session_the_relay_can_echo_back() {
McpServer::instance().mark_engine_ready_for_tests();
let (url, server) = fake_relay(
vec![
serde_json::json!({ "t": "hello", "protocol": BRIDGE_PROTOCOL, "instanceId": "i" }),
serde_json::json!({
"t": "rpc",
"cid": "cid-init",
"sessionId": serde_json::Value::Null,
"payload": {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": { "protocolVersion": "2025-11-25" },
},
}),
],
1,
)
.await;
let stream = dial_fake(&url).await;
let _ = pump(stream, Arc::new(AtomicBool::new(false))).await;
let results = server.await.expect("the fake relay must finish");
let frame = &results[0];
assert_eq!(frame["status"], "ok");
// The relay turns this into the `mcp-session-id` response header, which is
// the whole reason it travels beside the payload rather than inside it.
let session = frame["sessionId"]
.as_str()
.expect("initialize must mint a session id");
assert!(!session.is_empty());
assert_eq!(frame["payload"]["result"]["protocolVersion"], "2025-11-25");
}
#[tokio::test]
async fn a_notification_is_accepted_with_no_reply_body() {
McpServer::instance().mark_engine_ready_for_tests();
let (url, server) = fake_relay(
vec![serde_json::json!({
"t": "rpc",
"cid": "cid-note",
"sessionId": "session-that-does-not-exist",
"payload": { "jsonrpc": "2.0", "method": "notifications/initialized" },
})],
1,
)
.await;
let stream = dial_fake(&url).await;
let _ = pump(stream, Arc::new(AtomicBool::new(false))).await;
let results = server.await.expect("the fake relay must finish");
// 202 over HTTP, `accepted` here. JSON-RPC forbids replying to a
// notification, and the session check must not turn one into a 404.
assert_eq!(results[0]["status"], "accepted");
assert!(results[0].get("payload").is_none());
}
#[tokio::test]
async fn an_unknown_session_is_reported_as_such_rather_than_answered() {
McpServer::instance().mark_engine_ready_for_tests();
let (url, server) = fake_relay(
vec![serde_json::json!({
"t": "rpc",
"cid": "cid-ghost",
"sessionId": "00000000-0000-4000-8000-000000000000",
"payload": { "jsonrpc": "2.0", "id": 3, "method": "tools/list" },
})],
1,
)
.await;
let stream = dial_fake(&url).await;
let _ = pump(stream, Arc::new(AtomicBool::new(false))).await;
let results = server.await.expect("the fake relay must finish");
// The relay renders this as 404, the streamable HTTP answer to an unknown
// session, so an agent's own session handling works unchanged.
assert_eq!(results[0]["status"], "unknownSession");
}
#[tokio::test]
async fn a_malformed_payload_is_refused_without_dropping_the_socket() {
McpServer::instance().mark_engine_ready_for_tests();
let (url, server) = fake_relay(
vec![
serde_json::json!({
"t": "rpc",
"cid": "cid-bad",
"sessionId": serde_json::Value::Null,
"payload": { "not": "json-rpc" },
}),
// Sent after the bad one: the socket has to survive it, or one
// malformed call from a buggy client takes the whole bridge down.
serde_json::json!({
"t": "rpc",
"cid": "cid-good",
"sessionId": serde_json::Value::Null,
"payload": { "jsonrpc": "2.0", "id": 9, "method": "ping" },
}),
],
2,
)
.await;
let stream = dial_fake(&url).await;
let _ = pump(stream, Arc::new(AtomicBool::new(false))).await;
let results = server.await.expect("the fake relay must finish");
let by_cid = |cid: &str| {
results
.iter()
.find(|frame| frame["cid"] == cid)
.unwrap_or_else(|| panic!("no result for {cid}"))
};
assert_eq!(by_cid("cid-bad")["status"], "badRequest");
assert_eq!(by_cid("cid-good")["status"], "ok");
}
#[tokio::test]
async fn a_frame_this_build_does_not_know_is_ignored_rather_than_fatal() {
McpServer::instance().mark_engine_ready_for_tests();
let (url, server) = fake_relay(
vec![
// A newer server's frame. Dropping it must not cost the connection, or
// a server-side rollout would take every desktop offline.
serde_json::json!({ "t": "somethingNewer", "data": 1 }),
serde_json::json!({
"t": "rpc",
"cid": "cid-after",
"sessionId": serde_json::Value::Null,
"payload": { "jsonrpc": "2.0", "id": 1, "method": "ping" },
}),
],
1,
)
.await;
let stream = dial_fake(&url).await;
let _ = pump(stream, Arc::new(AtomicBool::new(false))).await;
let results = server.await.expect("the fake relay must finish");
assert_eq!(results.len(), 1);
assert_eq!(results[0]["cid"], "cid-after");
assert_eq!(results[0]["status"], "ok");
}
#[tokio::test]
async fn the_desktop_answers_the_relays_keepalive_ping() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let port = listener.local_addr().expect("port").port();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.expect("accept");
let mut stream = tokio_tungstenite::accept_async(socket)
.await
.expect("handshake");
stream
.send(Message::Ping(b"keepalive".to_vec().into()))
.await
.expect("ping");
// An idle WebSocket is closed upstream, so the pong is what keeps the
// bridge alive. A desktop that never answers is dropped and reconnects
// forever.
let mut pong = None;
while let Some(Ok(message)) = stream.next().await {
if let Message::Pong(payload) = message {
pong = Some(payload.to_vec());
break;
}
}
let _ = stream.close(None).await;
pong
});
let stream = dial_fake(&format!("ws://127.0.0.1:{port}")).await;
let _ = pump(stream, Arc::new(AtomicBool::new(false))).await;
assert_eq!(
server.await.expect("the fake relay must finish"),
Some(b"keepalive".to_vec())
);
}
#[tokio::test]
async fn a_writer_held_open_by_a_stuck_call_is_abandoned_rather_than_waited_on() {
// The scenario, reduced to its mechanism: `dispatch` clones the outbound
// sender into every spawned tool call, so `drop(tx)` in `pump` closes
// nothing while one is still running. A plain `writer.await` then waits for
// the slowest tool call, and the socket it would write to is already gone.
//
// Unbounded, that pins `pump` forever: the idle timeout fires, `run` never
// reaches its reconnect sleep, BRIDGE_CONNECTED stays true, and the
// Integrations page reports a healthy bridge over a dead socket until the
// app restarts. Exactly the failure the idle timeout exists to catch.
let (tx, mut rx) = mpsc::channel::<Message>(4);
let held = tx.clone(); // stands in for an in-flight tool call
let writer = tokio::spawn(async move { while rx.recv().await.is_some() {} });
drop(tx);
let budget = Duration::from_millis(50);
let started = tokio::time::Instant::now();
// Bounded from the outside too: an unbounded drain does not return a wrong
// answer, it never returns at all, and a bare `.await` here would hang the
// whole test binary instead of reporting the regression.
tokio::time::timeout(budget * 8, drain_writer(writer, budget))
.await
.expect("a stuck call must not be able to hold the reconnect loop open");
let waited = started.elapsed();
assert!(waited >= budget, "the budget must actually be honoured");
// The stand-in outlives the drain, which is the point: the call is left to
// finish into a closed channel rather than aborted mid-browser-launch.
drop(held);
}
#[test]
fn stopping_a_bridge_that_is_not_running_returns_before_it_publishes() {
// Called from logout and from app exit, both of which run whether or not
// remote control was ever switched on.
//
// This used to assert `!is_running()`, call `stop(None)` twice, and assert
// it again. Both globals default to false, so every assertion already held
// before a single line of `stop` ran, the test could only have failed if a
// sibling on another of cargo's threads had flipped them, which is the one
// thing it was not testing. The property that actually matters is that the
// guard sits AHEAD of the publish, so a stop on a bridge nobody started
// cannot emit a state change to a screen that never showed one.
let source = include_str!("mcp_remote.rs");
let production = source
.split_once("\n#[cfg(test)]")
.map_or(source, |(code, _)| code);
let stop_fn = production
.split("pub fn stop(app: Option<&AppHandle>) {")
.nth(1)
.expect("stop must exist");
let body = &stop_fn[..stop_fn.find("\n}").unwrap_or(stop_fn.len())];
let guard = body
.find("if !BRIDGE_RUNNING.swap(false, Ordering::SeqCst) {")
.expect("stop must be guarded by the running flag");
let publish = body
.find("publish_state(")
.expect("stop must publish the new state");
assert!(
guard < publish,
"the running guard must return before anything is published, or every \
logout and every app exit emits a bridge state change for a bridge that \
was never switched on"
);
// And it really is safe to call when nothing is running.
stop(None);
stop(None);
}
#[test]
fn every_path_that_closes_the_bridge_has_one_that_can_reopen_it() {
// A source assertion, because this is a WHOLE-LIFECYCLE property and no
// single function can hold it. It exists because the obvious version of the
// startup gate got it wrong: adding a signed-in check at boot, with nothing
// on the sign-in path, left "sign out, sign back in" showing remote control
// switched ON in Settings while the bridge was dead until a restart. The UI
// said yes and the account page said no desktop was connected.
//
// The rule this pins: the bridge is stopped on logout and on exit, so it
// must be (re)opened on sign-in and on a periodic tick, not only at boot.
let cloud_auth = std::fs::read_to_string("src/cloud_auth.rs").expect("cloud_auth.rs");
let lib = std::fs::read_to_string("src/lib.rs").expect("lib.rs");
// BOTH teardown paths in cloud_auth, not just one. The original assertion
// used `contains`, which logout alone satisfied, so `invalidate_session`,
// the AUTOMATIC twin reached when the background refresh loop gives up,
// silently left the bridge dialling a relay with a credential that no
// longer existed, for ever, backing off into an "unauthorized" shown to
// somebody whose session had merely expired.
assert_eq!(
cloud_auth.matches("crate::mcp_remote::stop(None);").count(),
2,
"both logout AND invalidate_session must hang up the bridge before \
deleting the credential it was using"
);
for owner in ["pub async fn invalidate_session", "pub async fn logout"] {
let body = cloud_auth
.split(owner)
.nth(1)
.unwrap_or_else(|| panic!("{owner} not found in cloud_auth.rs"));
let body = &body[..body.len().min(1200)];
assert!(
body.contains("crate::mcp_remote::stop(None);"),
"{owner} clears the credential, so it must close the bridge too"
);
}
assert!(
lib.contains("mcp_remote::stop(None);"),
"exiting must release the account's single bridge slot rather than let the server reap it"
);
// Three reopen paths, and each is load-bearing: boot restores it, sign-in
// covers the logout/login round trip, and the tick catches every other way
// the setting and the socket can drift apart.
assert_eq!(
cloud_auth
.matches("ensure_remote_bridge(&app_handle).await;")
.count(),
2,
"the sign-in path and the periodic reconnect tick must both reopen the bridge"
);
assert!(
lib.contains("cloud_auth::ensure_remote_bridge(&bridge_handle).await;"),
"startup must reopen a bridge the user had switched on"
);
}
#[test]
fn the_bridge_accepts_the_engine_body_size() {
// The frame budget must match the limit the remote endpoint accepts.
assert_eq!(McpServer::MAX_MESSAGE_BYTES, 1024 * 1024);
}
#[tokio::test]
async fn a_writer_with_nothing_left_to_send_is_joined_immediately() {
let (tx, mut rx) = mpsc::channel::<Message>(4);
let writer = tokio::spawn(async move { while rx.recv().await.is_some() {} });
drop(tx);
let started = tokio::time::Instant::now();
// The common case: no call in flight, so the channel really does close and
// the join returns at once instead of waiting out the budget.
drain_writer(writer, Duration::from_secs(30)).await;
assert!(started.elapsed() < Duration::from_secs(5));
}
#[tokio::test]
async fn a_close_naming_the_slot_conflict_is_reported_as_one() {
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let port = listener.local_addr().expect("port").port();
tokio::spawn(async move {
let (socket, _) = listener.accept().await.expect("accept");
let mut stream = tokio_tungstenite::accept_async(socket)
.await
.expect("handshake");
// The exact wording the bridge endpoint sends when the slot is held.
let _ = stream
.close(Some(CloseFrame {
code: CloseCode::Policy,
reason: "another Donut instance on this account holds the remote-control slot".into(),
}))
.await;
while stream.next().await.is_some() {}
});
let stream = dial_fake(&format!("ws://127.0.0.1:{port}")).await;
let outcome = pump(stream, Arc::new(AtomicBool::new(false))).await;
let error = outcome.expect_err("a policy close is not a clean shutdown");
assert!(
matches!(error, BridgeError::SlotTaken(_)),
"got {error:?}, which would retry on a one-second timer against a server that keeps saying no"
);
assert!(error.is_terminal_refusal());
}
}