import { invoke } from '@tauri-apps/api/core'; import debug from 'debug'; import { dispatchLocalAiMethod } from '../lib/ai/localCoreAiMemory'; import { CORE_RPC_TIMEOUT_MS, CORE_RPC_URL } from '../utils/config'; import { getStoredCoreToken, normalizeRpcUrl, peekStoredRpcUrl } from '../utils/configPersistence'; import { redactRpcUrlForLog } from '../utils/redactRpcUrlForLog'; import { sanitizeError } from '../utils/sanitize'; import { isTauri as coreIsTauri } from '../utils/tauriCommands/common'; import { normalizeRpcMethod } from './rpcMethods'; interface CoreRpcRelayRequest { method: string; params?: unknown; serviceManaged?: boolean; /** * Per-call timeout override in milliseconds. When omitted, defaults to the * global `CORE_RPC_TIMEOUT_MS` (30s). Use for slow-but-alive RPCs such as * first-launch `openhuman.app_state_snapshot` (#2156). Clamped to the same * [MIN, MAX] window as the global default. */ timeoutMs?: number; } /** Mirror of `parseCoreRpcTimeoutMs` bounds in `utils/config.ts`. */ const PER_CALL_TIMEOUT_MIN_MS = 1_000; const PER_CALL_TIMEOUT_MAX_MS = 10 * 60 * 1_000; function resolvePerCallTimeoutMs(override: number | undefined): number { if (override === undefined) return CORE_RPC_TIMEOUT_MS; if (!Number.isFinite(override)) return CORE_RPC_TIMEOUT_MS; const clamped = Math.min( Math.max(Math.round(override), PER_CALL_TIMEOUT_MIN_MS), PER_CALL_TIMEOUT_MAX_MS ); return clamped; } interface JsonRpcRequestBody { jsonrpc: '2.0'; id: number; method: string; params: unknown; } interface JsonRpcError { code: number; message: string; data?: unknown; } interface JsonRpcResponse { jsonrpc?: string; id?: number | string | null; result?: T; error?: JsonRpcError; } let nextJsonRpcId = 1; let resolvedCoreRpcUrl: string | null = null; let resolvingCoreRpcUrl: Promise | null = null; let resolvedCoreRpcToken: string | null = null; let didResolveCoreRpcToken = false; let resolvingCoreRpcToken: Promise | null = null; /** * Stable classification of an RPC failure. Callers (hooks, providers, Sentry * filters) should branch on `kind` — never on raw message regexes. The shape * exists so a single 401 from the Rust backend (`Session expired. Please log * in again.`) can drive both a silent swallow in usage/credits chains AND * a global reauth signal without every caller re-implementing the regex. */ export type CoreRpcErrorKind = | 'auth_expired' | 'provider_auth' // downstream provider 401 — NOT user session expiry | 'transport' | 'timeout' | 'rate_limited' | 'budget_exceeded' | 'thread_not_found' | 'unknown'; export class CoreRpcError extends Error { readonly kind: CoreRpcErrorKind; readonly httpStatus?: number; readonly data?: unknown; constructor(message: string, kind: CoreRpcErrorKind, httpStatus?: number, data?: unknown) { super(message); this.name = 'CoreRpcError'; this.kind = kind; this.httpStatus = httpStatus; this.data = data; } } const AUTH_EXPIRED_EVENT = 'core-rpc-auth-expired'; /** * Classify an RPC error from its surfaced message and (when available) the * HTTP status the core returned. Patterns map to the Rust-side error shapes * produced by `src/openhuman/backend_api/*` (`authed_json`, rate limiter, * budget guard) and `reqwest::Error`'s connect/timeout variants. */ export function classifyRpcError( message: string, httpStatus?: number, data?: unknown ): CoreRpcErrorKind { if (isThreadNotFoundRpcData(data)) return 'thread_not_found'; if (httpStatus === 401) return 'auth_expired'; if (httpStatus === 429) return 'rate_limited'; // 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. // HEAD and OPTIONS intentionally excluded — authed_json only uses these five verbs. // Aligned with Rust is_session_expired_error: starts-with-verb check + separate // contains checks for "401" and "unauthorized" (case-insensitive). if ( /^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(message) && /401/.test(message) && /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 // `client error (Connect): operation timed out`. Must run BEFORE the // `transport` arm so the more specific kind wins. if (/timed out after \d+ms/i.test(message)) return 'timeout'; if (/error sending request|client error \(Connect\)|timed out|ECONNREFUSED/i.test(message)) { return 'transport'; } return 'unknown'; } function isThreadNotFoundRpcData(data: unknown): boolean { if (!data || typeof data !== 'object') return false; // The server only ever emits kind === 'ThreadNotFound' (see // src/openhuman/threads/error.rs THREAD_NOT_FOUND_KIND). The snake_case // variant is not produced anywhere; keep only the canonical form. return (data as { kind?: unknown }).kind === 'ThreadNotFound'; } function threadIdFromRpcData(data: unknown): string | null { if (!data || typeof data !== 'object') return null; const record = data as { thread_id?: unknown; threadId?: unknown }; if (typeof record.thread_id === 'string') return record.thread_id; if (typeof record.threadId === 'string') return record.threadId; return null; } export function isThreadNotFoundCoreRpcError( error: unknown, threadId?: string ): error is CoreRpcError { if (!(error instanceof CoreRpcError) || error.kind !== 'thread_not_found') return false; if (!threadId) return true; const errorThreadId = threadIdFromRpcData(error.data); return !errorThreadId || errorThreadId === threadId; } function dispatchAuthExpired(method: string): void { if (typeof window === 'undefined') return; try { window.dispatchEvent( new CustomEvent(AUTH_EXPIRED_EVENT, { detail: { method, source: 'rpc' } }) ); } catch { // jsdom in some test paths can throw on CustomEvent constructor edge // cases; never let a telemetry hop fail the original RPC error path. } } /** * Invalidate the cached core RPC URL so the next call to getCoreRpcUrl() * re-resolves from the user-configured or environment-default value. * Call this after the user saves a new RPC URL preference. */ export function clearCoreRpcUrlCache(): void { resolvedCoreRpcUrl = null; resolvingCoreRpcUrl = null; } /** * Pub/sub for "the core RPC bearer just became stale — drop any cached value * and re-resolve". Long-lived consumers (e.g. SSE subscriptions that embed * the bearer in the URL) need this so they can tear down the old connection * and open a new one when the in-process core is restarted with a fresh * `OPENHUMAN_CORE_TOKEN`. * * Implemented over `EventTarget` (no third-party dep, no React coupling) so * services + hooks can both attach without a provider boundary. */ const coreRpcTokenInvalidationBus = new EventTarget(); const CORE_RPC_TOKEN_INVALIDATED_EVENT = 'invalidated'; /** * Subscribe to core RPC bearer invalidations. Returns an unsubscribe handle. * The listener fires AFTER the cache has been cleared, so a subsequent * `getCoreRpcToken()` will re-resolve. */ export function subscribeCoreRpcTokenInvalidated(listener: () => void): () => void { const wrapped = () => listener(); coreRpcTokenInvalidationBus.addEventListener(CORE_RPC_TOKEN_INVALIDATED_EVENT, wrapped); return () => { coreRpcTokenInvalidationBus.removeEventListener(CORE_RPC_TOKEN_INVALIDATED_EVENT, wrapped); }; } /** * Invalidate the cached core RPC bearer token so the next call to * `getCoreRpcToken()` re-resolves from `getStoredCoreToken()` or the Tauri * sidecar. Call after the user saves a new cloud-mode token (or switches * mode) so in-flight changes take effect without a full reload. * * Also dispatches on the invalidation bus so token-bearing long-lived * connections (webhook SSE per #1922) can reconnect with the fresh value. */ export function clearCoreRpcTokenCache(): void { resolvedCoreRpcToken = null; didResolveCoreRpcToken = false; resolvingCoreRpcToken = null; coreRpcTokenInvalidationBus.dispatchEvent(new Event(CORE_RPC_TOKEN_INVALIDATED_EVENT)); } const coreRpcLog = debug('core-rpc'); const coreRpcError = debug('core-rpc:error'); function coreRpcErrorMessage(err: unknown): string { if (err instanceof Error && err.message) { return err.message; } if (typeof err === 'string') { return err; } if (err && typeof err === 'object') { const maybeMessage = (err as { message?: unknown }).message; if (typeof maybeMessage === 'string' && maybeMessage.trim().length > 0) { return maybeMessage; } const maybeError = (err as { error?: unknown }).error; if (typeof maybeError === 'string' && maybeError.trim().length > 0) { return maybeError; } } return 'Unknown core RPC error'; } export async function getCoreRpcUrl(): Promise { if (resolvedCoreRpcUrl) { return resolvedCoreRpcUrl; } if (!coreIsTauri()) { // Web environment: respect any user-stored URL (including one that // happens to equal the build-time default). `peekStoredRpcUrl` returns // null when nothing is stored, which lets us distinguish "user hasn't // chosen yet" from "user chose a value identical to the default". const storedUrl = peekStoredRpcUrl(); resolvedCoreRpcUrl = normalizeRpcUrl(storedUrl ?? CORE_RPC_URL); return resolvedCoreRpcUrl; } if (resolvingCoreRpcUrl) { return resolvingCoreRpcUrl; } const resolvePromise: Promise = (async () => { try { // Tauri: any user-stored URL (cloud picker output) wins. Without this // a cloud-mode user whose picker URL coincides with the build-time // `VITE_OPENHUMAN_CORE_RPC_URL` would be silently routed to whatever // `core_rpc_url` returns (typically the local sidecar's // `http://127.0.0.1:/rpc`), producing ERR_CONNECTION_REFUSED in // cloud mode where no local sidecar is running. const storedUrl = peekStoredRpcUrl(); if (storedUrl) { resolvedCoreRpcUrl = normalizeRpcUrl(storedUrl); return resolvedCoreRpcUrl; } const url = await invoke('core_rpc_url'); const trimmed = String(url || '').trim(); if (!trimmed) { coreRpcError('core_rpc_url returned empty; using build-time default', { fallback: CORE_RPC_URL, }); } resolvedCoreRpcUrl = normalizeRpcUrl(trimmed || CORE_RPC_URL); return resolvedCoreRpcUrl || CORE_RPC_URL; } catch (err) { // Tauri invoke failed — fall back to stored URL if any, then the // build-time default. Keep the underlying invoke failure visible so // port mismatches and shell misconfiguration are diagnosable. const storedUrl = peekStoredRpcUrl(); resolvedCoreRpcUrl = normalizeRpcUrl(storedUrl ?? CORE_RPC_URL); coreRpcError('core_rpc_url invoke failed; using fallback RPC URL', { fallback: redactRpcUrlForLog(resolvedCoreRpcUrl), usedStoredUrl: Boolean(storedUrl), error: sanitizeError(err), }); return resolvedCoreRpcUrl; } finally { resolvingCoreRpcUrl = null; } })(); resolvingCoreRpcUrl = resolvePromise; return resolvePromise; } /** * Returns the bearer token for authenticating against the core RPC endpoint. * * Resolution order: * 1. `getStoredCoreToken()` — token entered by the user in the cloud-mode * picker. When set, the desktop is talking to a remote core and the * local-sidecar token would be wrong. Takes priority so cloud mode * always sends the user's own token. * 2. Tauri `core_rpc_token` command — the embedded sidecar's per-process * token, written by the core binary to `~/.openhuman/core.token` at * startup. Cached for the lifetime of the frontend process. * 3. `null` in non-Tauri environments (e.g. Vitest, web preview) when no * stored token is set so existing tests remain unaffected. */ export async function getCoreRpcToken(): Promise { if (didResolveCoreRpcToken) return resolvedCoreRpcToken; const storedToken = getStoredCoreToken(); if (storedToken) { resolvedCoreRpcToken = storedToken; didResolveCoreRpcToken = true; coreRpcLog('core RPC token loaded from cloud-mode persistence'); return resolvedCoreRpcToken; } if (!coreIsTauri()) return null; if (resolvingCoreRpcToken) return resolvingCoreRpcToken; resolvingCoreRpcToken = (async () => { try { const token = await invoke('core_rpc_token'); resolvedCoreRpcToken = token?.trim() || null; didResolveCoreRpcToken = true; coreRpcLog('core RPC token loaded'); return resolvedCoreRpcToken; } catch (err) { coreRpcError('failed to load core RPC token', err); resolvedCoreRpcToken = null; didResolveCoreRpcToken = true; return null; } finally { resolvingCoreRpcToken = null; } })(); return resolvingCoreRpcToken; } /** * Probe an arbitrary core RPC URL with `core.ping`. Used by the * Welcome page's "Test Connection" affordance to validate a user-entered * RPC URL without going through the cached `getCoreRpcUrl` resolution. * * Encapsulates the bearer-token + JSON-RPC envelope assembly that would * otherwise sit in the calling component, keeping all RPC client behavior * inside the service per the project guideline ("Keep Tauri IPC and RPC * client calls localized to services … do not scatter `invoke()` or * direct RPC calls throughout components"). * * `tokenOverride` lets the cloud-mode picker test a freshly-typed token * before it's persisted; without it, falls back to the normal resolution. */ export async function testCoreRpcConnection( url: string, tokenOverride?: string, init?: { signal?: AbortSignal } ): Promise { const rpcUrl = normalizeRpcUrl(url); const token = tokenOverride?.trim() || (await getCoreRpcToken()); const headers: Record = { 'Content-Type': 'application/json' }; if (token) { headers.Authorization = `Bearer ${token}`; } return fetch(rpcUrl, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }), signal: init?.signal, }); } export async function getCoreHttpBaseUrl(): Promise { const rpcUrl = await getCoreRpcUrl(); const url = new URL(rpcUrl); url.pathname = ''; url.search = ''; url.hash = ''; return url.toString().replace(/\/$/, ''); } /** * Build the URL the FE uses to subscribe to `/events/webhooks` via SSE. * * Native `EventSource` cannot attach an `Authorization` header (whatwg/html * §10.7), so the core RPC bearer is forwarded as a `?token=…` query param. * The Rust middleware validates it against the same in-process token used * for `POST /rpc` (single source of truth — see `src/core/auth.rs` * `QUERY_TOKEN_PATHS`). * * Returns the URL on success, or `null` when no token is available — the * caller should then **skip** creating the EventSource rather than ship an * unauthenticated request that the server will reject with 401 and have the * browser auto-reconnect against forever. * * The same helper is consumed by the WebhooksDebugPanel settings screen and * is the seam #1339 will reuse when the approvals SSE stream lands. */ export function buildWebhookEventsUrl(baseUrl: string, coreRpcToken: string | null): string | null { if (!coreRpcToken) return null; return `${baseUrl}/events/webhooks?token=${encodeURIComponent(coreRpcToken)}`; } export async function callCoreRpc({ method, params, serviceManaged = false, // kept for compatibility; direct frontend RPC does not use relay-level routing. timeoutMs, }: CoreRpcRelayRequest): Promise { void serviceManaged; if (method.startsWith('ai.')) { return dispatchLocalAiMethod(method, (params ?? {}) as Record) as T; } const normalizedMethod = normalizeRpcMethod(method); const effectiveTimeoutMs = resolvePerCallTimeoutMs(timeoutMs); const payload: JsonRpcRequestBody = { jsonrpc: '2.0', id: nextJsonRpcId++, method: normalizedMethod, params: params ?? {}, }; try { const [rpcUrl, token] = await Promise.all([getCoreRpcUrl(), getCoreRpcToken()]); coreRpcLog('HTTP request', { id: payload.id, method: payload.method }); if (normalizedMethod === 'openhuman.auth_store_session') { coreRpcLog('[rpc] auth_store_session routing', { rpcUrl, tokenSource: getStoredCoreToken() ? 'cloud-stored' : 'local-resolved', }); } if (coreIsTauri() && !token) { throw new Error('Core RPC token unavailable in Tauri; local RPC auth cannot be satisfied'); } const headers: Record = { 'Content-Type': 'application/json' }; if (token) { headers['Authorization'] = `Bearer ${token}`; } // Bound the fetch. Without this a hung core sidecar would block every // caller (and the UI) forever. We use a manual AbortController + // setTimeout rather than AbortSignal.timeout() so test fake timers can // drive the abort deterministically. Per-call `timeoutMs` (clamped) lets // legitimately-slow RPCs such as first-launch `app_state_snapshot` // (#2156) opt into a longer-but-still-bounded budget. const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), effectiveTimeoutMs); let response: Response; try { response = await fetch(rpcUrl, { method: 'POST', headers, body: JSON.stringify(payload), signal: controller.signal, }); } catch (fetchErr) { if (controller.signal.aborted) { // Throw a fully-classified `CoreRpcError` here so the outer catch // doesn't re-wrap a bare `Error` and so callers can branch on // `err.kind === 'timeout'` (Sentry filter, soft toast skip). Use // the per-call `effectiveTimeoutMs` so the message reflects the // actual budget (#2156 raised the snapshot path to 90s). throw new CoreRpcError( `Core RPC ${payload.method} timed out after ${effectiveTimeoutMs}ms`, 'timeout' ); } throw fetchErr; } finally { clearTimeout(timeoutId); } if (!response.ok) { const text = await response.text(); const httpMessage = `Core RPC HTTP ${response.status}: ${text || response.statusText}`; const kind = classifyRpcError(text || response.statusText, response.status); if (kind === 'auth_expired') dispatchAuthExpired(payload.method); throw new CoreRpcError(httpMessage, kind, response.status); } const json = (await response.json()) as JsonRpcResponse; if (json.error) { coreRpcError('HTTP error response', { id: payload.id, method: payload.method, error: json.error, }); const rawMessage = json.error.message || 'Core RPC returned an error'; const kind = classifyRpcError(rawMessage, undefined, json.error.data); if (kind === 'auth_expired') dispatchAuthExpired(payload.method); throw new CoreRpcError(rawMessage, kind, undefined, json.error.data); } if (!Object.prototype.hasOwnProperty.call(json, 'result')) { throw new Error('Core RPC response missing result'); } coreRpcLog('HTTP response', { id: payload.id, method: payload.method }); return json.result as T; } catch (err) { coreRpcError('Core RPC call failed', sanitizeError(err)); if (err instanceof CoreRpcError) throw err; const message = coreRpcErrorMessage(err); throw new CoreRpcError(message, classifyRpcError(message)); } }