/** * Cross-connection routing: resolving a roster row's owning connection, the * RPC door that rides that owner, and the alias identity that keeps a * configured Desktop alias attached to the backend row it activates into. * * The bottom of the Bot Mode module graph — it imports no sibling module, so * everything else can dispatch through it without a cycle. */ import { host } from '@hermes/plugin-sdk' import type { BotMeta, ProfileRoute, RosterRow } from './types' export function botRouteKey(route: ProfileRoute): string { return `${route.connectionId}::${route.profile}` } // ── cross-connection routing ───────────────────────────────────────────────── // A bot from another registered connection (remoteSource rows) is reached // through host.requestProfile with a route descriptor; local bots keep the // active-gateway door. Feature-detected: older desktops without // requestProfile simply have no remote routes (callers fall back / disable). /** Outcome of resolving a row's owning connection. `profile` is only carried * on the `owner_removed` branch, where the strict wrapper needs it for the * error message. */ interface BotRouteResolution { profile?: string route: ProfileRoute | null status: 'not_scoped' | 'owner_removed' | 'resolved' } /** Non-throwing resolver behind botConnectionRoute(). Returns a typed status * instead of throwing, so passive callers (display/meta lookups) can branch * on `resolved | owner_removed | not_scoped` rather than catching whatever * exception the strict wrapper below happens to throw. */ export function resolveBotConnectionRoute(bot: Partial | null | undefined): BotRouteResolution { if (!bot?.sourceScoped && !bot?.remoteSource) { return { status: 'not_scoped', route: null } } const candidate = bot.route || { connectionId: bot.connectionId, mode: bot.connectionKind === 'local' ? 'local' : 'remote', profile: bot.name, targetProfile: bot.targetProfile || bot.name } const connectionId = String(candidate?.connectionId || '').trim() const profile = String(candidate?.profile || bot?.name || '').trim() || 'default' const targetProfile = String(candidate?.targetProfile || profile).trim() || profile if (!connectionId) { return { status: 'owner_removed', route: null, profile } } return { status: 'resolved', route: Object.freeze({ connectionId, mode: candidate.mode === 'local' || connectionId === 'local' ? 'local' : 'remote', profile, targetProfile }) } } /** Immutable owner descriptor for every source-scoped row. The active * gateway is presentation state and is never consulted here. Strict: throws * when the owning connection is gone -- correct for real dispatch * (requestForBot, session creation, etc.), covered by * remote-routing-races.test.mjs. Passive lookups (rendering, meta) must call * resolveBotConnectionRoute() directly instead of catching this throw. */ export function botConnectionRoute(bot: Partial | null | undefined): null | ProfileRoute { const resolved = resolveBotConnectionRoute(bot) if (resolved.status === 'owner_removed') { throw new Error(`Bot ${resolved.profile} has no connection owner`) } return resolved.route } export function botWorkspaceOwnerKey(bot: RosterRow) { // Render-reachable (sidebar sync, context menus): an orphaned row must // yield a stable degraded key, never the dispatch throw. const route = resolveBotConnectionRoute(bot).route return `bot:${route ? botRouteKey(route) : String(bot?.name || 'default')}` } export function setBotsWorkspaceOwner( ownerKey: string, bot: null | RosterRow = null, blockedMessage = 'Select a Bot or group first.' ) { // Render-reachable (sidebar listener fires on visibility flips). An // orphaned row degrades to the blocked target instead of throwing. const route = bot ? resolveBotConnectionRoute(bot).route : null const target: { kind: 'blocked'; message: string } | { kind: 'route'; route: ProfileRoute } = route ? { kind: 'route', route } : { kind: 'blocked', message: blockedMessage } host.setWorkspaceScope?.('bots', ownerKey, target) } export function backendTargetProfile(route: null | ProfileRoute | undefined, fallbackProfile = 'default') { if (!route) { return fallbackProfile } return route.targetProfile || route.profile } function rewriteCliProfileOperands(argv: string[], logical: string, target: string) { const next = [...argv] for (let index = 0; index < next.length; index += 1) { if (next[index] === '--profile' && next[index + 1] === logical) { next[index + 1] = target index += 1 } else if (next[index] === `--profile=${logical}`) { next[index] = `--profile=${target}` } } const profileCommand = next.indexOf('profile') const operand = profileCommand >= 0 ? profileCommand + 2 : -1 if (operand < next.length && next[operand] === logical) { next[operand] = target } return next } function scopedBotParams(route: ProfileRoute, method: string, params: Record) { const logical = route.profile const target = backendTargetProfile(route, logical) let next = params if (Object.prototype.hasOwnProperty.call(params, 'profile')) { next = { ...next, profile: target } } if (method.startsWith('profiles.') && method !== 'profiles.create' && params.name === logical) { next = { ...next, name: target } } if (params.clone_from === logical) { next = { ...next, clone_from: target } } if (method === 'cli.exec' && Array.isArray(params.argv)) { next = { ...next, argv: rewriteCliProfileOperands(params.argv, logical, target) } } return next } export function botBackendProfileScope(route: null | ProfileRoute | undefined, fallbackProfile = 'default') { if (!route) { return fallbackProfile } return { connectionId: route.connectionId, profile: backendTargetProfile(route, fallbackProfile) } } /** Gateway RPC on the bot's OWN source. Source-scoped rows always use the * explicit descriptor, including a registered local source. */ export async function requestForBot( bot: Partial | null | undefined, method: string, params: Record = {} ): Promise { const route = botConnectionRoute(bot) if (route) { if (typeof host.requestProfile !== 'function') { throw new Error(`Cannot route ${method} for ${route.connectionId}::${route.profile}`) } try { return await host.requestProfile(route, method, scopedBotParams(route, method, params)) } catch (error) { // React 19 formats query errors with `(error.name || '').trim()`. IPC / // JSON-RPC rejections are often plain objects whose `name` is a number, // which crashes the Routines pane and hides the original failure (#94471). throw asRpcError(error, `Gateway request ${method} failed`) } } try { return await host.request(method, params) } catch (error) { throw asRpcError(error, `Gateway request ${method} failed`) } } /** A rejection duck-typed across realms: an Error-like whose fields are only * conventionally typed, so every read stays `unknown` until it is checked. */ export interface RpcErrorLike { message?: unknown name?: unknown stack?: unknown } /** Coerce an IPC/JSON-RPC rejection into an Error with a string `name`. * * React Query stores whatever the queryFn throws. React 19 then formats it * with `(e.name || '').trim()`, which throws TypeError when `name` is a * number (JSON-RPC codes) or another non-string — the Routines pane crash * in #94471. Real Error instances are returned as-is when already safe. */ function asRpcError(value: unknown, fallback: string): unknown { // Duck-type across realms (plugin tests run the source in `vm`, and IPC // can deliver Error-like objects whose prototype is not this realm's // Error). React 19 only needs a string `name`. Never mutate the rejection: // frozen/sealed objects make `name = 'Error'` a silent no-op in sloppy // mode, so a non-string name always becomes a fresh Error with cause. const isObject = value != null && typeof value === 'object' const name = isObject ? (value as RpcErrorLike).name : undefined const message = isObject ? (value as RpcErrorLike).message : undefined const hasStringName = typeof name === 'string' const hasStringMessage = typeof message === 'string' const hasStack = isObject && typeof (value as RpcErrorLike).stack === 'string' if (isObject && hasStringName && (hasStack || hasStringMessage)) { return value } if (isObject) { const text = hasStringMessage && String(message).trim() ? String(message) : fallback const error = new Error(text) error.cause = value return error } return new Error(value == null || value === '' ? fallback : String(value)) } // ── alias identity for connection rows (#89131) ───────────────────────────── // A Desktop per-profile alias (profile `moxie` with a Cloud/URL/SSH override) // routes to a remote backend's root profile: its route reads // { connectionId: C, profile: 'moxie', targetProfile: 'default' }. Once that // backend answers the roster itself, the row's identity is (C, 'default') — // a DIFFERENT key than the alias meta (C::moxie / 'moxie') — so the friendly // name fell off after source/session activation: the row regressed to the // raw Cloud hostname, or to generic 'Hermes' in Cloud-only mode. // // aliasRouteIndex bridges the backend row identity back to its configured // alias. It is keyed by (connectionId, targetProfile), so two same-named // `default` rows on different connections can never share a title, and it // fails closed when two aliases claim the same backend row (mirroring the // fail-closed route resolution). This is the one sanctioned exception to // "remote rows never borrow local meta": the alias IS the local identity of // exactly this connection row, proven by the configured route — never by a // bare name match. interface AliasIdentity { /** Both the source-qualified v2 key and the bare v1 name key. */ metaKeys: string[] name: string } /** Keyed `${connectionId}::${targetProfile}`; null marks an ambiguous claim. */ let aliasRouteIndex = new Map() let aliasRouteEpoch = 0 /** Claim the next rebuild epoch BEFORE reading the route inventory, then hand * the token to indexAliasRoutes. The read is async and the index is replaced * wholesale, so two overlapping roster fetches can resolve out of order and * the slower one's stale routes would win. */ export function beginAliasRouteIndex(): number { aliasRouteEpoch += 1 return aliasRouteEpoch } /** Rebuild the alias index from the credential-free route inventory. Only * genuine aliases (route.profile !== route.targetProfile) participate. A * rebuild overtaken by a newer one drops its result. */ export function indexAliasRoutes(routes: ProfileRoute[], epoch = beginAliasRouteIndex()) { if (epoch < aliasRouteEpoch) { return } const next = new Map() for (const route of Array.isArray(routes) ? routes : []) { const connectionId = String(route?.connectionId || '').trim() const profile = String(route?.profile || '').trim() const target = String(route?.targetProfile || '').trim() if (!connectionId || !profile || !target || profile === target) { continue } const key = `${connectionId}::${target}` // Two aliases pointing at the same backend row are ambiguous — neither // may claim the identity. next.set( key, next.has(key) ? null : { name: profile, // Alias meta can live under the source-qualified v2 key or the bare // v1 name key (aliases predate the v2 migration on mixed setups). metaKeys: [`${connectionId}::${profile}`, profile] } ) } aliasRouteIndex = next } /** The configured alias identity claiming this roster row, or null. Matches * strictly by (connectionId, backend target profile); the alias row itself * keeps resolving its own meta directly. */ export function aliasIdentityFor(bot: Partial | null | undefined): AliasIdentity | null { if (!aliasRouteIndex.size) { return null } const connectionId = String( bot?.connectionId || bot?.route?.connectionId || // Unannotated rich rows (no host.agents on this build) still belong to // the ACTIVE gateway — Cloud-only mode must resolve the alias too. (!bot?.remoteSource && !bot?.sourceScoped ? host.state.connectionId?.get?.() || '' : '') ).trim() if (!connectionId) { return null } const target = String(bot?.targetProfile || bot?.route?.targetProfile || bot?.name || '').trim() || 'default' const entry = aliasRouteIndex.get(`${connectionId}::${target}`) || null return entry && entry.name !== String(bot?.name || '').trim() ? entry : null } // Bot metadata is scoped to the active gateway until the server exposes a // union of rich profile rows. Never paint that metadata onto a thin row from // another source: two `default` agents must not borrow each other's title, // pin, avatar, group, unread state, or canonical-chat pointer. The ONE // exception is a configured alias route claiming the row — see // aliasRouteIndex above — which is connection-exact, never name-based. export function botRosterMeta(bot: RosterRow, metaByName: Record) { if (bot?.sourceScoped || bot?.remoteSource) { // Passive meta lookup: branch on the typed status instead of catching // botConnectionRoute's throw, so an owner_removed row (e.g. a stale // persisted group roster after its connection was deleted) reads as "no // route" without masking an unrelated failure under the same catch. const resolved = resolveBotConnectionRoute(bot) const route = resolved.status === 'resolved' ? resolved.route : null const direct = route ? metaByName?.[botRouteKey(route)] : null if (direct) { return direct } const alias = aliasIdentityFor(bot) if (alias) { for (const key of alias.metaKeys) { if (metaByName?.[key]) { return metaByName[key] } } } return direct } const own = metaByName?.[bot?.name] if (own) { return own } const alias = aliasIdentityFor(bot) if (alias) { for (const key of alias.metaKeys) { if (metaByName?.[key]) { return metaByName[key] } } } return own }