mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
refactor(backend): the token resolver moves to a web_server sibling
web_server.py is a facade and AGENTS.md puts behaviour in siblings (<stem>_<topic>.py). The file-reading resolver is behaviour, so it now lives in web_server_session_token.py as resolve_session_token(); the facade keeps the _SESSION_TOKEN module state and the call. Tests import the sibling; no re-export shim.
This commit is contained in:
1 parent
20f2654429
commit
493b19d931
4 files changed
+61
-46
No files matched your search
@@ -26,6 +26,7 @@ from hermes_cli.install_identity import get_install_id as _shared_get_install_id
|
||||
from hermes_cli.process_identity import is_desktop_owned_backend
|
||||
from hermes_cli.pty_session import run_reaper
|
||||
from hermes_cli import web_server_file_tickets as _file_tickets
|
||||
from hermes_cli.web_server_session_token import resolve_session_token
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
@@ -217,36 +218,10 @@ from hermes_cli.memory_oauth import router as _memory_oauth_router
|
||||
|
||||
app.include_router(_memory_oauth_router)
|
||||
|
||||
# Session token for sensitive endpoints. The desktop shell mints it via
|
||||
# HERMES_DASHBOARD_SESSION_TOKEN; otherwise fresh per server start. It dies with
|
||||
# the process and is injected into the SPA HTML so only the web UI can use it.
|
||||
def _resolve_session_token() -> str:
|
||||
env_token = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN")
|
||||
if not env_token:
|
||||
return secrets.token_urlsafe(32)
|
||||
# A "file:" prefix means the value is a token-file path, not a token:
|
||||
# deployment managers (agenix, sops-nix, systemd credentials) hand a
|
||||
# secret over as a file, and a path -- unlike the token -- can sit in a
|
||||
# systemd Environment= or a launchd plist without putting anything
|
||||
# readable into a store path. The Nix modules set this form from
|
||||
# backend.sessionTokenFile.
|
||||
if env_token.startswith("file:"):
|
||||
token_file = env_token[len("file:") :]
|
||||
try:
|
||||
token = Path(token_file).read_text(encoding="utf-8-sig").strip()
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise SystemExit(
|
||||
f"HERMES_DASHBOARD_SESSION_TOKEN names an unreadable token file ({token_file}): {exc}"
|
||||
) from exc
|
||||
if not token:
|
||||
raise SystemExit(
|
||||
f"HERMES_DASHBOARD_SESSION_TOKEN names an empty token file ({token_file})"
|
||||
)
|
||||
return token
|
||||
return env_token
|
||||
|
||||
|
||||
_SESSION_TOKEN = _resolve_session_token()
|
||||
# Session token for sensitive endpoints. Resolution lives in the
|
||||
# web_server_session_token sibling; it dies with the process and is injected
|
||||
# into the SPA HTML so only the web UI can use it.
|
||||
_SESSION_TOKEN = resolve_session_token()
|
||||
_SESSION_HEADER_NAME = "X-Hermes-Session-Token"
|
||||
_SSH_OWNER_NONCE: Optional[str] = None
|
||||
_SSH_RUNTIME_PURELIB: Optional[tuple[str, int, int]] = None
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""The dashboard session token: where its value comes from.
|
||||
|
||||
The desktop shell mints the token via HERMES_DASHBOARD_SESSION_TOKEN; a
|
||||
"file:<path>" value instead names a file to read it from (the Nix modules
|
||||
render backend.sessionTokenFile this way, so a path -- not a secret -- sits
|
||||
in the service environment). With no variable, a fresh token is minted per
|
||||
server start. It dies with the process and is injected into the SPA HTML so
|
||||
only the web UI can use it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resolve_session_token() -> str:
|
||||
env_token = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN")
|
||||
if not env_token:
|
||||
return secrets.token_urlsafe(32)
|
||||
# A "file:" prefix means the value is a token-file path, not a token:
|
||||
# deployment managers (agenix, sops-nix, systemd credentials) hand a
|
||||
# secret over as a file, and a path -- unlike the token -- can sit in a
|
||||
# systemd Environment= or a launchd plist without putting anything
|
||||
# readable into a store path.
|
||||
if env_token.startswith("file:"):
|
||||
token_file = env_token[len("file:") :]
|
||||
try:
|
||||
token = Path(token_file).read_text(encoding="utf-8-sig").strip()
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise SystemExit(
|
||||
f"HERMES_DASHBOARD_SESSION_TOKEN names an unreadable token file ({token_file}): {exc}"
|
||||
) from exc
|
||||
if not token:
|
||||
raise SystemExit(
|
||||
f"HERMES_DASHBOARD_SESSION_TOKEN names an empty token file ({token_file})"
|
||||
)
|
||||
return token
|
||||
return env_token
|
||||
@@ -4,59 +4,59 @@ import pytest
|
||||
|
||||
|
||||
def test_plain_env_token_is_used_verbatim(monkeypatch):
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli import web_server_session_token as st
|
||||
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "raw-token")
|
||||
|
||||
assert web_server._resolve_session_token() == "raw-token"
|
||||
assert st.resolve_session_token() == "raw-token"
|
||||
|
||||
|
||||
def test_file_prefix_reads_and_strips(monkeypatch, tmp_path):
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli import web_server_session_token as st
|
||||
|
||||
token_file = tmp_path / "token"
|
||||
token_file.write_text(" file-token " + chr(13) + chr(10), encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", f"file:{token_file}")
|
||||
|
||||
assert web_server._resolve_session_token() == "file-token"
|
||||
assert st.resolve_session_token() == "file-token"
|
||||
|
||||
|
||||
def test_unreadable_token_file_fails_loudly(monkeypatch, tmp_path):
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli import web_server_session_token as st
|
||||
|
||||
monkeypatch.setenv(
|
||||
"HERMES_DASHBOARD_SESSION_TOKEN", f"file:{tmp_path / 'absent'}"
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit, match="unreadable"):
|
||||
web_server._resolve_session_token()
|
||||
st.resolve_session_token()
|
||||
|
||||
|
||||
def test_empty_token_file_fails_loudly(monkeypatch, tmp_path):
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli import web_server_session_token as st
|
||||
|
||||
token_file = tmp_path / "token"
|
||||
token_file.write_text(chr(10), encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", f"file:{token_file}")
|
||||
|
||||
with pytest.raises(SystemExit, match="empty"):
|
||||
web_server._resolve_session_token()
|
||||
st.resolve_session_token()
|
||||
|
||||
|
||||
def test_malformed_token_file_fails_loudly(monkeypatch, tmp_path):
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli import web_server_session_token as st
|
||||
|
||||
token_file = tmp_path / "token"
|
||||
token_file.write_bytes(b"\xff\xfe\x00not-utf8")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", f"file:{token_file}")
|
||||
|
||||
with pytest.raises(SystemExit, match="unreadable"):
|
||||
web_server._resolve_session_token()
|
||||
st.resolve_session_token()
|
||||
|
||||
|
||||
def test_no_source_mints_a_token(monkeypatch):
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli import web_server_session_token as st
|
||||
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)
|
||||
|
||||
assert len(web_server._resolve_session_token()) > 0
|
||||
assert len(st.resolve_session_token()) > 0
|
||||
@@ -202,11 +202,12 @@ class TestSessionTokenInjection:
|
||||
|
||||
def test_honors_injected_token(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
import hermes_cli.web_server_session_token as st
|
||||
|
||||
original_app = ws.app
|
||||
original_token = ws._SESSION_TOKEN
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-seeded-token")
|
||||
assert ws._resolve_session_token() == "desktop-seeded-token"
|
||||
assert st.resolve_session_token() == "desktop-seeded-token"
|
||||
# No module reload: the loaded app and its adopted token are untouched.
|
||||
assert ws.app is original_app
|
||||
assert ws._SESSION_TOKEN == original_token
|
||||
@@ -214,16 +215,17 @@ class TestSessionTokenInjection:
|
||||
|
||||
def test_session_token_resolution_preserves_loaded_app_auth(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
import hermes_cli.web_server_session_token as st
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
original_app = ws.app
|
||||
original_header_name = ws._SESSION_HEADER_NAME
|
||||
original_token = ws._SESSION_TOKEN
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-seeded-token")
|
||||
assert ws._resolve_session_token() == "desktop-seeded-token"
|
||||
assert st.resolve_session_token() == "desktop-seeded-token"
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)
|
||||
with patch.object(ws.secrets, "token_urlsafe", return_value="generated-token"):
|
||||
assert ws._resolve_session_token() == "generated-token"
|
||||
with patch.object(st.secrets, "token_urlsafe", return_value="generated-token"):
|
||||
assert st.resolve_session_token() == "generated-token"
|
||||
|
||||
client = TestClient(original_app)
|
||||
client.headers[original_header_name] = original_token
|
||||
|
||||
Reference in new issue
Block a user