From ececcccbde432e62e247591242b96e97514fa45a Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 12 May 2026 17:13:33 +0530 Subject: [PATCH 1/2] fix(rpc): rewrite legacy method names server-side before dispatch Older shipped frontend bundles still call the bare `openhuman.update_composio_trigger_settings`, but the core only registers the namespaced `openhuman.config_update_composio_trigger_settings`, so the dispatcher's exact-match lookup falls through and emits the Sentry "unknown method" errors observed under OPENHUMAN-TAURI-BQ. Mirror the frontend's `LEGACY_METHOD_ALIASES` table (in `app/src/services/rpcMethods.ts`) inside `src/core/legacy_aliases.rs` and resolve incoming method names through it before the tiered subsystem lookup. The frontend rewrites outgoing names for clients that just updated; the core rewrites incoming names for clients that haven't yet. Together they form a symmetric migration safety net. Logs each rewrite at info with a stable `[rpc-legacy-alias]` prefix so support can grep how prevalent the legacy traffic is. Covers all 16 entries from the frontend table -- not just the composio one observed in Sentry. --- src/core/dispatch.rs | 29 +++++++ src/core/legacy_aliases.rs | 159 +++++++++++++++++++++++++++++++++++++ src/core/mod.rs | 1 + 3 files changed, 189 insertions(+) create mode 100644 src/core/legacy_aliases.rs diff --git a/src/core/dispatch.rs b/src/core/dispatch.rs index 4d0b5b2ee9..c591b63646 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,22 @@ 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 { + log::info!( + "[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 +177,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..eb821e4cf2 --- /dev/null +++ b/src/core/legacy_aliases.rs @@ -0,0 +1,159 @@ +//! 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. +/// +/// Lifetimes are arranged so callers can use the returned reference for +/// the duration of the input borrow without allocating. +pub fn resolve_legacy<'a>(method: &'a str) -> &'a 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; From 68873b17eb289781e0bae3100d300647ca3e1b31 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 12 May 2026 22:37:38 +0530 Subject: [PATCH 2/2] review: polish lifetime sig + log level (graycyrus on #1544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `resolve_legacy` now uses lifetime elision instead of the explicit `<'a>` annotation that under-promised the return lifetime (the matched-canonical branch is `&'static`, the pass-through branch is the input borrow; elision picks the tighter input lifetime). - Per-rewrite log dropped from `info!` to `debug!` so the dispatcher hot path stays quiet at scale. Aggregate visibility belongs in the observability layer. Both flagged `[minor]` and "not blocking" — landing as polish. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/dispatch.rs | 5 ++++- src/core/legacy_aliases.rs | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/core/dispatch.rs b/src/core/dispatch.rs index c591b63646..8d97627de8 100644 --- a/src/core/dispatch.rs +++ b/src/core/dispatch.rs @@ -45,7 +45,10 @@ pub async fn dispatch( // `crate::core::legacy_aliases` for the shared table. let resolved = resolve_legacy(method); if resolved != method { - log::info!( + // 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 diff --git a/src/core/legacy_aliases.rs b/src/core/legacy_aliases.rs index eb821e4cf2..cf2fb25d62 100644 --- a/src/core/legacy_aliases.rs +++ b/src/core/legacy_aliases.rs @@ -88,9 +88,10 @@ const LEGACY_ALIASES: &[(&str, &str)] = &[ /// calling it on an already-canonical name (or any unrelated name) is a /// no-op. /// -/// Lifetimes are arranged so callers can use the returned reference for -/// the duration of the input borrow without allocating. -pub fn resolve_legacy<'a>(method: &'a str) -> &'a str { +/// 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;