931 lines
34 KiB
Python
931 lines
34 KiB
Python
"""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).
|
|
"""
|
|
|
|
import base64
|
|
import binascii
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import stat
|
|
import tempfile
|
|
import asyncio
|
|
import secrets
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
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
|
|
|
|
router = APIRouter()
|
|
|
|
# web_server helpers, late-bound so monkeypatch.setattr(web_server, ...) stays authoritative.
|
|
_fs_path = late("_fs_path")
|
|
_managed_file_entry = late("_managed_file_entry")
|
|
_managed_response_meta = late("_managed_response_meta")
|
|
_profile_scope = late("_profile_scope")
|
|
_resolve_managed_path = late("_resolve_managed_path")
|
|
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.
|
|
_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",
|
|
}
|
|
|
|
|
|
_MEDIA_MAX_BYTES = 25 * 1024 * 1024
|
|
|
|
|
|
_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",
|
|
}
|
|
|
|
|
|
# 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.
|
|
_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",
|
|
})
|
|
|
|
|
|
# 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",
|
|
})
|
|
|
|
|
|
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.
|
|
"""
|
|
lowered = name.lower()
|
|
if lowered == ".env" or lowered.startswith(".env.") or lowered == ".envrc":
|
|
return True
|
|
return lowered in _SENSITIVE_MANAGED_FILE_BASENAMES
|
|
|
|
|
|
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.
|
|
"""
|
|
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.
|
|
_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",
|
|
}
|
|
|
|
|
|
_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",
|
|
}
|
|
|
|
|
|
def _fs_mime_type(path: Path) -> str:
|
|
suffix = path.suffix.lower()
|
|
if suffix in _FS_MIME_TYPES:
|
|
return _FS_MIME_TYPES[suffix]
|
|
guessed, _ = mimetypes.guess_type(str(path))
|
|
return guessed or "application/octet-stream"
|
|
|
|
|
|
def _fs_looks_binary(data: bytes) -> bool:
|
|
if not data:
|
|
return False
|
|
if b"\0" in data:
|
|
return True
|
|
suspicious = sum(1 for byte in data if byte < 32 and byte not in {9, 10, 13})
|
|
return suspicious / len(data) > 0.12
|
|
|
|
|
|
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:
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
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 "Invalid path")
|
|
if stat.S_ISDIR(st.st_mode):
|
|
raise HTTPException(status_code=400, detail="Path points to a directory")
|
|
if not stat.S_ISREG(st.st_mode):
|
|
raise HTTPException(status_code=400, detail="Only regular files can be read")
|
|
return target, st
|
|
|
|
|
|
def _fs_find_git_root(start: Path) -> str | None:
|
|
directory = start
|
|
for _ in range(50):
|
|
try:
|
|
if (directory / ".git").exists():
|
|
return str(directory)
|
|
except OSError:
|
|
return None
|
|
parent = directory.parent
|
|
if parent == directory:
|
|
return None
|
|
directory = parent
|
|
return None
|
|
|
|
|
|
def _fs_default_cwd() -> str:
|
|
cfg_terminal = load_config().get("terminal") or {}
|
|
raw = str(cfg_terminal.get("cwd") or os.environ.get("TERMINAL_CWD") or "").strip()
|
|
if raw and raw not in {".", "auto", "cwd"}:
|
|
try:
|
|
candidate = Path(raw).expanduser().resolve(strict=False)
|
|
if candidate.is_dir():
|
|
return str(candidate)
|
|
except (OSError, RuntimeError):
|
|
pass
|
|
return str(Path.cwd())
|
|
|
|
|
|
def _fs_git_branch(cwd: str) -> str:
|
|
try:
|
|
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,
|
|
)
|
|
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.
|
|
"""
|
|
home = get_hermes_home()
|
|
roots = [home / "images", home / "screenshots", home / "cache"]
|
|
out: list[Path] = []
|
|
for root in roots:
|
|
try:
|
|
out.append(root.resolve())
|
|
except (OSError, RuntimeError):
|
|
continue
|
|
return out
|
|
|
|
|
|
@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.
|
|
"""
|
|
try:
|
|
target = Path(path).expanduser().resolve()
|
|
except (OSError, RuntimeError):
|
|
raise HTTPException(status_code=400, detail="Invalid path")
|
|
|
|
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:
|
|
raise HTTPException(status_code=413, detail="File too large")
|
|
|
|
encoded = base64.b64encode(target.read_bytes()).decode("ascii")
|
|
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()
|
|
if not text.startswith("data:") or "," not in text:
|
|
raise HTTPException(status_code=400, detail="Upload payload must be a data URL")
|
|
header, encoded = text.split(",", 1)
|
|
mime_type = header[5:].split(";", 1)[0] or "application/octet-stream"
|
|
if ";base64" not in header:
|
|
raise HTTPException(status_code=400, detail="Upload payload must be base64 encoded")
|
|
try:
|
|
data = base64.b64decode(encoded, validate=True)
|
|
except (binascii.Error, ValueError):
|
|
raise HTTPException(status_code=400, detail="Upload payload is not valid base64")
|
|
if len(data) > _MANAGED_FILE_MAX_BYTES:
|
|
raise HTTPException(status_code=413, detail="File is too large")
|
|
return data, mime_type
|
|
|
|
|
|
_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"),
|
|
)
|
|
|
|
|
|
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"
|
|
|
|
|
|
def _chat_image_extension(data: bytes) -> str | None:
|
|
head = data[:16]
|
|
if head.startswith(b"RIFF") and head[8:12] == b"WEBP":
|
|
return ".webp"
|
|
for sig, ext in _CHAT_IMAGE_MAGIC:
|
|
if head.startswith(sig):
|
|
return ext
|
|
return None
|
|
|
|
|
|
def _decode_chat_image_upload(payload: ChatImageUpload) -> tuple[bytes, str, str]:
|
|
data, mime_type = _decode_data_url(payload.data_url)
|
|
if not mime_type.lower().startswith("image/"):
|
|
raise HTTPException(status_code=400, detail="Upload payload must be an image")
|
|
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")
|
|
return data, mime_type, ext
|
|
|
|
|
|
@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.
|
|
|
|
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.
|
|
"""
|
|
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.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:
|
|
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,
|
|
"path": str(target),
|
|
"name": target.name,
|
|
"bytes": len(data),
|
|
"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).
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
@router.get("/api/files")
|
|
async def list_managed_files(request: Request, path: Optional[str] = None):
|
|
policy, target, display_path = _resolve_managed_path(path, request)
|
|
if not target.exists():
|
|
raise HTTPException(status_code=404, detail="Path not found")
|
|
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}")
|
|
|
|
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),
|
|
}
|
|
|
|
|
|
@router.get("/api/files/read")
|
|
async def read_managed_file(request: Request, path: str):
|
|
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")
|
|
|
|
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"
|
|
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=500, detail=f"Could not read file: {exc}")
|
|
|
|
return {
|
|
"name": target.name,
|
|
"path": display_path,
|
|
"size": size,
|
|
"mime_type": mime_type,
|
|
"data_url": f"data:{mime_type};base64,{encoded}",
|
|
**_managed_response_meta(policy),
|
|
}
|
|
|
|
|
|
def _managed_file_response(
|
|
request: Request,
|
|
path: str,
|
|
*,
|
|
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")
|
|
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"
|
|
|
|
return FileResponse(
|
|
path=str(target),
|
|
media_type=mime_type,
|
|
filename=target.name,
|
|
content_disposition_type=content_disposition_type,
|
|
headers={"X-Content-Type-Options": "nosniff"} if media_only else None,
|
|
)
|
|
|
|
|
|
@router.get("/api/files/download")
|
|
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.
|
|
"""
|
|
fetch_destination = request.headers.get("sec-fetch-dest", "").lower()
|
|
is_media_subresource = fetch_destination in {"audio", "video"}
|
|
return _managed_file_response(
|
|
request,
|
|
path,
|
|
content_disposition_type="inline" if is_media_subresource else "attachment",
|
|
media_only=is_media_subresource,
|
|
)
|
|
|
|
|
|
@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,
|
|
)
|
|
|
|
|
|
@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)
|
|
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:
|
|
raise HTTPException(status_code=409, detail="File already exists")
|
|
|
|
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}")
|
|
|
|
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),
|
|
):
|
|
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:
|
|
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}")
|
|
|
|
# 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)
|
|
)
|
|
tmp_path = Path(tmp_name)
|
|
total = 0
|
|
renamed = False
|
|
try:
|
|
with os.fdopen(tmp_fd, "wb") as out:
|
|
while True:
|
|
chunk = await file.read(_UPLOAD_CHUNK_BYTES)
|
|
if not chunk:
|
|
break
|
|
total += len(chunk)
|
|
if total > _MANAGED_FILE_MAX_BYTES:
|
|
raise HTTPException(status_code=413, detail="File is 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")
|
|
except OSError as exc:
|
|
raise HTTPException(status_code=500, detail=f"Could not write file: {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 {
|
|
"ok": True,
|
|
"entry": _managed_file_entry(policy, target),
|
|
"path": display_path,
|
|
**_managed_response_meta(policy),
|
|
}
|
|
|
|
|
|
@router.post("/api/files/mkdir")
|
|
async def create_managed_directory(payload: ManagedDirectoryCreate, request: Request):
|
|
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:
|
|
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),
|
|
}
|
|
|
|
|
|
@router.delete("/api/files")
|
|
async def delete_managed_file(payload: ManagedFileDelete, request: Request):
|
|
policy, target, display_path = _resolve_managed_path(payload.path, request)
|
|
if policy.locked_root is not None and target == policy.locked_root:
|
|
raise HTTPException(status_code=400, detail="Cannot delete the managed files root")
|
|
if target.parent == target:
|
|
raise HTTPException(status_code=400, detail="Cannot delete the filesystem root")
|
|
if not target.exists():
|
|
raise HTTPException(status_code=404, detail="Path not found")
|
|
|
|
try:
|
|
if target.is_dir():
|
|
if payload.recursive:
|
|
shutil.rmtree(target)
|
|
else:
|
|
target.rmdir()
|
|
else:
|
|
target.unlink()
|
|
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)}
|
|
|
|
|
|
@router.get("/api/fs/list")
|
|
async def fs_list(path: str):
|
|
target = _fs_path(path)
|
|
try:
|
|
entries = []
|
|
with os.scandir(target) as scan:
|
|
for entry in scan:
|
|
if entry.name in _FS_READDIR_HIDDEN:
|
|
continue
|
|
entries.append({
|
|
"name": entry.name,
|
|
"path": str(target / entry.name),
|
|
"isDirectory": entry.is_dir(follow_symlinks=False),
|
|
})
|
|
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:
|
|
return {"entries": [], "error": getattr(exc, "strerror", None) or "read-error"}
|
|
|
|
|
|
@router.get("/api/fs/read-text")
|
|
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")
|
|
return {
|
|
"binary": _fs_looks_binary(data[:4096]),
|
|
"byteSize": st.st_size,
|
|
"language": _FS_PREVIEW_LANGUAGE_BY_EXT.get(target.suffix.lower(), "text"),
|
|
"mimeType": _fs_mime_type(target),
|
|
"path": str(target),
|
|
"text": data.decode("utf-8", errors="replace"),
|
|
"truncated": st.st_size > _FS_TEXT_PREVIEW_MAX_BYTES,
|
|
}
|
|
|
|
|
|
@router.post("/api/fs/write-text")
|
|
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.
|
|
"""
|
|
target = _fs_path(payload.path)
|
|
text = payload.content or ""
|
|
if len(text.encode("utf-8")) > _FS_TEXT_WRITE_MAX_BYTES:
|
|
raise HTTPException(status_code=413, detail="Content too large")
|
|
|
|
try:
|
|
st: Optional[os.stat_result] = target.stat()
|
|
except FileNotFoundError:
|
|
st = None
|
|
except PermissionError:
|
|
raise HTTPException(status_code=403, detail="File is not writable")
|
|
except OSError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc) or "Invalid path")
|
|
|
|
if st is not None and stat.S_ISDIR(st.st_mode):
|
|
raise HTTPException(status_code=400, detail="Path points to a directory")
|
|
if st is not None and not stat.S_ISREG(st.st_mode):
|
|
raise HTTPException(status_code=400, detail="Only regular files can be written")
|
|
if not target.parent.is_dir():
|
|
raise HTTPException(status_code=400, detail="Parent directory does not exist")
|
|
|
|
tmp = target.with_name(f".{target.name}.hermes-tmp-{os.getpid()}")
|
|
try:
|
|
tmp.write_text(text, encoding="utf-8")
|
|
os.replace(tmp, target)
|
|
except PermissionError:
|
|
tmp.unlink(missing_ok=True)
|
|
raise HTTPException(status_code=403, detail="File is not writable")
|
|
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"))}
|
|
|
|
|
|
@router.get("/api/fs/read-data-url")
|
|
async def fs_read_data_url(path: str):
|
|
from hermes_cli.web_server import _FS_DATA_URL_MAX_BYTES
|
|
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")
|
|
return {"dataUrl": f"data:{_fs_mime_type(target)};base64,{encoded}"}
|
|
|
|
|
|
@router.get("/api/fs/download")
|
|
async def fs_download(path: str):
|
|
target, _st = _fs_regular_file(_fs_path(path))
|
|
if _is_sensitive_path(target):
|
|
raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed")
|
|
return FileResponse(
|
|
path=str(target),
|
|
media_type=_fs_mime_type(target),
|
|
filename=target.name,
|
|
content_disposition_type="attachment",
|
|
)
|
|
|
|
|
|
@router.get("/api/fs/git-root")
|
|
async def fs_git_root(path: str):
|
|
target = _fs_path(path)
|
|
try:
|
|
st = target.stat()
|
|
start = target if stat.S_ISDIR(st.st_mode) else target.parent
|
|
except OSError:
|
|
start = target
|
|
return {"root": _fs_find_git_root(start)}
|
|
|
|
|
|
@router.get("/api/fs/default-cwd")
|
|
async def fs_default_cwd():
|
|
cwd = _fs_default_cwd()
|
|
return {"cwd": cwd, "branch": _fs_git_branch(cwd)}
|