diff --git a/cron/lifecycle_guard.py b/cron/lifecycle_guard.py index 6c70c1af8ae8..27e418d18c3c 100644 --- a/cron/lifecycle_guard.py +++ b/cron/lifecycle_guard.py @@ -56,7 +56,12 @@ class GatewayLifecycleBlocked(ValueError): # labels look like `ai.hermes.gateway` / `hermes-gateway`. Requiring the # gateway identifier prevents blocking unrelated hermes services (e.g. # `launchctl unload ai.hermes.update-checker.plist`). - r"|(?:launchctl\s+(?:kickstart|unload|load|stop|restart)\b[^\n]*\bhermes[.\-]?gateway)" + # `submit` is included alongside the direct verbs (kickstart/etc.): + # `launchctl submit -l ai.hermes.gateway- -- ` + # creates a NEW keepalive job wrapping an arbitrary helper, which is how + # a blocked direct restart/kill gets laundered into a persistent restart + # loop instead (#62891) — same foot-gun, indirect shape. + r"|(?:launchctl\s+(?:kickstart|unload|load|stop|restart|submit)\b[^\n]*\bhermes[.\-]?gateway)" # Branch C: systemctl ops on a hermes-gateway unit. r"|(?:systemctl\s+(?:-\S+\s+)*(?:restart|stop|start)\b[^\n]*\bhermes[.\-]?gateway)" # Branch D: pkill / kill targeting the hermes gateway process. Both @@ -66,11 +71,25 @@ class GatewayLifecycleBlocked(ValueError): ) +# A backslash immediately followed by a newline is a POSIX shell line +# continuation — the shell joins the two lines before parsing. Every branch +# above uses `[^\n]*` between its verb and the gateway identifier so the +# match can't span unrelated lines of a longer cron prompt/script, but that +# also means a real multi-line shell invocation split across continuation +# lines (e.g. `launchctl submit \` / ` -l ai.hermes.gateway-... \` / ` -- ...`, +# the exact reported shape in #62891) would otherwise slip past. Collapse +# continuations to a single space before matching, mirroring what the shell +# itself does, rather than loosening `[^\n]*` and risking false positives +# across genuinely separate lines. +_SHELL_LINE_CONTINUATION = re.compile(r"\\\r?\n[ \t]*") + + def contains_gateway_lifecycle_command(text: str) -> bool: """Return True if *text* contains a gateway lifecycle command pattern.""" if not text: return False - return bool(_GATEWAY_LIFECYCLE_PATTERN.search(text)) + normalized = _SHELL_LINE_CONTINUATION.sub(" ", text) + return bool(_GATEWAY_LIFECYCLE_PATTERN.search(normalized)) def _resolve_script_path(script_path: str) -> Path: diff --git a/tests/hermes_cli/test_gateway_restart_loop.py b/tests/hermes_cli/test_gateway_restart_loop.py index 59c61da114fe..9b09c3a4dd88 100644 --- a/tests/hermes_cli/test_gateway_restart_loop.py +++ b/tests/hermes_cli/test_gateway_restart_loop.py @@ -35,6 +35,34 @@ class TestGatewayLifecyclePattern: def test_hermes_gateway_commands(self, text): assert _contains_gateway_lifecycle_command(text), f"Should match: {text!r}" + @pytest.mark.parametrize("text", [ + # #62891: a blocked direct restart/kill laundered through a NEW + # launchd keepalive job wrapping a helper script, instead of a + # direct kickstart/unload/stop/restart on the existing service. + "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 hermes-gateway-restart-helper -- /bin/sh helper.sh", + # 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 + # physical lines unless continuations are normalized first. + ( + "launchctl submit \\\n" + " -l ai.hermes.gateway-hard-restart-no-photon-notice \\\n" + " -- /bin/sh ~/.hermes/scripts/hard_restart_gateway_no_photon_notice.sh" + ), + ]) + def test_launchctl_submit_commands(self, text): + assert _contains_gateway_lifecycle_command(text), f"Should match: {text!r}" + + def test_line_continuation_does_not_bridge_unrelated_lines(self): + # A backslash-newline is only normalized when it's a real shell + # continuation. Two genuinely separate lines of a longer prompt + # (no trailing backslash) must not be bridged into a false match. + text = ( + "this restarts the payment gateway\n" + "unrelated hermes note on the next line" + ) + assert not _contains_gateway_lifecycle_command(text), f"Should NOT match: {text!r}" @pytest.mark.parametrize("text", [ "restart the server application", @@ -55,6 +83,8 @@ def test_hermes_gateway_commands(self, text): # hermes token). "launchctl unload ai.hermes.update-checker.plist", "launchctl restart ai.hermes.daemon", + # `submit` on an unrelated launchd label must not be falsely blocked. + "launchctl submit -l com.example.backup -- /bin/sh backup.sh", "systemctl restart hermes-meta.service", "systemctl restart hermes-cron-helper", # Regression (#30728 follow-up): legit prompts that merely mention an