fix: explain stale plan token

This commit is contained in:
zhom committed 2026-10-20 10:28:48 +04:00
1 parent 7ed16725cf
commit 06486ff380
2 files changed
+106 -24

No files matched your search

+46 -24
View File
@@ -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<T>(
id: &str,
result: Result<T, crate::mcp_server::AgentError>,
) -> Result<Json<T>, (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<crate::wayfern_cdp::PerceptionRequest>,
) -> Result<Json<crate::wayfern_cdp::PerceptionPage>, (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<crate::mcp_server::AgentResolveRequest>,
) -> Result<Json<crate::wayfern_cdp::LocatorResolution>, (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<crate::mcp_server::AgentClickRequest>,
) -> Result<Json<crate::mcp_server::AgentClick>, (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<crate::mcp_server::AgentTypeRequest>,
) -> Result<Json<crate::mcp_server::AgentTyping>, (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<crate::wayfern_cdp::ExtractionRequest>,
) -> Result<Json<crate::wayfern_cdp::Extraction>, (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)]
+60
View File
@@ -234,6 +234,47 @@ fn explain_engine_refusal(mut error: McpError, device_refusal: Option<String>) -
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<String> {
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<u64>) -> 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() {