mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-19 22:17:28 +02:00
fix: explain device refusal over mcp
This commit is contained in:
1 parent
77c0029a01
commit
becac29f68
2 files changed
+99
-1
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user