"""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.`` 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, }