Skip to content

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

Description

@nevgenov

Symptom

Multi-instance sidecar setups (e.g. multiple [[sidecar_channels]] of channel_type = "telegram" for several bots) have two observable symptoms that share one root.

1. Cross-bot routing collision in channel_defaults

Every [[sidecar_channels]] block registers its default_agent under the same bare channel_defaults["<channel_type>"] key. The last sidecar to boot overwrites the previous ones and becomes the de-facto router target for every bot — defeating the purpose of per-block default_agent entries.

Daemon log on beta.16 with 6 Telegram bots (boot order in config):

bot-A default agent: agent-A (...) [channel: telegram]   ← bare key
bot-B default agent: agent-B (...) [channel: telegram]   ← overwrites A
bot-C default agent: agent-C (...) [channel: telegram]   ← overwrites B
bot-D default agent: agent-D (...) [channel: telegram]   ← overwrites C
bot-E default agent: agent-E (...) [channel: telegram]   ← FINAL — answers in every bot

This happens without any user action: every inbound message in bot-A / bot-B / etc. routes to the last-booted agent.

2. /agent cross-bot leak (regression of #5672 Layer B)

When a user runs /agent <name> in bot-A, the override leaks to every other bot for that user. #5688 added a per-bot path (set_user_default_for_channel keyed by "<channel_type>:<account_id>") but the path is dead code under any sidecar adapter because account_id never reaches message.metadata.

17:23  /agent agent-A   in bot-A → Now talking to: agent-A
17:24  → agent-A responds in bot-A ✓
18:07  /agent agent-B   in bot-B  
18:08  → "agent-A is still answering me 🙂" in bot-B   ❌ override stuck to user_id, not (user_id, bot)

Root cause — one wiring gap, two manifestations

Both symptoms stem from the daemon never propagating the operator-known SidecarChannelConfig::name as account_id.

Place 1: registration uses bare key

crates/librefang-api/src/channel_bridge.rs::start_channel_bridge_with_config

for sidecar_config in &sidecar_cfg.sidecar_channels {
    let adapter = Arc::new(SidecarAdapter::new(sidecar_config, ...));
    adapters.push((adapter, sidecar_config.default_agent.clone(), None));
    //                                                              ^^^^
    //                          hardcoded — sidecar_config.name not propagated
}

The downstream loop computes channel_key from this tuple:

let channel_key = match account_id {
    Some(aid) => format!("{channel_type}:{aid}"),  // qualified per-bot
    None      => channel_type.to_string(),          // BARE — last sidecar wins
};
router.set_channel_default_with_name(channel_key, agent_id, name);

With account_id always None, every sidecar collides on the bare key → Symptom 1.

Place 2: inbound metadata missing account_id

crates/librefang-channels/src/sidecar.rs (reader loop in spawn_once) injects into metadata: channel_id, platform, sender_username, group_members, group_participants. No account_id — even though the SDK's account_id_cell.get() already holds it from the ready event.

crates/librefang-channels/src/bridge.rs::dispatch_message (fix from #5688) routes /agent correctly only when account_id is in message.metadata:

match message.metadata.get("account_id").and_then(|v| v.as_str()) {
    Some(aid) => router.set_user_default_for_channel(...),  // per-bot (correct)
    None      => router.set_user_default(...),               // GLOBAL leak path
}

With nothing populating that metadata key for sidecar adapters, the Some(aid) arm is dead code → every /agent falls through to global set_user_default((None, peer_id)) → Symptom 2.

Why this didn't surface before sidecar migration

The pre-#5241 in-process Telegram adapter populated account_id from the per-bot config table. After the sidecar migration, the field moved into the adapter trait surface (SidecarAdapter::account_id()) which defaults to None. The Rust Telegram adapter from #5831 doesn't override it, and the daemon registration path doesn't fall back to sidecar_config.name — so the field stays None end-to-end.

Proposed fix (two-line wiring, daemon-side, no SDK change)

Both fixes key off SidecarChannelConfig::name — the operator-provided value already in TOML, identity-stable across supervised restarts.

  1. channel_bridge.rs::start_channel_bridge_with_config — pass Some(sidecar_config.name.clone()) instead of None.
  2. sidecar.rs reader loop — defensive metadata.entry("account_id").or_insert_with(|| ...adapter_name...) next to the other metadata.insert calls.

Local validation (see test patch in comment below) — cargo fmt --all --check clean, cargo clippy -p librefang-api -p librefang-channels --all-targets -- -D warnings clean, live-tested on a 6-bot setup: qualified keys appear in the boot log, every bot answers from its own default_agent, /agent X issued in bot-A no longer leaks to bot-B.

Setup (for reference)

  • v2026.5.31-beta.16 (Rust sidecar via PR feat(channels): ship librefang-sidecar-telegram binary in release tarballs #5937)
  • Multiple [[sidecar_channels]] entries with channel_type = "telegram", each with distinct name + default_agent
  • PUT /api/agents/{id}/channels with bot-name allowlist is rejected — the API expects channel type ("telegram"), not channel name — so per-agent allowlist can't currently scope to a single bot in multi-Telegram setups (separate concern).

Refs: #5672 (original Layer B), #5688 (the fix whose per-bot branch depends on account_id in metadata), #5831 (Rust sidecar adapter), #5241 (sidecar migration that moved account_id into trait surface).

Metadata

Metadata

Assignees

No one assigned

    Labels

    has-prA pull request has been linked to this issueneeds-triageAuto-applied when the issue title/body matched no area label — needs maintainer review

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions