Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/core/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -36,6 +37,25 @@ pub async fn dispatch(
rpc_log::redact_params_for_log(&params)
);

// 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.
Expand Down Expand Up @@ -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!({}))
Expand Down
160 changes: 160 additions & 0 deletions src/core/legacy_aliases.rs
Original file line number Diff line number Diff line change
@@ -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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] The lifetime annotation pub fn resolve_legacy<'a>(method: &'a str) -> &'a str is technically incorrect when returning a &'static str from the table — the returned reference lives longer than 'a. It compiles because 'static: 'a, but the signature under-promises. Consider lifetime elision (pub fn resolve_legacy(method: &str) -> &str) which already does the right thing, or explicitly return &'static str when matched. Not a bug, just slightly misleading.

#[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");
}
}
1 change: 1 addition & 0 deletions src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading