Skip to content

fix(channels): propagate per-sidecar account_id for multi-bot isolation (#5955)#5996

Merged
houko merged 2 commits into
mainfrom
fix/5955-sidecar-account-id
Jun 3, 2026
Merged

fix(channels): propagate per-sidecar account_id for multi-bot isolation (#5955)#5996
houko merged 2 commits into
mainfrom
fix/5955-sidecar-account-id

Conversation

@houko

@houko houko commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

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::name as account_id, so the per-bot isolation guards added in #5688 are dead code for sidecar adapters (the trait-default SidecarAdapter::account_id() is None, and neither the registration path nor the reader loop fell back to the config name).

Two-site root cause and fix

Site A — daemon registration hardcoded None

crates/librefang-api/src/channel_bridge.rs::start_channel_bridge_with_config pushed each sidecar adapter as (adapter, default_agent, None). With account_id = None, the downstream router-population loop keyed every Telegram sidecar's default_agent under the bare channel_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 claimed SidecarChannelConfig "carries no such field" — it has .name.

Site B — sidecar reader loop never stamped account_id

crates/librefang-channels/src/sidecar.rs (reader loop) stamps channel_id, platform, sender_username, group_members, group_participants into per-message metadata but never account_id. bridge.rs::dispatch_message (the #5688 fix) only takes the per-bot set_user_default_for_channel branch when metadata["account_id"] is present; otherwise it falls through to the global set_user_default, so a /agent override sticks to user_id alone and leaks across bots.

Fix: stamp metadata["account_id"] from the adapter (config) name via entry().or_insert_with(...), mirroring the bare-string-key style of the surrounding inserts. or_insert preserves an account_id an 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 stamps metadata["account_id"] = <name> so bridge.rs recomputes "telegram:" + metadata["account_id"] = the same key. The stamp deliberately keys off the config name, NOT the ready-event account_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 distinct names; asserts channel_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 #5688 test user_default_does_not_leak_across_bots does 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 on which_python(), mirroring test_sidecar_username_folds_to_sender_username_metadata); the child emits a ready carrying a different account_id than the config name, then two messages. Asserts the first message (no account_id) is stamped with the adapter name "bot-a" (not the ready account "ready-acct"), and the second (own account_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:

  • Site A: tuple element type unchanged (Option<String>); sidecar_config.name is a String.
  • Site B: metadata is HashMap<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.
  • Tests use only public APIs and the same imports as the adjacent existing tests.
  • Broad scan: no other 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}/channels expects 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

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
@github-actions github-actions Bot added size/M 50-249 lines changed area/docs Documentation and guides area/channels Messaging channel adapters labels Jun 3, 2026
@houko
houko enabled auto-merge (squash) June 3, 2026 07:12
@houko
houko merged commit c240776 into main Jun 3, 2026
31 checks passed
@houko
houko deleted the fix/5955-sidecar-account-id branch June 3, 2026 07:24
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 size/M 50-249 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Regression: /agent cross-bot leak returns in beta.16 — sidecar messages missing account_id in metadata

1 participant