diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index 6b9bc67c4..166b367b1 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -666,6 +666,14 @@ describe('classifyRpcError', () => { undefined, 'transport', ], + // Issue #2286: downstream provider 401s must NOT clear the user session. + [ + 'Discord API error: Discord list guilds failed (401): Unauthorized', + undefined, + 'provider_auth', + ], + ['OpenAI API error (401 Unauthorized): invalid api key', undefined, 'provider_auth'], + ['Anthropic API error (401 Unauthorized): auth error', undefined, 'provider_auth'], ['some random message', undefined, 'unknown'], ] as const)('%s => %s', (message, status, expected) => { expect(classifyRpcError(message, status)).toBe(expected); diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index dfc1e7124..9b4d79b46 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -71,6 +71,7 @@ let resolvingCoreRpcToken: Promise | null = null; */ export type CoreRpcErrorKind = | 'auth_expired' + | 'provider_auth' // downstream provider 401 — NOT user session expiry | 'transport' | 'timeout' | 'rate_limited' @@ -107,13 +108,34 @@ export function classifyRpcError( if (isThreadNotFoundRpcData(data)) return 'thread_not_found'; if (httpStatus === 401) return 'auth_expired'; if (httpStatus === 429) return 'rate_limited'; - if (/\(401\b.*Unauthorized\)|Session expired/i.test(message)) return 'auth_expired'; + // Confirmed OpenHuman session expiry — explicit markers from the backend/core. + if (/Session expired|SESSION_EXPIRED/i.test(message)) return 'auth_expired'; // Core-side "no backend session token" → the auth profile is gone but the // frontend may still hold a stale sessionToken from an optimistic post-login // patch. Treat as auth-expired so `CoreStateProvider` clears the session and // `ProtectedRoute` bounces the user back to `/` (login) instead of trapping // them on an onboarding step that polls a failing RPC every 5 s. if (/no backend session token/i.test(message)) return 'auth_expired'; + // "session JWT required" covers the case where a prior 401 already cleared + // the token and the very next RPC call finds no JWT in the store. + if (/session jwt required/i.test(message)) return 'auth_expired'; + // OpenHuman backend path 401s (via authed_json): "{METHOD} /path failed (401 Unauthorized)" + // The HTTP method prefix distinguishes these from downstream provider 401s. + // Fix for issue #2286: only match when the message starts with an HTTP verb + // followed by a path — this excludes "Discord API error:", "OpenAI API error:", etc. + if (/^(GET|POST|PUT|DELETE|PATCH)\s+\/[^\s].*\(401\b.*Unauthorized\)/i.test(message)) + return 'auth_expired'; + // Downstream provider/integration 401 — NOT user session expiry. + // e.g. "Discord API error: Discord list guilds failed (401): Unauthorized" + // e.g. "OpenAI API error (401 Unauthorized): invalid api key" + // e.g. "Composio v3 API error: HTTP 401: Unauthorized" + // Note: Discord uses "(401): Unauthorized" format (colon after status, reason outside parens), + // so we test for 401 and "unauthorized" independently rather than requiring both inside parens. + if ( + (/401/.test(message) && /unauthorized/i.test(message)) || + /invalid token|bad token/i.test(message) + ) + return 'provider_auth'; if (/429.*rate.?limit/i.test(message)) return 'rate_limited'; if (/Budget exceeded|Insufficient budget/i.test(message)) return 'budget_exceeded'; // Local AbortController hit `CORE_RPC_TIMEOUT_MS` — distinct from backend diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 3621579d4..d1ef222f0 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -180,7 +180,7 @@ pub async fn invoke_method(state: AppState, method: &str, params: Value) -> Resu if let Err(ref msg) = result { if is_session_expired_error(msg) { log::warn!( - "[jsonrpc] backend returned 401 for method '{}' — publishing SessionExpired", + "[jsonrpc] confirmed session expiry for method='{}' — publishing SessionExpired", method ); // Scrub before publishing — subscribers log `reason`, and the @@ -193,47 +193,87 @@ pub async fn invoke_method(state: AppState, method: &str, params: Value) -> Resu reason: crate::openhuman::inference::provider::ops::sanitize_api_error(msg), }, ); + } else if is_downstream_provider_auth_error(msg) { + log::info!( + "[jsonrpc] downstream provider auth failure for method='{}' (not session expiry) — {}", + method, + msg + ); } } result } -/// Helper to determine if an error message indicates an expired or invalid session. +/// Helper to determine if an error message indicates an expired or invalid +/// OpenHuman backend session. /// -/// Deliberately **looser** than -/// [`crate::core::observability::is_session_expired_message`]: this -/// dispatch-site predicate also matches the generic `"401 + unauthorized"` / -/// `"invalid token"` pair so token cleanup + -/// `DomainEvent::SessionExpired` publish fire on *any* 401, including -/// BYO-key provider failures (which clear the stale local token even if -/// the user mis-configured an OpenAI / Anthropic key). The strict -/// classifier in `observability` is for the agent / web-channel -/// `report_error_or_expected` call sites, where matching too loosely would -/// silence actionable BYO-key configuration errors (OPENHUMAN-TAURI-26 -/// rationale: the agent-layer demote must NOT also swallow generic -/// provider 401s). +/// **Narrower than the previous implementation** (fixed in issue #2286): /// -/// "No backend session token" is also treated as a session-expired signal: the -/// auth profile is missing entirely (the user was never signed in, or their -/// stored profile was wiped between login and the next RPC). The frontend may -/// still believe it holds a session token from an optimistic post-login patch, -/// so we want the same auto-cleanup + UI-level re-auth path to fire instead of -/// repeatedly reporting this as a hard error to Sentry. See #1465-ish: users -/// stuck on the onboarding `SkillsStep` would spam `composio_list_connections` -/// failures every 5 s without ever being bounced back to the login screen. +/// The old predicate matched ANY `"401 + unauthorized"` pattern, which caused +/// downstream provider 401s (Discord bot token failures, BYO-key OpenAI / +/// Anthropic failures, Composio direct-mode errors) to clear the user's session +/// and log them out. The fix distinguishes between: /// -/// "session JWT required" covers the case where a prior 401 already cleared the -/// token and the very next RPC call (e.g. `channels_telegram_login_start`) finds -/// no JWT in the store. This is the same auth-boundary condition, just surfaced -/// as a local guard rather than a backend response. +/// - **OpenHuman backend 401s** (`authed_json` in `src/api/rest.rs`): formatted +/// as `"{METHOD} /path failed (401 Unauthorized): {body}"`, e.g. +/// `"GET /teams failed (401 Unauthorized): {"success":false}"`. These always +/// start with an HTTP method verb followed by a space and a forward slash. +/// - **Provider / downstream 401s** (`api_error` in +/// `src/openhuman/inference/provider/ops.rs`): formatted as +/// `"{ProviderName} API error (401 Unauthorized): {body}"` or +/// `"Discord API error: ... (401): Unauthorized"`. These start with a +/// provider name, NOT an HTTP method verb. +/// +/// **What still triggers session expiry:** +/// - `"Session expired"` — explicit body text from the OpenHuman backend. +/// - `"no backend session token"` — pre-flight guard; auth profile is missing. +/// - `"session jwt required"` — local guard; JWT already cleared by a prior 401. +/// - `"SESSION_EXPIRED"` — scheduler-gate sentinel (exact case). +/// - HTTP-method-prefixed 401s (`GET /`, `POST /`, etc.) — backend path format. +/// +/// **What no longer triggers session expiry (fixed in #2286):** +/// - Provider-prefixed 401s (`"Discord API error: ..."`, `"OpenAI API error ..."`) +/// - `"invalid token"` — too broad; also matches Discord / OAuth provider tokens. +/// +/// Note: for inference-path OpenHuman backend 401s, `api_error` (in +/// `inference/provider/ops.rs` lines 479–497) ALREADY publishes `SessionExpired` +/// directly, so there is no regression if this predicate misses them — the +/// subscriber is idempotent and a harmless double-publish would still be correct. fn is_session_expired_error(msg: &str) -> bool { let lower = msg.to_lowercase(); - (lower.contains("401") && lower.contains("unauthorized")) - || lower.contains("invalid token") + // Explicit session-expired markers from the OpenHuman backend / local guards. + if lower.contains("session expired") || lower.contains("no backend session token") || lower.contains("session jwt required") || msg.contains("SESSION_EXPIRED") + { + return true; + } + // OpenHuman backend path 401s via `authed_json`: + // format is "{METHOD} /path failed (401 Unauthorized): {body}" + // The HTTP-method prefix distinguishes these from provider-prefixed errors. + if (lower.contains("401") && lower.contains("unauthorized")) + && (msg.starts_with("GET /") + || msg.starts_with("POST /") + || msg.starts_with("PUT /") + || msg.starts_with("DELETE /") + || msg.starts_with("PATCH /")) + { + return true; + } + false +} + +/// Returns `true` when the error looks like a downstream provider / integration +/// 401 that should NOT clear the user's OpenHuman session. +/// +/// Used exclusively for diagnostic logging at the `invoke_method` call site so +/// provider auth failures are visible in the logs without being misclassified +/// as session expiry. Does not drive any side-effects. +fn is_downstream_provider_auth_error(msg: &str) -> bool { + let lower = msg.to_lowercase(); + lower.contains("401") && lower.contains("unauthorized") } /// Returns `true` when the error message comes from JSON-RPC params validation diff --git a/src/core/jsonrpc_tests.rs b/src/core/jsonrpc_tests.rs index 3eed02cf0..8a3621c7b 100644 --- a/src/core/jsonrpc_tests.rs +++ b/src/core/jsonrpc_tests.rs @@ -565,25 +565,86 @@ fn parse_json_params_reports_error_message() { #[test] fn is_session_expired_error_matches_401_unauthorized() { + // Issue #2286: only OpenHuman backend path 401s (HTTP-method prefix) should + // match, not generic 401/Unauthorized strings. assert!(is_session_expired_error( + "GET /teams failed (401 Unauthorized): {\"success\":false}" + )); + assert!(is_session_expired_error( + "POST /auth/token failed (401 Unauthorized): session expired" + )); + assert!(is_session_expired_error( + "DELETE /sessions/abc failed (401 Unauthorized): unauthorized" + )); + // Generic 401+unauthorized strings without HTTP-method prefix must NOT match. + assert!(!is_session_expired_error( "backend returned 401 Unauthorized" )); - assert!(is_session_expired_error("401 UNAUTHORIZED")); - assert!(is_session_expired_error("got 401 and unauthorized body")); + assert!(!is_session_expired_error("401 UNAUTHORIZED")); + assert!(!is_session_expired_error("got 401 and unauthorized body")); } #[test] fn is_session_expired_error_requires_both_401_and_unauthorized() { // 401 alone is not sufficient — could be HTTP/3.01 nonsense or - // unrelated text. We require the string "unauthorized" too. + // unrelated text. We require the string "unauthorized" too, plus HTTP-method + // prefix for the 401 path. assert!(!is_session_expired_error("server returned 401")); assert!(!is_session_expired_error("unauthorized without code")); } +#[test] +fn is_session_expired_error_matches_openhuman_backend_path_401() { + // OpenHuman backend calls via authed_json use the format: + // "{METHOD} /path failed (401 Unauthorized): {body}" + assert!(is_session_expired_error( + "GET /teams failed (401 Unauthorized): {\"success\":false}" + )); + assert!(is_session_expired_error( + "POST /auth/token failed (401 Unauthorized): session expired" + )); + assert!(is_session_expired_error( + "GET /teams/me/usage failed (401 Unauthorized): unauthorized" + )); + assert!(is_session_expired_error( + "PUT /profile failed (401 Unauthorized): token expired" + )); + assert!(is_session_expired_error( + "PATCH /settings failed (401 Unauthorized): unauthorized" + )); +} + +#[test] +fn is_session_expired_error_does_not_match_discord_api_error() { + // Issue #2286: Discord bot token 401 must not clear the user session. + assert!(!is_session_expired_error( + "Discord API error: Discord list guilds failed (401): Unauthorized" + )); + assert!(!is_session_expired_error( + "Discord API error: Discord get bot user failed (401): bad token" + )); +} + +#[test] +fn is_session_expired_error_does_not_match_byo_key_provider_401() { + // BYO-key provider 401 should not clear the user session. + assert!(!is_session_expired_error( + "OpenAI API error (401 Unauthorized): invalid api key" + )); + assert!(!is_session_expired_error( + "Anthropic API error (401 Unauthorized): authentication error" + )); + assert!(!is_session_expired_error( + "Composio v3 API error: HTTP 401: Unauthorized" + )); +} + #[test] fn is_session_expired_error_matches_invalid_token_case_insensitive() { - assert!(is_session_expired_error("Invalid Token")); - assert!(is_session_expired_error("got an invalid token here")); + // "invalid token" is no longer a session-expiry trigger (issue #2286): + // it was too broad and caught Discord/OAuth provider token errors. + assert!(!is_session_expired_error("Invalid Token")); + assert!(!is_session_expired_error("got an invalid token here")); } #[test]