Merge branch 'simp/r3-25-E' into simp/integration3
This commit is contained in:
+224
-593
File diff suppressed because it is too large
Load Diff
+87
-154
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
+198
-436
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user