From 715fa9ea1c8f1e1b49b698ec32a1ba822e5a7ce3 Mon Sep 17 00:00:00 2001 From: Charles Power Date: Sun, 7 Jun 2026 21:57:20 -0700 Subject: [PATCH] fix(gateway): harden gateway command-line matcher (review findings) Address correctness gaps found in pre-PR review of the strict matcher: - Profile selectors can appear on EITHER side of the `gateway` token (`_apply_profile_override` strips `--profile`/`-p` from anywhere in argv before argparse), so `hermes gateway --profile work run` and `python -m hermes_cli.main gateway -p work run` are valid launches the previous matcher wrongly rejected. Strip `--profile`/`-p`/`--profile=`/`-p=` from anywhere before locating the subcommand. - A profile literally named `gateway` (`hermes -p gateway gateway run`) made the old token scan stop on the profile value; stripping the selector+value first fixes it. - Tokenize quote-aware with `shlex` so quoted Windows paths containing spaces (`"C:\Program Files\Hermes\hermes-gateway.exe"`) are no longer split mid-path and the dedicated-entrypoint match survives. Without these, the matcher could MISS a real running gateway -> the opposite failure (restart/status reporting "down" when up). Adds regression tests for all three shapes. Co-Authored-By: Claude Opus 4.8 (1M context) --- gateway/status.py | 63 ++++++++++++++----- .../test_gateway_command_line_matcher.py | 12 ++++ 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/gateway/status.py b/gateway/status.py index 5e5584a1ed8..2b4bd08ba39 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -14,7 +14,7 @@ concurrently under distinct configurations). import hashlib import json import os -import re +import shlex import signal import subprocess import sys @@ -173,36 +173,69 @@ def looks_like_gateway_command_line(command: str | None) -> bool: test also matched ``hermes_cli.main gateway status`` and even unrelated processes like ``python -m tui_gateway`` -- which made ``restart()`` race against a still-draining old process and ``status``/``start`` report false - positives. This requires the actual ``gateway`` subcommand to be followed - by ``run`` (or the gateway-dedicated entrypoints), excluding the other + positives. This requires the actual ``gateway`` subcommand followed by + ``run`` (or one of the gateway-dedicated entrypoints), excluding the other ``gateway`` management subcommands and any process that merely contains the word "gateway". + + Tokenizes quote-aware (``shlex``) so quoted Windows paths with spaces + (``"C:\\Program Files\\...\\hermes-gateway.exe"``) survive, and strips + ``--profile``/``-p`` selectors from anywhere in argv -- Hermes's + ``_apply_profile_override`` removes them before argparse, so the profile + flag (and a profile literally named ``gateway``) can legally appear on + either side of the ``gateway`` subcommand. """ if not command: return False - normalized = command.replace("\\", "/").lower() + + try: + raw_tokens = shlex.split(command, posix=False) + except ValueError: + raw_tokens = command.split() + # Strip surrounding quotes, normalize slashes + case per token. + tokens = [t.strip("\"'").replace("\\", "/").lower() for t in raw_tokens] + if not tokens: + return False # Gateway-dedicated entrypoints carry no subcommand to inspect. - if re.search(r"(^|[/\s])gateway/run\.py(\s|$)", normalized): - return True - if re.search(r"(^|[/\s])hermes-gateway(?:\.exe)?(\s|$)", normalized): - return True + for token in tokens: + if token == "gateway/run.py" or token.endswith("/gateway/run.py"): + return True + basename = token.rsplit("/", 1)[-1] + if basename in ("hermes-gateway", "hermes-gateway.exe"): + return True + joined = " ".join(tokens) has_gateway_entry = ( - "hermes_cli.main" in normalized - or "hermes_cli/main.py" in normalized - or re.search(r"(^|[/\s])hermes(?:\.exe)?(\s|$)", normalized) is not None + "hermes_cli.main" in joined + or "hermes_cli/main.py" in joined + or any(t.rsplit("/", 1)[-1] in ("hermes", "hermes.exe") for t in tokens) ) if not has_gateway_entry: return False - tokens = [t.strip("\"'").replace("\\", "/").lower() for t in command.split()] - for i, token in enumerate(tokens): + # Drop profile selectors anywhere: --profile X / -p X / --profile=X / -p=X. + # This consumes a profile VALUE of "gateway" too, so the real subcommand + # token is the one we land on below. + filtered: list[str] = [] + skip_next = False + for token in tokens: + if skip_next: + skip_next = False + continue + if token in ("--profile", "-p"): + skip_next = True + continue + if token.startswith("--profile=") or token.startswith("-p="): + continue + filtered.append(token) + + for i, token in enumerate(filtered): if token != "gateway": continue - if i + 1 >= len(tokens): + if i + 1 >= len(filtered): return True # bare `hermes gateway` defaults to `run` - return tokens[i + 1] == "run" + return filtered[i + 1] == "run" return False diff --git a/tests/gateway/test_gateway_command_line_matcher.py b/tests/gateway/test_gateway_command_line_matcher.py index 5b8b16a7d54..bc8113b91a0 100644 --- a/tests/gateway/test_gateway_command_line_matcher.py +++ b/tests/gateway/test_gateway_command_line_matcher.py @@ -24,6 +24,18 @@ ACCEPT = [ "hermes-gateway.exe", "hermes gateway", # bare `hermes gateway` defaults to run "hermes gateway run", + # profile selector AFTER the `gateway` token (argv is profile-position + # agnostic — _apply_profile_override strips --profile/-p anywhere) + "hermes gateway --profile work run", + "python -m hermes_cli.main gateway -p work run", + "hermes gateway --profile=work run", + # a profile literally NAMED "gateway" + "hermes -p gateway gateway run", + "python -m hermes_cli.main --profile gateway gateway run", + # quoted Windows paths with spaces (shlex-aware tokenization) + r'"C:\Program Files\Hermes\hermes-gateway.exe"', + r'"C:\Program Files\Hermes\gateway\run.py" run', + r'"C:\Program Files\Py\pythonw.exe" -m hermes_cli.main gateway run', ] REJECT = [