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:
ethernet committed 2026-10-28 12:18:46 -04:00
1 parent 20f2654429
commit 493b19d931
4 files changed
+61 -46

No files matched your search

+5 -30
View File
@@ -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
+38
View File
@@ -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
+6 -4
View File
@@ -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