mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-04 18:28:42 +02:00
feat: cookie bot
This commit is contained in:
+1347
-47
File diff suppressed because it is too large
Load Diff
@@ -43,10 +43,20 @@ pub struct Entitlements {
|
||||
pub cloud_backup: bool,
|
||||
#[serde(rename = "teamCollaboration", default)]
|
||||
pub team_collaboration: bool,
|
||||
/// Overnight profile warming on a leased remote host. Present on the wire
|
||||
/// since the cookie-bot release; a field missing here is silently dropped on
|
||||
/// the way to the UI, which is why every mirror of this struct has to move
|
||||
/// together.
|
||||
#[serde(rename = "cookieBot", default)]
|
||||
pub cookie_bot: bool,
|
||||
#[serde(rename = "profileLimit", default)]
|
||||
pub profile_limit: i64,
|
||||
#[serde(rename = "requestsPerHour", default)]
|
||||
pub requests_per_hour: i64,
|
||||
/// Per-seat monthly remote-session allowance. Reporting only — a team pools
|
||||
/// it across seats, so the spendable figure comes from the quota route.
|
||||
#[serde(rename = "remoteBrowserHours", default)]
|
||||
pub remote_browser_hours: i64,
|
||||
}
|
||||
|
||||
/// Local fallback mirror of the backend plan -> capability matrix, used only when
|
||||
@@ -66,8 +76,10 @@ fn derive_entitlements(
|
||||
cross_os_fingerprints: false,
|
||||
cloud_backup: false,
|
||||
team_collaboration: false,
|
||||
cookie_bot: false,
|
||||
profile_limit: 0,
|
||||
requests_per_hour: 0,
|
||||
remote_browser_hours: 0,
|
||||
};
|
||||
}
|
||||
// pro and any unrecognized paid plan -> pro-level (never team).
|
||||
@@ -82,12 +94,18 @@ fn derive_entitlements(
|
||||
cross_os_fingerprints,
|
||||
cloud_backup,
|
||||
team_collaboration,
|
||||
// A bot run IS remote automation on leased hardware, so the two capabilities
|
||||
// never diverge: a plan that cannot drive a browser cannot warm one either.
|
||||
cookie_bot: browser_automation,
|
||||
profile_limit,
|
||||
requests_per_hour: if browser_automation {
|
||||
DEFAULT_REQUESTS_PER_HOUR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Deliberately 0 in the fallback: the allowance is the server's to state and
|
||||
// guessing it here would show a customer hours they may not have.
|
||||
remote_browser_hours: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Turning a donutbrowser-infra HTTP failure into a stable, translatable code.
|
||||
//!
|
||||
//! Every cloud transport in this crate flattens its failures through
|
||||
//! `api_call_with_retry`, which needs a `String` so it can sniff for a 401.
|
||||
//! That flattening loses the status, and the body it carries is the backend's
|
||||
//! own English — which would reach the user untranslated, the exact bug the
|
||||
//! `{"code":…}` convention exists to prevent.
|
||||
//!
|
||||
//! So the backend sends a machine code, this module recovers it, and the
|
||||
//! frontend resolves it through `translateBackendError`. When the backend
|
||||
//! sends something else (a proxy error page, a gateway 502), the status alone
|
||||
//! still picks a code the user can act on.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A backend failure reduced to the shape `translateBackendError` consumes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendFailure {
|
||||
/// The HTTP status it came from. 0 when the request never got that far.
|
||||
pub status: u16,
|
||||
pub code: String,
|
||||
pub params: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl BackendFailure {
|
||||
/// Render as the `{"code":…,"params":{…}}` string a Tauri command returns.
|
||||
pub fn to_error_json(&self) -> String {
|
||||
let mut object = serde_json::Map::new();
|
||||
object.insert("code".to_string(), Value::String(self.code.clone()));
|
||||
if !self.params.is_empty() {
|
||||
let params = self
|
||||
.params
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
object.insert("params".to_string(), Value::Object(params));
|
||||
}
|
||||
Value::Object(object).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Which code a status maps to when the body carries none.
|
||||
///
|
||||
/// 404 and 409 mean different things per route — "no schedule for this
|
||||
/// profile" and "that run id is not yours" are both 404 — so each caller
|
||||
/// supplies its own, rather than every route sharing one vague code.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FailureCodes {
|
||||
pub bad_request: &'static str,
|
||||
pub forbidden: &'static str,
|
||||
pub not_found: &'static str,
|
||||
pub conflict: &'static str,
|
||||
}
|
||||
|
||||
/// The desktop has no cloud session at all.
|
||||
pub const NOT_SIGNED_IN: &str = "CLOUD_NOT_SIGNED_IN";
|
||||
/// The request never reached donutbrowser-infra.
|
||||
pub const UNREACHABLE: &str = "CLOUD_UNREACHABLE";
|
||||
/// The backend answered, but with nothing the user can act on.
|
||||
pub const UNAVAILABLE: &str = "CLOUD_REQUEST_FAILED";
|
||||
/// Too many automation requests, backend side.
|
||||
pub const RATE_LIMITED: &str = "REMOTE_RATE_LIMITED";
|
||||
/// No host of the profile's OS has a free slot.
|
||||
pub const NO_CAPACITY: &str = "REMOTE_NO_CAPACITY";
|
||||
|
||||
/// Recover `(status, body)` from the string `api_call_with_retry` hands back.
|
||||
///
|
||||
/// The transports encode a non-2xx as `"(503) no macos host free"` so the
|
||||
/// helper can spot a 401 and still let the caller recover the kind. Anything
|
||||
/// that is not that shape is a transport failure, not a status.
|
||||
pub fn split_status(message: &str) -> Option<(u16, &str)> {
|
||||
let rest = message.strip_prefix('(')?;
|
||||
let (code, tail) = rest.split_once(')')?;
|
||||
let status = code.trim().parse::<u16>().ok()?;
|
||||
Some((status, tail.trim()))
|
||||
}
|
||||
|
||||
/// Classify one HTTP failure.
|
||||
pub fn classify(status: u16, body: &str, codes: FailureCodes) -> BackendFailure {
|
||||
if let Some(failure) = from_body(status, body) {
|
||||
return failure;
|
||||
}
|
||||
BackendFailure {
|
||||
status,
|
||||
code: code_for_status(status, codes).to_string(),
|
||||
params: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a flattened error string, whether or not it encodes a status.
|
||||
///
|
||||
/// Some callers strip the status before they get here (a typed error that
|
||||
/// kept only the body), so a bare `{"code":…}` envelope is still recognised.
|
||||
pub fn classify_message(message: &str, codes: FailureCodes) -> BackendFailure {
|
||||
if let Some((status, body)) = split_status(message) {
|
||||
return classify(status, body, codes);
|
||||
}
|
||||
if let Some(failure) = from_body(0, message) {
|
||||
return failure;
|
||||
}
|
||||
transport_failure(message)
|
||||
}
|
||||
|
||||
/// A failure that never became an HTTP response.
|
||||
///
|
||||
/// `api_call_with_retry` reports a missing token as plain text, so the
|
||||
/// signed-out case is recognised here rather than surfacing as "something went
|
||||
/// wrong" — being signed out is a state the user can fix.
|
||||
pub fn transport_failure(message: &str) -> BackendFailure {
|
||||
let code = if message.contains("Not logged in") || message.contains("No refresh token") {
|
||||
NOT_SIGNED_IN
|
||||
} else {
|
||||
UNREACHABLE
|
||||
};
|
||||
BackendFailure {
|
||||
status: 0,
|
||||
code: code.to_string(),
|
||||
params: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn code_for_status(status: u16, codes: FailureCodes) -> &'static str {
|
||||
match status {
|
||||
400 | 422 => codes.bad_request,
|
||||
401 => NOT_SIGNED_IN,
|
||||
402 | 403 => codes.forbidden,
|
||||
404 => codes.not_found,
|
||||
409 => codes.conflict,
|
||||
429 => RATE_LIMITED,
|
||||
503 => NO_CAPACITY,
|
||||
_ => UNAVAILABLE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the backend's own `{"code":…}` envelope when it sent one.
|
||||
fn from_body(status: u16, body: &str) -> Option<BackendFailure> {
|
||||
let parsed = serde_json::from_str::<Value>(body).ok()?;
|
||||
let object = parsed.as_object()?;
|
||||
let code = object.get("code")?.as_str()?;
|
||||
if code.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut params = BTreeMap::new();
|
||||
|
||||
// The nested shape first, so a top-level key of the same name still wins.
|
||||
//
|
||||
// The cookie-bot routes send every interpolated value under `params`
|
||||
// (`{"code":…,"params":{…}}`) while the remote-session routes spread theirs
|
||||
// at the top level. Only the flat one was read, so
|
||||
// COOKIE_BOT_INVALID_TIMEZONE rendered with an empty timezone name,
|
||||
// COOKIE_BOT_SITE_LIMIT always showed the hardcoded fallback, and a team out
|
||||
// of hours was told it had "used 0 of 0".
|
||||
if let Some(Value::Object(nested)) = object.get("params") {
|
||||
collect_scalars(nested, &mut params);
|
||||
}
|
||||
|
||||
for (key, value) in object {
|
||||
if key == "code" || key == "params" {
|
||||
continue;
|
||||
}
|
||||
if let Value::Array(items) = value {
|
||||
if key == "conflicts" {
|
||||
collect_conflict_params(items, &mut params);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(text) = scalar(value) {
|
||||
params.insert(key.clone(), text);
|
||||
}
|
||||
}
|
||||
|
||||
Some(BackendFailure {
|
||||
status,
|
||||
code: code.to_string(),
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/// A JSON value that can be substituted into a translated sentence.
|
||||
///
|
||||
/// An object or an array has no rendering, so it is dropped rather than
|
||||
/// stringified into the user's face.
|
||||
fn scalar(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) => Some(text.clone()),
|
||||
Value::Number(number) => Some(number.to_string()),
|
||||
Value::Bool(flag) => Some(flag.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_scalars(object: &serde_json::Map<String, Value>, params: &mut BTreeMap<String, String>) {
|
||||
for (key, value) in object {
|
||||
if let Some(text) = scalar(value) {
|
||||
params.insert(key.clone(), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Name the teammate whose enrolment blocks this one.
|
||||
///
|
||||
/// A schedule conflict is only actionable if the user learns WHO and WHEN, and
|
||||
/// the list arrives as an array the generic scalar copy would drop. Only the
|
||||
/// first entry is surfaced; the full list is in the response body for the UI.
|
||||
fn collect_conflict_params(items: &[Value], params: &mut BTreeMap<String, String>) {
|
||||
params.insert("conflict_count".to_string(), items.len().to_string());
|
||||
let Some(first) = items.first().and_then(Value::as_object) else {
|
||||
return;
|
||||
};
|
||||
if let Some(email) = first.get("email").and_then(Value::as_str) {
|
||||
params.insert("email".to_string(), email.to_string());
|
||||
}
|
||||
if let Some(timezone) = first.get("timezone").and_then(Value::as_str) {
|
||||
params.insert("timezone".to_string(), timezone.to_string());
|
||||
}
|
||||
if let Some(minute) = first.get("run_at_minute").and_then(Value::as_u64) {
|
||||
params.insert("run_at_minute".to_string(), minute.to_string());
|
||||
params.insert("time".to_string(), format_minute_of_day(minute));
|
||||
}
|
||||
}
|
||||
|
||||
/// Minute-of-day to a zero-padded 24h clock reading.
|
||||
///
|
||||
/// The value is a wall-clock offset in the conflicting enrolment's own
|
||||
/// timezone, so there is no date and nothing to convert — only to render.
|
||||
pub fn format_minute_of_day(minute: u64) -> String {
|
||||
let minute = minute % 1440;
|
||||
format!("{:02}:{:02}", minute / 60, minute % 60)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CODES: FailureCodes = FailureCodes {
|
||||
bad_request: "BAD",
|
||||
forbidden: "FORBIDDEN",
|
||||
not_found: "MISSING",
|
||||
conflict: "CLASH",
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn the_backends_own_code_wins_over_the_status_default() {
|
||||
// The status table is a fallback for gateway pages. When infra names the
|
||||
// failure, that name is the one the user's locale has a string for.
|
||||
let failure = classify(403, r#"{"code":"COOKIE_BOT_NOT_ENTITLED"}"#, CODES);
|
||||
assert_eq!(failure.code, "COOKIE_BOT_NOT_ENTITLED");
|
||||
assert_eq!(failure.status, 403);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_without_a_code_falls_back_to_the_routes_own_meaning() {
|
||||
// 404 means "no schedule" on one route and "no such run" on another;
|
||||
// sharing one code would tell the user the wrong thing on one of them.
|
||||
assert_eq!(classify(404, "Not Found", CODES).code, "MISSING");
|
||||
assert_eq!(classify(409, "", CODES).code, "CLASH");
|
||||
assert_eq!(classify(400, "<html>", CODES).code, "BAD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_and_rate_limits_are_never_reported_as_a_fault() {
|
||||
// 503 is "come back in a minute" — the fleet is four Windows hosts wide,
|
||||
// so a busy fleet is normal and must not look like an outage.
|
||||
assert_eq!(classify(503, "", CODES).code, NO_CAPACITY);
|
||||
assert_eq!(classify(429, "", CODES).code, RATE_LIMITED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unauthenticated_response_is_always_the_signed_out_code() {
|
||||
// Never the route's forbidden code: "sign in" and "upgrade your plan" are
|
||||
// different instructions and the user can only follow one of them.
|
||||
assert_eq!(classify(401, "", CODES).code, NOT_SIGNED_IN);
|
||||
assert_eq!(classify(402, "", CODES).code, "FORBIDDEN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_body_fields_become_translation_params() {
|
||||
let failure = classify(
|
||||
403,
|
||||
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200,"used":201.5,"pooled":true}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("granted").map(String::as_str),
|
||||
Some("200")
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("used").map(String::as_str),
|
||||
Some("201.5")
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("pooled").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_params_are_read_because_that_is_the_shape_cookie_bot_sends() {
|
||||
// `body(code, params)` in cookie-bot.errors.ts returns `{code, params}`,
|
||||
// which Nest serialises verbatim. Reading only the top level dropped every
|
||||
// interpolated value: the timezone the user typed, the site limit, the
|
||||
// hours a team had actually spent.
|
||||
let failure = classify(
|
||||
400,
|
||||
r#"{"code":"COOKIE_BOT_INVALID_TIMEZONE","params":{"timezone":"Europe/Nowhere"}}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(failure.code, "COOKIE_BOT_INVALID_TIMEZONE");
|
||||
assert_eq!(
|
||||
failure.params.get("timezone").map(String::as_str),
|
||||
Some("Europe/Nowhere")
|
||||
);
|
||||
|
||||
let limit = classify(
|
||||
400,
|
||||
r#"{"code":"COOKIE_BOT_SITE_LIMIT","params":{"min":1,"max":40}}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(limit.params.get("min").map(String::as_str), Some("1"));
|
||||
assert_eq!(limit.params.get("max").map(String::as_str), Some("40"));
|
||||
|
||||
let hours = classify(
|
||||
403,
|
||||
r#"{"code":"REMOTE_HOURS_EXHAUSTED","params":{"granted":200,"used":214.5}}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(hours.params.get("granted").map(String::as_str), Some("200"));
|
||||
assert_eq!(hours.params.get("used").map(String::as_str), Some("214.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_body_shapes_coexist_and_the_top_level_one_wins() {
|
||||
// The two planes disagree about where params live, and neither is going to
|
||||
// change for the other. A key present in both must resolve once.
|
||||
let failure = classify(
|
||||
403,
|
||||
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200,"params":{"granted":1,"used":5}}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("granted").map(String::as_str),
|
||||
Some("200")
|
||||
);
|
||||
assert_eq!(failure.params.get("used").map(String::as_str), Some("5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_scalar_param_is_dropped_rather_than_rendered_as_json() {
|
||||
// These values are substituted into a translated sentence. An object has
|
||||
// no rendering, and `[object Object]` in a toast is worse than nothing.
|
||||
let failure = classify(
|
||||
400,
|
||||
r#"{"code":"COOKIE_BOT_INVALID_SCHEDULE","params":{"field":"sites","detail":{"a":1},"list":[1,2]}}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("field").map(String::as_str),
|
||||
Some("sites")
|
||||
);
|
||||
assert!(!failure.params.contains_key("detail"));
|
||||
assert!(!failure.params.contains_key("list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_schedule_conflict_names_the_teammate_and_the_time() {
|
||||
// Without these the dialog can only say "someone else already warms this
|
||||
// profile", which is not something the user can act on.
|
||||
let failure = classify(
|
||||
409,
|
||||
r#"{"code":"COOKIE_BOT_SCHEDULE_CONFLICT","conflicts":[{"email":"alex@example.com","run_at_minute":120,"timezone":"Europe/Berlin"}]}"#,
|
||||
CODES,
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("email").map(String::as_str),
|
||||
Some("alex@example.com")
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("time").map(String::as_str),
|
||||
Some("02:00")
|
||||
);
|
||||
assert_eq!(
|
||||
failure.params.get("conflict_count").map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minute_of_day_renders_as_a_padded_clock_reading() {
|
||||
assert_eq!(format_minute_of_day(0), "00:00");
|
||||
assert_eq!(format_minute_of_day(9 * 60 + 5), "09:05");
|
||||
assert_eq!(format_minute_of_day(1439), "23:59");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_encoded_message_round_trips_to_its_code() {
|
||||
assert_eq!(
|
||||
classify_message(r#"(409) {"code":"COOKIE_BOT_RUN_IN_PROGRESS"}"#, CODES).code,
|
||||
"COOKIE_BOT_RUN_IN_PROGRESS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_out_desktop_is_told_to_sign_in_not_that_the_network_failed() {
|
||||
assert_eq!(classify_message("Not logged in", CODES).code, NOT_SIGNED_IN);
|
||||
assert_eq!(
|
||||
classify_message("reach backend: connection refused", CODES).code,
|
||||
UNREACHABLE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rendered_json_is_what_translate_backend_error_parses() {
|
||||
let failure = classify(404, r#"{"code":"COOKIE_BOT_NOT_ENROLLED"}"#, CODES);
|
||||
assert_eq!(
|
||||
failure.to_error_json(),
|
||||
r#"{"code":"COOKIE_BOT_NOT_ENROLLED"}"#
|
||||
);
|
||||
|
||||
let with_params = classify(
|
||||
403,
|
||||
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200}"#,
|
||||
CODES,
|
||||
);
|
||||
let parsed: Value = serde_json::from_str(&with_params.to_error_json())
|
||||
.expect("the rendered error must be valid JSON");
|
||||
assert_eq!(parsed["code"], "REMOTE_HOURS_EXHAUSTED");
|
||||
assert_eq!(parsed["params"]["granted"], "200");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_status_does_not_misread_ordinary_prose() {
|
||||
assert_eq!(split_status("(503) busy"), Some((503, "busy")));
|
||||
assert_eq!(split_status("(nope) busy"), None);
|
||||
assert_eq!(split_status("decode response: expected value"), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,9 @@ mod wayfern_manager;
|
||||
mod wayfern_terms;
|
||||
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
|
||||
pub mod cloud_auth;
|
||||
mod cloud_errors;
|
||||
mod commercial_license;
|
||||
mod cookie_bot;
|
||||
mod cookie_manager;
|
||||
pub mod events;
|
||||
mod mcp_integrations;
|
||||
@@ -1286,6 +1288,247 @@ async fn generate_sample_fingerprint(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Remote sessions --------------------------------------------------------
|
||||
//
|
||||
// Everything below is transport only. The session state machine, the fleet, the
|
||||
// schedule, the browsing behaviour and the budget all live behind
|
||||
// donutbrowser-infra; these commands carry the user's own scalars there and
|
||||
// render back what the server says.
|
||||
|
||||
/// Turn a remote-session failure into the code the frontend translates.
|
||||
///
|
||||
/// The typed variants carry the backend's own English, which reaches the user
|
||||
/// untranslated if it is surfaced as-is. The raw text is kept in the app log,
|
||||
/// where support can read it, and never in the toast.
|
||||
fn remote_session_error(context: &str, err: remote_session::RemoteSessionError) -> String {
|
||||
log::warn!("Remote session {context} failed: {err}");
|
||||
err.to_error_json()
|
||||
}
|
||||
|
||||
/// Every remote session the signed-in user currently owns.
|
||||
#[tauri::command]
|
||||
async fn list_remote_sessions() -> Result<Vec<remote_session::RemoteSessionState>, String> {
|
||||
remote_session::list_remote_sessions()
|
||||
.await
|
||||
.map_err(|e| remote_session_error("list", e))
|
||||
}
|
||||
|
||||
/// One session's real state.
|
||||
///
|
||||
/// The stream is how the desktop normally learns a transition; this is the
|
||||
/// one-shot read for a window opened after the fact, or a reconnect confirming
|
||||
/// what it missed.
|
||||
#[tauri::command]
|
||||
async fn get_remote_session(
|
||||
session_id: String,
|
||||
) -> Result<remote_session::RemoteSessionState, String> {
|
||||
remote_session::get_remote_session(&session_id)
|
||||
.await
|
||||
.map_err(|e| remote_session_error("read", e))
|
||||
}
|
||||
|
||||
/// Stop a remote session and settle what it cost.
|
||||
///
|
||||
/// Without this the only thing that ends a session is the fleet's two-hour cap,
|
||||
/// so a handful of short launches bills an allowance meant for a hundred.
|
||||
#[tauri::command]
|
||||
async fn stop_remote_session(
|
||||
session_id: String,
|
||||
) -> Result<remote_session::EndRemoteSessionOutcome, String> {
|
||||
remote_session::end_remote_session(&session_id)
|
||||
.await
|
||||
.map_err(|e| remote_session_error("stop", e))
|
||||
}
|
||||
|
||||
/// Subscribe to session transitions. Idempotent.
|
||||
///
|
||||
/// Called once the desktop has a cloud session: signed out there is nothing to
|
||||
/// stream and the socket would only be refused on a loop.
|
||||
#[tauri::command]
|
||||
fn start_remote_session_events(app_handle: tauri::AppHandle) {
|
||||
remote_session::start_session_events(app_handle);
|
||||
}
|
||||
|
||||
/// Unsubscribe. Safe when nothing is running; called on sign-out.
|
||||
#[tauri::command]
|
||||
fn stop_remote_session_events() {
|
||||
remote_session::stop_session_events();
|
||||
}
|
||||
|
||||
/// Whether the desktop is subscribed to session transitions.
|
||||
///
|
||||
/// A UI that mounts after the stream started has no `remote-session-stream`
|
||||
/// event to read, so this is how it decides whether to trust the live state or
|
||||
/// fall back to `list_remote_sessions`.
|
||||
#[tauri::command]
|
||||
fn get_remote_session_events_status() -> bool {
|
||||
remote_session::session_events_running()
|
||||
}
|
||||
|
||||
// --- Cookie bot -------------------------------------------------------------
|
||||
|
||||
/// Turn a cookie-bot failure into the code the frontend translates.
|
||||
fn cookie_bot_error(context: &str, err: cookie_bot::CookieBotError) -> String {
|
||||
log::warn!(
|
||||
"Cookie bot {context} failed: {} (HTTP {})",
|
||||
err.code(),
|
||||
err.status()
|
||||
);
|
||||
err.to_error_json()
|
||||
}
|
||||
|
||||
/// The local profile a cookie-bot write refers to.
|
||||
///
|
||||
/// Enrolment and run-now act on a profile this machine holds: the client-side
|
||||
/// preconditions read its sync mode, OS and exit node, and none of that can be
|
||||
/// checked for a profile that is not here.
|
||||
fn cookie_bot_profile(profile_id: &str) -> Result<profile::BrowserProfile, String> {
|
||||
let profiles = profile::manager::ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| wrap_backend_error(e, "Failed to read profiles"))?;
|
||||
profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| backend_error("PROFILE_NOT_FOUND"))
|
||||
}
|
||||
|
||||
/// Every enrolment the caller can see. `scope` is `mine` or `team`.
|
||||
#[tauri::command]
|
||||
async fn get_cookie_bot_schedules(
|
||||
scope: Option<String>,
|
||||
) -> Result<cookie_bot::CookieBotScheduleList, String> {
|
||||
cookie_bot::list_schedules(scope.as_deref())
|
||||
.await
|
||||
.map_err(|e| cookie_bot_error("schedule list", e))
|
||||
}
|
||||
|
||||
/// This profile's enrolment, or `None` when it has none.
|
||||
#[tauri::command]
|
||||
async fn get_cookie_bot_schedule(
|
||||
profile_id: String,
|
||||
) -> Result<Option<cookie_bot::CookieBotSchedule>, String> {
|
||||
cookie_bot::get_schedule(&profile_id)
|
||||
.await
|
||||
.map_err(|e| cookie_bot_error("schedule read", e))
|
||||
}
|
||||
|
||||
/// Create or replace this profile's enrolment.
|
||||
///
|
||||
/// `acknowledge_conflict` is the second half of a two-step write: a teammate's
|
||||
/// existing enrolment refuses the first PUT and names them, and the same call
|
||||
/// with the flag set goes through.
|
||||
#[tauri::command]
|
||||
async fn save_cookie_bot_schedule(
|
||||
profile_id: String,
|
||||
schedule: cookie_bot::CookieBotScheduleInput,
|
||||
acknowledge_conflict: bool,
|
||||
) -> Result<cookie_bot::CookieBotScheduleSaved, String> {
|
||||
// Refused here rather than at 02:00: a profile that can never be warmed
|
||||
// should never reach a schedule row, an hour of quota or a leased host.
|
||||
let profile = cookie_bot_profile(&profile_id)?;
|
||||
cookie_bot::bot_precondition(&profile)?;
|
||||
// The frontend sends the user's choices; the profile facts the server refuses
|
||||
// a run on are stamped here, from the profile itself, so a caller cannot
|
||||
// assert them.
|
||||
let schedule = schedule.with_profile_state(cookie_bot::profile_state(&profile));
|
||||
cookie_bot::save_schedule(&profile_id, &schedule, acknowledge_conflict)
|
||||
.await
|
||||
.map_err(|e| cookie_bot_error("schedule write", e))
|
||||
}
|
||||
|
||||
/// Turn the bot off for this profile. `false` means there was nothing enrolled.
|
||||
#[tauri::command]
|
||||
async fn delete_cookie_bot_schedule(profile_id: String) -> Result<bool, String> {
|
||||
cookie_bot::delete_schedule(&profile_id)
|
||||
.await
|
||||
.map(|outcome| outcome.deleted)
|
||||
.map_err(|e| cookie_bot_error("schedule delete", e))
|
||||
}
|
||||
|
||||
/// Who else already warms this profile, without writing anything.
|
||||
#[tauri::command]
|
||||
async fn check_cookie_bot_conflicts(
|
||||
profile_id: String,
|
||||
run_at_minute: Option<u16>,
|
||||
timezone: Option<String>,
|
||||
days_mask: Option<u8>,
|
||||
) -> Result<Vec<cookie_bot::CookieBotConflict>, String> {
|
||||
cookie_bot::check_conflicts(&profile_id, run_at_minute, timezone.as_deref(), days_mask)
|
||||
.await
|
||||
.map(|check| check.conflicts)
|
||||
.map_err(|e| cookie_bot_error("conflict check", e))
|
||||
}
|
||||
|
||||
/// One page of run history, newest first.
|
||||
#[tauri::command]
|
||||
async fn get_cookie_bot_runs(
|
||||
profile_id: Option<String>,
|
||||
scope: Option<String>,
|
||||
limit: Option<u32>,
|
||||
before: Option<String>,
|
||||
) -> Result<cookie_bot::CookieBotRunPage, String> {
|
||||
cookie_bot::list_runs(
|
||||
profile_id.as_deref(),
|
||||
scope.as_deref(),
|
||||
limit,
|
||||
before.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| cookie_bot_error("run list", e))
|
||||
}
|
||||
|
||||
/// Start a run now instead of waiting for tonight.
|
||||
///
|
||||
/// The preset and the site list come from the stored enrolment, so an
|
||||
/// unenrolled profile is refused rather than run with client-chosen defaults.
|
||||
#[tauri::command]
|
||||
async fn run_cookie_bot_now(
|
||||
profile_id: String,
|
||||
max_minutes: Option<u32>,
|
||||
) -> Result<cookie_bot::CookieBotRunStarted, String> {
|
||||
cookie_bot::bot_precondition(&cookie_bot_profile(&profile_id)?)?;
|
||||
cookie_bot::run_now(&profile_id, max_minutes)
|
||||
.await
|
||||
.map_err(|e| cookie_bot_error("run start", e))
|
||||
}
|
||||
|
||||
/// Stop a run that is still going.
|
||||
#[tauri::command]
|
||||
async fn cancel_cookie_bot_run(run_id: String) -> Result<cookie_bot::CookieBotRun, String> {
|
||||
cookie_bot::cancel_run(&run_id)
|
||||
.await
|
||||
.map_err(|e| cookie_bot_error("run cancel", e))
|
||||
}
|
||||
|
||||
/// The intensities the server offers today. Opaque ids and a typical duration —
|
||||
/// what each one actually does is the server's to know.
|
||||
#[tauri::command]
|
||||
async fn get_cookie_bot_presets() -> Result<cookie_bot::CookieBotPresetList, String> {
|
||||
cookie_bot::list_presets()
|
||||
.await
|
||||
.map_err(|e| cookie_bot_error("preset list", e))
|
||||
}
|
||||
|
||||
/// The pooled remote-hour budget: bot and interactive sessions share one pool.
|
||||
///
|
||||
/// Being refused a launch must not be the only way to learn a limit exists.
|
||||
#[tauri::command]
|
||||
async fn get_remote_hours_quota() -> Result<cookie_bot::RemoteHoursQuota, String> {
|
||||
cookie_bot::remote_hours_quota()
|
||||
.await
|
||||
.map_err(|e| cookie_bot_error("quota read", e))
|
||||
}
|
||||
|
||||
/// Per-member and per-profile spend for a calendar month (`YYYY-MM`).
|
||||
#[tauri::command]
|
||||
async fn get_cookie_bot_usage(
|
||||
period: Option<String>,
|
||||
) -> Result<cookie_bot::CookieBotUsage, String> {
|
||||
cookie_bot::team_usage(period.as_deref())
|
||||
.await
|
||||
.map_err(|e| cookie_bot_error("usage read", e))
|
||||
}
|
||||
|
||||
/// Confirm a quit chosen from the close-confirmation dialog and exit the app.
|
||||
#[tauri::command]
|
||||
fn confirm_quit(app_handle: tauri::AppHandle) {
|
||||
@@ -2286,6 +2529,12 @@ pub fn run_with_builder(
|
||||
}
|
||||
};
|
||||
tokio::join!(sync_token_fut, proxy_fut, wayfern_fut);
|
||||
|
||||
// Subscribe to remote-session transitions. Started here rather than
|
||||
// unconditionally because a signed-out desktop has nothing to stream
|
||||
// and would only be refused on a loop; the frontend starts it again
|
||||
// through `start_remote_session_events` once the user signs in.
|
||||
remote_session::start_session_events(app_handle_cloud.clone());
|
||||
}
|
||||
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
|
||||
});
|
||||
@@ -2478,6 +2727,25 @@ pub fn run_with_builder(
|
||||
dns_blocklist::set_custom_dns_config,
|
||||
dns_blocklist::import_custom_dns_rules,
|
||||
dns_blocklist::export_custom_dns_rules,
|
||||
// Remote session commands
|
||||
list_remote_sessions,
|
||||
get_remote_session,
|
||||
stop_remote_session,
|
||||
start_remote_session_events,
|
||||
stop_remote_session_events,
|
||||
get_remote_session_events_status,
|
||||
// Cookie bot commands
|
||||
get_cookie_bot_schedules,
|
||||
get_cookie_bot_schedule,
|
||||
save_cookie_bot_schedule,
|
||||
delete_cookie_bot_schedule,
|
||||
check_cookie_bot_conflicts,
|
||||
get_cookie_bot_runs,
|
||||
run_cookie_bot_now,
|
||||
cancel_cookie_bot_run,
|
||||
get_cookie_bot_presets,
|
||||
get_remote_hours_quota,
|
||||
get_cookie_bot_usage,
|
||||
// Profile password commands
|
||||
set_profile_password,
|
||||
change_profile_password,
|
||||
@@ -2490,6 +2758,12 @@ pub fn run_with_builder(
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|_app_handle, _event| {
|
||||
// Drop the session stream before the runtime goes away, so a shutdown
|
||||
// never waits out a reconnect backoff that is about to be pointless.
|
||||
if let tauri::RunEvent::Exit = _event {
|
||||
remote_session::stop_session_events();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Reopen { .. } = _event {
|
||||
if let Some(window) = _app_handle.get_webview_window("main") {
|
||||
@@ -2523,6 +2797,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frontend_listens_for_the_remote_session_events_that_are_emitted() {
|
||||
// These names are the whole of BUG-2's fix: the backend answers a launch
|
||||
// with `provisioning` and nothing else, so a desktop that subscribes to a
|
||||
// name the emitter does not use is blind between launch and stop and shows
|
||||
// nothing at all. Renaming one side is silent everywhere else.
|
||||
let client = fs::read_to_string("../src/lib/remote-sessions.ts")
|
||||
.expect("the frontend remote-session client must exist");
|
||||
for event in [
|
||||
crate::remote_session::EVENT_SESSION_STATE,
|
||||
crate::remote_session::EVENT_SESSION_SNAPSHOT,
|
||||
crate::remote_session::EVENT_STREAM_STATUS,
|
||||
] {
|
||||
assert!(
|
||||
client.contains(&format!("\"{event}\"")),
|
||||
"no frontend listener for the emitted event {event}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_unused_tauri_commands() {
|
||||
check_unused_commands(false); // Run in strict mode for CI
|
||||
|
||||
+817
-6
@@ -509,6 +509,18 @@ impl McpServer {
|
||||
| "get_interactive_elements"
|
||||
| "click_by_index"
|
||||
| "type_by_index"
|
||||
// Starting a bot run leases a remote host for up to two hours and
|
||||
// spends the account's pooled remote-hour budget, which makes it the
|
||||
// most expensive tool here. Cancelling one reaches the same fleet, and
|
||||
// is metered alongside the remote-session stop it mirrors.
|
||||
//
|
||||
// Deliberately absent: set_cookie_bot_schedule and
|
||||
// delete_cookie_bot_schedule. They write one row in Donut cloud and
|
||||
// lease nothing; metering them would throttle an agent enrolling a
|
||||
// fleet of profiles, while the budget that actually guards the
|
||||
// hardware is spent per RUN and enforced server-side.
|
||||
| "run_cookie_bot_now"
|
||||
| "cancel_cookie_bot_run"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1646,6 +1658,255 @@ impl McpServer {
|
||||
"required": ["profile_id", "index", "text"]
|
||||
}),
|
||||
},
|
||||
// Remote fleet observability. `run_profile_remote` hands back a session
|
||||
// id and the word "provisioning"; without these an agent can only learn
|
||||
// that a session became usable by trying to drive it and failing.
|
||||
McpTool {
|
||||
name: "list_remote_sessions".to_string(),
|
||||
description: "List the remote browser sessions this account currently owns, with their live status".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "get_remote_session".to_string(),
|
||||
description: "Read one remote session's real state: provisioning, ready, live or closed, plus whether it can be driven yet".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "Session id returned when the remote session was started"
|
||||
}
|
||||
},
|
||||
"required": ["session_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "get_remote_hours_quota".to_string(),
|
||||
description: "Read the pooled remote-hour budget. Bot runs and interactive remote sessions spend the same pool".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
// Cookie bot. Every one of these is a proxy onto Donut cloud, which owns
|
||||
// the schedule and the browsing behaviour; the tools carry only the
|
||||
// user's own choices.
|
||||
McpTool {
|
||||
name: "list_cookie_bot_schedules".to_string(),
|
||||
description: "List profiles enrolled in the nightly cookie bot".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["mine", "team"],
|
||||
"description": "Whose enrolments to list (default: mine)"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "get_cookie_bot_schedule".to_string(),
|
||||
description: "Get one profile's cookie-bot enrolment, or null when it is not enrolled".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the profile"
|
||||
}
|
||||
},
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "set_cookie_bot_schedule".to_string(),
|
||||
description: "Enrol a profile in the nightly cookie bot, or replace its enrolment. The profile must have cloud sync (not end-to-end encrypted), a recorded Windows or macOS operating system, and a proxy or VPN".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the profile to enrol"
|
||||
},
|
||||
"profile_name": {
|
||||
"type": "string",
|
||||
"description": "Label shown in run history (default: the profile's own name)"
|
||||
},
|
||||
"platform": {
|
||||
"type": "string",
|
||||
"enum": ["windows", "macos"],
|
||||
"description": "Must match the profile's own operating system; taken from the profile when omitted"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the nightly run is armed"
|
||||
},
|
||||
"run_at_minute": {
|
||||
"type": "integer",
|
||||
"description": "Minutes past local midnight, 0-1439"
|
||||
},
|
||||
"days_mask": {
|
||||
"type": "integer",
|
||||
"description": "Bitmask of local weekdays, bit 0 = Monday, 1-127"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "IANA zone the run time is expressed in, e.g. Europe/Berlin"
|
||||
},
|
||||
"preset": {
|
||||
"type": "string",
|
||||
"description": "Preset id from list_cookie_bot_presets"
|
||||
},
|
||||
"max_minutes": {
|
||||
"type": "integer",
|
||||
"description": "Upper bound on one run, in minutes"
|
||||
},
|
||||
"sites": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Absolute http(s) URLs to browse. The bot visits only these"
|
||||
},
|
||||
"jitter_seconds": {
|
||||
"type": "integer",
|
||||
"description": "Random spread around the run time, in seconds"
|
||||
},
|
||||
"acknowledge_conflict": {
|
||||
"type": "boolean",
|
||||
"description": "Write anyway when a teammate already enrols this profile"
|
||||
}
|
||||
},
|
||||
"required": ["profile_id", "enabled", "run_at_minute", "days_mask", "timezone", "preset", "max_minutes"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "delete_cookie_bot_schedule".to_string(),
|
||||
description: "Turn the cookie bot off for a profile. Safe to repeat; a run already in flight is not cancelled".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the profile to unenrol"
|
||||
}
|
||||
},
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "check_cookie_bot_conflicts".to_string(),
|
||||
description: "Ask, without writing anything, which teammates already enrol this profile and whether a proposed time would overlap theirs".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the profile"
|
||||
},
|
||||
"run_at_minute": {
|
||||
"type": "integer",
|
||||
"description": "Proposed minutes past local midnight, 0-1439"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "Proposed IANA zone"
|
||||
},
|
||||
"days_mask": {
|
||||
"type": "integer",
|
||||
"description": "Proposed weekday bitmask, bit 0 = Monday"
|
||||
}
|
||||
},
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "list_cookie_bot_runs".to_string(),
|
||||
description: "List cookie-bot runs, newest first, with how many sites each visited and what it cost".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "Restrict to one profile"
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["mine", "team"],
|
||||
"description": "Whose runs to list (default: mine)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Page size, 1-100 (default: 30)"
|
||||
},
|
||||
"before": {
|
||||
"type": "string",
|
||||
"description": "Keyset cursor from a previous page's next_before"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "run_cookie_bot_now".to_string(),
|
||||
description: "Start a cookie-bot run immediately instead of waiting for the schedule. The profile must already be enrolled: the preset and site list live in its schedule. Requires an active Pro subscription and spends the pooled remote-hour budget".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the enrolled profile to warm"
|
||||
},
|
||||
"max_minutes": {
|
||||
"type": "integer",
|
||||
"description": "Cap this run only, overriding the schedule's own"
|
||||
}
|
||||
},
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "cancel_cookie_bot_run".to_string(),
|
||||
description: "Stop a cookie-bot run that is still going. Idempotent: cancelling a finished run returns it unchanged".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"description": "Run id from list_cookie_bot_runs"
|
||||
}
|
||||
},
|
||||
"required": ["run_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "list_cookie_bot_presets".to_string(),
|
||||
description: "List the cookie-bot intensities that can be chosen, with roughly how long each takes".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "get_cookie_bot_usage".to_string(),
|
||||
description: "Per-member and per-profile cookie-bot spend for a calendar month. Reporting only".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"period": {
|
||||
"type": "string",
|
||||
"description": "Calendar month as YYYY-MM (default: the current UTC month)"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1991,6 +2252,33 @@ impl McpServer {
|
||||
.await?;
|
||||
self.handle_type_by_index(arguments).await
|
||||
}
|
||||
// Remote fleet observability. Reads only, and free: being unable to see
|
||||
// that a session you are already paying for has become usable is not a
|
||||
// feature worth withholding.
|
||||
"list_remote_sessions" => Self::handle_list_remote_sessions().await,
|
||||
"get_remote_session" => Self::handle_get_remote_session(arguments).await,
|
||||
"get_remote_hours_quota" => Self::handle_get_remote_hours_quota().await,
|
||||
// Cookie bot. Reading and configuring are free; only starting a run,
|
||||
// which leases a host and spends the pooled hours, needs the plan.
|
||||
"list_cookie_bot_schedules" => Self::handle_list_cookie_bot_schedules(arguments).await,
|
||||
"get_cookie_bot_schedule" => Self::handle_get_cookie_bot_schedule(arguments).await,
|
||||
"set_cookie_bot_schedule" => Self::handle_set_cookie_bot_schedule(arguments).await,
|
||||
"delete_cookie_bot_schedule" => Self::handle_delete_cookie_bot_schedule(arguments).await,
|
||||
"check_cookie_bot_conflicts" => Self::handle_check_cookie_bot_conflicts(arguments).await,
|
||||
"list_cookie_bot_runs" => Self::handle_list_cookie_bot_runs(arguments).await,
|
||||
"run_cookie_bot_now" => {
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
Self::handle_run_cookie_bot_now(arguments).await
|
||||
}
|
||||
// No capability gate on the cancel. A lapsed plan must never be the
|
||||
// reason an agent cannot stop something that is spending hours.
|
||||
"cancel_cookie_bot_run" => Self::handle_cancel_cookie_bot_run(arguments).await,
|
||||
"list_cookie_bot_presets" => Self::handle_list_cookie_bot_presets().await,
|
||||
"get_cookie_bot_usage" => Self::handle_get_cookie_bot_usage(arguments).await,
|
||||
_ => Err(McpError {
|
||||
code: -32602,
|
||||
message: format!("Unknown tool: {tool_name}"),
|
||||
@@ -5521,6 +5809,348 @@ impl McpServer {
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Remote fleet and cookie bot -----------------------------------------
|
||||
//
|
||||
// Every tool below is a proxy onto Donut cloud, which owns the schedule, the
|
||||
// calendar arithmetic, the browsing behaviour and the pooled hour budget.
|
||||
// Nothing here decides when a run happens or what it does. What this file
|
||||
// DOES decide is which profiles may be offered to the bot at all.
|
||||
|
||||
/// Render a value as the single text block an MCP tool answers with.
|
||||
fn json_content<T: Serialize>(value: &T) -> Result<serde_json::Value, McpError> {
|
||||
let text = serde_json::to_string_pretty(value).map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to encode response: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::json!({ "content": [{ "type": "text", "text": text }] }))
|
||||
}
|
||||
|
||||
fn require_str<'a>(arguments: &'a serde_json::Value, key: &str) -> Result<&'a str, McpError> {
|
||||
arguments
|
||||
.get(key)
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: format!("Missing {key}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a whole-number argument, refusing anything that would silently wrap.
|
||||
///
|
||||
/// `as_u64() as u16` would turn a run time of 1440 into 1440 but 65536 into
|
||||
/// 0, quietly scheduling a run at midnight nobody asked for.
|
||||
fn require_u16(arguments: &serde_json::Value, key: &str) -> Result<u16, McpError> {
|
||||
Self::optional_u16(arguments, key)?.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: format!("Missing {key}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_u16(arguments: &serde_json::Value, key: &str) -> Result<Option<u16>, McpError> {
|
||||
let Some(value) = arguments.get(key).filter(|value| !value.is_null()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
value
|
||||
.as_u64()
|
||||
.and_then(|raw| u16::try_from(raw).ok())
|
||||
.map(Some)
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: format!("{key} must be a whole number between 0 and 65535"),
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_u8(arguments: &serde_json::Value, key: &str) -> Result<Option<u8>, McpError> {
|
||||
let Some(value) = arguments.get(key).filter(|value| !value.is_null()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
value
|
||||
.as_u64()
|
||||
.and_then(|raw| u8::try_from(raw).ok())
|
||||
.map(Some)
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: format!("{key} must be a whole number between 0 and 255"),
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_u32(arguments: &serde_json::Value, key: &str) -> Result<Option<u32>, McpError> {
|
||||
let Some(value) = arguments.get(key).filter(|value| !value.is_null()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
value
|
||||
.as_u64()
|
||||
.and_then(|raw| u32::try_from(raw).ok())
|
||||
.map(Some)
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: format!("{key} must be a whole number between 0 and 4294967295"),
|
||||
})
|
||||
}
|
||||
|
||||
/// A cloud failure, rendered as the `{"code":…,"params":{…}}` envelope.
|
||||
///
|
||||
/// The backend's own English would be meaningless to an agent deciding what
|
||||
/// to do next; a stable code and its parameters are something it can branch
|
||||
/// on, and it is the same envelope the desktop and the REST API answer with.
|
||||
fn cloud_error(err: crate::cookie_bot::CookieBotError) -> McpError {
|
||||
McpError {
|
||||
code: -32000,
|
||||
message: err.to_error_json(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a profile the cookie bot is allowed to touch.
|
||||
///
|
||||
/// The same gate the REST surface applies, for the same reason: the bot runs
|
||||
/// ONLY on the leased fleet, so a profile that cannot make the round trip to
|
||||
/// a remote host and back — never synced, encrypted with a key that never
|
||||
/// leaves this machine, no recorded OS, an OS the fleet cannot lease, or no
|
||||
/// proxy or VPN to egress through — must never reach an enrolment, a quota
|
||||
/// check or a leased host on ANY surface.
|
||||
fn cookie_bot_eligible_profile(profile_id: &str) -> Result<BrowserProfile, McpError> {
|
||||
let profiles = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to list profiles: {e}"),
|
||||
})?;
|
||||
|
||||
let profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: format!("Profile not found: {profile_id}"),
|
||||
})?;
|
||||
|
||||
crate::cookie_bot::bot_precondition(&profile).map_err(|message| McpError {
|
||||
code: -32000,
|
||||
message,
|
||||
})?;
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
async fn handle_list_remote_sessions() -> Result<serde_json::Value, McpError> {
|
||||
let sessions = crate::remote_session::list_remote_sessions()
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: e.to_error_json(),
|
||||
})?;
|
||||
Self::json_content(&sessions)
|
||||
}
|
||||
|
||||
async fn handle_get_remote_session(
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let session_id = Self::require_str(arguments, "session_id")?;
|
||||
let state = crate::remote_session::get_remote_session(session_id)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: e.to_error_json(),
|
||||
})?;
|
||||
Self::json_content(&state)
|
||||
}
|
||||
|
||||
async fn handle_get_remote_hours_quota() -> Result<serde_json::Value, McpError> {
|
||||
let quota = crate::cookie_bot::remote_hours_quota()
|
||||
.await
|
||||
.map_err(Self::cloud_error)?;
|
||||
Self::json_content("a)
|
||||
}
|
||||
|
||||
async fn handle_list_cookie_bot_schedules(
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let scope = arguments.get("scope").and_then(|value| value.as_str());
|
||||
let schedules = crate::cookie_bot::list_schedules(scope)
|
||||
.await
|
||||
.map_err(Self::cloud_error)?;
|
||||
Self::json_content(&schedules)
|
||||
}
|
||||
|
||||
async fn handle_get_cookie_bot_schedule(
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = Self::require_str(arguments, "profile_id")?;
|
||||
// Not gated on eligibility: a profile whose sync was turned off after it
|
||||
// was enrolled must still be able to show what it is enrolled as.
|
||||
let schedule = crate::cookie_bot::get_schedule(profile_id)
|
||||
.await
|
||||
.map_err(Self::cloud_error)?;
|
||||
Self::json_content(&schedule)
|
||||
}
|
||||
|
||||
async fn handle_set_cookie_bot_schedule(
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = Self::require_str(arguments, "profile_id")?;
|
||||
let profile = Self::cookie_bot_eligible_profile(profile_id)?;
|
||||
|
||||
// `bot_precondition` already proved the profile has an OS the fleet can
|
||||
// lease. Taking the platform from the profile rather than the arguments is
|
||||
// what stops an agent enrolling a macOS profile onto a Windows host.
|
||||
let platform = profile
|
||||
.resolved_os()
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "Profile has no recorded operating system".to_string(),
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
if let Some(requested) = arguments.get("platform").and_then(|v| v.as_str()) {
|
||||
if requested != platform {
|
||||
return Err(McpError {
|
||||
code: -32602,
|
||||
message: format!(
|
||||
"platform {requested:?} does not match the profile's own operating system {platform:?}"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let enabled = arguments
|
||||
.get("enabled")
|
||||
.and_then(|value| value.as_bool())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing enabled".to_string(),
|
||||
})?;
|
||||
|
||||
let sites = arguments
|
||||
.get("sites")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str().map(str::to_string))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let input = crate::cookie_bot::CookieBotScheduleInput {
|
||||
profile_name: arguments
|
||||
.get("profile_name")
|
||||
.and_then(|value| value.as_str())
|
||||
.map_or_else(|| profile.name.clone(), str::to_string),
|
||||
platform,
|
||||
enabled,
|
||||
run_at_minute: Self::require_u16(arguments, "run_at_minute")?,
|
||||
days_mask: Self::optional_u8(arguments, "days_mask")?.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing days_mask".to_string(),
|
||||
})?,
|
||||
timezone: Self::require_str(arguments, "timezone")?.to_string(),
|
||||
preset: Self::require_str(arguments, "preset")?.to_string(),
|
||||
max_minutes: Self::optional_u32(arguments, "max_minutes")?.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing max_minutes".to_string(),
|
||||
})?,
|
||||
sites,
|
||||
jitter_seconds: Self::optional_u32(arguments, "jitter_seconds")?,
|
||||
..Default::default()
|
||||
}
|
||||
// Derived from the profile, never from the tool arguments: an agent must not
|
||||
// be able to claim a profile has a proxy when it does not.
|
||||
.with_profile_state(crate::cookie_bot::profile_state(&profile));
|
||||
|
||||
let acknowledge_conflict = arguments
|
||||
.get("acknowledge_conflict")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let saved = crate::cookie_bot::save_schedule(profile_id, &input, acknowledge_conflict)
|
||||
.await
|
||||
.map_err(Self::cloud_error)?;
|
||||
Self::json_content(&saved)
|
||||
}
|
||||
|
||||
async fn handle_delete_cookie_bot_schedule(
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = Self::require_str(arguments, "profile_id")?;
|
||||
// No eligibility gate: a profile that has since become ineligible is
|
||||
// exactly the one an agent most needs to be able to unenrol.
|
||||
let deleted = crate::cookie_bot::delete_schedule(profile_id)
|
||||
.await
|
||||
.map_err(Self::cloud_error)?;
|
||||
Self::json_content(&deleted)
|
||||
}
|
||||
|
||||
async fn handle_check_cookie_bot_conflicts(
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = Self::require_str(arguments, "profile_id")?;
|
||||
let conflicts = crate::cookie_bot::check_conflicts(
|
||||
profile_id,
|
||||
Self::optional_u16(arguments, "run_at_minute")?,
|
||||
arguments.get("timezone").and_then(|value| value.as_str()),
|
||||
Self::optional_u8(arguments, "days_mask")?,
|
||||
)
|
||||
.await
|
||||
.map_err(Self::cloud_error)?;
|
||||
Self::json_content(&conflicts)
|
||||
}
|
||||
|
||||
async fn handle_list_cookie_bot_runs(
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let runs = crate::cookie_bot::list_runs(
|
||||
arguments.get("profile_id").and_then(|value| value.as_str()),
|
||||
arguments.get("scope").and_then(|value| value.as_str()),
|
||||
Self::optional_u32(arguments, "limit")?,
|
||||
arguments.get("before").and_then(|value| value.as_str()),
|
||||
)
|
||||
.await
|
||||
.map_err(Self::cloud_error)?;
|
||||
Self::json_content(&runs)
|
||||
}
|
||||
|
||||
async fn handle_run_cookie_bot_now(
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = Self::require_str(arguments, "profile_id")?;
|
||||
Self::cookie_bot_eligible_profile(profile_id)?;
|
||||
|
||||
let started =
|
||||
crate::cookie_bot::run_now(profile_id, Self::optional_u32(arguments, "max_minutes")?)
|
||||
.await
|
||||
.map_err(Self::cloud_error)?;
|
||||
Self::json_content(&started)
|
||||
}
|
||||
|
||||
async fn handle_cancel_cookie_bot_run(
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let run_id = Self::require_str(arguments, "run_id")?;
|
||||
let run = crate::cookie_bot::cancel_run(run_id)
|
||||
.await
|
||||
.map_err(Self::cloud_error)?;
|
||||
Self::json_content(&run)
|
||||
}
|
||||
|
||||
async fn handle_list_cookie_bot_presets() -> Result<serde_json::Value, McpError> {
|
||||
// Ids and a rough duration only. What a preset expands to — the site
|
||||
// ordering, the dwell model, the scroll and click programme — is the
|
||||
// server's, and stays there.
|
||||
let presets = crate::cookie_bot::list_presets()
|
||||
.await
|
||||
.map_err(Self::cloud_error)?;
|
||||
Self::json_content(&presets)
|
||||
}
|
||||
|
||||
async fn handle_get_cookie_bot_usage(
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let usage = crate::cookie_bot::team_usage(arguments.get("period").and_then(|v| v.as_str()))
|
||||
.await
|
||||
.map_err(Self::cloud_error)?;
|
||||
Self::json_content(&usage)
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -5536,8 +6166,21 @@ mod tests {
|
||||
let server = McpServer::new();
|
||||
let tools = server.get_tools();
|
||||
|
||||
// Should have at least 41 tools (34 + 7 browser interaction tools)
|
||||
assert!(tools.len() >= 41);
|
||||
// Should have at least 54 tools (34 + 7 browser interaction + 13 remote
|
||||
// fleet and cookie-bot tools)
|
||||
assert!(tools.len() >= 54);
|
||||
|
||||
// Names are the contract an MCP client is written against, so a duplicate
|
||||
// silently shadows one of the two in dispatch and the tool that loses is
|
||||
// simply never reachable.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for tool in &tools {
|
||||
assert!(
|
||||
seen.insert(tool.name.as_str()),
|
||||
"duplicate MCP tool name: {}",
|
||||
tool.name
|
||||
);
|
||||
}
|
||||
|
||||
// Check tool names
|
||||
let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
|
||||
@@ -5602,6 +6245,150 @@ mod tests {
|
||||
assert!(tool_names.contains(&"type_text"));
|
||||
assert!(tool_names.contains(&"get_page_content"));
|
||||
assert!(tool_names.contains(&"get_page_info"));
|
||||
// Remote fleet observability
|
||||
assert!(tool_names.contains(&"list_remote_sessions"));
|
||||
assert!(tool_names.contains(&"get_remote_session"));
|
||||
assert!(tool_names.contains(&"get_remote_hours_quota"));
|
||||
// Cookie bot
|
||||
assert!(tool_names.contains(&"list_cookie_bot_schedules"));
|
||||
assert!(tool_names.contains(&"get_cookie_bot_schedule"));
|
||||
assert!(tool_names.contains(&"set_cookie_bot_schedule"));
|
||||
assert!(tool_names.contains(&"delete_cookie_bot_schedule"));
|
||||
assert!(tool_names.contains(&"check_cookie_bot_conflicts"));
|
||||
assert!(tool_names.contains(&"list_cookie_bot_runs"));
|
||||
assert!(tool_names.contains(&"run_cookie_bot_now"));
|
||||
assert!(tool_names.contains(&"cancel_cookie_bot_run"));
|
||||
assert!(tool_names.contains(&"list_cookie_bot_presets"));
|
||||
assert!(tool_names.contains(&"get_cookie_bot_usage"));
|
||||
}
|
||||
|
||||
// A tool advertised in tools/list but missing from dispatch answers "Unknown
|
||||
// tool": the client can see it and cannot call it, and nothing else in the
|
||||
// build notices.
|
||||
//
|
||||
// Asserted against the source rather than by dispatching, because half these
|
||||
// tools take no arguments — calling them would reach Donut cloud, and a unit
|
||||
// test that needs the network is a test that gets deleted.
|
||||
#[test]
|
||||
fn every_cookie_bot_tool_is_both_advertised_and_dispatchable() {
|
||||
let server = McpServer::new();
|
||||
let advertised: Vec<String> = server
|
||||
.get_tools()
|
||||
.into_iter()
|
||||
.map(|tool| tool.name)
|
||||
.filter(|name| name.contains("cookie_bot") || name.contains("remote_"))
|
||||
.collect();
|
||||
|
||||
let dispatched = include_str!("mcp_server.rs");
|
||||
for name in &advertised {
|
||||
assert!(
|
||||
dispatched.contains(&format!("\"{name}\" =>")),
|
||||
"tool is advertised but has no dispatch arm: {name}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
advertised.len(),
|
||||
13,
|
||||
"expected the full remote-fleet and cookie-bot set: {advertised:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The bot runs ONLY on the leased fleet. A profile that cannot be
|
||||
// materialised on a remote host has no path to a run, and every write tool
|
||||
// resolves its profile through this gate before the cloud is asked, so there
|
||||
// is no argument shape that points the bot at a local-only profile.
|
||||
#[test]
|
||||
fn a_profile_the_bot_could_never_run_is_refused_before_the_cloud_is_asked() {
|
||||
use crate::profile::types::SyncMode;
|
||||
|
||||
let eligible = || BrowserProfile {
|
||||
id: uuid::Uuid::nil(),
|
||||
name: "warm me".to_string(),
|
||||
browser: "wayfern".to_string(),
|
||||
version: "latest".to_string(),
|
||||
sync_mode: SyncMode::Regular,
|
||||
host_os: Some("macos".to_string()),
|
||||
proxy_id: Some("proxy-1".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(crate::cookie_bot::bot_precondition(&eligible()).is_ok());
|
||||
|
||||
let mut local_only = eligible();
|
||||
local_only.sync_mode = SyncMode::Disabled;
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&local_only).is_err(),
|
||||
"a profile with no cloud copy has nothing for a host to open"
|
||||
);
|
||||
|
||||
let mut linux = eligible();
|
||||
linux.host_os = Some("linux".to_string());
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&linux).is_err(),
|
||||
"the fleet cannot lease a linux host"
|
||||
);
|
||||
|
||||
let mut datacenter_egress = eligible();
|
||||
datacenter_egress.proxy_id = None;
|
||||
datacenter_egress.vpn_id = None;
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&datacenter_egress).is_err(),
|
||||
"hours of traffic from a hosting ASN damages the identity being warmed"
|
||||
);
|
||||
}
|
||||
|
||||
// Enrolment carries only the user's own scalars. A site list, a dwell range
|
||||
// or a step programme appearing in the schema would mean the browsing model
|
||||
// had leaked out of the server and into this AGPL client.
|
||||
#[test]
|
||||
fn the_bot_tools_expose_choices_not_behaviour() {
|
||||
let server = McpServer::new();
|
||||
let tools = server.get_tools();
|
||||
|
||||
let presets = tools
|
||||
.iter()
|
||||
.find(|tool| tool.name == "list_cookie_bot_presets")
|
||||
.expect("list_cookie_bot_presets tool");
|
||||
assert_eq!(
|
||||
presets.input_schema["properties"]
|
||||
.as_object()
|
||||
.map(serde_json::Map::len),
|
||||
Some(0),
|
||||
"a preset is chosen by id; it takes no behaviour parameters"
|
||||
);
|
||||
|
||||
let set = tools
|
||||
.iter()
|
||||
.find(|tool| tool.name == "set_cookie_bot_schedule")
|
||||
.expect("set_cookie_bot_schedule tool");
|
||||
let properties = set.input_schema["properties"]
|
||||
.as_object()
|
||||
.expect("schedule properties");
|
||||
for leaked in [
|
||||
"dwell",
|
||||
"dwell_seconds",
|
||||
"scroll",
|
||||
"clicks",
|
||||
"steps",
|
||||
"actions",
|
||||
"corpus",
|
||||
"user_agent",
|
||||
] {
|
||||
assert!(
|
||||
!properties.contains_key(leaked),
|
||||
"the browsing model leaked into the tool contract: {leaked}"
|
||||
);
|
||||
}
|
||||
|
||||
// `platform` is accepted but not required: this machine already knows the
|
||||
// profile's operating system, and a supplied one that disagrees is
|
||||
// refused rather than honoured.
|
||||
let required = set.input_schema["required"]
|
||||
.as_array()
|
||||
.expect("required fields");
|
||||
assert!(!required.iter().any(|field| field == "platform"));
|
||||
assert!(required.iter().any(|field| field == "profile_id"));
|
||||
assert!(required.iter().any(|field| field == "preset"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5658,6 +6445,11 @@ mod tests {
|
||||
"get_interactive_elements",
|
||||
"click_by_index",
|
||||
"type_by_index",
|
||||
// Leases a remote host for up to two hours and spends the pooled
|
||||
// remote-hour budget.
|
||||
"run_cookie_bot_now",
|
||||
// Reaches the fleet, like the remote-session stop it mirrors.
|
||||
"cancel_cookie_bot_run",
|
||||
] {
|
||||
assert!(
|
||||
McpServer::is_automation_tool_call(&request("tools/call", Some(name))),
|
||||
@@ -5665,10 +6457,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
assert!(!McpServer::is_automation_tool_call(&request(
|
||||
"tools/call",
|
||||
Some("list_profiles")
|
||||
)));
|
||||
for name in [
|
||||
"list_profiles",
|
||||
// Configuration, not automation: one row in Donut cloud, no hardware
|
||||
// leased. Metering it would throttle an agent enrolling a fleet of
|
||||
// profiles, while the budget that guards the hardware is spent per run.
|
||||
"set_cookie_bot_schedule",
|
||||
"delete_cookie_bot_schedule",
|
||||
"list_cookie_bot_schedules",
|
||||
"get_cookie_bot_schedule",
|
||||
"check_cookie_bot_conflicts",
|
||||
"list_cookie_bot_runs",
|
||||
"list_cookie_bot_presets",
|
||||
"get_cookie_bot_usage",
|
||||
"get_remote_hours_quota",
|
||||
"list_remote_sessions",
|
||||
"get_remote_session",
|
||||
] {
|
||||
assert!(
|
||||
!McpServer::is_automation_tool_call(&request("tools/call", Some(name))),
|
||||
"free or non-leasing tool was limited: {name}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(!McpServer::is_automation_tool_call(&request(
|
||||
"tools/list",
|
||||
None
|
||||
|
||||
@@ -1189,6 +1189,12 @@ impl ProfileManager {
|
||||
|
||||
crate::sync::queue_profile_sync_if_eligible(&profile);
|
||||
|
||||
// The cookie bot refuses a run on a profile with no exit node, using the
|
||||
// copy of that fact the desktop last declared. Detaching a proxy has to
|
||||
// move that copy, or tonight's run egresses from the leased host's own
|
||||
// datacenter address.
|
||||
crate::cookie_bot::report_profile_state(&profile);
|
||||
|
||||
// Auto-enable sync for new proxy if profile has sync enabled
|
||||
if profile.is_sync_enabled() {
|
||||
if let Some(ref new_proxy_id) = proxy_id {
|
||||
@@ -1250,6 +1256,10 @@ impl ProfileManager {
|
||||
|
||||
crate::sync::queue_profile_sync_if_eligible(&profile);
|
||||
|
||||
// Same reason as the proxy path: a VPN is the profile's exit node too, and
|
||||
// the server only knows what this machine last told it.
|
||||
crate::cookie_bot::report_profile_state(&profile);
|
||||
|
||||
// Auto-enable sync for the new VPN if profile has sync enabled.
|
||||
if profile.is_sync_enabled() {
|
||||
if let Some(ref new_vpn_id) = vpn_id {
|
||||
|
||||
@@ -6,10 +6,23 @@
|
||||
//! That indirection is the point: a desktop client that could call the manager
|
||||
//! itself would need credentials capable of launching sessions for anyone.
|
||||
|
||||
use crate::cloud_errors::{self, FailureCodes};
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tauri::AppHandle;
|
||||
|
||||
/// Which code a remote-session failure resolves to when the backend sends no
|
||||
/// envelope of its own.
|
||||
const SESSION_CODES: FailureCodes = FailureCodes {
|
||||
bad_request: "REMOTE_SESSION_REFUSED",
|
||||
forbidden: "REMOTE_NOT_ENTITLED",
|
||||
not_found: "REMOTE_SESSION_NOT_FOUND",
|
||||
conflict: "REMOTE_SESSION_CONFLICT",
|
||||
};
|
||||
|
||||
/// Why a remote launch failed, mapped to the status the local API should return.
|
||||
#[derive(Debug)]
|
||||
pub enum RemoteSessionError {
|
||||
@@ -32,6 +45,25 @@ impl std::fmt::Display for RemoteSessionError {
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteSessionError {
|
||||
/// The `{"code":…,"params":{…}}` string a Tauri command returns.
|
||||
///
|
||||
/// The variants carry the backend's own English, which reaches the user
|
||||
/// untranslated if it is surfaced as-is. This recovers the machine code the
|
||||
/// frontend has a locale string for.
|
||||
pub fn to_error_json(&self) -> String {
|
||||
// The three typed variants know the status they came from; `Other` kept
|
||||
// only the body, so it is re-read for an embedded envelope.
|
||||
let (status, message) = match self {
|
||||
Self::NoCapacity(m) => (503, m),
|
||||
Self::Conflict(m) => (409, m),
|
||||
Self::NotAuthorised(m) => (403, m),
|
||||
Self::Other(m) => return cloud_errors::classify_message(m, SESSION_CODES).to_error_json(),
|
||||
};
|
||||
cloud_errors::classify(status, message, SESSION_CODES).to_error_json()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the backend returns when a session starts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteSessionOutcome {
|
||||
@@ -197,14 +229,513 @@ pub async fn end_remote_session(
|
||||
/// status is re-parsed here rather than lost — a 503 surfacing as a generic
|
||||
/// failure would tell the user their fleet is broken when it is merely busy.
|
||||
pub fn classify_error_string(message: &str) -> RemoteSessionError {
|
||||
if let Some(rest) = message.strip_prefix('(') {
|
||||
if let Some((code, tail)) = rest.split_once(')') {
|
||||
if let Ok(status) = code.trim().parse::<u16>() {
|
||||
return classify_backend_status(status, tail.trim());
|
||||
match cloud_errors::split_status(message) {
|
||||
Some((status, body)) => classify_backend_status(status, body),
|
||||
None => RemoteSessionError::Other(message.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A session as the backend currently sees it.
|
||||
///
|
||||
/// `POST /api/remote-sessions` hands back the literal string `provisioning`
|
||||
/// and nothing else, so until this type existed the only way anyone observed a
|
||||
/// session becoming usable was by reading the production database.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct RemoteSessionState {
|
||||
pub session_id: String,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
/// `provisioning` | `ready` | `live` | `closed` | `error`.
|
||||
///
|
||||
/// Named `state` because that is what `RemoteSessionView` in
|
||||
/// donutbrowser-infra actually sends. It carried the name `status` until a
|
||||
/// real payload was compared against it, and because the field had no
|
||||
/// default, every list and single read failed at `missing field \`status\``
|
||||
/// and surfaced as CLOUD_UNREACHABLE. The alias keeps the launch reply —
|
||||
/// which predates the reconciled vocabulary and still says `status` —
|
||||
/// decoding through the same type.
|
||||
#[serde(rename = "state", alias = "status")]
|
||||
pub state: String,
|
||||
/// The relay is up, so the session can actually be driven.
|
||||
#[serde(default)]
|
||||
pub cdp_ready: bool,
|
||||
/// `interactive` or `cookie_bot`.
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
/// Set when this session belongs to a cookie-bot run.
|
||||
#[serde(default)]
|
||||
pub run_id: Option<String>,
|
||||
/// The team the hours are attributed to, when the caller belongs to one.
|
||||
#[serde(default)]
|
||||
pub team_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub started_at: Option<String>,
|
||||
/// When it finished. The backend sends one timestamp, not a
|
||||
/// `ready_at`/`closed_at` pair.
|
||||
#[serde(default)]
|
||||
pub ended_at: Option<String>,
|
||||
/// Why it ended: `stopped_by_user`, `max_duration`, `lost the profile lock`…
|
||||
#[serde(default)]
|
||||
pub close_reason: Option<String>,
|
||||
/// What it has cost so far. A live session is already being charged, so this
|
||||
/// is the running wall clock rather than 0 until it closes.
|
||||
#[serde(default)]
|
||||
pub billed_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct RemoteSessionListResponse {
|
||||
#[serde(default)]
|
||||
sessions: Vec<RemoteSessionState>,
|
||||
}
|
||||
|
||||
/// Every session the caller currently owns.
|
||||
pub async fn list_remote_sessions() -> Result<Vec<RemoteSessionState>, RemoteSessionError> {
|
||||
let endpoint = format!("{}/api/remote-sessions", crate::cloud_auth::CLOUD_API_URL);
|
||||
let response: RemoteSessionListResponse = get_json(endpoint).await?;
|
||||
Ok(response.sessions)
|
||||
}
|
||||
|
||||
/// One session's real state, for a one-shot read.
|
||||
///
|
||||
/// The event stream is how the desktop normally learns a transition; this is
|
||||
/// for the cases a stream cannot serve — a window opened after the fact, or a
|
||||
/// reconnect that needs to confirm what it missed.
|
||||
pub async fn get_remote_session(
|
||||
session_id: &str,
|
||||
) -> Result<RemoteSessionState, RemoteSessionError> {
|
||||
let endpoint = format!(
|
||||
"{}/api/remote-sessions/{}",
|
||||
crate::cloud_auth::CLOUD_API_URL,
|
||||
urlencoding::encode(session_id)
|
||||
);
|
||||
get_json(endpoint).await
|
||||
}
|
||||
|
||||
async fn get_json<T: serde::de::DeserializeOwned>(
|
||||
endpoint: String,
|
||||
) -> Result<T, RemoteSessionError> {
|
||||
crate::cloud_auth::CLOUD_AUTH
|
||||
.api_call_with_retry(|token| {
|
||||
let endpoint = endpoint.clone();
|
||||
async move {
|
||||
let response = reqwest::Client::new()
|
||||
.get(&endpoint)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("reach backend: {e}"))?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
if !(200..300).contains(&status) {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(format!("({status}) {text}"));
|
||||
}
|
||||
response
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|e| format!("decode response: {e}"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| classify_error_string(&e))
|
||||
}
|
||||
|
||||
// --- Live state, without polling -------------------------------------------
|
||||
|
||||
/// A session transition. Payload is the session as the backend sees it.
|
||||
pub const EVENT_SESSION_STATE: &str = "remote-session-state";
|
||||
/// Everything the caller owns, sent once when the stream connects.
|
||||
pub const EVENT_SESSION_SNAPSHOT: &str = "remote-session-snapshot";
|
||||
/// Whether the desktop is currently receiving transitions.
|
||||
pub const EVENT_STREAM_STATUS: &str = "remote-session-stream";
|
||||
|
||||
/// How long a silent stream is trusted before it is treated as dead.
|
||||
///
|
||||
/// The backend heartbeats, so silence past this means the socket died without
|
||||
/// an error — which is exactly what a laptop returning from sleep sees. Held
|
||||
/// well above the heartbeat interval so a slow network cannot cause a churn of
|
||||
/// reconnects.
|
||||
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
/// First reconnect delay. Doubles per failure.
|
||||
const RECONNECT_BASE: Duration = Duration::from_secs(1);
|
||||
/// Ceiling on the reconnect delay.
|
||||
const RECONNECT_MAX: Duration = Duration::from_secs(60);
|
||||
/// Where the backoff restarts after an auth failure. Being signed out or
|
||||
/// unentitled is not something a fast retry fixes, and hammering an endpoint
|
||||
/// that will keep saying no is how a background task becomes a battery bug.
|
||||
const AUTH_BACKOFF_ATTEMPT: u32 = 6;
|
||||
/// Granularity of the cancellable sleep, so a shutdown is not held up by a
|
||||
/// minute-long backoff.
|
||||
const SHUTDOWN_POLL: Duration = Duration::from_millis(250);
|
||||
|
||||
static STREAM_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
static STREAM_TASK: Mutex<Option<tauri::async_runtime::JoinHandle<()>>> = Mutex::new(None);
|
||||
|
||||
/// One frame off the wire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SseFrame {
|
||||
pub id: Option<String>,
|
||||
pub event: Option<String>,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// Incremental `text/event-stream` decoder.
|
||||
///
|
||||
/// Kept as a value with no IO so the framing rules — multi-line data, the
|
||||
/// blank-line terminator, comments, CRLF, a chunk boundary landing mid-field —
|
||||
/// are testable without a server.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SseDecoder {
|
||||
buffer: Vec<u8>,
|
||||
event: Option<String>,
|
||||
data: String,
|
||||
id: Option<String>,
|
||||
}
|
||||
|
||||
impl SseDecoder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Feed bytes, get back whatever frames completed.
|
||||
pub fn push(&mut self, chunk: &[u8]) -> Vec<SseFrame> {
|
||||
self.buffer.extend_from_slice(chunk);
|
||||
let mut frames = Vec::new();
|
||||
|
||||
// A newline is never part of a multi-byte UTF-8 sequence, so splitting the
|
||||
// raw bytes on it cannot cut a character in half.
|
||||
while let Some(index) = self.buffer.iter().position(|b| *b == b'\n') {
|
||||
let line: Vec<u8> = self.buffer.drain(..=index).collect();
|
||||
let line = String::from_utf8_lossy(&line[..line.len() - 1]);
|
||||
let line = line.strip_suffix('\r').unwrap_or(&line);
|
||||
|
||||
if line.is_empty() {
|
||||
if let Some(frame) = self.take_frame() {
|
||||
frames.push(frame);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A leading colon is a comment; some proxies keep a stream alive with
|
||||
// nothing else, so it must not be mistaken for a field.
|
||||
if line.starts_with(':') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (field, value) = match line.split_once(':') {
|
||||
Some((field, value)) => (field, value.strip_prefix(' ').unwrap_or(value)),
|
||||
None => (line, ""),
|
||||
};
|
||||
match field {
|
||||
"event" => self.event = Some(value.to_string()),
|
||||
"id" => self.id = Some(value.to_string()),
|
||||
"data" => {
|
||||
if !self.data.is_empty() {
|
||||
self.data.push('\n');
|
||||
}
|
||||
self.data.push_str(value);
|
||||
}
|
||||
// `retry` is the server's reconnect hint; this client's own backoff
|
||||
// already bounds that, so honouring it would only make the interval
|
||||
// less predictable.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
frames
|
||||
}
|
||||
|
||||
fn take_frame(&mut self) -> Option<SseFrame> {
|
||||
let event = self.event.take();
|
||||
let id = self.id.take();
|
||||
let data = std::mem::take(&mut self.data);
|
||||
if data.is_empty() && event.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(SseFrame { id, event, data })
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame that carries nothing the frontend needs.
|
||||
fn is_heartbeat(kind: &str) -> bool {
|
||||
matches!(kind, "heartbeat" | "ping" | "keepalive")
|
||||
}
|
||||
|
||||
/// Turn one decoded frame into the Tauri event and payload it becomes.
|
||||
///
|
||||
/// The discriminator lives INSIDE the JSON, not in the SSE `event:` line: Nest
|
||||
/// only sets `MessageEvent.type` for the heartbeat, so every real frame arrives
|
||||
/// as the default `message` event carrying
|
||||
/// `{"type":"snapshot"|"state"|"progress"|"closed","at":…,"sessions"|"session":…}`.
|
||||
/// Routing on the event name alone emitted that whole envelope as a session, so
|
||||
/// `profile_id` was always undefined and the frontend dropped every transition
|
||||
/// — the desktop stayed exactly as blind between launch and stop as it was
|
||||
/// before the stream existed.
|
||||
///
|
||||
/// The SSE name is still honoured when there is one, so a backend that starts
|
||||
/// naming its frames keeps working without a desktop release.
|
||||
pub fn route_frame(event: Option<&str>, data: &str) -> Option<(&'static str, serde_json::Value)> {
|
||||
if let Some(name) = event {
|
||||
if is_heartbeat(name) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let payload = match serde_json::from_str::<serde_json::Value>(data) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
log::warn!("Ignoring malformed remote-session event: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let object = payload.as_object()?;
|
||||
|
||||
let kind = object
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or(event)
|
||||
.unwrap_or("state");
|
||||
if is_heartbeat(kind) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if kind == "snapshot" {
|
||||
let sessions = object
|
||||
.get("sessions")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
|
||||
return Some((
|
||||
EVENT_SESSION_SNAPSHOT,
|
||||
serde_json::json!({ "sessions": sessions }),
|
||||
));
|
||||
}
|
||||
|
||||
// `state`, `progress` and `closed` all wrap one session. A frame that
|
||||
// carries neither an inner `session` nor a session of its own is not
|
||||
// something a consumer can apply, and forwarding it is how the envelope bug
|
||||
// happened in the first place.
|
||||
if let Some(session) = object.get("session").filter(|v| v.is_object()) {
|
||||
return Some((EVENT_SESSION_STATE, session.clone()));
|
||||
}
|
||||
if object.contains_key("session_id") {
|
||||
return Some((EVENT_SESSION_STATE, payload));
|
||||
}
|
||||
log::warn!("Ignoring remote-session frame with no session: {kind}");
|
||||
None
|
||||
}
|
||||
|
||||
/// Delay before reconnect attempt `attempt`, doubling to a ceiling.
|
||||
pub fn reconnect_delay(attempt: u32) -> Duration {
|
||||
let factor = 1u64.checked_shl(attempt.min(16)).unwrap_or(u64::MAX);
|
||||
RECONNECT_BASE
|
||||
.saturating_mul(factor.min(u32::MAX as u64) as u32)
|
||||
.min(RECONNECT_MAX)
|
||||
}
|
||||
|
||||
/// Start receiving session transitions. Idempotent: a second call while the
|
||||
/// stream is up is a no-op rather than a second socket.
|
||||
pub fn start_session_events(app: AppHandle) {
|
||||
if STREAM_RUNNING.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
run_session_events(app).await;
|
||||
});
|
||||
if let Ok(mut slot) = STREAM_TASK.lock() {
|
||||
*slot = Some(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop receiving. Safe to call when nothing is running.
|
||||
pub fn stop_session_events() {
|
||||
if !STREAM_RUNNING.swap(false, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut slot) = STREAM_TASK.lock() {
|
||||
if let Some(handle) = slot.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the subscriber task is alive.
|
||||
pub fn session_events_running() -> bool {
|
||||
STREAM_RUNNING.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
async fn run_session_events(app: AppHandle) {
|
||||
let mut attempt = 0u32;
|
||||
// Echoed back on reconnect as `Last-Event-ID`, per the SSE spec, IF the
|
||||
// backend ever labels its frames. It does not today — `stream()` emits no
|
||||
// `id:` line and keeps no replay buffer — so this stays `None` and nothing is
|
||||
// resumed. What bounds the loss instead is the stream opening with a full
|
||||
// snapshot, which re-states every session the caller still owns.
|
||||
let mut last_event_id: Option<String> = None;
|
||||
|
||||
while STREAM_RUNNING.load(Ordering::SeqCst) {
|
||||
match connect_session_events(last_event_id.as_deref()).await {
|
||||
Ok(response) => {
|
||||
attempt = 0;
|
||||
emit_stream_status(&app, true, None);
|
||||
match consume_session_events(&app, response, &mut last_event_id).await {
|
||||
Ok(()) => {
|
||||
log::info!("Remote session stream closed by the backend");
|
||||
emit_stream_status(&app, false, None);
|
||||
}
|
||||
Err(reason) => {
|
||||
log::warn!("Remote session stream ended: {reason}");
|
||||
emit_stream_status(&app, false, Some(&reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let reason = err.to_string();
|
||||
if matches!(err, RemoteSessionError::NotAuthorised(_)) {
|
||||
attempt = attempt.max(AUTH_BACKOFF_ATTEMPT);
|
||||
}
|
||||
log::warn!("Remote session stream could not connect: {reason}");
|
||||
emit_stream_status(&app, false, Some(&reason));
|
||||
}
|
||||
}
|
||||
|
||||
if !STREAM_RUNNING.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let delay = jittered(reconnect_delay(attempt));
|
||||
attempt = attempt.saturating_add(1);
|
||||
sleep_unless_stopped(delay).await;
|
||||
}
|
||||
|
||||
log::info!("Remote session stream stopped");
|
||||
}
|
||||
|
||||
/// Spread reconnects so every desktop that lost the same backend does not come
|
||||
/// back in the same millisecond.
|
||||
fn jittered(delay: Duration) -> Duration {
|
||||
use rand::RngExt;
|
||||
let factor = rand::rng().random_range(0.8f64..1.2f64);
|
||||
delay.mul_f64(factor)
|
||||
}
|
||||
|
||||
async fn sleep_unless_stopped(total: Duration) {
|
||||
let mut slept = Duration::ZERO;
|
||||
while slept < total && STREAM_RUNNING.load(Ordering::SeqCst) {
|
||||
let step = SHUTDOWN_POLL.min(total - slept);
|
||||
tokio::time::sleep(step).await;
|
||||
slept += step;
|
||||
}
|
||||
}
|
||||
|
||||
/// The stream's own HTTP client.
|
||||
///
|
||||
/// Deliberately not the shared one: a total request timeout would kill a
|
||||
/// healthy stream on schedule, so only the connect phase is bounded and
|
||||
/// liveness is enforced by the idle timeout instead.
|
||||
fn stream_client() -> &'static reqwest::Client {
|
||||
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
})
|
||||
}
|
||||
|
||||
async fn connect_session_events(
|
||||
last_event_id: Option<&str>,
|
||||
) -> Result<reqwest::Response, RemoteSessionError> {
|
||||
let endpoint = format!(
|
||||
"{}/api/remote-sessions/events",
|
||||
crate::cloud_auth::CLOUD_API_URL
|
||||
);
|
||||
|
||||
crate::cloud_auth::CLOUD_AUTH
|
||||
.api_call_with_retry(|token| {
|
||||
let endpoint = endpoint.clone();
|
||||
let resume_from = last_event_id.map(str::to_string);
|
||||
async move {
|
||||
// Going through api_call_with_retry means a token that expired during
|
||||
// a long stream is refreshed on the reconnect instead of turning a
|
||||
// signed-in desktop into a permanently silent one.
|
||||
let mut request = stream_client()
|
||||
.get(&endpoint)
|
||||
.bearer_auth(token)
|
||||
.header(reqwest::header::ACCEPT, "text/event-stream")
|
||||
.header(reqwest::header::CACHE_CONTROL, "no-cache");
|
||||
if let Some(id) = resume_from {
|
||||
request = request.header("Last-Event-ID", id);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("reach backend: {e}"))?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
if !(200..300).contains(&status) {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(format!("({status}) {text}"));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| classify_error_string(&e))
|
||||
}
|
||||
|
||||
async fn consume_session_events(
|
||||
app: &AppHandle,
|
||||
response: reqwest::Response,
|
||||
last_event_id: &mut Option<String>,
|
||||
) -> Result<(), String> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut decoder = SseDecoder::new();
|
||||
|
||||
loop {
|
||||
if !STREAM_RUNNING.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let next = tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await;
|
||||
let chunk = match next {
|
||||
// No heartbeat. The socket is gone even though nothing errored, which
|
||||
// is what a machine returning from sleep sees.
|
||||
Err(_) => return Err("no heartbeat within the idle timeout".to_string()),
|
||||
Ok(None) => return Ok(()),
|
||||
Ok(Some(Err(e))) => return Err(format!("stream error: {e}")),
|
||||
Ok(Some(Ok(bytes))) => bytes,
|
||||
};
|
||||
|
||||
for frame in decoder.push(&chunk) {
|
||||
if let Some(id) = &frame.id {
|
||||
*last_event_id = Some(id.clone());
|
||||
}
|
||||
dispatch_frame(app, &frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_frame(app: &AppHandle, frame: &SseFrame) {
|
||||
let Some((target, payload)) = route_frame(frame.event.as_deref(), &frame.data) else {
|
||||
return;
|
||||
};
|
||||
|
||||
use tauri::Emitter;
|
||||
if let Err(e) = app.emit(target, payload) {
|
||||
log::warn!("Failed to emit {target}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_stream_status(app: &AppHandle, connected: bool, reason: Option<&str>) {
|
||||
use tauri::Emitter;
|
||||
let payload = serde_json::json!({ "connected": connected, "reason": reason });
|
||||
if let Err(e) = app.emit(EVENT_STREAM_STATUS, payload) {
|
||||
log::warn!("Failed to emit {EVENT_STREAM_STATUS}: {e}");
|
||||
}
|
||||
RemoteSessionError::Other(message.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -306,4 +837,290 @@ mod tests {
|
||||
assert_ne!(a, idempotency_key("p1", "attempt-2"));
|
||||
assert_ne!(a, idempotency_key("p2", "attempt-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typed_failure_becomes_a_code_the_frontend_can_translate() {
|
||||
// The variants carry the backend's English. Surfacing that verbatim is
|
||||
// how an untranslated string reaches a Russian user.
|
||||
let busy = RemoteSessionError::NoCapacity("no macos host free".to_string());
|
||||
assert_eq!(busy.to_error_json(), r#"{"code":"REMOTE_NO_CAPACITY"}"#);
|
||||
|
||||
let taken = RemoteSessionError::Conflict("profile already has a live session".to_string());
|
||||
assert_eq!(
|
||||
taken.to_error_json(),
|
||||
r#"{"code":"REMOTE_SESSION_CONFLICT"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backend_supplied_code_survives_the_trip_through_the_typed_error() {
|
||||
// Once infra sends an envelope, its code must win over the status default
|
||||
// — "you are out of hours" and "the fleet is full" are both refusals but
|
||||
// only one of them is worth retrying.
|
||||
let err =
|
||||
classify_error_string(r#"(403) {"code":"REMOTE_HOURS_EXHAUSTED","granted":200,"used":200}"#);
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&err.to_error_json()).expect("valid envelope");
|
||||
assert_eq!(json["code"], "REMOTE_HOURS_EXHAUSTED");
|
||||
assert_eq!(json["params"]["granted"], "200");
|
||||
}
|
||||
|
||||
/// A verbatim `RemoteSessionView`, field for field, as `toView` in
|
||||
/// donutbrowser-infra's `remote-sessions.service.ts` builds it.
|
||||
///
|
||||
/// Hand-written JSON is what let this type declare `status`, `ready_at` and
|
||||
/// `closed_at` while the backend sent `state` and `ended_at`: the test agreed
|
||||
/// with the type and neither agreed with the server, so every list and single
|
||||
/// read failed to decode in production and passed in CI.
|
||||
const SERVER_SESSION_VIEW: &str = r#"{
|
||||
"session_id":"sess-1","profile_id":"p1","platform":"macos","kind":"cookie_bot",
|
||||
"run_id":"r1","team_id":"t1","state":"live","cdp_ready":true,
|
||||
"started_at":"2026-08-03T00:00:00.000Z","ended_at":null,
|
||||
"billed_seconds":1830,"close_reason":null
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn the_session_state_payload_matches_what_the_backend_sends() {
|
||||
// The desktop has been blind between launch and stop; every field here is
|
||||
// one it could previously only learn by reading the production database.
|
||||
let state: RemoteSessionState = serde_json::from_str(SERVER_SESSION_VIEW)
|
||||
.expect("the backend's session payload must deserialize");
|
||||
|
||||
assert_eq!(state.state, "live");
|
||||
assert!(state.cdp_ready);
|
||||
assert_eq!(state.run_id.as_deref(), Some("r1"));
|
||||
assert_eq!(state.team_id.as_deref(), Some("t1"));
|
||||
assert_eq!(state.kind.as_deref(), Some("cookie_bot"));
|
||||
assert_eq!(state.billed_seconds, Some(1830));
|
||||
assert!(state.ended_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_response_of_real_server_views_decodes() {
|
||||
// `list_remote_sessions` is the fallback for everything the stream cannot
|
||||
// serve. It returned Err("decode response: missing field `status`") on
|
||||
// every call for as long as this type disagreed with `toView`.
|
||||
let body = format!(r#"{{"sessions":[{SERVER_SESSION_VIEW}]}}"#);
|
||||
let response: RemoteSessionListResponse =
|
||||
serde_json::from_str(&body).expect("the backend's list payload must deserialize");
|
||||
assert_eq!(response.sessions.len(), 1);
|
||||
assert_eq!(response.sessions[0].state, "live");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_older_status_key_from_the_launch_reply_still_decodes() {
|
||||
// `POST /api/remote-sessions` predates the reconciled vocabulary and
|
||||
// answers `status`. One type reads both rather than two types drifting.
|
||||
let state: RemoteSessionState =
|
||||
serde_json::from_str(r#"{"session_id":"s1","status":"provisioning"}"#)
|
||||
.expect("a fresh session must deserialize");
|
||||
assert_eq!(state.state, "provisioning");
|
||||
assert!(!state.cdp_ready);
|
||||
assert!(state.platform.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_transition_carries_what_the_session_cost() {
|
||||
let state: RemoteSessionState = serde_json::from_str(
|
||||
r#"{"session_id":"s1","state":"closed","close_reason":"stopped_by_user",
|
||||
"ended_at":"2026-08-03T01:30:00.000Z","billed_seconds":1830}"#,
|
||||
)
|
||||
.expect("a close payload must deserialize");
|
||||
assert_eq!(state.billed_seconds, Some(1830));
|
||||
assert_eq!(state.close_reason.as_deref(), Some("stopped_by_user"));
|
||||
assert!(state.ended_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_state_decodes_rather_than_being_treated_as_unknown() {
|
||||
// `error` is one of the five states the backend reconciles to. A session
|
||||
// that failed on the fleet must reach the UI as itself.
|
||||
let state: RemoteSessionState =
|
||||
serde_json::from_str(r#"{"session_id":"s1","state":"error","close_reason":"agent_lost"}"#)
|
||||
.expect("an error payload must deserialize");
|
||||
assert_eq!(state.state, "error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_decoder_reads_a_whole_frame() {
|
||||
let mut decoder = SseDecoder::new();
|
||||
let frames = decoder.push(b"event: session\nid: 7\ndata: {\"status\":\"ready\"}\n\n");
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].event.as_deref(), Some("session"));
|
||||
assert_eq!(frames[0].id.as_deref(), Some("7"));
|
||||
assert_eq!(frames[0].data, r#"{"status":"ready"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_split_across_chunks_is_not_lost() {
|
||||
// TCP does not respect frame boundaries. Dropping a half-arrived frame
|
||||
// would silently lose the transition that says the browser is ready.
|
||||
let mut decoder = SseDecoder::new();
|
||||
assert!(decoder.push(b"event: session\ndata: {\"sta").is_empty());
|
||||
assert!(decoder.push(b"tus\":\"live\"}").is_empty());
|
||||
let frames = decoder.push(b"\n\n");
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, r#"{"status":"live"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_frames_in_one_chunk_all_arrive() {
|
||||
let mut decoder = SseDecoder::new();
|
||||
let frames = decoder.push(b"data: 1\n\ndata: 2\n\ndata: 3\n\n");
|
||||
let payloads: Vec<&str> = frames.iter().map(|f| f.data.as_str()).collect();
|
||||
assert_eq!(payloads, vec!["1", "2", "3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_and_crlf_framing_do_not_produce_phantom_events() {
|
||||
// A proxy that keeps the connection warm with `:` lines must not look
|
||||
// like a stream of empty transitions.
|
||||
let mut decoder = SseDecoder::new();
|
||||
let frames = decoder.push(b": keep-alive\r\n\r\ndata: {}\r\n\r\n");
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_line_data_is_rejoined_with_newlines() {
|
||||
let mut decoder = SseDecoder::new();
|
||||
let frames = decoder.push(b"data: {\ndata: \"a\": 1\ndata: }\n\n");
|
||||
assert_eq!(frames[0].data, "{\n\"a\": 1\n}");
|
||||
}
|
||||
|
||||
/// Route whatever the decoder makes of a literal wire capture, so the test
|
||||
/// exercises the same two steps production does.
|
||||
fn route_wire(bytes: &[u8]) -> Vec<(&'static str, serde_json::Value)> {
|
||||
let mut decoder = SseDecoder::new();
|
||||
decoder
|
||||
.push(bytes)
|
||||
.iter()
|
||||
.filter_map(|frame| route_frame(frame.event.as_deref(), &frame.data))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_heartbeat_is_not_forwarded_to_the_frontend() {
|
||||
// Emitting one would make every consumer re-render twice a minute for
|
||||
// nothing. Nest names this one, so it arrives with an `event:` line.
|
||||
assert!(route_wire(b"event: ping\ndata: {}\n\n").is_empty());
|
||||
assert!(route_wire(b"event: heartbeat\ndata: {}\n\n").is_empty());
|
||||
// And the same frame with the discriminator inside the JSON instead.
|
||||
assert!(
|
||||
route_wire(b"data: {\"type\":\"ping\",\"at\":\"2026-08-03T00:00:00.000Z\"}\n\n").is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_opening_snapshot_reaches_the_snapshot_event() {
|
||||
// Byte-for-byte what Nest writes for `{type:'snapshot',at,sessions}`: no
|
||||
// `event:` line, because the controller only sets MessageEvent.type for the
|
||||
// ping. Routing on the event NAME sent this to `remote-session-state` as a
|
||||
// raw envelope, so `remote-session-snapshot` was never emitted at all and
|
||||
// the live view started empty and stayed empty.
|
||||
let routed = route_wire(
|
||||
b"data: {\"type\":\"snapshot\",\"at\":\"2026-08-03T00:00:00.000Z\",\"sessions\":[{\"session_id\":\"s1\",\"profile_id\":\"p1\",\"state\":\"live\"}]}\n\n",
|
||||
);
|
||||
assert_eq!(routed.len(), 1);
|
||||
assert_eq!(routed[0].0, EVENT_SESSION_SNAPSHOT);
|
||||
assert_eq!(routed[0].1["sessions"][0]["profile_id"], "p1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transition_is_unwrapped_to_the_session_the_frontend_indexes_by() {
|
||||
// The consumer keys `liveSessions` by `profile_id`. Emitting the envelope
|
||||
// meant every frame hit `if (!session.profile_id) return;` and a live run
|
||||
// showed as idle for its whole duration.
|
||||
for kind in ["state", "progress", "closed"] {
|
||||
let wire = format!(
|
||||
"data: {{\"type\":\"{kind}\",\"at\":\"2026-08-03T00:00:00.000Z\",\"session\":{{\"session_id\":\"s1\",\"profile_id\":\"p1\",\"state\":\"live\",\"cdp_ready\":true}}}}\n\n"
|
||||
);
|
||||
let routed = route_wire(wire.as_bytes());
|
||||
assert_eq!(routed.len(), 1, "{kind} must produce one event");
|
||||
assert_eq!(routed[0].0, EVENT_SESSION_STATE);
|
||||
assert_eq!(routed[0].1["profile_id"], "p1", "{kind} must be unwrapped");
|
||||
assert_eq!(routed[0].1["state"], "live");
|
||||
// And the payload must deserialize as the type the one-shot reads use.
|
||||
let session: RemoteSessionState = serde_json::from_value(routed[0].1.clone())
|
||||
.expect("a streamed session must decode as RemoteSessionState");
|
||||
assert_eq!(session.session_id, "s1");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_event_carrying_a_bare_session_is_still_routed() {
|
||||
// If the backend starts labelling its frames and drops the envelope, the
|
||||
// desktop must not need a release to keep working.
|
||||
let routed = route_wire(
|
||||
b"event: state\ndata: {\"session_id\":\"s1\",\"profile_id\":\"p1\",\"state\":\"ready\"}\n\n",
|
||||
);
|
||||
assert_eq!(routed.len(), 1);
|
||||
assert_eq!(routed[0].0, EVENT_SESSION_STATE);
|
||||
assert_eq!(routed[0].1["profile_id"], "p1");
|
||||
|
||||
let snapshot =
|
||||
route_wire(b"event: snapshot\ndata: {\"type\":\"snapshot\",\"sessions\":[]}\n\n");
|
||||
assert_eq!(snapshot[0].0, EVENT_SESSION_SNAPSHOT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_carrying_no_session_is_dropped_rather_than_emitted_raw() {
|
||||
// Forwarding an envelope the consumer cannot apply is exactly the bug this
|
||||
// routing exists to close; a malformed frame must be silent, not wrong.
|
||||
assert!(
|
||||
route_wire(b"data: {\"type\":\"state\",\"at\":\"2026-08-03T00:00:00.000Z\"}\n\n").is_empty()
|
||||
);
|
||||
assert!(route_wire(b"data: not json\n\n").is_empty());
|
||||
assert!(route_wire(b"data: []\n\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_backs_off_and_stops_growing() {
|
||||
assert_eq!(reconnect_delay(0), Duration::from_secs(1));
|
||||
assert_eq!(reconnect_delay(3), Duration::from_secs(8));
|
||||
// A backend that is down for an hour must not be probed thousands of
|
||||
// times, nor overflow the shift.
|
||||
assert_eq!(reconnect_delay(20), RECONNECT_MAX);
|
||||
assert_eq!(reconnect_delay(u32::MAX), RECONNECT_MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_auth_backoff_floor_is_far_longer_than_the_first_retry() {
|
||||
// Being signed out or unentitled is not fixed by retrying in a second.
|
||||
assert!(reconnect_delay(AUTH_BACKOFF_ATTEMPT) >= Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_stays_within_a_fifth_of_the_delay() {
|
||||
let base = Duration::from_secs(10);
|
||||
for _ in 0..64 {
|
||||
let delay = jittered(base);
|
||||
assert!(
|
||||
delay >= Duration::from_secs(8) && delay <= Duration::from_secs(12),
|
||||
"jitter escaped its bounds: {delay:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stopped_stream_does_not_wait_out_its_backoff() {
|
||||
// A minute-long backoff must not hold up app shutdown.
|
||||
STREAM_RUNNING.store(true, Ordering::SeqCst);
|
||||
let started = std::time::Instant::now();
|
||||
let sleeper = tokio::spawn(sleep_unless_stopped(Duration::from_secs(60)));
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
STREAM_RUNNING.store(false, Ordering::SeqCst);
|
||||
sleeper.await.expect("the sleep task must finish");
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(5),
|
||||
"shutdown waited on the backoff"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_a_stream_that_never_started_is_harmless() {
|
||||
STREAM_RUNNING.store(false, Ordering::SeqCst);
|
||||
stop_session_events();
|
||||
assert!(!session_events_running());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3321,6 +3321,12 @@ pub async fn set_profile_sync_mode(
|
||||
.save_profile(&profile)
|
||||
.map_err(|e| format!("Failed to save profile: {e}"))?;
|
||||
|
||||
// The bot materialises the profile from donut-sync, so switching sync off (or
|
||||
// to Encrypted, which the host cannot decrypt) is a refusal reason. The server
|
||||
// holds only the copy this machine declared; without this, an enrolment keeps
|
||||
// claiming a syncable profile every night after the user turned sync off.
|
||||
crate::cookie_bot::report_profile_state(&profile);
|
||||
|
||||
let _ = events::emit("profiles-changed", ());
|
||||
|
||||
// When (re-)enabling sync, clear any stale tombstone from a previous
|
||||
|
||||
@@ -72,6 +72,24 @@ impl SyncScheduler {
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Whether this specific profile is mid-sync or queued to sync.
|
||||
///
|
||||
/// A remote host materialises the profile by pulling the synced manifest, so
|
||||
/// launching one while the upload is still running hands it a torn snapshot:
|
||||
/// the manifest is written last, but a launch that races a *queued* sync can
|
||||
/// still pull files that are about to be replaced. Either way the browser
|
||||
/// comes up on a profile that never existed on this machine.
|
||||
///
|
||||
/// Deliberately per-profile rather than the global
|
||||
/// {@link Self::is_sync_in_progress}: an unrelated profile uploading 80 MB
|
||||
/// must not block launching this one.
|
||||
pub async fn is_profile_sync_in_progress(&self, profile_id: &str) -> bool {
|
||||
if self.in_flight_profiles.lock().await.contains(profile_id) {
|
||||
return true;
|
||||
}
|
||||
self.pending_profiles.lock().await.contains_key(profile_id)
|
||||
}
|
||||
|
||||
/// Check if any sync operation is currently in progress
|
||||
pub async fn is_sync_in_progress(&self) -> bool {
|
||||
let in_flight = self.in_flight_profiles.lock().await;
|
||||
|
||||
Reference in New Issue
Block a user