diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 7db8711ee9d..039da6f49c7 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -48,9 +48,7 @@ try { const path = require('node:path') const resourcesPath = process.resourcesPath if (resourcesPath) { - nodePty = require( - path.join(resourcesPath, 'native-deps', 'node-pty') - ) + nodePty = require(path.join(resourcesPath, 'native-deps', 'node-pty')) } } catch { nodePty = null @@ -103,7 +101,9 @@ function loadInstallStamp() { const parsed = JSON.parse(raw) if (parsed && typeof parsed === 'object' && typeof parsed.commit === 'string' && parsed.commit.length >= 7) { if (parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) { - console.warn(`[hermes] install-stamp.json schemaVersion ${parsed.schemaVersion} != expected ${INSTALL_STAMP_SCHEMA_VERSION}; ignoring`) + console.warn( + `[hermes] install-stamp.json schemaVersion ${parsed.schemaVersion} != expected ${INSTALL_STAMP_SCHEMA_VERSION}; ignoring` + ) continue } return Object.freeze({ @@ -124,11 +124,15 @@ function loadInstallStamp() { } const INSTALL_STAMP = loadInstallStamp() if (INSTALL_STAMP) { - console.log(`[hermes] install stamp: ${INSTALL_STAMP.commit.slice(0, 12)}${INSTALL_STAMP.branch ? ` (${INSTALL_STAMP.branch})` : ''}${INSTALL_STAMP.dirty ? ' [DIRTY]' : ''} from ${INSTALL_STAMP.source || 'unknown'}`) + console.log( + `[hermes] install stamp: ${INSTALL_STAMP.commit.slice(0, 12)}${INSTALL_STAMP.branch ? ` (${INSTALL_STAMP.branch})` : ''}${INSTALL_STAMP.dirty ? ' [DIRTY]' : ''} from ${INSTALL_STAMP.source || 'unknown'}` + ) } else if (IS_PACKAGED) { // Dev builds without a stamp are normal; packaged builds without one // mean the bootstrap won't know what to clone. Surface clearly. - console.error('[hermes] WARNING: no install-stamp.json found in packaged build. First-launch bootstrap will not have a pinned ref to install.') + console.error( + '[hermes] WARNING: no install-stamp.json found in packaged build. First-launch bootstrap will not have a pinned ref to install.' + ) } // HERMES_HOME — the user-facing root for everything Hermes-related. Mirrors @@ -667,6 +671,41 @@ function isCommandScript(command) { return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '') } +function normalizeExecutablePathForCompare(commandPath) { + if (!commandPath) return null + + let resolved = path.resolve(String(commandPath)) + try { + resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved) + } catch { + // Fallback to path.resolve() above. + } + + return IS_WINDOWS ? resolved.toLowerCase() : resolved +} + +function looksLikeDesktopAppBinary(commandPath) { + if (!IS_WINDOWS || !commandPath) return false + + const normalizedCandidate = normalizeExecutablePathForCompare(commandPath) + const normalizedCurrentExec = normalizeExecutablePathForCompare(process.execPath) + if (normalizedCandidate && normalizedCurrentExec && normalizedCandidate === normalizedCurrentExec) { + return true + } + + let resolved = path.resolve(String(commandPath)) + try { + resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved) + } catch { + // Keep resolved path fallback. + } + + const resourcesDir = path.join(path.dirname(resolved), 'resources') + return ( + fileExists(path.join(resourcesDir, 'app.asar')) || directoryExists(path.join(resourcesDir, 'app.asar.unpacked')) + ) +} + function isHermesSourceRoot(root) { return directoryExists(root) && fileExists(path.join(root, 'hermes_cli', 'main.py')) } @@ -1303,6 +1342,13 @@ function resolveHermesBackend(dashboardArgs) { hermesCommand = findOnPath('hermes') } + if (hermesCommand) { + if (looksLikeDesktopAppBinary(hermesCommand)) { + rememberLog(`Ignoring desktop app executable on PATH while resolving Hermes CLI: ${hermesCommand}`) + hermesCommand = null + } + } + if (hermesCommand) { return { label: `existing Hermes CLI at ${hermesCommand}`, @@ -1498,8 +1544,7 @@ async function ensureRuntime(backend) { // install.ps1 succeeds. If we hit this, the user (or a deleted venv) // broke the invariant; tell them to re-run the install. throw new Error( - `Hermes venv missing at ${VENV_ROOT}. Re-run the desktop installer or ` + - '`scripts/install.ps1` to rebuild it.' + `Hermes venv missing at ${VENV_ROOT}. Re-run the desktop installer or ` + '`scripts/install.ps1` to rebuild it.' ) } diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 5accfdb4108..13564f1e6e2 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -23,6 +23,7 @@ try: from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler from slack_sdk.web.async_client import AsyncWebClient import aiohttp + SLACK_AVAILABLE = True except ImportError: SLACK_AVAILABLE = False @@ -32,6 +33,7 @@ except ImportError: import sys from pathlib import Path as _Path + sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) from gateway.config import Platform, PlatformConfig @@ -59,13 +61,15 @@ logger = logging.getLogger(__name__) # (Python 3.7+), so the value set in _handle_slash_command's task is # visible in _process_message_background's child task. _slash_user_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( - "_slash_user_id", default=None, + "_slash_user_id", + default=None, ) @dataclass class _ThreadContextCache: """Cache entry for fetched thread context.""" + content: str fetched_at: float = field(default_factory=time.monotonic) message_count: int = 0 @@ -86,6 +90,7 @@ def check_slack_requirements() -> bool: from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler from slack_sdk.web.async_client import AsyncWebClient import aiohttp + return { "AsyncApp": AsyncApp, "AsyncSocketModeHandler": AsyncSocketModeHandler, @@ -95,6 +100,7 @@ def check_slack_requirements() -> bool: } from tools.lazy_deps import ensure_and_bind + return ensure_and_bind("platform.slack", _import, globals(), prompt=False) @@ -176,7 +182,11 @@ def _extract_text_from_slack_blocks(blocks: list) -> str: code_text = "\n".join(code_lines) if code_text: lang = elem.get("language", "") - _append_line(f"```{lang}\n{code_text}\n```", quote_depth=quote_depth, bullet=bullet) + _append_line( + f"```{lang}\n{code_text}\n```", + quote_depth=quote_depth, + bullet=bullet, + ) else: rendered = _render_inline_elements([elem]) if rendered: @@ -226,7 +236,11 @@ def _serialize_slack_blocks_for_agent(blocks: list, max_chars: int = 6000) -> st def _sanitize(value): if isinstance(value, list): - return [item for item in (_sanitize(v) for v in value) if item not in (None, {}, [], "")] + return [ + item + for item in (_sanitize(v) for v in value) + if item not in (None, {}, [], "") + ] if isinstance(value, dict): sanitized = {} for key, item in value.items(): @@ -312,9 +326,9 @@ class SlackAdapter(BasePlatformAdapter): self._user_name_cache: Dict[str, str] = {} # user_id → display name self._socket_mode_task: Optional[asyncio.Task] = None # Multi-workspace support - self._team_clients: Dict[str, Any] = {} # team_id → WebClient - self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id - self._channel_team: Dict[str, str] = {} # channel_id → team_id + self._team_clients: Dict[str, Any] = {} # team_id → WebClient + self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id + self._channel_team: Dict[str, str] = {} # channel_id → team_id # Dedup cache: prevents duplicate bot responses when Socket Mode # reconnects redeliver events. self._dedup = MessageDeduplicator() @@ -348,8 +362,190 @@ class SlackAdapter(BasePlatformAdapter): # (channel_id, user_id) to avoid cross-user collisions. # Each value: {"response_url": str, "ts": float} self._slash_command_contexts: Dict[Tuple[str, str], Dict[str, Any]] = {} + # Socket Mode resilience: track runtime connection state so we can + # self-heal when Slack silently drops the websocket. + self._app_token: Optional[str] = None + self._proxy_url: Optional[str] = None + self._socket_watchdog_task: Optional[asyncio.Task] = None + self._socket_reconnect_lock = asyncio.Lock() + self._socket_watchdog_interval_s = 15.0 - def _describe_slack_api_error(self, response: Any, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]: + def _start_socket_mode_handler(self) -> None: + """Start the Slack Socket Mode background task.""" + if not self._app or not self._app_token: + raise RuntimeError("Socket Mode requires an initialized app and app token") + + self._handler = AsyncSocketModeHandler( + self._app, self._app_token, proxy=self._proxy_url + ) + _apply_slack_proxy(self._handler.client, self._proxy_url) + + task = asyncio.create_task(self._handler.start_async()) + self._socket_mode_task = task + task.add_done_callback(self._on_socket_mode_task_done) + + async def _stop_socket_mode_handler(self) -> None: + """Stop Socket Mode handler and task.""" + handler = self._handler + task = self._socket_mode_task + self._handler = None + self._socket_mode_task = None + + if handler is not None: + try: + await handler.close_async() + except Exception as e: # pragma: no cover - defensive logging + logger.warning( + "[Slack] Error while closing Socket Mode handler: %s", + e, + exc_info=True, + ) + + if task is not None and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception: # pragma: no cover - defensive logging + logger.debug( + "[Slack] Socket Mode task failed while stopping", exc_info=True + ) + + async def _socket_transport_connected(self) -> Optional[bool]: + """Best-effort check of current Socket Mode transport state.""" + client = getattr(self._handler, "client", None) + if client is None: + return None + + state = getattr(client, "is_connected", None) + if state is None: + return None + + try: + value = state() if callable(state) else state + if asyncio.iscoroutine(value): + value = await value + return bool(value) + except Exception: # pragma: no cover - optional client API + logger.debug( + "[Slack] Could not inspect Socket Mode transport state", exc_info=True + ) + return None + + async def _restart_socket_mode(self, reason: str) -> None: + """Reconnect Socket Mode without rebuilding adapter state.""" + if not self._running: + return + + async with self._socket_reconnect_lock: + if not self._running or not self._app or not self._app_token: + return + + logger.warning("[Slack] Socket Mode unhealthy (%s); reconnecting", reason) + await self._stop_socket_mode_handler() + + try: + self._start_socket_mode_handler() + except Exception as exc: # pragma: no cover - defensive logging + logger.error( + "[Slack] Socket Mode reconnect failed: %s", exc, exc_info=True + ) + + async def _socket_watchdog_loop(self) -> None: + """Monitor Socket Mode and reconnect if the task/transport dies. + + The body is wrapped in a broad except so a transient bug in + ``_restart_socket_mode`` or the transport probe cannot permanently + disable self-healing — the loop logs and keeps polling. + """ + while self._running: + try: + await asyncio.sleep(self._socket_watchdog_interval_s) + if not self._running: + break + + task = self._socket_mode_task + if task is None: + await self._restart_socket_mode("socket task missing") + continue + + if task.done(): + await self._restart_socket_mode("socket task stopped") + continue + + connected = await self._socket_transport_connected() + if connected is False: + await self._restart_socket_mode("transport disconnected") + except asyncio.CancelledError: + raise + except Exception: # pragma: no cover - defensive logging + logger.warning( + "[Slack] Socket Mode watchdog iteration failed; continuing", + exc_info=True, + ) + + def _on_socket_watchdog_done(self, task: asyncio.Task) -> None: + if task is not self._socket_watchdog_task: + return + if task.cancelled() or not self._running: + return + try: + exc = task.exception() + except (asyncio.CancelledError, Exception): # pragma: no cover + exc = None + if exc is not None: + logger.warning( + "[Slack] Socket Mode watchdog exited with error; restarting: %s", + exc, + exc_info=True, + ) + else: + logger.warning("[Slack] Socket Mode watchdog exited; restarting") + self._socket_watchdog_task = None + self._ensure_socket_watchdog() + + def _ensure_socket_watchdog(self) -> None: + if self._socket_watchdog_task is None or self._socket_watchdog_task.done(): + task = asyncio.create_task(self._socket_watchdog_loop()) + self._socket_watchdog_task = task + task.add_done_callback(self._on_socket_watchdog_done) + + def _on_socket_mode_task_done(self, task: asyncio.Task) -> None: + # Ignore stale tasks from intentional reconnect/shutdown. + if task is not self._socket_mode_task: + return + if task.cancelled(): + return + if not self._running: + return + + exc = None + try: + exc = task.exception() + except asyncio.CancelledError: + return + except Exception: # pragma: no cover - defensive logging + logger.debug( + "[Slack] Could not inspect Socket Mode task exception", exc_info=True + ) + + if exc is not None: + logger.warning( + "[Slack] Socket Mode task exited with error: %s", exc, exc_info=True + ) + else: + logger.warning("[Slack] Socket Mode task exited unexpectedly") + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + loop.create_task(self._restart_socket_mode("socket task exited")) + + def _describe_slack_api_error( + self, response: Any, *, file_obj: Optional[Dict[str, Any]] = None + ) -> Optional[str]: """Convert Slack API auth/permission failures into actionable user-facing text.""" if response is None or not hasattr(response, "get"): return None @@ -358,26 +554,46 @@ class SlackAdapter(BasePlatformAdapter): if not error: return None - file_label = str((file_obj or {}).get("name") or (file_obj or {}).get("id") or "this attachment") + file_label = str( + (file_obj or {}).get("name") + or (file_obj or {}).get("id") + or "this attachment" + ) needed = str(response.get("needed", "") or "").strip() provided = str(response.get("provided", "") or "").strip() reinstall_hint = " Update the Slack app scopes/settings and reinstall the app to the workspace." provided_hint = f" Current bot scopes: {provided}." if provided else "" if error == "missing_scope": - needed_hint = f"Missing scope: {needed}." if needed else "Missing required Slack scope." + needed_hint = ( + f"Missing scope: {needed}." + if needed + else "Missing required Slack scope." + ) return f"Slack attachment access failed for {file_label}. {needed_hint}{provided_hint}{reinstall_hint}" if error in {"not_authed", "invalid_auth", "account_inactive", "token_revoked"}: return f"Slack attachment access failed for {file_label} because the bot token is not authorized ({error}). Refresh the token/reinstall the app." if error in {"file_not_found", "file_deleted"}: return f"Slack attachment {file_label} is no longer available ({error})." - if error in {"access_denied", "file_access_denied", "no_permission", "not_allowed_token_type", "restricted_action"}: + if error in { + "access_denied", + "file_access_denied", + "no_permission", + "not_allowed_token_type", + "restricted_action", + }: return f"Slack attachment access failed for {file_label} because the bot does not have permission ({error}). Check workspace permissions/scopes and reinstall if needed." return None - def _describe_slack_download_failure(self, exc: Exception, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]: + def _describe_slack_download_failure( + self, exc: Exception, *, file_obj: Optional[Dict[str, Any]] = None + ) -> Optional[str]: """Translate Slack download exceptions into user-facing attachment diagnostics.""" - file_label = str((file_obj or {}).get("name") or (file_obj or {}).get("id") or "this attachment") + file_label = str( + (file_obj or {}).get("name") + or (file_obj or {}).get("id") + or "this attachment" + ) response = getattr(exc, "response", None) api_detail = self._describe_slack_api_error(response, file_obj=file_obj) @@ -399,7 +615,10 @@ class SlackAdapter(BasePlatformAdapter): return f"Slack attachment {file_label} returned HTTP 404 and is no longer reachable." message = str(exc) - if "Slack returned HTML instead of media" in message or "non-image data" in message: + if ( + "Slack returned HTML instead of media" in message + or "non-image data" in message + ): return ( f"Slack attachment access failed for {file_label}: Slack returned an HTML/login or non-media response. " "This usually means a scope, auth, or file-permission problem." @@ -415,7 +634,8 @@ class SlackAdapter(BasePlatformAdapter): # as ephemeral if the command handler was slow or dropped. def _pop_slash_context( - self, chat_id: str, + self, + chat_id: str, ) -> Optional[Dict[str, Any]]: """Return and remove the slash-command context for *chat_id*, if fresh. @@ -431,7 +651,8 @@ class SlackAdapter(BasePlatformAdapter): now = time.monotonic() # Clean up stale entries on every lookup — dict is small. stale_keys = [ - k for k, v in self._slash_command_contexts.items() + k + for k, v in self._slash_command_contexts.items() if now - v["ts"] > self._SLASH_CTX_TTL ] for k in stale_keys: @@ -498,7 +719,8 @@ class SlackAdapter(BasePlatformAdapter): ) except Exception as e: logger.warning( - "[Slack] response_url POST failed: %s", e, + "[Slack] response_url POST failed: %s", + e, ) # Non-fatal — the user saw the initial ack already. return SendResult(success=True, message_id=None) @@ -523,13 +745,17 @@ class SlackAdapter(BasePlatformAdapter): proxy_url = _resolve_slack_proxy_url() if proxy_url: - logger.info("[Slack] Using proxy for Slack transport: %s", safe_url_for_log(proxy_url)) + logger.info( + "[Slack] Using proxy for Slack transport: %s", + safe_url_for_log(proxy_url), + ) # Support comma-separated bot tokens for multi-workspace bot_tokens = [t.strip() for t in raw_token.split(",") if t.strip()] # Also load tokens from OAuth token file from hermes_constants import get_hermes_home + tokens_file = get_hermes_home() / "slack_tokens.json" if tokens_file.exists(): try: @@ -538,16 +764,44 @@ class SlackAdapter(BasePlatformAdapter): tok = entry.get("token", "") if isinstance(entry, dict) else "" if tok and tok not in bot_tokens: bot_tokens.append(tok) - team_label = entry.get("team_name", team_id) if isinstance(entry, dict) else team_id - logger.info("[Slack] Loaded saved token for workspace %s", team_label) + team_label = ( + entry.get("team_name", team_id) + if isinstance(entry, dict) + else team_id + ) + logger.info( + "[Slack] Loaded saved token for workspace %s", team_label + ) except Exception as e: logger.warning("[Slack] Failed to read %s: %s", tokens_file, e) lock_acquired = False try: - if not self._acquire_platform_lock('slack-app-token', app_token, 'Slack app token'): + if not self._acquire_platform_lock( + "slack-app-token", app_token, "Slack app token" + ): return False lock_acquired = True + self._running = False + + # Tear down any prior reconnect state before flipping ``_running`` + # back on. We must cancel + await the existing watchdog (not just + # check ``task.done()`` later) so an old watchdog can't observe + # ``_running=False``, exit, and then leave us with no monitor when + # ``_ensure_socket_watchdog`` runs before the new task is visible. + watchdog_task = self._socket_watchdog_task + self._socket_watchdog_task = None + if watchdog_task is not None and not watchdog_task.done(): + watchdog_task.cancel() + try: + await watchdog_task + except asyncio.CancelledError: + pass + except Exception: # pragma: no cover - defensive logging + logger.debug( + "[Slack] Prior watchdog task failed while stopping", + exc_info=True, + ) # Close any previous handler before creating a new one so that # calling connect() a second time (e.g. during a gateway restart or @@ -555,14 +809,18 @@ class SlackAdapter(BasePlatformAdapter): # connection alive. Both the old and new connections would otherwise # receive every Slack event and dispatch it twice, producing double # responses — the same bug that affected DiscordAdapter (#18187). - if self._handler is not None: - try: - await self._handler.close_async() - except Exception: - logger.debug("[%s] Failed to close previous Slack handler", self.name) - finally: - self._handler = None - self._app = None + await self._stop_socket_mode_handler() + self._app = None + self._app_token = app_token + self._proxy_url = proxy_url + + # Reset multi-workspace state before re-populating it so a + # reconnect that drops a workspace (or rotates the primary bot + # token) doesn't carry stale ``_bot_user_id`` / ``_team_clients`` + # / ``_team_bot_user_ids`` entries from the prior session. + self._bot_user_id = None + self._team_clients = {} + self._team_bot_user_ids = {} # First token is the primary — used for AsyncApp / Socket Mode primary_token = bot_tokens[0] @@ -582,13 +840,17 @@ class SlackAdapter(BasePlatformAdapter): self._team_clients[team_id] = client self._team_bot_user_ids[team_id] = bot_user_id - # First token sets the primary bot_user_id (backward compat) + # First token always wins as the primary bot user id; we + # cleared ``_bot_user_id`` above so this picks up the current + # token's identity even on reconnect. if self._bot_user_id is None: self._bot_user_id = bot_user_id logger.info( "[Slack] Authenticated as @%s in workspace %s (team: %s)", - bot_name, team_name, team_id, + bot_name, + team_name, + team_id, ) # Register message event handler @@ -681,12 +943,25 @@ class SlackAdapter(BasePlatformAdapter): ): self._app.action(_action_id)(self._handle_slash_confirm_action) - # Start Socket Mode handler in background - self._handler = AsyncSocketModeHandler(self._app, app_token, proxy=proxy_url) - _apply_slack_proxy(self._handler.client, proxy_url) - self._socket_mode_task = asyncio.create_task(self._handler.start_async()) + # Bring up the handler and watchdog atomically. ``_running`` only + # flips to True after the handler is alive so the watchdog loop + # observes the live task immediately; on any failure here we tear + # down whatever we managed to start, leave ``_running=False``, and + # let the ``finally`` block release the platform lock cleanly. + try: + self._start_socket_mode_handler() + self._running = True + self._ensure_socket_watchdog() + except Exception: + self._running = False + try: + await self._stop_socket_mode_handler() + except Exception: # pragma: no cover - defensive logging + logger.debug( + "[Slack] Cleanup after failed start raised", exc_info=True + ) + raise - self._running = True logger.info( "[Slack] Socket Mode connected (%d workspace(s))", len(self._team_clients), @@ -720,30 +995,54 @@ class SlackAdapter(BasePlatformAdapter): client = self._get_client(parent_chat_id) if client is None: return None - seed_text = f":thread: Hermes handoff — *{(name or 'session').strip()[:80]}*" + seed_text = ( + f":thread: Hermes handoff — *{(name or 'session').strip()[:80]}*" + ) result = await client.chat_postMessage( channel=parent_chat_id, text=seed_text, ) - ts = result.get("ts") if isinstance(result, dict) else getattr(result, "get", lambda _k, _d=None: None)("ts") + ts = ( + result.get("ts") + if isinstance(result, dict) + else getattr(result, "get", lambda _k, _d=None: None)("ts") + ) if ts: return str(ts) except Exception as exc: logger.warning( "[%s] Handoff thread: seed-post failed for channel %s: %s", - self.name, parent_chat_id, exc, + self.name, + parent_chat_id, + exc, ) return None async def disconnect(self) -> None: """Disconnect from Slack.""" - if self._handler: - try: - await self._handler.close_async() - except Exception as e: # pragma: no cover - defensive logging - logger.warning("[Slack] Error while closing Socket Mode handler: %s", e, exc_info=True) self._running = False + watchdog_task = self._socket_watchdog_task + self._socket_watchdog_task = None + if watchdog_task is not None and not watchdog_task.done(): + watchdog_task.cancel() + try: + await watchdog_task + except asyncio.CancelledError: + pass + except Exception: # pragma: no cover - defensive logging + # Watchdog may have lost the cancellation race and exited with + # an unrelated exception. Log and continue so handler cleanup + # and lock release still happen. + logger.debug( + "[Slack] Watchdog task raised during disconnect", exc_info=True + ) + + await self._stop_socket_mode_handler() + self._app = None + self._app_token = None + self._proxy_url = None + self._release_platform_lock() logger.info("[Slack] Disconnected") @@ -775,7 +1074,8 @@ class SlackAdapter(BasePlatformAdapter): slash_ctx = self._pop_slash_context(chat_id) if slash_ctx: return await self._send_slash_ephemeral( - slash_ctx, content, + slash_ctx, + content, ) # Convert standard markdown → Slack mrkdwn @@ -1070,7 +1370,7 @@ class SlackAdapter(BasePlatformAdapter): thread_ts = self._resolve_thread_ts(None, metadata) CHUNK = 10 - chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)] + chunks = [images[i : i + CHUNK] for i in range(0, len(images), CHUNK)] for chunk_idx, chunk in enumerate(chunks): if human_delay > 0 and chunk_idx > 0: @@ -1079,7 +1379,9 @@ class SlackAdapter(BasePlatformAdapter): file_uploads: List[Dict[str, Any]] = [] initial_comment_parts: List[str] = [] try: - async with _httpx.AsyncClient(timeout=30.0, follow_redirects=True) as http_client: + async with _httpx.AsyncClient( + timeout=30.0, follow_redirects=True + ) as http_client: for image_url, alt_text in chunk: if alt_text: initial_comment_parts.append(alt_text) @@ -1087,15 +1389,21 @@ class SlackAdapter(BasePlatformAdapter): if image_url.startswith("file://"): local_path = _unquote(image_url[7:]) if not os.path.exists(local_path): - logger.warning("[Slack] Skipping missing image: %s", local_path) + logger.warning( + "[Slack] Skipping missing image: %s", local_path + ) continue - file_uploads.append({ - "file": local_path, - "filename": os.path.basename(local_path), - }) + file_uploads.append( + { + "file": local_path, + "filename": os.path.basename(local_path), + } + ) else: if not _is_safe_url(image_url): - logger.warning("[Slack] Blocked unsafe image URL in batch") + logger.warning( + "[Slack] Blocked unsafe image URL in batch" + ) continue try: response = await http_client.get(image_url) @@ -1108,24 +1416,31 @@ class SlackAdapter(BasePlatformAdapter): ext = "gif" elif "webp" in ct: ext = "webp" - file_uploads.append({ - "content": response.content, - "filename": f"image_{len(file_uploads)}.{ext}", - }) + file_uploads.append( + { + "content": response.content, + "filename": f"image_{len(file_uploads)}.{ext}", + } + ) except Exception as dl_err: logger.warning( "[Slack] Download failed for %s: %s", - safe_url_for_log(image_url), dl_err, + safe_url_for_log(image_url), + dl_err, ) continue if not file_uploads: continue - initial_comment = "\n".join(initial_comment_parts) if initial_comment_parts else "" + initial_comment = ( + "\n".join(initial_comment_parts) if initial_comment_parts else "" + ) logger.info( "[Slack] Sending %d image(s) in single files_upload_v2 (chunk %d/%d)", - len(file_uploads), chunk_idx + 1, len(chunks), + len(file_uploads), + chunk_idx + 1, + len(chunks), ) result = await self._get_client(chat_id).files_upload_v2( channel=chat_id, @@ -1138,12 +1453,18 @@ class SlackAdapter(BasePlatformAdapter): except Exception as e: logger.warning( "[Slack] Multi-image files_upload_v2 failed (chunk %d/%d), falling back to per-image: %s", - chunk_idx + 1, len(chunks), e, + chunk_idx + 1, + len(chunks), + e, exc_info=True, ) - await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay) + await super().send_multiple_images( + chat_id, chunk, metadata, human_delay=human_delay + ) - def _record_uploaded_file_thread(self, chat_id: str, thread_ts: Optional[str]) -> None: + def _record_uploaded_file_thread( + self, chat_id: str, thread_ts: Optional[str] + ) -> None: """Treat successful file uploads as bot participation in a thread.""" if not thread_ts: return @@ -1160,15 +1481,21 @@ class SlackAdapter(BasePlatformAdapter): return status_code == 429 or status_code >= 500 body = " ".join( - str(part) for part in ( + str(part) + for part in ( exc, getattr(exc, "message", ""), getattr(exc, "response", None), - ) if part + ) + if part ).lower() if "rate_limited" in body or "ratelimited" in body or "429" in body: return True - if "connection reset" in body or "service unavailable" in body or "temporarily unavailable" in body: + if ( + "connection reset" in body + or "service unavailable" in body + or "temporarily unavailable" in body + ): return True return self._is_retryable_error(body) @@ -1198,24 +1525,24 @@ class SlackAdapter(BasePlatformAdapter): # 1) Protect fenced code blocks (``` ... ```) text = re.sub( - r'(```(?:[^\n]*\n)?[\s\S]*?```)', + r"(```(?:[^\n]*\n)?[\s\S]*?```)", lambda m: _ph(m.group(0)), text, ) # 2) Protect inline code (`...`) - text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text) + text = re.sub(r"(`[^`]+`)", lambda m: _ph(m.group(0)), text) # 3) Convert markdown links [text](url) → def _convert_markdown_link(m): label = m.group(1) url = m.group(2).strip() - if url.startswith('<') and url.endswith('>'): + if url.startswith("<") and url.endswith(">"): url = url[1:-1].strip() - return _ph(f'<{url}|{label}>') + return _ph(f"<{url}|{label}>") text = re.sub( - r'(?\n]+>)', + r"(<(?:[@#!]|(?:https?|mailto|tel):)[^>\n]+>)", lambda m: _ph(m.group(1)), text, ) # 5) Protect blockquote markers before escaping - text = re.sub(r'^(>+\s)', lambda m: _ph(m.group(0)), text, flags=re.MULTILINE) + text = re.sub(r"^(>+\s)", lambda m: _ph(m.group(0)), text, flags=re.MULTILINE) # 6) Escape Slack control characters in remaining plain text. # Unescape first so already-escaped input doesn't get double-escaped. - text = text.replace('&', '&').replace('<', '<').replace('>', '>') - text = text.replace('&', '&').replace('<', '<').replace('>', '>') + text = text.replace("&", "&").replace("<", "<").replace(">", ">") + text = text.replace("&", "&").replace("<", "<").replace(">", ">") # 7) Convert headers (## Title) → *Title* (bold) def _convert_header(m): inner = m.group(1).strip() # Strip redundant bold markers inside a header - inner = re.sub(r'\*\*(.+?)\*\*', r'\1', inner) - return _ph(f'*{inner}*') + inner = re.sub(r"\*\*(.+?)\*\*", r"\1", inner) + return _ph(f"*{inner}*") - text = re.sub( - r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE - ) + text = re.sub(r"^#{1,6}\s+(.+)$", _convert_header, text, flags=re.MULTILINE) # 8) Convert bold+italic: ***text*** → *_text_* (Slack bold wrapping italic) text = re.sub( - r'\*\*\*(.+?)\*\*\*', - lambda m: _ph(f'*_{m.group(1)}_*'), + r"\*\*\*(.+?)\*\*\*", + lambda m: _ph(f"*_{m.group(1)}_*"), text, ) # 9) Convert bold: **text** → *text* (Slack bold) text = re.sub( - r'\*\*(.+?)\*\*', - lambda m: _ph(f'*{m.group(1)}*'), + r"\*\*(.+?)\*\*", + lambda m: _ph(f"*{m.group(1)}*"), text, ) @@ -1266,15 +1591,15 @@ class SlackAdapter(BasePlatformAdapter): # emphasized text touches non-whitespace on both sides so literal # delimiters like "a * b * c" are preserved. text = re.sub( - r'(? bool: + async def _add_reaction(self, channel: str, timestamp: str, emoji: str) -> bool: """Add an emoji reaction to a message. Returns True on success.""" if not self._app: return False @@ -1304,9 +1627,7 @@ class SlackAdapter(BasePlatformAdapter): logger.debug("[Slack] reactions.add failed (%s): %s", emoji, e) return False - async def _remove_reaction( - self, channel: str, timestamp: str, emoji: str - ) -> bool: + async def _remove_reaction(self, channel: str, timestamp: str, emoji: str) -> bool: """Remove an emoji reaction from a message. Returns True on success.""" if not self._app: return False @@ -1334,7 +1655,9 @@ class SlackAdapter(BasePlatformAdapter): if channel_id: await self._add_reaction(channel_id, ts, "eyes") - async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None: + async def on_processing_complete( + self, event: MessageEvent, outcome: ProcessingOutcome + ) -> None: """Swap the in-progress reaction for a final success/failure reaction.""" if not self._reactions_enabled(): return @@ -1393,9 +1716,13 @@ class SlackAdapter(BasePlatformAdapter): ) -> SendResult: """Send a local image file to Slack by uploading it.""" try: - return await self._upload_file(chat_id, image_path, caption, reply_to, metadata) + return await self._upload_file( + chat_id, image_path, caption, reply_to, metadata + ) except FileNotFoundError: - return SendResult(success=False, error=f"Image file not found: {image_path}") + return SendResult( + success=False, error=f"Image file not found: {image_path}" + ) except Exception as e: # pragma: no cover - defensive logging logger.error( "[%s] Failed to send local Slack image %s: %s", @@ -1422,9 +1749,12 @@ class SlackAdapter(BasePlatformAdapter): return SendResult(success=False, error="Not connected") from tools.url_safety import is_safe_url + if not is_safe_url(image_url): logger.warning("[Slack] Blocked unsafe image URL (SSRF protection)") - return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata) + return await super().send_image( + chat_id, image_url, caption, reply_to, metadata=metadata + ) try: import httpx @@ -1484,9 +1814,13 @@ class SlackAdapter(BasePlatformAdapter): ) -> SendResult: """Send an audio file to Slack.""" try: - return await self._upload_file(chat_id, audio_path, caption, reply_to, metadata) + return await self._upload_file( + chat_id, audio_path, caption, reply_to, metadata + ) except FileNotFoundError: - return SendResult(success=False, error=f"Audio file not found: {audio_path}") + return SendResult( + success=False, error=f"Audio file not found: {audio_path}" + ) except Exception as e: # pragma: no cover - defensive logging logger.error( "[Slack] Failed to send audio file %s: %s", @@ -1509,7 +1843,9 @@ class SlackAdapter(BasePlatformAdapter): return SendResult(success=False, error="Not connected") if not os.path.exists(video_path): - return SendResult(success=False, error=f"Video file not found: {video_path}") + return SendResult( + success=False, error=f"Video file not found: {video_path}" + ) try: thread_ts = self._resolve_thread_ts(reply_to, metadata) @@ -1635,7 +1971,9 @@ class SlackAdapter(BasePlatformAdapter): # ----- Internal handlers ----- - def _assistant_thread_key(self, channel_id: str, thread_ts: str) -> Optional[Tuple[str, str]]: + def _assistant_thread_key( + self, channel_id: str, thread_ts: str + ) -> Optional[Tuple[str, str]]: """Return a stable cache key for Slack assistant thread metadata.""" if not channel_id or not thread_ts: return None @@ -1809,11 +2147,16 @@ class SlackAdapter(BasePlatformAdapter): if original_text.startswith("!"): try: from hermes_cli.commands import is_gateway_known_command + first_token = original_text[1:].split(maxsplit=1)[0] # Strip "@suffix" the same way get_command() does, so # forms like ``!stop@hermes`` still resolve. cmd_name = first_token.split("@", 1)[0].lower() - if cmd_name and "/" not in cmd_name and is_gateway_known_command(cmd_name): + if ( + cmd_name + and "/" not in cmd_name + and is_gateway_known_command(cmd_name) + ): original_text = "/" + original_text[1:] except Exception: # pragma: no cover - defensive pass @@ -1966,7 +2309,9 @@ class SlackAdapter(BasePlatformAdapter): # Check allowed channels — if set, only respond in these channels (whitelist) allowed_channels = self._slack_allowed_channels() if allowed_channels and channel_id not in allowed_channels: - logger.debug("[Slack] Ignoring message in non-allowed channel: %s", channel_id) + logger.debug( + "[Slack] Ignoring message in non-allowed channel: %s", channel_id + ) return if channel_id in self._slack_free_response_channels(): @@ -1983,15 +2328,16 @@ class SlackAdapter(BasePlatformAdapter): event_thread_ts is not None and event_thread_ts in self._mentioned_threads ) - has_session = ( - is_thread_reply - and self._has_active_session_for_thread( - channel_id=channel_id, - thread_ts=event_thread_ts, - user_id=user_id, - ) + has_session = is_thread_reply and self._has_active_session_for_thread( + channel_id=channel_id, + thread_ts=event_thread_ts, + user_id=user_id, ) - if not reply_to_bot_thread and not in_mentioned_thread and not has_session: + if ( + not reply_to_bot_thread + and not in_mentioned_thread + and not has_session + ): return if is_mentioned: @@ -2004,7 +2350,9 @@ class SlackAdapter(BasePlatformAdapter): if event_thread_ts and not self._slack_strict_mention(): self._mentioned_threads.add(event_thread_ts) if len(self._mentioned_threads) > self._MENTIONED_THREADS_MAX: - to_remove = list(self._mentioned_threads)[:self._MENTIONED_THREADS_MAX // 2] + to_remove = list(self._mentioned_threads)[ + : self._MENTIONED_THREADS_MAX // 2 + ] for t in to_remove: self._mentioned_threads.discard(t) @@ -2045,7 +2393,9 @@ class SlackAdapter(BasePlatformAdapter): if not file_id: continue try: - info_resp = await self._get_client(channel_id).files_info(file=file_id) + info_resp = await self._get_client(channel_id).files_info( + file=file_id + ) if info_resp.get("ok"): f = info_resp["file"] else: @@ -2056,7 +2406,8 @@ class SlackAdapter(BasePlatformAdapter): else: logger.warning( "[Slack] files.info failed for %s: %s", - file_id, info_resp.get("error"), + file_id, + info_resp.get("error"), ) continue except Exception as e: @@ -2066,7 +2417,12 @@ class SlackAdapter(BasePlatformAdapter): attachment_notices.append(detail) logger.warning("[Slack] %s", detail) else: - logger.warning("[Slack] files.info error for %s: %s", file_id, e, exc_info=True) + logger.warning( + "[Slack] files.info error for %s: %s", + file_id, + e, + exc_info=True, + ) continue mimetype = f.get("mimetype", "unknown") @@ -2086,13 +2442,20 @@ class SlackAdapter(BasePlatformAdapter): attachment_notices.append(detail) logger.warning("[Slack] %s", detail) else: - logger.warning("[Slack] Failed to cache image from %s: %s", url, e, exc_info=True) + logger.warning( + "[Slack] Failed to cache image from %s: %s", + url, + e, + exc_info=True, + ) elif mimetype.startswith("audio/") and url: try: ext = "." + mimetype.split("/")[-1].split(";")[0] if ext not in {".ogg", ".mp3", ".wav", ".webm", ".m4a"}: ext = ".ogg" - cached = await self._download_slack_file(url, ext, audio=True, team_id=team_id) + cached = await self._download_slack_file( + url, ext, audio=True, team_id=team_id + ) media_urls.append(cached) media_types.append(mimetype) except Exception as e: # pragma: no cover - defensive logging @@ -2101,7 +2464,12 @@ class SlackAdapter(BasePlatformAdapter): attachment_notices.append(detail) logger.warning("[Slack] %s", detail) else: - logger.warning("[Slack] Failed to cache audio from %s: %s", url, e, exc_info=True) + logger.warning( + "[Slack] Failed to cache audio from %s: %s", + url, + e, + exc_info=True, + ) elif url: # Try to handle as a document attachment try: @@ -2113,7 +2481,9 @@ class SlackAdapter(BasePlatformAdapter): # Fallback: reverse-lookup from MIME type if not ext and mimetype: - mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()} + mime_to_ext = { + v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items() + } ext = mime_to_ext.get(mimetype, "") if ext not in SUPPORTED_DOCUMENT_TYPES: @@ -2123,11 +2493,15 @@ class SlackAdapter(BasePlatformAdapter): file_size = f.get("size", 0) MAX_DOC_BYTES = 20 * 1024 * 1024 if not file_size or file_size > MAX_DOC_BYTES: - logger.warning("[Slack] Document too large or unknown size: %s", file_size) + logger.warning( + "[Slack] Document too large or unknown size: %s", file_size + ) continue # Download and cache - raw_bytes = await self._download_slack_file_bytes(url, team_id=team_id) + raw_bytes = await self._download_slack_file_bytes( + url, team_id=team_id + ) cached_path = cache_document_from_bytes( raw_bytes, original_filename or f"document{ext}" ) @@ -2140,14 +2514,26 @@ class SlackAdapter(BasePlatformAdapter): # snippets like JSON/YAML/configs are actually visible to the agent. MAX_TEXT_INJECT_BYTES = 100 * 1024 TEXT_INJECT_EXTENSIONS = { - ".md", ".txt", ".csv", ".log", ".json", ".xml", - ".yaml", ".yml", ".toml", ".ini", ".cfg", + ".md", + ".txt", + ".csv", + ".log", + ".json", + ".xml", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", } - if ext in TEXT_INJECT_EXTENSIONS and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + if ( + ext in TEXT_INJECT_EXTENSIONS + and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES + ): try: text_content = raw_bytes.decode("utf-8") display_name = original_filename or f"document{ext}" - display_name = re.sub(r'[^\w.\- ]', '_', display_name) + display_name = re.sub(r"[^\w.\- ]", "_", display_name) injection = f"[Content of {display_name}]:\n{text_content}" if text: text = f"{injection}\n\n{text}" @@ -2162,10 +2548,17 @@ class SlackAdapter(BasePlatformAdapter): attachment_notices.append(detail) logger.warning("[Slack] %s", detail) else: - logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True) + logger.warning( + "[Slack] Failed to cache document from %s: %s", + url, + e, + exc_info=True, + ) if attachment_notices: - notice_block = "[Slack attachment notice]\n" + "\n".join(f"- {n}" for n in attachment_notices) + notice_block = "[Slack attachment notice]\n" + "\n".join( + f"- {n}" for n in attachment_notices + ) text = f"{notice_block}\n\n{text}" if text else notice_block if msg_type != MessageType.COMMAND and media_types: @@ -2190,12 +2583,20 @@ class SlackAdapter(BasePlatformAdapter): ) # Per-channel ephemeral prompt - from gateway.platforms.base import resolve_channel_prompt, resolve_channel_skills + from gateway.platforms.base import ( + resolve_channel_prompt, + resolve_channel_skills, + ) + _channel_prompt = resolve_channel_prompt( - self.config.extra, channel_id, None, + self.config.extra, + channel_id, + None, ) _auto_skill = resolve_channel_skills( - self.config.extra, channel_id, None, + self.config.extra, + channel_id, + None, ) # Extract reply context if this message is a thread reply. @@ -2206,11 +2607,14 @@ class SlackAdapter(BasePlatformAdapter): reply_to_text = None if thread_ts and thread_ts != ts: try: - reply_to_text = await self._fetch_thread_parent_text( - channel_id=channel_id, - thread_ts=thread_ts, - team_id=team_id, - ) or None + reply_to_text = ( + await self._fetch_thread_parent_text( + channel_id=channel_id, + thread_ts=thread_ts, + team_id=team_id, + ) + or None + ) except Exception: # pragma: no cover - defensive reply_to_text = None @@ -2240,7 +2644,10 @@ class SlackAdapter(BasePlatformAdapter): # ----- Approval button support (Block Kit) ----- async def send_exec_approval( - self, chat_id: str, command: str, session_key: str, + self, + chat_id: str, + command: str, + session_key: str, description: str = "dangerous command", metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: @@ -2320,8 +2727,13 @@ class SlackAdapter(BasePlatformAdapter): return SendResult(success=False, error=str(e)) async def send_slash_confirm( - self, chat_id: str, title: str, message: str, session_key: str, - confirm_id: str, metadata: Optional[Dict[str, Any]] = None, + self, + chat_id: str, + title: str, + message: str, + session_key: str, + confirm_id: str, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a Block Kit three-option slash-command confirmation prompt.""" if not self._app: @@ -2378,7 +2790,9 @@ class SlackAdapter(BasePlatformAdapter): kwargs["thread_ts"] = thread_ts result = await self._get_client(chat_id).chat_postMessage(**kwargs) - return SendResult(success=True, message_id=result.get("ts", ""), raw_response=result) + return SendResult( + success=True, message_id=result.get("ts", ""), raw_response=result + ) except Exception as e: logger.error("[Slack] send_slash_confirm failed: %s", e, exc_info=True) return SendResult(success=False, error=str(e)) @@ -2402,7 +2816,8 @@ class SlackAdapter(BasePlatformAdapter): if "*" not in allowed_ids and user_id not in allowed_ids: logger.warning( "[Slack] Unauthorized slash-confirm click by %s (%s) — ignoring", - user_name, user_id, + user_name, + user_id, ) return @@ -2463,7 +2878,10 @@ class SlackAdapter(BasePlatformAdapter): # Resolve via the module-level primitive and post any follow-up. try: from tools import slash_confirm as _slash_confirm_mod - result_text = await _slash_confirm_mod.resolve(session_key, confirm_id, choice) + + result_text = await _slash_confirm_mod.resolve( + session_key, confirm_id, choice + ) if result_text: post_kwargs: Dict[str, Any] = { "channel": channel_id, @@ -2476,10 +2894,16 @@ class SlackAdapter(BasePlatformAdapter): await self._get_client(channel_id).chat_postMessage(**post_kwargs) logger.info( "Slack button resolved slash-confirm for session %s (choice=%s, user=%s)", - session_key, choice, user_name, + session_key, + choice, + user_name, ) except Exception as exc: - logger.error("Failed to resolve slash-confirm from Slack button: %s", exc, exc_info=True) + logger.error( + "Failed to resolve slash-confirm from Slack button: %s", + exc, + exc_info=True, + ) async def _handle_approval_action(self, ack, body, action) -> None: """Handle an approval button click from Block Kit.""" @@ -2502,7 +2926,8 @@ class SlackAdapter(BasePlatformAdapter): if "*" not in allowed_ids and user_id not in allowed_ids: logger.warning( "[Slack] Unauthorized approval click by %s (%s) — ignoring", - user_name, user_id, + user_name, + user_id, ) return @@ -2564,21 +2989,31 @@ class SlackAdapter(BasePlatformAdapter): # Resolve the approval — this unblocks the agent thread try: from tools.approval import resolve_gateway_approval + count = resolve_gateway_approval(session_key, choice) logger.info( "Slack button resolved %d approval(s) for session %s (choice=%s, user=%s)", - count, session_key, choice, user_name, + count, + session_key, + choice, + user_name, ) except Exception as exc: - logger.error("Failed to resolve gateway approval from Slack button: %s", exc) + logger.error( + "Failed to resolve gateway approval from Slack button: %s", exc + ) # (approval state already consumed by atomic pop above) # ----- Thread context fetching ----- async def _fetch_thread_context( - self, channel_id: str, thread_ts: str, current_ts: str, - team_id: str = "", limit: int = 30, + self, + channel_id: str, + thread_ts: str, + current_ts: str, + team_id: str = "", + limit: int = 30, ) -> str: """Fetch recent thread messages to provide context when the bot is mentioned mid-thread for the first time. @@ -2624,10 +3059,11 @@ class SlackAdapter(BasePlatformAdapter): or "rate_limited" in err_str ) if is_rate_limit and attempt < 2: - retry_after = 1.0 * (2 ** attempt) # 1s, 2s + retry_after = 1.0 * (2**attempt) # 1s, 2s logger.warning( "[Slack] conversations.replies rate limited; retrying in %.1fs (attempt %d/3)", - retry_after, attempt + 1, + retry_after, + attempt + 1, ) await asyncio.sleep(retry_after) continue @@ -2657,9 +3093,7 @@ class SlackAdapter(BasePlatformAdapter): # Identify "our own" bot for this workspace (multi-workspace safe). msg_team = msg.get("team") or team_id self_bot_uid = ( - self._team_bot_user_ids.get(msg_team) - if msg_team - else None + self._team_bot_user_ids.get(msg_team) if msg_team else None ) or self._bot_user_id # Exclude only our own prior bot replies (circular context). @@ -2714,7 +3148,10 @@ class SlackAdapter(BasePlatformAdapter): return "" async def _fetch_thread_parent_text( - self, channel_id: str, thread_ts: str, team_id: str = "", + self, + channel_id: str, + thread_ts: str, + team_id: str = "", ) -> str: """Return the raw text of the thread parent message (for reply_to_text). @@ -2783,6 +3220,7 @@ class SlackAdapter(BasePlatformAdapter): # Empty slash_name falls into this branch for backward compat # with any caller that didn't populate command["command"]. from hermes_cli.commands import slack_subcommand_map + subcommand_map = slack_subcommand_map() subcommand_map["compact"] = "/compress" # Guard against whitespace-only text where ``text`` is truthy but @@ -2790,8 +3228,12 @@ class SlackAdapter(BasePlatformAdapter): parts = text.split() if text else [] first_word = parts[0] if parts else "" if first_word in subcommand_map: - rest = text[len(first_word):].strip() - text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word] + rest = text[len(first_word) :].strip() + text = ( + f"{subcommand_map[first_word]} {rest}".strip() + if rest + else subcommand_map[first_word] + ) elif text: pass # Treat as a regular question else: @@ -2814,7 +3256,9 @@ class SlackAdapter(BasePlatformAdapter): event = MessageEvent( text=text, - message_type=MessageType.COMMAND if text.startswith("/") else MessageType.TEXT, + message_type=( + MessageType.COMMAND if text.startswith("/") else MessageType.TEXT + ), source=source, raw_message=command, ) @@ -2873,8 +3317,16 @@ class SlackAdapter(BasePlatformAdapter): # Read session isolation settings from the store's config store_cfg = getattr(session_store, "config", None) - gspu = getattr(store_cfg, "group_sessions_per_user", True) if store_cfg else True - tspu = getattr(store_cfg, "thread_sessions_per_user", False) if store_cfg else False + gspu = ( + getattr(store_cfg, "group_sessions_per_user", True) + if store_cfg + else True + ) + tspu = ( + getattr(store_cfg, "thread_sessions_per_user", False) + if store_cfg + else False + ) session_key = build_session_key( source, @@ -2887,11 +3339,17 @@ class SlackAdapter(BasePlatformAdapter): except Exception: return False - async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str: + async def _download_slack_file( + self, url: str, ext: str, audio: bool = False, team_id: str = "" + ) -> str: """Download a Slack file using the bot token for auth, with retry.""" import httpx - bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token + bot_token = ( + self._team_clients[team_id].token + if team_id and team_id in self._team_clients + else self.config.token + ) async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: for attempt in range(3): @@ -2916,16 +3374,25 @@ class SlackAdapter(BasePlatformAdapter): if audio: from gateway.platforms.base import cache_audio_from_bytes + return cache_audio_from_bytes(response.content, ext) else: from gateway.platforms.base import cache_image_from_bytes + return cache_image_from_bytes(response.content, ext) except (httpx.TimeoutException, httpx.HTTPStatusError) as exc: - if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429: + if ( + isinstance(exc, httpx.HTTPStatusError) + and exc.response.status_code < 429 + ): raise if attempt < 2: - logger.debug("Slack file download retry %d/2 for %s: %s", - attempt + 1, url[:80], exc) + logger.debug( + "Slack file download retry %d/2 for %s: %s", + attempt + 1, + url[:80], + exc, + ) await asyncio.sleep(1.5 * (attempt + 1)) continue raise @@ -2934,7 +3401,11 @@ class SlackAdapter(BasePlatformAdapter): """Download a Slack file and return raw bytes, with retry.""" import httpx - bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token + bot_token = ( + self._team_clients[team_id].token + if team_id and team_id in self._team_clients + else self.config.token + ) async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: for attempt in range(3): @@ -2952,14 +3423,25 @@ class SlackAdapter(BasePlatformAdapter): "check bot token scopes and file permissions" ) return response.content - except (httpx.TimeoutException, httpx.HTTPStatusError, ValueError) as exc: - if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429: + except ( + httpx.TimeoutException, + httpx.HTTPStatusError, + ValueError, + ) as exc: + if ( + isinstance(exc, httpx.HTTPStatusError) + and exc.response.status_code < 429 + ): raise if isinstance(exc, ValueError): raise if attempt < 2: - logger.debug("Slack file download retry %d/2 for %s: %s", - attempt + 1, url[:80], exc) + logger.debug( + "Slack file download retry %d/2 for %s: %s", + attempt + 1, + url[:80], + exc, + ) await asyncio.sleep(1.5 * (attempt + 1)) continue raise @@ -2978,7 +3460,12 @@ class SlackAdapter(BasePlatformAdapter): if isinstance(configured, str): return configured.lower() not in {"false", "0", "no", "off"} return bool(configured) - return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no", "off"} + return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in { + "false", + "0", + "no", + "off", + } def _slack_strict_mention(self) -> bool: """When true, channel threads require an explicit @-mention on every @@ -2990,7 +3477,12 @@ class SlackAdapter(BasePlatformAdapter): if isinstance(configured, str): return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv("SLACK_STRICT_MENTION", "false").lower() in {"true", "1", "yes", "on"} + return os.getenv("SLACK_STRICT_MENTION", "false").lower() in { + "true", + "1", + "yes", + "on", + } def _slack_free_response_channels(self) -> set: """Return channel IDs where no @mention is required.""" diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index ef57d5ce9fe..7c499166b17 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -31,11 +31,18 @@ from hermes_cli.config import ( read_raw_config, save_env_value, ) + # display_hermes_home is imported lazily at call sites to avoid ImportError # when hermes_constants is cached from a pre-update version during `hermes update`. from hermes_cli.setup import ( - print_header, print_info, print_success, print_warning, print_error, - prompt, prompt_choice, prompt_yes_no, + print_header, + print_info, + print_success, + print_warning, + print_error, + prompt, + prompt_choice, + prompt_yes_no, ) from hermes_cli.colors import Colors, color @@ -69,6 +76,7 @@ class ProfileGatewayProcess: path: Path pid: int + def _get_service_pids() -> set: """Return PIDs currently managed by systemd or launchd gateway services. @@ -84,9 +92,17 @@ def _get_service_pids() -> set: for scope_args in [["systemctl", "--user"], ["systemctl"]]: try: result = subprocess.run( - scope_args + ["list-units", "hermes-gateway*", - "--plain", "--no-legend", "--no-pager"], - capture_output=True, text=True, timeout=5, + scope_args + + [ + "list-units", + "hermes-gateway*", + "--plain", + "--no-legend", + "--no-pager", + ], + capture_output=True, + text=True, + timeout=5, ) for line in result.stdout.strip().splitlines(): parts = line.split() @@ -95,9 +111,10 @@ def _get_service_pids() -> set: svc = parts[0] try: show = subprocess.run( - scope_args + ["show", svc, - "--property=MainPID", "--value"], - capture_output=True, text=True, timeout=5, + scope_args + ["show", svc, "--property=MainPID", "--value"], + capture_output=True, + text=True, + timeout=5, ) pid = int(show.stdout.strip()) if pid > 0: @@ -113,7 +130,9 @@ def _get_service_pids() -> set: label = get_launchd_label() result = subprocess.run( ["launchctl", "list", label], - capture_output=True, text=True, timeout=5, + capture_output=True, + text=True, + timeout=5, ) if result.returncode == 0: # Output: "PID\tStatus\tLabel" header, then one data line @@ -145,6 +164,7 @@ def _get_parent_pid(pid: int) -> int | None: return None try: import psutil # type: ignore + return psutil.Process(pid).ppid() or None except ImportError: pass @@ -196,7 +216,9 @@ def _request_gateway_self_restart(pid: int) -> bool: if not _is_pid_ancestor_of_current_process(pid): return False try: - os.kill(pid, signal.SIGUSR1) # windows-footgun: ok — POSIX signal, guarded by hasattr(signal, 'SIGUSR1') above + os.kill( + pid, signal.SIGUSR1 + ) # windows-footgun: ok — POSIX signal, guarded by hasattr(signal, 'SIGUSR1') above except (ProcessLookupError, PermissionError, OSError): return False return True @@ -232,7 +254,9 @@ def _graceful_restart_via_sigusr1(pid: int, drain_timeout: float) -> bool: if pid <= 0: return False try: - os.kill(pid, signal.SIGUSR1) # windows-footgun: ok — POSIX signal, guarded by hasattr(signal, 'SIGUSR1') above + os.kill( + pid, signal.SIGUSR1 + ) # windows-footgun: ok — POSIX signal, guarded by hasattr(signal, 'SIGUSR1') above except ProcessLookupError: # Already gone — nothing to drain. return True @@ -277,7 +301,9 @@ def _get_ancestor_pids() -> set[int]: return ancestors -def _append_unique_pid(pids: list[int], pid: int | None, exclude_pids: set[int]) -> None: +def _append_unique_pid( + pids: list[int], pid: int | None, exclude_pids: set[int] +) -> None: if pid is None or pid <= 0: return if pid == os.getpid() or pid in exclude_pids or pid in pids: @@ -305,18 +331,30 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li "hermes_cli/main.py --profile", "hermes_cli/main.py -p", "hermes gateway", + # Windows: only match invocations that actually carry the ``gateway`` + # subcommand or the gateway-dedicated console-script shim. Bare + # ``hermes.exe --profile`` / ``hermes.exe -p`` would also match + # ``hermes.exe --profile foo dashboard`` and other CLI subcommands, + # producing false-positive gateway PIDs (Copilot review). + "hermes.exe gateway", + "hermes-gateway.exe", "gateway/run.py", ] current_home = str(get_hermes_home().resolve()) + current_home_lc = current_home.lower() current_profile_arg = _profile_arg(current_home) - current_profile_name = current_profile_arg.split()[-1] if current_profile_arg else "" + current_profile_name = ( + current_profile_arg.split()[-1] if current_profile_arg else "" + ) + current_profile_name_lc = current_profile_name.lower() def _matches_current_profile(command: str) -> bool: + command_lc = command.lower() if current_profile_name: return ( - f"--profile {current_profile_name}" in command - or f"-p {current_profile_name}" in command - or f"HERMES_HOME={current_home}" in command + f"--profile {current_profile_name_lc}" in command_lc + or f"-p {current_profile_name_lc}" in command_lc + or f"hermes_home={current_home_lc}" in command_lc ) # Default-profile case: no profile flag in argv. Accept as long as @@ -324,9 +362,12 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li # may be passed via env (not visible in wmic/CIM command line) so # its absence is NOT disqualifying — only a non-matching explicit # HERMES_HOME= in argv is. - if "--profile " in command or " -p " in command: + if "--profile " in command_lc or " -p " in command_lc: return False - if "HERMES_HOME=" in command and f"HERMES_HOME={current_home}" not in command: + if ( + "hermes_home=" in command_lc + and f"hermes_home={current_home_lc}" not in command_lc + ): return False return True @@ -343,7 +384,13 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li if wmic_path is not None: try: result = subprocess.run( - [wmic_path, "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"], + [ + wmic_path, + "process", + "get", + "ProcessId,CommandLine", + "/FORMAT:LIST", + ], capture_output=True, text=True, encoding="utf-8", @@ -384,10 +431,11 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li for line in result.stdout.split("\n"): line = line.strip() if line.startswith("CommandLine="): - current_cmd = line[len("CommandLine="):] + current_cmd = line[len("CommandLine=") :] elif line.startswith("ProcessId="): - pid_str = line[len("ProcessId="):] - if any(p in current_cmd for p in patterns) and ( + pid_str = line[len("ProcessId=") :] + current_cmd_lc = current_cmd.lower() + if any(p in current_cmd_lc for p in patterns) and ( all_profiles or _matches_current_profile(current_cmd) ): try: @@ -409,9 +457,14 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li if pid == my_pid or pid in exclude_pids: continue try: - cmdline = open(f"/proc/{pid}/cmdline", "rb").read().decode("utf-8", errors="replace") + cmdline = ( + open(f"/proc/{pid}/cmdline", "rb") + .read() + .decode("utf-8", errors="replace") + ) cmdline = cmdline.replace("\x00", " ") - if any(p in cmdline for p in patterns) and ( + cmdline_lc = cmdline.lower() + if any(p in cmdline_lc for p in patterns) and ( all_profiles or _matches_current_profile(cmdline) ): _append_unique_pid(pids, pid, exclude_pids) @@ -454,7 +507,8 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li if pid is None: continue - if any(pattern in command for pattern in patterns) and ( + command_lc = command.lower() + if any(pattern in command_lc for pattern in patterns) and ( all_profiles or _matches_current_profile(command) ): _append_unique_pid(pids, pid, exclude_pids) @@ -508,7 +562,9 @@ def _filter_venv_launcher_stubs(pids: list[int]) -> list[int]: return [p for p in pids if p not in drop] -def find_gateway_pids(exclude_pids: set | None = None, all_profiles: bool = False) -> list: +def find_gateway_pids( + exclude_pids: set | None = None, all_profiles: bool = False +) -> list: """Find PIDs of running gateway processes. Args: @@ -557,7 +613,9 @@ def find_profile_gateway_processes( if pid is None or pid <= 0 or pid in _exclude or pid in seen: continue seen.add(pid) - processes.append(ProfileGatewayProcess(profile=profile.name, path=profile.path, pid=pid)) + processes.append( + ProfileGatewayProcess(profile=profile.name, path=profile.path, pid=pid) + ) return processes @@ -635,7 +693,13 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool: # Same platform-aware detach for the watcher process itself — so # closing the user's terminal doesn't kill the watcher. subprocess.Popen( - [sys.executable, "-c", watcher, str(old_pid), *_gateway_run_args_for_profile(profile)], + [ + sys.executable, + "-c", + watcher, + str(old_pid), + *_gateway_run_args_for_profile(profile), + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **windows_detach_popen_kwargs(), @@ -693,7 +757,7 @@ def _read_systemd_unit_environment(system: bool = False) -> dict[str, str]: for line in result.stdout.splitlines(): if not line.startswith("Environment="): continue - body = line[len("Environment="):].strip() + body = line[len("Environment=") :].strip() for token in body.split(): if "=" not in token: continue @@ -835,11 +899,17 @@ def _wait_for_systemd_service_restart( print(f"✓ {scope_label} service restarted (PID {new_pid})") return True if gateway_state == "startup_failed": - reason = (runtime_state or {}).get("exit_reason") or "startup failed" - print(f"⚠ {scope_label} service process restarted (PID {new_pid}), but gateway startup failed: {reason}") + reason = (runtime_state or {}).get( + "exit_reason" + ) or "startup failed" + print( + f"⚠ {scope_label} service process restarted (PID {new_pid}), but gateway startup failed: {reason}" + ) return False if not printed_runtime_wait: - print(f"⏳ {scope_label} service process started (PID {new_pid}); waiting for gateway runtime...") + print( + f"⏳ {scope_label} service process started (PID {new_pid}); waiting for gateway runtime..." + ) printed_runtime_wait = True if active_state == "activating" and sub_state == "auto-restart": @@ -895,12 +965,16 @@ def _print_systemd_start_limit_wait(system: bool = False) -> None: journal_prefix = "journalctl " if system else "journalctl --user " print(f"⏳ {scope_label} service is temporarily rate-limited by systemd.") print(" systemd is refusing another immediate start after repeated exits.") - print(f" Wait for the start-limit window to expire, then run: {'sudo ' if system else ''}hermes gateway restart{scope_flag}") + print( + f" Wait for the start-limit window to expire, then run: {'sudo ' if system else ''}hermes gateway restart{scope_flag}" + ) print(f" Or clear the failed state manually: {systemctl_prefix}reset-failed {svc}") print(f" Check logs: {journal_prefix}-u {svc} -l --since '5 min ago'") -def _recover_pending_systemd_restart(system: bool = False, previous_pid: int | None = None) -> bool: +def _recover_pending_systemd_restart( + system: bool = False, previous_pid: int | None = None +) -> bool: """Recover a planned service restart that is stuck in systemd state.""" props = _read_systemd_unit_properties(system=system) if not props: @@ -933,7 +1007,9 @@ def _recover_pending_systemd_restart(system: bool = False, previous_pid: int | N ): svc = get_service_name() scope_label = _service_scope_label(system).capitalize() - print(f"↻ Clearing failed state for pending {scope_label.lower()} service restart...") + print( + f"↻ Clearing failed state for pending {scope_label.lower()} service restart..." + ) _run_systemctl( ["reset-failed", svc], system=system, @@ -1012,8 +1088,14 @@ def get_gateway_runtime_snapshot(system: bool = False) -> GatewayRuntimeSnapshot ) -def _format_gateway_pids(pids: tuple[int, ...] | list[int], *, limit: int | None = 3) -> str: - rendered = [str(pid) for pid in pids[:limit] if pid > 0] if limit is not None else [str(pid) for pid in pids if pid > 0] +def _format_gateway_pids( + pids: tuple[int, ...] | list[int], *, limit: int | None = 3 +) -> str: + rendered = ( + [str(pid) for pid in pids[:limit] if pid > 0] + if limit is not None + else [str(pid) for pid in pids if pid > 0] + ) if limit is not None and len(pids) > limit: rendered.append("...") return ", ".join(rendered) @@ -1023,7 +1105,9 @@ def _print_gateway_process_mismatch(snapshot: GatewayRuntimeSnapshot) -> None: if not snapshot.has_process_service_mismatch: return print() - print("⚠ Gateway process is running for this profile, but the service is not active") + print( + "⚠ Gateway process is running for this profile, but the service is not active" + ) print(f" PID(s): {_format_gateway_pids(snapshot.gateway_pids, limit=None)}") print(" This is usually a manual foreground/tmux/nohup run, so `hermes gateway`") print(" can refuse to start another copy until this process stops.") @@ -1041,8 +1125,7 @@ def _print_other_profiles_gateway_status() -> None: current = get_active_profile_name() other_processes = [ - p for p in find_profile_gateway_processes() - if p.profile != current + p for p in find_profile_gateway_processes() if p.profile != current ] if not other_processes: return @@ -1085,6 +1168,7 @@ def _gateway_list() -> None: if prof.gateway_running: try: from gateway.status import get_running_pid + pid = get_running_pid(prof.path / "gateway.pid", cleanup_stale=False) if pid: parts.append(f"PID {pid}") @@ -1095,8 +1179,9 @@ def _gateway_list() -> None: print(" — ".join(parts)) -def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None, - all_profiles: bool = False) -> int: +def kill_gateway_processes( + force: bool = False, exclude_pids: set | None = None, all_profiles: bool = False +) -> int: """Kill any running gateway processes. Returns count killed. Args: @@ -1108,7 +1193,7 @@ def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None, """ pids = find_gateway_pids(exclude_pids=exclude_pids, all_profiles=all_profiles) killed = 0 - + for pid in pids: try: terminate_pid(pid, force=force) @@ -1118,7 +1203,7 @@ def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None, pass except PermissionError: print(f"⚠ Permission denied to kill PID {pid}") - + except OSError as exc: print(f"Failed to kill PID {pid}: {exc}") return killed @@ -1142,6 +1227,7 @@ def stop_profile_gateway() -> bool: try: from gateway.status import write_planned_stop_marker + write_planned_stop_marker(pid) except Exception: pass @@ -1158,6 +1244,7 @@ def stop_profile_gateway() -> bool: # a no-op — route through the cross-platform existence check. import time as _time from gateway.status import _pid_exists + for _ in range(20): if not _pid_exists(pid): break @@ -1169,7 +1256,7 @@ def stop_profile_gateway() -> bool: def is_linux() -> bool: - return sys.platform.startswith('linux') + return sys.platform.startswith("linux") from hermes_constants import is_container, is_termux, is_wsl @@ -1223,10 +1310,11 @@ def supports_systemd_services() -> bool: def is_macos() -> bool: - return sys.platform == 'darwin' + return sys.platform == "darwin" + def is_windows() -> bool: - return sys.platform == 'win32' + return sys.platform == "win32" def _windows_gateway_should_absorb_console_controls() -> bool: @@ -1268,6 +1356,7 @@ def _profile_suffix() -> str: import hashlib import re from hermes_constants import get_default_hermes_root + home = get_hermes_home().resolve() default = get_default_hermes_root().resolve() if home == default: @@ -1298,6 +1387,7 @@ def _profile_arg(hermes_home: str | None = None) -> str: """ import re from hermes_constants import get_default_hermes_root + home = Path(hermes_home or str(get_hermes_home())).resolve() default = get_default_hermes_root().resolve() if home == default: @@ -1326,7 +1416,6 @@ def get_service_name() -> str: return f"{_SERVICE_BASE}-{suffix}" - def get_systemd_unit_path(system: bool = False) -> Path: name = get_service_name() if system: @@ -1366,13 +1455,17 @@ class SystemScopeRequiresRootError(RuntimeError): def _user_dbus_socket_path() -> Path: """Return the expected per-user D-Bus socket path (regardless of existence).""" - xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" # windows-footgun: ok — POSIX systemd helper, never invoked on Windows + xdg = ( + os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" + ) # windows-footgun: ok — POSIX systemd helper, never invoked on Windows return Path(xdg) / "bus" def _user_systemd_private_socket_path() -> Path: """Return the per-user systemd private socket path (regardless of existence).""" - xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" # windows-footgun: ok — POSIX systemd helper, never invoked on Windows + xdg = ( + os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" + ) # windows-footgun: ok — POSIX systemd helper, never invoked on Windows return Path(xdg) / "systemd" / "private" @@ -1383,7 +1476,10 @@ def _user_systemd_socket_ready() -> bool: D-Bus session bus socket is absent. ``systemctl --user`` can still work in that configuration, so preflight checks must treat either socket as valid. """ - return _user_dbus_socket_path().exists() or _user_systemd_private_socket_path().exists() + return ( + _user_dbus_socket_path().exists() + or _user_systemd_private_socket_path().exists() + ) def _ensure_user_systemd_env() -> None: @@ -1395,7 +1491,9 @@ def _ensure_user_systemd_env() -> None: We detect the standard socket path and set the vars so all subsequent subprocess calls inherit them. """ - uid = os.getuid() # windows-footgun: ok — POSIX systemd helper, never invoked on Windows + uid = ( + os.getuid() + ) # windows-footgun: ok — POSIX systemd helper, never invoked on Windows if "XDG_RUNTIME_DIR" not in os.environ: runtime_dir = f"/run/user/{uid}" if Path(runtime_dir).exists(): @@ -1495,7 +1593,9 @@ def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None: f" Or reboot and run: systemctl --user start {get_service_name()}" ), ) - detail = (result.stderr or result.stdout or f"exit {result.returncode}").strip() + detail = ( + result.stderr or result.stdout or f"exit {result.returncode}" + ).strip() _raise_user_systemd_unavailable( username, reason=f"loginctl enable-linger was denied: {detail}", @@ -1512,7 +1612,9 @@ def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None: ) -def _raise_user_systemd_unavailable(username: str, *, reason: str, fix_hint: str) -> None: +def _raise_user_systemd_unavailable( + username: str, *, reason: str, fix_hint: str +) -> None: """Build a user-facing error message and raise UserSystemdUnavailableError.""" msg = ( f"{reason}\n" @@ -1538,7 +1640,9 @@ def _journalctl_cmd(system: bool = False) -> list[str]: return ["journalctl"] if system else ["journalctl", "--user"] -def _run_systemctl(args: list[str], *, system: bool = False, **kwargs) -> subprocess.CompletedProcess: +def _run_systemctl( + args: list[str], *, system: bool = False, **kwargs +) -> subprocess.CompletedProcess: """Run a systemctl command, raising RuntimeError if systemctl is missing. Defense-in-depth: callers are gated by ``supports_systemd_services()``, @@ -1548,9 +1652,7 @@ def _run_systemctl(args: list[str], *, system: bool = False, **kwargs) -> subpro try: return subprocess.run(_systemctl_cmd(system) + args, **kwargs) except FileNotFoundError: - raise RuntimeError( - "systemctl is not available on this system" - ) from None + raise RuntimeError("systemctl is not available on this system") from None def _service_scope_label(system: bool = False) -> str: @@ -1731,7 +1833,9 @@ def remove_legacy_hermes_units( # System-scope removal (needs root) if system_units: - if os.geteuid() != 0: # windows-footgun: ok — Linux systemd removal path, guarded by `if system == "Linux"` / systemd-only branch + if ( + os.geteuid() != 0 + ): # windows-footgun: ok — Linux systemd removal path, guarded by `if system == "Linux"` / systemd-only branch print() print_warning("System-scope legacy units require root to remove.") print_info(" Re-run with: sudo hermes gateway migrate-legacy") @@ -1741,7 +1845,9 @@ def remove_legacy_hermes_units( for name, path in system_units: try: _run_systemctl(["stop", name], system=True, check=False, timeout=90) - _run_systemctl(["disable", name], system=True, check=False, timeout=30) + _run_systemctl( + ["disable", name], system=True, check=False, timeout=30 + ) path.unlink(missing_ok=True) print(f" ✓ Removed {path}") removed += 1 @@ -1756,7 +1862,9 @@ def remove_legacy_hermes_units( print() if remaining: - print_warning(f"{len(remaining)} legacy unit(s) still present — see messages above.") + print_warning( + f"{len(remaining)} legacy unit(s) still present — see messages above." + ) else: print_success(f"Removed {removed} legacy unit(s).") @@ -1769,16 +1877,22 @@ def print_systemd_scope_conflict_warning() -> None: return rendered_scopes = " + ".join(scopes) - print_warning(f"Both user and system gateway services are installed ({rendered_scopes}).") + print_warning( + f"Both user and system gateway services are installed ({rendered_scopes})." + ) print_info(" This is confusing and can make start/stop/status behavior ambiguous.") - print_info(" Default gateway commands target the user service unless you pass --system.") + print_info( + " Default gateway commands target the user service unless you pass --system." + ) print_info(" Keep one of these:") print_info(" hermes gateway uninstall") print_info(" sudo hermes gateway uninstall --system") def _require_root_for_system_service(action: str) -> None: - if os.geteuid() != 0: # windows-footgun: ok — POSIX systemd helper, never invoked on Windows + if ( + os.geteuid() != 0 + ): # windows-footgun: ok — POSIX systemd helper, never invoked on Windows raise SystemScopeRequiresRootError( f"System gateway {action} requires root. Re-run with sudo.", action, @@ -1790,14 +1904,26 @@ def _system_service_identity(run_as_user: str | None = None) -> tuple[str, str, import grp import pwd - username = (run_as_user or os.getenv("SUDO_USER") or os.getenv("USER") or os.getenv("LOGNAME") or getpass.getuser()).strip() + username = ( + run_as_user + or os.getenv("SUDO_USER") + or os.getenv("USER") + or os.getenv("LOGNAME") + or getpass.getuser() + ).strip() if not username: - raise ValueError("Could not determine which user the gateway service should run as") + raise ValueError( + "Could not determine which user the gateway service should run as" + ) if username == "root" and not run_as_user: - raise ValueError("Refusing to install the gateway system service as root; pass --run-as-user root to override (e.g. in LXC containers)") + raise ValueError( + "Refusing to install the gateway system service as root; pass --run-as-user root to override (e.g. in LXC containers)" + ) if username == "root": print_warning("Installing gateway service to run as root.") - print_info(" This is fine for LXC/container environments but not recommended on bare-metal hosts.") + print_info( + " This is fine for LXC/container environments but not recommended on bare-metal hosts." + ) try: user_info = pwd.getpwnam(username) @@ -1846,18 +1972,28 @@ def install_linux_gateway_from_setup(force: bool = False) -> tuple[str | None, b if scope == "system": run_as_user = _default_system_service_user() - if os.geteuid() != 0: # windows-footgun: ok — Linux systemd install wizard, never invoked on Windows - print_warning(" System service install requires sudo, so Hermes can't create it from this user session.") + if ( + os.geteuid() != 0 + ): # windows-footgun: ok — Linux systemd install wizard, never invoked on Windows + print_warning( + " System service install requires sudo, so Hermes can't create it from this user session." + ) if run_as_user: - print_info(f" After setup, run: sudo hermes gateway install --system --run-as-user {run_as_user}") + print_info( + f" After setup, run: sudo hermes gateway install --system --run-as-user {run_as_user}" + ) else: - print_info(" After setup, run: sudo hermes gateway install --system --run-as-user ") + print_info( + " After setup, run: sudo hermes gateway install --system --run-as-user " + ) print_info(" Then start it with: sudo hermes gateway start --system") return scope, False if not run_as_user: while True: - run_as_user = prompt(" Run the system gateway service as which user?", default="") + run_as_user = prompt( + " Run the system gateway service as which user?", default="" + ) run_as_user = (run_as_user or "").strip() if run_as_user: break @@ -1890,7 +2026,10 @@ def get_systemd_linger_status() -> tuple[bool | None, str]: if not username: try: import pwd - username = pwd.getpwuid(os.getuid()).pw_name # windows-footgun: ok — POSIX loginctl helper, never invoked on Windows + + username = pwd.getpwuid( + os.getuid() + ).pw_name # windows-footgun: ok — POSIX loginctl helper, never invoked on Windows except Exception: return None, "could not determine current user" @@ -1932,6 +2071,7 @@ def print_systemd_linger_guidance() -> None: print(" If you want the gateway user service to survive logout, run:") print(" sudo loginctl enable-linger $USER") + def _launchd_user_home() -> Path: """Return the real macOS user home for launchd artifacts. @@ -1940,7 +2080,9 @@ def _launchd_user_home() -> Path: """ import pwd - return Path(pwd.getpwuid(os.getuid()).pw_dir) # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows + return Path( + pwd.getpwuid(os.getuid()).pw_dir + ) # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows def get_launchd_plist_path() -> Path: @@ -1953,6 +2095,7 @@ def get_launchd_plist_path() -> Path: name = f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway" return _launchd_user_home() / "Library" / "LaunchAgents" / f"{name}.plist" + def _detect_venv_dir() -> Path | None: """Detect the active virtualenv directory. @@ -2002,13 +2145,14 @@ def get_python_path() -> str: # Systemd (Linux) # ============================================================================= + def _build_user_local_paths(home: Path, path_entries: list[str]) -> list[str]: """Return user-local bin dirs that exist and aren't already in *path_entries*.""" candidates = [ - str(home / ".local" / "bin"), # uv, uvx, pip-installed CLIs - str(home / ".cargo" / "bin"), # Rust/cargo tools - str(home / "go" / "bin"), # Go tools - str(home / ".npm-global" / "bin"), # npm global packages + str(home / ".local" / "bin"), # uv, uvx, pip-installed CLIs + str(home / ".cargo" / "bin"), # Rust/cargo tools + str(home / "go" / "bin"), # Go tools + str(home / ".npm-global" / "bin"), # npm global packages ] return [p for p in candidates if p not in path_entries and Path(p).exists()] @@ -2152,7 +2296,14 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) if resolved_node_dir not in path_entries: path_entries.append(resolved_node_dir) - common_bin_paths = ["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"] + common_bin_paths = [ + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", + ] # systemd's TimeoutStopSec must exceed the gateway's drain_timeout so # there's budget left for post-interrupt cleanup (tool subprocess kill, # adapter disconnect, session DB close) before systemd escalates to @@ -2246,6 +2397,7 @@ StandardError=journal WantedBy=default.target """ + def _normalize_service_definition(text: str) -> str: return "\n".join(line.rstrip() for line in text.strip().splitlines()) @@ -2262,8 +2414,8 @@ def _normalize_launchd_plist_for_comparison(text: str) -> str: normalized = _normalize_service_definition(text) return re.sub( - r'(PATH\s*)(.*?)()', - r'\1__HERMES_PATH__\3', + r"(PATH\s*)(.*?)()", + r"\1__HERMES_PATH__\3", normalized, flags=re.S, ) @@ -2277,8 +2429,9 @@ def systemd_unit_is_current(system: bool = False) -> bool: installed = unit_path.read_text(encoding="utf-8") expected_user = _read_systemd_user_from_unit(unit_path) if system else None expected = generate_systemd_unit(system=system, run_as_user=expected_user) - return _normalize_service_definition(installed) == _normalize_service_definition(expected) - + return _normalize_service_definition(installed) == _normalize_service_definition( + expected + ) def refresh_systemd_unit_if_needed(system: bool = False) -> bool: @@ -2306,18 +2459,19 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool: # still works. if not system and ( "/pytest-of-" in new_unit - or "/hermes_test\"" in new_unit + or '/hermes_test"' in new_unit or "/hermes_test/" in new_unit ): return False unit_path.write_text(new_unit, encoding="utf-8") _run_systemctl(["daemon-reload"], system=system, check=True, timeout=30) - print(f"↻ Updated gateway {_service_scope_label(system)} service definition to match the current Hermes install") + print( + f"↻ Updated gateway {_service_scope_label(system)} service definition to match the current Hermes install" + ) return True - def _print_linger_enable_warning(username: str, detail: str | None = None) -> None: print() print("⚠ Linger not enabled — gateway may stop when you close this terminal.") @@ -2332,7 +2486,6 @@ def _print_linger_enable_warning(username: str, detail: str | None = None) -> No print() - def _ensure_linger_enabled() -> None: """Enable linger when possible so the user gateway survives logout.""" if is_termux() or not is_linux(): @@ -2379,7 +2532,10 @@ def _ensure_linger_enabled() -> None: def _select_systemd_scope(system: bool = False) -> bool: if system: return True - return get_systemd_unit_path(system=True).exists() and not get_systemd_unit_path(system=False).exists() + return ( + get_systemd_unit_path(system=True).exists() + and not get_systemd_unit_path(system=False).exists() + ) def _system_scope_wizard_would_need_root(system: bool = False) -> bool: @@ -2392,7 +2548,9 @@ def _system_scope_wizard_would_need_root(system: bool = False) -> bool: ``SystemScopeRequiresRootError`` propagate out and leave the user staring at a bare shell. """ - if os.geteuid() == 0: # windows-footgun: ok — systemd scope wizard decision, never invoked on Windows + if ( + os.geteuid() == 0 + ): # windows-footgun: ok — systemd scope wizard decision, never invoked on Windows return False return _select_systemd_scope(system=system) @@ -2404,8 +2562,7 @@ def _print_system_scope_remediation(action: str) -> None: """ svc = get_service_name() print_warning( - f"Gateway is installed as a system-wide service — " - f"{action} requires root." + f"Gateway is installed as a system-wide service — " f"{action} requires root." ) print_info(" Options:") print_info(f" 1. {action.capitalize()} it this time:") @@ -2437,7 +2594,9 @@ def _get_restart_drain_timeout() -> float: return parse_restart_drain_timeout(raw) -def systemd_install(force: bool = False, system: bool = False, run_as_user: str | None = None): +def systemd_install( + force: bool = False, system: bool = False, run_as_user: str | None = None +): if system: _require_root_for_system_service("install") @@ -2459,10 +2618,16 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str if unit_path.exists() and not force: if not systemd_unit_is_current(system=system): - print(f"↻ Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}") + print( + f"↻ Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}" + ) refresh_systemd_unit_if_needed(system=system) - _run_systemctl(["enable", get_service_name()], system=system, check=True, timeout=30) - print(f"✓ {_service_scope_label(system).capitalize()} service definition updated") + _run_systemctl( + ["enable", get_service_name()], system=system, check=True, timeout=30 + ) + print( + f"✓ {_service_scope_label(system).capitalize()} service definition updated" + ) return print(f"Service already installed at: {unit_path}") print("Use --force to reinstall") @@ -2470,18 +2635,30 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str unit_path.parent.mkdir(parents=True, exist_ok=True) print(f"Installing {_service_scope_label(system)} systemd service to: {unit_path}") - unit_path.write_text(generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8") + unit_path.write_text( + generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8" + ) _run_systemctl(["daemon-reload"], system=system, check=True, timeout=30) - _run_systemctl(["enable", get_service_name()], system=system, check=True, timeout=30) + _run_systemctl( + ["enable", get_service_name()], system=system, check=True, timeout=30 + ) print() - print(f"✓ {_service_scope_label(system).capitalize()} service installed and enabled!") + print( + f"✓ {_service_scope_label(system).capitalize()} service installed and enabled!" + ) print() print("Next steps:") - print(f" {'sudo ' if system else ''}hermes gateway start{scope_flag} # Start the service") - print(f" {'sudo ' if system else ''}hermes gateway status{scope_flag} # Check status") - print(f" {'journalctl' if system else 'journalctl --user'} -u {get_service_name()} -f # View logs") + print( + f" {'sudo ' if system else ''}hermes gateway start{scope_flag} # Start the service" + ) + print( + f" {'sudo ' if system else ''}hermes gateway status{scope_flag} # Check status" + ) + print( + f" {'journalctl' if system else 'journalctl --user'} -u {get_service_name()} -f # View logs" + ) print() if system: @@ -2501,7 +2678,9 @@ def systemd_uninstall(system: bool = False): _require_root_for_system_service("uninstall") _run_systemctl(["stop", get_service_name()], system=system, check=False, timeout=90) - _run_systemctl(["disable", get_service_name()], system=system, check=False, timeout=30) + _run_systemctl( + ["disable", get_service_name()], system=system, check=False, timeout=30 + ) unit_path = get_systemd_unit_path(system=system) if unit_path.exists(): @@ -2536,7 +2715,6 @@ def systemd_start(system: bool = False): print(f"✓ {_service_scope_label(system).capitalize()} service started") - def systemd_stop(system: bool = False): system = _select_systemd_scope(system) if system: @@ -2545,13 +2723,16 @@ def systemd_stop(system: bool = False): _sync_hermes_home_from_systemd_unit(system=system) try: from gateway.status import get_running_pid, write_planned_stop_marker + pid = get_running_pid(cleanup_stale=False) if pid is not None: write_planned_stop_marker(pid) except Exception: pass try: - _run_systemctl(["stop", get_service_name()], system=system, check=True, timeout=90) + _run_systemctl( + ["stop", get_service_name()], system=system, check=True, timeout=90 + ) except subprocess.TimeoutExpired: label = _service_scope_label(system) print( @@ -2562,7 +2743,6 @@ def systemd_stop(system: bool = False): print(f"✓ {_service_scope_label(system).capitalize()} service stopped") - def systemd_restart(system: bool = False): system = _select_systemd_scope(system) if system: @@ -2616,7 +2796,9 @@ def systemd_restart(system: bool = False): try: _run_systemctl(["restart", svc], system=system, check=True, timeout=90) except subprocess.CalledProcessError as exc: - if _systemd_error_indicates_start_limit(exc) or _systemd_service_is_start_limited(system=system): + if _systemd_error_indicates_start_limit( + exc + ) or _systemd_service_is_start_limited(system=system): _print_systemd_start_limit_wait(system=system) return raise @@ -2640,9 +2822,13 @@ def systemd_restart(system: bool = False): timeout=30, ) try: - _run_systemctl(["restart", get_service_name()], system=system, check=True, timeout=90) + _run_systemctl( + ["restart", get_service_name()], system=system, check=True, timeout=90 + ) except subprocess.CalledProcessError as exc: - if _systemd_error_indicates_start_limit(exc) or _systemd_service_is_start_limited(system=system): + if _systemd_error_indicates_start_limit( + exc + ) or _systemd_service_is_start_limited(system=system): _print_systemd_start_limit_wait(system=system) return raise @@ -2656,7 +2842,6 @@ def systemd_restart(system: bool = False): _wait_for_systemd_service_restart(system=system, previous_pid=pid) - def systemd_status(deep: bool = False, system: bool = False, full: bool = False): system = _select_systemd_scope(system) unit_path = get_systemd_unit_path(system=system) @@ -2679,7 +2864,9 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) if not systemd_unit_is_current(system=system): print("⚠ Installed gateway service definition is outdated") - print(f" Run: {'sudo ' if system else ''}hermes gateway restart{scope_flag} # auto-refreshes the unit") + print( + f" Run: {'sudo ' if system else ''}hermes gateway restart{scope_flag} # auto-refreshes the unit" + ) print() status_cmd = ["status", get_service_name(), "--no-pager"] @@ -2704,9 +2891,13 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) status = result.stdout.strip() if status == "active": - print(f"✓ {_service_scope_label(system).capitalize()} gateway service is running") + print( + f"✓ {_service_scope_label(system).capitalize()} gateway service is running" + ) else: - print(f"✗ {_service_scope_label(system).capitalize()} gateway service is stopped") + print( + f"✗ {_service_scope_label(system).capitalize()} gateway service is stopped" + ) print(f" Run: {'sudo ' if system else ''}hermes gateway start{scope_flag}") configured_user = _read_systemd_user_from_unit(unit_path) if system else None @@ -2729,11 +2920,19 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) print(" ⏳ Restart pending: systemd is waiting to relaunch the gateway") elif _systemd_unit_is_start_limited(unit_props): print(" ⏳ Restart pending: systemd is temporarily rate-limiting starts") - print(f" Run after the start-limit window expires: {'sudo ' if system else ''}hermes gateway restart{scope_flag}") - print(f" Or clear it manually: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()}") - elif active_state == "failed" and exec_main_status == str(GATEWAY_SERVICE_RESTART_EXIT_CODE): + print( + f" Run after the start-limit window expires: {'sudo ' if system else ''}hermes gateway restart{scope_flag}" + ) + print( + f" Or clear it manually: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()}" + ) + elif active_state == "failed" and exec_main_status == str( + GATEWAY_SERVICE_RESTART_EXIT_CODE + ): print(" ⚠ Planned restart is stuck in systemd failed state (exit 75)") - print(f" Run: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()} && {'sudo ' if system else ''}hermes gateway start{scope_flag}") + print( + f" Run: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()} && {'sudo ' if system else ''}hermes gateway start{scope_flag}" + ) elif active_state == "failed" and result_code: print(f" ⚠ Systemd unit result: {result_code}") @@ -2752,7 +2951,13 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) if deep: print() print("Recent logs:") - log_cmd = _journalctl_cmd(system) + ["-u", get_service_name(), "-n", "20", "--no-pager"] + log_cmd = _journalctl_cmd(system) + [ + "-u", + get_service_name(), + "-n", + "20", + "--no-pager", + ] if full: log_cmd.append("-l") subprocess.run(log_cmd, timeout=10) @@ -2762,6 +2967,7 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) # Launchd (macOS) # ============================================================================= + def get_launchd_label() -> str: """Return the launchd service label, scoped per profile.""" suffix = _profile_suffix() @@ -2796,7 +3002,9 @@ def generate_launchd_plist() -> str: if resolved_node_dir not in priority_dirs: priority_dirs.append(resolved_node_dir) sane_path = ":".join( - dict.fromkeys(priority_dirs + [p for p in os.environ.get("PATH", "").split(":") if p]) + dict.fromkeys( + priority_dirs + [p for p in os.environ.get("PATH", "").split(":") if p] + ) ) # Build ProgramArguments array, including --profile when using a named profile @@ -2808,11 +3016,13 @@ def generate_launchd_plist() -> str: if profile_arg: for part in profile_arg.split(): prog_args.append(f"{part}") - prog_args.extend([ - "gateway", - "run", - "--replace", - ]) + prog_args.extend( + [ + "gateway", + "run", + "--replace", + ] + ) prog_args_xml = "\n ".join(prog_args) return f""" @@ -2858,6 +3068,7 @@ def generate_launchd_plist() -> str: """ + def launchd_plist_is_current() -> bool: """Check if the installed launchd plist matches the currently generated one.""" plist_path = get_launchd_plist_path() @@ -2866,7 +3077,9 @@ def launchd_plist_is_current() -> bool: installed = plist_path.read_text(encoding="utf-8") expected = generate_launchd_plist() - return _normalize_launchd_plist_for_comparison(installed) == _normalize_launchd_plist_for_comparison(expected) + return _normalize_launchd_plist_for_comparison( + installed + ) == _normalize_launchd_plist_for_comparison(expected) def refresh_launchd_plist_if_needed() -> bool: @@ -2883,15 +3096,25 @@ def refresh_launchd_plist_if_needed() -> bool: plist_path.write_text(generate_launchd_plist(), encoding="utf-8") label = get_launchd_label() # Bootout/bootstrap so launchd picks up the new definition - subprocess.run(["launchctl", "bootout", f"{_launchd_domain()}/{label}"], check=False, timeout=90) - subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=False, timeout=30) - print("↻ Updated gateway launchd service definition to match the current Hermes install") + subprocess.run( + ["launchctl", "bootout", f"{_launchd_domain()}/{label}"], + check=False, + timeout=90, + ) + subprocess.run( + ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], + check=False, + timeout=30, + ) + print( + "↻ Updated gateway launchd service definition to match the current Hermes install" + ) return True def launchd_install(force: bool = False): plist_path = get_launchd_plist_path() - + if plist_path.exists() and not force: if not launchd_plist_is_current(): print(f"↻ Repairing outdated launchd service at: {plist_path}") @@ -2901,32 +3124,43 @@ def launchd_install(force: bool = False): print(f"Service already installed at: {plist_path}") print("Use --force to reinstall") return - + plist_path.parent.mkdir(parents=True, exist_ok=True) print(f"Installing launchd service to: {plist_path}") plist_path.write_text(generate_launchd_plist()) - - subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30) - + + subprocess.run( + ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], + check=True, + timeout=30, + ) + print() print("✓ Service installed and loaded!") print() print("Next steps:") print(" hermes gateway status # Check status") from hermes_constants import display_hermes_home as _dhh + print(f" tail -f {_dhh()}/logs/gateway.log # View logs") + def launchd_uninstall(): plist_path = get_launchd_plist_path() label = get_launchd_label() - subprocess.run(["launchctl", "bootout", f"{_launchd_domain()}/{label}"], check=False, timeout=90) - + subprocess.run( + ["launchctl", "bootout", f"{_launchd_domain()}/{label}"], + check=False, + timeout=90, + ) + if plist_path.exists(): plist_path.unlink() print(f"✓ Removed {plist_path}") - + print("✓ Service uninstalled") + def launchd_start(): plist_path = get_launchd_plist_path() label = get_launchd_label() @@ -2936,27 +3170,49 @@ def launchd_start(): print("↻ launchd plist missing; regenerating service definition") plist_path.parent.mkdir(parents=True, exist_ok=True) plist_path.write_text(generate_launchd_plist(), encoding="utf-8") - subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30) - subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30) + subprocess.run( + ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], + check=True, + timeout=30, + ) + subprocess.run( + ["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], + check=True, + timeout=30, + ) print("✓ Service started") return refresh_launchd_plist_if_needed() try: - subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30) + subprocess.run( + ["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], + check=True, + timeout=30, + ) except subprocess.CalledProcessError as e: if e.returncode not in {3, 113}: raise print("↻ launchd job was unloaded; reloading service definition") - subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30) - subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30) + subprocess.run( + ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], + check=True, + timeout=30, + ) + subprocess.run( + ["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], + check=True, + timeout=30, + ) print("✓ Service started") + def launchd_stop(): label = get_launchd_label() target = f"{_launchd_domain()}/{label}" try: from gateway.status import get_running_pid, write_planned_stop_marker + pid = get_running_pid(cleanup_stale=False) if pid is not None: write_planned_stop_marker(pid) @@ -2976,7 +3232,10 @@ def launchd_stop(): _wait_for_gateway_exit(timeout=10.0, force_after=5.0) print("✓ Service stopped") -def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5.0) -> bool: + +def _wait_for_gateway_exit( + timeout: float = 10.0, force_after: float | None = 5.0 +) -> bool: """Wait for the gateway process (by saved PID) to exit. Uses the PID from the gateway.pid file — not launchd labels — so this @@ -2991,7 +3250,9 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5. from gateway.status import get_running_pid deadline = time.monotonic() + timeout - force_deadline = (time.monotonic() + force_after) if force_after is not None else None + force_deadline = ( + (time.monotonic() + force_after) if force_after is not None else None + ) force_sent = False while time.monotonic() < deadline: @@ -2999,7 +3260,11 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5. if pid is None: return True # Process exited cleanly. - if force_after is not None and not force_sent and time.monotonic() >= force_deadline: + if ( + force_after is not None + and not force_sent + and time.monotonic() >= force_deadline + ): # Grace period expired — force-kill the specific PID. try: terminate_pid(pid, force=True) @@ -3013,7 +3278,9 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5. # Timed out even after force-kill. remaining_pid = get_running_pid() if remaining_pid is not None: - print(f"⚠ Gateway PID {remaining_pid} still running after {timeout}s — restart may fail") + print( + f"⚠ Gateway PID {remaining_pid} still running after {timeout}s — restart may fail" + ) return False return True @@ -3037,7 +3304,9 @@ def launchd_restart(): if pid is not None: exited = _wait_for_gateway_exit(timeout=drain_timeout, force_after=None) if not exited: - print(f"⚠ Gateway drain timed out after {drain_timeout:.0f}s — forcing launchd restart") + print( + f"⚠ Gateway drain timed out after {drain_timeout:.0f}s — forcing launchd restart" + ) subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90) print("✓ Service restarted") except subprocess.CalledProcessError as e: @@ -3046,10 +3315,15 @@ def launchd_restart(): # Job not loaded — bootstrap and start fresh print("↻ launchd job was unloaded; reloading") plist_path = get_launchd_plist_path() - subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30) + subprocess.run( + ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], + check=True, + timeout=30, + ) subprocess.run(["launchctl", "kickstart", target], check=True, timeout=30) print("✓ Service restarted") + def launchd_status(deep: bool = False): plist_path = get_launchd_plist_path() label = get_launchd_label() @@ -3080,7 +3354,7 @@ def launchd_status(deep: bool = False): print("✗ Gateway service is not loaded") print(" Service definition exists locally but launchd has not loaded it.") print(" Run: hermes gateway start") - + if deep: log_file = get_hermes_home() / "logs" / "gateway.log" if log_file.exists(): @@ -3093,6 +3367,7 @@ def launchd_status(deep: bool = False): # Gateway Runner # ============================================================================= + def _truthy_env(value: str | None) -> bool: return str(value or "").strip().lower() in {"1", "true", "yes", "on"} @@ -3125,13 +3400,15 @@ def _guard_official_docker_root_gateway() -> None: " Running the gateway as root can leave root-owned files in " "$HERMES_HOME and break later non-root dashboard/gateway runs." ) - print(" Set HERMES_ALLOW_ROOT_GATEWAY=1 only if you intentionally accept this risk.") + print( + " Set HERMES_ALLOW_ROOT_GATEWAY=1 only if you intentionally accept this risk." + ) sys.exit(1) def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): """Run the gateway in foreground. - + Args: verbose: Stderr log verbosity count added on top of default WARNING (0=WARNING, 1=INFO, 2+=DEBUG). quiet: Suppress all stderr log output. @@ -3171,6 +3448,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): # handlers above. try: import ctypes + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] # BOOL SetConsoleCtrlHandler(NULL, Add) — Add=TRUE means # "install the NULL handler", which has the documented @@ -3194,9 +3472,9 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): refresh_systemd_unit_if_needed(system=False) except Exception: pass # best-effort; don't block gateway startup - + from gateway.run import start_gateway - + print("┌─────────────────────────────────────────────────────────┐") print("│ ⚕ Hermes Gateway Starting... │") print("├─────────────────────────────────────────────────────────┤") @@ -3204,7 +3482,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): print("│ Press Ctrl+C to stop │") print("└─────────────────────────────────────────────────────────┘") print() - + # Exit with code 1 if gateway fails to connect any platform, # so systemd Restart=always will retry on transient errors verbosity = None if quiet else verbose @@ -3228,6 +3506,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): return try: from hermes_constants import get_hermes_home as _ghh + log_dir = _ghh() / "logs" log_dir.mkdir(parents=True, exist_ok=True) ts = _dt.now(_tz.utc).isoformat() @@ -3240,6 +3519,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): **extra, } import json as _json + with open(log_dir / "gateway-exit-diag.log", "a", encoding="utf-8") as f: f.write(_json.dumps(line, default=str) + "\n") except Exception: @@ -3272,8 +3552,11 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): print("\nGateway stopped.") return except SystemExit as e: - _exit_diag("asyncio.run.SystemExit", code=getattr(e, "code", None), - traceback=_traceback.format_exc()) + _exit_diag( + "asyncio.run.SystemExit", + code=getattr(e, "code", None), + traceback=_traceback.format_exc(), + ) raise except BaseException as e: # Absolutely everything else: Exception, asyncio.CancelledError, @@ -3310,13 +3593,25 @@ _PLATFORMS = [ "4. To find your user ID: message @userinfobot — it replies with your numeric ID", ], "vars": [ - {"name": "TELEGRAM_BOT_TOKEN", "prompt": "Bot token", "password": True, - "help": "Paste the token from @BotFather (step 3 above)."}, - {"name": "TELEGRAM_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated)", "password": False, - "is_allowlist": True, - "help": "Paste your user ID from step 4 above."}, - {"name": "TELEGRAM_HOME_CHANNEL", "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", "password": False, - "help": "For DMs, this is your user ID. You can set it later by typing /set-home in chat."}, + { + "name": "TELEGRAM_BOT_TOKEN", + "prompt": "Bot token", + "password": True, + "help": "Paste the token from @BotFather (step 3 above).", + }, + { + "name": "TELEGRAM_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated)", + "password": False, + "is_allowlist": True, + "help": "Paste your user ID from step 4 above.", + }, + { + "name": "TELEGRAM_HOME_CHANNEL", + "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", + "password": False, + "help": "For DMs, this is your user ID. You can set it later by typing /set-home in chat.", + }, ], }, { @@ -3338,13 +3633,25 @@ _PLATFORMS = [ " then right-click your name → Copy ID", ], "vars": [ - {"name": "DISCORD_BOT_TOKEN", "prompt": "Bot token", "password": True, - "help": "Paste the token from step 2 above."}, - {"name": "DISCORD_ALLOWED_USERS", "prompt": "Allowed user IDs or usernames (comma-separated)", "password": False, - "is_allowlist": True, - "help": "Paste your user ID from step 5 above."}, - {"name": "DISCORD_HOME_CHANNEL", "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", "password": False, - "help": "Right-click a channel → Copy Channel ID (requires Developer Mode)."}, + { + "name": "DISCORD_BOT_TOKEN", + "prompt": "Bot token", + "password": True, + "help": "Paste the token from step 2 above.", + }, + { + "name": "DISCORD_ALLOWED_USERS", + "prompt": "Allowed user IDs or usernames (comma-separated)", + "password": False, + "is_allowlist": True, + "help": "Paste your user ID from step 5 above.", + }, + { + "name": "DISCORD_HOME_CHANNEL", + "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", + "password": False, + "help": "Right-click a channel → Copy Channel ID (requires Developer Mode).", + }, ], }, { @@ -3369,13 +3676,25 @@ _PLATFORMS = [ "8. Invite the bot to channels: /invite @YourBot", ], "vars": [ - {"name": "SLACK_BOT_TOKEN", "prompt": "Bot Token (xoxb-...)", "password": True, - "help": "Paste the bot token from step 3 above."}, - {"name": "SLACK_APP_TOKEN", "prompt": "App Token (xapp-...)", "password": True, - "help": "Paste the app-level token from step 4 above."}, - {"name": "SLACK_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated)", "password": False, - "is_allowlist": True, - "help": "Paste your member ID from step 7 above."}, + { + "name": "SLACK_BOT_TOKEN", + "prompt": "Bot Token (xoxb-...)", + "password": True, + "help": "Paste the bot token from step 3 above.", + }, + { + "name": "SLACK_APP_TOKEN", + "prompt": "App Token (xapp-...)", + "password": True, + "help": "Paste the app-level token from step 4 above.", + }, + { + "name": "SLACK_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated)", + "password": False, + "is_allowlist": True, + "help": "Paste your member ID from step 7 above.", + }, ], }, { @@ -3388,23 +3707,43 @@ _PLATFORMS = [ "2. Create a bot user on your homeserver, or use your own account", "3. Get an access token: Element → Settings → Help & About → Access Token", " Or via API: curl -X POST https://your-server/_matrix/client/v3/login \\", - " -d '{\"type\":\"m.login.password\",\"user\":\"@bot:server\",\"password\":\"...\"}'", + ' -d \'{"type":"m.login.password","user":"@bot:server","password":"..."}\'', "4. Alternatively, provide user ID + password and Hermes will log in directly", "5. For E2EE: set MATRIX_ENCRYPTION=true (requires pip install 'mautrix[encryption]')", "6. To find your user ID: it's @username:your-server (shown in Element profile)", ], "vars": [ - {"name": "MATRIX_HOMESERVER", "prompt": "Homeserver URL (e.g. https://matrix.example.org)", "password": False, - "help": "Your Matrix homeserver URL. Works with any self-hosted instance."}, - {"name": "MATRIX_ACCESS_TOKEN", "prompt": "Access token (leave empty to use password login instead)", "password": True, - "help": "Paste your access token, or leave empty and provide user ID + password below."}, - {"name": "MATRIX_USER_ID", "prompt": "User ID (@bot:server — required for password login)", "password": False, - "help": "Full Matrix user ID, e.g. @hermes:matrix.example.org"}, - {"name": "MATRIX_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, e.g. @you:server)", "password": False, - "is_allowlist": True, - "help": "Matrix user IDs who can interact with the bot."}, - {"name": "MATRIX_HOME_ROOM", "prompt": "Home room ID (for cron/notification delivery, or empty to set later with /set-home)", "password": False, - "help": "Room ID (e.g. !abc123:server) for delivering cron results and notifications."}, + { + "name": "MATRIX_HOMESERVER", + "prompt": "Homeserver URL (e.g. https://matrix.example.org)", + "password": False, + "help": "Your Matrix homeserver URL. Works with any self-hosted instance.", + }, + { + "name": "MATRIX_ACCESS_TOKEN", + "prompt": "Access token (leave empty to use password login instead)", + "password": True, + "help": "Paste your access token, or leave empty and provide user ID + password below.", + }, + { + "name": "MATRIX_USER_ID", + "prompt": "User ID (@bot:server — required for password login)", + "password": False, + "help": "Full Matrix user ID, e.g. @hermes:matrix.example.org", + }, + { + "name": "MATRIX_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated, e.g. @you:server)", + "password": False, + "is_allowlist": True, + "help": "Matrix user IDs who can interact with the bot.", + }, + { + "name": "MATRIX_HOME_ROOM", + "prompt": "Home room ID (for cron/notification delivery, or empty to set later with /set-home)", + "password": False, + "help": "Room ID (e.g. !abc123:server) for delivering cron results and notifications.", + }, ], }, { @@ -3423,17 +3762,37 @@ _PLATFORMS = [ "5. To get a channel ID: click the channel name → View Info → copy the ID", ], "vars": [ - {"name": "MATTERMOST_URL", "prompt": "Server URL (e.g. https://mm.example.com)", "password": False, - "help": "Your Mattermost server URL. Works with any self-hosted instance."}, - {"name": "MATTERMOST_TOKEN", "prompt": "Bot token", "password": True, - "help": "Paste the bot token from step 2 above."}, - {"name": "MATTERMOST_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated)", "password": False, - "is_allowlist": True, - "help": "Your Mattermost user ID from step 4 above."}, - {"name": "MATTERMOST_HOME_CHANNEL", "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", "password": False, - "help": "Channel ID where Hermes delivers cron results and notifications."}, - {"name": "MATTERMOST_REPLY_MODE", "prompt": "Reply mode — 'off' for flat messages, 'thread' for threaded replies (default: off)", "password": False, - "help": "off = flat channel messages, thread = replies nest under your message."}, + { + "name": "MATTERMOST_URL", + "prompt": "Server URL (e.g. https://mm.example.com)", + "password": False, + "help": "Your Mattermost server URL. Works with any self-hosted instance.", + }, + { + "name": "MATTERMOST_TOKEN", + "prompt": "Bot token", + "password": True, + "help": "Paste the bot token from step 2 above.", + }, + { + "name": "MATTERMOST_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated)", + "password": False, + "is_allowlist": True, + "help": "Your Mattermost user ID from step 4 above.", + }, + { + "name": "MATTERMOST_HOME_CHANNEL", + "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", + "password": False, + "help": "Channel ID where Hermes delivers cron results and notifications.", + }, + { + "name": "MATTERMOST_REPLY_MODE", + "prompt": "Reply mode — 'off' for flat messages, 'thread' for threaded replies (default: off)", + "password": False, + "help": "off = flat channel messages, thread = replies nest under your message.", + }, ], }, { @@ -3461,17 +3820,37 @@ _PLATFORMS = [ "4. IMAP must be enabled on your email account", ], "vars": [ - {"name": "EMAIL_ADDRESS", "prompt": "Email address", "password": False, - "help": "The email address Hermes will use (e.g., hermes@gmail.com)."}, - {"name": "EMAIL_PASSWORD", "prompt": "Email password (or app password)", "password": True, - "help": "For Gmail, use an App Password (not your regular password)."}, - {"name": "EMAIL_IMAP_HOST", "prompt": "IMAP host", "password": False, - "help": "e.g., imap.gmail.com for Gmail, outlook.office365.com for Outlook."}, - {"name": "EMAIL_SMTP_HOST", "prompt": "SMTP host", "password": False, - "help": "e.g., smtp.gmail.com for Gmail, smtp.office365.com for Outlook."}, - {"name": "EMAIL_ALLOWED_USERS", "prompt": "Allowed sender emails (comma-separated)", "password": False, - "is_allowlist": True, - "help": "Only emails from these addresses will be processed."}, + { + "name": "EMAIL_ADDRESS", + "prompt": "Email address", + "password": False, + "help": "The email address Hermes will use (e.g., hermes@gmail.com).", + }, + { + "name": "EMAIL_PASSWORD", + "prompt": "Email password (or app password)", + "password": True, + "help": "For Gmail, use an App Password (not your regular password).", + }, + { + "name": "EMAIL_IMAP_HOST", + "prompt": "IMAP host", + "password": False, + "help": "e.g., imap.gmail.com for Gmail, outlook.office365.com for Outlook.", + }, + { + "name": "EMAIL_SMTP_HOST", + "prompt": "SMTP host", + "password": False, + "help": "e.g., smtp.gmail.com for Gmail, smtp.office365.com for Outlook.", + }, + { + "name": "EMAIL_ALLOWED_USERS", + "prompt": "Allowed sender emails (comma-separated)", + "password": False, + "is_allowlist": True, + "help": "Only emails from these addresses will be processed.", + }, ], }, { @@ -3488,17 +3867,37 @@ _PLATFORMS = [ " → Messaging → A MESSAGE COMES IN → Webhook → https://your-server:8080/webhooks/twilio", ], "vars": [ - {"name": "TWILIO_ACCOUNT_SID", "prompt": "Twilio Account SID", "password": False, - "help": "Found on the Twilio Console dashboard."}, - {"name": "TWILIO_AUTH_TOKEN", "prompt": "Twilio Auth Token", "password": True, - "help": "Found on the Twilio Console dashboard (click to reveal)."}, - {"name": "TWILIO_PHONE_NUMBER", "prompt": "Twilio phone number (E.164 format, e.g. +15551234567)", "password": False, - "help": "The Twilio phone number to send SMS from."}, - {"name": "SMS_ALLOWED_USERS", "prompt": "Allowed phone numbers (comma-separated, E.164 format)", "password": False, - "is_allowlist": True, - "help": "Only messages from these phone numbers will be processed."}, - {"name": "SMS_HOME_CHANNEL", "prompt": "Home channel phone number (for cron/notification delivery, or empty)", "password": False, - "help": "Phone number to deliver cron job results and notifications to."}, + { + "name": "TWILIO_ACCOUNT_SID", + "prompt": "Twilio Account SID", + "password": False, + "help": "Found on the Twilio Console dashboard.", + }, + { + "name": "TWILIO_AUTH_TOKEN", + "prompt": "Twilio Auth Token", + "password": True, + "help": "Found on the Twilio Console dashboard (click to reveal).", + }, + { + "name": "TWILIO_PHONE_NUMBER", + "prompt": "Twilio phone number (E.164 format, e.g. +15551234567)", + "password": False, + "help": "The Twilio phone number to send SMS from.", + }, + { + "name": "SMS_ALLOWED_USERS", + "prompt": "Allowed phone numbers (comma-separated, E.164 format)", + "password": False, + "is_allowlist": True, + "help": "Only messages from these phone numbers will be processed.", + }, + { + "name": "SMS_HOME_CHANNEL", + "prompt": "Home channel phone number (for cron/notification delivery, or empty)", + "password": False, + "help": "Phone number to deliver cron job results and notifications to.", + }, ], }, { @@ -3513,10 +3912,18 @@ _PLATFORMS = [ "4. Add the bot to a group chat or message it directly", ], "vars": [ - {"name": "DINGTALK_CLIENT_ID", "prompt": "AppKey (Client ID)", "password": False, - "help": "The AppKey from your DingTalk application credentials."}, - {"name": "DINGTALK_CLIENT_SECRET", "prompt": "AppSecret (Client Secret)", "password": True, - "help": "The AppSecret from your DingTalk application credentials."}, + { + "name": "DINGTALK_CLIENT_ID", + "prompt": "AppKey (Client ID)", + "password": False, + "help": "The AppKey from your DingTalk application credentials.", + }, + { + "name": "DINGTALK_CLIENT_SECRET", + "prompt": "AppSecret (Client Secret)", + "password": True, + "help": "The AppSecret from your DingTalk application credentials.", + }, ], }, { @@ -3533,19 +3940,43 @@ _PLATFORMS = [ "6. Restrict access with FEISHU_ALLOWED_USERS for production use", ], "vars": [ - {"name": "FEISHU_APP_ID", "prompt": "App ID", "password": False, - "help": "The App ID from your Feishu/Lark application."}, - {"name": "FEISHU_APP_SECRET", "prompt": "App Secret", "password": True, - "help": "The App Secret from your Feishu/Lark application."}, - {"name": "FEISHU_DOMAIN", "prompt": "Domain — feishu or lark (default: feishu)", "password": False, - "help": "Use 'feishu' for Feishu China, or 'lark' for Lark international."}, - {"name": "FEISHU_CONNECTION_MODE", "prompt": "Connection mode — websocket or webhook (default: websocket)", "password": False, - "help": "websocket is recommended unless you specifically need webhook mode."}, - {"name": "FEISHU_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False, - "is_allowlist": True, - "help": "Restrict which Feishu/Lark users can interact with the bot."}, - {"name": "FEISHU_HOME_CHANNEL", "prompt": "Home chat ID (optional, for cron/notifications)", "password": False, - "help": "Chat ID for scheduled results and notifications."}, + { + "name": "FEISHU_APP_ID", + "prompt": "App ID", + "password": False, + "help": "The App ID from your Feishu/Lark application.", + }, + { + "name": "FEISHU_APP_SECRET", + "prompt": "App Secret", + "password": True, + "help": "The App Secret from your Feishu/Lark application.", + }, + { + "name": "FEISHU_DOMAIN", + "prompt": "Domain — feishu or lark (default: feishu)", + "password": False, + "help": "Use 'feishu' for Feishu China, or 'lark' for Lark international.", + }, + { + "name": "FEISHU_CONNECTION_MODE", + "prompt": "Connection mode — websocket or webhook (default: websocket)", + "password": False, + "help": "websocket is recommended unless you specifically need webhook mode.", + }, + { + "name": "FEISHU_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated, or empty)", + "password": False, + "is_allowlist": True, + "help": "Restrict which Feishu/Lark users can interact with the bot.", + }, + { + "name": "FEISHU_HOME_CHANNEL", + "prompt": "Home chat ID (optional, for cron/notifications)", + "password": False, + "help": "Chat ID for scheduled results and notifications.", + }, ], }, { @@ -3561,15 +3992,31 @@ _PLATFORMS = [ "5. Restrict access with WECOM_ALLOWED_USERS for production use", ], "vars": [ - {"name": "WECOM_BOT_ID", "prompt": "Bot ID", "password": False, - "help": "The Bot ID from your WeCom AI Bot."}, - {"name": "WECOM_SECRET", "prompt": "Secret", "password": True, - "help": "The secret from your WeCom AI Bot."}, - {"name": "WECOM_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False, - "is_allowlist": True, - "help": "Restrict which WeCom users can interact with the bot."}, - {"name": "WECOM_HOME_CHANNEL", "prompt": "Home chat ID (optional, for cron/notifications)", "password": False, - "help": "Chat ID for scheduled results and notifications."}, + { + "name": "WECOM_BOT_ID", + "prompt": "Bot ID", + "password": False, + "help": "The Bot ID from your WeCom AI Bot.", + }, + { + "name": "WECOM_SECRET", + "prompt": "Secret", + "password": True, + "help": "The secret from your WeCom AI Bot.", + }, + { + "name": "WECOM_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated, or empty)", + "password": False, + "is_allowlist": True, + "help": "Restrict which WeCom users can interact with the bot.", + }, + { + "name": "WECOM_HOME_CHANNEL", + "prompt": "Home chat ID (optional, for cron/notifications)", + "password": False, + "help": "Chat ID for scheduled results and notifications.", + }, ], }, { @@ -3586,21 +4033,49 @@ _PLATFORMS = [ "6. Restrict access with WECOM_CALLBACK_ALLOWED_USERS for production use", ], "vars": [ - {"name": "WECOM_CALLBACK_CORP_ID", "prompt": "Corp ID", "password": False, - "help": "Your WeCom enterprise Corp ID."}, - {"name": "WECOM_CALLBACK_CORP_SECRET", "prompt": "Corp Secret", "password": True, - "help": "The secret for your self-built application."}, - {"name": "WECOM_CALLBACK_AGENT_ID", "prompt": "Agent ID", "password": False, - "help": "The Agent ID of your self-built application."}, - {"name": "WECOM_CALLBACK_TOKEN", "prompt": "Callback Token", "password": True, - "help": "The Token from your WeCom callback configuration."}, - {"name": "WECOM_CALLBACK_ENCODING_AES_KEY", "prompt": "Encoding AES Key", "password": True, - "help": "The EncodingAESKey from your WeCom callback configuration."}, - {"name": "WECOM_CALLBACK_PORT", "prompt": "Callback server port (default: 8645)", "password": False, - "help": "Port for the HTTP callback server."}, - {"name": "WECOM_CALLBACK_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False, - "is_allowlist": True, - "help": "Restrict which WeCom users can interact with the app."}, + { + "name": "WECOM_CALLBACK_CORP_ID", + "prompt": "Corp ID", + "password": False, + "help": "Your WeCom enterprise Corp ID.", + }, + { + "name": "WECOM_CALLBACK_CORP_SECRET", + "prompt": "Corp Secret", + "password": True, + "help": "The secret for your self-built application.", + }, + { + "name": "WECOM_CALLBACK_AGENT_ID", + "prompt": "Agent ID", + "password": False, + "help": "The Agent ID of your self-built application.", + }, + { + "name": "WECOM_CALLBACK_TOKEN", + "prompt": "Callback Token", + "password": True, + "help": "The Token from your WeCom callback configuration.", + }, + { + "name": "WECOM_CALLBACK_ENCODING_AES_KEY", + "prompt": "Encoding AES Key", + "password": True, + "help": "The EncodingAESKey from your WeCom callback configuration.", + }, + { + "name": "WECOM_CALLBACK_PORT", + "prompt": "Callback server port (default: 8645)", + "password": False, + "help": "Port for the HTTP callback server.", + }, + { + "name": "WECOM_CALLBACK_ALLOWED_USERS", + "prompt": "Allowed user IDs (comma-separated, or empty)", + "password": False, + "is_allowlist": True, + "help": "Restrict which WeCom users can interact with the app.", + }, ], }, { @@ -3626,15 +4101,31 @@ _PLATFORMS = [ " Share the code — the user sends it via iMessage to get approved", ], "vars": [ - {"name": "BLUEBUBBLES_SERVER_URL", "prompt": "BlueBubbles server URL (e.g. http://192.168.1.10:1234)", "password": False, - "help": "The URL shown in BlueBubbles Settings → API."}, - {"name": "BLUEBUBBLES_PASSWORD", "prompt": "BlueBubbles server password", "password": True, - "help": "The password shown in BlueBubbles Settings → API."}, - {"name": "BLUEBUBBLES_ALLOWED_USERS", "prompt": "Pre-authorized phone numbers or iMessage IDs (comma-separated, or leave empty for DM pairing)", "password": False, - "is_allowlist": True, - "help": "Optional — pre-authorize specific users. Leave empty to use DM pairing instead (recommended)."}, - {"name": "BLUEBUBBLES_HOME_CHANNEL", "prompt": "Home channel (phone number or iMessage ID for cron/notifications, or empty)", "password": False, - "help": "Phone number or Apple ID to deliver cron results and notifications to."}, + { + "name": "BLUEBUBBLES_SERVER_URL", + "prompt": "BlueBubbles server URL (e.g. http://192.168.1.10:1234)", + "password": False, + "help": "The URL shown in BlueBubbles Settings → API.", + }, + { + "name": "BLUEBUBBLES_PASSWORD", + "prompt": "BlueBubbles server password", + "password": True, + "help": "The password shown in BlueBubbles Settings → API.", + }, + { + "name": "BLUEBUBBLES_ALLOWED_USERS", + "prompt": "Pre-authorized phone numbers or iMessage IDs (comma-separated, or leave empty for DM pairing)", + "password": False, + "is_allowlist": True, + "help": "Optional — pre-authorize specific users. Leave empty to use DM pairing instead (recommended).", + }, + { + "name": "BLUEBUBBLES_HOME_CHANNEL", + "prompt": "Home channel (phone number or iMessage ID for cron/notifications, or empty)", + "password": False, + "help": "Phone number or Apple ID to deliver cron results and notifications to.", + }, ], }, { @@ -3649,15 +4140,31 @@ _PLATFORMS = [ "4. Configure sandbox or publish the bot", ], "vars": [ - {"name": "QQ_APP_ID", "prompt": "QQ Bot App ID", "password": False, - "help": "Your QQ Bot App ID from q.qq.com."}, - {"name": "QQ_CLIENT_SECRET", "prompt": "QQ Bot App Secret", "password": True, - "help": "Your QQ Bot App Secret from q.qq.com."}, - {"name": "QQ_ALLOWED_USERS", "prompt": "Allowed user OpenIDs (comma-separated, leave empty for open access)", "password": False, - "is_allowlist": True, - "help": "Optional — restrict DM access to specific user OpenIDs."}, - {"name": "QQBOT_HOME_CHANNEL", "prompt": "Home channel (user/group OpenID for cron delivery, or empty)", "password": False, - "help": "OpenID to deliver cron results and notifications to."}, + { + "name": "QQ_APP_ID", + "prompt": "QQ Bot App ID", + "password": False, + "help": "Your QQ Bot App ID from q.qq.com.", + }, + { + "name": "QQ_CLIENT_SECRET", + "prompt": "QQ Bot App Secret", + "password": True, + "help": "Your QQ Bot App Secret from q.qq.com.", + }, + { + "name": "QQ_ALLOWED_USERS", + "prompt": "Allowed user OpenIDs (comma-separated, leave empty for open access)", + "password": False, + "is_allowlist": True, + "help": "Optional — restrict DM access to specific user OpenIDs.", + }, + { + "name": "QQBOT_HOME_CHANNEL", + "prompt": "Home channel (user/group OpenID for cron delivery, or empty)", + "password": False, + "help": "OpenID to deliver cron results and notifications to.", + }, ], }, { @@ -3672,13 +4179,23 @@ _PLATFORMS = [ "4. Enter them below and Hermes will connect automatically over WebSocket", ], "vars": [ - {"name": "YUANBAO_APP_ID", "prompt": "App ID", "password": False, - "help": "The App ID from your Yuanbao IM Bot credentials."}, - {"name": "YUANBAO_APP_SECRET", "prompt": "App Secret", "password": True, - "help": "The App Secret (used for HMAC signing) from your Yuanbao IM Bot."}, + { + "name": "YUANBAO_APP_ID", + "prompt": "App ID", + "password": False, + "help": "The App ID from your Yuanbao IM Bot credentials.", + }, + { + "name": "YUANBAO_APP_SECRET", + "prompt": "App Secret", + "password": True, + "help": "The App Secret (used for HMAC signing) from your Yuanbao IM Bot.", + }, ], }, ] + + def _all_platforms() -> list[dict]: """Return the full list of platforms for setup menus. @@ -3705,6 +4222,7 @@ def _all_platforms() -> list[dict]: # opt-in via ``plugins.enabled`` (untrusted code). try: from hermes_cli.plugins import discover_plugins + discover_plugins() except Exception as e: logger.debug("plugin discovery failed during platform enumeration: %s", e) @@ -3725,14 +4243,16 @@ def _all_platforms() -> list[dict]: for entry in platform_registry.all_entries(): if entry.name in by_key: continue # built-in already covers it - platforms.append({ - "key": entry.name, - "label": entry.label, - "emoji": entry.emoji, - "token_var": entry.required_env[0] if entry.required_env else "", - "install_hint": entry.install_hint, - "_registry_entry": entry, - }) + platforms.append( + { + "key": entry.name, + "label": entry.label, + "emoji": entry.emoji, + "token_var": entry.required_env[0] if entry.required_env else "", + "install_hint": entry.install_hint, + "_registry_entry": entry, + } + ) return platforms @@ -3750,6 +4270,7 @@ def _platform_status(platform: dict) -> str: if entry.is_connected is not None: try: from gateway.config import PlatformConfig + synthetic = PlatformConfig(enabled=True) configured = bool(entry.is_connected(synthetic)) except Exception: @@ -3908,15 +4429,23 @@ def _setup_standard_platform(platform: dict): "Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')", "Skip for now (bot will deny all users until configured)", ] - access_idx = prompt_choice(" How should unauthorized users be handled?", access_choices, 1) + access_idx = prompt_choice( + " How should unauthorized users be handled?", access_choices, 1 + ) if access_idx == 0: save_env_value("GATEWAY_ALLOW_ALL_USERS", "true") print_warning(" Open access enabled — anyone can use your bot!") elif access_idx == 1: - print_success(" DM pairing mode — users will receive a code to request access.") - print_info(" Approve with: hermes pairing approve ") + print_success( + " DM pairing mode — users will receive a code to request access." + ) + print_info( + " Approve with: hermes pairing approve " + ) else: - print_info(" Skipped — configure later with 'hermes gateway setup'") + print_info( + " Skipped — configure later with 'hermes gateway setup'" + ) continue value = prompt(f" {var['prompt']}", password=var.get("password", False)) @@ -3935,7 +4464,9 @@ def _setup_standard_platform(platform: dict): home_val = get_env_value(home_var) if allowed_val_set and not home_val and label == "Telegram": first_id = allowed_val_set.split(",")[0].strip() - if first_id and prompt_yes_no(f" Use your user ID ({first_id}) as the home channel?", True): + if first_id and prompt_yes_no( + f" Use your user ID ({first_id}) as the home channel?", True + ): save_env_value(home_var, first_id) print_success(f" Home channel set to {first_id}") @@ -3947,6 +4478,7 @@ def _setup_whatsapp(): """Delegate to the existing WhatsApp setup flow.""" from hermes_cli.main import cmd_whatsapp import argparse + cmd_whatsapp(argparse.Namespace()) @@ -3965,7 +4497,10 @@ def _setup_sms(): def _setup_dingtalk(): """Configure DingTalk — QR scan (recommended) or manual credential entry.""" from hermes_cli.setup import ( - prompt_choice, prompt_yes_no, print_success, print_warning, + prompt_choice, + prompt_yes_no, + print_success, + print_warning, ) dingtalk_platform = next(p for p in _PLATFORMS if p["key"] == "dingtalk") @@ -3997,7 +4532,9 @@ def _setup_dingtalk(): try: from hermes_cli.dingtalk_auth import dingtalk_qr_auth except ImportError as exc: - print_warning(f" QR auth module failed to load ({exc}), falling back to manual input.") + print_warning( + f" QR auth module failed to load ({exc}), falling back to manual input." + ) _setup_standard_platform(dingtalk_platform) return @@ -4040,7 +4577,9 @@ def _setup_wecom(): "Scan QR code to obtain Bot ID and Secret automatically (recommended)", "Enter existing Bot ID and Secret manually", ] - method_idx = prompt_choice(" How would you like to set up WeCom?", method_choices, 0) + method_idx = prompt_choice( + " How would you like to set up WeCom?", method_choices, 0 + ) bot_id = None secret = None @@ -4076,7 +4615,9 @@ def _setup_wecom(): # ── Manual credential input ── if not bot_id or not secret: print() - print_info(" 1. Go to WeCom Application → Workspace → Smart Robot -> Create smart robots") + print_info( + " 1. Go to WeCom Application → Workspace → Smart Robot -> Create smart robots" + ) print_info(" 2. Select API Mode") print_info(" 3. Copy the Bot ID and Secret from the bot's credentials info") print_info(" 4. The bot connects via WebSocket — no public endpoint needed") @@ -4111,14 +4652,18 @@ def _setup_wecom(): "Disable direct messages", "Skip for now (bot will deny all users until configured)", ] - access_idx = prompt_choice(" How should unauthorized users be handled?", access_choices, 1) + access_idx = prompt_choice( + " How should unauthorized users be handled?", access_choices, 1 + ) if access_idx == 0: save_env_value("WECOM_DM_POLICY", "open") save_env_value("GATEWAY_ALLOW_ALL_USERS", "true") print_warning(" Open access enabled — anyone can use your bot!") elif access_idx == 1: save_env_value("WECOM_DM_POLICY", "pairing") - print_success(" DM pairing mode — users will receive a code to request access.") + print_success( + " DM pairing mode — users will receive a code to request access." + ) print_info(" Approve with: hermes pairing approve ") elif access_idx == 2: save_env_value("WECOM_DM_POLICY", "disabled") @@ -4147,11 +4692,15 @@ def _setup_yuanbao(): def _is_service_installed() -> bool: """Check if the gateway is installed as a system service.""" if supports_systemd_services(): - return get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists() + return ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ) elif is_macos(): return get_launchd_plist_path().exists() elif is_windows(): from hermes_cli import gateway_windows + return gateway_windows.is_installed() return False @@ -4166,7 +4715,10 @@ def _is_service_running() -> bool: try: result = _run_systemctl( ["is-active", get_service_name()], - system=False, capture_output=True, text=True, timeout=10, + system=False, + capture_output=True, + text=True, + timeout=10, ) if result.stdout.strip() == "active": return True @@ -4177,7 +4729,10 @@ def _is_service_running() -> bool: try: result = _run_systemctl( ["is-active", get_service_name()], - system=True, capture_output=True, text=True, timeout=10, + system=True, + capture_output=True, + text=True, + timeout=10, ) if result.stdout.strip() == "active": return True @@ -4189,13 +4744,16 @@ def _is_service_running() -> bool: try: result = subprocess.run( ["launchctl", "list", get_launchd_label()], - capture_output=True, text=True, timeout=10, + capture_output=True, + text=True, + timeout=10, ) return result.returncode == 0 except subprocess.TimeoutExpired: return False elif is_windows(): from hermes_cli import gateway_windows + if gateway_windows.is_installed(): # "installed" doesn't necessarily mean "running" on Windows. The # canonical check is whether a gateway process actually exists. @@ -4211,8 +4769,12 @@ def _setup_weixin(): print() print_info(" 1. Hermes will open Tencent iLink QR login in this terminal.") print_info(" 2. Use WeChat to scan and confirm the QR code.") - print_info(" 3. Hermes will store the returned account_id/token in ~/.hermes/.env.") - print_info(" 4. This adapter supports native text, image, video, and document delivery.") + print_info( + " 3. Hermes will store the returned account_id/token in ~/.hermes/.env." + ) + print_info( + " 4. This adapter supports native text, image, video, and document delivery." + ) existing_account = get_env_value("WEIXIN_ACCOUNT_ID") existing_token = get_env_value("WEIXIN_TOKEN") @@ -4240,6 +4802,7 @@ def _setup_weixin(): return import asyncio + try: credentials = asyncio.run(qr_login(str(get_hermes_home()))) except KeyboardInterrupt: @@ -4263,7 +4826,10 @@ def _setup_weixin(): save_env_value("WEIXIN_TOKEN", token) if base_url: save_env_value("WEIXIN_BASE_URL", base_url) - save_env_value("WEIXIN_CDN_BASE_URL", get_env_value("WEIXIN_CDN_BASE_URL") or "https://novac2c.cdn.weixin.qq.com/c2c") + save_env_value( + "WEIXIN_CDN_BASE_URL", + get_env_value("WEIXIN_CDN_BASE_URL") or "https://novac2c.cdn.weixin.qq.com/c2c", + ) print() access_choices = [ @@ -4272,13 +4838,17 @@ def _setup_weixin(): "Only allow listed user IDs", "Disable direct messages", ] - access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0) + access_idx = prompt_choice( + " How should direct messages be authorized?", access_choices, 0 + ) if access_idx == 0: save_env_value("WEIXIN_DM_POLICY", "pairing") save_env_value("WEIXIN_ALLOW_ALL_USERS", "false") save_env_value("WEIXIN_ALLOWED_USERS", "") print_success(" DM pairing enabled.") - print_info(" Unknown DM users can request access and you approve them with `hermes pairing approve`.") + print_info( + " Unknown DM users can request access and you approve them with `hermes pairing approve`." + ) elif access_idx == 1: save_env_value("WEIXIN_DM_POLICY", "open") save_env_value("WEIXIN_ALLOW_ALL_USERS", "true") @@ -4286,7 +4856,9 @@ def _setup_weixin(): print_warning(" Open DM access enabled for Weixin.") elif access_idx == 2: default_allow = user_id or "" - allowlist = prompt(" Allowed Weixin user IDs (comma-separated)", default_allow, password=False).replace(" ", "") + allowlist = prompt( + " Allowed Weixin user IDs (comma-separated)", default_allow, password=False + ).replace(" ", "") save_env_value("WEIXIN_DM_POLICY", "allowlist") save_env_value("WEIXIN_ALLOW_ALL_USERS", "false") save_env_value("WEIXIN_ALLOWED_USERS", allowlist) @@ -4298,11 +4870,21 @@ def _setup_weixin(): print_warning(" Direct messages disabled.") print() - print_info(" Note: QR login connects an iLink bot identity (e.g. ...@im.bot), not a") - print_info(" scriptable personal WeChat account. Ordinary WeChat groups typically cannot") - print_info(" invite an @im.bot identity, and iLink does not deliver ordinary-group events") - print_info(" to most bot accounts. The settings below only apply when iLink actually") - print_info(" delivers group events for your account type — otherwise DM remains the only") + print_info( + " Note: QR login connects an iLink bot identity (e.g. ...@im.bot), not a" + ) + print_info( + " scriptable personal WeChat account. Ordinary WeChat groups typically cannot" + ) + print_info( + " invite an @im.bot identity, and iLink does not deliver ordinary-group events" + ) + print_info( + " to most bot accounts. The settings below only apply when iLink actually" + ) + print_info( + " delivers group events for your account type — otherwise DM remains the only" + ) print_info(" working channel regardless of this choice.") group_choices = [ "Disable group chats (recommended)", @@ -4317,16 +4899,26 @@ def _setup_weixin(): elif group_idx == 1: save_env_value("WEIXIN_GROUP_POLICY", "open") save_env_value("WEIXIN_GROUP_ALLOWED_USERS", "") - print_warning(" All group chats enabled (only takes effect if iLink delivers group events).") + print_warning( + " All group chats enabled (only takes effect if iLink delivers group events)." + ) else: - allow_groups = prompt(" Allowed group chat IDs (comma-separated, not member user IDs)", "", password=False).replace(" ", "") + allow_groups = prompt( + " Allowed group chat IDs (comma-separated, not member user IDs)", + "", + password=False, + ).replace(" ", "") save_env_value("WEIXIN_GROUP_POLICY", "allowlist") save_env_value("WEIXIN_GROUP_ALLOWED_USERS", allow_groups) - print_success(" Group allowlist saved (only takes effect if iLink delivers group events).") + print_success( + " Group allowlist saved (only takes effect if iLink delivers group events)." + ) if user_id: print() - if prompt_yes_no(f" Use your Weixin user ID ({user_id}) as the home channel?", True): + if prompt_yes_no( + f" Use your Weixin user ID ({user_id}) as the home channel?", True + ): save_env_value("WEIXIN_HOME_CHANNEL", user_id) print_success(f" Home channel set to {user_id}") @@ -4356,7 +4948,9 @@ def _setup_feishu(): "Scan QR code to create a new bot automatically (recommended)", "Enter existing App ID and App Secret manually", ] - method_idx = prompt_choice(" How would you like to set up Feishu / Lark?", method_choices, 0) + method_idx = prompt_choice( + " How would you like to set up Feishu / Lark?", method_choices, 0 + ) credentials = None used_qr = False @@ -4386,8 +4980,12 @@ def _setup_feishu(): # ── Manual credential input ── if not credentials: print() - print_info(" Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)") - print_info(" Create an app, enable the Bot capability, and copy the credentials.") + print_info( + " Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)" + ) + print_info( + " Create an app, enable the Bot capability, and copy the credentials." + ) print() app_id = prompt(" App ID", password=False) if not app_id: @@ -4406,12 +5004,15 @@ def _setup_feishu(): bot_name = None try: from gateway.platforms.feishu import probe_bot + bot_info = probe_bot(app_id, app_secret, domain) if bot_info: bot_name = bot_info.get("bot_name") print_success(f" Credentials verified — bot: {bot_name or 'unnamed'}") else: - print_warning(" Could not verify bot connection. Credentials saved anyway.") + print_warning( + " Could not verify bot connection. Credentials saved anyway." + ) except Exception as exc: print_warning(f" Credential verification skipped: {exc}") @@ -4448,8 +5049,12 @@ def _setup_feishu(): connection_mode = "webhook" if mode_idx == 1 else "websocket" if connection_mode == "webhook": print_info(" Webhook defaults: 127.0.0.1:8765/feishu/webhook") - print_info(" Override with FEISHU_WEBHOOK_HOST / FEISHU_WEBHOOK_PORT / FEISHU_WEBHOOK_PATH") - print_info(" For signature verification, set FEISHU_ENCRYPT_KEY and FEISHU_VERIFICATION_TOKEN") + print_info( + " Override with FEISHU_WEBHOOK_HOST / FEISHU_WEBHOOK_PORT / FEISHU_WEBHOOK_PATH" + ) + print_info( + " For signature verification, set FEISHU_ENCRYPT_KEY and FEISHU_VERIFICATION_TOKEN" + ) save_env_value("FEISHU_CONNECTION_MODE", connection_mode) if bot_name: @@ -4463,12 +5068,16 @@ def _setup_feishu(): "Allow all direct messages", "Only allow listed user IDs", ] - access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0) + access_idx = prompt_choice( + " How should direct messages be authorized?", access_choices, 0 + ) if access_idx == 0: save_env_value("FEISHU_ALLOW_ALL_USERS", "false") save_env_value("FEISHU_ALLOWED_USERS", "") print_success(" DM pairing enabled.") - print_info(" Unknown users can request access; approve with `hermes pairing approve`.") + print_info( + " Unknown users can request access; approve with `hermes pairing approve`." + ) elif access_idx == 1: save_env_value("FEISHU_ALLOW_ALL_USERS", "true") save_env_value("FEISHU_ALLOWED_USERS", "") @@ -4476,7 +5085,9 @@ def _setup_feishu(): else: save_env_value("FEISHU_ALLOW_ALL_USERS", "false") default_allow = open_id or "" - allowlist = prompt(" Allowed user IDs (comma-separated)", default_allow, password=False).replace(" ", "") + allowlist = prompt( + " Allowed user IDs (comma-separated)", default_allow, password=False + ).replace(" ", "") save_env_value("FEISHU_ALLOWED_USERS", allowlist) print_success(" Allowlist saved.") @@ -4496,7 +5107,9 @@ def _setup_feishu(): # ── Home channel ── print() - home_channel = prompt(" Home chat ID (optional, for cron/notifications)", password=False) + home_channel = prompt( + " Home chat ID (optional, for cron/notifications)", password=False + ) if home_channel: save_env_value("FEISHU_HOME_CHANNEL", home_channel) print_success(f" Home channel set to {home_channel}") @@ -4528,7 +5141,9 @@ def _setup_qqbot(): "Scan QR code to add bot automatically (recommended)", "Enter existing App ID and App Secret manually", ] - method_idx = prompt_choice(" How would you like to set up QQ Bot?", method_choices, 0) + method_idx = prompt_choice( + " How would you like to set up QQ Bot?", method_choices, 0 + ) credentials = None @@ -4536,6 +5151,7 @@ def _setup_qqbot(): # ── QR scan-to-configure ── try: from gateway.platforms.qqbot import qr_register + credentials = qr_register() except KeyboardInterrupt: print() @@ -4558,7 +5174,11 @@ def _setup_qqbot(): if not app_secret: print_warning(" Skipped — QQ Bot won't work without an App Secret.") return - credentials = {"app_id": app_id.strip(), "client_secret": app_secret.strip(), "user_openid": ""} + credentials = { + "app_id": app_id.strip(), + "client_secret": app_secret.strip(), + "user_openid": "", + } # ── Save core credentials ── save_env_value("QQ_APP_ID", credentials["app_id"]) @@ -4573,12 +5193,16 @@ def _setup_qqbot(): "Allow all direct messages", "Only allow listed user OpenIDs", ] - access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0) + access_idx = prompt_choice( + " How should direct messages be authorized?", access_choices, 0 + ) if access_idx == 0: save_env_value("QQ_ALLOW_ALL_USERS", "false") if user_openid: print() - if prompt_yes_no(f" Add yourself ({user_openid}) to the allow list?", True): + if prompt_yes_no( + f" Add yourself ({user_openid}) to the allow list?", True + ): save_env_value("QQ_ALLOWED_USERS", user_openid) print_success(f" Allow list set to {user_openid}") else: @@ -4586,14 +5210,18 @@ def _setup_qqbot(): else: save_env_value("QQ_ALLOWED_USERS", "") print_success(" DM pairing enabled.") - print_info(" Unknown users can request access; approve with `hermes pairing approve`.") + print_info( + " Unknown users can request access; approve with `hermes pairing approve`." + ) elif access_idx == 1: save_env_value("QQ_ALLOW_ALL_USERS", "true") save_env_value("QQ_ALLOWED_USERS", "") print_warning(" Open DM access enabled for QQ Bot.") else: default_allow = user_openid or "" - allowlist = prompt(" Allowed user OpenIDs (comma-separated)", default_allow, password=False).replace(" ", "") + allowlist = prompt( + " Allowed user OpenIDs (comma-separated)", default_allow, password=False + ).replace(" ", "") save_env_value("QQ_ALLOW_ALL_USERS", "false") save_env_value("QQ_ALLOWED_USERS", allowlist) print_success(" Allowlist saved.") @@ -4601,12 +5229,16 @@ def _setup_qqbot(): # ── Home channel ── if user_openid: print() - if prompt_yes_no(f" Use your QQ user ID ({user_openid}) as the home channel?", True): + if prompt_yes_no( + f" Use your QQ user ID ({user_openid}) as the home channel?", True + ): save_env_value("QQBOT_HOME_CHANNEL", user_openid) print_success(f" Home channel set to {user_openid}") else: print() - home_channel = prompt(" Home channel OpenID (for cron/notifications, or empty)", password=False) + home_channel = prompt( + " Home channel OpenID (for cron/notifications, or empty)", password=False + ) if home_channel: save_env_value("QQBOT_HOME_CHANNEL", home_channel.strip()) print_success(f" Home channel set to {home_channel.strip()}") @@ -4639,12 +5271,14 @@ def _setup_signal(): print_warning("signal-cli not found on PATH.") print_info(" Signal requires signal-cli running as an HTTP daemon.") print_info(" Install options:") - print_info(" Linux: download from https://github.com/AsamK/signal-cli/releases") + print_info( + " Linux: download from https://github.com/AsamK/signal-cli/releases" + ) print_info(" macOS: brew install signal-cli") print_info(" Docker: bbernhard/signal-cli-rest-api") print() print_info(" After installing, link your account and start the daemon:") - print_info(" signal-cli link -n \"HermesAgent\"") + print_info(' signal-cli link -n "HermesAgent"') print_info(" signal-cli --account +YOURNUMBER daemon --http 127.0.0.1:8080") print() @@ -4662,6 +5296,7 @@ def _setup_signal(): print_info(" Testing connection...") try: import httpx + resp = httpx.get(f"{url.rstrip('/')}/api/v1/check", timeout=10.0) if resp.status_code == 200: print_success(" signal-cli daemon is reachable!") @@ -4671,7 +5306,9 @@ def _setup_signal(): return except Exception as e: print_warning(f" Could not reach signal-cli at {url}: {e}") - if not prompt_yes_no(" Save this URL anyway? (you can start signal-cli later)", True): + if not prompt_yes_no( + " Save this URL anyway? (you can start signal-cli later)", True + ): return save_env_value("SIGNAL_HTTP_URL", url) @@ -4682,7 +5319,9 @@ def _setup_signal(): print_info(" Example: +15551234567") default_account = existing_account or "" try: - account = input(f" Account number{f' [{default_account}]' if default_account else ''}: ").strip() + account = input( + f" Account number{f' [{default_account}]' if default_account else ''}: " + ).strip() if not account: account = default_account except (EOFError, KeyboardInterrupt): @@ -4702,7 +5341,9 @@ def _setup_signal(): existing_allowed = get_env_value("SIGNAL_ALLOWED_USERS") or "" default_allowed = existing_allowed or account try: - allowed = input(f" Allowed users [{default_allowed}]: ").strip() or default_allowed + allowed = ( + input(f" Allowed users [{default_allowed}]: ").strip() or default_allowed + ) except (EOFError, KeyboardInterrupt): print("\n Setup cancelled.") return @@ -4711,12 +5352,18 @@ def _setup_signal(): # Group messaging print() - if prompt_yes_no(" Enable group messaging? (disabled by default for security)", False): + if prompt_yes_no( + " Enable group messaging? (disabled by default for security)", False + ): print() print_info(" Enter group IDs to allow, or * for all groups.") existing_groups = get_env_value("SIGNAL_GROUP_ALLOWED_USERS") or "" try: - groups = input(f" Group IDs [{existing_groups or '*'}]: ").strip() or existing_groups or "*" + groups = ( + input(f" Group IDs [{existing_groups or '*'}]: ").strip() + or existing_groups + or "*" + ) except (EOFError, KeyboardInterrupt): print("\n Setup cancelled.") return @@ -4727,7 +5374,9 @@ def _setup_signal(): print_info(f" URL: {url}") print_info(f" Account: {account}") print_info(" DM auth: via SIGNAL_ALLOWED_USERS + DM pairing") - print_info(f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}") + print_info( + f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}" + ) def _builtin_setup_fn(key: str): @@ -4737,6 +5386,7 @@ def _builtin_setup_fn(key: str): imports from this module for the remaining bespoke flows). """ from hermes_cli import setup as _s + return { "telegram": _s._setup_telegram, "discord": _s._setup_discord, @@ -4753,6 +5403,8 @@ def _builtin_setup_fn(key: str): "wecom": _setup_wecom, "qqbot": _setup_qqbot, }.get(key) + + def _configure_platform(platform: dict) -> None: """Run the interactive setup flow for a single platform. @@ -4790,7 +5442,9 @@ def _configure_platform(platform: dict) -> None: if required: print_info(f" Set these env vars in ~/.hermes/.env: {', '.join(required)}") else: - print_info(f" Configure {label} in config.yaml under gateway.platforms.{platform['key']}") + print_info( + f" Configure {label} in config.yaml under gateway.platforms.{platform['key']}" + ) if platform.get("install_hint"): print_info(f" {platform['install_hint']}") @@ -4802,12 +5456,40 @@ def gateway_setup(): return print() - print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA)) - print(color("│ ⚕ Gateway Setup │", Colors.MAGENTA)) - print(color("├─────────────────────────────────────────────────────────┤", Colors.MAGENTA)) - print(color("│ Configure messaging platforms and the gateway service. │", Colors.MAGENTA)) - print(color("│ Press Ctrl+C at any time to exit. │", Colors.MAGENTA)) - print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA)) + print( + color( + "┌─────────────────────────────────────────────────────────┐", + Colors.MAGENTA, + ) + ) + print( + color( + "│ ⚕ Gateway Setup │", Colors.MAGENTA + ) + ) + print( + color( + "├─────────────────────────────────────────────────────────┤", + Colors.MAGENTA, + ) + ) + print( + color( + "│ Configure messaging platforms and the gateway service. │", + Colors.MAGENTA, + ) + ) + print( + color( + "│ Press Ctrl+C at any time to exit. │", Colors.MAGENTA + ) + ) + print( + color( + "└─────────────────────────────────────────────────────────┘", + Colors.MAGENTA, + ) + ) # ── Gateway service status ── print() @@ -4858,12 +5540,13 @@ def gateway_setup(): platforms = _all_platforms() menu_items = [ - f"{p['emoji']} {p['label']} ({_platform_status(p)})" - for p in platforms + f"{p['emoji']} {p['label']} ({_platform_status(p)})" for p in platforms ] menu_items.append("Done") - choice = prompt_choice("Select a platform to configure:", menu_items, len(menu_items) - 1) + choice = prompt_choice( + "Select a platform to configure:", menu_items, len(menu_items) - 1 + ) if choice == len(platforms): break @@ -4882,9 +5565,7 @@ def gateway_setup(): or s.startswith("plugin disabled") ) - any_configured = any( - _is_progress(_platform_status(p)) for p in _all_platforms() - ) + any_configured = any(_is_progress(_platform_status(p)) for p in _all_platforms()) if any_configured: print() @@ -4903,6 +5584,7 @@ def gateway_setup(): launchd_restart() elif is_windows(): from hermes_cli import gateway_windows + gateway_windows.restart() else: stop_profile_gateway() @@ -4927,6 +5609,7 @@ def gateway_setup(): launchd_start() elif is_windows(): from hermes_cli import gateway_windows + gateway_windows.start() except UserSystemdUnavailableError as e: print_error(" Start failed — user systemd not reachable:") @@ -4946,14 +5629,21 @@ def gateway_setup(): platform_name = "launchd" else: platform_name = "Scheduled Task" - wsl_note = " (note: services may not survive WSL restarts)" if is_wsl() else "" - if prompt_yes_no(f" Install the gateway as a {platform_name} service?{wsl_note} (runs in background, starts on boot)", True): + wsl_note = ( + " (note: services may not survive WSL restarts)" if is_wsl() else "" + ) + if prompt_yes_no( + f" Install the gateway as a {platform_name} service?{wsl_note} (runs in background, starts on boot)", + True, + ): try: installed_scope = None did_install = False started_inline = False if supports_systemd_services(): - installed_scope, did_install = install_linux_gateway_from_setup(force=False) + installed_scope, did_install = ( + install_linux_gateway_from_setup(force=False) + ) elif is_macos(): launchd_install(force=False) did_install = True @@ -4962,18 +5652,25 @@ def gateway_setup(): # Task AND starts it (schtasks /Run or direct-spawn # fallback), so no separate start prompt is needed. from hermes_cli import gateway_windows + gateway_windows.install(force=False) did_install = True started_inline = True print() - if did_install and not started_inline and prompt_yes_no(" Start the service now?", True): + if ( + did_install + and not started_inline + and prompt_yes_no(" Start the service now?", True) + ): try: if supports_systemd_services(): systemd_start(system=installed_scope == "system") else: launchd_start() except UserSystemdUnavailableError as e: - print_error(" Start failed — user systemd not reachable:") + print_error( + " Start failed — user systemd not reachable:" + ) for line in str(e).splitlines(): print(f" {line}") except subprocess.CalledProcessError as e: @@ -4984,18 +5681,27 @@ def gateway_setup(): else: print_info(" You can install later: hermes gateway install") if supports_systemd_services(): - print_info(" Or as a boot-time service: sudo hermes gateway install --system") + print_info( + " Or as a boot-time service: sudo hermes gateway install --system" + ) print_info(" Or run in foreground: hermes gateway run") elif is_wsl(): print_info(" WSL detected but systemd is not running.") print_info(" Run in foreground: hermes gateway run") - print_info(" For persistence: tmux new -s hermes 'hermes gateway run'") - print_info(" To enable systemd: add systemd=true to /etc/wsl.conf, then 'wsl --shutdown'") + print_info( + " For persistence: tmux new -s hermes 'hermes gateway run'" + ) + print_info( + " To enable systemd: add systemd=true to /etc/wsl.conf, then 'wsl --shutdown'" + ) elif is_termux(): from hermes_constants import display_hermes_home as _dhh + print_info(" Termux does not use systemd/launchd services.") print_info(" Run in foreground: hermes gateway run") - print_info(f" Or start it manually in the background (best effort): nohup hermes gateway run >{_dhh()}/logs/gateway.log 2>&1 &") + print_info( + f" Or start it manually in the background (best effort): nohup hermes gateway run >{_dhh()}/logs/gateway.log 2>&1 &" + ) else: print_info(" Service install not supported on this platform.") print_info(" Run in foreground: hermes gateway run") @@ -5010,6 +5716,7 @@ def gateway_setup(): # Main Command Handler # ============================================================================= + def gateway_command(args): """Handle gateway subcommands.""" try: @@ -5032,13 +5739,13 @@ def gateway_command(args): def _gateway_command_inner(args): - subcmd = getattr(args, 'gateway_command', None) - + subcmd = getattr(args, "gateway_command", None) + # Default to run if no subcommand if subcmd is None or subcmd == "run": - verbose = getattr(args, 'verbose', 0) - quiet = getattr(args, 'quiet', False) - replace = getattr(args, 'replace', False) + verbose = getattr(args, "verbose", 0) + quiet = getattr(args, "quiet", False) + replace = getattr(args, "replace", False) run_gateway(verbose, quiet=quiet, replace=replace) return @@ -5051,39 +5758,58 @@ def _gateway_command_inner(args): if is_managed(): managed_error("install gateway service (managed by NixOS)") return - force = getattr(args, 'force', False) - system = getattr(args, 'system', False) - run_as_user = getattr(args, 'run_as_user', None) + force = getattr(args, "force", False) + system = getattr(args, "system", False) + run_as_user = getattr(args, "run_as_user", None) if is_termux(): print("Gateway service installation is not supported on Termux.") print("Run manually: hermes gateway") sys.exit(1) if supports_systemd_services(): if is_wsl(): - print_warning("WSL detected — systemd services may not survive WSL restarts.") - print_info(" Consider running in foreground instead: hermes gateway run") - print_info(" Or use tmux/screen for persistence: tmux new -s hermes 'hermes gateway run'") + print_warning( + "WSL detected — systemd services may not survive WSL restarts." + ) + print_info( + " Consider running in foreground instead: hermes gateway run" + ) + print_info( + " Or use tmux/screen for persistence: tmux new -s hermes 'hermes gateway run'" + ) print() systemd_install(force=force, system=system, run_as_user=run_as_user) elif is_macos(): launchd_install(force) elif is_windows(): from hermes_cli import gateway_windows + gateway_windows.install(force=force) elif is_wsl(): print("WSL detected but systemd is not running.") - print("Either enable systemd (add systemd=true to /etc/wsl.conf and restart WSL)") + print( + "Either enable systemd (add systemd=true to /etc/wsl.conf and restart WSL)" + ) print("or run the gateway in foreground mode:") print() - print(" hermes gateway run # direct foreground") - print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux") - print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background") + print( + " hermes gateway run # direct foreground" + ) + print( + " tmux new -s hermes 'hermes gateway run' # persistent via tmux" + ) + print( + " nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background" + ) sys.exit(1) elif is_container(): print("Service installation is not needed inside a Docker container.") - print("The container runtime is your service manager — use Docker restart policies instead:") + print( + "The container runtime is your service manager — use Docker restart policies instead:" + ) print() - print(" docker run --restart unless-stopped ... # auto-restart on crash/reboot") + print( + " docker run --restart unless-stopped ... # auto-restart on crash/reboot" + ) print(" docker restart # manual restart") print() print("To run the gateway: hermes gateway run") @@ -5092,14 +5818,16 @@ def _gateway_command_inner(args): print("Service installation not supported on this platform.") print("Run manually: hermes gateway run") sys.exit(1) - + elif subcmd == "uninstall": if is_managed(): managed_error("uninstall gateway service (managed by NixOS)") return - system = getattr(args, 'system', False) + system = getattr(args, "system", False) if is_termux(): - print("Gateway service uninstall is not supported on Termux because there is no managed service to remove.") + print( + "Gateway service uninstall is not supported on Termux because there is no managed service to remove." + ) print("Stop manual runs with: hermes gateway stop") sys.exit(1) if supports_systemd_services(): @@ -5108,6 +5836,7 @@ def _gateway_command_inner(args): launchd_uninstall() elif is_windows(): from hermes_cli import gateway_windows + gateway_windows.uninstall() elif is_container(): print("Service uninstall is not applicable inside a Docker container.") @@ -5121,18 +5850,22 @@ def _gateway_command_inner(args): sys.exit(1) elif subcmd == "start": - system = getattr(args, 'system', False) - start_all = getattr(args, 'all', False) + system = getattr(args, "system", False) + start_all = getattr(args, "all", False) if start_all: # Kill all stale gateway processes across all profiles before starting killed = kill_gateway_processes(all_profiles=True) if killed: - print(f"✓ Killed {killed} stale gateway process(es) across all profiles") + print( + f"✓ Killed {killed} stale gateway process(es) across all profiles" + ) _wait_for_gateway_exit(timeout=10.0, force_after=5.0) if is_termux(): - print("Gateway service start is not supported on Termux because there is no system service manager.") + print( + "Gateway service start is not supported on Termux because there is no system service manager." + ) print("Run manually: hermes gateway") sys.exit(1) if supports_systemd_services(): @@ -5141,16 +5874,25 @@ def _gateway_command_inner(args): launchd_start() elif is_windows(): from hermes_cli import gateway_windows + gateway_windows.start() elif is_wsl(): print("WSL detected but systemd is not available.") print("Run the gateway in foreground mode instead:") print() - print(" hermes gateway run # direct foreground") - print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux") - print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background") + print( + " hermes gateway run # direct foreground" + ) + print( + " tmux new -s hermes 'hermes gateway run' # persistent via tmux" + ) + print( + " nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background" + ) print() - print("To enable systemd: add systemd=true to /etc/wsl.conf and run 'wsl --shutdown' from PowerShell.") + print( + "To enable systemd: add systemd=true to /etc/wsl.conf and run 'wsl --shutdown' from PowerShell." + ) sys.exit(1) elif is_container(): print("Service start is not applicable inside a Docker container.") @@ -5166,13 +5908,16 @@ def _gateway_command_inner(args): sys.exit(1) elif subcmd == "stop": - stop_all = getattr(args, 'all', False) - system = getattr(args, 'system', False) + stop_all = getattr(args, "all", False) + system = getattr(args, "system", False) if stop_all: # --all: kill every gateway process on the machine service_available = False - if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ): try: systemd_stop(system=system) service_available = True @@ -5186,6 +5931,7 @@ def _gateway_command_inner(args): pass elif is_windows(): from hermes_cli import gateway_windows + if gateway_windows.is_installed(): try: gateway_windows.stop() @@ -5201,7 +5947,10 @@ def _gateway_command_inner(args): else: # Default: stop only the current profile's gateway service_available = False - if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ): try: systemd_stop(system=system) service_available = True @@ -5215,6 +5964,7 @@ def _gateway_command_inner(args): pass elif is_windows(): from hermes_cli import gateway_windows + if gateway_windows.is_installed(): try: gateway_windows.stop() @@ -5230,18 +5980,21 @@ def _gateway_command_inner(args): print("✗ No gateway running for this profile") else: print(f"✓ Stopped {get_service_name()} service") - + elif subcmd == "restart": # Try service first, fall back to killing and restarting service_available = False - system = getattr(args, 'system', False) - restart_all = getattr(args, 'all', False) + system = getattr(args, "system", False) + restart_all = getattr(args, "all", False) service_configured = False if restart_all: # --all: stop every gateway process across all profiles, then start fresh service_stopped = False - if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ): try: systemd_stop(system=system) service_stopped = True @@ -5255,6 +6008,7 @@ def _gateway_command_inner(args): pass elif is_windows(): from hermes_cli import gateway_windows + if gateway_windows.is_installed(): try: gateway_windows.stop() @@ -5269,12 +6023,16 @@ def _gateway_command_inner(args): # Start the current profile's service fresh print("Starting gateway...") - if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ): systemd_start(system=system) elif is_macos() and get_launchd_plist_path().exists(): launchd_start() elif is_windows(): from hermes_cli import gateway_windows + if gateway_windows.is_installed(): gateway_windows.start() else: @@ -5282,8 +6040,11 @@ def _gateway_command_inner(args): else: run_gateway(verbose=0) return - - if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ): service_configured = True try: systemd_restart(system=system) @@ -5299,6 +6060,7 @@ def _gateway_command_inner(args): pass elif is_windows(): from hermes_cli import gateway_windows + if gateway_windows.is_installed(): service_configured = True try: @@ -5306,17 +6068,22 @@ def _gateway_command_inner(args): service_available = True except (subprocess.CalledProcessError, RuntimeError): pass - + if not service_available: # systemd/launchd restart failed — check if linger is the issue if supports_systemd_services(): linger_ok, _detail = get_systemd_linger_status() if linger_ok is not True: import getpass + _username = getpass.getuser() print() - print("⚠ Cannot restart gateway as a service — linger is not enabled.") - print(" The gateway user service requires linger to function on headless servers.") + print( + "⚠ Cannot restart gateway as a service — linger is not enabled." + ) + print( + " The gateway user service requires linger to function on headless servers." + ) print() print(f" Run: sudo loginctl enable-linger {_username}") print() @@ -5327,7 +6094,9 @@ def _gateway_command_inner(args): if service_configured: print() print("✗ Gateway service restart failed.") - print(" The service definition exists, but the service manager did not recover it.") + print( + " The service definition exists, but the service manager did not recover it." + ) print(" Fix the service, then retry: hermes gateway start") sys.exit(1) @@ -5340,19 +6109,23 @@ def _gateway_command_inner(args): # Start fresh print("Starting gateway...") run_gateway(verbose=0) - + elif subcmd == "status": - deep = getattr(args, 'deep', False) - full = getattr(args, 'full', False) - system = getattr(args, 'system', False) + deep = getattr(args, "deep", False) + full = getattr(args, "full", False) + system = getattr(args, "system", False) snapshot = get_gateway_runtime_snapshot(system=system) - + # Check for service first _windows_service_installed = False if is_windows(): from hermes_cli import gateway_windows + _windows_service_installed = gateway_windows.is_installed() - if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() + or get_systemd_unit_path(system=True).exists() + ): systemd_status(deep, system=system, full=full) _print_gateway_process_mismatch(snapshot) elif is_macos() and get_launchd_plist_path().exists(): @@ -5360,6 +6133,7 @@ def _gateway_command_inner(args): _print_gateway_process_mismatch(snapshot) elif _windows_service_installed: from hermes_cli import gateway_windows + gateway_windows.status(deep=deep) _print_gateway_process_mismatch(snapshot) else: @@ -5380,10 +6154,16 @@ def _gateway_command_inner(args): print(" Android may stop background jobs when Termux is suspended") elif is_wsl(): print("WSL note:") - print(" The gateway is running in foreground/manual mode (recommended for WSL).") - print(" Use tmux or screen for persistence across terminal closes.") + print( + " The gateway is running in foreground/manual mode (recommended for WSL)." + ) + print( + " Use tmux or screen for persistence across terminal closes." + ) elif is_windows(): - print("To install as a Windows Scheduled Task (auto-start on login):") + print( + "To install as a Windows Scheduled Task (auto-start on login):" + ) print(" hermes gateway install") else: print("To install as a service:") @@ -5401,15 +6181,25 @@ def _gateway_command_inner(args): print("To start:") print(" hermes gateway run # Run in foreground") if is_termux(): - print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # Best-effort background start") + print( + " nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # Best-effort background start" + ) elif is_wsl(): - print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux") - print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background") + print( + " tmux new -s hermes 'hermes gateway run' # persistent via tmux" + ) + print( + " nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background" + ) elif is_windows(): - print(" hermes gateway install # Install as Windows Scheduled Task (auto-start on login)") + print( + " hermes gateway install # Install as Windows Scheduled Task (auto-start on login)" + ) else: print(" hermes gateway install # Install as user service") - print(" sudo hermes gateway install --system # Install as boot-time system service") + print( + " sudo hermes gateway install --system # Install as boot-time system service" + ) # Show other profiles' gateway status for multi-profile awareness _print_other_profiles_gateway_status() @@ -5421,8 +6211,8 @@ def _gateway_command_inner(args): # Stop, disable, and remove legacy Hermes gateway unit files from # pre-rename installs (e.g. hermes.service). Profile units and # unrelated third-party services are never touched. - dry_run = getattr(args, 'dry_run', False) - yes = getattr(args, 'yes', False) + dry_run = getattr(args, "dry_run", False) + yes = getattr(args, "yes", False) if not supports_systemd_services() and not is_macos(): print("Legacy unit migration only applies to systemd-based Linux hosts.") return diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 637924c1bfe..866cc7ef713 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -64,8 +64,15 @@ except ImportError: # them out of every other install path. After install, re-import. try: from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("tool.dashboard", prompt=False) - from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect + from fastapi import ( + FastAPI, + HTTPException, + Request, + WebSocket, + WebSocketDisconnect, + ) from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles @@ -76,7 +83,11 @@ except ImportError: f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'" ) -WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist" +WEB_DIST = ( + Path(os.environ["HERMES_WEB_DIST"]) + if "HERMES_WEB_DIST" in os.environ + else Path(__file__).parent / "web_dist" +) _log = logging.getLogger(__name__) app = FastAPI(title="Hermes Agent", version=__version__) @@ -88,9 +99,9 @@ app = FastAPI(title="Hermes Agent", version=__version__) # Native desktop shells can pre-seed the token because they own the local # child process and do not need to scrape index.html before opening /api/ws. # --------------------------------------------------------------------------- -_SESSION_TOKEN = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or secrets.token_urlsafe( - 32 -) +_SESSION_TOKEN = os.environ.get( + "HERMES_DASHBOARD_SESSION_TOKEN" +) or secrets.token_urlsafe(32) _SESSION_HEADER_NAME = "X-Hermes-Session-Token" # In-browser Chat tab (/chat, /api/pty, …). Off unless ``hermes dashboard --tui`` @@ -130,15 +141,17 @@ app.add_middleware( # /api/ is gated by the auth middleware below. Keep this list minimal — # only truly non-sensitive, read-only endpoints belong here. # --------------------------------------------------------------------------- -_PUBLIC_API_PATHS: frozenset = frozenset({ - "/api/status", - "/api/config/defaults", - "/api/config/schema", - "/api/model/info", - "/api/dashboard/themes", - "/api/dashboard/plugins", - "/api/dashboard/plugins/rescan", -}) +_PUBLIC_API_PATHS: frozenset = frozenset( + { + "/api/status", + "/api/config/defaults", + "/api/config/schema", + "/api/model/info", + "/api/dashboard/themes", + "/api/dashboard/plugins", + "/api/dashboard/plugins/rescan", + } +) def _has_valid_session_token(request: Request) -> bool: @@ -173,9 +186,13 @@ def _require_token(request: Request) -> None: # checks because the browser now considers evil.test and our dashboard # "same origin". Validating the Host header at the app layer rejects any # request whose Host isn't one we bound for. See GHSA-ppp5-vxwm-4cf7. -_LOOPBACK_HOST_VALUES: frozenset = frozenset({ - "localhost", "127.0.0.1", "::1", -}) +_LOOPBACK_HOST_VALUES: frozenset = frozenset( + { + "localhost", + "127.0.0.1", + "::1", + } +) def _is_accepted_host(host_header: str, bound_host: str) -> bool: @@ -319,12 +336,24 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "terminal.backend": { "type": "select", "description": "Terminal execution backend", - "options": ["local", "docker", "ssh", "modal", "daytona", "vercel_sandbox", "singularity"], + "options": [ + "local", + "docker", + "ssh", + "modal", + "daytona", + "vercel_sandbox", + "singularity", + ], }, "terminal.vercel_runtime": { "type": "select", "description": "Vercel Sandbox runtime", - "options": ["node24", "node22", "python3.13"], # sync with _SUPPORTED_VERCEL_RUNTIMES in terminal_tool.py + "options": [ + "node24", + "node22", + "python3.13", + ], # sync with _SUPPORTED_VERCEL_RUNTIMES in terminal_tool.py }, "terminal.modal_mode": { "type": "select", @@ -341,7 +370,7 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "description": "Speech-to-text provider", # "mistral" temporarily removed — mistralai PyPI package quarantined # (malicious 2.4.6 release on 2026-05-12). Restore once available. - "options": ["local", "groq", "openai", "xai", "elevenlabs"], + "options": ["local", "groq", "openai", "xai", "elevenlabs"], }, "stt.elevenlabs.model_id": { "type": "select", @@ -427,9 +456,21 @@ _CATEGORY_MERGE: Dict[str, str] = { # Display order for tabs — unlisted categories sort alphabetically after these. _CATEGORY_ORDER = [ - "general", "agent", "terminal", "display", "delegation", - "memory", "compression", "security", "browser", "voice", - "tts", "stt", "logging", "discord", "auxiliary", + "general", + "agent", + "terminal", + "display", + "delegation", + "memory", + "compression", + "security", + "browser", + "voice", + "tts", + "stt", + "logging", + "discord", + "auxiliary", ] @@ -458,7 +499,9 @@ def _build_schema_from_config( full_key = f"{prefix}.{key}" if prefix else key # Skip internal / version keys - if full_key in {"_config_version",}: + if full_key in { + "_config_version", + }: continue # Category is the first path component for nested keys, or "general" @@ -483,7 +526,9 @@ def _build_schema_from_config( if full_key in _SCHEMA_OVERRIDES: entry.update(_SCHEMA_OVERRIDES[full_key]) # Merge small categories - entry["category"] = _CATEGORY_MERGE.get(entry["category"], entry["category"]) + entry["category"] = _CATEGORY_MERGE.get( + entry["category"], entry["category"] + ) schema[full_key] = entry return schema @@ -538,6 +583,7 @@ class ModelAssignment(BaseModel): scope="auxiliary" with task="" → applied to every auxiliary.* slot scope="auxiliary" with task="__reset__" → resets every slot to provider="auto" """ + scope: str provider: str model: str @@ -668,7 +714,11 @@ async def get_status(): # Prefer the detailed health endpoint response (has full state) when the # local runtime status file is absent or stale (cross-container). runtime = read_runtime_status() - if runtime is None and remote_health_body and remote_health_body.get("gateway_state"): + if ( + runtime is None + and remote_health_body + and remote_health_body.get("gateway_state") + ): runtime = remote_health_body if runtime: @@ -683,7 +733,11 @@ async def get_status(): gateway_exit_reason = runtime.get("exit_reason") gateway_updated_at = runtime.get("updated_at") if not gateway_running: - gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped" + gateway_state = ( + gateway_state + if gateway_state in {"stopped", "startup_failed"} + else "stopped" + ) gateway_platforms = {} elif gateway_running and remote_health_body is not None: # The health probe confirmed the gateway is alive, but the local @@ -700,12 +754,14 @@ async def get_status(): active_sessions = 0 try: from hermes_state import SessionDB + db = SessionDB() try: sessions = db.list_sessions_rich(limit=50) now = time.time() active_sessions = sum( - 1 for s in sessions + 1 + for s in sessions if s.get("ended_at") is None and (now - s.get("last_active", s.get("started_at", 0))) < 300 ) @@ -741,12 +797,21 @@ async def transcribe_audio_upload(payload: AudioTranscriptionRequest): header, encoded = data_url.split(",", 1) if ";base64" not in header: - raise HTTPException(status_code=400, detail="Audio payload must be base64 encoded") + raise HTTPException( + status_code=400, detail="Audio payload must be base64 encoded" + ) - mime_type = (payload.mime_type or header[5:].split(";", 1)[0] or "audio/webm").strip() + mime_type = ( + payload.mime_type or header[5:].split(";", 1)[0] or "audio/webm" + ).strip() normalized_mime_type = mime_type.split(";", 1)[0].lower() - if not (normalized_mime_type.startswith("audio/") or normalized_mime_type == "video/webm"): - raise HTTPException(status_code=400, detail="Payload must be an audio recording") + if not ( + normalized_mime_type.startswith("audio/") + or normalized_mime_type == "video/webm" + ): + raise HTTPException( + status_code=400, detail="Payload must be an audio recording" + ) try: audio_bytes = base64.b64decode(encoded, validate=True) @@ -816,7 +881,11 @@ async def get_elevenlabs_voices(): The desktop UI uses this for the ``tts.elevenlabs.voice_id`` dropdown. Only non-secret voice metadata is returned; the API key stays server-side. """ - api_key = (load_env().get("ELEVENLABS_API_KEY") or os.environ.get("ELEVENLABS_API_KEY") or "").strip() + api_key = ( + load_env().get("ELEVENLABS_API_KEY") + or os.environ.get("ELEVENLABS_API_KEY") + or "" + ).strip() if not api_key: return {"available": False, "voices": []} @@ -849,11 +918,13 @@ async def get_elevenlabs_voices(): if not voice_id: continue - voices.append({ - "voice_id": voice_id, - "name": str(voice.get("name") or voice_id), - "label": _elevenlabs_voice_label(voice), - }) + voices.append( + { + "voice_id": voice_id, + "name": str(voice.get("name") or voice_id), + "label": _elevenlabs_voice_label(voice), + } + ) voices.sort(key=lambda item: str(item.get("label") or "").lower()) return {"available": True, "voices": voices} @@ -874,6 +945,7 @@ async def speak_text(payload: TTSSpeakRequest): try: from tools.tts_tool import text_to_speech_tool + loop = asyncio.get_running_loop() result_json = await loop.run_in_executor(None, text_to_speech_tool, text) except Exception as exc: @@ -881,7 +953,9 @@ async def speak_text(payload: TTSSpeakRequest): raise HTTPException(status_code=500, detail=f"Speech synthesis failed: {exc}") try: - result = json.loads(result_json) if isinstance(result_json, str) else result_json + result = ( + json.loads(result_json) if isinstance(result_json, str) else result_json + ) except Exception: raise HTTPException(status_code=500, detail="Invalid TTS response") @@ -945,6 +1019,10 @@ _ACTION_LOG_FILES: Dict[str, str] = { # ``name`` → most recently spawned Popen handle. Used so ``status`` can # report liveness and exit code without shelling out to ``ps``. _ACTION_PROCS: Dict[str, subprocess.Popen] = {} +# Serialize the check+spawn+store sequence so two concurrent restart/update +# requests can't both observe "no live process" and double-spawn (the FastAPI +# default threadpool runs sync handlers concurrently). +_ACTION_SPAWN_LOCK = threading.Lock() def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: @@ -953,34 +1031,55 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: Uses the running interpreter's ``hermes_cli.main`` module so the action inherits the same venv/PYTHONPATH the web server is using. """ - log_file_name = _ACTION_LOG_FILES[name] - _ACTION_LOG_DIR.mkdir(parents=True, exist_ok=True) - log_path = _ACTION_LOG_DIR / log_file_name - log_file = open(log_path, "ab", buffering=0) - log_file.write( - f"\n=== {name} started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode() - ) + with _ACTION_SPAWN_LOCK: + existing = _ACTION_PROCS.get(name) + if existing is not None and existing.poll() is None: + # Desktop can fire duplicate restart/update requests from retries. + # Reuse the active process instead of spawning overlapping actions. + return existing - cmd = [sys.executable, "-m", "hermes_cli.main", *subcommand] - - popen_kwargs: Dict[str, Any] = { - "cwd": str(PROJECT_ROOT), - "stdin": subprocess.DEVNULL, - "stdout": log_file, - "stderr": subprocess.STDOUT, - "env": {**os.environ, "HERMES_NONINTERACTIVE": "1"}, - } - if sys.platform == "win32": - popen_kwargs["creationflags"] = ( - subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] - | getattr(subprocess, "DETACHED_PROCESS", 0) + log_file_name = _ACTION_LOG_FILES[name] + _ACTION_LOG_DIR.mkdir(parents=True, exist_ok=True) + log_path = _ACTION_LOG_DIR / log_file_name + log_file = open(log_path, "ab", buffering=0) + log_file.write( + f"\n=== {name} started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode() ) - else: - popen_kwargs["start_new_session"] = True - proc = subprocess.Popen(cmd, **popen_kwargs) - _ACTION_PROCS[name] = proc - return proc + cmd = [sys.executable, "-m", "hermes_cli.main", *subcommand] + + popen_kwargs: Dict[str, Any] = { + "cwd": str(PROJECT_ROOT), + "stdin": subprocess.DEVNULL, + "stdout": log_file, + "stderr": subprocess.STDOUT, + "env": {**os.environ, "HERMES_NONINTERACTIVE": "1"}, + } + if sys.platform == "win32": + popen_kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + | getattr(subprocess, "DETACHED_PROCESS", 0) + ) + else: + popen_kwargs["start_new_session"] = True + + try: + proc = subprocess.Popen(cmd, **popen_kwargs) + except (OSError, ValueError) as exc: + # Record the failure in the action log so the dashboard's status + # endpoint surfaces something useful, then close the file handle + # before re-raising so we don't leak it. + try: + log_file.write( + f"=== {name} spawn failed: {exc} ===\n".encode() + ) + except Exception: # pragma: no cover - defensive logging + pass + log_file.close() + raise + + _ACTION_PROCS[name] = proc + return proc def _tail_lines(path: Path, n: int) -> List[str]: @@ -1060,10 +1159,13 @@ async def get_action_status(name: str, lines: int = 200): async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0): try: from hermes_state import SessionDB + db = SessionDB() try: min_message_count = max(0, min_messages) - sessions = db.list_sessions_rich(limit=limit, offset=offset, min_message_count=min_message_count) + sessions = db.list_sessions_rich( + limit=limit, offset=offset, min_message_count=min_message_count + ) total = db.session_count(min_message_count=min_message_count) now = time.time() for s in sessions: @@ -1075,7 +1177,12 @@ async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0): s.get("ended_at") is None and (now - s.get("last_active", s.get("started_at", 0))) < 300 ) - return {"sessions": sessions, "total": total, "limit": limit, "offset": offset} + return { + "sessions": sessions, + "total": total, + "limit": limit, + "offset": offset, + } finally: db.close() except Exception: @@ -1090,12 +1197,14 @@ async def search_sessions(q: str = "", limit: int = 20): return {"results": []} try: from hermes_state import SessionDB + db = SessionDB() try: # Auto-add prefix wildcards so partial words match # e.g. "nimb" → "nimb*" matches "nimby" # Preserve quoted phrases and existing wildcards as-is import re + terms = [] for token in re.findall(r'"[^"]*"|\S+', q.strip()): if token.startswith('"') or token.endswith("*"): @@ -1206,6 +1315,7 @@ def get_model_info(): # purely auto-detected value, then separately report the override) try: from agent.model_metadata import get_model_context_length + auto_ctx = get_model_context_length( model=model_name, base_url=base_url, @@ -1226,6 +1336,7 @@ def get_model_info(): caps = {} try: from agent.models_dev import get_model_capabilities + mc = get_model_capabilities(provider=provider, model=model_name) if mc is not None: caps = { @@ -1312,13 +1423,17 @@ def get_auxiliary_models(): tasks = [] for slot in _AUX_TASK_SLOTS: - slot_cfg = aux_cfg.get(slot, {}) if isinstance(aux_cfg.get(slot), dict) else {} - tasks.append({ - "task": slot, - "provider": str(slot_cfg.get("provider", "auto") or "auto"), - "model": str(slot_cfg.get("model", "") or ""), - "base_url": str(slot_cfg.get("base_url", "") or ""), - }) + slot_cfg = ( + aux_cfg.get(slot, {}) if isinstance(aux_cfg.get(slot), dict) else {} + ) + tasks.append( + { + "task": slot, + "provider": str(slot_cfg.get("provider", "auto") or "auto"), + "model": str(slot_cfg.get("model", "") or ""), + "base_url": str(slot_cfg.get("base_url", "") or ""), + } + ) model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): @@ -1349,14 +1464,18 @@ async def set_model_assignment(body: ModelAssignment): task = (body.task or "").strip().lower() if scope not in {"main", "auxiliary"}: - raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'") + raise HTTPException( + status_code=400, detail="scope must be 'main' or 'auxiliary'" + ) try: cfg = load_config() if scope == "main": if not provider or not model: - raise HTTPException(status_code=400, detail="provider and model required for main") + raise HTTPException( + status_code=400, detail="provider and model required for main" + ) model_cfg = cfg.get("model", {}) if not isinstance(model_cfg, dict): model_cfg = {} @@ -1392,12 +1511,16 @@ async def set_model_assignment(body: ModelAssignment): return {"ok": True, "scope": "auxiliary", "reset": True} if not provider: - raise HTTPException(status_code=400, detail="provider required for auxiliary") + raise HTTPException( + status_code=400, detail="provider required for auxiliary" + ) targets = [task] if task else list(_AUX_TASK_SLOTS) for slot in targets: if slot not in _AUX_TASK_SLOTS: - raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}") + raise HTTPException( + status_code=400, detail=f"unknown auxiliary task: {slot}" + ) slot_cfg = aux.get(slot) if not isinstance(slot_cfg, dict): slot_cfg = {} @@ -1421,8 +1544,6 @@ async def set_model_assignment(body: ModelAssignment): raise HTTPException(status_code=500, detail="Failed to save model assignment") - - def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]: """Reverse _normalize_config_for_web before saving. @@ -1545,7 +1666,9 @@ async def reveal_env_var(body: EnvVarReveal, request: Request): cutoff = now - _REVEAL_WINDOW_SECONDS _reveal_timestamps[:] = [t for t in _reveal_timestamps if t > cutoff] if len(_reveal_timestamps) >= _REVEAL_MAX_PER_WINDOW: - raise HTTPException(status_code=429, detail="Too many reveal requests. Try again shortly.") + raise HTTPException( + status_code=429, detail="Too many reveal requests. Try again shortly." + ) _reveal_timestamps.append(now) # --- Reveal --- @@ -1575,7 +1698,11 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { "name": "Discord", "description": "Connect Hermes to Discord DMs, channels, and threads.", "docs_url": "https://discord.com/developers/applications", - "env_vars": ("DISCORD_BOT_TOKEN", "DISCORD_ALLOWED_USERS", "DISCORD_REPLY_TO_MODE"), + "env_vars": ( + "DISCORD_BOT_TOKEN", + "DISCORD_ALLOWED_USERS", + "DISCORD_REPLY_TO_MODE", + ), "required_env": ("DISCORD_BOT_TOKEN",), }, "slack": { @@ -1596,7 +1723,12 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { "name": "Matrix", "description": "Use Hermes in Matrix rooms and direct messages.", "docs_url": "https://matrix.org/ecosystem/servers/", - "env_vars": ("MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID", "MATRIX_ALLOWED_USERS"), + "env_vars": ( + "MATRIX_HOMESERVER", + "MATRIX_ACCESS_TOKEN", + "MATRIX_USER_ID", + "MATRIX_ALLOWED_USERS", + ), "required_env": ("MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID"), }, "signal": { @@ -1624,8 +1756,18 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { "name": "Email", "description": "Talk to Hermes through an IMAP/SMTP mailbox.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/", - "env_vars": ("EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST"), - "required_env": ("EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST"), + "env_vars": ( + "EMAIL_ADDRESS", + "EMAIL_PASSWORD", + "EMAIL_IMAP_HOST", + "EMAIL_SMTP_HOST", + ), + "required_env": ( + "EMAIL_ADDRESS", + "EMAIL_PASSWORD", + "EMAIL_IMAP_HOST", + "EMAIL_SMTP_HOST", + ), }, "sms": { "name": "SMS (Twilio)", @@ -1645,7 +1787,12 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { "name": "Feishu / Lark", "description": "Use Hermes inside Feishu / Lark.", "docs_url": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/intro", - "env_vars": ("FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN"), + "env_vars": ( + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "FEISHU_ENCRYPT_KEY", + "FEISHU_VERIFICATION_TOKEN", + ), "required_env": ("FEISHU_APP_ID", "FEISHU_APP_SECRET"), }, "wecom": { @@ -1666,7 +1813,11 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { "WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY", ), - "required_env": ("WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID"), + "required_env": ( + "WECOM_CALLBACK_CORP_ID", + "WECOM_CALLBACK_CORP_SECRET", + "WECOM_CALLBACK_AGENT_ID", + ), }, "weixin": { "name": "WeChat (Official Account)", @@ -1679,7 +1830,11 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { "name": "BlueBubbles (iMessage)", "description": "Use Hermes through iMessage via a BlueBubbles server.", "docs_url": "https://bluebubbles.app/", - "env_vars": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", "BLUEBUBBLES_ALLOWED_USERS"), + "env_vars": ( + "BLUEBUBBLES_SERVER_URL", + "BLUEBUBBLES_PASSWORD", + "BLUEBUBBLES_ALLOWED_USERS", + ), "required_env": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"), }, "qqbot": { @@ -1764,7 +1919,11 @@ _MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = { "prompt": "Enable WhatsApp", "advanced": True, }, - "WHATSAPP_MODE": {"description": "WhatsApp bridge mode", "prompt": "WhatsApp mode", "advanced": True}, + "WHATSAPP_MODE": { + "description": "WhatsApp bridge mode", + "prompt": "WhatsApp mode", + "advanced": True, + }, "WHATSAPP_ALLOWED_USERS": { "description": "Comma-separated WhatsApp users allowed to use the bot", "prompt": "Allowed WhatsApp users", @@ -1778,47 +1937,94 @@ _MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = { "prompt": "Home Assistant access token", "password": True, }, - "EMAIL_ADDRESS": {"description": "Email address to send and receive from", "prompt": "Email address"}, + "EMAIL_ADDRESS": { + "description": "Email address to send and receive from", + "prompt": "Email address", + }, "EMAIL_PASSWORD": { "description": "Email account password or app password", "prompt": "Email password", "password": True, }, - "EMAIL_IMAP_HOST": {"description": "IMAP server host (e.g. imap.gmail.com)", "prompt": "IMAP host"}, - "EMAIL_SMTP_HOST": {"description": "SMTP server host (e.g. smtp.gmail.com)", "prompt": "SMTP host"}, + "EMAIL_IMAP_HOST": { + "description": "IMAP server host (e.g. imap.gmail.com)", + "prompt": "IMAP host", + }, + "EMAIL_SMTP_HOST": { + "description": "SMTP server host (e.g. smtp.gmail.com)", + "prompt": "SMTP host", + }, "TWILIO_ACCOUNT_SID": { "description": "Twilio Account SID", "prompt": "Twilio Account SID", "url": "https://www.twilio.com/console", }, - "TWILIO_AUTH_TOKEN": {"description": "Twilio Auth Token", "prompt": "Twilio Auth Token", "password": True}, + "TWILIO_AUTH_TOKEN": { + "description": "Twilio Auth Token", + "prompt": "Twilio Auth Token", + "password": True, + }, "WECOM_BOT_ID": {"description": "WeCom group bot ID", "prompt": "WeCom Bot ID"}, - "WECOM_SECRET": {"description": "WeCom group bot secret", "prompt": "WeCom Secret", "password": True}, - "WECOM_CALLBACK_CORP_ID": {"description": "WeCom corp ID", "prompt": "WeCom Corp ID"}, + "WECOM_SECRET": { + "description": "WeCom group bot secret", + "prompt": "WeCom Secret", + "password": True, + }, + "WECOM_CALLBACK_CORP_ID": { + "description": "WeCom corp ID", + "prompt": "WeCom Corp ID", + }, "WECOM_CALLBACK_CORP_SECRET": { "description": "WeCom app corp secret", "prompt": "WeCom Corp Secret", "password": True, }, - "WECOM_CALLBACK_AGENT_ID": {"description": "WeCom app agent ID", "prompt": "WeCom Agent ID"}, - "WECOM_CALLBACK_TOKEN": {"description": "WeCom callback verification token", "prompt": "WeCom Token"}, + "WECOM_CALLBACK_AGENT_ID": { + "description": "WeCom app agent ID", + "prompt": "WeCom Agent ID", + }, + "WECOM_CALLBACK_TOKEN": { + "description": "WeCom callback verification token", + "prompt": "WeCom Token", + }, "WECOM_CALLBACK_ENCODING_AES_KEY": { "description": "WeCom callback AES encoding key", "prompt": "WeCom AES Key", "password": True, }, - "WEIXIN_ACCOUNT_ID": {"description": "WeChat Official Account ID", "prompt": "Account ID"}, - "WEIXIN_TOKEN": {"description": "WeChat callback token", "prompt": "Token", "password": True}, - "WEIXIN_BASE_URL": {"description": "WeChat platform base URL", "prompt": "Base URL"}, + "WEIXIN_ACCOUNT_ID": { + "description": "WeChat Official Account ID", + "prompt": "Account ID", + }, + "WEIXIN_TOKEN": { + "description": "WeChat callback token", + "prompt": "Token", + "password": True, + }, + "WEIXIN_BASE_URL": { + "description": "WeChat platform base URL", + "prompt": "Base URL", + }, "FEISHU_APP_ID": {"description": "Feishu / Lark app ID", "prompt": "App ID"}, - "FEISHU_APP_SECRET": {"description": "Feishu / Lark app secret", "prompt": "App secret", "password": True}, - "FEISHU_ENCRYPT_KEY": {"description": "Feishu / Lark encrypt key", "prompt": "Encrypt key", "password": True}, + "FEISHU_APP_SECRET": { + "description": "Feishu / Lark app secret", + "prompt": "App secret", + "password": True, + }, + "FEISHU_ENCRYPT_KEY": { + "description": "Feishu / Lark encrypt key", + "prompt": "Encrypt key", + "password": True, + }, "FEISHU_VERIFICATION_TOKEN": { "description": "Feishu / Lark verification token", "prompt": "Verification token", "password": True, }, - "DINGTALK_CLIENT_ID": {"description": "DingTalk client ID (App key)", "prompt": "Client ID"}, + "DINGTALK_CLIENT_ID": { + "description": "DingTalk client ID (App key)", + "prompt": "Client ID", + }, "DINGTALK_CLIENT_SECRET": { "description": "DingTalk client secret (App secret)", "prompt": "Client secret", @@ -1862,11 +2068,15 @@ def _messaging_platform_catalog() -> tuple[dict[str, Any], ...]: _log.debug("plugin platform registry unavailable", exc_info=True) order = {pid: idx for idx, pid in enumerate(_PLATFORM_ORDER)} - entries.sort(key=lambda e: (order.get(e["id"], len(_PLATFORM_ORDER)), e["name"].lower())) + entries.sort( + key=lambda e: (order.get(e["id"], len(_PLATFORM_ORDER)), e["name"].lower()) + ) return tuple(entries) -def _build_catalog_entry(platform_id: str, plugin_entry: Any | None = None) -> dict[str, Any]: +def _build_catalog_entry( + platform_id: str, plugin_entry: Any | None = None +) -> dict[str, Any]: override = _PLATFORM_OVERRIDES.get(platform_id, {}) if "env_vars" in override: @@ -1932,34 +2142,55 @@ def _gateway_platform_config(platform_id: str): return config, platform, platform_config -def _messaging_platform_payload(entry: dict[str, Any], env_on_disk: dict[str, str], runtime: dict | None) -> dict[str, Any]: +def _messaging_platform_payload( + entry: dict[str, Any], env_on_disk: dict[str, str], runtime: dict | None +) -> dict[str, Any]: platform_id = entry["id"] gateway_running = get_running_pid() is not None runtime_platforms = runtime.get("platforms") if runtime else {} - runtime_platform = runtime_platforms.get(platform_id, {}) if isinstance(runtime_platforms, dict) else {} + runtime_platform = ( + runtime_platforms.get(platform_id, {}) + if isinstance(runtime_platforms, dict) + else {} + ) env_vars = [] for key in entry["env_vars"]: value = env_on_disk.get(key) or os.getenv(key, "") - env_vars.append({ - "key": key, - "required": key in entry["required_env"], - "is_set": bool(value), - "redacted_value": redact_key(value) if value else None, - **_messaging_env_info(key), - }) + env_vars.append( + { + "key": key, + "required": key in entry["required_env"], + "is_set": bool(value), + "redacted_value": redact_key(value) if value else None, + **_messaging_env_info(key), + } + ) try: - gateway_config, platform, platform_config = _gateway_platform_config(platform_id) + gateway_config, platform, platform_config = _gateway_platform_config( + platform_id + ) enabled = bool(platform_config and platform_config.enabled) - configured = bool(platform_config and gateway_config._is_platform_connected(platform, platform_config)) - home_channel = platform_config.home_channel.to_dict() if platform_config and platform_config.home_channel else None + configured = bool( + platform_config + and gateway_config._is_platform_connected(platform, platform_config) + ) + home_channel = ( + platform_config.home_channel.to_dict() + if platform_config and platform_config.home_channel + else None + ) except Exception: enabled = False - configured = all(env_on_disk.get(key) or os.getenv(key, "") for key in entry["required_env"]) + configured = all( + env_on_disk.get(key) or os.getenv(key, "") for key in entry["required_env"] + ) home_channel = None - state = runtime_platform.get("state") if isinstance(runtime_platform, dict) else None + state = ( + runtime_platform.get("state") if isinstance(runtime_platform, dict) else None + ) if not enabled: state = "disabled" elif not configured: @@ -1978,9 +2209,21 @@ def _messaging_platform_payload(entry: dict[str, Any], env_on_disk: dict[str, st "configured": configured, "gateway_running": gateway_running, "state": state, - "error_code": runtime_platform.get("error_code") if isinstance(runtime_platform, dict) else None, - "error_message": runtime_platform.get("error_message") if isinstance(runtime_platform, dict) else None, - "updated_at": runtime_platform.get("updated_at") if isinstance(runtime_platform, dict) else None, + "error_code": ( + runtime_platform.get("error_code") + if isinstance(runtime_platform, dict) + else None + ), + "error_message": ( + runtime_platform.get("error_message") + if isinstance(runtime_platform, dict) + else None + ), + "updated_at": ( + runtime_platform.get("updated_at") + if isinstance(runtime_platform, dict) + else None + ), "home_channel": home_channel, "env_vars": env_vars, } @@ -2016,18 +2259,26 @@ async def get_messaging_platforms(): async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpdate): entry = _catalog_lookup(platform_id) if not entry: - raise HTTPException(status_code=404, detail=f"Unknown messaging platform: {platform_id}") + raise HTTPException( + status_code=404, detail=f"Unknown messaging platform: {platform_id}" + ) allowed_env = set(entry["env_vars"]) try: for key in body.clear_env: if key not in allowed_env: - raise HTTPException(status_code=400, detail=f"{key} is not configurable for {entry['name']}") + raise HTTPException( + status_code=400, + detail=f"{key} is not configurable for {entry['name']}", + ) remove_env_value(key) for key, value in body.env.items(): if key not in allowed_env: - raise HTTPException(status_code=400, detail=f"{key} is not configurable for {entry['name']}") + raise HTTPException( + status_code=400, + detail=f"{key} is not configurable for {entry['name']}", + ) trimmed = value.strip() if trimmed: save_env_value(key, trimmed) @@ -2047,7 +2298,9 @@ async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpd async def test_messaging_platform(platform_id: str): entry = _catalog_lookup(platform_id) if not entry: - raise HTTPException(status_code=404, detail=f"Unknown messaging platform: {platform_id}") + raise HTTPException( + status_code=404, detail=f"Unknown messaging platform: {platform_id}" + ) env_on_disk = load_env() payload = _messaging_platform_payload(entry, env_on_disk, read_runtime_status()) @@ -2055,8 +2308,16 @@ async def test_messaging_platform(platform_id: str): message = f"{entry['name']} is disabled. Enable it, then restart the gateway." return {"ok": False, "state": payload["state"], "message": message} if not payload["configured"]: - missing = [field["key"] for field in payload["env_vars"] if field["required"] and not field["is_set"]] - message = f"Missing required setup: {', '.join(missing)}" if missing else "Platform setup is incomplete." + missing = [ + field["key"] + for field in payload["env_vars"] + if field["required"] and not field["is_set"] + ] + message = ( + f"Missing required setup: {', '.join(missing)}" + if missing + else "Platform setup is incomplete." + ) return {"ok": False, "state": payload["state"], "message": message} if not payload["gateway_running"]: return { @@ -2065,9 +2326,17 @@ async def test_messaging_platform(platform_id: str): "message": "Gateway is not running. Restart the gateway to connect this platform.", } if payload["state"] == "connected": - return {"ok": True, "state": payload["state"], "message": f"{entry['name']} is connected."} + return { + "ok": True, + "state": payload["state"], + "message": f"{entry['name']} is connected.", + } if payload.get("error_message"): - return {"ok": False, "state": payload["state"], "message": payload["error_message"]} + return { + "ok": False, + "state": payload["state"], + "message": payload["error_message"], + } return { "ok": False, "state": payload["state"], @@ -2180,6 +2449,7 @@ def _claude_code_only_status() -> Dict[str, Any]: """ try: from agent.anthropic_adapter import read_claude_code_credentials + creds = read_claude_code_credentials() except Exception: creds = None @@ -2268,6 +2538,7 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]: return {"logged_in": False, "error": str(e)} try: from hermes_cli import auth as hauth + if provider_id == "nous": raw = hauth.get_nous_auth_status() return { @@ -2335,14 +2606,16 @@ async def list_oauth_providers(): providers = [] for p in _OAUTH_PROVIDER_CATALOG: status = _resolve_provider_status(p["id"], p.get("status_fn")) - providers.append({ - "id": p["id"], - "name": p["name"], - "flow": p["flow"], - "cli_command": p["cli_command"], - "docs_url": p["docs_url"], - "status": status, - }) + providers.append( + { + "id": p["id"], + "name": p["name"], + "flow": p["flow"], + "cli_command": p["cli_command"], + "docs_url": p["docs_url"], + "status": status, + } + ) return {"providers": providers} @@ -2356,7 +2629,7 @@ async def disconnect_oauth_provider(provider_id: str, request: Request): raise HTTPException( status_code=400, detail=f"Unknown provider: {provider_id}. " - f"Available: {', '.join(sorted(valid_ids))}", + f"Available: {', '.join(sorted(valid_ids))}", ) # Anthropic and claude-code clear the same Hermes-managed PKCE file @@ -2366,6 +2639,7 @@ async def disconnect_oauth_provider(provider_id: str, request: Request): if provider_id in {"anthropic", "claude-code"}: try: from agent.anthropic_adapter import _HERMES_OAUTH_FILE + if _HERMES_OAUTH_FILE.exists(): _HERMES_OAUTH_FILE.unlink() except Exception: @@ -2373,6 +2647,7 @@ async def disconnect_oauth_provider(provider_id: str, request: Request): # Also clear the credential pool entry if present. try: from hermes_cli.auth import clear_provider_auth + clear_provider_auth("anthropic") except Exception: pass @@ -2381,6 +2656,7 @@ async def disconnect_oauth_provider(provider_id: str, request: Request): try: from hermes_cli.auth import clear_provider_auth + cleared = clear_provider_auth(provider_id) _log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared) return {"ok": bool(cleared), "provider": provider_id} @@ -2440,6 +2716,7 @@ try: _OAUTH_SCOPES as _ANTHROPIC_OAUTH_SCOPES, _generate_pkce as _generate_pkce_pair, ) + _ANTHROPIC_OAUTH_AVAILABLE = True except ImportError: _ANTHROPIC_OAUTH_AVAILABLE = False @@ -2450,7 +2727,9 @@ def _gc_oauth_sessions() -> None: """Drop expired sessions. Called opportunistically on /start.""" cutoff = time.time() - _OAUTH_SESSION_TTL_SECONDS with _oauth_sessions_lock: - stale = [sid for sid, sess in _oauth_sessions.items() if sess["created_at"] < cutoff] + stale = [ + sid for sid, sess in _oauth_sessions.items() if sess["created_at"] < cutoff + ] for sid in stale: _oauth_sessions.pop(sid, None) @@ -2471,13 +2750,16 @@ def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any] return sid, sess -def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_at_ms: int) -> None: +def _save_anthropic_oauth_creds( + access_token: str, refresh_token: str, expires_at_ms: int +) -> None: """Persist Anthropic PKCE creds to both Hermes file AND credential pool. Mirrors what auth_commands.add_command does so the dashboard flow leaves the system in the same state as ``hermes auth add anthropic``. """ from agent.anthropic_adapter import _HERMES_OAUTH_FILE + payload = { "accessToken": access_token, "refreshToken": refresh_token, @@ -2496,9 +2778,14 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a SOURCE_MANUAL, ) import uuid + pool = load_pool("anthropic") # Avoid duplicate entries: delete any prior dashboard-issued OAuth entry - existing = [e for e in pool.entries() if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_pkce")] + existing = [ + e + for e in pool.entries() + if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_pkce") + ] for e in existing: try: pool.remove_entry(getattr(e, "id", "")) @@ -2523,7 +2810,9 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a def _start_anthropic_pkce() -> Dict[str, Any]: """Begin PKCE flow. Returns the auth URL the UI should open.""" if not _ANTHROPIC_OAUTH_AVAILABLE: - raise HTTPException(status_code=501, detail="Anthropic OAuth not available (missing adapter)") + raise HTTPException( + status_code=501, detail="Anthropic OAuth not available (missing adapter)" + ) verifier, challenge = _generate_pkce_pair() sid, sess = _new_oauth_session("anthropic", "pkce") sess["verifier"] = verifier @@ -2554,7 +2843,11 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]: if not sess or sess["provider"] != "anthropic" or sess["flow"] != "pkce": raise HTTPException(status_code=404, detail="Unknown or expired session") if sess["status"] != "pending": - return {"ok": False, "status": sess["status"], "message": sess.get("error_message")} + return { + "ok": False, + "status": sess["status"], + "message": sess.get("error_message"), + } # Anthropic's redirect callback page formats the code as `#`. # Strip the state suffix if present (we already have the verifier server-side). @@ -2564,14 +2857,16 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]: return {"ok": False, "status": "error", "message": "No code provided"} state_from_callback = parts[1] if len(parts) > 1 else "" - exchange_data = json.dumps({ - "grant_type": "authorization_code", - "client_id": _ANTHROPIC_OAUTH_CLIENT_ID, - "code": code, - "state": state_from_callback or sess["state"], - "redirect_uri": _ANTHROPIC_OAUTH_REDIRECT_URI, - "code_verifier": sess["verifier"], - }).encode() + exchange_data = json.dumps( + { + "grant_type": "authorization_code", + "client_id": _ANTHROPIC_OAUTH_CLIENT_ID, + "code": code, + "state": state_from_callback or sess["state"], + "redirect_uri": _ANTHROPIC_OAUTH_REDIRECT_URI, + "code_verifier": sess["verifier"], + } + ).encode() req = urllib.request.Request( _ANTHROPIC_OAUTH_TOKEN_URL, data=exchange_data, @@ -2627,6 +2922,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: PROVIDER_REGISTRY, ) import httpx + pconfig = PROVIDER_REGISTRY["nous"] portal_base_url = ( os.getenv("HERMES_PORTAL_BASE_URL") @@ -2683,7 +2979,9 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: # verification_url back via the session dict. The helper prints # to stdout — we capture nothing here, just status. threading.Thread( - target=_codex_full_login_worker, args=(sid,), daemon=True, + target=_codex_full_login_worker, + args=(sid,), + daemon=True, name=f"oauth-codex-{sid[:6]}", ).start() # Block briefly until the worker has populated the user_code, OR error. @@ -2697,9 +2995,14 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: with _oauth_sessions_lock: s = _oauth_sessions.get(sid, {}) if s.get("status") == "error": - raise HTTPException(status_code=500, detail=s.get("error_message") or "device-auth failed") + raise HTTPException( + status_code=500, detail=s.get("error_message") or "device-auth failed" + ) if not s.get("user_code"): - raise HTTPException(status_code=504, detail="device-auth timed out before returning a user code") + raise HTTPException( + status_code=504, + detail="device-auth timed out before returning a user code", + ) return { "session_id": sid, "flow": "device_code", @@ -2723,10 +3026,12 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: MINIMAX_OAUTH_GLOBAL_BASE, ) import httpx + verifier, challenge, state = _minimax_pkce_pair() portal_base_url = ( os.getenv("MINIMAX_PORTAL_BASE_URL") or MINIMAX_OAUTH_GLOBAL_BASE ).rstrip("/") + def _do_minimax_request(): with httpx.Client( timeout=httpx.Timeout(15.0), @@ -2740,6 +3045,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: code_challenge=challenge, state=state, ) + device_data = await asyncio.get_event_loop().run_in_executor( None, _do_minimax_request ) @@ -2748,9 +3054,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: # `interval` field is in milliseconds (defensive default 2000ms # in _minimax_poll_token). interval_raw = device_data.get("interval") - sess["interval_ms"] = ( - int(interval_raw) if interval_raw is not None else None - ) + sess["interval_ms"] = int(interval_raw) if interval_raw is not None else None sess["user_code"] = str(device_data["user_code"]) sess["code_verifier"] = verifier sess["state"] = state @@ -2785,7 +3089,10 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: "poll_interval": max(2, (sess["interval_ms"] or 2000) // 1000), } - raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow") + raise HTTPException( + status_code=400, + detail=f"Provider {provider_id} does not support device-code flow", + ) def _nous_poller(session_id: str) -> None: @@ -2797,6 +3104,7 @@ def _nous_poller(session_id: str) -> None: ) from datetime import datetime, timezone import httpx + with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: @@ -2808,7 +3116,9 @@ def _nous_poller(session_id: str) -> None: scope = sess.get("scope") expires_in = max(60, int(sess["expires_at"] - time.time())) try: - with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client: + with httpx.Client( + timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"} + ) as client: token_data = _poll_for_token( client=client, portal_base_url=portal_base_url, @@ -2830,8 +3140,11 @@ def _nous_poller(session_id: str) -> None: "refresh_token": token_data.get("refresh_token"), "obtained_at": now.isoformat(), "expires_at": ( - datetime.fromtimestamp(now.timestamp() + token_ttl, tz=timezone.utc).isoformat() - if token_ttl else None + datetime.fromtimestamp( + now.timestamp() + token_ttl, tz=timezone.utc + ).isoformat() + if token_ttl + else None ), "expires_in": token_ttl, } @@ -2843,6 +3156,7 @@ def _nous_poller(session_id: str) -> None: inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH, ) from hermes_cli.auth import persist_nous_credentials + persist_nous_credentials(full_state) with _oauth_sessions_lock: sess["status"] = "approved" @@ -2874,6 +3188,7 @@ def _minimax_poller(session_id: str) -> None: ) from datetime import datetime, timezone import httpx + with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: @@ -2906,7 +3221,8 @@ def _minimax_poller(session_id: str) -> None: # flow which supports `--region cn`. now = datetime.now(timezone.utc) expires_at_ts = _minimax_resolve_token_expiry_unix( - int(token_data["expired_in"]), now=now, + int(token_data["expired_in"]), + now=now, ) expires_in_s = max(0, int(expires_at_ts - now.timestamp())) auth_state = { @@ -2959,6 +3275,7 @@ def _codex_full_login_worker(session_id: str) -> None: CODEX_OAUTH_TOKEN_URL, DEFAULT_CODEX_BASE_URL, ) + issuer = "https://auth.openai.com" # Step 1: request device code @@ -2975,7 +3292,9 @@ def _codex_full_login_worker(session_id: str) -> None: device_auth_id = device_data.get("device_auth_id", "") poll_interval = max(3, int(device_data.get("interval", "5"))) if not user_code or not device_auth_id: - raise RuntimeError("device-code response missing user_code or device_auth_id") + raise RuntimeError( + "device-code response missing user_code or device_auth_id" + ) verification_url = f"{issuer}/codex/device" with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) @@ -3016,7 +3335,9 @@ def _codex_full_login_worker(session_id: str) -> None: authorization_code = code_resp.get("authorization_code", "") code_verifier = code_resp.get("code_verifier", "") if not authorization_code or not code_verifier: - raise RuntimeError("device-auth response missing authorization_code/code_verifier") + raise RuntimeError( + "device-auth response missing authorization_code/code_verifier" + ) with httpx.Client(timeout=httpx.Timeout(15.0)) as client: token_resp = client.post( CODEX_OAUTH_TOKEN_URL, @@ -3045,6 +3366,7 @@ def _codex_full_login_worker(session_id: str) -> None: SOURCE_MANUAL, ) import uuid as _uuid + pool = load_pool("openai-codex") base_url = ( os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") @@ -3118,9 +3440,14 @@ async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Re _require_token(request) if provider_id == "anthropic": return await asyncio.get_running_loop().run_in_executor( - None, _submit_anthropic_pkce, body.session_id, body.code, + None, + _submit_anthropic_pkce, + body.session_id, + body.code, ) - raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}") + raise HTTPException( + status_code=400, detail=f"submit not supported for {provider_id}" + ) @app.get("/api/providers/oauth/{provider_id}/poll/{session_id}") @@ -3156,7 +3483,6 @@ async def cancel_oauth_session(session_id: str, request: Request): # --------------------------------------------------------------------------- - def _session_latest_descendant(session_id: str): """Resolve a session id to the newest child leaf session. @@ -3195,11 +3521,13 @@ def _session_latest_descendant(session_id: str): "SELECT id, parent_session_id, started_at FROM sessions" ).fetchall() for row in raw_rows: - rows.append({ - "id": row_get(row, "id", 0), - "parent_session_id": row_get(row, "parent_session_id", 1), - "started_at": row_get(row, "started_at", 2), - }) + rows.append( + { + "id": row_get(row, "id", 0), + "parent_session_id": row_get(row, "parent_session_id", 1), + "started_at": row_get(row, "started_at", 2), + } + ) else: rows = db.list_sessions_rich(limit=10000, offset=0) @@ -3233,9 +3561,11 @@ def _session_latest_descendant(session_id: str): finally: db.close() + @app.get("/api/sessions/{session_id}") async def get_session_detail(session_id: str): from hermes_state import SessionDB + db = SessionDB() try: sid = db.resolve_session_id(session_id) @@ -3247,7 +3577,6 @@ async def get_session_detail(session_id: str): db.close() - @app.get("/api/sessions/{session_id}/latest-descendant") async def get_session_latest_descendant(session_id: str): latest, path = _session_latest_descendant(session_id) @@ -3260,9 +3589,11 @@ async def get_session_latest_descendant(session_id: str): "changed": bool(path and latest != path[0]), } + @app.get("/api/sessions/{session_id}/messages") async def get_session_messages(session_id: str): from hermes_state import SessionDB + db = SessionDB() try: sid = db.resolve_session_id(session_id) @@ -3277,6 +3608,7 @@ async def get_session_messages(session_id: str): @app.delete("/api/sessions/{session_id}") async def delete_session_endpoint(session_id: str): from hermes_state import SessionDB + db = SessionDB() try: if not db.delete_session(session_id): @@ -3293,6 +3625,7 @@ class SessionRename(BaseModel): @app.patch("/api/sessions/{session_id}") async def rename_session_endpoint(session_id: str, body: SessionRename): from hermes_state import SessionDB + db = SessionDB() try: sid = db.resolve_session_id(session_id) or session_id @@ -3345,14 +3678,15 @@ async def get_logs( raise HTTPException( status_code=400, detail=f"Unknown component: {component}. " - f"Available: {', '.join(sorted(COMPONENT_PREFIXES))}", + f"Available: {', '.join(sorted(COMPONENT_PREFIXES))}", ) else: comp_prefixes = None has_filters = bool(min_level or comp_prefixes or search) result = _read_tail( - log_path, min(lines, 500) if not search else 2000, + log_path, + min(lines, 500) if not search else 2000, has_filters=has_filters, min_level=min_level, component_prefixes=comp_prefixes, @@ -3362,7 +3696,7 @@ async def get_logs( # trim to the requested line count afterward. if search: needle = search.lower() - result = [l for l in result if needle in l.lower()][-min(lines, 500):] + result = [l for l in result if needle in l.lower()][-min(lines, 500) :] return {"file": file, "lines": result} @@ -3385,12 +3719,14 @@ class CronJobUpdate(BaseModel): @app.get("/api/cron/jobs") async def list_cron_jobs(): from cron.jobs import list_jobs + return list_jobs(include_disabled=True) @app.get("/api/cron/jobs/{job_id}") async def get_cron_job(job_id: str): from cron.jobs import get_job + job = get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") @@ -3400,9 +3736,14 @@ async def get_cron_job(job_id: str): @app.post("/api/cron/jobs") async def create_cron_job(body: CronJobCreate): from cron.jobs import create_job + try: - job = create_job(prompt=body.prompt, schedule=body.schedule, - name=body.name, deliver=body.deliver) + job = create_job( + prompt=body.prompt, + schedule=body.schedule, + name=body.name, + deliver=body.deliver, + ) return job except Exception as e: _log.exception("POST /api/cron/jobs failed") @@ -3412,6 +3753,7 @@ async def create_cron_job(body: CronJobCreate): @app.put("/api/cron/jobs/{job_id}") async def update_cron_job(job_id: str, body: CronJobUpdate): from cron.jobs import update_job + job = update_job(job_id, body.updates) if not job: raise HTTPException(status_code=404, detail="Job not found") @@ -3421,6 +3763,7 @@ async def update_cron_job(job_id: str, body: CronJobUpdate): @app.post("/api/cron/jobs/{job_id}/pause") async def pause_cron_job(job_id: str): from cron.jobs import pause_job + job = pause_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") @@ -3430,6 +3773,7 @@ async def pause_cron_job(job_id: str): @app.post("/api/cron/jobs/{job_id}/resume") async def resume_cron_job(job_id: str): from cron.jobs import resume_job + job = resume_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") @@ -3439,6 +3783,7 @@ async def resume_cron_job(job_id: str): @app.post("/api/cron/jobs/{job_id}/trigger") async def trigger_cron_job(job_id: str): from cron.jobs import trigger_job + job = trigger_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") @@ -3448,6 +3793,7 @@ async def trigger_cron_job(job_id: str): @app.delete("/api/cron/jobs/{job_id}") async def delete_cron_job(job_id: str): from cron.jobs import remove_job + if not remove_job(job_id): raise HTTPException(status_code=404, detail="Job not found") return {"ok": True} @@ -3501,32 +3847,44 @@ def _fallback_profile_dicts(profiles_mod) -> List[Dict[str, Any]]: profiles: List[Dict[str, Any]] = [] default_home = profiles_mod._get_default_hermes_home() if default_home.is_dir(): - model, provider = _safe(lambda: profiles_mod._read_config_model(default_home), (None, None)) - profiles.append({ - "name": "default", - "path": str(default_home), - "is_default": True, - "model": model, - "provider": provider, - "has_env": (default_home / ".env").exists(), - "skill_count": _safe(lambda: profiles_mod._count_skills(default_home), 0), - }) + model, provider = _safe( + lambda: profiles_mod._read_config_model(default_home), (None, None) + ) + profiles.append( + { + "name": "default", + "path": str(default_home), + "is_default": True, + "model": model, + "provider": provider, + "has_env": (default_home / ".env").exists(), + "skill_count": _safe( + lambda: profiles_mod._count_skills(default_home), 0 + ), + } + ) profiles_root = profiles_mod._get_profiles_root() if profiles_root.is_dir(): for entry in sorted(profiles_root.iterdir()): if not entry.is_dir() or not profiles_mod._PROFILE_ID_RE.match(entry.name): continue - model, provider = _safe(lambda entry=entry: profiles_mod._read_config_model(entry), (None, None)) - profiles.append({ - "name": entry.name, - "path": str(entry), - "is_default": False, - "model": model, - "provider": provider, - "has_env": (entry / ".env").exists(), - "skill_count": _safe(lambda entry=entry: profiles_mod._count_skills(entry), 0), - }) + model, provider = _safe( + lambda entry=entry: profiles_mod._read_config_model(entry), (None, None) + ) + profiles.append( + { + "name": entry.name, + "path": str(entry), + "is_default": False, + "model": model, + "provider": provider, + "has_env": (entry / ".env").exists(), + "skill_count": _safe( + lambda entry=entry: profiles_mod._count_skills(entry), 0 + ), + } + ) return profiles @@ -3534,6 +3892,7 @@ def _fallback_profile_dicts(profiles_mod) -> List[Dict[str, Any]]: def _resolve_profile_dir(name: str) -> Path: """Validate ``name`` and resolve to its directory or raise an HTTPException.""" from hermes_cli import profiles as profiles_mod + try: profiles_mod.validate_profile_name(name) except ValueError as e: @@ -3552,16 +3911,20 @@ def _profile_setup_command(name: str) -> str: @app.get("/api/profiles") async def list_profiles_endpoint(): from hermes_cli import profiles as profiles_mod + try: return {"profiles": [_profile_to_dict(p) for p in profiles_mod.list_profiles()]} except Exception: - _log.exception("GET /api/profiles failed; falling back to profile directory scan") + _log.exception( + "GET /api/profiles failed; falling back to profile directory scan" + ) return {"profiles": _fallback_profile_dicts(profiles_mod)} @app.post("/api/profiles") async def create_profile_endpoint(body: ProfileCreate): from hermes_cli import profiles as profiles_mod + try: path = profiles_mod.create_profile( name=body.name, @@ -3613,7 +3976,10 @@ async def open_profile_terminal_endpoint(name: str): subprocess.Popen(["osascript", "-e", applescript]) else: terminal_commands = [ - ("x-terminal-emulator", ["x-terminal-emulator", "-e", "sh", "-lc", command]), + ( + "x-terminal-emulator", + ["x-terminal-emulator", "-e", "sh", "-lc", command], + ), ("gnome-terminal", ["gnome-terminal", "--", "sh", "-lc", command]), ("konsole", ["konsole", "-e", "sh", "-lc", command]), ("xfce4-terminal", ["xfce4-terminal", "-e", f"sh -lc '{command}'"]), @@ -3625,11 +3991,14 @@ async def open_profile_terminal_endpoint(name: str): ("xterm", ["xterm", "-e", "sh", "-lc", command]), ] for executable, popen_args in terminal_commands: - if subprocess.call( - ["which", executable], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) == 0: + if ( + subprocess.call( + ["which", executable], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + == 0 + ): subprocess.Popen(popen_args) break else: @@ -3652,6 +4021,7 @@ async def open_profile_terminal_endpoint(name: str): @app.patch("/api/profiles/{name}") async def rename_profile_endpoint(name: str, body: ProfileRename): from hermes_cli import profiles as profiles_mod + try: path = profiles_mod.rename_profile(name, body.new_name) except FileNotFoundError as e: @@ -3670,6 +4040,7 @@ async def delete_profile_endpoint(name: str): its own dialog before this request, so we always pass ``yes=True`` to skip the CLI's interactive prompt.""" from hermes_cli import profiles as profiles_mod + try: path = profiles_mod.delete_profile(name, yes=True) except FileNotFoundError as e: @@ -3718,6 +4089,7 @@ class SkillToggle(BaseModel): async def get_skills(): from tools.skills_tool import _find_all_skills from hermes_cli.skills_config import get_disabled_skills + config = load_config() disabled = get_disabled_skills(config) skills = _find_all_skills(skip_disabled=True) @@ -3729,6 +4101,7 @@ async def get_skills(): @app.put("/api/skills/toggle") async def toggle_skill(body: SkillToggle): from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills + config = load_config() disabled = get_disabled_skills(config) if body.enabled: @@ -3761,13 +4134,17 @@ async def get_toolsets(): except Exception: tools = [] is_enabled = name in enabled_toolsets - result.append({ - "name": name, "label": label, "description": desc, - "enabled": is_enabled, - "available": is_enabled, - "configured": _toolset_has_keys(name, config), - "tools": tools, - }) + result.append( + { + "name": name, + "label": label, + "description": desc, + "enabled": is_enabled, + "available": is_enabled, + "configured": _toolset_has_keys(name, config), + "tools": tools, + } + ) return result @@ -3813,7 +4190,8 @@ async def get_usage_analytics(days: int = 30): db = SessionDB() try: cutoff = time.time() - (days * 86400) - cur = db._conn.execute(""" + cur = db._conn.execute( + """ SELECT date(started_at, 'unixepoch') as day, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, @@ -3825,10 +4203,13 @@ async def get_usage_analytics(days: int = 30): SUM(COALESCE(api_call_count, 0)) as api_calls FROM sessions WHERE started_at > ? GROUP BY day ORDER BY day - """, (cutoff,)) + """, + (cutoff,), + ) daily = [dict(r) for r in cur.fetchall()] - cur2 = db._conn.execute(""" + cur2 = db._conn.execute( + """ SELECT model, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, @@ -3837,10 +4218,13 @@ async def get_usage_analytics(days: int = 30): SUM(COALESCE(api_call_count, 0)) as api_calls FROM sessions WHERE started_at > ? AND model IS NOT NULL GROUP BY model ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC - """, (cutoff,)) + """, + (cutoff,), + ) by_model = [dict(r) for r in cur2.fetchall()] - cur3 = db._conn.execute(""" + cur3 = db._conn.execute( + """ SELECT SUM(input_tokens) as total_input, SUM(output_tokens) as total_output, SUM(cache_read_tokens) as total_cache_read, @@ -3850,18 +4234,23 @@ async def get_usage_analytics(days: int = 30): COUNT(*) as total_sessions, SUM(COALESCE(api_call_count, 0)) as total_api_calls FROM sessions WHERE started_at > ? - """, (cutoff,)) + """, + (cutoff,), + ) totals = dict(cur3.fetchone()) insights_report = InsightsEngine(db).generate(days=days) - skills = insights_report.get("skills", { - "summary": { - "total_skill_loads": 0, - "total_skill_edits": 0, - "total_skill_actions": 0, - "distinct_skills_used": 0, + skills = insights_report.get( + "skills", + { + "summary": { + "total_skill_loads": 0, + "total_skill_edits": 0, + "total_skill_actions": 0, + "distinct_skills_used": 0, + }, + "top_skills": [], }, - "top_skills": [], - }) + ) return { "daily": daily, @@ -3887,7 +4276,8 @@ async def get_models_analytics(days: int = 30): try: cutoff = time.time() - (days * 86400) - cur = db._conn.execute(""" + cur = db._conn.execute( + """ SELECT model, billing_provider, SUM(input_tokens) as input_tokens, @@ -3904,7 +4294,9 @@ async def get_models_analytics(days: int = 30): FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != '' GROUP BY model, billing_provider ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC - """, (cutoff,)) + """, + (cutoff,), + ) rows = [dict(r) for r in cur.fetchall()] models = [] @@ -3914,6 +4306,7 @@ async def get_models_analytics(days: int = 30): caps = {} try: from agent.models_dev import get_model_capabilities + mc = get_model_capabilities(provider=provider, model=model_name) if mc is not None: caps = { @@ -3927,24 +4320,27 @@ async def get_models_analytics(days: int = 30): except Exception: pass - models.append({ - "model": model_name, - "provider": provider, - "input_tokens": row["input_tokens"], - "output_tokens": row["output_tokens"], - "cache_read_tokens": row["cache_read_tokens"], - "reasoning_tokens": row["reasoning_tokens"], - "estimated_cost": row["estimated_cost"], - "actual_cost": row["actual_cost"], - "sessions": row["sessions"], - "api_calls": row["api_calls"], - "tool_calls": row["tool_calls"], - "last_used_at": row["last_used_at"], - "avg_tokens_per_session": row["avg_tokens_per_session"], - "capabilities": caps, - }) + models.append( + { + "model": model_name, + "provider": provider, + "input_tokens": row["input_tokens"], + "output_tokens": row["output_tokens"], + "cache_read_tokens": row["cache_read_tokens"], + "reasoning_tokens": row["reasoning_tokens"], + "estimated_cost": row["estimated_cost"], + "actual_cost": row["actual_cost"], + "sessions": row["sessions"], + "api_calls": row["api_calls"], + "tool_calls": row["tool_calls"], + "last_used_at": row["last_used_at"], + "avg_tokens_per_session": row["avg_tokens_per_session"], + "capabilities": caps, + } + ) - totals_cur = db._conn.execute(""" + totals_cur = db._conn.execute( + """ SELECT COUNT(DISTINCT model) as distinct_models, SUM(input_tokens) as total_input, SUM(output_tokens) as total_output, @@ -3955,7 +4351,9 @@ async def get_models_analytics(days: int = 30): COUNT(*) as total_sessions, SUM(COALESCE(api_call_count, 0)) as total_api_calls FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != '' - """, (cutoff,)) + """, + (cutoff,), + ) totals = dict(totals_cur.fetchone()) return { @@ -3990,6 +4388,7 @@ import asyncio # /api/pty endpoint cleanly refuses with a WSL-suggested message. try: from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError + _PTY_BRIDGE_AVAILABLE = True except ImportError as _pty_import_err: # pragma: no cover - Windows-only path PtyBridge = None # type: ignore[assignment] @@ -3997,8 +4396,10 @@ except ImportError as _pty_import_err: # pragma: no cover - Windows-only path class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] """Stub on platforms where pty_bridge can't be imported.""" + pass + _RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]") _PTY_READ_CHUNK_TIMEOUT = 0.2 _VALID_CHANNEL_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$") @@ -4034,6 +4435,7 @@ def _ws_client_label(ws: "WebSocket") -> str: port = ws.client.port return f"{host}:{port}" if port is not None else host + # Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard) # and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id # the chat tab generates on mount; entries auto-evict when the last subscriber @@ -4101,7 +4503,11 @@ def _build_gateway_ws_url() -> Optional[str]: if not host or not port: return None - netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}" + netloc = ( + f"[{host}]:{port}" + if ":" in host and not host.startswith("[") + else f"{host}:{port}" + ) qs = urllib.parse.urlencode({"token": _SESSION_TOKEN}) return f"ws://{netloc}/api/ws?{qs}" @@ -4115,7 +4521,11 @@ def _build_sidecar_url(channel: str) -> Optional[str]: if not host or not port: return None - netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}" + netloc = ( + f"[{host}]:{port}" + if ":" in host and not host.startswith("[") + else f"{host}:{port}" + ) qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel}) return f"ws://{netloc}/api/pub?{qs}" @@ -4337,6 +4747,7 @@ async def gateway_ws(ws: WebSocket) -> None: return from tui_gateway.ws import handle_ws + _log.info("gateway-ws connect peer=%s", peer) try: await handle_ws(ws) @@ -4401,7 +4812,9 @@ async def pub_ws(ws: WebSocket) -> None: messages += 1 await _broadcast_event(channel, payload) except WebSocketDisconnect: - _log.info("pub-ws disconnect peer=%s channel=%s messages=%d", peer, channel, messages) + _log.info( + "pub-ws disconnect peer=%s channel=%s messages=%d", peer, channel, messages + ) @app.websocket("/api/events") @@ -4470,7 +4883,11 @@ def _normalise_prefix(raw: Optional[str]) -> str: if not p.startswith("/"): p = "/" + p p = p.rstrip("/") - if "//" in p or ".." in p or any(c in p for c in ('"', "'", "<", ">", " ", "\n", "\r", "\t")): + if ( + "//" in p + or ".." in p + or any(c in p for c in ('"', "'", "<", ">", " ", "\n", "\r", "\t")) + ): return "" if len(p) > 64: return "" @@ -4492,12 +4909,16 @@ def mount_spa(application: FastAPI): without rebuilding the bundle. """ if not WEB_DIST.exists(): + @application.get("/{full_path:path}") async def no_frontend(full_path: str): return JSONResponse( - {"error": "Frontend not built. Run: cd apps/dashboard && npm run build"}, + { + "error": "Frontend not built. Run: cd apps/dashboard && npm run build" + }, status_code=404, ) + return _index_path = WEB_DIST / "index.html" @@ -4549,11 +4970,13 @@ def mount_spa(application: FastAPI): if prefix: for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"): css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}") - css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}") + css = css.replace(f'url("{asset_dir}', f'url("{prefix}{asset_dir}') css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}") return Response(content=css, media_type="text/css") - application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets") + application.mount( + "/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets" + ) @application.get("/{full_path:path}") async def serve_spa(full_path: str, request: Request): @@ -4577,17 +5000,47 @@ def mount_spa(application: FastAPI): # Built-in dashboard themes — label + description only. The actual color # definitions live in the frontend (apps/dashboard/src/themes/presets.ts). _BUILTIN_DASHBOARD_THEMES = [ - {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, - {"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"}, - {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, - {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"}, - {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"}, - {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"}, - {"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"}, + { + "name": "default", + "label": "Hermes Teal", + "description": "Classic dark teal — the canonical Hermes look", + }, + { + "name": "default-large", + "label": "Hermes Teal (Large)", + "description": "Hermes Teal with bigger fonts and roomier spacing", + }, + { + "name": "midnight", + "label": "Midnight", + "description": "Deep blue-violet with cool accents", + }, + { + "name": "ember", + "label": "Ember", + "description": "Warm crimson and bronze — forge vibes", + }, + { + "name": "mono", + "label": "Mono", + "description": "Clean grayscale — minimal and focused", + }, + { + "name": "cyberpunk", + "label": "Cyberpunk", + "description": "Neon green on black — matrix terminal", + }, + { + "name": "rose", + "label": "Rosé", + "description": "Soft pink and warm ivory — easy on the eyes", + }, ] -def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[Dict[str, Any]]: +def _parse_theme_layer( + value: Any, default_hex: str, default_alpha: float = 1.0 +) -> Optional[Dict[str, Any]]: """Normalise a theme layer spec from YAML into `{hex, alpha}` form. Accepts shorthand (a bare hex string) or full dict form. Returns @@ -4625,11 +5078,25 @@ _THEME_DEFAULT_LAYOUT: Dict[str, str] = { } _THEME_OVERRIDE_KEYS = { - "card", "cardForeground", "popover", "popoverForeground", - "primary", "primaryForeground", "secondary", "secondaryForeground", - "muted", "mutedForeground", "accent", "accentForeground", - "destructive", "destructiveForeground", "success", "warning", - "border", "input", "ring", + "card", + "cardForeground", + "popover", + "popoverForeground", + "primary", + "primaryForeground", + "secondary", + "secondaryForeground", + "muted", + "mutedForeground", + "accent", + "accentForeground", + "destructive", + "destructiveForeground", + "success", + "warning", + "border", + "input", + "ring", } # Well-known named asset slots themes can populate. Any other keys under @@ -4644,8 +5111,15 @@ _THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"} # can restyle chrome (clip-path, border-image, segmented progress, etc.) # without shipping their own CSS. _THEME_COMPONENT_BUCKETS = { - "card", "header", "footer", "sidebar", "tab", - "progress", "badge", "backdrop", "page", + "card", + "header", + "footer", + "sidebar", + "tab", + "progress", + "badge", + "backdrop", + "page", } _THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"} @@ -4670,20 +5144,30 @@ def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any] return None # Palette - palette_src = data.get("palette", {}) if isinstance(data.get("palette"), dict) else {} + palette_src = ( + data.get("palette", {}) if isinstance(data.get("palette"), dict) else {} + ) # Allow top-level `colors.background` as a shorthand too. colors_src = data.get("colors", {}) if isinstance(data.get("colors"), dict) else {} - def _layer(key: str, default_hex: str, default_alpha: float = 1.0) -> Dict[str, Any]: + def _layer( + key: str, default_hex: str, default_alpha: float = 1.0 + ) -> Dict[str, Any]: spec = palette_src.get(key, colors_src.get(key)) parsed = _parse_theme_layer(spec, default_hex, default_alpha) - return parsed if parsed is not None else {"hex": default_hex, "alpha": default_alpha} + return ( + parsed + if parsed is not None + else {"hex": default_hex, "alpha": default_alpha} + ) palette = { "background": _layer("background", "#041c1c", 1.0), "midground": _layer("midground", "#ffe6cb", 1.0), "foreground": _layer("foreground", "#ffffff", 0.0), - "warmGlow": palette_src.get("warmGlow") or data.get("warmGlow") or "rgba(255, 189, 56, 0.35)", + "warmGlow": palette_src.get("warmGlow") + or data.get("warmGlow") + or "rgba(255, 189, 56, 0.35)", "noiseOpacity": 1.0, } raw_noise = palette_src.get("noiseOpacity", data.get("noiseOpacity")) @@ -4693,9 +5177,19 @@ def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any] palette["noiseOpacity"] = 1.0 # Typography - typo_src = data.get("typography", {}) if isinstance(data.get("typography"), dict) else {} + typo_src = ( + data.get("typography", {}) if isinstance(data.get("typography"), dict) else {} + ) typography = dict(_THEME_DEFAULT_TYPOGRAPHY) - for key in ("fontSans", "fontMono", "fontDisplay", "fontUrl", "baseSize", "lineHeight", "letterSpacing"): + for key in ( + "fontSans", + "fontMono", + "fontDisplay", + "fontUrl", + "baseSize", + "lineHeight", + "letterSpacing", + ): val = typo_src.get(key) if isinstance(val, str) and val.strip(): typography[key] = val @@ -4777,7 +5271,8 @@ def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any] layout_variant_src = data.get("layoutVariant") layout_variant = ( layout_variant_src - if isinstance(layout_variant_src, str) and layout_variant_src in _THEME_LAYOUT_VARIANTS + if isinstance(layout_variant_src, str) + and layout_variant_src in _THEME_LAYOUT_VARIANTS else "standard" ) @@ -4844,12 +5339,14 @@ async def get_dashboard_themes(): for t in user_themes: if t["name"] in seen: continue - themes.append({ - "name": t["name"], - "label": t["label"], - "description": t["description"], - "definition": t, - }) + themes.append( + { + "name": t["name"], + "label": t["label"], + "description": t["description"], + "definition": t, + } + ) seen.add(t["name"]) return {"themes": themes, "active": active} @@ -4873,6 +5370,7 @@ async def set_dashboard_theme(body: ThemeSetBody): # Dashboard plugin system # --------------------------------------------------------------------------- + def _discover_dashboard_plugins() -> list: """Scan plugins/*/dashboard/manifest.json for dashboard extensions. @@ -4885,6 +5383,7 @@ def _discover_dashboard_plugins() -> list: seen_names: set = set() from hermes_cli.plugins import get_bundled_plugins_dir + bundled_root = get_bundled_plugins_dir() search_dirs = [ (get_hermes_home() / "plugins", "user"), @@ -4913,7 +5412,9 @@ def _discover_dashboard_plugins() -> list: # ``override`` to replace a built-in route, and ``hidden`` to # register the plugin component/slots without adding a tab # (useful for slot-only plugins like a header-crest injector). - raw_tab = data.get("tab", {}) if isinstance(data.get("tab"), dict) else {} + raw_tab = ( + data.get("tab", {}) if isinstance(data.get("tab"), dict) else {} + ) tab_info = { "path": raw_tab.get("path", f"/{name}"), "position": raw_tab.get("position", "end"), @@ -4930,21 +5431,23 @@ def _discover_dashboard_plugins() -> list: slots: List[str] = [] if isinstance(slots_src, list): slots = [s for s in slots_src if isinstance(s, str) and s] - plugins.append({ - "name": name, - "label": data.get("label", name), - "description": data.get("description", ""), - "icon": data.get("icon", "Puzzle"), - "version": data.get("version", "0.0.0"), - "tab": tab_info, - "slots": slots, - "entry": data.get("entry", "dist/index.js"), - "css": data.get("css"), - "has_api": bool(data.get("api")), - "source": source, - "_dir": str(child / "dashboard"), - "_api_file": data.get("api"), - }) + plugins.append( + { + "name": name, + "label": data.get("label", name), + "description": data.get("description", ""), + "icon": data.get("icon", "Puzzle"), + "version": data.get("version", "0.0.0"), + "tab": tab_info, + "slots": slots, + "entry": data.get("entry", "dist/index.js"), + "css": data.get("css"), + "has_api": bool(data.get("api")), + "source": source, + "_dir": str(child / "dashboard"), + "_api_file": data.get("api"), + } + ) except Exception as exc: _log.warning("Bad dashboard plugin manifest %s: %s", manifest_file, exc) continue @@ -5018,7 +5521,9 @@ def _merged_plugins_hub() -> Dict[str, Any]: # Read user-hidden plugins from config for the user_hidden field. config = load_config() - hidden_plugins: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or [] + hidden_plugins: list = ( + cfg_get(config, "dashboard", "hidden_plugins", default=[]) or [] + ) plugins_root_resolved = (get_hermes_home() / "plugins").resolve() rows: List[Dict[str, Any]] = [] @@ -5033,7 +5538,9 @@ def _merged_plugins_hub() -> Dict[str, Any]: dir_path = Path(dir_str) dm = dash_by_name.get(name) - has_dash_manifest = dm is not None or (dir_path / "dashboard" / "manifest.json").exists() + has_dash_manifest = ( + dm is not None or (dir_path / "dashboard" / "manifest.json").exists() + ) under_user_tree = False try: @@ -5054,6 +5561,7 @@ def _merged_plugins_hub() -> Dict[str, Any]: if provides_tools: try: from tools.registry import registry + for tname in provides_tools: entry = registry.get_entry(tname) if entry and entry.check_fn and not entry.check_fn(): @@ -5063,21 +5571,24 @@ def _merged_plugins_hub() -> Dict[str, Any]: except Exception: pass - rows.append({ - "name": name, - "version": version or "", - "description": description or "", - "source": source, - "runtime_status": runtime_status, - "has_dashboard_manifest": has_dash_manifest, - "dashboard_manifest": _strip_dashboard_manifest(dm) if dm else None, - "path": dir_str, - "can_remove": can_remove_update, - "can_update_git": can_remove_update and (Path(dir_str) / ".git").exists(), - "auth_required": auth_required, - "auth_command": auth_command, - "user_hidden": name in hidden_plugins, - }) + rows.append( + { + "name": name, + "version": version or "", + "description": description or "", + "source": source, + "runtime_status": runtime_status, + "has_dashboard_manifest": has_dash_manifest, + "dashboard_manifest": _strip_dashboard_manifest(dm) if dm else None, + "path": dir_str, + "can_remove": can_remove_update, + "can_update_git": can_remove_update + and (Path(dir_str) / ".git").exists(), + "auth_required": auth_required, + "auth_command": auth_command, + "user_hidden": name in hidden_plugins, + } + ) agent_names = {r["name"] for r in rows} orphan_dashboard = [ @@ -5120,7 +5631,9 @@ async def get_plugins_hub(request: Request): return _merged_plugins_hub() except Exception as exc: _log.warning("plugins/hub failed: %s", exc) - raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc + raise HTTPException( + status_code=500, detail="Failed to build plugins hub." + ) from exc @app.post("/api/dashboard/agent-plugins/install") @@ -5159,7 +5672,9 @@ async def post_agent_plugin_enable(request: Request, name: str): result = dashboard_set_agent_plugin_enabled(name, enabled=True) if not result.get("ok"): - raise HTTPException(status_code=400, detail=result.get("error") or "Enable failed.") + raise HTTPException( + status_code=400, detail=result.get("error") or "Enable failed." + ) return result @@ -5171,7 +5686,9 @@ async def post_agent_plugin_disable(request: Request, name: str): result = dashboard_set_agent_plugin_enabled(name, enabled=False) if not result.get("ok"): - raise HTTPException(status_code=400, detail=result.get("error") or "Disable failed.") + raise HTTPException( + status_code=400, detail=result.get("error") or "Disable failed." + ) return result @@ -5183,7 +5700,9 @@ async def post_agent_plugin_update(request: Request, name: str): result = dashboard_update_user_plugin(name) if not result.get("ok"): - raise HTTPException(status_code=400, detail=result.get("error") or "Update failed.") + raise HTTPException( + status_code=400, detail=result.get("error") or "Update failed." + ) _get_dashboard_plugins(force_rescan=True) return result @@ -5196,7 +5715,9 @@ async def delete_agent_plugin(request: Request, name: str): result = dashboard_remove_user_plugin(name) if not result.get("ok"): - raise HTTPException(status_code=400, detail=result.get("error") or "Remove failed.") + raise HTTPException( + status_code=400, detail=result.get("error") or "Remove failed." + ) _get_dashboard_plugins(force_rescan=True) return result @@ -5227,7 +5748,9 @@ class _PluginVisibilityBody(BaseModel): @app.post("/api/dashboard/plugins/{name}/visibility") -async def post_plugin_visibility(request: Request, name: str, body: _PluginVisibilityBody): +async def post_plugin_visibility( + request: Request, name: str, body: _PluginVisibilityBody +): """Toggle a plugin's sidebar visibility (persists to config.yaml dashboard.hidden_plugins).""" _require_token(request) name = _validate_plugin_name(name) @@ -5300,7 +5823,11 @@ def _mount_plugin_api_routes(): continue api_path = Path(plugin["_dir"]) / api_file_name if not api_path.exists(): - _log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name) + _log.warning( + "Plugin %s declares api=%s but file not found", + plugin["name"], + api_file_name, + ) continue try: module_name = f"hermes_dashboard_plugin_{plugin['name']}" @@ -5322,7 +5849,9 @@ def _mount_plugin_api_routes(): raise router = getattr(mod, "router", None) if router is None: - _log.warning("Plugin %s api file has no 'router' attribute", plugin["name"]) + _log.warning( + "Plugin %s api file has no 'router' attribute", plugin["name"] + ) continue app.include_router(router, prefix=f"/api/plugins/{plugin['name']}") _log.info("Mounted plugin API routes: /api/plugins/%s/", plugin["name"]) @@ -5368,7 +5897,8 @@ def start_server( if host not in _LOCALHOST: _log.warning( "Binding to %s with --insecure — the dashboard has no robust " - "authentication. Only use on trusted networks.", host, + "authentication. Only use on trusted networks.", + host, ) # Record the bound host so host_header_middleware can validate incoming @@ -5395,6 +5925,7 @@ def start_server( ) if _has_display: + def _open(): try: time.sleep(1.0) diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index bc09279eec4..c1e521a7bcc 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -9,6 +9,7 @@ We mock the slack modules at import time to avoid collection errors. """ import asyncio +import contextlib import os import sys from unittest.mock import AsyncMock, MagicMock, patch, call @@ -29,6 +30,7 @@ from gateway.platforms.base import ( # Mock the slack-bolt package if it's not installed # --------------------------------------------------------------------------- + def _ensure_slack_mock(): """Install mock slack modules so SlackAdapter can be imported.""" if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"): @@ -46,7 +48,10 @@ def _ensure_slack_mock(): ("slack_bolt.async_app", slack_bolt.async_app), ("slack_bolt.adapter", slack_bolt.adapter), ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode), - ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler), + ( + "slack_bolt.adapter.socket_mode.async_handler", + slack_bolt.adapter.socket_mode.async_handler, + ), ("slack_sdk", slack_sdk), ("slack_sdk.web", slack_sdk.web), ("slack_sdk.web.async_client", slack_sdk.web.async_client), @@ -61,15 +66,42 @@ _ensure_slack_mock() # Patch SLACK_AVAILABLE before importing the adapter import gateway.platforms.slack as _slack_mod + _slack_mod.SLACK_AVAILABLE = True from gateway.platforms.slack import SlackAdapter # noqa: E402 +async def _pending_for_fake_task(): + # Stay pending so done-callbacks attached by the adapter (which would + # otherwise schedule a reconnect) don't fire during the test. The pytest + # event loop will cancel us at teardown, which the adapter's + # ``_on_socket_mode_task_done`` already treats as intentional shutdown. + await asyncio.Event().wait() + + +def _fake_create_task(coro): + """Test helper: consume the real coroutine and return a real awaitable Task. + + Returning an actual ``asyncio.Task`` (built via ``loop.create_task`` so the + ``asyncio.create_task`` patch doesn't recurse) keeps the substitute usable + by code that later cancels, awaits, or attaches ``add_done_callback`` — + so future tests that exercise ``disconnect()`` after patching + ``asyncio.create_task`` won't trip over a non-awaitable MagicMock. + """ + assert asyncio.iscoroutine(coro), ( + f"_fake_create_task expected a coroutine, got {type(coro).__name__}" + ) + coro.close() + loop = asyncio.get_event_loop() + return loop.create_task(_pending_for_fake_task()) + + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture() def adapter(): config = PlatformConfig(enabled=True, token="xoxb-fake-token") @@ -96,6 +128,7 @@ def _redirect_cache(tmp_path, monkeypatch): # TestSlashCommandSessionIsolation # --------------------------------------------------------------------------- + class TestSlashCommandSessionIsolation: @pytest.mark.asyncio async def test_channel_slash_command_uses_group_session_semantics(self, adapter): @@ -136,6 +169,7 @@ class TestSlashCommandSessionIsolation: # TestAppMentionHandler # --------------------------------------------------------------------------- + class TestAppMentionHandler: """Verify that the app_mention event handler is registered.""" @@ -154,37 +188,50 @@ class TestAppMentionHandler: def decorator(fn): registered_events.append(event_type) return fn + return decorator def mock_command(cmd): def decorator(fn): registered_commands.append(cmd) return fn + return decorator mock_app.event = mock_event mock_app.command = mock_command mock_app.client = AsyncMock() - mock_app.client.auth_test = AsyncMock(return_value={ - "user_id": "U_BOT", - "user": "testbot", - }) + mock_app.client.auth_test = AsyncMock( + return_value={ + "user_id": "U_BOT", + "user": "testbot", + } + ) # Mock AsyncWebClient so multi-workspace auth_test is awaitable mock_web_client = AsyncMock() - mock_web_client.auth_test = AsyncMock(return_value={ - "user_id": "U_BOT", - "user": "testbot", - "team_id": "T_FAKE", - "team": "FakeTeam", - }) + mock_web_client.auth_test = AsyncMock( + return_value={ + "user_id": "U_BOT", + "user": "testbot", + "team_id": "T_FAKE", + "team": "FakeTeam", + } + ) - with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \ - patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \ - patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \ - patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \ - patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ - patch("asyncio.create_task"): + socket_mode_handler = MagicMock() + socket_mode_handler.start_async = AsyncMock(return_value=None) + + with ( + patch.object(_slack_mod, "AsyncApp", return_value=mock_app), + patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), + patch.object( + _slack_mod, "AsyncSocketModeHandler", return_value=socket_mode_handler + ), + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("asyncio.create_task", side_effect=_fake_create_task), + ): asyncio.run(adapter.connect()) assert "message" in registered_events @@ -195,16 +242,17 @@ class TestAppMentionHandler: # covering every COMMAND_REGISTRY entry (e.g. /hermes, /btw, /stop, # /model, ...) so users get native-slash parity with Discord and # Telegram. Verify the regex matches the key expected slashes. - assert len(registered_commands) == 1, ( - f"expected 1 combined slash matcher, got {registered_commands!r}" - ) + assert ( + len(registered_commands) == 1 + ), f"expected 1 combined slash matcher, got {registered_commands!r}" slash_matcher = registered_commands[0] import re as _re + assert isinstance(slash_matcher, _re.Pattern) for expected in ("/hermes", "/btw", "/stop", "/model", "/help"): - assert slash_matcher.match(expected), ( - f"Slack slash regex does not match {expected}" - ) + assert slash_matcher.match( + expected + ), f"Slack slash regex does not match {expected}" class TestSlackConnectCleanup: @@ -219,12 +267,16 @@ class TestSlackConnectCleanup: mock_web_client = AsyncMock() mock_web_client.auth_test = AsyncMock(side_effect=RuntimeError("boom")) - with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \ - patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \ - patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \ - patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \ - patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ - patch("gateway.status.release_scoped_lock") as mock_release: + with ( + patch.object(_slack_mod, "AsyncApp", return_value=mock_app), + patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), + patch.object( + _slack_mod, "AsyncSocketModeHandler", return_value=MagicMock() + ), + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("gateway.status.release_scoped_lock") as mock_release, + ): result = await adapter.connect() assert result is False @@ -249,31 +301,45 @@ class TestSlackConnectCleanup: adapter._handler = first_handler mock_app = MagicMock() + def _noop_decorator(event_type): - def decorator(fn): return fn + def decorator(fn): + return fn + return decorator + mock_app.event = _noop_decorator mock_app.command = _noop_decorator mock_app.action = _noop_decorator mock_app.client = AsyncMock() mock_web_client = AsyncMock() - mock_web_client.auth_test = AsyncMock(return_value={ - "user_id": "U_BOT", - "user": "testbot", - "team_id": "T_FAKE", - "team": "FakeTeam", - }) + mock_web_client.auth_test = AsyncMock( + return_value={ + "user_id": "U_BOT", + "user": "testbot", + "team_id": "T_FAKE", + "team": "FakeTeam", + } + ) second_handler = MagicMock() + # _start_socket_mode_handler awaits the result of start_async via + # asyncio.create_task — so the stub must return a real coroutine, not a + # bare MagicMock. + second_handler.start_async = AsyncMock(return_value=None) - with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \ - patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \ - patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=second_handler), \ - patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \ - patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ - patch("gateway.status.release_scoped_lock"), \ - patch("asyncio.create_task"): + with ( + patch.object(_slack_mod, "AsyncApp", return_value=mock_app), + patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), + patch.object( + _slack_mod, "AsyncSocketModeHandler", return_value=second_handler + ), + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("gateway.status.release_scoped_lock"), + patch("asyncio.create_task", side_effect=_fake_create_task), + ): result = await adapter.connect() assert result is True @@ -281,10 +347,324 @@ class TestSlackConnectCleanup: assert adapter._handler is second_handler +# --------------------------------------------------------------------------- +# TestSlackSocketWatchdog +# --------------------------------------------------------------------------- + + +class TestSlackSocketWatchdog: + """End-to-end behavioural coverage for the Socket Mode watchdog/reconnect. + + These tests drive the adapter through a fake AsyncSocketModeHandler so we + can simulate Slack silently dropping the websocket (the original P0) and + assert the adapter heals itself without touching real network/Slack. + """ + + def _make_fake_handler_factory(self): + """Return ``(factory, instances)`` where each call records a handler.""" + instances: list = [] + + class FakeHandler: + def __init__(self, app, app_token, proxy=None): + self.app = app + self.app_token = app_token + self.proxy = proxy + self.client = MagicMock() + self.client.proxy = proxy + self.client.is_connected = lambda: True + self._start_event = asyncio.Event() + self.closed = False + self.start_calls = 0 + instances.append(self) + + async def start_async(self): + self.start_calls += 1 + await self._start_event.wait() + + async def close_async(self): + self.closed = True + self._start_event.set() + + return FakeHandler, instances + + def _patch_stack(self, fake_factory): + """Return a list of patcher context managers to keep active for the test.""" + mock_app = MagicMock() + + def _noop_decorator(_): + def decorator(fn): + return fn + + return decorator + + mock_app.event = _noop_decorator + mock_app.command = _noop_decorator + mock_app.action = _noop_decorator + mock_app.client = AsyncMock() + + mock_web_client = AsyncMock() + mock_web_client.auth_test = AsyncMock( + return_value={ + "user_id": "U_BOT", + "user": "testbot", + "team_id": "T_FAKE", + "team": "FakeTeam", + } + ) + + return [ + patch.object(_slack_mod, "AsyncApp", return_value=mock_app), + patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), + patch.object(_slack_mod, "AsyncSocketModeHandler", fake_factory), + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("gateway.status.release_scoped_lock"), + ] + + async def _drain(self, iterations=10): + for _ in range(iterations): + await asyncio.sleep(0) + + @pytest.mark.asyncio + async def test_watchdog_reconnects_when_socket_task_dies_unexpectedly(self): + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 0.01 + factory, instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + assert len(instances) == 1 + + instances[0]._start_event.set() + await self._drain() + + for _ in range(40): + if len(instances) >= 2: + break + await asyncio.sleep(0.01) + + assert len(instances) >= 2, "watchdog/done_callback did not reconnect" + assert instances[0].closed is True + assert instances[-1].start_calls == 1 + assert adapter._handler is instances[-1] + finally: + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_watchdog_reconnects_when_transport_reports_disconnected(self): + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 0.01 + factory, instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + assert len(instances) == 1 + + instances[0].client.is_connected = lambda: False + + for _ in range(40): + if len(instances) >= 2: + break + await asyncio.sleep(0.01) + + assert len(instances) >= 2, "watchdog did not heal dead transport" + assert instances[0].closed is True + assert adapter._handler is instances[-1] + finally: + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_disconnect_stops_watchdog_and_does_not_reconnect(self): + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 0.01 + factory, instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + assert await adapter.connect() is True + assert len(instances) == 1 + + await adapter.disconnect() + + assert adapter._handler is None + assert adapter._socket_mode_task is None + assert adapter._socket_watchdog_task is None + assert instances[0].closed is True + + for _ in range(10): + await asyncio.sleep(0.01) + + assert len(instances) == 1, "watchdog kept reconnecting after disconnect" + + @pytest.mark.asyncio + async def test_watchdog_cancellation_does_not_respawn(self): + """Cancellation is the intentional-shutdown signal — no respawn allowed.""" + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 0.01 + factory, _instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + first_watchdog = adapter._socket_watchdog_task + + first_watchdog.cancel() + for _ in range(20): + if first_watchdog.done(): + break + await asyncio.sleep(0.01) + + # Done-callback must treat cancel as a shutdown signal and + # leave the watchdog unattended (either cleared or unchanged + # to the same cancelled task — never a fresh respawn). + assert adapter._socket_watchdog_task is None or ( + adapter._socket_watchdog_task is first_watchdog + ) + finally: + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_watchdog_unexpected_exit_respawns_via_done_callback(self): + """A real exception out of the loop body must trigger a respawn.""" + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 0.01 + factory, _instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + first_watchdog = adapter._socket_watchdog_task + assert first_watchdog is not None + + # Build a fake "crashed" task: a coroutine that raises so the + # done-callback observes a non-cancelled exit with exception. + async def _boom(): + raise RuntimeError("simulated watchdog crash") + + crashed = asyncio.create_task(_boom()) + # Wait for it to actually complete with the exception. + for _ in range(20): + if crashed.done(): + break + await asyncio.sleep(0.01) + assert crashed.done() and crashed.exception() is not None + + # Pretend this crashed task is the current watchdog and drive + # the done-callback directly — this is the exact signal the + # event loop fires when the real watchdog blows up. + adapter._socket_watchdog_task = crashed + adapter._on_socket_watchdog_done(crashed) + + replacement = adapter._socket_watchdog_task + assert replacement is not None + assert replacement is not crashed + assert not replacement.done() + finally: + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_connect_replaces_prior_watchdog_atomically(self): + """A reconnect must not leave the adapter without a watchdog.""" + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 0.01 + factory, instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + first_watchdog = adapter._socket_watchdog_task + assert first_watchdog is not None + + # Second connect() must cancel the prior watchdog and install + # a brand new one — never observe a window with no watchdog. + assert await adapter.connect() is True + second_watchdog = adapter._socket_watchdog_task + assert second_watchdog is not None + assert second_watchdog is not first_watchdog + assert first_watchdog.done() + finally: + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_reconnect_refreshes_multi_workspace_state(self): + """A reconnect that rotates the primary token must drop stale state.""" + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 9999 + factory, _instances = self._make_fake_handler_factory() + + # Pre-seed stale multi-workspace state as if a prior connect had run. + adapter._bot_user_id = "U_OLD_BOT" + adapter._team_clients = {"T_OLD": MagicMock(name="old-client")} + adapter._team_bot_user_ids = {"T_OLD": "U_OLD_BOT"} + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + + # State must reflect the fresh auth, not the stale seed. + assert adapter._bot_user_id == "U_BOT" + assert "T_OLD" not in adapter._team_clients + assert "T_OLD" not in adapter._team_bot_user_ids + assert "T_FAKE" in adapter._team_clients + assert adapter._team_bot_user_ids["T_FAKE"] == "U_BOT" + finally: + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_reconnect_lock_prevents_concurrent_reconnects(self): + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-fake")) + adapter._socket_watchdog_interval_s = 9999 + factory, instances = self._make_fake_handler_factory() + + with contextlib.ExitStack() as stack: + for p in self._patch_stack(factory): + stack.enter_context(p) + + try: + assert await adapter.connect() is True + baseline = len(instances) + + await asyncio.gather( + adapter._restart_socket_mode("watchdog"), + adapter._restart_socket_mode("done-callback"), + ) + + new_handlers = len(instances) - baseline + assert new_handlers >= 1 + assert ( + new_handlers <= 2 + ), f"reconnect lock failed: {new_handlers} new handlers" + finally: + await adapter.disconnect() + + # --------------------------------------------------------------------------- # TestSlackProxyBehavior # --------------------------------------------------------------------------- + class TestSlackProxyBehavior: def test_no_proxy_helper_matches_slack_hosts(self): assert is_host_excluded_by_no_proxy("slack.com", "localhost,.slack.com") @@ -293,18 +673,34 @@ class TestSlackProxyBehavior: assert not is_host_excluded_by_no_proxy("slack.com", "localhost,.internal.corp") def test_resolve_slack_proxy_url_ignores_unsupported_proxy_schemes(self): - with patch.object(_slack_mod, "resolve_proxy_url", return_value="socks5://proxy.example.com:1080"): + with patch.object( + _slack_mod, + "resolve_proxy_url", + return_value="socks5://proxy.example.com:1080", + ): assert _slack_mod._resolve_slack_proxy_url() is None def test_resolve_slack_proxy_url_checks_all_slack_hosts(self): - with patch.object(_slack_mod, "resolve_proxy_url", return_value="http://proxy.example.com:3128"), \ - patch.object(_slack_mod, "is_host_excluded_by_no_proxy", side_effect=lambda host: host == "wss-primary.slack.com") as excluded: + with ( + patch.object( + _slack_mod, + "resolve_proxy_url", + return_value="http://proxy.example.com:3128", + ), + patch.object( + _slack_mod, + "is_host_excluded_by_no_proxy", + side_effect=lambda host: host == "wss-primary.slack.com", + ) as excluded, + ): assert _slack_mod._resolve_slack_proxy_url() is None - excluded.assert_has_calls([ - call("slack.com"), - call("files.slack.com"), - call("wss-primary.slack.com"), - ]) + excluded.assert_has_calls( + [ + call("slack.com"), + call("files.slack.com"), + call("wss-primary.slack.com"), + ] + ) @pytest.mark.asyncio async def test_connect_uses_proxy_when_not_bypassed(self): @@ -316,12 +712,14 @@ class TestSlackProxyBehavior: self.token = token self.proxy = "constructor-default" suffix = token.split("-")[-1] - self.auth_test = AsyncMock(return_value={ - "team_id": f"T_{suffix}", - "user_id": f"U_{suffix}", - "user": f"bot-{suffix}", - "team": f"Team {suffix}", - }) + self.auth_test = AsyncMock( + return_value={ + "team_id": f"T_{suffix}", + "user_id": f"U_{suffix}", + "user": f"bot-{suffix}", + "team": f"Team {suffix}", + } + ) created_clients.append(self) class FakeApp: @@ -364,7 +762,7 @@ class TestSlackProxyBehavior: self.proxy = proxy self.client = MagicMock(proxy="constructor-default") - def start_async(self): + async def start_async(self): return None async def close_async(self): @@ -373,18 +771,27 @@ class TestSlackProxyBehavior: config = PlatformConfig(enabled=True, token="xoxb-primary,xoxb-secondary") adapter = SlackAdapter(config) - with patch.object(_slack_mod, "AsyncApp", side_effect=FakeApp), \ - patch.object(_slack_mod, "AsyncWebClient", side_effect=FakeWebClient), \ - patch.object(_slack_mod, "AsyncSocketModeHandler", FakeSocketModeHandler), \ - patch.object(_slack_mod, "_resolve_slack_proxy_url", return_value="http://proxy.example.com:3128"), \ - patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}, clear=False), \ - patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ - patch("asyncio.create_task", return_value=MagicMock(name="socket-mode-task")): + with ( + patch.object(_slack_mod, "AsyncApp", side_effect=FakeApp), + patch.object(_slack_mod, "AsyncWebClient", side_effect=FakeWebClient), + patch.object(_slack_mod, "AsyncSocketModeHandler", FakeSocketModeHandler), + patch.object( + _slack_mod, + "_resolve_slack_proxy_url", + return_value="http://proxy.example.com:3128", + ), + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}, clear=False), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("asyncio.create_task", side_effect=_fake_create_task), + ): result = await adapter.connect() assert result is True assert created_apps[0].client.proxy == "http://proxy.example.com:3128" - assert all(client.proxy == "http://proxy.example.com:3128" for client in created_clients) + assert all( + client.proxy == "http://proxy.example.com:3128" + for client in created_clients + ) assert adapter._handler is not None assert adapter._handler.proxy == "http://proxy.example.com:3128" assert adapter._handler.client.proxy == "http://proxy.example.com:3128" @@ -399,12 +806,14 @@ class TestSlackProxyBehavior: self.token = token self.proxy = "constructor-default" suffix = token.split("-")[-1] - self.auth_test = AsyncMock(return_value={ - "team_id": f"T_{suffix}", - "user_id": f"U_{suffix}", - "user": f"bot-{suffix}", - "team": f"Team {suffix}", - }) + self.auth_test = AsyncMock( + return_value={ + "team_id": f"T_{suffix}", + "user_id": f"U_{suffix}", + "user": f"bot-{suffix}", + "team": f"Team {suffix}", + } + ) created_clients.append(self) class FakeApp: @@ -447,7 +856,7 @@ class TestSlackProxyBehavior: self.proxy = proxy self.client = MagicMock(proxy="constructor-default") - def start_async(self): + async def start_async(self): return None async def close_async(self): @@ -456,13 +865,15 @@ class TestSlackProxyBehavior: config = PlatformConfig(enabled=True, token="xoxb-primary") adapter = SlackAdapter(config) - with patch.object(_slack_mod, "AsyncApp", side_effect=FakeApp), \ - patch.object(_slack_mod, "AsyncWebClient", side_effect=FakeWebClient), \ - patch.object(_slack_mod, "AsyncSocketModeHandler", FakeSocketModeHandler), \ - patch.object(_slack_mod, "_resolve_slack_proxy_url", return_value=None), \ - patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}, clear=False), \ - patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ - patch("asyncio.create_task", return_value=MagicMock(name="socket-mode-task")): + with ( + patch.object(_slack_mod, "AsyncApp", side_effect=FakeApp), + patch.object(_slack_mod, "AsyncWebClient", side_effect=FakeWebClient), + patch.object(_slack_mod, "AsyncSocketModeHandler", FakeSocketModeHandler), + patch.object(_slack_mod, "_resolve_slack_proxy_url", return_value=None), + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}, clear=False), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("asyncio.create_task", side_effect=_fake_create_task), + ): result = await adapter.connect() assert result is True @@ -477,6 +888,7 @@ class TestSlackProxyBehavior: # TestSendDocument # --------------------------------------------------------------------------- + class TestSendDocument: @pytest.mark.asyncio async def test_send_document_success(self, adapter, tmp_path): @@ -573,7 +985,9 @@ class TestSendDocument: assert call_kwargs["thread_ts"] == "1234567890.123456" @pytest.mark.asyncio - async def test_send_document_thread_upload_marks_bot_participation(self, adapter, tmp_path): + async def test_send_document_thread_upload_marks_bot_participation( + self, adapter, tmp_path + ): test_file = tmp_path / "notes.txt" test_file.write_bytes(b"some notes") @@ -588,7 +1002,9 @@ class TestSendDocument: assert "1234567890.123456" in adapter._bot_message_ts @pytest.mark.asyncio - async def test_send_document_retries_transient_upload_error(self, adapter, tmp_path): + async def test_send_document_retries_transient_upload_error( + self, adapter, tmp_path + ): test_file = tmp_path / "notes.txt" test_file.write_bytes(b"some notes") @@ -610,7 +1026,9 @@ class TestSendDocument: class TestSendPrivateNotice: @pytest.mark.asyncio async def test_send_private_notice_uses_ephemeral_api(self, adapter): - adapter._app.client.chat_postEphemeral = AsyncMock(return_value={"message_ts": "123.456"}) + adapter._app.client.chat_postEphemeral = AsyncMock( + return_value={"message_ts": "123.456"} + ) result = await adapter.send_private_notice( chat_id="C123", @@ -633,6 +1051,7 @@ class TestSendPrivateNotice: # TestSendVideo # --------------------------------------------------------------------------- + class TestSendVideo: @pytest.mark.asyncio async def test_send_video_success(self, adapter, tmp_path): @@ -784,7 +1203,9 @@ class TestBangPrefixCommands: class TestIncomingDocumentHandling: - def _make_event(self, files=None, text="hello", channel_type="im", blocks=None, attachments=None): + def _make_event( + self, files=None, text="hello", channel_type="im", blocks=None, attachments=None + ): """Build a mock Slack message event with file attachments.""" return { "text": text, @@ -802,14 +1223,20 @@ class TestIncomingDocumentHandling: """A PDF attachment should be downloaded, cached, and set as DOCUMENT type.""" pdf_bytes = b"%PDF-1.4 fake content" - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.return_value = pdf_bytes - event = self._make_event(files=[{ - "mimetype": "application/pdf", - "name": "report.pdf", - "url_private_download": "https://files.slack.com/report.pdf", - "size": len(pdf_bytes), - }]) + event = self._make_event( + files=[ + { + "mimetype": "application/pdf", + "name": "report.pdf", + "url_private_download": "https://files.slack.com/report.pdf", + "size": len(pdf_bytes), + } + ] + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -823,16 +1250,20 @@ class TestIncomingDocumentHandling: """A .txt file under 100KB should have its content injected into event text.""" content = b"Hello from a text file" - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.return_value = content event = self._make_event( text="summarize this", - files=[{ - "mimetype": "text/plain", - "name": "notes.txt", - "url_private_download": "https://files.slack.com/notes.txt", - "size": len(content), - }], + files=[ + { + "mimetype": "text/plain", + "name": "notes.txt", + "url_private_download": "https://files.slack.com/notes.txt", + "size": len(content), + } + ], ) await adapter._handle_slack_message(event) @@ -846,14 +1277,21 @@ class TestIncomingDocumentHandling: """A .md file under 100KB should have its content injected.""" content = b"# Title\nSome markdown content" - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.return_value = content - event = self._make_event(files=[{ - "mimetype": "text/markdown", - "name": "readme.md", - "url_private_download": "https://files.slack.com/readme.md", - "size": len(content), - }], text="") + event = self._make_event( + files=[ + { + "mimetype": "text/markdown", + "name": "readme.md", + "url_private_download": "https://files.slack.com/readme.md", + "size": len(content), + } + ], + text="", + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -864,20 +1302,24 @@ class TestIncomingDocumentHandling: """A .json snippet should be treated as a text document and injected.""" content = b'{"hello": "world", "count": 2}' - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.return_value = content event = self._make_event( text="can you parse this", - files=[{ - "mimetype": "text/plain", - "name": "zapfile.json", - "filetype": "json", - "pretty_type": "JSON", - "mode": "snippet", - "editable": True, - "url_private_download": "https://files.slack.com/zapfile.json", - "size": len(content), - }], + files=[ + { + "mimetype": "text/plain", + "name": "zapfile.json", + "filetype": "json", + "pretty_type": "JSON", + "mode": "snippet", + "editable": True, + "url_private_download": "https://files.slack.com/zapfile.json", + "size": len(content), + } + ], ) await adapter._handle_slack_message(event) @@ -885,23 +1327,30 @@ class TestIncomingDocumentHandling: assert msg_event.message_type == MessageType.DOCUMENT assert len(msg_event.media_urls) == 1 assert msg_event.media_types == ["application/json"] - assert '[Content of zapfile.json]' in msg_event.text + assert "[Content of zapfile.json]" in msg_event.text assert '"hello": "world"' in msg_event.text - assert 'can you parse this' in msg_event.text + assert "can you parse this" in msg_event.text @pytest.mark.asyncio async def test_large_txt_not_injected(self, adapter): """A .txt file over 100KB should be cached but NOT injected.""" content = b"x" * (200 * 1024) - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.return_value = content - event = self._make_event(files=[{ - "mimetype": "text/plain", - "name": "big.txt", - "url_private_download": "https://files.slack.com/big.txt", - "size": len(content), - }], text="") + event = self._make_event( + files=[ + { + "mimetype": "text/plain", + "name": "big.txt", + "url_private_download": "https://files.slack.com/big.txt", + "size": len(content), + } + ], + text="", + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -911,14 +1360,20 @@ class TestIncomingDocumentHandling: @pytest.mark.asyncio async def test_zip_file_cached(self, adapter): """A .zip file should be cached as a supported document.""" - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.return_value = b"PK\x03\x04zip" - event = self._make_event(files=[{ - "mimetype": "application/zip", - "name": "archive.zip", - "url_private_download": "https://files.slack.com/archive.zip", - "size": 1024, - }]) + event = self._make_event( + files=[ + { + "mimetype": "application/zip", + "name": "archive.zip", + "url_private_download": "https://files.slack.com/archive.zip", + "size": 1024, + } + ] + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -929,12 +1384,16 @@ class TestIncomingDocumentHandling: @pytest.mark.asyncio async def test_oversized_document_skipped(self, adapter): """A document over 20MB should be skipped.""" - event = self._make_event(files=[{ - "mimetype": "application/pdf", - "name": "huge.pdf", - "url_private_download": "https://files.slack.com/huge.pdf", - "size": 25 * 1024 * 1024, - }]) + event = self._make_event( + files=[ + { + "mimetype": "application/pdf", + "name": "huge.pdf", + "url_private_download": "https://files.slack.com/huge.pdf", + "size": 25 * 1024 * 1024, + } + ] + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -943,14 +1402,20 @@ class TestIncomingDocumentHandling: @pytest.mark.asyncio async def test_document_download_error_handled(self, adapter): """If document download fails, handler should not crash.""" - with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file_bytes", new_callable=AsyncMock + ) as dl: dl.side_effect = RuntimeError("download failed") - event = self._make_event(files=[{ - "mimetype": "application/pdf", - "name": "report.pdf", - "url_private_download": "https://files.slack.com/report.pdf", - "size": 1024, - }]) + event = self._make_event( + files=[ + { + "mimetype": "application/pdf", + "name": "report.pdf", + "url_private_download": "https://files.slack.com/report.pdf", + "size": 1024, + } + ] + ) await adapter._handle_slack_message(event) # Handler should still be called (the exception is caught) @@ -959,14 +1424,20 @@ class TestIncomingDocumentHandling: @pytest.mark.asyncio async def test_image_still_handled(self, adapter): """Image attachments should still go through the image path, not document.""" - with patch.object(adapter, "_download_slack_file", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file", new_callable=AsyncMock + ) as dl: dl.return_value = "/tmp/cached_image.jpg" - event = self._make_event(files=[{ - "mimetype": "image/jpeg", - "name": "photo.jpg", - "url_private_download": "https://files.slack.com/photo.jpg", - "size": 1024, - }]) + event = self._make_event( + files=[ + { + "mimetype": "image/jpeg", + "name": "photo.jpg", + "url_private_download": "https://files.slack.com/photo.jpg", + "size": 1024, + } + ] + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -981,18 +1452,26 @@ class TestIncomingDocumentHandling: runs only when the download actually fails. """ import httpx + req = httpx.Request("GET", "https://files.slack.com/photo.jpg") resp = httpx.Response(403, request=req) - with patch.object(adapter, "_download_slack_file", new_callable=AsyncMock) as dl: + with patch.object( + adapter, "_download_slack_file", new_callable=AsyncMock + ) as dl: dl.side_effect = httpx.HTTPStatusError("403", request=req, response=resp) - event = self._make_event(text="what's in this?", files=[{ - "id": "F123", - "mimetype": "image/jpeg", - "name": "photo.jpg", - "url_private_download": "https://files.slack.com/photo.jpg", - "size": 1024, - }]) + event = self._make_event( + text="what's in this?", + files=[ + { + "id": "F123", + "mimetype": "image/jpeg", + "name": "photo.jpg", + "url_private_download": "https://files.slack.com/photo.jpg", + "size": 1024, + } + ], + ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] @@ -1041,7 +1520,9 @@ class TestIncomingDocumentHandling: "elements": [ { "type": "rich_text_section", - "elements": [{"type": "text", "text": "Quoted line"}], + "elements": [ + {"type": "text", "text": "Quoted line"} + ], } ], }, @@ -1051,11 +1532,15 @@ class TestIncomingDocumentHandling: "elements": [ { "type": "rich_text_section", - "elements": [{"type": "text", "text": "First bullet"}], + "elements": [ + {"type": "text", "text": "First bullet"} + ], }, { "type": "rich_text_section", - "elements": [{"type": "text", "text": "Second bullet"}], + "elements": [ + {"type": "text", "text": "Second bullet"} + ], }, ], }, @@ -1073,7 +1558,9 @@ class TestIncomingDocumentHandling: assert "• Second bullet" in msg_event.text @pytest.mark.asyncio - async def test_attachments_unfurl_text_is_appended_even_when_url_is_in_message(self, adapter): + async def test_attachments_unfurl_text_is_appended_even_when_url_is_in_message( + self, adapter + ): """Shared URLs should still expose unfurl preview text to the agent.""" event = self._make_event( text="Look at this doc https://example.com/spec", @@ -1115,7 +1602,9 @@ class TestIncomingDocumentHandling: assert msg_event.text == "https://example.com/thread" @pytest.mark.asyncio - async def test_channel_routing_ignores_bot_mentions_inside_block_text(self, adapter): + async def test_channel_routing_ignores_bot_mentions_inside_block_text( + self, adapter + ): """Block-extracted text with a bot mention must not satisfy mention gating in channels — routing decisions use the original user text so quoted/forwarded content can't trick the bot into responding.""" @@ -1131,7 +1620,12 @@ class TestIncomingDocumentHandling: "elements": [ { "type": "rich_text_section", - "elements": [{"type": "text", "text": "Contains <@U_BOT> in quoted text"}], + "elements": [ + { + "type": "text", + "text": "Contains <@U_BOT> in quoted text", + } + ], } ], } @@ -1145,7 +1639,9 @@ class TestIncomingDocumentHandling: adapter.handle_message.assert_not_called() @pytest.mark.asyncio - async def test_quoted_slash_command_text_does_not_change_message_type(self, adapter): + async def test_quoted_slash_command_text_does_not_change_message_type( + self, adapter + ): """Quoted slash-like content should not convert a normal message into a command.""" event = self._make_event( text="", @@ -1158,7 +1654,9 @@ class TestIncomingDocumentHandling: "elements": [ { "type": "rich_text_section", - "elements": [{"type": "text", "text": "/deploy now"}], + "elements": [ + {"type": "text", "text": "/deploy now"} + ], } ], } @@ -1178,6 +1676,7 @@ class TestIncomingDocumentHandling: # TestMessageRouting # --------------------------------------------------------------------------- + class TestMessageRouting: @pytest.mark.asyncio async def test_dm_processed_without_mention(self, adapter): @@ -1297,7 +1796,9 @@ class TestSendTyping: await adapter.stop_typing("C123", metadata={"thread_id": "parent_ts"}) - assert adapter._app.client.assistant_threads_setStatus.call_args_list[1] == call( + assert adapter._app.client.assistant_threads_setStatus.call_args_list[ + 1 + ] == call( channel_id="C123", thread_ts="parent_ts", status="", @@ -1330,7 +1831,9 @@ class TestSendTyping: @pytest.mark.asyncio async def test_send_clears_status_after_final_post(self, adapter): - adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"}) + adapter._app.client.chat_postMessage = AsyncMock( + return_value={"ts": "reply_ts"} + ) adapter._app.client.assistant_threads_setStatus = AsyncMock() adapter._active_status_threads["C123"] = "parent_ts" @@ -1529,7 +2032,9 @@ class TestFormatMessage: def test_link_with_parentheses_in_url(self, adapter): """Wikipedia-style URL with balanced parens is not truncated.""" - result = adapter.format_message("[Foo](https://en.wikipedia.org/wiki/Foo_(bar))") + result = adapter.format_message( + "[Foo](https://en.wikipedia.org/wiki/Foo_(bar))" + ) assert result == "" def test_link_with_multiple_paren_pairs(self, adapter): @@ -1544,7 +2049,9 @@ class TestFormatMessage: def test_link_with_angle_brackets_and_parens(self, adapter): """Angle-bracket URL with parens (CommonMark syntax).""" - result = adapter.format_message("[Foo]()") + result = adapter.format_message( + "[Foo]()" + ) assert result == "" def test_escaping_is_idempotent(self, adapter): @@ -1566,7 +2073,10 @@ class TestFormatMessage: def test_subteam_mention_preserved(self, adapter): """ user group mention passes through unchanged.""" - assert adapter.format_message("Paging ") == "Paging " + assert ( + adapter.format_message("Paging ") + == "Paging " + ) def test_date_formatting_preserved(self, adapter): """ formatting token passes through unchanged.""" @@ -1770,7 +2280,9 @@ class TestEditMessageStreamingPipeline: async def test_edit_message_formats_url_with_parens(self, adapter): """Wikipedia-style URL with parens survives edit pipeline.""" adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) - await adapter.edit_message("C123", "ts1", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))") + await adapter.edit_message( + "C123", "ts1", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))" + ) kwargs = adapter._app.client.chat_update.call_args.kwargs assert "" in kwargs["text"] @@ -1802,7 +2314,9 @@ class TestReactions: @pytest.mark.asyncio async def test_add_reaction_handles_error(self, adapter): - adapter._app.client.reactions_add = AsyncMock(side_effect=Exception("already_reacted")) + adapter._app.client.reactions_add = AsyncMock( + side_effect=Exception("already_reacted") + ) result = await adapter._add_reaction("C123", "ts1", "eyes") assert result is False @@ -1817,9 +2331,9 @@ class TestReactions: """Reactions should be bracketed around actual processing via hooks.""" adapter._app.client.reactions_add = AsyncMock() adapter._app.client.reactions_remove = AsyncMock() - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Tyler"}}} + ) event = { "text": "hello", @@ -1836,6 +2350,7 @@ class TestReactions: # Simulate the base class calling on_processing_start from gateway.platforms.base import MessageEvent, MessageType, SessionSource from gateway.config import Platform + source = SessionSource( platform=Platform.SLACK, chat_id="C123", @@ -1856,6 +2371,7 @@ class TestReactions: # Simulate the base class calling on_processing_complete from gateway.platforms.base import ProcessingOutcome + await adapter.on_processing_complete(msg_event, ProcessingOutcome.SUCCESS) add_calls = adapter._app.client.reactions_add.call_args_list @@ -1874,8 +2390,14 @@ class TestReactions: adapter._app.client.reactions_add = AsyncMock() adapter._app.client.reactions_remove = AsyncMock() - from gateway.platforms.base import MessageEvent, MessageType, SessionSource, ProcessingOutcome + from gateway.platforms.base import ( + MessageEvent, + MessageType, + SessionSource, + ProcessingOutcome, + ) from gateway.config import Platform + source = SessionSource( platform=Platform.SLACK, chat_id="C123", @@ -1903,9 +2425,9 @@ class TestReactions: """Non-DM, non-mention messages should not get reactions.""" adapter._app.client.reactions_add = AsyncMock() adapter._app.client.reactions_remove = AsyncMock() - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Tyler"}}} + ) event = { "text": "hello", @@ -1927,9 +2449,9 @@ class TestReactions: monkeypatch.setenv("SLACK_REACTIONS", "false") adapter._app.client.reactions_add = AsyncMock() adapter._app.client.reactions_remove = AsyncMock() - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Tyler"}}} + ) event = { "text": "hello", @@ -1944,8 +2466,14 @@ class TestReactions: assert "1234567890.000004" not in adapter._reacting_message_ids # Hooks should also be no-ops when disabled - from gateway.platforms.base import MessageEvent, MessageType, SessionSource, ProcessingOutcome + from gateway.platforms.base import ( + MessageEvent, + MessageType, + SessionSource, + ProcessingOutcome, + ) from gateway.config import Platform + source = SessionSource( platform=Platform.SLACK, chat_id="C123", @@ -2095,9 +2623,7 @@ class TestThreadReplyHandling: adapter_with_session_store.handle_message.assert_not_called() @pytest.mark.asyncio - async def test_no_session_store_ignores_thread_replies( - self, adapter - ): + async def test_no_session_store_ignores_thread_replies(self, adapter): """If no session store is attached, thread replies without mention should be ignored.""" # adapter fixture has no session store attached event = { @@ -2145,7 +2671,9 @@ class TestAssistantThreadLifecycle: return a @pytest.mark.asyncio - async def test_lifecycle_event_seeds_session_store(self, assistant_adapter, mock_session_store): + async def test_lifecycle_event_seeds_session_store( + self, assistant_adapter, mock_session_store + ): event = { "type": "assistant_thread_started", "team_id": "T_TEAM", @@ -2159,7 +2687,10 @@ class TestAssistantThreadLifecycle: await assistant_adapter._handle_assistant_thread_lifecycle_event(event) - assert assistant_adapter._assistant_threads[("D123", "171.000")]["user_id"] == "U_USER" + assert ( + assistant_adapter._assistant_threads[("D123", "171.000")]["user_id"] + == "U_USER" + ) mock_session_store.get_or_create_session.assert_called_once() source = mock_session_store.get_or_create_session.call_args[0][0] assert source.chat_id == "D123" @@ -2169,16 +2700,18 @@ class TestAssistantThreadLifecycle: assert source.chat_topic == "C_ORIGIN" @pytest.mark.asyncio - async def test_message_uses_cached_assistant_thread_identity(self, assistant_adapter): + async def test_message_uses_cached_assistant_thread_identity( + self, assistant_adapter + ): assistant_adapter._assistant_threads[("D123", "171.000")] = { "channel_id": "D123", "thread_ts": "171.000", "user_id": "U_USER", "team_id": "T_TEAM", } - assistant_adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler"}} - }) + assistant_adapter._app.client.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Tyler"}}} + ) assistant_adapter._app.client.reactions_add = AsyncMock() assistant_adapter._app.client.reactions_remove = AsyncMock() @@ -2203,19 +2736,23 @@ class TestAssistantThreadLifecycle: assistant_adapter._ASSISTANT_THREADS_MAX = 10 # Fill to the limit for i in range(10): - assistant_adapter._cache_assistant_thread_metadata({ - "channel_id": f"D{i}", - "thread_ts": f"{i}.000", - "user_id": f"U{i}", - }) + assistant_adapter._cache_assistant_thread_metadata( + { + "channel_id": f"D{i}", + "thread_ts": f"{i}.000", + "user_id": f"U{i}", + } + ) assert len(assistant_adapter._assistant_threads) == 10 # Adding one more should trigger eviction (down to max // 2 = 5) - assistant_adapter._cache_assistant_thread_metadata({ - "channel_id": "D999", - "thread_ts": "999.000", - "user_id": "U999", - }) + assistant_adapter._cache_assistant_thread_metadata( + { + "channel_id": "D999", + "thread_ts": "999.000", + "user_id": "U999", + } + ) assert len(assistant_adapter._assistant_threads) <= 10 # The newest entry must survive eviction assert ("D999", "999.000") in assistant_adapter._assistant_threads @@ -2231,25 +2768,29 @@ class TestUserNameResolution: @pytest.mark.asyncio async def test_resolves_display_name(self, adapter): - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler", "real_name": "Tyler B"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={ + "user": {"profile": {"display_name": "Tyler", "real_name": "Tyler B"}} + } + ) name = await adapter._resolve_user_name("U123") assert name == "Tyler" @pytest.mark.asyncio async def test_falls_back_to_real_name(self, adapter): - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "", "real_name": "Tyler B"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={ + "user": {"profile": {"display_name": "", "real_name": "Tyler B"}} + } + ) name = await adapter._resolve_user_name("U123") assert name == "Tyler B" @pytest.mark.asyncio async def test_caches_result(self, adapter): - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Tyler"}}} + ) await adapter._resolve_user_name("U123") await adapter._resolve_user_name("U123") # Only one API call despite two lookups @@ -2257,16 +2798,18 @@ class TestUserNameResolution: @pytest.mark.asyncio async def test_handles_api_error(self, adapter): - adapter._app.client.users_info = AsyncMock(side_effect=Exception("rate limited")) + adapter._app.client.users_info = AsyncMock( + side_effect=Exception("rate limited") + ) name = await adapter._resolve_user_name("U123") assert name == "U123" # Falls back to user_id @pytest.mark.asyncio async def test_user_name_in_message_source(self, adapter): """Message source should include resolved user name.""" - adapter._app.client.users_info = AsyncMock(return_value={ - "user": {"profile": {"display_name": "Tyler"}} - }) + adapter._app.client.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Tyler"}}} + ) adapter._app.client.reactions_add = AsyncMock() adapter._app.client.reactions_remove = AsyncMock() @@ -2417,9 +2960,7 @@ class TestMessageSplitting: async def test_long_message_split_into_chunks(self, adapter): """Messages over MAX_MESSAGE_LENGTH should be split.""" long_text = "x" * 45000 # Over Slack's 40k API limit - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "ts1"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) await adapter.send("C123", long_text) # Should have been called multiple times assert adapter._app.client.chat_postMessage.call_count >= 2 @@ -2427,9 +2968,7 @@ class TestMessageSplitting: @pytest.mark.asyncio async def test_short_message_single_send(self, adapter): """Short messages should be sent in one call.""" - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "ts1"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) await adapter.send("C123", "hello world") assert adapter._app.client.chat_postMessage.call_count == 1 @@ -2486,9 +3025,7 @@ class TestReplyBroadcast: @pytest.mark.asyncio async def test_broadcast_disabled_by_default(self, adapter): - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "ts1"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) await adapter.send("C123", "hi", metadata={"thread_id": "parent_ts"}) kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert "reply_broadcast" not in kwargs @@ -2496,9 +3033,7 @@ class TestReplyBroadcast: @pytest.mark.asyncio async def test_broadcast_enabled_via_config(self, adapter): adapter.config.extra["reply_broadcast"] = True - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "ts1"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) await adapter.send("C123", "hi", metadata={"thread_id": "parent_ts"}) kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert kwargs.get("reply_broadcast") is True @@ -2508,6 +3043,7 @@ class TestReplyBroadcast: # TestFallbackPreservesThreadContext # --------------------------------------------------------------------------- + class TestFallbackPreservesThreadContext: """Bug fix: file upload fallbacks lost thread context (metadata) when calling super() without metadata, causing replies to appear outside @@ -2521,9 +3057,7 @@ class TestFallbackPreservesThreadContext: adapter._app.client.files_upload_v2 = AsyncMock( side_effect=Exception("upload failed") ) - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "msg_ts"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "msg_ts"}) metadata = {"thread_id": "parent_ts_123"} await adapter.send_image_file( @@ -2544,9 +3078,7 @@ class TestFallbackPreservesThreadContext: adapter._app.client.files_upload_v2 = AsyncMock( side_effect=Exception("upload failed") ) - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "msg_ts"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "msg_ts"}) metadata = {"thread_id": "parent_ts_456"} await adapter.send_video( @@ -2566,9 +3098,7 @@ class TestFallbackPreservesThreadContext: adapter._app.client.files_upload_v2 = AsyncMock( side_effect=Exception("upload failed") ) - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "msg_ts"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "msg_ts"}) metadata = {"thread_id": "parent_ts_789"} await adapter.send_document( @@ -2589,9 +3119,7 @@ class TestFallbackPreservesThreadContext: adapter._app.client.files_upload_v2 = AsyncMock( side_effect=Exception("upload failed") ) - adapter._app.client.chat_postMessage = AsyncMock( - return_value={"ts": "msg_ts"} - ) + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "msg_ts"}) await adapter.send_image_file( chat_id="C123", @@ -2607,6 +3135,7 @@ class TestFallbackPreservesThreadContext: # TestSendImageSSRFGuards # --------------------------------------------------------------------------- + class TestSendImageSSRFGuards: """send_image should reject redirects that land on private/internal hosts.""" @@ -2629,7 +3158,9 @@ class TestSendImageSSRFGuards: mock_client.get = AsyncMock(side_effect=fake_get) adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True}) - adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"}) + adapter._app.client.chat_postMessage = AsyncMock( + return_value={"ts": "reply_ts"} + ) def fake_async_client(*args, **kwargs): client_kwargs.update(kwargs) @@ -2676,7 +3207,9 @@ class TestSendImageSSRFGuards: mock_client.get = AsyncMock(side_effect=fake_get) adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True}) - adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"}) + adapter._app.client.chat_postMessage = AsyncMock( + return_value={"ts": "reply_ts"} + ) def fake_async_client(*args, **kwargs): client_kwargs.update(kwargs) @@ -2704,6 +3237,7 @@ class TestSendImageSSRFGuards: # TestProgressMessageThread # --------------------------------------------------------------------------- + class TestProgressMessageThread: """Verify that progress messages go to the correct thread. @@ -2727,10 +3261,14 @@ class TestProgressMessageThread: } captured_events = [] - adapter.handle_message = AsyncMock(side_effect=lambda e: captured_events.append(e)) + adapter.handle_message = AsyncMock( + side_effect=lambda e: captured_events.append(e) + ) # Patch _resolve_user_name to avoid async Slack API call - with patch.object(adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser")): + with patch.object( + adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser") + ): await adapter._handle_slack_message(event) assert len(captured_events) == 1 @@ -2753,7 +3291,9 @@ class TestProgressMessageThread: # Verify that the Slack send() method correctly threads a message # when metadata contains thread_id equal to the original ts - adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"}) + adapter._app.client.chat_postMessage = AsyncMock( + return_value={"ts": "reply_ts"} + ) result = await adapter.send( chat_id="D_DM", content="⚙️ working...", @@ -2780,9 +3320,13 @@ class TestProgressMessageThread: } captured_events = [] - adapter.handle_message = AsyncMock(side_effect=lambda e: captured_events.append(e)) + adapter.handle_message = AsyncMock( + side_effect=lambda e: captured_events.append(e) + ) - with patch.object(adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser")): + with patch.object( + adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser") + ): await adapter._handle_slack_message(event) assert len(captured_events) == 1 @@ -2808,9 +3352,13 @@ class TestProgressMessageThread: } captured_events = [] - adapter.handle_message = AsyncMock(side_effect=lambda e: captured_events.append(e)) + adapter.handle_message = AsyncMock( + side_effect=lambda e: captured_events.append(e) + ) - with patch.object(adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser")): + with patch.object( + adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser") + ): await adapter._handle_slack_message(event) assert len(captured_events) == 1 @@ -2838,16 +3386,18 @@ class TestSlackReplyToText: adapter._team_bot_user_ids = {} # Mock conversations_replies to return a bot-posted parent - adapter._app.client.conversations_replies = AsyncMock(return_value={ - "messages": [ - { - "ts": "1000.0", - "bot_id": "B_CRON", - "text": "メール要約: 新着メール3件あります", - }, - {"ts": "1000.5", "user": "U_USER", "text": "詳細を教えて"}, - ] - }) + adapter._app.client.conversations_replies = AsyncMock( + return_value={ + "messages": [ + { + "ts": "1000.0", + "bot_id": "B_CRON", + "text": "メール要約: 新着メール3件あります", + }, + {"ts": "1000.5", "user": "U_USER", "text": "詳細を教えて"}, + ] + } + ) # Use a DM so mention-gating doesn't short-circuit the handler. event = { @@ -2864,9 +3414,9 @@ class TestSlackReplyToText: ): await adapter._handle_slack_message(event) - assert adapter.handle_message.call_args is not None, ( - "handle_message must be invoked for thread-reply DM" - ) + assert ( + adapter.handle_message.call_args is not None + ), "handle_message must be invoked for thread-reply DM" msg_event = adapter.handle_message.call_args[0][0] assert msg_event.reply_to_message_id == "1000.0" # The critical assertion: parent text is exposed as reply_to_text so the @@ -2942,6 +3492,7 @@ class TestSlashEphemeralAck: async def test_pop_slash_context_returns_and_removes(self, adapter): """_pop_slash_context returns the context and removes it.""" import time + adapter._slash_command_contexts[("C1", "U1")] = { "response_url": "https://hooks.slack.com/test", "ts": time.monotonic(), @@ -2963,6 +3514,7 @@ class TestSlashEphemeralAck: async def test_pop_slash_context_discards_stale_entries(self, adapter): """Stale contexts older than TTL are cleaned up.""" import time + adapter._slash_command_contexts[("C1", "U1")] = { "response_url": "https://hooks.slack.com/stale", "ts": time.monotonic() - adapter._SLASH_CTX_TTL - 1, @@ -2976,6 +3528,7 @@ class TestSlashEphemeralAck: async def test_send_uses_response_url_when_context_exists(self, adapter): """send() should POST to response_url for slash command replies.""" import time + adapter._slash_command_contexts[("C_SLASH", "U_SLASH")] = { "response_url": "https://hooks.slack.com/commands/T123/456/abc", "ts": time.monotonic(), @@ -2991,7 +3544,9 @@ class TestSlashEphemeralAck: mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) - with patch("gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session): + with patch( + "gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session + ): result = await adapter.send("C_SLASH", "Queued for the next turn.") assert result.success is True @@ -3022,6 +3577,7 @@ class TestSlashEphemeralAck: async def test_send_slash_ephemeral_fallback_on_post_failure(self, adapter): """_send_slash_ephemeral returns success=True even if POST fails.""" import time + adapter._slash_command_contexts[("C1", "U1")] = { "response_url": "https://hooks.slack.com/commands/bad", "ts": time.monotonic(), @@ -3038,7 +3594,9 @@ class TestSlashEphemeralAck: mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) - with patch("gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session): + with patch( + "gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session + ): result = await adapter.send("C1", "Some response") # Still success — the user saw the initial ack already @@ -3048,6 +3606,7 @@ class TestSlashEphemeralAck: async def test_send_slash_ephemeral_fallback_on_exception(self, adapter): """_send_slash_ephemeral returns success=True even if aiohttp raises.""" import time + adapter._slash_command_contexts[("C1", "U1")] = { "response_url": "https://hooks.slack.com/commands/timeout", "ts": time.monotonic(), @@ -3058,7 +3617,9 @@ class TestSlashEphemeralAck: mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) - with patch("gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session): + with patch( + "gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session + ): result = await adapter.send("C1", "Some response") assert result.success is True diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py index 20c2ca7cda4..c504e951af6 100644 --- a/tests/hermes_cli/test_gateway.py +++ b/tests/hermes_cli/test_gateway.py @@ -442,6 +442,28 @@ def test_find_gateway_pids_falls_back_to_pid_file_when_process_scan_fails(monkey assert gateway.find_gateway_pids() == [321] +def test_scan_gateway_pids_detects_windows_hermes_exe_case_variants(monkeypatch): + monkeypatch.setattr(gateway, "is_windows", lambda: True) + monkeypatch.setattr(gateway, "_get_ancestor_pids", lambda: set()) + monkeypatch.setattr(gateway.shutil, "which", lambda name: "wmic.exe" if name == "wmic" else None) + + def fake_run(cmd, **kwargs): + if cmd[:4] == ["wmic.exe", "process", "get", "ProcessId,CommandLine"]: + return SimpleNamespace( + returncode=0, + stdout=( + "CommandLine=C:\\Program Files\\Hermes\\Hermes.EXE gateway run --replace\n" + "ProcessId=2468\n\n" + ), + stderr="", + ) + raise AssertionError(f"Unexpected command: {cmd}") + + monkeypatch.setattr(gateway.subprocess, "run", fake_run) + + assert gateway._scan_gateway_pids(set(), all_profiles=True) == [2468] + + # --------------------------------------------------------------------------- # _wait_for_gateway_exit # ---------------------------------------------------------------------------