fix(gateway): resolve uninstall lifecycle guard conflict
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user