From b9f65672f863d603cde2d5c8926823a886ddbc67 Mon Sep 17 00:00:00 2001 From: alex107ivanov <30668368+alex107ivanov@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:14:33 -0700 Subject: [PATCH 1/3] feat(discord): optionally mention approval owners on exec prompts Opt-in discord.approval_mentions (config.yaml, bridged to DISCORD_APPROVAL_MENTIONS) prepends <@id> mentions for numeric allowlist entries to exec-approval prompts, with a scoped AllowedMentions override (users only). Default off - no surprise pings. Reapplied onto the content-mirror layout from #60245: mentions prepend to the visible content block and its truncation budget. Original implementation from PR #39719; commits arrived bot-authored, re-attributed to the contributor. --- cli-config.yaml.example | 236 ++---------------- hermes_cli/config.py | 5 + plugins/platforms/discord/adapter.py | 42 +++- .../gateway/test_discord_approval_mentions.py | 87 +++++++ 4 files changed, 148 insertions(+), 222 deletions(-) create mode 100644 tests/gateway/test_discord_approval_mentions.py diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 5e4bc2331771e..33de62259067f 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -13,7 +13,7 @@ model: # Inference provider selection: # "auto" - Auto-detect from credentials (default) # "openrouter" - OpenRouter (requires: OPENROUTER_API_KEY or OPENAI_API_KEY) - # "nous" - Nous Portal OAuth (requires: hermes auth add nous) + # "nous" - Nous Portal OAuth (requires: hermes login) # "nous-api" - Nous Portal API key (requires: NOUS_API_KEY) # "anthropic" - Direct Anthropic API (requires: ANTHROPIC_API_KEY) # "openai-codex" - OpenAI Codex (requires: hermes auth) @@ -85,25 +85,6 @@ model: # # default_headers: # User-Agent: "curl/8.7.1" - # - # extra_headers: accepted as an alias of default_headers (merged, with - # extra_headers winning when both are set) — matches the per-provider - # extra_headers key below. - # - # Per-provider variant: named providers / custom_providers entries accept an - # extra_headers dict scoped to that endpoint only — for reverse proxies, - # gateways, or custom auth (e.g. Cloudflare Access service tokens). - # Merged onto SDK/provider defaults with the entry's values winning. - # Header values are treated as secrets and are never logged. - # - # providers: - # my-proxy: - # base_url: "https://llm.internal.example.com/v1" - # key_env: "MY_PROXY_API_KEY" - # extra_headers: - # CF-Access-Client-Id: "xxxx.access" - # CF-Access-Client-Secret: "${CF_ACCESS_SECRET}" - # X-Client-Name: "hermes-agent" # Named provider overrides (optional) # Use this for per-provider request timeouts, non-stream stale timeouts, @@ -117,9 +98,7 @@ model: # ``stale_timeout_seconds`` controls the non-streaming stale-call detector and # wins over the legacy HERMES_API_CALL_STALE_TIMEOUT env var. Leaving these # unset keeps the legacy defaults (HERMES_API_TIMEOUT=1800s, -# HERMES_API_CALL_STALE_TIMEOUT=90s, native Anthropic 900s). The -# implicit non-stream stale detector is auto-disabled for local endpoints -# and can scale upward for very large contexts. +# HERMES_API_CALL_STALE_TIMEOUT=300s, native Anthropic 900s). # # Not currently wired for AWS Bedrock (bedrock_converse + AnthropicBedrock # SDK paths) — those use boto3 with its own timeout configuration. @@ -185,16 +164,6 @@ model: # # worktree: true # Always create a worktree when in a git repo # worktree: false # Default — only create when -w flag is passed -# -# By default a new worktree branches from the freshly-fetched remote tip -# (the current branch's upstream, else the remote's default branch) so it -# starts current with the project instead of from the local clone's -# (possibly stale) HEAD. Set worktree_sync: false to branch from local HEAD -# instead — useful when offline or when you deliberately want the clone's -# exact current state as the base. -# -# worktree_sync: true # Default — branch from the fetched remote tip -# worktree_sync: false # Branch from local HEAD (offline / pinned base) # ============================================================================= # Terminal Tool Configuration @@ -213,11 +182,6 @@ terminal: backend: "local" cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise. timeout: 180 - # HOME policy for tool subprocesses: - # auto - default: host uses your real HOME; containers use HERMES_HOME/home - # real - force your real OS-user HOME - # profile - force HERMES_HOME/home for strict per-profile CLI config isolation - home_mode: "auto" docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace. lifetime_seconds: 300 # sudo_password: "hunter2" # Optional: pipe a sudo password via sudo -S. SECURITY WARNING: plaintext. @@ -410,11 +374,7 @@ compression: # Trigger compression at this % of model's context limit (default: 0.50 = 50%) # Lower values = more aggressive compression, higher values = compress later threshold: 0.50 - - # Existing Codex gpt-5.5 behavior: raise Hermes' compaction trigger to 85% - # for the ChatGPT Codex OAuth route. Set false to opt back down to threshold. - codex_gpt55_autoraise: true - + # Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%) # e.g. 20% of 50% threshold = 10% of total context kept as recent messages. # Summary output is separately capped at 12K tokens (Gemini output limit). @@ -426,14 +386,6 @@ compression: # compression of older turns. protect_last_n: 20 - # Codex app-server (codex CLI runtime) thread-compaction mode. The codex - # agent owns the real thread context on this runtime, so Hermes' summarizer - # cannot shrink it — compaction goes through the app server instead. - # native = let Codex decide when to compact its own thread (default) - # hermes = let Hermes threshold trigger Codex thread/compact/start - # off = Hermes will not auto-trigger compaction; Codex may still compact natively - codex_app_server_auto: native - # Number of non-system messages to protect at the head of the transcript, in # ADDITION to the system prompt (which is always implicitly protected). # Head messages are NEVER summarized — they survive every compression @@ -482,7 +434,7 @@ prompt_caching: # Provider options: # "auto" - Best available: OpenRouter → Nous Portal → main endpoint (default) # "openrouter" - Force OpenRouter (requires OPENROUTER_API_KEY) -# "nous" - Force Nous Portal (requires: hermes auth add nous) +# "nous" - Force Nous Portal (requires: hermes login) # "gemini" - Force Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY) # "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY) # "codex" - Force Codex OAuth (requires: hermes model → Codex). @@ -526,10 +478,6 @@ prompt_caching: # # reasoning controls: # # extra_body: # # enable_thinking: false -# # Some vLLM/Qwen deployments expect this nested: -# # extra_body: -# # chat_template_kwargs: -# # enable_thinking: false # ============================================================================= # Persistent Memory @@ -602,41 +550,6 @@ max_concurrent_sessions: null # explicitly want one shared "room brain" per group/channel. group_sessions_per_user: true -# ───────────────────────────────────────────────────────────────────────────── -# API Server — per-client model routing -# ───────────────────────────────────────────────────────────────────────────── -# Route different API clients to different models/providers on a single -# Hermes deployment. Clients choose a backend by sending a specific string -# as the OpenAI ``model`` field. Unmapped model values fall back to the -# global model configured in the ``model:`` section above, and an explicit -# session /model override always wins over a route. -# -# Configure via the ``platforms.api_server.extra.model_routes`` gateway -# config block: -# -# platforms: -# api_server: -# enabled: true -# extra: -# key: "your-api-server-secret" -# model_routes: -# # Xiaozhi clients send model="minimax-m2" → routed to MiniMax via OpenRouter -# minimax-m2: -# model: "minimax/minimax-m1" -# provider: "openrouter" # optional — overrides global provider -# # api_key: "sk-..." # optional — per-route UPSTREAM provider -# # key (NOT caller auth; never logged) -# # base_url: "https://..." # optional — per-route base URL -# # GPT clients keep their own alias -# gpt-5: -# model: "openai/gpt-5" -# provider: "openrouter" -# -# Configured aliases are automatically listed by GET /v1/models so clients -# can discover them without manual coordination. Caller authentication is -# unchanged: every request still authenticates with the global API server -# key (``extra.key`` / API_SERVER_KEY). - # ───────────────────────────────────────────────────────────────────────────── # Gateway Streaming # ───────────────────────────────────────────────────────────────────────────── @@ -692,10 +605,10 @@ agent: # gateway_timeout_warning: 900 # Graceful drain timeout for gateway stop/restart (seconds). - # Default 0 = no drain: a restart interrupts in-flight agents immediately, - # cleans up, and exits. Set a positive value only if you want a grace - # window on /restart, and keep it well under systemd's TimeoutStopSec. - # restart_drain_timeout: 0 + # The gateway stops accepting new work, waits for in-flight agents to + # finish, then interrupts anything still running after this timeout. + # 0 = no drain, interrupt immediately. + # restart_drain_timeout: 60 # Max app-level retry attempts for API errors (connection drops, provider # timeouts, 5xx, etc.) before the agent surfaces the failure. Lower this @@ -703,34 +616,7 @@ agent: # primaries (default 3). The OpenAI SDK does its own low-level retries # underneath this wrapper — this is the Hermes-level loop. # api_max_retries: 3 - - # After the agent edits code without fresh passing verification, nudge it to - # verify before finishing. The default "auto" enables it on interactive - # coding surfaces (CLI, TUI, desktop) and programmatic callers, and disables - # it on conversational messaging surfaces (Telegram, Discord, etc.) where the - # verification summary would reach a human as chat noise. Set true or false to - # force it on or off; the HERMES_VERIFY_ON_STOP env var (1/0) takes precedence. - # verify_on_stop: auto - - # Standing operator instructions for the coding posture (when Hermes is in a - # code workspace). Appended to the coding brief as an extra system block, so - # you can pin project-wide workflow rules without editing the shipped brief. - # Accepts a string or a list of strings. Takes effect next session. - # coding_instructions: - # - "For UI work, don't run tsc/lint until I approve the look." - # - "Clean the diff before you commit and push." - - # When verify-on-stop finds edited code without fresh verification evidence, - # append guidance for creative UI work (avoid broad tsc/lint/test before visual - # approval) and clean-diff expectations. Set false to keep that nudge terse. - # verify_guidance: true - - # A `pre_verify` hook (plugin or shell, see Event Hooks docs) can keep the - # agent going one more turn to verify/clean before finishing. This caps how - # many times one turn may be nudged to continue, so a hook can't trap the loop. - # Default 3. - # max_verify_nudges: 3 - + # Enable verbose logging verbose: false @@ -833,19 +719,6 @@ platform_toolsets: # # allowed_chats: ["-1001234567890"] # extra: # disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages -# rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default false for copyable legacy MarkdownV2, set true to opt in -# rich_drafts: false # Experimental rich draft previews during Telegram DM streaming; default false because Telegram Desktop/macOS can visually overlay draft frames -# command_menu: -# # Telegram allows up to 100 BotCommands; Hermes defaults to 60 so -# # all built-in commands plus common skill commands stay visible -# # while remaining under Telegram's payload-size limit. Clamped 1..100. -# max_commands: 60 -# # prepend = user priority first, then Hermes defaults -# # append = Hermes defaults first, then user priority -# # replace = only the list below defines priority -# priority_mode: prepend -# priority: -# - my_plugin_command # # Discord-specific settings (config.yaml top-level, not under platforms:): # @@ -856,6 +729,7 @@ platform_toolsets: # reactions: true # Show processing reactions (default: true) # history_backfill: true # Recover missed channel messages on mention (default: true) # history_backfill_limit: 50 # Max messages to scan backwards (default: 50) +# approval_mentions: false # Mention numeric allowed users on approval prompts (default: false) # ───────────────────────────────────────────────────────────────────────────── # Available toolsets (use these names in platform_toolsets or the toolsets list) @@ -876,6 +750,7 @@ platform_toolsets: # image_gen - image_generate (requires FAL_KEY) # skills - skills_list, skill_view # skills_hub - skill_hub (search/install/manage from online registries — user-driven only) +# moa - mixture_of_agents (requires OPENROUTER_API_KEY) # todo - todo (in-memory task planning, no deps) # tts - text_to_speech (Edge TTS free, or ELEVENLABS/OPENAI/MINIMAX/MISTRAL key) # cronjob - cronjob (create/list/update/pause/resume/run/remove scheduled tasks) @@ -890,7 +765,7 @@ platform_toolsets: # # COMPOSITE: # debugging - terminal + web + file -# safe - web + vision (no terminal access) +# safe - web + vision + moa (no terminal access) # all - Everything available # # web - Web search and content extraction (web_search, web_extract) @@ -901,6 +776,7 @@ platform_toolsets: # vision - Image analysis (vision_analyze) # image_gen - Image generation with FLUX (image_generate) # skills - Load skill documents (skills_list, skill_view) +# moa - Mixture of Agents reasoning (mixture_of_agents) # todo - Task planning and tracking for multi-step work # memory - Persistent memory across sessions (personal notes + user profile) # session_search - Search and recall past conversations (FTS5 + Gemini Flash summarization) @@ -909,7 +785,7 @@ platform_toolsets: # # Composite toolsets: # debugging - terminal + web + file (for troubleshooting) -# safe - web + vision (no terminal access) +# safe - web + vision + moa (no terminal access) # NOTE: The top-level "toolsets" key is deprecated and ignored. # Tool configuration is managed per-platform via platform_toolsets above. @@ -922,7 +798,7 @@ platform_toolsets: # ============================================================================= # Connect to external MCP servers to add tools from the MCP ecosystem. # Each server's tools are automatically discovered and registered. -# See website/docs/user-guide/features/mcp.md for full documentation. +# See docs/mcp.md for full documentation. # # Stdio servers (spawn a subprocess): # command: the executable to run @@ -936,10 +812,6 @@ platform_toolsets: # Optional per-server settings: # timeout: tool call timeout in seconds (default: 120) # connect_timeout: initial connection timeout (default: 60) -# keepalive_interval: liveness ping cadence in seconds (default: 180). -# Lower it below the server's session TTL for servers that expire idle -# sessions quickly (e.g. Unreal Engine editor MCP, ~15s), otherwise idle -# tool calls hit an expired session and pay a slow reconnect. Floored at 5s. # # mcp_servers: # time: @@ -1088,7 +960,6 @@ display: # new: Show a tool indicator only when the tool changes (skip repeats) # all: Show every tool call with a short preview (default) # verbose: Full args, results, and debug logs (same as /verbose) - # log: Silent in chat; append every tool call to ~/.hermes/logs/tool_calls.log (gateway only) # Toggle at runtime with /verbose in the CLI tool_progress: all @@ -1362,80 +1233,3 @@ updates: # # default — works on Fly.io out of the box). # # # # public_url: "https://example.com/hermes" -# -# ----------------------------------------------------------------------------- -# Self-hosted OIDC dashboard auth (generic OpenID Connect — Authentik, -# Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …). Use this INSTEAD of the -# nous block above when gating the dashboard with your own identity provider. -# Each setting can be overridden by an environment variable: -# -# dashboard.oauth.self_hosted.issuer <- HERMES_DASHBOARD_OIDC_ISSUER -# dashboard.oauth.self_hosted.client_id <- HERMES_DASHBOARD_OIDC_CLIENT_ID -# dashboard.oauth.self_hosted.scopes <- HERMES_DASHBOARD_OIDC_SCOPES -# dashboard.oauth.self_hosted.client_secret <- HERMES_DASHBOARD_OIDC_CLIENT_SECRET -# -# dashboard: -# oauth: -# provider: self-hosted -# self_hosted: -# issuer: "https://auth.example.com/application/o/hermes/" # required -# client_id: "hermes-dashboard" # required -# scopes: "openid profile email" # optional -# -# # OPTIONAL — set ONLY if your IDP registered the client as -# # *confidential* (Authentik / Keycloak often default to this). When -# # set, Hermes authenticates the client at the token endpoint -# # (client_secret_basic or client_secret_post, auto-selected from the -# # IDP's discovery doc) IN ADDITION to PKCE. Leave unset for a public -# # (PKCE-only) client — the common case. -# # -# # This is a CREDENTIAL: prefer setting HERMES_DASHBOARD_OIDC_CLIENT_SECRET -# # in ~/.hermes/.env over putting it here in config.yaml. -# # client_secret: "" - -# ============================================================================= -# External secret sources -# ============================================================================= -# Pull provider credentials from external secret managers at process startup -# instead of storing them in ~/.hermes/.env. Only the manager's bootstrap -# credential (e.g. BWS_ACCESS_TOKEN / OP_SERVICE_ACCOUNT_TOKEN) lives in .env -# (or your shell / desktop session); everything else rotates centrally. -# Failures never block startup — Hermes warns once and continues with -# whatever .env already had. -# -# Multiple sources can be enabled at once: -# - "mapped" sources (explicit VAR -> ref bindings, e.g. 1Password's env: -# map) beat "bulk" sources (whole-project dumps like Bitwarden BSM) -# - within a shape, the first source to claim a var wins; later claims -# are skipped with a startup warning (never a silent clobber) -# - a source's override_existing lets it beat .env/shell values, but -# never another secret source's claim -# Docs: https://hermes-agent.nousresearch.com/docs/user-guide/secrets/ -# -# secrets: -# # Optional explicit ordering of enabled sources. -# # sources: [onepassword, bitwarden] -# -# # ---- Bitwarden Secrets Manager (bws CLI) -------------------------------- -# bitwarden: -# enabled: false -# access_token_env: BWS_ACCESS_TOKEN # bootstrap token, sourced from .env -# project_id: "" # UUID of the BSM project to sync -# server_url: "" # "" = US Cloud; EU/self-hosted URL otherwise -# cache_ttl_seconds: 300 # 0 disables caching -# override_existing: true # BSM values win over existing env -# auto_install: true # lazy-download bws into ~/.hermes/bin -# -# # ---- 1Password (op CLI) ------------------------------------------------- -# onepassword: -# enabled: false -# # Map env-var names to op:// secret references. Each is resolved with a -# # single `op read` at startup. -# env: -# OPENAI_API_KEY: "op://Private/OpenAI/api key" -# ANTHROPIC_API_KEY: "op://Private/Anthropic/credential" -# account: "" # op --account shorthand; "" = default -# service_account_token_env: OP_SERVICE_ACCOUNT_TOKEN # headless auth; unset = desktop session -# binary_path: "" # "" = resolve op via PATH; else absolute path -# cache_ttl_seconds: 300 # 0 disables BOTH cache layers -# override_existing: true # resolved values win over existing env diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7f2945de5f80c..184350ae3a47a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2384,6 +2384,11 @@ def _ensure_hermes_home_managed(home: Path): # real memory cost. Default 32 MiB matches the historical hardcoded # cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES. "max_attachment_bytes": 33554432, + # When True, Discord approval prompts mention numeric allowed users so + # owners notice approval requests in shared channels/threads. Env + # override: DISCORD_APPROVAL_MENTIONS. Default false avoids surprise + # pings. + "approval_mentions": False, # Voice-channel audio effects (the continuous mixer). OFF by default. # When enabled, the bot installs a software mixer on the outgoing voice # stream so a low ambient "thinking" bed, verbal acknowledgements, and diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 47e93a2836cc6..8a83f8805ed9a 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -754,6 +754,13 @@ def _read_dm_role_auth_guild() -> Optional[int]: _DISCORD_PROMPT_TIMEOUT_MAX = 900 +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name, "").strip().lower() + if not raw: + return default + return raw in {"true", "1", "yes", "on"} + + def _read_discord_prompt_timeout() -> int: """Return the timeout (in seconds) for Discord button views. @@ -5502,6 +5509,20 @@ def _self_contained_prompt_content( body = body[: max(0, budget - len(truncated_suffix))] + truncated_suffix return f"{prefix}{body}{suffix}" + def _approval_mention_content(self) -> Optional[str]: + """Return user mentions for approval prompts when explicitly enabled. + + Gated on ``discord.approval_mentions`` in config.yaml (bridged to the + ``DISCORD_APPROVAL_MENTIONS`` env var). Only numeric allowlist entries + can be mentioned; default off avoids surprise pings. + """ + if not _env_bool("DISCORD_APPROVAL_MENTIONS", False): + return None + user_ids = sorted(uid for uid in self._allowed_user_ids if str(uid).isdigit()) + if not user_ids: + return None + return " ".join(f"<@{uid}>" for uid in user_ids) + async def send_exec_approval( self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", @@ -5541,6 +5562,9 @@ async def send_exec_approval( "Do you want Hermes to run this command?\n\n" "**Requested command:**\n```bash\n" ) + mention_content = self._approval_mention_content() + if mention_content: + prompt_prefix = f"{mention_content}\n{prompt_prefix}" 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)) @@ -5576,7 +5600,17 @@ async def send_exec_approval( admin_user_ids=admin_user_ids, ) - msg = await channel.send(content=content, embed=embed, view=view) + send_kwargs: Dict[str, Any] = {"content": content, "embed": embed, "view": view} + if mention_content: + allowed_mentions_cls = getattr(discord, "AllowedMentions", None) + if allowed_mentions_cls is not None: + send_kwargs["allowed_mentions"] = allowed_mentions_cls( + users=True, + roles=False, + everyone=False, + replied_user=False, + ) + msg = await channel.send(**send_kwargs) view._message = msg # store for on_timeout expiration editing return SendResult(success=True, message_id=str(msg.id)) @@ -8161,6 +8195,12 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: if isinstance(allowed_users_cfg, list): allowed_users_cfg = ",".join(str(v) for v in allowed_users_cfg) os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_cfg) + approval_mentions_cfg = ( + discord_cfg["approval_mentions"] if "approval_mentions" in discord_cfg + else platform_extra_cfg.get("approval_mentions") + ) + if approval_mentions_cfg is not None and not os.getenv("DISCORD_APPROVAL_MENTIONS"): + os.environ["DISCORD_APPROVAL_MENTIONS"] = str(approval_mentions_cfg).lower() frc = discord_cfg.get("free_response_channels") if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): if isinstance(frc, list): diff --git a/tests/gateway/test_discord_approval_mentions.py b/tests/gateway/test_discord_approval_mentions.py new file mode 100644 index 0000000000000..786ecace34294 --- /dev/null +++ b/tests/gateway/test_discord_approval_mentions.py @@ -0,0 +1,87 @@ +"""Discord approval prompts can opt into owner mentions.""" + +import os +from types import SimpleNamespace + +import pytest + +from plugins.platforms.discord.adapter import ( + DiscordAdapter, + _apply_yaml_config, +) + + +class _FakeChannel: + def __init__(self): + self.sent_kwargs = None + + async def send(self, **kwargs): + self.sent_kwargs = kwargs + return SimpleNamespace(id=12345) + + +class _FakeClient: + def __init__(self, channel): + self.channel = channel + + def get_channel(self, channel_id): + return self.channel + + +@pytest.mark.asyncio +async def test_exec_approval_mentions_allowed_users_when_enabled(monkeypatch): + monkeypatch.setenv("DISCORD_APPROVAL_MENTIONS", "true") + channel = _FakeChannel() + adapter = object.__new__(DiscordAdapter) + adapter._client = _FakeClient(channel) + adapter._allowed_user_ids = {"222", "111", "alice"} + adapter._allowed_role_ids = set() + adapter.config = SimpleNamespace(extra=None) + + result = await adapter.send_exec_approval( + chat_id="99", + command="make check", + session_key="session-1", + description="dangerous command", + ) + + assert result.success is True + # Mentions are prepended to the (always present) content mirror. + assert channel.sent_kwargs["content"].startswith("<@111> <@222>\n") + assert "make check" in channel.sent_kwargs["content"] + assert "allowed_mentions" in channel.sent_kwargs + assert channel.sent_kwargs["embed"].title.endswith("Command Approval Required") + + +@pytest.mark.asyncio +async def test_exec_approval_does_not_mention_by_default(monkeypatch): + monkeypatch.delenv("DISCORD_APPROVAL_MENTIONS", raising=False) + channel = _FakeChannel() + adapter = object.__new__(DiscordAdapter) + adapter._client = _FakeClient(channel) + adapter._allowed_user_ids = {"111"} + adapter._allowed_role_ids = set() + adapter.config = SimpleNamespace(extra=None) + + result = await adapter.send_exec_approval( + chat_id="99", + command="make check", + session_key="session-1", + ) + + assert result.success is True + # Content mirror is always present (embed-invisibility fix), but no + # mention markup and no allowed_mentions override. + assert "<@" not in channel.sent_kwargs["content"] + assert "allowed_mentions" not in channel.sent_kwargs + + +def test_yaml_config_bridges_approval_mentions_to_env(monkeypatch): + monkeypatch.delenv("DISCORD_APPROVAL_MENTIONS", raising=False) + + _apply_yaml_config( + {"discord": {"approval_mentions": True}}, + {"approval_mentions": True}, + ) + + assert os.environ["DISCORD_APPROVAL_MENTIONS"] == "true" From 15bcd442a41332a9f5877501666fba976332452d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:14:50 -0700 Subject: [PATCH 2/3] chore: add alex107ivanov to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 65001e564fb41..8e19363706a1a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -291,6 +291,7 @@ "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "30668368+alex107ivanov@users.noreply.github.com": "alex107ivanov", "210088133+rungmc357@users.noreply.github.com": "rungmc357", "florian.rutishauser@outlook.com": "flo1t", "fanyang@microsoft.com": "fanyangCS", From 25d2597c27be0c29318c429a1d73ce5684267234 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:17:57 -0700 Subject: [PATCH 3/3] fix: restore cli-config.yaml.example from main (stale-branch version leaked into salvage) --- cli-config.yaml.example | 236 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 221 insertions(+), 15 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 33de62259067f..5e4bc2331771e 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -13,7 +13,7 @@ model: # Inference provider selection: # "auto" - Auto-detect from credentials (default) # "openrouter" - OpenRouter (requires: OPENROUTER_API_KEY or OPENAI_API_KEY) - # "nous" - Nous Portal OAuth (requires: hermes login) + # "nous" - Nous Portal OAuth (requires: hermes auth add nous) # "nous-api" - Nous Portal API key (requires: NOUS_API_KEY) # "anthropic" - Direct Anthropic API (requires: ANTHROPIC_API_KEY) # "openai-codex" - OpenAI Codex (requires: hermes auth) @@ -85,6 +85,25 @@ model: # # default_headers: # User-Agent: "curl/8.7.1" + # + # extra_headers: accepted as an alias of default_headers (merged, with + # extra_headers winning when both are set) — matches the per-provider + # extra_headers key below. + # + # Per-provider variant: named providers / custom_providers entries accept an + # extra_headers dict scoped to that endpoint only — for reverse proxies, + # gateways, or custom auth (e.g. Cloudflare Access service tokens). + # Merged onto SDK/provider defaults with the entry's values winning. + # Header values are treated as secrets and are never logged. + # + # providers: + # my-proxy: + # base_url: "https://llm.internal.example.com/v1" + # key_env: "MY_PROXY_API_KEY" + # extra_headers: + # CF-Access-Client-Id: "xxxx.access" + # CF-Access-Client-Secret: "${CF_ACCESS_SECRET}" + # X-Client-Name: "hermes-agent" # Named provider overrides (optional) # Use this for per-provider request timeouts, non-stream stale timeouts, @@ -98,7 +117,9 @@ model: # ``stale_timeout_seconds`` controls the non-streaming stale-call detector and # wins over the legacy HERMES_API_CALL_STALE_TIMEOUT env var. Leaving these # unset keeps the legacy defaults (HERMES_API_TIMEOUT=1800s, -# HERMES_API_CALL_STALE_TIMEOUT=300s, native Anthropic 900s). +# HERMES_API_CALL_STALE_TIMEOUT=90s, native Anthropic 900s). The +# implicit non-stream stale detector is auto-disabled for local endpoints +# and can scale upward for very large contexts. # # Not currently wired for AWS Bedrock (bedrock_converse + AnthropicBedrock # SDK paths) — those use boto3 with its own timeout configuration. @@ -164,6 +185,16 @@ model: # # worktree: true # Always create a worktree when in a git repo # worktree: false # Default — only create when -w flag is passed +# +# By default a new worktree branches from the freshly-fetched remote tip +# (the current branch's upstream, else the remote's default branch) so it +# starts current with the project instead of from the local clone's +# (possibly stale) HEAD. Set worktree_sync: false to branch from local HEAD +# instead — useful when offline or when you deliberately want the clone's +# exact current state as the base. +# +# worktree_sync: true # Default — branch from the fetched remote tip +# worktree_sync: false # Branch from local HEAD (offline / pinned base) # ============================================================================= # Terminal Tool Configuration @@ -182,6 +213,11 @@ terminal: backend: "local" cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise. timeout: 180 + # HOME policy for tool subprocesses: + # auto - default: host uses your real HOME; containers use HERMES_HOME/home + # real - force your real OS-user HOME + # profile - force HERMES_HOME/home for strict per-profile CLI config isolation + home_mode: "auto" docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace. lifetime_seconds: 300 # sudo_password: "hunter2" # Optional: pipe a sudo password via sudo -S. SECURITY WARNING: plaintext. @@ -374,7 +410,11 @@ compression: # Trigger compression at this % of model's context limit (default: 0.50 = 50%) # Lower values = more aggressive compression, higher values = compress later threshold: 0.50 - + + # Existing Codex gpt-5.5 behavior: raise Hermes' compaction trigger to 85% + # for the ChatGPT Codex OAuth route. Set false to opt back down to threshold. + codex_gpt55_autoraise: true + # Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%) # e.g. 20% of 50% threshold = 10% of total context kept as recent messages. # Summary output is separately capped at 12K tokens (Gemini output limit). @@ -386,6 +426,14 @@ compression: # compression of older turns. protect_last_n: 20 + # Codex app-server (codex CLI runtime) thread-compaction mode. The codex + # agent owns the real thread context on this runtime, so Hermes' summarizer + # cannot shrink it — compaction goes through the app server instead. + # native = let Codex decide when to compact its own thread (default) + # hermes = let Hermes threshold trigger Codex thread/compact/start + # off = Hermes will not auto-trigger compaction; Codex may still compact natively + codex_app_server_auto: native + # Number of non-system messages to protect at the head of the transcript, in # ADDITION to the system prompt (which is always implicitly protected). # Head messages are NEVER summarized — they survive every compression @@ -434,7 +482,7 @@ prompt_caching: # Provider options: # "auto" - Best available: OpenRouter → Nous Portal → main endpoint (default) # "openrouter" - Force OpenRouter (requires OPENROUTER_API_KEY) -# "nous" - Force Nous Portal (requires: hermes login) +# "nous" - Force Nous Portal (requires: hermes auth add nous) # "gemini" - Force Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY) # "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY) # "codex" - Force Codex OAuth (requires: hermes model → Codex). @@ -478,6 +526,10 @@ prompt_caching: # # reasoning controls: # # extra_body: # # enable_thinking: false +# # Some vLLM/Qwen deployments expect this nested: +# # extra_body: +# # chat_template_kwargs: +# # enable_thinking: false # ============================================================================= # Persistent Memory @@ -550,6 +602,41 @@ max_concurrent_sessions: null # explicitly want one shared "room brain" per group/channel. group_sessions_per_user: true +# ───────────────────────────────────────────────────────────────────────────── +# API Server — per-client model routing +# ───────────────────────────────────────────────────────────────────────────── +# Route different API clients to different models/providers on a single +# Hermes deployment. Clients choose a backend by sending a specific string +# as the OpenAI ``model`` field. Unmapped model values fall back to the +# global model configured in the ``model:`` section above, and an explicit +# session /model override always wins over a route. +# +# Configure via the ``platforms.api_server.extra.model_routes`` gateway +# config block: +# +# platforms: +# api_server: +# enabled: true +# extra: +# key: "your-api-server-secret" +# model_routes: +# # Xiaozhi clients send model="minimax-m2" → routed to MiniMax via OpenRouter +# minimax-m2: +# model: "minimax/minimax-m1" +# provider: "openrouter" # optional — overrides global provider +# # api_key: "sk-..." # optional — per-route UPSTREAM provider +# # key (NOT caller auth; never logged) +# # base_url: "https://..." # optional — per-route base URL +# # GPT clients keep their own alias +# gpt-5: +# model: "openai/gpt-5" +# provider: "openrouter" +# +# Configured aliases are automatically listed by GET /v1/models so clients +# can discover them without manual coordination. Caller authentication is +# unchanged: every request still authenticates with the global API server +# key (``extra.key`` / API_SERVER_KEY). + # ───────────────────────────────────────────────────────────────────────────── # Gateway Streaming # ───────────────────────────────────────────────────────────────────────────── @@ -605,10 +692,10 @@ agent: # gateway_timeout_warning: 900 # Graceful drain timeout for gateway stop/restart (seconds). - # The gateway stops accepting new work, waits for in-flight agents to - # finish, then interrupts anything still running after this timeout. - # 0 = no drain, interrupt immediately. - # restart_drain_timeout: 60 + # Default 0 = no drain: a restart interrupts in-flight agents immediately, + # cleans up, and exits. Set a positive value only if you want a grace + # window on /restart, and keep it well under systemd's TimeoutStopSec. + # restart_drain_timeout: 0 # Max app-level retry attempts for API errors (connection drops, provider # timeouts, 5xx, etc.) before the agent surfaces the failure. Lower this @@ -616,7 +703,34 @@ agent: # primaries (default 3). The OpenAI SDK does its own low-level retries # underneath this wrapper — this is the Hermes-level loop. # api_max_retries: 3 - + + # After the agent edits code without fresh passing verification, nudge it to + # verify before finishing. The default "auto" enables it on interactive + # coding surfaces (CLI, TUI, desktop) and programmatic callers, and disables + # it on conversational messaging surfaces (Telegram, Discord, etc.) where the + # verification summary would reach a human as chat noise. Set true or false to + # force it on or off; the HERMES_VERIFY_ON_STOP env var (1/0) takes precedence. + # verify_on_stop: auto + + # Standing operator instructions for the coding posture (when Hermes is in a + # code workspace). Appended to the coding brief as an extra system block, so + # you can pin project-wide workflow rules without editing the shipped brief. + # Accepts a string or a list of strings. Takes effect next session. + # coding_instructions: + # - "For UI work, don't run tsc/lint until I approve the look." + # - "Clean the diff before you commit and push." + + # When verify-on-stop finds edited code without fresh verification evidence, + # append guidance for creative UI work (avoid broad tsc/lint/test before visual + # approval) and clean-diff expectations. Set false to keep that nudge terse. + # verify_guidance: true + + # A `pre_verify` hook (plugin or shell, see Event Hooks docs) can keep the + # agent going one more turn to verify/clean before finishing. This caps how + # many times one turn may be nudged to continue, so a hook can't trap the loop. + # Default 3. + # max_verify_nudges: 3 + # Enable verbose logging verbose: false @@ -719,6 +833,19 @@ platform_toolsets: # # allowed_chats: ["-1001234567890"] # extra: # disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages +# rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default false for copyable legacy MarkdownV2, set true to opt in +# rich_drafts: false # Experimental rich draft previews during Telegram DM streaming; default false because Telegram Desktop/macOS can visually overlay draft frames +# command_menu: +# # Telegram allows up to 100 BotCommands; Hermes defaults to 60 so +# # all built-in commands plus common skill commands stay visible +# # while remaining under Telegram's payload-size limit. Clamped 1..100. +# max_commands: 60 +# # prepend = user priority first, then Hermes defaults +# # append = Hermes defaults first, then user priority +# # replace = only the list below defines priority +# priority_mode: prepend +# priority: +# - my_plugin_command # # Discord-specific settings (config.yaml top-level, not under platforms:): # @@ -729,7 +856,6 @@ platform_toolsets: # reactions: true # Show processing reactions (default: true) # history_backfill: true # Recover missed channel messages on mention (default: true) # history_backfill_limit: 50 # Max messages to scan backwards (default: 50) -# approval_mentions: false # Mention numeric allowed users on approval prompts (default: false) # ───────────────────────────────────────────────────────────────────────────── # Available toolsets (use these names in platform_toolsets or the toolsets list) @@ -750,7 +876,6 @@ platform_toolsets: # image_gen - image_generate (requires FAL_KEY) # skills - skills_list, skill_view # skills_hub - skill_hub (search/install/manage from online registries — user-driven only) -# moa - mixture_of_agents (requires OPENROUTER_API_KEY) # todo - todo (in-memory task planning, no deps) # tts - text_to_speech (Edge TTS free, or ELEVENLABS/OPENAI/MINIMAX/MISTRAL key) # cronjob - cronjob (create/list/update/pause/resume/run/remove scheduled tasks) @@ -765,7 +890,7 @@ platform_toolsets: # # COMPOSITE: # debugging - terminal + web + file -# safe - web + vision + moa (no terminal access) +# safe - web + vision (no terminal access) # all - Everything available # # web - Web search and content extraction (web_search, web_extract) @@ -776,7 +901,6 @@ platform_toolsets: # vision - Image analysis (vision_analyze) # image_gen - Image generation with FLUX (image_generate) # skills - Load skill documents (skills_list, skill_view) -# moa - Mixture of Agents reasoning (mixture_of_agents) # todo - Task planning and tracking for multi-step work # memory - Persistent memory across sessions (personal notes + user profile) # session_search - Search and recall past conversations (FTS5 + Gemini Flash summarization) @@ -785,7 +909,7 @@ platform_toolsets: # # Composite toolsets: # debugging - terminal + web + file (for troubleshooting) -# safe - web + vision + moa (no terminal access) +# safe - web + vision (no terminal access) # NOTE: The top-level "toolsets" key is deprecated and ignored. # Tool configuration is managed per-platform via platform_toolsets above. @@ -798,7 +922,7 @@ platform_toolsets: # ============================================================================= # Connect to external MCP servers to add tools from the MCP ecosystem. # Each server's tools are automatically discovered and registered. -# See docs/mcp.md for full documentation. +# See website/docs/user-guide/features/mcp.md for full documentation. # # Stdio servers (spawn a subprocess): # command: the executable to run @@ -812,6 +936,10 @@ platform_toolsets: # Optional per-server settings: # timeout: tool call timeout in seconds (default: 120) # connect_timeout: initial connection timeout (default: 60) +# keepalive_interval: liveness ping cadence in seconds (default: 180). +# Lower it below the server's session TTL for servers that expire idle +# sessions quickly (e.g. Unreal Engine editor MCP, ~15s), otherwise idle +# tool calls hit an expired session and pay a slow reconnect. Floored at 5s. # # mcp_servers: # time: @@ -960,6 +1088,7 @@ display: # new: Show a tool indicator only when the tool changes (skip repeats) # all: Show every tool call with a short preview (default) # verbose: Full args, results, and debug logs (same as /verbose) + # log: Silent in chat; append every tool call to ~/.hermes/logs/tool_calls.log (gateway only) # Toggle at runtime with /verbose in the CLI tool_progress: all @@ -1233,3 +1362,80 @@ updates: # # default — works on Fly.io out of the box). # # # # public_url: "https://example.com/hermes" +# +# ----------------------------------------------------------------------------- +# Self-hosted OIDC dashboard auth (generic OpenID Connect — Authentik, +# Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …). Use this INSTEAD of the +# nous block above when gating the dashboard with your own identity provider. +# Each setting can be overridden by an environment variable: +# +# dashboard.oauth.self_hosted.issuer <- HERMES_DASHBOARD_OIDC_ISSUER +# dashboard.oauth.self_hosted.client_id <- HERMES_DASHBOARD_OIDC_CLIENT_ID +# dashboard.oauth.self_hosted.scopes <- HERMES_DASHBOARD_OIDC_SCOPES +# dashboard.oauth.self_hosted.client_secret <- HERMES_DASHBOARD_OIDC_CLIENT_SECRET +# +# dashboard: +# oauth: +# provider: self-hosted +# self_hosted: +# issuer: "https://auth.example.com/application/o/hermes/" # required +# client_id: "hermes-dashboard" # required +# scopes: "openid profile email" # optional +# +# # OPTIONAL — set ONLY if your IDP registered the client as +# # *confidential* (Authentik / Keycloak often default to this). When +# # set, Hermes authenticates the client at the token endpoint +# # (client_secret_basic or client_secret_post, auto-selected from the +# # IDP's discovery doc) IN ADDITION to PKCE. Leave unset for a public +# # (PKCE-only) client — the common case. +# # +# # This is a CREDENTIAL: prefer setting HERMES_DASHBOARD_OIDC_CLIENT_SECRET +# # in ~/.hermes/.env over putting it here in config.yaml. +# # client_secret: "" + +# ============================================================================= +# External secret sources +# ============================================================================= +# Pull provider credentials from external secret managers at process startup +# instead of storing them in ~/.hermes/.env. Only the manager's bootstrap +# credential (e.g. BWS_ACCESS_TOKEN / OP_SERVICE_ACCOUNT_TOKEN) lives in .env +# (or your shell / desktop session); everything else rotates centrally. +# Failures never block startup — Hermes warns once and continues with +# whatever .env already had. +# +# Multiple sources can be enabled at once: +# - "mapped" sources (explicit VAR -> ref bindings, e.g. 1Password's env: +# map) beat "bulk" sources (whole-project dumps like Bitwarden BSM) +# - within a shape, the first source to claim a var wins; later claims +# are skipped with a startup warning (never a silent clobber) +# - a source's override_existing lets it beat .env/shell values, but +# never another secret source's claim +# Docs: https://hermes-agent.nousresearch.com/docs/user-guide/secrets/ +# +# secrets: +# # Optional explicit ordering of enabled sources. +# # sources: [onepassword, bitwarden] +# +# # ---- Bitwarden Secrets Manager (bws CLI) -------------------------------- +# bitwarden: +# enabled: false +# access_token_env: BWS_ACCESS_TOKEN # bootstrap token, sourced from .env +# project_id: "" # UUID of the BSM project to sync +# server_url: "" # "" = US Cloud; EU/self-hosted URL otherwise +# cache_ttl_seconds: 300 # 0 disables caching +# override_existing: true # BSM values win over existing env +# auto_install: true # lazy-download bws into ~/.hermes/bin +# +# # ---- 1Password (op CLI) ------------------------------------------------- +# onepassword: +# enabled: false +# # Map env-var names to op:// secret references. Each is resolved with a +# # single `op read` at startup. +# env: +# OPENAI_API_KEY: "op://Private/OpenAI/api key" +# ANTHROPIC_API_KEY: "op://Private/Anthropic/credential" +# account: "" # op --account shorthand; "" = default +# service_account_token_env: OP_SERVICE_ACCOUNT_TOKEN # headless auth; unset = desktop session +# binary_path: "" # "" = resolve op via PATH; else absolute path +# cache_ttl_seconds: 300 # 0 disables BOTH cache layers +# override_existing: true # resolved values win over existing env