fix(discord): extend channel-name matching to slash-command auth; clamp flush deadline to disconnect budget

Follow-up to the salvaged #8008 fix:
- Sibling-site fix: _evaluate_slash_authorization gated DISCORD_ALLOWED_CHANNELS /
  DISCORD_IGNORED_CHANNELS on numeric IDs only, so name/#name config that now works
  for on_message still silently failed for slash-command interactions. Refactor the
  channel-key helper to _discord_channel_keys_from_channel(channel, parent) and reuse
  it at the interaction gate. Fail-closed on missing channel id is preserved.
- The contributor's hardcoded 8s flush deadline could be hard-cancelled mid-flush:
  _teardown_adapter already wraps cancel_background_tasks() in the per-adapter
  disconnect budget (HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT, default 5s). The flush
  deadline now derives from that budget with headroom so it always completes inside it.
- AUTHOR_MAP: map cypher@augmentl.com -> Nickperillo for CI.
- Tests: slash-auth name/#name allow + name ignore matching.
This commit is contained in:
teknium1 2026-06-30 01:26:10 -07:00 committed by Teknium
parent cb9308f0a6
commit b6045170bb
3 changed files with 43 additions and 4 deletions

View file

@ -1331,9 +1331,13 @@ class DiscordAdapter(BasePlatformAdapter):
budget = parsed
except ValueError:
pass
# Leave ~20% headroom (min 0.5s) so the outer wait_for can't pre-empt our
# own straggler cancellation, and never go below 1s for the happy path.
return max(1.0, budget - max(0.5, budget * 0.2))
# Stay strictly below the budget so the gateway's outer wait_for can't
# pre-empt our own straggler cancellation. Reserve ~20% (min 0.5s) of
# headroom, and never let the floor push us back up to/over the budget
# on tiny budgets — cap at 90% of the budget as a hard ceiling.
headroom = max(0.5, budget * 0.2)
deadline = max(1.0, budget - headroom)
return min(deadline, budget * 0.9)
async def disconnect(self) -> None:
"""Disconnect from Discord."""

View file

@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"cypher@augmentl.com": "Nickperillo", # PR #8008 salvage (Discord channel-name matching + flush pending sends on shutdown)
"telos@apex-z.com": "telos-oc", # PR #14353 salvage (propagate custom_providers key_env into ProviderDef.api_key_env_vars; named + bare-custom self-heal paths)
"256073454+Kolektori@users.noreply.github.com": "Kolektori", # PR #6436 salvage (require approval for host-bound Docker commands; container guard fast-path)
"41764686+LIC99@users.noreply.github.com": "LIC99", # PR #4682 salvage (warn + default to manual on unknown approvals.mode; #4261)

View file

@ -128,13 +128,15 @@ _SENTINEL = object()
def _make_interaction(
user_id, *, channel_id=12345, guild_id=42, in_dm=False, in_thread=False,
parent_channel_id=None, user=_SENTINEL,
parent_channel_id=None, user=_SENTINEL, channel_name=None,
):
"""Build a mock Discord Interaction with a still-unresponded response.
``channel_id`` may be set to ``None`` to simulate a guild interaction
payload missing a resolvable channel id (fail-closed exercise).
Pass ``user=None`` to simulate a payload missing the user object.
``channel_name`` attaches a ``.name`` to the channel so channel-name /
``#name`` allow/ignore matching can be exercised (mirrors on_message).
"""
import discord
@ -146,10 +148,14 @@ def _make_interaction(
channel = discord.Thread()
channel.id = channel_id
channel.parent_id = parent_channel_id
if channel_name is not None:
channel.name = channel_name
elif channel_id is None:
channel = None
else:
channel = SimpleNamespace(id=channel_id)
if channel_name is not None:
channel.name = channel_name
if user is _SENTINEL:
user_obj = SimpleNamespace(id=int(user_id), name=f"user_{user_id}")
@ -275,6 +281,26 @@ async def test_channel_allowlist_wildcard_passes(adapter, monkeypatch):
assert await adapter._check_slash_authorization(interaction, "/help") is True
@pytest.mark.asyncio
async def test_channel_allowlist_matches_by_name(adapter, monkeypatch):
"""Allowlist configured by channel NAME matches slash interactions too —
the same name-form matching on_message gained. Without it, a deployment
using DISCORD_ALLOWED_CHANNELS=cypher (by name) would reject every slash
command even though messages in that channel pass.
"""
monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "cypher")
interaction = _make_interaction("100200300", channel_id=9999, channel_name="cypher")
assert await adapter._check_slash_authorization(interaction, "/help") is True
@pytest.mark.asyncio
async def test_channel_allowlist_matches_by_hash_name(adapter, monkeypatch):
"""``#name`` form in the allowlist also matches slash interactions."""
monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "#cypher")
interaction = _make_interaction("100200300", channel_id=9999, channel_name="cypher")
assert await adapter._check_slash_authorization(interaction, "/help") is True
@pytest.mark.asyncio
async def test_channel_allowlist_does_not_apply_to_dms(adapter, monkeypatch):
"""DMs aren't channel-gated — they go through on_message's DM lockdown."""
@ -304,6 +330,14 @@ async def test_ignored_channel_wildcard_blocks_all(adapter, monkeypatch):
assert await adapter._check_slash_authorization(interaction, "/help") is False
@pytest.mark.asyncio
async def test_ignored_channel_matches_by_name(adapter, monkeypatch):
"""Ignore list configured by channel NAME blocks slash interactions too."""
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "cypher")
interaction = _make_interaction("100200300", channel_id=9999, channel_name="cypher")
assert await adapter._check_slash_authorization(interaction, "/help") is False
# ---------------------------------------------------------------------------
# Cross-platform admin notification
# ---------------------------------------------------------------------------