Skip to content
Open
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
74 changes: 63 additions & 11 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@
r"|auto-lowered\s+compression\s+threshold"
r"|compacting\s+context\s+[—-]\s+summarizing\s+earlier\s+conversation"
r"|preflight\s+compression"
r"|pre-api\s+compression"
r"|compression\s+aborted"
r"|run\s+/compress\s+to\s+retry"
r"|session\s+compressed\s+\d+\s+times"
r"|rate\s+limited\.\s+waiting\s+\d"
r"|retrying\s+in\s+\d"
Expand Down Expand Up @@ -8986,6 +8989,43 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
# Otherwise control/session commands like /new or /help get silently
# consumed as update answers instead of being dispatched normally.
_quick_key = self._session_key_for_source(source)

# Profile-local context-hygiene watchdogs may request a silent session

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This introduces a new pending_self_reset.json state-file protocol outside the PR summary. Current main has no producer for this file, and the changed tests exercise only restart-resume prompt wording. Please split this into a focused gateway change with an end-to-end producer/consumer test.

# boundary by writing state/pending_self_reset.json. Older builds only
# wrote the file, so the user still had to send /new or a wake word.
# Consume it here on the next real inbound message, rotate the cached
# agent/session via the normal /new machinery, suppress the reset banner,
# and then let THIS user message continue into the fresh session.
if not is_internal:
_pending_self_reset_path = _hermes_home / "state" / "pending_self_reset.json"
try:
_pending_map = json.loads(_pending_self_reset_path.read_text()) if _pending_self_reset_path.exists() else {}
except Exception:
_pending_map = {}
if isinstance(_pending_map, dict) and _quick_key in _pending_map:
_pending_entry = _pending_map.get(_quick_key) or {}
_pending_sid = str(_pending_entry.get("session_id") or "") if isinstance(_pending_entry, dict) else ""
_current_entry = getattr(self.session_store, "_entries", {}).get(_quick_key)
_current_sid = str(getattr(_current_entry, "session_id", "") or "")
_should_reset = not _pending_sid or not _current_sid or _pending_sid == _current_sid
_pending_map.pop(_quick_key, None)
try:
_tmp = _pending_self_reset_path.with_suffix(".tmp")
_tmp.write_text(json.dumps(_pending_map, ensure_ascii=False, indent=2))
_tmp.replace(_pending_self_reset_path)
except Exception:
logger.debug("Failed to update pending_self_reset map", exc_info=True)
if _should_reset and not (event.text or "").strip().startswith("/"):
logger.info(
"Consuming pending self-reset for %s (reason=%s)",
_quick_key,
(_pending_entry or {}).get("reason") if isinstance(_pending_entry, dict) else "",
)
try:
await self._handle_reset_command(dataclasses.replace(event, text="/new"))
except Exception:
logger.warning("Pending self-reset failed for %s", _quick_key, exc_info=True)

_update_prompts = getattr(self, "_update_prompt_pending", {})
if _update_prompts.get(_quick_key):
raw = (event.text or "").strip()
Expand Down Expand Up @@ -18678,25 +18718,32 @@ def _approval_notify_sync(approval_data: dict) -> None:
_persist_user_message_override = message
# The empty-message case is the auto-resume startup turn
# synthesized by _schedule_resume_pending_sessions — there is
# no NEW user message to address, so tell the model to report
# recovery instead of the (nonexistent) "new message".
# no NEW user message to address. This must still continue the
# interrupted objective autonomously; asking "what next?" turns
# a gateway restart into a user-operated recovery procedure.
if message:
_resume_guidance = (
"Address the user's NEW message below FIRST and focus "
"on what the user is asking now."
)
else:
_resume_guidance = (
"Report to the user that the session was restored "
"successfully and ask what they would like to do next."
"Report briefly that the session was restored, then "
"continue autonomously with the safest recoverable next "
"step from the transcript, todos, handoff/state files, "
"or other available context. Do NOT ask what to do next "
"unless an explicit owner/approval gate or missing "
"required context blocks every safe action."
)
message = (
f"[System note: The previous turn was interrupted by "
f"{_reason_phrase}; the gateway is now back online. "
f"Any restart/shutdown command in the history has already "
f"run — do NOT re-execute or verify it. {_resume_guidance} "
f"Do NOT re-execute old tool calls — skip any unfinished "
f"work from the conversation history.]"
f"Do NOT re-execute old tool calls blindly; reconstruct current "
f"state first, then resume unfinished safe work from the "
f"conversation history without repeating dangerous or "
f"externally mutating actions.]"
+ (f"\n\n{message}" if message else "")
)
elif _has_fresh_tool_tail:
Expand Down Expand Up @@ -18749,11 +18796,16 @@ def _approval_notify_sync(approval_data: dict) -> None:
f"[System note: The previous turn was interrupted by "
f"{_sn_reason_phrase}; the gateway is now back online. "
f"Any restart/shutdown command in the history has already "
f"run — do NOT re-execute or verify it. Report to the user "
f"that the session was restored successfully and ask what "
f"they would like to do next. Do NOT re-execute old tool "
f"calls — skip any unfinished work from the conversation "
f"history.]"
f"run — do NOT re-execute or verify it. Report briefly that "
f"the session was restored, then continue autonomously with "
f"the safest recoverable next step from the transcript, "
f"todos, handoff/state files, or other available context. "
f"Do NOT ask what to do next unless an explicit owner/approval "
f"gate or missing required context blocks every safe action. "
f"Do NOT re-execute old tool calls blindly; reconstruct current "
f"state first, then resume unfinished safe work from the "
f"conversation history without repeating dangerous or "
f"externally mutating actions.]"
)

