diff --git a/finder_commander/app/main.py b/finder_commander/app/main.py index 43c0a28..14bad2e 100644 --- a/finder_commander/app/main.py +++ b/finder_commander/app/main.py @@ -1,149 +1,109 @@ from __future__ import annotations -import fnmatch -import html import json import mimetypes import os -import secrets -import shutil -import stat -import time -from datetime import datetime +from dataclasses import dataclass +from datetime import datetime, timezone from functools import lru_cache from pathlib import Path -from typing import Literal, Optional -from fastapi import Body, FastAPI, File, Form, HTTPException, Request, UploadFile -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -from pydantic import BaseModel, Field +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse -APP_NAME = "Finder Commander" -HOME_ROOT = Path.home().resolve() -TRASH_DIR = HOME_ROOT / ".Trash" -MAX_TEXT_PREVIEW_BYTES = 2 * 1024 * 1024 -CSRF_TOKEN = secrets.token_urlsafe(32) - -app = FastAPI(title=APP_NAME) -app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static") -templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) +APP_NAME = "Finder Commander Remote Agent" +DEFAULT_PORT = 8765 -class PathsPayload(BaseModel): - paths: list[str] = Field(default_factory=list) - destination_dir: Optional[str] = None - - -class RenamePayload(BaseModel): - path: str - new_name: str - - -class DeletePayload(BaseModel): - paths: list[str] = Field(default_factory=list) - mode: Literal["trash", "permanent"] = "trash" - - -class CommandPayload(BaseModel): - command: str - cwd: str = "" - -PathsPayload.model_rebuild() -RenamePayload.model_rebuild() -DeletePayload.model_rebuild() -CommandPayload.model_rebuild() - -TEXT_SUFFIXES = { - ".md", - ".txt", - ".py", - ".js", - ".ts", - ".tsx", - ".jsx", - ".css", - ".html", - ".json", - ".yaml", - ".yml", - ".toml", - ".ini", - ".env", - ".log", - ".xml", - ".sh", - ".zsh", - ".bash", - ".c", - ".cpp", - ".h", - ".java", - ".go", - ".rs", - ".sql", - ".conf", - ".service", - ".container", - ".network", - ".pod", - ".kube", -} +@dataclass(frozen=True) +class AgentRuntimeConfig: + config_path: Path | None + agent_access_token: str + shares: dict[str, str] + display_name: str + endpoint: str + client_id: str + platform: str def _now_iso() -> str: - return datetime.utcnow().isoformat(timespec="seconds") + "Z" + return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z") + + +def _candidate_config_paths() -> list[Path]: + candidates: list[Path] = [] + env_path = os.getenv("FINDER_COMMANDER_REMOTE_AGENT_CONFIG", "").strip() + if env_path: + candidates.append(Path(env_path).expanduser().resolve(strict=False)) + base_dir = Path(__file__).resolve().parents[1] + candidates.append(base_dir / "remote_client_agent.launchd.json") + candidates.append(base_dir / "remote_client_agent.example.json") + return candidates + + +def _load_raw_config() -> tuple[Path | None, dict]: + for candidate in _candidate_config_paths(): + if candidate.is_file(): + try: + raw = json.loads(candidate.read_text(encoding="utf-8")) + except ValueError as exc: + raise RuntimeError(f"Invalid JSON in config file: {candidate}") from exc + if not isinstance(raw, dict): + raise RuntimeError(f"Config file must contain a JSON object: {candidate}") + return candidate.resolve(strict=False), raw + return None, {} @lru_cache(maxsize=1) -def remote_agent_config() -> dict: - config_path = os.getenv("FINDER_COMMANDER_REMOTE_AGENT_CONFIG", "").strip() - if not config_path: - return {} - try: - return json.loads(Path(config_path).read_text(encoding="utf-8")) - except (OSError, ValueError): - return {} +def get_runtime_config() -> AgentRuntimeConfig: + config_path, raw = _load_raw_config() + shares_raw = raw.get("shares", {}) + shares: dict[str, str] = {} + if isinstance(shares_raw, dict): + for key, value in shares_raw.items(): + normalized_key = str(key).strip() + normalized_value = str(value).strip() + if normalized_key and normalized_value: + shares[normalized_key] = normalized_value + + return AgentRuntimeConfig( + config_path=config_path, + agent_access_token=os.getenv("FINDER_COMMANDER_AGENT_ACCESS_TOKEN", "").strip() + or str(raw.get("agent_access_token", "")).strip(), + shares=shares, + display_name=str(raw.get("display_name", "")).strip(), + endpoint=str(raw.get("public_endpoint", raw.get("endpoint", ""))).strip(), + client_id=str(raw.get("client_id", "")).strip(), + platform=str(raw.get("platform", "macos")).strip() or "macos", + ) -def remote_agent_access_token() -> str: - return os.getenv("FINDER_COMMANDER_AGENT_ACCESS_TOKEN", "").strip() or str( - remote_agent_config().get("agent_access_token", "") - ).strip() - - -def remote_agent_shares() -> dict[str, str]: - raw = remote_agent_config().get("shares", {}) - if not isinstance(raw, dict): - return {} - normalized: dict[str, str] = {} - for key, value in raw.items(): - share_key = str(key).strip() - share_root = str(value).strip() - if share_key and share_root: - normalized[share_key] = share_root - return normalized - - -def require_remote_agent_auth(request: Request) -> None: - expected_token = remote_agent_access_token() - if not expected_token: +def require_agent_auth(request: Request) -> None: + config = get_runtime_config() + if not config.agent_access_token: return authorization = request.headers.get("authorization", "").strip() - if authorization != f"Bearer {expected_token}": - raise HTTPException(status_code=403, detail="Invalid agent token") + if authorization != f"Bearer {config.agent_access_token}": + raise HTTPException( + status_code=403, + detail={ + "message": "Invalid agent token", + "config_path": str(config.config_path) if config.config_path else None, + "client_id": config.client_id or None, + "display_name": config.display_name or None, + }, + ) -def share_root(share: str) -> Path: - shares = remote_agent_shares() +def get_share_root(share: str) -> Path: + config = get_runtime_config() normalized_share = (share or "").strip() - if normalized_share not in shares: + if normalized_share not in config.shares: raise HTTPException(status_code=404, detail="Share not found") - return Path(shares[normalized_share]).expanduser().resolve(strict=False) + return Path(config.shares[normalized_share]).expanduser().resolve(strict=False) -def ensure_within_share(root: Path, candidate: Path) -> Path: +def ensure_within_root(root: Path, candidate: Path) -> Path: try: candidate.relative_to(root) except ValueError as exc: @@ -152,28 +112,44 @@ def ensure_within_share(root: Path, candidate: Path) -> Path: def resolve_share_path(share: str, raw_path: str, *, must_exist: bool = True) -> Path: - root = share_root(share) - normalized_raw_path = (raw_path or "").strip().replace("\\", "/") - if normalized_raw_path.startswith("/") or any(part == ".." for part in normalized_raw_path.split("/")): + root = get_share_root(share) + normalized = (raw_path or "").strip().replace("\\", "/") + if normalized.startswith("/") or any(part == ".." for part in normalized.split("/")): raise HTTPException(status_code=400, detail="Invalid share-relative path") - candidate = (root / normalized_raw_path).resolve(strict=False) - candidate = ensure_within_share(root, candidate) + candidate = (root / normalized).resolve(strict=False) + candidate = ensure_within_root(root, candidate) if must_exist and not candidate.exists(): raise HTTPException(status_code=404, detail="Path not found") return candidate -def remote_entry_payload(path: Path) -> dict: - st = path.lstat() +def directory_entry_payload(path: Path) -> dict: + stat_result = path.lstat() return { "name": path.name, "kind": "directory" if path.is_dir() else "file", - "size": st.st_size, - "modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"), + "size": stat_result.st_size, + "modified": datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z"), } -def sorted_share_entries(path: Path, show_hidden: bool = False) -> list[dict]: +def info_payload(path: Path, *, share: str, raw_path: str) -> dict: + stat_result = path.lstat() + kind = "directory" if path.is_dir() else "file" + mime, _ = mimetypes.guess_type(path.name) + return { + "share": share, + "path": raw_path.strip().replace("\\", "/").strip("/"), + "name": path.name, + "kind": kind, + "size": None if path.is_dir() else stat_result.st_size, + "modified": datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z"), + "content_type": mime or "application/octet-stream", + "config_path": str(get_runtime_config().config_path) if get_runtime_config().config_path else None, + } + + +def list_directory(path: Path, *, show_hidden: bool) -> list[dict]: try: children = list(path.iterdir()) except PermissionError as exc: @@ -183,444 +159,72 @@ def sorted_share_entries(path: Path, show_hidden: bool = False) -> list[dict]: if not show_hidden and child.name.startswith("."): continue filtered.append(child) - filtered.sort(key=lambda p: (not p.is_dir(), p.name.lower())) - return [remote_entry_payload(child) for child in filtered] + filtered.sort(key=lambda item: (not item.is_dir(), item.name.lower())) + return [directory_entry_payload(child) for child in filtered] - -def rel_from_home(path: Path) -> str: - return "" if path == HOME_ROOT else str(path.relative_to(HOME_ROOT)) +app = FastAPI(title=APP_NAME) - -def ensure_within_home(candidate: Path) -> Path: - try: - candidate.relative_to(HOME_ROOT) - except ValueError as exc: - raise HTTPException(status_code=403, detail="Path escapes home directory") from exc - return candidate - - - -def sanitize_name(name: str) -> str: - name = (name or "").strip() - if not name or name in {".", ".."} or "/" in name: - raise HTTPException(status_code=400, detail="Invalid name") - return name - - - -def resolve_user_path(raw_path: Optional[str], *, must_exist: bool = True) -> Path: - raw_path = (raw_path or "").strip() - candidate = (HOME_ROOT / raw_path).resolve(strict=False) - candidate = ensure_within_home(candidate) - if must_exist and not candidate.exists(): - raise HTTPException(status_code=404, detail="Path not found") - return candidate - - - -def check_origin(request: Request) -> None: - origin = request.headers.get("origin") - if not origin: - return - expected = str(request.base_url).rstrip("/") - if origin.rstrip("/") != expected: - raise HTTPException(status_code=403, detail="Origin not allowed") - - - -def check_csrf(request: Request) -> None: - token = request.headers.get("x-csrf-token") - if token != CSRF_TOKEN: - raise HTTPException(status_code=403, detail="Invalid CSRF token") - - - -def perms_string(mode: int) -> str: - return stat.filemode(mode) - - - -def can_preview_text(path: Path) -> bool: - if path.is_dir(): - return False - if path.stat().st_size > MAX_TEXT_PREVIEW_BYTES: - return False - mime, _ = mimetypes.guess_type(path.name) - if mime and ( - mime.startswith("text/") - or mime in {"application/json", "application/xml", "application/javascript"} - ): - return True - return path.suffix.lower() in TEXT_SUFFIXES - - - -def entry_payload(path: Path) -> dict: - st = path.lstat() - kind = "directory" if path.is_dir() else "file" - mime, _ = mimetypes.guess_type(path.name) +@app.get("/") +def root() -> dict: + config = get_runtime_config() return { - "name": path.name, - "rel_path": rel_from_home(path), - "parent_rel_path": rel_from_home(path.parent), - "kind": kind, - "is_symlink": path.is_symlink(), - "size": st.st_size, - "modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"), - "mime": mime or "application/octet-stream", - "perms": perms_string(st.st_mode), - "can_preview_text": can_preview_text(path) if path.is_file() else False, + "ok": True, + "app": APP_NAME, + "time": _now_iso(), + "client_id": config.client_id or None, + "display_name": config.display_name or None, + "config_path": str(config.config_path) if config.config_path else None, + "shares": sorted(config.shares.keys()), + "auth_enabled": bool(config.agent_access_token), } - -def sorted_entries(path: Path, show_hidden: bool = False) -> list[dict]: - try: - children = list(path.iterdir()) - except PermissionError as exc: - raise HTTPException(status_code=403, detail="Permission denied by operating system") from exc - filtered = [] - for child in children: - if not show_hidden and child.name.startswith('.'): - continue - filtered.append(child) - filtered.sort(key=lambda p: (not p.is_dir(), p.name.lower())) - return [entry_payload(child) for child in filtered] - - - -def move_to_trash(path: Path) -> Path: - TRASH_DIR.mkdir(parents=True, exist_ok=True) - target = TRASH_DIR / path.name - if target.exists(): - target = TRASH_DIR / f"{target.stem}-{int(time.time())}{target.suffix}" - shutil.move(str(path), str(target)) - return target - - - -def copy_entry(source: Path, destination_dir: Path) -> Path: - destination = destination_dir / source.name - if destination.exists(): - raise HTTPException(status_code=409, detail=f"Destination already exists: {destination.name}") - if source.is_dir(): - shutil.copytree(source, destination, symlinks=True) - else: - shutil.copy2(source, destination, follow_symlinks=False) - return destination - - - -def move_entry(source: Path, destination_dir: Path) -> Path: - destination = destination_dir / source.name - if destination.exists(): - raise HTTPException(status_code=409, detail=f"Destination already exists: {destination.name}") - shutil.move(str(source), str(destination)) - return destination - - - -def select_paths_or_current(paths: list[str], cwd: str) -> list[Path]: - result = [resolve_user_path(p) for p in paths] - if not result: - raise HTTPException(status_code=400, detail="No paths selected") - return result - - - -def resolve_from_cwd(cwd_path: Path, raw: str, *, must_exist: bool = True) -> Path: - raw = (raw or "").strip() - candidate = (cwd_path / raw).resolve(strict=False) - candidate = ensure_within_home(candidate) - if must_exist and not candidate.exists(): - raise HTTPException(status_code=404, detail="Path not found") - return candidate - - -def run_command(command: str, cwd: str) -> dict: - command = (command or "").strip() - if not command: - raise HTTPException(status_code=400, detail="Empty command") - cwd_path = resolve_user_path(cwd) - if not cwd_path.is_dir(): - raise HTTPException(status_code=400, detail="CWD is not a directory") - - parts = command.split() - verb = parts[0].lower() - args = parts[1:] - - if verb == "cd": - raw_target = " ".join(args) if args else "" - target = resolve_user_path(raw_target) if raw_target.startswith("/") else resolve_from_cwd(cwd_path, raw_target or ".") - if not target.is_dir(): - raise HTTPException(status_code=400, detail="Target is not a directory") - return {"ok": True, "action": "cd", "cwd": rel_from_home(target), "message": str(target)} - - if verb == "mkdir": - name = sanitize_name(" ".join(args)) - target = resolve_from_cwd(cwd_path, name, must_exist=False) - target.mkdir(exist_ok=False) - return {"ok": True, "action": "mkdir", "cwd": rel_from_home(cwd_path), "message": f"Created {name}"} - - if verb == "touch": - name = sanitize_name(" ".join(args)) - target = resolve_from_cwd(cwd_path, name, must_exist=False) - target.touch(exist_ok=False) - return {"ok": True, "action": "touch", "cwd": rel_from_home(cwd_path), "message": f"Created {name}"} - - if verb == "select": - pattern = " ".join(args).strip() or "*" - entries = sorted_entries(cwd_path, show_hidden=True) - matches = [e["rel_path"] for e in entries if fnmatch.fnmatch(e["name"], pattern)] - return { - "ok": True, - "action": "select", - "cwd": rel_from_home(cwd_path), - "message": f"Matched {len(matches)} item(s)", - "matches": matches, - } - - if verb == "help": - return { - "ok": True, - "action": "help", - "cwd": rel_from_home(cwd_path), - "message": "Commands: cd , mkdir , touch , select , help", - } - - raise HTTPException(status_code=400, detail="Unsupported command") - - -@app.middleware("http") -async def harden_headers(request: Request, call_next): - response = await call_next(request) - response.headers["X-Frame-Options"] = "DENY" - response.headers["Content-Security-Policy"] = ( - "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; " - "connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" - ) - response.headers["Referrer-Policy"] = "no-referrer" - response.headers["X-Content-Type-Options"] = "nosniff" - return response - - @app.get("/health") def health(request: Request) -> dict: - require_remote_agent_auth(request) - return {"ok": True, "app": APP_NAME, "time": _now_iso(), "home": str(HOME_ROOT)} - - -@app.get("/", response_class=HTMLResponse) -def index(request: Request): - return templates.TemplateResponse( - request, - "index.html", - { - "app_name": APP_NAME, - "home_root": str(HOME_ROOT), - "csrf_token": CSRF_TOKEN, - }, - ) + require_agent_auth(request) + config = get_runtime_config() + return { + "ok": True, + "app": APP_NAME, + "time": _now_iso(), + "client_id": config.client_id or None, + "display_name": config.display_name or None, + "platform": config.platform, + "endpoint": config.endpoint or None, + "shares": sorted(config.shares.keys()), + "config_path": str(config.config_path) if config.config_path else None, + "port_hint": DEFAULT_PORT, + "auth_enabled": bool(config.agent_access_token), + } @app.get("/api/list") -def api_list(request: Request, path: str = "", share: str = "", show_hidden: bool = False) -> dict: - if share.strip(): - require_remote_agent_auth(request) - target = resolve_share_path(share, path) - if not target.is_dir(): - raise HTTPException(status_code=400, detail="Path is not a directory") - return { - "share": share.strip(), - "path": path.strip().replace("\\", "/").strip("/"), - "entries": sorted_share_entries(target, show_hidden=show_hidden), - } - target = resolve_user_path(path) +def api_list(request: Request, share: str, path: str = "", show_hidden: bool = False) -> dict: + require_agent_auth(request) + target = resolve_share_path(share, path) if not target.is_dir(): raise HTTPException(status_code=400, detail="Path is not a directory") return { - "cwd": rel_from_home(target), - "absolute": str(target), - "parent": "" if target == HOME_ROOT else rel_from_home(target.parent), - "entries": sorted_entries(target, show_hidden=show_hidden), + "share": share.strip(), + "path": path.strip().replace("\\", "/").strip("/"), + "entries": list_directory(target, show_hidden=show_hidden), } -@app.get("/api/read") -def api_read(path: str) -> dict: - target = resolve_user_path(path) - if target.is_dir(): - raise HTTPException(status_code=400, detail="Cannot read a directory as text") - if not can_preview_text(target): - raise HTTPException(status_code=415, detail="File is not previewable as text") - try: - content = target.read_text(encoding="utf-8") - encoding = "utf-8" - except UnicodeDecodeError: - content = target.read_text(encoding="utf-8", errors="replace") - encoding = "utf-8 (lossy)" - return { - "path": rel_from_home(target), - "name": target.name, - "encoding": encoding, - "content": content, - "size": target.stat().st_size, - } - - -@app.get("/api/meta") -def api_meta(path: str) -> dict: - target = resolve_user_path(path) - payload = entry_payload(target) - payload["absolute"] = str(target) - return payload - - -@app.get("/api/download") -def api_download(path: str): - target = resolve_user_path(path) - if target.is_dir(): - raise HTTPException(status_code=400, detail="Cannot download a directory") - return FileResponse(path=target, filename=target.name) - - -@app.get("/api/preview") -def api_preview(path: str): - target = resolve_user_path(path) - if target.is_dir(): - raise HTTPException(status_code=400, detail="Cannot preview a directory") - mime, _ = mimetypes.guess_type(target.name) - if not mime or not mime.startswith("image/"): - raise HTTPException(status_code=415, detail="Preview only supports images") - return FileResponse(path=target, media_type=mime) - - -@app.put("/api/write") -async def api_write(request: Request, path: str = Form(...), content: str = Form(...)) -> dict: - check_origin(request) - check_csrf(request) - target = resolve_user_path(path, must_exist=False) - ensure_within_home(target.parent.resolve(strict=False)) - if target.exists() and target.is_dir(): - raise HTTPException(status_code=400, detail="Cannot overwrite a directory") - target.parent.mkdir(parents=True, exist_ok=True) - tmp = target.with_name(target.name + ".tmp-write") - tmp.write_text(content, encoding="utf-8") - os.replace(tmp, target) - return {"ok": True, "path": rel_from_home(target)} - - -@app.post("/api/mkdir") -async def api_mkdir(request: Request, path: str = Form(...), name: str = Form(...)) -> dict: - check_origin(request) - check_csrf(request) - base = resolve_user_path(path) - if not base.is_dir(): - raise HTTPException(status_code=400, detail="Base path is not a directory") - child = resolve_user_path(str(Path(rel_from_home(base)) / sanitize_name(name)), must_exist=False) - child.mkdir(parents=False, exist_ok=False) - return {"ok": True, "path": rel_from_home(child)} - - -@app.post("/api/upload") -async def api_upload(request: Request, path: str = Form(...), files: list[UploadFile] = File(...)) -> dict: - check_origin(request) - check_csrf(request) - base = resolve_user_path(path) - if not base.is_dir(): - raise HTTPException(status_code=400, detail="Upload target is not a directory") - saved: list[str] = [] - for upload in files: - filename = Path(upload.filename or "").name - if not filename: - continue - destination = resolve_user_path(str(Path(rel_from_home(base)) / filename), must_exist=False) - with destination.open("wb") as f: - while chunk := await upload.read(1024 * 1024): - f.write(chunk) - saved.append(rel_from_home(destination)) - return {"ok": True, "saved": saved} - - -@app.post("/api/rename") -async def api_rename(request: Request, payload: RenamePayload) -> dict: - check_origin(request) - check_csrf(request) - source = resolve_user_path(payload.path) - destination = resolve_user_path(str(Path(rel_from_home(source.parent)) / sanitize_name(payload.new_name)), must_exist=False) - if destination.exists(): - raise HTTPException(status_code=409, detail="Destination already exists") - os.replace(source, destination) - return {"ok": True, "old_path": rel_from_home(source), "new_path": rel_from_home(destination)} - - -@app.post("/api/copy") -async def api_copy(request: Request, payload: PathsPayload) -> dict: - check_origin(request) - check_csrf(request) - if payload.destination_dir is None: - raise HTTPException(status_code=400, detail="Missing destination_dir") - destination_dir = resolve_user_path(payload.destination_dir) - if not destination_dir.is_dir(): - raise HTTPException(status_code=400, detail="Destination is not a directory") - results = [] - for source in select_paths_or_current(payload.paths, payload.destination_dir): - copied = copy_entry(source, destination_dir) - results.append(rel_from_home(copied)) - return {"ok": True, "copied": results} - - -@app.post("/api/move") -async def api_move(request: Request, payload: PathsPayload) -> dict: - check_origin(request) - check_csrf(request) - if payload.destination_dir is None: - raise HTTPException(status_code=400, detail="Missing destination_dir") - destination_dir = resolve_user_path(payload.destination_dir) - if not destination_dir.is_dir(): - raise HTTPException(status_code=400, detail="Destination is not a directory") - results = [] - for source in select_paths_or_current(payload.paths, payload.destination_dir): - moved = move_entry(source, destination_dir) - results.append(rel_from_home(moved)) - return {"ok": True, "moved": results} - - -@app.post("/api/delete") -async def api_delete(request: Request, payload: DeletePayload) -> dict: - check_origin(request) - check_csrf(request) - paths = select_paths_or_current(payload.paths, "") - deleted = [] - for target in paths: - if target == HOME_ROOT: - raise HTTPException(status_code=400, detail="Refusing to delete home root") - if payload.mode == "trash": - moved = move_to_trash(target) - deleted.append(str(moved)) - else: - if target.is_dir(): - shutil.rmtree(target) - else: - target.unlink() - deleted.append(rel_from_home(target)) - return {"ok": True, "mode": payload.mode, "deleted": deleted} - - -@app.post("/api/command") -async def api_command(request: Request, payload: CommandPayload) -> dict: - check_origin(request) - check_csrf(request) - return run_command(payload.command, payload.cwd) +@app.get("/api/info") +def api_info(request: Request, share: str, path: str = "") -> dict: + require_agent_auth(request) + target = resolve_share_path(share, path) + return info_payload(target, share=share.strip(), raw_path=path) @app.exception_handler(HTTPException) -async def http_exception_handler(_: Request, exc: HTTPException): +async def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse: return JSONResponse(status_code=exc.status_code, content={"ok": False, "detail": exc.detail}) @app.exception_handler(Exception) -async def unhandled_exception_handler(_: Request, exc: Exception): - return JSONResponse(status_code=500, content={"ok": False, "detail": html.escape(str(exc))}) +async def unhandled_exception_handler(_: Request, exc: Exception) -> JSONResponse: + return JSONResponse(status_code=500, content={"ok": False, "detail": str(exc)}) diff --git a/finder_commander/remote_client_agent.py b/finder_commander/remote_client_agent.py index a21a621..9cce62b 100644 --- a/finder_commander/remote_client_agent.py +++ b/finder_commander/remote_client_agent.py @@ -3,12 +3,15 @@ from __future__ import annotations import argparse import json import sys -import time +import threading import uuid from dataclasses import dataclass from pathlib import Path from typing import Any from urllib import error, request +from urllib.parse import urlparse + +import uvicorn AGENT_VERSION = "1.1.0-phase1" @@ -106,7 +109,7 @@ def post_json(url: str, token: str, payload: dict[str, Any]) -> dict[str, Any]: return json.loads(resp.read().decode("utf-8")) -def run(config: AgentConfig) -> None: +def run_heartbeat_loop(config: AgentConfig, stop_event: threading.Event) -> None: require_non_empty(config.webmanager_base_url, "webmanager_base_url") require_non_empty(config.registration_token, "registration_token") require_non_empty(config.agent_access_token, "agent_access_token") @@ -117,9 +120,10 @@ def run(config: AgentConfig) -> None: heartbeat_url = f"{config.normalized_base_url}/api/clients/heartbeat" print(f"Starting remote client agent for {config.display_name} ({config.client_id})", flush=True) - print("agent_access_token is configured for future authenticated agent endpoints", flush=True) + print(f"Using config: {config.config_path}", flush=True) + print("agent_access_token is configured for authenticated agent endpoints", flush=True) - while True: + while not stop_event.is_set(): try: post_json(register_url, config.registration_token, build_register_payload(config)) print("register ok", flush=True) @@ -128,9 +132,10 @@ def run(config: AgentConfig) -> None: print(f"register failed: HTTP {exc.code}", file=sys.stderr, flush=True) except error.URLError as exc: print(f"register failed: {exc.reason}", file=sys.stderr, flush=True) - time.sleep(config.heartbeat_interval_seconds) + if stop_event.wait(config.heartbeat_interval_seconds): + return - while True: + while not stop_event.is_set(): try: post_json(heartbeat_url, config.registration_token, build_heartbeat_payload(config)) print("heartbeat ok", flush=True) @@ -138,7 +143,52 @@ def run(config: AgentConfig) -> None: print(f"heartbeat failed: HTTP {exc.code}", file=sys.stderr, flush=True) except error.URLError as exc: print(f"heartbeat failed: {exc.reason}", file=sys.stderr, flush=True) - time.sleep(config.heartbeat_interval_seconds) + if stop_event.wait(config.heartbeat_interval_seconds): + return + + +def resolve_bind_host(config: AgentConfig, requested_host: str | None) -> str: + normalized = (requested_host or "").strip() + if normalized: + return normalized + return "0.0.0.0" + + +def resolve_bind_port(config: AgentConfig, requested_port: int | None) -> int: + if requested_port and requested_port > 0: + return requested_port + parsed = urlparse(config.endpoint) + if parsed.port: + return parsed.port + if parsed.scheme == "https": + return 443 + if parsed.scheme == "http": + return 80 + return 8765 + + +def run(config: AgentConfig, requested_host: str | None, requested_port: int | None) -> None: + stop_event = threading.Event() + heartbeat_thread = threading.Thread( + target=run_heartbeat_loop, + args=(config, stop_event), + daemon=True, + name="remote-client-heartbeat", + ) + heartbeat_thread.start() + + bind_host = resolve_bind_host(config, requested_host) + bind_port = resolve_bind_port(config, requested_port) + print(f"Starting HTTP agent on {bind_host}:{bind_port}", flush=True) + print(f"Advertised endpoint: {config.endpoint}", flush=True) + try: + import os + + os.environ["FINDER_COMMANDER_REMOTE_AGENT_CONFIG"] = str(config.config_path) + uvicorn.run("app.main:app", host=bind_host, port=bind_port) + finally: + stop_event.set() + heartbeat_thread.join(timeout=2) def parse_args() -> argparse.Namespace: @@ -148,6 +198,8 @@ def parse_args() -> argparse.Namespace: default=str(Path(__file__).resolve().with_name("remote_client_agent.example.json")), help="Path to remote client agent config JSON", ) + parser.add_argument("--host", default="", help="Bind host for the HTTP agent, defaults to 0.0.0.0") + parser.add_argument("--port", type=int, default=0, help="Bind port for the HTTP agent, defaults to endpoint port") return parser.parse_args() @@ -155,7 +207,7 @@ def main() -> int: args = parse_args() try: config = load_config(Path(args.config).resolve()) - run(config) + run(config, requested_host=args.host, requested_port=args.port) except KeyboardInterrupt: return 130 except Exception as exc: diff --git a/finder_commander/run_agent_http.py b/finder_commander/run_agent_http.py new file mode 100644 index 0000000..3df769a --- /dev/null +++ b/finder_commander/run_agent_http.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +import uvicorn + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run Finder Commander remote agent HTTP API") + parser.add_argument( + "--config", + required=True, + help="Path to remote agent config JSON", + ) + parser.add_argument("--host", default="0.0.0.0", help="Listen host") + parser.add_argument("--port", type=int, default=8765, help="Listen port") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + config_path = Path(args.config).expanduser().resolve(strict=False) + if not config_path.is_file(): + raise SystemExit(f"Config file not found: {config_path}") + + os.environ["FINDER_COMMANDER_REMOTE_AGENT_CONFIG"] = str(config_path) + print(f"Using config: {config_path}", flush=True) + uvicorn.run("app.main:app", host=args.host, port=args.port) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 8533834..bed6a8c 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