from __future__ import annotations import argparse import json import sys 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" @dataclass class AgentConfig: config_path: Path webmanager_base_url: str registration_token: str agent_access_token: str display_name: str endpoint: str shares: dict[str, str] heartbeat_interval_seconds: int client_id: str platform: str = "macos" @property def normalized_base_url(self) -> str: return self.webmanager_base_url.rstrip("/") def load_config(config_path: Path) -> AgentConfig: raw = json.loads(config_path.read_text(encoding="utf-8")) client_id = str(raw.get("client_id", "")).strip() if not client_id: client_id = str(uuid.uuid4()) raw["client_id"] = client_id config_path.write_text(json.dumps(raw, indent=2, sort_keys=True) + "\n", encoding="utf-8") shares_raw = raw.get("shares") or {} 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 if not shares: raise ValueError("config requires at least one share") return AgentConfig( config_path=config_path, webmanager_base_url=str(raw.get("webmanager_base_url", "")).strip(), registration_token=str(raw.get("registration_token", "")).strip(), agent_access_token=str(raw.get("agent_access_token", "")).strip(), display_name=str(raw.get("display_name", "")).strip(), endpoint=str(raw.get("public_endpoint", raw.get("endpoint", ""))).strip(), shares=shares, heartbeat_interval_seconds=max(5, int(raw.get("heartbeat_interval_seconds", 20))), client_id=client_id, platform=str(raw.get("platform", "macos")).strip() or "macos", ) def require_non_empty(value: str, field: str) -> str: normalized = value.strip() if not normalized: raise ValueError(f"config field '{field}' is required") return normalized def build_register_payload(config: AgentConfig) -> dict[str, Any]: return { "client_id": config.client_id, "display_name": config.display_name, "platform": config.platform, "agent_version": AGENT_VERSION, "endpoint": config.endpoint, "shares": [{"key": key, "label": key.capitalize()} for key in sorted(config.shares.keys())], } def build_heartbeat_payload(config: AgentConfig) -> dict[str, Any]: return { "client_id": config.client_id, "agent_version": AGENT_VERSION, } def post_json(url: str, token: str, payload: dict[str, Any]) -> dict[str, Any]: data = json.dumps(payload).encode("utf-8") req = request.Request( url, method="POST", data=data, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {token}", }, ) with request.urlopen(req, timeout=10) as resp: return json.loads(resp.read().decode("utf-8")) 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") require_non_empty(config.display_name, "display_name") require_non_empty(config.endpoint, "public_endpoint") register_url = f"{config.normalized_base_url}/api/clients/register" 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"Using config: {config.config_path}", flush=True) print("agent_access_token is configured for authenticated agent endpoints", flush=True) while not stop_event.is_set(): try: post_json(register_url, config.registration_token, build_register_payload(config)) print("register ok", flush=True) break except error.HTTPError as exc: 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) if stop_event.wait(config.heartbeat_interval_seconds): return while not stop_event.is_set(): try: post_json(heartbeat_url, config.registration_token, build_heartbeat_payload(config)) print("heartbeat ok", flush=True) except error.HTTPError as exc: 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) 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: parser = argparse.ArgumentParser(description="Remote client agent Phase 1 for WebManager MVP") parser.add_argument( "--config", 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() def main() -> int: args = parse_args() try: config = load_config(Path(args.config).resolve()) run(config, requested_host=args.host, requested_port=args.port) except KeyboardInterrupt: return 130 except Exception as exc: print(str(exc), file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())