fix(gateway): resolve uninstall lifecycle guard conflict

This commit is contained in:
KeaneYan
2026-08-06 11:52:55 +08:00
committed by Teknium
parent ca8a598787
commit 1c791cbfe6
4 changed files with 35 additions and 7 deletions
+4 -3
View File
@@ -27,7 +27,7 @@ This is a defence-in-depth layer. ``tools/terminal_tool.py`` blocks direct
commands and shell scripts they reference when ``_HERMES_GATEWAY=1``. It also
rejects ``launchctl submit`` in gateway sessions because launchd treats that
primitive as a persistent KeepAlive job, not a one-shot task. ``hermes gateway
stop|restart`` separately refuse to self-target from inside the gateway.
stop|restart|uninstall`` separately refuse to self-target from inside the gateway.
Blocking cron specs at creation time as well means the agent gets an immediate,
informative rejection instead of scheduling a job that will only fail
(silently) when it fires.
@@ -60,7 +60,8 @@ class GatewayLifecycleBlocked(ValueError):
# actual shell-command-shaped strings, not on prose.
_GATEWAY_LIFECYCLE_PATTERN = re.compile(
r"(?i)"
# Branch A: `hermes gateway restart|stop` — the canonical foot-gun.
# Branch A: destructive `hermes gateway` operations.
# The destructive operations are restart, stop, and uninstall.
# `start` is intentionally excluded: starting a gateway from inside a
# gateway is benign (a no-op or "already running" error), and a
# legitimate cron job might start a sibling profile's gateway.
@@ -70,7 +71,7 @@ _GATEWAY_LIFECYCLE_PATTERN = re.compile(
# matching via the `/hermes` tail, while every real command position
# (start of text, whitespace, `;`/`&`/`|`, `$(`, backtick, even a
# U+FFFD from binary-content decoding) still matches.
r"(?:(?<![/\w.\-])hermes\s+gateway\s+(?:restart|stop)\b)"
r"(?:(?<![/\w.\-])hermes\s+gateway\s+(?:restart|stop|uninstall)\b)"
# Branch B: launchctl ops on a hermes-gateway label. macOS launchd
# labels look like `ai.hermes.gateway` / `hermes-gateway`. Requiring the
# gateway identifier prevents blocking unrelated hermes services (e.g.
+14
View File
@@ -7935,6 +7935,20 @@ def _gateway_command_inner(args):
sys.exit(1)
elif subcmd == "uninstall":
# Uninstall stops the managed service before removing it. Gate on
# PID-file ownership like stop/restart (#92560): the env marker is
# inherited by every descendant, and CLI sessions spawned under the
# gateway tree must stay able to manage it.
from tools.process_registry import _is_supervised_gateway_process
if _is_supervised_gateway_process():
print_error(
"Refusing to uninstall the gateway from inside the gateway process.\n"
"This command was blocked to prevent the gateway from terminating itself.\n"
"Use `hermes gateway uninstall` from a shell outside the running gateway."
)
sys.exit(1)
if is_managed():
managed_error("uninstall gateway service")
return
+14 -1
View File
@@ -28,6 +28,7 @@ class TestGatewayLifecyclePattern:
@pytest.mark.parametrize("text", [
"hermes gateway restart",
"hermes gateway stop",
"hermes gateway uninstall",
"hermes gateway restart", # double spaces
"Hermez Gateway Restart".lower().replace("z", "s"), # case handled
"HERMES GATEWAY RESTART", # uppercase
@@ -43,6 +44,7 @@ class TestGatewayLifecyclePattern:
"launchctl submit -l hermes-gateway-restart-helper -- /bin/sh helper.sh",
# bootstrap loads an arbitrary plist — same laundering shape.
"launchctl bootstrap gui/501 ~/Library/LaunchAgents/ai.hermes.gateway.restart-once.plist",
"launchctl bootout gui/501/ai.hermes.gateway",
# The exact reported shape: split across shell line-continuations
# (`\` immediately followed by a newline). `[^\n]*` alone can't span
# that, so the verb and the gateway-label token land on different
@@ -400,7 +402,7 @@ class TestCronCreateLifecycleBlock:
# ---------------------------------------------------------------------------
class TestGatewaySelfTargetingGuard:
"""Verify hermes gateway stop/restart refuse when _HERMES_GATEWAY=1."""
"""Verify destructive gateway commands refuse inside the gateway."""
def test_stop_refuses_inside_gateway(self, monkeypatch):
from tools import process_registry
@@ -413,6 +415,15 @@ class TestGatewaySelfTargetingGuard:
gateway_command(args)
assert exc_info.value.code == 1
def test_uninstall_refuses_inside_gateway(self, monkeypatch):
monkeypatch.setenv("_HERMES_GATEWAY", "1")
from hermes_cli.gateway import gateway_command
args = Namespace(gateway_command="uninstall", system=False)
with pytest.raises(SystemExit) as exc_info:
gateway_command(args)
assert exc_info.value.code == 1
def test_stop_allows_outside_gateway(self, monkeypatch):
# With the gateway marker unset, the self-targeting guard must NOT
@@ -476,7 +487,9 @@ class TestTerminalToolGatewayLifecycleGuard:
"systemctl --user restart hermes-gateway",
"systemctl stop hermes-gateway.service",
"hermes gateway restart",
"hermes gateway uninstall",
"launchctl kickstart gui/501/ai.hermes.gateway",
"launchctl bootout gui/501/ai.hermes.gateway",
# #62891 exact reported shape and its bootstrap sibling.
"launchctl submit -l ai.hermes.gateway-hard-restart-no-photon-notice -- /bin/sh ~/.hermes/scripts/hard_restart_gateway_no_photon_notice.sh",
"launchctl submit -l com.foo -- /path/gateway",
+3 -3
View File
@@ -2870,7 +2870,7 @@ def terminal_tool(
session_key = get_current_session_key(default="") or (task_id or "")
# Hard-block: gateway lifecycle commands (systemctl/launchctl/hermes
# restart|stop targeting hermes-gateway) must never run inside the
# restart|stop|uninstall targeting hermes-gateway) must never run inside the
# gateway process itself. The restart would SIGTERM the gateway, which
# kills this very subprocess before it can complete — the service may
# never restart. This mirrors the `hermes gateway restart` guard in
@@ -2978,8 +2978,8 @@ def terminal_tool(
"output": "",
"exit_code": 1,
"error": (
"Blocked: command or referenced script cannot restart or stop "
"the gateway from inside the gateway process. The gateway would "
"Blocked: command or referenced script cannot restart, stop, or "
"uninstall the gateway from inside the gateway process. The gateway would "
"kill this command before it could complete (SIGTERM propagates "
"to child processes). Run `hermes gateway restart` from a "
"separate shell outside the running gateway."