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
|
from __future__ import annotations
|
||||||
|
|
||||||
import fnmatch
|
|
||||||
import html
|
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import secrets
|
from dataclasses import dataclass
|
||||||
import shutil
|
from datetime import datetime, timezone
|
||||||
import stat
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal, Optional
|
|
||||||
|
|
||||||
from fastapi import Body, FastAPI, File, Form, HTTPException, Request, UploadFile
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
APP_NAME = "Finder Commander"
|
APP_NAME = "Finder Commander Remote Agent"
|
||||||
HOME_ROOT = Path.home().resolve()
|
DEFAULT_PORT = 8765
|
||||||
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"))
|
|
||||||
|
|
||||||
|
|
||||||
class PathsPayload(BaseModel):
|
@dataclass(frozen=True)
|
||||||
paths: list[str] = Field(default_factory=list)
|
class AgentRuntimeConfig:
|
||||||
destination_dir: Optional[str] = None
|
config_path: Path | None
|
||||||
|
agent_access_token: str
|
||||||
|
shares: dict[str, str]
|
||||||
class RenamePayload(BaseModel):
|
display_name: str
|
||||||
path: str
|
endpoint: str
|
||||||
new_name: str
|
client_id: str
|
||||||
|
platform: 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",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> 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)
|
@lru_cache(maxsize=1)
|
||||||
def remote_agent_config() -> dict:
|
def get_runtime_config() -> AgentRuntimeConfig:
|
||||||
config_path = os.getenv("FINDER_COMMANDER_REMOTE_AGENT_CONFIG", "").strip()
|
config_path, raw = _load_raw_config()
|
||||||
if not config_path:
|
shares_raw = raw.get("shares", {})
|
||||||
return {}
|
shares: dict[str, str] = {}
|
||||||
try:
|
if isinstance(shares_raw, dict):
|
||||||
return json.loads(Path(config_path).read_text(encoding="utf-8"))
|
for key, value in shares_raw.items():
|
||||||
except (OSError, ValueError):
|
normalized_key = str(key).strip()
|
||||||
return {}
|
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:
|
def require_agent_auth(request: Request) -> None:
|
||||||
return os.getenv("FINDER_COMMANDER_AGENT_ACCESS_TOKEN", "").strip() or str(
|
config = get_runtime_config()
|
||||||
remote_agent_config().get("agent_access_token", "")
|
if not config.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:
|
|
||||||
return
|
return
|
||||||
authorization = request.headers.get("authorization", "").strip()
|
authorization = request.headers.get("authorization", "").strip()
|
||||||
if authorization != f"Bearer {expected_token}":
|
if authorization != f"Bearer {config.agent_access_token}":
|
||||||
raise HTTPException(status_code=403, detail="Invalid agent 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:
|
def get_share_root(share: str) -> Path:
|
||||||
shares = remote_agent_shares()
|
config = get_runtime_config()
|
||||||
normalized_share = (share or "").strip()
|
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")
|
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:
|
try:
|
||||||
candidate.relative_to(root)
|
candidate.relative_to(root)
|
||||||
except ValueError as exc:
|
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:
|
def resolve_share_path(share: str, raw_path: str, *, must_exist: bool = True) -> Path:
|
||||||
root = share_root(share)
|
root = get_share_root(share)
|
||||||
normalized_raw_path = (raw_path or "").strip().replace("\\", "/")
|
normalized = (raw_path or "").strip().replace("\\", "/")
|
||||||
if normalized_raw_path.startswith("/") or any(part == ".." for part in normalized_raw_path.split("/")):
|
if normalized.startswith("/") or any(part == ".." for part in normalized.split("/")):
|
||||||
raise HTTPException(status_code=400, detail="Invalid share-relative path")
|
raise HTTPException(status_code=400, detail="Invalid share-relative path")
|
||||||
candidate = (root / normalized_raw_path).resolve(strict=False)
|
candidate = (root / normalized).resolve(strict=False)
|
||||||
candidate = ensure_within_share(root, candidate)
|
candidate = ensure_within_root(root, candidate)
|
||||||
if must_exist and not candidate.exists():
|
if must_exist and not candidate.exists():
|
||||||
raise HTTPException(status_code=404, detail="Path not found")
|
raise HTTPException(status_code=404, detail="Path not found")
|
||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
def remote_entry_payload(path: Path) -> dict:
|
def directory_entry_payload(path: Path) -> dict:
|
||||||
st = path.lstat()
|
stat_result = path.lstat()
|
||||||
return {
|
return {
|
||||||
"name": path.name,
|
"name": path.name,
|
||||||
"kind": "directory" if path.is_dir() else "file",
|
"kind": "directory" if path.is_dir() else "file",
|
||||||
"size": st.st_size,
|
"size": stat_result.st_size,
|
||||||
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"),
|
"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:
|
try:
|
||||||
children = list(path.iterdir())
|
children = list(path.iterdir())
|
||||||
except PermissionError as exc:
|
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("."):
|
if not show_hidden and child.name.startswith("."):
|
||||||
continue
|
continue
|
||||||
filtered.append(child)
|
filtered.append(child)
|
||||||
filtered.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
|
filtered.sort(key=lambda item: (not item.is_dir(), item.name.lower()))
|
||||||
return [remote_entry_payload(child) for child in filtered]
|
return [directory_entry_payload(child) for child in filtered]
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title=APP_NAME)
|
||||||
def rel_from_home(path: Path) -> str:
|
|
||||||
return "" if path == HOME_ROOT else str(path.relative_to(HOME_ROOT))
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
def ensure_within_home(candidate: Path) -> Path:
|
def root() -> dict:
|
||||||
try:
|
config = get_runtime_config()
|
||||||
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)
|
|
||||||
return {
|
return {
|
||||||
"name": path.name,
|
"ok": True,
|
||||||
"rel_path": rel_from_home(path),
|
"app": APP_NAME,
|
||||||
"parent_rel_path": rel_from_home(path.parent),
|
"time": _now_iso(),
|
||||||
"kind": kind,
|
"client_id": config.client_id or None,
|
||||||
"is_symlink": path.is_symlink(),
|
"display_name": config.display_name or None,
|
||||||
"size": st.st_size,
|
"config_path": str(config.config_path) if config.config_path else None,
|
||||||
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"),
|
"shares": sorted(config.shares.keys()),
|
||||||
"mime": mime or "application/octet-stream",
|
"auth_enabled": bool(config.agent_access_token),
|
||||||
"perms": perms_string(st.st_mode),
|
|
||||||
"can_preview_text": can_preview_text(path) if path.is_file() else False,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
@app.get("/health")
|
||||||
def health(request: Request) -> dict:
|
def health(request: Request) -> dict:
|
||||||
require_remote_agent_auth(request)
|
require_agent_auth(request)
|
||||||
return {"ok": True, "app": APP_NAME, "time": _now_iso(), "home": str(HOME_ROOT)}
|
config = get_runtime_config()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
@app.get("/", response_class=HTMLResponse)
|
"app": APP_NAME,
|
||||||
def index(request: Request):
|
"time": _now_iso(),
|
||||||
return templates.TemplateResponse(
|
"client_id": config.client_id or None,
|
||||||
request,
|
"display_name": config.display_name or None,
|
||||||
"index.html",
|
"platform": config.platform,
|
||||||
{
|
"endpoint": config.endpoint or None,
|
||||||
"app_name": APP_NAME,
|
"shares": sorted(config.shares.keys()),
|
||||||
"home_root": str(HOME_ROOT),
|
"config_path": str(config.config_path) if config.config_path else None,
|
||||||
"csrf_token": CSRF_TOKEN,
|
"port_hint": DEFAULT_PORT,
|
||||||
},
|
"auth_enabled": bool(config.agent_access_token),
|
||||||
)
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/list")
|
@app.get("/api/list")
|
||||||
def api_list(request: Request, path: str = "", share: str = "", show_hidden: bool = False) -> dict:
|
def api_list(request: Request, share: str, path: str = "", show_hidden: bool = False) -> dict:
|
||||||
if share.strip():
|
require_agent_auth(request)
|
||||||
require_remote_agent_auth(request)
|
target = resolve_share_path(share, path)
|
||||||
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)
|
|
||||||
if not target.is_dir():
|
if not target.is_dir():
|
||||||
raise HTTPException(status_code=400, detail="Path is not a directory")
|
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||||
return {
|
return {
|
||||||
"cwd": rel_from_home(target),
|
"share": share.strip(),
|
||||||
"absolute": str(target),
|
"path": path.strip().replace("\\", "/").strip("/"),
|
||||||
"parent": "" if target == HOME_ROOT else rel_from_home(target.parent),
|
"entries": list_directory(target, show_hidden=show_hidden),
|
||||||
"entries": sorted_entries(target, show_hidden=show_hidden),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/read")
|
@app.get("/api/info")
|
||||||
def api_read(path: str) -> dict:
|
def api_info(request: Request, share: str, path: str = "") -> dict:
|
||||||
target = resolve_user_path(path)
|
require_agent_auth(request)
|
||||||
if target.is_dir():
|
target = resolve_share_path(share, path)
|
||||||
raise HTTPException(status_code=400, detail="Cannot read a directory as text")
|
return info_payload(target, share=share.strip(), raw_path=path)
|
||||||
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.exception_handler(HTTPException)
|
@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})
|
return JSONResponse(status_code=exc.status_code, content={"ok": False, "detail": exc.detail})
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def unhandled_exception_handler(_: Request, exc: Exception):
|
async def unhandled_exception_handler(_: Request, exc: Exception) -> JSONResponse:
|
||||||
return JSONResponse(status_code=500, content={"ok": False, "detail": html.escape(str(exc))})
|
return JSONResponse(status_code=500, content={"ok": False, "detail": str(exc)})
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import time
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib import error, request
|
from urllib import error, request
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
|
||||||
AGENT_VERSION = "1.1.0-phase1"
|
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"))
|
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.webmanager_base_url, "webmanager_base_url")
|
||||||
require_non_empty(config.registration_token, "registration_token")
|
require_non_empty(config.registration_token, "registration_token")
|
||||||
require_non_empty(config.agent_access_token, "agent_access_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"
|
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(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:
|
try:
|
||||||
post_json(register_url, config.registration_token, build_register_payload(config))
|
post_json(register_url, config.registration_token, build_register_payload(config))
|
||||||
print("register ok", flush=True)
|
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)
|
print(f"register failed: HTTP {exc.code}", file=sys.stderr, flush=True)
|
||||||
except error.URLError as exc:
|
except error.URLError as exc:
|
||||||
print(f"register failed: {exc.reason}", file=sys.stderr, flush=True)
|
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:
|
try:
|
||||||
post_json(heartbeat_url, config.registration_token, build_heartbeat_payload(config))
|
post_json(heartbeat_url, config.registration_token, build_heartbeat_payload(config))
|
||||||
print("heartbeat ok", flush=True)
|
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)
|
print(f"heartbeat failed: HTTP {exc.code}", file=sys.stderr, flush=True)
|
||||||
except error.URLError as exc:
|
except error.URLError as exc:
|
||||||
print(f"heartbeat failed: {exc.reason}", file=sys.stderr, flush=True)
|
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:
|
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")),
|
default=str(Path(__file__).resolve().with_name("remote_client_agent.example.json")),
|
||||||
help="Path to remote client agent config 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()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -155,7 +207,7 @@ def main() -> int:
|
|||||||
args = parse_args()
|
args = parse_args()
|
||||||
try:
|
try:
|
||||||
config = load_config(Path(args.config).resolve())
|
config = load_config(Path(args.config).resolve())
|
||||||
run(config)
|
run(config, requested_host=args.host, requested_port=args.port)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
return 130
|
return 130
|
||||||
except Exception as exc:
|
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