From 7d82a25107f36b73e77d26f58b7d5d44aceb899f Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:57:45 +0400 Subject: [PATCH] feat: cookie bot --- e2e/coverage-map.mjs | 29 + e2e/tests/integrations.test.mjs | 110 ++ package.json | 3 +- src-tauri/src/api_server.rs | 1394 ++++++++++++++++++- src-tauri/src/cloud_auth.rs | 18 + src-tauri/src/cloud_errors.rs | 438 ++++++ src-tauri/src/cookie_bot.rs | 1423 ++++++++++++++++++++ src-tauri/src/lib.rs | 294 ++++ src-tauri/src/mcp_server.rs | 823 ++++++++++- src-tauri/src/profile/manager.rs | 10 + src-tauri/src/remote_session.rs | 827 +++++++++++- src-tauri/src/sync/engine.rs | 6 + src-tauri/src/sync/scheduler.rs | 18 + src/app/page.tsx | 50 +- src/components/account-page.tsx | 102 +- src/components/command-palette.tsx | 2 + src/components/cookie-bot-activity.tsx | 583 ++++++++ src/components/cookie-bot-enrol-dialog.tsx | 923 +++++++++++++ src/components/cookie-bot-overview.tsx | 575 ++++++++ src/components/cookie-bot-page.tsx | 604 +++++++++ src/components/cookie-bot-runs-dialog.tsx | 237 ++++ src/components/cookie-bot-schedule.tsx | 264 ++++ src/components/cookie-bot-shared.tsx | 821 +++++++++++ src/components/profile-data-table.tsx | 594 +++++++- src/components/profile-info-dialog.tsx | 3 +- src/components/rail-nav.tsx | 22 +- src/components/team-usage-panel.tsx | 470 +++++++ src/hooks/use-cookie-bot.ts | 306 +++++ src/i18n/locales/en.json | 331 ++++- src/i18n/locales/es.json | 358 ++++- src/i18n/locales/fr.json | 358 ++++- src/i18n/locales/ja.json | 331 ++++- src/i18n/locales/ko.json | 331 ++++- src/i18n/locales/pt.json | 358 ++++- src/i18n/locales/ru.json | 385 +++++- src/i18n/locales/tr.json | 331 ++++- src/i18n/locales/vi.json | 335 ++++- src/i18n/locales/zh.json | 331 ++++- src/lib/backend-errors.ts | 113 +- src/lib/cookie-bot-limits.test.mjs | 65 + src/lib/cookie-bot-limits.ts | 32 + src/lib/cookie-bot.ts | 337 +++++ src/lib/entitlements.ts | 48 +- src/lib/remote-sessions.ts | 143 ++ src/lib/shortcuts.ts | 9 + src/types.ts | 62 +- 46 files changed, 15059 insertions(+), 148 deletions(-) create mode 100644 src-tauri/src/cloud_errors.rs create mode 100644 src-tauri/src/cookie_bot.rs create mode 100644 src/components/cookie-bot-activity.tsx create mode 100644 src/components/cookie-bot-enrol-dialog.tsx create mode 100644 src/components/cookie-bot-overview.tsx create mode 100644 src/components/cookie-bot-page.tsx create mode 100644 src/components/cookie-bot-runs-dialog.tsx create mode 100644 src/components/cookie-bot-schedule.tsx create mode 100644 src/components/cookie-bot-shared.tsx create mode 100644 src/components/team-usage-panel.tsx create mode 100644 src/hooks/use-cookie-bot.ts create mode 100644 src/lib/cookie-bot-limits.test.mjs create mode 100644 src/lib/cookie-bot-limits.ts create mode 100644 src/lib/cookie-bot.ts create mode 100644 src/lib/remote-sessions.ts diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 74cb779..1628f0e 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -248,6 +248,35 @@ export const commandCoverage = { "team_lock::get_team_lock_status", ], }, + remoteSessions: { + suite: "integrations", + level: "contract", + commands: [ + "list_remote_sessions", + "get_remote_session", + "stop_remote_session", + "start_remote_session_events", + "stop_remote_session_events", + "get_remote_session_events_status", + ], + }, + cookieBot: { + suite: "integrations", + level: "contract", + 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", + ], + }, updateContracts: { suite: "integrations", level: "contract", diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs index c741d64..c465188 100644 --- a/e2e/tests/integrations.test.mjs +++ b/e2e/tests/integrations.test.mjs @@ -652,6 +652,116 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de assert.ok(versionStatus && typeof versionStatus === "object"); assert.equal(typeof (await app.invoke("is_default_browser")), "boolean"); + // Remote sessions and the cookie bot are brokered by the cloud backend. + // Signed out, every one of them must fail as a code the UI can + // translate — a raw English string from the transport would reach the + // user untranslated, which is what the {"code":…} convention prevents. + const notSignedIn = /"code":"CLOUD_NOT_SIGNED_IN"/; + const missingProfileId = "00000000-0000-0000-0000-0000000000ff"; + assert.match(await app.invokeError("list_remote_sessions"), notSignedIn); + assert.match( + await app.invokeError("get_remote_session", { + sessionId: "missing-e2e-session", + }), + notSignedIn, + ); + assert.match( + await app.invokeError("stop_remote_session", { + sessionId: "missing-e2e-session", + }), + notSignedIn, + ); + + // The transition stream is what the desktop uses instead of polling, so + // its subscriber has to start, report itself, and stop on demand. Both + // calls are repeated: a second start must not open a second socket, and + // a second stop must not fail. + assert.equal(await app.invoke("get_remote_session_events_status"), false); + await app.invoke("start_remote_session_events"); + assert.equal(await app.invoke("get_remote_session_events_status"), true); + await app.invoke("start_remote_session_events"); + assert.equal(await app.invoke("get_remote_session_events_status"), true); + await app.invoke("stop_remote_session_events"); + assert.equal(await app.invoke("get_remote_session_events_status"), false); + await app.invoke("stop_remote_session_events"); + assert.equal(await app.invoke("get_remote_session_events_status"), false); + + assert.match( + await app.invokeError("get_cookie_bot_schedules", { scope: "mine" }), + notSignedIn, + ); + assert.match( + await app.invokeError("get_cookie_bot_schedule", { + profileId: missingProfileId, + }), + notSignedIn, + ); + assert.match( + await app.invokeError("delete_cookie_bot_schedule", { + profileId: missingProfileId, + }), + notSignedIn, + ); + assert.match( + await app.invokeError("check_cookie_bot_conflicts", { + profileId: missingProfileId, + runAtMinute: 120, + daysMask: 127, + }), + notSignedIn, + ); + assert.match( + await app.invokeError("get_cookie_bot_runs", { limit: 10 }), + notSignedIn, + ); + assert.match( + await app.invokeError("cancel_cookie_bot_run", { + runId: "missing-e2e-run", + }), + notSignedIn, + ); + assert.match( + await app.invokeError("get_cookie_bot_presets"), + notSignedIn, + ); + assert.match( + await app.invokeError("get_remote_hours_quota"), + notSignedIn, + ); + assert.match( + await app.invokeError("get_cookie_bot_usage", { period: "2026-01" }), + notSignedIn, + ); + + // Enrolling and running act on a profile this machine holds: both are + // refused before any network call when it does not exist, so a bad id + // can never reach a leased host or an hour of the pooled budget. + assert.match( + await app.invokeError("save_cookie_bot_schedule", { + profileId: missingProfileId, + schedule: { + profile_name: "E2E missing profile", + platform: "windows", + enabled: true, + run_at_minute: 120, + days_mask: 127, + timezone: "UTC", + preset: "balanced", + max_minutes: 60, + sites: ["https://example.com"], + }, + acknowledgeConflict: false, + }), + /"code":"PROFILE_NOT_FOUND"/, + ); + assert.match( + await app.invokeError("run_cookie_bot_now", { + profileId: missingProfileId, + maxMinutes: 30, + }), + /"code":"PROFILE_NOT_FOUND"/, + ); + const trial = await app.invoke("get_commercial_trial_status"); assert.ok(trial && typeof trial === "object"); await app.invoke("acknowledge_trial_expiration"); diff --git a/package.json b/package.json index 16fa3d3..686c3ac 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,9 @@ "prebuild": "pnpm licenses:generate", "build": "next build", "start": "next start", - "test": "pnpm test:themes && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", + "test": "pnpm test:themes && pnpm test:cookie-bot-limits && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", "test:themes": "node --test src/lib/themes.test.mjs", + "test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs", "test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check", "test:xray-packaging": "node --test src-tauri/download-xray.test.mjs", "licenses:generate": "node scripts/generate-licenses.mjs", diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 31017be..166459a 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -33,7 +33,6 @@ pub struct ApiProfile { pub process_id: Option, pub last_launch: Option, pub release_type: String, - #[schema(value_type = Object)] pub group_id: Option, pub tags: Vec, pub is_running: bool, @@ -302,6 +301,86 @@ pub struct StopRemoteResponse { pub billed_seconds: u64, } +/// Every remote session the signed-in account currently owns. +/// +/// `run-remote` hands back a session id and the literal string `provisioning`; +/// without a way to read the real state back, an automation client can only +/// discover that a session became usable by trying to drive it. +#[derive(Debug, Serialize, ToSchema)] +struct ApiRemoteSessionsResponse { + sessions: Vec, +} + +/// Enrol a profile in the nightly cookie bot, or replace its enrolment. +/// +/// `platform` and `profile_name` are optional because this machine already +/// knows both: the platform is the profile's own operating system, and a +/// caller-supplied one that disagrees is a mistake, not a choice. +#[derive(Debug, Deserialize, ToSchema)] +struct SetCookieBotScheduleRequest { + /// Defaults to the profile's local name. + profile_name: Option, + /// `windows` or `macos`. Defaults to the profile's own operating system, and + /// must match it when supplied. + platform: Option, + /// Whether the nightly run is armed. A disabled schedule keeps its settings. + enabled: bool, + /// Minutes past local midnight the run is anchored to (0..1439). + run_at_minute: u16, + /// Bitmask of local weekdays, bit 0 = Monday (1..127). + days_mask: u8, + /// IANA zone the run time is expressed in. + timezone: String, + /// Server-issued preset id from `GET /v1/cookie-bot/presets`. + preset: String, + /// Upper bound on one run, in minutes. + max_minutes: u32, + /// Absolute http(s) URLs to browse. The bot visits only these. + #[serde(default)] + sites: Vec, + /// Random spread around the anchor time, in seconds. + jitter_seconds: Option, + /// Write anyway when a teammate already enrols this profile. Without it, a + /// colliding write is refused with 409 and the teammate's details. + #[serde(default)] + acknowledge_conflict: bool, +} + +#[derive(Debug, Deserialize, ToSchema)] +struct StartCookieBotRunRequest { + /// Profile to warm. It must already have a schedule: the preset and the site + /// list live there, so a run never carries a behaviour of its own. + profile_id: String, + /// Overrides the schedule's own cap for this run only. + max_minutes: Option, +} + +#[derive(Debug, Deserialize)] +struct CookieBotScopeQuery { + scope: Option, +} + +#[derive(Debug, Deserialize)] +struct CookieBotConflictsQuery { + profile_id: String, + run_at_minute: Option, + timezone: Option, + days_mask: Option, +} + +#[derive(Debug, Deserialize)] +struct CookieBotRunsQuery { + profile_id: Option, + scope: Option, + limit: Option, + before: Option, +} + +#[derive(Debug, Deserialize)] +struct CookieBotUsageQuery { + period: Option, +} + #[derive(Debug, Deserialize, ToSchema)] struct RunProfileRequest { url: Option, @@ -428,6 +507,22 @@ struct ImportProxiesResponse { update_profile, delete_profile, run_profile, + run_profile_remote, + stop_remote_session, + list_remote_sessions_api, + get_remote_session_api, + get_remote_hours, + set_profile_cloud_sync, + list_cookie_bot_schedules, + get_cookie_bot_schedule, + set_cookie_bot_schedule, + delete_cookie_bot_schedule, + get_cookie_bot_conflicts, + list_cookie_bot_runs, + start_cookie_bot_run, + cancel_cookie_bot_run, + list_cookie_bot_presets, + get_cookie_bot_usage, open_url_in_profile, kill_profile, batch_run_profiles, @@ -484,6 +579,30 @@ struct ImportProxiesResponse { RunProfileResponse, RunRemoteRequest, RunRemoteResponse, + StopRemoteResponse, + SetCloudSyncRequest, + SetCloudSyncResponse, + ApiRemoteSessionsResponse, + SetCookieBotScheduleRequest, + StartCookieBotRunRequest, + crate::remote_session::RemoteSessionState, + crate::cookie_bot::CookieBotSchedule, + crate::cookie_bot::CookieBotScheduleList, + crate::cookie_bot::CookieBotScheduleSaved, + crate::cookie_bot::CookieBotScheduleDeleted, + crate::cookie_bot::CookieBotConflict, + crate::cookie_bot::CookieBotConflictCheck, + crate::cookie_bot::CookieBotRun, + crate::cookie_bot::CookieBotRunPage, + crate::cookie_bot::CookieBotRunStarted, + crate::cookie_bot::CookieBotPreset, + crate::cookie_bot::CookieBotPresetList, + crate::cookie_bot::CookieBotUsage, + crate::cookie_bot::CookieBotUsageMember, + crate::cookie_bot::CookieBotUsageProfile, + crate::cookie_bot::RemoteHoursQuota, + crate::cookie_bot::RemoteHoursMember, + crate::cookie_bot::RemoteHoursBreakdown, RunProfileRequest, BatchRunRequest, BatchRunResult, @@ -514,6 +633,8 @@ struct ImportProxiesResponse { (name = "extensions", description = "Extension management endpoints"), (name = "browsers", description = "Browser management endpoints"), (name = "cookies", description = "Cookie management endpoints"), + (name = "remote-sessions", description = "Sessions running on the leased remote fleet"), + (name = "cookie-bot", description = "Scheduled cookie-warming runs on the remote fleet"), ), modifiers(&SecurityAddon), )] @@ -595,39 +716,7 @@ impl ApiServer { .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? .port(); - // Create router with OpenAPI documentation - let (v1_routes, _) = OpenApiRouter::new() - .routes(routes!(get_profiles, create_profile)) - .routes(routes!(get_profile, update_profile, delete_profile)) - .routes(routes!(run_profile)) - .routes(routes!(run_profile_remote)) - .routes(routes!(stop_remote_session)) - .routes(routes!(set_profile_cloud_sync)) - .routes(routes!(open_url_in_profile)) - .routes(routes!(kill_profile)) - .routes(routes!(batch_run_profiles)) - .routes(routes!(batch_stop_profiles)) - .routes(routes!(detect_import_profiles)) - .routes(routes!(import_profiles_api)) - .routes(routes!(import_profile_cookies)) - .routes(routes!(get_groups, create_group)) - .routes(routes!(get_group, update_group, delete_group)) - .routes(routes!(get_tags)) - .routes(routes!(get_proxies, create_proxy)) - .routes(routes!(import_proxies_api)) - .routes(routes!(get_proxy, update_proxy, delete_proxy)) - .routes(routes!(get_vpns, create_vpn)) - .routes(routes!(import_vpn)) - .routes(routes!(export_vpn)) - .routes(routes!(get_vpn, update_vpn, delete_vpn)) - .routes(routes!(get_extensions)) - .routes(routes!(delete_extension_api)) - .routes(routes!(get_extension_groups)) - .routes(routes!(delete_extension_group_api)) - .routes(routes!(download_browser_api)) - .routes(routes!(get_browser_versions)) - .routes(routes!(check_browser_downloaded)) - .split_for_parts(); + let v1_routes = build_v1_router(); let api = ApiDoc::openapi(); @@ -685,6 +774,68 @@ impl ApiServer { } } +/// Register every `/v1` handler. +/// +/// Pulled out of `start` so a test can build it. Axum panics when two handlers +/// claim the same path, and until this was callable the only thing that +/// exercised it was starting the real server — a conflict introduced here +/// would have shipped as an app that dies the moment the API is switched on. +/// +/// The OpenAPI half of `split_for_parts` is discarded on purpose: the served +/// spec comes from the hand-maintained `ApiDoc`, which is why +/// `openapi_spec_covers_registered_routes` exists. +fn build_v1_router() -> Router { + let (routes, _) = OpenApiRouter::new() + .routes(routes!(get_profiles, create_profile)) + .routes(routes!(get_profile, update_profile, delete_profile)) + .routes(routes!(run_profile)) + .routes(routes!(run_profile_remote)) + // One `routes!` per PATH, not per handler: the GET and the DELETE share + // `/v1/remote-sessions/{id}`, and registering them separately would have + // the second overwrite the first. + .routes(routes!(get_remote_session_api, stop_remote_session)) + .routes(routes!(list_remote_sessions_api)) + .routes(routes!(get_remote_hours)) + .routes(routes!(set_profile_cloud_sync)) + .routes(routes!(list_cookie_bot_schedules)) + .routes(routes!( + get_cookie_bot_schedule, + set_cookie_bot_schedule, + delete_cookie_bot_schedule + )) + .routes(routes!(get_cookie_bot_conflicts)) + .routes(routes!(list_cookie_bot_runs, start_cookie_bot_run)) + .routes(routes!(cancel_cookie_bot_run)) + .routes(routes!(list_cookie_bot_presets)) + .routes(routes!(get_cookie_bot_usage)) + .routes(routes!(open_url_in_profile)) + .routes(routes!(kill_profile)) + .routes(routes!(batch_run_profiles)) + .routes(routes!(batch_stop_profiles)) + .routes(routes!(detect_import_profiles)) + .routes(routes!(import_profiles_api)) + .routes(routes!(import_profile_cookies)) + .routes(routes!(get_groups, create_group)) + .routes(routes!(get_group, update_group, delete_group)) + .routes(routes!(get_tags)) + .routes(routes!(get_proxies, create_proxy)) + .routes(routes!(import_proxies_api)) + .routes(routes!(get_proxy, update_proxy, delete_proxy)) + .routes(routes!(get_vpns, create_vpn)) + .routes(routes!(import_vpn)) + .routes(routes!(export_vpn)) + .routes(routes!(get_vpn, update_vpn, delete_vpn)) + .routes(routes!(get_extensions)) + .routes(routes!(delete_extension_api)) + .routes(routes!(get_extension_groups)) + .routes(routes!(delete_extension_group_api)) + .routes(routes!(download_browser_api)) + .routes(routes!(get_browser_versions)) + .routes(routes!(check_browser_downloaded)) + .split_for_parts(); + routes +} + // Terms and Conditions check middleware async fn terms_check_middleware( request: axum::extract::Request, @@ -786,11 +937,43 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next) } fn is_automation_request(method: &Method, path: &str) -> bool { + // Ending a remote session is the one automation action that is not a POST. + // Its handler declares a 429, which could never fire while this function + // returned early for every non-POST method. + // + // Cancelling a cookie-bot run joins it: both reach across to the fleet, and + // treating one stop as metered and the other as free would be arbitrary. + // Note that the desktop's own stop button goes through a Tauri command, not + // this server, so a human can always stop a run the limiter has cut off. + if method == Method::DELETE { + let mut segments = match path + .strip_prefix("/v1/remote-sessions/") + .or_else(|| path.strip_prefix("/v1/cookie-bot/runs/")) + { + Some(rest) => rest.split('/'), + None => return false, + }; + return matches!((segments.next(), segments.next()), (Some(id), None) if !id.is_empty()); + } + if method != Method::POST { return false; } - if matches!(path, "/v1/profiles/batch/run" | "/v1/profiles/batch/stop") { + // Starting a bot run leases a host for up to two hours and spends the + // account's pooled remote-hour budget, which makes it the single most + // expensive thing this API can be asked to do. + // + // Deliberately NOT here: the cookie-bot schedule writes (PUT and DELETE on + // /v1/cookie-bot/schedules/{profile_id}). They are configuration — a small + // row in donutbrowser-infra — and lease nothing. Metering them would 429 a + // client enrolling a fleet of profiles at start-up, while the thing that + // actually protects the hardware, the pooled hour budget, is enforced + // server-side on every run whether or not it was scheduled from here. + if matches!( + path, + "/v1/profiles/batch/run" | "/v1/profiles/batch/stop" | "/v1/cookie-bot/runs" + ) { return true; } @@ -800,7 +983,13 @@ fn is_automation_request(method: &Method, path: &str) -> bool { let mut segments = profile_action.split('/'); matches!( (segments.next(), segments.next(), segments.next()), - (Some(_), Some("run" | "open-url" | "kill"), None) + // `run-remote` is a separate segment from `run`, so it matched nothing here + // and every remote launch bypassed the quota it declares a 429 for. + ( + Some(_), + Some("run" | "open-url" | "kill" | "run-remote"), + None + ) ) } @@ -2230,7 +2419,7 @@ async fn run_profile_remote( // The profile must exist in cloud storage before a remote host can open it — // the VM pulls it from donut-sync, and a profile that has never synced would // launch an empty browser and then push that emptiness back over the real one. - if let Err(reason) = remote_launch_precondition(profile) { + if let Err(reason) = remote_launch_precondition(profile).await { return Err((StatusCode::BAD_REQUEST, reason)); } @@ -2305,7 +2494,7 @@ async fn set_profile_cloud_sync( // Reported rather than left for the caller to discover at launch time: the // most common reason a caller enables sync is to run the profile remotely, // and Encrypted mode silently makes that impossible. - let blocked = remote_launch_precondition(profile).err(); + let blocked = remote_launch_precondition(profile).await.err(); Ok(Json(SetCloudSyncResponse { profile_id: profile.id.to_string(), mode, @@ -2334,10 +2523,35 @@ fn sync_mode_error_response(err: String) -> (StatusCode, String) { /// Whether a profile may be launched on a remote host. /// -/// Extracted so the rule is unit-testable without a running app: it is the one -/// gate between "the user asked" and "a browser opens somewhere else holding -/// their cookies". -pub fn remote_launch_precondition( +/// The one gate between "the user asked" and "a browser opens somewhere else +/// holding their cookies". Adds the live check the pure rules cannot make: a +/// launch that races this profile's own upload hands the host a torn snapshot. +pub async fn remote_launch_precondition( + profile: &crate::profile::types::BrowserProfile, +) -> Result<(), String> { + remote_launch_profile_rules(profile)?; + + // The manifest is written last, so a host pulling mid-upload gets files + // that are about to be replaced and a manifest that does not describe them. + // The browser then comes up on a profile that never existed on this machine + // and pushes it back over the real one. + if let Some(scheduler) = crate::sync::get_global_scheduler() { + if scheduler + .is_profile_sync_in_progress(&profile.id.to_string()) + .await + { + return Err(serde_json::json!({ "code": "REMOTE_SYNC_IN_PROGRESS" }).to_string()); + } + } + + Ok(()) +} + +/// The parts of the rule that depend only on the profile itself. +/// +/// Split out so the rules stay unit-testable without a running app or a sync +/// scheduler, and so the live check above cannot be reached without them. +pub fn remote_launch_profile_rules( profile: &crate::profile::types::BrowserProfile, ) -> Result<(), String> { if !profile.is_sync_enabled() { @@ -2416,6 +2630,615 @@ fn remote_session_error_response( } } +/// Map a read of remote state onto a status, and answer with a machine code. +/// +/// Separate from `remote_session_error_response` on purpose. That one serves +/// the LAUNCH path, whose documented contract is a plain-English diagnostic and +/// whose only interesting failures are "busy", "already open" and "not on your +/// plan". A read has a different failure set — chiefly "no such session", which +/// the launch mapping would report as a 500 — and it is new, so it can answer +/// with the `{"code":…}` envelope from the start instead of English a client +/// would have to pattern-match. +fn remote_session_read_response( + err: crate::remote_session::RemoteSessionError, +) -> (StatusCode, String) { + use crate::remote_session::RemoteSessionError; + let upstream = match &err { + RemoteSessionError::NoCapacity(_) => 503, + RemoteSessionError::Conflict(_) => 409, + RemoteSessionError::NotAuthorised(_) => 403, + // The status was consumed on the way in; the code is recovered from the + // backend's own envelope instead. + RemoteSessionError::Other(_) => 0, + }; + let body = err.to_error_json(); + let status = cloud_failure_status(upstream, &error_code_of(&body)); + (status, body) +} + +fn cookie_bot_error_response(err: crate::cookie_bot::CookieBotError) -> (StatusCode, String) { + let status = cloud_failure_status(err.status(), err.code()); + (status, err.to_error_json()) +} + +/// Read the machine code out of a `{"code":…}` body. +fn error_code_of(body: &str) -> String { + serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get("code") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .unwrap_or_default() +} + +/// Turn a donutbrowser-infra failure into the status a local client can act on. +/// +/// The upstream status is not echoed blindly. A 401 up there means THIS desktop +/// has no cloud session, which has nothing to do with the caller's own bearer +/// token — answering 401 would send an automation client off to rotate a token +/// that is perfectly good. Likewise the cloud's 403 covers two unrelated +/// things: "your plan does not include this", which is the 402 this API uses +/// everywhere else, and "you are not a member of that team", which no payment +/// fixes. +fn cloud_failure_status(upstream: u16, code: &str) -> StatusCode { + // The disambiguations first, because no status can express them. + if code == crate::cloud_errors::NOT_SIGNED_IN { + return StatusCode::FORBIDDEN; + } + if code.ends_with("NOT_ENTITLED") || code == "REMOTE_HOURS_EXHAUSTED" { + return StatusCode::PAYMENT_REQUIRED; + } + if code == crate::cloud_errors::RATE_LIMITED { + return StatusCode::TOO_MANY_REQUESTS; + } + if code == crate::cloud_errors::UNREACHABLE || code == crate::cloud_errors::NO_CAPACITY { + return StatusCode::SERVICE_UNAVAILABLE; + } + + match upstream { + 400 | 422 => StatusCode::BAD_REQUEST, + 402 => StatusCode::PAYMENT_REQUIRED, + 403 => StatusCode::FORBIDDEN, + 404 => StatusCode::NOT_FOUND, + 409 => StatusCode::CONFLICT, + 429 => StatusCode::TOO_MANY_REQUESTS, + 503 => StatusCode::SERVICE_UNAVAILABLE, + // Some transports keep only the body, so the status is gone by the time + // it gets here. Falling straight through to 500 would report every one of + // those as our fault, including "no such run". + _ => status_for_code(code), + } +} + +/// The status a machine code implies when the HTTP status did not survive. +fn status_for_code(code: &str) -> StatusCode { + if code.ends_with("NOT_FOUND") || code == "COOKIE_BOT_NOT_ENROLLED" { + StatusCode::NOT_FOUND + } else if code.ends_with("CONFLICT") + || code == "COOKIE_BOT_RUN_IN_PROGRESS" + || code == "REMOTE_SYNC_IN_PROGRESS" + { + StatusCode::CONFLICT + } else if code.starts_with("COOKIE_BOT_INVALID") + || code == "COOKIE_BOT_SITE_LIMIT" + || code == "REMOTE_SESSION_REFUSED" + { + StatusCode::BAD_REQUEST + } else if code == "NOT_TEAM_MEMBER" { + StatusCode::FORBIDDEN + } else { + StatusCode::INTERNAL_SERVER_ERROR + } +} + +// API Handler - Every remote session this account currently owns +#[utoipa::path( + get, + path = "/v1/remote-sessions", + responses( + (status = 200, description = "Sessions owned by the signed-in account", body = ApiRemoteSessionsResponse), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), + (status = 403, description = "This desktop is not signed in to Donut cloud"), + (status = 503, description = "Donut cloud could not be reached"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "remote-sessions" +)] +async fn list_remote_sessions_api() -> Result, (StatusCode, String)> +{ + let sessions = crate::remote_session::list_remote_sessions() + .await + .map_err(remote_session_read_response)?; + Ok(Json(ApiRemoteSessionsResponse { sessions })) +} + +// API Handler - One remote session's real state +#[utoipa::path( + get, + path = "/v1/remote-sessions/{id}", + params( + ("id" = String, Path, description = "Remote session ID from run-remote") + ), + responses( + (status = 200, description = "Current session state", body = crate::remote_session::RemoteSessionState), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), + (status = 403, description = "This desktop is not signed in to Donut cloud"), + (status = 404, description = "No such remote session"), + (status = 503, description = "Donut cloud could not be reached"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "remote-sessions" +)] +async fn get_remote_session_api( + Path(id): Path, +) -> Result, (StatusCode, String)> { + // `run-remote` answers `provisioning` and nothing more. Until this route + // existed, an automation client had no way to learn a session had become + // usable other than repeatedly trying to drive it. + crate::remote_session::get_remote_session(&id) + .await + .map(Json) + .map_err(remote_session_read_response) +} + +// API Handler - The pooled remote-hour budget +#[utoipa::path( + get, + path = "/v1/remote-hours", + responses( + (status = 200, description = "Pooled remote-hour budget and its breakdown", body = crate::cookie_bot::RemoteHoursQuota), + (status = 401, description = "Unauthorized"), + (status = 403, description = "This desktop is not signed in to Donut cloud"), + (status = 503, description = "Donut cloud could not be reached"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "remote-sessions" +)] +async fn get_remote_hours( +) -> Result, (StatusCode, String)> { + // Bot runs and interactive remote sessions spend one pool. Being refused a + // launch should not be the only way to find out how much of it is left. + crate::cookie_bot::remote_hours_quota() + .await + .map(Json) + .map_err(cookie_bot_error_response) +} + +// --- Cookie bot ------------------------------------------------------------- +// +// Thin proxies onto donutbrowser-infra, which owns the schedule, the calendar +// arithmetic, the browsing model 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 it at all. + +/// Resolve a profile the cookie bot is allowed to touch. +/// +/// The bot exists only on the leased fleet: a run materialises the profile on a +/// remote host from cloud sync, warms it, and pushes it back. A profile that +/// cannot make that round trip — 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 — has no path to a run and must never reach +/// an enrolment, a quota check or a leased host. +/// +/// Every cookie-bot WRITE on this server goes through here, so there is no +/// surface on which a local-only profile can be pointed at the bot. The server +/// re-checks all of it; this exists so the refusal happens at the moment the +/// caller asks rather than silently at 02:00. +fn cookie_bot_eligible_profile( + profile_id: &str, +) -> Result { + let profiles = ProfileManager::instance() + .list_profiles() + .map_err(manager_error_response)?; + let profile = profiles + .into_iter() + .find(|p| p.id.to_string() == profile_id) + .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?; + + crate::cookie_bot::bot_precondition(&profile) + .map_err(|reason| (StatusCode::BAD_REQUEST, reason))?; + Ok(profile) +} + +// API Handler - Every cookie-bot enrolment the caller can see +#[utoipa::path( + get, + path = "/v1/cookie-bot/schedules", + params( + ("scope" = Option, Query, description = "`mine` (default) or `team`") + ), + responses( + (status = 200, description = "Enrolled profiles", body = crate::cookie_bot::CookieBotScheduleList), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Plan does not include the cookie bot"), + (status = 403, description = "Not signed in, or scope=team from a non-member"), + (status = 503, description = "Donut cloud could not be reached"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "cookie-bot" +)] +async fn list_cookie_bot_schedules( + Query(query): Query, +) -> Result, (StatusCode, String)> { + crate::cookie_bot::list_schedules(query.scope.as_deref()) + .await + .map(Json) + .map_err(cookie_bot_error_response) +} + +// API Handler - One profile's enrolment +#[utoipa::path( + get, + path = "/v1/cookie-bot/schedules/{profile_id}", + params( + ("profile_id" = String, Path, description = "Profile ID") + ), + responses( + (status = 200, description = "The profile's enrolment", body = crate::cookie_bot::CookieBotSchedule), + (status = 401, description = "Unauthorized"), + (status = 403, description = "This desktop is not signed in to Donut cloud"), + (status = 404, description = "This profile is not enrolled"), + (status = 503, description = "Donut cloud could not be reached"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "cookie-bot" +)] +async fn get_cookie_bot_schedule( + Path(profile_id): Path, +) -> Result, (StatusCode, String)> { + // Deliberately 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, + // otherwise the only way to see the schedule is to be allowed to run it. + match crate::cookie_bot::get_schedule(&profile_id) + .await + .map_err(cookie_bot_error_response)? + { + Some(schedule) => Ok(Json(schedule)), + None => Err(( + StatusCode::NOT_FOUND, + serde_json::json!({ "code": "COOKIE_BOT_NOT_ENROLLED" }).to_string(), + )), + } +} + +// API Handler - Enrol a profile, or replace its enrolment +#[utoipa::path( + put, + path = "/v1/cookie-bot/schedules/{profile_id}", + params( + ("profile_id" = String, Path, description = "Profile ID") + ), + request_body = SetCookieBotScheduleRequest, + responses( + (status = 200, description = "Enrolment saved", body = crate::cookie_bot::CookieBotScheduleSaved), + (status = 400, description = "Invalid schedule, or a profile the bot cannot run"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Plan does not include the cookie bot"), + (status = 403, description = "This desktop is not signed in to Donut cloud"), + (status = 404, description = "Profile not found"), + (status = 409, description = "A teammate already enrols this profile; retry with acknowledge_conflict"), + (status = 503, description = "Donut cloud could not be reached"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "cookie-bot" +)] +async fn set_cookie_bot_schedule( + Path(profile_id): Path, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let profile = cookie_bot_eligible_profile(&profile_id)?; + // `bot_precondition` already proved the profile has a resolvable OS the + // fleet can lease, so this cannot fail; taking it from the profile rather + // than the request is what stops a caller enrolling a macOS profile onto a + // Windows host. + let platform = profile + .resolved_os() + .ok_or(( + StatusCode::BAD_REQUEST, + serde_json::json!({ "code": "COOKIE_BOT_UNKNOWN_PLATFORM" }).to_string(), + ))? + .to_string(); + + if let Some(requested) = request.platform.as_deref() { + if requested != platform { + return Err(( + StatusCode::BAD_REQUEST, + serde_json::json!({ + "code": "COOKIE_BOT_UNSUPPORTED_PLATFORM", + "params": { "platform": requested } + }) + .to_string(), + )); + } + } + + let input = crate::cookie_bot::CookieBotScheduleInput { + profile_name: request.profile_name.unwrap_or_else(|| profile.name.clone()), + platform, + enabled: request.enabled, + run_at_minute: request.run_at_minute, + days_mask: request.days_mask, + timezone: request.timezone, + preset: request.preset, + max_minutes: request.max_minutes, + sites: request.sites, + jitter_seconds: request.jitter_seconds, + ..Default::default() + } + // The server requires these and cannot read them itself — the profile lives + // in the user's sync namespace, not its database. + .with_profile_state(crate::cookie_bot::profile_state(&profile)); + + crate::cookie_bot::save_schedule(&profile_id, &input, request.acknowledge_conflict) + .await + .map(Json) + .map_err(cookie_bot_error_response) +} + +// API Handler - Turn the bot off for a profile +#[utoipa::path( + delete, + path = "/v1/cookie-bot/schedules/{profile_id}", + params( + ("profile_id" = String, Path, description = "Profile ID") + ), + responses( + (status = 200, description = "Enrolment removed, or there was none", body = crate::cookie_bot::CookieBotScheduleDeleted), + (status = 401, description = "Unauthorized"), + (status = 403, description = "This desktop is not signed in to Donut cloud"), + (status = 503, description = "Donut cloud could not be reached"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "cookie-bot" +)] +async fn delete_cookie_bot_schedule( + Path(profile_id): Path, +) -> Result, (StatusCode, String)> { + // No eligibility gate and no 404: "turn the bot off" must be safe to repeat, + // and a profile that has since become ineligible is exactly the one a caller + // most needs to be able to unenrol. + crate::cookie_bot::delete_schedule(&profile_id) + .await + .map(Json) + .map_err(cookie_bot_error_response) +} + +// API Handler - Who else already warms this profile +#[utoipa::path( + get, + path = "/v1/cookie-bot/conflicts", + params( + ("profile_id" = String, Query, description = "Profile ID"), + ("run_at_minute" = Option, Query, description = "Proposed minute past local midnight"), + ("timezone" = Option, Query, description = "Proposed IANA zone"), + ("days_mask" = Option, Query, description = "Proposed weekday bitmask, bit 0 = Monday") + ), + responses( + (status = 200, description = "Teammates enrolling the same profile", body = crate::cookie_bot::CookieBotConflictCheck), + (status = 400, description = "profile_id missing"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "This desktop is not signed in to Donut cloud"), + (status = 503, description = "Donut cloud could not be reached"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "cookie-bot" +)] +async fn get_cookie_bot_conflicts( + Query(query): Query, +) -> Result, (StatusCode, String)> { + // A dry run that writes nothing, so an automation client can find a + // collision before it makes one instead of after two operators have quietly + // scheduled the same profile against itself. + crate::cookie_bot::check_conflicts( + &query.profile_id, + query.run_at_minute, + query.timezone.as_deref(), + query.days_mask, + ) + .await + .map(Json) + .map_err(cookie_bot_error_response) +} + +// API Handler - Run history +#[utoipa::path( + get, + path = "/v1/cookie-bot/runs", + params( + ("profile_id" = Option, Query, description = "Restrict to one profile"), + ("scope" = Option, Query, description = "`mine` (default) or `team`"), + ("limit" = Option, Query, description = "Page size, 1..100 (default 30)"), + ("before" = Option, Query, description = "Keyset cursor from a previous page's next_before") + ), + responses( + (status = 200, description = "One page of runs, newest first", body = crate::cookie_bot::CookieBotRunPage), + (status = 400, description = "limit out of range or malformed cursor"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Not signed in, or scope=team from a non-member"), + (status = 503, description = "Donut cloud could not be reached"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "cookie-bot" +)] +async fn list_cookie_bot_runs( + Query(query): Query, +) -> Result, (StatusCode, String)> { + crate::cookie_bot::list_runs( + query.profile_id.as_deref(), + query.scope.as_deref(), + query.limit, + query.before.as_deref(), + ) + .await + .map(Json) + .map_err(cookie_bot_error_response) +} + +// API Handler - Warm a profile now instead of waiting for tonight +#[utoipa::path( + post, + path = "/v1/cookie-bot/runs", + request_body = StartCookieBotRunRequest, + responses( + (status = 202, description = "Run accepted; it keeps executing for minutes after this response", body = crate::cookie_bot::CookieBotRunStarted), + (status = 400, description = "A profile the bot cannot run"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Plan does not include the cookie bot, or the pooled hours are spent"), + (status = 403, description = "This desktop is not signed in to Donut cloud"), + (status = 404, description = "Profile not found, or not enrolled"), + (status = 409, description = "A run or remote session already holds this profile"), + (status = 429, description = "Automation request rate limit exceeded"), + (status = 503, description = "No host of that operating system has a free slot"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "cookie-bot" +)] +async fn start_cookie_bot_run( + Json(request): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + if !crate::cloud_auth::CLOUD_AUTH + .can_use_browser_automation() + .await + { + return Err((StatusCode::PAYMENT_REQUIRED, String::new())); + } + + cookie_bot_eligible_profile(&request.profile_id)?; + + let started = crate::cookie_bot::run_now(&request.profile_id, request.max_minutes) + .await + .map_err(cookie_bot_error_response)?; + + // 202, not 200: the fleet is still browsing when this returns. Answering 200 + // would tell a client the work is done when it has barely started. + Ok((StatusCode::ACCEPTED, Json(started))) +} + +// API Handler - Stop a run that is still going +#[utoipa::path( + delete, + path = "/v1/cookie-bot/runs/{run_id}", + params( + ("run_id" = String, Path, description = "Run ID") + ), + responses( + (status = 200, description = "The run, cancelled (or unchanged if it had already finished)", body = crate::cookie_bot::CookieBotRun), + (status = 401, description = "Unauthorized"), + (status = 403, description = "This desktop is not signed in to Donut cloud"), + (status = 404, description = "No such run for this account"), + (status = 429, description = "Automation request rate limit exceeded"), + (status = 503, description = "The fleet could not be reached; the run is still live"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "cookie-bot" +)] +async fn cancel_cookie_bot_run( + Path(run_id): Path, +) -> Result, (StatusCode, String)> { + // No entitlement gate. A lapsed plan must never be the reason a user cannot + // stop something that is spending their hours. + crate::cookie_bot::cancel_run(&run_id) + .await + .map(Json) + .map_err(cookie_bot_error_response) +} + +// API Handler - The intensities the server offers +#[utoipa::path( + get, + path = "/v1/cookie-bot/presets", + responses( + (status = 200, description = "Selectable presets", body = crate::cookie_bot::CookieBotPresetList), + (status = 401, description = "Unauthorized"), + (status = 403, description = "This desktop is not signed in to Donut cloud"), + (status = 503, description = "Donut cloud could not be reached"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "cookie-bot" +)] +async fn list_cookie_bot_presets( +) -> Result, (StatusCode, String)> { + // 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. + crate::cookie_bot::list_presets() + .await + .map(Json) + .map_err(cookie_bot_error_response) +} + +// API Handler - Who spent what, for a calendar month +#[utoipa::path( + get, + path = "/v1/cookie-bot/usage", + params( + ("period" = Option, Query, description = "`YYYY-MM`; defaults to the current UTC month") + ), + responses( + (status = 200, description = "Per-member and per-profile spend", body = crate::cookie_bot::CookieBotUsage), + (status = 400, description = "Malformed period"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Not signed in, or not a member of that team"), + (status = 503, description = "Donut cloud could not be reached"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "cookie-bot" +)] +async fn get_cookie_bot_usage( + Query(query): Query, +) -> Result, (StatusCode, String)> { + // Reporting, never enforcement: the pooled budget is spent against by the + // server, and this is how an owner finds out where it went. + crate::cookie_bot::team_usage(query.period.as_deref()) + .await + .map(Json) + .map_err(cookie_bot_error_response) +} + // API Handler - Open URL in existing browser #[utoipa::path( post, @@ -2959,12 +3782,12 @@ mod tests { // would launch an empty browser and push that emptiness over the real one. #[test] fn remote_launch_requires_cloud_sync() { - let err = remote_launch_precondition(&profile_with(SyncMode::Disabled, Some("macos"))) + let err = remote_launch_profile_rules(&profile_with(SyncMode::Disabled, Some("macos"))) .expect_err("a non-synced profile must be refused"); assert!(err.contains("cloud sync"), "unhelpful message: {err}"); assert!( - remote_launch_precondition(&profile_with(SyncMode::Regular, Some("macos"))).is_ok(), + remote_launch_profile_rules(&profile_with(SyncMode::Regular, Some("macos"))).is_ok(), "a synced profile must be allowed" ); } @@ -2976,7 +3799,7 @@ mod tests { // the corruption back over the user's real profile. Refusing here also // saves taking the profile lock and a slot on leased hardware for a // session that cannot possibly work. - let err = remote_launch_precondition(&profile_with(SyncMode::Encrypted, Some("macos"))) + let err = remote_launch_profile_rules(&profile_with(SyncMode::Encrypted, Some("macos"))) .expect_err("an encrypted profile must be refused"); assert!( err.contains("encrypted") && err.contains("Regular"), @@ -2988,7 +3811,7 @@ mod tests { fn remote_launch_requires_a_known_operating_system() { // Without one there is no way to pick a matching host, and guessing would // be the cross-OS mismatch this whole design exists to prevent. - assert!(remote_launch_precondition(&profile_with(SyncMode::Regular, None)).is_err()); + assert!(remote_launch_profile_rules(&profile_with(SyncMode::Regular, None)).is_err()); } #[test] @@ -3007,7 +3830,33 @@ mod tests { ); // Local /run refuses this; running it remotely on a host of its own OS is // exactly what /run-remote is for. - assert!(remote_launch_precondition(&foreign).is_ok()); + assert!(remote_launch_profile_rules(&foreign).is_ok()); + } + + #[tokio::test] + async fn remote_launch_is_refused_while_the_profile_is_mid_upload() { + // The manifest is written last. A host that pulls during the upload gets + // files that are about to be replaced described by a manifest that does + // not match them, launches Chromium on that, and pushes the result back + // over the real profile. + let scheduler = std::sync::Arc::new(crate::sync::SyncScheduler::new()); + crate::sync::set_global_scheduler(scheduler.clone()); + + let mut profile = profile_with(SyncMode::Regular, Some("macos")); + profile.id = uuid::Uuid::new_v4(); + assert!( + remote_launch_precondition(&profile).await.is_ok(), + "an idle profile must be launchable" + ); + + scheduler.queue_profile_sync(profile.id.to_string()).await; + let err = remote_launch_precondition(&profile) + .await + .expect_err("a profile mid-upload must be refused"); + assert!( + err.contains("REMOTE_SYNC_IN_PROGRESS"), + "the refusal must be a code the frontend can translate: {err}" + ); } #[test] @@ -3106,8 +3955,15 @@ mod tests { "/v1/profiles/profile-id/run", "/v1/profiles/profile-id/open-url", "/v1/profiles/profile-id/kill", + // Launching on leased remote hardware is the most expensive automation + // action there is; it went unmetered because `run-remote` is its own + // path segment and never matched `run`. + "/v1/profiles/profile-id/run-remote", "/v1/profiles/batch/run", "/v1/profiles/batch/stop", + // Starting a bot run leases a host for up to two hours and spends the + // account's pooled remote-hour budget. + "/v1/cookie-bot/runs", ] { assert!( is_automation_request(&Method::POST, path), @@ -3115,12 +3971,41 @@ mod tests { ); } + // Stopping a remote session is a DELETE, and its handler declares a 429. + // Cancelling a bot run reaches the same fleet and is metered the same way. + for path in [ + "/v1/remote-sessions/session-id", + "/v1/cookie-bot/runs/run-id", + ] { + assert!( + is_automation_request(&Method::DELETE, path), + "metered stop was not limited: {path}" + ); + } + for (method, path) in [ (Method::GET, "/v1/profiles/profile-id/run"), (Method::POST, "/v1/profiles"), (Method::POST, "/v1/profiles/import"), (Method::GET, "/v1/profiles"), (Method::GET, "/openapi.json"), + // Only the single-session DELETE is automation; the collection is not a + // route, and a GET of one never launches anything. + (Method::DELETE, "/v1/remote-sessions/"), + (Method::GET, "/v1/remote-sessions/session-id"), + (Method::GET, "/v1/remote-sessions"), + // Enrolling a profile writes one row on the server and leases nothing. + // Metering it would 429 a client setting up a fleet of profiles, while + // the budget that actually protects the hardware is spent per RUN and + // enforced server-side however the run was scheduled. + (Method::PUT, "/v1/cookie-bot/schedules/profile-id"), + (Method::DELETE, "/v1/cookie-bot/schedules/profile-id"), + (Method::GET, "/v1/cookie-bot/schedules"), + (Method::GET, "/v1/cookie-bot/runs"), + (Method::GET, "/v1/cookie-bot/usage"), + (Method::GET, "/v1/remote-hours"), + // A run id is required; the collection DELETE is not a route. + (Method::DELETE, "/v1/cookie-bot/runs/"), ] { assert!( !is_automation_request(&method, path), @@ -3129,6 +4014,142 @@ mod tests { } } + // The bot exists only on the leased fleet. Every write surface resolves the + // profile through `bot_precondition` first, so there is no request shape on + // this server that points it at a profile which could never make the round + // trip to a remote host and back. + #[test] + fn a_profile_the_bot_could_never_run_is_refused_before_the_cloud_is_asked() { + let mut local_only = profile_with(SyncMode::Disabled, Some("macos")); + local_only.proxy_id = Some("proxy-1".to_string()); + 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 encrypted = profile_with(SyncMode::Encrypted, Some("macos")); + encrypted.proxy_id = Some("proxy-1".to_string()); + assert!( + crate::cookie_bot::bot_precondition(&encrypted).is_err(), + "a host cannot decrypt a profile whose key never leaves this machine" + ); + + let mut datacenter_egress = profile_with(SyncMode::Regular, Some("macos")); + 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" + ); + + let mut eligible = profile_with(SyncMode::Regular, Some("macos")); + eligible.proxy_id = Some("proxy-1".to_string()); + assert!(crate::cookie_bot::bot_precondition(&eligible).is_ok()); + } + + #[test] + fn a_cloud_401_is_not_reported_as_the_callers_own_token_being_wrong() { + // The caller's bearer token was accepted — the auth middleware ran. It is + // THIS desktop that has no cloud session, and answering 401 would send an + // automation client off to rotate a token that is perfectly good. + assert_eq!( + cloud_failure_status(401, crate::cloud_errors::NOT_SIGNED_IN), + StatusCode::FORBIDDEN + ); + } + + #[test] + fn the_clouds_403_splits_into_the_two_things_it_means() { + // "Your plan does not include this" is the 402 this API uses everywhere + // else; "you are not in that team" is not something a payment fixes. + assert_eq!( + cloud_failure_status(403, "COOKIE_BOT_NOT_ENTITLED"), + StatusCode::PAYMENT_REQUIRED + ); + assert_eq!( + cloud_failure_status(403, "NOT_TEAM_MEMBER"), + StatusCode::FORBIDDEN + ); + } + + #[test] + fn spending_the_pooled_hours_is_a_payment_problem_not_a_server_fault() { + // It arrives as a 403 with a code. Reporting it as a plain forbidden would + // hide the one thing the user can act on. + assert_eq!( + cloud_failure_status(403, "REMOTE_HOURS_EXHAUSTED"), + StatusCode::PAYMENT_REQUIRED + ); + } + + #[test] + fn a_busy_or_unreachable_fleet_is_never_reported_as_broken() { + // 503 means "try again shortly". Turning it into a 500 tells the user + // their automation is broken when nothing is. + assert_eq!( + cloud_failure_status(503, crate::cloud_errors::NO_CAPACITY), + StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!( + cloud_failure_status(0, crate::cloud_errors::UNREACHABLE), + StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!( + cloud_failure_status(429, crate::cloud_errors::RATE_LIMITED), + StatusCode::TOO_MANY_REQUESTS + ); + } + + #[test] + fn a_missing_schedule_and_a_missing_run_both_stay_a_404() { + assert_eq!( + cloud_failure_status(404, "COOKIE_BOT_NOT_ENROLLED"), + StatusCode::NOT_FOUND + ); + assert_eq!( + cloud_failure_status(404, "COOKIE_BOT_RUN_NOT_FOUND"), + StatusCode::NOT_FOUND + ); + assert_eq!( + cloud_failure_status(409, "COOKIE_BOT_SCHEDULE_CONFLICT"), + StatusCode::CONFLICT + ); + assert_eq!( + cloud_failure_status(400, "COOKIE_BOT_INVALID_SCHEDULE"), + StatusCode::BAD_REQUEST + ); + } + + #[test] + fn a_read_of_a_session_that_does_not_exist_is_a_404_not_a_500() { + // The launch mapping folds every unrecognised status into 500, which for a + // read means "no such session" is indistinguishable from "our backend + // fell over". + let missing = + crate::remote_session::classify_backend_status(404, r#"{"code":"REMOTE_SESSION_NOT_FOUND"}"#); + let (status, body) = remote_session_read_response(missing); + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(error_code_of(&body), "REMOTE_SESSION_NOT_FOUND"); + } + + #[test] + fn a_read_answers_with_a_code_rather_than_the_backends_english() { + let busy = crate::remote_session::classify_backend_status(503, "no macos host has a free slot"); + let (status, body) = remote_session_read_response(busy); + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(error_code_of(&body), crate::cloud_errors::NO_CAPACITY); + } + + // Axum panics when two handlers claim one path, and the router is only built + // when the API server is switched on — so a conflict introduced here would + // ship as an app that dies the first time a user enables the API. Both + // `/v1/remote-sessions/{id}` and `/v1/cookie-bot/schedules/{profile_id}` now + // carry several methods, which is exactly the shape that trips it. + #[test] + fn every_v1_route_can_be_registered_together() { + let _router: Router = build_v1_router(); + } + fn schema_required(spec: &serde_json::Value, schema: &str) -> Vec { spec["components"]["schemas"][schema]["required"] .as_array() @@ -3153,6 +4174,20 @@ mod tests { "wayfern_config must be optional, required list: {create_profile:?}" ); + // `ApiProfile` is the response body of every profile-returning route, so a + // wrongly-required `group_id` makes generated clients assume a group is + // always present on an ungrouped profile. + let api_profile = schema_required(&spec, "ApiProfile"); + assert!( + !api_profile.iter().any(|f| f == "group_id"), + "group_id must be optional on ApiProfile, required list: {api_profile:?}" + ); + assert_eq!( + spec["components"]["schemas"]["ApiProfile"]["properties"]["group_id"]["type"], + serde_json::json!(["string", "null"]), + "group_id must be a nullable string, not a free-form object" + ); + let update_profile = schema_required(&spec, "UpdateProfileRequest"); assert!( !update_profile.iter().any(|f| f == "group_id"), @@ -3192,6 +4227,91 @@ mod tests { "{field} must be optional on import items, required list: {import_item:?}" ); } + + // A remote launch with no URL just opens the browser; forcing generated + // clients to send one would make the common case the awkward one. + let run_remote = schema_required(&spec, "RunRemoteRequest"); + assert!( + !run_remote.iter().any(|f| f == "url"), + "url must be optional on a remote launch, required list: {run_remote:?}" + ); + + // A run-now with no cap inherits the schedule's own. + let start_run = schema_required(&spec, "StartCookieBotRunRequest"); + assert!( + start_run.iter().any(|f| f == "profile_id"), + "profile_id is the one thing a run cannot infer, required list: {start_run:?}" + ); + assert!( + !start_run.iter().any(|f| f == "max_minutes"), + "max_minutes must be optional, required list: {start_run:?}" + ); + + // This machine already knows the profile's name and operating system, and + // a caller-supplied platform that disagrees is refused rather than + // honoured — so neither may be marked required. + let set_schedule = schema_required(&spec, "SetCookieBotScheduleRequest"); + for field in [ + "profile_name", + "platform", + "jitter_seconds", + "sites", + "acknowledge_conflict", + ] { + assert!( + !set_schedule.iter().any(|f| f == field), + "{field} must be optional when enrolling, required list: {set_schedule:?}" + ); + } + + // A freshly created enrolment has never run, so every one of these is + // absent on the first read. Marking them required would make a generated + // client reject the response it gets immediately after enrolling. + let schedule = schema_required(&spec, "CookieBotSchedule"); + for field in ["next_run_at", "last_run_at", "last_run_id", "updated_at"] { + assert!( + !schedule.iter().any(|f| f == field), + "{field} must be optional on a schedule, required list: {schedule:?}" + ); + } + + let run = schema_required(&spec, "CookieBotRun"); + for field in ["started_at", "ended_at", "outcome_code", "session_id"] { + assert!( + !run.iter().any(|f| f == field), + "{field} must be optional on a run, required list: {run:?}" + ); + } + + // Only `session_id` and `status` are guaranteed while a session is still + // provisioning; everything else arrives as the session progresses. + let session = schema_required(&spec, "RemoteSessionState"); + for field in [ + "profile_id", + "platform", + "kind", + "run_id", + "started_at", + "ready_at", + "closed_at", + "close_reason", + "billed_seconds", + ] { + assert!( + !session.iter().any(|f| f == field), + "{field} must be optional on a session, required list: {session:?}" + ); + } + + // The route predates the pooled budget and returned only two keys. A + // deployment that has not rolled forward must still satisfy the spec. + let quota = schema_required(&spec, "RemoteHoursQuota"); + for field in ["members", "breakdown", "scope", "team_id", "seats"] { + assert!( + !quota.iter().any(|f| f == field), + "{field} must be optional on the quota, required list: {quota:?}" + ); + } } #[test] @@ -3225,19 +4345,170 @@ mod tests { "/v1/profiles/import", "/v1/profiles/import/detect", "/v1/proxies/import", + // The whole remote-execution surface was registered on the router but + // absent from ApiDoc, so it never appeared in the served spec. This list + // is a hand-maintained allowlist, which is exactly why that drift went + // unnoticed — every route added here must also be added below. + "/v1/profiles/{id}/run-remote", + "/v1/profiles/{id}/cloud-sync", + "/v1/remote-sessions/{id}", + // Remote-session observability and the whole cookie-bot surface. Same + // hazard, so the same guard: registered on the router is not registered + // in the spec, and the spec is what an automation client is written from. + "/v1/remote-sessions", + "/v1/remote-hours", + "/v1/cookie-bot/schedules", + "/v1/cookie-bot/schedules/{profile_id}", + "/v1/cookie-bot/conflicts", + "/v1/cookie-bot/runs", + "/v1/cookie-bot/runs/{run_id}", + "/v1/cookie-bot/presets", + "/v1/cookie-bot/usage", ] { assert!(paths.contains_key(path), "missing from ApiDoc: {path}"); } + // Every method of every shared path must survive. Registering two handlers + // on one path in separate `routes!` calls silently drops one of them, and + // the spec is where that shows up. + for (path, method) in [ + ("/v1/remote-sessions/{id}", "get"), + ("/v1/remote-sessions/{id}", "delete"), + ("/v1/cookie-bot/schedules/{profile_id}", "get"), + ("/v1/cookie-bot/schedules/{profile_id}", "put"), + ("/v1/cookie-bot/schedules/{profile_id}", "delete"), + ("/v1/cookie-bot/runs", "get"), + ("/v1/cookie-bot/runs", "post"), + ("/v1/cookie-bot/runs/{run_id}", "delete"), + ] { + assert!( + paths[path].get(method).is_some(), + "missing from ApiDoc: {method} {path}" + ); + } + + // Every cookie-bot operation must be findable by tag, or it is invisible in + // a generated client's grouping even though the path exists. + for (path, method) in [ + ("/v1/cookie-bot/schedules", "get"), + ("/v1/cookie-bot/schedules/{profile_id}", "put"), + ("/v1/cookie-bot/runs", "post"), + ("/v1/cookie-bot/usage", "get"), + ] { + let tags = paths[path][method]["tags"] + .as_array() + .unwrap_or_else(|| panic!("{method} {path} has no tags")); + assert!( + tags.iter().any(|tag| tag == "cookie-bot"), + "{method} {path} is not tagged cookie-bot: {tags:?}" + ); + } + + // A bot run is accepted, not completed: the fleet browses for minutes + // after the response. A 200 here would be a lie the client acts on. + assert!( + paths["/v1/cookie-bot/runs"]["post"]["responses"] + .get("202") + .is_some(), + "starting a bot run must declare 202 Accepted" + ); + assert!( !paths.keys().any(|p| p.contains("wayfern-token")), "wayfern-token endpoints were removed and must stay out of the spec" ); + // A path with a body that resolves to nothing is worse than a missing + // path: a generator emits a client for it and the response type is empty. + // These live in other modules, so `components(schemas(...))` is the only + // thing pulling them in. + for schema in [ + "RemoteSessionState", + "ApiRemoteSessionsResponse", + "SetCookieBotScheduleRequest", + "StartCookieBotRunRequest", + "CookieBotSchedule", + "CookieBotScheduleList", + "CookieBotScheduleSaved", + "CookieBotScheduleDeleted", + "CookieBotConflict", + "CookieBotConflictCheck", + "CookieBotRun", + "CookieBotRunPage", + "CookieBotRunStarted", + "CookieBotPreset", + "CookieBotPresetList", + "CookieBotUsage", + "CookieBotUsageMember", + "CookieBotUsageProfile", + "RemoteHoursQuota", + "RemoteHoursMember", + "RemoteHoursBreakdown", + ] { + assert!( + spec["components"]["schemas"][schema]["properties"].is_object(), + "schema is missing from the served spec: {schema}" + ); + } + + // A response body declared as a path outside this module must resolve to + // the component that path registered, not to a dangling or inlined name. + for (path, method, status, schema) in [ + ( + "/v1/cookie-bot/schedules", + "get", + "200", + "CookieBotScheduleList", + ), + ("/v1/cookie-bot/runs", "post", "202", "CookieBotRunStarted"), + ( + "/v1/cookie-bot/runs/{run_id}", + "delete", + "200", + "CookieBotRun", + ), + ( + "/v1/remote-sessions/{id}", + "get", + "200", + "RemoteSessionState", + ), + ("/v1/remote-hours", "get", "200", "RemoteHoursQuota"), + ] { + let reference = + &paths[path][method]["responses"][status]["content"]["application/json"]["schema"]["$ref"]; + assert_eq!( + reference.as_str(), + Some(format!("#/components/schemas/{schema}").as_str()), + "{method} {path} {status} does not reference {schema}: {reference:?}" + ); + } + + // The presets a client may choose from must never carry the behaviour they + // expand to. A site list, a dwell range or a step programme appearing here + // would mean the browsing model had leaked out of the server. + let preset_properties = spec["components"]["schemas"]["CookieBotPreset"]["properties"] + .as_object() + .expect("preset properties"); + for leaked in [ + "sites", + "dwell", + "dwell_seconds", + "steps", + "actions", + "corpus", + ] { + assert!( + !preset_properties.contains_key(leaked), + "the browsing model leaked into the client contract: {leaked}" + ); + } + for path in [ "/v1/profiles/{id}/run", "/v1/profiles/{id}/open-url", "/v1/profiles/{id}/kill", + "/v1/profiles/{id}/run-remote", "/v1/profiles/batch/run", "/v1/profiles/batch/stop", ] { @@ -3246,5 +4517,34 @@ mod tests { "automation route is missing its 429 response: {path}" ); } + + assert!( + paths["/v1/cookie-bot/runs"]["post"]["responses"] + .get("429") + .is_some(), + "starting a bot run is metered and must declare its 429" + ); + + // The automation routes that are not POSTs. Both declared a 429 that + // `is_automation_request` could never produce, because that function + // returned early for every non-POST method. + for path in ["/v1/remote-sessions/{id}", "/v1/cookie-bot/runs/{run_id}"] { + assert!( + paths[path]["delete"]["responses"].get("429").is_some(), + "metered stop route is missing its 429 response: {path}" + ); + } + + // Schedule writes are configuration, not automation. Declaring a 429 they + // can never return would send a client building retry logic for a status + // it will never see. + for method in ["put", "delete"] { + assert!( + paths["/v1/cookie-bot/schedules/{profile_id}"][method]["responses"] + .get("429") + .is_none(), + "a schedule write must not declare a 429: {method}" + ); + } } } diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index f610ea8..3fc11f3 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -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, } } diff --git a/src-tauri/src/cloud_errors.rs b/src-tauri/src/cloud_errors.rs new file mode 100644 index 0000000..1561529 --- /dev/null +++ b/src-tauri/src/cloud_errors.rs @@ -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, +} + +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::>(); + 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::().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 { + let parsed = serde_json::from_str::(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 { + 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, params: &mut BTreeMap) { + 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) { + 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, "", 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); + } +} diff --git a/src-tauri/src/cookie_bot.rs b/src-tauri/src/cookie_bot.rs new file mode 100644 index 0000000..b9ed138 --- /dev/null +++ b/src-tauri/src/cookie_bot.rs @@ -0,0 +1,1423 @@ +//! Cookie-bot transport. +//! +//! The bot warms a profile's cookies overnight by driving it on a leased +//! remote host. NONE of that lives here: the schedule, the calendar maths, the +//! preset expansion, the site ordering, the dwell and scroll model, the pooled +//! budget and the nightly dispatcher are all held by donutbrowser-infra and +//! the Wayfern manager. +//! +//! This module is the wire only. It sends the user's own scalars — when to +//! run, for how long, which of their sites, which server-issued preset id — +//! and renders back what the server says happened. It deliberately keeps NO +//! local copy of a schedule: the server holds the only one, so two desktops +//! signed into one account cannot disagree about when the bot runs. + +use crate::cloud_errors::{self, BackendFailure, FailureCodes}; +use crate::profile::types::BrowserProfile; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::sync::OnceLock; +use std::time::Duration; + +/// Operating systems the fleet can lease. Linux is refused by the manager, so +/// refusing it here turns a nightly failure at 02:00 into a refusal at the +/// moment the user picks the profile. +pub const BOT_PLATFORMS: [&str; 2] = ["windows", "macos"]; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + +/// Failure codes for the schedule routes. +const SCHEDULE_CODES: FailureCodes = FailureCodes { + bad_request: "COOKIE_BOT_INVALID_SCHEDULE", + forbidden: "COOKIE_BOT_NOT_ENTITLED", + not_found: "COOKIE_BOT_NOT_ENROLLED", + conflict: "COOKIE_BOT_SCHEDULE_CONFLICT", +}; + +/// Failure codes for the run routes. +const RUN_CODES: FailureCodes = FailureCodes { + bad_request: "COOKIE_BOT_INVALID_SCHEDULE", + forbidden: "COOKIE_BOT_NOT_ENTITLED", + not_found: "COOKIE_BOT_RUN_NOT_FOUND", + conflict: "COOKIE_BOT_RUN_IN_PROGRESS", +}; + +/// Failure codes for the read-only reporting routes. +const REPORT_CODES: FailureCodes = FailureCodes { + bad_request: "COOKIE_BOT_INVALID_PERIOD", + forbidden: "NOT_TEAM_MEMBER", + not_found: cloud_errors::UNAVAILABLE, + conflict: cloud_errors::UNAVAILABLE, +}; + +/// Every cookie-bot call fails as a code the frontend can translate. +/// +/// There is no `Other(String)` carrying backend English: a raw message reaches +/// the user untranslated, which is the bug pattern the `{"code":…}` convention +/// exists to block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CookieBotError(pub BackendFailure); + +impl CookieBotError { + pub fn code(&self) -> &str { + &self.0.code + } + + pub fn status(&self) -> u16 { + self.0.status + } + + /// The `{"code":…,"params":{…}}` string a Tauri command returns. + pub fn to_error_json(&self) -> String { + self.0.to_error_json() + } +} + +impl std::fmt::Display for CookieBotError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_error_json()) + } +} + +impl From for CookieBotError { + fn from(failure: BackendFailure) -> Self { + Self(failure) + } +} + +// --- Wire types ------------------------------------------------------------- +// +// One place for every request and response shape, so a backend contract change +// is a single edit here rather than a hunt through call sites. + +/// A profile enrolled in the nightly bot. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotSchedule { + pub profile_id: String, + pub profile_name: String, + pub platform: String, + pub enabled: bool, + /// Minutes past local midnight the run is anchored to. + pub run_at_minute: u16, + /// Bitmask of local weekdays, bit 0 = Monday. + pub days_mask: u8, + pub timezone: String, + /// Server-issued preset id. Opaque here — what it expands to is infra's. + pub preset: String, + pub max_minutes: u32, + #[serde(default)] + pub sites: Vec, + #[serde(default)] + pub jitter_seconds: u32, + + // The profile facts the desktop declared, echoed back on every read. Kept so + // the UI can tell that what the server believes about a profile no longer + // matches what this machine can see, and re-declare it. + #[serde(default)] + pub sync_enabled: bool, + #[serde(default)] + pub encrypted_sync: bool, + #[serde(default)] + pub has_proxy: bool, + #[serde(default)] + pub touch_fingerprint: bool, + #[serde(default)] + pub sticky_exit: bool, + /// When those facts were last refreshed. + #[serde(default)] + pub profile_state_at: Option, + + /// Why tonight would be refused, or `None`. + /// + /// The server computes this on every read precisely so a broken enrolment is + /// visible the moment it breaks. Dropping it meant a profile whose proxy was + /// detached in the afternoon still showed a healthy row and a next-run time, + /// and first announced itself with a skipped run at 02:00. + #[serde(default)] + pub blocked_by: Option, + + #[serde(default)] + pub next_run_at: Option, + #[serde(default)] + pub last_run_at: Option, + #[serde(default)] + pub last_run_id: Option, + #[serde(default)] + pub owner_user_id: Option, + #[serde(default)] + pub owner_email: Option, + #[serde(default)] + pub updated_at: Option, +} + +/// What the desktop sends when enrolling or editing. +/// +/// `next_run_at` is absent by design: the server recomputes it and ignores any +/// client value, so there is nothing here for two devices to disagree about. +#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotScheduleInput { + pub profile_name: String, + pub platform: String, + pub enabled: bool, + pub run_at_minute: u16, + pub days_mask: u8, + pub timezone: String, + pub preset: String, + pub max_minutes: u32, + pub sites: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jitter_seconds: Option, + + // The profile facts the server refuses a run on. It cannot read them itself — + // the profile lives in the user's sync namespace, not in its database — so the + // desktop reports them and the server decides. + // + // Every caller (Tauri, REST, MCP) overwrites all five through + // `with_profile_state`, derived from the profile itself, so a caller can never + // assert them. They are therefore `default` on the way IN — which is what the + // GUI sends, and what the Tauri command's own argument deserialization + // requires, since demanding them made every enrolment fail with + // `invalid args schedule: missing field sync_enabled` before the stamping + // could run — and unconditionally present on the way OUT, because the server + // rejects a write that omits them. + // + // Defaulting is safe in exactly one direction: `bool::default()` is false, so + // an unstamped input reads as "no sync, no proxy" and is REFUSED. The failure + // this must never have is the opposite one, a defaulted `has_proxy: true` + // warming a profile out of the fleet's own datacenter address. + #[serde(default)] + pub sync_enabled: bool, + #[serde(default)] + pub has_proxy: bool, + #[serde(default)] + pub encrypted_sync: bool, + #[serde(default)] + pub touch_fingerprint: bool, + #[serde(default)] + pub sticky_exit: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotScheduleList { + #[serde(default)] + pub schedules: Vec, + #[serde(default)] + pub team_id: Option, + #[serde(default)] + pub scope: Option, +} + +/// A teammate's enrolment of the same profile. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotConflict { + pub user_id: String, + pub email: String, + pub run_at_minute: u16, + pub timezone: String, + pub days_mask: u8, + pub enabled: bool, + /// Set on the dry-run check: the two enrolments share a weekday and fire + /// within an hour of each other. + #[serde(default)] + pub overlaps: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotScheduleSaved { + pub schedule: CookieBotSchedule, + /// Repeated on a successful acknowledged write so the UI can keep showing + /// the warning rather than pretending the collision went away. + #[serde(default)] + pub conflicts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotConflictCheck { + pub profile_id: String, + #[serde(default)] + pub conflicts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotScheduleDeleted { + pub profile_id: String, + pub deleted: bool, +} + +/// One night's work on one profile. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotRun { + pub id: String, + pub profile_id: String, + #[serde(default)] + pub profile_name: Option, + #[serde(default)] + pub user_id: Option, + #[serde(default)] + pub email: Option, + /// `schedule` or `manual`. + #[serde(default)] + pub team_id: Option, + pub trigger: String, + /// `pending` | `running` | `succeeded` | `partial` | `failed` | `skipped` | + /// `cancelled`. + pub status: String, + pub scheduled_for: String, + /// The jittered instant the run was allowed to start. + #[serde(default)] + pub dispatch_after: Option, + #[serde(default)] + pub started_at: Option, + #[serde(default)] + pub ended_at: Option, + /// The night's whole budget, which may be split across several chunks. + #[serde(default)] + pub max_minutes: u32, + /// How many browser sessions this night is split into, and which one is + /// running. A night longer than one session's cap is checkpointed at each + /// boundary, and "chunk 2 of 3" is the only honest way to report that. + #[serde(default)] + pub chunks_total: u32, + #[serde(default)] + pub chunk_index: u32, + #[serde(default)] + pub sites_total: u32, + #[serde(default)] + pub sites_visited: u32, + #[serde(default)] + pub sites_failed: u32, + #[serde(default)] + pub consent_dismissed: u32, + #[serde(default)] + pub billed_seconds: u64, + /// Why it ended the way it did, e.g. `profile_locked`, `no_capacity`. + #[serde(default)] + pub outcome_code: Option, + #[serde(default)] + pub session_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotRunPage { + #[serde(default)] + pub runs: Vec, + /// Keyset cursor; `None` on the last page. + #[serde(default)] + pub next_before: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotRunStarted { + pub run: CookieBotRun, + #[serde(default)] + pub session_id: Option, +} + +/// A named intensity the user can pick. The client never learns what it +/// expands to; only enough to label the choice and show its rough cost. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotPreset { + pub id: String, + #[serde(default)] + pub typical_minutes: Option, + #[serde(default)] + pub recommended: bool, + /// Server-supplied English label, present only so a preset added after this + /// build still renders. The UI must prefer its own `t()` key for a known id. + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotPresetList { + #[serde(default)] + pub presets: Vec, + /// Which preset the server suggests when the user has expressed no + /// preference. + #[serde(default)] + pub default_preset: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RemoteHoursBreakdown { + #[serde(default)] + pub interactive_hours: f64, + #[serde(default)] + pub bot_hours: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RemoteHoursMember { + pub user_id: String, + pub email: String, + #[serde(default)] + pub role: Option, + #[serde(default)] + pub used_hours: f64, + #[serde(default)] + pub interactive_hours: f64, + #[serde(default)] + pub bot_hours: f64, +} + +/// The single pooled remote-hour budget. Bot and interactive hours share it; +/// the breakdown is reporting, never a sub-cap. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RemoteHoursQuota { + pub granted_hours: f64, + pub remaining_hours: f64, + #[serde(default)] + pub used_hours: f64, + #[serde(default)] + pub period_start: Option, + #[serde(default)] + pub period_end: Option, + /// `user` or `team`. + #[serde(default)] + pub scope: Option, + #[serde(default)] + pub team_id: Option, + #[serde(default)] + pub seats: u32, + #[serde(default)] + pub per_seat_hours: f64, + #[serde(default)] + pub breakdown: Option, + /// The full roster for an owner or admin; just the caller otherwise. + #[serde(default)] + pub members: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotUsageMember { + pub user_id: String, + pub email: String, + #[serde(default)] + pub role: Option, + #[serde(default)] + pub interactive_hours: f64, + #[serde(default)] + pub bot_hours: f64, + #[serde(default)] + pub used_hours: f64, + #[serde(default)] + pub sessions: u32, + #[serde(default)] + pub bot_runs: u32, + #[serde(default)] + pub bot_runs_failed: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotUsageProfile { + pub profile_id: String, + #[serde(default)] + pub profile_name: Option, + #[serde(default)] + pub owner_email: Option, + #[serde(default)] + pub bot_hours: f64, + #[serde(default)] + pub runs: u32, + /// How many of those runs did not do what they were asked. Its sibling on the + /// member view is `bot_runs_failed`; both are on the wire and both belong in + /// the report. + #[serde(default)] + pub runs_failed: u32, + #[serde(default)] + pub last_run_at: Option, + #[serde(default)] + pub last_status: Option, +} + +/// The team owner's after-the-fact view of who spent what. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CookieBotUsage { + pub period: String, + #[serde(default)] + pub period_start: Option, + #[serde(default)] + pub period_end: Option, + #[serde(default)] + pub team_id: Option, + #[serde(default)] + pub seats: u32, + #[serde(default)] + pub granted_hours: f64, + #[serde(default)] + pub used_hours: f64, + #[serde(default)] + pub remaining_hours: f64, + #[serde(default)] + pub members: Vec, + #[serde(default)] + pub profiles: Vec, +} + +// --- Client-side preconditions --------------------------------------------- + +/// Whether this profile could ever be warmed by the bot. +/// +/// The server is authoritative — it re-checks all of this and owns the parts +/// the client cannot see — but a profile that can never qualify should never +/// reach a confirm dialog, an hour of quota or a leased host. Returns the +/// `{"code":…}` string a Tauri command surfaces directly. +pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> { + if !profile.is_sync_enabled() { + // The host materialises the profile by pulling it from donut-sync. A + // local-only profile has nothing there, so there is no path to a run. + return Err(error("COOKIE_BOT_REQUIRES_CLOUD_SYNC", &[])); + } + if profile.is_encrypted_sync() { + // The key never leaves this machine, so the host would launch Chromium on + // ciphertext and push the corruption back over the real profile. + return Err(error("COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED", &[])); + } + let Some(platform) = profile.resolved_os() else { + return Err(error("COOKIE_BOT_UNKNOWN_PLATFORM", &[])); + }; + if !BOT_PLATFORMS.contains(&platform) { + return Err(error( + "COOKIE_BOT_UNSUPPORTED_PLATFORM", + &[("platform", platform)], + )); + } + if profile.proxy_id.is_none() && profile.vpn_id.is_none() { + // Without one the run egresses from the fleet's own datacenter address. + // Hours of traffic from a hosting ASN is worse for the profile's identity + // than not warming it at all. + return Err(error("COOKIE_BOT_REQUIRES_EXIT_NODE", &[])); + } + Ok(()) +} + +/// The profile facts the server needs but cannot see. +/// +/// The server holds the schedule; the PROFILE lives in the user's sync +/// namespace, so `sync_enabled`, `has_proxy` and the rest are only knowable +/// here. It requires them on every write rather than defaulting them, because +/// a defaulted `has_proxy` is a profile warmed out of the fleet's own +/// datacenter address. +/// +/// Derived in one place so the Tauri, REST and MCP call sites cannot drift into +/// three different answers about the same profile. +pub fn profile_state(profile: &BrowserProfile) -> ProfileState { + ProfileState { + sync_enabled: profile.is_sync_enabled(), + encrypted_sync: profile.is_encrypted_sync(), + // A VPN is an exit node just as much as a proxy is; the server only asks + // whether the traffic leaves through something the user brought. + has_proxy: profile.proxy_id.is_some() || profile.vpn_id.is_some(), + // Always false: this data model has no mobile/touch profile. `resolved_os` + // yields only windows, macos or linux, and `bot_precondition` already + // refuses everything but the first two. Reported rather than omitted so the + // server keeps one required shape, and it stays authoritative — it sees the + // real fingerprint on the host and can still refuse a run this cannot know + // to reject. + touch_fingerprint: false, + // A VPN is one persistent tunnel, so the night's chunks share an exit. A + // stored proxy may rotate per connection, and claiming stickiness we cannot + // guarantee is worse than declining it: the server's fallback is to run the + // night as a single chunk, which is the safe answer either way. + sticky_exit: profile.vpn_id.is_some(), + } +} + +/// What {@link profile_state} derives. Applied onto a schedule input before it +/// is sent. +#[derive(Debug, Clone, Copy)] +pub struct ProfileState { + pub sync_enabled: bool, + pub encrypted_sync: bool, + pub has_proxy: bool, + pub touch_fingerprint: bool, + pub sticky_exit: bool, +} + +impl CookieBotScheduleInput { + /// Stamp the profile facts onto an input built from user-chosen values. + pub fn with_profile_state(mut self, state: ProfileState) -> Self { + self.sync_enabled = state.sync_enabled; + self.encrypted_sync = state.encrypted_sync; + self.has_proxy = state.has_proxy; + self.touch_fingerprint = state.touch_fingerprint; + self.sticky_exit = state.sticky_exit; + self + } +} + +fn error(code: &str, params: &[(&str, &str)]) -> String { + let mut object = serde_json::Map::new(); + object.insert( + "code".to_string(), + serde_json::Value::String(code.to_string()), + ); + if !params.is_empty() { + let map = params + .iter() + .map(|(k, v)| { + ( + (*k).to_string(), + serde_json::Value::String((*v).to_string()), + ) + }) + .collect::>(); + object.insert("params".to_string(), serde_json::Value::Object(map)); + } + serde_json::Value::Object(object).to_string() +} + +// --- Routes ----------------------------------------------------------------- + +fn base() -> String { + format!("{}/api/cookie-bot", crate::cloud_auth::CLOUD_API_URL) +} + +/// Every enrolment the caller can see. +pub async fn list_schedules(scope: Option<&str>) -> Result { + let query = scope + .map(|s| vec![("scope".to_string(), s.to_string())]) + .unwrap_or_default(); + request( + reqwest::Method::GET, + format!("{}/schedules", base()), + query, + None, + SCHEDULE_CODES, + ) + .await +} + +/// This profile's enrolment, or `None` when there is none. +/// +/// "Not enrolled" is a state the UI renders, not a failure it reports, so the +/// 404 is folded into `Ok(None)` here rather than at every call site. +pub async fn get_schedule(profile_id: &str) -> Result, CookieBotError> { + let result: Result = request( + reqwest::Method::GET, + format!("{}/schedules/{}", base(), urlencoding::encode(profile_id)), + Vec::new(), + None, + SCHEDULE_CODES, + ) + .await; + + match result { + Ok(envelope) => Ok(Some(envelope.schedule)), + Err(e) if e.code() == "COOKIE_BOT_NOT_ENROLLED" => Ok(None), + Err(e) => Err(e), + } +} + +#[derive(Debug, Deserialize)] +struct ScheduleEnvelope { + schedule: CookieBotSchedule, +} + +/// Create or replace this profile's enrolment. +/// +/// `acknowledge_conflict` is the second half of a two-step write: the first +/// PUT is refused with the teammate's name and time, and the same PUT with the +/// flag set goes through. Two operators colliding into a silent nightly 409 is +/// exactly what that costs to avoid. +pub async fn save_schedule( + profile_id: &str, + input: &CookieBotScheduleInput, + acknowledge_conflict: bool, +) -> Result { + let mut body = serde_json::to_value(input).map_err(|e| { + CookieBotError(cloud_errors::transport_failure(&format!( + "encode schedule: {e}" + ))) + })?; + if let Some(object) = body.as_object_mut() { + object.insert( + "acknowledge_conflict".to_string(), + serde_json::Value::Bool(acknowledge_conflict), + ); + } + + request( + reqwest::Method::PUT, + format!("{}/schedules/{}", base(), urlencoding::encode(profile_id)), + Vec::new(), + Some(body), + SCHEDULE_CODES, + ) + .await +} + +/// Re-declare the profile facts the server cannot observe for itself. +/// +/// Narrow on purpose: a detached proxy should not have to resend a whole +/// schedule and risk clobbering an edit made from another device in between. +pub async fn update_profile_state( + profile_id: &str, + state: ProfileState, + timezone: Option<&str>, +) -> Result { + let mut body = serde_json::Map::new(); + body.insert("sync_enabled".to_string(), state.sync_enabled.into()); + body.insert("encrypted_sync".to_string(), state.encrypted_sync.into()); + body.insert("has_proxy".to_string(), state.has_proxy.into()); + body.insert( + "touch_fingerprint".to_string(), + state.touch_fingerprint.into(), + ); + body.insert("sticky_exit".to_string(), state.sticky_exit.into()); + if let Some(zone) = timezone { + body.insert("timezone".to_string(), zone.into()); + } + + let envelope: ScheduleEnvelope = request( + reqwest::Method::POST, + format!( + "{}/schedules/{}/profile-state", + base(), + urlencoding::encode(profile_id) + ), + Vec::new(), + Some(serde_json::Value::Object(body)), + SCHEDULE_CODES, + ) + .await?; + Ok(envelope.schedule) +} + +/// Push this profile's current facts to the server, without blocking the edit +/// that changed them. +/// +/// The server refuses a run on the copy the desktop last declared — +/// `has_proxy: false` is `proxy_required`, and that check exists because a run +/// without an exit node egresses from the leased host's own datacenter address. +/// Nothing but a full schedule write refreshed that copy, so detaching a proxy +/// from an enrolled profile left `has_proxy: true` on the row and the night ran +/// anyway. This closes that gap at the moment the profile changes. +/// +/// Silent on failure by design: a profile edit must not fail because the cloud +/// is unreachable, an unenrolled profile answers `COOKIE_BOT_NOT_ENROLLED` +/// which is the normal case, and the next edit re-declares the same facts. +pub fn report_profile_state(profile: &BrowserProfile) { + let profile_id = profile.id.to_string(); + let state = profile_state(profile); + tauri::async_runtime::spawn(async move { + if !crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { + return; + } + match update_profile_state(&profile_id, state, None).await { + Ok(_) => { + log::debug!("Re-declared cookie-bot profile state for {profile_id}"); + } + // Not enrolled is the common answer and not worth a log line at warn. + Err(e) if e.code() == "COOKIE_BOT_NOT_ENROLLED" => {} + Err(e) => { + log::warn!("Could not re-declare cookie-bot profile state for {profile_id}: {e}"); + } + } + }); +} + +/// Turn the bot off for this profile. Safe to repeat: deleting an enrolment +/// that is already gone succeeds with `deleted: false`. +pub async fn delete_schedule(profile_id: &str) -> Result { + request( + reqwest::Method::DELETE, + format!("{}/schedules/{}", base(), urlencoding::encode(profile_id)), + Vec::new(), + None, + SCHEDULE_CODES, + ) + .await +} + +/// Ask, without writing anything, who else already warms this profile. +pub async fn check_conflicts( + profile_id: &str, + run_at_minute: Option, + timezone: Option<&str>, + days_mask: Option, +) -> Result { + let mut query = vec![("profile_id".to_string(), profile_id.to_string())]; + if let Some(minute) = run_at_minute { + query.push(("run_at_minute".to_string(), minute.to_string())); + } + if let Some(zone) = timezone { + query.push(("timezone".to_string(), zone.to_string())); + } + if let Some(mask) = days_mask { + query.push(("days_mask".to_string(), mask.to_string())); + } + + request( + reqwest::Method::GET, + format!("{}/conflicts", base()), + query, + None, + SCHEDULE_CODES, + ) + .await +} + +/// One page of run history, newest first. +pub async fn list_runs( + profile_id: Option<&str>, + scope: Option<&str>, + limit: Option, + before: Option<&str>, +) -> Result { + let mut query = Vec::new(); + if let Some(id) = profile_id { + query.push(("profile_id".to_string(), id.to_string())); + } + if let Some(s) = scope { + query.push(("scope".to_string(), s.to_string())); + } + if let Some(n) = limit { + query.push(("limit".to_string(), n.to_string())); + } + if let Some(cursor) = before { + query.push(("before".to_string(), cursor.to_string())); + } + + request( + reqwest::Method::GET, + format!("{}/runs", base()), + query, + None, + RUN_CODES, + ) + .await +} + +/// Start a run now instead of waiting for tonight. +/// +/// The preset and the site list come from the stored schedule, so this carries +/// no behaviour of its own — an unenrolled profile is a 404, not an implicit +/// enrolment with client-chosen defaults. +pub async fn run_now( + profile_id: &str, + max_minutes: Option, +) -> Result { + let mut body = serde_json::Map::new(); + body.insert( + "profile_id".to_string(), + serde_json::Value::String(profile_id.to_string()), + ); + if let Some(minutes) = max_minutes { + body.insert("max_minutes".to_string(), serde_json::Value::from(minutes)); + } + + request( + reqwest::Method::POST, + format!("{}/runs", base()), + Vec::new(), + Some(serde_json::Value::Object(body)), + RUN_CODES, + ) + .await +} + +/// Stop a run that is still going. +/// +/// A 503 here means the fleet could not be reached and the browser is still +/// up, so the run stays `running` rather than being marked cancelled under a +/// live browser — retiring a row while something is still writing the cookie +/// jar is the two-writer case the profile lock exists to prevent. +pub async fn cancel_run(run_id: &str) -> Result { + let envelope: RunEnvelope = request( + reqwest::Method::DELETE, + format!("{}/runs/{}", base(), urlencoding::encode(run_id)), + Vec::new(), + None, + RUN_CODES, + ) + .await?; + Ok(envelope.run) +} + +#[derive(Debug, Deserialize)] +struct RunEnvelope { + run: CookieBotRun, +} + +/// The intensities the server offers today. +pub async fn list_presets() -> Result { + request( + reqwest::Method::GET, + format!("{}/presets", base()), + Vec::new(), + None, + REPORT_CODES, + ) + .await +} + +/// Per-member and per-profile spend for a calendar month (`YYYY-MM`). +pub async fn team_usage(period: Option<&str>) -> Result { + let query = period + .map(|p| vec![("period".to_string(), p.to_string())]) + .unwrap_or_default(); + request( + reqwest::Method::GET, + format!("{}/usage", base()), + query, + None, + REPORT_CODES, + ) + .await +} + +/// The pooled remote-hour budget. +/// +/// Being refused a launch must not be the only way to learn a limit exists, +/// which is what this route has been for as long as nothing called it. +pub async fn remote_hours_quota() -> Result { + request( + reqwest::Method::GET, + format!( + "{}/api/remote-sessions/quota", + crate::cloud_auth::CLOUD_API_URL + ), + Vec::new(), + None, + REPORT_CODES, + ) + .await +} + +// --- Transport -------------------------------------------------------------- + +fn http() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) + }) +} + +/// Append a percent-encoded query string. +/// +/// Built here rather than left to the HTTP client so a profile id or a keyset +/// cursor containing a `&` cannot smuggle a second parameter into the request. +fn with_query(url: &str, query: &[(String, String)]) -> String { + if query.is_empty() { + return url.to_string(); + } + let encoded = query + .iter() + .map(|(key, value)| { + format!( + "{}={}", + urlencoding::encode(key), + urlencoding::encode(value) + ) + }) + .collect::>() + .join("&"); + let separator = if url.contains('?') { '&' } else { '?' }; + format!("{url}{separator}{encoded}") +} + +/// One request, one place. +/// +/// Goes through `api_call_with_retry` so an expired access token is refreshed +/// and the call retried once — otherwise a user whose token aged out overnight +/// sees "not signed in" on a machine that is signed in. +async fn request( + method: reqwest::Method, + url: String, + query: Vec<(String, String)>, + body: Option, + codes: FailureCodes, +) -> Result { + crate::cloud_auth::CLOUD_AUTH + .api_call_with_retry(|token| { + let method = method.clone(); + let url = url.clone(); + let query = query.clone(); + let body = body.clone(); + async move { + let url = with_query(&url, &query); + let mut builder = http().request(method, &url).bearer_auth(token); + if let Some(payload) = body { + builder = builder.json(&payload); + } + + let response = builder + .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(); + // Encode the status so api_call_with_retry can spot a 401 and + // classify_message can recover the code afterwards. + return Err(format!("({status}) {text}")); + } + + response + .json::() + .await + .map_err(|e| format!("decode response: {e}")) + } + }) + .await + .map_err(|e| CookieBotError(cloud_errors::classify_message(&e, codes))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::types::SyncMode; + + fn eligible_profile() -> BrowserProfile { + 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() + } + } + + #[test] + fn the_profile_facts_the_server_requires_are_derived_from_the_profile() { + let state = profile_state(&eligible_profile()); + + assert!(state.sync_enabled); + assert!(!state.encrypted_sync); + assert!(state.has_proxy); + assert!(!state.touch_fingerprint); + // A stored proxy is not claimed to be sticky — only a VPN tunnel is. + assert!(!state.sticky_exit); + } + + #[test] + fn a_vpn_counts_as_the_exit_node_and_as_a_sticky_one() { + // `has_proxy` asks whether the user brought an exit, not whether that exit + // is specifically a stored proxy. A VPN-only profile that reported false + // would be refused a run it is perfectly entitled to. + let profile = BrowserProfile { + proxy_id: None, + vpn_id: Some("vpn-1".to_string()), + ..eligible_profile() + }; + + let state = profile_state(&profile); + + assert!(state.has_proxy); + assert!(state.sticky_exit); + } + + #[test] + fn a_profile_with_no_exit_reports_none() { + let profile = BrowserProfile { + proxy_id: None, + vpn_id: None, + ..eligible_profile() + }; + + assert!(!profile_state(&profile).has_proxy); + } + + fn code_of(err: &str) -> String { + serde_json::from_str::(err) + .expect("a precondition failure must be a JSON error envelope")["code"] + .as_str() + .expect("the envelope must name a code") + .to_string() + } + + #[test] + fn a_local_only_profile_has_no_path_to_a_run() { + // The host obtains the profile from donut-sync. Without sync there is + // nothing to pull, so the run would warm an empty browser and then push + // that emptiness over the user's real profile. + let mut profile = eligible_profile(); + profile.sync_mode = SyncMode::Disabled; + let err = bot_precondition(&profile).expect_err("a local-only profile must be refused"); + assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_CLOUD_SYNC"); + } + + #[test] + fn end_to_end_encrypted_sync_is_refused_with_its_own_code() { + // Distinct from "turn sync on": the fix is to switch to Regular sync, and + // one code cannot carry two different instructions. + let mut profile = eligible_profile(); + profile.sync_mode = SyncMode::Encrypted; + let err = bot_precondition(&profile).expect_err("encrypted sync must be refused"); + assert_eq!(code_of(&err), "COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED"); + } + + #[test] + fn linux_is_refused_at_enrolment_rather_than_at_two_in_the_morning() { + let mut profile = eligible_profile(); + profile.host_os = Some("linux".to_string()); + let err = bot_precondition(&profile).expect_err("linux has no host to lease"); + let parsed: serde_json::Value = serde_json::from_str(&err).expect("valid envelope"); + assert_eq!(parsed["code"], "COOKIE_BOT_UNSUPPORTED_PLATFORM"); + assert_eq!( + parsed["params"]["platform"], "linux", + "the message must name the platform that cannot run" + ); + } + + #[test] + fn a_profile_with_no_recorded_os_cannot_be_scheduled_onto_a_host() { + let mut profile = eligible_profile(); + profile.host_os = None; + let err = bot_precondition(&profile).expect_err("no OS means no matching host"); + assert_eq!(code_of(&err), "COOKIE_BOT_UNKNOWN_PLATFORM"); + } + + #[test] + fn a_run_without_a_proxy_or_vpn_is_refused() { + // Hours of overnight traffic from a hosting ASN damages the profile's + // identity more than not warming it would. + let mut profile = eligible_profile(); + profile.proxy_id = None; + profile.vpn_id = None; + let err = bot_precondition(&profile).expect_err("datacenter egress must be refused"); + assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_EXIT_NODE"); + } + + #[test] + fn a_vpn_satisfies_the_exit_node_requirement_just_as_a_proxy_does() { + let mut profile = eligible_profile(); + profile.proxy_id = None; + profile.vpn_id = Some("vpn-1".to_string()); + assert!(bot_precondition(&profile).is_ok()); + } + + #[test] + fn a_windows_profile_with_sync_and_a_proxy_qualifies() { + let mut profile = eligible_profile(); + profile.host_os = Some("windows".to_string()); + assert!(bot_precondition(&profile).is_ok()); + } + + /// A verbatim `CookieBotScheduleView`, field for field, as `toScheduleView` + /// in donutbrowser-infra's `cookie-bot.service.ts` builds it. + const SERVER_SCHEDULE_VIEW: &str = r#"{ + "profile_id":"p1","profile_name":"Yu","platform":"macos","enabled":true, + "run_at_minute":120,"days_mask":127,"timezone":"Europe/Berlin", + "preset":"balanced","max_minutes":45,"sites":["https://example.com"], + "jitter_seconds":900,"sync_enabled":true,"encrypted_sync":false, + "has_proxy":true,"touch_fingerprint":false,"sticky_exit":false, + "profile_state_at":"2026-08-03T09:00:00.000Z","next_run_at":"2026-08-04T00:00:00.000Z", + "last_run_at":null,"last_run_id":null,"blocked_by":null,"owner_user_id":"u1", + "owner_email":"a@example.com","updated_at":"2026-08-03T10:00:00.000Z" + }"#; + + #[test] + fn the_schedule_payload_matches_what_the_backend_sends() { + // Pinned against the Schedule shape in donutbrowser-infra's + // cookie-bot controller. A field name that drifts makes every read fail + // at the decode step, which surfaces as "something went wrong" with no + // hint that the contract moved. + let schedule: CookieBotSchedule = serde_json::from_str(SERVER_SCHEDULE_VIEW) + .expect("the backend's schedule payload must deserialize"); + + assert_eq!(schedule.run_at_minute, 120); + assert_eq!(schedule.days_mask, 127); + assert_eq!(schedule.max_minutes, 45); + assert_eq!(schedule.sites, vec!["https://example.com".to_string()]); + assert!(schedule.last_run_at.is_none()); + // The declared facts, echoed back. Dropped, they left the UI unable to see + // that the server's copy of a profile no longer matched this machine's. + assert!(schedule.sync_enabled); + assert!(schedule.has_proxy); + assert!(!schedule.encrypted_sync); + assert!(schedule.profile_state_at.is_some()); + assert!(schedule.blocked_by.is_none()); + } + + #[test] + fn a_broken_enrolment_carries_the_reason_it_cannot_run() { + // The whole point of `blocked_by`: a profile whose proxy was detached in + // the afternoon should not first announce itself with a skipped run at + // 02:00. Dropping the field made the enrolment look healthy until it + // failed. + let schedule: CookieBotSchedule = serde_json::from_str( + &SERVER_SCHEDULE_VIEW + .replace("\"has_proxy\":true", "\"has_proxy\":false") + .replace("\"blocked_by\":null", "\"blocked_by\":\"proxy_required\""), + ) + .expect("a blocked schedule must deserialize"); + + assert!(!schedule.has_proxy); + assert_eq!(schedule.blocked_by.as_deref(), Some("proxy_required")); + } + + #[test] + fn a_schedule_missing_every_optional_field_still_decodes() { + // A freshly created enrolment has never run, so the backend omits or + // nulls half the object. Failing to decode that would make the enrolment + // the user just made look broken. + let schedule: CookieBotSchedule = serde_json::from_str( + r#"{"profile_id":"p1","profile_name":"Yu","platform":"windows","enabled":false, + "run_at_minute":0,"days_mask":1,"timezone":"UTC","preset":"light", + "max_minutes":5}"#, + ) + .expect("a never-run schedule must deserialize"); + assert!(schedule.sites.is_empty()); + assert_eq!(schedule.jitter_seconds, 0); + assert!(schedule.next_run_at.is_none()); + } + + #[test] + fn the_run_payload_matches_what_the_backend_sends() { + // Verbatim `CookieBotRunView`, as `toRunViews` builds it. `max_minutes`, + // `chunks_total`, `chunk_index`, `dispatch_after` and `team_id` were all + // already on the wire and all silently discarded, so a multi-chunk night + // could not be reported as one. + let page: CookieBotRunPage = serde_json::from_str( + r#"{"runs":[{"id":"r1","profile_id":"p1","profile_name":"Yu","user_id":"u1", + "email":"a@example.com","team_id":"t1","trigger":"schedule","status":"running", + "scheduled_for":"2026-08-03T00:00:00.000Z", + "dispatch_after":"2026-08-03T00:07:30.000Z","started_at":"2026-08-03T00:08:00.000Z", + "ended_at":null,"max_minutes":180,"chunks_total":3,"chunk_index":2, + "sites_total":12,"sites_visited":11, + "sites_failed":1,"consent_dismissed":4,"billed_seconds":2220, + "outcome_code":null,"session_id":"s1"}],"next_before":null}"#, + ) + .expect("the backend's run page must deserialize"); + + let run = &page.runs[0]; + assert_eq!(run.status, "running"); + assert_eq!(run.billed_seconds, 2220); + assert_eq!(run.sites_visited, 11); + assert_eq!(run.team_id.as_deref(), Some("t1")); + assert_eq!(run.max_minutes, 180); + assert_eq!(run.chunks_total, 3); + assert_eq!(run.chunk_index, 2); + assert!(run.dispatch_after.is_some()); + assert!(page.next_before.is_none()); + } + + #[test] + fn the_usage_report_keeps_the_per_profile_failure_count() { + // `runs_failed` sits beside `runs` on the wire and is the one number that + // answers "is this enrolment actually working?" in the team dashboard. + let usage: CookieBotUsage = serde_json::from_str( + r#"{"period":"2026-08","period_start":"2026-08-01T00:00:00.000Z", + "period_end":"2026-09-01T00:00:00.000Z","team_id":"t1","seats":2, + "granted_hours":400,"used_hours":12.5,"remaining_hours":387.5, + "members":[], + "profiles":[{"profile_id":"p1","profile_name":"Yu","owner_email":"a@example.com", + "bot_hours":12.5,"runs":9,"runs_failed":4, + "last_run_at":"2026-08-03T00:41:00.000Z","last_status":"failed"}]}"#, + ) + .expect("the usage report must deserialize"); + + assert_eq!(usage.profiles[0].runs, 9); + assert_eq!(usage.profiles[0].runs_failed, 4); + } + + #[test] + fn the_profile_state_body_declares_every_fact_the_server_gates_on() { + // The narrow re-declaration route. `has_proxy` false is `proxy_required` + // server-side, so an omitted field silently keeps the stale value and the + // night runs unproxied. + let state = profile_state(&eligible_profile()); + let body = serde_json::json!({ + "sync_enabled": state.sync_enabled, + "encrypted_sync": state.encrypted_sync, + "has_proxy": state.has_proxy, + "touch_fingerprint": state.touch_fingerprint, + "sticky_exit": state.sticky_exit, + }); + for key in [ + "sync_enabled", + "encrypted_sync", + "has_proxy", + "touch_fingerprint", + "sticky_exit", + ] { + assert!( + body.get(key).is_some_and(serde_json::Value::is_boolean), + "{key} must be declared, not left to the server's stale copy" + ); + } + } + + #[test] + fn the_gui_payload_deserialises_without_the_facts_the_command_stamps() { + // This is exactly what `saveCookieBotSchedule` in src/lib/cookie-bot.ts + // sends: the user's own scalars and nothing else. Requiring the profile + // facts here made Tauri reject the argument with + // `invalid args schedule: missing field sync_enabled` before + // `with_profile_state` ever ran, so every enrolment from the GUI failed + // while REST and MCP — which build the struct in Rust — worked. + let input: CookieBotScheduleInput = serde_json::from_str( + r#"{"profile_name":"Yu","platform":"macos","enabled":true,"run_at_minute":120, + "days_mask":127,"timezone":"Europe/Berlin","preset":"balanced", + "max_minutes":45,"sites":["https://example.com"]}"#, + ) + .expect("the frontend's schedule payload must deserialize"); + + // Fail-closed: an input nobody stamped claims no sync and no exit node, and + // the server refuses both. The dangerous default would be the other way. + assert!(!input.sync_enabled); + assert!(!input.has_proxy); + + let stamped = input.with_profile_state(profile_state(&eligible_profile())); + assert!(stamped.sync_enabled); + assert!(stamped.has_proxy); + } + + #[test] + fn a_schedule_input_serialises_without_a_next_run_at() { + // The server always recomputes the next fire instant. Sending one would + // invite a client and a server that disagree about when the bot runs. + let input = CookieBotScheduleInput { + profile_name: "Yu".to_string(), + platform: "macos".to_string(), + enabled: true, + run_at_minute: 120, + days_mask: 127, + timezone: "Europe/Berlin".to_string(), + preset: "balanced".to_string(), + max_minutes: 45, + sites: vec!["https://example.com".to_string()], + jitter_seconds: None, + ..Default::default() + }; + // The server rejects a write missing either of these with + // COOKIE_BOT_INVALID_SCHEDULE, so they must be on the wire unconditionally + // — `skip_serializing_if` on them would make every enrolment a 400. + let encoded = serde_json::to_value(&input).expect("input must serialize"); + assert!(encoded.get("sync_enabled").is_some()); + assert!(encoded.get("has_proxy").is_some()); + assert!(encoded.get("next_run_at").is_none()); + assert!( + encoded.get("jitter_seconds").is_none(), + "an unset jitter must be omitted so the server's default applies" + ); + } + + #[test] + fn the_quota_payload_survives_a_backend_that_only_sends_the_original_two_keys() { + // The route predates this feature and returned only these two fields. + // A deployment that has not rolled forward must still render a budget. + let quota: RemoteHoursQuota = + serde_json::from_str(r#"{"granted_hours":200,"remaining_hours":187.25}"#) + .expect("the legacy quota payload must deserialize"); + assert_eq!(quota.granted_hours, 200.0); + assert_eq!(quota.seats, 0); + assert!(quota.members.is_empty()); + } + + #[test] + fn a_pooled_team_quota_decodes_its_roster() { + let quota: RemoteHoursQuota = serde_json::from_str( + r#"{"granted_hours":600,"remaining_hours":0,"used_hours":612.75,"scope":"team", + "team_id":"t1","seats":3,"per_seat_hours":200, + "breakdown":{"interactive_hours":100.5,"bot_hours":512.25}, + "members":[{"user_id":"u1","email":"a@example.com","role":"owner", + "used_hours":400,"interactive_hours":90,"bot_hours":310}]}"#, + ) + .expect("the pooled quota payload must deserialize"); + + // used_hours is deliberately unclamped while remaining_hours is: an + // over-spent team must be able to see how far over it went. + assert_eq!(quota.used_hours, 612.75); + assert_eq!(quota.remaining_hours, 0.0); + assert_eq!(quota.seats, 3); + assert_eq!(quota.members.len(), 1); + assert_eq!( + quota.breakdown.map(|b| b.bot_hours), + Some(512.25), + "the bot/interactive split is reporting only, but it must survive the wire" + ); + } + + #[test] + fn a_conflict_response_carries_the_teammate_the_ui_has_to_name() { + let saved: CookieBotScheduleSaved = serde_json::from_str( + r#"{"schedule":{"profile_id":"p1","profile_name":"Yu","platform":"macos", + "enabled":true,"run_at_minute":120,"days_mask":127,"timezone":"UTC", + "preset":"deep","max_minutes":90}, + "conflicts":[{"user_id":"u2","email":"alex@example.com","run_at_minute":120, + "timezone":"UTC","days_mask":127,"enabled":true}]}"#, + ) + .expect("an acknowledged write must still report the conflict"); + assert_eq!(saved.conflicts[0].email, "alex@example.com"); + assert!(!saved.conflicts[0].overlaps); + } + + #[test] + fn errors_render_as_the_envelope_the_frontend_translates() { + let err = CookieBotError(cloud_errors::classify_message( + r#"(403) {"code":"COOKIE_BOT_NOT_ENTITLED"}"#, + SCHEDULE_CODES, + )); + assert_eq!(err.code(), "COOKIE_BOT_NOT_ENTITLED"); + assert_eq!(err.status(), 403); + assert_eq!(err.to_error_json(), r#"{"code":"COOKIE_BOT_NOT_ENTITLED"}"#); + } + + #[test] + fn a_bare_404_means_something_different_on_a_schedule_and_on_a_run() { + // Both routes 404. Sharing one code would tell a user with no enrolment + // that their run id is wrong, and vice versa. + assert_eq!( + cloud_errors::classify_message("(404) Not Found", SCHEDULE_CODES).code, + "COOKIE_BOT_NOT_ENROLLED" + ); + assert_eq!( + cloud_errors::classify_message("(404) Not Found", RUN_CODES).code, + "COOKIE_BOT_RUN_NOT_FOUND" + ); + } + + #[test] + fn query_values_are_encoded_so_they_cannot_smuggle_a_parameter() { + // A keyset cursor is a server-issued opaque string. One containing `&` + // would otherwise inject a second parameter into the request. + let url = with_query( + "https://api.example.com/runs", + &[ + ("scope".to_string(), "team".to_string()), + ( + "before".to_string(), + "2026-08-03T00:00:00Z&limit=100".to_string(), + ), + ], + ); + assert_eq!( + url, + "https://api.example.com/runs?scope=team&before=2026-08-03T00%3A00%3A00Z%26limit%3D100" + ); + assert_eq!( + with_query("https://api.example.com/runs", &[]), + "https://api.example.com/runs", + "an empty query must not leave a dangling separator" + ); + } + + #[test] + fn the_preset_list_carries_ids_not_behaviour() { + // If this type ever gained a site list, a dwell range or a step + // programme, the browsing model would have leaked into the open-source + // client. Ids and a rough duration are all that may cross. + let presets: CookieBotPresetList = serde_json::from_str( + r#"{"presets":[{"id":"balanced","typical_minutes":35,"recommended":true}], + "default_preset":"balanced"}"#, + ) + .expect("the preset list must deserialize"); + assert_eq!(presets.presets[0].id, "balanced"); + assert_eq!(presets.presets[0].typical_minutes, Some(35)); + assert_eq!(presets.default_preset.as_deref(), Some("balanced")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e847bd2..50d7bfa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, 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::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::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 { + 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, +) -> Result { + 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, 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 { + // 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 { + 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, + timezone: Option, + days_mask: Option, +) -> Result, 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, + scope: Option, + limit: Option, + before: Option, +) -> Result { + 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, +) -> Result { + 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::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::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::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, +) -> Result { + 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 diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index d9624a5..ba79c48 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -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(value: &T) -> Result { + 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 { + 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, 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, 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::>() + }) + .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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 = 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 diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 52e4792..9392271 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -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 { diff --git a/src-tauri/src/remote_session.rs b/src-tauri/src/remote_session.rs index 4d9ea91..8c5e590 100644 --- a/src-tauri/src/remote_session.rs +++ b/src-tauri/src/remote_session.rs @@ -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::() { - 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, + #[serde(default)] + pub platform: Option, + /// `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, + /// Set when this session belongs to a cookie-bot run. + #[serde(default)] + pub run_id: Option, + /// The team the hours are attributed to, when the caller belongs to one. + #[serde(default)] + pub team_id: Option, + #[serde(default)] + pub started_at: Option, + /// When it finished. The backend sends one timestamp, not a + /// `ready_at`/`closed_at` pair. + #[serde(default)] + pub ended_at: Option, + /// Why it ended: `stopped_by_user`, `max_duration`, `lost the profile lock`… + #[serde(default)] + pub close_reason: Option, + /// 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, +} + +#[derive(Debug, Clone, Deserialize)] +struct RemoteSessionListResponse { + #[serde(default)] + sessions: Vec, +} + +/// Every session the caller currently owns. +pub async fn list_remote_sessions() -> Result, 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 { + let endpoint = format!( + "{}/api/remote-sessions/{}", + crate::cloud_auth::CLOUD_API_URL, + urlencoding::encode(session_id) + ); + get_json(endpoint).await +} + +async fn get_json( + endpoint: String, +) -> Result { + 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::() + .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>> = Mutex::new(None); + +/// One frame off the wire. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SseFrame { + pub id: Option, + pub event: Option, + 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, + event: Option, + data: String, + id: Option, +} + +impl SseDecoder { + pub fn new() -> Self { + Self::default() + } + + /// Feed bytes, get back whatever frames completed. + pub fn push(&mut self, chunk: &[u8]) -> Vec { + 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 = 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 { + 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::(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 = 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 = 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 { + 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, +) -> 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()); + } } diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index cd155b3..081f29b 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -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 diff --git a/src-tauri/src/sync/scheduler.rs b/src-tauri/src/sync/scheduler.rs index 8bb6426..a001438 100644 --- a/src-tauri/src/sync/scheduler.rs +++ b/src-tauri/src/sync/scheduler.rs @@ -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; diff --git a/src/app/page.tsx b/src/app/page.tsx index daf7637..9e82285 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -18,6 +18,7 @@ import { ConsistencyWarningDialog, isConsistencyWarningSuppressed, } from "@/components/consistency-warning-dialog"; +import { CookieBotPage, type CookieBotTab } from "@/components/cookie-bot-page"; import { CookieCopyDialog } from "@/components/cookie-copy-dialog"; import { CookieManagementDialog } from "@/components/cookie-management-dialog"; import { CreateProfileDialog } from "@/components/create-profile-dialog"; @@ -55,6 +56,7 @@ import { WindowResizeWarningDialog } from "@/components/window-resize-warning-di import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"; import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { useCommercialTrial } from "@/hooks/use-commercial-trial"; +import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot"; import { useGroupEvents } from "@/hooks/use-group-events"; import type { PermissionType } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions"; @@ -66,7 +68,7 @@ import { useVersionUpdater } from "@/hooks/use-version-updater"; import { useVpnEvents } from "@/hooks/use-vpn-events"; import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; import { translateBackendError } from "@/lib/backend-errors"; -import { getEntitlements } from "@/lib/entitlements"; +import { canUseCookieBot, getEntitlements } from "@/lib/entitlements"; import { MOTION_EASE_OUT } from "@/lib/motion"; import { ONBOARDING_TOUR_CLOSED_EVENT, @@ -253,6 +255,14 @@ export default function Home() { // /v1/profiles/batch/run API gate. Free/starter users see the bulk Run/Stop // actions disabled with a Pro badge. const automationUnlocked = getEntitlements(cloudUser).browserAutomation; + // The rail needs to show a live run from every page, so the shell subscribes + // to the shared cookie-bot store too. It is a module singleton, so this costs + // one more listener and no extra request. This is also what starts the event + // stream for a user who signs in without restarting the app. + const { liveSessions: cookieBotLiveSessions } = useCookieBot( + canUseCookieBot(cloudUser), + cookieBotScopeFor(cloudUser), + ); const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] = useState(false); @@ -283,6 +293,9 @@ export default function Home() { const [integrationsInitialTab, setIntegrationsInitialTab] = useState< "api" | "mcp" >("api"); + const [cookieBotDialogOpen, setCookieBotDialogOpen] = useState(false); + const [cookieBotInitialTab, setCookieBotInitialTab] = + useState("overview"); const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false); @@ -379,6 +392,7 @@ export default function Home() { setIntegrationsDialogOpen(false); setImportProfileDialogOpen(false); setAccountDialogOpen(false); + setCookieBotDialogOpen(false); setCurrentPage(page); switch (page) { @@ -397,6 +411,9 @@ export default function Home() { case "groups": setGroupManagementDialogOpen(true); break; + case "cookieBot": + setCookieBotDialogOpen(true); + break; case "integrations": setIntegrationsDialogOpen(true); break; @@ -462,6 +479,19 @@ export default function Home() { case "goGroups": handleRailNavigate("groups"); break; + case "goCookieBot": { + // Mod+B: navigate first time; flip overview↔activity while already + // there, matching how Mod+I flips the integrations tabs. + if (currentPage === "cookieBot") { + setCookieBotInitialTab((cur) => + cur === "overview" ? "activity" : "overview", + ); + } else { + setCookieBotInitialTab("overview"); + handleRailNavigate("cookieBot"); + } + break; + } case "goIntegrations": { // Mod+I: flip api↔mcp tab when already on integrations. if (currentPage === "integrations") { @@ -1636,6 +1666,7 @@ export default function Home() { onOpenAbout={() => { setAboutDialogOpen(true); }} + cookieBotRunning={Object.keys(cookieBotLiveSessions).length > 0} />
{currentPage === "profiles" && ( @@ -1666,6 +1697,7 @@ export default function Home() { isUpdating={isUpdating} onDeleteSelectedProfiles={handleDeleteSelectedProfiles} onAssignProfilesToGroup={handleAssignProfilesToGroup} + onAssignProfilesToProxy={handleAssignProfilesToProxy} selectedGroupId={selectedGroupId} selectedProfiles={selectedProfiles} onSelectedProfilesChange={setSelectedProfiles} @@ -1786,6 +1818,22 @@ export default function Home() { /> )} + {cookieBotDialogOpen && ( + { + setCookieBotDialogOpen(false); + setCurrentPage("profiles"); + }} + subPage={currentPage === "cookieBot"} + initialTab={cookieBotInitialTab} + profiles={profiles} + cloudUser={cloudUser} + onOpenProfileSync={handleOpenProfileSyncDialog} + onAssignProxy={handleAssignProfilesToProxy} + /> + )} + {accountDialogOpen && ( { + if (!showTeamUsage && activeTab === "team-usage") setActiveTab("account"); + }, [showTeamUsage, activeTab]); + // Self-hosted server state. Loaded once when the dialog opens and persisted // via `save_sync_settings` so the rest of the app picks up the new URL/token // from `SettingsManager`. @@ -201,11 +231,16 @@ export function AccountPage({
- + {t("account.tabs.account")} + {showTeamUsage && ( + + {t("account.tabs.teamUsage")} + + )}
+ {remoteHoursVisible && ( + // A headline block, not one field among six: the allowance + // is the number a customer needs before a launch is + // refused, which is the only way they ever saw it before. +
+
+

+ {t("cookieBot.hours.label")} +

+ {formatDate(quota?.period_end) && ( +

+ {t("cookieBot.hours.resets", { + date: formatDate(quota?.period_end), + })} +

+ )} +
+

+ {quota ? formatHours(quota.remaining_hours) : "—"} + + {t("cookieBot.hours.remainingOf", { + total: quota + ? formatHours(quota.granted_hours) + : "—", + })} + +

+ +
+

+ {t("cookieBot.hours.used", { + used: quota ? formatHours(quota.used_hours) : "—", + total: quota + ? formatHours(quota.granted_hours) + : "—", + })} +

+ {showTeamUsage && ( + + )} +
+
+ )} + {isLoggedIn && user && (
@@ -362,6 +454,12 @@ export function AccountPage({
+ {showTeamUsage && ( + + + + )} + {selfHostedDisabled ? ( // Defensive: the tab trigger is disabled while the user is diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx index 1d20142..08740f2 100644 --- a/src/components/command-palette.tsx +++ b/src/components/command-palette.tsx @@ -8,6 +8,7 @@ import { LuBadgeInfo, LuCircleStop, LuCloud, + LuCookie, LuInfo, LuKeyboard, LuPlay, @@ -67,6 +68,7 @@ const ICONS: Record> = { goProxies: FiWifi, goExtensions: LuPuzzle, goGroups: LuUsers, + goCookieBot: LuCookie, goIntegrations: LuPlug, goAccount: LuCloud, goSettings: GoGear, diff --git a/src/components/cookie-bot-activity.tsx b/src/components/cookie-bot-activity.tsx new file mode 100644 index 0000000..939142e --- /dev/null +++ b/src/components/cookie-bot-activity.tsx @@ -0,0 +1,583 @@ +"use client"; + +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuSearch } from "react-icons/lu"; +import { + formatDateTime, + formatDuration, + formatElapsed, + hasRunCounters, + indexProfiles, + indexRunsById, + indexRunsBySession, + outcomeLabel, + parseIso, + runStatusLabel, + runStatusTone, + StatusDot, + sessionCloseReason, + sessionDisplayName, + sessionElapsedSeconds, + sessionPhaseLabel, + sessionTone, + useSecondTicker, +} from "@/components/cookie-bot-shared"; +import { Button } from "@/components/ui/button"; +import { FadingScrollArea } from "@/components/ui/fading-scroll-area"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { translateBackendError } from "@/lib/backend-errors"; +import { type CookieBotRun, cancelCookieBotRun } from "@/lib/cookie-bot"; +import { MOTION_EASE_OUT } from "@/lib/motion"; +import { + type RemoteSessionState, + stopRemoteSession, +} from "@/lib/remote-sessions"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { cn } from "@/lib/utils"; +import type { BrowserProfile } from "@/types"; + +export type RunFilter = "all" | "succeeded" | "partial" | "failed"; + +interface CookieBotActivityProps { + live: RemoteSessionState[]; + streamConnected: boolean; + runs: CookieBotRun[]; + isLoading: boolean; + profiles: BrowserProfile[]; + showOperator: boolean; + filter: RunFilter; + onFilterChange: (filter: RunFilter) => void; + onRefresh: () => void; +} + +export function CookieBotActivity({ + live, + streamConnected, + runs, + isLoading, + profiles, + showOperator, + filter, + onFilterChange, + onRefresh, +}: CookieBotActivityProps) { + const { t } = useTranslation(); + const [search, setSearch] = useState(""); + + const profileIndex = useMemo(() => indexProfiles(profiles), [profiles]); + const runsBySession = useMemo(() => indexRunsBySession(runs), [runs]); + const runsById = useMemo(() => indexRunsById(runs), [runs]); + + const filtered = useMemo(() => { + const needle = search.trim().toLowerCase(); + return runs.filter((run) => { + if (filter !== "all" && run.status !== filter) return false; + if (!needle) return true; + const name = run.profile_name ?? profileIndex.get(run.profile_id)?.name; + return ( + (name ?? "").toLowerCase().includes(needle) || + (run.email ?? "").toLowerCase().includes(needle) + ); + }); + }, [runs, filter, search, profileIndex]); + + return ( +
+ + +
+
+ + { + setSearch(event.target.value); + }} + className="h-8 pl-8 text-sm" + placeholder={t("cookieBot.history.searchPlaceholder")} + /> +
+ +
+ + + + + + + {t("cookieBot.history.columnStarted")} + + + {t("cookieBot.history.columnProfile")} + + + {t("cookieBot.history.columnDuration")} + + + {t("cookieBot.history.columnSites")} + + + {t("cookieBot.history.columnStatus")} + + {showOperator && ( + + {t("cookieBot.history.columnOperator")} + + )} + + + + {isLoading && runs.length === 0 ? ( + Array.from({ length: 6 }, (_, i) => ( + + +
+ + +
+ + +
+ + + )) + ) : filtered.length === 0 ? ( + + +

+ {runs.length === 0 + ? t("cookieBot.history.empty") + : t("cookieBot.history.noMatch")} +

+
+
+ ) : ( + filtered.map((run) => ( + + )) + )} + +
+
+
+ ); +} + +function RunRow({ + run, + profileName, + showOperator, +}: { + run: CookieBotRun; + profileName: string | null; + showOperator: boolean; +}) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const reduceMotion = useReducedMotion(); + + const started = parseIso(run.started_at); + const ended = parseIso(run.ended_at); + const durationSeconds = + started && ended + ? Math.max(0, Math.floor((ended.getTime() - started.getTime()) / 1000)) + : run.billed_seconds > 0 + ? run.billed_seconds + : null; + + const countersKnown = hasRunCounters(run); + const hasDetail = Boolean(run.outcome_code) || run.sites_failed > 0; + + return ( + <> + { + if (hasDetail) setExpanded((open) => !open); + }} + > + + {formatDateTime(run.started_at ?? run.scheduled_for) ?? "—"} + + + {profileName ?? t("cookieBot.history.unknownProfile")} + + + {durationSeconds === null ? "—" : formatDuration(t, durationSeconds)} + + {/* An em dash, not a confident `0/12`: the counters are not written + until the fleet's figures are ingested, and printing the column + default as a fact tells a paying user their run did nothing. */} + + {!countersKnown ? ( + + + + + + {t("cookieBot.history.sitesUnknown")} + + + ) : run.sites_total > 0 ? ( + `${run.sites_visited}/${run.sites_total}` + ) : ( + String(run.sites_visited) + )} + + + + + {runStatusLabel(t, run.status)} + + + {showOperator && ( + + {run.email ?? "—"} + + )} + + {hasDetail && ( + + + + {expanded && ( + + {run.outcome_code && ( + + {t("cookieBot.history.outcome", { + reason: outcomeLabel(t, run.outcome_code) ?? "", + })} + + )} + {run.sites_failed > 0 && ( + + {t("cookieBot.history.sitesFailed", { + count: run.sites_failed, + })} + + )} + {run.consent_dismissed > 0 && ( + + {t("cookieBot.history.consentHandled", { + count: run.consent_dismissed, + })} + + )} + + )} + + + + )} + + ); +} + +/* -------------------------------------------------------------------------- */ +/* Live */ +/* -------------------------------------------------------------------------- */ + +function LiveSessions({ + live, + streamConnected, + profileIndex, + runsBySession, + runsById, + onChanged, +}: { + live: RemoteSessionState[]; + streamConnected: boolean; + profileIndex: Map; + runsBySession: Map; + runsById: Map; + onChanged: () => void; +}) { + const { t } = useTranslation(); + const now = useSecondTicker(live.length > 0); + + if (live.length === 0) { + return ( +
+ + + {streamConnected + ? t("cookieBot.live.idle") + : t("cookieBot.live.streamOffline")} + +
+ ); + } + + return ( +
+ {!streamConnected && ( +

+ {t("cookieBot.live.streamOfflineDetail")} +

+ )} + {live.map((session) => ( + + ))} +
+ ); +} + +function LiveSessionRow({ + session, + now, + name, + run, + onChanged, +}: { + session: RemoteSessionState; + now: number; + name: string | null; + run: CookieBotRun | undefined; + onChanged: () => void; +}) { + const { t } = useTranslation(); + const reduceMotion = useReducedMotion(); + const [isStopping, setIsStopping] = useState(false); + + const elapsed = sessionElapsedSeconds(session, now); + const phase = sessionPhaseLabel(t, session); + const tone = sessionTone(session); + const closeReason = sessionCloseReason(t, session); + // Only once the backend has actually written a counter. Until then the bar + // sat at zero for the whole run and read as "nothing is happening". + const countersKnown = run ? hasRunCounters(run) : false; + const total = run?.sites_total ?? 0; + const visited = run?.sites_visited ?? 0; + const progress = + countersKnown && total > 0 ? Math.min(1, visited / total) : null; + // A night longer than one session's cap is split into chunks, and the run row + // is the only place that can say which one is running. `chunk_index` counts + // chunks STARTED — the server bumps it as it launches each one and treats 0 + // as "never got going" — so it already reads as a 1-based position and must + // not be incremented again. + const chunks = + run && run.chunks_total > 1 && run.chunk_index > 0 + ? t("cookieBot.live.chunk", { + index: Math.min(run.chunk_index, run.chunks_total), + total: run.chunks_total, + }) + : null; + + const stop = useCallback(async () => { + setIsStopping(true); + try { + if (session.run_id) { + await cancelCookieBotRun(session.run_id); + } else { + await stopRemoteSession(session.session_id); + } + showSuccessToast(t("cookieBot.running.stopped")); + onChanged(); + } catch (error) { + showErrorToast(translateBackendError(t, error)); + } finally { + setIsStopping(false); + } + }, [session.run_id, session.session_id, onChanged, t]); + + return ( +
+
+ + + {name ?? t("cookieBot.live.unnamedSession")} + + + {/* The phase swaps in place: the words change, the row does not move. + The slot is a fixed width so the elapsed clock beside it never + shifts, and the entering label starts at 0.55 rather than 0 — if + the animation never runs, the single most important live signal on + the screen is still legible. */} + + + + {phase} + + + + + + {elapsed === null ? ( + + + + + + {t("cookieBot.live.notStartedYet")} + + + ) : ( + formatElapsed(elapsed) + )} + + + +
+ +
+ + {countersKnown && total > 0 + ? t("cookieBot.live.sitesProgress", { visited, total }) + : t("cookieBot.live.sitesUnknown")} + + + {countersKnown && run + ? t("cookieBot.live.consentHandled", { + count: run.consent_dismissed, + }) + : t("cookieBot.live.consentUnknown")} + + + {session.billed_seconds !== null && + session.billed_seconds !== undefined + ? t("cookieBot.live.billed", { + duration: formatElapsed(session.billed_seconds), + }) + : t("cookieBot.live.billedUnknown")} + + {chunks && {chunks}} + {closeReason && ( + {closeReason} + )} +
+ + {progress !== null && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/cookie-bot-enrol-dialog.tsx b/src/components/cookie-bot-enrol-dialog.tsx new file mode 100644 index 0000000..5ad249b --- /dev/null +++ b/src/components/cookie-bot-enrol-dialog.tsx @@ -0,0 +1,923 @@ +"use client"; + +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuChevronRight, LuInfo } from "react-icons/lu"; +import { + CADENCES, + type CadenceId, + cadenceForMask, + clockToMinutes, + enableProfileSync, + formatHours, + minutesToClock, + nightsPerWeek, + type PreflightResult, + preflight, + preflightFixLabel, + preflightReason, + profileTimezone, + RemoteHoursMeter, + resolvedOs, +} from "@/components/cookie-bot-shared"; +import { + AnimatedTabs, + AnimatedTabsList, + AnimatedTabsTrigger, +} from "@/components/ui/animated-tabs"; +import { AutoHeight } from "@/components/ui/auto-height"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { RippleButton } from "@/components/ui/ripple"; +import { StepTransition } from "@/components/ui/step-transition"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useCloudAuth } from "@/hooks/use-cloud-auth"; +import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot"; +import { parseBackendError, translateBackendError } from "@/lib/backend-errors"; +import { + type CookieBotConflict, + type CookieBotPlatform, + type CookieBotPreset, + type CookieBotPresetList, + type CookieBotSchedule, + type CookieBotScheduleInput, + checkCookieBotConflicts, + getCookieBotPresets, + saveCookieBotSchedule, +} from "@/lib/cookie-bot"; +import { SCHEDULE_BOUNDS } from "@/lib/cookie-bot-limits"; +import { canUseCookieBot } from "@/lib/entitlements"; +import { MOTION_EASE_OUT } from "@/lib/motion"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { cn } from "@/lib/utils"; +import type { BrowserProfile } from "@/types"; + +/** + * The cap when the server has not published one for the chosen preset. It is a + * user-facing ceiling on machine time, not a description of what the bot does + * with it — the contract allows 5..120 and this sits comfortably inside. + */ +const FALLBACK_MAX_MINUTES = 40; + +/** + * The server's schedule bounds. Mirrored, never re-declared: see + * `src/lib/cookie-bot-limits.ts` and the test that pins them. + */ +const { + minMaxMinutes: MIN_MAX_MINUTES, + maxMaxMinutes: MAX_MAX_MINUTES, + minSites: MIN_SITES, + maxSites: MAX_SITES, +} = SCHEDULE_BOUNDS; + +/** The default start: deep enough into the night to be plausible anywhere. */ +const DEFAULT_RUN_AT_MINUTE = 2 * 60; + +const PRESET_LABEL_KEYS: Record = { + light: "cookieBot.preset.light", + balanced: "cookieBot.preset.balanced", + deep: "cookieBot.preset.deep", +}; + +interface EnrolTarget { + profile: BrowserProfile; + check: PreflightResult; +} + +interface ConflictNotice { + email: string; + time: string; + profileIds: string[]; +} + +export interface CookieBotEnrolDialogProps { + isOpen: boolean; + onClose: () => void; + /** The profiles being enrolled. One for the fast path, many for a bulk enrol. */ + profiles: BrowserProfile[]; + /** Pre-fills the form when editing an existing enrolment. */ + existing?: CookieBotSchedule | null; + /** Extra work after the shared store has already been refreshed. */ + onSaved?: () => void; + /** + * Opens the profile's sync settings, for an end-to-end encrypted profile. + * Omitted where there is no sub-page to hand off to; the reason still shows, + * only the one-click repair is absent. + */ + onOpenProfileSync?: (profile: BrowserProfile) => void; + /** Opens proxy assignment for profiles with no exit node. */ + onAssignProxy?: (profileIds: string[]) => void; +} + +export function CookieBotEnrolDialog({ + isOpen, + onClose, + profiles, + existing, + onSaved, + onOpenProfileSync, + onAssignProxy, +}: CookieBotEnrolDialogProps) { + const { t } = useTranslation(); + const reduceMotion = useReducedMotion(); + const { user } = useCloudAuth(); + // The same entitlement answer every other consumer of the shared store + // passes; see the note in cookie-bot-page.tsx. + const { quota, refresh: refreshCookieBot } = useCookieBot( + canUseCookieBot(user), + cookieBotScopeFor(user), + ); + const canReplaceOthers = + !user?.teamId || user.teamRole === "owner" || user.teamRole === "admin"; + + const [presets, setPresets] = useState(null); + const [isLoadingPresets, setIsLoadingPresets] = useState(false); + const presetList = useMemo(() => presets?.presets ?? [], [presets]); + const defaultPreset = useMemo(() => pickDefaultPreset(presets), [presets]); + + /** + * Read the server's catalogue of intensities. + * + * An imperative loader rather than an effect keyed off an attempt counter, + * because the enrolment cannot name a preset without this: a transient + * failure of a secondary request disables the PRIMARY action, so the retry + * has to be a real call the button can make, not a state flip a lint fix can + * quietly drop from a dependency array. + */ + const loadPresets = useCallback(async () => { + setIsLoadingPresets(true); + try { + setPresets(await getCookieBotPresets()); + } catch { + // Losing the catalogue costs the depth control and blocks the save; the + // note beside the retry button says so. + setPresets(null); + } finally { + setIsLoadingPresets(false); + } + }, []); + + useEffect(() => { + if (!isOpen) return; + void loadPresets(); + }, [isOpen, loadPresets]); + + const [preset, setPreset] = useState(""); + const [runAt, setRunAt] = useState( + minutesToClock(DEFAULT_RUN_AT_MINUTE), + ); + const [daysMask, setDaysMask] = useState(CADENCES[0].mask); + const [maxMinutes, setMaxMinutes] = useState(FALLBACK_MAX_MINUTES); + const [maxMinutesTouched, setMaxMinutesTouched] = useState(false); + const [sitesText, setSitesText] = useState(""); + const [adjustOpen, setAdjustOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [fixingId, setFixingId] = useState(null); + const [conflict, setConflict] = useState(null); + const [conflictAcknowledged, setConflictAcknowledged] = useState(false); + + const isEdit = Boolean(existing); + const single = profiles.length === 1 ? profiles[0] : null; + + // Reset to the defaults every time the dialog is opened, so a previous + // enrolment's answers never leak into the next one. + useEffect(() => { + if (!isOpen) return; + setPreset(existing?.preset ?? defaultPreset?.id ?? ""); + setRunAt(minutesToClock(existing?.run_at_minute ?? DEFAULT_RUN_AT_MINUTE)); + setDaysMask(existing?.days_mask ?? CADENCES[0].mask); + setMaxMinutes( + existing?.max_minutes ?? + defaultPreset?.typical_minutes ?? + FALLBACK_MAX_MINUTES, + ); + setMaxMinutesTouched(Boolean(existing)); + setSitesText((existing?.sites ?? []).join("\n")); + setAdjustOpen(false); + setConflict(null); + setConflictAcknowledged(false); + setIsSaving(false); + }, [isOpen, existing, defaultPreset]); + + // Switching depth moves the cap with it, until the operator sets their own. + useEffect(() => { + if (maxMinutesTouched) return; + const chosen = presetList.find((p) => p.id === preset); + if (chosen?.typical_minutes) setMaxMinutes(chosen.typical_minutes); + }, [preset, presetList, maxMinutesTouched]); + + const targets: EnrolTarget[] = useMemo( + () => profiles.map((profile) => ({ profile, check: preflight(profile) })), + [profiles], + ); + const eligible = useMemo( + () => targets.filter((target) => target.check.eligible), + [targets], + ); + const blocked = useMemo( + () => targets.filter((target) => !target.check.eligible), + [targets], + ); + + const runAtMinute = clockToMinutes(runAt); + const sites = useMemo(() => normaliseSites(sitesText), [sitesText]); + const sitesTooMany = sites.length > MAX_SITES; + // v1 browses the user's declared sites and nothing else, so an empty list is + // not a schedule the server can accept — it 400s with COOKIE_BOT_SITE_LIMIT + // and, before this, `canSubmit` did not ask. The three-click happy path + // (bot cell -> Enrol -> "Enrol tonight") posted `sites: []` and failed every + // single time, with the only input the bot cannot run without hidden inside a + // collapsed disclosure. + const sitesTooFew = sites.length < MIN_SITES; + const maxMinutesValid = + Number.isFinite(maxMinutes) && + maxMinutes >= MIN_MAX_MINUTES && + maxMinutes <= MAX_MAX_MINUTES; + const presetsUnavailable = presets === null; + + // A week's machine time from the operator's own two numbers. The budget it is + // compared against is the server's; nothing here decides entitlement. + const weeklyHours = (nightsPerWeek(daysMask) * maxMinutes) / 60; + const remainingHours = quota?.remaining_hours ?? null; + const overBudget = + remainingHours !== null && weeklyHours > remainingHours && !isEdit; + + const canSubmit = + eligible.length > 0 && + preset.length > 0 && + runAtMinute !== null && + maxMinutesValid && + !sitesTooMany && + !sitesTooFew && + !isSaving; + + // A single-profile enrolment asks the server up front whether a teammate + // already owns this profile's night, so the one decision that matters is + // made before the user commits rather than after. + useEffect(() => { + if (!isOpen || !single || isEdit) return; + let cancelled = false; + void checkCookieBotConflicts(single.id, {}) + .then((found) => { + if (cancelled) return; + const overlapping = found.filter((c) => c.enabled); + if (overlapping.length === 0) return; + setConflict(toNotice(overlapping[0], [single.id])); + }) + .catch(() => { + // A conflict check that cannot run is not a reason to block enrolment; + // the save path re-detects the same 409 and shows the same block. + }); + return () => { + cancelled = true; + }; + }, [isOpen, single, isEdit]); + + const buildInput = useCallback( + (profile: BrowserProfile): CookieBotScheduleInput | null => { + const minute = clockToMinutes(runAt); + const platform = resolvedOs(profile); + if (minute === null || !platform) return null; + return { + profile_name: profile.name, + platform: platform as CookieBotPlatform, + enabled: true, + run_at_minute: minute, + days_mask: daysMask, + timezone: profileTimezone(profile), + preset, + max_minutes: Math.round(maxMinutes), + sites, + }; + }, + [runAt, daysMask, preset, maxMinutes, sites], + ); + + const submit = useCallback( + async (acknowledge: boolean, only?: string[]) => { + const list = only + ? eligible.filter((target) => only.includes(target.profile.id)) + : eligible; + if (list.length === 0) return; + setIsSaving(true); + let saved = 0; + const conflicted: string[] = []; + let firstError: unknown = null; + let conflictParams: { email: string; time: string } | null = null; + + for (const target of list) { + const input = buildInput(target.profile); + if (!input) continue; + try { + await saveCookieBotSchedule(target.profile.id, input, acknowledge); + saved += 1; + } catch (error) { + const parsed = parseBackendError(error); + if (parsed?.code === "COOKIE_BOT_SCHEDULE_CONFLICT") { + conflicted.push(target.profile.id); + if (!conflictParams) { + conflictParams = { + email: parsed.params?.email ?? "", + time: parsed.params?.time ?? minutesToClock(runAtMinute ?? 0), + }; + } + continue; + } + if (!firstError) firstError = error; + } + } + + setIsSaving(false); + + if (conflicted.length > 0 && conflictParams) { + setConflict({ + email: conflictParams.email, + time: conflictParams.time, + profileIds: conflicted, + }); + if (saved > 0) { + void refreshCookieBot(); + onSaved?.(); + } + return; + } + + if (firstError) { + showErrorToast(translateBackendError(t, firstError)); + if (saved > 0) { + void refreshCookieBot(); + onSaved?.(); + } + return; + } + + if (saved > 0) { + showSuccessToast( + isEdit + ? t("cookieBot.enrol.saved") + : t("cookieBot.enrol.enrolled", { count: saved }), + ); + void refreshCookieBot(); + onSaved?.(); + onClose(); + } + }, + [ + eligible, + buildInput, + isEdit, + onSaved, + onClose, + t, + runAtMinute, + refreshCookieBot, + ], + ); + + const applyFix = useCallback( + async (target: EnrolTarget) => { + const { profile, check } = target; + if (check.fix === "proxy") { + onAssignProxy?.([profile.id]); + return; + } + if (check.fix === "syncSettings") { + onOpenProfileSync?.(profile); + return; + } + if (check.fix !== "sync") return; + setFixingId(profile.id); + try { + await enableProfileSync(profile.id); + } catch (error) { + showErrorToast( + parseBackendError(error) + ? translateBackendError(t, error) + : t("cookieBot.preflight.fixFailed"), + ); + } finally { + setFixingId(null); + } + }, + [onAssignProxy, onOpenProfileSync, t], + ); + + const showConflict = conflict !== null && !conflictAcknowledged; + + const title = isEdit + ? t("cookieBot.enrol.editTitle") + : single + ? t("cookieBot.enrol.titleOne", { name: single.name }) + : t("cookieBot.enrol.titleCount", { count: profiles.length }); + + const cadenceId = cadenceForMask(daysMask); + // One complete sentence per cadence rather than a label spliced into a + // fragment: "Runs Nightly at 02:00" only reads correctly in English, and a + // translator needs the whole clause to reorder. + const summaryKey = cadenceId + ? `cookieBot.enrol.summary${cadenceId[0].toUpperCase()}${cadenceId.slice(1)}` + : "cookieBot.enrol.summaryCustom"; + + return ( + { + if (!open) onClose(); + }} + > + + + {title} + + {t("cookieBot.enrol.description")} + + + +
+ {/* The whole default, said once, as one sentence. One key, not five + fragments: a translator has to be free to reorder it. */} +

+ {t(summaryKey, { + count: nightsPerWeek(daysMask), + time: minutesToClock(runAtMinute ?? DEFAULT_RUN_AT_MINUTE), + minutes: Math.round(maxMinutes), + })} +

+ +
+

+ {remainingHours === null + ? t("cookieBot.hours.estimateOnly", { + hours: formatHours(weeklyHours), + }) + : overBudget + ? t("cookieBot.hours.estimateOverBudget", { + hours: formatHours(weeklyHours), + remaining: formatHours(remainingHours), + }) + : t("cookieBot.hours.estimate", { + hours: formatHours(weeklyHours), + remaining: formatHours(remainingHours), + })} +

+ +
+ + {blocked.length > 0 && ( +
+

+ {t("cookieBot.preflight.ineligible", { + count: blocked.length, + })} +

+ {blocked.map((target) => { + const reachable = + target.check.fix === "sync" || + (target.check.fix === "proxy" && Boolean(onAssignProxy)) || + (target.check.fix === "syncSettings" && + Boolean(onOpenProfileSync)); + const fixLabel = reachable + ? preflightFixLabel(t, target.check.fix) + : null; + return ( +
+ + {target.profile.name} + + + {preflightReason(t, target.check)} + {target.check.code === "noExitNode" && } + + {fixLabel && ( + + )} +
+ ); + })} +
+ )} + + {/* Sites is not an adjustment: v1 browses the user's declared list + and nothing else, so it is the one input without which there is no + run. It sat inside the collapsed "Adjust schedule" disclosure, + which made the default path a form the server always refused. */} + +