From becac29f68838c2c25992de9aea57346ae5abd0f Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:03:32 +0400 Subject: [PATCH] fix: explain device refusal over mcp --- src-tauri/src/cloud_auth.rs | 50 +++++++++++++++++++++++++++++++++++++ src-tauri/src/mcp_server.rs | 50 ++++++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index de5edc11..9bda3eaf 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -343,6 +343,10 @@ pub struct CloudAuthManager { state: Mutex>, refresh_lock: tokio::sync::Mutex<()>, wayfern_token: Mutex>, + /// The server's reason for refusing this desktop a Wayfern token under a + /// device rule, until a token is acquired again. The engine reports a + /// missing token as a plan problem; this is what lets MCP say the real one. + wayfern_device_refusal: std::sync::Mutex>, } lazy_static! { @@ -365,6 +369,7 @@ impl CloudAuthManager { state: Mutex::new(state), refresh_lock: tokio::sync::Mutex::new(()), wayfern_token: Mutex::new(None), + wayfern_device_refusal: std::sync::Mutex::new(None), } } @@ -932,6 +937,7 @@ impl CloudAuthManager { pub async fn logout(&self) -> Result<(), String> { // Clear wayfern token self.clear_wayfern_token().await; + self.set_wayfern_device_refusal(None); // Disconnect profile lock manager crate::team_lock::PROFILE_LOCK.disconnect().await; @@ -1376,6 +1382,7 @@ impl CloudAuthManager { // a lockout that does not exist and hides the real answer, which is // that their plan does not include browser automation. if is_device_restriction(&e) { + self.set_wayfern_device_refusal(Some(refusal_message(&e))); let _ = crate::events::emit_empty("wayfern-paid-blocked"); } } @@ -1385,6 +1392,7 @@ impl CloudAuthManager { let mut wt = self.wayfern_token.lock().await; *wt = Some(token); + self.set_wayfern_device_refusal(None); log::info!("Wayfern token acquired"); Ok(()) } @@ -1411,6 +1419,20 @@ impl CloudAuthManager { *wt = None; } + fn set_wayfern_device_refusal(&self, refusal: Option) { + if let Ok(mut current) = self.wayfern_device_refusal.lock() { + *current = refusal; + } + } + + pub fn wayfern_device_refusal(&self) -> Option { + self + .wayfern_device_refusal + .lock() + .ok() + .and_then(|current| current.clone()) + } + /// Background loop that refreshes the sync token periodically pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) { let mut wayfern_refresh_counter: u32 = 0; @@ -1507,6 +1529,21 @@ fn is_device_restriction(error: &str) -> bool { error.contains("primary device") || error.contains("requires the desktop app") } +/// The `message` of the JSON body in a refused token request, or the whole +/// error when the body is not JSON. +fn refusal_message(error: &str) -> String { + error + .find('{') + .and_then(|start| serde_json::from_str::(&error[start..]).ok()) + .and_then(|body| { + body + .get("message") + .and_then(|m| m.as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| error.to_string()) +} + fn solve_pow(prefix: &str, difficulty: u32) -> Option { if difficulty == 0 || difficulty > 32 { return None; @@ -1880,6 +1917,19 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St mod tests { use super::*; + #[test] + fn a_refused_token_request_yields_the_servers_message() { + let refused = r#"Wayfern token request failed (403 Forbidden): {"message":"Browser automation is restricted to your primary device. Log out other devices to use it here.","error":"Forbidden","statusCode":403}"#; + assert_eq!( + refusal_message(refused), + "Browser automation is restricted to your primary device. Log out other devices to use it here." + ); + assert_eq!( + refusal_message("Failed to request wayfern token: timeout"), + "Failed to request wayfern token: timeout" + ); + } + fn active_solo() -> Entitlements { derive_entitlements("solo", Some("monthly"), "active", 20) } diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index aca52b0f..a2704849 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -196,6 +196,21 @@ fn cdp_error(error: CdpError) -> McpError { } } +/// Replace the engine's plan refusal with the server's device refusal. +/// +/// A desktop the server will not give a Wayfern token, because another desktop +/// on the account is the primary one, launches the engine without a token, and +/// the engine then refuses every command as "requires a paid Donut Browser +/// plan", which is false for a paying account. +fn explain_engine_refusal(mut error: McpError, device_refusal: Option) -> McpError { + if error.message.contains("requires a paid Donut Browser plan") { + if let Some(reason) = device_refusal { + error.message = reason; + } + } + error +} + const DEFAULT_MCP_PORT: u16 = 51080; /// The event the desktop turns into the "local MCP is being removed" dialog. @@ -4713,7 +4728,10 @@ impl McpServer { }); } - let result = self.dispatch_tool_call(caller, tool_name, &arguments).await; + let result = self + .dispatch_tool_call(caller, tool_name, &arguments) + .await + .map_err(|error| explain_engine_refusal(error, CLOUD_AUTH.wayfern_device_refusal())); let elapsed_ms = started.elapsed().as_millis(); match &result { Ok(_) => { @@ -9729,6 +9747,36 @@ lazy_static::lazy_static! { mod tests { use super::*; + #[test] + fn an_engine_plan_refusal_is_explained_by_a_device_refusal() { + let engine = || { + McpError { + code: -32000, + message: r#"CDP error: {"code":-32000,"message":"Browser automation requires a paid Donut Browser plan."}"# + .to_string(), + data: None, + } + }; + let device = "Browser automation is restricted to your primary device. Log out other devices to use it here."; + assert_eq!( + explain_engine_refusal(engine(), Some(device.to_string())).message, + device + ); + assert_eq!( + explain_engine_refusal(engine(), None).message, + engine().message + ); + let other = McpError { + code: -32000, + message: "Profile not found".to_string(), + data: None, + }; + assert_eq!( + explain_engine_refusal(other, Some(device.to_string())).message, + "Profile not found" + ); + } + #[tokio::test] async fn the_local_tombstone_answers_gone_with_a_removal_message() { // Local MCP is removed; anything that still reaches the loopback port must