diff --git a/src/core/dispatch.rs b/src/core/dispatch.rs index 4d0b5b2ee9..8d97627de8 100644 --- a/src/core/dispatch.rs +++ b/src/core/dispatch.rs @@ -3,6 +3,7 @@ //! This module coordinates the routing of incoming requests to either the //! core subsystem or the OpenHuman domain-specific handlers. +use crate::core::legacy_aliases::resolve_legacy; use crate::core::rpc_log; use crate::core::types::{AppState, InvocationResult}; use serde_json::{json, Map, Value}; @@ -36,6 +37,25 @@ pub async fn dispatch( rpc_log::redact_params_for_log(¶ms) ); + // Tier 0: Rewrite legacy method names to their canonical form before + // any subsystem lookup. Symmetric with the frontend's + // `normalizeRpcMethod` (`app/src/services/rpcMethods.ts`): the + // frontend rewrites outgoing names for clients that just updated, the + // core rewrites incoming names for clients that haven't yet. See + // `crate::core::legacy_aliases` for the shared table. + let resolved = resolve_legacy(method); + if resolved != method { + // Per-rewrite log at debug to keep the dispatcher hot path quiet + // at scale (per graycyrus review on PR #1544). Aggregate + // visibility belongs in the observability layer, not here. + log::debug!( + "[rpc-legacy-alias] rewrite method={} -> canonical={}", + method, + resolved + ); + } + let method = resolved; + // Tier 1: Internal core methods. // These are handled directly within the core module and don't require // a separate controller registration. @@ -160,6 +180,18 @@ mod tests { assert_eq!(out, json!({ "ok": true })); } + #[tokio::test] + async fn dispatch_rewrites_legacy_alias_before_lookup() { + // `openhuman.ping` is a legacy alias for `core.ping` in the shared + // alias table. Going through the dispatcher must rewrite it and + // route successfully to Tier 1 instead of falling through to the + // unknown-method error path. + let out = dispatch(test_state(), "openhuman.ping", json!({})) + .await + .expect("legacy alias openhuman.ping must resolve to core.ping"); + assert_eq!(out, json!({ "ok": true })); + } + #[tokio::test] async fn dispatch_unknown_method_returns_error() { let err = dispatch(test_state(), "does.not.exist", json!({})) diff --git a/src/core/legacy_aliases.rs b/src/core/legacy_aliases.rs new file mode 100644 index 0000000000..cf2fb25d62 --- /dev/null +++ b/src/core/legacy_aliases.rs @@ -0,0 +1,160 @@ +//! Server-side legacy RPC method aliases. +//! +//! Mirrors the frontend's `LEGACY_METHOD_ALIASES` table in +//! `app/src/services/rpcMethods.ts`. The frontend rewrites outgoing method +//! names for clients that just updated; this module rewrites incoming +//! method names for clients that haven't updated yet (older shipped bundles +//! in the wild). Together they form a symmetric migration safety net: +//! either side can be the one that's behind, and the call still resolves. +//! +//! When adding or removing an entry here, keep +//! `app/src/services/rpcMethods.ts:LEGACY_METHOD_ALIASES` in sync. The two +//! tables are intentionally identical: the same legacy → canonical map +//! applied at both ends of the wire. +//! +//! The rewrite is a pure key-to-key lookup. No domain branches, no +//! parameter inspection — if a method isn't in the table, it passes through +//! untouched. + +/// Legacy → canonical RPC method name pairs. +/// +/// Order doesn't matter for correctness, but is kept alphabetical by legacy +/// key for easier diffing against the frontend table. +const LEGACY_ALIASES: &[(&str, &str)] = &[ + ( + "openhuman.get_analytics_settings", + "openhuman.config_get_analytics_settings", + ), + ( + "openhuman.get_composio_trigger_settings", + "openhuman.config_get_composio_trigger_settings", + ), + ("openhuman.get_config", "openhuman.config_get"), + ( + "openhuman.get_runtime_flags", + "openhuman.config_get_runtime_flags", + ), + ("openhuman.ping", "core.ping"), + ( + "openhuman.set_browser_allow_all", + "openhuman.config_set_browser_allow_all", + ), + ( + "openhuman.update_analytics_settings", + "openhuman.config_update_analytics_settings", + ), + ( + "openhuman.update_browser_settings", + "openhuman.config_update_browser_settings", + ), + ( + "openhuman.update_composio_trigger_settings", + "openhuman.config_update_composio_trigger_settings", + ), + ( + "openhuman.update_local_ai_settings", + "openhuman.config_update_local_ai_settings", + ), + ( + "openhuman.update_memory_settings", + "openhuman.config_update_memory_settings", + ), + ( + "openhuman.update_model_settings", + "openhuman.config_update_model_settings", + ), + ( + "openhuman.update_runtime_settings", + "openhuman.config_update_runtime_settings", + ), + ( + "openhuman.update_screen_intelligence_settings", + "openhuman.config_update_screen_intelligence_settings", + ), + ( + "openhuman.workspace_onboarding_flag_exists", + "openhuman.config_workspace_onboarding_flag_exists", + ), + ( + "openhuman.workspace_onboarding_flag_set", + "openhuman.config_workspace_onboarding_flag_set", + ), +]; + +/// Resolves a legacy RPC method name to its canonical form, if any. +/// +/// Returns the canonical name when `method` is a known legacy alias; +/// otherwise returns `method` unchanged. This function is idempotent: +/// calling it on an already-canonical name (or any unrelated name) is a +/// no-op. +/// +/// Returns a borrow that lives for at least the input's lifetime — the +/// matched-canonical branch returns `&'static`, the pass-through branch +/// returns the input borrow; elision picks the tighter input lifetime. +pub fn resolve_legacy(method: &str) -> &str { + for (legacy, canonical) in LEGACY_ALIASES { + if *legacy == method { + return canonical; + } + } + method +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_legacy_rewrites_every_table_entry() { + for (legacy, canonical) in LEGACY_ALIASES { + assert_eq!( + resolve_legacy(legacy), + *canonical, + "expected legacy alias {legacy} to resolve to {canonical}", + ); + } + } + + #[test] + fn resolve_legacy_rewrites_composio_trigger_settings() { + // The specific case observed in Sentry: older bundles called the + // bare `openhuman.update_composio_trigger_settings` against a core + // that only registers the namespaced form. + assert_eq!( + resolve_legacy("openhuman.update_composio_trigger_settings"), + "openhuman.config_update_composio_trigger_settings", + ); + } + + #[test] + fn resolve_legacy_passes_through_unknown_methods() { + assert_eq!( + resolve_legacy("openhuman.memory_list_namespaces"), + "openhuman.memory_list_namespaces" + ); + assert_eq!(resolve_legacy("does.not.exist"), "does.not.exist"); + assert_eq!(resolve_legacy(""), ""); + } + + #[test] + fn resolve_legacy_is_idempotent_for_canonical_names() { + // Canonical names already match what the registry expects; + // running them through the resolver must be a no-op so callers + // can wrap the lookup unconditionally. + for (_, canonical) in LEGACY_ALIASES { + assert_eq!( + resolve_legacy(canonical), + *canonical, + "canonical {canonical} must pass through unchanged", + ); + } + } + + #[test] + fn resolve_legacy_returned_str_equals_table_value() { + // Sanity check: the function returns the canonical str slice from + // the table when it matches, not a copy of the input. + let out = resolve_legacy("openhuman.ping"); + assert_eq!(out, "core.ping"); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index b879d4522e..1d93d0cfed 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -14,6 +14,7 @@ pub mod cli; pub mod dispatch; pub mod event_bus; pub mod jsonrpc; +pub mod legacy_aliases; pub mod logging; pub mod memory_cli; pub mod observability;