Round 2 of the REST profile-scope pass. A call-graph audit (AST over every router plus their imported helpers, following functools.partial bindings, closures and callbacks handed to to_thread/_spawn_job) found the writes a signature grep cannot see, and the SPA/Desktop callers that never named a profile at all. Handlers that reached a config write through indirection: * PUT /api/dashboard/plugin-providers wrote memory.provider + context.engine through functools.partial(_write_config_value, ...) — the SAME key PUT /api/memory/provider scopes — into the launch profile's config.yaml. * POST /api/local-models/quickstart is `activate` plus a download; only `activate` had been scoped, so _set_runtime_enabled/_assign_default landed in the launch profile. * POST /api/local-models/runtime/install regenerates launch presets under get_hermes_home(). * GET /api/model/recommended-default lazily PERSISTS discovered custom-provider models via build_models_payload -> _save_discovered_models_to_config. Policy gaps: * POST /api/ops/hooks is now gated like DELETE: writing an arbitrary command into `hooks:` and, with approve, into the consent allowlist is strictly more privileged than removing one. * POST /api/sessions/prune (non-dry-run), DELETE /api/sessions/empty and POST /api/sessions/bulk-delete join the same destructive class. * PUT /api/memory/provider ran its readiness check OUTSIDE the scope, so it judged the launch profile and could write a broken setting into another. Callers: * /api/credentials was missing from the SPA's PROFILE_SCOPED_PREFIXES, so the credential-pool delete button 400'd unconditionally; so were /api/dashboard/plugin-providers, /api/local-models and the model route above. * The management scope was empty until the switcher resolved, so every destructive route 400'd in working UI on any host with a second profile directory. The backend now injects the profile it itself serves (__HERMES_DASHBOARD_PROFILE__) — and only when that name provably resolves back to its own home, so the fallback can never retarget another profile. * authedFetch went around the scope entirely; the Desktop /api/ops callers (doctor, security-audit, backup, debug-share) sent no profile while Electron pins that family to the shared primary backend.
155 lines
6.4 KiB
Python
155 lines
6.4 KiB
Python
"""Shared plumbing for the extracted dashboard routers — thin wrappers over the
|
|
late-binding seam in :mod:`hermes_cli.web_deps` (web_server owns helpers/state;
|
|
every access resolves at call time so ``monkeypatch.setattr(<owning module>, ...)`` wins)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
import sqlite3
|
|
import time
|
|
from typing import Any, Callable, Dict, Optional
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from hermes_cli.web_deps import LateState, late
|
|
from hermes_cli.web_server_profiles import _profile_cli_args
|
|
|
|
# Same logger the handlers used before extraction (identical logger object).
|
|
log = logging.getLogger("hermes_cli.web_server")
|
|
|
|
_profile_scope = late("_profile_scope", "hermes_cli.web_server_profiles")
|
|
_config_profile_scope = late("_config_profile_scope", "hermes_cli.web_server_profiles")
|
|
_spawn_hermes_action = late("_spawn_hermes_action", "hermes_cli.web_server_gateway")
|
|
# Config read-modify-write serialization for off-loop handlers (live lock —
|
|
# LateState supports ``with``-blocks).
|
|
_CONFIG_MUTATION_LOCK = LateState("_CONFIG_MUTATION_LOCK")
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def config_write_scope(profile: Optional[str]):
|
|
"""Profile scope, then the config mutation lock — the write-path nesting
|
|
every config-mutating handler uses."""
|
|
with _profile_scope(profile):
|
|
with _CONFIG_MUTATION_LOCK:
|
|
yield
|
|
|
|
|
|
async def scoped_to_thread(profile: Optional[str], fn: Callable[[], Any]) -> Any:
|
|
"""Run ``fn()`` inside ``_profile_scope(profile)`` on a worker thread."""
|
|
|
|
def _run():
|
|
with _profile_scope(profile):
|
|
return fn()
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
async def config_scoped_to_thread(profile: Optional[str], fn: Callable[[], Any]) -> Any:
|
|
"""Run ``fn()`` inside ``_config_profile_scope(profile)`` on a worker thread —
|
|
home + secret scope without the process-global skills-module swap."""
|
|
|
|
def _run():
|
|
with _config_profile_scope(profile):
|
|
return fn()
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
def destructive_profile(profile: Optional[str], route: str) -> Optional[str]:
|
|
"""The profile a DESTRUCTIVE or PRIVILEGED route acts on, or 400 when it is ambiguous.
|
|
|
|
One backend serves every profile, so an omitted ``profile`` on a route that deletes,
|
|
overwrites or privileges profile-owned data is not a default — it silently meant
|
|
"whichever home this process launched with". Named profile: honoured. Omitted:
|
|
rejected as soon as the process hosts more than one profile
|
|
(``is_multiplex_active()``, decided once at boot by
|
|
``activate_multi_profile_hosting_eagerly``). A genuinely single-profile host has
|
|
nothing to confuse, so there an omitted profile keeps meaning the launch profile
|
|
and `curl` against a plain ``hermes serve`` is unchanged.
|
|
|
|
"Privileged" is the same class as "destructive": arming an auto-approved shell hook
|
|
in the wrong profile is at least as bad as removing one from it.
|
|
"""
|
|
if (profile or "").strip():
|
|
return profile
|
|
from agent.secret_scope import is_multiplex_active
|
|
if is_multiplex_active():
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"{route} requires an explicit profile: this backend serves several profiles, "
|
|
"so an unnamed target would act on the wrong profile's data.")
|
|
return profile
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def http_failure(log_msg: str, status: int, prefix: Optional[str] = None, *, detail: Optional[str] = None):
|
|
"""Map unexpected exceptions to an ``HTTPException``.
|
|
|
|
``HTTPException`` passes through; anything else is logged with ``log_msg`` (traceback),
|
|
then re-raised as ``HTTPException(status, f"{prefix}: {exc}")`` — or ``detail`` when given
|
|
(fixed message, exception text only in the log).
|
|
"""
|
|
try:
|
|
yield
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
log.exception(log_msg)
|
|
raise HTTPException(status_code=status, detail=detail if detail is not None else f"{prefix}: {exc}")
|
|
|
|
|
|
def spawn_profile_action(
|
|
profile: Optional[str], argv: list, name: str, *, log_msg: str, prefix: str
|
|
) -> dict:
|
|
"""Spawn a background ``hermes -p <profile> <argv>`` action; a spawn
|
|
failure is logged and becomes ``500 "<prefix>: <exc>"``."""
|
|
with http_failure(log_msg, 500, prefix):
|
|
proc = _spawn_hermes_action(_profile_cli_args(profile) + argv, name)
|
|
return {"ok": True, "pid": proc.pid, "name": name}
|
|
|
|
|
|
def require(value: Optional[str], detail: str) -> str:
|
|
"""Strip ``value``; 400 with ``detail`` when empty."""
|
|
stripped = (value or "").strip()
|
|
if not stripped:
|
|
raise HTTPException(status_code=400, detail=detail)
|
|
return stripped
|
|
|
|
|
|
# Corrupt-store reporting for polled read endpoints. The dashboard polls analytics every few
|
|
# seconds; a persistently malformed state.db once produced ~520K identical tracebacks in 24 h
|
|
# (#96591). One WARNING per store per interval, then debug; the caller gets an explicit status
|
|
# instead of a 500. The file is never quarantined or renamed from here — that is `hermes doctor`'s job.
|
|
_CORRUPT_STORE_WARN_INTERVAL_S = 300.0
|
|
_corrupt_store_warned_at: Dict[str, float] = {} # {db path: monotonic}
|
|
|
|
CORRUPT_STORE_DETAIL = {
|
|
"error": "state_db_corrupt",
|
|
"message": "state.db corrupt — run `hermes doctor` (then `hermes doctor --fix` or `hermes sessions repair`).",
|
|
}
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def corrupt_store_as_status(db_path):
|
|
"""Map a corrupt-image ``sqlite3.DatabaseError`` from a state.db read to a 503 status
|
|
payload, warning once per store per :data:`_CORRUPT_STORE_WARN_INTERVAL_S`.
|
|
Busy/locked and every other error propagate unchanged."""
|
|
from hermes_state_errors import is_malformed_db_error
|
|
|
|
try:
|
|
yield
|
|
except sqlite3.DatabaseError as exc:
|
|
if not is_malformed_db_error(exc):
|
|
raise
|
|
key, now = str(db_path), time.monotonic()
|
|
last = _corrupt_store_warned_at.get(key)
|
|
if last is None or now - last >= _CORRUPT_STORE_WARN_INTERVAL_S:
|
|
_corrupt_store_warned_at[key] = now
|
|
log.warning("state.db at %s is corrupt (%s); dashboard reads return a status payload until it is "
|
|
"repaired — run `hermes doctor`", db_path, exc)
|
|
else:
|
|
log.debug("state.db at %s still corrupt: %s", db_path, exc)
|
|
raise HTTPException(status_code=503, detail={**CORRUPT_STORE_DETAIL, "path": key}) from exc
|