_approval_session_key = session_key or ""
Expand Down
75 changes: 75 additions & 0 deletions hermes_cli/dashboard_auth/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import logging
from typing import Awaitable, Callable
from urllib.parse import urlsplit

from fastapi import Request
from fastapi.responses import JSONResponse, RedirectResponse, Response
Expand Down Expand Up @@ -252,6 +253,76 @@ def _safe_next_target(request: Request) -> str:
return quote(target, safe="")


def _normalise_origin(scheme: str, host: str) -> str:
"""Return a canonical ``scheme://host[:port]`` origin string."""
scheme = (scheme or "").lower()
host = (host or "").strip().lower()
if not scheme or not host:
return ""
# Host may already include a port (or IPv6 bracket notation). Keep explicit
# non-default ports but strip default :80/:443 so equivalent origins match.
default = ":443" if scheme == "https" else ":80" if scheme == "http" else ""
if default and host.endswith(default):
host = host[: -len(default)]
return f"{scheme}://{host}"


def _request_origin(request: Request) -> str:
"""Best-effort external origin for the dashboard request.

Honour common reverse-proxy headers because the auth gate is often mounted
behind TLS termination; fall back to the ASGI URL/Host shape used by tests
and direct Uvicorn binds.
"""
proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip()
scheme = proto or request.url.scheme
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
return _normalise_origin(scheme, host)


def _header_origin(value: str) -> str:
if not value or value == "null":
return ""
try:
parsed = urlsplit(value)
except ValueError:
return ""
if not parsed.scheme or not parsed.netloc:
return ""
return _normalise_origin(parsed.scheme, parsed.netloc)


def _csrf_origin_ok(request: Request) -> bool:
"""Validate Fetch Metadata plus Origin/Referer for cookie-auth unsafe APIs.

Per OWASP CSRF guidance, privileged cookie-authenticated state changes must
not rely on SameSite alone. Browser unsafe requests should provide either a
same-origin ``Origin`` or, as fallback, a same-origin ``Referer``; Fetch
Metadata gives an additional early reject for explicit cross-site attempts.
Requests missing both headers are rejected fail-closed for cookie sessions.
Token-auth service routes bypass the cookie gate before this check.
"""
sec_fetch_site = request.headers.get("sec-fetch-site", "").strip().lower()
if sec_fetch_site == "cross-site":
return False

expected = _request_origin(request)
origin = request.headers.get("origin", "")
if origin:
return bool(expected) and _header_origin(origin) == expected
referer = request.headers.get("referer", "")
if referer:
return bool(expected) and _header_origin(referer) == expected
return False


def _csrf_forbidden() -> JSONResponse:
return JSONResponse(
{"detail": "Cross-site request blocked: invalid Origin or Referer"},
status_code=403,
)


