Files
hermes-agent/hermes_cli/web_server_files.py

223 lines
8.5 KiB
Python

"""Managed-files policy for the dashboard file browser: root resolution, path canonicalisation/containment, entry metadata.
Split out of ``hermes_cli.web_server``; every externally used name is re-imported
there, so ``web_server.<name>`` keeps resolving (and monkeypatching) as before.
Helpers that tests patch on ``web_server`` are reached lazily through it.
"""
import mimetypes
import os
import urllib.request
from dataclasses import dataclass
from fastapi import HTTPException, Request
from pathlib import Path
from typing import Any, Dict
_MANAGED_FILES_ROOT_ENV = "HERMES_DASHBOARD_FILES_ROOT"
_HOSTED_MANAGED_FILES_ROOT = Path("/opt/data")
@dataclass(frozen=True)
class ManagedFilesPolicy:
default_path: Path
locked_root: Path | None
can_change_path: bool
def _fs_path(raw_path: str) -> Path:
raw = str(raw_path or "").strip()
if not raw:
raise HTTPException(status_code=400, detail="Path is required")
if "\0" in raw:
raise HTTPException(status_code=400, detail="Invalid path")
try:
if raw.lower().startswith("file:"):
parsed = urllib.parse.urlparse(raw)
if parsed.netloc and parsed.netloc not in {"", "localhost"}:
raise ValueError
raw = urllib.request.url2pathname(parsed.path)
candidate = Path(raw).expanduser()
if not candidate.is_absolute():
candidate = Path.cwd() / candidate
return candidate.resolve(strict=False)
except (OSError, RuntimeError, ValueError):
raise HTTPException(status_code=400, detail="Invalid path")
def _canonical_path(path: Path, *, require_exists: bool = False) -> Path:
try:
return path.expanduser().resolve(strict=require_exists)
except FileNotFoundError:
if require_exists:
raise HTTPException(status_code=404, detail="Path not found")
raise
except (OSError, RuntimeError):
raise HTTPException(status_code=400, detail="Invalid path")
def _ensure_managed_root(raw_path: str | Path) -> Path:
root = Path(raw_path).expanduser()
try:
root.mkdir(parents=True, exist_ok=True)
resolved = root.resolve()
except (OSError, RuntimeError) as exc:
raise HTTPException(status_code=500, detail=f"Managed files root is unavailable: {exc}")
if not resolved.is_dir():
raise HTTPException(status_code=500, detail="Managed files root is not a directory")
return resolved
def _path_is_under(root: Path, target: Path) -> bool:
return target == root or root in target.parents
def _path_text(raw_path: str | None) -> str:
text = str(raw_path or "").strip()
if "\x00" in text:
raise HTTPException(status_code=400, detail="Invalid path")
return text
def _default_hermes_root_is_opt_data() -> bool:
raw = os.environ.get("HERMES_HOME", "").strip()
if not raw:
return False
try:
from hermes_constants import get_default_hermes_root
root = get_default_hermes_root().expanduser().resolve(strict=False)
except (OSError, RuntimeError):
root = Path(raw).expanduser().resolve(strict=False)
return root == _HOSTED_MANAGED_FILES_ROOT
def _dashboard_local_update_managed_externally() -> bool:
"""Return true when the dashboard should not offer ``hermes update``.
Containerized dashboards are updated by the outer launcher/image, not by an
in-browser local update action. Keep this dashboard capability separate
from install-method detection: manual git/pip installs inside containers can
still behave like their actual install method in the CLI.
However, when the install method is ``git`` (a bind-mounted checkout inside
a container — e.g. the hermes-webui image sharing the Hermes source tree),
the dashboard's ``hermes update`` button is the correct update path and
should not be suppressed. Other containerized install methods remain
externally managed unless their apply path is proven safe inside the
running container filesystem.
"""
from hermes_cli.web_server import PROJECT_ROOT, detect_install_method
if _default_hermes_root_is_opt_data():
return True
try:
from hermes_constants import is_container
if not is_container():
return False
except Exception:
return False
# We are inside a container, but the install may still be self-managed.
# If the install method is git, the dashboard update button works against
# the mounted checkout and should be offered. Keep pip blocked inside
# containers: its apply path mutates the running container filesystem and
# is not the bind-mounted checkout case this gate is meant to recover.
try:
method = detect_install_method(PROJECT_ROOT)
if method == "git":
return False
except Exception:
pass
return True
def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy:
raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip()
if raw_forced_root:
root = _ensure_managed_root(raw_forced_root) if create_root else _canonical_path(Path(raw_forced_root))
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
# Remote/OAuth access does not imply a hosted container. Users can expose a
# local dashboard through the auth gate (for example a macOS launchd install)
# and still expect the Files page to browse their local home directory. Lock
# to /opt/data only when the installation's Hermes root is actually /opt/data
# (the container/hosted layout) or when HERMES_DASHBOARD_FILES_ROOT is set.
if _default_hermes_root_is_opt_data():
root = _ensure_managed_root(_HOSTED_MANAGED_FILES_ROOT) if create_root else _HOSTED_MANAGED_FILES_ROOT
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
home = _canonical_path(Path.home())
return ManagedFilesPolicy(default_path=home, locked_root=None, can_change_path=True)
def _resolve_managed_path(
raw_path: str | None,
request: Request,
*,
for_write: bool = False,
) -> tuple[ManagedFilesPolicy, Path, str]:
policy = _managed_files_policy(request)
text = _path_text(raw_path)
root = policy.locked_root
if root is not None and (not text or text in {".", "/"}):
candidate = root
elif not text:
candidate = policy.default_path
else:
candidate = Path(text).expanduser()
if root is not None and not candidate.is_absolute():
if any(part == ".." for part in candidate.parts):
raise HTTPException(status_code=400, detail="Path cannot contain '..'")
candidate = root / candidate
elif not candidate.is_absolute():
raise HTTPException(status_code=400, detail="Path must be absolute")
if ".." in candidate.parts:
raise HTTPException(status_code=400, detail="Path cannot contain '..'")
if for_write and not candidate.exists():
parent = _canonical_path(candidate.parent)
resolved = parent / candidate.name
else:
resolved = _canonical_path(candidate, require_exists=not for_write)
if root is not None and not _path_is_under(root, resolved):
raise HTTPException(status_code=403, detail="Path outside managed files root")
return policy, resolved, str(resolved)
def _managed_response_meta(policy: ManagedFilesPolicy) -> Dict[str, Any]:
locked_root = str(policy.locked_root) if policy.locked_root is not None else None
return {
"root": locked_root,
"locked_root": locked_root,
"can_change_path": policy.can_change_path,
}
def _managed_file_entry(policy: ManagedFilesPolicy, target: Path) -> Dict[str, Any]:
try:
resolved = target.resolve()
except (OSError, RuntimeError):
raise HTTPException(status_code=400, detail="Invalid path")
if policy.locked_root is not None and not _path_is_under(policy.locked_root, resolved):
raise HTTPException(status_code=403, detail="Path outside managed files root")
try:
st = resolved.stat()
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not stat path: {exc}")
is_dir = resolved.is_dir()
mime_type = None if is_dir else (mimetypes.guess_type(resolved.name)[0] or "application/octet-stream")
return {
"name": target.name or resolved.name or str(resolved),
"path": str(resolved),
"is_directory": is_dir,
"size": None if is_dir else st.st_size,
"mtime": st.st_mtime,
"mime_type": mime_type,
}