Merge branch 'simp/r3-25-E' into simp/integration3

This commit is contained in:
Teknium
2026-09-02 21:58:24 -07:00
5 changed files with 836 additions and 1827 deletions
File diff suppressed because it is too large Load Diff
+87 -154
View File
@@ -8,20 +8,15 @@ late-binding seam so ``monkeypatch.setattr(web_server, ...)`` keeps working.
import asyncio
import functools
import time
from typing import Optional
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from hermes_cli.web_deps import late
from hermes_cli.web_models import (
CronJobCreate,
CronJobUpdate,
AutomationBlueprintInstantiate,
)
from hermes_cli.web_models import AutomationBlueprintInstantiate, CronJobCreate, CronJobUpdate
from hermes_cli.web_routers._common import log as _log
from typing import Any, Dict, List
from pathlib import Path
router = APIRouter()
@@ -46,47 +41,51 @@ _cron_optional_text = late("_cron_optional_text")
_cron_string_list = late("_cron_string_list")
_normalize_dashboard_cron_script = late("_normalize_dashboard_cron_script")
def _job_not_found() -> HTTPException:
return HTTPException(status_code=404, detail="Job not found")
def _normalize_dashboard_cron_updates(
updates: Dict[str, Any],
profile_home: Path,
) -> Dict[str, Any]:
def _normalize_dashboard_cron_updates(updates: Dict[str, Any], profile_home: Path) -> Dict[str, Any]:
"""Normalize dashboard JSON into cron.jobs.update_job's storage shape.
This intentionally stays in the dashboard adapter layer: cron/jobs.py is the
source of truth for scheduling behaviour; the dashboard only translates form
payloads into the shapes that existing core functions already accept.
Stays in the dashboard adapter layer on purpose: cron/jobs.py is the source
of truth for scheduling; this only translates form payloads into shapes the
core functions already accept.
"""
normalized = dict(updates or {})
for key in ("model", "provider", "workdir"):
if key in normalized:
normalized[key] = _cron_optional_text(normalized[key])
if "script" in normalized:
normalized["script"] = _normalize_dashboard_cron_script(
normalized["script"],
profile_home,
)
normalized["script"] = _normalize_dashboard_cron_script(normalized["script"], profile_home)
if "base_url" in normalized:
normalized["base_url"] = _cron_optional_text(
normalized["base_url"], strip_trailing_slash=True
)
normalized["base_url"] = _cron_optional_text(normalized["base_url"], strip_trailing_slash=True)
if "deliver" in normalized:
normalized["deliver"] = _cron_optional_text(normalized["deliver"]) or "local"
if "failure_deliver" in normalized:
# Same text normalization as deliver, but empty CLEARS the override
# (failures fall back to deliver) rather than coalescing to a target
# — the field is optional by design (NS-788).
normalized["failure_deliver"] = _cron_optional_text(
normalized["failure_deliver"]
)
if "context_from" in normalized:
normalized["context_from"] = _cron_string_list(normalized["context_from"])
if "enabled_toolsets" in normalized:
normalized["enabled_toolsets"] = _cron_string_list(normalized["enabled_toolsets"])
# Same normalization as deliver, but empty CLEARS the override (failures
# fall back to deliver) rather than coalescing — the field is optional.
normalized["failure_deliver"] = _cron_optional_text(normalized["failure_deliver"])
for key in ("context_from", "enabled_toolsets"):
if key in normalized:
normalized[key] = _cron_string_list(normalized[key])
return normalized
def _job_profile(job_id: str, profile: Optional[str]) -> str:
"""Profile owning ``job_id`` (explicit or discovered); 404 when none."""
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise _job_not_found()
return selected
def _found(job):
if not job:
raise _job_not_found()
return job
def _list_cron_jobs_sync(profile: str = "all"):
requested = (profile or "all").strip()
if requested.lower() != "all":
@@ -105,29 +104,17 @@ def _list_cron_jobs_sync(profile: str = "all"):
def _get_cron_job_sync(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _call_cron_for_profile(selected, "get_job", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
return _found(_call_cron_for_profile(_job_profile(job_id, profile), "get_job", job_id))
def _list_cron_job_runs_sync(job_id: str, profile: Optional[str] = None, limit: int = 20):
"""Run sessions produced by a cron job, newest first.
Cron runs are stored as ordinary sessions whose id is
``cron_{job_id}_{timestamp}`` (see cron/scheduler.run_job). A job's history
is therefore every session whose id carries that prefix; ``source='cron'``
narrows it and the id prefix binds it to this job. Powers the run-history
list under each job in the desktop cron detail. Same row shape as
``/api/sessions`` so the frontend can reuse SessionInfo.
Backed by ``SessionDB.list_cron_job_runs`` — a bounded ``[prefix, hi)``
id-range scan, not the compression-chain CTE used for the recents list,
so the cost scales with the requested window and not the (unbounded) total
cron history.
Runs are ordinary sessions with id ``cron_{job_id}_{timestamp}`` (see
cron/scheduler.run_job); ``source='cron'`` plus the id prefix binds them to
this job. Same row shape as ``/api/sessions`` so the frontend reuses
SessionInfo. Backed by ``SessionDB.list_cron_job_runs`` — a bounded id-range
scan, so cost scales with the requested window, not total cron history.
"""
selected = profile or _find_cron_job_profile(job_id)
# job_id may be a human name; resolve to the canonical id used in run-session ids.
@@ -147,10 +134,7 @@ def _list_cron_job_runs_sync(job_id: str, profile: Optional[str] = None, limit:
runs = db.list_cron_job_runs(canonical, limit=limit_n, offset=0)
now = time.time()
for s in runs:
s["is_active"] = (
s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
s["is_active"] = s.get("ended_at") is None and (now - s.get("last_active", s.get("started_at", 0))) < 300
s["archived"] = bool(s.get("archived"))
if selected:
s["profile"] = selected
@@ -159,26 +143,18 @@ def _list_cron_job_runs_sync(job_id: str, profile: Optional[str] = None, limit:
db.close()
_EXECUTION_FIELDS = {"prompt", "skill", "skills", "script", "no_agent"}
def _update_cron_job_sync(job_id: str, body: CronJobUpdate, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
selected = _job_profile(job_id, profile)
try:
profile_name, profile_home = _cron_profile_home(selected)
existing = _call_cron_for_profile(profile_name, "get_job", job_id)
if not existing:
raise HTTPException(status_code=404, detail="Job not found")
updates = _normalize_dashboard_cron_updates(
body.updates,
profile_home,
)
existing = _found(_call_cron_for_profile(profile_name, "get_job", job_id))
updates = _normalize_dashboard_cron_updates(body.updates, profile_home)
if "context_from" in updates:
_validate_dashboard_cron_context_from(
updates.get("context_from"),
profile_name,
)
execution_fields = {"prompt", "skill", "skills", "script", "no_agent"}
if execution_fields.intersection(updates):
_validate_dashboard_cron_context_from(updates.get("context_from"), profile_name)
if _EXECUTION_FIELDS.intersection(updates):
effective = {**existing, **updates}
if "skills" in updates and "skill" not in updates:
effective["skill"] = None
@@ -188,80 +164,53 @@ def _update_cron_job_sync(job_id: str, body: CronJobUpdate, profile: Optional[st
raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
return _found(job)
def _pause_cron_job_sync(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _mutate_cron_for_profile(selected, "pause_job", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
return _found(_mutate_cron_for_profile(_job_profile(job_id, profile), "pause_job", job_id))
def _resume_cron_job_sync(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _mutate_cron_for_profile(selected, "resume_job", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
return _found(_mutate_cron_for_profile(_job_profile(job_id, profile), "resume_job", job_id))
def _trigger_cron_job_sync(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
job = _call_cron_for_profile(selected, "resolve_job_ref", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
# Do not expose the job as due before claiming it: the built-in ticker and
# external/manual fire paths share the same durable claim, so only one can
# execute this selected run even if they race across processes. Active jobs
# keep the legacy provider call shape; paused jobs need the explicit force
# flag to resume and claim atomically.
selected = _job_profile(job_id, profile)
job = _found(_call_cron_for_profile(selected, "resolve_job_ref", job_id))
# Never expose the job as due before claiming it: the built-in ticker and
# external/manual fire paths share one durable claim, so only one executes
# this run even racing across processes. Active jobs keep the legacy call
# shape; paused jobs need the explicit force flag to resume + claim atomically.
force = not job.get("enabled", True) or job.get("state") == "paused"
ran = _fire_cron_job_for_profile(selected, job["id"], force=force)
refreshed = _call_cron_for_profile(selected, "get_job", job["id"])
if refreshed and refreshed.get("last_run_at") != job.get("last_run_at"):
return refreshed
if not ran:
raise HTTPException(
status_code=409,
detail="Job is already running or was claimed by another scheduler",
)
raise HTTPException(status_code=409, detail="Job is already running or was claimed by another scheduler")
if refreshed:
return refreshed
# A one-shot may remove itself after exhausting repeat=1. Keep the response
# shape compatible without inventing an outcome that is no longer present
# in the job store; authoritative list refresh removes the completed row.
return {
**job,
"enabled": False,
"state": "completed",
}
# A one-shot may remove itself after exhausting repeat=1: keep the response
# shape without inventing an outcome the store no longer holds; the list
# refresh removes the completed row.
return {**job, "enabled": False, "state": "completed"}
def _delete_cron_job_sync(job_id: str, profile: Optional[str] = None):
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")
selected = _job_profile(job_id, profile)
try:
removed = _mutate_cron_for_profile(selected, "remove_job", job_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not removed:
raise HTTPException(status_code=404, detail="Job not found")
raise _job_not_found()
return {"ok": True}
# Retry-After hint (seconds) on retryable cron-fire 503s: sized to clear a
# scale-to-zero wake or gateway restart so a scheduler that honors it spaces
# its next attempt past the outage instead of burning its retry budget in it.
# Retry-After (seconds) on retryable cron-fire 503s: sized to clear a
# scale-to-zero wake or gateway restart so a scheduler that honors it spaces its
# next attempt past the outage instead of burning its retry budget in it.
_CRON_FIRE_RETRY_AFTER_SECONDS = 60
@@ -290,14 +239,7 @@ async def get_cron_delivery_targets():
"""Delivery targets for the cron dropdown: implicit ``local`` plus the
configured gateway platforms (a platform without a cron home channel is
still listed with ``home_target_set: false`` so the UI can say so)."""
targets = [
{
"id": "local",
"name": "Local (save only)",
"home_target_set": True,
"home_env_var": None,
}
]
targets = [{"id": "local", "name": "Local (save only)", "home_target_set": True, "home_env_var": None}]
try:
from cron.scheduler import cron_delivery_targets
@@ -336,13 +278,12 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None):
async def cron_fire_webhook(request: Request):
"""Chronos managed-cron fire webhook (NAS -> agent) — gateway forwarder.
Gated by the NAS-minted JWT (this path is in ``PUBLIC_API_PATHS``), not the
dashboard cookie. The dashboard is only the public door: execution belongs
to the GATEWAY process (it owns the live platform adapters relay-fronted
and E2EE targets need), so the fire is forwarded to the gateway
api_server's own ``/api/cron/fire`` on loopback and its response passed
through (the gateway re-verifies the JWT). Gateway unreachable -> 503 so
NAS retries; deliberately NO local-execution fallback.
Gated by the NAS-minted JWT (path is in ``PUBLIC_API_PATHS``), not the
dashboard cookie. Execution belongs to the GATEWAY process (it owns the live
platform adapters relay-fronted and E2EE targets need), so the fire is
forwarded to the gateway api_server's own ``/api/cron/fire`` on loopback
and its response passed through (the gateway re-verifies the JWT). Gateway
unreachable -> 503 so NAS retries; deliberately NO local-execution fallback.
"""
from plugins.cron_providers.chronos.verify import get_fire_verifier
@@ -369,15 +310,14 @@ async def cron_fire_webhook(request: Request):
# Walks every profile's job list (file I/O) — off the event loop.
profile = await _run_cron_dashboard_io(_find_cron_job_profile, job_id)
if not profile:
# Job is gone (cancelled / completed): 200 so NAS does not retry.
if not profile: # job is gone (cancelled / completed): 200 so NAS does not retry
return JSONResponse({"status": "gone", "job_id": job_id}, status_code=200)
forwarded = await _forward_cron_fire_to_gateway(profile, job_id, auth)
if forwarded is None:
# Stamp the miss on the job record (last_fire_error) so the dead hop is
# visible in `cronjob list` / the dashboard, not just gui.log.
# Best-effort: visibility must never break the retry contract below.
# visible in `cronjob list` / the dashboard. Best-effort: visibility
# must never break the retry contract below.
try:
await _run_cron_dashboard_io(
_call_cron_for_profile,
@@ -394,7 +334,7 @@ async def cron_fire_webhook(request: Request):
# Split by operator intent: a deliberately stopped gateway (durable
# desired_state == "stopped") can never be reached by retrying, so drop
# with 200 + a structured log line — the Chronos provider re-arms every
# job on the next gateway start. A transient window (wake, restart,
# job on the next gateway start. A transient window (wake, restart,
# crash loop) keeps the retryable 503 with a Retry-After hint.
if await _run_cron_dashboard_io(_gateway_intentionally_stopped, profile):
_log.info(
@@ -406,27 +346,21 @@ async def cron_fire_webhook(request: Request):
return JSONResponse(
{
"status": "gateway_stopped",
"detail": "gateway deliberately stopped; fire dropped, "
"jobs re-arm on next gateway start",
"detail": "gateway deliberately stopped; fire dropped, jobs re-arm on next gateway start",
"job_id": job_id,
"profile": profile,
},
status_code=200,
)
return JSONResponse(
{
"error": "gateway unreachable; retry",
"job_id": job_id,
"profile": profile,
},
{"error": "gateway unreachable; retry", "job_id": job_id, "profile": profile},
status_code=503,
headers={"Retry-After": str(_CRON_FIRE_RETRY_AFTER_SECONDS)},
)
status_code, gateway_body = forwarded
if isinstance(gateway_body, dict):
gateway_body.setdefault("job_id", job_id)
# The gateway's own 503s (draining, admission failure) are equally
# transient — same spacing hint.
# The gateway's own 503s (draining, admission failure) are equally transient.
headers = {"Retry-After": str(_CRON_FIRE_RETRY_AFTER_SECONDS)} if status_code == 503 else None
return JSONResponse(gateway_body, status_code=status_code, headers=headers)
@@ -465,21 +399,20 @@ async def list_cron_blueprints():
async def instantiate_blueprint(body: AutomationBlueprintInstantiate, profile: str = "default"):
"""Fill a blueprint's slots and create the cron job (form-submit path)."""
try:
from cron.blueprint_catalog import fill_blueprint, get_blueprint, BlueprintFillError
from cron.blueprint_catalog import BlueprintFillError, fill_blueprint, get_blueprint
blueprint = get_blueprint(body.blueprint)
if blueprint is None:
raise HTTPException(status_code=404, detail=f"Unknown blueprint: {body.blueprint}")
try:
spec = fill_blueprint(blueprint, body.values)
except BlueprintFillError as exc:
# Field-level validation error — 422 so the form can show it inline.
except BlueprintFillError as exc: # field-level error — 422 so the form shows it inline
raise HTTPException(status_code=422, detail=str(exc)) from exc
# Blueprint jobs deliver to the dashboard's configured target by
# default; the form's deliver slot overrides via spec["deliver"].
# Blueprint jobs deliver to the dashboard's configured target by default;
# the form's deliver slot overrides via spec["deliver"].
spec.pop("origin", None)
# Off the event loop like the sibling endpoints; partial keeps **spec
# keys from colliding with the wrapper's own parameters.
# Off-loop like the siblings; partial keeps **spec keys from colliding
# with the wrapper's own parameters.
_create = functools.partial(_call_cron_for_profile, profile, "create_job", **spec)
created = await _run_cron_dashboard_io(_create)
# Reconcile the profile-scoped provider (file I/O + NAS calls) off-loop.
+217 -423
View File
@@ -1,31 +1,35 @@
"""Managed-files, chat image upload, /api/media and /api/fs dashboard routes.
Extracted from ``hermes_cli.web_server``; helpers/state that tests monkeypatch on
``web_server`` stay there and are imported lazily at call time (cycle-safe).
Helpers/state that tests monkeypatch on ``web_server`` stay there and are
reached through the late-binding seam (cycle-safe).
"""
import asyncio
import base64
import binascii
import contextlib
import mimetypes
import os
import re
import stat
import tempfile
import asyncio
import secrets
import shutil
import stat
import subprocess
import sys
import tempfile
from datetime import datetime
from fastapi import APIRouter
from hermes_cli.web_deps import late
from fastapi import File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from hermes_cli._subprocess_compat import windows_hide_flags
from hermes_cli.web_models import ManagedFileUpload, ChatImageUpload, ManagedDirectoryCreate, ManagedFileDelete, FsWriteText
from pathlib import Path
from typing import Any, Dict, Optional
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from hermes_cli._subprocess_compat import windows_hide_flags
from hermes_cli.web_deps import late
from hermes_cli.web_models import (
ChatImageUpload, FsWriteText, ManagedDirectoryCreate, ManagedFileDelete, ManagedFileUpload,
)
router = APIRouter()
# web_server helpers, late-bound so monkeypatch.setattr(web_server, ...) stays authoritative.
@@ -38,114 +42,48 @@ get_hermes_home = late("get_hermes_home")
load_config = late("load_config")
# Image MIME types this endpoint will serve. Extension-allowlisted so an
# authenticated caller can't pull non-image files through it.
# Image types GET /api/media serves — extension-allowlisted so an authenticated
# caller can't pull non-image files through it.
_MEDIA_CONTENT_TYPES = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".bmp": "image/bmp",
".ico": "image/x-icon",
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp", ".ico": "image/x-icon",
}
_MEDIA_MAX_BYTES = 25 * 1024 * 1024
_STREAMABLE_MEDIA_EXTENSIONS = frozenset(
{
".avi",
".flac",
".m4a",
".mkv",
".mov",
".mp3",
".mp4",
".ogg",
".opus",
".wav",
".webm",
}
)
_STREAMABLE_MEDIA_EXTENSIONS = frozenset({
".avi", ".flac", ".m4a", ".mkv", ".mov", ".mp3", ".mp4", ".ogg", ".opus", ".wav", ".webm",
})
_FS_READDIR_HIDDEN = {
".git",
".hg",
".svn",
".cache",
".next",
".turbo",
".venv",
"__pycache__",
"build",
"dist",
"node_modules",
"target",
"venv",
".git", ".hg", ".svn", ".cache", ".next", ".turbo", ".venv", "__pycache__",
"build", "dist", "node_modules", "target", "venv",
}
# Filenames that must never be listed, read, or downloaded through the
# managed-files API. These typically contain credentials (API keys, tokens)
# and exposing them through the dashboard file browser is a security leak —
# see issue #57505. The set mirrors the credential-file basenames of the two
# canonical credential guards elsewhere in the codebase
# (agent.file_safety.get_read_block_error and
# gateway.platforms.base._ROOT_CREDENTIAL_FILES) so the dashboard Files tab
# doesn't lag behind them — an operator can point the managed root at
# HERMES_HOME itself, at which point every one of these basenames is a live
# secret store sitting in the browsable tree.
# Basenames the managed-files API must never list, read or download: credential
# stores that become live secrets in the browsable tree the moment an operator
# points the managed root at HERMES_HOME. Mirrors the two canonical guards
# (agent.file_safety.get_read_block_error, gateway.platforms.base
# ._ROOT_CREDENTIAL_FILES) so the Files tab never lags behind them.
_SENSITIVE_MANAGED_FILE_BASENAMES = frozenset({
"auth.json",
"auth.lock",
"credentials",
"config.yaml",
".anthropic_oauth.json",
"google_token.json",
"google_oauth_pending.json",
"google_oauth.json",
"webhook_subscriptions.json",
"bws_cache.json",
"bws_cache.enc.json",
# git's credential-store helper cache (agent.file_safety blocks this too).
".git-credentials",
"auth.json", "auth.lock", "credentials", "config.yaml", ".anthropic_oauth.json",
"google_token.json", "google_oauth_pending.json", "google_oauth.json",
"webhook_subscriptions.json", "bws_cache.json", "bws_cache.enc.json",
".git-credentials", # git's credential-store cache (file_safety blocks it too)
})
# Directory names whose entire subtree is credential material. Both canonical
# guards deny these as directory trees, not basenames:
# * gateway.platforms.base._ROOT_CREDENTIAL_DIRS = {"pairing", "mcp-tokens"}
# * agent.file_safety.get_read_block_error (mcp-tokens/ prefix match)
# The managed-files API lets the browser descend into subdirs, so a
# basename-only guard would still expose e.g. ``mcp-tokens/<server>.json``
# (live MCP OAuth tokens) and ``pairing/<x>``. We match on ANY path component
# so these trees are blocked wherever they appear under the browsable root,
# without needing to resolve them relative to HERMES_HOME.
_SENSITIVE_MANAGED_DIR_NAMES = frozenset({
"mcp-tokens",
"pairing",
})
# Directory names whose whole subtree is credential material (the canonical
# guards deny these as trees: _ROOT_CREDENTIAL_DIRS and the mcp-tokens/ prefix
# match). The browser can descend into subdirs, so a basename-only guard would
# still expose ``mcp-tokens/<server>.json``; match on ANY path component so the
# trees are blocked wherever they sit under the root, no HERMES_HOME resolution.
_SENSITIVE_MANAGED_DIR_NAMES = frozenset({"mcp-tokens", "pairing"})
def _is_sensitive_filename(name: str) -> bool:
"""Return True for a basename the managed-files API must never expose.
Covers ``.env`` / ``.env.<suffix>`` / ``.envrc`` variants plus the
canonical Hermes credential-store basenames (see
``_SENSITIVE_MANAGED_FILE_BASENAMES`` above).
Case-insensitive so ``.ENV`` / ``.Env.local`` / ``Auth.JSON`` on
case-insensitive filesystems (macOS/Windows mounts) can't slip past
the guard.
Basename-only: for the directory-tree credential stores
(``mcp-tokens/``, ``pairing/``) that the canonical guards also deny,
use :func:`_is_sensitive_path`, which the API call sites route through.
"""
"""Basename denylist: ``.env`` / ``.env.<suffix>`` / ``.envrc`` plus the
credential-store basenames. Case-insensitive so ``.ENV`` / ``Auth.JSON``
on case-insensitive mounts can't slip past. Basename-only — call sites use
:func:`_is_sensitive_path`, which adds the credential-directory check."""
lowered = name.lower()
if lowered == ".env" or lowered.startswith(".env.") or lowered == ".envrc":
return True
@@ -153,94 +91,35 @@ def _is_sensitive_filename(name: str) -> bool:
def _is_sensitive_path(path: Path) -> bool:
"""Return True for any path the managed-files API must never expose.
Combines the basename denylist (:func:`_is_sensitive_filename`) with a
credential-directory-tree check: a path is sensitive if its own basename
is sensitive OR any of its path components is a credential directory
(``mcp-tokens`` / ``pairing``). The component match is case-insensitive
and needs no HERMES_HOME resolution, so it blocks these trees wherever
they sit under the operator-configured managed root — closing the gap
the canonical guards cover as directory trees but a basename-only check
would miss.
Read-side only: this guards list/read/download (the #57505 exfil surface).
The write endpoints (upload/mkdir/delete) are a separate threat class
handled by the write-path checks; extending this guard to them is out of
scope for this fix.
"""
"""True when the basename is sensitive OR any path component (case-
insensitive) is a credential directory. Read-side guard (list/read/
download); the write endpoints are a separate threat class."""
if _is_sensitive_filename(path.name):
return True
return any(part.lower() in _SENSITIVE_MANAGED_DIR_NAMES for part in path.parts)
_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024
_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024
# Upper bound for the in-app spot editor's save. The editor only opens
# non-truncated text (<= the preview cap), so this is a safety ceiling against
# a pasted-in megablob, not the expected payload size.
# Spot-editor save ceiling: the editor only opens non-truncated text (<= the
# preview cap), so this guards against a pasted megablob, not expected payloads.
_FS_TEXT_WRITE_MAX_BYTES = 8 * 1024 * 1024
_FS_PREVIEW_LANGUAGE_BY_EXT = {
".c": "c",
".conf": "ini",
".cpp": "cpp",
".css": "css",
".csv": "csv",
".go": "go",
".graphql": "graphql",
".h": "c",
".hpp": "cpp",
".html": "html",
".java": "java",
".js": "javascript",
".json": "json",
".jsx": "jsx",
".kt": "kotlin",
".lua": "lua",
".md": "markdown",
".mjs": "javascript",
".py": "python",
".rb": "ruby",
".rs": "rust",
".sh": "shell",
".sql": "sql",
".svg": "xml",
".toml": "toml",
".ts": "typescript",
".tsx": "tsx",
".txt": "text",
".xml": "xml",
".yaml": "yaml",
".yml": "yaml",
".zsh": "shell",
".c": "c", ".conf": "ini", ".cpp": "cpp", ".css": "css", ".csv": "csv", ".go": "go",
".graphql": "graphql", ".h": "c", ".hpp": "cpp", ".html": "html", ".java": "java",
".js": "javascript", ".json": "json", ".jsx": "jsx", ".kt": "kotlin", ".lua": "lua",
".md": "markdown", ".mjs": "javascript", ".py": "python", ".rb": "ruby", ".rs": "rust",
".sh": "shell", ".sql": "sql", ".svg": "xml", ".toml": "toml", ".ts": "typescript",
".tsx": "tsx", ".txt": "text", ".xml": "xml", ".yaml": "yaml", ".yml": "yaml", ".zsh": "shell",
}
_FS_MIME_TYPES = {
".avi": "video/x-msvideo",
".bmp": "image/bmp",
".flac": "audio/flac",
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".m4a": "audio/mp4",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".ogg": "audio/ogg",
".opus": "audio/ogg; codecs=opus",
".png": "image/png",
".svg": "image/svg+xml",
".wav": "audio/wav",
".webm": "video/webm",
".webp": "image/webp",
".avi": "video/x-msvideo", ".bmp": "image/bmp", ".flac": "audio/flac", ".gif": "image/gif",
".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".m4a": "audio/mp4", ".mkv": "video/x-matroska",
".mov": "video/quicktime", ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".ogg": "audio/ogg",
".opus": "audio/ogg; codecs=opus", ".png": "image/png", ".svg": "image/svg+xml",
".wav": "audio/wav", ".webm": "video/webm", ".webp": "image/webp",
}
@@ -261,13 +140,22 @@ def _fs_looks_binary(data: bytes) -> bool:
return suspicious / len(data) > 0.12
@contextlib.contextmanager
def _io_errors(denied: str, failed: str):
"""PermissionError -> 403 ``denied``; other OSError -> 500 ``"<failed>: <exc>"``."""
try:
yield
except PermissionError:
raise HTTPException(status_code=403, detail=denied)
except OSError as exc:
raise HTTPException(status_code=500, detail=f"{failed}: {exc}")
def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]:
target = _fs_path(str(path))
try:
st = target.stat()
except FileNotFoundError:
raise HTTPException(status_code=404, detail="File not found")
except NotADirectoryError:
except (FileNotFoundError, NotADirectoryError):
raise HTTPException(status_code=404, detail="File not found")
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
@@ -280,6 +168,19 @@ def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]:
return target, st
def _fs_read_bytes(target: Path, limit: Optional[int] = None) -> bytes:
"""Read (a prefix of) ``target``; 403/400 on failure."""
try:
if limit is None:
return target.read_bytes()
with target.open("rb") as handle:
return handle.read(limit)
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "File read failed")
def _fs_find_git_root(start: Path) -> str | None:
directory = start
for _ in range(50):
@@ -310,36 +211,22 @@ def _fs_default_cwd() -> str:
def _fs_git_branch(cwd: str) -> str:
try:
run_kwargs: Dict[str, Any] = {
"capture_output": True,
"text": True,
"timeout": 2,
"check": False,
}
run_kwargs: Dict[str, Any] = {"capture_output": True, "text": True, "timeout": 2, "check": False}
if sys.platform == "win32":
run_kwargs["creationflags"] = windows_hide_flags()
result = subprocess.run(
["git", "-C", cwd, "branch", "--show-current"],
**run_kwargs,
)
result = subprocess.run(["git", "-C", cwd, "branch", "--show-current"], **run_kwargs)
return result.stdout.strip() if result.returncode == 0 else ""
except Exception:
return ""
def _media_serve_roots() -> list[Path]:
"""Directories ``GET /api/media`` is allowed to read from.
Confined to where the agent and attach pipeline actually write media on the
gateway host — its images dir and cache subtree. This stops an authenticated
client from reading image-extension files anywhere on disk (e.g. a renamed
key or a screenshot outside the cache) merely because the suffix passes the
allowlist.
"""
"""Directories GET /api/media may read: where the agent and attach pipeline
actually write media (images, screenshots, cache). Stops an authenticated
client reading image-suffixed files anywhere else on disk."""
home = get_hermes_home()
roots = [home / "images", home / "screenshots", home / "cache"]
out: list[Path] = []
for root in roots:
for root in (home / "images", home / "screenshots", home / "cache"):
try:
out.append(root.resolve())
except (OSError, RuntimeError):
@@ -349,16 +236,9 @@ def _media_serve_roots() -> list[Path]:
@router.get("/api/media")
async def get_media(path: str):
"""Return a gateway-local image file as a base64 data URL.
Lets remote clients (the desktop app over the network, or the web dashboard
in a browser) display images the agent wrote to *this* machine's filesystem
— they can't read the gateway's local disk directly.
Auth-gated by the session token like every other /api route. Restricted to
an image-extension allowlist, a size cap, AND the gateway's own media roots
(resolved, symlink-safe) so it can't be used to read arbitrary files.
"""
"""Return a gateway-local image as a base64 data URL for remote clients
that can't read this machine's disk. Auth-gated; restricted to the image
allowlist, a size cap AND the resolved (symlink-safe) media roots."""
try:
target = Path(path).expanduser().resolve()
except (OSError, RuntimeError):
@@ -366,11 +246,9 @@ async def get_media(path: str):
if target.suffix.lower() not in _MEDIA_CONTENT_TYPES:
raise HTTPException(status_code=415, detail="Unsupported media type")
roots = _media_serve_roots()
if not any(target == root or root in target.parents for root in roots):
raise HTTPException(status_code=403, detail="Path outside media roots")
if not target.is_file():
raise HTTPException(status_code=404, detail="File not found")
if target.stat().st_size > _MEDIA_MAX_BYTES:
@@ -380,15 +258,6 @@ async def get_media(path: str):
return {"data_url": f"data:{_MEDIA_CONTENT_TYPES[target.suffix.lower()]};base64,{encoded}"}
def _local_dashboard_request(request: Request) -> bool:
if getattr(request.app.state, "auth_required", False):
return False
host = (request.url.hostname or "").lower()
client_host = (request.client.host if request.client else "").lower()
local_hosts = {"", "localhost", "127.0.0.1", "::1", "testserver", "testclient"}
return host in local_hosts or client_host in local_hosts
def _decode_data_url(data_url: str) -> tuple[bytes, str]:
from hermes_cli.web_server import _MANAGED_FILE_MAX_BYTES
text = (data_url or "").strip()
@@ -408,25 +277,17 @@ def _decode_data_url(data_url: str) -> tuple[bytes, str]:
_CHAT_IMAGE_UPLOAD_MAX_BYTES = 25 * 1024 * 1024
_CHAT_IMAGE_ALLOWED_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"})
_CHAT_IMAGE_MAGIC: tuple[tuple[bytes, str], ...] = (
(b"\x89PNG\r\n\x1a\n", ".png"),
(b"\xff\xd8\xff", ".jpg"),
(b"GIF87a", ".gif"),
(b"GIF89a", ".gif"),
(b"BM", ".bmp"),
(b"\x89PNG\r\n\x1a\n", ".png"), (b"\xff\xd8\xff", ".jpg"),
(b"GIF87a", ".gif"), (b"GIF89a", ".gif"), (b"BM", ".bmp"),
)
def _sanitize_chat_image_filename(filename: str | None) -> str:
candidate = Path(str(filename or "").strip()).name
candidate = re.sub(r"[\x00-\x1f]+", "_", candidate)
candidate = candidate.strip().strip(".")
return candidate or "pasted-image"
return candidate.strip().strip(".") or "pasted-image"
def _chat_image_extension(data: bytes) -> str | None:
@@ -446,7 +307,6 @@ def _decode_chat_image_upload(payload: ChatImageUpload) -> tuple[bytes, str, str
if len(data) > _CHAT_IMAGE_UPLOAD_MAX_BYTES:
mb = _CHAT_IMAGE_UPLOAD_MAX_BYTES // (1024 * 1024)
raise HTTPException(status_code=413, detail=f"Image is too large; cap is {mb} MB")
ext = _chat_image_extension(data)
if ext not in _CHAT_IMAGE_ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="Unsupported image type")
@@ -455,38 +315,26 @@ def _decode_chat_image_upload(payload: ChatImageUpload) -> tuple[bytes, str, str
@router.post("/api/chat/image-upload")
async def upload_chat_image(payload: ChatImageUpload, profile: Optional[str] = None):
"""Persist a browser-provided chat image where the embedded TUI can read it.
"""Persist a browser clipboard image where the embedded TUI can read it.
The dashboard /chat page runs Hermes inside an xterm.js PTY. Browser
clipboard image bytes are not visible to the server-side clipboard, so the
page uploads them here, then drives the TUI's ``/image <path>`` command
with the returned gateway-visible path. Files land under
``HERMES_HOME/images/`` — the same directory ``clipboard.paste`` /
``image.attach`` already use.
Browser clipboard bytes aren't visible to the server-side clipboard, so the
/chat page uploads them here and drives the TUI's ``/image <path>`` with
the returned gateway-visible path under ``HERMES_HOME/images/`` (the same
dir ``clipboard.paste`` / ``image.attach`` use).
"""
def _run():
data, mime_type, ext = _decode_chat_image_upload(payload)
with _profile_scope(profile) as scoped_home:
home = scoped_home or get_hermes_home()
img_dir = Path(home) / "images"
try:
img_dir = Path(scoped_home or get_hermes_home()) / "images"
with _io_errors("Image directory is not writable", "Could not create image directory"):
img_dir.mkdir(parents=True, exist_ok=True)
except PermissionError:
raise HTTPException(status_code=403, detail="Image directory is not writable")
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not create image directory: {exc}")
stem = Path(_sanitize_chat_image_filename(payload.filename)).stem or "pasted-image"
stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", stem).strip("._-") or "pasted-image"
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
target = img_dir / f"dashboard_{ts}_{secrets.token_hex(4)}_{stem}{ext}"
try:
with _io_errors("Image directory is not writable", "Could not write image"):
target.write_bytes(data)
except PermissionError:
raise HTTPException(status_code=403, detail="Image directory is not writable")
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not write image: {exc}")
return {
"ok": True,
@@ -496,9 +344,9 @@ async def upload_chat_image(payload: ChatImageUpload, profile: Optional[str] = N
"mime_type": mime_type,
}
# _profile_scope acquires _SKILLS_PROFILE_LOCK and the body does file I/O —
# keep both off the event loop (asyncio.to_thread copies the contextvar
# context, so the profile override stays scoped to the worker thread).
# _profile_scope takes _SKILLS_PROFILE_LOCK and the body does file I/O — both
# off the loop; to_thread copies the contextvar context so the override
# stays scoped to the worker thread.
return await asyncio.to_thread(_run)
@@ -510,33 +358,25 @@ async def list_managed_files(request: Request, path: Optional[str] = None):
if not target.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory")
try:
with os.scandir(target) as scan:
entries = [
_managed_file_entry(policy, Path(entry.path))
for entry in scan
if not _is_sensitive_path(Path(entry.path))
]
except PermissionError:
raise HTTPException(status_code=403, detail="Directory is not readable")
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not read directory: {exc}")
with _io_errors("Directory is not readable", "Could not read directory"), os.scandir(target) as scan:
entries = [
_managed_file_entry(policy, Path(entry.path))
for entry in scan
if not _is_sensitive_path(Path(entry.path))
]
entries.sort(key=lambda item: (not item["is_directory"], str(item["name"]).lower()))
locked_root = policy.locked_root
parent = None
if target.parent != target and (locked_root is None or target != locked_root):
parent = str(target.parent)
return {
"path": display_path,
"parent": parent,
"entries": entries,
**_managed_response_meta(policy),
}
return {"path": display_path, "parent": parent, "entries": entries, **_managed_response_meta(policy)}
@router.get("/api/files/read")
async def read_managed_file(request: Request, path: str):
def _managed_readable_file(request: Request, path: str) -> tuple[Any, Path, str, int, str]:
"""Resolve + guard a managed file for reading: existence, regular file,
sensitive-path denylist, size cap. Returns (policy, target, display_path,
size, mime_type)."""
from hermes_cli.web_server import _MANAGED_FILE_MAX_BYTES
policy, target, display_path = _resolve_managed_path(path, request)
if not target.exists():
@@ -545,22 +385,26 @@ async def read_managed_file(request: Request, path: str):
raise HTTPException(status_code=400, detail="Path is not a file")
if _is_sensitive_path(target):
raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed")
mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
return policy, target, display_path, _MANAGED_FILE_MAX_BYTES, mime_type
def _managed_file_size(target: Path, max_bytes: int) -> int:
try:
size = target.stat().st_size
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}")
if size > _MANAGED_FILE_MAX_BYTES:
if size > max_bytes:
raise HTTPException(status_code=413, detail="File is too large")
return size
mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
try:
@router.get("/api/files/read")
async def read_managed_file(request: Request, path: str):
policy, target, display_path, max_bytes, mime_type = _managed_readable_file(request, path)
size = _managed_file_size(target, max_bytes)
with _io_errors("File is not readable", "Could not read file"):
encoded = base64.b64encode(target.read_bytes()).decode("ascii")
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not read file: {exc}")
return {
"name": target.name,
"path": display_path,
@@ -578,27 +422,11 @@ def _managed_file_response(
content_disposition_type: str,
media_only: bool = False,
) -> FileResponse:
"""Build a range-aware response after applying managed-file policy."""
from hermes_cli.web_server import _MANAGED_FILE_MAX_BYTES
policy, target, _display_path = _resolve_managed_path(path, request)
if not target.exists():
raise HTTPException(status_code=404, detail="File not found")
if not target.is_file():
raise HTTPException(status_code=400, detail="Path is not a file")
if _is_sensitive_path(target):
raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed")
"""Range-aware response after applying managed-file policy."""
_policy, target, _display_path, max_bytes, mime_type = _managed_readable_file(request, path)
if media_only and target.suffix.lower() not in _STREAMABLE_MEDIA_EXTENSIONS:
raise HTTPException(status_code=415, detail="Unsupported media type")
try:
size = target.stat().st_size
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}")
if size > _MANAGED_FILE_MAX_BYTES:
raise HTTPException(status_code=413, detail="File is too large")
mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
_managed_file_size(target, max_bytes)
return FileResponse(
path=str(target),
media_type=mime_type,
@@ -612,16 +440,11 @@ def _managed_file_response(
async def download_managed_file(request: Request, path: str):
"""Stream a managed file as an attachment download.
Remote clients (desktop app, browser dashboard) open agent-written files
that live on *this* gateway's disk, not theirs. Auth-gated like every other
managed-files route — ``auth_middleware`` additionally accepts the session
token as a ``?token=`` query param here so a shell/browser-opened download
(which can't set the session header) still authenticates. See ``/api/pty``
for the same query-token precedent. Chromium identifies ``<audio>`` and
``<video>`` subresource requests through ``Sec-Fetch-Dest``; serve those
inline for compatibility with Desktop builds that still use this route as
their player source, while preserving attachment semantics for ordinary
link/document requests.
``auth_middleware`` also accepts the session token as ``?token=`` here so a
shell/browser-opened download (no session header) still authenticates.
Chromium marks ``<audio>``/``<video>`` subresource requests via
``Sec-Fetch-Dest``; those are served inline for Desktop builds that still
use this route as their player source, attachment semantics otherwise.
"""
fetch_destination = request.headers.get("sec-fetch-dest", "").lower()
is_media_subresource = fetch_destination in {"audio", "video"}
@@ -636,39 +459,23 @@ async def download_managed_file(request: Request, path: str):
@router.get("/api/files/stream")
@router.head("/api/files/stream")
async def stream_managed_file(request: Request, path: str):
"""Stream managed audio/video inline with HTTP Range support.
Electron's Chromium media pipeline may reject an attachment response used
as an ``<audio>`` or ``<video>`` source. This route shares the download
endpoint's authentication, size cap, sensitive-file guard, MIME detection,
and Starlette ``FileResponse`` range handling, but explicitly marks the
response inline so metadata loading, playback, and seeking work remotely.
"""
return _managed_file_response(
request,
path,
content_disposition_type="inline",
media_only=True,
)
"""Stream managed audio/video inline with HTTP Range support — Electron's
media pipeline may reject an attachment response as an ``<audio>``/
``<video>`` source. Same auth, size cap, sensitive guard and MIME detection
as download."""
return _managed_file_response(request, path, content_disposition_type="inline", media_only=True)
@router.post("/api/files/upload")
async def upload_managed_file(payload: ManagedFileUpload, request: Request):
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
def _managed_write_target(path: str, request: Request, overwrite: bool):
policy, target, display_path = _resolve_managed_path(path, request, for_write=True)
if target.exists() and target.is_dir():
raise HTTPException(status_code=409, detail="A directory already exists at that path")
if target.exists() and not payload.overwrite:
if target.exists() and not overwrite:
raise HTTPException(status_code=409, detail="File already exists")
return policy, target, display_path
data, _mime_type = _decode_data_url(payload.data_url)
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
except PermissionError:
raise HTTPException(status_code=403, detail="File is not writable")
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
def _managed_write_result(policy, target: Path, display_path: str) -> dict:
return {
"ok": True,
"entry": _managed_file_entry(policy, target),
@@ -677,32 +484,34 @@ async def upload_managed_file(payload: ManagedFileUpload, request: Request):
}
@router.post("/api/files/upload-stream")
async def upload_managed_file_stream(
request: Request,
file: UploadFile = File(...),
path: str = Form(...),
overwrite: bool = Form(True),
):
from hermes_cli.web_server import _MANAGED_FILE_MAX_BYTES, _UPLOAD_CHUNK_BYTES
policy, target, display_path = _resolve_managed_path(path, request, for_write=True)
if target.exists() and target.is_dir():
raise HTTPException(status_code=409, detail="A directory already exists at that path")
if target.exists() and not overwrite:
raise HTTPException(status_code=409, detail="File already exists")
try:
@router.post("/api/files/upload")
async def upload_managed_file(payload: ManagedFileUpload, request: Request):
policy, target, display_path = _managed_write_target(payload.path, request, payload.overwrite)
data, _mime_type = _decode_data_url(payload.data_url)
with _io_errors("File is not writable", "Could not write file"):
target.parent.mkdir(parents=True, exist_ok=True)
except PermissionError:
raise HTTPException(status_code=403, detail="File is not writable")
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not create parent directory: {exc}")
target.write_bytes(data)
return _managed_write_result(policy, target, display_path)
# Write to a sibling temp file first so a partial/aborted upload never
# clobbers an existing file, then atomically rename into place.
tmp_fd, tmp_name = tempfile.mkstemp(
prefix=f".{target.name}.", suffix=".upload", dir=str(target.parent)
)
async def stream_upload_to_path(
file: UploadFile,
target: Path,
*,
too_large: str,
not_writable: str,
write_failed: str,
) -> int:
"""Stream a multipart upload to ``target`` in chunks; returns bytes written.
Writes a sibling temp file first so a partial/aborted upload never clobbers
an existing file, enforces ``_MANAGED_FILE_MAX_BYTES`` as it goes (413
``too_large``), then atomically renames into place. The temp file is
removed on EVERY non-success exit — including asyncio.CancelledError when a
browser aborts a large upload mid-stream.
"""
from hermes_cli.web_server import _MANAGED_FILE_MAX_BYTES, _UPLOAD_CHUNK_BYTES
tmp_fd, tmp_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".upload", dir=str(target.parent))
tmp_path = Path(tmp_name)
total = 0
renamed = False
@@ -714,32 +523,40 @@ async def upload_managed_file_stream(
break
total += len(chunk)
if total > _MANAGED_FILE_MAX_BYTES:
raise HTTPException(status_code=413, detail="File is too large")
raise HTTPException(status_code=413, detail=too_large)
out.write(chunk)
os.replace(tmp_path, target)
renamed = True
except HTTPException:
raise
except PermissionError:
raise HTTPException(status_code=403, detail="File is not writable")
raise HTTPException(status_code=403, detail=not_writable)
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
raise HTTPException(status_code=500, detail=f"{write_failed}: {exc}")
finally:
# Clean up the temp file on every non-success exit, including
# BaseException paths the `except` clauses above don't catch — most
# importantly asyncio.CancelledError when a browser aborts a large
# upload mid-stream (the exact NS-501 scenario). os.replace clears
# tmp_path on success, so only unlink when the rename didn't happen.
if not renamed:
tmp_path.unlink(missing_ok=True)
await file.close()
return total
return {
"ok": True,
"entry": _managed_file_entry(policy, target),
"path": display_path,
**_managed_response_meta(policy),
}
@router.post("/api/files/upload-stream")
async def upload_managed_file_stream(
request: Request,
file: UploadFile = File(...),
path: str = Form(...),
overwrite: bool = Form(True),
):
"""Chunked multipart upload: constant memory and no base64 inflation, unlike
the JSON data-URL endpoint that trips proxy body-size limits on large archives."""
policy, target, display_path = _managed_write_target(path, request, overwrite)
with _io_errors("File is not writable", "Could not create parent directory"):
target.parent.mkdir(parents=True, exist_ok=True)
await stream_upload_to_path(
file, target,
too_large="File is too large",
not_writable="File is not writable",
write_failed="Could not write file",
)
return _managed_write_result(policy, target, display_path)
@router.post("/api/files/mkdir")
@@ -747,20 +564,9 @@ async def create_managed_directory(payload: ManagedDirectoryCreate, request: Req
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
if target.exists() and not target.is_dir():
raise HTTPException(status_code=409, detail="A file already exists at that path")
try:
with _io_errors("Directory is not writable", "Could not create directory"):
target.mkdir(parents=True, exist_ok=True)
except PermissionError:
raise HTTPException(status_code=403, detail="Directory is not writable")
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not create directory: {exc}")
return {
"ok": True,
"entry": _managed_file_entry(policy, target),
"path": display_path,
**_managed_response_meta(policy),
}
return _managed_write_result(policy, target, display_path)
@router.delete("/api/files")
@@ -784,10 +590,16 @@ async def delete_managed_file(payload: ManagedFileDelete, request: Request):
except OSError as exc:
status_code = 409 if target.is_dir() and not payload.recursive else 500
raise HTTPException(status_code=status_code, detail=f"Could not delete path: {exc}")
return {"ok": True, "path": display_path, **_managed_response_meta(policy)}
_FS_LIST_ERRNO = (
(FileNotFoundError, "ENOENT"),
(NotADirectoryError, "ENOTDIR"),
(PermissionError, "EACCES"),
)
@router.get("/api/fs/list")
async def fs_list(path: str):
target = _fs_path(path)
@@ -804,13 +616,10 @@ async def fs_list(path: str):
})
entries.sort(key=lambda item: (not item["isDirectory"], item["name"].lower(), item["name"]))
return {"entries": entries}
except FileNotFoundError:
return {"entries": [], "error": "ENOENT"}
except NotADirectoryError:
return {"entries": [], "error": "ENOTDIR"}
except PermissionError:
return {"entries": [], "error": "EACCES"}
except OSError as exc:
for exc_type, code in _FS_LIST_ERRNO:
if isinstance(exc, exc_type):
return {"entries": [], "error": code}
return {"entries": [], "error": getattr(exc, "strerror", None) or "read-error"}
@@ -819,14 +628,7 @@ async def fs_read_text(path: str):
target, st = _fs_regular_file(_fs_path(path))
if st.st_size > _FS_TEXT_SOURCE_MAX_BYTES:
raise HTTPException(status_code=413, detail="File too large")
bytes_to_read = min(st.st_size, _FS_TEXT_PREVIEW_MAX_BYTES)
try:
with target.open("rb") as handle:
data = handle.read(bytes_to_read)
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "File read failed")
data = _fs_read_bytes(target, min(st.st_size, _FS_TEXT_PREVIEW_MAX_BYTES))
return {
"binary": _fs_looks_binary(data[:4096]),
"byteSize": st.st_size,
@@ -842,13 +644,11 @@ async def fs_read_text(path: str):
async def fs_write_text(payload: FsWriteText):
"""Overwrite (or create) a UTF-8 text file for the in-app spot editor.
Mirrors the local Electron ``hermes:fs:writeText`` hardening: the path is
resolved + validated by ``_fs_path``, the parent directory must already
exist (we never build directory trees), only regular files may be replaced,
and the payload is size-capped. The write is staged to a sibling temp file
and ``os.replace``-d into place so a crash mid-write can't truncate the
original. Stale-on-disk detection is the client's job (re-read before save),
so both transports behave identically.
Mirrors the Electron ``hermes:fs:writeText`` hardening: path validated by
``_fs_path``, the parent must already exist (never build trees), only
regular files may be replaced, payload size-capped, staged to a sibling
temp file and ``os.replace``-d so a crash can't truncate the original.
Stale-on-disk detection is the client's job (re-read before save).
"""
target = _fs_path(payload.path)
text = payload.content or ""
@@ -881,7 +681,6 @@ async def fs_write_text(payload: FsWriteText):
except OSError as exc:
tmp.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
return {"ok": True, "path": str(target), "byteSize": len(text.encode("utf-8"))}
@@ -891,12 +690,7 @@ async def fs_read_data_url(path: str):
target, st = _fs_regular_file(_fs_path(path))
if st.st_size > _FS_DATA_URL_MAX_BYTES:
raise HTTPException(status_code=413, detail="File too large")
try:
encoded = base64.b64encode(target.read_bytes()).decode("ascii")
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "File read failed")
encoded = base64.b64encode(_fs_read_bytes(target)).decode("ascii")
return {"dataUrl": f"data:{_fs_mime_type(target)};base64,{encoded}"}
+110 -221
View File
@@ -1,37 +1,27 @@
"""MCP dashboard routes.
The OAuth flow registry (``_mcp_oauth_flows`` + lock + pending cap) and the
worker/helpers stay in web_server — reached via the late-binding seam so tests
that mutate ``web_server._mcp_oauth_flows`` or monkeypatch its helpers keep
working unchanged.
The OAuth flow registry (``_mcp_oauth_flows``) and the worker/helpers stay in
web_server — reached via the late-binding seam so tests that mutate
``web_server._mcp_oauth_flows`` or monkeypatch its helpers keep working.
"""
import asyncio
import hashlib
import re
import secrets
import threading
import time
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from hermes_cli.web_deps import late, LateState
from hermes_cli.web_models import (
MCPCatalogInstall,
MCPEnabledToggle,
MCPServerCreate,
MCPServersReplace,
)
from hermes_cli.web_deps import LateState, late
from hermes_cli.web_models import MCPCatalogInstall, MCPEnabledToggle, MCPServerCreate, MCPServersReplace
from hermes_cli.web_routers._common import (
_profile_cli_args,
_profile_scope,
_spawn_hermes_action,
config_write_scope,
log as _log,
scoped_to_thread,
_profile_cli_args, _profile_scope, _spawn_hermes_action, config_write_scope, http_failure,
log as _log, scoped_to_thread,
)
import hashlib
import re
import time
router = APIRouter()
@@ -45,37 +35,25 @@ save_config = late("save_config")
save_env_value = late("save_env_value")
_mcp_oauth_flows = LateState("_mcp_oauth_flows")
_MCP_DASHBOARD_OAUTH_TTL = 15 * 60
_mcp_oauth_flows_lock = threading.Lock()
_MCP_DASHBOARD_OAUTH_TTL = 15 * 60
_MAX_PENDING_MCP_OAUTH_FLOWS = 8
def _gc_mcp_oauth_flows() -> None:
cutoff = time.time() - _MCP_DASHBOARD_OAUTH_TTL
with _mcp_oauth_flows_lock:
stale = [
flow_id
for flow_id, flow in _mcp_oauth_flows.items()
if getattr(flow, "created_at", 0) < cutoff
]
stale = [fid for fid, flow in _mcp_oauth_flows.items() if getattr(flow, "created_at", 0) < cutoff]
for flow_id in stale:
_mcp_oauth_flows.pop(flow_id, None)
def _mcp_oauth_callback_url(request: Request, server_name: str) -> str:
"""Build the externally reachable callback URL for a dashboard flow."""
from urllib.parse import urlparse, urlunparse
"""Externally reachable callback URL for a dashboard flow."""
from urllib.parse import quote, urlparse, urlunparse
from hermes_cli.dashboard_auth.prefix import prefix_from_request, resolve_public_url
from urllib.parse import quote
suffix = f"/api/mcp/oauth/callback/{quote(server_name, safe='')}"
public_url = resolve_public_url()
if public_url:
@@ -102,20 +80,12 @@ async def list_mcp_servers(profile: Optional[str] = None):
from hermes_cli.mcp_config import _get_mcp_servers
servers = await scoped_to_thread(profile, _get_mcp_servers)
return {
"servers": [
_mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items())
]
}
return {"servers": [_mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items())]}
@router.post("/api/mcp/servers")
async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None):
from hermes_cli.mcp_config import (
_get_mcp_servers,
_save_bearer_auth_token,
_save_mcp_server,
)
from hermes_cli.mcp_config import _get_mcp_servers, _save_bearer_auth_token, _save_mcp_server
try:
name, server_config, bearer_token = _normalize_mcp_server_create(body)
@@ -124,19 +94,16 @@ async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None):
def _run():
# _save_mcp_server does its own load→mutate→save; the duplicate-name
# check sits under the same lock span so a concurrent add of the same
# name can't slip between check and save.
# check sits under the same lock span so a concurrent add can't slip
# between check and save.
with config_write_scope(body.profile or profile):
if name in _get_mcp_servers():
raise HTTPException(
status_code=409, detail=f"Server '{name}' already exists"
)
raise HTTPException(status_code=409, detail=f"Server '{name}' already exists")
if bearer_token is not None:
server_config["headers"] = _save_bearer_auth_token(name, bearer_token)
if not _save_mcp_server(name, server_config):
raise HTTPException(
status_code=400,
detail=f"Server '{name}' rejected: suspicious command/args configuration",
status_code=400, detail=f"Server '{name}' rejected: suspicious command/args configuration",
)
try:
@@ -146,18 +113,14 @@ async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None):
except Exception as exc:
_log.exception("POST /api/mcp/servers failed")
raise HTTPException(status_code=400, detail=str(exc)) from exc
return _mcp_server_summary(name, server_config)
@router.put("/api/mcp/servers")
async def replace_mcp_servers(body: MCPServersReplace, profile: Optional[str] = None):
"""Replace the entire ``mcp_servers`` map (the GUI mcp.json editor's save).
The generic ``/api/config`` endpoint deep-merges maps and so can never
delete a key or drop an ``enabled: false``; this sets the whole map so
removals actually persist.
"""
"""Replace the entire ``mcp_servers`` map (the mcp.json editor's save) —
the deep-merging ``/api/config`` can never delete a key or drop an
``enabled: false``, so removals wouldn't persist through it."""
from hermes_cli.mcp_config import _replace_mcp_servers
def _run():
@@ -178,20 +141,15 @@ async def remove_mcp_server(name: str, profile: Optional[str] = None):
with config_write_scope(profile):
return _remove_mcp_server(name)
removed = await asyncio.to_thread(_run)
if not removed:
if not await asyncio.to_thread(_run):
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
return {"ok": True}
@router.post("/api/mcp/servers/{name}/test")
async def test_mcp_server(name: str, profile: Optional[str] = None):
"""Connect to the server, list its tools, disconnect. Returns tool list."""
from hermes_cli.mcp_config import (
_get_mcp_servers,
_oauth_tokens_present,
_probe_single_server,
)
"""Connect to the server, list its tools, disconnect."""
from hermes_cli.mcp_config import _get_mcp_servers, _oauth_tokens_present, _probe_single_server
servers = await scoped_to_thread(profile, _get_mcp_servers)
if name not in servers:
@@ -204,32 +162,21 @@ async def test_mcp_server(name: str, profile: Optional[str] = None):
def _probe_scoped():
# Home-only scope (contextvar), NOT _profile_scope: a probe can block for
# many seconds (stdio `npx` cold start) and _profile_scope holds the
# process-global skills lock for its whole body, which serialized every
# other endpoint behind a slow server. The probe only needs the
# HERMES_HOME override for .env interpolation + OAuth token resolution.
# seconds (stdio `npx` cold start) and _profile_scope holds the
# process-global skills lock for its whole body, serializing every other
# endpoint. The probe only needs HERMES_HOME for .env + token resolution.
with _config_profile_scope(profile):
tools = _probe_single_server(name, servers[name], details=details)
token_present = _oauth_tokens_present(name) if needs_oauth_token else True
return tools, token_present
return tools, (_oauth_tokens_present(name) if needs_oauth_token else True)
try:
# Probe blocks on a dedicated MCP event loop — keep it off the FastAPI loop.
try: # probe blocks on a dedicated MCP event loop — keep it off the FastAPI loop
tools, token_present = await asyncio.to_thread(_probe_scoped)
except Exception as exc:
return {
"ok": False,
"error": str(exc),
"tools": [],
}
return {"ok": False, "error": str(exc), "tools": []}
if not token_present:
return {
"ok": False,
"error": "OAuth authentication required — no token found.",
"tools": [],
}
# Optional per-tool schema size (chars) — the desktop's cost overlay
# estimates tokens from it; failed probes simply omit it.
return {"ok": False, "error": "OAuth authentication required — no token found.", "tools": []}
# Optional per-tool schema size (chars) for the desktop's cost overlay;
# failed probes simply omit it.
schema_chars = details.get("schema_chars") or {}
return {
"ok": True,
@@ -237,11 +184,7 @@ async def test_mcp_server(name: str, profile: Optional[str] = None):
{
"name": t,
"description": d,
**(
{"schema_chars": schema_chars[t]}
if isinstance(schema_chars.get(t), int)
else {}
),
**({"schema_chars": schema_chars[t]} if isinstance(schema_chars.get(t), int) else {}),
}
for t, d in tools
],
@@ -254,17 +197,20 @@ async def test_mcp_server(name: str, profile: Optional[str] = None):
async def auth_mcp_server(name: str, request: Request, profile: Optional[str] = None):
"""Start MCP OAuth and hand the authorization URL to the dashboard browser."""
from hermes_cli.mcp_config import _get_mcp_servers
from hermes_constants import get_hermes_home
from tools.mcp_dashboard_oauth import DashboardOAuthFlow
_require_token(request)
_gc_mcp_oauth_flows()
from hermes_constants import get_hermes_home
process_home = str(get_hermes_home().expanduser().resolve(strict=False))
def _home() -> str:
return str(get_hermes_home().expanduser().resolve(strict=False))
process_home = _home()
def _read():
with _profile_scope(profile):
return _get_mcp_servers(), str(get_hermes_home().expanduser().resolve(strict=False))
return _get_mcp_servers(), _home()
servers, flow_home = await asyncio.to_thread(_read)
if name not in servers:
@@ -282,37 +228,17 @@ async def auth_mcp_server(name: str, request: Request, profile: Optional[str] =
server_name=name,
profile=profile,
hermes_home=flow_home,
redirect_uri=(cfg.get("oauth") or {}).get("redirect_uri")
or _mcp_oauth_callback_url(request, name),
redirect_uri=(cfg.get("oauth") or {}).get("redirect_uri") or _mcp_oauth_callback_url(request, name),
reconnect_live=flow_home == process_home,
)
with _mcp_oauth_flows_lock:
pending = sum(
not flow.worker_done
for flow in _mcp_oauth_flows.values()
)
if pending >= _MAX_PENDING_MCP_OAUTH_FLOWS:
raise HTTPException(
status_code=429,
detail="Too many MCP OAuth flows are already in progress",
)
if any(
flow.server_name == name
and flow.hermes_home == flow_home
and not flow.worker_done
for flow in _mcp_oauth_flows.values()
):
raise HTTPException(
status_code=409,
detail=f"MCP OAuth for '{name}' is already in progress",
)
live = [f for f in _mcp_oauth_flows.values() if not f.worker_done]
if len(live) >= _MAX_PENDING_MCP_OAUTH_FLOWS:
raise HTTPException(status_code=429, detail="Too many MCP OAuth flows are already in progress")
if any(f.server_name == name and f.hermes_home == flow_home for f in live):
raise HTTPException(status_code=409, detail=f"MCP OAuth for '{name}' is already in progress")
_mcp_oauth_flows[flow_id] = flow
threading.Thread(
target=_run_dashboard_mcp_oauth,
args=(flow, cfg),
daemon=True,
name=f"mcp-oauth-{name}",
).start()
threading.Thread(target=_run_dashboard_mcp_oauth, args=(flow, cfg), daemon=True, name=f"mcp-oauth-{name}").start()
try:
await flow.wait_for_authorization_url(timeout=30)
except Exception as exc:
@@ -334,14 +260,13 @@ async def mcp_oauth_flow_status(flow_id: str, request: Request):
@router.delete("/api/mcp/oauth/flows/{flow_id}")
async def cancel_mcp_oauth_flow(flow_id: str, request: Request):
"""Cancel an in-flight MCP OAuth flow. mark_error unblocks the worker so
it frees the per-server "already in progress" slot — otherwise a renderer
that stops polling leaves the flow squatting until the 300s callback
timeout and every retry 409s. Idempotent: a settled flow is left as-is."""
"""Cancel an in-flight flow. mark_error unblocks the worker so it frees the
per-server "already in progress" slot — otherwise a renderer that stops
polling leaves the flow squatting until the 300s callback timeout and every
retry 409s. Idempotent: a settled flow is left as-is."""
_require_token(request)
flow = _mcp_oauth_flows.get(flow_id)
if flow is None:
# Expired/GC'd is the goal state of a cancel — not an error.
if flow is None: # expired/GC'd is the goal state of a cancel — not an error
return {"ok": True, "status": "expired"}
flow.mark_error("Cancelled by user")
return {"ok": True, "status": flow.snapshot()["status"]}
@@ -357,19 +282,12 @@ async def mcp_oauth_callback(
_gc_mcp_oauth_flows()
with _mcp_oauth_flows_lock:
candidates = [
flow
for flow in _mcp_oauth_flows.values()
if flow.server_name == server_name
and flow.status == "authorization_required"
flow for flow in _mcp_oauth_flows.values()
if flow.server_name == server_name and flow.status == "authorization_required"
]
flow = next(
(
candidate
for candidate in candidates
if candidate.expected_state is not None
and state is not None
and secrets.compare_digest(candidate.expected_state, state)
),
(c for c in candidates
if c.expected_state is not None and state is not None and secrets.compare_digest(c.expected_state, state)),
None,
)
if flow is None:
@@ -377,12 +295,9 @@ async def mcp_oauth_callback(
try:
flow.deliver_callback(code=code, state=state, error=error)
except ValueError as exc:
reason = str(exc)
status_code = 409 if "already received" in reason else 400
return HTMLResponse(
"<h1>OAuth callback rejected</h1>"
"<p>The callback was invalid or already used.</p>",
status_code=status_code,
"<h1>OAuth callback rejected</h1><p>The callback was invalid or already used.</p>",
status_code=409 if "already received" in str(exc) else 400,
)
if error:
return HTMLResponse("<h1>Authorization failed</h1><p>Return to Hermes for details.</p>", status_code=400)
@@ -390,11 +305,9 @@ async def mcp_oauth_callback(
@router.put("/api/mcp/servers/{name}/enabled")
async def set_mcp_server_enabled(
name: str, body: MCPEnabledToggle, profile: Optional[str] = None
):
"""Toggle the server's ``enabled`` flag (takes effect on next session/gateway);
disabled servers stay in config so they can be re-enabled without re-entry."""
async def set_mcp_server_enabled(name: str, body: MCPEnabledToggle, profile: Optional[str] = None):
"""Toggle ``enabled`` (takes effect on next session/gateway); disabled
servers stay in config so they can be re-enabled without re-entry."""
def _run():
with config_write_scope(body.profile or profile):
cfg = load_config()
@@ -410,82 +323,70 @@ async def set_mcp_server_enabled(
return await asyncio.to_thread(_run)
def _catalog_entry_json(entry: Any, installed: bool, enabled: bool) -> Dict[str, Any]:
auth = entry.auth
transport = entry.transport
install = entry.install
return {
"name": entry.name,
"description": entry.description,
"source": entry.source,
"transport": transport.type,
"auth_type": getattr(auth, "type", "none"),
# Env vars the user must supply (names + prompts only, never values).
"required_env": [
{"name": e.name, "prompt": e.prompt, "required": e.required}
for e in getattr(auth, "env", []) or []
],
# Transport details surfaced on purpose: the trust model asks users to
# inspect command/args/url + bootstrap before installing.
"command": transport.command,
"args": list(transport.args or []),
"url": transport.url,
# Git bootstrap (present only for entries that clone + build).
"install_url": install.url if install else None,
"install_ref": install.ref if install else None,
"bootstrap": list(install.bootstrap) if install else [],
"default_enabled": list(entry.tools.default_enabled) if entry.tools.default_enabled is not None else None,
"post_install": entry.post_install or "",
# Composer-suggestion triggers (desktop brand pills), only when the
# manifest declares a `suggest` block.
"suggest": {"keywords": list(entry.suggest.keywords), "hosts": list(entry.suggest.hosts)} if entry.suggest else None,
"needs_install": install is not None,
"installed": installed,
"enabled": enabled,
}
@router.get("/api/mcp/catalog")
async def list_mcp_catalog(profile: Optional[str] = None):
"""Browse the Nous-approved MCP catalog (optional-mcps/ manifests), each
entry annotated with installed/enabled state for ``profile``."""
try:
with http_failure("mcp_catalog import failed", 500, "Catalog unavailable"):
from hermes_cli import mcp_catalog
except Exception as exc:
_log.exception("mcp_catalog import failed")
raise HTTPException(status_code=500, detail=f"Catalog unavailable: {exc}")
entries = []
try:
def _read():
with _profile_scope(profile):
catalog = list(mcp_catalog.list_catalog())
state = {
e.name: (mcp_catalog.is_installed(e.name), mcp_catalog.is_enabled(e.name))
for e in catalog
}
state = {e.name: (mcp_catalog.is_installed(e.name), mcp_catalog.is_enabled(e.name)) for e in catalog}
return catalog, state
catalog_entries, installed_state = await asyncio.to_thread(_read)
for entry in catalog_entries:
auth = entry.auth
transport = entry.transport
install = entry.install
entries.append({
"name": entry.name,
"description": entry.description,
"source": entry.source,
"transport": transport.type,
"auth_type": getattr(auth, "type", "none"),
# Env vars the user must supply (names + prompts only, never values).
"required_env": [
{"name": e.name, "prompt": e.prompt, "required": e.required}
for e in getattr(auth, "env", []) or []
],
# Transport details surfaced on purpose: the trust model asks
# users to inspect command/args/url + bootstrap before installing.
"command": transport.command,
"args": list(transport.args or []),
"url": transport.url,
# Git bootstrap (present only for entries that clone + build).
"install_url": install.url if install else None,
"install_ref": install.ref if install else None,
"bootstrap": list(install.bootstrap) if install else [],
# Default tool pre-selection hint and post-install guidance.
"default_enabled": list(entry.tools.default_enabled)
if entry.tools.default_enabled is not None
else None,
"post_install": entry.post_install or "",
# Composer-suggestion triggers (desktop brand pills). Present
# only for entries whose manifest declares a `suggest` block.
"suggest": {
"keywords": list(entry.suggest.keywords),
"hosts": list(entry.suggest.hosts),
} if entry.suggest else None,
"needs_install": entry.install is not None,
"installed": installed_state.get(entry.name, (False, False))[0],
"enabled": installed_state.get(entry.name, (False, False))[1],
})
except HTTPException:
# Unknown/invalid profile → 404, not a silently-empty catalog.
installed, enabled = installed_state.get(entry.name, (False, False))
entries.append(_catalog_entry_json(entry, installed, enabled))
except HTTPException: # unknown/invalid profile → 404, not a silently-empty catalog
raise
except Exception:
_log.exception("list_mcp_catalog failed")
diagnostics = []
try:
diagnostics = [
{"name": n, "kind": k, "message": m}
for (n, k, m) in mcp_catalog.catalog_diagnostics()
]
diagnostics = [{"name": n, "kind": k, "message": m} for (n, k, m) in mcp_catalog.catalog_diagnostics()]
except Exception:
pass
return {"entries": entries, "diagnostics": diagnostics}
@@ -494,6 +395,7 @@ async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[s
"""Install a catalog MCP into config.yaml (declared env vars go to .env
first; git-bootstrap entries run via the background CLI action path)."""
from hermes_cli import mcp_catalog
from hermes_cli.config import validate_env_var_name_for_write
name = (body.name or "").strip()
entry = mcp_catalog.get_entry(name)
@@ -507,23 +409,16 @@ async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[s
if undeclared_env:
raise HTTPException(
status_code=400,
detail=(
f"Catalog entry '{name}' does not declare environment "
f"variable(s): {', '.join(undeclared_env)}"
),
detail=f"Catalog entry '{name}' does not declare environment variable(s): {', '.join(undeclared_env)}",
)
# Validate the complete map before the first write so a mixed
# valid+invalid request cannot partially persist credentials.
from hermes_cli.config import validate_env_var_name_for_write
try:
for key in body.env:
validate_env_var_name_for_write(key)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# Persist any supplied, declared env vars first.
effective_profile = body.profile or profile
if body.env:
def _write_env():
@@ -534,16 +429,12 @@ async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[s
await asyncio.to_thread(_write_env)
# Git-bootstrap entries can take a while to clone — background action path
# so the request returns immediately. Per-entry action name: a shared
# "mcp-install" would let a re-click overwrite the tracked process/log.
# Git-bootstrap entries can take a while to clone — background action so
# the request returns immediately (per-entry action name, see helper).
if entry.install is not None:
action = _mcp_install_action_name(name)
try:
_spawn_hermes_action(
_profile_cli_args(effective_profile) + ["mcp", "install", name],
action,
)
_spawn_hermes_action(_profile_cli_args(effective_profile) + ["mcp", "install", name], action)
except HTTPException:
raise
except Exception as exc:
@@ -553,9 +444,7 @@ async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[s
# No git step — install synchronously; install_entry goes through the
# call-time config/env resolvers so the profile scope covers it.
try:
await scoped_to_thread(
effective_profile, lambda: mcp_catalog.install_entry(entry, enable=body.enable)
)
await scoped_to_thread(effective_profile, lambda: mcp_catalog.install_entry(entry, enable=body.enable))
except HTTPException:
raise
except Exception as exc:
File diff suppressed because it is too large Load Diff