From 2acbdd1848b7d48e1471ce39fc296f12300824d4 Mon Sep 17 00:00:00 2001 From: Jake Present Date: Thu, 25 Jun 2026 10:20:22 -0400 Subject: [PATCH] fix(discord): include approval command in message content --- plugins/platforms/discord/adapter.py | 41 +++++++++-- .../test_discord_exec_approval_content.py | 68 +++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 tests/gateway/test_discord_exec_approval_content.py diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 4e466433619..027cc73949c 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -5453,15 +5453,44 @@ class DiscordAdapter(BasePlatformAdapter): if not channel: channel = await self._client.fetch_channel(int(target_id)) - # Discord embed description limit is 4096; show full command up to that - max_desc = 4088 - cmd_display = command if len(command) <= max_desc else command[: max_desc - 3] + "..." + # Keep the approval request self-contained in plain message content. + # Discord embeds can be invisible or visually separated from the + # component row on some clients (notably web/mobile), so the actual + # command and reason must be visible in the same content block as + # the approval buttons. + reason_budget = 300 + reason_display = str(description or "dangerous command") + if len(reason_display) > reason_budget: + reason_display = reason_display[: reason_budget - 15] + "... [truncated]" + + prompt_prefix = ( + "⚠️ **Command Approval Required**\n\n" + "Do you want Hermes to run this command?\n\n" + "**Requested command:**\n```bash\n" + ) + prompt_tail = f"\n```\n**Reason:** {reason_display}" + truncated_suffix = "\n... [truncated]" + command_budget = max(0, self.MAX_MESSAGE_LENGTH - len(prompt_prefix) - len(prompt_tail)) + content_cmd_display = str(command or "") + if len(content_cmd_display) > command_budget: + content_cmd_display = ( + content_cmd_display[: max(0, command_budget - len(truncated_suffix))] + + truncated_suffix + ) + content = f"{prompt_prefix}{content_cmd_display}{prompt_tail}" + + # Preserve the richer embed path and its larger description budget + # for clients where embeds render correctly. + max_embed_desc = 4088 + embed_cmd_display = str(command or "") + if len(embed_cmd_display) > max_embed_desc: + embed_cmd_display = embed_cmd_display[: max_embed_desc - 3] + "..." embed = discord.Embed( title="⚠️ Command Approval Required", - description=f"```\n{cmd_display}\n```", + description=f"```\n{embed_cmd_display}\n```", color=discord.Color.orange(), ) - embed.add_field(name="Reason", value=description, inline=False) + embed.add_field(name="Reason", value=reason_display, inline=False) require_admin, admin_user_ids = _resolve_exec_approval_admin_gate( getattr(self.config, "extra", None) @@ -5474,7 +5503,7 @@ class DiscordAdapter(BasePlatformAdapter): admin_user_ids=admin_user_ids, ) - msg = await channel.send(embed=embed, view=view) + msg = await channel.send(content=content, embed=embed, view=view) view._message = msg # store for on_timeout expiration editing return SendResult(success=True, message_id=str(msg.id)) diff --git a/tests/gateway/test_discord_exec_approval_content.py b/tests/gateway/test_discord_exec_approval_content.py new file mode 100644 index 00000000000..20a9e85a5b6 --- /dev/null +++ b/tests/gateway/test_discord_exec_approval_content.py @@ -0,0 +1,68 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway.config import PlatformConfig +from plugins.platforms.discord.adapter import DiscordAdapter + + +def _capture_channel(adapter): + sent = {} + + async def fake_send(**kwargs): + sent.update(kwargs) + return SimpleNamespace(id=1234) + + channel = SimpleNamespace(send=AsyncMock(side_effect=fake_send)) + adapter._client = SimpleNamespace( + get_channel=lambda _chat_id: channel, + fetch_channel=AsyncMock(), + ) + return sent + + +@pytest.mark.asyncio +async def test_exec_approval_prompt_uses_visible_content_with_command_and_reason(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + command = "python scripts/deploy.py --env prod --force" + result = await adapter.send_exec_approval( + chat_id="555", + command=command, + session_key="discord:555", + description="script execution via -c flag", + ) + + assert result.success is True + assert sent["view"] is not None + assert sent["embed"] is not None + + prompt_text = sent["content"] + assert "Command Approval Required" in prompt_text + assert "Do you want Hermes to run this command?" in prompt_text + assert "Requested command" in prompt_text + assert command in prompt_text + assert "Reason" in prompt_text + assert "script execution via -c flag" in prompt_text + + +@pytest.mark.asyncio +async def test_exec_approval_prompt_truncates_long_command_in_content(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + sent = _capture_channel(adapter) + + long_command = "python -c '" + ("x" * 5000) + "'" + result = await adapter.send_exec_approval( + chat_id="555", + command=long_command, + session_key="discord:555", + description="long generated shell command", + ) + + assert result.success is True + assert len(sent["content"]) <= adapter.MAX_MESSAGE_LENGTH + assert "... [truncated]" in sent["content"] + assert "long generated shell command" in sent["content"] + assert len(sent["embed"].description) > len(sent["content"])