From 09025fd583947a6f8d9abb65936d57a6d62c4cd0 Mon Sep 17 00:00:00 2001 From: porkmagus <427085+porkmagus@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:22:58 -0500 Subject: [PATCH 1/2] fix(gateway): respect explicit enabled:false in YAML for all platforms When a user disables a platform in config.yaml (or via hermes setup gateway), the corresponding env vars remain in ~/.hermes/.env. On the next gateway restart, _apply_env_overrides() sees those env vars and re-enables the platform, ignoring the explicit disable. This fix: 1. Promotes _enabled_explicit from extra dict (consumed on load) to a real PlatformConfig dataclass field that persists across restarts. 2. Replaces _enable_from_env() with _maybe_enable_from_env() that only auto-enables when no YAML config exists for that platform. 3. Applies the fix to ALL platforms (not just Telegram/Discord), including HomeAssistant, Email, SMS, DingTalk, Feishu, WeCom, WeCom callback, Weixin, BlueBubbles, QQ, Yuanbao, API Server, Webhook, MSGraph Webhook. 4. Adds hermes env CLI suite (path, list, get, set, rm, edit) for discoverability of .env. 5. Adds automatic .env cleanup on disable via env_cleanup.py module. 6. Hooks cleanup into hermes config set, hermes tools disable, and gateway setup wizard. Fixes #35555, #37609 Refs PR #35562 (partial fix for Telegram/Discord only) Refs PR #18764 (merged May 2 - same pattern for agent.max_turns) --- gateway/config.py | 123 +++++++++++++------ hermes_cli/config.py | 91 ++++++++++++++ hermes_cli/env_cleanup.py | 238 +++++++++++++++++++++++++++++++++++++ hermes_cli/main.py | 41 +++++++ hermes_cli/tools_config.py | 6 + 5 files changed, 460 insertions(+), 39 deletions(-) create mode 100644 hermes_cli/env_cleanup.py diff --git a/gateway/config.py b/gateway/config.py index a1b61fed5628c..7a93fc5f9b3b5 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -284,7 +284,7 @@ class PlatformConfig: token: Optional[str] = None # Bot token (Telegram, Discord) api_key: Optional[str] = None # API key if different from token home_channel: Optional[HomeChannel] = None - + # Reply threading mode (Telegram/Slack) # - "off": Never thread replies to original message # - "first": Only first chunk threads to user's message (default) @@ -298,6 +298,11 @@ class PlatformConfig: # noise; keep True for back-channels where the operator wants them. gateway_restart_notification: bool = True + # Whether the enabled flag was explicitly set by the user (e.g. via + # config.yaml or hermes config set). When True, env vars must NOT + # override the enabled state. + enabled_explicit: bool = False + # Platform-specific settings extra: Dict[str, Any] = field(default_factory=dict) @@ -308,6 +313,8 @@ def to_dict(self) -> Dict[str, Any]: "reply_to_mode": self.reply_to_mode, "gateway_restart_notification": self.gateway_restart_notification, } + if self.enabled_explicit: + result["enabled_explicit"] = self.enabled_explicit if self.token: result["token"] = self.token if self.api_key: @@ -330,8 +337,17 @@ def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": if _grn is None: _grn = data.get("extra", {}).get("gateway_restart_notification") + # enabled_explicit is True when the user explicitly set enabled in + # config (not just missing / default). This prevents env vars from + # resurrecting a platform after the user disabled it. + enabled = _coerce_bool(data.get("enabled"), False) + enabled_explicit = data.get("enabled_explicit", False) + if enabled_explicit is False and "enabled" in data: + enabled_explicit = True + return cls( - enabled=_coerce_bool(data.get("enabled"), False), + enabled=enabled, + enabled_explicit=enabled_explicit, token=data.get("token"), api_key=data.get("api_key"), home_channel=home_channel, @@ -1255,21 +1271,28 @@ def _validate_gateway_config(config: "GatewayConfig") -> None: def _apply_env_overrides(config: GatewayConfig) -> None: """Apply environment variable overrides to config.""" - def _enable_from_env(platform: Platform) -> PlatformConfig: + def _maybe_enable_from_env(platform: Platform) -> PlatformConfig: + """Return or create a platform config, only auto-enabling on first env-only setup. + + If the user has already configured this platform (enabled_explicit=True), + respect their choice and do NOT override the enabled state. + """ if platform not in config.platforms: config.platforms[platform] = PlatformConfig(enabled=True) return config.platforms[platform] platform_config = config.platforms[platform] - enabled_was_explicit = bool(platform_config.extra.pop("_enabled_explicit", False)) - if not platform_config.enabled and not enabled_was_explicit: + # Do not override an explicit user choice. The old _enable_from_env + # consumed _enabled_explicit from extra and lost the guard after + # the first load; enabled_explicit is a persistent dataclass field. + if not platform_config.enabled and not platform_config.enabled_explicit: platform_config.enabled = True return platform_config # Telegram telegram_token = os.getenv("TELEGRAM_BOT_TOKEN") if telegram_token: - telegram_config = _enable_from_env(Platform.TELEGRAM) + telegram_config = _maybe_enable_from_env(Platform.TELEGRAM) telegram_config.token = telegram_token # Reply threading mode for Telegram (off/first/all) @@ -1299,7 +1322,7 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: # Discord discord_token = os.getenv("DISCORD_BOT_TOKEN") if discord_token: - discord_config = _enable_from_env(Platform.DISCORD) + discord_config = _maybe_enable_from_env(Platform.DISCORD) discord_config.token = discord_token discord_home = os.getenv("DISCORD_HOME_CHANNEL") @@ -1320,15 +1343,12 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: # WhatsApp (typically uses different auth mechanism) whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in {"true", "1", "yes"} - whatsapp_disabled_explicitly = os.getenv("WHATSAPP_ENABLED", "").lower() in {"false", "0", "no"} if Platform.WHATSAPP in config.platforms: - # YAML config exists — respect explicit disable + # YAML config exists — respect explicit disable. Only override if the + # user never explicitly set enabled (legacy env-only migration). wa_cfg = config.platforms[Platform.WHATSAPP] - if whatsapp_disabled_explicitly: - wa_cfg.enabled = False - elif whatsapp_enabled: + if whatsapp_enabled and not wa_cfg.enabled and not wa_cfg.enabled_explicit: wa_cfg.enabled = True - # else: keep whatever the YAML set elif whatsapp_enabled: config.platforms[Platform.WHATSAPP] = PlatformConfig(enabled=True) whatsapp_home = os.getenv("WHATSAPP_HOME_CHANNEL") @@ -1349,15 +1369,11 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: config.platforms[Platform.SLACK].enabled = True else: slack_config = config.platforms[Platform.SLACK] - enabled_was_explicit = bool(slack_config.extra.pop("_enabled_explicit", False)) - if not slack_config.enabled and not enabled_was_explicit: - # Top-level Slack settings such as channel prompts should not - # turn an env-token setup into a disabled platform. Only an - # explicit slack.enabled/platforms.slack.enabled false should. + if not slack_config.enabled and not slack_config.enabled_explicit: + # Only auto-enable if the user has never explicitly set enabled slack_config.enabled = True - # If yaml config exists, respect its enabled flag (don't override - # explicit enabled: false). Token is still stored so skills that - # send Slack messages can use it without activating the gateway adapter. + # Token is always stored so skills that send Slack messages can use it + # without activating the gateway adapter. config.platforms[Platform.SLACK].token = slack_token slack_home = os.getenv("SLACK_HOME_CHANNEL") if slack_home and Platform.SLACK in config.platforms: @@ -1372,7 +1388,7 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: signal_url = os.getenv("SIGNAL_HTTP_URL") signal_account = os.getenv("SIGNAL_ACCOUNT") if signal_url and signal_account: - signal_config = _enable_from_env(Platform.SIGNAL) + signal_config = _maybe_enable_from_env(Platform.SIGNAL) signal_config.extra.update({ "http_url": signal_url, "account": signal_account, @@ -1393,7 +1409,7 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: mattermost_url = os.getenv("MATTERMOST_URL", "") if not mattermost_url: logger.warning("MATTERMOST_TOKEN set but MATTERMOST_URL is missing") - mattermost_config = _enable_from_env(Platform.MATTERMOST) + mattermost_config = _maybe_enable_from_env(Platform.MATTERMOST) mattermost_config.token = mattermost_token mattermost_config.extra["url"] = mattermost_url mattermost_home = os.getenv("MATTERMOST_HOME_CHANNEL") @@ -1411,7 +1427,7 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if matrix_token or os.getenv("MATRIX_PASSWORD"): if not matrix_homeserver: logger.warning("MATRIX_ACCESS_TOKEN/MATRIX_PASSWORD set but MATRIX_HOMESERVER is missing") - matrix_config = _enable_from_env(Platform.MATRIX) + matrix_config = _maybe_enable_from_env(Platform.MATRIX) if matrix_token: matrix_config.token = matrix_token matrix_config.extra["homeserver"] = matrix_homeserver @@ -1440,7 +1456,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if hass_token: if Platform.HOMEASSISTANT not in config.platforms: config.platforms[Platform.HOMEASSISTANT] = PlatformConfig() - config.platforms[Platform.HOMEASSISTANT].enabled = True + config.platforms[Platform.HOMEASSISTANT].enabled = True + elif not config.platforms[Platform.HOMEASSISTANT].enabled_explicit: + config.platforms[Platform.HOMEASSISTANT].enabled = True config.platforms[Platform.HOMEASSISTANT].token = hass_token hass_url = os.getenv("HASS_URL") if hass_url: @@ -1454,7 +1472,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if all([email_addr, email_pwd, email_imap, email_smtp]): if Platform.EMAIL not in config.platforms: config.platforms[Platform.EMAIL] = PlatformConfig() - config.platforms[Platform.EMAIL].enabled = True + config.platforms[Platform.EMAIL].enabled = True + elif not config.platforms[Platform.EMAIL].enabled_explicit: + config.platforms[Platform.EMAIL].enabled = True config.platforms[Platform.EMAIL].extra.update({ "address": email_addr, "imap_host": email_imap, @@ -1474,7 +1494,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if twilio_sid: if Platform.SMS not in config.platforms: config.platforms[Platform.SMS] = PlatformConfig() - config.platforms[Platform.SMS].enabled = True + config.platforms[Platform.SMS].enabled = True + elif not config.platforms[Platform.SMS].enabled_explicit: + config.platforms[Platform.SMS].enabled = True config.platforms[Platform.SMS].api_key = os.getenv("TWILIO_AUTH_TOKEN", "") sms_home = os.getenv("SMS_HOME_CHANNEL") if sms_home and Platform.SMS in config.platforms: @@ -1494,7 +1516,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if api_server_enabled or api_server_key: if Platform.API_SERVER not in config.platforms: config.platforms[Platform.API_SERVER] = PlatformConfig() - config.platforms[Platform.API_SERVER].enabled = True + config.platforms[Platform.API_SERVER].enabled = True + elif not config.platforms[Platform.API_SERVER].enabled_explicit: + config.platforms[Platform.API_SERVER].enabled = True if api_server_key: config.platforms[Platform.API_SERVER].extra["key"] = api_server_key if api_server_cors_origins: @@ -1519,7 +1543,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if webhook_enabled: if Platform.WEBHOOK not in config.platforms: config.platforms[Platform.WEBHOOK] = PlatformConfig() - config.platforms[Platform.WEBHOOK].enabled = True + config.platforms[Platform.WEBHOOK].enabled = True + elif not config.platforms[Platform.WEBHOOK].enabled_explicit: + config.platforms[Platform.WEBHOOK].enabled = True if webhook_port: try: config.platforms[Platform.WEBHOOK].extra["port"] = int(webhook_port) @@ -1550,8 +1576,11 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: ): if Platform.MSGRAPH_WEBHOOK not in config.platforms: config.platforms[Platform.MSGRAPH_WEBHOOK] = PlatformConfig() - if msgraph_webhook_enabled: - config.platforms[Platform.MSGRAPH_WEBHOOK].enabled = True + if msgraph_webhook_enabled: + config.platforms[Platform.MSGRAPH_WEBHOOK].enabled = True + elif not config.platforms[Platform.MSGRAPH_WEBHOOK].enabled_explicit: + if msgraph_webhook_enabled: + config.platforms[Platform.MSGRAPH_WEBHOOK].enabled = True if msgraph_webhook_port: try: config.platforms[Platform.MSGRAPH_WEBHOOK].extra["port"] = int( @@ -1590,7 +1619,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if dingtalk_client_id and dingtalk_client_secret: if Platform.DINGTALK not in config.platforms: config.platforms[Platform.DINGTALK] = PlatformConfig() - config.platforms[Platform.DINGTALK].enabled = True + config.platforms[Platform.DINGTALK].enabled = True + elif not config.platforms[Platform.DINGTALK].enabled_explicit: + config.platforms[Platform.DINGTALK].enabled = True config.platforms[Platform.DINGTALK].extra.update({ "client_id": dingtalk_client_id, "client_secret": dingtalk_client_secret, @@ -1610,7 +1641,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if feishu_app_id and feishu_app_secret: if Platform.FEISHU not in config.platforms: config.platforms[Platform.FEISHU] = PlatformConfig() - config.platforms[Platform.FEISHU].enabled = True + config.platforms[Platform.FEISHU].enabled = True + elif not config.platforms[Platform.FEISHU].enabled_explicit: + config.platforms[Platform.FEISHU].enabled = True config.platforms[Platform.FEISHU].extra.update({ "app_id": feishu_app_id, "app_secret": feishu_app_secret, @@ -1638,7 +1671,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if wecom_bot_id and wecom_secret: if Platform.WECOM not in config.platforms: config.platforms[Platform.WECOM] = PlatformConfig() - config.platforms[Platform.WECOM].enabled = True + config.platforms[Platform.WECOM].enabled = True + elif not config.platforms[Platform.WECOM].enabled_explicit: + config.platforms[Platform.WECOM].enabled = True config.platforms[Platform.WECOM].extra.update({ "bot_id": wecom_bot_id, "secret": wecom_secret, @@ -1661,7 +1696,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if wecom_callback_corp_id and wecom_callback_corp_secret: if Platform.WECOM_CALLBACK not in config.platforms: config.platforms[Platform.WECOM_CALLBACK] = PlatformConfig() - config.platforms[Platform.WECOM_CALLBACK].enabled = True + config.platforms[Platform.WECOM_CALLBACK].enabled = True + elif not config.platforms[Platform.WECOM_CALLBACK].enabled_explicit: + config.platforms[Platform.WECOM_CALLBACK].enabled = True config.platforms[Platform.WECOM_CALLBACK].extra.update({ "corp_id": wecom_callback_corp_id, "corp_secret": wecom_callback_corp_secret, @@ -1678,7 +1715,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if weixin_token or weixin_account_id: if Platform.WEIXIN not in config.platforms: config.platforms[Platform.WEIXIN] = PlatformConfig() - config.platforms[Platform.WEIXIN].enabled = True + config.platforms[Platform.WEIXIN].enabled = True + elif not config.platforms[Platform.WEIXIN].enabled_explicit: + config.platforms[Platform.WEIXIN].enabled = True if weixin_token: config.platforms[Platform.WEIXIN].token = weixin_token extra = config.platforms[Platform.WEIXIN].extra @@ -1720,7 +1759,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if bluebubbles_server_url and bluebubbles_password: if Platform.BLUEBUBBLES not in config.platforms: config.platforms[Platform.BLUEBUBBLES] = PlatformConfig() - config.platforms[Platform.BLUEBUBBLES].enabled = True + config.platforms[Platform.BLUEBUBBLES].enabled = True + elif not config.platforms[Platform.BLUEBUBBLES].enabled_explicit: + config.platforms[Platform.BLUEBUBBLES].enabled = True config.platforms[Platform.BLUEBUBBLES].extra.update({ "server_url": bluebubbles_server_url.rstrip("/"), "password": bluebubbles_password, @@ -1760,7 +1801,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if qq_app_id or qq_client_secret: if Platform.QQBOT not in config.platforms: config.platforms[Platform.QQBOT] = PlatformConfig() - config.platforms[Platform.QQBOT].enabled = True + config.platforms[Platform.QQBOT].enabled = True + elif not config.platforms[Platform.QQBOT].enabled_explicit: + config.platforms[Platform.QQBOT].enabled = True extra = config.platforms[Platform.QQBOT].extra if qq_app_id: extra["app_id"] = qq_app_id @@ -1802,7 +1845,9 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if yuanbao_app_id and yuanbao_app_secret: if Platform.YUANBAO not in config.platforms: config.platforms[Platform.YUANBAO] = PlatformConfig() - config.platforms[Platform.YUANBAO].enabled = True + config.platforms[Platform.YUANBAO].enabled = True + elif not config.platforms[Platform.YUANBAO].enabled_explicit: + config.platforms[Platform.YUANBAO].enabled = True extra = config.platforms[Platform.YUANBAO].extra extra["app_id"] = yuanbao_app_id extra["app_secret"] = yuanbao_app_secret diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 1b72c31527321..5a5b3f5c35068 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -5916,6 +5916,14 @@ def set_config_value(key: str, value: str): from utils import atomic_yaml_write atomic_yaml_write(config_path, user_config, sort_keys=False) + # Clean up .env vars when a platform is explicitly disabled + if key.startswith("platforms.") and key.endswith(".enabled") and value is False: + platform_name = key.split(".")[1] + from hermes_cli.env_cleanup import remove_env_vars_for_feature + removed = remove_env_vars_for_feature("platform", platform_name) + if removed: + logger.info("Removed %s env vars for disabled platform %s", len(removed), platform_name) + # Keep .env in sync for keys that terminal_tool reads directly from env vars. # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc. _config_to_env_sync = { @@ -6225,3 +6233,86 @@ def _inject_platform_plugin_env_vars() -> None: # Eagerly inject so that platform plugin env vars show up in the setup wizard. _inject_platform_plugin_env_vars() + + +# ============================================================================= +# Env command handler +# ============================================================================= + +def env_command(args): + """Handle env subcommands for ~/.hermes/.env.""" + subcmd = getattr(args, "env_command", None) + + if subcmd is None or subcmd == "path": + print(get_env_path()) + return + + if subcmd == "list": + env_path = get_env_path() + if not env_path.exists(): + print("No .env file found.") + return + with open(env_path, encoding="utf-8-sig", errors="replace") as f: + lines = f.readlines() + print(f"\n Env vars in {env_path}:") + for line in lines: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, _ = line.split("=", 1) + print(f" {key}") + print() + return + + if subcmd == "get": + key = getattr(args, "key", None) + if not key: + print("Usage: hermes env get ") + sys.exit(1) + value = get_env_value(key) + if value is not None: + # Mask secrets + if len(value) > 8: + print(f"{key}={value[:4]}{'*' * (len(value) - 8)}{value[-4:]}") + else: + print(f"{key}={value}") + else: + print(f"{key} is not set") + return + + if subcmd == "set": + key = getattr(args, "key", None) + value = getattr(args, "value", None) + if not key or value is None: + print("Usage: hermes env set ") + sys.exit(1) + save_env_value(key, value) + print(f"✓ Set {key} in {get_env_path()}") + return + + if subcmd == "rm": + key = getattr(args, "key", None) + if not key: + print("Usage: hermes env rm ") + sys.exit(1) + if remove_env_value(key): + print(f"✓ Removed {key} from {get_env_path()}") + else: + print(f"{key} not found in {get_env_path()}") + return + + if subcmd == "edit": + editor = os.environ.get("EDITOR", "nano") + env_path = get_env_path() + print() + print("WARNING: This file contains sensitive credentials.") + print("Removing a token here may break the associated platform or tool.") + print("Use `hermes config set` to disable features safely; it will clean up") + print(".env automatically. Only edit this file directly if you know what") + print("you are doing.") + print() + if not env_path.exists(): + env_path.write_text("# Hermes environment variables\n", encoding="utf-8") + subprocess.run([editor, str(env_path)]) + return diff --git a/hermes_cli/env_cleanup.py b/hermes_cli/env_cleanup.py new file mode 100644 index 0000000000000..d3e3284b1bd65 --- /dev/null +++ b/hermes_cli/env_cleanup.py @@ -0,0 +1,238 @@ +"""Env cleanup mappings: feature name → list of env vars. + +Used to remove stale env vars from ~/.hermes/.env when a user disables a +platform or tool. This prevents the "zombie resurrection" bug where env vars +left behind silently re-enable a feature on the next gateway restart. +""" + +from typing import Dict, List + +# Mapping of platform names to env vars that configure them. +# Derived from gateway/config.py::_apply_env_overrides() and platform adapters. +PLATFORM_ENV_VARS: Dict[str, List[str]] = { + "slack": [ + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + "SLACK_HOME_CHANNEL", + "SLACK_HOME_CHANNEL_NAME", + "SLACK_HOME_CHANNEL_THREAD_ID", + ], + "telegram": [ + "TELEGRAM_BOT_TOKEN", + "TELEGRAM_REPLY_TO_MODE", + "TELEGRAM_FALLBACK_IPS", + "TELEGRAM_HOME_CHANNEL", + "TELEGRAM_HOME_CHANNEL_NAME", + "TELEGRAM_HOME_CHANNEL_THREAD_ID", + ], + "discord": [ + "DISCORD_BOT_TOKEN", + "DISCORD_HOME_CHANNEL", + "DISCORD_HOME_CHANNEL_NAME", + "DISCORD_HOME_CHANNEL_THREAD_ID", + "DISCORD_REPLY_TO_MODE", + ], + "whatsapp": [ + "WHATSAPP_ENABLED", + "WHATSAPP_HOME_CHANNEL", + "WHATSAPP_HOME_CHANNEL_NAME", + "WHATSAPP_HOME_CHANNEL_THREAD_ID", + ], + "signal": [ + "SIGNAL_HTTP_URL", + "SIGNAL_ACCOUNT", + "SIGNAL_IGNORE_STORIES", + "SIGNAL_HOME_CHANNEL", + "SIGNAL_HOME_CHANNEL_NAME", + "SIGNAL_HOME_CHANNEL_THREAD_ID", + ], + "mattermost": [ + "MATTERMOST_TOKEN", + "MATTERMOST_URL", + "MATTERMOST_HOME_CHANNEL", + "MATTERMOST_HOME_CHANNEL_NAME", + "MATTERMOST_HOME_CHANNEL_THREAD_ID", + ], + "matrix": [ + "MATRIX_ACCESS_TOKEN", + "MATRIX_HOMESERVER", + "MATRIX_PASSWORD", + "MATRIX_USER_ID", + "MATRIX_ENCRYPTION", + "MATRIX_DEVICE_ID", + "MATRIX_HOME_ROOM", + "MATRIX_HOME_ROOM_NAME", + "MATRIX_HOME_ROOM_THREAD_ID", + ], + "homeassistant": [ + "HASS_TOKEN", + "HASS_URL", + ], + "email": [ + "EMAIL_ADDRESS", + "EMAIL_PASSWORD", + "EMAIL_IMAP_HOST", + "EMAIL_SMTP_HOST", + "EMAIL_HOME_ADDRESS", + "EMAIL_HOME_ADDRESS_NAME", + "EMAIL_HOME_ADDRESS_THREAD_ID", + ], + "sms": [ + "TWILIO_ACCOUNT_SID", + "TWILIO_AUTH_TOKEN", + "SMS_HOME_CHANNEL", + "SMS_HOME_CHANNEL_NAME", + "SMS_HOME_CHANNEL_THREAD_ID", + ], + "dingtalk": [ + "DINGTALK_CLIENT_ID", + "DINGTALK_CLIENT_SECRET", + "DINGTALK_HOME_CHANNEL", + "DINGTALK_HOME_CHANNEL_NAME", + "DINGTALK_HOME_CHANNEL_THREAD_ID", + ], + "api_server": [ + "API_SERVER_ENABLED", + "API_SERVER_KEY", + "API_SERVER_CORS_ORIGINS", + "API_SERVER_PORT", + "API_SERVER_HOST", + "API_SERVER_MODEL_NAME", + ], + "webhook": [ + "WEBHOOK_ENABLED", + "WEBHOOK_PORT", + "WEBHOOK_SECRET", + ], + "msgraph_webhook": [ + "MSGRAPH_WEBHOOK_ENABLED", + "MSGRAPH_WEBHOOK_PORT", + "MSGRAPH_WEBHOOK_CLIENT_STATE", + "MSGRAPH_WEBHOOK_ACCEPTED_RESOURCES", + "MSGRAPH_WEBHOOK_ALLOWED_SOURCE_CIDRS", + ], + "feishu": [ + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "FEISHU_DOMAIN", + "FEISHU_CONNECTION_MODE", + "FEISHU_ENCRYPT_KEY", + "FEISHU_VERIFICATION_TOKEN", + "FEISHU_HOME_CHANNEL", + "FEISHU_HOME_CHANNEL_NAME", + "FEISHU_HOME_CHANNEL_THREAD_ID", + ], + "wecom": [ + "WECOM_BOT_ID", + "WECOM_SECRET", + "WECOM_WEBSOCKET_URL", + "WECOM_HOME_CHANNEL", + "WECOM_HOME_CHANNEL_NAME", + "WECOM_HOME_CHANNEL_THREAD_ID", + ], + "wecom_callback": [ + "WECOM_CALLBACK_CORP_ID", + "WECOM_CALLBACK_CORP_SECRET", + "WECOM_CALLBACK_AGENT_ID", + "WECOM_CALLBACK_TOKEN", + "WECOM_CALLBACK_ENCODING_AES_KEY", + "WECOM_CALLBACK_HOST", + "WECOM_CALLBACK_PORT", + ], + "weixin": [ + "WEIXIN_TOKEN", + "WEIXIN_ACCOUNT_ID", + "WEIXIN_BASE_URL", + "WEIXIN_CDN_BASE_URL", + "WEIXIN_DM_POLICY", + "WEIXIN_GROUP_POLICY", + "WEIXIN_ALLOWED_USERS", + "WEIXIN_GROUP_ALLOWED_USERS", + "WEIXIN_SPLIT_MULTILINE_MESSAGES", + "WEIXIN_HOME_CHANNEL", + "WEIXIN_HOME_CHANNEL_NAME", + "WEIXIN_HOME_CHANNEL_THREAD_ID", + ], + "bluebubbles": [ + "BLUEBUBBLES_SERVER_URL", + "BLUEBUBBLES_PASSWORD", + "BLUEBUBBLES_WEBHOOK_HOST", + "BLUEBUBBLES_WEBHOOK_PORT", + "BLUEBUBBLES_WEBHOOK_PATH", + "BLUEBUBBLES_SEND_READ_RECEIPTS", + "BLUEBUBBLES_REQUIRE_MENTION", + "BLUEBUBBLES_MENTION_PATTERNS", + "BLUEBUBBLES_HOME_CHANNEL", + "BLUEBUBBLES_HOME_CHANNEL_NAME", + "BLUEBUBBLES_HOME_CHANNEL_THREAD_ID", + ], + "qqbot": [ + "QQ_APP_ID", + "QQ_CLIENT_SECRET", + "QQ_ALLOWED_USERS", + "QQ_GROUP_ALLOWED_USERS", + "QQBOT_HOME_CHANNEL", + "QQBOT_HOME_CHANNEL_NAME", + "QQBOT_HOME_CHANNEL_THREAD_ID", + "QQ_HOME_CHANNEL", + "QQ_HOME_CHANNEL_NAME", + "QQ_HOME_CHANNEL_THREAD_ID", + ], + "yuanbao": [ + "YUANBAO_APP_ID", + "YUANBAO_APP_KEY", + "YUANBAO_APP_SECRET", + ], +} + +# Mapping of tool names to env vars that configure them. +# Derived from TOOL_CATEGORIES and TOOLSET_ENV_REQUIREMENTS in tools_config.py. +TOOL_ENV_VARS: Dict[str, List[str]] = { + "vision": ["OPENROUTER_API_KEY"], + "moa": ["OPENROUTER_API_KEY"], + "tts": [ + "VOICE_TOOLS_OPENAI_KEY", + "ELEVENLABS_API_KEY", + "MISTRAL_API_KEY", + "GEMINI_API_KEY", + ], + "web": [ + "FIRECRAWL_API_URL", + "FIRECRAWL_API_KEY", + ], + "image_gen": ["FAL_KEY"], + "video_gen": ["FAL_KEY"], + "x_search": ["XAI_API_KEY"], + "browser": [ + "BROWSER_USE_API_KEY", + "CAMOFOX_URL", + ], + "homeassistant": [ + "HASS_TOKEN", + "HASS_URL", + ], + "langfuse": [ + "HERMES_LANGFUSE_PUBLIC_KEY", + "HERMES_LANGFUSE_SECRET_KEY", + "HERMES_LANGFUSE_BASE_URL", + ], + "spotify": [], # Spotify uses OAuth post-setup, no env vars + "computer_use": [], # cua-driver is local, no env vars required +} + + +def remove_env_vars_for_feature(category: str, name: str) -> List[str]: + """Remove env vars for a disabled feature. Returns list of removed keys. + + Args: + category: "platform" or "tool". + name: Platform or tool name (e.g. "slack", "homeassistant"). + """ + from hermes_cli.config import remove_env_value + + removed: List[str] = [] + mapping = PLATFORM_ENV_VARS if category == "platform" else TOOL_ENV_VARS + for key in mapping.get(name, []): + if remove_env_value(key): + removed.append(key) + return removed diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a13e21cebc177..ae20589a5b29b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6544,6 +6544,13 @@ def cmd_config(args): config_command(args) +def cmd_env(args): + """Env file management.""" + from hermes_cli.config import env_command + + env_command(args) + + def cmd_backup(args): """Back up Hermes home directory to a zip file.""" if getattr(args, "quick", False): @@ -13653,6 +13660,40 @@ def _dispatch_secrets(args): # noqa: ANN001 config_parser.set_defaults(func=cmd_config) + # ========================================================================= + # env command + # ========================================================================= + env_parser = subparsers.add_parser( + "env", + help="Manage ~/.hermes/.env secrets", + description="Inspect and edit the .env file that stores API keys and tokens", + ) + env_subparsers = env_parser.add_subparsers(dest="env_command") + + # env path (default) + env_subparsers.add_parser("path", help="Print .env file path") + + # env list + env_subparsers.add_parser("list", help="List all env vars (values masked)") + + # env get + env_get = env_subparsers.add_parser("get", help="Get value of an env var") + env_get.add_argument("key", help="Env var name") + + # env set + env_set = env_subparsers.add_parser("set", help="Set an env var") + env_set.add_argument("key", help="Env var name") + env_set.add_argument("value", help="Value to set") + + # env rm + env_rm = env_subparsers.add_parser("rm", help="Remove an env var") + env_rm.add_argument("key", help="Env var name") + + # env edit + env_subparsers.add_parser("edit", help="Open .env in $EDITOR") + + env_parser.set_defaults(func=cmd_env) + # ========================================================================= # pairing command # ========================================================================= diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index e401a234266b8..693a254fa6315 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -3660,6 +3660,12 @@ def _apply_toolset_change(config: dict, platform: str, toolset_names: List[str], enabled = _get_platform_tools(config, platform, include_default_mcp_servers=False) if action == "disable": updated = enabled - set(toolset_names) + # Clean up .env vars for disabled tools + for tool_name in toolset_names: + from hermes_cli.env_cleanup import remove_env_vars_for_feature + removed = remove_env_vars_for_feature("tool", tool_name) + if removed: + logger.info("Removed %s env vars for disabled tool %s", len(removed), tool_name) else: updated = enabled | set(toolset_names) _save_platform_tools(config, platform, updated) From 810a6c9e2525c9b03ad7e61f05e643378f8755f9 Mon Sep 17 00:00:00 2001 From: porkmagus <427085+porkmagus@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:33:44 -0500 Subject: [PATCH 2/2] Address Copilot review: type safety, secret masking, shlex, validation, WhatsApp semantics --- gateway/config.py | 12 ++++++++++- hermes_cli/config.py | 45 +++++++++++++++++++++++---------------- hermes_cli/env_cleanup.py | 6 ++++++ hermes_cli/main.py | 2 +- 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 7a93fc5f9b3b5..affa8b1cf423f 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1343,14 +1343,24 @@ def _maybe_enable_from_env(platform: Platform) -> PlatformConfig: # WhatsApp (typically uses different auth mechanism) whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in {"true", "1", "yes"} + whatsapp_disabled_explicitly = os.getenv("WHATSAPP_ENABLED", "").lower() in {"false", "0", "no"} if Platform.WHATSAPP in config.platforms: # YAML config exists — respect explicit disable. Only override if the # user never explicitly set enabled (legacy env-only migration). wa_cfg = config.platforms[Platform.WHATSAPP] - if whatsapp_enabled and not wa_cfg.enabled and not wa_cfg.enabled_explicit: + if wa_cfg.enabled_explicit: + # YAML is authoritative; ignore env var for enable/disable + pass + elif whatsapp_disabled_explicitly: + wa_cfg.enabled = False + elif whatsapp_enabled: wa_cfg.enabled = True elif whatsapp_enabled: config.platforms[Platform.WHATSAPP] = PlatformConfig(enabled=True) + elif whatsapp_disabled_explicitly: + # User explicitly disabled via env, but no YAML config exists yet. + # Create a disabled entry so the intent is recorded. + config.platforms[Platform.WHATSAPP] = PlatformConfig(enabled=False) whatsapp_home = os.getenv("WHATSAPP_HOME_CHANNEL") if whatsapp_home and Platform.WHATSAPP in config.platforms: config.platforms[Platform.WHATSAPP].home_channel = HomeChannel( diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5a5b3f5c35068..d9977ba2433a5 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -17,6 +17,7 @@ import os import platform import re +import shlex import shutil import stat import subprocess @@ -5859,8 +5860,11 @@ def edit_config(): subprocess.run([editor, str(config_path)]) -def set_config_value(key: str, value: str): +from typing import Union + +def set_config_value(key: str, value: Union[str, bool, int, float]): """Set a configuration value.""" + from hermes_cli.env_cleanup import remove_env_vars_for_feature if is_managed(): managed_error("set configuration values") return @@ -5878,7 +5882,7 @@ def set_config_value(key: str, value: str): ] if key.upper() in api_keys or key.upper().endswith(('_API_KEY', '_TOKEN')) or key.upper().startswith('TERMINAL_SSH'): - save_env_value(key.upper(), value) + save_env_value(key.upper(), str(value)) print(f"✓ Set {key} in {get_env_path()}") return @@ -5899,15 +5903,16 @@ def set_config_value(key: str, value: str): # _set_nested which preserves list-typed nodes; before #17876 the # inline navigation here silently overwrote lists with dicts. - # Convert value to appropriate type - if value.lower() in {'true', 'yes', 'on'}: - value = True - elif value.lower() in {'false', 'no', 'off'}: - value = False - elif value.isdigit(): - value = int(value) - elif value.replace('.', '', 1).isdigit(): - value = float(value) + # Convert value to appropriate type (only if it's a string) + if isinstance(value, str): + if value.lower() in {'true', 'yes', 'on'}: + value = True + elif value.lower() in {'false', 'no', 'off'}: + value = False + elif value.isdigit(): + value = int(value) + elif value.replace('.', '', 1).isdigit(): + value = float(value) _set_nested(user_config, key, value) @@ -5917,9 +5922,8 @@ def set_config_value(key: str, value: str): atomic_yaml_write(config_path, user_config, sort_keys=False) # Clean up .env vars when a platform is explicitly disabled - if key.startswith("platforms.") and key.endswith(".enabled") and value is False: + if key.startswith("platforms.") and key.endswith(".enabled") and isinstance(value, bool) and value is False: platform_name = key.split(".")[1] - from hermes_cli.env_cleanup import remove_env_vars_for_feature removed = remove_env_vars_for_feature("platform", platform_name) if removed: logger.info("Removed %s env vars for disabled platform %s", len(removed), platform_name) @@ -6272,11 +6276,12 @@ def env_command(args): sys.exit(1) value = get_env_value(key) if value is not None: - # Mask secrets - if len(value) > 8: - print(f"{key}={value[:4]}{'*' * (len(value) - 8)}{value[-4:]}") + # Always mask secrets — even short tokens can leak credentials + if len(value) <= 4: + masked = "*" * len(value) else: - print(f"{key}={value}") + masked = value[:2] + "*" * (len(value) - 4) + value[-2:] + print(f"{key}={masked}") else: print(f"{key} is not set") return @@ -6314,5 +6319,9 @@ def env_command(args): print() if not env_path.exists(): env_path.write_text("# Hermes environment variables\n", encoding="utf-8") - subprocess.run([editor, str(env_path)]) + try: + editor_cmd = shlex.split(editor) + except ValueError: + editor_cmd = [editor] + subprocess.run(editor_cmd + [str(env_path)]) return diff --git a/hermes_cli/env_cleanup.py b/hermes_cli/env_cleanup.py index d3e3284b1bd65..d5988c4245c73 100644 --- a/hermes_cli/env_cleanup.py +++ b/hermes_cli/env_cleanup.py @@ -227,9 +227,15 @@ def remove_env_vars_for_feature(category: str, name: str) -> List[str]: Args: category: "platform" or "tool". name: Platform or tool name (e.g. "slack", "homeassistant"). + + Raises: + ValueError: If category is not "platform" or "tool". """ from hermes_cli.config import remove_env_value + if category not in ("platform", "tool"): + raise ValueError(f"Invalid category {category!r}: must be 'platform' or 'tool'") + removed: List[str] = [] mapping = PLATFORM_ENV_VARS if category == "platform" else TOOL_ENV_VARS for key in mapping.get(name, []): diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ae20589a5b29b..aaed294eceef7 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13674,7 +13674,7 @@ def _dispatch_secrets(args): # noqa: ANN001 env_subparsers.add_parser("path", help="Print .env file path") # env list - env_subparsers.add_parser("list", help="List all env vars (values masked)") + env_subparsers.add_parser("list", help="List env var names") # env get env_get = env_subparsers.add_parser("get", help="Get value of an env var")