Implement Remote Client Shares Phase 2 browse support and unify remote agent HTTP + heartbeat
This commit is contained in:
+148
-544
@@ -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 <path>, mkdir <name>, touch <name>, select <glob>, 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)})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
Binary file not shown.
Reference in New Issue
Block a user