diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 552a06e5..2ff3ad4f 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -5490,6 +5490,29 @@ fn resolve_error_response(error: crate::cdp_target::ResolveError) -> (StatusCode /// The body is the same `{"code": ..., ...}` envelope the MCP transport puts /// in its error `data`, plus `message`, so a locator that matched three /// buttons hands a REST client the same candidate list an agent gets. +/// An agent call's answer. A browser that refuses automation as unpaid on an +/// account whose plan includes it gets the real reason: another desktop is the +/// primary one, or the browser's own plan token is stale. +async fn agent_reply( + id: &str, + result: Result, +) -> Result, (StatusCode, String)> { + use crate::mcp_server::AgentError; + match result { + Ok(value) => Ok(Json(value)), + Err(AgentError::PaymentRequired(message)) => { + let reason = match crate::cloud_auth::CLOUD_AUTH.wayfern_device_refusal() { + Some(device) => Some(device), + None => crate::mcp_server::stale_plan_token_refusal(Some(id)).await, + }; + Err(agent_error_response(AgentError::PaymentRequired( + reason.unwrap_or(message), + ))) + } + Err(error) => Err(agent_error_response(error)), + } +} + fn agent_error_response(error: crate::mcp_server::AgentError) -> (StatusCode, String) { use crate::cdp_target::CdpError; use crate::mcp_server::AgentError; @@ -5548,10 +5571,7 @@ async fn agent_perceive_api( Json(request): Json, ) -> Result, (StatusCode, String)> { let ctx = agent_context_for(&id).await?; - crate::mcp_server::agent_perceive(&ctx, &request) - .await - .map(Json) - .map_err(agent_error_response) + agent_reply(&id, crate::mcp_server::agent_perceive(&ctx, &request).await).await } #[utoipa::path( @@ -5582,10 +5602,11 @@ async fn agent_resolve_locator_api( Json(request): Json, ) -> Result, (StatusCode, String)> { let ctx = agent_context_for(&id).await?; - crate::mcp_server::agent_resolve_locator(&ctx, &request) - .await - .map(Json) - .map_err(agent_error_response) + agent_reply( + &id, + crate::mcp_server::agent_resolve_locator(&ctx, &request).await, + ) + .await } #[utoipa::path( @@ -5616,10 +5637,11 @@ async fn agent_click_api( Json(request): Json, ) -> Result, (StatusCode, String)> { let ctx = agent_context_for(&id).await?; - crate::mcp_server::agent_click_locator(&ctx, &request) - .await - .map(Json) - .map_err(agent_error_response) + agent_reply( + &id, + crate::mcp_server::agent_click_locator(&ctx, &request).await, + ) + .await } #[utoipa::path( @@ -5650,10 +5672,12 @@ async fn agent_type_api( Json(request): Json, ) -> Result, (StatusCode, String)> { let ctx = agent_context_for(&id).await?; - crate::mcp_server::agent_type_locator(&ctx, &request, crate::mcp_server::MAX_TYPING_SECONDS) - .await - .map(Json) - .map_err(agent_error_response) + agent_reply( + &id, + crate::mcp_server::agent_type_locator(&ctx, &request, crate::mcp_server::MAX_TYPING_SECONDS) + .await, + ) + .await } #[utoipa::path( @@ -5684,10 +5708,7 @@ async fn agent_extract_api( Json(request): Json, ) -> Result, (StatusCode, String)> { let ctx = agent_context_for(&id).await?; - crate::mcp_server::agent_extract(&ctx, &request) - .await - .map(Json) - .map_err(agent_error_response) + agent_reply(&id, crate::mcp_server::agent_extract(&ctx, &request).await).await } #[utoipa::path( @@ -5723,10 +5744,11 @@ async fn agent_pick_api( .timeout_ms .unwrap_or(crate::mcp_server::DEFAULT_PICK_TIMEOUT_MS) .clamp(1_000, crate::mcp_server::MAX_PICK_TIMEOUT_MS); - crate::mcp_server::agent_pick_element(&ctx, timeout_ms) - .await - .map(Json) - .map_err(agent_error_response) + agent_reply( + &id, + crate::mcp_server::agent_pick_element(&ctx, timeout_ms).await, + ) + .await } #[cfg(test)] diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index a0a8c6f7..c4f71b71 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -234,6 +234,47 @@ fn explain_engine_refusal(mut error: McpError, device_refusal: Option) - error } +/// Why a running browser refuses automation on an account whose plan includes +/// it, when no device refusal explains it. +/// +/// A browser keeps the plan token it was launched with for its whole life. +/// That token expires, and a launch while the token service did not answer +/// gets none, so only a new launch gives the browser a token that works. +pub(crate) async fn stale_plan_token_refusal(profile_id: Option<&str>) -> Option { + if CLOUD_AUTH.wayfern_device_refusal().is_some() || !CLOUD_AUTH.can_use_browser_automation().await + { + return None; + } + let running_for = profile_id + .and_then(|id| { + crate::profile::ProfileManager::instance() + .find_profile(id) + .ok() + }) + .and_then(|profile| profile.process_id) + .and_then(crate::proxy_storage::process_start_time) + .and_then(|start| { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs(); + Some(now.saturating_sub(start)) + }); + Some(stale_plan_token_message(running_for)) +} + +fn stale_plan_token_message(running_for: Option) -> String { + let age = running_for + .map(|secs| format!(" This browser has run for {} h.", secs / 3600)) + .unwrap_or_default(); + format!( + "This browser cannot confirm the plan, although the account includes browser automation: \ + a browser keeps the plan token it was launched with, and that token has expired, or the \ + token service did not answer when the browser started.{age} Restart the profile to \ + automate it: call kill_profile, then run_profile." + ) +} + /// The create_profile result. A setting that failed after the profile was /// created is named as not applied, so the agent never takes it as set. fn created_profile_result( @@ -6623,6 +6664,14 @@ impl McpServer { .dispatch_tool_call(caller, tool_name, &arguments) .await .map_err(|error| explain_engine_refusal(error, CLOUD_AUTH.wayfern_device_refusal())); + if let Err(error) = &mut result { + if error.message.contains("requires a paid Donut Browser plan") { + let profile = arguments.get("profile_id").and_then(|v| v.as_str()); + if let Some(reason) = stale_plan_token_refusal(profile).await { + error.message = reason; + } + } + } let elapsed_ms = started.elapsed().as_millis(); if human_tool { crate::agent_console::touch_session(caller.session); @@ -12862,6 +12911,17 @@ mod tests { "Profile not found" ); } + #[test] + fn a_stale_plan_token_says_to_restart_and_how_long_the_browser_ran() { + let message = stale_plan_token_message(Some(204_189)); + assert!(message.contains("56 h"), "{message}"); + assert!( + message.contains("kill_profile, then run_profile"), + "{message}" + ); + assert!(!message.contains("requires a paid"), "{message}"); + assert!(!stale_plan_token_message(None).contains(" h.")); + } #[test] fn a_bot_check_is_named_but_a_login_form_is_not() {