fix(channels): filter /commands dispatch by account_id (multi-bot isolation)#5688
Merged
Conversation
houko
enabled auto-merge (squash)
May 24, 2026 02:00
…lation) Channel-side slash commands (/btw, /new, /reboot, /compact, /model, /stop, /usage, /think, plus the model: interactive-button callback) called the context-less AgentRouter::resolve, which has no account_id parameter and only probes the plain channel key. With per-bot defaults stored under qualified "<channel>:<account_id>" keys (since #2183 / #4861), the lookup fell through to the system default — collapsing every bot's /command to the first-registered agent. /agent <name> stored its override via set_user_default keyed by platform_id only, with no channel scope, so a /agent in bot-a silently overrode every other bot's default_agent for that user until daemon restart (user_defaults is in-memory DashMap, never persisted). Fix re-keys user_defaults as (Option<channel_account_key>, user_key); resolve_with_context probes the per-(channel,account) slot first and falls back to the legacy global slot. handle_command gains an account_id parameter, builds a BindingContext per call via a resolve_for_command closure, and writes /agent's selection via the new set_user_default_for_channel helper. The model: button callback and resolve_or_fallback's auto-bind path are migrated identically — otherwise the fallback path would silently re-introduce the leak. 5 regression tests pin the fix: - router::user_default_does_not_leak_across_bots - router::channel_scoped_user_default_overrides_global - router::legacy_set_user_default_is_channel_agnostic - bridge::command_resolution_respects_account_id (records set_model dispatch agent_ids; /model from bot-b then bot-c records [agent_b, agent_c] — pre-fix this would be [agent_a, agent_a]) - bridge::agent_command_does_not_leak_across_bots cargo test -p librefang-channels --lib → 465/465 green. cargo clippy -p librefang-channels --all-targets -- -D warnings clean. Closes #5672
houko
force-pushed
the
fix/channels-commands-account-id-filter
branch
from
May 24, 2026 03:13
1731431 to
d198a88
Compare
This was referenced May 24, 2026
houko
added a commit
that referenced
this pull request
Jun 3, 2026
…on (#5996) Multi-instance Telegram sidecars regressed the #5688 per-bot isolation because the daemon never propagated the operator-known `SidecarChannelConfig::name` as `account_id`, leaving the #5688 guards as dead code for sidecar adapters. Two manifestations, one root: - `channel_bridge.rs::start_channel_bridge_with_config` pushed sidecar adapters with a hardcoded `account_id = None`, so every Telegram sidecar's `default_agent` collided on the bare `channel_defaults["telegram"]` key and the last-booted bot answered in every bot. Now passes `Some(sidecar_config.name.clone())` so each registers under a distinct `"telegram:<name>"` key. - The `sidecar.rs` reader loop stamped `channel_id` / `platform` / `sender_username` into per-message metadata but never `account_id`, so `dispatch_message` always took the global `set_user_default` branch and a `/agent <name>` selection in bot-A leaked to bot-B for the same platform user. Now stamps `metadata["account_id"]` from the adapter name via `entry().or_insert_with(...)`, preserving an adapter-supplied `account_id` (dingtalk / email / google_chat). Both sides key off `SidecarChannelConfig::name`, so the registration key and the resolution key line up. Regression tests (no live daemon): - `router::tests::sidecar_default_does_not_collide_across_bots` — two sidecars with distinct names register under distinct `telegram:<name>` keys and resolve independently, no last-writer-wins collision. - `sidecar::tests::test_sidecar_stamps_account_id_from_adapter_name` — a real sidecar subprocess stamps `account_id` from the config name (not the `ready`-event account) and preserves an adapter-supplied one. Refs #5955
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #5672 — channel-side
/commandsnow filter byaccount_id, so a multi-bot Telegram (or any other channel sidecar) deployment no longer routes every command to the first-registered agent, and/agentno longer leaks the user's selection across bots.The non-command message dispatch path already routed correctly because it built a
BindingContextand calledAgentRouter::resolve_with_context(the account-qualified resolver added in #2183 / #4861). The regression was strictly in the command arms (and in themodel:interactive-button callback indispatch_message) — every one of them called the context-lessAgentRouter::resolve(channel_type, platform_id, user_key), which has noaccount_idparameter and only probes the plain"telegram"channel key. With per-bot defaults stored under qualified"telegram:<account_id>"keys, the lookup fell through to the system default — which the first-registered telegram bot also set — so/model,/new,/reboot,/compact,/stop,/usage,/think,/btw(and themodel:button) inbot-b/bot-calways pickedbot-a's agent.Layer B (
/agent-leak):/agent <name>wrote the user's selection viarouter.set_user_default(sender.platform_id, agent_id), keyed byplatform_idonly — no channel scope. The same lookup is then probed at step 2 of every resolver before the per-channel default, so a/agent agent-Cissued inbot-asilently overrode every other bot'sdefault_agentfor that user, and stayed leaked until daemon restart (user_defaultsis in-memoryDashMap, never persisted).Fix
Single architectural change applied in two places:
AgentRouter.user_defaultsis now keyed by(Option<channel_account_key>, user_key):Some("<channel>:<account_id>")— per-bot scope, set byset_user_default_for_channel(...)(new).None— channel-agnostic global scope, the legacy semantics relied on bytests/bridge_integration_test.rsandbenches/dispatch.rs.set_user_default(user_key, agent)keeps writing to this slot.resolve_with_contextprobes the per-channel-account entry first, falls back to global.handle_commandgains anaccount_id: Option<&str>parameter; the two dispatch-site callers indispatch_messagethreadmessage.metadata["account_id"]through. Inside, aresolve_for_commandclosure builds aBindingContextper call and routes throughresolve_with_context./agentwrites viaset_user_default_for_channelwhen anaccount_idis known and falls back to the legacy global write for CLI / single-bot channels. Themodel:interactive-button callback path indispatch_messageis migrated the same way.resolve_or_fallback's auto-bind on first-message is migrated identically — otherwise the fallback path would silently re-introduce the cross-bot leak.Tests
Five new regression tests pin the fix; all existing tests (incl.
test_multi_bot_account_id_routingfrom #2183) still pass:router::tests::user_default_does_not_leak_across_bots—/agent agent-Cscoped tobot-adoes NOT changebot-b's resolution for the same user.router::tests::channel_scoped_user_default_overrides_global— per-(channel,account) scope wins over global; different bot still sees only global.router::tests::legacy_set_user_default_is_channel_agnostic—set_user_default(user_key, agent)keeps channel-agnostic semantics so existing tests/benches stay valid.bridge::tests::command_resolution_respects_account_id— recordsset_model(agent_id, _)calls via a recording mock;/modelissued inbot-bthenbot-crecords[agent_b, agent_c]. Pre-fix this would be[agent_a, agent_a].bridge::tests::agent_command_does_not_leak_across_bots— asserts the post-/agentresolve_with_contextforbot-breturnsbot-b's default, notbot-a's override.Verification
cargo test -p librefang-channels --lib→ 465/465 passed (459 pre-existing + 6 new/regression).cargo test -p librefang-channels --tests→ all integration tests green.cargo clippy -p librefang-channels --all-targets -- -D warnings→ clean.cargo fmt -p librefang-channels --check→ clean.cargo check --workspace --lib→ clean.Files touched
crates/librefang-channels/src/router.rs— composite key +set_user_default_for_channel+resolve_with_contextuser-default probe order + 3 regression tests.crates/librefang-channels/src/bridge.rs—handle_commandsignature gainsaccount_id;resolve_for_commandclosure replaces everyrouter.resolve(...)command arm;/agentusesset_user_default_for_channelwhen scoped;dispatch_messagebutton-callback (model:) migrated;resolve_or_fallbackauto-bind migrated; 2 new regression tests + caller updates.CHANGELOG.md—[Unreleased] / Fixedentry.Related
AgentRoutertheBindingContextprobe order this PR re-uses./newwipes ALL agent sessions, not just the channel-derived one #4868 / fix(channels,kernel): scope/newto the calling channel + purge JSONL on delete (#4868) #4905 — scoped/new's session reset to the calling channel but left the agent resolution itself context-less; this PR closes the gap.Closes #5672