Skip to content

fix(channels): filter /commands dispatch by account_id (multi-bot isolation)#5688

Merged
houko merged 1 commit into
mainfrom
fix/channels-commands-account-id-filter
May 24, 2026
Merged

fix(channels): filter /commands dispatch by account_id (multi-bot isolation)#5688
houko merged 1 commit into
mainfrom
fix/channels-commands-account-id-filter

Conversation

@houko

@houko houko commented May 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #5672 — channel-side /commands now filter by account_id, so a multi-bot Telegram (or any other channel sidecar) deployment no longer routes every command to the first-registered agent, and /agent no longer leaks the user's selection across bots.

The non-command message dispatch path already routed correctly because it built a BindingContext and called AgentRouter::resolve_with_context (the account-qualified resolver added in #2183 / #4861). The regression was strictly in the command arms (and in the model: interactive-button callback in dispatch_message) — every one of them called the context-less AgentRouter::resolve(channel_type, platform_id, user_key), which has no account_id parameter 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 the model: button) in bot-b / bot-c always picked bot-a's agent.

Layer B (/agent-leak): /agent <name> wrote the user's selection via router.set_user_default(sender.platform_id, agent_id), keyed by platform_id only — no channel scope. The same lookup is then probed at step 2 of every resolver before the per-channel default, so a /agent agent-C issued in bot-a silently overrode every other bot's default_agent for that user, and stayed leaked until daemon restart (user_defaults is in-memory DashMap, never persisted).

Fix

Single architectural change applied in two places:

  1. AgentRouter.user_defaults is now keyed by (Option<channel_account_key>, user_key):
    • Some("<channel>:<account_id>") — per-bot scope, set by set_user_default_for_channel(...) (new).
    • None — channel-agnostic global scope, the legacy semantics relied on by tests/bridge_integration_test.rs and benches/dispatch.rs. set_user_default(user_key, agent) keeps writing to this slot.
    • resolve_with_context probes the per-channel-account entry first, falls back to global.
  2. handle_command gains an account_id: Option<&str> parameter; the two dispatch-site callers in dispatch_message thread message.metadata["account_id"] through. Inside, a resolve_for_command closure builds a BindingContext per call and routes through resolve_with_context. /agent writes via set_user_default_for_channel when an account_id is known and falls back to the legacy global write for CLI / single-bot channels. The model: interactive-button callback path in dispatch_message is migrated the same way.
  3. 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_routing from #2183) still pass:

  • router::tests::user_default_does_not_leak_across_bots/agent agent-C scoped to bot-a does NOT change bot-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_agnosticset_user_default(user_key, agent) keeps channel-agnostic semantics so existing tests/benches stay valid.
  • bridge::tests::command_resolution_respects_account_id — records set_model(agent_id, _) calls via a recording mock; /model issued in bot-b then bot-c records [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-/agent resolve_with_context for bot-b returns bot-b's default, not bot-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_context user-default probe order + 3 regression tests.
  • crates/librefang-channels/src/bridge.rshandle_command signature gains account_id; resolve_for_command closure replaces every router.resolve(...) command arm; /agent uses set_user_default_for_channel when scoped; dispatch_message button-callback (model:) migrated; resolve_or_fallback auto-bind migrated; 2 new regression tests + caller updates.
  • CHANGELOG.md[Unreleased] / Fixed entry.

Related

Closes #5672

@houko houko added the area/channels Messaging channel adapters label May 24, 2026
@houko
houko enabled auto-merge (squash) May 24, 2026 02:00
@github-actions github-actions Bot added area/docs Documentation and guides size/L 250-999 lines changed has-conflicts PR has merge conflicts that need resolution labels May 24, 2026
…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
houko force-pushed the fix/channels-commands-account-id-filter branch from 1731431 to d198a88 Compare May 24, 2026 03:13
@houko
houko merged commit e6de2cc into main May 24, 2026
14 of 15 checks passed
@houko
houko deleted the fix/channels-commands-account-id-filter branch May 24, 2026 03:13
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/channels Messaging channel adapters area/docs Documentation and guides ready-for-review PR is ready for maintainer review size/L 250-999 lines changed

Projects

None yet

1 participant