Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions cron/lifecycle_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<suffix> -- <helper-script>`
# 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)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[^\n]* still prevents this branch from reaching a -l ai.hermes.gateway-… label when launchctl submit is written with the backslash-newline continuation shown in #62891. Please normalize or explicitly handle shell continuations and add that literal reproduction.

# 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
Expand All @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions tests/hermes_cli/test_gateway_restart_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
Loading