fix: explain device refusal over mcp

This commit is contained in:
zhom committed 2026-09-23 06:03:32 +04:00
1 parent 77c0029a01
commit becac29f68
2 files changed
+99 -1

No files matched your search

+50
View File
@@ -343,6 +343,10 @@ pub struct CloudAuthManager {
state: Mutex<Option<CloudAuthState>>,
refresh_lock: tokio::sync::Mutex<()>,
wayfern_token: Mutex<Option<String>>,
/// 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<Option<String>>,
}
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<String>) {
if let Ok(mut current) = self.wayfern_device_refusal.lock() {
*current = refusal;
}
}
pub fn wayfern_device_refusal(&self) -> Option<String> {
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::<serde_json::Value>(&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<String> {
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)
}
+49 -1
View File
@@ -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<String>) -> 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