Skip to content

feat(channels): port command-policy and message coalescing to sidecar channels#5931

Merged
houko merged 2 commits into
mainfrom
feat/sidecar-channels-command-policy
May 30, 2026
Merged

feat(channels): port command-policy and message coalescing to sidecar channels#5931
houko merged 2 commits into
mainfrom
feat/sidecar-channels-command-policy

Conversation

@houko

@houko houko commented May 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Ports two per-channel overrides that the in-process Telegram adapter had but the sidecar architecture dropped (#5241), so sidecar operators can replicate them again.

Closes #5841.

1. Command policy — command_policy on [[sidecar_channels]]

Re-exposes the in-process disable_commands / allowed_commands / blocked_commands gating (#2063) on sidecar channels:

[[sidecar_channels]]
name = "afina-sales-bot"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.telegram"]
channel_type = "telegram"
command_policy = "disable"      # allow (default) | disable | allowlist | blocklist
# allowed_commands = ["start"]  # used when command_policy = "allowlist"
# blocked_commands = ["new", "reboot", "agent"]  # used when command_policy = "blocklist"

A rejected command is forwarded to the agent as plain text, never answered with an "unknown command" error — preserving the #2063 behaviour public-facing / customer-service bots rely on.
A non-technical end user who types /new or /reboot no longer triggers a session reset.

2. Inbound coalescing — message_coalesce_window_ms

Re-exposes the inbound debounce window (#4441):

[[sidecar_channels]]
name = "afina-sales-bot"
message_coalesce_window_ms = 3000   # buffer inbound messages; dispatch once window expires

Messages from one sender arriving within the window are buffered and dispatched as a single batched context, so a user forwarding several messages in rapid succession produces one agent invocation instead of racing concurrent ones on the same session (the "Sorry, something went wrong" regression in the issue).

Implementation

Reuses the gating the bridge already has — the inbound is_command_allowed check (bridge.rs) and the MessageDebouncer.
The only gap was a config path to feed them for sidecars, since KernelBridgeAdapter::channel_overrides() returns None for sidecar channels.

  • SidecarChannelConfig gains command_policy: SidecarCommandPolicy, allowed_commands, blocked_commands, message_coalesce_window_ms — all #[serde(default)], backward-compatible.
  • SidecarAdapter builds a ChannelOverrides from its block (overrides_from_sidecar_config) and exposes it via a new ChannelAdapter::channel_overrides() default trait method (default None).
  • The bridge prefers that per-instance override over the kernel-level channel_overrides(channel_type, …) lookup at all three resolution sites (debouncer setup in start_adapter, dispatch_message, and the coalesced-media flush path). The kernel lookup is keyed only by channel_type and cannot distinguish two sidecars sharing a channel_type (e.g. two Telegram bots); a per-adapter override can.
  • command_policy maps onto the existing is_command_allowed boolean/list fields; message_coalesce_window_ms maps onto the debouncer's message_debounce_ms. Gating happens uniformly kernel-side, so no Python sidecar change is needed — the adapter still emits the command, the bridge forwards it as text when policy rejects it.

Tests

  • librefang-types (config/types.rs): serde defaults are backward-compatible (allow policy, coalescing off); disable / allowlist / blocklist blocks parse with their command lists.
  • librefang-channels (sidecar.rs): overrides_from_sidecar_config maps each policy variant and the coalesce window correctly, returns None for a plain config (so the kernel fallback still applies), and SidecarAdapter::channel_overrides() surfaces the built overrides.

Verification

Run in the sanctioned Docker dev image (Dockerfile.rust-dev, named volumes — host target/ untouched):

  • cargo test -p librefang-types -p librefang-channels --lib — 1333 pass (6 new in channels, 3 new in types)
  • cargo clippy -p librefang-types -p librefang-channels --all-targets -- -D warnings — clean
  • cargo fmt -p librefang-types -p librefang-channels -- --check — clean

Notes / deferred

  • The kernel-config golden fixture (kernel_config_schema.golden.json) was updated by hand to mirror the deterministic schemars output (new command_policy / allowed_commands / blocked_commands / message_coalesce_window_ms properties + the SidecarCommandPolicy definition). The automated regen test lives in librefang-api, whose build currently hits a pre-existing aarch64-Linux libc::SYS_getpgrp break in librefang-runtime (unrelated to this change; SYS_getpgrp is x86-only). The golden assertion kernel_config_schema_matches_golden_fixture is itself #[ignore]d on main for separate pre-existing drift, so CI does not gate on the fixture yet — but please re-run cargo test -p librefang-api --test config_schema_golden -- --ignored regenerate_golden --nocapture on an x86_64 host to confirm a byte-match if convenient.
  • No CHANGELOG entry added (no [Unreleased] section exists on main and the attribution guard expects (@user)); add one on merge.
  • No dashboard form for these fields — they are TOML-only, matching the issue's proposed config shape. Adding a dashboard surface would be a separate change in the dashboard domain.

… channels

Ports two per-channel overrides that the in-process Telegram adapter had
but the sidecar architecture dropped (#5241), so sidecar operators can
replicate them again (closes #5841).

disable_commands / allowed_commands / blocked_commands command policy is
re-exposed on `[[sidecar_channels]]` as `command_policy` (allow / disable
/ allowlist / blocklist) plus `allowed_commands` / `blocked_commands`.
A rejected command is forwarded to the agent as plain text, never
answered with an "unknown command" error — matching the #2063 behaviour
public-facing bots rely on.

message_coalesce_window_ms re-exposes the inbound debounce window (#4441):
messages from one sender arriving within the window are buffered and
dispatched as a single batched context, so a user forwarding several
messages in rapid succession produces one agent invocation instead of
racing concurrent ones on the same session.

Implementation reuses the gating the bridge already has. `SidecarAdapter`
builds a `ChannelOverrides` from its `[[sidecar_channels]]` block and
exposes it via a new `ChannelAdapter::channel_overrides()` default trait
method; the bridge prefers that per-instance override over the
kernel-level channel-type lookup, which cannot tell two sidecars sharing
a `channel_type` (e.g. two Telegram bots) apart. `command_policy` maps
onto the existing `is_command_allowed` boolean/list fields and
`message_coalesce_window_ms` onto the debouncer's `message_debounce_ms`,
so no Python sidecar change is needed — gating happens uniformly
kernel-side for every adapter type.

Tests:
- librefang-types: serde defaults are backward-compatible (allow /
  coalescing-off); disable / allowlist / blocklist parse.
- librefang-channels: `overrides_from_sidecar_config` maps each policy
  variant and the coalesce window correctly, returns None for a plain
  config, and the adapter surfaces the built overrides.

Verified in the sanctioned Docker dev image (named volumes, host target
untouched):
- cargo test -p librefang-types -p librefang-channels --lib (1333 pass)
- cargo clippy -p librefang-types -p librefang-channels --all-targets -- -D warnings (clean)
- cargo fmt -p librefang-types -p librefang-channels -- --check (clean)

The kernel-config golden fixture was updated by hand (the regen test
lives in librefang-api, whose build hits a pre-existing aarch64-Linux
`libc::SYS_getpgrp` break in librefang-runtime unrelated to this change;
the golden assertion is currently `#[ignore]`d on main for separate
pre-existing drift, so CI does not gate on it yet).
@github-actions github-actions Bot added area/channels Messaging channel adapters size/L 250-999 lines changed labels May 30, 2026
`command_policy = "allowlist"` is an explicit default-deny intent, but with an empty (omitted or `[]`) `allowed_commands` the sidecar produced `ChannelOverrides { allowed_commands: [] }`.
The bridge's `is_command_allowed` treats an empty allowlist as "no allowlist configured" and falls through to the empty-blocklist branch, which allows everything — so selecting allowlist silently became fail-open, the exact public-bot scenario the policy is meant to lock down.

Map an empty allowlist onto `disable_commands = true` (the highest-precedence deny `is_command_allowed` honours) so it denies all commands.
A non-empty allowlist still allows only its listed commands; `disable` and `blocklist` are unchanged.
An empty blocklist keeps its intuitive "block nothing, allow all" meaning but now logs a WARN since it usually signals a forgotten list.

Tests:
- sidecar unit test asserts allowlist + empty list yields `disable_commands == true`.
- new bridge integration test builds the override through the real `SidecarAdapter` and drives `/agent coder` through the live dispatch path, asserting the command is gated (forwarded to the agent as the plain text `/agent coder`, router unchanged) rather than honoured.

Verified via the repo Docker dev image: `cargo test -p librefang-channels` (479 lib + 28 integration + 4 conformance + 3 doctests, all pass) and `cargo clippy -p librefang-channels --all-targets -- -D warnings` clean.
@houko
houko merged commit 340faae into main May 30, 2026
28 checks passed
@houko
houko deleted the feat/sidecar-channels-command-policy branch May 30, 2026 23:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/channels Messaging channel adapters size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(channels/sidecar): port disable_commands command-policy and message_coalesce_window_ms to [[sidecar_channels]] config

1 participant