async def gated_auth_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
Expand Down Expand Up @@ -359,6 +430,8 @@ async def gated_auth_middleware(
if refreshed is not None:
new_session, refreshing_provider = refreshed
request.state.session = new_session
if request.method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and not _csrf_origin_ok(request):
return _csrf_forbidden()
response = await call_next(request)
# Persist the ROTATED tokens. Portal rotates the refresh token on
# every refresh and runs reuse-detection, so writing the new RT
Expand Down Expand Up @@ -405,6 +478,8 @@ async def gated_auth_middleware(
return response

request.state.session = session
if request.method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and not _csrf_origin_ok(request):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This check does not cover POST /auth/logout: _path_is_public() returns earlier for that route, while its handler revokes the refresh token and clears session cookies. Please apply the same origin validation to cookie-bearing logout and add same-origin/cross-origin coverage.

return _csrf_forbidden()
return await call_next(request)


Expand Down
109 changes: 89 additions & 20 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5824,6 +5824,88 @@ def cmd_gui(args: argparse.Namespace):
sys.exit(launch_result.returncode)


def _cmdline_runs_dashboard_server(command: str) -> bool:
"""Return True for real Hermes dashboard/serve process command lines.

Global CLI flags can legally appear before the subcommand, e.g.
``python -m hermes_cli.main -p default dashboard``. A plain substring
search for ``"hermes_cli.main dashboard"`` misses that shape, while a
greedy ``hermes.*dashboard`` regex catches unrelated chat prompts and shell
wrappers. Parse only the *executed program* shape we understand, then identify
the first non-option Hermes subcommand after the launcher.
"""
try:
tokens = shlex.split(command, posix=(sys.platform != "win32"))
except ValueError:
tokens = command.split()
if not tokens:
return False

def _base(token: str) -> str:
cleaned = token.strip("\"'").replace("\\", "/")
return cleaned.rsplit("/", 1)[-1].lower()

def _norm(token: str) -> str:
return token.strip("\"'").replace("\\", "/").lower()

i = 0
# Support simple env-prefix launchers such as
# ``HERMES_HOME=/x hermes dashboard`` or ``env HERMES_HOME=/x hermes ...``.
if _base(tokens[i]) == "env":
i += 1
while i < len(tokens) and "=" in tokens[i] and not tokens[i].startswith("-"):
i += 1
if i >= len(tokens):
return False

first_base = _base(tokens[i])
first_norm = _norm(tokens[i])
if first_base in {"hermes", "hermes.exe"}:
arg_start = i + 1
elif first_base.startswith("python"):
if i + 2 < len(tokens) and tokens[i + 1] == "-m" and tokens[i + 2] == "hermes_cli.main":
arg_start = i + 3
elif i + 1 < len(tokens) and _norm(tokens[i + 1]).endswith("hermes_cli/main.py"):
arg_start = i + 2
elif i + 1 < len(tokens) and _base(tokens[i + 1]) in {"hermes", "hermes.exe"}:
# Console-script wrapper as seen in some venv process tables:
# ``python /venv/bin/hermes -p default dashboard``.
arg_start = i + 2
else:
return False
elif first_norm == "hermes_cli.main" or first_norm.endswith("hermes_cli/main.py"):
arg_start = i + 1
else:
# Do not scan arbitrary shell wrappers such as
# ``bash -lc '... hermes dashboard ...'``; matching those would make
# status/stop double-count the supervising shell and the real listener.
return False

options_with_values = {
"-p", "--profile", "-m", "--model", "--provider", "-t", "--toolsets",
"-s", "--skills", "--resume", "-r", "--continue", "-c", "--source",
"--personality", "--workdir", "--config", "--env",
}
j = arg_start
while j < len(tokens):
token = tokens[j]
if token in {"dashboard", "serve"}:
return True
if token.startswith("-"):
if token in options_with_values and j + 1 < len(tokens):
j += 2
continue
# --flag=value consumes its value in the same token; boolean flags
# simply fall through to the next token.
j += 1
continue
# First non-option token is another Hermes subcommand (e.g. chat) or a
# positional for a wrapper we do not understand; avoid matching later
# prompt text that merely contains "dashboard".
return False
return False


def _find_stale_dashboard_pids(
*,
exclude_pids: set[int] | None = None,
Expand Down Expand Up @@ -5853,17 +5935,6 @@ def _find_stale_dashboard_pids(

Returns an empty list on any scan error (missing ps/wmic, timeout, etc.).
"""
patterns = [
"hermes dashboard",
"hermes_cli.main dashboard",
"hermes_cli/main.py dashboard",
# The headless backend (`hermes serve`) is the same long-lived server
# under a different command name — the desktop app spawns it. Reap it
# on update for the same frontend/backend-mismatch reason.
"hermes serve",
"hermes_cli.main serve",
"hermes_cli/main.py serve",
]
self_pid = os.getpid()
dashboard_pids: list[int] = []

Expand Down Expand Up @@ -5899,14 +5970,12 @@ def _find_stale_dashboard_pids(
current_cmd = line[len("CommandLine=") :]
elif line.startswith("ProcessId="):
pid_str = line[len("ProcessId=") :]
if (
any(p in current_cmd for p in patterns)
and int(pid_str) != self_pid
):
try:
dashboard_pids.append(int(pid_str))
except ValueError:
pass
try:
pid = int(pid_str)
except ValueError:
continue
if _cmdline_runs_dashboard_server(current_cmd) and pid != self_pid:
dashboard_pids.append(pid)
else:
# Linux / macOS: scan the process table via ps and match against
# the same explicit patterns list used on Windows. Using ps
Expand All @@ -5933,7 +6002,7 @@ def _find_stale_dashboard_pids(
except ValueError:
continue
command = parts[1]
if any(p in command for p in patterns) and pid != self_pid:
if _cmdline_runs_dashboard_server(command) and pid != self_pid:
dashboard_pids.append(pid)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return []
Expand Down
Loading