mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 11:40:26 +00:00
ff0d25fd0e
macOS Finder's 'Copy as Pathname' (Cmd+Option+C) wraps paths in single
quotes by default — '/Users/x/Documents/foo' — and users routinely paste
those quoted strings into the Add Space input expecting them to work.
Other shells and OS file managers do similar things with double quotes.
Today the path is taken via .strip() only, so the literal quote
characters become part of the resolved Path and the validator rejects
the result as 'not a directory'. cygnus reported this on Discord
(2026-05-01) — she had to manually un-quote her paths to register a
new Space.
Fix:
- New api.workspace._strip_surrounding_quotes() helper. Removes only
the outermost paired single or double quotes; preserves unpaired or
mismatched quotes (a path may legitimately contain a literal quote).
- validate_workspace_to_add() calls it before resolution so every
code path that registers a workspace benefits, not just the HTTP
route.
- _handle_workspace_add() also calls it at the route entry so the
blocked-system-path check and the duplicate-detection check both
see the cleaned form.
14 regression tests pin the behavior matrix:
- Unwrapped path unchanged
- Single quotes stripped
- Double quotes stripped
- Whitespace outside quotes handled (trim-then-strip)
- Only outermost pair removed (internal quotes preserved)
- Unpaired / mismatched quotes preserved
- Empty string + just-a-pair edge cases
- Validate_workspace_to_add accepts quoted form for existing dir
4610 tests pass (+14 from this PR), 0 regressions, ~2:27 full suite.
Reported by Cygnus on Discord, May 1 2026.
8274 lines
324 KiB
Python
8274 lines
324 KiB
Python
"""
|
||
Hermes Web UI -- Route handlers for GET and POST endpoints.
|
||
Extracted from server.py (Sprint 11) so server.py is a thin shell.
|
||
"""
|
||
|
||
import html as _html
|
||
import copy
|
||
import json
|
||
import logging
|
||
import os
|
||
import queue
|
||
import re
|
||
import platform
|
||
import shutil
|
||
import sqlite3
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
import uuid
|
||
import re
|
||
from pathlib import Path
|
||
from contextlib import closing
|
||
from urllib.parse import parse_qs
|
||
from api.agent_sessions import (
|
||
MESSAGING_SOURCES,
|
||
is_cli_session_row,
|
||
is_cli_session_row_visible,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Treat stalled/closed HTTP clients as normal disconnects. Long-lived SSE
|
||
# connections often end this way when a browser tab sleeps, a phone switches
|
||
# networks, or Tailscale leaves the socket half-closed. If these bubble to the
|
||
# request handler, the server logs 500s and can leave CLOSE-WAIT sockets around
|
||
# until the OS-level timeout fires.
|
||
_CLIENT_DISCONNECT_ERRORS = (
|
||
BrokenPipeError,
|
||
ConnectionResetError,
|
||
ConnectionAbortedError,
|
||
TimeoutError,
|
||
OSError,
|
||
)
|
||
|
||
# ── Cron run tracking ────────────────────────────────────────────────────────
|
||
# Track job IDs currently being executed so the frontend can poll status.
|
||
_RUNNING_CRON_JOBS: dict[str, float] = {} # job_id → start_timestamp
|
||
_RUNNING_CRON_LOCK = threading.Lock()
|
||
_CRON_OUTPUT_CONTENT_LIMIT = 8000
|
||
_CRON_OUTPUT_HEADER_CONTEXT = 200
|
||
_MESSAGING_RAW_SOURCES = {str(s).strip().lower() for s in MESSAGING_SOURCES}
|
||
_MESSAGING_SESSION_METADATA_CACHE: dict[str, object] = {
|
||
"path": None,
|
||
"mtime": None,
|
||
"identity": {},
|
||
}
|
||
_MESSAGING_SESSION_METADATA_LOCK = threading.Lock()
|
||
_STALE_MESSAGING_END_REASONS = {"session_reset", "session_switch"}
|
||
|
||
|
||
# ── Profile-scoped session/project filtering (#1611, #1614) ────────────────
|
||
#
|
||
# Sessions and projects are stored in the WebUI sidecar without per-row
|
||
# isolation by default — they're tagged with a `profile` field but every
|
||
# query saw all rows. The fix scopes both endpoints to the active profile
|
||
# by default, with `?all_profiles=1` opting into aggregate mode.
|
||
#
|
||
# Renamed-root profile handling (#1612): a row tagged `profile='default'`
|
||
# matches the active root regardless of the root's display name, and a row
|
||
# tagged with the renamed-root display name (e.g. 'kinni') likewise matches
|
||
# when the active profile is `'default'`. _is_root_profile() is the
|
||
# canonical check.
|
||
|
||
def _profiles_match(row_profile, active_profile) -> bool:
|
||
"""Return True if a session/project row's profile matches the active profile.
|
||
|
||
Treats both the literal alias 'default' and any renamed-root display name
|
||
(per _is_root_profile) as equivalent, so legacy rows tagged 'default'
|
||
still surface when the user has renamed the root profile to e.g. 'kinni',
|
||
and vice versa.
|
||
|
||
A row with no profile (`None` or empty string) is treated as belonging to
|
||
the root profile — that's the convention used by the legacy backfill at
|
||
api/models.py::all_sessions, and matches the default seen in
|
||
`static/sessions.js` (`S.activeProfile||'default'`).
|
||
"""
|
||
from api.profiles import _is_root_profile
|
||
|
||
row = row_profile or 'default'
|
||
active = active_profile or 'default'
|
||
if row == active:
|
||
return True
|
||
# Cross-alias the renamed root.
|
||
if _is_root_profile(row) and _is_root_profile(active):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _all_profiles_query_flag(parsed_url) -> bool:
|
||
"""Return True if the request URL has `?all_profiles=1` (or true/yes).
|
||
|
||
Centralizes the opt-in parsing so /api/sessions and /api/projects use
|
||
the same shape. Accepts 1/true/yes (case-insensitive) for ergonomics.
|
||
"""
|
||
qs = parse_qs(parsed_url.query)
|
||
raw = qs.get('all_profiles', [''])[0].strip().lower()
|
||
return raw in ('1', 'true', 'yes', 'on')
|
||
|
||
# ── SSE app-level heartbeat (#1623) ────────────────────────────────────────
|
||
#
|
||
# Kernel TCP keepalive (server.py setsockopt block) declares a peer dead at
|
||
# KEEPIDLE (10s) + KEEPINTVL (5s) * KEEPCNT (3) = 25s in the worst case. The
|
||
# app-level SSE heartbeat must fire well below that window so flaky-network
|
||
# probes never get the chance to kill an idle stream during long LLM thinking
|
||
# phases. 5s gives the kernel ~5x headroom: probe at 10s, heartbeat byte at
|
||
# every 5s of idle keeps the socket warm.
|
||
#
|
||
# Cost: ~12 bytes per heartbeat * 12 extra heartbeats/min = ~150B/min idle.
|
||
# Trivial; many production SSE deployments run 5-15s heartbeats specifically
|
||
# to handle proxies and mobile NAT.
|
||
_SSE_HEARTBEAT_INTERVAL_SECONDS = 5
|
||
|
||
|
||
def _normalize_messaging_source(raw_source) -> str:
|
||
return str(raw_source or "").strip().lower()
|
||
|
||
|
||
def _is_known_messaging_source(raw_source) -> bool:
|
||
return _normalize_messaging_source(raw_source) in _MESSAGING_RAW_SOURCES
|
||
|
||
|
||
def _safe_first(*values):
|
||
for value in values:
|
||
if value is None:
|
||
continue
|
||
text = str(value).strip()
|
||
if text:
|
||
return text
|
||
return ""
|
||
|
||
|
||
def _gateway_session_metadata_path():
|
||
try:
|
||
from api.profiles import get_active_hermes_home
|
||
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
|
||
except Exception:
|
||
hermes_home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser().resolve()
|
||
return hermes_home / "sessions" / "sessions.json"
|
||
|
||
|
||
def _load_gateway_session_identity_map() -> dict[str, dict]:
|
||
path = _gateway_session_metadata_path()
|
||
if not path.exists():
|
||
return {}
|
||
|
||
try:
|
||
st = path.stat()
|
||
cache = _MESSAGING_SESSION_METADATA_CACHE
|
||
with _MESSAGING_SESSION_METADATA_LOCK:
|
||
if cache["path"] == str(path) and cache["mtime"] == st.st_mtime:
|
||
return cache["identity"].copy()
|
||
except Exception:
|
||
return {}
|
||
|
||
try:
|
||
raw_sessions = json.loads(path.read_text(encoding="utf-8"))
|
||
except Exception as _json_err:
|
||
logger.debug("Failed to parse gateway sessions metadata from %s: %s", path, _json_err)
|
||
return {}
|
||
|
||
mapping: dict[str, dict] = {}
|
||
if isinstance(raw_sessions, dict):
|
||
for _entry in raw_sessions.values():
|
||
if not isinstance(_entry, dict):
|
||
continue
|
||
session_id = _safe_first(_entry.get("session_id"))
|
||
if not session_id:
|
||
continue
|
||
origin = _entry.get("origin") if isinstance(_entry.get("origin"), dict) else {}
|
||
platform = _safe_first(origin.get("platform"), _entry.get("platform"))
|
||
mapping[session_id] = {
|
||
"session_key": _safe_first(_entry.get("session_key"), _entry.get("key")),
|
||
"chat_id": _safe_first(origin.get("chat_id"), _entry.get("chat_id")),
|
||
"thread_id": _safe_first(origin.get("thread_id"), _entry.get("thread_id")),
|
||
"chat_type": _safe_first(origin.get("chat_type"), _entry.get("chat_type")),
|
||
"user_id": _safe_first(origin.get("user_id"), _entry.get("user_id")),
|
||
"platform": platform,
|
||
"raw_source": platform,
|
||
}
|
||
|
||
with _MESSAGING_SESSION_METADATA_LOCK:
|
||
_MESSAGING_SESSION_METADATA_CACHE["path"] = str(path)
|
||
_MESSAGING_SESSION_METADATA_CACHE["mtime"] = st.st_mtime
|
||
_MESSAGING_SESSION_METADATA_CACHE["identity"] = mapping
|
||
return mapping.copy()
|
||
|
||
|
||
def _mark_cron_running(job_id: str):
|
||
with _RUNNING_CRON_LOCK:
|
||
_RUNNING_CRON_JOBS[job_id] = time.time()
|
||
|
||
|
||
def _mark_cron_done(job_id: str):
|
||
with _RUNNING_CRON_LOCK:
|
||
_RUNNING_CRON_JOBS.pop(job_id, None)
|
||
|
||
|
||
def _is_cron_running(job_id: str) -> tuple[bool, float]:
|
||
"""Return (is_running, elapsed_seconds)."""
|
||
with _RUNNING_CRON_LOCK:
|
||
t = _RUNNING_CRON_JOBS.get(job_id)
|
||
if t is None:
|
||
return False, 0.0
|
||
return True, time.time() - t
|
||
|
||
|
||
def _cron_response_marker_index(text: str) -> int:
|
||
"""Return the start index of a markdown Response heading, if present."""
|
||
candidates = []
|
||
for heading in ("## Response", "# Response"):
|
||
if text.startswith(heading):
|
||
candidates.append(0)
|
||
idx = text.find(f"\n{heading}")
|
||
if idx >= 0:
|
||
candidates.append(idx + 1)
|
||
return min(candidates) if candidates else -1
|
||
|
||
|
||
def _cron_output_content_window(text: str, limit: int = _CRON_OUTPUT_CONTENT_LIMIT) -> str:
|
||
"""Return a bounded cron output window that preserves useful response text.
|
||
|
||
Cron output files can contain large skill dumps in the Prompt section. The
|
||
UI already extracts ``## Response`` when present, so keep that section in
|
||
the API payload instead of blindly returning the first ``limit`` chars.
|
||
"""
|
||
if limit <= 0:
|
||
return ""
|
||
if len(text) <= limit:
|
||
return text
|
||
|
||
response_idx = _cron_response_marker_index(text)
|
||
if response_idx >= 0:
|
||
header = text[:min(_CRON_OUTPUT_HEADER_CONTEXT, response_idx)].rstrip()
|
||
response = text[response_idx:].lstrip("\n")
|
||
content = f"{header}\n...\n{response}" if header else response
|
||
return content[:limit]
|
||
|
||
return text[-limit:]
|
||
|
||
|
||
|
||
|
||
def _cron_job_for_api(job: dict) -> dict:
|
||
"""Return a cron job payload with the #617 optional profile field present.
|
||
|
||
Legacy jobs intentionally persist without ``profile`` so they keep the
|
||
scheduler's server-default behavior. The API still returns ``profile: None``
|
||
so the UI can label that state explicitly instead of guessing.
|
||
"""
|
||
payload = dict(job or {})
|
||
payload.setdefault("profile", None)
|
||
return payload
|
||
|
||
|
||
def _cron_jobs_for_api(jobs) -> list[dict]:
|
||
return [_cron_job_for_api(job) for job in (jobs or [])]
|
||
|
||
|
||
def _available_cron_profile_names() -> set[str]:
|
||
from api.profiles import list_profiles_api
|
||
|
||
names = {"default"}
|
||
for profile in list_profiles_api():
|
||
try:
|
||
name = str(profile.get("name") or "").strip()
|
||
except AttributeError:
|
||
continue
|
||
if name:
|
||
names.add(name)
|
||
return names
|
||
|
||
|
||
def _normalize_cron_profile_value(value) -> str | None:
|
||
if value is None:
|
||
return None
|
||
profile = str(value).strip()
|
||
if not profile:
|
||
return None
|
||
if profile not in _available_cron_profile_names():
|
||
raise ValueError(f"Unknown profile: {profile}")
|
||
return profile
|
||
|
||
|
||
def _profile_home_for_cron_job(job: dict):
|
||
"""Resolve the execution profile for a cron job, with graceful fallback.
|
||
|
||
A missing/blank profile preserves legacy server-default behavior. If a job
|
||
points at a profile that was deleted after save, fall back to the active
|
||
server profile and log a warning instead of crashing the Run Now path.
|
||
"""
|
||
from api.profiles import get_active_hermes_home, get_hermes_home_for_profile
|
||
|
||
raw = str((job or {}).get("profile") or "").strip()
|
||
if not raw:
|
||
return get_active_hermes_home()
|
||
if raw not in _available_cron_profile_names():
|
||
logger.warning(
|
||
"Cron job %s references missing profile %r; falling back to server default",
|
||
(job or {}).get("id", "?"), raw,
|
||
)
|
||
return get_active_hermes_home()
|
||
return get_hermes_home_for_profile(raw)
|
||
|
||
|
||
def _run_cron_tracked(job, profile_home=None, execution_profile_home=None):
|
||
"""Wrapper that tracks running state around cron.scheduler.run_job.
|
||
|
||
``profile_home`` is the cron store that owns the job row/output metadata.
|
||
``execution_profile_home`` is the selected per-job profile used to load
|
||
agent config/.env while running. When no job profile is selected, both homes
|
||
are the same and legacy server-default behavior is preserved.
|
||
"""
|
||
from cron.scheduler import run_job # import here — runs inside a worker thread
|
||
from cron.jobs import mark_job_run, save_job_output
|
||
|
||
job_id = job.get("id", "")
|
||
execution_profile_home = execution_profile_home or profile_home
|
||
|
||
def _with_cron_home(home, fn):
|
||
if home is None:
|
||
return fn()
|
||
from api.profiles import cron_profile_context_for_home
|
||
|
||
with cron_profile_context_for_home(home):
|
||
return fn()
|
||
|
||
try:
|
||
success, output, final_response, error = _with_cron_home(
|
||
execution_profile_home, lambda: run_job(job)
|
||
)
|
||
|
||
# Persist output and run metadata back to the job's owning cron store,
|
||
# even when the selected execution profile is different.
|
||
def _persist_success():
|
||
save_job_output(job_id, output)
|
||
|
||
# Match the scheduled cron path: an apparently successful run with no
|
||
# final response should not leave the job looking healthy.
|
||
_success, _error = success, error
|
||
if _success and not final_response:
|
||
_success = False
|
||
_error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)"
|
||
|
||
mark_job_run(job_id, _success, _error)
|
||
|
||
_with_cron_home(profile_home, _persist_success)
|
||
except Exception as e:
|
||
logger.exception("Manual cron run failed for job %s", job_id)
|
||
try:
|
||
_with_cron_home(profile_home, lambda: mark_job_run(job_id, False, str(e)))
|
||
except Exception:
|
||
logger.debug("Failed to mark manual cron run failure for %s", job_id)
|
||
finally:
|
||
_mark_cron_done(job_id)
|
||
|
||
_PROVIDER_ALIASES = {
|
||
"claude": "anthropic",
|
||
"gpt": "openai",
|
||
"gemini": "google",
|
||
"openai-codex": "openai",
|
||
}
|
||
|
||
# OpenAI-compatible /v1/models endpoints for live model discovery.
|
||
# Used as fallback when hermes_cli.provider_model_ids() is unavailable or
|
||
# returns [] for a provider (#871). Kept at module level so the dict is
|
||
# built once, not reconstructed per request.
|
||
_OPENAI_COMPAT_ENDPOINTS = {
|
||
"zai": "https://api.z.ai/v1",
|
||
"minimax": "https://api.minimax.chat/v1",
|
||
"mistralai": "https://api.mistral.ai/v1",
|
||
"xai": "https://api.x.ai/v1",
|
||
"deepseek": "https://api.deepseek.com",
|
||
"gemini": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||
"nvidia": "https://integrate.api.nvidia.com/v1",
|
||
}
|
||
# NOTE: "openai-codex" is excluded because it maps to the same endpoint as
|
||
# the base "openai" provider (api.openai.com/v1). When both are configured
|
||
# the openai provider is already wired through provider_model_ids(); codex-
|
||
# specific model filtering happens downstream in hermes_cli.
|
||
#
|
||
_LIVE_MODELS_CACHE_TTL = 60.0
|
||
_LIVE_MODELS_CACHE: dict[tuple[str, str], tuple[float, dict]] = {}
|
||
_LIVE_MODELS_CACHE_LOCK = threading.RLock()
|
||
|
||
|
||
def _active_profile_for_live_models_cache() -> str:
|
||
try:
|
||
from api.profiles import get_active_profile_name
|
||
|
||
return get_active_profile_name() or "default"
|
||
except Exception as _e:
|
||
# A transient profile-resolution error mis-scopes the cache for up to
|
||
# 60s ("default" gets the wrong payload). Log so we can detect it; the
|
||
# blast radius stays small because the TTL caps the bad-cache window.
|
||
logger.debug("_active_profile_for_live_models_cache fell back to 'default': %s", _e)
|
||
return "default"
|
||
|
||
|
||
def _live_models_cache_key(provider: str) -> tuple[str, str]:
|
||
return (_active_profile_for_live_models_cache(), provider)
|
||
|
||
|
||
def _get_cached_live_models(key: tuple[str, str]) -> dict | None:
|
||
now = time.monotonic()
|
||
with _LIVE_MODELS_CACHE_LOCK:
|
||
cached = _LIVE_MODELS_CACHE.get(key)
|
||
if not cached:
|
||
return None
|
||
ts, payload = cached
|
||
if now - ts >= _LIVE_MODELS_CACHE_TTL:
|
||
_LIVE_MODELS_CACHE.pop(key, None)
|
||
return None
|
||
return copy.deepcopy(payload)
|
||
|
||
|
||
def _set_cached_live_models(key: tuple[str, str], payload: dict) -> None:
|
||
with _LIVE_MODELS_CACHE_LOCK:
|
||
_LIVE_MODELS_CACHE[key] = (time.monotonic(), copy.deepcopy(payload))
|
||
|
||
|
||
def _clear_live_models_cache() -> None:
|
||
with _LIVE_MODELS_CACHE_LOCK:
|
||
_LIVE_MODELS_CACHE.clear()
|
||
|
||
from api.config import (
|
||
STATE_DIR,
|
||
SESSION_DIR,
|
||
DEFAULT_WORKSPACE,
|
||
DEFAULT_MODEL,
|
||
SESSIONS,
|
||
SESSIONS_MAX,
|
||
LOCK,
|
||
STREAMS,
|
||
STREAMS_LOCK,
|
||
CANCEL_FLAGS,
|
||
SERVER_START_TIME,
|
||
_resolve_cli_toolsets,
|
||
_INDEX_HTML_PATH,
|
||
get_available_models,
|
||
IMAGE_EXTS,
|
||
MD_EXTS,
|
||
MIME_MAP,
|
||
MAX_FILE_BYTES,
|
||
MAX_UPLOAD_BYTES,
|
||
CHAT_LOCK,
|
||
_get_session_agent_lock,
|
||
SESSION_AGENT_LOCKS,
|
||
SESSION_AGENT_LOCKS_LOCK,
|
||
load_settings,
|
||
save_settings,
|
||
set_hermes_default_model,
|
||
model_with_provider_context,
|
||
get_reasoning_status,
|
||
set_reasoning_display,
|
||
set_reasoning_effort,
|
||
create_stream_channel,
|
||
get_webui_session_save_mode,
|
||
)
|
||
from api.helpers import (
|
||
require,
|
||
bad,
|
||
safe_resolve,
|
||
j,
|
||
t,
|
||
read_body,
|
||
_security_headers,
|
||
_sanitize_error,
|
||
redact_session_data,
|
||
_redact_text,
|
||
)
|
||
from api.agent_health import build_agent_health_payload
|
||
from api.system_health import build_system_health_payload
|
||
|
||
|
||
def _clear_stale_stream_state(session) -> bool:
|
||
"""Clear persisted streaming flags when the in-memory stream no longer exists.
|
||
|
||
A server restart or worker crash can leave active_stream_id/pending_* in the
|
||
session JSON while STREAMS is empty. The frontend then keeps reconnecting to
|
||
a dead stream and shows a permanent running/thinking state.
|
||
|
||
SAFETY (#1558): If ``session`` was loaded with ``metadata_only=True``, its
|
||
``messages`` array is empty by design and calling ``save()`` would
|
||
atomically overwrite the on-disk JSON, wiping the conversation. In that
|
||
case we re-load the full session before mutating, so the persisted
|
||
write carries the real messages forward.
|
||
"""
|
||
stream_id = getattr(session, "active_stream_id", None)
|
||
if not stream_id:
|
||
return False
|
||
with STREAMS_LOCK:
|
||
stream_alive = stream_id in STREAMS
|
||
if stream_alive:
|
||
return False
|
||
|
||
# ── #1558 P0 safety: if we were handed a metadata-only stub, reload the
|
||
# full session before touching persisted state. The original
|
||
# metadata-only object is left untouched so the caller's read path is
|
||
# unaffected.
|
||
original_stub = session # SHOULD-FIX #1 (Opus): keep reference so we can
|
||
# patch the caller's in-memory copy after a
|
||
# successful clear, avoiding one ghost SSE
|
||
# reconnect on the very next /api/session GET.
|
||
if getattr(session, "_loaded_metadata_only", False):
|
||
try:
|
||
from api.models import get_session as _get_session
|
||
session = _get_session(session.session_id, metadata_only=False)
|
||
except Exception:
|
||
# If we cannot upgrade to a full load (file gone, decode error,
|
||
# etc.) bail without clearing — better to leave a stale
|
||
# active_stream_id than to wipe the conversation.
|
||
logger.warning(
|
||
"_clear_stale_stream_state: refused to clear stale stream %s "
|
||
"for session %s — full reload failed and we will not save a "
|
||
"metadata-only stub. See #1558.",
|
||
stream_id, getattr(session, "session_id", "?"),
|
||
)
|
||
return False
|
||
if session is None:
|
||
return False
|
||
# The full-load path may have already repaired stale pending fields
|
||
# via _repair_stale_pending(); only re-assert if still set.
|
||
if not getattr(session, "active_stream_id", None):
|
||
# Patch the caller's stub so its read path also sees the cleared
|
||
# field (matches the Opus SHOULD-FIX #1 — without this, /api/session
|
||
# would briefly return the stale active_stream_id and the frontend
|
||
# would attempt one ghost SSE reconnect before recovering).
|
||
try:
|
||
original_stub.active_stream_id = None
|
||
if hasattr(original_stub, "pending_user_message"):
|
||
original_stub.pending_user_message = None
|
||
if hasattr(original_stub, "pending_attachments"):
|
||
original_stub.pending_attachments = []
|
||
if hasattr(original_stub, "pending_started_at"):
|
||
original_stub.pending_started_at = None
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
# ── #1533 race fix: acquire the per-session lock and re-read
|
||
# active_stream_id under it. A concurrent chat_start may have already
|
||
# registered a new stream after our STREAMS_LOCK check above; in that
|
||
# case we must NOT clobber its session.active_stream_id.
|
||
with _get_session_agent_lock(session.session_id):
|
||
if getattr(session, "active_stream_id", None) != stream_id:
|
||
return False
|
||
session.active_stream_id = None
|
||
if hasattr(session, "pending_user_message"):
|
||
session.pending_user_message = None
|
||
if hasattr(session, "pending_attachments"):
|
||
session.pending_attachments = []
|
||
if hasattr(session, "pending_started_at"):
|
||
session.pending_started_at = None
|
||
try:
|
||
session.save()
|
||
except Exception:
|
||
logger.exception(
|
||
"_clear_stale_stream_state: save() failed for session %s",
|
||
getattr(session, "session_id", "?"),
|
||
)
|
||
# Patch the caller's stub (if different from the full-load object) so
|
||
# its in-memory active_stream_id matches what just got persisted.
|
||
if original_stub is not session:
|
||
try:
|
||
original_stub.active_stream_id = None
|
||
if hasattr(original_stub, "pending_user_message"):
|
||
original_stub.pending_user_message = None
|
||
if hasattr(original_stub, "pending_attachments"):
|
||
original_stub.pending_attachments = []
|
||
if hasattr(original_stub, "pending_started_at"):
|
||
original_stub.pending_started_at = None
|
||
except Exception:
|
||
pass
|
||
return True
|
||
|
||
# ── CSRF: validate Origin/Referer on POST ────────────────────────────────────
|
||
import re as _re
|
||
|
||
|
||
def _normalize_host_port(value: str) -> tuple[str, str | None]:
|
||
"""Split a host or host:port string into (hostname, port|None).
|
||
Handles IPv6 bracket notation, e.g. [::1]:8080."""
|
||
value = value.strip().lower()
|
||
if not value:
|
||
return '', None
|
||
if value.startswith('['):
|
||
end = value.find(']')
|
||
if end != -1:
|
||
host = value[1:end]
|
||
rest = value[end + 1 :]
|
||
if rest.startswith(':') and rest[1:].isdigit():
|
||
return host, rest[1:]
|
||
return host, None
|
||
if value.count(':') == 1:
|
||
host, port = value.rsplit(':', 1)
|
||
if port.isdigit():
|
||
return host, port
|
||
return value, None
|
||
|
||
|
||
def _ports_match(origin_scheme: str, origin_port: str | None, allowed_port: str | None) -> bool:
|
||
"""Return True when two ports should be considered equivalent, scheme-aware.
|
||
|
||
Treats an absent port as the scheme default: port 80 for http, port 443 for https.
|
||
Port 80 is NOT treated as equivalent to 443 (different protocols = different origins).
|
||
"""
|
||
if origin_port == allowed_port:
|
||
return True
|
||
# Determine the default port for the origin's scheme
|
||
default = '443' if origin_scheme == 'https' else '80'
|
||
if not origin_port and allowed_port == default:
|
||
return True
|
||
if not allowed_port and origin_port == default:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _allowed_public_origins() -> set[str]:
|
||
"""Parse HERMES_WEBUI_ALLOWED_ORIGINS env var (comma-separated) into a set.
|
||
|
||
Each entry must include the scheme, e.g. https://myapp.example.com:8000.
|
||
Entries without a scheme are silently skipped and a warning is printed.
|
||
"""
|
||
raw = os.getenv('HERMES_WEBUI_ALLOWED_ORIGINS', '')
|
||
result = set()
|
||
for value in raw.split(','):
|
||
value = value.strip().rstrip('/').lower()
|
||
if not value:
|
||
continue
|
||
if not (value.startswith('http://') or value.startswith('https://')):
|
||
import sys
|
||
print(
|
||
f"[webui] WARNING: HERMES_WEBUI_ALLOWED_ORIGINS entry {value!r} is missing "
|
||
f"the scheme (expected https://hostname or http://hostname). Entry ignored.",
|
||
flush=True, file=sys.stderr,
|
||
)
|
||
continue
|
||
result.add(value)
|
||
return result
|
||
|
||
|
||
def _check_csrf(handler) -> bool:
|
||
"""Reject cross-origin POST requests. Returns True if OK."""
|
||
origin = handler.headers.get("Origin", "")
|
||
referer = handler.headers.get("Referer", "")
|
||
host = handler.headers.get("Host", "")
|
||
if not origin and not referer:
|
||
return True # non-browser clients (curl, agent) have no Origin
|
||
target = origin or referer
|
||
# Extract host:port from origin/referer
|
||
m = _re.match(r"^https?://([^/]+)", target)
|
||
if not m:
|
||
return False
|
||
origin_host = m.group(1)
|
||
origin_scheme = m.group(0).split('://')[0].lower() # 'http' or 'https'
|
||
origin_name, origin_port = _normalize_host_port(origin_host)
|
||
# Check against explicitly allowed public origins (env var)
|
||
origin_value = m.group(0).rstrip('/').lower()
|
||
if origin_value in _allowed_public_origins():
|
||
return True
|
||
# Allow same-origin: check Host, X-Forwarded-Host (reverse proxy), and
|
||
# X-Real-Host against the origin. Reverse proxies (Caddy, nginx) set
|
||
# X-Forwarded-Host to the client's original Host header.
|
||
allowed_hosts = [
|
||
h.strip()
|
||
for h in [
|
||
host,
|
||
handler.headers.get("X-Forwarded-Host", ""),
|
||
handler.headers.get("X-Real-Host", ""),
|
||
]
|
||
if h.strip()
|
||
]
|
||
for allowed in allowed_hosts:
|
||
allowed_name, allowed_port = _normalize_host_port(allowed)
|
||
if origin_name == allowed_name and _ports_match(origin_scheme, origin_port, allowed_port):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _normalize_provider_id(value: str | None) -> str:
|
||
raw = str(value or "").strip().lower()
|
||
if not raw:
|
||
return ""
|
||
if raw in _PROVIDER_ALIASES:
|
||
return _PROVIDER_ALIASES[raw]
|
||
for prefix, normalized in (
|
||
("openai-codex", "openai"),
|
||
("openai", "openai"),
|
||
("anthropic", "anthropic"),
|
||
("claude", "anthropic"),
|
||
("google", "google"),
|
||
("gemini", "google"),
|
||
("openrouter", "openrouter"),
|
||
("custom", "custom"),
|
||
):
|
||
if raw.startswith(prefix):
|
||
return normalized
|
||
# Unknown prefix — return empty so callers treat it as "no match" and pass
|
||
# the model through unchanged rather than incorrectly stripping it.
|
||
return ""
|
||
|
||
|
||
def _catalog_provider_id_sets(catalog: dict) -> tuple[set[str], set[str]]:
|
||
raw_provider_ids: set[str] = set()
|
||
normalized_provider_ids: set[str] = set()
|
||
for group in catalog.get("groups") or []:
|
||
raw = str(group.get("provider_id") or "").strip().lower()
|
||
if not raw:
|
||
continue
|
||
raw_provider_ids.add(raw)
|
||
normalized = _normalize_provider_id(raw)
|
||
if normalized:
|
||
normalized_provider_ids.add(normalized)
|
||
return raw_provider_ids, normalized_provider_ids
|
||
|
||
|
||
def _catalog_has_provider(
|
||
provider_raw: str,
|
||
provider_normalized: str,
|
||
raw_provider_ids: set[str],
|
||
normalized_provider_ids: set[str],
|
||
) -> bool:
|
||
return (
|
||
provider_raw in raw_provider_ids
|
||
or (provider_normalized and provider_normalized in raw_provider_ids)
|
||
or (provider_normalized and provider_normalized in normalized_provider_ids)
|
||
)
|
||
|
||
|
||
def _model_matches_active_provider_family(
|
||
model: str,
|
||
active_provider: str,
|
||
) -> bool:
|
||
model_lower = model.lower()
|
||
for bare_prefix in ("gpt", "claude", "gemini"):
|
||
if model_lower.startswith(bare_prefix):
|
||
return _normalize_provider_id(bare_prefix) == active_provider
|
||
return False
|
||
|
||
|
||
def _catalog_model_id_matches(candidate: str, model: str) -> bool:
|
||
candidate = str(candidate or "").strip()
|
||
if candidate.startswith("@") and ":" in candidate:
|
||
candidate = candidate.rsplit(":", 1)[1]
|
||
if "/" in candidate:
|
||
candidate = candidate.split("/", 1)[1]
|
||
return candidate.replace("-", ".").lower() == model.replace("-", ".").lower()
|
||
|
||
|
||
def _clean_session_model_provider(value: str | None) -> str | None:
|
||
provider = str(value or "").strip().lower()
|
||
if not provider or provider == "default":
|
||
return None
|
||
if provider.startswith("@"):
|
||
provider = provider[1:]
|
||
return provider or None
|
||
|
||
|
||
def _split_provider_qualified_model(model: str) -> tuple[str, str | None]:
|
||
model = str(model or "").strip()
|
||
if model.startswith("@") and ":" in model:
|
||
provider_hint, bare_model = model[1:].rsplit(":", 1)
|
||
provider = _clean_session_model_provider(provider_hint)
|
||
bare = bare_model.strip()
|
||
if provider and bare:
|
||
return bare, provider
|
||
return model, None
|
||
|
||
|
||
def _should_attach_codex_provider_context(model: str, raw_active_provider: str, catalog: dict) -> bool:
|
||
"""Return True when a bare Codex model needs separate provider context.
|
||
|
||
OpenAI, OpenAI Codex, Copilot, and OpenRouter can all expose GPT-looking
|
||
bare names. If a session stores only ``gpt-...`` while Codex is active, a
|
||
later provider-list/default-model round trip can lose the user's Codex
|
||
choice. Store the provider separately instead of converting the persisted
|
||
model to ``@openai-codex:model``.
|
||
"""
|
||
if raw_active_provider != "openai-codex":
|
||
return False
|
||
if not model.lower().startswith("gpt"):
|
||
return False
|
||
for group in catalog.get("groups") or []:
|
||
if str(group.get("provider_id") or "").strip().lower() != "openai-codex":
|
||
continue
|
||
return any(
|
||
_catalog_model_id_matches(entry.get("id"), model)
|
||
for entry in group.get("models", [])
|
||
if isinstance(entry, dict)
|
||
)
|
||
return False
|
||
|
||
|
||
def _resolve_compatible_session_model_state(
|
||
model_id: str | None,
|
||
model_provider: str | None = None,
|
||
) -> tuple[str, str | None, bool]:
|
||
"""Return (effective_model, effective_provider, model_was_normalized).
|
||
|
||
Sessions can outlive provider changes. When an older session still points at
|
||
a different provider namespace (for example `gemini/...` after switching the
|
||
agent to OpenAI Codex), reusing that stale model causes chat startup to hit
|
||
the wrong backend and fail. Normalize only obvious cross-provider mismatches.
|
||
When a model has an explicit provider context, keep the model string itself
|
||
in its picker/API shape and carry the provider as separate state.
|
||
"""
|
||
catalog = get_available_models()
|
||
default_model = str(catalog.get("default_model") or DEFAULT_MODEL or "").strip()
|
||
model = str(model_id or "").strip()
|
||
requested_provider = _clean_session_model_provider(model_provider)
|
||
if not model:
|
||
return default_model, requested_provider, bool(default_model)
|
||
|
||
active_provider = _normalize_provider_id(catalog.get("active_provider"))
|
||
# Also keep the raw active_provider slug for cross-provider detection with
|
||
# non-listed providers (ollama-cloud, deepseek, xai, etc.) that _normalize_provider_id
|
||
# returns "" for. If the raw provider is set but normalization returned "", we still
|
||
# want to detect that a session model from a known provider (e.g. openai/gpt-5.4-mini)
|
||
# is stale relative to this unknown active provider. (#1023)
|
||
raw_active_provider = str(catalog.get("active_provider") or "").strip().lower()
|
||
if not active_provider and not raw_active_provider:
|
||
bare_model, explicit_provider = _split_provider_qualified_model(model)
|
||
return model, explicit_provider or requested_provider, False
|
||
|
||
bare_for_context, explicit_provider = _split_provider_qualified_model(model)
|
||
if requested_provider and not explicit_provider:
|
||
return model, requested_provider, False
|
||
|
||
if model.startswith("@") and ":" in model:
|
||
provider_raw = explicit_provider or ""
|
||
provider_normalized = _normalize_provider_id(provider_raw)
|
||
bare_model = bare_for_context.strip()
|
||
if not provider_raw or not bare_model:
|
||
return model, requested_provider, False
|
||
|
||
raw_provider_ids, normalized_provider_ids = _catalog_provider_id_sets(catalog)
|
||
hint_matches_active = (
|
||
provider_raw == raw_active_provider
|
||
or provider_raw == active_provider
|
||
or (provider_normalized and provider_normalized == active_provider)
|
||
)
|
||
if hint_matches_active:
|
||
# The @provider:model hint explicitly names the active provider, so this
|
||
# selection is intentional — not a stale cross-provider artifact. Return
|
||
# the full @provider:model string unchanged so downstream (resolve_model_provider
|
||
# in config.py) can route through the correct provider. Stripping the prefix
|
||
# here would collapse duplicate model IDs from different providers back to the
|
||
# bare ID, causing the first matching provider to win on the next UI render
|
||
# and the wrong provider to be used for the agent run. (#1253)
|
||
return model, provider_raw, False
|
||
|
||
if _catalog_has_provider(
|
||
provider_raw,
|
||
provider_normalized,
|
||
raw_provider_ids,
|
||
normalized_provider_ids,
|
||
):
|
||
return model, provider_raw, False
|
||
|
||
if _model_matches_active_provider_family(bare_model, active_provider):
|
||
provider_context = (
|
||
raw_active_provider
|
||
if _should_attach_codex_provider_context(bare_model, raw_active_provider, catalog)
|
||
else None
|
||
)
|
||
return bare_model, provider_context, True
|
||
if default_model:
|
||
provider_context = (
|
||
raw_active_provider
|
||
if _should_attach_codex_provider_context(default_model, raw_active_provider, catalog)
|
||
else None
|
||
)
|
||
return default_model, provider_context, True
|
||
return model, provider_raw, False
|
||
|
||
slash = model.find("/")
|
||
if slash < 0:
|
||
model_lower = model.lower()
|
||
for bare_prefix in ("gpt", "claude", "gemini"):
|
||
if model_lower.startswith(bare_prefix):
|
||
model_provider = _normalize_provider_id(bare_prefix)
|
||
if model_provider and model_provider != active_provider and default_model:
|
||
provider_context = (
|
||
raw_active_provider
|
||
if _should_attach_codex_provider_context(default_model, raw_active_provider, catalog)
|
||
else None
|
||
)
|
||
return default_model, provider_context, True
|
||
provider_context = (
|
||
raw_active_provider
|
||
if _should_attach_codex_provider_context(model, raw_active_provider, catalog)
|
||
else requested_provider
|
||
)
|
||
return model, provider_context, False
|
||
return model, requested_provider, False
|
||
|
||
model_provider = _normalize_provider_id(model[:slash])
|
||
|
||
# For custom/openrouter active providers: only skip normalization when the
|
||
# model's namespace prefix is actually routable by a group in the catalog.
|
||
# A user who only has custom_providers configured (active_provider="custom")
|
||
# with a stale session model like "openai/gpt-5.4-mini" would otherwise
|
||
# never get cleaned up, causing "(unavailable)" to appear in the picker.
|
||
if active_provider in {"custom", "openrouter"}:
|
||
# These namespaces are always routable as-is — preserve them.
|
||
if model_provider in {"", "custom", "openrouter"}:
|
||
return model, requested_provider, False
|
||
# Check if any catalog group can actually route this model's prefix.
|
||
groups = catalog.get("groups") or []
|
||
routable_provider_ids = {
|
||
_normalize_provider_id(g.get("provider_id") or "") for g in groups
|
||
}
|
||
# openrouter group can route any provider/model namespace
|
||
has_openrouter_group = any(
|
||
(g.get("provider_id") or "") == "openrouter" for g in groups
|
||
)
|
||
if model_provider in routable_provider_ids or has_openrouter_group:
|
||
return model, requested_provider, False
|
||
# Model prefix is not routable — stale cross-provider reference, clear it.
|
||
if default_model:
|
||
return default_model, requested_provider, True
|
||
return model, requested_provider, False
|
||
|
||
# Skip normalization for models on custom/openrouter namespaces — these are
|
||
# user-controlled and should never be silently replaced.
|
||
#
|
||
# OpenAI Codex is intentionally normalized to the OpenAI family above so bare
|
||
# GPT IDs survive provider switches. Slash-qualified OpenAI IDs are different:
|
||
# ``openai/gpt-...`` is the OpenRouter shape for OpenAI models, and
|
||
# resolve_model_provider() routes that through OpenRouter when Codex is the
|
||
# configured provider. Legacy sessions can carry that stale slash ID without
|
||
# a saved model_provider, so repair it to the active Codex default unless the
|
||
# session/request explicitly says it is an OpenRouter selection. (#1734)
|
||
if (
|
||
raw_active_provider == "openai-codex"
|
||
and model_provider == "openai"
|
||
and requested_provider is None
|
||
and default_model
|
||
):
|
||
# Persist provider_context = "openai-codex" unconditionally on this
|
||
# repair path so the resolved shape is stable across resolutions
|
||
# (Opus stage-303 SHOULD-FIX: avoid redundant repair-writes per
|
||
# chat-start when the catalog-coverage check fails — e.g. if a
|
||
# future Codex default is itself slash-prefixed). Once we've
|
||
# decided the session belongs to Codex, persist that decision.
|
||
return default_model, raw_active_provider, True
|
||
|
||
# Also normalize when the model is from a known provider but the active provider
|
||
# is an unlisted one (e.g. ollama-cloud) — active_provider is "" in that case
|
||
# but raw_active_provider is set. If model_provider doesn't start with the raw
|
||
# active provider name, the session model is stale. (#1023)
|
||
_active_for_compare = active_provider or raw_active_provider
|
||
if model_provider and model_provider not in {"", "custom", "openrouter"} and model_provider != _active_for_compare and default_model:
|
||
return default_model, requested_provider, True
|
||
return model, requested_provider, False
|
||
|
||
|
||
def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]:
|
||
"""Return (effective_model, model_was_normalized) for legacy callers."""
|
||
effective_model, _provider, changed = _resolve_compatible_session_model_state(model_id)
|
||
return effective_model, changed
|
||
|
||
|
||
def _normalize_session_model_in_place(session) -> str:
|
||
original_model = getattr(session, "model", None) or ""
|
||
original_provider = _clean_session_model_provider(
|
||
getattr(session, "model_provider", None)
|
||
)
|
||
effective_model, effective_provider, changed = _resolve_compatible_session_model_state(
|
||
original_model or None,
|
||
original_provider,
|
||
)
|
||
provider_changed = effective_provider != original_provider
|
||
# Only persist the correction if the session had an explicit model that needed changing.
|
||
# Sessions with no model stored (empty/None) get the effective default returned without
|
||
# a disk write — no need to rebuild the index for a fill-in-blank operation.
|
||
if original_model and effective_model and (
|
||
(changed and original_model != effective_model) or provider_changed
|
||
):
|
||
if changed and original_model != effective_model:
|
||
session.model = effective_model
|
||
session.model_provider = effective_provider
|
||
session.save(touch_updated_at=False)
|
||
return effective_model
|
||
|
||
|
||
def _resolve_effective_session_model_for_display(session) -> str:
|
||
"""Resolve the model a session should display without mutating persisted state.
|
||
|
||
`GET /api/session` should stay side-effect free. If a stale persisted model
|
||
needs normalization for the current provider configuration, return the
|
||
effective model for the response payload only and leave disk state alone.
|
||
"""
|
||
original_model = getattr(session, "model", None) or ""
|
||
effective_model, _provider, _changed = _resolve_compatible_session_model_state(
|
||
original_model or None,
|
||
getattr(session, "model_provider", None),
|
||
)
|
||
return effective_model or original_model
|
||
|
||
def _resolve_effective_session_model_provider_for_display(session) -> str | None:
|
||
original_model = getattr(session, "model", None) or ""
|
||
_model, provider, _changed = _resolve_compatible_session_model_state(
|
||
original_model or None,
|
||
getattr(session, "model_provider", None),
|
||
)
|
||
return provider
|
||
|
||
|
||
def _session_model_state_from_request(
|
||
model: str | None,
|
||
requested_provider: str | None,
|
||
current_provider: str | None = None,
|
||
) -> tuple[str | None, str | None]:
|
||
model_value = str(model).strip() if model is not None else None
|
||
provider = (
|
||
_clean_session_model_provider(requested_provider)
|
||
if requested_provider is not None
|
||
else None
|
||
)
|
||
if model_value:
|
||
_bare, explicit_provider = _split_provider_qualified_model(model_value)
|
||
if explicit_provider:
|
||
provider = explicit_provider
|
||
elif requested_provider is None:
|
||
provider = _clean_session_model_provider(current_provider)
|
||
model_value, provider, _changed = _resolve_compatible_session_model_state(
|
||
model_value,
|
||
provider,
|
||
)
|
||
return model_value, provider
|
||
|
||
|
||
def _lookup_gateway_session_identity(session_id: str) -> dict:
|
||
if not session_id:
|
||
return {}
|
||
metadata = _load_gateway_session_identity_map().get(str(session_id))
|
||
return metadata if isinstance(metadata, dict) else {}
|
||
|
||
|
||
def _lookup_cli_session_metadata(session_id: str) -> dict:
|
||
if not session_id:
|
||
return {}
|
||
try:
|
||
for row in get_cli_sessions():
|
||
if row.get("session_id") == session_id:
|
||
return row
|
||
except Exception:
|
||
return {}
|
||
return {}
|
||
|
||
|
||
def _messaging_session_identity(session: dict, raw_source: str) -> str:
|
||
metadata = _lookup_gateway_session_identity(session.get("session_id"))
|
||
session_key = _safe_first(
|
||
metadata.get("session_key"),
|
||
session.get("session_key"),
|
||
session.get("gateway_session_key"),
|
||
)
|
||
if session_key:
|
||
return f"{raw_source}|session_key:{session_key}"
|
||
|
||
chat_id = _safe_first(
|
||
metadata.get("chat_id"),
|
||
session.get("chat_id"),
|
||
session.get("origin_chat_id"),
|
||
)
|
||
thread_id = _safe_first(metadata.get("thread_id"), session.get("thread_id"))
|
||
chat_type = _safe_first(metadata.get("chat_type"), session.get("chat_type"))
|
||
user_id = _safe_first(
|
||
metadata.get("user_id"),
|
||
session.get("user_id"),
|
||
session.get("origin_user_id"),
|
||
)
|
||
|
||
identity_parts = []
|
||
if chat_type:
|
||
identity_parts.append(f"chat_type:{chat_type}")
|
||
if chat_id:
|
||
identity_parts.append(f"chat_id:{chat_id}")
|
||
if thread_id:
|
||
identity_parts.append(f"thread_id:{thread_id}")
|
||
if user_id:
|
||
identity_parts.append(f"user_id:{user_id}")
|
||
|
||
if identity_parts:
|
||
return f"{raw_source}|" + "|".join(identity_parts)
|
||
return raw_source
|
||
|
||
|
||
def _session_messaging_raw_source(session: dict) -> str:
|
||
raw = _safe_first(
|
||
session.get("raw_source"),
|
||
session.get("source_tag"),
|
||
session.get("source"),
|
||
session.get("platform"),
|
||
)
|
||
if not raw:
|
||
raw = session.get("source_label") or "messaging"
|
||
return _normalize_messaging_source(raw)
|
||
|
||
|
||
def _has_durable_messaging_identity(session: dict) -> bool:
|
||
metadata = _lookup_gateway_session_identity(session.get("session_id"))
|
||
return bool(_safe_first(
|
||
metadata.get("session_key"),
|
||
session.get("session_key"),
|
||
session.get("gateway_session_key"),
|
||
metadata.get("chat_id"),
|
||
session.get("chat_id"),
|
||
session.get("origin_chat_id"),
|
||
metadata.get("thread_id"),
|
||
session.get("thread_id"),
|
||
))
|
||
|
||
|
||
def _numeric_count(value) -> int:
|
||
try:
|
||
return int(float(_safe_first(value, 0) or 0))
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
|
||
def _should_hide_stale_messaging_session(
|
||
session: dict,
|
||
active_gateway_session_ids: set[str],
|
||
active_gateway_sources: set[str],
|
||
) -> bool:
|
||
"""Hide stale Gateway-owned internal rows after an external chat moved on.
|
||
|
||
Hermes Gateway keeps the external conversation identity in sessions.json.
|
||
Compression/session-reset can leave old Agent state.db rows behind; those
|
||
rows are implementation segments, not distinct conversations users chose.
|
||
Only apply this aggressive hiding when Gateway is currently advertising an
|
||
active session for the same messaging source. Without that source-of-truth
|
||
file we keep the old fallback behavior.
|
||
"""
|
||
raw_source = _session_messaging_raw_source(session)
|
||
if not _is_known_messaging_source(raw_source):
|
||
return False
|
||
if not active_gateway_session_ids or raw_source not in active_gateway_sources:
|
||
return False
|
||
|
||
sid = _safe_first(session.get("session_id"))
|
||
if sid and sid in active_gateway_session_ids:
|
||
return False
|
||
|
||
if _safe_first(session.get("end_reason")) in _STALE_MESSAGING_END_REASONS:
|
||
return True
|
||
|
||
if not _has_durable_messaging_identity(session):
|
||
return True
|
||
|
||
if session.get("parent_session_id"):
|
||
return True
|
||
|
||
message_count = _numeric_count(session.get("message_count"))
|
||
actual_count = _numeric_count(session.get("actual_message_count"))
|
||
if message_count <= 0 and actual_count <= 0:
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
def _is_messaging_session_record(session) -> bool:
|
||
"""Return true for sessions backed by external messaging channels."""
|
||
if not session:
|
||
return False
|
||
if (
|
||
(getattr(session, "session_source", None) if not isinstance(session, dict) else session.get("session_source")) == "messaging"
|
||
):
|
||
return True
|
||
raw = _safe_first(
|
||
getattr(session, "raw_source", None) if not isinstance(session, dict) else session.get("raw_source"),
|
||
getattr(session, "source_tag", None) if not isinstance(session, dict) else session.get("source_tag"),
|
||
getattr(session, "source", None) if not isinstance(session, dict) else session.get("source"),
|
||
session.get("source_label") if isinstance(session, dict) else None,
|
||
)
|
||
return _is_known_messaging_source(raw)
|
||
|
||
|
||
def _is_messaging_session_id(sid: str) -> bool:
|
||
"""Detect messaging-backed sessions from WebUI metadata or Agent rows."""
|
||
try:
|
||
session = Session.load(sid)
|
||
if _is_messaging_session_record(session):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return _is_messaging_session_record(_lookup_cli_session_metadata(sid))
|
||
|
||
|
||
def _session_sort_timestamp(session: dict) -> float:
|
||
return float(
|
||
_safe_first(
|
||
session.get("last_message_at"),
|
||
session.get("updated_at"),
|
||
session.get("created_at"),
|
||
session.get("started_at"),
|
||
0,
|
||
) or 0
|
||
) or 0.0
|
||
|
||
|
||
def _is_cli_session_for_settings(session: dict) -> bool:
|
||
"""Return True for importable CLI sessions that are safe to classify for settings."""
|
||
if not isinstance(session, dict):
|
||
return False
|
||
if is_cli_session_row(session):
|
||
return True
|
||
|
||
# Fallback for legacy local copies that had weak/empty metadata:
|
||
# keep this conservative so messaging sessions do not collapse incorrectly.
|
||
if not session.get("is_cli_session"):
|
||
return False
|
||
source = str(session.get("source") or "").strip().lower()
|
||
if source in MESSAGING_SOURCES:
|
||
return False
|
||
title = str(session.get("title") or "").strip().lower()
|
||
return title in ("", "untitled", "cli", "cli session") or title.endswith(" session") and (
|
||
not source or source == "cli"
|
||
)
|
||
|
||
|
||
CLI_VISIBLE_SESSION_CAP = 20
|
||
|
||
|
||
def _cap_recent_cli_sessions(sessions: list[dict], cli_cap: int = CLI_VISIBLE_SESSION_CAP) -> list[dict]:
|
||
"""Keep only the most recent CLI-visible sessions after filtering."""
|
||
if cli_cap <= 0:
|
||
return sessions
|
||
kept = []
|
||
cli_seen = 0
|
||
for session in sessions:
|
||
if _is_cli_session_for_settings(session):
|
||
cli_seen += 1
|
||
if cli_seen > cli_cap:
|
||
continue
|
||
kept.append(session)
|
||
return kept
|
||
|
||
|
||
def _merge_cli_sidebar_metadata(ui_session: dict, cli_meta: dict) -> dict:
|
||
"""Merge source-of-truth CLI metadata into a sidebar session row.
|
||
|
||
Preserve UI-owned state (archived/pinned) while replacing metadata that can
|
||
legitimately drift in WebUI snapshots.
|
||
"""
|
||
if not ui_session:
|
||
return ui_session
|
||
if not cli_meta:
|
||
return dict(ui_session)
|
||
merged = dict(ui_session)
|
||
merged["is_cli_session"] = True
|
||
for key in (
|
||
"source_tag",
|
||
"raw_source",
|
||
"session_source",
|
||
"source_label",
|
||
"user_id",
|
||
"chat_id",
|
||
"chat_type",
|
||
"thread_id",
|
||
"session_key",
|
||
"platform",
|
||
"parent_session_id",
|
||
"end_reason",
|
||
"actual_message_count",
|
||
"_lineage_root_id",
|
||
"_lineage_tip_id",
|
||
"_compression_segment_count",
|
||
):
|
||
value = _safe_first(cli_meta.get(key))
|
||
if value:
|
||
merged[key] = value
|
||
|
||
if cli_meta.get("created_at") is not None:
|
||
merged["created_at"] = cli_meta["created_at"]
|
||
if cli_meta.get("updated_at") is not None:
|
||
merged["updated_at"] = cli_meta["updated_at"]
|
||
if cli_meta.get("last_message_at") is not None:
|
||
merged["last_message_at"] = cli_meta["last_message_at"]
|
||
if cli_meta.get("message_count") is not None:
|
||
merged["message_count"] = max(
|
||
_numeric_count(merged.get("message_count")),
|
||
_numeric_count(cli_meta.get("message_count")),
|
||
)
|
||
elif cli_meta.get("actual_message_count") is not None:
|
||
merged["message_count"] = max(
|
||
_numeric_count(merged.get("message_count")),
|
||
_numeric_count(cli_meta.get("actual_message_count")),
|
||
)
|
||
|
||
if cli_meta.get("title"):
|
||
current_title = merged.get("title")
|
||
if not current_title or current_title == "Untitled":
|
||
merged["title"] = cli_meta["title"]
|
||
|
||
if cli_meta.get("model"):
|
||
if not merged.get("model") or merged.get("model") == "unknown":
|
||
merged["model"] = cli_meta["model"]
|
||
return merged
|
||
|
||
|
||
def _messaging_source_key(session: dict) -> str | None:
|
||
raw = _session_messaging_raw_source(session)
|
||
if not _is_known_messaging_source(raw):
|
||
return None
|
||
return _messaging_session_identity(session, raw)
|
||
|
||
|
||
def _keep_latest_messaging_session_per_source(sessions: list[dict]) -> list[dict]:
|
||
"""Keep only the newest sidebar row per messaging session identity."""
|
||
gateway_metadata = _load_gateway_session_identity_map()
|
||
active_gateway_session_ids = {str(sid) for sid in gateway_metadata.keys() if sid}
|
||
active_gateway_sources = {
|
||
_normalize_messaging_source(_safe_first(meta.get("raw_source"), meta.get("platform")))
|
||
for meta in gateway_metadata.values()
|
||
if isinstance(meta, dict)
|
||
}
|
||
active_gateway_sources = {source for source in active_gateway_sources if _is_known_messaging_source(source)}
|
||
|
||
kept_sources: set[str] = set()
|
||
best_by_source: dict[str, dict] = {}
|
||
kept: list[dict] = []
|
||
for session in sessions:
|
||
key = _messaging_source_key(session)
|
||
if not key:
|
||
kept.append(session)
|
||
continue
|
||
if _should_hide_stale_messaging_session(session, active_gateway_session_ids, active_gateway_sources):
|
||
continue
|
||
if key in kept_sources:
|
||
kept_sources.add(key)
|
||
current = best_by_source.get(key)
|
||
if current is None or _session_sort_timestamp(session) > _session_sort_timestamp(current):
|
||
best_by_source[key] = session
|
||
continue
|
||
kept_sources.add(key)
|
||
best_by_source[key] = session
|
||
|
||
kept.extend(best_by_source.values())
|
||
kept.sort(key=_session_sort_timestamp, reverse=True)
|
||
return kept
|
||
|
||
|
||
from api.models import (
|
||
Session,
|
||
get_session,
|
||
new_session,
|
||
all_sessions,
|
||
title_from,
|
||
_write_session_index,
|
||
SESSION_INDEX_FILE,
|
||
_active_state_db_path,
|
||
load_projects,
|
||
save_projects,
|
||
import_cli_session,
|
||
get_cli_sessions,
|
||
get_cli_session_messages,
|
||
ensure_cron_project,
|
||
is_cron_session,
|
||
)
|
||
from api.workspace import (
|
||
load_workspaces,
|
||
save_workspaces,
|
||
get_last_workspace,
|
||
set_last_workspace,
|
||
list_dir,
|
||
list_workspace_suggestions,
|
||
read_file_content,
|
||
safe_resolve_ws,
|
||
resolve_trusted_workspace,
|
||
validate_workspace_to_add,
|
||
_is_blocked_system_path,
|
||
_strip_surrounding_quotes,
|
||
_workspace_blocked_roots,
|
||
)
|
||
from api.upload import handle_upload, handle_upload_extract, handle_transcribe
|
||
from api.streaming import _sse, _run_agent_streaming, cancel_stream
|
||
from api.providers import get_providers, get_provider_quota, set_provider_key, remove_provider_key
|
||
from api.onboarding import (
|
||
apply_onboarding_setup,
|
||
get_onboarding_status,
|
||
complete_onboarding,
|
||
probe_provider_endpoint,
|
||
)
|
||
from api.oauth import (
|
||
cancel_onboarding_oauth_flow,
|
||
poll_onboarding_oauth_flow,
|
||
start_onboarding_oauth_flow,
|
||
)
|
||
|
||
# Approval system (optional -- graceful fallback if agent not available)
|
||
try:
|
||
from tools.approval import (
|
||
submit_pending as _submit_pending_raw,
|
||
approve_session,
|
||
approve_permanent,
|
||
save_permanent_allowlist,
|
||
is_approved,
|
||
_pending,
|
||
_lock,
|
||
_permanent_approved,
|
||
resolve_gateway_approval,
|
||
enable_session_yolo,
|
||
disable_session_yolo,
|
||
is_session_yolo_enabled,
|
||
)
|
||
except ImportError:
|
||
_submit_pending_raw = lambda *a, **k: None
|
||
approve_session = lambda *a, **k: None
|
||
approve_permanent = lambda *a, **k: None
|
||
save_permanent_allowlist = lambda *a, **k: None
|
||
is_approved = lambda *a, **k: True
|
||
resolve_gateway_approval = lambda *a, **k: 0
|
||
enable_session_yolo = lambda *a, **k: None
|
||
disable_session_yolo = lambda *a, **k: None
|
||
is_session_yolo_enabled = lambda *a, **k: False
|
||
_pending = {}
|
||
_lock = threading.Lock()
|
||
_permanent_approved = set()
|
||
|
||
|
||
# ── Approval SSE subscribers (long-connection push) ──────────────────────────
|
||
_approval_sse_subscribers: dict[str, list[queue.Queue]] = {}
|
||
|
||
|
||
def _approval_sse_subscribe(session_id: str) -> queue.Queue:
|
||
"""Register an SSE subscriber for approval events on a given session."""
|
||
q = queue.Queue(maxsize=16)
|
||
with _lock:
|
||
_approval_sse_subscribers.setdefault(session_id, []).append(q)
|
||
return q
|
||
|
||
|
||
def _approval_sse_unsubscribe(session_id: str, q: queue.Queue) -> None:
|
||
"""Remove an SSE subscriber."""
|
||
with _lock:
|
||
subs = _approval_sse_subscribers.get(session_id)
|
||
if subs and q in subs:
|
||
subs.remove(q)
|
||
if not subs:
|
||
_approval_sse_subscribers.pop(session_id, None)
|
||
|
||
|
||
def _approval_sse_notify_locked(session_id: str, head: dict | None, total: int) -> None:
|
||
"""Push an approval event to all SSE subscribers for a session.
|
||
|
||
CALLER MUST HOLD `_lock`. Snapshots the subscriber list under the held
|
||
lock and then calls `q.put_nowait()` on each (which is itself thread-safe).
|
||
|
||
`head` is the approval entry currently at the head of the queue (the one
|
||
the UI should display) — NOT the just-appended entry. With multiple
|
||
parallel approvals (#527), the just-appended entry is at the TAIL, but
|
||
`/api/approval/pending` always returns the HEAD, so SSE must match.
|
||
|
||
`total` is the total number of pending approvals.
|
||
|
||
Pass `head=None` and `total=0` when the queue has just been emptied (e.g.
|
||
`_handle_approval_respond` popped the last entry) so the client knows to
|
||
hide its approval card.
|
||
"""
|
||
payload = {"pending": dict(head) if head else None, "pending_count": total}
|
||
subs = _approval_sse_subscribers.get(session_id, ())
|
||
for q in subs:
|
||
try:
|
||
q.put_nowait(payload)
|
||
except queue.Full:
|
||
pass # drop if subscriber is slow (bounded queue prevents memory leak)
|
||
|
||
|
||
def _approval_sse_notify(session_id: str, head: dict | None, total: int) -> None:
|
||
"""Convenience wrapper that takes `_lock` itself.
|
||
|
||
Use only from contexts that don't already hold `_lock`. Production call
|
||
sites (submit_pending, _handle_approval_respond) MUST hold the lock and
|
||
call `_approval_sse_notify_locked` directly to avoid a notify-ordering
|
||
race where a later append's notify can fire before an earlier append's
|
||
notify (resulting in stale `pending_count`).
|
||
"""
|
||
with _lock:
|
||
_approval_sse_notify_locked(session_id, head, total)
|
||
|
||
|
||
def submit_pending(session_key: str, approval: dict) -> None:
|
||
"""Append a pending approval to the per-session queue.
|
||
|
||
Wraps the agent's submit_pending to:
|
||
- Add a stable approval_id (uuid4 hex) so the respond endpoint can target
|
||
a specific entry even when multiple approvals are queued simultaneously.
|
||
- Change the storage from a single overwriting dict value to a list, so
|
||
parallel tool calls each get their own approval slot (fixes #527).
|
||
- Notify any connected SSE subscribers immediately.
|
||
"""
|
||
entry = dict(approval)
|
||
entry.setdefault("approval_id", uuid.uuid4().hex)
|
||
with _lock:
|
||
queue_list = _pending.setdefault(session_key, [])
|
||
# Replace a legacy non-list value if the agent version uses the old pattern.
|
||
if not isinstance(queue_list, list):
|
||
_pending[session_key] = [queue_list]
|
||
queue_list = _pending[session_key]
|
||
queue_list.append(entry)
|
||
total = len(queue_list)
|
||
head = queue_list[0] # /api/approval/pending always returns head
|
||
# Push to SSE subscribers from inside _lock so two parallel
|
||
# submit_pending calls can't deliver out-of-order (T2's later
|
||
# notify arriving before T1's earlier notify with a stale count).
|
||
_approval_sse_notify_locked(session_key, head, total)
|
||
# NOTE: We do NOT call _submit_pending_raw here — that function overwrites
|
||
# _pending[session_key] with a single dict, which would undo the list we just
|
||
# built. The gateway blocking path uses _gateway_queues (a separate mechanism
|
||
# managed by check_all_command_guards / register_gateway_notify), which is
|
||
# unaffected by _pending. The _pending dict is only used for UI polling.
|
||
|
||
# Clarify prompts (optional -- graceful fallback if agent not available)
|
||
try:
|
||
from api.clarify import (
|
||
submit_pending as submit_clarify_pending,
|
||
get_pending as get_clarify_pending,
|
||
resolve_clarify,
|
||
sse_subscribe as clarify_sse_subscribe,
|
||
sse_unsubscribe as clarify_sse_unsubscribe,
|
||
)
|
||
except ImportError:
|
||
submit_clarify_pending = lambda *a, **k: None
|
||
get_clarify_pending = lambda *a, **k: None
|
||
clarify_sse_subscribe = None
|
||
resolve_clarify = lambda *a, **k: 0
|
||
|
||
|
||
# ── Login page locale strings ─────────────────────────────────────────────────
|
||
# Add entries here to support more languages on the login page.
|
||
# The key must match the 'language' setting value (from static/i18n.js LOCALES).
|
||
_LOGIN_LOCALE = {
|
||
"en": {
|
||
"lang": "en",
|
||
"title": "Sign in",
|
||
"subtitle": "Enter your password to continue",
|
||
"placeholder": "Password",
|
||
"btn": "Sign in",
|
||
"invalid_pw": "Invalid password",
|
||
"conn_failed": "Connection failed",
|
||
},
|
||
"es": {
|
||
"lang": "es-ES",
|
||
"title": "Iniciar sesi\u00f3n",
|
||
"subtitle": "Introduce tu contrase\u00f1a para continuar",
|
||
"placeholder": "Contrase\u00f1a",
|
||
"btn": "Entrar",
|
||
"invalid_pw": "Contrase\u00f1a inv\u00e1lida",
|
||
"conn_failed": "Error de conexi\u00f3n",
|
||
},
|
||
"de": {
|
||
"lang": "de-DE",
|
||
"title": "Anmelden",
|
||
"subtitle": "Geben Sie Ihr Passwort ein, um fortzufahren",
|
||
"placeholder": "Passwort",
|
||
"btn": "Anmelden",
|
||
"invalid_pw": "Ung\u00fcltiges Passwort",
|
||
"conn_failed": "Verbindung fehlgeschlagen",
|
||
},
|
||
"ru": {
|
||
"lang": "ru-RU",
|
||
"title": "\u0412\u043e\u0439\u0442\u0438",
|
||
"subtitle": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c, \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c",
|
||
"placeholder": "\u041f\u0430\u0440\u043e\u043b\u044c",
|
||
"btn": "\u0412\u043e\u0439\u0442\u0438",
|
||
"invalid_pw": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c",
|
||
"conn_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f",
|
||
},
|
||
"zh": {
|
||
"lang": "zh-CN",
|
||
"title": "\u767b\u5f55",
|
||
"subtitle": "\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528",
|
||
"placeholder": "\u5bc6\u7801",
|
||
"btn": "\u767b\u5f55",
|
||
"invalid_pw": "\u5bc6\u7801\u9519\u8bef",
|
||
"conn_failed": "\u8fde\u63a5\u5931\u8d25",
|
||
},
|
||
"zh-Hant": {
|
||
"lang": "zh-TW",
|
||
"title": "\u767b\u5f55",
|
||
"subtitle": "\u8f38\u5165\u5bc6\u78bc\u7e7c\u7e8c\u4f7f\u7528",
|
||
"placeholder": "\u5bc6\u78bc",
|
||
"btn": "\u767b\u5f55",
|
||
"invalid_pw": "\u5bc6\u78bc\u932f\u8aa4",
|
||
"conn_failed": "\u9023\u63a5\u5931\u6557",
|
||
},
|
||
# Strings mirror static/i18n.js login_* keys for the corresponding locale.
|
||
# See issue #1442. When adding a new locale to LOCALES in i18n.js, also add
|
||
# the matching entry here — tests/test_login_locale_parity.py enforces this.
|
||
"ja": {
|
||
"lang": "ja-JP",
|
||
"title": "\u30b5\u30a4\u30f3\u30a4\u30f3",
|
||
"subtitle": "\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u7d9a\u884c",
|
||
"placeholder": "\u30d1\u30b9\u30ef\u30fc\u30c9",
|
||
"btn": "\u30b5\u30a4\u30f3\u30a4\u30f3",
|
||
"invalid_pw": "\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u7121\u52b9\u3067\u3059",
|
||
"conn_failed": "\u63a5\u7d9a\u5931\u6557",
|
||
},
|
||
"pt": {
|
||
"lang": "pt-BR",
|
||
"title": "Entrar",
|
||
"subtitle": "Digite sua senha para continuar",
|
||
"placeholder": "Senha",
|
||
"btn": "Entrar",
|
||
"invalid_pw": "Senha inv\u00e1lida",
|
||
"conn_failed": "Falha na conex\u00e3o",
|
||
},
|
||
"ko": {
|
||
"lang": "ko-KR",
|
||
"title": "\ub85c\uadf8\uc778",
|
||
"subtitle": "\uacc4\uc18d\ud558\ub824\uba74 \ube44\ubc00\ubc88\ud638\ub97c \uc785\ub825\ud558\uc138\uc694",
|
||
"placeholder": "\ube44\ubc00\ubc88\ud638",
|
||
"btn": "\ub85c\uadf8\uc778",
|
||
"invalid_pw": "\ube44\ubc00\ubc88\ud638\uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4",
|
||
"conn_failed": "\uc5f0\uacb0 \uc2e4\ud328",
|
||
},
|
||
}
|
||
|
||
|
||
def _resolve_login_locale_key(raw_lang: str | None) -> str:
|
||
"""Resolve settings.language to a known _LOGIN_LOCALE key."""
|
||
if not raw_lang:
|
||
return "en"
|
||
lang = str(raw_lang).strip()
|
||
if not lang:
|
||
return "en"
|
||
if lang in _LOGIN_LOCALE:
|
||
return lang
|
||
|
||
normalized = lang.replace("_", "-")
|
||
lower = normalized.lower()
|
||
|
||
# Case-insensitive direct key match first.
|
||
for key in _LOGIN_LOCALE:
|
||
if key.lower() == lower:
|
||
return key
|
||
|
||
# Common Chinese aliases.
|
||
if lower == "zh" or lower.startswith("zh-cn") or lower.startswith("zh-sg") or lower.startswith("zh-hans"):
|
||
return "zh"
|
||
if lower.startswith("zh-tw") or lower.startswith("zh-hk") or lower.startswith("zh-mo") or lower.startswith("zh-hant"):
|
||
return "zh-Hant" if "zh-Hant" in _LOGIN_LOCALE else "zh"
|
||
|
||
# Fallback to base language subtag (e.g. en-US -> en).
|
||
base = lower.split("-", 1)[0]
|
||
for key in _LOGIN_LOCALE:
|
||
if key.lower() == base:
|
||
return key
|
||
return "en"
|
||
|
||
# ── Login page (self-contained, no external deps) ────────────────────────────
|
||
_LOGIN_PAGE_HTML = """<!doctype html>
|
||
<html lang="{{LANG}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>{{BOT_NAME}} — {{LOGIN_TITLE}}</title>
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{background:#1a1a2e;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
|
||
height:100vh;display:flex;align-items:center;justify-content:center}
|
||
.card{background:#16213e;border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:36px 32px;
|
||
width:320px;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.3)}
|
||
.logo{width:48px;height:48px;border-radius:12px;background:linear-gradient(145deg,#e8a030,#e94560);
|
||
display:flex;align-items:center;justify-content:center;font-weight:800;font-size:20px;color:#fff;
|
||
margin:0 auto 12px;box-shadow:0 2px 12px rgba(233,69,96,.3)}
|
||
h1{font-size:18px;font-weight:600;margin-bottom:4px}
|
||
.sub{font-size:12px;color:#8888aa;margin-bottom:24px}
|
||
input{width:100%;padding:10px 14px;border-radius:10px;border:1px solid rgba(255,255,255,.1);
|
||
background:rgba(255,255,255,.04);color:#e8e8f0;font-size:14px;outline:none;margin-bottom:14px;
|
||
transition:border-color .15s}
|
||
input:focus{border-color:rgba(124,185,255,.5);box-shadow:0 0 0 3px rgba(124,185,255,.1)}
|
||
button{width:100%;padding:10px;border-radius:10px;border:none;background:rgba(124,185,255,.15);
|
||
border:1px solid rgba(124,185,255,.3);color:#7cb9ff;font-size:14px;font-weight:600;cursor:pointer;
|
||
transition:all .15s}
|
||
button:hover{background:rgba(124,185,255,.25)}
|
||
.err{color:#e94560;font-size:12px;margin-top:10px;display:none}
|
||
</style></head><body>
|
||
<div class="card">
|
||
<div class="logo">{{BOT_NAME_INITIAL}}</div>
|
||
<h1>{{BOT_NAME}}</h1>
|
||
<p class="sub">{{LOGIN_SUBTITLE}}</p>
|
||
<form id="login-form" data-invalid-pw="{{LOGIN_INVALID_PW}}" data-conn-failed="{{LOGIN_CONN_FAILED}}">
|
||
<input type="password" id="pw" placeholder="{{LOGIN_PLACEHOLDER}}" autofocus>
|
||
<button type="submit">{{LOGIN_BTN}}</button>
|
||
</form>
|
||
<div class="err" id="err"></div>
|
||
</div>
|
||
<!-- Keep login.js relative so subpath mounts load it under the current scope. -->
|
||
<script src="static/login.js?v={{WEBUI_VERSION}}"></script>
|
||
</body></html>"""
|
||
|
||
|
||
# ── Logs endpoint ─────────────────────────────────────────────────────────────
|
||
_LOG_FILE_WHITELIST = {
|
||
"agent": "agent.log",
|
||
"errors": "errors.log",
|
||
"gateway": "gateway.log",
|
||
}
|
||
_LOG_TAIL_VALUES = {100, 200, 500, 1000}
|
||
_LOG_DEFAULT_TAIL = 200
|
||
_LOG_MAX_BYTES = 4 * 1024 * 1024
|
||
|
||
|
||
def _normalize_logs_tail(raw_tail) -> int:
|
||
try:
|
||
tail = int(str(raw_tail or "").strip())
|
||
except (TypeError, ValueError):
|
||
return _LOG_DEFAULT_TAIL
|
||
return tail if tail in _LOG_TAIL_VALUES else _LOG_DEFAULT_TAIL
|
||
|
||
|
||
def _handle_logs(handler, parsed) -> bool:
|
||
"""Return a bounded tail window for an active-profile Hermes log file."""
|
||
query = parse_qs(parsed.query)
|
||
file_key = (query.get("file", ["agent"])[0] or "agent").strip().lower()
|
||
filename = _LOG_FILE_WHITELIST.get(file_key)
|
||
if not filename:
|
||
return bad(handler, "Unknown log file", status=400)
|
||
|
||
tail = _normalize_logs_tail(query.get("tail", [None])[0])
|
||
try:
|
||
from api.profiles import get_active_hermes_home
|
||
|
||
hermes_home = Path(get_active_hermes_home()).expanduser()
|
||
except Exception:
|
||
hermes_home = Path(os.environ.get("HERMES_HOME") or (Path.home() / ".hermes")).expanduser()
|
||
|
||
log_dir = hermes_home / "logs"
|
||
log_path = log_dir / filename
|
||
try:
|
||
# Defense in depth: the filename is hardcoded above, but keep the final
|
||
# path anchored under the active profile's logs directory.
|
||
if log_path.resolve(strict=False).parent != log_dir.resolve(strict=False):
|
||
return bad(handler, "Invalid log file", status=400)
|
||
if not log_path.exists() or not log_path.is_file():
|
||
return j(handler, {
|
||
"file": file_key,
|
||
"tail": tail,
|
||
"lines": [],
|
||
"truncated": False,
|
||
"total_bytes": 0,
|
||
"mtime": None,
|
||
"hint": f"Log file for {file_key} not found yet.",
|
||
})
|
||
st = log_path.stat()
|
||
total_bytes = int(st.st_size)
|
||
read_bytes = min(total_bytes, _LOG_MAX_BYTES)
|
||
with log_path.open("rb") as fh:
|
||
if total_bytes > read_bytes:
|
||
fh.seek(total_bytes - read_bytes)
|
||
raw = fh.read(read_bytes)
|
||
text = raw.decode("utf-8", errors="replace")
|
||
lines = text.splitlines()[-tail:]
|
||
return j(handler, {
|
||
"file": file_key,
|
||
"tail": tail,
|
||
"lines": lines,
|
||
"truncated": total_bytes > read_bytes,
|
||
"total_bytes": total_bytes,
|
||
"mtime": st.st_mtime,
|
||
"hint": "",
|
||
})
|
||
except Exception as exc:
|
||
logger.exception("Failed to read whitelisted log file %s", file_key)
|
||
return bad(handler, _sanitize_error(exc), status=500)
|
||
|
||
# ── Insights endpoint ──────────────────────────────────────────────────────────
|
||
|
||
_LLM_WIKI_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/research/research-llm-wiki"
|
||
_LLM_WIKI_PAGE_DIRS = ("entities", "concepts", "comparisons", "queries")
|
||
|
||
|
||
def _llm_wiki_active_hermes_home() -> Path:
|
||
try:
|
||
from api.profiles import get_active_hermes_home
|
||
return Path(get_active_hermes_home()).expanduser()
|
||
except Exception:
|
||
return Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
|
||
|
||
|
||
def _llm_wiki_env_file_path(hermes_home: Path) -> str | None:
|
||
env_path = hermes_home / ".env"
|
||
if not env_path.exists() or not env_path.is_file():
|
||
return None
|
||
try:
|
||
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||
stripped = line.strip()
|
||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||
continue
|
||
key, value = stripped.split("=", 1)
|
||
if key.strip() != "WIKI_PATH":
|
||
continue
|
||
value = value.strip().strip('"').strip("'")
|
||
return value or None
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _llm_wiki_get_config_path_value(config: dict, dotted_key: str) -> str | None:
|
||
if not isinstance(config, dict):
|
||
return None
|
||
if dotted_key in config and config.get(dotted_key):
|
||
return str(config.get(dotted_key))
|
||
cur = config
|
||
for part in dotted_key.split("."):
|
||
if not isinstance(cur, dict) or part not in cur:
|
||
return None
|
||
cur = cur[part]
|
||
return str(cur) if cur else None
|
||
|
||
|
||
def _llm_wiki_config_path() -> str | None:
|
||
try:
|
||
from api.config import get_config as _get_cfg
|
||
cfg = _get_cfg()
|
||
except Exception:
|
||
return None
|
||
return (
|
||
_llm_wiki_get_config_path_value(cfg, "skills.config.wiki.path")
|
||
or _llm_wiki_get_config_path_value(cfg, "wiki.path")
|
||
)
|
||
|
||
|
||
# Cap WIKI walks to prevent self-DoS if WIKI_PATH points at /, /etc, /home, etc.
|
||
# Real LLM wikis have under a few thousand files; 10k is generous and catches misconfig.
|
||
_LLM_WIKI_MAX_FILES = 10000
|
||
# Refuse to walk these system roots even if explicitly configured.
|
||
_LLM_WIKI_FORBIDDEN_ROOTS = frozenset(
|
||
str(Path(p).expanduser().resolve()) for p in ("/", "/etc", "/usr", "/var", "/opt", "/sys", "/proc")
|
||
)
|
||
|
||
|
||
def _llm_wiki_resolve_path() -> tuple[Path, str, bool]:
|
||
hermes_home = _llm_wiki_active_hermes_home()
|
||
raw = os.getenv("WIKI_PATH") or _llm_wiki_env_file_path(hermes_home)
|
||
source = "WIKI_PATH" if raw else "default"
|
||
configured = bool(raw)
|
||
if not raw:
|
||
raw = _llm_wiki_config_path()
|
||
if raw:
|
||
source = "skills.config.wiki.path"
|
||
configured = True
|
||
if not raw:
|
||
raw = "~/wiki"
|
||
return Path(os.path.expandvars(raw)).expanduser(), source, configured
|
||
|
||
|
||
def _llm_wiki_safe_iso(ts: float | None) -> str | None:
|
||
if not ts:
|
||
return None
|
||
try:
|
||
from datetime import datetime, timezone
|
||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _llm_wiki_count_files(root: Path) -> int:
|
||
if not root.exists() or not root.is_dir():
|
||
return 0
|
||
# Defense in depth: refuse to walk forbidden system roots even if WIKI_PATH
|
||
# was set to one. The endpoint is auth-gated but a misconfigured server
|
||
# shouldn't self-DoS by rglob'ing all of /etc on every Insights load.
|
||
try:
|
||
if str(root.resolve()) in _LLM_WIKI_FORBIDDEN_ROOTS:
|
||
return 0
|
||
except Exception:
|
||
return 0
|
||
count = 0
|
||
iterated = 0
|
||
for item in root.rglob("*"):
|
||
iterated += 1
|
||
if iterated > _LLM_WIKI_MAX_FILES:
|
||
break # bounded — prevents hangs on symlink loops or huge trees
|
||
try:
|
||
if item.is_file() and not any(part.startswith(".") for part in item.relative_to(root).parts):
|
||
count += 1
|
||
except Exception:
|
||
continue
|
||
return count
|
||
|
||
|
||
def _llm_wiki_page_files(wiki_path: Path) -> list[Path]:
|
||
pages: list[Path] = []
|
||
# Defense in depth: refuse forbidden system roots.
|
||
try:
|
||
if str(wiki_path.resolve()) in _LLM_WIKI_FORBIDDEN_ROOTS:
|
||
return pages
|
||
except Exception:
|
||
return pages
|
||
iterated = 0
|
||
for dirname in _LLM_WIKI_PAGE_DIRS:
|
||
section = wiki_path / dirname
|
||
if not section.exists() or not section.is_dir():
|
||
continue
|
||
for item in section.rglob("*.md"):
|
||
iterated += 1
|
||
if iterated > _LLM_WIKI_MAX_FILES:
|
||
return pages # bounded
|
||
try:
|
||
rel = item.relative_to(section)
|
||
if item.is_file() and not any(part.startswith(".") for part in rel.parts):
|
||
pages.append(item)
|
||
except Exception:
|
||
continue
|
||
return pages
|
||
|
||
|
||
def _build_llm_wiki_status() -> dict:
|
||
"""Return private-safe LLM Wiki status metadata without reading page bodies."""
|
||
try:
|
||
wiki_path, path_source, path_configured = _llm_wiki_resolve_path()
|
||
base = {
|
||
"available": False,
|
||
"enabled": False,
|
||
"status": "missing",
|
||
"entry_count": 0,
|
||
"page_count": 0,
|
||
"raw_source_count": 0,
|
||
"last_updated": None,
|
||
"last_writer": None,
|
||
"path_configured": path_configured,
|
||
"path_source": path_source,
|
||
"toggle_available": False,
|
||
"toggle_reason": "Hermes Agent exposes WIKI_PATH/wiki.path for location, but no stable on/off config flag is currently available.",
|
||
"docs_url": _LLM_WIKI_DOCS_URL,
|
||
}
|
||
if not wiki_path.exists():
|
||
return base
|
||
if not wiki_path.is_dir():
|
||
base["status"] = "not_directory"
|
||
return base
|
||
|
||
page_files = _llm_wiki_page_files(wiki_path)
|
||
status_files = [p for p in (wiki_path / "SCHEMA.md", wiki_path / "index.md", wiki_path / "log.md") if p.exists() and p.is_file()]
|
||
status_files.extend(page_files)
|
||
latest = None
|
||
for item in status_files:
|
||
try:
|
||
mtime = item.stat().st_mtime
|
||
except Exception:
|
||
continue
|
||
latest = mtime if latest is None else max(latest, mtime)
|
||
|
||
base.update({
|
||
"available": True,
|
||
"enabled": True,
|
||
"status": "ready" if page_files else "empty",
|
||
"entry_count": len(page_files),
|
||
"page_count": len(page_files),
|
||
"raw_source_count": _llm_wiki_count_files(wiki_path / "raw"),
|
||
"last_updated": _llm_wiki_safe_iso(latest),
|
||
})
|
||
return base
|
||
except Exception as exc:
|
||
return {
|
||
"available": False,
|
||
"enabled": False,
|
||
"status": "error",
|
||
"entry_count": 0,
|
||
"page_count": 0,
|
||
"raw_source_count": 0,
|
||
"last_updated": None,
|
||
"last_writer": None,
|
||
"path_configured": False,
|
||
"path_source": "unknown",
|
||
"toggle_available": False,
|
||
"toggle_reason": "Unable to inspect LLM Wiki status safely.",
|
||
"docs_url": _LLM_WIKI_DOCS_URL,
|
||
"error": type(exc).__name__,
|
||
}
|
||
|
||
|
||
def _handle_llm_wiki_status(handler, parsed) -> bool:
|
||
j(handler, _build_llm_wiki_status())
|
||
return True
|
||
|
||
|
||
def _handle_insights(handler, parsed) -> bool:
|
||
"""Return usage analytics from local WebUI session data."""
|
||
import collections
|
||
import time as _time
|
||
|
||
query = parse_qs(parsed.query)
|
||
try:
|
||
days = min(max(int(query.get("days", ["30"])[0]), 1), 365)
|
||
except (ValueError, TypeError):
|
||
days = 30
|
||
|
||
now = _time.time()
|
||
today = _time.localtime(now)
|
||
today_midnight = _time.mktime((today.tm_year, today.tm_mon, today.tm_mday, 0, 0, 0, today.tm_wday, today.tm_yday, today.tm_isdst))
|
||
day_secs = 86400
|
||
first_day_ts = today_midnight - ((days - 1) * day_secs)
|
||
cutoff = first_day_ts
|
||
|
||
def _safe_usage_int(value) -> int:
|
||
try:
|
||
return max(int(float(value or 0)), 0)
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
def _safe_cost_float(value) -> float:
|
||
if value is None:
|
||
return 0.0
|
||
try:
|
||
if isinstance(value, str):
|
||
value = value.strip().replace("$", "").replace(",", "")
|
||
if not value:
|
||
return 0.0
|
||
return max(float(value), 0.0)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
|
||
def _session_usage_ts(session: dict) -> float:
|
||
return session.get("updated_at", session.get("created_at", 0)) or session.get("created_at", 0) or 0
|
||
|
||
# Walk session index (fast, no full JSON parse)
|
||
sessions_data = []
|
||
idx_path = SESSION_DIR / "_index.json"
|
||
if idx_path.exists():
|
||
try:
|
||
idx = json.loads(idx_path.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
idx = []
|
||
else:
|
||
idx = []
|
||
|
||
for entry in idx:
|
||
created = entry.get("created_at", 0) or 0
|
||
updated = entry.get("updated_at", 0) or 0
|
||
# Session is relevant if it was created or updated within the calendar window.
|
||
if max(created, updated) < cutoff:
|
||
continue
|
||
sessions_data.append(entry)
|
||
|
||
# Aggregate
|
||
total_sessions = len(sessions_data)
|
||
total_messages = 0
|
||
total_input_tokens = 0
|
||
total_output_tokens = 0
|
||
total_cost = 0.0
|
||
model_stats: dict[str, dict] = {}
|
||
daily_tokens: dict[str, dict] = {}
|
||
# Activity by day of week (0=Mon .. 6=Sun)
|
||
dow_activity = collections.Counter()
|
||
# Activity by hour of day (0-23)
|
||
hod_activity = collections.Counter()
|
||
|
||
for s in sessions_data:
|
||
input_tokens = _safe_usage_int(s.get("input_tokens"))
|
||
output_tokens = _safe_usage_int(s.get("output_tokens"))
|
||
cost_value = _safe_cost_float(s.get("estimated_cost"))
|
||
total_messages += _safe_usage_int(s.get("message_count"))
|
||
total_input_tokens += input_tokens
|
||
total_output_tokens += output_tokens
|
||
total_cost += cost_value
|
||
|
||
model = s.get("model") or "unknown"
|
||
bucket = model_stats.setdefault(model, {
|
||
"sessions": 0,
|
||
"input_tokens": 0,
|
||
"output_tokens": 0,
|
||
"cost": 0.0,
|
||
})
|
||
bucket["sessions"] += 1
|
||
bucket["input_tokens"] += input_tokens
|
||
bucket["output_tokens"] += output_tokens
|
||
bucket["cost"] += cost_value
|
||
|
||
# Activity patterns
|
||
ts = _session_usage_ts(s)
|
||
if ts:
|
||
try:
|
||
dt = _time.localtime(ts)
|
||
day_key = _time.strftime("%Y-%m-%d", dt)
|
||
daily_bucket = daily_tokens.setdefault(day_key, {
|
||
"input_tokens": 0,
|
||
"output_tokens": 0,
|
||
"sessions": 0,
|
||
"cost": 0.0,
|
||
})
|
||
daily_bucket["input_tokens"] += input_tokens
|
||
daily_bucket["output_tokens"] += output_tokens
|
||
daily_bucket["sessions"] += 1
|
||
daily_bucket["cost"] += cost_value
|
||
dow_activity[dt.tm_wday] += 1
|
||
hod_activity[dt.tm_hour] += 1
|
||
except Exception:
|
||
pass
|
||
|
||
# Build model breakdown
|
||
total_tokens = total_input_tokens + total_output_tokens
|
||
models_breakdown = []
|
||
for model, stats in model_stats.items():
|
||
row_total_tokens = stats["input_tokens"] + stats["output_tokens"]
|
||
row_cost = round(stats["cost"], 6)
|
||
models_breakdown.append({
|
||
"model": model,
|
||
"sessions": stats["sessions"],
|
||
"input_tokens": stats["input_tokens"],
|
||
"output_tokens": stats["output_tokens"],
|
||
"total_tokens": row_total_tokens,
|
||
"cost": row_cost,
|
||
"session_share": int(round((stats["sessions"] / total_sessions) * 100)) if total_sessions else 0,
|
||
"token_share": int(round((row_total_tokens / total_tokens) * 100)) if total_tokens else 0,
|
||
"cost_share": int(round((row_cost / total_cost) * 100)) if total_cost else 0,
|
||
})
|
||
models_breakdown.sort(key=lambda r: (-r["cost"], -r["sessions"], r["model"]))
|
||
|
||
daily_series = []
|
||
for i in range(days):
|
||
day_ts = first_day_ts + (i * day_secs)
|
||
day_key = _time.strftime("%Y-%m-%d", _time.localtime(day_ts))
|
||
bucket = daily_tokens.get(day_key, {
|
||
"input_tokens": 0,
|
||
"output_tokens": 0,
|
||
"sessions": 0,
|
||
"cost": 0.0,
|
||
})
|
||
daily_series.append({
|
||
"date": day_key,
|
||
"input_tokens": bucket["input_tokens"],
|
||
"output_tokens": bucket["output_tokens"],
|
||
"sessions": bucket["sessions"],
|
||
"cost": round(bucket["cost"], 6),
|
||
})
|
||
|
||
# Day-of-week labels
|
||
dow_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||
dow_data = [{"day": dow_labels[i], "sessions": dow_activity.get(i, 0)} for i in range(7)]
|
||
|
||
# Hour-of-day data
|
||
hod_data = [{"hour": h, "sessions": hod_activity.get(h, 0)} for h in range(24)]
|
||
|
||
return j(handler, {
|
||
"period_days": days,
|
||
"total_sessions": total_sessions,
|
||
"total_messages": total_messages,
|
||
"total_input_tokens": total_input_tokens,
|
||
"total_output_tokens": total_output_tokens,
|
||
"total_tokens": total_tokens,
|
||
"total_cost": round(total_cost, 6),
|
||
"models": models_breakdown,
|
||
"daily_tokens": daily_series,
|
||
"activity_by_day": dow_data,
|
||
"activity_by_hour": hod_data,
|
||
})
|
||
|
||
|
||
# ── GET routes ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _accept_loop_health(handler) -> dict:
|
||
server = getattr(handler, "server", None)
|
||
return {
|
||
"requests_total": int(getattr(server, "accept_loop_requests_total", 0) or 0),
|
||
"last_request_at": round(float(getattr(server, "accept_loop_last_request_at", 0.0) or 0.0), 3),
|
||
}
|
||
|
||
|
||
def _streams_lock_health(timeout_seconds: float = 0.5) -> dict:
|
||
t0 = time.time()
|
||
acquired = STREAMS_LOCK.acquire(timeout=timeout_seconds)
|
||
elapsed_ms = round((time.time() - t0) * 1000, 1)
|
||
if not acquired:
|
||
return {
|
||
"status": "blocked",
|
||
"timeout_seconds": timeout_seconds,
|
||
"ms": elapsed_ms,
|
||
}
|
||
try:
|
||
return {
|
||
"status": "ok",
|
||
"active_streams": len(STREAMS),
|
||
"ms": elapsed_ms,
|
||
}
|
||
finally:
|
||
STREAMS_LOCK.release()
|
||
|
||
|
||
def _deep_health_checks(stream_check: dict | None = None) -> tuple[dict, bool]:
|
||
"""Run cheap probes that exercise the state paths used by the UI shell.
|
||
|
||
Plain /health intentionally stays tiny. /health?deep=1 is for supervisors
|
||
and watchdogs that need to know whether the process can still touch the
|
||
shared stream map, sidebar/session path, project state, and Hermes state.db
|
||
without hitting the RST-before-write failure mode from #1458.
|
||
|
||
`stream_check` is the result from a prior `_streams_lock_health()` call;
|
||
if provided, it's reused so we don't acquire `STREAMS_LOCK` twice on the
|
||
same /health?deep=1 request (per Opus advisor on stage-297).
|
||
"""
|
||
checks: dict[str, dict] = {}
|
||
|
||
checks["streams_lock"] = stream_check if stream_check is not None else _streams_lock_health()
|
||
if checks["streams_lock"].get("status") != "ok":
|
||
return checks, False
|
||
|
||
t0 = time.time()
|
||
try:
|
||
sessions = all_sessions()
|
||
checks["sessions"] = {
|
||
"status": "ok",
|
||
"count": len(sessions),
|
||
"ms": round((time.time() - t0) * 1000, 1),
|
||
}
|
||
except Exception as exc:
|
||
checks["sessions"] = {
|
||
"status": "error",
|
||
"error": type(exc).__name__,
|
||
"ms": round((time.time() - t0) * 1000, 1),
|
||
}
|
||
|
||
t0 = time.time()
|
||
try:
|
||
projects = load_projects(_migrate=False)
|
||
checks["projects"] = {
|
||
"status": "ok",
|
||
"count": len(projects),
|
||
"ms": round((time.time() - t0) * 1000, 1),
|
||
}
|
||
except Exception as exc:
|
||
checks["projects"] = {
|
||
"status": "error",
|
||
"error": type(exc).__name__,
|
||
"ms": round((time.time() - t0) * 1000, 1),
|
||
}
|
||
|
||
t0 = time.time()
|
||
try:
|
||
db_path = _active_state_db_path()
|
||
if not db_path.exists():
|
||
checks["state_db"] = {
|
||
"status": "missing",
|
||
"ms": round((time.time() - t0) * 1000, 1),
|
||
}
|
||
else:
|
||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||
conn.execute("PRAGMA schema_version").fetchone()
|
||
checks["state_db"] = {
|
||
"status": "ok",
|
||
"ms": round((time.time() - t0) * 1000, 1),
|
||
}
|
||
except Exception as exc:
|
||
checks["state_db"] = {
|
||
"status": "error",
|
||
"error": type(exc).__name__,
|
||
"ms": round((time.time() - t0) * 1000, 1),
|
||
}
|
||
|
||
healthy = all(
|
||
check.get("status") in {"ok", "missing"}
|
||
for check in checks.values()
|
||
)
|
||
return checks, healthy
|
||
|
||
|
||
def _handle_health(handler, parsed):
|
||
deep = parse_qs(parsed.query or "").get("deep", [""])[0].lower() in {"1", "true", "yes", "on"}
|
||
stream_check = _streams_lock_health()
|
||
payload = {
|
||
"status": "ok" if stream_check.get("status") == "ok" else "degraded",
|
||
"sessions": len(SESSIONS),
|
||
"active_streams": int(stream_check.get("active_streams") or 0),
|
||
"uptime_seconds": round(time.time() - SERVER_START_TIME, 1),
|
||
"accept_loop": _accept_loop_health(handler),
|
||
}
|
||
if deep:
|
||
if stream_check.get("status") != "ok":
|
||
payload["checks"] = {"streams_lock": stream_check}
|
||
return j(handler, payload, status=503)
|
||
checks, healthy = _deep_health_checks(stream_check=stream_check)
|
||
payload["checks"] = checks
|
||
if not healthy:
|
||
payload["status"] = "degraded"
|
||
return j(handler, payload, status=503)
|
||
if payload["status"] != "ok":
|
||
return j(handler, payload, status=503)
|
||
return j(handler, payload)
|
||
|
||
|
||
# ── Plugin visibility endpoint (#539) ───────────────────────────────────────
|
||
_PLUGIN_VISIBILITY_HOOKS = (
|
||
"pre_tool_call",
|
||
"post_tool_call",
|
||
"pre_llm_call",
|
||
"post_llm_call",
|
||
)
|
||
_PLUGIN_VISIBILITY_HOOK_SET = set(_PLUGIN_VISIBILITY_HOOKS)
|
||
|
||
|
||
def _get_plugin_manager_for_visibility():
|
||
"""Return Hermes Agent's plugin manager for read-only WebUI visibility."""
|
||
from hermes_cli.plugins import get_plugin_manager
|
||
|
||
return get_plugin_manager()
|
||
|
||
|
||
def _clean_plugin_visibility_text(value, *, limit=240) -> str:
|
||
"""Return bounded display text without path/callback-like internals."""
|
||
if value is None:
|
||
return ""
|
||
text = str(value).replace("\x00", "").strip()
|
||
# Display metadata should be plain labels/descriptions. Drop multiline text
|
||
# and common path separators rather than risk leaking local plugin paths.
|
||
text = " ".join(text.split())
|
||
if len(text) > limit:
|
||
text = text[: limit - 1].rstrip() + "…"
|
||
return text
|
||
|
||
|
||
def _plugin_visibility_payload(manager=None) -> dict:
|
||
"""Build a sanitized plugin/hook visibility payload for Settings.
|
||
|
||
The Hermes Agent manager stores manifests and callback objects internally.
|
||
This endpoint intentionally exposes only safe, user-facing metadata and the
|
||
four lifecycle hook names called out by the Settings visibility MVP. It
|
||
never includes plugin source paths, callback names, callback reprs, or raw
|
||
load errors because those can contain private filesystem details.
|
||
"""
|
||
manager = manager or _get_plugin_manager_for_visibility()
|
||
manager.discover_and_load(force=False)
|
||
|
||
plugins = []
|
||
raw_plugins = getattr(manager, "_plugins", {}) or {}
|
||
for key, loaded in sorted(raw_plugins.items(), key=lambda item: str(item[0])):
|
||
manifest = getattr(loaded, "manifest", None)
|
||
if manifest is None:
|
||
continue
|
||
plugin_key = _clean_plugin_visibility_text(
|
||
getattr(manifest, "key", None) or key or getattr(manifest, "name", ""),
|
||
limit=120,
|
||
)
|
||
name = _clean_plugin_visibility_text(getattr(manifest, "name", "") or plugin_key, limit=120)
|
||
version = _clean_plugin_visibility_text(getattr(manifest, "version", ""), limit=80)
|
||
description = _clean_plugin_visibility_text(getattr(manifest, "description", ""), limit=280)
|
||
registered = []
|
||
for hook in list(getattr(manifest, "provides_hooks", []) or []) + list(getattr(loaded, "hooks_registered", []) or []):
|
||
hook_name = str(hook or "").strip()
|
||
if hook_name in _PLUGIN_VISIBILITY_HOOK_SET and hook_name not in registered:
|
||
registered.append(hook_name)
|
||
registered.sort(key=_PLUGIN_VISIBILITY_HOOKS.index)
|
||
plugins.append({
|
||
"name": name,
|
||
"key": plugin_key or name,
|
||
"version": version,
|
||
"description": description,
|
||
"enabled": bool(getattr(loaded, "enabled", False)),
|
||
"hooks": registered,
|
||
})
|
||
|
||
return {
|
||
"plugins": plugins,
|
||
"empty": not bool(plugins),
|
||
"supported_hooks": list(_PLUGIN_VISIBILITY_HOOKS),
|
||
"read_only": True,
|
||
}
|
||
|
||
|
||
def _handle_plugins(handler, parsed) -> bool:
|
||
try:
|
||
return j(handler, _plugin_visibility_payload())
|
||
except Exception as exc:
|
||
logger.warning("Failed to build plugin visibility payload: %s", exc)
|
||
return j(
|
||
handler,
|
||
{
|
||
"plugins": [],
|
||
"empty": True,
|
||
"supported_hooks": list(_PLUGIN_VISIBILITY_HOOKS),
|
||
"read_only": True,
|
||
"unavailable": True,
|
||
},
|
||
)
|
||
|
||
|
||
def handle_get(handler, parsed) -> bool:
|
||
"""Handle all GET routes. Returns True if handled, False for 404."""
|
||
|
||
if parsed.path.startswith("/session/static/"):
|
||
# Strip the leading "/session" so _serve_static() sees a path that
|
||
# starts with "/static/" (its required prefix). _serve_static enforces
|
||
# its own path-traversal sandbox via Path.resolve()+relative_to().
|
||
stripped = parsed._replace(path=parsed.path[len("/session"):])
|
||
return _serve_static(handler, stripped)
|
||
|
||
if parsed.path in ("/", "/index.html") or parsed.path.startswith("/session/"):
|
||
from urllib.parse import quote
|
||
from api.updates import WEBUI_VERSION
|
||
version_token = quote(WEBUI_VERSION, safe="")
|
||
from api.extensions import inject_extension_tags
|
||
|
||
html = _INDEX_HTML_PATH.read_text(encoding="utf-8").replace("__WEBUI_VERSION__", version_token)
|
||
return t(
|
||
handler,
|
||
inject_extension_tags(html),
|
||
content_type="text/html; charset=utf-8",
|
||
)
|
||
|
||
if parsed.path == "/login":
|
||
_settings = load_settings()
|
||
_bn = _html.escape(_settings.get("bot_name") or "Hermes")
|
||
_lang = _settings.get("language", "en")
|
||
_login_strings = _LOGIN_LOCALE[
|
||
_resolve_login_locale_key(_lang)
|
||
]
|
||
from urllib.parse import quote
|
||
from api.updates import WEBUI_VERSION
|
||
version_token = quote(WEBUI_VERSION, safe="")
|
||
_page = (
|
||
_LOGIN_PAGE_HTML.replace("{{BOT_NAME}}", _bn)
|
||
.replace("{{BOT_NAME_INITIAL}}", _bn[0].upper())
|
||
.replace("{{WEBUI_VERSION}}", version_token)
|
||
.replace("{{LANG}}", _html.escape(_login_strings["lang"]))
|
||
.replace("{{LOGIN_TITLE}}", _html.escape(_login_strings["title"]))
|
||
.replace("{{LOGIN_SUBTITLE}}", _html.escape(_login_strings["subtitle"]))
|
||
.replace(
|
||
"{{LOGIN_PLACEHOLDER}}", _html.escape(_login_strings["placeholder"])
|
||
)
|
||
.replace("{{LOGIN_BTN}}", _html.escape(_login_strings["btn"]))
|
||
.replace("{{LOGIN_INVALID_PW}}", _html.escape(_login_strings["invalid_pw"]))
|
||
.replace(
|
||
"{{LOGIN_CONN_FAILED}}", _html.escape(_login_strings["conn_failed"])
|
||
)
|
||
)
|
||
return t(handler, _page, content_type="text/html; charset=utf-8")
|
||
|
||
if parsed.path == "/api/auth/status":
|
||
from api.auth import is_auth_enabled, parse_cookie, verify_session
|
||
|
||
logged_in = False
|
||
if is_auth_enabled():
|
||
cv = parse_cookie(handler)
|
||
logged_in = bool(cv and verify_session(cv))
|
||
return j(handler, {"auth_enabled": is_auth_enabled(), "logged_in": logged_in})
|
||
|
||
if parsed.path in ("/manifest.json", "/manifest.webmanifest"):
|
||
static_root = Path(__file__).parent.parent / "static"
|
||
manifest_path = (static_root / "manifest.json").resolve()
|
||
if manifest_path.exists():
|
||
data = manifest_path.read_bytes()
|
||
handler.send_response(200)
|
||
handler.send_header("Content-Type", "application/manifest+json; charset=utf-8")
|
||
handler.send_header("Cache-Control", "no-store")
|
||
handler.send_header("Content-Length", str(len(data)))
|
||
handler.end_headers()
|
||
handler.wfile.write(data)
|
||
return True
|
||
return j(handler, {"error": "not found"}, status=404)
|
||
|
||
if parsed.path == "/sw.js":
|
||
static_root = Path(__file__).parent.parent / "static"
|
||
sw_path = (static_root / "sw.js").resolve()
|
||
if sw_path.exists():
|
||
# Inject the current git-derived version as the cache name so the
|
||
# service worker cache busts automatically on every new deploy.
|
||
from urllib.parse import quote
|
||
from api.updates import WEBUI_VERSION
|
||
version_token = quote(WEBUI_VERSION, safe="")
|
||
text = sw_path.read_text(encoding="utf-8").replace(
|
||
"__WEBUI_VERSION__", version_token
|
||
)
|
||
data = text.encode("utf-8")
|
||
handler.send_response(200)
|
||
handler.send_header("Content-Type", "application/javascript; charset=utf-8")
|
||
handler.send_header("Cache-Control", "no-store")
|
||
handler.send_header("Service-Worker-Allowed", "/")
|
||
handler.send_header("Content-Length", str(len(data)))
|
||
handler.end_headers()
|
||
handler.wfile.write(data)
|
||
return True
|
||
return j(handler, {"error": "not found"}, status=404)
|
||
|
||
if parsed.path == "/favicon.ico":
|
||
static_root = Path(__file__).parent.parent / "static"
|
||
ico_path = (static_root / "favicon.ico").resolve()
|
||
if ico_path.exists() and ico_path.is_file():
|
||
data = ico_path.read_bytes()
|
||
handler.send_response(200)
|
||
handler.send_header("Content-Type", "image/x-icon")
|
||
handler.send_header("Content-Length", str(len(data)))
|
||
handler.send_header("Cache-Control", "public, max-age=86400")
|
||
handler.end_headers()
|
||
handler.wfile.write(data)
|
||
else:
|
||
handler.send_response(204)
|
||
handler.end_headers()
|
||
return True
|
||
|
||
# ── Insights / knowledge status ──
|
||
if parsed.path == "/api/insights":
|
||
return _handle_insights(handler, parsed)
|
||
|
||
if parsed.path.startswith("/api/kanban/"):
|
||
from api.kanban_bridge import handle_kanban_get
|
||
|
||
return handle_kanban_get(handler, parsed)
|
||
if parsed.path == "/api/wiki/status":
|
||
return _handle_llm_wiki_status(handler, parsed)
|
||
if parsed.path == "/api/logs":
|
||
return _handle_logs(handler, parsed)
|
||
|
||
if parsed.path == "/health":
|
||
return _handle_health(handler, parsed)
|
||
|
||
if parsed.path == "/api/health/agent":
|
||
return j(handler, build_agent_health_payload())
|
||
|
||
if parsed.path == "/api/system/health":
|
||
j(handler, build_system_health_payload())
|
||
return True
|
||
|
||
if parsed.path == "/api/models":
|
||
return j(handler, get_available_models())
|
||
|
||
if parsed.path == "/api/models/live":
|
||
return _handle_live_models(handler, parsed)
|
||
|
||
if parsed.path == "/api/dashboard/status":
|
||
from api import dashboard_probe
|
||
|
||
j(handler, dashboard_probe.get_dashboard_status())
|
||
return True
|
||
|
||
if parsed.path == "/api/dashboard/config":
|
||
from api import dashboard_probe
|
||
|
||
try:
|
||
j(handler, dashboard_probe.get_dashboard_config())
|
||
except ValueError as exc:
|
||
bad(handler, str(exc), status=400)
|
||
return True
|
||
|
||
# ── Providers (GET) ──
|
||
if parsed.path == "/api/providers":
|
||
return j(handler, get_providers())
|
||
|
||
# ── Plugins/hooks visibility (read-only, no callback/source internals) ──
|
||
if parsed.path == "/api/plugins":
|
||
return _handle_plugins(handler, parsed)
|
||
if parsed.path == "/api/provider/quota":
|
||
query = parse_qs(parsed.query)
|
||
provider_id = (query.get("provider", [""])[0] or None)
|
||
return j(handler, get_provider_quota(provider_id))
|
||
|
||
if parsed.path == "/api/settings":
|
||
settings = load_settings()
|
||
# Never expose the stored password hash to clients
|
||
settings.pop("password_hash", None)
|
||
# Surface env-var precedence so the UI can disable the password field
|
||
# instead of silently no-oping the save (#1560). The setting takes
|
||
# precedence in api.auth.get_password_hash(), but until now the UI
|
||
# had no way to know — see issue #1139 / #1560.
|
||
settings["password_env_var"] = bool(
|
||
os.getenv("HERMES_WEBUI_PASSWORD", "").strip()
|
||
)
|
||
# Inject the running version so the UI badge stays in sync with git tags
|
||
# without any manual release step.
|
||
try:
|
||
from api.updates import AGENT_VERSION, WEBUI_VERSION
|
||
settings["webui_version"] = WEBUI_VERSION
|
||
settings["agent_version"] = AGENT_VERSION
|
||
except Exception:
|
||
pass
|
||
return j(handler, settings)
|
||
|
||
if parsed.path == "/api/reasoning":
|
||
# Current reasoning config (shared source of truth with the CLI —
|
||
# reads display.show_reasoning and agent.reasoning_effort from
|
||
# the active profile's config.yaml).
|
||
return j(handler, get_reasoning_status())
|
||
|
||
if parsed.path == "/api/onboarding/status":
|
||
return j(handler, get_onboarding_status())
|
||
|
||
if parsed.path.startswith("/extensions/"):
|
||
from api.extensions import serve_extension_static
|
||
|
||
return serve_extension_static(handler, parsed)
|
||
|
||
if parsed.path.startswith("/static/"):
|
||
return _serve_static(handler, parsed)
|
||
|
||
if parsed.path == "/api/session":
|
||
import time as _time
|
||
_t0 = _time.monotonic()
|
||
_debug_slow = os.environ.get("HERMES_DEBUG_SLOW", "")
|
||
query = parse_qs(parsed.query)
|
||
sid = query.get("session_id", [""])[0]
|
||
if not sid:
|
||
return j(handler, {"error": "session_id is required"}, status=400)
|
||
# ?messages=0 skips the message payload for fast session switching.
|
||
# The frontend uses this when switching conversations in the sidebar
|
||
# (only needs metadata). The full message array is loaded lazily
|
||
# via ?messages=1 when the message panel opens.
|
||
load_messages = query.get("messages", ["1"])[0] != "0"
|
||
resolve_model_default = "1" if load_messages else "0"
|
||
resolve_model = query.get("resolve_model", [resolve_model_default])[0] != "0"
|
||
# ?msg_limit=N returns only the last N messages (tail window).
|
||
# Used by the frontend for fast session switching — avoids serialising
|
||
# and sending hundreds of messages when the user only sees the most
|
||
# recent exchange. Older messages are loaded on-demand via scrolling.
|
||
_msg_limit = query.get("msg_limit", [None])[0]
|
||
try:
|
||
msg_limit = max(1, int(_msg_limit)) if _msg_limit else None
|
||
except (ValueError, TypeError):
|
||
msg_limit = None
|
||
# ?msg_before=N — 0-based index into the full message array.
|
||
# Returns messages before this index (for scroll-to-top lazy loading).
|
||
# Combined with msg_limit for paging.
|
||
_msg_before = query.get("msg_before", [None])[0]
|
||
try:
|
||
msg_before = int(_msg_before) if _msg_before else None
|
||
except (ValueError, TypeError):
|
||
msg_before = None
|
||
try:
|
||
_t1 = _time.monotonic()
|
||
s = get_session(sid, metadata_only=(not load_messages))
|
||
_clear_stale_stream_state(s)
|
||
cli_meta = _lookup_cli_session_metadata(sid)
|
||
is_messaging_session = _is_messaging_session_record(s) or _is_messaging_session_record(cli_meta)
|
||
cli_messages = []
|
||
if is_messaging_session:
|
||
cli_messages = get_cli_session_messages(sid)
|
||
_t2 = _time.monotonic()
|
||
effective_model = (
|
||
_resolve_effective_session_model_for_display(s)
|
||
if resolve_model
|
||
else None
|
||
)
|
||
effective_provider = (
|
||
_resolve_effective_session_model_provider_for_display(s)
|
||
if resolve_model
|
||
else None
|
||
)
|
||
_t3 = _time.monotonic()
|
||
if load_messages:
|
||
if is_messaging_session and cli_messages:
|
||
sidecar_messages = getattr(s, "messages", []) or []
|
||
# Recovery/aggregate sidecars can intentionally contain a
|
||
# longer visible conversation than the single state.db
|
||
# segment for this messaging session id. Prefer the longer
|
||
# sidecar so repaired WebUI history is not hidden behind the
|
||
# canonical per-segment transcript.
|
||
_all_msgs = sidecar_messages if len(sidecar_messages) > len(cli_messages) else cli_messages
|
||
else:
|
||
_all_msgs = s.messages
|
||
else:
|
||
_all_msgs = []
|
||
if load_messages:
|
||
if msg_before is not None:
|
||
# Scroll-to-top paging: msg_before is a 0-based index into
|
||
# the full message list. Return the msg_limit messages that
|
||
# appear *before* this index (i.e. older messages).
|
||
# Using index instead of timestamp avoids issues with
|
||
# duplicate/missing timestamps.
|
||
_before_idx = max(0, min(int(msg_before), len(_all_msgs)))
|
||
_slice = _all_msgs[:_before_idx]
|
||
_truncated_msgs = _slice[-msg_limit:] if msg_limit else _slice
|
||
elif msg_limit and len(_all_msgs) > msg_limit:
|
||
_truncated_msgs = _all_msgs[-msg_limit:]
|
||
else:
|
||
_truncated_msgs = _all_msgs
|
||
else:
|
||
_truncated_msgs = _all_msgs
|
||
# Resolve effective context_length with model-metadata fallback so
|
||
# older sessions (pre-#1318) that have context_length=0 persisted
|
||
# still render a meaningful indicator on load. Mirrors the
|
||
# SSE-path fallback in api/streaming.py:2333-2342. Fixes #1436.
|
||
_persisted_cl = getattr(s, "context_length", 0) or 0
|
||
if not _persisted_cl:
|
||
_model_for_lookup = (
|
||
getattr(s, "model", "") or effective_model or ""
|
||
).strip()
|
||
if _model_for_lookup:
|
||
try:
|
||
from agent.model_metadata import get_model_context_length as _get_cl
|
||
_fb_cl = _get_cl(_model_for_lookup, "") or 0
|
||
if _fb_cl:
|
||
_persisted_cl = _fb_cl
|
||
except Exception:
|
||
pass
|
||
raw = s.compact() | {
|
||
"messages": _truncated_msgs,
|
||
"tool_calls": getattr(s, "tool_calls", []) if load_messages else [],
|
||
"active_stream_id": getattr(s, "active_stream_id", None),
|
||
"pending_user_message": getattr(s, "pending_user_message", None),
|
||
"pending_attachments": getattr(s, "pending_attachments", []) if load_messages else [],
|
||
"pending_started_at": getattr(s, "pending_started_at", None),
|
||
"context_length": _persisted_cl,
|
||
"threshold_tokens": getattr(s, "threshold_tokens", 0) or 0,
|
||
"last_prompt_tokens": getattr(s, "last_prompt_tokens", 0) or 0,
|
||
}
|
||
if cli_meta and _is_messaging_session_record(cli_meta):
|
||
raw = _merge_cli_sidebar_metadata(raw, cli_meta)
|
||
# Signal to the frontend that older messages were omitted.
|
||
# For msg_before paging, compare against the filtered set,
|
||
# not the full list — otherwise we signal truncation even when
|
||
# all older messages were returned.
|
||
if msg_before is not None:
|
||
_truncated = load_messages and msg_limit is not None and len(_slice) > msg_limit
|
||
else:
|
||
_truncated = load_messages and msg_limit is not None and len(_all_msgs) > msg_limit
|
||
raw["_messages_truncated"] = _truncated
|
||
# Index of the first returned message in the full message array.
|
||
# Frontend uses this as cursor for scroll-to-top paging.
|
||
if msg_before is not None:
|
||
raw["_messages_offset"] = max(0, _before_idx - len(_truncated_msgs))
|
||
else:
|
||
raw["_messages_offset"] = max(0, len(_all_msgs) - len(_truncated_msgs))
|
||
_t4 = _time.monotonic()
|
||
if effective_model:
|
||
raw["model"] = effective_model
|
||
if effective_provider:
|
||
raw["model_provider"] = effective_provider
|
||
redact = redact_session_data(raw)
|
||
_t5 = _time.monotonic()
|
||
resp = j(handler, {"session": redact})
|
||
_t6 = _time.monotonic()
|
||
if _debug_slow:
|
||
logger.warning(
|
||
"[SLOW] session_id=%s get_session=%.1fms model_resolve=%.1fms "
|
||
"compact=%.1fms redact=%.1fms json_write=%.1fms total=%.1fms",
|
||
sid,
|
||
(_t2-_t1)*1000, (_t3-_t2)*1000, (_t4-_t3)*1000,
|
||
(_t5-_t4)*1000, (_t6-_t5)*1000, (_t6-_t0)*1000,
|
||
)
|
||
return resp
|
||
except KeyError:
|
||
# Not a WebUI session -- try CLI store
|
||
cli_meta = _lookup_cli_session_metadata(sid)
|
||
msgs = get_cli_session_messages(sid)
|
||
if msgs:
|
||
sess = {
|
||
"session_id": sid,
|
||
"title": (cli_meta or {}).get("title", "CLI Session"),
|
||
"workspace": (cli_meta or {}).get("workspace", ""),
|
||
"model": (cli_meta or {}).get("model", "unknown"),
|
||
"message_count": len(msgs),
|
||
"created_at": (cli_meta or {}).get("created_at", 0),
|
||
"updated_at": (cli_meta or {}).get("updated_at", 0),
|
||
"last_message_at": (cli_meta or {}).get("last_message_at")
|
||
or (cli_meta or {}).get("updated_at", 0)
|
||
or (msgs[-1] if msgs else {"timestamp": 0}).get("timestamp", 0),
|
||
"pinned": False,
|
||
"archived": False,
|
||
"project_id": None,
|
||
"profile": (cli_meta or {}).get("profile"),
|
||
"is_cli_session": True,
|
||
"source_tag": (cli_meta or {}).get("source_tag"),
|
||
"raw_source": (cli_meta or {}).get("raw_source"),
|
||
"session_source": (cli_meta or {}).get("session_source"),
|
||
"source_label": (cli_meta or {}).get("source_label"),
|
||
"read_only": bool((cli_meta or {}).get("read_only")),
|
||
"messages": msgs,
|
||
"tool_calls": [],
|
||
}
|
||
sess = _merge_cli_sidebar_metadata(sess, cli_meta)
|
||
return j(handler, {"session": redact_session_data(sess)})
|
||
return bad(handler, "Session not found", 404)
|
||
|
||
if parsed.path == "/api/session/status":
|
||
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||
if not sid:
|
||
return bad(handler, "Missing session_id")
|
||
try:
|
||
from api.session_ops import session_status
|
||
_clear_stale_stream_state(get_session(sid, metadata_only=True))
|
||
return j(handler, session_status(sid))
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
|
||
if parsed.path == "/api/session/yolo":
|
||
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||
if not sid:
|
||
return bad(handler, "Missing session_id")
|
||
return j(handler, {"yolo_enabled": is_session_yolo_enabled(sid)})
|
||
|
||
if parsed.path == "/api/session/usage":
|
||
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||
if not sid:
|
||
return bad(handler, "Missing session_id")
|
||
try:
|
||
from api.session_ops import session_usage
|
||
return j(handler, session_usage(sid))
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
|
||
if parsed.path == "/api/background/status":
|
||
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||
if not sid:
|
||
return bad(handler, "Missing session_id")
|
||
from api.background import get_results
|
||
return j(handler, {"results": get_results(sid)})
|
||
|
||
if parsed.path == "/api/sessions":
|
||
webui_sessions = all_sessions()
|
||
settings = load_settings()
|
||
show_cli_sessions = bool(settings.get("show_cli_sessions"))
|
||
if show_cli_sessions:
|
||
cli = get_cli_sessions()
|
||
cli_by_id = {s["session_id"]: s for s in cli}
|
||
for s in webui_sessions:
|
||
meta = cli_by_id.get(s.get("session_id"))
|
||
if not meta:
|
||
continue
|
||
if _is_messaging_session_record(meta):
|
||
s.update(_merge_cli_sidebar_metadata(s, meta))
|
||
if s.get("session_id") != meta.get("session_id"):
|
||
s["session_id"] = meta.get("session_id")
|
||
else:
|
||
for key in ("source_tag", "raw_source", "session_source", "source_label"):
|
||
if not s.get(key) and meta.get(key):
|
||
s[key] = meta[key]
|
||
# Apply the same CLI visibility semantics to imported local copies so
|
||
# low-value imported artifacts do not leak into the sidebar.
|
||
webui_sessions = [s for s in webui_sessions if is_cli_session_row_visible(s)]
|
||
webui_ids = {s["session_id"] for s in webui_sessions}
|
||
from api.models import _hide_from_default_sidebar as _cron_hide
|
||
deduped_cli = [s for s in cli if s["session_id"] not in webui_ids and is_cli_session_row_visible(s) and not _cron_hide(s)]
|
||
else:
|
||
webui_sessions = [s for s in webui_sessions if not _is_cli_session_for_settings(s)]
|
||
deduped_cli = []
|
||
merged = webui_sessions + deduped_cli
|
||
merged.sort(
|
||
key=lambda s: s.get("last_message_at") or s.get("updated_at", 0) or 0,
|
||
reverse=True,
|
||
)
|
||
# ── Profile scoping (#1611) ────────────────────────────────────────
|
||
# Default: filter to the active profile. ?all_profiles=1 opts into
|
||
# the aggregate view used by the "All profiles" sidebar toggle.
|
||
# The other_profile_count is always returned so the UI can render
|
||
# the "Show N from other profiles" affordance without sending the
|
||
# cross-profile rows by default.
|
||
#
|
||
# IMPORTANT: scope BEFORE _keep_latest_messaging_session_per_source.
|
||
# _messaging_source_key is profile-blind (#1614 follow-up): if the
|
||
# same Slack/Telegram identity has sessions in profiles A and B, a
|
||
# profile-blind dedupe would discard the older one even when scoped
|
||
# to its own profile, leaving that profile with zero rows for that
|
||
# source. Filter first so the dedupe operates only within the active
|
||
# profile's rows.
|
||
from api.profiles import get_active_profile_name
|
||
active_profile = get_active_profile_name()
|
||
all_profiles = _all_profiles_query_flag(parsed)
|
||
if all_profiles:
|
||
scoped = merged
|
||
other_profile_count = 0
|
||
else:
|
||
scoped = [s for s in merged
|
||
if _profiles_match(s.get("profile"), active_profile)]
|
||
other_profile_count = len(merged) - len(scoped)
|
||
scoped = _keep_latest_messaging_session_per_source(scoped)
|
||
if show_cli_sessions:
|
||
scoped = _cap_recent_cli_sessions(scoped, cli_cap=CLI_VISIBLE_SESSION_CAP)
|
||
safe_merged = []
|
||
for s in scoped:
|
||
item = dict(s)
|
||
if isinstance(item.get("title"), str):
|
||
item["title"] = _redact_text(item["title"])
|
||
safe_merged.append(item)
|
||
return j(handler, {
|
||
"sessions": safe_merged,
|
||
"cli_count": len(deduped_cli),
|
||
"all_profiles": all_profiles,
|
||
"active_profile": active_profile,
|
||
"other_profile_count": other_profile_count,
|
||
"server_time": time.time(),
|
||
"server_tz": time.strftime("%z"),
|
||
})
|
||
|
||
if parsed.path == "/api/projects":
|
||
# ── Profile scoping (#1614) ────────────────────────────────────────
|
||
# Default: filter to the active profile. ?all_profiles=1 returns the
|
||
# aggregate list so settings/admin UIs can still see everything.
|
||
from api.profiles import get_active_profile_name
|
||
active_profile = get_active_profile_name()
|
||
all_projects = load_projects()
|
||
all_profiles = _all_profiles_query_flag(parsed)
|
||
if all_profiles:
|
||
scoped = all_projects
|
||
else:
|
||
scoped = [p for p in all_projects
|
||
if _profiles_match(p.get("profile"), active_profile)]
|
||
return j(handler, {
|
||
"projects": scoped,
|
||
"all_profiles": all_profiles,
|
||
"active_profile": active_profile,
|
||
"other_profile_count": len(all_projects) - len(scoped),
|
||
})
|
||
|
||
if parsed.path == "/api/session/export":
|
||
return _handle_session_export(handler, parsed)
|
||
|
||
if parsed.path == "/api/workspaces":
|
||
return j(
|
||
handler, {"workspaces": load_workspaces(), "last": get_last_workspace()}
|
||
)
|
||
|
||
if parsed.path == "/api/workspaces/suggest":
|
||
qs = parse_qs(parsed.query)
|
||
prefix = qs.get("prefix", [""])[0]
|
||
return j(
|
||
handler,
|
||
{
|
||
"suggestions": list_workspace_suggestions(prefix),
|
||
"prefix": prefix,
|
||
},
|
||
)
|
||
|
||
if parsed.path == "/api/sessions/search":
|
||
return _handle_sessions_search(handler, parsed)
|
||
|
||
if parsed.path == "/api/list":
|
||
return _handle_list_dir(handler, parsed)
|
||
|
||
if parsed.path == "/api/personalities":
|
||
# Read personalities from config.yaml agent.personalities section
|
||
# (matches hermes-agent CLI behavior, not filesystem SOUL.md approach)
|
||
from api.config import reload_config as _reload_cfg
|
||
|
||
_reload_cfg() # pick up config.yaml changes without server restart
|
||
from api.config import get_config as _get_cfg
|
||
|
||
_cfg = _get_cfg()
|
||
agent_cfg = _cfg.get("agent", {})
|
||
raw_personalities = agent_cfg.get("personalities", {})
|
||
personalities = []
|
||
if isinstance(raw_personalities, dict):
|
||
for name, value in raw_personalities.items():
|
||
desc = ""
|
||
if isinstance(value, dict):
|
||
desc = value.get("description", "")
|
||
elif isinstance(value, str):
|
||
desc = value[:80] + ("..." if len(value) > 80 else "")
|
||
personalities.append({"name": name, "description": desc})
|
||
return j(handler, {"personalities": personalities})
|
||
|
||
if parsed.path == "/api/git-info":
|
||
qs = parse_qs(parsed.query)
|
||
sid = qs.get("session_id", [""])[0]
|
||
if not sid:
|
||
return bad(handler, "session_id required")
|
||
try:
|
||
s = get_session(sid)
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
from api.workspace import git_info_for_workspace
|
||
|
||
info = git_info_for_workspace(Path(s.workspace))
|
||
return j(handler, {"git": info})
|
||
|
||
if parsed.path == "/api/commands":
|
||
from api.commands import list_commands
|
||
return j(handler, {"commands": list_commands()})
|
||
|
||
if parsed.path == "/api/updates/check":
|
||
settings = load_settings()
|
||
if not settings.get("check_for_updates", True):
|
||
return j(handler, {"disabled": True})
|
||
qs = parse_qs(parsed.query)
|
||
force = qs.get("force", ["0"])[0] == "1"
|
||
# ?simulate=1 returns fake behind counts for UI testing (localhost only)
|
||
if (
|
||
qs.get("simulate", ["0"])[0] == "1"
|
||
and handler.client_address[0] == "127.0.0.1"
|
||
):
|
||
return j(
|
||
handler,
|
||
{
|
||
"webui": {
|
||
"name": "webui",
|
||
"behind": 3,
|
||
"current_sha": "abc1234",
|
||
"latest_sha": "def5678",
|
||
"branch": "master",
|
||
},
|
||
"agent": {
|
||
"name": "agent",
|
||
"behind": 1,
|
||
"current_sha": "aaa0001",
|
||
"latest_sha": "bbb0002",
|
||
"branch": "master",
|
||
},
|
||
"checked_at": 0,
|
||
},
|
||
)
|
||
from api.updates import check_for_updates
|
||
|
||
return j(handler, check_for_updates(force=force))
|
||
|
||
if parsed.path == "/api/chat/stream/status":
|
||
stream_id = parse_qs(parsed.query).get("stream_id", [""])[0]
|
||
return j(handler, {"active": stream_id in STREAMS, "stream_id": stream_id})
|
||
|
||
if parsed.path == "/api/chat/cancel":
|
||
stream_id = parse_qs(parsed.query).get("stream_id", [""])[0]
|
||
if not stream_id:
|
||
return bad(handler, "stream_id required")
|
||
cancelled = cancel_stream(stream_id)
|
||
return j(handler, {"ok": True, "cancelled": cancelled, "stream_id": stream_id})
|
||
|
||
if parsed.path == "/api/chat/stream":
|
||
return _handle_sse_stream(handler, parsed)
|
||
|
||
if parsed.path == "/api/terminal/output":
|
||
return _handle_terminal_output(handler, parsed)
|
||
|
||
if parsed.path == '/api/sessions/gateway/stream':
|
||
return _handle_gateway_sse_stream(handler, parsed)
|
||
|
||
if parsed.path == "/api/media":
|
||
return _handle_media(handler, parsed)
|
||
|
||
if parsed.path == "/api/file/raw":
|
||
return _handle_file_raw(handler, parsed)
|
||
|
||
if parsed.path == "/api/file":
|
||
return _handle_file_read(handler, parsed)
|
||
|
||
if parsed.path == "/api/approval/pending":
|
||
return _handle_approval_pending(handler, parsed)
|
||
|
||
if parsed.path == "/api/approval/stream":
|
||
return _handle_approval_sse_stream(handler, parsed)
|
||
|
||
if parsed.path == "/api/approval/inject_test":
|
||
# Loopback-only: used by automated tests; blocked from any remote client
|
||
if handler.client_address[0] != "127.0.0.1":
|
||
return j(handler, {"error": "not found"}, status=404)
|
||
return _handle_approval_inject(handler, parsed)
|
||
|
||
if parsed.path == "/api/clarify/pending":
|
||
return _handle_clarify_pending(handler, parsed)
|
||
|
||
if parsed.path == "/api/clarify/stream":
|
||
return _handle_clarify_sse_stream(handler, parsed)
|
||
|
||
if parsed.path == "/api/clarify/inject_test":
|
||
# Loopback-only: used by automated tests; blocked from any remote client
|
||
if handler.client_address[0] != "127.0.0.1":
|
||
return j(handler, {"error": "not found"}, status=404)
|
||
return _handle_clarify_inject(handler, parsed)
|
||
|
||
if parsed.path == "/api/onboarding/oauth/poll":
|
||
qs = parse_qs(parsed.query)
|
||
flow_id = qs.get("flow_id", [""])[0]
|
||
try:
|
||
return j(
|
||
handler,
|
||
poll_onboarding_oauth_flow(flow_id),
|
||
extra_headers={"Cache-Control": "no-store"},
|
||
)
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
except KeyError as e:
|
||
return bad(handler, str(e), 404)
|
||
|
||
# ── Cron API (GET) ──
|
||
# All cron handlers touch cron.jobs which resolves HERMES_HOME from
|
||
# os.environ (process-global) at call time. Wrap in cron_profile_context
|
||
# so the TLS-active profile's jobs.json is read, not the process default.
|
||
if parsed.path == "/api/crons":
|
||
from cron.jobs import list_jobs
|
||
from api.profiles import cron_profile_context
|
||
|
||
with cron_profile_context():
|
||
return j(handler, {"jobs": _cron_jobs_for_api(list_jobs(include_disabled=True))})
|
||
|
||
if parsed.path == "/api/crons/output":
|
||
from api.profiles import cron_profile_context
|
||
|
||
with cron_profile_context():
|
||
return _handle_cron_output(handler, parsed)
|
||
|
||
if parsed.path == "/api/crons/history":
|
||
from api.profiles import cron_profile_context
|
||
|
||
with cron_profile_context():
|
||
return _handle_cron_history(handler, parsed)
|
||
|
||
if parsed.path == "/api/crons/run":
|
||
from api.profiles import cron_profile_context
|
||
|
||
with cron_profile_context():
|
||
return _handle_cron_run_detail(handler, parsed)
|
||
|
||
if parsed.path == "/api/crons/recent":
|
||
from api.profiles import cron_profile_context
|
||
|
||
with cron_profile_context():
|
||
return _handle_cron_recent(handler, parsed)
|
||
|
||
if parsed.path == "/api/crons/status":
|
||
from api.profiles import cron_profile_context
|
||
|
||
with cron_profile_context():
|
||
return _handle_cron_status(handler, parsed)
|
||
|
||
# ── Skills API (GET) ──
|
||
if parsed.path == "/api/skills":
|
||
from tools.skills_tool import skills_list as _skills_list
|
||
|
||
raw = _skills_list()
|
||
data = json.loads(raw) if isinstance(raw, str) else raw
|
||
return j(handler, {"skills": data.get("skills", [])})
|
||
|
||
if parsed.path == "/api/skills/content":
|
||
from tools.skills_tool import skill_view as _skill_view, SKILLS_DIR
|
||
|
||
qs = parse_qs(parsed.query)
|
||
name = qs.get("name", [""])[0]
|
||
if not name:
|
||
return j(handler, {"error": "name required"}, status=400)
|
||
file_path = qs.get("file", [""])[0]
|
||
if file_path:
|
||
# Serve a linked file from the skill directory
|
||
import re as _re
|
||
|
||
if _re.search(r"[*?\[\]]", name):
|
||
return bad(handler, "Invalid skill name", 400)
|
||
skill_dir = None
|
||
for p in SKILLS_DIR.rglob(name):
|
||
if p.is_dir():
|
||
skill_dir = p
|
||
break
|
||
if not skill_dir:
|
||
return bad(handler, "Skill not found", 404)
|
||
target = (skill_dir / file_path).resolve()
|
||
try:
|
||
target.relative_to(skill_dir.resolve())
|
||
except ValueError:
|
||
return bad(handler, "Invalid file path", 400)
|
||
if not target.exists() or not target.is_file():
|
||
return bad(handler, "File not found", 404)
|
||
return j(
|
||
handler,
|
||
{"content": target.read_text(encoding="utf-8"), "path": file_path},
|
||
)
|
||
raw = _skill_view(name)
|
||
data = json.loads(raw) if isinstance(raw, str) else raw
|
||
if not isinstance(data.get("linked_files"), dict):
|
||
data["linked_files"] = {}
|
||
return j(handler, data)
|
||
|
||
# ── Memory API (GET) ──
|
||
if parsed.path == "/api/memory":
|
||
return _handle_memory_read(handler)
|
||
|
||
# ── Profile API (GET) ──
|
||
if parsed.path == "/api/profiles":
|
||
from api.profiles import list_profiles_api, get_active_profile_name
|
||
|
||
return j(
|
||
handler,
|
||
{"profiles": list_profiles_api(), "active": get_active_profile_name()},
|
||
)
|
||
|
||
if parsed.path == "/api/profile/active":
|
||
from api.profiles import get_active_profile_name, get_active_hermes_home
|
||
|
||
return j(
|
||
handler,
|
||
{"name": get_active_profile_name(), "path": str(get_active_hermes_home())},
|
||
)
|
||
|
||
# ── Gateway Status (GET) ──
|
||
if parsed.path == "/api/gateway/status":
|
||
import datetime
|
||
identity_map = _load_gateway_session_identity_map()
|
||
sessions_path = _gateway_session_metadata_path()
|
||
running = bool(identity_map)
|
||
platforms_set: set[str] = set()
|
||
for meta in identity_map.values():
|
||
raw = meta.get("raw_source") or meta.get("platform") or ""
|
||
norm = _normalize_messaging_source(raw)
|
||
if norm:
|
||
platforms_set.add(norm)
|
||
_PLATFORM_LABELS = {
|
||
"telegram": "Telegram",
|
||
"discord": "Discord",
|
||
"slack": "Slack",
|
||
"web": "Web",
|
||
"api": "API",
|
||
}
|
||
platforms = sorted(
|
||
[{"name": p, "label": _PLATFORM_LABELS.get(p, p.title())} for p in platforms_set],
|
||
key=lambda x: x["label"],
|
||
)
|
||
last_active = ""
|
||
if running and sessions_path.exists():
|
||
try:
|
||
mtime = sessions_path.stat().st_mtime
|
||
last_active = datetime.datetime.fromtimestamp(mtime).isoformat()
|
||
except Exception:
|
||
pass
|
||
return j(handler, {
|
||
"running": running,
|
||
"platforms": platforms,
|
||
"last_active": last_active,
|
||
"session_count": len(identity_map),
|
||
})
|
||
|
||
# ── MCP Servers (GET) ──
|
||
if parsed.path == "/api/mcp/servers":
|
||
return _handle_mcp_servers_list(handler)
|
||
|
||
# ── MCP Tools (GET) ──
|
||
if parsed.path == "/api/mcp/tools":
|
||
return _handle_mcp_tools_list(handler)
|
||
|
||
# ── Checkpoints / Rollback (GET) ──
|
||
if parsed.path == "/api/rollback/list":
|
||
qs = parse_qs(parsed.query)
|
||
workspace = qs.get("workspace", [""])[0]
|
||
if not workspace:
|
||
return bad(handler, "workspace query parameter is required")
|
||
try:
|
||
from api.rollback import list_checkpoints
|
||
return j(handler, list_checkpoints(workspace))
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
except Exception as e:
|
||
logger.exception("rollback/list failed")
|
||
return bad(handler, str(e), status=500)
|
||
|
||
if parsed.path == "/api/rollback/diff":
|
||
qs = parse_qs(parsed.query)
|
||
workspace = qs.get("workspace", [""])[0]
|
||
checkpoint = qs.get("checkpoint", [""])[0]
|
||
if not workspace or not checkpoint:
|
||
return bad(handler, "workspace and checkpoint query parameters are required")
|
||
try:
|
||
from api.rollback import get_checkpoint_diff
|
||
return j(handler, get_checkpoint_diff(workspace, checkpoint))
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
except Exception as e:
|
||
logger.exception("rollback/diff failed")
|
||
return bad(handler, str(e), status=500)
|
||
|
||
return False # 404
|
||
|
||
|
||
# ── GET route helpers
|
||
|
||
|
||
def handle_post(handler, parsed) -> bool:
|
||
"""Handle all POST routes. Returns True if handled, False for 404."""
|
||
# CSRF: reject cross-origin browser requests
|
||
if not _check_csrf(handler):
|
||
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
|
||
|
||
if parsed.path == "/api/upload":
|
||
return handle_upload(handler)
|
||
if parsed.path == "/api/upload/extract":
|
||
return handle_upload_extract(handler)
|
||
|
||
if parsed.path == "/api/transcribe":
|
||
return handle_transcribe(handler)
|
||
|
||
body = read_body(handler)
|
||
|
||
if parsed.path.startswith("/api/kanban/"):
|
||
from api.kanban_bridge import handle_kanban_post
|
||
|
||
return handle_kanban_post(handler, parsed, body)
|
||
if parsed.path == "/api/dashboard/config":
|
||
from api import dashboard_probe
|
||
|
||
try:
|
||
j(handler, dashboard_probe.save_dashboard_config(body))
|
||
except ValueError as exc:
|
||
bad(handler, str(exc), status=400)
|
||
except Exception as exc:
|
||
logger.exception("dashboard config save failed")
|
||
bad(handler, str(exc), status=500)
|
||
return True
|
||
|
||
if parsed.path == "/api/session/new":
|
||
try:
|
||
workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
model, model_provider = _session_model_state_from_request(
|
||
body.get("model"),
|
||
body.get("model_provider"),
|
||
)
|
||
# Use the profile sent by the client tab (if any) so that two tabs on
|
||
# different profiles never clobber each other via the process-level global.
|
||
s = new_session(
|
||
workspace=workspace,
|
||
model=model,
|
||
model_provider=model_provider,
|
||
profile=body.get("profile") or None,
|
||
project_id=body.get("project_id") or None,
|
||
)
|
||
return j(handler, {"session": s.compact() | {"messages": s.messages}})
|
||
|
||
if parsed.path == "/api/session/duplicate":
|
||
try:
|
||
sid = body.get("session_id")
|
||
if not sid:
|
||
return bad(handler, "session_id is required")
|
||
|
||
session = Session.load(sid)
|
||
if not session:
|
||
# 404, not 400 — missing resource, not a malformed request.
|
||
return bad(handler, "Session not found", status=404)
|
||
|
||
# Deep-copy mutable lists so the duplicate is *actually* independent.
|
||
# `Session.__init__` does `self.messages = messages or []` — plain
|
||
# assignment, no copy. Without deepcopy, both sessions share the same
|
||
# list object in memory; appending to one mutates the other.
|
||
# Items inside `messages` are dicts with mutable values (tool_calls,
|
||
# content arrays), so a shallow `list(...)` is not enough.
|
||
copied_session = Session(
|
||
session_id=uuid.uuid4().hex[:12],
|
||
# Defensive: legacy sessions may have title=None on disk; fall back to 'Untitled'
|
||
# so `+ " (copy)"` doesn't TypeError.
|
||
title=(session.title or "Untitled") + " (copy)",
|
||
workspace=session.workspace,
|
||
model=session.model,
|
||
model_provider=session.model_provider,
|
||
messages=copy.deepcopy(session.messages),
|
||
tool_calls=copy.deepcopy(session.tool_calls),
|
||
# Reset ephemeral / per-session-instance flags. Duplicating an
|
||
# archived conversation should produce a visible (un-archived)
|
||
# copy; pinned status doesn't transfer either.
|
||
pinned=False,
|
||
archived=False,
|
||
project_id=session.project_id,
|
||
profile=session.profile,
|
||
input_tokens=session.input_tokens,
|
||
output_tokens=session.output_tokens,
|
||
estimated_cost=session.estimated_cost,
|
||
# Per-session settings the user may have customized — carry them over
|
||
# so the duplicate behaves identically until further edits. Compression
|
||
# anchor + last_prompt_tokens are intentionally NOT carried — those
|
||
# re-derive on the next turn.
|
||
personality=session.personality,
|
||
enabled_toolsets=getattr(session, "enabled_toolsets", None),
|
||
context_length=getattr(session, "context_length", None),
|
||
threshold_tokens=getattr(session, "threshold_tokens", None),
|
||
created_at=time.time(),
|
||
updated_at=time.time(),
|
||
)
|
||
|
||
with LOCK:
|
||
SESSIONS[copied_session.session_id] = copied_session
|
||
SESSIONS.move_to_end(copied_session.session_id)
|
||
while len(SESSIONS) > SESSIONS_MAX:
|
||
SESSIONS.popitem(last=False)
|
||
# Persist immediately. The pre-PR flow (/api/session/new + /api/session/rename)
|
||
# accidentally avoided this because `/api/session/rename` calls `s.save()`.
|
||
# Without this explicit save, the duplicate is in-memory only — if the user
|
||
# refreshes before sending a turn, the duplicate vanishes.
|
||
copied_session.save()
|
||
|
||
return j(handler, {"session": copied_session.compact() | {"messages": copied_session.messages}})
|
||
except Exception as e:
|
||
return bad(handler, str(e))
|
||
|
||
if parsed.path == "/api/default-model":
|
||
try:
|
||
return j(handler, set_hermes_default_model(body.get("model")))
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
except RuntimeError as e:
|
||
return bad(handler, str(e), 500)
|
||
|
||
# ── Providers (POST) ──
|
||
if parsed.path == "/api/providers":
|
||
provider_id = (body.get("provider") or "").strip().lower()
|
||
api_key = body.get("api_key")
|
||
if not provider_id:
|
||
return bad(handler, "provider is required")
|
||
if api_key is not None:
|
||
api_key = str(api_key).strip() or None
|
||
result = set_provider_key(provider_id, api_key)
|
||
if not result.get("ok"):
|
||
return bad(handler, result.get("error", "Unknown error"))
|
||
return j(handler, result)
|
||
|
||
if parsed.path == "/api/providers/delete":
|
||
provider_id = (body.get("provider") or "").strip().lower()
|
||
if not provider_id:
|
||
return bad(handler, "provider is required")
|
||
result = remove_provider_key(provider_id)
|
||
if not result.get("ok"):
|
||
return bad(handler, result.get("error", "Unknown error"))
|
||
return j(handler, result)
|
||
|
||
if parsed.path == "/api/reasoning":
|
||
# CLI-parity /reasoning handler — writes to the same config.yaml keys
|
||
# the CLI uses (display.show_reasoning, agent.reasoning_effort) so a
|
||
# preference set via WebUI is honoured in the terminal REPL and vice
|
||
# versa. Body is one of:
|
||
# {"display": "show"|"hide"|"on"|"off"} → display.show_reasoning
|
||
# {"effort": "none"|"minimal"|"low"|"medium"|"high"|"xhigh"}
|
||
# → agent.reasoning_effort
|
||
try:
|
||
display = body.get("display")
|
||
effort = body.get("effort")
|
||
if display is not None:
|
||
flag = str(display).strip().lower()
|
||
if flag in ("show", "on", "true", "1"):
|
||
return j(handler, set_reasoning_display(True))
|
||
if flag in ("hide", "off", "false", "0"):
|
||
return j(handler, set_reasoning_display(False))
|
||
return bad(handler, f"display must be show|hide|on|off (got '{display}')")
|
||
if effort is not None:
|
||
return j(handler, set_reasoning_effort(effort))
|
||
return bad(handler, "reasoning: must supply 'display' or 'effort'")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
except RuntimeError as e:
|
||
return bad(handler, str(e), 500)
|
||
|
||
if parsed.path == "/api/admin/reload":
|
||
# Hot-reload api.models module to pick up code changes without restart.
|
||
import importlib
|
||
from api import models as _models
|
||
importlib.reload(_models)
|
||
# Also re-expose get_session from the reloaded module so routes.py
|
||
# continues to work (routes.py imported it at module level).
|
||
import api.routes as _routes
|
||
_routes.get_session = _models.get_session
|
||
_routes.Session = _models.Session
|
||
_routes.compact = _models.compact
|
||
return j(handler, {"status": "ok", "reloaded": "api.models"})
|
||
|
||
if parsed.path == "/api/sessions/cleanup":
|
||
return _handle_sessions_cleanup(handler, body, zero_only=False)
|
||
|
||
if parsed.path == "/api/sessions/cleanup_zero_message":
|
||
return _handle_sessions_cleanup(handler, body, zero_only=True)
|
||
|
||
if parsed.path == "/api/session/rename":
|
||
try:
|
||
require(body, "session_id", "title")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
with _get_session_agent_lock(body["session_id"]):
|
||
s.title = str(body["title"]).strip()[:80] or "Untitled"
|
||
s.save()
|
||
return j(handler, {"session": s.compact()})
|
||
|
||
if parsed.path == "/api/personality/set":
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
if "name" not in body:
|
||
return bad(handler, "Missing required field: name")
|
||
sid = body["session_id"]
|
||
name = body["name"].strip()
|
||
try:
|
||
s = get_session(sid)
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
# Resolve personality from config.yaml agent.personalities section
|
||
# (matches hermes-agent CLI behavior)
|
||
prompt = ""
|
||
if name:
|
||
from api.config import reload_config as _reload_cfg2
|
||
|
||
_reload_cfg2() # pick up config changes without restart
|
||
from api.config import get_config as _get_cfg2
|
||
|
||
_cfg2 = _get_cfg2()
|
||
agent_cfg = _cfg2.get("agent", {})
|
||
raw_personalities = agent_cfg.get("personalities", {})
|
||
if not isinstance(raw_personalities, dict) or name not in raw_personalities:
|
||
return bad(
|
||
handler, f'Personality "{name}" not found in config.yaml', 404
|
||
)
|
||
value = raw_personalities[name]
|
||
# Resolve prompt using the same logic as hermes-agent cli.py
|
||
if isinstance(value, dict):
|
||
parts = [value.get("system_prompt", "") or value.get("prompt", "")]
|
||
if value.get("tone"):
|
||
parts.append(f"Tone: {value['tone']}")
|
||
if value.get("style"):
|
||
parts.append(f"Style: {value['style']}")
|
||
prompt = "\n".join(p for p in parts if p)
|
||
else:
|
||
prompt = str(value)
|
||
with _get_session_agent_lock(sid):
|
||
s.personality = name if name else None
|
||
s.save()
|
||
return j(handler, {"ok": True, "personality": s.personality, "prompt": prompt})
|
||
|
||
if parsed.path == "/api/session/toolsets":
|
||
"""Set or clear per-session toolset override (#493).
|
||
|
||
POST body: { session_id, toolsets: [...] | null }
|
||
- toolsets: list of toolset names to restrict the session to, or null to clear.
|
||
"""
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
sid = body["session_id"]
|
||
toolsets = body.get("toolsets")
|
||
# Validate: if not None, must be a non-empty list of strings
|
||
if toolsets is not None:
|
||
if not isinstance(toolsets, list) or not toolsets:
|
||
return bad(handler, "toolsets must be a non-empty list or null")
|
||
if not all(isinstance(t, str) and t for t in toolsets):
|
||
return bad(handler, "each toolset must be a non-empty string")
|
||
try:
|
||
s = get_session(sid)
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
with _get_session_agent_lock(sid):
|
||
s.enabled_toolsets = toolsets
|
||
s.save()
|
||
return j(handler, {"ok": True, "enabled_toolsets": s.enabled_toolsets})
|
||
|
||
if parsed.path == "/api/session/update":
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
old_ws = getattr(s, "workspace", "")
|
||
try:
|
||
new_ws = str(resolve_trusted_workspace(body.get("workspace", s.workspace)))
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
with _get_session_agent_lock(body["session_id"]):
|
||
s.workspace = new_ws
|
||
if "model" in body or "model_provider" in body:
|
||
model, provider = _session_model_state_from_request(
|
||
body.get("model", s.model),
|
||
body.get("model_provider") if "model_provider" in body else None,
|
||
getattr(s, "model_provider", None),
|
||
)
|
||
if model is not None:
|
||
s.model = model
|
||
s.model_provider = provider
|
||
s.save()
|
||
if str(old_ws or "") != str(new_ws or ""):
|
||
try:
|
||
from api.terminal import close_terminal
|
||
close_terminal(body["session_id"])
|
||
except Exception:
|
||
logger.debug("Failed to close workspace terminal after workspace update")
|
||
set_last_workspace(new_ws)
|
||
return j(handler, {"session": s.compact() | {"messages": s.messages}})
|
||
|
||
if parsed.path == "/api/session/delete":
|
||
sid = body.get("session_id", "")
|
||
if not sid:
|
||
return bad(handler, "session_id is required")
|
||
if not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid):
|
||
return bad(handler, "Invalid session_id", 400)
|
||
cli_meta_for_delete = _lookup_cli_session_metadata(sid)
|
||
if cli_meta_for_delete.get("read_only"):
|
||
return bad(handler, "Read-only imported sessions cannot be deleted from WebUI", 400)
|
||
is_messaging_session = _is_messaging_session_id(sid)
|
||
# Delete from WebUI session store
|
||
with LOCK:
|
||
SESSIONS.pop(sid, None)
|
||
try:
|
||
SESSION_INDEX_FILE.unlink(missing_ok=True)
|
||
except Exception:
|
||
logger.debug("Failed to unlink session index")
|
||
# Evict cached agent so turn count doesn't leak into a recycled session
|
||
from api.config import _evict_session_agent
|
||
_evict_session_agent(sid)
|
||
try:
|
||
p = (SESSION_DIR / f"{sid}.json").resolve()
|
||
p.relative_to(SESSION_DIR.resolve())
|
||
except Exception:
|
||
return bad(handler, "Invalid session_id", 400)
|
||
try:
|
||
p.unlink(missing_ok=True)
|
||
except Exception:
|
||
logger.debug("Failed to unlink session file %s", p)
|
||
# Prune the per-session agent lock so deleted sessions don't leak
|
||
# Lock entries in SESSION_AGENT_LOCKS forever.
|
||
with SESSION_AGENT_LOCKS_LOCK:
|
||
SESSION_AGENT_LOCKS.pop(sid, None)
|
||
try:
|
||
from api.terminal import close_terminal
|
||
close_terminal(sid)
|
||
except Exception:
|
||
logger.debug("Failed to close workspace terminal for deleted session %s", sid)
|
||
# Also delete from CLI state.db for CLI sessions shown in sidebar,
|
||
# but never erase external messaging channel memory via WebUI delete.
|
||
if not is_messaging_session:
|
||
try:
|
||
from api.models import delete_cli_session
|
||
|
||
delete_cli_session(sid)
|
||
except Exception:
|
||
logger.debug("Failed to delete CLI session %s", sid)
|
||
return j(handler, {"ok": True})
|
||
|
||
if parsed.path == "/api/session/clear":
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
with _get_session_agent_lock(body["session_id"]):
|
||
s.messages = []
|
||
s.tool_calls = []
|
||
s.title = "Untitled"
|
||
s.save()
|
||
# Evict cached agent — cleared session is a fresh conversation
|
||
from api.config import _evict_session_agent
|
||
_evict_session_agent(body["session_id"])
|
||
return j(handler, {"ok": True, "session": s.compact()})
|
||
|
||
if parsed.path == "/api/session/truncate":
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
if body.get("keep_count") is None:
|
||
return bad(handler, "Missing required field(s): keep_count")
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
keep = int(body["keep_count"])
|
||
with _get_session_agent_lock(body["session_id"]):
|
||
s.messages = s.messages[:keep]
|
||
s.save()
|
||
return j(
|
||
handler, {"ok": True, "session": s.compact() | {"messages": s.messages}}
|
||
)
|
||
|
||
if parsed.path == "/api/session/branch":
|
||
# Fork a conversation from any message point (#465).
|
||
# Accepts: {session_id, keep_count?, title?}
|
||
# keep_count: number of messages to copy (0=empty, undefined=full history)
|
||
# title: custom title (defaults to "<original title> (fork)")
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
# Reject non-string session_id explicitly so the failure surfaces as a
|
||
# 400 instead of a generic 500 from get_session() raising TypeError.
|
||
# (Opus pre-release follow-up.)
|
||
if not isinstance(body["session_id"], str):
|
||
return bad(handler, "session_id must be a string")
|
||
try:
|
||
source = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
|
||
keep_count = body.get("keep_count")
|
||
if keep_count is not None:
|
||
try:
|
||
keep_count = int(keep_count)
|
||
except (ValueError, TypeError):
|
||
return bad(handler, "keep_count must be an integer")
|
||
# Negative slice (`messages[:-N]`) returns "all but last N", which
|
||
# is a confusing fork semantic. Reject explicitly so the user
|
||
# doesn't accidentally fork a session with the tail truncated when
|
||
# they meant to copy the prefix. (Opus pre-release follow-up.)
|
||
if keep_count < 0:
|
||
return bad(handler, "keep_count must be non-negative")
|
||
|
||
custom_title = body.get("title")
|
||
if custom_title:
|
||
custom_title = str(custom_title).strip()[:80] or None
|
||
|
||
# Build messages slice
|
||
source_messages = source.messages or []
|
||
if keep_count is not None:
|
||
forked_messages = source_messages[:keep_count]
|
||
else:
|
||
forked_messages = list(source_messages)
|
||
|
||
# Derive title
|
||
if custom_title:
|
||
branch_title = custom_title
|
||
else:
|
||
source_title = source.title or "Untitled"
|
||
branch_title = f"{source_title} (fork)"
|
||
|
||
# Create new session inheriting workspace/model/profile
|
||
branch = Session(
|
||
workspace=source.workspace,
|
||
model=source.model,
|
||
profile=getattr(source, "profile", None),
|
||
title=branch_title,
|
||
messages=forked_messages,
|
||
parent_session_id=source.session_id,
|
||
)
|
||
with LOCK:
|
||
SESSIONS[branch.session_id] = branch
|
||
SESSIONS.move_to_end(branch.session_id)
|
||
while len(SESSIONS) > SESSIONS_MAX:
|
||
SESSIONS.popitem(last=False)
|
||
|
||
# Persist only if there are messages (matches new_session pattern)
|
||
if forked_messages:
|
||
branch.save()
|
||
|
||
return j(handler, {
|
||
"session_id": branch.session_id,
|
||
"title": branch_title,
|
||
"parent_session_id": source.session_id,
|
||
})
|
||
|
||
if parsed.path == "/api/session/compress":
|
||
return _handle_session_compress(handler, body)
|
||
|
||
if parsed.path == "/api/session/conversation-rounds":
|
||
return _handle_conversation_rounds(handler, body)
|
||
|
||
if parsed.path == "/api/session/handoff-summary":
|
||
return _handle_handoff_summary(handler, body)
|
||
|
||
if parsed.path == "/api/session/retry":
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
from api.session_ops import retry_last
|
||
result = retry_last(body["session_id"])
|
||
return j(handler, {"ok": True, **result})
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
except ValueError as e:
|
||
return j(handler, {"error": str(e)})
|
||
|
||
if parsed.path == "/api/session/undo":
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
from api.session_ops import undo_last
|
||
result = undo_last(body["session_id"])
|
||
return j(handler, {"ok": True, **result})
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
except ValueError as e:
|
||
return j(handler, {"error": str(e)})
|
||
|
||
# ── YOLO mode toggle (POST) ──
|
||
# Session-scoped only — stored in-memory on the server side.
|
||
# Important lifecycle notes:
|
||
# • Page reload: state PERSISTS (frontend re-fetches via GET endpoint)
|
||
# • Cross-tab: state is SHARED (same server-side flag per session)
|
||
# • Server restart: state is LOST (in-memory only)
|
||
# • Cross-session: isolated (each session has its own flag)
|
||
# Fixes #467
|
||
if parsed.path == "/api/session/yolo":
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
sid = body["session_id"]
|
||
enabled = bool(body.get("enabled", True))
|
||
if enabled:
|
||
enable_session_yolo(sid)
|
||
# Also resolve any pending approvals for this session so the
|
||
# agent doesn't stay stuck waiting on an already-dismissed card.
|
||
try:
|
||
from tools.approval import _pending as _p, _lock as _l
|
||
with _l:
|
||
_p.pop(sid, None)
|
||
except Exception:
|
||
pass
|
||
resolve_gateway_approval(sid, "once", resolve_all=True)
|
||
else:
|
||
disable_session_yolo(sid)
|
||
return j(handler, {"ok": True, "yolo_enabled": enabled})
|
||
|
||
if parsed.path == "/api/btw":
|
||
return _handle_btw(handler, body)
|
||
|
||
if parsed.path == "/api/background":
|
||
return _handle_background(handler, body)
|
||
|
||
if parsed.path == "/api/chat/start":
|
||
return _handle_chat_start(handler, body)
|
||
|
||
if parsed.path == "/api/chat":
|
||
return _handle_chat_sync(handler, body)
|
||
|
||
if parsed.path == "/api/chat/steer":
|
||
from api.streaming import _handle_chat_steer
|
||
return _handle_chat_steer(handler, body)
|
||
|
||
if parsed.path == "/api/terminal/start":
|
||
return _handle_terminal_start(handler, body)
|
||
|
||
if parsed.path == "/api/terminal/input":
|
||
return _handle_terminal_input(handler, body)
|
||
|
||
if parsed.path == "/api/terminal/resize":
|
||
return _handle_terminal_resize(handler, body)
|
||
|
||
if parsed.path == "/api/terminal/close":
|
||
return _handle_terminal_close(handler, body)
|
||
|
||
# ── Cron API (POST) ──
|
||
# See GET-side comment above: wrap in cron_profile_context so writes go
|
||
# to the TLS-active profile's jobs.json instead of the process default.
|
||
if parsed.path == "/api/crons/create":
|
||
from api.profiles import cron_profile_context
|
||
|
||
with cron_profile_context():
|
||
return _handle_cron_create(handler, body)
|
||
|
||
if parsed.path == "/api/crons/update":
|
||
from api.profiles import cron_profile_context
|
||
|
||
with cron_profile_context():
|
||
return _handle_cron_update(handler, body)
|
||
|
||
if parsed.path == "/api/crons/delete":
|
||
from api.profiles import cron_profile_context
|
||
|
||
with cron_profile_context():
|
||
return _handle_cron_delete(handler, body)
|
||
|
||
if parsed.path == "/api/crons/run":
|
||
from api.profiles import cron_profile_context
|
||
|
||
with cron_profile_context():
|
||
return _handle_cron_run(handler, body)
|
||
|
||
if parsed.path == "/api/crons/pause":
|
||
from api.profiles import cron_profile_context
|
||
|
||
with cron_profile_context():
|
||
return _handle_cron_pause(handler, body)
|
||
|
||
if parsed.path == "/api/crons/resume":
|
||
from api.profiles import cron_profile_context
|
||
|
||
with cron_profile_context():
|
||
return _handle_cron_resume(handler, body)
|
||
|
||
# ── File ops (POST) ──
|
||
if parsed.path == "/api/file/delete":
|
||
return _handle_file_delete(handler, body)
|
||
|
||
if parsed.path == "/api/file/save":
|
||
return _handle_file_save(handler, body)
|
||
|
||
if parsed.path == "/api/file/create":
|
||
return _handle_file_create(handler, body)
|
||
|
||
if parsed.path == "/api/file/rename":
|
||
return _handle_file_rename(handler, body)
|
||
|
||
if parsed.path == "/api/file/create-dir":
|
||
return _handle_create_dir(handler, body)
|
||
|
||
if parsed.path == "/api/file/reveal":
|
||
return _handle_file_reveal(handler, body)
|
||
|
||
# ── Workspace management (POST) ──
|
||
if parsed.path == "/api/workspaces/add":
|
||
return _handle_workspace_add(handler, body)
|
||
|
||
if parsed.path == "/api/workspaces/remove":
|
||
return _handle_workspace_remove(handler, body)
|
||
|
||
if parsed.path == "/api/workspaces/rename":
|
||
return _handle_workspace_rename(handler, body)
|
||
|
||
if parsed.path == "/api/workspaces/reorder":
|
||
return _handle_workspace_reorder(handler, body)
|
||
|
||
# ── Approval (POST) ──
|
||
if parsed.path == "/api/approval/respond":
|
||
return _handle_approval_respond(handler, body)
|
||
|
||
# ── Clarify (POST) ──
|
||
if parsed.path == "/api/clarify/respond":
|
||
return _handle_clarify_respond(handler, body)
|
||
|
||
# ── Skills (POST) ──
|
||
if parsed.path == "/api/skills/save":
|
||
return _handle_skill_save(handler, body)
|
||
|
||
if parsed.path == "/api/skills/delete":
|
||
return _handle_skill_delete(handler, body)
|
||
|
||
# ── Memory (POST) ──
|
||
if parsed.path == "/api/memory/write":
|
||
return _handle_memory_write(handler, body)
|
||
|
||
# ── Profile API (POST) ──
|
||
if parsed.path == "/api/profile/switch":
|
||
name = body.get("name", "").strip()
|
||
if not name:
|
||
return bad(handler, "name is required")
|
||
try:
|
||
from api.profiles import switch_profile, _validate_profile_name
|
||
from api.helpers import build_profile_cookie
|
||
if name != 'default':
|
||
_validate_profile_name(name)
|
||
# process_wide=False: don't mutate the process-global _active_profile.
|
||
# Per-client profile is managed via cookie + thread-local (#798).
|
||
result = switch_profile(name, process_wide=False)
|
||
# Invalidate the models cache so the very next /api/models request
|
||
# rebuilds from the new profile's config.yaml rather than returning
|
||
# the old profile's cached model list (#1200 — profile-switch model bug).
|
||
from api.config import invalidate_models_cache
|
||
invalidate_models_cache()
|
||
return j(handler, result, extra_headers={
|
||
'Set-Cookie': build_profile_cookie(name),
|
||
})
|
||
except (ValueError, FileNotFoundError) as e:
|
||
return bad(handler, _sanitize_error(e), 404)
|
||
except RuntimeError as e:
|
||
return bad(handler, str(e), 409)
|
||
|
||
if parsed.path == "/api/profile/create":
|
||
name = body.get("name", "").strip()
|
||
if not name:
|
||
return bad(handler, "name is required")
|
||
import re as _re
|
||
|
||
if not _re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", name):
|
||
return bad(
|
||
handler,
|
||
"Invalid profile name: lowercase letters, numbers, hyphens, underscores only",
|
||
)
|
||
clone_from = body.get("clone_from")
|
||
if clone_from is not None:
|
||
clone_from = str(clone_from).strip()
|
||
if not _re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", clone_from):
|
||
return bad(handler, "Invalid clone_from name")
|
||
base_url = body.get("base_url", "").strip() if body.get("base_url") else None
|
||
api_key = body.get("api_key", "").strip() if body.get("api_key") else None
|
||
if base_url and not base_url.startswith(("http://", "https://")):
|
||
return bad(handler, "base_url must start with http:// or https://")
|
||
try:
|
||
from api.profiles import create_profile_api
|
||
|
||
result = create_profile_api(
|
||
name,
|
||
clone_from=clone_from,
|
||
clone_config=bool(body.get("clone_config", False)),
|
||
base_url=base_url,
|
||
api_key=api_key,
|
||
)
|
||
return j(handler, {"ok": True, "profile": result})
|
||
except (ValueError, FileExistsError, RuntimeError) as e:
|
||
return bad(handler, str(e))
|
||
|
||
if parsed.path == "/api/profile/delete":
|
||
name = body.get("name", "").strip()
|
||
if not name:
|
||
return bad(handler, "name is required")
|
||
try:
|
||
from api.profiles import delete_profile_api, _validate_profile_name
|
||
|
||
_validate_profile_name(name)
|
||
result = delete_profile_api(name)
|
||
return j(handler, result)
|
||
except (ValueError, FileNotFoundError) as e:
|
||
return bad(handler, _sanitize_error(e))
|
||
except RuntimeError as e:
|
||
return bad(handler, str(e), 409)
|
||
|
||
# ── Settings (POST) ──
|
||
if parsed.path == "/api/settings":
|
||
from api.auth import (
|
||
create_session,
|
||
is_auth_enabled,
|
||
parse_cookie,
|
||
set_auth_cookie,
|
||
verify_session,
|
||
)
|
||
|
||
if "bot_name" in body:
|
||
body["bot_name"] = (str(body["bot_name"]) or "").strip() or "Hermes"
|
||
|
||
auth_enabled_before = is_auth_enabled()
|
||
current_cookie = parse_cookie(handler)
|
||
logged_in_before = bool(current_cookie and verify_session(current_cookie))
|
||
requested_password = bool(
|
||
isinstance(body.get("_set_password"), str)
|
||
and body.get("_set_password", "").strip()
|
||
)
|
||
requested_clear_password = bool(body.get("_clear_password"))
|
||
|
||
# #1560: HERMES_WEBUI_PASSWORD env var takes precedence in
|
||
# api.auth.get_password_hash(), so writing password_hash to settings.json
|
||
# has no effect on auth. Refuse loudly with 409 instead of silently
|
||
# succeeding — the previous behaviour returned 200 + a green save toast
|
||
# while every subsequent login still required the env-var password.
|
||
if requested_password or requested_clear_password:
|
||
if os.getenv("HERMES_WEBUI_PASSWORD", "").strip():
|
||
return bad(
|
||
handler,
|
||
"HERMES_WEBUI_PASSWORD env var is set — it overrides the settings password. "
|
||
"Unset the env var and restart the server before changing the password here.",
|
||
409,
|
||
)
|
||
|
||
saved = save_settings(body)
|
||
saved.pop("password_hash", None) # never expose hash to client
|
||
|
||
auth_enabled_after = is_auth_enabled()
|
||
auth_just_enabled = bool(
|
||
requested_password and auth_enabled_after and not auth_enabled_before
|
||
)
|
||
logged_in_after = logged_in_before
|
||
new_cookie = None
|
||
|
||
if auth_just_enabled and not logged_in_before:
|
||
new_cookie = create_session()
|
||
logged_in_after = True
|
||
|
||
saved["auth_enabled"] = auth_enabled_after
|
||
saved["logged_in"] = logged_in_after
|
||
saved["auth_just_enabled"] = auth_just_enabled
|
||
|
||
if not new_cookie:
|
||
return j(handler, saved)
|
||
|
||
response_body = json.dumps(saved, ensure_ascii=False, indent=2).encode("utf-8")
|
||
handler.send_response(200)
|
||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||
handler.send_header("Content-Length", str(len(response_body)))
|
||
handler.send_header("Cache-Control", "no-store")
|
||
set_auth_cookie(handler, new_cookie)
|
||
_security_headers(handler)
|
||
handler.end_headers()
|
||
handler.wfile.write(response_body)
|
||
return True
|
||
|
||
if parsed.path == "/api/onboarding/oauth/start":
|
||
from api.auth import is_auth_enabled
|
||
import os as _os
|
||
if not is_auth_enabled() and not _os.getenv("HERMES_WEBUI_ONBOARDING_OPEN"):
|
||
import ipaddress
|
||
try:
|
||
_xff = handler.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
||
_xri = handler.headers.get("X-Real-IP", "").strip()
|
||
_raw = handler.client_address[0]
|
||
addr = ipaddress.ip_address(_xff or _xri or _raw)
|
||
is_local = addr.is_loopback or addr.is_private
|
||
except ValueError:
|
||
is_local = False
|
||
if not is_local:
|
||
return bad(handler, "Onboarding OAuth is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403)
|
||
try:
|
||
return j(handler, start_onboarding_oauth_flow(body), extra_headers={"Cache-Control": "no-store"})
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
except RuntimeError as e:
|
||
return bad(handler, str(e), 500)
|
||
|
||
if parsed.path == "/api/onboarding/oauth/cancel":
|
||
try:
|
||
return j(handler, cancel_onboarding_oauth_flow(body), extra_headers={"Cache-Control": "no-store"})
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
|
||
if parsed.path == "/api/onboarding/setup":
|
||
# Writing API keys to disk - restrict to local/private networks unless auth is active.
|
||
# In Docker, requests arrive from the bridge network (172.x.x.x), not 127.0.0.1,
|
||
# even when the user accesses via localhost:8787 on the host.
|
||
# Behind a reverse proxy (nginx/Caddy/Traefik) or SSH tunnel, X-Forwarded-For
|
||
# carries the real origin IP — read it first before falling back to the raw socket addr.
|
||
# HERMES_WEBUI_ONBOARDING_OPEN=1 lets operators on remote servers explicitly bypass
|
||
# the check when they control network access themselves (e.g. firewall + VPN).
|
||
from api.auth import is_auth_enabled
|
||
import os as _os
|
||
if not is_auth_enabled() and not _os.getenv("HERMES_WEBUI_ONBOARDING_OPEN"):
|
||
import ipaddress
|
||
try:
|
||
# Prefer forwarded headers set by reverse proxies
|
||
_xff = handler.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
||
_xri = handler.headers.get("X-Real-IP", "").strip()
|
||
_raw = handler.client_address[0]
|
||
_ip_str = _xff or _xri or _raw
|
||
addr = ipaddress.ip_address(_ip_str)
|
||
is_local = addr.is_loopback or addr.is_private
|
||
except ValueError:
|
||
is_local = False
|
||
if not is_local:
|
||
return bad(handler, "Onboarding setup is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403)
|
||
try:
|
||
return j(handler, apply_onboarding_setup(body))
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
except RuntimeError as e:
|
||
return bad(handler, str(e), 500)
|
||
|
||
if parsed.path == "/api/onboarding/complete":
|
||
return j(handler, complete_onboarding())
|
||
|
||
if parsed.path == "/api/onboarding/probe":
|
||
# Probe a self-hosted provider endpoint (#1499). Validates the
|
||
# configured base URL is reachable + parses /models, returns the
|
||
# model catalog so the wizard can populate its dropdown.
|
||
# Read-only: no config.yaml or .env writes happen here. Same local-
|
||
# network gate as /api/onboarding/setup (also writing-adjacent in
|
||
# spirit because it carries an api_key the user typed).
|
||
from api.auth import is_auth_enabled
|
||
import os as _os
|
||
if not is_auth_enabled() and not _os.getenv("HERMES_WEBUI_ONBOARDING_OPEN"):
|
||
import ipaddress
|
||
try:
|
||
_xff = handler.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
||
_xri = handler.headers.get("X-Real-IP", "").strip()
|
||
_raw = handler.client_address[0]
|
||
_ip_str = _xff or _xri or _raw
|
||
addr = ipaddress.ip_address(_ip_str)
|
||
is_local = addr.is_loopback or addr.is_private
|
||
except ValueError:
|
||
is_local = False
|
||
if not is_local:
|
||
return bad(handler, "Onboarding probe is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403)
|
||
provider = str((body or {}).get("provider") or "").strip().lower()
|
||
base_url = str((body or {}).get("base_url") or "")
|
||
api_key = str((body or {}).get("api_key") or "").strip() or None
|
||
try:
|
||
return j(handler, probe_provider_endpoint(provider, base_url, api_key))
|
||
except Exception as e:
|
||
return bad(handler, f"probe failed: {e}", 500)
|
||
|
||
# ── Session pin (POST) ──
|
||
if parsed.path == "/api/session/pin":
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
with _get_session_agent_lock(body["session_id"]):
|
||
s.pinned = bool(body.get("pinned", True))
|
||
s.save()
|
||
return j(handler, {"ok": True, "session": s.compact()})
|
||
|
||
# ── Session archive (POST) ──
|
||
if parsed.path == "/api/session/archive":
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
sid = body["session_id"]
|
||
try:
|
||
s = get_session(sid)
|
||
except KeyError:
|
||
cli_meta = _lookup_cli_session_metadata(sid)
|
||
if not cli_meta:
|
||
return bad(handler, "Session not found", 404)
|
||
if cli_meta.get("read_only"):
|
||
return bad(handler, "Read-only imported sessions cannot be archived from WebUI", 400)
|
||
if _is_messaging_session_record(cli_meta):
|
||
s = Session(
|
||
session_id=sid,
|
||
title=cli_meta.get("title") or title_from(get_cli_session_messages(sid), "CLI Session"),
|
||
workspace=get_last_workspace(),
|
||
messages=[],
|
||
model=cli_meta.get("model") or "unknown",
|
||
created_at=cli_meta.get("created_at"),
|
||
updated_at=cli_meta.get("updated_at"),
|
||
)
|
||
s.is_cli_session = True
|
||
s.source_tag = cli_meta.get("source_tag")
|
||
s.raw_source = cli_meta.get("raw_source") or cli_meta.get("source_tag")
|
||
s.session_source = cli_meta.get("session_source")
|
||
s.source_label = cli_meta.get("source_label")
|
||
s.user_id = cli_meta.get("user_id")
|
||
s.chat_id = cli_meta.get("chat_id")
|
||
s.chat_type = cli_meta.get("chat_type")
|
||
s.thread_id = cli_meta.get("thread_id")
|
||
s.session_key = cli_meta.get("session_key")
|
||
s.platform = cli_meta.get("platform")
|
||
s.save(touch_updated_at=False)
|
||
else:
|
||
msgs = get_cli_session_messages(sid)
|
||
if not msgs:
|
||
return bad(handler, "Session not found", 404)
|
||
s = import_cli_session(
|
||
sid,
|
||
cli_meta.get("title") or title_from(msgs, "CLI Session"),
|
||
msgs,
|
||
cli_meta.get("model") or "unknown",
|
||
profile=cli_meta.get("profile"),
|
||
created_at=cli_meta.get("created_at"),
|
||
updated_at=cli_meta.get("updated_at"),
|
||
)
|
||
s.is_cli_session = True
|
||
s.source_tag = cli_meta.get("source_tag")
|
||
s.raw_source = cli_meta.get("raw_source") or cli_meta.get("source_tag")
|
||
s.session_source = cli_meta.get("session_source")
|
||
s.source_label = cli_meta.get("source_label")
|
||
s.user_id = cli_meta.get("user_id")
|
||
s.chat_id = cli_meta.get("chat_id")
|
||
s.chat_type = cli_meta.get("chat_type")
|
||
s.thread_id = cli_meta.get("thread_id")
|
||
s.session_key = cli_meta.get("session_key")
|
||
s.platform = cli_meta.get("platform")
|
||
with _get_session_agent_lock(sid):
|
||
s.archived = bool(body.get("archived", True))
|
||
s.save(touch_updated_at=False)
|
||
return j(handler, {"ok": True, "session": s.compact()})
|
||
|
||
# ── Session move to project (POST) ──
|
||
if parsed.path == "/api/session/move":
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
# #1614: refuse moves into a project owned by another profile.
|
||
target_pid = body.get("project_id") or None
|
||
if target_pid:
|
||
from api.profiles import get_active_profile_name
|
||
active_profile = get_active_profile_name()
|
||
target = next(
|
||
(p for p in load_projects() if p["project_id"] == target_pid),
|
||
None,
|
||
)
|
||
if not target:
|
||
return bad(handler, "Project not found", 404)
|
||
if not _profiles_match(target.get("profile"), active_profile):
|
||
return bad(handler, "Project not found", 404)
|
||
with _get_session_agent_lock(body["session_id"]):
|
||
s.project_id = target_pid
|
||
s.save()
|
||
return j(handler, {"ok": True, "session": s.compact()})
|
||
|
||
# ── Project CRUD (POST) ──
|
||
if parsed.path == "/api/projects/create":
|
||
try:
|
||
require(body, "name")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
import re as _re
|
||
from api.profiles import get_active_profile_name
|
||
|
||
name = body["name"].strip()[:128]
|
||
if not name:
|
||
return bad(handler, "name required")
|
||
color = body.get("color")
|
||
if color and not _re.match(r"^#[0-9a-fA-F]{3,8}$", color):
|
||
return bad(handler, "Invalid color format")
|
||
projects = load_projects()
|
||
proj = {
|
||
"project_id": uuid.uuid4().hex[:12],
|
||
"name": name,
|
||
"color": color,
|
||
"profile": get_active_profile_name() or 'default',
|
||
"created_at": time.time(),
|
||
}
|
||
projects.append(proj)
|
||
save_projects(projects)
|
||
return j(handler, {"ok": True, "project": proj})
|
||
|
||
if parsed.path == "/api/projects/rename":
|
||
try:
|
||
require(body, "project_id", "name")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
import re as _re
|
||
from api.profiles import get_active_profile_name
|
||
|
||
projects = load_projects()
|
||
proj = next(
|
||
(p for p in projects if p["project_id"] == body["project_id"]), None
|
||
)
|
||
if not proj:
|
||
return bad(handler, "Project not found", 404)
|
||
# #1614: a project can only be renamed by the profile that owns it.
|
||
active_profile = get_active_profile_name()
|
||
if not _profiles_match(proj.get("profile"), active_profile):
|
||
return bad(handler, "Project not found", 404)
|
||
proj["name"] = body["name"].strip()[:128]
|
||
if "color" in body:
|
||
color = body["color"]
|
||
if color and not _re.match(r"^#[0-9a-fA-F]{3,8}$", color):
|
||
return bad(handler, "Invalid color format")
|
||
proj["color"] = color
|
||
save_projects(projects)
|
||
return j(handler, {"ok": True, "project": proj})
|
||
|
||
if parsed.path == "/api/projects/delete":
|
||
try:
|
||
require(body, "project_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
from api.profiles import get_active_profile_name
|
||
projects = load_projects()
|
||
proj = next(
|
||
(p for p in projects if p["project_id"] == body["project_id"]), None
|
||
)
|
||
if not proj:
|
||
return bad(handler, "Project not found", 404)
|
||
# #1614: a project can only be deleted by the profile that owns it.
|
||
active_profile = get_active_profile_name()
|
||
if not _profiles_match(proj.get("profile"), active_profile):
|
||
return bad(handler, "Project not found", 404)
|
||
projects = [p for p in projects if p["project_id"] != body["project_id"]]
|
||
save_projects(projects)
|
||
# Unassign all sessions that belonged to this project
|
||
if SESSION_INDEX_FILE.exists():
|
||
try:
|
||
index = json.loads(SESSION_INDEX_FILE.read_text(encoding="utf-8"))
|
||
for entry in index:
|
||
if entry.get("project_id") == body["project_id"]:
|
||
try:
|
||
s = get_session(entry["session_id"])
|
||
s.project_id = None
|
||
s.save()
|
||
except Exception:
|
||
logger.debug("Failed to update session %s", entry.get("session_id"))
|
||
except Exception:
|
||
logger.debug("Failed to load session index for project unlink")
|
||
return j(handler, {"ok": True})
|
||
|
||
# ── Session import from JSON (POST) ──
|
||
if parsed.path == "/api/session/import":
|
||
return _handle_session_import(handler, body)
|
||
|
||
# ── Self-update (POST) ──
|
||
if parsed.path == "/api/updates/apply":
|
||
target = body.get("target", "")
|
||
if target not in ("webui", "agent"):
|
||
return bad(handler, 'target must be "webui" or "agent"')
|
||
from api.updates import apply_update
|
||
|
||
return j(handler, apply_update(target))
|
||
|
||
if parsed.path == "/api/updates/force":
|
||
target = body.get("target", "")
|
||
if target not in ("webui", "agent"):
|
||
return bad(handler, 'target must be "webui" or "agent"')
|
||
from api.updates import apply_force_update
|
||
|
||
return j(handler, apply_force_update(target))
|
||
|
||
# ── CLI session import (POST) ──
|
||
if parsed.path == "/api/session/import_cli":
|
||
return _handle_session_import_cli(handler, body)
|
||
|
||
# ── Auth endpoints (POST) ──
|
||
if parsed.path == "/api/auth/login":
|
||
from api.auth import (
|
||
verify_password,
|
||
create_session,
|
||
set_auth_cookie,
|
||
is_auth_enabled,
|
||
)
|
||
from api.auth import _check_login_rate, _record_login_attempt
|
||
|
||
if not is_auth_enabled():
|
||
return j(handler, {"ok": True, "message": "Auth not enabled"})
|
||
client_ip = handler.client_address[0]
|
||
if not _check_login_rate(client_ip):
|
||
return j(
|
||
handler,
|
||
{"error": "Too many attempts. Try again in a minute."},
|
||
status=429,
|
||
)
|
||
password = body.get("password", "")
|
||
if not verify_password(password):
|
||
_record_login_attempt(client_ip)
|
||
return bad(handler, "Invalid password", 401)
|
||
cookie_val = create_session()
|
||
handler.send_response(200)
|
||
handler.send_header("Content-Type", "application/json")
|
||
handler.send_header("Cache-Control", "no-store")
|
||
_security_headers(handler)
|
||
set_auth_cookie(handler, cookie_val)
|
||
handler.end_headers()
|
||
handler.wfile.write(json.dumps({"ok": True}).encode())
|
||
return True
|
||
|
||
if parsed.path == "/api/auth/logout":
|
||
from api.auth import clear_auth_cookie, invalidate_session, parse_cookie
|
||
|
||
cookie_val = parse_cookie(handler)
|
||
if cookie_val:
|
||
invalidate_session(cookie_val)
|
||
handler.send_response(200)
|
||
handler.send_header("Content-Type", "application/json")
|
||
handler.send_header("Cache-Control", "no-store")
|
||
_security_headers(handler)
|
||
clear_auth_cookie(handler)
|
||
handler.end_headers()
|
||
handler.wfile.write(json.dumps({"ok": True}).encode())
|
||
return True
|
||
|
||
# ── Checkpoints / Rollback (POST) ──
|
||
if parsed.path == "/api/rollback/restore":
|
||
if not body:
|
||
return bad(handler, "request body is required")
|
||
workspace = body.get("workspace", "")
|
||
checkpoint = body.get("checkpoint", "")
|
||
if not workspace or not checkpoint:
|
||
return bad(handler, "workspace and checkpoint are required")
|
||
try:
|
||
from api.rollback import restore_checkpoint
|
||
return j(handler, restore_checkpoint(workspace, checkpoint))
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
except Exception as e:
|
||
logger.exception("rollback/restore failed")
|
||
return bad(handler, str(e), status=500)
|
||
|
||
return False # 404
|
||
|
||
|
||
def handle_patch(handler, parsed) -> bool:
|
||
"""Handle all PATCH routes. Returns True if handled, False for 404."""
|
||
if not _check_csrf(handler):
|
||
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
|
||
body = read_body(handler)
|
||
if parsed.path.startswith("/api/kanban/"):
|
||
from api.kanban_bridge import handle_kanban_patch
|
||
|
||
return handle_kanban_patch(handler, parsed, body)
|
||
return False
|
||
|
||
|
||
def handle_delete(handler, parsed) -> bool:
|
||
"""Handle all DELETE routes. Returns True if handled, False for 404."""
|
||
if not _check_csrf(handler):
|
||
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
|
||
body = read_body(handler)
|
||
if parsed.path.startswith("/api/kanban/"):
|
||
from api.kanban_bridge import handle_kanban_delete
|
||
|
||
return handle_kanban_delete(handler, parsed, body)
|
||
return False
|
||
|
||
# ── GET route helpers ─────────────────────────────────────────────────────────
|
||
|
||
# MIME types for static file serving. Hoisted to module scope to avoid
|
||
# rebuilding the dict on every request.
|
||
_STATIC_MIME = {
|
||
"css": "text/css",
|
||
"js": "application/javascript",
|
||
"html": "text/html",
|
||
"svg": "image/svg+xml",
|
||
"png": "image/png",
|
||
"jpg": "image/jpeg",
|
||
"jpeg": "image/jpeg",
|
||
"ico": "image/x-icon",
|
||
"gif": "image/gif",
|
||
"webp": "image/webp",
|
||
"woff": "font/woff",
|
||
"woff2": "font/woff2",
|
||
}
|
||
# MIME types that are text-based and should carry charset=utf-8
|
||
_TEXT_MIME_TYPES = {"text/css", "application/javascript", "text/html", "image/svg+xml", "text/plain"}
|
||
|
||
|
||
def _serve_static(handler, parsed):
|
||
static_root = (Path(__file__).parent.parent / "static").resolve()
|
||
# Strip the leading '/static/' prefix, then resolve and sandbox
|
||
rel = parsed.path[len("/static/") :]
|
||
static_file = (static_root / rel).resolve()
|
||
try:
|
||
static_file.relative_to(static_root)
|
||
except ValueError:
|
||
return j(handler, {"error": "not found"}, status=404)
|
||
if not static_file.exists() or not static_file.is_file():
|
||
return j(handler, {"error": "not found"}, status=404)
|
||
ext = static_file.suffix.lower()
|
||
ct = _STATIC_MIME.get(ext.lstrip("."), "text/plain")
|
||
ct_header = f"{ct}; charset=utf-8" if ct in _TEXT_MIME_TYPES else ct
|
||
handler.send_response(200)
|
||
handler.send_header("Content-Type", ct_header)
|
||
handler.send_header("Cache-Control", "no-store")
|
||
raw = static_file.read_bytes()
|
||
handler.send_header("Content-Length", str(len(raw)))
|
||
handler.end_headers()
|
||
handler.wfile.write(raw)
|
||
return True
|
||
|
||
|
||
def _handle_session_export(handler, parsed):
|
||
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||
if not sid:
|
||
return bad(handler, "session_id is required")
|
||
try:
|
||
s = get_session(sid)
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
safe = redact_session_data(s.__dict__)
|
||
payload = json.dumps(safe, ensure_ascii=False, indent=2)
|
||
handler.send_response(200)
|
||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||
handler.send_header(
|
||
"Content-Disposition", f'attachment; filename="hermes-{sid}.json"'
|
||
)
|
||
handler.send_header("Content-Length", str(len(payload.encode("utf-8"))))
|
||
handler.send_header("Cache-Control", "no-store")
|
||
handler.end_headers()
|
||
handler.wfile.write(payload.encode("utf-8"))
|
||
return True
|
||
|
||
|
||
def _handle_sessions_search(handler, parsed):
|
||
qs = parse_qs(parsed.query)
|
||
q = qs.get("q", [""])[0].lower().strip()
|
||
content_search = qs.get("content", ["1"])[0] == "1"
|
||
depth = int(qs.get("depth", ["5"])[0])
|
||
if not q:
|
||
safe_sessions = []
|
||
for s in all_sessions():
|
||
item = dict(s)
|
||
if isinstance(item.get("title"), str):
|
||
item["title"] = _redact_text(item["title"])
|
||
safe_sessions.append(item)
|
||
return j(handler, {"sessions": safe_sessions})
|
||
results = []
|
||
for s in all_sessions():
|
||
title_match = q in (s.get("title") or "").lower()
|
||
if title_match:
|
||
item = dict(s, match_type="title")
|
||
if isinstance(item.get("title"), str):
|
||
item["title"] = _redact_text(item["title"])
|
||
results.append(item)
|
||
continue
|
||
if content_search:
|
||
try:
|
||
sess = get_session(s["session_id"])
|
||
msgs = sess.messages[:depth] if depth else sess.messages
|
||
for m in msgs:
|
||
c = m.get("content") or ""
|
||
if isinstance(c, list):
|
||
c = " ".join(
|
||
p.get("text", "")
|
||
for p in c
|
||
if isinstance(p, dict) and p.get("type") == "text"
|
||
)
|
||
if q in str(c).lower():
|
||
item = dict(s, match_type="content")
|
||
if isinstance(item.get("title"), str):
|
||
item["title"] = _redact_text(item["title"])
|
||
results.append(item)
|
||
break
|
||
except (KeyError, Exception):
|
||
pass
|
||
return j(handler, {"sessions": results, "query": q, "count": len(results)})
|
||
|
||
|
||
def _handle_list_dir(handler, parsed):
|
||
qs = parse_qs(parsed.query)
|
||
sid = qs.get("session_id", [""])[0]
|
||
if not sid:
|
||
return bad(handler, "session_id is required")
|
||
try:
|
||
s = get_session(sid)
|
||
workspace = s.workspace
|
||
except KeyError:
|
||
# Fallback for CLI sessions not loaded in WebUI memory
|
||
try:
|
||
cli_meta = None
|
||
for cs in get_cli_sessions():
|
||
if cs["session_id"] == sid:
|
||
cli_meta = cs
|
||
break
|
||
if not cli_meta:
|
||
return bad(handler, "Session not found", 404)
|
||
workspace = cli_meta.get("workspace", "")
|
||
except Exception:
|
||
return bad(handler, "Session not found", 404)
|
||
try:
|
||
return j(
|
||
handler,
|
||
{
|
||
"entries": list_dir(Path(workspace), qs.get("path", ["."])[0]),
|
||
"path": qs.get("path", ["."])[0],
|
||
},
|
||
)
|
||
except (FileNotFoundError, ValueError) as e:
|
||
return bad(handler, _sanitize_error(e), 404)
|
||
|
||
|
||
def _handle_sse_stream(handler, parsed):
|
||
stream_id = parse_qs(parsed.query).get("stream_id", [""])[0]
|
||
stream = STREAMS.get(stream_id)
|
||
if stream is None:
|
||
return j(handler, {"error": "stream not found"}, status=404)
|
||
subscriber = stream.subscribe() if hasattr(stream, "subscribe") else stream
|
||
handler.send_response(200)
|
||
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||
handler.send_header("Cache-Control", "no-cache")
|
||
handler.send_header("X-Accel-Buffering", "no")
|
||
handler.send_header("Connection", "keep-alive")
|
||
handler.end_headers()
|
||
try:
|
||
while True:
|
||
try:
|
||
event, data = subscriber.get(timeout=_SSE_HEARTBEAT_INTERVAL_SECONDS)
|
||
except queue.Empty:
|
||
handler.wfile.write(b": heartbeat\n\n")
|
||
handler.wfile.flush()
|
||
continue
|
||
_sse(handler, event, data)
|
||
if event in ("stream_end", "error", "cancel"):
|
||
break
|
||
except _CLIENT_DISCONNECT_ERRORS:
|
||
pass
|
||
finally:
|
||
if subscriber is not stream and hasattr(stream, "unsubscribe"):
|
||
try:
|
||
stream.unsubscribe(subscriber)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
|
||
|
||
def _terminal_session_and_workspace(body_or_query):
|
||
sid = str(body_or_query.get("session_id", "")).strip()
|
||
if not sid:
|
||
raise ValueError("session_id required")
|
||
try:
|
||
s = get_session(sid)
|
||
except KeyError:
|
||
raise KeyError("Session not found")
|
||
workspace = resolve_trusted_workspace(getattr(s, "workspace", "") or "")
|
||
return sid, workspace
|
||
|
||
|
||
def _handle_terminal_start(handler, body):
|
||
try:
|
||
sid, workspace = _terminal_session_and_workspace(body)
|
||
from api.terminal import start_terminal
|
||
term = start_terminal(
|
||
sid,
|
||
workspace,
|
||
rows=int(body.get("rows") or 24),
|
||
cols=int(body.get("cols") or 80),
|
||
restart=bool(body.get("restart")),
|
||
)
|
||
return j(
|
||
handler,
|
||
{
|
||
"ok": True,
|
||
"session_id": sid,
|
||
"workspace": term.workspace,
|
||
"running": term.is_alive(),
|
||
},
|
||
)
|
||
except KeyError as e:
|
||
return bad(handler, str(e), 404)
|
||
except ValueError as e:
|
||
return bad(handler, str(e), 400)
|
||
except Exception as e:
|
||
return bad(handler, _sanitize_error(e), 500)
|
||
|
||
|
||
def _handle_terminal_input(handler, body):
|
||
try:
|
||
require(body, "session_id")
|
||
data = str(body.get("data", ""))
|
||
if len(data) > 8192:
|
||
return bad(handler, "input too large", 413)
|
||
from api.terminal import write_terminal
|
||
write_terminal(body["session_id"], data)
|
||
return j(handler, {"ok": True})
|
||
except KeyError as e:
|
||
return bad(handler, str(e), 404)
|
||
except ValueError as e:
|
||
return bad(handler, str(e), 400)
|
||
except Exception as e:
|
||
return bad(handler, _sanitize_error(e), 500)
|
||
|
||
|
||
def _handle_terminal_resize(handler, body):
|
||
try:
|
||
require(body, "session_id")
|
||
from api.terminal import resize_terminal
|
||
resize_terminal(
|
||
body["session_id"],
|
||
rows=int(body.get("rows") or 24),
|
||
cols=int(body.get("cols") or 80),
|
||
)
|
||
return j(handler, {"ok": True})
|
||
except KeyError as e:
|
||
return bad(handler, str(e), 404)
|
||
except ValueError as e:
|
||
return bad(handler, str(e), 400)
|
||
except Exception as e:
|
||
return bad(handler, _sanitize_error(e), 500)
|
||
|
||
|
||
def _handle_terminal_close(handler, body):
|
||
try:
|
||
require(body, "session_id")
|
||
from api.terminal import close_terminal
|
||
closed = close_terminal(body["session_id"])
|
||
return j(handler, {"ok": True, "closed": closed})
|
||
except ValueError as e:
|
||
return bad(handler, str(e), 400)
|
||
|
||
|
||
def _handle_terminal_output(handler, parsed):
|
||
qs = parse_qs(parsed.query)
|
||
sid = qs.get("session_id", [""])[0]
|
||
if not sid:
|
||
return bad(handler, "session_id required")
|
||
from api.terminal import get_terminal
|
||
term = get_terminal(sid)
|
||
if term is None:
|
||
return j(handler, {"error": "terminal not running"}, status=404)
|
||
|
||
handler.send_response(200)
|
||
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||
handler.send_header("Cache-Control", "no-cache")
|
||
handler.send_header("X-Accel-Buffering", "no")
|
||
handler.send_header("Connection", "keep-alive")
|
||
handler.end_headers()
|
||
try:
|
||
while True:
|
||
try:
|
||
event, data = term.output.get(timeout=_SSE_HEARTBEAT_INTERVAL_SECONDS)
|
||
except queue.Empty:
|
||
handler.wfile.write(b": terminal heartbeat\n\n")
|
||
handler.wfile.flush()
|
||
if term.closed.is_set() and term.output.empty():
|
||
_sse(handler, "terminal_closed", {"exit_code": term.proc.poll()})
|
||
break
|
||
continue
|
||
_sse(handler, event, data)
|
||
if event in ("terminal_closed", "terminal_error"):
|
||
break
|
||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||
pass
|
||
return True
|
||
|
||
|
||
def _gateway_sse_probe_payload(settings, watcher):
|
||
enabled = bool(settings.get('show_cli_sessions'))
|
||
# Use the public is_alive() accessor where available (current GatewayWatcher);
|
||
# fall back to the private _thread check for any older in-memory instance
|
||
# that might still be hanging around mid-upgrade, and for test doubles that
|
||
# don't implement the full public API.
|
||
if watcher is None:
|
||
watcher_alive = False
|
||
elif hasattr(watcher, 'is_alive') and callable(getattr(watcher, 'is_alive')):
|
||
watcher_alive = bool(watcher.is_alive())
|
||
else:
|
||
_t = getattr(watcher, '_thread', None)
|
||
watcher_alive = _t is not None and _t.is_alive()
|
||
payload = {
|
||
'enabled': enabled,
|
||
'fallback_poll_ms': 30000,
|
||
'ok': enabled and watcher_alive,
|
||
'watcher_running': watcher_alive,
|
||
}
|
||
if not enabled:
|
||
payload['error'] = 'agent sessions not enabled'
|
||
return payload, 404
|
||
if not watcher_alive:
|
||
payload['error'] = 'watcher not started'
|
||
return payload, 503
|
||
return payload, 200
|
||
|
||
|
||
def _handle_gateway_sse_stream(handler, parsed):
|
||
"""SSE endpoint for real-time gateway session updates.
|
||
Streams change events from the gateway watcher background thread.
|
||
Only active when show_cli_sessions (show_agent_sessions) setting is enabled.
|
||
"""
|
||
settings = load_settings()
|
||
|
||
from api.gateway_watcher import get_watcher
|
||
watcher = get_watcher()
|
||
|
||
probe = parse_qs(parsed.query).get('probe', [''])[0].lower() in {'1', 'true', 'yes'}
|
||
if probe:
|
||
payload, status = _gateway_sse_probe_payload(settings, watcher)
|
||
return j(handler, payload, status=status)
|
||
|
||
# Check if the feature is enabled
|
||
if not settings.get('show_cli_sessions'):
|
||
return j(handler, {'error': 'agent sessions not enabled'}, status=404)
|
||
|
||
# Same watcher_alive semantics as the probe path — centralised via
|
||
# the helper so both branches stay in sync.
|
||
_probe_body, _probe_status = _gateway_sse_probe_payload(settings, watcher)
|
||
if not _probe_body['watcher_running']:
|
||
return j(handler, {'error': 'watcher not started'}, status=503)
|
||
|
||
handler.send_response(200)
|
||
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
|
||
handler.send_header('Cache-Control', 'no-cache')
|
||
handler.send_header('X-Accel-Buffering', 'no')
|
||
handler.send_header('Connection', 'keep-alive')
|
||
handler.end_headers()
|
||
|
||
q = watcher.subscribe()
|
||
try:
|
||
# Send initial snapshot immediately
|
||
from api.models import get_cli_sessions
|
||
initial = get_cli_sessions()
|
||
_sse(handler, 'sessions_changed', {'sessions': initial})
|
||
|
||
while True:
|
||
try:
|
||
event_data = q.get(timeout=_SSE_HEARTBEAT_INTERVAL_SECONDS)
|
||
except queue.Empty:
|
||
handler.wfile.write(b': keepalive\n\n')
|
||
handler.wfile.flush()
|
||
continue
|
||
if event_data is None:
|
||
break # watcher is stopping
|
||
_sse(handler, event_data.get('type', 'sessions_changed'), event_data)
|
||
except _CLIENT_DISCONNECT_ERRORS:
|
||
pass
|
||
finally:
|
||
watcher.unsubscribe(q)
|
||
return True
|
||
|
||
|
||
def _content_disposition_value(disposition: str, filename: str) -> str:
|
||
"""Build a latin-1-safe Content-Disposition value with RFC 5987 filename*."""
|
||
import urllib.parse as _up
|
||
|
||
safe_name = Path(filename).name.replace("\r", "").replace("\n", "")
|
||
ascii_fallback = "".join(
|
||
ch if 32 <= ord(ch) < 127 and ch not in {'"', '\\'} else "_"
|
||
for ch in safe_name
|
||
).strip(" .")
|
||
if not ascii_fallback:
|
||
suffix = Path(safe_name).suffix
|
||
ascii_suffix = "".join(
|
||
ch if 32 <= ord(ch) < 127 and ch not in {'"', '\\'} else "_"
|
||
for ch in suffix
|
||
)
|
||
ascii_fallback = f"download{ascii_suffix}" if ascii_suffix else "download"
|
||
quoted_name = _up.quote(safe_name, safe="")
|
||
return (
|
||
f'{disposition}; filename="{ascii_fallback}"; '
|
||
f"filename*=UTF-8''{quoted_name}"
|
||
)
|
||
|
||
|
||
def _parse_range_header(range_header: str, file_size: int) -> tuple[int, int] | None:
|
||
"""Parse a single HTTP bytes range into inclusive start/end offsets."""
|
||
if not range_header or not range_header.startswith("bytes=") or file_size < 1:
|
||
return None
|
||
spec = range_header.split("=", 1)[1].strip()
|
||
if "," in spec or "-" not in spec:
|
||
return None
|
||
start_s, end_s = spec.split("-", 1)
|
||
try:
|
||
if start_s == "":
|
||
# suffix range: bytes=-500
|
||
suffix_len = int(end_s)
|
||
if suffix_len <= 0:
|
||
return None
|
||
start = max(0, file_size - suffix_len)
|
||
end = file_size - 1
|
||
else:
|
||
start = int(start_s)
|
||
end = int(end_s) if end_s else file_size - 1
|
||
if start < 0:
|
||
return None
|
||
end = min(end, file_size - 1)
|
||
if start > end or start >= file_size:
|
||
return None
|
||
return start, end
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_control: str, *, csp: str | None = None):
|
||
"""Serve a file with correct MIME/disposition and optional byte-range support."""
|
||
try:
|
||
file_size = target.stat().st_size
|
||
except PermissionError:
|
||
return bad(handler, "Permission denied", 403)
|
||
except Exception:
|
||
return bad(handler, "Could not stat file", 500)
|
||
|
||
byte_range = _parse_range_header(handler.headers.get("Range", ""), file_size)
|
||
if handler.headers.get("Range") and byte_range is None:
|
||
handler.send_response(416)
|
||
handler.send_header("Content-Range", f"bytes */{file_size}")
|
||
handler.send_header("Accept-Ranges", "bytes")
|
||
_security_headers(handler)
|
||
handler.end_headers()
|
||
return True
|
||
|
||
start, end = byte_range if byte_range else (0, max(0, file_size - 1))
|
||
content_length = end - start + 1 if file_size else 0
|
||
handler.send_response(206 if byte_range else 200)
|
||
handler.send_header("Content-Type", mime)
|
||
handler.send_header("Content-Length", str(content_length))
|
||
handler.send_header("Accept-Ranges", "bytes")
|
||
if byte_range:
|
||
handler.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
|
||
handler.send_header("Cache-Control", cache_control)
|
||
handler.send_header("Content-Disposition", _content_disposition_value(disposition, target.name))
|
||
if csp:
|
||
handler.send_header("Content-Security-Policy", csp)
|
||
_security_headers(handler)
|
||
handler.end_headers()
|
||
|
||
if content_length:
|
||
try:
|
||
with target.open("rb") as f:
|
||
f.seek(start)
|
||
remaining = content_length
|
||
while remaining:
|
||
chunk = f.read(min(1024 * 1024, remaining))
|
||
if not chunk:
|
||
break
|
||
handler.wfile.write(chunk)
|
||
remaining -= len(chunk)
|
||
except PermissionError:
|
||
return True
|
||
return True
|
||
|
||
|
||
def _handle_media(handler, parsed):
|
||
"""Serve a local file by absolute path for inline display in the chat.
|
||
|
||
Security:
|
||
- Path must resolve to an allowed root (hermes home, /tmp, common dirs)
|
||
- Auth-gated when auth is enabled
|
||
- Only image MIME types are served inline; all others force download
|
||
- SVG always served as attachment (XSS risk)
|
||
- No path traversal: resolved path must stay within an allowed root
|
||
"""
|
||
import os as _os
|
||
from api.auth import is_auth_enabled, parse_cookie, verify_session
|
||
_HOME = Path(_os.path.expanduser("~"))
|
||
_HERMES_HOME = Path(_os.getenv("HERMES_HOME", str(_HOME / ".hermes"))).expanduser()
|
||
|
||
# Auth check
|
||
if is_auth_enabled():
|
||
cv = parse_cookie(handler)
|
||
if not (cv and verify_session(cv)):
|
||
handler.send_response(401)
|
||
handler.send_header("Content-Type", "application/json")
|
||
handler.end_headers()
|
||
handler.wfile.write(b'{"error":"Authentication required"}')
|
||
return
|
||
|
||
qs = parse_qs(parsed.query)
|
||
raw_path = qs.get("path", [""])[0].strip()
|
||
if not raw_path:
|
||
return bad(handler, "path parameter required", 400)
|
||
|
||
# Resolve the path and check it is within an allowed root
|
||
try:
|
||
target = Path(raw_path).resolve()
|
||
except Exception:
|
||
return bad(handler, "Invalid path", 400)
|
||
|
||
# Allowed roots: hermes home, /tmp, and active workspace.
|
||
# Intentionally NOT the entire home dir — that would expose ~/.ssh,
|
||
# ~/.aws, browser profiles, etc. to any authenticated user.
|
||
allowed_roots = [
|
||
_HERMES_HOME.resolve(),
|
||
Path("/tmp").resolve(),
|
||
(_HOME / ".hermes").resolve(),
|
||
]
|
||
# Also allow the active workspace directory (where screenshots land)
|
||
try:
|
||
from api.workspace import get_last_workspace
|
||
ws = Path(get_last_workspace()).resolve()
|
||
if ws.is_dir():
|
||
allowed_roots.append(ws)
|
||
except Exception:
|
||
pass
|
||
within_allowed = any(
|
||
_os.path.commonpath([str(target), str(root)]) == str(root)
|
||
for root in allowed_roots
|
||
if root.exists()
|
||
)
|
||
if not within_allowed:
|
||
return bad(handler, "Path not in allowed location", 403)
|
||
|
||
if not target.exists() or not target.is_file():
|
||
return j(handler, {"error": "not found"}, status=404)
|
||
|
||
# Determine MIME type
|
||
ext = target.suffix.lower()
|
||
mime = MIME_MAP.get(ext, "application/octet-stream")
|
||
|
||
# Only serve safe media/PDF types inline when explicitly requested. Everything
|
||
# else remains a download. SVG is always a download (XSS risk).
|
||
_INLINE_IMAGE_TYPES = {
|
||
"image/png", "image/jpeg", "image/gif", "image/webp",
|
||
"image/x-icon", "image/bmp",
|
||
}
|
||
_INLINE_PREVIEW_TYPES = _INLINE_IMAGE_TYPES | {
|
||
"audio/mpeg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/aac",
|
||
"audio/ogg", "audio/opus", "audio/flac",
|
||
"video/mp4", "video/quicktime", "video/webm", "video/ogg",
|
||
"application/pdf",
|
||
}
|
||
_DOWNLOAD_TYPES = {"image/svg+xml"} # SVG: XSS risk, force download
|
||
inline_preview = qs.get("inline", [""])[0] == "1"
|
||
disposition = "inline" if (
|
||
mime not in _DOWNLOAD_TYPES and (
|
||
mime in _INLINE_IMAGE_TYPES or (inline_preview and mime in _INLINE_PREVIEW_TYPES)
|
||
)
|
||
) else "attachment"
|
||
return _serve_file_bytes(handler, target, mime, disposition, "private, max-age=3600")
|
||
|
||
|
||
def _handle_file_raw(handler, parsed):
|
||
qs = parse_qs(parsed.query)
|
||
sid = qs.get("session_id", [""])[0]
|
||
if not sid:
|
||
return bad(handler, "session_id is required")
|
||
try:
|
||
s = get_session(sid)
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
rel = qs.get("path", [""])[0]
|
||
force_download = qs.get("download", [""])[0] == "1"
|
||
target = safe_resolve(Path(s.workspace), rel)
|
||
if not target.exists() or not target.is_file():
|
||
return j(handler, {"error": "not found"}, status=404)
|
||
ext = target.suffix.lower()
|
||
mime = MIME_MAP.get(ext, "application/octet-stream")
|
||
# Security: force download for dangerous MIME types to prevent XSS.
|
||
# Exception: ?inline=1 permits text/html to be served inline for the
|
||
# sandboxed workspace HTML preview iframe (sandbox="allow-scripts" with no
|
||
# allow-same-origin, so the iframe cannot access parent cookies/storage).
|
||
inline_preview = qs.get("inline", [""])[0] == "1"
|
||
dangerous_types = {"text/html", "application/xhtml+xml", "image/svg+xml"}
|
||
html_inline_ok = inline_preview and mime == "text/html"
|
||
disposition = "attachment" if force_download or (mime in dangerous_types and not html_inline_ok) else "inline"
|
||
# Defense-in-depth for ?inline=1 HTML: even though the workspace.js iframe
|
||
# sets sandbox="allow-scripts", a user could be tricked into opening the
|
||
# ?inline=1 URL directly in a top-level tab (e.g. via a chat link), which
|
||
# would render the HTML in the WebUI's origin without iframe sandbox. The
|
||
# CSP sandbox directive applies the same isolation server-side: without
|
||
# allow-same-origin, the document is treated as a unique opaque origin and
|
||
# cannot read WebUI cookies, localStorage, or postMessage to the parent.
|
||
csp = "sandbox allow-scripts" if html_inline_ok else None
|
||
# _serve_file_bytes sends Content-Security-Policy when csp is set.
|
||
return _serve_file_bytes(handler, target, mime, disposition, "no-store", csp=csp)
|
||
|
||
|
||
def _handle_file_read(handler, parsed):
|
||
qs = parse_qs(parsed.query)
|
||
sid = qs.get("session_id", [""])[0]
|
||
if not sid:
|
||
return bad(handler, "session_id is required")
|
||
try:
|
||
s = get_session(sid)
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
rel = qs.get("path", [""])[0]
|
||
if not rel:
|
||
return bad(handler, "path is required")
|
||
try:
|
||
return j(handler, read_file_content(Path(s.workspace), rel))
|
||
except (FileNotFoundError, ValueError) as e:
|
||
return bad(handler, _sanitize_error(e), 404)
|
||
|
||
|
||
def _handle_approval_pending(handler, parsed):
|
||
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||
with _lock:
|
||
queue = _pending.get(sid)
|
||
# Support both the new list format and a legacy single-dict value.
|
||
if isinstance(queue, list):
|
||
p = queue[0] if queue else None
|
||
total = len(queue)
|
||
elif queue:
|
||
p = queue
|
||
total = 1
|
||
else:
|
||
p = None
|
||
total = 0
|
||
if p:
|
||
return j(handler, {"pending": dict(p), "pending_count": total})
|
||
return j(handler, {"pending": None, "pending_count": 0})
|
||
|
||
|
||
def _handle_approval_sse_stream(handler, parsed):
|
||
"""SSE endpoint for real-time approval notifications.
|
||
|
||
Long-lived connection that pushes approval events the moment they arrive,
|
||
replacing the 1.5s polling loop. The frontend uses EventSource and falls
|
||
back to HTTP polling if the connection fails.
|
||
"""
|
||
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||
if not sid:
|
||
return bad(handler, "session_id is required")
|
||
|
||
# Subscribe AND snapshot atomically under a single _lock acquisition so a
|
||
# submit_pending() that fires between the two cannot be lost. If we
|
||
# snapshot first then subscribe (the naive ordering), an approval that
|
||
# arrives in the gap is appended to _pending (after our snapshot) AND
|
||
# notified to subscribers (before we joined) — leaving the client unaware
|
||
# until the next event arrives.
|
||
q = queue.Queue(maxsize=16)
|
||
initial_pending = None
|
||
initial_count = 0
|
||
with _lock:
|
||
_approval_sse_subscribers.setdefault(sid, []).append(q)
|
||
q_list = _pending.get(sid)
|
||
if isinstance(q_list, list):
|
||
initial_pending = dict(q_list[0]) if q_list else None
|
||
initial_count = len(q_list)
|
||
elif q_list:
|
||
initial_pending = dict(q_list)
|
||
initial_count = 1
|
||
|
||
handler.send_response(200)
|
||
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
|
||
handler.send_header('Cache-Control', 'no-cache')
|
||
handler.send_header('X-Accel-Buffering', 'no')
|
||
handler.send_header('Connection', 'keep-alive')
|
||
handler.end_headers()
|
||
|
||
from api.streaming import _sse
|
||
|
||
# Push initial state immediately so the client doesn't miss anything.
|
||
_sse(handler, 'initial', {"pending": initial_pending, "pending_count": initial_count})
|
||
|
||
try:
|
||
while True:
|
||
try:
|
||
payload = q.get(timeout=_SSE_HEARTBEAT_INTERVAL_SECONDS)
|
||
except queue.Empty:
|
||
# Keepalive — SSE comment line prevents proxy/CDN timeout.
|
||
handler.wfile.write(b': keepalive\n\n')
|
||
handler.wfile.flush()
|
||
continue
|
||
if payload is None:
|
||
break # signal to close
|
||
_sse(handler, 'approval', payload)
|
||
except _CLIENT_DISCONNECT_ERRORS:
|
||
pass # client went away — normal for long-lived connections
|
||
finally:
|
||
_approval_sse_unsubscribe(sid, q)
|
||
|
||
|
||
def _handle_approval_inject(handler, parsed):
|
||
"""Inject a fake pending approval -- loopback-only, used by automated tests."""
|
||
qs = parse_qs(parsed.query)
|
||
sid = qs.get("session_id", [""])[0]
|
||
key = qs.get("pattern_key", ["test_pattern"])[0]
|
||
cmd = qs.get("command", ["rm -rf /tmp/test"])[0]
|
||
if sid:
|
||
submit_pending(
|
||
sid,
|
||
{
|
||
"command": cmd,
|
||
"pattern_key": key,
|
||
"pattern_keys": [key],
|
||
"description": "test pattern",
|
||
},
|
||
)
|
||
return j(handler, {"ok": True, "session_id": sid})
|
||
return j(handler, {"error": "session_id required"}, status=400)
|
||
|
||
|
||
def _handle_clarify_pending(handler, parsed):
|
||
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||
pending = get_clarify_pending(sid)
|
||
if pending:
|
||
return j(handler, {"pending": pending})
|
||
return j(handler, {"pending": None})
|
||
|
||
|
||
def _handle_clarify_sse_stream(handler, parsed):
|
||
"""SSE endpoint for real-time clarify notifications.
|
||
|
||
Long-lived connection that pushes clarify events the moment they arrive,
|
||
replacing the 1.5s polling loop. The frontend uses EventSource and falls
|
||
back to HTTP polling if the connection fails.
|
||
"""
|
||
if clarify_sse_subscribe is None:
|
||
return bad(handler, "clarify SSE not available")
|
||
|
||
sid = parse_qs(parsed.query).get("session_id", [""])[0]
|
||
if not sid:
|
||
return bad(handler, "session_id is required")
|
||
|
||
# Subscribe AND snapshot atomically. We import clarify's _lock so that
|
||
# subscribe and the snapshot read happen under the same mutex — same
|
||
# pattern as the approval SSE handler.
|
||
#
|
||
# NOTE: We must NOT call clarify.get_pending() here — it acquires _lock
|
||
# internally, which would deadlock since clarify._lock is a non-reentrant
|
||
# threading.Lock. Instead, read _gateway_queues / _pending inline under
|
||
# the lock we already hold.
|
||
from api.clarify import (
|
||
_lock as _clarify_lock,
|
||
_clarify_sse_subscribers as _clarify_subs,
|
||
_gateway_queues as _clarify_gateway_queues,
|
||
_pending as _clarify_pending,
|
||
)
|
||
q = queue.Queue(maxsize=16)
|
||
initial_pending = None
|
||
initial_count = 0
|
||
with _clarify_lock:
|
||
_clarify_subs.setdefault(sid, []).append(q)
|
||
gw_q = _clarify_gateway_queues.get(sid) or []
|
||
if gw_q:
|
||
initial_pending = dict(gw_q[0].data)
|
||
initial_count = len(gw_q)
|
||
else:
|
||
_legacy = _clarify_pending.get(sid)
|
||
if _legacy:
|
||
initial_pending = dict(_legacy)
|
||
initial_count = 1
|
||
|
||
handler.send_response(200)
|
||
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
|
||
handler.send_header('Cache-Control', 'no-cache')
|
||
handler.send_header('X-Accel-Buffering', 'no')
|
||
handler.send_header('Connection', 'keep-alive')
|
||
handler.end_headers()
|
||
|
||
from api.streaming import _sse
|
||
|
||
# Push initial state immediately so the client doesn't miss anything.
|
||
_sse(handler, 'initial', {"pending": initial_pending, "pending_count": initial_count})
|
||
|
||
try:
|
||
while True:
|
||
try:
|
||
payload = q.get(timeout=_SSE_HEARTBEAT_INTERVAL_SECONDS)
|
||
except queue.Empty:
|
||
handler.wfile.write(b': keepalive\n\n')
|
||
handler.wfile.flush()
|
||
continue
|
||
if payload is None:
|
||
break
|
||
_sse(handler, 'clarify', payload)
|
||
except _CLIENT_DISCONNECT_ERRORS:
|
||
pass
|
||
finally:
|
||
clarify_sse_unsubscribe(sid, q)
|
||
|
||
|
||
def _handle_clarify_inject(handler, parsed):
|
||
"""Inject a fake pending clarify prompt -- loopback-only, used by automated tests."""
|
||
qs = parse_qs(parsed.query)
|
||
sid = qs.get("session_id", [""])[0]
|
||
question = qs.get("question", ["Which option?"])[0]
|
||
choices = qs.get("choices", [])
|
||
if sid:
|
||
submit_clarify_pending(
|
||
sid,
|
||
{
|
||
"question": question,
|
||
"choices_offered": choices,
|
||
"session_id": sid,
|
||
"kind": "clarify",
|
||
},
|
||
)
|
||
return j(handler, {"ok": True, "session_id": sid})
|
||
return j(handler, {"error": "session_id required"}, status=400)
|
||
|
||
|
||
def _handle_live_models(handler, parsed):
|
||
"""Return the live model list for a provider.
|
||
|
||
Delegates to the agent's provider_model_ids() which handles:
|
||
- OpenRouter: live fetch from /api/v1/models
|
||
- Anthropic: live fetch from /v1/models (API key or OAuth token)
|
||
- Copilot: live fetch from api.githubcopilot.com/models with correct headers
|
||
- openai-codex: Codex OAuth endpoint + local ~/.codex/ cache fallback
|
||
- Nous: live fetch from inference-api.nousresearch.com/v1/models
|
||
- DeepSeek, kimi-coding, opencode-zen/go, custom: generic OpenAI-compat /v1/models
|
||
- ZAI, MiniMax, Google/Gemini: fall back to static list (non-standard endpoints)
|
||
- All others: static _PROVIDER_MODELS fallback
|
||
|
||
The agent already maintains all provider-specific auth and endpoint logic
|
||
in one place; the WebUI inherits it rather than duplicating it.
|
||
|
||
Query params:
|
||
provider (optional) — provider ID; defaults to active profile provider
|
||
"""
|
||
qs = parse_qs(parsed.query)
|
||
provider = (qs.get("provider", [""])[0] or "").lower().strip()
|
||
|
||
try:
|
||
from api.config import get_config as _gc
|
||
cfg = _gc()
|
||
if not provider:
|
||
provider = cfg.get("model", {}).get("provider") or ""
|
||
if not provider:
|
||
return j(handler, {"error": "no_provider", "models": []})
|
||
|
||
# Normalize provider alias so 'z.ai' -> 'zai', 'x.ai' -> 'xai', etc.
|
||
# The browser sends whatever active_provider the static endpoint returned;
|
||
# without normalization, provider_model_ids() misses the alias and returns [].
|
||
# Uses the WebUI-owned table (api/config._resolve_provider_alias) which
|
||
# works even when hermes_cli is not on sys.path.
|
||
from api.config import _resolve_provider_alias
|
||
provider = _resolve_provider_alias(provider)
|
||
|
||
cache_key = _live_models_cache_key(provider)
|
||
cached = _get_cached_live_models(cache_key)
|
||
if cached is not None:
|
||
return j(handler, cached)
|
||
|
||
def _finish(payload: dict):
|
||
_set_cached_live_models(cache_key, payload)
|
||
return j(handler, payload)
|
||
|
||
# Delegate to the agent's live-fetch + fallback resolver.
|
||
# provider_model_ids() tries live endpoints first and falls back to
|
||
# the static _PROVIDER_MODELS list — it never raises.
|
||
try:
|
||
import sys as _sys
|
||
import os as _os
|
||
_agent_dir = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
|
||
"..", "..", ".hermes", "hermes-agent")
|
||
_agent_dir = _os.path.normpath(_agent_dir)
|
||
if _agent_dir not in _sys.path:
|
||
_sys.path.insert(0, _agent_dir)
|
||
from hermes_cli.models import provider_model_ids as _pmi
|
||
ids = _pmi(provider)
|
||
except Exception as _import_err:
|
||
logger.debug("provider_model_ids import failed for %s: %s", provider, _import_err)
|
||
ids = []
|
||
|
||
if not ids:
|
||
# For 'custom' and 'custom:*' providers, provider_model_ids()
|
||
# returns [] because they aren't real hermes_cli endpoints.
|
||
# Fall back to the custom_providers entries from config.yaml so
|
||
# the live-model enrichment step can add any models that weren't
|
||
# already in the static list (issue #1619).
|
||
if provider == "custom" or provider.startswith("custom:"):
|
||
try:
|
||
_cp_entries = cfg.get("custom_providers", [])
|
||
if isinstance(_cp_entries, list):
|
||
ids = [
|
||
_cp.get("model", "")
|
||
for _cp in _cp_entries
|
||
if isinstance(_cp, dict) and _cp.get("model", "")
|
||
]
|
||
except Exception:
|
||
pass
|
||
|
||
# If still no ids, try fetching from base_url directly (OpenAI-compat endpoint)
|
||
if not ids and (provider == "custom" or provider.startswith("custom:")):
|
||
_base_url = cfg.get("model", {}).get("base_url")
|
||
_api_key = cfg.get("model", {}).get("api_key")
|
||
if _base_url and _api_key:
|
||
try:
|
||
import urllib.request
|
||
import json
|
||
|
||
# Build the models endpoint URL
|
||
# AxonHub and similar OpenAI-compat endpoints serve /v1/models
|
||
_ep = _base_url.rstrip("/")
|
||
# If base_url already ends with /v1, use /models; otherwise add /v1/models
|
||
if _ep.endswith("/v1"):
|
||
_models_url = f"{_ep}/models"
|
||
else:
|
||
_models_url = f"{_ep}/v1/models"
|
||
|
||
_req = urllib.request.Request(
|
||
_models_url,
|
||
headers={"Authorization": f"Bearer {_api_key}"},
|
||
)
|
||
|
||
with urllib.request.urlopen(_req, timeout=8) as _resp:
|
||
_body = json.loads(_resp.read())
|
||
|
||
# Parse response: {"data": [{"id": "model1", ...}, ...]}
|
||
if isinstance(_body, dict):
|
||
_data = _body.get("data", [])
|
||
if isinstance(_data, list):
|
||
ids = [m.get("id", "") for m in _data if m.get("id")]
|
||
elif isinstance(_body, list):
|
||
ids = [m.get("id", m) if isinstance(m, dict) else m for m in _body]
|
||
|
||
if ids:
|
||
logger.debug("Live-fetched %d models from custom provider %s", len(ids), _base_url)
|
||
else:
|
||
logger.debug("Custom provider returned no models from %s", _base_url)
|
||
|
||
except Exception as _fetch_err:
|
||
logger.debug("Live fetch from custom provider failed: %s", _fetch_err)
|
||
|
||
# ── OpenAI-compat live fetch fallback ──────────────────────────────────
|
||
# When provider_model_ids() is unavailable or returns [] for a provider
|
||
# that exposes a standard /v1/models endpoint, fetch directly. This
|
||
# eliminates the need to keep _PROVIDER_MODELS in sync for providers
|
||
# that have a discoverable API (#871).
|
||
#
|
||
# WARNING: This uses synchronous urllib.request which blocks the worker
|
||
# thread for up to 8 seconds on timeout. This is acceptable because:
|
||
# (a) the server uses threading (not async), so other requests continue;
|
||
# (b) the frontend shows the static list immediately and enriches in
|
||
# the background via _fetchLiveModels(), so the user never waits.
|
||
if not ids:
|
||
_ep = _OPENAI_COMPAT_ENDPOINTS.get(provider)
|
||
if _ep:
|
||
try:
|
||
import urllib.request
|
||
_providers_cfg = cfg.get("providers", {})
|
||
_prov = _providers_cfg.get(provider, {}) if isinstance(_providers_cfg, dict) else {}
|
||
# Only use provider-scoped key — never fall back to a top-level
|
||
# api_key which may belong to a different provider.
|
||
_key = _prov.get("api_key") if isinstance(_prov, dict) else None
|
||
if not _key:
|
||
_key = cfg.get("model", {}).get("api_key")
|
||
if _key:
|
||
_req = urllib.request.Request(
|
||
f"{_ep}/models",
|
||
headers={"Authorization": f"Bearer {_key}"},
|
||
)
|
||
with urllib.request.urlopen(_req, timeout=8) as _resp:
|
||
_body = json.loads(_resp.read())
|
||
ids = [m.get("id", "") for m in _body.get("data", []) if m.get("id")]
|
||
logger.debug("Live-fetched %d models from %s /v1/models", len(ids), provider)
|
||
except Exception as _fetch_err:
|
||
logger.debug("Live fetch from %s failed: %s", provider, _fetch_err)
|
||
# Fall through to static list below
|
||
|
||
# Static fallback — only reached when live fetch also failed.
|
||
if not ids:
|
||
from api.config import _PROVIDER_MODELS as _pm
|
||
ids = [m["id"] for m in _pm.get(provider, [])]
|
||
if not ids:
|
||
return _finish({"provider": provider, "models": [], "count": 0})
|
||
|
||
# For Nous Portal, apply the same featured-set cap that
|
||
# /api/models uses so background enrichment via _fetchLiveModels()
|
||
# doesn't undo the dropdown trim — otherwise a 397-model catalog
|
||
# would still flood the picker after the initial render finished
|
||
# the cap. The full list is returned via the main /api/models
|
||
# endpoint's extra_models field for /model autocomplete; the live
|
||
# endpoint is purely a dropdown-enrichment surface, so it should
|
||
# match the dropdown's visibility budget. (#1567)
|
||
if provider == "nous":
|
||
try:
|
||
from api.config import _build_nous_featured_set
|
||
_default_model = (cfg.get("model", {}) or {}).get("model") if isinstance(cfg.get("model"), dict) else None
|
||
_featured, _ = _build_nous_featured_set(ids, selected_model_id=_default_model)
|
||
ids = _featured
|
||
except Exception:
|
||
logger.debug("Failed to apply Nous featured-set cap for /api/models/live")
|
||
|
||
# Normalise to {id, label} — provider_model_ids() returns plain string IDs.
|
||
# For ollama-cloud use the shared Ollama formatter (handles `:variant` suffix).
|
||
# For all other providers use a simpler hyphen-split capitaliser.
|
||
from api.config import _format_ollama_label as _fmt_ollama
|
||
|
||
def _make_label(mid):
|
||
"""Best-effort human label from a model ID string."""
|
||
if provider in ("ollama", "ollama-cloud"):
|
||
return _fmt_ollama(mid)
|
||
# Preserve slashes for router IDs like "anthropic/claude-sonnet-4.6"
|
||
display = mid.split("/")[-1] if "/" in mid else mid
|
||
parts = display.split("-")
|
||
result = []
|
||
for p in parts:
|
||
pl = p.lower()
|
||
if pl == "gpt":
|
||
result.append("GPT")
|
||
elif pl in ("claude", "gemini", "gemma", "llama", "mistral",
|
||
"qwen", "deepseek", "grok", "kimi", "glm"):
|
||
result.append(p.capitalize())
|
||
elif p[:1].isdigit():
|
||
result.append(p) # version numbers: 5.4, 3.5, 4.6 — unchanged
|
||
else:
|
||
result.append(p.capitalize())
|
||
label = " ".join(result)
|
||
# Restore well-known uppercase tokens that title-casing breaks
|
||
for orig in ("GPT", "GLM", "API", "AI", "XL", "MoE"):
|
||
label = label.replace(orig.title(), orig)
|
||
return label
|
||
|
||
models_out = [{"id": mid, "label": _make_label(mid)} for mid in ids if mid]
|
||
return _finish({"provider": provider, "models": models_out,
|
||
"count": len(models_out)})
|
||
|
||
except Exception as _e:
|
||
logger.debug("_handle_live_models failed for %s: %s", provider, _e)
|
||
return j(handler, {"error": str(_e), "models": []})
|
||
|
||
|
||
def _handle_cron_history(handler, parsed):
|
||
"""List cron run output files with metadata (no content).
|
||
|
||
Returns lightweight file listing so the frontend can render a run history
|
||
without fetching full output for every run.
|
||
"""
|
||
from cron.jobs import OUTPUT_DIR as CRON_OUT
|
||
import re as _re
|
||
|
||
qs = parse_qs(parsed.query)
|
||
job_id = qs.get("job_id", [""])[0]
|
||
if not job_id:
|
||
return j(handler, {"error": "job_id required"}, status=400)
|
||
# Defense-in-depth: cron job_ids are 12-char hex from the agent's scheduler.
|
||
# Without validation, a job_id of "../<other>" would let an authenticated
|
||
# caller enumerate .md filenames in adjacent directories under CRON_OUT's
|
||
# parent. Mirror the rollback checkpoint id regex shape.
|
||
# (Opus pre-release advisor finding.)
|
||
if not _re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}", job_id) or job_id in (".", ".."):
|
||
return j(handler, {"error": "invalid job_id"}, status=400)
|
||
# Reject malformed offset/limit instead of letting int() raise ValueError
|
||
# and surface as a confusing 500. Clamp to safe ranges.
|
||
try:
|
||
offset = max(0, int(qs.get("offset", ["0"])[0]))
|
||
limit = max(1, min(500, int(qs.get("limit", ["50"])[0])))
|
||
except (ValueError, TypeError):
|
||
return j(handler, {"error": "offset and limit must be integers"}, status=400)
|
||
out_dir = CRON_OUT / job_id
|
||
runs = []
|
||
total = 0
|
||
if out_dir.exists():
|
||
all_files = sorted(out_dir.glob("*.md"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||
total = len(all_files)
|
||
page = all_files[offset:offset + limit]
|
||
for f in page:
|
||
try:
|
||
st = f.stat()
|
||
runs.append({
|
||
"filename": f.name,
|
||
"size": st.st_size,
|
||
"modified": st.st_mtime,
|
||
})
|
||
except OSError:
|
||
logger.debug("Failed to stat cron output file %s", f)
|
||
return j(handler, {"job_id": job_id, "runs": runs, "total": total, "offset": offset})
|
||
|
||
|
||
def _handle_cron_run_detail(handler, parsed):
|
||
"""Return full content of a single cron run output file."""
|
||
from cron.jobs import OUTPUT_DIR as CRON_OUT
|
||
import re as _re
|
||
|
||
qs = parse_qs(parsed.query)
|
||
job_id = qs.get("job_id", [""])[0]
|
||
filename = qs.get("filename", [""])[0]
|
||
if not job_id or not filename:
|
||
return j(handler, {"error": "job_id and filename required"}, status=400)
|
||
# Validate job_id shape (defense-in-depth even though the resolve+is_relative_to
|
||
# check below catches traversal — fail-closed at the parameter boundary so
|
||
# malformed job_ids return a 400 from the validator rather than a 400 from
|
||
# the path resolver).
|
||
if not _re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}", job_id) or job_id in (".", ".."):
|
||
return j(handler, {"error": "invalid job_id"}, status=400)
|
||
# Prevent path traversal — resolve and verify it stays within the job's output dir
|
||
fpath = (CRON_OUT / job_id / filename).resolve()
|
||
if not fpath.is_relative_to(CRON_OUT.resolve()):
|
||
return j(handler, {"error": "invalid filename"}, status=400)
|
||
if not fpath.exists():
|
||
return j(handler, {"error": "run not found"}, status=404)
|
||
try:
|
||
content = fpath.read_text(encoding="utf-8", errors="replace")
|
||
snippet = _cron_output_snippet(content)
|
||
return j(handler, {"job_id": job_id, "filename": filename,
|
||
"content": content, "snippet": snippet})
|
||
except Exception as e:
|
||
return j(handler, {"error": str(e)}, status=500)
|
||
|
||
|
||
def _cron_output_snippet(text: str, limit: int = 600) -> str:
|
||
"""Extract the response body from a cron output .md file for preview.
|
||
|
||
Contract: cron output files use markdown front-matter followed by a
|
||
``## Response`` (or ``# Response``) heading that marks the start of the
|
||
agent's reply. This function locates that heading and returns everything
|
||
after it (up to *limit* chars). If no heading is found the entire text
|
||
is returned — callers should be aware that front-matter fields (model,
|
||
timestamp, …) may appear in the snippet.
|
||
"""
|
||
lines = text.split("\n")
|
||
response_idx = -1
|
||
for i, line in enumerate(lines):
|
||
if line.startswith("## Response") or line.startswith("# Response"):
|
||
response_idx = i
|
||
break
|
||
body = ("\n".join(lines[response_idx + 1:]) if response_idx >= 0 else "\n".join(lines)).strip()
|
||
return body[:limit] or "(empty)"
|
||
|
||
|
||
def _handle_cron_output(handler, parsed):
|
||
from cron.jobs import OUTPUT_DIR as CRON_OUT
|
||
|
||
qs = parse_qs(parsed.query)
|
||
job_id = qs.get("job_id", [""])[0]
|
||
limit = int(qs.get("limit", ["5"])[0])
|
||
if not job_id:
|
||
return j(handler, {"error": "job_id required"}, status=400)
|
||
out_dir = CRON_OUT / job_id
|
||
outputs = []
|
||
if out_dir.exists():
|
||
files = sorted(out_dir.glob("*.md"), key=lambda f: f.stat().st_mtime, reverse=True)[:limit]
|
||
for f in files:
|
||
try:
|
||
txt = f.read_text(encoding="utf-8", errors="replace")
|
||
outputs.append({"filename": f.name, "content": _cron_output_content_window(txt)})
|
||
except Exception:
|
||
logger.debug("Failed to read cron output file %s", f)
|
||
return j(handler, {"job_id": job_id, "outputs": outputs})
|
||
|
||
|
||
def _handle_cron_status(handler, parsed):
|
||
"""Return running status for one or all cron jobs."""
|
||
qs = parse_qs(parsed.query)
|
||
job_id = qs.get("job_id", [""])[0]
|
||
if job_id:
|
||
running, elapsed = _is_cron_running(job_id)
|
||
return j(handler, {"job_id": job_id, "running": running, "elapsed": round(elapsed, 1)})
|
||
# Return status for all running jobs
|
||
with _RUNNING_CRON_LOCK:
|
||
all_running = {jid: round(time.time() - t, 1) for jid, t in _RUNNING_CRON_JOBS.items()}
|
||
return j(handler, {"running": all_running})
|
||
|
||
|
||
def _handle_cron_recent(handler, parsed):
|
||
"""Return cron jobs that have completed since a given timestamp."""
|
||
import datetime
|
||
|
||
qs = parse_qs(parsed.query)
|
||
since = float(qs.get("since", ["0"])[0])
|
||
try:
|
||
from cron.jobs import list_jobs
|
||
|
||
jobs = list_jobs(include_disabled=True)
|
||
completions = []
|
||
for job in jobs:
|
||
last_run = job.get("last_run_at")
|
||
if not last_run:
|
||
continue
|
||
if isinstance(last_run, str):
|
||
try:
|
||
ts = datetime.datetime.fromisoformat(
|
||
last_run.replace("Z", "+00:00")
|
||
).timestamp()
|
||
except (ValueError, TypeError):
|
||
continue
|
||
else:
|
||
ts = float(last_run)
|
||
if ts > since:
|
||
completions.append(
|
||
{
|
||
"job_id": job.get("id", ""),
|
||
"name": job.get("name", "Unknown"),
|
||
"status": job.get("last_status", "unknown"),
|
||
"completed_at": ts,
|
||
}
|
||
)
|
||
return j(handler, {"completions": completions, "since": since})
|
||
except ImportError:
|
||
return j(handler, {"completions": [], "since": since})
|
||
|
||
|
||
def _handle_memory_read(handler):
|
||
try:
|
||
from api.profiles import get_active_hermes_home
|
||
|
||
mem_dir = get_active_hermes_home() / "memories"
|
||
except ImportError:
|
||
mem_dir = Path.home() / ".hermes" / "memories"
|
||
mem_file = mem_dir / "MEMORY.md"
|
||
user_file = mem_dir / "USER.md"
|
||
memory = (
|
||
mem_file.read_text(encoding="utf-8", errors="replace")
|
||
if mem_file.exists()
|
||
else ""
|
||
)
|
||
user = (
|
||
user_file.read_text(encoding="utf-8", errors="replace")
|
||
if user_file.exists()
|
||
else ""
|
||
)
|
||
return j(
|
||
handler,
|
||
{
|
||
"memory": _redact_text(memory),
|
||
"user": _redact_text(user),
|
||
"memory_path": str(mem_file),
|
||
"user_path": str(user_file),
|
||
"memory_mtime": mem_file.stat().st_mtime if mem_file.exists() else None,
|
||
"user_mtime": user_file.stat().st_mtime if user_file.exists() else None,
|
||
},
|
||
)
|
||
|
||
|
||
# ── POST route helpers ────────────────────────────────────────────────────────
|
||
|
||
|
||
def _handle_sessions_cleanup(handler, body, zero_only=False):
|
||
cleaned = 0
|
||
for p in SESSION_DIR.glob("*.json"):
|
||
if p.name.startswith("_"):
|
||
continue
|
||
try:
|
||
s = Session.load(p.stem)
|
||
if zero_only:
|
||
should_delete = s and len(s.messages) == 0
|
||
else:
|
||
should_delete = s and s.title == "Untitled" and len(s.messages) == 0
|
||
if should_delete:
|
||
with LOCK:
|
||
SESSIONS.pop(p.stem, None)
|
||
p.unlink(missing_ok=True)
|
||
cleaned += 1
|
||
except Exception:
|
||
logger.debug("Failed to clean up session file %s", p)
|
||
if SESSION_INDEX_FILE.exists():
|
||
SESSION_INDEX_FILE.unlink(missing_ok=True)
|
||
return j(handler, {"ok": True, "cleaned": cleaned})
|
||
|
||
|
||
def _handle_btw(handler, body):
|
||
"""POST /api/btw — ephemeral side question using session context.
|
||
|
||
Creates a temporary hidden session, streams the answer via SSE, then
|
||
discards the session. The parent session is not modified.
|
||
"""
|
||
try:
|
||
require(body, "session_id")
|
||
require(body, "question")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
question = str(body["question"]).strip()
|
||
if not question:
|
||
return bad(handler, "question is required")
|
||
# Duplicate-stream guard (same pattern as chat/start)
|
||
current_stream_id = getattr(s, "active_stream_id", None)
|
||
if current_stream_id:
|
||
with STREAMS_LOCK:
|
||
if current_stream_id in STREAMS:
|
||
return j(handler, {"error": "session already has an active stream"}, status=409)
|
||
s.active_stream_id = None
|
||
# Create ephemeral hidden session inheriting context
|
||
from api.models import new_session as _new_session
|
||
model_provider = getattr(s, 'model_provider', None)
|
||
ephemeral = _new_session(
|
||
workspace=s.workspace,
|
||
model=s.model,
|
||
model_provider=model_provider,
|
||
profile=getattr(s, 'profile', None),
|
||
)
|
||
# Copy conversation history for context (agent reads from messages)
|
||
ephemeral.messages = list(s.messages or [])
|
||
ephemeral.title = f"btw: {question[:60]}"
|
||
ephemeral.save()
|
||
stream_id = uuid.uuid4().hex
|
||
ephemeral.active_stream_id = stream_id
|
||
ephemeral.save()
|
||
stream = create_stream_channel()
|
||
with STREAMS_LOCK:
|
||
STREAMS[stream_id] = stream
|
||
from api.background import track_btw
|
||
track_btw(body["session_id"], ephemeral.session_id, stream_id, question)
|
||
thr = threading.Thread(
|
||
target=_run_agent_streaming,
|
||
args=(ephemeral.session_id, question, s.model, s.workspace, stream_id, None),
|
||
kwargs={"ephemeral": True, "model_provider": model_provider},
|
||
daemon=True,
|
||
)
|
||
thr.start()
|
||
return j(handler, {"stream_id": stream_id, "session_id": ephemeral.session_id, "parent_session_id": body["session_id"]})
|
||
|
||
|
||
def _handle_background(handler, body):
|
||
"""POST /api/background — run prompt in parallel background agent.
|
||
|
||
Creates a hidden session, starts streaming in a daemon thread.
|
||
Frontend polls /api/background/status for completed results.
|
||
"""
|
||
try:
|
||
require(body, "session_id")
|
||
require(body, "prompt")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
prompt = str(body["prompt"]).strip()
|
||
if not prompt:
|
||
return bad(handler, "prompt is required")
|
||
from api.models import new_session as _new_session
|
||
model_provider = getattr(s, 'model_provider', None)
|
||
bg = _new_session(
|
||
workspace=s.workspace,
|
||
model=s.model,
|
||
model_provider=model_provider,
|
||
profile=getattr(s, 'profile', None),
|
||
)
|
||
bg.title = f"bg: {prompt[:60]}"
|
||
bg.save()
|
||
stream_id = uuid.uuid4().hex
|
||
bg.active_stream_id = stream_id
|
||
bg.save()
|
||
stream = create_stream_channel()
|
||
with STREAMS_LOCK:
|
||
STREAMS[stream_id] = stream
|
||
task_id = uuid.uuid4().hex[:8]
|
||
from api.background import track_background, complete_background
|
||
parent_sid = body["session_id"]
|
||
bg_sid = bg.session_id
|
||
track_background(parent_sid, bg_sid, stream_id, task_id, prompt)
|
||
|
||
def _run_bg_and_notify():
|
||
"""Run the background agent, then mark the tracked task `done` with the
|
||
last assistant reply so `/api/background/status` can surface it. Without
|
||
this, `complete_background()` is never called and the result is lost —
|
||
`get_results()` would see a forever-`running` task and return nothing.
|
||
"""
|
||
try:
|
||
_run_agent_streaming(
|
||
bg_sid,
|
||
prompt,
|
||
s.model,
|
||
s.workspace,
|
||
stream_id,
|
||
None,
|
||
model_provider=model_provider,
|
||
)
|
||
# Reload the bg session from disk and extract the final assistant reply.
|
||
try:
|
||
from api.models import Session as _Session
|
||
reloaded = _Session.load(bg_sid)
|
||
_answer = ""
|
||
for _m in reversed((reloaded.messages if reloaded else None) or []):
|
||
if not isinstance(_m, dict) or _m.get("role") != "assistant":
|
||
continue
|
||
if _m.get("_error"):
|
||
continue
|
||
_content = str(_m.get("content") or "").strip()
|
||
if _content:
|
||
_answer = _content
|
||
break
|
||
complete_background(parent_sid, task_id, _answer or "(no answer produced)")
|
||
except Exception:
|
||
complete_background(parent_sid, task_id, "(background task failed)")
|
||
# Best-effort cleanup of the hidden bg session file so it doesn't
|
||
# clutter the sidebar or SESSION_DIR. The index is pruned on the
|
||
# next rebuild via _index_entry_exists().
|
||
try:
|
||
(SESSION_DIR / f"{bg_sid}.json").unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
try:
|
||
complete_background(parent_sid, task_id, "(background task failed)")
|
||
except Exception:
|
||
pass
|
||
|
||
thr = threading.Thread(target=_run_bg_and_notify, daemon=True)
|
||
thr.start()
|
||
return j(handler, {"task_id": task_id, "stream_id": stream_id, "session_id": bg.session_id})
|
||
|
||
|
||
def _checkpoint_user_message_for_eager_session_save(s, msg: str, attachments, started_at: float | None) -> None:
|
||
"""Materialize the current user turn for eager first-turn persistence.
|
||
|
||
The streaming thread still receives ``pending_user_message`` so existing
|
||
cancel/recovery/final-merge paths keep their current contract. Eager mode
|
||
only adds a durable display-message checkpoint before the agent launches.
|
||
"""
|
||
if not msg:
|
||
return
|
||
existing = list(getattr(s, "messages", None) or [])
|
||
if existing:
|
||
latest = existing[-1]
|
||
if isinstance(latest, dict) and latest.get("role") == "user":
|
||
latest_text = " ".join(str(latest.get("content") or "").split())
|
||
msg_text = " ".join(str(msg or "").split())
|
||
if latest_text == msg_text:
|
||
return
|
||
user_msg = {"role": "user", "content": msg}
|
||
if isinstance(started_at, (int, float)) and started_at > 0:
|
||
user_msg["timestamp"] = int(started_at)
|
||
if attachments:
|
||
user_msg["attachments"] = list(attachments)
|
||
s.messages.append(user_msg)
|
||
|
||
|
||
def _prepare_chat_start_session_for_stream(
|
||
s,
|
||
*,
|
||
msg: str,
|
||
attachments,
|
||
workspace: str,
|
||
model: str,
|
||
model_provider,
|
||
stream_id: str,
|
||
started_at: float | None = None,
|
||
):
|
||
"""Persist chat-start state according to webui.session_save_mode.
|
||
|
||
``deferred`` keeps the existing sidecar/WAL-backed behaviour: save pending
|
||
fields but leave the display transcript empty until the agent merges the
|
||
result. ``eager`` additionally writes the current user turn into messages so
|
||
a process restart immediately after /api/chat/start preserves the prompt as
|
||
a normal session message. Empty sessions are never saved here because this
|
||
helper only runs after a non-empty message is validated.
|
||
"""
|
||
s.workspace = workspace
|
||
s.model = model
|
||
s.model_provider = model_provider
|
||
s.active_stream_id = stream_id
|
||
s.pending_user_message = msg
|
||
s.pending_attachments = attachments
|
||
s.pending_started_at = started_at if started_at is not None else time.time()
|
||
if get_webui_session_save_mode() == "eager":
|
||
_checkpoint_user_message_for_eager_session_save(
|
||
s,
|
||
msg,
|
||
attachments,
|
||
s.pending_started_at,
|
||
)
|
||
s.save()
|
||
|
||
|
||
def _handle_chat_start(handler, body):
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
requested_profile = str(body.get("profile") or "").strip()
|
||
if requested_profile:
|
||
try:
|
||
from api.profiles import _PROFILE_ID_RE
|
||
|
||
if requested_profile != "default" and not _PROFILE_ID_RE.fullmatch(requested_profile):
|
||
return bad(handler, "invalid profile", 400)
|
||
except ImportError:
|
||
requested_profile = ""
|
||
if requested_profile and not _profiles_match(getattr(s, "profile", None), requested_profile):
|
||
has_persisted_turns = bool(
|
||
getattr(s, "messages", None)
|
||
or getattr(s, "context_messages", None)
|
||
or getattr(s, "pending_user_message", None)
|
||
)
|
||
if not has_persisted_turns:
|
||
# Empty sessions are placeholders. If the user switches profiles
|
||
# before sending the first turn, run the placeholder under the
|
||
# currently-selected profile instead of the stale one stamped at
|
||
# creation time.
|
||
s.profile = requested_profile
|
||
msg = str(body.get("message", "")).strip()
|
||
if not msg:
|
||
return bad(handler, "message is required")
|
||
attachments = _normalize_chat_attachments(body.get("attachments") or [])[:20]
|
||
try:
|
||
workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace))
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
requested_model = body.get("model") or s.model
|
||
requested_provider = (
|
||
body.get("model_provider")
|
||
if "model_provider" in body
|
||
else getattr(s, "model_provider", None)
|
||
)
|
||
model, model_provider, normalized_model = _resolve_compatible_session_model_state(
|
||
requested_model,
|
||
requested_provider,
|
||
)
|
||
# Prevent duplicate runs in the same session while a stream is still active.
|
||
# This commonly happens after page refresh/reconnect races and can produce
|
||
# duplicated clarify cards for what appears to be a single user request.
|
||
current_stream_id = getattr(s, "active_stream_id", None)
|
||
if current_stream_id:
|
||
with STREAMS_LOCK:
|
||
current_active = current_stream_id in STREAMS
|
||
if current_active:
|
||
return j(
|
||
handler,
|
||
{
|
||
"error": "session already has an active stream",
|
||
"active_stream_id": current_stream_id,
|
||
},
|
||
status=409,
|
||
)
|
||
# Stale stream id from a previous run; clear and continue.
|
||
_clear_stale_stream_state(s)
|
||
stream_id = uuid.uuid4().hex
|
||
with _get_session_agent_lock(s.session_id):
|
||
_prepare_chat_start_session_for_stream(
|
||
s,
|
||
msg=msg,
|
||
attachments=attachments,
|
||
workspace=workspace,
|
||
model=model,
|
||
model_provider=model_provider,
|
||
stream_id=stream_id,
|
||
)
|
||
set_last_workspace(workspace)
|
||
stream = create_stream_channel()
|
||
with STREAMS_LOCK:
|
||
STREAMS[stream_id] = stream
|
||
thr = threading.Thread(
|
||
target=_run_agent_streaming,
|
||
args=(s.session_id, msg, model, workspace, stream_id, attachments),
|
||
kwargs={"model_provider": model_provider},
|
||
daemon=True,
|
||
)
|
||
thr.start()
|
||
response = {
|
||
"stream_id": stream_id,
|
||
"session_id": s.session_id,
|
||
"pending_started_at": s.pending_started_at,
|
||
}
|
||
if normalized_model:
|
||
response["effective_model"] = model
|
||
if model_provider:
|
||
response["effective_model_provider"] = model_provider
|
||
return j(handler, response)
|
||
|
||
|
||
def _normalize_chat_attachments(raw_attachments):
|
||
"""Normalize attachment payloads from the browser.
|
||
|
||
Older clients send a list of filenames. Newer clients send upload result
|
||
objects containing name/path/mime/size so image attachments can be supplied
|
||
to Hermes as native multimodal inputs for the current turn.
|
||
"""
|
||
normalized = []
|
||
if not isinstance(raw_attachments, list):
|
||
return normalized
|
||
for item in raw_attachments:
|
||
if isinstance(item, dict):
|
||
name = str(item.get("name") or item.get("filename") or "").strip()
|
||
path = str(item.get("path") or "").strip()
|
||
mime = str(item.get("mime") or "").strip()
|
||
att = {"name": name or path, "path": path, "mime": mime}
|
||
size = item.get("size")
|
||
if isinstance(size, int):
|
||
att["size"] = size
|
||
is_image = item.get("is_image")
|
||
if isinstance(is_image, bool):
|
||
att["is_image"] = is_image
|
||
normalized.append(att)
|
||
else:
|
||
value = str(item).strip()
|
||
if value:
|
||
normalized.append({"name": value, "path": "", "mime": ""})
|
||
return normalized
|
||
|
||
|
||
def _handle_chat_sync(handler, body):
|
||
"""Fallback synchronous chat endpoint (POST /api/chat). Not used by frontend."""
|
||
s = get_session(body["session_id"])
|
||
msg = str(body.get("message", "")).strip()
|
||
if not msg:
|
||
return j(handler, {"error": "empty message"}, status=400)
|
||
try:
|
||
workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace))
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
with _get_session_agent_lock(s.session_id):
|
||
s.workspace = workspace
|
||
model, model_provider = _resolve_compatible_session_model_state(
|
||
body.get("model") or s.model,
|
||
body.get("model_provider") if "model_provider" in body else getattr(s, "model_provider", None),
|
||
)[:2]
|
||
s.model = model
|
||
s.model_provider = model_provider
|
||
from api.streaming import _ENV_LOCK
|
||
|
||
with _ENV_LOCK:
|
||
old_cwd = os.environ.get("TERMINAL_CWD")
|
||
os.environ["TERMINAL_CWD"] = str(workspace)
|
||
old_exec_ask = os.environ.get("HERMES_EXEC_ASK")
|
||
old_session_key = os.environ.get("HERMES_SESSION_KEY")
|
||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||
os.environ["HERMES_SESSION_KEY"] = s.session_id
|
||
try:
|
||
from run_agent import AIAgent
|
||
|
||
with CHAT_LOCK:
|
||
from api.config import resolve_model_provider
|
||
|
||
_model, _provider, _base_url = resolve_model_provider(
|
||
model_with_provider_context(s.model, getattr(s, "model_provider", None))
|
||
)
|
||
# Resolve API key via Hermes runtime provider (matches gateway behaviour)
|
||
_api_key = None
|
||
try:
|
||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||
|
||
_rt = resolve_runtime_provider(requested=_provider)
|
||
_api_key = _rt.get("api_key")
|
||
# Also use runtime provider/base_url if the webui config didn't resolve them
|
||
if not _provider:
|
||
_provider = _rt.get("provider")
|
||
if not _base_url:
|
||
_base_url = _rt.get("base_url")
|
||
except Exception as _e:
|
||
print(
|
||
f"[webui] WARNING: resolve_runtime_provider failed: {_e}",
|
||
flush=True,
|
||
)
|
||
agent = AIAgent(
|
||
model=_model,
|
||
provider=_provider,
|
||
base_url=_base_url,
|
||
api_key=_api_key,
|
||
# Identify browser-originated sessions as WebUI so Hermes Agent
|
||
# does not inject CLI-specific terminal/output guidance.
|
||
platform="webui",
|
||
quiet_mode=True,
|
||
enabled_toolsets=_resolve_cli_toolsets(),
|
||
session_id=s.session_id,
|
||
)
|
||
workspace_ctx = f"[Workspace: {s.workspace}]\n"
|
||
workspace_system_msg = (
|
||
f"Active workspace at session start: {s.workspace}\n"
|
||
"Every user message is prefixed with [Workspace: /absolute/path] indicating the "
|
||
"workspace the user has selected in the web UI at the time they sent that message. "
|
||
"This tag is the single authoritative source of the active workspace and updates "
|
||
"with every message. It overrides any prior workspace mentioned in this system "
|
||
"prompt, memory, or conversation history. Always use the value from the most recent "
|
||
"[Workspace: ...] tag as your default working directory for ALL file operations: "
|
||
"write_file, read_file, search_files, terminal workdir, and patch. "
|
||
"Never fall back to a hardcoded path when this tag is present."
|
||
)
|
||
from api.streaming import (
|
||
_merge_display_messages_after_agent_result,
|
||
_restore_reasoning_metadata,
|
||
_sanitize_messages_for_api,
|
||
_session_context_messages,
|
||
)
|
||
|
||
_previous_messages = list(s.messages or [])
|
||
_previous_context_messages = list(_session_context_messages(s))
|
||
|
||
result = agent.run_conversation(
|
||
user_message=workspace_ctx + msg,
|
||
system_message=workspace_system_msg,
|
||
conversation_history=_sanitize_messages_for_api(_previous_context_messages),
|
||
task_id=s.session_id,
|
||
persist_user_message=msg,
|
||
)
|
||
finally:
|
||
with _ENV_LOCK:
|
||
if old_cwd is None:
|
||
os.environ.pop("TERMINAL_CWD", None)
|
||
else:
|
||
os.environ["TERMINAL_CWD"] = old_cwd
|
||
if old_exec_ask is None:
|
||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||
else:
|
||
os.environ["HERMES_EXEC_ASK"] = old_exec_ask
|
||
if old_session_key is None:
|
||
os.environ.pop("HERMES_SESSION_KEY", None)
|
||
else:
|
||
os.environ["HERMES_SESSION_KEY"] = old_session_key
|
||
with _get_session_agent_lock(s.session_id):
|
||
_result_messages = result.get("messages") or _previous_context_messages
|
||
_next_context_messages = _restore_reasoning_metadata(
|
||
_previous_context_messages,
|
||
_result_messages,
|
||
)
|
||
s.context_messages = _next_context_messages
|
||
s.messages = _merge_display_messages_after_agent_result(
|
||
_previous_messages,
|
||
_previous_context_messages,
|
||
_restore_reasoning_metadata(_previous_messages, _result_messages),
|
||
msg,
|
||
)
|
||
# Only auto-generate title when still default; preserves user renames
|
||
if s.title == "Untitled":
|
||
s.title = title_from(s.messages, s.title)
|
||
s.save()
|
||
# Sync to state.db for /insights (opt-in setting)
|
||
try:
|
||
if load_settings().get("sync_to_insights"):
|
||
from api.state_sync import sync_session_usage
|
||
|
||
sync_session_usage(
|
||
session_id=s.session_id,
|
||
input_tokens=s.input_tokens or 0,
|
||
output_tokens=s.output_tokens or 0,
|
||
estimated_cost=s.estimated_cost,
|
||
model=s.model,
|
||
title=s.title,
|
||
message_count=len(s.messages),
|
||
)
|
||
except Exception:
|
||
logger.debug("Failed to update session cost tracking")
|
||
return j(
|
||
handler,
|
||
{
|
||
"answer": result.get("final_response") or "",
|
||
"status": "done" if result.get("completed", True) else "partial",
|
||
"session": s.compact() | {"messages": s.messages},
|
||
"result": {k: v for k, v in result.items() if k != "messages"},
|
||
},
|
||
)
|
||
|
||
|
||
def _handle_cron_create(handler, body):
|
||
try:
|
||
require(body, "prompt", "schedule")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
from cron.jobs import create_job, update_job
|
||
|
||
profile = _normalize_cron_profile_value(body.get("profile"))
|
||
job = create_job(
|
||
prompt=body["prompt"],
|
||
schedule=body["schedule"],
|
||
name=body.get("name") or None,
|
||
deliver=body.get("deliver") or "local",
|
||
skills=body.get("skills") or [],
|
||
model=body.get("model") or None,
|
||
)
|
||
if profile is not None:
|
||
job = update_job(job["id"], {"profile": profile}) or job
|
||
return j(handler, {"ok": True, "job": _cron_job_for_api(job)})
|
||
except Exception as e:
|
||
return j(handler, {"error": str(e)}, status=400)
|
||
|
||
|
||
def _handle_cron_update(handler, body):
|
||
try:
|
||
require(body, "job_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
from cron.jobs import update_job
|
||
|
||
try:
|
||
updates = {}
|
||
for k, v in body.items():
|
||
if k == "job_id":
|
||
continue
|
||
if k == "profile":
|
||
updates[k] = _normalize_cron_profile_value(v)
|
||
elif v is not None:
|
||
updates[k] = v
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
job = update_job(body["job_id"], updates)
|
||
if not job:
|
||
return bad(handler, "Job not found", 404)
|
||
return j(handler, {"ok": True, "job": _cron_job_for_api(job)})
|
||
|
||
|
||
def _handle_cron_delete(handler, body):
|
||
try:
|
||
require(body, "job_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
from cron.jobs import remove_job
|
||
|
||
ok = remove_job(body["job_id"])
|
||
if not ok:
|
||
return bad(handler, "Job not found", 404)
|
||
return j(handler, {"ok": True, "job_id": body["job_id"]})
|
||
|
||
|
||
def _handle_cron_run(handler, body):
|
||
job_id = body.get("job_id", "")
|
||
if not job_id:
|
||
return bad(handler, "job_id required")
|
||
from cron.jobs import get_job
|
||
|
||
job = get_job(job_id)
|
||
if not job:
|
||
return bad(handler, "Job not found", 404)
|
||
# Prevent double-run: reject if the job is already tracked as running
|
||
already_running, elapsed = _is_cron_running(job_id)
|
||
if already_running:
|
||
return j(handler, {"ok": False, "job_id": job_id, "status": "already_running",
|
||
"elapsed": round(elapsed, 1)})
|
||
_mark_cron_running(job_id)
|
||
# Capture the TLS-active profile home now — the thread runs after the
|
||
# request finishes, so TLS is gone by then.
|
||
#
|
||
# Resolve directly without a try/except: get_active_hermes_home() does
|
||
# in-memory dict reads + a single Path.is_dir() stat, so the only way
|
||
# it could raise from inside a request handler is if api.profiles
|
||
# itself partially failed to import (in which case we'd already be
|
||
# 500-ing the whole request). A silent fallback to None here would
|
||
# re-introduce the exact bug #1573 fixes — the worker thread would
|
||
# run unpinned against the process-global HERMES_HOME — so we'd
|
||
# rather let any unexpected exception 500 the request than corrupt
|
||
# cross-profile state.
|
||
from api.profiles import get_active_hermes_home
|
||
|
||
_profile_home = get_active_hermes_home()
|
||
_execution_profile_home = _profile_home_for_cron_job(job)
|
||
threading.Thread(target=_run_cron_tracked, args=(job, _profile_home, _execution_profile_home), daemon=True).start()
|
||
return j(handler, {"ok": True, "job_id": job_id, "status": "running"})
|
||
|
||
|
||
def _handle_cron_pause(handler, body):
|
||
job_id = body.get("job_id", "")
|
||
if not job_id:
|
||
return bad(handler, "job_id required")
|
||
from cron.jobs import pause_job
|
||
|
||
result = pause_job(job_id, reason=body.get("reason"))
|
||
if result:
|
||
return j(handler, {"ok": True, "job": result})
|
||
return bad(handler, "Job not found", 404)
|
||
|
||
|
||
def _handle_cron_resume(handler, body):
|
||
job_id = body.get("job_id", "")
|
||
if not job_id:
|
||
return bad(handler, "job_id required")
|
||
from cron.jobs import resume_job
|
||
|
||
result = resume_job(job_id)
|
||
if result:
|
||
return j(handler, {"ok": True, "job": result})
|
||
return bad(handler, "Job not found", 404)
|
||
|
||
|
||
def _handle_file_delete(handler, body):
|
||
try:
|
||
require(body, "session_id", "path")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
try:
|
||
target = safe_resolve(Path(s.workspace), body["path"])
|
||
if not target.exists():
|
||
return bad(handler, "File not found", 404)
|
||
if target.is_dir():
|
||
if not body.get("recursive"):
|
||
return bad(handler, "Set recursive=true to delete directories")
|
||
shutil.rmtree(target)
|
||
else:
|
||
target.unlink()
|
||
return j(handler, {"ok": True, "path": body["path"]})
|
||
except (ValueError, PermissionError) as e:
|
||
return bad(handler, _sanitize_error(e))
|
||
|
||
|
||
def _handle_file_save(handler, body):
|
||
try:
|
||
require(body, "session_id", "path")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
try:
|
||
target = safe_resolve(Path(s.workspace), body["path"])
|
||
if not target.exists():
|
||
return bad(handler, "File not found", 404)
|
||
if target.is_dir():
|
||
return bad(handler, "Cannot save: path is a directory")
|
||
target.write_text(body.get("content", ""), encoding="utf-8")
|
||
return j(
|
||
handler, {"ok": True, "path": body["path"], "size": target.stat().st_size}
|
||
)
|
||
except (ValueError, PermissionError) as e:
|
||
return bad(handler, _sanitize_error(e))
|
||
|
||
|
||
def _handle_file_create(handler, body):
|
||
try:
|
||
require(body, "session_id", "path")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
try:
|
||
target = safe_resolve(Path(s.workspace), body["path"])
|
||
if target.exists():
|
||
return bad(handler, "File already exists")
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
target.write_text(body.get("content", ""), encoding="utf-8")
|
||
return j(
|
||
handler, {"ok": True, "path": str(target.relative_to(Path(s.workspace)))}
|
||
)
|
||
except (ValueError, PermissionError) as e:
|
||
return bad(handler, _sanitize_error(e))
|
||
|
||
|
||
def _handle_file_rename(handler, body):
|
||
try:
|
||
require(body, "session_id", "path", "new_name")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
try:
|
||
source = safe_resolve(Path(s.workspace), body["path"])
|
||
if not source.exists():
|
||
return bad(handler, "File not found", 404)
|
||
new_name = body["new_name"].strip()
|
||
if not new_name or "/" in new_name or ".." in new_name:
|
||
return bad(handler, "Invalid file name")
|
||
dest = source.parent / new_name
|
||
if dest.exists():
|
||
return bad(handler, f'A file named "{new_name}" already exists')
|
||
source.rename(dest)
|
||
new_rel = str(dest.relative_to(Path(s.workspace)))
|
||
return j(handler, {"ok": True, "old_path": body["path"], "new_path": new_rel})
|
||
except (ValueError, PermissionError, OSError) as e:
|
||
return bad(handler, _sanitize_error(e))
|
||
|
||
|
||
def _handle_create_dir(handler, body):
|
||
try:
|
||
require(body, "session_id", "path")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
try:
|
||
target = safe_resolve(Path(s.workspace), body["path"])
|
||
if target.exists():
|
||
return bad(handler, "Path already exists")
|
||
target.mkdir(parents=True)
|
||
return j(
|
||
handler, {"ok": True, "path": str(target.relative_to(Path(s.workspace)))}
|
||
)
|
||
except (ValueError, PermissionError, OSError) as e:
|
||
return bad(handler, _sanitize_error(e))
|
||
|
||
|
||
def _handle_file_reveal(handler, body):
|
||
try:
|
||
require(body, "session_id", "path")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
s = get_session(body["session_id"])
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
try:
|
||
target = safe_resolve(Path(s.workspace), body["path"])
|
||
if not target.exists():
|
||
return bad(handler, "File not found", 404)
|
||
|
||
system = platform.system()
|
||
if system == "Darwin":
|
||
subprocess.Popen(["open", "-R", str(target)])
|
||
elif system == "Windows":
|
||
subprocess.Popen(["explorer.exe", "/select," + str(target)])
|
||
else:
|
||
# Linux / other — open parent directory
|
||
subprocess.Popen(["xdg-open", str(target.parent)])
|
||
|
||
return j(handler, {"ok": True, "path": body["path"]})
|
||
except (ValueError, PermissionError, OSError) as e:
|
||
return bad(handler, _sanitize_error(e))
|
||
|
||
|
||
def _handle_workspace_add(handler, body):
|
||
# Strip surrounding paired quotes BEFORE any further processing — macOS
|
||
# Finder's "Copy as Pathname" wraps paths in single quotes, and users
|
||
# routinely paste those quoted strings into the Add Space input.
|
||
# Doing this at the route entry means every downstream check (blocked
|
||
# system path, validate_workspace_to_add, duplicate detection) sees the
|
||
# cleaned form.
|
||
path_str = _strip_surrounding_quotes(body.get("path", "").strip())
|
||
name = body.get("name", "").strip()
|
||
auto_create = body.get("create", False)
|
||
if not path_str:
|
||
return bad(handler, "path is required")
|
||
# Validate the path is NOT a blocked system root BEFORE any filesystem mutation.
|
||
# This prevents creating orphan directories on rejected paths (#782 review).
|
||
# _is_blocked_system_path honours user-tmp carve-outs (e.g. /var/folders on
|
||
# macOS) so pytest's tmp_path_factory paths and other legit user-tmp dirs
|
||
# still register cleanly.
|
||
candidate = Path(path_str).expanduser().resolve()
|
||
if _is_blocked_system_path(candidate):
|
||
return bad(handler, f"Path points to a system directory: {candidate}")
|
||
# Now safe to create the directory if requested
|
||
if auto_create:
|
||
try:
|
||
candidate.mkdir(parents=True, exist_ok=True)
|
||
except (OSError, PermissionError) as e:
|
||
return bad(handler, f"Could not create directory: {_sanitize_error(e)}")
|
||
# Full validation (exists, is_dir) — should pass now that dir exists
|
||
try:
|
||
p = validate_workspace_to_add(path_str)
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
wss = load_workspaces()
|
||
if any(w["path"] == str(p) for w in wss):
|
||
return bad(handler, "Workspace already in list")
|
||
wss.append({"path": str(p), "name": name or p.name})
|
||
save_workspaces(wss)
|
||
return j(handler, {"ok": True, "workspaces": wss})
|
||
|
||
|
||
def _handle_workspace_remove(handler, body):
|
||
path_str = body.get("path", "").strip()
|
||
if not path_str:
|
||
return bad(handler, "path is required")
|
||
wss = load_workspaces()
|
||
wss = [w for w in wss if w["path"] != path_str]
|
||
save_workspaces(wss)
|
||
return j(handler, {"ok": True, "workspaces": wss})
|
||
|
||
|
||
def _handle_workspace_rename(handler, body):
|
||
path_str = body.get("path", "").strip()
|
||
name = body.get("name", "").strip()
|
||
if not path_str or not name:
|
||
return bad(handler, "path and name are required")
|
||
wss = load_workspaces()
|
||
for w in wss:
|
||
if w["path"] == path_str:
|
||
w["name"] = name
|
||
break
|
||
else:
|
||
return bad(handler, "Workspace not found", 404)
|
||
save_workspaces(wss)
|
||
return j(handler, {"ok": True, "workspaces": wss})
|
||
|
||
|
||
def _handle_workspace_reorder(handler, body):
|
||
"""Reorder workspaces by providing an ordered list of paths.
|
||
|
||
Accepts {"paths": ["path1", "path2", ...]}. The workspaces list is
|
||
rewritten so that entries appear in the given order. Any workspace
|
||
not included in the request is appended at the end (preserves data).
|
||
"""
|
||
paths = body.get("paths", [])
|
||
if not paths or not isinstance(paths, list):
|
||
return bad(handler, "paths is required and must be a list")
|
||
wss = load_workspaces()
|
||
by_path = {w["path"]: w for w in wss}
|
||
# Build reordered list: given order first, then any omitted entries
|
||
reordered = []
|
||
seen = set()
|
||
for p in paths:
|
||
p = p.strip()
|
||
if p in by_path and p not in seen:
|
||
reordered.append(by_path[p])
|
||
seen.add(p)
|
||
# Append any workspaces not mentioned (safety net)
|
||
for w in wss:
|
||
if w["path"] not in seen:
|
||
reordered.append(w)
|
||
save_workspaces(reordered)
|
||
return j(handler, {"ok": True, "workspaces": reordered})
|
||
|
||
|
||
def _handle_approval_respond(handler, body):
|
||
sid = body.get("session_id", "")
|
||
if not sid:
|
||
return bad(handler, "session_id is required")
|
||
choice = body.get("choice", "deny")
|
||
if choice not in ("once", "session", "always", "deny"):
|
||
return bad(handler, f"Invalid choice: {choice}")
|
||
approval_id = body.get("approval_id", "")
|
||
|
||
# Pop the targeted entry from the pending queue by approval_id.
|
||
# Falls back to popping the first entry for backward-compat with old clients.
|
||
pending = None
|
||
with _lock:
|
||
queue = _pending.get(sid)
|
||
if isinstance(queue, list):
|
||
if approval_id:
|
||
# Find and remove the specific entry by approval_id.
|
||
for i, entry in enumerate(queue):
|
||
if entry.get("approval_id") == approval_id:
|
||
pending = queue.pop(i)
|
||
break
|
||
else:
|
||
# approval_id not found -- fall back to oldest entry.
|
||
pending = queue.pop(0) if queue else None
|
||
else:
|
||
pending = queue.pop(0) if queue else None
|
||
if not queue:
|
||
_pending.pop(sid, None)
|
||
elif queue:
|
||
# Legacy single-dict value.
|
||
pending = _pending.pop(sid, None)
|
||
# Notify SSE subscribers of the new head (or empty state) so the UI
|
||
# surfaces any trailing approvals that were queued behind this one
|
||
# without waiting for the next submit_pending. Without this, a parallel
|
||
# tool-call scenario (#527) would leave the second approval invisible
|
||
# in the SSE path until the next event ever fired (the agent thread
|
||
# would be parked indefinitely from the user's perspective).
|
||
if isinstance(_pending.get(sid), list) and _pending[sid]:
|
||
_approval_sse_notify_locked(sid, _pending[sid][0], len(_pending[sid]))
|
||
else:
|
||
_approval_sse_notify_locked(sid, None, 0)
|
||
|
||
if pending:
|
||
keys = pending.get("pattern_keys") or [pending.get("pattern_key", "")]
|
||
if choice in ("once", "session"):
|
||
for k in keys:
|
||
approve_session(sid, k)
|
||
elif choice == "always":
|
||
for k in keys:
|
||
approve_session(sid, k)
|
||
approve_permanent(k)
|
||
save_permanent_allowlist(_permanent_approved)
|
||
# Unblock the agent thread waiting in the gateway approval queue.
|
||
# This is the primary signal when streaming is active — the agent
|
||
# thread is parked in entry.event.wait() and needs to be woken up.
|
||
resolve_gateway_approval(sid, choice, resolve_all=False)
|
||
return j(handler, {"ok": True, "choice": choice})
|
||
|
||
|
||
def _handle_clarify_respond(handler, body):
|
||
sid = body.get("session_id", "")
|
||
if not sid:
|
||
return bad(handler, "session_id is required")
|
||
response = body.get("response")
|
||
if response is None:
|
||
response = body.get("answer")
|
||
if response is None:
|
||
response = body.get("choice")
|
||
response = str(response or "").strip()
|
||
if not response:
|
||
return bad(handler, "response is required")
|
||
resolve_clarify(sid, response, resolve_all=False)
|
||
return j(handler, {"ok": True, "response": response})
|
||
|
||
|
||
def _handle_session_compress(handler, body):
|
||
def _visible_messages_for_anchor(messages):
|
||
out = []
|
||
for m in messages or []:
|
||
if not isinstance(m, dict):
|
||
continue
|
||
role = m.get("role")
|
||
if not role or role == "tool":
|
||
continue
|
||
content = m.get("content", "")
|
||
has_attachments = bool(m.get("attachments"))
|
||
if role == "assistant":
|
||
tool_calls = m.get("tool_calls")
|
||
has_tool_calls = isinstance(tool_calls, list) and len(tool_calls) > 0
|
||
has_tool_use = False
|
||
has_reasoning = bool(m.get("reasoning"))
|
||
if isinstance(content, list):
|
||
for p in content:
|
||
if not isinstance(p, dict):
|
||
continue
|
||
if p.get("type") == "tool_use":
|
||
has_tool_use = True
|
||
if p.get("type") in {"thinking", "reasoning"}:
|
||
has_reasoning = True
|
||
text = "\n".join(
|
||
str(p.get("text") or p.get("content") or "")
|
||
for p in content
|
||
if isinstance(p, dict) and p.get("type") == "text"
|
||
).strip()
|
||
else:
|
||
text = str(content or "").strip()
|
||
if text or has_attachments or has_tool_calls or has_tool_use or has_reasoning:
|
||
out.append(m)
|
||
continue
|
||
if isinstance(content, list):
|
||
text = "\n".join(
|
||
str(p.get("text") or p.get("content") or "")
|
||
for p in content
|
||
if isinstance(p, dict) and p.get("type") == "text"
|
||
).strip()
|
||
else:
|
||
text = str(content or "").strip()
|
||
if text or has_attachments:
|
||
out.append(m)
|
||
return out
|
||
|
||
def _anchor_message_key(m):
|
||
if not isinstance(m, dict):
|
||
return None
|
||
role = str(m.get("role") or "")
|
||
if not role or role == "tool":
|
||
return None
|
||
content = m.get("content", "")
|
||
if isinstance(content, list):
|
||
text = "\n".join(
|
||
str(p.get("text") or p.get("content") or "")
|
||
for p in content
|
||
if isinstance(p, dict) and p.get("type") == "text"
|
||
)
|
||
else:
|
||
text = str(content or "")
|
||
norm = " ".join(text.split()).strip()[:160]
|
||
ts = m.get("_ts") or m.get("timestamp")
|
||
attachments = m.get("attachments")
|
||
attach_count = len(attachments) if isinstance(attachments, list) else 0
|
||
if not norm and not attach_count and not ts:
|
||
return None
|
||
return {"role": role, "ts": ts, "text": norm, "attachments": attach_count}
|
||
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
|
||
sid = str(body.get("session_id") or "").strip()
|
||
if not sid:
|
||
return bad(handler, "session_id is required")
|
||
|
||
# Cap focus_topic to 500 chars — matches the defensive input-size pattern
|
||
# used elsewhere (session title :80, first-exchange snippets :500) and
|
||
# prevents a user from forwarding an unbounded string into the compressor
|
||
# prompt path. No privilege boundary here (user prompting themself), just
|
||
# cheap bound-checking.
|
||
focus_topic = str(body.get("focus_topic") or body.get("topic") or "").strip()[:500] or None
|
||
|
||
try:
|
||
s = get_session(sid)
|
||
except KeyError:
|
||
return bad(handler, "Session not found", 404)
|
||
|
||
if getattr(s, "active_stream_id", None):
|
||
return bad(handler, "Session is still streaming; wait for the current turn to finish.", 409)
|
||
|
||
try:
|
||
from api.streaming import _sanitize_messages_for_api
|
||
|
||
messages = _sanitize_messages_for_api(s.messages)
|
||
if len(messages) < 4:
|
||
return bad(handler, "Not enough conversation to compress (need at least 4 messages).")
|
||
|
||
def _fallback_estimate_messages_tokens_rough(msgs):
|
||
"""Fallback heuristic token estimate when runtime metadata helpers are absent.
|
||
|
||
Uses whitespace token-like word counting only. This intentionally
|
||
over/under-estimates BPE token counts (roughly around x3/x4 scale),
|
||
and is only for resilient fallback behavior.
|
||
"""
|
||
total = 0
|
||
for m in msgs or []:
|
||
if not isinstance(m, dict):
|
||
continue
|
||
content = m.get("content", "")
|
||
if isinstance(content, list):
|
||
content_text = "\n".join(
|
||
str(p.get("text") or p.get("content") or "")
|
||
for p in content
|
||
if isinstance(p, dict)
|
||
)
|
||
else:
|
||
content_text = str(content or "")
|
||
total += len(content_text.split())
|
||
return max(1, total)
|
||
|
||
def _fallback_summarize_manual_compression(original_messages, compressed_messages, before_tokens, after_tokens, focus_topic=None):
|
||
"""Lightweight fallback summary to keep /session/compress usable in tests/runtime."""
|
||
after_tokens = after_tokens if after_tokens is not None else _fallback_estimate_messages_tokens_rough(compressed_messages)
|
||
headline = f"Compressed: {len(original_messages)} \u2192 {len(compressed_messages)} messages"
|
||
summary = {
|
||
"headline": headline,
|
||
"token_line": f"Rough transcript estimate: ~{before_tokens} \u2192 ~{after_tokens} tokens",
|
||
"note": f"Focus: {focus_topic}" if focus_topic else None,
|
||
}
|
||
summary["reference_message"] = (
|
||
f"[CONTEXT COMPACTION \u2014 REFERENCE ONLY] {headline}\n"
|
||
f"{summary['token_line']}\n"
|
||
+ (summary["note"] + "\n" if summary.get("note") else "")
|
||
+ "Compression completed."
|
||
)
|
||
return summary
|
||
|
||
def _estimate_messages_tokens_rough(msgs):
|
||
try:
|
||
from agent.model_metadata import estimate_messages_tokens_rough
|
||
|
||
return estimate_messages_tokens_rough(msgs)
|
||
except Exception:
|
||
return _fallback_estimate_messages_tokens_rough(msgs)
|
||
|
||
def _summarize_manual_compression(
|
||
original_messages,
|
||
compressed_messages,
|
||
before_tokens,
|
||
after_tokens,
|
||
focus_topic=None,
|
||
):
|
||
try:
|
||
from agent.manual_compression_feedback import summarize_manual_compression
|
||
|
||
return summarize_manual_compression(
|
||
original_messages,
|
||
compressed_messages,
|
||
before_tokens,
|
||
after_tokens,
|
||
)
|
||
except Exception:
|
||
return _fallback_summarize_manual_compression(
|
||
original_messages,
|
||
compressed_messages,
|
||
before_tokens,
|
||
after_tokens,
|
||
focus_topic,
|
||
)
|
||
|
||
import api.config as _cfg
|
||
import hermes_cli.runtime_provider as _runtime_provider
|
||
import run_agent as _run_agent
|
||
|
||
resolved_model, resolved_provider, resolved_base_url = _cfg.resolve_model_provider(
|
||
_cfg.model_with_provider_context(s.model, getattr(s, "model_provider", None))
|
||
)
|
||
|
||
resolved_api_key = None
|
||
try:
|
||
_rt = _runtime_provider.resolve_runtime_provider(requested=resolved_provider)
|
||
resolved_api_key = _rt.get("api_key")
|
||
if not resolved_provider:
|
||
resolved_provider = _rt.get("provider")
|
||
if not resolved_base_url:
|
||
resolved_base_url = _rt.get("base_url")
|
||
except Exception as _e:
|
||
logger.warning("resolve_runtime_provider failed for compression: %s", _e)
|
||
|
||
if not resolved_api_key:
|
||
return bad(handler, "No provider configured -- cannot compress.")
|
||
|
||
# Compute compression *outside* the lock — the LLM round-trip can take
|
||
# many seconds and we must not block cancel_stream or other writers.
|
||
# Lock contract: hold for the in-memory mutation only, never across
|
||
# network I/O.
|
||
original_messages = list(messages)
|
||
approx_tokens = _estimate_messages_tokens_rough(original_messages)
|
||
|
||
agent = _run_agent.AIAgent(
|
||
model=resolved_model,
|
||
provider=resolved_provider,
|
||
base_url=resolved_base_url,
|
||
api_key=resolved_api_key,
|
||
# Identify browser-originated sessions as WebUI so Hermes Agent
|
||
# does not inject CLI-specific terminal/output guidance.
|
||
platform="webui",
|
||
quiet_mode=True,
|
||
enabled_toolsets=_resolve_cli_toolsets(),
|
||
session_id=sid,
|
||
)
|
||
compressed = agent.context_compressor.compress(
|
||
original_messages,
|
||
current_tokens=approx_tokens,
|
||
focus_topic=focus_topic,
|
||
)
|
||
new_tokens = _estimate_messages_tokens_rough(compressed)
|
||
summary = _summarize_manual_compression(
|
||
original_messages,
|
||
compressed,
|
||
approx_tokens,
|
||
new_tokens,
|
||
focus_topic=focus_topic,
|
||
)
|
||
|
||
with _cfg._get_session_agent_lock(sid):
|
||
# Re-read messages to detect concurrent edits during the LLM call.
|
||
# If the history changed, the compression result is stale — abort.
|
||
if _sanitize_messages_for_api(s.messages) != original_messages:
|
||
return bad(handler, "Session was modified during compression; please retry.", 409)
|
||
|
||
s.messages = compressed
|
||
s.context_messages = compressed
|
||
s.tool_calls = []
|
||
s.active_stream_id = None
|
||
s.pending_user_message = None
|
||
s.pending_attachments = []
|
||
s.pending_started_at = None
|
||
visible_after = _visible_messages_for_anchor(compressed)
|
||
s.compression_anchor_visible_idx = max(0, len(visible_after) - 1) if visible_after else None
|
||
s.compression_anchor_message_key = _anchor_message_key(visible_after[-1]) if visible_after else None
|
||
s.save()
|
||
|
||
session_payload = redact_session_data(
|
||
s.compact() | {
|
||
"messages": s.messages,
|
||
"tool_calls": s.tool_calls,
|
||
"active_stream_id": s.active_stream_id,
|
||
"pending_user_message": s.pending_user_message,
|
||
"pending_attachments": s.pending_attachments,
|
||
"pending_started_at": s.pending_started_at,
|
||
"compression_anchor_visible_idx": getattr(s, "compression_anchor_visible_idx", None),
|
||
"compression_anchor_message_key": getattr(s, "compression_anchor_message_key", None),
|
||
}
|
||
)
|
||
return j(
|
||
handler,
|
||
{
|
||
"ok": True,
|
||
"session": session_payload,
|
||
"summary": summary,
|
||
"focus_topic": focus_topic,
|
||
},
|
||
)
|
||
except Exception as e:
|
||
logger.warning("Manual session compression failed: %s", e)
|
||
return bad(handler, f"Compression failed: {_sanitize_error(e)}")
|
||
|
||
|
||
def _handle_conversation_rounds(handler, body):
|
||
"""Return conversation-round count for a gateway session.
|
||
|
||
Request body::
|
||
|
||
{ "session_id": "...", "since": <unix_ts_or_iso> }
|
||
|
||
Response::
|
||
|
||
{ "ok": true, "rounds": 12, "threshold": 10, "should_show": true }
|
||
"""
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
|
||
sid = str(body.get("session_id") or "").strip()
|
||
if not sid:
|
||
return bad(handler, "session_id is required")
|
||
|
||
since = body.get("since")
|
||
if since is not None:
|
||
try:
|
||
since = float(since)
|
||
except (TypeError, ValueError):
|
||
return bad(handler, "since must be a unix timestamp (number)")
|
||
|
||
from api.models import count_conversation_rounds, CONVERSATION_ROUND_THRESHOLD
|
||
|
||
rounds = count_conversation_rounds(sid, since=since)
|
||
return j(handler, {
|
||
"ok": True,
|
||
"rounds": rounds,
|
||
"threshold": CONVERSATION_ROUND_THRESHOLD,
|
||
"should_show": rounds >= CONVERSATION_ROUND_THRESHOLD,
|
||
})
|
||
|
||
|
||
def _build_handoff_summary_tool_message(
|
||
sid: str,
|
||
summary: str,
|
||
channel: str | None,
|
||
rounds: int | None = None,
|
||
fallback: bool = False,
|
||
) -> dict:
|
||
"""Build a compact tool-role transcript marker for persistence."""
|
||
now = time.time()
|
||
return {
|
||
"role": "tool",
|
||
# Keep this intentionally empty so API-history sanitization drops it from
|
||
# model context (it is display-only data).
|
||
"tool_call_id": "",
|
||
"name": "handoff_summary",
|
||
"timestamp": now,
|
||
"_ts": now,
|
||
"content": json.dumps({
|
||
"_handoff_summary_card": True,
|
||
"session_id": sid,
|
||
"summary": str(summary or "").strip(),
|
||
"channel": (str(channel or "").strip() or None),
|
||
"rounds": rounds,
|
||
"fallback": bool(fallback),
|
||
"generated_at": now,
|
||
}, ensure_ascii=False),
|
||
}
|
||
|
||
|
||
def _extract_handoff_summary_payload(message: dict) -> dict | None:
|
||
"""Return a normalized handoff-summary payload if *message* is a tool marker."""
|
||
if not isinstance(message, dict):
|
||
return None
|
||
if message.get("role") != "tool" or message.get("name") != "handoff_summary":
|
||
return None
|
||
|
||
content = message.get("content")
|
||
if isinstance(content, dict):
|
||
payload = content
|
||
else:
|
||
try:
|
||
payload = json.loads(content or "")
|
||
except Exception:
|
||
return None
|
||
|
||
if not isinstance(payload, dict) or not payload.get("_handoff_summary_card"):
|
||
return None
|
||
if payload.get("session_id") is None:
|
||
return None
|
||
return {
|
||
"session_id": str(payload.get("session_id")),
|
||
"summary": str(payload.get("summary", "")),
|
||
"channel": payload.get("channel"),
|
||
"rounds": payload.get("rounds"),
|
||
"fallback": bool(payload.get("fallback")),
|
||
"_handoff_summary_card": True,
|
||
}
|
||
|
||
|
||
def _is_matching_handoff_summary_message(existing: dict, target: dict) -> bool:
|
||
"""Return True when two message payloads represent the same handoff summary."""
|
||
existing_payload = _extract_handoff_summary_payload(existing)
|
||
target_payload = _extract_handoff_summary_payload(target)
|
||
if not existing_payload or not target_payload:
|
||
return False
|
||
return (
|
||
existing_payload.get("session_id") == target_payload.get("session_id") and
|
||
existing_payload.get("summary") == target_payload.get("summary") and
|
||
existing_payload.get("channel") == target_payload.get("channel") and
|
||
existing_payload.get("rounds") == target_payload.get("rounds") and
|
||
existing_payload.get("fallback") == target_payload.get("fallback") and
|
||
existing_payload.get("_handoff_summary_card") == target_payload.get("_handoff_summary_card")
|
||
)
|
||
|
||
|
||
def _is_matching_handoff_summary_content(content: object, target_payload: dict | None) -> bool:
|
||
"""Return True if DB content JSON matches an expected handoff summary payload."""
|
||
if target_payload is None:
|
||
return False
|
||
try:
|
||
payload = json.loads(content or "")
|
||
except Exception:
|
||
return False
|
||
if not isinstance(payload, dict):
|
||
return False
|
||
if payload.get("session_id") is None:
|
||
return False
|
||
return (
|
||
payload.get("_handoff_summary_card") is True and
|
||
str(payload.get("session_id")) == str(target_payload.get("session_id")) and
|
||
str(payload.get("summary", "")) == str(target_payload.get("summary", "")) and
|
||
payload.get("channel") == target_payload.get("channel") and
|
||
payload.get("rounds") == target_payload.get("rounds") and
|
||
bool(payload.get("fallback")) == bool(target_payload.get("fallback"))
|
||
)
|
||
|
||
|
||
def _persist_handoff_summary_locally(sid: str, message: dict) -> bool:
|
||
"""Persist a handoff summary marker into a local WebUI session file."""
|
||
try:
|
||
from api.models import get_session
|
||
|
||
s = get_session(sid)
|
||
except KeyError:
|
||
return False
|
||
|
||
try:
|
||
if s.messages and _is_matching_handoff_summary_message(s.messages[-1], message):
|
||
return True
|
||
s.messages.append(message)
|
||
s.save()
|
||
return True
|
||
except Exception as e:
|
||
logger.warning("Failed to persist handoff summary marker in local session %s: %s", sid, e)
|
||
return False
|
||
|
||
|
||
def _persist_handoff_summary_to_state_db(sid: str, message: dict) -> bool:
|
||
"""Persist a handoff summary marker into CLI sessions state.db.
|
||
|
||
This keeps summary cards available after hard-refresh for imported gateway
|
||
sessions that are not in local session JSON yet.
|
||
"""
|
||
import os
|
||
|
||
try:
|
||
import sqlite3
|
||
except ImportError:
|
||
return False
|
||
|
||
try:
|
||
from api.profiles import get_active_hermes_home
|
||
|
||
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
|
||
except Exception:
|
||
hermes_home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser().resolve()
|
||
|
||
db_path = hermes_home / "state.db"
|
||
if not db_path.exists():
|
||
return False
|
||
|
||
ts = message.get("timestamp", time.time())
|
||
content = message.get("content", "")
|
||
if not isinstance(content, str):
|
||
content = json.dumps(content, ensure_ascii=False)
|
||
|
||
marker_payload = _extract_handoff_summary_payload(message)
|
||
try:
|
||
with sqlite3.connect(str(db_path)) as conn:
|
||
try:
|
||
if marker_payload is not None:
|
||
cur = conn.execute(
|
||
"SELECT content FROM messages WHERE session_id = ? AND role = 'tool' "
|
||
"ORDER BY rowid DESC LIMIT 1",
|
||
(sid,),
|
||
)
|
||
row = cur.fetchone()
|
||
if row is not None and _is_matching_handoff_summary_content(row[0], marker_payload):
|
||
return True
|
||
except Exception:
|
||
# If tail-read fails, continue with a best-effort write.
|
||
logger.debug("Unable to read tail handoff marker from state.db for %s", sid)
|
||
|
||
conn.execute(
|
||
"INSERT INTO messages (session_id, role, content, timestamp) "
|
||
"VALUES (?, 'tool', ?, ?)",
|
||
(sid, content, ts),
|
||
)
|
||
# Keep session row message_count/last-activity aligned with displayed
|
||
# transcript length. session rows are optional in some test DBs, so
|
||
# this update is best-effort.
|
||
conn.execute(
|
||
"UPDATE sessions SET message_count = COALESCE(message_count, 0) + 1 "
|
||
"WHERE id = ?",
|
||
(sid,),
|
||
)
|
||
conn.commit()
|
||
return True
|
||
except Exception as e:
|
||
logger.warning("Failed to persist handoff summary marker in state.db for %s: %s", sid, e)
|
||
return False
|
||
|
||
|
||
def _persist_handoff_summary(sid: str, summary: str, channel: str | None, rounds: int | None, fallback: bool = False) -> dict:
|
||
"""Persist a handoff summary marker across local/session backends."""
|
||
marker = _build_handoff_summary_tool_message(sid, summary, channel, rounds, fallback)
|
||
is_messaging_session = _is_messaging_session_id(sid)
|
||
if is_messaging_session:
|
||
_persist_handoff_summary_to_state_db(sid, marker)
|
||
_persist_handoff_summary_locally(sid, marker)
|
||
return marker
|
||
persisted_local = _persist_handoff_summary_locally(sid, marker)
|
||
if persisted_local:
|
||
return marker
|
||
return marker if _persist_handoff_summary_to_state_db(sid, marker) else marker
|
||
|
||
|
||
def _handle_handoff_summary(handler, body):
|
||
"""Generate an on-demand handoff summary for a gateway session.
|
||
|
||
Request body::
|
||
|
||
{ "session_id": "...", "since": <unix_ts_or_iso> }
|
||
|
||
Uses the session's configured model to produce a concise summary of
|
||
recent conversation activity. Returns the summary text so the caller
|
||
can display it in a tool-card.
|
||
"""
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
|
||
sid = str(body.get("session_id") or "").strip()
|
||
if not sid:
|
||
return bad(handler, "session_id is required")
|
||
|
||
since = body.get("since")
|
||
if since is not None:
|
||
try:
|
||
since = float(since)
|
||
except (TypeError, ValueError):
|
||
return bad(handler, "since must be a unix timestamp (number)")
|
||
|
||
from api.models import get_cli_session_messages, count_conversation_rounds, CONVERSATION_ROUND_THRESHOLD
|
||
|
||
rounds = count_conversation_rounds(sid, since=since)
|
||
if rounds < CONVERSATION_ROUND_THRESHOLD:
|
||
return bad(handler, "Not enough conversation rounds to generate a summary.", 400)
|
||
|
||
# Filter messages by ``since``.
|
||
all_msgs = get_cli_session_messages(sid)
|
||
if since is not None:
|
||
import datetime as _dt
|
||
filtered = []
|
||
for m in all_msgs:
|
||
ts_raw = m.get("timestamp")
|
||
if ts_raw is None:
|
||
continue
|
||
try:
|
||
if isinstance(ts_raw, (int, float)):
|
||
ts_val = float(ts_raw)
|
||
else:
|
||
ts_val = _dt.datetime.fromisoformat(
|
||
str(ts_raw).replace("Z", "+00:00")
|
||
).timestamp()
|
||
if ts_val > since:
|
||
filtered.append(m)
|
||
except Exception:
|
||
pass
|
||
msgs = filtered
|
||
else:
|
||
msgs = all_msgs
|
||
|
||
# Cap to last 50 messages.
|
||
msgs = msgs[-50:]
|
||
|
||
if len(msgs) < 2:
|
||
return bad(handler, "Not enough messages to summarize.", 400)
|
||
|
||
def _extract_handoff_text(raw_content):
|
||
if isinstance(raw_content, list):
|
||
return " ".join(
|
||
str(p.get("text") or p.get("content") or "")
|
||
for p in raw_content
|
||
if isinstance(p, dict)
|
||
).strip()
|
||
return str(raw_content or "").strip()
|
||
|
||
def _contains_chinese(text):
|
||
return any("\u4e00" <= ch <= "\u9fff" for ch in str(text))
|
||
|
||
transcript_is_chinese = any(
|
||
_contains_chinese(_extract_handoff_text(m.get("content")))
|
||
for m in msgs
|
||
)
|
||
# Build a lightweight conversation transcript for the LLM.
|
||
lines = []
|
||
for m in msgs:
|
||
role = m.get("role", "")
|
||
content = _extract_handoff_text(m.get("content"))
|
||
content = str(content or "").strip()[:1000]
|
||
if role in ("user", "assistant") and content:
|
||
lines.append(content)
|
||
transcript = "\n".join(lines)
|
||
|
||
def _fallback_handoff_summary(items):
|
||
"""Return a deterministic summary when LLM summary generation is unavailable."""
|
||
user_points = []
|
||
assistant_points = []
|
||
|
||
def _summarize_snippet(raw_text, max_len=78):
|
||
text = " ".join(str(raw_text or "").split()).strip()
|
||
if not text:
|
||
return ""
|
||
if len(text) <= max_len:
|
||
return text
|
||
return text[: max_len - 1].rstrip() + "…"
|
||
|
||
for m in items:
|
||
role = m.get("role", "")
|
||
content = _summarize_snippet(_extract_handoff_text(m.get("content")), 82)
|
||
if role in ("user", "assistant") and content:
|
||
if role == "user":
|
||
user_points.append(content)
|
||
else:
|
||
assistant_points.append(content)
|
||
if not user_points and not assistant_points:
|
||
return (
|
||
"近期可读文本不足,无法生成更完整的交接摘要,请补充一条消息后重试。"
|
||
if transcript_is_chinese
|
||
else "Not enough readable text to create a useful handoff summary; please send one more message and retry."
|
||
)
|
||
|
||
if transcript_is_chinese:
|
||
bullets = []
|
||
if user_points:
|
||
bullets.append(f"- 你刚讨论了:{user_points[-1]}。")
|
||
if assistant_points:
|
||
bullets.append(f"- 助手已回复:{assistant_points[-1]}。")
|
||
if len(user_points) + len(assistant_points) >= 2:
|
||
bullets.append("- 当前对话存在尚未确认的后续动作。")
|
||
else:
|
||
bullets.append("- 当前信息偏少,建议补充关键点后再切换。")
|
||
return "\n".join(bullets)
|
||
|
||
bullets = []
|
||
if user_points:
|
||
bullets.append(f"- You asked: {user_points[-1]}.")
|
||
if assistant_points:
|
||
bullets.append(f"- The assistant responded: {assistant_points[-1]}.")
|
||
if len(user_points) + len(assistant_points) >= 2:
|
||
bullets.append("- There is pending context to continue next.")
|
||
else:
|
||
bullets.append("- The conversation is still short; add one more turn before summarizing.")
|
||
return "\n".join(bullets)
|
||
|
||
def _summary_output_incomplete(text):
|
||
"""Best-effort guard for truncated summaries when LLM signals are unavailable."""
|
||
if not isinstance(text, str):
|
||
text = str(text or "")
|
||
text = text.strip()
|
||
if not text:
|
||
return True
|
||
if text.endswith("...") or text.endswith("…"):
|
||
return True
|
||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||
if not lines:
|
||
return True
|
||
last_line = lines[-1]
|
||
if re.search(r"[。!?;!?.;]$", last_line):
|
||
return False
|
||
if len(last_line) >= 56 and not re.search(r"\b(and|or|so|then|because|if|when|but|so|as)\b$", last_line, re.IGNORECASE):
|
||
return True
|
||
return bool(re.search(r"\b(and|or|but|so|because|if|when)$", last_line, re.IGNORECASE))
|
||
|
||
def _agent_summary_incomplete(summary_result):
|
||
if not isinstance(summary_result, dict):
|
||
return True
|
||
reason = (summary_result.get("finish_reason") or "").strip().lower()
|
||
if reason == "length":
|
||
return True
|
||
stop_reason = (summary_result.get("stop_reason") or "").strip().lower()
|
||
if stop_reason in {"max_tokens", "length"}:
|
||
return True
|
||
return _summary_output_incomplete(summary_result.get("text", ""))
|
||
|
||
def _resolve_handoff_channel_label():
|
||
channel_label = None
|
||
try:
|
||
from api.models import get_session as _get_session, get_cli_sessions
|
||
|
||
session_meta = _get_session(sid)
|
||
channel_label = (
|
||
session_meta.source_label
|
||
or session_meta.raw_source
|
||
or session_meta.source_tag
|
||
or session_meta.session_source
|
||
)
|
||
if not channel_label:
|
||
for candidate in get_cli_sessions():
|
||
if candidate.get("session_id") == sid:
|
||
channel_label = (
|
||
candidate.get("source_label")
|
||
or candidate.get("raw_source")
|
||
or candidate.get("source_tag")
|
||
or candidate.get("source")
|
||
)
|
||
break
|
||
except Exception:
|
||
pass
|
||
return channel_label
|
||
|
||
def _agent_text_completion(agent, system_prompt, user_text, max_tokens=700):
|
||
"""Use the current Hermes Agent transport without mutating conversation history."""
|
||
api_messages = [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_text},
|
||
]
|
||
result = {
|
||
"text": "",
|
||
"finish_reason": None,
|
||
"stop_reason": None,
|
||
"incomplete": True,
|
||
}
|
||
disabled_reasoning = {"enabled": False}
|
||
previous_reasoning = getattr(agent, "reasoning_config", None)
|
||
try:
|
||
agent.reasoning_config = disabled_reasoning
|
||
if getattr(agent, "api_mode", "") == "codex_responses":
|
||
codex_kwargs = agent._build_api_kwargs(api_messages)
|
||
codex_kwargs.pop("tools", None)
|
||
codex_kwargs["max_output_tokens"] = max_tokens
|
||
resp = agent._run_codex_stream(codex_kwargs)
|
||
assistant_message, _ = agent._normalize_codex_response(resp)
|
||
result["text"] = str((assistant_message.content or "") if assistant_message else "").strip()
|
||
result["incomplete"] = _summary_output_incomplete(result["text"])
|
||
return result
|
||
|
||
if getattr(agent, "api_mode", "") == "anthropic_messages":
|
||
from agent.anthropic_adapter import build_anthropic_kwargs, normalize_anthropic_response
|
||
|
||
ant_kwargs = build_anthropic_kwargs(
|
||
model=agent.model,
|
||
messages=api_messages,
|
||
tools=None,
|
||
max_tokens=max_tokens,
|
||
reasoning_config=disabled_reasoning,
|
||
is_oauth=getattr(agent, "_is_anthropic_oauth", False),
|
||
preserve_dots=agent._anthropic_preserve_dots(),
|
||
base_url=getattr(agent, "_anthropic_base_url", None),
|
||
)
|
||
resp = agent._anthropic_messages_create(ant_kwargs)
|
||
assistant_message, _ = normalize_anthropic_response(
|
||
resp,
|
||
strip_tool_prefix=getattr(agent, "_is_anthropic_oauth", False),
|
||
)
|
||
result["text"] = str((assistant_message.content or "") if assistant_message else "").strip()
|
||
result["incomplete"] = _summary_output_incomplete(result["text"])
|
||
return result
|
||
|
||
api_kwargs = agent._build_api_kwargs(api_messages)
|
||
api_kwargs.pop("tools", None)
|
||
api_kwargs["temperature"] = 0.2
|
||
api_kwargs["timeout"] = 30.0
|
||
if "max_completion_tokens" in api_kwargs:
|
||
api_kwargs["max_completion_tokens"] = max_tokens
|
||
else:
|
||
api_kwargs["max_tokens"] = max_tokens
|
||
resp = agent._ensure_primary_openai_client(reason="handoff_summary").chat.completions.create(
|
||
**api_kwargs,
|
||
)
|
||
choice = (getattr(resp, "choices", None) or [None])[0]
|
||
msg = getattr(choice, "message", None) if choice is not None else None
|
||
result["text"] = str(getattr(msg, "content", "") or "").strip()
|
||
result["finish_reason"] = getattr(choice, "finish_reason", None)
|
||
result["stop_reason"] = getattr(choice, "stop_reason", None)
|
||
result["incomplete"] = _agent_summary_incomplete(result)
|
||
return result
|
||
finally:
|
||
agent.reasoning_config = previous_reasoning
|
||
|
||
# Call LLM for summary.
|
||
try:
|
||
import api.config as _cfg
|
||
import hermes_cli.runtime_provider as _runtime_provider
|
||
import run_agent as _run_agent
|
||
|
||
# Try to resolve model from an existing session, fall back to default.
|
||
resolved_model = None
|
||
resolved_provider = None
|
||
resolved_base_url = None
|
||
try:
|
||
from api.models import get_session
|
||
s_obj = get_session(sid)
|
||
resolved_model = getattr(s_obj, "model", None)
|
||
except Exception:
|
||
pass
|
||
|
||
resolved_model, resolved_provider, resolved_base_url = _cfg.resolve_model_provider(resolved_model)
|
||
|
||
resolved_api_key = None
|
||
try:
|
||
_rt = _runtime_provider.resolve_runtime_provider(requested=resolved_provider)
|
||
resolved_api_key = _rt.get("api_key")
|
||
if not resolved_provider:
|
||
resolved_provider = _rt.get("provider")
|
||
if not resolved_base_url:
|
||
resolved_base_url = _rt.get("base_url")
|
||
except Exception as _e:
|
||
logger.warning("resolve_runtime_provider failed for handoff summary: %s", _e)
|
||
|
||
if not resolved_api_key:
|
||
summary_text = _fallback_handoff_summary(msgs)
|
||
try:
|
||
_persist_handoff_summary(
|
||
sid,
|
||
summary_text,
|
||
_resolve_handoff_channel_label(),
|
||
rounds,
|
||
fallback=True,
|
||
)
|
||
except Exception:
|
||
pass
|
||
return j(handler, {
|
||
"ok": True,
|
||
"summary": summary_text,
|
||
"message_count": len(msgs),
|
||
"rounds": rounds,
|
||
"fallback": True,
|
||
})
|
||
|
||
agent = _run_agent.AIAgent(
|
||
model=resolved_model,
|
||
provider=resolved_provider,
|
||
base_url=resolved_base_url,
|
||
api_key=resolved_api_key,
|
||
platform="webui",
|
||
quiet_mode=True,
|
||
enabled_toolsets=[],
|
||
session_id=sid,
|
||
)
|
||
|
||
summary_system_prompt = (
|
||
"You are summarizing an external-channel conversation so a Web UI reader "
|
||
"can quickly catch up after switching contexts.\n\n"
|
||
"Only use the latest messages, and never copy raw transcript lines.\n"
|
||
"Do not output role labels (no “你:” / “assistant:” / “user:” / “assistant”).\n"
|
||
"Use direct 2–5 bullet points in the conversation language.\n"
|
||
"English: speak using “you”.\n"
|
||
"中文: 使用“你”。\n\n"
|
||
"Focus on:\n"
|
||
"- Unfinished tasks or action items\n"
|
||
"- Pending questions that need replies\n"
|
||
"- Key decisions made\n"
|
||
"- Open disagreements or TBD items\n\n"
|
||
"If the conversation is purely casual with no actionable items, "
|
||
"say so in one sentence."
|
||
)
|
||
summary_user_text = f"Conversation transcript:\n{transcript}"
|
||
|
||
try:
|
||
first_pass = _agent_text_completion(
|
||
agent,
|
||
summary_system_prompt,
|
||
summary_user_text,
|
||
max_tokens=700,
|
||
)
|
||
summary_text = first_pass.get("text") if isinstance(first_pass, dict) else ""
|
||
if _agent_summary_incomplete(first_pass):
|
||
second_pass = _agent_text_completion(
|
||
agent,
|
||
summary_system_prompt,
|
||
summary_user_text,
|
||
max_tokens=1400,
|
||
)
|
||
summary_text = second_pass.get("text") if isinstance(second_pass, dict) else ""
|
||
if _agent_summary_incomplete(second_pass):
|
||
summary_text = _fallback_handoff_summary(msgs)
|
||
fallback = True
|
||
else:
|
||
fallback = False
|
||
else:
|
||
fallback = False
|
||
finally:
|
||
try:
|
||
agent.release_clients()
|
||
except Exception:
|
||
pass
|
||
if not summary_text:
|
||
summary_text = _fallback_handoff_summary(msgs)
|
||
fallback = True
|
||
elif _summary_output_incomplete(summary_text):
|
||
if not fallback:
|
||
fallback = True
|
||
|
||
channel_label = _resolve_handoff_channel_label()
|
||
_persist_handoff_summary(
|
||
sid,
|
||
summary_text,
|
||
channel_label,
|
||
rounds,
|
||
fallback=fallback,
|
||
)
|
||
|
||
return j(handler, {
|
||
"ok": True,
|
||
"summary": summary_text,
|
||
"message_count": len(msgs),
|
||
"rounds": rounds,
|
||
"fallback": fallback,
|
||
})
|
||
except Exception as e:
|
||
logger.warning("Handoff summary generation failed: %s", e)
|
||
summary_text = _fallback_handoff_summary(msgs)
|
||
try:
|
||
_persist_handoff_summary(
|
||
sid,
|
||
summary_text,
|
||
_resolve_handoff_channel_label(),
|
||
rounds,
|
||
fallback=True,
|
||
)
|
||
except Exception:
|
||
pass
|
||
return j(handler, {
|
||
"ok": True,
|
||
"summary": summary_text,
|
||
"message_count": len(msgs),
|
||
"rounds": rounds,
|
||
"fallback": True,
|
||
"warning": f"Summary generation used local fallback: {_sanitize_error(e)}",
|
||
})
|
||
|
||
|
||
def _handle_skill_save(handler, body):
|
||
try:
|
||
require(body, "name", "content")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
skill_name = body["name"].strip().lower().replace(" ", "-")
|
||
if not skill_name or "/" in skill_name or ".." in skill_name:
|
||
return bad(handler, "Invalid skill name")
|
||
category = body.get("category", "").strip()
|
||
if category and ("/" in category or ".." in category):
|
||
return bad(handler, "Invalid category")
|
||
from tools.skills_tool import SKILLS_DIR
|
||
|
||
if category:
|
||
skill_dir = SKILLS_DIR / category / skill_name
|
||
else:
|
||
skill_dir = SKILLS_DIR / skill_name
|
||
# Validate resolved path stays within SKILLS_DIR
|
||
try:
|
||
skill_dir.resolve().relative_to(SKILLS_DIR.resolve())
|
||
except ValueError:
|
||
return bad(handler, "Invalid skill path")
|
||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||
skill_file = skill_dir / "SKILL.md"
|
||
skill_file.write_text(body["content"], encoding="utf-8")
|
||
return j(handler, {"ok": True, "name": skill_name, "path": str(skill_file)})
|
||
|
||
|
||
def _handle_skill_delete(handler, body):
|
||
try:
|
||
require(body, "name")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
from tools.skills_tool import SKILLS_DIR
|
||
import shutil
|
||
|
||
matches = list(SKILLS_DIR.rglob(f"{body['name']}/SKILL.md"))
|
||
if not matches:
|
||
return bad(handler, "Skill not found", 404)
|
||
skill_dir = matches[0].parent
|
||
shutil.rmtree(str(skill_dir))
|
||
return j(handler, {"ok": True, "name": body["name"]})
|
||
|
||
|
||
def _handle_memory_write(handler, body):
|
||
try:
|
||
require(body, "section", "content")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
try:
|
||
from api.profiles import get_active_hermes_home
|
||
|
||
mem_dir = get_active_hermes_home() / "memories"
|
||
except ImportError:
|
||
mem_dir = Path.home() / ".hermes" / "memories"
|
||
mem_dir.mkdir(parents=True, exist_ok=True)
|
||
section = body["section"]
|
||
if section == "memory":
|
||
target = mem_dir / "MEMORY.md"
|
||
elif section == "user":
|
||
target = mem_dir / "USER.md"
|
||
else:
|
||
return bad(handler, 'section must be "memory" or "user"')
|
||
target.write_text(body["content"], encoding="utf-8")
|
||
return j(handler, {"ok": True, "section": section, "path": str(target)})
|
||
|
||
|
||
def _normalize_message_for_import_refresh(message: object) -> object:
|
||
"""Normalize message payloads for import refresh prefix checks.
|
||
|
||
The strict dict comparison previously failed when existing messages held
|
||
integer timestamps while refreshed messages held floating-point timestamps.
|
||
Strip timing keys before comparison so we can safely treat semantic
|
||
prefixes as equivalent.
|
||
"""
|
||
if not isinstance(message, dict):
|
||
return message
|
||
normalized = dict(message)
|
||
normalized.pop("timestamp", None)
|
||
normalized.pop("_ts", None)
|
||
return normalized
|
||
|
||
|
||
def _is_messages_refresh_prefix_match(existing_messages: list, fresh_messages: list) -> bool:
|
||
"""Return True when existing_messages is a prefix of fresh_messages by value.
|
||
|
||
This is a semantic comparison intended for import refresh, not deep
|
||
structural equality. It intentionally ignores timing fields that may differ
|
||
in type/precision between storage layers.
|
||
"""
|
||
if not isinstance(existing_messages, list) or not isinstance(fresh_messages, list):
|
||
return False
|
||
if len(existing_messages) > len(fresh_messages):
|
||
return False
|
||
for idx, existing_message in enumerate(existing_messages):
|
||
fresh_message = fresh_messages[idx]
|
||
if _normalize_message_for_import_refresh(existing_message) != _normalize_message_for_import_refresh(fresh_message):
|
||
return False
|
||
return True
|
||
|
||
|
||
def _handle_session_import_cli(handler, body):
|
||
"""Import a single CLI session into the WebUI store."""
|
||
try:
|
||
require(body, "session_id")
|
||
except ValueError as e:
|
||
return bad(handler, str(e))
|
||
|
||
sid = str(body["session_id"])
|
||
|
||
# Check if already imported — refresh messages from CLI store if new ones arrived
|
||
existing = Session.load(sid)
|
||
if existing:
|
||
fresh_msgs = get_cli_session_messages(sid)
|
||
changed = False
|
||
cli_meta = None
|
||
for cs in list(get_cli_sessions()):
|
||
if cs["session_id"] == sid:
|
||
cli_meta = cs
|
||
break
|
||
if fresh_msgs and len(fresh_msgs) > len(existing.messages):
|
||
# Prefix-equality guard: only extend if existing messages are a prefix of
|
||
# the fresh CLI messages. Prevents silently dropping WebUI-added messages
|
||
# on hybrid sessions (user sent messages via WebUI while CLI continued).
|
||
if _is_messages_refresh_prefix_match(existing.messages, fresh_msgs):
|
||
existing.messages = fresh_msgs
|
||
changed = True
|
||
if cli_meta:
|
||
updates = {
|
||
"is_cli_session": True,
|
||
"source_tag": existing.source_tag or cli_meta.get("source_tag"),
|
||
"raw_source": existing.raw_source or cli_meta.get("raw_source") or cli_meta.get("source_tag"),
|
||
"session_source": existing.session_source or cli_meta.get("session_source"),
|
||
"source_label": existing.source_label or cli_meta.get("source_label"),
|
||
"parent_session_id": existing.parent_session_id or cli_meta.get("parent_session_id"),
|
||
}
|
||
for attr, value in updates.items():
|
||
if getattr(existing, attr, None) != value:
|
||
setattr(existing, attr, value)
|
||
changed = True
|
||
if changed:
|
||
existing.save(touch_updated_at=False)
|
||
return j(
|
||
handler,
|
||
{
|
||
"session": existing.compact()
|
||
| {
|
||
"messages": existing.messages,
|
||
"is_cli_session": True,
|
||
"read_only": bool((cli_meta or {}).get("read_only")),
|
||
},
|
||
"imported": False,
|
||
},
|
||
)
|
||
|
||
# Fetch messages from CLI store
|
||
msgs = get_cli_session_messages(sid)
|
||
if not msgs:
|
||
return bad(handler, "Session not found in CLI store", 404)
|
||
|
||
# Get profile, model, timestamps, and title from CLI session metadata
|
||
profile = None
|
||
created_at = None
|
||
updated_at = None
|
||
cli_title = None
|
||
cli_source_tag = None
|
||
model = "unknown"
|
||
cli_raw_source = None
|
||
cli_session_source = None
|
||
cli_source_label = None
|
||
cli_user_id = None
|
||
cli_chat_id = None
|
||
cli_chat_type = None
|
||
cli_thread_id = None
|
||
cli_session_key = None
|
||
cli_platform = None
|
||
cli_parent_session_id = None
|
||
cli_read_only = False
|
||
for cs in get_cli_sessions():
|
||
if cs["session_id"] == sid:
|
||
profile = cs.get("profile")
|
||
model = cs.get("model", "unknown")
|
||
created_at = cs.get("created_at")
|
||
updated_at = cs.get("updated_at")
|
||
cli_title = cs.get("title")
|
||
cli_source_tag = cs.get("source_tag")
|
||
cli_raw_source = cs.get("raw_source")
|
||
cli_session_source = cs.get("session_source")
|
||
cli_source_label = cs.get("source_label")
|
||
cli_user_id = cs.get("user_id")
|
||
cli_chat_id = cs.get("chat_id")
|
||
cli_chat_type = cs.get("chat_type")
|
||
cli_thread_id = cs.get("thread_id")
|
||
cli_session_key = cs.get("session_key")
|
||
cli_platform = cs.get("platform")
|
||
cli_parent_session_id = cs.get("parent_session_id")
|
||
cli_read_only = bool(cs.get("read_only"))
|
||
break
|
||
|
||
# Use the CLI session title if available (e.g., cron job name), otherwise derive from messages
|
||
title = cli_title or title_from(msgs, "CLI Session")
|
||
|
||
# Auto-assign cron sessions to the dedicated "Cron Jobs" project (#1079)
|
||
cron_project_id = None
|
||
if is_cron_session(sid, cli_source_tag):
|
||
cron_project_id = ensure_cron_project()
|
||
|
||
if cli_read_only:
|
||
session_payload = {
|
||
"session_id": sid,
|
||
"title": title,
|
||
"workspace": str(get_last_workspace()),
|
||
"model": model,
|
||
"message_count": len(msgs),
|
||
"created_at": created_at,
|
||
"updated_at": updated_at,
|
||
"last_message_at": updated_at or created_at,
|
||
"pinned": False,
|
||
"archived": False,
|
||
"project_id": None,
|
||
"profile": profile,
|
||
"is_cli_session": True,
|
||
"source_tag": cli_source_tag,
|
||
"raw_source": cli_raw_source or cli_source_tag,
|
||
"session_source": cli_session_source,
|
||
"source_label": cli_source_label,
|
||
"parent_session_id": cli_parent_session_id,
|
||
"read_only": True,
|
||
"messages": msgs,
|
||
"tool_calls": [],
|
||
}
|
||
return j(handler, {"session": session_payload, "imported": False})
|
||
|
||
s = import_cli_session(
|
||
sid,
|
||
title,
|
||
msgs,
|
||
model,
|
||
profile=profile,
|
||
created_at=created_at,
|
||
updated_at=updated_at,
|
||
parent_session_id=cli_parent_session_id,
|
||
)
|
||
if cron_project_id:
|
||
s.project_id = cron_project_id
|
||
s.is_cli_session = True
|
||
s.source_tag = cli_source_tag
|
||
s.raw_source = cli_raw_source or cli_source_tag
|
||
s.session_source = cli_session_source
|
||
s.source_label = cli_source_label
|
||
s.user_id = cli_user_id
|
||
s.chat_id = cli_chat_id
|
||
s.chat_type = cli_chat_type
|
||
s.thread_id = cli_thread_id
|
||
s.session_key = cli_session_key
|
||
s.platform = cli_platform
|
||
s._cli_origin = sid
|
||
s.save(touch_updated_at=False)
|
||
return j(
|
||
handler,
|
||
{
|
||
"session": s.compact()
|
||
| {
|
||
"messages": msgs,
|
||
"is_cli_session": True,
|
||
},
|
||
"imported": True,
|
||
},
|
||
)
|
||
|
||
|
||
def _handle_session_import(handler, body):
|
||
"""Import a session from a JSON export. Creates a new session with a new ID."""
|
||
if not body or not isinstance(body, dict):
|
||
return bad(handler, "Request body must be a JSON object")
|
||
messages = body.get("messages")
|
||
if not isinstance(messages, list):
|
||
return bad(handler, 'JSON must contain a "messages" array')
|
||
title = body.get("title", "Imported session")
|
||
workspace = body.get("workspace", str(DEFAULT_WORKSPACE))
|
||
model = body.get("model", DEFAULT_MODEL)
|
||
s = Session(
|
||
title=title,
|
||
workspace=workspace,
|
||
model=model,
|
||
messages=messages,
|
||
tool_calls=body.get("tool_calls", []),
|
||
)
|
||
s.pinned = body.get("pinned", False)
|
||
with LOCK:
|
||
SESSIONS[s.session_id] = s
|
||
SESSIONS.move_to_end(s.session_id)
|
||
while len(SESSIONS) > SESSIONS_MAX:
|
||
SESSIONS.popitem(last=False)
|
||
s.save()
|
||
return j(handler, {"ok": True, "session": s.compact() | {"messages": s.messages}})
|
||
|
||
|
||
# ── MCP Server helpers ──
|
||
from api.config import get_config, _save_yaml_config_file, _get_config_path, reload_config
|
||
|
||
def _mask_secrets(obj):
|
||
"""Mask sensitive values in env vars and headers."""
|
||
if not isinstance(obj, dict):
|
||
return obj
|
||
sensitive = ("auth", "token", "key", "secret", "password", "credential")
|
||
masked = {}
|
||
for k, v in obj.items():
|
||
if isinstance(v, str) and any(s in k.lower() for s in sensitive):
|
||
masked[k] = "••••••"
|
||
elif isinstance(v, dict):
|
||
masked[k] = _mask_secrets(v)
|
||
else:
|
||
masked[k] = v
|
||
return masked
|
||
|
||
|
||
def _parse_mcp_enabled(value) -> bool:
|
||
"""Parse Hermes MCP ``enabled`` values without raising on bad config."""
|
||
if value is None:
|
||
return True
|
||
if isinstance(value, bool):
|
||
return value
|
||
if isinstance(value, (int, float)):
|
||
return value != 0
|
||
if isinstance(value, str):
|
||
normalized = value.strip().lower()
|
||
if normalized in {"true", "1", "yes", "on"}:
|
||
return True
|
||
if normalized in {"false", "0", "no", "off"}:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _mcp_runtime_status_by_name() -> dict[str, dict]:
|
||
"""Return already-known MCP runtime status without starting servers.
|
||
|
||
``tools.mcp_tool.get_mcp_status()`` only reads the existing MCP registry and
|
||
configuration; it does not probe or spawn MCP subprocesses. If Hermes Agent
|
||
is unavailable, fall back to an empty map so the API remains safe.
|
||
"""
|
||
try:
|
||
from tools.mcp_tool import get_mcp_status
|
||
statuses = get_mcp_status()
|
||
except Exception:
|
||
return {}
|
||
if not isinstance(statuses, list):
|
||
return {}
|
||
return {
|
||
str(entry.get("name")): entry
|
||
for entry in statuses
|
||
if isinstance(entry, dict) and entry.get("name")
|
||
}
|
||
|
||
|
||
def _server_summary(name, cfg, runtime_status=None):
|
||
"""Return a safe summary of an MCP server config."""
|
||
runtime_status = runtime_status if isinstance(runtime_status, dict) else {}
|
||
out = {"name": name}
|
||
if not isinstance(cfg, dict):
|
||
out.update({
|
||
"transport": "invalid",
|
||
"timeout": 120,
|
||
"connect_timeout": 60,
|
||
"enabled": False,
|
||
"active": False,
|
||
"status": "invalid_config",
|
||
"tool_count": None,
|
||
})
|
||
return out
|
||
|
||
enabled = _parse_mcp_enabled(cfg.get("enabled", True))
|
||
connected = bool(runtime_status.get("connected")) if enabled else False
|
||
if "url" in cfg:
|
||
out["transport"] = "http"
|
||
# Mask auth headers
|
||
if "headers" in cfg:
|
||
out["headers"] = _mask_secrets(cfg["headers"])
|
||
out["url"] = cfg["url"]
|
||
elif "command" in cfg:
|
||
out["transport"] = "stdio"
|
||
out["command"] = cfg.get("command", "")
|
||
out["args"] = cfg.get("args", [])
|
||
if "env" in cfg:
|
||
out["env"] = _mask_secrets(cfg["env"])
|
||
else:
|
||
out["transport"] = "invalid"
|
||
enabled = False
|
||
connected = False
|
||
|
||
out["timeout"] = cfg.get("timeout", 120)
|
||
out["connect_timeout"] = cfg.get("connect_timeout", 60)
|
||
out["enabled"] = enabled
|
||
out["active"] = connected
|
||
if out["transport"] == "invalid":
|
||
out["status"] = "invalid_config"
|
||
elif not enabled:
|
||
out["status"] = "disabled"
|
||
elif connected:
|
||
out["status"] = "active"
|
||
else:
|
||
out["status"] = "configured"
|
||
out["tool_count"] = runtime_status.get("tools") if runtime_status else None
|
||
return out
|
||
|
||
|
||
def _mcp_safe_display_text(value, *, limit: int) -> str:
|
||
"""Return redacted, bounded MCP text safe for WebUI inventory rows."""
|
||
if not isinstance(value, str):
|
||
value = "" if value is None else str(value)
|
||
value = _redact_text(value).strip()
|
||
value = re.sub(r"Authorization:\s*Bearer\s+\S+", "[REDACTED CREDENTIAL]", value, flags=re.I)
|
||
if len(value) > limit:
|
||
value = value[: max(0, limit - 1)].rstrip() + "…"
|
||
return value
|
||
|
||
|
||
def _mcp_schema_type(schema) -> str:
|
||
"""Return a compact, non-sensitive display type for a JSON schema node."""
|
||
if not isinstance(schema, dict):
|
||
return "unknown"
|
||
typ = schema.get("type")
|
||
if isinstance(typ, list):
|
||
typ = "/".join(str(t) for t in typ if t)
|
||
if isinstance(typ, str) and typ:
|
||
return typ
|
||
for composite in ("anyOf", "oneOf", "allOf"):
|
||
if isinstance(schema.get(composite), list) and schema[composite]:
|
||
return composite
|
||
if "enum" in schema:
|
||
return "enum"
|
||
return "unknown"
|
||
|
||
|
||
def _mcp_schema_summary(schema, *, limit: int = 12) -> list[dict]:
|
||
"""Summarize an MCP input schema without exposing raw defaults/examples.
|
||
|
||
The WebUI only needs searchable/displayable argument hints. Returning raw
|
||
JSON Schema can overexpose server-provided defaults, examples, enums, or
|
||
vendor extensions, so this strips each parameter down to name/type/required
|
||
and a redacted description.
|
||
"""
|
||
if not isinstance(schema, dict):
|
||
return []
|
||
properties = schema.get("properties")
|
||
if not isinstance(properties, dict):
|
||
return []
|
||
required = schema.get("required")
|
||
required_names = set(required) if isinstance(required, list) else set()
|
||
out = []
|
||
for name, prop in properties.items():
|
||
if len(out) >= limit:
|
||
break
|
||
if not isinstance(name, str):
|
||
continue
|
||
prop = prop if isinstance(prop, dict) else {}
|
||
desc = prop.get("description", "")
|
||
if not isinstance(desc, str):
|
||
desc = ""
|
||
desc = _mcp_safe_display_text(desc, limit=180)
|
||
out.append({
|
||
"name": name,
|
||
"type": _mcp_schema_type(prop),
|
||
"required": name in required_names,
|
||
"description": desc,
|
||
})
|
||
return out
|
||
|
||
|
||
def _mcp_tool_schema_from_payload(tool):
|
||
if not isinstance(tool, dict):
|
||
return {}
|
||
for key in ("parameters", "inputSchema", "input_schema", "schema"):
|
||
value = tool.get(key)
|
||
if isinstance(value, dict):
|
||
if key == "schema" and isinstance(value.get("parameters"), dict):
|
||
return value["parameters"]
|
||
return value
|
||
return {}
|
||
|
||
|
||
def _mcp_tool_summary(name, tool, server_summary):
|
||
"""Return a safe global inventory row for one MCP tool."""
|
||
server_summary = server_summary if isinstance(server_summary, dict) else {}
|
||
if isinstance(tool, str):
|
||
tool = {"name": tool}
|
||
elif not isinstance(tool, dict):
|
||
tool = {}
|
||
tool_name = str(tool.get("name") or name or "")
|
||
description = tool.get("description") or ""
|
||
if not isinstance(description, str):
|
||
description = str(description)
|
||
description = _mcp_safe_display_text(description, limit=360)
|
||
return {
|
||
"name": tool_name,
|
||
"server": str(server_summary.get("name") or ""),
|
||
"description": description,
|
||
"active": bool(server_summary.get("active")),
|
||
"enabled": bool(server_summary.get("enabled")),
|
||
"status": server_summary.get("status") or "unknown",
|
||
"schema_summary": _mcp_schema_summary(_mcp_tool_schema_from_payload(tool)),
|
||
}
|
||
|
||
|
||
def _mcp_tools_from_runtime_status(runtime_by_name, server_summaries):
|
||
"""Read detailed MCP tool payloads from runtime status when available."""
|
||
tools = []
|
||
if not isinstance(runtime_by_name, dict):
|
||
return tools
|
||
for server_name, runtime in runtime_by_name.items():
|
||
if not isinstance(runtime, dict):
|
||
continue
|
||
raw_tools = runtime.get("tools")
|
||
if not isinstance(raw_tools, list):
|
||
raw_tools = runtime.get("tool_schemas")
|
||
if not isinstance(raw_tools, list):
|
||
continue
|
||
server_summary = server_summaries.get(str(server_name), {"name": str(server_name)})
|
||
for index, tool in enumerate(raw_tools):
|
||
fallback_name = f"{server_name}:{index}"
|
||
summary = _mcp_tool_summary(fallback_name, tool, server_summary)
|
||
if summary["name"]:
|
||
tools.append(summary)
|
||
return tools
|
||
|
||
|
||
def _mcp_tools_from_registry(server_summaries):
|
||
"""Read already-registered MCP tool schemas without probing MCP servers."""
|
||
try:
|
||
from tools.registry import registry
|
||
except Exception:
|
||
return []
|
||
tools = []
|
||
try:
|
||
names = registry.get_all_tool_names()
|
||
except Exception:
|
||
return []
|
||
for tool_name in names:
|
||
try:
|
||
toolset = registry.get_toolset_for_tool(tool_name)
|
||
except Exception:
|
||
continue
|
||
if not isinstance(toolset, str) or not toolset.startswith("mcp-"):
|
||
continue
|
||
server_name = toolset[len("mcp-"):]
|
||
schema = registry.get_schema(tool_name) or {}
|
||
server_summary = server_summaries.get(server_name, {
|
||
"name": server_name,
|
||
"enabled": True,
|
||
"active": False,
|
||
"status": "configured",
|
||
})
|
||
tools.append(_mcp_tool_summary(tool_name, schema, server_summary))
|
||
return tools
|
||
|
||
|
||
def _handle_mcp_tools_list(handler):
|
||
"""List known MCP tools from already-available runtime inventory only."""
|
||
cfg = get_config()
|
||
servers = cfg.get("mcp_servers", {})
|
||
if not isinstance(servers, dict):
|
||
servers = {}
|
||
runtime = _mcp_runtime_status_by_name()
|
||
server_summaries = {
|
||
str(name): _server_summary(str(name), scfg, runtime.get(str(name)))
|
||
for name, scfg in servers.items()
|
||
}
|
||
tools = _mcp_tools_from_runtime_status(runtime, server_summaries)
|
||
source = "mcp_runtime_status"
|
||
if not tools:
|
||
tools = _mcp_tools_from_registry(server_summaries)
|
||
source = "tool_registry" if tools else "none"
|
||
tools.sort(key=lambda row: (row.get("server", ""), row.get("name", "")))
|
||
unavailable_servers = [
|
||
summary["name"] for summary in server_summaries.values()
|
||
if summary.get("enabled") and not summary.get("active")
|
||
]
|
||
return j(handler, {
|
||
"tools": tools,
|
||
"total": len(tools),
|
||
"source": source,
|
||
"inventory_scope": "already_known_runtime_only",
|
||
"unavailable_servers": unavailable_servers,
|
||
})
|
||
|
||
|
||
def _handle_mcp_servers_list(handler):
|
||
"""List configured MCP servers with safe, read-only runtime visibility."""
|
||
cfg = get_config()
|
||
servers = cfg.get("mcp_servers", {})
|
||
if not isinstance(servers, dict):
|
||
servers = {}
|
||
runtime = _mcp_runtime_status_by_name()
|
||
result = [
|
||
_server_summary(name, scfg, runtime.get(str(name)))
|
||
for name, scfg in servers.items()
|
||
]
|
||
return j(handler, {
|
||
"servers": result,
|
||
"toggle_supported": False,
|
||
"reload_required": True,
|
||
})
|
||
|
||
|
||
def _handle_mcp_server_delete(handler, name):
|
||
"""Delete an MCP server by name."""
|
||
from urllib.parse import unquote
|
||
name = unquote(name)
|
||
if not name:
|
||
return bad(handler, "name is required")
|
||
cfg = get_config()
|
||
servers = cfg.get("mcp_servers", {})
|
||
if not isinstance(servers, dict):
|
||
servers = {}
|
||
if name not in servers:
|
||
return bad(handler, f"MCP server '{name}' not found", 404)
|
||
del servers[name]
|
||
cfg["mcp_servers"] = servers
|
||
_save_yaml_config_file(_get_config_path(), cfg)
|
||
reload_config()
|
||
return j(handler, {"ok": True, "deleted": name})
|
||
|
||
|
||
_MASKED_PLACEHOLDER = "••••••"
|
||
|
||
|
||
def _strip_masked_values(submitted, existing):
|
||
"""Remove masked placeholder values from submitted dict, keeping originals."""
|
||
if not isinstance(submitted, dict) or not isinstance(existing, dict):
|
||
return submitted
|
||
cleaned = {}
|
||
for k, v in submitted.items():
|
||
if isinstance(v, str) and v == _MASKED_PLACEHOLDER:
|
||
if k in existing and isinstance(existing[k], str):
|
||
cleaned[k] = existing[k] # preserve original real value
|
||
continue
|
||
elif isinstance(v, dict) and k in existing and isinstance(existing[k], dict):
|
||
cleaned[k] = _strip_masked_values(v, existing[k])
|
||
else:
|
||
cleaned[k] = v
|
||
return cleaned
|
||
|
||
|
||
def _handle_mcp_server_update(handler, name, body):
|
||
"""Add or update an MCP server."""
|
||
from urllib.parse import unquote
|
||
name = unquote(name)
|
||
if not name:
|
||
return bad(handler, "name is required")
|
||
# Validate: must have url (http) or command (stdio)
|
||
server_cfg = {}
|
||
cfg = get_config()
|
||
servers = cfg.get("mcp_servers", {})
|
||
if not isinstance(servers, dict):
|
||
servers = {}
|
||
existing_cfg = servers.get(name, {})
|
||
if body.get("url"):
|
||
server_cfg["url"] = body["url"].strip()
|
||
if body.get("headers"):
|
||
server_cfg["headers"] = _strip_masked_values(body["headers"], existing_cfg.get("headers", {}))
|
||
elif body.get("command"):
|
||
server_cfg["command"] = body["command"].strip()
|
||
if body.get("args"):
|
||
server_cfg["args"] = body["args"] if isinstance(body["args"], list) else [body["args"]]
|
||
if body.get("env"):
|
||
server_cfg["env"] = _strip_masked_values(body["env"], existing_cfg.get("env", {}))
|
||
else:
|
||
return bad(handler, "url or command is required")
|
||
if body.get("timeout") is not None:
|
||
try:
|
||
server_cfg["timeout"] = int(body["timeout"])
|
||
except (ValueError, TypeError):
|
||
pass
|
||
servers[name] = server_cfg
|
||
cfg["mcp_servers"] = servers
|
||
_save_yaml_config_file(_get_config_path(), cfg)
|
||
reload_config()
|
||
return j(handler, {"ok": True, "server": _server_summary(name, server_cfg)})
|