fix(channels): propagate per-sidecar account_id for multi-bot isolation (#5955)#5996
Merged
Conversation
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
houko
enabled auto-merge (squash)
June 3, 2026 07:12
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 the SECURITY regression in #5955: a
/agent <name>selection in one Telegram bot leaks to every other bot for the same platform user, and every multi-bot sidecar setup routes all traffic to the last-booted agent.Both symptoms share one root: the daemon never propagated the operator-known
SidecarChannelConfig::nameasaccount_id, so the per-bot isolation guards added in #5688 are dead code for sidecar adapters (the trait-defaultSidecarAdapter::account_id()isNone, and neither the registration path nor the reader loop fell back to the configname).Two-site root cause and fix
Site A — daemon registration hardcoded
Nonecrates/librefang-api/src/channel_bridge.rs::start_channel_bridge_with_configpushed each sidecar adapter as(adapter, default_agent, None). Withaccount_id = None, the downstream router-population loop keyed every Telegram sidecar'sdefault_agentunder the barechannel_defaults["telegram"]key (set_channel_default_with_name, last-writer-wins), so the last sidecar to boot became the de-facto router for every bot.Fix: pass
Some(sidecar_config.name.clone())so each sidecar registers under a distinct"telegram:<name>"key. Also corrected the stale comment that claimedSidecarChannelConfig"carries no such field" — it has.name.Site B — sidecar reader loop never stamped
account_idcrates/librefang-channels/src/sidecar.rs(reader loop) stampschannel_id,platform,sender_username,group_members,group_participantsinto per-messagemetadatabut neveraccount_id.bridge.rs::dispatch_message(the #5688 fix) only takes the per-botset_user_default_for_channelbranch whenmetadata["account_id"]is present; otherwise it falls through to the globalset_user_default, so a/agentoverride sticks touser_idalone and leaks across bots.Fix: stamp
metadata["account_id"]from the adapter (config) name viaentry().or_insert_with(...), mirroring the bare-string-key style of the surrounding inserts.or_insertpreserves anaccount_idan adapter already supplies in its message metadata (dingtalk / email / google_chat).Consistency
The registration key (Site A) and the resolution key (Site B) both derive from
SidecarChannelConfig::name: Site A registers under"telegram:<name>", and Site B stampsmetadata["account_id"] = <name>sobridge.rsrecomputes"telegram:" + metadata["account_id"]= the same key. The stamp deliberately keys off the config name, NOT theready-eventaccount_id, so the two sides line up.Regression tests (no live daemon)
crates/librefang-channels/src/router.rs::tests::sidecar_default_does_not_collide_across_bots— replicates the daemon's exact key-building for two sidecars with distinctnames; assertschannel_default("telegram:bot-a")and("telegram:bot-b")resolve independently, the per-bot names are preserved, and the bare"telegram"key is never shadowed. Targets the registration keying (Site A), which the existing#5688testuser_default_does_not_leak_across_botsdoes not cover (it passes"telegram:bot-a"keys directly to the resolver).crates/librefang-channels/src/sidecar.rs::tests::test_sidecar_stamps_account_id_from_adapter_name— spawns a real sidecar subprocess (gated onwhich_python(), mirroringtest_sidecar_username_folds_to_sender_username_metadata); the child emits areadycarrying a differentaccount_idthan the config name, then two messages. Asserts the first message (noaccount_id) is stamped with the adapter name"bot-a"(not thereadyaccount"ready-acct"), and the second (ownaccount_id = "own-acct") is preserved. Targets the metadata population (Site B).Verification
No native Rust toolchain on this host (and the dev Docker image is unavailable), so compilation/test verification is the CI gate:
Option<String>);sidecar_config.nameis aString.metadataisHashMap<String, serde_json::Value>;entry(String).or_insert_with(|| serde_json::Value::String(adapter_name.clone()));adapter_name(ctx.name.clone()) is used by-ref afterwards, so the clone does not move it.adapters.push(... None)site, and no API integration test asserts the old bare-key behaviour. No config-struct change, so the kernel-config golden fixture is untouched.Scope
One PR ↔ one issue. The reporter also flagged that
PUT /api/agents/{id}/channelsexpects channel type not channel name (so per-agent allowlist can't scope to a single bot) — that is a separate concern in a different route domain and is intentionally not bundled here.Closes #5955